diff --git a/bun.lock b/bun.lock
index 510c464e32..6f3a41d42b 100644
--- a/bun.lock
+++ b/bun.lock
@@ -764,7 +764,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",
@@ -4904,7 +4904,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 fb44c2f8b7..8c4b1d4ba9 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 1e73ed590f..f5da21a609 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -29,7 +29,7 @@ import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@opencode-ai/ui/toast"
import { checksum } from "@opencode-ai/core/util/encode"
-import { useSearchParams } from "@solidjs/router"
+import { useLocation, useSearchParams } from "@solidjs/router"
import { NewSessionView, SessionHeader } from "@/components/session"
import { useComments } from "@/context/comments"
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
@@ -75,7 +75,6 @@ type VcsMode = "git" | "branch"
type SessionHistoryWindowInput = {
sessionID: () => string | undefined
- messagesReady: () => boolean
loaded: () => number
visibleUserMessages: () => UserMessage[]
historyMore: () => boolean
@@ -85,205 +84,74 @@ 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 userMessages = createMemo(() => input.visibleUserMessages(), emptyUserMessages, {
+ equals: same,
})
- 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 cancelShiftReset = () => {
+ if (shiftFrame === undefined) return
+ cancelAnimationFrame(shiftFrame)
+ shiftFrame = undefined
}
- 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 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 +160,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,
}
@@ -333,6 +192,7 @@ export default function Page() {
const comments = useComments()
const terminal = useTerminal()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
+ const location = useLocation()
const { params, sessionKey, tabs, view } = useSessionLayout()
createEffect(() => {
@@ -737,6 +597,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 +1264,8 @@ export default function Page() {
},
)
- const historyWindow = createSessionHistoryWindow({
+ const historyLoader = createSessionHistoryLoader({
sessionID: () => params.id,
- messagesReady,
loaded: () => messages().length,
visibleUserMessages,
historyMore,
@@ -1427,9 +1287,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 +1299,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 },
@@ -1749,15 +1608,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,
})
@@ -1830,20 +1688,23 @@ export default function Page() {
>
+
+
+ {reviewContent({
+ diffStyle: "unified",
+ classes: {
+ root: "pb-8",
+ header: "px-4",
+ container: "px-4",
+ },
+ loadingClass: "px-4 py-4 text-text-weak",
+ emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
+ })}
+
+
+ !location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
+ }
centered={centered()}
setContentRef={(el) => {
content = el
@@ -1863,14 +1727,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.data.ts b/packages/app/src/pages/session/message-timeline.data.ts
new file mode 100644
index 0000000000..0334f16b01
--- /dev/null
+++ b/packages/app/src/pages/session/message-timeline.data.ts
@@ -0,0 +1,337 @@
+import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
+import { AssistantMessage, Part, SessionStatus, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2"
+import { groupParts, PartGroup, renderable } from "@opencode-ai/ui/message-part"
+import { Data, Equal } from "effect"
+
+export type SummaryDiff = SnapshotFileDiff & { file: string }
+
+export type TimelineRowMap = {
+ CommentStrip: {
+ userMessageID: string
+ previousUserMessage: boolean
+ }
+ UserMessage: {
+ userMessageID: string
+ anchor: boolean
+ previousUserMessage: boolean
+ }
+ TurnDivider: {
+ userMessageID: string
+ label: "compaction" | "interrupted"
+ }
+ AssistantPart: {
+ userMessageID: string
+ group: PartGroup
+ previousAssistantPart: boolean
+ lastAssistantPart: boolean
+ }
+ Thinking: { userMessageID: string; reasoningHeading?: string }
+ Retry: { userMessageID: string }
+ DiffSummary: { userMessageID: string; diffs: SummaryDiff[] }
+ Error: { userMessageID: string; text: string }
+ BottomSpacer: {}
+}
+
+export namespace TimelineRow {
+ export class CommentStrip extends Data.TaggedClass("CommentStrip")<{
+ userMessageID: string
+ previousUserMessage: boolean
+ }> {}
+ export class UserMessage extends Data.TaggedClass("UserMessage")<{
+ userMessageID: string
+ anchor: boolean
+ previousUserMessage: boolean
+ }> {}
+ export class TurnDivider extends Data.TaggedClass("TurnDivider")<{
+ userMessageID: string
+ label: "compaction" | "interrupted"
+ }> {}
+ export class AssistantPart extends Data.TaggedClass("AssistantPart")<{
+ userMessageID: string
+ group: PartGroup
+ previousAssistantPart: boolean
+ lastAssistantPart: boolean
+ }> {}
+ export class Thinking extends Data.TaggedClass("Thinking")<{
+ userMessageID: string
+ reasoningHeading?: string
+ }> {}
+ export class DiffSummary extends Data.TaggedClass("DiffSummary")<{
+ userMessageID: string
+ diffs: SummaryDiff[]
+ }> {}
+ export class Error extends Data.TaggedClass("Error")<{
+ userMessageID: string
+ text: string
+ }> {}
+ export class Retry extends Data.TaggedClass("Retry")<{
+ userMessageID: string
+ }> {}
+ export class BottomSpacer extends Data.TaggedClass("BottomSpacer")<{}> {}
+
+ export type TimelineRow =
+ | CommentStrip
+ | UserMessage
+ | TurnDivider
+ | AssistantPart
+ | Thinking
+ | DiffSummary
+ | Error
+ | Retry
+ | BottomSpacer
+
+ export const key = (row: TimelineRow) => {
+ switch (row._tag) {
+ case "CommentStrip":
+ return `comment-strip:${row.userMessageID}`
+ case "UserMessage":
+ return `user-message:${row.userMessageID}`
+ case "TurnDivider":
+ return `turn-divider:${row.userMessageID}:${row.label}`
+ case "AssistantPart":
+ return `assistant-part:${row.userMessageID}:${row.group.key}`
+ case "Thinking":
+ return `thinking:${row.userMessageID}`
+ case "DiffSummary":
+ return `diff-summary:${row.userMessageID}`
+ case "Error":
+ return `error:${row.userMessageID}`
+ case "Retry":
+ return `retry:${row.userMessageID}`
+ case "BottomSpacer":
+ return "bottom-spacer"
+ }
+ }
+
+ export function equals(a: TimelineRow, b: TimelineRow) {
+ return Equal.equals(a, b)
+ }
+}
+
+export namespace Timeline {
+ export function constructMessageRows(
+ userMessage: UserMessage,
+ getMessageParts: (messageID: string) => Part[],
+ assistantMessages: AssistantMessage[],
+ index: number,
+ showReasoning: boolean,
+ status: SessionStatus["type"],
+ isActive: boolean,
+ ) {
+ const rows: TimelineRow.TimelineRow[] = []
+
+ const previousUserMessage = index > 0
+ const userParts = getMessageParts(userMessage.id)
+ const comments = userParts.flatMap((p) => MessageComment.fromPart(p) ?? [])
+ const compaction = userParts.some((p) => p.type === "compaction")
+ const errorMsg = assistantMessages.find((m) => m.error?.name === "MessageAbortedError")
+ const interrupted = !!errorMsg
+
+ const assistantPartRefs = assistantMessages.flatMap((message) =>
+ getMessageParts(message.id)
+ .filter((part) => renderable(part, showReasoning))
+ .map((part) => ({ messageID: message.id, part })),
+ )
+ const assistantGroups = groupParts(assistantPartRefs)
+
+ if (comments.length > 0)
+ rows.push(
+ new TimelineRow.CommentStrip({
+ userMessageID: userMessage.id,
+ previousUserMessage,
+ }),
+ )
+
+ rows.push(
+ new TimelineRow.UserMessage({
+ userMessageID: userMessage.id,
+ anchor: comments.length === 0,
+ previousUserMessage: comments.length === 0 && previousUserMessage,
+ }),
+ )
+
+ if (compaction || interrupted) {
+ rows.push(
+ new TimelineRow.TurnDivider({
+ userMessageID: userMessage.id,
+ label: compaction ? "compaction" : "interrupted",
+ }),
+ )
+ }
+
+ assistantGroups.forEach((group, index) =>
+ rows.push(
+ new TimelineRow.AssistantPart({
+ userMessageID: userMessage.id,
+ group,
+ previousAssistantPart: index > 0,
+ lastAssistantPart: index === assistantGroups.length - 1,
+ }),
+ ),
+ )
+
+ if (isActive && status === "busy" && !errorMsg?.error && (showReasoning ? assistantPartRefs.length === 0 : true)) {
+ const heading = assistantMessages
+ .flatMap((message) => getMessageParts(message.id))
+ .map((part) => (part.type === "reasoning" && part.text ? reasoningHeading(part.text) : undefined))
+ .find((value): value is string => !!value)
+
+ rows.push(
+ new TimelineRow.Thinking({
+ userMessageID: userMessage.id,
+ reasoningHeading: heading,
+ }),
+ )
+ }
+
+ if (isActive && status === "retry") rows.push(new TimelineRow.Retry({ userMessageID: userMessage.id }))
+
+ const diffs = (userMessage.summary?.diffs ?? [])
+ .reduceRight((result, diff) => {
+ if (!isSummaryDiff(diff)) return result
+ if (result.some((item) => item.file === diff.file)) return result
+ result.push(diff)
+ return result
+ }, [])
+ .reverse()
+ if (diffs.length > 0 && (status === "idle" || !isActive)) {
+ rows.push(
+ new TimelineRow.DiffSummary({
+ userMessageID: userMessage.id,
+ diffs,
+ }),
+ )
+ }
+
+ if (errorMsg?.error) {
+ const data = errorMsg.error.data?.message
+ rows.push(
+ new TimelineRow.Error({
+ userMessageID: userMessage.id,
+ text: unwrapErrorMessage(
+ typeof data === "string" ? data : data === undefined || data === null ? "" : String(data),
+ ),
+ }),
+ )
+ }
+
+ return rows
+ }
+
+ function isSummaryDiff(value: SnapshotFileDiff): value is SummaryDiff {
+ return typeof value.file === "string"
+ }
+
+ 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 cleanHeading(value: string) {
+ return value
+ .replace(/`([^`]+)`/g, "$1")
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
+ .replace(/[*_~]+/g, "")
+ .trim()
+ }
+
+ 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 record(value: unknown): value is Record {
+ return !!value && typeof value === "object" && !Array.isArray(value)
+ }
+}
+
+export namespace MessageComment {
+ export type MessageComment = {
+ path: string
+ comment: string
+ selection?: {
+ startLine: number
+ endLine: number
+ }
+ }
+
+ export const fromPart = (part: Part): MessageComment | undefined => {
+ if (part.type !== "text" || !part.synthetic) return
+ const next = readCommentMetadata(part.metadata) ?? parseCommentNote(part.text)
+ if (!next) return
+ return {
+ path: next.path,
+ comment: next.comment,
+ selection: next.selection
+ ? {
+ startLine: next.selection.startLine,
+ endLine: next.selection.endLine,
+ }
+ : undefined,
+ }
+ }
+}
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
index 8bbaafb4e4..fb756e5e9a 100644
--- a/packages/app/src/pages/session/message-timeline.tsx
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -1,8 +1,36 @@
-import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX, createSignal } from "solid-js"
+import {
+ createEffect,
+ createMemo,
+ createSignal,
+ For,
+ Index,
+ Match,
+ Switch,
+ on,
+ onCleanup,
+ Show,
+ mapArray,
+ untrack,
+ type Accessor,
+ 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,
+ Message,
+ MessageDivider,
+ Part as MessagePart,
+ partDefaultOpen,
+ 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 +38,25 @@ 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,
+ 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"
@@ -31,45 +70,54 @@ import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { messageAgentColor } from "@/utils/agent"
import { sessionTitle } from "@/utils/session-title"
-import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
import { makeTimer } from "@solid-primitives/timer"
-
-type MessageComment = {
- path: string
- comment: string
- selection?: {
- startLine: number
- endLine: number
- }
-}
+import { MessageComment, SummaryDiff, Timeline, TimelineRow, TimelineRowMap } from "./message-timeline.data"
const emptyMessages: MessageType[] = []
+const emptyParts: PartType[] = []
+const emptyTools: ToolPart[] = []
+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 FramedTimelineRow = Exclude
+type TimelineRowByTag = Extract
+
+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[] =>
- parts.flatMap((part) => {
- if (part.type !== "text" || !(part as TextPart).synthetic) return []
- const next = readCommentMetadata(part.metadata) ?? parseCommentNote(part.text)
- if (!next) return []
- return [
- {
- path: next.path,
- comment: next.comment,
- selection: next.selection
- ? {
- startLine: next.selection.startLine,
- endLine: next.selection.endLine,
- }
- : undefined,
- },
- ]
- })
+const timelineCacheLimit = 16
+const timelineFallbackItemSize = 60
+const timelineCache = new Map()
-const taskDescription = (part: Part, sessionID: string) => {
+function readTimelineCache(id: string, keys: readonly string[]) {
+ const entry = timelineCache.get(id)
+ if (!entry) return
+ if (sameKeys(entry.keys, keys)) return entry.cache
+ timelineCache.delete(id)
+}
+
+function writeTimelineCache(id: string, keys: readonly string[], handle: VirtualizerHandle | undefined) {
+ if (!handle || keys.length === 0) return
+ timelineCache.delete(id)
+ timelineCache.set(id, { keys: keys.slice(), cache: handle.cache })
+ while (timelineCache.size > timelineCacheLimit) timelineCache.delete(timelineCache.keys().next().value!)
+}
+
+function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) {
+ if (!previous?.length) return rows
+ const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const))
+ return rows.map((row) => {
+ const existing = byKey.get(TimelineRow.key(row))
+ if (!existing) return row
+ return TimelineRow.equals(existing, row) ? existing : row
+ })
+}
+
+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,106 +158,114 @@ 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 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 opened = createMemo(() => expanded().includes(diff.file))
+
+ return (
+
+
+
+
+
+
+ {`\u202A${getDirectory(diff.file)}\u202C`}
+
+ {getFilename(diff.file)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+ }}
+
+
+
0}>
+ setState("showAll", true)}>
+ {language.t("ui.sessionTurn.diffs.more", { count: String(overflow()) })}
+
+
+
+
+ )
+}
+
+function TimelineDiffView(props: { diff: SummaryDiff }) {
+ const fileComponent = useFileComponent()
+ const view = normalize(props.diff)
+
+ return (
+
+
+
+ )
}
export function MessageTimeline(props: {
- mobileChanges: boolean
- mobileFallback: JSX.Element
actions?: UserActions
scroll: { overflow: boolean; bottom: boolean; jump: boolean }
onResumeScroll: () => void
@@ -219,16 +275,15 @@ export function MessageTimeline(props: {
onMarkScrollGesture: (target?: EventTarget | null) => void
hasScrollGesture: () => boolean
onUserScroll: () => void
- onTurnBackfillScroll: () => void
+ onHistoryScroll: () => void
onAutoScrollInteraction: (event: MouseEvent) => void
+ shouldAnchorBottom: () => boolean
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 +297,27 @@ export function MessageTimeline(props: {
const { params, sessionKey } = useSessionKey()
const platform = usePlatform()
- const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
+ 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",
@@ -317,11 +386,12 @@ export function MessageTimeline(props: {
return sync.data.message[id] ?? emptyMessages
})
const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
+ const getMsgParts = (msgId: string) => sync.data.part[msgId] ?? emptyParts
const childTaskDescription = createMemo(() => {
const id = sessionID()
if (!id) return
return parentMessages()
- .flatMap((message) => sync.data.part[message.id] ?? [])
+ .flatMap((message) => getMsgParts(message.id))
.map((part) => taskDescription(part, id))
.findLast((value): value is string => !!value)
})
@@ -333,12 +403,139 @@ 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 messageRowMemos = createMemo(
+ mapArray(
+ () => props.userMessages,
+ (userMessage, indexAccessor) => {
+ return createMemo((previous: TimelineRow.TimelineRow[] | undefined) => {
+ const rows = Timeline.constructMessageRows(
+ userMessage,
+ getMsgParts,
+ assistantMessagesByParent().get(userMessage.id) ?? emptyAssistantMessages,
+ indexAccessor(),
+ settings.general.showReasoningSummaries(),
+ sessionStatus().type,
+ activeMessageID() === userMessage.id,
+ )
+
+ return reuseTimelineRows(previous, rows)
+ })
+ },
+ ),
+ )
+
+ const timelineRows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) => {
+ const rows = messageRowMemos().flatMap((memo) => memo())
+ if (rows.length === 0) return rows
+ return reuseTimelineRows(previous, [...rows, new TimelineRow.BottomSpacer()])
+ })
+ const timelineRowByKey = createMemo(() => new Map(timelineRows().map((row) => [TimelineRow.key(row), row] as const)))
+ const timelineRowKeys = createMemo(() => [...timelineRowByKey().keys()], [] as string[], { equals: sameKeys })
+ const virtualCache = createMemo(() => readTimelineCache(sessionKey(), timelineRowKeys()))
+ const messageRowIndex = createMemo(() => {
+ const result = new Map()
+ timelineRows().forEach((row, index) => {
+ if (!("userMessageID" in row)) return
+ 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) => "userMessageID" in row && row.userMessageID === id)
+ if (index < 0) return
+ return [index]
+ })
+ const activeAssistantMessages = createMemo(() => {
+ const id = activeMessageID() ?? props.userMessages[props.userMessages.length - 1]?.id
+ if (!id) return emptyAssistantMessages
+ return assistantMessagesByParent().get(id) ?? emptyAssistantMessages
+ })
+ const activeAssistantContentVersion = createMemo(() =>
+ activeAssistantMessages()
+ .flatMap((message) => [
+ `${message.id}:${message.time.completed ?? ""}:${message.error?.name ?? ""}`,
+ ...getMsgParts(message.id).map((part) => {
+ if (part.type === "text" || part.type === "reasoning") return `${part.id}:${part.type}:${part.text.length}`
+ if (part.type === "tool") {
+ const metadata = "metadata" in part.state ? part.state.metadata : undefined
+ const output = "output" in part.state && typeof part.state.output === "string" ? part.state.output.length : 0
+ const metadataOutput =
+ metadata && typeof metadata === "object" && "output" in metadata && typeof metadata.output === "string"
+ ? metadata.output.length
+ : 0
+ return `${part.id}:${part.tool}:${part.state.status}:${output}:${metadataOutput}`
+ }
+ return `${part.id}:${part.type}`
+ }),
+ ])
+ .join("|"),
+ )
+
+ createEffect(
+ on(
+ () => [timelineRowKeys(), activeAssistantContentVersion(), sessionStatus().type] as const,
+ () => {
+ if (!virtualizer) return
+ if (!props.shouldAnchorBottom() && !measuredBottomAnchored) return
+ const keys = timelineRowKeys()
+ if (keys.length === 0) return
+ virtualizer.scrollToIndex(keys.length - 1, { align: "end" })
+ scheduleMeasuredBottomAnchor()
+ },
+ { defer: true },
+ ),
+ )
+
+ createEffect(() => {
+ props.setRevealMessage?.((id) => {
+ const index = messageRowIndex().get(id)
+ if (index === undefined) return
+ virtualizer?.scrollToIndex(index, { align: "center" })
+ })
+ })
+
+ let cacheSessionKey = sessionKey()
+ let cacheRowKeys = timelineRowKeys()
+ let virtualizerSessionKey = cacheSessionKey
+ let virtualizerRowKeys = cacheRowKeys
+ let bottomAnchorSessionKey = ""
+
+ const maybeAnchorBottom = () => {
+ const key = sessionKey()
+ if (bottomAnchorSessionKey === key) return
+ if (!virtualizer) return
+ const keys = timelineRowKeys()
+ if (keys.length === 0) return
+ bottomAnchorSessionKey = key
+ if (!props.shouldAnchorBottom()) return
+ virtualizer.scrollToIndex(keys.length - 1, { align: "end" })
+ }
+
+ createEffect(
+ on(
+ () => [sessionKey(), timelineRowKeys()] as const,
+ (next, prev) => {
+ if (prev && prev[0] !== next[0]) writeTimelineCache(prev[0], prev[1], virtualizer)
+ cacheSessionKey = next[0]
+ cacheRowKeys = next[1]
+ if (virtualizer) {
+ virtualizerSessionKey = cacheSessionKey
+ virtualizerRowKeys = cacheRowKeys
+ maybeAnchorBottom()
+ }
+ },
+ { defer: true },
+ ),
+ )
+
+ onCleanup(() => {
+ writeTimelineCache(virtualizerSessionKey, virtualizerRowKeys, virtualizer)
+ props.setRevealMessage?.(() => {})
})
const [title, setTitle] = createStore({
@@ -360,14 +557,150 @@ export function MessageTimeline(props: {
let more: HTMLButtonElement | undefined
let head: HTMLDivElement | undefined
+ let listRoot: HTMLDivElement | undefined
+ let listFrame: number | undefined
+ let contentFrame: number | undefined
+ let bottomAnchorFrame: number | undefined
+ let bottomAnchorFrames = 0
+ let measuredBottomAnchored = true
+ const [scrollRoot, setScrollRoot] = createSignal()
- createResizeObserver(
- () => head,
- () => {
- if (!head || head.clientWidth <= 0) return
- setBar("ms", pace(head.clientWidth))
- },
- )
+ const updateTitleMetrics = () => {
+ if (!head || head.clientWidth <= 0) return
+ setBar("ms", pace(head.clientWidth))
+ }
+
+ createResizeObserver(() => head, updateTitleMetrics)
+
+ const isMeasuredBottom = (root: HTMLDivElement) => root.scrollHeight - root.clientHeight - root.scrollTop <= 4
+
+ function anchorMeasuredBottom() {
+ if (!listRoot) return false
+ if (!measuredBottomAnchored) return false
+ listRoot.scrollTop = listRoot.scrollHeight
+ return true
+ }
+
+ function scheduleMeasuredBottomAnchor() {
+ // Workaround for virtua issue #301: virtua does not expose a synchronous item-resize hook for
+ // "stay at bottom if already at bottom". Tool rows can briefly outgrow the measured virtual
+ // height, so keep the scroll container bottom-locked for a few frames while measurement settles.
+ bottomAnchorFrames = 90
+ if (bottomAnchorFrame !== undefined) return
+
+ const tick = () => {
+ bottomAnchorFrame = undefined
+ if (!anchorMeasuredBottom()) {
+ bottomAnchorFrames = 0
+ return
+ }
+
+ bottomAnchorFrames = working() ? 12 : bottomAnchorFrames - 1
+ if (bottomAnchorFrames <= 0) return
+ bottomAnchorFrame = requestAnimationFrame(tick)
+ }
+
+ bottomAnchorFrame = requestAnimationFrame(tick)
+ }
+
+ const bindContentRoot = (root: HTMLDivElement) => {
+ const child = root.firstElementChild
+ props.setContentRef(child instanceof HTMLDivElement ? child : root)
+ }
+
+ const scheduleContentRoot = (root: HTMLDivElement) => {
+ if (contentFrame !== undefined) cancelAnimationFrame(contentFrame)
+ contentFrame = requestAnimationFrame(() => {
+ contentFrame = undefined
+ if (listRoot !== root) return
+ bindContentRoot(root)
+ })
+ }
+
+ const connectListRoot = (root: HTMLDivElement) => {
+ if (listRoot !== root) return
+ if (!root.isConnected || !root.ownerDocument.defaultView) {
+ listFrame = requestAnimationFrame(() => {
+ listFrame = undefined
+ connectListRoot(root)
+ })
+ return
+ }
+
+ props.setScrollRef(root)
+ measuredBottomAnchored = isMeasuredBottom(root)
+ setScrollRoot(root)
+ scheduleContentRoot(root)
+ }
+
+ const bindListRoot = (root: HTMLDivElement) => {
+ if (root === listRoot) return
+
+ if (listFrame !== undefined) cancelAnimationFrame(listFrame)
+ if (contentFrame !== undefined) cancelAnimationFrame(contentFrame)
+ listRoot = root
+ setScrollRoot(undefined)
+ connectListRoot(root)
+ }
+
+ const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => {
+ const root = event.currentTarget
+ const delta = normalizeWheelDelta({
+ deltaY: event.deltaY,
+ deltaMode: event.deltaMode,
+ rootHeight: root.clientHeight,
+ })
+ if (!delta) return
+ markBoundaryGesture({ root, target: event.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
+ }
+
+ const handleListTouchStart = (event: TouchEvent) => {
+ touchGesture = event.touches[0]?.clientY
+ }
+
+ const handleListTouchMove = (event: TouchEvent & { currentTarget: HTMLDivElement }) => {
+ const next = event.touches[0]?.clientY
+ const prev = touchGesture
+ touchGesture = next
+ if (next === undefined || prev === undefined) return
+
+ const delta = prev - next
+ if (!delta) return
+
+ markBoundaryGesture({
+ root: event.currentTarget,
+ target: event.target,
+ delta,
+ onMarkScrollGesture: props.onMarkScrollGesture,
+ })
+ }
+
+ const handleListTouchEnd = () => {
+ touchGesture = undefined
+ }
+
+ const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => {
+ if (event.target !== event.currentTarget) return
+ props.onMarkScrollGesture(event.currentTarget)
+ }
+
+ const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
+ measuredBottomAnchored = isMeasuredBottom(event.currentTarget)
+ props.onScheduleScrollState(event.currentTarget)
+ props.onHistoryScroll()
+ if (!props.hasScrollGesture()) return
+ props.onUserScroll()
+ props.onAutoScrollHandleScroll()
+ props.onMarkScrollGesture(event.currentTarget)
+ }
+
+ onCleanup(() => {
+ if (listFrame !== undefined) cancelAnimationFrame(listFrame)
+ if (contentFrame !== undefined) cancelAnimationFrame(contentFrame)
+ if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame)
+ setScrollRoot(undefined)
+ props.setScrollRef(undefined)
+ })
const viewShare = () => {
const url = shareUrl()
@@ -623,496 +956,633 @@ export function MessageTimeline(props: {
)
}
- return (
- {props.mobileFallback} }
- >
-
-
-
+ 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 = getMsgParts(message.id)
+ 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 getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
+
+ const renderAssistantPartGroup = (row: Accessor) => {
+ if (untrack(row).group.type === "context") {
+ const parts = createMemo(() => {
+ const group = row().group
+ if (group.type !== "context") return emptyTools
+ return group.refs
+ .map((ref) => getMsgPart(ref.messageID, ref.partID))
+ .filter((part): part is ToolPart => part?.type === "tool")
+ })
+
+ return
+ }
+
+ const message = createMemo(() => {
+ const group = row().group
+ if (group.type !== "part") return
+ return messageByID().get(group.ref.messageID)
+ })
+ const part = createMemo(() => {
+ const group = row().group
+ if (group.type !== "part") return
+ return getMsgPart(group.ref.messageID, group.ref.partID)
+ })
+
+ return (
+
+ {(message) => (
+
+ {(part) => (
+
+ )}
+
+ )}
+
+ )
+ }
+
+ function TimelineRowFrame(input: { row: Accessor; children: JSX.Element }) {
+ const anchor = () => {
+ const row = input.row()
+ return row._tag === "CommentStrip" || (row._tag === "UserMessage" && row.anchor)
+ }
+ const previousUserMessage = () => {
+ const row = input.row()
+ return (row._tag === "CommentStrip" || row._tag === "UserMessage") && row.previousUserMessage
+ }
+ const previousAssistantPart = () => {
+ const row = input.row()
+ return row._tag === "AssistantPart" && row.previousAssistantPart
+ }
+
+ return (
+
+
+ {input.children}
-
{
- const root = e.currentTarget
- const delta = normalizeWheelDelta({
- deltaY: e.deltaY,
- deltaMode: e.deltaMode,
- rootHeight: root.clientHeight,
- })
- if (!delta) return
- markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
- }}
- onTouchStart={(e) => {
- touchGesture = e.touches[0]?.clientY
- }}
- onTouchMove={(e) => {
- const next = e.touches[0]?.clientY
- const prev = touchGesture
- touchGesture = next
- if (next === undefined || prev === undefined) return
+
+ )
+ }
- const delta = prev - next
- if (!delta) return
-
- const root = e.currentTarget
- markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
- }}
- onTouchEnd={() => {
- touchGesture = undefined
- }}
- onTouchCancel={() => {
- touchGesture = undefined
- }}
- onPointerDown={(e) => {
- if (e.target !== e.currentTarget) return
- props.onMarkScrollGesture(e.currentTarget)
- }}
- onScroll={(e) => {
- props.onScheduleScrollState(e.currentTarget)
- props.onTurnBackfillScroll()
- if (!props.hasScrollGesture()) return
- props.onUserScroll()
- props.onAutoScrollHandleScroll()
- props.onMarkScrollGesture(e.currentTarget)
- }}
- onClick={props.onAutoScrollInteraction}
- class="relative min-w-0 w-full h-full"
- style={{
- "--session-title-height": showHeader() ? "40px" : "0px",
- "--sticky-accordion-top": showHeader() ? "48px" : "0px",
- }}
- >
-
-
- {
- head = el
- setBar("ms", pace(el.clientWidth))
- }}
- data-session-title
- classList={{
- "sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
- relative: true,
- "w-full": true,
- "pb-4": true,
- "pl-2 pr-3 md:pl-4 md:pr-3": true,
- "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
- }}
- >
-
-
-
-
-
-
-
-
-
- /
-
-
-
-
-
- {childTitle()}
-
- }
- >
- {
- titleRef = el
- }}
- data-slot="session-title-child"
- value={title.draft}
- disabled={titleMutation.isPending}
- class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px] pl-1 -ml-1"
- 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 (open) return
- }}
- >
- {
- more = el
- }}
- />
-
- {
- if (title.pendingRename) {
- event.preventDefault()
- setTitle("pendingRename", false)
- openTitleEditor()
- return
- }
- if (title.pendingShare) {
- event.preventDefault()
- requestAnimationFrame(() => {
- setShare({ open: true, dismiss: null })
- setTitle("pendingShare", false)
- })
- }
- }}
- >
- {
- setTitle("pendingRename", true)
- setTitle("menuOpen", false)
- }}
- >
- {language.t("common.rename")}
-
-
- {
- setTitle({ pendingShare: true, menuOpen: false })
- }}
- >
-
- {language.t("session.share.action.share")}
-
-
-
- void archiveSession(id)}>
- {language.t("common.archive")}
-
-
- dialog.show(() => )}
- >
- {language.t("common.delete")}
-
-
-
-
-
- more}
- placement="bottom-end"
- gutter={4}
- modal={false}
- onOpenChange={(open) => {
- if (open) setShare("dismiss", null)
- setShare("open", open)
- }}
- >
-
- {
- setShare({ dismiss: "escape", open: false })
- event.preventDefault()
- event.stopPropagation()
- }}
- onPointerDownOutside={() => {
- setShare({ dismiss: "outside", open: false })
- }}
- onFocusOutside={() => {
- setShare({ dismiss: "outside", open: false })
- }}
- onCloseAutoFocus={(event) => {
- if (share.dismiss === "outside") event.preventDefault()
- setShare("dismiss", null)
- }}
- >
-
-
-
- {language.t("session.share.popover.title")}
-
-
- {shareUrl()
- ? language.t("session.share.popover.description.shared")
- : language.t("session.share.popover.description.unshared")}
-
-
-
-
- {shareMutation.isPending
- ? language.t("session.share.action.publishing")
- : language.t("session.share.action.publish")}
-
- }
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ const renderTimelineRow = (row: Accessor
) => {
+ switch (row()._tag) {
+ case "CommentStrip": {
+ const commentStripRow = row as Accessor>
+ const comments = createMemo(() =>
+ getMsgParts(commentStripRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []),
+ )
+ return (
+
+
+
+ )
+ }
+ case "UserMessage": {
+ const userMessageRow = row as Accessor>
+ const message = createMemo(() => {
+ const m = messageByID().get(userMessageRow().userMessageID)
+ if (m?.role === "user") return m
+ })
+ return (
+
+
+ {(message) => (
+
+ )}
+
+
+ )
+ }
+ case "TurnDivider": {
+ const turnDividerRow = row as Accessor>
+ return (
+
+
+
+ )
+ }
+ case "AssistantPart": {
+ const assistantPartRow = row as Accessor>
+ return (
+
+
+
+ {renderAssistantPartGroup(assistantPartRow)}
+
+
+
+ )
+ }
+ case "Thinking": {
+ const thinkingRow = row as Accessor>
+ return (
+
+
+
+
+
+ )
+ }
+ case "Retry": {
+ const retryRow = row as Accessor>
+ return (
+
+
+
+
+
+ )
+ }
+ case "DiffSummary": {
+ const diffSummaryRow = row as Accessor>
+ return (
+
+
+
+
+
+ )
+ }
+ case "Error": {
+ const errorRow = row as Accessor>
+ return (
+
+
+
+ {errorRow().text}
+
+
+
+ )
+ }
+ case "BottomSpacer":
+ return
+ }
+ }
+
+ function TimelineRowView(props: { rowKey: string }) {
+ return (
+
+ {(item) => renderTimelineRow(item)}
+
+ )
+ }
+
+ return (
+
+
+
+
+ {
+ head = el
+ updateTitleMetrics()
+ }}
+ data-session-title
+ classList={{
+ "sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
+ relative: true,
+ "w-full": true,
+ "pb-4": true,
+ "pl-2 pr-3 md:pl-4 md:pr-3": true,
+ "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
+ }}
+ >
+
+
+
+
+
+
+
+
+
+ /
+
+
+
+
+
+ {childTitle()}
+
+ }
+ >
+ {
+ titleRef = el
+ }}
+ data-slot="session-title-child"
+ value={title.draft}
+ disabled={titleMutation.isPending}
+ class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px] pl-1 -ml-1"
+ 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}
+ />
+
-
-
-
0 || props.historyMore}>
-
-
-
-
-
- {(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}>
-
-
-
+ {(id) => (
+
+
+
+ {
+ setTitle("menuOpen", open)
+ if (open) return
}}
- />
-
- )
- }}
-
+ >
+ {
+ more = el
+ }}
+ />
+
+ {
+ if (title.pendingRename) {
+ event.preventDefault()
+ setTitle("pendingRename", false)
+ openTitleEditor()
+ return
+ }
+ if (title.pendingShare) {
+ event.preventDefault()
+ requestAnimationFrame(() => {
+ setShare({ open: true, dismiss: null })
+ setTitle("pendingShare", false)
+ })
+ }
+ }}
+ >
+ {
+ setTitle("pendingRename", true)
+ setTitle("menuOpen", false)
+ }}
+ >
+ {language.t("common.rename")}
+
+
+ {
+ setTitle({ pendingShare: true, menuOpen: false })
+ }}
+ >
+
+ {language.t("session.share.action.share")}
+
+
+
+ void archiveSession(id)}>
+ {language.t("common.archive")}
+
+
+ dialog.show(() => )}
+ >
+ {language.t("common.delete")}
+
+
+
+
+
+ more}
+ placement="bottom-end"
+ gutter={4}
+ modal={false}
+ onOpenChange={(open) => {
+ if (open) setShare("dismiss", null)
+ setShare("open", open)
+ }}
+ >
+
+ {
+ setShare({ dismiss: "escape", open: false })
+ event.preventDefault()
+ event.stopPropagation()
+ }}
+ onPointerDownOutside={() => {
+ setShare({ dismiss: "outside", open: false })
+ }}
+ onFocusOutside={() => {
+ setShare({ dismiss: "outside", open: false })
+ }}
+ onCloseAutoFocus={(event) => {
+ if (share.dismiss === "outside") event.preventDefault()
+ setShare("dismiss", null)
+ }}
+ >
+
+
+
+ {language.t("session.share.popover.title")}
+
+
+ {shareUrl()
+ ? language.t("session.share.popover.description.shared")
+ : language.t("session.share.popover.description.unshared")}
+
+
+
+
+ {shareMutation.isPending
+ ? language.t("session.share.action.publishing")
+ : language.t("session.share.action.publish")}
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+
+
+ {(root) => (
+ {
+ if (!handle) {
+ writeTimelineCache(virtualizerSessionKey, virtualizerRowKeys, virtualizer)
+ virtualizer = undefined
+ return
+ }
+ virtualizer = handle
+ virtualizerSessionKey = cacheSessionKey
+ virtualizerRowKeys = cacheRowKeys
+ maybeAnchorBottom()
+ scheduleContentRoot(root())
+ }}
+ >
+ {(key) => }
+
+ )}
+
-
+
)
}
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 c582749d1c..65577abae2 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 28e2f86ba5..a335aa3b77 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 27ad7c3c70..cb9f9b17fe 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 94da6cc669..07b8022abe 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -150,6 +150,7 @@ export interface MessagePartProps {
message: MessageType
hideDetails?: boolean
defaultOpen?: boolean
+ deferToolContent?: boolean
showAssistantCopyPartID?: string | null
turnDurationMs?: number
}
@@ -486,12 +487,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"
@@ -520,14 +521,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
@@ -575,7 +576,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"
@@ -591,7 +592,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)
}
@@ -904,7 +905,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(
@@ -914,7 +915,13 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
const summary = createMemo(() => contextToolSummary(props.parts))
return (
-
+ part.id).join(",")}
+ >
+
0}>
@@ -1228,6 +1235,7 @@ export function Part(props: MessagePartProps) {
message={props.message}
hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen}
+ deferToolContent={props.deferToolContent}
showAssistantCopyPartID={props.showAssistantCopyPartID}
turnDurationMs={props.turnDurationMs}
/>
@@ -1244,6 +1252,7 @@ export interface ToolProps {
status?: string
hideDetails?: boolean
defaultOpen?: boolean
+ deferContent?: boolean
forceOpen?: boolean
locked?: boolean
}
@@ -1344,7 +1353,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
return (
-
+
{(error) => {
@@ -1382,6 +1391,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
status={part().state.status}
hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen}
+ deferContent={props.deferToolContent}
/>
@@ -1487,7 +1497,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
return (
-
+
}>
@@ -1531,7 +1541,7 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
return (
-
+
}>
@@ -1913,7 +1923,7 @@ ToolRegistry.register({
@@ -1974,7 +1984,7 @@ ToolRegistry.register({
@@ -2056,7 +2066,7 @@ ToolRegistry.register({
-
+