summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-18 11:16:18 -0600
committerAdam <[email protected]>2025-12-18 11:16:33 -0600
commitc868a4088d374534a5e33795b8cc419c75f3654e (patch)
tree57348cb41e19020f1b0a0c5e94534280c901629c
parent83d8a88c90001637d68eb2cacb380bd5d3ede7a0 (diff)
downloadopencode-c868a4088d374534a5e33795b8cc419c75f3654e.tar.gz
opencode-c868a4088d374534a5e33795b8cc419c75f3654e.zip
fix(desktop): rendering shell mode messages
-rw-r--r--packages/ui/src/components/message-part.css13
-rw-r--r--packages/ui/src/components/message-part.tsx26
-rw-r--r--packages/ui/src/components/session-turn.tsx698
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)}&lrm;</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)}&lrm;</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>