From bbd0f3a25283b6f9567a04e79d7f6972950ab0a6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 7 Mar 2026 06:25:22 -0500 Subject: [PATCH] STUPID SEXY TIMELINE (#16420) --- packages/app/e2e/actions.ts | 7 +- packages/app/e2e/selectors.ts | 2 + packages/app/e2e/session/session.spec.ts | 12 +- packages/app/src/pages/session.tsx | 21 +- .../src/pages/session/message-timeline.tsx | 501 ++--- .../pages/session/session-timeline-header.tsx | 522 +++++ .../pages/session/use-session-hash-scroll.ts | 52 +- .../ui/src/components/animated-number.css | 11 +- .../ui/src/components/animated-number.tsx | 29 +- packages/ui/src/components/basic-tool.css | 59 +- packages/ui/src/components/basic-tool.tsx | 356 +++- packages/ui/src/components/collapsible.css | 55 +- .../src/components/context-tool-results.tsx | 199 ++ packages/ui/src/components/grow-box.tsx | 426 ++++ packages/ui/src/components/message-part.css | 535 ++++- packages/ui/src/components/message-part.tsx | 1888 ++++++++--------- packages/ui/src/components/motion-spring.tsx | 32 +- packages/ui/src/components/motion.tsx | 77 + .../ui/src/components/rolling-results.css | 92 + .../ui/src/components/rolling-results.tsx | 326 +++ packages/ui/src/components/scroll-view.css | 15 +- packages/ui/src/components/scroll-view.tsx | 66 +- packages/ui/src/components/session-turn.css | 127 +- packages/ui/src/components/session-turn.tsx | 556 +++-- .../src/components/shell-rolling-results.tsx | 310 +++ .../ui/src/components/shell-submessage.css | 12 +- packages/ui/src/components/text-reveal.css | 61 +- packages/ui/src/components/text-reveal.tsx | 107 +- packages/ui/src/components/text-shimmer.css | 17 +- packages/ui/src/components/text-shimmer.tsx | 15 + packages/ui/src/components/text-utils.ts | 17 + .../ui/src/components/tool-count-label.css | 6 +- .../ui/src/components/tool-count-label.tsx | 21 +- .../ui/src/components/tool-count-summary.css | 22 +- .../ui/src/components/tool-status-title.css | 7 +- .../ui/src/components/tool-status-title.tsx | 67 +- packages/ui/src/components/tool-utils.ts | 325 +++ packages/ui/src/hooks/create-auto-scroll.tsx | 257 ++- packages/ui/src/hooks/index.ts | 3 + packages/ui/src/hooks/use-element-height.ts | 25 + packages/ui/src/hooks/use-page-visible.ts | 11 + packages/ui/src/hooks/use-reduced-motion.ts | 9 + packages/ui/src/styles/index.css | 1 + packages/util/src/array.ts | 7 + 44 files changed, 5186 insertions(+), 2080 deletions(-) create mode 100644 packages/app/src/pages/session/session-timeline-header.tsx create mode 100644 packages/ui/src/components/context-tool-results.tsx create mode 100644 packages/ui/src/components/grow-box.tsx create mode 100644 packages/ui/src/components/motion.tsx create mode 100644 packages/ui/src/components/rolling-results.css create mode 100644 packages/ui/src/components/rolling-results.tsx create mode 100644 packages/ui/src/components/shell-rolling-results.tsx create mode 100644 packages/ui/src/components/text-utils.ts create mode 100644 packages/ui/src/components/tool-utils.ts create mode 100644 packages/ui/src/hooks/use-element-height.ts create mode 100644 packages/ui/src/hooks/use-page-visible.ts create mode 100644 packages/ui/src/hooks/use-reduced-motion.ts 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([sessionID]) - - const byParent = new Map() - for (const item of draft.session) { - const parentID = item.parentID - if (!parentID) continue - const existing = byParent.get(parentID) - if (existing) { - existing.push(item.id) - continue - } - byParent.set(parentID, [item.id]) - } - - const stack = [sessionID] - while (stack.length) { - const parentID = stack.pop() - if (!parentID) continue - - const children = byParent.get(parentID) - if (!children) continue - - for (const child of children) { - if (removed.has(child)) continue - removed.add(child) - stack.push(child) - } - } - - draft.session = draft.session.filter((s) => !removed.has(s.id)) - }), - ) - - navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) - return true - } - - const navigateParent = () => { - const id = parentID() - if (!id) return - navigate(`/${params.dir}/session/${id}`) - } - - function DialogDeleteSession(props: { sessionID: string }) { - const name = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new")) - const handleDelete = async () => { - await deleteSession(props.sessionID) - dialog.close() - } - - return ( - -
-
- - {language.t("session.delete.confirm", { name: name() })} - -
-
- - -
-
-
- ) - } + const rendered = createMemo(() => staging.messages().map((message) => message.id)) return ( + { @@ -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", }} > -
- -
-
-
- - - - - - {titleValue()} - - } - > - { - titleRef = el - }} - value={title.draft} - disabled={title.saving} - class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]" - style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }} - onInput={(event) => setTitle("draft", event.currentTarget.value)} - onKeyDown={(event) => { - event.stopPropagation() - if (event.key === "Enter") { - event.preventDefault() - void saveTitleEditor() - return - } - if (event.key === "Escape") { - event.preventDefault() - closeTitleEditor() - } - }} - onBlur={closeTitleEditor} - /> - - -
- - {(id) => ( -
- - setTitle("menuOpen", open)} - > - - - { - if (!title.pendingRename) return - event.preventDefault() - setTitle("pendingRename", false) - openTitleEditor() - }} - > - { - setTitle("pendingRename", true) - setTitle("menuOpen", false) - }} - > - {language.t("common.rename")} - - void archiveSession(id())}> - {language.t("common.archive")} - - - dialog.show(() => )} - > - {language.t("common.delete")} - - - - -
- )} -
-
-
-
- +
{(messageID) => { + // Capture at creation time: animate only messages added after the + // timeline finishes its initial backfill staging, 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([sessionID]) + const byParent = new Map() + + for (const item of draft.session) { + const parentID = item.parentID + if (!parentID) continue + + const existing = byParent.get(parentID) + if (existing) { + existing.push(item.id) + continue + } + byParent.set(parentID, [item.id]) + } + + const stack = [sessionID] + while (stack.length) { + const parentID = stack.pop() + if (!parentID) continue + + const children = byParent.get(parentID) + if (!children) continue + + for (const child of children) { + if (removed.has(child)) continue + removed.add(child) + stack.push(child) + } + } + + draft.session = draft.session.filter((item) => !removed.has(item.id)) + }), + ) + + navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) + return true + } + + const navigateParent = () => { + const id = props.parentID() + if (!id) return + navigate(`/${params.dir}/session/${id}`) + } + + function DialogDeleteSession(input: { sessionID: string }) { + const name = createMemo(() => sync.session.get(input.sessionID)?.title ?? language.t("command.session.new")) + + const handleDelete = async () => { + await deleteSession(input.sessionID) + dialog.close() + } + + return ( + +
+
+ + {language.t("session.delete.confirm", { name: name() })} + +
+
+ + +
+
+
+ ) + } + + return ( + +
{ + headerRef = el + el.style.opacity = "0" + }} + class="pointer-events-none absolute inset-x-0 top-0 z-30" + > +
+
+
+ +
+ +
+
+ + + + + {headerText.value} + + + {headerText.prev} + + + + } + > + { + titleRef = el + }} + value={title.draft} + disabled={title.saving} + class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]" + style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }} + onInput={(event) => setTitle("draft", event.currentTarget.value)} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === "Enter") { + event.preventDefault() + void saveTitleEditor() + return + } + if (event.key === "Escape") { + event.preventDefault() + closeTitleEditor() + } + }} + onBlur={closeTitleEditor} + /> + + +
+ + {(id) => ( +
+ + setTitle("menuOpen", open)} + > + + + { + if (!title.pendingRename) return + event.preventDefault() + setTitle("pendingRename", false) + openTitleEditor() + }} + > + { + setTitle("pendingRename", true) + setTitle("menuOpen", false) + }} + > + {language.t("common.rename")} + + void archiveSession(id())}> + {language.t("common.archive")} + + + dialog.show(() => )}> + {language.t("common.delete")} + + + + +
+ )} +
+
+
+
+
+ ) +} diff --git a/packages/app/src/pages/session/use-session-hash-scroll.ts b/packages/app/src/pages/session/use-session-hash-scroll.ts index 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([]) + let exitTimer: number | undefined + + createEffect( + on( + digits, + (current, prev) => { + if (prev && current.length < prev.length) { + setExitingDigits(prev.slice(current.length)) + clearTimeout(exitTimer) + exitTimer = window.setTimeout(() => setExitingDigits([]), DURATION) + } else { + clearTimeout(exitTimer) + setExitingDigits([]) + } + }, + { defer: true }, + ), + ) + + const displayDigits = createMemo(() => { + const exiting = exitingDigits() + return exiting.length ? [...digits(), ...exiting] : digits() + }) + return ( - {(digit) => } + {(digit) => } ) diff --git a/packages/ui/src/components/basic-tool.css b/packages/ui/src/components/basic-tool.css index 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 ( +
+
+
+ + + {(trigger) => ( +
+
+ + + + + + { + if (!props.onSubtitleClick) return + e.stopPropagation() + props.onSubtitleClick() + }} + > + {trigger().subtitle} + + + + + {(arg) => ( + + {arg} + + )} + + + +
+ {trigger().action} +
+ )} +
+ {props.trigger as JSX.Element} +
+
+
+ + + +
+ ) +} -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(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 ( -
-
-
- - - {(trigger) => ( -
-
- - - - - - { - if (props.onSubtitleClick) { - e.stopPropagation() - props.onSubtitleClick() - } - }} - > - {trigger().subtitle} - - - - - {(arg) => ( - - {arg} - - )} - - - -
- {trigger().action} -
- )} -
- {props.trigger as JSX.Element} -
-
-
- - - -
+
- +
- {props.children} +
+ {props.children} +
- + - {props.children} + +
{props.children}
+
@@ -222,6 +330,60 @@ function args(input: Record | undefined) { .slice(0, 3) } +export interface ToolCallRowProps { + variant: "row" + icon: string + trigger: TriggerTitle | JSX.Element + status?: string + animate?: boolean + onSubtitleClick?: () => void + open?: boolean + showArrow?: boolean + onOpenChange?: (value: boolean) => void +} +export interface ToolCallPanelProps extends Omit { + variant: "panel" +} +export type ToolCallProps = ToolCallRowProps | ToolCallPanelProps +function ToolCallRoot(props: ToolCallProps) { + const pending = () => props.status === "pending" || props.status === "running" + if (props.variant === "row") { + return ( + +
+ +
+
+ } + > + {(onOpenChange) => ( + + + + + + )} + + ) + } + + const [, rest] = splitProps(props, ["variant"]) + return +} +export const ToolCall = ToolCallRoot + export function GenericTool(props: { tool: string status?: string @@ -229,7 +391,8 @@ export function GenericTool(props: { input?: Record }) { return ( - ) } 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 ( + { + if (!props.pending) props.onOpenChange(v) + }} + trigger={ +
+ + + + + + + + +
+ } + /> + ) +} + +export function ContextToolExpandedList(props: { parts: ToolPart[]; expanded: boolean }) { + let contentRef: HTMLDivElement | undefined + let bodyRef: HTMLDivElement | undefined + let scrollRef: HTMLDivElement | undefined + const updateMask = () => { + if (scrollRef) updateScrollMask(scrollRef) + } + + useCollapsible({ + content: () => contentRef, + body: () => bodyRef, + open: () => props.expanded, + onOpen: updateMask, + }) + + return ( +
+
+
+ + {(part) => { + const label = createMemo(() => contextToolLabel(part)) + return ( +
+ {label().action} + {label().detail} +
+ ) + }} +
+
+
+
+ ) +} + +export function ContextToolRollingResults(props: { parts: ToolPart[]; pending: boolean }) { + const wiped = new Set() + 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 ( +
+ part.callID || part.id} + render={(part) => { + const label = createMemo(() => contextToolLabel(part)) + const k = part.callID || part.id + return ( +
+ {label().action} + {(() => { + const [detailRef, setDetailRef] = createSignal() + useRowWipe({ + id: () => k, + text: () => label().detail, + ref: detailRef, + seen: wiped, + }) + return ( + + {label().detail} + + ) + })()} +
+ ) + }} + /> +
+ ) +} diff --git a/packages/ui/src/components/grow-box.tsx b/packages/ui/src/components/grow-box.tsx 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 | undefined + let edgeOn = false + let mountFrame: number | undefined + let resizeFrame: number | undefined + let observer: ResizeObserver | undefined + let springTarget = -1 + const height = tunableSpringValue(0, { + type: "spring", + get visualDuration() { + return (mode === "toggle" ? toggleSpring() : spring()).visualDuration + }, + get bounce() { + return (mode === "toggle" ? toggleSpring() : spring()).bounce + }, + }) + + const gap = () => Math.max(0, props.gap ?? 0) + const grow = () => props.grow !== false + const watch = () => props.watch === true + const open = () => props.open !== false + const animateToggle = () => props.animateToggle !== false + const edge = () => props.edge === true + const edgeHeight = () => Math.max(0, props.edgeHeight ?? 20) + const edgeOpacity = () => Math.min(1, Math.max(0, props.edgeOpacity ?? 1)) + const edgeIdle = () => Math.max(0, props.edgeIdle ?? 320) + const edgeFade = () => Math.max(0.05, props.edgeFade ?? 0.24) + const edgeRise = () => Math.max(0.05, props.edgeRise ?? 0.2) + const animated = () => props.animate !== false && !reduce() + const edgeReady = () => animated() && open() && edge() && edgeHeight() > 0 + + const stopEdgeTimer = () => { + if (edgeTimer === undefined) return + clearTimeout(edgeTimer) + edgeTimer = undefined + } + + const hideEdge = (instant = false) => { + stopEdgeTimer() + if (!edgeRef) { + edgeOn = false + return + } + edgeAnim?.stop() + edgeAnim = undefined + if (instant || reduce()) { + edgeRef.style.opacity = "0" + edgeOn = false + return + } + if (!edgeOn) { + edgeRef.style.opacity = "0" + return + } + const current = animate(edgeRef, { opacity: 0 }, { type: "spring", visualDuration: edgeFade(), bounce: 0 }) + edgeAnim = current + current.finished + .catch(() => {}) + .finally(() => { + if (edgeAnim !== current) return + edgeAnim = undefined + if (!edgeRef) return + edgeRef.style.opacity = "0" + edgeOn = false + }) + } + + const showEdge = () => { + stopEdgeTimer() + if (!edgeRef) return + if (reduce()) { + edgeRef.style.opacity = `${edgeOpacity()}` + edgeOn = true + return + } + if (edgeOn && edgeAnim === undefined) { + edgeRef.style.opacity = `${edgeOpacity()}` + return + } + edgeAnim?.stop() + edgeAnim = undefined + if (!edgeOn) edgeRef.style.opacity = "0" + const current = animate( + edgeRef, + { opacity: edgeOpacity() }, + { type: "spring", visualDuration: edgeRise(), bounce: 0 }, + ) + edgeAnim = current + edgeOn = true + current.finished + .catch(() => {}) + .finally(() => { + if (edgeAnim !== current) return + edgeAnim = undefined + if (!edgeRef) return + edgeRef.style.opacity = `${edgeOpacity()}` + }) + } + + const queueEdgeHide = () => { + stopEdgeTimer() + if (!edgeOn) return + if (edgeIdle() <= 0) { + hideEdge() + return + } + edgeTimer = setTimeout(() => { + edgeTimer = undefined + hideEdge() + }, edgeIdle()) + } + + const hideBody = () => { + if (!body) return + body.style.opacity = "0" + body.style.filter = "blur(2px)" + } + + const clearBody = () => { + if (!body) return + body.style.opacity = "" + body.style.filter = "" + } + + const fadeBodyIn = (nextMode: "mount" | "toggle" = "mount") => { + if (props.fade === false || !body) return + if (reduce()) { + clearBody() + return + } + hideBody() + fadeAnim?.stop() + fadeAnim = animate(body, { opacity: 1, filter: "blur(0px)" }, nextMode === "toggle" ? toggleSpring() : spring()) + fadeAnim.finished.then(() => { + if (!body || !open()) return + clearBody() + }) + } + + const setInstant = (visible: boolean) => { + const next = visible ? targetHeight() : 0 + springTarget = next + height.jump(next) + root!.style.height = visible ? "" : "0px" + root!.style.overflow = visible ? "" : "clip" + hideEdge(true) + if (visible || props.fade === false) clearBody() + else hideBody() + } + + const currentHeight = () => { + if (!root) return 0 + const v = root.style.height + if (v && v !== "auto") { + const n = Number.parseFloat(v) + if (!Number.isNaN(n)) return n + } + return Math.max(0, root.getBoundingClientRect().height) + } + + const targetHeight = () => Math.max(0, Math.ceil(body?.getBoundingClientRect().height ?? 0)) + + const setHeight = (nextMode: "mount" | "toggle" = "mount") => { + if (!root || !open()) return + const next = targetHeight() + if (reduce()) { + springTarget = next + height.jump(next) + if (props.autoHeight === false || watch()) { + root.style.height = `${next}px` + root.style.overflow = next > 0 ? "visible" : "clip" + return + } + root.style.height = "auto" + root.style.overflow = next > 0 ? "visible" : "clip" + return + } + if (next === springTarget) return + const prev = currentHeight() + if (Math.abs(next - prev) < 1) { + springTarget = next + if (props.autoHeight === false || watch()) { + root.style.height = `${next}px` + root.style.overflow = next > 0 ? "visible" : "clip" + } + return + } + root.style.overflow = "clip" + springTarget = next + mode = nextMode + height.set(next) + } + + onMount(() => { + if (!root || !body) return + + const offChange = height.on("change", (next) => { + if (!root) return + root.style.height = `${Math.max(0, next)}px` + }) + const offStart = height.on("animationStart", () => { + if (!root) return + root.style.overflow = "clip" + root.style.willChange = "height" + root.style.contain = "layout style" + if (edgeReady()) showEdge() + }) + const offComplete = height.on("animationComplete", () => { + if (!root) return + root.style.willChange = "" + root.style.contain = "" + if (!open()) { + springTarget = 0 + root.style.height = "0px" + root.style.overflow = "clip" + return + } + const next = targetHeight() + springTarget = next + if (props.autoHeight === false || watch()) { + root.style.height = `${next}px` + root.style.overflow = next > 0 ? "visible" : "clip" + if (edgeReady()) queueEdgeHide() + return + } + root.style.height = "auto" + root.style.overflow = "visible" + if (edgeReady()) queueEdgeHide() + }) + + onCleanup(() => { + offComplete() + offStart() + offChange() + }) + + if (!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 ( +
+
0 ? `${gap()}px` : undefined }}> + {props.children} +
+
+
+ ) +} diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 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-meta-line"], + .message-part-meta-line { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: var(--font-weight-regular); + + [data-component="diff-changes"] { + flex-shrink: 0; + gap: 6px; + } + } + + .message-part-meta-line.soft { + [data-slot="message-part-title-filename"] { + color: var(--text-base); + } } [data-slot="message-part-title-filename"] { /* No text-transform - preserve original filename casing */ - font-weight: var(--font-weight-regular); + color: var(--text-strong); + flex-shrink: 0; } - [data-slot="message-part-path"] { - display: flex; - flex-grow: 1; - min-width: 0; - font-weight: var(--font-weight-regular); - } - - [data-slot="message-part-directory"] { + [data-slot="message-part-directory-inline"] { color: var(--text-weak); + min-width: 0; + max-width: min(48vw, 36ch); text-overflow: ellipsis; overflow: hidden; white-space: nowrap; direction: rtl; text-align: left; } - - [data-slot="message-part-filename"] { - color: var(--text-strong); - flex-shrink: 0; - } - - [data-slot="message-part-actions"] { - display: flex; - gap: 16px; - align-items: center; - justify-content: flex-end; - } } [data-component="edit-content"] { @@ -617,6 +612,17 @@ } } +[data-slot="webfetch-meta"] { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 8px; + + [data-component="tool-action"] { + flex-shrink: 0; + } +} + [data-component="todos"] { padding: 10px 0 24px 0; display: flex; @@ -639,7 +645,6 @@ } [data-component="context-tool-group-trigger"] { - width: 100%; min-height: 24px; display: flex; align-items: center; @@ -647,28 +652,352 @@ gap: 0px; cursor: pointer; + &[data-pending] { + cursor: default; + } + [data-slot="context-tool-group-title"] { flex-shrink: 1; min-width: 0; } +} - [data-slot="collapsible-arrow"] { - color: var(--icon-weaker); +/* Prevent the trigger content from stretching full-width so the arrow sits after the text */ +[data-slot="basic-tool-tool-trigger-content"]:has([data-component="context-tool-group-trigger"]) { + width: auto; + flex: 0 1 auto; + + [data-slot="basic-tool-tool-info"] { + flex: 0 1 auto; } } -[data-component="context-tool-group-list"] { - padding: 6px 0 4px 0; +[data-component="context-tool-step"] { + width: 100%; + min-width: 0; + padding-left: 12px; +} + +[data-component="context-tool-expanded-list"] { display: flex; flex-direction: column; - gap: 2px; + padding: 4px 0 4px 12px; + max-height: 200px; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: none; + -ms-overflow-style: none; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; - [data-slot="context-tool-group-item"] { - min-width: 0; - padding: 6px 0; + &::-webkit-scrollbar { + display: none; } } +[data-component="context-tool-expanded-row"] { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + height: 22px; + flex-shrink: 0; + white-space: nowrap; + overflow: hidden; + + [data-slot="context-tool-expanded-action"] { + flex-shrink: 0; + font-size: var(--font-size-base); + font-weight: 500; + color: var(--text-base); + } + + [data-slot="context-tool-expanded-detail"] { + flex-shrink: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font-size: var(--font-size-base); + color: var(--text-base); + opacity: 0.75; + } +} + +[data-component="context-tool-rolling-row"] { + display: inline-flex; + align-items: center; + gap: 6px; + width: 100%; + min-width: 0; + white-space: nowrap; + overflow: hidden; + padding-left: 12px; + + [data-slot="context-tool-rolling-action"] { + flex-shrink: 0; + font-size: var(--font-size-base); + font-weight: 500; + color: var(--text-base); + } + + [data-slot="context-tool-rolling-detail"] { + flex-shrink: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font-size: var(--font-size-base); + color: var(--text-weak); + } +} + +[data-component="shell-rolling-results"] { + width: 100%; + min-width: 0; + display: flex; + flex-direction: column; + + [data-slot="shell-rolling-header-clip"] { + &:hover [data-slot="shell-rolling-actions"] { + opacity: 1; + } + + &[data-clickable="true"] { + cursor: pointer; + } + } + + [data-slot="shell-rolling-header"] { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + max-width: 100%; + height: 37px; + box-sizing: border-box; + } + + [data-slot="shell-rolling-title"] { + flex-shrink: 0; + font-family: var(--font-family-sans); + font-size: 14px; + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + color: var(--text-strong); + } + + [data-slot="shell-rolling-subtitle"] { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-family-sans); + font-size: 14px; + font-weight: var(--font-weight-normal); + line-height: var(--line-height-large); + color: var(--text-weak); + } + + [data-slot="shell-rolling-actions"] { + flex-shrink: 0; + display: inline-flex; + align-items: center; + gap: 2px; + opacity: 0; + transition: opacity 0.15s ease; + } + + .shell-rolling-copy { + border: none !important; + outline: none !important; + box-shadow: none !important; + background: transparent !important; + + [data-slot="icon-svg"] { + color: var(--icon-weaker); + } + + &:hover:not(:disabled) { + background: color-mix(in srgb, var(--text-base) 8%, transparent) !important; + box-shadow: 0 0 0 1px color-mix(in srgb, var(--icon-weaker) 40%, transparent) !important; + border-radius: var(--radius-sm); + + [data-slot="icon-svg"] { + color: var(--icon-base); + } + } + } + + [data-slot="shell-rolling-arrow"] { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--icon-weaker); + transform: rotate(-90deg); + transition: transform 0.15s ease; + } + + [data-slot="shell-rolling-arrow"][data-open="true"] { + transform: rotate(0deg); + } +} + +[data-component="shell-rolling-output"] { + width: 100%; + min-width: 0; +} + +[data-slot="shell-rolling-preview"] { + width: 100%; + min-width: 0; +} + +[data-component="shell-expanded-output"] { + width: 100%; + max-width: 100%; + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: none; + -ms-overflow-style: none; + + &::-webkit-scrollbar { + display: none; + } +} + +[data-component="shell-expanded-shell"] { + position: relative; + width: 100%; + min-width: 0; + border: 1px solid var(--border-weak-base); + border-radius: 6px; + background: transparent; + overflow: hidden; +} + +[data-slot="shell-expanded-body"] { + position: relative; + width: 100%; + min-width: 0; +} + +[data-slot="shell-expanded-top"] { + position: relative; + width: 100%; + min-width: 0; + padding: 9px 44px 9px 16px; + box-sizing: border-box; +} + +[data-slot="shell-expanded-command"] { + display: flex; + align-items: flex-start; + gap: 8px; + width: 100%; + min-width: 0; + font-family: var(--font-family-mono); + font-feature-settings: var(--font-family-mono--font-feature-settings); + font-size: 13px; + line-height: 1.45; +} + +[data-slot="shell-expanded-prompt"] { + flex-shrink: 0; + color: var(--text-weaker); +} + +[data-slot="shell-expanded-input"] { + min-width: 0; + color: var(--text-strong); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +[data-slot="shell-expanded-actions"] { + position: absolute; + top: 50%; + right: 8px; + z-index: 1; + transform: translateY(-50%); +} + +.shell-expanded-copy { + border: none !important; + outline: none !important; + box-shadow: none !important; + background: transparent !important; + + [data-slot="icon-svg"] { + color: var(--icon-weaker); + } + + &:hover:not(:disabled) { + background: color-mix(in srgb, var(--text-base) 8%, transparent) !important; + box-shadow: 0 0 0 1px color-mix(in srgb, var(--icon-weaker) 40%, transparent) !important; + border-radius: var(--radius-sm); + + [data-slot="icon-svg"] { + color: var(--icon-base); + } + } +} + +[data-slot="shell-expanded-divider"] { + width: 100%; + height: 1px; + background: var(--border-weak-base); +} + +[data-slot="shell-expanded-pre"] { + margin: 0; + padding: 12px 16px; + white-space: pre-wrap; + overflow-wrap: anywhere; + + code { + font-family: var(--font-family-mono); + font-feature-settings: var(--font-family-mono--font-feature-settings); + font-size: 13px; + line-height: 1.45; + color: var(--text-base); + } +} + +[data-component="shell-rolling-command"], +[data-component="shell-rolling-row"] { + display: inline-flex; + align-items: center; + width: 100%; + min-width: 0; + overflow: hidden; + white-space: pre; + padding-left: 12px; +} + +[data-slot="shell-rolling-text"] { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font-family: var(--font-family-mono); + font-feature-settings: var(--font-family-mono--font-feature-settings); + font-size: var(--font-size-small); + line-height: var(--line-height-large); +} + +[data-component="shell-rolling-command"] [data-slot="shell-rolling-text"] { + color: var(--text-base); +} + +[data-component="shell-rolling-command"] [data-slot="shell-rolling-prompt"] { + color: var(--text-weaker); +} + +[data-component="shell-rolling-row"] [data-slot="shell-rolling-text"] { + color: var(--text-weak); +} + [data-component="diagnostics"] { display: flex; flex-direction: column; @@ -729,6 +1058,30 @@ width: 100%; } +[data-slot="assistant-part-grow"] { + width: 100%; + min-width: 0; + overflow: visible; +} + +[data-component="tool-part-wrapper"][data-tool="bash"] { + [data-component="tool-trigger"] { + width: auto; + max-width: 100%; + } + + [data-slot="basic-tool-tool-info-main"] { + align-items: center; + } + + [data-slot="basic-tool-tool-title"], + [data-slot="basic-tool-tool-subtitle"] { + display: inline-flex; + align-items: center; + line-height: var(--line-height-large); + } +} + [data-component="dock-prompt"][data-kind="permission"] { position: relative; display: flex; @@ -1187,8 +1540,7 @@ position: sticky; top: var(--sticky-accordion-top, 0px); z-index: 20; - height: 40px; - padding-bottom: 8px; + height: 37px; background-color: var(--background-stronger); } } @@ -1199,11 +1551,12 @@ } [data-slot="apply-patch-trigger-content"] { - display: flex; + display: inline-flex; align-items: center; - justify-content: space-between; - width: 100%; - gap: 20px; + justify-content: flex-start; + max-width: 100%; + min-width: 0; + gap: 8px; } [data-slot="apply-patch-file-info"] { @@ -1237,9 +1590,9 @@ [data-slot="apply-patch-trigger-actions"] { flex-shrink: 0; display: flex; - gap: 16px; + gap: 8px; align-items: center; - justify-content: flex-end; + justify-content: flex-start; } [data-slot="apply-patch-change"] { @@ -1279,10 +1632,11 @@ } [data-component="tool-loaded-file"] { + min-width: 0; display: flex; align-items: center; gap: 8px; - padding: 4px 0 4px 28px; + padding: 4px 0 4px 12px; font-family: var(--font-family-sans); font-size: var(--font-size-small); font-weight: var(--font-weight-regular); @@ -1293,4 +1647,11 @@ flex-shrink: 0; color: var(--icon-weak); } + + span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } } 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 ( - - - - - {props.text} - - - - - ) -} +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 export const PART_MAPPING: Record = {} -const TEXT_RENDER_THROTTLE_MS = 100 - -function createThrottledValue(getValue: () => string) { - const [value, setValue] = createSignal(getValue()) - let timeout: ReturnType | undefined - let last = 0 - - createEffect(() => { - const next = getValue() - const now = Date.now() - - const remaining = TEXT_RENDER_THROTTLE_MS - (now - last) - if (remaining <= 0) { - if (timeout) { - clearTimeout(timeout) - timeout = undefined - } - last = now - setValue(next) - return - } - if (timeout) clearTimeout(timeout) - timeout = setTimeout(() => { - last = Date.now() - setValue(next) - timeout = undefined - }, remaining) - }) - - onCleanup(() => { - if (timeout) clearTimeout(timeout) - }) - - return value -} - function relativizeProjectPath(path: string, directory?: string) { if (!path) return "" if (!directory) return path @@ -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(value: T[] | undefined | null, fallback: T[]) { - if (Array.isArray(value)) return value - return fallback -} +import { pageVisible } from "../hooks/use-page-visible" -function same(a: readonly T[] | undefined, b: readonly T[] | undefined) { - if (a === b) return true - if (!a || !b) return false - if (a.length !== b.length) return false - return a.every((x, i) => x === b[i]) -} - -type PartRef = { - messageID: string - partID: string -} - -type PartGroup = - | { - key: string - type: "part" - ref: PartRef - } - | { - key: string - type: "context" - refs: PartRef[] - } - -function sameRef(a: PartRef, b: PartRef) { - return a.messageID === b.messageID && a.partID === b.partID -} - -function sameGroup(a: PartGroup, b: PartGroup) { - if (a === b) return true - if (a.key !== b.key) return false - if (a.type !== b.type) return false - if (a.type === "part") { - if (b.type !== "part") return false - return sameRef(a.ref, b.ref) +function createGroupOpenState() { + const [state, setState] = createStore>({}) + const read = (key?: string, collapse?: boolean) => { + if (!key) return true + const value = state[key] + if (value !== undefined) return value + return !collapse } - 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 ( + + {props.children} + + ) +} + 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 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 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 }, - ) + let start = -1 - const last = createMemo(() => grouped().at(-1)?.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 + } + + flush(index - 1, false, (item as { part: PartType }).part.type === "tool") + push(`part:${item.message.id}:${id(item.part)}`, { type: "part", part: item.part, message: item.message }) + }) + + flush(parts.length - 1, true, false) + return { keys, items } + }) + + const last = createMemo(() => grouped().keys.at(-1)) return ( - - {(entryAccessor) => { - const entryType = createMemo(() => entryAccessor().type) +
+ + {(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 + }) - return ( - - - {(() => { - const parts = createMemo( - () => { - const entry = entryAccessor() - if (entry.type !== "context") return emptyTools - return entry.refs - .map((ref) => partByID(list(data.store.part?.[ref.messageID], emptyParts), ref.partID)) - .filter((part): part is ToolPart => !!part && isContextGroupTool(part)) - }, - emptyTools, - { equals: same }, - ) - const busy = createMemo(() => props.working && last() === entryAccessor().key) - - return ( - 0}> - - - ) - })()} - - - {(() => { - const message = createMemo(() => { - const entry = entryAccessor() - if (entry.type !== "part") return - return props.messages.find((item) => item.id === entry.ref.messageID) - }) - const part = createMemo(() => { - const entry = entryAccessor() - if (entry.type !== "part") return - return partByID(list(data.store.part?.[entry.ref.messageID], emptyParts), entry.ref.partID) - }) - - return ( - - - + const turnSummary = createMemo(() => { + const value = part() + if (!value) return false + if (value.part.type !== "text") return false + if (!props.showTurnDiffSummary) return false + return props.showAssistantCopyPartID === value.part.id + }) + const fade = createMemo(() => { + if (ctx()) return true + return tool() + }) + const edge = createMemo(() => { + const entry = part() + if (!entry) return false + if (entry.part.type !== "text") return false + if (!props.working) return false + return tail() + }) + const watch = createMemo(() => !context() && !tool() && tail() && !turnSummary()) + const ctxPartsCache = new Map() + let ctxPartsPrev: ToolPart[] = [] + const ctxParts = createMemo(() => { + const parts = ctx()?.parts ?? [] + if (parts.length === 0 && ctxPartsPrev.length > 0) return ctxPartsPrev + const result: ToolPart[] = [] + for (const item of parts) { + const k = item.part.callID || item.part.id + const cached = ctxPartsCache.get(k) + if (cached) { + result.push(cached) + } else { + ctxPartsCache.set(k, item.part) + result.push(item.part) + } + } + ctxPartsPrev = result + return result + }) + const 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 ( + +
+ + {(entry) => ( + <> + + groupState.write(entry().groupKey, value)} + /> + + + + + )} + + {(value) => } + + {(entry) => ( + + +
+ +
+
-
- ) - })()} - - - ) - }} - + )} + +
+
+ ) + }} +
+
) } @@ -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) { - const input = (part.state.input ?? {}) as Record - const path = typeof input.path === "string" ? input.path : "/" - const filePath = typeof input.filePath === "string" ? input.filePath : undefined - const pattern = typeof input.pattern === "string" ? input.pattern : undefined - const include = typeof input.include === "string" ? input.include : undefined - const offset = typeof input.offset === "number" ? input.offset : undefined - const limit = typeof input.limit === "number" ? input.limit : undefined - - switch (part.tool) { - case "read": { - const args: string[] = [] - if (offset !== undefined) args.push("offset=" + offset) - if (limit !== undefined) args.push("limit=" + limit) - return { - title: i18n.t("ui.tool.read"), - subtitle: filePath ? getFilename(filePath) : "", - args, - } - } - case "list": - return { - title: i18n.t("ui.tool.list"), - subtitle: getDirectory(path), - } - case "glob": - return { - title: i18n.t("ui.tool.glob"), - subtitle: getDirectory(path), - args: pattern ? ["pattern=" + pattern] : [], - } - case "grep": { - const args: string[] = [] - if (pattern) args.push("pattern=" + pattern) - if (include) args.push("include=" + include) - return { - title: i18n.t("ui.tool.grep"), - subtitle: getDirectory(path), - args, - } - } - default: { - const info = getToolInfo(part.tool, input) - return { - title: info.title, - subtitle: info.subtitle || contextToolDetail(part), - args: [], - } - } - } -} - -function contextToolSummary(parts: ToolPart[]) { - const read = parts.filter((part) => part.tool === "read").length - const search = parts.filter((part) => part.tool === "glob" || part.tool === "grep").length - const list = parts.filter((part) => part.tool === "list").length - return { read, search, list } -} - function ExaOutput(props: { output?: string }) { const links = createMemo(() => urls(props.output)) @@ -651,210 +657,11 @@ export function registerPartComponent(type: string, component: PartComponent) { PART_MAPPING[type] = component } -export function Message(props: MessageProps) { - return ( - - - {(userMessage) => ( - - )} - - - {(assistantMessage) => ( - - )} - - - ) -} - -export function AssistantMessageDisplay(props: { - message: AssistantMessage - parts: PartType[] - showAssistantCopyPartID?: string | null - showReasoningSummaries?: boolean -}) { - const emptyTools: ToolPart[] = [] - const grouped = createMemo( - () => - groupParts( - props.parts - .filter((part) => renderable(part, props.showReasoningSummaries ?? true)) - .map((part) => ({ - messageID: props.message.id, - part, - })), - ), - [] as PartGroup[], - { equals: sameGroups }, - ) - - return ( - - {(entryAccessor) => { - const entryType = createMemo(() => entryAccessor().type) - - return ( - - - {(() => { - const parts = createMemo( - () => { - const entry = entryAccessor() - if (entry.type !== "context") return emptyTools - return entry.refs - .map((ref) => partByID(props.parts, ref.partID)) - .filter((part): part is ToolPart => !!part && isContextGroupTool(part)) - }, - emptyTools, - { equals: same }, - ) - - return ( - 0}> - - - ) - })()} - - - {(() => { - const part = createMemo(() => { - const entry = entryAccessor() - if (entry.type !== "part") return - return partByID(props.parts, entry.ref.partID) - }) - - return ( - - - - ) - })()} - - - ) - }} - - ) -} - -function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) { - const i18n = useI18n() - const [open, setOpen] = createSignal(false) - const pending = createMemo( - () => - !!props.busy || props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"), - ) - const summary = createMemo(() => contextToolSummary(props.parts)) - - return ( - - -
- - - - - - - - - -
-
- -
- - {(partAccessor) => { - const trigger = createMemo(() => contextToolTrigger(partAccessor(), i18n)) - const running = createMemo( - () => partAccessor().state.status === "pending" || partAccessor().state.status === "running", - ) - return ( -
-
-
-
-
-
- - - - - {trigger().subtitle} - - - - {(arg) => {arg}} - - -
-
-
-
-
-
- ) - }} -
-
-
-
- ) -} - export function UserMessageDisplay(props: { message: UserMessage parts: PartType[] interrupted?: boolean + animate?: boolean queued?: boolean }) { const data = useData() @@ -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 ( -
- 0}> -
- - {(file) => ( -
{ - if (file.mime.startsWith("image/") && file.url) { - openImagePreview(file.url, file.filename) - } - }} - > - - -
- } - > - {file.filename - -
- )} - -
- - - <> -
-
- + +
+
+ 0}> +
+ + {(file) => ( +
{ + if (file.mime.startsWith("image/") && file.url) { + openImagePreview(file.url, file.filename) + } + }} + > + + +
+ } + > + {file.filename + +
+ )} +
- -
- + + + <> +
+
+ +
+ +
+ +
+
-
-
-
- - - +
+ - {metaHead()} + {userMeta()} - - - {"\u00A0\u00B7\u00A0"} - - - - - {metaTail()} - - - - - - e.preventDefault()} - onClick={(event) => { - event.stopPropagation() - handleCopy() - }} - aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} - /> - -
- -
-
+ + e.preventDefault()} + onClick={(event) => { + event.stopPropagation() + handleCopy() + }} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} + /> + +
+ + +
+
+ ) } @@ -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} />
) @@ -1078,12 +873,16 @@ export interface ToolProps { input: Record metadata: Record tool: string + partID?: string + callID?: string output?: string status?: string hideDetails?: boolean defaultOpen?: boolean forceOpen?: boolean locked?: boolean + animate?: boolean + reveal?: boolean } export type ToolComponent = Component @@ -1117,7 +916,7 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre @@ -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 = {} const emptyMetadata: Record = {} - const input = () => part().state?.input ?? emptyInput + const input = () => part.state?.input ?? emptyInput // @ts-expect-error - const partMetadata = () => part().state?.metadata ?? emptyMetadata + const partMetadata = () => part.state?.metadata ?? emptyMetadata - const render = createMemo(() => ToolRegistry.render(part().tool) ?? GenericTool) + const render = createMemo(() => ToolRegistry.render(part.tool) ?? GenericTool) return ( -
+
- + {(error) => { const cleaned = error().replace("Error: ", "") - if (part().tool === "question" && cleaned.includes("dismissed this question")) { + if (part.tool === "question" && cleaned.includes("dismissed this question")) { return (
@@ -1205,13 +1000,17 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { @@ -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 ( @@ -1311,28 +1052,12 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
- -
- - e.preventDefault()} - onClick={handleCopy} - aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} - /> - - - - {meta()} - - -
+ + {(render) => ( + +
{render()()}
+
+ )}
@@ -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 ( <> - + } /> {(filepath) => ( -
- - - {i18n.t("ui.tool.loaded")} {relativizeProjectPath(filepath, data.directory)} - -
+ )}
@@ -1397,18 +1125,29 @@ ToolRegistry.register({ name: "list", render(props) { const i18n = useI18n() + const pending = createMemo(() => busy(props.status)) return ( - + } > -
- -
+ {(output) => ( +
+ +
+ )}
-
+ ) }, }) @@ -1417,22 +1156,30 @@ ToolRegistry.register({ name: "glob", render(props) { const i18n = useI18n() + const pending = createMemo(() => busy(props.status)) return ( - - -
+ {(output) => ( +
+ +
+ )} - + ) }, }) @@ -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 ( - + } > -
- -
+ {(output) => ( +
+ +
+ )}
-
+ ) }, }) +function useToolReveal(pending: () => boolean, animate?: () => boolean) { + const enabled = () => animate?.() ?? true + const [live, setLive] = createSignal(pending() || enabled()) + createEffect(() => { + if (pending()) setLive(true) + }) + return () => enabled() && live() +} + +function WebfetchMeta(props: { url: string; animate?: boolean }) { + let ref: HTMLSpanElement | undefined + useToolFade(() => ref, { wipe: true, animate: props.animate }) + + return ( + + event.stopPropagation()} + > + {props.url} + +
+ +
+
+ ) +} + +function TaskLink(props: { href: string; text: string; onClick: (e: MouseEvent) => void; animate?: boolean }) { + let ref: HTMLAnchorElement | undefined + useToolFade(() => ref, { wipe: true, animate: props.animate }) + + return ( + + {props.text} + + ) +} + +function ToolText(props: { text: string; delay?: number; animate?: boolean }) { + let ref: HTMLSpanElement | undefined + useToolFade(() => ref, { delay: props.delay, wipe: true, animate: props.animate }) + + return ( + + {props.text} + + ) +} + +function ToolLoadedFile(props: { text: string; animate?: boolean }) { + let ref: HTMLDivElement | undefined + useToolFade(() => ref, { delay: 0.02, wipe: true, animate: props.animate }) + + return ( + +
+ + {props.text} +
+
+ ) +} + +function ToolTriggerRow(props: { + title: string + pending: boolean + subtitle?: string + args?: string[] + action?: JSX.Element + animate?: boolean + revealOnMount?: boolean +}) { + const reveal = useToolReveal( + () => props.pending, + () => props.animate !== false, + ) + const detail = createMemo(() => [props.subtitle, ...(props.args ?? [])].filter((x): x is string => !!x).join(" ")) + const detailAnimate = createMemo(() => { + if (props.animate === false) return false + if (props.revealOnMount) return true + if (!props.pending && !reveal()) return true + return reveal() + }) + + return ( +
+
+ + + + {(text) => } +
+ {props.action} +
+ ) +} + +type DiffValue = { additions: number; deletions: number } | { additions: number; deletions: number }[] + +function ToolMetaLine(props: { + filename: string + path?: string + changes?: DiffValue + delay?: number + animate?: boolean + soft?: boolean +}) { + let ref: HTMLSpanElement | undefined + useToolFade(() => ref, { delay: props.delay ?? 0.02, wipe: true, animate: props.animate }) + + return ( + + {props.filename} + + {props.path} + + {(changes) => } + + ) +} + +function ToolChanges(props: { changes: DiffValue; animate?: boolean }) { + let ref: HTMLDivElement | undefined + useToolFade(() => ref, { delay: 0.04, animate: props.animate }) + + return ( +
+ +
+ ) +} + +function ShellText(props: { text: string; animate?: boolean }) { + let ref: HTMLSpanElement | undefined + useToolFade(() => ref, { wipe: true, animate: props.animate }) + + return ( + + + + {props.text} + + + + ) +} + ToolRegistry.register({ name: "webfetch", render(props) { const i18n = useI18n() - const pending = createMemo(() => 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 ( - @@ -1485,24 +1406,8 @@ ToolRegistry.register({ - - event.stopPropagation()} - > - {url()} - - + {(value) => }
- -
- -
-
} /> @@ -1521,7 +1426,8 @@ ToolRegistry.register({ }) return ( - - +
) }, }) @@ -1547,7 +1453,8 @@ ToolRegistry.register({ }) return ( - - + ) }, }) @@ -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 = () => + 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 = () => (
- - {titleContent()} + + + {(type) => } - e.stopPropagation()} - > - {description()} - + {(url) => ( + + )} - {description()} + @@ -1619,7 +1546,7 @@ ToolRegistry.register({
) - return + return }, }) @@ -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 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 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 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 ( -
- - - + {(text) => }
} @@ -1684,7 +1626,7 @@ ToolRegistry.register({
- + ) }, }) @@ -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 (
- - - {filename()} + + {(name) => ( + + )}
- -
- {getDirectory(props.input.filePath!)} -
-
- -
- - -
} @@ -1734,7 +1675,7 @@ ToolRegistry.register({ path={path()} actions={ - + {(diff) => } } > @@ -1755,7 +1696,7 @@ ToolRegistry.register({
- + ) }, @@ -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 (
- - - {filename()} + + {(name) => ( + + )}
- -
- {getDirectory(props.input.filePath!)} -
-
-
{/* */}
} > @@ -1814,7 +1757,7 @@ ToolRegistry.register({ - + ) }, @@ -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([]) 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 ( - - +
+ +
+
+ + + + + {(file) => ( + + )} + + {(text) => } +
+
+
+ } + > + 0}> 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({ -
- - } - > -
- -
-
- - - - - {getFilename(single()!.relativePath)} - -
- -
- {getDirectory(single()!.relativePath)} -
-
-
-
- - - -
-
} > - - - - {i18n.t("ui.patch.action.created")} - - - - - {i18n.t("ui.patch.action.deleted")} - - - - - {i18n.t("ui.patch.action.moved")} - - - - - - - } - > -
- -
-
- - -
+ {(file) => ( + + + + {i18n.t("ui.patch.action.created")} + + + + + {i18n.t("ui.patch.action.deleted")} + + + + + {i18n.t("ui.patch.action.moved")} + + + + + + + } + > +
+ +
+
+ )} + + + ) }, }) @@ -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 ( - + } >
@@ -2079,7 +2018,7 @@ ToolRegistry.register({
-
+ ) }, }) @@ -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 ( - + } >
@@ -2124,7 +2069,7 @@ ToolRegistry.register({
-
+ ) }, }) @@ -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 = () => - - const trigger = () => ( -
-
- - {titleContent()} - -
-
+ const i18n = useI18n() + const pending = createMemo(() => busy(props.status)) + const name = createMemo(() => { + const value = props.input.name || props.metadata.name + if (typeof value === "string") return value + }) + return ( + + } + animate + /> ) - - return }, }) diff --git a/packages/ui/src/components/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> +type Opt = Pick 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(initial: T, config: SpringConfig): MotionValue { + return followValue(initial, config as any) +} + +let _growDuration = 0.5 +let _collapsibleDuration = 0.3 + +export const GROW_SPRING = { + type: "spring" as const, + get visualDuration() { + return _growDuration + }, + bounce: 0, +} + +export const COLLAPSIBLE_SPRING = { + type: "spring" as const, + get visualDuration() { + return _collapsibleDuration + }, + bounce: 0, +} + +export const setGrowDuration = (v: number) => { + _growDuration = v +} +export const setCollapsibleDuration = (v: number) => { + _collapsibleDuration = v +} +export const getGrowDuration = () => _growDuration +export const getCollapsibleDuration = () => _collapsibleDuration + +export type SpringConfig = { type: "spring"; visualDuration: number; bounce: number } + +export const FAST_SPRING = { + type: "spring" as const, + visualDuration: 0.35, + bounce: 0, +} + +export const GLOW_SPRING = { + type: "spring" as const, + visualDuration: 0.4, + bounce: 0.15, +} + +export const WIPE_MASK = + "linear-gradient(to right, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 45%, rgba(0,0,0,0) 60%, rgba(0,0,0,0) 100%)" + +export const clearMaskStyles = (el: HTMLElement) => { + el.style.maskImage = "" + el.style.webkitMaskImage = "" + el.style.maskSize = "" + el.style.webkitMaskSize = "" + el.style.maskRepeat = "" + el.style.webkitMaskRepeat = "" + el.style.maskPosition = "" + el.style.webkitMaskPosition = "" +} + +export const clearFadeStyles = (el: HTMLElement) => { + el.style.opacity = "" + el.style.filter = "" + el.style.transform = "" +} diff --git a/packages/ui/src/components/rolling-results.css b/packages/ui/src/components/rolling-results.css 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 = { + items: T[] + render: (item: T, index: number) => JSX.Element + fixed?: JSX.Element + getKey?: (item: T, index: number) => string + rows?: number + rowHeight?: number + fixedHeight?: number + rowGap?: number + open?: boolean + scrollable?: boolean + spring?: SpringConfig + animate?: boolean + class?: string + empty?: JSX.Element + noFadeOnCollapse?: boolean +} + +export function RollingResults(props: RollingResultsProps) { + let view: HTMLDivElement | undefined + let track: HTMLDivElement | undefined + let windowEl: HTMLDivElement | undefined + let shift: AnimationPlaybackControls | undefined + let resize: AnimationPlaybackControls | undefined + let edgeFade: AnimationPlaybackControls | undefined + + const 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 ( +
+
+ +
{props.fixed}
+
+
+
+ +
{props.empty}
+
+
+ + {(item, index) => ( +
+ {props.render(item, index())} +
+ )} +
+
+
+
+
+
+ ) +} diff --git a/packages/ui/src/components/scroll-view.css b/packages/ui/src/components/scroll-view.css index 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 (
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 { return !!value && typeof value === "object" && !Array.isArray(value) } @@ -73,18 +77,12 @@ function unwrap(message: string) { return message } -function same(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(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 | undefined + let handoffTimer: ReturnType | undefined + + const copyAssistant = async () => { + const text = assistantCopyPart()?.text + if (!text) return + + await navigator.clipboard.writeText(text) + setCopied(true) + if (copiedTimer !== undefined) clearTimeout(copiedTimer) + copiedTimer = setTimeout(() => { + copiedTimer = undefined + setCopied(false) + }, 2000) + } + + createEffect( + on( + () => [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 = () => ( +
+ + +
+
+ {i18n.t("ui.sessionReview.change.modified")} + + {edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")} + +
+ + +
+
+
+
+ + +
+ setExpanded(Array.isArray(value) ? value : value ? [value] : [])} + > + + {(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 ( + + + +
+ + + {`\u202A${getDirectory(diff.file)}\u202C`} + + {getFilename(diff.file)} + +
+ + + + + + +
+
+
+
+ + +
+ +
+
+
+
+ ) + }} +
+
+
+
+
+
+
+ ) + + const divider = (label: string) => ( +
+
+ + + {label} + + +
+
+ ) + return (
-
-
- -
- -
- -
-
- 0}> -
- ( +
+
+
- - -
- - - - -
-
- - 0 && !working()}> -
- - -
-
- {i18n.t("ui.sessionReview.change.modified")} - - {edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")} - -
- - -
-
+ + {(part) => ( + +
+
- - - -
- setExpanded(Array.isArray(value) ? value : value ? [value] : [])} + + )} + +
+ +
+ +
+
+ +
+
+ + +
+ +
+ - - {(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 ( - - - -
- - - - {`\u202A${getDirectory(diff.file)}\u202C`} - - - {getFilename(diff.file)} - -
- - - - - - -
-
-
-
- - -
- -
-
-
-
- ) - }} -
- + event.preventDefault()} + onClick={() => void copyAssistant()} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} + /> +
+ + + {meta()} + +
- - +
+
- - - - {errorText()} - - -
+ + {divider(i18n.t("ui.message.interrupted"))} + + + + {turnDiffSummary()} + + + + {errorText()} + + +
+ )} {props.children}
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 ( + + {props.text} + + ) +} + +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 ( +
+ + $ {props.text} + +
+ ) +} + +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 ( +
+
+
+
+
+ $ + {props.cmd} +
+
+ + e.preventDefault()} + onClick={handleCopy} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} + /> + +
+
+ + <> +
+
+
+                  {props.out}
+                
+
+ + +
+
+
+ ) +} + +export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }) { + const i18n = useI18n() + const wiped = new Set() + 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) + 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 + }) + const text = createThrottledValue(() => stripAnsi(output())) + const rows = createMemo(() => shellRows(text())) + + return ( +
+
+
+ + + + {(text) => } + + + + + + + +
+
+
+ row.id} + render={(row) => { + const [textRef, setTextRef] = createSignal() + useRowWipe({ + id: () => row.id, + text: () => row.text, + ref: textRef, + seen: wiped, + }) + return ( +
+ + {row.text} + +
+ ) + }} + /> +
+ +
+ ) +} 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: { ) } + +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 ( + + {props.text ?? "\u00A0"} + + ) +} 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 = (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 ( (props: { style={{ "--text-shimmer-swap": `${swap}ms`, "--text-shimmer-index": `${offset()}`, + "--text-shimmer-size": `${shimmerSize()}%`, + "--text-shimmer-duration": `${shimmerDuration()}ms`, }} > diff --git a/packages/ui/src/components/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(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: { + @@ -118,7 +143,7 @@ export function ToolStatusTitle(props: { - + 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 | 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 | 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 +}) { + 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 | undefined - let autoTimer: ReturnType | undefined let cleanup: (() => void) | undefined - let auto: { top: number; time: number } | undefined + let programmaticUntil = 0 + let scrollAnim: AnimationPlaybackControls | undefined + let hold: + | { + el: HTMLElement + top: number + until: number + quiet: number + frame: number | undefined + } + | undefined const threshold = () => options.bottomThreshold ?? 10 @@ -27,77 +44,160 @@ export function createAutoScroll(options: AutoScrollOptions) { const active = () => options.working() || settling const distanceFromBottom = (el: HTMLElement) => { - return el.scrollHeight - el.clientHeight - el.scrollTop + // With column-reverse, scrollTop=0 is at the bottom, negative = scrolled up + return Math.abs(el.scrollTop) } const canScroll = (el: HTMLElement) => { return el.scrollHeight - el.clientHeight > 1 } - // Browsers can dispatch scroll events asynchronously. If new content arrives - // between us calling `scrollTo()` and the subsequent `scroll` event firing, - // the handler can see a non-zero `distanceFromBottom` and incorrectly assume - // the user scrolled. - const markAuto = (el: HTMLElement) => { - auto = { - top: Math.max(0, el.scrollHeight - el.clientHeight), - time: Date.now(), - } - - if (autoTimer) clearTimeout(autoTimer) - autoTimer = setTimeout(() => { - auto = undefined - autoTimer = undefined - }, 1500) + const markProgrammatic = () => { + programmaticUntil = Date.now() + AUTO_SCROLL_GRACE_MS } - const isAuto = (el: HTMLElement) => { - const a = auto - if (!a) return false + const clearHold = () => { + const next = hold + if (!next) return + if (next.frame !== undefined) cancelAnimationFrame(next.frame) + hold = undefined + } - if (Date.now() - a.time > 1500) { - auto = undefined + const tickHold = () => { + const next = hold + const el = scroll + if (!next || !el) return false + if (Date.now() > next.until) { + clearHold() + return false + } + if (!next.el.isConnected) { + clearHold() return false } - return Math.abs(el.scrollTop - a.top) < 2 - } - - const scrollToBottomNow = (behavior: ScrollBehavior) => { - const el = scroll - if (!el) return - markAuto(el) - if (behavior === "smooth") { - el.scrollTo({ top: el.scrollHeight, behavior }) - return + const current = next.el.getBoundingClientRect().top + if (!Number.isFinite(current)) { + clearHold() + return false } - // `scrollTop` assignment bypasses any CSS `scroll-behavior: smooth`. - el.scrollTop = el.scrollHeight + const delta = current - next.top + if (Math.abs(delta) <= AUTO_SCROLL_EPSILON) { + next.quiet += 1 + if (next.quiet > MANUAL_ANCHOR_QUIET_FRAMES) { + clearHold() + return false + } + return true + } + + next.quiet = 0 + if (!store.userScrolled) { + setStore("userScrolled", true) + options.onUserInteracted?.() + } + el.scrollTop += delta + markProgrammatic() + return true + } + + const scheduleHold = () => { + const next = hold + if (!next) return + if (next.frame !== undefined) return + + next.frame = requestAnimationFrame(() => { + const value = hold + if (!value) return + value.frame = undefined + if (!tickHold()) return + scheduleHold() + }) + } + + const preserve = (target: HTMLElement) => { + const el = scroll + if (!el) return + + if (!store.userScrolled) { + setStore("userScrolled", true) + options.onUserInteracted?.() + } + + const top = target.getBoundingClientRect().top + if (!Number.isFinite(top)) return + + clearHold() + hold = { + el: target, + top, + until: Date.now() + MANUAL_ANCHOR_MS, + quiet: 0, + frame: undefined, + } + scheduleHold() } const scrollToBottom = (force: boolean) => { if (!force && !active()) return + clearHold() + if (force && store.userScrolled) setStore("userScrolled", false) const el = scroll if (!el) return + if (scrollAnim) cancelSmooth() if (!force && store.userScrolled) return - const distance = distanceFromBottom(el) - if (distance < 2) { - markAuto(el) + // With column-reverse, scrollTop=0 is at the bottom + if (Math.abs(el.scrollTop) <= AUTO_SCROLL_EPSILON) { + markProgrammatic() return } - // For auto-following content we prefer immediate updates to avoid - // visible "catch up" animations while content is still settling. - scrollToBottomNow("auto") + el.scrollTop = 0 + markProgrammatic() } - const stop = () => { + const cancelSmooth = () => { + if (scrollAnim) { + scrollAnim.stop() + scrollAnim = undefined + } + } + + const smoothScrollToBottom = () => { + const el = scroll + if (!el) return + + cancelSmooth() + if (store.userScrolled) setStore("userScrolled", false) + + // With column-reverse, scrollTop=0 is at the bottom + if (Math.abs(el.scrollTop) <= AUTO_SCROLL_EPSILON) { + markProgrammatic() + return + } + + scrollAnim = animate(el.scrollTop, 0, { + ...FAST_SPRING, + onUpdate: (v) => { + markProgrammatic() + el.scrollTop = v + }, + onComplete: () => { + scrollAnim = undefined + markProgrammatic() + }, + }) + } + + const stop = (input?: { hold?: boolean }) => { + if (input?.hold !== false) clearHold() + const el = scroll if (!el) return if (!canScroll(el)) { @@ -106,15 +206,25 @@ export function createAutoScroll(options: AutoScrollOptions) { } if (store.userScrolled) return + markProgrammatic() setStore("userScrolled", true) options.onUserInteracted?.() } const handleWheel = (e: WheelEvent) => { + if (e.deltaY !== 0) clearHold() + + if (e.deltaY > 0) { + const el = scroll + if (!el) return + if (distanceFromBottom(el) >= threshold()) return + if (store.userScrolled) setStore("userScrolled", false) + markProgrammatic() + return + } + if (e.deltaY >= 0) return - // If the user is scrolling within a nested scrollable region (tool output, - // code block, etc), don't treat it as leaving the "follow bottom" mode. - // Those regions opt in via `data-scrollable`. + cancelSmooth() const el = scroll const target = e.target instanceof Element ? e.target : undefined const nested = target?.closest("[data-scrollable]") @@ -126,23 +236,27 @@ export function createAutoScroll(options: AutoScrollOptions) { const el = scroll if (!el) return + if (hold) { + if (Date.now() < programmaticUntil) return + clearHold() + } + if (!canScroll(el)) { if (store.userScrolled) setStore("userScrolled", false) + markProgrammatic() return } if (distanceFromBottom(el) < threshold()) { + if (Date.now() < programmaticUntil) return if (store.userScrolled) setStore("userScrolled", false) + markProgrammatic() return } - // Ignore scroll events triggered by our own scrollToBottom calls. - if (!store.userScrolled && isAuto(el)) { - scrollToBottom(false) - return - } + if (!store.userScrolled && Date.now() < programmaticUntil) return - stop() + stop({ hold: false }) } const handleInteraction = () => { @@ -154,6 +268,11 @@ export function createAutoScroll(options: AutoScrollOptions) { } const updateOverflowAnchor = (el: HTMLElement) => { + if (hold) { + el.style.overflowAnchor = "none" + return + } + const mode = options.overflowAnchor ?? "dynamic" if (mode === "none") { @@ -173,15 +292,17 @@ export function createAutoScroll(options: AutoScrollOptions) { () => store.contentRef, () => { const el = scroll + if (hold) { + scheduleHold() + return + } if (el && !canScroll(el)) { if (store.userScrolled) setStore("userScrolled", false) + markProgrammatic() return } if (!active()) return if (store.userScrolled) return - // ResizeObserver fires after layout, before paint. - // Keep the bottom locked in the same frame to avoid visible - // "jump up then catch up" artifacts while streaming content. scrollToBottom(false) }, ) @@ -200,13 +321,11 @@ export function createAutoScroll(options: AutoScrollOptions) { settling = true settleTimer = setTimeout(() => { settling = false - }, 300) + }, SETTLE_MS) }), ) createEffect(() => { - // Track `userScrolled` even before `scrollRef` is attached, so we can - // update overflow anchoring once the element exists. store.userScrolled const el = scroll if (!el) return @@ -215,7 +334,8 @@ export function createAutoScroll(options: AutoScrollOptions) { onCleanup(() => { if (settleTimer) clearTimeout(settleTimer) - if (autoTimer) clearTimeout(autoTimer) + clearHold() + cancelSmooth() if (cleanup) cleanup() }) @@ -228,8 +348,12 @@ export function createAutoScroll(options: AutoScrollOptions) { scroll = el - if (!el) return + if (!el) { + clearHold() + return + } + markProgrammatic() updateOverflowAnchor(el) el.addEventListener("wheel", handleWheel, { passive: true }) @@ -240,13 +364,18 @@ export function createAutoScroll(options: AutoScrollOptions) { contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el), handleScroll, handleInteraction, + preserve, pause: stop, - resume: () => { - if (store.userScrolled) setStore("userScrolled", false) - scrollToBottom(true) - }, - scrollToBottom: () => scrollToBottom(false), forceScrollToBottom: () => scrollToBottom(true), + smoothScrollToBottom, + snapToBottom: () => { + const el = scroll + if (!el) return + if (store.userScrolled) setStore("userScrolled", false) + // With column-reverse, scrollTop=0 is at the bottom + el.scrollTop = 0 + markProgrammatic() + }, userScrolled: () => store.userScrolled, } } 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), + initial = 0, +): Accessor { + const [height, setHeight] = createSignal(initial) + + createEffect(() => { + const el = ref() + if (!el) return + setHeight(el.getBoundingClientRect().height) + const observer = new ResizeObserver(() => { + setHeight(el.getBoundingClientRect().height) + }) + observer.observe(el) + onCleanup(() => observer.disconnect()) + }) + + return height +} 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(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( items: readonly T[], predicate: (item: T, index: number, items: readonly T[]) => boolean,