diff options
| author | Adam <[email protected]> | 2025-12-18 11:16:18 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-12-18 11:16:33 -0600 |
| commit | c868a4088d374534a5e33795b8cc419c75f3654e (patch) | |
| tree | 57348cb41e19020f1b0a0c5e94534280c901629c | |
| parent | 83d8a88c90001637d68eb2cacb380bd5d3ede7a0 (diff) | |
| download | opencode-c868a4088d374534a5e33795b8cc419c75f3654e.tar.gz opencode-c868a4088d374534a5e33795b8cc419c75f3654e.zip | |
fix(desktop): rendering shell mode messages
| -rw-r--r-- | packages/ui/src/components/message-part.css | 13 | ||||
| -rw-r--r-- | packages/ui/src/components/message-part.tsx | 26 | ||||
| -rw-r--r-- | packages/ui/src/components/session-turn.tsx | 698 |
3 files changed, 385 insertions, 352 deletions
diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index b79ac2894..49392d6b7 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -152,9 +152,22 @@ align-items: flex-start; justify-content: flex-start; + [data-component="markdown"] { + width: 100%; + min-width: 0; + + pre { + margin: 0; + padding: 0; + background-color: transparent !important; + border: none !important; + } + } + pre { margin: 0; padding: 0; + background: none; } &[data-scrollable] { diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 186b52cf3..ef85dd9ce 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -69,6 +69,7 @@ export interface MessagePartProps { part: PartType message: MessageType hideDetails?: boolean + defaultOpen?: boolean } export type PartComponent = Component<MessagePartProps> @@ -208,7 +209,13 @@ export function Part(props: MessagePartProps) { const component = createMemo(() => PART_MAPPING[props.part.type]) return ( <Show when={component()}> - <Dynamic component={component()} part={props.part} message={props.message} hideDetails={props.hideDetails} /> + <Dynamic + component={component()} + part={props.part} + message={props.message} + hideDetails={props.hideDetails} + defaultOpen={props.defaultOpen} + /> </Show> ) } @@ -219,6 +226,7 @@ export interface ToolProps { tool: string output?: string hideDetails?: boolean + defaultOpen?: boolean } export type ToolComponent = Component<ToolProps> @@ -286,6 +294,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { metadata={metadata} output={part.state.status === "completed" ? part.state.output : undefined} hideDetails={props.hideDetails} + defaultOpen={props.defaultOpen} /> </Match> </Switch> @@ -326,6 +335,7 @@ ToolRegistry.register({ render(props) { return ( <BasicTool + {...props} icon="glasses" trigger={{ title: "Read", @@ -340,7 +350,11 @@ ToolRegistry.register({ name: "list", render(props) { return ( - <BasicTool icon="bullet-list" trigger={{ title: "List", subtitle: getDirectory(props.input.path || "/") }}> + <BasicTool + {...props} + icon="bullet-list" + trigger={{ title: "List", subtitle: getDirectory(props.input.path || "/") }} + > <Show when={props.output}> {(output) => ( <div data-component="tool-output" data-scrollable> @@ -358,6 +372,7 @@ ToolRegistry.register({ render(props) { return ( <BasicTool + {...props} icon="magnifying-glass-menu" trigger={{ title: "Glob", @@ -385,6 +400,7 @@ ToolRegistry.register({ if (props.input.include) args.push("include=" + props.input.include) return ( <BasicTool + {...props} icon="magnifying-glass-menu" trigger={{ title: "Grep", @@ -409,6 +425,7 @@ ToolRegistry.register({ render(props) { return ( <BasicTool + {...props} icon="window-cursor" trigger={{ title: "Webfetch", @@ -438,6 +455,7 @@ ToolRegistry.register({ render(props) { return ( <BasicTool + {...props} icon="task" trigger={{ title: `${props.input.subagent_type || props.tool} Agent`, @@ -462,6 +480,7 @@ ToolRegistry.register({ render(props) { return ( <BasicTool + {...props} icon="console" trigger={{ title: "Shell", @@ -485,6 +504,7 @@ ToolRegistry.register({ const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) return ( <BasicTool + {...props} defaultOpen icon="code-lines" trigger={ @@ -534,6 +554,7 @@ ToolRegistry.register({ const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) return ( <BasicTool + {...props} defaultOpen icon="code-lines" trigger={ @@ -575,6 +596,7 @@ ToolRegistry.register({ render(props) { return ( <BasicTool + {...props} defaultOpen icon="checklist" trigger={{ diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 5be4a6bb4..3f4c30fe3 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -7,7 +7,7 @@ import { createEffect, createMemo, For, Match, onCleanup, ParentProps, Show, Swi import { createResizeObserver } from "@solid-primitives/resize-observer" import { DiffChanges } from "./diff-changes" import { Typewriter } from "./typewriter" -import { Message } from "./message-part" +import { Message, Part } from "./message-part" import { Markdown } from "./markdown" import { Accordion } from "./accordion" import { StickyAccordionHeader } from "./sticky-accordion-header" @@ -35,29 +35,133 @@ export function SessionTurn( ) { const data = useData() const diffComponent = useDiffComponent() - const messages = createMemo(() => (props.sessionID ? (data.store.message[props.sessionID] ?? []) : [])) + const messages = createMemo(() => data.store.message[props.sessionID] ?? []) const userMessages = createMemo(() => messages() .filter((m) => m.role === "user") .sort((a, b) => a.id.localeCompare(b.id)), ) - const message = createMemo(() => userMessages()?.find((m) => m.id === props.messageID)) + const lastUserMessage = createMemo(() => userMessages().at(-1)!) + const message = createMemo(() => userMessages().find((m) => m.id === props.messageID)!) const status = createMemo( () => data.store.session_status[props.sessionID] ?? { type: "idle", }, ) - const working = createMemo(() => status()?.type !== "idle" && message()?.id === userMessages().at(-1)?.id) + const working = createMemo(() => status().type !== "idle" && message().id === lastUserMessage().id) const retry = createMemo(() => { const s = status() if (s.type !== "retry") return return s }) + const assistantMessages = createMemo(() => { + return messages().filter((m) => m.role === "assistant" && m.parentID == message().id) as AssistantMessage[] + }) + const assistantParts = createMemo(() => assistantMessages().flatMap((m) => data.store.part[m.id])) + const lastAssistantMessage = createMemo(() => assistantMessages().at(-1)) + const error = createMemo(() => assistantMessages().find((m) => m.error)?.error) + const parts = createMemo(() => data.store.part[message().id]) + const lastTextPart = createMemo(() => + assistantParts() + .filter((p) => p?.type === "text") + .at(-1), + ) + const summary = createMemo(() => message().summary?.body) + const response = createMemo(() => lastTextPart()?.text) + + 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 + }) + const hasDiffs = createMemo(() => message().summary?.diffs?.length) + const isShellMode = createMemo(() => { + if (parts().some((p) => p.type !== "text" || !p.synthetic)) return false + if (assistantParts().length !== 1) return false + const assistantPart = assistantParts()[0] + if (assistantPart.type !== "tool") return false + if (assistantPart.tool !== "bash") return false + return true + }) + + 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, + }) + } + let scrollRef: HTMLDivElement | undefined let lastScrollTop = 0 - const [state, setState] = createStore({ + const [store, setStore] = createStore({ contentRef: undefined as HTMLDivElement | undefined, stickyTitleRef: undefined as HTMLDivElement | undefined, stickyTriggerRef: undefined as HTMLDivElement | undefined, @@ -65,418 +169,312 @@ export function SessionTurn( userScrolled: false, stickyHeaderHeight: 0, retrySeconds: 0, + status: rawStatus(), + stepsExpanded: props.stepsExpanded ?? working(), + duration: duration(), }) createEffect(() => { const r = retry() if (!r) { - setState("retrySeconds", 0) + setStore("retrySeconds", 0) return } const updateSeconds = () => { const next = r.next - if (next) setState("retrySeconds", Math.max(0, Math.round((next - Date.now()) / 1000))) + if (next) setStore("retrySeconds", Math.max(0, Math.round((next - Date.now()) / 1000))) } updateSeconds() - const timer = setInterval(updateSeconds, 1000) onCleanup(() => clearInterval(timer)) }) function handleScroll() { - if (!scrollRef || state.autoScrolled) return + if (!scrollRef || store.autoScrolled) return const { scrollTop } = scrollRef // only mark as user scrolled if they actively scrolled upward // content growth increases scrollHeight but never decreases scrollTop const scrolledUp = scrollTop < lastScrollTop - 10 if (scrolledUp && working()) { - setState("userScrolled", true) + setStore("userScrolled", true) } lastScrollTop = scrollTop } function handleInteraction() { - if (working()) { - setState("userScrolled", true) - } + if (working()) setStore("userScrolled", true) } function scrollToBottom() { - if (!scrollRef || state.userScrolled || !working()) return - setState("autoScrolled", true) + if (!scrollRef || store.userScrolled || !working()) return + setStore("autoScrolled", true) requestAnimationFrame(() => { scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "smooth" }) requestAnimationFrame(() => { lastScrollTop = scrollRef?.scrollTop ?? 0 - setState("autoScrolled", false) + setStore("autoScrolled", false) }) }) } - createResizeObserver(() => state.contentRef, scrollToBottom) + createResizeObserver(() => store.contentRef, scrollToBottom) createEffect(() => { - if (!working()) { - setState("userScrolled", false) - } + if (!working()) 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) }, ) - return ( - <div data-component="session-turn" class={props.classes?.root}> - <div ref={scrollRef} onScroll={handleScroll} data-slot="session-turn-content" class={props.classes?.content}> - <div onClick={handleInteraction}> - <Show when={message()}> - {(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, - }) - } - - const [store, setStore] = createStore({ - status: rawStatus(), - stepsExpanded: props.stepsExpanded ?? working(), - duration: duration(), - }) - - createEffect(() => { - if (props.stepsExpanded !== undefined) { - setStore("stepsExpanded", props.stepsExpanded) - } - }) + createEffect(() => { + if (props.stepsExpanded !== undefined) { + setStore("stepsExpanded", props.stepsExpanded) + } + }) - createEffect(() => { - const timer = setInterval(() => { - setStore("duration", duration()) - }, 1000) - onCleanup(() => clearInterval(timer)) - }) + 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 + let lastStatusChange = Date.now() + let statusTimeout: number | undefined + createEffect(() => { + const newStatus = rawStatus() + if (newStatus === store.status || !newStatus) return - const timeSinceLastChange = Date.now() - lastStatusChange + 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 - } - }) + 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) { - setStore("stepsExpanded", true) - props.onStepsExpandedChange?.(true) - } - if (prev && !isWorking && !state.userScrolled) { - setStore("stepsExpanded", false) - props.onStepsExpandedChange?.(false) - } - return isWorking - }, working()) + createEffect((prev) => { + const isWorking = working() + if (!prev && isWorking) { + setStore("stepsExpanded", true) + props.onStepsExpandedChange?.(true) + } + if (prev && !isWorking && !store.userScrolled) { + setStore("stepsExpanded", false) + props.onStepsExpandedChange?.(false) + } + return isWorking + }, working()) - return ( - <div - ref={(el) => setState("contentRef", el)} - data-message={message().id} - data-slot="session-turn-message-container" - class={props.classes?.container} - style={{ "--sticky-header-height": `${state.stickyHeaderHeight}px` }} - > - {/* Title (sticky) */} - <div ref={(el) => setState("stickyTitleRef", el)} data-slot="session-turn-sticky-title"> - <div data-slot="session-turn-message-header"> - <div data-slot="session-turn-message-title"> - <Switch> - <Match when={working()}> - <Typewriter as="h1" text={message().summary?.title} data-slot="session-turn-typewriter" /> - </Match> - <Match when={true}> - <h1>{message().summary?.title}</h1> - </Match> - </Switch> - </div> - </div> - </div> - {/* User Message */} - <div data-slot="session-turn-message-content"> - <Message message={message()} parts={parts()} /> - </div> - {/* Trigger (sticky) */} - <div ref={(el) => setState("stickyTriggerRef", el)} data-slot="session-turn-response-trigger"> - <Button - data-expandable={assistantMessages().length > 0} - data-slot="session-turn-collapsible-trigger-content" - variant="ghost" - size="small" - onClick={() => { - if (assistantMessages().length === 0) return - const next = !store.stepsExpanded - setStore("stepsExpanded", next) - props.onStepsExpandedChange?.(next) - }} - > - <Show when={working()}> - <Spinner /> - </Show> + return ( + <div data-component="session-turn" class={props.classes?.root}> + <div ref={scrollRef} onScroll={handleScroll} data-slot="session-turn-content" class={props.classes?.content}> + <div onClick={handleInteraction}> + <div + ref={(el) => setStore("contentRef", el)} + data-message={message().id} + data-slot="session-turn-message-container" + class={props.classes?.container} + style={{ "--sticky-header-height": `${store.stickyHeaderHeight}px` }} + > + <Switch> + <Match when={isShellMode()}> + <Part part={assistantParts()[0]} message={message()} defaultOpen /> + </Match> + <Match when={true}> + {/* Title (sticky) */} + <div ref={(el) => setStore("stickyTitleRef", el)} data-slot="session-turn-sticky-title"> + <div data-slot="session-turn-message-header"> + <div data-slot="session-turn-message-title"> <Switch> - <Match when={retry()}> - <span data-slot="session-turn-retry-message"> - {(() => { - const r = retry() - if (!r) return "" - return r.message.length > 60 ? r.message.slice(0, 60) + "..." : r.message - })()} - </span> - <span data-slot="session-turn-retry-seconds"> - · retrying {state.retrySeconds > 0 ? `in ${state.retrySeconds}s ` : ""} - </span> - <span data-slot="session-turn-retry-attempt">(#{retry()?.attempt})</span> + <Match when={working()}> + <Typewriter as="h1" text={message().summary?.title} data-slot="session-turn-typewriter" /> + </Match> + <Match when={true}> + <h1>{message().summary?.title ?? "New message"}</h1> </Match> - <Match when={working()}>{store.status ?? "Considering next steps"}</Match> - <Match when={store.stepsExpanded}>Hide steps</Match> - <Match when={!store.stepsExpanded}>Show steps</Match> </Switch> - <span>·</span> - <span>{store.duration}</span> - <Show when={assistantMessages().length > 0}> - <Icon name="chevron-grabber-vertical" size="small" /> - </Show> - </Button> - </div> - {/* Response */} - <Show when={store.stepsExpanded && assistantMessages().length > 0}> - <div data-slot="session-turn-collapsible-content-inner"> - <For each={assistantMessages()}> - {(assistantMessage) => { - const parts = createMemo(() => data.store.part[assistantMessage.id] ?? []) - const last = createMemo(() => - parts() - .filter((p) => p?.type === "text") - .at(-1), - ) - return ( - <Switch> - <Match when={lastTextPartShown() && lastTextPart()?.id === last()?.id}> - <Message - message={assistantMessage} - parts={parts().filter((p) => p?.id !== last()?.id)} - /> - </Match> - <Match when={true}> - <Message message={assistantMessage} parts={parts()} /> - </Match> - </Switch> - ) - }} - </For> - <Show when={error()}> - <Card variant="error" class="error-card"> - {error()?.data?.message as string} - </Card> - </Show> </div> - </Show> - {/* Summary */} - <Show when={!working()}> - <div data-slot="session-turn-summary-section"> - <div data-slot="session-turn-summary-header"> - <h2 data-slot="session-turn-summary-title"> + </div> + </div> + {/* User Message */} + <div data-slot="session-turn-message-content"> + <Message message={message()} parts={parts()} /> + </div> + {/* Trigger (sticky) */} + <div ref={(el) => setStore("stickyTriggerRef", el)} data-slot="session-turn-response-trigger"> + <Button + data-expandable={assistantMessages().length > 0} + data-slot="session-turn-collapsible-trigger-content" + variant="ghost" + size="small" + onClick={() => { + if (assistantMessages().length === 0) return + const next = !store.stepsExpanded + setStore("stepsExpanded", next) + props.onStepsExpandedChange?.(next) + }} + > + <Show when={working()}> + <Spinner /> + </Show> + <Switch> + <Match when={retry()}> + <span data-slot="session-turn-retry-message"> + {(() => { + const r = retry() + if (!r) return "" + return r.message.length > 60 ? r.message.slice(0, 60) + "..." : r.message + })()} + </span> + <span data-slot="session-turn-retry-seconds"> + · retrying {store.retrySeconds > 0 ? `in ${store.retrySeconds}s ` : ""} + </span> + <span data-slot="session-turn-retry-attempt">(#{retry()?.attempt})</span> + </Match> + <Match when={working()}>{store.status ?? "Considering next steps"}</Match> + <Match when={store.stepsExpanded}>Hide steps</Match> + <Match when={!store.stepsExpanded}>Show steps</Match> + </Switch> + <span>·</span> + <span>{store.duration}</span> + <Show when={assistantMessages().length > 0}> + <Icon name="chevron-grabber-vertical" size="small" /> + </Show> + </Button> + </div> + {/* Response */} + <Show when={store.stepsExpanded && assistantMessages().length > 0}> + <div data-slot="session-turn-collapsible-content-inner"> + <For each={assistantMessages()}> + {(assistantMessage) => { + const parts = createMemo(() => data.store.part[assistantMessage.id] ?? []) + const last = createMemo(() => + parts() + .filter((p) => p?.type === "text") + .at(-1), + ) + return ( <Switch> - <Match when={message().summary?.diffs?.length}>Summary</Match> - <Match when={true}>Response</Match> + <Match when={response() && lastTextPart()?.id === last()?.id}> + <Message message={assistantMessage} parts={parts().filter((p) => p?.id !== last()?.id)} /> + </Match> + <Match when={true}> + <Message message={assistantMessage} parts={parts()} /> + </Match> </Switch> - </h2> - <Show when={summary()}> + ) + }} + </For> + <Show when={error()}> + <Card variant="error" class="error-card"> + {error()?.data?.message as string} + </Card> + </Show> + </div> + </Show> + {/* Summary */} + <Show when={!working()}> + <div data-slot="session-turn-summary-section"> + <div data-slot="session-turn-summary-header"> + <Switch> + <Match when={summary()}> {(summary) => ( - <Markdown - data-slot="session-turn-markdown" - data-diffs={!!message().summary?.diffs?.length} - text={summary()} - /> + <> + <h2 data-slot="session-turn-summary-title">Summary</h2> + <Markdown data-slot="session-turn-markdown" data-diffs={hasDiffs()} text={summary()} /> + </> )} - </Show> - </div> - <Accordion data-slot="session-turn-accordion" multiple> - <For each={message().summary?.diffs ?? []}> - {(diff) => ( - <Accordion.Item value={diff.file}> - <StickyAccordionHeader> - <Accordion.Trigger> - <div data-slot="session-turn-accordion-trigger-content"> - <div data-slot="session-turn-file-info"> - <FileIcon - node={{ path: diff.file, type: "file" }} - data-slot="session-turn-file-icon" - /> - <div data-slot="session-turn-file-path"> - <Show when={diff.file.includes("/")}> - <span data-slot="session-turn-directory">{getDirectory(diff.file)}‎</span> - </Show> - <span data-slot="session-turn-filename">{getFilename(diff.file)}</span> - </div> - </div> - <div data-slot="session-turn-accordion-actions"> - <DiffChanges changes={diff} /> - <Icon name="chevron-grabber-vertical" size="small" /> - </div> - </div> - </Accordion.Trigger> - </StickyAccordionHeader> - <Accordion.Content data-slot="session-turn-accordion-content"> - <Dynamic - component={diffComponent} - before={{ - name: diff.file!, - contents: diff.before!, - cacheKey: checksum(diff.before!), - }} - after={{ - name: diff.file!, - contents: diff.after!, - cacheKey: checksum(diff.after!), - }} - /> - </Accordion.Content> - </Accordion.Item> + </Match> + <Match when={response()}> + {(response) => ( + <> + <h2 data-slot="session-turn-summary-title">Response</h2> + <Markdown data-slot="session-turn-markdown" data-diffs={hasDiffs()} text={response()} /> + </> )} - </For> - </Accordion> + </Match> + </Switch> </div> - </Show> - <Show when={error() && !store.stepsExpanded}> - <Card variant="error" class="error-card"> - {error()?.data?.message as string} - </Card> - </Show> - </div> - ) - }} - </Show> + <Accordion data-slot="session-turn-accordion" multiple> + <For each={message().summary?.diffs ?? []}> + {(diff) => ( + <Accordion.Item value={diff.file}> + <StickyAccordionHeader> + <Accordion.Trigger> + <div data-slot="session-turn-accordion-trigger-content"> + <div data-slot="session-turn-file-info"> + <FileIcon + node={{ path: diff.file, type: "file" }} + data-slot="session-turn-file-icon" + /> + <div data-slot="session-turn-file-path"> + <Show when={diff.file.includes("/")}> + <span data-slot="session-turn-directory">{getDirectory(diff.file)}‎</span> + </Show> + <span data-slot="session-turn-filename">{getFilename(diff.file)}</span> + </div> + </div> + <div data-slot="session-turn-accordion-actions"> + <DiffChanges changes={diff} /> + <Icon name="chevron-grabber-vertical" size="small" /> + </div> + </div> + </Accordion.Trigger> + </StickyAccordionHeader> + <Accordion.Content data-slot="session-turn-accordion-content"> + <Dynamic + component={diffComponent} + before={{ + name: diff.file!, + contents: diff.before!, + cacheKey: checksum(diff.before!), + }} + after={{ + name: diff.file!, + contents: diff.after!, + cacheKey: checksum(diff.after!), + }} + /> + </Accordion.Content> + </Accordion.Item> + )} + </For> + </Accordion> + </div> + </Show> + <Show when={error() && !store.stepsExpanded}> + <Card variant="error" class="error-card"> + {error()?.data?.message as string} + </Card> + </Show> + </Match> + </Switch> + </div> {props.children} </div> </div> |
