diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index e5f2ca72c..f5da21a60 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -92,13 +92,9 @@ function createSessionHistoryLoader(input: SessionHistoryWindowInput) { shift: false, }) - const userMessages = createMemo( - () => input.visibleUserMessages(), - emptyUserMessages, - { - equals: same, - }, - ) + const userMessages = createMemo(() => input.visibleUserMessages(), emptyUserMessages, { + equals: same, + }) const cancelShiftReset = () => { if (shiftFrame === undefined) return @@ -1692,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", + })} +
+
- + {} + 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 519fbc28a..e28b9ddcd 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -1,4 +1,18 @@ -import { createEffect, createMemo, createSignal, For, Index, on, onCleanup, Show, mapArray, type Accessor, type JSX } from "solid-js" +import { + createEffect, + createMemo, + createSignal, + For, + Index, + Match, + Switch, + on, + onCleanup, + Show, + mapArray, + 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" @@ -9,13 +23,10 @@ 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" @@ -36,8 +47,6 @@ import type { AssistantMessage, Message as MessageType, Part as PartType, - SnapshotFileDiff, - TextPart, ToolPart, UserMessage, } from "@opencode-ai/sdk/v2" @@ -60,17 +69,8 @@ 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[] = [] @@ -78,28 +78,7 @@ const emptyTools: ToolPart[] = [] const emptyAssistantMessages: AssistantMessage[] = [] const idle = { type: "idle" as const } -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 } - | { key: string; type: "bottom-spacer" } - -type FramedTimelineRow = Exclude -type TimelineRowByType = Extract +type FramedTimelineRow = Exclude function sameKeys(a: readonly string[] | undefined, b: readonly string[] | undefined) { if (a === b) return true @@ -126,181 +105,16 @@ function writeTimelineCache(id: string, keys: readonly string[], handle: Virtual while (timelineCache.size > timelineCacheLimit) timelineCache.delete(timelineCache.keys().next().value!) } -function samePartGroup(a: PartGroup, b: PartGroup) { - if (a === b) return true - if (a.key !== b.key) return false - if (a.type !== b.type) return false - if (a.type === "part") { - if (b.type !== "part") return false - return a.ref.messageID === b.ref.messageID && a.ref.partID === b.ref.partID - } - if (b.type !== "context") return false - if (a.refs.length !== b.refs.length) return false - return a.refs.every((ref, index) => ref.messageID === b.refs[index]?.messageID && ref.partID === b.refs[index]?.partID) -} - -function sameSummaryDiff(a: SummaryDiff, b: SummaryDiff) { - return a.file === b.file && a.patch === b.patch && a.additions === b.additions && a.deletions === b.deletions && a.status === b.status -} - -function sameSummaryDiffs(a: readonly SummaryDiff[], b: readonly SummaryDiff[]) { - if (a === b) return true - if (a.length !== b.length) return false - return a.every((diff, index) => sameSummaryDiff(diff, b[index]!)) -} - -function sameTimelineRow(a: TimelineRow, b: TimelineRow) { - if (a === b) return true - if (a.key !== b.key) return false - if (a.type !== b.type) return false - if (a.type === "bottom-spacer") return b.type === "bottom-spacer" - if (b.type === "bottom-spacer") return false - if (a.userMessageID !== b.userMessageID) return false - - switch (a.type) { - case "comment-strip": - return b.type === "comment-strip" && a.previousUserMessage === b.previousUserMessage - case "user-message": - return b.type === "user-message" && a.anchor === b.anchor && a.previousUserMessage === b.previousUserMessage - case "turn-divider": - return b.type === "turn-divider" && a.label === b.label - case "assistant-part": - return ( - b.type === "assistant-part" && - a.previousAssistantPart === b.previousAssistantPart && - a.lastAssistantPart === b.lastAssistantPart && - samePartGroup(a.group, b.group) - ) - case "thinking": - return b.type === "thinking" && a.reasoningHeading === b.reasoningHeading - case "retry": - return b.type === "retry" - case "diff-summary": - return b.type === "diff-summary" && sameSummaryDiffs(a.diffs, b.diffs) - case "error": - return b.type === "error" && a.text === b.text - } -} - -function reuseTimelineRows(previous: TimelineRow[] | undefined, rows: TimelineRow[]) { +function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) { if (!previous?.length) return rows - const byKey = new Map(previous.map((row) => [row.key, row] as const)) + const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const)) return rows.map((row) => { - const existing = byKey.get(row.key) + const existing = byKey.get(TimelineRow.key(row)) if (!existing) return row - return sameTimelineRow(existing, row) ? existing : row + return TimelineRow.equals(existing, row) ? existing : row }) } -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) - if (!next) return [] - return [ - { - path: next.path, - comment: next.comment, - selection: next.selection - ? { - startLine: next.selection.startLine, - endLine: next.selection.endLine, - } - : undefined, - }, - ] - }) - const taskDescription = (part: PartType, sessionID: string) => { if (part.type !== "tool" || part.tool !== "task") return const metadata = "metadata" in part.state ? part.state.metadata : undefined @@ -349,12 +163,7 @@ function TimelineThinkingRow(props: { reasoningHeading?: string; showReasoningSu
- +
) @@ -373,10 +182,14 @@ function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) { const visible = createMemo(() => (showAll() ? props.diffs : props.diffs.slice(0, maxFiles))) return ( -
+
- {props.diffs.length} {language.t("ui.sessionTurn.diffs.changed")} {" "} + {props.diffs.length} {language.t("ui.sessionTurn.diffs.changed")}{" "} {language.t(props.diffs.length === 1 ? "ui.common.file.one" : "ui.common.file.other")} @@ -451,8 +264,6 @@ function TimelineDiffView(props: { diff: SummaryDiff }) { } export function MessageTimeline(props: { - mobileChanges: boolean - mobileFallback: JSX.Element actions?: UserActions scroll: { overflow: boolean; bottom: boolean; jump: boolean } onResumeScroll: () => void @@ -573,11 +384,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) }) @@ -594,109 +406,31 @@ export function MessageTimeline(props: { mapArray( () => props.userMessages, (userMessage, indexAccessor) => { - return createMemo((previous: TimelineRow[] | undefined) => { - const rows: TimelineRow[] = [] - const status = sessionStatus() - const active = activeMessageID() - const showReasoning = settings.general.showReasoningSummaries() - - 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 })), + 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, ) - 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 = indexAccessor() > 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 reuseTimelineRows(previous, rows) }) - } - ) + }, + ), ) - const timelineRows = createMemo((previous: TimelineRow[] | undefined) => { + const timelineRows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) => { const rows = messageRowMemos().flatMap((memo) => memo()) if (rows.length === 0) return rows - return reuseTimelineRows(previous, [...rows, { key: "bottom-spacer", type: "bottom-spacer" }]) + return reuseTimelineRows(previous, [...rows, new TimelineRow.BottomSpacer()]) }) - const timelineRowKeys = createMemo(() => timelineRows().map((row) => row.key), [] as string[], { equals: sameKeys }) + 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 timelineRowByKey = createMemo(() => new Map(timelineRows().map((row) => [row.key, row] as const))) const messageRowIndex = createMemo(() => { const result = new Map() timelineRows().forEach((row, index) => { @@ -791,10 +525,7 @@ export function MessageTimeline(props: { setBar("ms", pace(head.clientWidth)) } - createResizeObserver( - () => head, - updateTitleMetrics, - ) + createResizeObserver(() => head, updateTitleMetrics) const bindContentRoot = (root: HTMLDivElement) => { const child = root.firstElementChild @@ -859,7 +590,12 @@ export function MessageTimeline(props: { const delta = prev - next if (!delta) return - markBoundaryGesture({ root: event.currentTarget, target: event.target, delta, onMarkScrollGesture: props.onMarkScrollGesture }) + markBoundaryGesture({ + root: event.currentTarget, + target: event.target, + delta, + onMarkScrollGesture: props.onMarkScrollGesture, + }) } const handleListTouchEnd = () => { @@ -1168,7 +904,7 @@ export function MessageTimeline(props: { const message = messages[i] if (!message) continue - const parts = sync.data.part[message.id] ?? emptyParts + 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 @@ -1177,16 +913,15 @@ export function MessageTimeline(props: { } } - const partByRef = (messageID: string, partID: string) => - (sync.data.part[messageID] ?? emptyParts).find((part) => part.id === partID) + const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID) - const renderAssistantPartGroup = (row: Accessor>) => { + const renderAssistantPartGroup = (row: Accessor) => { if (row().group.type === "context") { const parts = createMemo(() => { const group = row().group if (group.type !== "context") return emptyTools return group.refs - .map((ref) => partByRef(ref.messageID, ref.partID)) + .map((ref) => getMsgPart(ref.messageID, ref.partID)) .filter((part): part is ToolPart => part?.type === "tool") }) @@ -1201,7 +936,7 @@ export function MessageTimeline(props: { const part = createMemo(() => { const group = row().group if (group.type !== "part") return - return partByRef(group.ref.messageID, group.ref.partID) + return getMsgPart(group.ref.messageID, group.ref.partID) }) return ( @@ -1214,7 +949,11 @@ export function MessageTimeline(props: { message={message()} showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)} turnDurationMs={turnDurationMs(row().userMessageID)} - defaultOpen={partDefaultOpen(part(), settings.general.shellToolPartsExpanded(), settings.general.editToolPartsExpanded())} + defaultOpen={partDefaultOpen( + part(), + settings.general.shellToolPartsExpanded(), + settings.general.editToolPartsExpanded(), + )} deferToolContent={false} /> )} @@ -1227,22 +966,22 @@ export function MessageTimeline(props: { function TimelineRowFrame(input: { row: Accessor; children: JSX.Element }) { const anchor = () => { const row = input.row() - return row.type === "comment-strip" || (row.type === "user-message" && row.anchor) + return row._tag === "CommentStrip" || (row._tag === "UserMessage" && props.anchor) } const previousUserMessage = () => { const row = input.row() - return (row.type === "comment-strip" || row.type === "user-message") && row.previousUserMessage + return (row._tag === "CommentStrip" || row._tag === "UserMessage") && row.previousUserMessage } const previousAssistantPart = () => { const row = input.row() - return row.type === "assistant-part" && row.previousAssistantPart + return row._tag === "AssistantPart" && row.previousAssistantPart } return (
) => { - switch (row().type) { - case "comment-strip": { - const commentStripRow = row as Accessor> - const comments = createMemo(() => messageComments(sync.data.part[commentStripRow().userMessageID] ?? emptyParts)) - 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 userRow = row as Accessor> - const message = createMemo(() => { - const message = messageByID().get(userRow().userMessageID) - if (message?.role === "user") return message - }) - return ( - - - {(message) => ( -
-
- + const renderTimelineRow = (row: TimelineRow.TimelineRow) => { + return ( +
- )} - + )} + + + {(row) => ( +
+
+ {renderAssistantPartGroup(() => row)} +
+
+ )} +
+ + {(row) => ( +
+ +
+ )} +
+ + {(row) => ( +
+ +
+ )} +
+ + {(row) => ( +
+ +
+ )} +
+ + {(row) => ( +
+ + {row.text} + +
+ )} +
+ - ) - } - case "turn-divider": { - const dividerRow = row as Accessor> - return ( - -
-
- -
-
-
- ) - } - case "assistant-part": { - const assistantRow = row as Accessor> - return ( - -
-
- {renderAssistantPartGroup(assistantRow)} -
-
-
- ) - } - case "thinking": { - const thinkingRow = row as Accessor> - return ( - -
- -
-
- ) - } - case "retry": { - const retryRow = row as Accessor> - return ( - -
- -
-
- ) - } - case "diff-summary": { - const diffSummaryRow = row as Accessor> - return ( - -
- -
-
- ) - } - case "error": { - const errorRow = row as Accessor> - return ( - -
- - {errorRow().text} - -
-
- ) - } - case "bottom-spacer": - 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, + }} + > +
{ - 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, + data-component="session-progress" + data-state={workingStatus()} + aria-hidden="true" + style={{ + "--session-progress-color": tint() ?? "var(--icon-interactive-base)", + "--session-progress-ms": `${bar.ms}ms`, }} > - +
+
+
+
+
+
+ + + + + + {(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) => } + + )} - - - {(root) => ( - { - if (!handle) { - writeTimelineCache(virtualizerSessionKey, virtualizerRowKeys, virtualizer) - virtualizer = undefined - return - } - virtualizer = handle - virtualizerSessionKey = cacheSessionKey - virtualizerRowKeys = cacheRowKeys - maybeAnchorBottom() - scheduleContentRoot(root()) - }} - > - {(key) => } - - )} - - -
+
-
+
) }