diff --git a/bun.lock b/bun.lock index c3758e232..41d1a5922 100644 --- a/bun.lock +++ b/bun.lock @@ -737,7 +737,7 @@ "tailwindcss": "4.1.11", "typescript": "5.8.2", "ulid": "3.0.1", - "virtua": "0.42.3", + "virtua": "0.49.1", "vite": "7.1.4", "vite-plugin-solid": "2.11.10", "zod": "4.1.8", @@ -5005,7 +5005,7 @@ "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], - "virtua": ["virtua@0.42.3", "", { "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0", "solid-js": ">=1.0", "svelte": ">=5.0", "vue": ">=3.2" }, "optionalPeers": ["react", "react-dom", "solid-js", "svelte", "vue"] }, "sha512-5FoAKcEvh05qsUF97Yz42SWJ7bwnPExjUYHGuoxz1EUtfWtaOgXaRwnylJbDpA0QcH1rKvJ2qsGRi9MK1fpQbg=="], + "virtua": ["virtua@0.49.1", "", { "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0", "solid-js": ">=1.0", "svelte": ">=5.0", "vue": ">=3.2" }, "optionalPeers": ["react", "react-dom", "solid-js", "svelte", "vue"] }, "sha512-6f79msqg3jzNFdqJiS0FSzhRN1EHlDhR7EvW7emp6z5qQ22VdsReiDHflkpMEMhoAyUuYr69nwT0aagiM7NrUg=="], "vite": ["vite@7.1.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-X5QFK4SGynAeeIt+A7ZWnApdUyHYm+pzv/8/A57LqSGcI88U6R6ipOs3uCesdc6yl7nl+zNO0t8LmqAdXcQihw=="], diff --git a/package.json b/package.json index 5faf8be92..3fa966f68 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "shiki": "3.20.0", "solid-list": "0.3.0", "tailwindcss": "4.1.11", - "virtua": "0.42.3", + "virtua": "0.49.1", "vite": "7.1.4", "@solidjs/meta": "0.29.4", "@solidjs/router": "0.15.4", diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 1345e355e..e916cc2f0 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -75,7 +75,6 @@ type VcsMode = "git" | "branch" type SessionHistoryWindowInput = { sessionID: () => string | undefined - messagesReady: () => boolean loaded: () => number visibleUserMessages: () => UserMessage[] historyMore: () => boolean @@ -85,205 +84,78 @@ type SessionHistoryWindowInput = { 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 +function createSessionHistoryLoader(input: SessionHistoryWindowInput) { + const historyScrollThreshold = 200 + let shiftFrame: number | undefined const [state, setState] = createStore({ - turnID: undefined as string | undefined, - turnStart: 0, - prefetchUntil: 0, - prefetchNoGrowth: 0, + shift: false, }) - 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) - }, + const userMessages = createMemo( + () => input.visibleUserMessages(), 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 cancelShiftReset = () => { + if (shiftFrame === undefined) return + cancelAnimationFrame(shiftFrame) + shiftFrame = undefined + } + + const scheduleShiftReset = () => { + cancelShiftReset() + shiftFrame = requestAnimationFrame(() => { + shiftFrame = undefined + setState("shift", false) }) } - 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 - let loaded = input.loaded() - - if (start > 0) setTurnStart(0) - - if (!input.historyMore() || input.historyLoading()) return - - let afterVisible = beforeVisible - let added = 0 - - while (true) { - await input.loadMore(id) - if (input.sessionID() !== id) return - - afterVisible = input.visibleUserMessages().length - const nextLoaded = input.loaded() - const raw = nextLoaded - loaded - added += raw - loaded = nextLoaded - - if (afterVisible > beforeVisible) break - if (raw <= 0) break - if (!input.historyMore()) break - } - - if (added <= 0) return - if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0) - - const growth = afterVisible - beforeVisible - if (growth <= 0) return - if (turnStart() !== 0) return - - const target = Math.min(afterVisible, beforeVisible + turnBatch) - setTurnStart(Math.max(0, afterVisible - target)) - } - - /** Scroll/prefetch path: fetch older history from server. */ - const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => { + const fetchOlderMessages = async () => { 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() + // TODO(session-timeline): switch this to core cursor-based part pagination when that API lands. const beforeVisible = input.visibleUserMessages().length - const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length let loaded = input.loaded() - let added = 0 let growth = 0 + cancelShiftReset() + setState("shift", true) + while (true) { await input.loadMore(id) if (input.sessionID() !== id) return const nextLoaded = input.loaded() const raw = nextLoaded - loaded - added += raw loaded = nextLoaded growth = input.visibleUserMessages().length - beforeVisible if (growth > 0) break if (raw <= 0) break - if (opts?.prefetch) break if (!input.historyMore()) break } - const afterVisible = input.visibleUserMessages().length - - if (opts?.prefetch) { - setState("prefetchNoGrowth", added > 0 ? 0 : state.prefetchNoGrowth + 1) - } else if (added > 0 && state.prefetchNoGrowth) { - setState("prefetchNoGrowth", 0) - } - - if (added <= 0) return - if (growth <= 0) return - - if (opts?.prefetch) { - const current = turnStart() - preserveScroll(() => setTurnStart(current + growth)) + if (growth > 0) { + scheduleShiftReset() return } - if (turnStart() !== start) return - - const currentRendered = renderedUserMessages().length - const base = Math.max(beforeRendered, currentRendered) - const target = Math.min(afterVisible, base + turnBatch) - preserveScroll(() => setTurnStart(Math.max(0, afterVisible - target))) + setState("shift", false) } + const loadAndReveal = () => fetchOlderMessages() + 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 - } + if (el.scrollTop >= historyScrollThreshold) return void fetchOlderMessages() } @@ -292,27 +164,18 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) { on( input.sessionID, () => { - setState({ prefetchUntil: 0, prefetchNoGrowth: 0 }) + cancelShiftReset() + setState({ shift: false }) }, { defer: true }, ), ) - createEffect( - on( - () => [input.sessionID(), input.messagesReady()] as const, - ([id, ready]) => { - if (!id || !ready) return - setTurnStart(initialTurnStart(input.visibleUserMessages().length)) - }, - { defer: true }, - ), - ) + onCleanup(cancelShiftReset) return { - turnStart, - setTurnStart, - renderedUserMessages, + userMessages, + shift: () => state.shift, loadAndReveal, onScrollerScroll, } @@ -737,6 +600,7 @@ export default function Page() { let dockHeight = 0 let scroller: HTMLDivElement | undefined let content: HTMLDivElement | undefined + let revealMessage = (_id: string) => {} let scrollMark = 0 let messageMark = 0 @@ -1403,9 +1267,8 @@ export default function Page() { }, ) - const historyWindow = createSessionHistoryWindow({ + const historyLoader = createSessionHistoryLoader({ sessionID: () => params.id, - messagesReady, loaded: () => messages().length, visibleUserMessages, historyMore, @@ -1427,9 +1290,9 @@ export default function Page() { const el = scroller if (!el) return if (el.scrollHeight > el.clientHeight + 1) return - if (historyWindow.turnStart() <= 0 && !historyMore()) return + if (!historyMore()) return - void historyWindow.loadAndReveal() + void historyLoader.loadAndReveal() }) } @@ -1439,15 +1302,14 @@ export default function Page() { [ params.id, messagesReady(), - historyWindow.turnStart(), historyMore(), historyLoading(), autoScroll.userScrolled(), visibleUserMessages().length, ] as const, - ([id, ready, start, more, loading, scrolled]) => { + ([id, ready, more, loading, scrolled]) => { if (!id || !ready || loading || scrolled) return - if (start <= 0 && !more) return + if (!more) return fill() }, { defer: true }, @@ -1754,15 +1616,14 @@ export default function Page() { historyMore, historyLoading, loadMore: (sessionID) => sync.session.history.loadMore(sessionID), - turnStart: historyWindow.turnStart, currentMessageId: () => store.messageId, pendingMessage: () => ui.pendingMessage, setPendingMessage: (value) => setUi("pendingMessage", value), setActiveMessage, - setTurnStart: historyWindow.setTurnStart, autoScroll, scroller: () => scroller, anchor, + revealMessage: (id) => revealMessage(id), scheduleScrollState, consumePendingMessage: layout.pendingMessage.consume, }) @@ -1836,7 +1697,7 @@ export default function Page() {
- + { @@ -1868,14 +1729,12 @@ export default function Page() { const root = scroller if (root) scheduleScrollState(root) }} - turnStart={historyWindow.turnStart()} - historyMore={historyMore()} - historyLoading={historyLoading()} - onLoadEarlier={() => { - void historyWindow.loadAndReveal() - }} - renderedUserMessages={historyWindow.renderedUserMessages()} + historyShift={historyLoader.shift()} + userMessages={historyLoader.userMessages()} anchor={anchor} + setRevealMessage={(fn) => { + revealMessage = fn + }} /> diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 8bbaafb4e..fd8c9123f 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -1,8 +1,24 @@ -import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX, createSignal } from "solid-js" +import { createEffect, createMemo, createSignal, For, Index, on, onCleanup, Show, type JSX } from "solid-js" import { createStore, produce } from "solid-js/store" +import { Dynamic } from "solid-js/web" import { useNavigate } from "@solidjs/router" import { useMutation } from "@tanstack/solid-query" +import { Virtualizer, type VirtualizerHandle } from "virtua/solid" +import { Accordion } from "@opencode-ai/ui/accordion" import { Button } from "@opencode-ai/ui/button" +import { Card } from "@opencode-ai/ui/card" +import { + ContextToolGroup, + groupParts, + Message, + MessageDivider, + Part as MessagePart, + partDefaultOpen, + renderable, + type PartGroup, + type UserActions, +} from "@opencode-ai/ui/message-part" +import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" @@ -10,14 +26,27 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Dialog } from "@opencode-ai/ui/dialog" import { InlineInput } from "@opencode-ai/ui/inline-input" import { Spinner } from "@opencode-ai/ui/spinner" -import { SessionTurn } from "@opencode-ai/ui/session-turn" +import { SessionRetry } from "@opencode-ai/ui/session-retry" import { ScrollView } from "@opencode-ai/ui/scroll-view" +import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" import { TextField } from "@opencode-ai/ui/text-field" -import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2" +import { TextReveal } from "@opencode-ai/ui/text-reveal" +import { TextShimmer } from "@opencode-ai/ui/text-shimmer" +import type { + AssistantMessage, + Message as MessageType, + Part as PartType, + SnapshotFileDiff, + TextPart, + ToolPart, + UserMessage, +} from "@opencode-ai/sdk/v2" import { showToast } from "@opencode-ai/ui/toast" import { Binary } from "@opencode-ai/core/util/binary" -import { getFilename } from "@opencode-ai/core/util/path" +import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { Popover as KobaltePopover } from "@kobalte/core/popover" +import { normalize } from "@opencode-ai/ui/session-diff" +import { useFileComponent } from "@opencode-ai/ui/context/file" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" import { SessionContextUsage } from "@/components/session-context-usage" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -44,13 +73,127 @@ type MessageComment = { } const emptyMessages: MessageType[] = [] +const emptyParts: PartType[] = [] +const emptyAssistantMessages: AssistantMessage[] = [] const idle = { type: "idle" as const } -type UserActions = { - fork?: (input: { sessionID: string; messageID: string }) => Promise | void - revert?: (input: { sessionID: string; messageID: string }) => Promise | void + +type SummaryDiff = SnapshotFileDiff & { file: string } + +type TimelineRow = + | { key: string; type: "comment-strip"; userMessageID: string; previousUserMessage: boolean } + | { key: string; type: "user-message"; userMessageID: string; anchor: boolean; previousUserMessage: boolean } + | { key: string; type: "turn-divider"; userMessageID: string; label: "compaction" | "interrupted" } + | { + key: string + type: "assistant-part" + userMessageID: string + group: PartGroup + previousAssistantPart: boolean + lastAssistantPart: boolean + } + | { key: string; type: "thinking"; userMessageID: string; reasoningHeading?: string } + | { key: string; type: "retry"; userMessageID: string } + | { key: string; type: "diff-summary"; userMessageID: string; diffs: SummaryDiff[] } + | { key: string; type: "error"; userMessageID: string; text: string } + +function sameKeys(a: readonly string[] | undefined, b: readonly string[] | undefined) { + if (a === b) return true + if (!a || !b) return false + if (a.length !== b.length) return false + return a.every((key, index) => key === b[index]) } -const messageComments = (parts: Part[]): MessageComment[] => +function record(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +} + +function unwrapErrorMessage(message: string) { + const text = message.replace(/^Error:\s*/, "").trim() + + const parse = (value: string) => { + try { + return JSON.parse(value) as unknown + } catch { + return undefined + } + } + + const read = (value: string) => { + const first = parse(value) + if (typeof first !== "string") return first + return parse(first.trim()) + } + + let json = read(text) + + if (json === undefined) { + const start = text.indexOf("{") + const end = text.lastIndexOf("}") + if (start !== -1 && end > start) json = read(text.slice(start, end + 1)) + } + + if (!record(json)) return message + + const err = record(json.error) ? json.error : undefined + if (err) { + const type = typeof err.type === "string" ? err.type : undefined + const msg = typeof err.message === "string" ? err.message : undefined + if (type && msg) return `${type}: ${msg}` + if (msg) return msg + if (type) return type + const code = typeof err.code === "string" ? err.code : undefined + if (code) return code + } + + const msg = typeof json.message === "string" ? json.message : undefined + if (msg) return msg + + const reason = typeof json.error === "string" ? json.error : undefined + if (reason) return reason + + return message +} + +function cleanHeading(value: string) { + return value + .replace(/`([^`]+)`/g, "$1") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/[*_~]+/g, "") + .trim() +} + +function reasoningHeading(text: string) { + const markdown = text.replace(/\r\n?/g, "\n") + const html = markdown.match(/]*>([\s\S]*?)<\/h[1-6]>/i) + if (html?.[1]) { + const value = cleanHeading(html[1].replace(/<[^>]+>/g, " ")) + if (value) return value + } + + const atx = markdown.match(/^\s{0,3}#{1,6}[ \t]+(.+?)(?:[ \t]+#+[ \t]*)?$/m) + if (atx?.[1]) { + const value = cleanHeading(atx[1]) + if (value) return value + } + + const setext = markdown.match(/^([^\n]+)\n(?:=+|-+)\s*$/m) + if (setext?.[1]) { + const value = cleanHeading(setext[1]) + if (value) return value + } + + const strong = markdown.match(/^\s*(?:\*\*|__)(.+?)(?:\*\*|__)\s*$/m) + if (strong?.[1]) { + const value = cleanHeading(strong[1]) + if (value) return value + } +} + +function summaryDiff(value: SnapshotFileDiff): value is SummaryDiff { + return typeof value.file === "string" +} + +const messageComments = (parts: PartType[]): MessageComment[] => parts.flatMap((part) => { if (part.type !== "text" || !(part as TextPart).synthetic) return [] const next = readCommentMetadata(part.metadata) ?? parseCommentNote(part.text) @@ -69,7 +212,7 @@ const messageComments = (parts: Part[]): MessageComment[] => ] }) -const taskDescription = (part: Part, sessionID: string) => { +const taskDescription = (part: PartType, sessionID: string) => { if (part.type !== "tool" || part.tool !== "task") return const metadata = "metadata" in part.state ? part.state.metadata : undefined if (metadata?.sessionId !== sessionID) return @@ -110,101 +253,123 @@ const markBoundaryGesture = (input: { } } -type StageConfig = { - init: number - batch: number -} +function TimelineThinkingRow(props: { reasoningHeading?: string; showReasoningSummaries: boolean }) { + const language = useLanguage() -type TimelineStageInput = { - sessionKey: () => string - turnStart: () => number - messages: () => UserMessage[] - config: StageConfig -} - -/** - * Defer-mounts small timeline windows so revealing older turns does not - * block first paint with a large DOM mount. - * - * Once staging completes for a session it never re-stages — backfill and - * new messages render immediately. - */ -function createTimelineStaging(input: TimelineStageInput) { - const [state, setState] = createStore({ - activeSession: "", - completedSession: "", - count: 0, - }) - - const stagedCount = createMemo(() => { - const total = input.messages().length - if (input.turnStart() <= 0) return total - if (state.completedSession === input.sessionKey()) return total - const init = Math.min(total, input.config.init) - if (state.count <= init) return init - if (state.count >= total) return total - return state.count - }) - - const stagedUserMessages = createMemo(() => { - const list = input.messages() - const count = stagedCount() - if (count >= list.length) return list - return list.slice(Math.max(0, list.length - count)) - }) - - let frame: number | undefined - const cancel = () => { - if (frame === undefined) return - cancelAnimationFrame(frame) - frame = undefined - } - - createEffect( - on( - () => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const, - ([sessionKey, isWindowed, total]) => { - cancel() - const shouldStage = - isWindowed && - total > input.config.init && - state.completedSession !== sessionKey && - state.activeSession !== sessionKey - if (!shouldStage) { - setState({ activeSession: "", count: total }) - return - } - - let count = Math.min(total, input.config.init) - setState({ activeSession: sessionKey, count }) - - const step = () => { - if (input.sessionKey() !== sessionKey) { - frame = undefined - return - } - const currentTotal = input.messages().length - count = Math.min(currentTotal, count + input.config.batch) - setState("count", count) - if (count >= currentTotal) { - setState({ completedSession: sessionKey, activeSession: "" }) - frame = undefined - return - } - frame = requestAnimationFrame(step) - } - frame = requestAnimationFrame(step) - }, - ), + return ( +
+ + + + +
) +} - const isStaging = createMemo(() => { - const key = input.sessionKey() - return state.activeSession === key && state.completedSession !== key +function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) { + const language = useLanguage() + const fileComponent = useFileComponent() + const maxFiles = 10 + const [state, setState] = createStore({ + showAll: false, + expanded: [] as string[], }) + const showAll = () => state.showAll + const expanded = () => state.expanded + const overflow = createMemo(() => Math.max(0, props.diffs.length - maxFiles)) + const visible = createMemo(() => (showAll() ? props.diffs : props.diffs.slice(0, maxFiles))) - onCleanup(cancel) - return { messages: stagedUserMessages, isStaging } + return ( +
+
+ + {props.diffs.length} {language.t("ui.sessionTurn.diffs.changed")} {" "} + {language.t(props.diffs.length === 1 ? "ui.common.file.one" : "ui.common.file.other")} + + + 0}> + setState("showAll", !showAll())}> + {showAll() ? language.t("ui.sessionTurn.diffs.showLess") : language.t("ui.sessionTurn.diffs.showAll")} + + +
+
+ setState("expanded", Array.isArray(value) ? value : value ? [value] : [])} + > + + {(diff) => { + const view = normalize(diff) + const active = createMemo(() => expanded().includes(diff.file)) + const [shown, setShown] = createSignal(false) + + createEffect( + on( + active, + (value) => { + if (!value) { + setShown(false) + return + } + + requestAnimationFrame(() => { + if (active()) setShown(true) + }) + }, + { defer: true }, + ), + ) + + return ( + + + +
+ + + {`\u202A${getDirectory(diff.file)}\u202C`} + + {getFilename(diff.file)} + +
+ + + + + + +
+
+
+
+ + +
+ +
+
+
+
+ ) + }} +
+
+ 0}> +
setState("showAll", true)}> + {language.t("ui.sessionTurn.diffs.more", { count: String(overflow()) })} +
+
+
+
+ ) } export function MessageTimeline(props: { @@ -219,16 +384,14 @@ export function MessageTimeline(props: { onMarkScrollGesture: (target?: EventTarget | null) => void hasScrollGesture: () => boolean onUserScroll: () => void - onTurnBackfillScroll: () => void + onHistoryScroll: () => void onAutoScrollInteraction: (event: MouseEvent) => void centered: boolean setContentRef: (el: HTMLDivElement) => void - turnStart: number - historyMore: boolean - historyLoading: boolean - onLoadEarlier: () => void - renderedUserMessages: UserMessage[] + historyShift: boolean + userMessages: UserMessage[] anchor: (id: string) => string + setRevealMessage?: (fn: (id: string) => void) => void }) { let touchGesture: number | undefined @@ -242,13 +405,28 @@ export function MessageTimeline(props: { const { params, sessionKey } = useSessionKey() const platform = usePlatform() - const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id)) + const [viewport, setViewport] = createSignal() + let virtualizer: VirtualizerHandle | undefined const sessionID = createMemo(() => params.id) const sessionMessages = createMemo(() => { const id = sessionID() if (!id) return emptyMessages return sync.data.message[id] ?? emptyMessages }) + const messageByID = createMemo(() => new Map(sessionMessages().map((message) => [message.id, message] as const))) + const assistantMessagesByParent = createMemo(() => { + const result = new Map() + for (const message of sessionMessages()) { + if (message.role !== "assistant") continue + const messages = result.get(message.parentID) + if (messages) { + messages.push(message) + continue + } + result.set(message.parentID, [message]) + } + return result + }) const pending = createMemo(() => sessionMessages().findLast( (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number", @@ -333,12 +511,130 @@ export function MessageTimeline(props: { return language.t("command.session.new") }) const showHeader = createMemo(() => !!(titleValue() || parentID())) - const stageCfg = { init: 1, batch: 3 } - const staging = createTimelineStaging({ - sessionKey, - turnStart: () => props.turnStart, - messages: () => props.renderedUserMessages, - config: stageCfg, + + const timelineRows = createMemo(() => { + const rows: TimelineRow[] = [] + const status = sessionStatus() + const active = activeMessageID() + const showReasoning = settings.general.showReasoningSummaries() + + // TODO(session-timeline): replace this loaded-message flattening with core cursor-paged timeline parts once available. + for (const userMessage of props.userMessages) { + const userParts = sync.data.part[userMessage.id] ?? emptyParts + const comments = messageComments(userParts) + const assistantMessages = assistantMessagesByParent().get(userMessage.id) ?? emptyAssistantMessages + const compaction = userParts.find((part) => part.type === "compaction") + const interrupted = assistantMessages.some((message) => message.error?.name === "MessageAbortedError") + const error = assistantMessages.find((message) => message.error?.name !== "MessageAbortedError")?.error + const workingTurn = status.type !== "idle" && active === userMessage.id + const assistantPartRefs = assistantMessages.flatMap((message) => + (sync.data.part[message.id] ?? emptyParts) + .filter((part) => renderable(part, showReasoning)) + .map((part) => ({ messageID: message.id, part })), + ) + const assistantGroups = groupParts(assistantPartRefs) + const diffs = (userMessage.summary?.diffs ?? []) + .reduceRight((result, diff) => { + if (!summaryDiff(diff)) return result + if (result.some((item) => item.file === diff.file)) return result + result.push(diff) + return result + }, []) + .reverse() + const heading = assistantMessages + .flatMap((message) => sync.data.part[message.id] ?? emptyParts) + .map((part) => (part.type === "reasoning" && part.text ? reasoningHeading(part.text) : undefined)) + .find((value): value is string => !!value) + + const previousUserMessage = rows.length > 0 + if (comments.length > 0) + rows.push({ + key: `comment-strip:${userMessage.id}`, + type: "comment-strip", + userMessageID: userMessage.id, + previousUserMessage, + }) + + rows.push({ + key: `user-message:${userMessage.id}`, + type: "user-message", + userMessageID: userMessage.id, + anchor: comments.length === 0, + previousUserMessage: comments.length === 0 && previousUserMessage, + }) + + if (compaction || interrupted) { + rows.push({ + key: `turn-divider:${userMessage.id}:${compaction ? "compaction" : "interrupted"}`, + type: "turn-divider", + userMessageID: userMessage.id, + label: compaction ? "compaction" : "interrupted", + }) + } + + assistantGroups.forEach((group, index) => + rows.push({ + key: `assistant-part:${userMessage.id}:${group.key}`, + type: "assistant-part", + userMessageID: userMessage.id, + group, + previousAssistantPart: index > 0, + lastAssistantPart: index === assistantGroups.length - 1, + }), + ) + + if (workingTurn && !error && status.type !== "retry" && (showReasoning ? assistantPartRefs.length === 0 : true)) { + rows.push({ key: `thinking:${userMessage.id}`, type: "thinking", userMessageID: userMessage.id, reasoningHeading: heading }) + } + + if (workingTurn && status.type === "retry") rows.push({ key: `retry:${userMessage.id}`, type: "retry", userMessageID: userMessage.id }) + + if (diffs.length > 0 && !workingTurn) { + rows.push({ key: `diff-summary:${userMessage.id}`, type: "diff-summary", userMessageID: userMessage.id, diffs }) + } + + if (error) { + const data = error.data?.message + rows.push({ + key: `error:${userMessage.id}`, + type: "error", + userMessageID: userMessage.id, + text: unwrapErrorMessage(typeof data === "string" ? data : data === undefined || data === null ? "" : String(data)), + }) + } + } + + return rows + }) + const timelineRowKeys = createMemo(() => timelineRows().map((row) => row.key), [] as string[], { equals: sameKeys }) + const timelineRowByKey = createMemo(() => new Map(timelineRows().map((row) => [row.key, row] as const))) + const messageRowIndex = createMemo(() => { + const result = new Map() + timelineRows().forEach((row, index) => { + if (result.has(row.userMessageID)) return + result.set(row.userMessageID, index) + }) + return result + }) + const keepMounted = createMemo(() => { + const id = activeMessageID() + if (!id) return + const rows = timelineRows() + const index = rows.findLastIndex((row) => row.userMessageID === id) + if (index < 0) return + return [index] + }) + + createEffect(() => { + props.setRevealMessage?.((id) => { + const index = messageRowIndex().get(id) + if (index === undefined) return + virtualizer?.scrollToIndex(index, { align: "center" }) + }) + }) + + onCleanup(() => { + props.setRevealMessage?.(() => {}) }) const [title, setTitle] = createStore({ @@ -623,6 +919,209 @@ export function MessageTimeline(props: { ) } + const workingTurn = (userMessageID: string) => sessionStatus().type !== "idle" && activeMessageID() === userMessageID + + const turnDurationMs = (userMessageID: string) => { + const message = messageByID().get(userMessageID) + if (!message || message.role !== "user") return + const end = (assistantMessagesByParent().get(userMessageID) ?? emptyAssistantMessages).reduce( + (max, item) => { + const completed = item.time.completed + if (typeof completed !== "number") return max + if (max === undefined) return completed + return Math.max(max, completed) + }, + undefined, + ) + if (typeof end !== "number") return + if (end < message.time.created) return + return end - message.time.created + } + + const assistantCopyPartID = (userMessageID: string) => { + if (workingTurn(userMessageID)) return null + const messages = assistantMessagesByParent().get(userMessageID) ?? emptyAssistantMessages + + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i] + if (!message) continue + + const parts = sync.data.part[message.id] ?? emptyParts + for (let j = parts.length - 1; j >= 0; j--) { + const part = parts[j] + if (!part || part.type !== "text" || !part.text?.trim()) continue + return part.id + } + } + } + + const partByRef = (messageID: string, partID: string) => + (sync.data.part[messageID] ?? emptyParts).find((part) => part.id === partID) + + const renderAssistantPartGroup = (row: Extract) => { + if (row.group.type === "context") { + const parts = row.group.refs + .map((ref) => partByRef(ref.messageID, ref.partID)) + .filter((part): part is ToolPart => part?.type === "tool") + + return + } + + const message = messageByID().get(row.group.ref.messageID) + const part = partByRef(row.group.ref.messageID, row.group.ref.partID) + if (!message || !part) return null + + return ( + + ) + } + + function TimelineRowFrame(input: { row: TimelineRow; children: JSX.Element }) { + const anchor = () => input.row.type === "comment-strip" || (input.row.type === "user-message" && input.row.anchor) + + return ( +
+
+ {input.children} +
+
+ ) + } + + const renderTimelineRow = (row: TimelineRow) => { + switch (row.type) { + case "comment-strip": + return ( + +
+
+
+ + {(commentAccessor: () => MessageComment) => { + const comment = createMemo(() => commentAccessor()) + return ( + + {(c) => ( +
+
+ + {getFilename(c().path)} + + {(selection) => ( + + {selection().startLine === selection().endLine + ? `:${selection().startLine}` + : `:${selection().startLine}-${selection().endLine}`} + + )} + +
+
+ {c().comment} +
+
+ )} +
+ ) + }} +
+
+
+
+
+ ) + case "user-message": { + const message = messageByID().get(row.userMessageID) + if (!message || message.role !== "user") return null + return ( + +
+
+ +
+
+
+ ) + } + case "turn-divider": + return ( + +
+
+ +
+
+
+ ) + case "assistant-part": + return ( + +
+
+ {renderAssistantPartGroup(row)} +
+
+
+ ) + case "thinking": + return ( + +
+ +
+
+ ) + case "retry": + return ( + +
+ +
+
+ ) + case "diff-summary": + return ( + +
+ +
+
+ ) + case "error": + return ( + +
+ + {row.text} + +
+
+ ) + } + } + return (
{ + setViewport(el) + props.setScrollRef(el) + }} onWheel={(e) => { const root = e.currentTarget const delta = normalizeWheelDelta({ @@ -691,7 +1192,7 @@ export function MessageTimeline(props: { }} onScroll={(e) => { props.onScheduleScrollState(e.currentTarget) - props.onTurnBackfillScroll() + props.onHistoryScroll() if (!props.hasScrollGesture()) return props.onUserScroll() props.onAutoScrollHandleScroll() @@ -1006,109 +1507,25 @@ export function MessageTimeline(props: { "mt-0": !props.centered, }} > - 0 || props.historyMore}> -
- -
+ {(key) => { + const row = timelineRowByKey().get(key) + if (!row) return null + return renderTimelineRow(row) + }} + + )}
- - {(messageID) => { - const active = createMemo(() => activeMessageID() === messageID) - const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], { - equals: (a, b) => - a.length === b.length && - a.every( - (c, i) => - c.path === b[i].path && - c.comment === b[i].comment && - c.selection?.startLine === b[i].selection?.startLine && - c.selection?.endLine === b[i].selection?.endLine, - ), - }) - const commentCount = createMemo(() => comments().length) - return ( -
- 0}> -
-
-
- - {(commentAccessor: () => MessageComment) => { - const comment = createMemo(() => commentAccessor()) - return ( - - {(c) => ( -
-
- - {getFilename(c().path)} - - {(selection) => ( - - {selection().startLine === selection().endLine - ? `:${selection().startLine}` - : `:${selection().startLine}-${selection().endLine}`} - - )} - -
-
- {c().comment} -
-
- )} -
- ) - }} -
-
-
-
-
- -
- ) - }} -
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 c582749d1..65577abae 100644 --- a/packages/app/src/pages/session/use-session-hash-scroll.ts +++ b/packages/app/src/pages/session/use-session-hash-scroll.ts @@ -11,21 +11,19 @@ export const useSessionHashScroll = (input: { historyMore: () => boolean historyLoading: () => boolean loadMore: (sessionID: string) => Promise - turnStart: () => number currentMessageId: () => string | undefined pendingMessage: () => string | undefined setPendingMessage: (value: string | undefined) => void setActiveMessage: (message: UserMessage | undefined) => void - setTurnStart: (value: number) => void autoScroll: { pause: () => void; forceScrollToBottom: () => void } scroller: () => HTMLDivElement | undefined anchor: (id: string) => string + revealMessage?: (id: string) => void scheduleScrollState: (el: HTMLDivElement) => void consumePendingMessage: (key: string) => string | undefined }) => { const visibleUserMessages = createMemo(() => input.visibleUserMessages()) const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m]))) - const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i]))) let pendingKey = "" let clearing = false @@ -77,6 +75,7 @@ export const useSessionHashScroll = (input: { } const seek = (id: string, behavior: ScrollBehavior, left = 4): boolean => { + input.revealMessage?.(id) const el = document.getElementById(input.anchor(id)) if (el) return scrollToElement(el, behavior) if (left <= 0) return false @@ -89,18 +88,7 @@ export const useSessionHashScroll = (input: { const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => { cancel() if (input.currentMessageId() !== message.id) input.setActiveMessage(message) - - const index = messageIndex().get(message.id) ?? -1 - if (index !== -1 && index < input.turnStart()) { - input.setTurnStart(index) - - queue(() => { - seek(message.id, behavior) - }) - - updateHash(message.id) - return - } + input.revealMessage?.(message.id) if (seek(message.id, behavior)) { updateHash(message.id) @@ -154,7 +142,6 @@ export const useSessionHashScroll = (input: { if (!input.sessionID() || !input.messagesReady()) return visibleUserMessages() - input.turnStart() let targetId = input.pendingMessage() if (!targetId) { diff --git a/packages/ui/package.json b/packages/ui/package.json index 12441c8d0..9168085f7 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -6,6 +6,7 @@ "exports": { "./package.json": "./package.json", "./*": "./src/components/*.tsx", + "./session-diff": "./src/components/session-diff.ts", "./i18n/*": "./src/i18n/*.ts", "./pierre": "./src/pierre/index.ts", "./pierre/*": "./src/pierre/*.ts", diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index 27ad7c3c7..cb9f9b17f 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -1,4 +1,4 @@ -import { createEffect, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js" +import { createEffect, For, Match, on, onCleanup, onMount, Show, Switch, type JSX } from "solid-js" import { animate, type AnimationPlaybackControls } from "motion" import { useI18n } from "../context/i18n" import { createStore } from "solid-js/store" @@ -40,26 +40,76 @@ export interface BasicToolProps { } const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 } +const deferredMounts: Array<{ active: boolean; fn: () => void }> = [] +let deferredFrame: number | undefined + +function flushDeferredMounts() { + while (deferredMounts.length > 0) { + // Timeline tools are mounted top-to-bottom, but the viewport starts at the latest turn. + // Pop from the end so heavy default-open bodies near the bottom become interactive first. + const item = deferredMounts.pop()! + if (item.active) { + deferredFrame = deferredMounts.length > 0 ? requestAnimationFrame(flushDeferredMounts) : undefined + item.fn() + return + } + } + deferredFrame = undefined +} + +function scheduleDeferredFlush() { + if (deferredFrame !== undefined) return + deferredFrame = requestAnimationFrame(() => { + deferredFrame = requestAnimationFrame(flushDeferredMounts) + }) +} + +function scheduleDeferredMount(fn: () => void) { + const item = { active: true, fn } + deferredMounts.push(item) + scheduleDeferredFlush() + return () => { + item.active = false + } +} + +function scheduleFrameMount(fn: () => void) { + const frame = requestAnimationFrame(fn) + return () => cancelAnimationFrame(frame) +} export function BasicTool(props: BasicToolProps) { const [state, setState] = createStore({ open: props.defaultOpen ?? false, - ready: props.defaultOpen ?? false, + ready: !props.defer && (props.defaultOpen ?? false), }) const open = () => state.open const ready = () => state.ready const pending = () => props.status === "pending" || props.status === "running" + const hasChildren = () => (props.defer ? "children" in props : props.children) - let frame: number | undefined + let cancelReady: (() => void) | undefined const cancel = () => { - if (frame === undefined) return - cancelAnimationFrame(frame) - frame = undefined + cancelReady?.() + cancelReady = undefined + } + + const scheduleReady = (initial = false) => { + cancel() + cancelReady = (initial ? scheduleDeferredMount : scheduleFrameMount)(() => { + cancelReady = undefined + if (!open()) return + setState("ready", true) + }) } onCleanup(cancel) + onMount(() => { + if (props.defer && open()) scheduleReady(true) + }) + createEffect(() => { if (props.forceOpen) setState("open", true) }) @@ -75,12 +125,7 @@ export function BasicTool(props: BasicToolProps) { return } - cancel() - frame = requestAnimationFrame(() => { - frame = undefined - if (!open()) return - setState("ready", true) - }) + scheduleReady() }, { defer: true }, ), @@ -189,7 +234,7 @@ export function BasicTool(props: BasicToolProps) { - + @@ -219,7 +264,7 @@ export function BasicTool(props: BasicToolProps) { )} - +
- {props.children} + {props.children}
- + {props.children} diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 137f68975..3f05bf992 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -485,12 +485,12 @@ function same(a: readonly T[] | undefined, b: readonly T[] | undefined) { return a.every((x, i) => x === b[i]) } -type PartRef = { +export type PartRef = { messageID: string partID: string } -type PartGroup = +export type PartGroup = | { key: string type: "part" @@ -519,14 +519,14 @@ function sameGroup(a: PartGroup, b: PartGroup) { return a.refs.every((ref, i) => sameRef(ref, b.refs[i]!)) } -function sameGroups(a: readonly PartGroup[] | undefined, b: readonly PartGroup[] | undefined) { +export 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 }[]) { +export function groupParts(parts: { messageID: string; part: PartType }[]) { const result: PartGroup[] = [] let start = -1 @@ -574,7 +574,7 @@ function index(items: readonly T[]) { return new Map(items.map((item) => [item.id, item] as const)) } -function renderable(part: PartType, showReasoningSummaries = true) { +export function renderable(part: PartType, showReasoningSummaries = true) { if (part.type === "tool") { if (HIDDEN_TOOLS.has(part.tool)) return false if (part.tool === "question") return part.state.status !== "pending" && part.state.status !== "running" @@ -590,7 +590,7 @@ function toolDefaultOpen(tool: string, shell = false, edit = false) { if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit } -function partDefaultOpen(part: PartType, shell = false, edit = false) { +export function partDefaultOpen(part: PartType, shell = false, edit = false) { if (part.type !== "tool") return return toolDefaultOpen(part.tool, shell, edit) } @@ -903,7 +903,7 @@ export function AssistantMessageDisplay(props: { ) } -function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) { +export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) { const i18n = useI18n() const [open, setOpen] = createSignal(false) const pending = createMemo( @@ -913,7 +913,13 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) { const summary = createMemo(() => contextToolSummary(props.parts)) return ( - + part.id).join(",")} + >
+
0}>
@@ -1343,7 +1349,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { return ( -
+
{(error) => { @@ -1486,7 +1492,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { return ( -
+
}> @@ -1529,7 +1535,7 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) { return ( -
+
}>