From 50004d1f94de833244025caa67870b0c6de103e8 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 24 Feb 2026 23:54:49 -0500 Subject: [PATCH 01/76] refactor: replace Bun.sleep with node timers --- github/index.ts | 3 ++- packages/opencode/src/cli/cmd/auth.ts | 3 ++- packages/opencode/src/cli/cmd/debug/lsp.ts | 3 ++- packages/opencode/src/cli/cmd/github.ts | 5 +++-- packages/opencode/src/cli/cmd/tui/worker.ts | 5 +++-- packages/opencode/src/plugin/codex.ts | 3 ++- packages/opencode/src/plugin/copilot.ts | 7 ++++--- packages/opencode/src/shell/shell.ts | 5 +++-- packages/opencode/test/preload.ts | 3 ++- packages/opencode/test/pty/pty-output-isolation.test.ts | 7 ++++--- packages/opencode/test/session/retry.test.ts | 3 ++- 11 files changed, 29 insertions(+), 18 deletions(-) diff --git a/github/index.ts b/github/index.ts index da310178a7d..1a0a9926224 100644 --- a/github/index.ts +++ b/github/index.ts @@ -8,6 +8,7 @@ import type { Context as GitHubContext } from "@actions/github/lib/context" import type { IssueCommentEvent, PullRequestReviewCommentEvent } from "@octokit/webhooks-types" import { createOpencodeClient } from "@opencode-ai/sdk" import { spawn } from "node:child_process" +import { setTimeout as sleep } from "node:timers/promises" type GitHubAuthor = { login: string @@ -281,7 +282,7 @@ async function assertOpencodeConnected() { connected = true break } catch (e) {} - await Bun.sleep(300) + await sleep(300) } while (retry++ < 30) if (!connected) { diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index 4a97a5e0b83..8aa843843f4 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -13,6 +13,7 @@ import { Instance } from "../../project/instance" import type { Hooks } from "@opencode-ai/plugin" import { Process } from "../../util/process" import { text } from "node:stream/consumers" +import { setTimeout as sleep } from "node:timers/promises" type PluginAuth = NonNullable @@ -38,7 +39,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): const method = plugin.auth.methods[index] // Handle prompts for all auth types - await Bun.sleep(10) + await sleep(10) const inputs: Record = {} if (method.prompts) { for (const prompt of method.prompts) { diff --git a/packages/opencode/src/cli/cmd/debug/lsp.ts b/packages/opencode/src/cli/cmd/debug/lsp.ts index d83c4ed8a43..4b8a3e7d453 100644 --- a/packages/opencode/src/cli/cmd/debug/lsp.ts +++ b/packages/opencode/src/cli/cmd/debug/lsp.ts @@ -3,6 +3,7 @@ import { bootstrap } from "../../bootstrap" import { cmd } from "../cmd" import { Log } from "../../../util/log" import { EOL } from "os" +import { setTimeout as sleep } from "node:timers/promises" export const LSPCommand = cmd({ command: "lsp", @@ -19,7 +20,7 @@ const DiagnosticsCommand = cmd({ async handler(args) { await bootstrap(process.cwd(), async () => { await LSP.touchFile(args.file, true) - await Bun.sleep(1000) + await sleep(1000) process.stdout.write(JSON.stringify(await LSP.diagnostics(), null, 2) + EOL) }) }, diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 672e73d49a9..2491abc567d 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -28,6 +28,7 @@ import { Bus } from "../../bus" import { MessageV2 } from "../../session/message-v2" import { SessionPrompt } from "@/session/prompt" import { $ } from "bun" +import { setTimeout as sleep } from "node:timers/promises" type GitHubAuthor = { login: string @@ -353,7 +354,7 @@ export const GithubInstallCommand = cmd({ } retries++ - await Bun.sleep(1000) + await sleep(1000) } while (true) s.stop("Installed GitHub app") @@ -1372,7 +1373,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"` } catch (e) { if (retries > 0) { console.log(`Retrying after ${delayMs}ms...`) - await Bun.sleep(delayMs) + await sleep(delayMs) return withRetry(fn, retries - 1, delayMs) } throw e diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index bb5495c4811..68d3668cdf6 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -10,6 +10,7 @@ import { GlobalBus } from "@/bus/global" import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2" import type { BunWebSocketData } from "hono/bun" import { Flag } from "@/flag/flag" +import { setTimeout as sleep } from "node:timers/promises" await Log.init({ print: process.argv.includes("--print-logs"), @@ -75,7 +76,7 @@ const startEventStream = (directory: string) => { ).catch(() => undefined) if (!events) { - await Bun.sleep(250) + await sleep(250) continue } @@ -84,7 +85,7 @@ const startEventStream = (directory: string) => { } if (!signal.aborted) { - await Bun.sleep(250) + await sleep(250) } } })().catch((error) => { diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index 56931b2ed62..c9afd2a8646 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -4,6 +4,7 @@ import { Installation } from "../installation" import { Auth, OAUTH_DUMMY_KEY } from "../auth" import os from "os" import { ProviderTransform } from "@/provider/transform" +import { setTimeout as sleep } from "node:timers/promises" const log = Log.create({ service: "plugin.codex" }) @@ -602,7 +603,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { return { type: "failed" as const } } - await Bun.sleep(interval + OAUTH_POLLING_SAFETY_MARGIN_MS) + await sleep(interval + OAUTH_POLLING_SAFETY_MARGIN_MS) } }, } diff --git a/packages/opencode/src/plugin/copilot.ts b/packages/opencode/src/plugin/copilot.ts index 39ea0d00d28..3945c63ce28 100644 --- a/packages/opencode/src/plugin/copilot.ts +++ b/packages/opencode/src/plugin/copilot.ts @@ -1,6 +1,7 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin" import { Installation } from "@/installation" import { iife } from "@/util/iife" +import { setTimeout as sleep } from "node:timers/promises" const CLIENT_ID = "Ov23li8tweQw6odWQebz" // Add a small safety buffer when polling to avoid hitting the server @@ -270,7 +271,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { } if (data.error === "authorization_pending") { - await Bun.sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS) + await sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS) continue } @@ -286,13 +287,13 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { newInterval = serverInterval * 1000 } - await Bun.sleep(newInterval + OAUTH_POLLING_SAFETY_MARGIN_MS) + await sleep(newInterval + OAUTH_POLLING_SAFETY_MARGIN_MS) continue } if (data.error) return { type: "failed" as const } - await Bun.sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS) + await sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS) continue } }, diff --git a/packages/opencode/src/shell/shell.ts b/packages/opencode/src/shell/shell.ts index e7b7cdb3e4d..4779cfef754 100644 --- a/packages/opencode/src/shell/shell.ts +++ b/packages/opencode/src/shell/shell.ts @@ -3,6 +3,7 @@ import { lazy } from "@/util/lazy" import { Filesystem } from "@/util/filesystem" import path from "path" import { spawn, type ChildProcess } from "child_process" +import { setTimeout as sleep } from "node:timers/promises" const SIGKILL_TIMEOUT_MS = 200 @@ -22,13 +23,13 @@ export namespace Shell { try { process.kill(-pid, "SIGTERM") - await Bun.sleep(SIGKILL_TIMEOUT_MS) + await sleep(SIGKILL_TIMEOUT_MS) if (!opts?.exited?.()) { process.kill(-pid, "SIGKILL") } } catch (_e) { proc.kill("SIGTERM") - await Bun.sleep(SIGKILL_TIMEOUT_MS) + await sleep(SIGKILL_TIMEOUT_MS) if (!opts?.exited?.()) { proc.kill("SIGKILL") } diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 41028633e83..caac3bb0de0 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -3,6 +3,7 @@ import os from "os" import path from "path" import fs from "fs/promises" +import { setTimeout as sleep } from "node:timers/promises" import { afterAll } from "bun:test" // Set XDG env vars FIRST, before any src/ imports @@ -15,7 +16,7 @@ afterAll(async () => { typeof error === "object" && error !== null && "code" in error && error.code === "EBUSY" const rm = async (left: number): Promise => { Bun.gc(true) - await Bun.sleep(100) + await sleep(100) return fs.rm(dir, { recursive: true, force: true }).catch((error) => { if (!busy(error)) throw error if (left <= 1) throw error diff --git a/packages/opencode/test/pty/pty-output-isolation.test.ts b/packages/opencode/test/pty/pty-output-isolation.test.ts index 07e86ea97b6..1720f5a4b90 100644 --- a/packages/opencode/test/pty/pty-output-isolation.test.ts +++ b/packages/opencode/test/pty/pty-output-isolation.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { Instance } from "../../src/project/instance" import { Pty } from "../../src/pty" import { tmpdir } from "../fixture/fixture" +import { setTimeout as sleep } from "node:timers/promises" describe("pty", () => { test("does not leak output when websocket objects are reused", async () => { @@ -43,7 +44,7 @@ describe("pty", () => { // Output from a must never show up in b. Pty.write(a.id, "AAA\n") - await Bun.sleep(100) + await sleep(100) expect(outB.join("")).not.toContain("AAA") } finally { @@ -88,7 +89,7 @@ describe("pty", () => { } Pty.write(a.id, "AAA\n") - await Bun.sleep(100) + await sleep(100) expect(outB.join("")).not.toContain("AAA") } finally { @@ -132,7 +133,7 @@ describe("pty", () => { } Pty.write(a.id, "AAA\n") - await Bun.sleep(100) + await sleep(100) expect(outB.join("")).not.toContain("AAA") } finally { diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 6768e72d95a..eba4a995053 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import type { NamedError } from "@opencode-ai/util/error" import { APICallError } from "ai" +import { setTimeout as sleep } from "node:timers/promises" import { SessionRetry } from "../../src/session/retry" import { MessageV2 } from "../../src/session/message-v2" @@ -135,7 +136,7 @@ describe("session.message-v2.fromError", () => { new ReadableStream({ async pull(controller) { controller.enqueue("Hello,") - await Bun.sleep(10000) + await sleep(10000) controller.enqueue(" World!") controller.close() }, From 5fee541d5249553b4a7e49c95ba450407c0f3e4a Mon Sep 17 00:00:00 2001 From: anduimagui Date: Thu, 26 Feb 2026 18:38:48 +0000 Subject: [PATCH 02/76] feat(ui): make file references clickable in session output Add clickable file links for inline markdown path references and edit/write/apply_patch file entries so desktop and web users can open changed files directly from the timeline. --- packages/app/src/pages/directory-layout.tsx | 7 + packages/app/src/pages/session.tsx | 12 ++ packages/ui/src/components/markdown.css | 16 +++ packages/ui/src/components/markdown.tsx | 138 ++++++++++++++++---- packages/ui/src/components/message-part.tsx | 51 +++++++- packages/ui/src/context/data.tsx | 4 + 6 files changed, 197 insertions(+), 31 deletions(-) diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 71b52180f2e..3daab6b256d 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -21,6 +21,13 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { directory={props.directory} onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)} onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`} + onOpenFilePath={(input) => { + window.dispatchEvent( + new CustomEvent("opencode:open-file-path", { + detail: input, + }), + ) + }} > {props.children} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 0d2718efbda..0b3106bb40c 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -504,6 +504,18 @@ export default function Page() { loadFile: file.load, }) + onMount(() => { + const open = (event: Event) => { + const detail = (event as CustomEvent<{ path?: string }>).detail + const path = detail?.path + if (!path) return + openReviewFile(path) + } + + window.addEventListener("opencode:open-file-path", open) + onCleanup(() => window.removeEventListener("opencode:open-file-path", open)) + }) + const changesOptions = ["session", "turn"] as const const changesOptionsList = [...changesOptions] diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index 1fe11a7de89..839ef134464 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -258,3 +258,19 @@ text-decoration: underline; text-underline-offset: 2px; } + +[data-component="markdown"] button.file-link { + appearance: none; + border: none; + background: transparent; + padding: 0; + margin: 0; + color: inherit; + font: inherit; + cursor: pointer; +} + +[data-component="markdown"] button.file-link:hover > code { + text-decoration: underline; + text-underline-offset: 2px; +} diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index bb41c74efbd..57ff8179d5a 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -1,5 +1,6 @@ import { useMarked } from "../context/marked" import { useI18n } from "../context/i18n" +import { useData } from "../context/data" import DOMPurify from "dompurify" import morphdom from "morphdom" import { checksum } from "@opencode-ai/util/encode" @@ -49,6 +50,11 @@ type CopyLabels = { copied: string } +type FileRef = { + path: string + line?: number +} + const urlPattern = /^https?:\/\/[^\s<>()`"']+$/ function codeUrl(text: string) { @@ -62,6 +68,53 @@ function codeUrl(text: string) { } } +function looksLikePath(path: string) { + if (!path) return false + if (path.startsWith("./") || path.startsWith("../") || path.startsWith("/")) return true + if (/^[a-zA-Z]:[\\/]/.test(path)) return true + return path.includes("/") || path.includes("\\") +} + +function normalizeProjectPath(path: string, directory: string) { + if (!path) return path + const file = path.replace(/\\/g, "/") + const root = directory.replace(/\\/g, "/") + if (file.startsWith(root + "/")) return file.slice(root.length + 1) + if (file === root) return "" + if (file.startsWith("./")) return file.slice(2) + return file +} + +function codeFileRef(text: string, directory: string): FileRef | undefined { + let value = text.trim().replace(/[),.;!?]+$/, "") + if (!value) return + + if (value.startsWith("file://")) { + try { + const url = new URL(value) + value = decodeURIComponent(url.pathname) + } catch { + return + } + } + + const hash = value.match(/#L(\d+)$/) + const lineFromHash = hash ? Number(hash[1]) : undefined + if (hash) value = value.slice(0, -hash[0].length) + + const line = value.match(/:(\d+)(?::\d+)?$/) + const lineFromSuffix = line ? Number(line[1]) : undefined + if (line) { + const maybePath = value.slice(0, -line[0].length) + if (looksLikePath(maybePath)) value = maybePath + } + + if (!looksLikePath(value)) return + const path = normalizeProjectPath(value, directory) + if (!path) return + return { path, line: lineFromHash ?? lineFromSuffix } +} + function createIcon(path: string, slot: string) { const icon = document.createElement("div") icon.setAttribute("data-component", "icon") @@ -130,7 +183,7 @@ function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) { } } -function markCodeLinks(root: HTMLDivElement) { +function markCodeLinks(root: HTMLDivElement, directory: string, openable: boolean) { const codeNodes = Array.from(root.querySelectorAll(":not(pre) > code")) for (const code of codeNodes) { const href = codeUrl(code.textContent ?? "") @@ -139,35 +192,46 @@ function markCodeLinks(root: HTMLDivElement) { ? code.parentElement : null - if (!href) { - if (parentLink) parentLink.replaceWith(code) + if (href) { + if (parentLink) { + parentLink.href = href + } else { + const link = document.createElement("a") + link.href = href + link.className = "external-link" + link.target = "_blank" + link.rel = "noopener noreferrer" + code.parentNode?.replaceChild(link, code) + link.appendChild(code) + } continue } - if (parentLink) { - parentLink.href = href - continue - } + if (parentLink) parentLink.replaceWith(code) + if (!openable) continue - const link = document.createElement("a") - link.href = href - link.className = "external-link" - link.target = "_blank" - link.rel = "noopener noreferrer" - code.parentNode?.replaceChild(link, code) - link.appendChild(code) + const file = codeFileRef(code.textContent ?? "", directory) + if (!file) continue + + const button = document.createElement("button") + button.type = "button" + button.className = "file-link" + button.setAttribute("data-file-path", file.path) + if (file.line) button.setAttribute("data-file-line", String(file.line)) + code.parentNode?.replaceChild(button, code) + button.appendChild(code) } } -function decorate(root: HTMLDivElement, labels: CopyLabels) { +function decorate(root: HTMLDivElement, labels: CopyLabels, directory: string, openable: boolean) { const blocks = Array.from(root.querySelectorAll("pre")) for (const block of blocks) { ensureCodeWrapper(block, labels) } - markCodeLinks(root) + markCodeLinks(root, directory, openable) } -function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) { +function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels, onFileOpen?: (input: FileRef) => void) { const timeouts = new Map>() const updateLabel = (button: HTMLButtonElement) => { @@ -179,6 +243,18 @@ function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) { const target = event.target if (!(target instanceof Element)) return + const file = target.closest("button.file-link") + if (file instanceof HTMLButtonElement) { + const path = file.getAttribute("data-file-path") + if (!path || !onFileOpen) return + event.preventDefault() + event.stopPropagation() + const raw = file.getAttribute("data-file-line") + const line = raw ? Number(raw) : undefined + onFileOpen({ path, line }) + return + } + const button = target.closest('[data-slot="markdown-copy-button"]') if (!(button instanceof HTMLButtonElement)) return const code = button.closest('[data-component="markdown-code"]')?.querySelector("code") @@ -194,8 +270,6 @@ function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) { timeouts.set(button, timeout) } - decorate(root, labels) - const buttons = Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]')) for (const button of buttons) { if (button instanceof HTMLButtonElement) updateLabel(button) @@ -232,6 +306,7 @@ export function Markdown( ) { const [local, others] = splitProps(props, ["text", "cacheKey", "class", "classList"]) const marked = useMarked() + const data = useData() const i18n = useI18n() const [root, setRoot] = createSignal() const [html] = createResource( @@ -274,10 +349,15 @@ export function Markdown( const temp = document.createElement("div") temp.innerHTML = content - decorate(temp, { - copy: i18n.t("ui.message.copy"), - copied: i18n.t("ui.message.copied"), - }) + decorate( + temp, + { + copy: i18n.t("ui.message.copy"), + copied: i18n.t("ui.message.copied"), + }, + data.directory, + !!data.openFilePath, + ) morphdom(container, temp, { childrenOnly: true, @@ -290,10 +370,14 @@ export function Markdown( if (copySetupTimer) clearTimeout(copySetupTimer) copySetupTimer = setTimeout(() => { if (copyCleanup) copyCleanup() - copyCleanup = setupCodeCopy(container, { - copy: i18n.t("ui.message.copy"), - copied: i18n.t("ui.message.copied"), - }) + copyCleanup = setupCodeCopy( + container, + { + copy: i18n.t("ui.message.copy"), + copied: i18n.t("ui.message.copied"), + }, + data.openFilePath, + ) }, 150) }) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 6b6dfe2e50e..ba9f24ebc59 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -162,6 +162,17 @@ function getDirectory(path: string | undefined) { return relativizeProjectPath(_getDirectory(path), data.directory) } +function openProjectFile( + path: string | undefined, + directory: string, + openFilePath?: (input: { path: string }) => void, +) { + if (!path) return + const file = relativizeProjectPaths(path, directory).replace(/^\//, "") + if (!file) return + openFilePath?.({ path: file }) +} + import type { IconProps } from "./icon" export type ToolInfo = { @@ -927,7 +938,12 @@ export const ToolRegistry = { render: getTool, } -function ToolFileAccordion(props: { path: string; actions?: JSX.Element; children: JSX.Element }) { +function ToolFileAccordion(props: { + path: string + actions?: JSX.Element + children: JSX.Element + onPathClick?: () => void +}) { const value = createMemo(() => props.path || "tool-file") return ( @@ -947,7 +963,17 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre {`\u202A${getDirectory(props.path)}\u202C`} - {getFilename(props.path)} + { + if (!props.onPathClick) return + event.stopPropagation() + props.onPathClick() + }} + > + {getFilename(props.path)} +
@@ -1463,6 +1489,7 @@ ToolRegistry.register({ ToolRegistry.register({ name: "edit", render(props) { + const data = useData() const i18n = useI18n() const fileComponent = useFileComponent() const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) @@ -1505,6 +1532,7 @@ ToolRegistry.register({ openProjectFile(path(), data.directory, data.openFilePath)} actions={ {(diff) => } } @@ -1535,6 +1563,7 @@ ToolRegistry.register({ ToolRegistry.register({ name: "write", render(props) { + const data = useData() const i18n = useI18n() const fileComponent = useFileComponent() const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) @@ -1571,7 +1600,10 @@ ToolRegistry.register({ } > - + openProjectFile(path(), data.directory, data.openFilePath)} + >
(props.metadata.files ?? []) as ApplyPatchFile[]) @@ -1684,7 +1717,16 @@ ToolRegistry.register({ {`\u202A${getDirectory(file.relativePath)}\u202C`} - {getFilename(file.relativePath)} + { + event.stopPropagation() + openProjectFile(file.relativePath, data.directory, data.openFilePath) + }} + > + {getFilename(file.relativePath)} +
@@ -1770,6 +1812,7 @@ ToolRegistry.register({ > openProjectFile(file().relativePath, data.directory, data.openFilePath)} actions={ diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index e116199eb23..5fe5dc8aa90 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -26,6 +26,8 @@ export type NavigateToSessionFn = (sessionID: string) => void export type SessionHrefFn = (sessionID: string) => string +export type OpenFilePathFn = (input: { path: string; line?: number }) => void + export const { use: useData, provider: DataProvider } = createSimpleContext({ name: "Data", init: (props: { @@ -33,6 +35,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ directory: string onNavigateToSession?: NavigateToSessionFn onSessionHref?: SessionHrefFn + onOpenFilePath?: OpenFilePathFn }) => { return { get store() { @@ -43,6 +46,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }, navigateToSession: props.onNavigateToSession, sessionHref: props.onSessionHref, + openFilePath: props.onOpenFilePath, } }, }) From d7b2a15959d6d20b8efbcba9637cf12c238278a8 Mon Sep 17 00:00:00 2001 From: anduimagui Date: Fri, 27 Feb 2026 11:51:05 +0000 Subject: [PATCH 03/76] fix(ui): restore singular project path helper name --- packages/ui/src/components/message-part.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index ba9f24ebc59..26b1dd9cdf1 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -168,7 +168,7 @@ function openProjectFile( openFilePath?: (input: { path: string }) => void, ) { if (!path) return - const file = relativizeProjectPaths(path, directory).replace(/^\//, "") + const file = relativizeProjectPath(path, directory).replace(/^\//, "") if (!file) return openFilePath?.({ path: file }) } From 8405c709936b5c8ff1fa6e7cb5940dcd5e65513d Mon Sep 17 00:00:00 2001 From: anduimagui Date: Sun, 1 Mar 2026 09:04:38 +0000 Subject: [PATCH 04/76] refactor(ui): extract markdown file reference parser Move inline file-path parsing into a shared helper and add focused tests, including Windows file URL handling, so clickable markdown file links stay stable as parsing rules evolve. --- .../src/components/markdown-file-ref.test.ts | 43 +++++++++++++++ .../ui/src/components/markdown-file-ref.ts | 55 +++++++++++++++++++ packages/ui/src/components/markdown.tsx | 55 +------------------ 3 files changed, 100 insertions(+), 53 deletions(-) create mode 100644 packages/ui/src/components/markdown-file-ref.test.ts create mode 100644 packages/ui/src/components/markdown-file-ref.ts diff --git a/packages/ui/src/components/markdown-file-ref.test.ts b/packages/ui/src/components/markdown-file-ref.test.ts new file mode 100644 index 00000000000..9f5614168c6 --- /dev/null +++ b/packages/ui/src/components/markdown-file-ref.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test" +import { parseCodeFileRef } from "./markdown-file-ref" + +describe("parseCodeFileRef", () => { + test("parses relative path with line and trims punctuation", () => { + expect(parseCodeFileRef("src/app.ts:42,", "")).toEqual({ + path: "src/app.ts", + line: 42, + }) + }) + + test("parses hash-based line suffix", () => { + expect(parseCodeFileRef("src/app.ts#L12", "")).toEqual({ + path: "src/app.ts", + line: 12, + }) + }) + + test("parses file urls and strips project root", () => { + expect(parseCodeFileRef("file:///Users/test/repo/src/main.ts:9", "/Users/test/repo")).toEqual({ + path: "src/main.ts", + line: 9, + }) + }) + + test("normalizes windows paths", () => { + expect(parseCodeFileRef("C:\\repo\\src\\main.ts:7", "")).toEqual({ + path: "C:/repo/src/main.ts", + line: 7, + }) + }) + + test("parses windows file url paths", () => { + expect(parseCodeFileRef("file:///C:/repo/src/main.ts#L11", "")).toEqual({ + path: "C:/repo/src/main.ts", + line: 11, + }) + }) + + test("ignores non-path text", () => { + expect(parseCodeFileRef("hello-world", "")).toBeUndefined() + }) +}) diff --git a/packages/ui/src/components/markdown-file-ref.ts b/packages/ui/src/components/markdown-file-ref.ts new file mode 100644 index 00000000000..c3d0edbe546 --- /dev/null +++ b/packages/ui/src/components/markdown-file-ref.ts @@ -0,0 +1,55 @@ +export type FileRef = { + path: string + line?: number +} + +function looksLikePath(path: string) { + if (!path) return false + if (path.startsWith("./") || path.startsWith("../") || path.startsWith("/")) return true + if (/^[a-zA-Z]:[\\/]/.test(path)) return true + return path.includes("/") || path.includes("\\") +} + +function normalizeProjectPath(path: string, directory: string) { + if (!path) return path + const file = path.replace(/\\/g, "/") + const root = directory.replace(/\\/g, "/") + if (/^\/[a-zA-Z]:\//.test(file)) return file.slice(1) + if (file.startsWith(root + "/")) return file.slice(root.length + 1) + if (file === root) return "" + if (file.startsWith("./")) return file.slice(2) + return file +} + +export function parseCodeFileRef(text: string, directory: string): FileRef | undefined { + let value = text.trim().replace(/[),.;!?]+$/, "") + let lineFromUrlHash: number | undefined + if (!value) return + + if (value.startsWith("file://")) { + try { + const url = new URL(value) + value = decodeURIComponent(url.pathname) + const match = url.hash.match(/^#L(\d+)$/) + lineFromUrlHash = match ? Number(match[1]) : undefined + } catch { + return + } + } + + const hash = value.match(/#L(\d+)$/) + const lineFromHash = hash ? Number(hash[1]) : undefined + if (hash) value = value.slice(0, -hash[0].length) + + const line = value.match(/:(\d+)(?::\d+)?$/) + const lineFromSuffix = line ? Number(line[1]) : undefined + if (line) { + const maybePath = value.slice(0, -line[0].length) + if (looksLikePath(maybePath)) value = maybePath + } + + if (!looksLikePath(value)) return + const path = normalizeProjectPath(value, directory) + if (!path) return + return { path, line: lineFromUrlHash ?? lineFromHash ?? lineFromSuffix } +} diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 57ff8179d5a..7de7f2d963b 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -1,6 +1,7 @@ import { useMarked } from "../context/marked" import { useI18n } from "../context/i18n" import { useData } from "../context/data" +import { parseCodeFileRef, type FileRef } from "./markdown-file-ref" import DOMPurify from "dompurify" import morphdom from "morphdom" import { checksum } from "@opencode-ai/util/encode" @@ -50,11 +51,6 @@ type CopyLabels = { copied: string } -type FileRef = { - path: string - line?: number -} - const urlPattern = /^https?:\/\/[^\s<>()`"']+$/ function codeUrl(text: string) { @@ -68,53 +64,6 @@ function codeUrl(text: string) { } } -function looksLikePath(path: string) { - if (!path) return false - if (path.startsWith("./") || path.startsWith("../") || path.startsWith("/")) return true - if (/^[a-zA-Z]:[\\/]/.test(path)) return true - return path.includes("/") || path.includes("\\") -} - -function normalizeProjectPath(path: string, directory: string) { - if (!path) return path - const file = path.replace(/\\/g, "/") - const root = directory.replace(/\\/g, "/") - if (file.startsWith(root + "/")) return file.slice(root.length + 1) - if (file === root) return "" - if (file.startsWith("./")) return file.slice(2) - return file -} - -function codeFileRef(text: string, directory: string): FileRef | undefined { - let value = text.trim().replace(/[),.;!?]+$/, "") - if (!value) return - - if (value.startsWith("file://")) { - try { - const url = new URL(value) - value = decodeURIComponent(url.pathname) - } catch { - return - } - } - - const hash = value.match(/#L(\d+)$/) - const lineFromHash = hash ? Number(hash[1]) : undefined - if (hash) value = value.slice(0, -hash[0].length) - - const line = value.match(/:(\d+)(?::\d+)?$/) - const lineFromSuffix = line ? Number(line[1]) : undefined - if (line) { - const maybePath = value.slice(0, -line[0].length) - if (looksLikePath(maybePath)) value = maybePath - } - - if (!looksLikePath(value)) return - const path = normalizeProjectPath(value, directory) - if (!path) return - return { path, line: lineFromHash ?? lineFromSuffix } -} - function createIcon(path: string, slot: string) { const icon = document.createElement("div") icon.setAttribute("data-component", "icon") @@ -210,7 +159,7 @@ function markCodeLinks(root: HTMLDivElement, directory: string, openable: boolea if (parentLink) parentLink.replaceWith(code) if (!openable) continue - const file = codeFileRef(code.textContent ?? "", directory) + const file = parseCodeFileRef(code.textContent ?? "", directory) if (!file) continue const button = document.createElement("button") From 1ac4a1a1fab668f43f76badc1122ba37e5a5f5d7 Mon Sep 17 00:00:00 2001 From: anduimagui Date: Sun, 1 Mar 2026 09:04:42 +0000 Subject: [PATCH 05/76] fix(ui): make tool file links keyboard-accessible Render clickable tool filenames as native buttons with focus-visible styles so edit/write/apply_patch file links are accessible and keep existing click-to-open behavior. --- packages/ui/src/components/message-part.css | 21 +++++++++++++++ packages/ui/src/components/message-part.tsx | 30 ++++++++++++--------- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 58227f62597..b186f67dadb 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -1168,6 +1168,27 @@ flex-shrink: 0; } + button[data-slot="apply-patch-filename"] { + appearance: none; + border: none; + background: transparent; + padding: 0; + margin: 0; + text-align: left; + color: inherit; + font: inherit; + line-height: inherit; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; + + &:focus-visible { + outline: 1px solid var(--border-interactive-base); + outline-offset: 2px; + border-radius: 2px; + } + } + [data-slot="apply-patch-trigger-actions"] { flex-shrink: 0; display: flex; diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 26b1dd9cdf1..ca09c8e19e7 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -963,17 +963,21 @@ function ToolFileAccordion(props: { {`\u202A${getDirectory(props.path)}\u202C`} - { - if (!props.onPathClick) return - event.stopPropagation() - props.onPathClick() - }} + {getFilename(props.path)}} > - {getFilename(props.path)} - + +
@@ -1717,16 +1721,16 @@ ToolRegistry.register({ {`\u202A${getDirectory(file.relativePath)}\u202C`} - { event.stopPropagation() openProjectFile(file.relativePath, data.directory, data.openFilePath) }} > {getFilename(file.relativePath)} - +
From 5e9dd5dca33d85320bd7d2bcbcc561982da19202 Mon Sep 17 00:00:00 2001 From: anduimagui Date: Sun, 1 Mar 2026 11:52:42 +0000 Subject: [PATCH 06/76] feat(ui): open clicked file refs in desktop default apps Route markdown and tool file-link clicks to the desktop openPath integration when available, with in-app review fallback if external open fails. Also harden file-ref parsing for wrapped paths and ensure in-app fallback activates the opened tab. --- packages/app/src/pages/directory-layout.tsx | 25 ++++++++++++++++++- packages/app/src/pages/session.tsx | 2 ++ .../app/src/pages/session/helpers.test.ts | 11 +++++++- packages/app/src/pages/session/helpers.ts | 9 ++++++- .../src/components/markdown-file-ref.test.ts | 6 +++++ .../ui/src/components/markdown-file-ref.ts | 5 +++- packages/ui/src/components/markdown.css | 3 +++ packages/ui/src/components/markdown.tsx | 4 +-- 8 files changed, 59 insertions(+), 6 deletions(-) diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 3daab6b256d..0424c3a8bc5 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -9,11 +9,13 @@ import { DataProvider } from "@opencode-ai/ui/context" import { decode64 } from "@/utils/base64" import { showToast } from "@opencode-ai/ui/toast" import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { const params = useParams() const navigate = useNavigate() const sync = useSync() + const platform = usePlatform() return ( ) { directory={props.directory} onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)} onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`} - onOpenFilePath={(input) => { + onOpenFilePath={async (input) => { + const file = input.path.replace(/^[\\/]+/, "") + const separator = props.directory.includes("\\") ? "\\" : "/" + const path = props.directory.endsWith(separator) ? props.directory + file : props.directory + separator + file + + if (platform.platform === "desktop" && platform.openPath) { + await platform.openPath(path).catch((error) => { + const description = error instanceof Error ? error.message : String(error) + showToast({ + variant: "error", + title: "Open failed", + description, + }) + window.dispatchEvent( + new CustomEvent("opencode:open-file-path", { + detail: input, + }), + ) + }) + return + } + window.dispatchEvent( new CustomEvent("opencode:open-file-path", { detail: input, diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 0b3106bb40c..ad7f1b456ef 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -499,8 +499,10 @@ export default function Page() { const openReviewFile = createOpenReviewFile({ showAllFiles, + openReviewPanel, tabForPath: file.tab, openTab: tabs().open, + setActive: tabs().setActive, loadFile: file.load, }) diff --git a/packages/app/src/pages/session/helpers.test.ts b/packages/app/src/pages/session/helpers.test.ts index aaa5b932fe9..f8efd931c07 100644 --- a/packages/app/src/pages/session/helpers.test.ts +++ b/packages/app/src/pages/session/helpers.test.ts @@ -6,17 +6,26 @@ describe("createOpenReviewFile", () => { const calls: string[] = [] const openReviewFile = createOpenReviewFile({ showAllFiles: () => calls.push("show"), + openReviewPanel: () => calls.push("review"), tabForPath: (path) => { calls.push(`tab:${path}`) return `file://${path}` }, openTab: (tab) => calls.push(`open:${tab}`), + setActive: (tab) => calls.push(`active:${tab}`), loadFile: (path) => calls.push(`load:${path}`), }) openReviewFile("src/a.ts") - expect(calls).toEqual(["show", "load:src/a.ts", "tab:src/a.ts", "open:file://src/a.ts"]) + expect(calls).toEqual([ + "tab:src/a.ts", + "show", + "review", + "load:src/a.ts", + "open:file://src/a.ts", + "active:file://src/a.ts", + ]) }) }) diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index 20f1d99a8be..33f045cdb75 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -24,13 +24,20 @@ export const createOpenReviewFile = (input: { showAllFiles: () => void tabForPath: (path: string) => string openTab: (tab: string) => void + setActive: (tab: string) => void + openReviewPanel: () => void loadFile: (path: string) => any | Promise }) => { return (path: string) => { + const tab = input.tabForPath(path) batch(() => { input.showAllFiles() + input.openReviewPanel() const maybePromise = input.loadFile(path) - const openTab = () => input.openTab(input.tabForPath(path)) + const openTab = () => { + input.openTab(tab) + input.setActive(tab) + } if (maybePromise instanceof Promise) maybePromise.then(openTab) else openTab() }) diff --git a/packages/ui/src/components/markdown-file-ref.test.ts b/packages/ui/src/components/markdown-file-ref.test.ts index 9f5614168c6..4757449c190 100644 --- a/packages/ui/src/components/markdown-file-ref.test.ts +++ b/packages/ui/src/components/markdown-file-ref.test.ts @@ -37,6 +37,12 @@ describe("parseCodeFileRef", () => { }) }) + test("normalizes line breaks inside long paths", () => { + expect(parseCodeFileRef("clients/notes/reply-to-\nharry-2026-02-27.md", "")).toEqual({ + path: "clients/notes/reply-to-harry-2026-02-27.md", + }) + }) + test("ignores non-path text", () => { expect(parseCodeFileRef("hello-world", "")).toBeUndefined() }) diff --git a/packages/ui/src/components/markdown-file-ref.ts b/packages/ui/src/components/markdown-file-ref.ts index c3d0edbe546..afec68fba41 100644 --- a/packages/ui/src/components/markdown-file-ref.ts +++ b/packages/ui/src/components/markdown-file-ref.ts @@ -22,7 +22,10 @@ function normalizeProjectPath(path: string, directory: string) { } export function parseCodeFileRef(text: string, directory: string): FileRef | undefined { - let value = text.trim().replace(/[),.;!?]+$/, "") + let value = text + .trim() + .replace(/\s*\n\s*/g, "") + .replace(/[),.;!?]+$/, "") let lineFromUrlHash: number | undefined if (!value) return diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index 839ef134464..f2f3583584e 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -263,10 +263,13 @@ appearance: none; border: none; background: transparent; + display: inline; padding: 0; margin: 0; color: inherit; font: inherit; + text-align: left; + white-space: normal; cursor: pointer; } diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 7de7f2d963b..ba9188f2d50 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -195,11 +195,11 @@ function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels, onFileOpen?: (i const file = target.closest("button.file-link") if (file instanceof HTMLButtonElement) { const path = file.getAttribute("data-file-path") + const raw = file.getAttribute("data-file-line") + const line = raw ? Number(raw) : undefined if (!path || !onFileOpen) return event.preventDefault() event.stopPropagation() - const raw = file.getAttribute("data-file-line") - const line = raw ? Number(raw) : undefined onFileOpen({ path, line }) return } From da34dfa80bf0d70e4ba2d9091bb1f5e4f5f7ad0d Mon Sep 17 00:00:00 2001 From: anduimagui Date: Sun, 1 Mar 2026 12:07:31 +0000 Subject: [PATCH 07/76] fix(app): apply file-link line targets in in-app fallback When external open is unavailable or fails, opening from clickable file refs now preserves optional line numbers by selecting the target line in the review file tab. --- packages/app/src/pages/session.tsx | 5 +++-- .../app/src/pages/session/helpers.test.ts | 19 +++++++++++++++++++ packages/app/src/pages/session/helpers.ts | 4 +++- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index ad7f1b456ef..05230a9e8b2 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -503,15 +503,16 @@ export default function Page() { tabForPath: file.tab, openTab: tabs().open, setActive: tabs().setActive, + setSelectedLines: file.setSelectedLines, loadFile: file.load, }) onMount(() => { const open = (event: Event) => { - const detail = (event as CustomEvent<{ path?: string }>).detail + const detail = (event as CustomEvent<{ path?: string; line?: number }>).detail const path = detail?.path if (!path) return - openReviewFile(path) + openReviewFile(path, detail?.line) } window.addEventListener("opencode:open-file-path", open) diff --git a/packages/app/src/pages/session/helpers.test.ts b/packages/app/src/pages/session/helpers.test.ts index f8efd931c07..7895e769d16 100644 --- a/packages/app/src/pages/session/helpers.test.ts +++ b/packages/app/src/pages/session/helpers.test.ts @@ -13,6 +13,7 @@ describe("createOpenReviewFile", () => { }, openTab: (tab) => calls.push(`open:${tab}`), setActive: (tab) => calls.push(`active:${tab}`), + setSelectedLines: (path, range) => calls.push(`select:${path}:${range ? `${range.start}-${range.end}` : "none"}`), loadFile: (path) => calls.push(`load:${path}`), }) @@ -25,8 +26,26 @@ describe("createOpenReviewFile", () => { "load:src/a.ts", "open:file://src/a.ts", "active:file://src/a.ts", + "select:src/a.ts:none", ]) }) + + test("selects the requested line when provided", () => { + const calls: string[] = [] + const openReviewFile = createOpenReviewFile({ + showAllFiles: () => calls.push("show"), + openReviewPanel: () => calls.push("review"), + tabForPath: (path) => `file://${path}`, + openTab: () => calls.push("open"), + setActive: () => calls.push("active"), + setSelectedLines: (_path, range) => calls.push(`select:${range?.start}-${range?.end}`), + loadFile: () => calls.push("load"), + }) + + openReviewFile("src/a.ts", 12) + + expect(calls).toContain("select:12-12") + }) }) describe("createOpenSessionFileTab", () => { diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index 33f045cdb75..db0357cef7e 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -26,9 +26,10 @@ export const createOpenReviewFile = (input: { openTab: (tab: string) => void setActive: (tab: string) => void openReviewPanel: () => void + setSelectedLines: (path: string, range: { start: number; end: number } | null) => void loadFile: (path: string) => any | Promise }) => { - return (path: string) => { + return (path: string, line?: number) => { const tab = input.tabForPath(path) batch(() => { input.showAllFiles() @@ -37,6 +38,7 @@ export const createOpenReviewFile = (input: { const openTab = () => { input.openTab(tab) input.setActive(tab) + input.setSelectedLines(path, line ? { start: line, end: line } : null) } if (maybePromise instanceof Promise) maybePromise.then(openTab) else openTab() From 695a26a168e009cc8478722a4f364cdf941ae464 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:26:03 +1000 Subject: [PATCH 08/76] fix(tui): use WinRT clipboard API on Windows --- packages/opencode/src/cli/cmd/tui/util/clipboard.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index 1a8197bf4e8..5dca3f5cd6f 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -126,7 +126,7 @@ export namespace Clipboard { } if (os === "win32") { - console.log("clipboard: using powershell") + console.log("clipboard: using windows runtime") return async (text: string) => { // Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.) const proc = Process.spawn( @@ -135,7 +135,7 @@ export namespace Clipboard { "-NonInteractive", "-NoProfile", "-Command", - "[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())", + "[Console]::InputEncoding = [System.Text.Encoding]::UTF8; $null = [Windows.ApplicationModel.DataTransfer.DataPackage, Windows.ApplicationModel.DataTransfer, ContentType=WindowsRuntime]; $null = [Windows.ApplicationModel.DataTransfer.Clipboard, Windows.ApplicationModel.DataTransfer, ContentType=WindowsRuntime]; $pkg = New-Object Windows.ApplicationModel.DataTransfer.DataPackage; $pkg.SetText([Console]::In.ReadToEnd()); [Windows.ApplicationModel.DataTransfer.Clipboard]::SetContent($pkg); [Windows.ApplicationModel.DataTransfer.Clipboard]::Flush()", ], { stdin: "pipe", From 90345c57e1479a160de55b61094d9edc4f9286ae Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 27 Feb 2026 21:20:41 -0500 Subject: [PATCH 09/76] tweak(ui): shimmering titles and animated counts --- bun.lock | 1 - .../src/pages/session/message-timeline.tsx | 407 +++++++++--------- .../ui/src/components/animated-number.tsx | 2 +- packages/ui/src/components/basic-tool.tsx | 4 +- packages/ui/src/components/message-part.tsx | 38 +- .../src/components/text-shimmer.stories.tsx | 38 +- packages/ui/src/components/text-shimmer.tsx | 24 +- .../components/tool-count-summary.stories.tsx | 101 ++++- .../ui/src/components/tool-status-title.tsx | 7 +- 9 files changed, 355 insertions(+), 267 deletions(-) diff --git a/bun.lock b/bun.lock index c3b108a0e2f..d97e378bda7 100644 --- a/bun.lock +++ b/bun.lock @@ -459,7 +459,6 @@ "@storybook/addon-links": "^10.2.13", "@storybook/addon-onboarding": "^10.2.13", "@storybook/addon-vitest": "^10.2.13", - "@tailwindcss/vite": "catalog:", "@tsconfig/node22": "catalog:", "@types/node": "catalog:", "@types/react": "18.0.25", diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 433c36e2e61..ae05da9cbad 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -550,228 +550,215 @@ export function MessageTimeline(props: { "--sticky-accordion-top": showHeader() ? "48px" : "0px", }} > -
- -
-
-
- - +
+
+
+ + + + + + {titleValue()} + + } + > + { + titleRef = el + }} + value={title.draft} + disabled={title.saving} + class="text-14-medium text-text-strong grow-1 min-w-0 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} /> - - - {titleValue()} - - } + +
+ + {(id) => ( +
+ + setTitle("menuOpen", open)} > - { - 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} + - - -
- - {(id) => ( -
- - setTitle("menuOpen", open)} - > - - - { - if (!title.pendingRename) return - event.preventDefault() - setTitle("pendingRename", false) - openTitleEditor() + + { + if (!title.pendingRename) return + event.preventDefault() + setTitle("pendingRename", false) + openTitleEditor() + }} + > + { + setTitle("pendingRename", true) + setTitle("menuOpen", false) }} > - { - setTitle("pendingRename", true) - setTitle("menuOpen", false) - }} - > - {language.t("common.rename")} - - void archiveSession(id())}> - {language.t("common.archive")} - - - dialog.show(() => )} - > - {language.t("common.delete")} - - - - -
- )} -
-
+ {language.t("common.rename")} + + void archiveSession(id())}> + {language.t("common.archive")} + + + dialog.show(() => )} + > + {language.t("common.delete")} + + + + +
+ )} +
+
+
+ + +
+ 0 || props.historyMore}> +
+
- -
- 0 || props.historyMore}> -
- -
-
- - {(messageID) => { - const active = createMemo(() => activeMessageID() === messageID) - const queued = createMemo(() => { - if (active()) return false - const activeID = activeMessageID() - if (activeID) return messageID > activeID - return false - }) - const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], { - equals: (a, b) => JSON.stringify(a) === JSON.stringify(b), - }) - const commentCount = createMemo(() => comments().length) - return ( -
{ - props.onRegisterMessage(el, messageID) - onCleanup(() => props.onUnregisterMessage(messageID)) - }} - classList={{ - "min-w-0 w-full max-w-full": true, - "md:max-w-200 2xl:max-w-[1000px]": props.centered, - }} - > - 0}> -
-
-
- - {(commentAccessor: () => MessageComment) => { - const comment = createMemo(() => commentAccessor()) - return ( -
-
- - {getFilename(comment().path)} - - {(selection) => ( - - {selection().startLine === selection().endLine - ? `:${selection().startLine}` - : `:${selection().startLine}-${selection().endLine}`} - - )} - -
-
- {comment().comment} -
+ 0}> +
+
+
+ + {(commentAccessor: () => MessageComment) => { + const comment = createMemo(() => commentAccessor()) + return ( +
+
+ + {getFilename(comment().path)} + + {(selection) => ( + + {selection().startLine === selection().endLine + ? `:${selection().startLine}` + : `:${selection().startLine}-${selection().endLine}`} + + )} + +
+
+ {comment().comment}
- ) - }} - -
+
+ ) + }} +
-
- -
- ) - }} - -
+
+ + +
+ ) + }} +
diff --git a/packages/ui/src/components/animated-number.tsx b/packages/ui/src/components/animated-number.tsx index b5fceba2563..00bb876cb27 100644 --- a/packages/ui/src/components/animated-number.tsx +++ b/packages/ui/src/components/animated-number.tsx @@ -1,7 +1,7 @@ import { For, Index, createEffect, createMemo, createSignal, on } from "solid-js" const TRACK = Array.from({ length: 30 }, (_, index) => index % 10) -const DURATION = 600 +const DURATION = 700 function normalize(value: number) { return ((value % 10) + 10) % 10 diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index fff6e92f17c..6f70d19154a 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -132,7 +132,9 @@ export function BasicTool(props: BasicToolProps) { [trigger().titleClass ?? ""]: !!trigger().titleClass, }} > - + + + diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index aecdbc8e41f..be4c249c5c0 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -51,40 +51,6 @@ import { IconButton } from "./icon-button" import { TextShimmer } from "./text-shimmer" import { AnimatedCountList } from "./tool-count-summary" import { ToolStatusTitle } from "./tool-status-title" -import { animate } from "motion" - -function ShellSubmessage(props: { text: string; animate?: boolean }) { - let widthRef: HTMLSpanElement | undefined - let valueRef: HTMLSpanElement | undefined - - onMount(() => { - if (!props.animate) return - requestAnimationFrame(() => { - if (widthRef) { - animate(widthRef, { width: "auto" }, { type: "spring", visualDuration: 0.25, bounce: 0 }) - } - if (valueRef) { - animate(valueRef, { opacity: 1, filter: "blur(0px)" }, { duration: 0.32, ease: [0.16, 1, 0.3, 1] }) - } - }) - }) - - return ( - - - - - {props.text} - - - - - ) -} interface Diagnostic { range: { @@ -776,7 +742,9 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
- + + + {trigger().subtitle} diff --git a/packages/ui/src/components/text-shimmer.stories.tsx b/packages/ui/src/components/text-shimmer.stories.tsx index a88a7158b11..fd9542e4266 100644 --- a/packages/ui/src/components/text-shimmer.stories.tsx +++ b/packages/ui/src/components/text-shimmer.stories.tsx @@ -10,14 +10,16 @@ Use for pending states inside buttons or list rows. ### API - Required: \`text\` string. -- Optional: \`as\`, \`active\`, \`offset\`, \`class\`. +- Optional: \`as\`, \`active\`, \`stepMs\`, \`durationMs\`, \`swapMs\`, \`offset\`. +- Sweep controls: \`spread\` (band width), \`size\` (travel span), \`angle\`. +- Color controls: \`base\`, \`peak\`. ### Variants and states - Active/inactive state via \`active\`. ### Behavior - Uses a moving gradient sweep clipped to text. -- \`offset\` lets multiple shimmers run out-of-phase. +- \`offset\` and \`stepMs\` let multiple shimmers run out-of-phase. ### Accessibility - Uses \`aria-label\` with the full text. @@ -31,6 +33,12 @@ const defaults = { text: "Loading...", active: true, class: "text-14-medium text-text-strong", + durationMs: 1200, + stepMs: 45, + swapMs: 220, + spread: 5.2, + size: 360, + angle: 90, offset: 0, } as const @@ -46,7 +54,15 @@ export default { text: { control: "text" }, class: { control: "text" }, active: { control: "boolean" }, + durationMs: { control: { type: "range", min: 400, max: 4000, step: 50 } }, + stepMs: { control: { type: "range", min: 0, max: 200, step: 5 } }, + swapMs: { control: { type: "range", min: 0, max: 800, step: 10 } }, + spread: { control: { type: "range", min: 0.3, max: 6, step: 0.1 } }, + size: { control: { type: "range", min: 120, max: 400, step: 5 } }, + angle: { control: { type: "range", min: 0, max: 180, step: 1 } }, offset: { control: { type: "range", min: 0, max: 80, step: 1 } }, + base: { control: "text" }, + peak: { control: "text" }, }, parameters: { docs: { @@ -90,3 +106,21 @@ export const Inactive = { active: false, }, } + +export const CustomTiming = { + args: { + text: "Custom timing", + stepMs: 80, + durationMs: 1800, + }, +} + +export const CustomSweep = { + args: { + text: "Custom sweep", + spread: 2.8, + size: 280, + angle: 96, + durationMs: 1600, + }, +} diff --git a/packages/ui/src/components/text-shimmer.tsx b/packages/ui/src/components/text-shimmer.tsx index c4c20b8e768..684abfc0082 100644 --- a/packages/ui/src/components/text-shimmer.tsx +++ b/packages/ui/src/components/text-shimmer.tsx @@ -7,11 +7,22 @@ export const TextShimmer = (props: { as?: T active?: boolean offset?: number + stepMs?: number + durationMs?: number + swapMs?: number + spread?: number + size?: number + angle?: number + base?: string + peak?: string }) => { const active = createMemo(() => props.active ?? true) const offset = createMemo(() => props.offset ?? 0) + const swap = createMemo(() => props.swapMs ?? 220) + const spread = createMemo(() => props.spread ?? 5.2) + const size = createMemo(() => props.size ?? 360) + const angle = createMemo(() => props.angle ?? 90) const [run, setRun] = createSignal(active()) - const swap = 220 let timer: ReturnType | undefined createEffect(() => { @@ -28,7 +39,7 @@ export const TextShimmer = (props: { timer = setTimeout(() => { timer = undefined setRun(false) - }, swap) + }, swap()) }) onCleanup(() => { @@ -44,8 +55,15 @@ export const TextShimmer = (props: { class={props.class} aria-label={props.text} style={{ - "--text-shimmer-swap": `${swap}ms`, + "--text-shimmer-step": `${props.stepMs ?? 45}ms`, + "--text-shimmer-duration": `${props.durationMs ?? 1200}ms`, + "--text-shimmer-swap": `${swap()}ms`, "--text-shimmer-index": `${offset()}`, + "--text-shimmer-spread": `${spread()}ch`, + "--text-shimmer-size": `${size()}%`, + "--text-shimmer-angle": `${angle()}deg`, + "--text-shimmer-base-color": props.base ?? "var(--text-weak)", + "--text-shimmer-peak-color": props.peak ?? "var(--text-strong)", }} > diff --git a/packages/ui/src/components/tool-count-summary.stories.tsx b/packages/ui/src/components/tool-count-summary.stories.tsx index 4be3a02bbec..2a0cd0c12d1 100644 --- a/packages/ui/src/components/tool-count-summary.stories.tsx +++ b/packages/ui/src/components/tool-count-summary.stories.tsx @@ -1,5 +1,5 @@ // @ts-nocheck -import { createSignal, onCleanup } from "solid-js" +import { createSignal, onCleanup, For } from "solid-js" import { AnimatedCountList, type CountItem } from "./tool-count-summary" import { ToolStatusTitle } from "./tool-status-title" @@ -21,14 +21,75 @@ as it appears in the context tool group on the session page.`, }, } -const TEXT = { - active: "Exploring", - done: "Explored", - read: { one: "{{count}} read", other: "{{count}} reads" }, - search: { one: "{{count}} search", other: "{{count}} searches" }, - list: { one: "{{count}} list", other: "{{count}} lists" }, +const LOCALES = { + en: { + label: "English", + active: "Exploring", + done: "Explored", + read: { one: "{{count}} read", other: "{{count}} reads" }, + search: { one: "{{count}} search", other: "{{count}} searches" }, + list: { one: "{{count}} list", other: "{{count}} lists" }, + }, + fr: { + label: "Français", + active: "Exploration", + done: "Exploré", + read: { one: "{{count}} lecture", other: "{{count}} lectures" }, + search: { one: "{{count}} recherche", other: "{{count}} recherches" }, + list: { one: "{{count}} liste", other: "{{count}} listes" }, + }, + ja: { + label: "日本語", + active: "探索中", + done: "探索済み", + read: { one: "{{count}} 件の読み取り", other: "{{count}} 件の読み取り" }, + search: { one: "{{count}} 件の検索", other: "{{count}} 件の検索" }, + list: { one: "{{count}} 件のリスト", other: "{{count}} 件のリスト" }, + }, + ko: { + label: "한국어", + active: "탐색 중", + done: "탐색됨", + read: { one: "{{count}}개 읽음", other: "{{count}}개 읽음" }, + search: { one: "{{count}}개 검색", other: "{{count}}개 검색" }, + list: { one: "{{count}}개 목록", other: "{{count}}개 목록" }, + }, + de: { + label: "Deutsch", + active: "Erkunden", + done: "Erkundet", + read: { one: "{{count}} Lesevorgang", other: "{{count}} Lesevorgänge" }, + search: { one: "{{count}} Suche", other: "{{count}} Suchen" }, + list: { one: "{{count}} Liste", other: "{{count}} Listen" }, + }, + es: { + label: "Español", + active: "Explorando", + done: "Explorado", + read: { one: "{{count}} lectura", other: "{{count}} lecturas" }, + search: { one: "{{count}} búsqueda", other: "{{count}} búsquedas" }, + list: { one: "{{count}} lista", other: "{{count}} listas" }, + }, + th: { + label: "ไทย", + active: "กำลังสำรวจ", + done: "สำรวจแล้ว", + read: { one: "อ่าน {{count}} รายการ", other: "อ่าน {{count}} รายการ" }, + search: { one: "ค้นหา {{count}} รายการ", other: "ค้นหา {{count}} รายการ" }, + list: { one: "รายการ {{count}} รายการ", other: "รายการ {{count}} รายการ" }, + }, + ar: { + label: "العربية", + active: "استكشاف", + done: "تم الاستكشاف", + read: { one: "{{count}} قراءة", other: "{{count}} قراءات" }, + search: { one: "{{count}} بحث", other: "{{count}} عمليات بحث" }, + list: { one: "{{count}} قائمة", other: "{{count}} قوائم" }, + }, } as const +type LocaleKey = keyof typeof LOCALES + function rand(min: number, max: number) { return Math.floor(Math.random() * (max - min + 1)) + min } @@ -61,8 +122,11 @@ export const Playground = { const [searches, setSearches] = createSignal(0) const [lists, setLists] = createSignal(0) const [active, setActive] = createSignal(false) + const [locale, setLocale] = createSignal("en") const [reducedMotion, setReducedMotion] = createSignal(false) + const l = () => LOCALES[locale()] + let timeouts: ReturnType[] = [] const clearAll = () => { @@ -110,9 +174,9 @@ export const Playground = { } const items = (): CountItem[] => [ - { key: "read", count: reads(), one: TEXT.read.one, other: TEXT.read.other }, - { key: "search", count: searches(), one: TEXT.search.one, other: TEXT.search.other }, - { key: "list", count: lists(), one: TEXT.list.one, other: TEXT.list.other }, + { key: "read", count: reads(), one: l().read.one, other: l().read.other }, + { key: "search", count: searches(), one: l().search.one, other: l().search.other }, + { key: "list", count: lists(), one: l().list.one, other: l().list.other }, ] return ( @@ -141,7 +205,7 @@ export const Playground = { }} > - + + {/* Language picker */} +
+ + {(key) => ( + + )} + +
+
) diff --git a/packages/ui/src/components/tool-status-title.tsx b/packages/ui/src/components/tool-status-title.tsx index 68440b6c637..4cf8f15abd1 100644 --- a/packages/ui/src/components/tool-status-title.tsx +++ b/packages/ui/src/components/tool-status-title.tsx @@ -72,7 +72,12 @@ export function ToolStatusTitle(props: { }) } - createEffect(on([() => props.active, activeTail, doneTail, suffix], () => schedule())) + createEffect( + on( + [() => props.active, activeTail, doneTail, suffix], + () => schedule(), + ), + ) onMount(() => { measure() From 33b6ba68fcf0159852952872681fc541297a7e36 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 27 Feb 2026 21:31:03 -0500 Subject: [PATCH 10/76] refactor(ui): simplify text shimmer API and story controls --- .../src/components/text-shimmer.stories.tsx | 38 +------------------ packages/ui/src/components/text-shimmer.tsx | 24 ++---------- 2 files changed, 5 insertions(+), 57 deletions(-) diff --git a/packages/ui/src/components/text-shimmer.stories.tsx b/packages/ui/src/components/text-shimmer.stories.tsx index fd9542e4266..a88a7158b11 100644 --- a/packages/ui/src/components/text-shimmer.stories.tsx +++ b/packages/ui/src/components/text-shimmer.stories.tsx @@ -10,16 +10,14 @@ Use for pending states inside buttons or list rows. ### API - Required: \`text\` string. -- Optional: \`as\`, \`active\`, \`stepMs\`, \`durationMs\`, \`swapMs\`, \`offset\`. -- Sweep controls: \`spread\` (band width), \`size\` (travel span), \`angle\`. -- Color controls: \`base\`, \`peak\`. +- Optional: \`as\`, \`active\`, \`offset\`, \`class\`. ### Variants and states - Active/inactive state via \`active\`. ### Behavior - Uses a moving gradient sweep clipped to text. -- \`offset\` and \`stepMs\` let multiple shimmers run out-of-phase. +- \`offset\` lets multiple shimmers run out-of-phase. ### Accessibility - Uses \`aria-label\` with the full text. @@ -33,12 +31,6 @@ const defaults = { text: "Loading...", active: true, class: "text-14-medium text-text-strong", - durationMs: 1200, - stepMs: 45, - swapMs: 220, - spread: 5.2, - size: 360, - angle: 90, offset: 0, } as const @@ -54,15 +46,7 @@ export default { text: { control: "text" }, class: { control: "text" }, active: { control: "boolean" }, - durationMs: { control: { type: "range", min: 400, max: 4000, step: 50 } }, - stepMs: { control: { type: "range", min: 0, max: 200, step: 5 } }, - swapMs: { control: { type: "range", min: 0, max: 800, step: 10 } }, - spread: { control: { type: "range", min: 0.3, max: 6, step: 0.1 } }, - size: { control: { type: "range", min: 120, max: 400, step: 5 } }, - angle: { control: { type: "range", min: 0, max: 180, step: 1 } }, offset: { control: { type: "range", min: 0, max: 80, step: 1 } }, - base: { control: "text" }, - peak: { control: "text" }, }, parameters: { docs: { @@ -106,21 +90,3 @@ export const Inactive = { active: false, }, } - -export const CustomTiming = { - args: { - text: "Custom timing", - stepMs: 80, - durationMs: 1800, - }, -} - -export const CustomSweep = { - args: { - text: "Custom sweep", - spread: 2.8, - size: 280, - angle: 96, - durationMs: 1600, - }, -} diff --git a/packages/ui/src/components/text-shimmer.tsx b/packages/ui/src/components/text-shimmer.tsx index 684abfc0082..c4c20b8e768 100644 --- a/packages/ui/src/components/text-shimmer.tsx +++ b/packages/ui/src/components/text-shimmer.tsx @@ -7,22 +7,11 @@ export const TextShimmer = (props: { as?: T active?: boolean offset?: number - stepMs?: number - durationMs?: number - swapMs?: number - spread?: number - size?: number - angle?: number - base?: string - peak?: string }) => { const active = createMemo(() => props.active ?? true) const offset = createMemo(() => props.offset ?? 0) - const swap = createMemo(() => props.swapMs ?? 220) - const spread = createMemo(() => props.spread ?? 5.2) - const size = createMemo(() => props.size ?? 360) - const angle = createMemo(() => props.angle ?? 90) const [run, setRun] = createSignal(active()) + const swap = 220 let timer: ReturnType | undefined createEffect(() => { @@ -39,7 +28,7 @@ export const TextShimmer = (props: { timer = setTimeout(() => { timer = undefined setRun(false) - }, swap()) + }, swap) }) onCleanup(() => { @@ -55,15 +44,8 @@ export const TextShimmer = (props: { class={props.class} aria-label={props.text} style={{ - "--text-shimmer-step": `${props.stepMs ?? 45}ms`, - "--text-shimmer-duration": `${props.durationMs ?? 1200}ms`, - "--text-shimmer-swap": `${swap()}ms`, + "--text-shimmer-swap": `${swap}ms`, "--text-shimmer-index": `${offset()}`, - "--text-shimmer-spread": `${spread()}ch`, - "--text-shimmer-size": `${size()}%`, - "--text-shimmer-angle": `${angle()}deg`, - "--text-shimmer-base-color": props.base ?? "var(--text-weak)", - "--text-shimmer-peak-color": props.peak ?? "var(--text-strong)", }} > From 0ad0d84402b1de1e7c83270ba0d46835e39e5b37 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 27 Feb 2026 21:40:37 -0500 Subject: [PATCH 11/76] chore(storybook): simplify animated count story locales --- .../components/tool-count-summary.stories.tsx | 101 +++--------------- 1 file changed, 13 insertions(+), 88 deletions(-) diff --git a/packages/ui/src/components/tool-count-summary.stories.tsx b/packages/ui/src/components/tool-count-summary.stories.tsx index 2a0cd0c12d1..4be3a02bbec 100644 --- a/packages/ui/src/components/tool-count-summary.stories.tsx +++ b/packages/ui/src/components/tool-count-summary.stories.tsx @@ -1,5 +1,5 @@ // @ts-nocheck -import { createSignal, onCleanup, For } from "solid-js" +import { createSignal, onCleanup } from "solid-js" import { AnimatedCountList, type CountItem } from "./tool-count-summary" import { ToolStatusTitle } from "./tool-status-title" @@ -21,75 +21,14 @@ as it appears in the context tool group on the session page.`, }, } -const LOCALES = { - en: { - label: "English", - active: "Exploring", - done: "Explored", - read: { one: "{{count}} read", other: "{{count}} reads" }, - search: { one: "{{count}} search", other: "{{count}} searches" }, - list: { one: "{{count}} list", other: "{{count}} lists" }, - }, - fr: { - label: "Français", - active: "Exploration", - done: "Exploré", - read: { one: "{{count}} lecture", other: "{{count}} lectures" }, - search: { one: "{{count}} recherche", other: "{{count}} recherches" }, - list: { one: "{{count}} liste", other: "{{count}} listes" }, - }, - ja: { - label: "日本語", - active: "探索中", - done: "探索済み", - read: { one: "{{count}} 件の読み取り", other: "{{count}} 件の読み取り" }, - search: { one: "{{count}} 件の検索", other: "{{count}} 件の検索" }, - list: { one: "{{count}} 件のリスト", other: "{{count}} 件のリスト" }, - }, - ko: { - label: "한국어", - active: "탐색 중", - done: "탐색됨", - read: { one: "{{count}}개 읽음", other: "{{count}}개 읽음" }, - search: { one: "{{count}}개 검색", other: "{{count}}개 검색" }, - list: { one: "{{count}}개 목록", other: "{{count}}개 목록" }, - }, - de: { - label: "Deutsch", - active: "Erkunden", - done: "Erkundet", - read: { one: "{{count}} Lesevorgang", other: "{{count}} Lesevorgänge" }, - search: { one: "{{count}} Suche", other: "{{count}} Suchen" }, - list: { one: "{{count}} Liste", other: "{{count}} Listen" }, - }, - es: { - label: "Español", - active: "Explorando", - done: "Explorado", - read: { one: "{{count}} lectura", other: "{{count}} lecturas" }, - search: { one: "{{count}} búsqueda", other: "{{count}} búsquedas" }, - list: { one: "{{count}} lista", other: "{{count}} listas" }, - }, - th: { - label: "ไทย", - active: "กำลังสำรวจ", - done: "สำรวจแล้ว", - read: { one: "อ่าน {{count}} รายการ", other: "อ่าน {{count}} รายการ" }, - search: { one: "ค้นหา {{count}} รายการ", other: "ค้นหา {{count}} รายการ" }, - list: { one: "รายการ {{count}} รายการ", other: "รายการ {{count}} รายการ" }, - }, - ar: { - label: "العربية", - active: "استكشاف", - done: "تم الاستكشاف", - read: { one: "{{count}} قراءة", other: "{{count}} قراءات" }, - search: { one: "{{count}} بحث", other: "{{count}} عمليات بحث" }, - list: { one: "{{count}} قائمة", other: "{{count}} قوائم" }, - }, +const TEXT = { + active: "Exploring", + done: "Explored", + read: { one: "{{count}} read", other: "{{count}} reads" }, + search: { one: "{{count}} search", other: "{{count}} searches" }, + list: { one: "{{count}} list", other: "{{count}} lists" }, } as const -type LocaleKey = keyof typeof LOCALES - function rand(min: number, max: number) { return Math.floor(Math.random() * (max - min + 1)) + min } @@ -122,11 +61,8 @@ export const Playground = { const [searches, setSearches] = createSignal(0) const [lists, setLists] = createSignal(0) const [active, setActive] = createSignal(false) - const [locale, setLocale] = createSignal("en") const [reducedMotion, setReducedMotion] = createSignal(false) - const l = () => LOCALES[locale()] - let timeouts: ReturnType[] = [] const clearAll = () => { @@ -174,9 +110,9 @@ export const Playground = { } const items = (): CountItem[] => [ - { key: "read", count: reads(), one: l().read.one, other: l().read.other }, - { key: "search", count: searches(), one: l().search.one, other: l().search.other }, - { key: "list", count: lists(), one: l().list.one, other: l().list.other }, + { key: "read", count: reads(), one: TEXT.read.one, other: TEXT.read.other }, + { key: "search", count: searches(), one: TEXT.search.one, other: TEXT.search.other }, + { key: "list", count: lists(), one: TEXT.list.one, other: TEXT.list.other }, ] return ( @@ -205,7 +141,7 @@ export const Playground = { }} > - + - {/* Language picker */} -
- - {(key) => ( - - )} - -
-
) From 9d53b3a22134e00fea202b49d9f2a01d99d7083e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 1 Mar 2026 19:48:19 -0500 Subject: [PATCH 12/76] wip: checkpoint todo panel + odometer motion work --- bun.lock | 1 + .../composer/session-composer-region.tsx | 11 +- .../composer/session-composer-state.ts | 6 +- .../ui/src/components/animated-number.tsx | 2 +- packages/ui/src/components/message-part.tsx | 23 +- packages/ui/src/components/motion-spring.tsx | 4 +- packages/ui/src/components/session-turn.tsx | 8 +- .../shell-submessage-motion.stories.tsx | 125 +++---- .../ui/src/components/shell-submessage.css | 30 +- packages/ui/src/components/text-odometer.css | 89 +++++ .../src/components/text-odometer.stories.tsx | 326 ++++++++++++++++++ packages/ui/src/components/text-odometer.tsx | 157 +++++++++ packages/ui/src/components/text-reveal.css | 28 +- packages/ui/src/components/text-reveal.tsx | 9 +- .../components/thinking-heading.stories.tsx | 24 +- .../components/todo-panel-motion.stories.tsx | 55 +-- packages/ui/src/styles/index.css | 2 +- 17 files changed, 695 insertions(+), 205 deletions(-) create mode 100644 packages/ui/src/components/text-odometer.css create mode 100644 packages/ui/src/components/text-odometer.stories.tsx create mode 100644 packages/ui/src/components/text-odometer.tsx diff --git a/bun.lock b/bun.lock index d97e378bda7..c3b108a0e2f 100644 --- a/bun.lock +++ b/bun.lock @@ -459,6 +459,7 @@ "@storybook/addon-links": "^10.2.13", "@storybook/addon-onboarding": "^10.2.13", "@storybook/addon-vitest": "^10.2.13", + "@tailwindcss/vite": "catalog:", "@tsconfig/node22": "catalog:", "@types/node": "catalog:", "@types/react": "18.0.25", diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 93ea3d465c5..a15d28f667b 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -35,9 +35,6 @@ export function SessionComposerRegion(props: { subtitleTravel?: number subtitleEdge?: number countDuration?: number - countMask?: number - countMaskHeight?: number - countWidthDuration?: number }) { const params = useParams() const prompt = usePrompt() @@ -112,7 +109,10 @@ export function SessionComposerRegion(props: { bounce: props.dockCloseBounce ?? props.bounce ?? 0, }, ) - const progress = useSpring(() => (open() ? 1 : 0), config) + const progress = useSpring( + () => (open() ? 1 : 0), + config, + ) const value = createMemo(() => Math.max(0, Math.min(1, progress()))) const [height, setHeight] = createSignal(320) const dock = createMemo(() => (gate.ready && props.state.dock()) || value() > 0.001) @@ -202,9 +202,6 @@ export function SessionComposerRegion(props: { subtitleTravel={props.subtitleTravel} subtitleEdge={props.subtitleEdge} countDuration={props.countDuration} - countMask={props.countMask} - countMaskHeight={props.countMaskHeight} - countWidthDuration={props.countWidthDuration} />
diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts index f70bc4bbdd0..03e96463c1d 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -29,7 +29,11 @@ export function createSessionComposerBlocked() { }) } -export function createSessionComposerState(options?: { closeMs?: number | (() => number) }) { +export function createSessionComposerState( + options?: { + closeMs?: number | (() => number) + }, +) { const params = useParams() const sdk = useSDK() const sync = useSync() diff --git a/packages/ui/src/components/animated-number.tsx b/packages/ui/src/components/animated-number.tsx index 00bb876cb27..b5fceba2563 100644 --- a/packages/ui/src/components/animated-number.tsx +++ b/packages/ui/src/components/animated-number.tsx @@ -1,7 +1,7 @@ import { For, Index, createEffect, createMemo, createSignal, on } from "solid-js" const TRACK = Array.from({ length: 30 }, (_, index) => index % 10) -const DURATION = 700 +const DURATION = 600 function normalize(value: number) { return ((value % 10) + 10) % 10 diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index be4c249c5c0..69f3effbc6c 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -52,6 +52,22 @@ import { TextShimmer } from "./text-shimmer" import { AnimatedCountList } from "./tool-count-summary" import { ToolStatusTitle } from "./tool-status-title" +function ShellSubmessage(props: { text: string }) { + let ref: HTMLSpanElement | undefined + onMount(() => { + requestAnimationFrame(() => ref?.setAttribute("data-visible", "")) + }) + return ( + + + + {props.text} + + + + ) +} + interface Diagnostic { range: { start: { line: number; character: number } @@ -1502,7 +1518,6 @@ ToolRegistry.register({ render(props) { const i18n = useI18n() const pending = () => props.status === "pending" || props.status === "running" - const sawPending = pending() const text = createMemo(() => { const cmd = props.input.command ?? props.metadata.command ?? "" const out = stripAnsi(props.output || props.metadata.output || "") @@ -1526,10 +1541,12 @@ ToolRegistry.register({
- + + + - +
diff --git a/packages/ui/src/components/motion-spring.tsx b/packages/ui/src/components/motion-spring.tsx index a5104a1a3ef..f674f43db45 100644 --- a/packages/ui/src/components/motion-spring.tsx +++ b/packages/ui/src/components/motion-spring.tsx @@ -2,7 +2,7 @@ import { attachSpring, motionValue } from "motion" import type { SpringOptions } from "motion" import { createEffect, createSignal, onCleanup } from "solid-js" -type Opt = Partial> +type Opt = Pick const eq = (a: Opt | undefined, b: Opt | undefined) => a?.visualDuration === b?.visualDuration && a?.bounce === b?.bounce && @@ -18,7 +18,7 @@ export function useSpring(target: () => number, options?: Opt | (() => Opt)) { const spring = motionValue(value()) let config = read() let stop = attachSpring(spring, source, config) - let off = spring.on("change", (next: number) => setValue(next)) + let off = spring.on("change", (next) => setValue(next)) createEffect(() => { source.set(target()) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index a8a41b8ef41..1178d2e8c9a 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -15,7 +15,6 @@ import { Collapsible } from "./collapsible" import { DiffChanges } from "./diff-changes" import { Icon } from "./icon" import { TextShimmer } from "./text-shimmer" -import { SessionRetry } from "./session-retry" import { TextReveal } from "./text-reveal" import { createAutoScroll } from "../hooks" import { useI18n } from "../context/i18n" @@ -422,12 +421,7 @@ export function SessionTurn(
- +
diff --git a/packages/ui/src/components/shell-submessage-motion.stories.tsx b/packages/ui/src/components/shell-submessage-motion.stories.tsx index 1f53b6e4de3..0245fb77bae 100644 --- a/packages/ui/src/components/shell-submessage-motion.stories.tsx +++ b/packages/ui/src/components/shell-submessage-motion.stories.tsx @@ -1,7 +1,6 @@ // @ts-nocheck import { createEffect, createSignal, onCleanup } from "solid-js" import { BasicTool } from "./basic-tool" -import { animate } from "motion" export default { title: "UI/Shell Submessage Motion", @@ -18,7 +17,7 @@ Interactive playground for animating the Shell tool subtitle ("submessage") in t - Bash tool subtitle source: \`packages/ui/src/components/message-part.tsx\` (tool: \`bash\`, \`trigger.subtitle\`) ### What this playground tunes -- Width reveal (spring-driven pixel width via \`useSpring\`) +- Width reveal (grid 0fr \u2192 1fr, or instant) - Opacity fade - Blur settle`, }, @@ -61,33 +60,36 @@ const shellCss = ` [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; + display: grid; + grid-template-columns: 0fr; overflow: hidden; + transition: grid-template-columns var(--shell-sub-width-ms, 420ms) var(--shell-sub-width-ease, cubic-bezier(0.16, 1, 0.3, 1)); +} + +[data-component="shell-submessage"][data-visible="true"] [data-slot="shell-submessage-width"] { + grid-template-columns: 1fr; } [data-component="shell-submessage"] [data-slot="shell-submessage-value"] { display: inline-block; vertical-align: baseline; min-width: 0; - line-height: inherit; white-space: nowrap; opacity: 0; - filter: blur(var(--shell-sub-blur, 2px)); + filter: blur(var(--shell-sub-blur, 5px)); transition-property: opacity, filter; transition-duration: var(--shell-sub-fade-ms, 320ms); transition-timing-function: var(--shell-sub-fade-ease, cubic-bezier(0.22, 1, 0.36, 1)); } -[data-component="shell-submessage"][data-visible] [data-slot="shell-submessage-value"] { +[data-component="shell-submessage"][data-visible="true"] [data-slot="shell-submessage-value"] { opacity: 1; filter: blur(0px); } @@ -100,50 +102,15 @@ const ease = { linear: "linear", } -function SpringSubmessage(props: { text: string; visible: boolean; visualDuration: number; bounce: number }) { - let ref: HTMLSpanElement | undefined - let widthRef: HTMLSpanElement | undefined - - createEffect(() => { - if (!widthRef) return - if (props.visible) { - requestAnimationFrame(() => { - ref?.setAttribute("data-visible", "") - animate( - widthRef!, - { width: "auto" }, - { type: "spring", visualDuration: props.visualDuration, bounce: props.bounce }, - ) - }) - } else { - ref?.removeAttribute("data-visible") - animate( - widthRef, - { width: "0px" }, - { type: "spring", visualDuration: props.visualDuration, bounce: props.bounce }, - ) - } - }) - - return ( - - - - {props.text || "\u00A0"} - - - - ) -} - export const Playground = { render: () => { const [text, setText] = createSignal("Prints five topic blocks between timed commands") const [show, setShow] = createSignal(true) - const [visualDuration, setVisualDuration] = createSignal(0.35) - const [bounce, setBounce] = createSignal(0) + const [widthAnim, setWidthAnim] = createSignal(true) + const [widthMs, setWidthMs] = createSignal(420) const [fadeMs, setFadeMs] = createSignal(320) - const [blur, setBlur] = createSignal(2) + const [blur, setBlur] = createSignal(5) + const [widthEase, setWidthEase] = createSignal("smooth") const [fadeEase, setFadeEase] = createSignal("snappy") const [auto, setAuto] = createSignal(false) let replayTimer @@ -185,8 +152,10 @@ export const Playground = { gap: "20px", padding: "20px", "max-width": "860px", + "--shell-sub-width-ms": `${widthAnim() ? widthMs() : 0}ms`, "--shell-sub-fade-ms": `${fadeMs()}ms`, "--shell-sub-blur": `${blur()}px`, + "--shell-sub-width-ease": ease[widthEase()], "--shell-sub-fade-ease": ease[fadeEase()], }} > @@ -199,7 +168,16 @@ export const Playground = {
Shell - + + + + {text() || "\u00A0"} + + +
} @@ -258,29 +236,25 @@ export const Playground = {
- visualDuration - setVisualDuration(Number(e.currentTarget.value))} - /> - {visualDuration().toFixed(2)}s + width mode + + {widthAnim() ? "grid 0fr\u21921fr" : "instant"}
- bounce - setBounce(Number(e.currentTarget.value))} - /> - {bounce().toFixed(2)} + width ease +
@@ -297,6 +271,19 @@ export const Playground = {
+
+ width + setWidthMs(Number(e.currentTarget.value))} + /> + {widthMs()}ms +
+
fade + ({ + padding: "5px 12px", + "border-radius": "6px", + border: accent ? "1px solid var(--color-accent, #58f)" : "1px solid var(--color-divider, #333)", + background: accent ? "var(--color-accent, #58f)" : "var(--color-fill-element, #222)", + color: "var(--color-text, #eee)", + cursor: "pointer", + "font-size": "12px", + }) as const + +const sliderLabel = { + width: "90px", + "font-size": "12px", + color: "var(--color-text-secondary, #a3a3a3)", + "flex-shrink": "0", +} as const + +const cardStyle = { + padding: "20px 24px", + "border-radius": "10px", + border: "1px solid var(--color-divider, #333)", + background: "var(--color-fill-element, #1a1a1a)", + display: "grid", + gap: "12px", +} as const + +const cardLabel = { + "font-size": "11px", + "font-family": "monospace", + color: "var(--color-text-weak, #666)", +} as const + +const previewRow = { + display: "flex", + "align-items": "center", + gap: "8px", + "font-size": "14px", + "font-weight": "500", + "line-height": "20px", + color: "var(--text-weak, #aaa)", + "min-height": "20px", + overflow: "visible", +} as const + +const headingSlot = { + "min-width": "0", + overflow: "visible", + color: "var(--text-weaker, #888)", + "font-weight": "400", +} as const + +export const Playground = { + render: () => { + const [index, setIndex] = createSignal(0) + const [cycling, setCycling] = createSignal(false) + const [growOnly, setGrowOnly] = createSignal(true) + + // shared + const [duration, setDuration] = createSignal(600) + const [bounce, setBounce] = createSignal(1.0) + const [bounceSoft, setBounceSoft] = createSignal(1.0) + + // odometer-specific + const [travel, setTravel] = createSignal(4) + const [mask, setMask] = createSignal(12) + const [pad, setPad] = createSignal(9) + const [height, setHeight] = createSignal(0) + + // reveal-specific + const [edge, setEdge] = createSignal(17) + const [revealTravel, setRevealTravel] = createSignal(0) + + // hybrid-specific + const [hybridTravel, setHybridTravel] = createSignal(25) + const [hybridEdge, setHybridEdge] = createSignal(17) + + let timer: number | undefined + + const text = () => TEXTS[index()] + + const next = () => { + setIndex((i) => (i + 1) % TEXTS.length) + } + + const prev = () => { + setIndex((i) => (i - 1 + TEXTS.length) % TEXTS.length) + } + + const toggleCycle = () => { + if (cycling()) { + if (timer) clearTimeout(timer) + timer = undefined + setCycling(false) + return + } + setCycling(true) + const tick = () => { + next() + timer = window.setTimeout(tick, 700 + Math.floor(Math.random() * 600)) + } + timer = window.setTimeout(tick, 700 + Math.floor(Math.random() * 600)) + } + + onCleanup(() => { + if (timer) clearTimeout(timer) + }) + + const spring = () => `cubic-bezier(0.34, ${bounce()}, 0.64, 1)` + const springSoft = () => `cubic-bezier(0.34, ${bounceSoft()}, 0.64, 1)` + + return ( +
+ {/* ── preview cards ── */} +
+ {/* hybrid card — first */} +
+ text-reveal (mask wipe + slide) +
+ Thinking + + + +
+
+ + {/* odometer card */} +
+ text-odometer (slide through mask) +
+ Thinking + + + +
+
+ + {/* reveal card */} +
+ text-reveal (mask wipe only) +
+ Thinking + + + +
+
+
+ + {/* ── text selector chips ── */} +
+ {TEXTS.map((t, i) => ( + + ))} +
+ + {/* ── controls ── */} +
+ + + + +
+ + {/* ── sliders ── */} +
+ {/* ── hybrid sliders — first ── */} +
Hybrid (wipe + slide)
+ + + + + + {/* ── shared sliders ── */} +
Shared
+ + + + + + + + {/* ── odometer sliders ── */} +
Odometer
+ + + + + + + + + + {/* ── reveal sliders ── */} +
Reveal (wipe only)
+ + + + +
+ + {/* debug info */} +
+ text: {text() ?? "(none)"} · growOnly: {growOnly() ? "on" : "off"} +
+
+ ) + }, +} diff --git a/packages/ui/src/components/text-odometer.tsx b/packages/ui/src/components/text-odometer.tsx new file mode 100644 index 00000000000..a43698199b3 --- /dev/null +++ b/packages/ui/src/components/text-odometer.tsx @@ -0,0 +1,157 @@ +import { createEffect, createSignal, on, onCleanup, onMount } from "solid-js" + +const px = (value: number | string | undefined, fallback: number) => { + if (typeof value === "number") return `${value}px` + if (typeof value === "string") return value + return `${fallback}px` +} + +const ms = (value: number | string | undefined, fallback: number) => { + if (typeof value === "number") return `${value}ms` + if (typeof value === "string") return value + return `${fallback}ms` +} + +export function TextOdometer(props: { + text?: string + class?: string + duration?: number | string + travel?: number | string + mask?: number | string + pad?: number | string + height?: number | string + line?: number | string + spring?: string + springSoft?: string + growOnly?: boolean +}) { + const [cur, setCur] = createSignal(props.text) + const [old, setOld] = createSignal() + const [width, setWidth] = createSignal("auto") + const [ready, setReady] = createSignal(false) + const [swapping, setSwapping] = createSignal(false) + const [fit, setFit] = createSignal({ + line: 20, + travel: 4, + mask: 12, + pad: 9, + height: 0, + }) + let inRef: HTMLSpanElement | undefined + 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) { + const prev = Number.parseFloat(width()) + if (Number.isFinite(prev) && next <= prev) return + } + setWidth(`${next}px`) + } + + const refine = () => { + const el = rootRef + if (!el || typeof window === "undefined") return + const style = window.getComputedStyle(el) + const font = Number.parseFloat(style.fontSize) + const line = Number.parseFloat(style.lineHeight) + const unit = Number.isFinite(font) ? font : 14 + const high = Number.isFinite(line) ? line : unit * 1.43 + const travel = Math.max(2, Math.round(high * 0.2)) + const mask = Math.max(2, Math.round(high * 0.6)) + const pad = Math.max(4, Math.round(high * 0.45)) + const height = Math.max(0, Math.round((high - 20) * 0.25)) + setFit({ line: high, travel, mask, pad, height }) + } + + createEffect( + on( + () => props.text, + (next, prev) => { + if (next === prev) return + setSwapping(true) + setOld(prev) + setCur(next) + + if (typeof requestAnimationFrame !== "function") { + widen(Math.max(win(), wout())) + rootRef?.offsetHeight + setSwapping(false) + return + } + if (frame !== undefined && typeof cancelAnimationFrame === "function") cancelAnimationFrame(frame) + frame = requestAnimationFrame(() => { + widen(Math.max(win(), wout())) + rootRef?.offsetHeight + setSwapping(false) + frame = undefined + }) + }, + ), + ) + + onMount(() => { + widen(win()) + refine() + const el = rootRef + if (el && typeof ResizeObserver !== "undefined") { + const observer = new ResizeObserver(refine) + observer.observe(el) + onCleanup(() => observer.disconnect()) + } + const fonts = typeof document !== "undefined" ? document.fonts : undefined + if (typeof requestAnimationFrame !== "function") { + setReady(true) + return + } + if (!fonts) { + requestAnimationFrame(() => setReady(true)) + return + } + fonts.ready.finally(() => { + widen(win()) + refine() + requestAnimationFrame(() => setReady(true)) + }) + }) + + onCleanup(() => { + if (frame === undefined || typeof cancelAnimationFrame !== "function") return + cancelAnimationFrame(frame) + }) + + return ( + + + + {cur() ?? "\u00A0"} + + + {old() ?? "\u00A0"} + + + + ) +} diff --git a/packages/ui/src/components/text-reveal.css b/packages/ui/src/components/text-reveal.css index f799962f094..5e1f6d461d2 100644 --- a/packages/ui/src/components/text-reveal.css +++ b/packages/ui/src/components/text-reveal.css @@ -67,10 +67,7 @@ -webkit-mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge))); mask-position: 0 100%; -webkit-mask-position: 0 100%; - transition-property: - mask-position, - -webkit-mask-position, - transform; + transition-property: mask-position, -webkit-mask-position, transform; transform: translateY(0); } @@ -85,10 +82,7 @@ -webkit-mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge))); mask-position: 0 100%; -webkit-mask-position: 0 100%; - transition-property: - mask-position, - -webkit-mask-position, - transform; + transition-property: mask-position, -webkit-mask-position, transform; transform: translateY(var(--_travel)); } @@ -118,24 +112,6 @@ &[data-ready="false"] [data-slot="text-reveal-leaving"] { transition-duration: 0ms !important; } - - &[data-truncate="true"] { - width: 100%; - } - - &[data-truncate="true"] [data-slot="text-reveal-track"] { - width: 100%; - min-width: 0; - overflow: hidden; - } - - &[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; - } } @media (prefers-reduced-motion: reduce) { diff --git a/packages/ui/src/components/text-reveal.tsx b/packages/ui/src/components/text-reveal.tsx index c4fe1302f0e..15bce03c36a 100644 --- a/packages/ui/src/components/text-reveal.tsx +++ b/packages/ui/src/components/text-reveal.tsx @@ -28,7 +28,6 @@ export function TextReveal(props: { spring?: string springSoft?: string growOnly?: boolean - truncate?: boolean }) { const [cur, setCur] = createSignal(props.text) const [old, setOld] = createSignal() @@ -57,11 +56,6 @@ export function TextReveal(props: { () => 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) @@ -111,7 +105,6 @@ export function TextReveal(props: { data-component="text-reveal" data-ready={ready() ? "true" : "false"} data-swapping={swapping() ? "true" : "false"} - data-truncate={props.truncate ? "true" : "false"} class={props.class} aria-label={props.text ?? ""} style={{ @@ -122,7 +115,7 @@ export function TextReveal(props: { "--text-reveal-spring-soft": props.springSoft ?? "cubic-bezier(0.34, 1, 0.64, 1)", }} > - + {cur() ?? "\u00A0"} diff --git a/packages/ui/src/components/thinking-heading.stories.tsx b/packages/ui/src/components/thinking-heading.stories.tsx index 90eb7ee3190..e6ef372af20 100644 --- a/packages/ui/src/components/thinking-heading.stories.tsx +++ b/packages/ui/src/components/thinking-heading.stories.tsx @@ -1,7 +1,6 @@ // @ts-nocheck import { createSignal, createEffect, on, onMount, onCleanup } from "solid-js" import { TextShimmer } from "./text-shimmer" -import { TextReveal } from "./text-reveal" export default { title: "UI/ThinkingHeading", @@ -13,8 +12,8 @@ export default { component: `### Overview Playground for animating the secondary heading beside "Thinking". -Uses TextReveal for the production heading animation with tunable -duration, travel, bounce, and fade controls.`, +Five spring-animated transition styles shown in a card grid with tunable +duration, blur, travel, bounce (spring overshoot), and odometer fade controls.`, }, }, }, @@ -544,7 +543,7 @@ const cardStyle = { // Variants // --------------------------------------------------------------------------- -const VARIANTS: { key: string; label: string }[] = [] +const VARIANTS = [{ key: "odometer", label: "odometer" }] // --------------------------------------------------------------------------- // Story @@ -633,23 +632,6 @@ export const Playground = { {/* ── Variant cards ─────────────────────────────────── */}
-
- TextReveal (production) - - - - - - -
{VARIANTS.map((v) => (
{v.label} diff --git a/packages/ui/src/components/todo-panel-motion.stories.tsx b/packages/ui/src/components/todo-panel-motion.stories.tsx index 39d34215783..651f8316d08 100644 --- a/packages/ui/src/components/todo-panel-motion.stories.tsx +++ b/packages/ui/src/components/todo-panel-motion.stories.tsx @@ -139,14 +139,11 @@ export const Playground = { const [drawerExpandBounce, setDrawerExpandBounce] = createSignal(0) const [drawerCollapseDuration, setDrawerCollapseDuration] = createSignal(0.3) const [drawerCollapseBounce, setDrawerCollapseBounce] = createSignal(0) - const [subtitleDuration, setSubtitleDuration] = createSignal(600) + const [subtitleDuration, setSubtitleDuration] = createSignal(700) const [subtitleAuto, setSubtitleAuto] = createSignal(true) const [subtitleTravel, setSubtitleTravel] = createSignal(25) const [subtitleEdge, setSubtitleEdge] = createSignal(17) const [countDuration, setCountDuration] = createSignal(600) - const [countMask, setCountMask] = createSignal(18) - const [countMaskHeight, setCountMaskHeight] = createSignal(0) - const [countWidthDuration, setCountWidthDuration] = createSignal(560) const state = createSessionComposerState({ closeMs: () => Math.round(dockCloseDuration() * 1000) }) let frame let composerRef @@ -268,9 +265,6 @@ export const Playground = { subtitleTravel={subtitleAuto() ? undefined : subtitleTravel()} subtitleEdge={subtitleAuto() ? undefined : subtitleEdge()} countDuration={countDuration()} - countMask={countMask()} - countMaskHeight={countMaskHeight()} - countWidthDuration={countWidthDuration()} />
@@ -530,53 +524,6 @@ export const Playground = { {Math.round(countDuration())}ms
- - -
) diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index cec42f5a0ca..bc369bdd48b 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -50,8 +50,8 @@ @import "../components/sticky-accordion-header.css" layer(components); @import "../components/tabs.css" layer(components); @import "../components/tag.css" layer(components); +@import "../components/text-odometer.css" layer(components); @import "../components/text-reveal.css" layer(components); -@import "../components/text-strikethrough.css" layer(components); @import "../components/text-shimmer.css" layer(components); @import "../components/tool-count-label.css" layer(components); @import "../components/tool-count-summary.css" layer(components); From 14f88ae8893cfd16ffd0963a5b94d8913723ef01 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 1 Mar 2026 20:39:43 -0500 Subject: [PATCH 13/76] feat(ui): spring animations for composer mode toggle and tray transitions Add spring-based animations to the prompt input composer: - Mode toggle (shell/conversation) indicator uses spring cubic-bezier - Submit and plus buttons animate with individual scale, opacity, and blur - Tray items (agent, model, variant selectors) crossfade with spring - Shell label animates in/out opposite to normal mode controls - Add TextStrikethrough component for todo item completion - Add truncate support to TextReveal - Wire up count mask/height/width props through composer region --- packages/app/src/components/prompt-input.tsx | 5 +- .../composer/session-composer-region.tsx | 6 ++ packages/ui/src/components/text-reveal.css | 18 ++++++ packages/ui/src/components/text-reveal.tsx | 4 +- .../components/todo-panel-motion.stories.tsx | 55 ++++++++++++++++++- packages/ui/src/styles/index.css | 1 + 6 files changed, 86 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index c9c8bc6b441..89169af0d4a 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -256,7 +256,10 @@ export const PromptInput: Component = (props) => { pendingAutoAccept: false, }) - const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 }) + const buttonsSpring = useSpring( + () => (store.mode === "normal" ? 1 : 0), + { visualDuration: 0.2, bounce: 0 }, + ) const commentCount = createMemo(() => { if (store.mode === "shell") return 0 diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index a15d28f667b..80e153723bd 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -35,6 +35,9 @@ export function SessionComposerRegion(props: { subtitleTravel?: number subtitleEdge?: number countDuration?: number + countMask?: number + countMaskHeight?: number + countWidthDuration?: number }) { const params = useParams() const prompt = usePrompt() @@ -202,6 +205,9 @@ export function SessionComposerRegion(props: { subtitleTravel={props.subtitleTravel} subtitleEdge={props.subtitleEdge} countDuration={props.countDuration} + countMask={props.countMask} + countMaskHeight={props.countMaskHeight} + countWidthDuration={props.countWidthDuration} /> diff --git a/packages/ui/src/components/text-reveal.css b/packages/ui/src/components/text-reveal.css index 5e1f6d461d2..a9036f8dafa 100644 --- a/packages/ui/src/components/text-reveal.css +++ b/packages/ui/src/components/text-reveal.css @@ -112,6 +112,24 @@ &[data-ready="false"] [data-slot="text-reveal-leaving"] { transition-duration: 0ms !important; } + + &[data-truncate="true"] { + width: 100%; + } + + &[data-truncate="true"] [data-slot="text-reveal-track"] { + width: 100%; + min-width: 0; + overflow: hidden; + } + + &[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; + } } @media (prefers-reduced-motion: reduce) { diff --git a/packages/ui/src/components/text-reveal.tsx b/packages/ui/src/components/text-reveal.tsx index 15bce03c36a..f01704365e8 100644 --- a/packages/ui/src/components/text-reveal.tsx +++ b/packages/ui/src/components/text-reveal.tsx @@ -28,6 +28,7 @@ export function TextReveal(props: { spring?: string springSoft?: string growOnly?: boolean + truncate?: boolean }) { const [cur, setCur] = createSignal(props.text) const [old, setOld] = createSignal() @@ -105,6 +106,7 @@ export function TextReveal(props: { data-component="text-reveal" data-ready={ready() ? "true" : "false"} data-swapping={swapping() ? "true" : "false"} + data-truncate={props.truncate ? "true" : "false"} class={props.class} aria-label={props.text ?? ""} style={{ @@ -115,7 +117,7 @@ export function TextReveal(props: { "--text-reveal-spring-soft": props.springSoft ?? "cubic-bezier(0.34, 1, 0.64, 1)", }} > - + {cur() ?? "\u00A0"} diff --git a/packages/ui/src/components/todo-panel-motion.stories.tsx b/packages/ui/src/components/todo-panel-motion.stories.tsx index 651f8316d08..39d34215783 100644 --- a/packages/ui/src/components/todo-panel-motion.stories.tsx +++ b/packages/ui/src/components/todo-panel-motion.stories.tsx @@ -139,11 +139,14 @@ export const Playground = { const [drawerExpandBounce, setDrawerExpandBounce] = createSignal(0) const [drawerCollapseDuration, setDrawerCollapseDuration] = createSignal(0.3) const [drawerCollapseBounce, setDrawerCollapseBounce] = createSignal(0) - const [subtitleDuration, setSubtitleDuration] = createSignal(700) + const [subtitleDuration, setSubtitleDuration] = createSignal(600) const [subtitleAuto, setSubtitleAuto] = createSignal(true) const [subtitleTravel, setSubtitleTravel] = createSignal(25) const [subtitleEdge, setSubtitleEdge] = createSignal(17) const [countDuration, setCountDuration] = createSignal(600) + const [countMask, setCountMask] = createSignal(18) + const [countMaskHeight, setCountMaskHeight] = createSignal(0) + const [countWidthDuration, setCountWidthDuration] = createSignal(560) const state = createSessionComposerState({ closeMs: () => Math.round(dockCloseDuration() * 1000) }) let frame let composerRef @@ -265,6 +268,9 @@ export const Playground = { subtitleTravel={subtitleAuto() ? undefined : subtitleTravel()} subtitleEdge={subtitleAuto() ? undefined : subtitleEdge()} countDuration={countDuration()} + countMask={countMask()} + countMaskHeight={countMaskHeight()} + countWidthDuration={countWidthDuration()} /> @@ -524,6 +530,53 @@ export const Playground = { {Math.round(countDuration())}ms + + + ) diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index bc369bdd48b..cd4b9f160f3 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -52,6 +52,7 @@ @import "../components/tag.css" layer(components); @import "../components/text-odometer.css" layer(components); @import "../components/text-reveal.css" layer(components); +@import "../components/text-strikethrough.css" layer(components); @import "../components/text-shimmer.css" layer(components); @import "../components/tool-count-label.css" layer(components); @import "../components/tool-count-summary.css" layer(components); From 55ab1f094c0661e2484c23f42f8d292005706db9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 1 Mar 2026 21:29:24 -0500 Subject: [PATCH 14/76] feat(ui): spring width animation for shell submessage, replace TextOdometer with TextReveal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Shell submessage now uses Motion's animate() with width: "auto" for spring-driven width reveal instead of CSS grid 0fr→1fr transition - Skip animation on page load (sawPending flag), only animate live tool calls - Fix baseline alignment with overflow: clip instead of overflow: hidden - Replace TextOdometer with TextReveal in production (session-turn, todo-dock) - Remove TextOdometer component, CSS, and stories - Add TextReveal to thinking-heading story - Update shell submessage story with visualDuration/bounce sliders --- packages/ui/src/components/message-part.tsx | 22 +- .../shell-submessage-motion.stories.tsx | 132 ++++--- .../ui/src/components/shell-submessage.css | 11 +- packages/ui/src/components/text-odometer.css | 89 ----- .../src/components/text-odometer.stories.tsx | 326 ------------------ packages/ui/src/components/text-odometer.tsx | 157 --------- .../ui/src/components/text-reveal.stories.tsx | 82 +---- .../components/thinking-heading.stories.tsx | 24 +- packages/ui/src/styles/index.css | 1 - 9 files changed, 127 insertions(+), 717 deletions(-) delete mode 100644 packages/ui/src/components/text-odometer.css delete mode 100644 packages/ui/src/components/text-odometer.stories.tsx delete mode 100644 packages/ui/src/components/text-odometer.tsx diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 69f3effbc6c..4910968066e 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -51,15 +51,28 @@ import { IconButton } from "./icon-button" import { TextShimmer } from "./text-shimmer" import { AnimatedCountList } from "./tool-count-summary" import { ToolStatusTitle } from "./tool-status-title" +import { animate } from "motion" -function ShellSubmessage(props: { text: string }) { +function ShellSubmessage(props: { text: string; animate?: boolean }) { let ref: HTMLSpanElement | undefined + let widthRef: HTMLSpanElement | undefined + onMount(() => { - requestAnimationFrame(() => ref?.setAttribute("data-visible", "")) + if (!props.animate) { + ref?.setAttribute("data-visible", "") + return + } + requestAnimationFrame(() => { + ref?.setAttribute("data-visible", "") + if (widthRef) { + animate(widthRef, { width: "auto" }, { type: "spring", visualDuration: 0.25, bounce: 0 }) + } + }) }) + return ( - + {props.text} @@ -1518,6 +1531,7 @@ ToolRegistry.register({ render(props) { const i18n = useI18n() const pending = () => props.status === "pending" || props.status === "running" + const sawPending = pending() const text = createMemo(() => { const cmd = props.input.command ?? props.metadata.command ?? "" const out = stripAnsi(props.output || props.metadata.output || "") @@ -1546,7 +1560,7 @@ ToolRegistry.register({ - + diff --git a/packages/ui/src/components/shell-submessage-motion.stories.tsx b/packages/ui/src/components/shell-submessage-motion.stories.tsx index 0245fb77bae..444fc0fa9af 100644 --- a/packages/ui/src/components/shell-submessage-motion.stories.tsx +++ b/packages/ui/src/components/shell-submessage-motion.stories.tsx @@ -1,6 +1,7 @@ // @ts-nocheck import { createEffect, createSignal, onCleanup } from "solid-js" import { BasicTool } from "./basic-tool" +import { animate } from "motion" export default { title: "UI/Shell Submessage Motion", @@ -17,7 +18,7 @@ Interactive playground for animating the Shell tool subtitle ("submessage") in t - Bash tool subtitle source: \`packages/ui/src/components/message-part.tsx\` (tool: \`bash\`, \`trigger.subtitle\`) ### What this playground tunes -- Width reveal (grid 0fr \u2192 1fr, or instant) +- Width reveal (spring-driven pixel width via \`useSpring\`) - Opacity fade - Blur settle`, }, @@ -67,14 +68,8 @@ const shellCss = ` [data-component="shell-submessage"] [data-slot="shell-submessage-width"] { min-width: 0; max-width: 100%; - display: grid; - grid-template-columns: 0fr; - overflow: hidden; - transition: grid-template-columns var(--shell-sub-width-ms, 420ms) var(--shell-sub-width-ease, cubic-bezier(0.16, 1, 0.3, 1)); -} - -[data-component="shell-submessage"][data-visible="true"] [data-slot="shell-submessage-width"] { - grid-template-columns: 1fr; + display: inline-block; + overflow: clip; } [data-component="shell-submessage"] [data-slot="shell-submessage-value"] { @@ -83,13 +78,13 @@ const shellCss = ` min-width: 0; white-space: nowrap; opacity: 0; - filter: blur(var(--shell-sub-blur, 5px)); + filter: blur(var(--shell-sub-blur, 2px)); transition-property: opacity, filter; transition-duration: var(--shell-sub-fade-ms, 320ms); transition-timing-function: var(--shell-sub-fade-ease, cubic-bezier(0.22, 1, 0.36, 1)); } -[data-component="shell-submessage"][data-visible="true"] [data-slot="shell-submessage-value"] { +[data-component="shell-submessage"][data-visible] [data-slot="shell-submessage-value"] { opacity: 1; filter: blur(0px); } @@ -102,15 +97,55 @@ const ease = { linear: "linear", } +function SpringSubmessage(props: { + text: string + visible: boolean + visualDuration: number + bounce: number +}) { + let ref: HTMLSpanElement | undefined + let widthRef: HTMLSpanElement | undefined + + createEffect(() => { + if (!widthRef) return + if (props.visible) { + requestAnimationFrame(() => { + ref?.setAttribute("data-visible", "") + animate( + widthRef!, + { width: "auto" }, + { type: "spring", visualDuration: props.visualDuration, bounce: props.bounce }, + ) + }) + } else { + ref?.removeAttribute("data-visible") + animate( + widthRef, + { width: "0px" }, + { type: "spring", visualDuration: props.visualDuration, bounce: props.bounce }, + ) + } + }) + + return ( + + + + {props.text || "\u00A0"} + + + + ) +} + export const Playground = { render: () => { const [text, setText] = createSignal("Prints five topic blocks between timed commands") const [show, setShow] = createSignal(true) - const [widthAnim, setWidthAnim] = createSignal(true) - const [widthMs, setWidthMs] = createSignal(420) + const [visualDuration, setVisualDuration] = createSignal(0.35) + const [bounce, setBounce] = createSignal(0) const [fadeMs, setFadeMs] = createSignal(320) - const [blur, setBlur] = createSignal(5) - const [widthEase, setWidthEase] = createSignal("smooth") + const [blur, setBlur] = createSignal(2) const [fadeEase, setFadeEase] = createSignal("snappy") const [auto, setAuto] = createSignal(false) let replayTimer @@ -152,10 +187,8 @@ export const Playground = { gap: "20px", padding: "20px", "max-width": "860px", - "--shell-sub-width-ms": `${widthAnim() ? widthMs() : 0}ms`, "--shell-sub-fade-ms": `${fadeMs()}ms`, "--shell-sub-blur": `${blur()}px`, - "--shell-sub-width-ease": ease[widthEase()], "--shell-sub-fade-ease": ease[fadeEase()], }} > @@ -168,16 +201,12 @@ export const Playground = {
Shell - - - - {text() || "\u00A0"} - - - +
} @@ -236,25 +265,29 @@ export const Playground = {
- width mode - - {widthAnim() ? "grid 0fr\u21921fr" : "instant"} + visualDuration + setVisualDuration(Number(e.currentTarget.value))} + /> + {visualDuration().toFixed(2)}s
- width ease - + bounce + setBounce(Number(e.currentTarget.value))} + /> + {bounce().toFixed(2)}
@@ -271,19 +304,6 @@ export const Playground = {
-
- width - setWidthMs(Number(e.currentTarget.value))} - /> - {widthMs()}ms -
-
fade - ({ - padding: "5px 12px", - "border-radius": "6px", - border: accent ? "1px solid var(--color-accent, #58f)" : "1px solid var(--color-divider, #333)", - background: accent ? "var(--color-accent, #58f)" : "var(--color-fill-element, #222)", - color: "var(--color-text, #eee)", - cursor: "pointer", - "font-size": "12px", - }) as const - -const sliderLabel = { - width: "90px", - "font-size": "12px", - color: "var(--color-text-secondary, #a3a3a3)", - "flex-shrink": "0", -} as const - -const cardStyle = { - padding: "20px 24px", - "border-radius": "10px", - border: "1px solid var(--color-divider, #333)", - background: "var(--color-fill-element, #1a1a1a)", - display: "grid", - gap: "12px", -} as const - -const cardLabel = { - "font-size": "11px", - "font-family": "monospace", - color: "var(--color-text-weak, #666)", -} as const - -const previewRow = { - display: "flex", - "align-items": "center", - gap: "8px", - "font-size": "14px", - "font-weight": "500", - "line-height": "20px", - color: "var(--text-weak, #aaa)", - "min-height": "20px", - overflow: "visible", -} as const - -const headingSlot = { - "min-width": "0", - overflow: "visible", - color: "var(--text-weaker, #888)", - "font-weight": "400", -} as const - -export const Playground = { - render: () => { - const [index, setIndex] = createSignal(0) - const [cycling, setCycling] = createSignal(false) - const [growOnly, setGrowOnly] = createSignal(true) - - // shared - const [duration, setDuration] = createSignal(600) - const [bounce, setBounce] = createSignal(1.0) - const [bounceSoft, setBounceSoft] = createSignal(1.0) - - // odometer-specific - const [travel, setTravel] = createSignal(4) - const [mask, setMask] = createSignal(12) - const [pad, setPad] = createSignal(9) - const [height, setHeight] = createSignal(0) - - // reveal-specific - const [edge, setEdge] = createSignal(17) - const [revealTravel, setRevealTravel] = createSignal(0) - - // hybrid-specific - const [hybridTravel, setHybridTravel] = createSignal(25) - const [hybridEdge, setHybridEdge] = createSignal(17) - - let timer: number | undefined - - const text = () => TEXTS[index()] - - const next = () => { - setIndex((i) => (i + 1) % TEXTS.length) - } - - const prev = () => { - setIndex((i) => (i - 1 + TEXTS.length) % TEXTS.length) - } - - const toggleCycle = () => { - if (cycling()) { - if (timer) clearTimeout(timer) - timer = undefined - setCycling(false) - return - } - setCycling(true) - const tick = () => { - next() - timer = window.setTimeout(tick, 700 + Math.floor(Math.random() * 600)) - } - timer = window.setTimeout(tick, 700 + Math.floor(Math.random() * 600)) - } - - onCleanup(() => { - if (timer) clearTimeout(timer) - }) - - const spring = () => `cubic-bezier(0.34, ${bounce()}, 0.64, 1)` - const springSoft = () => `cubic-bezier(0.34, ${bounceSoft()}, 0.64, 1)` - - return ( -
- {/* ── preview cards ── */} -
- {/* hybrid card — first */} -
- text-reveal (mask wipe + slide) -
- Thinking - - - -
-
- - {/* odometer card */} -
- text-odometer (slide through mask) -
- Thinking - - - -
-
- - {/* reveal card */} -
- text-reveal (mask wipe only) -
- Thinking - - - -
-
-
- - {/* ── text selector chips ── */} -
- {TEXTS.map((t, i) => ( - - ))} -
- - {/* ── controls ── */} -
- - - - -
- - {/* ── sliders ── */} -
- {/* ── hybrid sliders — first ── */} -
Hybrid (wipe + slide)
- - - - - - {/* ── shared sliders ── */} -
Shared
- - - - - - - - {/* ── odometer sliders ── */} -
Odometer
- - - - - - - - - - {/* ── reveal sliders ── */} -
Reveal (wipe only)
- - - - -
- - {/* debug info */} -
- text: {text() ?? "(none)"} · growOnly: {growOnly() ? "on" : "off"} -
-
- ) - }, -} diff --git a/packages/ui/src/components/text-odometer.tsx b/packages/ui/src/components/text-odometer.tsx deleted file mode 100644 index a43698199b3..00000000000 --- a/packages/ui/src/components/text-odometer.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { createEffect, createSignal, on, onCleanup, onMount } from "solid-js" - -const px = (value: number | string | undefined, fallback: number) => { - if (typeof value === "number") return `${value}px` - if (typeof value === "string") return value - return `${fallback}px` -} - -const ms = (value: number | string | undefined, fallback: number) => { - if (typeof value === "number") return `${value}ms` - if (typeof value === "string") return value - return `${fallback}ms` -} - -export function TextOdometer(props: { - text?: string - class?: string - duration?: number | string - travel?: number | string - mask?: number | string - pad?: number | string - height?: number | string - line?: number | string - spring?: string - springSoft?: string - growOnly?: boolean -}) { - const [cur, setCur] = createSignal(props.text) - const [old, setOld] = createSignal() - const [width, setWidth] = createSignal("auto") - const [ready, setReady] = createSignal(false) - const [swapping, setSwapping] = createSignal(false) - const [fit, setFit] = createSignal({ - line: 20, - travel: 4, - mask: 12, - pad: 9, - height: 0, - }) - let inRef: HTMLSpanElement | undefined - 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) { - const prev = Number.parseFloat(width()) - if (Number.isFinite(prev) && next <= prev) return - } - setWidth(`${next}px`) - } - - const refine = () => { - const el = rootRef - if (!el || typeof window === "undefined") return - const style = window.getComputedStyle(el) - const font = Number.parseFloat(style.fontSize) - const line = Number.parseFloat(style.lineHeight) - const unit = Number.isFinite(font) ? font : 14 - const high = Number.isFinite(line) ? line : unit * 1.43 - const travel = Math.max(2, Math.round(high * 0.2)) - const mask = Math.max(2, Math.round(high * 0.6)) - const pad = Math.max(4, Math.round(high * 0.45)) - const height = Math.max(0, Math.round((high - 20) * 0.25)) - setFit({ line: high, travel, mask, pad, height }) - } - - createEffect( - on( - () => props.text, - (next, prev) => { - if (next === prev) return - setSwapping(true) - setOld(prev) - setCur(next) - - if (typeof requestAnimationFrame !== "function") { - widen(Math.max(win(), wout())) - rootRef?.offsetHeight - setSwapping(false) - return - } - if (frame !== undefined && typeof cancelAnimationFrame === "function") cancelAnimationFrame(frame) - frame = requestAnimationFrame(() => { - widen(Math.max(win(), wout())) - rootRef?.offsetHeight - setSwapping(false) - frame = undefined - }) - }, - ), - ) - - onMount(() => { - widen(win()) - refine() - const el = rootRef - if (el && typeof ResizeObserver !== "undefined") { - const observer = new ResizeObserver(refine) - observer.observe(el) - onCleanup(() => observer.disconnect()) - } - const fonts = typeof document !== "undefined" ? document.fonts : undefined - if (typeof requestAnimationFrame !== "function") { - setReady(true) - return - } - if (!fonts) { - requestAnimationFrame(() => setReady(true)) - return - } - fonts.ready.finally(() => { - widen(win()) - refine() - requestAnimationFrame(() => setReady(true)) - }) - }) - - onCleanup(() => { - if (frame === undefined || typeof cancelAnimationFrame !== "function") return - cancelAnimationFrame(frame) - }) - - return ( - - - - {cur() ?? "\u00A0"} - - - {old() ?? "\u00A0"} - - - - ) -} diff --git a/packages/ui/src/components/text-reveal.stories.tsx b/packages/ui/src/components/text-reveal.stories.tsx index df514ca38d1..f3da13f5df8 100644 --- a/packages/ui/src/components/text-reveal.stories.tsx +++ b/packages/ui/src/components/text-reveal.stories.tsx @@ -179,12 +179,8 @@ export const Playground = {
- - + + @@ -198,29 +194,13 @@ export const Playground = { @@ -228,75 +208,33 @@ export const Playground = { -
- Wipe only -
+
Wipe only
diff --git a/packages/ui/src/components/thinking-heading.stories.tsx b/packages/ui/src/components/thinking-heading.stories.tsx index e6ef372af20..90eb7ee3190 100644 --- a/packages/ui/src/components/thinking-heading.stories.tsx +++ b/packages/ui/src/components/thinking-heading.stories.tsx @@ -1,6 +1,7 @@ // @ts-nocheck import { createSignal, createEffect, on, onMount, onCleanup } from "solid-js" import { TextShimmer } from "./text-shimmer" +import { TextReveal } from "./text-reveal" export default { title: "UI/ThinkingHeading", @@ -12,8 +13,8 @@ export default { component: `### Overview Playground for animating the secondary heading beside "Thinking". -Five spring-animated transition styles shown in a card grid with tunable -duration, blur, travel, bounce (spring overshoot), and odometer fade controls.`, +Uses TextReveal for the production heading animation with tunable +duration, travel, bounce, and fade controls.`, }, }, }, @@ -543,7 +544,7 @@ const cardStyle = { // Variants // --------------------------------------------------------------------------- -const VARIANTS = [{ key: "odometer", label: "odometer" }] +const VARIANTS: { key: string; label: string }[] = [] // --------------------------------------------------------------------------- // Story @@ -632,6 +633,23 @@ export const Playground = { {/* ── Variant cards ─────────────────────────────────── */}
+
+ TextReveal (production) + + + + + + +
{VARIANTS.map((v) => (
{v.label} diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index cd4b9f160f3..cec42f5a0ca 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -50,7 +50,6 @@ @import "../components/sticky-accordion-header.css" layer(components); @import "../components/tabs.css" layer(components); @import "../components/tag.css" layer(components); -@import "../components/text-odometer.css" layer(components); @import "../components/text-reveal.css" layer(components); @import "../components/text-strikethrough.css" layer(components); @import "../components/text-shimmer.css" layer(components); From bc56419124b0ed09cc6f1d8e348f91aa2868e65d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 1 Mar 2026 21:32:44 -0500 Subject: [PATCH 15/76] fix(ui): keep TextShimmer mounted for smooth transitions, move shell submessage fade to JS - Fix 6 instances where TextShimmer was destroyed/recreated via swap instead of toggling active prop (bash, webfetch, edit, write, context tools, basic-tool fallback) - Move shell submessage opacity/blur from CSS transitions to animate() so they respect the animate prop and don't fire on page load - Remove data-visible attribute pattern, all animation now driven by Motion's animate() when animate=true --- packages/ui/src/components/basic-tool.tsx | 4 +-- packages/ui/src/components/message-part.tsx | 35 +++++++++++-------- .../ui/src/components/shell-submessage.css | 16 --------- 3 files changed, 21 insertions(+), 34 deletions(-) diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index 6f70d19154a..fff6e92f17c 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -132,9 +132,7 @@ export function BasicTool(props: BasicToolProps) { [trigger().titleClass ?? ""]: !!trigger().titleClass, }} > - - - + diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 4910968066e..c941f83ada2 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -54,27 +54,36 @@ import { ToolStatusTitle } from "./tool-status-title" import { animate } from "motion" function ShellSubmessage(props: { text: string; animate?: boolean }) { - let ref: HTMLSpanElement | undefined let widthRef: HTMLSpanElement | undefined + let valueRef: HTMLSpanElement | undefined onMount(() => { - if (!props.animate) { - ref?.setAttribute("data-visible", "") - return - } + if (!props.animate) return requestAnimationFrame(() => { - ref?.setAttribute("data-visible", "") if (widthRef) { animate(widthRef, { width: "auto" }, { type: "spring", visualDuration: 0.25, bounce: 0 }) } + if (valueRef) { + animate(valueRef, { opacity: 1, filter: "blur(0px)" }, { duration: 0.32, ease: [0.16, 1, 0.3, 1] }) + } }) }) return ( - - + + - {props.text} + + {props.text} + @@ -771,9 +780,7 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
- - - + {trigger().subtitle} @@ -1555,9 +1562,7 @@ ToolRegistry.register({
- - - + diff --git a/packages/ui/src/components/shell-submessage.css b/packages/ui/src/components/shell-submessage.css index 4583348d74a..81734da236c 100644 --- a/packages/ui/src/components/shell-submessage.css +++ b/packages/ui/src/components/shell-submessage.css @@ -17,20 +17,4 @@ vertical-align: baseline; min-width: 0; white-space: nowrap; - opacity: 0; - filter: blur(2px); - transition-property: opacity, filter; - transition-duration: 320ms; - transition-timing-function: cubic-bezier(0.16, 1, 0.3, 1); -} - -[data-component="shell-submessage"][data-visible] [data-slot="shell-submessage-value"] { - opacity: 1; - filter: blur(0px); -} - -@media (prefers-reduced-motion: reduce) { - [data-component="shell-submessage"] [data-slot="shell-submessage-value"] { - transition-duration: 0ms; - } } From b939299a0fe4f2026fd12648f6d4738daba9bf47 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 2 Mar 2026 16:05:32 -0500 Subject: [PATCH 16/76] wip: checkpoint tool-call motion and timeline animation fixes --- packages/app/src/pages/session.tsx | 2 +- .../src/pages/session/message-timeline.tsx | 61 +- .../pages/session/use-session-hash-scroll.ts | 6 +- .../ui/src/components/bash-tool.stories.tsx | 580 +++++++++++ packages/ui/src/components/basic-tool.css | 2 +- packages/ui/src/components/basic-tool.tsx | 153 ++- packages/ui/src/components/collapsible.css | 12 +- packages/ui/src/components/grow-box.tsx | 173 ++++ packages/ui/src/components/message-part.css | 62 +- packages/ui/src/components/message-part.tsx | 831 +++++++++------ packages/ui/src/components/motion.tsx | 24 + .../session-timeline-simulator.stories.tsx | 943 ++++++++++++++++++ packages/ui/src/components/session-turn.css | 41 +- packages/ui/src/components/session-turn.tsx | 255 ++++- packages/ui/src/components/text-shimmer.css | 17 +- .../src/components/text-shimmer.stories.tsx | 34 + packages/ui/src/components/text-shimmer.tsx | 15 + .../ui/src/components/tool-status-title.css | 1 - .../ui/src/components/tool-status-title.tsx | 55 +- packages/ui/src/hooks/create-auto-scroll.tsx | 121 +-- 20 files changed, 2921 insertions(+), 467 deletions(-) create mode 100644 packages/ui/src/components/bash-tool.stories.tsx create mode 100644 packages/ui/src/components/grow-box.tsx create mode 100644 packages/ui/src/components/motion.tsx create mode 100644 packages/ui/src/components/session-timeline-simulator.stories.tsx diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index cc81ae7b6ca..9396585b719 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1049,7 +1049,7 @@ export default function Page() { const resumeScroll = () => { setStore("messageId", undefined) - autoScroll.forceScrollToBottom() + autoScroll.smoothScrollToBottom() clearMessageHash() const el = scroller diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index ae05da9cbad..da50f609e90 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -1,4 +1,4 @@ -import { For, createEffect, createMemo, on, onCleanup, Show, startTransition, Index, type JSX } from "solid-js" +import { For, createEffect, createMemo, createSignal, on, onCleanup, Show, startTransition, type JSX } from "solid-js" import { createStore, produce } from "solid-js/store" import { useNavigate, useParams } from "@solidjs/router" import { Button } from "@opencode-ai/ui/button" @@ -105,11 +105,17 @@ type TimelineStageInput = { * new messages render immediately. */ function createTimelineStaging(input: TimelineStageInput) { + const log = (...args: unknown[]) => { + if (typeof window === "undefined") return + console.debug("[ui:staging]", ...args) + } const [state, setState] = createStore({ activeSession: "", completedSession: "", count: 0, }) + const [readySession, setReadySession] = createSignal("") + let active = "" const stagedCount = createMemo(() => { const total = input.messages().length @@ -134,19 +140,51 @@ function createTimelineStaging(input: TimelineStageInput) { cancelAnimationFrame(frame) frame = undefined } + const scheduleReady = (sessionKey: string) => { + if (input.sessionKey() !== sessionKey) return + if (readySession() === sessionKey) return + setReadySession(sessionKey) + log("ready-set", { sessionKey }) + } createEffect( on( () => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const, ([sessionKey, isWindowed, total]) => { + log("source", { + sessionKey, + isWindowed, + total, + active: state.activeSession, + completed: state.completedSession, + readySession: readySession(), + }) cancel() + const switched = active !== sessionKey + if (switched) { + active = sessionKey + setReadySession("") + log("switch", { sessionKey }) + } + const staging = state.activeSession === sessionKey && state.completedSession !== sessionKey + if (staging && !switched) return const shouldStage = isWindowed && total > input.config.init && - state.completedSession !== sessionKey && - state.activeSession !== sessionKey + state.completedSession !== sessionKey + if (shouldStage) setReadySession("") if (!shouldStage) { - setState({ activeSession: "", count: total }) + setState({ + activeSession: "", + completedSession: isWindowed ? sessionKey : state.completedSession, + count: total, + }) + if (total <= 0) { + setReadySession("") + log("ready-clear-empty", { sessionKey }) + return + } + if (readySession() !== sessionKey) scheduleReady(sessionKey) return } @@ -164,6 +202,7 @@ function createTimelineStaging(input: TimelineStageInput) { if (count >= currentTotal) { setState({ completedSession: sessionKey, activeSession: "" }) frame = undefined + scheduleReady(sessionKey) return } frame = requestAnimationFrame(step) @@ -177,9 +216,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: { @@ -667,7 +709,7 @@ export function MessageTimeline(props: {
0}>
@@ -745,7 +790,7 @@ export function MessageTimeline(props: { void setActiveMessage: (message: UserMessage | undefined) => void setTurnStart: (value: number) => void - autoScroll: { pause: () => void; forceScrollToBottom: () => void } + autoScroll: { pause: () => void; forceScrollToBottom: () => void; snapToBottom: () => void } scroller: () => HTMLDivElement | undefined anchor: (id: string) => string scheduleScrollState: (el: HTMLDivElement) => void @@ -102,7 +102,7 @@ export const useSessionHashScroll = (input: { const applyHash = (behavior: ScrollBehavior) => { 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 @@ -126,7 +126,7 @@ export const useSessionHashScroll = (input: { return } - input.autoScroll.forceScrollToBottom() + input.autoScroll.snapToBottom() const el = input.scroller() if (el) input.scheduleScrollState(el) } diff --git a/packages/ui/src/components/bash-tool.stories.tsx b/packages/ui/src/components/bash-tool.stories.tsx new file mode 100644 index 00000000000..a59ae2def5d --- /dev/null +++ b/packages/ui/src/components/bash-tool.stories.tsx @@ -0,0 +1,580 @@ +// @ts-nocheck +import { createSignal, createMemo, createEffect, on, onCleanup, batch, For } from "solid-js" +import { createStore, produce } from "solid-js/store" +import type { + Message, + UserMessage, + AssistantMessage, + Part, + TextPart, + ToolPart, + SessionStatus, +} from "@opencode-ai/sdk/v2" +import { DataProvider } from "../context/data" +import { FileComponentProvider } from "../context/file" +import { SessionTurn } from "./session-turn" + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const SESSION_ID = "bash-sim-1" +const USER_MSG_ID = "bash-user-1" +const ASST_MSG_ID = "bash-asst-1" +const T0 = Date.now() + +const COMMAND = 'sleep 1 && cowsay "Doing some stuff"' +const DESCRIPTION = "Running a quick demo" + +const COWSAY_OUTPUT = ` __________________ +< Doing some stuff > + ------------------ + \\ ^__^ + \\ (oo)\\_______ + (__)\\ )\\/\\ + ||----w | + || ||` + +// --------------------------------------------------------------------------- +// Timeline event types (same as session-timeline-simulator) +// --------------------------------------------------------------------------- + +type TimelineEvent = + | { type: "message"; message: Message } + | { type: "part"; part: Part } + | { type: "part-update"; messageID: string; partID: string; patch: Record } + | { type: "status"; status: SessionStatus } + | { type: "delay"; ms: number; label?: string } + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +let _pid = 0 +const pid = () => `bp-${++_pid}` +const cid = () => `bc-${_pid}` + +function mkUser(id: string): UserMessage { + return { + id, + sessionID: SESSION_ID, + role: "user", + time: { created: T0 }, + agent: "assistant", + model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" }, + } +} + +function mkAssistant(id: string, parentID: string, completed?: number): AssistantMessage { + return { + id, + sessionID: SESSION_ID, + role: "assistant", + time: { created: T0 + 100, completed }, + parentID, + modelID: "claude-sonnet-4-20250514", + providerID: "anthropic", + mode: "default", + agent: "assistant", + path: { cwd: "/project", root: "/project" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + } +} + +function mkText(messageID: string, text: string): TextPart { + return { id: pid(), sessionID: SESSION_ID, messageID, type: "text", text } +} + +function mkTool(messageID: string, tool: string, input: Record): ToolPart { + const id = pid() + return { + id, + sessionID: SESSION_ID, + messageID, + type: "tool", + callID: cid(), + tool, + state: { status: "pending", input, raw: JSON.stringify(input) }, + } +} + +// --------------------------------------------------------------------------- +// Build focused bash timeline +// --------------------------------------------------------------------------- + +function buildTimeline() { + _pid = 0 + const events: TimelineEvent[] = [] + const e = (ev: TimelineEvent) => events.push(ev) + const delay = (ms: number, label?: string) => e({ type: "delay", ms, label }) + const status = (s: SessionStatus) => e({ type: "status", status: s }) + const msg = (m: Message) => e({ type: "message", message: m }) + const part = (p: Part) => e({ type: "part", part: p }) + const upd = (p: Part, patch: Record) => + e({ type: "part-update", messageID: p.messageID, partID: p.id, patch }) + + // ── User message ────────────────────────────────────────────────────── + const userText = mkText(USER_MSG_ID, "Quick cowsay demo") + msg(mkUser(USER_MSG_ID)) + part(userText) + + // ── Session goes busy ───────────────────────────────────────────────── + status({ type: "busy" }) + + // ── Assistant starts (incomplete) ───────────────────────────────────── + msg(mkAssistant(ASST_MSG_ID, USER_MSG_ID)) + + delay(400, "Assistant thinking...") + + // ── Shell tool: pending ─────────────────────────────────────────────── + const shellInput = { command: COMMAND, description: DESCRIPTION } + const shellPart = mkTool(ASST_MSG_ID, "bash", shellInput) + part(shellPart) + + delay(300, "Shell pending — trigger visible, shimmer active") + + // ── Shell tool: running ─────────────────────────────────────────────── + upd(shellPart, { + state: { + status: "running", + input: shellInput, + title: COMMAND, + metadata: { command: COMMAND }, + time: { start: T0 + 700 }, + }, + }) + + delay(1200, "Shell running — still shimmering, no output yet") + + // ── Shell tool: completed with output ───────────────────────────────── + upd(shellPart, { + state: { + status: "completed", + input: shellInput, + output: COWSAY_OUTPUT, + title: COMMAND, + metadata: { command: COMMAND, output: COWSAY_OUTPUT }, + time: { start: T0 + 700, end: T0 + 1900 }, + }, + }) + + delay(300, "Shell completed — output box should height-animate from 0") + + // ── Complete the assistant message ──────────────────────────────────── + msg(mkAssistant(ASST_MSG_ID, USER_MSG_ID, T0 + 2200)) + + // ── Session idle ────────────────────────────────────────────────────── + status({ type: "idle" }) + + return events +} + +// --------------------------------------------------------------------------- +// Store-backed playback engine (same pattern as session-timeline-simulator) +// --------------------------------------------------------------------------- + +function createPlayback(events: TimelineEvent[]) { + const [step, setStep] = createSignal(0) + const totalSteps = events.length + + const [data, setData] = createStore({ + session: [], + session_status: {}, + session_diff: {}, + message: {}, + part: {}, + }) + + function applyEvent(event: TimelineEvent) { + console.debug("[bash-story] apply", event.type, event.type === "delay" ? event.label : "") + switch (event.type) { + case "status": + console.debug("[bash-story] status →", event.status.type) + setData("session_status", SESSION_ID, event.status) + break + + case "message": + console.debug("[bash-story] message", event.message.role, event.message.id, "completed:", !!event.message.time?.completed) + setData( + produce((d) => { + if (!d.message[SESSION_ID]) d.message[SESSION_ID] = [] + const list = d.message[SESSION_ID] + const idx = list.findIndex((m) => m.id === event.message.id) + if (idx >= 0) { + list[idx] = event.message + } else { + list.push(event.message) + } + }), + ) + break + + case "part": + console.debug("[bash-story] part", event.part.type, event.part.type === "tool" ? event.part.tool : "", event.part.id) + setData( + produce((d) => { + const mid = event.part.messageID + if (!d.part[mid]) d.part[mid] = [] + d.part[mid].push(event.part) + }), + ) + break + + case "part-update": { + const patch = event.patch + const status = patch?.state?.status + const hasOutput = !!patch?.state?.output + console.debug("[bash-story] part-update", event.partID, "status:", status, "hasOutput:", hasOutput) + setData( + produce((d) => { + const list = d.part[event.messageID] + if (!list) return + const idx = list.findIndex((p) => p.id === event.partID) + if (idx < 0) return + Object.assign(list[idx], patch) + }), + ) + break + } + } + } + + function resetStore() { + console.debug("[bash-story] resetStore") + setData({ + session: [], + session_status: {}, + session_diff: {}, + message: {}, + part: {}, + }) + } + + function replayTo(target: number) { + console.debug("[bash-story] replayTo", target) + resetStore() + batch(() => { + for (let i = 0; i < target && i < events.length; i++) { + applyEvent(events[i]) + } + }) + } + + let appliedStep = 0 + + createEffect( + on(step, (target) => { + console.debug("[bash-story] step changed:", appliedStep, "→", target) + if (target > appliedStep) { + batch(() => { + for (let i = appliedStep; i < target && i < events.length; i++) { + applyEvent(events[i]) + } + }) + } else if (target < appliedStep) { + replayTo(target) + } + appliedStep = target + }), + ) + + const stepForward = () => { + let next = step() + 1 + // Skip delay events when stepping manually + while (next < totalSteps && events[next]?.type === "delay") next++ + const clamped = Math.min(next, totalSteps) + console.debug("[bash-story] stepForward →", clamped) + setStep(clamped) + } + + const stepBack = () => { + let next = step() - 1 + while (next > 0 && events[next - 1]?.type === "delay") next-- + const clamped = Math.max(next, 0) + console.debug("[bash-story] stepBack →", clamped) + setStep(clamped) + } + + const reset = () => { + console.debug("[bash-story] reset") + setStep(0) + appliedStep = 0 + resetStore() + } + + const jumpTo = (s: number) => { + const clamped = Math.max(0, Math.min(s, totalSteps)) + console.debug("[bash-story] jumpTo", clamped) + setStep(clamped) + } + + // Event label for current position + const label = createMemo(() => { + const s = step() + if (s <= 0) return "Start" + if (s >= totalSteps) return "Complete" + const ev = events[s - 1] + if (!ev) return "" + switch (ev.type) { + case "message": + return `${ev.message.role} message` + case "part": { + const p = ev.part + return p.type === "tool" ? `tool (${p.tool}) pending` : p.type + } + case "part-update": + return `part update` + case "status": + return `status: ${ev.status.type}` + case "delay": + return ev.label || `delay ${ev.ms}ms` + } + }) + + return { step, totalSteps, data, label, stepForward, stepBack, reset, jumpTo } +} + +// --------------------------------------------------------------------------- +// Placeholder file component +// --------------------------------------------------------------------------- + +function PlaceholderFile() { + return null +} + +// --------------------------------------------------------------------------- +// Simulator component +// --------------------------------------------------------------------------- + +function BashToolSimulator() { + const events = buildTimeline() + const pb = createPlayback(events) + + const [animateEnabled, setAnimateEnabled] = createSignal(true) + const [shellOpen, setShellOpen] = createSignal(true) + + // Keyboard navigation + const onKey = (e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return + if (e.key === "ArrowRight") { + e.preventDefault() + pb.stepForward() + } else if (e.key === "ArrowLeft") { + e.preventDefault() + pb.stepBack() + } else if (e.key === "r") { + e.preventDefault() + pb.reset() + } else if (e.key === "a") { + e.preventDefault() + setAnimateEnabled((v) => !v) + } + } + window.addEventListener("keydown", onKey) + onCleanup(() => window.removeEventListener("keydown", onKey)) + + const progress = createMemo(() => (pb.step() / pb.totalSteps) * 100) + + return ( +
+ {/* Main content */} +
+ + + + + +
+ + {/* Controls panel */} +
+ {/* Scrubber */} +
{ + const rect = e.currentTarget.getBoundingClientRect() + const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) + pb.jumpTo(Math.round(ratio * pb.totalSteps)) + }} + > +
+
+ + {/* Transport + info */} +
+
+ + + +
+ + + {pb.step()}/{pb.totalSteps} + + + + {pb.label()} + + + {/* Toggles */} + + +
+
+
+ ) +} + +function Btn(props: { onClick: () => void; title?: string; children: any }) { + return ( + + ) +} + +// --------------------------------------------------------------------------- +// Storybook exports +// --------------------------------------------------------------------------- + +export default { + title: "Tools / Bash Tool States", + id: "bash-tool-states", + parameters: { + layout: "fullscreen", + docs: { + description: { + component: `### Bash Tool State Stepper + +Step through bash tool lifecycle states to debug height animations. + +**Keyboard:** +- Arrow Right/Left: step forward/back (skips delays) +- R: reset to start +- A: toggle animate + +**States:** pending → running → completed (output arrives) → done (message complete) + +Open console to see \`[bash-story]\` debug logs. +`, + }, + }, + }, +} + +export const Playback = { + render: () => , +} diff --git a/packages/ui/src/components/basic-tool.css b/packages/ui/src/components/basic-tool.css index 02be54d738b..1240ad7b995 100644 --- a/packages/ui/src/components/basic-tool.css +++ b/packages/ui/src/components/basic-tool.css @@ -64,7 +64,7 @@ [data-slot="basic-tool-tool-info-main"] { display: flex; - align-items: baseline; + align-items: center; gap: 8px; min-width: 0; overflow: hidden; diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index fff6e92f17c..f4cd25262cc 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -1,5 +1,5 @@ -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, Switch, type JSX } from "solid-js" +import { animate, type AnimationPlaybackControls, springValue, HEIGHT_SPRING, FADE_SPRING } from "./motion" import { Collapsible } from "./collapsible" import type { IconProps } from "./icon" import { TextShimmer } from "./text-shimmer" @@ -25,21 +25,28 @@ export interface BasicToolProps { trigger: TriggerTitle | JSX.Element children?: JSX.Element status?: string + debugID?: string + animate?: boolean hideDetails?: boolean defaultOpen?: boolean forceOpen?: boolean defer?: boolean locked?: boolean + watchDetails?: boolean animated?: boolean + animateIn?: boolean onSubtitleClick?: () => void } -const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 } - export function BasicTool(props: BasicToolProps) { const [open, setOpen] = createSignal(props.defaultOpen ?? false) const [ready, setReady] = createSignal(open()) const pending = () => props.status === "pending" || props.status === "running" + const watchDetails = () => props.watchDetails !== false + const log = (...args: unknown[]) => { + if (!props.debugID || typeof window === "undefined") return + console.debug("[ui:tool]", props.debugID, ...args) + } let frame: number | undefined @@ -55,6 +62,10 @@ export function BasicTool(props: BasicToolProps) { if (props.forceOpen) setOpen(true) }) + createEffect(() => { + log("state", { open: open(), status: props.status, forceOpen: !!props.forceOpen, pending: pending() }) + }) + createEffect( on( open, @@ -77,36 +88,119 @@ 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 - const initialOpen = open() + let bodyRef: HTMLDivElement | undefined + let fadeAnim: AnimationPlaybackControls | undefined + let observer: ResizeObserver | undefined + let resizeFrame: number | undefined + const initialOpen = props.animateIn ? false : open() + const heightSpring = springValue(0, HEIGHT_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() + log("open", { next }) + fadeAnim?.stop() + fadeAnim = animate(bodyRef, { opacity: 1, filter: "blur(0px)" }, FADE_SPRING) + fadeAnim.finished.then(() => { + if (!bodyRef) return + bodyRef.style.opacity = "" + bodyRef.style.filter = "" + }) + heightSpring.set(next) + } + + const doClose = () => { + if (!contentRef || !bodyRef) return + log("close") + fadeAnim?.stop() + fadeAnim = animate(bodyRef, { opacity: 0, filter: "blur(2px)" }, FADE_SPRING) + heightSpring.set(0) + } + + const grow = () => { + if (!contentRef || !open()) return + const next = read() + if (Math.abs(next - heightSpring.get()) < 1) return + log("grow", { next }) + heightSpring.set(next) + } + + onMount(() => { + log("mount", { + status: props.status, + defaultOpen: props.defaultOpen, + forceOpen: props.forceOpen, + animated: props.animated, + animateIn: props.animateIn, + }) + if (!props.animated || 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` + }) + const offComplete = heightSpring.on("animationComplete", () => { + if (!contentRef || open()) return + contentRef.style.display = "none" + log("close-done") + }) + onCleanup(() => { + offComplete() + 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 + } + requestAnimationFrame(() => { + if (!open()) return + doOpen() + }) + }) 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.animated || props.animate === false || !contentRef) return + if (isOpen) doOpen() + else doClose() }, { defer: true }, ), ) onCleanup(() => { - heightAnim?.stop() + log("unmount") + if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame) + observer?.disconnect() + fadeAnim?.stop() + heightSpring.destroy() }) const handleOpenChange = (value: boolean) => { @@ -181,22 +275,27 @@ export function BasicTool(props: BasicToolProps) {
- +
- {props.children} +
+ {props.children} +
- + - {props.children} + +
{props.children}
+
diff --git a/packages/ui/src/components/collapsible.css b/packages/ui/src/components/collapsible.css index bab2c4f9269..af572ae8f2d 100644 --- a/packages/ui/src/components/collapsible.css +++ b/packages/ui/src/components/collapsible.css @@ -8,8 +8,8 @@ border-radius: var(--radius-md); overflow: visible; - &.tool-collapsible { - gap: 8px; + &.tool-collapsible [data-slot="basic-tool-content-inner"] { + padding-top: 8px; } [data-slot="collapsible-trigger"] { @@ -83,15 +83,15 @@ [data-slot="collapsible-content"] { overflow: hidden; - /* animation: slideUp 250ms ease-out; */ &[data-expanded] { overflow: visible; } - /* &[data-expanded] { */ - /* animation: slideDown 250ms ease-out; */ - /* } */ + /* JS-animated content: overflow managed by animate() */ + &[data-animated] { + overflow: hidden; + } } &[data-variant="ghost"] { diff --git a/packages/ui/src/components/grow-box.tsx b/packages/ui/src/components/grow-box.tsx new file mode 100644 index 00000000000..ac0af085a53 --- /dev/null +++ b/packages/ui/src/components/grow-box.tsx @@ -0,0 +1,173 @@ +import { type JSX, onMount, onCleanup } from "solid-js" +import { animate, springValue, type AnimationPlaybackControls, FADE_SPRING, HEIGHT_SPRING } from "./motion" + +export interface GrowBoxProps { + children: JSX.Element + /** Enable animation. When false, content shows immediately at full height. */ + animate?: boolean + /** Animate height from 0 to content height. Default: true. */ + grow?: boolean + /** Keep watching body size and animate subsequent height changes. Default: false. */ + watch?: boolean + /** Fade in body content (opacity + blur). Default: true. */ + fade?: boolean + /** Top padding in px on the body wrapper. Default: 0. */ + gap?: number + /** Reset to height:auto after grow completes, or stay at fixed px. Default: true. */ + autoHeight?: boolean + /** data-slot attribute on the root div. */ + slot?: string + /** CSS class on the root div. */ + class?: string +} + +/** + * 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) { + let root: HTMLDivElement | undefined + let body: HTMLDivElement | undefined + let fadeAnim: AnimationPlaybackControls | undefined + let mountFrame: number | undefined + let resizeFrame: number | undefined + let observer: ResizeObserver | undefined + const height = springValue(0, HEIGHT_SPRING) + + const gap = () => Math.max(0, props.gap ?? 0) + const grow = () => props.grow !== false + const watch = () => props.watch === true + + const log = (...args: unknown[]) => { + if (typeof window === "undefined") return + const id = props.slot || props.class || "?" + console.debug("[ui:grow-box]", id, ...args) + } + + 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, Math.ceil(root.getBoundingClientRect().height)) + } + + const targetHeight = () => Math.max(0, Math.ceil(body?.getBoundingClientRect().height ?? 0)) + + const setHeight = () => { + if (!root) return + const next = targetHeight() + const prev = currentHeight() + log("open", { prev, next, grow: grow(), watch: watch() }) + if (Math.abs(next - prev) < 1) { + if (props.autoHeight === false || watch()) { + root.style.height = `${next}px` + root.style.overflow = next > 0 ? "visible" : "hidden" + } + log("open-skip", { next }) + return + } + root.style.overflow = "hidden" + height.set(next) + log("open-animate", { prev, next }) + } + + onMount(() => { + if (!root || !body) return + + const offChange = height.on("change", (next) => { + if (!root) return + root.style.height = `${Math.max(0, Math.ceil(next))}px` + }) + const offStart = height.on("animationStart", () => { + if (!root) return + root.style.overflow = "hidden" + }) + const offComplete = height.on("animationComplete", () => { + if (!root) return + const next = targetHeight() + if (props.autoHeight === false || watch()) { + root.style.height = `${next}px` + root.style.overflow = next > 0 ? "visible" : "hidden" + log("open-done-fixed", { next }) + return + } + root.style.height = "auto" + root.style.overflow = "visible" + log("open-done-auto") + }) + + onCleanup(() => { + offComplete() + offStart() + offChange() + }) + + log("mount", { animate: props.animate, grow: grow(), watch: watch(), fade: props.fade !== false }) + if (!props.animate) { + root.style.height = "" + root.style.overflow = "" + body.style.opacity = "" + body.style.filter = "" + log("mount-skip-no-animate") + return + } + if (grow()) { + root.style.height = "0px" + root.style.overflow = "hidden" + } else { + root.style.height = "auto" + root.style.overflow = "visible" + } + if (props.fade !== false) { + body.style.opacity = "0" + body.style.filter = "blur(2px)" + } + mountFrame = requestAnimationFrame(() => { + mountFrame = undefined + log("mount-raf", { grow: grow(), bodyHeight: targetHeight() }) + if (props.fade !== false && body) { + fadeAnim?.stop() + fadeAnim = animate(body, { opacity: 1, filter: "blur(0px)" }, FADE_SPRING) + fadeAnim.finished.then(() => { + if (!body) return + body.style.opacity = "" + body.style.filter = "" + }) + } + if (grow()) setHeight() + }) + if (watch()) { + log("mount-observer-setup") + observer = new ResizeObserver(() => { + if (resizeFrame !== undefined) return + resizeFrame = requestAnimationFrame(() => { + resizeFrame = undefined + log("resize-observer-fire", { bodyHeight: targetHeight(), rootHeight: currentHeight() }) + setHeight() + }) + }) + observer.observe(body) + } + }) + + onCleanup(() => { + if (mountFrame !== undefined) cancelAnimationFrame(mountFrame) + if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame) + observer?.disconnect() + height.destroy() + fadeAnim?.stop() + }) + + return ( +
+
0 ? `${gap()}px` : undefined }}> + {props.children} +
+
+ ) +} diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 3eee45c75fc..01d732d4c0f 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -27,6 +27,15 @@ color: var(--text-weak); } + [data-slot="user-message-inner"] { + position: relative; + display: flex; + flex-direction: column; + align-items: flex-end; + width: 100%; + gap: 8px; + } + [data-slot="user-message-attachments"] { display: flex; flex-wrap: wrap; @@ -133,16 +142,20 @@ } [data-slot="user-message-copy-wrapper"] { - min-height: 24px; - margin-top: 4px; + position: absolute; + top: 100%; + left: 0; + right: 0; + padding-top: 4px; display: flex; align-items: center; justify-content: flex-end; gap: 10px; - width: 100%; opacity: 0; pointer-events: none; - transition: opacity 0.15s ease; + transition: + opacity 0.15s ease 0.25s, + pointer-events 0s 0.25s; will-change: opacity; [data-component="tooltip-trigger"] { @@ -186,6 +199,7 @@ &:focus-within [data-slot="user-message-copy-wrapper"] { opacity: 1; pointer-events: auto; + transition: opacity 0.15s ease; } .text-text-strong { @@ -199,15 +213,25 @@ [data-component="text-part"] { width: 100%; - margin-top: 24px; + margin-top: 0; + position: relative; [data-slot="text-part-body"] { margin-top: 0; } + [data-slot="text-part-copy-wrap"] { + width: 100%; + min-width: 0; + display: none; + } + + [data-slot="text-part-copy-wrap"][data-visible] { + display: block; + } + [data-slot="text-part-copy-wrapper"] { min-height: 24px; - margin-top: 4px; display: flex; align-items: center; justify-content: flex-start; @@ -392,6 +416,7 @@ background: transparent; position: relative; overflow: hidden; + will-change: opacity, filter; [data-slot="bash-copy"] { position: absolute; @@ -618,14 +643,14 @@ } [data-component="context-tool-group-list"] { - padding: 6px 0 4px 0; + margin-top: -4px; display: flex; flex-direction: column; - gap: 2px; + gap: 0px; [data-slot="context-tool-group-item"] { min-width: 0; - padding: 6px 0; + padding: 4px 0; } } @@ -689,6 +714,25 @@ width: 100%; } +[data-slot="assistant-part-grow"] { + width: 100%; + min-width: 0; + overflow: visible; +} + +[data-component="tool-part-wrapper"][data-tool="bash"] { + [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; diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index c941f83ada2..b2815b22f06 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -5,6 +5,7 @@ import { createSignal, For, Match, + on, onMount, Show, Switch, @@ -37,7 +38,6 @@ import { GenericTool } from "./basic-tool" import { Accordion } from "./accordion" import { StickyAccordionHeader } from "./sticky-accordion-header" import { Card } from "./card" -import { Collapsible } from "./collapsible" import { FileIcon } from "./file-icon" import { Icon } from "./icon" import { Checkbox } from "./checkbox" @@ -51,44 +51,8 @@ import { IconButton } from "./icon-button" import { TextShimmer } from "./text-shimmer" import { AnimatedCountList } from "./tool-count-summary" import { ToolStatusTitle } from "./tool-status-title" -import { animate } from "motion" - -function ShellSubmessage(props: { text: string; animate?: boolean }) { - let widthRef: HTMLSpanElement | undefined - let valueRef: HTMLSpanElement | undefined - - onMount(() => { - if (!props.animate) return - requestAnimationFrame(() => { - if (widthRef) { - animate(widthRef, { width: "auto" }, { type: "spring", visualDuration: 0.25, bounce: 0 }) - } - if (valueRef) { - animate(valueRef, { opacity: 1, filter: "blur(0px)" }, { duration: 0.32, ease: [0.16, 1, 0.3, 1] }) - } - }) - }) - - return ( - - - - - {props.text} - - - - - ) -} +import { GrowBox } from "./grow-box" +import { animate, type AnimationPlaybackControls, FADE_SPRING } from "./motion" interface Diagnostic { range: { @@ -134,7 +98,7 @@ export interface MessageProps { parts: PartType[] showAssistantCopyPartID?: string | null interrupted?: boolean - queued?: boolean + animate?: boolean showReasoningSummaries?: boolean } @@ -145,6 +109,7 @@ export interface MessagePartProps { defaultOpen?: boolean showAssistantCopyPartID?: string | null turnDurationMs?: number + animate?: boolean } export type PartComponent = Component @@ -431,6 +396,27 @@ function partDefaultOpen(part: PartType, shell = false, edit = false) { return toolDefaultOpen(part.tool, shell, edit) } +function PartGrow(props: { + children: JSX.Element + animate?: boolean + debugID?: string + gap?: number + fade?: boolean + grow?: boolean +}) { + return ( + + {props.children} + + ) +} + export function AssistantParts(props: { messages: AssistantMessage[] showAssistantCopyPartID?: string | null @@ -439,88 +425,131 @@ export function AssistantParts(props: { showReasoningSummaries?: boolean shellToolDefaultOpen?: boolean editToolDefaultOpen?: boolean + animate?: boolean }) { const data = useData() const emptyParts: PartType[] = [] - const emptyTools: ToolPart[] = [] + const log = (...args: unknown[]) => { + if (typeof window === "undefined") return + console.debug("[ui:assistant-parts]", ...args) + } - const grouped = createMemo( - () => - groupParts( - props.messages.flatMap((message) => - list(data.store.part?.[message.id], emptyParts) - .filter((part) => renderable(part, props.showReasoningSummaries ?? true)) - .map((part) => ({ - messageID: message.id, - part, - })), - ), - ), - [] as PartGroup[], - { equals: sameGroups }, - ) + const grouped = createMemo(() => { + const keys: string[] = [] + const items: Record< + string, + { type: "part"; part: PartType; message: AssistantMessage } | { type: "context"; parts: ToolPart[] } + > = {} + const push = ( + key: string, + item: { type: "part"; part: PartType; message: AssistantMessage } | { type: "context"; parts: ToolPart[] }, + ) => { + keys.push(key) + items[key] = item + } + const id = (part: PartType) => { + if (part.type === "tool") return part.callID || part.id + return part.id + } - const last = createMemo(() => grouped().at(-1)?.key) + const parts = props.messages.flatMap((message) => + list(data.store.part?.[message.id], emptyParts) + .filter((part) => renderable(part, props.showReasoningSummaries ?? true)) + .map((part) => ({ message, part })), + ) - return ( - - {(entryAccessor) => { - const entryType = createMemo(() => entryAccessor().type) + let start = -1 - return ( - - - {(() => { - const parts = createMemo( - () => { - const entry = entryAccessor() as { type: "context"; refs: PartRef[] } - return entry.refs - .map((ref) => partByID(list(data.store.part?.[ref.messageID], emptyParts), ref.partID)) - .filter((part): part is ToolPart => !!part && isContextGroupTool(part)) - }, - emptyTools, - { equals: same }, - ) - const busy = createMemo(() => props.working && last() === entryAccessor().key) + const flush = (end: number) => { + if (start < 0) return + const first = parts[start] + if (!first || !parts[end]) { + start = -1 + return + } + push(`context:${first.message.id}:${id(first.part)}`, { + type: "context", + parts: parts + .slice(start, end + 1) + .map((x) => x.part) + .filter((part): part is ToolPart => isContextGroupTool(part)), + }) + start = -1 + } - return ( - 0}> - - - ) - })()} - - - {(() => { - const message = createMemo(() => { - const entry = entryAccessor() as { type: "part"; ref: PartRef } - return props.messages.find((item) => item.id === entry.ref.messageID) - }) - const part = createMemo(() => { - const entry = entryAccessor() as { type: "part"; ref: PartRef } - return partByID(list(data.store.part?.[entry.ref.messageID], emptyParts), entry.ref.partID) - }) + parts.forEach((item, index) => { + if (isContextGroupTool(item.part)) { + if (start < 0) start = index + return + } - return ( - - {(msg) => ( - - {(p) => ( - - )} - - )} - - ) - })()} - - + flush(index - 1) + push(`part:${item.message.id}:${id(item.part)}`, { type: "part", part: item.part, message: item.message }) + }) + + flush(parts.length - 1) + + return { keys, items } + }) + + const last = createMemo(() => grouped().keys.at(-1)) + createEffect( + on( + () => grouped().keys.join("|"), + () => { + const value = grouped() + log("keys", { count: value.keys.length, keys: value.keys }) + }, + { defer: true }, + ), + ) + + return ( + + {(key, idx) => { + const item = createMemo(() => grouped().items[key]) + const ctx = createMemo(() => { + const value = item() + if (!value) return + if (value.type !== "context") return + return value + }) + const part = createMemo(() => { + const value = item() + if (!value) return + if (value.type !== "part") return + return value + }) + const tail = createMemo(() => last() === key) + const tool = createMemo(() => { + const value = part() + if (!value) return false + return value.part.type === "tool" + }) + const fade = createMemo(() => { + if (ctx()) return true + return tool() + }) + return ( + + + {(entry) => ( + + )} + + + {(entry) => ( + + )} + + ) }} @@ -614,7 +643,7 @@ export function Message(props: MessageProps) { message={userMessage() as UserMessage} parts={props.parts} interrupted={props.interrupted} - queued={props.queued} + animate={props.animate} /> )} @@ -707,18 +736,26 @@ export function AssistantMessageDisplay(props: { ) } -function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) { +function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; animate?: boolean }) { const i18n = useI18n() - const [open, setOpen] = createSignal(false) - const pending = createMemo( - () => - !!props.busy || props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"), + const anyRunning = createMemo(() => + props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"), ) + // Once all parts are done and the group is no longer busy, latch into + // "explored" permanently so brief status flickers can't jump it back. + const [settled, setSettled] = createSignal(false) + createEffect(() => { + if (!anyRunning() && !props.busy) setSettled(true) + }) + const pending = createMemo(() => !settled() && (!!props.busy || anyRunning())) const summary = createMemo(() => contextToolSummary(props.parts)) return ( - - + -
- - -
- - {(partAccessor) => { - const trigger = createMemo(() => contextToolTrigger(partAccessor(), i18n)) - const running = createMemo( - () => partAccessor().state.status === "pending" || partAccessor().state.status === "running", - ) - return ( -
-
-
-
-
-
- - - - - {trigger().subtitle} - - - - {(arg) => {arg}} - - -
+ } + > +
+ + {(part) => { + const trigger = contextToolTrigger(part, i18n) + const running = createMemo(() => part.state.status === "pending" || part.state.status === "running") + return ( +
+
+
+
+
+
+ + + + + {trigger.subtitle} + + + {(arg) => {arg}} +
- ) - }} - -
- - +
+ ) + }} + +
+ ) } @@ -809,7 +840,7 @@ export function UserMessageDisplay(props: { message: UserMessage parts: PartType[] interrupted?: boolean - queued?: boolean + animate?: boolean }) { const data = useData() const dialog = useDialog() @@ -882,93 +913,91 @@ export function UserMessageDisplay(props: { } return ( -
- 0}> -
- - {(file) => ( -
{ - if (file.mime.startsWith("image/") && file.url) { - openImagePreview(file.url, file.filename) - } - }} - > - - -
- } - > - {file.filename - -
- )} - -
- - - <> -
-
- + +
+
+ 0}> +
+ + {(file) => ( +
{ + if (file.mime.startsWith("image/") && file.url) { + openImagePreview(file.url, file.filename) + } + }} + > + + +
+ } + > + {file.filename + +
+ )} +
- -
- + + + <> +
+
+ +
-
-
-
- - - - - {metaHead()} - - - - - {"\u00A0\u00B7\u00A0"} - - - - - {metaTail()} +
+ + + + + {metaHead()} + + + + + {"\u00A0\u00B7\u00A0"} + + + + + {metaTail()} + + - - - - e.preventDefault()} - onClick={(event) => { - event.stopPropagation() - handleCopy() - }} - aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} - /> - -
- -
-
+ + e.preventDefault()} + onClick={(event) => { + event.stopPropagation() + handleCopy() + }} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} + /> + +
+ + +
+
+ ) } @@ -1023,6 +1052,7 @@ export function Part(props: MessagePartProps) { defaultOpen={props.defaultOpen} showAssistantCopyPartID={props.showAssistantCopyPartID} turnDurationMs={props.turnDurationMs} + animate={props.animate} />
) @@ -1032,12 +1062,15 @@ export interface ToolProps { input: Record metadata: Record tool: string + partID?: string + callID?: string output?: string status?: string hideDetails?: boolean defaultOpen?: boolean forceOpen?: boolean locked?: boolean + animate?: boolean } export type ToolComponent = Component @@ -1102,8 +1135,38 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre PART_MAPPING["tool"] = function ToolPartDisplay(props) { const i18n = useI18n() - const part = () => props.part as ToolPart - if (part().tool === "todowrite" || part().tool === "todoread") return null + const part = props.part as ToolPart + const log = (...args: unknown[]) => { + if (typeof window === "undefined") return + console.debug("[ui:tool-part]", part.callID, ...args) + } + const out = createMemo(() => { + const value = (part.state as Record).output + if (typeof value !== "string") return 0 + return value.length + }) + onMount(() => { + log("mount", { tool: part.tool, partID: part.id, status: part.state.status }) + }) + onCleanup(() => log("unmount", { tool: part.tool, partID: part.id })) + createEffect( + on( + () => part.state.status, + (status, prev) => log("status", { prev: prev ?? null, next: status }), + { defer: true }, + ), + ) + createEffect( + on( + out, + (next, prev) => { + if (!next && !prev) return + log("output", { prev: prev ?? 0, next }) + }, + { defer: true }, + ), + ) + if (part.tool === "todowrite" || part.tool === "todoread") return null const hideQuestion = createMemo( () => part().tool === "question" && (part().state.status === "pending" || part().state.status === "running"), @@ -1120,7 +1183,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { return ( -
+
{(error) => { @@ -1159,13 +1222,16 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { @@ -1265,8 +1331,12 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
- -
+
+
e.preventDefault()} onClick={handleCopy} aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} @@ -1287,7 +1358,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
- +
) @@ -1297,11 +1368,13 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) { const part = () => props.part as ReasoningPart const text = () => part().text.trim() const throttledText = createThrottledValue(text) + let ref: HTMLDivElement | undefined + useToolFade(() => ref) return ( -
- +
+
) @@ -1424,6 +1497,141 @@ ToolRegistry.register({ }, }) +function useToolFade(ref: () => HTMLElement | undefined, delay = 0) { + let anim: AnimationPlaybackControls | undefined + let frame: number | undefined + + onMount(() => { + const el = ref() + if (!el || typeof window === "undefined") return + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return + + el.style.opacity = "0" + el.style.filter = "blur(2px)" + el.style.transform = "translateY(0.04em)" + + frame = requestAnimationFrame(() => { + frame = undefined + const node = ref() + if (!node) return + anim = animate(node, { opacity: 1, filter: "blur(0px)", transform: "translateY(0)" }, { ...FADE_SPRING, delay }) + }) + }) + + onCleanup(() => { + if (frame !== undefined) cancelAnimationFrame(frame) + anim?.stop() + }) +} + +function WebfetchLink(props: { url: string }) { + let ref: HTMLAnchorElement | undefined + useToolFade(() => ref) + + return ( + event.stopPropagation()} + > + {props.url} + + ) +} + +function WebfetchAction() { + let ref: HTMLDivElement | undefined + useToolFade(() => ref, 0.04) + + return ( +
+ +
+ ) +} + +function TaskLink(props: { href: string; text: string; onClick: (e: MouseEvent) => void }) { + let ref: HTMLAnchorElement | undefined + useToolFade(() => ref) + + return ( + + {props.text} + + ) +} + +function ToolText(props: { text: string }) { + let ref: HTMLSpanElement | undefined + useToolFade(() => ref) + + return ( + + {props.text} + + ) +} + +type DiffValue = { additions: number; deletions: number } | { additions: number; deletions: number }[] + +function ToolFilename(props: { text: string }) { + let ref: HTMLSpanElement | undefined + useToolFade(() => ref) + + return ( + + {props.text} + + ) +} + +function ToolPath(props: { text: string }) { + let ref: HTMLDivElement | undefined + useToolFade(() => ref, 0.02) + + return ( +
+ {props.text} +
+ ) +} + +function ToolChanges(props: { changes: DiffValue }) { + let ref: HTMLDivElement | undefined + useToolFade(() => ref, 0.04) + + return ( +
+ +
+ ) +} + +function ShellText(props: { text: string }) { + let ref: HTMLSpanElement | undefined + useToolFade(() => ref) + + return ( + + + + {props.text} + + + + ) +} + ToolRegistry.register({ name: "webfetch", render(props) { @@ -1445,23 +1653,10 @@ ToolRegistry.register({ - - event.stopPropagation()} - > - {url()} - - + {(value) => }
-
- -
+
} @@ -1498,6 +1693,27 @@ ToolRegistry.register({ return `${path.slice(0, idx)}/session/${sessionId}` }) + const handleLinkClick = (e: MouseEvent) => { + const sessionId = childSessionId() + const url = href() + if (!sessionId || !url) return + + e.stopPropagation() + + if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return + + const nav = data.navigateToSession + if (!nav || typeof window === "undefined") return + + e.preventDefault() + const before = window.location.pathname + window.location.search + window.location.hash + nav(sessionId) + setTimeout(() => { + const after = window.location.pathname + window.location.search + window.location.hash + if (after === before) window.location.assign(url) + }, 50) + } + const titleContent = () => const trigger = () => ( @@ -1509,19 +1725,10 @@ ToolRegistry.register({ - {(url) => ( - e.stopPropagation()} - > - {description()} - - )} + {(url) => } - {description()} + @@ -1529,7 +1736,7 @@ ToolRegistry.register({
) - return + return }, }) @@ -1538,12 +1745,22 @@ ToolRegistry.register({ render(props) { const i18n = useI18n() const pending = () => props.status === "pending" || props.status === "running" - const sawPending = pending() - const text = createMemo(() => { + const subtitle = () => props.input.description ?? props.metadata.description + const output = createMemo(() => { + if (typeof props.output === "string") return props.output + if (typeof props.metadata.output === "string") return props.metadata.output + return "" + }) + const command = createMemo(() => { const cmd = props.input.command ?? props.metadata.command ?? "" - const out = stripAnsi(props.output || props.metadata.output || "") - return `$ ${cmd}${out ? "\n\n" + out : ""}` + return `$ ${cmd}` }) + const result = createMemo(() => stripAnsi(output())) + const text = createMemo(() => { + const value = result() + return `${command()}${value ? "\n\n" + value : ""}` + }) + const hasOutput = createMemo(() => result().length > 0) const [copied, setCopied] = createSignal(false) const handleCopy = async () => { @@ -1558,15 +1775,17 @@ ToolRegistry.register({
- - - + {(text) => }
} @@ -1622,18 +1841,16 @@ ToolRegistry.register({ - {filename()} +
-
- {getDirectory(props.input.filePath!)} -
+
- + {(changes) => }
@@ -1643,7 +1860,7 @@ ToolRegistry.register({ {(diff) => } + {(diff) => } } >
@@ -1692,13 +1909,11 @@ ToolRegistry.register({ - {filename()} +
-
- {getDirectory(props.input.filePath!)} -
+
{/* */}
@@ -1778,10 +1993,18 @@ ToolRegistry.register({ {...props} icon="code-lines" defer - trigger={{ - title: i18n.t("ui.tool.patch"), - subtitle: subtitle(), - }} + trigger={ +
+
+
+ + + + {(text) => } +
+
+
+ } > 0}> - {getFilename(file().relativePath)} +
-
- {getDirectory(file().relativePath)} -
+
- +
@@ -1921,7 +2142,7 @@ ToolRegistry.register({ - + } @@ -2012,7 +2233,7 @@ ToolRegistry.register({ return ( ) - return + return }, }) diff --git a/packages/ui/src/components/motion.tsx b/packages/ui/src/components/motion.tsx new file mode 100644 index 00000000000..5d0b69a5ef2 --- /dev/null +++ b/packages/ui/src/components/motion.tsx @@ -0,0 +1,24 @@ +export { animate, springValue } from "motion" +export type { AnimationPlaybackControls } from "motion" + +export const HEIGHT_DURATION = 0.3 +export const FADE_DURATION = 0.5 +export const TOGGLE_DURATION = 0.3 + +export const HEIGHT_SPRING = { + type: "spring" as const, + visualDuration: HEIGHT_DURATION, + bounce: 0, +} + +export const TOGGLE_SPRING = { + type: "spring" as const, + visualDuration: TOGGLE_DURATION, + bounce: 0, +} + +export const FADE_SPRING = { + type: "spring" as const, + visualDuration: FADE_DURATION, + bounce: 0, +} diff --git a/packages/ui/src/components/session-timeline-simulator.stories.tsx b/packages/ui/src/components/session-timeline-simulator.stories.tsx new file mode 100644 index 00000000000..f5248c96a53 --- /dev/null +++ b/packages/ui/src/components/session-timeline-simulator.stories.tsx @@ -0,0 +1,943 @@ +// @ts-nocheck +import { createSignal, createMemo, createEffect, on, onCleanup, batch, For, Show } from "solid-js" +import { createStore, produce } from "solid-js/store" +import type { + Message, + UserMessage, + AssistantMessage, + Part, + TextPart, + ReasoningPart, + ToolPart, + SessionStatus, +} from "@opencode-ai/sdk/v2" +import { DataProvider } from "../context/data" +import { FileComponentProvider } from "../context/file" +import { SessionTurn } from "./session-turn" + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const SESSION_ID = "sim-session-1" +const USER_MSG_ID = "msg-user-1" +const ASST_MSG_ID = "msg-asst-1" +const T0 = Date.now() + +// --------------------------------------------------------------------------- +// Timeline event types +// --------------------------------------------------------------------------- + +type TimelineEvent = + | { type: "message"; message: Message } + | { type: "part"; part: Part } + | { type: "part-update"; messageID: string; partID: string; patch: Record } + | { type: "status"; status: SessionStatus } + | { type: "delay"; ms: number; label?: string } + +// --------------------------------------------------------------------------- +// Helpers to build mock data +// --------------------------------------------------------------------------- + +let _pid = 0 +const pid = () => `p-${++_pid}` +const cid = () => `c-${_pid}` + +function mkUser(id: string): UserMessage { + return { + id, + sessionID: SESSION_ID, + role: "user", + time: { created: T0 }, + agent: "assistant", + model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" }, + } +} + +function mkAssistant(id: string, parentID: string, completed?: number): AssistantMessage { + return { + id, + sessionID: SESSION_ID, + role: "assistant", + time: { created: T0 + 100, completed }, + parentID, + modelID: "claude-sonnet-4-20250514", + providerID: "anthropic", + mode: "default", + agent: "assistant", + path: { cwd: "/Users/kit/project", root: "/Users/kit/project" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + } +} + +function mkText(messageID: string, text: string): TextPart { + return { id: pid(), sessionID: SESSION_ID, messageID, type: "text", text } +} + +function mkReasoning(messageID: string, text: string): ReasoningPart { + return { + id: pid(), + sessionID: SESSION_ID, + messageID, + type: "reasoning", + text, + time: { start: T0 + 200 }, + } +} + +function mkTool(messageID: string, tool: string, input: Record): ToolPart { + const id = pid() + return { + id, + sessionID: SESSION_ID, + messageID, + type: "tool", + callID: cid(), + tool, + state: { status: "pending", input, raw: JSON.stringify(input) }, + } +} + +function toolRunning(part: ToolPart, title: string, t: number): Record { + return { + state: { status: "running", input: part.state.input, title, time: { start: t } }, + } +} + +function toolCompleted( + part: ToolPart, + title: string, + output: string, + tStart: number, + tEnd: number, +): Record { + return { + state: { + status: "completed", + input: part.state.input, + output, + title, + metadata: {}, + time: { start: tStart, end: tEnd }, + }, + } +} + +// --------------------------------------------------------------------------- +// Build the timeline +// --------------------------------------------------------------------------- + +function buildTimeline() { + _pid = 0 + const events: TimelineEvent[] = [] + const e = (ev: TimelineEvent) => events.push(ev) + const delay = (ms: number, label?: string) => e({ type: "delay", ms, label }) + const status = (s: SessionStatus) => e({ type: "status", status: s }) + const msg = (m: Message) => e({ type: "message", message: m }) + const part = (p: Part) => e({ type: "part", part: p }) + const upd = (p: Part, patch: Record) => + e({ type: "part-update", messageID: p.messageID, partID: p.id, patch }) + + // ── User message ────────────────────────────────────────────────────── + const userText = mkText(USER_MSG_ID, "Quick 1 second sleep then cowsay") + msg(mkUser(USER_MSG_ID)) + part(userText) + + // ── Session goes busy ───────────────────────────────────────────────── + status({ type: "busy" }) + + // ── Assistant starts (incomplete — no time.completed) ───────────────── + msg(mkAssistant(ASST_MSG_ID, USER_MSG_ID)) + + delay(600, "Thinking shimmer animates in...") + + // ── Context gathering: read → grep → glob → list (all grouped) ────── + const readPart = mkTool(ASST_MSG_ID, "read", { + filePath: "/Users/kit/project/packages/opencode/src/tool/bash.ts", + }) + part(readPart) + delay(120) + upd(readPart, toolRunning(readPart, "bash.ts", T0 + 700)) + delay(350) + upd( + readPart, + toolCompleted(readPart, "bash.ts", 'export const bash = Tool.define({ name: "bash", ... })', T0 + 700, T0 + 1050), + ) + + const grepPart = mkTool(ASST_MSG_ID, "grep", { pattern: "Tool.define", path: "/Users/kit/project" }) + part(grepPart) + delay(100) + upd(grepPart, toolRunning(grepPart, "Searching for Tool.define", T0 + 1150)) + delay(500) + upd( + grepPart, + toolCompleted( + grepPart, + "22 Tool.define matches in packages/opencode/src/tool", + "22 matches found", + T0 + 1150, + T0 + 1650, + ), + ) + + const globPart = mkTool(ASST_MSG_ID, "glob", { pattern: "**/*.test.ts", path: "/Users/kit/project" }) + part(globPart) + delay(80) + upd(globPart, toolRunning(globPart, "Searching for **/*.test.ts", T0 + 1730)) + delay(400) + upd(globPart, toolCompleted(globPart, "many **/*.test.ts files found", "47 files matched", T0 + 1730, T0 + 2130)) + + const listPart = mkTool(ASST_MSG_ID, "list", { path: "/Users/kit/project/packages/opencode/src/tool" }) + part(listPart) + delay(80) + upd(listPart, toolRunning(listPart, "tool directory", T0 + 2210)) + delay(260) + upd( + listPart, + toolCompleted(listPart, "tool directory", "bash.ts\nread.ts\nglob.ts\ngrep.ts\nwebfetch.ts", T0 + 2210, T0 + 2470), + ) + + delay(250, "Context group settles") + + // ── Reasoning part (shows as heading next to thinking shimmer) ──────── + const reasoning = mkReasoning( + ASST_MSG_ID, + "## Analyzing the codebase structure\n\nLooking at tool definitions and test coverage patterns.", + ) + part(reasoning) + + delay(300) + + // ── Shell tool: bash ────────────────────────────────────────────────── + const shellInput = { + command: 'sleep 1 && cowsay "Quick demo!"', + description: "Quick 1 second sleep then cowsay", + } + const shellPart = mkTool(ASST_MSG_ID, "bash", shellInput) + part(shellPart) + delay(200, "Shell pending") + upd(shellPart, toolRunning(shellPart, 'sleep 1 && cowsay "Quick demo!"', T0 + 2800)) + delay(1400, "Shell running...") + + const cowsay = ` ______________ +< Quick demo! > + -------------- + \\ ^__^ + \\ (oo)\\_______ + (__)\\ )\\/\\ + ||----w | + || ||` + + upd(shellPart, toolCompleted(shellPart, 'sleep 1 && cowsay "Quick demo!"', cowsay, T0 + 2800, T0 + 4200)) + delay(250) + + // ── WebFetch tool ───────────────────────────────────────────────────── + const fetchInput = { url: "https://api.github.com/zen", prompt: "What is the zen of GitHub?" } + const fetchPart = mkTool(ASST_MSG_ID, "webfetch", fetchInput) + part(fetchPart) + delay(150) + upd(fetchPart, toolRunning(fetchPart, "https://api.github.com/zen", T0 + 4450)) + delay(900, "WebFetch running...") + upd( + fetchPart, + toolCompleted( + fetchPart, + "https://api.github.com/zen", + '"Half measures are as bad as nothing at all."', + T0 + 4450, + T0 + 5350, + ), + ) + + // ── Task tool ───────────────────────────────────────────────────────── + const taskInput = { description: "Explore Effect docs", subagent_type: "general" } + const taskPart = mkTool(ASST_MSG_ID, "task", taskInput) + part(taskPart) + delay(120) + upd(taskPart, toolRunning(taskPart, "Exploring Effect docs", T0 + 5470)) + delay(500) + upd(taskPart, { + state: { + status: "completed", + input: taskInput, + output: "Found 3 references", + title: "Explored Effect docs", + metadata: { sessionId: "sim-subagent-1" }, + time: { start: T0 + 5470, end: T0 + 5970 }, + }, + }) + + // ── Edit tool ───────────────────────────────────────────────────────── + const editInput = { + filePath: "/Users/kit/project/packages/opencode/src/tool/bash.ts", + oldString: "const cmd = input.command", + newString: "const cmd = sanitize(input.command)", + } + const editPart = mkTool(ASST_MSG_ID, "edit", editInput) + part(editPart) + delay(120) + upd(editPart, toolRunning(editPart, "bash.ts", T0 + 6090)) + delay(320) + upd(editPart, { + state: { + status: "completed", + input: editInput, + title: "Updated bash.ts", + metadata: { + filediff: { + file: editInput.filePath, + before: "const cmd = input.command", + after: "const cmd = sanitize(input.command)", + additions: 1, + deletions: 1, + }, + diagnostics: {}, + }, + time: { start: T0 + 6090, end: T0 + 6410 }, + }, + }) + + // ── Write tool ──────────────────────────────────────────────────────── + const writeInput = { + filePath: "/Users/kit/project/packages/opencode/notes/simulator.md", + content: "# Timeline Simulator\n\n- Added all tool-call variants for animation testing.", + } + const writePart = mkTool(ASST_MSG_ID, "write", writeInput) + part(writePart) + delay(100) + upd(writePart, toolRunning(writePart, "simulator.md", T0 + 6510)) + delay(220) + upd(writePart, { + state: { + status: "completed", + input: writeInput, + title: "Created simulator.md", + metadata: { diagnostics: {} }, + time: { start: T0 + 6510, end: T0 + 6730 }, + }, + }) + + // ── Apply Patch tool ────────────────────────────────────────────────── + const patchFiles = [ + { + filePath: "/Users/kit/project/packages/opencode/src/tool/new-tool.ts", + relativePath: "packages/opencode/src/tool/new-tool.ts", + type: "add", + diff: "+export const newTool = true", + before: "", + after: "export const newTool = true\n", + additions: 1, + deletions: 0, + }, + ] + const patchInput = { files: patchFiles.map((f) => ({ filePath: f.filePath })) } + const patchPart = mkTool(ASST_MSG_ID, "apply_patch", patchInput) + part(patchPart) + delay(120) + upd(patchPart, toolRunning(patchPart, "Applying patch", T0 + 6850)) + delay(260) + upd(patchPart, { + state: { + status: "completed", + input: patchInput, + title: "Applied patch to 1 file", + metadata: { files: patchFiles }, + time: { start: T0 + 6850, end: T0 + 7110 }, + }, + }) + + // ── Question tool ───────────────────────────────────────────────────── + const questionInput = { + questions: [{ question: "Proceed with the refactor?", options: ["yes", "no"] }], + } + const questionPart = mkTool(ASST_MSG_ID, "question", questionInput) + part(questionPart) + delay(180) + upd(questionPart, { + state: { + status: "completed", + input: questionInput, + title: "Question answered", + metadata: { answers: [["yes"]] }, + time: { start: T0 + 7290, end: T0 + 7290 }, + }, + }) + + // ── Skill tool ──────────────────────────────────────────────────────── + const skillInput = { name: "effect" } + const skillPart = mkTool(ASST_MSG_ID, "skill", skillInput) + part(skillPart) + delay(90) + upd(skillPart, toolRunning(skillPart, "effect", T0 + 7380)) + delay(220) + upd(skillPart, toolCompleted(skillPart, "effect", "Loaded skill docs", T0 + 7380, T0 + 7600)) + + // ── Generic tools (non-registered) ─────────────────────────────────── + const exaSearch = mkTool(ASST_MSG_ID, "exa_web_search_exa", { query: "effect schema validation" }) + part(exaSearch) + delay(80) + upd(exaSearch, toolRunning(exaSearch, "effect schema validation", T0 + 7680)) + delay(180) + upd(exaSearch, toolCompleted(exaSearch, "effect schema validation", "8 search results", T0 + 7680, T0 + 7860)) + + const exaCode = mkTool(ASST_MSG_ID, "exa_get_code_context_exa", { query: "Effect.gen examples" }) + part(exaCode) + delay(80) + upd(exaCode, toolRunning(exaCode, "Effect.gen examples", T0 + 7940)) + delay(180) + upd(exaCode, toolCompleted(exaCode, "Effect.gen examples", "Collected docs snippets", T0 + 7940, T0 + 8120)) + + const effectTool = mkTool(ASST_MSG_ID, "effect", { topic: "Layer setup" }) + part(effectTool) + delay(80) + upd(effectTool, toolRunning(effectTool, "Layer setup", T0 + 8200)) + delay(180) + upd(effectTool, toolCompleted(effectTool, "Layer setup", "Mapped Layer dependencies", T0 + 8200, T0 + 8380)) + + delay(200) + + // ── Final text response ─────────────────────────────────────────────── + // This is when thinking hides (tail becomes "text") + const finalText = mkText( + ASST_MSG_ID, + `Done! I ran all tool variants in this simulator:\n\n- Context tools: read, grep, glob, list\n- Action tools: bash, webfetch, task\n- File tools: edit, write, apply_patch\n- Decision tools: question, skill\n- Generic tools: exa_web_search_exa, exa_get_code_context_exa, effect`, + ) + part(finalText) + delay(300, "Text arrives, thinking hides") + + // ── Complete the assistant message ──────────────────────────────────── + msg(mkAssistant(ASST_MSG_ID, USER_MSG_ID, T0 + 9200)) + + // ── Session idle ────────────────────────────────────────────────────── + status({ type: "idle" }) + + return events +} + +// --------------------------------------------------------------------------- +// Store-backed playback engine +// --------------------------------------------------------------------------- + +function createPlayback(events: TimelineEvent[]) { + const [step, setStep] = createSignal(0) + const [playing, setPlaying] = createSignal(false) + const [speed, setSpeed] = createSignal(1) + const totalSteps = events.length + + // Reactive store shaped exactly like Data from context/data.tsx + const [data, setData] = createStore({ + session: [], + session_status: {}, + session_diff: {}, + message: {}, + part: {}, + }) + + // Apply a single event to the store + function applyEvent(event: TimelineEvent) { + switch (event.type) { + case "status": + setData("session_status", SESSION_ID, event.status) + break + + case "message": + setData( + produce((d) => { + if (!d.message[SESSION_ID]) d.message[SESSION_ID] = [] + const list = d.message[SESSION_ID] + const idx = list.findIndex((m) => m.id === event.message.id) + if (idx >= 0) { + list[idx] = event.message + } else { + list.push(event.message) + } + }), + ) + break + + case "part": + setData( + produce((d) => { + const mid = event.part.messageID + if (!d.part[mid]) d.part[mid] = [] + d.part[mid].push(event.part) + }), + ) + break + + case "part-update": + setData( + produce((d) => { + const list = d.part[event.messageID] + if (!list) return + const idx = list.findIndex((p) => p.id === event.partID) + if (idx < 0) return + Object.assign(list[idx], event.patch) + }), + ) + break + } + } + + // Reset the store to empty + function resetStore() { + setData({ + session: [], + session_status: {}, + session_diff: {}, + message: {}, + part: {}, + }) + } + + // Replay events [0, target) into a fresh store + function replayTo(target: number) { + resetStore() + batch(() => { + for (let i = 0; i < target && i < events.length; i++) { + applyEvent(events[i]) + } + }) + } + + // When step changes, figure out if we can just apply forward or need a full replay + let appliedStep = 0 + + createEffect( + on(step, (target) => { + if (target > appliedStep) { + // Forward: apply events [appliedStep, target) + batch(() => { + for (let i = appliedStep; i < target && i < events.length; i++) { + applyEvent(events[i]) + } + }) + } else if (target < appliedStep) { + // Backward: full replay + replayTo(target) + } + appliedStep = target + }), + ) + + // Auto-play timer + let timer: ReturnType | undefined + + const stopTimer = () => { + if (timer !== undefined) { + clearTimeout(timer) + timer = undefined + } + } + + const scheduleNext = () => { + stopTimer() + if (!playing()) return + const current = step() + if (current >= totalSteps) { + setPlaying(false) + return + } + const event = events[current] + const delay = event?.type === "delay" ? Math.max(20, event.ms / speed()) : 60 / speed() + + timer = setTimeout(() => { + if (!playing()) return + const next = step() + 1 + if (next > totalSteps) { + setPlaying(false) + return + } + setStep(next) + scheduleNext() + }, delay) + } + + const play = () => { + if (step() >= totalSteps) { + setStep(0) + appliedStep = 0 + resetStore() + } + setPlaying(true) + scheduleNext() + } + + const pause = () => { + setPlaying(false) + stopTimer() + } + + const togglePlay = () => (playing() ? pause() : play()) + + const stepForward = () => { + pause() + let next = step() + 1 + while (next < totalSteps && events[next]?.type === "delay") next++ + setStep(Math.min(next, totalSteps)) + } + + const stepBack = () => { + pause() + let next = step() - 1 + while (next > 0 && events[next - 1]?.type === "delay") next-- + setStep(Math.max(next, 0)) + } + + const reset = () => { + pause() + setStep(0) + appliedStep = 0 + resetStore() + } + + const jumpTo = (s: number) => { + pause() + setStep(Math.max(0, Math.min(s, totalSteps))) + } + + // Event label + const label = createMemo(() => { + const s = step() + if (s <= 0) return "Start" + if (s >= totalSteps) return "Complete" + const ev = events[s - 1] + if (!ev) return "" + switch (ev.type) { + case "message": + return `${ev.message.role} message` + case "part": { + const p = ev.part + if (p.type === "tool") return `tool (${p.tool}) pending` + if (p.type === "reasoning") return "reasoning" + return p.type + } + case "part-update": + return `part update` + case "status": + return `status: ${ev.status.type}` + case "delay": + return ev.label || `delay ${ev.ms}ms` + } + }) + + return { + step, + totalSteps, + playing, + speed, + setSpeed, + data, + label, + play, + pause, + togglePlay, + stepForward, + stepBack, + reset, + jumpTo, + cleanup: stopTimer, + } +} + +// --------------------------------------------------------------------------- +// Placeholder file component (for FileComponentProvider) +// --------------------------------------------------------------------------- + +function PlaceholderFile(props: any) { + return ( +
+      {props.mode === "diff" ? `--- ${props.before?.name}\n+++ ${props.after?.name}` : "file"}
+    
+ ) +} + +// --------------------------------------------------------------------------- +// Control UI helpers +// --------------------------------------------------------------------------- + +function Btn(props: { onClick: () => void; title?: string; children: any }) { + return ( + + ) +} + +function Toggle(props: { label: string; value: boolean; onChange: (v: boolean) => void }) { + return ( + + ) +} + +// --------------------------------------------------------------------------- +// Simulator component +// --------------------------------------------------------------------------- + +function SessionTimelineSimulator() { + const events = buildTimeline() + const pb = createPlayback(events) + + // Controls + const [showReasoningSummaries, setShowReasoningSummaries] = createSignal(false) + const [animateEnabled, setAnimateEnabled] = createSignal(true) + const [shellOpen, setShellOpen] = createSignal(true) + + onCleanup(pb.cleanup) + + // Keyboard: left/right arrow to step, space to play/pause + const onKey = (e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return + if (e.key === "ArrowRight") { + e.preventDefault() + pb.stepForward() + } else if (e.key === "ArrowLeft") { + e.preventDefault() + pb.stepBack() + } else if (e.key === " ") { + e.preventDefault() + pb.togglePlay() + } + } + window.addEventListener("keydown", onKey) + onCleanup(() => window.removeEventListener("keydown", onKey)) + + const progress = createMemo(() => (pb.step() / pb.totalSteps) * 100) + + return ( +
+ {/* Main content */} +
+ + + + + +
+ + {/* Controls panel */} +
+ {/* Scrubber */} +
{ + const rect = e.currentTarget.getBoundingClientRect() + const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) + pb.jumpTo(Math.round(ratio * pb.totalSteps)) + }} + > +
+
+ + {/* Transport + info */} +
+
+ + ⏮ + + + ⏪ + + + {pb.playing() ? "⏸" : "▶"} + + + ⏩ + +
+ + + {pb.step()}/{pb.totalSteps} + + + + {pb.label()} + + + {/* Speed */} +
+ Speed + + {(s) => ( + + )} + +
+
+ + {/* Toggles */} +
+ + + +
+
+
+ ) +} + +// --------------------------------------------------------------------------- +// Storybook exports +// --------------------------------------------------------------------------- + +export default { + title: "Session/Timeline Simulator", + id: "session-timeline-simulator", + tags: ["autodocs"], + parameters: { + layout: "fullscreen", + docs: { + description: { + component: `### Session Timeline Simulator + +Replays a mock session timeline step-by-step to test height animations, +GrowBox transitions, thinking shimmer persistence, and part rendering. + +**Key behavior to observe:** +- With \`showReasoningSummaries=false\` (default): thinking shimmer stays at the bottom throughout tool execution +- With \`showReasoningSummaries=true\`: thinking hides as soon as the first tool part appears +- Context tools (read/grep/glob/list) group into a "Gathering context" section +- Includes all visible tool renderers: bash, webfetch, task, edit, write, apply_patch, question, skill +- Also includes generic unknown tools (exa_web_search_exa, exa_get_code_context_exa, effect) +- Each row animates in via GrowBox (height spring + fade) +- The thinking shimmer shows a reasoning heading via TextReveal + +**Controls:** +- Transport: reset / step back / play-pause / step forward +- Click the scrubber bar to jump to any point +- Speed buttons: 0.25x to 4x +- Toggles: showReasoningSummaries, animate, shellToolDefaultOpen +`, + }, + }, + }, +} + +export const Playback = { + render: () => , +} diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index 15d7b503525..8c1bc39b2f0 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -26,7 +26,7 @@ align-items: flex-start; align-self: stretch; min-width: 0; - gap: 18px; + gap: 0px; overflow-anchor: none; } @@ -43,12 +43,22 @@ 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); @@ -60,13 +70,40 @@ width: 16px; height: 16px; } + + > [data-component="text-shimmer"] { + flex: 0 0 auto; + white-space: nowrap; + } + } + + [data-slot="session-turn-thinking-wrap"] { + width: 100%; + min-width: 0; + overflow: visible; + } + + [data-slot="session-turn-thinking"] { + position: relative; + will-change: opacity, filter, transform; } [data-component="text-reveal"].session-turn-thinking-heading { flex: 1 1 auto; min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + 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 +121,7 @@ display: flex; flex-direction: column; align-self: stretch; - gap: 12px; + gap: 0px; > :first-child > [data-component="markdown"]:first-child { margin-top: 0; diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 1178d2e8c9a..c57082a91be 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -5,8 +5,9 @@ import { useFileComponent } from "../context/file" 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 { animate, type AnimationPlaybackControls, FADE_SPRING, HEIGHT_SPRING } from "./motion" import { AssistantParts, Message, Part, PART_MAPPING } from "./message-part" import { Card } from "./card" import { Accordion } from "./accordion" @@ -18,6 +19,8 @@ import { TextShimmer } from "./text-shimmer" import { TextReveal } from "./text-reveal" import { createAutoScroll } from "../hooks" import { useI18n } from "../context/i18n" +const THINKING_GAP_PX = 12 +const THINKING_HIDE_DELAY_MS = 220 function record(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value) @@ -140,6 +143,7 @@ export function SessionTurn( props: ParentProps<{ sessionID: string messageID: string + animate?: boolean showReasoningSummaries?: boolean shellToolDefaultOpen?: boolean editToolDefaultOpen?: boolean @@ -225,7 +229,6 @@ export function SessionTurn( if (!item) return false return id > item.id }) - const parts = createMemo(() => { const msg = message() if (!msg) return emptyParts @@ -312,12 +315,21 @@ 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 latestUserID = createMemo(() => { + const messages = allMessages() ?? emptyMessages + const latest = messages.findLast((item) => item.role === "user") + if (!latest || latest.role !== "user") return undefined + return latest.id + }) + const working = createMemo(() => { + if (status().type === "idle") return false + const msg = message() + if (!msg) return false + const item = pending() + if (item) return item.parentID === msg.id + return latestUserID() === msg.id }) - const working = createMemo(() => status().type !== "idle" && active()) const showReasoningSummaries = createMemo(() => props.showReasoningSummaries ?? true) const assistantCopyPartID = createMemo(() => { @@ -370,6 +382,168 @@ export function SessionTurn( if (showReasoningSummaries()) return assistantVisible() === 0 return true }) + const hasAssistant = createMemo(() => assistantMessages().length > 0) + const [shown, setShown] = createSignal(showThinking()) + let hideTimer: ReturnType | undefined + const thinking = createMemo(() => shown()) + const lane = createMemo(() => hasAssistant() || thinking()) + const entry = createMemo(() => props.animate !== false) + const log = (...args: unknown[]) => { + if (typeof window === "undefined") return + console.debug("[ui:thinking]", ...args) + } + const initialThinking = thinking() + let thinkingRef: HTMLDivElement | undefined + let thinkingBodyRef: HTMLDivElement | undefined + let thinkingAnim: AnimationPlaybackControls | undefined + let thinkingHeightAnim: AnimationPlaybackControls | undefined + let thinkingToggleFrame: number | undefined + const gap = () => (hasAssistant() ? `${THINKING_GAP_PX}px` : "0px") + + const stopHide = () => { + if (!hideTimer) return + clearTimeout(hideTimer) + hideTimer = undefined + } + + const showBox = () => { + if (!thinkingRef || !thinkingBodyRef) return + thinkingAnim?.stop() + thinkingHeightAnim?.stop() + const next = Math.max(1, Math.ceil(thinkingBodyRef.getBoundingClientRect().height)) + const prev = Math.max(0, Math.ceil(thinkingRef.getBoundingClientRect().height)) + if (!entry()) { + thinkingRef.style.overflow = "visible" + thinkingRef.style.height = "auto" + thinkingRef.style.marginTop = gap() + thinkingBodyRef.style.opacity = "1" + thinkingBodyRef.style.filter = "blur(0px)" + thinkingBodyRef.style.transform = "" + log("open-done") + return + } + thinkingRef.style.overflow = "hidden" + thinkingRef.style.height = `${prev}px` + thinkingRef.style.marginTop = prev > 0 ? gap() : "0px" + thinkingHeightAnim = animate( + thinkingRef, + { + height: `${next}px`, + marginTop: gap(), + }, + HEIGHT_SPRING, + ) + thinkingHeightAnim.finished.then(() => { + if (!thinkingRef || !thinking()) return + thinkingRef.style.height = "auto" + thinkingRef.style.marginTop = gap() + thinkingRef.style.overflow = "visible" + log("open-done") + }) + thinkingBodyRef.style.opacity = "0" + thinkingBodyRef.style.filter = "blur(2px)" + thinkingBodyRef.style.transform = "" + thinkingAnim = animate( + thinkingBodyRef, + { + opacity: 1, + filter: "blur(0px)", + }, + FADE_SPRING, + ) + } + + const hideBox = () => { + if (!thinkingRef || !thinkingBodyRef) return + thinkingAnim?.stop() + thinkingHeightAnim?.stop() + if (!entry()) { + thinkingRef.style.height = "0px" + thinkingRef.style.marginTop = "0px" + thinkingRef.style.overflow = "hidden" + thinkingBodyRef.style.opacity = "0" + thinkingBodyRef.style.filter = "blur(2px)" + thinkingBodyRef.style.transform = "" + log("close-done") + return + } + thinkingRef.style.overflow = "hidden" + const h = Math.max(1, Math.ceil(thinkingRef.getBoundingClientRect().height)) + thinkingRef.style.height = `${h}px` + thinkingHeightAnim = animate( + thinkingRef, + { + height: "0px", + marginTop: "0px", + }, + HEIGHT_SPRING, + ) + thinkingAnim = animate( + thinkingBodyRef, + { + opacity: 0, + filter: "blur(2px)", + }, + FADE_SPRING, + ) + thinkingHeightAnim.finished.then(() => { + if (!thinkingRef || thinking()) return + thinkingRef.style.height = "0px" + thinkingRef.style.marginTop = "0px" + thinkingRef.style.overflow = "hidden" + log("close-done") + }) + } + + createEffect( + on( + showThinking, + (value) => { + log("source", { + value, + working: working(), + status: status().type, + visible: assistantVisible(), + tail: assistantTailVisible(), + }) + stopHide() + if (value) { + if (!shown()) setShown(true) + return + } + hideTimer = setTimeout(() => { + hideTimer = undefined + if (showThinking()) return + if (!shown()) return + setShown(false) + }, THINKING_HIDE_DELAY_MS) + }, + { defer: true }, + ), + ) + + createEffect( + on( + thinking, + (value) => { + log("toggle", { value }) + if (thinkingToggleFrame !== undefined) { + cancelAnimationFrame(thinkingToggleFrame) + thinkingToggleFrame = undefined + } + if (value) { + thinkingToggleFrame = requestAnimationFrame(() => { + thinkingToggleFrame = undefined + if (!thinking()) return + showBox() + }) + return + } + hideBox() + }, + { defer: true }, + ), + ) const autoScroll = createAutoScroll({ working, @@ -377,6 +551,13 @@ export function SessionTurn( overflowAnchor: "dynamic", }) + onCleanup(() => { + stopHide() + if (thinkingToggleFrame !== undefined) cancelAnimationFrame(thinkingToggleFrame) + thinkingAnim?.stop() + thinkingHeightAnim?.stop() + }) + return (
- +
{(part) => ( @@ -404,27 +585,45 @@ export function SessionTurn(
)} - 0}> -
- -
-
- -
- - - - +
+ +
+ +
+
+
+
+ + +
- +
0 && !working()}>
diff --git a/packages/ui/src/components/text-shimmer.css b/packages/ui/src/components/text-shimmer.css index f042dd2d862..bd1437c273b 100644 --- a/packages/ui/src/components/text-shimmer.css +++ b/packages/ui/src/components/text-shimmer.css @@ -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; } diff --git a/packages/ui/src/components/text-shimmer.stories.tsx b/packages/ui/src/components/text-shimmer.stories.tsx index a88a7158b11..5da7a7e2a6c 100644 --- a/packages/ui/src/components/text-shimmer.stories.tsx +++ b/packages/ui/src/components/text-shimmer.stories.tsx @@ -90,3 +90,37 @@ export const Inactive = { active: false, }, } + +export const Sizes = { + render: () => { + const samples = [ + "Shell", + "Edit", + "Read", + "Thinking", + "Loading...", + "Running command", + "Searching codebase for matches", + ] + return ( +
+ {samples.map((text) => ( +
+ + {text.length}ch + + +
+ ))} +
+ ) + }, +} diff --git a/packages/ui/src/components/text-shimmer.tsx b/packages/ui/src/components/text-shimmer.tsx index c4c20b8e768..7ad4c093c9b 100644 --- a/packages/ui/src/components/text-shimmer.tsx +++ b/packages/ui/src/components/text-shimmer.tsx @@ -36,6 +36,19 @@ export const TextShimmer = (props: { clearTimeout(timer) }) + const shimmerSize = createMemo(() => { + const len = Math.max(props.text.length, 1) + return Math.max(300, Math.round(200 + 1400 / len)) + }) + + // duration = len × (size - 1) / velocity → uniform perceived sweep speed + const VELOCITY = 0.0125 // ch per ms, calibrated to "Shell" at 600%/2000ms + const shimmerDuration = createMemo(() => { + const len = Math.max(props.text.length, 1) + const s = shimmerSize() / 100 + return Math.max(1000, Math.min(2500, Math.round((len * (s - 1)) / VELOCITY))) + }) + return ( (props: { style={{ "--text-shimmer-swap": `${swap}ms`, "--text-shimmer-index": `${offset()}`, + "--text-shimmer-size": `${shimmerSize()}%`, + "--text-shimmer-duration": `${shimmerDuration()}ms`, }} > diff --git a/packages/ui/src/components/tool-status-title.css b/packages/ui/src/components/tool-status-title.css index d4415bd2daf..8978105c2f1 100644 --- a/packages/ui/src/components/tool-status-title.css +++ b/packages/ui/src/components/tool-status-title.css @@ -20,7 +20,6 @@ display: inline-grid; overflow: hidden; 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"], diff --git a/packages/ui/src/components/tool-status-title.tsx b/packages/ui/src/components/tool-status-title.tsx index 4cf8f15abd1..269179f70bb 100644 --- a/packages/ui/src/components/tool-status-title.tsx +++ b/packages/ui/src/components/tool-status-title.tsx @@ -1,4 +1,5 @@ import { Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js" +import { animate, type AnimationPlaybackControls, HEIGHT_SPRING } from "./motion" import { TextShimmer } from "./text-shimmer" function common(active: string, done: string) { @@ -35,17 +36,53 @@ export function ToolStatusTitle(props: { const activeTail = createMemo(() => (suffix() ? split().active : props.activeText)) const doneTail = createMemo(() => (suffix() ? split().done : 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 = () => { + if (typeof window === "undefined") return false + return window.matchMedia("(prefers-reduced-motion: reduce)").matches + } + + 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` }, HEIGHT_SPRING) + widthAnim.finished.then(() => { + const el = node() + if (!el) return + el.style.width = `${next}px` + }) } const schedule = () => { @@ -72,12 +109,7 @@ export function ToolStatusTitle(props: { }) } - createEffect( - on( - [() => props.active, activeTail, doneTail, suffix], - () => schedule(), - ), - ) + createEffect(on([() => props.active, activeTail, doneTail, suffix], () => schedule())) onMount(() => { measure() @@ -95,6 +127,7 @@ export function ToolStatusTitle(props: { onCleanup(() => { if (frame !== undefined) cancelAnimationFrame(frame) if (readyFrame !== undefined) cancelAnimationFrame(readyFrame) + widthAnim?.stop() }) return ( @@ -109,7 +142,7 @@ export function ToolStatusTitle(props: { + @@ -123,7 +156,7 @@ export function ToolStatusTitle(props: { - + diff --git a/packages/ui/src/hooks/create-auto-scroll.tsx b/packages/ui/src/hooks/create-auto-scroll.tsx index 3dc520c6213..cd5caafb846 100644 --- a/packages/ui/src/hooks/create-auto-scroll.tsx +++ b/packages/ui/src/hooks/create-auto-scroll.tsx @@ -1,6 +1,7 @@ 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" export interface AutoScrollOptions { working: () => boolean @@ -9,13 +10,17 @@ export interface AutoScrollOptions { bottomThreshold?: number } +const SETTLE_MS = 500 +const AUTO_SCROLL_GRACE_MS = 120 +const AUTO_SCROLL_EPSILON = 1 + export function createAutoScroll(options: AutoScrollOptions) { let scroll: HTMLElement | undefined let settling = false let settleTimer: ReturnType | undefined - let autoTimer: ReturnType | undefined let cleanup: (() => void) | undefined - let auto: { top: number; time: number } | undefined + let programmaticUntil = 0 + let scrollAnim: AnimationPlaybackControls | undefined const threshold = () => options.bottomThreshold ?? 10 @@ -34,67 +39,63 @@ export function createAutoScroll(options: AutoScrollOptions) { 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 - - if (Date.now() - a.time > 1500) { - auto = undefined - return false - } + const scrollToBottom = (force: boolean) => { + if (!force && !active()) return - return Math.abs(el.scrollTop - a.top) < 2 - } + if (force && store.userScrolled) setStore("userScrolled", false) - const scrollToBottomNow = (behavior: ScrollBehavior) => { const el = scroll if (!el) return - markAuto(el) - if (behavior === "smooth") { - el.scrollTo({ top: el.scrollHeight, behavior }) + + if (!force && store.userScrolled) return + + const next = Math.max(0, el.scrollHeight - el.clientHeight) + if (Math.abs(el.scrollTop - next) <= AUTO_SCROLL_EPSILON) { + markProgrammatic() return } - // `scrollTop` assignment bypasses any CSS `scroll-behavior: smooth`. - el.scrollTop = el.scrollHeight + el.scrollTop = next + markProgrammatic() } - const scrollToBottom = (force: boolean) => { - if (!force && !active()) return - - if (force && store.userScrolled) setStore("userScrolled", false) + const cancelSmooth = () => { + if (scrollAnim) { + scrollAnim.stop() + scrollAnim = undefined + } + } + const smoothScrollToBottom = () => { const el = scroll if (!el) return - if (!force && store.userScrolled) return + cancelSmooth() + if (store.userScrolled) setStore("userScrolled", false) - const distance = distanceFromBottom(el) - if (distance < 2) { - markAuto(el) + const next = Math.max(0, el.scrollHeight - el.clientHeight) + if (Math.abs(el.scrollTop - next) <= 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") + scrollAnim = animate(el.scrollTop, next, { + type: "spring", + visualDuration: 0.35, + bounce: 0, + onUpdate: (v) => { + markProgrammatic() + el.scrollTop = v + }, + onComplete: () => { + scrollAnim = undefined + markProgrammatic() + }, + }) } const stop = () => { @@ -106,15 +107,14 @@ export function createAutoScroll(options: AutoScrollOptions) { } if (store.userScrolled) return + markProgrammatic() setStore("userScrolled", true) options.onUserInteracted?.() } const handleWheel = (e: WheelEvent) => { 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]") @@ -128,19 +128,17 @@ export function createAutoScroll(options: AutoScrollOptions) { if (!canScroll(el)) { if (store.userScrolled) setStore("userScrolled", false) + markProgrammatic() return } if (distanceFromBottom(el) < threshold()) { 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() } @@ -175,13 +173,11 @@ export function createAutoScroll(options: AutoScrollOptions) { const el = scroll 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 +196,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 +209,7 @@ export function createAutoScroll(options: AutoScrollOptions) { onCleanup(() => { if (settleTimer) clearTimeout(settleTimer) - if (autoTimer) clearTimeout(autoTimer) + cancelSmooth() if (cleanup) cleanup() }) @@ -230,6 +224,7 @@ export function createAutoScroll(options: AutoScrollOptions) { if (!el) return + markProgrammatic() updateOverflowAnchor(el) el.addEventListener("wheel", handleWheel, { passive: true }) @@ -247,6 +242,14 @@ export function createAutoScroll(options: AutoScrollOptions) { }, scrollToBottom: () => scrollToBottom(false), forceScrollToBottom: () => scrollToBottom(true), + smoothScrollToBottom, + snapToBottom: () => { + const el = scroll + if (!el) return + if (store.userScrolled) setStore("userScrolled", false) + el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight) + markProgrammatic() + }, userScrolled: () => store.userScrolled, } } From eadd42bfe137367ba102bb00fe96d6af9fc6e101 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 2 Mar 2026 16:14:02 -0500 Subject: [PATCH 17/76] feat(ui): add gradient wipe reveal for tool metadata --- packages/ui/src/components/message-part.css | 43 +++---- packages/ui/src/components/message-part.tsx | 135 +++++++++++++------- packages/ui/src/components/motion.tsx | 2 +- packages/ui/src/components/text-shimmer.tsx | 26 +++- 4 files changed, 133 insertions(+), 73 deletions(-) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 01d732d4c0f..bd3c148be0f 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -473,7 +473,7 @@ [data-component="write-trigger"] { display: flex; align-items: center; - justify-content: space-between; + justify-content: flex-start; gap: 8px; width: 100%; @@ -486,7 +486,8 @@ } [data-slot="message-part-title"] { - flex-shrink: 0; + flex-shrink: 1; + min-width: 0; display: flex; align-items: center; gap: 8px; @@ -518,40 +519,38 @@ [data-slot="message-part-title-text"] { text-transform: capitalize; color: var(--text-strong); + flex-shrink: 0; } - [data-slot="message-part-title-filename"] { - /* No text-transform - preserve original filename casing */ + [data-slot="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; + } } - [data-slot="message-part-path"] { - display: flex; - flex-grow: 1; - min-width: 0; - font-weight: var(--font-weight-regular); + [data-slot="message-part-title-filename"] { + /* No text-transform - preserve original filename casing */ + color: var(--text-strong); + flex-shrink: 0; } - [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"] { diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index b2815b22f06..d5cae03ebb4 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1497,24 +1497,79 @@ ToolRegistry.register({ }, }) -function useToolFade(ref: () => HTMLElement | undefined, delay = 0) { +const TOOL_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%)" + +function useToolFade(ref: () => HTMLElement | undefined, options?: { delay?: number; wipe?: boolean }) { let anim: AnimationPlaybackControls | undefined let frame: number | undefined + const delay = options?.delay ?? 0 + const wipe = options?.wipe ?? false onMount(() => { const el = ref() if (!el || typeof window === "undefined") return if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return + const mask = + wipe && typeof CSS !== "undefined" && CSS.supports("mask-image", "linear-gradient(to right, black, transparent)") + el.style.opacity = "0" - el.style.filter = "blur(2px)" - el.style.transform = "translateY(0.04em)" + el.style.filter = wipe ? "blur(3px)" : "blur(2px)" + el.style.transform = wipe ? "translateX(-0.06em)" : "translateY(0.04em)" + + if (mask) { + el.style.maskImage = TOOL_WIPE_MASK + el.style.webkitMaskImage = TOOL_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 (wipe && !mask) el.style.clipPath = "inset(0 100% 0 0)" frame = requestAnimationFrame(() => { frame = undefined const node = ref() if (!node) return - anim = animate(node, { opacity: 1, filter: "blur(0px)", transform: "translateY(0)" }, { ...FADE_SPRING, delay }) + + if (!wipe) { + anim = animate(node, { opacity: 1, filter: "blur(0px)", transform: "translateY(0)" }, { ...FADE_SPRING, delay }) + } + + if (wipe) { + anim = mask + ? animate( + node, + { opacity: 1, filter: "blur(0px)", transform: "translateX(0)", maskPosition: "0% 0%" }, + { ...FADE_SPRING, delay }, + ) + : animate( + node, + { opacity: 1, filter: "blur(0px)", transform: "translateX(0)", clipPath: "inset(0 0% 0 0)" }, + { ...FADE_SPRING, delay }, + ) + } + + anim?.finished.then(() => { + const value = ref() + if (!value) return + value.style.opacity = "" + value.style.filter = "" + value.style.transform = "" + value.style.clipPath = "" + value.style.maskImage = "" + value.style.webkitMaskImage = "" + value.style.maskSize = "" + value.style.webkitMaskSize = "" + value.style.maskRepeat = "" + value.style.webkitMaskRepeat = "" + value.style.maskPosition = "" + value.style.webkitMaskPosition = "" + }) }) }) @@ -1526,7 +1581,7 @@ function useToolFade(ref: () => HTMLElement | undefined, delay = 0) { function WebfetchLink(props: { url: string }) { let ref: HTMLAnchorElement | undefined - useToolFade(() => ref) + useToolFade(() => ref, { wipe: true }) return ( ref, 0.04) + useToolFade(() => ref, { delay: 0.04 }) return (
@@ -1556,7 +1611,7 @@ function WebfetchAction() { function TaskLink(props: { href: string; text: string; onClick: (e: MouseEvent) => void }) { let ref: HTMLAnchorElement | undefined - useToolFade(() => ref) + useToolFade(() => ref, { wipe: true }) return ( ref) + useToolFade(() => ref, { wipe: true }) return ( @@ -1584,31 +1639,24 @@ function ToolText(props: { text: string }) { type DiffValue = { additions: number; deletions: number } | { additions: number; deletions: number }[] -function ToolFilename(props: { text: string }) { +function ToolMetaLine(props: { filename: string; path?: string; changes?: DiffValue; delay?: number }) { let ref: HTMLSpanElement | undefined - useToolFade(() => ref) + useToolFade(() => ref, { delay: props.delay ?? 0.02, wipe: true }) return ( - - {props.text} + + {props.filename} + + {props.path} + + {(changes) => } ) } -function ToolPath(props: { text: string }) { - let ref: HTMLDivElement | undefined - useToolFade(() => ref, 0.02) - - return ( -
- {props.text} -
- ) -} - function ToolChanges(props: { changes: DiffValue }) { let ref: HTMLDivElement | undefined - useToolFade(() => ref, 0.04) + useToolFade(() => ref, { delay: 0.04 }) return (
@@ -1619,7 +1667,7 @@ function ToolChanges(props: { changes: DiffValue }) { function ShellText(props: { text: string }) { let ref: HTMLSpanElement | undefined - useToolFade(() => ref) + useToolFade(() => ref, { wipe: true }) return ( @@ -1841,17 +1889,13 @@ ToolRegistry.register({ - +
- - - -
-
- - {(changes) => } -
} @@ -1909,14 +1953,13 @@ ToolRegistry.register({ - +
- - -
-
{/* */}
} > @@ -2107,17 +2150,13 @@ ToolRegistry.register({ - +
- - - -
-
- - -
} diff --git a/packages/ui/src/components/motion.tsx b/packages/ui/src/components/motion.tsx index 5d0b69a5ef2..90f3d8cab85 100644 --- a/packages/ui/src/components/motion.tsx +++ b/packages/ui/src/components/motion.tsx @@ -1,7 +1,7 @@ export { animate, springValue } from "motion" export type { AnimationPlaybackControls } from "motion" -export const HEIGHT_DURATION = 0.3 +export const HEIGHT_DURATION = 0.45 export const FADE_DURATION = 0.5 export const TOGGLE_DURATION = 0.3 diff --git a/packages/ui/src/components/text-shimmer.tsx b/packages/ui/src/components/text-shimmer.tsx index 7ad4c093c9b..6f760bed250 100644 --- a/packages/ui/src/components/text-shimmer.tsx +++ b/packages/ui/src/components/text-shimmer.tsx @@ -1,5 +1,6 @@ -import { createEffect, createMemo, createSignal, onCleanup, type ValidComponent } from "solid-js" +import { createEffect, createMemo, createSignal, on, onCleanup, type ValidComponent } from "solid-js" import { Dynamic } from "solid-js/web" +import { animate, type AnimationPlaybackControls } from "./motion" export const TextShimmer = (props: { text: string @@ -31,7 +32,28 @@ export const TextShimmer = (props: { }, swap) }) + let baseRef: HTMLSpanElement | undefined + let glowAnim: AnimationPlaybackControls | undefined + + // Glow pulse when shimmer deactivates + createEffect( + on(active, (isActive) => { + if (isActive || !baseRef) return + glowAnim?.stop() + glowAnim = animate( + baseRef, + { filter: ["brightness(1.5)", "brightness(1)"] }, + { type: "spring", visualDuration: 0.4, bounce: 0.15 }, + ) + glowAnim.finished.then(() => { + if (!baseRef) return + baseRef.style.filter = "" + }) + }, { defer: true }), + ) + onCleanup(() => { + glowAnim?.stop() if (!timer) return clearTimeout(timer) }) @@ -64,7 +86,7 @@ export const TextShimmer = (props: { }} > -
@@ -1136,36 +1131,6 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre PART_MAPPING["tool"] = function ToolPartDisplay(props) { const i18n = useI18n() const part = props.part as ToolPart - const log = (...args: unknown[]) => { - if (typeof window === "undefined") return - console.debug("[ui:tool-part]", part.callID, ...args) - } - const out = createMemo(() => { - const value = (part.state as Record).output - if (typeof value !== "string") return 0 - return value.length - }) - onMount(() => { - log("mount", { tool: part.tool, partID: part.id, status: part.state.status }) - }) - onCleanup(() => log("unmount", { tool: part.tool, partID: part.id })) - createEffect( - on( - () => part.state.status, - (status, prev) => log("status", { prev: prev ?? null, next: status }), - { defer: true }, - ), - ) - createEffect( - on( - out, - (next, prev) => { - if (!next && !prev) return - log("output", { prev: prev ?? 0, next }) - }, - { defer: true }, - ), - ) if (part.tool === "todowrite" || part.tool === "todoread") return null const hideQuestion = createMemo( @@ -1331,33 +1296,26 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
-
-
+ - - e.preventDefault()} - onClick={handleCopy} - aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} - /> - - - - {meta()} - - -
+ e.preventDefault()} + onClick={handleCopy} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} + /> + + + + {meta()} + +
@@ -1394,16 +1352,20 @@ ToolRegistry.register({ if (!value || !Array.isArray(value)) return [] return value.filter((p): p is string => typeof p === "string") }) + const pending = createMemo(() => props.status === "pending" || props.status === "running") return ( <> + } /> {(filepath) => ( @@ -1424,11 +1386,18 @@ ToolRegistry.register({ name: "list", render(props) { const i18n = useI18n() + const pending = createMemo(() => props.status === "pending" || props.status === "running") return ( + } > {(output) => ( @@ -1446,15 +1415,19 @@ ToolRegistry.register({ name: "glob", render(props) { const i18n = useI18n() + const pending = createMemo(() => props.status === "pending" || props.status === "running") return ( + } > {(output) => ( @@ -1512,7 +1489,10 @@ function useToolFade(ref: () => HTMLElement | undefined, options?: { delay?: num if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return const mask = - wipe && typeof CSS !== "undefined" && CSS.supports("mask-image", "linear-gradient(to right, black, transparent)") + 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)" @@ -1529,30 +1509,20 @@ function useToolFade(ref: () => HTMLElement | undefined, options?: { delay?: num el.style.webkitMaskPosition = "100% 0%" } - if (wipe && !mask) el.style.clipPath = "inset(0 100% 0 0)" - frame = requestAnimationFrame(() => { frame = undefined const node = ref() if (!node) return - if (!wipe) { - anim = animate(node, { opacity: 1, filter: "blur(0px)", transform: "translateY(0)" }, { ...FADE_SPRING, delay }) - } - - if (wipe) { - anim = mask + anim = wipe + ? mask ? animate( node, { opacity: 1, filter: "blur(0px)", transform: "translateX(0)", maskPosition: "0% 0%" }, { ...FADE_SPRING, delay }, ) - : animate( - node, - { opacity: 1, filter: "blur(0px)", transform: "translateX(0)", clipPath: "inset(0 0% 0 0)" }, - { ...FADE_SPRING, delay }, - ) - } + : animate(node, { opacity: 1, filter: "blur(0px)", transform: "translateX(0)" }, { ...FADE_SPRING, delay }) + : animate(node, { opacity: 1, filter: "blur(0px)", transform: "translateY(0)" }, { ...FADE_SPRING, delay }) anim?.finished.then(() => { const value = ref() @@ -1560,7 +1530,7 @@ function useToolFade(ref: () => HTMLElement | undefined, options?: { delay?: num value.style.opacity = "" value.style.filter = "" value.style.transform = "" - value.style.clipPath = "" + if (!mask) return value.style.maskImage = "" value.style.webkitMaskImage = "" value.style.maskSize = "" @@ -1579,33 +1549,26 @@ function useToolFade(ref: () => HTMLElement | undefined, options?: { delay?: num }) } -function WebfetchLink(props: { url: string }) { - let ref: HTMLAnchorElement | undefined +function WebfetchMeta(props: { url: string }) { + let ref: HTMLSpanElement | undefined useToolFade(() => ref, { wipe: true }) return ( -
event.stopPropagation()} - > - {props.url} - - ) -} - -function WebfetchAction() { - let ref: HTMLDivElement | undefined - useToolFade(() => ref, { delay: 0.04 }) - - return ( -
- -
+ + event.stopPropagation()} + > + {props.url} + +
+ +
+
) } @@ -1626,9 +1589,9 @@ function TaskLink(props: { href: string; text: string; onClick: (e: MouseEvent) ) } -function ToolText(props: { text: string }) { +function ToolText(props: { text: string; delay?: number }) { let ref: HTMLSpanElement | undefined - useToolFade(() => ref, { wipe: true }) + useToolFade(() => ref, { delay: props.delay, wipe: true }) return ( @@ -1637,6 +1600,40 @@ function ToolText(props: { text: string }) { ) } +function ToolArg(props: { text: string; delay?: number }) { + let ref: HTMLSpanElement | undefined + useToolFade(() => ref, { delay: props.delay, wipe: true }) + + return ( + + {props.text} + + ) +} + +function ToolTriggerRow(props: { + title: string + pending: boolean + subtitle?: string + args?: string[] + action?: JSX.Element +}) { + return ( +
+
+ + + + {(text) => } + + {(arg, idx) => } + +
+ {props.action} +
+ ) +} + type DiffValue = { additions: number; deletions: number } | { additions: number; deletions: number }[] function ToolMetaLine(props: { filename: string; path?: string; changes?: DiffValue; delay?: number }) { @@ -1701,11 +1698,8 @@ ToolRegistry.register({ - {(value) => } + {(value) => } - - - } /> @@ -2216,6 +2210,7 @@ ToolRegistry.register({ return [] }) + const pending = createMemo(() => props.status === "pending" || props.status === "running") const subtitle = createMemo(() => { const list = todos() @@ -2228,10 +2223,7 @@ ToolRegistry.register({ {...props} defaultOpen icon="checklist" - trigger={{ - title: i18n.t("ui.tool.todos"), - subtitle: subtitle(), - }} + trigger={} >
@@ -2261,6 +2253,7 @@ ToolRegistry.register({ const questions = createMemo(() => (props.input.questions ?? []) as QuestionInfo[]) const answers = createMemo(() => (props.metadata.answers ?? []) as QuestionAnswer[]) const completed = createMemo(() => answers().length > 0) + const pending = createMemo(() => props.status === "pending" || props.status === "running") const subtitle = createMemo(() => { const count = questions().length @@ -2274,10 +2267,7 @@ ToolRegistry.register({ {...props} defaultOpen={false} icon="bubble-5" - trigger={{ - title: i18n.t("ui.tool.questions"), - subtitle: subtitle(), - }} + trigger={} >
diff --git a/packages/ui/src/components/motion.tsx b/packages/ui/src/components/motion.tsx index 90f3d8cab85..39e4a57ebe4 100644 --- a/packages/ui/src/components/motion.tsx +++ b/packages/ui/src/components/motion.tsx @@ -1,9 +1,8 @@ export { animate, springValue } from "motion" export type { AnimationPlaybackControls } from "motion" -export const HEIGHT_DURATION = 0.45 +export const HEIGHT_DURATION = 0.5 export const FADE_DURATION = 0.5 -export const TOGGLE_DURATION = 0.3 export const HEIGHT_SPRING = { type: "spring" as const, @@ -11,14 +10,14 @@ export const HEIGHT_SPRING = { bounce: 0, } -export const TOGGLE_SPRING = { +export const FADE_SPRING = { type: "spring" as const, - visualDuration: TOGGLE_DURATION, + visualDuration: FADE_DURATION, bounce: 0, } -export const FADE_SPRING = { +export const GLOW_SPRING = { type: "spring" as const, - visualDuration: FADE_DURATION, - bounce: 0, + visualDuration: 0.4, + bounce: 0.15, } diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index c57082a91be..71a1574cab7 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -388,10 +388,6 @@ export function SessionTurn( const thinking = createMemo(() => shown()) const lane = createMemo(() => hasAssistant() || thinking()) const entry = createMemo(() => props.animate !== false) - const log = (...args: unknown[]) => { - if (typeof window === "undefined") return - console.debug("[ui:thinking]", ...args) - } const initialThinking = thinking() let thinkingRef: HTMLDivElement | undefined let thinkingBodyRef: HTMLDivElement | undefined @@ -419,7 +415,6 @@ export function SessionTurn( thinkingBodyRef.style.opacity = "1" thinkingBodyRef.style.filter = "blur(0px)" thinkingBodyRef.style.transform = "" - log("open-done") return } thinkingRef.style.overflow = "hidden" @@ -438,7 +433,6 @@ export function SessionTurn( thinkingRef.style.height = "auto" thinkingRef.style.marginTop = gap() thinkingRef.style.overflow = "visible" - log("open-done") }) thinkingBodyRef.style.opacity = "0" thinkingBodyRef.style.filter = "blur(2px)" @@ -464,7 +458,6 @@ export function SessionTurn( thinkingBodyRef.style.opacity = "0" thinkingBodyRef.style.filter = "blur(2px)" thinkingBodyRef.style.transform = "" - log("close-done") return } thinkingRef.style.overflow = "hidden" @@ -491,7 +484,6 @@ export function SessionTurn( thinkingRef.style.height = "0px" thinkingRef.style.marginTop = "0px" thinkingRef.style.overflow = "hidden" - log("close-done") }) } @@ -499,13 +491,6 @@ export function SessionTurn( on( showThinking, (value) => { - log("source", { - value, - working: working(), - status: status().type, - visible: assistantVisible(), - tail: assistantTailVisible(), - }) stopHide() if (value) { if (!shown()) setShown(true) @@ -526,7 +511,6 @@ export function SessionTurn( on( thinking, (value) => { - log("toggle", { value }) if (thinkingToggleFrame !== undefined) { cancelAnimationFrame(thinkingToggleFrame) thinkingToggleFrame = undefined @@ -619,7 +603,7 @@ export function SessionTurn( text={!showReasoningSummaries() ? (reasoningHeading() ?? "") : ""} class="session-turn-thinking-heading" travel={25} - duration={700} + duration={900} />
diff --git a/packages/ui/src/components/text-shimmer.tsx b/packages/ui/src/components/text-shimmer.tsx index 6f760bed250..3c9599095d4 100644 --- a/packages/ui/src/components/text-shimmer.tsx +++ b/packages/ui/src/components/text-shimmer.tsx @@ -1,6 +1,6 @@ import { createEffect, createMemo, createSignal, on, onCleanup, type ValidComponent } from "solid-js" import { Dynamic } from "solid-js/web" -import { animate, type AnimationPlaybackControls } from "./motion" +import { animate, type AnimationPlaybackControls, GLOW_SPRING } from "./motion" export const TextShimmer = (props: { text: string @@ -43,7 +43,7 @@ export const TextShimmer = (props: { glowAnim = animate( baseRef, { filter: ["brightness(1.5)", "brightness(1)"] }, - { type: "spring", visualDuration: 0.4, bounce: 0.15 }, + GLOW_SPRING, ) glowAnim.finished.then(() => { if (!baseRef) return diff --git a/packages/ui/src/hooks/create-auto-scroll.tsx b/packages/ui/src/hooks/create-auto-scroll.tsx index cd5caafb846..6eb78c57198 100644 --- a/packages/ui/src/hooks/create-auto-scroll.tsx +++ b/packages/ui/src/hooks/create-auto-scroll.tsx @@ -21,6 +21,7 @@ export function createAutoScroll(options: AutoScrollOptions) { let cleanup: (() => void) | undefined let programmaticUntil = 0 let scrollAnim: AnimationPlaybackControls | undefined + let resizeFrame: number | undefined const threshold = () => options.bottomThreshold ?? 10 @@ -51,6 +52,7 @@ export function createAutoScroll(options: AutoScrollOptions) { const el = scroll if (!el) return + if (scrollAnim) cancelSmooth() if (!force && store.userScrolled) return const next = Math.max(0, el.scrollHeight - el.clientHeight) @@ -170,15 +172,19 @@ export function createAutoScroll(options: AutoScrollOptions) { createResizeObserver( () => store.contentRef, () => { - const el = scroll - if (el && !canScroll(el)) { - if (store.userScrolled) setStore("userScrolled", false) - markProgrammatic() - return - } - if (!active()) return - if (store.userScrolled) return - scrollToBottom(false) + if (resizeFrame !== undefined) return + resizeFrame = requestAnimationFrame(() => { + resizeFrame = undefined + const el = scroll + if (el && !canScroll(el)) { + if (store.userScrolled) setStore("userScrolled", false) + markProgrammatic() + return + } + if (!active()) return + if (store.userScrolled) return + scrollToBottom(false) + }) }, ) @@ -209,6 +215,7 @@ export function createAutoScroll(options: AutoScrollOptions) { onCleanup(() => { if (settleTimer) clearTimeout(settleTimer) + if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame) cancelSmooth() if (cleanup) cleanup() }) From 21b6a5f5fdb63e899cc9f34691f575c6ffe6e3c6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 2 Mar 2026 20:00:25 -0500 Subject: [PATCH 19/76] fix(ui): reduce height animation jitter in GrowBox and thinking box - Remove Math.ceil rounding from spring animation frames to eliminate 1px oscillation during height transitions - Add will-change:height and contain:layout style during active springs for better compositor performance - Replace blanket ResizeObserver gate with spring target deduplication so genuine content changes can re-target mid-flight without feedback loops - Apply same Math.ceil removal and will-change/contain hints to the thinking box show/hide animations in session-turn --- packages/ui/src/components/grow-box.tsx | 13 ++- packages/ui/src/components/message-part.tsx | 103 +++++++++++++------- packages/ui/src/components/session-turn.tsx | 14 ++- 3 files changed, 88 insertions(+), 42 deletions(-) diff --git a/packages/ui/src/components/grow-box.tsx b/packages/ui/src/components/grow-box.tsx index d0874cc31a1..21a8e0e8ad3 100644 --- a/packages/ui/src/components/grow-box.tsx +++ b/packages/ui/src/components/grow-box.tsx @@ -34,6 +34,7 @@ export function GrowBox(props: GrowBoxProps) { let mountFrame: number | undefined let resizeFrame: number | undefined let observer: ResizeObserver | undefined + let springTarget = -1 const height = springValue(0, HEIGHT_SPRING) const gap = () => Math.max(0, props.gap ?? 0) @@ -47,7 +48,7 @@ export function GrowBox(props: GrowBoxProps) { const n = Number.parseFloat(v) if (!Number.isNaN(n)) return n } - return Math.max(0, Math.ceil(root.getBoundingClientRect().height)) + return Math.max(0, root.getBoundingClientRect().height) } const targetHeight = () => Math.max(0, Math.ceil(body?.getBoundingClientRect().height ?? 0)) @@ -55,8 +56,10 @@ export function GrowBox(props: GrowBoxProps) { const setHeight = () => { if (!root) return const next = targetHeight() + 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" : "hidden" @@ -64,6 +67,7 @@ export function GrowBox(props: GrowBoxProps) { return } root.style.overflow = "hidden" + springTarget = next height.set(next) } @@ -72,15 +76,20 @@ export function GrowBox(props: GrowBoxProps) { const offChange = height.on("change", (next) => { if (!root) return - root.style.height = `${Math.max(0, Math.ceil(next))}px` + root.style.height = `${Math.max(0, next)}px` }) const offStart = height.on("animationStart", () => { if (!root) return root.style.overflow = "hidden" + root.style.willChange = "height" + root.style.contain = "layout style" }) const offComplete = height.on("animationComplete", () => { if (!root) return + root.style.willChange = "" + root.style.contain = "" const next = targetHeight() + springTarget = next if (props.autoHeight === false || watch()) { root.style.height = `${next}px` root.style.overflow = next > 0 ? "visible" : "hidden" diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index c012a4b779a..03623100dd5 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -811,10 +811,12 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; animate?: - {(text) => } - + + {(text) => } + + - {(arg, idx) => } + {(arg, idx) => } @@ -1268,18 +1270,6 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { const displayText = () => (part().text ?? "").trim() const throttledText = createThrottledValue(displayText) - const isLastTextPart = createMemo(() => { - const last = (data.store.part?.[props.message.id] ?? []) - .filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim()) - .at(-1) - return last?.id === part().id - }) - const showCopy = createMemo(() => { - if (props.message.role !== "assistant") return isLastTextPart() - if (props.showAssistantCopyPartID === null) return false - if (typeof props.showAssistantCopyPartID === "string") return props.showAssistantCopyPartID === part().id - return isLastTextPart() - }) const [copied, setCopied] = createSignal(false) const handleCopy = async () => { @@ -1477,16 +1467,38 @@ ToolRegistry.register({ const TOOL_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%)" -function useToolFade(ref: () => HTMLElement | undefined, options?: { delay?: number; wipe?: boolean }) { +function useToolFade( + ref: () => HTMLElement | undefined, + options?: { delay?: number; wipe?: boolean; hidden?: () => boolean }, +) { let anim: AnimationPlaybackControls | undefined let frame: number | undefined const delay = options?.delay ?? 0 const wipe = options?.wipe ?? false + const hidden = () => options?.hidden?.() ?? false + + const clearMask = (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 = "" + } - onMount(() => { + const play = () => { const el = ref() if (!el || typeof window === "undefined") return if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return + if (hidden()) return + + anim?.stop() + if (frame !== undefined) { + cancelAnimationFrame(frame) + frame = undefined + } const mask = wipe && @@ -1512,7 +1524,7 @@ function useToolFade(ref: () => HTMLElement | undefined, options?: { delay?: num frame = requestAnimationFrame(() => { frame = undefined const node = ref() - if (!node) return + if (!node || hidden()) return anim = wipe ? mask @@ -1530,18 +1542,35 @@ function useToolFade(ref: () => HTMLElement | undefined, options?: { delay?: num value.style.opacity = "" value.style.filter = "" value.style.transform = "" - if (!mask) return - value.style.maskImage = "" - value.style.webkitMaskImage = "" - value.style.maskSize = "" - value.style.webkitMaskSize = "" - value.style.maskRepeat = "" - value.style.webkitMaskRepeat = "" - value.style.maskPosition = "" - value.style.webkitMaskPosition = "" + if (mask) clearMask(value) }) }) - }) + } + + onMount(play) + + createEffect( + on( + hidden, + (next, prev) => { + if (prev === undefined) return + if (next) { + anim?.stop() + if (frame !== undefined) { + cancelAnimationFrame(frame) + frame = undefined + } + const el = ref() + if (!el) return + el.style.maskImage = "" + el.style.webkitMaskImage = "" + return + } + play() + }, + { defer: true }, + ), + ) onCleanup(() => { if (frame !== undefined) cancelAnimationFrame(frame) @@ -1549,12 +1578,12 @@ function useToolFade(ref: () => HTMLElement | undefined, options?: { delay?: num }) } -function WebfetchMeta(props: { url: string }) { +function WebfetchMeta(props: { url: string; pending?: boolean }) { let ref: HTMLSpanElement | undefined - useToolFade(() => ref, { wipe: true }) + useToolFade(() => ref, { wipe: true, hidden: () => !!props.pending }) return ( - + ref, { delay: props.delay, wipe: true }) + useToolFade(() => ref, { delay: props.delay, wipe: true, hidden: () => !!props.pending }) return ( - + {props.text} ) } -function ToolArg(props: { text: string; delay?: number }) { +function ToolArg(props: { text: string; delay?: number; pending?: boolean }) { let ref: HTMLSpanElement | undefined - useToolFade(() => ref, { delay: props.delay, wipe: true }) + useToolFade(() => ref, { delay: props.delay, wipe: true, hidden: () => !!props.pending }) return ( - + {props.text} ) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 71a1574cab7..9eed81c9acc 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -406,8 +406,8 @@ export function SessionTurn( if (!thinkingRef || !thinkingBodyRef) return thinkingAnim?.stop() thinkingHeightAnim?.stop() - const next = Math.max(1, Math.ceil(thinkingBodyRef.getBoundingClientRect().height)) - const prev = Math.max(0, Math.ceil(thinkingRef.getBoundingClientRect().height)) + const next = Math.max(1, thinkingBodyRef.getBoundingClientRect().height) + const prev = Math.max(0, thinkingRef.getBoundingClientRect().height) if (!entry()) { thinkingRef.style.overflow = "visible" thinkingRef.style.height = "auto" @@ -418,6 +418,8 @@ export function SessionTurn( return } thinkingRef.style.overflow = "hidden" + thinkingRef.style.willChange = "height" + thinkingRef.style.contain = "layout style" thinkingRef.style.height = `${prev}px` thinkingRef.style.marginTop = prev > 0 ? gap() : "0px" thinkingHeightAnim = animate( @@ -430,6 +432,8 @@ export function SessionTurn( ) thinkingHeightAnim.finished.then(() => { if (!thinkingRef || !thinking()) return + thinkingRef.style.willChange = "" + thinkingRef.style.contain = "" thinkingRef.style.height = "auto" thinkingRef.style.marginTop = gap() thinkingRef.style.overflow = "visible" @@ -461,7 +465,9 @@ export function SessionTurn( return } thinkingRef.style.overflow = "hidden" - const h = Math.max(1, Math.ceil(thinkingRef.getBoundingClientRect().height)) + thinkingRef.style.willChange = "height" + thinkingRef.style.contain = "layout style" + const h = Math.max(1, thinkingRef.getBoundingClientRect().height) thinkingRef.style.height = `${h}px` thinkingHeightAnim = animate( thinkingRef, @@ -481,6 +487,8 @@ export function SessionTurn( ) thinkingHeightAnim.finished.then(() => { if (!thinkingRef || thinking()) return + thinkingRef.style.willChange = "" + thinkingRef.style.contain = "" thinkingRef.style.height = "0px" thinkingRef.style.marginTop = "0px" thinkingRef.style.overflow = "hidden" From c96a3b15c57b334efa88c4f5665a8b67ef37475f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 2 Mar 2026 20:17:51 -0500 Subject: [PATCH 20/76] fix(ui): remove legacy shell submessage width style --- packages/ui/src/components/shell-submessage.css | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/ui/src/components/shell-submessage.css b/packages/ui/src/components/shell-submessage.css index 81734da236c..9f19c2d1527 100644 --- a/packages/ui/src/components/shell-submessage.css +++ b/packages/ui/src/components/shell-submessage.css @@ -5,13 +5,6 @@ vertical-align: baseline; } -[data-component="shell-submessage"] [data-slot="shell-submessage-width"] { - min-width: 0; - max-width: 100%; - display: inline-block; - overflow: clip; -} - [data-component="shell-submessage"] [data-slot="shell-submessage-value"] { display: inline-block; vertical-align: baseline; From 1b9ca3da27ed1e88799c47de2ec87e0f15b313d8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 2 Mar 2026 20:49:37 -0500 Subject: [PATCH 21/76] fix(ui): eliminate auto-scroll jitter and add compositor hints Remove rAF debounce from ResizeObserver callback in createAutoScroll so scrollToBottom fires in the same frame as layout changes (no 1-frame lag). Add GPU layer promotion (translateZ) on GrowBox and layout containment on assistant content to reduce cross-element repaint. --- packages/ui/src/components/grow-box.tsx | 2 +- packages/ui/src/components/session-turn.tsx | 2 +- packages/ui/src/hooks/create-auto-scroll.tsx | 24 ++++++++------------ 3 files changed, 11 insertions(+), 17 deletions(-) diff --git a/packages/ui/src/components/grow-box.tsx b/packages/ui/src/components/grow-box.tsx index 21a8e0e8ad3..0429517ff60 100644 --- a/packages/ui/src/components/grow-box.tsx +++ b/packages/ui/src/components/grow-box.tsx @@ -160,7 +160,7 @@ export function GrowBox(props: GrowBoxProps) { }) return ( -
+
0 ? `${gap()}px` : undefined }}> {props.children}
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 9eed81c9acc..2900f3b2228 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -579,7 +579,7 @@ export function SessionTurn(
-
+
void) | undefined let programmaticUntil = 0 let scrollAnim: AnimationPlaybackControls | undefined - let resizeFrame: number | undefined const threshold = () => options.bottomThreshold ?? 10 @@ -172,19 +171,15 @@ export function createAutoScroll(options: AutoScrollOptions) { createResizeObserver( () => store.contentRef, () => { - if (resizeFrame !== undefined) return - resizeFrame = requestAnimationFrame(() => { - resizeFrame = undefined - const el = scroll - if (el && !canScroll(el)) { - if (store.userScrolled) setStore("userScrolled", false) - markProgrammatic() - return - } - if (!active()) return - if (store.userScrolled) return - scrollToBottom(false) - }) + const el = scroll + if (el && !canScroll(el)) { + if (store.userScrolled) setStore("userScrolled", false) + markProgrammatic() + return + } + if (!active()) return + if (store.userScrolled) return + scrollToBottom(false) }, ) @@ -215,7 +210,6 @@ export function createAutoScroll(options: AutoScrollOptions) { onCleanup(() => { if (settleTimer) clearTimeout(settleTimer) - if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame) cancelSmooth() if (cleanup) cleanup() }) From 28538bc65df4183b24e9c3ed55ea8d44c11fff38 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 2 Mar 2026 21:00:30 -0500 Subject: [PATCH 22/76] fix(ui): use column-reverse for jitter-free bottom-anchored scrolling Switch the scroll viewport to flex-direction: column-reverse so the browser natively anchors to the bottom (scrollTop=0 = bottom). This eliminates the 1-frame jitter between content height changes and scroll position updates. Update all scrollTop math across auto-scroll, scroll spy, gesture detection, hash scroll, and custom thumb to account for the inverted coordinate system. --- packages/app/src/pages/session.tsx | 16 ++++++++++------ .../src/pages/session/message-gesture.test.ts | 9 ++++++--- .../app/src/pages/session/message-gesture.ts | 5 +++-- .../app/src/pages/session/message-timeline.tsx | 2 ++ .../pages/session/use-session-hash-scroll.ts | 3 ++- packages/ui/src/components/scroll-view.css | 2 ++ packages/ui/src/components/scroll-view.tsx | 13 +++++++++---- packages/ui/src/hooks/create-auto-scroll.tsx | 18 ++++++++++-------- 8 files changed, 44 insertions(+), 24 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 663d61d157a..7b965e58582 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -125,7 +125,10 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) { requestAnimationFrame(() => { const delta = el.scrollHeight - beforeHeight if (!delta) return - el.scrollTop = beforeTop + delta + // With column-reverse, adding content at the top doesn't shift the + // viewport because scroll origin is at the bottom. Subtract delta + // to maintain position (beforeTop is negative or zero). + el.scrollTop = beforeTop - delta }) } @@ -209,7 +212,8 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) { if (!input.userScrolled()) return const el = input.scroller() if (!el) return - if (el.scrollTop >= turnScrollThreshold) return + // With column-reverse, distance from top = scrollHeight - clientHeight + scrollTop + if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return const start = turnStart() if (start > 0) { @@ -1028,7 +1032,8 @@ export default function Page() { const overflow = max > 1 // If auto-scroll is tracking the bottom, always report bottom: true // to prevent the scroll-down arrow from flashing during height animations - const bottom = !overflow || el.scrollTop >= max - 2 || !autoScroll.userScrolled() + // With column-reverse, scrollTop=0 is at the bottom + const bottom = !overflow || Math.abs(el.scrollTop) <= 2 || !autoScroll.userScrolled() if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return setUi("scroll", { overflow, bottom }) @@ -1119,9 +1124,8 @@ 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 + // With column-reverse, near bottom = scrollTop near 0 + const stick = el ? Math.abs(el.scrollTop) < 10 + Math.max(0, delta) : false dockHeight = next diff --git a/packages/app/src/pages/session/message-gesture.test.ts b/packages/app/src/pages/session/message-gesture.test.ts index b2af4bb8342..69e26e8dc3e 100644 --- a/packages/app/src/pages/session/message-gesture.test.ts +++ b/packages/app/src/pages/session/message-gesture.test.ts @@ -28,10 +28,11 @@ describe("shouldMarkBoundaryGesture", () => { }) test("marks when scrolling beyond top boundary", () => { + // column-reverse: scrollTop=-590 means 590px from bottom (10px from top, max=600) expect( shouldMarkBoundaryGesture({ delta: -40, - scrollTop: 10, + scrollTop: -590, scrollHeight: 1000, clientHeight: 400, }), @@ -39,10 +40,11 @@ describe("shouldMarkBoundaryGesture", () => { }) test("marks when scrolling beyond bottom boundary", () => { + // column-reverse: scrollTop=-20 means 20px from bottom expect( shouldMarkBoundaryGesture({ delta: 50, - scrollTop: 580, + scrollTop: -20, scrollHeight: 1000, clientHeight: 400, }), @@ -50,10 +52,11 @@ describe("shouldMarkBoundaryGesture", () => { }) test("does not mark when nested scroller can consume movement", () => { + // column-reverse: scrollTop=-400 means 400px from bottom (middle of scroll) expect( shouldMarkBoundaryGesture({ delta: 20, - scrollTop: 200, + scrollTop: -400, scrollHeight: 1000, clientHeight: 400, }), diff --git a/packages/app/src/pages/session/message-gesture.ts b/packages/app/src/pages/session/message-gesture.ts index 731cb1bdeb6..03ae724edbe 100644 --- a/packages/app/src/pages/session/message-gesture.ts +++ b/packages/app/src/pages/session/message-gesture.ts @@ -14,8 +14,9 @@ export const shouldMarkBoundaryGesture = (input: { if (max <= 1) return true if (!input.delta) return false - if (input.delta < 0) return input.scrollTop + input.delta <= 0 + // With column-reverse: scrollTop=0 at bottom, -max at top + if (input.delta < 0) return input.scrollTop + input.delta <= -max - const remaining = max - input.scrollTop + const remaining = -input.scrollTop // distance from bottom return input.delta > remaining } diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 96add9a0f2c..224516ef385 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -574,6 +574,7 @@ export function MessageTimeline(props: { "--sticky-accordion-top": showHeader() ? "48px" : "0px", }} > +
+
diff --git a/packages/app/src/pages/session/use-session-hash-scroll.ts b/packages/app/src/pages/session/use-session-hash-scroll.ts index da5506faf03..19ce8f2aed4 100644 --- a/packages/app/src/pages/session/use-session-hash-scroll.ts +++ b/packages/app/src/pages/session/use-session-hash-scroll.ts @@ -47,7 +47,8 @@ export const useSessionHashScroll = (input: { 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) + // 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 } diff --git a/packages/ui/src/components/scroll-view.css b/packages/ui/src/components/scroll-view.css index f6a49e241c6..8429f318c3b 100644 --- a/packages/ui/src/components/scroll-view.css +++ b/packages/ui/src/components/scroll-view.css @@ -9,6 +9,8 @@ overflow-y: auto; scrollbar-width: none; outline: none; + display: flex; + flex-direction: column-reverse; } .scroll-view__viewport::-webkit-scrollbar { diff --git a/packages/ui/src/components/scroll-view.tsx b/packages/ui/src/components/scroll-view.tsx index 52ed39a465f..58017238f77 100644 --- a/packages/ui/src/components/scroll-view.tsx +++ b/packages/ui/src/components/scroll-view.tsx @@ -57,9 +57,12 @@ export function ScrollView(props: ScrollViewProps) { const maxScrollTop = scrollHeight - clientHeight const maxThumbTop = trackHeight - height - const top = maxScrollTop > 0 ? (scrollTop / maxScrollTop) * maxThumbTop : 0 + // With column-reverse: scrollTop=0 is at bottom, negative = scrolled up + // Normalize so 0 = at top, maxScrollTop = at bottom + const normalizedScrollTop = maxScrollTop + scrollTop + const top = maxScrollTop > 0 ? (normalizedScrollTop / maxScrollTop) * maxThumbTop : 0 - // 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) @@ -147,11 +150,13 @@ export function ScrollView(props: ScrollViewProps) { break case "Home": e.preventDefault() - viewportRef.scrollTo({ top: 0, behavior: "smooth" }) + // With column-reverse, top of content = -(scrollHeight - clientHeight) + viewportRef.scrollTo({ top: -(viewportRef.scrollHeight - viewportRef.clientHeight), behavior: "smooth" }) break case "End": e.preventDefault() - viewportRef.scrollTo({ top: viewportRef.scrollHeight, behavior: "smooth" }) + // With column-reverse, bottom of content = 0 + viewportRef.scrollTo({ top: 0, behavior: "smooth" }) break case "ArrowUp": e.preventDefault() diff --git a/packages/ui/src/hooks/create-auto-scroll.tsx b/packages/ui/src/hooks/create-auto-scroll.tsx index f047ee8c586..7faf5a66218 100644 --- a/packages/ui/src/hooks/create-auto-scroll.tsx +++ b/packages/ui/src/hooks/create-auto-scroll.tsx @@ -32,7 +32,8 @@ 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) => { @@ -54,13 +55,13 @@ export function createAutoScroll(options: AutoScrollOptions) { if (scrollAnim) cancelSmooth() if (!force && store.userScrolled) return - const next = Math.max(0, el.scrollHeight - el.clientHeight) - if (Math.abs(el.scrollTop - next) <= AUTO_SCROLL_EPSILON) { + // With column-reverse, scrollTop=0 is at the bottom + if (Math.abs(el.scrollTop) <= AUTO_SCROLL_EPSILON) { markProgrammatic() return } - el.scrollTop = next + el.scrollTop = 0 markProgrammatic() } @@ -78,13 +79,13 @@ export function createAutoScroll(options: AutoScrollOptions) { cancelSmooth() if (store.userScrolled) setStore("userScrolled", false) - const next = Math.max(0, el.scrollHeight - el.clientHeight) - if (Math.abs(el.scrollTop - next) <= AUTO_SCROLL_EPSILON) { + // With column-reverse, scrollTop=0 is at the bottom + if (Math.abs(el.scrollTop) <= AUTO_SCROLL_EPSILON) { markProgrammatic() return } - scrollAnim = animate(el.scrollTop, next, { + scrollAnim = animate(el.scrollTop, 0, { type: "spring", visualDuration: 0.35, bounce: 0, @@ -248,7 +249,8 @@ export function createAutoScroll(options: AutoScrollOptions) { const el = scroll if (!el) return if (store.userScrolled) setStore("userScrolled", false) - el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight) + // With column-reverse, scrollTop=0 is at the bottom + el.scrollTop = 0 markProgrammatic() }, userScrolled: () => store.userScrolled, From 49f55ae426d4ee73c5d55a3c6b65d90a75c4c553 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 2 Mar 2026 21:22:51 -0500 Subject: [PATCH 23/76] fix(ui): fix user message animation and smooth BasicTool toggle - Pass props.animate (not live()) to Message so user message GrowBox mounts with animate=true and properly runs height + fade animation - Remove Math.ceil from BasicTool height spring to prevent 1px jitter - Remove rAF debounce from BasicTool ResizeObserver for same-frame updates - Use live() for AssistantParts to defer animation until streaming starts --- packages/ui/src/components/message-part.tsx | 188 ++++++++++++-------- packages/ui/src/components/session-turn.tsx | 35 +++- 2 files changed, 145 insertions(+), 78 deletions(-) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 03623100dd5..733fc11ffe3 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -749,7 +749,7 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; animate?: { const trigger = contextToolTrigger(part, i18n) const running = createMemo(() => part.state.status === "pending" || part.state.status === "running") + const reveal = useToolReveal(running, () => props.animate !== false) return (
@@ -811,12 +812,12 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; animate?: - - {(text) => } + + {(text) => } - + - {(arg, idx) => } + {(arg, idx) => }
@@ -1068,6 +1069,7 @@ export interface ToolProps { forceOpen?: boolean locked?: boolean animate?: boolean + reveal?: boolean } export type ToolComponent = Component @@ -1198,7 +1200,8 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { status={part().state.status} hideDetails={props.hideDetails} defaultOpen={props.defaultOpen} - animate={props.animate} + animate + reveal={props.animate} /> @@ -1317,7 +1320,7 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) { const text = () => part().text.trim() const throttledText = createThrottledValue(text) let ref: HTMLDivElement | undefined - useToolFade(() => ref) + useToolFade(() => ref, { animate: props.animate }) return ( @@ -1354,6 +1357,7 @@ ToolRegistry.register({ pending={pending()} subtitle={props.input.filePath ? getFilename(props.input.filePath) : ""} args={args} + animate={props.reveal} /> } /> @@ -1386,6 +1390,7 @@ ToolRegistry.register({ title={i18n.t("ui.tool.list")} pending={pending()} subtitle={getDirectory(props.input.path || "/")} + animate={props.reveal} /> } > @@ -1416,6 +1421,7 @@ ToolRegistry.register({ pending={pending()} subtitle={getDirectory(props.input.path || "/")} args={props.input.pattern ? ["pattern=" + props.input.pattern] : []} + animate={props.reveal} /> } > @@ -1449,6 +1455,7 @@ ToolRegistry.register({ pending={pending()} subtitle={getDirectory(props.input.path || "/")} args={args} + animate={props.reveal} /> } > @@ -1467,15 +1474,26 @@ ToolRegistry.register({ const TOOL_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%)" +function useToolReveal(pending: () => boolean, animate?: () => boolean) { + const [live, setLive] = createSignal(pending()) + + createEffect(() => { + if (pending()) setLive(true) + }) + + const enabled = () => animate?.() ?? true + return () => enabled() && live() +} + function useToolFade( ref: () => HTMLElement | undefined, - options?: { delay?: number; wipe?: boolean; hidden?: () => boolean }, + 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 hidden = () => options?.hidden?.() ?? false + const active = options?.animate !== false const clearMask = (el: HTMLElement) => { el.style.maskImage = "" @@ -1488,17 +1506,12 @@ function useToolFade( el.style.webkitMaskPosition = "" } - const play = () => { + onMount(() => { + if (!active) return + const el = ref() if (!el || typeof window === "undefined") return if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return - if (hidden()) return - - anim?.stop() - if (frame !== undefined) { - cancelAnimationFrame(frame) - frame = undefined - } const mask = wipe && @@ -1524,7 +1537,7 @@ function useToolFade( frame = requestAnimationFrame(() => { frame = undefined const node = ref() - if (!node || hidden()) return + if (!node) return anim = wipe ? mask @@ -1545,32 +1558,7 @@ function useToolFade( if (mask) clearMask(value) }) }) - } - - onMount(play) - - createEffect( - on( - hidden, - (next, prev) => { - if (prev === undefined) return - if (next) { - anim?.stop() - if (frame !== undefined) { - cancelAnimationFrame(frame) - frame = undefined - } - const el = ref() - if (!el) return - el.style.maskImage = "" - el.style.webkitMaskImage = "" - return - } - play() - }, - { defer: true }, - ), - ) + }) onCleanup(() => { if (frame !== undefined) cancelAnimationFrame(frame) @@ -1578,12 +1566,12 @@ function useToolFade( }) } -function WebfetchMeta(props: { url: string; pending?: boolean }) { +function WebfetchMeta(props: { url: string; animate?: boolean }) { let ref: HTMLSpanElement | undefined - useToolFade(() => ref, { wipe: true, hidden: () => !!props.pending }) + useToolFade(() => ref, { wipe: true, animate: props.animate }) return ( - +
void }) { +function TaskLink(props: { href: string; text: string; onClick: (e: MouseEvent) => void; animate?: boolean }) { let ref: HTMLAnchorElement | undefined - useToolFade(() => ref, { wipe: true }) + useToolFade(() => ref, { wipe: true, animate: props.animate }) return ( ref, { delay: props.delay, wipe: true, hidden: () => !!props.pending }) + useToolFade(() => ref, { delay: props.delay, wipe: true, animate: props.animate }) return ( - + {props.text} ) } -function ToolArg(props: { text: string; delay?: number; pending?: boolean }) { +function ToolArg(props: { text: string; delay?: number; animate?: boolean }) { let ref: HTMLSpanElement | undefined - useToolFade(() => ref, { delay: props.delay, wipe: true, hidden: () => !!props.pending }) + useToolFade(() => ref, { delay: props.delay, wipe: true, animate: props.animate }) return ( - + {props.text} ) @@ -1646,16 +1634,24 @@ function ToolTriggerRow(props: { subtitle?: string args?: string[] action?: JSX.Element + animate?: boolean }) { + const reveal = useToolReveal( + () => props.pending, + () => props.animate !== false, + ) + return (
- {(text) => } + {(text) => } - {(arg, idx) => } + + {(arg, idx) => } +
{props.action} @@ -1665,9 +1661,15 @@ function ToolTriggerRow(props: { type DiffValue = { additions: number; deletions: number } | { additions: number; deletions: number }[] -function ToolMetaLine(props: { filename: string; path?: string; changes?: DiffValue; delay?: number }) { +function ToolMetaLine(props: { + filename: string + path?: string + changes?: DiffValue + delay?: number + animate?: boolean +}) { let ref: HTMLSpanElement | undefined - useToolFade(() => ref, { delay: props.delay ?? 0.02, wipe: true }) + useToolFade(() => ref, { delay: props.delay ?? 0.02, wipe: true, animate: props.animate }) return ( @@ -1680,9 +1682,9 @@ function ToolMetaLine(props: { filename: string; path?: string; changes?: DiffVa ) } -function ToolChanges(props: { changes: DiffValue }) { +function ToolChanges(props: { changes: DiffValue; animate?: boolean }) { let ref: HTMLDivElement | undefined - useToolFade(() => ref, { delay: 0.04 }) + useToolFade(() => ref, { delay: 0.04, animate: props.animate }) return (
@@ -1691,9 +1693,9 @@ function ToolChanges(props: { changes: DiffValue }) { ) } -function ShellText(props: { text: string }) { +function ShellText(props: { text: string; animate?: boolean }) { let ref: HTMLSpanElement | undefined - useToolFade(() => ref, { wipe: true }) + useToolFade(() => ref, { wipe: true, animate: props.animate }) return ( @@ -1711,6 +1713,7 @@ ToolRegistry.register({ render(props) { const i18n = useI18n() const pending = createMemo(() => props.status === "pending" || props.status === "running") + const reveal = useToolReveal(pending, () => props.reveal !== false) const url = createMemo(() => { const value = props.input.url if (typeof value !== "string") return "" @@ -1727,7 +1730,7 @@ ToolRegistry.register({ - {(value) => } + {(value) => }
} @@ -1749,6 +1752,7 @@ ToolRegistry.register({ return undefined }) const running = createMemo(() => props.status === "pending" || props.status === "running") + const reveal = useToolReveal(running, () => props.reveal !== false) const href = createMemo(() => { const sessionId = childSessionId() @@ -1796,10 +1800,12 @@ ToolRegistry.register({ - {(url) => } + {(url) => ( + + )} - + @@ -1807,7 +1813,7 @@ ToolRegistry.register({
) - return + return }, }) @@ -1816,6 +1822,7 @@ ToolRegistry.register({ render(props) { const i18n = useI18n() const pending = () => props.status === "pending" || props.status === "running" + const reveal = useToolReveal(pending, () => props.reveal !== false) const subtitle = () => props.input.description ?? props.metadata.description const output = createMemo(() => { if (typeof props.output === "string") return props.output @@ -1846,7 +1853,7 @@ ToolRegistry.register({ - {(text) => } + {(text) => }
} @@ -1898,12 +1905,14 @@ ToolRegistry.register({ const path = createMemo(() => props.metadata?.filediff?.file || props.input.filePath || "") const filename = () => getFilename(props.input.filePath ?? "") const pending = () => props.status === "pending" || props.status === "running" + const reveal = useToolReveal(pending, () => props.reveal !== false) return (
@@ -1916,6 +1925,7 @@ ToolRegistry.register({ filename={filename()} path={props.input.filePath?.includes("/") ? getDirectory(props.input.filePath!) : undefined} changes={props.metadata.filediff} + animate={reveal()} />
@@ -1927,7 +1937,9 @@ ToolRegistry.register({ {(diff) => } + + {(diff) => } + } >
@@ -1962,12 +1974,14 @@ ToolRegistry.register({ const path = createMemo(() => props.input.filePath || "") const filename = () => getFilename(props.input.filePath ?? "") const pending = () => props.status === "pending" || props.status === "running" + const reveal = useToolReveal(pending, () => props.reveal !== false) return (
@@ -1979,6 +1993,7 @@ ToolRegistry.register({
@@ -2028,6 +2043,7 @@ ToolRegistry.register({ const fileComponent = useFileComponent() const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[]) const pending = createMemo(() => props.status === "pending" || props.status === "running") + const reveal = useToolReveal(pending, () => props.reveal !== false) const single = createMemo(() => { const list = files() if (list.length !== 1) return @@ -2059,6 +2075,7 @@ ToolRegistry.register({ {...props} icon="code-lines" defer + animated trigger={
@@ -2066,7 +2083,9 @@ ToolRegistry.register({ - {(text) => } + + {(text) => } +
@@ -2165,6 +2184,7 @@ ToolRegistry.register({ {...props} icon="code-lines" defer + animated trigger={
@@ -2177,6 +2197,7 @@ ToolRegistry.register({ filename={getFilename(file().relativePath)} path={file().relativePath.includes("/") ? getDirectory(file().relativePath) : undefined} changes={{ additions: file().additions, deletions: file().deletions }} + animate={reveal()} />
@@ -2204,7 +2225,10 @@ ToolRegistry.register({ - + } @@ -2252,7 +2276,14 @@ ToolRegistry.register({ {...props} defaultOpen icon="checklist" - trigger={} + trigger={ + + } >
@@ -2296,7 +2327,14 @@ ToolRegistry.register({ {...props} defaultOpen={false} icon="bubble-5" - trigger={} + trigger={ + + } >
@@ -2336,6 +2374,6 @@ ToolRegistry.register({
) - return + return }, }) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 2900f3b2228..c29b7797d62 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -387,7 +387,10 @@ export function SessionTurn( let hideTimer: ReturnType | undefined const thinking = createMemo(() => shown()) const lane = createMemo(() => hasAssistant() || thinking()) - const entry = createMemo(() => props.animate !== false) + const animateEnabled = createMemo(() => props.animate !== false) + const [live, setLive] = createSignal(false) + let liveFrame: number | undefined + const entry = createMemo(() => live()) const initialThinking = thinking() let thinkingRef: HTMLDivElement | undefined let thinkingBodyRef: HTMLDivElement | undefined @@ -396,6 +399,27 @@ export function SessionTurn( let thinkingToggleFrame: number | undefined const gap = () => (hasAssistant() ? `${THINKING_GAP_PX}px` : "0px") + createEffect( + on( + () => [animateEnabled(), working()] as const, + ([enabled, isWorking]) => { + if (liveFrame !== undefined) { + cancelAnimationFrame(liveFrame) + liveFrame = undefined + } + if (!enabled) { + setLive(false) + return + } + if (!isWorking || live()) return + liveFrame = requestAnimationFrame(() => { + liveFrame = undefined + setLive(true) + }) + }, + ), + ) + const stopHide = () => { if (!hideTimer) return clearTimeout(hideTimer) @@ -545,6 +569,7 @@ export function SessionTurn( onCleanup(() => { stopHide() + if (liveFrame !== undefined) cancelAnimationFrame(liveFrame) if (thinkingToggleFrame !== undefined) cancelAnimationFrame(thinkingToggleFrame) thinkingAnim?.stop() thinkingHeightAnim?.stop() @@ -579,13 +604,17 @@ export function SessionTurn(
-
+
Date: Mon, 2 Mar 2026 21:32:47 -0500 Subject: [PATCH 24/76] fix(ui): restore patch collapse behavior and add user message entry spacing --- packages/ui/src/components/basic-tool.tsx | 4 ++-- packages/ui/src/components/message-part.tsx | 4 +--- packages/ui/src/components/motion.tsx | 8 ++++++++ 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index b16481cb9c7..9d96110752a 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -1,5 +1,5 @@ import { createEffect, createSignal, For, Match, on, onCleanup, onMount, Show, Switch, type JSX } from "solid-js" -import { animate, type AnimationPlaybackControls, springValue, HEIGHT_SPRING, FADE_SPRING } from "./motion" +import { animate, type AnimationPlaybackControls, springValue, TOOL_HEIGHT_SPRING, FADE_SPRING } from "./motion" import { Collapsible } from "./collapsible" import type { IconProps } from "./icon" import { TextShimmer } from "./text-shimmer" @@ -87,7 +87,7 @@ export function BasicTool(props: BasicToolProps) { let observer: ResizeObserver | undefined let resizeFrame: number | undefined const initialOpen = props.animateIn ? false : open() - const heightSpring = springValue(0, HEIGHT_SPRING) + const heightSpring = springValue(0, TOOL_HEIGHT_SPRING) const read = () => Math.max(0, Math.ceil(bodyRef?.getBoundingClientRect().height ?? 0)) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 733fc11ffe3..86f3e2e1cde 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -911,7 +911,7 @@ export function UserMessageDisplay(props: { } return ( - +
0}> @@ -2075,7 +2075,6 @@ ToolRegistry.register({ {...props} icon="code-lines" defer - animated trigger={
@@ -2184,7 +2183,6 @@ ToolRegistry.register({ {...props} icon="code-lines" defer - animated trigger={
diff --git a/packages/ui/src/components/motion.tsx b/packages/ui/src/components/motion.tsx index 39e4a57ebe4..d3414cc04ce 100644 --- a/packages/ui/src/components/motion.tsx +++ b/packages/ui/src/components/motion.tsx @@ -10,6 +10,14 @@ export const HEIGHT_SPRING = { bounce: 0, } +export const TOOL_HEIGHT_DURATION = HEIGHT_DURATION + +export const TOOL_HEIGHT_SPRING = { + type: "spring" as const, + visualDuration: TOOL_HEIGHT_DURATION, + bounce: 0, +} + export const FADE_SPRING = { type: "spring" as const, visualDuration: FADE_DURATION, From 6f8f0d183050056dbac5952e170b901da7724a8e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 2 Mar 2026 21:36:47 -0500 Subject: [PATCH 25/76] fix(ui): fix backfill scroll preservation for column-reverse Disable browser scroll anchoring (overflow-anchor: none) which interferes with column-reverse, and restore scrollTop synchronously before paint instead of in rAF with delta math. --- packages/app/src/pages/session.tsx | 15 ++-- packages/ui/src/components/message-part.tsx | 99 ++++++++------------- packages/ui/src/components/scroll-view.css | 1 + 3 files changed, 46 insertions(+), 69 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 7b965e58582..72791c4d298 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -120,16 +120,13 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) { return } const beforeTop = el.scrollTop - const beforeHeight = el.scrollHeight fn() - requestAnimationFrame(() => { - const delta = el.scrollHeight - beforeHeight - if (!delta) return - // With column-reverse, adding content at the top doesn't shift the - // viewport because scroll origin is at the bottom. Subtract delta - // to maintain position (beforeTop is negative or zero). - el.scrollTop = beforeTop - delta - }) + // SolidJS updates the DOM synchronously. Force reflow so the browser + // processes the new layout, then restore scrollTop before paint. + // With column-reverse + overflow-anchor:none the same scrollTop value + // keeps the same distance from the bottom — no delta math needed. + void el.scrollHeight + el.scrollTop = beforeTop } const backfillTurns = () => { diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 86f3e2e1cde..fd8bca2e216 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -2051,7 +2051,6 @@ ToolRegistry.register({ }) const [expanded, setExpanded] = createSignal([]) let seeded = false - createEffect(() => { const list = files() if (list.length === 0) return @@ -2059,7 +2058,6 @@ ToolRegistry.register({ seeded = true setExpanded(list.filter((f) => f.type !== "delete").map((f) => f.filePath)) }) - const subtitle = createMemo(() => { const count = files().length if (count === 0) return "" @@ -2067,29 +2065,39 @@ ToolRegistry.register({ }) return ( - - -
-
- - - - - {(text) => } - -
-
+
+ +
+
+ + + + + {(file) => ( + + )} + + + {(text) => } +
- } - > +
+
+ } + > + 0}> { const active = createMemo(() => expanded().includes(file.filePath)) const [visible, setVisible] = createSignal(false) - createEffect(() => { if (!active()) { setVisible(false) return } - requestAnimationFrame(() => { if (!active()) return setVisible(true) @@ -2173,36 +2179,9 @@ ToolRegistry.register({ -
-
- } - > - {(file) => ( -
- -
-
- - - - - - -
-
-
- } - > + } + > + {(file) => (
- -
- )} - + )} + + +
) }, }) diff --git a/packages/ui/src/components/scroll-view.css b/packages/ui/src/components/scroll-view.css index 8429f318c3b..0759ce20ad3 100644 --- a/packages/ui/src/components/scroll-view.css +++ b/packages/ui/src/components/scroll-view.css @@ -11,6 +11,7 @@ outline: none; display: flex; flex-direction: column-reverse; + overflow-anchor: none; } .scroll-view__viewport::-webkit-scrollbar { From 0ce963bfb3422bdf85b6c9bde29288378e0bd441 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 2 Mar 2026 21:41:22 -0500 Subject: [PATCH 26/76] fix(ui): keep apply_patch tool collapsed by default --- packages/ui/src/components/message-part.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index fd8bca2e216..e0871da6ada 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -388,7 +388,8 @@ function renderable(part: PartType, showReasoningSummaries = true) { function toolDefaultOpen(tool: string, shell = false, edit = false) { if (tool === "bash") return shell - if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit + if (tool === "edit" || tool === "write") return edit + if (tool === "apply_patch") return false } function partDefaultOpen(part: PartType, shell = false, edit = false) { From 55762b2bbb6a6d801ac4180ed8eb758036ee79cd Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 2 Mar 2026 21:48:11 -0500 Subject: [PATCH 27/76] fix(ui): restore in-flow user copy row spacing --- packages/ui/src/components/message-part.css | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index e74438433d3..fc4504e2a40 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -33,9 +33,8 @@ flex-direction: column; align-items: flex-end; width: 100%; - gap: 8px; + gap: 4px; } - [data-slot="user-message-attachments"] { display: flex; flex-wrap: wrap; @@ -44,6 +43,7 @@ width: fit-content; max-width: min(82%, 64ch); margin-left: auto; + margin-bottom: 4px; } [data-slot="user-message-attachment"] { @@ -142,22 +142,17 @@ } [data-slot="user-message-copy-wrapper"] { - position: absolute; - top: 100%; - left: 0; - right: 0; - padding-top: 4px; + min-height: 24px; + margin-top: 0; display: flex; align-items: center; justify-content: flex-end; gap: 10px; + width: 100%; opacity: 0; pointer-events: none; - transition: - opacity 0.15s ease 0.25s, - pointer-events 0s 0.25s; + transition: opacity 0.15s ease; will-change: opacity; - [data-component="tooltip-trigger"] { display: inline-flex; width: fit-content; @@ -199,7 +194,6 @@ &:focus-within [data-slot="user-message-copy-wrapper"] { opacity: 1; pointer-events: auto; - transition: opacity 0.15s ease; } .text-text-strong { From 418ecc7073ee9127d427db8e67d4ed4b8e659b73 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 3 Mar 2026 09:17:02 -0500 Subject: [PATCH 28/76] fix(ui): align defer behavior for animated tools --- packages/ui/src/components/basic-tool.tsx | 2 +- packages/ui/src/components/message-part.tsx | 134 +++----------------- 2 files changed, 19 insertions(+), 117 deletions(-) diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index 9d96110752a..a0852adc1e1 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -62,7 +62,7 @@ export function BasicTool(props: BasicToolProps) { on( open, (value) => { - if (!props.defer) return + if (!props.defer || props.animated) return if (!value) { cancel() setReady(false) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index e0871da6ada..3b9eebb3bea 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -279,100 +279,8 @@ function list(value: T[] | undefined | null, fallback: T[]) { return fallback } -function same(a: readonly T[] | undefined, b: readonly T[] | undefined) { - if (a === b) return true - if (!a || !b) return false - if (a.length !== b.length) return false - return a.every((x, i) => x === b[i]) -} - -type PartRef = { - messageID: string - partID: string -} - -type PartGroup = - | { - key: string - type: "part" - ref: PartRef - } - | { - key: string - type: "context" - refs: PartRef[] - } - -function sameRef(a: PartRef, b: PartRef) { - return a.messageID === b.messageID && a.partID === b.partID -} - -function sameGroup(a: PartGroup, b: PartGroup) { - if (a === b) return true - if (a.key !== b.key) return false - if (a.type !== b.type) return false - if (a.type === "part") { - if (b.type !== "part") return false - return sameRef(a.ref, b.ref) - } - if (b.type !== "context") return false - if (a.refs.length !== b.refs.length) return false - return a.refs.every((ref, i) => sameRef(ref, b.refs[i]!)) -} - -function sameGroups(a: readonly PartGroup[] | undefined, b: readonly PartGroup[] | undefined) { - if (a === b) return true - if (!a || !b) return false - if (a.length !== b.length) return false - return a.every((item, i) => sameGroup(item, b[i]!)) -} - -function groupParts(parts: { messageID: string; part: PartType }[]) { - const result: PartGroup[] = [] - let start = -1 - - const flush = (end: number) => { - if (start < 0) return - const first = parts[start] - const last = parts[end] - if (!first || !last) { - start = -1 - return - } - result.push({ - key: `context:${first.part.id}`, - type: "context", - refs: parts.slice(start, end + 1).map((item) => ({ - messageID: item.messageID, - partID: item.part.id, - })), - }) - start = -1 - } - - parts.forEach((item, index) => { - if (isContextGroupTool(item.part)) { - if (start < 0) start = index - return - } - - flush(index - 1) - result.push({ - key: `part:${item.messageID}:${item.part.id}`, - type: "part", - ref: { - messageID: item.messageID, - partID: item.part.id, - }, - }) - }) - - flush(parts.length - 1) - return result -} - -function partByID(parts: readonly PartType[], partID: string) { - return parts.find((part) => part.id === partID) +function busy(status: string | undefined) { + return status === "pending" || status === "running" } function renderable(part: PartType, showReasoningSummaries = true) { @@ -734,9 +642,7 @@ export function AssistantMessageDisplay(props: { function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; animate?: boolean }) { const i18n = useI18n() - const anyRunning = createMemo(() => - props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"), - ) + const anyRunning = createMemo(() => props.parts.some((part) => busy(part.state.status))) // Once all parts are done and the group is no longer busy, latch into // "explored" permanently so brief status flickers can't jump it back. const [settled, setSettled] = createSignal(false) @@ -801,7 +707,7 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; animate?: {(part) => { const trigger = contextToolTrigger(part, i18n) - const running = createMemo(() => part.state.status === "pending" || part.state.status === "running") + const running = createMemo(() => busy(part.state.status)) const reveal = useToolReveal(running, () => props.animate !== false) return (
@@ -1138,9 +1044,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { const part = props.part as ToolPart if (part.tool === "todowrite" || part.tool === "todoread") return null - const hideQuestion = createMemo( - () => part().tool === "question" && (part().state.status === "pending" || part().state.status === "running"), - ) + const hideQuestion = createMemo(() => part.tool === "question" && busy(part.state.status)) const emptyInput: Record = {} const emptyMetadata: Record = {} @@ -1346,7 +1250,7 @@ ToolRegistry.register({ if (!value || !Array.isArray(value)) return [] return value.filter((p): p is string => typeof p === "string") }) - const pending = createMemo(() => props.status === "pending" || props.status === "running") + const pending = createMemo(() => busy(props.status)) return ( <> props.status === "pending" || props.status === "running") + const pending = createMemo(() => busy(props.status)) return ( props.status === "pending" || props.status === "running") + const pending = createMemo(() => busy(props.status)) return ( busy(props.status)) return ( props.status === "pending" || props.status === "running") + const pending = createMemo(() => busy(props.status)) const reveal = useToolReveal(pending, () => props.reveal !== false) const url = createMemo(() => { const value = props.input.url @@ -1752,7 +1656,7 @@ ToolRegistry.register({ if (typeof value === "string") return value return undefined }) - const running = createMemo(() => props.status === "pending" || props.status === "running") + const running = createMemo(() => busy(props.status)) const reveal = useToolReveal(running, () => props.reveal !== false) const href = createMemo(() => { @@ -1822,7 +1726,7 @@ ToolRegistry.register({ name: "bash", render(props) { const i18n = useI18n() - const pending = () => props.status === "pending" || props.status === "running" + const pending = () => busy(props.status) const reveal = useToolReveal(pending, () => props.reveal !== false) const subtitle = () => props.input.description ?? props.metadata.description const output = createMemo(() => { @@ -1905,14 +1809,13 @@ ToolRegistry.register({ const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) const path = createMemo(() => props.metadata?.filediff?.file || props.input.filePath || "") const filename = () => getFilename(props.input.filePath ?? "") - const pending = () => props.status === "pending" || props.status === "running" + const pending = () => busy(props.status) const reveal = useToolReveal(pending, () => props.reveal !== false) return (
@@ -1974,14 +1877,13 @@ ToolRegistry.register({ const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) const path = createMemo(() => props.input.filePath || "") const filename = () => getFilename(props.input.filePath ?? "") - const pending = () => props.status === "pending" || props.status === "running" + const pending = () => busy(props.status) const reveal = useToolReveal(pending, () => props.reveal !== false) return (
@@ -2043,7 +1945,7 @@ ToolRegistry.register({ const i18n = useI18n() const fileComponent = useFileComponent() const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[]) - const pending = createMemo(() => props.status === "pending" || props.status === "running") + const pending = createMemo(() => busy(props.status)) const reveal = useToolReveal(pending, () => props.reveal !== false) const single = createMemo(() => { const list = files() @@ -2241,7 +2143,7 @@ ToolRegistry.register({ return [] }) - const pending = createMemo(() => props.status === "pending" || props.status === "running") + const pending = createMemo(() => busy(props.status)) const subtitle = createMemo(() => { const list = todos() @@ -2291,7 +2193,7 @@ ToolRegistry.register({ const questions = createMemo(() => (props.input.questions ?? []) as QuestionInfo[]) const answers = createMemo(() => (props.metadata.answers ?? []) as QuestionAnswer[]) const completed = createMemo(() => answers().length > 0) - const pending = createMemo(() => props.status === "pending" || props.status === "running") + const pending = createMemo(() => busy(props.status)) const subtitle = createMemo(() => { const count = questions().length @@ -2338,7 +2240,7 @@ ToolRegistry.register({ name: "skill", render(props) { const title = createMemo(() => props.input.name || "skill") - const running = createMemo(() => props.status === "pending" || props.status === "running") + const running = createMemo(() => busy(props.status)) const titleContent = () => From 8851c619b4e474aaa1a11c222f50516e8dad1d13 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 3 Mar 2026 09:37:58 -0500 Subject: [PATCH 29/76] fix(ui): speed up nested collapsible panels --- packages/ui/src/components/basic-tool.tsx | 14 ++++++++++---- packages/ui/src/components/message-part.tsx | 6 ++---- packages/ui/src/components/motion.tsx | 14 ++++++++++---- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index a0852adc1e1..1dfca0c9293 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -1,5 +1,11 @@ import { createEffect, createSignal, For, Match, on, onCleanup, onMount, Show, Switch, type JSX } from "solid-js" -import { animate, type AnimationPlaybackControls, springValue, TOOL_HEIGHT_SPRING, FADE_SPRING } from "./motion" +import { + animate, + type AnimationPlaybackControls, + springValue, + COLLAPSIBLE_CONTENT_FADE_SPRING, + COLLAPSIBLE_CONTENT_HEIGHT_SPRING, +} from "./motion" import { Collapsible } from "./collapsible" import type { IconProps } from "./icon" import { TextShimmer } from "./text-shimmer" @@ -87,7 +93,7 @@ export function BasicTool(props: BasicToolProps) { let observer: ResizeObserver | undefined let resizeFrame: number | undefined const initialOpen = props.animateIn ? false : open() - const heightSpring = springValue(0, TOOL_HEIGHT_SPRING) + const heightSpring = springValue(0, COLLAPSIBLE_CONTENT_HEIGHT_SPRING) const read = () => Math.max(0, Math.ceil(bodyRef?.getBoundingClientRect().height ?? 0)) @@ -101,7 +107,7 @@ export function BasicTool(props: BasicToolProps) { } const next = read() fadeAnim?.stop() - fadeAnim = animate(bodyRef, { opacity: 1, filter: "blur(0px)" }, FADE_SPRING) + fadeAnim = animate(bodyRef, { opacity: 1, filter: "blur(0px)" }, COLLAPSIBLE_CONTENT_FADE_SPRING) fadeAnim.finished.then(() => { if (!bodyRef) return bodyRef.style.opacity = "" @@ -113,7 +119,7 @@ export function BasicTool(props: BasicToolProps) { const doClose = () => { if (!contentRef || !bodyRef) return fadeAnim?.stop() - fadeAnim = animate(bodyRef, { opacity: 0, filter: "blur(2px)" }, FADE_SPRING) + fadeAnim = animate(bodyRef, { opacity: 0, filter: "blur(2px)" }, COLLAPSIBLE_CONTENT_FADE_SPRING) fadeAnim.finished.then(() => { if (!contentRef || open()) return contentRef.style.display = "none" diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 3b9eebb3bea..2fb3c7915aa 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -719,10 +719,8 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; animate?: - - {(text) => } - - + {(text) => } + {(arg, idx) => } diff --git a/packages/ui/src/components/motion.tsx b/packages/ui/src/components/motion.tsx index d3414cc04ce..369af40b064 100644 --- a/packages/ui/src/components/motion.tsx +++ b/packages/ui/src/components/motion.tsx @@ -3,6 +3,8 @@ export type { AnimationPlaybackControls } from "motion" export const HEIGHT_DURATION = 0.5 export const FADE_DURATION = 0.5 +export const COLLAPSIBLE_CONTENT_HEIGHT_DURATION = 0.3 +export const COLLAPSIBLE_CONTENT_FADE_DURATION = COLLAPSIBLE_CONTENT_HEIGHT_DURATION export const HEIGHT_SPRING = { type: "spring" as const, @@ -10,11 +12,9 @@ export const HEIGHT_SPRING = { bounce: 0, } -export const TOOL_HEIGHT_DURATION = HEIGHT_DURATION - -export const TOOL_HEIGHT_SPRING = { +export const COLLAPSIBLE_CONTENT_HEIGHT_SPRING = { type: "spring" as const, - visualDuration: TOOL_HEIGHT_DURATION, + visualDuration: COLLAPSIBLE_CONTENT_HEIGHT_DURATION, bounce: 0, } @@ -24,6 +24,12 @@ export const FADE_SPRING = { bounce: 0, } +export const COLLAPSIBLE_CONTENT_FADE_SPRING = { + type: "spring" as const, + visualDuration: COLLAPSIBLE_CONTENT_FADE_DURATION, + bounce: 0, +} + export const GLOW_SPRING = { type: "spring" as const, visualDuration: 0.4, From 45eb4be7f2f5df7e5cfe7570cc7ffd41f96e5d02 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 3 Mar 2026 10:16:18 -0500 Subject: [PATCH 30/76] refactor(ui): standardize tool call variants --- .../ui/src/components/basic-tool.stories.tsx | 37 +-- packages/ui/src/components/basic-tool.tsx | 220 ++++++++++++------ packages/ui/src/components/message-part.css | 12 +- packages/ui/src/components/message-part.tsx | 100 ++++---- .../shell-submessage-motion.stories.tsx | 21 +- 5 files changed, 242 insertions(+), 148 deletions(-) diff --git a/packages/ui/src/components/basic-tool.stories.tsx b/packages/ui/src/components/basic-tool.stories.tsx index 9d9d97acfeb..b9cefc1329b 100644 --- a/packages/ui/src/components/basic-tool.stories.tsx +++ b/packages/ui/src/components/basic-tool.stories.tsx @@ -4,19 +4,21 @@ import * as mod from "./basic-tool" import { create } from "../storybook/scaffold" const docs = `### Overview -Expandable tool panel with a structured trigger and optional details. +Tool call surface with explicit row and panel variants. Use structured triggers for consistent layout; custom triggers allowed. ### API -- Required: \`icon\` and \`trigger\` (structured or custom JSX). -- Optional: \`status\`, \`defaultOpen\`, \`forceOpen\`, \`defer\`, \`locked\`. +- Required: \`variant\`, \`icon\`, and \`trigger\`. +- Row tools render summary-only. +- Panel/group tools support \`defaultOpen\`, \`forceOpen\`, \`defer\`, and \`locked\`. ### Variants and states - Pending/running status animates the title via TextShimmer. ### Behavior -- Uses Collapsible; can defer content rendering until open. +- Row tools skip collapsible state and render lightweight trigger-only markup. +- Panel/group tools use Collapsible and can defer content rendering until open. - Locked state prevents closing. ### Accessibility @@ -28,13 +30,15 @@ Use structured triggers for consistent layout; custom triggers allowed. ` const story = create({ - title: "UI/Basic Tool", + title: "UI/Tool Call", mod, + name: "ToolCall", args: { + variant: "panel", icon: "mcp", defaultOpen: true, trigger: { - title: "Basic Tool", + title: "Tool Call", subtitle: "Example subtitle", args: ["--flag", "value"], }, @@ -43,8 +47,8 @@ const story = create({ }) export default { - title: "UI/Basic Tool", - id: "components-basic-tool", + title: "UI/Tool Call", + id: "components-tool-call", component: story.meta.component, tags: ["autodocs"], parameters: { @@ -60,6 +64,7 @@ export const Basic = story.Basic export const Pending = { args: { + variant: "panel", status: "pending", trigger: { title: "Running tool", @@ -71,6 +76,7 @@ export const Pending = { export const Locked = { args: { + variant: "panel", locked: true, trigger: { title: "Locked tool", @@ -82,6 +88,7 @@ export const Locked = { export const Deferred = { args: { + variant: "panel", defer: true, defaultOpen: false, trigger: { @@ -94,6 +101,7 @@ export const Deferred = { export const ForceOpen = { args: { + variant: "panel", forceOpen: true, trigger: { title: "Forced open", @@ -103,14 +111,14 @@ export const ForceOpen = { }, } -export const HideDetails = { +export const Row = { args: { - hideDetails: true, + variant: "row", + icon: "mcp", trigger: { title: "Summary only", - subtitle: "Details hidden", + subtitle: "Lightweight row", }, - children: "Hidden content", }, } @@ -120,13 +128,14 @@ export const SubtitleAction = { return (
{message()}
- setMessage("Subtitle clicked")} > Subtitle action details - +
) }, diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index 1dfca0c9293..621b337ae05 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -1,4 +1,16 @@ -import { createEffect, createSignal, For, Match, on, onCleanup, onMount, Show, Switch, type JSX } from "solid-js" +import { + createEffect, + createSignal, + For, + Match, + on, + onCleanup, + onMount, + Show, + splitProps, + Switch, + type JSX, +} from "solid-js" import { animate, type AnimationPlaybackControls, @@ -26,7 +38,7 @@ const isTriggerTitle = (val: any): val is TriggerTitle => { ) } -export interface BasicToolProps { +interface ToolCallPanelBaseProps { icon: IconProps["name"] trigger: TriggerTitle | JSX.Element children?: JSX.Element @@ -44,7 +56,78 @@ export interface BasicToolProps { onSubtitleClick?: () => void } -export function BasicTool(props: BasicToolProps) { +function ToolCallTriggerBody(props: { + trigger: TriggerTitle | JSX.Element + pending: boolean + onSubtitleClick?: () => void + arrow?: boolean +}) { + return ( +
+
+
+ + + {(trigger) => ( +
+
+ + + + + + { + if (!props.onSubtitleClick) return + e.stopPropagation() + props.onSubtitleClick() + }} + > + {trigger().subtitle} + + + + + {(arg) => ( + + {arg} + + )} + + + +
+ {trigger().action} +
+ )} +
+ {props.trigger as JSX.Element} +
+
+
+ + + +
+ ) +} + +function ToolCallPanel(props: ToolCallPanelBaseProps) { const [open, setOpen] = createSignal(props.defaultOpen ?? false) const [ready, setReady] = createSignal(open()) const pending = () => props.status === "pending" || props.status === "running" @@ -197,68 +280,12 @@ export function BasicTool(props: BasicToolProps) { return ( -
-
-
- - - {(trigger) => ( -
-
- - - - - - { - if (props.onSubtitleClick) { - e.stopPropagation() - props.onSubtitleClick() - } - }} - > - {trigger().subtitle} - - - - - {(arg) => ( - - {arg} - - )} - - - -
- {trigger().action} -
- )} -
- {props.trigger as JSX.Element} -
-
-
- - - -
+
void +} + +// `group` currently shares the same behavior as `panel`; the separate variant is semantic so grouped call sites can stay explicit. +export interface ToolCallPanelProps extends Omit { + variant: "panel" | "group" +} + +export type ToolCallProps = ToolCallRowProps | ToolCallPanelProps +function ToolCallRoot(props: ToolCallProps) { + const [local, rest] = splitProps(props, ["variant", "trigger", "status", "onSubtitleClick"]) + const pending = () => local.status === "pending" || local.status === "running" + if (local.variant === "row") { + return ( +
+
+ +
+
+ ) + } + + return ( + + ) +} + +function ToolCallList(props: { children?: JSX.Element }) { + return
{props.children}
+} + +function ToolCallRow(props: { children: JSX.Element }) { + return ( +
+
+
+
{props.children}
+
+
+
+ ) +} + +export const ToolCall = Object.assign(ToolCallRoot, { + List: ToolCallList, + Row: ToolCallRow, +}) + export function GenericTool(props: { tool: string; status?: string; hideDetails?: boolean }) { - return + return ( + + ) } diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index fc4504e2a40..470e256e586 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -637,16 +637,18 @@ } } -[data-component="context-tool-group-list"] { +[data-component="context-tool-group-list"], +[data-component="tool-call-list"] { margin-top: -4px; display: flex; flex-direction: column; gap: 0px; +} - [data-slot="context-tool-group-item"] { - min-width: 0; - padding: 4px 0; - } +[data-component="context-tool-group-list"] [data-slot="context-tool-group-item"], +[data-component="tool-call-list"] [data-slot="tool-call-item"] { + min-width: 0; + padding: 4px 0; } [data-component="diagnostics"] { diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 2fb3c7915aa..6a227223a6d 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -33,8 +33,7 @@ import { useData } from "../context" import { useFileComponent } from "../context/file" import { useDialog } from "../context/dialog" import { useI18n } from "../context/i18n" -import { BasicTool } from "./basic-tool" -import { GenericTool } from "./basic-tool" +import { GenericTool, ToolCall } from "./basic-tool" import { Accordion } from "./accordion" import { StickyAccordionHeader } from "./sticky-accordion-header" import { Card } from "./card" @@ -653,7 +652,8 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; animate?: const summary = createMemo(() => contextToolSummary(props.parts)) return ( - } > -
+ {(part) => { const trigger = contextToolTrigger(part, i18n) const running = createMemo(() => busy(part.state.status)) const reveal = useToolReveal(running, () => props.animate !== false) return ( -
-
-
-
-
-
- - - - {(text) => } - - - {(arg, idx) => } - - -
-
-
+ +
+
+ + + + {(text) => } + + + {(arg, idx) => } + +
-
+ ) }} -
- + + ) } @@ -1251,7 +1245,8 @@ ToolRegistry.register({ const pending = createMemo(() => busy(props.status)) return ( <> - busy(props.status)) return ( - )} - + ) }, }) @@ -1315,7 +1311,8 @@ ToolRegistry.register({ const i18n = useI18n() const pending = createMemo(() => busy(props.status)) return ( - )} - + ) }, }) @@ -1349,7 +1346,8 @@ ToolRegistry.register({ if (props.input.include) args.push("include=" + props.input.include) const pending = createMemo(() => busy(props.status)) return ( - )} - + ) }, }) @@ -1623,9 +1621,9 @@ ToolRegistry.register({ return value }) return ( - @@ -1716,7 +1714,7 @@ ToolRegistry.register({
) - return + return }, }) @@ -1753,7 +1751,8 @@ ToolRegistry.register({ } return ( -
-
+ ) }, }) @@ -1811,7 +1810,8 @@ ToolRegistry.register({ const reveal = useToolReveal(pending, () => props.reveal !== false) return (
- - +
) }, @@ -1879,7 +1879,8 @@ ToolRegistry.register({ const reveal = useToolReveal(pending, () => props.reveal !== false) return (
- - +
) }, @@ -1967,7 +1968,8 @@ ToolRegistry.register({ return (
- )} - +
) }, @@ -2150,7 +2152,8 @@ ToolRegistry.register({ }) return ( -
-
+ ) }, }) @@ -2201,7 +2204,8 @@ ToolRegistry.register({ }) return ( -
-
+ ) }, }) @@ -2252,6 +2256,6 @@ ToolRegistry.register({
) - return + return }, }) diff --git a/packages/ui/src/components/shell-submessage-motion.stories.tsx b/packages/ui/src/components/shell-submessage-motion.stories.tsx index 444fc0fa9af..414d0a9d876 100644 --- a/packages/ui/src/components/shell-submessage-motion.stories.tsx +++ b/packages/ui/src/components/shell-submessage-motion.stories.tsx @@ -1,6 +1,6 @@ // @ts-nocheck import { createEffect, createSignal, onCleanup } from "solid-js" -import { BasicTool } from "./basic-tool" +import { ToolCall } from "./basic-tool" import { animate } from "motion" export default { @@ -97,12 +97,7 @@ const ease = { linear: "linear", } -function SpringSubmessage(props: { - text: string - visible: boolean - visualDuration: number - bounce: number -}) { +function SpringSubmessage(props: { text: string; visible: boolean; visualDuration: number; bounce: number }) { let ref: HTMLSpanElement | undefined let widthRef: HTMLSpanElement | undefined @@ -194,19 +189,15 @@ export const Playground = { > -
Shell - +
} @@ -225,7 +216,7 @@ export const Playground = { > {"$ cat <<'TOPIC1'"}
- +
+ { + setTitle("pendingRename", true) + setTitle("menuOpen", false) + }} + > + {language.t("common.rename")} + + void archiveSession(id())}> + {language.t("common.archive")} + + + dialog.show(() => )} + > + {language.t("common.delete")} + + + + +
+ )} + +
- - {(messageID) => { - const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? [])) - return ( -
{ - props.onRegisterMessage(el, messageID) - onCleanup(() => props.onUnregisterMessage(messageID)) - }} - classList={{ - "min-w-0 w-full max-w-full": true, - "md:max-w-200 2xl:max-w-[1000px]": props.centered, - }} - style={ - staging.ready() - ? undefined - : { "content-visibility": "auto", "contain-intrinsic-size": "auto 500px" } - } + +
+ 0 || props.historyMore}> +
+ +
+
+ + {(messageID) => { + const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? [])) + const commentCount = createMemo(() => comments().length) + return ( +
{ + props.onRegisterMessage(el, messageID) + onCleanup(() => props.onUnregisterMessage(messageID)) + }} + classList={{ + "min-w-0 w-full max-w-full": true, + "md:max-w-200 2xl:max-w-[1000px]": props.centered, + }} + style={ + staging.ready() + ? undefined + : { "content-visibility": "auto", "contain-intrinsic-size": "auto 500px" } + } + > + 0}> +
+
+
+ + {(commentAccessor: () => MessageComment) => { + const comment = createMemo(() => commentAccessor()) + return ( +
+
+ + {getFilename(comment().path)} + + {(selection) => ( + + {selection().startLine === selection().endLine + ? `:${selection().startLine}` + : `:${selection().startLine}-${selection().endLine}`} + + )} + +
+
+ {comment().comment} +
-
- ) - }} - + ) + }} + +
-
- - -
- ) - }} -
-
+ + +
+ ) + }} + +
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 14b344eb979..31bd956e174 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -468,7 +468,7 @@ export function AssistantParts(props: { ) }} - + ) } @@ -585,26 +585,65 @@ export function AssistantMessageDisplay(props: { turnDiffSummary?: () => JSX.Element showReasoningSummaries?: boolean }) { - const emptyTools: ToolPart[] = [] - const grouped = createMemo( - () => - groupParts( - props.parts - .filter((part) => renderable(part, props.showReasoningSummaries ?? true)) - .map((part) => ({ - messageID: props.message.id, - part, - })), - ), - [] as PartGroup[], - { equals: sameGroups }, - ) + const grouped = createMemo(() => { + const keys: string[] = [] + const items: Record = {} + const push = (key: string, item: { type: "part"; part: PartType } | { type: "context"; parts: ToolPart[] }) => { + keys.push(key) + items[key] = item + } - return ( - - {(entryAccessor) => { - const entryType = createMemo(() => entryAccessor().type) + const parts = props.parts + let start = -1 + + const flush = (end: number) => { + if (start < 0) return + const first = parts[start] + const last = parts[end] + if (!first || !last) { + start = -1 + return + } + push(`context:${first.id}`, { + type: "context", + parts: parts.slice(start, end + 1).filter((part): part is ToolPart => isContextGroupTool(part)), + }) + start = -1 + } + + parts.forEach((part, index) => { + if (!renderable(part, props.showReasoningSummaries ?? true)) return + + if (isContextGroupTool(part)) { + if (start < 0) start = index + return + } + + flush(index - 1) + push(`part:${part.id}`, { type: "part", part }) + }) + + flush(parts.length - 1) + return { keys, items } + }) + + return ( + + {(key) => { + const item = createMemo(() => grouped().items[key]) + const ctx = createMemo(() => { + const value = item() + if (!value) return + if (value.type !== "context") return + return value + }) + const part = createMemo(() => { + const value = item() + if (!value) return + if (value.type !== "part") return + return value + }) return ( <> {(entry) => } @@ -622,7 +661,7 @@ export function AssistantMessageDisplay(props: { ) }} - + ) } @@ -1030,20 +1069,20 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { const emptyInput: Record = {} const emptyMetadata: Record = {} - const input = () => part().state?.input ?? emptyInput + const input = () => part.state?.input ?? emptyInput // @ts-expect-error - const partMetadata = () => part().state?.metadata ?? emptyMetadata + const partMetadata = () => part.state?.metadata ?? emptyMetadata - const render = createMemo(() => ToolRegistry.render(part().tool) ?? GenericTool) + const render = createMemo(() => ToolRegistry.render(part.tool) ?? GenericTool) return (
- + {(error) => { const cleaned = error().replace("Error: ", "") - if (part().tool === "question" && cleaned.includes("dismissed this question")) { + if (part.tool === "question" && cleaned.includes("dismissed this question")) { return (
@@ -1082,8 +1121,8 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { callID={part.callID} metadata={partMetadata()} // @ts-expect-error - output={part().state.output} - status={part().state.status} + output={part.state.output} + status={part.state.status} hideDetails={props.hideDetails} defaultOpen={props.defaultOpen} animate @@ -1163,7 +1202,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { const summary = createMemo(() => { if (props.message.role !== "assistant") return if (!props.showTurnDiffSummary) return - if (props.showAssistantCopyPartID !== part.id) return + if (props.showAssistantCopyPartID !== part().id) return return props.turnDiffSummary }) @@ -1224,7 +1263,7 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) { return (
- +
) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index d6fae987578..088b6532969 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -17,6 +17,7 @@ import { DiffChanges } from "./diff-changes" import { Icon } from "./icon" import { TextShimmer } from "./text-shimmer" import { TextReveal } from "./text-reveal" +import { SessionRetry } from "./session-retry" import { createAutoScroll } from "../hooks" import { useI18n } from "../context/i18n" const THINKING_GAP_PX = 12 From 83de487dcd5bcf9584738f19919a7acd9007d9b0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 3 Mar 2026 11:28:15 -0500 Subject: [PATCH 33/76] fix(app): restore staging render path and nested scroll boundary checks --- .../src/pages/session/message-gesture.test.ts | 49 +++++++++++++++++-- .../app/src/pages/session/message-gesture.ts | 16 ++++-- .../src/pages/session/message-timeline.tsx | 18 +++++-- 3 files changed, 71 insertions(+), 12 deletions(-) diff --git a/packages/app/src/pages/session/message-gesture.test.ts b/packages/app/src/pages/session/message-gesture.test.ts index 69e26e8dc3e..65525cfe8d1 100644 --- a/packages/app/src/pages/session/message-gesture.test.ts +++ b/packages/app/src/pages/session/message-gesture.test.ts @@ -23,23 +23,25 @@ describe("shouldMarkBoundaryGesture", () => { scrollTop: 0, scrollHeight: 300, clientHeight: 300, + mode: "normal", }), ).toBe(true) }) - test("marks when scrolling beyond top boundary", () => { - // column-reverse: scrollTop=-590 means 590px from bottom (10px from top, max=600) + test("marks when scrolling beyond top boundary in reversed mode", () => { + // column-reverse: scrollTop=-590 means 10px from top (max=600) expect( shouldMarkBoundaryGesture({ delta: -40, scrollTop: -590, scrollHeight: 1000, clientHeight: 400, + mode: "reversed", }), ).toBe(true) }) - test("marks when scrolling beyond bottom boundary", () => { + test("marks when scrolling beyond bottom boundary in reversed mode", () => { // column-reverse: scrollTop=-20 means 20px from bottom expect( shouldMarkBoundaryGesture({ @@ -47,18 +49,55 @@ describe("shouldMarkBoundaryGesture", () => { scrollTop: -20, scrollHeight: 1000, clientHeight: 400, + mode: "reversed", }), ).toBe(true) }) - test("does not mark when nested scroller can consume movement", () => { - // column-reverse: scrollTop=-400 means 400px from bottom (middle of scroll) + test("does not mark when reversed scroller can consume movement", () => { expect( shouldMarkBoundaryGesture({ delta: 20, scrollTop: -400, scrollHeight: 1000, clientHeight: 400, + mode: "reversed", + }), + ).toBe(false) + }) + + test("marks when scrolling beyond top boundary in normal mode", () => { + expect( + shouldMarkBoundaryGesture({ + delta: -40, + scrollTop: 10, + scrollHeight: 1000, + clientHeight: 400, + mode: "normal", + }), + ).toBe(true) + }) + + test("marks when scrolling beyond bottom boundary in normal mode", () => { + expect( + shouldMarkBoundaryGesture({ + delta: 50, + scrollTop: 580, + scrollHeight: 1000, + clientHeight: 400, + mode: "normal", + }), + ).toBe(true) + }) + + test("does not mark when normal scroller can consume movement", () => { + expect( + shouldMarkBoundaryGesture({ + delta: 20, + scrollTop: 300, + scrollHeight: 1000, + clientHeight: 400, + mode: "normal", }), ).toBe(false) }) diff --git a/packages/app/src/pages/session/message-gesture.ts b/packages/app/src/pages/session/message-gesture.ts index 03ae724edbe..996fd6c4528 100644 --- a/packages/app/src/pages/session/message-gesture.ts +++ b/packages/app/src/pages/session/message-gesture.ts @@ -9,14 +9,22 @@ export const shouldMarkBoundaryGesture = (input: { scrollTop: number scrollHeight: number clientHeight: number + mode?: "reversed" | "normal" }) => { const max = input.scrollHeight - input.clientHeight if (max <= 1) return true if (!input.delta) return false - // With column-reverse: scrollTop=0 at bottom, -max at top - if (input.delta < 0) return input.scrollTop + input.delta <= -max + const mode = input.mode ?? "reversed" + if (mode === "normal") { + const top = Math.max(0, Math.min(max, input.scrollTop)) + if (input.delta < 0) return -input.delta > top + const bottom = max - top + return input.delta > bottom + } - const remaining = -input.scrollTop // distance from bottom - return input.delta > remaining + const top = max + Math.max(-max, Math.min(0, input.scrollTop)) + if (input.delta < 0) return -input.delta > top + const bottom = -Math.max(-max, Math.min(0, input.scrollTop)) + return input.delta > bottom } diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index b64f1f85420..f8d102819f1 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -73,6 +73,12 @@ const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => { return nested } +const boundaryMode = (root: HTMLDivElement, target: HTMLElement) => { + if (target === root) return "reversed" as const + if (target.dataset.scrollDirection === "reversed") return "reversed" as const + return "normal" as const +} + const markBoundaryGesture = (input: { root: HTMLDivElement target: EventTarget | null @@ -90,6 +96,7 @@ const markBoundaryGesture = (input: { scrollTop: target.scrollTop, scrollHeight: target.scrollHeight, clientHeight: target.clientHeight, + mode: boundaryMode(input.root, target), }) ) { input.onMarkScrollGesture(input.root) @@ -157,15 +164,19 @@ function createTimelineStaging(input: TimelineStageInput) { on( () => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const, ([sessionKey, isWindowed, total]) => { - cancel() const switched = active !== sessionKey if (switched) { active = sessionKey setReadySession("") } + const staging = state.activeSession === sessionKey && state.completedSession !== sessionKey - if (staging && !switched) return const shouldStage = isWindowed && total > input.config.init && state.completedSession !== sessionKey + + if (staging && !switched && shouldStage && frame !== undefined) return + + cancel() + if (shouldStage) setReadySession("") if (!shouldStage) { setState({ @@ -182,6 +193,7 @@ function createTimelineStaging(input: TimelineStageInput) { } 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 = () => { @@ -252,7 +264,6 @@ export function MessageTimeline(props: { const dialog = useDialog() const language = useLanguage() - const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id)) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const sessionID = createMemo(() => params.id) const sessionMessages = createMemo(() => { @@ -304,6 +315,7 @@ export function MessageTimeline(props: { messages: () => props.renderedUserMessages, config: stageCfg, }) + const rendered = createMemo(() => staging.messages().map((message) => message.id)) const [title, setTitle] = createStore({ draft: "", From a16d77b08d163c9eb2514a2e1651764f23e12044 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 3 Mar 2026 13:00:16 -0500 Subject: [PATCH 34/76] fix(app/ui/opencode): align tool detail reveal timing and turn animation gating --- .../src/pages/session/message-timeline.tsx | 7 +- packages/opencode/src/tool/read.ts | 6 +- packages/ui/src/components/basic-tool.css | 12 +- packages/ui/src/components/basic-tool.tsx | 71 +-- packages/ui/src/components/grow-box.tsx | 121 ++++- packages/ui/src/components/message-part.css | 34 +- packages/ui/src/components/message-part.tsx | 487 ++++++++++-------- packages/ui/src/components/session-turn.tsx | 8 +- 8 files changed, 432 insertions(+), 314 deletions(-) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index f8d102819f1..8edd8c4fae7 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -754,11 +754,6 @@ export function MessageTimeline(props: { "min-w-0 w-full max-w-full": true, "md:max-w-200 2xl:max-w-[1000px]": props.centered, }} - style={ - staging.ready() - ? undefined - : { "content-visibility": "auto", "contain-intrinsic-size": "auto 500px" } - } > 0}>
@@ -799,7 +794,7 @@ export function MessageTimeline(props: { i.filepath) + await ctx.metadata({ metadata: { loaded } }) // Exclude SVG (XML-based) and vnd.fastbidsheet (.fbs extension, commonly FlatBuffers schema files) const mime = Filesystem.mimeType(filepath) @@ -129,7 +131,7 @@ export const ReadTool = Tool.define("read", { metadata: { preview: msg, truncated: false, - loaded: instructions.map((i) => i.filepath), + loaded, }, attachments: [ { @@ -226,7 +228,7 @@ export const ReadTool = Tool.define("read", { metadata: { preview, truncated, - loaded: instructions.map((i) => i.filepath), + loaded, }, } }, diff --git a/packages/ui/src/components/basic-tool.css b/packages/ui/src/components/basic-tool.css index 1240ad7b995..7fd01cc8e21 100644 --- a/packages/ui/src/components/basic-tool.css +++ b/packages/ui/src/components/basic-tool.css @@ -8,7 +8,8 @@ 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; @@ -49,13 +50,15 @@ } [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,6 +66,7 @@ } [data-slot="basic-tool-tool-info-main"] { + flex: 0 1 auto; display: flex; align-items: center; gap: 8px; @@ -91,7 +95,9 @@ } [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; diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index 621b337ae05..9d5d58466d7 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -1,16 +1,4 @@ -import { - createEffect, - createSignal, - For, - Match, - on, - onCleanup, - onMount, - Show, - splitProps, - Switch, - type JSX, -} from "solid-js" +import { createEffect, createSignal, For, Match, on, onCleanup, onMount, Show, Switch, type JSX } from "solid-js" import { animate, type AnimationPlaybackControls, @@ -321,52 +309,45 @@ export interface ToolCallRowProps { status?: string animate?: boolean onSubtitleClick?: () => void + open?: boolean + showArrow?: boolean + onOpenChange?: (value: boolean) => void } - -// `group` currently shares the same behavior as `panel`; the separate variant is semantic so grouped call sites can stay explicit. export interface ToolCallPanelProps extends Omit { - variant: "panel" | "group" + variant: "panel" } - export type ToolCallProps = ToolCallRowProps | ToolCallPanelProps function ToolCallRoot(props: ToolCallProps) { - const [local, rest] = splitProps(props, ["variant", "trigger", "status", "onSubtitleClick"]) - const pending = () => local.status === "pending" || local.status === "running" - if (local.variant === "row") { + const pending = () => props.status === "pending" || props.status === "running" + if (props.variant === "row") { + if (props.showArrow && props.onOpenChange) { + return ( + + + + + + ) + } + return (
- +
) } - return ( - - ) -} - -function ToolCallList(props: { children?: JSX.Element }) { - return
{props.children}
+ const { variant: _, ...rest } = props + return } - -function ToolCallRow(props: { children: JSX.Element }) { - return ( -
-
-
-
{props.children}
-
-
-
- ) -} - -export const ToolCall = Object.assign(ToolCallRoot, { - List: ToolCallList, - Row: ToolCallRow, -}) +export const ToolCall = ToolCallRoot export function GenericTool(props: { tool: string; status?: string; hideDetails?: boolean }) { return ( diff --git a/packages/ui/src/components/grow-box.tsx b/packages/ui/src/components/grow-box.tsx index 0429517ff60..cf6d71cd032 100644 --- a/packages/ui/src/components/grow-box.tsx +++ b/packages/ui/src/components/grow-box.tsx @@ -1,4 +1,4 @@ -import { type JSX, onMount, onCleanup } from "solid-js" +import { createEffect, on, type JSX, onMount, onCleanup } from "solid-js" import { animate, springValue, type AnimationPlaybackControls, FADE_SPRING, HEIGHT_SPRING } from "./motion" export interface GrowBoxProps { @@ -15,6 +15,10 @@ export interface GrowBoxProps { 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. */ @@ -40,6 +44,8 @@ export function GrowBox(props: GrowBoxProps) { 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 currentHeight = () => { if (!root) return 0 @@ -54,7 +60,7 @@ export function GrowBox(props: GrowBoxProps) { const targetHeight = () => Math.max(0, Math.ceil(body?.getBoundingClientRect().height ?? 0)) const setHeight = () => { - if (!root) return + if (!root || !open()) return const next = targetHeight() if (next === springTarget) return const prev = currentHeight() @@ -88,6 +94,12 @@ export function GrowBox(props: GrowBoxProps) { if (!root) return root.style.willChange = "" root.style.contain = "" + if (!open()) { + springTarget = 0 + root.style.height = "0px" + root.style.overflow = "hidden" + return + } const next = targetHeight() springTarget = next if (props.autoHeight === false || watch()) { @@ -106,41 +118,49 @@ export function GrowBox(props: GrowBoxProps) { }) if (!props.animate) { - root.style.height = "" - root.style.overflow = "" - body.style.opacity = "" - body.style.filter = "" + root.style.height = open() ? "" : "0px" + root.style.overflow = open() ? "" : "hidden" + body.style.opacity = open() || props.fade === false ? "" : "0" + body.style.filter = open() || props.fade === false ? "" : "blur(2px)" return } - if (grow()) { + if (!open()) { root.style.height = "0px" root.style.overflow = "hidden" + if (props.fade !== false) { + body.style.opacity = "0" + body.style.filter = "blur(2px)" + } } else { - root.style.height = "auto" - root.style.overflow = "visible" - } - - if (props.fade !== false) { - body.style.opacity = "0" - body.style.filter = "blur(2px)" - } - - mountFrame = requestAnimationFrame(() => { - mountFrame = undefined - if (props.fade !== false && body) { - fadeAnim?.stop() - fadeAnim = animate(body, { opacity: 1, filter: "blur(0px)" }, FADE_SPRING) - fadeAnim.finished.then(() => { - if (!body) return - body.style.opacity = "" - body.style.filter = "" - }) + if (grow()) { + root.style.height = "0px" + root.style.overflow = "hidden" + } else { + root.style.height = "auto" + root.style.overflow = "visible" } - if (grow()) setHeight() - }) + if (props.fade !== false) { + body.style.opacity = "0" + body.style.filter = "blur(2px)" + } + mountFrame = requestAnimationFrame(() => { + mountFrame = undefined + if (props.fade !== false && body) { + fadeAnim?.stop() + fadeAnim = animate(body, { opacity: 1, filter: "blur(0px)" }, FADE_SPRING) + fadeAnim.finished.then(() => { + if (!body) return + body.style.opacity = "" + body.style.filter = "" + }) + } + if (grow()) setHeight() + }) + } if (watch()) { observer = new ResizeObserver(() => { + if (!open()) return if (resizeFrame !== undefined) return resizeFrame = requestAnimationFrame(() => { resizeFrame = undefined @@ -151,6 +171,51 @@ export function GrowBox(props: GrowBoxProps) { } }) + createEffect( + on( + () => props.open, + (value) => { + if (value === undefined) return + if (!root || !body) return + if (!animateToggle()) { + root.style.height = value ? "" : "0px" + root.style.overflow = value ? "" : "hidden" + body.style.opacity = value || props.fade === false ? "" : "0" + body.style.filter = value || props.fade === false ? "" : "blur(2px)" + return + } + fadeAnim?.stop() + 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)" }, FADE_SPRING) + } + root.style.overflow = "hidden" + springTarget = 0 + height.set(0) + return + } + if (props.fade !== false) { + body.style.opacity = "0" + body.style.filter = "blur(2px)" + fadeAnim = animate(body, { opacity: 1, filter: "blur(0px)" }, FADE_SPRING) + fadeAnim.finished.then(() => { + if (!body || !open()) return + body.style.opacity = "" + body.style.filter = "" + }) + } + setHeight() + }, + { defer: true }, + ), + ) + onCleanup(() => { if (mountFrame !== undefined) cancelAnimationFrame(mountFrame) if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index f719e99ce09..083b29abb15 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -386,18 +386,20 @@ 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; } } -} [data-component="bash-output"] { width: 100%; @@ -641,20 +643,14 @@ } } -[data-component="context-tool-group-list"], -[data-component="tool-call-list"] { - margin-top: -4px; - display: flex; - flex-direction: column; - gap: 0px; -} - -[data-component="context-tool-group-list"] [data-slot="context-tool-group-item"], -[data-component="tool-call-list"] [data-slot="tool-call-item"] { +[data-component="context-tool-step"] { + width: 100%; min-width: 0; - padding: 4px 0; + padding-left: 12px; } + + [data-component="diagnostics"] { display: flex; flex-direction: column; @@ -1284,10 +1280,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); @@ -1298,4 +1295,11 @@ flex-shrink: 0; color: var(--icon-weak); } + + span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } } diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 31bd956e174..b30ce446c9f 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -98,6 +98,7 @@ export interface MessageProps { showAssistantCopyPartID?: string | null interrupted?: boolean animate?: boolean + working?: boolean showReasoningSummaries?: boolean } @@ -262,7 +263,8 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo { case "skill": return { icon: "brain", - title: input.name || "skill", + title: i18n.t("ui.tool.skill"), + subtitle: typeof input.name === "string" ? input.name : undefined, } default: return { @@ -284,6 +286,42 @@ function busy(status: string | undefined) { return status === "pending" || status === "running" } +const pageVisible = /* @__PURE__ */ (() => { + const [visible, setVisible] = createSignal(true) + if (typeof document !== "undefined") { + const sync = () => setVisible(document.visibilityState !== "hidden") + sync() + document.addEventListener("visibilitychange", sync) + } + return visible +})() + +function createGroupOpenState() { + const [state, setState] = createSignal>({}) + const read = (key?: string, collapse?: boolean) => { + if (!key) return true + const value = state()[key] + if (value !== undefined) return value + return !collapse + } + const write = (key: string, value: boolean) => { + setState((prev) => ({ ...prev, [key]: value })) + } + return { read, write } +} + +function shouldCollapseGroup( + statuses: (string | undefined)[], + opts: { afterTool?: boolean; groupTail?: boolean; working?: boolean }, +) { + if (opts.afterTool) return true + if (opts.groupTail === false) return true + if (!pageVisible()) return false + if (opts.working) return false + if (!statuses.length) return false + return !statuses.some((s) => busy(s)) +} + function renderable(part: PartType, showReasoningSummaries = true) { if (part.type === "tool") { if (HIDDEN_TOOLS.has(part.tool)) return false @@ -309,19 +347,23 @@ function partDefaultOpen(part: PartType, shell = false, edit = false) { function PartGrow(props: { children: JSX.Element animate?: boolean + animateToggle?: boolean debugID?: string gap?: number fade?: boolean grow?: boolean watch?: boolean + open?: boolean }) { return ( {props.children} @@ -343,17 +385,30 @@ export function AssistantParts(props: { }) { const data = useData() const emptyParts: PartType[] = [] - + const groupState = createGroupOpenState() const grouped = createMemo(() => { const keys: string[] = [] const items: Record< string, - { type: "part"; part: PartType; message: AssistantMessage } | { type: "context"; parts: ToolPart[] } + | { + type: "part" + part: PartType + message: AssistantMessage + context?: boolean + groupKey?: string + afterTool?: boolean + groupTail?: boolean + groupParts?: { part: ToolPart; message: AssistantMessage }[] + } + | { + type: "context" + groupKey: string + parts: { part: ToolPart; message: AssistantMessage }[] + tail: boolean + afterTool: boolean + } > = {} - const push = ( - key: string, - item: { type: "part"; part: PartType; message: AssistantMessage } | { type: "context"; parts: ToolPart[] }, - ) => { + const push = (key: string, item: (typeof items)[string]) => { keys.push(key) items[key] = item } @@ -361,7 +416,6 @@ export function AssistantParts(props: { if (part.type === "tool") return part.callID || part.id return part.id } - const parts = props.messages.flatMap((message) => list(data.store.part?.[message.id], emptyParts) .filter((part) => renderable(part, props.showReasoningSummaries ?? true)) @@ -370,35 +424,48 @@ export function AssistantParts(props: { let start = -1 - const flush = (end: number) => { + const flush = (end: number, tail: boolean, afterTool: boolean) => { if (start < 0) return - const first = parts[start] - if (!first || !parts[end]) { + const group = parts + .slice(start, end + 1) + .filter((entry): entry is { part: ToolPart; message: AssistantMessage } => isContextGroupTool(entry.part)) + if (!group.length) { start = -1 return } - push(`context:${first.message.id}:${id(first.part)}`, { + const groupKey = `context:${group[0].message.id}:${id(group[0].part)}` + push(groupKey, { type: "context", - parts: parts - .slice(start, end + 1) - .map((x) => x.part) - .filter((part): part is ToolPart => isContextGroupTool(part)), + groupKey, + parts: group, + tail, + afterTool, + }) + group.forEach((entry) => { + push(`part:${entry.message.id}:${id(entry.part)}`, { + type: "part", + part: entry.part, + message: entry.message, + context: true, + groupKey, + afterTool, + groupTail: tail, + groupParts: group, + }) }) start = -1 } - parts.forEach((item, index) => { if (isContextGroupTool(item.part)) { if (start < 0) start = index return } - flush(index - 1) + flush(index - 1, false, (item as { part: PartType }).part.type === "tool") push(`part:${item.message.id}:${id(item.part)}`, { type: "part", part: item.part, message: item.message }) }) - flush(parts.length - 1) - + flush(parts.length - 1, true, false) return { keys, items } }) @@ -426,6 +493,25 @@ export function AssistantParts(props: { if (!value) return false return value.part.type === "tool" }) + const context = createMemo(() => !!part()?.context) + const contextOpen = createMemo(() => { + const collapse = ( + afterTool?: boolean, + groupTail?: boolean, + group?: { part: ToolPart; message: AssistantMessage }[], + ) => + shouldCollapseGroup(group?.map((item) => item.part.state.status) ?? [], { + afterTool, + groupTail, + working: props.working, + }) + const value = ctx() + if (value) return groupState.read(value.groupKey, collapse(value.afterTool, value.tail, value.parts)) + const entry = part() + return groupState.read(entry?.groupKey, collapse(entry?.afterTool, entry?.groupTail, entry?.groupParts)) + }) + const visible = createMemo(() => !context() || contextOpen()) + const turnSummary = createMemo(() => { const value = part() if (!value) return false @@ -441,28 +527,38 @@ export function AssistantParts(props: { {(entry) => ( - + item.part)} + busy={props.working && entry().tail} + open={contextOpen()} + onOpenChange={(value: boolean) => groupState.write(entry().groupKey, value)} + /> )} {(entry) => ( - +
+ +
)}
@@ -476,69 +572,6 @@ function isContextGroupTool(part: PartType): part is ToolPart { return part.type === "tool" && CONTEXT_GROUP_TOOLS.has(part.tool) } -function contextToolDetail(part: ToolPart): string | undefined { - const info = getToolInfo(part.tool, part.state.input ?? {}) - if (info.subtitle) return info.subtitle - if (part.state.status === "error") return part.state.error - if ((part.state.status === "running" || part.state.status === "completed") && part.state.title) - return part.state.title - const description = part.state.input?.description - if (typeof description === "string") return description - return undefined -} - -function contextToolTrigger(part: ToolPart, i18n: ReturnType) { - const input = (part.state.input ?? {}) as Record - const path = typeof input.path === "string" ? input.path : "/" - const filePath = typeof input.filePath === "string" ? input.filePath : undefined - const pattern = typeof input.pattern === "string" ? input.pattern : undefined - const include = typeof input.include === "string" ? input.include : undefined - const offset = typeof input.offset === "number" ? input.offset : undefined - const limit = typeof input.limit === "number" ? input.limit : undefined - - switch (part.tool) { - case "read": { - const args: string[] = [] - if (offset !== undefined) args.push("offset=" + offset) - if (limit !== undefined) args.push("limit=" + limit) - return { - title: i18n.t("ui.tool.read"), - subtitle: filePath ? getFilename(filePath) : "", - args, - } - } - case "list": - return { - title: i18n.t("ui.tool.list"), - subtitle: getDirectory(path), - } - case "glob": - return { - title: i18n.t("ui.tool.glob"), - subtitle: getDirectory(path), - args: pattern ? ["pattern=" + pattern] : [], - } - case "grep": { - const args: string[] = [] - if (pattern) args.push("pattern=" + pattern) - if (include) args.push("include=" + include) - return { - title: i18n.t("ui.tool.grep"), - subtitle: getDirectory(path), - args, - } - } - default: { - const info = getToolInfo(part.tool, input) - return { - title: info.title, - subtitle: info.subtitle || contextToolDetail(part), - args: [], - } - } - } -} - function contextToolSummary(parts: ToolPart[]) { const read = parts.filter((part) => part.tool === "read").length const search = parts.filter((part) => part.tool === "glob" || part.tool === "grep").length @@ -569,6 +602,7 @@ export function Message(props: MessageProps) { message={assistantMessage() as AssistantMessage} parts={props.parts} showAssistantCopyPartID={props.showAssistantCopyPartID} + working={props.working} showReasoningSummaries={props.showReasoningSummaries} /> )} @@ -583,48 +617,64 @@ export function AssistantMessageDisplay(props: { showAssistantCopyPartID?: string | null showTurnDiffSummary?: boolean turnDiffSummary?: () => JSX.Element + working?: boolean showReasoningSummaries?: boolean }) { + const groupState = createGroupOpenState() const grouped = createMemo(() => { const keys: string[] = [] - const items: Record = {} - const push = (key: string, item: { type: "part"; part: PartType } | { type: "context"; parts: ToolPart[] }) => { + const items: Record< + string, + | { + type: "part" + part: PartType + context?: boolean + groupKey?: string + afterTool?: boolean + groupTail?: boolean + groupParts?: ToolPart[] + } + | { type: "context"; groupKey: string; parts: ToolPart[]; tail: boolean; afterTool: boolean } + > = {} + const push = (key: string, item: (typeof items)[string]) => { keys.push(key) items[key] = item } - - const parts = props.parts + const parts = props.parts.filter((part) => renderable(part, props.showReasoningSummaries ?? true)) let start = -1 - - const flush = (end: number) => { + const flush = (end: number, tail: boolean, afterTool: boolean) => { if (start < 0) return - const first = parts[start] - const last = parts[end] - if (!first || !last) { + const group = parts.slice(start, end + 1).filter((part): part is ToolPart => isContextGroupTool(part)) + if (!group.length) { start = -1 return } - push(`context:${first.id}`, { - type: "context", - parts: parts.slice(start, end + 1).filter((part): part is ToolPart => isContextGroupTool(part)), - }) + const groupKey = `context:${group[0].id}` + push(groupKey, { type: "context", groupKey, parts: group, tail, afterTool }) + group.forEach((part) => + push(`part:${part.id}`, { + type: "part", + part, + context: true, + groupKey, + afterTool, + groupTail: tail, + groupParts: group, + }), + ) start = -1 } parts.forEach((part, index) => { - if (!renderable(part, props.showReasoningSummaries ?? true)) return - if (isContextGroupTool(part)) { if (start < 0) start = index return } - - flush(index - 1) + flush(index - 1, false, (part as PartType).type === "tool") push(`part:${part.id}`, { type: "part", part }) }) - flush(parts.length - 1) - + flush(parts.length - 1, true, false) return { keys, items } }) @@ -644,20 +694,45 @@ export function AssistantMessageDisplay(props: { if (value.type !== "part") return return value }) + const contextOpen = createMemo(() => { + const collapse = (afterTool?: boolean, groupTail?: boolean, group?: ToolPart[]) => + shouldCollapseGroup(group?.map((part) => part.state.status) ?? [], { + afterTool, + groupTail, + working: props.working, + }) + const value = ctx() + if (value) return groupState.read(value.groupKey, collapse(value.afterTool, value.tail, value.parts)) + const entry = part() + return groupState.read(entry?.groupKey, collapse(entry?.afterTool, entry?.groupTail, entry?.groupParts)) + }) return ( <> - {(entry) => } - + {(entry) => ( - groupState.write(entry().groupKey, value)} /> )} + + {(entry) => ( + +
+ +
+
+ )} +
) }} @@ -665,24 +740,27 @@ export function AssistantMessageDisplay(props: { ) } -function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; animate?: boolean }) { +function ContextToolGroup(props: { + parts: ToolPart[] + busy?: boolean + open: boolean + onOpenChange: (value: boolean) => void +}) { const i18n = useI18n() const anyRunning = createMemo(() => props.parts.some((part) => busy(part.state.status))) - // Once all parts are done and the group is no longer busy, latch into - // "explored" permanently so brief status flickers can't jump it back. const [settled, setSettled] = createSignal(false) createEffect(() => { if (!anyRunning() && !props.busy) setSettled(true) }) const pending = createMemo(() => !settled() && (!!props.busy || anyRunning())) const summary = createMemo(() => contextToolSummary(props.parts)) - return (
} - > - - - {(part) => { - const trigger = contextToolTrigger(part, i18n) - const running = createMemo(() => busy(part.state.status)) - const reveal = useToolReveal(running, () => props.animate !== false) - return ( - -
-
- - - - {(text) => } - - - {(arg, idx) => } - - -
-
-
- ) - }} -
-
- + /> ) } @@ -1278,7 +1329,6 @@ ToolRegistry.register({ if (props.input.offset) args.push("offset=" + props.input.offset) if (props.input.limit) args.push("limit=" + props.input.limit) const loaded = createMemo(() => { - if (props.status !== "completed") return [] const value = props.metadata.loaded if (!value || !Array.isArray(value)) return [] return value.filter((p): p is string => typeof p === "string") @@ -1302,12 +1352,10 @@ ToolRegistry.register({ /> {(filepath) => ( -
- - - {i18n.t("ui.tool.loaded")} {relativizeProjectPath(filepath, data.directory)} - -
+ )}
@@ -1329,7 +1377,7 @@ ToolRegistry.register({ } @@ -1360,7 +1408,7 @@ ToolRegistry.register({ @@ -1559,14 +1607,15 @@ function ToolText(props: { text: string; delay?: number; animate?: boolean }) { ) } -function ToolArg(props: { text: string; delay?: number; animate?: boolean }) { - let ref: HTMLSpanElement | undefined - useToolFade(() => ref, { delay: props.delay, wipe: true, animate: props.animate }) +function ToolLoadedFile(props: { text: string; animate?: boolean }) { + let ref: HTMLDivElement | undefined + useToolFade(() => ref, { delay: 0.02, wipe: true, animate: props.animate }) return ( - - {props.text} - +
+ + {props.text} +
) } @@ -1577,11 +1626,17 @@ function ToolTriggerRow(props: { args?: string[] action?: JSX.Element animate?: boolean + revealOnMount?: boolean }) { const reveal = useToolReveal( () => props.pending, () => props.animate !== false, ) + const detail = createMemo(() => [props.subtitle, ...(props.args ?? [])].filter((x): x is string => !!x).join(" ")) + const detailAnimate = createMemo(() => { + if (props.revealOnMount) return props.animate !== false + return reveal() + }) return (
@@ -1589,14 +1644,9 @@ function ToolTriggerRow(props: { - {(text) => } - - - {(arg, idx) => } - - + {(text) => }
- {props.action} + {props.action}
) } @@ -1672,7 +1722,7 @@ ToolRegistry.register({ - {(value) => } + {(value) => }
} @@ -1863,13 +1913,15 @@ ToolRegistry.register({ - - + + {(name) => ( + + )}
@@ -1932,12 +1984,14 @@ ToolRegistry.register({ - - + + {(name) => ( + + )} @@ -2021,7 +2075,7 @@ ToolRegistry.register({ - + {(file) => ( )} - - {(text) => } - + {(text) => } @@ -2282,21 +2334,28 @@ ToolRegistry.register({ ToolRegistry.register({ name: "skill", render(props) { - const title = createMemo(() => props.input.name || "skill") - const running = createMemo(() => busy(props.status)) - - const titleContent = () => - - const trigger = () => ( -
-
- - {titleContent()} - -
-
+ const i18n = useI18n() + const pending = createMemo(() => busy(props.status)) + const name = createMemo(() => { + const value = props.input.name || props.metadata.name + if (typeof value === "string") return value + }) + return ( + + } + animate + /> ) - - return }, }) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 088b6532969..a7331234a1c 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -690,7 +690,13 @@ export function SessionTurn( class={props.classes?.container} >
- +
{(part) => ( From 7add2e67c3228b2d3a00866625006c6bef745c3f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 3 Mar 2026 13:02:05 -0500 Subject: [PATCH 35/76] chore(ui): add skill label translations --- packages/ui/src/i18n/ar.ts | 1 + packages/ui/src/i18n/br.ts | 1 + packages/ui/src/i18n/bs.ts | 1 + packages/ui/src/i18n/da.ts | 1 + packages/ui/src/i18n/de.ts | 1 + packages/ui/src/i18n/en.ts | 1 + packages/ui/src/i18n/es.ts | 1 + packages/ui/src/i18n/fr.ts | 1 + packages/ui/src/i18n/ja.ts | 1 + packages/ui/src/i18n/ko.ts | 1 + packages/ui/src/i18n/no.ts | 1 + packages/ui/src/i18n/pl.ts | 1 + packages/ui/src/i18n/ru.ts | 1 + packages/ui/src/i18n/th.ts | 1 + packages/ui/src/i18n/tr.ts | 1 + packages/ui/src/i18n/zh.ts | 1 + packages/ui/src/i18n/zht.ts | 1 + 17 files changed, 17 insertions(+) diff --git a/packages/ui/src/i18n/ar.ts b/packages/ui/src/i18n/ar.ts index 3579eff5a8a..065802376f7 100644 --- a/packages/ui/src/i18n/ar.ts +++ b/packages/ui/src/i18n/ar.ts @@ -99,6 +99,7 @@ export const dict = { "ui.tool.todos": "المهام", "ui.tool.todos.read": "قراءة المهام", "ui.tool.questions": "أسئلة", + "ui.tool.skill": "مهارة", "ui.tool.agent": "وكيل {{type}}", "ui.common.file.one": "ملف", diff --git a/packages/ui/src/i18n/br.ts b/packages/ui/src/i18n/br.ts index 76028878f9c..dbd4cb8b79a 100644 --- a/packages/ui/src/i18n/br.ts +++ b/packages/ui/src/i18n/br.ts @@ -99,6 +99,7 @@ export const dict = { "ui.tool.todos": "Tarefas", "ui.tool.todos.read": "Ler tarefas", "ui.tool.questions": "Perguntas", + "ui.tool.skill": "Habilidade", "ui.tool.agent": "Agente {{type}}", "ui.common.file.one": "arquivo", diff --git a/packages/ui/src/i18n/bs.ts b/packages/ui/src/i18n/bs.ts index 9bc22933612..615af15cca5 100644 --- a/packages/ui/src/i18n/bs.ts +++ b/packages/ui/src/i18n/bs.ts @@ -103,6 +103,7 @@ export const dict = { "ui.tool.todos": "Lista zadataka", "ui.tool.todos.read": "Čitanje liste zadataka", "ui.tool.questions": "Pitanja", + "ui.tool.skill": "Vještina", "ui.tool.agent": "{{type}} agent", "ui.common.file.one": "datoteka", diff --git a/packages/ui/src/i18n/da.ts b/packages/ui/src/i18n/da.ts index 1bb4758568e..c404134c4d9 100644 --- a/packages/ui/src/i18n/da.ts +++ b/packages/ui/src/i18n/da.ts @@ -98,6 +98,7 @@ export const dict = { "ui.tool.todos": "Opgaver", "ui.tool.todos.read": "Læs opgaver", "ui.tool.questions": "Spørgsmål", + "ui.tool.skill": "Færdighed", "ui.tool.agent": "{{type}} Agent", "ui.common.file.one": "fil", diff --git a/packages/ui/src/i18n/de.ts b/packages/ui/src/i18n/de.ts index 951833c3091..37ce487a2a0 100644 --- a/packages/ui/src/i18n/de.ts +++ b/packages/ui/src/i18n/de.ts @@ -104,6 +104,7 @@ export const dict = { "ui.tool.todos": "Aufgaben", "ui.tool.todos.read": "Aufgaben lesen", "ui.tool.questions": "Fragen", + "ui.tool.skill": "Fähigkeit", "ui.tool.agent": "{{type}} Agent", "ui.common.file.one": "Datei", diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 9c9ae6e27a5..7d6f779745d 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -100,6 +100,7 @@ export const dict: Record = { "ui.tool.todos": "To-dos", "ui.tool.todos.read": "Read to-dos", "ui.tool.questions": "Questions", + "ui.tool.skill": "Skill", "ui.tool.agent": "{{type}} Agent", "ui.common.file.one": "file", diff --git a/packages/ui/src/i18n/es.ts b/packages/ui/src/i18n/es.ts index 6fb6eea5117..d88516308bb 100644 --- a/packages/ui/src/i18n/es.ts +++ b/packages/ui/src/i18n/es.ts @@ -99,6 +99,7 @@ export const dict = { "ui.tool.todos": "Tareas", "ui.tool.todos.read": "Leer tareas", "ui.tool.questions": "Preguntas", + "ui.tool.skill": "Habilidad", "ui.tool.agent": "Agente {{type}}", "ui.common.file.one": "archivo", diff --git a/packages/ui/src/i18n/fr.ts b/packages/ui/src/i18n/fr.ts index 3a77a3f5c63..28c54fb0462 100644 --- a/packages/ui/src/i18n/fr.ts +++ b/packages/ui/src/i18n/fr.ts @@ -99,6 +99,7 @@ export const dict = { "ui.tool.todos": "Tâches", "ui.tool.todos.read": "Lire les tâches", "ui.tool.questions": "Questions", + "ui.tool.skill": "Compétence", "ui.tool.agent": "Agent {{type}}", "ui.common.file.one": "fichier", diff --git a/packages/ui/src/i18n/ja.ts b/packages/ui/src/i18n/ja.ts index 9dfb03f76b1..7f285d42c26 100644 --- a/packages/ui/src/i18n/ja.ts +++ b/packages/ui/src/i18n/ja.ts @@ -98,6 +98,7 @@ export const dict = { "ui.tool.todos": "Todo", "ui.tool.todos.read": "Todo読み込み", "ui.tool.questions": "質問", + "ui.tool.skill": "スキル", "ui.tool.agent": "{{type}}エージェント", "ui.common.file.one": "ファイル", diff --git a/packages/ui/src/i18n/ko.ts b/packages/ui/src/i18n/ko.ts index 84d261ac89f..a10e88d1aa7 100644 --- a/packages/ui/src/i18n/ko.ts +++ b/packages/ui/src/i18n/ko.ts @@ -99,6 +99,7 @@ export const dict = { "ui.tool.todos": "할 일", "ui.tool.todos.read": "할 일 읽기", "ui.tool.questions": "질문", + "ui.tool.skill": "스킬", "ui.tool.agent": "{{type}} 에이전트", "ui.common.file.one": "파일", diff --git a/packages/ui/src/i18n/no.ts b/packages/ui/src/i18n/no.ts index dd1822beee4..54ba01ace0a 100644 --- a/packages/ui/src/i18n/no.ts +++ b/packages/ui/src/i18n/no.ts @@ -102,6 +102,7 @@ export const dict: Record = { "ui.tool.todos": "Gjøremål", "ui.tool.todos.read": "Les gjøremål", "ui.tool.questions": "Spørsmål", + "ui.tool.skill": "Ferdighet", "ui.tool.agent": "{{type}}-agent", "ui.common.file.one": "fil", diff --git a/packages/ui/src/i18n/pl.ts b/packages/ui/src/i18n/pl.ts index fcfedb2ef98..4e43bb6d286 100644 --- a/packages/ui/src/i18n/pl.ts +++ b/packages/ui/src/i18n/pl.ts @@ -98,6 +98,7 @@ export const dict = { "ui.tool.todos": "Zadania", "ui.tool.todos.read": "Czytaj zadania", "ui.tool.questions": "Pytania", + "ui.tool.skill": "Umiejętność", "ui.tool.agent": "Agent {{type}}", "ui.common.file.one": "plik", diff --git a/packages/ui/src/i18n/ru.ts b/packages/ui/src/i18n/ru.ts index 713ff47d1e6..371e4f8fffc 100644 --- a/packages/ui/src/i18n/ru.ts +++ b/packages/ui/src/i18n/ru.ts @@ -98,6 +98,7 @@ export const dict = { "ui.tool.todos": "Задачи", "ui.tool.todos.read": "Читать задачи", "ui.tool.questions": "Вопросы", + "ui.tool.skill": "Навык", "ui.tool.agent": "Агент {{type}}", "ui.common.file.one": "файл", diff --git a/packages/ui/src/i18n/th.ts b/packages/ui/src/i18n/th.ts index 44761a279e1..55ccf9af67d 100644 --- a/packages/ui/src/i18n/th.ts +++ b/packages/ui/src/i18n/th.ts @@ -100,6 +100,7 @@ export const dict = { "ui.tool.todos": "รายการงาน", "ui.tool.todos.read": "อ่านรายการงาน", "ui.tool.questions": "คำถาม", + "ui.tool.skill": "ทักษะ", "ui.tool.agent": "เอเจนต์ {{type}}", "ui.common.file.one": "ไฟล์", diff --git a/packages/ui/src/i18n/tr.ts b/packages/ui/src/i18n/tr.ts index 5ec108d4aa4..22f31b84fe0 100644 --- a/packages/ui/src/i18n/tr.ts +++ b/packages/ui/src/i18n/tr.ts @@ -95,6 +95,7 @@ export const dict = { "ui.tool.todos": "Görevler", "ui.tool.todos.read": "Görevleri oku", "ui.tool.questions": "Sorular", + "ui.tool.skill": "Beceri", "ui.tool.agent": "{{type}} Ajan", "ui.common.file.one": "dosya", diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index 39226605b90..fbe5c4f67cb 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -103,6 +103,7 @@ export const dict = { "ui.tool.todos": "待办", "ui.tool.todos.read": "读取待办", "ui.tool.questions": "问题", + "ui.tool.skill": "技能", "ui.tool.agent": "{{type}} 智能体", "ui.common.file.one": "个文件", diff --git a/packages/ui/src/i18n/zht.ts b/packages/ui/src/i18n/zht.ts index 068e222d65d..fb345153bf2 100644 --- a/packages/ui/src/i18n/zht.ts +++ b/packages/ui/src/i18n/zht.ts @@ -103,6 +103,7 @@ export const dict = { "ui.tool.todos": "待辦", "ui.tool.todos.read": "讀取待辦", "ui.tool.questions": "問題", + "ui.tool.skill": "技能", "ui.tool.agent": "{{type}} 代理程式", "ui.common.file.one": "個檔案", From a0296094c50b092b96c6f858a42a4d901bf92124 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 3 Mar 2026 13:26:55 -0500 Subject: [PATCH 36/76] fix(ui): preserve SolidJS reactivity and clean up minor issues Use splitProps instead of JS destructuring in ToolCallRoot to preserve reactivity on forwarded props. Extract fade helpers in GrowBox to deduplicate opacity/filter/blur patterns across mount and toggle paths (also fixes mount fade-in cleanup not checking open() state). Simplify redundant getDirectory ternary guards and contextToolSummary iteration. --- packages/ui/src/components/basic-tool.tsx | 4 +- packages/ui/src/components/grow-box.tsx | 69 ++++++++++----------- packages/ui/src/components/message-part.tsx | 17 +++-- 3 files changed, 47 insertions(+), 43 deletions(-) diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index 9d5d58466d7..db4d71d4e9a 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -1,4 +1,4 @@ -import { createEffect, createSignal, For, Match, on, onCleanup, onMount, Show, Switch, type JSX } from "solid-js" +import { createEffect, createSignal, For, Match, on, onCleanup, onMount, Show, splitProps, Switch, type JSX } from "solid-js" import { animate, type AnimationPlaybackControls, @@ -344,7 +344,7 @@ function ToolCallRoot(props: ToolCallProps) { ) } - const { variant: _, ...rest } = props + const [, rest] = splitProps(props, ["variant"]) return } export const ToolCall = ToolCallRoot diff --git a/packages/ui/src/components/grow-box.tsx b/packages/ui/src/components/grow-box.tsx index cf6d71cd032..fb70765adb1 100644 --- a/packages/ui/src/components/grow-box.tsx +++ b/packages/ui/src/components/grow-box.tsx @@ -47,6 +47,34 @@ export function GrowBox(props: GrowBoxProps) { const open = () => props.open !== false const animateToggle = () => props.animateToggle !== false + const hideBody = () => { + body!.style.opacity = "0" + body!.style.filter = "blur(2px)" + } + + const clearBody = () => { + body!.style.opacity = "" + body!.style.filter = "" + } + + const fadeBodyIn = () => { + if (props.fade === false || !body) return + hideBody() + fadeAnim?.stop() + fadeAnim = animate(body, { opacity: 1, filter: "blur(0px)" }, FADE_SPRING) + fadeAnim.finished.then(() => { + if (!body || !open()) return + clearBody() + }) + } + + const setInstant = (visible: boolean) => { + root!.style.height = visible ? "" : "0px" + root!.style.overflow = visible ? "" : "hidden" + if (visible || props.fade === false) clearBody() + else hideBody() + } + const currentHeight = () => { if (!root) return 0 const v = root.style.height @@ -118,20 +146,15 @@ export function GrowBox(props: GrowBoxProps) { }) if (!props.animate) { - root.style.height = open() ? "" : "0px" - root.style.overflow = open() ? "" : "hidden" - body.style.opacity = open() || props.fade === false ? "" : "0" - body.style.filter = open() || props.fade === false ? "" : "blur(2px)" + setInstant(open()) return } + if (props.fade !== false) hideBody() + if (!open()) { root.style.height = "0px" root.style.overflow = "hidden" - if (props.fade !== false) { - body.style.opacity = "0" - body.style.filter = "blur(2px)" - } } else { if (grow()) { root.style.height = "0px" @@ -140,21 +163,9 @@ export function GrowBox(props: GrowBoxProps) { root.style.height = "auto" root.style.overflow = "visible" } - if (props.fade !== false) { - body.style.opacity = "0" - body.style.filter = "blur(2px)" - } mountFrame = requestAnimationFrame(() => { mountFrame = undefined - if (props.fade !== false && body) { - fadeAnim?.stop() - fadeAnim = animate(body, { opacity: 1, filter: "blur(0px)" }, FADE_SPRING) - fadeAnim.finished.then(() => { - if (!body) return - body.style.opacity = "" - body.style.filter = "" - }) - } + fadeBodyIn() if (grow()) setHeight() }) } @@ -178,10 +189,7 @@ export function GrowBox(props: GrowBoxProps) { if (value === undefined) return if (!root || !body) return if (!animateToggle()) { - root.style.height = value ? "" : "0px" - root.style.overflow = value ? "" : "hidden" - body.style.opacity = value || props.fade === false ? "" : "0" - body.style.filter = value || props.fade === false ? "" : "blur(2px)" + setInstant(value) return } fadeAnim?.stop() @@ -200,16 +208,7 @@ export function GrowBox(props: GrowBoxProps) { height.set(0) return } - if (props.fade !== false) { - body.style.opacity = "0" - body.style.filter = "blur(2px)" - fadeAnim = animate(body, { opacity: 1, filter: "blur(0px)" }, FADE_SPRING) - fadeAnim.finished.then(() => { - if (!body || !open()) return - body.style.opacity = "" - body.style.filter = "" - }) - } + fadeBodyIn() setHeight() }, { defer: true }, diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index b30ce446c9f..c451fa09ae1 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -573,9 +573,14 @@ function isContextGroupTool(part: PartType): part is ToolPart { } function contextToolSummary(parts: ToolPart[]) { - const read = parts.filter((part) => part.tool === "read").length - const search = parts.filter((part) => part.tool === "glob" || part.tool === "grep").length - const list = parts.filter((part) => part.tool === "list").length + 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 } } @@ -1377,7 +1382,7 @@ ToolRegistry.register({ } @@ -1408,7 +1413,7 @@ ToolRegistry.register({ From 7e5477c7c46afb88c1dc19d4c574ed75c176dffd Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 3 Mar 2026 13:28:45 -0500 Subject: [PATCH 37/76] fix(ui): close tool-output css block for app build --- packages/ui/src/components/message-part.css | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 083b29abb15..6182efe5da9 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -400,6 +400,7 @@ overflow: visible; } } +} [data-component="bash-output"] { width: 100%; @@ -649,8 +650,6 @@ padding-left: 12px; } - - [data-component="diagnostics"] { display: flex; flex-direction: column; From 24bcc96511aa695fd41780ca57deabcaa9e5ebfe Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 3 Mar 2026 14:21:18 -0500 Subject: [PATCH 38/76] fix(ui): unify turn row heights and remove inter-part gaps Standardize all turn rows (tool calls, thinking, diff summary) to 36px height with zero gaps between them. Previously rows were 32px with varying gaps (2px/6px/8px/12px) causing inconsistent spacing and a margin-top pop when the thinking indicator's gap changed reactively. - Collapsible trigger: 32px -> 36px - Thinking box: min-height 32px -> height 36px, gap removed (was 12px) - Diff summary trigger: min-height 24px -> height 36px, gap removed - Part gaps: removed all inter-part padding-top (was 0/2/6px) - User message GrowBox: removed 8px gap - Thread working prop to text parts for copy button visibility - Wrap copy button in animated GrowBox with open/close transitions - Simplify thinking show/hide by removing delay timer and using entry gate to prevent animation before live signal - Batch optimistic message updates to prevent animate race condition --- .../app/src/components/prompt-input/submit.ts | 13 ++- .../src/pages/session/message-timeline.tsx | 29 +++--- packages/ui/src/components/collapsible.css | 2 +- packages/ui/src/components/message-part.css | 1 - packages/ui/src/components/message-part.tsx | 58 +++++++----- packages/ui/src/components/session-turn.css | 6 +- packages/ui/src/components/session-turn.tsx | 89 ++++++++----------- 7 files changed, 97 insertions(+), 101 deletions(-) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index a8c2609f437..4e0273bfcdc 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -2,7 +2,7 @@ import type { Message } from "@opencode-ai/sdk/v2/client" import { showToast } from "@opencode-ai/ui/toast" import { base64Encode } from "@opencode-ai/util/encode" import { useNavigate, useParams } from "@solidjs/router" -import type { Accessor } from "solid-js" +import { batch, type Accessor } from "solid-js" import type { FileSelection } from "@/context/file" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" @@ -332,9 +332,14 @@ export function createPromptSubmit(input: PromptSubmitInput) { messageID, }) - removeCommentItems(commentItems) - clearInput() - addOptimisticMessage() + batch(() => { + removeCommentItems(commentItems) + clearInput() + if (sessionDirectory === projectDirectory) { + sync.set("session_status", session.id, { type: "busy" }) + } + addOptimisticMessage() + }) const waitForWorktree = async () => { const worktree = WorktreeState.get(sessionDirectory) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 8edd8c4fae7..7574aa23c82 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -44,7 +44,6 @@ type MessageComment = { } const emptyMessages: MessageType[] = [] -const idle = { type: "idle" as const } const messageComments = (parts: Part[]): MessageComment[] => parts.flatMap((part) => { @@ -276,28 +275,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(() => { diff --git a/packages/ui/src/components/collapsible.css b/packages/ui/src/components/collapsible.css index af572ae8f2d..7542e10c0d5 100644 --- a/packages/ui/src/components/collapsible.css +++ b/packages/ui/src/components/collapsible.css @@ -15,7 +15,7 @@ [data-slot="collapsible-trigger"] { width: 100%; display: flex; - height: 32px; + height: 36px; padding: 0; align-items: center; align-self: stretch; diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 6182efe5da9..f494abe3fdd 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -230,7 +230,6 @@ pointer-events: none; transition: opacity 0.15s ease; will-change: opacity; - [data-component="tooltip-trigger"] { display: inline-flex; width: fit-content; diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index c451fa09ae1..2df37d43058 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -112,6 +112,7 @@ export interface MessagePartProps { turnDiffSummary?: () => JSX.Element turnDurationMs?: number animate?: boolean + working?: boolean } export type PartComponent = Component @@ -527,7 +528,7 @@ export function AssistantParts(props: { )} @@ -733,6 +735,7 @@ export function AssistantMessageDisplay(props: { showTurnDiffSummary={props.showTurnDiffSummary} turnDiffSummary={props.turnDiffSummary} hideDetails={entry().context} + working={props.working} /> @@ -892,7 +895,7 @@ export function UserMessageDisplay(props: { } return ( - +
0}> @@ -1034,6 +1037,7 @@ export function Part(props: MessagePartProps) { turnDiffSummary={props.turnDiffSummary} turnDurationMs={props.turnDurationMs} animate={props.animate} + working={props.working} /> ) @@ -1262,6 +1266,12 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { return props.turnDiffSummary }) + const showCopy = createMemo(() => { + if (props.message.role !== "assistant") return true + if (props.working) return false + return props.showAssistantCopyPartID === part().id + }) + const handleCopy = async () => { const content = displayText() if (!content) return @@ -1283,27 +1293,29 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { )} -
- - e.preventDefault()} - onClick={handleCopy} - aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} - /> - - - - {meta()} - - -
+ +
+ + e.preventDefault()} + onClick={handleCopy} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} + /> + + + + {meta()} + + +
+
) diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index d05baac298f..333a117b5df 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -63,8 +63,8 @@ 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; @@ -146,7 +146,7 @@ [data-component="session-turn-diffs-trigger"] { width: 100%; - min-height: 24px; + height: 36px; display: flex; align-items: center; justify-content: flex-start; diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index a7331234a1c..f63203181c3 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -8,6 +8,7 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path" import { createEffect, createMemo, createSignal, For, on, onCleanup, ParentProps, Show } from "solid-js" import { Dynamic } from "solid-js/web" import { animate, type AnimationPlaybackControls, FADE_SPRING, HEIGHT_SPRING } from "./motion" +import { GrowBox } from "./grow-box" import { AssistantParts, Message, Part, PART_MAPPING } from "./message-part" import { Card } from "./card" import { Accordion } from "./accordion" @@ -20,8 +21,7 @@ import { TextReveal } from "./text-reveal" import { SessionRetry } from "./session-retry" import { createAutoScroll } from "../hooks" import { useI18n } from "../context/i18n" -const THINKING_GAP_PX = 12 -const THINKING_HIDE_DELAY_MS = 220 +const THINKING_GAP_PX = 0 function record(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value) @@ -333,11 +333,7 @@ export function SessionTurn( }) const showReasoningSummaries = createMemo(() => props.showReasoningSummaries ?? true) const showDiffSummary = createMemo(() => edited() > 0 && !working()) - - const assistantCopyPartID = createMemo(() => { - if (working()) return null - return showAssistantCopyPartID() ?? null - }) + const assistantCopyPartID = createMemo(() => showAssistantCopyPartID() ?? null) const turnDurationMs = createMemo(() => { const start = message()?.time.created if (typeof start !== "number") return undefined @@ -385,15 +381,14 @@ export function SessionTurn( return true }) const hasAssistant = createMemo(() => assistantMessages().length > 0) - const [shown, setShown] = createSignal(showThinking()) - let hideTimer: ReturnType | undefined - const thinking = createMemo(() => shown()) + const thinking = createMemo(() => showThinking()) const lane = createMemo(() => hasAssistant() || thinking()) const animateEnabled = createMemo(() => props.animate !== false) const [live, setLive] = createSignal(false) + let liveFrame: number | undefined const entry = createMemo(() => live()) - const initialThinking = thinking() + const initialThinking = thinking() && !animateEnabled() let thinkingRef: HTMLDivElement | undefined let thinkingBodyRef: HTMLDivElement | undefined let thinkingAnim: AnimationPlaybackControls | undefined @@ -409,11 +404,7 @@ export function SessionTurn( cancelAnimationFrame(liveFrame) liveFrame = undefined } - if (!enabled) { - setLive(false) - return - } - if (!isWorking || live()) return + if (!enabled || !isWorking || live()) return liveFrame = requestAnimationFrame(() => { liveFrame = undefined setLive(true) @@ -422,12 +413,6 @@ export function SessionTurn( ), ) - const stopHide = () => { - if (!hideTimer) return - clearTimeout(hideTimer) - hideTimer = undefined - } - const showBox = () => { if (!thinkingRef || !thinkingBodyRef) return thinkingAnim?.stop() @@ -523,36 +508,17 @@ export function SessionTurn( createEffect( on( - showThinking, - (value) => { - stopHide() - if (value) { - if (!shown()) setShown(true) - return - } - hideTimer = setTimeout(() => { - hideTimer = undefined - if (showThinking()) return - if (!shown()) return - setShown(false) - }, THINKING_HIDE_DELAY_MS) - }, - { defer: true }, - ), - ) - - createEffect( - on( - thinking, - (value) => { + () => [thinking(), entry()] as const, + ([value, entered]) => { if (thinkingToggleFrame !== undefined) { cancelAnimationFrame(thinkingToggleFrame) thinkingToggleFrame = undefined } if (value) { + if (!entered) return thinkingToggleFrame = requestAnimationFrame(() => { thinkingToggleFrame = undefined - if (!thinking()) return + if (!thinking() || !entry()) return showBox() }) return @@ -570,7 +536,6 @@ export function SessionTurn( }) onCleanup(() => { - stopHide() if (liveFrame !== undefined) cancelAnimationFrame(liveFrame) if (thinkingToggleFrame !== undefined) cancelAnimationFrame(thinkingToggleFrame) thinkingAnim?.stop() @@ -672,6 +637,18 @@ export function SessionTurn(
) + const divider = (label: string) => ( +
+
+ + + {label} + + +
+
+ ) + return (
{(part) => ( -
- -
+ +
+ +
+
)}
@@ -750,8 +729,18 @@ export function SessionTurn(
+ + {divider(i18n.t("ui.message.interrupted"))} + - {turnDiffSummary()} + + {turnDiffSummary()} + {errorText()} From 7ebf1c3707296781627874b06ff916ac0f6560b5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 3 Mar 2026 14:23:30 -0500 Subject: [PATCH 39/76] fix(ui): keep interrupted copy row left-aligned --- packages/ui/src/components/message-part.css | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index f494abe3fdd..41a52c00726 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -241,8 +241,6 @@ } [data-slot="text-part-copy-wrapper"][data-interrupted] { - width: 100%; - justify-content: flex-end; gap: 12px; } From 82a29ba9b9efe2ea662918c5ffb2c6e8a9d00061 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 3 Mar 2026 14:42:06 -0500 Subject: [PATCH 40/76] fix(ui): add top padding to text parts after tool calls Text/reasoning parts get 8px top padding inside the GrowBox body so it animates with height. Tool/context parts stay at 0. --- packages/ui/src/components/message-part.tsx | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 2df37d43058..069d97f25f2 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -528,7 +528,7 @@ export function AssistantParts(props: { { - const items = [stamp(), props.interrupted ? i18n.t("ui.message.interrupted") : ""] + const items = [stamp()] return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0") }) @@ -1247,13 +1247,8 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { const meta = createMemo(() => { if (props.message.role !== "assistant") return "" const agent = (props.message as AssistantMessage).agent - const items = [ - agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", - model(), - duration(), - interrupted() ? i18n.t("ui.message.interrupted") : "", - ] - return items.filter((x) => !!x).join(" \u00B7 ") + const items = [agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", model(), duration()] + return items.filter((x) => !!x).join(" · ") }) const displayText = () => (part().text ?? "").trim() From abe4a2548f50083441716050de8c689fe38b7b19 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 3 Mar 2026 14:47:11 -0500 Subject: [PATCH 41/76] fix(ui): simplify metaTail to return stamp directly No longer wraps a single value in an array with filter+join. --- packages/ui/src/components/message-part.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 069d97f25f2..e7e387269bf 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -877,10 +877,7 @@ export function UserMessageDisplay(props: { return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0") }) - const metaTail = createMemo(() => { - const items = [stamp()] - return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0") - }) + const metaTail = createMemo(() => stamp()) const openImagePreview = (url: string, alt?: string) => { dialog.show(() => ) From 4ef8ae9ddbf10917c26db202ff010a689cb27bf0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 3 Mar 2026 16:15:58 -0500 Subject: [PATCH 42/76] chore(ui): clean up tool-call icon experiment --- packages/ui/src/components/basic-tool.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index db4d71d4e9a..f50e54982f4 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -1,4 +1,16 @@ -import { createEffect, createSignal, For, Match, on, onCleanup, onMount, Show, splitProps, Switch, type JSX } from "solid-js" +import { + createEffect, + createSignal, + For, + Match, + on, + onCleanup, + onMount, + Show, + splitProps, + Switch, + type JSX, +} from "solid-js" import { animate, type AnimationPlaybackControls, From bb8b850b5f1fb7f2e8ee4133dea67d5c55fa9bb4 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 3 Mar 2026 17:13:24 -0500 Subject: [PATCH 43/76] fix(ui): animate immediate tool subtitles on first reveal --- packages/ui/src/components/message-part.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index e7e387269bf..48f24b44cec 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1474,13 +1474,11 @@ const TOOL_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%)" function useToolReveal(pending: () => boolean, animate?: () => boolean) { - const [live, setLive] = createSignal(pending()) - + const enabled = () => animate?.() ?? true + const [live, setLive] = createSignal(pending() || enabled()) createEffect(() => { if (pending()) setLive(true) }) - - const enabled = () => animate?.() ?? true return () => enabled() && live() } @@ -1643,7 +1641,9 @@ function ToolTriggerRow(props: { ) const detail = createMemo(() => [props.subtitle, ...(props.args ?? [])].filter((x): x is string => !!x).join(" ")) const detailAnimate = createMemo(() => { - if (props.revealOnMount) return props.animate !== false + if (props.animate === false) return false + if (props.revealOnMount) return true + if (!props.pending && !reveal()) return true return reveal() }) From bffda3716ec84438f767b00d149f7562ab033c9e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 3 Mar 2026 18:09:12 -0500 Subject: [PATCH 44/76] fix(session): stop gracefully after deleted session writes --- packages/opencode/src/session/processor.ts | 12 +++++++++++- packages/opencode/src/session/prompt.ts | 10 ++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 67edc0ecfe3..2c83e434bb7 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -15,11 +15,17 @@ import { Config } from "@/config/config" import { SessionCompaction } from "./compaction" import { PermissionNext } from "@/permission/next" import { Question } from "@/question" - +import { NotFoundError } from "@/storage/db" export namespace SessionProcessor { const DOOM_LOOP_THRESHOLD = 3 const log = Log.create({ service: "session.processor" }) + const stale = (error: unknown) => { + if (NotFoundError.isInstance(error)) return true + if (error instanceof Error && error.message.includes("FOREIGN KEY constraint failed")) return true + return false + } + export type Info = Awaited> export type Result = Awaited> @@ -351,6 +357,10 @@ export namespace SessionProcessor { if (needsCompaction) break } } catch (e: any) { + if (stale(e)) { + SessionStatus.set(input.sessionID, { type: "idle" }) + return "stop" + } log.error("process", { error: e, stack: JSON.stringify(e.stack), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 4f77920cc98..d5ac4425f12 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -37,8 +37,9 @@ import { SessionSummary } from "./summary" import { NamedError } from "@opencode-ai/util/error" import { fn } from "@/util/fn" import { SessionProcessor } from "./processor" -import { TaskTool } from "@/tool/task" +import { NotFoundError } from "@/storage/db" import { Tool } from "@/tool/tool" +import { TaskTool } from "@/tool/task" import { PermissionNext } from "@/permission/next" import { SessionStatus } from "./status" import { LLM } from "./llm" @@ -1955,7 +1956,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the if (!cleaned) return const title = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned - return Session.setTitle({ sessionID: input.session.id, title }) + try { + return Session.setTitle({ sessionID: input.session.id, title }) + } catch (error) { + if (NotFoundError.isInstance(error)) return + throw error + } } } } From 0461efa8ab9164a381bfe1623d485028d39d3001 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 3 Mar 2026 18:38:08 -0500 Subject: [PATCH 45/76] fix(session): stabilize e2e teardown races --- .../session/composer/session-todo-dock.tsx | 1 + packages/opencode/src/session/processor.ts | 66 +++++++++++-------- packages/opencode/src/session/prompt.ts | 10 ++- packages/opencode/src/session/summary.ts | 34 ++++++---- 4 files changed, 70 insertions(+), 41 deletions(-) diff --git a/packages/app/src/pages/session/composer/session-todo-dock.tsx b/packages/app/src/pages/session/composer/session-todo-dock.tsx index da2b8c8da17..d27254a2104 100644 --- a/packages/app/src/pages/session/composer/session-todo-dock.tsx +++ b/packages/app/src/pages/session/composer/session-todo-dock.tsx @@ -197,6 +197,7 @@ export function SessionTodoDock(props: { visibility: off() ? "hidden" : "visible", opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`, filter: `blur(${Math.max(0, Math.min(1, hide())) * 2}px)`, + visibility: hide() > 0.98 ? "hidden" : "visible", }} > diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 2c83e434bb7..8a71f00a9d7 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -394,39 +394,47 @@ export namespace SessionProcessor { SessionStatus.set(input.sessionID, { type: "idle" }) } } - if (snapshot) { - const patch = await Snapshot.patch(snapshot) - if (patch.files.length) { - await Session.updatePart({ - id: Identifier.ascending("part"), - messageID: input.assistantMessage.id, - sessionID: input.sessionID, - type: "patch", - hash: patch.hash, - files: patch.files, - }) + try { + if (snapshot) { + const patch = await Snapshot.patch(snapshot) + if (patch.files.length) { + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: input.assistantMessage.id, + sessionID: input.sessionID, + type: "patch", + hash: patch.hash, + files: patch.files, + }) + } + snapshot = undefined } - snapshot = undefined - } - const p = await MessageV2.parts(input.assistantMessage.id) - for (const part of p) { - if (part.type === "tool" && part.state.status !== "completed" && part.state.status !== "error") { - await Session.updatePart({ - ...part, - state: { - ...part.state, - status: "error", - error: "Tool execution aborted", - time: { - start: Date.now(), - end: Date.now(), + const p = await MessageV2.parts(input.assistantMessage.id) + for (const part of p) { + if (part.type === "tool" && part.state.status !== "completed" && part.state.status !== "error") { + await Session.updatePart({ + ...part, + state: { + ...part.state, + status: "error", + error: "Tool execution aborted", + time: { + start: Date.now(), + end: Date.now(), + }, }, - }, - }) + }) + } + } + input.assistantMessage.time.completed = Date.now() + await Session.updateMessage(input.assistantMessage) + } catch (e) { + if (stale(e)) { + SessionStatus.set(input.sessionID, { type: "idle" }) + return "stop" } + throw e } - input.assistantMessage.time.completed = Date.now() - await Session.updateMessage(input.assistantMessage) if (needsCompaction) return "compact" if (blocked) return "stop" if (input.assistantMessage.error) return "stop" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index d5ac4425f12..f5003fb9f15 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -292,6 +292,7 @@ export namespace SessionPrompt { let step = 0 const session = await Session.get(sessionID) + let reply: MessageV2.Assistant | undefined while (true) { SessionStatus.set(sessionID, { type: "busy" }) log.info("loop", { step, sessionID }) @@ -595,6 +596,7 @@ export namespace SessionPrompt { model, abort, }) + reply = processor.message using _ = defer(() => InstructionPrompt.clear(processor.message.id)) // Check if user explicitly invoked an agent via @ in this turn @@ -723,6 +725,12 @@ export namespace SessionPrompt { } return item } + if (reply) { + return { + info: reply, + parts: await MessageV2.parts(reply.id).catch(() => []), + } + } throw new Error("Impossible") }) @@ -1957,7 +1965,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const title = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned try { - return Session.setTitle({ sessionID: input.session.id, title }) + return await Session.setTitle({ sessionID: input.session.id, title }) } catch (error) { if (NotFoundError.isInstance(error)) return throw error diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 349336ba788..37256fdfa26 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -7,6 +7,7 @@ import { Identifier } from "@/id/id" import { Snapshot } from "@/snapshot" import { Storage } from "@/storage/storage" +import { NotFoundError } from "@/storage/db" import { Bus } from "@/bus" export namespace SessionSummary { @@ -82,14 +83,19 @@ export namespace SessionSummary { async function summarizeSession(input: { sessionID: string; messages: MessageV2.WithParts[] }) { const diffs = await computeDiff({ messages: input.messages }) - await Session.setSummary({ - sessionID: input.sessionID, - summary: { - additions: diffs.reduce((sum, x) => sum + x.additions, 0), - deletions: diffs.reduce((sum, x) => sum + x.deletions, 0), - files: diffs.length, - }, - }) + try { + await Session.setSummary({ + sessionID: input.sessionID, + summary: { + additions: diffs.reduce((sum, x) => sum + x.additions, 0), + deletions: diffs.reduce((sum, x) => sum + x.deletions, 0), + files: diffs.length, + }, + }) + } catch (error) { + if (NotFoundError.isInstance(error)) return + throw error + } await Storage.write(["session_diff", input.sessionID], diffs) Bus.publish(Session.Event.Diff, { sessionID: input.sessionID, @@ -101,14 +107,20 @@ export namespace SessionSummary { const messages = input.messages.filter( (m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID), ) - const msgWithParts = messages.find((m) => m.info.id === input.messageID)! - const userMsg = msgWithParts.info as MessageV2.User + const msgWithParts = messages.find((m) => m.info.id === input.messageID) + if (!msgWithParts || msgWithParts.info.role !== "user") return + const userMsg = msgWithParts.info const diffs = await computeDiff({ messages }) userMsg.summary = { ...userMsg.summary, diffs, } - await Session.updateMessage(userMsg) + try { + await Session.updateMessage(userMsg) + } catch (error) { + if (NotFoundError.isInstance(error)) return + throw error + } } export const diff = fn( From 9939e735bb07e49f3a949c0b0341c389eafe9d13 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 3 Mar 2026 19:27:52 -0500 Subject: [PATCH 46/76] fix(ui): restore queued message feature and clean up dead code Restore the queued message indicator that was accidentally dropped during the animation refactor, now with GrowBox spring animation. Fix three bugs: queued label only shows on actually-queued messages (not all after interrupt), new user messages animate height in via staging.ready() gate, and thinking indicator stays visible on the active turn when a queued message follows. Also remove dead code: unused imports, console.debug calls, orphaned CSS, unreachable guards, and unexported internal constants. --- .../src/pages/session/message-gesture.test.ts | 48 ++----------------- .../app/src/pages/session/message-gesture.ts | 13 +---- .../src/pages/session/message-timeline.tsx | 21 ++++---- .../pages/session/use-session-hash-scroll.ts | 2 +- .../ui/src/components/bash-tool.stories.tsx | 14 +----- packages/ui/src/components/basic-tool.css | 29 ----------- packages/ui/src/components/basic-tool.tsx | 9 ++-- packages/ui/src/components/collapsible.css | 24 ---------- packages/ui/src/components/message-part.css | 10 ---- packages/ui/src/components/message-part.tsx | 18 +++---- packages/ui/src/components/motion.tsx | 8 ++-- packages/ui/src/components/scroll-view.css | 12 ----- packages/ui/src/components/scroll-view.tsx | 8 ++-- .../session-timeline-simulator.stories.tsx | 2 +- packages/ui/src/components/session-turn.css | 1 - packages/ui/src/components/session-turn.tsx | 10 ++-- packages/ui/src/hooks/create-auto-scroll.tsx | 5 -- 17 files changed, 47 insertions(+), 187 deletions(-) diff --git a/packages/app/src/pages/session/message-gesture.test.ts b/packages/app/src/pages/session/message-gesture.test.ts index 65525cfe8d1..62758b1d896 100644 --- a/packages/app/src/pages/session/message-gesture.test.ts +++ b/packages/app/src/pages/session/message-gesture.test.ts @@ -23,81 +23,39 @@ describe("shouldMarkBoundaryGesture", () => { scrollTop: 0, scrollHeight: 300, clientHeight: 300, - mode: "normal", }), ).toBe(true) }) - test("marks when scrolling beyond top boundary in reversed mode", () => { - // column-reverse: scrollTop=-590 means 10px from top (max=600) - expect( - shouldMarkBoundaryGesture({ - delta: -40, - scrollTop: -590, - scrollHeight: 1000, - clientHeight: 400, - mode: "reversed", - }), - ).toBe(true) - }) - - test("marks when scrolling beyond bottom boundary in reversed mode", () => { - // column-reverse: scrollTop=-20 means 20px from bottom - expect( - shouldMarkBoundaryGesture({ - delta: 50, - scrollTop: -20, - scrollHeight: 1000, - clientHeight: 400, - mode: "reversed", - }), - ).toBe(true) - }) - - test("does not mark when reversed scroller can consume movement", () => { - expect( - shouldMarkBoundaryGesture({ - delta: 20, - scrollTop: -400, - scrollHeight: 1000, - clientHeight: 400, - mode: "reversed", - }), - ).toBe(false) - }) - - test("marks when scrolling beyond top boundary in normal mode", () => { + test("marks when scrolling beyond top boundary", () => { expect( shouldMarkBoundaryGesture({ delta: -40, scrollTop: 10, scrollHeight: 1000, clientHeight: 400, - mode: "normal", }), ).toBe(true) }) - test("marks when scrolling beyond bottom boundary in normal mode", () => { + test("marks when scrolling beyond bottom boundary", () => { expect( shouldMarkBoundaryGesture({ delta: 50, scrollTop: 580, scrollHeight: 1000, clientHeight: 400, - mode: "normal", }), ).toBe(true) }) - test("does not mark when normal scroller can consume movement", () => { + test("does not mark when scroller can consume movement", () => { expect( shouldMarkBoundaryGesture({ delta: 20, scrollTop: 300, scrollHeight: 1000, clientHeight: 400, - mode: "normal", }), ).toBe(false) }) diff --git a/packages/app/src/pages/session/message-gesture.ts b/packages/app/src/pages/session/message-gesture.ts index 996fd6c4528..cd29b5392d5 100644 --- a/packages/app/src/pages/session/message-gesture.ts +++ b/packages/app/src/pages/session/message-gesture.ts @@ -9,22 +9,13 @@ export const shouldMarkBoundaryGesture = (input: { scrollTop: number scrollHeight: number clientHeight: number - mode?: "reversed" | "normal" }) => { const max = input.scrollHeight - input.clientHeight if (max <= 1) return true if (!input.delta) return false - const mode = input.mode ?? "reversed" - if (mode === "normal") { - const top = Math.max(0, Math.min(max, input.scrollTop)) - if (input.delta < 0) return -input.delta > top - const bottom = max - top - return input.delta > bottom - } - - const top = max + Math.max(-max, Math.min(0, input.scrollTop)) + const top = Math.max(0, Math.min(max, input.scrollTop)) if (input.delta < 0) return -input.delta > top - const bottom = -Math.max(-max, Math.min(0, input.scrollTop)) + const bottom = max - top return input.delta > bottom } diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 7574aa23c82..934ff42a752 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -72,12 +72,6 @@ const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => { return nested } -const boundaryMode = (root: HTMLDivElement, target: HTMLElement) => { - if (target === root) return "reversed" as const - if (target.dataset.scrollDirection === "reversed") return "reversed" as const - return "normal" as const -} - const markBoundaryGesture = (input: { root: HTMLDivElement target: EventTarget | null @@ -95,7 +89,6 @@ const markBoundaryGesture = (input: { scrollTop: target.scrollTop, scrollHeight: target.scrollHeight, clientHeight: target.clientHeight, - mode: boundaryMode(input.root, target), }) ) { input.onMarkScrollGesture(input.root) @@ -731,6 +724,16 @@ export function MessageTimeline(props: { {(messageID) => { + // Capture at creation time: animate only messages added after the + // timeline finishes its initial backfill staging. + const isNew = staging.ready() + const active = createMemo(() => activeMessageID() === messageID) + const queued = createMemo(() => { + if (active()) return false + const activeID = activeMessageID() + if (activeID) return messageID > activeID + return false + }) const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? [])) const commentCount = createMemo(() => comments().length) return ( @@ -785,7 +788,9 @@ export function MessageTimeline(props: { void setActiveMessage: (message: UserMessage | undefined) => void setTurnStart: (value: number) => void - autoScroll: { pause: () => void; forceScrollToBottom: () => void; snapToBottom: () => void } + autoScroll: { pause: () => void; snapToBottom: () => void } scroller: () => HTMLDivElement | undefined anchor: (id: string) => string scheduleScrollState: (el: HTMLDivElement) => void diff --git a/packages/ui/src/components/bash-tool.stories.tsx b/packages/ui/src/components/bash-tool.stories.tsx index a59ae2def5d..354ebe30eca 100644 --- a/packages/ui/src/components/bash-tool.stories.tsx +++ b/packages/ui/src/components/bash-tool.stories.tsx @@ -1,5 +1,5 @@ // @ts-nocheck -import { createSignal, createMemo, createEffect, on, onCleanup, batch, For } from "solid-js" +import { createSignal, createMemo, createEffect, on, onCleanup, batch } from "solid-js" import { createStore, produce } from "solid-js/store" import type { Message, @@ -187,15 +187,12 @@ function createPlayback(events: TimelineEvent[]) { }) function applyEvent(event: TimelineEvent) { - console.debug("[bash-story] apply", event.type, event.type === "delay" ? event.label : "") switch (event.type) { case "status": - console.debug("[bash-story] status →", event.status.type) setData("session_status", SESSION_ID, event.status) break case "message": - console.debug("[bash-story] message", event.message.role, event.message.id, "completed:", !!event.message.time?.completed) setData( produce((d) => { if (!d.message[SESSION_ID]) d.message[SESSION_ID] = [] @@ -211,7 +208,6 @@ function createPlayback(events: TimelineEvent[]) { break case "part": - console.debug("[bash-story] part", event.part.type, event.part.type === "tool" ? event.part.tool : "", event.part.id) setData( produce((d) => { const mid = event.part.messageID @@ -225,7 +221,6 @@ function createPlayback(events: TimelineEvent[]) { const patch = event.patch const status = patch?.state?.status const hasOutput = !!patch?.state?.output - console.debug("[bash-story] part-update", event.partID, "status:", status, "hasOutput:", hasOutput) setData( produce((d) => { const list = d.part[event.messageID] @@ -241,7 +236,6 @@ function createPlayback(events: TimelineEvent[]) { } function resetStore() { - console.debug("[bash-story] resetStore") setData({ session: [], session_status: {}, @@ -252,7 +246,6 @@ function createPlayback(events: TimelineEvent[]) { } function replayTo(target: number) { - console.debug("[bash-story] replayTo", target) resetStore() batch(() => { for (let i = 0; i < target && i < events.length; i++) { @@ -265,7 +258,6 @@ function createPlayback(events: TimelineEvent[]) { createEffect( on(step, (target) => { - console.debug("[bash-story] step changed:", appliedStep, "→", target) if (target > appliedStep) { batch(() => { for (let i = appliedStep; i < target && i < events.length; i++) { @@ -284,7 +276,6 @@ function createPlayback(events: TimelineEvent[]) { // Skip delay events when stepping manually while (next < totalSteps && events[next]?.type === "delay") next++ const clamped = Math.min(next, totalSteps) - console.debug("[bash-story] stepForward →", clamped) setStep(clamped) } @@ -292,12 +283,10 @@ function createPlayback(events: TimelineEvent[]) { let next = step() - 1 while (next > 0 && events[next - 1]?.type === "delay") next-- const clamped = Math.max(next, 0) - console.debug("[bash-story] stepBack →", clamped) setStep(clamped) } const reset = () => { - console.debug("[bash-story] reset") setStep(0) appliedStep = 0 resetStore() @@ -305,7 +294,6 @@ function createPlayback(events: TimelineEvent[]) { const jumpTo = (s: number) => { const clamped = Math.max(0, Math.min(s, totalSteps)) - console.debug("[bash-story] jumpTo", clamped) setStep(clamped) } diff --git a/packages/ui/src/components/basic-tool.css b/packages/ui/src/components/basic-tool.css index 7fd01cc8e21..c7a951e074f 100644 --- a/packages/ui/src/components/basic-tool.css +++ b/packages/ui/src/components/basic-tool.css @@ -16,35 +16,6 @@ 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; } diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index f50e54982f4..f60c1442c01 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -19,7 +19,6 @@ import { COLLAPSIBLE_CONTENT_HEIGHT_SPRING, } from "./motion" import { Collapsible } from "./collapsible" -import type { IconProps } from "./icon" import { TextShimmer } from "./text-shimmer" export type TriggerTitle = { @@ -39,11 +38,10 @@ const isTriggerTitle = (val: any): val is TriggerTitle => { } interface ToolCallPanelBaseProps { - icon: IconProps["name"] + icon: string trigger: TriggerTitle | JSX.Element children?: JSX.Element status?: string - debugID?: string animate?: boolean hideDetails?: boolean defaultOpen?: boolean @@ -52,7 +50,6 @@ interface ToolCallPanelBaseProps { locked?: boolean watchDetails?: boolean animated?: boolean - animateIn?: boolean onSubtitleClick?: () => void } @@ -175,7 +172,7 @@ function ToolCallPanel(props: ToolCallPanelBaseProps) { let fadeAnim: AnimationPlaybackControls | undefined let observer: ResizeObserver | undefined let resizeFrame: number | undefined - const initialOpen = props.animateIn ? false : open() + const initialOpen = open() const heightSpring = springValue(0, COLLAPSIBLE_CONTENT_HEIGHT_SPRING) const read = () => Math.max(0, Math.ceil(bodyRef?.getBoundingClientRect().height ?? 0)) @@ -316,7 +313,7 @@ function ToolCallPanel(props: ToolCallPanelBaseProps) { export interface ToolCallRowProps { variant: "row" - icon: IconProps["name"] + icon: string trigger: TriggerTitle | JSX.Element status?: string animate?: boolean diff --git a/packages/ui/src/components/collapsible.css b/packages/ui/src/components/collapsible.css index 7542e10c0d5..bf410683dc5 100644 --- a/packages/ui/src/components/collapsible.css +++ b/packages/ui/src/components/collapsible.css @@ -50,9 +50,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); @@ -103,9 +100,6 @@ border: none; padding: 0; - /* &:hover { */ - /* color: var(--text-strong); */ - /* } */ &:focus-visible { outline: none; background-color: var(--surface-raised-base-hover); @@ -122,21 +116,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; - } -} diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 41a52c00726..f11e83ffaff 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -196,13 +196,6 @@ pointer-events: auto; } - .text-text-strong { - color: var(--text-strong); - } - - .font-medium { - font-weight: var(--font-weight-medium); - } } [data-component="text-part"] { @@ -636,9 +629,6 @@ min-width: 0; } - [data-slot="collapsible-arrow"] { - color: var(--icon-weaker); - } } [data-component="context-tool-step"] { diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 48f24b44cec..b4bbd06c3c3 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -5,12 +5,10 @@ import { createSignal, For, Match, - on, onMount, Show, Switch, onCleanup, - Index, type JSX, } from "solid-js" import stripAnsi from "strip-ansi" @@ -98,6 +96,7 @@ export interface MessageProps { showAssistantCopyPartID?: string | null interrupted?: boolean animate?: boolean + queued?: boolean working?: boolean showReasoningSummaries?: boolean } @@ -349,7 +348,6 @@ function PartGrow(props: { children: JSX.Element animate?: boolean animateToggle?: boolean - debugID?: string gap?: number fade?: boolean grow?: boolean @@ -527,7 +525,6 @@ export function AssistantParts(props: { return ( )} @@ -823,6 +821,7 @@ export function UserMessageDisplay(props: { parts: PartType[] interrupted?: boolean animate?: boolean + queued?: boolean }) { const data = useData() const dialog = useDialog() @@ -902,6 +901,7 @@ export function UserMessageDisplay(props: {
{ if (file.mime.startsWith("image/") && file.url) { openImagePreview(file.url, file.filename) @@ -930,9 +930,14 @@ export function UserMessageDisplay(props: { <>
-
+
+ +
+ +
+
@@ -1119,8 +1124,6 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre PART_MAPPING["tool"] = function ToolPartDisplay(props) { const i18n = useI18n() const part = props.part as ToolPart - if (part.tool === "todowrite" || part.tool === "todoread") return null - const hideQuestion = createMemo(() => part.tool === "question" && busy(part.state.status)) const emptyInput: Record = {} @@ -1858,7 +1861,6 @@ ToolRegistry.register({ animate animated defaultOpen={false} - debugID={`bash:${props.callID ?? props.partID ?? "unknown"}`} trigger={
diff --git a/packages/ui/src/components/motion.tsx b/packages/ui/src/components/motion.tsx index 369af40b064..e083f44bd90 100644 --- a/packages/ui/src/components/motion.tsx +++ b/packages/ui/src/components/motion.tsx @@ -1,10 +1,10 @@ export { animate, springValue } from "motion" export type { AnimationPlaybackControls } from "motion" -export const HEIGHT_DURATION = 0.5 -export const FADE_DURATION = 0.5 -export const COLLAPSIBLE_CONTENT_HEIGHT_DURATION = 0.3 -export const COLLAPSIBLE_CONTENT_FADE_DURATION = COLLAPSIBLE_CONTENT_HEIGHT_DURATION +const HEIGHT_DURATION = 0.5 +const FADE_DURATION = 0.5 +const COLLAPSIBLE_CONTENT_HEIGHT_DURATION = 0.3 +const COLLAPSIBLE_CONTENT_FADE_DURATION = COLLAPSIBLE_CONTENT_HEIGHT_DURATION export const HEIGHT_SPRING = { type: "spring" as const, diff --git a/packages/ui/src/components/scroll-view.css b/packages/ui/src/components/scroll-view.css index 0759ce20ad3..a01298f77d5 100644 --- a/packages/ui/src/components/scroll-view.css +++ b/packages/ui/src/components/scroll-view.css @@ -48,18 +48,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; } diff --git a/packages/ui/src/components/scroll-view.tsx b/packages/ui/src/components/scroll-view.tsx index 58017238f77..539c265c06f 100644 --- a/packages/ui/src/components/scroll-view.tsx +++ b/packages/ui/src/components/scroll-view.tsx @@ -1,17 +1,15 @@ -import { createSignal, onCleanup, onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js" +import { createSignal, onCleanup, onMount, splitProps, type ComponentProps, Show } from "solid-js" import { useI18n } from "../context/i18n" export interface ScrollViewProps extends ComponentProps<"div"> { viewportRef?: (el: HTMLDivElement) => void - orientation?: "vertical" | "horizontal" // currently only vertical is fully implemented for thumb } 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"], [ "onScroll", "onWheel", diff --git a/packages/ui/src/components/session-timeline-simulator.stories.tsx b/packages/ui/src/components/session-timeline-simulator.stories.tsx index f5248c96a53..aea37409980 100644 --- a/packages/ui/src/components/session-timeline-simulator.stories.tsx +++ b/packages/ui/src/components/session-timeline-simulator.stories.tsx @@ -1,5 +1,5 @@ // @ts-nocheck -import { createSignal, createMemo, createEffect, on, onCleanup, batch, For, Show } from "solid-js" +import { createSignal, createMemo, createEffect, on, onCleanup, batch, For } from "solid-js" import { createStore, produce } from "solid-js/store" import type { Message, diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index 333a117b5df..1c8f5c27564 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -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; diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index f63203181c3..1d2c36ef3d7 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -21,8 +21,6 @@ import { TextReveal } from "./text-reveal" import { SessionRetry } from "./session-retry" import { createAutoScroll } from "../hooks" import { useI18n } from "../context/i18n" -const THINKING_GAP_PX = 0 - function record(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value) } @@ -327,6 +325,9 @@ export function SessionTurn( if (status().type === "idle") return false const msg = message() if (!msg) return false + // When active is explicitly provided, use it directly — the parent + // already computed which turn is active, so we don't need to self-detect. + if (typeof props.active === "boolean") return props.active const item = pending() if (item) return item.parentID === msg.id return latestUserID() === msg.id @@ -394,7 +395,7 @@ export function SessionTurn( let thinkingAnim: AnimationPlaybackControls | undefined let thinkingHeightAnim: AnimationPlaybackControls | undefined let thinkingToggleFrame: number | undefined - const gap = () => (hasAssistant() ? `${THINKING_GAP_PX}px` : "0px") + const gap = () => "0px" createEffect( on( @@ -672,6 +673,7 @@ export function SessionTurn( parts={parts()} interrupted={interrupted()} animate={props.animate} + queued={queued()} working={working()} />
@@ -710,7 +712,7 @@ export function SessionTurn( data-slot="session-turn-thinking-wrap" style={{ height: initialThinking ? "auto" : "0px", - "margin-top": initialThinking && hasAssistant() ? `${THINKING_GAP_PX}px` : "0px", + "margin-top": "0px", overflow: initialThinking ? "visible" : "hidden", }} > diff --git a/packages/ui/src/hooks/create-auto-scroll.tsx b/packages/ui/src/hooks/create-auto-scroll.tsx index 7faf5a66218..a54c4712bcf 100644 --- a/packages/ui/src/hooks/create-auto-scroll.tsx +++ b/packages/ui/src/hooks/create-auto-scroll.tsx @@ -238,11 +238,6 @@ export function createAutoScroll(options: AutoScrollOptions) { handleScroll, handleInteraction, pause: stop, - resume: () => { - if (store.userScrolled) setStore("userScrolled", false) - scrollToBottom(true) - }, - scrollToBottom: () => scrollToBottom(false), forceScrollToBottom: () => scrollToBottom(true), smoothScrollToBottom, snapToBottom: () => { From 2c39a7fcb3f5fbee36d0c761681508ee75a926f9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 3 Mar 2026 19:49:36 -0500 Subject: [PATCH 47/76] refactor(ui): remove self-detection from SessionTurn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SessionTurn no longer scans the message list to figure out if it's active or queued — it just reads the props (defaulting to false). Deletes pending(), pendingUser(), latestUserID(), and all the typeof guards that switched between explicit and self-detect modes. --- packages/ui/src/components/session-turn.tsx | 53 ++------------------- 1 file changed, 4 insertions(+), 49 deletions(-) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 1d2c36ef3d7..e8f17542774 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -193,41 +193,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 @@ -315,22 +282,10 @@ export function SessionTurn( }) const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle) - const latestUserID = createMemo(() => { - const messages = allMessages() ?? emptyMessages - const latest = messages.findLast((item) => item.role === "user") - if (!latest || latest.role !== "user") return undefined - return latest.id - }) const working = createMemo(() => { if (status().type === "idle") return false - const msg = message() - if (!msg) return false - // When active is explicitly provided, use it directly — the parent - // already computed which turn is active, so we don't need to self-detect. - if (typeof props.active === "boolean") return props.active - const item = pending() - if (item) return item.parentID === msg.id - return latestUserID() === msg.id + if (!message()) return false + return active() }) const showReasoningSummaries = createMemo(() => props.showReasoningSummaries ?? true) const showDiffSummary = createMemo(() => edited() > 0 && !working()) From 986a6b19e031c6b49e4989a23513318703c20263 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 3 Mar 2026 19:55:14 -0500 Subject: [PATCH 48/76] chore(ui): remove trivial indirection and dead code - Hoist empty array constants and idle sentinel to module scope - Merge showAssistantCopyPartID into assistantCopyPartID (return null) - Remove thinking() pass-through alias for showThinking() - Remove entry() memo wrapping live() signal - Inline gap() constant ("0px") at all callsites - Remove unused rootRef in ScrollView --- packages/ui/src/components/scroll-view.tsx | 2 -- packages/ui/src/components/session-turn.tsx | 34 +++++++++------------ 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/packages/ui/src/components/scroll-view.tsx b/packages/ui/src/components/scroll-view.tsx index 539c265c06f..c24a5e3be38 100644 --- a/packages/ui/src/components/scroll-view.tsx +++ b/packages/ui/src/components/scroll-view.tsx @@ -23,7 +23,6 @@ export function ScrollView(props: ScrollViewProps) { ], ) - let rootRef!: HTMLDivElement let viewportRef!: HTMLDivElement let thumbRef!: HTMLDivElement @@ -169,7 +168,6 @@ export function ScrollView(props: ScrollViewProps) { return (
setIsHovered(true)} diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index e8f17542774..551fb7b314f 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -86,6 +86,10 @@ function list(value: T[] | undefined | null, fallback: T[]) { } const hidden = new Set(["todowrite", "todoread"]) +const emptyMessages: MessageType[] = [] +const emptyAssistant: AssistantMessage[] = [] +const emptyDiffs: FileDiff[] = [] +const idle: SessionStatus = { type: "idle" as const } function partState(part: PartType, showReasoningSummaries: boolean) { if (part.type === "tool") { @@ -161,11 +165,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)) @@ -257,7 +257,7 @@ export function SessionTurn( const error = createMemo( () => assistantMessages().find((m) => m.error && m.error.name !== "MessageAbortedError")?.error, ) - const showAssistantCopyPartID = createMemo(() => { + const assistantCopyPartID = createMemo(() => { const messages = assistantMessages() for (let i = messages.length - 1; i >= 0; i--) { @@ -272,7 +272,7 @@ export function SessionTurn( } } - return undefined + return null }) const errorText = createMemo(() => { const msg = error()?.data?.message @@ -289,7 +289,6 @@ export function SessionTurn( }) const showReasoningSummaries = createMemo(() => props.showReasoningSummaries ?? true) const showDiffSummary = createMemo(() => edited() > 0 && !working()) - const assistantCopyPartID = createMemo(() => showAssistantCopyPartID() ?? null) const turnDurationMs = createMemo(() => { const start = message()?.time.created if (typeof start !== "number") return undefined @@ -329,7 +328,7 @@ 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 @@ -337,20 +336,17 @@ export function SessionTurn( return true }) const hasAssistant = createMemo(() => assistantMessages().length > 0) - const thinking = createMemo(() => showThinking()) const lane = createMemo(() => hasAssistant() || thinking()) const animateEnabled = createMemo(() => props.animate !== false) const [live, setLive] = createSignal(false) let liveFrame: number | undefined - const entry = createMemo(() => live()) const initialThinking = thinking() && !animateEnabled() let thinkingRef: HTMLDivElement | undefined let thinkingBodyRef: HTMLDivElement | undefined let thinkingAnim: AnimationPlaybackControls | undefined let thinkingHeightAnim: AnimationPlaybackControls | undefined let thinkingToggleFrame: number | undefined - const gap = () => "0px" createEffect( on( @@ -375,10 +371,10 @@ export function SessionTurn( thinkingHeightAnim?.stop() const next = Math.max(1, thinkingBodyRef.getBoundingClientRect().height) const prev = Math.max(0, thinkingRef.getBoundingClientRect().height) - if (!entry()) { + if (!live()) { thinkingRef.style.overflow = "visible" thinkingRef.style.height = "auto" - thinkingRef.style.marginTop = gap() + thinkingRef.style.marginTop = "0px" thinkingBodyRef.style.opacity = "1" thinkingBodyRef.style.filter = "blur(0px)" thinkingBodyRef.style.transform = "" @@ -388,12 +384,12 @@ export function SessionTurn( thinkingRef.style.willChange = "height" thinkingRef.style.contain = "layout style" thinkingRef.style.height = `${prev}px` - thinkingRef.style.marginTop = prev > 0 ? gap() : "0px" + thinkingRef.style.marginTop = "0px" thinkingHeightAnim = animate( thinkingRef, { height: `${next}px`, - marginTop: gap(), + marginTop: "0px", }, HEIGHT_SPRING, ) @@ -402,7 +398,7 @@ export function SessionTurn( thinkingRef.style.willChange = "" thinkingRef.style.contain = "" thinkingRef.style.height = "auto" - thinkingRef.style.marginTop = gap() + thinkingRef.style.marginTop = "0px" thinkingRef.style.overflow = "visible" }) thinkingBodyRef.style.opacity = "0" @@ -422,7 +418,7 @@ export function SessionTurn( if (!thinkingRef || !thinkingBodyRef) return thinkingAnim?.stop() thinkingHeightAnim?.stop() - if (!entry()) { + if (!live()) { thinkingRef.style.height = "0px" thinkingRef.style.marginTop = "0px" thinkingRef.style.overflow = "hidden" @@ -464,7 +460,7 @@ export function SessionTurn( createEffect( on( - () => [thinking(), entry()] as const, + () => [thinking(), live()] as const, ([value, entered]) => { if (thinkingToggleFrame !== undefined) { cancelAnimationFrame(thinkingToggleFrame) @@ -474,7 +470,7 @@ export function SessionTurn( if (!entered) return thinkingToggleFrame = requestAnimationFrame(() => { thinkingToggleFrame = undefined - if (!thinking() || !entry()) return + if (!thinking() || !live()) return showBox() }) return From c0fd605d73f53d8806c4d5e495079b87c1162147 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 3 Mar 2026 19:59:26 -0500 Subject: [PATCH 49/76] =?UTF-8?q?refactor(ui):=20rename=20animated=20?= =?UTF-8?q?=E2=86=92=20springContent=20in=20ToolCallPanel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clarifies the confusing animate/animated prop pair. `animate` controls whether animations are globally enabled (false during backfill). `springContent` declares that this tool panel wants spring-animated height transitions for its collapsible content. --- packages/ui/src/components/basic-tool.tsx | 14 +++++++------- packages/ui/src/components/collapsible.css | 2 +- packages/ui/src/components/message-part.tsx | 6 +++--- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index f60c1442c01..1f1e04b3870 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -49,7 +49,7 @@ interface ToolCallPanelBaseProps { defer?: boolean locked?: boolean watchDetails?: boolean - animated?: boolean + springContent?: boolean onSubtitleClick?: () => void } @@ -148,7 +148,7 @@ function ToolCallPanel(props: ToolCallPanelBaseProps) { on( open, (value) => { - if (!props.defer || props.animated) return + if (!props.defer || props.springContent) return if (!value) { cancel() setReady(false) @@ -215,7 +215,7 @@ function ToolCallPanel(props: ToolCallPanelBaseProps) { } onMount(() => { - if (!props.animated || props.animate === false || !contentRef || !bodyRef) return + if (!props.springContent || props.animate === false || !contentRef || !bodyRef) return const offChange = heightSpring.on("change", (v) => { if (!contentRef) return @@ -253,7 +253,7 @@ function ToolCallPanel(props: ToolCallPanelBaseProps) { on( open, (isOpen) => { - if (!props.animated || props.animate === false || !contentRef) return + if (!props.springContent || props.animate === false || !contentRef) return if (isOpen) doOpen() else doClose() }, @@ -284,11 +284,11 @@ function ToolCallPanel(props: ToolCallPanelBaseProps) { arrow={!!props.children && !props.hideDetails && !props.locked && !pending()} /> - +
- +
{props.children}
diff --git a/packages/ui/src/components/collapsible.css b/packages/ui/src/components/collapsible.css index bf410683dc5..230e2902d18 100644 --- a/packages/ui/src/components/collapsible.css +++ b/packages/ui/src/components/collapsible.css @@ -86,7 +86,7 @@ } /* JS-animated content: overflow managed by animate() */ - &[data-animated] { + &[data-spring-content] { overflow: hidden; } } diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index b4bbd06c3c3..abae41efbf1 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1859,7 +1859,7 @@ ToolRegistry.register({ {...props} icon="console" animate - animated + springContent defaultOpen={false} trigger={
@@ -1916,7 +1916,7 @@ ToolRegistry.register({ variant="panel" {...props} icon="code-lines" - animated + springContent trigger={
@@ -1987,7 +1987,7 @@ ToolRegistry.register({ variant="panel" {...props} icon="code-lines" - animated + springContent trigger={
From 2dcb54c5f2cc9f6df3faca11763f93de4788bf97 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 3 Mar 2026 20:48:25 -0500 Subject: [PATCH 50/76] refactor(ui): replace manual thinking animation with GrowBox Replace ~90 lines of manual animate()/springValue thinking indicator code with a GrowBox wrapper. Both the thinking collapse and copy row growth now use the same springValue mechanism with identical HEIGHT_SPRING config, eliminating the vertical bounce when the session completes (both springs produce the same normalized progress curve). Also fix animation breaking when navigating away from a streaming session and back: the active turn now always receives animate=true via `animate={isNew || active()}` so the live() gate can activate regardless of backfill staging state. --- .../src/pages/session/message-timeline.tsx | 300 +++++++++++------- packages/ui/src/components/session-turn.tsx | 149 +-------- 2 files changed, 190 insertions(+), 259 deletions(-) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 934ff42a752..6bb9c461676 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -21,6 +21,7 @@ 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 { TextReveal } from "@opencode-ai/ui/text-reveal" 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" @@ -45,6 +46,9 @@ type MessageComment = { const emptyMessages: MessageType[] = [] +const isDefaultSessionTitle = (title?: string) => + !!title && /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title) + const messageComments = (parts: Part[]): MessageComment[] => parts.flatMap((part) => { if (part.type !== "text" || !(part as TextPart).synthetic) return [] @@ -289,9 +293,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, @@ -308,7 +322,37 @@ export function MessageTimeline(props: { menuOpen: false, pendingRename: false, }) + const [headerLive, setHeaderLive] = createSignal(false) + let headerFrame: number | undefined let titleRef: HTMLInputElement | undefined + createEffect( + on( + () => [sessionKey(), showHeader()] as const, + ([, show]) => { + if (headerFrame !== undefined) cancelAnimationFrame(headerFrame) + setHeaderLive(false) + if (!show) return + headerFrame = requestAnimationFrame(() => { + headerFrame = undefined + setHeaderLive(true) + }) + }, + ), + ) + onCleanup(() => { + if (headerFrame !== undefined) cancelAnimationFrame(headerFrame) + }) + const headerStyle = createMemo(() => ({ + opacity: headerLive() ? "1" : "0", + filter: headerLive() ? "blur(0px)" : "blur(3px)", + transform: headerLive() ? "translateY(0)" : "translateY(-3px)", + transition: + "opacity 450ms cubic-bezier(0.34, 1, 0.64, 1), filter 450ms cubic-bezier(0.34, 1, 0.64, 1), transform 450ms cubic-bezier(0.34, 1, 0.64, 1)", + })) + const headerTitleStyle = createMemo(() => ({ + opacity: headerLive() ? "1" : "0", + transition: "opacity 220ms cubic-bezier(0.34, 1, 0.64, 1)", + })) const errorMessage = (err: unknown) => { if (err && typeof err === "object" && "data" in err) { @@ -529,6 +573,132 @@ export function MessageTimeline(props: {
+ +
+
+
+
+ +
+ +
+
+ + + + + } + > + { + 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} + /> + + +
+ + {(id) => ( +
+ + setTitle("menuOpen", open)} + > + + + { + if (!title.pendingRename) return + event.preventDefault() + setTitle("pendingRename", false) + openTitleEditor() + }} + > + { + setTitle("pendingRename", true) + setTitle("menuOpen", false) + }} + > + {language.t("common.rename")} + + void archiveSession(id())}> + {language.t("common.archive")} + + + dialog.show(() => )} + > + {language.t("common.delete")} + + + + +
+ )} +
+
+
+
+
{ @@ -582,120 +752,6 @@ export function MessageTimeline(props: { }} >
- -
-
-
- - - - - - {titleValue()} - - } - > - { - 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} - /> - - -
- - {(id) => ( -
- - setTitle("menuOpen", open)} - > - - - { - if (!title.pendingRename) return - event.preventDefault() - setTitle("pendingRename", false) - openTitleEditor() - }} - > - { - setTitle("pendingRename", true) - setTitle("menuOpen", false) - }} - > - {language.t("common.rename")} - - void archiveSession(id())}> - {language.t("common.archive")} - - - dialog.show(() => )} - > - {language.t("common.delete")} - - - - -
- )} -
-
-
-
-
{(messageID) => { // Capture at creation time: animate only messages added after the - // timeline finishes its initial backfill staging. - const isNew = staging.ready() + // 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 @@ -790,7 +852,7 @@ export function MessageTimeline(props: { messageID={messageID} active={active()} queued={queued()} - animate={isNew} + animate={isNew || active()} showReasoningSummaries={settings.general.showReasoningSummaries()} shellToolDefaultOpen={settings.general.shellToolPartsExpanded()} editToolDefaultOpen={settings.general.editToolPartsExpanded()} diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 551fb7b314f..18b06068da4 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -7,7 +7,6 @@ import { Binary } from "@opencode-ai/util/binary" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { createEffect, createMemo, createSignal, For, on, onCleanup, ParentProps, Show } from "solid-js" import { Dynamic } from "solid-js/web" -import { animate, type AnimationPlaybackControls, FADE_SPRING, HEIGHT_SPRING } from "./motion" import { GrowBox } from "./grow-box" import { AssistantParts, Message, Part, PART_MAPPING } from "./message-part" import { Card } from "./card" @@ -339,14 +338,9 @@ export function SessionTurn( const lane = createMemo(() => hasAssistant() || thinking()) const animateEnabled = createMemo(() => props.animate !== false) const [live, setLive] = createSignal(false) + const thinkingOpen = createMemo(() => thinking() && (live() || !animateEnabled())) let liveFrame: number | undefined - const initialThinking = thinking() && !animateEnabled() - let thinkingRef: HTMLDivElement | undefined - let thinkingBodyRef: HTMLDivElement | undefined - let thinkingAnim: AnimationPlaybackControls | undefined - let thinkingHeightAnim: AnimationPlaybackControls | undefined - let thinkingToggleFrame: number | undefined createEffect( on( @@ -365,122 +359,6 @@ export function SessionTurn( ), ) - const showBox = () => { - if (!thinkingRef || !thinkingBodyRef) return - thinkingAnim?.stop() - thinkingHeightAnim?.stop() - const next = Math.max(1, thinkingBodyRef.getBoundingClientRect().height) - const prev = Math.max(0, thinkingRef.getBoundingClientRect().height) - if (!live()) { - thinkingRef.style.overflow = "visible" - thinkingRef.style.height = "auto" - thinkingRef.style.marginTop = "0px" - thinkingBodyRef.style.opacity = "1" - thinkingBodyRef.style.filter = "blur(0px)" - thinkingBodyRef.style.transform = "" - return - } - thinkingRef.style.overflow = "hidden" - thinkingRef.style.willChange = "height" - thinkingRef.style.contain = "layout style" - thinkingRef.style.height = `${prev}px` - thinkingRef.style.marginTop = "0px" - thinkingHeightAnim = animate( - thinkingRef, - { - height: `${next}px`, - marginTop: "0px", - }, - HEIGHT_SPRING, - ) - thinkingHeightAnim.finished.then(() => { - if (!thinkingRef || !thinking()) return - thinkingRef.style.willChange = "" - thinkingRef.style.contain = "" - thinkingRef.style.height = "auto" - thinkingRef.style.marginTop = "0px" - thinkingRef.style.overflow = "visible" - }) - thinkingBodyRef.style.opacity = "0" - thinkingBodyRef.style.filter = "blur(2px)" - thinkingBodyRef.style.transform = "" - thinkingAnim = animate( - thinkingBodyRef, - { - opacity: 1, - filter: "blur(0px)", - }, - FADE_SPRING, - ) - } - - const hideBox = () => { - if (!thinkingRef || !thinkingBodyRef) return - thinkingAnim?.stop() - thinkingHeightAnim?.stop() - if (!live()) { - thinkingRef.style.height = "0px" - thinkingRef.style.marginTop = "0px" - thinkingRef.style.overflow = "hidden" - thinkingBodyRef.style.opacity = "0" - thinkingBodyRef.style.filter = "blur(2px)" - thinkingBodyRef.style.transform = "" - return - } - thinkingRef.style.overflow = "hidden" - thinkingRef.style.willChange = "height" - thinkingRef.style.contain = "layout style" - const h = Math.max(1, thinkingRef.getBoundingClientRect().height) - thinkingRef.style.height = `${h}px` - thinkingHeightAnim = animate( - thinkingRef, - { - height: "0px", - marginTop: "0px", - }, - HEIGHT_SPRING, - ) - thinkingAnim = animate( - thinkingBodyRef, - { - opacity: 0, - filter: "blur(2px)", - }, - FADE_SPRING, - ) - thinkingHeightAnim.finished.then(() => { - if (!thinkingRef || thinking()) return - thinkingRef.style.willChange = "" - thinkingRef.style.contain = "" - thinkingRef.style.height = "0px" - thinkingRef.style.marginTop = "0px" - thinkingRef.style.overflow = "hidden" - }) - } - - createEffect( - on( - () => [thinking(), live()] as const, - ([value, entered]) => { - if (thinkingToggleFrame !== undefined) { - cancelAnimationFrame(thinkingToggleFrame) - thinkingToggleFrame = undefined - } - if (value) { - if (!entered) return - thinkingToggleFrame = requestAnimationFrame(() => { - thinkingToggleFrame = undefined - if (!thinking() || !live()) return - showBox() - }) - return - } - hideBox() - }, - { defer: true }, - ), - ) - const autoScroll = createAutoScroll({ working, onUserInteracted: props.onUserInteracted, @@ -489,9 +367,6 @@ export function SessionTurn( onCleanup(() => { if (liveFrame !== undefined) cancelAnimationFrame(liveFrame) - if (thinkingToggleFrame !== undefined) cancelAnimationFrame(thinkingToggleFrame) - thinkingAnim?.stop() - thinkingHeightAnim?.stop() }) const turnDiffSummary = () => ( @@ -658,20 +533,14 @@ export function SessionTurn( />
-
-
+
-
+
{divider(i18n.t("ui.message.interrupted"))} From 8efadf98586a52e5a55ae95c37ce51671865cddf Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 3 Mar 2026 21:18:42 -0500 Subject: [PATCH 51/76] chore(ui): slow down tool-count animation durations Width/transform transitions bumped to 800ms, opacity/filter to 400ms across count summary, suffix, and status title components. Odometer digit roll duration increased to 800ms. --- packages/ui/src/components/animated-number.css | 4 ++-- packages/ui/src/components/animated-number.tsx | 2 +- packages/ui/src/components/tool-count-label.css | 2 +- packages/ui/src/components/tool-count-summary.css | 12 ++++++------ packages/ui/src/components/tool-status-title.css | 4 ++-- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/ui/src/components/animated-number.css b/packages/ui/src/components/animated-number.css index 022b347e968..22374aca746 100644 --- a/packages/ui/src/components/animated-number.css +++ b/packages/ui/src/components/animated-number.css @@ -13,7 +13,7 @@ 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)); + 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"] { @@ -46,7 +46,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)); } diff --git a/packages/ui/src/components/animated-number.tsx b/packages/ui/src/components/animated-number.tsx index b5fceba2563..eead67169c6 100644 --- a/packages/ui/src/components/animated-number.tsx +++ b/packages/ui/src/components/animated-number.tsx @@ -1,7 +1,7 @@ import { For, Index, createEffect, createMemo, createSignal, on } from "solid-js" const TRACK = Array.from({ length: 30 }, (_, index) => index % 10) -const DURATION = 600 +const DURATION = 800 function normalize(value: number) { return ((value % 10) + 10) % 10 diff --git a/packages/ui/src/components/tool-count-label.css b/packages/ui/src/components/tool-count-label.css index 11a33ff5d14..8c53cc31a9e 100644 --- a/packages/ui/src/components/tool-count-label.css +++ b/packages/ui/src/components/tool-count-label.css @@ -30,7 +30,7 @@ overflow: hidden; 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)); diff --git a/packages/ui/src/components/tool-count-summary.css b/packages/ui/src/components/tool-count-summary.css index da8455267cc..12ae3c1f682 100644 --- a/packages/ui/src/components/tool-count-summary.css +++ b/packages/ui/src/components/tool-count-summary.css @@ -14,8 +14,8 @@ 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)); @@ -39,8 +39,8 @@ 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)); @@ -79,8 +79,8 @@ 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; } diff --git a/packages/ui/src/components/tool-status-title.css b/packages/ui/src/components/tool-status-title.css index 8978105c2f1..a6bbcaa1159 100644 --- a/packages/ui/src/components/tool-status-title.css +++ b/packages/ui/src/components/tool-status-title.css @@ -30,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; } From c2796fa8035e7bff16526936631236da4ae149a9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 3 Mar 2026 21:38:15 -0500 Subject: [PATCH 52/76] perf(ui): use overflow: clip instead of hidden, drop stale will-change overflow: clip avoids creating a scroll container and block formatting context, eliminating compositor clipping bugs and stacking context overhead. Also removes unused transform from will-change on the thinking element and drops text-overflow: ellipsis in favor of clip. --- packages/ui/src/components/animated-number.css | 4 ++-- packages/ui/src/components/basic-tool.css | 8 +++----- packages/ui/src/components/collapsible.css | 4 ++-- packages/ui/src/components/grow-box.tsx | 18 +++++++++--------- packages/ui/src/components/session-turn.css | 13 +++++-------- packages/ui/src/components/text-reveal.css | 5 ++--- .../ui/src/components/tool-count-label.css | 4 ++-- .../ui/src/components/tool-count-summary.css | 10 +++++----- .../ui/src/components/tool-status-title.css | 2 +- 9 files changed, 31 insertions(+), 37 deletions(-) diff --git a/packages/ui/src/components/animated-number.css b/packages/ui/src/components/animated-number.css index 22374aca746..a64862769ab 100644 --- a/packages/ui/src/components/animated-number.css +++ b/packages/ui/src/components/animated-number.css @@ -12,7 +12,7 @@ justify-content: flex-end; line-height: inherit; width: var(--animated-number-width, 1ch); - overflow: hidden; + overflow: clip; transition: width var(--tool-motion-spring-ms, 800ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); } @@ -21,7 +21,7 @@ width: 1ch; height: 1em; line-height: 1em; - overflow: hidden; + overflow: clip; vertical-align: baseline; -webkit-mask-image: linear-gradient( to bottom, diff --git a/packages/ui/src/components/basic-tool.css b/packages/ui/src/components/basic-tool.css index c7a951e074f..f255b6bc43f 100644 --- a/packages/ui/src/components/basic-tool.css +++ b/packages/ui/src/components/basic-tool.css @@ -42,7 +42,7 @@ align-items: center; gap: 8px; min-width: 0; - overflow: hidden; + overflow: clip; } [data-slot="basic-tool-tool-title"] { @@ -70,8 +70,7 @@ 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; @@ -115,8 +114,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; diff --git a/packages/ui/src/components/collapsible.css b/packages/ui/src/components/collapsible.css index 230e2902d18..2a572881a53 100644 --- a/packages/ui/src/components/collapsible.css +++ b/packages/ui/src/components/collapsible.css @@ -79,7 +79,7 @@ } [data-slot="collapsible-content"] { - overflow: hidden; + overflow: clip; &[data-expanded] { overflow: visible; @@ -87,7 +87,7 @@ /* JS-animated content: overflow managed by animate() */ &[data-spring-content] { - overflow: hidden; + overflow: clip; } } diff --git a/packages/ui/src/components/grow-box.tsx b/packages/ui/src/components/grow-box.tsx index fb70765adb1..678780e9482 100644 --- a/packages/ui/src/components/grow-box.tsx +++ b/packages/ui/src/components/grow-box.tsx @@ -70,7 +70,7 @@ export function GrowBox(props: GrowBoxProps) { const setInstant = (visible: boolean) => { root!.style.height = visible ? "" : "0px" - root!.style.overflow = visible ? "" : "hidden" + root!.style.overflow = visible ? "" : "clip" if (visible || props.fade === false) clearBody() else hideBody() } @@ -96,11 +96,11 @@ export function GrowBox(props: GrowBoxProps) { springTarget = next if (props.autoHeight === false || watch()) { root.style.height = `${next}px` - root.style.overflow = next > 0 ? "visible" : "hidden" + root.style.overflow = next > 0 ? "visible" : "clip" } return } - root.style.overflow = "hidden" + root.style.overflow = "clip" springTarget = next height.set(next) } @@ -114,7 +114,7 @@ export function GrowBox(props: GrowBoxProps) { }) const offStart = height.on("animationStart", () => { if (!root) return - root.style.overflow = "hidden" + root.style.overflow = "clip" root.style.willChange = "height" root.style.contain = "layout style" }) @@ -125,14 +125,14 @@ export function GrowBox(props: GrowBoxProps) { if (!open()) { springTarget = 0 root.style.height = "0px" - root.style.overflow = "hidden" + 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" : "hidden" + root.style.overflow = next > 0 ? "visible" : "clip" return } root.style.height = "auto" @@ -154,11 +154,11 @@ export function GrowBox(props: GrowBoxProps) { if (!open()) { root.style.height = "0px" - root.style.overflow = "hidden" + root.style.overflow = "clip" } else { if (grow()) { root.style.height = "0px" - root.style.overflow = "hidden" + root.style.overflow = "clip" } else { root.style.height = "auto" root.style.overflow = "visible" @@ -203,7 +203,7 @@ export function GrowBox(props: GrowBoxProps) { if (props.fade !== false) { fadeAnim = animate(body, { opacity: 0, filter: "blur(2px)" }, FADE_SPRING) } - root.style.overflow = "hidden" + root.style.overflow = "clip" springTarget = 0 height.set(0) return diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index 1c8f5c27564..b128e2f10fd 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -84,14 +84,13 @@ [data-slot="session-turn-thinking"] { position: relative; - will-change: opacity, filter, transform; + will-change: opacity, filter; } [data-component="text-reveal"].session-turn-thinking-heading { flex: 1 1 auto; min-width: 0; - overflow: hidden; - text-overflow: ellipsis; + overflow: clip; white-space: nowrap; line-height: inherit; color: var(--text-weaker); @@ -210,7 +209,7 @@ display: flex; min-width: 0; align-items: baseline; - overflow: hidden; + overflow: clip; white-space: nowrap; font-family: var(--font-family-sans); @@ -222,8 +221,7 @@ flex: 1 1 auto; color: var(--text-weak); min-width: 0; - overflow: hidden; - text-overflow: ellipsis; + overflow: clip; white-space: nowrap; direction: rtl; unicode-bidi: plaintext; @@ -234,8 +232,7 @@ flex-shrink: 0; max-width: 100%; min-width: 0; - overflow: hidden; - text-overflow: ellipsis; + overflow: clip; white-space: nowrap; color: var(--text-strong); font-weight: var(--font-weight-medium); diff --git a/packages/ui/src/components/text-reveal.css b/packages/ui/src/components/text-reveal.css index a9036f8dafa..881ff5de9ff 100644 --- a/packages/ui/src/components/text-reveal.css +++ b/packages/ui/src/components/text-reveal.css @@ -120,15 +120,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; } } diff --git a/packages/ui/src/components/tool-count-label.css b/packages/ui/src/components/tool-count-label.css index 8c53cc31a9e..4ed46e50b5f 100644 --- a/packages/ui/src/components/tool-count-label.css +++ b/packages/ui/src/components/tool-count-label.css @@ -27,7 +27,7 @@ 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: 800ms, 400ms, 400ms, 800ms; @@ -45,7 +45,7 @@ [data-slot="tool-count-label-suffix-inner"] { min-width: 0; - overflow: hidden; + overflow: clip; white-space: pre; } } diff --git a/packages/ui/src/components/tool-count-summary.css b/packages/ui/src/components/tool-count-summary.css index 12ae3c1f682..a57ceb482af 100644 --- a/packages/ui/src/components/tool-count-summary.css +++ b/packages/ui/src/components/tool-count-summary.css @@ -10,7 +10,7 @@ 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: @@ -35,7 +35,7 @@ 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: @@ -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,7 +75,7 @@ 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: diff --git a/packages/ui/src/components/tool-status-title.css b/packages/ui/src/components/tool-status-title.css index a6bbcaa1159..050f5e390a6 100644 --- a/packages/ui/src/components/tool-status-title.css +++ b/packages/ui/src/components/tool-status-title.css @@ -18,7 +18,7 @@ [data-slot="tool-status-swap"], [data-slot="tool-status-tail"] { display: inline-grid; - overflow: hidden; + overflow: clip; justify-items: start; } From 4c0f6cbc386eb0327aefc3e5034c91b191847277 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 3 Mar 2026 22:25:05 -0500 Subject: [PATCH 53/76] refactor(ui): deduplicate animation utils and shared helpers Extract commonPrefix() and list() into text-utils.ts, centralize WIPE_MASK/FAST_SPRING/clearMaskStyles/clearFadeStyles in motion.tsx, deduplicate fadeInTitle enter block, fix GrowBox null guards, and cancel leaked rAF in ToolCallPanel onMount. --- .../src/pages/session/message-timeline.tsx | 190 +++++++++++++++--- .../pages/session/use-session-hash-scroll.ts | 4 +- packages/ui/src/components/basic-tool.tsx | 6 +- packages/ui/src/components/grow-box.tsx | 10 +- packages/ui/src/components/message-part.tsx | 29 +-- packages/ui/src/components/motion.tsx | 26 +++ packages/ui/src/components/session-turn.tsx | 5 +- packages/ui/src/components/text-reveal.tsx | 101 +++++++++- packages/ui/src/components/text-utils.ts | 17 ++ .../ui/src/components/tool-count-label.tsx | 21 +- .../ui/src/components/tool-status-title.tsx | 21 +- packages/ui/src/hooks/create-auto-scroll.tsx | 5 +- 12 files changed, 330 insertions(+), 105 deletions(-) create mode 100644 packages/ui/src/components/text-utils.ts diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 6bb9c461676..e1b15c0ae8f 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -21,7 +21,7 @@ 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 { TextReveal } from "@opencode-ai/ui/text-reveal" +import { animate, type AnimationPlaybackControls, clearFadeStyles, FAST_SPRING } from "@opencode-ai/ui/motion" 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" @@ -323,24 +323,156 @@ export function MessageTimeline(props: { pendingRename: false, }) const [headerLive, setHeaderLive] = createSignal(false) + const [headerText, setHeaderText] = createStore({ + session: sessionKey(), + value: placeholderTitle() ? undefined : headerTitle(), + prev: undefined as string | undefined, + muted: false, + prevMuted: false, + }) let headerFrame: number | undefined + let titleFrame: number | undefined + let enterAnim: AnimationPlaybackControls | undefined + let leaveAnim: AnimationPlaybackControls | undefined let titleRef: HTMLInputElement | undefined + let enterRef: HTMLSpanElement | undefined + let leaveRef: HTMLSpanElement | undefined + + const clearTitleAnims = () => { + if (titleFrame !== undefined) { + cancelAnimationFrame(titleFrame) + titleFrame = undefined + } + 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 + enterRef.style.opacity = "0" + enterRef.style.filter = "blur(2px)" + enterRef.style.transform = "translateY(-2px)" + titleFrame = requestAnimationFrame(() => { + titleFrame = undefined + if (!enterRef) return + enterAnim = animate(enterRef, { opacity: 1, filter: "blur(0px)", transform: "translateY(0)" }, FAST_SPRING) + enterAnim.finished.then(() => settleTitleEnter()) + }) + } + + const crossfadeTitle = (nextTitle: string, nextMuted: boolean) => { + clearTitleAnims() + + // snapshot old text into leave span before updating store + setHeaderText({ prev: headerText.value, prevMuted: headerText.muted }) + + // show leave span with old text + if (leaveRef) { + leaveRef.style.opacity = "1" + leaveRef.style.filter = "blur(0px)" + leaveRef.style.transform = "translateY(0)" + } + + // update to new text + setHeaderText({ value: nextTitle, muted: nextMuted }) + + // fade out leave span + if (leaveRef) { + leaveAnim = animate( + leaveRef, + { opacity: 0, filter: "blur(2px)", transform: "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( - () => [sessionKey(), showHeader()] as const, - ([, show]) => { - if (headerFrame !== undefined) cancelAnimationFrame(headerFrame) + on(showHeader, (show, prev) => { + if (headerFrame !== undefined) cancelAnimationFrame(headerFrame) + if (!show) { setHeaderLive(false) - if (!show) return - headerFrame = requestAnimationFrame(() => { - headerFrame = undefined - setHeaderLive(true) - }) + return + } + if (prev) { + setHeaderLive(true) + return + } + setHeaderLive(false) + headerFrame = requestAnimationFrame(() => { + headerFrame = undefined + setHeaderLive(true) + }) + }), + ) + + createEffect( + on( + () => [sessionKey(), headerTitle(), placeholderTitle()] as const, + ([nextSession, nextTitle, nextMuted]) => { + // new session — snap immediately + if (nextSession !== headerText.session) { + setHeaderText("session", nextSession) + if (nextTitle && nextMuted) { + fadeInTitle(nextTitle, nextMuted) + } else { + snapTitle(nextTitle, nextMuted) + } + return + } + if (nextTitle === headerText.value && nextMuted === headerText.muted) return + if (!nextTitle) { + snapTitle(undefined, false) + return + } + // first title appearing + if (!headerText.value) { + fadeInTitle(nextTitle, nextMuted) + return + } + // manual rename — snap + if (title.saving || title.editing) { + snapTitle(nextTitle, nextMuted) + return + } + // normal swap — crossfade + crossfadeTitle(nextTitle, nextMuted) }, ), ) onCleanup(() => { if (headerFrame !== undefined) cancelAnimationFrame(headerFrame) + clearTitleAnims() }) const headerStyle = createMemo(() => ({ opacity: headerLive() ? "1" : "0", @@ -349,10 +481,6 @@ export function MessageTimeline(props: { transition: "opacity 450ms cubic-bezier(0.34, 1, 0.64, 1), filter 450ms cubic-bezier(0.34, 1, 0.64, 1), transform 450ms cubic-bezier(0.34, 1, 0.64, 1)", })) - const headerTitleStyle = createMemo(() => ({ - opacity: headerLive() ? "1" : "0", - transition: "opacity 220ms cubic-bezier(0.34, 1, 0.64, 1)", - })) const errorMessage = (err: unknown) => { if (err && typeof err === "object" && "data" in err) { @@ -577,9 +705,9 @@ export function MessageTimeline(props: {
- + - +

+ + + {headerText.value} + + + {headerText.prev} + +

} > diff --git a/packages/app/src/pages/session/use-session-hash-scroll.ts b/packages/app/src/pages/session/use-session-hash-scroll.ts index 5f9b3d5e745..41d840fbb43 100644 --- a/packages/app/src/pages/session/use-session-hash-scroll.ts +++ b/packages/app/src/pages/session/use-session-hash-scroll.ts @@ -45,8 +45,8 @@ 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 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 }) diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index 1f1e04b3870..5136481ac0d 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -243,10 +243,14 @@ function ToolCallPanel(props: ToolCallPanelBaseProps) { contentRef.style.height = `${next}px` return } - requestAnimationFrame(() => { + let mountFrame: number | undefined = requestAnimationFrame(() => { + mountFrame = undefined if (!open()) return doOpen() }) + onCleanup(() => { + if (mountFrame !== undefined) cancelAnimationFrame(mountFrame) + }) }) createEffect( diff --git a/packages/ui/src/components/grow-box.tsx b/packages/ui/src/components/grow-box.tsx index 678780e9482..01cc483693b 100644 --- a/packages/ui/src/components/grow-box.tsx +++ b/packages/ui/src/components/grow-box.tsx @@ -48,13 +48,15 @@ export function GrowBox(props: GrowBoxProps) { const animateToggle = () => props.animateToggle !== false const hideBody = () => { - body!.style.opacity = "0" - body!.style.filter = "blur(2px)" + if (!body) return + body.style.opacity = "0" + body.style.filter = "blur(2px)" } const clearBody = () => { - body!.style.opacity = "" - body!.style.filter = "" + if (!body) return + body.style.opacity = "" + body.style.filter = "" } const fadeBodyIn = () => { diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index abae41efbf1..471898283e4 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -46,10 +46,11 @@ import { checksum } from "@opencode-ai/util/encode" import { Tooltip } from "./tooltip" import { IconButton } from "./icon-button" import { TextShimmer } from "./text-shimmer" +import { list } from "./text-utils" import { AnimatedCountList } from "./tool-count-summary" import { ToolStatusTitle } from "./tool-status-title" import { GrowBox } from "./grow-box" -import { animate, type AnimationPlaybackControls, FADE_SPRING } from "./motion" +import { animate, type AnimationPlaybackControls, clearFadeStyles, clearMaskStyles, FADE_SPRING, WIPE_MASK } from "./motion" interface Diagnostic { range: { @@ -277,10 +278,6 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo { const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"]) const HIDDEN_TOOLS = new Set(["todowrite", "todoread"]) -function list(value: T[] | undefined | null, fallback: T[]) { - if (Array.isArray(value)) return value - return fallback -} function busy(status: string | undefined) { return status === "pending" || status === "running" @@ -1473,8 +1470,6 @@ ToolRegistry.register({ }, }) -const TOOL_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%)" function useToolReveal(pending: () => boolean, animate?: () => boolean) { const enabled = () => animate?.() ?? true @@ -1495,16 +1490,6 @@ function useToolFade( const wipe = options?.wipe ?? false const active = options?.animate !== false - const clearMask = (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 = "" - } onMount(() => { if (!active) return @@ -1524,8 +1509,8 @@ function useToolFade( el.style.transform = wipe ? "translateX(-0.06em)" : "translateY(0.04em)" if (mask) { - el.style.maskImage = TOOL_WIPE_MASK - el.style.webkitMaskImage = TOOL_WIPE_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" @@ -1552,10 +1537,8 @@ function useToolFade( anim?.finished.then(() => { const value = ref() if (!value) return - value.style.opacity = "" - value.style.filter = "" - value.style.transform = "" - if (mask) clearMask(value) + clearFadeStyles(value) + if (mask) clearMaskStyles(value) }) }) }) diff --git a/packages/ui/src/components/motion.tsx b/packages/ui/src/components/motion.tsx index e083f44bd90..e4548afe844 100644 --- a/packages/ui/src/components/motion.tsx +++ b/packages/ui/src/components/motion.tsx @@ -30,8 +30,34 @@ export const COLLAPSIBLE_CONTENT_FADE_SPRING = { bounce: 0, } +export const FAST_SPRING = { + type: "spring" as const, + visualDuration: 0.35, + bounce: 0, +} + export const GLOW_SPRING = { type: "spring" as const, visualDuration: 0.4, bounce: 0.15, } + +export const WIPE_MASK = + "linear-gradient(to right, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 45%, rgba(0,0,0,0) 60%, rgba(0,0,0,0) 100%)" + +export const clearMaskStyles = (el: HTMLElement) => { + el.style.maskImage = "" + el.style.webkitMaskImage = "" + el.style.maskSize = "" + el.style.webkitMaskSize = "" + el.style.maskRepeat = "" + el.style.webkitMaskRepeat = "" + el.style.maskPosition = "" + el.style.webkitMaskPosition = "" +} + +export const clearFadeStyles = (el: HTMLElement) => { + el.style.opacity = "" + el.style.filter = "" + el.style.transform = "" +} diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 18b06068da4..dee887c8390 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -17,6 +17,7 @@ import { DiffChanges } from "./diff-changes" import { Icon } from "./icon" import { TextShimmer } from "./text-shimmer" import { TextReveal } from "./text-reveal" +import { list } from "./text-utils" import { SessionRetry } from "./session-retry" import { createAutoScroll } from "../hooks" import { useI18n } from "../context/i18n" @@ -79,10 +80,6 @@ function same(a: readonly T[], b: readonly T[]) { return a.every((x, i) => x === b[i]) } -function list(value: T[] | undefined | null, fallback: T[]) { - if (Array.isArray(value)) return value - return fallback -} const hidden = new Set(["todowrite", "todoread"]) const emptyMessages: MessageType[] = [] diff --git a/packages/ui/src/components/text-reveal.tsx b/packages/ui/src/components/text-reveal.tsx index f01704365e8..a5c2ca5249a 100644 --- a/packages/ui/src/components/text-reveal.tsx +++ b/packages/ui/src/components/text-reveal.tsx @@ -1,4 +1,5 @@ import { createEffect, createSignal, on, onCleanup, onMount } from "solid-js" +import { animate, type AnimationPlaybackControls, clearFadeStyles, clearMaskStyles, FADE_SPRING, WIPE_MASK } from "./motion" const px = (value: number | string | undefined, fallback: number) => { if (typeof value === "number") return `${value}px` @@ -17,6 +18,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 +45,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,7 +55,6 @@ export function TextReveal(props: { } setWidth(`${next}px`) } - createEffect( on( () => props.text, @@ -60,7 +63,6 @@ export function TextReveal(props: { setSwapping(true) setOld(prev) setCur(next) - if (typeof requestAnimationFrame !== "function") { widen(Math.max(win(), wout())) rootRef?.offsetHeight @@ -128,3 +130,94 @@ export function TextReveal(props: { ) } + +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 (window.matchMedia("(prefers-reduced-motion: reduce)").matches) 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%" }, + { ...FADE_SPRING, delay: props.delay ?? 0 }, + ) + : animate( + node, + { opacity: 1, filter: "blur(0px)", transform: "translateX(0)" }, + { ...FADE_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 ( + + {props.text ?? "\u00A0"} + + ) +} diff --git a/packages/ui/src/components/text-utils.ts b/packages/ui/src/components/text-utils.ts new file mode 100644 index 00000000000..c094b5e65f6 --- /dev/null +++ b/packages/ui/src/components/text-utils.ts @@ -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(value: T[] | undefined | null, fallback: T[]): T[] { + if (Array.isArray(value)) return value + return fallback +} diff --git a/packages/ui/src/components/tool-count-label.tsx b/packages/ui/src/components/tool-count-label.tsx index 67e861cdcb3..c374d2d3762 100644 --- a/packages/ui/src/components/tool-count-label.tsx +++ b/packages/ui/src/components/tool-count-label.tsx @@ -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) diff --git a/packages/ui/src/components/tool-status-title.tsx b/packages/ui/src/components/tool-status-title.tsx index 269179f70bb..47e522838a3 100644 --- a/packages/ui/src/components/tool-status-title.tsx +++ b/packages/ui/src/components/tool-status-title.tsx @@ -1,18 +1,7 @@ import { Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js" import { animate, type AnimationPlaybackControls, HEIGHT_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" function contentWidth(el: HTMLSpanElement | undefined) { if (!el) return 0 @@ -28,13 +17,13 @@ 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 [ready, setReady] = createSignal(false) let activeRef: HTMLSpanElement | undefined diff --git a/packages/ui/src/hooks/create-auto-scroll.tsx b/packages/ui/src/hooks/create-auto-scroll.tsx index a54c4712bcf..94971dc26a1 100644 --- a/packages/ui/src/hooks/create-auto-scroll.tsx +++ b/packages/ui/src/hooks/create-auto-scroll.tsx @@ -2,6 +2,7 @@ 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 @@ -86,9 +87,7 @@ export function createAutoScroll(options: AutoScrollOptions) { } scrollAnim = animate(el.scrollTop, 0, { - type: "spring", - visualDuration: 0.35, - bounce: 0, + ...FAST_SPRING, onUpdate: (v) => { markProgrammatic() el.scrollTop = v From 868fc1829a164a4381e456ab82068e04bbf2222c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 4 Mar 2026 08:46:08 -0500 Subject: [PATCH 54/76] refactor(app): deduplicate spring fade styles and remove redundant code Extract springFade helper in prompt-input to replace 7 duplicated inline style blocks. Remove redundant turn memo in session-todo-dock (identical to value). Remove redundant first queue.refresh() call in global-sync. --- packages/app/src/components/prompt-input.tsx | 63 +++++-------------- packages/app/src/context/global-sync.tsx | 1 - .../session/composer/session-todo-dock.tsx | 14 ++--- 3 files changed, 22 insertions(+), 56 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 89169af0d4a..c83d6807b92 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -261,6 +261,13 @@ export const PromptInput: Component = (props) => { { visualDuration: 0.2, bounce: 0 }, ) + const springFade = (t: number): Record => ({ + opacity: `${t}`, + transform: `scale(${0.95 + t * 0.05})`, + filter: `blur(${(1 - t) * 2}px)`, + "pointer-events": t > 0.5 ? "auto" : "none", + }) + const commentCount = createMemo(() => { if (store.mode === "shell") return 0 return prompt.context.items().filter((item) => !!item.comment?.trim()).length @@ -1257,9 +1264,7 @@ export const PromptInput: Component = (props) => {
0.5 ? "auto" : "none", - }} + style={{ "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none" }} > = (props) => { type="button" variant="ghost" class="size-8 p-0" - style={{ - opacity: buttonsSpring(), - transform: `scale(${0.95 + buttonsSpring() * 0.05})`, - filter: `blur(${(1 - buttonsSpring()) * 2}px)`, - }} + style={springFade(buttonsSpring())} onClick={pick} disabled={store.mode !== "normal"} tabIndex={store.mode === "normal" ? undefined : -1} @@ -1313,11 +1314,7 @@ export const PromptInput: Component = (props) => { icon={working() ? "stop" : "arrow-up"} variant="primary" class="size-8" - style={{ - opacity: buttonsSpring(), - transform: `scale(${0.95 + buttonsSpring() * 0.05})`, - filter: `blur(${(1 - buttonsSpring()) * 2}px)`, - }} + style={springFade(buttonsSpring())} aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")} /> @@ -1373,13 +1370,7 @@ export const PromptInput: Component = (props) => {
{language.t("prompt.mode.shell")}
@@ -1398,13 +1389,7 @@ export const PromptInput: Component = (props) => { onSelect={local.agent.set} class="capitalize max-w-[160px]" valueClass="truncate text-13-regular" - triggerStyle={{ - height: "28px", - opacity: buttonsSpring(), - transform: `scale(${0.95 + buttonsSpring() * 0.05})`, - filter: `blur(${(1 - buttonsSpring()) * 2}px)`, - "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none", - }} + triggerStyle={{ height: "28px", ...springFade(buttonsSpring()) }} variant="ghost" /> @@ -1422,13 +1407,7 @@ export const PromptInput: Component = (props) => { variant="ghost" size="normal" class="min-w-0 max-w-[320px] text-13-regular group" - style={{ - height: "28px", - opacity: buttonsSpring(), - transform: `scale(${0.95 + buttonsSpring() * 0.05})`, - filter: `blur(${(1 - buttonsSpring()) * 2}px)`, - "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none", - }} + style={{ height: "28px", ...springFade(buttonsSpring()) }} onClick={() => dialog.show(() => )} > @@ -1457,13 +1436,7 @@ export const PromptInput: Component = (props) => { triggerProps={{ variant: "ghost", size: "normal", - style: { - height: "28px", - opacity: buttonsSpring(), - transform: `scale(${0.95 + buttonsSpring() * 0.05})`, - filter: `blur(${(1 - buttonsSpring()) * 2}px)`, - "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none", - }, + style: { height: "28px", ...springFade(buttonsSpring()) }, class: "min-w-0 max-w-[320px] text-13-regular group", }} > @@ -1495,13 +1468,7 @@ export const PromptInput: Component = (props) => { onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)} class="capitalize max-w-[160px]" valueClass="truncate text-13-regular" - triggerStyle={{ - height: "28px", - opacity: buttonsSpring(), - transform: `scale(${0.95 + buttonsSpring() * 0.05})`, - filter: `blur(${(1 - buttonsSpring()) * 2}px)`, - "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none", - }} + triggerStyle={{ height: "28px", ...springFade(buttonsSpring()) }} variant="ghost" /> diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 5749291157e..c21776184bb 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -358,7 +358,6 @@ function createGlobalSync() { .update({ config }) .then(bootstrap) .then(() => { - queue.refresh() setGlobalStore("reload", undefined) queue.refresh() }) diff --git a/packages/app/src/pages/session/composer/session-todo-dock.tsx b/packages/app/src/pages/session/composer/session-todo-dock.tsx index d27254a2104..c8b0bb2ad30 100644 --- a/packages/app/src/pages/session/composer/session-todo-dock.tsx +++ b/packages/app/src/pages/session/composer/session-todo-dock.tsx @@ -89,8 +89,6 @@ export function SessionTodoDock(props: { const shut = createMemo(() => 1 - dock()) const value = createMemo(() => Math.max(0, Math.min(1, collapse()))) const hide = createMemo(() => Math.max(value(), shut())) - const off = createMemo(() => hide() > 0.98) - const turn = createMemo(() => Math.max(0, Math.min(1, value()))) const [height, setHeight] = createSignal(320) const full = createMemo(() => Math.max(78, height())) let contentRef: HTMLDivElement | undefined @@ -173,7 +171,7 @@ export function SessionTodoDock(props: { icon="chevron-down" size="normal" variant="ghost" - style={{ transform: `rotate(${turn() * 180}deg)` }} + style={{ transform: `rotate(${value() * 180}deg)` }} onMouseDown={(event) => { event.preventDefault() event.stopPropagation() @@ -189,12 +187,11 @@ export function SessionTodoDock(props: {
0.1, }} style={{ - visibility: off() ? "hidden" : "visible", opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`, filter: `blur(${Math.max(0, Math.min(1, hide())) * 2}px)`, visibility: hide() > 0.98 ? "hidden" : "visible", @@ -282,8 +279,10 @@ function TodoList(props: { todos: Todo[]; open: boolean }) { style={{ "--checkbox-align": "flex-start", "--checkbox-offset": "1px", - transition: "opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))", + transition: + "opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), filter 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))", opacity: todo().status === "pending" ? "0.94" : "1", + filter: todo().status === "pending" ? "blur(0.3px)" : "blur(0px)", }} > From 6455ccb6096d06ce53710392215e961ecee92186 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 4 Mar 2026 10:23:04 -0500 Subject: [PATCH 55/76] refactor(app): extract useElementHeight hook and clean up todo dock - Extract shared useElementHeight hook into @opencode-ai/ui/hooks, replacing duplicated ResizeObserver patterns in session-composer-region and session-todo-dock - Replace 13 animation tuning props with module-level constants (they were always passed as undefined from session.tsx) - Export COLLAPSED_HEIGHT from session-todo-dock to share with session-composer-region - Strip unnecessary clamps on critically damped springs - Remove ~280 lines of dead slider UI from todo-panel-motion story --- .../composer/session-composer-region.tsx | 118 +------ .../session/composer/session-todo-dock.tsx | 91 ++--- .../components/todo-panel-motion.stories.tsx | 316 +----------------- packages/ui/src/hooks/index.ts | 1 + packages/ui/src/hooks/use-element-height.ts | 25 ++ 5 files changed, 78 insertions(+), 473 deletions(-) create mode 100644 packages/ui/src/hooks/use-element-height.ts diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 80e153723bd..6f9b16f336e 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -1,7 +1,7 @@ -import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js" -import { createStore } from "solid-js/store" +import { Show, createMemo, createSignal, createEffect } from "solid-js" import { useParams } from "@solidjs/router" import { useSpring } from "@opencode-ai/ui/motion-spring" +import { useElementHeight } from "@opencode-ai/ui/hooks" import { PromptInput } from "@/components/prompt-input" import { useLanguage } from "@/context/language" import { usePrompt } from "@/context/prompt" @@ -9,11 +9,12 @@ import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff" import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock" import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock" import type { SessionComposerState } from "@/pages/session/composer/session-composer-state" -import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock" +import { SessionTodoDock, COLLAPSED_HEIGHT } from "@/pages/session/composer/session-todo-dock" + +const DOCK_SPRING = { visualDuration: 0.3, bounce: 0 } export function SessionComposerRegion(props: { state: SessionComposerState - ready: boolean centered: boolean inputRef: (el: HTMLDivElement) => void newSessionWorktree: string @@ -21,23 +22,6 @@ export function SessionComposerRegion(props: { onSubmit: () => void onResponseSubmit: () => void setPromptDockRef: (el: HTMLDivElement) => void - visualDuration?: number - bounce?: number - dockOpenVisualDuration?: number - dockOpenBounce?: number - dockCloseVisualDuration?: number - dockCloseBounce?: number - drawerExpandVisualDuration?: number - drawerExpandBounce?: number - drawerCollapseVisualDuration?: number - drawerCollapseBounce?: number - subtitleDuration?: number - subtitleTravel?: number - subtitleEdge?: number - countDuration?: number - countMask?: number - countMaskHeight?: number - countWidthDuration?: number }) { const params = useParams() const prompt = usePrompt() @@ -63,76 +47,15 @@ export function SessionComposerRegion(props: { setSessionHandoff(sessionKey(), { prompt: previewPrompt() }) }) - const [gate, setGate] = createStore({ - ready: false, - }) - let timer: number | undefined - let frame: number | undefined - - const clear = () => { - if (timer !== undefined) { - window.clearTimeout(timer) - timer = undefined - } - if (frame !== undefined) { - cancelAnimationFrame(frame) - frame = undefined - } - } - - createEffect(() => { - sessionKey() - const ready = props.ready - const delay = 140 - - clear() - setGate("ready", false) - if (!ready) return - - frame = requestAnimationFrame(() => { - frame = undefined - timer = window.setTimeout(() => { - setGate("ready", true) - timer = undefined - }, delay) - }) - }) - - onCleanup(clear) - - const open = createMemo(() => gate.ready && props.state.dock() && !props.state.closing()) - const config = createMemo(() => - open() - ? { - visualDuration: props.dockOpenVisualDuration ?? props.visualDuration ?? 0.3, - bounce: props.dockOpenBounce ?? props.bounce ?? 0, - } - : { - visualDuration: props.dockCloseVisualDuration ?? props.visualDuration ?? 0.3, - bounce: props.dockCloseBounce ?? props.bounce ?? 0, - }, - ) + const open = createMemo(() => props.state.dock() && !props.state.closing()) const progress = useSpring( () => (open() ? 1 : 0), - config, + DOCK_SPRING, ) - const value = createMemo(() => Math.max(0, Math.min(1, progress()))) - const [height, setHeight] = createSignal(320) - const dock = createMemo(() => (gate.ready && props.state.dock()) || value() > 0.001) - const full = createMemo(() => Math.max(78, height())) + const dock = createMemo(() => props.state.dock() || progress() > 0.001) const [contentRef, setContentRef] = createSignal() - - createEffect(() => { - const el = contentRef() - if (!el) return - const update = () => { - setHeight(el.getBoundingClientRect().height) - } - update() - const observer = new ResizeObserver(update) - observer.observe(el) - onCleanup(() => observer.disconnect()) - }) + const height = useElementHeight(contentRef, 320) + const full = createMemo(() => Math.max(COLLAPSED_HEIGHT, height())) return (
@@ -194,20 +117,7 @@ export function SessionComposerRegion(props: { title={language.t("session.todo.title")} collapseLabel={language.t("session.todo.collapse")} expandLabel={language.t("session.todo.expand")} - dockProgress={value()} - visualDuration={props.visualDuration} - bounce={props.bounce} - expandVisualDuration={props.drawerExpandVisualDuration} - expandBounce={props.drawerExpandBounce} - collapseVisualDuration={props.drawerCollapseVisualDuration} - collapseBounce={props.drawerCollapseBounce} - subtitleDuration={props.subtitleDuration} - subtitleTravel={props.subtitleTravel} - subtitleEdge={props.subtitleEdge} - countDuration={props.countDuration} - countMask={props.countMask} - countMaskHeight={props.countMaskHeight} - countWidthDuration={props.countWidthDuration} + dockProgress={progress()} />
@@ -217,7 +127,7 @@ export function SessionComposerRegion(props: { "relative z-10": true, }} style={{ - "margin-top": `${-36 * value()}px`, + "margin-top": `${-36 * progress()}px`, }} > active()?.content ?? "") - const config = createMemo(() => - store.collapsed - ? { - visualDuration: props.collapseVisualDuration ?? props.visualDuration ?? 0.3, - bounce: props.collapseBounce ?? props.bounce ?? 0, - } - : { - visualDuration: props.expandVisualDuration ?? props.visualDuration ?? 0.3, - bounce: props.expandBounce ?? props.bounce ?? 0, - }, - ) - const collapse = useSpring(() => (store.collapsed ? 1 : 0), config) - const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress ?? 1))) - const shut = createMemo(() => 1 - dock()) - const value = createMemo(() => Math.max(0, Math.min(1, collapse()))) - const hide = createMemo(() => Math.max(value(), shut())) - const [height, setHeight] = createSignal(320) - const full = createMemo(() => Math.max(78, height())) + const collapse = useSpring(() => (store.collapsed ? 1 : 0), COLLAPSE_SPRING) + const shut = createMemo(() => 1 - (props.dockProgress ?? 1)) + const hide = createMemo(() => Math.max(collapse(), shut())) let contentRef: HTMLDivElement | undefined - - createEffect(() => { - const el = contentRef - if (!el) return - const update = () => { - setHeight(el.getBoundingClientRect().height) - } - update() - const observer = new ResizeObserver(update) - observer.observe(el) - onCleanup(() => observer.disconnect()) - }) + const height = useElementHeight(() => contentRef, 320) + const full = createMemo(() => Math.max(COLLAPSED_HEIGHT, height())) return (
@@ -131,12 +99,12 @@ export function SessionTodoDock(props: { class="text-14-regular text-text-strong cursor-default inline-flex items-baseline shrink-0 whitespace-nowrap overflow-visible" aria-label={label()} style={{ - "--tool-motion-odometer-ms": `${props.countDuration ?? 600}ms`, - "--tool-motion-mask": `${props.countMask ?? 18}%`, - "--tool-motion-mask-height": `${props.countMaskHeight ?? 0}px`, - "--tool-motion-spring-ms": `${props.countWidthDuration ?? 560}ms`, - opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`, - filter: `blur(${Math.max(0, Math.min(1, shut())) * 2}px)`, + "--tool-motion-odometer-ms": `${COUNT.duration}ms`, + "--tool-motion-mask": `${COUNT.mask}%`, + "--tool-motion-mask-height": `${COUNT.maskHeight}px`, + "--tool-motion-spring-ms": `${COUNT.widthDuration}ms`, + opacity: `${1 - shut()}`, + filter: shut() > 0.01 ? `blur(${shut() * 2}px)` : "none", }} > @@ -155,9 +123,9 @@ export function SessionTodoDock(props: { { event.preventDefault() event.stopPropagation() @@ -187,13 +155,14 @@ export function SessionTodoDock(props: {
0.1, }} style={{ - opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`, - filter: `blur(${Math.max(0, Math.min(1, hide())) * 2}px)`, + opacity: `${1 - hide()}`, + filter: hide() > 0.01 ? `blur(${hide() * 2}px)` : "none", visibility: hide() > 0.98 ? "hidden" : "visible", }} > @@ -279,10 +248,8 @@ function TodoList(props: { todos: Todo[]; open: boolean }) { style={{ "--checkbox-align": "flex-start", "--checkbox-offset": "1px", - transition: - "opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), filter 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))", - opacity: todo().status === "pending" ? "0.94" : "1", - filter: todo().status === "pending" ? "blur(0.3px)" : "blur(0px)", + transition: "opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))", + opacity: todo().status === "pending" ? "0.5" : "1", }} > diff --git a/packages/ui/src/components/todo-panel-motion.stories.tsx b/packages/ui/src/components/todo-panel-motion.stories.tsx index 39d34215783..c7008e41bfe 100644 --- a/packages/ui/src/components/todo-panel-motion.stories.tsx +++ b/packages/ui/src/components/todo-panel-motion.stories.tsx @@ -131,22 +131,7 @@ export const Playground = { const global = useGlobalSync() const [open, setOpen] = createSignal(true) const [step, setStep] = createSignal(1) - const [dockOpenDuration, setDockOpenDuration] = createSignal(0.3) - const [dockOpenBounce, setDockOpenBounce] = createSignal(0) const [dockCloseDuration, setDockCloseDuration] = createSignal(0.3) - const [dockCloseBounce, setDockCloseBounce] = createSignal(0) - const [drawerExpandDuration, setDrawerExpandDuration] = createSignal(0.3) - const [drawerExpandBounce, setDrawerExpandBounce] = createSignal(0) - const [drawerCollapseDuration, setDrawerCollapseDuration] = createSignal(0.3) - const [drawerCollapseBounce, setDrawerCollapseBounce] = createSignal(0) - const [subtitleDuration, setSubtitleDuration] = createSignal(600) - const [subtitleAuto, setSubtitleAuto] = createSignal(true) - const [subtitleTravel, setSubtitleTravel] = createSignal(25) - const [subtitleEdge, setSubtitleEdge] = createSignal(17) - const [countDuration, setCountDuration] = createSignal(600) - const [countMask, setCountMask] = createSignal(18) - const [countMaskHeight, setCountMaskHeight] = createSignal(0) - const [countWidthDuration, setCountWidthDuration] = createSignal(560) const state = createSessionComposerState({ closeMs: () => Math.round(dockCloseDuration() * 1000) }) let frame let composerRef @@ -256,21 +241,6 @@ export const Playground = { onSubmit={() => {}} onResponseSubmit={pin} setPromptDockRef={() => {}} - dockOpenVisualDuration={dockOpenDuration()} - dockOpenBounce={dockOpenBounce()} - dockCloseVisualDuration={dockCloseDuration()} - dockCloseBounce={dockCloseBounce()} - drawerExpandVisualDuration={drawerExpandDuration()} - drawerExpandBounce={drawerExpandBounce()} - drawerCollapseVisualDuration={drawerCollapseDuration()} - drawerCollapseBounce={drawerCollapseBounce()} - subtitleDuration={subtitleDuration()} - subtitleTravel={subtitleAuto() ? undefined : subtitleTravel()} - subtitleEdge={subtitleAuto() ? undefined : subtitleEdge()} - countDuration={countDuration()} - countMask={countMask()} - countMaskHeight={countMaskHeight()} - countWidthDuration={countWidthDuration()} />
@@ -279,62 +249,21 @@ export const Playground = {
- - - - {[0, 1, 2, 3].map((value) => ( - ))}
-
Dock open
- - - -
- Dock close -
+
Dock close
- - -
- Drawer expand -
- - - -
- Drawer collapse -
- - - -
- Subtitle odometer -
- - - - - -
- Count odometer -
- - - -
) diff --git a/packages/ui/src/hooks/index.ts b/packages/ui/src/hooks/index.ts index 1c90a2e493d..9637c88cb50 100644 --- a/packages/ui/src/hooks/index.ts +++ b/packages/ui/src/hooks/index.ts @@ -1,2 +1,3 @@ export * from "./use-filtered-list" export * from "./create-auto-scroll" +export * from "./use-element-height" diff --git a/packages/ui/src/hooks/use-element-height.ts b/packages/ui/src/hooks/use-element-height.ts new file mode 100644 index 00000000000..a9f06ec8b84 --- /dev/null +++ b/packages/ui/src/hooks/use-element-height.ts @@ -0,0 +1,25 @@ +import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js" + +/** + * Tracks an element's height via ResizeObserver. + * Returns a reactive signal that updates whenever the element resizes. + */ +export function useElementHeight( + ref: Accessor | (() => HTMLElement | undefined), + initial = 0, +): Accessor { + const [height, setHeight] = createSignal(initial) + + createEffect(() => { + const el = ref() + if (!el) return + setHeight(el.getBoundingClientRect().height) + const observer = new ResizeObserver(() => { + setHeight(el.getBoundingClientRect().height) + }) + observer.observe(el) + onCleanup(() => observer.disconnect()) + }) + + return height +} From 7fe615247bdbef0cd66ead074cd38e7f54c87261 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 4 Mar 2026 10:23:12 -0500 Subject: [PATCH 56/76] fix(ui): cache reduced-motion query and clean up deferRender timers - Add module-level prefersReducedMotion signal in message-part.tsx, replacing per-mount window.matchMedia calls in useToolFade - Cancel pending rAF/setTimeout in session.tsx deferRender when session key changes rapidly, and clean up on unmount --- packages/app/src/pages/session.tsx | 18 ++++++++++++++++-- packages/ui/src/components/message-part.tsx | 18 +++++++++++++----- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 6029f35acb1..e0ee3bd28f3 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -38,6 +38,7 @@ import { createScrollSpy } from "@/pages/session/scroll-spy" import { SessionReviewTab, type DiffStyle, type SessionReviewTabProps } from "@/pages/session/review-tab" import { TerminalPanel } from "@/pages/session/terminal-panel" import { MessageTimeline } from "@/pages/session/message-timeline" +import { AnimationDebugPanel } from "@opencode-ai/ui/animation-debug-panel" import { useSessionCommands } from "@/pages/session/use-session-commands" import { SessionComposerRegion, createSessionComposerState } from "@/pages/session/composer" import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs" @@ -419,16 +420,28 @@ export default function Page() { deferRender: false, }) + let deferFrame: number | undefined + let deferTimer: ReturnType | undefined createComputed((prev) => { const key = sessionKey() if (key !== prev) { + if (deferFrame !== undefined) cancelAnimationFrame(deferFrame) + if (deferTimer !== undefined) clearTimeout(deferTimer) setStore("deferRender", true) - requestAnimationFrame(() => { - setTimeout(() => setStore("deferRender", false), 0) + deferFrame = requestAnimationFrame(() => { + deferFrame = undefined + deferTimer = setTimeout(() => { + deferTimer = undefined + setStore("deferRender", false) + }, 0) }) } return key }, sessionKey()) + onCleanup(() => { + if (deferFrame !== undefined) cancelAnimationFrame(deferFrame) + if (deferTimer !== undefined) clearTimeout(deferTimer) + }) const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? []) const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs())) @@ -1162,6 +1175,7 @@ export default function Page() { return (
+ {import.meta.env.DEV && }
{ return visible })() +const prefersReducedMotion = /* @__PURE__ */ (() => { + if (typeof window === "undefined") return () => false + const mql = window.matchMedia("(prefers-reduced-motion: reduce)") + const [reduced, setReduced] = createSignal(mql.matches) + mql.addEventListener("change", () => setReduced(mql.matches)) + return reduced +})() + function createGroupOpenState() { const [state, setState] = createSignal>({}) const read = (key?: string, collapse?: boolean) => { @@ -1496,7 +1504,7 @@ function useToolFade( const el = ref() if (!el || typeof window === "undefined") return - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return + if (prefersReducedMotion()) return const mask = wipe && @@ -1529,10 +1537,10 @@ function useToolFade( ? animate( node, { opacity: 1, filter: "blur(0px)", transform: "translateX(0)", maskPosition: "0% 0%" }, - { ...FADE_SPRING, delay }, + { ...GROW_SPRING, delay }, ) - : animate(node, { opacity: 1, filter: "blur(0px)", transform: "translateX(0)" }, { ...FADE_SPRING, delay }) - : animate(node, { opacity: 1, filter: "blur(0px)", transform: "translateY(0)" }, { ...FADE_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() From 1abb7efc28261ce43c4209f0254d919331b0d6d6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 4 Mar 2026 10:35:53 -0500 Subject: [PATCH 57/76] fix(app): prevent session header flash on first message --- packages/app/src/pages/session.tsx | 91 +++++++++---------- .../src/pages/session/message-timeline.tsx | 90 +++++++++--------- 2 files changed, 88 insertions(+), 93 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index e0ee3bd28f3..0aaf05e869c 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -453,11 +453,6 @@ export default function Page() { return "main" }) - const activeMessage = createMemo(() => { - if (!store.messageId) return lastUserMessage() - const found = visibleUserMessages()?.find((m) => m.id === store.messageId) - return found ?? lastUserMessage() - }) const setActiveMessage = (message: UserMessage | undefined) => { setStore("messageId", message?.id) } @@ -1201,50 +1196,48 @@ export default function Page() {
- - { - content = el - autoScroll.contentRef(el) - - const root = scroller - if (root) scheduleScrollState(root) - }} - turnStart={historyWindow.turnStart()} - historyMore={historyMore()} - historyLoading={historyLoading()} - onLoadEarlier={() => { - void historyWindow.loadAndReveal() - }} - renderedUserMessages={historyWindow.renderedUserMessages()} - anchor={anchor} - onRegisterMessage={scrollSpy.register} - onUnregisterMessage={scrollSpy.unregister} - /> - + { + content = el + autoScroll.contentRef(el) + + const root = scroller + if (root) scheduleScrollState(root) + }} + turnStart={historyWindow.turnStart()} + historyMore={historyMore()} + historyLoading={historyLoading()} + onLoadEarlier={() => { + void historyWindow.loadAndReveal() + }} + renderedUserMessages={historyWindow.renderedUserMessages()} + anchor={anchor} + onRegisterMessage={scrollSpy.register} + onUnregisterMessage={scrollSpy.unregister} + /> { + headerAnim?.stop() + headerAnim = undefined + } + + const animateHeader = () => { + const el = headerRef + if (!el) return + + clearHeaderAnim() + headerAnim = animate(el, { opacity: [0, 1] }, FAST_SPRING) + headerAnim.finished.then(() => { + if (headerRef !== el) return + clearFadeStyles(el) + }) + } + const clearTitleAnims = () => { if (titleFrame !== undefined) { cancelAnimationFrame(titleFrame) @@ -362,15 +379,12 @@ export function MessageTimeline(props: { const animateEnterSpan = () => { if (!enterRef) return - enterRef.style.opacity = "0" - enterRef.style.filter = "blur(2px)" - enterRef.style.transform = "translateY(-2px)" - titleFrame = requestAnimationFrame(() => { - titleFrame = undefined - if (!enterRef) return - enterAnim = animate(enterRef, { opacity: 1, filter: "blur(0px)", transform: "translateY(0)" }, FAST_SPRING) - enterAnim.finished.then(() => settleTitleEnter()) - }) + 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) => { @@ -379,13 +393,6 @@ export function MessageTimeline(props: { // snapshot old text into leave span before updating store setHeaderText({ prev: headerText.value, prevMuted: headerText.muted }) - // show leave span with old text - if (leaveRef) { - leaveRef.style.opacity = "1" - leaveRef.style.filter = "blur(0px)" - leaveRef.style.transform = "translateY(0)" - } - // update to new text setHeaderText({ value: nextTitle, muted: nextMuted }) @@ -393,7 +400,7 @@ export function MessageTimeline(props: { if (leaveRef) { leaveAnim = animate( leaveRef, - { opacity: 0, filter: "blur(2px)", transform: "translateY(2px)" }, + { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"], transform: ["translateY(0)", "translateY(2px)"] }, FAST_SPRING, ) leaveAnim.finished.then(() => { @@ -419,20 +426,13 @@ export function MessageTimeline(props: { createEffect( on(showHeader, (show, prev) => { - if (headerFrame !== undefined) cancelAnimationFrame(headerFrame) if (!show) { - setHeaderLive(false) + clearHeaderAnim() return } - if (prev) { - setHeaderLive(true) - return - } - setHeaderLive(false) - headerFrame = requestAnimationFrame(() => { - headerFrame = undefined - setHeaderLive(true) - }) + + if (show === prev) return + animateHeader() }), ) @@ -450,7 +450,9 @@ export function MessageTimeline(props: { } return } - if (nextTitle === headerText.value && nextMuted === headerText.muted) return + if (nextTitle === headerText.value && nextMuted === headerText.muted) { + return + } if (!nextTitle) { snapTitle(undefined, false) return @@ -471,16 +473,9 @@ export function MessageTimeline(props: { ), ) onCleanup(() => { - if (headerFrame !== undefined) cancelAnimationFrame(headerFrame) + clearHeaderAnim() clearTitleAnims() }) - const headerStyle = createMemo(() => ({ - opacity: headerLive() ? "1" : "0", - filter: headerLive() ? "blur(0px)" : "blur(3px)", - transform: headerLive() ? "translateY(0)" : "translateY(-3px)", - transition: - "opacity 450ms cubic-bezier(0.34, 1, 0.64, 1), filter 450ms cubic-bezier(0.34, 1, 0.64, 1), transform 450ms cubic-bezier(0.34, 1, 0.64, 1)", - })) const errorMessage = (err: unknown) => { if (err && typeof err === "object" && "data" in err) { @@ -702,7 +697,14 @@ export function MessageTimeline(props: {
-
+
{ + headerRef = el + el.style.opacity = "0" + }} + class="pointer-events-none absolute inset-x-0 top-0 z-30" + >
-
+
{(id) => ( -
+
Date: Wed, 4 Mar 2026 10:42:50 -0500 Subject: [PATCH 58/76] fix(ui): keep automatic context collapse on grow timing Route GrowBox mount and toggle animations through separate spring configs so COLLAPSIBLE_SPRING only applies after user-driven toggles. Also wrap loaded-file rows in GrowBox to animate height expansion instead of popping in. --- packages/ui/src/components/grow-box.tsx | 39 ++++++++++++++------ packages/ui/src/components/message-part.tsx | 40 ++++++++++++++++----- 2 files changed, 59 insertions(+), 20 deletions(-) diff --git a/packages/ui/src/components/grow-box.tsx b/packages/ui/src/components/grow-box.tsx index 01cc483693b..f13d1b4b8bb 100644 --- a/packages/ui/src/components/grow-box.tsx +++ b/packages/ui/src/components/grow-box.tsx @@ -1,5 +1,5 @@ import { createEffect, on, type JSX, onMount, onCleanup } from "solid-js" -import { animate, springValue, type AnimationPlaybackControls, FADE_SPRING, HEIGHT_SPRING } from "./motion" +import { animate, tunableSpringValue, type AnimationPlaybackControls, GROW_SPRING, type SpringConfig } from "./motion" export interface GrowBoxProps { children: JSX.Element @@ -23,6 +23,10 @@ export interface GrowBoxProps { 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 } /** @@ -32,6 +36,9 @@ export interface GrowBoxProps { * Used for timeline turns, assistant part groups, and user messages. */ export function GrowBox(props: GrowBoxProps) { + 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 @@ -39,7 +46,15 @@ export function GrowBox(props: GrowBoxProps) { let resizeFrame: number | undefined let observer: ResizeObserver | undefined let springTarget = -1 - const height = springValue(0, HEIGHT_SPRING) + const height = tunableSpringValue(0, { + type: "spring", + get visualDuration() { + return (mode === "toggle" ? toggleSpring() : spring()).visualDuration + }, + get bounce() { + return (mode === "toggle" ? toggleSpring() : spring()).bounce + }, + }) const gap = () => Math.max(0, props.gap ?? 0) const grow = () => props.grow !== false @@ -59,11 +74,11 @@ export function GrowBox(props: GrowBoxProps) { body.style.filter = "" } - const fadeBodyIn = () => { + const fadeBodyIn = (nextMode: "mount" | "toggle" = "mount") => { if (props.fade === false || !body) return hideBody() fadeAnim?.stop() - fadeAnim = animate(body, { opacity: 1, filter: "blur(0px)" }, FADE_SPRING) + fadeAnim = animate(body, { opacity: 1, filter: "blur(0px)" }, nextMode === "toggle" ? toggleSpring() : spring()) fadeAnim.finished.then(() => { if (!body || !open()) return clearBody() @@ -89,7 +104,7 @@ export function GrowBox(props: GrowBoxProps) { const targetHeight = () => Math.max(0, Math.ceil(body?.getBoundingClientRect().height ?? 0)) - const setHeight = () => { + const setHeight = (nextMode: "mount" | "toggle" = "mount") => { if (!root || !open()) return const next = targetHeight() if (next === springTarget) return @@ -104,6 +119,7 @@ export function GrowBox(props: GrowBoxProps) { } root.style.overflow = "clip" springTarget = next + mode = nextMode height.set(next) } @@ -167,8 +183,8 @@ export function GrowBox(props: GrowBoxProps) { } mountFrame = requestAnimationFrame(() => { mountFrame = undefined - fadeBodyIn() - if (grow()) setHeight() + fadeBodyIn("mount") + if (grow()) setHeight("mount") }) } if (watch()) { @@ -177,7 +193,7 @@ export function GrowBox(props: GrowBoxProps) { if (resizeFrame !== undefined) return resizeFrame = requestAnimationFrame(() => { resizeFrame = undefined - setHeight() + setHeight("mount") }) }) observer.observe(body) @@ -203,15 +219,16 @@ export function GrowBox(props: GrowBoxProps) { root.style.height = `${next}px` } if (props.fade !== false) { - fadeAnim = animate(body, { opacity: 0, filter: "blur(2px)" }, FADE_SPRING) + fadeAnim = animate(body, { opacity: 0, filter: "blur(2px)" }, toggleSpring()) } root.style.overflow = "clip" springTarget = 0 + mode = "toggle" height.set(0) return } - fadeBodyIn() - setHeight() + fadeBodyIn("toggle") + setHeight("toggle") }, { defer: true }, ), diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 63b63505551..038b23b0437 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -50,7 +50,15 @@ import { list } from "./text-utils" import { AnimatedCountList } from "./tool-count-summary" import { ToolStatusTitle } from "./tool-status-title" import { GrowBox } from "./grow-box" -import { animate, type AnimationPlaybackControls, clearFadeStyles, clearMaskStyles, GROW_SPRING, WIPE_MASK } from "./motion" +import { + animate, + type AnimationPlaybackControls, + clearFadeStyles, + clearMaskStyles, + COLLAPSIBLE_SPRING, + GROW_SPRING, + WIPE_MASK, +} from "./motion" interface Diagnostic { range: { @@ -278,7 +286,6 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo { const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"]) const HIDDEN_TOOLS = new Set(["todowrite", "todoread"]) - function busy(status: string | undefined) { return status === "pending" || status === "running" } @@ -309,10 +316,14 @@ function createGroupOpenState() { if (value !== undefined) return value return !collapse } + const controlled = (key?: string) => { + if (!key) return false + return state()[key] !== undefined + } const write = (key: string, value: boolean) => { setState((prev) => ({ ...prev, [key]: value })) } - return { read, write } + return { read, controlled, write } } function shouldCollapseGroup( @@ -358,6 +369,8 @@ function PartGrow(props: { grow?: boolean watch?: boolean open?: boolean + spring?: import("./motion").SpringConfig + toggleSpring?: import("./motion").SpringConfig }) { return ( {props.children} @@ -498,6 +513,12 @@ export function AssistantParts(props: { return value.part.type === "tool" }) const context = createMemo(() => !!part()?.context) + const contextSpring = createMemo(() => { + const entry = part() + if (!entry?.context) return undefined + if (!groupState.controlled(entry.groupKey)) return undefined + return COLLAPSIBLE_SPRING + }) const contextOpen = createMemo(() => { const collapse = ( afterTool?: boolean, @@ -536,6 +557,7 @@ export function AssistantParts(props: { watch={!context() && !tool() && tail() && !turnSummary()} animateToggle open={visible()} + toggleSpring={contextSpring()} > {(entry) => ( @@ -1478,7 +1500,6 @@ ToolRegistry.register({ }, }) - function useToolReveal(pending: () => boolean, animate?: () => boolean) { const enabled = () => animate?.() ?? true const [live, setLive] = createSignal(pending() || enabled()) @@ -1498,7 +1519,6 @@ function useToolFade( const wipe = options?.wipe ?? false const active = options?.animate !== false - onMount(() => { if (!active) return @@ -1613,10 +1633,12 @@ function ToolLoadedFile(props: { text: string; animate?: boolean }) { useToolFade(() => ref, { delay: 0.02, wipe: true, animate: props.animate }) return ( -
- - {props.text} -
+ +
+ + {props.text} +
+
) } From 302f4bd8636df23d2d13620a3026243a6f3bc3fb Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 4 Mar 2026 10:50:00 -0500 Subject: [PATCH 59/76] fix(ui): fix inconsistent dot separator spacing in user message meta Render user meta (agent, model, timestamp) as a single text node instead of separate spans, eliminating JSX whitespace that caused uneven spacing around the middot separators. --- .../src/components/animation-debug-panel.tsx | 122 ++++ packages/ui/src/components/basic-tool.tsx | 11 +- .../header-title-crossfade.stories.tsx | 562 ++++++++++++++++++ packages/ui/src/components/message-part.tsx | 26 +- packages/ui/src/components/motion.tsx | 48 +- packages/ui/src/components/text-reveal.tsx | 6 +- .../ui/src/components/tool-status-title.tsx | 4 +- 7 files changed, 730 insertions(+), 49 deletions(-) create mode 100644 packages/ui/src/components/animation-debug-panel.tsx create mode 100644 packages/ui/src/components/header-title-crossfade.stories.tsx diff --git a/packages/ui/src/components/animation-debug-panel.tsx b/packages/ui/src/components/animation-debug-panel.tsx new file mode 100644 index 00000000000..1c3cccd901a --- /dev/null +++ b/packages/ui/src/components/animation-debug-panel.tsx @@ -0,0 +1,122 @@ +import { createSignal } from "solid-js" +import { + getGrowDuration, + getCollapsibleDuration, + setGrowDuration, + setCollapsibleDuration, +} from "./motion" + +export function AnimationDebugPanel() { + const [grow, setGrow] = createSignal(getGrowDuration()) + const [collapsible, setCollapsible] = createSignal(getCollapsibleDuration()) + const [collapsed, setCollapsed] = createSignal(true) + const [dragging, setDragging] = createSignal(false) + const [pos, setPos] = createSignal({ x: 16, y: 16 }) + let dragOffset = { x: 0, y: 0 } + + const onPointerDown = (e: PointerEvent) => { + if ((e.target as HTMLElement).closest("button, input")) return + setDragging(true) + dragOffset = { x: e.clientX + pos().x, y: e.clientY + pos().y } + ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId) + } + + const onPointerMove = (e: PointerEvent) => { + if (!dragging()) return + setPos({ x: dragOffset.x - e.clientX, y: dragOffset.y - e.clientY }) + } + + const onPointerUp = () => setDragging(false) + + return ( +
+
+ + springs + + +
+ {!collapsed() && ( +
+ { + setGrow(v) + setGrowDuration(v) + }} + /> + { + setCollapsible(v) + setCollapsibleDuration(v) + }} + /> +
+ )} +
+ ) +} + +function SliderRow(props: { label: string; value: number; onChange: (v: number) => void }) { + return ( +
+ {props.label} + props.onChange(parseFloat(e.currentTarget.value))} + style={{ width: "100px", cursor: "pointer" }} + /> + + {props.value.toFixed(2)} + +
+ ) +} diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index 5136481ac0d..2979f1b09f3 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -14,9 +14,8 @@ import { import { animate, type AnimationPlaybackControls, - springValue, - COLLAPSIBLE_CONTENT_FADE_SPRING, - COLLAPSIBLE_CONTENT_HEIGHT_SPRING, + tunableSpringValue, + COLLAPSIBLE_SPRING, } from "./motion" import { Collapsible } from "./collapsible" import { TextShimmer } from "./text-shimmer" @@ -173,7 +172,7 @@ function ToolCallPanel(props: ToolCallPanelBaseProps) { let observer: ResizeObserver | undefined let resizeFrame: number | undefined const initialOpen = open() - const heightSpring = springValue(0, COLLAPSIBLE_CONTENT_HEIGHT_SPRING) + const heightSpring = tunableSpringValue(0, COLLAPSIBLE_SPRING) const read = () => Math.max(0, Math.ceil(bodyRef?.getBoundingClientRect().height ?? 0)) @@ -187,7 +186,7 @@ function ToolCallPanel(props: ToolCallPanelBaseProps) { } const next = read() fadeAnim?.stop() - fadeAnim = animate(bodyRef, { opacity: 1, filter: "blur(0px)" }, COLLAPSIBLE_CONTENT_FADE_SPRING) + fadeAnim = animate(bodyRef, { opacity: 1, filter: "blur(0px)" }, COLLAPSIBLE_SPRING) fadeAnim.finished.then(() => { if (!bodyRef) return bodyRef.style.opacity = "" @@ -199,7 +198,7 @@ function ToolCallPanel(props: ToolCallPanelBaseProps) { const doClose = () => { if (!contentRef || !bodyRef) return fadeAnim?.stop() - fadeAnim = animate(bodyRef, { opacity: 0, filter: "blur(2px)" }, COLLAPSIBLE_CONTENT_FADE_SPRING) + fadeAnim = animate(bodyRef, { opacity: 0, filter: "blur(2px)" }, COLLAPSIBLE_SPRING) fadeAnim.finished.then(() => { if (!contentRef || open()) return contentRef.style.display = "none" diff --git a/packages/ui/src/components/header-title-crossfade.stories.tsx b/packages/ui/src/components/header-title-crossfade.stories.tsx new file mode 100644 index 00000000000..f6b72259ec9 --- /dev/null +++ b/packages/ui/src/components/header-title-crossfade.stories.tsx @@ -0,0 +1,562 @@ +// @ts-nocheck +import { createSignal, createEffect, createMemo, on, onCleanup, onMount } from "solid-js" +import { animate, type AnimationPlaybackControls, GROW_SPRING } from "./motion" +import { TextReveal } from "./text-reveal" + +export default { + title: "UI/HeaderTitleCrossfade", + id: "components-header-title-crossfade", +} + +const TITLES = [ + "New session", + "Pickles conversation idea", + "Refactor ToolStatusTitle DOM measurement to use contentWidth helper", + "Fix bug", + "New session", + "Understanding the codebase architecture and making improvements", +] + +const btn = (accent?: boolean) => + ({ + padding: "5px 12px", + "border-radius": "6px", + border: accent ? "1px solid #58f" : "1px solid #444", + background: accent ? "#58f" : "#222", + color: "#eee", + cursor: "pointer", + "font-size": "12px", + }) as const + +const TITLE_SPRING = { ...GROW_SPRING, visualDuration: 0.35, bounce: 0 } + +// ─── Shared wrapper ─────────────────────────────────────────────── + +function TitleFrame(props: { label: string; children: any }) { + return ( +
+ {props.label} +
+

+ {props.children} +

+
+
+ ) +} + +// ─── Approach 1: Explicit keyframe arrays with animate() ────────── + +function CrossfadeExplicitKeyframes(props: { text: () => string; muted: () => boolean }) { + let enterRef: HTMLSpanElement | undefined + let leaveRef: HTMLSpanElement | undefined + let enterAnim: AnimationPlaybackControls | undefined + let leaveAnim: AnimationPlaybackControls | undefined + + const [cur, setCur] = createSignal(props.text()) + const [curMuted, setCurMuted] = createSignal(props.muted()) + const [prev, setPrev] = createSignal() + const [prevMuted, setPrevMuted] = createSignal(false) + + const clearEnter = () => { + if (!enterRef) return + enterRef.style.opacity = "" + enterRef.style.transform = "" + } + + const hideLeave = () => { + if (!leaveRef) return + leaveRef.style.opacity = "0" + leaveRef.style.transform = "" + } + + createEffect( + on( + () => [props.text(), props.muted()] as const, + ([nextText, nextMuted], prevTuple) => { + if (!prevTuple) { + setCur(nextText) + setCurMuted(nextMuted) + if (enterRef) { + enterAnim?.stop() + enterAnim = animate(enterRef, { opacity: [0, 1], transform: ["translateY(-2px)", "translateY(0)"] }, TITLE_SPRING) + enterAnim.finished.then(clearEnter) + } + return + } + if (nextText === prevTuple[0]) { + setCurMuted(nextMuted) + return + } + + enterAnim?.stop() + leaveAnim?.stop() + + setPrev(cur()) + setPrevMuted(curMuted()) + setCur(nextText) + setCurMuted(nextMuted) + + if (leaveRef) { + leaveRef.style.opacity = "1" + leaveAnim = animate(leaveRef, { opacity: [1, 0], transform: ["translateY(0)", "translateY(2px)"] }, TITLE_SPRING) + leaveAnim.finished.then(() => { + setPrev(undefined) + setPrevMuted(false) + hideLeave() + }) + } + + if (enterRef) { + enterAnim = animate(enterRef, { opacity: [0, 1], transform: ["translateY(-2px)", "translateY(0)"] }, TITLE_SPRING) + enterAnim.finished.then(clearEnter) + } + }, + ), + ) + + onCleanup(() => { + enterAnim?.stop() + leaveAnim?.stop() + }) + + return ( + + + {cur()} + + + {prev()} + + + ) +} + +// ─── Approach 2: TextReveal component ───────────────────────────── + +function CrossfadeTextReveal(props: { text: () => string; muted: () => boolean }) { + return ( + + + + ) +} + +// ─── Approach 3: CSS transitions (data-swapping toggle) ─────────── + +function CrossfadeCSSTransitions(props: { text: () => string; muted: () => boolean }) { + const [cur, setCur] = createSignal(props.text()) + const [curMuted, setCurMuted] = createSignal(props.muted()) + const [prev, setPrev] = createSignal() + const [prevMuted, setPrevMuted] = createSignal(false) + const [swapping, setSwapping] = createSignal(false) + let frame: number | undefined + + createEffect( + on( + () => [props.text(), props.muted()] as const, + ([nextText, nextMuted], prevTuple) => { + if (!prevTuple) { + setCur(nextText) + setCurMuted(nextMuted) + setSwapping(true) + if (frame !== undefined) cancelAnimationFrame(frame) + frame = requestAnimationFrame(() => { + setSwapping(false) + frame = undefined + }) + return + } + if (nextText === prevTuple[0]) { + setCurMuted(nextMuted) + return + } + + if (frame !== undefined) cancelAnimationFrame(frame) + + setPrev(cur()) + setPrevMuted(curMuted()) + setCur(nextText) + setCurMuted(nextMuted) + + // snap to swapping state (instant) + setSwapping(true) + + // next frame: remove swapping, CSS transitions animate + frame = requestAnimationFrame(() => { + setSwapping(false) + frame = undefined + }) + }, + ), + ) + + onCleanup(() => { + if (frame !== undefined) cancelAnimationFrame(frame) + }) + + const duration = "350ms" + const easing = "cubic-bezier(0.34, 1.08, 0.64, 1)" + + return ( + + {/* Entering span */} + + {cur()} + + {/* Leaving span */} + + {prev()} + + + ) +} + +// ─── Approach 4: Raw WAAPI (element.animate()) ──────────────────── + +function CrossfadeWAAPI(props: { text: () => string; muted: () => boolean }) { + let enterRef: HTMLSpanElement | undefined + let leaveRef: HTMLSpanElement | undefined + let enterAnim: Animation | undefined + let leaveAnim: Animation | undefined + + const [cur, setCur] = createSignal(props.text()) + const [curMuted, setCurMuted] = createSignal(props.muted()) + const [prev, setPrev] = createSignal() + const [prevMuted, setPrevMuted] = createSignal(false) + + const wapiOpts: KeyframeAnimationOptions = { + duration: 350, + easing: "cubic-bezier(0.34, 1.08, 0.64, 1)", + fill: "none" as const, + } + + const clearStyles = (el: HTMLElement) => { + el.style.opacity = "" + el.style.transform = "" + } + + createEffect( + on( + () => [props.text(), props.muted()] as const, + ([nextText, nextMuted], prevTuple) => { + if (!prevTuple) { + setCur(nextText) + setCurMuted(nextMuted) + if (enterRef) { + enterRef.style.opacity = "1" + enterRef.style.transform = "translateY(0)" + enterAnim?.cancel() + enterAnim = enterRef.animate( + [ + { opacity: 0, transform: "translateY(-2px)" }, + { opacity: 1, transform: "translateY(0)" }, + ], + wapiOpts, + ) + enterAnim.onfinish = () => clearStyles(enterRef!) + } + return + } + if (nextText === prevTuple[0]) { + setCurMuted(nextMuted) + return + } + + enterAnim?.cancel() + leaveAnim?.cancel() + + setPrev(cur()) + setPrevMuted(curMuted()) + setCur(nextText) + setCurMuted(nextMuted) + + if (leaveRef) { + leaveRef.style.opacity = "0" + leaveAnim = leaveRef.animate( + [ + { opacity: 1, transform: "translateY(0)" }, + { opacity: 0, transform: "translateY(2px)" }, + ], + wapiOpts, + ) + leaveAnim.onfinish = () => { + setPrev(undefined) + setPrevMuted(false) + if (leaveRef) leaveRef.style.opacity = "0" + } + } + + if (enterRef) { + enterRef.style.opacity = "1" + enterRef.style.transform = "translateY(0)" + enterAnim = enterRef.animate( + [ + { opacity: 0, transform: "translateY(-2px)" }, + { opacity: 1, transform: "translateY(0)" }, + ], + wapiOpts, + ) + enterAnim.onfinish = () => clearStyles(enterRef!) + } + }, + ), + ) + + onCleanup(() => { + enterAnim?.cancel() + leaveAnim?.cancel() + }) + + return ( + + + {cur()} + + + {prev()} + + + ) +} + +// ─── Approach 5: Chained animate() calls ────────────────────────── + +function CrossfadeChained(props: { text: () => string; muted: () => boolean }) { + let enterRef: HTMLSpanElement | undefined + let leaveRef: HTMLSpanElement | undefined + let enterAnim: AnimationPlaybackControls | undefined + let leaveAnim: AnimationPlaybackControls | undefined + let snapAnim: AnimationPlaybackControls | undefined + + const [cur, setCur] = createSignal(props.text()) + const [curMuted, setCurMuted] = createSignal(props.muted()) + const [prev, setPrev] = createSignal() + const [prevMuted, setPrevMuted] = createSignal(false) + + const clearEnter = () => { + if (!enterRef) return + enterRef.style.opacity = "" + enterRef.style.transform = "" + } + + const hideLeave = () => { + if (!leaveRef) return + leaveRef.style.opacity = "0" + leaveRef.style.transform = "" + } + + createEffect( + on( + () => [props.text(), props.muted()] as const, + ([nextText, nextMuted], prevTuple) => { + if (!prevTuple) { + setCur(nextText) + setCurMuted(nextMuted) + if (enterRef) { + enterAnim?.stop() + snapAnim?.stop() + // snap to hidden, then animate to visible + snapAnim = animate(enterRef, { opacity: 0, transform: "translateY(-2px)" }, { duration: 0 }) + snapAnim.finished.then(() => { + enterAnim = animate(enterRef!, { opacity: 1, transform: "translateY(0)" }, TITLE_SPRING) + enterAnim.finished.then(clearEnter) + }) + } + return + } + if (nextText === prevTuple[0]) { + setCurMuted(nextMuted) + return + } + + enterAnim?.stop() + leaveAnim?.stop() + snapAnim?.stop() + + setPrev(cur()) + setPrevMuted(curMuted()) + setCur(nextText) + setCurMuted(nextMuted) + + // fade out leave + if (leaveRef) { + leaveRef.style.opacity = "1" + leaveRef.style.transform = "translateY(0)" + leaveAnim = animate(leaveRef, { opacity: 0, transform: "translateY(2px)" }, TITLE_SPRING) + leaveAnim.finished.then(() => { + setPrev(undefined) + setPrevMuted(false) + hideLeave() + }) + } + + // snap enter to hidden (updates MotionValue to 0), then animate to visible + if (enterRef) { + snapAnim = animate(enterRef, { opacity: 0, transform: "translateY(-2px)" }, { duration: 0 }) + snapAnim.finished.then(() => { + enterAnim = animate(enterRef!, { opacity: 1, transform: "translateY(0)" }, TITLE_SPRING) + enterAnim.finished.then(clearEnter) + }) + } + }, + ), + ) + + onCleanup(() => { + enterAnim?.stop() + leaveAnim?.stop() + snapAnim?.stop() + }) + + return ( + + + {cur()} + + + {prev()} + + + ) +} + +// ─── Approach 6: Fade-in only (single span) ────────────────────── + +function CrossfadeFadeInOnly(props: { text: () => string; muted: () => boolean }) { + let ref: HTMLSpanElement | undefined + let anim: AnimationPlaybackControls | undefined + + const clearStyles = () => { + if (!ref) return + ref.style.opacity = "" + ref.style.transform = "" + } + + createEffect( + on( + () => props.text(), + (_next, prev) => { + if (!ref) return + anim?.stop() + if (prev !== undefined) { + // text changed — fade in + anim = animate(ref, { opacity: [0, 1], transform: ["translateY(-2px)", "translateY(0)"] }, TITLE_SPRING) + anim.finished.then(clearStyles) + } else { + // initial mount — fade in + anim = animate(ref, { opacity: [0, 1], transform: ["translateY(-2px)", "translateY(0)"] }, TITLE_SPRING) + anim.finished.then(clearStyles) + } + }, + ), + ) + + onCleanup(() => { + anim?.stop() + }) + + return ( + + {props.text()} + + ) +} + +// ─── Story ──────────────────────────────────────────────────────── + +export const Playground = { + render: () => { + const [index, setIndex] = createSignal(0) + const [cycling, setCycling] = createSignal(false) + let timer: number | undefined + + const title = createMemo(() => TITLES[index()]) + const muted = createMemo(() => title() === "New session") + const next = () => setIndex((i) => (i + 1) % TITLES.length) + + const toggleCycle = () => { + if (cycling()) { + if (timer) clearTimeout(timer) + timer = undefined + setCycling(false) + return + } + setCycling(true) + const tick = () => { + next() + timer = window.setTimeout(tick, 1200 + Math.floor(Math.random() * 800)) + } + timer = window.setTimeout(tick, 1200) + } + + onCleanup(() => { + if (timer) clearTimeout(timer) + }) + + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ {TITLES.map((t, i) => ( + + ))} +
+ +
+ + +
+ +
+ title: {title()} · muted: {muted() ? "yes" : "no"} +
+
+ ) + }, +} diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 038b23b0437..65f3aecf825 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -897,14 +897,12 @@ export function UserMessageDisplay(props: { return `${hour12}:${minute} ${hours < 12 ? "AM" : "PM"}` }) - const metaHead = createMemo(() => { + const userMeta = createMemo(() => { const agent = props.message.agent - const items = [agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", model()] + const items = [agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", model(), stamp()] return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0") }) - const metaTail = createMemo(() => stamp()) - const openImagePreview = (url: string, alt?: string) => { dialog.show(() => ) } @@ -967,23 +965,9 @@ export function UserMessageDisplay(props: {
- - - - - {metaHead()} - - - - - {"\u00A0\u00B7\u00A0"} - - - - - {metaTail()} - - + + + {userMeta()} (initial: T, config: SpringConfig): MotionValue { + return followValue(initial, config as any) } -export const COLLAPSIBLE_CONTENT_HEIGHT_SPRING = { +let _growDuration = 0.5 +let _collapsibleDuration = 0.3 + +export const GROW_SPRING = { type: "spring" as const, - visualDuration: COLLAPSIBLE_CONTENT_HEIGHT_DURATION, + get visualDuration() { + return _growDuration + }, bounce: 0, } -export const FADE_SPRING = { +export const COLLAPSIBLE_SPRING = { type: "spring" as const, - visualDuration: FADE_DURATION, + get visualDuration() { + return _collapsibleDuration + }, bounce: 0, } -export const COLLAPSIBLE_CONTENT_FADE_SPRING = { - type: "spring" as const, - visualDuration: COLLAPSIBLE_CONTENT_FADE_DURATION, - 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, diff --git a/packages/ui/src/components/text-reveal.tsx b/packages/ui/src/components/text-reveal.tsx index a5c2ca5249a..1f26d56f6b7 100644 --- a/packages/ui/src/components/text-reveal.tsx +++ b/packages/ui/src/components/text-reveal.tsx @@ -1,5 +1,5 @@ import { createEffect, createSignal, on, onCleanup, onMount } from "solid-js" -import { animate, type AnimationPlaybackControls, clearFadeStyles, clearMaskStyles, FADE_SPRING, WIPE_MASK } from "./motion" +import { animate, type AnimationPlaybackControls, clearFadeStyles, clearMaskStyles, GROW_SPRING, WIPE_MASK } from "./motion" const px = (value: number | string | undefined, fallback: number) => { if (typeof value === "number") return `${value}px` @@ -181,12 +181,12 @@ export function TextWipe(props: { text?: string; class?: string; delay?: number; ? animate( node, { opacity: 1, filter: "blur(0px)", transform: "translateX(0)", maskPosition: "0% 0%" }, - { ...FADE_SPRING, delay: props.delay ?? 0 }, + { ...GROW_SPRING, delay: props.delay ?? 0 }, ) : animate( node, { opacity: 1, filter: "blur(0px)", transform: "translateX(0)" }, - { ...FADE_SPRING, delay: props.delay ?? 0 }, + { ...GROW_SPRING, delay: props.delay ?? 0 }, ) anim?.finished.then(() => { diff --git a/packages/ui/src/components/tool-status-title.tsx b/packages/ui/src/components/tool-status-title.tsx index 47e522838a3..d6f4e558415 100644 --- a/packages/ui/src/components/tool-status-title.tsx +++ b/packages/ui/src/components/tool-status-title.tsx @@ -1,5 +1,5 @@ import { Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js" -import { animate, type AnimationPlaybackControls, HEIGHT_SPRING } from "./motion" +import { animate, type AnimationPlaybackControls, GROW_SPRING } from "./motion" import { TextShimmer } from "./text-shimmer" import { commonPrefix } from "./text-utils" @@ -66,7 +66,7 @@ export function ToolStatusTitle(props: { ref.style.width = `${prev}px` widthAnim?.stop() - widthAnim = animate(ref, { width: `${next}px` }, HEIGHT_SPRING) + widthAnim = animate(ref, { width: `${next}px` }, GROW_SPRING) widthAnim.finished.then(() => { const el = node() if (!el) return From 92534bbabde34c6a938068c63b54f3da3d61eeed Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 4 Mar 2026 11:01:34 -0500 Subject: [PATCH 60/76] refactor(ui): remove assistant text part copy/meta UI and use NBSPs in assistant meta separators --- packages/ui/src/components/message-part.tsx | 76 --------------------- 1 file changed, 76 deletions(-) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 65f3aecf825..6b1953bee15 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1222,49 +1222,10 @@ PART_MAPPING["compaction"] = function CompactionPartDisplay() { } PART_MAPPING["text"] = function TextPartDisplay(props) { - const data = useData() - const i18n = useI18n() const part = () => props.part as TextPart - const interrupted = createMemo( - () => - props.message.role === "assistant" && (props.message as AssistantMessage).error?.name === "MessageAbortedError", - ) - - const model = createMemo(() => { - if (props.message.role !== "assistant") return "" - const message = props.message as AssistantMessage - const match = data.store.provider?.all?.find((p) => p.id === message.providerID) - return match?.models?.[message.modelID]?.name ?? message.modelID - }) - - const duration = createMemo(() => { - if (props.message.role !== "assistant") return "" - const message = props.message as AssistantMessage - const completed = message.time.completed - const ms = - typeof props.turnDurationMs === "number" - ? props.turnDurationMs - : typeof completed === "number" - ? completed - message.time.created - : -1 - if (!(ms >= 0)) return "" - const total = Math.round(ms / 1000) - if (total < 60) return `${total}s` - const minutes = Math.floor(total / 60) - const seconds = total % 60 - return `${minutes}m ${seconds}s` - }) - - const meta = createMemo(() => { - if (props.message.role !== "assistant") return "" - const agent = (props.message as AssistantMessage).agent - const items = [agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", model(), duration()] - return items.filter((x) => !!x).join(" · ") - }) const displayText = () => (part().text ?? "").trim() const throttledText = createThrottledValue(displayText) - const [copied, setCopied] = createSignal(false) const summary = createMemo(() => { if (props.message.role !== "assistant") return if (!props.showTurnDiffSummary) return @@ -1272,20 +1233,6 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { return props.turnDiffSummary }) - const showCopy = createMemo(() => { - if (props.message.role !== "assistant") return true - if (props.working) return false - return props.showAssistantCopyPartID === part().id - }) - - const handleCopy = async () => { - const content = displayText() - if (!content) return - await navigator.clipboard.writeText(content) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - } - return (
@@ -1299,29 +1246,6 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { )} - -
- - e.preventDefault()} - onClick={handleCopy} - aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} - /> - - - - {meta()} - - -
-
) From b7be8787fb92d6cc848c59476c3fa9d880e346e2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 4 Mar 2026 11:17:02 -0500 Subject: [PATCH 61/76] fix(ui): smooth session turn handoff to hover meta --- packages/ui/src/components/message-part.css | 33 ---- packages/ui/src/components/message-part.tsx | 181 ++------------------ packages/ui/src/components/session-turn.css | 72 +++++++- packages/ui/src/components/session-turn.tsx | 149 ++++++++++++++-- 4 files changed, 213 insertions(+), 222 deletions(-) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index f11e83ffaff..72ee17997b4 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -195,7 +195,6 @@ opacity: 1; pointer-events: auto; } - } [data-component="text-part"] { @@ -212,37 +211,6 @@ min-width: 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] { - gap: 12px; - } - - &:hover [data-slot="text-part-copy-wrapper"], - &:focus-within [data-slot="text-part-copy-wrapper"] { - opacity: 1; - pointer-events: auto; - } - [data-component="markdown"] { margin-top: 0; font-size: var(--font-size-base); @@ -628,7 +596,6 @@ flex-shrink: 1; min-width: 0; } - } [data-component="context-tool-step"] { diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 6b1953bee15..616dca72df5 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -99,17 +99,6 @@ function DiagnosticsDisplay(props: { diagnostics: Diagnostic[] }): JSX.Element { ) } -export interface MessageProps { - message: MessageType - parts: PartType[] - showAssistantCopyPartID?: string | null - interrupted?: boolean - animate?: boolean - queued?: boolean - working?: boolean - showReasoningSummaries?: boolean -} - export interface MessagePartProps { part: PartType message: MessageType @@ -118,7 +107,6 @@ export interface MessagePartProps { showAssistantCopyPartID?: string | null showTurnDiffSummary?: boolean turnDiffSummary?: () => JSX.Element - turnDurationMs?: number animate?: boolean working?: boolean } @@ -395,7 +383,6 @@ export function AssistantParts(props: { showAssistantCopyPartID?: string | null showTurnDiffSummary?: boolean turnDiffSummary?: () => JSX.Element - turnDurationMs?: number working?: boolean showReasoningSummaries?: boolean shellToolDefaultOpen?: boolean @@ -578,7 +565,6 @@ export function AssistantParts(props: { showAssistantCopyPartID={props.showAssistantCopyPartID} showTurnDiffSummary={props.showTurnDiffSummary} turnDiffSummary={props.turnDiffSummary} - turnDurationMs={props.turnDurationMs} defaultOpen={partDefaultOpen(entry().part, props.shellToolDefaultOpen, props.editToolDefaultOpen)} hideDetails={entry().context} animate={props.animate} @@ -614,162 +600,22 @@ export function registerPartComponent(type: string, component: PartComponent) { PART_MAPPING[type] = component } -export function Message(props: MessageProps) { - return ( - - - {(userMessage) => ( - - )} - - - {(assistantMessage) => ( - - )} - - - ) -} - -export function AssistantMessageDisplay(props: { - message: AssistantMessage +/** @deprecated Only renders user messages — assistant branch was dead code. Caller can use UserMessageDisplay directly. */ +export function Message(props: { + message: MessageType parts: PartType[] - showAssistantCopyPartID?: string | null - showTurnDiffSummary?: boolean - turnDiffSummary?: () => JSX.Element - working?: boolean - showReasoningSummaries?: boolean + interrupted?: boolean + animate?: boolean + queued?: boolean }) { - const groupState = createGroupOpenState() - const grouped = createMemo(() => { - const keys: string[] = [] - const items: Record< - string, - | { - type: "part" - part: PartType - context?: boolean - groupKey?: string - afterTool?: boolean - groupTail?: boolean - groupParts?: ToolPart[] - } - | { type: "context"; groupKey: string; parts: ToolPart[]; tail: boolean; afterTool: boolean } - > = {} - const push = (key: string, item: (typeof items)[string]) => { - keys.push(key) - items[key] = item - } - const parts = props.parts.filter((part) => renderable(part, props.showReasoningSummaries ?? true)) - let start = -1 - const flush = (end: number, tail: boolean, afterTool: boolean) => { - if (start < 0) return - const group = parts.slice(start, end + 1).filter((part): part is ToolPart => isContextGroupTool(part)) - if (!group.length) { - start = -1 - return - } - const groupKey = `context:${group[0].id}` - push(groupKey, { type: "context", groupKey, parts: group, tail, afterTool }) - group.forEach((part) => - push(`part:${part.id}`, { - type: "part", - part, - context: true, - groupKey, - afterTool, - groupTail: tail, - groupParts: group, - }), - ) - start = -1 - } - - parts.forEach((part, index) => { - if (isContextGroupTool(part)) { - if (start < 0) start = index - return - } - flush(index - 1, false, (part as PartType).type === "tool") - push(`part:${part.id}`, { type: "part", part }) - }) - - flush(parts.length - 1, true, false) - return { keys, items } - }) - return ( - - {(key) => { - const item = createMemo(() => grouped().items[key]) - const ctx = createMemo(() => { - const value = item() - if (!value) return - if (value.type !== "context") return - return value - }) - const part = createMemo(() => { - const value = item() - if (!value) return - if (value.type !== "part") return - return value - }) - const contextOpen = createMemo(() => { - const collapse = (afterTool?: boolean, groupTail?: boolean, group?: ToolPart[]) => - shouldCollapseGroup(group?.map((part) => part.state.status) ?? [], { - afterTool, - groupTail, - working: props.working, - }) - const value = ctx() - if (value) return groupState.read(value.groupKey, collapse(value.afterTool, value.tail, value.parts)) - const entry = part() - return groupState.read(entry?.groupKey, collapse(entry?.afterTool, entry?.groupTail, entry?.groupParts)) - }) - return ( - <> - - {(entry) => ( - groupState.write(entry().groupKey, value)} - /> - )} - - - {(entry) => ( - -
- -
-
- )} -
- - ) - }} -
+ ) } @@ -1048,7 +894,6 @@ export function Part(props: MessagePartProps) { showAssistantCopyPartID={props.showAssistantCopyPartID} showTurnDiffSummary={props.showTurnDiffSummary} turnDiffSummary={props.turnDiffSummary} - turnDurationMs={props.turnDurationMs} animate={props.animate} working={props.working} /> diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index b128e2f10fd..d85c8d5013b 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -76,15 +76,83 @@ } } - [data-slot="session-turn-thinking-wrap"] { + [data-slot="session-turn-handoff-wrap"] { width: 100%; min-width: 0; overflow: visible; } - [data-slot="session-turn-thinking"] { + [data-slot="session-turn-handoff"] { + width: 100%; + min-width: 0; + min-height: 36px; 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: 36px; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 10px; + opacity: 0; + filter: blur(2px); + transform: translateY(-1px); + pointer-events: none; + transition: + opacity 180ms ease-out, + filter 180ms ease-out, + transform 180ms ease-out; + } + + [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; + filter: blur(0px); + transform: translateY(0px); + 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 { diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index dee887c8390..fc89dd24fbe 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -15,10 +15,12 @@ import { StickyAccordionHeader } from "./sticky-accordion-header" import { Collapsible } from "./collapsible" import { DiffChanges } from "./diff-changes" import { Icon } from "./icon" +import { IconButton } from "./icon-button" import { TextShimmer } from "./text-shimmer" import { TextReveal } from "./text-reveal" import { list } from "./text-utils" import { SessionRetry } from "./session-retry" +import { Tooltip } from "./tooltip" import { createAutoScroll } from "../hooks" import { useI18n } from "../context/i18n" function record(value: unknown): value is Record { @@ -80,12 +82,12 @@ function same(a: readonly T[], b: readonly T[]) { return a.every((x, i) => x === b[i]) } - 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") { @@ -253,7 +255,7 @@ export function SessionTurn( const error = createMemo( () => assistantMessages().find((m) => m.error && m.error.name !== "MessageAbortedError")?.error, ) - const assistantCopyPartID = createMemo(() => { + const assistantCopyPart = createMemo(() => { const messages = assistantMessages() for (let i = messages.length - 1; i >= 0; i--) { @@ -263,13 +265,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 null }) + const assistantCopyPartID = createMemo(() => assistantCopyPart()?.id ?? null) const errorText = createMemo(() => { const msg = error()?.data?.message if (typeof msg === "string") return unwrap(msg) @@ -332,12 +339,55 @@ export function SessionTurn( return true }) const hasAssistant = createMemo(() => assistantMessages().length > 0) - const lane = createMemo(() => hasAssistant() || thinking()) 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(" · ") + }) + 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 | undefined + let handoffTimer: ReturnType | 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( @@ -356,6 +406,35 @@ export function SessionTurn( ), ) + 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, onUserInteracted: props.onUserInteracted, @@ -364,6 +443,8 @@ export function SessionTurn( onCleanup(() => { if (liveFrame !== undefined) cancelAnimationFrame(liveFrame) + if (copiedTimer !== undefined) clearTimeout(copiedTimer) + if (handoffTimer !== undefined) clearTimeout(handoffTimer) }) const turnDiffSummary = () => ( @@ -497,7 +578,6 @@ export function SessionTurn( interrupted={interrupted()} animate={props.animate} queued={queued()} - working={working()} />
@@ -521,7 +601,6 @@ export function SessionTurn( showAssistantCopyPartID={assistantCopyPartID()} showTurnDiffSummary={showDiffSummary()} turnDiffSummary={turnDiffSummary} - turnDurationMs={turnDurationMs()} working={working()} animate={live()} showReasoningSummaries={showReasoningSummaries()} @@ -533,18 +612,50 @@ export function SessionTurn( -
- - +
+
+ + +
+ +
+ + event.preventDefault()} + onClick={() => void copyAssistant()} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} + /> + + + + {meta()} + + +
+
From 6082393db3401fe57fabc7806c0f27fb6a841707 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 4 Mar 2026 11:28:36 -0500 Subject: [PATCH 62/76] fix(ui): keep Agent title fixed, animate type label in separately Title no longer mutates from "Agent" to "explore Agent". Instead, the title stays "Agent" and the subagent type (e.g. "Explore") wipe-animates in as a subtitle once known, followed by the description. Updated all i18n files to remove {{type}} from the agent label. --- packages/ui/src/components/message-part.tsx | 19 ++++++++++++------- packages/ui/src/i18n/ar.ts | 2 +- packages/ui/src/i18n/br.ts | 2 +- packages/ui/src/i18n/bs.ts | 2 +- packages/ui/src/i18n/da.ts | 2 +- packages/ui/src/i18n/de.ts | 2 +- packages/ui/src/i18n/en.ts | 2 +- packages/ui/src/i18n/es.ts | 2 +- packages/ui/src/i18n/fr.ts | 2 +- packages/ui/src/i18n/ja.ts | 2 +- packages/ui/src/i18n/ko.ts | 2 +- packages/ui/src/i18n/no.ts | 2 +- packages/ui/src/i18n/pl.ts | 2 +- packages/ui/src/i18n/ru.ts | 2 +- packages/ui/src/i18n/th.ts | 2 +- packages/ui/src/i18n/tr.ts | 2 +- packages/ui/src/i18n/zh.ts | 2 +- packages/ui/src/i18n/zht.ts | 2 +- 18 files changed, 29 insertions(+), 24 deletions(-) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 616dca72df5..12fe087978a 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -213,7 +213,7 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo { case "task": return { icon: "task", - title: i18n.t("ui.tool.agent", { type: input.subagent_type || "task" }), + title: i18n.t("ui.tool.agent"), subtitle: input.description, } case "bash": @@ -1515,7 +1515,11 @@ ToolRegistry.register({ const data = useData() const i18n = useI18n() const childSessionId = () => props.metadata.sessionId as string | undefined - const title = createMemo(() => i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool })) + const agentType = createMemo(() => { + const raw = props.input.subagent_type + if (typeof raw !== "string" || !raw) return undefined + return raw[0]!.toUpperCase() + raw.slice(1) + }) const description = createMemo(() => { const value = props.input.description if (typeof value === "string") return value @@ -1559,14 +1563,15 @@ ToolRegistry.register({ }, 50) } - const titleContent = () => - const trigger = () => (
- - {titleContent()} + + + + {(type) => } + @@ -1575,7 +1580,7 @@ ToolRegistry.register({ )} - + diff --git a/packages/ui/src/i18n/ar.ts b/packages/ui/src/i18n/ar.ts index 065802376f7..3937acb64ac 100644 --- a/packages/ui/src/i18n/ar.ts +++ b/packages/ui/src/i18n/ar.ts @@ -100,7 +100,7 @@ export const dict = { "ui.tool.todos.read": "قراءة المهام", "ui.tool.questions": "أسئلة", "ui.tool.skill": "مهارة", - "ui.tool.agent": "وكيل {{type}}", + "ui.tool.agent": "وكيل", "ui.common.file.one": "ملف", "ui.common.file.other": "ملفات", diff --git a/packages/ui/src/i18n/br.ts b/packages/ui/src/i18n/br.ts index dbd4cb8b79a..1764c8ceb68 100644 --- a/packages/ui/src/i18n/br.ts +++ b/packages/ui/src/i18n/br.ts @@ -100,7 +100,7 @@ export const dict = { "ui.tool.todos.read": "Ler tarefas", "ui.tool.questions": "Perguntas", "ui.tool.skill": "Habilidade", - "ui.tool.agent": "Agente {{type}}", + "ui.tool.agent": "Agente", "ui.common.file.one": "arquivo", "ui.common.file.other": "arquivos", diff --git a/packages/ui/src/i18n/bs.ts b/packages/ui/src/i18n/bs.ts index 615af15cca5..5e22b619b15 100644 --- a/packages/ui/src/i18n/bs.ts +++ b/packages/ui/src/i18n/bs.ts @@ -104,7 +104,7 @@ export const dict = { "ui.tool.todos.read": "Čitanje liste zadataka", "ui.tool.questions": "Pitanja", "ui.tool.skill": "Vještina", - "ui.tool.agent": "{{type}} agent", + "ui.tool.agent": "Agent", "ui.common.file.one": "datoteka", "ui.common.file.other": "datoteke", diff --git a/packages/ui/src/i18n/da.ts b/packages/ui/src/i18n/da.ts index c404134c4d9..b056500c9e6 100644 --- a/packages/ui/src/i18n/da.ts +++ b/packages/ui/src/i18n/da.ts @@ -99,7 +99,7 @@ export const dict = { "ui.tool.todos.read": "Læs opgaver", "ui.tool.questions": "Spørgsmål", "ui.tool.skill": "Færdighed", - "ui.tool.agent": "{{type}} Agent", + "ui.tool.agent": "Agent", "ui.common.file.one": "fil", "ui.common.file.other": "filer", diff --git a/packages/ui/src/i18n/de.ts b/packages/ui/src/i18n/de.ts index 37ce487a2a0..e48bf6f83be 100644 --- a/packages/ui/src/i18n/de.ts +++ b/packages/ui/src/i18n/de.ts @@ -105,7 +105,7 @@ export const dict = { "ui.tool.todos.read": "Aufgaben lesen", "ui.tool.questions": "Fragen", "ui.tool.skill": "Fähigkeit", - "ui.tool.agent": "{{type}} Agent", + "ui.tool.agent": "Agent", "ui.common.file.one": "Datei", "ui.common.file.other": "Dateien", diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 7d6f779745d..8298ec94de6 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -101,7 +101,7 @@ export const dict: Record = { "ui.tool.todos.read": "Read to-dos", "ui.tool.questions": "Questions", "ui.tool.skill": "Skill", - "ui.tool.agent": "{{type}} Agent", + "ui.tool.agent": "Agent", "ui.common.file.one": "file", "ui.common.file.other": "files", diff --git a/packages/ui/src/i18n/es.ts b/packages/ui/src/i18n/es.ts index d88516308bb..6d40040509d 100644 --- a/packages/ui/src/i18n/es.ts +++ b/packages/ui/src/i18n/es.ts @@ -100,7 +100,7 @@ export const dict = { "ui.tool.todos.read": "Leer tareas", "ui.tool.questions": "Preguntas", "ui.tool.skill": "Habilidad", - "ui.tool.agent": "Agente {{type}}", + "ui.tool.agent": "Agente", "ui.common.file.one": "archivo", "ui.common.file.other": "archivos", diff --git a/packages/ui/src/i18n/fr.ts b/packages/ui/src/i18n/fr.ts index 28c54fb0462..f07e13faa86 100644 --- a/packages/ui/src/i18n/fr.ts +++ b/packages/ui/src/i18n/fr.ts @@ -100,7 +100,7 @@ export const dict = { "ui.tool.todos.read": "Lire les tâches", "ui.tool.questions": "Questions", "ui.tool.skill": "Compétence", - "ui.tool.agent": "Agent {{type}}", + "ui.tool.agent": "Agent", "ui.common.file.one": "fichier", "ui.common.file.other": "fichiers", diff --git a/packages/ui/src/i18n/ja.ts b/packages/ui/src/i18n/ja.ts index 7f285d42c26..7bdfa0eb2d0 100644 --- a/packages/ui/src/i18n/ja.ts +++ b/packages/ui/src/i18n/ja.ts @@ -99,7 +99,7 @@ export const dict = { "ui.tool.todos.read": "Todo読み込み", "ui.tool.questions": "質問", "ui.tool.skill": "スキル", - "ui.tool.agent": "{{type}}エージェント", + "ui.tool.agent": "エージェント", "ui.common.file.one": "ファイル", "ui.common.file.other": "ファイル", diff --git a/packages/ui/src/i18n/ko.ts b/packages/ui/src/i18n/ko.ts index a10e88d1aa7..e753e695ad3 100644 --- a/packages/ui/src/i18n/ko.ts +++ b/packages/ui/src/i18n/ko.ts @@ -100,7 +100,7 @@ export const dict = { "ui.tool.todos.read": "할 일 읽기", "ui.tool.questions": "질문", "ui.tool.skill": "스킬", - "ui.tool.agent": "{{type}} 에이전트", + "ui.tool.agent": "에이전트", "ui.common.file.one": "파일", "ui.common.file.other": "파일", diff --git a/packages/ui/src/i18n/no.ts b/packages/ui/src/i18n/no.ts index 54ba01ace0a..26b4b5211db 100644 --- a/packages/ui/src/i18n/no.ts +++ b/packages/ui/src/i18n/no.ts @@ -103,7 +103,7 @@ export const dict: Record = { "ui.tool.todos.read": "Les gjøremål", "ui.tool.questions": "Spørsmål", "ui.tool.skill": "Ferdighet", - "ui.tool.agent": "{{type}}-agent", + "ui.tool.agent": "Agent", "ui.common.file.one": "fil", "ui.common.file.other": "filer", diff --git a/packages/ui/src/i18n/pl.ts b/packages/ui/src/i18n/pl.ts index 4e43bb6d286..e8d12f7e41e 100644 --- a/packages/ui/src/i18n/pl.ts +++ b/packages/ui/src/i18n/pl.ts @@ -99,7 +99,7 @@ export const dict = { "ui.tool.todos.read": "Czytaj zadania", "ui.tool.questions": "Pytania", "ui.tool.skill": "Umiejętność", - "ui.tool.agent": "Agent {{type}}", + "ui.tool.agent": "Agent", "ui.common.file.one": "plik", "ui.common.file.other": "pliki", diff --git a/packages/ui/src/i18n/ru.ts b/packages/ui/src/i18n/ru.ts index 371e4f8fffc..29e0a119a20 100644 --- a/packages/ui/src/i18n/ru.ts +++ b/packages/ui/src/i18n/ru.ts @@ -99,7 +99,7 @@ export const dict = { "ui.tool.todos.read": "Читать задачи", "ui.tool.questions": "Вопросы", "ui.tool.skill": "Навык", - "ui.tool.agent": "Агент {{type}}", + "ui.tool.agent": "Агент", "ui.common.file.one": "файл", "ui.common.file.other": "файлов", diff --git a/packages/ui/src/i18n/th.ts b/packages/ui/src/i18n/th.ts index 55ccf9af67d..af76db88394 100644 --- a/packages/ui/src/i18n/th.ts +++ b/packages/ui/src/i18n/th.ts @@ -101,7 +101,7 @@ export const dict = { "ui.tool.todos.read": "อ่านรายการงาน", "ui.tool.questions": "คำถาม", "ui.tool.skill": "ทักษะ", - "ui.tool.agent": "เอเจนต์ {{type}}", + "ui.tool.agent": "เอเจนต์", "ui.common.file.one": "ไฟล์", "ui.common.file.other": "ไฟล์", diff --git a/packages/ui/src/i18n/tr.ts b/packages/ui/src/i18n/tr.ts index 22f31b84fe0..415ef47a932 100644 --- a/packages/ui/src/i18n/tr.ts +++ b/packages/ui/src/i18n/tr.ts @@ -96,7 +96,7 @@ export const dict = { "ui.tool.todos.read": "Görevleri oku", "ui.tool.questions": "Sorular", "ui.tool.skill": "Beceri", - "ui.tool.agent": "{{type}} Ajan", + "ui.tool.agent": "Ajan", "ui.common.file.one": "dosya", "ui.common.file.other": "dosya", diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index fbe5c4f67cb..51541b87ce9 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -104,7 +104,7 @@ export const dict = { "ui.tool.todos.read": "读取待办", "ui.tool.questions": "问题", "ui.tool.skill": "技能", - "ui.tool.agent": "{{type}} 智能体", + "ui.tool.agent": "智能体", "ui.common.file.one": "个文件", "ui.common.file.other": "个文件", diff --git a/packages/ui/src/i18n/zht.ts b/packages/ui/src/i18n/zht.ts index fb345153bf2..2ec36d00e62 100644 --- a/packages/ui/src/i18n/zht.ts +++ b/packages/ui/src/i18n/zht.ts @@ -104,7 +104,7 @@ export const dict = { "ui.tool.todos.read": "讀取待辦", "ui.tool.questions": "問題", "ui.tool.skill": "技能", - "ui.tool.agent": "{{type}} 代理程式", + "ui.tool.agent": "代理程式", "ui.common.file.one": "個檔案", "ui.common.file.other": "個檔案", From a2fb4ca322ec969e5548fd7bddd0da0585703c44 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 4 Mar 2026 13:55:56 -0500 Subject: [PATCH 63/76] fix(ui): speed up text shimmer and remove idle glow pulse --- packages/ui/src/components/text-shimmer.tsx | 28 +++------------------ 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/packages/ui/src/components/text-shimmer.tsx b/packages/ui/src/components/text-shimmer.tsx index 3c9599095d4..5d3dee1ebc4 100644 --- a/packages/ui/src/components/text-shimmer.tsx +++ b/packages/ui/src/components/text-shimmer.tsx @@ -1,6 +1,5 @@ -import { createEffect, createMemo, createSignal, on, onCleanup, type ValidComponent } from "solid-js" +import { createEffect, createMemo, createSignal, onCleanup, type ValidComponent } from "solid-js" import { Dynamic } from "solid-js/web" -import { animate, type AnimationPlaybackControls, GLOW_SPRING } from "./motion" export const TextShimmer = (props: { text: string @@ -32,28 +31,7 @@ export const TextShimmer = (props: { }, swap) }) - let baseRef: HTMLSpanElement | undefined - let glowAnim: AnimationPlaybackControls | undefined - - // Glow pulse when shimmer deactivates - createEffect( - on(active, (isActive) => { - if (isActive || !baseRef) return - glowAnim?.stop() - glowAnim = animate( - baseRef, - { filter: ["brightness(1.5)", "brightness(1)"] }, - GLOW_SPRING, - ) - glowAnim.finished.then(() => { - if (!baseRef) return - baseRef.style.filter = "" - }) - }, { defer: true }), - ) - onCleanup(() => { - glowAnim?.stop() if (!timer) return clearTimeout(timer) }) @@ -64,7 +42,7 @@ export const TextShimmer = (props: { }) // duration = len × (size - 1) / velocity → uniform perceived sweep speed - const VELOCITY = 0.0125 // ch per ms, calibrated to "Shell" at 600%/2000ms + const VELOCITY = 0.01375 // ch per ms, ~10% faster than original 0.0125 baseline const shimmerDuration = createMemo(() => { const len = Math.max(props.text.length, 1) const s = shimmerSize() / 100 @@ -86,7 +64,7 @@ export const TextShimmer = (props: { }} > -