diff --git a/bun.lock b/bun.lock index 5202b70d9..17ea5b347 100644 --- a/bun.lock +++ b/bun.lock @@ -483,11 +483,8 @@ "@pierre/diffs": "catalog:", "@shikijs/transformers": "3.9.2", "@solid-primitives/bounds": "0.1.3", - "@solid-primitives/lifecycle": "0.1.2", "@solid-primitives/media": "2.3.3", - "@solid-primitives/page-visibility": "2.1.1", "@solid-primitives/resize-observer": "2.1.3", - "@solid-primitives/rootless": "1.5.2", "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", "dompurify": "3.3.1", @@ -1837,14 +1834,10 @@ "@solid-primitives/keyed": ["@solid-primitives/keyed@1.5.3", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zNadtyYBhJSOjXtogkGHmRxjGdz9KHc8sGGVAGlUABkE8BED2tbIZoxkwSqzOwde8OcUEH0bb5DLZUWIMvyBSA=="], - "@solid-primitives/lifecycle": ["@solid-primitives/lifecycle@0.1.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-+K0T10kZXqorocFj0coIqt8NYm2UqoZfpF3nm2RwrDMZMV+C+SC0Oi3N6Dnq2i7W/n1cHAnfpoV4CBLsW21lJw=="], - "@solid-primitives/map": ["@solid-primitives/map@0.4.13", "", { "dependencies": { "@solid-primitives/trigger": "^1.1.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-B1zyFbsiTQvqPr+cuPCXO72sRuczG9Swncqk5P74NCGw1VE8qa/Ry9GlfI1e/VdeQYHjan+XkbE3rO2GW/qKew=="], "@solid-primitives/media": ["@solid-primitives/media@2.3.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hQ4hLOGvfbugQi5Eu1BFWAIJGIAzztq9x0h02xgBGl2l0Jaa3h7tg6bz5tV1NSuNYVGio4rPoa7zVQQLkkx9dA=="], - "@solid-primitives/page-visibility": ["@solid-primitives/page-visibility@2.1.1", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.1", "@solid-primitives/rootless": "^1.5.1", "@solid-primitives/utils": "^6.3.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-CV9BqMqhunf4OOyBkhJCH9f5ivg0ADavdcaBsrqoFvwIk1FoD/blPSHYM4CK8IjS/AEXNcsjlNVc34lMu+2Wdg=="], - "@solid-primitives/props": ["@solid-primitives/props@3.2.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-lZOTwFJajBrshSyg14nBMEP0h8MXzPowGO0s3OeiR3z6nXHTfj0FhzDtJMv+VYoRJKQHG2QRnJTgCzK6erARAw=="], "@solid-primitives/refs": ["@solid-primitives/refs@1.1.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-K7tf2thy7L+YJjdqXspXOg5xvNEOH8tgEWsp0+1mQk3obHBRD6hEjYZk7p7FlJphSZImS35je3UfmWuD7MhDfg=="], diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index 90a449d50..86147dc65 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -7,7 +7,6 @@ import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils" import { dropdownMenuTriggerSelector, dropdownMenuContentSelector, - sessionTimelineHeaderSelector, projectMenuTriggerSelector, projectCloseMenuSelector, projectWorkspacesToggleSelector, @@ -244,9 +243,7 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) { const scroller = page.locator(".scroll-view__viewport").first() await expect(scroller).toBeVisible() - const header = page.locator(sessionTimelineHeaderSelector).first() - await expect(header).toBeVisible({ timeout: 30_000 }) - await expect(header.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 }) + await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 }) const menu = page .locator(dropdownMenuContentSelector) @@ -262,7 +259,7 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) { if (opened) return menu - const menuTrigger = header.getByRole("button", { name: /more options/i }).first() + const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first() await expect(menuTrigger).toBeVisible() await menuTrigger.click() diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts index 002ac2114..2061a1128 100644 --- a/packages/app/e2e/selectors.ts +++ b/packages/app/e2e/selectors.ts @@ -51,8 +51,6 @@ export const dropdownMenuContentSelector = '[data-component="dropdown-menu-conte export const inlineInputSelector = '[data-component="inline-input"]' -export const sessionTimelineHeaderSelector = "[data-session-title]" - export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]` export const workspaceItemSelector = (slug: string) => diff --git a/packages/app/e2e/session/session.spec.ts b/packages/app/e2e/session/session.spec.ts index e541738c5..68d992949 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, sessionTimelineHeaderSelector } from "../selectors" +import { sessionItemSelector, inlineInputSelector } from "../selectors" const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1" @@ -39,14 +39,12 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession } await withSession(sdk, originalTitle, async (session) => { await seedMessage(sdk, session.id) await gotoSession(session.id) - await expect(page.locator(sessionTimelineHeaderSelector).getByRole("heading", { level: 1 }).first()).toHaveText( - originalTitle, - ) + await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle) const menu = await openSessionMoreMenu(page, session.id) await clickMenuItem(menu, /rename/i) - const input = page.locator(sessionTimelineHeaderSelector).locator(inlineInputSelector).first() + const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first() await expect(input).toBeVisible() await expect(input).toBeFocused() await input.fill(renamedTitle) @@ -63,9 +61,7 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession } ) .toBe(renamedTitle) - await expect(page.locator(sessionTimelineHeaderSelector).getByRole("heading", { level: 1 }).first()).toHaveText( - renamedTitle, - ) + await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle) }) }) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 90769a28a..3f5da0c94 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -41,12 +41,220 @@ import { createScrollSpy } from "@/pages/session/scroll-spy" import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs" import { SessionSidePanel } from "@/pages/session/session-side-panel" import { TerminalPanel } from "@/pages/session/terminal-panel" -import { createSessionHistoryWindow, emptyUserMessages } from "@/pages/session/history-window" import { useSessionCommands } from "@/pages/session/use-session-commands" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" import { same } from "@/utils/same" import { formatServerError } from "@/utils/server-errors" +const emptyUserMessages: UserMessage[] = [] + +type SessionHistoryWindowInput = { + sessionID: () => string | undefined + messagesReady: () => boolean + visibleUserMessages: () => UserMessage[] + historyMore: () => boolean + historyLoading: () => boolean + loadMore: (sessionID: string) => Promise + userScrolled: () => boolean + scroller: () => HTMLDivElement | undefined +} + +/** + * Maintains the rendered history window for a session timeline. + * + * It keeps initial paint bounded to recent turns, reveals cached turns in + * small batches while scrolling upward, and prefetches older history near top. + */ +function createSessionHistoryWindow(input: SessionHistoryWindowInput) { + const turnInit = 10 + const turnBatch = 8 + const turnScrollThreshold = 200 + const turnPrefetchBuffer = 16 + const prefetchCooldownMs = 400 + const prefetchNoGrowthLimit = 2 + + const [state, setState] = createStore({ + turnID: undefined as string | undefined, + turnStart: 0, + prefetchUntil: 0, + prefetchNoGrowth: 0, + }) + + const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0) + + const turnStart = createMemo(() => { + const id = input.sessionID() + const len = input.visibleUserMessages().length + if (!id || len <= 0) return 0 + if (state.turnID !== id) return initialTurnStart(len) + if (state.turnStart <= 0) return 0 + if (state.turnStart >= len) return initialTurnStart(len) + return state.turnStart + }) + + const setTurnStart = (start: number) => { + const id = input.sessionID() + const next = start > 0 ? start : 0 + if (!id) { + setState({ turnID: undefined, turnStart: next }) + return + } + setState({ turnID: id, turnStart: next }) + } + + const renderedUserMessages = createMemo( + () => { + const msgs = input.visibleUserMessages() + const start = turnStart() + if (start <= 0) return msgs + return msgs.slice(start) + }, + emptyUserMessages, + { + equals: same, + }, + ) + + const preserveScroll = (fn: () => void) => { + const el = input.scroller() + if (!el) { + fn() + return + } + const beforeTop = el.scrollTop + const beforeHeight = el.scrollHeight + fn() + requestAnimationFrame(() => { + const delta = el.scrollHeight - beforeHeight + if (!delta) return + el.scrollTop = beforeTop + delta + }) + } + + const backfillTurns = () => { + const start = turnStart() + if (start <= 0) return + + const next = start - turnBatch + const nextStart = next > 0 ? next : 0 + + preserveScroll(() => setTurnStart(nextStart)) + } + + /** Button path: reveal all cached turns, fetch older history, reveal one batch. */ + const loadAndReveal = async () => { + const id = input.sessionID() + if (!id) return + + const start = turnStart() + const beforeVisible = input.visibleUserMessages().length + + if (start > 0) setTurnStart(0) + + if (!input.historyMore() || input.historyLoading()) return + + await input.loadMore(id) + if (input.sessionID() !== id) return + + const afterVisible = input.visibleUserMessages().length + const growth = afterVisible - beforeVisible + if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0) + if (growth <= 0) return + if (turnStart() !== 0) return + + const target = Math.min(afterVisible, Math.max(beforeVisible, renderedUserMessages().length) + turnBatch) + const nextStart = Math.max(0, afterVisible - target) + preserveScroll(() => setTurnStart(nextStart)) + } + + /** Scroll/prefetch path: fetch older history from server. */ + const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => { + const id = input.sessionID() + if (!id) return + if (!input.historyMore() || input.historyLoading()) return + + if (opts?.prefetch) { + const now = Date.now() + if (state.prefetchUntil > now) return + if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return + setState("prefetchUntil", now + prefetchCooldownMs) + } + + const start = turnStart() + const beforeVisible = input.visibleUserMessages().length + const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length + + await input.loadMore(id) + if (input.sessionID() !== id) return + + const afterVisible = input.visibleUserMessages().length + const growth = afterVisible - beforeVisible + + if (opts?.prefetch) { + setState("prefetchNoGrowth", growth > 0 ? 0 : state.prefetchNoGrowth + 1) + } else if (growth > 0 && state.prefetchNoGrowth) { + setState("prefetchNoGrowth", 0) + } + + if (growth <= 0) return + if (turnStart() !== start) return + + const reveal = !opts?.prefetch + const currentRendered = renderedUserMessages().length + const base = Math.max(beforeRendered, currentRendered) + const target = reveal ? Math.min(afterVisible, base + turnBatch) : base + const nextStart = Math.max(0, afterVisible - target) + preserveScroll(() => setTurnStart(nextStart)) + } + + const onScrollerScroll = () => { + if (!input.userScrolled()) return + const el = input.scroller() + if (!el) return + if (el.scrollTop >= turnScrollThreshold) return + + const start = turnStart() + if (start > 0) { + if (start <= turnPrefetchBuffer) { + void fetchOlderMessages({ prefetch: true }) + } + backfillTurns() + return + } + + void fetchOlderMessages() + } + + createEffect( + on( + input.sessionID, + () => { + setState({ prefetchUntil: 0, prefetchNoGrowth: 0 }) + }, + { defer: true }, + ), + ) + + createEffect( + on( + () => [input.sessionID(), input.messagesReady()] as const, + ([id, ready]) => { + if (!id || !ready) return + setTurnStart(initialTurnStart(input.visibleUserMessages().length)) + }, + { defer: true }, + ), + ) + + return { + turnStart, + setTurnStart, + renderedUserMessages, + loadAndReveal, + onScrollerScroll, + } +} + export default function Page() { const globalSync = useGlobalSync() const layout = useLayout() @@ -886,7 +1094,6 @@ export default function Page() { let scrollStateFrame: number | undefined let scrollStateTarget: HTMLDivElement | undefined - let historyFillFrame: number | undefined const scrollSpy = createScrollSpy({ onActive: (id) => { if (id === store.messageId) return @@ -897,7 +1104,7 @@ export default function Page() { const updateScrollState = (el: HTMLDivElement) => { const max = el.scrollHeight - el.clientHeight const overflow = max > 1 - const bottom = !overflow || Math.abs(el.scrollTop) <= 2 || !autoScroll.userScrolled() + const bottom = !overflow || el.scrollTop >= max - 2 if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return setUi("scroll", { overflow, bottom }) @@ -920,7 +1127,7 @@ export default function Page() { const resumeScroll = () => { setStore("messageId", undefined) - autoScroll.smoothScrollToBottom() + autoScroll.forceScrollToBottom() clearMessageHash() const el = scroller @@ -956,9 +1163,7 @@ export default function Page() { scroller = el autoScroll.scrollRef(el) scrollSpy.setContainer(el) - if (!el) return - scheduleScrollState(el) - scheduleHistoryFill() + if (el) scheduleScrollState(el) } createResizeObserver( @@ -967,7 +1172,6 @@ export default function Page() { const el = scroller if (el) scheduleScrollState(el) scrollSpy.markDirty() - scheduleHistoryFill() }, ) @@ -982,45 +1186,6 @@ export default function Page() { scroller: () => scroller, }) - const scheduleHistoryFill = () => { - if (historyFillFrame !== undefined) return - - historyFillFrame = requestAnimationFrame(() => { - historyFillFrame = undefined - - if (!params.id || !messagesReady()) return - if (autoScroll.userScrolled() || historyLoading()) return - - const el = scroller - if (!el) return - if (el.scrollHeight > el.clientHeight + 1) return - if (historyWindow.turnStart() <= 0 && !historyMore()) return - - void historyWindow.loadAndReveal() - }) - } - - createEffect( - on( - () => - [ - params.id, - messagesReady(), - historyWindow.turnStart(), - historyMore(), - historyLoading(), - autoScroll.userScrolled(), - visibleUserMessages().length, - ] as const, - ([id, ready, start, more, loading, scrolled]) => { - if (!id || !ready || loading || scrolled) return - if (start <= 0 && !more) return - scheduleHistoryFill() - }, - { defer: true }, - ), - ) - createResizeObserver( () => promptDock, ({ height }) => { @@ -1030,15 +1195,16 @@ export default function Page() { const el = scroller const delta = next - dockHeight - const stick = el ? Math.abs(el.scrollTop) < 10 + Math.max(0, delta) : false + const stick = el + ? !autoScroll.userScrolled() || el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta) + : false dockHeight = next - if (stick) autoScroll.smoothScrollToBottom() + if (stick) autoScroll.forceScrollToBottom() if (el) scheduleScrollState(el) scrollSpy.markDirty() - scheduleHistoryFill() }, ) @@ -1068,7 +1234,6 @@ export default function Page() { document.removeEventListener("keydown", handleKeyDown) scrollSpy.destroy() if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame) - if (historyFillFrame !== undefined) cancelAnimationFrame(historyFillFrame) }) return ( @@ -1122,7 +1287,6 @@ 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/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 18a02993b..93ea3d465 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -140,7 +140,7 @@ export function SessionComposerRegion(props: {
diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index 07df4305f..77643789d 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -446,9 +446,9 @@ export function FileTabContent(props: { tab: string }) { ) return ( - + { scroll = el restoreScroll() diff --git a/packages/app/src/pages/session/history-window.test.ts b/packages/app/src/pages/session/history-window.test.ts deleted file mode 100644 index 4a9b894e2..000000000 --- a/packages/app/src/pages/session/history-window.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { historyLoadMode, historyRevealTop } from "./history-window" - -describe("historyLoadMode", () => { - test("reveals cached turns before fetching", () => { - expect(historyLoadMode({ start: 10, more: true, loading: false })).toBe("reveal") - }) - - test("fetches older history when cache is already revealed", () => { - expect(historyLoadMode({ start: 0, more: true, loading: false })).toBe("fetch") - }) - - test("does nothing while history is unavailable or loading", () => { - expect(historyLoadMode({ start: 0, more: false, loading: false })).toBe("noop") - expect(historyLoadMode({ start: 0, more: true, loading: true })).toBe("noop") - }) -}) - -describe("historyRevealTop", () => { - test("pins the viewport to the top when older turns were revealed there", () => { - expect(historyRevealTop({ top: -400, height: 1000, gap: 0, max: 400 }, { clientHeight: 600, height: 2000 })).toBe( - -1400, - ) - }) - - test("keeps the latest turns pinned when the viewport was underfilled", () => { - expect(historyRevealTop({ top: 0, height: 200, gap: -400, max: -400 }, { clientHeight: 600, height: 2000 })).toBe(0) - }) - - test("keeps the current anchor when the user was not at the top", () => { - expect(historyRevealTop({ top: -200, height: 1000, gap: 200, max: 400 }, { clientHeight: 600, height: 2000 })).toBe( - -200, - ) - }) -}) diff --git a/packages/app/src/pages/session/history-window.ts b/packages/app/src/pages/session/history-window.ts deleted file mode 100644 index e3ef20f13..000000000 --- a/packages/app/src/pages/session/history-window.ts +++ /dev/null @@ -1,273 +0,0 @@ -import type { UserMessage } from "@opencode-ai/sdk/v2" -import { createEffect, createMemo, on } from "solid-js" -import { createStore } from "solid-js/store" -import { same } from "@/utils/same" - -export const emptyUserMessages: UserMessage[] = [] - -export type SessionHistoryWindowInput = { - sessionID: () => string | undefined - messagesReady: () => boolean - visibleUserMessages: () => UserMessage[] - historyMore: () => boolean - historyLoading: () => boolean - loadMore: (sessionID: string) => Promise - userScrolled: () => boolean - scroller: () => HTMLDivElement | undefined -} - -type Snap = { - top: number - height: number - gap: number - max: number -} - -export const historyLoadMode = (input: { start: number; more: boolean; loading: boolean }) => { - if (input.start > 0) return "reveal" - if (!input.more || input.loading) return "noop" - return "fetch" -} - -export const historyRevealTop = ( - mark: { top: number; height: number; gap: number; max: number }, - next: { clientHeight: number; height: number }, - threshold = 16, -) => { - const delta = next.height - mark.height - if (delta <= 0) return mark.top - if (mark.max <= 0) return mark.top - if (mark.gap > threshold) return mark.top - - const max = next.height - next.clientHeight - if (max <= 0) return 0 - return Math.max(-max, Math.min(0, mark.top - delta)) -} - -const snap = (el: HTMLDivElement | undefined): Snap | undefined => { - if (!el) return - const max = el.scrollHeight - el.clientHeight - return { - top: el.scrollTop, - height: el.scrollHeight, - gap: max + el.scrollTop, - max, - } -} - -const clamp = (el: HTMLDivElement, top: number) => { - const max = el.scrollHeight - el.clientHeight - if (max <= 0) return 0 - return Math.max(-max, Math.min(0, top)) -} - -const revealThreshold = 16 - -const reveal = (input: SessionHistoryWindowInput, mark: Snap | undefined) => { - const el = input.scroller() - if (!el || !mark) return - el.scrollTop = clamp( - el, - historyRevealTop(mark, { clientHeight: el.clientHeight, height: el.scrollHeight }, revealThreshold), - ) -} - -const preserve = (input: SessionHistoryWindowInput, fn: () => void) => { - const el = input.scroller() - if (!el) { - fn() - return - } - const top = el.scrollTop - fn() - el.scrollTop = top -} - -/** - * Maintains the rendered history window for a session timeline. - * - * It keeps initial paint bounded to recent turns, reveals cached turns in - * small batches while scrolling upward, and prefetches older history near top. - */ -export function createSessionHistoryWindow(input: SessionHistoryWindowInput) { - const turnInit = 10 - const turnBatch = 8 - const turnScrollThreshold = 200 - const turnPrefetchBuffer = 16 - const prefetchCooldownMs = 400 - const prefetchNoGrowthLimit = 2 - - const [state, setState] = createStore({ - turnID: undefined as string | undefined, - turnStart: 0, - prefetchUntil: 0, - prefetchNoGrowth: 0, - }) - - const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0) - - const turnStart = createMemo(() => { - const id = input.sessionID() - const len = input.visibleUserMessages().length - if (!id || len <= 0) return 0 - if (state.turnID !== id) return initialTurnStart(len) - if (state.turnStart <= 0) return 0 - if (state.turnStart >= len) return initialTurnStart(len) - return state.turnStart - }) - - const setTurnStart = (start: number) => { - const id = input.sessionID() - const next = start > 0 ? start : 0 - if (!id) { - setState({ turnID: undefined, turnStart: next }) - return - } - setState({ turnID: id, turnStart: next }) - } - - const renderedUserMessages = createMemo( - () => { - const msgs = input.visibleUserMessages() - const start = turnStart() - if (start <= 0) return msgs - return msgs.slice(start) - }, - emptyUserMessages, - { - equals: same, - }, - ) - - const backfillTurns = () => { - const start = turnStart() - if (start <= 0) return - - const next = start - turnBatch - const nextStart = next > 0 ? next : 0 - - preserve(input, () => setTurnStart(nextStart)) - } - - /** Button path: reveal cached turns first, then fetch older history. */ - const loadAndReveal = async () => { - const id = input.sessionID() - if (!id) return - - const start = turnStart() - const mode = historyLoadMode({ - start, - more: input.historyMore(), - loading: input.historyLoading(), - }) - - if (mode === "reveal") { - const mark = snap(input.scroller()) - setTurnStart(0) - reveal(input, mark) - return - } - - if (mode === "noop") return - - const beforeVisible = input.visibleUserMessages().length - const mark = snap(input.scroller()) - - await input.loadMore(id) - if (input.sessionID() !== id) return - - const afterVisible = input.visibleUserMessages().length - const growth = afterVisible - beforeVisible - if (growth <= 0) return - if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0) - - reveal(input, mark) - } - - /** Scroll/prefetch path: fetch older history from server. */ - const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => { - const id = input.sessionID() - if (!id) return - if (!input.historyMore() || input.historyLoading()) return - - if (opts?.prefetch) { - const now = Date.now() - if (state.prefetchUntil > now) return - if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return - setState("prefetchUntil", now + prefetchCooldownMs) - } - - const start = turnStart() - const beforeVisible = input.visibleUserMessages().length - const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length - - await input.loadMore(id) - if (input.sessionID() !== id) return - - const afterVisible = input.visibleUserMessages().length - const growth = afterVisible - beforeVisible - - if (opts?.prefetch) { - setState("prefetchNoGrowth", growth > 0 ? 0 : state.prefetchNoGrowth + 1) - } else if (growth > 0 && state.prefetchNoGrowth) { - setState("prefetchNoGrowth", 0) - } - - if (growth <= 0) return - if (turnStart() !== start) return - - const revealMore = !opts?.prefetch - const currentRendered = renderedUserMessages().length - const base = Math.max(beforeRendered, currentRendered) - const target = revealMore ? Math.min(afterVisible, base + turnBatch) : base - const nextStart = Math.max(0, afterVisible - target) - preserve(input, () => setTurnStart(nextStart)) - } - - const onScrollerScroll = () => { - if (!input.userScrolled()) return - const el = input.scroller() - if (!el) return - if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return - - const start = turnStart() - if (start > 0) { - if (start <= turnPrefetchBuffer) { - void fetchOlderMessages({ prefetch: true }) - } - backfillTurns() - return - } - - void fetchOlderMessages() - } - - createEffect( - on( - input.sessionID, - () => { - setState({ prefetchUntil: 0, prefetchNoGrowth: 0 }) - }, - { defer: true }, - ), - ) - - createEffect( - on( - () => [input.sessionID(), input.messagesReady()] as const, - ([id, ready]) => { - if (!id || !ready) return - setTurnStart(initialTurnStart(input.visibleUserMessages().length)) - }, - { defer: true }, - ), - ) - - return { - turnStart, - setTurnStart, - renderedUserMessages, - loadAndReveal, - onScrollerScroll, - } -} diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index e93ca11a3..ce6a01378 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -1,31 +1,27 @@ -import { - For, - Index, - createEffect, - createMemo, - createSignal, - on, - onCleanup, - Show, - startTransition, - type JSX, -} from "solid-js" -import { createStore } from "solid-js/store" -import { useParams } from "@solidjs/router" +import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js" +import { createStore, produce } from "solid-js/store" +import { useNavigate, useParams } from "@solidjs/router" import { Button } from "@opencode-ai/ui/button" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { Dialog } from "@opencode-ai/ui/dialog" +import { InlineInput } from "@opencode-ai/ui/inline-input" import { SessionTurn } from "@opencode-ai/ui/session-turn" import { ScrollView } from "@opencode-ai/ui/scroll-view" import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2" +import { showToast } from "@opencode-ai/ui/toast" import { Binary } from "@opencode-ai/util/binary" import { getFilename } from "@opencode-ai/util/path" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" +import { SessionContextUsage } from "@/components/session-context-usage" +import { useDialog } from "@opencode-ai/ui/context/dialog" import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" +import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" -import { SessionTimelineHeader } from "@/pages/session/session-timeline-header" type MessageComment = { path: string @@ -37,9 +33,7 @@ type MessageComment = { } const emptyMessages: MessageType[] = [] - -const isDefaultSessionTitle = (title?: string) => - !!title && /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title) +const idle = { type: "idle" as const } const messageComments = (parts: Part[]): MessageComment[] => parts.flatMap((part) => { @@ -116,8 +110,6 @@ function createTimelineStaging(input: TimelineStageInput) { completedSession: "", count: 0, }) - const [readySession, setReadySession] = createSignal("") - let active = "" const stagedCount = createMemo(() => { const total = input.messages().length @@ -142,46 +134,23 @@ function createTimelineStaging(input: TimelineStageInput) { cancelAnimationFrame(frame) frame = undefined } - const scheduleReady = (sessionKey: string) => { - if (input.sessionKey() !== sessionKey) return - if (readySession() === sessionKey) return - setReadySession(sessionKey) - } createEffect( on( () => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const, ([sessionKey, isWindowed, total]) => { - const switched = active !== sessionKey - if (switched) { - active = sessionKey - setReadySession("") - } - - const staging = state.activeSession === sessionKey && state.completedSession !== sessionKey - const shouldStage = isWindowed && total > input.config.init && state.completedSession !== sessionKey - - if (staging && !switched && shouldStage && frame !== undefined) return - cancel() - - if (shouldStage) setReadySession("") + const shouldStage = + isWindowed && + total > input.config.init && + state.completedSession !== sessionKey && + state.activeSession !== sessionKey if (!shouldStage) { - setState({ - activeSession: "", - completedSession: isWindowed ? sessionKey : state.completedSession, - count: total, - }) - if (total <= 0) { - setReadySession("") - return - } - if (readySession() !== sessionKey) scheduleReady(sessionKey) + setState({ activeSession: "", count: total }) return } let count = Math.min(total, input.config.init) - if (staging) count = Math.min(total, Math.max(count, state.count)) setState({ activeSession: sessionKey, count }) const step = () => { @@ -191,11 +160,10 @@ function createTimelineStaging(input: TimelineStageInput) { } const currentTotal = input.messages().length count = Math.min(currentTotal, count + input.config.batch) - startTransition(() => setState("count", count)) + setState("count", count) if (count >= currentTotal) { setState({ completedSession: sessionKey, activeSession: "" }) frame = undefined - scheduleReady(sessionKey) return } frame = requestAnimationFrame(step) @@ -209,12 +177,9 @@ function createTimelineStaging(input: TimelineStageInput) { const key = input.sessionKey() return state.activeSession === key && state.completedSession !== key }) - const ready = createMemo(() => readySession() === input.sessionKey()) - onCleanup(() => { - cancel() - }) - return { messages: stagedUserMessages, isStaging, ready } + onCleanup(cancel) + return { messages: stagedUserMessages, isStaging } } export function MessageTimeline(props: { @@ -231,7 +196,6 @@ export function MessageTimeline(props: { onScrollSpyScroll: () => void onTurnBackfillScroll: () => void onAutoScrollInteraction: (event: MouseEvent) => void - onPreserveScrollAnchor: (target: HTMLElement) => void centered: boolean setContentRef: (el: HTMLDivElement) => void turnStart: number @@ -246,19 +210,14 @@ 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 trigger = (target: EventTarget | null) => { - const next = - target instanceof Element - ? target.closest('[data-slot="collapsible-trigger"], [data-slot="accordion-trigger"], [data-scroll-preserve]') - : undefined - if (!(next instanceof HTMLElement)) return - return next - } - + const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id)) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const sessionID = createMemo(() => params.id) const sessionMessages = createMemo(() => { @@ -271,20 +230,28 @@ export function MessageTimeline(props: { (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number", ), ) - const sessionStatus = createMemo(() => sync.data.session_status[sessionID() ?? ""]?.type ?? "idle") + const sessionStatus = createMemo(() => { + const id = sessionID() + if (!id) return idle + return sync.data.session_status[id] ?? idle + }) const activeMessageID = createMemo(() => { - const messages = sessionMessages() - const message = pending() - if (message?.parentID) { - const result = Binary.search(messages, message.parentID, (item) => item.id) - const parent = result.found ? messages[result.index] : messages.find((item) => item.id === message.parentID) - if (parent?.role === "user") return parent.id + const parentID = pending()?.parentID + if (parentID) { + const messages = sessionMessages() + const result = Binary.search(messages, parentID, (message) => message.id) + const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID) + if (message && message.role === "user") return message.id } - if (sessionStatus() === "idle") return undefined - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].role === "user") return messages[i].id + const status = sessionStatus() + if (status.type !== "idle") { + const messages = sessionMessages() + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "user") return messages[i].id + } } + return undefined }) const info = createMemo(() => { @@ -292,19 +259,9 @@ export function MessageTimeline(props: { if (!id) return return sync.session.get(id) }) - const titleValue = createMemo(() => { - const title = info()?.title - if (!title) return - if (isDefaultSessionTitle(title)) return language.t("command.session.new") - return title - }) - const defaultTitle = createMemo(() => isDefaultSessionTitle(info()?.title)) - const headerTitle = createMemo( - () => titleValue() ?? (props.renderedUserMessages.length ? language.t("command.session.new") : undefined), - ) - const placeholderTitle = createMemo(() => defaultTitle() || (!info()?.title && props.renderedUserMessages.length > 0)) + const titleValue = createMemo(() => info()?.title) const parentID = createMemo(() => info()?.parentID) - const showHeader = createMemo(() => !!(headerTitle() || parentID())) + const showHeader = createMemo(() => !!(titleValue() || parentID())) const stageCfg = { init: 1, batch: 3 } const staging = createTimelineStaging({ sessionKey, @@ -312,7 +269,212 @@ export function MessageTimeline(props: { messages: () => props.renderedUserMessages, config: stageCfg, }) - const rendered = createMemo(() => staging.messages().map((message) => message.id)) + + const [title, setTitle] = createStore({ + draft: "", + editing: false, + saving: false, + menuOpen: false, + pendingRename: false, + }) + let titleRef: HTMLInputElement | undefined + + const errorMessage = (err: unknown) => { + if (err && typeof err === "object" && "data" in err) { + const data = (err as { data?: { message?: string } }).data + if (data?.message) return data.message + } + if (err instanceof Error) return err.message + return language.t("common.requestFailed") + } + + createEffect( + on( + sessionKey, + () => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }), + { defer: true }, + ), + ) + + const openTitleEditor = () => { + if (!sessionID()) return + setTitle({ editing: true, draft: titleValue() ?? "" }) + requestAnimationFrame(() => { + titleRef?.focus() + titleRef?.select() + }) + } + + const closeTitleEditor = () => { + if (title.saving) return + setTitle({ editing: false, saving: false }) + } + + const saveTitleEditor = async () => { + const id = sessionID() + if (!id) return + if (title.saving) return + + const next = title.draft.trim() + if (!next || next === (titleValue() ?? "")) { + setTitle({ editing: false, saving: false }) + return + } + + setTitle("saving", true) + await sdk.client.session + .update({ sessionID: id, title: next }) + .then(() => { + sync.set( + produce((draft) => { + const index = draft.session.findIndex((s) => s.id === id) + if (index !== -1) draft.session[index].title = next + }), + ) + setTitle({ editing: false, saving: false }) + }) + .catch((err) => { + setTitle("saving", false) + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(err), + }) + }) + } + + const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => { + if (params.id !== sessionID) return + if (parentID) { + navigate(`/${params.dir}/session/${parentID}`) + return + } + if (nextSessionID) { + navigate(`/${params.dir}/session/${nextSessionID}`) + return + } + navigate(`/${params.dir}/session`) + } + + const archiveSession = async (sessionID: string) => { + const session = sync.session.get(sessionID) + if (!session) return + + const sessions = sync.data.session ?? [] + const index = sessions.findIndex((s) => s.id === sessionID) + const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) + + await sdk.client.session + .update({ sessionID, time: { archived: Date.now() } }) + .then(() => { + sync.set( + produce((draft) => { + const index = draft.session.findIndex((s) => s.id === sessionID) + if (index !== -1) draft.session.splice(index, 1) + }), + ) + navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) + }) + .catch((err) => { + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(err), + }) + }) + } + + const deleteSession = async (sessionID: string) => { + const session = sync.session.get(sessionID) + if (!session) return false + + const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived) + const index = sessions.findIndex((s) => s.id === sessionID) + const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) + + const result = await sdk.client.session + .delete({ sessionID }) + .then((x) => x.data) + .catch((err) => { + showToast({ + title: language.t("session.delete.failed.title"), + description: errorMessage(err), + }) + return false + }) + + if (!result) return false + + sync.set( + produce((draft) => { + const removed = new Set([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() })} + +
+
+ + +
+
+
+ ) + } return (
- { const root = e.currentTarget @@ -381,18 +532,9 @@ 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() @@ -401,24 +543,134 @@ export function MessageTimeline(props: { props.onMarkScrollGesture(e.currentTarget) if (props.isDesktop) props.onScrollSpyScroll() }} - onClick={(e) => { - props.onAutoScrollInteraction(e) - }} + onClick={props.onAutoScrollInteraction} class="relative min-w-0 w-full h-full" style={{ - "--session-title-height": showHeader() ? "72px" : "0px", + "--session-title-height": showHeader() ? "40px" : "0px", "--sticky-accordion-top": showHeader() ? "48px" : "0px", }} > -
+
+ +
+
+
+ + + + + + {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 @@ -457,10 +700,7 @@ export function MessageTimeline(props: { return false }) const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], { - equals: (a, b) => { - if (a.length !== b.length) return false - return a.every((x, i) => x.path === b[i].path && x.comment === b[i].comment) - }, + equals: (a, b) => JSON.stringify(a) === JSON.stringify(b), }) const commentCount = createMemo(() => comments().length) return ( @@ -473,7 +713,7 @@ export function MessageTimeline(props: { }} classList={{ "min-w-0 w-full max-w-full": true, - "md:max-w-[500px] 2xl:max-w-[700px]": props.centered, + "md:max-w-200 2xl:max-w-[1000px]": props.centered, }} > 0}> @@ -517,7 +757,7 @@ export function MessageTimeline(props: { messageID={messageID} active={active()} queued={queued()} - animate={isNew || active()} + status={active() ? sessionStatus() : undefined} showReasoningSummaries={settings.general.showReasoningSummaries()} shellToolDefaultOpen={settings.general.shellToolPartsExpanded()} editToolDefaultOpen={settings.general.editToolPartsExpanded()} diff --git a/packages/app/src/pages/session/session-timeline-header.tsx b/packages/app/src/pages/session/session-timeline-header.tsx deleted file mode 100644 index 32412f0a7..000000000 --- a/packages/app/src/pages/session/session-timeline-header.tsx +++ /dev/null @@ -1,522 +0,0 @@ -import { createEffect, createMemo, on, onCleanup, Show } from "solid-js" -import { createStore, produce } from "solid-js/store" -import { useNavigate, useParams } from "@solidjs/router" -import { Button } from "@opencode-ai/ui/button" -import { useReducedMotion } from "@opencode-ai/ui/hooks" -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 { 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 = useReducedMotion() - - 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 278a1ba6e..20e88a3ea 100644 --- a/packages/app/src/pages/session/use-session-hash-scroll.ts +++ b/packages/app/src/pages/session/use-session-hash-scroll.ts @@ -1,5 +1,6 @@ import type { UserMessage } from "@opencode-ai/sdk/v2" -import { createEffect, createMemo, onCleanup, onMount } from "solid-js" +import { useLocation, useNavigate } from "@solidjs/router" +import { createEffect, createMemo, onMount } from "solid-js" import { messageIdFromHash } from "./message-id-from-hash" export { messageIdFromHash } from "./message-id-from-hash" @@ -15,7 +16,7 @@ export const useSessionHashScroll = (input: { setPendingMessage: (value: string | undefined) => void setActiveMessage: (message: UserMessage | undefined) => void setTurnStart: (value: number) => void - autoScroll: { pause: () => void; snapToBottom: () => void } + autoScroll: { pause: () => void; forceScrollToBottom: () => void } scroller: () => HTMLDivElement | undefined anchor: (id: string) => string scheduleScrollState: (el: HTMLDivElement) => void @@ -26,13 +27,18 @@ 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 (!window.location.hash) return - window.history.replaceState(null, "", window.location.pathname + window.location.search) + if (!location.hash) return + navigate(location.pathname + location.search, { replace: true }) } const updateHash = (id: string) => { - window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}#${input.anchor(id)}`) + navigate(location.pathname + location.search + `#${input.anchor(id)}`, { + replace: true, + }) } const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => { @@ -41,15 +47,15 @@ export const useSessionHashScroll = (input: { const a = el.getBoundingClientRect() const b = root.getBoundingClientRect() - const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height")) - const inset = Number.isNaN(title) ? 0 : title - // With column-reverse, scrollTop is negative — don't clamp to 0 - const top = a.top - b.top + root.scrollTop - inset + const sticky = root.querySelector("[data-session-title]") + const inset = sticky instanceof HTMLElement ? sticky.offsetHeight : 0 + const top = Math.max(0, a.top - b.top + root.scrollTop - inset) root.scrollTo({ top, behavior }) return true } const 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 @@ -97,9 +103,9 @@ export const useSessionHashScroll = (input: { } const applyHash = (behavior: ScrollBehavior) => { - const hash = window.location.hash.slice(1) + const hash = location.hash.slice(1) if (!hash) { - input.autoScroll.snapToBottom() + input.autoScroll.forceScrollToBottom() const el = input.scroller() if (el) input.scheduleScrollState(el) return @@ -123,26 +129,13 @@ export const useSessionHashScroll = (input: { return } - input.autoScroll.snapToBottom() + input.autoScroll.forceScrollToBottom() const el = input.scroller() if (el) input.scheduleScrollState(el) } - onMount(() => { - if (typeof window !== "undefined" && "scrollRestoration" in window.history) { - window.history.scrollRestoration = "manual" - } - - const handler = () => { - if (!input.sessionID() || !input.messagesReady()) return - requestAnimationFrame(() => applyHash("auto")) - } - - window.addEventListener("hashchange", handler) - onCleanup(() => window.removeEventListener("hashchange", handler)) - }) - createEffect(() => { + location.hash if (!input.sessionID() || !input.messagesReady()) return requestAnimationFrame(() => applyHash("auto")) }) @@ -166,6 +159,7 @@ export const useSessionHashScroll = (input: { } } + if (!targetId) targetId = messageIdFromHash(location.hash) if (!targetId) return if (input.currentMessageId() === targetId) return @@ -177,6 +171,12 @@ 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/package.json b/packages/ui/package.json index 4022deb4a..ab1f09af3 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -48,11 +48,8 @@ "@pierre/diffs": "catalog:", "@shikijs/transformers": "3.9.2", "@solid-primitives/bounds": "0.1.3", - "@solid-primitives/lifecycle": "0.1.2", "@solid-primitives/media": "2.3.3", - "@solid-primitives/page-visibility": "2.1.1", "@solid-primitives/resize-observer": "2.1.3", - "@solid-primitives/rootless": "1.5.2", "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", "dompurify": "3.3.1", diff --git a/packages/ui/src/components/animated-number.css b/packages/ui/src/components/animated-number.css index b69ce6508..022b347e9 100644 --- a/packages/ui/src/components/animated-number.css +++ b/packages/ui/src/components/animated-number.css @@ -9,20 +9,19 @@ display: inline-flex; flex-direction: row-reverse; align-items: baseline; - justify-content: flex-start; + justify-content: flex-end; line-height: inherit; width: var(--animated-number-width, 1ch); - overflow: clip; - transition: width var(--tool-motion-spring-ms, 800ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); + overflow: hidden; + transition: width var(--tool-motion-spring-ms, 560ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); } [data-slot="animated-number-digit"] { display: inline-block; - flex-shrink: 0; width: 1ch; height: 1em; line-height: 1em; - overflow: clip; + overflow: hidden; vertical-align: baseline; -webkit-mask-image: linear-gradient( to bottom, @@ -47,7 +46,7 @@ flex-direction: column; transform: translateY(calc(var(--animated-number-offset, 10) * -1em)); transition-property: transform; - transition-duration: var(--animated-number-duration, 600ms); + transition-duration: var(--animated-number-duration, 560ms); transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); } diff --git a/packages/ui/src/components/animated-number.tsx b/packages/ui/src/components/animated-number.tsx index dfe368b8b..b5fceba25 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 = 800 +const DURATION = 600 function normalize(value: number) { return ((value % 10) + 10) % 10 @@ -90,35 +90,10 @@ export function AnimatedNumber(props: { value: number; class?: string }) { ) const width = createMemo(() => `${digits().length}ch`) - const [exitingDigits, setExitingDigits] = createSignal([]) - 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 ad25bef32..1dbfce26e 100644 --- a/packages/ui/src/components/basic-tool.css +++ b/packages/ui/src/components/basic-tool.css @@ -8,28 +8,54 @@ justify-content: flex-start; [data-slot="basic-tool-tool-trigger-content"] { - width: 100%; - min-width: 0; + width: auto; display: flex; align-items: center; align-self: stretch; gap: 8px; } + [data-slot="basic-tool-tool-indicator"] { + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + [data-component="spinner"] { + width: 16px; + height: 16px; + } + } + + [data-slot="basic-tool-tool-spinner"] { + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--text-weak); + + [data-component="spinner"] { + width: 16px; + height: 16px; + } + } + [data-slot="icon-svg"] { flex-shrink: 0; } [data-slot="basic-tool-tool-info"] { - flex: 1 1 auto; + flex: 0 1 auto; min-width: 0; font-size: 14px; } [data-slot="basic-tool-tool-info-structured"] { width: auto; - max-width: 100%; - min-width: 0; display: flex; align-items: center; gap: 8px; @@ -37,12 +63,11 @@ } [data-slot="basic-tool-tool-info-main"] { - flex: 0 1 auto; display: flex; - align-items: center; + align-items: baseline; gap: 8px; min-width: 0; - overflow: clip; + overflow: hidden; } [data-slot="basic-tool-tool-title"] { @@ -54,14 +79,22 @@ line-height: var(--line-height-large); letter-spacing: var(--letter-spacing-normal); color: var(--text-strong); + + &.capitalize { + text-transform: capitalize; + } + + &.agent-title { + color: var(--text-strong); + font-weight: var(--font-weight-medium); + } } [data-slot="basic-tool-tool-subtitle"] { - display: inline-block; - flex: 0 1 auto; - max-width: 100%; + flex-shrink: 1; min-width: 0; - overflow: clip; + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-family-sans); font-variant-numeric: tabular-nums; @@ -106,7 +139,8 @@ [data-slot="basic-tool-tool-arg"] { flex-shrink: 1; min-width: 0; - overflow: clip; + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-family-sans); font-variant-numeric: tabular-nums; diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index 3210b4870..4ad91824d 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -1,20 +1,8 @@ -import { - createEffect, - createSignal, - For, - Match, - on, - onCleanup, - onMount, - Show, - splitProps, - Switch, - type JSX, -} from "solid-js" -import { animate, type AnimationPlaybackControls, tunableSpringValue, COLLAPSIBLE_SPRING } from "./motion" +import { createEffect, createSignal, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js" +import { animate, type AnimationPlaybackControls } from "motion" import { Collapsible } from "./collapsible" +import type { IconProps } from "./icon" import { TextShimmer } from "./text-shimmer" -import { hold } from "./tool-utils" export type TriggerTitle = { title: string @@ -32,99 +20,26 @@ const isTriggerTitle = (val: any): val is TriggerTitle => { ) } -interface ToolCallPanelBaseProps { - icon: string +export interface BasicToolProps { + icon: IconProps["name"] trigger: TriggerTitle | JSX.Element children?: JSX.Element status?: string - animate?: boolean hideDetails?: boolean defaultOpen?: boolean forceOpen?: boolean defer?: boolean locked?: boolean - watchDetails?: boolean - springContent?: boolean + animated?: boolean onSubtitleClick?: () => void } -function ToolCallTriggerBody(props: { - trigger: TriggerTitle | JSX.Element - pending: boolean - onSubtitleClick?: () => void - arrow?: boolean -}) { - return ( -
-
-
- - - {(trigger) => ( -
-
- - - - - - { - if (!props.onSubtitleClick) return - e.stopPropagation() - props.onSubtitleClick() - }} - > - {trigger().subtitle} - - - - - {(arg) => ( - - {arg} - - )} - - - -
- {trigger().action} -
- )} -
- {props.trigger as JSX.Element} -
-
-
- - - -
- ) -} +const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 } -function ToolCallPanel(props: ToolCallPanelBaseProps) { +export function BasicTool(props: BasicToolProps) { const [open, setOpen] = createSignal(props.defaultOpen ?? false) const [ready, setReady] = createSignal(open()) - const pendingRaw = () => props.status === "pending" || props.status === "running" - const pending = hold(pendingRaw, 1000) - const watchDetails = () => props.watchDetails !== false + const pending = () => props.status === "pending" || props.status === "running" let frame: number | undefined @@ -144,7 +59,7 @@ function ToolCallPanel(props: ToolCallPanelBaseProps) { on( open, (value) => { - if (!props.defer || props.springContent) return + if (!props.defer) return if (!value) { cancel() setReady(false) @@ -162,110 +77,36 @@ function ToolCallPanel(props: ToolCallPanelBaseProps) { ), ) - // Animated content height — single springValue drives all height changes + // Animated height for collapsible open/close let contentRef: HTMLDivElement | undefined - let bodyRef: HTMLDivElement | undefined - let fadeAnim: AnimationPlaybackControls | undefined - let observer: ResizeObserver | undefined - let resizeFrame: number | undefined + let heightAnim: AnimationPlaybackControls | undefined const initialOpen = open() - const heightSpring = tunableSpringValue(0, COLLAPSIBLE_SPRING) - - const read = () => Math.max(0, Math.ceil(bodyRef?.getBoundingClientRect().height ?? 0)) - - const doOpen = () => { - if (!contentRef || !bodyRef) return - contentRef.style.display = "" - // Ensure fade starts from 0 if content was hidden (first open or after close cleared styles) - if (bodyRef.style.opacity === "") { - bodyRef.style.opacity = "0" - bodyRef.style.filter = "blur(2px)" - } - const next = read() - fadeAnim?.stop() - fadeAnim = animate(bodyRef, { opacity: 1, filter: "blur(0px)" }, COLLAPSIBLE_SPRING) - fadeAnim.finished.then(() => { - if (!bodyRef) return - bodyRef.style.opacity = "" - bodyRef.style.filter = "" - }) - heightSpring.set(next) - } - - const doClose = () => { - if (!contentRef || !bodyRef) return - fadeAnim?.stop() - fadeAnim = animate(bodyRef, { opacity: 0, filter: "blur(2px)" }, COLLAPSIBLE_SPRING) - fadeAnim.finished.then(() => { - if (!contentRef || open()) return - contentRef.style.display = "none" - }) - heightSpring.set(0) - } - - const grow = () => { - if (!contentRef || !open()) return - const next = read() - if (Math.abs(next - heightSpring.get()) < 1) return - heightSpring.set(next) - } - - onMount(() => { - if (!props.springContent || props.animate === false || !contentRef || !bodyRef) return - - const offChange = heightSpring.on("change", (v) => { - if (!contentRef) return - contentRef.style.height = `${Math.max(0, Math.ceil(v))}px` - }) - onCleanup(() => { - offChange() - }) - - if (watchDetails()) { - observer = new ResizeObserver(() => { - if (resizeFrame !== undefined) return - resizeFrame = requestAnimationFrame(() => { - resizeFrame = undefined - grow() - }) - }) - observer.observe(bodyRef) - } - - if (!open()) return - if (contentRef.style.display !== "none") { - const next = read() - heightSpring.jump(next) - contentRef.style.height = `${next}px` - return - } - let mountFrame: number | undefined = requestAnimationFrame(() => { - mountFrame = undefined - if (!open()) return - doOpen() - }) - onCleanup(() => { - if (mountFrame !== undefined) cancelAnimationFrame(mountFrame) - }) - }) createEffect( on( open, (isOpen) => { - if (!props.springContent || props.animate === false || !contentRef) return - if (isOpen) doOpen() - else doClose() + if (!props.animated || !contentRef) return + heightAnim?.stop() + if (isOpen) { + contentRef.style.overflow = "hidden" + heightAnim = animate(contentRef, { height: "auto" }, SPRING) + heightAnim.finished.then(() => { + if (!contentRef || !open()) return + contentRef.style.overflow = "visible" + contentRef.style.height = "auto" + }) + } else { + contentRef.style.overflow = "hidden" + heightAnim = animate(contentRef, { height: "0px" }, SPRING) + } }, { defer: true }, ), ) onCleanup(() => { - if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame) - observer?.disconnect() - fadeAnim?.stop() - heightSpring.destroy() + heightAnim?.stop() }) const handleOpenChange = (value: boolean) => { @@ -277,34 +118,85 @@ function ToolCallPanel(props: ToolCallPanelBaseProps) { return ( - +
+
+
+ + + {(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}
@@ -330,60 +222,6 @@ 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 @@ -391,8 +229,7 @@ export function GenericTool(props: { input?: Record }) { return ( - ) } diff --git a/packages/ui/src/components/collapsible.css b/packages/ui/src/components/collapsible.css index 1a86338bd..bab2c4f92 100644 --- a/packages/ui/src/components/collapsible.css +++ b/packages/ui/src/components/collapsible.css @@ -8,18 +8,14 @@ border-radius: var(--radius-md); overflow: visible; - &.tool-collapsible [data-slot="collapsible-trigger"] { - height: 37px; - } - - &.tool-collapsible [data-slot="basic-tool-content-inner"] { - padding-top: 0; + &.tool-collapsible { + gap: 8px; } [data-slot="collapsible-trigger"] { width: 100%; display: flex; - height: 36px; + height: 32px; padding: 0; align-items: center; align-self: stretch; @@ -27,17 +23,6 @@ user-select: none; color: var(--text-base); - > [data-component="tool-trigger"][data-arrow] { - width: auto; - max-width: 100%; - flex: 0 1 auto; - - [data-slot="basic-tool-tool-trigger-content"] { - width: auto; - max-width: 100%; - } - } - [data-slot="collapsible-arrow"] { opacity: 0; transition: opacity 0.15s ease; @@ -65,6 +50,9 @@ line-height: var(--line-height-large); /* 166.667% */ letter-spacing: var(--letter-spacing-normal); + /* &:hover { */ + /* background-color: var(--surface-base); */ + /* } */ &:focus-visible { outline: none; background-color: var(--surface-raised-base-hover); @@ -94,16 +82,16 @@ } [data-slot="collapsible-content"] { - overflow: clip; + overflow: hidden; + /* animation: slideUp 250ms ease-out; */ &[data-expanded] { overflow: visible; } - /* JS-animated content: overflow managed by animate() */ - &[data-spring-content] { - overflow: clip; - } + /* &[data-expanded] { */ + /* animation: slideDown 250ms ease-out; */ + /* } */ } &[data-variant="ghost"] { @@ -115,6 +103,9 @@ border: none; padding: 0; + /* &:hover { */ + /* color: var(--text-strong); */ + /* } */ &:focus-visible { outline: none; background-color: var(--surface-raised-base-hover); @@ -131,3 +122,21 @@ } } } + +@keyframes slideDown { + from { + height: 0; + } + to { + height: var(--kb-collapsible-content-height); + } +} + +@keyframes slideUp { + from { + height: var(--kb-collapsible-content-height); + } + to { + height: 0; + } +} diff --git a/packages/ui/src/components/context-tool-results.tsx b/packages/ui/src/components/context-tool-results.tsx deleted file mode 100644 index a0d9311de..000000000 --- a/packages/ui/src/components/context-tool-results.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import { createMemo, createSignal, For, onMount } from "solid-js" -import type { ToolPart } from "@opencode-ai/sdk/v2" -import { getFilename } from "@opencode-ai/util/path" -import { useReducedMotion } from "../hooks/use-reduced-motion" -import { useI18n } from "../context/i18n" -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 ( - - - - - - - - - -
- } - /> - ) -} - -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 reduce = useReducedMotion() - const wiped = new Set() - const [mounted, setMounted] = createSignal(false) - onMount(() => setMounted(true)) - 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 deleted file mode 100644 index c8ea6f3b3..000000000 --- a/packages/ui/src/components/grow-box.tsx +++ /dev/null @@ -1,432 +0,0 @@ -import { createEffect, on, type JSX, onMount, onCleanup } from "solid-js" -import { useReducedMotion } from "../hooks/use-reduced-motion" -import { animate, tunableSpringValue, type AnimationPlaybackControls, GROW_SPRING, type SpringConfig } from "./motion" - -export interface GrowBoxProps { - children: JSX.Element - /** Enable animation. When false, content shows immediately at full height. */ - animate?: boolean - /** Animate height from 0 to content height. Default: true. */ - grow?: boolean - /** Keep watching body size and animate subsequent height changes. Default: false. */ - watch?: boolean - /** Fade in body content (opacity + blur). Default: true. */ - fade?: boolean - /** Top padding in px on the body wrapper. Default: 0. */ - gap?: number - /** Reset to height:auto after grow completes, or stay at fixed px. Default: true. */ - autoHeight?: boolean - /** 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 = useReducedMotion() - 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 (watch()) { - observer = new ResizeObserver(() => { - if (!open()) return - if (resizeFrame !== undefined) return - resizeFrame = requestAnimationFrame(() => { - resizeFrame = undefined - setHeight("mount") - }) - }) - observer.observe(body) - } - - 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") - }) - } - }) - - 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 9a6784d70..8fc709013 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -1,20 +1,10 @@ [data-component="assistant-message"] { content-visibility: auto; width: 100%; -} - -[data-component="assistant-parts"] { - width: 100%; - min-width: 0; display: flex; flex-direction: column; align-items: flex-start; - gap: 0; -} - -[data-component="assistant-part-item"] { - width: 100%; - min-width: 0; + gap: 12px; } [data-component="user-message"] { @@ -37,14 +27,6 @@ color: var(--text-weak); } - [data-slot="user-message-inner"] { - position: relative; - display: flex; - flex-direction: column; - align-items: flex-end; - width: 100%; - gap: 4px; - } [data-slot="user-message-attachments"] { display: flex; flex-wrap: wrap; @@ -53,7 +35,6 @@ width: fit-content; max-width: min(82%, 64ch); margin-left: auto; - margin-bottom: 4px; } [data-slot="user-message-attachment"] { @@ -153,7 +134,7 @@ [data-slot="user-message-copy-wrapper"] { min-height: 24px; - margin-top: 0; + margin-top: 4px; display: flex; align-items: center; justify-content: flex-end; @@ -163,6 +144,7 @@ pointer-events: none; transition: opacity 0.15s ease; will-change: opacity; + [data-component="tooltip-trigger"] { display: inline-flex; width: fit-content; @@ -205,21 +187,56 @@ opacity: 1; pointer-events: auto; } + + .text-text-strong { + color: var(--text-strong); + } + + .font-medium { + font-weight: var(--font-weight-medium); + } } [data-component="text-part"] { width: 100%; - margin-top: 0; - padding-block: 4px; - position: relative; + margin-top: 24px; [data-slot="text-part-body"] { margin-top: 0; } - [data-slot="text-part-turn-summary"] { + [data-slot="text-part-copy-wrapper"] { + min-height: 24px; + margin-top: 4px; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 10px; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease; + will-change: opacity; + + [data-component="tooltip-trigger"] { + display: inline-flex; + width: fit-content; + } + } + + [data-slot="text-part-meta"] { + user-select: none; + } + + [data-slot="text-part-copy-wrapper"][data-interrupted] { width: 100%; - min-width: 0; + justify-content: flex-end; + gap: 12px; + } + + &:hover [data-slot="text-part-copy-wrapper"], + &:focus-within [data-slot="text-part-copy-wrapper"] { + opacity: 1; + pointer-events: auto; } [data-component="markdown"] { @@ -228,10 +245,6 @@ } } -[data-component="assistant-part-item"][data-kind="text"][data-last="true"] [data-component="text-part"] { - padding-bottom: 0; -} - [data-component="compaction-part"] { width: 100%; display: flex; @@ -265,6 +278,7 @@ line-height: var(--line-height-normal); [data-component="markdown"] { + margin-top: 24px; font-style: normal; font-size: inherit; color: var(--text-weak); @@ -358,16 +372,13 @@ height: auto; max-height: 240px; overflow-y: auto; - overscroll-behavior: contain; scrollbar-width: none; -ms-overflow-style: none; - -webkit-mask-image: linear-gradient(to bottom, transparent 0, black 6px, black calc(100% - 6px), transparent 100%); - mask-image: linear-gradient(to bottom, transparent 0, black 6px, black calc(100% - 6px), transparent 100%); - -webkit-mask-repeat: no-repeat; - mask-repeat: no-repeat; + &::-webkit-scrollbar { display: none; } + [data-component="markdown"] { overflow: visible; } @@ -437,7 +448,7 @@ [data-component="write-trigger"] { display: flex; align-items: center; - justify-content: flex-start; + justify-content: space-between; gap: 8px; width: 100%; @@ -450,8 +461,7 @@ } [data-slot="message-part-title"] { - flex-shrink: 1; - min-width: 0; + flex-shrink: 0; display: flex; align-items: center; gap: 8px; @@ -483,45 +493,40 @@ [data-slot="message-part-title-text"] { text-transform: capitalize; color: var(--text-strong); - flex-shrink: 0; - } - - [data-slot="message-part-meta-line"], - .message-part-meta-line { - min-width: 0; - display: inline-flex; - align-items: center; - gap: 6px; - font-weight: var(--font-weight-regular); - - [data-component="diff-changes"] { - flex-shrink: 0; - gap: 6px; - } - } - - .message-part-meta-line.soft { - [data-slot="message-part-title-filename"] { - color: var(--text-base); - } } [data-slot="message-part-title-filename"] { /* No text-transform - preserve original filename casing */ - color: var(--text-strong); - flex-shrink: 0; + font-weight: var(--font-weight-regular); } - [data-slot="message-part-directory-inline"] { - color: var(--text-weak); + [data-slot="message-part-path"] { + display: flex; + flex-grow: 1; min-width: 0; - max-width: min(48vw, 36ch); + font-weight: var(--font-weight-regular); + } + + [data-slot="message-part-directory"] { + color: var(--text-weak); text-overflow: ellipsis; overflow: hidden; white-space: nowrap; direction: rtl; text-align: left; } + + [data-slot="message-part-filename"] { + color: var(--text-strong); + flex-shrink: 0; + } + + [data-slot="message-part-actions"] { + display: flex; + gap: 16px; + align-items: center; + justify-content: flex-end; + } } [data-component="edit-content"] { @@ -612,17 +617,6 @@ } } -[data-slot="webfetch-meta"] { - min-width: 0; - display: inline-flex; - align-items: center; - gap: 8px; - - [data-component="tool-action"] { - flex-shrink: 0; - } -} - [data-component="todos"] { padding: 10px 0 24px 0; display: flex; @@ -645,6 +639,7 @@ } [data-component="context-tool-group-trigger"] { + width: 100%; min-height: 24px; display: flex; align-items: center; @@ -652,352 +647,28 @@ gap: 0px; cursor: pointer; - &[data-pending] { - cursor: default; - } - [data-slot="context-tool-group-title"] { flex-shrink: 1; min-width: 0; } -} -/* Prevent the trigger content from stretching full-width so the arrow sits after the text */ -[data-slot="basic-tool-tool-trigger-content"]:has([data-component="context-tool-group-trigger"]) { - width: auto; - flex: 0 1 auto; - - [data-slot="basic-tool-tool-info"] { - flex: 0 1 auto; - } -} - -[data-component="context-tool-step"] { - width: 100%; - min-width: 0; - padding-left: 12px; -} - -[data-component="context-tool-expanded-list"] { - display: flex; - flex-direction: column; - padding: 4px 0 4px 12px; - max-height: 200px; - overflow-y: auto; - overscroll-behavior: contain; - scrollbar-width: none; - -ms-overflow-style: none; - -webkit-mask-repeat: no-repeat; - mask-repeat: no-repeat; - - &::-webkit-scrollbar { - display: none; - } -} - -[data-component="context-tool-expanded-row"] { - display: flex; - align-items: center; - gap: 6px; - min-width: 0; - height: 22px; - flex-shrink: 0; - white-space: nowrap; - overflow: hidden; - - [data-slot="context-tool-expanded-action"] { - flex-shrink: 0; - font-size: var(--font-size-base); - font-weight: 500; - color: var(--text-base); - } - - [data-slot="context-tool-expanded-detail"] { - flex-shrink: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - font-size: var(--font-size-base); - color: var(--text-base); - opacity: 0.75; - } -} - -[data-component="context-tool-rolling-row"] { - display: inline-flex; - align-items: center; - gap: 6px; - width: 100%; - min-width: 0; - white-space: nowrap; - overflow: hidden; - padding-left: 12px; - - [data-slot="context-tool-rolling-action"] { - flex-shrink: 0; - font-size: var(--font-size-base); - font-weight: 500; - color: var(--text-base); - } - - [data-slot="context-tool-rolling-detail"] { - flex-shrink: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - font-size: var(--font-size-base); - color: var(--text-weak); - } -} - -[data-component="shell-rolling-results"] { - width: 100%; - min-width: 0; - display: flex; - flex-direction: column; - - [data-slot="shell-rolling-header-clip"] { - &:hover [data-slot="shell-rolling-actions"] { - opacity: 1; - } - - &[data-clickable="true"] { - cursor: pointer; - } - } - - [data-slot="shell-rolling-header"] { - display: inline-flex; - align-items: center; - gap: 8px; - min-width: 0; - max-width: 100%; - height: 37px; - box-sizing: border-box; - } - - [data-slot="shell-rolling-title"] { - flex-shrink: 0; - font-family: var(--font-family-sans); - font-size: 14px; - font-style: normal; - font-weight: var(--font-weight-medium); - line-height: var(--line-height-large); - letter-spacing: var(--letter-spacing-normal); - color: var(--text-strong); - } - - [data-slot="shell-rolling-subtitle"] { - flex: 0 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-family: var(--font-family-sans); - font-size: 14px; - font-weight: var(--font-weight-normal); - line-height: var(--line-height-large); - color: var(--text-weak); - } - - [data-slot="shell-rolling-actions"] { - flex-shrink: 0; - display: inline-flex; - align-items: center; - gap: 2px; - opacity: 0; - transition: opacity 0.15s ease; - } - - .shell-rolling-copy { - border: none !important; - outline: none !important; - box-shadow: none !important; - background: transparent !important; - - [data-slot="icon-svg"] { - color: var(--icon-weaker); - } - - &:hover:not(:disabled) { - background: color-mix(in srgb, var(--text-base) 8%, transparent) !important; - box-shadow: 0 0 0 1px color-mix(in srgb, var(--icon-weaker) 40%, transparent) !important; - border-radius: var(--radius-sm); - - [data-slot="icon-svg"] { - color: var(--icon-base); - } - } - } - - [data-slot="shell-rolling-arrow"] { - display: inline-flex; - align-items: center; - justify-content: center; - color: var(--icon-weaker); - transform: rotate(-90deg); - transition: transform 0.15s ease; - } - - [data-slot="shell-rolling-arrow"][data-open="true"] { - transform: rotate(0deg); - } -} - -[data-component="shell-rolling-output"] { - width: 100%; - min-width: 0; -} - -[data-slot="shell-rolling-preview"] { - width: 100%; - min-width: 0; -} - -[data-component="shell-expanded-output"] { - width: 100%; - max-width: 100%; - overflow-y: auto; - overflow-x: hidden; - scrollbar-width: none; - -ms-overflow-style: none; - - &::-webkit-scrollbar { - display: none; - } -} - -[data-component="shell-expanded-shell"] { - position: relative; - width: 100%; - min-width: 0; - border: 1px solid var(--border-weak-base); - border-radius: 6px; - background: transparent; - overflow: hidden; -} - -[data-slot="shell-expanded-body"] { - position: relative; - width: 100%; - min-width: 0; -} - -[data-slot="shell-expanded-top"] { - position: relative; - width: 100%; - min-width: 0; - padding: 9px 44px 9px 16px; - box-sizing: border-box; -} - -[data-slot="shell-expanded-command"] { - display: flex; - align-items: flex-start; - gap: 8px; - width: 100%; - min-width: 0; - font-family: var(--font-family-mono); - font-feature-settings: var(--font-family-mono--font-feature-settings); - font-size: 13px; - line-height: 1.45; -} - -[data-slot="shell-expanded-prompt"] { - flex-shrink: 0; - color: var(--text-weaker); -} - -[data-slot="shell-expanded-input"] { - min-width: 0; - color: var(--text-strong); - white-space: pre-wrap; - overflow-wrap: anywhere; -} - -[data-slot="shell-expanded-actions"] { - position: absolute; - top: 50%; - right: 8px; - z-index: 1; - transform: translateY(-50%); -} - -.shell-expanded-copy { - border: none !important; - outline: none !important; - box-shadow: none !important; - background: transparent !important; - - [data-slot="icon-svg"] { + [data-slot="collapsible-arrow"] { color: var(--icon-weaker); } +} - &:hover:not(:disabled) { - background: color-mix(in srgb, var(--text-base) 8%, transparent) !important; - box-shadow: 0 0 0 1px color-mix(in srgb, var(--icon-weaker) 40%, transparent) !important; - border-radius: var(--radius-sm); +[data-component="context-tool-group-list"] { + padding: 6px 0 4px 0; + display: flex; + flex-direction: column; + gap: 2px; - [data-slot="icon-svg"] { - color: var(--icon-base); - } + [data-slot="context-tool-group-item"] { + min-width: 0; + padding: 6px 0; } } -[data-slot="shell-expanded-divider"] { - width: 100%; - height: 1px; - background: var(--border-weak-base); -} - -[data-slot="shell-expanded-pre"] { - margin: 0; - padding: 12px 16px; - white-space: pre-wrap; - overflow-wrap: anywhere; - - code { - font-family: var(--font-family-mono); - font-feature-settings: var(--font-family-mono--font-feature-settings); - font-size: 13px; - line-height: 1.45; - color: var(--text-base); - } -} - -[data-component="shell-rolling-command"], -[data-component="shell-rolling-row"] { - display: inline-flex; - align-items: center; - width: 100%; - min-width: 0; - overflow: hidden; - white-space: pre; - padding-left: 12px; -} - -[data-slot="shell-rolling-text"] { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - font-family: var(--font-family-mono); - font-feature-settings: var(--font-family-mono--font-feature-settings); - font-size: var(--font-size-small); - line-height: var(--line-height-large); -} - -[data-component="shell-rolling-command"] [data-slot="shell-rolling-text"] { - color: var(--text-base); -} - -[data-component="shell-rolling-command"] [data-slot="shell-rolling-prompt"] { - color: var(--text-weaker); -} - -[data-component="shell-rolling-row"] [data-slot="shell-rolling-text"] { - color: var(--text-weak); -} - [data-component="diagnostics"] { display: flex; flex-direction: column; @@ -1058,30 +729,6 @@ width: 100%; } -[data-slot="assistant-part-grow"] { - width: 100%; - min-width: 0; - overflow: visible; -} - -[data-component="tool-part-wrapper"][data-tool="bash"] { - [data-component="tool-trigger"] { - width: auto; - max-width: 100%; - } - - [data-slot="basic-tool-tool-info-main"] { - align-items: center; - } - - [data-slot="basic-tool-tool-title"], - [data-slot="basic-tool-tool-subtitle"] { - display: inline-flex; - align-items: center; - line-height: var(--line-height-large); - } -} - [data-component="dock-prompt"][data-kind="permission"] { position: relative; display: flex; @@ -1540,7 +1187,8 @@ position: sticky; top: var(--sticky-accordion-top, 0px); z-index: 20; - height: 37px; + height: 40px; + padding-bottom: 8px; background-color: var(--background-stronger); } } @@ -1551,12 +1199,11 @@ } [data-slot="apply-patch-trigger-content"] { - display: inline-flex; + display: flex; align-items: center; - justify-content: flex-start; - max-width: 100%; - min-width: 0; - gap: 8px; + justify-content: space-between; + width: 100%; + gap: 20px; } [data-slot="apply-patch-file-info"] { @@ -1590,9 +1237,9 @@ [data-slot="apply-patch-trigger-actions"] { flex-shrink: 0; display: flex; - gap: 8px; + gap: 16px; align-items: center; - justify-content: flex-start; + justify-content: flex-end; } [data-slot="apply-patch-change"] { @@ -1632,11 +1279,10 @@ } [data-component="tool-loaded-file"] { - min-width: 0; display: flex; align-items: center; gap: 8px; - padding: 4px 0 4px 12px; + padding: 4px 0 4px 28px; font-family: var(--font-family-sans); font-size: var(--font-size-small); font-weight: var(--font-weight-regular); @@ -1647,11 +1293,4 @@ 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 d82121159..45b174e2b 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1,6 +1,18 @@ -import { Component, createEffect, createMemo, createSignal, For, Match, on, Show, Switch, type JSX } from "solid-js" +import { + Component, + createEffect, + createMemo, + createSignal, + For, + Match, + onMount, + Show, + Switch, + onCleanup, + Index, + type JSX, +} from "solid-js" import stripAnsi from "strip-ansi" -import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web" import { AgentPart, @@ -20,10 +32,11 @@ import { useData } from "../context" import { useFileComponent } from "../context/file" import { useDialog } from "../context/dialog" import { type UiI18n, useI18n } from "../context/i18n" -import { GenericTool, ToolCall } from "./basic-tool" +import { BasicTool, GenericTool } from "./basic-tool" import { Accordion } from "./accordion" import { StickyAccordionHeader } from "./sticky-accordion-header" import { Card } from "./card" +import { Collapsible } from "./collapsible" import { FileIcon } from "./file-icon" import { Icon } from "./icon" import { Checkbox } from "./checkbox" @@ -35,12 +48,43 @@ import { checksum } from "@opencode-ai/util/encode" import { Tooltip } from "./tooltip" import { IconButton } from "./icon-button" import { TextShimmer } from "./text-shimmer" -import { list } from "./text-utils" -import { GrowBox } from "./grow-box" -import { COLLAPSIBLE_SPRING } from "./motion" -import { busy, createThrottledValue, useToolFade, useContextToolPending } from "./tool-utils" -import { ContextToolGroupHeader, ContextToolExpandedList, ContextToolRollingResults } from "./context-tool-results" -import { ShellRollingResults } from "./shell-rolling-results" +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} + + + + + ) +} interface Diagnostic { range: { @@ -81,22 +125,64 @@ 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 - showTurnDiffSummary?: boolean - turnDiffSummary?: () => JSX.Element - animate?: boolean - working?: boolean + turnDurationMs?: number } 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 @@ -228,8 +314,7 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo { case "skill": return { icon: "brain", - title: i18n.t("ui.tool.skill"), - subtitle: typeof input.name === "string" ? input.name : undefined, + title: input.name || "skill", } default: return { @@ -254,22 +339,105 @@ function urls(text: string | undefined) { const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"]) const HIDDEN_TOOLS = new Set(["todowrite", "todoread"]) -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 +function list(value: T[] | undefined | null, fallback: T[]) { + if (Array.isArray(value)) return value + return fallback +} + +function same(a: readonly T[] | undefined, b: readonly T[] | undefined) { + if (a === b) return true + if (!a || !b) return false + if (a.length !== b.length) return false + return a.every((x, i) => x === b[i]) +} + +type PartRef = { + messageID: string + partID: string +} + +type PartGroup = + | { + key: string + type: "part" + ref: PartRef + } + | { + key: string + type: "context" + refs: PartRef[] + } + +function sameRef(a: PartRef, b: PartRef) { + return a.messageID === b.messageID && a.partID === b.partID +} + +function sameGroup(a: PartGroup, b: PartGroup) { + if (a === b) return true + if (a.key !== b.key) return false + if (a.type !== b.type) return false + if (a.type === "part") { + if (b.type !== "part") return false + return sameRef(a.ref, b.ref) } - const controlled = (key?: string) => { - if (!key) return false - return state[key] !== undefined + 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 write = (key: string, value: boolean) => { - setState(key, value) - } - return { read, controlled, write } + + parts.forEach((item, index) => { + if (isContextGroupTool(item.part)) { + if (start < 0) start = index + return + } + + flush(index - 1) + result.push({ + key: `part:${item.messageID}:${item.part.id}`, + type: "part", + ref: { + messageID: item.messageID, + partID: item.part.id, + }, + }) + }) + + flush(parts.length - 1) + return result +} + +function partByID(parts: readonly PartType[], partID: string) { + return parts.find((part) => part.id === partID) } function renderable(part: PartType, showReasoningSummaries = true) { @@ -285,8 +453,7 @@ function renderable(part: PartType, showReasoningSummaries = true) { function toolDefaultOpen(tool: string, shell = false, edit = false) { if (tool === "bash") return shell - if (tool === "edit" || tool === "write") return edit - if (tool === "apply_patch") return false + if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit } function partDefaultOpen(part: PartType, shell = false, edit = false) { @@ -294,323 +461,98 @@ 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 - showTurnDiffSummary?: boolean - turnDiffSummary?: () => JSX.Element + turnDurationMs?: number working?: boolean showReasoningSummaries?: boolean shellToolDefaultOpen?: boolean editToolDefaultOpen?: boolean - animate?: boolean }) { const data = useData() const emptyParts: PartType[] = [] - const groupState = createGroupOpenState() - const grouped = createMemo(() => { - const keys: string[] = [] - const items: Record< - string, - | { - type: "part" - part: PartType - message: AssistantMessage - 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 emptyTools: ToolPart[] = [] - let start = -1 + const grouped = createMemo( + () => + groupParts( + props.messages.flatMap((message) => + list(data.store.part?.[message.id], emptyParts) + .filter((part) => renderable(part, props.showReasoningSummaries ?? true)) + .map((part) => ({ + messageID: message.id, + part, + })), + ), + ), + [] as PartGroup[], + { equals: sameGroups }, + ) - const 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)) + const last = createMemo(() => grouped().at(-1)?.key) return ( -
- - {(key) => { - const item = createMemo(() => grouped().items[key]) - const ctx = createMemo(() => { - const value = item() - if (!value) return - if (value.type !== "context") return - return value - }) - const part = createMemo(() => { - const value = item() - if (!value) return - if (value.type !== "part") return - return value - }) - const 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 value = ctx() - if (value) return groupState.read(value.groupKey, true) - return groupState.read(part()?.groupKey, true) - }) - const visible = createMemo(() => { - if (!context()) return true - if (ctx()) return true - return false - }) + + {(entryAccessor) => { + const entryType = createMemo(() => entryAccessor().type) - 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 ctxPending = useContextToolPending(ctxParts, () => !!(props.working && ctx()?.tail)) - 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) => ( - - -
- -
-
+ 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 ( + + + - )} - -
-
- ) - }} -
-
+ + ) + })()} + + + ) + }} + ) } @@ -618,6 +560,76 @@ 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)) @@ -648,11 +660,210 @@ 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() @@ -702,9 +913,14 @@ export function UserMessageDisplay(props: { return `${hour12}:${minute} ${hours < 12 ? "AM" : "PM"}` }) - const userMeta = createMemo(() => { + const metaHead = createMemo(() => { const agent = props.message.agent - const items = [agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", model(), stamp()] + 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") : ""] return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0") }) @@ -721,83 +937,93 @@ 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 +
-
- + )} + +
+
+ + <> +
+
+ +
+ +
+ +
+
+
+
+ + + - {userMeta()} + {metaHead()} - - e.preventDefault()} - onClick={(event) => { - event.stopPropagation() - handleCopy() - }} - aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} - /> - -
- -
-
-
- + + + {"\u00A0\u00B7\u00A0"} + + + + + {metaTail()} + + + +
+ + e.preventDefault()} + onClick={(event) => { + event.stopPropagation() + handleCopy() + }} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} + /> + +
+ + +
) } @@ -851,10 +1077,7 @@ export function Part(props: MessagePartProps) { hideDetails={props.hideDetails} defaultOpen={props.defaultOpen} showAssistantCopyPartID={props.showAssistantCopyPartID} - showTurnDiffSummary={props.showTurnDiffSummary} - turnDiffSummary={props.turnDiffSummary} - animate={props.animate} - working={props.working} + turnDurationMs={props.turnDurationMs} /> ) @@ -864,16 +1087,12 @@ 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 @@ -907,7 +1126,7 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre @@ -938,26 +1157,30 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre PART_MAPPING["tool"] = function ToolPartDisplay(props) { const i18n = useI18n() - const part = props.part as ToolPart - const hideQuestion = createMemo(() => part.tool === "question" && busy(part.state.status)) + 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 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 (
@@ -991,17 +1214,13 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { @@ -1026,16 +1245,74 @@ 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 summary = createMemo(() => { - if (props.message.role !== "assistant") return - if (!props.showTurnDiffSummary) return - if (props.showAssistantCopyPartID !== part().id) return - return props.turnDiffSummary + const isLastTextPart = createMemo(() => { + const last = (data.store.part?.[props.message.id] ?? []) + .filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim()) + .at(-1) + return last?.id === part().id }) + const showCopy = createMemo(() => { + if (props.message.role !== "assistant") return isLastTextPart() + if (props.showAssistantCopyPartID === null) return false + if (typeof props.showAssistantCopyPartID === "string") return props.showAssistantCopyPartID === part().id + return isLastTextPart() + }) + const [copied, setCopied] = createSignal(false) + + const handleCopy = async () => { + const content = displayText() + if (!content) return + await navigator.clipboard.writeText(content) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } return ( @@ -1043,12 +1320,28 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
- - {(render) => ( - -
{render()()}
-
- )} + +
+ + e.preventDefault()} + onClick={handleCopy} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} + /> + + + + {meta()} + + +
@@ -1078,33 +1371,30 @@ 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 ( <> - - } + trigger={{ + title: i18n.t("ui.tool.read"), + subtitle: props.input.filePath ? getFilename(props.input.filePath) : "", + args, + }} /> {(filepath) => ( - +
+ + + {i18n.t("ui.tool.loaded")} {relativizeProjectPath(filepath, data.directory)} + +
)}
@@ -1116,29 +1406,18 @@ ToolRegistry.register({ name: "list", render(props) { const i18n = useI18n() - const pending = createMemo(() => busy(props.status)) return ( - - } + trigger={{ title: i18n.t("ui.tool.list"), subtitle: getDirectory(props.input.path || "/") }} > - {(output) => ( -
- -
- )} +
+ +
-
+ ) }, }) @@ -1147,30 +1426,22 @@ ToolRegistry.register({ name: "glob", render(props) { const i18n = useI18n() - const pending = createMemo(() => busy(props.status)) return ( - - -
- )} +
+ +
- + ) }, }) @@ -1182,214 +1453,40 @@ 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 ( - - } + trigger={{ + title: i18n.t("ui.tool.grep"), + subtitle: getDirectory(props.input.path || "/"), + args, + }} > - {(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(() => busy(props.status)) - const reveal = useToolReveal(pending, () => props.reveal !== false) + const pending = createMemo(() => props.status === "pending" || props.status === "running") const url = createMemo(() => { const value = props.input.url if (typeof value !== "string") return "" return value }) return ( - @@ -1397,8 +1494,24 @@ ToolRegistry.register({ - {(value) => } + + event.stopPropagation()} + > + {url()} + +
+ +
+ +
+
} /> @@ -1417,8 +1530,7 @@ ToolRegistry.register({ }) return ( - - + ) }, }) @@ -1444,8 +1556,7 @@ ToolRegistry.register({ }) return ( - - + ) }, }) @@ -1465,6 +1576,7 @@ ToolRegistry.register({ render(props) { const data = useData() const i18n = useI18n() + const location = useLocation() const childSessionId = () => props.metadata.sessionId as string | undefined const type = createMemo(() => { const raw = props.input.subagent_type @@ -1477,8 +1589,7 @@ ToolRegistry.register({ if (typeof value === "string") return value return undefined }) - const running = createMemo(() => busy(props.status)) - const reveal = useToolReveal(running, () => props.reveal !== false) + const running = createMemo(() => props.status === "pending" || props.status === "running") const href = createMemo(() => { const sessionId = childSessionId() @@ -1487,49 +1598,34 @@ ToolRegistry.register({ const direct = data.sessionHref?.(sessionId) if (direct) return direct - if (typeof window === "undefined") return - const path = window.location.pathname + const path = location.pathname const idx = path.indexOf("/session") if (idx === -1) return return `${path.slice(0, idx)}/session/${sessionId}` }) - const handleLinkClick = (e: MouseEvent) => { - const sessionId = childSessionId() - const url = href() - if (!sessionId || !url) return - - e.stopPropagation() - - if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return - - const nav = data.navigateToSession - if (!nav || typeof window === "undefined") return - - e.preventDefault() - const before = window.location.pathname + window.location.search + window.location.hash - nav(sessionId) - setTimeout(() => { - const after = window.location.pathname + window.location.search + window.location.hash - if (after === before) window.location.assign(url) - }, 50) - } + const titleContent = () => const trigger = () => (
- - + + {titleContent()} - {(url) => ( - - )} + e.stopPropagation()} + > + {description()} + - + {description()} @@ -1537,7 +1633,7 @@ ToolRegistry.register({
) - return + return }, }) @@ -1545,26 +1641,13 @@ ToolRegistry.register({ name: "bash", render(props) { const i18n = useI18n() - 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 pending = () => props.status === "pending" || props.status === "running" + const sawPending = pending() const text = createMemo(() => { - const value = result() - return `${command()}${value ? "\n\n" + value : ""}` + const cmd = props.input.command ?? props.metadata.command ?? "" + const out = stripAnsi(props.output || props.metadata.output || "") + return `$ ${cmd}${out ? "\n\n" + out : ""}` }) - const hasOutput = createMemo(() => result().length > 0) const [copied, setCopied] = createSignal(false) const handleCopy = async () => { @@ -1576,20 +1659,18 @@ ToolRegistry.register({ } return ( -
- {(text) => } + + +
} @@ -1617,7 +1698,7 @@ ToolRegistry.register({ - + ) }, }) @@ -1630,12 +1711,10 @@ 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 = () => busy(props.status) - const reveal = useToolReveal(pending, () => props.reveal !== false) + const pending = () => props.status === "pending" || props.status === "running" return (
- - - {(name) => ( - - )} + + {filename()}
+ +
+ {getDirectory(props.input.filePath!)} +
+
+ +
+ + +
} @@ -1666,7 +1748,7 @@ ToolRegistry.register({ path={path()} actions={ - {(diff) => } + } > @@ -1687,7 +1769,7 @@ ToolRegistry.register({ - + ) }, @@ -1701,12 +1783,10 @@ 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 = () => busy(props.status) - const reveal = useToolReveal(pending, () => props.reveal !== false) + const pending = () => props.status === "pending" || props.status === "running" return (
- - - {(name) => ( - - )} + + {filename()}
+ +
+ {getDirectory(props.input.filePath!)} +
+
+
{/* */}
} > @@ -1748,7 +1828,7 @@ ToolRegistry.register({ - + ) }, @@ -1772,8 +1852,7 @@ ToolRegistry.register({ const i18n = useI18n() const fileComponent = useFileComponent() const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[]) - const pending = createMemo(() => busy(props.status)) - const reveal = useToolReveal(pending, () => props.reveal !== false) + const pending = createMemo(() => props.status === "pending" || props.status === "running") const single = createMemo(() => { const list = files() if (list.length !== 1) return @@ -1781,6 +1860,7 @@ ToolRegistry.register({ }) const [expanded, setExpanded] = createSignal([]) let seeded = false + createEffect(() => { const list = files() if (list.length === 0) return @@ -1788,6 +1868,7 @@ 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 "" @@ -1795,44 +1876,24 @@ ToolRegistry.register({ }) return ( -
- -
-
- - - - - {(file) => ( - - )} - - {(text) => } -
-
-
- } - > - + 0}> setExpanded(Array.isArray(value) ? value : value ? [value] : [])} > @@ -1840,11 +1901,13 @@ 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) @@ -1909,50 +1972,77 @@ ToolRegistry.register({ + + + } + > +
+ +
+
+ + + + + {getFilename(single()!.relativePath)} + +
+ +
+ {getDirectory(single()!.relativePath)} +
+
+
+
+ + + +
+
} > - {(file) => ( - - - - {i18n.t("ui.patch.action.created")} - - - - - {i18n.t("ui.patch.action.deleted")} - - - - - {i18n.t("ui.patch.action.moved")} - - - - - - - } - > -
- -
-
- )} -
- - + + + + {i18n.t("ui.patch.action.created")} + + + + + {i18n.t("ui.patch.action.deleted")} + + + + + {i18n.t("ui.patch.action.moved")} + + + + + + + } + > +
+ +
+
+ + + ) }, }) @@ -1970,7 +2060,6 @@ ToolRegistry.register({ return [] }) - const pending = createMemo(() => busy(props.status)) const subtitle = createMemo(() => { const list = todos() @@ -1979,19 +2068,14 @@ ToolRegistry.register({ }) return ( - - } + trigger={{ + title: i18n.t("ui.tool.todos"), + subtitle: subtitle(), + }} >
@@ -2009,7 +2093,7 @@ ToolRegistry.register({
-
+ ) }, }) @@ -2021,7 +2105,6 @@ 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 @@ -2031,19 +2114,14 @@ ToolRegistry.register({ }) return ( - - } + trigger={{ + title: i18n.t("ui.tool.questions"), + subtitle: subtitle(), + }} >
@@ -2060,7 +2138,7 @@ ToolRegistry.register({
-
+ ) }, }) @@ -2068,28 +2146,21 @@ ToolRegistry.register({ ToolRegistry.register({ name: "skill", render(props) { - 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 - /> + const title = createMemo(() => props.input.name || "skill") + const running = createMemo(() => props.status === "pending" || props.status === "running") + + const titleContent = () => + + const trigger = () => ( +
+
+ + {titleContent()} + +
+
) + + return }, }) diff --git a/packages/ui/src/components/motion-spring.tsx b/packages/ui/src/components/motion-spring.tsx index c7ff1fbcd..a5104a1a3 100644 --- a/packages/ui/src/components/motion-spring.tsx +++ b/packages/ui/src/components/motion-spring.tsx @@ -1,9 +1,8 @@ import { attachSpring, motionValue } from "motion" import type { SpringOptions } from "motion" import { createEffect, createSignal, onCleanup } from "solid-js" -import { useReducedMotion } from "../hooks/use-reduced-motion" -type Opt = Pick +type Opt = Partial> const eq = (a: Opt | undefined, b: Opt | undefined) => a?.visualDuration === b?.visualDuration && a?.bounce === b?.bounce && @@ -14,41 +13,24 @@ const eq = (a: Opt | undefined, b: Opt | undefined) => export function useSpring(target: () => number, options?: Opt | (() => Opt)) { const read = () => (typeof options === "function" ? options() : options) - const reduce = useReducedMotion() const [value, setValue] = createSignal(target()) const source = motionValue(value()) const spring = motionValue(value()) let config = read() - let reduced = reduce() - let stop = reduced ? () => {} : attachSpring(spring, source, config) - let off = spring.on("change", (next) => setValue(next)) + let stop = attachSpring(spring, source, config) + let off = spring.on("change", (next: number) => setValue(next)) createEffect(() => { - const next = target() - if (reduced) { - source.set(next) - spring.set(next) - setValue(next) - return - } - source.set(next) + source.set(target()) }) createEffect(() => { + if (!options) return const next = read() - const skip = reduce() - if (eq(config, next) && reduced === skip) return + if (eq(config, next)) return config = next - reduced = skip stop() - stop = skip ? () => {} : attachSpring(spring, source, next) - if (skip) { - const value = target() - source.set(value) - spring.set(value) - setValue(value) - return - } + stop = attachSpring(spring, source, next) setValue(spring.get()) }) diff --git a/packages/ui/src/components/motion.tsx b/packages/ui/src/components/motion.tsx deleted file mode 100644 index 6cdf01c73..000000000 --- a/packages/ui/src/components/motion.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { followValue } from "motion" -import type { MotionValue } from "motion" - -export { animate, springValue } from "motion" -export type { AnimationPlaybackControls } from "motion" - -/** - * Like `springValue` but preserves getters on the config object. - * `springValue` spreads config at creation, snapshotting getter values. - * This passes the config through to `followValue` intact, so getters - * on `visualDuration` etc. fire on every `.set()` call. - */ -export function tunableSpringValue(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 deleted file mode 100644 index 200b2a97e..000000000 --- a/packages/ui/src/components/rolling-results.css +++ /dev/null @@ -1,92 +0,0 @@ -[data-component="rolling-results"] { - --rolling-results-row-height: 22px; - --rolling-results-fixed-height: var(--rolling-results-row-height); - --rolling-results-fixed-gap: 0px; - --rolling-results-row-gap: 0px; - - display: block; - width: 100%; - min-width: 0; - - [data-slot="rolling-results-viewport"] { - position: relative; - min-width: 0; - height: 0; - overflow: clip; - } - - &[data-overflowing="true"]:not([data-scrollable="true"]) [data-slot="rolling-results-window"] { - mask-image: linear-gradient( - to bottom, - transparent 0%, - black var(--rolling-results-fade), - black calc(100% - calc(var(--rolling-results-fade) * 0.5)), - transparent 100% - ); - -webkit-mask-image: linear-gradient( - to bottom, - transparent 0%, - black var(--rolling-results-fade), - black calc(100% - calc(var(--rolling-results-fade) * 0.5)), - transparent 100% - ); - } - - [data-slot="rolling-results-fixed"] { - min-width: 0; - height: var(--rolling-results-fixed-height); - min-height: var(--rolling-results-fixed-height); - display: flex; - align-items: center; - } - - [data-slot="rolling-results-window"] { - min-width: 0; - margin-top: var(--rolling-results-fixed-gap); - height: calc(100% - var(--rolling-results-fixed-height) - var(--rolling-results-fixed-gap)); - overflow: clip; - } - - &[data-scrollable="true"] [data-slot="rolling-results-window"] { - scrollbar-width: none; - -ms-overflow-style: none; - - &::-webkit-scrollbar { - display: none; - } - } - - &[data-scrollable="true"] [data-slot="rolling-results-track"] { - transform: none !important; - will-change: auto; - } - - [data-slot="rolling-results-body"] { - min-width: 0; - } - - [data-slot="rolling-results-track"] { - display: flex; - min-width: 0; - flex-direction: column; - gap: var(--rolling-results-row-gap); - will-change: transform; - } - - [data-slot="rolling-results-row"], - [data-slot="rolling-results-empty"] { - min-width: 0; - height: var(--rolling-results-row-height); - min-height: var(--rolling-results-row-height); - display: flex; - align-items: center; - } - - [data-slot="rolling-results-row"] { - color: var(--text-base); - } - - [data-slot="rolling-results-empty"] { - color: var(--text-weaker); - } -} diff --git a/packages/ui/src/components/rolling-results.tsx b/packages/ui/src/components/rolling-results.tsx deleted file mode 100644 index 77ffdb1b3..000000000 --- a/packages/ui/src/components/rolling-results.tsx +++ /dev/null @@ -1,325 +0,0 @@ -import { For, Show, batch, createEffect, createMemo, createSignal, on, onCleanup, onMount, type JSX } from "solid-js" -import { useReducedMotion } from "../hooks/use-reduced-motion" -import { animate, clearMaskStyles, GROW_SPRING, type AnimationPlaybackControls, type SpringConfig } from "./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 reduce = useReducedMotion() - - 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) && !reduce()) - 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 a8574cc9f..f6a49e241 100644 --- a/packages/ui/src/components/scroll-view.css +++ b/packages/ui/src/components/scroll-view.css @@ -9,13 +9,6 @@ overflow-y: auto; scrollbar-width: none; outline: none; - display: block; - overflow-anchor: none; -} - -.scroll-view__viewport[data-reverse="true"] { - display: flex; - flex-direction: column-reverse; } .scroll-view__viewport::-webkit-scrollbar { @@ -52,6 +45,18 @@ background-color: var(--border-strong-base); } +.dark .scroll-view__thumb::after, +[data-theme="dark"] .scroll-view__thumb::after { + background-color: var(--border-weak-base); +} + +.dark .scroll-view__thumb:hover::after, +[data-theme="dark"] .scroll-view__thumb:hover::after, +.dark .scroll-view__thumb[data-dragging="true"]::after, +[data-theme="dark"] .scroll-view__thumb[data-dragging="true"]::after { + background-color: var(--border-strong-base); +} + .scroll-view__thumb[data-visible="true"] { opacity: 1; } diff --git a/packages/ui/src/components/scroll-view.tsx b/packages/ui/src/components/scroll-view.tsx index a8d3cf0f8..52ed39a46 100644 --- a/packages/ui/src/components/scroll-view.tsx +++ b/packages/ui/src/components/scroll-view.tsx @@ -1,18 +1,17 @@ -import { createSignal, onCleanup, onMount, splitProps, type ComponentProps, Show } from "solid-js" -import { animate, type AnimationPlaybackControls } from "motion" +import { createSignal, onCleanup, onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js" import { useI18n } from "../context/i18n" -import { FAST_SPRING } from "./motion" export interface ScrollViewProps extends ComponentProps<"div"> { viewportRef?: (el: HTMLDivElement) => void - reverse?: boolean + orientation?: "vertical" | "horizontal" // currently only vertical is fully implemented for thumb } export function ScrollView(props: ScrollViewProps) { const i18n = useI18n() + const merged = mergeProps({ orientation: "vertical" }, props) const [local, events, rest] = splitProps( - props, - ["class", "children", "viewportRef", "style", "reverse"], + merged, + ["class", "children", "viewportRef", "orientation", "style"], [ "onScroll", "onWheel", @@ -26,9 +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) @@ -37,8 +36,6 @@ export function ScrollView(props: ScrollViewProps) { const [thumbTop, setThumbTop] = createSignal(0) const [showThumb, setShowThumb] = createSignal(false) - const reverse = () => local.reverse === true - const updateThumb = () => { if (!viewportRef) return const { scrollTop, scrollHeight, clientHeight } = viewportRef @@ -60,13 +57,9 @@ export function ScrollView(props: ScrollViewProps) { const maxScrollTop = scrollHeight - clientHeight const maxThumbTop = trackHeight - height - const top = (() => { - if (maxScrollTop <= 0) return 0 - if (!reverse()) return (scrollTop / maxScrollTop) * maxThumbTop - return ((maxScrollTop + scrollTop) / maxScrollTop) * maxThumbTop - })() + const top = maxScrollTop > 0 ? (scrollTop / maxScrollTop) * maxThumbTop : 0 - // Ensure thumb stays within bounds + // Ensure thumb stays within bounds (shouldn't be necessary due to math above, but good for safety) const boundedTop = trackPadding + Math.max(0, Math.min(top, maxThumbTop)) setThumbHeight(height) @@ -89,7 +82,6 @@ export function ScrollView(props: ScrollViewProps) { } onCleanup(() => { - stop() observer.disconnect() }) @@ -131,31 +123,6 @@ export function ScrollView(props: ScrollViewProps) { thumbRef.addEventListener("pointerup", onPointerUp) } - const stop = () => { - if (!anim) return - anim.stop() - anim = undefined - } - - const limit = (top: number) => { - const max = viewportRef.scrollHeight - viewportRef.clientHeight - if (reverse()) return Math.max(-max, Math.min(0, top)) - return Math.max(0, Math.min(max, top)) - } - - const glide = (top: number) => { - stop() - anim = animate(viewportRef.scrollTop, limit(top), { - ...FAST_SPRING, - onUpdate: (v) => { - viewportRef.scrollTop = v - }, - onComplete: () => { - anim = undefined - }, - }) - } - // Keybinds implementation // We ensure the viewport has a tabindex so it can receive focus // We can also explicitly catch PageUp/Down if we want smooth scroll or specific behavior, @@ -180,11 +147,11 @@ export function ScrollView(props: ScrollViewProps) { break case "Home": e.preventDefault() - glide(reverse() ? -(viewportRef.scrollHeight - viewportRef.clientHeight) : 0) + viewportRef.scrollTo({ top: 0, behavior: "smooth" }) break case "End": e.preventDefault() - glide(reverse() ? 0 : viewportRef.scrollHeight - viewportRef.clientHeight) + viewportRef.scrollTo({ top: viewportRef.scrollHeight, behavior: "smooth" }) break case "ArrowUp": e.preventDefault() @@ -199,6 +166,7 @@ export function ScrollView(props: ScrollViewProps) { return (
setIsHovered(true)} @@ -209,26 +177,16 @@ export function ScrollView(props: ScrollViewProps) {
{ updateThumb() if (typeof events.onScroll === "function") events.onScroll(e as any) }} - onWheel={(e) => { - if (e.deltaY) stop() - if (typeof events.onWheel === "function") events.onWheel(e as any) - }} - onTouchStart={(e) => { - stop() - if (typeof events.onTouchStart === "function") events.onTouchStart(e as any) - }} + onWheel={events.onWheel as any} + onTouchStart={events.onTouchStart as any} onTouchMove={events.onTouchMove as any} onTouchEnd={events.onTouchEnd as any} onTouchCancel={events.onTouchCancel as any} - onPointerDown={(e) => { - stop() - if (typeof events.onPointerDown === "function") events.onPointerDown(e as any) - }} + onPointerDown={events.onPointerDown as any} onClick={events.onClick as any} tabIndex={0} role="region" diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index 56e060633..eea9a13e4 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -1,4 +1,5 @@ [data-component="session-turn"] { + --sticky-header-height: calc(var(--session-title-height, 0px) + 24px); height: 100%; min-height: 0; min-width: 0; @@ -25,7 +26,7 @@ align-items: flex-start; align-self: stretch; min-width: 0; - gap: 0px; + gap: 18px; overflow-anchor: none; } @@ -42,127 +43,30 @@ align-self: stretch; } - [data-slot="session-turn-assistant-lane"] { - width: 100%; - min-width: 0; - display: flex; - flex-direction: column; - align-self: stretch; - } - [data-slot="session-turn-thinking"] { display: flex; - flex-wrap: nowrap; align-items: center; gap: 8px; width: 100%; min-width: 0; - white-space: nowrap; color: var(--text-weak); font-family: var(--font-family-sans); font-size: var(--font-size-base); font-weight: var(--font-weight-medium); - line-height: var(--line-height-large); - height: 36px; + line-height: 20px; + min-height: 20px; [data-component="spinner"] { width: 16px; height: 16px; } - - > [data-component="text-shimmer"] { - flex: 0 0 auto; - white-space: nowrap; - } - } - - [data-slot="session-turn-handoff-wrap"] { - width: 100%; - min-width: 0; - overflow: visible; - } - - [data-slot="session-turn-handoff"] { - width: 100%; - min-width: 0; - min-height: 37px; - position: relative; - } - - [data-slot="session-turn-thinking"] { - position: absolute; - inset: 0; - will-change: opacity, filter; - transition: - opacity 180ms ease-out, - filter 180ms ease-out, - transform 180ms ease-out; - } - - [data-slot="session-turn-thinking"][data-visible="false"] { - opacity: 0; - filter: blur(2px); - transform: translateY(1px); - pointer-events: none; - } - - [data-slot="session-turn-thinking"][data-visible="true"] { - opacity: 1; - filter: blur(0px); - transform: translateY(0px); - } - - [data-slot="session-turn-meta"] { - position: absolute; - inset: 0; - min-height: 37px; - display: flex; - align-items: center; - justify-content: flex-start; - gap: 10px; - opacity: 0; - pointer-events: none; - transition: opacity 0.15s ease; - } - - [data-slot="session-turn-meta"][data-interrupted] { - gap: 12px; - } - - [data-slot="session-turn-meta"] [data-component="tooltip-trigger"] { - display: inline-flex; - width: fit-content; - } - - [data-slot="session-turn-message-container"]:hover [data-slot="session-turn-meta"][data-visible="true"], - [data-slot="session-turn-message-container"]:focus-within [data-slot="session-turn-meta"][data-visible="true"] { - opacity: 1; - pointer-events: auto; - } - - [data-slot="session-turn-meta-label"] { - user-select: none; - min-width: 0; - overflow: clip; - white-space: nowrap; - text-overflow: ellipsis; } [data-component="text-reveal"].session-turn-thinking-heading { flex: 1 1 auto; min-width: 0; - overflow: clip; - white-space: nowrap; - line-height: inherit; color: var(--text-weaker); font-weight: var(--font-weight-regular); - - [data-slot="text-reveal-track"], - [data-slot="text-reveal-entering"], - [data-slot="text-reveal-leaving"] { - min-height: 0; - line-height: inherit; - } } .error-card { @@ -180,7 +84,7 @@ display: flex; flex-direction: column; align-self: stretch; - gap: 0px; + gap: 12px; > :first-child > [data-component="markdown"]:first-child { margin-top: 0; @@ -205,7 +109,6 @@ [data-component="session-turn-diffs-trigger"] { width: 100%; - height: 36px; display: flex; align-items: center; justify-content: flex-start; @@ -215,7 +118,7 @@ [data-slot="session-turn-diffs-title"] { display: inline-flex; - align-items: center; + align-items: baseline; gap: 8px; } @@ -233,7 +136,7 @@ font-variant-numeric: tabular-nums; font-size: var(--font-size-base); font-weight: var(--font-weight-regular); - line-height: var(--line-height-large); + line-height: var(--line-height-x-large); } [data-slot="session-turn-diffs-meta"] { @@ -269,10 +172,8 @@ [data-slot="session-turn-diff-path"] { display: flex; + flex-grow: 1; min-width: 0; - align-items: baseline; - overflow: clip; - white-space: nowrap; font-family: var(--font-family-sans); font-size: var(--font-size-small); @@ -280,22 +181,16 @@ } [data-slot="session-turn-diff-directory"] { - flex: 1 1 auto; - color: var(--text-weak); - min-width: 0; - overflow: clip; + color: var(--text-base); + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; direction: rtl; - unicode-bidi: plaintext; text-align: left; } [data-slot="session-turn-diff-filename"] { flex-shrink: 0; - max-width: 100%; - min-width: 0; - overflow: clip; - white-space: nowrap; color: var(--text-strong); font-weight: var(--font-weight-medium); } diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index f1aee802e..3323a9fc6 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -3,27 +3,23 @@ import type { SessionStatus } from "@opencode-ai/sdk/v2" import { useData } from "../context" import { useFileComponent } from "../context/file" -import { same } from "@opencode-ai/util/array" import { Binary } from "@opencode-ai/util/binary" import { getDirectory, getFilename } from "@opencode-ai/util/path" -import { createEffect, createMemo, createSignal, For, on, onCleanup, ParentProps, Show } from "solid-js" +import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js" import { Dynamic } from "solid-js/web" -import { GrowBox } from "./grow-box" -import { AssistantParts, UserMessageDisplay, Part, PART_MAPPING } from "./message-part" +import { AssistantParts, Message, Part, PART_MAPPING } from "./message-part" import { Card } from "./card" import { Accordion } from "./accordion" import { StickyAccordionHeader } from "./sticky-accordion-header" import { Collapsible } from "./collapsible" import { DiffChanges } from "./diff-changes" import { Icon } from "./icon" -import { IconButton } from "./icon-button" import { TextShimmer } from "./text-shimmer" -import { TextReveal } from "./text-reveal" -import { list } from "./text-utils" import { SessionRetry } from "./session-retry" -import { Tooltip } from "./tooltip" +import { TextReveal } from "./text-reveal" import { createAutoScroll } from "../hooks" import { useI18n } from "../context/i18n" + function record(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value) } @@ -77,12 +73,18 @@ 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") { @@ -139,7 +141,6 @@ export function SessionTurn( props: ParentProps<{ sessionID: string messageID: string - animate?: boolean showReasoningSummaries?: boolean shellToolDefaultOpen?: boolean editToolDefaultOpen?: boolean @@ -158,7 +159,11 @@ export function SessionTurn( const i18n = useI18n() const fileComponent = useFileComponent() + const emptyMessages: MessageType[] = [] const emptyParts: PartType[] = [] + const emptyAssistant: AssistantMessage[] = [] + const emptyDiffs: FileDiff[] = [] + const idle = { type: "idle" as const } const allMessages = createMemo(() => list(data.store.message?.[props.sessionID], emptyMessages)) @@ -186,8 +191,42 @@ export function SessionTurn( return msg }) - const active = createMemo(() => props.active ?? false) - const queued = createMemo(() => props.queued ?? false) + const pending = createMemo(() => { + if (typeof props.active === "boolean" && typeof props.queued === "boolean") return + const messages = allMessages() ?? emptyMessages + return messages.findLast( + (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number", + ) + }) + + const pendingUser = createMemo(() => { + const item = pending() + if (!item?.parentID) return + const messages = allMessages() ?? emptyMessages + const result = Binary.search(messages, item.parentID, (m) => m.id) + const msg = result.found ? messages[result.index] : messages.find((m) => m.id === item.parentID) + if (!msg || msg.role !== "user") return + return msg + }) + + const active = createMemo(() => { + if (typeof props.active === "boolean") return props.active + const msg = message() + const parent = pendingUser() + if (!msg || !parent) return false + return parent.id === msg.id + }) + + const queued = createMemo(() => { + if (typeof props.queued === "boolean") return props.queued + const id = message()?.id + if (!id) return false + if (!pendingUser()) return false + const item = pending() + if (!item) return false + return id > item.id + }) + const parts = createMemo(() => { const msg = message() if (!msg) return emptyParts @@ -250,7 +289,7 @@ export function SessionTurn( const error = createMemo( () => assistantMessages().find((m) => m.error && m.error.name !== "MessageAbortedError")?.error, ) - const assistantCopyPart = createMemo(() => { + const showAssistantCopyPartID = createMemo(() => { const messages = assistantMessages() for (let i = messages.length - 1; i >= 0; i--) { @@ -260,18 +299,13 @@ export function SessionTurn( const parts = list(data.store.part?.[message.id], emptyParts) for (let j = parts.length - 1; j >= 0; j--) { const part = parts[j] - if (!part || part.type !== "text") continue - const text = part.text?.trim() - if (!text) continue - return { - id: part.id, - text, - message, - } + if (!part || part.type !== "text" || !part.text?.trim()) continue + return part.id } } + + return undefined }) - const assistantCopyPartID = createMemo(() => assistantCopyPart()?.id ?? null) const errorText = createMemo(() => { const msg = error()?.data?.message if (typeof msg === "string") return unwrap(msg) @@ -279,14 +313,18 @@ export function SessionTurn( return unwrap(String(msg)) }) - const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle) - const working = createMemo(() => { - if (status().type === "idle") return false - if (!message()) return false - return active() + const status = createMemo(() => { + if (props.status !== undefined) return props.status + if (typeof props.active === "boolean" && !props.active) return idle + return data.store.session_status[props.sessionID] ?? idle }) + const working = createMemo(() => status().type !== "idle" && active()) const showReasoningSummaries = createMemo(() => props.showReasoningSummaries ?? true) - const showDiffSummary = createMemo(() => edited() > 0 && !working()) + + const assistantCopyPartID = createMemo(() => { + if (working()) return null + return showAssistantCopyPartID() ?? null + }) const turnDurationMs = createMemo(() => { const start = message()?.time.created if (typeof start !== "number") return undefined @@ -326,109 +364,13 @@ export function SessionTurn( .filter((text): text is string => !!text) .at(-1), ) - const thinking = createMemo(() => { + const showThinking = createMemo(() => { if (!working() || !!error()) return false if (queued()) return false if (status().type === "retry") return false if (showReasoningSummaries()) return assistantVisible() === 0 return true }) - const hasAssistant = createMemo(() => assistantMessages().length > 0) - const animateEnabled = createMemo(() => props.animate !== false) - const [live, setLive] = createSignal(false) - const thinkingOpen = createMemo(() => thinking() && (live() || !animateEnabled())) - const metaOpen = createMemo(() => !working() && !!assistantCopyPart()) - const duration = createMemo(() => { - const ms = turnDurationMs() - if (typeof ms !== "number" || ms < 0) return "" - - const total = Math.round(ms / 1000) - if (total < 60) return `${total}s` - - const minutes = Math.floor(total / 60) - const seconds = total % 60 - return `${minutes}m ${seconds}s` - }) - const meta = createMemo(() => { - const item = assistantCopyPart() - if (!item) return "" - - const agent = item.message.agent ? item.message.agent[0]?.toUpperCase() + item.message.agent.slice(1) : "" - const model = item.message.modelID - ? (data.store.provider?.all?.find((provider) => provider.id === item.message.providerID)?.models?.[ - item.message.modelID - ]?.name ?? item.message.modelID) - : "" - return [agent, model, duration()].filter((value) => !!value).join("\u00A0\u00B7\u00A0") - }) - const [copied, setCopied] = createSignal(false) - const [handoffHold, setHandoffHold] = createSignal(false) - const thinkingVisible = createMemo(() => thinkingOpen() || handoffHold()) - const handoffOpen = createMemo(() => thinkingVisible() || metaOpen()) - const lane = createMemo(() => hasAssistant() || handoffOpen()) - - let liveFrame: number | undefined - let copiedTimer: ReturnType | 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, @@ -436,119 +378,6 @@ export function SessionTurn( overflowAnchor: "dynamic", }) - onCleanup(() => { - if (liveFrame !== undefined) cancelAnimationFrame(liveFrame) - if (copiedTimer !== undefined) clearTimeout(copiedTimer) - if (handoffTimer !== undefined) clearTimeout(handoffTimer) - }) - - const turnDiffSummary = () => ( -
- - -
-
- {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 (
- {(msg) => ( -
-
- +
+ +
+ +
+ +
+
+ 0}> +
+
- - {(part) => ( - -
- -
-
- )} -
-
- -
- -
+
+ +
+ + + - -
-
- - +
+ + + 0 && !working()}> +
+ + +
+
+ {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] : [])} > - event.preventDefault()} - onClick={() => void copyAssistant()} - aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} - /> - - - - {meta()} - - + + {(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)} + +
+ + + + + + +
+
+
+
+ + +
+ +
+
+
+
+ ) + }} +
+
-
- + +
- - {divider(i18n.t("ui.message.interrupted"))} - - - - {turnDiffSummary()} - - - - {errorText()} - - -
- )} + + + + {errorText()} + + +
{props.children}
diff --git a/packages/ui/src/components/shell-rolling-results.tsx b/packages/ui/src/components/shell-rolling-results.tsx deleted file mode 100644 index 0210e46e0..000000000 --- a/packages/ui/src/components/shell-rolling-results.tsx +++ /dev/null @@ -1,291 +0,0 @@ -import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js" -import stripAnsi from "strip-ansi" -import type { ToolPart } from "@opencode-ai/sdk/v2" -import { useReducedMotion } 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, 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; defaultOpen?: boolean }) { - const i18n = useI18n() - const reduce = useReducedMotion() - const wiped = new Set() - const [mounted, setMounted] = createSignal(false) - const [open, setOpen] = createSignal(props.defaultOpen ?? true) - onMount(() => setMounted(true)) - const state = createMemo(() => props.part.state as Record) - const pending = createMemo(() => busy(props.part.state.status)) - const expanded = createMemo(() => open() && !pending()) - const previewOpen = createMemo(() => open() && pending()) - 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 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 = () => { - const el = headerClipRef - const viewport = el?.closest(".scroll-view__viewport") as HTMLElement | null - const beforeY = el?.getBoundingClientRect().top ?? 0 - setOpen((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 9f19c2d15..f72ba3fc7 100644 --- a/packages/ui/src/components/shell-submessage.css +++ b/packages/ui/src/components/shell-submessage.css @@ -1,13 +1,23 @@ [data-component="shell-submessage"] { min-width: 0; max-width: 100%; - display: inline-block; + display: inline-flex; + align-items: baseline; vertical-align: baseline; } +[data-component="shell-submessage"] [data-slot="shell-submessage-width"] { + min-width: 0; + max-width: 100%; + display: inline-flex; + align-items: baseline; + overflow: hidden; +} + [data-component="shell-submessage"] [data-slot="shell-submessage-value"] { display: inline-block; vertical-align: baseline; min-width: 0; + line-height: inherit; white-space: nowrap; } diff --git a/packages/ui/src/components/text-reveal.css b/packages/ui/src/components/text-reveal.css index 7939322e6..f799962f0 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: bottom-to-top. New text rises in from below, old text exits upward. + * Direction: top-to-bottom. New text drops in from above, old text exits downward. * - * Entering: gradient reveals bottom-to-top (bottom of text appears first). + * Entering: gradient reveals top-to-bottom (top 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 bottom-to-top (bottom of text disappears first). + * Leaving: gradient hides top-to-bottom (top 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 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) + /* ── 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) */ [data-slot="text-reveal-entering"] { - 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%; + 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%; transition-property: mask-position, -webkit-mask-position, @@ -74,37 +74,37 @@ transform: translateY(0); } - /* ── 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) + /* ── 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) */ [data-slot="text-reveal-leaving"] { - 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%; + 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%; transition-property: mask-position, -webkit-mask-position, transform; - transform: translateY(calc(var(--_travel) * -1)); + transform: translateY(var(--_travel)); } /* ── swapping: instant reset ── - * Snap entering to hidden (below), leaving to visible (center). + * Snap entering to hidden (above), leaving to visible (center). */ &[data-swapping="true"] [data-slot="text-reveal-entering"] { - mask-position: 0 100%; - -webkit-mask-position: 0 100%; - transform: translateY(var(--_travel)); + mask-position: 0 0%; + -webkit-mask-position: 0 0%; + transform: translateY(calc(var(--_travel) * -1)); transition-duration: 0ms !important; } &[data-swapping="true"] [data-slot="text-reveal-leaving"] { - mask-position: 0 100%; - -webkit-mask-position: 0 100%; + mask-position: 0 0%; + -webkit-mask-position: 0 0%; transform: translateY(0); transition-duration: 0ms !important; } @@ -126,14 +126,15 @@ &[data-truncate="true"] [data-slot="text-reveal-track"] { width: 100%; min-width: 0; - overflow: clip; + overflow: hidden; } &[data-truncate="true"] [data-slot="text-reveal-entering"], &[data-truncate="true"] [data-slot="text-reveal-leaving"] { min-width: 0; width: 100%; - overflow: clip; + overflow: hidden; + text-overflow: ellipsis; } } diff --git a/packages/ui/src/components/text-reveal.tsx b/packages/ui/src/components/text-reveal.tsx index edf5dbf83..c4fe1302f 100644 --- a/packages/ui/src/components/text-reveal.tsx +++ b/packages/ui/src/components/text-reveal.tsx @@ -1,13 +1,4 @@ import { createEffect, createSignal, on, onCleanup, onMount } from "solid-js" -import { useReducedMotion } from "../hooks/use-reduced-motion" -import { - animate, - type AnimationPlaybackControls, - clearFadeStyles, - clearMaskStyles, - GROW_SPRING, - WIPE_MASK, -} from "./motion" const px = (value: number | string | undefined, fallback: number) => { if (typeof value === "number") return `${value}px` @@ -26,11 +17,6 @@ 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 @@ -53,8 +39,10 @@ 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) { @@ -63,14 +51,21 @@ 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 @@ -138,95 +133,3 @@ 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 reduce = useReducedMotion() - - const run = () => { - if (props.animate === false) return - const el = ref - if (!el || !props.text || typeof window === "undefined") return - if (reduce()) 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 bd1437c27..f042dd2d8 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: 2000ms; + --text-shimmer-duration: 1200ms; --text-shimmer-swap: 220ms; --text-shimmer-index: 0; --text-shimmer-angle: 90deg; --text-shimmer-spread: 5.2ch; - --text-shimmer-size: 600%; + --text-shimmer-size: 360%; --text-shimmer-base-color: var(--text-weak); --text-shimmer-peak-color: var(--text-strong); --text-shimmer-sweep: linear-gradient( @@ -16,17 +16,15 @@ ); --text-shimmer-base: linear-gradient(var(--text-shimmer-base-color), var(--text-shimmer-base-color)); - display: inline-block; - vertical-align: baseline; + display: inline-flex; + align-items: baseline; font: inherit; letter-spacing: inherit; line-height: inherit; } [data-component="text-shimmer"] [data-slot="text-shimmer-char"] { - display: inline-block; - position: relative; - vertical-align: baseline; + display: inline-grid; white-space: pre; font: inherit; letter-spacing: inherit; @@ -35,7 +33,7 @@ [data-component="text-shimmer"] [data-slot="text-shimmer-char-base"], [data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] { - display: inline-block; + grid-area: 1 / 1; white-space: pre; transition: opacity var(--text-shimmer-swap) ease-out; font: inherit; @@ -44,14 +42,11 @@ } [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 0d797e5c1..3ab077d92 100644 --- a/packages/ui/src/components/text-shimmer.tsx +++ b/packages/ui/src/components/text-shimmer.tsx @@ -37,16 +37,6 @@ export const TextShimmer = (props: { clearTimeout(timer) }) - const len = createMemo(() => Math.max(text().length, 1)) - const shimmerSize = createMemo(() => Math.max(300, Math.round(200 + 1400 / len()))) - - // duration = len × (size - 1) / velocity → uniform perceived sweep speed - const VELOCITY = 0.01375 // ch per ms, ~10% faster than original 0.0125 baseline - const shimmerDuration = createMemo(() => { - const s = shimmerSize() / 100 - return Math.max(1000, Math.min(2500, Math.round((len() * (s - 1)) / VELOCITY))) - }) - return ( (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 deleted file mode 100644 index c094b5e65..000000000 --- a/packages/ui/src/components/text-utils.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** 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 4ed46e50b..11a33ff5d 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: clip; + overflow: hidden; transform: translateX(-0.04em); transition-property: grid-template-columns, opacity, filter, transform; - transition-duration: 800ms, 400ms, 400ms, 800ms; + transition-duration: 250ms, 250ms, 250ms, 250ms; 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: clip; + overflow: hidden; white-space: pre; } } diff --git a/packages/ui/src/components/tool-count-label.tsx b/packages/ui/src/components/tool-count-label.tsx index c374d2d37..67e861cdc 100644 --- a/packages/ui/src/components/tool-count-label.tsx +++ b/packages/ui/src/components/tool-count-label.tsx @@ -1,6 +1,5 @@ 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) @@ -12,23 +11,35 @@ 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(() => commonPrefix(one().after, other().after)) + const suffix = createMemo(() => common(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().prefix : active().after)) + const stem = createMemo(() => (splitSuffix() ? suffix().stem : active().after)) const tail = createMemo(() => { if (!splitSuffix()) return "" - if (singular()) return suffix().aSuffix - return suffix().bSuffix + if (singular()) return suffix().one + return suffix().other }) 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 435ed95fe..da8455267 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: clip; + overflow: hidden; transform-origin: left center; transition-property: grid-template-columns, opacity, filter, transform; transition-duration: - var(--tool-motion-spring-ms, 800ms), var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms), - var(--tool-motion-spring-ms, 800ms); + var(--tool-motion-spring-ms, 480ms), var(--tool-motion-fade-ms, 240ms), var(--tool-motion-fade-ms, 280ms), + var(--tool-motion-spring-ms, 480ms); 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: clip; + overflow: hidden; transform-origin: left center; transition-property: grid-template-columns, opacity, filter, transform; transition-duration: - var(--tool-motion-spring-ms, 800ms), var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms), - var(--tool-motion-spring-ms, 800ms); + var(--tool-motion-spring-ms, 480ms), var(--tool-motion-fade-ms, 280ms), var(--tool-motion-fade-ms, 320ms), + var(--tool-motion-spring-ms, 480ms); 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: clip; + overflow: hidden; white-space: nowrap; } @@ -63,7 +63,7 @@ display: inline-flex; align-items: baseline; min-width: 0; - overflow: clip; + overflow: hidden; white-space: nowrap; } @@ -75,11 +75,12 @@ margin-right: 0; opacity: 0; filter: blur(calc(var(--tool-motion-blur, 2px) * 0.55)); - overflow: clip; + overflow: hidden; transform: translateX(-0.08em); transition-property: opacity, filter, transform; transition-duration: - var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms); + 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); 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 050f5e390..d4415bd2d 100644 --- a/packages/ui/src/components/tool-status-title.css +++ b/packages/ui/src/components/tool-status-title.css @@ -18,8 +18,9 @@ [data-slot="tool-status-swap"], [data-slot="tool-status-tail"] { display: inline-grid; - overflow: clip; + overflow: hidden; justify-items: start; + transition: width var(--tool-motion-spring-ms, 480ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); } [data-slot="tool-status-active"], @@ -30,8 +31,8 @@ text-align: start; transition-property: opacity, filter, transform; transition-duration: - var(--tool-motion-fade-ms, 400ms), calc(var(--tool-motion-fade-ms, 400ms) * 0.8), - calc(var(--tool-motion-fade-ms, 400ms) * 0.8); + var(--tool-motion-fade-ms, 240ms), calc(var(--tool-motion-fade-ms, 240ms) * 0.8), + calc(var(--tool-motion-fade-ms, 240ms) * 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 444955af9..68440b6c6 100644 --- a/packages/ui/src/components/tool-status-title.tsx +++ b/packages/ui/src/components/tool-status-title.tsx @@ -1,8 +1,17 @@ import { Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js" -import { useReducedMotion } from "../hooks/use-reduced-motion" -import { animate, type AnimationPlaybackControls, GROW_SPRING } from "./motion" import { TextShimmer } from "./text-shimmer" -import { commonPrefix } from "./text-utils" + +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(""), + } +} function contentWidth(el: HTMLSpanElement | undefined) { if (!el) return 0 @@ -18,58 +27,25 @@ export function ToolStatusTitle(props: { class?: string split?: boolean }) { - const reduce = useReducedMotion() - const split = createMemo(() => commonPrefix(props.activeText, props.doneText)) + const split = createMemo(() => common(props.activeText, props.doneText)) const suffix = createMemo( - () => - (props.split ?? true) && split().prefix.length >= 2 && split().aSuffix.length > 0 && split().bSuffix.length > 0, + () => (props.split ?? true) && split().prefix.length >= 2 && split().active.length > 0 && split().done.length > 0, ) const prefixLen = createMemo(() => Array.from(split().prefix).length) - const activeTail = createMemo(() => (suffix() ? split().aSuffix : props.activeText)) - const doneTail = createMemo(() => (suffix() ? split().bSuffix : props.doneText)) + const activeTail = createMemo(() => (suffix() ? split().active : props.activeText)) + const doneTail = createMemo(() => (suffix() ? split().done : props.doneText)) + const [width, setWidth] = createSignal("auto") const [ready, setReady] = createSignal(false) let activeRef: HTMLSpanElement | undefined let doneRef: HTMLSpanElement | undefined - let swapRef: HTMLSpanElement | undefined - let tailRef: HTMLSpanElement | undefined let frame: number | undefined let readyFrame: number | undefined - let widthAnim: AnimationPlaybackControls | undefined - - const node = () => (suffix() ? tailRef : swapRef) - - const setNodeWidth = (width: string) => { - if (swapRef) swapRef.style.width = width - if (tailRef) tailRef.style.width = width - } const measure = () => { const target = props.active ? activeRef : doneRef - 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 px = contentWidth(target) + if (px > 0) setWidth(`${px}px`) } const schedule = () => { @@ -114,7 +90,6 @@ export function ToolStatusTitle(props: { onCleanup(() => { if (frame !== undefined) cancelAnimationFrame(frame) if (readyFrame !== undefined) cancelAnimationFrame(readyFrame) - widthAnim?.stop() }) return ( @@ -129,7 +104,7 @@ export function ToolStatusTitle(props: { + @@ -143,7 +118,7 @@ export function ToolStatusTitle(props: { - + diff --git a/packages/ui/src/components/tool-utils.ts b/packages/ui/src/components/tool-utils.ts deleted file mode 100644 index 4d57c626e..000000000 --- a/packages/ui/src/components/tool-utils.ts +++ /dev/null @@ -1,336 +0,0 @@ -import type { ToolPart } from "@opencode-ai/sdk/v2" -import { createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js" -import { useReducedMotion } from "../hooks/use-reduced-motion" -import { - animate, - type AnimationPlaybackControls, - clearFadeStyles, - clearMaskStyles, - COLLAPSIBLE_SPRING, - GROW_SPRING, - WIPE_MASK, -} from "./motion" - -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 -}) { - const reduce = useReducedMotion() - 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() - if (reduce()) { - body.style.opacity = "" - body.style.filter = "" - if (isOpen) { - content.style.display = "" - content.style.height = "auto" - options.onOpen?.() - return - } - content.style.height = "0px" - content.style.display = "none" - return - } - 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" - }, - () => {}, - ) - }), - ) - - 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 = useReducedMotion() - - 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 - const reduce = useReducedMotion() - - onMount(() => { - if (!active) return - - const el = ref() - if (!el || typeof window === "undefined") return - if (reduce()) 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 d36102590..3dc520c62 100644 --- a/packages/ui/src/hooks/create-auto-scroll.tsx +++ b/packages/ui/src/hooks/create-auto-scroll.tsx @@ -1,8 +1,6 @@ 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 @@ -11,28 +9,13 @@ 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 programmaticUntil = 0 - let scrollAnim: AnimationPlaybackControls | undefined - let hold: - | { - el: HTMLElement - top: number - until: number - quiet: number - frame: number | undefined - } - | undefined + let auto: { top: number; time: number } | undefined const threshold = () => options.bottomThreshold ?? 10 @@ -44,160 +27,77 @@ export function createAutoScroll(options: AutoScrollOptions) { const active = () => options.working() || settling const distanceFromBottom = (el: HTMLElement) => { - // With column-reverse, scrollTop=0 is at the bottom, negative = scrolled up - return Math.abs(el.scrollTop) + return el.scrollHeight - el.clientHeight - el.scrollTop } const canScroll = (el: HTMLElement) => { return el.scrollHeight - el.clientHeight > 1 } - const markProgrammatic = () => { - programmaticUntil = Date.now() + AUTO_SCROLL_GRACE_MS - } - - const clearHold = () => { - const next = hold - if (!next) return - if (next.frame !== undefined) cancelAnimationFrame(next.frame) - hold = undefined - } - - const tickHold = () => { - const next = hold - const el = scroll - if (!next || !el) return false - if (Date.now() > next.until) { - clearHold() - return false + // 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 (!next.el.isConnected) { - clearHold() + + if (autoTimer) clearTimeout(autoTimer) + autoTimer = setTimeout(() => { + auto = undefined + autoTimer = undefined + }, 1500) + } + + const isAuto = (el: HTMLElement) => { + const a = auto + if (!a) return false + + if (Date.now() - a.time > 1500) { + auto = undefined return false } - const current = next.el.getBoundingClientRect().top - if (!Number.isFinite(current)) { - clearHold() - return false - } - - 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 + return Math.abs(el.scrollTop - a.top) < 2 } - 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 scrollToBottomNow = (behavior: ScrollBehavior) => { const el = scroll if (!el) return - - if (!store.userScrolled) { - setStore("userScrolled", true) - options.onUserInteracted?.() + markAuto(el) + if (behavior === "smooth") { + el.scrollTo({ top: el.scrollHeight, behavior }) + return } - 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() + // `scrollTop` assignment bypasses any CSS `scroll-behavior: smooth`. + el.scrollTop = el.scrollHeight } 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 - // With column-reverse, scrollTop=0 is at the bottom - if (Math.abs(el.scrollTop) <= AUTO_SCROLL_EPSILON) { - markProgrammatic() + const distance = distanceFromBottom(el) + if (distance < 2) { + markAuto(el) return } - el.scrollTop = 0 - markProgrammatic() + // For auto-following content we prefer immediate updates to avoid + // visible "catch up" animations while content is still settling. + scrollToBottomNow("auto") } - 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 stop = () => { const el = scroll if (!el) return if (!canScroll(el)) { @@ -206,25 +106,15 @@ 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 - cancelSmooth() + // 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`. const el = scroll const target = e.target instanceof Element ? e.target : undefined const nested = target?.closest("[data-scrollable]") @@ -236,27 +126,23 @@ 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 } - if (!store.userScrolled && Date.now() < programmaticUntil) return + // Ignore scroll events triggered by our own scrollToBottom calls. + if (!store.userScrolled && isAuto(el)) { + scrollToBottom(false) + return + } - stop({ hold: false }) + stop() } const handleInteraction = () => { @@ -268,11 +154,6 @@ export function createAutoScroll(options: AutoScrollOptions) { } const updateOverflowAnchor = (el: HTMLElement) => { - if (hold) { - el.style.overflowAnchor = "none" - return - } - const mode = options.overflowAnchor ?? "dynamic" if (mode === "none") { @@ -292,17 +173,15 @@ 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) }, ) @@ -321,11 +200,13 @@ export function createAutoScroll(options: AutoScrollOptions) { settling = true settleTimer = setTimeout(() => { settling = false - }, SETTLE_MS) + }, 300) }), ) 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 @@ -334,8 +215,7 @@ export function createAutoScroll(options: AutoScrollOptions) { onCleanup(() => { if (settleTimer) clearTimeout(settleTimer) - clearHold() - cancelSmooth() + if (autoTimer) clearTimeout(autoTimer) if (cleanup) cleanup() }) @@ -348,12 +228,8 @@ export function createAutoScroll(options: AutoScrollOptions) { scroll = el - if (!el) { - clearHold() - return - } + if (!el) return - markProgrammatic() updateOverflowAnchor(el) el.addEventListener("wheel", handleWheel, { passive: true }) @@ -364,18 +240,13 @@ export function createAutoScroll(options: AutoScrollOptions) { contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el), handleScroll, handleInteraction, - preserve, pause: stop, - forceScrollToBottom: () => scrollToBottom(true), - smoothScrollToBottom, - snapToBottom: () => { - const el = scroll - if (!el) return + resume: () => { if (store.userScrolled) setStore("userScrolled", false) - // With column-reverse, scrollTop=0 is at the bottom - el.scrollTop = 0 - markProgrammatic() + scrollToBottom(true) }, + scrollToBottom: () => scrollToBottom(false), + forceScrollToBottom: () => scrollToBottom(true), userScrolled: () => store.userScrolled, } } diff --git a/packages/ui/src/hooks/index.ts b/packages/ui/src/hooks/index.ts index 0fcf6f086..1c90a2e49 100644 --- a/packages/ui/src/hooks/index.ts +++ b/packages/ui/src/hooks/index.ts @@ -1,3 +1,2 @@ export * from "./use-filtered-list" export * from "./create-auto-scroll" -export * from "./use-reduced-motion" diff --git a/packages/ui/src/hooks/use-reduced-motion.ts b/packages/ui/src/hooks/use-reduced-motion.ts deleted file mode 100644 index 0038760ec..000000000 --- a/packages/ui/src/hooks/use-reduced-motion.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { isHydrated } from "@solid-primitives/lifecycle" -import { createMediaQuery } from "@solid-primitives/media" -import { createHydratableSingletonRoot } from "@solid-primitives/rootless" - -const query = "(prefers-reduced-motion: reduce)" - -export const useReducedMotion = createHydratableSingletonRoot(() => { - const value = createMediaQuery(query) - return () => !isHydrated() || value() -}) diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 213a37c51..cec42f5a0 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -40,7 +40,6 @@ @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 91b923dee..1fb8ac69e 100644 --- a/packages/util/src/array.ts +++ b/packages/util/src/array.ts @@ -1,10 +1,3 @@ -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,