summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-03-07 06:25:22 -0500
committerGitHub <[email protected]>2026-03-07 05:25:22 -0600
commitbbd0f3a25283b6f9567a04e79d7f6972950ab0a6 (patch)
tree56cb41ff9c67749c6fa894fef32bb14cefd73db3 /packages
parentb7e208b4f1e6641a1cbb1e13f59789c7b7f4c60a (diff)
downloadopencode-bbd0f3a25283b6f9567a04e79d7f6972950ab0a6.tar.gz
opencode-bbd0f3a25283b6f9567a04e79d7f6972950ab0a6.zip
STUPID SEXY TIMELINE (#16420)
Diffstat (limited to 'packages')
-rw-r--r--packages/app/e2e/actions.ts7
-rw-r--r--packages/app/e2e/selectors.ts2
-rw-r--r--packages/app/e2e/session/session.spec.ts12
-rw-r--r--packages/app/src/pages/session.tsx21
-rw-r--r--packages/app/src/pages/session/message-timeline.tsx501
-rw-r--r--packages/app/src/pages/session/session-timeline-header.tsx522
-rw-r--r--packages/app/src/pages/session/use-session-hash-scroll.ts52
-rw-r--r--packages/ui/src/components/animated-number.css11
-rw-r--r--packages/ui/src/components/animated-number.tsx29
-rw-r--r--packages/ui/src/components/basic-tool.css59
-rw-r--r--packages/ui/src/components/basic-tool.tsx356
-rw-r--r--packages/ui/src/components/collapsible.css55
-rw-r--r--packages/ui/src/components/context-tool-results.tsx199
-rw-r--r--packages/ui/src/components/grow-box.tsx426
-rw-r--r--packages/ui/src/components/message-part.css531
-rw-r--r--packages/ui/src/components/message-part.tsx1888
-rw-r--r--packages/ui/src/components/motion-spring.tsx32
-rw-r--r--packages/ui/src/components/motion.tsx77
-rw-r--r--packages/ui/src/components/rolling-results.css92
-rw-r--r--packages/ui/src/components/rolling-results.tsx326
-rw-r--r--packages/ui/src/components/scroll-view.css15
-rw-r--r--packages/ui/src/components/scroll-view.tsx66
-rw-r--r--packages/ui/src/components/session-turn.css127
-rw-r--r--packages/ui/src/components/session-turn.tsx554
-rw-r--r--packages/ui/src/components/shell-rolling-results.tsx310
-rw-r--r--packages/ui/src/components/shell-submessage.css12
-rw-r--r--packages/ui/src/components/text-reveal.css61
-rw-r--r--packages/ui/src/components/text-reveal.tsx107
-rw-r--r--packages/ui/src/components/text-shimmer.css17
-rw-r--r--packages/ui/src/components/text-shimmer.tsx15
-rw-r--r--packages/ui/src/components/text-utils.ts17
-rw-r--r--packages/ui/src/components/tool-count-label.css6
-rw-r--r--packages/ui/src/components/tool-count-label.tsx21
-rw-r--r--packages/ui/src/components/tool-count-summary.css22
-rw-r--r--packages/ui/src/components/tool-status-title.css7
-rw-r--r--packages/ui/src/components/tool-status-title.tsx67
-rw-r--r--packages/ui/src/components/tool-utils.ts325
-rw-r--r--packages/ui/src/hooks/create-auto-scroll.tsx245
-rw-r--r--packages/ui/src/hooks/index.ts3
-rw-r--r--packages/ui/src/hooks/use-element-height.ts25
-rw-r--r--packages/ui/src/hooks/use-page-visible.ts11
-rw-r--r--packages/ui/src/hooks/use-reduced-motion.ts9
-rw-r--r--packages/ui/src/styles/index.css1
-rw-r--r--packages/util/src/array.ts7
44 files changed, 5177 insertions, 2071 deletions
diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts
index 86147dc65..90a449d50 100644
--- a/packages/app/e2e/actions.ts
+++ b/packages/app/e2e/actions.ts
@@ -7,6 +7,7 @@ import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
import {
dropdownMenuTriggerSelector,
dropdownMenuContentSelector,
+ sessionTimelineHeaderSelector,
projectMenuTriggerSelector,
projectCloseMenuSelector,
projectWorkspacesToggleSelector,
@@ -243,7 +244,9 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) {
const scroller = page.locator(".scroll-view__viewport").first()
await expect(scroller).toBeVisible()
- await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
+ const header = page.locator(sessionTimelineHeaderSelector).first()
+ await expect(header).toBeVisible({ timeout: 30_000 })
+ await expect(header.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
const menu = page
.locator(dropdownMenuContentSelector)
@@ -259,7 +262,7 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) {
if (opened) return menu
- const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
+ const menuTrigger = header.getByRole("button", { name: /more options/i }).first()
await expect(menuTrigger).toBeVisible()
await menuTrigger.click()
diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts
index 5fad2c06b..fd4177fef 100644
--- a/packages/app/e2e/selectors.ts
+++ b/packages/app/e2e/selectors.ts
@@ -53,6 +53,8 @@ export const dropdownMenuContentSelector = '[data-component="dropdown-menu-conte
export const inlineInputSelector = '[data-component="inline-input"]'
+export const sessionTimelineHeaderSelector = "[data-session-title]"
+
export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
export const workspaceItemSelector = (slug: string) =>
diff --git a/packages/app/e2e/session/session.spec.ts b/packages/app/e2e/session/session.spec.ts
index 68d992949..e541738c5 100644
--- a/packages/app/e2e/session/session.spec.ts
+++ b/packages/app/e2e/session/session.spec.ts
@@ -7,7 +7,7 @@ import {
openSharePopover,
withSession,
} from "../actions"
-import { sessionItemSelector, inlineInputSelector } from "../selectors"
+import { sessionItemSelector, inlineInputSelector, sessionTimelineHeaderSelector } from "../selectors"
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
@@ -39,12 +39,14 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
await withSession(sdk, originalTitle, async (session) => {
await seedMessage(sdk, session.id)
await gotoSession(session.id)
- await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
+ await expect(page.locator(sessionTimelineHeaderSelector).getByRole("heading", { level: 1 }).first()).toHaveText(
+ originalTitle,
+ )
const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /rename/i)
- const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
+ const input = page.locator(sessionTimelineHeaderSelector).locator(inlineInputSelector).first()
await expect(input).toBeVisible()
await expect(input).toBeFocused()
await input.fill(renamedTitle)
@@ -61,7 +63,9 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
)
.toBe(renamedTitle)
- await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
+ await expect(page.locator(sessionTimelineHeaderSelector).getByRole("heading", { level: 1 }).first()).toHaveText(
+ renamedTitle,
+ )
})
})
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 82a581e68..578dadecf 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -121,13 +121,9 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
return
}
const beforeTop = el.scrollTop
- const beforeHeight = el.scrollHeight
fn()
- requestAnimationFrame(() => {
- const delta = el.scrollHeight - beforeHeight
- if (!delta) return
- el.scrollTop = beforeTop + delta
- })
+ void el.scrollHeight
+ el.scrollTop = beforeTop
}
const backfillTurns = () => {
@@ -210,7 +206,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
if (!input.userScrolled()) return
const el = input.scroller()
if (!el) return
- if (el.scrollTop >= turnScrollThreshold) return
+ if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return
const start = turnStart()
if (start > 0) {
@@ -1110,7 +1106,7 @@ export default function Page() {
const updateScrollState = (el: HTMLDivElement) => {
const max = el.scrollHeight - el.clientHeight
const overflow = max > 1
- const bottom = !overflow || el.scrollTop >= max - 2
+ const bottom = !overflow || Math.abs(el.scrollTop) <= 2 || !autoScroll.userScrolled()
if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return
setUi("scroll", { overflow, bottom })
@@ -1133,7 +1129,7 @@ export default function Page() {
const resumeScroll = () => {
setStore("messageId", undefined)
- autoScroll.forceScrollToBottom()
+ autoScroll.smoothScrollToBottom()
clearMessageHash()
const el = scroller
@@ -1201,13 +1197,11 @@ export default function Page() {
const el = scroller
const delta = next - dockHeight
- const stick = el
- ? !autoScroll.userScrolled() || el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta)
- : false
+ const stick = el ? Math.abs(el.scrollTop) < 10 + Math.max(0, delta) : false
dockHeight = next
- if (stick) autoScroll.forceScrollToBottom()
+ if (stick) autoScroll.smoothScrollToBottom()
if (el) scheduleScrollState(el)
scrollSpy.markDirty()
@@ -1293,6 +1287,7 @@ export default function Page() {
onScrollSpyScroll={scrollSpy.onScroll}
onTurnBackfillScroll={historyWindow.onScrollerScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
+ onPreserveScrollAnchor={autoScroll.preserve}
centered={centered()}
setContentRef={(el) => {
content = el
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
index ce6a01378..938ff4fbd 100644
--- a/packages/app/src/pages/session/message-timeline.tsx
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -1,27 +1,31 @@
-import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js"
-import { createStore, produce } from "solid-js/store"
-import { useNavigate, useParams } from "@solidjs/router"
+import {
+ For,
+ Index,
+ createEffect,
+ createMemo,
+ createSignal,
+ on,
+ onCleanup,
+ Show,
+ startTransition,
+ type JSX,
+} from "solid-js"
+import { createStore } from "solid-js/store"
+import { useParams } from "@solidjs/router"
import { Button } from "@opencode-ai/ui/button"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
-import { IconButton } from "@opencode-ai/ui/icon-button"
-import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
-import { Dialog } from "@opencode-ai/ui/dialog"
-import { InlineInput } from "@opencode-ai/ui/inline-input"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
-import { showToast } from "@opencode-ai/ui/toast"
import { Binary } from "@opencode-ai/util/binary"
import { getFilename } from "@opencode-ai/util/path"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
-import { SessionContextUsage } from "@/components/session-context-usage"
-import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
-import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
+import { SessionTimelineHeader } from "@/pages/session/session-timeline-header"
type MessageComment = {
path: string
@@ -33,7 +37,9 @@ type MessageComment = {
}
const emptyMessages: MessageType[] = []
-const idle = { type: "idle" as const }
+
+const isDefaultSessionTitle = (title?: string) =>
+ !!title && /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title)
const messageComments = (parts: Part[]): MessageComment[] =>
parts.flatMap((part) => {
@@ -110,6 +116,8 @@ function createTimelineStaging(input: TimelineStageInput) {
completedSession: "",
count: 0,
})
+ const [readySession, setReadySession] = createSignal("")
+ let active = ""
const stagedCount = createMemo(() => {
const total = input.messages().length
@@ -134,23 +142,46 @@ function createTimelineStaging(input: TimelineStageInput) {
cancelAnimationFrame(frame)
frame = undefined
}
+ const scheduleReady = (sessionKey: string) => {
+ if (input.sessionKey() !== sessionKey) return
+ if (readySession() === sessionKey) return
+ setReadySession(sessionKey)
+ }
createEffect(
on(
() => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const,
([sessionKey, isWindowed, total]) => {
+ const switched = active !== sessionKey
+ if (switched) {
+ active = sessionKey
+ setReadySession("")
+ }
+
+ const staging = state.activeSession === sessionKey && state.completedSession !== sessionKey
+ const shouldStage = isWindowed && total > input.config.init && state.completedSession !== sessionKey
+
+ if (staging && !switched && shouldStage && frame !== undefined) return
+
cancel()
- const shouldStage =
- isWindowed &&
- total > input.config.init &&
- state.completedSession !== sessionKey &&
- state.activeSession !== sessionKey
+
+ if (shouldStage) setReadySession("")
if (!shouldStage) {
- setState({ activeSession: "", count: total })
+ setState({
+ activeSession: "",
+ completedSession: isWindowed ? sessionKey : state.completedSession,
+ count: total,
+ })
+ if (total <= 0) {
+ setReadySession("")
+ return
+ }
+ if (readySession() !== sessionKey) scheduleReady(sessionKey)
return
}
let count = Math.min(total, input.config.init)
+ if (staging) count = Math.min(total, Math.max(count, state.count))
setState({ activeSession: sessionKey, count })
const step = () => {
@@ -160,10 +191,11 @@ function createTimelineStaging(input: TimelineStageInput) {
}
const currentTotal = input.messages().length
count = Math.min(currentTotal, count + input.config.batch)
- setState("count", count)
+ startTransition(() => setState("count", count))
if (count >= currentTotal) {
setState({ completedSession: sessionKey, activeSession: "" })
frame = undefined
+ scheduleReady(sessionKey)
return
}
frame = requestAnimationFrame(step)
@@ -177,9 +209,12 @@ function createTimelineStaging(input: TimelineStageInput) {
const key = input.sessionKey()
return state.activeSession === key && state.completedSession !== key
})
+ const ready = createMemo(() => readySession() === input.sessionKey())
- onCleanup(cancel)
- return { messages: stagedUserMessages, isStaging }
+ onCleanup(() => {
+ cancel()
+ })
+ return { messages: stagedUserMessages, isStaging, ready }
}
export function MessageTimeline(props: {
@@ -196,6 +231,7 @@ export function MessageTimeline(props: {
onScrollSpyScroll: () => void
onTurnBackfillScroll: () => void
onAutoScrollInteraction: (event: MouseEvent) => void
+ onPreserveScrollAnchor: (target: HTMLElement) => void
centered: boolean
setContentRef: (el: HTMLDivElement) => void
turnStart: number
@@ -210,14 +246,19 @@ export function MessageTimeline(props: {
let touchGesture: number | undefined
const params = useParams()
- const navigate = useNavigate()
- const sdk = useSDK()
const sync = useSync()
const settings = useSettings()
- const dialog = useDialog()
const language = useLanguage()
- const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
+ const trigger = (target: EventTarget | null) => {
+ const next =
+ target instanceof Element
+ ? target.closest('[data-slot="collapsible-trigger"], [data-slot="accordion-trigger"], [data-scroll-preserve]')
+ : undefined
+ if (!(next instanceof HTMLElement)) return
+ return next
+ }
+
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const sessionID = createMemo(() => params.id)
const sessionMessages = createMemo(() => {
@@ -230,28 +271,20 @@ export function MessageTimeline(props: {
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
),
)
- const sessionStatus = createMemo(() => {
- const id = sessionID()
- if (!id) return idle
- return sync.data.session_status[id] ?? idle
- })
+ const sessionStatus = createMemo(() => sync.data.session_status[sessionID() ?? ""]?.type ?? "idle")
const activeMessageID = createMemo(() => {
- const parentID = pending()?.parentID
- if (parentID) {
- const messages = sessionMessages()
- const result = Binary.search(messages, parentID, (message) => message.id)
- const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID)
- if (message && message.role === "user") return message.id
+ const messages = sessionMessages()
+ const message = pending()
+ if (message?.parentID) {
+ const result = Binary.search(messages, message.parentID, (item) => item.id)
+ const parent = result.found ? messages[result.index] : messages.find((item) => item.id === message.parentID)
+ if (parent?.role === "user") return parent.id
}
- const status = sessionStatus()
- if (status.type !== "idle") {
- const messages = sessionMessages()
- for (let i = messages.length - 1; i >= 0; i--) {
- if (messages[i].role === "user") return messages[i].id
- }
+ if (sessionStatus() === "idle") return undefined
+ for (let i = messages.length - 1; i >= 0; i--) {
+ if (messages[i].role === "user") return messages[i].id
}
-
return undefined
})
const info = createMemo(() => {
@@ -259,9 +292,19 @@ export function MessageTimeline(props: {
if (!id) return
return sync.session.get(id)
})
- const titleValue = createMemo(() => info()?.title)
+ const titleValue = createMemo(() => {
+ const title = info()?.title
+ if (!title) return
+ if (isDefaultSessionTitle(title)) return language.t("command.session.new")
+ return title
+ })
+ const defaultTitle = createMemo(() => isDefaultSessionTitle(info()?.title))
+ const headerTitle = createMemo(
+ () => titleValue() ?? (props.renderedUserMessages.length ? language.t("command.session.new") : undefined),
+ )
+ const placeholderTitle = createMemo(() => defaultTitle() || (!info()?.title && props.renderedUserMessages.length > 0))
const parentID = createMemo(() => info()?.parentID)
- const showHeader = createMemo(() => !!(titleValue() || parentID()))
+ const showHeader = createMemo(() => !!(headerTitle() || parentID()))
const stageCfg = { init: 1, batch: 3 }
const staging = createTimelineStaging({
sessionKey,
@@ -269,212 +312,7 @@ export function MessageTimeline(props: {
messages: () => props.renderedUserMessages,
config: stageCfg,
})
-
- const [title, setTitle] = createStore({
- draft: "",
- editing: false,
- saving: false,
- menuOpen: false,
- pendingRename: false,
- })
- let titleRef: HTMLInputElement | undefined
-
- const errorMessage = (err: unknown) => {
- if (err && typeof err === "object" && "data" in err) {
- const data = (err as { data?: { message?: string } }).data
- if (data?.message) return data.message
- }
- if (err instanceof Error) return err.message
- return language.t("common.requestFailed")
- }
-
- createEffect(
- on(
- sessionKey,
- () => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
- { defer: true },
- ),
- )
-
- const openTitleEditor = () => {
- if (!sessionID()) return
- setTitle({ editing: true, draft: titleValue() ?? "" })
- requestAnimationFrame(() => {
- titleRef?.focus()
- titleRef?.select()
- })
- }
-
- const closeTitleEditor = () => {
- if (title.saving) return
- setTitle({ editing: false, saving: false })
- }
-
- const saveTitleEditor = async () => {
- const id = sessionID()
- if (!id) return
- if (title.saving) return
-
- const next = title.draft.trim()
- if (!next || next === (titleValue() ?? "")) {
- setTitle({ editing: false, saving: false })
- return
- }
-
- setTitle("saving", true)
- await sdk.client.session
- .update({ sessionID: id, title: next })
- .then(() => {
- sync.set(
- produce((draft) => {
- const index = draft.session.findIndex((s) => s.id === id)
- if (index !== -1) draft.session[index].title = next
- }),
- )
- setTitle({ editing: false, saving: false })
- })
- .catch((err) => {
- setTitle("saving", false)
- showToast({
- title: language.t("common.requestFailed"),
- description: errorMessage(err),
- })
- })
- }
-
- const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
- if (params.id !== sessionID) return
- if (parentID) {
- navigate(`/${params.dir}/session/${parentID}`)
- return
- }
- if (nextSessionID) {
- navigate(`/${params.dir}/session/${nextSessionID}`)
- return
- }
- navigate(`/${params.dir}/session`)
- }
-
- const archiveSession = async (sessionID: string) => {
- const session = sync.session.get(sessionID)
- if (!session) return
-
- const sessions = sync.data.session ?? []
- const index = sessions.findIndex((s) => s.id === sessionID)
- const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
-
- await sdk.client.session
- .update({ sessionID, time: { archived: Date.now() } })
- .then(() => {
- sync.set(
- produce((draft) => {
- const index = draft.session.findIndex((s) => s.id === sessionID)
- if (index !== -1) draft.session.splice(index, 1)
- }),
- )
- navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
- })
- .catch((err) => {
- showToast({
- title: language.t("common.requestFailed"),
- description: errorMessage(err),
- })
- })
- }
-
- const deleteSession = async (sessionID: string) => {
- const session = sync.session.get(sessionID)
- if (!session) return false
-
- const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
- const index = sessions.findIndex((s) => s.id === sessionID)
- const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
-
- const result = await sdk.client.session
- .delete({ sessionID })
- .then((x) => x.data)
- .catch((err) => {
- showToast({
- title: language.t("session.delete.failed.title"),
- description: errorMessage(err),
- })
- return false
- })
-
- if (!result) return false
-
- sync.set(
- produce((draft) => {
- const removed = new Set<string>([sessionID])
-
- const byParent = new Map<string, string[]>()
- for (const item of draft.session) {
- const parentID = item.parentID
- if (!parentID) continue
- const existing = byParent.get(parentID)
- if (existing) {
- existing.push(item.id)
- continue
- }
- byParent.set(parentID, [item.id])
- }
-
- const stack = [sessionID]
- while (stack.length) {
- const parentID = stack.pop()
- if (!parentID) continue
-
- const children = byParent.get(parentID)
- if (!children) continue
-
- for (const child of children) {
- if (removed.has(child)) continue
- removed.add(child)
- stack.push(child)
- }
- }
-
- draft.session = draft.session.filter((s) => !removed.has(s.id))
- }),
- )
-
- navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
- return true
- }
-
- const navigateParent = () => {
- const id = parentID()
- if (!id) return
- navigate(`/${params.dir}/session/${id}`)
- }
-
- function DialogDeleteSession(props: { sessionID: string }) {
- const name = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
- const handleDelete = async () => {
- await deleteSession(props.sessionID)
- dialog.close()
- }
-
- return (
- <Dialog title={language.t("session.delete.title")} fit>
- <div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
- <div class="flex flex-col gap-1">
- <span class="text-14-regular text-text-strong">
- {language.t("session.delete.confirm", { name: name() })}
- </span>
- </div>
- <div class="flex justify-end gap-2">
- <Button variant="ghost" size="large" onClick={() => dialog.close()}>
- {language.t("common.cancel")}
- </Button>
- <Button variant="primary" size="large" onClick={handleDelete}>
- {language.t("session.delete.button")}
- </Button>
- </div>
- </div>
- </Dialog>
- )
- }
+ const rendered = createMemo(() => staging.messages().map((message) => message.id))
return (
<Show
@@ -498,6 +336,16 @@ export function MessageTimeline(props: {
<Icon name="arrow-down-to-line" />
</button>
</div>
+ <SessionTimelineHeader
+ centered={props.centered}
+ showHeader={showHeader}
+ sessionKey={sessionKey}
+ sessionID={sessionID}
+ parentID={parentID}
+ titleValue={titleValue}
+ headerTitle={headerTitle}
+ placeholderTitle={placeholderTitle}
+ />
<ScrollView
viewportRef={props.setScrollRef}
onWheel={(e) => {
@@ -532,9 +380,18 @@ export function MessageTimeline(props: {
touchGesture = undefined
}}
onPointerDown={(e) => {
+ const next = trigger(e.target)
+ if (next) props.onPreserveScrollAnchor(next)
+
if (e.target !== e.currentTarget) return
props.onMarkScrollGesture(e.currentTarget)
}}
+ onKeyDown={(e) => {
+ if (e.key !== "Enter" && e.key !== " ") return
+ const next = trigger(e.target)
+ if (!next) return
+ props.onPreserveScrollAnchor(next)
+ }}
onScroll={(e) => {
props.onScheduleScrollState(e.currentTarget)
props.onTurnBackfillScroll()
@@ -543,131 +400,21 @@ export function MessageTimeline(props: {
props.onMarkScrollGesture(e.currentTarget)
if (props.isDesktop) props.onScrollSpyScroll()
}}
- onClick={props.onAutoScrollInteraction}
+ onClick={(e) => {
+ props.onAutoScrollInteraction(e)
+ }}
class="relative min-w-0 w-full h-full"
style={{
- "--session-title-height": showHeader() ? "40px" : "0px",
+ "--session-title-height": showHeader() ? "72px" : "0px",
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
}}
>
- <div ref={props.setContentRef} class="min-w-0 w-full">
- <Show when={showHeader()}>
- <div
- data-session-title
- classList={{
- "sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
- "w-full": true,
- "pb-4": true,
- "pl-2 pr-3 md:pl-4 md:pr-3": true,
- "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
- }}
- >
- <div class="h-12 w-full flex items-center justify-between gap-2">
- <div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
- <Show when={parentID()}>
- <IconButton
- tabIndex={-1}
- icon="arrow-left"
- variant="ghost"
- onClick={navigateParent}
- aria-label={language.t("common.goBack")}
- />
- </Show>
- <Show when={titleValue() || title.editing}>
- <Show
- when={title.editing}
- fallback={
- <h1
- class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2"
- onDblClick={openTitleEditor}
- >
- {titleValue()}
- </h1>
- }
- >
- <InlineInput
- ref={(el) => {
- titleRef = el
- }}
- value={title.draft}
- disabled={title.saving}
- class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]"
- style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
- onInput={(event) => setTitle("draft", event.currentTarget.value)}
- onKeyDown={(event) => {
- event.stopPropagation()
- if (event.key === "Enter") {
- event.preventDefault()
- void saveTitleEditor()
- return
- }
- if (event.key === "Escape") {
- event.preventDefault()
- closeTitleEditor()
- }
- }}
- onBlur={closeTitleEditor}
- />
- </Show>
- </Show>
- </div>
- <Show when={sessionID()}>
- {(id) => (
- <div class="shrink-0 flex items-center gap-3">
- <SessionContextUsage placement="bottom" />
- <DropdownMenu
- gutter={4}
- placement="bottom-end"
- open={title.menuOpen}
- onOpenChange={(open) => setTitle("menuOpen", open)}
- >
- <DropdownMenu.Trigger
- as={IconButton}
- icon="dot-grid"
- variant="ghost"
- class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
- aria-label={language.t("common.moreOptions")}
- />
- <DropdownMenu.Portal>
- <DropdownMenu.Content
- style={{ "min-width": "104px" }}
- onCloseAutoFocus={(event) => {
- if (!title.pendingRename) return
- event.preventDefault()
- setTitle("pendingRename", false)
- openTitleEditor()
- }}
- >
- <DropdownMenu.Item
- onSelect={() => {
- setTitle("pendingRename", true)
- setTitle("menuOpen", false)
- }}
- >
- <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- <DropdownMenu.Item onSelect={() => void archiveSession(id())}>
- <DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- <DropdownMenu.Separator />
- <DropdownMenu.Item
- onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
- >
- <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- </DropdownMenu.Content>
- </DropdownMenu.Portal>
- </DropdownMenu>
- </div>
- )}
- </Show>
- </div>
- </div>
- </Show>
-
+ <div>
<div
+ ref={props.setContentRef}
role="log"
- class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
+ class="flex flex-col gap-0 items-start justify-start pb-16 transition-[margin]"
+ style={{ "padding-top": "var(--session-title-height)" }}
classList={{
"w-full": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
@@ -692,6 +439,15 @@ export function MessageTimeline(props: {
</Show>
<For each={rendered()}>
{(messageID) => {
+ // Capture at creation time: animate only messages added after the
+ // timeline finishes its initial backfill staging, plus the first
+ // turn while a brand new session is still using its default title.
+ const isNew =
+ staging.ready() ||
+ (defaultTitle() &&
+ sessionStatus() !== "idle" &&
+ props.renderedUserMessages.length === 1 &&
+ messageID === props.renderedUserMessages[0]?.id)
const active = createMemo(() => activeMessageID() === messageID)
const queued = createMemo(() => {
if (active()) return false
@@ -700,7 +456,10 @@ export function MessageTimeline(props: {
return false
})
const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
- equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
+ equals: (a, b) => {
+ if (a.length !== b.length) return false
+ return a.every((x, i) => x.path === b[i].path && x.comment === b[i].comment)
+ },
})
const commentCount = createMemo(() => comments().length)
return (
@@ -757,7 +516,7 @@ export function MessageTimeline(props: {
messageID={messageID}
active={active()}
queued={queued()}
- status={active() ? sessionStatus() : undefined}
+ animate={isNew || active()}
showReasoningSummaries={settings.general.showReasoningSummaries()}
shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
editToolDefaultOpen={settings.general.editToolPartsExpanded()}
diff --git a/packages/app/src/pages/session/session-timeline-header.tsx b/packages/app/src/pages/session/session-timeline-header.tsx
new file mode 100644
index 000000000..fcddb38a4
--- /dev/null
+++ b/packages/app/src/pages/session/session-timeline-header.tsx
@@ -0,0 +1,522 @@
+import { createEffect, createMemo, on, onCleanup, Show } from "solid-js"
+import { createStore, produce } from "solid-js/store"
+import { useNavigate, useParams } from "@solidjs/router"
+import { Button } from "@opencode-ai/ui/button"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { prefersReducedMotion } from "@opencode-ai/ui/hooks"
+import { InlineInput } from "@opencode-ai/ui/inline-input"
+import { animate, type AnimationPlaybackControls, clearFadeStyles, FAST_SPRING } from "@opencode-ai/ui/motion"
+import { showToast } from "@opencode-ai/ui/toast"
+import { errorMessage } from "@/pages/layout/helpers"
+import { SessionContextUsage } from "@/components/session-context-usage"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { useLanguage } from "@/context/language"
+import { useSDK } from "@/context/sdk"
+import { useSync } from "@/context/sync"
+
+export function SessionTimelineHeader(props: {
+ centered: boolean
+ showHeader: () => boolean
+ sessionKey: () => string
+ sessionID: () => string | undefined
+ parentID: () => string | undefined
+ titleValue: () => string | undefined
+ headerTitle: () => string | undefined
+ placeholderTitle: () => boolean
+}) {
+ const navigate = useNavigate()
+ const params = useParams()
+ const sdk = useSDK()
+ const sync = useSync()
+ const dialog = useDialog()
+ const language = useLanguage()
+ const reduce = prefersReducedMotion
+
+ const [title, setTitle] = createStore({
+ draft: "",
+ editing: false,
+ saving: false,
+ menuOpen: false,
+ pendingRename: false,
+ })
+ const [headerText, setHeaderText] = createStore({
+ session: props.sessionKey(),
+ value: props.headerTitle(),
+ prev: undefined as string | undefined,
+ muted: props.placeholderTitle(),
+ prevMuted: false,
+ })
+ let headerAnim: AnimationPlaybackControls | undefined
+ let enterAnim: AnimationPlaybackControls | undefined
+ let leaveAnim: AnimationPlaybackControls | undefined
+ let titleRef: HTMLInputElement | undefined
+ let headerRef: HTMLDivElement | undefined
+ let enterRef: HTMLSpanElement | undefined
+ let leaveRef: HTMLSpanElement | undefined
+
+ const clearHeaderAnim = () => {
+ headerAnim?.stop()
+ headerAnim = undefined
+ }
+
+ const animateHeader = () => {
+ const el = headerRef
+ if (!el) return
+
+ clearHeaderAnim()
+ if (!headerText.muted || reduce()) {
+ el.style.opacity = "1"
+ return
+ }
+
+ headerAnim = animate(el, { opacity: [0, 1] }, { type: "spring", visualDuration: 1.0, bounce: 0 })
+ headerAnim.finished.then(() => {
+ if (headerRef !== el) return
+ clearFadeStyles(el)
+ })
+ }
+
+ const clearTitleAnims = () => {
+ enterAnim?.stop()
+ enterAnim = undefined
+ leaveAnim?.stop()
+ leaveAnim = undefined
+ }
+
+ const settleTitleEnter = () => {
+ if (enterRef) clearFadeStyles(enterRef)
+ }
+
+ const hideLeave = () => {
+ if (!leaveRef) return
+ leaveRef.style.opacity = "0"
+ leaveRef.style.filter = ""
+ leaveRef.style.transform = ""
+ }
+
+ const animateEnterSpan = () => {
+ if (!enterRef) return
+ if (reduce()) {
+ settleTitleEnter()
+ return
+ }
+ enterAnim = animate(
+ enterRef,
+ { opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"], transform: ["translateY(-2px)", "translateY(0)"] },
+ FAST_SPRING,
+ )
+ enterAnim.finished.then(() => settleTitleEnter())
+ }
+
+ const crossfadeTitle = (nextTitle: string, nextMuted: boolean) => {
+ clearTitleAnims()
+ setHeaderText({ prev: headerText.value, prevMuted: headerText.muted })
+ setHeaderText({ value: nextTitle, muted: nextMuted })
+
+ if (reduce()) {
+ setHeaderText({ prev: undefined, prevMuted: false })
+ hideLeave()
+ settleTitleEnter()
+ return
+ }
+
+ if (leaveRef) {
+ leaveAnim = animate(
+ leaveRef,
+ { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"], transform: ["translateY(0)", "translateY(2px)"] },
+ FAST_SPRING,
+ )
+ leaveAnim.finished.then(() => {
+ setHeaderText({ prev: undefined, prevMuted: false })
+ hideLeave()
+ })
+ }
+
+ animateEnterSpan()
+ }
+
+ const fadeInTitle = (nextTitle: string, nextMuted: boolean) => {
+ clearTitleAnims()
+ setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false })
+ animateEnterSpan()
+ }
+
+ const snapTitle = (nextTitle: string | undefined, nextMuted: boolean) => {
+ clearTitleAnims()
+ setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false })
+ settleTitleEnter()
+ }
+
+ createEffect(
+ on(props.showHeader, (show, prev) => {
+ if (!show) {
+ clearHeaderAnim()
+ return
+ }
+ if (show === prev) return
+ animateHeader()
+ }),
+ )
+
+ createEffect(
+ on(
+ () => [props.sessionKey(), props.headerTitle(), props.placeholderTitle()] as const,
+ ([nextSession, nextTitle, nextMuted]) => {
+ if (nextSession !== headerText.session) {
+ setHeaderText("session", nextSession)
+ if (nextTitle && nextMuted) {
+ fadeInTitle(nextTitle, nextMuted)
+ return
+ }
+ snapTitle(nextTitle, nextMuted)
+ return
+ }
+ if (nextTitle === headerText.value && nextMuted === headerText.muted) return
+ if (!nextTitle) {
+ snapTitle(undefined, false)
+ return
+ }
+ if (!headerText.value) {
+ fadeInTitle(nextTitle, nextMuted)
+ return
+ }
+ if (title.saving || title.editing) {
+ snapTitle(nextTitle, nextMuted)
+ return
+ }
+ crossfadeTitle(nextTitle, nextMuted)
+ },
+ ),
+ )
+
+ onCleanup(() => {
+ clearHeaderAnim()
+ clearTitleAnims()
+ })
+
+ const toastError = (err: unknown) => errorMessage(err, language.t("common.requestFailed"))
+
+ createEffect(
+ on(
+ props.sessionKey,
+ () => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
+ { defer: true },
+ ),
+ )
+
+ const openTitleEditor = () => {
+ if (!props.sessionID()) return
+ setTitle({ editing: true, draft: props.titleValue() ?? "" })
+ requestAnimationFrame(() => {
+ titleRef?.focus()
+ titleRef?.select()
+ })
+ }
+
+ const closeTitleEditor = () => {
+ if (title.saving) return
+ setTitle({ editing: false, saving: false })
+ }
+
+ const saveTitleEditor = async () => {
+ const id = props.sessionID()
+ if (!id) return
+ if (title.saving) return
+
+ const next = title.draft.trim()
+ if (!next || next === (props.titleValue() ?? "")) {
+ setTitle({ editing: false, saving: false })
+ return
+ }
+
+ setTitle("saving", true)
+ await sdk.client.session
+ .update({ sessionID: id, title: next })
+ .then(() => {
+ sync.set(
+ produce((draft) => {
+ const index = draft.session.findIndex((session) => session.id === id)
+ if (index !== -1) draft.session[index].title = next
+ }),
+ )
+ setTitle({ editing: false, saving: false })
+ })
+ .catch((err) => {
+ setTitle("saving", false)
+ showToast({
+ title: language.t("common.requestFailed"),
+ description: toastError(err),
+ })
+ })
+ }
+
+ const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
+ if (params.id !== sessionID) return
+ if (parentID) {
+ navigate(`/${params.dir}/session/${parentID}`)
+ return
+ }
+ if (nextSessionID) {
+ navigate(`/${params.dir}/session/${nextSessionID}`)
+ return
+ }
+ navigate(`/${params.dir}/session`)
+ }
+
+ const archiveSession = async (sessionID: string) => {
+ const session = sync.session.get(sessionID)
+ if (!session) return
+
+ const sessions = sync.data.session ?? []
+ const index = sessions.findIndex((item) => item.id === sessionID)
+ const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
+
+ await sdk.client.session
+ .update({ sessionID, time: { archived: Date.now() } })
+ .then(() => {
+ sync.set(
+ produce((draft) => {
+ const index = draft.session.findIndex((item) => item.id === sessionID)
+ if (index !== -1) draft.session.splice(index, 1)
+ }),
+ )
+ navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
+ })
+ .catch((err) => {
+ showToast({
+ title: language.t("common.requestFailed"),
+ description: toastError(err),
+ })
+ })
+ }
+
+ const deleteSession = async (sessionID: string) => {
+ const session = sync.session.get(sessionID)
+ if (!session) return false
+
+ const sessions = (sync.data.session ?? []).filter((item) => !item.parentID && !item.time?.archived)
+ const index = sessions.findIndex((item) => item.id === sessionID)
+ const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
+
+ const result = await sdk.client.session
+ .delete({ sessionID })
+ .then((x) => x.data)
+ .catch((err) => {
+ showToast({
+ title: language.t("session.delete.failed.title"),
+ description: toastError(err),
+ })
+ return false
+ })
+
+ if (!result) return false
+
+ sync.set(
+ produce((draft) => {
+ const removed = new Set<string>([sessionID])
+ const byParent = new Map<string, string[]>()
+
+ for (const item of draft.session) {
+ const parentID = item.parentID
+ if (!parentID) continue
+
+ const existing = byParent.get(parentID)
+ if (existing) {
+ existing.push(item.id)
+ continue
+ }
+ byParent.set(parentID, [item.id])
+ }
+
+ const stack = [sessionID]
+ while (stack.length) {
+ const parentID = stack.pop()
+ if (!parentID) continue
+
+ const children = byParent.get(parentID)
+ if (!children) continue
+
+ for (const child of children) {
+ if (removed.has(child)) continue
+ removed.add(child)
+ stack.push(child)
+ }
+ }
+
+ draft.session = draft.session.filter((item) => !removed.has(item.id))
+ }),
+ )
+
+ navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
+ return true
+ }
+
+ const navigateParent = () => {
+ const id = props.parentID()
+ if (!id) return
+ navigate(`/${params.dir}/session/${id}`)
+ }
+
+ function DialogDeleteSession(input: { sessionID: string }) {
+ const name = createMemo(() => sync.session.get(input.sessionID)?.title ?? language.t("command.session.new"))
+
+ const handleDelete = async () => {
+ await deleteSession(input.sessionID)
+ dialog.close()
+ }
+
+ return (
+ <Dialog title={language.t("session.delete.title")} fit>
+ <div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
+ <div class="flex flex-col gap-1">
+ <span class="text-14-regular text-text-strong">
+ {language.t("session.delete.confirm", { name: name() })}
+ </span>
+ </div>
+ <div class="flex justify-end gap-2">
+ <Button variant="ghost" size="large" onClick={() => dialog.close()}>
+ {language.t("common.cancel")}
+ </Button>
+ <Button variant="primary" size="large" onClick={handleDelete}>
+ {language.t("session.delete.button")}
+ </Button>
+ </div>
+ </div>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Show when={props.showHeader()}>
+ <div
+ data-session-title
+ ref={(el) => {
+ headerRef = el
+ el.style.opacity = "0"
+ }}
+ class="pointer-events-none absolute inset-x-0 top-0 z-30"
+ >
+ <div
+ classList={{
+ "bg-[linear-gradient(to_bottom,var(--background-stronger)_38px,transparent)]": true,
+ "w-full": true,
+ "pb-10": true,
+ "px-4 md:px-5": true,
+ "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
+ }}
+ >
+ <div class="pointer-events-auto h-12 w-full flex items-center justify-between gap-2">
+ <div class="flex items-center gap-1 min-w-0 flex-1">
+ <Show when={props.parentID()}>
+ <div>
+ <IconButton
+ tabIndex={-1}
+ icon="arrow-left"
+ variant="ghost"
+ onClick={navigateParent}
+ aria-label={language.t("common.goBack")}
+ />
+ </div>
+ </Show>
+ <Show when={!!headerText.value || title.editing}>
+ <Show
+ when={title.editing}
+ fallback={
+ <h1 class="text-14-medium text-text-strong grow-1 min-w-0" onDblClick={openTitleEditor}>
+ <span class="grid min-w-0" style={{ overflow: "clip" }}>
+ <span ref={enterRef} class="col-start-1 row-start-1 min-w-0 truncate">
+ <span classList={{ "opacity-60": headerText.muted }}>{headerText.value}</span>
+ </span>
+ <span
+ ref={leaveRef}
+ class="col-start-1 row-start-1 min-w-0 truncate pointer-events-none"
+ style={{ opacity: "0" }}
+ >
+ <span classList={{ "opacity-60": headerText.prevMuted }}>{headerText.prev}</span>
+ </span>
+ </span>
+ </h1>
+ }
+ >
+ <InlineInput
+ ref={(el) => {
+ titleRef = el
+ }}
+ value={title.draft}
+ disabled={title.saving}
+ class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
+ style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
+ onInput={(event) => setTitle("draft", event.currentTarget.value)}
+ onKeyDown={(event) => {
+ event.stopPropagation()
+ if (event.key === "Enter") {
+ event.preventDefault()
+ void saveTitleEditor()
+ return
+ }
+ if (event.key === "Escape") {
+ event.preventDefault()
+ closeTitleEditor()
+ }
+ }}
+ onBlur={closeTitleEditor}
+ />
+ </Show>
+ </Show>
+ </div>
+ <Show when={props.sessionID()}>
+ {(id) => (
+ <div class="shrink-0 flex items-center gap-3">
+ <SessionContextUsage placement="bottom" />
+ <DropdownMenu
+ gutter={4}
+ placement="bottom-end"
+ open={title.menuOpen}
+ onOpenChange={(open) => setTitle("menuOpen", open)}
+ >
+ <DropdownMenu.Trigger
+ as={IconButton}
+ icon="dot-grid"
+ variant="ghost"
+ class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
+ aria-label={language.t("common.moreOptions")}
+ />
+ <DropdownMenu.Portal>
+ <DropdownMenu.Content
+ style={{ "min-width": "104px" }}
+ onCloseAutoFocus={(event) => {
+ if (!title.pendingRename) return
+ event.preventDefault()
+ setTitle("pendingRename", false)
+ openTitleEditor()
+ }}
+ >
+ <DropdownMenu.Item
+ onSelect={() => {
+ setTitle("pendingRename", true)
+ setTitle("menuOpen", false)
+ }}
+ >
+ <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ <DropdownMenu.Item onSelect={() => void archiveSession(id())}>
+ <DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ <DropdownMenu.Separator />
+ <DropdownMenu.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}>
+ <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ </DropdownMenu.Content>
+ </DropdownMenu.Portal>
+ </DropdownMenu>
+ </div>
+ )}
+ </Show>
+ </div>
+ </div>
+ </div>
+ </Show>
+ )
+}
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 20e88a3ea..278a1ba6e 100644
--- a/packages/app/src/pages/session/use-session-hash-scroll.ts
+++ b/packages/app/src/pages/session/use-session-hash-scroll.ts
@@ -1,6 +1,5 @@
import type { UserMessage } from "@opencode-ai/sdk/v2"
-import { useLocation, useNavigate } from "@solidjs/router"
-import { createEffect, createMemo, onMount } from "solid-js"
+import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { messageIdFromHash } from "./message-id-from-hash"
export { messageIdFromHash } from "./message-id-from-hash"
@@ -16,7 +15,7 @@ export const useSessionHashScroll = (input: {
setPendingMessage: (value: string | undefined) => void
setActiveMessage: (message: UserMessage | undefined) => void
setTurnStart: (value: number) => void
- autoScroll: { pause: () => void; forceScrollToBottom: () => void }
+ autoScroll: { pause: () => void; snapToBottom: () => void }
scroller: () => HTMLDivElement | undefined
anchor: (id: string) => string
scheduleScrollState: (el: HTMLDivElement) => void
@@ -27,18 +26,13 @@ export const useSessionHashScroll = (input: {
const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i])))
let pendingKey = ""
- const location = useLocation()
- const navigate = useNavigate()
-
const clearMessageHash = () => {
- if (!location.hash) return
- navigate(location.pathname + location.search, { replace: true })
+ if (!window.location.hash) return
+ window.history.replaceState(null, "", window.location.pathname + window.location.search)
}
const updateHash = (id: string) => {
- navigate(location.pathname + location.search + `#${input.anchor(id)}`, {
- replace: true,
- })
+ window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}#${input.anchor(id)}`)
}
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
@@ -47,15 +41,15 @@ export const useSessionHashScroll = (input: {
const a = el.getBoundingClientRect()
const b = root.getBoundingClientRect()
- const sticky = root.querySelector("[data-session-title]")
- const inset = sticky instanceof HTMLElement ? sticky.offsetHeight : 0
- const top = Math.max(0, a.top - b.top + root.scrollTop - inset)
+ const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height"))
+ const inset = Number.isNaN(title) ? 0 : title
+ // With column-reverse, scrollTop is negative — don't clamp to 0
+ const top = a.top - b.top + root.scrollTop - inset
root.scrollTo({ top, behavior })
return true
}
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
- console.log({ message, behavior })
if (input.currentMessageId() !== message.id) input.setActiveMessage(message)
const index = messageIndex().get(message.id) ?? -1
@@ -103,9 +97,9 @@ export const useSessionHashScroll = (input: {
}
const applyHash = (behavior: ScrollBehavior) => {
- const hash = location.hash.slice(1)
+ const hash = window.location.hash.slice(1)
if (!hash) {
- input.autoScroll.forceScrollToBottom()
+ input.autoScroll.snapToBottom()
const el = input.scroller()
if (el) input.scheduleScrollState(el)
return
@@ -129,13 +123,26 @@ export const useSessionHashScroll = (input: {
return
}
- input.autoScroll.forceScrollToBottom()
+ input.autoScroll.snapToBottom()
const el = input.scroller()
if (el) input.scheduleScrollState(el)
}
+ onMount(() => {
+ if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
+ window.history.scrollRestoration = "manual"
+ }
+
+ const handler = () => {
+ if (!input.sessionID() || !input.messagesReady()) return
+ requestAnimationFrame(() => applyHash("auto"))
+ }
+
+ window.addEventListener("hashchange", handler)
+ onCleanup(() => window.removeEventListener("hashchange", handler))
+ })
+
createEffect(() => {
- location.hash
if (!input.sessionID() || !input.messagesReady()) return
requestAnimationFrame(() => applyHash("auto"))
})
@@ -159,7 +166,6 @@ export const useSessionHashScroll = (input: {
}
}
- if (!targetId) targetId = messageIdFromHash(location.hash)
if (!targetId) return
if (input.currentMessageId() === targetId) return
@@ -171,12 +177,6 @@ export const useSessionHashScroll = (input: {
requestAnimationFrame(() => scrollToMessage(msg, "auto"))
})
- onMount(() => {
- if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
- window.history.scrollRestoration = "manual"
- }
- })
-
return {
clearMessageHash,
scrollToMessage,
diff --git a/packages/ui/src/components/animated-number.css b/packages/ui/src/components/animated-number.css
index 022b347e9..b69ce6508 100644
--- a/packages/ui/src/components/animated-number.css
+++ b/packages/ui/src/components/animated-number.css
@@ -9,19 +9,20 @@
display: inline-flex;
flex-direction: row-reverse;
align-items: baseline;
- justify-content: flex-end;
+ justify-content: flex-start;
line-height: inherit;
width: var(--animated-number-width, 1ch);
- overflow: hidden;
- transition: width var(--tool-motion-spring-ms, 560ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
+ overflow: clip;
+ transition: width var(--tool-motion-spring-ms, 800ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
}
[data-slot="animated-number-digit"] {
display: inline-block;
+ flex-shrink: 0;
width: 1ch;
height: 1em;
line-height: 1em;
- overflow: hidden;
+ overflow: clip;
vertical-align: baseline;
-webkit-mask-image: linear-gradient(
to bottom,
@@ -46,7 +47,7 @@
flex-direction: column;
transform: translateY(calc(var(--animated-number-offset, 10) * -1em));
transition-property: transform;
- transition-duration: var(--animated-number-duration, 560ms);
+ transition-duration: var(--animated-number-duration, 600ms);
transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
}
diff --git a/packages/ui/src/components/animated-number.tsx b/packages/ui/src/components/animated-number.tsx
index b5fceba25..dfe368b8b 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
@@ -90,10 +90,35 @@ export function AnimatedNumber(props: { value: number; class?: string }) {
)
const width = createMemo(() => `${digits().length}ch`)
+ const [exitingDigits, setExitingDigits] = createSignal<number[]>([])
+ let exitTimer: number | undefined
+
+ createEffect(
+ on(
+ digits,
+ (current, prev) => {
+ if (prev && current.length < prev.length) {
+ setExitingDigits(prev.slice(current.length))
+ clearTimeout(exitTimer)
+ exitTimer = window.setTimeout(() => setExitingDigits([]), DURATION)
+ } else {
+ clearTimeout(exitTimer)
+ setExitingDigits([])
+ }
+ },
+ { defer: true },
+ ),
+ )
+
+ const displayDigits = createMemo(() => {
+ const exiting = exitingDigits()
+ return exiting.length ? [...digits(), ...exiting] : digits()
+ })
+
return (
<span data-component="animated-number" class={props.class} aria-label={label()}>
<span data-slot="animated-number-value" style={{ "--animated-number-width": width() }}>
- <Index each={digits()}>{(digit) => <Digit value={digit()} direction={direction()} />}</Index>
+ <Index each={displayDigits()}>{(digit) => <Digit value={digit()} direction={direction()} />}</Index>
</span>
</span>
)
diff --git a/packages/ui/src/components/basic-tool.css b/packages/ui/src/components/basic-tool.css
index 02be54d73..c3a62c59c 100644
--- a/packages/ui/src/components/basic-tool.css
+++ b/packages/ui/src/components/basic-tool.css
@@ -8,54 +8,28 @@
justify-content: flex-start;
[data-slot="basic-tool-tool-trigger-content"] {
- width: auto;
+ width: 100%;
+ min-width: 0;
display: flex;
align-items: center;
align-self: stretch;
gap: 8px;
}
- [data-slot="basic-tool-tool-indicator"] {
- width: 16px;
- height: 16px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
-
- [data-component="spinner"] {
- width: 16px;
- height: 16px;
- }
- }
-
- [data-slot="basic-tool-tool-spinner"] {
- width: 16px;
- height: 16px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
- color: var(--text-weak);
-
- [data-component="spinner"] {
- width: 16px;
- height: 16px;
- }
- }
-
[data-slot="icon-svg"] {
flex-shrink: 0;
}
[data-slot="basic-tool-tool-info"] {
- flex: 0 1 auto;
+ flex: 1 1 auto;
min-width: 0;
font-size: 14px;
}
[data-slot="basic-tool-tool-info-structured"] {
width: auto;
+ max-width: 100%;
+ min-width: 0;
display: flex;
align-items: center;
gap: 8px;
@@ -63,11 +37,12 @@
}
[data-slot="basic-tool-tool-info-main"] {
+ flex: 0 1 auto;
display: flex;
- align-items: baseline;
+ align-items: center;
gap: 8px;
min-width: 0;
- overflow: hidden;
+ overflow: clip;
}
[data-slot="basic-tool-tool-title"] {
@@ -80,21 +55,14 @@
letter-spacing: var(--letter-spacing-normal);
color: var(--text-strong);
- &.capitalize {
- text-transform: capitalize;
- }
-
- &.agent-title {
- color: var(--text-strong);
- font-weight: var(--font-weight-medium);
- }
}
[data-slot="basic-tool-tool-subtitle"] {
- flex-shrink: 1;
+ display: inline-block;
+ flex: 0 1 auto;
+ max-width: 100%;
min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
+ overflow: clip;
white-space: nowrap;
font-family: var(--font-family-sans);
font-size: 14px;
@@ -138,8 +106,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/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx
index 4ad91824d..3210b4870 100644
--- a/packages/ui/src/components/basic-tool.tsx
+++ b/packages/ui/src/components/basic-tool.tsx
@@ -1,8 +1,20 @@
-import { createEffect, createSignal, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js"
-import { animate, type AnimationPlaybackControls } from "motion"
+import {
+ createEffect,
+ createSignal,
+ For,
+ Match,
+ on,
+ onCleanup,
+ onMount,
+ Show,
+ splitProps,
+ Switch,
+ type JSX,
+} from "solid-js"
+import { animate, type AnimationPlaybackControls, tunableSpringValue, COLLAPSIBLE_SPRING } from "./motion"
import { Collapsible } from "./collapsible"
-import type { IconProps } from "./icon"
import { TextShimmer } from "./text-shimmer"
+import { hold } from "./tool-utils"
export type TriggerTitle = {
title: string
@@ -20,26 +32,99 @@ const isTriggerTitle = (val: any): val is TriggerTitle => {
)
}
-export interface BasicToolProps {
- icon: IconProps["name"]
+interface ToolCallPanelBaseProps {
+ icon: string
trigger: TriggerTitle | JSX.Element
children?: JSX.Element
status?: string
+ animate?: boolean
hideDetails?: boolean
defaultOpen?: boolean
forceOpen?: boolean
defer?: boolean
locked?: boolean
- animated?: boolean
+ watchDetails?: boolean
+ springContent?: boolean
onSubtitleClick?: () => void
}
-const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 }
+function ToolCallTriggerBody(props: {
+ trigger: TriggerTitle | JSX.Element
+ pending: boolean
+ onSubtitleClick?: () => void
+ arrow?: boolean
+}) {
+ return (
+ <div data-component="tool-trigger" data-arrow={props.arrow ? "" : undefined}>
+ <div data-slot="basic-tool-tool-trigger-content">
+ <div data-slot="basic-tool-tool-info">
+ <Switch>
+ <Match when={isTriggerTitle(props.trigger) && props.trigger}>
+ {(trigger) => (
+ <div data-slot="basic-tool-tool-info-structured">
+ <div data-slot="basic-tool-tool-info-main">
+ <span
+ data-slot="basic-tool-tool-title"
+ classList={{
+ [trigger().titleClass ?? ""]: !!trigger().titleClass,
+ }}
+ >
+ <TextShimmer text={trigger().title} active={props.pending} />
+ </span>
+ <Show when={!props.pending}>
+ <Show when={trigger().subtitle}>
+ <span
+ data-slot="basic-tool-tool-subtitle"
+ classList={{
+ [trigger().subtitleClass ?? ""]: !!trigger().subtitleClass,
+ clickable: !!props.onSubtitleClick,
+ }}
+ onClick={(e) => {
+ if (!props.onSubtitleClick) return
+ e.stopPropagation()
+ props.onSubtitleClick()
+ }}
+ >
+ {trigger().subtitle}
+ </span>
+ </Show>
+ <Show when={trigger().args?.length}>
+ <For each={trigger().args}>
+ {(arg) => (
+ <span
+ data-slot="basic-tool-tool-arg"
+ classList={{
+ [trigger().argsClass ?? ""]: !!trigger().argsClass,
+ }}
+ >
+ {arg}
+ </span>
+ )}
+ </For>
+ </Show>
+ </Show>
+ </div>
+ <Show when={!props.pending && trigger().action}>{trigger().action}</Show>
+ </div>
+ )}
+ </Match>
+ <Match when={true}>{props.trigger as JSX.Element}</Match>
+ </Switch>
+ </div>
+ </div>
+ <Show when={props.arrow}>
+ <Collapsible.Arrow />
+ </Show>
+ </div>
+ )
+}
-export function BasicTool(props: BasicToolProps) {
+function ToolCallPanel(props: ToolCallPanelBaseProps) {
const [open, setOpen] = createSignal(props.defaultOpen ?? false)
const [ready, setReady] = createSignal(open())
- const pending = () => props.status === "pending" || props.status === "running"
+ const pendingRaw = () => props.status === "pending" || props.status === "running"
+ const pending = hold(pendingRaw, 1000)
+ const watchDetails = () => props.watchDetails !== false
let frame: number | undefined
@@ -59,7 +144,7 @@ export function BasicTool(props: BasicToolProps) {
on(
open,
(value) => {
- if (!props.defer) return
+ if (!props.defer || props.springContent) return
if (!value) {
cancel()
setReady(false)
@@ -77,36 +162,110 @@ export function BasicTool(props: BasicToolProps) {
),
)
- // Animated height for collapsible open/close
+ // Animated content height — single springValue drives all height changes
let contentRef: HTMLDivElement | undefined
- let heightAnim: AnimationPlaybackControls | undefined
+ let bodyRef: HTMLDivElement | undefined
+ let fadeAnim: AnimationPlaybackControls | undefined
+ let observer: ResizeObserver | undefined
+ let resizeFrame: number | undefined
const initialOpen = open()
+ const heightSpring = tunableSpringValue<number>(0, COLLAPSIBLE_SPRING)
+
+ const read = () => Math.max(0, Math.ceil(bodyRef?.getBoundingClientRect().height ?? 0))
+
+ const doOpen = () => {
+ if (!contentRef || !bodyRef) return
+ contentRef.style.display = ""
+ // Ensure fade starts from 0 if content was hidden (first open or after close cleared styles)
+ if (bodyRef.style.opacity === "") {
+ bodyRef.style.opacity = "0"
+ bodyRef.style.filter = "blur(2px)"
+ }
+ const next = read()
+ fadeAnim?.stop()
+ fadeAnim = animate(bodyRef, { opacity: 1, filter: "blur(0px)" }, COLLAPSIBLE_SPRING)
+ fadeAnim.finished.then(() => {
+ if (!bodyRef) return
+ bodyRef.style.opacity = ""
+ bodyRef.style.filter = ""
+ })
+ heightSpring.set(next)
+ }
+
+ const doClose = () => {
+ if (!contentRef || !bodyRef) return
+ fadeAnim?.stop()
+ fadeAnim = animate(bodyRef, { opacity: 0, filter: "blur(2px)" }, COLLAPSIBLE_SPRING)
+ fadeAnim.finished.then(() => {
+ if (!contentRef || open()) return
+ contentRef.style.display = "none"
+ })
+ heightSpring.set(0)
+ }
+
+ const grow = () => {
+ if (!contentRef || !open()) return
+ const next = read()
+ if (Math.abs(next - heightSpring.get()) < 1) return
+ heightSpring.set(next)
+ }
+
+ onMount(() => {
+ if (!props.springContent || props.animate === false || !contentRef || !bodyRef) return
+
+ const offChange = heightSpring.on("change", (v) => {
+ if (!contentRef) return
+ contentRef.style.height = `${Math.max(0, Math.ceil(v))}px`
+ })
+ onCleanup(() => {
+ offChange()
+ })
+
+ if (watchDetails()) {
+ observer = new ResizeObserver(() => {
+ if (resizeFrame !== undefined) return
+ resizeFrame = requestAnimationFrame(() => {
+ resizeFrame = undefined
+ grow()
+ })
+ })
+ observer.observe(bodyRef)
+ }
+
+ if (!open()) return
+ if (contentRef.style.display !== "none") {
+ const next = read()
+ heightSpring.jump(next)
+ contentRef.style.height = `${next}px`
+ return
+ }
+ let mountFrame: number | undefined = requestAnimationFrame(() => {
+ mountFrame = undefined
+ if (!open()) return
+ doOpen()
+ })
+ onCleanup(() => {
+ if (mountFrame !== undefined) cancelAnimationFrame(mountFrame)
+ })
+ })
createEffect(
on(
open,
(isOpen) => {
- if (!props.animated || !contentRef) return
- heightAnim?.stop()
- if (isOpen) {
- contentRef.style.overflow = "hidden"
- heightAnim = animate(contentRef, { height: "auto" }, SPRING)
- heightAnim.finished.then(() => {
- if (!contentRef || !open()) return
- contentRef.style.overflow = "visible"
- contentRef.style.height = "auto"
- })
- } else {
- contentRef.style.overflow = "hidden"
- heightAnim = animate(contentRef, { height: "0px" }, SPRING)
- }
+ if (!props.springContent || props.animate === false || !contentRef) return
+ if (isOpen) doOpen()
+ else doClose()
},
{ defer: true },
),
)
onCleanup(() => {
- heightAnim?.stop()
+ if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
+ observer?.disconnect()
+ fadeAnim?.stop()
+ heightSpring.destroy()
})
const handleOpenChange = (value: boolean) => {
@@ -118,85 +277,34 @@ export function BasicTool(props: BasicToolProps) {
return (
<Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
<Collapsible.Trigger>
- <div data-component="tool-trigger">
- <div data-slot="basic-tool-tool-trigger-content">
- <div data-slot="basic-tool-tool-info">
- <Switch>
- <Match when={isTriggerTitle(props.trigger) && props.trigger}>
- {(trigger) => (
- <div data-slot="basic-tool-tool-info-structured">
- <div data-slot="basic-tool-tool-info-main">
- <span
- data-slot="basic-tool-tool-title"
- classList={{
- [trigger().titleClass ?? ""]: !!trigger().titleClass,
- }}
- >
- <TextShimmer text={trigger().title} active={pending()} />
- </span>
- <Show when={!pending()}>
- <Show when={trigger().subtitle}>
- <span
- data-slot="basic-tool-tool-subtitle"
- classList={{
- [trigger().subtitleClass ?? ""]: !!trigger().subtitleClass,
- clickable: !!props.onSubtitleClick,
- }}
- onClick={(e) => {
- if (props.onSubtitleClick) {
- e.stopPropagation()
- props.onSubtitleClick()
- }
- }}
- >
- {trigger().subtitle}
- </span>
- </Show>
- <Show when={trigger().args?.length}>
- <For each={trigger().args}>
- {(arg) => (
- <span
- data-slot="basic-tool-tool-arg"
- classList={{
- [trigger().argsClass ?? ""]: !!trigger().argsClass,
- }}
- >
- {arg}
- </span>
- )}
- </For>
- </Show>
- </Show>
- </div>
- <Show when={!pending() && trigger().action}>{trigger().action}</Show>
- </div>
- )}
- </Match>
- <Match when={true}>{props.trigger as JSX.Element}</Match>
- </Switch>
- </div>
- </div>
- <Show when={props.children && !props.hideDetails && !props.locked && !pending()}>
- <Collapsible.Arrow />
- </Show>
- </div>
+ <ToolCallTriggerBody
+ trigger={props.trigger}
+ pending={pending()}
+ onSubtitleClick={props.onSubtitleClick}
+ arrow={!!props.children && !props.hideDetails && !props.locked && !pending()}
+ />
</Collapsible.Trigger>
- <Show when={props.animated && props.children && !props.hideDetails}>
+ <Show when={props.springContent && props.animate !== false && props.children && !props.hideDetails}>
<div
ref={contentRef}
data-slot="collapsible-content"
- data-animated
+ data-spring-content
style={{
height: initialOpen ? "auto" : "0px",
- overflow: initialOpen ? "visible" : "hidden",
+ overflow: "hidden",
+ display: initialOpen ? undefined : "none",
}}
>
- {props.children}
+ <div ref={bodyRef} data-slot="basic-tool-content-inner">
+ {props.children}
+ </div>
</div>
</Show>
- <Show when={!props.animated && props.children && !props.hideDetails}>
+ <Show when={(!props.springContent || props.animate === false) && props.children && !props.hideDetails}>
<Collapsible.Content>
- <Show when={!props.defer || ready()}>{props.children}</Show>
+ <Show when={!props.defer || ready()}>
+ <div data-slot="basic-tool-content-inner">{props.children}</div>
+ </Show>
</Collapsible.Content>
</Show>
</Collapsible>
@@ -222,6 +330,60 @@ function args(input: Record<string, unknown> | undefined) {
.slice(0, 3)
}
+export interface ToolCallRowProps {
+ variant: "row"
+ icon: string
+ trigger: TriggerTitle | JSX.Element
+ status?: string
+ animate?: boolean
+ onSubtitleClick?: () => void
+ open?: boolean
+ showArrow?: boolean
+ onOpenChange?: (value: boolean) => void
+}
+export interface ToolCallPanelProps extends Omit<ToolCallPanelBaseProps, "hideDetails"> {
+ variant: "panel"
+}
+export type ToolCallProps = ToolCallRowProps | ToolCallPanelProps
+function ToolCallRoot(props: ToolCallProps) {
+ const pending = () => props.status === "pending" || props.status === "running"
+ if (props.variant === "row") {
+ return (
+ <Show
+ when={props.onOpenChange}
+ fallback={
+ <div data-component="collapsible" data-variant="normal" class="tool-collapsible">
+ <div data-slot="collapsible-trigger">
+ <ToolCallTriggerBody
+ trigger={props.trigger}
+ pending={pending()}
+ onSubtitleClick={props.onSubtitleClick}
+ />
+ </div>
+ </div>
+ }
+ >
+ {(onOpenChange) => (
+ <Collapsible open={props.open ?? true} onOpenChange={onOpenChange()} class="tool-collapsible">
+ <Collapsible.Trigger>
+ <ToolCallTriggerBody
+ trigger={props.trigger}
+ pending={pending()}
+ onSubtitleClick={props.onSubtitleClick}
+ arrow={!!props.showArrow}
+ />
+ </Collapsible.Trigger>
+ </Collapsible>
+ )}
+ </Show>
+ )
+ }
+
+ const [, rest] = splitProps(props, ["variant"])
+ return <ToolCallPanel {...rest} />
+}
+export const ToolCall = ToolCallRoot
+
export function GenericTool(props: {
tool: string
status?: string
@@ -229,7 +391,8 @@ export function GenericTool(props: {
input?: Record<string, unknown>
}) {
return (
- <BasicTool
+ <ToolCall
+ variant={props.hideDetails ? "row" : "panel"}
icon="mcp"
status={props.status}
trigger={{
@@ -237,7 +400,6 @@ export function GenericTool(props: {
subtitle: label(props.input),
args: args(props.input),
}}
- hideDetails={props.hideDetails}
/>
)
}
diff --git a/packages/ui/src/components/collapsible.css b/packages/ui/src/components/collapsible.css
index bab2c4f92..1a86338bd 100644
--- a/packages/ui/src/components/collapsible.css
+++ b/packages/ui/src/components/collapsible.css
@@ -8,14 +8,18 @@
border-radius: var(--radius-md);
overflow: visible;
- &.tool-collapsible {
- gap: 8px;
+ &.tool-collapsible [data-slot="collapsible-trigger"] {
+ height: 37px;
+ }
+
+ &.tool-collapsible [data-slot="basic-tool-content-inner"] {
+ padding-top: 0;
}
[data-slot="collapsible-trigger"] {
width: 100%;
display: flex;
- height: 32px;
+ height: 36px;
padding: 0;
align-items: center;
align-self: stretch;
@@ -23,6 +27,17 @@
user-select: none;
color: var(--text-base);
+ > [data-component="tool-trigger"][data-arrow] {
+ width: auto;
+ max-width: 100%;
+ flex: 0 1 auto;
+
+ [data-slot="basic-tool-tool-trigger-content"] {
+ width: auto;
+ max-width: 100%;
+ }
+ }
+
[data-slot="collapsible-arrow"] {
opacity: 0;
transition: opacity 0.15s ease;
@@ -50,9 +65,6 @@
line-height: var(--line-height-large); /* 166.667% */
letter-spacing: var(--letter-spacing-normal);
- /* &:hover { */
- /* background-color: var(--surface-base); */
- /* } */
&:focus-visible {
outline: none;
background-color: var(--surface-raised-base-hover);
@@ -82,16 +94,16 @@
}
[data-slot="collapsible-content"] {
- overflow: hidden;
- /* animation: slideUp 250ms ease-out; */
+ overflow: clip;
&[data-expanded] {
overflow: visible;
}
- /* &[data-expanded] { */
- /* animation: slideDown 250ms ease-out; */
- /* } */
+ /* JS-animated content: overflow managed by animate() */
+ &[data-spring-content] {
+ overflow: clip;
+ }
}
&[data-variant="ghost"] {
@@ -103,9 +115,6 @@
border: none;
padding: 0;
- /* &:hover { */
- /* color: var(--text-strong); */
- /* } */
&:focus-visible {
outline: none;
background-color: var(--surface-raised-base-hover);
@@ -122,21 +131,3 @@
}
}
}
-
-@keyframes slideDown {
- from {
- height: 0;
- }
- to {
- height: var(--kb-collapsible-content-height);
- }
-}
-
-@keyframes slideUp {
- from {
- height: var(--kb-collapsible-content-height);
- }
- to {
- height: 0;
- }
-}
diff --git a/packages/ui/src/components/context-tool-results.tsx b/packages/ui/src/components/context-tool-results.tsx
new file mode 100644
index 000000000..25d120e05
--- /dev/null
+++ b/packages/ui/src/components/context-tool-results.tsx
@@ -0,0 +1,199 @@
+import { createMemo, createSignal, For, onMount } from "solid-js"
+import type { ToolPart } from "@opencode-ai/sdk/v2"
+import { getFilename } from "@opencode-ai/util/path"
+import { useI18n } from "../context/i18n"
+import { prefersReducedMotion } from "../hooks/use-reduced-motion"
+import { ToolCall } from "./basic-tool"
+import { ToolStatusTitle } from "./tool-status-title"
+import { AnimatedCountList } from "./tool-count-summary"
+import { RollingResults } from "./rolling-results"
+import { GROW_SPRING } from "./motion"
+import { useSpring } from "./motion-spring"
+import { busy, updateScrollMask, useCollapsible, useRowWipe } from "./tool-utils"
+
+function contextToolLabel(part: ToolPart): { action: string; detail: string } {
+ const state = part.state
+ const title = "title" in state ? (state.title as string | undefined) : undefined
+ const input = state.input
+ if (part.tool === "read") {
+ const path = input?.filePath as string | undefined
+ return { action: "Read", detail: title || (path ? getFilename(path) : "") }
+ }
+ if (part.tool === "grep") {
+ const pattern = input?.pattern as string | undefined
+ return { action: "Search", detail: title || (pattern ? `"${pattern}"` : "") }
+ }
+ if (part.tool === "glob") {
+ const pattern = input?.pattern as string | undefined
+ return { action: "Find", detail: title || (pattern ?? "") }
+ }
+ if (part.tool === "list") {
+ const path = input?.path as string | undefined
+ return { action: "List", detail: title || (path ? getFilename(path) : "") }
+ }
+ return { action: part.tool, detail: title || "" }
+}
+
+function contextToolSummary(parts: ToolPart[]) {
+ let read = 0
+ let search = 0
+ let list = 0
+ for (const part of parts) {
+ if (part.tool === "read") read++
+ else if (part.tool === "glob" || part.tool === "grep") search++
+ else if (part.tool === "list") list++
+ }
+ return { read, search, list }
+}
+
+export function ContextToolGroupHeader(props: {
+ parts: ToolPart[]
+ pending: boolean
+ open: boolean
+ onOpenChange: (value: boolean) => void
+}) {
+ const i18n = useI18n()
+ const summary = createMemo(() => contextToolSummary(props.parts))
+ return (
+ <ToolCall
+ variant="row"
+ icon="magnifying-glass-menu"
+ open={!props.pending && props.open}
+ showArrow={!props.pending}
+ onOpenChange={(v) => {
+ if (!props.pending) props.onOpenChange(v)
+ }}
+ trigger={
+ <div data-component="context-tool-group-trigger" data-pending={props.pending || undefined}>
+ <span
+ data-slot="context-tool-group-title"
+ class="min-w-0 flex items-center gap-2 text-14-medium text-text-strong"
+ >
+ <span data-slot="context-tool-group-label" class="shrink-0">
+ <ToolStatusTitle
+ active={props.pending}
+ activeText={i18n.t("ui.sessionTurn.status.gatheringContext")}
+ doneText={i18n.t("ui.sessionTurn.status.gatheredContext")}
+ split={false}
+ />
+ </span>
+ <span
+ data-slot="context-tool-group-summary"
+ class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-normal text-text-base"
+ >
+ <AnimatedCountList
+ items={[
+ {
+ key: "read",
+ count: summary().read,
+ one: i18n.t("ui.messagePart.context.read.one"),
+ other: i18n.t("ui.messagePart.context.read.other"),
+ },
+ {
+ key: "search",
+ count: summary().search,
+ one: i18n.t("ui.messagePart.context.search.one"),
+ other: i18n.t("ui.messagePart.context.search.other"),
+ },
+ {
+ key: "list",
+ count: summary().list,
+ one: i18n.t("ui.messagePart.context.list.one"),
+ other: i18n.t("ui.messagePart.context.list.other"),
+ },
+ ]}
+ fallback=""
+ />
+ </span>
+ </span>
+ </div>
+ }
+ />
+ )
+}
+
+export function ContextToolExpandedList(props: { parts: ToolPart[]; expanded: boolean }) {
+ let contentRef: HTMLDivElement | undefined
+ let bodyRef: HTMLDivElement | undefined
+ let scrollRef: HTMLDivElement | undefined
+ const updateMask = () => {
+ if (scrollRef) updateScrollMask(scrollRef)
+ }
+
+ useCollapsible({
+ content: () => contentRef,
+ body: () => bodyRef,
+ open: () => props.expanded,
+ onOpen: updateMask,
+ })
+
+ return (
+ <div ref={contentRef} style={{ overflow: "clip", height: "0px", display: "none" }}>
+ <div ref={bodyRef}>
+ <div ref={scrollRef} data-component="context-tool-expanded-list" onScroll={updateMask}>
+ <For each={props.parts}>
+ {(part) => {
+ const label = createMemo(() => contextToolLabel(part))
+ return (
+ <div data-component="context-tool-expanded-row">
+ <span data-slot="context-tool-expanded-action">{label().action}</span>
+ <span data-slot="context-tool-expanded-detail">{label().detail}</span>
+ </div>
+ )
+ }}
+ </For>
+ </div>
+ </div>
+ </div>
+ )
+}
+
+export function ContextToolRollingResults(props: { parts: ToolPart[]; pending: boolean }) {
+ const wiped = new Set<string>()
+ const [mounted, setMounted] = createSignal(false)
+ onMount(() => setMounted(true))
+ const reduce = prefersReducedMotion
+ const show = () => mounted() && props.pending
+ const opacity = useSpring(() => (show() ? 1 : 0), GROW_SPRING)
+ const blur = useSpring(() => (show() ? 0 : 2), GROW_SPRING)
+ return (
+ <div style={{ opacity: reduce() ? (show() ? 1 : 0) : opacity(), filter: `blur(${reduce() ? 0 : blur()}px)` }}>
+ <RollingResults
+ items={props.parts}
+ rows={5}
+ rowHeight={22}
+ rowGap={0}
+ open={props.pending}
+ animate
+ getKey={(part) => part.callID || part.id}
+ render={(part) => {
+ const label = createMemo(() => contextToolLabel(part))
+ const k = part.callID || part.id
+ return (
+ <div data-component="context-tool-rolling-row">
+ <span data-slot="context-tool-rolling-action">{label().action}</span>
+ {(() => {
+ const [detailRef, setDetailRef] = createSignal<HTMLSpanElement>()
+ useRowWipe({
+ id: () => k,
+ text: () => label().detail,
+ ref: detailRef,
+ seen: wiped,
+ })
+ return (
+ <span
+ ref={setDetailRef}
+ data-slot="context-tool-rolling-detail"
+ style={{ display: label().detail ? undefined : "none" }}
+ >
+ {label().detail}
+ </span>
+ )
+ })()}
+ </div>
+ )
+ }}
+ />
+ </div>
+ )
+}
diff --git a/packages/ui/src/components/grow-box.tsx b/packages/ui/src/components/grow-box.tsx
new file mode 100644
index 000000000..ec4921ab3
--- /dev/null
+++ b/packages/ui/src/components/grow-box.tsx
@@ -0,0 +1,426 @@
+import { createEffect, on, type JSX, onMount, onCleanup } from "solid-js"
+import { animate, tunableSpringValue, type AnimationPlaybackControls, GROW_SPRING, type SpringConfig } from "./motion"
+import { prefersReducedMotion } from "../hooks/use-reduced-motion"
+
+export interface GrowBoxProps {
+ children: JSX.Element
+ /** Enable animation. When false, content shows immediately at full height. */
+ animate?: boolean
+ /** Animate height from 0 to content height. Default: true. */
+ grow?: boolean
+ /** Keep watching body size and animate subsequent height changes. Default: false. */
+ watch?: boolean
+ /** Fade in body content (opacity + blur). Default: true. */
+ fade?: boolean
+ /** Top padding in px on the body wrapper. Default: 0. */
+ gap?: number
+ /** Reset to height:auto after grow completes, or stay at fixed px. Default: true. */
+ autoHeight?: boolean
+ /** Controlled visibility for animating open/close without unmounting children. */
+ open?: boolean
+ /** Animate controlled open/close changes after mount. Default: true. */
+ animateToggle?: boolean
+ /** data-slot attribute on the root div. */
+ slot?: string
+ /** CSS class on the root div. */
+ class?: string
+ /** Override mount and resize spring config. Default: GROW_SPRING. */
+ spring?: SpringConfig
+ /** Override controlled open/close spring config. Default: spring. */
+ toggleSpring?: SpringConfig
+ /** Show a temporary bottom edge fade while height animation is running. */
+ edge?: boolean
+ /** Edge fade height in px. Default: 20. */
+ edgeHeight?: number
+ /** Edge fade opacity (0-1). Default: 1. */
+ edgeOpacity?: number
+ /** Delay before edge fades out after height settles. Default: 320. */
+ edgeIdle?: number
+ /** Edge fade-out duration in seconds. Default: 0.24. */
+ edgeFade?: number
+ /** Edge fade-in duration in seconds. Default: 0.2. */
+ edgeRise?: number
+}
+
+/**
+ * Wraps children in a container that animates from zero height on mount.
+ *
+ * Includes a ResizeObserver so content changes after mount are also spring-animated.
+ * Used for timeline turns, assistant part groups, and user messages.
+ */
+export function GrowBox(props: GrowBoxProps) {
+ const reduce = prefersReducedMotion
+ const spring = () => props.spring ?? GROW_SPRING
+ const toggleSpring = () => props.toggleSpring ?? spring()
+ let mode: "mount" | "toggle" = "mount"
+ let root: HTMLDivElement | undefined
+ let body: HTMLDivElement | undefined
+ let fadeAnim: AnimationPlaybackControls | undefined
+ let edgeRef: HTMLDivElement | undefined
+ let edgeAnim: AnimationPlaybackControls | undefined
+ let edgeTimer: ReturnType<typeof setTimeout> | undefined
+ let edgeOn = false
+ let mountFrame: number | undefined
+ let resizeFrame: number | undefined
+ let observer: ResizeObserver | undefined
+ let springTarget = -1
+ const height = tunableSpringValue<number>(0, {
+ type: "spring",
+ get visualDuration() {
+ return (mode === "toggle" ? toggleSpring() : spring()).visualDuration
+ },
+ get bounce() {
+ return (mode === "toggle" ? toggleSpring() : spring()).bounce
+ },
+ })
+
+ const gap = () => Math.max(0, props.gap ?? 0)
+ const grow = () => props.grow !== false
+ const watch = () => props.watch === true
+ const open = () => props.open !== false
+ const animateToggle = () => props.animateToggle !== false
+ const edge = () => props.edge === true
+ const edgeHeight = () => Math.max(0, props.edgeHeight ?? 20)
+ const edgeOpacity = () => Math.min(1, Math.max(0, props.edgeOpacity ?? 1))
+ const edgeIdle = () => Math.max(0, props.edgeIdle ?? 320)
+ const edgeFade = () => Math.max(0.05, props.edgeFade ?? 0.24)
+ const edgeRise = () => Math.max(0.05, props.edgeRise ?? 0.2)
+ const animated = () => props.animate !== false && !reduce()
+ const edgeReady = () => animated() && open() && edge() && edgeHeight() > 0
+
+ const stopEdgeTimer = () => {
+ if (edgeTimer === undefined) return
+ clearTimeout(edgeTimer)
+ edgeTimer = undefined
+ }
+
+ const hideEdge = (instant = false) => {
+ stopEdgeTimer()
+ if (!edgeRef) {
+ edgeOn = false
+ return
+ }
+ edgeAnim?.stop()
+ edgeAnim = undefined
+ if (instant || reduce()) {
+ edgeRef.style.opacity = "0"
+ edgeOn = false
+ return
+ }
+ if (!edgeOn) {
+ edgeRef.style.opacity = "0"
+ return
+ }
+ const current = animate(edgeRef, { opacity: 0 }, { type: "spring", visualDuration: edgeFade(), bounce: 0 })
+ edgeAnim = current
+ current.finished
+ .catch(() => {})
+ .finally(() => {
+ if (edgeAnim !== current) return
+ edgeAnim = undefined
+ if (!edgeRef) return
+ edgeRef.style.opacity = "0"
+ edgeOn = false
+ })
+ }
+
+ const showEdge = () => {
+ stopEdgeTimer()
+ if (!edgeRef) return
+ if (reduce()) {
+ edgeRef.style.opacity = `${edgeOpacity()}`
+ edgeOn = true
+ return
+ }
+ if (edgeOn && edgeAnim === undefined) {
+ edgeRef.style.opacity = `${edgeOpacity()}`
+ return
+ }
+ edgeAnim?.stop()
+ edgeAnim = undefined
+ if (!edgeOn) edgeRef.style.opacity = "0"
+ const current = animate(
+ edgeRef,
+ { opacity: edgeOpacity() },
+ { type: "spring", visualDuration: edgeRise(), bounce: 0 },
+ )
+ edgeAnim = current
+ edgeOn = true
+ current.finished
+ .catch(() => {})
+ .finally(() => {
+ if (edgeAnim !== current) return
+ edgeAnim = undefined
+ if (!edgeRef) return
+ edgeRef.style.opacity = `${edgeOpacity()}`
+ })
+ }
+
+ const queueEdgeHide = () => {
+ stopEdgeTimer()
+ if (!edgeOn) return
+ if (edgeIdle() <= 0) {
+ hideEdge()
+ return
+ }
+ edgeTimer = setTimeout(() => {
+ edgeTimer = undefined
+ hideEdge()
+ }, edgeIdle())
+ }
+
+ const hideBody = () => {
+ if (!body) return
+ body.style.opacity = "0"
+ body.style.filter = "blur(2px)"
+ }
+
+ const clearBody = () => {
+ if (!body) return
+ body.style.opacity = ""
+ body.style.filter = ""
+ }
+
+ const fadeBodyIn = (nextMode: "mount" | "toggle" = "mount") => {
+ if (props.fade === false || !body) return
+ if (reduce()) {
+ clearBody()
+ return
+ }
+ hideBody()
+ fadeAnim?.stop()
+ fadeAnim = animate(body, { opacity: 1, filter: "blur(0px)" }, nextMode === "toggle" ? toggleSpring() : spring())
+ fadeAnim.finished.then(() => {
+ if (!body || !open()) return
+ clearBody()
+ })
+ }
+
+ const setInstant = (visible: boolean) => {
+ const next = visible ? targetHeight() : 0
+ springTarget = next
+ height.jump(next)
+ root!.style.height = visible ? "" : "0px"
+ root!.style.overflow = visible ? "" : "clip"
+ hideEdge(true)
+ if (visible || props.fade === false) clearBody()
+ else hideBody()
+ }
+
+ const currentHeight = () => {
+ if (!root) return 0
+ const v = root.style.height
+ if (v && v !== "auto") {
+ const n = Number.parseFloat(v)
+ if (!Number.isNaN(n)) return n
+ }
+ return Math.max(0, root.getBoundingClientRect().height)
+ }
+
+ const targetHeight = () => Math.max(0, Math.ceil(body?.getBoundingClientRect().height ?? 0))
+
+ const setHeight = (nextMode: "mount" | "toggle" = "mount") => {
+ if (!root || !open()) return
+ const next = targetHeight()
+ if (reduce()) {
+ springTarget = next
+ height.jump(next)
+ if (props.autoHeight === false || watch()) {
+ root.style.height = `${next}px`
+ root.style.overflow = next > 0 ? "visible" : "clip"
+ return
+ }
+ root.style.height = "auto"
+ root.style.overflow = next > 0 ? "visible" : "clip"
+ return
+ }
+ if (next === springTarget) return
+ const prev = currentHeight()
+ if (Math.abs(next - prev) < 1) {
+ springTarget = next
+ if (props.autoHeight === false || watch()) {
+ root.style.height = `${next}px`
+ root.style.overflow = next > 0 ? "visible" : "clip"
+ }
+ return
+ }
+ root.style.overflow = "clip"
+ springTarget = next
+ mode = nextMode
+ height.set(next)
+ }
+
+ onMount(() => {
+ if (!root || !body) return
+
+ const offChange = height.on("change", (next) => {
+ if (!root) return
+ root.style.height = `${Math.max(0, next)}px`
+ })
+ const offStart = height.on("animationStart", () => {
+ if (!root) return
+ root.style.overflow = "clip"
+ root.style.willChange = "height"
+ root.style.contain = "layout style"
+ if (edgeReady()) showEdge()
+ })
+ const offComplete = height.on("animationComplete", () => {
+ if (!root) return
+ root.style.willChange = ""
+ root.style.contain = ""
+ if (!open()) {
+ springTarget = 0
+ root.style.height = "0px"
+ root.style.overflow = "clip"
+ return
+ }
+ const next = targetHeight()
+ springTarget = next
+ if (props.autoHeight === false || watch()) {
+ root.style.height = `${next}px`
+ root.style.overflow = next > 0 ? "visible" : "clip"
+ if (edgeReady()) queueEdgeHide()
+ return
+ }
+ root.style.height = "auto"
+ root.style.overflow = "visible"
+ if (edgeReady()) queueEdgeHide()
+ })
+
+ onCleanup(() => {
+ offComplete()
+ offStart()
+ offChange()
+ })
+
+ if (!animated()) {
+ setInstant(open())
+ return
+ }
+
+ if (props.fade !== false) hideBody()
+ hideEdge(true)
+
+ if (!open()) {
+ root.style.height = "0px"
+ root.style.overflow = "clip"
+ } else {
+ if (grow()) {
+ root.style.height = "0px"
+ root.style.overflow = "clip"
+ } else {
+ root.style.height = "auto"
+ root.style.overflow = "visible"
+ }
+ mountFrame = requestAnimationFrame(() => {
+ mountFrame = undefined
+ fadeBodyIn("mount")
+ if (grow()) setHeight("mount")
+ })
+ }
+ if (watch()) {
+ observer = new ResizeObserver(() => {
+ if (!open()) return
+ if (resizeFrame !== undefined) return
+ resizeFrame = requestAnimationFrame(() => {
+ resizeFrame = undefined
+ setHeight("mount")
+ })
+ })
+ observer.observe(body)
+ }
+ })
+
+ createEffect(
+ on(
+ () => props.open,
+ (value) => {
+ if (value === undefined) return
+ if (!root || !body) return
+ if (!animateToggle() || reduce()) {
+ setInstant(value)
+ return
+ }
+ fadeAnim?.stop()
+ if (!value) hideEdge(true)
+ if (!value) {
+ const next = currentHeight()
+ if (Math.abs(next - height.get()) >= 1) {
+ springTarget = next
+ height.jump(next)
+ root.style.height = `${next}px`
+ }
+ if (props.fade !== false) {
+ fadeAnim = animate(body, { opacity: 0, filter: "blur(2px)" }, toggleSpring())
+ }
+ root.style.overflow = "clip"
+ springTarget = 0
+ mode = "toggle"
+ height.set(0)
+ return
+ }
+ fadeBodyIn("toggle")
+ setHeight("toggle")
+ },
+ { defer: true },
+ ),
+ )
+
+ createEffect(() => {
+ if (!edgeRef) return
+ edgeRef.style.height = `${edgeHeight()}px`
+ if (!animated() || !open() || edgeHeight() <= 0) {
+ hideEdge(true)
+ return
+ }
+ if (edge()) return
+ hideEdge()
+ })
+
+ createEffect(() => {
+ if (!root || !body) return
+ if (!reduce()) return
+ fadeAnim?.stop()
+ edgeAnim?.stop()
+ setInstant(open())
+ })
+
+ onCleanup(() => {
+ stopEdgeTimer()
+ if (mountFrame !== undefined) cancelAnimationFrame(mountFrame)
+ if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
+ observer?.disconnect()
+ height.destroy()
+ fadeAnim?.stop()
+ edgeAnim?.stop()
+ edgeAnim = undefined
+ edgeOn = false
+ })
+
+ return (
+ <div
+ ref={root}
+ data-slot={props.slot}
+ class={props.class}
+ style={{ transform: "translateZ(0)", position: "relative" }}
+ >
+ <div ref={body} style={{ "padding-top": gap() > 0 ? `${gap()}px` : undefined }}>
+ {props.children}
+ </div>
+ <div
+ ref={edgeRef}
+ data-slot="grow-box-edge"
+ style={{
+ position: "absolute",
+ left: "0",
+ right: "0",
+ bottom: "0",
+ height: `${edgeHeight()}px`,
+ opacity: 0,
+ "pointer-events": "none",
+ background: "linear-gradient(to bottom, transparent 0%, var(--background-stronger) 100%)",
+ }}
+ />
+ </div>
+ )
+}
diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css
index 8fc709013..9a6784d70 100644
--- a/packages/ui/src/components/message-part.css
+++ b/packages/ui/src/components/message-part.css
@@ -1,10 +1,20 @@
[data-component="assistant-message"] {
content-visibility: auto;
width: 100%;
+}
+
+[data-component="assistant-parts"] {
+ width: 100%;
+ min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
- gap: 12px;
+ gap: 0;
+}
+
+[data-component="assistant-part-item"] {
+ width: 100%;
+ min-width: 0;
}
[data-component="user-message"] {
@@ -27,6 +37,14 @@
color: var(--text-weak);
}
+ [data-slot="user-message-inner"] {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ width: 100%;
+ gap: 4px;
+ }
[data-slot="user-message-attachments"] {
display: flex;
flex-wrap: wrap;
@@ -35,6 +53,7 @@
width: fit-content;
max-width: min(82%, 64ch);
margin-left: auto;
+ margin-bottom: 4px;
}
[data-slot="user-message-attachment"] {
@@ -134,7 +153,7 @@
[data-slot="user-message-copy-wrapper"] {
min-height: 24px;
- margin-top: 4px;
+ margin-top: 0;
display: flex;
align-items: center;
justify-content: flex-end;
@@ -144,7 +163,6 @@
pointer-events: none;
transition: opacity 0.15s ease;
will-change: opacity;
-
[data-component="tooltip-trigger"] {
display: inline-flex;
width: fit-content;
@@ -187,56 +205,21 @@
opacity: 1;
pointer-events: auto;
}
-
- .text-text-strong {
- color: var(--text-strong);
- }
-
- .font-medium {
- font-weight: var(--font-weight-medium);
- }
}
[data-component="text-part"] {
width: 100%;
- margin-top: 24px;
+ margin-top: 0;
+ padding-block: 4px;
+ position: relative;
[data-slot="text-part-body"] {
margin-top: 0;
}
- [data-slot="text-part-copy-wrapper"] {
- min-height: 24px;
- margin-top: 4px;
- display: flex;
- align-items: center;
- justify-content: flex-start;
- gap: 10px;
- opacity: 0;
- pointer-events: none;
- transition: opacity 0.15s ease;
- will-change: opacity;
-
- [data-component="tooltip-trigger"] {
- display: inline-flex;
- width: fit-content;
- }
- }
-
- [data-slot="text-part-meta"] {
- user-select: none;
- }
-
- [data-slot="text-part-copy-wrapper"][data-interrupted] {
+ [data-slot="text-part-turn-summary"] {
width: 100%;
- justify-content: flex-end;
- gap: 12px;
- }
-
- &:hover [data-slot="text-part-copy-wrapper"],
- &:focus-within [data-slot="text-part-copy-wrapper"] {
- opacity: 1;
- pointer-events: auto;
+ min-width: 0;
}
[data-component="markdown"] {
@@ -245,6 +228,10 @@
}
}
+[data-component="assistant-part-item"][data-kind="text"][data-last="true"] [data-component="text-part"] {
+ padding-bottom: 0;
+}
+
[data-component="compaction-part"] {
width: 100%;
display: flex;
@@ -278,7 +265,6 @@
line-height: var(--line-height-normal);
[data-component="markdown"] {
- margin-top: 24px;
font-style: normal;
font-size: inherit;
color: var(--text-weak);
@@ -372,13 +358,16 @@
height: auto;
max-height: 240px;
overflow-y: auto;
+ overscroll-behavior: contain;
scrollbar-width: none;
-ms-overflow-style: none;
-
+ -webkit-mask-image: linear-gradient(to bottom, transparent 0, black 6px, black calc(100% - 6px), transparent 100%);
+ mask-image: linear-gradient(to bottom, transparent 0, black 6px, black calc(100% - 6px), transparent 100%);
+ -webkit-mask-repeat: no-repeat;
+ mask-repeat: no-repeat;
&::-webkit-scrollbar {
display: none;
}
-
[data-component="markdown"] {
overflow: visible;
}
@@ -448,7 +437,7 @@
[data-component="write-trigger"] {
display: flex;
align-items: center;
- justify-content: space-between;
+ justify-content: flex-start;
gap: 8px;
width: 100%;
@@ -461,7 +450,8 @@
}
[data-slot="message-part-title"] {
- flex-shrink: 0;
+ flex-shrink: 1;
+ min-width: 0;
display: flex;
align-items: center;
gap: 8px;
@@ -493,40 +483,45 @@
[data-slot="message-part-title-text"] {
text-transform: capitalize;
color: var(--text-strong);
+ flex-shrink: 0;
}
- [data-slot="message-part-title-filename"] {
- /* No text-transform - preserve original filename casing */
+ [data-slot="message-part-meta-line"],
+ .message-part-meta-line {
+ min-width: 0;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
font-weight: var(--font-weight-regular);
+
+ [data-component="diff-changes"] {
+ flex-shrink: 0;
+ gap: 6px;
+ }
}
- [data-slot="message-part-path"] {
- display: flex;
- flex-grow: 1;
- min-width: 0;
- font-weight: var(--font-weight-regular);
+ .message-part-meta-line.soft {
+ [data-slot="message-part-title-filename"] {
+ color: var(--text-base);
+ }
}
- [data-slot="message-part-directory"] {
+ [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-inline"] {
color: var(--text-weak);
+ min-width: 0;
+ max-width: min(48vw, 36ch);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
direction: rtl;
text-align: left;
}
-
- [data-slot="message-part-filename"] {
- color: var(--text-strong);
- flex-shrink: 0;
- }
-
- [data-slot="message-part-actions"] {
- display: flex;
- gap: 16px;
- align-items: center;
- justify-content: flex-end;
- }
}
[data-component="edit-content"] {
@@ -617,6 +612,17 @@
}
}
+[data-slot="webfetch-meta"] {
+ min-width: 0;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+
+ [data-component="tool-action"] {
+ flex-shrink: 0;
+ }
+}
+
[data-component="todos"] {
padding: 10px 0 24px 0;
display: flex;
@@ -639,7 +645,6 @@
}
[data-component="context-tool-group-trigger"] {
- width: 100%;
min-height: 24px;
display: flex;
align-items: center;
@@ -647,28 +652,352 @@
gap: 0px;
cursor: pointer;
+ &[data-pending] {
+ cursor: default;
+ }
+
[data-slot="context-tool-group-title"] {
flex-shrink: 1;
min-width: 0;
}
+}
- [data-slot="collapsible-arrow"] {
- color: var(--icon-weaker);
+/* Prevent the trigger content from stretching full-width so the arrow sits after the text */
+[data-slot="basic-tool-tool-trigger-content"]:has([data-component="context-tool-group-trigger"]) {
+ width: auto;
+ flex: 0 1 auto;
+
+ [data-slot="basic-tool-tool-info"] {
+ flex: 0 1 auto;
}
}
-[data-component="context-tool-group-list"] {
- padding: 6px 0 4px 0;
+[data-component="context-tool-step"] {
+ width: 100%;
+ min-width: 0;
+ padding-left: 12px;
+}
+
+[data-component="context-tool-expanded-list"] {
display: flex;
flex-direction: column;
- gap: 2px;
+ padding: 4px 0 4px 12px;
+ max-height: 200px;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+ -webkit-mask-repeat: no-repeat;
+ mask-repeat: no-repeat;
- [data-slot="context-tool-group-item"] {
+ &::-webkit-scrollbar {
+ display: none;
+ }
+}
+
+[data-component="context-tool-expanded-row"] {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+ height: 22px;
+ flex-shrink: 0;
+ white-space: nowrap;
+ overflow: hidden;
+
+ [data-slot="context-tool-expanded-action"] {
+ flex-shrink: 0;
+ font-size: var(--font-size-base);
+ font-weight: 500;
+ color: var(--text-base);
+ }
+
+ [data-slot="context-tool-expanded-detail"] {
+ flex-shrink: 1;
min-width: 0;
- padding: 6px 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-size: var(--font-size-base);
+ color: var(--text-base);
+ opacity: 0.75;
}
}
+[data-component="context-tool-rolling-row"] {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ width: 100%;
+ min-width: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ padding-left: 12px;
+
+ [data-slot="context-tool-rolling-action"] {
+ flex-shrink: 0;
+ font-size: var(--font-size-base);
+ font-weight: 500;
+ color: var(--text-base);
+ }
+
+ [data-slot="context-tool-rolling-detail"] {
+ flex-shrink: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-size: var(--font-size-base);
+ color: var(--text-weak);
+ }
+}
+
+[data-component="shell-rolling-results"] {
+ width: 100%;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+
+ [data-slot="shell-rolling-header-clip"] {
+ &:hover [data-slot="shell-rolling-actions"] {
+ opacity: 1;
+ }
+
+ &[data-clickable="true"] {
+ cursor: pointer;
+ }
+ }
+
+ [data-slot="shell-rolling-header"] {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+ max-width: 100%;
+ height: 37px;
+ box-sizing: border-box;
+ }
+
+ [data-slot="shell-rolling-title"] {
+ flex-shrink: 0;
+ font-family: var(--font-family-sans);
+ font-size: 14px;
+ font-style: normal;
+ font-weight: var(--font-weight-medium);
+ line-height: var(--line-height-large);
+ letter-spacing: var(--letter-spacing-normal);
+ color: var(--text-strong);
+ }
+
+ [data-slot="shell-rolling-subtitle"] {
+ flex: 0 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-family: var(--font-family-sans);
+ font-size: 14px;
+ font-weight: var(--font-weight-normal);
+ line-height: var(--line-height-large);
+ color: var(--text-weak);
+ }
+
+ [data-slot="shell-rolling-actions"] {
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ opacity: 0;
+ transition: opacity 0.15s ease;
+ }
+
+ .shell-rolling-copy {
+ border: none !important;
+ outline: none !important;
+ box-shadow: none !important;
+ background: transparent !important;
+
+ [data-slot="icon-svg"] {
+ color: var(--icon-weaker);
+ }
+
+ &:hover:not(:disabled) {
+ background: color-mix(in srgb, var(--text-base) 8%, transparent) !important;
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--icon-weaker) 40%, transparent) !important;
+ border-radius: var(--radius-sm);
+
+ [data-slot="icon-svg"] {
+ color: var(--icon-base);
+ }
+ }
+ }
+
+ [data-slot="shell-rolling-arrow"] {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--icon-weaker);
+ transform: rotate(-90deg);
+ transition: transform 0.15s ease;
+ }
+
+ [data-slot="shell-rolling-arrow"][data-open="true"] {
+ transform: rotate(0deg);
+ }
+}
+
+[data-component="shell-rolling-output"] {
+ width: 100%;
+ min-width: 0;
+}
+
+[data-slot="shell-rolling-preview"] {
+ width: 100%;
+ min-width: 0;
+}
+
+[data-component="shell-expanded-output"] {
+ width: 100%;
+ max-width: 100%;
+ overflow-y: auto;
+ overflow-x: hidden;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+}
+
+[data-component="shell-expanded-shell"] {
+ position: relative;
+ width: 100%;
+ min-width: 0;
+ border: 1px solid var(--border-weak-base);
+ border-radius: 6px;
+ background: transparent;
+ overflow: hidden;
+}
+
+[data-slot="shell-expanded-body"] {
+ position: relative;
+ width: 100%;
+ min-width: 0;
+}
+
+[data-slot="shell-expanded-top"] {
+ position: relative;
+ width: 100%;
+ min-width: 0;
+ padding: 9px 44px 9px 16px;
+ box-sizing: border-box;
+}
+
+[data-slot="shell-expanded-command"] {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ width: 100%;
+ min-width: 0;
+ font-family: var(--font-family-mono);
+ font-feature-settings: var(--font-family-mono--font-feature-settings);
+ font-size: 13px;
+ line-height: 1.45;
+}
+
+[data-slot="shell-expanded-prompt"] {
+ flex-shrink: 0;
+ color: var(--text-weaker);
+}
+
+[data-slot="shell-expanded-input"] {
+ min-width: 0;
+ color: var(--text-strong);
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+[data-slot="shell-expanded-actions"] {
+ position: absolute;
+ top: 50%;
+ right: 8px;
+ z-index: 1;
+ transform: translateY(-50%);
+}
+
+.shell-expanded-copy {
+ border: none !important;
+ outline: none !important;
+ box-shadow: none !important;
+ background: transparent !important;
+
+ [data-slot="icon-svg"] {
+ color: var(--icon-weaker);
+ }
+
+ &:hover:not(:disabled) {
+ background: color-mix(in srgb, var(--text-base) 8%, transparent) !important;
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--icon-weaker) 40%, transparent) !important;
+ border-radius: var(--radius-sm);
+
+ [data-slot="icon-svg"] {
+ color: var(--icon-base);
+ }
+ }
+}
+
+[data-slot="shell-expanded-divider"] {
+ width: 100%;
+ height: 1px;
+ background: var(--border-weak-base);
+}
+
+[data-slot="shell-expanded-pre"] {
+ margin: 0;
+ padding: 12px 16px;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+
+ code {
+ font-family: var(--font-family-mono);
+ font-feature-settings: var(--font-family-mono--font-feature-settings);
+ font-size: 13px;
+ line-height: 1.45;
+ color: var(--text-base);
+ }
+}
+
+[data-component="shell-rolling-command"],
+[data-component="shell-rolling-row"] {
+ display: inline-flex;
+ align-items: center;
+ width: 100%;
+ min-width: 0;
+ overflow: hidden;
+ white-space: pre;
+ padding-left: 12px;
+}
+
+[data-slot="shell-rolling-text"] {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-family: var(--font-family-mono);
+ font-feature-settings: var(--font-family-mono--font-feature-settings);
+ font-size: var(--font-size-small);
+ line-height: var(--line-height-large);
+}
+
+[data-component="shell-rolling-command"] [data-slot="shell-rolling-text"] {
+ color: var(--text-base);
+}
+
+[data-component="shell-rolling-command"] [data-slot="shell-rolling-prompt"] {
+ color: var(--text-weaker);
+}
+
+[data-component="shell-rolling-row"] [data-slot="shell-rolling-text"] {
+ color: var(--text-weak);
+}
+
[data-component="diagnostics"] {
display: flex;
flex-direction: column;
@@ -729,6 +1058,30 @@
width: 100%;
}
+[data-slot="assistant-part-grow"] {
+ width: 100%;
+ min-width: 0;
+ overflow: visible;
+}
+
+[data-component="tool-part-wrapper"][data-tool="bash"] {
+ [data-component="tool-trigger"] {
+ width: auto;
+ max-width: 100%;
+ }
+
+ [data-slot="basic-tool-tool-info-main"] {
+ align-items: center;
+ }
+
+ [data-slot="basic-tool-tool-title"],
+ [data-slot="basic-tool-tool-subtitle"] {
+ display: inline-flex;
+ align-items: center;
+ line-height: var(--line-height-large);
+ }
+}
+
[data-component="dock-prompt"][data-kind="permission"] {
position: relative;
display: flex;
@@ -1187,8 +1540,7 @@
position: sticky;
top: var(--sticky-accordion-top, 0px);
z-index: 20;
- height: 40px;
- padding-bottom: 8px;
+ height: 37px;
background-color: var(--background-stronger);
}
}
@@ -1199,11 +1551,12 @@
}
[data-slot="apply-patch-trigger-content"] {
- display: flex;
+ display: inline-flex;
align-items: center;
- justify-content: space-between;
- width: 100%;
- gap: 20px;
+ justify-content: flex-start;
+ max-width: 100%;
+ min-width: 0;
+ gap: 8px;
}
[data-slot="apply-patch-file-info"] {
@@ -1237,9 +1590,9 @@
[data-slot="apply-patch-trigger-actions"] {
flex-shrink: 0;
display: flex;
- gap: 16px;
+ gap: 8px;
align-items: center;
- justify-content: flex-end;
+ justify-content: flex-start;
}
[data-slot="apply-patch-change"] {
@@ -1279,10 +1632,11 @@
}
[data-component="tool-loaded-file"] {
+ min-width: 0;
display: flex;
align-items: center;
gap: 8px;
- padding: 4px 0 4px 28px;
+ padding: 4px 0 4px 12px;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-weight: var(--font-weight-regular);
@@ -1293,4 +1647,11 @@
flex-shrink: 0;
color: var(--icon-weak);
}
+
+ span {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
}
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index 9286d2a92..77e39b1e1 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -1,18 +1,6 @@
-import {
- Component,
- createEffect,
- createMemo,
- createSignal,
- For,
- Match,
- onMount,
- Show,
- Switch,
- onCleanup,
- Index,
- type JSX,
-} from "solid-js"
+import { Component, createEffect, createMemo, createSignal, For, Match, on, Show, Switch, type JSX } from "solid-js"
import stripAnsi from "strip-ansi"
+import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import {
AgentPart,
@@ -32,12 +20,10 @@ 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"
-import { Collapsible } from "./collapsible"
import { FileIcon } from "./file-icon"
import { Icon } from "./icon"
import { Checkbox } from "./checkbox"
@@ -49,43 +35,12 @@ import { checksum } from "@opencode-ai/util/encode"
import { Tooltip } from "./tooltip"
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"
-import { useLocation } from "@solidjs/router"
-
-function ShellSubmessage(props: { text: string; animate?: boolean }) {
- let widthRef: HTMLSpanElement | undefined
- let valueRef: HTMLSpanElement | undefined
-
- onMount(() => {
- if (!props.animate) return
- requestAnimationFrame(() => {
- if (widthRef) {
- animate(widthRef, { width: "auto" }, { type: "spring", visualDuration: 0.25, bounce: 0 })
- }
- if (valueRef) {
- animate(valueRef, { opacity: 1, filter: "blur(0px)" }, { duration: 0.32, ease: [0.16, 1, 0.3, 1] })
- }
- })
- })
-
- return (
- <span data-component="shell-submessage">
- <span ref={widthRef} data-slot="shell-submessage-width" style={{ width: props.animate ? "0px" : undefined }}>
- <span data-slot="basic-tool-tool-subtitle">
- <span
- ref={valueRef}
- data-slot="shell-submessage-value"
- style={props.animate ? { opacity: 0, filter: "blur(2px)" } : undefined}
- >
- {props.text}
- </span>
- </span>
- </span>
- </span>
- )
-}
+import { list } from "./text-utils"
+import { GrowBox } from "./grow-box"
+import { COLLAPSIBLE_SPRING } from "./motion"
+import { busy, hold, createThrottledValue, useToolFade, useContextToolPending } from "./tool-utils"
+import { ContextToolGroupHeader, ContextToolExpandedList, ContextToolRollingResults } from "./context-tool-results"
+import { ShellRollingResults } from "./shell-rolling-results"
interface Diagnostic {
range: {
@@ -126,64 +81,22 @@ function DiagnosticsDisplay(props: { diagnostics: Diagnostic[] }): JSX.Element {
)
}
-export interface MessageProps {
- message: MessageType
- parts: PartType[]
- showAssistantCopyPartID?: string | null
- interrupted?: boolean
- queued?: boolean
- showReasoningSummaries?: boolean
-}
-
export interface MessagePartProps {
part: PartType
message: MessageType
hideDetails?: boolean
defaultOpen?: boolean
showAssistantCopyPartID?: string | null
- turnDurationMs?: number
+ showTurnDiffSummary?: boolean
+ turnDiffSummary?: () => JSX.Element
+ animate?: boolean
+ working?: boolean
}
export type PartComponent = Component<MessagePartProps>
export const PART_MAPPING: Record<string, PartComponent | undefined> = {}
-const TEXT_RENDER_THROTTLE_MS = 100
-
-function createThrottledValue(getValue: () => string) {
- const [value, setValue] = createSignal(getValue())
- let timeout: ReturnType<typeof setTimeout> | undefined
- let last = 0
-
- createEffect(() => {
- const next = getValue()
- const now = Date.now()
-
- const remaining = TEXT_RENDER_THROTTLE_MS - (now - last)
- if (remaining <= 0) {
- if (timeout) {
- clearTimeout(timeout)
- timeout = undefined
- }
- last = now
- setValue(next)
- return
- }
- if (timeout) clearTimeout(timeout)
- timeout = setTimeout(() => {
- last = Date.now()
- setValue(next)
- timeout = undefined
- }, remaining)
- })
-
- onCleanup(() => {
- if (timeout) clearTimeout(timeout)
- })
-
- return value
-}
-
function relativizeProjectPath(path: string, directory?: string) {
if (!path) return ""
if (!directory) return path
@@ -258,7 +171,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":
@@ -305,7 +218,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 {
@@ -330,105 +244,36 @@ function urls(text: string | undefined) {
const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"])
const HIDDEN_TOOLS = new Set(["todowrite", "todoread"])
-function list<T>(value: T[] | undefined | null, fallback: T[]) {
- if (Array.isArray(value)) return value
- return fallback
-}
+import { pageVisible } from "../hooks/use-page-visible"
-function same<T>(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)
+function createGroupOpenState() {
+ const [state, setState] = createStore<Record<string, boolean>>({})
+ const read = (key?: string, collapse?: boolean) => {
+ if (!key) return true
+ const value = state[key]
+ if (value !== undefined) return value
+ return !collapse
}
- 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
+ const controlled = (key?: string) => {
+ if (!key) return false
+ return state[key] !== undefined
}
-
- 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
+ const write = (key: string, value: boolean) => {
+ setState(key, value)
+ }
+ return { read, controlled, write }
}
-function partByID(parts: readonly PartType[], partID: string) {
- return parts.find((part) => part.id === partID)
+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) {
@@ -444,7 +289,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) {
@@ -452,98 +298,328 @@ function partDefaultOpen(part: PartType, shell = false, edit = false) {
return toolDefaultOpen(part.tool, shell, edit)
}
+function PartGrow(props: {
+ children: JSX.Element
+ animate?: boolean
+ animateToggle?: boolean
+ gap?: number
+ fade?: boolean
+ edge?: boolean
+ edgeHeight?: number
+ edgeOpacity?: number
+ edgeIdle?: number
+ edgeFade?: number
+ edgeRise?: number
+ grow?: boolean
+ watch?: boolean
+ open?: boolean
+ spring?: import("./motion").SpringConfig
+ toggleSpring?: import("./motion").SpringConfig
+}) {
+ return (
+ <GrowBox
+ animate={props.animate !== false}
+ animateToggle={props.animateToggle}
+ fade={props.fade}
+ edge={props.edge}
+ edgeHeight={props.edgeHeight}
+ edgeOpacity={props.edgeOpacity}
+ edgeIdle={props.edgeIdle}
+ edgeFade={props.edgeFade}
+ edgeRise={props.edgeRise}
+ gap={props.gap}
+ grow={props.grow}
+ watch={props.watch}
+ open={props.open}
+ spring={props.spring}
+ toggleSpring={props.toggleSpring}
+ slot="assistant-part-grow"
+ >
+ {props.children}
+ </GrowBox>
+ )
+}
+
export function AssistantParts(props: {
messages: AssistantMessage[]
showAssistantCopyPartID?: string | null
- turnDurationMs?: number
+ showTurnDiffSummary?: boolean
+ turnDiffSummary?: () => JSX.Element
working?: boolean
showReasoningSummaries?: boolean
shellToolDefaultOpen?: boolean
editToolDefaultOpen?: boolean
+ animate?: boolean
}) {
const data = useData()
const emptyParts: PartType[] = []
- const emptyTools: ToolPart[] = []
-
- const grouped = createMemo(
- () =>
- groupParts(
- props.messages.flatMap((message) =>
- list(data.store.part?.[message.id], emptyParts)
- .filter((part) => renderable(part, props.showReasoningSummaries ?? true))
- .map((part) => ({
- messageID: message.id,
- part,
- })),
- ),
- ),
- [] as PartGroup[],
- { equals: sameGroups },
- )
+ const groupState = createGroupOpenState()
+ const grouped = createMemo(() => {
+ const keys: string[] = []
+ const items: Record<
+ string,
+ | {
+ type: "part"
+ part: PartType
+ message: AssistantMessage
+ context?: boolean
+ groupKey?: string
+ afterTool?: boolean
+ groupTail?: boolean
+ groupParts?: { part: ToolPart; message: AssistantMessage }[]
+ }
+ | {
+ type: "context"
+ groupKey: string
+ parts: { part: ToolPart; message: AssistantMessage }[]
+ tail: boolean
+ afterTool: boolean
+ }
+ > = {}
+ const push = (key: string, item: (typeof items)[string]) => {
+ keys.push(key)
+ items[key] = item
+ }
+ const id = (part: PartType) => {
+ if (part.type === "tool") return part.callID || part.id
+ return part.id
+ }
+ const parts = props.messages.flatMap((message) =>
+ list(data.store.part?.[message.id], emptyParts)
+ .filter((part) => renderable(part, props.showReasoningSummaries ?? true))
+ .map((part) => ({ message, part })),
+ )
- const last = createMemo(() => grouped().at(-1)?.key)
+ let start = -1
- return (
- <Index each={grouped()}>
- {(entryAccessor) => {
- const entryType = createMemo(() => entryAccessor().type)
-
- return (
- <Switch>
- <Match when={entryType() === "context"}>
- {(() => {
- const parts = createMemo(
- () => {
- const entry = entryAccessor()
- if (entry.type !== "context") return emptyTools
- return entry.refs
- .map((ref) => partByID(list(data.store.part?.[ref.messageID], emptyParts), ref.partID))
- .filter((part): part is ToolPart => !!part && isContextGroupTool(part))
- },
- emptyTools,
- { equals: same },
- )
- const busy = createMemo(() => props.working && last() === entryAccessor().key)
+ const flush = (end: number, tail: boolean, afterTool: boolean) => {
+ if (start < 0) return
+ const group = parts
+ .slice(start, end + 1)
+ .filter((entry): entry is { part: ToolPart; message: AssistantMessage } => isContextGroupTool(entry.part))
+ if (!group.length) {
+ start = -1
+ return
+ }
+ const groupKey = `context:${group[0].message.id}:${id(group[0].part)}`
+ push(groupKey, {
+ type: "context",
+ groupKey,
+ parts: group,
+ tail,
+ afterTool,
+ })
+ group.forEach((entry) => {
+ push(`part:${entry.message.id}:${id(entry.part)}`, {
+ type: "part",
+ part: entry.part,
+ message: entry.message,
+ context: true,
+ groupKey,
+ afterTool,
+ groupTail: tail,
+ groupParts: group,
+ })
+ })
+ start = -1
+ }
+ parts.forEach((item, index) => {
+ if (isContextGroupTool(item.part)) {
+ if (start < 0) start = index
+ return
+ }
- return (
- <Show when={parts().length > 0}>
- <ContextToolGroup parts={parts()} busy={busy()} />
- </Show>
- )
- })()}
- </Match>
- <Match when={entryType() === "part"}>
- {(() => {
- const message = createMemo(() => {
- const entry = entryAccessor()
- if (entry.type !== "part") return
- return props.messages.find((item) => item.id === entry.ref.messageID)
- })
- const part = createMemo(() => {
- const entry = entryAccessor()
- if (entry.type !== "part") return
- return partByID(list(data.store.part?.[entry.ref.messageID], emptyParts), entry.ref.partID)
- })
+ 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 })
+ })
- return (
- <Show when={message()}>
- <Show when={part()}>
- <Part
- part={part()!}
- message={message()!}
- showAssistantCopyPartID={props.showAssistantCopyPartID}
- turnDurationMs={props.turnDurationMs}
- defaultOpen={partDefaultOpen(part()!, props.shellToolDefaultOpen, props.editToolDefaultOpen)}
- />
+ flush(parts.length - 1, true, false)
+ return { keys, items }
+ })
+
+ const last = createMemo(() => grouped().keys.at(-1))
+
+ return (
+ <div data-component="assistant-parts">
+ <For each={grouped().keys}>
+ {(key) => {
+ const item = createMemo(() => grouped().items[key])
+ const ctx = createMemo(() => {
+ const value = item()
+ if (!value) return
+ if (value.type !== "context") return
+ return value
+ })
+ const part = createMemo(() => {
+ const value = item()
+ if (!value) return
+ if (value.type !== "part") return
+ return value
+ })
+ const tail = createMemo(() => last() === key)
+ const tool = createMemo(() => {
+ const value = part()
+ if (!value) return false
+ return value.part.type === "tool"
+ })
+ const context = createMemo(() => !!part()?.context)
+ const contextSpring = createMemo(() => {
+ const entry = part()
+ if (!entry?.context) return undefined
+ if (!groupState.controlled(entry.groupKey)) return undefined
+ return COLLAPSIBLE_SPRING
+ })
+ const contextOpen = createMemo(() => {
+ const 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(() => {
+ if (!context()) return true
+ if (ctx()) return true
+ return false
+ })
+
+ const turnSummary = createMemo(() => {
+ const value = part()
+ if (!value) return false
+ if (value.part.type !== "text") return false
+ if (!props.showTurnDiffSummary) return false
+ return props.showAssistantCopyPartID === value.part.id
+ })
+ const fade = createMemo(() => {
+ if (ctx()) return true
+ return tool()
+ })
+ const edge = createMemo(() => {
+ const entry = part()
+ if (!entry) return false
+ if (entry.part.type !== "text") return false
+ if (!props.working) return false
+ return tail()
+ })
+ const watch = createMemo(() => !context() && !tool() && tail() && !turnSummary())
+ const ctxPartsCache = new Map<string, ToolPart>()
+ let ctxPartsPrev: ToolPart[] = []
+ const ctxParts = createMemo(() => {
+ const parts = ctx()?.parts ?? []
+ if (parts.length === 0 && ctxPartsPrev.length > 0) return ctxPartsPrev
+ const result: ToolPart[] = []
+ for (const item of parts) {
+ const k = item.part.callID || item.part.id
+ const cached = ctxPartsCache.get(k)
+ if (cached) {
+ result.push(cached)
+ } else {
+ ctxPartsCache.set(k, item.part)
+ result.push(item.part)
+ }
+ }
+ ctxPartsPrev = result
+ return result
+ })
+ const ctxPendingRaw = useContextToolPending(ctxParts, () => !!(props.working && ctx()?.tail))
+ const ctxPending = ctxPendingRaw
+ const ctxHoldOpen = hold(ctxPendingRaw)
+ const shell = createMemo(() => {
+ const value = part()
+ if (!value) return
+ if (value.part.type !== "tool") return
+ if (value.part.tool !== "bash") return
+ return value.part
+ })
+ const kind = createMemo(() => {
+ if (ctx()) return "context"
+ if (shell()) return "shell"
+ const value = part()
+ if (!value) return "part"
+ return value.part.type
+ })
+ const shown = createMemo(() => {
+ if (ctx()) return true
+ if (shell()) return true
+ const entry = part()
+ if (!entry) return false
+ return !entry.context
+ })
+ const partGrowProps = () => ({
+ animate: props.animate,
+ gap: 0,
+ fade: fade(),
+ edge: edge(),
+ edgeHeight: 20,
+ edgeOpacity: 0.95,
+ edgeIdle: 100,
+ edgeFade: 0.6,
+ edgeRise: 0.1,
+ grow: true,
+ watch: watch(),
+ animateToggle: true,
+ open: visible(),
+ toggleSpring: contextSpring(),
+ })
+ return (
+ <Show when={shown()}>
+ <div data-component="assistant-part-item" data-kind={kind()} data-last={tail() ? "true" : "false"}>
+ <Show when={ctx()}>
+ {(entry) => (
+ <>
+ <PartGrow {...partGrowProps()}>
+ <ContextToolGroupHeader
+ parts={ctxParts()}
+ pending={ctxPending()}
+ open={contextOpen()}
+ onOpenChange={(value: boolean) => groupState.write(entry().groupKey, value)}
+ />
+ </PartGrow>
+ <ContextToolExpandedList parts={ctxParts()} expanded={!ctxPending() && contextOpen()} />
+ <ContextToolRollingResults parts={ctxParts()} pending={ctxHoldOpen()} />
+ </>
+ )}
+ </Show>
+ <Show when={shell()}>{(value) => <ShellRollingResults part={value()} animate={props.animate} />}</Show>
+ <Show when={!shell() ? part() : undefined}>
+ {(entry) => (
+ <Show when={!entry().context}>
+ <PartGrow {...partGrowProps()}>
+ <div>
+ <Part
+ part={entry().part}
+ message={entry().message}
+ showAssistantCopyPartID={props.showAssistantCopyPartID}
+ showTurnDiffSummary={props.showTurnDiffSummary}
+ turnDiffSummary={props.turnDiffSummary}
+ defaultOpen={partDefaultOpen(
+ entry().part,
+ props.shellToolDefaultOpen,
+ props.editToolDefaultOpen,
+ )}
+ hideDetails={false}
+ animate={props.animate}
+ working={props.working}
+ />
+ </div>
+ </PartGrow>
</Show>
- </Show>
- )
- })()}
- </Match>
- </Switch>
- )
- }}
- </Index>
+ )}
+ </Show>
+ </div>
+ </Show>
+ )
+ }}
+ </For>
+ </div>
)
}
@@ -551,76 +627,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<typeof useI18n>) {
- const input = (part.state.input ?? {}) as Record<string, unknown>
- const path = typeof input.path === "string" ? input.path : "/"
- const filePath = typeof input.filePath === "string" ? input.filePath : undefined
- const pattern = typeof input.pattern === "string" ? input.pattern : undefined
- const include = typeof input.include === "string" ? input.include : undefined
- const offset = typeof input.offset === "number" ? input.offset : undefined
- const limit = typeof input.limit === "number" ? input.limit : undefined
-
- switch (part.tool) {
- case "read": {
- const args: string[] = []
- if (offset !== undefined) args.push("offset=" + offset)
- if (limit !== undefined) args.push("limit=" + limit)
- return {
- title: i18n.t("ui.tool.read"),
- subtitle: filePath ? getFilename(filePath) : "",
- args,
- }
- }
- case "list":
- return {
- title: i18n.t("ui.tool.list"),
- subtitle: getDirectory(path),
- }
- case "glob":
- return {
- title: i18n.t("ui.tool.glob"),
- subtitle: getDirectory(path),
- args: pattern ? ["pattern=" + pattern] : [],
- }
- case "grep": {
- const args: string[] = []
- if (pattern) args.push("pattern=" + pattern)
- if (include) args.push("include=" + include)
- return {
- title: i18n.t("ui.tool.grep"),
- subtitle: getDirectory(path),
- args,
- }
- }
- default: {
- const info = getToolInfo(part.tool, input)
- return {
- title: info.title,
- subtitle: info.subtitle || contextToolDetail(part),
- args: [],
- }
- }
- }
-}
-
-function contextToolSummary(parts: ToolPart[]) {
- const read = parts.filter((part) => part.tool === "read").length
- const search = parts.filter((part) => part.tool === "glob" || part.tool === "grep").length
- const list = parts.filter((part) => part.tool === "list").length
- return { read, search, list }
-}
-
function ExaOutput(props: { output?: string }) {
const links = createMemo(() => urls(props.output))
@@ -651,210 +657,11 @@ export function registerPartComponent(type: string, component: PartComponent) {
PART_MAPPING[type] = component
}
-export function Message(props: MessageProps) {
- return (
- <Switch>
- <Match when={props.message.role === "user" && props.message}>
- {(userMessage) => (
- <UserMessageDisplay
- message={userMessage() as UserMessage}
- parts={props.parts}
- interrupted={props.interrupted}
- queued={props.queued}
- />
- )}
- </Match>
- <Match when={props.message.role === "assistant" && props.message}>
- {(assistantMessage) => (
- <AssistantMessageDisplay
- message={assistantMessage() as AssistantMessage}
- parts={props.parts}
- showAssistantCopyPartID={props.showAssistantCopyPartID}
- showReasoningSummaries={props.showReasoningSummaries}
- />
- )}
- </Match>
- </Switch>
- )
-}
-
-export function AssistantMessageDisplay(props: {
- message: AssistantMessage
- parts: PartType[]
- showAssistantCopyPartID?: string | null
- showReasoningSummaries?: boolean
-}) {
- const emptyTools: ToolPart[] = []
- const grouped = createMemo(
- () =>
- groupParts(
- props.parts
- .filter((part) => renderable(part, props.showReasoningSummaries ?? true))
- .map((part) => ({
- messageID: props.message.id,
- part,
- })),
- ),
- [] as PartGroup[],
- { equals: sameGroups },
- )
-
- return (
- <Index each={grouped()}>
- {(entryAccessor) => {
- const entryType = createMemo(() => entryAccessor().type)
-
- return (
- <Switch>
- <Match when={entryType() === "context"}>
- {(() => {
- const parts = createMemo(
- () => {
- const entry = entryAccessor()
- if (entry.type !== "context") return emptyTools
- return entry.refs
- .map((ref) => partByID(props.parts, ref.partID))
- .filter((part): part is ToolPart => !!part && isContextGroupTool(part))
- },
- emptyTools,
- { equals: same },
- )
-
- return (
- <Show when={parts().length > 0}>
- <ContextToolGroup parts={parts()} />
- </Show>
- )
- })()}
- </Match>
- <Match when={entryType() === "part"}>
- {(() => {
- const part = createMemo(() => {
- const entry = entryAccessor()
- if (entry.type !== "part") return
- return partByID(props.parts, entry.ref.partID)
- })
-
- return (
- <Show when={part()}>
- <Part
- part={part()!}
- message={props.message}
- showAssistantCopyPartID={props.showAssistantCopyPartID}
- />
- </Show>
- )
- })()}
- </Match>
- </Switch>
- )
- }}
- </Index>
- )
-}
-
-function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
- const i18n = useI18n()
- const [open, setOpen] = createSignal(false)
- const pending = createMemo(
- () =>
- !!props.busy || props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"),
- )
- const summary = createMemo(() => contextToolSummary(props.parts))
-
- return (
- <Collapsible open={open()} onOpenChange={setOpen} variant="ghost">
- <Collapsible.Trigger>
- <div data-component="context-tool-group-trigger">
- <span
- data-slot="context-tool-group-title"
- class="min-w-0 flex items-center gap-2 text-14-medium text-text-strong"
- >
- <span data-slot="context-tool-group-label" class="shrink-0">
- <ToolStatusTitle
- active={pending()}
- activeText={i18n.t("ui.sessionTurn.status.gatheringContext")}
- doneText={i18n.t("ui.sessionTurn.status.gatheredContext")}
- split={false}
- />
- </span>
- <span
- data-slot="context-tool-group-summary"
- class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-normal text-text-base"
- >
- <AnimatedCountList
- items={[
- {
- key: "read",
- count: summary().read,
- one: i18n.t("ui.messagePart.context.read.one"),
- other: i18n.t("ui.messagePart.context.read.other"),
- },
- {
- key: "search",
- count: summary().search,
- one: i18n.t("ui.messagePart.context.search.one"),
- other: i18n.t("ui.messagePart.context.search.other"),
- },
- {
- key: "list",
- count: summary().list,
- one: i18n.t("ui.messagePart.context.list.one"),
- other: i18n.t("ui.messagePart.context.list.other"),
- },
- ]}
- fallback=""
- />
- </span>
- </span>
- <Collapsible.Arrow />
- </div>
- </Collapsible.Trigger>
- <Collapsible.Content>
- <div data-component="context-tool-group-list">
- <Index each={props.parts}>
- {(partAccessor) => {
- const trigger = createMemo(() => contextToolTrigger(partAccessor(), i18n))
- const running = createMemo(
- () => partAccessor().state.status === "pending" || partAccessor().state.status === "running",
- )
- return (
- <div data-slot="context-tool-group-item">
- <div data-component="tool-trigger">
- <div data-slot="basic-tool-tool-trigger-content">
- <div data-slot="basic-tool-tool-info">
- <div data-slot="basic-tool-tool-info-structured">
- <div data-slot="basic-tool-tool-info-main">
- <span data-slot="basic-tool-tool-title">
- <TextShimmer text={trigger().title} active={running()} />
- </span>
- <Show when={!running() && trigger().subtitle}>
- <span data-slot="basic-tool-tool-subtitle">{trigger().subtitle}</span>
- </Show>
- <Show when={!running() && trigger().args?.length}>
- <For each={trigger().args}>
- {(arg) => <span data-slot="basic-tool-tool-arg">{arg}</span>}
- </For>
- </Show>
- </div>
- </div>
- </div>
- </div>
- </div>
- </div>
- )
- }}
- </Index>
- </div>
- </Collapsible.Content>
- </Collapsible>
- )
-}
-
export function UserMessageDisplay(props: {
message: UserMessage
parts: PartType[]
interrupted?: boolean
+ animate?: boolean
queued?: boolean
}) {
const data = useData()
@@ -904,14 +711,9 @@ 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()]
- return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0")
- })
-
- const metaTail = createMemo(() => {
- const items = [stamp(), props.interrupted ? i18n.t("ui.message.interrupted") : ""]
+ const items = [agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", model(), stamp()]
return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0")
})
@@ -928,93 +730,83 @@ export function UserMessageDisplay(props: {
}
return (
- <div data-component="user-message" data-interrupted={props.interrupted ? "" : undefined}>
- <Show when={attachments().length > 0}>
- <div data-slot="user-message-attachments">
- <For each={attachments()}>
- {(file) => (
- <div
- data-slot="user-message-attachment"
- data-type={file.mime.startsWith("image/") ? "image" : "file"}
- data-queued={props.queued ? "" : undefined}
- onClick={() => {
- if (file.mime.startsWith("image/") && file.url) {
- openImagePreview(file.url, file.filename)
- }
- }}
- >
- <Show
- when={file.mime.startsWith("image/") && file.url}
- fallback={
- <div data-slot="user-message-attachment-icon">
- <Icon name="folder" />
- </div>
- }
- >
- <img
- data-slot="user-message-attachment-image"
- src={file.url}
- alt={file.filename ?? i18n.t("ui.message.attachment.alt")}
- />
- </Show>
- </div>
- )}
- </For>
- </div>
- </Show>
- <Show when={text()}>
- <>
- <div data-slot="user-message-body">
- <div data-slot="user-message-text" data-queued={props.queued ? "" : undefined}>
- <HighlightedText text={text()} references={inlineFiles()} agents={agents()} />
+ <GrowBox animate={!!props.animate} fade class="w-full min-w-0 self-stretch max-w-full">
+ <div data-component="user-message" data-interrupted={props.interrupted ? "" : undefined}>
+ <div data-slot="user-message-inner">
+ <Show when={attachments().length > 0}>
+ <div data-slot="user-message-attachments">
+ <For each={attachments()}>
+ {(file) => (
+ <div
+ data-slot="user-message-attachment"
+ data-type={file.mime.startsWith("image/") ? "image" : "file"}
+ data-queued={props.queued ? "" : undefined}
+ onClick={() => {
+ if (file.mime.startsWith("image/") && file.url) {
+ openImagePreview(file.url, file.filename)
+ }
+ }}
+ >
+ <Show
+ when={file.mime.startsWith("image/") && file.url}
+ fallback={
+ <div data-slot="user-message-attachment-icon">
+ <Icon name="folder" />
+ </div>
+ }
+ >
+ <img
+ data-slot="user-message-attachment-image"
+ src={file.url}
+ alt={file.filename ?? i18n.t("ui.message.attachment.alt")}
+ />
+ </Show>
+ </div>
+ )}
+ </For>
</div>
- <Show when={props.queued}>
- <div data-slot="user-message-queued-indicator">
- <TextShimmer text={i18n.t("ui.message.queued")} />
+ </Show>
+ <Show when={text()}>
+ <>
+ <div data-slot="user-message-body">
+ <div data-slot="user-message-text" data-queued={props.queued ? "" : undefined}>
+ <HighlightedText text={text()} references={inlineFiles()} agents={agents()} />
+ </div>
+ <GrowBox animate={!!props.animate} open={!!props.queued}>
+ <div data-slot="user-message-queued-indicator">
+ <TextShimmer text={i18n.t("ui.message.queued")} />
+ </div>
+ </GrowBox>
</div>
- </Show>
- </div>
- <div data-slot="user-message-copy-wrapper" data-interrupted={props.interrupted ? "" : undefined}>
- <Show when={metaHead() || metaTail()}>
- <span data-slot="user-message-meta-wrap">
- <Show when={metaHead()}>
+ <div data-slot="user-message-copy-wrapper" data-interrupted={props.interrupted ? "" : undefined}>
+ <Show when={userMeta()}>
<span data-slot="user-message-meta" class="text-12-regular text-text-weak cursor-default">
- {metaHead()}
- </span>
- </Show>
- <Show when={metaHead() && metaTail()}>
- <span data-slot="user-message-meta-sep" class="text-12-regular text-text-weak cursor-default">
- {"\u00A0\u00B7\u00A0"}
- </span>
- </Show>
- <Show when={metaTail()}>
- <span data-slot="user-message-meta-tail" class="text-12-regular text-text-weak cursor-default">
- {metaTail()}
+ {userMeta()}
</span>
</Show>
- </span>
- </Show>
- <Tooltip
- value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
- placement="top"
- gutter={4}
- >
- <IconButton
- icon={copied() ? "check" : "copy"}
- size="normal"
- variant="ghost"
- onMouseDown={(e) => e.preventDefault()}
- onClick={(event) => {
- event.stopPropagation()
- handleCopy()
- }}
- aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
- />
- </Tooltip>
- </div>
- </>
- </Show>
- </div>
+ <Tooltip
+ value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
+ placement="top"
+ gutter={4}
+ >
+ <IconButton
+ icon={copied() ? "check" : "copy"}
+ size="normal"
+ variant="ghost"
+ onMouseDown={(e) => e.preventDefault()}
+ onClick={(event) => {
+ event.stopPropagation()
+ handleCopy()
+ }}
+ aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
+ />
+ </Tooltip>
+ </div>
+ </>
+ </Show>
+ </div>
+ </div>
+ </GrowBox>
)
}
@@ -1068,7 +860,10 @@ export function Part(props: MessagePartProps) {
hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen}
showAssistantCopyPartID={props.showAssistantCopyPartID}
- turnDurationMs={props.turnDurationMs}
+ showTurnDiffSummary={props.showTurnDiffSummary}
+ turnDiffSummary={props.turnDiffSummary}
+ animate={props.animate}
+ working={props.working}
/>
</Show>
)
@@ -1078,12 +873,16 @@ export interface ToolProps {
input: Record<string, any>
metadata: Record<string, any>
tool: string
+ partID?: string
+ callID?: string
output?: string
status?: string
hideDetails?: boolean
defaultOpen?: boolean
forceOpen?: boolean
locked?: boolean
+ animate?: boolean
+ reveal?: boolean
}
export type ToolComponent = Component<ToolProps>
@@ -1117,7 +916,7 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre
<Accordion
multiple
data-scope="apply-patch"
- style={{ "--sticky-accordion-offset": "40px" }}
+ style={{ "--sticky-accordion-offset": "37px" }}
defaultValue={[value()]}
>
<Accordion.Item value={value()}>
@@ -1148,30 +947,26 @@ 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" && (part().state.status === "pending" || part().state.status === "running"),
- )
+ const part = props.part as ToolPart
+ const hideQuestion = createMemo(() => part.tool === "question" && busy(part.state.status))
const emptyInput: Record<string, any> = {}
const emptyMetadata: Record<string, any> = {}
- 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 (
<Show when={!hideQuestion()}>
- <div data-component="tool-part-wrapper">
+ <div data-component="tool-part-wrapper" data-tool={part.tool}>
<Switch>
- <Match when={part().state.status === "error" && (part().state as any).error}>
+ <Match when={part.state.status === "error" && part.state.error}>
{(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 (
<div style="width: 100%; display: flex; justify-content: flex-end;">
<span class="text-13-regular text-text-weak cursor-default">
@@ -1205,13 +1000,17 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
<Dynamic
component={render()}
input={input()}
- tool={part().tool}
+ tool={part.tool}
+ partID={part.id}
+ 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
+ reveal={props.animate}
/>
</Match>
</Switch>
@@ -1236,74 +1035,16 @@ PART_MAPPING["compaction"] = function CompactionPartDisplay() {
}
PART_MAPPING["text"] = function TextPartDisplay(props) {
- const data = useData()
- const i18n = useI18n()
const part = () => props.part as TextPart
- const interrupted = createMemo(
- () =>
- props.message.role === "assistant" && (props.message as AssistantMessage).error?.name === "MessageAbortedError",
- )
-
- const model = createMemo(() => {
- if (props.message.role !== "assistant") return ""
- const message = props.message as AssistantMessage
- const match = data.store.provider?.all?.find((p) => p.id === message.providerID)
- return match?.models?.[message.modelID]?.name ?? message.modelID
- })
-
- const duration = createMemo(() => {
- if (props.message.role !== "assistant") return ""
- const message = props.message as AssistantMessage
- const completed = message.time.completed
- const ms =
- typeof props.turnDurationMs === "number"
- ? props.turnDurationMs
- : typeof completed === "number"
- ? completed - message.time.created
- : -1
- if (!(ms >= 0)) return ""
- const total = Math.round(ms / 1000)
- if (total < 60) return `${total}s`
- const minutes = Math.floor(total / 60)
- const seconds = total % 60
- return `${minutes}m ${seconds}s`
- })
-
- const meta = createMemo(() => {
- if (props.message.role !== "assistant") return ""
- const agent = (props.message as AssistantMessage).agent
- const items = [
- agent ? agent[0]?.toUpperCase() + agent.slice(1) : "",
- model(),
- duration(),
- interrupted() ? i18n.t("ui.message.interrupted") : "",
- ]
- return items.filter((x) => !!x).join(" \u00B7 ")
- })
const displayText = () => (part().text ?? "").trim()
const throttledText = createThrottledValue(displayText)
- const 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 summary = createMemo(() => {
+ if (props.message.role !== "assistant") return
+ if (!props.showTurnDiffSummary) return
+ if (props.showAssistantCopyPartID !== part().id) return
+ return props.turnDiffSummary
})
- const showCopy = createMemo(() => {
- if (props.message.role !== "assistant") return isLastTextPart()
- if (props.showAssistantCopyPartID === null) return false
- if (typeof props.showAssistantCopyPartID === "string") return props.showAssistantCopyPartID === part().id
- return isLastTextPart()
- })
- const [copied, setCopied] = createSignal(false)
-
- const handleCopy = async () => {
- const content = displayText()
- if (!content) return
- await navigator.clipboard.writeText(content)
- setCopied(true)
- setTimeout(() => setCopied(false), 2000)
- }
return (
<Show when={throttledText()}>
@@ -1311,28 +1052,12 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
<div data-slot="text-part-body">
<Markdown text={throttledText()} cacheKey={part().id} />
</div>
- <Show when={showCopy()}>
- <div data-slot="text-part-copy-wrapper" data-interrupted={interrupted() ? "" : undefined}>
- <Tooltip
- value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")}
- placement="top"
- gutter={4}
- >
- <IconButton
- icon={copied() ? "check" : "copy"}
- size="normal"
- variant="ghost"
- onMouseDown={(e) => e.preventDefault()}
- onClick={handleCopy}
- aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")}
- />
- </Tooltip>
- <Show when={meta()}>
- <span data-slot="text-part-meta" class="text-12-regular text-text-weak cursor-default">
- {meta()}
- </span>
- </Show>
- </div>
+ <Show when={summary()}>
+ {(render) => (
+ <GrowBox animate={!!props.animate} fade gap={4} class="w-full min-w-0">
+ <div data-slot="text-part-turn-summary">{render()()}</div>
+ </GrowBox>
+ )}
</Show>
</div>
</Show>
@@ -1362,30 +1087,33 @@ ToolRegistry.register({
if (props.input.offset) args.push("offset=" + props.input.offset)
if (props.input.limit) args.push("limit=" + props.input.limit)
const loaded = createMemo(() => {
- if (props.status !== "completed") return []
const value = props.metadata.loaded
if (!value || !Array.isArray(value)) return []
return value.filter((p): p is string => typeof p === "string")
})
+ const pending = createMemo(() => busy(props.status))
return (
<>
- <BasicTool
+ <ToolCall
+ variant="row"
{...props}
icon="glasses"
- trigger={{
- title: i18n.t("ui.tool.read"),
- subtitle: props.input.filePath ? getFilename(props.input.filePath) : "",
- args,
- }}
+ trigger={
+ <ToolTriggerRow
+ title={i18n.t("ui.tool.read")}
+ pending={pending()}
+ subtitle={props.input.filePath ? getFilename(props.input.filePath) : ""}
+ args={args}
+ animate={props.reveal}
+ />
+ }
/>
<For each={loaded()}>
{(filepath) => (
- <div data-component="tool-loaded-file">
- <Icon name="enter" size="small" />
- <span>
- {i18n.t("ui.tool.loaded")} {relativizeProjectPath(filepath, data.directory)}
- </span>
- </div>
+ <ToolLoadedFile
+ text={`${i18n.t("ui.tool.loaded")} ${relativizeProjectPath(filepath, data.directory)}`}
+ animate={props.reveal}
+ />
)}
</For>
</>
@@ -1397,18 +1125,29 @@ ToolRegistry.register({
name: "list",
render(props) {
const i18n = useI18n()
+ const pending = createMemo(() => busy(props.status))
return (
- <BasicTool
+ <ToolCall
+ variant="panel"
{...props}
icon="bullet-list"
- trigger={{ title: i18n.t("ui.tool.list"), subtitle: getDirectory(props.input.path || "/") }}
+ trigger={
+ <ToolTriggerRow
+ title={i18n.t("ui.tool.list")}
+ pending={pending()}
+ subtitle={getDirectory(props.input.path)}
+ animate={props.reveal}
+ />
+ }
>
<Show when={props.output}>
- <div data-component="tool-output" data-scrollable>
- <Markdown text={props.output!} />
- </div>
+ {(output) => (
+ <div data-component="tool-output" data-scrollable>
+ <Markdown text={output()} />
+ </div>
+ )}
</Show>
- </BasicTool>
+ </ToolCall>
)
},
})
@@ -1417,22 +1156,30 @@ ToolRegistry.register({
name: "glob",
render(props) {
const i18n = useI18n()
+ const pending = createMemo(() => busy(props.status))
return (
- <BasicTool
+ <ToolCall
+ variant="panel"
{...props}
icon="magnifying-glass-menu"
- trigger={{
- title: i18n.t("ui.tool.glob"),
- subtitle: getDirectory(props.input.path || "/"),
- args: props.input.pattern ? ["pattern=" + props.input.pattern] : [],
- }}
+ trigger={
+ <ToolTriggerRow
+ title={i18n.t("ui.tool.glob")}
+ pending={pending()}
+ subtitle={getDirectory(props.input.path)}
+ args={props.input.pattern ? ["pattern=" + props.input.pattern] : []}
+ animate={props.reveal}
+ />
+ }
>
<Show when={props.output}>
- <div data-component="tool-output" data-scrollable>
- <Markdown text={props.output!} />
- </div>
+ {(output) => (
+ <div data-component="tool-output" data-scrollable>
+ <Markdown text={output()} />
+ </div>
+ )}
</Show>
- </BasicTool>
+ </ToolCall>
)
},
})
@@ -1444,40 +1191,214 @@ ToolRegistry.register({
const args: string[] = []
if (props.input.pattern) args.push("pattern=" + props.input.pattern)
if (props.input.include) args.push("include=" + props.input.include)
+ const pending = createMemo(() => busy(props.status))
return (
- <BasicTool
+ <ToolCall
+ variant="panel"
{...props}
icon="magnifying-glass-menu"
- trigger={{
- title: i18n.t("ui.tool.grep"),
- subtitle: getDirectory(props.input.path || "/"),
- args,
- }}
+ trigger={
+ <ToolTriggerRow
+ title={i18n.t("ui.tool.grep")}
+ pending={pending()}
+ subtitle={getDirectory(props.input.path)}
+ args={args}
+ animate={props.reveal}
+ />
+ }
>
<Show when={props.output}>
- <div data-component="tool-output" data-scrollable>
- <Markdown text={props.output!} />
- </div>
+ {(output) => (
+ <div data-component="tool-output" data-scrollable>
+ <Markdown text={output()} />
+ </div>
+ )}
</Show>
- </BasicTool>
+ </ToolCall>
)
},
})
+function useToolReveal(pending: () => boolean, animate?: () => boolean) {
+ const enabled = () => animate?.() ?? true
+ const [live, setLive] = createSignal(pending() || enabled())
+ createEffect(() => {
+ if (pending()) setLive(true)
+ })
+ return () => enabled() && live()
+}
+
+function WebfetchMeta(props: { url: string; animate?: boolean }) {
+ let ref: HTMLSpanElement | undefined
+ useToolFade(() => ref, { wipe: true, animate: props.animate })
+
+ return (
+ <span ref={ref} data-slot="webfetch-meta">
+ <a
+ data-slot="basic-tool-tool-subtitle"
+ class="clickable subagent-link"
+ href={props.url}
+ target="_blank"
+ rel="noopener noreferrer"
+ onClick={(event) => event.stopPropagation()}
+ >
+ {props.url}
+ </a>
+ <div data-component="tool-action">
+ <Icon name="square-arrow-top-right" size="small" />
+ </div>
+ </span>
+ )
+}
+
+function TaskLink(props: { href: string; text: string; onClick: (e: MouseEvent) => void; animate?: boolean }) {
+ let ref: HTMLAnchorElement | undefined
+ useToolFade(() => ref, { wipe: true, animate: props.animate })
+
+ return (
+ <a
+ ref={ref}
+ data-slot="basic-tool-tool-subtitle"
+ class="clickable subagent-link"
+ href={props.href}
+ onClick={props.onClick}
+ >
+ {props.text}
+ </a>
+ )
+}
+
+function ToolText(props: { text: string; delay?: number; animate?: boolean }) {
+ let ref: HTMLSpanElement | undefined
+ useToolFade(() => ref, { delay: props.delay, wipe: true, animate: props.animate })
+
+ return (
+ <span ref={ref} data-slot="basic-tool-tool-subtitle">
+ {props.text}
+ </span>
+ )
+}
+
+function ToolLoadedFile(props: { text: string; animate?: boolean }) {
+ let ref: HTMLDivElement | undefined
+ useToolFade(() => ref, { delay: 0.02, wipe: true, animate: props.animate })
+
+ return (
+ <GrowBox animate={props.animate !== false} fade={false} class="w-full min-w-0">
+ <div ref={ref} data-component="tool-loaded-file">
+ <Icon name="enter" size="small" />
+ <span>{props.text}</span>
+ </div>
+ </GrowBox>
+ )
+}
+
+function ToolTriggerRow(props: {
+ title: string
+ pending: boolean
+ subtitle?: string
+ args?: string[]
+ action?: JSX.Element
+ animate?: boolean
+ revealOnMount?: boolean
+}) {
+ const reveal = useToolReveal(
+ () => props.pending,
+ () => props.animate !== false,
+ )
+ const detail = createMemo(() => [props.subtitle, ...(props.args ?? [])].filter((x): x is string => !!x).join(" "))
+ const detailAnimate = createMemo(() => {
+ if (props.animate === false) return false
+ if (props.revealOnMount) return true
+ if (!props.pending && !reveal()) return true
+ return reveal()
+ })
+
+ return (
+ <div data-slot="basic-tool-tool-info-structured">
+ <div data-slot="basic-tool-tool-info-main">
+ <span data-slot="basic-tool-tool-title">
+ <TextShimmer text={props.title} active={props.pending} />
+ </span>
+ <Show when={detail()}>{(text) => <ToolText text={text()} animate={detailAnimate()} />}</Show>
+ </div>
+ <Show when={props.action}>{props.action}</Show>
+ </div>
+ )
+}
+
+type DiffValue = { additions: number; deletions: number } | { additions: number; deletions: number }[]
+
+function ToolMetaLine(props: {
+ filename: string
+ path?: string
+ changes?: DiffValue
+ delay?: number
+ animate?: boolean
+ soft?: boolean
+}) {
+ let ref: HTMLSpanElement | undefined
+ useToolFade(() => ref, { delay: props.delay ?? 0.02, wipe: true, animate: props.animate })
+
+ return (
+ <span
+ ref={ref}
+ data-slot={props.soft ? "basic-tool-tool-subtitle" : "message-part-meta-line"}
+ classList={{
+ "message-part-meta-line": !!props.soft,
+ soft: !!props.soft,
+ }}
+ >
+ <span data-slot="message-part-title-filename">{props.filename}</span>
+ <Show when={props.path}>
+ <span data-slot="message-part-directory-inline">{props.path}</span>
+ </Show>
+ <Show when={props.changes}>{(changes) => <DiffChanges changes={changes()} />}</Show>
+ </span>
+ )
+}
+
+function ToolChanges(props: { changes: DiffValue; animate?: boolean }) {
+ let ref: HTMLDivElement | undefined
+ useToolFade(() => ref, { delay: 0.04, animate: props.animate })
+
+ return (
+ <div ref={ref}>
+ <DiffChanges changes={props.changes} />
+ </div>
+ )
+}
+
+function ShellText(props: { text: string; animate?: boolean }) {
+ let ref: HTMLSpanElement | undefined
+ useToolFade(() => ref, { wipe: true, animate: props.animate })
+
+ return (
+ <span data-component="shell-submessage">
+ <span data-slot="basic-tool-tool-subtitle">
+ <span ref={ref} data-slot="shell-submessage-value">
+ {props.text}
+ </span>
+ </span>
+ </span>
+ )
+}
+
ToolRegistry.register({
name: "webfetch",
render(props) {
const i18n = useI18n()
- const pending = createMemo(() => 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
if (typeof value !== "string") return ""
return value
})
return (
- <BasicTool
+ <ToolCall
+ variant="row"
{...props}
- hideDetails
icon="window-cursor"
trigger={
<div data-slot="basic-tool-tool-info-structured">
@@ -1485,24 +1406,8 @@ ToolRegistry.register({
<span data-slot="basic-tool-tool-title">
<TextShimmer text={i18n.t("ui.tool.webfetch")} active={pending()} />
</span>
- <Show when={!pending() && url()}>
- <a
- data-slot="basic-tool-tool-subtitle"
- class="clickable subagent-link"
- href={url()}
- target="_blank"
- rel="noopener noreferrer"
- onClick={(event) => event.stopPropagation()}
- >
- {url()}
- </a>
- </Show>
+ <Show when={url()}>{(value) => <WebfetchMeta url={value()} animate={reveal()} />}</Show>
</div>
- <Show when={!pending() && url()}>
- <div data-component="tool-action">
- <Icon name="square-arrow-top-right" size="small" />
- </div>
- </Show>
</div>
}
/>
@@ -1521,7 +1426,8 @@ ToolRegistry.register({
})
return (
- <BasicTool
+ <ToolCall
+ variant="panel"
{...props}
icon="window-cursor"
trigger={{
@@ -1531,7 +1437,7 @@ ToolRegistry.register({
}}
>
<ExaOutput output={props.output} />
- </BasicTool>
+ </ToolCall>
)
},
})
@@ -1547,7 +1453,8 @@ ToolRegistry.register({
})
return (
- <BasicTool
+ <ToolCall
+ variant="panel"
{...props}
icon="code"
trigger={{
@@ -1557,7 +1464,7 @@ ToolRegistry.register({
}}
>
<ExaOutput output={props.output} />
- </BasicTool>
+ </ToolCall>
)
},
})
@@ -1567,15 +1474,19 @@ ToolRegistry.register({
render(props) {
const data = useData()
const i18n = useI18n()
- const location = useLocation()
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
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(() => {
const sessionId = childSessionId()
@@ -1584,34 +1495,50 @@ ToolRegistry.register({
const direct = data.sessionHref?.(sessionId)
if (direct) return direct
- const path = location.pathname
+ if (typeof window === "undefined") return
+ const path = window.location.pathname
const idx = path.indexOf("/session")
if (idx === -1) return
return `${path.slice(0, idx)}/session/${sessionId}`
})
- const titleContent = () => <TextShimmer text={title()} active={running()} />
+ 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 trigger = () => (
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
- <span data-slot="basic-tool-tool-title" class="capitalize agent-title">
- {titleContent()}
+ <span data-slot="basic-tool-tool-title">
+ <TextShimmer text={i18n.t("ui.tool.agent")} active={running()} />
</span>
+ <Show when={agentType()}>{(type) => <ToolText text={type()} animate={reveal()} />}</Show>
<Show when={description()}>
<Switch>
<Match when={href()}>
- <a
- data-slot="basic-tool-tool-subtitle"
- class="clickable subagent-link"
- href={href()!}
- onClick={(e) => e.stopPropagation()}
- >
- {description()}
- </a>
+ {(url) => (
+ <TaskLink href={url()} text={description() ?? ""} onClick={handleLinkClick} animate={reveal()} />
+ )}
</Match>
<Match when={true}>
- <span data-slot="basic-tool-tool-subtitle">{description()}</span>
+ <ToolText text={description() ?? ""} delay={0.02} animate={reveal()} />
</Match>
</Switch>
</Show>
@@ -1619,7 +1546,7 @@ ToolRegistry.register({
</div>
)
- return <BasicTool icon="task" status={props.status} trigger={trigger()} hideDetails />
+ return <ToolCall variant="row" icon="task" status={props.status} trigger={trigger()} animate />
},
})
@@ -1627,13 +1554,26 @@ ToolRegistry.register({
name: "bash",
render(props) {
const i18n = useI18n()
- const pending = () => props.status === "pending" || props.status === "running"
- const sawPending = pending()
+ const pending = () => busy(props.status)
+ const reveal = useToolReveal(pending, () => props.reveal !== false)
+ const subtitle = () => props.input.description ?? props.metadata.description
+ const cmd = createMemo(() => {
+ const value = props.input.command ?? props.metadata.command
+ if (typeof value === "string") return value
+ return ""
+ })
+ const output = createMemo(() => {
+ if (typeof props.output === "string") return props.output
+ if (typeof props.metadata.output === "string") return props.metadata.output
+ return ""
+ })
+ const command = createMemo(() => `$ ${cmd()}`)
+ const result = createMemo(() => stripAnsi(output()))
const text = createMemo(() => {
- const cmd = props.input.command ?? props.metadata.command ?? ""
- const out = stripAnsi(props.output || props.metadata.output || "")
- return `$ ${cmd}${out ? "\n\n" + out : ""}`
+ const value = result()
+ return `${command()}${value ? "\n\n" + value : ""}`
})
+ const hasOutput = createMemo(() => result().length > 0)
const [copied, setCopied] = createSignal(false)
const handleCopy = async () => {
@@ -1645,18 +1585,20 @@ ToolRegistry.register({
}
return (
- <BasicTool
+ <ToolCall
+ variant="panel"
{...props}
icon="console"
+ animate
+ springContent
+ defaultOpen={false}
trigger={
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">
<TextShimmer text={i18n.t("ui.tool.shell")} active={pending()} />
</span>
- <Show when={!pending() && props.input.description}>
- <ShellSubmessage text={props.input.description} animate={sawPending} />
- </Show>
+ <Show when={subtitle()}>{(text) => <ShellText text={text()} animate={reveal()} />}</Show>
</div>
</div>
}
@@ -1684,7 +1626,7 @@ ToolRegistry.register({
</pre>
</div>
</div>
- </BasicTool>
+ </ToolCall>
)
},
})
@@ -1697,10 +1639,12 @@ 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 (
<div data-component="edit-tool">
- <BasicTool
+ <ToolCall
+ variant="panel"
{...props}
icon="code-lines"
defer
@@ -1711,20 +1655,17 @@ ToolRegistry.register({
<span data-slot="message-part-title-text">
<TextShimmer text={i18n.t("ui.messagePart.title.edit")} active={pending()} />
</span>
- <Show when={!pending()}>
- <span data-slot="message-part-title-filename">{filename()}</span>
+ <Show when={filename()}>
+ {(name) => (
+ <ToolMetaLine
+ filename={name()}
+ path={props.input.filePath?.includes("/") ? getDirectory(props.input.filePath!) : undefined}
+ changes={props.metadata.filediff}
+ animate={reveal()}
+ />
+ )}
</Show>
</div>
- <Show when={!pending() && props.input.filePath?.includes("/")}>
- <div data-slot="message-part-path">
- <span data-slot="message-part-directory">{getDirectory(props.input.filePath!)}</span>
- </div>
- </Show>
- </div>
- <div data-slot="message-part-actions">
- <Show when={!pending() && props.metadata.filediff}>
- <DiffChanges changes={props.metadata.filediff} />
- </Show>
</div>
</div>
}
@@ -1734,7 +1675,7 @@ ToolRegistry.register({
path={path()}
actions={
<Show when={!pending() && props.metadata.filediff}>
- <DiffChanges changes={props.metadata.filediff!} />
+ {(diff) => <ToolChanges changes={diff()} animate={reveal()} />}
</Show>
}
>
@@ -1755,7 +1696,7 @@ ToolRegistry.register({
</ToolFileAccordion>
</Show>
<DiagnosticsDisplay diagnostics={diagnostics()} />
- </BasicTool>
+ </ToolCall>
</div>
)
},
@@ -1769,10 +1710,12 @@ 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 (
<div data-component="write-tool">
- <BasicTool
+ <ToolCall
+ variant="panel"
{...props}
icon="code-lines"
defer
@@ -1783,17 +1726,17 @@ ToolRegistry.register({
<span data-slot="message-part-title-text">
<TextShimmer text={i18n.t("ui.messagePart.title.write")} active={pending()} />
</span>
- <Show when={!pending()}>
- <span data-slot="message-part-title-filename">{filename()}</span>
+ <Show when={filename()}>
+ {(name) => (
+ <ToolMetaLine
+ filename={name()}
+ path={props.input.filePath?.includes("/") ? getDirectory(props.input.filePath!) : undefined}
+ animate={reveal()}
+ />
+ )}
</Show>
</div>
- <Show when={!pending() && props.input.filePath?.includes("/")}>
- <div data-slot="message-part-path">
- <span data-slot="message-part-directory">{getDirectory(props.input.filePath!)}</span>
- </div>
- </Show>
</div>
- <div data-slot="message-part-actions">{/* <DiffChanges diff={diff} /> */}</div>
</div>
}
>
@@ -1814,7 +1757,7 @@ ToolRegistry.register({
</ToolFileAccordion>
</Show>
<DiagnosticsDisplay diagnostics={diagnostics()} />
- </BasicTool>
+ </ToolCall>
</div>
)
},
@@ -1838,7 +1781,8 @@ 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()
if (list.length !== 1) return
@@ -1846,7 +1790,6 @@ ToolRegistry.register({
})
const [expanded, setExpanded] = createSignal<string[]>([])
let seeded = false
-
createEffect(() => {
const list = files()
if (list.length === 0) return
@@ -1854,7 +1797,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 ""
@@ -1862,24 +1804,44 @@ ToolRegistry.register({
})
return (
- <Show
- when={single()}
- fallback={
- <div data-component="apply-patch-tool">
- <BasicTool
- {...props}
- icon="code-lines"
- defer
- trigger={{
- title: i18n.t("ui.tool.patch"),
- subtitle: subtitle(),
- }}
- >
+ <div data-component="apply-patch-tool">
+ <ToolCall
+ variant="panel"
+ {...props}
+ icon="code-lines"
+ defer
+ trigger={
+ <div data-component={single() ? "edit-trigger" : "write-trigger"}>
+ <div data-slot="message-part-title-area">
+ <div data-slot="message-part-title">
+ <span data-slot="message-part-title-text">
+ <TextShimmer text={i18n.t("ui.tool.patch")} active={pending()} />
+ </span>
+ <Show when={single()}>
+ {(file) => (
+ <ToolMetaLine
+ filename={getFilename(file().relativePath)}
+ path={file().relativePath.includes("/") ? getDirectory(file().relativePath) : undefined}
+ changes={{ additions: file().additions, deletions: file().deletions }}
+ animate={reveal()}
+ soft
+ />
+ )}
+ </Show>
+ <Show when={!single() && subtitle()}>{(text) => <ToolText text={text()} animate={reveal()} />}</Show>
+ </div>
+ </div>
+ </div>
+ }
+ >
+ <Show
+ when={single()}
+ fallback={
<Show when={files().length > 0}>
<Accordion
multiple
data-scope="apply-patch"
- style={{ "--sticky-accordion-offset": "40px" }}
+ style={{ "--sticky-accordion-offset": "37px" }}
value={expanded()}
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
>
@@ -1887,13 +1849,11 @@ ToolRegistry.register({
{(file) => {
const active = createMemo(() => expanded().includes(file.filePath))
const [visible, setVisible] = createSignal(false)
-
createEffect(() => {
if (!active()) {
setVisible(false)
return
}
-
requestAnimationFrame(() => {
if (!active()) return
setVisible(true)
@@ -1958,77 +1918,50 @@ ToolRegistry.register({
</For>
</Accordion>
</Show>
- </BasicTool>
- </div>
- }
- >
- <div data-component="apply-patch-tool">
- <BasicTool
- {...props}
- icon="code-lines"
- defer
- trigger={
- <div data-component="edit-trigger">
- <div data-slot="message-part-title-area">
- <div data-slot="message-part-title">
- <span data-slot="message-part-title-text">
- <TextShimmer text={i18n.t("ui.tool.patch")} active={pending()} />
- </span>
- <Show when={!pending()}>
- <span data-slot="message-part-title-filename">{getFilename(single()!.relativePath)}</span>
- </Show>
- </div>
- <Show when={!pending() && single()!.relativePath.includes("/")}>
- <div data-slot="message-part-path">
- <span data-slot="message-part-directory">{getDirectory(single()!.relativePath)}</span>
- </div>
- </Show>
- </div>
- <div data-slot="message-part-actions">
- <Show when={!pending()}>
- <DiffChanges changes={{ additions: single()!.additions, deletions: single()!.deletions }} />
- </Show>
- </div>
- </div>
}
>
- <ToolFileAccordion
- path={single()!.relativePath}
- actions={
- <Switch>
- <Match when={single()!.type === "add"}>
- <span data-slot="apply-patch-change" data-type="added">
- {i18n.t("ui.patch.action.created")}
- </span>
- </Match>
- <Match when={single()!.type === "delete"}>
- <span data-slot="apply-patch-change" data-type="removed">
- {i18n.t("ui.patch.action.deleted")}
- </span>
- </Match>
- <Match when={single()!.type === "move"}>
- <span data-slot="apply-patch-change" data-type="modified">
- {i18n.t("ui.patch.action.moved")}
- </span>
- </Match>
- <Match when={true}>
- <DiffChanges changes={{ additions: single()!.additions, deletions: single()!.deletions }} />
- </Match>
- </Switch>
- }
- >
- <div data-component="apply-patch-file-diff">
- <Dynamic
- component={fileComponent}
- mode="diff"
- before={{ name: single()!.filePath, contents: single()!.before }}
- after={{ name: single()!.movePath ?? single()!.filePath, contents: single()!.after }}
- />
- </div>
- </ToolFileAccordion>
- </BasicTool>
- </div>
- </Show>
+ {(file) => (
+ <ToolFileAccordion
+ path={file().relativePath}
+ actions={
+ <Switch>
+ <Match when={file().type === "add"}>
+ <span data-slot="apply-patch-change" data-type="added">
+ {i18n.t("ui.patch.action.created")}
+ </span>
+ </Match>
+ <Match when={file().type === "delete"}>
+ <span data-slot="apply-patch-change" data-type="removed">
+ {i18n.t("ui.patch.action.deleted")}
+ </span>
+ </Match>
+ <Match when={file().type === "move"}>
+ <span data-slot="apply-patch-change" data-type="modified">
+ {i18n.t("ui.patch.action.moved")}
+ </span>
+ </Match>
+ <Match when={true}>
+ <ToolChanges
+ changes={{ additions: file().additions, deletions: file().deletions }}
+ animate={reveal()}
+ />
+ </Match>
+ </Switch>
+ }
+ >
+ <div data-component="apply-patch-file-diff">
+ <Dynamic
+ component={fileComponent}
+ mode="diff"
+ before={{ name: file().filePath, contents: file().before }}
+ after={{ name: file().movePath ?? file().filePath, contents: file().after }}
+ />
+ </div>
+ </ToolFileAccordion>
+ )}
+ </Show>
+ </ToolCall>
+ </div>
)
},
})
@@ -2046,6 +1979,7 @@ ToolRegistry.register({
return []
})
+ const pending = createMemo(() => busy(props.status))
const subtitle = createMemo(() => {
const list = todos()
@@ -2054,14 +1988,19 @@ ToolRegistry.register({
})
return (
- <BasicTool
+ <ToolCall
+ variant="panel"
{...props}
defaultOpen
icon="checklist"
- trigger={{
- title: i18n.t("ui.tool.todos"),
- subtitle: subtitle(),
- }}
+ trigger={
+ <ToolTriggerRow
+ title={i18n.t("ui.tool.todos")}
+ pending={pending()}
+ subtitle={subtitle()}
+ animate={props.reveal}
+ />
+ }
>
<Show when={todos().length}>
<div data-component="todos">
@@ -2079,7 +2018,7 @@ ToolRegistry.register({
</For>
</div>
</Show>
- </BasicTool>
+ </ToolCall>
)
},
})
@@ -2091,6 +2030,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(() => busy(props.status))
const subtitle = createMemo(() => {
const count = questions().length
@@ -2100,14 +2040,19 @@ ToolRegistry.register({
})
return (
- <BasicTool
+ <ToolCall
+ variant="panel"
{...props}
- defaultOpen={completed()}
+ defaultOpen={false}
icon="bubble-5"
- trigger={{
- title: i18n.t("ui.tool.questions"),
- subtitle: subtitle(),
- }}
+ trigger={
+ <ToolTriggerRow
+ title={i18n.t("ui.tool.questions")}
+ pending={pending()}
+ subtitle={subtitle()}
+ animate={props.reveal}
+ />
+ }
>
<Show when={completed()}>
<div data-component="question-answers">
@@ -2124,7 +2069,7 @@ ToolRegistry.register({
</For>
</div>
</Show>
- </BasicTool>
+ </ToolCall>
)
},
})
@@ -2132,21 +2077,28 @@ ToolRegistry.register({
ToolRegistry.register({
name: "skill",
render(props) {
- const title = createMemo(() => props.input.name || "skill")
- const running = createMemo(() => props.status === "pending" || props.status === "running")
-
- const titleContent = () => <TextShimmer text={title()} active={running()} />
-
- const trigger = () => (
- <div data-slot="basic-tool-tool-info-structured">
- <div data-slot="basic-tool-tool-info-main">
- <span data-slot="basic-tool-tool-title" class="capitalize agent-title">
- {titleContent()}
- </span>
- </div>
- </div>
+ 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 (
+ <ToolCall
+ variant="row"
+ icon="brain"
+ status={props.status}
+ trigger={
+ <ToolTriggerRow
+ title={i18n.t("ui.tool.skill")}
+ pending={pending()}
+ subtitle={name()}
+ animate={props.reveal}
+ revealOnMount
+ />
+ }
+ animate
+ />
)
-
- return <BasicTool icon="brain" status={props.status} trigger={trigger()} hideDetails />
},
})
diff --git a/packages/ui/src/components/motion-spring.tsx b/packages/ui/src/components/motion-spring.tsx
index a5104a1a3..5deefcfa6 100644
--- a/packages/ui/src/components/motion-spring.tsx
+++ b/packages/ui/src/components/motion-spring.tsx
@@ -1,8 +1,9 @@
import { attachSpring, motionValue } from "motion"
import type { SpringOptions } from "motion"
import { createEffect, createSignal, onCleanup } from "solid-js"
+import { prefersReducedMotion } from "../hooks/use-reduced-motion"
-type Opt = Partial<Pick<SpringOptions, "visualDuration" | "bounce" | "stiffness" | "damping" | "mass" | "velocity">>
+type Opt = Pick<SpringOptions, "visualDuration" | "bounce" | "stiffness" | "damping" | "mass" | "velocity">
const eq = (a: Opt | undefined, b: Opt | undefined) =>
a?.visualDuration === b?.visualDuration &&
a?.bounce === b?.bounce &&
@@ -13,24 +14,41 @@ const eq = (a: Opt | undefined, b: Opt | undefined) =>
export function useSpring(target: () => number, options?: Opt | (() => Opt)) {
const read = () => (typeof options === "function" ? options() : options)
+ const reduce = prefersReducedMotion
const [value, setValue] = createSignal(target())
const source = motionValue(value())
const spring = motionValue(value())
let config = read()
- let stop = attachSpring(spring, source, config)
- let off = spring.on("change", (next: number) => setValue(next))
+ let reduced = reduce()
+ let stop = reduced ? () => {} : attachSpring(spring, source, config)
+ let off = spring.on("change", (next) => setValue(next))
createEffect(() => {
- source.set(target())
+ const next = target()
+ if (reduced) {
+ source.set(next)
+ spring.set(next)
+ setValue(next)
+ return
+ }
+ source.set(next)
})
createEffect(() => {
- if (!options) return
const next = read()
- if (eq(config, next)) return
+ const skip = reduce()
+ if (eq(config, next) && reduced === skip) return
config = next
+ reduced = skip
stop()
- stop = attachSpring(spring, source, next)
+ stop = skip ? () => {} : attachSpring(spring, source, next)
+ if (skip) {
+ const value = target()
+ source.set(value)
+ spring.set(value)
+ setValue(value)
+ return
+ }
setValue(spring.get())
})
diff --git a/packages/ui/src/components/motion.tsx b/packages/ui/src/components/motion.tsx
new file mode 100644
index 000000000..6cdf01c73
--- /dev/null
+++ b/packages/ui/src/components/motion.tsx
@@ -0,0 +1,77 @@
+import { followValue } from "motion"
+import type { MotionValue } from "motion"
+
+export { animate, springValue } from "motion"
+export type { AnimationPlaybackControls } from "motion"
+
+/**
+ * Like `springValue` but preserves getters on the config object.
+ * `springValue` spreads config at creation, snapshotting getter values.
+ * This passes the config through to `followValue` intact, so getters
+ * on `visualDuration` etc. fire on every `.set()` call.
+ */
+export function tunableSpringValue<T extends string | number>(initial: T, config: SpringConfig): MotionValue<T> {
+ return followValue(initial, config as any)
+}
+
+let _growDuration = 0.5
+let _collapsibleDuration = 0.3
+
+export const GROW_SPRING = {
+ type: "spring" as const,
+ get visualDuration() {
+ return _growDuration
+ },
+ bounce: 0,
+}
+
+export const COLLAPSIBLE_SPRING = {
+ type: "spring" as const,
+ get visualDuration() {
+ return _collapsibleDuration
+ },
+ bounce: 0,
+}
+
+export const setGrowDuration = (v: number) => {
+ _growDuration = v
+}
+export const setCollapsibleDuration = (v: number) => {
+ _collapsibleDuration = v
+}
+export const getGrowDuration = () => _growDuration
+export const getCollapsibleDuration = () => _collapsibleDuration
+
+export type SpringConfig = { type: "spring"; visualDuration: number; bounce: number }
+
+export const FAST_SPRING = {
+ type: "spring" as const,
+ visualDuration: 0.35,
+ bounce: 0,
+}
+
+export const GLOW_SPRING = {
+ type: "spring" as const,
+ visualDuration: 0.4,
+ bounce: 0.15,
+}
+
+export const WIPE_MASK =
+ "linear-gradient(to right, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 45%, rgba(0,0,0,0) 60%, rgba(0,0,0,0) 100%)"
+
+export const clearMaskStyles = (el: HTMLElement) => {
+ el.style.maskImage = ""
+ el.style.webkitMaskImage = ""
+ el.style.maskSize = ""
+ el.style.webkitMaskSize = ""
+ el.style.maskRepeat = ""
+ el.style.webkitMaskRepeat = ""
+ el.style.maskPosition = ""
+ el.style.webkitMaskPosition = ""
+}
+
+export const clearFadeStyles = (el: HTMLElement) => {
+ el.style.opacity = ""
+ el.style.filter = ""
+ el.style.transform = ""
+}
diff --git a/packages/ui/src/components/rolling-results.css b/packages/ui/src/components/rolling-results.css
new file mode 100644
index 000000000..200b2a97e
--- /dev/null
+++ b/packages/ui/src/components/rolling-results.css
@@ -0,0 +1,92 @@
+[data-component="rolling-results"] {
+ --rolling-results-row-height: 22px;
+ --rolling-results-fixed-height: var(--rolling-results-row-height);
+ --rolling-results-fixed-gap: 0px;
+ --rolling-results-row-gap: 0px;
+
+ display: block;
+ width: 100%;
+ min-width: 0;
+
+ [data-slot="rolling-results-viewport"] {
+ position: relative;
+ min-width: 0;
+ height: 0;
+ overflow: clip;
+ }
+
+ &[data-overflowing="true"]:not([data-scrollable="true"]) [data-slot="rolling-results-window"] {
+ mask-image: linear-gradient(
+ to bottom,
+ transparent 0%,
+ black var(--rolling-results-fade),
+ black calc(100% - calc(var(--rolling-results-fade) * 0.5)),
+ transparent 100%
+ );
+ -webkit-mask-image: linear-gradient(
+ to bottom,
+ transparent 0%,
+ black var(--rolling-results-fade),
+ black calc(100% - calc(var(--rolling-results-fade) * 0.5)),
+ transparent 100%
+ );
+ }
+
+ [data-slot="rolling-results-fixed"] {
+ min-width: 0;
+ height: var(--rolling-results-fixed-height);
+ min-height: var(--rolling-results-fixed-height);
+ display: flex;
+ align-items: center;
+ }
+
+ [data-slot="rolling-results-window"] {
+ min-width: 0;
+ margin-top: var(--rolling-results-fixed-gap);
+ height: calc(100% - var(--rolling-results-fixed-height) - var(--rolling-results-fixed-gap));
+ overflow: clip;
+ }
+
+ &[data-scrollable="true"] [data-slot="rolling-results-window"] {
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+ }
+
+ &[data-scrollable="true"] [data-slot="rolling-results-track"] {
+ transform: none !important;
+ will-change: auto;
+ }
+
+ [data-slot="rolling-results-body"] {
+ min-width: 0;
+ }
+
+ [data-slot="rolling-results-track"] {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: var(--rolling-results-row-gap);
+ will-change: transform;
+ }
+
+ [data-slot="rolling-results-row"],
+ [data-slot="rolling-results-empty"] {
+ min-width: 0;
+ height: var(--rolling-results-row-height);
+ min-height: var(--rolling-results-row-height);
+ display: flex;
+ align-items: center;
+ }
+
+ [data-slot="rolling-results-row"] {
+ color: var(--text-base);
+ }
+
+ [data-slot="rolling-results-empty"] {
+ color: var(--text-weaker);
+ }
+}
diff --git a/packages/ui/src/components/rolling-results.tsx b/packages/ui/src/components/rolling-results.tsx
new file mode 100644
index 000000000..d2f30105e
--- /dev/null
+++ b/packages/ui/src/components/rolling-results.tsx
@@ -0,0 +1,326 @@
+import { For, Show, batch, createEffect, createMemo, createSignal, on, onCleanup, onMount, type JSX } from "solid-js"
+import { animate, clearMaskStyles, GROW_SPRING, type AnimationPlaybackControls, type SpringConfig } from "./motion"
+import { prefersReducedMotion } from "../hooks/use-reduced-motion"
+
+export type RollingResultsProps<T> = {
+ items: T[]
+ render: (item: T, index: number) => JSX.Element
+ fixed?: JSX.Element
+ getKey?: (item: T, index: number) => string
+ rows?: number
+ rowHeight?: number
+ fixedHeight?: number
+ rowGap?: number
+ open?: boolean
+ scrollable?: boolean
+ spring?: SpringConfig
+ animate?: boolean
+ class?: string
+ empty?: JSX.Element
+ noFadeOnCollapse?: boolean
+}
+
+export function RollingResults<T>(props: RollingResultsProps<T>) {
+ let view: HTMLDivElement | undefined
+ let track: HTMLDivElement | undefined
+ let windowEl: HTMLDivElement | undefined
+ let shift: AnimationPlaybackControls | undefined
+ let resize: AnimationPlaybackControls | undefined
+ let edgeFade: AnimationPlaybackControls | undefined
+
+ const reducedMotion = prefersReducedMotion
+
+ const rows = createMemo(() => Math.max(1, Math.round(props.rows ?? 3)))
+ const rowHeight = createMemo(() => Math.max(16, Math.round(props.rowHeight ?? 22)))
+ const fixedHeight = createMemo(() => Math.max(0, Math.round(props.fixedHeight ?? rowHeight())))
+ const rowGap = createMemo(() => Math.max(0, Math.round(props.rowGap ?? 0)))
+ const fixed = createMemo(() => props.fixed !== undefined)
+ const list = createMemo(() => props.items ?? [])
+ const count = createMemo(() => list().length)
+
+ // scrollReady is the internal "transition complete" state.
+ // It only becomes true after props.scrollable is true AND the offset animation has settled.
+ const [scrollReady, setScrollReady] = createSignal(false)
+
+ const backstop = createMemo(() => Math.max(rows() * 2, 12))
+ const rendered = createMemo(() => {
+ const items = list()
+ if (scrollReady()) return items
+ const max = backstop()
+ return items.length > max ? items.slice(-max) : items
+ })
+ const skipped = createMemo(() => {
+ if (scrollReady()) return 0
+ return count() - rendered().length
+ })
+ const open = createMemo(() => props.open !== false)
+ const active = createMemo(() => (props.animate !== false || props.spring !== undefined) && !reducedMotion())
+ const noFade = () => props.noFadeOnCollapse === true
+ const overflowing = createMemo(() => count() > rows())
+ const shown = createMemo(() => Math.min(rows(), count()))
+ const step = createMemo(() => rowHeight() + rowGap())
+ const offset = createMemo(() => Math.max(0, count() - shown()) * step())
+ const body = createMemo(() => {
+ if (shown() > 0) {
+ return shown() * rowHeight() + Math.max(0, shown() - 1) * rowGap()
+ }
+ if (props.empty === undefined) return 0
+ return rowHeight()
+ })
+ const gap = createMemo(() => {
+ if (!fixed()) return 0
+ if (body() <= 0) return 0
+ return rowGap()
+ })
+ const height = createMemo(() => {
+ if (!open()) return 0
+ if (!fixed()) return body()
+ return fixedHeight() + gap() + body()
+ })
+
+ const key = (item: T, index: number) => {
+ const value = props.getKey
+ if (value) return value(item, index)
+ return String(index)
+ }
+
+ const setTrack = (value: number) => {
+ if (!track) return
+ track.style.transform = `translateY(${-Math.round(value)}px)`
+ }
+
+ const setView = (value: number) => {
+ if (!view) return
+ view.style.height = `${Math.max(0, Math.round(value))}px`
+ }
+
+ onMount(() => {
+ setTrack(offset())
+ })
+
+ // Original WAAPI offset animation — untouched rolling behavior.
+ createEffect(
+ on(
+ offset,
+ (next) => {
+ if (!track) return
+ if (scrollReady()) return
+ if (props.scrollable) return
+ if (!active()) {
+ shift?.stop()
+ shift = undefined
+ setTrack(next)
+ return
+ }
+ shift?.stop()
+ const anim = animate(track, { transform: `translateY(${-next}px)` }, props.spring ?? GROW_SPRING)
+ shift = anim
+ anim.finished
+ .catch(() => {})
+ .finally(() => {
+ if (shift !== anim) return
+ setTrack(next)
+ shift = undefined
+ })
+ },
+ { defer: true },
+ ),
+ )
+
+ // Scrollable transition: wait for the offset animation to finish,
+ // then batch all DOM changes in one synchronous pass.
+ createEffect(
+ on(
+ () => props.scrollable === true,
+ (isScrollable) => {
+ if (!isScrollable) {
+ setScrollReady(false)
+ if (windowEl) {
+ windowEl.style.overflowY = ""
+ windowEl.style.maskImage = ""
+ windowEl.style.webkitMaskImage = ""
+ }
+ return
+ }
+ // Wait for the current offset animation to settle (if any).
+ const done = shift?.finished ?? Promise.resolve()
+ done
+ .catch(() => {})
+ .then(() => {
+ if (props.scrollable !== true) return
+
+ // Batch the signal update — Solid updates the DOM synchronously:
+ // rendered() returns all items, skipped() returns 0, padding-top removed,
+ // data-scrollable becomes "true".
+ batch(() => setScrollReady(true))
+
+ // Now the DOM has all items. Safe to switch layout strategy.
+ // CSS handles `transform: none !important` on [data-scrollable="true"].
+ if (windowEl) {
+ windowEl.style.overflowY = "auto"
+ windowEl.scrollTop = windowEl.scrollHeight
+ }
+ updateScrollMask()
+ })
+ },
+ ),
+ )
+
+ // Auto-scroll to bottom when new items arrive in scrollable mode
+ const [userScrolled, setUserScrolled] = createSignal(false)
+
+ const updateScrollMask = () => {
+ if (!windowEl) return
+ if (!scrollReady()) {
+ windowEl.style.maskImage = ""
+ windowEl.style.webkitMaskImage = ""
+ return
+ }
+ const { scrollTop, scrollHeight, clientHeight } = windowEl
+ const atBottom = scrollHeight - scrollTop - clientHeight < 8
+ // Top fade is always present in scrollable mode (matches rolling mode appearance).
+ // Bottom fade only when not scrolled to the end.
+ const mask = atBottom
+ ? "linear-gradient(to bottom, transparent 0, black 8px)"
+ : "linear-gradient(to bottom, transparent 0, black 8px, black calc(100% - 8px), transparent 100%)"
+ windowEl.style.maskImage = mask
+ windowEl.style.webkitMaskImage = mask
+ }
+
+ createEffect(() => {
+ if (!scrollReady()) {
+ setUserScrolled(false)
+ return
+ }
+ const _n = count()
+ const scrolled = userScrolled()
+ if (scrolled) return
+ if (windowEl) {
+ windowEl.scrollTop = windowEl.scrollHeight
+ updateScrollMask()
+ }
+ })
+
+ const onWindowScroll = () => {
+ if (!windowEl || !scrollReady()) return
+ const atBottom = windowEl.scrollHeight - windowEl.scrollTop - windowEl.clientHeight < 8
+ setUserScrolled(!atBottom)
+ updateScrollMask()
+ }
+
+ const EDGE_MASK = "linear-gradient(to top, transparent 0%, black 8px)"
+ const applyEdge = () => {
+ if (!view) return
+ edgeFade?.stop()
+ edgeFade = undefined
+ view.style.maskImage = EDGE_MASK
+ view.style.webkitMaskImage = EDGE_MASK
+ view.style.maskSize = "100% 100%"
+ view.style.maskRepeat = "no-repeat"
+ }
+ const clearEdge = () => {
+ if (!view) return
+ if (!active()) {
+ clearMaskStyles(view)
+ return
+ }
+ edgeFade?.stop()
+ const anim = animate(view, { maskSize: "100% 200%" }, props.spring ?? GROW_SPRING)
+ edgeFade = anim
+ anim.finished
+ .catch(() => {})
+ .then(() => {
+ if (edgeFade !== anim || !view) return
+ clearMaskStyles(view)
+ edgeFade = undefined
+ })
+ }
+
+ createEffect(
+ on(height, (next, prev) => {
+ if (!view) return
+ if (!active()) {
+ resize?.stop()
+ resize = undefined
+ setView(next)
+ view.style.opacity = ""
+ clearEdge()
+ return
+ }
+ const collapsing = next === 0 && prev !== undefined && prev > 0
+ const expanding = prev === 0 && next > 0
+ resize?.stop()
+ view.style.opacity = ""
+ applyEdge()
+ const spring = props.spring ?? GROW_SPRING
+ const anim = collapsing
+ ? animate(view, noFade() ? { height: `${next}px` } : { height: `${next}px`, opacity: 0 }, spring)
+ : expanding
+ ? animate(view, noFade() ? { height: `${next}px` } : { height: `${next}px`, opacity: [0, 1] }, spring)
+ : animate(view, { height: `${next}px` }, spring)
+ resize = anim
+ anim.finished
+ .catch(() => {})
+ .finally(() => {
+ view.style.opacity = ""
+ if (resize !== anim) return
+ setView(next)
+ resize = undefined
+ clearEdge()
+ })
+ }),
+ )
+
+ onCleanup(() => {
+ shift?.stop()
+ resize?.stop()
+ edgeFade?.stop()
+ shift = undefined
+ resize = undefined
+ edgeFade = undefined
+ })
+
+ return (
+ <div
+ data-component="rolling-results"
+ class={props.class}
+ data-open={open() ? "true" : "false"}
+ data-overflowing={overflowing() ? "true" : "false"}
+ data-scrollable={scrollReady() ? "true" : "false"}
+ data-fixed={fixed() ? "true" : "false"}
+ style={{
+ "--rolling-results-row-height": `${rowHeight()}px`,
+ "--rolling-results-fixed-height": `${fixed() ? fixedHeight() : 0}px`,
+ "--rolling-results-fixed-gap": `${gap()}px`,
+ "--rolling-results-row-gap": `${rowGap()}px`,
+ "--rolling-results-fade": `${Math.round(rowHeight() * 0.6)}px`,
+ }}
+ >
+ <div ref={view} data-slot="rolling-results-viewport" aria-live="polite">
+ <Show when={fixed()}>
+ <div data-slot="rolling-results-fixed">{props.fixed}</div>
+ </Show>
+ <div ref={windowEl} data-slot="rolling-results-window" onScroll={onWindowScroll}>
+ <div data-slot="rolling-results-body">
+ <Show when={list().length === 0 && props.empty !== undefined}>
+ <div data-slot="rolling-results-empty">{props.empty}</div>
+ </Show>
+ <div
+ ref={track}
+ data-slot="rolling-results-track"
+ style={{ "padding-top": scrollReady() ? undefined : `${skipped() * step()}px` }}
+ >
+ <For each={rendered()}>
+ {(item, index) => (
+ <div data-slot="rolling-results-row" data-key={key(item, index())}>
+ {props.render(item, index())}
+ </div>
+ )}
+ </For>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ )
+}
diff --git a/packages/ui/src/components/scroll-view.css b/packages/ui/src/components/scroll-view.css
index f6a49e241..a01298f77 100644
--- a/packages/ui/src/components/scroll-view.css
+++ b/packages/ui/src/components/scroll-view.css
@@ -9,6 +9,9 @@
overflow-y: auto;
scrollbar-width: none;
outline: none;
+ display: flex;
+ flex-direction: column-reverse;
+ overflow-anchor: none;
}
.scroll-view__viewport::-webkit-scrollbar {
@@ -45,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 52ed39a46..16af3d933 100644
--- a/packages/ui/src/components/scroll-view.tsx
+++ b/packages/ui/src/components/scroll-view.tsx
@@ -1,17 +1,17 @@
-import { createSignal, onCleanup, onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js"
+import { createSignal, onCleanup, onMount, splitProps, type ComponentProps, Show } from "solid-js"
+import { animate, type AnimationPlaybackControls } from "motion"
import { useI18n } from "../context/i18n"
+import { FAST_SPRING } from "./motion"
export interface ScrollViewProps extends ComponentProps<"div"> {
viewportRef?: (el: HTMLDivElement) => void
- orientation?: "vertical" | "horizontal" // currently only vertical is fully implemented for thumb
}
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",
@@ -25,9 +25,9 @@ export function ScrollView(props: ScrollViewProps) {
],
)
- let rootRef!: HTMLDivElement
let viewportRef!: HTMLDivElement
let thumbRef!: HTMLDivElement
+ let anim: AnimationPlaybackControls | undefined
const [isHovered, setIsHovered] = createSignal(false)
const [isDragging, setIsDragging] = createSignal(false)
@@ -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)
@@ -82,6 +85,7 @@ export function ScrollView(props: ScrollViewProps) {
}
onCleanup(() => {
+ stop()
observer.disconnect()
})
@@ -123,6 +127,30 @@ export function ScrollView(props: ScrollViewProps) {
thumbRef.addEventListener("pointerup", onPointerUp)
}
+ const stop = () => {
+ if (!anim) return
+ anim.stop()
+ anim = undefined
+ }
+
+ const limit = (top: number) => {
+ const max = viewportRef.scrollHeight - viewportRef.clientHeight
+ return Math.max(-max, Math.min(0, top))
+ }
+
+ const glide = (top: number) => {
+ stop()
+ anim = animate(viewportRef.scrollTop, limit(top), {
+ ...FAST_SPRING,
+ onUpdate: (v) => {
+ viewportRef.scrollTop = v
+ },
+ onComplete: () => {
+ anim = undefined
+ },
+ })
+ }
+
// Keybinds implementation
// We ensure the viewport has a tabindex so it can receive focus
// We can also explicitly catch PageUp/Down if we want smooth scroll or specific behavior,
@@ -147,11 +175,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)
+ glide(-(viewportRef.scrollHeight - viewportRef.clientHeight))
break
case "End":
e.preventDefault()
- viewportRef.scrollTo({ top: viewportRef.scrollHeight, behavior: "smooth" })
+ // With column-reverse, bottom of content = 0
+ glide(0)
break
case "ArrowUp":
e.preventDefault()
@@ -166,7 +196,6 @@ export function ScrollView(props: ScrollViewProps) {
return (
<div
- ref={rootRef}
class={`scroll-view ${local.class || ""}`}
style={local.style}
onPointerEnter={() => setIsHovered(true)}
@@ -181,12 +210,21 @@ export function ScrollView(props: ScrollViewProps) {
updateThumb()
if (typeof events.onScroll === "function") events.onScroll(e as any)
}}
- onWheel={events.onWheel as any}
- onTouchStart={events.onTouchStart as any}
+ onWheel={(e) => {
+ if (e.deltaY) stop()
+ if (typeof events.onWheel === "function") events.onWheel(e as any)
+ }}
+ onTouchStart={(e) => {
+ stop()
+ if (typeof events.onTouchStart === "function") events.onTouchStart(e as any)
+ }}
onTouchMove={events.onTouchMove as any}
onTouchEnd={events.onTouchEnd as any}
onTouchCancel={events.onTouchCancel as any}
- onPointerDown={events.onPointerDown as any}
+ onPointerDown={(e) => {
+ stop()
+ if (typeof events.onPointerDown === "function") events.onPointerDown(e as any)
+ }}
onClick={events.onClick as any}
tabIndex={0}
role="region"
diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css
index cf1e98115..2f19d20e6 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;
@@ -26,7 +25,7 @@
align-items: flex-start;
align-self: stretch;
min-width: 0;
- gap: 18px;
+ gap: 0px;
overflow-anchor: none;
}
@@ -43,30 +42,127 @@
align-self: stretch;
}
+ [data-slot="session-turn-assistant-lane"] {
+ width: 100%;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ align-self: stretch;
+ }
+
[data-slot="session-turn-thinking"] {
display: flex;
+ flex-wrap: nowrap;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
+ white-space: nowrap;
color: var(--text-weak);
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
- line-height: 20px;
- min-height: 20px;
+ line-height: var(--line-height-large);
+ height: 36px;
[data-component="spinner"] {
width: 16px;
height: 16px;
}
+
+ > [data-component="text-shimmer"] {
+ flex: 0 0 auto;
+ white-space: nowrap;
+ }
+ }
+
+ [data-slot="session-turn-handoff-wrap"] {
+ width: 100%;
+ min-width: 0;
+ overflow: visible;
+ }
+
+ [data-slot="session-turn-handoff"] {
+ width: 100%;
+ min-width: 0;
+ min-height: 37px;
+ position: relative;
+ }
+
+ [data-slot="session-turn-thinking"] {
+ position: absolute;
+ inset: 0;
+ will-change: opacity, filter;
+ transition:
+ opacity 180ms ease-out,
+ filter 180ms ease-out,
+ transform 180ms ease-out;
+ }
+
+ [data-slot="session-turn-thinking"][data-visible="false"] {
+ opacity: 0;
+ filter: blur(2px);
+ transform: translateY(1px);
+ pointer-events: none;
+ }
+
+ [data-slot="session-turn-thinking"][data-visible="true"] {
+ opacity: 1;
+ filter: blur(0px);
+ transform: translateY(0px);
+ }
+
+ [data-slot="session-turn-meta"] {
+ position: absolute;
+ inset: 0;
+ min-height: 37px;
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 10px;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.15s ease;
+ }
+
+ [data-slot="session-turn-meta"][data-interrupted] {
+ gap: 12px;
+ }
+
+ [data-slot="session-turn-meta"] [data-component="tooltip-trigger"] {
+ display: inline-flex;
+ width: fit-content;
+ }
+
+ [data-slot="session-turn-message-container"]:hover [data-slot="session-turn-meta"][data-visible="true"],
+ [data-slot="session-turn-message-container"]:focus-within [data-slot="session-turn-meta"][data-visible="true"] {
+ opacity: 1;
+ pointer-events: auto;
+ }
+
+ [data-slot="session-turn-meta-label"] {
+ user-select: none;
+ min-width: 0;
+ overflow: clip;
+ white-space: nowrap;
+ text-overflow: ellipsis;
}
[data-component="text-reveal"].session-turn-thinking-heading {
flex: 1 1 auto;
min-width: 0;
+ overflow: clip;
+ white-space: nowrap;
+ line-height: inherit;
color: var(--text-weaker);
font-weight: var(--font-weight-regular);
+
+ [data-slot="text-reveal-track"],
+ [data-slot="text-reveal-entering"],
+ [data-slot="text-reveal-leaving"] {
+ min-height: 0;
+ line-height: inherit;
+ }
}
.error-card {
@@ -84,7 +180,7 @@
display: flex;
flex-direction: column;
align-self: stretch;
- gap: 12px;
+ gap: 0px;
> :first-child > [data-component="markdown"]:first-child {
margin-top: 0;
@@ -109,6 +205,7 @@
[data-component="session-turn-diffs-trigger"] {
width: 100%;
+ height: 36px;
display: flex;
align-items: center;
justify-content: flex-start;
@@ -118,7 +215,7 @@
[data-slot="session-turn-diffs-title"] {
display: inline-flex;
- align-items: baseline;
+ align-items: center;
gap: 8px;
}
@@ -135,7 +232,7 @@
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
font-weight: var(--font-weight-regular);
- line-height: var(--line-height-x-large);
+ line-height: var(--line-height-large);
}
[data-slot="session-turn-diffs-meta"] {
@@ -171,8 +268,10 @@
[data-slot="session-turn-diff-path"] {
display: flex;
- flex-grow: 1;
min-width: 0;
+ align-items: baseline;
+ overflow: clip;
+ white-space: nowrap;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
@@ -180,16 +279,22 @@
}
[data-slot="session-turn-diff-directory"] {
- color: var(--text-base);
- overflow: hidden;
- text-overflow: ellipsis;
+ flex: 1 1 auto;
+ color: var(--text-weak);
+ min-width: 0;
+ overflow: clip;
white-space: nowrap;
direction: rtl;
+ unicode-bidi: plaintext;
text-align: left;
}
[data-slot="session-turn-diff-filename"] {
flex-shrink: 0;
+ max-width: 100%;
+ min-width: 0;
+ overflow: clip;
+ white-space: nowrap;
color: var(--text-strong);
font-weight: var(--font-weight-medium);
}
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index 3323a9fc6..f1aee802e 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/packages/ui/src/components/session-turn.tsx
@@ -3,23 +3,27 @@ import type { SessionStatus } from "@opencode-ai/sdk/v2"
import { useData } from "../context"
import { useFileComponent } from "../context/file"
+import { same } from "@opencode-ai/util/array"
import { Binary } from "@opencode-ai/util/binary"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
-import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js"
+import { createEffect, createMemo, createSignal, For, on, onCleanup, ParentProps, Show } from "solid-js"
import { Dynamic } from "solid-js/web"
-import { AssistantParts, Message, Part, PART_MAPPING } from "./message-part"
+import { GrowBox } from "./grow-box"
+import { AssistantParts, UserMessageDisplay, Part, PART_MAPPING } from "./message-part"
import { Card } from "./card"
import { Accordion } from "./accordion"
import { StickyAccordionHeader } from "./sticky-accordion-header"
import { Collapsible } from "./collapsible"
import { DiffChanges } from "./diff-changes"
import { Icon } from "./icon"
+import { IconButton } from "./icon-button"
import { TextShimmer } from "./text-shimmer"
-import { SessionRetry } from "./session-retry"
import { TextReveal } from "./text-reveal"
+import { list } from "./text-utils"
+import { SessionRetry } from "./session-retry"
+import { Tooltip } from "./tooltip"
import { createAutoScroll } from "../hooks"
import { useI18n } from "../context/i18n"
-
function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
@@ -73,18 +77,12 @@ function unwrap(message: string) {
return message
}
-function same<T>(a: readonly T[], b: readonly T[]) {
- if (a === b) return true
- if (a.length !== b.length) return false
- return a.every((x, i) => x === b[i])
-}
-
-function list<T>(value: T[] | undefined | null, fallback: T[]) {
- if (Array.isArray(value)) return value
- return fallback
-}
-
const hidden = new Set(["todowrite", "todoread"])
+const emptyMessages: MessageType[] = []
+const emptyAssistant: AssistantMessage[] = []
+const emptyDiffs: FileDiff[] = []
+const idle: SessionStatus = { type: "idle" as const }
+const handoffHoldMs = 120
function partState(part: PartType, showReasoningSummaries: boolean) {
if (part.type === "tool") {
@@ -141,6 +139,7 @@ export function SessionTurn(
props: ParentProps<{
sessionID: string
messageID: string
+ animate?: boolean
showReasoningSummaries?: boolean
shellToolDefaultOpen?: boolean
editToolDefaultOpen?: boolean
@@ -159,11 +158,7 @@ export function SessionTurn(
const i18n = useI18n()
const fileComponent = useFileComponent()
- const emptyMessages: MessageType[] = []
const emptyParts: PartType[] = []
- const emptyAssistant: AssistantMessage[] = []
- const emptyDiffs: FileDiff[] = []
- const idle = { type: "idle" as const }
const allMessages = createMemo(() => list(data.store.message?.[props.sessionID], emptyMessages))
@@ -191,42 +186,8 @@ export function SessionTurn(
return msg
})
- const pending = createMemo(() => {
- if (typeof props.active === "boolean" && typeof props.queued === "boolean") return
- const messages = allMessages() ?? emptyMessages
- return messages.findLast(
- (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
- )
- })
-
- const pendingUser = createMemo(() => {
- const item = pending()
- if (!item?.parentID) return
- const messages = allMessages() ?? emptyMessages
- const result = Binary.search(messages, item.parentID, (m) => m.id)
- const msg = result.found ? messages[result.index] : messages.find((m) => m.id === item.parentID)
- if (!msg || msg.role !== "user") return
- return msg
- })
-
- const active = createMemo(() => {
- if (typeof props.active === "boolean") return props.active
- const msg = message()
- const parent = pendingUser()
- if (!msg || !parent) return false
- return parent.id === msg.id
- })
-
- const queued = createMemo(() => {
- if (typeof props.queued === "boolean") return props.queued
- const id = message()?.id
- if (!id) return false
- if (!pendingUser()) return false
- const item = pending()
- if (!item) return false
- return id > item.id
- })
-
+ const active = createMemo(() => props.active ?? false)
+ const queued = createMemo(() => props.queued ?? false)
const parts = createMemo(() => {
const msg = message()
if (!msg) return emptyParts
@@ -289,7 +250,7 @@ export function SessionTurn(
const error = createMemo(
() => assistantMessages().find((m) => m.error && m.error.name !== "MessageAbortedError")?.error,
)
- const showAssistantCopyPartID = createMemo(() => {
+ const assistantCopyPart = createMemo(() => {
const messages = assistantMessages()
for (let i = messages.length - 1; i >= 0; i--) {
@@ -299,13 +260,18 @@ export function SessionTurn(
const parts = list(data.store.part?.[message.id], emptyParts)
for (let j = parts.length - 1; j >= 0; j--) {
const part = parts[j]
- if (!part || part.type !== "text" || !part.text?.trim()) continue
- return part.id
+ if (!part || part.type !== "text") continue
+ const text = part.text?.trim()
+ if (!text) continue
+ return {
+ id: part.id,
+ text,
+ message,
+ }
}
}
-
- return undefined
})
+ const assistantCopyPartID = createMemo(() => assistantCopyPart()?.id ?? null)
const errorText = createMemo(() => {
const msg = error()?.data?.message
if (typeof msg === "string") return unwrap(msg)
@@ -313,18 +279,14 @@ export function SessionTurn(
return unwrap(String(msg))
})
- const status = createMemo(() => {
- if (props.status !== undefined) return props.status
- if (typeof props.active === "boolean" && !props.active) return idle
- return data.store.session_status[props.sessionID] ?? idle
+ const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle)
+ const working = createMemo(() => {
+ if (status().type === "idle") return false
+ if (!message()) return false
+ return active()
})
- const working = createMemo(() => status().type !== "idle" && active())
const showReasoningSummaries = createMemo(() => props.showReasoningSummaries ?? true)
-
- const assistantCopyPartID = createMemo(() => {
- if (working()) return null
- return showAssistantCopyPartID() ?? null
- })
+ const showDiffSummary = createMemo(() => edited() > 0 && !working())
const turnDurationMs = createMemo(() => {
const start = message()?.time.created
if (typeof start !== "number") return undefined
@@ -364,13 +326,109 @@ export function SessionTurn(
.filter((text): text is string => !!text)
.at(-1),
)
- const showThinking = createMemo(() => {
+ const thinking = createMemo(() => {
if (!working() || !!error()) return false
if (queued()) return false
if (status().type === "retry") return false
if (showReasoningSummaries()) return assistantVisible() === 0
return true
})
+ const hasAssistant = createMemo(() => assistantMessages().length > 0)
+ const animateEnabled = createMemo(() => props.animate !== false)
+ const [live, setLive] = createSignal(false)
+ const thinkingOpen = createMemo(() => thinking() && (live() || !animateEnabled()))
+ const metaOpen = createMemo(() => !working() && !!assistantCopyPart())
+ const duration = createMemo(() => {
+ const ms = turnDurationMs()
+ if (typeof ms !== "number" || ms < 0) return ""
+
+ const total = Math.round(ms / 1000)
+ if (total < 60) return `${total}s`
+
+ const minutes = Math.floor(total / 60)
+ const seconds = total % 60
+ return `${minutes}m ${seconds}s`
+ })
+ const meta = createMemo(() => {
+ const item = assistantCopyPart()
+ if (!item) return ""
+
+ const agent = item.message.agent ? item.message.agent[0]?.toUpperCase() + item.message.agent.slice(1) : ""
+ const model = item.message.modelID
+ ? (data.store.provider?.all?.find((provider) => provider.id === item.message.providerID)?.models?.[
+ item.message.modelID
+ ]?.name ?? item.message.modelID)
+ : ""
+ return [agent, model, duration()].filter((value) => !!value).join("\u00A0\u00B7\u00A0")
+ })
+ const [copied, setCopied] = createSignal(false)
+ const [handoffHold, setHandoffHold] = createSignal(false)
+ const thinkingVisible = createMemo(() => thinkingOpen() || handoffHold())
+ const handoffOpen = createMemo(() => thinkingVisible() || metaOpen())
+ const lane = createMemo(() => hasAssistant() || handoffOpen())
+
+ let liveFrame: number | undefined
+ let copiedTimer: ReturnType<typeof setTimeout> | undefined
+ let handoffTimer: ReturnType<typeof setTimeout> | undefined
+
+ const copyAssistant = async () => {
+ const text = assistantCopyPart()?.text
+ if (!text) return
+
+ await navigator.clipboard.writeText(text)
+ setCopied(true)
+ if (copiedTimer !== undefined) clearTimeout(copiedTimer)
+ copiedTimer = setTimeout(() => {
+ copiedTimer = undefined
+ setCopied(false)
+ }, 2000)
+ }
+
+ createEffect(
+ on(
+ () => [animateEnabled(), working()] as const,
+ ([enabled, isWorking]) => {
+ if (liveFrame !== undefined) {
+ cancelAnimationFrame(liveFrame)
+ liveFrame = undefined
+ }
+ if (!enabled || !isWorking || live()) return
+ liveFrame = requestAnimationFrame(() => {
+ liveFrame = undefined
+ setLive(true)
+ })
+ },
+ ),
+ )
+
+ createEffect(
+ on(
+ () => [thinkingOpen(), metaOpen()] as const,
+ ([thinkingNow, metaNow]) => {
+ if (handoffTimer !== undefined) {
+ clearTimeout(handoffTimer)
+ handoffTimer = undefined
+ }
+
+ if (thinkingNow) {
+ setHandoffHold(true)
+ return
+ }
+
+ if (metaNow) {
+ setHandoffHold(false)
+ return
+ }
+
+ if (!handoffHold()) return
+ handoffTimer = setTimeout(() => {
+ handoffTimer = undefined
+ setHandoffHold(false)
+ }, handoffHoldMs)
+ },
+ { defer: true },
+ ),
+ )
const autoScroll = createAutoScroll({
working,
@@ -378,6 +436,119 @@ export function SessionTurn(
overflowAnchor: "dynamic",
})
+ onCleanup(() => {
+ if (liveFrame !== undefined) cancelAnimationFrame(liveFrame)
+ if (copiedTimer !== undefined) clearTimeout(copiedTimer)
+ if (handoffTimer !== undefined) clearTimeout(handoffTimer)
+ })
+
+ const turnDiffSummary = () => (
+ <div data-slot="session-turn-diffs">
+ <Collapsible open={open()} onOpenChange={setOpen} variant="ghost">
+ <Collapsible.Trigger>
+ <div data-component="session-turn-diffs-trigger">
+ <div data-slot="session-turn-diffs-title">
+ <span data-slot="session-turn-diffs-label">{i18n.t("ui.sessionReview.change.modified")}</span>
+ <span data-slot="session-turn-diffs-count">
+ {edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")}
+ </span>
+ <div data-slot="session-turn-diffs-meta">
+ <DiffChanges changes={diffs()} variant="bars" />
+ <Collapsible.Arrow />
+ </div>
+ </div>
+ </div>
+ </Collapsible.Trigger>
+ <Collapsible.Content>
+ <Show when={open()}>
+ <div data-component="session-turn-diffs-content">
+ <Accordion
+ multiple
+ style={{ "--sticky-accordion-offset": "37px" }}
+ value={expanded()}
+ onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
+ >
+ <For each={diffs()}>
+ {(diff) => {
+ const active = createMemo(() => expanded().includes(diff.file))
+ const [visible, setVisible] = createSignal(false)
+
+ createEffect(
+ on(
+ active,
+ (value) => {
+ if (!value) {
+ setVisible(false)
+ return
+ }
+
+ requestAnimationFrame(() => {
+ if (!active()) return
+ setVisible(true)
+ })
+ },
+ { defer: true },
+ ),
+ )
+
+ return (
+ <Accordion.Item value={diff.file}>
+ <StickyAccordionHeader>
+ <Accordion.Trigger>
+ <div data-slot="session-turn-diff-trigger">
+ <span data-slot="session-turn-diff-path">
+ <Show when={diff.file.includes("/")}>
+ <span data-slot="session-turn-diff-directory">{`\u202A${getDirectory(diff.file)}\u202C`}</span>
+ </Show>
+ <span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span>
+ </span>
+ <div data-slot="session-turn-diff-meta">
+ <span data-slot="session-turn-diff-changes">
+ <DiffChanges changes={diff} />
+ </span>
+ <span data-slot="session-turn-diff-chevron">
+ <Icon name="chevron-down" size="small" />
+ </span>
+ </div>
+ </div>
+ </Accordion.Trigger>
+ </StickyAccordionHeader>
+ <Accordion.Content>
+ <Show when={visible()}>
+ <div data-slot="session-turn-diff-view" data-scrollable>
+ <Dynamic
+ component={fileComponent}
+ mode="diff"
+ before={{ name: diff.file, contents: diff.before }}
+ after={{ name: diff.file, contents: diff.after }}
+ />
+ </div>
+ </Show>
+ </Accordion.Content>
+ </Accordion.Item>
+ )
+ }}
+ </For>
+ </Accordion>
+ </div>
+ </Show>
+ </Collapsible.Content>
+ </Collapsible>
+ </div>
+ )
+
+ const divider = (label: string) => (
+ <div data-component="compaction-part">
+ <div data-slot="compaction-part-divider">
+ <span data-slot="compaction-part-line" />
+ <span data-slot="compaction-part-label" class="text-12-regular text-text-weak">
+ {label}
+ </span>
+ <span data-slot="compaction-part-line" />
+ </div>
+ </div>
+ )
+
return (
<div data-component="session-turn" class={props.classes?.root}>
<div
@@ -388,149 +559,120 @@ export function SessionTurn(
>
<div onClick={autoScroll.handleInteraction}>
<Show when={message()}>
- <div
- ref={autoScroll.contentRef}
- data-message={message()!.id}
- data-slot="session-turn-message-container"
- class={props.classes?.container}
- >
- <div data-slot="session-turn-message-content" aria-live="off">
- <Message message={message()!} parts={parts()} interrupted={interrupted()} queued={queued()} />
- </div>
- <Show when={compaction()}>
- <div data-slot="session-turn-compaction">
- <Part part={compaction()!} message={message()!} hideDetails />
- </div>
- </Show>
- <Show when={assistantMessages().length > 0}>
- <div data-slot="session-turn-assistant-content" aria-hidden={working()}>
- <AssistantParts
- messages={assistantMessages()}
- showAssistantCopyPartID={assistantCopyPartID()}
- turnDurationMs={turnDurationMs()}
- working={working()}
- showReasoningSummaries={showReasoningSummaries()}
- shellToolDefaultOpen={props.shellToolDefaultOpen}
- editToolDefaultOpen={props.editToolDefaultOpen}
+ {(msg) => (
+ <div
+ ref={autoScroll.contentRef}
+ data-message={msg().id}
+ data-slot="session-turn-message-container"
+ class={props.classes?.container}
+ >
+ <div data-slot="session-turn-message-content" aria-live="off">
+ <UserMessageDisplay
+ message={msg()}
+ parts={parts()}
+ interrupted={interrupted()}
+ animate={props.animate}
+ queued={queued()}
/>
</div>
- </Show>
- <Show when={showThinking()}>
- <div data-slot="session-turn-thinking">
- <TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
- <Show when={!showReasoningSummaries()}>
- <TextReveal
- text={reasoningHeading()}
- class="session-turn-thinking-heading"
- travel={25}
- duration={700}
- />
+ <Show when={compaction()}>
+ {(part) => (
+ <GrowBox animate={props.animate !== false} fade gap={8} class="w-full min-w-0">
+ <div data-slot="session-turn-compaction">
+ <Part part={part()} message={msg()} hideDetails />
+ </div>
+ </GrowBox>
+ )}
+ </Show>
+ <div data-slot="session-turn-assistant-lane" aria-hidden={!lane()}>
+ <Show when={hasAssistant()}>
+ <div
+ data-slot="session-turn-assistant-content"
+ aria-hidden={working()}
+ style={{ contain: "layout paint" }}
+ >
+ <AssistantParts
+ messages={assistantMessages()}
+ showAssistantCopyPartID={assistantCopyPartID()}
+ showTurnDiffSummary={showDiffSummary()}
+ turnDiffSummary={turnDiffSummary}
+ working={working()}
+ animate={live()}
+ showReasoningSummaries={showReasoningSummaries()}
+ shellToolDefaultOpen={props.shellToolDefaultOpen}
+ editToolDefaultOpen={props.editToolDefaultOpen}
+ />
+ </div>
</Show>
- </div>
- </Show>
- <SessionRetry status={status()} show={active()} />
- <Show when={edited() > 0 && !working()}>
- <div data-slot="session-turn-diffs">
- <Collapsible open={open()} onOpenChange={setOpen} variant="ghost">
- <Collapsible.Trigger>
- <div data-component="session-turn-diffs-trigger">
- <div data-slot="session-turn-diffs-title">
- <span data-slot="session-turn-diffs-label">{i18n.t("ui.sessionReview.change.modified")}</span>
- <span data-slot="session-turn-diffs-count">
- {edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")}
- </span>
- <div data-slot="session-turn-diffs-meta">
- <DiffChanges changes={diffs()} variant="bars" />
- <Collapsible.Arrow />
- </div>
- </div>
+ <GrowBox
+ animate={live()}
+ animateToggle={live()}
+ open={handoffOpen()}
+ fade
+ slot="session-turn-handoff-wrap"
+ >
+ <div data-slot="session-turn-handoff">
+ <div data-slot="session-turn-thinking" data-visible={thinkingVisible() ? "true" : "false"}>
+ <TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
+ <TextReveal
+ text={!showReasoningSummaries() ? (reasoningHeading() ?? "") : ""}
+ class="session-turn-thinking-heading"
+ travel={25}
+ duration={900}
+ />
</div>
- </Collapsible.Trigger>
- <Collapsible.Content>
- <Show when={open()}>
- <div data-component="session-turn-diffs-content">
- <Accordion
- multiple
- style={{ "--sticky-accordion-offset": "40px" }}
- value={expanded()}
- onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
+ <Show when={metaOpen()}>
+ <div
+ data-slot="session-turn-meta"
+ data-visible={thinkingVisible() ? "false" : "true"}
+ data-interrupted={interrupted() ? "" : undefined}
+ >
+ <Tooltip
+ value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")}
+ placement="top"
+ gutter={4}
>
- <For each={diffs()}>
- {(diff) => {
- const active = createMemo(() => expanded().includes(diff.file))
- const [visible, setVisible] = createSignal(false)
-
- createEffect(
- on(
- active,
- (value) => {
- if (!value) {
- setVisible(false)
- return
- }
-
- requestAnimationFrame(() => {
- if (!active()) return
- setVisible(true)
- })
- },
- { defer: true },
- ),
- )
-
- return (
- <Accordion.Item value={diff.file}>
- <StickyAccordionHeader>
- <Accordion.Trigger>
- <div data-slot="session-turn-diff-trigger">
- <span data-slot="session-turn-diff-path">
- <Show when={diff.file.includes("/")}>
- <span data-slot="session-turn-diff-directory">
- {`\u202A${getDirectory(diff.file)}\u202C`}
- </span>
- </Show>
- <span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span>
- </span>
- <div data-slot="session-turn-diff-meta">
- <span data-slot="session-turn-diff-changes">
- <DiffChanges changes={diff} />
- </span>
- <span data-slot="session-turn-diff-chevron">
- <Icon name="chevron-down" size="small" />
- </span>
- </div>
- </div>
- </Accordion.Trigger>
- </StickyAccordionHeader>
- <Accordion.Content>
- <Show when={visible()}>
- <div data-slot="session-turn-diff-view" data-scrollable>
- <Dynamic
- component={fileComponent}
- mode="diff"
- before={{ name: diff.file, contents: diff.before }}
- after={{ name: diff.file, contents: diff.after }}
- />
- </div>
- </Show>
- </Accordion.Content>
- </Accordion.Item>
- )
- }}
- </For>
- </Accordion>
+ <IconButton
+ icon={copied() ? "check" : "copy"}
+ size="normal"
+ variant="ghost"
+ onMouseDown={(event) => event.preventDefault()}
+ onClick={() => void copyAssistant()}
+ aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")}
+ />
+ </Tooltip>
+ <Show when={meta()}>
+ <span
+ data-slot="session-turn-meta-label"
+ class="text-12-regular text-text-weak cursor-default"
+ >
+ {meta()}
+ </span>
+ </Show>
</div>
</Show>
- </Collapsible.Content>
- </Collapsible>
+ </div>
+ </GrowBox>
</div>
- </Show>
- <Show when={error()}>
- <Card variant="error" class="error-card">
- {errorText()}
- </Card>
- </Show>
- </div>
+ <GrowBox animate={props.animate !== false} fade gap={0} open={interrupted()} class="w-full min-w-0">
+ {divider(i18n.t("ui.message.interrupted"))}
+ </GrowBox>
+ <SessionRetry status={status()} show={active()} />
+ <GrowBox
+ animate={props.animate !== false}
+ fade
+ gap={0}
+ open={showDiffSummary() && !assistantCopyPartID()}
+ >
+ {turnDiffSummary()}
+ </GrowBox>
+ <Show when={error()}>
+ <Card variant="error" class="error-card">
+ {errorText()}
+ </Card>
+ </Show>
+ </div>
+ )}
</Show>
{props.children}
</div>
diff --git a/packages/ui/src/components/shell-rolling-results.tsx b/packages/ui/src/components/shell-rolling-results.tsx
new file mode 100644
index 000000000..6a3b7b02c
--- /dev/null
+++ b/packages/ui/src/components/shell-rolling-results.tsx
@@ -0,0 +1,310 @@
+import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
+import stripAnsi from "strip-ansi"
+import type { ToolPart } from "@opencode-ai/sdk/v2"
+import { prefersReducedMotion } from "../hooks/use-reduced-motion"
+import { useI18n } from "../context/i18n"
+import { RollingResults } from "./rolling-results"
+import { Icon } from "./icon"
+import { IconButton } from "./icon-button"
+import { TextShimmer } from "./text-shimmer"
+import { Tooltip } from "./tooltip"
+import { GROW_SPRING } from "./motion"
+import { useSpring } from "./motion-spring"
+import {
+ busy,
+ createThrottledValue,
+ hold,
+ updateScrollMask,
+ useCollapsible,
+ useRowWipe,
+ useToolFade,
+} from "./tool-utils"
+
+function ShellRollingSubtitle(props: { text: string; animate?: boolean }) {
+ let ref: HTMLSpanElement | undefined
+ useToolFade(() => ref, { wipe: true, animate: props.animate })
+
+ return (
+ <span data-slot="shell-rolling-subtitle">
+ <span ref={ref}>{props.text}</span>
+ </span>
+ )
+}
+
+function firstLine(text: string) {
+ return text
+ .split(/\r\n|\n|\r/g)
+ .map((item) => item.trim())
+ .find((item) => item.length > 0)
+}
+
+function shellRows(output: string) {
+ const rows: { id: string; text: string }[] = []
+ const lines = output
+ .split(/\r\n|\n|\r/g)
+ .map((item) => item.trimEnd())
+ .filter((item) => item.length > 0)
+ const start = Math.max(0, lines.length - 80)
+ for (let i = start; i < lines.length; i++) {
+ rows.push({ id: `line:${i}`, text: lines[i]! })
+ }
+
+ return rows
+}
+
+function ShellRollingCommand(props: { text: string; animate?: boolean }) {
+ let ref: HTMLSpanElement | undefined
+ useToolFade(() => ref, { wipe: true, animate: props.animate })
+
+ return (
+ <div data-component="shell-rolling-command">
+ <span ref={ref} data-slot="shell-rolling-text">
+ <span data-slot="shell-rolling-prompt">$</span> {props.text}
+ </span>
+ </div>
+ )
+}
+
+function ShellExpanded(props: { cmd: string; out: string; open: boolean }) {
+ const i18n = useI18n()
+ const rows = 10
+ const rowHeight = 22
+ const max = rows * rowHeight
+
+ let contentRef: HTMLDivElement | undefined
+ let bodyRef: HTMLDivElement | undefined
+ let scrollRef: HTMLDivElement | undefined
+ let topRef: HTMLDivElement | undefined
+ const [copied, setCopied] = createSignal(false)
+ const [cap, setCap] = createSignal(max)
+
+ const updateMask = () => {
+ if (scrollRef) updateScrollMask(scrollRef)
+ }
+
+ const resize = () => {
+ const top = Math.ceil(topRef?.getBoundingClientRect().height ?? 0)
+ setCap(Math.max(rowHeight * 2, max - top - (props.out ? 1 : 0)))
+ }
+
+ const measure = () => {
+ resize()
+ return Math.ceil(bodyRef?.getBoundingClientRect().height ?? 0)
+ }
+
+ onMount(() => {
+ resize()
+ if (!topRef) return
+ const obs = new ResizeObserver(resize)
+ obs.observe(topRef)
+ onCleanup(() => obs.disconnect())
+ })
+
+ createEffect(() => {
+ props.cmd
+ props.out
+ queueMicrotask(() => {
+ resize()
+ updateMask()
+ })
+ })
+
+ useCollapsible({
+ content: () => contentRef,
+ body: () => bodyRef,
+ open: () => props.open,
+ measure,
+ onOpen: updateMask,
+ })
+
+ const handleCopy = async (e: MouseEvent) => {
+ e.stopPropagation()
+ const cmd = props.cmd ? `$ ${props.cmd}` : ""
+ const text = `${cmd}${props.out ? `${cmd ? "\n\n" : ""}${props.out}` : ""}`
+ if (!text) return
+ await navigator.clipboard.writeText(text)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ }
+
+ return (
+ <div ref={contentRef} style={{ overflow: "clip", height: "0px", display: "none" }}>
+ <div ref={bodyRef} data-component="shell-expanded-shell">
+ <div data-slot="shell-expanded-body">
+ <div ref={topRef} data-slot="shell-expanded-top">
+ <div data-slot="shell-expanded-command">
+ <span data-slot="shell-expanded-prompt">$</span>
+ <span data-slot="shell-expanded-input">{props.cmd}</span>
+ </div>
+ <div data-slot="shell-expanded-actions">
+ <Tooltip
+ value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
+ placement="top"
+ gutter={4}
+ >
+ <IconButton
+ icon={copied() ? "check" : "copy"}
+ size="small"
+ variant="ghost"
+ class="shell-expanded-copy"
+ onMouseDown={(e: MouseEvent) => e.preventDefault()}
+ onClick={handleCopy}
+ aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
+ />
+ </Tooltip>
+ </div>
+ </div>
+ <Show when={props.out}>
+ <>
+ <div data-slot="shell-expanded-divider" />
+ <div
+ ref={scrollRef}
+ data-component="shell-expanded-output"
+ data-scrollable
+ onScroll={updateMask}
+ style={{ "max-height": `${cap()}px` }}
+ >
+ <pre data-slot="shell-expanded-pre">
+ <code>{props.out}</code>
+ </pre>
+ </div>
+ </>
+ </Show>
+ </div>
+ </div>
+ </div>
+ )
+}
+
+export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }) {
+ const i18n = useI18n()
+ const wiped = new Set<string>()
+ const [mounted, setMounted] = createSignal(false)
+ const [userToggled, setUserToggled] = createSignal(false)
+ const [userOpen, setUserOpen] = createSignal(false)
+ onMount(() => setMounted(true))
+ const state = createMemo(() => props.part.state as Record<string, any>)
+ const pending = createMemo(() => busy(props.part.state.status))
+ const autoOpen = hold(pending, 2000)
+ const effectiveOpen = createMemo(() => {
+ if (pending()) return true
+ if (userToggled()) return userOpen()
+ return autoOpen()
+ })
+ const expanded = createMemo(() => !pending() && !autoOpen() && userToggled() && userOpen())
+ const previewOpen = createMemo(() => effectiveOpen() && !expanded())
+ const command = createMemo(() => {
+ const value = state().input?.command ?? state().metadata?.command
+ if (typeof value === "string") return value
+ return ""
+ })
+ const subtitle = createMemo(() => {
+ const value = state().input?.description ?? state().metadata?.description
+ if (typeof value === "string" && value.trim().length > 0) return value
+ return firstLine(command()) ?? ""
+ })
+ const output = createMemo(() => {
+ const value = state().output ?? state().metadata?.output
+ if (typeof value === "string") return value
+ return ""
+ })
+ const reduce = prefersReducedMotion
+ const skip = () => reduce() || props.animate === false
+ const opacity = useSpring(() => (mounted() ? 1 : 0), GROW_SPRING)
+ const blur = useSpring(() => (mounted() ? 0 : 2), GROW_SPRING)
+ const previewOpacity = useSpring(() => (previewOpen() ? 1 : 0), GROW_SPRING)
+ const previewBlur = useSpring(() => (previewOpen() ? 0 : 2), GROW_SPRING)
+ const headerHeight = useSpring(() => (mounted() ? 37 : 0), GROW_SPRING)
+ let headerClipRef: HTMLDivElement | undefined
+ const handleHeaderClick = () => {
+ if (pending()) return
+ const el = headerClipRef
+ const viewport = el?.closest(".scroll-view__viewport") as HTMLElement | null
+ const beforeY = el?.getBoundingClientRect().top ?? 0
+ setUserToggled(true)
+ setUserOpen((prev) => !prev)
+ if (viewport && el) {
+ requestAnimationFrame(() => {
+ const afterY = el.getBoundingClientRect().top
+ const delta = afterY - beforeY
+ if (delta !== 0) viewport.scrollTop += delta
+ })
+ }
+ }
+ const line = createMemo(() => firstLine(command()))
+ const fixed = createMemo(() => {
+ const value = line()
+ if (!value) return
+ return <ShellRollingCommand text={value} animate={props.animate} />
+ })
+ const text = createThrottledValue(() => stripAnsi(output()))
+ const rows = createMemo(() => shellRows(text()))
+
+ return (
+ <div
+ data-component="shell-rolling-results"
+ style={{ opacity: skip() ? (mounted() ? 1 : 0) : opacity(), filter: `blur(${skip() ? 0 : blur()}px)` }}
+ >
+ <div
+ ref={headerClipRef}
+ data-slot="shell-rolling-header-clip"
+ data-scroll-preserve
+ data-clickable={!pending() ? "true" : "false"}
+ onClick={handleHeaderClick}
+ style={{ height: `${skip() ? (mounted() ? 37 : 0) : headerHeight()}px`, overflow: "clip" }}
+ >
+ <div data-slot="shell-rolling-header">
+ <span data-slot="shell-rolling-title">
+ <TextShimmer text={i18n.t("ui.tool.shell")} active={pending()} />
+ </span>
+ <Show when={subtitle()}>{(text) => <ShellRollingSubtitle text={text()} animate={props.animate} />}</Show>
+ <Show when={!pending()}>
+ <span data-slot="shell-rolling-actions">
+ <span data-slot="shell-rolling-arrow" data-open={effectiveOpen() ? "true" : "false"}>
+ <Icon name="chevron-down" size="small" />
+ </span>
+ </span>
+ </Show>
+ </div>
+ </div>
+ <div
+ data-slot="shell-rolling-preview"
+ style={{
+ opacity: skip() ? (previewOpen() ? 1 : 0) : previewOpacity(),
+ filter: `blur(${skip() ? 0 : previewBlur()}px)`,
+ }}
+ >
+ <RollingResults
+ class="shell-rolling-output"
+ noFadeOnCollapse
+ items={rows()}
+ fixed={fixed()}
+ fixedHeight={22}
+ rows={5}
+ rowHeight={22}
+ rowGap={0}
+ open={previewOpen()}
+ animate={props.animate !== false}
+ getKey={(row) => row.id}
+ render={(row) => {
+ const [textRef, setTextRef] = createSignal<HTMLSpanElement>()
+ useRowWipe({
+ id: () => row.id,
+ text: () => row.text,
+ ref: textRef,
+ seen: wiped,
+ })
+ return (
+ <div data-component="shell-rolling-row">
+ <span ref={setTextRef} data-slot="shell-rolling-text">
+ {row.text}
+ </span>
+ </div>
+ )
+ }}
+ />
+ </div>
+ <ShellExpanded cmd={command()} out={text()} open={expanded()} />
+ </div>
+ )
+}
diff --git a/packages/ui/src/components/shell-submessage.css b/packages/ui/src/components/shell-submessage.css
index f72ba3fc7..9f19c2d15 100644
--- a/packages/ui/src/components/shell-submessage.css
+++ b/packages/ui/src/components/shell-submessage.css
@@ -1,23 +1,13 @@
[data-component="shell-submessage"] {
min-width: 0;
max-width: 100%;
- display: inline-flex;
- align-items: baseline;
+ display: inline-block;
vertical-align: baseline;
}
-[data-component="shell-submessage"] [data-slot="shell-submessage-width"] {
- min-width: 0;
- max-width: 100%;
- display: inline-flex;
- align-items: baseline;
- overflow: hidden;
-}
-
[data-component="shell-submessage"] [data-slot="shell-submessage-value"] {
display: inline-block;
vertical-align: baseline;
min-width: 0;
- line-height: inherit;
white-space: nowrap;
}
diff --git a/packages/ui/src/components/text-reveal.css b/packages/ui/src/components/text-reveal.css
index f799962f0..7939322e6 100644
--- a/packages/ui/src/components/text-reveal.css
+++ b/packages/ui/src/components/text-reveal.css
@@ -4,14 +4,14 @@
* Instead of sliding text through a fixed mask (odometer style),
* the mask itself sweeps across each span to reveal/hide text.
*
- * Direction: top-to-bottom. New text drops in from above, old text exits downward.
+ * Direction: bottom-to-top. New text rises in from below, old text exits upward.
*
- * Entering: gradient reveals top-to-bottom (top of text appears first).
+ * Entering: gradient reveals bottom-to-top (bottom of text appears first).
* gradient(to bottom, white 33%, transparent 33%+edge)
* pos 0 100% = transparent covers element = hidden
* pos 0 0% = white covers element = visible
*
- * Leaving: gradient hides top-to-bottom (top of text disappears first).
+ * Leaving: gradient hides bottom-to-top (bottom of text disappears first).
* gradient(to top, white 33%, transparent 33%+edge)
* pos 0 100% = white covers element = visible
* pos 0 0% = transparent covers element = hidden
@@ -56,17 +56,17 @@
transition-timing-function: var(--_spring);
}
- /* ── entering: reveal top-to-bottom ──
- * Gradient(to top): white at bottom, transparent at top of mask.
- * Settled pos 0 100% = white covers element = visible
- * Swap pos 0 0% = transparent covers = hidden
- * Slides from above: translateY(-travel) → translateY(0)
+ /* ── entering: reveal bottom-to-top ──
+ * Gradient(to bottom): white at top, transparent at bottom of mask.
+ * Settled pos 0 0% = white covers element = visible
+ * Swap pos 0 100% = transparent covers = hidden
+ * Rises from below: translateY(travel) → translateY(0)
*/
[data-slot="text-reveal-entering"] {
- mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge)));
- -webkit-mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge)));
- mask-position: 0 100%;
- -webkit-mask-position: 0 100%;
+ mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge)));
+ -webkit-mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge)));
+ mask-position: 0 0%;
+ -webkit-mask-position: 0 0%;
transition-property:
mask-position,
-webkit-mask-position,
@@ -74,37 +74,37 @@
transform: translateY(0);
}
- /* ── leaving: hide top-to-bottom + slide downward ──
- * Gradient(to bottom): white at top, transparent at bottom of mask.
- * Swap pos 0 0% = white covers element = visible
- * Settled pos 0 100% = transparent covers = hidden
- * Slides down: translateY(0) → translateY(travel)
+ /* ── leaving: hide bottom-to-top + slide upward ──
+ * Gradient(to top): white at bottom, transparent at top of mask.
+ * Swap pos 0 100% = white covers element = visible
+ * Settled pos 0 0% = transparent covers = hidden
+ * Slides up: translateY(0) → translateY(-travel)
*/
[data-slot="text-reveal-leaving"] {
- mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge)));
- -webkit-mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge)));
- mask-position: 0 100%;
- -webkit-mask-position: 0 100%;
+ mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge)));
+ -webkit-mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge)));
+ mask-position: 0 0%;
+ -webkit-mask-position: 0 0%;
transition-property:
mask-position,
-webkit-mask-position,
transform;
- transform: translateY(var(--_travel));
+ transform: translateY(calc(var(--_travel) * -1));
}
/* ── swapping: instant reset ──
- * Snap entering to hidden (above), leaving to visible (center).
+ * Snap entering to hidden (below), leaving to visible (center).
*/
&[data-swapping="true"] [data-slot="text-reveal-entering"] {
- mask-position: 0 0%;
- -webkit-mask-position: 0 0%;
- transform: translateY(calc(var(--_travel) * -1));
+ mask-position: 0 100%;
+ -webkit-mask-position: 0 100%;
+ transform: translateY(var(--_travel));
transition-duration: 0ms !important;
}
&[data-swapping="true"] [data-slot="text-reveal-leaving"] {
- mask-position: 0 0%;
- -webkit-mask-position: 0 0%;
+ mask-position: 0 100%;
+ -webkit-mask-position: 0 100%;
transform: translateY(0);
transition-duration: 0ms !important;
}
@@ -126,15 +126,14 @@
&[data-truncate="true"] [data-slot="text-reveal-track"] {
width: 100%;
min-width: 0;
- overflow: hidden;
+ overflow: clip;
}
&[data-truncate="true"] [data-slot="text-reveal-entering"],
&[data-truncate="true"] [data-slot="text-reveal-leaving"] {
min-width: 0;
width: 100%;
- overflow: hidden;
- text-overflow: ellipsis;
+ overflow: clip;
}
}
diff --git a/packages/ui/src/components/text-reveal.tsx b/packages/ui/src/components/text-reveal.tsx
index c4fe1302f..b729367a5 100644
--- a/packages/ui/src/components/text-reveal.tsx
+++ b/packages/ui/src/components/text-reveal.tsx
@@ -1,4 +1,6 @@
import { createEffect, createSignal, on, onCleanup, onMount } from "solid-js"
+import { animate, type AnimationPlaybackControls, clearFadeStyles, clearMaskStyles, GROW_SPRING, WIPE_MASK } from "./motion"
+import { prefersReducedMotion } from "../hooks/use-reduced-motion"
const px = (value: number | string | undefined, fallback: number) => {
if (typeof value === "number") return `${value}px`
@@ -17,6 +19,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 +46,8 @@ export function TextReveal(props: {
let outRef: HTMLSpanElement | undefined
let rootRef: HTMLSpanElement | undefined
let frame: number | undefined
-
const win = () => inRef?.scrollWidth ?? 0
const wout = () => outRef?.scrollWidth ?? 0
-
const widen = (next: number) => {
if (next <= 0) return
if (props.growOnly ?? true) {
@@ -51,21 +56,14 @@ export function TextReveal(props: {
}
setWidth(`${next}px`)
}
-
createEffect(
on(
() => props.text,
(next, prev) => {
if (next === prev) return
- if (typeof next === "string" && typeof prev === "string" && next.startsWith(prev)) {
- setCur(next)
- widen(win())
- return
- }
setSwapping(true)
setOld(prev)
setCur(next)
-
if (typeof requestAnimationFrame !== "function") {
widen(Math.max(win(), wout()))
rootRef?.offsetHeight
@@ -133,3 +131,94 @@ export function TextReveal(props: {
</span>
)
}
+
+export function TextWipe(props: { text?: string; class?: string; delay?: number; animate?: boolean }) {
+ let ref: HTMLSpanElement | undefined
+ let frame: number | undefined
+ let anim: AnimationPlaybackControls | undefined
+
+ const run = () => {
+ if (props.animate === false) return
+ const el = ref
+ if (!el || !props.text || typeof window === "undefined") return
+ if (prefersReducedMotion()) return
+
+ const mask =
+ typeof CSS !== "undefined" &&
+ (CSS.supports("mask-image", "linear-gradient(to right, black, transparent)") ||
+ CSS.supports("-webkit-mask-image", "linear-gradient(to right, black, transparent)"))
+
+ anim?.stop()
+ if (frame !== undefined && typeof cancelAnimationFrame === "function") {
+ cancelAnimationFrame(frame)
+ frame = undefined
+ }
+
+ el.style.opacity = "0"
+ el.style.filter = "blur(3px)"
+ el.style.transform = "translateX(-0.06em)"
+
+ if (mask) {
+ el.style.maskImage = WIPE_MASK
+ el.style.webkitMaskImage = WIPE_MASK
+ el.style.maskSize = "240% 100%"
+ el.style.webkitMaskSize = "240% 100%"
+ el.style.maskRepeat = "no-repeat"
+ el.style.webkitMaskRepeat = "no-repeat"
+ el.style.maskPosition = "100% 0%"
+ el.style.webkitMaskPosition = "100% 0%"
+ }
+
+ if (typeof requestAnimationFrame !== "function") {
+ clearWipe(el)
+ return
+ }
+
+ frame = requestAnimationFrame(() => {
+ frame = undefined
+ const node = ref
+ if (!node) return
+ anim = mask
+ ? animate(
+ node,
+ { opacity: 1, filter: "blur(0px)", transform: "translateX(0)", maskPosition: "0% 0%" },
+ { ...GROW_SPRING, delay: props.delay ?? 0 },
+ )
+ : animate(
+ node,
+ { opacity: 1, filter: "blur(0px)", transform: "translateX(0)" },
+ { ...GROW_SPRING, delay: props.delay ?? 0 },
+ )
+
+ anim?.finished.then(() => {
+ const value = ref
+ if (!value) return
+ clearWipe(value)
+ })
+ })
+ }
+
+ createEffect(
+ on(
+ () => [props.text, props.animate] as const,
+ ([text, enabled]) => {
+ if (!text || enabled === false) {
+ if (ref) clearWipe(ref)
+ return
+ }
+ run()
+ },
+ ),
+ )
+
+ onCleanup(() => {
+ if (frame !== undefined && typeof cancelAnimationFrame === "function") cancelAnimationFrame(frame)
+ anim?.stop()
+ })
+
+ return (
+ <span ref={ref} class={props.class} aria-label={props.text ?? ""}>
+ {props.text ?? "\u00A0"}
+ </span>
+ )
+}
diff --git a/packages/ui/src/components/text-shimmer.css b/packages/ui/src/components/text-shimmer.css
index f042dd2d8..bd1437c27 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.tsx b/packages/ui/src/components/text-shimmer.tsx
index c4c20b8e7..5d3dee1eb 100644
--- a/packages/ui/src/components/text-shimmer.tsx
+++ b/packages/ui/src/components/text-shimmer.tsx
@@ -36,6 +36,19 @@ export const TextShimmer = <T extends ValidComponent = "span">(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.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
+ return Math.max(1000, Math.min(2500, Math.round((len * (s - 1)) / VELOCITY)))
+ })
+
return (
<Dynamic
component={props.as ?? "span"}
@@ -46,6 +59,8 @@ export const TextShimmer = <T extends ValidComponent = "span">(props: {
style={{
"--text-shimmer-swap": `${swap}ms`,
"--text-shimmer-index": `${offset()}`,
+ "--text-shimmer-size": `${shimmerSize()}%`,
+ "--text-shimmer-duration": `${shimmerDuration()}ms`,
}}
>
<span data-slot="text-shimmer-char">
diff --git a/packages/ui/src/components/text-utils.ts b/packages/ui/src/components/text-utils.ts
new file mode 100644
index 000000000..c094b5e65
--- /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<T>(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.css b/packages/ui/src/components/tool-count-label.css
index 11a33ff5d..4ed46e50b 100644
--- a/packages/ui/src/components/tool-count-label.css
+++ b/packages/ui/src/components/tool-count-label.css
@@ -27,10 +27,10 @@
grid-template-columns: 0fr;
opacity: 0;
filter: blur(calc(var(--tool-motion-blur, 2px) * 0.42));
- overflow: hidden;
+ overflow: clip;
transform: translateX(-0.04em);
transition-property: grid-template-columns, opacity, filter, transform;
- transition-duration: 250ms, 250ms, 250ms, 250ms;
+ transition-duration: 800ms, 400ms, 400ms, 800ms;
transition-timing-function:
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out,
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
@@ -45,7 +45,7 @@
[data-slot="tool-count-label-suffix-inner"] {
min-width: 0;
- overflow: hidden;
+ overflow: clip;
white-space: pre;
}
}
diff --git a/packages/ui/src/components/tool-count-label.tsx b/packages/ui/src/components/tool-count-label.tsx
index 67e861cdc..c374d2d37 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-count-summary.css b/packages/ui/src/components/tool-count-summary.css
index da8455267..a57ceb482 100644
--- a/packages/ui/src/components/tool-count-summary.css
+++ b/packages/ui/src/components/tool-count-summary.css
@@ -10,12 +10,12 @@
opacity: 1;
filter: blur(0);
transform: translateY(0) scale(1);
- overflow: hidden;
+ overflow: clip;
transform-origin: left center;
transition-property: grid-template-columns, opacity, filter, transform;
transition-duration:
- var(--tool-motion-spring-ms, 480ms), var(--tool-motion-fade-ms, 240ms), var(--tool-motion-fade-ms, 280ms),
- var(--tool-motion-spring-ms, 480ms);
+ var(--tool-motion-spring-ms, 800ms), var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms),
+ var(--tool-motion-spring-ms, 800ms);
transition-timing-function:
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out,
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
@@ -35,12 +35,12 @@
opacity: 0;
filter: blur(var(--tool-motion-blur, 2px));
transform: translateY(0.06em) scale(0.985);
- overflow: hidden;
+ overflow: clip;
transform-origin: left center;
transition-property: grid-template-columns, opacity, filter, transform;
transition-duration:
- var(--tool-motion-spring-ms, 480ms), var(--tool-motion-fade-ms, 280ms), var(--tool-motion-fade-ms, 320ms),
- var(--tool-motion-spring-ms, 480ms);
+ var(--tool-motion-spring-ms, 800ms), var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms),
+ var(--tool-motion-spring-ms, 800ms);
transition-timing-function:
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out,
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
@@ -55,7 +55,7 @@
[data-slot="tool-count-summary-empty-inner"] {
min-width: 0;
- overflow: hidden;
+ overflow: clip;
white-space: nowrap;
}
@@ -63,7 +63,7 @@
display: inline-flex;
align-items: baseline;
min-width: 0;
- overflow: hidden;
+ overflow: clip;
white-space: nowrap;
}
@@ -75,12 +75,12 @@
margin-right: 0;
opacity: 0;
filter: blur(calc(var(--tool-motion-blur, 2px) * 0.55));
- overflow: hidden;
+ overflow: clip;
transform: translateX(-0.08em);
transition-property: opacity, filter, transform;
transition-duration:
- calc(var(--tool-motion-fade-ms, 200ms) * 0.75), calc(var(--tool-motion-fade-ms, 220ms) * 0.75),
- calc(var(--tool-motion-fade-ms, 220ms) * 0.6);
+ var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms),
+ var(--tool-motion-fade-ms, 400ms);
transition-timing-function: ease-out, ease-out, ease-out;
}
diff --git a/packages/ui/src/components/tool-status-title.css b/packages/ui/src/components/tool-status-title.css
index d4415bd2d..050f5e390 100644
--- a/packages/ui/src/components/tool-status-title.css
+++ b/packages/ui/src/components/tool-status-title.css
@@ -18,9 +18,8 @@
[data-slot="tool-status-swap"],
[data-slot="tool-status-tail"] {
display: inline-grid;
- overflow: hidden;
+ overflow: clip;
justify-items: start;
- transition: width var(--tool-motion-spring-ms, 480ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
}
[data-slot="tool-status-active"],
@@ -31,8 +30,8 @@
text-align: start;
transition-property: opacity, filter, transform;
transition-duration:
- var(--tool-motion-fade-ms, 240ms), calc(var(--tool-motion-fade-ms, 240ms) * 0.8),
- calc(var(--tool-motion-fade-ms, 240ms) * 0.8);
+ var(--tool-motion-fade-ms, 400ms), calc(var(--tool-motion-fade-ms, 400ms) * 0.8),
+ calc(var(--tool-motion-fade-ms, 400ms) * 0.8);
transition-timing-function: ease-out, ease-out, ease-out;
}
diff --git a/packages/ui/src/components/tool-status-title.tsx b/packages/ui/src/components/tool-status-title.tsx
index 68440b6c6..adb8de0bd 100644
--- a/packages/ui/src/components/tool-status-title.tsx
+++ b/packages/ui/src/components/tool-status-title.tsx
@@ -1,17 +1,8 @@
import { Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js"
+import { animate, type AnimationPlaybackControls, GROW_SPRING } from "./motion"
import { TextShimmer } from "./text-shimmer"
-
-function common(active: string, done: string) {
- const a = Array.from(active)
- const b = Array.from(done)
- let i = 0
- while (i < a.length && i < b.length && a[i] === b[i]) i++
- return {
- prefix: a.slice(0, i).join(""),
- active: a.slice(i).join(""),
- done: b.slice(i).join(""),
- }
-}
+import { commonPrefix } from "./text-utils"
+import { prefersReducedMotion } from "../hooks/use-reduced-motion"
function contentWidth(el: HTMLSpanElement | undefined) {
if (!el) return 0
@@ -27,25 +18,58 @@ export function ToolStatusTitle(props: {
class?: string
split?: boolean
}) {
- const split = createMemo(() => common(props.activeText, props.doneText))
+ const split = createMemo(() => commonPrefix(props.activeText, props.doneText))
const suffix = createMemo(
- () => (props.split ?? true) && split().prefix.length >= 2 && split().active.length > 0 && split().done.length > 0,
+ () => (props.split ?? true) && split().prefix.length >= 2 && split().aSuffix.length > 0 && split().bSuffix.length > 0,
)
const prefixLen = createMemo(() => Array.from(split().prefix).length)
- const activeTail = createMemo(() => (suffix() ? split().active : props.activeText))
- const doneTail = createMemo(() => (suffix() ? split().done : props.doneText))
+ const activeTail = createMemo(() => (suffix() ? split().aSuffix : props.activeText))
+ const doneTail = createMemo(() => (suffix() ? split().bSuffix : props.doneText))
- const [width, setWidth] = createSignal("auto")
const [ready, setReady] = createSignal(false)
let activeRef: HTMLSpanElement | undefined
let doneRef: HTMLSpanElement | undefined
+ let swapRef: HTMLSpanElement | undefined
+ let tailRef: HTMLSpanElement | undefined
let frame: number | undefined
let readyFrame: number | undefined
+ let widthAnim: AnimationPlaybackControls | undefined
+
+ const node = () => (suffix() ? tailRef : swapRef)
+
+ const reduce = prefersReducedMotion
+
+ const setNodeWidth = (width: string) => {
+ if (swapRef) swapRef.style.width = width
+ if (tailRef) tailRef.style.width = width
+ }
const measure = () => {
const target = props.active ? activeRef : doneRef
- const px = contentWidth(target)
- if (px > 0) setWidth(`${px}px`)
+ const next = contentWidth(target)
+ if (next <= 0) return
+
+ const ref = node()
+ if (!ref || !ready() || reduce()) {
+ widthAnim?.stop()
+ setNodeWidth(`${next}px`)
+ return
+ }
+
+ const prev = Math.max(0, Math.ceil(ref.getBoundingClientRect().width))
+ if (Math.abs(next - prev) < 1) {
+ ref.style.width = `${next}px`
+ return
+ }
+
+ ref.style.width = `${prev}px`
+ widthAnim?.stop()
+ widthAnim = animate(ref, { width: `${next}px` }, GROW_SPRING)
+ widthAnim.finished.then(() => {
+ const el = node()
+ if (!el) return
+ el.style.width = `${next}px`
+ })
}
const schedule = () => {
@@ -90,6 +114,7 @@ export function ToolStatusTitle(props: {
onCleanup(() => {
if (frame !== undefined) cancelAnimationFrame(frame)
if (readyFrame !== undefined) cancelAnimationFrame(readyFrame)
+ widthAnim?.stop()
})
return (
@@ -104,7 +129,7 @@ export function ToolStatusTitle(props: {
<Show
when={suffix()}
fallback={
- <span data-slot="tool-status-swap" style={{ width: width() }}>
+ <span data-slot="tool-status-swap" ref={swapRef}>
<span data-slot="tool-status-active" ref={activeRef}>
<TextShimmer text={activeTail()} active={props.active} offset={0} />
</span>
@@ -118,7 +143,7 @@ export function ToolStatusTitle(props: {
<span data-slot="tool-status-prefix">
<TextShimmer text={split().prefix} active={props.active} offset={0} />
</span>
- <span data-slot="tool-status-tail" style={{ width: width() }}>
+ <span data-slot="tool-status-tail" ref={tailRef}>
<span data-slot="tool-status-active" ref={activeRef}>
<TextShimmer text={activeTail()} active={props.active} offset={prefixLen()} />
</span>
diff --git a/packages/ui/src/components/tool-utils.ts b/packages/ui/src/components/tool-utils.ts
new file mode 100644
index 000000000..171649e3d
--- /dev/null
+++ b/packages/ui/src/components/tool-utils.ts
@@ -0,0 +1,325 @@
+import { createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js"
+import {
+ animate,
+ type AnimationPlaybackControls,
+ clearFadeStyles,
+ clearMaskStyles,
+ COLLAPSIBLE_SPRING,
+ GROW_SPRING,
+ WIPE_MASK,
+} from "./motion"
+import { prefersReducedMotion } from "../hooks/use-reduced-motion"
+import type { ToolPart } from "@opencode-ai/sdk/v2"
+
+export const TEXT_RENDER_THROTTLE_MS = 100
+
+export function createThrottledValue(getValue: () => string) {
+ const [value, setValue] = createSignal(getValue())
+ let timeout: ReturnType<typeof setTimeout> | undefined
+ let last = 0
+
+ createEffect(() => {
+ const next = getValue()
+ const now = Date.now()
+
+ const remaining = TEXT_RENDER_THROTTLE_MS - (now - last)
+ if (remaining <= 0) {
+ if (timeout) {
+ clearTimeout(timeout)
+ timeout = undefined
+ }
+ last = now
+ setValue(next)
+ return
+ }
+ if (timeout) clearTimeout(timeout)
+ timeout = setTimeout(() => {
+ last = Date.now()
+ setValue(next)
+ timeout = undefined
+ }, remaining)
+ })
+
+ onCleanup(() => {
+ if (timeout) clearTimeout(timeout)
+ })
+
+ return value
+}
+
+export function busy(status: string | undefined) {
+ return status === "pending" || status === "running"
+}
+
+export function hold(state: () => boolean, wait = 2000) {
+ const [live, setLive] = createSignal(state())
+ let timer: ReturnType<typeof setTimeout> | undefined
+
+ createEffect(() => {
+ if (state()) {
+ if (timer) clearTimeout(timer)
+ timer = undefined
+ setLive(true)
+ return
+ }
+
+ if (timer) clearTimeout(timer)
+ timer = setTimeout(() => {
+ timer = undefined
+ setLive(false)
+ }, wait)
+ })
+
+ onCleanup(() => {
+ if (timer) clearTimeout(timer)
+ })
+
+ return live
+}
+
+export function updateScrollMask(el: HTMLElement, fade = 12) {
+ const { scrollTop, scrollHeight, clientHeight } = el
+ const overflow = scrollHeight - clientHeight
+ if (overflow <= 1) {
+ el.style.maskImage = ""
+ el.style.webkitMaskImage = ""
+ return
+ }
+ const top = scrollTop > 1
+ const bottom = scrollTop < overflow - 1
+ const mask =
+ top && bottom
+ ? `linear-gradient(to bottom, transparent 0, black ${fade}px, black calc(100% - ${fade}px), transparent 100%)`
+ : top
+ ? `linear-gradient(to bottom, transparent 0, black ${fade}px)`
+ : bottom
+ ? `linear-gradient(to bottom, black calc(100% - ${fade}px), transparent 100%)`
+ : ""
+ el.style.maskImage = mask
+ el.style.webkitMaskImage = mask
+}
+
+export function useCollapsible(options: {
+ content: () => HTMLElement | undefined
+ body: () => HTMLElement | undefined
+ open: () => boolean
+ measure?: () => number
+ onOpen?: () => void
+}) {
+ let heightAnim: AnimationPlaybackControls | undefined
+ let fadeAnim: AnimationPlaybackControls | undefined
+ let gen = 0
+
+ createEffect(
+ on(
+ options.open,
+ (isOpen) => {
+ const content = options.content()
+ const body = options.body()
+ if (!content || !body) return
+ heightAnim?.stop()
+ fadeAnim?.stop()
+ const id = ++gen
+ if (isOpen) {
+ content.style.display = ""
+ content.style.height = "0px"
+ body.style.opacity = "0"
+ body.style.filter = "blur(2px)"
+ fadeAnim = animate(body, { opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"] }, COLLAPSIBLE_SPRING)
+ queueMicrotask(() => {
+ if (gen !== id) return
+ const c = options.content()
+ if (!c) return
+ const h = options.measure?.() ?? Math.ceil(body.getBoundingClientRect().height)
+ heightAnim = animate(c, { height: ["0px", `${h}px`] }, COLLAPSIBLE_SPRING)
+ heightAnim.finished.then(
+ () => {
+ if (gen !== id) return
+ c.style.height = "auto"
+ options.onOpen?.()
+ },
+ () => {},
+ )
+ })
+ return
+ }
+
+ const h = content.getBoundingClientRect().height
+ heightAnim = animate(content, { height: [`${h}px`, "0px"] }, COLLAPSIBLE_SPRING)
+ fadeAnim = animate(body, { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"] }, COLLAPSIBLE_SPRING)
+ heightAnim.finished.then(
+ () => {
+ if (gen !== id) return
+ content.style.display = "none"
+ },
+ () => {},
+ )
+ },
+ { defer: true },
+ ),
+ )
+
+ onCleanup(() => {
+ ++gen
+ heightAnim?.stop()
+ fadeAnim?.stop()
+ })
+}
+
+export function useContextToolPending(parts: () => ToolPart[], working?: () => boolean) {
+ const anyRunning = createMemo(() => parts().some((part) => busy(part.state.status)))
+ const [settled, setSettled] = createSignal(false)
+ createEffect(() => {
+ if (!anyRunning() && !working?.()) setSettled(true)
+ })
+ return createMemo(() => !settled() && (!!working?.() || anyRunning()))
+}
+
+export function useRowWipe(opts: {
+ id: () => string
+ text: () => string | undefined
+ ref: () => HTMLElement | undefined
+ seen: Set<string>
+}) {
+ const reduce = prefersReducedMotion
+
+ createEffect(() => {
+ const id = opts.id()
+ const txt = opts.text()
+ const el = opts.ref()
+ if (!el) return
+ if (!txt) {
+ clearFadeStyles(el)
+ clearMaskStyles(el)
+ return
+ }
+ if (reduce() || typeof window === "undefined") {
+ clearFadeStyles(el)
+ clearMaskStyles(el)
+ return
+ }
+ if (opts.seen.has(id)) {
+ clearFadeStyles(el)
+ clearMaskStyles(el)
+ return
+ }
+ opts.seen.add(id)
+
+ el.style.maskImage = WIPE_MASK
+ el.style.webkitMaskImage = WIPE_MASK
+ el.style.maskSize = "240% 100%"
+ el.style.webkitMaskSize = "240% 100%"
+ el.style.maskRepeat = "no-repeat"
+ el.style.webkitMaskRepeat = "no-repeat"
+ el.style.maskPosition = "100% 0%"
+ el.style.webkitMaskPosition = "100% 0%"
+ el.style.opacity = "0"
+ el.style.filter = "blur(2px)"
+ el.style.transform = "translateX(-0.06em)"
+
+ let done = false
+ const clear = () => {
+ if (done) return
+ done = true
+ clearFadeStyles(el)
+ clearMaskStyles(el)
+ }
+ if (typeof requestAnimationFrame !== "function") {
+ clear()
+ return
+ }
+ let anim: AnimationPlaybackControls | undefined
+ let frame: number | undefined = requestAnimationFrame(() => {
+ frame = undefined
+ const node = opts.ref()
+ if (!node) return
+ anim = animate(
+ node,
+ {
+ opacity: [0, 1],
+ filter: ["blur(2px)", "blur(0px)"],
+ transform: ["translateX(-0.06em)", "translateX(0)"],
+ maskPosition: "0% 0%",
+ },
+ GROW_SPRING,
+ )
+
+ anim.finished.catch(() => {}).finally(clear)
+ })
+
+ onCleanup(() => {
+ if (frame !== undefined) {
+ cancelAnimationFrame(frame)
+ clear()
+ }
+ })
+ })
+}
+
+export function useToolFade(
+ ref: () => HTMLElement | undefined,
+ options?: { delay?: number; wipe?: boolean; animate?: boolean },
+) {
+ let anim: AnimationPlaybackControls | undefined
+ let frame: number | undefined
+ const delay = options?.delay ?? 0
+ const wipe = options?.wipe ?? false
+ const active = options?.animate !== false
+
+ onMount(() => {
+ if (!active) return
+
+ const el = ref()
+ if (!el || typeof window === "undefined") return
+ if (prefersReducedMotion()) return
+
+ const mask =
+ wipe &&
+ typeof CSS !== "undefined" &&
+ (CSS.supports("mask-image", "linear-gradient(to right, black, transparent)") ||
+ CSS.supports("-webkit-mask-image", "linear-gradient(to right, black, transparent)"))
+
+ el.style.opacity = "0"
+ el.style.filter = wipe ? "blur(3px)" : "blur(2px)"
+ el.style.transform = wipe ? "translateX(-0.06em)" : "translateY(0.04em)"
+
+ if (mask) {
+ el.style.maskImage = WIPE_MASK
+ el.style.webkitMaskImage = WIPE_MASK
+ el.style.maskSize = "240% 100%"
+ el.style.webkitMaskSize = "240% 100%"
+ el.style.maskRepeat = "no-repeat"
+ el.style.webkitMaskRepeat = "no-repeat"
+ el.style.maskPosition = "100% 0%"
+ el.style.webkitMaskPosition = "100% 0%"
+ }
+
+ frame = requestAnimationFrame(() => {
+ frame = undefined
+ const node = ref()
+ if (!node) return
+
+ anim = wipe
+ ? mask
+ ? animate(
+ node,
+ { opacity: 1, filter: "blur(0px)", transform: "translateX(0)", maskPosition: "0% 0%" },
+ { ...GROW_SPRING, delay },
+ )
+ : animate(node, { opacity: 1, filter: "blur(0px)", transform: "translateX(0)" }, { ...GROW_SPRING, delay })
+ : animate(node, { opacity: 1, filter: "blur(0px)", transform: "translateY(0)" }, { ...GROW_SPRING, delay })
+
+ anim?.finished.then(() => {
+ const value = ref()
+ if (!value) return
+ clearFadeStyles(value)
+ if (mask) clearMaskStyles(value)
+ })
+ })
+ })
+
+ onCleanup(() => {
+ if (frame !== undefined) cancelAnimationFrame(frame)
+ anim?.stop()
+ })
+}
diff --git a/packages/ui/src/hooks/create-auto-scroll.tsx b/packages/ui/src/hooks/create-auto-scroll.tsx
index 3dc520c62..d36102590 100644
--- a/packages/ui/src/hooks/create-auto-scroll.tsx
+++ b/packages/ui/src/hooks/create-auto-scroll.tsx
@@ -1,6 +1,8 @@
import { createEffect, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
+import { animate, type AnimationPlaybackControls } from "motion"
+import { FAST_SPRING } from "../components/motion"
export interface AutoScrollOptions {
working: () => boolean
@@ -9,13 +11,28 @@ export interface AutoScrollOptions {
bottomThreshold?: number
}
+const SETTLE_MS = 500
+const AUTO_SCROLL_GRACE_MS = 120
+const AUTO_SCROLL_EPSILON = 0.5
+const MANUAL_ANCHOR_MS = 3000
+const MANUAL_ANCHOR_QUIET_FRAMES = 24
+
export function createAutoScroll(options: AutoScrollOptions) {
let scroll: HTMLElement | undefined
let settling = false
let settleTimer: ReturnType<typeof setTimeout> | undefined
- let autoTimer: ReturnType<typeof setTimeout> | undefined
let cleanup: (() => void) | undefined
- let auto: { top: number; time: number } | undefined
+ let programmaticUntil = 0
+ let scrollAnim: AnimationPlaybackControls | undefined
+ let hold:
+ | {
+ el: HTMLElement
+ top: number
+ until: number
+ quiet: number
+ frame: number | undefined
+ }
+ | undefined
const threshold = () => options.bottomThreshold ?? 10
@@ -27,77 +44,160 @@ export function createAutoScroll(options: AutoScrollOptions) {
const active = () => options.working() || settling
const distanceFromBottom = (el: HTMLElement) => {
- return el.scrollHeight - el.clientHeight - el.scrollTop
+ // With column-reverse, scrollTop=0 is at the bottom, negative = scrolled up
+ return Math.abs(el.scrollTop)
}
const canScroll = (el: HTMLElement) => {
return el.scrollHeight - el.clientHeight > 1
}
- // Browsers can dispatch scroll events asynchronously. If new content arrives
- // between us calling `scrollTo()` and the subsequent `scroll` event firing,
- // the handler can see a non-zero `distanceFromBottom` and incorrectly assume
- // the user scrolled.
- const markAuto = (el: HTMLElement) => {
- auto = {
- top: Math.max(0, el.scrollHeight - el.clientHeight),
- time: Date.now(),
- }
+ const markProgrammatic = () => {
+ programmaticUntil = Date.now() + AUTO_SCROLL_GRACE_MS
+ }
- if (autoTimer) clearTimeout(autoTimer)
- autoTimer = setTimeout(() => {
- auto = undefined
- autoTimer = undefined
- }, 1500)
+ const clearHold = () => {
+ const next = hold
+ if (!next) return
+ if (next.frame !== undefined) cancelAnimationFrame(next.frame)
+ hold = undefined
}
- const isAuto = (el: HTMLElement) => {
- const a = auto
- if (!a) return false
+ const tickHold = () => {
+ const next = hold
+ const el = scroll
+ if (!next || !el) return false
+ if (Date.now() > next.until) {
+ clearHold()
+ return false
+ }
+ if (!next.el.isConnected) {
+ clearHold()
+ return false
+ }
- if (Date.now() - a.time > 1500) {
- auto = undefined
+ const current = next.el.getBoundingClientRect().top
+ if (!Number.isFinite(current)) {
+ clearHold()
return false
}
- return Math.abs(el.scrollTop - a.top) < 2
+ const delta = current - next.top
+ if (Math.abs(delta) <= AUTO_SCROLL_EPSILON) {
+ next.quiet += 1
+ if (next.quiet > MANUAL_ANCHOR_QUIET_FRAMES) {
+ clearHold()
+ return false
+ }
+ return true
+ }
+
+ next.quiet = 0
+ if (!store.userScrolled) {
+ setStore("userScrolled", true)
+ options.onUserInteracted?.()
+ }
+ el.scrollTop += delta
+ markProgrammatic()
+ return true
+ }
+
+ const scheduleHold = () => {
+ const next = hold
+ if (!next) return
+ if (next.frame !== undefined) return
+
+ next.frame = requestAnimationFrame(() => {
+ const value = hold
+ if (!value) return
+ value.frame = undefined
+ if (!tickHold()) return
+ scheduleHold()
+ })
}
- const scrollToBottomNow = (behavior: ScrollBehavior) => {
+ const preserve = (target: HTMLElement) => {
const el = scroll
if (!el) return
- markAuto(el)
- if (behavior === "smooth") {
- el.scrollTo({ top: el.scrollHeight, behavior })
- return
+
+ if (!store.userScrolled) {
+ setStore("userScrolled", true)
+ options.onUserInteracted?.()
}
- // `scrollTop` assignment bypasses any CSS `scroll-behavior: smooth`.
- el.scrollTop = el.scrollHeight
+ const top = target.getBoundingClientRect().top
+ if (!Number.isFinite(top)) return
+
+ clearHold()
+ hold = {
+ el: target,
+ top,
+ until: Date.now() + MANUAL_ANCHOR_MS,
+ quiet: 0,
+ frame: undefined,
+ }
+ scheduleHold()
}
const scrollToBottom = (force: boolean) => {
if (!force && !active()) return
+ clearHold()
+
if (force && store.userScrolled) setStore("userScrolled", false)
const el = scroll
if (!el) return
+ if (scrollAnim) cancelSmooth()
if (!force && store.userScrolled) return
- const distance = distanceFromBottom(el)
- if (distance < 2) {
- markAuto(el)
+ // With column-reverse, scrollTop=0 is at the bottom
+ if (Math.abs(el.scrollTop) <= AUTO_SCROLL_EPSILON) {
+ markProgrammatic()
return
}
- // For auto-following content we prefer immediate updates to avoid
- // visible "catch up" animations while content is still settling.
- scrollToBottomNow("auto")
+ el.scrollTop = 0
+ markProgrammatic()
+ }
+
+ const cancelSmooth = () => {
+ if (scrollAnim) {
+ scrollAnim.stop()
+ scrollAnim = undefined
+ }
}
- const stop = () => {
+ const smoothScrollToBottom = () => {
+ const el = scroll
+ if (!el) return
+
+ cancelSmooth()
+ if (store.userScrolled) setStore("userScrolled", false)
+
+ // With column-reverse, scrollTop=0 is at the bottom
+ if (Math.abs(el.scrollTop) <= AUTO_SCROLL_EPSILON) {
+ markProgrammatic()
+ return
+ }
+
+ scrollAnim = animate(el.scrollTop, 0, {
+ ...FAST_SPRING,
+ onUpdate: (v) => {
+ markProgrammatic()
+ el.scrollTop = v
+ },
+ onComplete: () => {
+ scrollAnim = undefined
+ markProgrammatic()
+ },
+ })
+ }
+
+ const stop = (input?: { hold?: boolean }) => {
+ if (input?.hold !== false) clearHold()
+
const el = scroll
if (!el) return
if (!canScroll(el)) {
@@ -106,15 +206,25 @@ export function createAutoScroll(options: AutoScrollOptions) {
}
if (store.userScrolled) return
+ markProgrammatic()
setStore("userScrolled", true)
options.onUserInteracted?.()
}
const handleWheel = (e: WheelEvent) => {
+ if (e.deltaY !== 0) clearHold()
+
+ if (e.deltaY > 0) {
+ const el = scroll
+ if (!el) return
+ if (distanceFromBottom(el) >= threshold()) return
+ if (store.userScrolled) setStore("userScrolled", false)
+ markProgrammatic()
+ return
+ }
+
if (e.deltaY >= 0) return
- // If the user is scrolling within a nested scrollable region (tool output,
- // code block, etc), don't treat it as leaving the "follow bottom" mode.
- // Those regions opt in via `data-scrollable`.
+ cancelSmooth()
const el = scroll
const target = e.target instanceof Element ? e.target : undefined
const nested = target?.closest("[data-scrollable]")
@@ -126,23 +236,27 @@ export function createAutoScroll(options: AutoScrollOptions) {
const el = scroll
if (!el) return
+ if (hold) {
+ if (Date.now() < programmaticUntil) return
+ clearHold()
+ }
+
if (!canScroll(el)) {
if (store.userScrolled) setStore("userScrolled", false)
+ markProgrammatic()
return
}
if (distanceFromBottom(el) < threshold()) {
+ if (Date.now() < programmaticUntil) return
if (store.userScrolled) setStore("userScrolled", false)
+ markProgrammatic()
return
}
- // Ignore scroll events triggered by our own scrollToBottom calls.
- if (!store.userScrolled && isAuto(el)) {
- scrollToBottom(false)
- return
- }
+ if (!store.userScrolled && Date.now() < programmaticUntil) return
- stop()
+ stop({ hold: false })
}
const handleInteraction = () => {
@@ -154,6 +268,11 @@ export function createAutoScroll(options: AutoScrollOptions) {
}
const updateOverflowAnchor = (el: HTMLElement) => {
+ if (hold) {
+ el.style.overflowAnchor = "none"
+ return
+ }
+
const mode = options.overflowAnchor ?? "dynamic"
if (mode === "none") {
@@ -173,15 +292,17 @@ export function createAutoScroll(options: AutoScrollOptions) {
() => store.contentRef,
() => {
const el = scroll
+ if (hold) {
+ scheduleHold()
+ return
+ }
if (el && !canScroll(el)) {
if (store.userScrolled) setStore("userScrolled", false)
+ markProgrammatic()
return
}
if (!active()) return
if (store.userScrolled) return
- // ResizeObserver fires after layout, before paint.
- // Keep the bottom locked in the same frame to avoid visible
- // "jump up then catch up" artifacts while streaming content.
scrollToBottom(false)
},
)
@@ -200,13 +321,11 @@ export function createAutoScroll(options: AutoScrollOptions) {
settling = true
settleTimer = setTimeout(() => {
settling = false
- }, 300)
+ }, SETTLE_MS)
}),
)
createEffect(() => {
- // Track `userScrolled` even before `scrollRef` is attached, so we can
- // update overflow anchoring once the element exists.
store.userScrolled
const el = scroll
if (!el) return
@@ -215,7 +334,8 @@ export function createAutoScroll(options: AutoScrollOptions) {
onCleanup(() => {
if (settleTimer) clearTimeout(settleTimer)
- if (autoTimer) clearTimeout(autoTimer)
+ clearHold()
+ cancelSmooth()
if (cleanup) cleanup()
})
@@ -228,8 +348,12 @@ export function createAutoScroll(options: AutoScrollOptions) {
scroll = el
- if (!el) return
+ if (!el) {
+ clearHold()
+ return
+ }
+ markProgrammatic()
updateOverflowAnchor(el)
el.addEventListener("wheel", handleWheel, { passive: true })
@@ -240,13 +364,18 @@ export function createAutoScroll(options: AutoScrollOptions) {
contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el),
handleScroll,
handleInteraction,
+ preserve,
pause: stop,
- resume: () => {
+ forceScrollToBottom: () => scrollToBottom(true),
+ smoothScrollToBottom,
+ snapToBottom: () => {
+ const el = scroll
+ if (!el) return
if (store.userScrolled) setStore("userScrolled", false)
- scrollToBottom(true)
+ // With column-reverse, scrollTop=0 is at the bottom
+ el.scrollTop = 0
+ markProgrammatic()
},
- scrollToBottom: () => scrollToBottom(false),
- forceScrollToBottom: () => scrollToBottom(true),
userScrolled: () => store.userScrolled,
}
}
diff --git a/packages/ui/src/hooks/index.ts b/packages/ui/src/hooks/index.ts
index 1c90a2e49..4a218024d 100644
--- a/packages/ui/src/hooks/index.ts
+++ b/packages/ui/src/hooks/index.ts
@@ -1,2 +1,5 @@
export * from "./use-filtered-list"
export * from "./create-auto-scroll"
+export * from "./use-element-height"
+export * from "./use-reduced-motion"
+export * from "./use-page-visible"
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 000000000..a9f06ec8b
--- /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> | (() => HTMLElement | undefined),
+ initial = 0,
+): Accessor<number> {
+ 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
+}
diff --git a/packages/ui/src/hooks/use-page-visible.ts b/packages/ui/src/hooks/use-page-visible.ts
new file mode 100644
index 000000000..88788ef4a
--- /dev/null
+++ b/packages/ui/src/hooks/use-page-visible.ts
@@ -0,0 +1,11 @@
+import { createSignal } from "solid-js"
+
+export 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
+})()
diff --git a/packages/ui/src/hooks/use-reduced-motion.ts b/packages/ui/src/hooks/use-reduced-motion.ts
new file mode 100644
index 000000000..7fa815bbd
--- /dev/null
+++ b/packages/ui/src/hooks/use-reduced-motion.ts
@@ -0,0 +1,9 @@
+import { createSignal } from "solid-js"
+
+export 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
+})()
diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css
index cec42f5a0..213a37c51 100644
--- a/packages/ui/src/styles/index.css
+++ b/packages/ui/src/styles/index.css
@@ -40,6 +40,7 @@
@import "../components/progress-circle.css" layer(components);
@import "../components/radio-group.css" layer(components);
@import "../components/resize-handle.css" layer(components);
+@import "../components/rolling-results.css" layer(components);
@import "../components/select.css" layer(components);
@import "../components/spinner.css" layer(components);
@import "../components/switch.css" layer(components);
diff --git a/packages/util/src/array.ts b/packages/util/src/array.ts
index 1fb8ac69e..91b923dee 100644
--- a/packages/util/src/array.ts
+++ b/packages/util/src/array.ts
@@ -1,3 +1,10 @@
+export function same<T>(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])
+}
+
export function findLast<T>(
items: readonly T[],
predicate: (item: T, index: number, items: readonly T[]) => boolean,