Compare commits

...

66 Commits

Author SHA1 Message Date
David Hill
f77a24ad87 feat(ui): use check icon for success cards 2026-03-09 22:30:30 +00:00
David Hill
0941a40953 refactor(ui): derive Card accent from theme icon tokens 2026-03-09 22:29:11 +00:00
David Hill
eaaf2c64db docs(ui): add ToolErrorCard story 2026-03-09 22:29:05 +00:00
David Hill
269b6da131 feat(ui): render tool failures with collapsible ToolErrorCard 2026-03-09 22:14:33 +00:00
David Hill
16f50213a7 feat(ui): restyle Card and add title/description slots 2026-03-09 22:14:21 +00:00
David Hill
bdc423d622 tui: fix ordered list indentation in markdown rendering 2026-03-09 16:15:48 +00:00
James Long
f20ee2fad2 fix(tui): handle error when creating a session (#16767) 2026-03-09 12:13:32 -04:00
Stephen Collings
8b9710e56c fix: Multiple jdtls LSPs eating memory in java monorepos (#12123) 2026-03-09 16:09:43 +00:00
opencode
c6262f9d40 release: v1.2.24 2026-03-09 16:09:34 +00:00
Adam
b749fa90f2 fix(app): scroll jitter/loop 2026-03-09 10:44:02 -05:00
Dax Raad
8a51cbd253 core: prevent accidental edits to migration files by restricting agent access 2026-03-09 11:25:58 -04:00
David Hill
399b8f0701 fix(app): session title turn spinner (#16764) 2026-03-09 09:46:15 -05:00
Filip
3742e42fdf fix(app): dismiss toast notifications when questions or permissions a… (#16758) 2026-03-09 09:36:57 -05:00
Karan Handa
0388ec6862 fix(storybook): add ci build workflow (#16760) 2026-03-09 09:33:19 -05:00
James Long
366b8a8034 feat(tui): add initial support for workspaces into the tui (#16230) 2026-03-09 10:28:04 -04:00
Armin Pašalić
ef9bc4ec9e feat(gitlab): send context-1m-2025-08-07 beta header to enable 1M context window (#16153)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-09 09:22:00 -05:00
Jack
5838b58913 add copilot gpt-5.4 xhigh support (#16294) 2026-03-09 22:07:12 +08:00
opencode
2712244ad3 release: v1.2.23 2026-03-09 13:50:43 +00:00
Adam
6388cbaf92 fix(app): remove oc-1 theme 2026-03-09 08:25:41 -05:00
David Hill
5cc61e1b53 tui: fix sidebar workspace container sizing by adding box-border class to prevent content overflow issues 2026-03-09 13:05:43 +00:00
Adam
0243be86a7 fix(app): don't animate review panel in/out 2026-03-09 07:49:11 -05:00
opencode-agent[bot]
9154cd64e7 chore: update nix node_modules hashes 2026-03-09 12:46:47 +00:00
Adam
c71d1bde5e revert(app): "STUPID SEXY TIMELINE (#16420)" (#16745) 2026-03-09 07:36:39 -05:00
Luke Parker
f27ef595f6 fix(app): sanitize workspace store filenames on Windows (#16703) 2026-03-09 20:26:53 +10:00
Yihui Khuu
34328828ae fix(app): fix issue with scroll jumping when pressing escape in comment text area (#15374) 2026-03-09 15:29:24 +05:30
Eric Clemmons
18fb19da3b fix(opencode): pass missing auth headers in run --attach (#16097)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-03-09 07:32:13 +00:00
opencode-agent[bot]
849e1ac543 docs(i18n): sync locale docs from english changes 2026-03-09 02:08:46 +00:00
Ariane Emory
656a8d8f55 docs: add session_child_first keybinding to documentation (#16631) 2026-03-08 21:03:52 -05:00
Adam
b976f339e8 feat(app): generate color palettes (#16232) 2026-03-08 19:28:58 -05:00
Dax Raad
7d7837e5b6 disable fallback to free nano for small model 2026-03-08 19:27:15 -04:00
opencode
1db292f4df release: v1.2.22 2026-03-08 22:34:59 +00:00
Sebastian
49a3a9fe36 guard tui exit (#16640) 2026-03-08 23:14:41 +01:00
Luke Parker
e51ed460a6 fix(tui): canonicalize cwd after chdir (#16641) 2026-03-09 07:57:48 +10:00
David Hill
d15c2ce349 tui: fix sidebar background color when collapsed
When the sidebar was collapsed (not on mobile), the background color was showing as the stronger variant instead of matching the base background. This fixes the hover state detection so users see a consistent lighter background when the sidebar is in collapsed mode.
2026-03-08 13:34:56 +00:00
David Hill
5cc4bb4089 app: suppress hover when opening project menu or right-clicking to prevent flickering 2026-03-08 13:31:18 +00:00
Shoubhit Dash
6e9e027886 fix: trim retained desktop terminal buffers (#16583) 2026-03-08 07:50:04 -05:00
opencode-agent[bot]
f9a3d129a4 chore: update nix node_modules hashes 2026-03-08 12:25:35 +00:00
Adam
c53d1d3ad8 fix(app): less auto-expand/collapse 2026-03-08 07:11:15 -05:00
Adam
f386137fba chore: refactoring ui hooks 2026-03-08 07:11:15 -05:00
Adam
c797b60069 fix(app): messages not loading reliably 2026-03-08 07:11:15 -05:00
Shoubhit Dash
a139e9297d fix: prune and evict stale app session caches (#16584) 2026-03-08 07:10:00 -05:00
Shoubhit Dash
050f99ec54 test: make process cwd check cross-platform (#16594) 2026-03-08 06:56:45 -05:00
Roy Bruschini
23ed652901 docs(zen.mdx): correct Italian grammar and punctuation errors (#16590) 2026-03-08 16:40:06 +05:30
tobwen
13a68f3de3 fix(opencode): avoid TTY corruption from double cleanup (#16565)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-03-08 13:55:33 +05:30
Nate Williams
fdad35aaa7 fix(tui): fix broken /mcp toggling (#16431)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-03-08 13:31:09 +05:30
Dax
a2ce4eb650 test: remove unused Ripgrep.search coverage (#16554) 2026-03-07 21:40:57 -05:00
David Hill
8fa04986cf Revert "tui: dock auto-accept after thinking and move Add file to bottom-left"
This reverts commit 69cb49f7cc.
2026-03-08 01:31:09 +00:00
David Hill
a5710ed3e1 Revert "tui: keep model + thinking selectors beside Add file"
This reverts commit 426dcfa3b0.
2026-03-08 01:31:06 +00:00
David Hill
2efdc9df93 Revert "tui: add more editor bottom padding for prompt controls"
This reverts commit 981353793d.
2026-03-08 01:31:03 +00:00
David Hill
0c245886fe Revert "tui: expose auto-accept as a permissions select"
This reverts commit 12d862dbd3.
2026-03-08 01:31:00 +00:00
David Hill
f03288b411 Revert "tui: use text-base color for prompt selects"
This reverts commit 207ebf4b8c.
2026-03-08 01:30:55 +00:00
David Hill
09388c98f3 Revert "tui: remove prompt model/thinking/permissions selectors on dev so the composer stays simple"
This reverts commit ae25c1e7b7.
2026-03-08 01:27:45 +00:00
David Hill
ae25c1e7b7 tui: remove prompt model/thinking/permissions selectors on dev so the composer stays simple 2026-03-08 01:21:45 +00:00
David Hill
0813c14cc6 tui: restore new-session logo on dev so users recognize OpenCode immediately 2026-03-08 01:18:42 +00:00
David Hill
b5151c421f tui: revert new-session logo on dev so this UI change only ships with auto-accept-permissions 2026-03-08 01:10:52 +00:00
David Hill
e66fd079db tui: add opencode logo to new session screen so users can immediately identify the app when starting a fresh session 2026-03-08 00:59:03 +00:00
David Hill
207ebf4b8c tui: use text-base color for prompt selects
Select triggers in the composer now use the normal text color so model/thinking/permissions controls read consistently with the rest of the input UI.
2026-03-08 00:53:57 +00:00
David Hill
12d862dbd3 tui: expose auto-accept as a permissions select
Lets people explicitly choose between normal permission prompts and auto-accept while composing, without relying on an ambiguous icon state.
2026-03-08 00:53:57 +00:00
David Hill
981353793d tui: add more editor bottom padding for prompt controls
Gives typed text more breathing room above the Add file/model/thinking row so the controls don’t visually crowd what you’re writing.
2026-03-08 00:53:57 +00:00
David Hill
426dcfa3b0 tui: keep model + thinking selectors beside Add file
People change models and thinking settings while composing, so keeping those controls next to the Add file button avoids hunting in the footer and reduces context switching mid-message.
2026-03-08 00:53:57 +00:00
David Hill
69cb49f7cc tui: dock auto-accept after thinking and move Add file to bottom-left
Auto-accept now lives in the footer dock beside the thinking control so it stays easy to find without crowding the text box.

The Add file button moves to the bottom-left of the editor and the input gets a bit more bottom padding so the control row doesn’t overlap what you’re typing.
2026-03-08 00:53:57 +00:00
Dax Raad
e30678a088 test: normalize ripgrep path assertion on windows 2026-03-07 19:47:57 -05:00
opencode-agent[bot]
771b29a857 chore: generate 2026-03-08 00:31:35 +00:00
Dax Raad
e6d1aae33a test: lock in process, ripgrep, and installation helpers 2026-03-07 19:30:32 -05:00
David Hill
9dc8ac4734 tui: revert prompt control docking
Restore the previous prompt control layout after the dock/position changes made the composer feel less familiar.

This brings auto-accept back to its prior spot and returns Add file to the previous placement.
2026-03-08 00:17:28 +00:00
David Hill
fdd037ba20 tui: dock auto-accept after thinking and move Add file to bottom-left
Auto-accept now lives in the footer dock beside the thinking control so it stays easy to find without crowding the text box.

The Add file button moves to the bottom-left of the editor and the input gets a bit more bottom padding so the control row doesn’t overlap what you’re typing.
2026-03-08 00:08:37 +00:00
169 changed files with 5715 additions and 9002 deletions

38
.github/workflows/storybook.yml vendored Normal file
View File

@@ -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

View File

@@ -5,6 +5,11 @@
"options": {},
},
},
"permission": {
"edit": {
"packages/opencode/migration/*": "deny",
},
},
"mcp": {},
"tools": {
"github-triage": false,

View File

@@ -26,7 +26,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.2.21",
"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.21",
"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.21",
"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.21",
"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.21",
"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.21",
"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.21",
"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.21",
"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.21",
"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.21",
"version": "1.2.24",
"bin": {
"opencode": "./bin/opencode",
},
@@ -409,7 +409,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.2.21",
"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.21",
"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.21",
"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.21",
"version": "1.2.24",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -521,7 +521,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.2.21",
"version": "1.2.24",
"dependencies": {
"zod": "catalog:",
},
@@ -532,7 +532,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.2.21",
"version": "1.2.24",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",

View File

@@ -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()

View File

@@ -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] },
)

View File

@@ -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) =>

View File

@@ -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)
})
})

View File

@@ -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()

View File

@@ -44,12 +44,14 @@ async function store(page: Page, key: string) {
}, key)
}
test("terminal tab buffers persist across tab switches", async ({ page, withProject }) => {
test("inactive terminal tab buffers persist across tab switches", async ({ page, withProject }) => {
await withProject(async ({ directory, gotoSession }) => {
const key = workspacePersistKey(directory, "terminal")
const one = `E2E_TERM_ONE_${Date.now()}`
const two = `E2E_TERM_TWO_${Date.now()}`
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
await gotoSession()
await open(page)
@@ -61,22 +63,39 @@ test("terminal tab buffers persist across tab switches", async ({ page, withProj
await run(page, `echo ${two}`)
await tabs
.filter({ hasText: /Terminal 1/ })
.first()
.click()
await first.click()
await expect(first).toHaveAttribute("aria-selected", "true")
await expect
.poll(
async () => {
const state = await store(page, key)
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
return first.includes(one) && second.includes(two)
return {
first: first.includes(one),
second: second.includes(two),
}
},
{ timeout: 30_000 },
)
.toBe(true)
.toEqual({ first: false, second: true })
await second.click()
await expect(second).toHaveAttribute("aria-selected", "true")
await expect
.poll(
async () => {
const state = await store(page, key)
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
return {
first: first.includes(one),
second: second.includes(two),
}
},
{ timeout: 30_000 },
)
.toEqual({ first: true, second: false })
})
})

View File

@@ -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}`
}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.2.21",
"version": "1.2.24",
"description": "",
"type": "module",
"exports": {

View File

@@ -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"

View File

@@ -4,6 +4,7 @@ import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
import { useLanguage } from "@/context/language"
import { Icon } from "@opencode-ai/ui/icon"
import { Mark } from "@opencode-ai/ui/logo"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
const MAIN_WORKTREE = "main"
@@ -52,7 +53,10 @@ export function NewSessionView(props: NewSessionViewProps) {
<div class="h-12 shrink-0" aria-hidden />
<div class="flex-1 px-6 pb-30 flex items-center justify-center text-center">
<div class="w-full max-w-200 flex flex-col items-center text-center gap-4">
<div class="text-20-medium text-text-strong">{language.t("session.new.title")}</div>
<div class="flex flex-col items-center gap-6">
<Mark class="w-10" />
<div class="text-20-medium text-text-strong">{language.t("session.new.title")}</div>
</div>
<div class="w-full flex flex-col gap-4 items-center">
<div class="flex items-start justify-center gap-3 min-h-5">
<div class="text-12-medium text-text-weak select-text leading-5 min-w-0 max-w-160 break-words text-center">

View File

@@ -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

View File

@@ -27,7 +27,7 @@ import type { InitError } from "../pages/error"
import { useGlobalSDK } from "./global-sdk"
import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
import { createChildStoreManager } from "./global-sync/child-store"
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer"
import { createRefreshQueue } from "./global-sync/queue"
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
import { trimSessions } from "./global-sync/session-trim"
@@ -189,6 +189,7 @@ function createGlobalSync() {
})
if (next.length !== store.session.length) {
setStore("session", reconcile(next, { key: "id" }))
cleanupDroppedSessionCaches(store, setStore, next, setSessionTodo)
}
children.unpin(directory)
return
@@ -220,6 +221,7 @@ function createGlobalSync() {
}),
)
setStore("session", reconcile(sessions, { key: "id" }))
cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
sessionMeta.set(directory, { limit })
})
.catch((err) => {

View File

@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { createStore } from "solid-js/store"
import type { State } from "./types"
import { applyDirectoryEvent, applyGlobalEvent } from "./event-reducer"
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
const rootSession = (input: { id: string; parentID?: string; archived?: number }) =>
({
@@ -248,6 +248,62 @@ describe("applyDirectoryEvent", () => {
}
})
test("cleans caches for trimmed sessions on session.created", () => {
const dropped = rootSession({ id: "ses_b" })
const kept = rootSession({ id: "ses_a" })
const message = userMessage("msg_1", dropped.id)
const todos: string[] = []
const [store, setStore] = createStore(
baseState({
limit: 1,
session: [dropped],
message: { [dropped.id]: [message] },
part: { [message.id]: [textPart("prt_1", dropped.id, message.id)] },
session_diff: { [dropped.id]: [] },
todo: { [dropped.id]: [] },
permission: { [dropped.id]: [] },
question: { [dropped.id]: [] },
session_status: { [dropped.id]: { type: "busy" } },
}),
)
applyDirectoryEvent({
event: { type: "session.created", properties: { info: kept } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
setSessionTodo(sessionID, value) {
if (value !== undefined) return
todos.push(sessionID)
},
})
expect(store.session.map((x) => x.id)).toEqual([kept.id])
expect(store.message[dropped.id]).toBeUndefined()
expect(store.part[message.id]).toBeUndefined()
expect(store.session_diff[dropped.id]).toBeUndefined()
expect(store.todo[dropped.id]).toBeUndefined()
expect(store.permission[dropped.id]).toBeUndefined()
expect(store.question[dropped.id]).toBeUndefined()
expect(store.session_status[dropped.id]).toBeUndefined()
expect(todos).toEqual([dropped.id])
})
test("cleanupDroppedSessionCaches clears part-only orphan state", () => {
const [store, setStore] = createStore(
baseState({
session: [rootSession({ id: "ses_keep" })],
part: { msg_1: [textPart("prt_1", "ses_drop", "msg_1")] },
}),
)
cleanupDroppedSessionCaches(store, setStore, store.session)
expect(store.part.msg_1).toBeUndefined()
})
test("upserts and removes messages while clearing orphaned parts", () => {
const sessionID = "ses_1"
const [store, setStore] = createStore(

View File

@@ -13,6 +13,7 @@ import type {
} from "@opencode-ai/sdk/v2/client"
import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache"
export function applyGlobalEvent(input: {
event: { type: string; properties?: unknown }
@@ -40,37 +41,44 @@ export function applyGlobalEvent(input: {
}
function cleanupSessionCaches(
store: Store<State>,
setStore: SetStoreFunction<State>,
sessionID: string,
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
) {
if (!sessionID) return
const hasAny =
store.message[sessionID] !== undefined ||
store.session_diff[sessionID] !== undefined ||
store.todo[sessionID] !== undefined ||
store.permission[sessionID] !== undefined ||
store.question[sessionID] !== undefined ||
store.session_status[sessionID] !== undefined
setSessionTodo?.(sessionID, undefined)
if (!hasAny) return
setStore(
produce((draft) => {
const messages = draft.message[sessionID]
if (messages) {
for (const message of messages) {
const id = message?.id
if (!id) continue
delete draft.part[id]
}
}
delete draft.message[sessionID]
delete draft.session_diff[sessionID]
delete draft.todo[sessionID]
delete draft.permission[sessionID]
delete draft.question[sessionID]
delete draft.session_status[sessionID]
dropSessionCaches(draft, [sessionID])
}),
)
}
export function cleanupDroppedSessionCaches(
store: Store<State>,
setStore: SetStoreFunction<State>,
next: Session[],
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
) {
const keep = new Set(next.map((item) => item.id))
const stale = [
...Object.keys(store.message),
...Object.keys(store.session_diff),
...Object.keys(store.todo),
...Object.keys(store.permission),
...Object.keys(store.question),
...Object.keys(store.session_status),
...Object.values(store.part)
.map((parts) => parts?.find((part) => !!part?.sessionID)?.sessionID)
.filter((sessionID): sessionID is string => !!sessionID),
].filter((sessionID, index, list) => !keep.has(sessionID) && list.indexOf(sessionID) === index)
if (stale.length === 0) return
for (const sessionID of stale) {
setSessionTodo?.(sessionID, undefined)
}
setStore(
produce((draft) => {
dropSessionCaches(draft, stale)
}),
)
}
@@ -102,6 +110,7 @@ export function applyDirectoryEvent(input: {
next.splice(result.index, 0, info)
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
input.setStore("session", reconcile(trimmed, { key: "id" }))
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
break
}
@@ -117,7 +126,7 @@ export function applyDirectoryEvent(input: {
}),
)
}
cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
@@ -130,6 +139,7 @@ export function applyDirectoryEvent(input: {
next.splice(result.index, 0, info)
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
input.setStore("session", reconcile(trimmed, { key: "id" }))
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
break
}
case "session.deleted": {
@@ -143,7 +153,7 @@ export function applyDirectoryEvent(input: {
}),
)
}
cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break

View File

@@ -0,0 +1,102 @@
import { describe, expect, test } from "bun:test"
import type {
FileDiff,
Message,
Part,
PermissionRequest,
QuestionRequest,
SessionStatus,
Todo,
} from "@opencode-ai/sdk/v2/client"
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
const msg = (id: string, sessionID: string) =>
({
id,
sessionID,
role: "user",
time: { created: 1 },
agent: "assistant",
model: { providerID: "openai", modelID: "gpt" },
}) as Message
const part = (id: string, sessionID: string, messageID: string) =>
({
id,
sessionID,
messageID,
type: "text",
text: id,
}) as Part
describe("app session cache", () => {
test("dropSessionCaches clears orphaned parts without message rows", () => {
const store: {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiff[] | undefined>
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
} = {
session_status: { ses_1: { type: "busy" } as SessionStatus },
session_diff: { ses_1: [] },
todo: { ses_1: [] as Todo[] },
message: {},
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
permission: { ses_1: [] as PermissionRequest[] },
question: { ses_1: [] as QuestionRequest[] },
}
dropSessionCaches(store, ["ses_1"])
expect(store.message.ses_1).toBeUndefined()
expect(store.part.msg_1).toBeUndefined()
expect(store.todo.ses_1).toBeUndefined()
expect(store.session_diff.ses_1).toBeUndefined()
expect(store.session_status.ses_1).toBeUndefined()
expect(store.permission.ses_1).toBeUndefined()
expect(store.question.ses_1).toBeUndefined()
})
test("dropSessionCaches clears message-backed parts", () => {
const m = msg("msg_1", "ses_1")
const store: {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiff[] | undefined>
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
} = {
session_status: {},
session_diff: {},
todo: {},
message: { ses_1: [m] },
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
permission: {},
question: {},
}
dropSessionCaches(store, ["ses_1"])
expect(store.message.ses_1).toBeUndefined()
expect(store.part[m.id]).toBeUndefined()
})
test("pickSessionCacheEvictions preserves requested sessions", () => {
const seen = new Set(["ses_1", "ses_2", "ses_3"])
const stale = pickSessionCacheEvictions({
seen,
keep: "ses_4",
limit: 2,
preserve: ["ses_1"],
})
expect(stale).toEqual(["ses_2", "ses_3"])
expect([...seen]).toEqual(["ses_1", "ses_4"])
})
})

View File

@@ -0,0 +1,62 @@
import type {
FileDiff,
Message,
Part,
PermissionRequest,
QuestionRequest,
SessionStatus,
Todo,
} from "@opencode-ai/sdk/v2/client"
export const SESSION_CACHE_LIMIT = 40
type SessionCache = {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiff[] | undefined>
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
}
export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<string>) {
const stale = new Set(Array.from(sessionIDs).filter(Boolean))
if (stale.size === 0) return
for (const key of Object.keys(store.part)) {
const parts = store.part[key]
if (!parts?.some((part) => stale.has(part?.sessionID ?? ""))) continue
delete store.part[key]
}
for (const sessionID of stale) {
delete store.message[sessionID]
delete store.todo[sessionID]
delete store.session_diff[sessionID]
delete store.session_status[sessionID]
delete store.permission[sessionID]
delete store.question[sessionID]
}
}
export function pickSessionCacheEvictions(input: {
seen: Set<string>
keep: string
limit: number
preserve?: Iterable<string>
}) {
const stale: string[] = []
const keep = new Set([input.keep, ...Array.from(input.preserve ?? [])])
if (input.seen.has(input.keep)) input.seen.delete(input.keep)
input.seen.add(input.keep)
for (const id of input.seen) {
if (input.seen.size - stale.length <= input.limit) break
if (keep.has(id)) continue
stale.push(id)
}
for (const id of stale) {
input.seen.delete(id)
}
return stale
}

View File

@@ -6,6 +6,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSync } from "./global-sync"
import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
function sortParts(parts: Part[]) {
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
@@ -108,6 +109,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
const maxDirs = 30
const seen = new Map<string, Set<string>>()
const [meta, setMeta] = createStore({
limit: {} as Record<string, number>,
complete: {} as Record<string, boolean>,
@@ -121,6 +124,62 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return undefined
}
const seenFor = (directory: string) => {
const existing = seen.get(directory)
if (existing) {
seen.delete(directory)
seen.set(directory, existing)
return existing
}
const created = new Set<string>()
seen.set(directory, created)
while (seen.size > maxDirs) {
const first = seen.keys().next().value
if (!first) break
const stale = [...(seen.get(first) ?? [])]
seen.delete(first)
const [, setStore] = globalSync.child(first, { bootstrap: false })
evict(first, setStore, stale)
}
return created
}
const clearMeta = (directory: string, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
setMeta(
produce((draft) => {
for (const sessionID of sessionIDs) {
const key = keyFor(directory, sessionID)
delete draft.limit[key]
delete draft.complete[key]
delete draft.loading[key]
}
}),
)
}
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
for (const sessionID of sessionIDs) {
globalSync.todo.set(sessionID, undefined)
}
setStore(
produce((draft) => {
dropSessionCaches(draft, sessionIDs)
}),
)
clearMeta(directory, sessionIDs)
}
const touch = (directory: string, setStore: Setter, sessionID: string) => {
const stale = pickSessionCacheEvictions({
seen: seenFor(directory),
keep: sessionID,
limit: SESSION_CACHE_LIMIT,
})
evict(directory, setStore, stale)
}
const fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => {
const messages = await retry(() =>
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }),
@@ -135,6 +194,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}
}
const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false
const loadMessages = async (input: {
directory: string
client: typeof sdk.client
@@ -148,6 +209,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
setMeta("loading", key, true)
await fetchMessages(input)
.then((next) => {
if (!tracked(input.directory, input.sessionID)) return
batch(() => {
input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" }))
for (const p of next.part) {
@@ -158,6 +220,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
})
})
.finally(() => {
if (!tracked(input.directory, input.sessionID)) return
setMeta("loading", key, false)
})
}
@@ -224,11 +287,16 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const key = keyFor(directory, sessionID)
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
touch(directory, setStore, sessionID)
if (store.message[sessionID] !== undefined && hasSession && meta.limit[key] !== undefined) return
const limit = meta.limit[key] ?? messagePageSize
const sessionReq = hasSession
? Promise.resolve()
: retry(() => client.session.get({ sessionID })).then((session) => {
if (!tracked(directory, sessionID)) return
const data = session.data
if (!data) return
setStore(
@@ -258,11 +326,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
if (store.session_diff[sessionID] !== undefined) return
const key = keyFor(directory, sessionID)
return runInflight(inflightDiff, key, () =>
retry(() => client.session.diff({ sessionID })).then((diff) => {
if (!tracked(directory, sessionID)) return
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
}),
)
@@ -271,6 +341,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
const existing = store.todo[sessionID]
const cached = globalSync.data.session_todo[sessionID]
if (existing !== undefined) {
@@ -287,6 +358,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const key = keyFor(directory, sessionID)
return runInflight(inflightTodo, key, () =>
retry(() => client.session.todo({ sessionID })).then((todo) => {
if (!tracked(directory, sessionID)) return
const list = todo.data ?? []
setStore("todo", sessionID, reconcile(list, { key: "id" }))
globalSync.todo.set(sessionID, list)
@@ -310,6 +382,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const directory = sdk.directory
const client = sdk.client
const [, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
const key = keyFor(directory, sessionID)
const step = count ?? messagePageSize
if (meta.loading[key]) return
@@ -325,6 +398,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
})
},
},
evict(sessionID: string, directory = sdk.directory) {
const [, setStore] = globalSync.child(directory)
seenFor(directory).delete(sessionID)
evict(directory, setStore, [sessionID])
},
fetch: async (count = 10) => {
const directory = sdk.directory
const client = sdk.client

View File

@@ -1,6 +1,6 @@
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js"
import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router"
import { useSDK } from "./sdk"
import type { Platform } from "./platform"
@@ -38,6 +38,16 @@ type TerminalCacheEntry = {
const caches = new Set<Map<string, TerminalCacheEntry>>()
const trimTerminal = (pty: LocalPTY) => {
if (!pty.buffer && pty.cursor === undefined && pty.scrollY === undefined) return pty
return {
...pty,
buffer: undefined,
cursor: undefined,
scrollY: undefined,
}
}
export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) {
const key = getWorkspaceTerminalCacheKey(dir)
for (const cache of caches) {
@@ -188,6 +198,18 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
console.error("Failed to update terminal", error)
})
},
trim(id: string) {
const index = store.all.findIndex((x) => x.id === id)
if (index === -1) return
setStore("all", index, (pty) => trimTerminal(pty))
},
trimAll() {
setStore("all", (all) => {
const next = all.map(trimTerminal)
if (next.every((pty, index) => pty === all[index])) return all
return next
})
},
async clone(id: string) {
const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index]
@@ -322,12 +344,27 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id))
createEffect(
on(
() => ({ dir: params.dir, id: params.id }),
(next, prev) => {
if (!prev?.dir) return
if (next.dir === prev.dir && next.id === prev.id) return
if (next.dir === prev.dir && next.id) return
loadWorkspace(prev.dir, prev.id).trimAll()
},
{ defer: true },
),
)
return {
ready: () => workspace().ready(),
all: () => workspace().all(),
active: () => workspace().active(),
new: () => workspace().new(),
update: (pty: Partial<LocalPTY> & { id: string }) => workspace().update(pty),
trim: (id: string) => workspace().trim(id),
trimAll: () => workspace().trimAll(),
clone: (id: string) => workspace().clone(id),
open: (id: string) => workspace().open(id),
close: (id: string) => workspace().close(id),

View File

@@ -34,6 +34,7 @@ import { useProviders } from "@/hooks/use-providers"
import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
import { useGlobalSDK } from "@/context/global-sdk"
import { clearWorkspaceTerminals } from "@/context/terminal"
import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { Binary } from "@opencode-ai/util/binary"
@@ -423,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"
@@ -657,25 +669,24 @@ export default function Layout(props: ParentProps) {
const prefetchQueues = new Map<string, PrefetchQueue>()
const PREFETCH_MAX_SESSIONS_PER_DIR = 10
const prefetchedByDir = new Map<string, Map<string, true>>()
const prefetchedByDir = new Map<string, Set<string>>()
const lruFor = (directory: string) => {
const existing = prefetchedByDir.get(directory)
if (existing) return existing
const created = new Map<string, true>()
const created = new Set<string>()
prefetchedByDir.set(directory, created)
return created
}
const markPrefetched = (directory: string, sessionID: string) => {
const lru = lruFor(directory)
if (lru.has(sessionID)) lru.delete(sessionID)
lru.set(sessionID, true)
while (lru.size > PREFETCH_MAX_SESSIONS_PER_DIR) {
const oldest = lru.keys().next().value as string | undefined
if (!oldest) return
lru.delete(oldest)
}
return pickSessionCacheEvictions({
seen: lru,
keep: sessionID,
limit: PREFETCH_MAX_SESSIONS_PER_DIR,
preserve: directory === params.dir && params.id ? [params.id] : undefined,
})
}
createEffect(() => {
@@ -724,6 +735,7 @@ export default function Layout(props: ParentProps) {
return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
.then((messages) => {
if (prefetchToken.value !== token) return
if (!lruFor(directory).has(sessionID)) return
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
@@ -787,7 +799,18 @@ export default function Layout(props: ParentProps) {
const lru = lruFor(directory)
const known = lru.has(session.id)
if (!known && lru.size >= PREFETCH_MAX_SESSIONS_PER_DIR && priority !== "high") return
markPrefetched(directory, session.id)
const stale = markPrefetched(directory, session.id)
if (stale.length > 0) {
const [, setStore] = globalSync.child(directory, { bootstrap: false })
for (const id of stale) {
globalSync.todo.set(id, undefined)
}
setStore(
produce((draft) => {
dropSessionCaches(draft, stale)
}),
)
}
if (priority === "high") q.pending.unshift(session.id)
if (priority !== "high") q.pending.push(session.id)
@@ -1879,6 +1902,7 @@ export default function Layout(props: ParentProps) {
const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean; merged?: boolean }) => {
const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened())
const projectName = createMemo(() => {
const project = panelProps.project
if (!project) return ""
@@ -1904,11 +1928,11 @@ export default function Layout(props: ParentProps) {
return (
<div
classList={{
"flex flex-col min-h-0 min-w-0 rounded-tl-[12px] px-2": true,
"flex flex-col min-h-0 min-w-0 box-border rounded-tl-[12px] px-2": true,
"border border-b-0 border-border-weak-base": !merged(),
"border-l border-t border-border-weaker-base": merged(),
"bg-background-base": merged(),
"bg-background-stronger": !merged(),
"bg-background-base": merged() || hover(),
"bg-background-stronger": !merged() && !hover(),
"flex-1 min-w-0": panelProps.mobile,
"max-w-full overflow-hidden": panelProps.mobile,
}}

View File

@@ -91,6 +91,7 @@ const ProjectTile = (props: {
modal={!props.sidebarHovering()}
onOpenChange={(value) => {
props.setMenu(value)
props.setSuppressHover(value)
if (value) props.setOpen(false)
}}
>
@@ -107,6 +108,12 @@ const ProjectTile = (props: {
!props.selected() && !props.active(),
"bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(),
}}
onPointerDown={(event) => {
if (!props.overlay()) return
if (event.button !== 2 && !(event.button === 0 && event.ctrlKey)) return
props.setSuppressHover(true)
event.preventDefault()
}}
onMouseEnter={(event: MouseEvent) => {
if (!props.overlay()) return
if (props.suppressHover()) return

View File

@@ -37,7 +37,6 @@ import { createOpenReviewFile, createSizing } from "@/pages/session/helpers"
import { MessageTimeline } from "@/pages/session/message-timeline"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { resetSessionModel, syncSessionModel } from "@/pages/session/session-model-helpers"
import { createScrollSpy } from "@/pages/session/scroll-spy"
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { TerminalPanel } from "@/pages/session/terminal-panel"
@@ -122,9 +121,13 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
return
}
const beforeTop = el.scrollTop
const beforeHeight = el.scrollHeight
fn()
void el.scrollHeight
el.scrollTop = beforeTop
requestAnimationFrame(() => {
const delta = el.scrollHeight - beforeHeight
if (!delta) return
el.scrollTop = beforeTop + delta
})
}
const backfillTurns = () => {
@@ -207,7 +210,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
if (!input.userScrolled()) return
const el = input.scroller()
if (!el) return
if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return
if (el.scrollTop >= turnScrollThreshold) return
const start = turnStart()
if (start > 0) {
@@ -281,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,
@@ -426,10 +430,12 @@ export default function Page() {
createEffect(
on(
() => params.id,
(id, prev) => {
if (id || !prev) return
resetSessionModel(local)
() => ({ dir: params.dir, id: params.id }),
(next, prev) => {
if (!prev) return
if (next.dir === prev.dir && next.id === prev.id) return
if (prev.id) sync.session.evict(prev.id, prev.dir)
if (!next.id) resetSessionModel(local)
},
{ defer: true },
),
@@ -454,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()))
@@ -464,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<HTMLElement>("[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
@@ -550,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
@@ -594,6 +646,7 @@ export default function Page() {
() => {
setStore("messageId", undefined)
setStore("changes", "session")
setUi("pendingMessage", undefined)
},
{ defer: true },
),
@@ -1088,17 +1141,11 @@ export default function Page() {
let scrollStateFrame: number | undefined
let scrollStateTarget: HTMLDivElement | 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 })
@@ -1121,7 +1168,7 @@ export default function Page() {
const resumeScroll = () => {
setStore("messageId", undefined)
autoScroll.smoothScrollToBottom()
autoScroll.forceScrollToBottom()
clearMessageHash()
const el = scroller
@@ -1141,31 +1188,21 @@ 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) scheduleScrollState(el)
}
const markUserScroll = () => {
scrollMark += 1
}
createResizeObserver(
() => content,
() => {
const el = scroller
if (el) scheduleScrollState(el)
scrollSpy.markDirty()
},
)
@@ -1189,14 +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()
},
)
@@ -1224,7 +1262,7 @@ export default function Page() {
onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown)
scrollSpy.destroy()
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
})
@@ -1246,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(),
@@ -1255,7 +1293,7 @@ export default function Page() {
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={params.id}>
<Show when={activeMessage()}>
<Show when={lastUserMessage()}>
<MessageTimeline
mobileChanges={mobileChanges()}
mobileFallback={reviewContent({
@@ -1275,11 +1313,9 @@ export default function Page() {
onAutoScrollHandleScroll={autoScroll.handleScroll}
onMarkScrollGesture={markScrollGesture}
hasScrollGesture={hasScrollGesture}
isDesktop={isDesktop()}
onScrollSpyScroll={scrollSpy.onScroll}
onUserScroll={markUserScroll}
onTurnBackfillScroll={historyWindow.onScrollerScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
onPreserveScrollAnchor={autoScroll.preserve}
centered={centered()}
setContentRef={(el) => {
content = el
@@ -1296,8 +1332,6 @@ export default function Page() {
}}
renderedUserMessages={historyWindow.renderedUserMessages()}
anchor={anchor}
onRegisterMessage={scrollSpy.register}
onUnregisterMessage={scrollSpy.unregister}
/>
</Show>
</Match>
@@ -1362,6 +1396,7 @@ export default function Page() {
reviewPanel={reviewPanel}
activeDiff={tree.activeDiff}
focusReviewDiff={focusReviewDiff}
reviewSnap={ui.reviewSnap}
size={size}
/>
</div>

View File

@@ -140,7 +140,7 @@ export function SessionComposerRegion(props: {
<div
classList={{
"w-full px-3 pointer-events-auto": true,
"md:max-w-[500px] md:mx-auto 2xl:max-w-[700px]": props.centered,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}}
>
<Show when={props.state.questionRequest()} keyed>

View File

@@ -446,9 +446,9 @@ export function FileTabContent(props: { tab: string }) {
)
return (
<Tabs.Content value={props.tab} class="mt-3 relative flex h-full min-h-0 flex-col overflow-hidden contain-strict">
<Tabs.Content value={props.tab} class="mt-3 relative h-full">
<ScrollView
class="h-full min-h-0 flex-1"
class="h-full"
viewportRef={(el: HTMLDivElement) => {
scroll = el
restoreScroll()

View File

@@ -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<string>([sessionID])
const byParent = new Map<string, string[]>()
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 (
<Dialog title={language.t("session.delete.title")} fit>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex flex-col gap-1">
<span class="text-14-regular text-text-strong">
{language.t("session.delete.confirm", { name: name() })}
</span>
</div>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="primary" size="large" onClick={handleDelete}>
{language.t("session.delete.button")}
</Button>
</div>
</div>
</Dialog>
)
}
return (
<Show
@@ -336,18 +530,7 @@ export function MessageTimeline(props: {
<Icon name="arrow-down-to-line" />
</button>
</div>
<SessionTimelineHeader
centered={props.centered}
showHeader={showHeader}
sessionKey={sessionKey}
sessionID={sessionID}
parentID={parentID}
titleValue={titleValue}
headerTitle={headerTitle}
placeholderTitle={placeholderTitle}
/>
<ScrollView
reverse
viewportRef={props.setScrollRef}
onWheel={(e) => {
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",
}}
>
<div>
<div ref={props.setContentRef} class="min-w-0 w-full">
<Show when={showHeader()}>
<div
data-session-title
classList={{
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
"w-full": true,
"pb-4": true,
"pl-2 pr-3 md:pl-4 md:pr-3": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}}
>
<div class="h-12 w-full flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
<Show when={parentID()}>
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={navigateParent}
aria-label={language.t("common.goBack")}
/>
</Show>
<div class="flex items-center min-w-0 grow-1">
<div
class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
style={{
width: slot.open ? "16px" : "0px",
"margin-right": slot.open ? "8px" : "0px",
}}
aria-hidden="true"
>
<Show when={slot.show}>
<div
class="transition-opacity duration-200 ease-out"
classList={{
"opacity-0": slot.fade,
}}
>
<Spinner class="size-4" style={{ color: "var(--icon-interactive-base)" }} />
</div>
</Show>
</div>
<Show when={titleValue() || title.editing}>
<Show
when={title.editing}
fallback={
<h1
class="text-14-medium text-text-strong truncate grow-1 min-w-0"
onDblClick={openTitleEditor}
>
{titleValue()}
</h1>
}
>
<InlineInput
ref={(el) => {
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}
/>
</Show>
</Show>
</div>
</div>
<Show when={sessionID()}>
{(id) => (
<div class="shrink-0 flex items-center gap-3">
<SessionContextUsage placement="bottom" />
<DropdownMenu
gutter={4}
placement="bottom-end"
open={title.menuOpen}
onOpenChange={(open) => setTitle("menuOpen", open)}
>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
aria-label={language.t("common.moreOptions")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content
style={{ "min-width": "104px" }}
onCloseAutoFocus={(event) => {
if (!title.pendingRename) return
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
}}
>
<DropdownMenu.Item
onSelect={() => {
setTitle("pendingRename", true)
setTitle("menuOpen", false)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
>
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
)}
</Show>
</div>
</div>
</Show>
<div
ref={props.setContentRef}
role="log"
class="flex flex-col gap-0 items-start justify-start pb-16 transition-[margin]"
style={{ "padding-top": "var(--session-title-height)" }}
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
classList={{
"w-full": true,
"md:max-w-[500px] md:mx-auto 2xl:max-w-[700px]": props.centered,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
"mt-0.5": props.centered,
"mt-0": !props.centered,
}}
@@ -440,15 +745,6 @@ export function MessageTimeline(props: {
</Show>
<For each={rendered()}>
{(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 (
<div
id={props.anchor(messageID)}
data-message-id={messageID}
ref={(el) => {
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,
}}
>
<Show when={commentCount() > 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()}

View File

@@ -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()
})
})

View File

@@ -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<string, HTMLElement>()
const id = new WeakMap<HTMLElement, string>()
const visible = new Map<string, { ratio: number; top: number }>()
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,
}
}

View File

@@ -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() }}
>

View File

@@ -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 { IconButton } from "@opencode-ai/ui/icon-button"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
import { prefersReducedMotion } from "@opencode-ai/ui/hooks"
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 = prefersReducedMotion
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<string>([sessionID])
const byParent = new Map<string, string[]>()
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 (
<Dialog title={language.t("session.delete.title")} fit>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex flex-col gap-1">
<span class="text-14-regular text-text-strong">
{language.t("session.delete.confirm", { name: name() })}
</span>
</div>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="primary" size="large" onClick={handleDelete}>
{language.t("session.delete.button")}
</Button>
</div>
</div>
</Dialog>
)
}
return (
<Show when={props.showHeader()}>
<div
data-session-title
ref={(el) => {
headerRef = el
el.style.opacity = "0"
}}
class="pointer-events-none absolute inset-x-0 top-0 z-30"
>
<div
classList={{
"bg-[linear-gradient(to_bottom,var(--background-stronger)_38px,transparent)]": true,
"w-full": true,
"pb-10": true,
"px-4 md:px-5": true,
"md:max-w-[500px] md:mx-auto 2xl:max-w-[700px]": props.centered,
}}
>
<div class="pointer-events-auto h-12 w-full flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1">
<Show when={props.parentID()}>
<div>
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={navigateParent}
aria-label={language.t("common.goBack")}
/>
</div>
</Show>
<Show when={!!headerText.value || title.editing}>
<Show
when={title.editing}
fallback={
<h1 class="text-14-medium text-text-strong grow-1 min-w-0" onDblClick={openTitleEditor}>
<span class="grid min-w-0" style={{ overflow: "clip" }}>
<span ref={enterRef} class="col-start-1 row-start-1 min-w-0 truncate">
<span classList={{ "opacity-60": headerText.muted }}>{headerText.value}</span>
</span>
<span
ref={leaveRef}
class="col-start-1 row-start-1 min-w-0 truncate pointer-events-none"
style={{ opacity: "0" }}
>
<span classList={{ "opacity-60": headerText.prevMuted }}>{headerText.prev}</span>
</span>
</span>
</h1>
}
>
<InlineInput
ref={(el) => {
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}
/>
</Show>
</Show>
</div>
<Show when={props.sessionID()}>
{(id) => (
<div class="shrink-0 flex items-center gap-3">
<SessionContextUsage placement="bottom" />
<DropdownMenu
gutter={4}
placement="bottom-end"
open={title.menuOpen}
onOpenChange={(open) => setTitle("menuOpen", open)}
>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
aria-label={language.t("common.moreOptions")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content
style={{ "min-width": "104px" }}
onCloseAutoFocus={(event) => {
if (!title.pendingRename) return
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
}}
>
<DropdownMenu.Item
onSelect={() => {
setTitle("pendingRename", true)
setTitle("menuOpen", false)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}>
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
)}
</Show>
</div>
</div>
</div>
</Show>
)
}

View File

@@ -250,6 +250,7 @@ export function TerminalPanel() {
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
<Terminal
pty={pty()}
onConnect={() => terminal.trim(id)}
onCleanup={terminal.update}
onConnectError={() => terminal.clone(id)}
/>

View File

@@ -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<number>()
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,

View File

@@ -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;")
})
})

View File

@@ -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()
})
})

View File

@@ -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 = {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.2.21",
"version": "1.2.24",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.2.21",
"version": "1.2.24",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.2.21",
"version": "1.2.24",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.2.21",
"version": "1.2.24",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.2.21",
"version": "1.2.24",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.2.21",
"version": "1.2.24",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.2.21",
"version": "1.2.24",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.2.21"
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.21/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.21/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.21/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.21/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.21/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.24/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.2.21",
"version": "1.2.24",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.2.21",
"version": "1.2.24",
"name": "opencode",
"type": "module",
"license": "MIT",

View File

@@ -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)
}

View File

@@ -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"
@@ -111,7 +112,6 @@ export function tui(input: {
fetch?: typeof fetch
headers?: RequestInit["headers"]
events?: EventSource
onExit?: () => Promise<void>
}) {
// promise to prevent immediate exit
return new Promise<void>(async (resolve) => {
@@ -126,7 +126,6 @@ export function tui(input: {
const onExit = async () => {
unguard?.()
await input.onExit?.()
resolve()
}
@@ -373,6 +372,22 @@ function App() {
dialog.replace(() => <DialogSessionList />)
},
},
...(Flag.OPENCODE_EXPERIMENTAL_WORKSPACES_TUI
? [
{
title: "Manage workspaces",
value: "workspace.list",
category: "Workspace",
suggested: true,
slash: {
name: "workspaces",
},
onSelect: () => {
dialog.replace(() => <DialogWorkspaceList />)
},
},
]
: []),
{
title: "New session",
suggested: route.data.type === "session",

View File

@@ -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<typeof useDialog>
route: ReturnType<typeof useRoute>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
toast: ReturnType<typeof useToast>
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<void> }) {
const dialog = useDialog()
const sync = useSync()
const sdk = useSDK()
const toast = useToast()
const [creating, setCreating] = createSignal<string>()
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 (
<DialogSelect
title={creating() ? "Creating Workspace" : "New Workspace"}
skipFilter={true}
options={options()}
onSelect={(option) => {
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<string>()
const [counts, setCounts] = createSignal<Record<string, number | null | undefined>>({})
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(() => <DialogSessionList localOnly={true} />)
return
}
route.navigate({
type: "home",
})
dialog.clear()
return
}
const count = counts()[workspaceID]
if (count && count > 0) {
dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
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(() => <DialogSessionList workspaceID={workspaceID} />)
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 (
<DialogSelect
title="Workspaces"
skipFilter={true}
options={options()}
current={currentWorkspaceID()}
onMove={() => {
setToDelete(undefined)
}}
onSelect={(option) => {
setToDelete(undefined)
if (option.value === "__create__") {
dialog.replace(() => <DialogWorkspaceCreate onSelect={(workspaceID) => 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()
},
},
]}
/>
)
}

View File

@@ -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

View File

@@ -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<string>()
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 ? <Spinner /> : undefined,
}
})
})
onMount(() => {
dialog.setSize("large")
})
return (
<DialogSelect
title={props.workspaceID ? `Workspace Sessions` : props.localOnly ? "Local Sessions" : "Sessions"}
options={options()}
skipFilter={!props.localOnly}
current={currentSessionID()}
onFilter={setSearch}
onMove={() => {
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(() => <DialogSessionRename session={option.value} />)
},
},
]}
/>
)
}

View File

@@ -15,6 +15,7 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
init: (input: { onExit?: () => Promise<void> }) => {
const renderer = useRenderer()
let message: string | undefined
let task: Promise<void> | undefined
const store = {
set: (value?: string) => {
const prev = message
@@ -29,20 +30,24 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
get: () => message,
}
const exit: Exit = Object.assign(
async (reason?: unknown) => {
// Reset window title before destroying renderer
renderer.setTerminalTitle("")
renderer.destroy()
win32FlushInputBuffer()
if (reason) {
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
if (formatted) {
process.stderr.write(formatted + "\n")
(reason?: unknown) => {
if (task) return task
task = (async () => {
// Reset window title before destroying renderer
renderer.setTerminalTitle("")
renderer.destroy()
win32FlushInputBuffer()
if (reason) {
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
if (formatted) {
process.stderr.write(formatted + "\n")
}
}
}
const text = store.get()
if (text) process.stdout.write(text + "\n")
await input.onExit?.()
const text = store.get()
if (text) process.stdout.write(text + "\n")
await input.onExit?.()
})()
return task
},
{
message: store,

View File

@@ -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<Event, { type: key }>
@@ -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
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 })
for await (const event of events.stream) {
if (ctrl.signal.aborted) break
handleEvent(event)
}
if (timer) clearTimeout(timer)
if (queue.length > 0) flush()
}
})().catch(() => {})
}
onMount(() => {
if (props.events) {
const unsub = props.events.on(handleEvent)
onCleanup(unsub)
return
}
// 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) {
handleEvent(event)
}
// Flush any remaining events
if (timer) clearTimeout(timer)
if (queue.length > 0) {
flush()
}
} 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,
}
},
})

View File

@@ -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

View File

@@ -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<Session> }) => {
@@ -29,6 +30,17 @@ const ContextInfo = (props: { context: Accessor<string | undefined>; cost: Acces
)
}
const WorkspaceInfo = (props: { workspace: Accessor<string | undefined> }) => {
const { theme } = useTheme()
return (
<Show when={props.workspace()}>
<text fg={theme.textMuted} wrapMode="none" flexShrink={0}>
{props.workspace()}
</text>
</Show>
)
}
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() {
<Match when={session()?.parentID}>
<box flexDirection="column" gap={1}>
<box flexDirection={narrow() ? "column" : "row"} justifyContent="space-between" gap={narrow() ? 1 : 0}>
<text fg={theme.text}>
<b>Subagent session</b>
</text>
{Flag.OPENCODE_EXPERIMENTAL_WORKSPACES_TUI ? (
<box flexDirection="column">
<text fg={theme.text}>
<b>Subagent session</b>
</text>
<WorkspaceInfo workspace={workspace} />
</box>
) : (
<text fg={theme.text}>
<b>Subagent session</b>
</text>
)}
<ContextInfo context={context} cost={cost} />
</box>
<box flexDirection="row" gap={2}>
@@ -124,7 +154,14 @@ export function Header() {
</Match>
<Match when={true}>
<box flexDirection={narrow() ? "column" : "row"} justifyContent="space-between" gap={1}>
<Title session={session} />
{Flag.OPENCODE_EXPERIMENTAL_WORKSPACES_TUI ? (
<box flexDirection="column">
<Title session={session} />
<WorkspaceInfo workspace={workspace} />
</box>
) : (
<Title session={session} />
)}
<ContextInfo context={context} cost={cost} />
</box>
</Match>

View File

@@ -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)

View File

@@ -42,6 +42,9 @@ function createWorkerFetch(client: RpcClient): typeof fetch {
function createEventSource(client: RpcClient): EventSource {
return {
on: (handler) => client.on<Event>("event", handler),
setWorkspace: (workspaceID) => {
void client.call("setWorkspace", { workspaceID })
},
}
}
@@ -110,18 +113,20 @@ export const TuiThreadCommand = cmd({
return
}
// Resolve relative paths against PWD to preserve behavior when using --cwd flag
// Resolve relative --project paths from PWD, then use the real cwd after
// chdir so the thread and worker share the same directory key.
const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
const cwd = args.project
const next = args.project
? Filesystem.resolve(path.isAbsolute(args.project) ? args.project : path.join(root, args.project))
: root
: Filesystem.resolve(process.cwd())
const file = await target()
try {
process.chdir(cwd)
process.chdir(next)
} catch {
UI.error("Failed to change directory to " + cwd)
UI.error("Failed to change directory to " + next)
return
}
const cwd = Filesystem.resolve(process.cwd())
const worker = new Worker(file, {
env: Object.fromEntries(
@@ -208,7 +213,6 @@ export const TuiThreadCommand = cmd({
prompt,
fork: args.fork,
},
onExit: stop,
})
} finally {
await stop()

View File

@@ -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<string, string>; 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()

View File

@@ -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"]

View File

@@ -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")

View File

@@ -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
}

View File

@@ -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) => [

View File

@@ -23,7 +23,7 @@ export namespace Keybind {
*/
export function fromParsedKey(key: ParsedKey, leader = false): Info {
return {
name: key.name,
name: key.name === " " ? "space" : key.name,
ctrl: key.ctrl,
meta: key.meta,
shift: key.shift,

View File

@@ -0,0 +1,157 @@
import { describe, expect, mock, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { tmpdir } from "../../fixture/fixture"
const stop = new Error("stop")
const seen = {
tui: [] as string[],
inst: [] as string[],
}
mock.module("../../../src/cli/cmd/tui/app", () => ({
tui: async (input: { directory: string }) => {
seen.tui.push(input.directory)
throw stop
},
}))
mock.module("@/util/rpc", () => ({
Rpc: {
client: () => ({
call: async () => ({ url: "http://127.0.0.1" }),
on: () => {},
}),
},
}))
mock.module("@/cli/ui", () => ({
UI: {
error: () => {},
},
}))
mock.module("@/util/log", () => ({
Log: {
init: async () => {},
create: () => ({
error: () => {},
info: () => {},
warn: () => {},
debug: () => {},
time: () => ({ stop: () => {} }),
}),
Default: {
error: () => {},
info: () => {},
warn: () => {},
debug: () => {},
},
},
}))
mock.module("@/util/timeout", () => ({
withTimeout: <T>(input: Promise<T>) => input,
}))
mock.module("@/cli/network", () => ({
withNetworkOptions: <T>(input: T) => input,
resolveNetworkOptions: async () => ({
mdns: false,
port: 0,
hostname: "127.0.0.1",
}),
}))
mock.module("../../../src/cli/cmd/tui/win32", () => ({
win32DisableProcessedInput: () => {},
win32InstallCtrlCGuard: () => undefined,
}))
mock.module("@/config/tui", () => ({
TuiConfig: {
get: () => ({}),
},
}))
mock.module("@/project/instance", () => ({
Instance: {
provide: async (input: { directory: string; fn: () => Promise<unknown> | unknown }) => {
seen.inst.push(input.directory)
return input.fn()
},
},
}))
describe("tui thread", () => {
async function call(project?: string) {
const { TuiThreadCommand } = await import("../../../src/cli/cmd/tui/thread")
const args: Parameters<NonNullable<typeof TuiThreadCommand.handler>>[0] = {
_: [],
$0: "opencode",
project,
prompt: "hi",
model: undefined,
agent: undefined,
session: undefined,
continue: false,
fork: false,
port: 0,
hostname: "127.0.0.1",
mdns: false,
"mdns-domain": "opencode.local",
mdnsDomain: "opencode.local",
cors: [],
}
return TuiThreadCommand.handler(args)
}
async function check(project?: string) {
await using tmp = await tmpdir({ git: true })
const cwd = process.cwd()
const pwd = process.env.PWD
const worker = globalThis.Worker
const tty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY")
const link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link")
const type = process.platform === "win32" ? "junction" : "dir"
seen.tui.length = 0
seen.inst.length = 0
await fs.symlink(tmp.path, link, type)
Object.defineProperty(process.stdin, "isTTY", {
configurable: true,
value: true,
})
globalThis.Worker = class extends EventTarget {
onerror = null
onmessage = null
onmessageerror = null
postMessage() {}
terminate() {}
} as unknown as typeof Worker
try {
process.chdir(tmp.path)
process.env.PWD = link
await expect(call(project)).rejects.toBe(stop)
expect(seen.inst[0]).toBe(tmp.path)
expect(seen.tui[0]).toBe(tmp.path)
} finally {
process.chdir(cwd)
if (pwd === undefined) delete process.env.PWD
else process.env.PWD = pwd
if (tty) Object.defineProperty(process.stdin, "isTTY", tty)
else delete (process.stdin as { isTTY?: boolean }).isTTY
globalThis.Worker = worker
await fs.rm(link, { recursive: true, force: true }).catch(() => undefined)
}
}
test("uses the real cwd when PWD points at a symlink", async () => {
await check()
})
test("uses the real cwd after resolving a relative project from PWD", async () => {
await check(".")
})
})

View File

@@ -36,4 +36,19 @@ describe("file.ripgrep", () => {
expect(hasVisible).toBe(true)
expect(hasHidden).toBe(false)
})
test("search returns empty when nothing matches", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "match.ts"), "const value = 'other'\n")
},
})
const hits = await Ripgrep.search({
cwd: tmp.path,
pattern: "needle",
})
expect(hits).toEqual([])
})
})

View File

@@ -0,0 +1,47 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Installation } from "../../src/installation"
const fetch0 = globalThis.fetch
afterEach(() => {
globalThis.fetch = fetch0
})
describe("installation", () => {
test("reads release version from GitHub releases", async () => {
globalThis.fetch = (async () =>
new Response(JSON.stringify({ tag_name: "v1.2.3" }), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof fetch
expect(await Installation.latest("unknown")).toBe("1.2.3")
})
test("reads scoop manifest versions", async () => {
globalThis.fetch = (async () =>
new Response(JSON.stringify({ version: "2.3.4" }), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof fetch
expect(await Installation.latest("scoop")).toBe("2.3.4")
})
test("reads chocolatey feed versions", async () => {
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
d: {
results: [{ Version: "3.4.5" }],
},
}),
{
status: 200,
headers: { "content-type": "application/json" },
},
)) as unknown as typeof fetch
expect(await Installation.latest("choco")).toBe("3.4.5")
})
})

View File

@@ -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) => {

View File

@@ -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", () => {

View File

@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Process } from "../../src/util/process"
import { tmpdir } from "../fixture/fixture"
function node(script: string) {
return [process.execPath, "-e", script]
@@ -56,4 +57,21 @@ describe("util.process", () => {
expect(out.code).not.toBe(0)
expect(Date.now() - started).toBeLessThan(1000)
}, 3000)
test("uses cwd when spawning commands", async () => {
await using tmp = await tmpdir()
const out = await Process.run(node("process.stdout.write(process.cwd())"), {
cwd: tmp.path,
})
expect(out.stdout.toString()).toBe(tmp.path)
})
test("merges environment overrides", async () => {
const out = await Process.run(node('process.stdout.write(process.env.OPENCODE_TEST ?? "")'), {
env: {
OPENCODE_TEST: "set",
},
})
expect(out.stdout.toString()).toBe("set")
})
})

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.2.21",
"version": "1.2.24",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.2.21",
"version": "1.2.24",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -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 })
}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.2.21",
"version": "1.2.24",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.2.21",
"version": "1.2.24",
"type": "module",
"license": "MIT",
"exports": {

View File

@@ -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));
}

View File

@@ -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<number[]>([])
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 (
<span data-component="animated-number" class={props.class} aria-label={label()}>
<span data-slot="animated-number-value" style={{ "--animated-number-width": width() }}>
<Index each={displayDigits()}>{(digit) => <Digit value={digit()} direction={direction()} />}</Index>
<Index each={digits()}>{(digit) => <Digit value={digit()} direction={direction()} />}</Index>
</span>
</span>
)

View File

@@ -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;

View File

@@ -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 (
<div data-component="tool-trigger" data-arrow={props.arrow ? "" : undefined}>
<div data-slot="basic-tool-tool-trigger-content">
<div data-slot="basic-tool-tool-info">
<Switch>
<Match when={isTriggerTitle(props.trigger) && props.trigger}>
{(trigger) => (
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span
data-slot="basic-tool-tool-title"
classList={{
[trigger().titleClass ?? ""]: !!trigger().titleClass,
}}
>
<TextShimmer text={trigger().title} active={props.pending} />
</span>
<Show when={!props.pending}>
<Show when={trigger().subtitle}>
<span
data-slot="basic-tool-tool-subtitle"
classList={{
[trigger().subtitleClass ?? ""]: !!trigger().subtitleClass,
clickable: !!props.onSubtitleClick,
}}
onClick={(e) => {
if (!props.onSubtitleClick) return
e.stopPropagation()
props.onSubtitleClick()
}}
>
{trigger().subtitle}
</span>
</Show>
<Show when={trigger().args?.length}>
<For each={trigger().args}>
{(arg) => (
<span
data-slot="basic-tool-tool-arg"
classList={{
[trigger().argsClass ?? ""]: !!trigger().argsClass,
}}
>
{arg}
</span>
)}
</For>
</Show>
</Show>
</div>
<Show when={!props.pending && trigger().action}>{trigger().action}</Show>
</div>
)}
</Match>
<Match when={true}>{props.trigger as JSX.Element}</Match>
</Switch>
</div>
</div>
<Show when={props.arrow}>
<Collapsible.Arrow />
</Show>
</div>
)
}
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<number>(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 (
<Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
<Collapsible.Trigger>
<ToolCallTriggerBody
trigger={props.trigger}
pending={pending()}
onSubtitleClick={props.onSubtitleClick}
arrow={!!props.children && !props.hideDetails && !props.locked && !pending()}
/>
<div data-component="tool-trigger">
<div data-slot="basic-tool-tool-trigger-content">
<div data-slot="basic-tool-tool-info">
<Switch>
<Match when={isTriggerTitle(props.trigger) && props.trigger}>
{(trigger) => (
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span
data-slot="basic-tool-tool-title"
classList={{
[trigger().titleClass ?? ""]: !!trigger().titleClass,
}}
>
<TextShimmer text={trigger().title} active={pending()} />
</span>
<Show when={!pending()}>
<Show when={trigger().subtitle}>
<span
data-slot="basic-tool-tool-subtitle"
classList={{
[trigger().subtitleClass ?? ""]: !!trigger().subtitleClass,
clickable: !!props.onSubtitleClick,
}}
onClick={(e) => {
if (props.onSubtitleClick) {
e.stopPropagation()
props.onSubtitleClick()
}
}}
>
{trigger().subtitle}
</span>
</Show>
<Show when={trigger().args?.length}>
<For each={trigger().args}>
{(arg) => (
<span
data-slot="basic-tool-tool-arg"
classList={{
[trigger().argsClass ?? ""]: !!trigger().argsClass,
}}
>
{arg}
</span>
)}
</For>
</Show>
</Show>
</div>
<Show when={!pending() && trigger().action}>{trigger().action}</Show>
</div>
)}
</Match>
<Match when={true}>{props.trigger as JSX.Element}</Match>
</Switch>
</div>
</div>
<Show when={props.children && !props.hideDetails && !props.locked && !pending()}>
<Collapsible.Arrow />
</Show>
</div>
</Collapsible.Trigger>
<Show when={props.springContent && props.animate !== false && props.children && !props.hideDetails}>
<Show when={props.animated && props.children && !props.hideDetails}>
<div
ref={contentRef}
data-slot="collapsible-content"
data-spring-content
data-animated
style={{
height: initialOpen ? "auto" : "0px",
overflow: "hidden",
display: initialOpen ? undefined : "none",
overflow: initialOpen ? "visible" : "hidden",
}}
>
<div ref={bodyRef} data-slot="basic-tool-content-inner">
{props.children}
</div>
{props.children}
</div>
</Show>
<Show when={(!props.springContent || props.animate === false) && props.children && !props.hideDetails}>
<Show when={!props.animated && props.children && !props.hideDetails}>
<Collapsible.Content>
<Show when={!props.defer || ready()}>
<div data-slot="basic-tool-content-inner">{props.children}</div>
</Show>
<Show when={!props.defer || ready()}>{props.children}</Show>
</Collapsible.Content>
</Show>
</Collapsible>
@@ -330,60 +222,6 @@ function args(input: Record<string, unknown> | 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<ToolCallPanelBaseProps, "hideDetails"> {
variant: "panel"
}
export type ToolCallProps = ToolCallRowProps | ToolCallPanelProps
function ToolCallRoot(props: ToolCallProps) {
const pending = () => props.status === "pending" || props.status === "running"
if (props.variant === "row") {
return (
<Show
when={props.onOpenChange}
fallback={
<div data-component="collapsible" data-variant="normal" class="tool-collapsible">
<div data-slot="collapsible-trigger">
<ToolCallTriggerBody
trigger={props.trigger}
pending={pending()}
onSubtitleClick={props.onSubtitleClick}
/>
</div>
</div>
}
>
{(onOpenChange) => (
<Collapsible open={props.open ?? true} onOpenChange={onOpenChange()} class="tool-collapsible">
<Collapsible.Trigger>
<ToolCallTriggerBody
trigger={props.trigger}
pending={pending()}
onSubtitleClick={props.onSubtitleClick}
arrow={!!props.showArrow}
/>
</Collapsible.Trigger>
</Collapsible>
)}
</Show>
)
}
const [, rest] = splitProps(props, ["variant"])
return <ToolCallPanel {...rest} />
}
export const ToolCall = ToolCallRoot
export function GenericTool(props: {
tool: string
status?: string
@@ -391,8 +229,7 @@ export function GenericTool(props: {
input?: Record<string, unknown>
}) {
return (
<ToolCall
variant={props.hideDetails ? "row" : "panel"}
<BasicTool
icon="mcp"
status={props.status}
trigger={{
@@ -400,6 +237,7 @@ export function GenericTool(props: {
subtitle: label(props.input),
args: args(props.input),
}}
hideDetails={props.hideDetails}
/>
)
}

View File

@@ -2,28 +2,89 @@
width: 100%;
display: flex;
flex-direction: column;
background-color: var(--surface-inset-base);
border: 1px solid var(--border-weaker-base);
transition: background-color 0.15s ease;
position: relative;
background: transparent;
border: none;
border-radius: var(--radius-md);
padding: 6px 12px;
overflow: clip;
padding: 10px 12px 10px 10px;
&[data-variant="error"] {
background-color: var(--surface-critical-weak);
border: 1px solid var(--border-critical-base);
color: rgba(218, 51, 25, 0.6);
&:has([data-slot="card-title"]) {
gap: 8px;
}
/* text-12-regular */
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-style: normal;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large); /* 166.667% */
letter-spacing: var(--letter-spacing-normal);
/* text-14-regular */
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
font-style: normal;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-strong);
&[data-component="icon"] {
color: var(--icon-critical-active);
}
--card-gap: 8px;
--card-icon: 16px;
--card-indent: calc(var(--card-icon) + var(--card-gap));
--card-accent: var(--icon-active);
&[data-icon="none"] {
--card-indent: 0px;
}
&::before {
content: "";
position: absolute;
left: 0;
top: 8px;
bottom: 8px;
width: 2px;
border-radius: 2px;
background-color: var(--card-accent);
}
:where([data-card="title"], [data-slot="card-title"]) {
color: var(--text-strong);
font-weight: var(--font-weight-medium);
}
:where([data-slot="card-title"]) {
display: flex;
align-items: center;
gap: var(--card-gap);
}
:where([data-slot="card-title"]) [data-component="icon"] {
color: var(--card-accent);
}
:where([data-slot="card-title-icon"]) {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--card-icon);
height: var(--card-icon);
flex: 0 0 auto;
}
:where([data-slot="card-title-icon"][data-placeholder]) [data-component="icon"] {
color: var(--text-weak);
}
:where([data-slot="card-title-icon"])
[data-slot="icon-svg"]
:is(path, line, polyline, polygon, rect, circle, ellipse)[stroke] {
stroke-width: 1.5px !important;
}
:where([data-card="description"], [data-slot="card-description"]) {
color: var(--text-base);
padding-left: var(--card-indent);
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
:where([data-card="actions"], [data-slot="card-actions"]) {
padding-left: var(--card-indent);
}
}

View File

@@ -1,5 +1,5 @@
// @ts-nocheck
import { Card } from "./card"
import { Card, CardActions, CardDescription, CardTitle } from "./card"
import { Button } from "./button"
const docs = `### Overview
@@ -49,15 +49,13 @@ export default {
render: (props: { variant?: "normal" | "error" | "warning" | "success" | "info" }) => {
return (
<Card variant={props.variant}>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 500 }}>Card title</div>
<div style={{ color: "var(--text-weak)", fontSize: "13px" }}>Small supporting text.</div>
</div>
<Button size="small" variant="ghost">
<CardTitle>Card title</CardTitle>
<CardDescription>Small supporting text.</CardDescription>
<CardActions>
<Button size="small" variant="secondary">
Action
</Button>
</div>
</CardActions>
</Card>
)
},

View File

@@ -1,22 +1,135 @@
import { type ComponentProps, splitProps } from "solid-js"
import { createContext, type ComponentProps, splitProps, useContext } from "solid-js"
import { Icon, type IconProps } from "./icon"
export interface CardProps extends ComponentProps<"div"> {
variant?: "normal" | "error" | "warning" | "success" | "info"
/**
* Optional card icon used by `CardTitle`.
*
* - `undefined`: picks a default icon based on `variant` (error/warning/info)
* - `false`/`null`: disables the icon
* - `Icon` name: forces a specific icon
*/
icon?: IconProps["name"] | false | null
}
type Ctx = {
variant: NonNullable<CardProps["variant"]>
mode: "none" | "set" | "placeholder"
icon?: IconProps["name"]
}
const Ctx = createContext<Ctx>()
function pick(variant: NonNullable<CardProps["variant"]>) {
if (variant === "error") return "circle-ban-sign" as const
if (variant === "warning") return "warning" as const
if (variant === "success") return "circle-check" as const
if (variant === "info") return "help" as const
return
}
function mix(style: ComponentProps<"div">["style"], value?: string) {
if (!value) return style
if (!style) return { "--card-accent": value }
if (typeof style === "string") return `${style};--card-accent:${value};`
return { ...(style as Record<string, string | number>), "--card-accent": value }
}
export function Card(props: CardProps) {
const [split, rest] = splitProps(props, ["variant", "class", "classList"])
const [split, rest] = splitProps(props, ["variant", "icon", "style", "class", "classList"])
const variant = () => split.variant || "normal"
const accent = () => {
const v = variant()
if (v === "error") return "var(--icon-critical-base)"
if (v === "warning") return "var(--icon-warning-active)"
if (v === "success") return "var(--icon-success-active)"
if (v === "info") return "var(--icon-info-active)"
return
}
const mode = () => {
if (split.icon === false || split.icon === null) return "none" as const
if (typeof split.icon === "string") return "set" as const
return pick(variant()) ? ("set" as const) : ("placeholder" as const)
}
const icon = () => {
if (split.icon === false || split.icon === null) return
if (typeof split.icon === "string") return split.icon
return pick(variant())
}
return (
<Ctx.Provider value={{ variant: variant(), mode: mode(), icon: icon() }}>
<div
{...rest}
data-component="card"
data-variant={variant()}
data-icon={mode()}
style={mix(split.style, accent())}
classList={{
...(split.classList ?? {}),
[split.class ?? ""]: !!split.class,
}}
>
{props.children}
</div>
</Ctx.Provider>
)
}
export function CardTitle(props: ComponentProps<"div">) {
const [split, rest] = splitProps(props, ["class", "classList", "children"])
const ctx = useContext(Ctx)
const show = () => ctx?.mode !== "none"
const name = () => ctx?.icon ?? ("dash" as const)
const placeholder = () => !ctx?.icon
return (
<div
{...rest}
data-component="card"
data-variant={split.variant || "normal"}
data-slot="card-title"
classList={{
...(split.classList ?? {}),
[split.class ?? ""]: !!split.class,
}}
>
{props.children}
{show() ? (
<span data-slot="card-title-icon" data-placeholder={placeholder() || undefined}>
<Icon name={name()} size="small" />
</span>
) : null}
{split.children}
</div>
)
}
export function CardDescription(props: ComponentProps<"div">) {
const [split, rest] = splitProps(props, ["class", "classList", "children"])
return (
<div
{...rest}
data-slot="card-description"
classList={{
...(split.classList ?? {}),
[split.class ?? ""]: !!split.class,
}}
>
{split.children}
</div>
)
}
export function CardActions(props: ComponentProps<"div">) {
const [split, rest] = splitProps(props, ["class", "classList", "children"])
return (
<div
{...rest}
data-slot="card-actions"
classList={{
...(split.classList ?? {}),
[split.class ?? ""]: !!split.class,
}}
>
{split.children}
</div>
)
}

View File

@@ -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;
}
}

View File

@@ -1,199 +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 { useI18n } from "../context/i18n"
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
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 (
<ToolCall
variant="row"
icon="magnifying-glass-menu"
open={!props.pending && props.open}
showArrow={!props.pending}
onOpenChange={(v) => {
if (!props.pending) props.onOpenChange(v)
}}
trigger={
<div data-component="context-tool-group-trigger" data-pending={props.pending || undefined}>
<span
data-slot="context-tool-group-title"
class="min-w-0 flex items-center gap-2 text-14-medium text-text-strong"
>
<span data-slot="context-tool-group-label" class="shrink-0">
<ToolStatusTitle
active={props.pending}
activeText={i18n.t("ui.sessionTurn.status.gatheringContext")}
doneText={i18n.t("ui.sessionTurn.status.gatheredContext")}
split={false}
/>
</span>
<span
data-slot="context-tool-group-summary"
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-normal text-text-base"
>
<AnimatedCountList
items={[
{
key: "read",
count: summary().read,
one: i18n.t("ui.messagePart.context.read.one"),
other: i18n.t("ui.messagePart.context.read.other"),
},
{
key: "search",
count: summary().search,
one: i18n.t("ui.messagePart.context.search.one"),
other: i18n.t("ui.messagePart.context.search.other"),
},
{
key: "list",
count: summary().list,
one: i18n.t("ui.messagePart.context.list.one"),
other: i18n.t("ui.messagePart.context.list.other"),
},
]}
fallback=""
/>
</span>
</span>
</div>
}
/>
)
}
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 (
<div ref={contentRef} style={{ overflow: "clip", height: "0px", display: "none" }}>
<div ref={bodyRef}>
<div ref={scrollRef} data-component="context-tool-expanded-list" onScroll={updateMask}>
<For each={props.parts}>
{(part) => {
const label = createMemo(() => contextToolLabel(part))
return (
<div data-component="context-tool-expanded-row">
<span data-slot="context-tool-expanded-action">{label().action}</span>
<span data-slot="context-tool-expanded-detail">{label().detail}</span>
</div>
)
}}
</For>
</div>
</div>
</div>
)
}
export function ContextToolRollingResults(props: { parts: ToolPart[]; pending: boolean }) {
const wiped = new Set<string>()
const [mounted, setMounted] = createSignal(false)
onMount(() => setMounted(true))
const reduce = prefersReducedMotion
const show = () => mounted() && props.pending
const opacity = useSpring(() => (show() ? 1 : 0), GROW_SPRING)
const blur = useSpring(() => (show() ? 0 : 2), GROW_SPRING)
return (
<div style={{ opacity: reduce() ? (show() ? 1 : 0) : opacity(), filter: `blur(${reduce() ? 0 : blur()}px)` }}>
<RollingResults
items={props.parts}
rows={5}
rowHeight={22}
rowGap={0}
open={props.pending}
animate
getKey={(part) => part.callID || part.id}
render={(part) => {
const label = createMemo(() => contextToolLabel(part))
const k = part.callID || part.id
return (
<div data-component="context-tool-rolling-row">
<span data-slot="context-tool-rolling-action">{label().action}</span>
{(() => {
const [detailRef, setDetailRef] = createSignal<HTMLSpanElement>()
useRowWipe({
id: () => k,
text: () => label().detail,
ref: detailRef,
seen: wiped,
})
return (
<span
ref={setDetailRef}
data-slot="context-tool-rolling-detail"
style={{ display: label().detail ? undefined : "none" }}
>
{label().detail}
</span>
)
})()}
</div>
)
}}
/>
</div>
)
}

View File

@@ -1,426 +0,0 @@
import { createEffect, on, type JSX, onMount, onCleanup } from "solid-js"
import { animate, tunableSpringValue, type AnimationPlaybackControls, GROW_SPRING, type SpringConfig } from "./motion"
import { prefersReducedMotion } from "../hooks/use-reduced-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 = prefersReducedMotion
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<typeof setTimeout> | undefined
let edgeOn = false
let mountFrame: number | undefined
let resizeFrame: number | undefined
let observer: ResizeObserver | undefined
let springTarget = -1
const height = tunableSpringValue<number>(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 (!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")
})
}
if (watch()) {
observer = new ResizeObserver(() => {
if (!open()) return
if (resizeFrame !== undefined) return
resizeFrame = requestAnimationFrame(() => {
resizeFrame = undefined
setHeight("mount")
})
})
observer.observe(body)
}
})
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 (
<div
ref={root}
data-slot={props.slot}
class={props.class}
style={{ transform: "translateZ(0)", position: "relative" }}
>
<div ref={body} style={{ "padding-top": gap() > 0 ? `${gap()}px` : undefined }}>
{props.children}
</div>
<div
ref={edgeRef}
data-slot="grow-box-edge"
style={{
position: "absolute",
left: "0",
right: "0",
bottom: "0",
height: `${edgeHeight()}px`,
opacity: 0,
"pointer-events": "none",
background: "linear-gradient(to bottom, transparent 0%, var(--background-stronger) 100%)",
}}
/>
</div>
)
}

View File

@@ -244,6 +244,7 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
event.stopPropagation()
if (e.key === "Escape") {
event.preventDefault()
e.currentTarget.blur()
split.onCancel()
return
}

View File

@@ -60,6 +60,7 @@
ol {
margin-top: 0.5rem;
margin-bottom: 1rem;
margin-left: 0;
padding-left: 1.5rem;
list-style-position: outside;
}
@@ -70,6 +71,7 @@
ol {
list-style-type: decimal;
padding-left: 2.25rem;
}
li {
@@ -98,6 +100,10 @@
padding-left: 1rem; /* Minimal indent for nesting only */
}
li > ol {
padding-left: 1.75rem;
}
/* Blockquotes */
blockquote {
border-left: 2px solid var(--border-weak-base);

View File

@@ -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);
@@ -291,41 +305,6 @@
}
}
[data-component="tool-error"] {
display: flex;
align-items: start;
gap: 8px;
[data-slot="icon-svg"] {
color: var(--icon-critical-base);
margin-top: 4px;
}
[data-slot="message-part-tool-error-content"] {
display: flex;
align-items: start;
gap: 8px;
}
[data-slot="message-part-tool-error-title"] {
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-on-critical-base);
white-space: nowrap;
}
[data-slot="message-part-tool-error-message"] {
color: var(--text-on-critical-weak);
max-height: 240px;
overflow-y: auto;
word-break: break-word;
}
}
[data-component="tool-output"] {
white-space: pre;
padding: 0;
@@ -358,16 +337,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 +413,7 @@
[data-component="write-trigger"] {
display: flex;
align-items: center;
justify-content: flex-start;
justify-content: space-between;
gap: 8px;
width: 100%;
@@ -450,8 +426,7 @@
}
[data-slot="message-part-title"] {
flex-shrink: 1;
min-width: 0;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 8px;
@@ -483,45 +458,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;
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;
font-weight: var(--font-weight-regular);
}
[data-slot="message-part-directory-inline"] {
color: var(--text-weak);
[data-slot="message-part-path"] {
display: flex;
flex-grow: 1;
min-width: 0;
max-width: min(48vw, 36ch);
font-weight: var(--font-weight-regular);
}
[data-slot="message-part-directory"] {
color: var(--text-weak);
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 +582,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 +604,7 @@
}
[data-component="context-tool-group-trigger"] {
width: 100%;
min-height: 24px;
display: flex;
align-items: center;
@@ -652,352 +612,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-component="context-tool-step"] {
width: 100%;
min-width: 0;
padding-left: 12px;
}
[data-component="context-tool-expanded-list"] {
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;
&::-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;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: var(--font-size-base);
color: var(--text-base);
opacity: 0.75;
}
}
[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"] {
[data-slot="collapsible-arrow"] {
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-component="context-tool-group-list"] {
padding: 6px 0 4px 0;
display: flex;
flex-direction: column;
gap: 2px;
[data-slot="icon-svg"] {
color: var(--icon-base);
}
[data-slot="context-tool-group-item"] {
min-width: 0;
padding: 6px 0;
}
}
[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;
@@ -1042,7 +678,6 @@
[data-component="user-message"] [data-slot="user-message-text"],
[data-component="text-part"],
[data-component="reasoning-part"],
[data-component="tool-error"],
[data-component="tool-output"],
[data-component="bash-output"],
[data-component="edit-content"],
@@ -1058,30 +693,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 +1151,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 +1163,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 +1201,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 +1243,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 +1257,4 @@
flex-shrink: 0;
color: var(--icon-weak);
}
span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,8 @@
import { attachSpring, motionValue } from "motion"
import type { SpringOptions } from "motion"
import { createEffect, createSignal, onCleanup } from "solid-js"
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
type Opt = Pick<SpringOptions, "visualDuration" | "bounce" | "stiffness" | "damping" | "mass" | "velocity">
type Opt = Partial<Pick<SpringOptions, "visualDuration" | "bounce" | "stiffness" | "damping" | "mass" | "velocity">>
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 = prefersReducedMotion
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())
})

View File

@@ -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<T extends string | number>(initial: T, config: SpringConfig): MotionValue<T> {
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 = ""
}

View File

@@ -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);
}
}

View File

@@ -1,326 +0,0 @@
import { For, Show, batch, createEffect, createMemo, createSignal, on, onCleanup, onMount, type JSX } from "solid-js"
import { animate, clearMaskStyles, GROW_SPRING, type AnimationPlaybackControls, type SpringConfig } from "./motion"
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
export type RollingResultsProps<T> = {
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<T>(props: RollingResultsProps<T>) {
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 reducedMotion = prefersReducedMotion
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) && !reducedMotion())
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 (
<div
data-component="rolling-results"
class={props.class}
data-open={open() ? "true" : "false"}
data-overflowing={overflowing() ? "true" : "false"}
data-scrollable={scrollReady() ? "true" : "false"}
data-fixed={fixed() ? "true" : "false"}
style={{
"--rolling-results-row-height": `${rowHeight()}px`,
"--rolling-results-fixed-height": `${fixed() ? fixedHeight() : 0}px`,
"--rolling-results-fixed-gap": `${gap()}px`,
"--rolling-results-row-gap": `${rowGap()}px`,
"--rolling-results-fade": `${Math.round(rowHeight() * 0.6)}px`,
}}
>
<div ref={view} data-slot="rolling-results-viewport" aria-live="polite">
<Show when={fixed()}>
<div data-slot="rolling-results-fixed">{props.fixed}</div>
</Show>
<div ref={windowEl} data-slot="rolling-results-window" onScroll={onWindowScroll}>
<div data-slot="rolling-results-body">
<Show when={list().length === 0 && props.empty !== undefined}>
<div data-slot="rolling-results-empty">{props.empty}</div>
</Show>
<div
ref={track}
data-slot="rolling-results-track"
style={{ "padding-top": scrollReady() ? undefined : `${skipped() * step()}px` }}
>
<For each={rendered()}>
{(item, index) => (
<div data-slot="rolling-results-row" data-key={key(item, index())}>
{props.render(item, index())}
</div>
)}
</For>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -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;
}

View File

@@ -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()
})
})

View File

@@ -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<KeyboardEvent, "key" | "altKey" | "ctrlKey" | "metaKey" | "shiftKey">) => {
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 (
<div
ref={rootRef}
class={`scroll-view ${local.class || ""}`}
style={local.style}
onPointerEnter={() => setIsHovered(true)}
@@ -209,26 +199,16 @@ export function ScrollView(props: ScrollViewProps) {
<div
ref={viewportRef}
class="scroll-view__viewport"
data-reverse={reverse() ? "true" : undefined}
onScroll={(e) => {
updateThumb()
if (typeof events.onScroll === "function") events.onScroll(e as any)
}}
onWheel={(e) => {
if (e.deltaY) stop()
if (typeof events.onWheel === "function") events.onWheel(e as any)
}}
onTouchStart={(e) => {
stop()
if (typeof events.onTouchStart === "function") events.onTouchStart(e as any)
}}
onWheel={events.onWheel as any}
onTouchStart={events.onTouchStart as any}
onTouchMove={events.onTouchMove as any}
onTouchEnd={events.onTouchEnd as any}
onTouchCancel={events.onTouchCancel as any}
onPointerDown={(e) => {
stop()
if (typeof events.onPointerDown === "function") events.onPointerDown(e as any)
}}
onPointerDown={events.onPointerDown as any}
onClick={events.onClick as any}
tabIndex={0}
role="region"

View File

@@ -1,4 +1,5 @@
[data-component="session-turn"] {
--sticky-header-height: calc(var(--session-title-height, 0px) + 24px);
height: 100%;
min-height: 0;
min-width: 0;
@@ -25,7 +26,7 @@
align-items: flex-start;
align-self: stretch;
min-width: 0;
gap: 0px;
gap: 18px;
overflow-anchor: none;
}
@@ -42,127 +43,30 @@
align-self: stretch;
}
[data-slot="session-turn-assistant-lane"] {
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
align-self: stretch;
}
[data-slot="session-turn-thinking"] {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
white-space: nowrap;
color: var(--text-weak);
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
height: 36px;
line-height: 20px;
min-height: 20px;
[data-component="spinner"] {
width: 16px;
height: 16px;
}
> [data-component="text-shimmer"] {
flex: 0 0 auto;
white-space: nowrap;
}
}
[data-slot="session-turn-handoff-wrap"] {
width: 100%;
min-width: 0;
overflow: visible;
}
[data-slot="session-turn-handoff"] {
width: 100%;
min-width: 0;
min-height: 37px;
position: relative;
}
[data-slot="session-turn-thinking"] {
position: absolute;
inset: 0;
will-change: opacity, filter;
transition:
opacity 180ms ease-out,
filter 180ms ease-out,
transform 180ms ease-out;
}
[data-slot="session-turn-thinking"][data-visible="false"] {
opacity: 0;
filter: blur(2px);
transform: translateY(1px);
pointer-events: none;
}
[data-slot="session-turn-thinking"][data-visible="true"] {
opacity: 1;
filter: blur(0px);
transform: translateY(0px);
}
[data-slot="session-turn-meta"] {
position: absolute;
inset: 0;
min-height: 37px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
}
[data-slot="session-turn-meta"][data-interrupted] {
gap: 12px;
}
[data-slot="session-turn-meta"] [data-component="tooltip-trigger"] {
display: inline-flex;
width: fit-content;
}
[data-slot="session-turn-message-container"]:hover [data-slot="session-turn-meta"][data-visible="true"],
[data-slot="session-turn-message-container"]:focus-within [data-slot="session-turn-meta"][data-visible="true"] {
opacity: 1;
pointer-events: auto;
}
[data-slot="session-turn-meta-label"] {
user-select: none;
min-width: 0;
overflow: clip;
white-space: nowrap;
text-overflow: ellipsis;
}
[data-component="text-reveal"].session-turn-thinking-heading {
flex: 1 1 auto;
min-width: 0;
overflow: clip;
white-space: nowrap;
line-height: inherit;
color: var(--text-weaker);
font-weight: var(--font-weight-regular);
[data-slot="text-reveal-track"],
[data-slot="text-reveal-entering"],
[data-slot="text-reveal-leaving"] {
min-height: 0;
line-height: inherit;
}
}
.error-card {
@@ -180,7 +84,7 @@
display: flex;
flex-direction: column;
align-self: stretch;
gap: 0px;
gap: 12px;
> :first-child > [data-component="markdown"]:first-child {
margin-top: 0;
@@ -205,7 +109,6 @@
[data-component="session-turn-diffs-trigger"] {
width: 100%;
height: 36px;
display: flex;
align-items: center;
justify-content: flex-start;
@@ -215,7 +118,7 @@
[data-slot="session-turn-diffs-title"] {
display: inline-flex;
align-items: center;
align-items: baseline;
gap: 8px;
}
@@ -233,7 +136,7 @@
font-variant-numeric: tabular-nums;
font-size: var(--font-size-base);
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
line-height: var(--line-height-x-large);
}
[data-slot="session-turn-diffs-meta"] {
@@ -269,10 +172,8 @@
[data-slot="session-turn-diff-path"] {
display: flex;
flex-grow: 1;
min-width: 0;
align-items: baseline;
overflow: clip;
white-space: nowrap;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
@@ -280,22 +181,16 @@
}
[data-slot="session-turn-diff-directory"] {
flex: 1 1 auto;
color: var(--text-weak);
min-width: 0;
overflow: clip;
color: var(--text-base);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
direction: rtl;
unicode-bidi: plaintext;
text-align: left;
}
[data-slot="session-turn-diff-filename"] {
flex-shrink: 0;
max-width: 100%;
min-width: 0;
overflow: clip;
white-space: nowrap;
color: var(--text-strong);
font-weight: var(--font-weight-medium);
}

View File

@@ -3,27 +3,23 @@ import type { SessionStatus } from "@opencode-ai/sdk/v2"
import { useData } from "../context"
import { useFileComponent } from "../context/file"
import { same } from "@opencode-ai/util/array"
import { Binary } from "@opencode-ai/util/binary"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { createEffect, createMemo, createSignal, For, on, onCleanup, ParentProps, Show } from "solid-js"
import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js"
import { Dynamic } from "solid-js/web"
import { GrowBox } from "./grow-box"
import { AssistantParts, UserMessageDisplay, Part, PART_MAPPING } from "./message-part"
import { AssistantParts, Message, Part, PART_MAPPING } from "./message-part"
import { Card } from "./card"
import { Accordion } from "./accordion"
import { StickyAccordionHeader } from "./sticky-accordion-header"
import { Collapsible } from "./collapsible"
import { DiffChanges } from "./diff-changes"
import { Icon } from "./icon"
import { IconButton } from "./icon-button"
import { TextShimmer } from "./text-shimmer"
import { TextReveal } from "./text-reveal"
import { list } from "./text-utils"
import { SessionRetry } from "./session-retry"
import { Tooltip } from "./tooltip"
import { TextReveal } from "./text-reveal"
import { createAutoScroll } from "../hooks"
import { useI18n } from "../context/i18n"
function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
@@ -77,12 +73,18 @@ function unwrap(message: string) {
return message
}
function same<T>(a: readonly T[], b: readonly T[]) {
if (a === b) return true
if (a.length !== b.length) return false
return a.every((x, i) => x === b[i])
}
function list<T>(value: T[] | undefined | null, fallback: T[]) {
if (Array.isArray(value)) return value
return fallback
}
const hidden = new Set(["todowrite", "todoread"])
const emptyMessages: MessageType[] = []
const emptyAssistant: AssistantMessage[] = []
const emptyDiffs: FileDiff[] = []
const idle: SessionStatus = { type: "idle" as const }
const handoffHoldMs = 120
function partState(part: PartType, showReasoningSummaries: boolean) {
if (part.type === "tool") {
@@ -139,7 +141,6 @@ export function SessionTurn(
props: ParentProps<{
sessionID: string
messageID: string
animate?: boolean
showReasoningSummaries?: boolean
shellToolDefaultOpen?: boolean
editToolDefaultOpen?: boolean
@@ -158,7 +159,11 @@ export function SessionTurn(
const i18n = useI18n()
const fileComponent = useFileComponent()
const emptyMessages: MessageType[] = []
const emptyParts: PartType[] = []
const emptyAssistant: AssistantMessage[] = []
const emptyDiffs: FileDiff[] = []
const idle = { type: "idle" as const }
const allMessages = createMemo(() => list(data.store.message?.[props.sessionID], emptyMessages))
@@ -186,8 +191,42 @@ export function SessionTurn(
return msg
})
const active = createMemo(() => props.active ?? false)
const queued = createMemo(() => props.queued ?? false)
const pending = createMemo(() => {
if (typeof props.active === "boolean" && typeof props.queued === "boolean") return
const messages = allMessages() ?? emptyMessages
return messages.findLast(
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
)
})
const pendingUser = createMemo(() => {
const item = pending()
if (!item?.parentID) return
const messages = allMessages() ?? emptyMessages
const result = Binary.search(messages, item.parentID, (m) => m.id)
const msg = result.found ? messages[result.index] : messages.find((m) => m.id === item.parentID)
if (!msg || msg.role !== "user") return
return msg
})
const active = createMemo(() => {
if (typeof props.active === "boolean") return props.active
const msg = message()
const parent = pendingUser()
if (!msg || !parent) return false
return parent.id === msg.id
})
const queued = createMemo(() => {
if (typeof props.queued === "boolean") return props.queued
const id = message()?.id
if (!id) return false
if (!pendingUser()) return false
const item = pending()
if (!item) return false
return id > item.id
})
const parts = createMemo(() => {
const msg = message()
if (!msg) return emptyParts
@@ -250,7 +289,7 @@ export function SessionTurn(
const error = createMemo(
() => assistantMessages().find((m) => m.error && m.error.name !== "MessageAbortedError")?.error,
)
const assistantCopyPart = createMemo(() => {
const showAssistantCopyPartID = createMemo(() => {
const messages = assistantMessages()
for (let i = messages.length - 1; i >= 0; i--) {
@@ -260,18 +299,13 @@ export function SessionTurn(
const parts = list(data.store.part?.[message.id], emptyParts)
for (let j = parts.length - 1; j >= 0; j--) {
const part = parts[j]
if (!part || part.type !== "text") continue
const text = part.text?.trim()
if (!text) continue
return {
id: part.id,
text,
message,
}
if (!part || part.type !== "text" || !part.text?.trim()) continue
return part.id
}
}
return undefined
})
const assistantCopyPartID = createMemo(() => assistantCopyPart()?.id ?? null)
const errorText = createMemo(() => {
const msg = error()?.data?.message
if (typeof msg === "string") return unwrap(msg)
@@ -279,14 +313,18 @@ export function SessionTurn(
return unwrap(String(msg))
})
const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle)
const working = createMemo(() => {
if (status().type === "idle") return false
if (!message()) return false
return active()
const status = createMemo(() => {
if (props.status !== undefined) return props.status
if (typeof props.active === "boolean" && !props.active) return idle
return data.store.session_status[props.sessionID] ?? idle
})
const working = createMemo(() => status().type !== "idle" && active())
const showReasoningSummaries = createMemo(() => props.showReasoningSummaries ?? true)
const showDiffSummary = createMemo(() => edited() > 0 && !working())
const assistantCopyPartID = createMemo(() => {
if (working()) return null
return showAssistantCopyPartID() ?? null
})
const turnDurationMs = createMemo(() => {
const start = message()?.time.created
if (typeof start !== "number") return undefined
@@ -326,109 +364,13 @@ export function SessionTurn(
.filter((text): text is string => !!text)
.at(-1),
)
const thinking = createMemo(() => {
const showThinking = createMemo(() => {
if (!working() || !!error()) return false
if (queued()) return false
if (status().type === "retry") return false
if (showReasoningSummaries()) return assistantVisible() === 0
return true
})
const hasAssistant = createMemo(() => assistantMessages().length > 0)
const animateEnabled = createMemo(() => props.animate !== false)
const [live, setLive] = createSignal(false)
const thinkingOpen = createMemo(() => thinking() && (live() || !animateEnabled()))
const metaOpen = createMemo(() => !working() && !!assistantCopyPart())
const duration = createMemo(() => {
const ms = turnDurationMs()
if (typeof ms !== "number" || 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(() => {
const item = assistantCopyPart()
if (!item) return ""
const agent = item.message.agent ? item.message.agent[0]?.toUpperCase() + item.message.agent.slice(1) : ""
const model = item.message.modelID
? (data.store.provider?.all?.find((provider) => provider.id === item.message.providerID)?.models?.[
item.message.modelID
]?.name ?? item.message.modelID)
: ""
return [agent, model, duration()].filter((value) => !!value).join("\u00A0\u00B7\u00A0")
})
const [copied, setCopied] = createSignal(false)
const [handoffHold, setHandoffHold] = createSignal(false)
const thinkingVisible = createMemo(() => thinkingOpen() || handoffHold())
const handoffOpen = createMemo(() => thinkingVisible() || metaOpen())
const lane = createMemo(() => hasAssistant() || handoffOpen())
let liveFrame: number | undefined
let copiedTimer: ReturnType<typeof setTimeout> | undefined
let handoffTimer: ReturnType<typeof setTimeout> | undefined
const copyAssistant = async () => {
const text = assistantCopyPart()?.text
if (!text) return
await navigator.clipboard.writeText(text)
setCopied(true)
if (copiedTimer !== undefined) clearTimeout(copiedTimer)
copiedTimer = setTimeout(() => {
copiedTimer = undefined
setCopied(false)
}, 2000)
}
createEffect(
on(
() => [animateEnabled(), working()] as const,
([enabled, isWorking]) => {
if (liveFrame !== undefined) {
cancelAnimationFrame(liveFrame)
liveFrame = undefined
}
if (!enabled || !isWorking || live()) return
liveFrame = requestAnimationFrame(() => {
liveFrame = undefined
setLive(true)
})
},
),
)
createEffect(
on(
() => [thinkingOpen(), metaOpen()] as const,
([thinkingNow, metaNow]) => {
if (handoffTimer !== undefined) {
clearTimeout(handoffTimer)
handoffTimer = undefined
}
if (thinkingNow) {
setHandoffHold(true)
return
}
if (metaNow) {
setHandoffHold(false)
return
}
if (!handoffHold()) return
handoffTimer = setTimeout(() => {
handoffTimer = undefined
setHandoffHold(false)
}, handoffHoldMs)
},
{ defer: true },
),
)
const autoScroll = createAutoScroll({
working,
@@ -436,119 +378,6 @@ export function SessionTurn(
overflowAnchor: "dynamic",
})
onCleanup(() => {
if (liveFrame !== undefined) cancelAnimationFrame(liveFrame)
if (copiedTimer !== undefined) clearTimeout(copiedTimer)
if (handoffTimer !== undefined) clearTimeout(handoffTimer)
})
const turnDiffSummary = () => (
<div data-slot="session-turn-diffs">
<Collapsible open={open()} onOpenChange={setOpen} variant="ghost">
<Collapsible.Trigger>
<div data-component="session-turn-diffs-trigger">
<div data-slot="session-turn-diffs-title">
<span data-slot="session-turn-diffs-label">{i18n.t("ui.sessionReview.change.modified")}</span>
<span data-slot="session-turn-diffs-count">
{edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")}
</span>
<div data-slot="session-turn-diffs-meta">
<DiffChanges changes={diffs()} variant="bars" />
<Collapsible.Arrow />
</div>
</div>
</div>
</Collapsible.Trigger>
<Collapsible.Content>
<Show when={open()}>
<div data-component="session-turn-diffs-content">
<Accordion
multiple
style={{ "--sticky-accordion-offset": "37px" }}
value={expanded()}
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
>
<For each={diffs()}>
{(diff) => {
const active = createMemo(() => expanded().includes(diff.file))
const [visible, setVisible] = createSignal(false)
createEffect(
on(
active,
(value) => {
if (!value) {
setVisible(false)
return
}
requestAnimationFrame(() => {
if (!active()) return
setVisible(true)
})
},
{ defer: true },
),
)
return (
<Accordion.Item value={diff.file}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="session-turn-diff-trigger">
<span data-slot="session-turn-diff-path">
<Show when={diff.file.includes("/")}>
<span data-slot="session-turn-diff-directory">{`\u202A${getDirectory(diff.file)}\u202C`}</span>
</Show>
<span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span>
</span>
<div data-slot="session-turn-diff-meta">
<span data-slot="session-turn-diff-changes">
<DiffChanges changes={diff} />
</span>
<span data-slot="session-turn-diff-chevron">
<Icon name="chevron-down" size="small" />
</span>
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content>
<Show when={visible()}>
<div data-slot="session-turn-diff-view" data-scrollable>
<Dynamic
component={fileComponent}
mode="diff"
before={{ name: diff.file, contents: diff.before }}
after={{ name: diff.file, contents: diff.after }}
/>
</div>
</Show>
</Accordion.Content>
</Accordion.Item>
)
}}
</For>
</Accordion>
</div>
</Show>
</Collapsible.Content>
</Collapsible>
</div>
)
const divider = (label: string) => (
<div data-component="compaction-part">
<div data-slot="compaction-part-divider">
<span data-slot="compaction-part-line" />
<span data-slot="compaction-part-label" class="text-12-regular text-text-weak">
{label}
</span>
<span data-slot="compaction-part-line" />
</div>
</div>
)
return (
<div data-component="session-turn" class={props.classes?.root}>
<div
@@ -559,120 +388,149 @@ export function SessionTurn(
>
<div onClick={autoScroll.handleInteraction}>
<Show when={message()}>
{(msg) => (
<div
ref={autoScroll.contentRef}
data-message={msg().id}
data-slot="session-turn-message-container"
class={props.classes?.container}
>
<div data-slot="session-turn-message-content" aria-live="off">
<UserMessageDisplay
message={msg()}
parts={parts()}
interrupted={interrupted()}
animate={props.animate}
queued={queued()}
<div
ref={autoScroll.contentRef}
data-message={message()!.id}
data-slot="session-turn-message-container"
class={props.classes?.container}
>
<div data-slot="session-turn-message-content" aria-live="off">
<Message message={message()!} parts={parts()} interrupted={interrupted()} queued={queued()} />
</div>
<Show when={compaction()}>
<div data-slot="session-turn-compaction">
<Part part={compaction()!} message={message()!} hideDetails />
</div>
</Show>
<Show when={assistantMessages().length > 0}>
<div data-slot="session-turn-assistant-content" aria-hidden={working()}>
<AssistantParts
messages={assistantMessages()}
showAssistantCopyPartID={assistantCopyPartID()}
turnDurationMs={turnDurationMs()}
working={working()}
showReasoningSummaries={showReasoningSummaries()}
shellToolDefaultOpen={props.shellToolDefaultOpen}
editToolDefaultOpen={props.editToolDefaultOpen}
/>
</div>
<Show when={compaction()}>
{(part) => (
<GrowBox animate={props.animate !== false} fade gap={8} class="w-full min-w-0">
<div data-slot="session-turn-compaction">
<Part part={part()} message={msg()} hideDetails />
</div>
</GrowBox>
)}
</Show>
<div data-slot="session-turn-assistant-lane" aria-hidden={!lane()}>
<Show when={hasAssistant()}>
<div
data-slot="session-turn-assistant-content"
aria-hidden={working()}
style={{ contain: "layout paint" }}
>
<AssistantParts
messages={assistantMessages()}
showAssistantCopyPartID={assistantCopyPartID()}
showTurnDiffSummary={showDiffSummary()}
turnDiffSummary={turnDiffSummary}
working={working()}
animate={live()}
showReasoningSummaries={showReasoningSummaries()}
shellToolDefaultOpen={props.shellToolDefaultOpen}
editToolDefaultOpen={props.editToolDefaultOpen}
/>
</div>
</Show>
<Show when={showThinking()}>
<div data-slot="session-turn-thinking">
<TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
<Show when={!showReasoningSummaries()}>
<TextReveal
text={reasoningHeading()}
class="session-turn-thinking-heading"
travel={25}
duration={700}
/>
</Show>
<GrowBox
animate={live()}
animateToggle={live()}
open={handoffOpen()}
fade
slot="session-turn-handoff-wrap"
>
<div data-slot="session-turn-handoff">
<div data-slot="session-turn-thinking" data-visible={thinkingVisible() ? "true" : "false"}>
<TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
<TextReveal
text={!showReasoningSummaries() ? (reasoningHeading() ?? "") : ""}
class="session-turn-thinking-heading"
travel={25}
duration={900}
/>
</div>
</Show>
<SessionRetry status={status()} show={active()} />
<Show when={edited() > 0 && !working()}>
<div data-slot="session-turn-diffs">
<Collapsible open={open()} onOpenChange={setOpen} variant="ghost">
<Collapsible.Trigger>
<div data-component="session-turn-diffs-trigger">
<div data-slot="session-turn-diffs-title">
<span data-slot="session-turn-diffs-label">{i18n.t("ui.sessionReview.change.modified")}</span>
<span data-slot="session-turn-diffs-count">
{edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")}
</span>
<div data-slot="session-turn-diffs-meta">
<DiffChanges changes={diffs()} variant="bars" />
<Collapsible.Arrow />
</div>
</div>
</div>
<Show when={metaOpen()}>
<div
data-slot="session-turn-meta"
data-visible={thinkingVisible() ? "false" : "true"}
data-interrupted={interrupted() ? "" : undefined}
>
<Tooltip
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")}
placement="top"
gutter={4}
</Collapsible.Trigger>
<Collapsible.Content>
<Show when={open()}>
<div data-component="session-turn-diffs-content">
<Accordion
multiple
style={{ "--sticky-accordion-offset": "40px" }}
value={expanded()}
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
>
<IconButton
icon={copied() ? "check" : "copy"}
size="normal"
variant="ghost"
onMouseDown={(event) => event.preventDefault()}
onClick={() => void copyAssistant()}
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")}
/>
</Tooltip>
<Show when={meta()}>
<span
data-slot="session-turn-meta-label"
class="text-12-regular text-text-weak cursor-default"
>
{meta()}
</span>
</Show>
<For each={diffs()}>
{(diff) => {
const active = createMemo(() => expanded().includes(diff.file))
const [visible, setVisible] = createSignal(false)
createEffect(
on(
active,
(value) => {
if (!value) {
setVisible(false)
return
}
requestAnimationFrame(() => {
if (!active()) return
setVisible(true)
})
},
{ defer: true },
),
)
return (
<Accordion.Item value={diff.file}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="session-turn-diff-trigger">
<span data-slot="session-turn-diff-path">
<Show when={diff.file.includes("/")}>
<span data-slot="session-turn-diff-directory">
{`\u202A${getDirectory(diff.file)}\u202C`}
</span>
</Show>
<span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span>
</span>
<div data-slot="session-turn-diff-meta">
<span data-slot="session-turn-diff-changes">
<DiffChanges changes={diff} />
</span>
<span data-slot="session-turn-diff-chevron">
<Icon name="chevron-down" size="small" />
</span>
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content>
<Show when={visible()}>
<div data-slot="session-turn-diff-view" data-scrollable>
<Dynamic
component={fileComponent}
mode="diff"
before={{ name: diff.file, contents: diff.before }}
after={{ name: diff.file, contents: diff.after }}
/>
</div>
</Show>
</Accordion.Content>
</Accordion.Item>
)
}}
</For>
</Accordion>
</div>
</Show>
</div>
</GrowBox>
</Collapsible.Content>
</Collapsible>
</div>
<GrowBox animate={props.animate !== false} fade gap={0} open={interrupted()} class="w-full min-w-0">
{divider(i18n.t("ui.message.interrupted"))}
</GrowBox>
<SessionRetry status={status()} show={active()} />
<GrowBox
animate={props.animate !== false}
fade
gap={0}
open={showDiffSummary() && !assistantCopyPartID()}
>
{turnDiffSummary()}
</GrowBox>
<Show when={error()}>
<Card variant="error" class="error-card">
{errorText()}
</Card>
</Show>
</div>
)}
</Show>
<Show when={error()}>
<Card variant="error" class="error-card">
{errorText()}
</Card>
</Show>
</div>
</Show>
{props.children}
</div>

View File

@@ -1,310 +0,0 @@
import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
import stripAnsi from "strip-ansi"
import type { ToolPart } from "@opencode-ai/sdk/v2"
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
import { useI18n } from "../context/i18n"
import { RollingResults } from "./rolling-results"
import { Icon } from "./icon"
import { IconButton } from "./icon-button"
import { TextShimmer } from "./text-shimmer"
import { Tooltip } from "./tooltip"
import { GROW_SPRING } from "./motion"
import { useSpring } from "./motion-spring"
import {
busy,
createThrottledValue,
hold,
updateScrollMask,
useCollapsible,
useRowWipe,
useToolFade,
} from "./tool-utils"
function ShellRollingSubtitle(props: { text: string; animate?: boolean }) {
let ref: HTMLSpanElement | undefined
useToolFade(() => ref, { wipe: true, animate: props.animate })
return (
<span data-slot="shell-rolling-subtitle">
<span ref={ref}>{props.text}</span>
</span>
)
}
function firstLine(text: string) {
return text
.split(/\r\n|\n|\r/g)
.map((item) => item.trim())
.find((item) => item.length > 0)
}
function shellRows(output: string) {
const rows: { id: string; text: string }[] = []
const lines = output
.split(/\r\n|\n|\r/g)
.map((item) => item.trimEnd())
.filter((item) => item.length > 0)
const start = Math.max(0, lines.length - 80)
for (let i = start; i < lines.length; i++) {
rows.push({ id: `line:${i}`, text: lines[i]! })
}
return rows
}
function ShellRollingCommand(props: { text: string; animate?: boolean }) {
let ref: HTMLSpanElement | undefined
useToolFade(() => ref, { wipe: true, animate: props.animate })
return (
<div data-component="shell-rolling-command">
<span ref={ref} data-slot="shell-rolling-text">
<span data-slot="shell-rolling-prompt">$</span> {props.text}
</span>
</div>
)
}
function ShellExpanded(props: { cmd: string; out: string; open: boolean }) {
const i18n = useI18n()
const rows = 10
const rowHeight = 22
const max = rows * rowHeight
let contentRef: HTMLDivElement | undefined
let bodyRef: HTMLDivElement | undefined
let scrollRef: HTMLDivElement | undefined
let topRef: HTMLDivElement | undefined
const [copied, setCopied] = createSignal(false)
const [cap, setCap] = createSignal(max)
const updateMask = () => {
if (scrollRef) updateScrollMask(scrollRef)
}
const resize = () => {
const top = Math.ceil(topRef?.getBoundingClientRect().height ?? 0)
setCap(Math.max(rowHeight * 2, max - top - (props.out ? 1 : 0)))
}
const measure = () => {
resize()
return Math.ceil(bodyRef?.getBoundingClientRect().height ?? 0)
}
onMount(() => {
resize()
if (!topRef) return
const obs = new ResizeObserver(resize)
obs.observe(topRef)
onCleanup(() => obs.disconnect())
})
createEffect(() => {
props.cmd
props.out
queueMicrotask(() => {
resize()
updateMask()
})
})
useCollapsible({
content: () => contentRef,
body: () => bodyRef,
open: () => props.open,
measure,
onOpen: updateMask,
})
const handleCopy = async (e: MouseEvent) => {
e.stopPropagation()
const cmd = props.cmd ? `$ ${props.cmd}` : ""
const text = `${cmd}${props.out ? `${cmd ? "\n\n" : ""}${props.out}` : ""}`
if (!text) return
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div ref={contentRef} style={{ overflow: "clip", height: "0px", display: "none" }}>
<div ref={bodyRef} data-component="shell-expanded-shell">
<div data-slot="shell-expanded-body">
<div ref={topRef} data-slot="shell-expanded-top">
<div data-slot="shell-expanded-command">
<span data-slot="shell-expanded-prompt">$</span>
<span data-slot="shell-expanded-input">{props.cmd}</span>
</div>
<div data-slot="shell-expanded-actions">
<Tooltip
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
placement="top"
gutter={4}
>
<IconButton
icon={copied() ? "check" : "copy"}
size="small"
variant="ghost"
class="shell-expanded-copy"
onMouseDown={(e: MouseEvent) => e.preventDefault()}
onClick={handleCopy}
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
/>
</Tooltip>
</div>
</div>
<Show when={props.out}>
<>
<div data-slot="shell-expanded-divider" />
<div
ref={scrollRef}
data-component="shell-expanded-output"
data-scrollable
onScroll={updateMask}
style={{ "max-height": `${cap()}px` }}
>
<pre data-slot="shell-expanded-pre">
<code>{props.out}</code>
</pre>
</div>
</>
</Show>
</div>
</div>
</div>
)
}
export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }) {
const i18n = useI18n()
const wiped = new Set<string>()
const [mounted, setMounted] = createSignal(false)
const [userToggled, setUserToggled] = createSignal(false)
const [userOpen, setUserOpen] = createSignal(false)
onMount(() => setMounted(true))
const state = createMemo(() => props.part.state as Record<string, any>)
const pending = createMemo(() => busy(props.part.state.status))
const autoOpen = hold(pending, 2000)
const effectiveOpen = createMemo(() => {
if (pending()) return true
if (userToggled()) return userOpen()
return autoOpen()
})
const expanded = createMemo(() => !pending() && !autoOpen() && userToggled() && userOpen())
const previewOpen = createMemo(() => effectiveOpen() && !expanded())
const command = createMemo(() => {
const value = state().input?.command ?? state().metadata?.command
if (typeof value === "string") return value
return ""
})
const subtitle = createMemo(() => {
const value = state().input?.description ?? state().metadata?.description
if (typeof value === "string" && value.trim().length > 0) return value
return firstLine(command()) ?? ""
})
const output = createMemo(() => {
const value = state().output ?? state().metadata?.output
if (typeof value === "string") return value
return ""
})
const reduce = prefersReducedMotion
const skip = () => reduce() || props.animate === false
const opacity = useSpring(() => (mounted() ? 1 : 0), GROW_SPRING)
const blur = useSpring(() => (mounted() ? 0 : 2), GROW_SPRING)
const previewOpacity = useSpring(() => (previewOpen() ? 1 : 0), GROW_SPRING)
const previewBlur = useSpring(() => (previewOpen() ? 0 : 2), GROW_SPRING)
const headerHeight = useSpring(() => (mounted() ? 37 : 0), GROW_SPRING)
let headerClipRef: HTMLDivElement | undefined
const handleHeaderClick = () => {
if (pending()) return
const el = headerClipRef
const viewport = el?.closest(".scroll-view__viewport") as HTMLElement | null
const beforeY = el?.getBoundingClientRect().top ?? 0
setUserToggled(true)
setUserOpen((prev) => !prev)
if (viewport && el) {
requestAnimationFrame(() => {
const afterY = el.getBoundingClientRect().top
const delta = afterY - beforeY
if (delta !== 0) viewport.scrollTop += delta
})
}
}
const line = createMemo(() => firstLine(command()))
const fixed = createMemo(() => {
const value = line()
if (!value) return
return <ShellRollingCommand text={value} animate={props.animate} />
})
const text = createThrottledValue(() => stripAnsi(output()))
const rows = createMemo(() => shellRows(text()))
return (
<div
data-component="shell-rolling-results"
style={{ opacity: skip() ? (mounted() ? 1 : 0) : opacity(), filter: `blur(${skip() ? 0 : blur()}px)` }}
>
<div
ref={headerClipRef}
data-slot="shell-rolling-header-clip"
data-scroll-preserve
data-clickable={!pending() ? "true" : "false"}
onClick={handleHeaderClick}
style={{ height: `${skip() ? (mounted() ? 37 : 0) : headerHeight()}px`, overflow: "clip" }}
>
<div data-slot="shell-rolling-header">
<span data-slot="shell-rolling-title">
<TextShimmer text={i18n.t("ui.tool.shell")} active={pending()} />
</span>
<Show when={subtitle()}>{(text) => <ShellRollingSubtitle text={text()} animate={props.animate} />}</Show>
<Show when={!pending()}>
<span data-slot="shell-rolling-actions">
<span data-slot="shell-rolling-arrow" data-open={effectiveOpen() ? "true" : "false"}>
<Icon name="chevron-down" size="small" />
</span>
</span>
</Show>
</div>
</div>
<div
data-slot="shell-rolling-preview"
style={{
opacity: skip() ? (previewOpen() ? 1 : 0) : previewOpacity(),
filter: `blur(${skip() ? 0 : previewBlur()}px)`,
}}
>
<RollingResults
class="shell-rolling-output"
noFadeOnCollapse
items={rows()}
fixed={fixed()}
fixedHeight={22}
rows={5}
rowHeight={22}
rowGap={0}
open={previewOpen()}
animate={props.animate !== false}
getKey={(row) => row.id}
render={(row) => {
const [textRef, setTextRef] = createSignal<HTMLSpanElement>()
useRowWipe({
id: () => row.id,
text: () => row.text,
ref: textRef,
seen: wiped,
})
return (
<div data-component="shell-rolling-row">
<span ref={setTextRef} data-slot="shell-rolling-text">
{row.text}
</span>
</div>
)
}}
/>
</div>
<ShellExpanded cmd={command()} out={text()} open={expanded()} />
</div>
)
}

View File

@@ -1,13 +1,23 @@
[data-component="shell-submessage"] {
min-width: 0;
max-width: 100%;
display: inline-block;
display: inline-flex;
align-items: baseline;
vertical-align: baseline;
}
[data-component="shell-submessage"] [data-slot="shell-submessage-width"] {
min-width: 0;
max-width: 100%;
display: inline-flex;
align-items: baseline;
overflow: hidden;
}
[data-component="shell-submessage"] [data-slot="shell-submessage-value"] {
display: inline-block;
vertical-align: baseline;
min-width: 0;
line-height: inherit;
white-space: nowrap;
}

View File

@@ -41,6 +41,7 @@ export function Spinner(props: {
animation: square.corner
? undefined
: `${square.outer ? "pulse-opacity-dim" : "pulse-opacity"} ${square.duration}s ease-in-out infinite`,
"animation-fill-mode": square.corner ? undefined : "both",
"animation-delay": square.corner ? undefined : `${square.delay}s`,
}}
/>

Some files were not shown because too many files have changed in this diff Show More