diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml
new file mode 100644
index 00000000000..6d143a8a22f
--- /dev/null
+++ b/.github/workflows/storybook.yml
@@ -0,0 +1,38 @@
+name: storybook
+
+on:
+ push:
+ branches: [dev]
+ paths:
+ - ".github/workflows/storybook.yml"
+ - "package.json"
+ - "bun.lock"
+ - "packages/storybook/**"
+ - "packages/ui/**"
+ pull_request:
+ branches: [dev]
+ paths:
+ - ".github/workflows/storybook.yml"
+ - "package.json"
+ - "bun.lock"
+ - "packages/storybook/**"
+ - "packages/ui/**"
+ workflow_dispatch:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ build:
+ name: storybook build
+ runs-on: blacksmith-4vcpu-ubuntu-2404
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Setup Bun
+ uses: ./.github/actions/setup-bun
+
+ - name: Build Storybook
+ run: bun --cwd packages/storybook build
diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc
index 3497847a676..8380f7f719e 100644
--- a/.opencode/opencode.jsonc
+++ b/.opencode/opencode.jsonc
@@ -5,6 +5,11 @@
"options": {},
},
},
+ "permission": {
+ "edit": {
+ "packages/opencode/migration/*": "deny",
+ },
+ },
"mcp": {},
"tools": {
"github-triage": false,
diff --git a/bun.lock b/bun.lock
index 5202b70d98b..bb13d0ea40a 100644
--- a/bun.lock
+++ b/bun.lock
@@ -26,7 +26,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
- "version": "1.2.22",
+ "version": "1.2.24",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -76,7 +76,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
- "version": "1.2.22",
+ "version": "1.2.24",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -110,7 +110,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
- "version": "1.2.22",
+ "version": "1.2.24",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -137,7 +137,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
- "version": "1.2.22",
+ "version": "1.2.24",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -161,7 +161,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
- "version": "1.2.22",
+ "version": "1.2.24",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -185,7 +185,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
- "version": "1.2.22",
+ "version": "1.2.24",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -218,7 +218,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
- "version": "1.2.22",
+ "version": "1.2.24",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -248,7 +248,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
- "version": "1.2.22",
+ "version": "1.2.24",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -277,7 +277,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
- "version": "1.2.22",
+ "version": "1.2.24",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -293,7 +293,7 @@
},
"packages/opencode": {
"name": "opencode",
- "version": "1.2.22",
+ "version": "1.2.24",
"bin": {
"opencode": "./bin/opencode",
},
@@ -409,7 +409,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
- "version": "1.2.22",
+ "version": "1.2.24",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -429,7 +429,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
- "version": "1.2.22",
+ "version": "1.2.24",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -440,7 +440,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
- "version": "1.2.22",
+ "version": "1.2.24",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -475,7 +475,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
- "version": "1.2.22",
+ "version": "1.2.24",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -483,11 +483,8 @@
"@pierre/diffs": "catalog:",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/bounds": "0.1.3",
- "@solid-primitives/lifecycle": "0.1.2",
"@solid-primitives/media": "2.3.3",
- "@solid-primitives/page-visibility": "2.1.1",
"@solid-primitives/resize-observer": "2.1.3",
- "@solid-primitives/rootless": "1.5.2",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"dompurify": "3.3.1",
@@ -524,7 +521,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
- "version": "1.2.22",
+ "version": "1.2.24",
"dependencies": {
"zod": "catalog:",
},
@@ -535,7 +532,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
- "version": "1.2.22",
+ "version": "1.2.24",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1837,14 +1834,10 @@
"@solid-primitives/keyed": ["@solid-primitives/keyed@1.5.3", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zNadtyYBhJSOjXtogkGHmRxjGdz9KHc8sGGVAGlUABkE8BED2tbIZoxkwSqzOwde8OcUEH0bb5DLZUWIMvyBSA=="],
- "@solid-primitives/lifecycle": ["@solid-primitives/lifecycle@0.1.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-+K0T10kZXqorocFj0coIqt8NYm2UqoZfpF3nm2RwrDMZMV+C+SC0Oi3N6Dnq2i7W/n1cHAnfpoV4CBLsW21lJw=="],
-
"@solid-primitives/map": ["@solid-primitives/map@0.4.13", "", { "dependencies": { "@solid-primitives/trigger": "^1.1.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-B1zyFbsiTQvqPr+cuPCXO72sRuczG9Swncqk5P74NCGw1VE8qa/Ry9GlfI1e/VdeQYHjan+XkbE3rO2GW/qKew=="],
"@solid-primitives/media": ["@solid-primitives/media@2.3.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hQ4hLOGvfbugQi5Eu1BFWAIJGIAzztq9x0h02xgBGl2l0Jaa3h7tg6bz5tV1NSuNYVGio4rPoa7zVQQLkkx9dA=="],
- "@solid-primitives/page-visibility": ["@solid-primitives/page-visibility@2.1.1", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.1", "@solid-primitives/rootless": "^1.5.1", "@solid-primitives/utils": "^6.3.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-CV9BqMqhunf4OOyBkhJCH9f5ivg0ADavdcaBsrqoFvwIk1FoD/blPSHYM4CK8IjS/AEXNcsjlNVc34lMu+2Wdg=="],
-
"@solid-primitives/props": ["@solid-primitives/props@3.2.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-lZOTwFJajBrshSyg14nBMEP0h8MXzPowGO0s3OeiR3z6nXHTfj0FhzDtJMv+VYoRJKQHG2QRnJTgCzK6erARAw=="],
"@solid-primitives/refs": ["@solid-primitives/refs@1.1.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-K7tf2thy7L+YJjdqXspXOg5xvNEOH8tgEWsp0+1mQk3obHBRD6hEjYZk7p7FlJphSZImS35je3UfmWuD7MhDfg=="],
diff --git a/nix/hashes.json b/nix/hashes.json
index 2f14f9bf4ea..73491735f40 100644
--- a/nix/hashes.json
+++ b/nix/hashes.json
@@ -1,8 +1,8 @@
{
"nodeModules": {
- "x86_64-linux": "sha256-c99eE1cKAQHvwJosaFo42U9Hk0Rtp/U5oTTlyiz2Zw4=",
- "aarch64-linux": "sha256-LbdssPrf8Bijyp4mRo8QaO/swxwUWSo1g0jLPm2rvUA=",
- "aarch64-darwin": "sha256-0L9y6Zk4l2vAxsM2bENahhtRZY1C3XhdxLgnnYlhkkY=",
- "x86_64-darwin": "sha256-0J5sFG/kHHRDcTpdpdPBMJEOHwCRnAUYmbxEHPPLDvU="
+ "x86_64-linux": "sha256-4kjoJ06VNvHltPHfzQRBG0bC6R39jao10ffGzrNZ230=",
+ "aarch64-linux": "sha256-6Uio+S2rcyBWbBEeOZb9N1CCKgkbKi68lOIKi3Ws/pQ=",
+ "aarch64-darwin": "sha256-8ngN5KVN4vhdsk0QJ11BGgSVBrcaEbwSj23c77HBpgs=",
+ "x86_64-darwin": "sha256-v/ueYGb9a0Nymzy+mkO4uQr78DAuJnES1qOT0onFgnQ="
}
}
diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts
index 90a449d5006..86147dc65d5 100644
--- a/packages/app/e2e/actions.ts
+++ b/packages/app/e2e/actions.ts
@@ -7,7 +7,6 @@ import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
import {
dropdownMenuTriggerSelector,
dropdownMenuContentSelector,
- sessionTimelineHeaderSelector,
projectMenuTriggerSelector,
projectCloseMenuSelector,
projectWorkspacesToggleSelector,
@@ -244,9 +243,7 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) {
const scroller = page.locator(".scroll-view__viewport").first()
await expect(scroller).toBeVisible()
- const header = page.locator(sessionTimelineHeaderSelector).first()
- await expect(header).toBeVisible({ timeout: 30_000 })
- await expect(header.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
+ await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
const menu = page
.locator(dropdownMenuContentSelector)
@@ -262,7 +259,7 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) {
if (opened) return menu
- const menuTrigger = header.getByRole("button", { name: /more options/i }).first()
+ const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
await expect(menuTrigger).toBeVisible()
await menuTrigger.click()
diff --git a/packages/app/e2e/projects/projects-close.spec.ts b/packages/app/e2e/projects/projects-close.spec.ts
index 76b1487e9ed..9454d683f02 100644
--- a/packages/app/e2e/projects/projects-close.spec.ts
+++ b/packages/app/e2e/projects/projects-close.spec.ts
@@ -25,16 +25,26 @@ test("closing active project navigates to another open project", async ({ page,
await clickMenuItem(menu, /^Close$/i, { force: true })
await expect
- .poll(() => {
- const pathname = new URL(page.url()).pathname
- if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
- if (pathname === "/") return "home"
- return ""
- })
+ .poll(
+ () => {
+ const pathname = new URL(page.url()).pathname
+ if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
+ if (pathname === "/") return "home"
+ return ""
+ },
+ { timeout: 15_000 },
+ )
.toMatch(/^(project|home)$/)
await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`))
- await expect(otherButton).toHaveCount(0)
+ await expect
+ .poll(
+ async () => {
+ return await page.locator(projectSwitchSelector(otherSlug)).count()
+ },
+ { timeout: 15_000 },
+ )
+ .toBe(0)
},
{ extra: [other] },
)
diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts
index 002ac2114c4..2061a112849 100644
--- a/packages/app/e2e/selectors.ts
+++ b/packages/app/e2e/selectors.ts
@@ -51,8 +51,6 @@ export const dropdownMenuContentSelector = '[data-component="dropdown-menu-conte
export const inlineInputSelector = '[data-component="inline-input"]'
-export const sessionTimelineHeaderSelector = "[data-session-title]"
-
export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
export const workspaceItemSelector = (slug: string) =>
diff --git a/packages/app/e2e/session/session.spec.ts b/packages/app/e2e/session/session.spec.ts
index e541738c590..68d99294996 100644
--- a/packages/app/e2e/session/session.spec.ts
+++ b/packages/app/e2e/session/session.spec.ts
@@ -7,7 +7,7 @@ import {
openSharePopover,
withSession,
} from "../actions"
-import { sessionItemSelector, inlineInputSelector, sessionTimelineHeaderSelector } from "../selectors"
+import { sessionItemSelector, inlineInputSelector } from "../selectors"
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
@@ -39,14 +39,12 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
await withSession(sdk, originalTitle, async (session) => {
await seedMessage(sdk, session.id)
await gotoSession(session.id)
- await expect(page.locator(sessionTimelineHeaderSelector).getByRole("heading", { level: 1 }).first()).toHaveText(
- originalTitle,
- )
+ await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /rename/i)
- const input = page.locator(sessionTimelineHeaderSelector).locator(inlineInputSelector).first()
+ const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
await expect(input).toBeVisible()
await expect(input).toBeFocused()
await input.fill(renamedTitle)
@@ -63,9 +61,7 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
)
.toBe(renamedTitle)
- await expect(page.locator(sessionTimelineHeaderSelector).getByRole("heading", { level: 1 }).first()).toHaveText(
- renamedTitle,
- )
+ await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
})
})
diff --git a/packages/app/e2e/settings/settings.spec.ts b/packages/app/e2e/settings/settings.spec.ts
index c2a8522eb05..f25e91a315e 100644
--- a/packages/app/e2e/settings/settings.spec.ts
+++ b/packages/app/e2e/settings/settings.spec.ts
@@ -83,16 +83,23 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
const select = dialog.locator(settingsThemeSelector)
await expect(select).toBeVisible()
+ const currentThemeId = await page.evaluate(() => {
+ return document.documentElement.getAttribute("data-theme")
+ })
+ const currentTheme = (await select.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
+
await select.locator('[data-slot="select-select-trigger"]').click()
const items = page.locator('[data-slot="select-select-item"]')
const count = await items.count()
expect(count).toBeGreaterThan(1)
- const firstTheme = await items.nth(1).locator('[data-slot="select-select-item-label"]').textContent()
- expect(firstTheme).toBeTruthy()
+ const nextTheme = (await items.locator('[data-slot="select-select-item-label"]').allTextContents())
+ .map((x) => x.trim())
+ .find((x) => x && x !== currentTheme)
+ expect(nextTheme).toBeTruthy()
- await items.nth(1).click()
+ await items.filter({ hasText: nextTheme! }).first().click()
await page.keyboard.press("Escape")
@@ -101,7 +108,7 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
})
expect(storedThemeId).not.toBeNull()
- expect(storedThemeId).not.toBe("oc-1")
+ expect(storedThemeId).not.toBe(currentThemeId)
const dataTheme = await page.evaluate(() => {
return document.documentElement.getAttribute("data-theme")
@@ -109,6 +116,42 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
expect(dataTheme).toBe(storedThemeId)
})
+test("legacy oc-1 theme migrates to oc-2", async ({ page, gotoSession }) => {
+ await page.addInitScript(() => {
+ localStorage.setItem("opencode-theme-id", "oc-1")
+ localStorage.setItem("opencode-theme-css-light", "--background-base:#fff;")
+ localStorage.setItem("opencode-theme-css-dark", "--background-base:#000;")
+ })
+
+ await gotoSession()
+
+ await expect(page.locator("html")).toHaveAttribute("data-theme", "oc-2")
+
+ await expect
+ .poll(async () => {
+ return await page.evaluate(() => {
+ return localStorage.getItem("opencode-theme-id")
+ })
+ })
+ .toBe("oc-2")
+
+ await expect
+ .poll(async () => {
+ return await page.evaluate(() => {
+ return localStorage.getItem("opencode-theme-css-light")
+ })
+ })
+ .toBeNull()
+
+ await expect
+ .poll(async () => {
+ return await page.evaluate(() => {
+ return localStorage.getItem("opencode-theme-css-dark")
+ })
+ })
+ .toBeNull()
+})
+
test("changing font persists in localStorage and updates CSS variable", async ({ page, gotoSession }) => {
await gotoSession()
diff --git a/packages/app/e2e/utils.ts b/packages/app/e2e/utils.ts
index 0dbc5f8b5a6..f07a8d3f111 100644
--- a/packages/app/e2e/utils.ts
+++ b/packages/app/e2e/utils.ts
@@ -57,7 +57,7 @@ export function sessionPath(directory: string, sessionID?: string) {
}
export function workspacePersistKey(directory: string, key: string) {
- const head = directory.slice(0, 12) || "workspace"
+ const head = (directory.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-")
const sum = checksum(directory) ?? "0"
return `opencode.workspace.${head}.${sum}.dat:workspace:${key}`
}
diff --git a/packages/app/package.json b/packages/app/package.json
index 51f9883a569..10ef17d1bf8 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
- "version": "1.2.22",
+ "version": "1.2.24",
"description": "",
"type": "module",
"exports": {
diff --git a/packages/app/public/oc-theme-preload.js b/packages/app/public/oc-theme-preload.js
index f8c71049619..36fa5d726af 100644
--- a/packages/app/public/oc-theme-preload.js
+++ b/packages/app/public/oc-theme-preload.js
@@ -1,6 +1,13 @@
;(function () {
- var themeId = localStorage.getItem("opencode-theme-id")
- if (!themeId) return
+ var key = "opencode-theme-id"
+ var themeId = localStorage.getItem(key) || "oc-2"
+
+ if (themeId === "oc-1") {
+ themeId = "oc-2"
+ localStorage.setItem(key, themeId)
+ localStorage.removeItem("opencode-theme-css-light")
+ localStorage.removeItem("opencode-theme-css-dark")
+ }
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
@@ -9,9 +16,9 @@
document.documentElement.dataset.theme = themeId
document.documentElement.dataset.colorScheme = mode
- if (themeId === "oc-1") return
+ if (themeId === "oc-2") return
- var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
+ var css = localStorage.getItem("opencode-theme-css-" + mode)
if (css) {
var style = document.createElement("style")
style.id = "oc-theme-preload"
diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx
index c27d6a977f2..9e5f12ee4c9 100644
--- a/packages/app/src/components/terminal.tsx
+++ b/packages/app/src/components/terminal.tsx
@@ -217,7 +217,7 @@ export const Terminal = (props: TerminalProps) => {
const currentTheme = theme.themes()[theme.themeId()]
if (!currentTheme) return fallback
const variant = mode === "dark" ? currentTheme.dark : currentTheme.light
- if (!variant?.seeds) return fallback
+ if (!variant?.seeds && !variant?.palette) return fallback
const resolved = resolveThemeVariant(variant, mode === "dark")
const text = resolved["text-stronger"] ?? fallback.foreground
const background = resolved["background-stronger"] ?? fallback.background
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index b7ac28ae1a7..40015db1be2 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -424,6 +424,17 @@ export default function Layout(props: ParentProps) {
return
}
+ if (
+ e.details?.type === "question.replied" ||
+ e.details?.type === "question.rejected" ||
+ e.details?.type === "permission.replied"
+ ) {
+ const props = e.details.properties as { sessionID: string }
+ const sessionKey = `${e.name}:${props.sessionID}`
+ dismissSessionAlert(sessionKey)
+ return
+ }
+
if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return
const title =
e.details.type === "permission.asked"
@@ -1917,7 +1928,7 @@ export default function Layout(props: ParentProps) {
return (
string | undefined
+ messagesReady: () => boolean
+ visibleUserMessages: () => UserMessage[]
+ historyMore: () => boolean
+ historyLoading: () => boolean
+ loadMore: (sessionID: string) => Promise
+ userScrolled: () => boolean
+ scroller: () => HTMLDivElement | undefined
+}
+
+/**
+ * Maintains the rendered history window for a session timeline.
+ *
+ * It keeps initial paint bounded to recent turns, reveals cached turns in
+ * small batches while scrolling upward, and prefetches older history near top.
+ */
+function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
+ const turnInit = 10
+ const turnBatch = 8
+ const turnScrollThreshold = 200
+ const turnPrefetchBuffer = 16
+ const prefetchCooldownMs = 400
+ const prefetchNoGrowthLimit = 2
+
+ const [state, setState] = createStore({
+ turnID: undefined as string | undefined,
+ turnStart: 0,
+ prefetchUntil: 0,
+ prefetchNoGrowth: 0,
+ })
+
+ const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0)
+
+ const turnStart = createMemo(() => {
+ const id = input.sessionID()
+ const len = input.visibleUserMessages().length
+ if (!id || len <= 0) return 0
+ if (state.turnID !== id) return initialTurnStart(len)
+ if (state.turnStart <= 0) return 0
+ if (state.turnStart >= len) return initialTurnStart(len)
+ return state.turnStart
+ })
+
+ const setTurnStart = (start: number) => {
+ const id = input.sessionID()
+ const next = start > 0 ? start : 0
+ if (!id) {
+ setState({ turnID: undefined, turnStart: next })
+ return
+ }
+ setState({ turnID: id, turnStart: next })
+ }
+
+ const renderedUserMessages = createMemo(
+ () => {
+ const msgs = input.visibleUserMessages()
+ const start = turnStart()
+ if (start <= 0) return msgs
+ return msgs.slice(start)
+ },
+ emptyUserMessages,
+ {
+ equals: same,
+ },
+ )
+
+ const preserveScroll = (fn: () => void) => {
+ const el = input.scroller()
+ if (!el) {
+ fn()
+ return
+ }
+ const beforeTop = el.scrollTop
+ const beforeHeight = el.scrollHeight
+ fn()
+ requestAnimationFrame(() => {
+ const delta = el.scrollHeight - beforeHeight
+ if (!delta) return
+ el.scrollTop = beforeTop + delta
+ })
+ }
+
+ const backfillTurns = () => {
+ const start = turnStart()
+ if (start <= 0) return
+
+ const next = start - turnBatch
+ const nextStart = next > 0 ? next : 0
+
+ preserveScroll(() => setTurnStart(nextStart))
+ }
+
+ /** Button path: reveal all cached turns, fetch older history, reveal one batch. */
+ const loadAndReveal = async () => {
+ const id = input.sessionID()
+ if (!id) return
+
+ const start = turnStart()
+ const beforeVisible = input.visibleUserMessages().length
+
+ if (start > 0) setTurnStart(0)
+
+ if (!input.historyMore() || input.historyLoading()) return
+
+ await input.loadMore(id)
+ if (input.sessionID() !== id) return
+
+ const afterVisible = input.visibleUserMessages().length
+ const growth = afterVisible - beforeVisible
+ if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0)
+ if (growth <= 0) return
+ if (turnStart() !== 0) return
+
+ const target = Math.min(afterVisible, Math.max(beforeVisible, renderedUserMessages().length) + turnBatch)
+ const nextStart = Math.max(0, afterVisible - target)
+ preserveScroll(() => setTurnStart(nextStart))
+ }
+
+ /** Scroll/prefetch path: fetch older history from server. */
+ const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => {
+ const id = input.sessionID()
+ if (!id) return
+ if (!input.historyMore() || input.historyLoading()) return
+
+ if (opts?.prefetch) {
+ const now = Date.now()
+ if (state.prefetchUntil > now) return
+ if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return
+ setState("prefetchUntil", now + prefetchCooldownMs)
+ }
+
+ const start = turnStart()
+ const beforeVisible = input.visibleUserMessages().length
+ const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length
+
+ await input.loadMore(id)
+ if (input.sessionID() !== id) return
+
+ const afterVisible = input.visibleUserMessages().length
+ const growth = afterVisible - beforeVisible
+
+ if (opts?.prefetch) {
+ setState("prefetchNoGrowth", growth > 0 ? 0 : state.prefetchNoGrowth + 1)
+ } else if (growth > 0 && state.prefetchNoGrowth) {
+ setState("prefetchNoGrowth", 0)
+ }
+
+ if (growth <= 0) return
+ if (turnStart() !== start) return
+
+ const reveal = !opts?.prefetch
+ const currentRendered = renderedUserMessages().length
+ const base = Math.max(beforeRendered, currentRendered)
+ const target = reveal ? Math.min(afterVisible, base + turnBatch) : base
+ const nextStart = Math.max(0, afterVisible - target)
+ preserveScroll(() => setTurnStart(nextStart))
+ }
+
+ const onScrollerScroll = () => {
+ if (!input.userScrolled()) return
+ const el = input.scroller()
+ if (!el) return
+ if (el.scrollTop >= turnScrollThreshold) return
+
+ const start = turnStart()
+ if (start > 0) {
+ if (start <= turnPrefetchBuffer) {
+ void fetchOlderMessages({ prefetch: true })
+ }
+ backfillTurns()
+ return
+ }
+
+ void fetchOlderMessages()
+ }
+
+ createEffect(
+ on(
+ input.sessionID,
+ () => {
+ setState({ prefetchUntil: 0, prefetchNoGrowth: 0 })
+ },
+ { defer: true },
+ ),
+ )
+
+ createEffect(
+ on(
+ () => [input.sessionID(), input.messagesReady()] as const,
+ ([id, ready]) => {
+ if (!id || !ready) return
+ setTurnStart(initialTurnStart(input.visibleUserMessages().length))
+ },
+ { defer: true },
+ ),
+ )
+
+ return {
+ turnStart,
+ setTurnStart,
+ renderedUserMessages,
+ loadAndReveal,
+ onScrollerScroll,
+ }
+}
+
export default function Page() {
const globalSync = useGlobalSync()
const layout = useLayout()
@@ -77,6 +284,7 @@ export default function Page() {
const [ui, setUi] = createStore({
git: false,
pendingMessage: undefined as string | undefined,
+ reviewSnap: false,
scrollGesture: 0,
scroll: {
overflow: false,
@@ -252,6 +460,21 @@ export default function Page() {
return key
}, sessionKey())
+ let reviewFrame: number | undefined
+
+ createComputed((prev) => {
+ const open = desktopReviewOpen()
+ if (prev === undefined || prev === open) return open
+
+ if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
+ setUi("reviewSnap", true)
+ reviewFrame = requestAnimationFrame(() => {
+ reviewFrame = undefined
+ setUi("reviewSnap", false)
+ })
+ return open
+ }, desktopReviewOpen())
+
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
@@ -262,20 +485,49 @@ export default function Page() {
return "main"
})
- const activeMessage = createMemo(() => {
- if (!store.messageId) return lastUserMessage()
- const found = visibleUserMessages()?.find((m) => m.id === store.messageId)
- return found ?? lastUserMessage()
- })
const setActiveMessage = (message: UserMessage | undefined) => {
+ messageMark = scrollMark
setStore("messageId", message?.id)
}
+ const anchor = (id: string) => `message-${id}`
+
+ const cursor = () => {
+ const root = scroller
+ if (!root) return store.messageId
+
+ const box = root.getBoundingClientRect()
+ const line = box.top + 100
+ const list = [...root.querySelectorAll("[data-message-id]")]
+ .map((el) => {
+ const id = el.dataset.messageId
+ if (!id) return
+
+ const rect = el.getBoundingClientRect()
+ return { id, top: rect.top, bottom: rect.bottom }
+ })
+ .filter((item): item is { id: string; top: number; bottom: number } => !!item)
+
+ const shown = list.filter((item) => item.bottom > box.top && item.top < box.bottom)
+ const hit = shown.find((item) => item.top <= line && item.bottom >= line)
+ if (hit) return hit.id
+
+ const near = [...shown].sort((a, b) => {
+ const da = Math.abs(a.top - line)
+ const db = Math.abs(b.top - line)
+ if (da !== db) return da - db
+ return a.top - b.top
+ })[0]
+ if (near) return near.id
+
+ return list.filter((item) => item.top <= line).at(-1)?.id ?? list[0]?.id ?? store.messageId
+ }
+
function navigateMessageByOffset(offset: number) {
const msgs = visibleUserMessages()
if (msgs.length === 0) return
- const current = store.messageId
+ const current = store.messageId && messageMark === scrollMark ? store.messageId : cursor()
const base = current ? msgs.findIndex((m) => m.id === current) : msgs.length
const currentIndex = base === -1 ? msgs.length : base
const targetIndex = currentIndex + offset
@@ -348,6 +600,8 @@ export default function Page() {
let dockHeight = 0
let scroller: HTMLDivElement | undefined
let content: HTMLDivElement | undefined
+ let scrollMark = 0
+ let messageMark = 0
const scrollGestureWindowMs = 250
@@ -392,6 +646,7 @@ export default function Page() {
() => {
setStore("messageId", undefined)
setStore("changes", "session")
+ setUi("pendingMessage", undefined)
},
{ defer: true },
),
@@ -886,18 +1141,11 @@ export default function Page() {
let scrollStateFrame: number | undefined
let scrollStateTarget: HTMLDivElement | undefined
- let historyFillFrame: number | undefined
- const scrollSpy = createScrollSpy({
- onActive: (id) => {
- if (id === store.messageId) return
- setStore("messageId", id)
- },
- })
const updateScrollState = (el: HTMLDivElement) => {
const max = el.scrollHeight - el.clientHeight
const overflow = max > 1
- const bottom = !overflow || Math.abs(el.scrollTop) <= 2 || !autoScroll.userScrolled()
+ const bottom = !overflow || el.scrollTop >= max - 2
if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return
setUi("scroll", { overflow, bottom })
@@ -920,7 +1168,7 @@ export default function Page() {
const resumeScroll = () => {
setStore("messageId", undefined)
- autoScroll.smoothScrollToBottom()
+ autoScroll.forceScrollToBottom()
clearMessageHash()
const el = scroller
@@ -940,25 +1188,14 @@ export default function Page() {
),
)
- createEffect(
- on(
- sessionKey,
- () => {
- scrollSpy.clear()
- },
- { defer: true },
- ),
- )
-
- const anchor = (id: string) => `message-${id}`
-
const setScrollRef = (el: HTMLDivElement | undefined) => {
scroller = el
autoScroll.scrollRef(el)
- scrollSpy.setContainer(el)
- if (!el) return
- scheduleScrollState(el)
- scheduleHistoryFill()
+ if (el) scheduleScrollState(el)
+ }
+
+ const markUserScroll = () => {
+ scrollMark += 1
}
createResizeObserver(
@@ -966,8 +1203,6 @@ export default function Page() {
() => {
const el = scroller
if (el) scheduleScrollState(el)
- scrollSpy.markDirty()
- scheduleHistoryFill()
},
)
@@ -982,45 +1217,6 @@ export default function Page() {
scroller: () => scroller,
})
- const scheduleHistoryFill = () => {
- if (historyFillFrame !== undefined) return
-
- historyFillFrame = requestAnimationFrame(() => {
- historyFillFrame = undefined
-
- if (!params.id || !messagesReady()) return
- if (autoScroll.userScrolled() || historyLoading()) return
-
- const el = scroller
- if (!el) return
- if (el.scrollHeight > el.clientHeight + 1) return
- if (historyWindow.turnStart() <= 0 && !historyMore()) return
-
- void historyWindow.loadAndReveal()
- })
- }
-
- createEffect(
- on(
- () =>
- [
- params.id,
- messagesReady(),
- historyWindow.turnStart(),
- historyMore(),
- historyLoading(),
- autoScroll.userScrolled(),
- visibleUserMessages().length,
- ] as const,
- ([id, ready, start, more, loading, scrolled]) => {
- if (!id || !ready || loading || scrolled) return
- if (start <= 0 && !more) return
- scheduleHistoryFill()
- },
- { defer: true },
- ),
- )
-
createResizeObserver(
() => promptDock,
({ height }) => {
@@ -1030,15 +1226,15 @@ export default function Page() {
const el = scroller
const delta = next - dockHeight
- const stick = el ? Math.abs(el.scrollTop) < 10 + Math.max(0, delta) : false
+ const stick = el
+ ? !autoScroll.userScrolled() || el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta)
+ : false
dockHeight = next
- if (stick) autoScroll.smoothScrollToBottom()
+ if (stick) autoScroll.forceScrollToBottom()
if (el) scheduleScrollState(el)
- scrollSpy.markDirty()
- scheduleHistoryFill()
},
)
@@ -1066,9 +1262,8 @@ export default function Page() {
onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown)
- scrollSpy.destroy()
+ if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
- if (historyFillFrame !== undefined) cancelAnimationFrame(historyFillFrame)
})
return (
@@ -1089,7 +1284,7 @@ export default function Page() {
classList={{
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger flex-1 md:flex-none": true,
"transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
- !size.active(),
+ !size.active() && !ui.reviewSnap,
}}
style={{
width: sessionPanelWidth(),
@@ -1098,7 +1293,7 @@ export default function Page() {
-
+
{
content = el
@@ -1139,8 +1332,6 @@ export default function Page() {
}}
renderedUserMessages={historyWindow.renderedUserMessages()}
anchor={anchor}
- onRegisterMessage={scrollSpy.register}
- onUnregisterMessage={scrollSpy.unregister}
/>
@@ -1205,6 +1396,7 @@ export default function Page() {
reviewPanel={reviewPanel}
activeDiff={tree.activeDiff}
focusReviewDiff={focusReviewDiff}
+ reviewSnap={ui.reviewSnap}
size={size}
/>
diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx
index 18a02993b69..93ea3d465c5 100644
--- a/packages/app/src/pages/session/composer/session-composer-region.tsx
+++ b/packages/app/src/pages/session/composer/session-composer-region.tsx
@@ -140,7 +140,7 @@ export function SessionComposerRegion(props: {
diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx
index 07df4305f0d..77643789d04 100644
--- a/packages/app/src/pages/session/file-tabs.tsx
+++ b/packages/app/src/pages/session/file-tabs.tsx
@@ -446,9 +446,9 @@ export function FileTabContent(props: { tab: string }) {
)
return (
-
+
{
scroll = el
restoreScroll()
diff --git a/packages/app/src/pages/session/history-window.test.ts b/packages/app/src/pages/session/history-window.test.ts
deleted file mode 100644
index 4a9b894e275..00000000000
--- a/packages/app/src/pages/session/history-window.test.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { describe, expect, test } from "bun:test"
-import { historyLoadMode, historyRevealTop } from "./history-window"
-
-describe("historyLoadMode", () => {
- test("reveals cached turns before fetching", () => {
- expect(historyLoadMode({ start: 10, more: true, loading: false })).toBe("reveal")
- })
-
- test("fetches older history when cache is already revealed", () => {
- expect(historyLoadMode({ start: 0, more: true, loading: false })).toBe("fetch")
- })
-
- test("does nothing while history is unavailable or loading", () => {
- expect(historyLoadMode({ start: 0, more: false, loading: false })).toBe("noop")
- expect(historyLoadMode({ start: 0, more: true, loading: true })).toBe("noop")
- })
-})
-
-describe("historyRevealTop", () => {
- test("pins the viewport to the top when older turns were revealed there", () => {
- expect(historyRevealTop({ top: -400, height: 1000, gap: 0, max: 400 }, { clientHeight: 600, height: 2000 })).toBe(
- -1400,
- )
- })
-
- test("keeps the latest turns pinned when the viewport was underfilled", () => {
- expect(historyRevealTop({ top: 0, height: 200, gap: -400, max: -400 }, { clientHeight: 600, height: 2000 })).toBe(0)
- })
-
- test("keeps the current anchor when the user was not at the top", () => {
- expect(historyRevealTop({ top: -200, height: 1000, gap: 200, max: 400 }, { clientHeight: 600, height: 2000 })).toBe(
- -200,
- )
- })
-})
diff --git a/packages/app/src/pages/session/history-window.ts b/packages/app/src/pages/session/history-window.ts
deleted file mode 100644
index e3ef20f13d4..00000000000
--- a/packages/app/src/pages/session/history-window.ts
+++ /dev/null
@@ -1,273 +0,0 @@
-import type { UserMessage } from "@opencode-ai/sdk/v2"
-import { createEffect, createMemo, on } from "solid-js"
-import { createStore } from "solid-js/store"
-import { same } from "@/utils/same"
-
-export const emptyUserMessages: UserMessage[] = []
-
-export type SessionHistoryWindowInput = {
- sessionID: () => string | undefined
- messagesReady: () => boolean
- visibleUserMessages: () => UserMessage[]
- historyMore: () => boolean
- historyLoading: () => boolean
- loadMore: (sessionID: string) => Promise
- userScrolled: () => boolean
- scroller: () => HTMLDivElement | undefined
-}
-
-type Snap = {
- top: number
- height: number
- gap: number
- max: number
-}
-
-export const historyLoadMode = (input: { start: number; more: boolean; loading: boolean }) => {
- if (input.start > 0) return "reveal"
- if (!input.more || input.loading) return "noop"
- return "fetch"
-}
-
-export const historyRevealTop = (
- mark: { top: number; height: number; gap: number; max: number },
- next: { clientHeight: number; height: number },
- threshold = 16,
-) => {
- const delta = next.height - mark.height
- if (delta <= 0) return mark.top
- if (mark.max <= 0) return mark.top
- if (mark.gap > threshold) return mark.top
-
- const max = next.height - next.clientHeight
- if (max <= 0) return 0
- return Math.max(-max, Math.min(0, mark.top - delta))
-}
-
-const snap = (el: HTMLDivElement | undefined): Snap | undefined => {
- if (!el) return
- const max = el.scrollHeight - el.clientHeight
- return {
- top: el.scrollTop,
- height: el.scrollHeight,
- gap: max + el.scrollTop,
- max,
- }
-}
-
-const clamp = (el: HTMLDivElement, top: number) => {
- const max = el.scrollHeight - el.clientHeight
- if (max <= 0) return 0
- return Math.max(-max, Math.min(0, top))
-}
-
-const revealThreshold = 16
-
-const reveal = (input: SessionHistoryWindowInput, mark: Snap | undefined) => {
- const el = input.scroller()
- if (!el || !mark) return
- el.scrollTop = clamp(
- el,
- historyRevealTop(mark, { clientHeight: el.clientHeight, height: el.scrollHeight }, revealThreshold),
- )
-}
-
-const preserve = (input: SessionHistoryWindowInput, fn: () => void) => {
- const el = input.scroller()
- if (!el) {
- fn()
- return
- }
- const top = el.scrollTop
- fn()
- el.scrollTop = top
-}
-
-/**
- * Maintains the rendered history window for a session timeline.
- *
- * It keeps initial paint bounded to recent turns, reveals cached turns in
- * small batches while scrolling upward, and prefetches older history near top.
- */
-export function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
- const turnInit = 10
- const turnBatch = 8
- const turnScrollThreshold = 200
- const turnPrefetchBuffer = 16
- const prefetchCooldownMs = 400
- const prefetchNoGrowthLimit = 2
-
- const [state, setState] = createStore({
- turnID: undefined as string | undefined,
- turnStart: 0,
- prefetchUntil: 0,
- prefetchNoGrowth: 0,
- })
-
- const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0)
-
- const turnStart = createMemo(() => {
- const id = input.sessionID()
- const len = input.visibleUserMessages().length
- if (!id || len <= 0) return 0
- if (state.turnID !== id) return initialTurnStart(len)
- if (state.turnStart <= 0) return 0
- if (state.turnStart >= len) return initialTurnStart(len)
- return state.turnStart
- })
-
- const setTurnStart = (start: number) => {
- const id = input.sessionID()
- const next = start > 0 ? start : 0
- if (!id) {
- setState({ turnID: undefined, turnStart: next })
- return
- }
- setState({ turnID: id, turnStart: next })
- }
-
- const renderedUserMessages = createMemo(
- () => {
- const msgs = input.visibleUserMessages()
- const start = turnStart()
- if (start <= 0) return msgs
- return msgs.slice(start)
- },
- emptyUserMessages,
- {
- equals: same,
- },
- )
-
- const backfillTurns = () => {
- const start = turnStart()
- if (start <= 0) return
-
- const next = start - turnBatch
- const nextStart = next > 0 ? next : 0
-
- preserve(input, () => setTurnStart(nextStart))
- }
-
- /** Button path: reveal cached turns first, then fetch older history. */
- const loadAndReveal = async () => {
- const id = input.sessionID()
- if (!id) return
-
- const start = turnStart()
- const mode = historyLoadMode({
- start,
- more: input.historyMore(),
- loading: input.historyLoading(),
- })
-
- if (mode === "reveal") {
- const mark = snap(input.scroller())
- setTurnStart(0)
- reveal(input, mark)
- return
- }
-
- if (mode === "noop") return
-
- const beforeVisible = input.visibleUserMessages().length
- const mark = snap(input.scroller())
-
- await input.loadMore(id)
- if (input.sessionID() !== id) return
-
- const afterVisible = input.visibleUserMessages().length
- const growth = afterVisible - beforeVisible
- if (growth <= 0) return
- if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0)
-
- reveal(input, mark)
- }
-
- /** Scroll/prefetch path: fetch older history from server. */
- const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => {
- const id = input.sessionID()
- if (!id) return
- if (!input.historyMore() || input.historyLoading()) return
-
- if (opts?.prefetch) {
- const now = Date.now()
- if (state.prefetchUntil > now) return
- if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return
- setState("prefetchUntil", now + prefetchCooldownMs)
- }
-
- const start = turnStart()
- const beforeVisible = input.visibleUserMessages().length
- const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length
-
- await input.loadMore(id)
- if (input.sessionID() !== id) return
-
- const afterVisible = input.visibleUserMessages().length
- const growth = afterVisible - beforeVisible
-
- if (opts?.prefetch) {
- setState("prefetchNoGrowth", growth > 0 ? 0 : state.prefetchNoGrowth + 1)
- } else if (growth > 0 && state.prefetchNoGrowth) {
- setState("prefetchNoGrowth", 0)
- }
-
- if (growth <= 0) return
- if (turnStart() !== start) return
-
- const revealMore = !opts?.prefetch
- const currentRendered = renderedUserMessages().length
- const base = Math.max(beforeRendered, currentRendered)
- const target = revealMore ? Math.min(afterVisible, base + turnBatch) : base
- const nextStart = Math.max(0, afterVisible - target)
- preserve(input, () => setTurnStart(nextStart))
- }
-
- const onScrollerScroll = () => {
- if (!input.userScrolled()) return
- const el = input.scroller()
- if (!el) return
- if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return
-
- const start = turnStart()
- if (start > 0) {
- if (start <= turnPrefetchBuffer) {
- void fetchOlderMessages({ prefetch: true })
- }
- backfillTurns()
- return
- }
-
- void fetchOlderMessages()
- }
-
- createEffect(
- on(
- input.sessionID,
- () => {
- setState({ prefetchUntil: 0, prefetchNoGrowth: 0 })
- },
- { defer: true },
- ),
- )
-
- createEffect(
- on(
- () => [input.sessionID(), input.messagesReady()] as const,
- ([id, ready]) => {
- if (!id || !ready) return
- setTurnStart(initialTurnStart(input.visibleUserMessages().length))
- },
- { defer: true },
- ),
- )
-
- return {
- turnStart,
- setTurnStart,
- renderedUserMessages,
- loadAndReveal,
- onScrollerScroll,
- }
-}
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
index e93ca11a36b..6463e7cbbe3 100644
--- a/packages/app/src/pages/session/message-timeline.tsx
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -1,31 +1,28 @@
-import {
- For,
- Index,
- createEffect,
- createMemo,
- createSignal,
- on,
- onCleanup,
- Show,
- startTransition,
- type JSX,
-} from "solid-js"
-import { createStore } from "solid-js/store"
-import { useParams } from "@solidjs/router"
+import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js"
+import { createStore, produce } from "solid-js/store"
+import { useNavigate, useParams } from "@solidjs/router"
import { Button } from "@opencode-ai/ui/button"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { InlineInput } from "@opencode-ai/ui/inline-input"
+import { Spinner } from "@opencode-ai/ui/spinner"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
+import { showToast } from "@opencode-ai/ui/toast"
import { Binary } from "@opencode-ai/util/binary"
import { getFilename } from "@opencode-ai/util/path"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
+import { SessionContextUsage } from "@/components/session-context-usage"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
+import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
-import { SessionTimelineHeader } from "@/pages/session/session-timeline-header"
type MessageComment = {
path: string
@@ -37,9 +34,7 @@ type MessageComment = {
}
const emptyMessages: MessageType[] = []
-
-const isDefaultSessionTitle = (title?: string) =>
- !!title && /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title)
+const idle = { type: "idle" as const }
const messageComments = (parts: Part[]): MessageComment[] =>
parts.flatMap((part) => {
@@ -116,8 +111,6 @@ function createTimelineStaging(input: TimelineStageInput) {
completedSession: "",
count: 0,
})
- const [readySession, setReadySession] = createSignal("")
- let active = ""
const stagedCount = createMemo(() => {
const total = input.messages().length
@@ -142,46 +135,23 @@ function createTimelineStaging(input: TimelineStageInput) {
cancelAnimationFrame(frame)
frame = undefined
}
- const scheduleReady = (sessionKey: string) => {
- if (input.sessionKey() !== sessionKey) return
- if (readySession() === sessionKey) return
- setReadySession(sessionKey)
- }
createEffect(
on(
() => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const,
([sessionKey, isWindowed, total]) => {
- const switched = active !== sessionKey
- if (switched) {
- active = sessionKey
- setReadySession("")
- }
-
- const staging = state.activeSession === sessionKey && state.completedSession !== sessionKey
- const shouldStage = isWindowed && total > input.config.init && state.completedSession !== sessionKey
-
- if (staging && !switched && shouldStage && frame !== undefined) return
-
cancel()
-
- if (shouldStage) setReadySession("")
+ const shouldStage =
+ isWindowed &&
+ total > input.config.init &&
+ state.completedSession !== sessionKey &&
+ state.activeSession !== sessionKey
if (!shouldStage) {
- setState({
- activeSession: "",
- completedSession: isWindowed ? sessionKey : state.completedSession,
- count: total,
- })
- if (total <= 0) {
- setReadySession("")
- return
- }
- if (readySession() !== sessionKey) scheduleReady(sessionKey)
+ setState({ activeSession: "", count: total })
return
}
let count = Math.min(total, input.config.init)
- if (staging) count = Math.min(total, Math.max(count, state.count))
setState({ activeSession: sessionKey, count })
const step = () => {
@@ -191,11 +161,10 @@ function createTimelineStaging(input: TimelineStageInput) {
}
const currentTotal = input.messages().length
count = Math.min(currentTotal, count + input.config.batch)
- startTransition(() => setState("count", count))
+ setState("count", count)
if (count >= currentTotal) {
setState({ completedSession: sessionKey, activeSession: "" })
frame = undefined
- scheduleReady(sessionKey)
return
}
frame = requestAnimationFrame(step)
@@ -209,12 +178,9 @@ function createTimelineStaging(input: TimelineStageInput) {
const key = input.sessionKey()
return state.activeSession === key && state.completedSession !== key
})
- const ready = createMemo(() => readySession() === input.sessionKey())
- onCleanup(() => {
- cancel()
- })
- return { messages: stagedUserMessages, isStaging, ready }
+ onCleanup(cancel)
+ return { messages: stagedUserMessages, isStaging }
}
export function MessageTimeline(props: {
@@ -227,11 +193,9 @@ export function MessageTimeline(props: {
onAutoScrollHandleScroll: () => void
onMarkScrollGesture: (target?: EventTarget | null) => void
hasScrollGesture: () => boolean
- isDesktop: boolean
- onScrollSpyScroll: () => void
+ onUserScroll: () => void
onTurnBackfillScroll: () => void
onAutoScrollInteraction: (event: MouseEvent) => void
- onPreserveScrollAnchor: (target: HTMLElement) => void
centered: boolean
setContentRef: (el: HTMLDivElement) => void
turnStart: number
@@ -240,25 +204,18 @@ export function MessageTimeline(props: {
onLoadEarlier: () => void
renderedUserMessages: UserMessage[]
anchor: (id: string) => string
- onRegisterMessage: (el: HTMLDivElement, id: string) => void
- onUnregisterMessage: (id: string) => void
}) {
let touchGesture: number | undefined
const params = useParams()
+ const navigate = useNavigate()
+ const sdk = useSDK()
const sync = useSync()
const settings = useSettings()
+ const dialog = useDialog()
const language = useLanguage()
- const trigger = (target: EventTarget | null) => {
- const next =
- target instanceof Element
- ? target.closest('[data-slot="collapsible-trigger"], [data-slot="accordion-trigger"], [data-scroll-preserve]')
- : undefined
- if (!(next instanceof HTMLElement)) return
- return next
- }
-
+ const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const sessionID = createMemo(() => params.id)
const sessionMessages = createMemo(() => {
@@ -271,20 +228,62 @@ export function MessageTimeline(props: {
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
),
)
- const sessionStatus = createMemo(() => sync.data.session_status[sessionID() ?? ""]?.type ?? "idle")
+ const sessionStatus = createMemo(() => {
+ const id = sessionID()
+ if (!id) return idle
+ return sync.data.session_status[id] ?? idle
+ })
+ const working = createMemo(() => !!pending() || sessionStatus().type !== "idle")
+
+ const [slot, setSlot] = createStore({
+ open: false,
+ show: false,
+ fade: false,
+ })
+
+ let f: number | undefined
+ const clear = () => {
+ if (f !== undefined) window.clearTimeout(f)
+ f = undefined
+ }
+
+ onCleanup(clear)
+ createEffect(
+ on(
+ working,
+ (on, prev) => {
+ clear()
+ if (on) {
+ setSlot({ open: true, show: true, fade: false })
+ return
+ }
+ if (prev) {
+ setSlot({ open: false, show: true, fade: true })
+ f = window.setTimeout(() => setSlot({ show: false, fade: false }), 260)
+ return
+ }
+ setSlot({ open: false, show: false, fade: false })
+ },
+ { defer: true },
+ ),
+ )
const activeMessageID = createMemo(() => {
- const messages = sessionMessages()
- const message = pending()
- if (message?.parentID) {
- const result = Binary.search(messages, message.parentID, (item) => item.id)
- const parent = result.found ? messages[result.index] : messages.find((item) => item.id === message.parentID)
- if (parent?.role === "user") return parent.id
+ const parentID = pending()?.parentID
+ if (parentID) {
+ const messages = sessionMessages()
+ const result = Binary.search(messages, parentID, (message) => message.id)
+ const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID)
+ if (message && message.role === "user") return message.id
}
- if (sessionStatus() === "idle") return undefined
- for (let i = messages.length - 1; i >= 0; i--) {
- if (messages[i].role === "user") return messages[i].id
+ const status = sessionStatus()
+ if (status.type !== "idle") {
+ const messages = sessionMessages()
+ for (let i = messages.length - 1; i >= 0; i--) {
+ if (messages[i].role === "user") return messages[i].id
+ }
}
+
return undefined
})
const info = createMemo(() => {
@@ -292,19 +291,9 @@ export function MessageTimeline(props: {
if (!id) return
return sync.session.get(id)
})
- const titleValue = createMemo(() => {
- const title = info()?.title
- if (!title) return
- if (isDefaultSessionTitle(title)) return language.t("command.session.new")
- return title
- })
- const defaultTitle = createMemo(() => isDefaultSessionTitle(info()?.title))
- const headerTitle = createMemo(
- () => titleValue() ?? (props.renderedUserMessages.length ? language.t("command.session.new") : undefined),
- )
- const placeholderTitle = createMemo(() => defaultTitle() || (!info()?.title && props.renderedUserMessages.length > 0))
+ const titleValue = createMemo(() => info()?.title)
const parentID = createMemo(() => info()?.parentID)
- const showHeader = createMemo(() => !!(headerTitle() || parentID()))
+ const showHeader = createMemo(() => !!(titleValue() || parentID()))
const stageCfg = { init: 1, batch: 3 }
const staging = createTimelineStaging({
sessionKey,
@@ -312,7 +301,212 @@ export function MessageTimeline(props: {
messages: () => props.renderedUserMessages,
config: stageCfg,
})
- const rendered = createMemo(() => staging.messages().map((message) => message.id))
+
+ const [title, setTitle] = createStore({
+ draft: "",
+ editing: false,
+ saving: false,
+ menuOpen: false,
+ pendingRename: false,
+ })
+ let titleRef: HTMLInputElement | undefined
+
+ const errorMessage = (err: unknown) => {
+ if (err && typeof err === "object" && "data" in err) {
+ const data = (err as { data?: { message?: string } }).data
+ if (data?.message) return data.message
+ }
+ if (err instanceof Error) return err.message
+ return language.t("common.requestFailed")
+ }
+
+ createEffect(
+ on(
+ sessionKey,
+ () => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
+ { defer: true },
+ ),
+ )
+
+ const openTitleEditor = () => {
+ if (!sessionID()) return
+ setTitle({ editing: true, draft: titleValue() ?? "" })
+ requestAnimationFrame(() => {
+ titleRef?.focus()
+ titleRef?.select()
+ })
+ }
+
+ const closeTitleEditor = () => {
+ if (title.saving) return
+ setTitle({ editing: false, saving: false })
+ }
+
+ const saveTitleEditor = async () => {
+ const id = sessionID()
+ if (!id) return
+ if (title.saving) return
+
+ const next = title.draft.trim()
+ if (!next || next === (titleValue() ?? "")) {
+ setTitle({ editing: false, saving: false })
+ return
+ }
+
+ setTitle("saving", true)
+ await sdk.client.session
+ .update({ sessionID: id, title: next })
+ .then(() => {
+ sync.set(
+ produce((draft) => {
+ const index = draft.session.findIndex((s) => s.id === id)
+ if (index !== -1) draft.session[index].title = next
+ }),
+ )
+ setTitle({ editing: false, saving: false })
+ })
+ .catch((err) => {
+ setTitle("saving", false)
+ showToast({
+ title: language.t("common.requestFailed"),
+ description: errorMessage(err),
+ })
+ })
+ }
+
+ const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
+ if (params.id !== sessionID) return
+ if (parentID) {
+ navigate(`/${params.dir}/session/${parentID}`)
+ return
+ }
+ if (nextSessionID) {
+ navigate(`/${params.dir}/session/${nextSessionID}`)
+ return
+ }
+ navigate(`/${params.dir}/session`)
+ }
+
+ const archiveSession = async (sessionID: string) => {
+ const session = sync.session.get(sessionID)
+ if (!session) return
+
+ const sessions = sync.data.session ?? []
+ const index = sessions.findIndex((s) => s.id === sessionID)
+ const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
+
+ await sdk.client.session
+ .update({ sessionID, time: { archived: Date.now() } })
+ .then(() => {
+ sync.set(
+ produce((draft) => {
+ const index = draft.session.findIndex((s) => s.id === sessionID)
+ if (index !== -1) draft.session.splice(index, 1)
+ }),
+ )
+ navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
+ })
+ .catch((err) => {
+ showToast({
+ title: language.t("common.requestFailed"),
+ description: errorMessage(err),
+ })
+ })
+ }
+
+ const deleteSession = async (sessionID: string) => {
+ const session = sync.session.get(sessionID)
+ if (!session) return false
+
+ const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
+ const index = sessions.findIndex((s) => s.id === sessionID)
+ const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
+
+ const result = await sdk.client.session
+ .delete({ sessionID })
+ .then((x) => x.data)
+ .catch((err) => {
+ showToast({
+ title: language.t("session.delete.failed.title"),
+ description: errorMessage(err),
+ })
+ return false
+ })
+
+ if (!result) return false
+
+ sync.set(
+ produce((draft) => {
+ const removed = new Set([sessionID])
+
+ const byParent = new Map()
+ for (const item of draft.session) {
+ const parentID = item.parentID
+ if (!parentID) continue
+ const existing = byParent.get(parentID)
+ if (existing) {
+ existing.push(item.id)
+ continue
+ }
+ byParent.set(parentID, [item.id])
+ }
+
+ const stack = [sessionID]
+ while (stack.length) {
+ const parentID = stack.pop()
+ if (!parentID) continue
+
+ const children = byParent.get(parentID)
+ if (!children) continue
+
+ for (const child of children) {
+ if (removed.has(child)) continue
+ removed.add(child)
+ stack.push(child)
+ }
+ }
+
+ draft.session = draft.session.filter((s) => !removed.has(s.id))
+ }),
+ )
+
+ navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
+ return true
+ }
+
+ const navigateParent = () => {
+ const id = parentID()
+ if (!id) return
+ navigate(`/${params.dir}/session/${id}`)
+ }
+
+ function DialogDeleteSession(props: { sessionID: string }) {
+ const name = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
+ const handleDelete = async () => {
+ await deleteSession(props.sessionID)
+ dialog.close()
+ }
+
+ return (
+
+ )
+ }
return (
-
{
const root = e.currentTarget
@@ -381,44 +564,166 @@ export function MessageTimeline(props: {
touchGesture = undefined
}}
onPointerDown={(e) => {
- const next = trigger(e.target)
- if (next) props.onPreserveScrollAnchor(next)
-
if (e.target !== e.currentTarget) return
props.onMarkScrollGesture(e.currentTarget)
}}
- onKeyDown={(e) => {
- if (e.key !== "Enter" && e.key !== " ") return
- const next = trigger(e.target)
- if (!next) return
- props.onPreserveScrollAnchor(next)
- }}
onScroll={(e) => {
props.onScheduleScrollState(e.currentTarget)
props.onTurnBackfillScroll()
if (!props.hasScrollGesture()) return
+ props.onUserScroll()
props.onAutoScrollHandleScroll()
props.onMarkScrollGesture(e.currentTarget)
- if (props.isDesktop) props.onScrollSpyScroll()
- }}
- onClick={(e) => {
- props.onAutoScrollInteraction(e)
}}
+ onClick={props.onAutoScrollInteraction}
class="relative min-w-0 w-full h-full"
style={{
- "--session-title-height": showHeader() ? "72px" : "0px",
+ "--session-title-height": showHeader() ? "40px" : "0px",
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
}}
>
-
+
+
+
+
+
+
+
+
+
+
+
+
+ {titleValue()}
+
+ }
+ >
+ {
+ titleRef = el
+ }}
+ value={title.draft}
+ disabled={title.saving}
+ class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
+ style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
+ onInput={(event) => setTitle("draft", event.currentTarget.value)}
+ onKeyDown={(event) => {
+ event.stopPropagation()
+ if (event.key === "Enter") {
+ event.preventDefault()
+ void saveTitleEditor()
+ return
+ }
+ if (event.key === "Escape") {
+ event.preventDefault()
+ closeTitleEditor()
+ }
+ }}
+ onBlur={closeTitleEditor}
+ />
+
+
+
+
+
+ {(id) => (
+
+
+ setTitle("menuOpen", open)}
+ >
+
+
+ {
+ if (!title.pendingRename) return
+ event.preventDefault()
+ setTitle("pendingRename", false)
+ openTitleEditor()
+ }}
+ >
+ {
+ setTitle("pendingRename", true)
+ setTitle("menuOpen", false)
+ }}
+ >
+ {language.t("common.rename")}
+
+ void archiveSession(id())}>
+ {language.t("common.archive")}
+
+
+ dialog.show(() => )}
+ >
+ {language.t("common.delete")}
+
+
+
+
+
+ )}
+
+
+
+
+
{(messageID) => {
- // Capture at creation time: animate only messages added after the
- // timeline finishes its initial backfill staging, plus the first
- // turn while a brand new session is still using its default title.
- const isNew =
- staging.ready() ||
- (defaultTitle() &&
- sessionStatus() !== "idle" &&
- props.renderedUserMessages.length === 1 &&
- messageID === props.renderedUserMessages[0]?.id)
const active = createMemo(() => activeMessageID() === messageID)
const queued = createMemo(() => {
if (active()) return false
@@ -457,23 +753,16 @@ export function MessageTimeline(props: {
return false
})
const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
- equals: (a, b) => {
- if (a.length !== b.length) return false
- return a.every((x, i) => x.path === b[i].path && x.comment === b[i].comment)
- },
+ equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
})
const commentCount = createMemo(() => comments().length)
return (
{
- props.onRegisterMessage(el, messageID)
- onCleanup(() => props.onUnregisterMessage(messageID))
- }}
classList={{
"min-w-0 w-full max-w-full": true,
- "md:max-w-[500px] 2xl:max-w-[700px]": props.centered,
+ "md:max-w-200 2xl:max-w-[1000px]": props.centered,
}}
>
0}>
@@ -517,7 +806,7 @@ export function MessageTimeline(props: {
messageID={messageID}
active={active()}
queued={queued()}
- animate={isNew || active()}
+ status={active() ? sessionStatus() : undefined}
showReasoningSummaries={settings.general.showReasoningSummaries()}
shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
editToolDefaultOpen={settings.general.editToolPartsExpanded()}
diff --git a/packages/app/src/pages/session/scroll-spy.test.ts b/packages/app/src/pages/session/scroll-spy.test.ts
deleted file mode 100644
index f3e6775cb48..00000000000
--- a/packages/app/src/pages/session/scroll-spy.test.ts
+++ /dev/null
@@ -1,127 +0,0 @@
-import { describe, expect, test } from "bun:test"
-import { createScrollSpy, pickOffsetId, pickVisibleId } from "./scroll-spy"
-
-const rect = (top: number, height = 80): DOMRect =>
- ({
- x: 0,
- y: top,
- top,
- left: 0,
- right: 800,
- bottom: top + height,
- width: 800,
- height,
- toJSON: () => ({}),
- }) as DOMRect
-
-const setRect = (el: Element, top: number, height = 80) => {
- Object.defineProperty(el, "getBoundingClientRect", {
- configurable: true,
- value: () => rect(top, height),
- })
-}
-
-describe("pickVisibleId", () => {
- test("prefers higher intersection ratio", () => {
- const id = pickVisibleId(
- [
- { id: "a", ratio: 0.2, top: 100 },
- { id: "b", ratio: 0.8, top: 300 },
- ],
- 120,
- )
-
- expect(id).toBe("b")
- })
-
- test("breaks ratio ties by nearest line", () => {
- const id = pickVisibleId(
- [
- { id: "a", ratio: 0.5, top: 90 },
- { id: "b", ratio: 0.5, top: 140 },
- ],
- 130,
- )
-
- expect(id).toBe("b")
- })
-})
-
-describe("pickOffsetId", () => {
- test("uses binary search cutoff", () => {
- const id = pickOffsetId(
- [
- { id: "a", top: 0 },
- { id: "b", top: 200 },
- { id: "c", top: 400 },
- ],
- 350,
- )
-
- expect(id).toBe("b")
- })
-})
-
-describe("createScrollSpy fallback", () => {
- test("tracks active id from offsets and dirty refresh", () => {
- const active: string[] = []
- const root = document.createElement("div") as HTMLDivElement
- const one = document.createElement("div")
- const two = document.createElement("div")
- const three = document.createElement("div")
-
- root.append(one, two, three)
- document.body.append(root)
-
- Object.defineProperty(root, "scrollTop", { configurable: true, writable: true, value: 250 })
- setRect(root, 0, 800)
- setRect(one, -250)
- setRect(two, -50)
- setRect(three, 150)
-
- const queue: FrameRequestCallback[] = []
- const flush = () => {
- const run = [...queue]
- queue.length = 0
- for (const cb of run) cb(0)
- }
-
- const spy = createScrollSpy({
- onActive: (id) => active.push(id),
- raf: (cb) => (queue.push(cb), queue.length),
- caf: () => {},
- IntersectionObserver: undefined,
- ResizeObserver: undefined,
- MutationObserver: undefined,
- })
-
- spy.setContainer(root)
- spy.register(one, "a")
- spy.register(two, "b")
- spy.register(three, "c")
- spy.onScroll()
- flush()
-
- expect(spy.getActiveId()).toBe("b")
- expect(active.at(-1)).toBe("b")
-
- root.scrollTop = 450
- setRect(one, -450)
- setRect(two, -250)
- setRect(three, -50)
- spy.onScroll()
- flush()
- expect(spy.getActiveId()).toBe("c")
-
- root.scrollTop = 250
- setRect(one, -250)
- setRect(two, 250)
- setRect(three, 150)
- spy.markDirty()
- spy.onScroll()
- flush()
- expect(spy.getActiveId()).toBe("a")
-
- spy.destroy()
- })
-})
diff --git a/packages/app/src/pages/session/scroll-spy.ts b/packages/app/src/pages/session/scroll-spy.ts
deleted file mode 100644
index 6ef4c844c41..00000000000
--- a/packages/app/src/pages/session/scroll-spy.ts
+++ /dev/null
@@ -1,275 +0,0 @@
-type Visible = {
- id: string
- ratio: number
- top: number
-}
-
-type Offset = {
- id: string
- top: number
-}
-
-type Input = {
- onActive: (id: string) => void
- raf?: (cb: FrameRequestCallback) => number
- caf?: (id: number) => void
- IntersectionObserver?: typeof globalThis.IntersectionObserver
- ResizeObserver?: typeof globalThis.ResizeObserver
- MutationObserver?: typeof globalThis.MutationObserver
-}
-
-export const pickVisibleId = (list: Visible[], line: number) => {
- if (list.length === 0) return
-
- const sorted = [...list].sort((a, b) => {
- if (b.ratio !== a.ratio) return b.ratio - a.ratio
-
- const da = Math.abs(a.top - line)
- const db = Math.abs(b.top - line)
- if (da !== db) return da - db
-
- return a.top - b.top
- })
-
- return sorted[0]?.id
-}
-
-export const pickOffsetId = (list: Offset[], cutoff: number) => {
- if (list.length === 0) return
-
- let lo = 0
- let hi = list.length - 1
- let out = 0
-
- while (lo <= hi) {
- const mid = (lo + hi) >> 1
- const top = list[mid]?.top
- if (top === undefined) break
-
- if (top <= cutoff) {
- out = mid
- lo = mid + 1
- continue
- }
-
- hi = mid - 1
- }
-
- return list[out]?.id
-}
-
-export const createScrollSpy = (input: Input) => {
- const raf = input.raf ?? requestAnimationFrame
- const caf = input.caf ?? cancelAnimationFrame
- const CtorIO = input.IntersectionObserver ?? globalThis.IntersectionObserver
- const CtorRO = input.ResizeObserver ?? globalThis.ResizeObserver
- const CtorMO = input.MutationObserver ?? globalThis.MutationObserver
-
- let root: HTMLDivElement | undefined
- let io: IntersectionObserver | undefined
- let ro: ResizeObserver | undefined
- let mo: MutationObserver | undefined
- let frame: number | undefined
- let active: string | undefined
- let dirty = true
-
- const node = new Map()
- const id = new WeakMap()
- const visible = new Map()
- let offset: Offset[] = []
-
- const schedule = () => {
- if (frame !== undefined) return
- frame = raf(() => {
- frame = undefined
- update()
- })
- }
-
- const refreshOffset = () => {
- const el = root
- if (!el) {
- offset = []
- dirty = false
- return
- }
-
- const base = el.getBoundingClientRect().top
- offset = [...node].map(([next, item]) => ({
- id: next,
- top: item.getBoundingClientRect().top - base + el.scrollTop,
- }))
- offset.sort((a, b) => a.top - b.top)
- dirty = false
- }
-
- const update = () => {
- const el = root
- if (!el) return
-
- const line = el.getBoundingClientRect().top + 100
- const next =
- pickVisibleId(
- [...visible].map(([k, v]) => ({
- id: k,
- ratio: v.ratio,
- top: v.top,
- })),
- line,
- ) ??
- (() => {
- if (dirty) refreshOffset()
- return pickOffsetId(offset, el.scrollTop + 100)
- })()
-
- if (!next || next === active) return
- active = next
- input.onActive(next)
- }
-
- const observe = () => {
- const el = root
- if (!el) return
-
- io?.disconnect()
- io = undefined
- if (CtorIO) {
- try {
- io = new CtorIO(
- (entries) => {
- for (const entry of entries) {
- const item = entry.target
- if (!(item instanceof HTMLElement)) continue
- const key = id.get(item)
- if (!key) continue
-
- if (!entry.isIntersecting || entry.intersectionRatio <= 0) {
- visible.delete(key)
- continue
- }
-
- visible.set(key, {
- ratio: entry.intersectionRatio,
- top: entry.boundingClientRect.top,
- })
- }
-
- schedule()
- },
- {
- root: el,
- threshold: [0, 0.25, 0.5, 0.75, 1],
- },
- )
- } catch {
- io = undefined
- }
- }
-
- if (io) {
- for (const item of node.values()) io.observe(item)
- }
-
- ro?.disconnect()
- ro = undefined
- if (CtorRO) {
- ro = new CtorRO(() => {
- dirty = true
- schedule()
- })
- ro.observe(el)
- for (const item of node.values()) ro.observe(item)
- }
-
- mo?.disconnect()
- mo = undefined
- if (CtorMO) {
- mo = new CtorMO(() => {
- dirty = true
- schedule()
- })
- mo.observe(el, { subtree: true, childList: true, characterData: true })
- }
-
- dirty = true
- schedule()
- }
-
- const setContainer = (el?: HTMLDivElement) => {
- if (root === el) return
-
- root = el
- visible.clear()
- active = undefined
- observe()
- }
-
- const register = (el: HTMLElement, key: string) => {
- const prev = node.get(key)
- if (prev && prev !== el) {
- io?.unobserve(prev)
- ro?.unobserve(prev)
- }
-
- node.set(key, el)
- id.set(el, key)
- if (io) io.observe(el)
- if (ro) ro.observe(el)
- dirty = true
- schedule()
- }
-
- const unregister = (key: string) => {
- const item = node.get(key)
- if (!item) return
-
- io?.unobserve(item)
- ro?.unobserve(item)
- node.delete(key)
- visible.delete(key)
- dirty = true
- schedule()
- }
-
- const markDirty = () => {
- dirty = true
- schedule()
- }
-
- const clear = () => {
- for (const item of node.values()) {
- io?.unobserve(item)
- ro?.unobserve(item)
- }
-
- node.clear()
- visible.clear()
- offset = []
- active = undefined
- dirty = true
- }
-
- const destroy = () => {
- if (frame !== undefined) caf(frame)
- frame = undefined
- clear()
- io?.disconnect()
- ro?.disconnect()
- mo?.disconnect()
- io = undefined
- ro = undefined
- mo = undefined
- root = undefined
- }
-
- return {
- setContainer,
- register,
- unregister,
- onScroll: schedule,
- markDirty,
- clear,
- destroy,
- getActiveId: () => active,
- }
-}
diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx
index a5e067c6f05..590f5b6d9ba 100644
--- a/packages/app/src/pages/session/session-side-panel.tsx
+++ b/packages/app/src/pages/session/session-side-panel.tsx
@@ -31,6 +31,7 @@ export function SessionSidePanel(props: {
reviewPanel: () => JSX.Element
activeDiff?: string
focusReviewDiff: (path: string) => void
+ reviewSnap: boolean
size: Sizing
}) {
const params = useParams()
@@ -228,7 +229,7 @@ export function SessionSidePanel(props: {
classList={{
"pointer-events-none": !open(),
"transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
- !props.size.active(),
+ !props.size.active() && !props.reviewSnap,
}}
style={{ width: panelWidth() }}
>
diff --git a/packages/app/src/pages/session/session-timeline-header.tsx b/packages/app/src/pages/session/session-timeline-header.tsx
deleted file mode 100644
index 32412f0a7fd..00000000000
--- a/packages/app/src/pages/session/session-timeline-header.tsx
+++ /dev/null
@@ -1,522 +0,0 @@
-import { createEffect, createMemo, on, onCleanup, Show } from "solid-js"
-import { createStore, produce } from "solid-js/store"
-import { useNavigate, useParams } from "@solidjs/router"
-import { Button } from "@opencode-ai/ui/button"
-import { useReducedMotion } from "@opencode-ai/ui/hooks"
-import { IconButton } from "@opencode-ai/ui/icon-button"
-import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
-import { Dialog } from "@opencode-ai/ui/dialog"
-import { InlineInput } from "@opencode-ai/ui/inline-input"
-import { animate, type AnimationPlaybackControls, clearFadeStyles, FAST_SPRING } from "@opencode-ai/ui/motion"
-import { showToast } from "@opencode-ai/ui/toast"
-import { errorMessage } from "@/pages/layout/helpers"
-import { SessionContextUsage } from "@/components/session-context-usage"
-import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { useLanguage } from "@/context/language"
-import { useSDK } from "@/context/sdk"
-import { useSync } from "@/context/sync"
-
-export function SessionTimelineHeader(props: {
- centered: boolean
- showHeader: () => boolean
- sessionKey: () => string
- sessionID: () => string | undefined
- parentID: () => string | undefined
- titleValue: () => string | undefined
- headerTitle: () => string | undefined
- placeholderTitle: () => boolean
-}) {
- const navigate = useNavigate()
- const params = useParams()
- const sdk = useSDK()
- const sync = useSync()
- const dialog = useDialog()
- const language = useLanguage()
- const reduce = useReducedMotion()
-
- const [title, setTitle] = createStore({
- draft: "",
- editing: false,
- saving: false,
- menuOpen: false,
- pendingRename: false,
- })
- const [headerText, setHeaderText] = createStore({
- session: props.sessionKey(),
- value: props.headerTitle(),
- prev: undefined as string | undefined,
- muted: props.placeholderTitle(),
- prevMuted: false,
- })
- let headerAnim: AnimationPlaybackControls | undefined
- let enterAnim: AnimationPlaybackControls | undefined
- let leaveAnim: AnimationPlaybackControls | undefined
- let titleRef: HTMLInputElement | undefined
- let headerRef: HTMLDivElement | undefined
- let enterRef: HTMLSpanElement | undefined
- let leaveRef: HTMLSpanElement | undefined
-
- const clearHeaderAnim = () => {
- headerAnim?.stop()
- headerAnim = undefined
- }
-
- const animateHeader = () => {
- const el = headerRef
- if (!el) return
-
- clearHeaderAnim()
- if (!headerText.muted || reduce()) {
- el.style.opacity = "1"
- return
- }
-
- headerAnim = animate(el, { opacity: [0, 1] }, { type: "spring", visualDuration: 1.0, bounce: 0 })
- headerAnim.finished.then(() => {
- if (headerRef !== el) return
- clearFadeStyles(el)
- })
- }
-
- const clearTitleAnims = () => {
- enterAnim?.stop()
- enterAnim = undefined
- leaveAnim?.stop()
- leaveAnim = undefined
- }
-
- const settleTitleEnter = () => {
- if (enterRef) clearFadeStyles(enterRef)
- }
-
- const hideLeave = () => {
- if (!leaveRef) return
- leaveRef.style.opacity = "0"
- leaveRef.style.filter = ""
- leaveRef.style.transform = ""
- }
-
- const animateEnterSpan = () => {
- if (!enterRef) return
- if (reduce()) {
- settleTitleEnter()
- return
- }
- enterAnim = animate(
- enterRef,
- { opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"], transform: ["translateY(-2px)", "translateY(0)"] },
- FAST_SPRING,
- )
- enterAnim.finished.then(() => settleTitleEnter())
- }
-
- const crossfadeTitle = (nextTitle: string, nextMuted: boolean) => {
- clearTitleAnims()
- setHeaderText({ prev: headerText.value, prevMuted: headerText.muted })
- setHeaderText({ value: nextTitle, muted: nextMuted })
-
- if (reduce()) {
- setHeaderText({ prev: undefined, prevMuted: false })
- hideLeave()
- settleTitleEnter()
- return
- }
-
- if (leaveRef) {
- leaveAnim = animate(
- leaveRef,
- { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"], transform: ["translateY(0)", "translateY(2px)"] },
- FAST_SPRING,
- )
- leaveAnim.finished.then(() => {
- setHeaderText({ prev: undefined, prevMuted: false })
- hideLeave()
- })
- }
-
- animateEnterSpan()
- }
-
- const fadeInTitle = (nextTitle: string, nextMuted: boolean) => {
- clearTitleAnims()
- setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false })
- animateEnterSpan()
- }
-
- const snapTitle = (nextTitle: string | undefined, nextMuted: boolean) => {
- clearTitleAnims()
- setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false })
- settleTitleEnter()
- }
-
- createEffect(
- on(props.showHeader, (show, prev) => {
- if (!show) {
- clearHeaderAnim()
- return
- }
- if (show === prev) return
- animateHeader()
- }),
- )
-
- createEffect(
- on(
- () => [props.sessionKey(), props.headerTitle(), props.placeholderTitle()] as const,
- ([nextSession, nextTitle, nextMuted]) => {
- if (nextSession !== headerText.session) {
- setHeaderText("session", nextSession)
- if (nextTitle && nextMuted) {
- fadeInTitle(nextTitle, nextMuted)
- return
- }
- snapTitle(nextTitle, nextMuted)
- return
- }
- if (nextTitle === headerText.value && nextMuted === headerText.muted) return
- if (!nextTitle) {
- snapTitle(undefined, false)
- return
- }
- if (!headerText.value) {
- fadeInTitle(nextTitle, nextMuted)
- return
- }
- if (title.saving || title.editing) {
- snapTitle(nextTitle, nextMuted)
- return
- }
- crossfadeTitle(nextTitle, nextMuted)
- },
- ),
- )
-
- onCleanup(() => {
- clearHeaderAnim()
- clearTitleAnims()
- })
-
- const toastError = (err: unknown) => errorMessage(err, language.t("common.requestFailed"))
-
- createEffect(
- on(
- props.sessionKey,
- () => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
- { defer: true },
- ),
- )
-
- const openTitleEditor = () => {
- if (!props.sessionID()) return
- setTitle({ editing: true, draft: props.titleValue() ?? "" })
- requestAnimationFrame(() => {
- titleRef?.focus()
- titleRef?.select()
- })
- }
-
- const closeTitleEditor = () => {
- if (title.saving) return
- setTitle({ editing: false, saving: false })
- }
-
- const saveTitleEditor = async () => {
- const id = props.sessionID()
- if (!id) return
- if (title.saving) return
-
- const next = title.draft.trim()
- if (!next || next === (props.titleValue() ?? "")) {
- setTitle({ editing: false, saving: false })
- return
- }
-
- setTitle("saving", true)
- await sdk.client.session
- .update({ sessionID: id, title: next })
- .then(() => {
- sync.set(
- produce((draft) => {
- const index = draft.session.findIndex((session) => session.id === id)
- if (index !== -1) draft.session[index].title = next
- }),
- )
- setTitle({ editing: false, saving: false })
- })
- .catch((err) => {
- setTitle("saving", false)
- showToast({
- title: language.t("common.requestFailed"),
- description: toastError(err),
- })
- })
- }
-
- const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
- if (params.id !== sessionID) return
- if (parentID) {
- navigate(`/${params.dir}/session/${parentID}`)
- return
- }
- if (nextSessionID) {
- navigate(`/${params.dir}/session/${nextSessionID}`)
- return
- }
- navigate(`/${params.dir}/session`)
- }
-
- const archiveSession = async (sessionID: string) => {
- const session = sync.session.get(sessionID)
- if (!session) return
-
- const sessions = sync.data.session ?? []
- const index = sessions.findIndex((item) => item.id === sessionID)
- const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
-
- await sdk.client.session
- .update({ sessionID, time: { archived: Date.now() } })
- .then(() => {
- sync.set(
- produce((draft) => {
- const index = draft.session.findIndex((item) => item.id === sessionID)
- if (index !== -1) draft.session.splice(index, 1)
- }),
- )
- navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
- })
- .catch((err) => {
- showToast({
- title: language.t("common.requestFailed"),
- description: toastError(err),
- })
- })
- }
-
- const deleteSession = async (sessionID: string) => {
- const session = sync.session.get(sessionID)
- if (!session) return false
-
- const sessions = (sync.data.session ?? []).filter((item) => !item.parentID && !item.time?.archived)
- const index = sessions.findIndex((item) => item.id === sessionID)
- const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
-
- const result = await sdk.client.session
- .delete({ sessionID })
- .then((x) => x.data)
- .catch((err) => {
- showToast({
- title: language.t("session.delete.failed.title"),
- description: toastError(err),
- })
- return false
- })
-
- if (!result) return false
-
- sync.set(
- produce((draft) => {
- const removed = new Set([sessionID])
- const byParent = new Map()
-
- for (const item of draft.session) {
- const parentID = item.parentID
- if (!parentID) continue
-
- const existing = byParent.get(parentID)
- if (existing) {
- existing.push(item.id)
- continue
- }
- byParent.set(parentID, [item.id])
- }
-
- const stack = [sessionID]
- while (stack.length) {
- const parentID = stack.pop()
- if (!parentID) continue
-
- const children = byParent.get(parentID)
- if (!children) continue
-
- for (const child of children) {
- if (removed.has(child)) continue
- removed.add(child)
- stack.push(child)
- }
- }
-
- draft.session = draft.session.filter((item) => !removed.has(item.id))
- }),
- )
-
- navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
- return true
- }
-
- const navigateParent = () => {
- const id = props.parentID()
- if (!id) return
- navigate(`/${params.dir}/session/${id}`)
- }
-
- function DialogDeleteSession(input: { sessionID: string }) {
- const name = createMemo(() => sync.session.get(input.sessionID)?.title ?? language.t("command.session.new"))
-
- const handleDelete = async () => {
- await deleteSession(input.sessionID)
- dialog.close()
- }
-
- return (
-
- )
- }
-
- return (
-
- {
- headerRef = el
- el.style.opacity = "0"
- }}
- class="pointer-events-none absolute inset-x-0 top-0 z-30"
- >
-
-
-
-
-
-
-
-
-
-
-
-
- {headerText.value}
-
-
- {headerText.prev}
-
-
-
- }
- >
- {
- titleRef = el
- }}
- value={title.draft}
- disabled={title.saving}
- class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
- style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
- onInput={(event) => setTitle("draft", event.currentTarget.value)}
- onKeyDown={(event) => {
- event.stopPropagation()
- if (event.key === "Enter") {
- event.preventDefault()
- void saveTitleEditor()
- return
- }
- if (event.key === "Escape") {
- event.preventDefault()
- closeTitleEditor()
- }
- }}
- onBlur={closeTitleEditor}
- />
-
-
-
-
- {(id) => (
-
-
- setTitle("menuOpen", open)}
- >
-
-
- {
- if (!title.pendingRename) return
- event.preventDefault()
- setTitle("pendingRename", false)
- openTitleEditor()
- }}
- >
- {
- setTitle("pendingRename", true)
- setTitle("menuOpen", false)
- }}
- >
- {language.t("common.rename")}
-
- void archiveSession(id())}>
- {language.t("common.archive")}
-
-
- dialog.show(() => )}>
- {language.t("common.delete")}
-
-
-
-
-
- )}
-
-
-
-
-
- )
-}
diff --git a/packages/app/src/pages/session/use-session-hash-scroll.ts b/packages/app/src/pages/session/use-session-hash-scroll.ts
index 278a1ba6e56..1ea6a302b95 100644
--- a/packages/app/src/pages/session/use-session-hash-scroll.ts
+++ b/packages/app/src/pages/session/use-session-hash-scroll.ts
@@ -1,4 +1,5 @@
import type { UserMessage } from "@opencode-ai/sdk/v2"
+import { useLocation, useNavigate } from "@solidjs/router"
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { messageIdFromHash } from "./message-id-from-hash"
@@ -15,7 +16,7 @@ export const useSessionHashScroll = (input: {
setPendingMessage: (value: string | undefined) => void
setActiveMessage: (message: UserMessage | undefined) => void
setTurnStart: (value: number) => void
- autoScroll: { pause: () => void; snapToBottom: () => void }
+ autoScroll: { pause: () => void; forceScrollToBottom: () => void }
scroller: () => HTMLDivElement | undefined
anchor: (id: string) => string
scheduleScrollState: (el: HTMLDivElement) => void
@@ -25,14 +26,40 @@ export const useSessionHashScroll = (input: {
const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m])))
const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i])))
let pendingKey = ""
+ let clearing = false
+
+ const location = useLocation()
+ const navigate = useNavigate()
+
+ const frames = new Set()
+ const queue = (fn: () => void) => {
+ const id = requestAnimationFrame(() => {
+ frames.delete(id)
+ fn()
+ })
+ frames.add(id)
+ }
+ const cancel = () => {
+ for (const id of frames) cancelAnimationFrame(id)
+ frames.clear()
+ }
const clearMessageHash = () => {
- if (!window.location.hash) return
- window.history.replaceState(null, "", window.location.pathname + window.location.search)
+ cancel()
+ input.consumePendingMessage(input.sessionKey())
+ if (input.pendingMessage()) input.setPendingMessage(undefined)
+ if (!location.hash) return
+ clearing = true
+ navigate(location.pathname + location.search, { replace: true })
}
const updateHash = (id: string) => {
- window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}#${input.anchor(id)}`)
+ const hash = `#${input.anchor(id)}`
+ if (location.hash === hash) return
+ clearing = false
+ navigate(location.pathname + location.search + hash, {
+ replace: true,
+ })
}
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
@@ -41,65 +68,51 @@ export const useSessionHashScroll = (input: {
const a = el.getBoundingClientRect()
const b = root.getBoundingClientRect()
- const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height"))
- const inset = Number.isNaN(title) ? 0 : title
- // With column-reverse, scrollTop is negative — don't clamp to 0
- const top = a.top - b.top + root.scrollTop - inset
+ const sticky = root.querySelector("[data-session-title]")
+ const inset = sticky instanceof HTMLElement ? sticky.offsetHeight : 0
+ const top = Math.max(0, a.top - b.top + root.scrollTop - inset)
root.scrollTo({ top, behavior })
return true
}
+ const seek = (id: string, behavior: ScrollBehavior, left = 4): boolean => {
+ const el = document.getElementById(input.anchor(id))
+ if (el) return scrollToElement(el, behavior)
+ if (left <= 0) return false
+ queue(() => {
+ seek(id, behavior, left - 1)
+ })
+ return false
+ }
+
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
+ cancel()
if (input.currentMessageId() !== message.id) input.setActiveMessage(message)
const index = messageIndex().get(message.id) ?? -1
if (index !== -1 && index < input.turnStart()) {
input.setTurnStart(index)
- requestAnimationFrame(() => {
- const el = document.getElementById(input.anchor(message.id))
- if (!el) {
- requestAnimationFrame(() => {
- const next = document.getElementById(input.anchor(message.id))
- if (!next) return
- scrollToElement(next, behavior)
- })
- return
- }
- scrollToElement(el, behavior)
+ queue(() => {
+ seek(message.id, behavior)
})
updateHash(message.id)
return
}
- const el = document.getElementById(input.anchor(message.id))
- if (!el) {
- updateHash(message.id)
- requestAnimationFrame(() => {
- const next = document.getElementById(input.anchor(message.id))
- if (!next) return
- if (!scrollToElement(next, behavior)) return
- })
- return
- }
- if (scrollToElement(el, behavior)) {
+ if (seek(message.id, behavior)) {
updateHash(message.id)
return
}
- requestAnimationFrame(() => {
- const next = document.getElementById(input.anchor(message.id))
- if (!next) return
- if (!scrollToElement(next, behavior)) return
- })
updateHash(message.id)
}
const applyHash = (behavior: ScrollBehavior) => {
- const hash = window.location.hash.slice(1)
+ const hash = location.hash.slice(1)
if (!hash) {
- input.autoScroll.snapToBottom()
+ input.autoScroll.forceScrollToBottom()
const el = input.scroller()
if (el) input.scheduleScrollState(el)
return
@@ -123,28 +136,17 @@ export const useSessionHashScroll = (input: {
return
}
- input.autoScroll.snapToBottom()
+ input.autoScroll.forceScrollToBottom()
const el = input.scroller()
if (el) input.scheduleScrollState(el)
}
- onMount(() => {
- if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
- window.history.scrollRestoration = "manual"
- }
-
- const handler = () => {
- if (!input.sessionID() || !input.messagesReady()) return
- requestAnimationFrame(() => applyHash("auto"))
- }
-
- window.addEventListener("hashchange", handler)
- onCleanup(() => window.removeEventListener("hashchange", handler))
- })
-
createEffect(() => {
+ const hash = location.hash
+ if (!hash) clearing = false
if (!input.sessionID() || !input.messagesReady()) return
- requestAnimationFrame(() => applyHash("auto"))
+ cancel()
+ queue(() => applyHash("auto"))
})
createEffect(() => {
@@ -166,17 +168,29 @@ export const useSessionHashScroll = (input: {
}
}
+ if (!targetId && !clearing) targetId = messageIdFromHash(location.hash)
if (!targetId) return
- if (input.currentMessageId() === targetId) return
+ const pending = input.pendingMessage() === targetId
const msg = messageById().get(targetId)
if (!msg) return
- if (input.pendingMessage() === targetId) input.setPendingMessage(undefined)
+ if (pending) input.setPendingMessage(undefined)
+ if (input.currentMessageId() === targetId && !pending) return
+
input.autoScroll.pause()
- requestAnimationFrame(() => scrollToMessage(msg, "auto"))
+ cancel()
+ queue(() => scrollToMessage(msg, "auto"))
+ })
+
+ onMount(() => {
+ if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
+ window.history.scrollRestoration = "manual"
+ }
})
+ onCleanup(cancel)
+
return {
clearMessageHash,
scrollToMessage,
diff --git a/packages/app/src/theme-preload.test.ts b/packages/app/src/theme-preload.test.ts
new file mode 100644
index 00000000000..00d7da23948
--- /dev/null
+++ b/packages/app/src/theme-preload.test.ts
@@ -0,0 +1,46 @@
+import { beforeEach, describe, expect, test } from "bun:test"
+
+const src = await Bun.file(new URL("../public/oc-theme-preload.js", import.meta.url)).text()
+
+const run = () => Function(src)()
+
+beforeEach(() => {
+ document.head.innerHTML = ""
+ document.documentElement.removeAttribute("data-theme")
+ document.documentElement.removeAttribute("data-color-scheme")
+ localStorage.clear()
+ Object.defineProperty(window, "matchMedia", {
+ value: () =>
+ ({
+ matches: false,
+ }) as MediaQueryList,
+ configurable: true,
+ })
+})
+
+describe("theme preload", () => {
+ test("migrates legacy oc-1 to oc-2 before mount", () => {
+ localStorage.setItem("opencode-theme-id", "oc-1")
+ localStorage.setItem("opencode-theme-css-light", "--background-base:#fff;")
+ localStorage.setItem("opencode-theme-css-dark", "--background-base:#000;")
+
+ run()
+
+ expect(document.documentElement.dataset.theme).toBe("oc-2")
+ expect(document.documentElement.dataset.colorScheme).toBe("light")
+ expect(localStorage.getItem("opencode-theme-id")).toBe("oc-2")
+ expect(localStorage.getItem("opencode-theme-css-light")).toBeNull()
+ expect(localStorage.getItem("opencode-theme-css-dark")).toBeNull()
+ expect(document.getElementById("oc-theme-preload")).toBeNull()
+ })
+
+ test("keeps cached css for non-default themes", () => {
+ localStorage.setItem("opencode-theme-id", "nightowl")
+ localStorage.setItem("opencode-theme-css-light", "--background-base:#fff;")
+
+ run()
+
+ expect(document.documentElement.dataset.theme).toBe("nightowl")
+ expect(document.getElementById("oc-theme-preload")?.textContent).toContain("--background-base:#fff;")
+ })
+})
diff --git a/packages/app/src/utils/persist.test.ts b/packages/app/src/utils/persist.test.ts
index 2a2c349b755..673acd224d2 100644
--- a/packages/app/src/utils/persist.test.ts
+++ b/packages/app/src/utils/persist.test.ts
@@ -104,4 +104,12 @@ describe("persist localStorage resilience", () => {
const result = persistTesting.normalize({ value: "ok" }, '{"value":"\\x"}')
expect(result).toBeUndefined()
})
+
+ test("workspace storage sanitizes Windows filename characters", () => {
+ const result = persistTesting.workspaceStorage("C:\\Users\\foo")
+
+ expect(result).toStartWith("opencode.workspace.")
+ expect(result.endsWith(".dat")).toBeTrue()
+ expect(/[:\\/]/.test(result)).toBeFalse()
+ })
})
diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts
index 91c504742a9..bee2f3e7d1f 100644
--- a/packages/app/src/utils/persist.ts
+++ b/packages/app/src/utils/persist.ts
@@ -204,7 +204,7 @@ function normalize(defaults: unknown, raw: string, migrate?: (value: unknown) =>
}
function workspaceStorage(dir: string) {
- const head = dir.slice(0, 12) || "workspace"
+ const head = (dir.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-")
const sum = checksum(dir) ?? "0"
return `opencode.workspace.${head}.${sum}.dat`
}
@@ -300,6 +300,7 @@ export const PersistTesting = {
localStorageDirect,
localStorageWithPrefix,
normalize,
+ workspaceStorage,
}
export const Persist = {
diff --git a/packages/console/app/package.json b/packages/console/app/package.json
index 62474711f08..2cbc8f75756 100644
--- a/packages/console/app/package.json
+++ b/packages/console/app/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
- "version": "1.2.22",
+ "version": "1.2.24",
"type": "module",
"license": "MIT",
"scripts": {
diff --git a/packages/console/core/package.json b/packages/console/core/package.json
index b8cf0810475..209a0e2df36 100644
--- a/packages/console/core/package.json
+++ b/packages/console/core/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
- "version": "1.2.22",
+ "version": "1.2.24",
"private": true,
"type": "module",
"license": "MIT",
diff --git a/packages/console/function/package.json b/packages/console/function/package.json
index ed4cfed17a7..377ab97cf61 100644
--- a/packages/console/function/package.json
+++ b/packages/console/function/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
- "version": "1.2.22",
+ "version": "1.2.24",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json
index c41e66c051b..9a383f19fbe 100644
--- a/packages/console/mail/package.json
+++ b/packages/console/mail/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
- "version": "1.2.22",
+ "version": "1.2.24",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json
index 2b9ce92da4c..45fa7355f8a 100644
--- a/packages/desktop-electron/package.json
+++ b/packages/desktop-electron/package.json
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
- "version": "1.2.22",
+ "version": "1.2.24",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
index 8663cc8d581..b028c840bae 100644
--- a/packages/desktop/package.json
+++ b/packages/desktop/package.json
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
- "version": "1.2.22",
+ "version": "1.2.24",
"type": "module",
"license": "MIT",
"scripts": {
diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json
index 9807922a2cd..1b14e3f142e 100644
--- a/packages/enterprise/package.json
+++ b/packages/enterprise/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
- "version": "1.2.22",
+ "version": "1.2.24",
"private": true,
"type": "module",
"license": "MIT",
diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml
index ddc61ca4c6c..44cdeb9d941 100644
--- a/packages/extensions/zed/extension.toml
+++ b/packages/extensions/zed/extension.toml
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
-version = "1.2.22"
+version = "1.2.24"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.22/opencode-darwin-arm64.zip"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.24/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.22/opencode-darwin-x64.zip"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.24/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.22/opencode-linux-arm64.tar.gz"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.24/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.22/opencode-linux-x64.tar.gz"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.24/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.22/opencode-windows-x64.zip"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.24/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
diff --git a/packages/function/package.json b/packages/function/package.json
index 1bbb1dffde4..7b64af1f99e 100644
--- a/packages/function/package.json
+++ b/packages/function/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
- "version": "1.2.22",
+ "version": "1.2.24",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index 7a25100b0b7..c560315d33f 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
- "version": "1.2.22",
+ "version": "1.2.24",
"name": "opencode",
"type": "module",
"license": "MIT",
diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts
index 61bc609bb7c..5c6dd11f997 100644
--- a/packages/opencode/src/cli/cmd/run.ts
+++ b/packages/opencode/src/cli/cmd/run.ts
@@ -280,6 +280,11 @@ export const RunCommand = cmd({
type: "string",
describe: "attach to a running opencode server (e.g., http://localhost:4096)",
})
+ .option("password", {
+ alias: ["p"],
+ type: "string",
+ describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)",
+ })
.option("dir", {
type: "string",
describe: "directory to run in, path on remote server if attaching",
@@ -648,7 +653,14 @@ export const RunCommand = cmd({
}
if (args.attach) {
- const sdk = createOpencodeClient({ baseUrl: args.attach, directory })
+ const headers = (() => {
+ const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD
+ if (!password) return undefined
+ const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode"
+ const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
+ return { Authorization: auth }
+ })()
+ const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers })
return await execute(sdk)
}
diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx
index 3304d6be6a6..e939b831d5a 100644
--- a/packages/opencode/src/cli/cmd/tui/app.tsx
+++ b/packages/opencode/src/cli/cmd/tui/app.tsx
@@ -20,6 +20,7 @@ import { DialogHelp } from "./ui/dialog-help"
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list"
+import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
import { KeybindProvider } from "@tui/context/keybind"
import { ThemeProvider, useTheme } from "@tui/context/theme"
import { Home } from "@tui/routes/home"
@@ -371,6 +372,22 @@ function App() {
dialog.replace(() => )
},
},
+ ...(Flag.OPENCODE_EXPERIMENTAL_WORKSPACES_TUI
+ ? [
+ {
+ title: "Manage workspaces",
+ value: "workspace.list",
+ category: "Workspace",
+ suggested: true,
+ slash: {
+ name: "workspaces",
+ },
+ onSelect: () => {
+ dialog.replace(() => )
+ },
+ },
+ ]
+ : []),
{
title: "New session",
suggested: route.data.type === "session",
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx
new file mode 100644
index 00000000000..a25b20505c4
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx
@@ -0,0 +1,326 @@
+import { useDialog } from "@tui/ui/dialog"
+import { DialogSelect } from "@tui/ui/dialog-select"
+import { useRoute } from "@tui/context/route"
+import { useSync } from "@tui/context/sync"
+import { createEffect, createMemo, createSignal, onMount } from "solid-js"
+import type { Session } from "@opencode-ai/sdk/v2"
+import { useSDK } from "../context/sdk"
+import { useToast } from "../ui/toast"
+import { useKeybind } from "../context/keybind"
+import { DialogSessionList } from "./workspace/dialog-session-list"
+import { createOpencodeClient } from "@opencode-ai/sdk/v2"
+
+async function openWorkspace(input: {
+ dialog: ReturnType
+ route: ReturnType
+ sdk: ReturnType
+ sync: ReturnType
+ toast: ReturnType
+ workspaceID: string
+ forceCreate?: boolean
+}) {
+ const cacheSession = (session: Session) => {
+ input.sync.set(
+ "session",
+ [...input.sync.data.session.filter((item) => item.id !== session.id), session].toSorted((a, b) =>
+ a.id.localeCompare(b.id),
+ ),
+ )
+ }
+
+ const client = createOpencodeClient({
+ baseUrl: input.sdk.url,
+ fetch: input.sdk.fetch,
+ directory: input.sync.data.path.directory || input.sdk.directory,
+ experimental_workspaceID: input.workspaceID,
+ })
+ const listed = input.forceCreate ? undefined : await client.session.list({ roots: true, limit: 1 })
+ const session = listed?.data?.[0]
+ if (session?.id) {
+ cacheSession(session)
+ input.route.navigate({
+ type: "session",
+ sessionID: session.id,
+ })
+ input.dialog.clear()
+ return
+ }
+ let created: Session | undefined
+ while (!created) {
+ const result = await client.session.create({}).catch(() => undefined)
+ if (!result) {
+ input.toast.show({
+ message: "Failed to open workspace",
+ variant: "error",
+ })
+ return
+ }
+ if (result.response.status >= 500 && result.response.status < 600) {
+ await Bun.sleep(1000)
+ continue
+ }
+ if (!result.data) {
+ input.toast.show({
+ message: "Failed to open workspace",
+ variant: "error",
+ })
+ return
+ }
+ created = result.data
+ }
+ cacheSession(created)
+ input.route.navigate({
+ type: "session",
+ sessionID: created.id,
+ })
+ input.dialog.clear()
+}
+
+function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise }) {
+ const dialog = useDialog()
+ const sync = useSync()
+ const sdk = useSDK()
+ const toast = useToast()
+ const [creating, setCreating] = createSignal()
+
+ onMount(() => {
+ dialog.setSize("medium")
+ })
+
+ const options = createMemo(() => {
+ const type = creating()
+ if (type) {
+ return [
+ {
+ title: `Creating ${type} workspace...`,
+ value: "creating" as const,
+ description: "This can take a while for remote environments",
+ },
+ ]
+ }
+ return [
+ {
+ title: "Worktree",
+ value: "worktree" as const,
+ description: "Create a local git worktree",
+ },
+ ]
+ })
+
+ const createWorkspace = async (type: string) => {
+ if (creating()) return
+ setCreating(type)
+
+ const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => {
+ console.log(err)
+ return undefined
+ })
+ console.log(JSON.stringify(result, null, 2))
+ const workspace = result?.data
+ if (!workspace) {
+ setCreating(undefined)
+ toast.show({
+ message: "Failed to create workspace",
+ variant: "error",
+ })
+ return
+ }
+ await sync.workspace.sync()
+ await props.onSelect(workspace.id)
+ setCreating(undefined)
+ }
+
+ return (
+ {
+ if (option.value === "creating") return
+ void createWorkspace(option.value)
+ }}
+ />
+ )
+}
+
+export function DialogWorkspaceList() {
+ const dialog = useDialog()
+ const route = useRoute()
+ const sync = useSync()
+ const sdk = useSDK()
+ const toast = useToast()
+ const keybind = useKeybind()
+ const [toDelete, setToDelete] = createSignal()
+ const [counts, setCounts] = createSignal>({})
+
+ const open = (workspaceID: string, forceCreate?: boolean) =>
+ openWorkspace({
+ dialog,
+ route,
+ sdk,
+ sync,
+ toast,
+ workspaceID,
+ forceCreate,
+ })
+
+ async function selectWorkspace(workspaceID: string) {
+ if (workspaceID === "__local__") {
+ if (localCount() > 0) {
+ dialog.replace(() => )
+ return
+ }
+ route.navigate({
+ type: "home",
+ })
+ dialog.clear()
+ return
+ }
+ const count = counts()[workspaceID]
+ if (count && count > 0) {
+ dialog.replace(() => )
+ return
+ }
+
+ if (count === 0) {
+ await open(workspaceID)
+ return
+ }
+ const client = createOpencodeClient({
+ baseUrl: sdk.url,
+ fetch: sdk.fetch,
+ directory: sync.data.path.directory || sdk.directory,
+ experimental_workspaceID: workspaceID,
+ })
+ const listed = await client.session.list({ roots: true, limit: 1 }).catch(() => undefined)
+ if (listed?.data?.length) {
+ dialog.replace(() => )
+ return
+ }
+ await open(workspaceID)
+ }
+
+ const currentWorkspaceID = createMemo(() => {
+ if (route.data.type === "session") {
+ return sync.session.get(route.data.sessionID)?.workspaceID ?? "__local__"
+ }
+ return "__local__"
+ })
+
+ const localCount = createMemo(
+ () => sync.data.session.filter((session) => !session.workspaceID && !session.parentID).length,
+ )
+
+ let run = 0
+ createEffect(() => {
+ const workspaces = sync.data.workspaceList
+ const next = ++run
+ if (!workspaces.length) {
+ setCounts({})
+ return
+ }
+ setCounts(Object.fromEntries(workspaces.map((workspace) => [workspace.id, undefined])))
+ void Promise.all(
+ workspaces.map(async (workspace) => {
+ const client = createOpencodeClient({
+ baseUrl: sdk.url,
+ fetch: sdk.fetch,
+ directory: sync.data.path.directory || sdk.directory,
+ experimental_workspaceID: workspace.id,
+ })
+ const result = await client.session.list({ roots: true }).catch(() => undefined)
+ return [workspace.id, result ? (result.data?.length ?? 0) : null] as const
+ }),
+ ).then((entries) => {
+ if (run !== next) return
+ setCounts(Object.fromEntries(entries))
+ })
+ })
+
+ const options = createMemo(() => [
+ {
+ title: "Local",
+ value: "__local__",
+ category: "Workspace",
+ description: "Use the local machine",
+ footer: `${localCount()} session${localCount() === 1 ? "" : "s"}`,
+ },
+ ...sync.data.workspaceList.map((workspace) => {
+ const count = counts()[workspace.id]
+ return {
+ title:
+ toDelete() === workspace.id
+ ? `Delete ${workspace.id}? Press ${keybind.print("session_delete")} again`
+ : workspace.id,
+ value: workspace.id,
+ category: workspace.type,
+ description: workspace.branch ? `Branch ${workspace.branch}` : undefined,
+ footer:
+ count === undefined
+ ? "Loading sessions..."
+ : count === null
+ ? "Sessions unavailable"
+ : `${count} session${count === 1 ? "" : "s"}`,
+ }
+ }),
+ {
+ title: "+ New workspace",
+ value: "__create__",
+ category: "Actions",
+ description: "Create a new workspace",
+ },
+ ])
+
+ onMount(() => {
+ dialog.setSize("large")
+ void sync.workspace.sync()
+ })
+
+ return (
+ {
+ setToDelete(undefined)
+ }}
+ onSelect={(option) => {
+ setToDelete(undefined)
+ if (option.value === "__create__") {
+ dialog.replace(() => open(workspaceID, true)} />)
+ return
+ }
+ void selectWorkspace(option.value)
+ }}
+ keybind={[
+ {
+ keybind: keybind.all.session_delete?.[0],
+ title: "delete",
+ onTrigger: async (option) => {
+ if (option.value === "__create__" || option.value === "__local__") return
+ if (toDelete() !== option.value) {
+ setToDelete(option.value)
+ return
+ }
+ const result = await sdk.client.experimental.workspace.remove({ id: option.value }).catch(() => undefined)
+ setToDelete(undefined)
+ if (result?.error) {
+ toast.show({
+ message: "Failed to delete workspace",
+ variant: "error",
+ })
+ return
+ }
+ if (currentWorkspaceID() === option.value) {
+ route.navigate({
+ type: "home",
+ })
+ }
+ await sync.workspace.sync()
+ },
+ },
+ ]}
+ />
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
index d63c248fb83..77577b2a0ef 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
@@ -539,12 +539,25 @@ export function Prompt(props: PromptProps) {
promptModelWarning()
return
}
- const sessionID = props.sessionID
- ? props.sessionID
- : await (async () => {
- const sessionID = await sdk.client.session.create({}).then((x) => x.data!.id)
- return sessionID
- })()
+
+ let sessionID = props.sessionID
+ if (sessionID == null) {
+ const res = await sdk.client.session.create({})
+
+ if (res.error) {
+ console.log("Creating a session failed:", res.error)
+
+ toast.show({
+ message: "Creating a session failed. Open console for more details.",
+ variant: "error",
+ })
+
+ return
+ }
+
+ sessionID = res.data.id
+ }
+
const messageID = Identifier.ascending("message")
let inputText = store.prompt.input
diff --git a/packages/opencode/src/cli/cmd/tui/component/workspace/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/workspace/dialog-session-list.tsx
new file mode 100644
index 00000000000..326f094a56f
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/component/workspace/dialog-session-list.tsx
@@ -0,0 +1,151 @@
+import { useDialog } from "@tui/ui/dialog"
+import { DialogSelect } from "@tui/ui/dialog-select"
+import { useRoute } from "@tui/context/route"
+import { useSync } from "@tui/context/sync"
+import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
+import { Locale } from "@/util/locale"
+import { useKeybind } from "../../context/keybind"
+import { useTheme } from "../../context/theme"
+import { useSDK } from "../../context/sdk"
+import { DialogSessionRename } from "../dialog-session-rename"
+import { useKV } from "../../context/kv"
+import { createDebouncedSignal } from "../../util/signal"
+import { Spinner } from "../spinner"
+import { useToast } from "../../ui/toast"
+
+export function DialogSessionList(props: { workspaceID?: string; localOnly?: boolean } = {}) {
+ const dialog = useDialog()
+ const route = useRoute()
+ const sync = useSync()
+ const keybind = useKeybind()
+ const { theme } = useTheme()
+ const sdk = useSDK()
+ const kv = useKV()
+ const toast = useToast()
+ const [toDelete, setToDelete] = createSignal()
+ const [search, setSearch] = createDebouncedSignal("", 150)
+
+ const [listed, listedActions] = createResource(
+ () => props.workspaceID,
+ async (workspaceID) => {
+ if (!workspaceID) return undefined
+ const result = await sdk.client.session.list({ roots: true })
+ return result.data ?? []
+ },
+ )
+
+ const [searchResults] = createResource(search, async (query) => {
+ if (!query || props.localOnly) return undefined
+ const result = await sdk.client.session.list({
+ search: query,
+ limit: 30,
+ ...(props.workspaceID ? { roots: true } : {}),
+ })
+ return result.data ?? []
+ })
+
+ const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
+
+ const sessions = createMemo(() => {
+ if (searchResults()) return searchResults()!
+ if (props.workspaceID) return listed() ?? []
+ if (props.localOnly) return sync.data.session.filter((session) => !session.workspaceID)
+ return sync.data.session
+ })
+
+ const options = createMemo(() => {
+ const today = new Date().toDateString()
+ return sessions()
+ .filter((x) => {
+ if (x.parentID !== undefined) return false
+ if (props.workspaceID && listed()) return true
+ if (props.workspaceID) return x.workspaceID === props.workspaceID
+ if (props.localOnly) return !x.workspaceID
+ return true
+ })
+ .toSorted((a, b) => b.time.updated - a.time.updated)
+ .map((x) => {
+ const date = new Date(x.time.updated)
+ let category = date.toDateString()
+ if (category === today) {
+ category = "Today"
+ }
+ const isDeleting = toDelete() === x.id
+ const status = sync.data.session_status?.[x.id]
+ const isWorking = status?.type === "busy"
+ return {
+ title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title,
+ bg: isDeleting ? theme.error : undefined,
+ value: x.id,
+ category,
+ footer: Locale.time(x.time.updated),
+ gutter: isWorking ? : undefined,
+ }
+ })
+ })
+
+ onMount(() => {
+ dialog.setSize("large")
+ })
+
+ return (
+ {
+ setToDelete(undefined)
+ }}
+ onSelect={(option) => {
+ route.navigate({
+ type: "session",
+ sessionID: option.value,
+ })
+ dialog.clear()
+ }}
+ keybind={[
+ {
+ keybind: keybind.all.session_delete?.[0],
+ title: "delete",
+ onTrigger: async (option) => {
+ if (toDelete() === option.value) {
+ const deleted = await sdk.client.session
+ .delete({
+ sessionID: option.value,
+ })
+ .then(() => true)
+ .catch(() => false)
+ setToDelete(undefined)
+ if (!deleted) {
+ toast.show({
+ message: "Failed to delete session",
+ variant: "error",
+ })
+ return
+ }
+ if (props.workspaceID) {
+ listedActions.mutate((sessions) => sessions?.filter((session) => session.id !== option.value))
+ return
+ }
+ sync.set(
+ "session",
+ sync.data.session.filter((session) => session.id !== option.value),
+ )
+ return
+ }
+ setToDelete(option.value)
+ },
+ },
+ {
+ keybind: keybind.all.session_rename?.[0],
+ title: "rename",
+ onTrigger: async (option) => {
+ dialog.replace(() => )
+ },
+ },
+ ]}
+ />
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx
index 7fa7e05c3d2..2403a4e938b 100644
--- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx
@@ -5,6 +5,7 @@ import { batch, onCleanup, onMount } from "solid-js"
export type EventSource = {
on: (handler: (event: Event) => void) => () => void
+ setWorkspace?: (workspaceID?: string) => void
}
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
@@ -17,13 +18,21 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
events?: EventSource
}) => {
const abort = new AbortController()
- const sdk = createOpencodeClient({
- baseUrl: props.url,
- signal: abort.signal,
- directory: props.directory,
- fetch: props.fetch,
- headers: props.headers,
- })
+ let workspaceID: string | undefined
+ let sse: AbortController | undefined
+
+ function createSDK() {
+ return createOpencodeClient({
+ baseUrl: props.url,
+ signal: abort.signal,
+ directory: props.directory,
+ fetch: props.fetch,
+ headers: props.headers,
+ experimental_workspaceID: workspaceID,
+ })
+ }
+
+ let sdk = createSDK()
const emitter = createGlobalEmitter<{
[key in Event["type"]]: Extract
@@ -61,41 +70,56 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
flush()
}
- onMount(async () => {
- // If an event source is provided, use it instead of SSE
- if (props.events) {
- const unsub = props.events.on(handleEvent)
- onCleanup(unsub)
- return
- }
+ function startSSE() {
+ sse?.abort()
+ const ctrl = new AbortController()
+ sse = ctrl
+ ;(async () => {
+ while (true) {
+ if (abort.signal.aborted || ctrl.signal.aborted) break
+ const events = await sdk.event.subscribe({}, { signal: ctrl.signal })
- // Fall back to SSE
- while (true) {
- if (abort.signal.aborted) break
- const events = await sdk.event.subscribe(
- {},
- {
- signal: abort.signal,
- },
- )
+ for await (const event of events.stream) {
+ if (ctrl.signal.aborted) break
+ handleEvent(event)
+ }
- for await (const event of events.stream) {
- handleEvent(event)
+ if (timer) clearTimeout(timer)
+ if (queue.length > 0) flush()
}
+ })().catch(() => {})
+ }
- // Flush any remaining events
- if (timer) clearTimeout(timer)
- if (queue.length > 0) {
- flush()
- }
+ onMount(() => {
+ if (props.events) {
+ const unsub = props.events.on(handleEvent)
+ onCleanup(unsub)
+ } else {
+ startSSE()
}
})
onCleanup(() => {
abort.abort()
+ sse?.abort()
if (timer) clearTimeout(timer)
})
- return { client: sdk, event: emitter, url: props.url }
+ return {
+ get client() {
+ return sdk
+ },
+ directory: props.directory,
+ event: emitter,
+ fetch: props.fetch ?? fetch,
+ setWorkspace(next?: string) {
+ if (workspaceID === next) return
+ workspaceID = next
+ sdk = createSDK()
+ props.events?.setWorkspace?.(next)
+ if (!props.events) startSSE()
+ },
+ url: props.url,
+ }
},
})
diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
index 269ed7ae0bd..3b296a927aa 100644
--- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
@@ -28,6 +28,7 @@ import { useArgs } from "./args"
import { batch, onMount } from "solid-js"
import { Log } from "@/util/log"
import type { Path } from "@opencode-ai/sdk"
+import type { Workspace } from "@opencode-ai/sdk/v2"
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
@@ -73,6 +74,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
formatter: FormatterStatus[]
vcs: VcsInfo | undefined
path: Path
+ workspaceList: Workspace[]
}>({
provider_next: {
all: [],
@@ -100,10 +102,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
formatter: [],
vcs: undefined,
path: { state: "", config: "", worktree: "", directory: "" },
+ workspaceList: [],
})
const sdk = useSDK()
+ async function syncWorkspaces() {
+ const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
+ if (!result?.data) return
+ setStore("workspaceList", reconcile(result.data))
+ }
+
sdk.event.listen((e) => {
const event = e.details
switch (event.type) {
@@ -413,6 +422,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
sdk.client.provider.auth().then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))),
sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))),
+ syncWorkspaces(),
]).then(() => {
setStore("status", "complete")
})
@@ -481,6 +491,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
fullSyncedSessions.add(sessionID)
},
},
+ workspace: {
+ get(workspaceID: string) {
+ return store.workspaceList.find((workspace) => workspace.id === workspaceID)
+ },
+ sync: syncWorkspaces,
+ },
bootstrap,
}
return result
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx
index 0c5ea9a8572..49b2d61091f 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx
@@ -7,6 +7,7 @@ import { SplitBorder } from "@tui/component/border"
import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2"
import { useCommandDialog } from "@tui/component/dialog-command"
import { useKeybind } from "../../context/keybind"
+import { Flag } from "@/flag/flag"
import { useTerminalDimensions } from "@opentui/solid"
const Title = (props: { session: Accessor }) => {
@@ -29,6 +30,17 @@ const ContextInfo = (props: { context: Accessor; cost: Acces
)
}
+const WorkspaceInfo = (props: { workspace: Accessor }) => {
+ const { theme } = useTheme()
+ return (
+
+
+ {props.workspace()}
+
+
+ )
+}
+
export function Header() {
const route = useRouteData("session")
const sync = useSync()
@@ -59,6 +71,14 @@ export function Header() {
return result
})
+ const workspace = createMemo(() => {
+ const id = session()?.workspaceID
+ if (!id) return "Workspace local"
+ const info = sync.workspace.get(id)
+ if (!info) return `Workspace ${id}`
+ return `Workspace ${id} (${info.type})`
+ })
+
const { theme } = useTheme()
const keybind = useKeybind()
const command = useCommandDialog()
@@ -83,9 +103,19 @@ export function Header() {
-
- Subagent session
-
+ {Flag.OPENCODE_EXPERIMENTAL_WORKSPACES_TUI ? (
+
+
+ Subagent session
+
+
+
+ ) : (
+
+ Subagent session
+
+ )}
+
@@ -124,7 +154,14 @@ export function Header() {
-
+ {Flag.OPENCODE_EXPERIMENTAL_WORKSPACES_TUI ? (
+
+
+
+
+ ) : (
+
+ )}
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index d3a4ff81e01..5358b61ef33 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -182,6 +182,12 @@ export function Session() {
return new CustomSpeedScroll(3)
})
+ createEffect(() => {
+ if (session()?.workspaceID) {
+ sdk.setWorkspace(session()?.workspaceID)
+ }
+ })
+
createEffect(async () => {
await sync.session
.sync(route.sessionID)
diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts
index 14a9c88731f..6e787c7afdd 100644
--- a/packages/opencode/src/cli/cmd/tui/thread.ts
+++ b/packages/opencode/src/cli/cmd/tui/thread.ts
@@ -42,6 +42,9 @@ function createWorkerFetch(client: RpcClient): typeof fetch {
function createEventSource(client: RpcClient): EventSource {
return {
on: (handler) => client.on("event", handler),
+ setWorkspace: (workspaceID) => {
+ void client.call("setWorkspace", { workspaceID })
+ },
}
}
diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts
index 4452d6d764a..f8dcee78a05 100644
--- a/packages/opencode/src/cli/cmd/tui/worker.ts
+++ b/packages/opencode/src/cli/cmd/tui/worker.ts
@@ -44,7 +44,7 @@ const eventStream = {
abort: undefined as AbortController | undefined,
}
-const startEventStream = (directory: string) => {
+const startEventStream = (input: { directory: string; workspaceID?: string }) => {
if (eventStream.abort) eventStream.abort.abort()
const abort = new AbortController()
eventStream.abort = abort
@@ -59,7 +59,8 @@ const startEventStream = (directory: string) => {
const sdk = createOpencodeClient({
baseUrl: "http://opencode.internal",
- directory,
+ directory: input.directory,
+ experimental_workspaceID: input.workspaceID,
fetch: fetchFn,
signal,
})
@@ -95,7 +96,7 @@ const startEventStream = (directory: string) => {
})
}
-startEventStream(process.cwd())
+startEventStream({ directory: process.cwd() })
export const rpc = {
async fetch(input: { url: string; method: string; headers: Record; body?: string }) {
@@ -135,6 +136,9 @@ export const rpc = {
Config.global.reset()
await Instance.disposeAll()
},
+ async setWorkspace(input: { workspaceID?: string }) {
+ startEventStream({ directory: process.cwd(), workspaceID: input.workspaceID })
+ },
async shutdown() {
Log.Default.info("worker shutting down")
if (eventStream.abort) eventStream.abort.abort()
diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts
index 6b2b16c67be..7c48e99f47f 100644
--- a/packages/opencode/src/flag/flag.ts
+++ b/packages/opencode/src/flag/flag.ts
@@ -57,6 +57,8 @@ export namespace Flag {
export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL")
export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK")
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
+ export const OPENCODE_EXPERIMENTAL_WORKSPACES_TUI =
+ OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES_TUI")
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts
index e09fbc97fe3..405c6728537 100644
--- a/packages/opencode/src/lsp/server.ts
+++ b/packages/opencode/src/lsp/server.ts
@@ -1130,7 +1130,30 @@ export namespace LSPServer {
export const JDTLS: Info = {
id: "jdtls",
- root: NearestRoot(["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"]),
+ root: async (file) => {
+ // Without exclusions, NearestRoot defaults to instance directory so we can't
+ // distinguish between a) no project found and b) project found at instance dir.
+ // So we can't choose the root from (potential) monorepo markers first.
+ // Look for potential subproject markers first while excluding potential monorepo markers.
+ const settingsMarkers = ["settings.gradle", "settings.gradle.kts"]
+ const gradleMarkers = ["gradlew", "gradlew.bat"]
+ const exclusionsForMonorepos = gradleMarkers.concat(settingsMarkers)
+
+ const [projectRoot, wrapperRoot, settingsRoot] = await Promise.all([
+ NearestRoot(
+ ["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"],
+ exclusionsForMonorepos,
+ )(file),
+ NearestRoot(gradleMarkers, settingsMarkers)(file),
+ NearestRoot(settingsMarkers)(file),
+ ])
+
+ // If projectRoot is undefined we know we are in a monorepo or no project at all.
+ // So can safely fall through to the other roots
+ if (projectRoot) return projectRoot
+ if (wrapperRoot) return wrapperRoot
+ if (settingsRoot) return settingsRoot
+ },
extensions: [".java"],
async spawn(root) {
const java = which("java")
diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts
index b4836ae047d..09035d272c9 100644
--- a/packages/opencode/src/provider/provider.ts
+++ b/packages/opencode/src/provider/provider.ts
@@ -480,6 +480,7 @@ export namespace Provider {
const aiGatewayHeaders = {
"User-Agent": `opencode/${Installation.VERSION} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
+ "anthropic-beta": "context-1m-2025-08-07",
...(providerConfig?.options?.aiGatewayHeaders || {}),
}
@@ -1288,12 +1289,6 @@ export namespace Provider {
}
}
- // Check if opencode provider is available before using it
- const opencodeProvider = await state().then((state) => state.providers["opencode"])
- if (opencodeProvider && opencodeProvider.models["gpt-5-nano"]) {
- return getModel("opencode", "gpt-5-nano")
- }
-
return undefined
}
diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts
index 6980be05188..471da03cbce 100644
--- a/packages/opencode/src/provider/transform.ts
+++ b/packages/opencode/src/provider/transform.ts
@@ -440,7 +440,9 @@ export namespace ProviderTransform {
const copilotEfforts = iife(() => {
if (id.includes("5.1-codex-max") || id.includes("5.2") || id.includes("5.3"))
return [...WIDELY_SUPPORTED_EFFORTS, "xhigh"]
- return WIDELY_SUPPORTED_EFFORTS
+ const arr = [...WIDELY_SUPPORTED_EFFORTS]
+ if (id.includes("gpt-5") && model.release_date >= "2025-12-04") arr.push("xhigh")
+ return arr
})
return Object.fromEntries(
copilotEfforts.map((effort) => [
diff --git a/packages/opencode/test/provider/gitlab-duo.test.ts b/packages/opencode/test/provider/gitlab-duo.test.ts
index c512a45909d..86e08a79284 100644
--- a/packages/opencode/test/provider/gitlab-duo.test.ts
+++ b/packages/opencode/test/provider/gitlab-duo.test.ts
@@ -198,6 +198,30 @@ test("GitLab Duo: config apiKey takes precedence over environment variable", asy
})
})
+test("GitLab Duo: includes context-1m beta header in aiGatewayHeaders", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ }),
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ init: async () => {
+ Env.set("GITLAB_TOKEN", "test-token")
+ },
+ fn: async () => {
+ const providers = await Provider.list()
+ expect(providers["gitlab"]).toBeDefined()
+ expect(providers["gitlab"].options?.aiGatewayHeaders?.["anthropic-beta"]).toContain("context-1m-2025-08-07")
+ },
+ })
+})
+
test("GitLab Duo: supports feature flags configuration", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts
index 2329846351c..512819a6577 100644
--- a/packages/opencode/test/provider/transform.test.ts
+++ b/packages/opencode/test/provider/transform.test.ts
@@ -2002,6 +2002,35 @@ describe("ProviderTransform.variants", () => {
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh"])
})
+
+ test("gpt-5.3-codex includes xhigh", () => {
+ const model = createMockModel({
+ id: "gpt-5.3-codex",
+ providerID: "github-copilot",
+ api: {
+ id: "gpt-5.3-codex",
+ url: "https://api.githubcopilot.com",
+ npm: "@ai-sdk/github-copilot",
+ },
+ })
+ const result = ProviderTransform.variants(model)
+ expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh"])
+ })
+
+ test("gpt-5.4 includes xhigh", () => {
+ const model = createMockModel({
+ id: "gpt-5.4",
+ release_date: "2026-03-05",
+ providerID: "github-copilot",
+ api: {
+ id: "gpt-5.4",
+ url: "https://api.githubcopilot.com",
+ npm: "@ai-sdk/github-copilot",
+ },
+ })
+ const result = ProviderTransform.variants(model)
+ expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh"])
+ })
})
describe("@ai-sdk/cerebras", () => {
diff --git a/packages/plugin/package.json b/packages/plugin/package.json
index cb6640b5d3f..3f1f6af95f6 100644
--- a/packages/plugin/package.json
+++ b/packages/plugin/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
- "version": "1.2.22",
+ "version": "1.2.24",
"type": "module",
"license": "MIT",
"scripts": {
diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json
index 19d50e85cd5..7281174c0f3 100644
--- a/packages/sdk/js/package.json
+++ b/packages/sdk/js/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
- "version": "1.2.22",
+ "version": "1.2.24",
"type": "module",
"license": "MIT",
"scripts": {
diff --git a/packages/sdk/js/src/v2/client.ts b/packages/sdk/js/src/v2/client.ts
index 8685be52d6a..ad956dd4b3c 100644
--- a/packages/sdk/js/src/v2/client.ts
+++ b/packages/sdk/js/src/v2/client.ts
@@ -5,7 +5,7 @@ import { type Config } from "./gen/client/types.gen.js"
import { OpencodeClient } from "./gen/sdk.gen.js"
export { type Config as OpencodeClientConfig, OpencodeClient }
-export function createOpencodeClient(config?: Config & { directory?: string }) {
+export function createOpencodeClient(config?: Config & { directory?: string; experimental_workspaceID?: string }) {
if (!config?.fetch) {
const customFetch: any = (req: any) => {
// @ts-ignore
@@ -27,6 +27,13 @@ export function createOpencodeClient(config?: Config & { directory?: string }) {
}
}
+ if (config?.experimental_workspaceID) {
+ config.headers = {
+ ...config.headers,
+ "x-opencode-workspace": config.experimental_workspaceID,
+ }
+ }
+
const client = createClient(config)
return new OpencodeClient({ client })
}
diff --git a/packages/slack/package.json b/packages/slack/package.json
index b5c02a45ff4..4ed7386e8a9 100644
--- a/packages/slack/package.json
+++ b/packages/slack/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
- "version": "1.2.22",
+ "version": "1.2.24",
"type": "module",
"license": "MIT",
"scripts": {
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 4022deb4aed..06c699f24ea 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
- "version": "1.2.22",
+ "version": "1.2.24",
"type": "module",
"license": "MIT",
"exports": {
@@ -48,11 +48,8 @@
"@pierre/diffs": "catalog:",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/bounds": "0.1.3",
- "@solid-primitives/lifecycle": "0.1.2",
"@solid-primitives/media": "2.3.3",
- "@solid-primitives/page-visibility": "2.1.1",
"@solid-primitives/resize-observer": "2.1.3",
- "@solid-primitives/rootless": "1.5.2",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"dompurify": "3.3.1",
diff --git a/packages/ui/src/components/animated-number.css b/packages/ui/src/components/animated-number.css
index b69ce65084b..022b347e968 100644
--- a/packages/ui/src/components/animated-number.css
+++ b/packages/ui/src/components/animated-number.css
@@ -9,20 +9,19 @@
display: inline-flex;
flex-direction: row-reverse;
align-items: baseline;
- justify-content: flex-start;
+ justify-content: flex-end;
line-height: inherit;
width: var(--animated-number-width, 1ch);
- overflow: clip;
- transition: width var(--tool-motion-spring-ms, 800ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
+ overflow: hidden;
+ transition: width var(--tool-motion-spring-ms, 560ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
}
[data-slot="animated-number-digit"] {
display: inline-block;
- flex-shrink: 0;
width: 1ch;
height: 1em;
line-height: 1em;
- overflow: clip;
+ overflow: hidden;
vertical-align: baseline;
-webkit-mask-image: linear-gradient(
to bottom,
@@ -47,7 +46,7 @@
flex-direction: column;
transform: translateY(calc(var(--animated-number-offset, 10) * -1em));
transition-property: transform;
- transition-duration: var(--animated-number-duration, 600ms);
+ transition-duration: var(--animated-number-duration, 560ms);
transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
}
diff --git a/packages/ui/src/components/animated-number.tsx b/packages/ui/src/components/animated-number.tsx
index dfe368b8bbf..b5fceba2563 100644
--- a/packages/ui/src/components/animated-number.tsx
+++ b/packages/ui/src/components/animated-number.tsx
@@ -1,7 +1,7 @@
import { For, Index, createEffect, createMemo, createSignal, on } from "solid-js"
const TRACK = Array.from({ length: 30 }, (_, index) => index % 10)
-const DURATION = 800
+const DURATION = 600
function normalize(value: number) {
return ((value % 10) + 10) % 10
@@ -90,35 +90,10 @@ export function AnimatedNumber(props: { value: number; class?: string }) {
)
const width = createMemo(() => `${digits().length}ch`)
- const [exitingDigits, setExitingDigits] = createSignal([])
- let exitTimer: number | undefined
-
- createEffect(
- on(
- digits,
- (current, prev) => {
- if (prev && current.length < prev.length) {
- setExitingDigits(prev.slice(current.length))
- clearTimeout(exitTimer)
- exitTimer = window.setTimeout(() => setExitingDigits([]), DURATION)
- } else {
- clearTimeout(exitTimer)
- setExitingDigits([])
- }
- },
- { defer: true },
- ),
- )
-
- const displayDigits = createMemo(() => {
- const exiting = exitingDigits()
- return exiting.length ? [...digits(), ...exiting] : digits()
- })
-
return (
- {(digit) => }
+ {(digit) => }
)
diff --git a/packages/ui/src/components/basic-tool.css b/packages/ui/src/components/basic-tool.css
index ad25bef32b4..1dbfce26ec5 100644
--- a/packages/ui/src/components/basic-tool.css
+++ b/packages/ui/src/components/basic-tool.css
@@ -8,28 +8,54 @@
justify-content: flex-start;
[data-slot="basic-tool-tool-trigger-content"] {
- width: 100%;
- min-width: 0;
+ width: auto;
display: flex;
align-items: center;
align-self: stretch;
gap: 8px;
}
+ [data-slot="basic-tool-tool-indicator"] {
+ width: 16px;
+ height: 16px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+
+ [data-component="spinner"] {
+ width: 16px;
+ height: 16px;
+ }
+ }
+
+ [data-slot="basic-tool-tool-spinner"] {
+ width: 16px;
+ height: 16px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ color: var(--text-weak);
+
+ [data-component="spinner"] {
+ width: 16px;
+ height: 16px;
+ }
+ }
+
[data-slot="icon-svg"] {
flex-shrink: 0;
}
[data-slot="basic-tool-tool-info"] {
- flex: 1 1 auto;
+ flex: 0 1 auto;
min-width: 0;
font-size: 14px;
}
[data-slot="basic-tool-tool-info-structured"] {
width: auto;
- max-width: 100%;
- min-width: 0;
display: flex;
align-items: center;
gap: 8px;
@@ -37,12 +63,11 @@
}
[data-slot="basic-tool-tool-info-main"] {
- flex: 0 1 auto;
display: flex;
- align-items: center;
+ align-items: baseline;
gap: 8px;
min-width: 0;
- overflow: clip;
+ overflow: hidden;
}
[data-slot="basic-tool-tool-title"] {
@@ -54,14 +79,22 @@
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-strong);
+
+ &.capitalize {
+ text-transform: capitalize;
+ }
+
+ &.agent-title {
+ color: var(--text-strong);
+ font-weight: var(--font-weight-medium);
+ }
}
[data-slot="basic-tool-tool-subtitle"] {
- display: inline-block;
- flex: 0 1 auto;
- max-width: 100%;
+ flex-shrink: 1;
min-width: 0;
- overflow: clip;
+ overflow: hidden;
+ text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-family-sans);
font-variant-numeric: tabular-nums;
@@ -106,7 +139,8 @@
[data-slot="basic-tool-tool-arg"] {
flex-shrink: 1;
min-width: 0;
- overflow: clip;
+ overflow: hidden;
+ text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-family-sans);
font-variant-numeric: tabular-nums;
diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx
index 3210b487019..4ad91824da1 100644
--- a/packages/ui/src/components/basic-tool.tsx
+++ b/packages/ui/src/components/basic-tool.tsx
@@ -1,20 +1,8 @@
-import {
- createEffect,
- createSignal,
- For,
- Match,
- on,
- onCleanup,
- onMount,
- Show,
- splitProps,
- Switch,
- type JSX,
-} from "solid-js"
-import { animate, type AnimationPlaybackControls, tunableSpringValue, COLLAPSIBLE_SPRING } from "./motion"
+import { createEffect, createSignal, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js"
+import { animate, type AnimationPlaybackControls } from "motion"
import { Collapsible } from "./collapsible"
+import type { IconProps } from "./icon"
import { TextShimmer } from "./text-shimmer"
-import { hold } from "./tool-utils"
export type TriggerTitle = {
title: string
@@ -32,99 +20,26 @@ const isTriggerTitle = (val: any): val is TriggerTitle => {
)
}
-interface ToolCallPanelBaseProps {
- icon: string
+export interface BasicToolProps {
+ icon: IconProps["name"]
trigger: TriggerTitle | JSX.Element
children?: JSX.Element
status?: string
- animate?: boolean
hideDetails?: boolean
defaultOpen?: boolean
forceOpen?: boolean
defer?: boolean
locked?: boolean
- watchDetails?: boolean
- springContent?: boolean
+ animated?: boolean
onSubtitleClick?: () => void
}
-function ToolCallTriggerBody(props: {
- trigger: TriggerTitle | JSX.Element
- pending: boolean
- onSubtitleClick?: () => void
- arrow?: boolean
-}) {
- return (
-
-
-
-
-
- {(trigger) => (
-
-
-
-
-
-
-
- {
- if (!props.onSubtitleClick) return
- e.stopPropagation()
- props.onSubtitleClick()
- }}
- >
- {trigger().subtitle}
-
-
-
-
- {(arg) => (
-
- {arg}
-
- )}
-
-
-
-
-
{trigger().action}
-
- )}
-
- {props.trigger as JSX.Element}
-
-
-
-
-
-
-
- )
-}
+const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 }
-function ToolCallPanel(props: ToolCallPanelBaseProps) {
+export function BasicTool(props: BasicToolProps) {
const [open, setOpen] = createSignal(props.defaultOpen ?? false)
const [ready, setReady] = createSignal(open())
- const pendingRaw = () => props.status === "pending" || props.status === "running"
- const pending = hold(pendingRaw, 1000)
- const watchDetails = () => props.watchDetails !== false
+ const pending = () => props.status === "pending" || props.status === "running"
let frame: number | undefined
@@ -144,7 +59,7 @@ function ToolCallPanel(props: ToolCallPanelBaseProps) {
on(
open,
(value) => {
- if (!props.defer || props.springContent) return
+ if (!props.defer) return
if (!value) {
cancel()
setReady(false)
@@ -162,110 +77,36 @@ function ToolCallPanel(props: ToolCallPanelBaseProps) {
),
)
- // Animated content height — single springValue drives all height changes
+ // Animated height for collapsible open/close
let contentRef: HTMLDivElement | undefined
- let bodyRef: HTMLDivElement | undefined
- let fadeAnim: AnimationPlaybackControls | undefined
- let observer: ResizeObserver | undefined
- let resizeFrame: number | undefined
+ let heightAnim: AnimationPlaybackControls | undefined
const initialOpen = open()
- const heightSpring = tunableSpringValue(0, COLLAPSIBLE_SPRING)
-
- const read = () => Math.max(0, Math.ceil(bodyRef?.getBoundingClientRect().height ?? 0))
-
- const doOpen = () => {
- if (!contentRef || !bodyRef) return
- contentRef.style.display = ""
- // Ensure fade starts from 0 if content was hidden (first open or after close cleared styles)
- if (bodyRef.style.opacity === "") {
- bodyRef.style.opacity = "0"
- bodyRef.style.filter = "blur(2px)"
- }
- const next = read()
- fadeAnim?.stop()
- fadeAnim = animate(bodyRef, { opacity: 1, filter: "blur(0px)" }, COLLAPSIBLE_SPRING)
- fadeAnim.finished.then(() => {
- if (!bodyRef) return
- bodyRef.style.opacity = ""
- bodyRef.style.filter = ""
- })
- heightSpring.set(next)
- }
-
- const doClose = () => {
- if (!contentRef || !bodyRef) return
- fadeAnim?.stop()
- fadeAnim = animate(bodyRef, { opacity: 0, filter: "blur(2px)" }, COLLAPSIBLE_SPRING)
- fadeAnim.finished.then(() => {
- if (!contentRef || open()) return
- contentRef.style.display = "none"
- })
- heightSpring.set(0)
- }
-
- const grow = () => {
- if (!contentRef || !open()) return
- const next = read()
- if (Math.abs(next - heightSpring.get()) < 1) return
- heightSpring.set(next)
- }
-
- onMount(() => {
- if (!props.springContent || props.animate === false || !contentRef || !bodyRef) return
-
- const offChange = heightSpring.on("change", (v) => {
- if (!contentRef) return
- contentRef.style.height = `${Math.max(0, Math.ceil(v))}px`
- })
- onCleanup(() => {
- offChange()
- })
-
- if (watchDetails()) {
- observer = new ResizeObserver(() => {
- if (resizeFrame !== undefined) return
- resizeFrame = requestAnimationFrame(() => {
- resizeFrame = undefined
- grow()
- })
- })
- observer.observe(bodyRef)
- }
-
- if (!open()) return
- if (contentRef.style.display !== "none") {
- const next = read()
- heightSpring.jump(next)
- contentRef.style.height = `${next}px`
- return
- }
- let mountFrame: number | undefined = requestAnimationFrame(() => {
- mountFrame = undefined
- if (!open()) return
- doOpen()
- })
- onCleanup(() => {
- if (mountFrame !== undefined) cancelAnimationFrame(mountFrame)
- })
- })
createEffect(
on(
open,
(isOpen) => {
- if (!props.springContent || props.animate === false || !contentRef) return
- if (isOpen) doOpen()
- else doClose()
+ if (!props.animated || !contentRef) return
+ heightAnim?.stop()
+ if (isOpen) {
+ contentRef.style.overflow = "hidden"
+ heightAnim = animate(contentRef, { height: "auto" }, SPRING)
+ heightAnim.finished.then(() => {
+ if (!contentRef || !open()) return
+ contentRef.style.overflow = "visible"
+ contentRef.style.height = "auto"
+ })
+ } else {
+ contentRef.style.overflow = "hidden"
+ heightAnim = animate(contentRef, { height: "0px" }, SPRING)
+ }
},
{ defer: true },
),
)
onCleanup(() => {
- if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
- observer?.disconnect()
- fadeAnim?.stop()
- heightSpring.destroy()
+ heightAnim?.stop()
})
const handleOpenChange = (value: boolean) => {
@@ -277,34 +118,85 @@ function ToolCallPanel(props: ToolCallPanelBaseProps) {
return (
-
+
+
+
+
+
+ {(trigger) => (
+
+
+
+
+
+
+
+ {
+ if (props.onSubtitleClick) {
+ e.stopPropagation()
+ props.onSubtitleClick()
+ }
+ }}
+ >
+ {trigger().subtitle}
+
+
+
+
+ {(arg) => (
+
+ {arg}
+
+ )}
+
+
+
+
+
{trigger().action}
+
+ )}
+
+ {props.trigger as JSX.Element}
+
+
+
+
+
+
+
-
+
-
- {props.children}
-
+ {props.children}
-
+
-
- {props.children}
-
+ {props.children}
@@ -330,60 +222,6 @@ function args(input: Record | undefined) {
.slice(0, 3)
}
-export interface ToolCallRowProps {
- variant: "row"
- icon: string
- trigger: TriggerTitle | JSX.Element
- status?: string
- animate?: boolean
- onSubtitleClick?: () => void
- open?: boolean
- showArrow?: boolean
- onOpenChange?: (value: boolean) => void
-}
-export interface ToolCallPanelProps extends Omit {
- variant: "panel"
-}
-export type ToolCallProps = ToolCallRowProps | ToolCallPanelProps
-function ToolCallRoot(props: ToolCallProps) {
- const pending = () => props.status === "pending" || props.status === "running"
- if (props.variant === "row") {
- return (
-
-
-
-
-
- }
- >
- {(onOpenChange) => (
-
-
-
-
-
- )}
-
- )
- }
-
- const [, rest] = splitProps(props, ["variant"])
- return
-}
-export const ToolCall = ToolCallRoot
-
export function GenericTool(props: {
tool: string
status?: string
@@ -391,8 +229,7 @@ export function GenericTool(props: {
input?: Record
}) {
return (
-
)
}
diff --git a/packages/ui/src/components/collapsible.css b/packages/ui/src/components/collapsible.css
index 1a86338bdc8..bab2c4f9269 100644
--- a/packages/ui/src/components/collapsible.css
+++ b/packages/ui/src/components/collapsible.css
@@ -8,18 +8,14 @@
border-radius: var(--radius-md);
overflow: visible;
- &.tool-collapsible [data-slot="collapsible-trigger"] {
- height: 37px;
- }
-
- &.tool-collapsible [data-slot="basic-tool-content-inner"] {
- padding-top: 0;
+ &.tool-collapsible {
+ gap: 8px;
}
[data-slot="collapsible-trigger"] {
width: 100%;
display: flex;
- height: 36px;
+ height: 32px;
padding: 0;
align-items: center;
align-self: stretch;
@@ -27,17 +23,6 @@
user-select: none;
color: var(--text-base);
- > [data-component="tool-trigger"][data-arrow] {
- width: auto;
- max-width: 100%;
- flex: 0 1 auto;
-
- [data-slot="basic-tool-tool-trigger-content"] {
- width: auto;
- max-width: 100%;
- }
- }
-
[data-slot="collapsible-arrow"] {
opacity: 0;
transition: opacity 0.15s ease;
@@ -65,6 +50,9 @@
line-height: var(--line-height-large); /* 166.667% */
letter-spacing: var(--letter-spacing-normal);
+ /* &:hover { */
+ /* background-color: var(--surface-base); */
+ /* } */
&:focus-visible {
outline: none;
background-color: var(--surface-raised-base-hover);
@@ -94,16 +82,16 @@
}
[data-slot="collapsible-content"] {
- overflow: clip;
+ overflow: hidden;
+ /* animation: slideUp 250ms ease-out; */
&[data-expanded] {
overflow: visible;
}
- /* JS-animated content: overflow managed by animate() */
- &[data-spring-content] {
- overflow: clip;
- }
+ /* &[data-expanded] { */
+ /* animation: slideDown 250ms ease-out; */
+ /* } */
}
&[data-variant="ghost"] {
@@ -115,6 +103,9 @@
border: none;
padding: 0;
+ /* &:hover { */
+ /* color: var(--text-strong); */
+ /* } */
&:focus-visible {
outline: none;
background-color: var(--surface-raised-base-hover);
@@ -131,3 +122,21 @@
}
}
}
+
+@keyframes slideDown {
+ from {
+ height: 0;
+ }
+ to {
+ height: var(--kb-collapsible-content-height);
+ }
+}
+
+@keyframes slideUp {
+ from {
+ height: var(--kb-collapsible-content-height);
+ }
+ to {
+ height: 0;
+ }
+}
diff --git a/packages/ui/src/components/context-tool-results.tsx b/packages/ui/src/components/context-tool-results.tsx
deleted file mode 100644
index a0d9311de45..00000000000
--- a/packages/ui/src/components/context-tool-results.tsx
+++ /dev/null
@@ -1,197 +0,0 @@
-import { createMemo, createSignal, For, onMount } from "solid-js"
-import type { ToolPart } from "@opencode-ai/sdk/v2"
-import { getFilename } from "@opencode-ai/util/path"
-import { useReducedMotion } from "../hooks/use-reduced-motion"
-import { useI18n } from "../context/i18n"
-import { ToolCall } from "./basic-tool"
-import { ToolStatusTitle } from "./tool-status-title"
-import { AnimatedCountList } from "./tool-count-summary"
-import { RollingResults } from "./rolling-results"
-import { GROW_SPRING } from "./motion"
-import { useSpring } from "./motion-spring"
-import { busy, updateScrollMask, useCollapsible, useRowWipe } from "./tool-utils"
-
-function contextToolLabel(part: ToolPart): { action: string; detail: string } {
- const state = part.state
- const title = "title" in state ? (state.title as string | undefined) : undefined
- const input = state.input
- if (part.tool === "read") {
- const path = input?.filePath as string | undefined
- return { action: "Read", detail: title || (path ? getFilename(path) : "") }
- }
- if (part.tool === "grep") {
- const pattern = input?.pattern as string | undefined
- return { action: "Search", detail: title || (pattern ? `"${pattern}"` : "") }
- }
- if (part.tool === "glob") {
- const pattern = input?.pattern as string | undefined
- return { action: "Find", detail: title || (pattern ?? "") }
- }
- if (part.tool === "list") {
- const path = input?.path as string | undefined
- return { action: "List", detail: title || (path ? getFilename(path) : "") }
- }
- return { action: part.tool, detail: title || "" }
-}
-
-function contextToolSummary(parts: ToolPart[]) {
- let read = 0
- let search = 0
- let list = 0
- for (const part of parts) {
- if (part.tool === "read") read++
- else if (part.tool === "glob" || part.tool === "grep") search++
- else if (part.tool === "list") list++
- }
- return { read, search, list }
-}
-
-export function ContextToolGroupHeader(props: {
- parts: ToolPart[]
- pending: boolean
- open: boolean
- onOpenChange: (value: boolean) => void
-}) {
- const i18n = useI18n()
- const summary = createMemo(() => contextToolSummary(props.parts))
- return (
-
-
-
-
-
-
-
-
-
-
- }
- />
- )
-}
-
-export function ContextToolExpandedList(props: { parts: ToolPart[]; expanded: boolean }) {
- let contentRef: HTMLDivElement | undefined
- let bodyRef: HTMLDivElement | undefined
- let scrollRef: HTMLDivElement | undefined
- const updateMask = () => {
- if (scrollRef) updateScrollMask(scrollRef)
- }
-
- useCollapsible({
- content: () => contentRef,
- body: () => bodyRef,
- open: () => props.expanded,
- onOpen: updateMask,
- })
-
- return (
-
-
-
-
- {(part) => {
- const label = createMemo(() => contextToolLabel(part))
- return (
-
- {label().action}
- {label().detail}
-
- )
- }}
-
-
-
-
- )
-}
-
-export function ContextToolRollingResults(props: { parts: ToolPart[]; pending: boolean }) {
- const reduce = useReducedMotion()
- const wiped = new Set
()
- const [mounted, setMounted] = createSignal(false)
- onMount(() => setMounted(true))
- const show = () => mounted() && props.pending
- const opacity = useSpring(() => (show() ? 1 : 0), GROW_SPRING)
- const blur = useSpring(() => (show() ? 0 : 2), GROW_SPRING)
- return (
-
-
part.callID || part.id}
- render={(part) => {
- const label = createMemo(() => contextToolLabel(part))
- const k = part.callID || part.id
- return (
-
- {label().action}
- {(() => {
- const [detailRef, setDetailRef] = createSignal()
- useRowWipe({
- id: () => k,
- text: () => label().detail,
- ref: detailRef,
- seen: wiped,
- })
- return (
-
- {label().detail}
-
- )
- })()}
-
- )
- }}
- />
-
- )
-}
diff --git a/packages/ui/src/components/grow-box.tsx b/packages/ui/src/components/grow-box.tsx
deleted file mode 100644
index c8ea6f3b3a9..00000000000
--- a/packages/ui/src/components/grow-box.tsx
+++ /dev/null
@@ -1,432 +0,0 @@
-import { createEffect, on, type JSX, onMount, onCleanup } from "solid-js"
-import { useReducedMotion } from "../hooks/use-reduced-motion"
-import { animate, tunableSpringValue, type AnimationPlaybackControls, GROW_SPRING, type SpringConfig } from "./motion"
-
-export interface GrowBoxProps {
- children: JSX.Element
- /** Enable animation. When false, content shows immediately at full height. */
- animate?: boolean
- /** Animate height from 0 to content height. Default: true. */
- grow?: boolean
- /** Keep watching body size and animate subsequent height changes. Default: false. */
- watch?: boolean
- /** Fade in body content (opacity + blur). Default: true. */
- fade?: boolean
- /** Top padding in px on the body wrapper. Default: 0. */
- gap?: number
- /** Reset to height:auto after grow completes, or stay at fixed px. Default: true. */
- autoHeight?: boolean
- /** Controlled visibility for animating open/close without unmounting children. */
- open?: boolean
- /** Animate controlled open/close changes after mount. Default: true. */
- animateToggle?: boolean
- /** data-slot attribute on the root div. */
- slot?: string
- /** CSS class on the root div. */
- class?: string
- /** Override mount and resize spring config. Default: GROW_SPRING. */
- spring?: SpringConfig
- /** Override controlled open/close spring config. Default: spring. */
- toggleSpring?: SpringConfig
- /** Show a temporary bottom edge fade while height animation is running. */
- edge?: boolean
- /** Edge fade height in px. Default: 20. */
- edgeHeight?: number
- /** Edge fade opacity (0-1). Default: 1. */
- edgeOpacity?: number
- /** Delay before edge fades out after height settles. Default: 320. */
- edgeIdle?: number
- /** Edge fade-out duration in seconds. Default: 0.24. */
- edgeFade?: number
- /** Edge fade-in duration in seconds. Default: 0.2. */
- edgeRise?: number
-}
-
-/**
- * Wraps children in a container that animates from zero height on mount.
- *
- * Includes a ResizeObserver so content changes after mount are also spring-animated.
- * Used for timeline turns, assistant part groups, and user messages.
- */
-export function GrowBox(props: GrowBoxProps) {
- const reduce = useReducedMotion()
- const spring = () => props.spring ?? GROW_SPRING
- const toggleSpring = () => props.toggleSpring ?? spring()
- let mode: "mount" | "toggle" = "mount"
- let root: HTMLDivElement | undefined
- let body: HTMLDivElement | undefined
- let fadeAnim: AnimationPlaybackControls | undefined
- let edgeRef: HTMLDivElement | undefined
- let edgeAnim: AnimationPlaybackControls | undefined
- let edgeTimer: ReturnType | undefined
- let edgeOn = false
- let mountFrame: number | undefined
- let resizeFrame: number | undefined
- let observer: ResizeObserver | undefined
- let springTarget = -1
- const height = tunableSpringValue(0, {
- type: "spring",
- get visualDuration() {
- return (mode === "toggle" ? toggleSpring() : spring()).visualDuration
- },
- get bounce() {
- return (mode === "toggle" ? toggleSpring() : spring()).bounce
- },
- })
-
- const gap = () => Math.max(0, props.gap ?? 0)
- const grow = () => props.grow !== false
- const watch = () => props.watch === true
- const open = () => props.open !== false
- const animateToggle = () => props.animateToggle !== false
- const edge = () => props.edge === true
- const edgeHeight = () => Math.max(0, props.edgeHeight ?? 20)
- const edgeOpacity = () => Math.min(1, Math.max(0, props.edgeOpacity ?? 1))
- const edgeIdle = () => Math.max(0, props.edgeIdle ?? 320)
- const edgeFade = () => Math.max(0.05, props.edgeFade ?? 0.24)
- const edgeRise = () => Math.max(0.05, props.edgeRise ?? 0.2)
- const animated = () => props.animate !== false && !reduce()
- const edgeReady = () => animated() && open() && edge() && edgeHeight() > 0
-
- const stopEdgeTimer = () => {
- if (edgeTimer === undefined) return
- clearTimeout(edgeTimer)
- edgeTimer = undefined
- }
-
- const hideEdge = (instant = false) => {
- stopEdgeTimer()
- if (!edgeRef) {
- edgeOn = false
- return
- }
- edgeAnim?.stop()
- edgeAnim = undefined
- if (instant || reduce()) {
- edgeRef.style.opacity = "0"
- edgeOn = false
- return
- }
- if (!edgeOn) {
- edgeRef.style.opacity = "0"
- return
- }
- const current = animate(edgeRef, { opacity: 0 }, { type: "spring", visualDuration: edgeFade(), bounce: 0 })
- edgeAnim = current
- current.finished
- .catch(() => {})
- .finally(() => {
- if (edgeAnim !== current) return
- edgeAnim = undefined
- if (!edgeRef) return
- edgeRef.style.opacity = "0"
- edgeOn = false
- })
- }
-
- const showEdge = () => {
- stopEdgeTimer()
- if (!edgeRef) return
- if (reduce()) {
- edgeRef.style.opacity = `${edgeOpacity()}`
- edgeOn = true
- return
- }
- if (edgeOn && edgeAnim === undefined) {
- edgeRef.style.opacity = `${edgeOpacity()}`
- return
- }
- edgeAnim?.stop()
- edgeAnim = undefined
- if (!edgeOn) edgeRef.style.opacity = "0"
- const current = animate(
- edgeRef,
- { opacity: edgeOpacity() },
- { type: "spring", visualDuration: edgeRise(), bounce: 0 },
- )
- edgeAnim = current
- edgeOn = true
- current.finished
- .catch(() => {})
- .finally(() => {
- if (edgeAnim !== current) return
- edgeAnim = undefined
- if (!edgeRef) return
- edgeRef.style.opacity = `${edgeOpacity()}`
- })
- }
-
- const queueEdgeHide = () => {
- stopEdgeTimer()
- if (!edgeOn) return
- if (edgeIdle() <= 0) {
- hideEdge()
- return
- }
- edgeTimer = setTimeout(() => {
- edgeTimer = undefined
- hideEdge()
- }, edgeIdle())
- }
-
- const hideBody = () => {
- if (!body) return
- body.style.opacity = "0"
- body.style.filter = "blur(2px)"
- }
-
- const clearBody = () => {
- if (!body) return
- body.style.opacity = ""
- body.style.filter = ""
- }
-
- const fadeBodyIn = (nextMode: "mount" | "toggle" = "mount") => {
- if (props.fade === false || !body) return
- if (reduce()) {
- clearBody()
- return
- }
- hideBody()
- fadeAnim?.stop()
- fadeAnim = animate(body, { opacity: 1, filter: "blur(0px)" }, nextMode === "toggle" ? toggleSpring() : spring())
- fadeAnim.finished.then(() => {
- if (!body || !open()) return
- clearBody()
- })
- }
-
- const setInstant = (visible: boolean) => {
- const next = visible ? targetHeight() : 0
- springTarget = next
- height.jump(next)
- root!.style.height = visible ? "" : "0px"
- root!.style.overflow = visible ? "" : "clip"
- hideEdge(true)
- if (visible || props.fade === false) clearBody()
- else hideBody()
- }
-
- const currentHeight = () => {
- if (!root) return 0
- const v = root.style.height
- if (v && v !== "auto") {
- const n = Number.parseFloat(v)
- if (!Number.isNaN(n)) return n
- }
- return Math.max(0, root.getBoundingClientRect().height)
- }
-
- const targetHeight = () => Math.max(0, Math.ceil(body?.getBoundingClientRect().height ?? 0))
-
- const setHeight = (nextMode: "mount" | "toggle" = "mount") => {
- if (!root || !open()) return
- const next = targetHeight()
- if (reduce()) {
- springTarget = next
- height.jump(next)
- if (props.autoHeight === false || watch()) {
- root.style.height = `${next}px`
- root.style.overflow = next > 0 ? "visible" : "clip"
- return
- }
- root.style.height = "auto"
- root.style.overflow = next > 0 ? "visible" : "clip"
- return
- }
- if (next === springTarget) return
- const prev = currentHeight()
- if (Math.abs(next - prev) < 1) {
- springTarget = next
- if (props.autoHeight === false || watch()) {
- root.style.height = `${next}px`
- root.style.overflow = next > 0 ? "visible" : "clip"
- }
- return
- }
- root.style.overflow = "clip"
- springTarget = next
- mode = nextMode
- height.set(next)
- }
-
- onMount(() => {
- if (!root || !body) return
-
- const offChange = height.on("change", (next) => {
- if (!root) return
- root.style.height = `${Math.max(0, next)}px`
- })
- const offStart = height.on("animationStart", () => {
- if (!root) return
- root.style.overflow = "clip"
- root.style.willChange = "height"
- root.style.contain = "layout style"
- if (edgeReady()) showEdge()
- })
- const offComplete = height.on("animationComplete", () => {
- if (!root) return
- root.style.willChange = ""
- root.style.contain = ""
- if (!open()) {
- springTarget = 0
- root.style.height = "0px"
- root.style.overflow = "clip"
- return
- }
- const next = targetHeight()
- springTarget = next
- if (props.autoHeight === false || watch()) {
- root.style.height = `${next}px`
- root.style.overflow = next > 0 ? "visible" : "clip"
- if (edgeReady()) queueEdgeHide()
- return
- }
- root.style.height = "auto"
- root.style.overflow = "visible"
- if (edgeReady()) queueEdgeHide()
- })
-
- onCleanup(() => {
- offComplete()
- offStart()
- offChange()
- })
-
- if (watch()) {
- observer = new ResizeObserver(() => {
- if (!open()) return
- if (resizeFrame !== undefined) return
- resizeFrame = requestAnimationFrame(() => {
- resizeFrame = undefined
- setHeight("mount")
- })
- })
- observer.observe(body)
- }
-
- if (!animated()) {
- setInstant(open())
- return
- }
-
- if (props.fade !== false) hideBody()
- hideEdge(true)
-
- if (!open()) {
- root.style.height = "0px"
- root.style.overflow = "clip"
- } else {
- if (grow()) {
- root.style.height = "0px"
- root.style.overflow = "clip"
- } else {
- root.style.height = "auto"
- root.style.overflow = "visible"
- }
- mountFrame = requestAnimationFrame(() => {
- mountFrame = undefined
- fadeBodyIn("mount")
- if (grow()) setHeight("mount")
- })
- }
- })
-
- createEffect(
- on(
- () => props.open,
- (value) => {
- if (value === undefined) return
- if (!root || !body) return
- if (!animateToggle() || reduce()) {
- setInstant(value)
- return
- }
- fadeAnim?.stop()
- if (!value) hideEdge(true)
- if (!value) {
- const next = currentHeight()
- if (Math.abs(next - height.get()) >= 1) {
- springTarget = next
- height.jump(next)
- root.style.height = `${next}px`
- }
- if (props.fade !== false) {
- fadeAnim = animate(body, { opacity: 0, filter: "blur(2px)" }, toggleSpring())
- }
- root.style.overflow = "clip"
- springTarget = 0
- mode = "toggle"
- height.set(0)
- return
- }
- fadeBodyIn("toggle")
- setHeight("toggle")
- },
- { defer: true },
- ),
- )
-
- createEffect(() => {
- if (!edgeRef) return
- edgeRef.style.height = `${edgeHeight()}px`
- if (!animated() || !open() || edgeHeight() <= 0) {
- hideEdge(true)
- return
- }
- if (edge()) return
- hideEdge()
- })
-
- createEffect(() => {
- if (!root || !body) return
- if (!reduce()) return
- fadeAnim?.stop()
- edgeAnim?.stop()
- setInstant(open())
- })
-
- onCleanup(() => {
- stopEdgeTimer()
- if (mountFrame !== undefined) cancelAnimationFrame(mountFrame)
- if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
- observer?.disconnect()
- height.destroy()
- fadeAnim?.stop()
- edgeAnim?.stop()
- edgeAnim = undefined
- edgeOn = false
- })
-
- return (
-
-
0 ? `${gap()}px` : undefined }}>
- {props.children}
-
-
-
- )
-}
diff --git a/packages/ui/src/components/line-comment.tsx b/packages/ui/src/components/line-comment.tsx
index 73d83f7d72c..ff5d1df007a 100644
--- a/packages/ui/src/components/line-comment.tsx
+++ b/packages/ui/src/components/line-comment.tsx
@@ -244,6 +244,7 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
event.stopPropagation()
if (e.key === "Escape") {
event.preventDefault()
+ e.currentTarget.blur()
split.onCancel()
return
}
diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css
index 9a6784d7025..8fc7090133e 100644
--- a/packages/ui/src/components/message-part.css
+++ b/packages/ui/src/components/message-part.css
@@ -1,20 +1,10 @@
[data-component="assistant-message"] {
content-visibility: auto;
width: 100%;
-}
-
-[data-component="assistant-parts"] {
- width: 100%;
- min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
- gap: 0;
-}
-
-[data-component="assistant-part-item"] {
- width: 100%;
- min-width: 0;
+ gap: 12px;
}
[data-component="user-message"] {
@@ -37,14 +27,6 @@
color: var(--text-weak);
}
- [data-slot="user-message-inner"] {
- position: relative;
- display: flex;
- flex-direction: column;
- align-items: flex-end;
- width: 100%;
- gap: 4px;
- }
[data-slot="user-message-attachments"] {
display: flex;
flex-wrap: wrap;
@@ -53,7 +35,6 @@
width: fit-content;
max-width: min(82%, 64ch);
margin-left: auto;
- margin-bottom: 4px;
}
[data-slot="user-message-attachment"] {
@@ -153,7 +134,7 @@
[data-slot="user-message-copy-wrapper"] {
min-height: 24px;
- margin-top: 0;
+ margin-top: 4px;
display: flex;
align-items: center;
justify-content: flex-end;
@@ -163,6 +144,7 @@
pointer-events: none;
transition: opacity 0.15s ease;
will-change: opacity;
+
[data-component="tooltip-trigger"] {
display: inline-flex;
width: fit-content;
@@ -205,21 +187,56 @@
opacity: 1;
pointer-events: auto;
}
+
+ .text-text-strong {
+ color: var(--text-strong);
+ }
+
+ .font-medium {
+ font-weight: var(--font-weight-medium);
+ }
}
[data-component="text-part"] {
width: 100%;
- margin-top: 0;
- padding-block: 4px;
- position: relative;
+ margin-top: 24px;
[data-slot="text-part-body"] {
margin-top: 0;
}
- [data-slot="text-part-turn-summary"] {
+ [data-slot="text-part-copy-wrapper"] {
+ min-height: 24px;
+ margin-top: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 10px;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.15s ease;
+ will-change: opacity;
+
+ [data-component="tooltip-trigger"] {
+ display: inline-flex;
+ width: fit-content;
+ }
+ }
+
+ [data-slot="text-part-meta"] {
+ user-select: none;
+ }
+
+ [data-slot="text-part-copy-wrapper"][data-interrupted] {
width: 100%;
- min-width: 0;
+ justify-content: flex-end;
+ gap: 12px;
+ }
+
+ &:hover [data-slot="text-part-copy-wrapper"],
+ &:focus-within [data-slot="text-part-copy-wrapper"] {
+ opacity: 1;
+ pointer-events: auto;
}
[data-component="markdown"] {
@@ -228,10 +245,6 @@
}
}
-[data-component="assistant-part-item"][data-kind="text"][data-last="true"] [data-component="text-part"] {
- padding-bottom: 0;
-}
-
[data-component="compaction-part"] {
width: 100%;
display: flex;
@@ -265,6 +278,7 @@
line-height: var(--line-height-normal);
[data-component="markdown"] {
+ margin-top: 24px;
font-style: normal;
font-size: inherit;
color: var(--text-weak);
@@ -358,16 +372,13 @@
height: auto;
max-height: 240px;
overflow-y: auto;
- overscroll-behavior: contain;
scrollbar-width: none;
-ms-overflow-style: none;
- -webkit-mask-image: linear-gradient(to bottom, transparent 0, black 6px, black calc(100% - 6px), transparent 100%);
- mask-image: linear-gradient(to bottom, transparent 0, black 6px, black calc(100% - 6px), transparent 100%);
- -webkit-mask-repeat: no-repeat;
- mask-repeat: no-repeat;
+
&::-webkit-scrollbar {
display: none;
}
+
[data-component="markdown"] {
overflow: visible;
}
@@ -437,7 +448,7 @@
[data-component="write-trigger"] {
display: flex;
align-items: center;
- justify-content: flex-start;
+ justify-content: space-between;
gap: 8px;
width: 100%;
@@ -450,8 +461,7 @@
}
[data-slot="message-part-title"] {
- flex-shrink: 1;
- min-width: 0;
+ flex-shrink: 0;
display: flex;
align-items: center;
gap: 8px;
@@ -483,45 +493,40 @@
[data-slot="message-part-title-text"] {
text-transform: capitalize;
color: var(--text-strong);
- flex-shrink: 0;
}
- [data-slot="message-part-meta-line"],
- .message-part-meta-line {
- min-width: 0;
- display: inline-flex;
- align-items: center;
- gap: 6px;
+ [data-slot="message-part-title-filename"] {
+ /* No text-transform - preserve original filename casing */
font-weight: var(--font-weight-regular);
-
- [data-component="diff-changes"] {
- flex-shrink: 0;
- gap: 6px;
- }
}
- .message-part-meta-line.soft {
- [data-slot="message-part-title-filename"] {
- color: var(--text-base);
- }
- }
-
- [data-slot="message-part-title-filename"] {
- /* No text-transform - preserve original filename casing */
- color: var(--text-strong);
- flex-shrink: 0;
+ [data-slot="message-part-path"] {
+ display: flex;
+ flex-grow: 1;
+ min-width: 0;
+ font-weight: var(--font-weight-regular);
}
- [data-slot="message-part-directory-inline"] {
+ [data-slot="message-part-directory"] {
color: var(--text-weak);
- min-width: 0;
- max-width: min(48vw, 36ch);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
direction: rtl;
text-align: left;
}
+
+ [data-slot="message-part-filename"] {
+ color: var(--text-strong);
+ flex-shrink: 0;
+ }
+
+ [data-slot="message-part-actions"] {
+ display: flex;
+ gap: 16px;
+ align-items: center;
+ justify-content: flex-end;
+ }
}
[data-component="edit-content"] {
@@ -612,17 +617,6 @@
}
}
-[data-slot="webfetch-meta"] {
- min-width: 0;
- display: inline-flex;
- align-items: center;
- gap: 8px;
-
- [data-component="tool-action"] {
- flex-shrink: 0;
- }
-}
-
[data-component="todos"] {
padding: 10px 0 24px 0;
display: flex;
@@ -645,6 +639,7 @@
}
[data-component="context-tool-group-trigger"] {
+ width: 100%;
min-height: 24px;
display: flex;
align-items: center;
@@ -652,352 +647,28 @@
gap: 0px;
cursor: pointer;
- &[data-pending] {
- cursor: default;
- }
-
[data-slot="context-tool-group-title"] {
flex-shrink: 1;
min-width: 0;
}
-}
-
-/* Prevent the trigger content from stretching full-width so the arrow sits after the text */
-[data-slot="basic-tool-tool-trigger-content"]:has([data-component="context-tool-group-trigger"]) {
- width: auto;
- flex: 0 1 auto;
- [data-slot="basic-tool-tool-info"] {
- flex: 0 1 auto;
+ [data-slot="collapsible-arrow"] {
+ color: var(--icon-weaker);
}
}
-[data-component="context-tool-step"] {
- width: 100%;
- min-width: 0;
- padding-left: 12px;
-}
-
-[data-component="context-tool-expanded-list"] {
+[data-component="context-tool-group-list"] {
+ padding: 6px 0 4px 0;
display: flex;
flex-direction: column;
- padding: 4px 0 4px 12px;
- max-height: 200px;
- overflow-y: auto;
- overscroll-behavior: contain;
- scrollbar-width: none;
- -ms-overflow-style: none;
- -webkit-mask-repeat: no-repeat;
- mask-repeat: no-repeat;
+ gap: 2px;
- &::-webkit-scrollbar {
- display: none;
- }
-}
-
-[data-component="context-tool-expanded-row"] {
- display: flex;
- align-items: center;
- gap: 6px;
- min-width: 0;
- height: 22px;
- flex-shrink: 0;
- white-space: nowrap;
- overflow: hidden;
-
- [data-slot="context-tool-expanded-action"] {
- flex-shrink: 0;
- font-size: var(--font-size-base);
- font-weight: 500;
- color: var(--text-base);
- }
-
- [data-slot="context-tool-expanded-detail"] {
- flex-shrink: 1;
+ [data-slot="context-tool-group-item"] {
min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- font-size: var(--font-size-base);
- color: var(--text-base);
- opacity: 0.75;
+ padding: 6px 0;
}
}
-[data-component="context-tool-rolling-row"] {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- width: 100%;
- min-width: 0;
- white-space: nowrap;
- overflow: hidden;
- padding-left: 12px;
-
- [data-slot="context-tool-rolling-action"] {
- flex-shrink: 0;
- font-size: var(--font-size-base);
- font-weight: 500;
- color: var(--text-base);
- }
-
- [data-slot="context-tool-rolling-detail"] {
- flex-shrink: 1;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- font-size: var(--font-size-base);
- color: var(--text-weak);
- }
-}
-
-[data-component="shell-rolling-results"] {
- width: 100%;
- min-width: 0;
- display: flex;
- flex-direction: column;
-
- [data-slot="shell-rolling-header-clip"] {
- &:hover [data-slot="shell-rolling-actions"] {
- opacity: 1;
- }
-
- &[data-clickable="true"] {
- cursor: pointer;
- }
- }
-
- [data-slot="shell-rolling-header"] {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- min-width: 0;
- max-width: 100%;
- height: 37px;
- box-sizing: border-box;
- }
-
- [data-slot="shell-rolling-title"] {
- flex-shrink: 0;
- font-family: var(--font-family-sans);
- font-size: 14px;
- font-style: normal;
- font-weight: var(--font-weight-medium);
- line-height: var(--line-height-large);
- letter-spacing: var(--letter-spacing-normal);
- color: var(--text-strong);
- }
-
- [data-slot="shell-rolling-subtitle"] {
- flex: 0 1 auto;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- font-family: var(--font-family-sans);
- font-size: 14px;
- font-weight: var(--font-weight-normal);
- line-height: var(--line-height-large);
- color: var(--text-weak);
- }
-
- [data-slot="shell-rolling-actions"] {
- flex-shrink: 0;
- display: inline-flex;
- align-items: center;
- gap: 2px;
- opacity: 0;
- transition: opacity 0.15s ease;
- }
-
- .shell-rolling-copy {
- border: none !important;
- outline: none !important;
- box-shadow: none !important;
- background: transparent !important;
-
- [data-slot="icon-svg"] {
- color: var(--icon-weaker);
- }
-
- &:hover:not(:disabled) {
- background: color-mix(in srgb, var(--text-base) 8%, transparent) !important;
- box-shadow: 0 0 0 1px color-mix(in srgb, var(--icon-weaker) 40%, transparent) !important;
- border-radius: var(--radius-sm);
-
- [data-slot="icon-svg"] {
- color: var(--icon-base);
- }
- }
- }
-
- [data-slot="shell-rolling-arrow"] {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- color: var(--icon-weaker);
- transform: rotate(-90deg);
- transition: transform 0.15s ease;
- }
-
- [data-slot="shell-rolling-arrow"][data-open="true"] {
- transform: rotate(0deg);
- }
-}
-
-[data-component="shell-rolling-output"] {
- width: 100%;
- min-width: 0;
-}
-
-[data-slot="shell-rolling-preview"] {
- width: 100%;
- min-width: 0;
-}
-
-[data-component="shell-expanded-output"] {
- width: 100%;
- max-width: 100%;
- overflow-y: auto;
- overflow-x: hidden;
- scrollbar-width: none;
- -ms-overflow-style: none;
-
- &::-webkit-scrollbar {
- display: none;
- }
-}
-
-[data-component="shell-expanded-shell"] {
- position: relative;
- width: 100%;
- min-width: 0;
- border: 1px solid var(--border-weak-base);
- border-radius: 6px;
- background: transparent;
- overflow: hidden;
-}
-
-[data-slot="shell-expanded-body"] {
- position: relative;
- width: 100%;
- min-width: 0;
-}
-
-[data-slot="shell-expanded-top"] {
- position: relative;
- width: 100%;
- min-width: 0;
- padding: 9px 44px 9px 16px;
- box-sizing: border-box;
-}
-
-[data-slot="shell-expanded-command"] {
- display: flex;
- align-items: flex-start;
- gap: 8px;
- width: 100%;
- min-width: 0;
- font-family: var(--font-family-mono);
- font-feature-settings: var(--font-family-mono--font-feature-settings);
- font-size: 13px;
- line-height: 1.45;
-}
-
-[data-slot="shell-expanded-prompt"] {
- flex-shrink: 0;
- color: var(--text-weaker);
-}
-
-[data-slot="shell-expanded-input"] {
- min-width: 0;
- color: var(--text-strong);
- white-space: pre-wrap;
- overflow-wrap: anywhere;
-}
-
-[data-slot="shell-expanded-actions"] {
- position: absolute;
- top: 50%;
- right: 8px;
- z-index: 1;
- transform: translateY(-50%);
-}
-
-.shell-expanded-copy {
- border: none !important;
- outline: none !important;
- box-shadow: none !important;
- background: transparent !important;
-
- [data-slot="icon-svg"] {
- color: var(--icon-weaker);
- }
-
- &:hover:not(:disabled) {
- background: color-mix(in srgb, var(--text-base) 8%, transparent) !important;
- box-shadow: 0 0 0 1px color-mix(in srgb, var(--icon-weaker) 40%, transparent) !important;
- border-radius: var(--radius-sm);
-
- [data-slot="icon-svg"] {
- color: var(--icon-base);
- }
- }
-}
-
-[data-slot="shell-expanded-divider"] {
- width: 100%;
- height: 1px;
- background: var(--border-weak-base);
-}
-
-[data-slot="shell-expanded-pre"] {
- margin: 0;
- padding: 12px 16px;
- white-space: pre-wrap;
- overflow-wrap: anywhere;
-
- code {
- font-family: var(--font-family-mono);
- font-feature-settings: var(--font-family-mono--font-feature-settings);
- font-size: 13px;
- line-height: 1.45;
- color: var(--text-base);
- }
-}
-
-[data-component="shell-rolling-command"],
-[data-component="shell-rolling-row"] {
- display: inline-flex;
- align-items: center;
- width: 100%;
- min-width: 0;
- overflow: hidden;
- white-space: pre;
- padding-left: 12px;
-}
-
-[data-slot="shell-rolling-text"] {
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- font-family: var(--font-family-mono);
- font-feature-settings: var(--font-family-mono--font-feature-settings);
- font-size: var(--font-size-small);
- line-height: var(--line-height-large);
-}
-
-[data-component="shell-rolling-command"] [data-slot="shell-rolling-text"] {
- color: var(--text-base);
-}
-
-[data-component="shell-rolling-command"] [data-slot="shell-rolling-prompt"] {
- color: var(--text-weaker);
-}
-
-[data-component="shell-rolling-row"] [data-slot="shell-rolling-text"] {
- color: var(--text-weak);
-}
-
[data-component="diagnostics"] {
display: flex;
flex-direction: column;
@@ -1058,30 +729,6 @@
width: 100%;
}
-[data-slot="assistant-part-grow"] {
- width: 100%;
- min-width: 0;
- overflow: visible;
-}
-
-[data-component="tool-part-wrapper"][data-tool="bash"] {
- [data-component="tool-trigger"] {
- width: auto;
- max-width: 100%;
- }
-
- [data-slot="basic-tool-tool-info-main"] {
- align-items: center;
- }
-
- [data-slot="basic-tool-tool-title"],
- [data-slot="basic-tool-tool-subtitle"] {
- display: inline-flex;
- align-items: center;
- line-height: var(--line-height-large);
- }
-}
-
[data-component="dock-prompt"][data-kind="permission"] {
position: relative;
display: flex;
@@ -1540,7 +1187,8 @@
position: sticky;
top: var(--sticky-accordion-top, 0px);
z-index: 20;
- height: 37px;
+ height: 40px;
+ padding-bottom: 8px;
background-color: var(--background-stronger);
}
}
@@ -1551,12 +1199,11 @@
}
[data-slot="apply-patch-trigger-content"] {
- display: inline-flex;
+ display: flex;
align-items: center;
- justify-content: flex-start;
- max-width: 100%;
- min-width: 0;
- gap: 8px;
+ justify-content: space-between;
+ width: 100%;
+ gap: 20px;
}
[data-slot="apply-patch-file-info"] {
@@ -1590,9 +1237,9 @@
[data-slot="apply-patch-trigger-actions"] {
flex-shrink: 0;
display: flex;
- gap: 8px;
+ gap: 16px;
align-items: center;
- justify-content: flex-start;
+ justify-content: flex-end;
}
[data-slot="apply-patch-change"] {
@@ -1632,11 +1279,10 @@
}
[data-component="tool-loaded-file"] {
- min-width: 0;
display: flex;
align-items: center;
gap: 8px;
- padding: 4px 0 4px 12px;
+ padding: 4px 0 4px 28px;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-weight: var(--font-weight-regular);
@@ -1647,11 +1293,4 @@
flex-shrink: 0;
color: var(--icon-weak);
}
-
- span {
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
}
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index d8212115929..45b174e2b84 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -1,6 +1,18 @@
-import { Component, createEffect, createMemo, createSignal, For, Match, on, Show, Switch, type JSX } from "solid-js"
+import {
+ Component,
+ createEffect,
+ createMemo,
+ createSignal,
+ For,
+ Match,
+ onMount,
+ Show,
+ Switch,
+ onCleanup,
+ Index,
+ type JSX,
+} from "solid-js"
import stripAnsi from "strip-ansi"
-import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import {
AgentPart,
@@ -20,10 +32,11 @@ import { useData } from "../context"
import { useFileComponent } from "../context/file"
import { useDialog } from "../context/dialog"
import { type UiI18n, useI18n } from "../context/i18n"
-import { GenericTool, ToolCall } from "./basic-tool"
+import { BasicTool, GenericTool } from "./basic-tool"
import { Accordion } from "./accordion"
import { StickyAccordionHeader } from "./sticky-accordion-header"
import { Card } from "./card"
+import { Collapsible } from "./collapsible"
import { FileIcon } from "./file-icon"
import { Icon } from "./icon"
import { Checkbox } from "./checkbox"
@@ -35,12 +48,43 @@ import { checksum } from "@opencode-ai/util/encode"
import { Tooltip } from "./tooltip"
import { IconButton } from "./icon-button"
import { TextShimmer } from "./text-shimmer"
-import { list } from "./text-utils"
-import { GrowBox } from "./grow-box"
-import { COLLAPSIBLE_SPRING } from "./motion"
-import { busy, createThrottledValue, useToolFade, useContextToolPending } from "./tool-utils"
-import { ContextToolGroupHeader, ContextToolExpandedList, ContextToolRollingResults } from "./context-tool-results"
-import { ShellRollingResults } from "./shell-rolling-results"
+import { AnimatedCountList } from "./tool-count-summary"
+import { ToolStatusTitle } from "./tool-status-title"
+import { animate } from "motion"
+import { useLocation } from "@solidjs/router"
+
+function ShellSubmessage(props: { text: string; animate?: boolean }) {
+ let widthRef: HTMLSpanElement | undefined
+ let valueRef: HTMLSpanElement | undefined
+
+ onMount(() => {
+ if (!props.animate) return
+ requestAnimationFrame(() => {
+ if (widthRef) {
+ animate(widthRef, { width: "auto" }, { type: "spring", visualDuration: 0.25, bounce: 0 })
+ }
+ if (valueRef) {
+ animate(valueRef, { opacity: 1, filter: "blur(0px)" }, { duration: 0.32, ease: [0.16, 1, 0.3, 1] })
+ }
+ })
+ })
+
+ return (
+
+
+
+
+ {props.text}
+
+
+
+
+ )
+}
interface Diagnostic {
range: {
@@ -81,22 +125,64 @@ function DiagnosticsDisplay(props: { diagnostics: Diagnostic[] }): JSX.Element {
)
}
+export interface MessageProps {
+ message: MessageType
+ parts: PartType[]
+ showAssistantCopyPartID?: string | null
+ interrupted?: boolean
+ queued?: boolean
+ showReasoningSummaries?: boolean
+}
+
export interface MessagePartProps {
part: PartType
message: MessageType
hideDetails?: boolean
defaultOpen?: boolean
showAssistantCopyPartID?: string | null
- showTurnDiffSummary?: boolean
- turnDiffSummary?: () => JSX.Element
- animate?: boolean
- working?: boolean
+ turnDurationMs?: number
}
export type PartComponent = Component
export const PART_MAPPING: Record = {}
+const TEXT_RENDER_THROTTLE_MS = 100
+
+function createThrottledValue(getValue: () => string) {
+ const [value, setValue] = createSignal(getValue())
+ let timeout: ReturnType | undefined
+ let last = 0
+
+ createEffect(() => {
+ const next = getValue()
+ const now = Date.now()
+
+ const remaining = TEXT_RENDER_THROTTLE_MS - (now - last)
+ if (remaining <= 0) {
+ if (timeout) {
+ clearTimeout(timeout)
+ timeout = undefined
+ }
+ last = now
+ setValue(next)
+ return
+ }
+ if (timeout) clearTimeout(timeout)
+ timeout = setTimeout(() => {
+ last = Date.now()
+ setValue(next)
+ timeout = undefined
+ }, remaining)
+ })
+
+ onCleanup(() => {
+ if (timeout) clearTimeout(timeout)
+ })
+
+ return value
+}
+
function relativizeProjectPath(path: string, directory?: string) {
if (!path) return ""
if (!directory) return path
@@ -228,8 +314,7 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo {
case "skill":
return {
icon: "brain",
- title: i18n.t("ui.tool.skill"),
- subtitle: typeof input.name === "string" ? input.name : undefined,
+ title: input.name || "skill",
}
default:
return {
@@ -254,22 +339,105 @@ function urls(text: string | undefined) {
const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"])
const HIDDEN_TOOLS = new Set(["todowrite", "todoread"])
-function createGroupOpenState() {
- const [state, setState] = createStore>({})
- const read = (key?: string, collapse?: boolean) => {
- if (!key) return true
- const value = state[key]
- if (value !== undefined) return value
- return !collapse
- }
- const controlled = (key?: string) => {
- if (!key) return false
- return state[key] !== undefined
+function list(value: T[] | undefined | null, fallback: T[]) {
+ if (Array.isArray(value)) return value
+ return fallback
+}
+
+function same(a: readonly T[] | undefined, b: readonly T[] | undefined) {
+ if (a === b) return true
+ if (!a || !b) return false
+ if (a.length !== b.length) return false
+ return a.every((x, i) => x === b[i])
+}
+
+type PartRef = {
+ messageID: string
+ partID: string
+}
+
+type PartGroup =
+ | {
+ key: string
+ type: "part"
+ ref: PartRef
+ }
+ | {
+ key: string
+ type: "context"
+ refs: PartRef[]
+ }
+
+function sameRef(a: PartRef, b: PartRef) {
+ return a.messageID === b.messageID && a.partID === b.partID
+}
+
+function sameGroup(a: PartGroup, b: PartGroup) {
+ if (a === b) return true
+ if (a.key !== b.key) return false
+ if (a.type !== b.type) return false
+ if (a.type === "part") {
+ if (b.type !== "part") return false
+ return sameRef(a.ref, b.ref)
}
- const write = (key: string, value: boolean) => {
- setState(key, value)
+ if (b.type !== "context") return false
+ if (a.refs.length !== b.refs.length) return false
+ return a.refs.every((ref, i) => sameRef(ref, b.refs[i]!))
+}
+
+function sameGroups(a: readonly PartGroup[] | undefined, b: readonly PartGroup[] | undefined) {
+ if (a === b) return true
+ if (!a || !b) return false
+ if (a.length !== b.length) return false
+ return a.every((item, i) => sameGroup(item, b[i]!))
+}
+
+function groupParts(parts: { messageID: string; part: PartType }[]) {
+ const result: PartGroup[] = []
+ let start = -1
+
+ const flush = (end: number) => {
+ if (start < 0) return
+ const first = parts[start]
+ const last = parts[end]
+ if (!first || !last) {
+ start = -1
+ return
+ }
+ result.push({
+ key: `context:${first.part.id}`,
+ type: "context",
+ refs: parts.slice(start, end + 1).map((item) => ({
+ messageID: item.messageID,
+ partID: item.part.id,
+ })),
+ })
+ start = -1
}
- return { read, controlled, write }
+
+ parts.forEach((item, index) => {
+ if (isContextGroupTool(item.part)) {
+ if (start < 0) start = index
+ return
+ }
+
+ flush(index - 1)
+ result.push({
+ key: `part:${item.messageID}:${item.part.id}`,
+ type: "part",
+ ref: {
+ messageID: item.messageID,
+ partID: item.part.id,
+ },
+ })
+ })
+
+ flush(parts.length - 1)
+ return result
+}
+
+function partByID(parts: readonly PartType[], partID: string) {
+ return parts.find((part) => part.id === partID)
}
function renderable(part: PartType, showReasoningSummaries = true) {
@@ -285,8 +453,7 @@ function renderable(part: PartType, showReasoningSummaries = true) {
function toolDefaultOpen(tool: string, shell = false, edit = false) {
if (tool === "bash") return shell
- if (tool === "edit" || tool === "write") return edit
- if (tool === "apply_patch") return false
+ if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit
}
function partDefaultOpen(part: PartType, shell = false, edit = false) {
@@ -294,323 +461,98 @@ function partDefaultOpen(part: PartType, shell = false, edit = false) {
return toolDefaultOpen(part.tool, shell, edit)
}
-function PartGrow(props: {
- children: JSX.Element
- animate?: boolean
- animateToggle?: boolean
- gap?: number
- fade?: boolean
- edge?: boolean
- edgeHeight?: number
- edgeOpacity?: number
- edgeIdle?: number
- edgeFade?: number
- edgeRise?: number
- grow?: boolean
- watch?: boolean
- open?: boolean
- spring?: import("./motion").SpringConfig
- toggleSpring?: import("./motion").SpringConfig
-}) {
- return (
-
- {props.children}
-
- )
-}
-
export function AssistantParts(props: {
messages: AssistantMessage[]
showAssistantCopyPartID?: string | null
- showTurnDiffSummary?: boolean
- turnDiffSummary?: () => JSX.Element
+ turnDurationMs?: number
working?: boolean
showReasoningSummaries?: boolean
shellToolDefaultOpen?: boolean
editToolDefaultOpen?: boolean
- animate?: boolean
}) {
const data = useData()
const emptyParts: PartType[] = []
- const groupState = createGroupOpenState()
- const grouped = createMemo(() => {
- const keys: string[] = []
- const items: Record<
- string,
- | {
- type: "part"
- part: PartType
- message: AssistantMessage
- context?: boolean
- groupKey?: string
- afterTool?: boolean
- groupTail?: boolean
- groupParts?: { part: ToolPart; message: AssistantMessage }[]
- }
- | {
- type: "context"
- groupKey: string
- parts: { part: ToolPart; message: AssistantMessage }[]
- tail: boolean
- afterTool: boolean
- }
- > = {}
- const push = (key: string, item: (typeof items)[string]) => {
- keys.push(key)
- items[key] = item
- }
- const id = (part: PartType) => {
- if (part.type === "tool") return part.callID || part.id
- return part.id
- }
- const parts = props.messages.flatMap((message) =>
- list(data.store.part?.[message.id], emptyParts)
- .filter((part) => renderable(part, props.showReasoningSummaries ?? true))
- .map((part) => ({ message, part })),
- )
-
- let start = -1
-
- const flush = (end: number, tail: boolean, afterTool: boolean) => {
- if (start < 0) return
- const group = parts
- .slice(start, end + 1)
- .filter((entry): entry is { part: ToolPart; message: AssistantMessage } => isContextGroupTool(entry.part))
- if (!group.length) {
- start = -1
- return
- }
- const groupKey = `context:${group[0].message.id}:${id(group[0].part)}`
- push(groupKey, {
- type: "context",
- groupKey,
- parts: group,
- tail,
- afterTool,
- })
- group.forEach((entry) => {
- push(`part:${entry.message.id}:${id(entry.part)}`, {
- type: "part",
- part: entry.part,
- message: entry.message,
- context: true,
- groupKey,
- afterTool,
- groupTail: tail,
- groupParts: group,
- })
- })
- start = -1
- }
- parts.forEach((item, index) => {
- if (isContextGroupTool(item.part)) {
- if (start < 0) start = index
- return
- }
+ const emptyTools: ToolPart[] = []
+
+ const grouped = createMemo(
+ () =>
+ groupParts(
+ props.messages.flatMap((message) =>
+ list(data.store.part?.[message.id], emptyParts)
+ .filter((part) => renderable(part, props.showReasoningSummaries ?? true))
+ .map((part) => ({
+ messageID: message.id,
+ part,
+ })),
+ ),
+ ),
+ [] as PartGroup[],
+ { equals: sameGroups },
+ )
- flush(index - 1, false, (item as { part: PartType }).part.type === "tool")
- push(`part:${item.message.id}:${id(item.part)}`, { type: "part", part: item.part, message: item.message })
- })
+ const last = createMemo(() => grouped().at(-1)?.key)
- flush(parts.length - 1, true, false)
- return { keys, items }
- })
+ return (
+
+ {(entryAccessor) => {
+ const entryType = createMemo(() => entryAccessor().type)
+
+ return (
+
+
+ {(() => {
+ const parts = createMemo(
+ () => {
+ const entry = entryAccessor()
+ if (entry.type !== "context") return emptyTools
+ return entry.refs
+ .map((ref) => partByID(list(data.store.part?.[ref.messageID], emptyParts), ref.partID))
+ .filter((part): part is ToolPart => !!part && isContextGroupTool(part))
+ },
+ emptyTools,
+ { equals: same },
+ )
+ const busy = createMemo(() => props.working && last() === entryAccessor().key)
- const last = createMemo(() => grouped().keys.at(-1))
+ return (
+ 0}>
+
+
+ )
+ })()}
+
+
+ {(() => {
+ const message = createMemo(() => {
+ const entry = entryAccessor()
+ if (entry.type !== "part") return
+ return props.messages.find((item) => item.id === entry.ref.messageID)
+ })
+ const part = createMemo(() => {
+ const entry = entryAccessor()
+ if (entry.type !== "part") return
+ return partByID(list(data.store.part?.[entry.ref.messageID], emptyParts), entry.ref.partID)
+ })
- return (
-
-
- {(key) => {
- const item = createMemo(() => grouped().items[key])
- const ctx = createMemo(() => {
- const value = item()
- if (!value) return
- if (value.type !== "context") return
- return value
- })
- const part = createMemo(() => {
- const value = item()
- if (!value) return
- if (value.type !== "part") return
- return value
- })
- const tail = createMemo(() => last() === key)
- const tool = createMemo(() => {
- const value = part()
- if (!value) return false
- return value.part.type === "tool"
- })
- const context = createMemo(() => !!part()?.context)
- const contextSpring = createMemo(() => {
- const entry = part()
- if (!entry?.context) return undefined
- if (!groupState.controlled(entry.groupKey)) return undefined
- return COLLAPSIBLE_SPRING
- })
- const contextOpen = createMemo(() => {
- const value = ctx()
- if (value) return groupState.read(value.groupKey, true)
- return groupState.read(part()?.groupKey, true)
- })
- const visible = createMemo(() => {
- if (!context()) return true
- if (ctx()) return true
- return false
- })
-
- const turnSummary = createMemo(() => {
- const value = part()
- if (!value) return false
- if (value.part.type !== "text") return false
- if (!props.showTurnDiffSummary) return false
- return props.showAssistantCopyPartID === value.part.id
- })
- const fade = createMemo(() => {
- if (ctx()) return true
- return tool()
- })
- const edge = createMemo(() => {
- const entry = part()
- if (!entry) return false
- if (entry.part.type !== "text") return false
- if (!props.working) return false
- return tail()
- })
- const watch = createMemo(() => !context() && !tool() && tail() && !turnSummary())
- const ctxPartsCache = new Map()
- let ctxPartsPrev: ToolPart[] = []
- const ctxParts = createMemo(() => {
- const parts = ctx()?.parts ?? []
- if (parts.length === 0 && ctxPartsPrev.length > 0) return ctxPartsPrev
- const result: ToolPart[] = []
- for (const item of parts) {
- const k = item.part.callID || item.part.id
- const cached = ctxPartsCache.get(k)
- if (cached) {
- result.push(cached)
- } else {
- ctxPartsCache.set(k, item.part)
- result.push(item.part)
- }
- }
- ctxPartsPrev = result
- return result
- })
- const ctxPending = useContextToolPending(ctxParts, () => !!(props.working && ctx()?.tail))
- const shell = createMemo(() => {
- const value = part()
- if (!value) return
- if (value.part.type !== "tool") return
- if (value.part.tool !== "bash") return
- return value.part
- })
- const kind = createMemo(() => {
- if (ctx()) return "context"
- if (shell()) return "shell"
- const value = part()
- if (!value) return "part"
- return value.part.type
- })
- const shown = createMemo(() => {
- if (ctx()) return true
- if (shell()) return true
- const entry = part()
- if (!entry) return false
- return !entry.context
- })
- const partGrowProps = () => ({
- animate: props.animate,
- gap: 0,
- fade: fade(),
- edge: edge(),
- edgeHeight: 20,
- edgeOpacity: 0.95,
- edgeIdle: 100,
- edgeFade: 0.6,
- edgeRise: 0.1,
- grow: true,
- watch: watch(),
- animateToggle: true,
- open: visible(),
- toggleSpring: contextSpring(),
- })
- return (
-
-
-
- {(entry) => (
- <>
-
- groupState.write(entry().groupKey, value)}
- />
-
-
-
- >
- )}
-
-
- {(value) => (
-
- )}
-
-
- {(entry) => (
-
-
-
-
+ return (
+
+
+
- )}
-
-
-
- )
- }}
-
-
+
+ )
+ })()}
+
+
+ )
+ }}
+
)
}
@@ -618,6 +560,76 @@ function isContextGroupTool(part: PartType): part is ToolPart {
return part.type === "tool" && CONTEXT_GROUP_TOOLS.has(part.tool)
}
+function contextToolDetail(part: ToolPart): string | undefined {
+ const info = getToolInfo(part.tool, part.state.input ?? {})
+ if (info.subtitle) return info.subtitle
+ if (part.state.status === "error") return part.state.error
+ if ((part.state.status === "running" || part.state.status === "completed") && part.state.title)
+ return part.state.title
+ const description = part.state.input?.description
+ if (typeof description === "string") return description
+ return undefined
+}
+
+function contextToolTrigger(part: ToolPart, i18n: ReturnType) {
+ const input = (part.state.input ?? {}) as Record
+ const path = typeof input.path === "string" ? input.path : "/"
+ const filePath = typeof input.filePath === "string" ? input.filePath : undefined
+ const pattern = typeof input.pattern === "string" ? input.pattern : undefined
+ const include = typeof input.include === "string" ? input.include : undefined
+ const offset = typeof input.offset === "number" ? input.offset : undefined
+ const limit = typeof input.limit === "number" ? input.limit : undefined
+
+ switch (part.tool) {
+ case "read": {
+ const args: string[] = []
+ if (offset !== undefined) args.push("offset=" + offset)
+ if (limit !== undefined) args.push("limit=" + limit)
+ return {
+ title: i18n.t("ui.tool.read"),
+ subtitle: filePath ? getFilename(filePath) : "",
+ args,
+ }
+ }
+ case "list":
+ return {
+ title: i18n.t("ui.tool.list"),
+ subtitle: getDirectory(path),
+ }
+ case "glob":
+ return {
+ title: i18n.t("ui.tool.glob"),
+ subtitle: getDirectory(path),
+ args: pattern ? ["pattern=" + pattern] : [],
+ }
+ case "grep": {
+ const args: string[] = []
+ if (pattern) args.push("pattern=" + pattern)
+ if (include) args.push("include=" + include)
+ return {
+ title: i18n.t("ui.tool.grep"),
+ subtitle: getDirectory(path),
+ args,
+ }
+ }
+ default: {
+ const info = getToolInfo(part.tool, input)
+ return {
+ title: info.title,
+ subtitle: info.subtitle || contextToolDetail(part),
+ args: [],
+ }
+ }
+ }
+}
+
+function contextToolSummary(parts: ToolPart[]) {
+ const read = parts.filter((part) => part.tool === "read").length
+ const search = parts.filter((part) => part.tool === "glob" || part.tool === "grep").length
+ const list = parts.filter((part) => part.tool === "list").length
+ return { read, search, list }
+}
+
function ExaOutput(props: { output?: string }) {
const links = createMemo(() => urls(props.output))
@@ -648,11 +660,210 @@ export function registerPartComponent(type: string, component: PartComponent) {
PART_MAPPING[type] = component
}
+export function Message(props: MessageProps) {
+ return (
+
+
+ {(userMessage) => (
+
+ )}
+
+
+ {(assistantMessage) => (
+
+ )}
+
+
+ )
+}
+
+export function AssistantMessageDisplay(props: {
+ message: AssistantMessage
+ parts: PartType[]
+ showAssistantCopyPartID?: string | null
+ showReasoningSummaries?: boolean
+}) {
+ const emptyTools: ToolPart[] = []
+ const grouped = createMemo(
+ () =>
+ groupParts(
+ props.parts
+ .filter((part) => renderable(part, props.showReasoningSummaries ?? true))
+ .map((part) => ({
+ messageID: props.message.id,
+ part,
+ })),
+ ),
+ [] as PartGroup[],
+ { equals: sameGroups },
+ )
+
+ return (
+
+ {(entryAccessor) => {
+ const entryType = createMemo(() => entryAccessor().type)
+
+ return (
+
+
+ {(() => {
+ const parts = createMemo(
+ () => {
+ const entry = entryAccessor()
+ if (entry.type !== "context") return emptyTools
+ return entry.refs
+ .map((ref) => partByID(props.parts, ref.partID))
+ .filter((part): part is ToolPart => !!part && isContextGroupTool(part))
+ },
+ emptyTools,
+ { equals: same },
+ )
+
+ return (
+ 0}>
+
+
+ )
+ })()}
+
+
+ {(() => {
+ const part = createMemo(() => {
+ const entry = entryAccessor()
+ if (entry.type !== "part") return
+ return partByID(props.parts, entry.ref.partID)
+ })
+
+ return (
+
+
+
+ )
+ })()}
+
+
+ )
+ }}
+
+ )
+}
+
+function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
+ const i18n = useI18n()
+ const [open, setOpen] = createSignal(false)
+ const pending = createMemo(
+ () =>
+ !!props.busy || props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"),
+ )
+ const summary = createMemo(() => contextToolSummary(props.parts))
+
+ return (
+
+
+
+
+
+
+
+ {(partAccessor) => {
+ const trigger = createMemo(() => contextToolTrigger(partAccessor(), i18n))
+ const running = createMemo(
+ () => partAccessor().state.status === "pending" || partAccessor().state.status === "running",
+ )
+ return (
+
+
+
+
+
+
+
+
+
+
+ {trigger().subtitle}
+
+
+
+ {(arg) => {arg}}
+
+
+
+
+
+
+
+
+ )
+ }}
+
+
+
+
+ )
+}
+
export function UserMessageDisplay(props: {
message: UserMessage
parts: PartType[]
interrupted?: boolean
- animate?: boolean
queued?: boolean
}) {
const data = useData()
@@ -702,9 +913,14 @@ export function UserMessageDisplay(props: {
return `${hour12}:${minute} ${hours < 12 ? "AM" : "PM"}`
})
- const userMeta = createMemo(() => {
+ const metaHead = createMemo(() => {
const agent = props.message.agent
- const items = [agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", model(), stamp()]
+ const items = [agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", model()]
+ return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0")
+ })
+
+ const metaTail = createMemo(() => {
+ const items = [stamp(), props.interrupted ? i18n.t("ui.message.interrupted") : ""]
return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0")
})
@@ -721,83 +937,93 @@ export function UserMessageDisplay(props: {
}
return (
-
-
-
-
0}>
-
-
- {(file) => (
- {
- if (file.mime.startsWith("image/") && file.url) {
- openImagePreview(file.url, file.filename)
- }
- }}
- >
-
-
-
- }
- >
-
-
-
- )}
-
+
+
0}>
+
+
+ {(file) => (
+ {
+ if (file.mime.startsWith("image/") && file.url) {
+ openImagePreview(file.url, file.filename)
+ }
+ }}
+ >
+
+
+
+ }
+ >
+
+
+
+ )}
+
+
+
+
+ <>
+
+
+
-
-
- <>
-
-
-
-
-
-
-
-
-
+
+
+
-
-
+
+
+
+
+
+
- {userMeta()}
+ {metaHead()}
-
- e.preventDefault()}
- onClick={(event) => {
- event.stopPropagation()
- handleCopy()
- }}
- aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
- />
-
-
- >
-
-
-
-
+
+
+ {"\u00A0\u00B7\u00A0"}
+
+
+
+
+ {metaTail()}
+
+
+
+
+
+ e.preventDefault()}
+ onClick={(event) => {
+ event.stopPropagation()
+ handleCopy()
+ }}
+ aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
+ />
+
+
+ >
+
+
)
}
@@ -851,10 +1077,7 @@ export function Part(props: MessagePartProps) {
hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen}
showAssistantCopyPartID={props.showAssistantCopyPartID}
- showTurnDiffSummary={props.showTurnDiffSummary}
- turnDiffSummary={props.turnDiffSummary}
- animate={props.animate}
- working={props.working}
+ turnDurationMs={props.turnDurationMs}
/>
)
@@ -864,16 +1087,12 @@ export interface ToolProps {
input: Record
metadata: Record
tool: string
- partID?: string
- callID?: string
output?: string
status?: string
hideDetails?: boolean
defaultOpen?: boolean
forceOpen?: boolean
locked?: boolean
- animate?: boolean
- reveal?: boolean
}
export type ToolComponent = Component
@@ -907,7 +1126,7 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre
@@ -938,26 +1157,30 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre
PART_MAPPING["tool"] = function ToolPartDisplay(props) {
const i18n = useI18n()
- const part = props.part as ToolPart
- const hideQuestion = createMemo(() => part.tool === "question" && busy(part.state.status))
+ const part = () => props.part as ToolPart
+ if (part().tool === "todowrite" || part().tool === "todoread") return null
+
+ const hideQuestion = createMemo(
+ () => part().tool === "question" && (part().state.status === "pending" || part().state.status === "running"),
+ )
const emptyInput: Record = {}
const emptyMetadata: Record = {}
- const input = () => part.state?.input ?? emptyInput
+ const input = () => part().state?.input ?? emptyInput
// @ts-expect-error
- const partMetadata = () => part.state?.metadata ?? emptyMetadata
+ const partMetadata = () => part().state?.metadata ?? emptyMetadata
- const render = createMemo(() => ToolRegistry.render(part.tool) ?? GenericTool)
+ const render = createMemo(() => ToolRegistry.render(part().tool) ?? GenericTool)
return (
-
+
-
+
{(error) => {
const cleaned = error().replace("Error: ", "")
- if (part.tool === "question" && cleaned.includes("dismissed this question")) {
+ if (part().tool === "question" && cleaned.includes("dismissed this question")) {
return (
@@ -991,17 +1214,13 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
@@ -1026,16 +1245,74 @@ PART_MAPPING["compaction"] = function CompactionPartDisplay() {
}
PART_MAPPING["text"] = function TextPartDisplay(props) {
+ const data = useData()
+ const i18n = useI18n()
const part = () => props.part as TextPart
+ const interrupted = createMemo(
+ () =>
+ props.message.role === "assistant" && (props.message as AssistantMessage).error?.name === "MessageAbortedError",
+ )
+
+ const model = createMemo(() => {
+ if (props.message.role !== "assistant") return ""
+ const message = props.message as AssistantMessage
+ const match = data.store.provider?.all?.find((p) => p.id === message.providerID)
+ return match?.models?.[message.modelID]?.name ?? message.modelID
+ })
+
+ const duration = createMemo(() => {
+ if (props.message.role !== "assistant") return ""
+ const message = props.message as AssistantMessage
+ const completed = message.time.completed
+ const ms =
+ typeof props.turnDurationMs === "number"
+ ? props.turnDurationMs
+ : typeof completed === "number"
+ ? completed - message.time.created
+ : -1
+ if (!(ms >= 0)) return ""
+ const total = Math.round(ms / 1000)
+ if (total < 60) return `${total}s`
+ const minutes = Math.floor(total / 60)
+ const seconds = total % 60
+ return `${minutes}m ${seconds}s`
+ })
+
+ const meta = createMemo(() => {
+ if (props.message.role !== "assistant") return ""
+ const agent = (props.message as AssistantMessage).agent
+ const items = [
+ agent ? agent[0]?.toUpperCase() + agent.slice(1) : "",
+ model(),
+ duration(),
+ interrupted() ? i18n.t("ui.message.interrupted") : "",
+ ]
+ return items.filter((x) => !!x).join(" \u00B7 ")
+ })
const displayText = () => (part().text ?? "").trim()
const throttledText = createThrottledValue(displayText)
- const summary = createMemo(() => {
- if (props.message.role !== "assistant") return
- if (!props.showTurnDiffSummary) return
- if (props.showAssistantCopyPartID !== part().id) return
- return props.turnDiffSummary
+ const isLastTextPart = createMemo(() => {
+ const last = (data.store.part?.[props.message.id] ?? [])
+ .filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim())
+ .at(-1)
+ return last?.id === part().id
})
+ const showCopy = createMemo(() => {
+ if (props.message.role !== "assistant") return isLastTextPart()
+ if (props.showAssistantCopyPartID === null) return false
+ if (typeof props.showAssistantCopyPartID === "string") return props.showAssistantCopyPartID === part().id
+ return isLastTextPart()
+ })
+ const [copied, setCopied] = createSignal(false)
+
+ const handleCopy = async () => {
+ const content = displayText()
+ if (!content) return
+ await navigator.clipboard.writeText(content)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ }
return (
@@ -1043,12 +1320,28 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
-
- {(render) => (
-
- {render()()}
-
- )}
+
+
+
+ e.preventDefault()}
+ onClick={handleCopy}
+ aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")}
+ />
+
+
+
+ {meta()}
+
+
+
@@ -1078,33 +1371,30 @@ ToolRegistry.register({
if (props.input.offset) args.push("offset=" + props.input.offset)
if (props.input.limit) args.push("limit=" + props.input.limit)
const loaded = createMemo(() => {
+ if (props.status !== "completed") return []
const value = props.metadata.loaded
if (!value || !Array.isArray(value)) return []
return value.filter((p): p is string => typeof p === "string")
})
- const pending = createMemo(() => busy(props.status))
return (
<>
-
- }
+ trigger={{
+ title: i18n.t("ui.tool.read"),
+ subtitle: props.input.filePath ? getFilename(props.input.filePath) : "",
+ args,
+ }}
/>
{(filepath) => (
-
+
+
+
+ {i18n.t("ui.tool.loaded")} {relativizeProjectPath(filepath, data.directory)}
+
+
)}
>
@@ -1116,29 +1406,18 @@ ToolRegistry.register({
name: "list",
render(props) {
const i18n = useI18n()
- const pending = createMemo(() => busy(props.status))
return (
-
- }
+ trigger={{ title: i18n.t("ui.tool.list"), subtitle: getDirectory(props.input.path || "/") }}
>
- {(output) => (
-
-
-
- )}
+
+
+
-
+
)
},
})
@@ -1147,30 +1426,22 @@ ToolRegistry.register({
name: "glob",
render(props) {
const i18n = useI18n()
- const pending = createMemo(() => busy(props.status))
return (
-
- }
+ trigger={{
+ title: i18n.t("ui.tool.glob"),
+ subtitle: getDirectory(props.input.path || "/"),
+ args: props.input.pattern ? ["pattern=" + props.input.pattern] : [],
+ }}
>
- {(output) => (
-
-
-
- )}
+
+
+
-
+
)
},
})
@@ -1182,214 +1453,40 @@ ToolRegistry.register({
const args: string[] = []
if (props.input.pattern) args.push("pattern=" + props.input.pattern)
if (props.input.include) args.push("include=" + props.input.include)
- const pending = createMemo(() => busy(props.status))
return (
-
- }
+ trigger={{
+ title: i18n.t("ui.tool.grep"),
+ subtitle: getDirectory(props.input.path || "/"),
+ args,
+ }}
>
- {(output) => (
-
-
-
- )}
+
+
+
-
+
)
},
})
-function useToolReveal(pending: () => boolean, animate?: () => boolean) {
- const enabled = () => animate?.() ?? true
- const [live, setLive] = createSignal(pending() || enabled())
- createEffect(() => {
- if (pending()) setLive(true)
- })
- return () => enabled() && live()
-}
-
-function WebfetchMeta(props: { url: string; animate?: boolean }) {
- let ref: HTMLSpanElement | undefined
- useToolFade(() => ref, { wipe: true, animate: props.animate })
-
- return (
-
- event.stopPropagation()}
- >
- {props.url}
-
-
-
-
-
- )
-}
-
-function TaskLink(props: { href: string; text: string; onClick: (e: MouseEvent) => void; animate?: boolean }) {
- let ref: HTMLAnchorElement | undefined
- useToolFade(() => ref, { wipe: true, animate: props.animate })
-
- return (
-
- {props.text}
-
- )
-}
-
-function ToolText(props: { text: string; delay?: number; animate?: boolean }) {
- let ref: HTMLSpanElement | undefined
- useToolFade(() => ref, { delay: props.delay, wipe: true, animate: props.animate })
-
- return (
-
- {props.text}
-
- )
-}
-
-function ToolLoadedFile(props: { text: string; animate?: boolean }) {
- let ref: HTMLDivElement | undefined
- useToolFade(() => ref, { delay: 0.02, wipe: true, animate: props.animate })
-
- return (
-
-
-
- {props.text}
-
-
- )
-}
-
-function ToolTriggerRow(props: {
- title: string
- pending: boolean
- subtitle?: string
- args?: string[]
- action?: JSX.Element
- animate?: boolean
- revealOnMount?: boolean
-}) {
- const reveal = useToolReveal(
- () => props.pending,
- () => props.animate !== false,
- )
- const detail = createMemo(() => [props.subtitle, ...(props.args ?? [])].filter((x): x is string => !!x).join(" "))
- const detailAnimate = createMemo(() => {
- if (props.animate === false) return false
- if (props.revealOnMount) return true
- if (!props.pending && !reveal()) return true
- return reveal()
- })
-
- return (
-
-
-
-
-
- {(text) => }
-
-
{props.action}
-
- )
-}
-
-type DiffValue = { additions: number; deletions: number } | { additions: number; deletions: number }[]
-
-function ToolMetaLine(props: {
- filename: string
- path?: string
- changes?: DiffValue
- delay?: number
- animate?: boolean
- soft?: boolean
-}) {
- let ref: HTMLSpanElement | undefined
- useToolFade(() => ref, { delay: props.delay ?? 0.02, wipe: true, animate: props.animate })
-
- return (
-
- {props.filename}
-
- {props.path}
-
- {(changes) => }
-
- )
-}
-
-function ToolChanges(props: { changes: DiffValue; animate?: boolean }) {
- let ref: HTMLDivElement | undefined
- useToolFade(() => ref, { delay: 0.04, animate: props.animate })
-
- return (
-
-
-
- )
-}
-
-function ShellText(props: { text: string; animate?: boolean }) {
- let ref: HTMLSpanElement | undefined
- useToolFade(() => ref, { wipe: true, animate: props.animate })
-
- return (
-
-
-
- {props.text}
-
-
-
- )
-}
-
ToolRegistry.register({
name: "webfetch",
render(props) {
const i18n = useI18n()
- const pending = createMemo(() => busy(props.status))
- const reveal = useToolReveal(pending, () => props.reveal !== false)
+ const pending = createMemo(() => props.status === "pending" || props.status === "running")
const url = createMemo(() => {
const value = props.input.url
if (typeof value !== "string") return ""
return value
})
return (
-
@@ -1397,8 +1494,24 @@ ToolRegistry.register({
- {(value) => }
+
+ event.stopPropagation()}
+ >
+ {url()}
+
+
+
+
+
+
+
}
/>
@@ -1417,8 +1530,7 @@ ToolRegistry.register({
})
return (
-
-
+
)
},
})
@@ -1444,8 +1556,7 @@ ToolRegistry.register({
})
return (
-
-
+
)
},
})
@@ -1465,6 +1576,7 @@ ToolRegistry.register({
render(props) {
const data = useData()
const i18n = useI18n()
+ const location = useLocation()
const childSessionId = () => props.metadata.sessionId as string | undefined
const type = createMemo(() => {
const raw = props.input.subagent_type
@@ -1477,8 +1589,7 @@ ToolRegistry.register({
if (typeof value === "string") return value
return undefined
})
- const running = createMemo(() => busy(props.status))
- const reveal = useToolReveal(running, () => props.reveal !== false)
+ const running = createMemo(() => props.status === "pending" || props.status === "running")
const href = createMemo(() => {
const sessionId = childSessionId()
@@ -1487,49 +1598,34 @@ ToolRegistry.register({
const direct = data.sessionHref?.(sessionId)
if (direct) return direct
- if (typeof window === "undefined") return
- const path = window.location.pathname
+ const path = location.pathname
const idx = path.indexOf("/session")
if (idx === -1) return
return `${path.slice(0, idx)}/session/${sessionId}`
})
- const handleLinkClick = (e: MouseEvent) => {
- const sessionId = childSessionId()
- const url = href()
- if (!sessionId || !url) return
-
- e.stopPropagation()
-
- if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
-
- const nav = data.navigateToSession
- if (!nav || typeof window === "undefined") return
-
- e.preventDefault()
- const before = window.location.pathname + window.location.search + window.location.hash
- nav(sessionId)
- setTimeout(() => {
- const after = window.location.pathname + window.location.search + window.location.hash
- if (after === before) window.location.assign(url)
- }, 50)
- }
+ const titleContent = () =>
const trigger = () => (
)
- return
+ return
},
})
@@ -1545,26 +1641,13 @@ ToolRegistry.register({
name: "bash",
render(props) {
const i18n = useI18n()
- const pending = () => busy(props.status)
- const reveal = useToolReveal(pending, () => props.reveal !== false)
- const subtitle = () => props.input.description ?? props.metadata.description
- const cmd = createMemo(() => {
- const value = props.input.command ?? props.metadata.command
- if (typeof value === "string") return value
- return ""
- })
- const output = createMemo(() => {
- if (typeof props.output === "string") return props.output
- if (typeof props.metadata.output === "string") return props.metadata.output
- return ""
- })
- const command = createMemo(() => `$ ${cmd()}`)
- const result = createMemo(() => stripAnsi(output()))
+ const pending = () => props.status === "pending" || props.status === "running"
+ const sawPending = pending()
const text = createMemo(() => {
- const value = result()
- return `${command()}${value ? "\n\n" + value : ""}`
+ const cmd = props.input.command ?? props.metadata.command ?? ""
+ const out = stripAnsi(props.output || props.metadata.output || "")
+ return `$ ${cmd}${out ? "\n\n" + out : ""}`
})
- const hasOutput = createMemo(() => result().length > 0)
const [copied, setCopied] = createSignal(false)
const handleCopy = async () => {
@@ -1576,20 +1659,18 @@ ToolRegistry.register({
}
return (
-
- {(text) => }
+
+
+
}
@@ -1617,7 +1698,7 @@ ToolRegistry.register({
-
+
)
},
})
@@ -1630,12 +1711,10 @@ ToolRegistry.register({
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
const path = createMemo(() => props.metadata?.filediff?.file || props.input.filePath || "")
const filename = () => getFilename(props.input.filePath ?? "")
- const pending = () => busy(props.status)
- const reveal = useToolReveal(pending, () => props.reveal !== false)
+ const pending = () => props.status === "pending" || props.status === "running"
return (
-
-
- {(name) => (
-
- )}
+
+ {filename()}
+
+
+ {getDirectory(props.input.filePath!)}
+
+
+
+
+
+
+
}
@@ -1666,7 +1748,7 @@ ToolRegistry.register({
path={path()}
actions={
- {(diff) => }
+
}
>
@@ -1687,7 +1769,7 @@ ToolRegistry.register({
-
+
)
},
@@ -1701,12 +1783,10 @@ ToolRegistry.register({
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
const path = createMemo(() => props.input.filePath || "")
const filename = () => getFilename(props.input.filePath ?? "")
- const pending = () => busy(props.status)
- const reveal = useToolReveal(pending, () => props.reveal !== false)
+ const pending = () => props.status === "pending" || props.status === "running"
return (
-
-
- {(name) => (
-
- )}
+
+ {filename()}
+
+
+ {getDirectory(props.input.filePath!)}
+
+
+ {/* */}
}
>
@@ -1748,7 +1828,7 @@ ToolRegistry.register({
-
+
)
},
@@ -1772,8 +1852,7 @@ ToolRegistry.register({
const i18n = useI18n()
const fileComponent = useFileComponent()
const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[])
- const pending = createMemo(() => busy(props.status))
- const reveal = useToolReveal(pending, () => props.reveal !== false)
+ const pending = createMemo(() => props.status === "pending" || props.status === "running")
const single = createMemo(() => {
const list = files()
if (list.length !== 1) return
@@ -1781,6 +1860,7 @@ ToolRegistry.register({
})
const [expanded, setExpanded] = createSignal([])
let seeded = false
+
createEffect(() => {
const list = files()
if (list.length === 0) return
@@ -1788,6 +1868,7 @@ ToolRegistry.register({
seeded = true
setExpanded(list.filter((f) => f.type !== "delete").map((f) => f.filePath))
})
+
const subtitle = createMemo(() => {
const count = files().length
if (count === 0) return ""
@@ -1795,44 +1876,24 @@ ToolRegistry.register({
})
return (
-
-
-
-
-
-
-
-
- {(file) => (
-
- )}
-
- {(text) => }
-
-
-
- }
- >
-
+
0}>
setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
>
@@ -1840,11 +1901,13 @@ ToolRegistry.register({
{(file) => {
const active = createMemo(() => expanded().includes(file.filePath))
const [visible, setVisible] = createSignal(false)
+
createEffect(() => {
if (!active()) {
setVisible(false)
return
}
+
requestAnimationFrame(() => {
if (!active()) return
setVisible(true)
@@ -1909,50 +1972,77 @@ ToolRegistry.register({
+
+
+ }
+ >
+
+
+
+
+
+
+
+
+ {getFilename(single()!.relativePath)}
+
+
+
+
+ {getDirectory(single()!.relativePath)}
+
+
+
+
+
+
+
+
+
}
>
- {(file) => (
-
-
-
- {i18n.t("ui.patch.action.created")}
-
-
-
-
- {i18n.t("ui.patch.action.deleted")}
-
-
-
-
- {i18n.t("ui.patch.action.moved")}
-
-
-
-
-
-
- }
- >
-
-
-
-
- )}
-
-
-
+
+
+
+ {i18n.t("ui.patch.action.created")}
+
+
+
+
+ {i18n.t("ui.patch.action.deleted")}
+
+
+
+
+ {i18n.t("ui.patch.action.moved")}
+
+
+
+
+
+
+ }
+ >
+
+
+
+
+
+
+
)
},
})
@@ -1970,7 +2060,6 @@ ToolRegistry.register({
return []
})
- const pending = createMemo(() => busy(props.status))
const subtitle = createMemo(() => {
const list = todos()
@@ -1979,19 +2068,14 @@ ToolRegistry.register({
})
return (
-
- }
+ trigger={{
+ title: i18n.t("ui.tool.todos"),
+ subtitle: subtitle(),
+ }}
>
@@ -2009,7 +2093,7 @@ ToolRegistry.register({
-
+
)
},
})
@@ -2021,7 +2105,6 @@ ToolRegistry.register({
const questions = createMemo(() => (props.input.questions ?? []) as QuestionInfo[])
const answers = createMemo(() => (props.metadata.answers ?? []) as QuestionAnswer[])
const completed = createMemo(() => answers().length > 0)
- const pending = createMemo(() => busy(props.status))
const subtitle = createMemo(() => {
const count = questions().length
@@ -2031,19 +2114,14 @@ ToolRegistry.register({
})
return (
-
- }
+ trigger={{
+ title: i18n.t("ui.tool.questions"),
+ subtitle: subtitle(),
+ }}
>
@@ -2060,7 +2138,7 @@ ToolRegistry.register({
-
+
)
},
})
@@ -2068,28 +2146,21 @@ ToolRegistry.register({
ToolRegistry.register({
name: "skill",
render(props) {
- const i18n = useI18n()
- const pending = createMemo(() => busy(props.status))
- const name = createMemo(() => {
- const value = props.input.name || props.metadata.name
- if (typeof value === "string") return value
- })
- return (
-
- }
- animate
- />
+ const title = createMemo(() => props.input.name || "skill")
+ const running = createMemo(() => props.status === "pending" || props.status === "running")
+
+ const titleContent = () =>
+
+ const trigger = () => (
+
+
+
+ {titleContent()}
+
+
+
)
+
+ return
},
})
diff --git a/packages/ui/src/components/motion-spring.tsx b/packages/ui/src/components/motion-spring.tsx
index c7ff1fbcd26..a5104a1a3ef 100644
--- a/packages/ui/src/components/motion-spring.tsx
+++ b/packages/ui/src/components/motion-spring.tsx
@@ -1,9 +1,8 @@
import { attachSpring, motionValue } from "motion"
import type { SpringOptions } from "motion"
import { createEffect, createSignal, onCleanup } from "solid-js"
-import { useReducedMotion } from "../hooks/use-reduced-motion"
-type Opt = Pick
+type Opt = Partial>
const eq = (a: Opt | undefined, b: Opt | undefined) =>
a?.visualDuration === b?.visualDuration &&
a?.bounce === b?.bounce &&
@@ -14,41 +13,24 @@ const eq = (a: Opt | undefined, b: Opt | undefined) =>
export function useSpring(target: () => number, options?: Opt | (() => Opt)) {
const read = () => (typeof options === "function" ? options() : options)
- const reduce = useReducedMotion()
const [value, setValue] = createSignal(target())
const source = motionValue(value())
const spring = motionValue(value())
let config = read()
- let reduced = reduce()
- let stop = reduced ? () => {} : attachSpring(spring, source, config)
- let off = spring.on("change", (next) => setValue(next))
+ let stop = attachSpring(spring, source, config)
+ let off = spring.on("change", (next: number) => setValue(next))
createEffect(() => {
- const next = target()
- if (reduced) {
- source.set(next)
- spring.set(next)
- setValue(next)
- return
- }
- source.set(next)
+ source.set(target())
})
createEffect(() => {
+ if (!options) return
const next = read()
- const skip = reduce()
- if (eq(config, next) && reduced === skip) return
+ if (eq(config, next)) return
config = next
- reduced = skip
stop()
- stop = skip ? () => {} : attachSpring(spring, source, next)
- if (skip) {
- const value = target()
- source.set(value)
- spring.set(value)
- setValue(value)
- return
- }
+ stop = attachSpring(spring, source, next)
setValue(spring.get())
})
diff --git a/packages/ui/src/components/motion.tsx b/packages/ui/src/components/motion.tsx
deleted file mode 100644
index 6cdf01c7314..00000000000
--- a/packages/ui/src/components/motion.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-import { followValue } from "motion"
-import type { MotionValue } from "motion"
-
-export { animate, springValue } from "motion"
-export type { AnimationPlaybackControls } from "motion"
-
-/**
- * Like `springValue` but preserves getters on the config object.
- * `springValue` spreads config at creation, snapshotting getter values.
- * This passes the config through to `followValue` intact, so getters
- * on `visualDuration` etc. fire on every `.set()` call.
- */
-export function tunableSpringValue(initial: T, config: SpringConfig): MotionValue {
- return followValue(initial, config as any)
-}
-
-let _growDuration = 0.5
-let _collapsibleDuration = 0.3
-
-export const GROW_SPRING = {
- type: "spring" as const,
- get visualDuration() {
- return _growDuration
- },
- bounce: 0,
-}
-
-export const COLLAPSIBLE_SPRING = {
- type: "spring" as const,
- get visualDuration() {
- return _collapsibleDuration
- },
- bounce: 0,
-}
-
-export const setGrowDuration = (v: number) => {
- _growDuration = v
-}
-export const setCollapsibleDuration = (v: number) => {
- _collapsibleDuration = v
-}
-export const getGrowDuration = () => _growDuration
-export const getCollapsibleDuration = () => _collapsibleDuration
-
-export type SpringConfig = { type: "spring"; visualDuration: number; bounce: number }
-
-export const FAST_SPRING = {
- type: "spring" as const,
- visualDuration: 0.35,
- bounce: 0,
-}
-
-export const GLOW_SPRING = {
- type: "spring" as const,
- visualDuration: 0.4,
- bounce: 0.15,
-}
-
-export const WIPE_MASK =
- "linear-gradient(to right, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 45%, rgba(0,0,0,0) 60%, rgba(0,0,0,0) 100%)"
-
-export const clearMaskStyles = (el: HTMLElement) => {
- el.style.maskImage = ""
- el.style.webkitMaskImage = ""
- el.style.maskSize = ""
- el.style.webkitMaskSize = ""
- el.style.maskRepeat = ""
- el.style.webkitMaskRepeat = ""
- el.style.maskPosition = ""
- el.style.webkitMaskPosition = ""
-}
-
-export const clearFadeStyles = (el: HTMLElement) => {
- el.style.opacity = ""
- el.style.filter = ""
- el.style.transform = ""
-}
diff --git a/packages/ui/src/components/rolling-results.css b/packages/ui/src/components/rolling-results.css
deleted file mode 100644
index 200b2a97e93..00000000000
--- a/packages/ui/src/components/rolling-results.css
+++ /dev/null
@@ -1,92 +0,0 @@
-[data-component="rolling-results"] {
- --rolling-results-row-height: 22px;
- --rolling-results-fixed-height: var(--rolling-results-row-height);
- --rolling-results-fixed-gap: 0px;
- --rolling-results-row-gap: 0px;
-
- display: block;
- width: 100%;
- min-width: 0;
-
- [data-slot="rolling-results-viewport"] {
- position: relative;
- min-width: 0;
- height: 0;
- overflow: clip;
- }
-
- &[data-overflowing="true"]:not([data-scrollable="true"]) [data-slot="rolling-results-window"] {
- mask-image: linear-gradient(
- to bottom,
- transparent 0%,
- black var(--rolling-results-fade),
- black calc(100% - calc(var(--rolling-results-fade) * 0.5)),
- transparent 100%
- );
- -webkit-mask-image: linear-gradient(
- to bottom,
- transparent 0%,
- black var(--rolling-results-fade),
- black calc(100% - calc(var(--rolling-results-fade) * 0.5)),
- transparent 100%
- );
- }
-
- [data-slot="rolling-results-fixed"] {
- min-width: 0;
- height: var(--rolling-results-fixed-height);
- min-height: var(--rolling-results-fixed-height);
- display: flex;
- align-items: center;
- }
-
- [data-slot="rolling-results-window"] {
- min-width: 0;
- margin-top: var(--rolling-results-fixed-gap);
- height: calc(100% - var(--rolling-results-fixed-height) - var(--rolling-results-fixed-gap));
- overflow: clip;
- }
-
- &[data-scrollable="true"] [data-slot="rolling-results-window"] {
- scrollbar-width: none;
- -ms-overflow-style: none;
-
- &::-webkit-scrollbar {
- display: none;
- }
- }
-
- &[data-scrollable="true"] [data-slot="rolling-results-track"] {
- transform: none !important;
- will-change: auto;
- }
-
- [data-slot="rolling-results-body"] {
- min-width: 0;
- }
-
- [data-slot="rolling-results-track"] {
- display: flex;
- min-width: 0;
- flex-direction: column;
- gap: var(--rolling-results-row-gap);
- will-change: transform;
- }
-
- [data-slot="rolling-results-row"],
- [data-slot="rolling-results-empty"] {
- min-width: 0;
- height: var(--rolling-results-row-height);
- min-height: var(--rolling-results-row-height);
- display: flex;
- align-items: center;
- }
-
- [data-slot="rolling-results-row"] {
- color: var(--text-base);
- }
-
- [data-slot="rolling-results-empty"] {
- color: var(--text-weaker);
- }
-}
diff --git a/packages/ui/src/components/rolling-results.tsx b/packages/ui/src/components/rolling-results.tsx
deleted file mode 100644
index 77ffdb1b349..00000000000
--- a/packages/ui/src/components/rolling-results.tsx
+++ /dev/null
@@ -1,325 +0,0 @@
-import { For, Show, batch, createEffect, createMemo, createSignal, on, onCleanup, onMount, type JSX } from "solid-js"
-import { useReducedMotion } from "../hooks/use-reduced-motion"
-import { animate, clearMaskStyles, GROW_SPRING, type AnimationPlaybackControls, type SpringConfig } from "./motion"
-
-export type RollingResultsProps = {
- items: T[]
- render: (item: T, index: number) => JSX.Element
- fixed?: JSX.Element
- getKey?: (item: T, index: number) => string
- rows?: number
- rowHeight?: number
- fixedHeight?: number
- rowGap?: number
- open?: boolean
- scrollable?: boolean
- spring?: SpringConfig
- animate?: boolean
- class?: string
- empty?: JSX.Element
- noFadeOnCollapse?: boolean
-}
-
-export function RollingResults(props: RollingResultsProps) {
- let view: HTMLDivElement | undefined
- let track: HTMLDivElement | undefined
- let windowEl: HTMLDivElement | undefined
- let shift: AnimationPlaybackControls | undefined
- let resize: AnimationPlaybackControls | undefined
- let edgeFade: AnimationPlaybackControls | undefined
- const reduce = useReducedMotion()
-
- const rows = createMemo(() => Math.max(1, Math.round(props.rows ?? 3)))
- const rowHeight = createMemo(() => Math.max(16, Math.round(props.rowHeight ?? 22)))
- const fixedHeight = createMemo(() => Math.max(0, Math.round(props.fixedHeight ?? rowHeight())))
- const rowGap = createMemo(() => Math.max(0, Math.round(props.rowGap ?? 0)))
- const fixed = createMemo(() => props.fixed !== undefined)
- const list = createMemo(() => props.items ?? [])
- const count = createMemo(() => list().length)
-
- // scrollReady is the internal "transition complete" state.
- // It only becomes true after props.scrollable is true AND the offset animation has settled.
- const [scrollReady, setScrollReady] = createSignal(false)
-
- const backstop = createMemo(() => Math.max(rows() * 2, 12))
- const rendered = createMemo(() => {
- const items = list()
- if (scrollReady()) return items
- const max = backstop()
- return items.length > max ? items.slice(-max) : items
- })
- const skipped = createMemo(() => {
- if (scrollReady()) return 0
- return count() - rendered().length
- })
- const open = createMemo(() => props.open !== false)
- const active = createMemo(() => (props.animate !== false || props.spring !== undefined) && !reduce())
- const noFade = () => props.noFadeOnCollapse === true
- const overflowing = createMemo(() => count() > rows())
- const shown = createMemo(() => Math.min(rows(), count()))
- const step = createMemo(() => rowHeight() + rowGap())
- const offset = createMemo(() => Math.max(0, count() - shown()) * step())
- const body = createMemo(() => {
- if (shown() > 0) {
- return shown() * rowHeight() + Math.max(0, shown() - 1) * rowGap()
- }
- if (props.empty === undefined) return 0
- return rowHeight()
- })
- const gap = createMemo(() => {
- if (!fixed()) return 0
- if (body() <= 0) return 0
- return rowGap()
- })
- const height = createMemo(() => {
- if (!open()) return 0
- if (!fixed()) return body()
- return fixedHeight() + gap() + body()
- })
-
- const key = (item: T, index: number) => {
- const value = props.getKey
- if (value) return value(item, index)
- return String(index)
- }
-
- const setTrack = (value: number) => {
- if (!track) return
- track.style.transform = `translateY(${-Math.round(value)}px)`
- }
-
- const setView = (value: number) => {
- if (!view) return
- view.style.height = `${Math.max(0, Math.round(value))}px`
- }
-
- onMount(() => {
- setTrack(offset())
- })
-
- // Original WAAPI offset animation — untouched rolling behavior.
- createEffect(
- on(
- offset,
- (next) => {
- if (!track) return
- if (scrollReady()) return
- if (props.scrollable) return
- if (!active()) {
- shift?.stop()
- shift = undefined
- setTrack(next)
- return
- }
- shift?.stop()
- const anim = animate(track, { transform: `translateY(${-next}px)` }, props.spring ?? GROW_SPRING)
- shift = anim
- anim.finished
- .catch(() => {})
- .finally(() => {
- if (shift !== anim) return
- setTrack(next)
- shift = undefined
- })
- },
- { defer: true },
- ),
- )
-
- // Scrollable transition: wait for the offset animation to finish,
- // then batch all DOM changes in one synchronous pass.
- createEffect(
- on(
- () => props.scrollable === true,
- (isScrollable) => {
- if (!isScrollable) {
- setScrollReady(false)
- if (windowEl) {
- windowEl.style.overflowY = ""
- windowEl.style.maskImage = ""
- windowEl.style.webkitMaskImage = ""
- }
- return
- }
- // Wait for the current offset animation to settle (if any).
- const done = shift?.finished ?? Promise.resolve()
- done
- .catch(() => {})
- .then(() => {
- if (props.scrollable !== true) return
-
- // Batch the signal update — Solid updates the DOM synchronously:
- // rendered() returns all items, skipped() returns 0, padding-top removed,
- // data-scrollable becomes "true".
- batch(() => setScrollReady(true))
-
- // Now the DOM has all items. Safe to switch layout strategy.
- // CSS handles `transform: none !important` on [data-scrollable="true"].
- if (windowEl) {
- windowEl.style.overflowY = "auto"
- windowEl.scrollTop = windowEl.scrollHeight
- }
- updateScrollMask()
- })
- },
- ),
- )
-
- // Auto-scroll to bottom when new items arrive in scrollable mode
- const [userScrolled, setUserScrolled] = createSignal(false)
-
- const updateScrollMask = () => {
- if (!windowEl) return
- if (!scrollReady()) {
- windowEl.style.maskImage = ""
- windowEl.style.webkitMaskImage = ""
- return
- }
- const { scrollTop, scrollHeight, clientHeight } = windowEl
- const atBottom = scrollHeight - scrollTop - clientHeight < 8
- // Top fade is always present in scrollable mode (matches rolling mode appearance).
- // Bottom fade only when not scrolled to the end.
- const mask = atBottom
- ? "linear-gradient(to bottom, transparent 0, black 8px)"
- : "linear-gradient(to bottom, transparent 0, black 8px, black calc(100% - 8px), transparent 100%)"
- windowEl.style.maskImage = mask
- windowEl.style.webkitMaskImage = mask
- }
-
- createEffect(() => {
- if (!scrollReady()) {
- setUserScrolled(false)
- return
- }
- const _n = count()
- const scrolled = userScrolled()
- if (scrolled) return
- if (windowEl) {
- windowEl.scrollTop = windowEl.scrollHeight
- updateScrollMask()
- }
- })
-
- const onWindowScroll = () => {
- if (!windowEl || !scrollReady()) return
- const atBottom = windowEl.scrollHeight - windowEl.scrollTop - windowEl.clientHeight < 8
- setUserScrolled(!atBottom)
- updateScrollMask()
- }
-
- const EDGE_MASK = "linear-gradient(to top, transparent 0%, black 8px)"
- const applyEdge = () => {
- if (!view) return
- edgeFade?.stop()
- edgeFade = undefined
- view.style.maskImage = EDGE_MASK
- view.style.webkitMaskImage = EDGE_MASK
- view.style.maskSize = "100% 100%"
- view.style.maskRepeat = "no-repeat"
- }
- const clearEdge = () => {
- if (!view) return
- if (!active()) {
- clearMaskStyles(view)
- return
- }
- edgeFade?.stop()
- const anim = animate(view, { maskSize: "100% 200%" }, props.spring ?? GROW_SPRING)
- edgeFade = anim
- anim.finished
- .catch(() => {})
- .then(() => {
- if (edgeFade !== anim || !view) return
- clearMaskStyles(view)
- edgeFade = undefined
- })
- }
-
- createEffect(
- on(height, (next, prev) => {
- if (!view) return
- if (!active()) {
- resize?.stop()
- resize = undefined
- setView(next)
- view.style.opacity = ""
- clearEdge()
- return
- }
- const collapsing = next === 0 && prev !== undefined && prev > 0
- const expanding = prev === 0 && next > 0
- resize?.stop()
- view.style.opacity = ""
- applyEdge()
- const spring = props.spring ?? GROW_SPRING
- const anim = collapsing
- ? animate(view, noFade() ? { height: `${next}px` } : { height: `${next}px`, opacity: 0 }, spring)
- : expanding
- ? animate(view, noFade() ? { height: `${next}px` } : { height: `${next}px`, opacity: [0, 1] }, spring)
- : animate(view, { height: `${next}px` }, spring)
- resize = anim
- anim.finished
- .catch(() => {})
- .finally(() => {
- view.style.opacity = ""
- if (resize !== anim) return
- setView(next)
- resize = undefined
- clearEdge()
- })
- }),
- )
-
- onCleanup(() => {
- shift?.stop()
- resize?.stop()
- edgeFade?.stop()
- shift = undefined
- resize = undefined
- edgeFade = undefined
- })
-
- return (
-
-
-
- {props.fixed}
-
-
-
-
- {props.empty}
-
-
-
- {(item, index) => (
-
- {props.render(item, index())}
-
- )}
-
-
-
-
-
-
- )
-}
diff --git a/packages/ui/src/components/scroll-view.css b/packages/ui/src/components/scroll-view.css
index a8574cc9f7b..f6a49e241c6 100644
--- a/packages/ui/src/components/scroll-view.css
+++ b/packages/ui/src/components/scroll-view.css
@@ -9,13 +9,6 @@
overflow-y: auto;
scrollbar-width: none;
outline: none;
- display: block;
- overflow-anchor: none;
-}
-
-.scroll-view__viewport[data-reverse="true"] {
- display: flex;
- flex-direction: column-reverse;
}
.scroll-view__viewport::-webkit-scrollbar {
@@ -52,6 +45,18 @@
background-color: var(--border-strong-base);
}
+.dark .scroll-view__thumb::after,
+[data-theme="dark"] .scroll-view__thumb::after {
+ background-color: var(--border-weak-base);
+}
+
+.dark .scroll-view__thumb:hover::after,
+[data-theme="dark"] .scroll-view__thumb:hover::after,
+.dark .scroll-view__thumb[data-dragging="true"]::after,
+[data-theme="dark"] .scroll-view__thumb[data-dragging="true"]::after {
+ background-color: var(--border-strong-base);
+}
+
.scroll-view__thumb[data-visible="true"] {
opacity: 1;
}
diff --git a/packages/ui/src/components/scroll-view.test.ts b/packages/ui/src/components/scroll-view.test.ts
new file mode 100644
index 00000000000..d28b51fea8b
--- /dev/null
+++ b/packages/ui/src/components/scroll-view.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, test } from "bun:test"
+import { scrollKey } from "./scroll-view"
+
+describe("scrollKey", () => {
+ test("maps plain navigation keys", () => {
+ expect(scrollKey({ key: "PageDown", altKey: false, ctrlKey: false, metaKey: false, shiftKey: false })).toBe(
+ "page-down",
+ )
+ expect(scrollKey({ key: "ArrowUp", altKey: false, ctrlKey: false, metaKey: false, shiftKey: false })).toBe("up")
+ })
+
+ test("ignores modified keybinds", () => {
+ expect(
+ scrollKey({ key: "ArrowDown", altKey: false, ctrlKey: false, metaKey: true, shiftKey: false }),
+ ).toBeUndefined()
+ expect(scrollKey({ key: "PageUp", altKey: false, ctrlKey: true, metaKey: false, shiftKey: false })).toBeUndefined()
+ expect(scrollKey({ key: "End", altKey: false, ctrlKey: false, metaKey: false, shiftKey: true })).toBeUndefined()
+ })
+})
diff --git a/packages/ui/src/components/scroll-view.tsx b/packages/ui/src/components/scroll-view.tsx
index a8d3cf0f841..c3d878af63e 100644
--- a/packages/ui/src/components/scroll-view.tsx
+++ b/packages/ui/src/components/scroll-view.tsx
@@ -1,18 +1,36 @@
-import { createSignal, onCleanup, onMount, splitProps, type ComponentProps, Show } from "solid-js"
-import { animate, type AnimationPlaybackControls } from "motion"
+import { createSignal, onCleanup, onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js"
import { useI18n } from "../context/i18n"
-import { FAST_SPRING } from "./motion"
export interface ScrollViewProps extends ComponentProps<"div"> {
viewportRef?: (el: HTMLDivElement) => void
- reverse?: boolean
+ orientation?: "vertical" | "horizontal" // currently only vertical is fully implemented for thumb
+}
+
+export const scrollKey = (event: Pick) => {
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
+
+ switch (event.key) {
+ case "PageDown":
+ return "page-down"
+ case "PageUp":
+ return "page-up"
+ case "Home":
+ return "home"
+ case "End":
+ return "end"
+ case "ArrowUp":
+ return "up"
+ case "ArrowDown":
+ return "down"
+ }
}
export function ScrollView(props: ScrollViewProps) {
const i18n = useI18n()
+ const merged = mergeProps({ orientation: "vertical" }, props)
const [local, events, rest] = splitProps(
- props,
- ["class", "children", "viewportRef", "style", "reverse"],
+ merged,
+ ["class", "children", "viewportRef", "orientation", "style"],
[
"onScroll",
"onWheel",
@@ -26,9 +44,9 @@ export function ScrollView(props: ScrollViewProps) {
],
)
+ let rootRef!: HTMLDivElement
let viewportRef!: HTMLDivElement
let thumbRef!: HTMLDivElement
- let anim: AnimationPlaybackControls | undefined
const [isHovered, setIsHovered] = createSignal(false)
const [isDragging, setIsDragging] = createSignal(false)
@@ -37,8 +55,6 @@ export function ScrollView(props: ScrollViewProps) {
const [thumbTop, setThumbTop] = createSignal(0)
const [showThumb, setShowThumb] = createSignal(false)
- const reverse = () => local.reverse === true
-
const updateThumb = () => {
if (!viewportRef) return
const { scrollTop, scrollHeight, clientHeight } = viewportRef
@@ -60,13 +76,9 @@ export function ScrollView(props: ScrollViewProps) {
const maxScrollTop = scrollHeight - clientHeight
const maxThumbTop = trackHeight - height
- const top = (() => {
- if (maxScrollTop <= 0) return 0
- if (!reverse()) return (scrollTop / maxScrollTop) * maxThumbTop
- return ((maxScrollTop + scrollTop) / maxScrollTop) * maxThumbTop
- })()
+ const top = maxScrollTop > 0 ? (scrollTop / maxScrollTop) * maxThumbTop : 0
- // Ensure thumb stays within bounds
+ // Ensure thumb stays within bounds (shouldn't be necessary due to math above, but good for safety)
const boundedTop = trackPadding + Math.max(0, Math.min(top, maxThumbTop))
setThumbHeight(height)
@@ -89,7 +101,6 @@ export function ScrollView(props: ScrollViewProps) {
}
onCleanup(() => {
- stop()
observer.disconnect()
})
@@ -131,31 +142,6 @@ export function ScrollView(props: ScrollViewProps) {
thumbRef.addEventListener("pointerup", onPointerUp)
}
- const stop = () => {
- if (!anim) return
- anim.stop()
- anim = undefined
- }
-
- const limit = (top: number) => {
- const max = viewportRef.scrollHeight - viewportRef.clientHeight
- if (reverse()) return Math.max(-max, Math.min(0, top))
- return Math.max(0, Math.min(max, top))
- }
-
- const glide = (top: number) => {
- stop()
- anim = animate(viewportRef.scrollTop, limit(top), {
- ...FAST_SPRING,
- onUpdate: (v) => {
- viewportRef.scrollTop = v
- },
- onComplete: () => {
- anim = undefined
- },
- })
- }
-
// Keybinds implementation
// We ensure the viewport has a tabindex so it can receive focus
// We can also explicitly catch PageUp/Down if we want smooth scroll or specific behavior,
@@ -166,31 +152,34 @@ export function ScrollView(props: ScrollViewProps) {
return
}
+ const next = scrollKey(e)
+ if (!next) return
+
const scrollAmount = viewportRef.clientHeight * 0.8
const lineAmount = 40
- switch (e.key) {
- case "PageDown":
+ switch (next) {
+ case "page-down":
e.preventDefault()
viewportRef.scrollBy({ top: scrollAmount, behavior: "smooth" })
break
- case "PageUp":
+ case "page-up":
e.preventDefault()
viewportRef.scrollBy({ top: -scrollAmount, behavior: "smooth" })
break
- case "Home":
+ case "home":
e.preventDefault()
- glide(reverse() ? -(viewportRef.scrollHeight - viewportRef.clientHeight) : 0)
+ viewportRef.scrollTo({ top: 0, behavior: "smooth" })
break
- case "End":
+ case "end":
e.preventDefault()
- glide(reverse() ? 0 : viewportRef.scrollHeight - viewportRef.clientHeight)
+ viewportRef.scrollTo({ top: viewportRef.scrollHeight, behavior: "smooth" })
break
- case "ArrowUp":
+ case "up":
e.preventDefault()
viewportRef.scrollBy({ top: -lineAmount, behavior: "smooth" })
break
- case "ArrowDown":
+ case "down":
e.preventDefault()
viewportRef.scrollBy({ top: lineAmount, behavior: "smooth" })
break
@@ -199,6 +188,7 @@ export function ScrollView(props: ScrollViewProps) {
return (