Compare commits

...

18 Commits

Author SHA1 Message Date
opencode
a52d640c8c release: v1.2.21 2026-03-07 18:00:39 +00:00
Karan Handa
218869cf45 fix(storybook): restore build by mocking useLocation (#16472) 2026-03-07 09:55:43 -06:00
Eric Guo
e99d7a4292 fix(app): text-shimmer undefined length (#16475) 2026-03-07 09:53:32 -06:00
SANGWOO PARK
f0beb38f91 fix(app): guard session-header current() against undefined when options is empty (#16478) 2026-03-07 09:51:21 -06:00
Filip
66fcab7b08 fix(app): preserve file tree tab on reopen + fix e2e test regressions (#16482) 2026-03-07 09:47:45 -06:00
David Hill
641e1781a2 tui: remove close button from project hover popover (#16403)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2026-03-07 07:00:58 -06:00
Adam
490b95efe7 fix(app): new session uses agent model/variant 2026-03-07 07:00:38 -06:00
Adam
ba1edea0ab fix(app): model sticks to session 2026-03-07 06:57:00 -06:00
Adam
73c9b685a7 fix(app): all panels transition 2026-03-07 06:48:37 -06:00
Adam
99d8aab0ac fix(app): can't scroll files 2026-03-07 06:47:11 -06:00
Adam
7dd6369952 fix(app): task agent title 2026-03-07 06:03:30 -06:00
Adam
06f60af1e9 chore: update web stats 2026-03-07 05:47:47 -06:00
Adam
66d0beba6f fix(app): fix max-width on timeline 2026-03-07 05:45:30 -06:00
David Hill
6b99dd50b6 tui: align session empty states (#16412) 2026-03-07 05:39:43 -06:00
opencode-agent[bot]
c53c9d4e4e chore: generate 2026-03-07 11:26:12 +00:00
Kit Langton
bbd0f3a252 STUPID SEXY TIMELINE (#16420) 2026-03-07 05:25:22 -06:00
Luke Parker
b7e208b4f1 test(app): share workspace slug wait helper across e2e specs (#16446) 2026-03-07 07:48:30 +00:00
Quan Ran
be9b4d1bcd fix(opencode): preserve original line endings in 'edit' tool (#9443)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-03-07 07:42:54 +00:00
126 changed files with 6383 additions and 2612 deletions

View File

@@ -26,7 +26,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.2.20",
"version": "1.2.21",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -76,7 +76,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.2.20",
"version": "1.2.21",
"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.20",
"version": "1.2.21",
"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.20",
"version": "1.2.21",
"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.20",
"version": "1.2.21",
"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.20",
"version": "1.2.21",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -218,7 +218,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.2.20",
"version": "1.2.21",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -248,7 +248,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.2.20",
"version": "1.2.21",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -277,7 +277,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.2.20",
"version": "1.2.21",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -293,7 +293,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.2.20",
"version": "1.2.21",
"bin": {
"opencode": "./bin/opencode",
},
@@ -409,7 +409,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.2.20",
"version": "1.2.21",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -429,7 +429,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.2.20",
"version": "1.2.21",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -440,7 +440,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.2.20",
"version": "1.2.21",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -475,7 +475,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.2.20",
"version": "1.2.21",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -521,7 +521,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.2.20",
"version": "1.2.21",
"dependencies": {
"zod": "catalog:",
},
@@ -532,7 +532,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.2.20",
"version": "1.2.21",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",

View File

@@ -72,6 +72,9 @@ test("test description", async ({ page, sdk, gotoSession }) => {
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
- `withSession(sdk, title, callback)` - Create temp session
- `withProject(...)` - Create temp project/workspace
- `sessionIDFromUrl(url)` - Read session ID from URL
- `slugFromUrl(url)` - Read workspace slug from URL
- `waitSlug(page, skip?)` - Wait for resolved workspace slug
- `trackSession(sessionID, directory?)` - Register session for fixture cleanup
- `trackDirectory(directory)` - Register directory for fixture cleanup
- `clickListItem(container, filter)` - Click list item by key/text
@@ -169,9 +172,10 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings
1. Choose appropriate folder or create new one
2. Import from `../fixtures`
3. Use helper functions from `../actions` and `../selectors`
4. Clean up any created resources
5. Use specific selectors (avoid CSS classes)
6. Test one feature per test file
4. When validating routing, use shared helpers from `../actions`. Workspace URL slugs can be canonicalized on Windows, so assert against canonical or resolved workspace slugs.
5. Clean up any created resources
6. Use specific selectors (avoid CSS classes)
7. Test one feature per test file
## Local Development

View File

@@ -7,6 +7,7 @@ import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
import {
dropdownMenuTriggerSelector,
dropdownMenuContentSelector,
sessionTimelineHeaderSelector,
projectMenuTriggerSelector,
projectCloseMenuSelector,
projectWorkspacesToggleSelector,
@@ -199,6 +200,33 @@ export async function cleanupTestProject(directory: string) {
await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined)
}
export function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
}
export async function waitSlug(page: Page, skip: string[] = []) {
let prev = ""
let next = ""
await expect
.poll(
() => {
const slug = slugFromUrl(page.url())
if (!slug) return ""
if (skip.includes(slug)) return ""
if (slug !== prev) {
prev = slug
next = ""
return ""
}
next = slug
return slug
},
{ timeout: 45_000 },
)
.not.toBe("")
return next
}
export function sessionIDFromUrl(url: string) {
const match = /\/session\/([^/?#]+)/.exec(url)
return match?.[1]
@@ -216,7 +244,9 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) {
const scroller = page.locator(".scroll-view__viewport").first()
await expect(scroller).toBeVisible()
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
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 })
const menu = page
.locator(dropdownMenuContentSelector)
@@ -232,7 +262,7 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) {
if (opened) return menu
const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
const menuTrigger = header.getByRole("button", { name: /more options/i }).first()
await expect(menuTrigger).toBeVisible()
await menuTrigger.click()

View File

@@ -1,17 +1,17 @@
import { test, expect } from "../fixtures"
import { serverName } from "../utils"
import { serverNamePattern } from "../utils"
test("home renders and shows core entrypoints", async ({ page }) => {
await page.goto("/")
await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible()
await expect(page.getByRole("button", { name: serverName })).toBeVisible()
await expect(page.getByRole("button", { name: serverNamePattern })).toBeVisible()
})
test("server picker dialog opens from home", async ({ page }) => {
await page.goto("/")
const trigger = page.getByRole("button", { name: serverName })
const trigger = page.getByRole("button", { name: serverNamePattern })
await expect(trigger).toBeVisible()
await trigger.click()

View File

@@ -1,6 +1,6 @@
import { test, expect } from "../fixtures"
import { serverName, serverUrl } from "../utils"
import { clickListItem, closeDialog, clickMenuItem } from "../actions"
import { serverNamePattern, serverUrls } from "../utils"
import { closeDialog, clickMenuItem } from "../actions"
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
@@ -31,10 +31,9 @@ test("can set a default server on web", async ({ page, gotoSession }) => {
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
const row = dialog.locator('[data-slot="list-item"]').filter({ hasText: serverName }).first()
await expect(row).toBeVisible()
await expect(dialog.getByText(serverNamePattern).first()).toBeVisible()
const menuTrigger = row.locator('[data-slot="dropdown-menu-trigger"]').first()
const menuTrigger = dialog.locator('[data-slot="dropdown-menu-trigger"]').first()
await expect(menuTrigger).toBeVisible()
await menuTrigger.click({ force: true })
@@ -42,14 +41,18 @@ test("can set a default server on web", async ({ page, gotoSession }) => {
await expect(menu).toBeVisible()
await clickMenuItem(menu, /set as default/i)
await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)).toBe(serverUrl)
await expect(row.getByText("Default", { exact: true })).toBeVisible()
await expect
.poll(async () =>
serverUrls.includes((await page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)) ?? ""),
)
.toBe(true)
await expect(dialog.getByText("Default", { exact: true })).toBeVisible()
await closeDialog(page, dialog)
await ensurePopoverOpen()
const serverRow = popover.locator("button").filter({ hasText: serverName }).first()
const serverRow = popover.locator("button").filter({ hasText: serverNamePattern }).first()
await expect(serverRow).toBeVisible()
await expect(serverRow.getByText("Default", { exact: true })).toBeVisible()
})

View File

@@ -10,6 +10,8 @@ const expanded = async (el: { getAttribute: (name: string) => Promise<string | n
test("review panel can be toggled via keybind", async ({ page, gotoSession }) => {
await gotoSession()
const reviewPanel = page.locator("#review-panel")
const treeToggle = page.getByRole("button", { name: "Toggle file tree" }).first()
await expect(treeToggle).toBeVisible()
if (await expanded(treeToggle)) await treeToggle.click()
@@ -19,13 +21,13 @@ test("review panel can be toggled via keybind", async ({ page, gotoSession }) =>
await expect(reviewToggle).toBeVisible()
if (await expanded(reviewToggle)) await reviewToggle.click()
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
await expect(page.locator("#review-panel")).toHaveCount(0)
await expect(reviewPanel).toHaveAttribute("aria-hidden", "true")
await page.keyboard.press(`${modKey}+Shift+R`)
await expect(reviewToggle).toHaveAttribute("aria-expanded", "true")
await expect(page.locator("#review-panel")).toBeVisible()
await expect(reviewPanel).toHaveAttribute("aria-hidden", "false")
await page.keyboard.press(`${modKey}+Shift+R`)
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
await expect(page.locator("#review-panel")).toHaveCount(0)
await expect(reviewPanel).toHaveAttribute("aria-hidden", "true")
})

View File

@@ -43,6 +43,13 @@ test("file tree can expand folders and open a file", async ({ page, gotoSession
await tab.click()
await expect(tab).toHaveAttribute("aria-selected", "true")
await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "false")
await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "true")
await expect(allTab).toHaveAttribute("aria-selected", "true")
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
await expect(viewer).toContainText("export default function FileTree")

View File

@@ -1,36 +1,8 @@
import { test, expect } from "../fixtures"
import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, openProjectMenu } from "../actions"
import { projectCloseHoverSelector, projectSwitchSelector } from "../selectors"
import { projectSwitchSelector } from "../selectors"
import { dirSlug } from "../utils"
test("can close a project via hover card close button", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const otherSlug = dirSlug(other)
try {
await withProject(
async () => {
await openSidebar(page)
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible()
await otherButton.hover()
const close = page.locator(projectCloseHoverSelector(otherSlug)).first()
await expect(close).toBeVisible()
await close.click()
await expect(otherButton).toHaveCount(0)
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}
})
test("closing active project navigates to another open project", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })

View File

@@ -1,13 +1,9 @@
import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl } from "../actions"
import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitSlug } from "../actions"
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { dirSlug } from "../utils"
function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
}
import { dirSlug, resolveDirectory } from "../utils"
async function workspaces(page: Page, directory: string, enabled: boolean) {
await page.evaluate(
@@ -76,7 +72,6 @@ test("switching back to a project opens the latest workspace session", async ({
const other = await createTestProject()
const otherSlug = dirSlug(other)
let workspaceDir: string | undefined
try {
await withProject(
async ({ directory, slug, trackSession, trackDirectory }) => {
@@ -89,33 +84,27 @@ test("switching back to a project opens the latest workspace session", async ({
await page.getByRole("button", { name: "New workspace" }).first().click()
await expect
.poll(
() => {
const next = slugFromUrl(page.url())
if (!next) return ""
if (next === slug) return ""
return next
},
{ timeout: 45_000 },
)
.not.toBe("")
const workspaceSlug = slugFromUrl(page.url())
workspaceDir = base64Decode(workspaceSlug)
if (!workspaceDir) throw new Error(`Failed to decode workspace slug: ${workspaceSlug}`)
trackDirectory(workspaceDir)
const raw = await waitSlug(page, [slug])
const dir = base64Decode(raw)
if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`)
const space = await resolveDirectory(dir)
const next = dirSlug(space)
trackDirectory(space)
await openSidebar(page)
const workspace = page.locator(workspaceItemSelector(workspaceSlug)).first()
await expect(workspace).toBeVisible()
await workspace.hover()
const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first()
await expect(item).toBeVisible()
await item.hover()
const newSession = page.locator(workspaceNewSessionSelector(workspaceSlug)).first()
await expect(newSession).toBeVisible()
await newSession.click({ force: true })
const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first()
await expect(btn).toBeVisible()
await btn.click({ force: true })
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session(?:[/?#]|$)`))
// A new workspace can be discovered via a transient slug before the route and sidebar
// settle to the canonical workspace path on Windows, so interact with either and assert
// against the resolved workspace slug.
await waitSlug(page)
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
// Create a session by sending a prompt
const prompt = page.locator(promptSelector)
@@ -128,9 +117,9 @@ test("switching back to a project opens the latest workspace session", async ({
const created = sessionIDFromUrl(page.url())
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
trackSession(created, workspaceDir)
trackSession(created, space)
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
await openSidebar(page)

View File

@@ -1,34 +1,10 @@
import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled } from "../actions"
import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, slugFromUrl, waitSlug } from "../actions"
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { createSdk } from "../utils"
function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
}
async function waitSlug(page: Page, skip: string[] = []) {
let prev = ""
await expect
.poll(
() => {
const slug = slugFromUrl(page.url())
if (!slug) return ""
if (skip.includes(slug)) return ""
if (slug !== prev) {
prev = slug
return ""
}
return slug
},
{ timeout: 45_000 },
)
.not.toBe("")
return slugFromUrl(page.url())
}
async function waitWorkspaceReady(page: Page, slug: string) {
await openSidebar(page)
await expect

View File

@@ -14,34 +14,12 @@ import {
openSidebar,
openWorkspaceMenu,
setWorkspacesEnabled,
slugFromUrl,
waitSlug,
} from "../actions"
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
import { createSdk, dirSlug } from "../utils"
function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
}
async function waitSlug(page: Page, skip: string[] = []) {
let prev = ""
await expect
.poll(
() => {
const slug = slugFromUrl(page.url())
if (!slug) return ""
if (skip.includes(slug)) return ""
if (slug !== prev) {
prev = slug
return ""
}
return slug
},
{ timeout: 45_000 },
)
.not.toBe("")
return slugFromUrl(page.url())
}
async function setupWorkspaceTest(page: Page, project: { slug: string }) {
const rootSlug = project.slug
await openSidebar(page)
@@ -353,17 +331,7 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
for (const _ of [0, 1]) {
const prev = slugFromUrl(page.url())
await page.getByRole("button", { name: "New workspace" }).first().click()
await expect
.poll(
() => {
const slug = slugFromUrl(page.url())
return slug.length > 0 && slug !== rootSlug && slug !== prev
},
{ timeout: 45_000 },
)
.toBe(true)
const slug = slugFromUrl(page.url())
const slug = await waitSlug(page, [rootSlug, prev])
const dir = base64Decode(slug)
workspaces.push({ slug, directory: dir })

View File

@@ -30,8 +30,6 @@ export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
export const projectSwitchSelector = (slug: string) =>
`${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
export const projectCloseHoverSelector = (slug: string) => `[data-action="project-close-hover"][data-project="${slug}"]`
export const projectMenuTriggerSelector = (slug: string) =>
`${sidebarNavSelector} [data-action="project-menu"][data-project="${slug}"]`
@@ -53,6 +51,8 @@ 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

@@ -45,7 +45,7 @@ async function seedConversation(input: {
.toBe(true)
if (!userMessageID) throw new Error("Expected a user message id")
await expect(input.page.locator(`[data-message-id="${userMessageID}"]`).first()).toBeVisible({ timeout: 30_000 })
await expect(input.page.locator(`[data-message-id="${userMessageID}"]`)).toHaveCount(1, { timeout: 30_000 })
return { prompt, userMessageID }
}
@@ -123,7 +123,7 @@ test("slash redo clears revert and restores latest state", async ({ page, withPr
.toBeUndefined()
await expect(seeded.prompt).not.toContainText(token)
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`).first()).toBeVisible()
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1)
})
})
})
@@ -158,8 +158,8 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro
const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`)
const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`)
await expect(firstMessage.first()).toBeVisible()
await expect(secondMessage.first()).toBeVisible()
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(1)
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
@@ -176,7 +176,7 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro
})
.toBe(second.userMessageID)
await expect(firstMessage.first()).toBeVisible()
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(0)
await second.prompt.click()
@@ -210,7 +210,7 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro
})
.toBe(second.userMessageID)
await expect(firstMessage.first()).toBeVisible()
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(0)
await second.prompt.click()
@@ -226,8 +226,8 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro
})
.toBeUndefined()
await expect(firstMessage.first()).toBeVisible()
await expect(secondMessage.first()).toBeVisible()
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(1)
})
})
})

View File

@@ -7,7 +7,7 @@ import {
openSharePopover,
withSession,
} from "../actions"
import { sessionItemSelector, inlineInputSelector } from "../selectors"
import { sessionItemSelector, inlineInputSelector, sessionTimelineHeaderSelector } from "../selectors"
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
@@ -39,12 +39,14 @@ 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.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
await expect(page.locator(sessionTimelineHeaderSelector).getByRole("heading", { level: 1 }).first()).toHaveText(
originalTitle,
)
const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /rename/i)
const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
const input = page.locator(sessionTimelineHeaderSelector).locator(inlineInputSelector).first()
await expect(input).toBeVisible()
await expect(input).toBeFocused()
await input.fill(renamedTitle)
@@ -61,7 +63,9 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
)
.toBe(renamedTitle)
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
await expect(page.locator(sessionTimelineHeaderSelector).getByRole("heading", { level: 1 }).first()).toHaveText(
renamedTitle,
)
})
})

View File

@@ -7,6 +7,22 @@ export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
export const serverUrl = `http://${serverHost}:${serverPort}`
export const serverName = `${serverHost}:${serverPort}`
const localHosts = ["127.0.0.1", "localhost"]
const serverLabels = (() => {
const url = new URL(serverUrl)
if (!localHosts.includes(url.hostname)) return [serverName]
return localHosts.map((host) => `${host}:${url.port}`)
})()
export const serverNames = [...new Set(serverLabels)]
export const serverUrls = serverNames.map((name) => `http://${name}`)
const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
export const serverNamePattern = new RegExp(`(?:${serverNames.map(escape).join("|")})`)
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
export const terminalToggleKey = "Control+Backquote"

View File

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

View File

@@ -6,10 +6,19 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
const createdClients: string[] = []
const createdSessions: string[] = []
const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
const optimistic: Array<{
message: {
agent: string
model: { providerID: string; modelID: string }
variant?: string
}
}> = []
const sentShell: string[] = []
const syncedDirectories: string[] = []
let params: { id?: string } = {}
let selected = "/repo/worktree-a"
let variant: string | undefined
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
@@ -26,6 +35,7 @@ const clientFor = (directory: string) => {
return { data: undefined }
},
prompt: async () => ({ data: undefined }),
promptAsync: async () => ({ data: undefined }),
command: async () => ({ data: undefined }),
abort: async () => ({ data: undefined }),
},
@@ -40,7 +50,7 @@ beforeAll(async () => {
mock.module("@solidjs/router", () => ({
useNavigate: () => () => undefined,
useParams: () => ({}),
useParams: () => params,
}))
mock.module("@opencode-ai/sdk/v2/client", () => ({
@@ -62,7 +72,7 @@ beforeAll(async () => {
useLocal: () => ({
model: {
current: () => ({ id: "model", provider: { id: "provider" } }),
variant: { current: () => undefined },
variant: { current: () => variant },
},
agent: {
current: () => ({ name: "agent" }),
@@ -118,7 +128,11 @@ beforeAll(async () => {
data: { command: [] },
session: {
optimistic: {
add: () => undefined,
add: (value: {
message: { agent: string; model: { providerID: string; modelID: string }; variant?: string }
}) => {
optimistic.push(value)
},
remove: () => undefined,
},
},
@@ -155,9 +169,12 @@ beforeEach(() => {
createdClients.length = 0
createdSessions.length = 0
enabledAutoAccept.length = 0
optimistic.length = 0
params = {}
sentShell.length = 0
syncedDirectories.length = 0
selected = "/repo/worktree-a"
variant = undefined
})
describe("prompt submit worktree selection", () => {
@@ -219,4 +236,39 @@ describe("prompt submit worktree selection", () => {
expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }])
})
test("includes the selected variant on optimistic prompts", async () => {
params = { id: "session-1" }
variant = "high"
const submit = createPromptSubmit({
info: () => ({ id: "session-1" }),
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
onSubmit: () => undefined,
})
const event = { preventDefault: () => undefined } as unknown as Event
await submit.handleSubmit(event)
expect(optimistic).toHaveLength(1)
expect(optimistic[0]).toMatchObject({
message: {
agent: "agent",
model: { providerID: "provider", modelID: "model" },
variant: "high",
},
})
})
})

View File

@@ -316,6 +316,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
time: { created: Date.now() },
agent,
model,
variant,
}
const addOptimisticMessage = () =>

View File

@@ -303,7 +303,12 @@ export function SessionHeader() {
})
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
const current = createMemo(
() =>
options().find((o) => o.id === prefs.app) ??
options()[0] ??
({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
)
const opening = createMemo(() => openRequest.app !== undefined)
const selectApp = (app: OpenApp) => {

View File

@@ -8,8 +8,7 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path"
const MAIN_WORKTREE = "main"
const CREATE_WORKTREE = "create"
const ROOT_CLASS =
"size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-16"
const ROOT_CLASS = "size-full flex flex-col"
interface NewSessionViewProps {
worktree: string
@@ -50,33 +49,40 @@ export function NewSessionView(props: NewSessionViewProps) {
return (
<div class={ROOT_CLASS}>
<div class="text-20-medium text-text-weaker">{language.t("command.session.new")}</div>
<div class="flex justify-center items-start gap-3 min-h-5">
<Icon name="folder" size="small" class="mt-0.5 shrink-0" />
<div class="text-12-medium text-text-weak select-text leading-5">
{getDirectory(projectRoot())}
<span class="text-text-strong">{getFilename(projectRoot())}</span>
<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="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">
{getDirectory(projectRoot())}
<span class="text-text-strong">{getFilename(projectRoot())}</span>
</div>
</div>
<div class="flex items-start justify-center gap-1.5 min-h-5">
<Icon name="branch" size="small" class="mt-0.5 shrink-0" />
<div class="text-12-medium text-text-weak select-text leading-5 min-w-0 max-w-160 break-words text-center">
{label(current())}
</div>
</div>
<Show when={sync.project}>
{(project) => (
<div class="flex items-start justify-center gap-3 min-h-5">
<div class="text-12-medium text-text-weak leading-5 min-w-0 max-w-160 break-words text-center">
{language.t("session.new.lastModified")}&nbsp;
<span class="text-text-strong">
{DateTime.fromMillis(project().time.updated ?? project().time.created)
.setLocale(language.intl())
.toRelative()}
</span>
</div>
</div>
)}
</Show>
</div>
</div>
</div>
<div class="flex justify-center items-start gap-3 min-h-5">
<Icon name="branch" size="small" class="mt-0.5 shrink-0" />
<div class="text-12-medium text-text-weak select-text leading-5">{label(current())}</div>
</div>
<Show when={sync.project}>
{(project) => (
<div class="flex justify-center items-start gap-3 min-h-5">
<Icon name="pencil-line" size="small" class="mt-0.5 shrink-0" />
<div class="text-12-medium text-text-weak leading-5">
{language.t("session.new.lastModified")}&nbsp;
<span class="text-text-strong">
{DateTime.fromMillis(project().time.updated ?? project().time.created)
.setLocale(language.intl())
.toRelative()}
</span>
</div>
</div>
)}
</Show>
</div>
)
}

View File

@@ -199,6 +199,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
parts: Part[]
agent: string
model: { providerID: string; modelID: string }
variant?: string
}) {
const message: Message = {
id: input.messageID,
@@ -207,6 +208,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
time: { created: Date.now() },
agent: input.agent,
model: input.model,
variant: input.variant,
}
const [, setStore] = target()
setOptimisticAdd(setStore as (...args: unknown[]) => void, {

View File

@@ -456,6 +456,7 @@ export const dict = {
"session.todo.title": "المهام",
"session.todo.collapse": "طي",
"session.todo.expand": "توسيع",
"session.new.title": "ابنِ أي شيء",
"session.new.worktree.main": "الفرع الرئيسي",
"session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})",
"session.new.worktree.create": "إنشاء شجرة عمل جديدة",

View File

@@ -459,6 +459,7 @@ export const dict = {
"session.todo.title": "Tarefas",
"session.todo.collapse": "Recolher",
"session.todo.expand": "Expandir",
"session.new.title": "Crie qualquer coisa",
"session.new.worktree.main": "Branch principal",
"session.new.worktree.mainWithBranch": "Branch principal ({{branch}})",
"session.new.worktree.create": "Criar novo worktree",

View File

@@ -515,6 +515,7 @@ export const dict = {
"session.todo.collapse": "Sažmi",
"session.todo.expand": "Proširi",
"session.new.title": "Napravi bilo šta",
"session.new.worktree.main": "Glavna grana",
"session.new.worktree.mainWithBranch": "Glavna grana ({{branch}})",
"session.new.worktree.create": "Kreiraj novi worktree",

View File

@@ -510,6 +510,7 @@ export const dict = {
"session.todo.collapse": "Skjul",
"session.todo.expand": "Udvid",
"session.new.title": "Byg hvad som helst",
"session.new.worktree.main": "Hovedgren",
"session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})",
"session.new.worktree.create": "Opret nyt worktree",

View File

@@ -467,6 +467,7 @@ export const dict = {
"session.todo.title": "Aufgaben",
"session.todo.collapse": "Einklappen",
"session.todo.expand": "Ausklappen",
"session.new.title": "Baue, was du willst",
"session.new.worktree.main": "Haupt-Branch",
"session.new.worktree.mainWithBranch": "Haupt-Branch ({{branch}})",
"session.new.worktree.create": "Neuen Worktree erstellen",

View File

@@ -531,6 +531,7 @@ export const dict = {
"session.todo.collapse": "Collapse",
"session.todo.expand": "Expand",
"session.new.title": "Build anything",
"session.new.worktree.main": "Main branch",
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
"session.new.worktree.create": "Create new worktree",

View File

@@ -516,6 +516,7 @@ export const dict = {
"session.todo.collapse": "Contraer",
"session.todo.expand": "Expandir",
"session.new.title": "Construye lo que quieras",
"session.new.worktree.main": "Rama principal",
"session.new.worktree.mainWithBranch": "Rama principal ({{branch}})",
"session.new.worktree.create": "Crear nuevo árbol de trabajo",

View File

@@ -463,6 +463,7 @@ export const dict = {
"session.todo.title": "Tâches",
"session.todo.collapse": "Réduire",
"session.todo.expand": "Développer",
"session.new.title": "Créez ce que vous voulez",
"session.new.worktree.main": "Branche principale",
"session.new.worktree.mainWithBranch": "Branche principale ({{branch}})",
"session.new.worktree.create": "Créer un nouvel arbre de travail",

View File

@@ -457,6 +457,7 @@ export const dict = {
"session.todo.title": "ToDo",
"session.todo.collapse": "折りたたむ",
"session.todo.expand": "展開",
"session.new.title": "何でも作る",
"session.new.worktree.main": "メインブランチ",
"session.new.worktree.mainWithBranch": "メインブランチ ({{branch}})",
"session.new.worktree.create": "新しいワークツリーを作成",

View File

@@ -459,6 +459,7 @@ export const dict = {
"session.todo.title": "할 일",
"session.todo.collapse": "접기",
"session.todo.expand": "펼치기",
"session.new.title": "무엇이든 만들기",
"session.new.worktree.main": "메인 브랜치",
"session.new.worktree.mainWithBranch": "메인 브랜치 ({{branch}})",
"session.new.worktree.create": "새 작업 트리 생성",

View File

@@ -516,6 +516,7 @@ export const dict = {
"session.todo.collapse": "Skjul",
"session.todo.expand": "Utvid",
"session.new.title": "Bygg hva som helst",
"session.new.worktree.main": "Hovedgren",
"session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})",
"session.new.worktree.create": "Opprett nytt worktree",

View File

@@ -458,6 +458,7 @@ export const dict = {
"session.todo.title": "Zadania",
"session.todo.collapse": "Zwiń",
"session.todo.expand": "Rozwiń",
"session.new.title": "Zbuduj cokolwiek",
"session.new.worktree.main": "Główna gałąź",
"session.new.worktree.mainWithBranch": "Główna gałąź ({{branch}})",
"session.new.worktree.create": "Utwórz nowe drzewo robocze",

View File

@@ -514,6 +514,7 @@ export const dict = {
"session.todo.collapse": "Свернуть",
"session.todo.expand": "Развернуть",
"session.new.title": "Создавайте что угодно",
"session.new.worktree.main": "Основная ветка",
"session.new.worktree.mainWithBranch": "Основная ветка ({{branch}})",
"session.new.worktree.create": "Создать новый worktree",

View File

@@ -511,6 +511,7 @@ export const dict = {
"session.todo.collapse": "ย่อ",
"session.todo.expand": "ขยาย",
"session.new.title": "สร้างอะไรก็ได้",
"session.new.worktree.main": "สาขาหลัก",
"session.new.worktree.mainWithBranch": "สาขาหลัก ({{branch}})",
"session.new.worktree.create": "สร้าง worktree ใหม่",

View File

@@ -523,6 +523,7 @@ export const dict = {
"session.todo.collapse": "Daralt",
"session.todo.expand": "Genişlet",
"session.new.title": "İstediğini yap",
"session.new.worktree.main": "Ana dal",
"session.new.worktree.mainWithBranch": "Ana dal ({{branch}})",
"session.new.worktree.create": "Yeni çalışma ağacı oluştur",

View File

@@ -510,6 +510,7 @@ export const dict = {
"session.todo.title": "待办事项",
"session.todo.collapse": "折叠",
"session.todo.expand": "展开",
"session.new.title": "构建任何东西",
"session.new.worktree.main": "主分支",
"session.new.worktree.mainWithBranch": "主分支({{branch}}",
"session.new.worktree.create": "创建新的 worktree",

View File

@@ -507,6 +507,7 @@ export const dict = {
"session.todo.collapse": "折疊",
"session.todo.expand": "展開",
"session.new.title": "建構任何東西",
"session.new.worktree.main": "主分支",
"session.new.worktree.mainWithBranch": "主分支 ({{branch}})",
"session.new.worktree.create": "建立新的 worktree",

View File

@@ -2252,7 +2252,7 @@ export default function Layout(props: ParentProps) {
>
<main
classList={{
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base xl:border-l xl:rounded-tl-[12px]": true,
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
}}
>
<Show when={!autoselecting()} fallback={<div class="size-full" />}>

View File

@@ -5,8 +5,6 @@ import { Button } from "@opencode-ai/ui/button"
import { ContextMenu } from "@opencode-ai/ui/context-menu"
import { HoverCard } from "@opencode-ai/ui/hover-card"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { createSortable } from "@thisbeyond/solid-dnd"
import { useLayout, type LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
@@ -194,21 +192,6 @@ const ProjectPreviewPanel = (props: {
<div class="-m-3 p-2 flex flex-col w-72">
<div class="px-4 pt-2 pb-1 flex items-center gap-2">
<div class="text-14-medium text-text-strong truncate grow">{displayName(props.project)}</div>
<Tooltip value={props.language.t("common.close")} placement="top" gutter={6}>
<IconButton
icon="circle-x"
variant="ghost"
class="shrink-0"
data-action="project-close-hover"
data-project={base64Encode(props.project.worktree)}
aria-label={props.language.t("common.close")}
onClick={(event) => {
event.stopPropagation()
props.setOpen(false)
props.ctx.closeProject(props.project.worktree)
}}
/>
</Tooltip>
</div>
<div class="px-4 pb-2 text-12-medium text-text-weak">{props.language.t("sidebar.project.recentSessions")}</div>
<div class="px-2 pb-2 flex flex-col gap-2">

View File

@@ -33,9 +33,10 @@ import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
import { createOpenReviewFile } from "@/pages/session/helpers"
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"
@@ -121,13 +122,9 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
return
}
const beforeTop = el.scrollTop
const beforeHeight = el.scrollHeight
fn()
requestAnimationFrame(() => {
const delta = el.scrollHeight - beforeHeight
if (!delta) return
el.scrollTop = beforeTop + delta
})
void el.scrollHeight
el.scrollTop = beforeTop
}
const backfillTurns = () => {
@@ -210,7 +207,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
if (!input.userScrolled()) return
const el = input.scroller()
if (!el) return
if (el.scrollTop >= turnScrollThreshold) return
if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return
const start = turnStart()
if (start > 0) {
@@ -336,6 +333,7 @@ export default function Page() {
)
const isDesktop = createMediaQuery("(min-width: 768px)")
const size = createSizing()
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
@@ -421,15 +419,22 @@ export default function Page() {
() => {
const msg = lastUserMessage()
if (!msg) return
if (msg.agent) {
local.agent.set(msg.agent)
if (local.agent.current()?.model) return
}
if (msg.model) local.model.set(msg.model)
syncSessionModel(local, msg)
},
),
)
createEffect(
on(
() => params.id,
(id, prev) => {
if (id || !prev) return
resetSessionModel(local)
},
{ defer: true },
),
)
const [store, setStore] = createStore({
messageId: undefined as string | undefined,
mobileTab: "session" as "session" | "changes",
@@ -794,7 +799,7 @@ export default function Page() {
}
const emptyTurn = () => (
<div class="h-full pb-30 flex flex-col items-center justify-center text-center gap-6">
<div class="h-full pb-64 flex flex-col items-center justify-center text-center gap-6">
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div>
</div>
)
@@ -909,7 +914,7 @@ export default function Page() {
diffStyle: layout.review.diffStyle(),
onDiffStyleChange: layout.review.setDiffStyle,
loadingClass: "px-6 py-4 text-text-weak",
emptyClass: "h-full pb-30 flex flex-col items-center justify-center text-center gap-6",
emptyClass: "h-full pb-64 flex flex-col items-center justify-center text-center gap-6",
})}
</div>
</div>
@@ -1033,23 +1038,6 @@ export default function Page() {
tabs().setActive(next)
})
createEffect(
on(
() => layout.fileTree.opened(),
(opened, prev) => {
if (prev === undefined) return
if (!isDesktop()) return
if (opened) {
const active = tabs().active()
const tab = active === "review" || (!active && hasReview()) ? "changes" : "all"
layout.fileTree.setTab(tab)
}
},
{ defer: true },
),
)
createEffect(() => {
const id = params.id
if (!id) return
@@ -1110,7 +1098,7 @@ export default function Page() {
const updateScrollState = (el: HTMLDivElement) => {
const max = el.scrollHeight - el.clientHeight
const overflow = max > 1
const bottom = !overflow || el.scrollTop >= max - 2
const bottom = !overflow || Math.abs(el.scrollTop) <= 2 || !autoScroll.userScrolled()
if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return
setUi("scroll", { overflow, bottom })
@@ -1133,7 +1121,7 @@ export default function Page() {
const resumeScroll = () => {
setStore("messageId", undefined)
autoScroll.forceScrollToBottom()
autoScroll.smoothScrollToBottom()
clearMessageHash()
const el = scroller
@@ -1201,13 +1189,11 @@ export default function Page() {
const el = scroller
const delta = next - dockHeight
const stick = el
? !autoScroll.userScrolled() || el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta)
: false
const stick = el ? Math.abs(el.scrollTop) < 10 + Math.max(0, delta) : false
dockHeight = next
if (stick) autoScroll.forceScrollToBottom()
if (stick) autoScroll.smoothScrollToBottom()
if (el) scheduleScrollState(el)
scrollSpy.markDirty()
@@ -1258,9 +1244,9 @@ export default function Page() {
{/* Session panel */}
<div
classList={{
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true,
"flex-1": true,
"md:flex-none": desktopSidePanelOpen(),
"@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(),
}}
style={{
width: sessionPanelWidth(),
@@ -1280,7 +1266,7 @@ export default function Page() {
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full pb-30 flex flex-col items-center justify-center text-center gap-6",
emptyClass: "h-full pb-64 flex flex-col items-center justify-center text-center gap-6",
})}
scroll={ui.scroll}
onResumeScroll={resumeScroll}
@@ -1293,6 +1279,7 @@ export default function Page() {
onScrollSpyScroll={scrollSpy.onScroll}
onTurnBackfillScroll={historyWindow.onScrollerScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
onPreserveScrollAnchor={autoScroll.preserve}
centered={centered()}
setContentRef={(el) => {
content = el
@@ -1356,17 +1343,27 @@ export default function Page() {
/>
<Show when={desktopReviewOpen()}>
<ResizeHandle
direction="horizontal"
size={layout.session.width()}
min={450}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.45}
onResize={layout.session.resize}
/>
<div onPointerDown={() => size.start()}>
<ResizeHandle
direction="horizontal"
size={layout.session.width()}
min={450}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.45}
onResize={(width) => {
size.touch()
layout.session.resize(width)
}}
/>
</div>
</Show>
</div>
<SessionSidePanel reviewPanel={reviewPanel} activeDiff={tree.activeDiff} focusReviewDiff={focusReviewDiff} />
<SessionSidePanel
reviewPanel={reviewPanel}
activeDiff={tree.activeDiff}
focusReviewDiff={focusReviewDiff}
size={size}
/>
</div>
<TerminalPanel />

View File

@@ -140,7 +140,7 @@ export function SessionComposerRegion(props: {
<div
classList={{
"w-full px-3 pointer-events-auto": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
"md:max-w-[500px] md:mx-auto 2xl:max-w-[700px]": 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 h-full">
<Tabs.Content value={props.tab} class="mt-3 relative flex h-full min-h-0 flex-col overflow-hidden contain-strict">
<ScrollView
class="h-full"
class="h-full min-h-0 flex-1"
viewportRef={(el: HTMLDivElement) => {
scroll = el
restoreScroll()

View File

@@ -1,4 +1,5 @@
import { batch } from "solid-js"
import { batch, createEffect, on, onCleanup, onMount, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
export const focusTerminalById = (id: string) => {
const wrapper = document.getElementById(`terminal-wrapper-${id}`)
@@ -69,3 +70,104 @@ export const getTabReorderIndex = (tabs: readonly string[], from: string, to: st
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return undefined
return toIndex
}
export const createSizing = () => {
const [state, setState] = createStore({ active: false })
let t: number | undefined
const stop = () => {
if (t !== undefined) {
clearTimeout(t)
t = undefined
}
setState("active", false)
}
const start = () => {
if (t !== undefined) {
clearTimeout(t)
t = undefined
}
setState("active", true)
}
onMount(() => {
window.addEventListener("pointerup", stop)
window.addEventListener("pointercancel", stop)
window.addEventListener("blur", stop)
onCleanup(() => {
window.removeEventListener("pointerup", stop)
window.removeEventListener("pointercancel", stop)
window.removeEventListener("blur", stop)
})
})
onCleanup(() => {
if (t !== undefined) clearTimeout(t)
})
return {
active: () => state.active,
start,
touch() {
start()
t = window.setTimeout(stop, 120)
},
}
}
export type Sizing = ReturnType<typeof createSizing>
export const createPresence = (open: Accessor<boolean>, wait = 200) => {
const [state, setState] = createStore({
show: open(),
open: open(),
})
let frame: number | undefined
let t: number | undefined
const clear = () => {
if (frame !== undefined) {
cancelAnimationFrame(frame)
frame = undefined
}
if (t !== undefined) {
clearTimeout(t)
t = undefined
}
}
createEffect(
on(open, (next) => {
clear()
if (next) {
if (state.show) {
setState("open", true)
return
}
setState({ show: true, open: false })
frame = requestAnimationFrame(() => {
frame = undefined
setState("open", true)
})
return
}
if (!state.show) return
setState("open", false)
t = window.setTimeout(() => {
t = undefined
setState("show", false)
}, wait)
}),
)
onCleanup(clear)
return {
show: () => state.show,
open: () => state.open,
}
}

View File

@@ -1,27 +1,31 @@
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 {
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 { 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 { 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
@@ -33,7 +37,9 @@ type MessageComment = {
}
const emptyMessages: MessageType[] = []
const idle = { type: "idle" as const }
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 messageComments = (parts: Part[]): MessageComment[] =>
parts.flatMap((part) => {
@@ -110,6 +116,8 @@ function createTimelineStaging(input: TimelineStageInput) {
completedSession: "",
count: 0,
})
const [readySession, setReadySession] = createSignal("")
let active = ""
const stagedCount = createMemo(() => {
const total = input.messages().length
@@ -134,23 +142,46 @@ 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()
const shouldStage =
isWindowed &&
total > input.config.init &&
state.completedSession !== sessionKey &&
state.activeSession !== sessionKey
if (shouldStage) setReadySession("")
if (!shouldStage) {
setState({ activeSession: "", count: total })
setState({
activeSession: "",
completedSession: isWindowed ? sessionKey : state.completedSession,
count: total,
})
if (total <= 0) {
setReadySession("")
return
}
if (readySession() !== sessionKey) scheduleReady(sessionKey)
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 = () => {
@@ -160,10 +191,11 @@ function createTimelineStaging(input: TimelineStageInput) {
}
const currentTotal = input.messages().length
count = Math.min(currentTotal, count + input.config.batch)
setState("count", count)
startTransition(() => setState("count", count))
if (count >= currentTotal) {
setState({ completedSession: sessionKey, activeSession: "" })
frame = undefined
scheduleReady(sessionKey)
return
}
frame = requestAnimationFrame(step)
@@ -177,9 +209,12 @@ 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 }
onCleanup(() => {
cancel()
})
return { messages: stagedUserMessages, isStaging, ready }
}
export function MessageTimeline(props: {
@@ -196,6 +231,7 @@ export function MessageTimeline(props: {
onScrollSpyScroll: () => void
onTurnBackfillScroll: () => void
onAutoScrollInteraction: (event: MouseEvent) => void
onPreserveScrollAnchor: (target: HTMLElement) => void
centered: boolean
setContentRef: (el: HTMLDivElement) => void
turnStart: number
@@ -210,14 +246,19 @@ export function MessageTimeline(props: {
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 rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
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 sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const sessionID = createMemo(() => params.id)
const sessionMessages = createMemo(() => {
@@ -230,28 +271,20 @@ export function MessageTimeline(props: {
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
),
)
const sessionStatus = createMemo(() => {
const id = sessionID()
if (!id) return idle
return sync.data.session_status[id] ?? idle
})
const sessionStatus = createMemo(() => sync.data.session_status[sessionID() ?? ""]?.type ?? "idle")
const activeMessageID = createMemo(() => {
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
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 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
}
if (sessionStatus() === "idle") return undefined
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "user") return messages[i].id
}
return undefined
})
const info = createMemo(() => {
@@ -259,9 +292,19 @@ export function MessageTimeline(props: {
if (!id) return
return sync.session.get(id)
})
const titleValue = createMemo(() => info()?.title)
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 parentID = createMemo(() => info()?.parentID)
const showHeader = createMemo(() => !!(titleValue() || parentID()))
const showHeader = createMemo(() => !!(headerTitle() || parentID()))
const stageCfg = { init: 1, batch: 3 }
const staging = createTimelineStaging({
sessionKey,
@@ -269,212 +312,7 @@ export function MessageTimeline(props: {
messages: () => props.renderedUserMessages,
config: stageCfg,
})
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>
)
}
const rendered = createMemo(() => staging.messages().map((message) => message.id))
return (
<Show
@@ -498,7 +336,18 @@ 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
@@ -532,9 +381,18 @@ 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()
@@ -543,134 +401,24 @@ export function MessageTimeline(props: {
props.onMarkScrollGesture(e.currentTarget)
if (props.isDesktop) props.onScrollSpyScroll()
}}
onClick={props.onAutoScrollInteraction}
onClick={(e) => {
props.onAutoScrollInteraction(e)
}}
class="relative min-w-0 w-full h-full"
style={{
"--session-title-height": showHeader() ? "40px" : "0px",
"--session-title-height": showHeader() ? "72px" : "0px",
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
}}
>
<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>
<Show when={titleValue() || title.editing}>
<Show
when={title.editing}
fallback={
<h1
class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2"
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 pl-2 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={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>
<div
ref={props.setContentRef}
role="log"
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
class="flex flex-col gap-0 items-start justify-start pb-16 transition-[margin]"
style={{ "padding-top": "var(--session-title-height)" }}
classList={{
"w-full": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
"md:max-w-[500px] md:mx-auto 2xl:max-w-[700px]": props.centered,
"mt-0.5": props.centered,
"mt-0": !props.centered,
}}
@@ -692,6 +440,15 @@ 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
@@ -700,7 +457,10 @@ export function MessageTimeline(props: {
return false
})
const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
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)
},
})
const commentCount = createMemo(() => comments().length)
return (
@@ -713,7 +473,7 @@ export function MessageTimeline(props: {
}}
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
"md:max-w-[500px] 2xl:max-w-[700px]": props.centered,
}}
>
<Show when={commentCount() > 0}>
@@ -757,7 +517,7 @@ export function MessageTimeline(props: {
messageID={messageID}
active={active()}
queued={queued()}
status={active() ? sessionStatus() : undefined}
animate={isNew || active()}
showReasoningSummaries={settings.general.showReasoningSummaries()}
shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
editToolDefaultOpen={settings.general.editToolPartsExpanded()}

View File

@@ -0,0 +1,158 @@
import { describe, expect, test } from "bun:test"
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { resetSessionModel, syncSessionModel } from "./session-model-helpers"
const message = (input?: Partial<Pick<UserMessage, "agent" | "model" | "variant">>) =>
({
id: "msg",
sessionID: "session",
role: "user",
time: { created: 1 },
agent: input?.agent ?? "build",
model: input?.model ?? { providerID: "anthropic", modelID: "claude-sonnet-4" },
variant: input?.variant,
}) as UserMessage
describe("syncSessionModel", () => {
test("restores the last message model and variant", () => {
const calls: unknown[] = []
syncSessionModel(
{
agent: {
current() {
return undefined
},
set(value) {
calls.push(["agent", value])
},
},
model: {
set(value) {
calls.push(["model", value])
},
current() {
return { id: "claude-sonnet-4", provider: { id: "anthropic" } }
},
variant: {
set(value) {
calls.push(["variant", value])
},
},
},
},
message({ variant: "high" }),
)
expect(calls).toEqual([
["agent", "build"],
["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }],
["variant", "high"],
])
})
test("skips variant when the model falls back", () => {
const calls: unknown[] = []
syncSessionModel(
{
agent: {
current() {
return undefined
},
set(value) {
calls.push(["agent", value])
},
},
model: {
set(value) {
calls.push(["model", value])
},
current() {
return { id: "gpt-5", provider: { id: "openai" } }
},
variant: {
set(value) {
calls.push(["variant", value])
},
},
},
},
message({ variant: "high" }),
)
expect(calls).toEqual([
["agent", "build"],
["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }],
])
})
})
describe("resetSessionModel", () => {
test("restores the current agent defaults", () => {
const calls: unknown[] = []
resetSessionModel({
agent: {
current() {
return {
model: { providerID: "anthropic", modelID: "claude-sonnet-4" },
variant: "high",
}
},
set() {},
},
model: {
set(value) {
calls.push(["model", value])
},
current() {
return undefined
},
variant: {
set(value) {
calls.push(["variant", value])
},
},
},
})
expect(calls).toEqual([
["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }],
["variant", "high"],
])
})
test("clears the variant when the agent has none", () => {
const calls: unknown[] = []
resetSessionModel({
agent: {
current() {
return {
model: { providerID: "anthropic", modelID: "claude-sonnet-4" },
}
},
set() {},
},
model: {
set(value) {
calls.push(["model", value])
},
current() {
return undefined
},
variant: {
set(value) {
calls.push(["variant", value])
},
},
},
})
expect(calls).toEqual([
["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }],
["variant", undefined],
])
})
})

View File

@@ -0,0 +1,48 @@
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { batch } from "solid-js"
type Local = {
agent: {
current():
| {
model?: UserMessage["model"]
variant?: string
}
| undefined
set(name: string | undefined): void
}
model: {
set(model: UserMessage["model"] | undefined): void
current():
| {
id: string
provider: { id: string }
}
| undefined
variant: {
set(value: string | undefined): void
}
}
}
export const resetSessionModel = (local: Local) => {
const agent = local.agent.current()
if (!agent) return
batch(() => {
local.model.set(agent.model)
local.model.variant.set(agent.variant)
})
}
export const syncSessionModel = (local: Local, msg: UserMessage) => {
batch(() => {
local.agent.set(msg.agent)
local.model.set(msg.model)
})
const model = local.model.current()
if (!model) return
if (model.provider.id !== msg.model.providerID) return
if (model.id !== msg.model.modelID) return
local.model.variant.set(msg.variant)
}

View File

@@ -23,7 +23,7 @@ import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
import { createOpenSessionFileTab, getTabReorderIndex } from "@/pages/session/helpers"
import { createOpenSessionFileTab, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
import { StickyAddButton } from "@/pages/session/review-tab"
import { setSessionHandoff } from "@/pages/session/handoff"
@@ -31,6 +31,7 @@ export function SessionSidePanel(props: {
reviewPanel: () => JSX.Element
activeDiff?: string
focusReviewDiff: (path: string) => void
size: Sizing
}) {
const params = useParams()
const layout = useLayout()
@@ -46,8 +47,20 @@ export function SessionSidePanel(props: {
const view = createMemo(() => layout.view(sessionKey))
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const open = createMemo(() => isDesktop() && (view().reviewPanel.opened() || layout.fileTree.opened()))
const fileOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
const open = createMemo(() => reviewOpen() || fileOpen())
const reviewTab = createMemo(() => isDesktop())
const panelWidth = createMemo(() => {
if (!open()) return "0px"
if (reviewOpen()) return `calc(100% - ${layout.session.width()}px)`
return `${layout.fileTree.width()}px`
})
const reviewWidth = createMemo(() => {
if (!reviewOpen()) return "0px"
if (!fileOpen()) return "100%"
return `calc(100% - ${layout.fileTree.width()}px)`
})
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
@@ -96,7 +109,7 @@ export function SessionSidePanel(props: {
const empty = (msg: string) => (
<div class="h-full flex flex-col">
<div class="h-12 shrink-0" aria-hidden />
<div class="flex-1 pb-30 flex items-center justify-center text-center">
<div class="flex-1 pb-64 flex items-center justify-center text-center">
<div class="text-12-regular text-text-weak">{msg}</div>
</div>
</div>
@@ -210,146 +223,175 @@ export function SessionSidePanel(props: {
})
return (
<Show when={open()}>
<Show when={isDesktop()}>
<aside
id="review-panel"
aria-label={language.t("session.panel.reviewAndFiles")}
class="relative min-w-0 h-full border-l border-border-weaker-base flex"
aria-hidden={!open()}
inert={!open()}
class="relative min-w-0 h-full flex shrink-0 overflow-hidden bg-background-base"
classList={{
"flex-1": reviewOpen(),
"shrink-0": !reviewOpen(),
"opacity-100": open(),
"opacity-0 pointer-events-none": !open(),
"transition-[width,opacity] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!props.size.active(),
}}
style={{ width: reviewOpen() ? undefined : `${layout.fileTree.width()}px` }}
style={{ width: panelWidth() }}
>
<Show when={reviewOpen()}>
<div class="flex-1 min-w-0 h-full">
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs value={activeTab()} onChange={openTab}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List
ref={(el: HTMLDivElement) => {
const stop = createFileTabListSync({ el, contextOpen })
onCleanup(stop)
}}
>
<Show when={reviewTab()}>
<Tabs.Trigger value="review">
<div class="flex items-center gap-1.5">
<div>{language.t("session.tab.review")}</div>
<Show when={hasReview()}>
<div>{reviewCount()}</div>
</Show>
</div>
</Tabs.Trigger>
</Show>
<Show when={contextOpen()}>
<Tabs.Trigger
value="context"
closeButton={
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
gutter={10}
>
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={() => tabs().close("context")}
aria-label={language.t("common.closeTab")}
/>
</TooltipKeybind>
}
hideCloseButton
onMiddleClick={() => tabs().close("context")}
>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
<div>{language.t("session.tab.context")}</div>
</div>
</Tabs.Trigger>
</Show>
<SortableProvider ids={openedTabs()}>
<For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
</SortableProvider>
<StickyAddButton>
<TooltipKeybind
title={language.t("command.file.open")}
keybind={command.keybind("file.open")}
class="flex items-center"
>
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
class="!rounded-md"
onClick={() => dialog.show(() => <DialogSelectFile mode="files" onOpenFile={showAllFiles} />)}
aria-label={language.t("command.file.open")}
/>
</TooltipKeybind>
</StickyAddButton>
</Tabs.List>
</div>
<div class="size-full flex border-l border-border-weaker-base">
<div
aria-hidden={!reviewOpen()}
inert={!reviewOpen()}
class="relative min-w-0 h-full shrink-0 overflow-hidden bg-background-base"
classList={{
"opacity-100": reviewOpen(),
"opacity-0 pointer-events-none": !reviewOpen(),
"transition-[width,opacity] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!props.size.active(),
}}
style={{ width: reviewWidth() }}
>
<div class="size-full min-w-0 h-full bg-background-base">
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs value={activeTab()} onChange={openTab}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List
ref={(el: HTMLDivElement) => {
const stop = createFileTabListSync({ el, contextOpen })
onCleanup(stop)
}}
>
<Show when={reviewTab()}>
<Tabs.Trigger value="review">
<div class="flex items-center gap-1.5">
<div>{language.t("session.tab.review")}</div>
<Show when={hasReview()}>
<div>{reviewCount()}</div>
</Show>
</div>
</Tabs.Trigger>
</Show>
<Show when={contextOpen()}>
<Tabs.Trigger
value="context"
closeButton={
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
gutter={10}
>
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={() => tabs().close("context")}
aria-label={language.t("common.closeTab")}
/>
</TooltipKeybind>
}
hideCloseButton
onMiddleClick={() => tabs().close("context")}
>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
<div>{language.t("session.tab.context")}</div>
</div>
</Tabs.Trigger>
</Show>
<SortableProvider ids={openedTabs()}>
<For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
</SortableProvider>
<StickyAddButton>
<TooltipKeybind
title={language.t("command.file.open")}
keybind={command.keybind("file.open")}
class="flex items-center"
>
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
class="!rounded-md"
onClick={() =>
dialog.show(() => <DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
}
aria-label={language.t("command.file.open")}
/>
</TooltipKeybind>
</StickyAddButton>
</Tabs.List>
</div>
<Show when={reviewTab()}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
</Tabs.Content>
</Show>
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "empty"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">
{language.t("session.files.selectToOpen")}
</div>
</div>
</div>
<Show when={reviewTab()}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
</Tabs.Content>
</Show>
</Tabs.Content>
<Show when={contextOpen()}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "context"}>
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "empty"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionContextTab />
<div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">
{language.t("session.files.selectToOpen")}
</div>
</div>
</div>
</Show>
</Tabs.Content>
</Show>
<Show when={activeFileTab()} keyed>
{(tab) => <FileTabContent tab={tab} />}
</Show>
</Tabs>
<DragOverlay>
<Show when={store.activeDraggable} keyed>
{(tab) => {
const path = createMemo(() => file.pathFromTab(tab))
return (
<div data-component="tabs-drag-preview">
<Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
</div>
)
}}
</Show>
</DragOverlay>
</DragDropProvider>
<Show when={contextOpen()}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "context"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionContextTab />
</div>
</Show>
</Tabs.Content>
</Show>
<Show when={activeFileTab()} keyed>
{(tab) => <FileTabContent tab={tab} />}
</Show>
</Tabs>
<DragOverlay>
<Show when={store.activeDraggable} keyed>
{(tab) => {
const path = createMemo(() => file.pathFromTab(tab))
return (
<div data-component="tabs-drag-preview">
<Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
</div>
)
}}
</Show>
</DragOverlay>
</DragDropProvider>
</div>
</div>
</Show>
<Show when={layout.fileTree.opened()}>
<div id="file-tree-panel" class="relative shrink-0 h-full" style={{ width: `${layout.fileTree.width()}px` }}>
<div
id="file-tree-panel"
aria-hidden={!fileOpen()}
inert={!fileOpen()}
class="relative min-w-0 h-full shrink-0 overflow-hidden"
classList={{
"opacity-100": fileOpen(),
"opacity-0 pointer-events-none": !fileOpen(),
"transition-[width,opacity] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!props.size.active(),
}}
style={{ width: treeWidth() }}
>
<div
class="h-full flex flex-col overflow-hidden group/filetree"
classList={{ "border-l border-border-weaker-base": reviewOpen() }}
@@ -412,18 +454,25 @@ export function SessionSidePanel(props: {
</Tabs.Content>
</Tabs>
</div>
<ResizeHandle
direction="horizontal"
edge="start"
size={layout.fileTree.width()}
min={200}
max={480}
collapseThreshold={160}
onResize={layout.fileTree.resize}
onCollapse={layout.fileTree.close}
/>
<Show when={fileOpen()}>
<div onPointerDown={() => props.size.start()}>
<ResizeHandle
direction="horizontal"
edge="start"
size={layout.fileTree.width()}
min={200}
max={480}
collapseThreshold={160}
onResize={(width) => {
props.size.touch()
layout.fileTree.resize(width)
}}
onCollapse={layout.fileTree.close}
/>
</div>
</Show>
</div>
</Show>
</div>
</aside>
</Show>
)

View File

@@ -0,0 +1,522 @@
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

@@ -17,7 +17,7 @@ import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useTerminal, type LocalPTY } from "@/context/terminal"
import { terminalTabLabel } from "@/pages/session/terminal-label"
import { focusTerminalById } from "@/pages/session/helpers"
import { createPresence, createSizing, focusTerminalById } from "@/pages/session/helpers"
import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
export function TerminalPanel() {
@@ -33,8 +33,11 @@ export function TerminalPanel() {
const opened = createMemo(() => view().terminal.opened())
const open = createMemo(() => isDesktop() && opened())
const panel = createPresence(open)
const size = createSizing()
const height = createMemo(() => layout.terminal.height())
const close = () => view().terminal.close()
let root: HTMLDivElement | undefined
const [store, setStore] = createStore({
autoCreated: false,
@@ -67,7 +70,7 @@ export function TerminalPanel() {
on(
() => terminal.active(),
(activeId) => {
if (!activeId || !open()) return
if (!activeId || !panel.open()) return
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
@@ -76,6 +79,14 @@ export function TerminalPanel() {
),
)
createEffect(() => {
if (panel.open()) return
const active = document.activeElement
if (!(active instanceof HTMLElement)) return
if (!root?.contains(active)) return
active.blur()
})
createEffect(() => {
const dir = params.dir
if (!dir) return
@@ -133,120 +144,142 @@ export function TerminalPanel() {
}
return (
<Show when={open()}>
<Show when={panel.show()}>
<div
ref={root}
id="terminal-panel"
role="region"
aria-label={language.t("terminal.title")}
class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base"
style={{ height: `${height()}px` }}
aria-hidden={!panel.open()}
inert={!panel.open()}
class="relative w-full shrink-0 overflow-hidden"
classList={{
"opacity-100": panel.open(),
"opacity-0 pointer-events-none": !panel.open(),
"transition-[height,opacity] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[height] motion-reduce:transition-none":
!size.active(),
}}
style={{ height: panel.open() ? `${height()}px` : "0px" }}
>
<ResizeHandle
direction="vertical"
size={height()}
min={100}
max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6}
collapseThreshold={50}
onResize={layout.terminal.resize}
onCollapse={close}
/>
<Show
when={terminal.ready()}
fallback={
<div class="flex flex-col h-full pointer-events-none">
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-background-stronger overflow-hidden">
<For each={handoff()}>
{(title) => (
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
{title}
</div>
)}
</For>
<div class="flex-1" />
<div class="text-text-weak pr-2">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
<div class="size-full flex flex-col border-t border-border-weak-base">
<div onPointerDown={() => size.start()}>
<ResizeHandle
direction="vertical"
size={height()}
min={100}
max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6}
collapseThreshold={50}
onResize={(next) => {
size.touch()
layout.terminal.resize(next)
}}
onCollapse={close}
/>
</div>
<Show
when={terminal.ready()}
fallback={
<div class="flex flex-col h-full pointer-events-none">
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-background-stronger overflow-hidden">
<For each={handoff()}>
{(title) => (
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
{title}
</div>
)}
</For>
<div class="flex-1" />
<div class="text-text-weak pr-2">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
</div>
<div class="flex-1 flex items-center justify-center text-text-weak">
{language.t("terminal.loading")}
</div>
</div>
<div class="flex-1 flex items-center justify-center text-text-weak">{language.t("terminal.loading")}</div>
</div>
}
>
<DragDropProvider
onDragStart={handleTerminalDragStart}
onDragEnd={handleTerminalDragEnd}
onDragOver={handleTerminalDragOver}
collisionDetector={closestCenter}
}
>
<DragDropSensors />
<ConstrainDragYAxis />
<div class="flex flex-col h-full">
<Tabs
variant="alt"
value={terminal.active()}
onChange={(id) => terminal.open(id)}
class="!h-auto !flex-none"
>
<Tabs.List class="h-10 border-b border-border-weaker-base">
<SortableProvider ids={ids()}>
<For each={ids()}>
{(id) => (
<Show when={byId().get(id)}>
{(pty) => <SortableTerminalTab terminal={pty()} onClose={close} />}
</Show>
)}
</For>
</SortableProvider>
<div class="h-full flex items-center justify-center">
<TooltipKeybind
title={language.t("command.terminal.new")}
keybind={command.keybind("terminal.new")}
class="flex items-center"
>
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
onClick={terminal.new}
aria-label={language.t("command.terminal.new")}
/>
</TooltipKeybind>
</div>
</Tabs.List>
</Tabs>
<div class="flex-1 min-h-0 relative">
<Show when={terminal.active()} keyed>
{(id) => (
<Show when={byId().get(id)}>
{(pty) => (
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
<Terminal pty={pty()} onCleanup={terminal.update} onConnectError={() => terminal.clone(id)} />
<DragDropProvider
onDragStart={handleTerminalDragStart}
onDragEnd={handleTerminalDragEnd}
onDragOver={handleTerminalDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<div class="flex flex-col h-full">
<Tabs
variant="alt"
value={terminal.active()}
onChange={(id) => terminal.open(id)}
class="!h-auto !flex-none"
>
<Tabs.List class="h-10 border-b border-border-weaker-base">
<SortableProvider ids={ids()}>
<For each={ids()}>
{(id) => (
<Show when={byId().get(id)}>
{(pty) => <SortableTerminalTab terminal={pty()} onClose={close} />}
</Show>
)}
</For>
</SortableProvider>
<div class="h-full flex items-center justify-center">
<TooltipKeybind
title={language.t("command.terminal.new")}
keybind={command.keybind("terminal.new")}
class="flex items-center"
>
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
onClick={terminal.new}
aria-label={language.t("command.terminal.new")}
/>
</TooltipKeybind>
</div>
</Tabs.List>
</Tabs>
<div class="flex-1 min-h-0 relative">
<Show when={terminal.active()} keyed>
{(id) => (
<Show when={byId().get(id)}>
{(pty) => (
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
<Terminal
pty={pty()}
onCleanup={terminal.update}
onConnectError={() => terminal.clone(id)}
/>
</div>
)}
</Show>
)}
</Show>
</div>
</div>
<DragOverlay>
<Show when={store.activeDraggable}>
{(draggedId) => (
<Show when={byId().get(draggedId())}>
{(t) => (
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
{terminalTabLabel({
title: t().title,
titleNumber: t().titleNumber,
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
})}
</div>
)}
</Show>
)}
</Show>
</div>
</div>
<DragOverlay>
<Show when={store.activeDraggable}>
{(draggedId) => (
<Show when={byId().get(draggedId())}>
{(t) => (
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
{terminalTabLabel({
title: t().title,
titleNumber: t().titleNumber,
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
})}
</div>
)}
</Show>
)}
</Show>
</DragOverlay>
</DragDropProvider>
</Show>
</DragOverlay>
</DragDropProvider>
</Show>
</div>
</div>
</Show>
)

View File

@@ -1,6 +1,5 @@
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { useLocation, useNavigate } from "@solidjs/router"
import { createEffect, createMemo, onMount } from "solid-js"
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { messageIdFromHash } from "./message-id-from-hash"
export { messageIdFromHash } from "./message-id-from-hash"
@@ -16,7 +15,7 @@ export const useSessionHashScroll = (input: {
setPendingMessage: (value: string | undefined) => void
setActiveMessage: (message: UserMessage | undefined) => void
setTurnStart: (value: number) => void
autoScroll: { pause: () => void; forceScrollToBottom: () => void }
autoScroll: { pause: () => void; snapToBottom: () => void }
scroller: () => HTMLDivElement | undefined
anchor: (id: string) => string
scheduleScrollState: (el: HTMLDivElement) => void
@@ -27,18 +26,13 @@ export const useSessionHashScroll = (input: {
const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i])))
let pendingKey = ""
const location = useLocation()
const navigate = useNavigate()
const clearMessageHash = () => {
if (!location.hash) return
navigate(location.pathname + location.search, { replace: true })
if (!window.location.hash) return
window.history.replaceState(null, "", window.location.pathname + window.location.search)
}
const updateHash = (id: string) => {
navigate(location.pathname + location.search + `#${input.anchor(id)}`, {
replace: true,
})
window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}#${input.anchor(id)}`)
}
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
@@ -47,15 +41,15 @@ export const useSessionHashScroll = (input: {
const a = el.getBoundingClientRect()
const b = root.getBoundingClientRect()
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)
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
root.scrollTo({ top, behavior })
return true
}
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
console.log({ message, behavior })
if (input.currentMessageId() !== message.id) input.setActiveMessage(message)
const index = messageIndex().get(message.id) ?? -1
@@ -103,9 +97,9 @@ export const useSessionHashScroll = (input: {
}
const applyHash = (behavior: ScrollBehavior) => {
const hash = location.hash.slice(1)
const hash = window.location.hash.slice(1)
if (!hash) {
input.autoScroll.forceScrollToBottom()
input.autoScroll.snapToBottom()
const el = input.scroller()
if (el) input.scheduleScrollState(el)
return
@@ -129,13 +123,26 @@ export const useSessionHashScroll = (input: {
return
}
input.autoScroll.forceScrollToBottom()
input.autoScroll.snapToBottom()
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(() => {
location.hash
if (!input.sessionID() || !input.messagesReady()) return
requestAnimationFrame(() => applyHash("auto"))
})
@@ -159,7 +166,6 @@ export const useSessionHashScroll = (input: {
}
}
if (!targetId) targetId = messageIdFromHash(location.hash)
if (!targetId) return
if (input.currentMessageId() === targetId) return
@@ -171,12 +177,6 @@ export const useSessionHashScroll = (input: {
requestAnimationFrame(() => scrollToMessage(msg, "auto"))
})
onMount(() => {
if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
window.history.scrollRestoration = "manual"
}
})
return {
clearMessageHash,
scrollToMessage,

View File

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

View File

@@ -9,8 +9,8 @@ export const config = {
github: {
repoUrl: "https://github.com/anomalyco/opencode",
starsFormatted: {
compact: "100K",
full: "100,000",
compact: "120K",
full: "120,000",
},
},
@@ -22,8 +22,8 @@ export const config = {
// Static stats (used on landing page)
stats: {
contributors: "700",
commits: "9,000",
monthlyUsers: "2.5M",
contributors: "800",
commits: "10,000",
monthlyUsers: "5M",
},
} as const

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.2.20",
"version": "1.2.21",
"$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.20",
"version": "1.2.21",
"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.20",
"version": "1.2.21",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.2.20",
"version": "1.2.21",
"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.20"
version = "1.2.21"
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.20/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.21/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.20/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.21/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.20/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.21/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.20/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.21/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.20/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.21/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.2.20",
"version": "1.2.21",
"$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.20",
"version": "1.2.21",
"name": "opencode",
"type": "module",
"license": "MIT",

View File

@@ -24,6 +24,15 @@ function normalizeLineEndings(text: string): string {
return text.replaceAll("\r\n", "\n")
}
function detectLineEnding(text: string): "\n" | "\r\n" {
return text.includes("\r\n") ? "\r\n" : "\n"
}
function convertToLineEnding(text: string, ending: "\n" | "\r\n"): string {
if (ending === "\n") return text
return text.replaceAll("\n", "\r\n")
}
export const EditTool = Tool.define("edit", {
description: DESCRIPTION,
parameters: z.object({
@@ -78,7 +87,12 @@ export const EditTool = Tool.define("edit", {
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`)
await FileTime.assert(ctx.sessionID, filePath)
contentOld = await Filesystem.readText(filePath)
contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll)
const ending = detectLineEnding(contentOld)
const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)
const next = convertToLineEnding(normalizeLineEndings(params.newString), ending)
contentNew = replace(contentOld, old, next, params.replaceAll)
diff = trimDiff(
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),

View File

@@ -451,6 +451,189 @@ describe("tool.edit", () => {
})
})
describe("line endings", () => {
const old = "alpha\nbeta\ngamma"
const next = "alpha\nbeta-updated\ngamma"
const alt = "alpha\nbeta\nomega"
const normalize = (text: string, ending: "\n" | "\r\n") => {
const normalized = text.replaceAll("\r\n", "\n")
if (ending === "\n") return normalized
return normalized.replaceAll("\n", "\r\n")
}
const count = (content: string) => {
const crlf = content.match(/\r\n/g)?.length ?? 0
const lf = content.match(/\n/g)?.length ?? 0
return {
crlf,
lf: lf - crlf,
}
}
const expectLf = (content: string) => {
const counts = count(content)
expect(counts.crlf).toBe(0)
expect(counts.lf).toBeGreaterThan(0)
}
const expectCrlf = (content: string) => {
const counts = count(content)
expect(counts.lf).toBe(0)
expect(counts.crlf).toBeGreaterThan(0)
}
type Input = {
content: string
oldString: string
newString: string
replaceAll?: boolean
}
const apply = async (input: Input) => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "test.txt"), input.content)
},
})
return await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await EditTool.init()
const filePath = path.join(tmp.path, "test.txt")
FileTime.read(ctx.sessionID, filePath)
await edit.execute(
{
filePath,
oldString: input.oldString,
newString: input.newString,
replaceAll: input.replaceAll,
},
ctx,
)
return await Bun.file(filePath).text()
},
})
}
test("preserves LF with LF multi-line strings", async () => {
const content = normalize(old + "\n", "\n")
const output = await apply({
content,
oldString: normalize(old, "\n"),
newString: normalize(next, "\n"),
})
expect(output).toBe(normalize(next + "\n", "\n"))
expectLf(output)
})
test("preserves CRLF with CRLF multi-line strings", async () => {
const content = normalize(old + "\n", "\r\n")
const output = await apply({
content,
oldString: normalize(old, "\r\n"),
newString: normalize(next, "\r\n"),
})
expect(output).toBe(normalize(next + "\n", "\r\n"))
expectCrlf(output)
})
test("preserves LF when old/new use CRLF", async () => {
const content = normalize(old + "\n", "\n")
const output = await apply({
content,
oldString: normalize(old, "\r\n"),
newString: normalize(next, "\r\n"),
})
expect(output).toBe(normalize(next + "\n", "\n"))
expectLf(output)
})
test("preserves CRLF when old/new use LF", async () => {
const content = normalize(old + "\n", "\r\n")
const output = await apply({
content,
oldString: normalize(old, "\n"),
newString: normalize(next, "\n"),
})
expect(output).toBe(normalize(next + "\n", "\r\n"))
expectCrlf(output)
})
test("preserves LF when newString uses CRLF", async () => {
const content = normalize(old + "\n", "\n")
const output = await apply({
content,
oldString: normalize(old, "\n"),
newString: normalize(next, "\r\n"),
})
expect(output).toBe(normalize(next + "\n", "\n"))
expectLf(output)
})
test("preserves CRLF when newString uses LF", async () => {
const content = normalize(old + "\n", "\r\n")
const output = await apply({
content,
oldString: normalize(old, "\r\n"),
newString: normalize(next, "\n"),
})
expect(output).toBe(normalize(next + "\n", "\r\n"))
expectCrlf(output)
})
test("preserves LF with mixed old/new line endings", async () => {
const content = normalize(old + "\n", "\n")
const output = await apply({
content,
oldString: "alpha\nbeta\r\ngamma",
newString: "alpha\r\nbeta\nomega",
})
expect(output).toBe(normalize(alt + "\n", "\n"))
expectLf(output)
})
test("preserves CRLF with mixed old/new line endings", async () => {
const content = normalize(old + "\n", "\r\n")
const output = await apply({
content,
oldString: "alpha\r\nbeta\ngamma",
newString: "alpha\nbeta\r\nomega",
})
expect(output).toBe(normalize(alt + "\n", "\r\n"))
expectCrlf(output)
})
test("replaceAll preserves LF for multi-line blocks", async () => {
const blockOld = "alpha\nbeta"
const blockNew = "alpha\nbeta-updated"
const content = normalize(blockOld + "\n" + blockOld + "\n", "\n")
const output = await apply({
content,
oldString: normalize(blockOld, "\n"),
newString: normalize(blockNew, "\n"),
replaceAll: true,
})
expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\n"))
expectLf(output)
})
test("replaceAll preserves CRLF for multi-line blocks", async () => {
const blockOld = "alpha\nbeta"
const blockNew = "alpha\nbeta-updated"
const content = normalize(blockOld + "\n" + blockOld + "\n", "\r\n")
const output = await apply({
content,
oldString: normalize(blockOld, "\r\n"),
newString: normalize(blockNew, "\r\n"),
replaceAll: true,
})
expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\r\n"))
expectCrlf(output)
})
})
describe("concurrent editing", () => {
test("serializes concurrent edits to same file", async () => {
await using tmp = await tmpdir()

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.2.20",
"version": "1.2.21",
"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.20",
"version": "1.2.21",
"type": "module",
"license": "MIT",
"scripts": {

View File

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

View File

@@ -11,6 +11,14 @@ export function useNavigate() {
return () => undefined
}
export function useLocation() {
return {
pathname: "/story/session/story-session",
search: "",
hash: "",
}
}
export function MemoryRouter(props: ParentProps) {
return props.children
}

View File

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

View File

@@ -9,19 +9,20 @@
display: inline-flex;
flex-direction: row-reverse;
align-items: baseline;
justify-content: flex-end;
justify-content: flex-start;
line-height: inherit;
width: var(--animated-number-width, 1ch);
overflow: hidden;
transition: width var(--tool-motion-spring-ms, 560ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
overflow: clip;
transition: width var(--tool-motion-spring-ms, 800ms) 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: hidden;
overflow: clip;
vertical-align: baseline;
-webkit-mask-image: linear-gradient(
to bottom,
@@ -46,7 +47,7 @@
flex-direction: column;
transform: translateY(calc(var(--animated-number-offset, 10) * -1em));
transition-property: transform;
transition-duration: var(--animated-number-duration, 560ms);
transition-duration: var(--animated-number-duration, 600ms);
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 = 600
const DURATION = 800
function normalize(value: number) {
return ((value % 10) + 10) % 10
@@ -90,10 +90,35 @@ 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={digits()}>{(digit) => <Digit value={digit()} direction={direction()} />}</Index>
<Index each={displayDigits()}>{(digit) => <Digit value={digit()} direction={direction()} />}</Index>
</span>
</span>
)

View File

@@ -8,54 +8,28 @@
justify-content: flex-start;
[data-slot="basic-tool-tool-trigger-content"] {
width: auto;
width: 100%;
min-width: 0;
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: 0 1 auto;
flex: 1 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;
@@ -63,11 +37,12 @@
}
[data-slot="basic-tool-tool-info-main"] {
flex: 0 1 auto;
display: flex;
align-items: baseline;
align-items: center;
gap: 8px;
min-width: 0;
overflow: hidden;
overflow: clip;
}
[data-slot="basic-tool-tool-title"] {
@@ -79,22 +54,14 @@
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"] {
flex-shrink: 1;
display: inline-block;
flex: 0 1 auto;
max-width: 100%;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
overflow: clip;
white-space: nowrap;
font-family: var(--font-family-sans);
font-size: 14px;
@@ -138,8 +105,7 @@
[data-slot="basic-tool-tool-arg"] {
flex-shrink: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
overflow: clip;
white-space: nowrap;
font-family: var(--font-family-sans);
font-size: 14px;

View File

@@ -1,8 +1,20 @@
import { createEffect, createSignal, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js"
import { animate, type AnimationPlaybackControls } from "motion"
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 { Collapsible } from "./collapsible"
import type { IconProps } from "./icon"
import { TextShimmer } from "./text-shimmer"
import { hold } from "./tool-utils"
export type TriggerTitle = {
title: string
@@ -20,26 +32,99 @@ const isTriggerTitle = (val: any): val is TriggerTitle => {
)
}
export interface BasicToolProps {
icon: IconProps["name"]
interface ToolCallPanelBaseProps {
icon: string
trigger: TriggerTitle | JSX.Element
children?: JSX.Element
status?: string
animate?: boolean
hideDetails?: boolean
defaultOpen?: boolean
forceOpen?: boolean
defer?: boolean
locked?: boolean
animated?: boolean
watchDetails?: boolean
springContent?: boolean
onSubtitleClick?: () => void
}
const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 }
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>
)
}
export function BasicTool(props: BasicToolProps) {
function ToolCallPanel(props: ToolCallPanelBaseProps) {
const [open, setOpen] = createSignal(props.defaultOpen ?? false)
const [ready, setReady] = createSignal(open())
const pending = () => props.status === "pending" || props.status === "running"
const pendingRaw = () => props.status === "pending" || props.status === "running"
const pending = hold(pendingRaw, 1000)
const watchDetails = () => props.watchDetails !== false
let frame: number | undefined
@@ -59,7 +144,7 @@ export function BasicTool(props: BasicToolProps) {
on(
open,
(value) => {
if (!props.defer) return
if (!props.defer || props.springContent) return
if (!value) {
cancel()
setReady(false)
@@ -77,36 +162,110 @@ export function BasicTool(props: BasicToolProps) {
),
)
// Animated height for collapsible open/close
// Animated content height — single springValue drives all height changes
let contentRef: HTMLDivElement | undefined
let heightAnim: AnimationPlaybackControls | undefined
let bodyRef: HTMLDivElement | undefined
let fadeAnim: AnimationPlaybackControls | undefined
let observer: ResizeObserver | undefined
let resizeFrame: number | 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.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)
}
if (!props.springContent || props.animate === false || !contentRef) return
if (isOpen) doOpen()
else doClose()
},
{ defer: true },
),
)
onCleanup(() => {
heightAnim?.stop()
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
observer?.disconnect()
fadeAnim?.stop()
heightSpring.destroy()
})
const handleOpenChange = (value: boolean) => {
@@ -118,85 +277,34 @@ export function BasicTool(props: BasicToolProps) {
return (
<Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
<Collapsible.Trigger>
<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>
<ToolCallTriggerBody
trigger={props.trigger}
pending={pending()}
onSubtitleClick={props.onSubtitleClick}
arrow={!!props.children && !props.hideDetails && !props.locked && !pending()}
/>
</Collapsible.Trigger>
<Show when={props.animated && props.children && !props.hideDetails}>
<Show when={props.springContent && props.animate !== false && props.children && !props.hideDetails}>
<div
ref={contentRef}
data-slot="collapsible-content"
data-animated
data-spring-content
style={{
height: initialOpen ? "auto" : "0px",
overflow: initialOpen ? "visible" : "hidden",
overflow: "hidden",
display: initialOpen ? undefined : "none",
}}
>
{props.children}
<div ref={bodyRef} data-slot="basic-tool-content-inner">
{props.children}
</div>
</div>
</Show>
<Show when={!props.animated && props.children && !props.hideDetails}>
<Show when={(!props.springContent || props.animate === false) && props.children && !props.hideDetails}>
<Collapsible.Content>
<Show when={!props.defer || ready()}>{props.children}</Show>
<Show when={!props.defer || ready()}>
<div data-slot="basic-tool-content-inner">{props.children}</div>
</Show>
</Collapsible.Content>
</Show>
</Collapsible>
@@ -222,6 +330,60 @@ 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
@@ -229,7 +391,8 @@ export function GenericTool(props: {
input?: Record<string, unknown>
}) {
return (
<BasicTool
<ToolCall
variant={props.hideDetails ? "row" : "panel"}
icon="mcp"
status={props.status}
trigger={{
@@ -237,7 +400,6 @@ export function GenericTool(props: {
subtitle: label(props.input),
args: args(props.input),
}}
hideDetails={props.hideDetails}
/>
)
}

View File

@@ -8,14 +8,18 @@
border-radius: var(--radius-md);
overflow: visible;
&.tool-collapsible {
gap: 8px;
&.tool-collapsible [data-slot="collapsible-trigger"] {
height: 37px;
}
&.tool-collapsible [data-slot="basic-tool-content-inner"] {
padding-top: 0;
}
[data-slot="collapsible-trigger"] {
width: 100%;
display: flex;
height: 32px;
height: 36px;
padding: 0;
align-items: center;
align-self: stretch;
@@ -23,6 +27,17 @@
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;
@@ -50,9 +65,6 @@
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);
@@ -82,16 +94,16 @@
}
[data-slot="collapsible-content"] {
overflow: hidden;
/* animation: slideUp 250ms ease-out; */
overflow: clip;
&[data-expanded] {
overflow: visible;
}
/* &[data-expanded] { */
/* animation: slideDown 250ms ease-out; */
/* } */
/* JS-animated content: overflow managed by animate() */
&[data-spring-content] {
overflow: clip;
}
}
&[data-variant="ghost"] {
@@ -103,9 +115,6 @@
border: none;
padding: 0;
/* &:hover { */
/* color: var(--text-strong); */
/* } */
&:focus-visible {
outline: none;
background-color: var(--surface-raised-base-hover);
@@ -122,21 +131,3 @@
}
}
}
@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

@@ -0,0 +1,199 @@
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

@@ -0,0 +1,426 @@
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

@@ -1,10 +1,20 @@
[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: 12px;
gap: 0;
}
[data-component="assistant-part-item"] {
width: 100%;
min-width: 0;
}
[data-component="user-message"] {
@@ -27,6 +37,14 @@
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;
@@ -35,6 +53,7 @@
width: fit-content;
max-width: min(82%, 64ch);
margin-left: auto;
margin-bottom: 4px;
}
[data-slot="user-message-attachment"] {
@@ -134,7 +153,7 @@
[data-slot="user-message-copy-wrapper"] {
min-height: 24px;
margin-top: 4px;
margin-top: 0;
display: flex;
align-items: center;
justify-content: flex-end;
@@ -144,7 +163,6 @@
pointer-events: none;
transition: opacity 0.15s ease;
will-change: opacity;
[data-component="tooltip-trigger"] {
display: inline-flex;
width: fit-content;
@@ -187,56 +205,21 @@
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: 24px;
margin-top: 0;
padding-block: 4px;
position: relative;
[data-slot="text-part-body"] {
margin-top: 0;
}
[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] {
[data-slot="text-part-turn-summary"] {
width: 100%;
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;
min-width: 0;
}
[data-component="markdown"] {
@@ -245,6 +228,10 @@
}
}
[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;
@@ -278,7 +265,6 @@
line-height: var(--line-height-normal);
[data-component="markdown"] {
margin-top: 24px;
font-style: normal;
font-size: inherit;
color: var(--text-weak);
@@ -372,13 +358,16 @@
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;
}
@@ -448,7 +437,7 @@
[data-component="write-trigger"] {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-start;
gap: 8px;
width: 100%;
@@ -461,7 +450,8 @@
}
[data-slot="message-part-title"] {
flex-shrink: 0;
flex-shrink: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
@@ -493,40 +483,45 @@
[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 */
font-weight: var(--font-weight-regular);
color: var(--text-strong);
flex-shrink: 0;
}
[data-slot="message-part-path"] {
display: flex;
flex-grow: 1;
min-width: 0;
font-weight: var(--font-weight-regular);
}
[data-slot="message-part-directory"] {
[data-slot="message-part-directory-inline"] {
color: var(--text-weak);
min-width: 0;
max-width: min(48vw, 36ch);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
direction: rtl;
text-align: left;
}
[data-slot="message-part-filename"] {
color: var(--text-strong);
flex-shrink: 0;
}
[data-slot="message-part-actions"] {
display: flex;
gap: 16px;
align-items: center;
justify-content: flex-end;
}
}
[data-component="edit-content"] {
@@ -617,6 +612,17 @@
}
}
[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;
@@ -639,7 +645,6 @@
}
[data-component="context-tool-group-trigger"] {
width: 100%;
min-height: 24px;
display: flex;
align-items: center;
@@ -647,28 +652,352 @@
gap: 0px;
cursor: pointer;
&[data-pending] {
cursor: default;
}
[data-slot="context-tool-group-title"] {
flex-shrink: 1;
min-width: 0;
}
}
[data-slot="collapsible-arrow"] {
color: var(--icon-weaker);
/* 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-group-list"] {
padding: 6px 0 4px 0;
[data-component="context-tool-step"] {
width: 100%;
min-width: 0;
padding-left: 12px;
}
[data-component="context-tool-expanded-list"] {
display: flex;
flex-direction: column;
gap: 2px;
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;
[data-slot="context-tool-group-item"] {
min-width: 0;
padding: 6px 0;
&::-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"] {
color: var(--icon-weaker);
}
&:hover:not(:disabled) {
background: color-mix(in srgb, var(--text-base) 8%, transparent) !important;
box-shadow: 0 0 0 1px color-mix(in srgb, var(--icon-weaker) 40%, transparent) !important;
border-radius: var(--radius-sm);
[data-slot="icon-svg"] {
color: var(--icon-base);
}
}
}
[data-slot="shell-expanded-divider"] {
width: 100%;
height: 1px;
background: var(--border-weak-base);
}
[data-slot="shell-expanded-pre"] {
margin: 0;
padding: 12px 16px;
white-space: pre-wrap;
overflow-wrap: anywhere;
code {
font-family: var(--font-family-mono);
font-feature-settings: var(--font-family-mono--font-feature-settings);
font-size: 13px;
line-height: 1.45;
color: var(--text-base);
}
}
[data-component="shell-rolling-command"],
[data-component="shell-rolling-row"] {
display: inline-flex;
align-items: center;
width: 100%;
min-width: 0;
overflow: hidden;
white-space: pre;
padding-left: 12px;
}
[data-slot="shell-rolling-text"] {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-family: var(--font-family-mono);
font-feature-settings: var(--font-family-mono--font-feature-settings);
font-size: var(--font-size-small);
line-height: var(--line-height-large);
}
[data-component="shell-rolling-command"] [data-slot="shell-rolling-text"] {
color: var(--text-base);
}
[data-component="shell-rolling-command"] [data-slot="shell-rolling-prompt"] {
color: var(--text-weaker);
}
[data-component="shell-rolling-row"] [data-slot="shell-rolling-text"] {
color: var(--text-weak);
}
[data-component="diagnostics"] {
display: flex;
flex-direction: column;
@@ -729,6 +1058,30 @@
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;
@@ -1187,8 +1540,7 @@
position: sticky;
top: var(--sticky-accordion-top, 0px);
z-index: 20;
height: 40px;
padding-bottom: 8px;
height: 37px;
background-color: var(--background-stronger);
}
}
@@ -1199,11 +1551,12 @@
}
[data-slot="apply-patch-trigger-content"] {
display: flex;
display: inline-flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 20px;
justify-content: flex-start;
max-width: 100%;
min-width: 0;
gap: 8px;
}
[data-slot="apply-patch-file-info"] {
@@ -1237,9 +1590,9 @@
[data-slot="apply-patch-trigger-actions"] {
flex-shrink: 0;
display: flex;
gap: 16px;
gap: 8px;
align-items: center;
justify-content: flex-end;
justify-content: flex-start;
}
[data-slot="apply-patch-change"] {
@@ -1279,10 +1632,11 @@
}
[data-component="tool-loaded-file"] {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0 4px 28px;
padding: 4px 0 4px 12px;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-weight: var(--font-weight-regular);
@@ -1293,4 +1647,11 @@
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,8 +1,9 @@
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 = Partial<Pick<SpringOptions, "visualDuration" | "bounce" | "stiffness" | "damping" | "mass" | "velocity">>
type Opt = Pick<SpringOptions, "visualDuration" | "bounce" | "stiffness" | "damping" | "mass" | "velocity">
const eq = (a: Opt | undefined, b: Opt | undefined) =>
a?.visualDuration === b?.visualDuration &&
a?.bounce === b?.bounce &&
@@ -13,24 +14,41 @@ 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 stop = attachSpring(spring, source, config)
let off = spring.on("change", (next: number) => setValue(next))
let reduced = reduce()
let stop = reduced ? () => {} : attachSpring(spring, source, config)
let off = spring.on("change", (next) => setValue(next))
createEffect(() => {
source.set(target())
const next = target()
if (reduced) {
source.set(next)
spring.set(next)
setValue(next)
return
}
source.set(next)
})
createEffect(() => {
if (!options) return
const next = read()
if (eq(config, next)) return
const skip = reduce()
if (eq(config, next) && reduced === skip) return
config = next
reduced = skip
stop()
stop = attachSpring(spring, source, next)
stop = skip ? () => {} : attachSpring(spring, source, next)
if (skip) {
const value = target()
source.set(value)
spring.set(value)
setValue(value)
return
}
setValue(spring.get())
})

View File

@@ -0,0 +1,77 @@
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

@@ -0,0 +1,92 @@
[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

@@ -0,0 +1,326 @@
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,6 +9,13 @@
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 {
@@ -45,18 +52,6 @@
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

@@ -1,17 +1,18 @@
import { createSignal, onCleanup, onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js"
import { createSignal, onCleanup, onMount, splitProps, type ComponentProps, Show } from "solid-js"
import { animate, type AnimationPlaybackControls } from "motion"
import { useI18n } from "../context/i18n"
import { FAST_SPRING } from "./motion"
export interface ScrollViewProps extends ComponentProps<"div"> {
viewportRef?: (el: HTMLDivElement) => void
orientation?: "vertical" | "horizontal" // currently only vertical is fully implemented for thumb
reverse?: boolean
}
export function ScrollView(props: ScrollViewProps) {
const i18n = useI18n()
const merged = mergeProps({ orientation: "vertical" }, props)
const [local, events, rest] = splitProps(
merged,
["class", "children", "viewportRef", "orientation", "style"],
props,
["class", "children", "viewportRef", "style", "reverse"],
[
"onScroll",
"onWheel",
@@ -25,9 +26,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)
@@ -36,6 +37,8 @@ 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
@@ -57,9 +60,13 @@ export function ScrollView(props: ScrollViewProps) {
const maxScrollTop = scrollHeight - clientHeight
const maxThumbTop = trackHeight - height
const top = maxScrollTop > 0 ? (scrollTop / maxScrollTop) * maxThumbTop : 0
const top = (() => {
if (maxScrollTop <= 0) return 0
if (!reverse()) return (scrollTop / maxScrollTop) * maxThumbTop
return ((maxScrollTop + scrollTop) / maxScrollTop) * maxThumbTop
})()
// Ensure thumb stays within bounds (shouldn't be necessary due to math above, but good for safety)
// Ensure thumb stays within bounds
const boundedTop = trackPadding + Math.max(0, Math.min(top, maxThumbTop))
setThumbHeight(height)
@@ -82,6 +89,7 @@ export function ScrollView(props: ScrollViewProps) {
}
onCleanup(() => {
stop()
observer.disconnect()
})
@@ -123,6 +131,31 @@ 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,
@@ -147,11 +180,11 @@ export function ScrollView(props: ScrollViewProps) {
break
case "Home":
e.preventDefault()
viewportRef.scrollTo({ top: 0, behavior: "smooth" })
glide(reverse() ? -(viewportRef.scrollHeight - viewportRef.clientHeight) : 0)
break
case "End":
e.preventDefault()
viewportRef.scrollTo({ top: viewportRef.scrollHeight, behavior: "smooth" })
glide(reverse() ? 0 : viewportRef.scrollHeight - viewportRef.clientHeight)
break
case "ArrowUp":
e.preventDefault()
@@ -166,7 +199,6 @@ export function ScrollView(props: ScrollViewProps) {
return (
<div
ref={rootRef}
class={`scroll-view ${local.class || ""}`}
style={local.style}
onPointerEnter={() => setIsHovered(true)}
@@ -177,16 +209,26 @@ 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={events.onWheel as any}
onTouchStart={events.onTouchStart 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)
}}
onTouchMove={events.onTouchMove as any}
onTouchEnd={events.onTouchEnd as any}
onTouchCancel={events.onTouchCancel as any}
onPointerDown={events.onPointerDown as any}
onPointerDown={(e) => {
stop()
if (typeof events.onPointerDown === "function") events.onPointerDown(e as any)
}}
onClick={events.onClick as any}
tabIndex={0}
role="region"

View File

@@ -1,5 +1,4 @@
[data-component="session-turn"] {
--sticky-header-height: calc(var(--session-title-height, 0px) + 24px);
height: 100%;
min-height: 0;
min-width: 0;
@@ -26,7 +25,7 @@
align-items: flex-start;
align-self: stretch;
min-width: 0;
gap: 18px;
gap: 0px;
overflow-anchor: none;
}
@@ -43,30 +42,127 @@
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: 20px;
min-height: 20px;
line-height: var(--line-height-large);
height: 36px;
[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 {
@@ -84,7 +180,7 @@
display: flex;
flex-direction: column;
align-self: stretch;
gap: 12px;
gap: 0px;
> :first-child > [data-component="markdown"]:first-child {
margin-top: 0;
@@ -109,6 +205,7 @@
[data-component="session-turn-diffs-trigger"] {
width: 100%;
height: 36px;
display: flex;
align-items: center;
justify-content: flex-start;
@@ -118,7 +215,7 @@
[data-slot="session-turn-diffs-title"] {
display: inline-flex;
align-items: baseline;
align-items: center;
gap: 8px;
}
@@ -135,7 +232,7 @@
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
font-weight: var(--font-weight-regular);
line-height: var(--line-height-x-large);
line-height: var(--line-height-large);
}
[data-slot="session-turn-diffs-meta"] {
@@ -171,8 +268,10 @@
[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);
@@ -180,16 +279,22 @@
}
[data-slot="session-turn-diff-directory"] {
color: var(--text-base);
overflow: hidden;
text-overflow: ellipsis;
flex: 1 1 auto;
color: var(--text-weak);
min-width: 0;
overflow: clip;
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,23 +3,27 @@ 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, ParentProps, Show } from "solid-js"
import { createEffect, createMemo, createSignal, For, on, onCleanup, ParentProps, Show } from "solid-js"
import { Dynamic } from "solid-js/web"
import { AssistantParts, Message, Part, PART_MAPPING } from "./message-part"
import { GrowBox } from "./grow-box"
import { AssistantParts, UserMessageDisplay, 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 { SessionRetry } from "./session-retry"
import { TextReveal } from "./text-reveal"
import { list } from "./text-utils"
import { SessionRetry } from "./session-retry"
import { Tooltip } from "./tooltip"
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)
}
@@ -73,18 +77,12 @@ 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") {
@@ -141,6 +139,7 @@ export function SessionTurn(
props: ParentProps<{
sessionID: string
messageID: string
animate?: boolean
showReasoningSummaries?: boolean
shellToolDefaultOpen?: boolean
editToolDefaultOpen?: boolean
@@ -159,11 +158,7 @@ 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))
@@ -191,42 +186,8 @@ export function SessionTurn(
return msg
})
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 active = createMemo(() => props.active ?? false)
const queued = createMemo(() => props.queued ?? false)
const parts = createMemo(() => {
const msg = message()
if (!msg) return emptyParts
@@ -289,7 +250,7 @@ export function SessionTurn(
const error = createMemo(
() => assistantMessages().find((m) => m.error && m.error.name !== "MessageAbortedError")?.error,
)
const showAssistantCopyPartID = createMemo(() => {
const assistantCopyPart = createMemo(() => {
const messages = assistantMessages()
for (let i = messages.length - 1; i >= 0; i--) {
@@ -299,13 +260,18 @@ 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" || !part.text?.trim()) continue
return part.id
if (!part || part.type !== "text") continue
const text = part.text?.trim()
if (!text) continue
return {
id: part.id,
text,
message,
}
}
}
return undefined
})
const assistantCopyPartID = createMemo(() => assistantCopyPart()?.id ?? null)
const errorText = createMemo(() => {
const msg = error()?.data?.message
if (typeof msg === "string") return unwrap(msg)
@@ -313,18 +279,14 @@ export function SessionTurn(
return unwrap(String(msg))
})
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 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 working = createMemo(() => status().type !== "idle" && active())
const showReasoningSummaries = createMemo(() => props.showReasoningSummaries ?? true)
const assistantCopyPartID = createMemo(() => {
if (working()) return null
return showAssistantCopyPartID() ?? null
})
const showDiffSummary = createMemo(() => edited() > 0 && !working())
const turnDurationMs = createMemo(() => {
const start = message()?.time.created
if (typeof start !== "number") return undefined
@@ -364,13 +326,109 @@ export function SessionTurn(
.filter((text): text is string => !!text)
.at(-1),
)
const showThinking = createMemo(() => {
const thinking = 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,
@@ -378,6 +436,119 @@ 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
@@ -388,149 +559,120 @@ export function SessionTurn(
>
<div onClick={autoScroll.handleInteraction}>
<Show when={message()}>
<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}
{(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>
</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>
</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>
<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>
</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] : [])}
</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>
<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 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}
>
<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>
<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>
</div>
</Show>
</Collapsible.Content>
</Collapsible>
</div>
</GrowBox>
</div>
</Show>
<Show when={error()}>
<Card variant="error" class="error-card">
{errorText()}
</Card>
</Show>
</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>
{props.children}
</div>

View File

@@ -0,0 +1,310 @@
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,23 +1,13 @@
[data-component="shell-submessage"] {
min-width: 0;
max-width: 100%;
display: inline-flex;
align-items: baseline;
display: inline-block;
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

@@ -4,14 +4,14 @@
* Instead of sliding text through a fixed mask (odometer style),
* the mask itself sweeps across each span to reveal/hide text.
*
* Direction: top-to-bottom. New text drops in from above, old text exits downward.
* Direction: bottom-to-top. New text rises in from below, old text exits upward.
*
* Entering: gradient reveals top-to-bottom (top of text appears first).
* Entering: gradient reveals bottom-to-top (bottom of text appears first).
* gradient(to bottom, white 33%, transparent 33%+edge)
* pos 0 100% = transparent covers element = hidden
* pos 0 0% = white covers element = visible
*
* Leaving: gradient hides top-to-bottom (top of text disappears first).
* Leaving: gradient hides bottom-to-top (bottom of text disappears first).
* gradient(to top, white 33%, transparent 33%+edge)
* pos 0 100% = white covers element = visible
* pos 0 0% = transparent covers element = hidden
@@ -56,17 +56,17 @@
transition-timing-function: var(--_spring);
}
/* ── entering: reveal top-to-bottom ──
* Gradient(to top): white at bottom, transparent at top of mask.
* Settled pos 0 100% = white covers element = visible
* Swap pos 0 0% = transparent covers = hidden
* Slides from above: translateY(-travel) → translateY(0)
/* ── entering: reveal bottom-to-top ──
* Gradient(to bottom): white at top, transparent at bottom of mask.
* Settled pos 0 0% = white covers element = visible
* Swap pos 0 100% = transparent covers = hidden
* Rises from below: translateY(travel) → translateY(0)
*/
[data-slot="text-reveal-entering"] {
mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge)));
-webkit-mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge)));
mask-position: 0 100%;
-webkit-mask-position: 0 100%;
mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge)));
-webkit-mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge)));
mask-position: 0 0%;
-webkit-mask-position: 0 0%;
transition-property:
mask-position,
-webkit-mask-position,
@@ -74,37 +74,37 @@
transform: translateY(0);
}
/* ── leaving: hide top-to-bottom + slide downward ──
* Gradient(to bottom): white at top, transparent at bottom of mask.
* Swap pos 0 0% = white covers element = visible
* Settled pos 0 100% = transparent covers = hidden
* Slides down: translateY(0) → translateY(travel)
/* ── leaving: hide bottom-to-top + slide upward ──
* Gradient(to top): white at bottom, transparent at top of mask.
* Swap pos 0 100% = white covers element = visible
* Settled pos 0 0% = transparent covers = hidden
* Slides up: translateY(0) → translateY(-travel)
*/
[data-slot="text-reveal-leaving"] {
mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge)));
-webkit-mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge)));
mask-position: 0 100%;
-webkit-mask-position: 0 100%;
mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge)));
-webkit-mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge)));
mask-position: 0 0%;
-webkit-mask-position: 0 0%;
transition-property:
mask-position,
-webkit-mask-position,
transform;
transform: translateY(var(--_travel));
transform: translateY(calc(var(--_travel) * -1));
}
/* ── swapping: instant reset ──
* Snap entering to hidden (above), leaving to visible (center).
* Snap entering to hidden (below), leaving to visible (center).
*/
&[data-swapping="true"] [data-slot="text-reveal-entering"] {
mask-position: 0 0%;
-webkit-mask-position: 0 0%;
transform: translateY(calc(var(--_travel) * -1));
mask-position: 0 100%;
-webkit-mask-position: 0 100%;
transform: translateY(var(--_travel));
transition-duration: 0ms !important;
}
&[data-swapping="true"] [data-slot="text-reveal-leaving"] {
mask-position: 0 0%;
-webkit-mask-position: 0 0%;
mask-position: 0 100%;
-webkit-mask-position: 0 100%;
transform: translateY(0);
transition-duration: 0ms !important;
}
@@ -126,15 +126,14 @@
&[data-truncate="true"] [data-slot="text-reveal-track"] {
width: 100%;
min-width: 0;
overflow: hidden;
overflow: clip;
}
&[data-truncate="true"] [data-slot="text-reveal-entering"],
&[data-truncate="true"] [data-slot="text-reveal-leaving"] {
min-width: 0;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
overflow: clip;
}
}

View File

@@ -1,4 +1,13 @@
import { createEffect, createSignal, on, onCleanup, onMount } from "solid-js"
import {
animate,
type AnimationPlaybackControls,
clearFadeStyles,
clearMaskStyles,
GROW_SPRING,
WIPE_MASK,
} from "./motion"
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
const px = (value: number | string | undefined, fallback: number) => {
if (typeof value === "number") return `${value}px`
@@ -17,6 +26,11 @@ const pct = (value: number | undefined, fallback: number) => {
return `${v}%`
}
const clearWipe = (el: HTMLElement) => {
clearFadeStyles(el)
clearMaskStyles(el)
}
export function TextReveal(props: {
text?: string
class?: string
@@ -39,10 +53,8 @@ export function TextReveal(props: {
let outRef: HTMLSpanElement | undefined
let rootRef: HTMLSpanElement | undefined
let frame: number | undefined
const win = () => inRef?.scrollWidth ?? 0
const wout = () => outRef?.scrollWidth ?? 0
const widen = (next: number) => {
if (next <= 0) return
if (props.growOnly ?? true) {
@@ -51,21 +63,14 @@ export function TextReveal(props: {
}
setWidth(`${next}px`)
}
createEffect(
on(
() => props.text,
(next, prev) => {
if (next === prev) return
if (typeof next === "string" && typeof prev === "string" && next.startsWith(prev)) {
setCur(next)
widen(win())
return
}
setSwapping(true)
setOld(prev)
setCur(next)
if (typeof requestAnimationFrame !== "function") {
widen(Math.max(win(), wout()))
rootRef?.offsetHeight
@@ -133,3 +138,94 @@ export function TextReveal(props: {
</span>
)
}
export function TextWipe(props: { text?: string; class?: string; delay?: number; animate?: boolean }) {
let ref: HTMLSpanElement | undefined
let frame: number | undefined
let anim: AnimationPlaybackControls | undefined
const run = () => {
if (props.animate === false) return
const el = ref
if (!el || !props.text || typeof window === "undefined") return
if (prefersReducedMotion()) return
const mask =
typeof CSS !== "undefined" &&
(CSS.supports("mask-image", "linear-gradient(to right, black, transparent)") ||
CSS.supports("-webkit-mask-image", "linear-gradient(to right, black, transparent)"))
anim?.stop()
if (frame !== undefined && typeof cancelAnimationFrame === "function") {
cancelAnimationFrame(frame)
frame = undefined
}
el.style.opacity = "0"
el.style.filter = "blur(3px)"
el.style.transform = "translateX(-0.06em)"
if (mask) {
el.style.maskImage = WIPE_MASK
el.style.webkitMaskImage = WIPE_MASK
el.style.maskSize = "240% 100%"
el.style.webkitMaskSize = "240% 100%"
el.style.maskRepeat = "no-repeat"
el.style.webkitMaskRepeat = "no-repeat"
el.style.maskPosition = "100% 0%"
el.style.webkitMaskPosition = "100% 0%"
}
if (typeof requestAnimationFrame !== "function") {
clearWipe(el)
return
}
frame = requestAnimationFrame(() => {
frame = undefined
const node = ref
if (!node) return
anim = mask
? animate(
node,
{ opacity: 1, filter: "blur(0px)", transform: "translateX(0)", maskPosition: "0% 0%" },
{ ...GROW_SPRING, delay: props.delay ?? 0 },
)
: animate(
node,
{ opacity: 1, filter: "blur(0px)", transform: "translateX(0)" },
{ ...GROW_SPRING, delay: props.delay ?? 0 },
)
anim?.finished.then(() => {
const value = ref
if (!value) return
clearWipe(value)
})
})
}
createEffect(
on(
() => [props.text, props.animate] as const,
([text, enabled]) => {
if (!text || enabled === false) {
if (ref) clearWipe(ref)
return
}
run()
},
),
)
onCleanup(() => {
if (frame !== undefined && typeof cancelAnimationFrame === "function") cancelAnimationFrame(frame)
anim?.stop()
})
return (
<span ref={ref} class={props.class} aria-label={props.text ?? ""}>
{props.text ?? "\u00A0"}
</span>
)
}

View File

@@ -1,11 +1,11 @@
[data-component="text-shimmer"] {
--text-shimmer-step: 45ms;
--text-shimmer-duration: 1200ms;
--text-shimmer-duration: 2000ms;
--text-shimmer-swap: 220ms;
--text-shimmer-index: 0;
--text-shimmer-angle: 90deg;
--text-shimmer-spread: 5.2ch;
--text-shimmer-size: 360%;
--text-shimmer-size: 600%;
--text-shimmer-base-color: var(--text-weak);
--text-shimmer-peak-color: var(--text-strong);
--text-shimmer-sweep: linear-gradient(
@@ -16,15 +16,17 @@
);
--text-shimmer-base: linear-gradient(var(--text-shimmer-base-color), var(--text-shimmer-base-color));
display: inline-flex;
align-items: baseline;
display: inline-block;
vertical-align: baseline;
font: inherit;
letter-spacing: inherit;
line-height: inherit;
}
[data-component="text-shimmer"] [data-slot="text-shimmer-char"] {
display: inline-grid;
display: inline-block;
position: relative;
vertical-align: baseline;
white-space: pre;
font: inherit;
letter-spacing: inherit;
@@ -33,7 +35,7 @@
[data-component="text-shimmer"] [data-slot="text-shimmer-char-base"],
[data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] {
grid-area: 1 / 1;
display: inline-block;
white-space: pre;
transition: opacity var(--text-shimmer-swap) ease-out;
font: inherit;
@@ -42,11 +44,14 @@
}
[data-component="text-shimmer"] [data-slot="text-shimmer-char-base"] {
position: relative;
color: inherit;
opacity: 1;
}
[data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] {
position: absolute;
inset: 0;
color: var(--text-weaker);
opacity: 0;
}

View File

@@ -8,6 +8,7 @@ export const TextShimmer = <T extends ValidComponent = "span">(props: {
active?: boolean
offset?: number
}) => {
const text = createMemo(() => props.text ?? "")
const active = createMemo(() => props.active ?? true)
const offset = createMemo(() => props.offset ?? 0)
const [run, setRun] = createSignal(active())
@@ -36,24 +37,36 @@ export const TextShimmer = <T extends ValidComponent = "span">(props: {
clearTimeout(timer)
})
const len = createMemo(() => Math.max(text().length, 1))
const shimmerSize = createMemo(() => Math.max(300, Math.round(200 + 1400 / len())))
// duration = len × (size - 1) / velocity → uniform perceived sweep speed
const VELOCITY = 0.01375 // ch per ms, ~10% faster than original 0.0125 baseline
const shimmerDuration = createMemo(() => {
const s = shimmerSize() / 100
return Math.max(1000, Math.min(2500, Math.round((len() * (s - 1)) / VELOCITY)))
})
return (
<Dynamic
component={props.as ?? "span"}
data-component="text-shimmer"
data-active={active() ? "true" : "false"}
class={props.class}
aria-label={props.text}
aria-label={text()}
style={{
"--text-shimmer-swap": `${swap}ms`,
"--text-shimmer-index": `${offset()}`,
"--text-shimmer-size": `${shimmerSize()}%`,
"--text-shimmer-duration": `${shimmerDuration()}ms`,
}}
>
<span data-slot="text-shimmer-char">
<span data-slot="text-shimmer-char-base" aria-hidden="true">
{props.text}
{text()}
</span>
<span data-slot="text-shimmer-char-shimmer" data-run={run() ? "true" : "false"} aria-hidden="true">
{props.text}
{text()}
</span>
</span>
</Dynamic>

View File

@@ -0,0 +1,17 @@
/** Find the longest common character prefix between two strings. */
export function commonPrefix(a: string, b: string) {
const ac = Array.from(a)
const bc = Array.from(b)
let i = 0
while (i < ac.length && i < bc.length && ac[i] === bc[i]) i++
return {
prefix: ac.slice(0, i).join(""),
aSuffix: ac.slice(i).join(""),
bSuffix: bc.slice(i).join(""),
}
}
export function list<T>(value: T[] | undefined | null, fallback: T[]): T[] {
if (Array.isArray(value)) return value
return fallback
}

View File

@@ -27,10 +27,10 @@
grid-template-columns: 0fr;
opacity: 0;
filter: blur(calc(var(--tool-motion-blur, 2px) * 0.42));
overflow: hidden;
overflow: clip;
transform: translateX(-0.04em);
transition-property: grid-template-columns, opacity, filter, transform;
transition-duration: 250ms, 250ms, 250ms, 250ms;
transition-duration: 800ms, 400ms, 400ms, 800ms;
transition-timing-function:
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out,
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
@@ -45,7 +45,7 @@
[data-slot="tool-count-label-suffix-inner"] {
min-width: 0;
overflow: hidden;
overflow: clip;
white-space: pre;
}
}

View File

@@ -1,5 +1,6 @@
import { createMemo } from "solid-js"
import { AnimatedNumber } from "./animated-number"
import { commonPrefix } from "./text-utils"
function split(text: string) {
const match = /{{\s*count\s*}}/.exec(text)
@@ -11,35 +12,23 @@ function split(text: string) {
}
}
function common(one: string, other: string) {
const a = Array.from(one)
const b = Array.from(other)
let i = 0
while (i < a.length && i < b.length && a[i] === b[i]) i++
return {
stem: a.slice(0, i).join(""),
one: a.slice(i).join(""),
other: b.slice(i).join(""),
}
}
export function AnimatedCountLabel(props: { count: number; one: string; other: string; class?: string }) {
const one = createMemo(() => split(props.one))
const other = createMemo(() => split(props.other))
const singular = createMemo(() => Math.round(props.count) === 1)
const active = createMemo(() => (singular() ? one() : other()))
const suffix = createMemo(() => common(one().after, other().after))
const suffix = createMemo(() => commonPrefix(one().after, other().after))
const splitSuffix = createMemo(
() =>
one().before === other().before &&
(one().after.startsWith(other().after) || other().after.startsWith(one().after)),
)
const before = createMemo(() => (splitSuffix() ? one().before : active().before))
const stem = createMemo(() => (splitSuffix() ? suffix().stem : active().after))
const stem = createMemo(() => (splitSuffix() ? suffix().prefix : active().after))
const tail = createMemo(() => {
if (!splitSuffix()) return ""
if (singular()) return suffix().one
return suffix().other
if (singular()) return suffix().aSuffix
return suffix().bSuffix
})
const showTail = createMemo(() => splitSuffix() && tail().length > 0)

View File

@@ -10,12 +10,12 @@
opacity: 1;
filter: blur(0);
transform: translateY(0) scale(1);
overflow: hidden;
overflow: clip;
transform-origin: left center;
transition-property: grid-template-columns, opacity, filter, transform;
transition-duration:
var(--tool-motion-spring-ms, 480ms), var(--tool-motion-fade-ms, 240ms), var(--tool-motion-fade-ms, 280ms),
var(--tool-motion-spring-ms, 480ms);
var(--tool-motion-spring-ms, 800ms), var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms),
var(--tool-motion-spring-ms, 800ms);
transition-timing-function:
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out,
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
@@ -35,12 +35,12 @@
opacity: 0;
filter: blur(var(--tool-motion-blur, 2px));
transform: translateY(0.06em) scale(0.985);
overflow: hidden;
overflow: clip;
transform-origin: left center;
transition-property: grid-template-columns, opacity, filter, transform;
transition-duration:
var(--tool-motion-spring-ms, 480ms), var(--tool-motion-fade-ms, 280ms), var(--tool-motion-fade-ms, 320ms),
var(--tool-motion-spring-ms, 480ms);
var(--tool-motion-spring-ms, 800ms), var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms),
var(--tool-motion-spring-ms, 800ms);
transition-timing-function:
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out,
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
@@ -55,7 +55,7 @@
[data-slot="tool-count-summary-empty-inner"] {
min-width: 0;
overflow: hidden;
overflow: clip;
white-space: nowrap;
}
@@ -63,7 +63,7 @@
display: inline-flex;
align-items: baseline;
min-width: 0;
overflow: hidden;
overflow: clip;
white-space: nowrap;
}
@@ -75,12 +75,11 @@
margin-right: 0;
opacity: 0;
filter: blur(calc(var(--tool-motion-blur, 2px) * 0.55));
overflow: hidden;
overflow: clip;
transform: translateX(-0.08em);
transition-property: opacity, filter, transform;
transition-duration:
calc(var(--tool-motion-fade-ms, 200ms) * 0.75), calc(var(--tool-motion-fade-ms, 220ms) * 0.75),
calc(var(--tool-motion-fade-ms, 220ms) * 0.6);
var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms);
transition-timing-function: ease-out, ease-out, ease-out;
}

View File

@@ -18,9 +18,8 @@
[data-slot="tool-status-swap"],
[data-slot="tool-status-tail"] {
display: inline-grid;
overflow: hidden;
overflow: clip;
justify-items: start;
transition: width var(--tool-motion-spring-ms, 480ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
}
[data-slot="tool-status-active"],
@@ -31,8 +30,8 @@
text-align: start;
transition-property: opacity, filter, transform;
transition-duration:
var(--tool-motion-fade-ms, 240ms), calc(var(--tool-motion-fade-ms, 240ms) * 0.8),
calc(var(--tool-motion-fade-ms, 240ms) * 0.8);
var(--tool-motion-fade-ms, 400ms), calc(var(--tool-motion-fade-ms, 400ms) * 0.8),
calc(var(--tool-motion-fade-ms, 400ms) * 0.8);
transition-timing-function: ease-out, ease-out, ease-out;
}

View File

@@ -1,17 +1,8 @@
import { Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js"
import { animate, type AnimationPlaybackControls, GROW_SPRING } from "./motion"
import { TextShimmer } from "./text-shimmer"
function common(active: string, done: string) {
const a = Array.from(active)
const b = Array.from(done)
let i = 0
while (i < a.length && i < b.length && a[i] === b[i]) i++
return {
prefix: a.slice(0, i).join(""),
active: a.slice(i).join(""),
done: b.slice(i).join(""),
}
}
import { commonPrefix } from "./text-utils"
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
function contentWidth(el: HTMLSpanElement | undefined) {
if (!el) return 0
@@ -27,25 +18,59 @@ export function ToolStatusTitle(props: {
class?: string
split?: boolean
}) {
const split = createMemo(() => common(props.activeText, props.doneText))
const split = createMemo(() => commonPrefix(props.activeText, props.doneText))
const suffix = createMemo(
() => (props.split ?? true) && split().prefix.length >= 2 && split().active.length > 0 && split().done.length > 0,
() =>
(props.split ?? true) && split().prefix.length >= 2 && split().aSuffix.length > 0 && split().bSuffix.length > 0,
)
const prefixLen = createMemo(() => Array.from(split().prefix).length)
const activeTail = createMemo(() => (suffix() ? split().active : props.activeText))
const doneTail = createMemo(() => (suffix() ? split().done : props.doneText))
const activeTail = createMemo(() => (suffix() ? split().aSuffix : props.activeText))
const doneTail = createMemo(() => (suffix() ? split().bSuffix : props.doneText))
const [width, setWidth] = createSignal("auto")
const [ready, setReady] = createSignal(false)
let activeRef: HTMLSpanElement | undefined
let doneRef: HTMLSpanElement | undefined
let swapRef: HTMLSpanElement | undefined
let tailRef: HTMLSpanElement | undefined
let frame: number | undefined
let readyFrame: number | undefined
let widthAnim: AnimationPlaybackControls | undefined
const node = () => (suffix() ? tailRef : swapRef)
const reduce = prefersReducedMotion
const setNodeWidth = (width: string) => {
if (swapRef) swapRef.style.width = width
if (tailRef) tailRef.style.width = width
}
const measure = () => {
const target = props.active ? activeRef : doneRef
const px = contentWidth(target)
if (px > 0) setWidth(`${px}px`)
const next = contentWidth(target)
if (next <= 0) return
const ref = node()
if (!ref || !ready() || reduce()) {
widthAnim?.stop()
setNodeWidth(`${next}px`)
return
}
const prev = Math.max(0, Math.ceil(ref.getBoundingClientRect().width))
if (Math.abs(next - prev) < 1) {
ref.style.width = `${next}px`
return
}
ref.style.width = `${prev}px`
widthAnim?.stop()
widthAnim = animate(ref, { width: `${next}px` }, GROW_SPRING)
widthAnim.finished.then(() => {
const el = node()
if (!el) return
el.style.width = `${next}px`
})
}
const schedule = () => {
@@ -90,6 +115,7 @@ export function ToolStatusTitle(props: {
onCleanup(() => {
if (frame !== undefined) cancelAnimationFrame(frame)
if (readyFrame !== undefined) cancelAnimationFrame(readyFrame)
widthAnim?.stop()
})
return (
@@ -104,7 +130,7 @@ export function ToolStatusTitle(props: {
<Show
when={suffix()}
fallback={
<span data-slot="tool-status-swap" style={{ width: width() }}>
<span data-slot="tool-status-swap" ref={swapRef}>
<span data-slot="tool-status-active" ref={activeRef}>
<TextShimmer text={activeTail()} active={props.active} offset={0} />
</span>
@@ -118,7 +144,7 @@ export function ToolStatusTitle(props: {
<span data-slot="tool-status-prefix">
<TextShimmer text={split().prefix} active={props.active} offset={0} />
</span>
<span data-slot="tool-status-tail" style={{ width: width() }}>
<span data-slot="tool-status-tail" ref={tailRef}>
<span data-slot="tool-status-active" ref={activeRef}>
<TextShimmer text={activeTail()} active={props.active} offset={prefixLen()} />
</span>

View File

@@ -0,0 +1,325 @@
import { createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js"
import {
animate,
type AnimationPlaybackControls,
clearFadeStyles,
clearMaskStyles,
COLLAPSIBLE_SPRING,
GROW_SPRING,
WIPE_MASK,
} from "./motion"
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
import type { ToolPart } from "@opencode-ai/sdk/v2"
export const TEXT_RENDER_THROTTLE_MS = 100
export function createThrottledValue(getValue: () => string) {
const [value, setValue] = createSignal(getValue())
let timeout: ReturnType<typeof setTimeout> | undefined
let last = 0
createEffect(() => {
const next = getValue()
const now = Date.now()
const remaining = TEXT_RENDER_THROTTLE_MS - (now - last)
if (remaining <= 0) {
if (timeout) {
clearTimeout(timeout)
timeout = undefined
}
last = now
setValue(next)
return
}
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => {
last = Date.now()
setValue(next)
timeout = undefined
}, remaining)
})
onCleanup(() => {
if (timeout) clearTimeout(timeout)
})
return value
}
export function busy(status: string | undefined) {
return status === "pending" || status === "running"
}
export function hold(state: () => boolean, wait = 2000) {
const [live, setLive] = createSignal(state())
let timer: ReturnType<typeof setTimeout> | undefined
createEffect(() => {
if (state()) {
if (timer) clearTimeout(timer)
timer = undefined
setLive(true)
return
}
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
timer = undefined
setLive(false)
}, wait)
})
onCleanup(() => {
if (timer) clearTimeout(timer)
})
return live
}
export function updateScrollMask(el: HTMLElement, fade = 12) {
const { scrollTop, scrollHeight, clientHeight } = el
const overflow = scrollHeight - clientHeight
if (overflow <= 1) {
el.style.maskImage = ""
el.style.webkitMaskImage = ""
return
}
const top = scrollTop > 1
const bottom = scrollTop < overflow - 1
const mask =
top && bottom
? `linear-gradient(to bottom, transparent 0, black ${fade}px, black calc(100% - ${fade}px), transparent 100%)`
: top
? `linear-gradient(to bottom, transparent 0, black ${fade}px)`
: bottom
? `linear-gradient(to bottom, black calc(100% - ${fade}px), transparent 100%)`
: ""
el.style.maskImage = mask
el.style.webkitMaskImage = mask
}
export function useCollapsible(options: {
content: () => HTMLElement | undefined
body: () => HTMLElement | undefined
open: () => boolean
measure?: () => number
onOpen?: () => void
}) {
let heightAnim: AnimationPlaybackControls | undefined
let fadeAnim: AnimationPlaybackControls | undefined
let gen = 0
createEffect(
on(
options.open,
(isOpen) => {
const content = options.content()
const body = options.body()
if (!content || !body) return
heightAnim?.stop()
fadeAnim?.stop()
const id = ++gen
if (isOpen) {
content.style.display = ""
content.style.height = "0px"
body.style.opacity = "0"
body.style.filter = "blur(2px)"
fadeAnim = animate(body, { opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"] }, COLLAPSIBLE_SPRING)
queueMicrotask(() => {
if (gen !== id) return
const c = options.content()
if (!c) return
const h = options.measure?.() ?? Math.ceil(body.getBoundingClientRect().height)
heightAnim = animate(c, { height: ["0px", `${h}px`] }, COLLAPSIBLE_SPRING)
heightAnim.finished.then(
() => {
if (gen !== id) return
c.style.height = "auto"
options.onOpen?.()
},
() => {},
)
})
return
}
const h = content.getBoundingClientRect().height
heightAnim = animate(content, { height: [`${h}px`, "0px"] }, COLLAPSIBLE_SPRING)
fadeAnim = animate(body, { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"] }, COLLAPSIBLE_SPRING)
heightAnim.finished.then(
() => {
if (gen !== id) return
content.style.display = "none"
},
() => {},
)
},
{ defer: true },
),
)
onCleanup(() => {
++gen
heightAnim?.stop()
fadeAnim?.stop()
})
}
export function useContextToolPending(parts: () => ToolPart[], working?: () => boolean) {
const anyRunning = createMemo(() => parts().some((part) => busy(part.state.status)))
const [settled, setSettled] = createSignal(false)
createEffect(() => {
if (!anyRunning() && !working?.()) setSettled(true)
})
return createMemo(() => !settled() && (!!working?.() || anyRunning()))
}
export function useRowWipe(opts: {
id: () => string
text: () => string | undefined
ref: () => HTMLElement | undefined
seen: Set<string>
}) {
const reduce = prefersReducedMotion
createEffect(() => {
const id = opts.id()
const txt = opts.text()
const el = opts.ref()
if (!el) return
if (!txt) {
clearFadeStyles(el)
clearMaskStyles(el)
return
}
if (reduce() || typeof window === "undefined") {
clearFadeStyles(el)
clearMaskStyles(el)
return
}
if (opts.seen.has(id)) {
clearFadeStyles(el)
clearMaskStyles(el)
return
}
opts.seen.add(id)
el.style.maskImage = WIPE_MASK
el.style.webkitMaskImage = WIPE_MASK
el.style.maskSize = "240% 100%"
el.style.webkitMaskSize = "240% 100%"
el.style.maskRepeat = "no-repeat"
el.style.webkitMaskRepeat = "no-repeat"
el.style.maskPosition = "100% 0%"
el.style.webkitMaskPosition = "100% 0%"
el.style.opacity = "0"
el.style.filter = "blur(2px)"
el.style.transform = "translateX(-0.06em)"
let done = false
const clear = () => {
if (done) return
done = true
clearFadeStyles(el)
clearMaskStyles(el)
}
if (typeof requestAnimationFrame !== "function") {
clear()
return
}
let anim: AnimationPlaybackControls | undefined
let frame: number | undefined = requestAnimationFrame(() => {
frame = undefined
const node = opts.ref()
if (!node) return
anim = animate(
node,
{
opacity: [0, 1],
filter: ["blur(2px)", "blur(0px)"],
transform: ["translateX(-0.06em)", "translateX(0)"],
maskPosition: "0% 0%",
},
GROW_SPRING,
)
anim.finished.catch(() => {}).finally(clear)
})
onCleanup(() => {
if (frame !== undefined) {
cancelAnimationFrame(frame)
clear()
}
})
})
}
export function useToolFade(
ref: () => HTMLElement | undefined,
options?: { delay?: number; wipe?: boolean; animate?: boolean },
) {
let anim: AnimationPlaybackControls | undefined
let frame: number | undefined
const delay = options?.delay ?? 0
const wipe = options?.wipe ?? false
const active = options?.animate !== false
onMount(() => {
if (!active) return
const el = ref()
if (!el || typeof window === "undefined") return
if (prefersReducedMotion()) return
const mask =
wipe &&
typeof CSS !== "undefined" &&
(CSS.supports("mask-image", "linear-gradient(to right, black, transparent)") ||
CSS.supports("-webkit-mask-image", "linear-gradient(to right, black, transparent)"))
el.style.opacity = "0"
el.style.filter = wipe ? "blur(3px)" : "blur(2px)"
el.style.transform = wipe ? "translateX(-0.06em)" : "translateY(0.04em)"
if (mask) {
el.style.maskImage = WIPE_MASK
el.style.webkitMaskImage = WIPE_MASK
el.style.maskSize = "240% 100%"
el.style.webkitMaskSize = "240% 100%"
el.style.maskRepeat = "no-repeat"
el.style.webkitMaskRepeat = "no-repeat"
el.style.maskPosition = "100% 0%"
el.style.webkitMaskPosition = "100% 0%"
}
frame = requestAnimationFrame(() => {
frame = undefined
const node = ref()
if (!node) return
anim = wipe
? mask
? animate(
node,
{ opacity: 1, filter: "blur(0px)", transform: "translateX(0)", maskPosition: "0% 0%" },
{ ...GROW_SPRING, delay },
)
: animate(node, { opacity: 1, filter: "blur(0px)", transform: "translateX(0)" }, { ...GROW_SPRING, delay })
: animate(node, { opacity: 1, filter: "blur(0px)", transform: "translateY(0)" }, { ...GROW_SPRING, delay })
anim?.finished.then(() => {
const value = ref()
if (!value) return
clearFadeStyles(value)
if (mask) clearMaskStyles(value)
})
})
})
onCleanup(() => {
if (frame !== undefined) cancelAnimationFrame(frame)
anim?.stop()
})
}

View File

@@ -1,6 +1,8 @@
import { createEffect, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { animate, type AnimationPlaybackControls } from "motion"
import { FAST_SPRING } from "../components/motion"
export interface AutoScrollOptions {
working: () => boolean
@@ -9,13 +11,28 @@ export interface AutoScrollOptions {
bottomThreshold?: number
}
const SETTLE_MS = 500
const AUTO_SCROLL_GRACE_MS = 120
const AUTO_SCROLL_EPSILON = 0.5
const MANUAL_ANCHOR_MS = 3000
const MANUAL_ANCHOR_QUIET_FRAMES = 24
export function createAutoScroll(options: AutoScrollOptions) {
let scroll: HTMLElement | undefined
let settling = false
let settleTimer: ReturnType<typeof setTimeout> | undefined
let autoTimer: ReturnType<typeof setTimeout> | undefined
let cleanup: (() => void) | undefined
let auto: { top: number; time: number } | undefined
let programmaticUntil = 0
let scrollAnim: AnimationPlaybackControls | undefined
let hold:
| {
el: HTMLElement
top: number
until: number
quiet: number
frame: number | undefined
}
| undefined
const threshold = () => options.bottomThreshold ?? 10
@@ -27,77 +44,160 @@ export function createAutoScroll(options: AutoScrollOptions) {
const active = () => options.working() || settling
const distanceFromBottom = (el: HTMLElement) => {
return el.scrollHeight - el.clientHeight - el.scrollTop
// With column-reverse, scrollTop=0 is at the bottom, negative = scrolled up
return Math.abs(el.scrollTop)
}
const canScroll = (el: HTMLElement) => {
return el.scrollHeight - el.clientHeight > 1
}
// Browsers can dispatch scroll events asynchronously. If new content arrives
// between us calling `scrollTo()` and the subsequent `scroll` event firing,
// the handler can see a non-zero `distanceFromBottom` and incorrectly assume
// the user scrolled.
const markAuto = (el: HTMLElement) => {
auto = {
top: Math.max(0, el.scrollHeight - el.clientHeight),
time: Date.now(),
}
if (autoTimer) clearTimeout(autoTimer)
autoTimer = setTimeout(() => {
auto = undefined
autoTimer = undefined
}, 1500)
const markProgrammatic = () => {
programmaticUntil = Date.now() + AUTO_SCROLL_GRACE_MS
}
const isAuto = (el: HTMLElement) => {
const a = auto
if (!a) return false
const clearHold = () => {
const next = hold
if (!next) return
if (next.frame !== undefined) cancelAnimationFrame(next.frame)
hold = undefined
}
if (Date.now() - a.time > 1500) {
auto = undefined
const tickHold = () => {
const next = hold
const el = scroll
if (!next || !el) return false
if (Date.now() > next.until) {
clearHold()
return false
}
if (!next.el.isConnected) {
clearHold()
return false
}
return Math.abs(el.scrollTop - a.top) < 2
}
const scrollToBottomNow = (behavior: ScrollBehavior) => {
const el = scroll
if (!el) return
markAuto(el)
if (behavior === "smooth") {
el.scrollTo({ top: el.scrollHeight, behavior })
return
const current = next.el.getBoundingClientRect().top
if (!Number.isFinite(current)) {
clearHold()
return false
}
// `scrollTop` assignment bypasses any CSS `scroll-behavior: smooth`.
el.scrollTop = el.scrollHeight
const delta = current - next.top
if (Math.abs(delta) <= AUTO_SCROLL_EPSILON) {
next.quiet += 1
if (next.quiet > MANUAL_ANCHOR_QUIET_FRAMES) {
clearHold()
return false
}
return true
}
next.quiet = 0
if (!store.userScrolled) {
setStore("userScrolled", true)
options.onUserInteracted?.()
}
el.scrollTop += delta
markProgrammatic()
return true
}
const scheduleHold = () => {
const next = hold
if (!next) return
if (next.frame !== undefined) return
next.frame = requestAnimationFrame(() => {
const value = hold
if (!value) return
value.frame = undefined
if (!tickHold()) return
scheduleHold()
})
}
const preserve = (target: HTMLElement) => {
const el = scroll
if (!el) return
if (!store.userScrolled) {
setStore("userScrolled", true)
options.onUserInteracted?.()
}
const top = target.getBoundingClientRect().top
if (!Number.isFinite(top)) return
clearHold()
hold = {
el: target,
top,
until: Date.now() + MANUAL_ANCHOR_MS,
quiet: 0,
frame: undefined,
}
scheduleHold()
}
const scrollToBottom = (force: boolean) => {
if (!force && !active()) return
clearHold()
if (force && store.userScrolled) setStore("userScrolled", false)
const el = scroll
if (!el) return
if (scrollAnim) cancelSmooth()
if (!force && store.userScrolled) return
const distance = distanceFromBottom(el)
if (distance < 2) {
markAuto(el)
// With column-reverse, scrollTop=0 is at the bottom
if (Math.abs(el.scrollTop) <= AUTO_SCROLL_EPSILON) {
markProgrammatic()
return
}
// For auto-following content we prefer immediate updates to avoid
// visible "catch up" animations while content is still settling.
scrollToBottomNow("auto")
el.scrollTop = 0
markProgrammatic()
}
const stop = () => {
const cancelSmooth = () => {
if (scrollAnim) {
scrollAnim.stop()
scrollAnim = undefined
}
}
const smoothScrollToBottom = () => {
const el = scroll
if (!el) return
cancelSmooth()
if (store.userScrolled) setStore("userScrolled", false)
// With column-reverse, scrollTop=0 is at the bottom
if (Math.abs(el.scrollTop) <= AUTO_SCROLL_EPSILON) {
markProgrammatic()
return
}
scrollAnim = animate(el.scrollTop, 0, {
...FAST_SPRING,
onUpdate: (v) => {
markProgrammatic()
el.scrollTop = v
},
onComplete: () => {
scrollAnim = undefined
markProgrammatic()
},
})
}
const stop = (input?: { hold?: boolean }) => {
if (input?.hold !== false) clearHold()
const el = scroll
if (!el) return
if (!canScroll(el)) {
@@ -106,15 +206,25 @@ export function createAutoScroll(options: AutoScrollOptions) {
}
if (store.userScrolled) return
markProgrammatic()
setStore("userScrolled", true)
options.onUserInteracted?.()
}
const handleWheel = (e: WheelEvent) => {
if (e.deltaY !== 0) clearHold()
if (e.deltaY > 0) {
const el = scroll
if (!el) return
if (distanceFromBottom(el) >= threshold()) return
if (store.userScrolled) setStore("userScrolled", false)
markProgrammatic()
return
}
if (e.deltaY >= 0) return
// If the user is scrolling within a nested scrollable region (tool output,
// code block, etc), don't treat it as leaving the "follow bottom" mode.
// Those regions opt in via `data-scrollable`.
cancelSmooth()
const el = scroll
const target = e.target instanceof Element ? e.target : undefined
const nested = target?.closest("[data-scrollable]")
@@ -126,23 +236,27 @@ export function createAutoScroll(options: AutoScrollOptions) {
const el = scroll
if (!el) return
if (hold) {
if (Date.now() < programmaticUntil) return
clearHold()
}
if (!canScroll(el)) {
if (store.userScrolled) setStore("userScrolled", false)
markProgrammatic()
return
}
if (distanceFromBottom(el) < threshold()) {
if (Date.now() < programmaticUntil) return
if (store.userScrolled) setStore("userScrolled", false)
markProgrammatic()
return
}
// Ignore scroll events triggered by our own scrollToBottom calls.
if (!store.userScrolled && isAuto(el)) {
scrollToBottom(false)
return
}
if (!store.userScrolled && Date.now() < programmaticUntil) return
stop()
stop({ hold: false })
}
const handleInteraction = () => {
@@ -154,6 +268,11 @@ export function createAutoScroll(options: AutoScrollOptions) {
}
const updateOverflowAnchor = (el: HTMLElement) => {
if (hold) {
el.style.overflowAnchor = "none"
return
}
const mode = options.overflowAnchor ?? "dynamic"
if (mode === "none") {
@@ -173,15 +292,17 @@ export function createAutoScroll(options: AutoScrollOptions) {
() => store.contentRef,
() => {
const el = scroll
if (hold) {
scheduleHold()
return
}
if (el && !canScroll(el)) {
if (store.userScrolled) setStore("userScrolled", false)
markProgrammatic()
return
}
if (!active()) return
if (store.userScrolled) return
// ResizeObserver fires after layout, before paint.
// Keep the bottom locked in the same frame to avoid visible
// "jump up then catch up" artifacts while streaming content.
scrollToBottom(false)
},
)
@@ -200,13 +321,11 @@ export function createAutoScroll(options: AutoScrollOptions) {
settling = true
settleTimer = setTimeout(() => {
settling = false
}, 300)
}, SETTLE_MS)
}),
)
createEffect(() => {
// Track `userScrolled` even before `scrollRef` is attached, so we can
// update overflow anchoring once the element exists.
store.userScrolled
const el = scroll
if (!el) return
@@ -215,7 +334,8 @@ export function createAutoScroll(options: AutoScrollOptions) {
onCleanup(() => {
if (settleTimer) clearTimeout(settleTimer)
if (autoTimer) clearTimeout(autoTimer)
clearHold()
cancelSmooth()
if (cleanup) cleanup()
})
@@ -228,8 +348,12 @@ export function createAutoScroll(options: AutoScrollOptions) {
scroll = el
if (!el) return
if (!el) {
clearHold()
return
}
markProgrammatic()
updateOverflowAnchor(el)
el.addEventListener("wheel", handleWheel, { passive: true })
@@ -240,13 +364,18 @@ export function createAutoScroll(options: AutoScrollOptions) {
contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el),
handleScroll,
handleInteraction,
preserve,
pause: stop,
resume: () => {
if (store.userScrolled) setStore("userScrolled", false)
scrollToBottom(true)
},
scrollToBottom: () => scrollToBottom(false),
forceScrollToBottom: () => scrollToBottom(true),
smoothScrollToBottom,
snapToBottom: () => {
const el = scroll
if (!el) return
if (store.userScrolled) setStore("userScrolled", false)
// With column-reverse, scrollTop=0 is at the bottom
el.scrollTop = 0
markProgrammatic()
},
userScrolled: () => store.userScrolled,
}
}

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