- {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 (
-
-
-
- )
- }
- 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 (
+
}
+ keyed
+ >
+ {(row) => (
+
row}>
+
+
+ {(row) => {
+ const comments = createMemo(() =>
+ getMsgParts(row.userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []),
+ )
+ return (
+ row}>
+
+
+ )
+ }}
+
+
+ {(row) => {
+ const message = createMemo(() => {
+ const m = messageByID().get(row.userMessageID)
+ if (m?.role === "user") return m
+ })
+ return (
+
+ {(message) => (
+
+ )}
+
+ )
+ }}
+
+
+ {(row) => (
+
-
- )}
-
+ )}
+
+
+ {(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
- }
+ )}
+
+ )
}
function TimelineRowView(props: { rowKey: string }) {
- const row = createMemo(() => timelineRowByKey().get(props.rowKey))
-
- return {(item) => renderTimelineRow(item)}
+ return (
+
+ {(item) => renderTimelineRow(item)}
+
+ )
}
return (
- {props.mobileFallback} }
- >
-
-
+
+
+ {
+ 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`,
}}
>
-
+
+
+
+
+
+
+
+
+
+ /
+
+
-
-
-
-
-
-
-
- /
-
-
+
-
-
- {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")}
-
- }
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )}
+
+
+ {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")}
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+
+ {(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) => }
-
- )}
-
-
-
+
-
+
)
}