From d81d63045ac619bc9201df4ae2e003a2d08596d1 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 05:18:39 -0600 Subject: [PATCH] wip(desktop): session turn state consolidation --- packages/ui/src/components/session-turn.tsx | 626 ++++++++++---------- 1 file changed, 306 insertions(+), 320 deletions(-) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index ad2e6c36e..e6654f480 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -40,6 +40,9 @@ export function SessionTurn( .sort((a, b) => a.id.localeCompare(b.id)), ) const message = createMemo(() => userMessages()?.find((m) => m.id === props.messageID)) + + if (!message()) return null + const status = createMemo( () => data.store.session_status[props.sessionID] ?? { @@ -49,379 +52,362 @@ export function SessionTurn( const working = createMemo(() => status()?.type !== "idle") let scrollRef: HTMLDivElement | undefined - const [state, setState] = createStore({ + + const assistantMessages = createMemo(() => { + return messages()?.filter((m) => m.role === "assistant" && m.parentID == message()!.id) as AssistantMessage[] + }) + const lastAssistantMessage = createMemo(() => assistantMessages()?.at(-1)) + const assistantMessageParts = createMemo(() => assistantMessages()?.flatMap((m) => data.store.part[m.id])) + const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error) + const parts = createMemo(() => data.store.part[message()!.id]) + const lastTextPart = createMemo(() => + assistantMessageParts() + .filter((p) => p?.type === "text") + ?.at(-1), + ) + const summary = createMemo(() => message()!.summary?.body ?? lastTextPart()?.text) + const lastTextPartShown = createMemo(() => !message()!.summary?.body && (lastTextPart()?.text?.length ?? 0) > 0) + + const assistantParts = createMemo(() => assistantMessages().flatMap((m) => data.store.part[m.id])) + const currentTask = createMemo( + () => + assistantParts().findLast( + (p) => + p && + p.type === "tool" && + p.tool === "task" && + p.state && + "metadata" in p.state && + p.state.metadata && + p.state.metadata.sessionId && + p.state.status === "running", + ) as ToolPart, + ) + const resolvedParts = createMemo(() => { + let resolved = assistantParts() + const task = currentTask() + if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) { + const msgs = data.store.message[task.state.metadata.sessionId as string]?.filter((m) => m.role === "assistant") + resolved = msgs?.flatMap((m) => data.store.part[m.id]) ?? assistantParts() + } + return resolved + }) + const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0)) + const rawStatus = createMemo(() => { + const last = lastPart() + if (!last) return undefined + + if (last.type === "tool") { + switch (last.tool) { + case "task": + return "Delegating work" + case "todowrite": + case "todoread": + return "Planning next steps" + case "read": + return "Gathering context" + case "list": + case "grep": + case "glob": + return "Searching the codebase" + case "webfetch": + return "Searching the web" + case "edit": + case "write": + return "Making edits" + case "bash": + return "Running commands" + default: + break + } + } else if (last.type === "reasoning") { + const text = last.text ?? "" + const match = text.trimStart().match(/^\*\*(.+?)\*\*/) + if (match) return `Thinking · ${match[1].trim()}` + return "Thinking" + } else if (last.type === "text") { + return "Gathering thoughts" + } + return undefined + }) + + function duration() { + const completed = lastAssistantMessage()?.time.completed + const from = DateTime.fromMillis(message()!.time.created) + const to = completed ? DateTime.fromMillis(completed) : DateTime.now() + const interval = Interval.fromDateTimes(from, to) + const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"] + + return interval.toDuration(unit).normalize().toHuman({ + notation: "compact", + unitDisplay: "narrow", + compactDisplay: "short", + showZeros: false, + }) + } + + const [store, setStore] = createStore({ stickyTitleRef: undefined as HTMLDivElement | undefined, stickyTriggerRef: undefined as HTMLDivElement | undefined, userScrolled: false, stickyHeaderHeight: 0, scrollY: 0, autoScrolling: false, + status: rawStatus(), + stepsExpanded: true, + duration: duration(), + lastStatusChange: Date.now(), + statusTimeout: undefined as number | undefined, }) function handleScroll() { if (!scrollRef) return // prevents scroll loops if (working() && scrollRef.scrollTop < 100) return - setState("scrollY", scrollRef.scrollTop) - if (state.autoScrolling) return + setStore("scrollY", scrollRef.scrollTop) + if (store.autoScrolling) return const { scrollTop, scrollHeight, clientHeight } = scrollRef const atBottom = scrollHeight - scrollTop - clientHeight < 50 if (!atBottom && working()) { - setState("userScrolled", true) + setStore("userScrolled", true) } } function handleInteraction() { if (working()) { - setState("userScrolled", true) + setStore("userScrolled", true) } } function scrollToBottom() { - if (!scrollRef || state.userScrolled || !working() || state.autoScrolling) return - setState("autoScrolling", true) + if (!scrollRef || store.userScrolled || !working() || store.autoScrolling) return + setStore("autoScrolling", true) requestAnimationFrame(() => { scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "instant" }) requestAnimationFrame(() => { - setState("autoScrolling", false) + setStore("autoScrolling", false) }) }) } createEffect(() => { if (!working()) { - setState("userScrolled", false) + setStore("userScrolled", false) } }) createResizeObserver( - () => state.stickyTitleRef, + () => store.stickyTitleRef, ({ height }) => { - const triggerHeight = state.stickyTriggerRef?.offsetHeight ?? 0 - setState("stickyHeaderHeight", height + triggerHeight + 8) + const triggerHeight = store.stickyTriggerRef?.offsetHeight ?? 0 + setStore("stickyHeaderHeight", height + triggerHeight + 8) }, ) createResizeObserver( - () => state.stickyTriggerRef, + () => store.stickyTriggerRef, ({ height }) => { - const titleHeight = state.stickyTitleRef?.offsetHeight ?? 0 - setState("stickyHeaderHeight", titleHeight + height + 8) + const titleHeight = store.stickyTitleRef?.offsetHeight ?? 0 + setStore("stickyHeaderHeight", titleHeight + height + 8) }, ) + createEffect(() => { + lastPart() + scrollToBottom() + }) + + createEffect(() => { + const timer = setInterval(() => { + setStore("duration", duration()) + }, 1000) + onCleanup(() => clearInterval(timer)) + }) + + createEffect(() => { + const newStatus = rawStatus() + if (newStatus === store.status || !newStatus) return + + const timeSinceLastChange = Date.now() - store.lastStatusChange + + if (timeSinceLastChange >= 2500) { + setStore("status", newStatus) + setStore("lastStatusChange", Date.now()) + if (store.statusTimeout) { + clearTimeout(store.statusTimeout) + setStore("statusTimeout", undefined) + } + } else { + if (store.statusTimeout) clearTimeout(store.statusTimeout) + setStore( + "statusTimeout", + setTimeout(() => { + setStore("status", rawStatus()) + setStore("lastStatusChange", Date.now()) + setStore("statusTimeout", undefined) + }, 2500 - timeSinceLastChange) as unknown as number, + ) + } + }) + + createEffect((prev) => { + const isWorking = working() + if (prev && !isWorking && !store.userScrolled) { + setStore("stepsExpanded", false) + } + return isWorking + }, working()) + return ( -
+
- - {(message) => { - const assistantMessages = createMemo(() => { - return messages()?.filter( - (m) => m.role === "assistant" && m.parentID == message().id, - ) as AssistantMessage[] - }) - const lastAssistantMessage = createMemo(() => assistantMessages()?.at(-1)) - const assistantMessageParts = createMemo(() => assistantMessages()?.flatMap((m) => data.store.part[m.id])) - const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error) - const parts = createMemo(() => data.store.part[message().id]) - const lastTextPart = createMemo(() => - assistantMessageParts() - .filter((p) => p?.type === "text") - ?.at(-1), - ) - const summary = createMemo(() => message().summary?.body ?? lastTextPart()?.text) - const lastTextPartShown = createMemo( - () => !message().summary?.body && (lastTextPart()?.text?.length ?? 0) > 0, - ) - - const assistantParts = createMemo(() => assistantMessages().flatMap((m) => data.store.part[m.id])) - const currentTask = createMemo( - () => - assistantParts().findLast( - (p) => - p && - p.type === "tool" && - p.tool === "task" && - p.state && - "metadata" in p.state && - p.state.metadata && - p.state.metadata.sessionId && - p.state.status === "running", - ) as ToolPart, - ) - const resolvedParts = createMemo(() => { - let resolved = assistantParts() - const task = currentTask() - if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) { - const messages = data.store.message[task.state.metadata.sessionId as string]?.filter( - (m) => m.role === "assistant", - ) - resolved = messages?.flatMap((m) => data.store.part[m.id]) ?? assistantParts() - } - return resolved - }) - const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0)) - const rawStatus = createMemo(() => { - const last = lastPart() - if (!last) return undefined - - if (last.type === "tool") { - switch (last.tool) { - case "task": - return "Delegating work" - case "todowrite": - case "todoread": - return "Planning next steps" - case "read": - return "Gathering context" - case "list": - case "grep": - case "glob": - return "Searching the codebase" - case "webfetch": - return "Searching the web" - case "edit": - case "write": - return "Making edits" - case "bash": - return "Running commands" - default: - break - } - } else if (last.type === "reasoning") { - const text = last.text ?? "" - const match = text.trimStart().match(/^\*\*(.+?)\*\*/) - if (match) return `Thinking · ${match[1].trim()}` - return "Thinking" - } else if (last.type === "text") { - return "Gathering thoughts" - } - return undefined - }) - - function duration() { - const completed = lastAssistantMessage()?.time.completed - const from = DateTime.fromMillis(message()!.time.created) - const to = completed ? DateTime.fromMillis(completed) : DateTime.now() - const interval = Interval.fromDateTimes(from, to) - const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"] - - return interval.toDuration(unit).normalize().toHuman({ - notation: "compact", - unitDisplay: "narrow", - compactDisplay: "short", - showZeros: false, - }) - } - - createEffect(() => { - lastPart() - scrollToBottom() - }) - - const [store, setStore] = createStore({ - status: rawStatus(), - stepsExpanded: true, - duration: duration(), - }) - - createEffect(() => { - const timer = setInterval(() => { - setStore("duration", duration()) - }, 1000) - onCleanup(() => clearInterval(timer)) - }) - - let lastStatusChange = Date.now() - let statusTimeout: number | undefined - createEffect(() => { - const newStatus = rawStatus() - if (newStatus === store.status || !newStatus) return - - const timeSinceLastChange = Date.now() - lastStatusChange - - if (timeSinceLastChange >= 2500) { - setStore("status", newStatus) - lastStatusChange = Date.now() - if (statusTimeout) { - clearTimeout(statusTimeout) - statusTimeout = undefined - } - } else { - if (statusTimeout) clearTimeout(statusTimeout) - statusTimeout = setTimeout(() => { - setStore("status", rawStatus()) - lastStatusChange = Date.now() - statusTimeout = undefined - }, 2500 - timeSinceLastChange) as unknown as number - } - }) - - createEffect((prev) => { - const isWorking = working() - if (prev && !isWorking && !state.userScrolled) { - setStore("stepsExpanded", false) - } - return isWorking - }, working()) - - return ( -
- {/* Title (sticky) */} -
setState("stickyTitleRef", el)} data-slot="session-turn-sticky-title"> -
-
- - - - - -

{message().summary?.title}

-
-
-
-
-
- {/* User Message */} -
- -
- {/* Trigger (sticky) */} -
setState("stickyTriggerRef", el)} data-slot="session-turn-response-trigger"> - +
+ {/* Response */} + +
+ + {(assistantMessage) => { + const parts = createMemo(() => data.store.part[assistantMessage.id] ?? []) + const last = createMemo(() => + parts() + .filter((p) => p?.type === "text") + .at(-1), + ) + return ( - {store.status ?? "Considering next steps"} - Hide steps - Show steps + + p?.id !== last()?.id)} /> + + + + - · - {store.duration} - - -
- {/* Response */} - -
- - {(assistantMessage) => { - const parts = createMemo(() => data.store.part[assistantMessage.id] ?? []) - const last = createMemo(() => - parts() - .filter((p) => p?.type === "text") - .at(-1), - ) - return ( - - - p?.id !== last()?.id)} - /> - - - - - - ) - }} - - - - {error()?.data?.message as string} - - -
-
- {/* Summary */} - -
-
-

- - Summary - Response - -

- - {(summary) => ( - - )} - -
- - - {(diff) => ( - - - -
-
- -
- - {getDirectory(diff.file)}‎ - - {getFilename(diff.file)} -
-
-
- - -
-
-
-
- - - -
- )} -
-
-
-
- - - {error()?.data?.message as string} - + ) + }} + + + + {error()?.data?.message as string} + + +
+
+ {/* Summary */} + +
+
+

+ + Summary + Response + +

+ + {(summary) => ( + + )}
- ) - }} - + + + {(diff) => ( + + + +
+
+ +
+ + {getDirectory(diff.file)}‎ + + {getFilename(diff.file)} +
+
+
+ + +
+
+
+
+ + + +
+ )} +
+
+
+
+ + + {error()?.data?.message as string} + + +
{props.children}