diff options
| author | Adam <[email protected]> | 2025-12-12 13:24:11 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-12-12 15:24:41 -0600 |
| commit | ad008d2151b13b7dd858fa7dc557748a1b7d4d27 (patch) | |
| tree | 261c1cc5c0f2c0e4a3d831ec4ed7a7e5daf2c282 | |
| parent | 651a10d6dbfbcf5112a8072459907463b7e3c577 (diff) | |
| download | opencode-ad008d2151b13b7dd858fa7dc557748a1b7d4d27.tar.gz opencode-ad008d2151b13b7dd858fa7dc557748a1b7d4d27.zip | |
wip: desktop timeline changes
| -rw-r--r-- | bun.lock | 1 | ||||
| -rw-r--r-- | packages/ui/package.json | 1 | ||||
| -rw-r--r-- | packages/ui/src/components/button.css | 20 | ||||
| -rw-r--r-- | packages/ui/src/components/button.tsx | 2 | ||||
| -rw-r--r-- | packages/ui/src/components/message-part.css | 10 | ||||
| -rw-r--r-- | packages/ui/src/components/message-part.tsx | 6 | ||||
| -rw-r--r-- | packages/ui/src/components/message-progress.tsx | 18 | ||||
| -rw-r--r-- | packages/ui/src/components/session-turn.css | 27 | ||||
| -rw-r--r-- | packages/ui/src/components/session-turn.tsx | 350 |
9 files changed, 263 insertions, 172 deletions
@@ -398,6 +398,7 @@ "@tailwindcss/vite": "catalog:", "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", + "@types/luxon": "catalog:", "tailwindcss": "catalog:", "typescript": "catalog:", "vite": "catalog:", diff --git a/packages/ui/package.json b/packages/ui/package.json index 7aede1dcd..874384efa 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -22,6 +22,7 @@ }, "devDependencies": { "@types/bun": "catalog:", + "@types/luxon": "catalog:", "@tsconfig/node22": "catalog:", "typescript": "catalog:", "vite": "catalog:", diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css index 3a32672fe..c5bd2c696 100644 --- a/packages/ui/src/components/button.css +++ b/packages/ui/src/components/button.css @@ -100,6 +100,26 @@ } } + &[data-size="small"] { + height: 22px; + padding: 0 8px; + &[data-icon] { + padding: 0 12px 0 4px; + } + + font-size: var(--font-size-small); + line-height: var(--line-height-large); + gap: 4px; + + /* text-12-medium */ + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); /* 166.667% */ + letter-spacing: var(--letter-spacing-normal); + } + &[data-size="normal"] { height: 24px; padding: 0 6px; diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx index 0802c3629..7f974b2f7 100644 --- a/packages/ui/src/components/button.tsx +++ b/packages/ui/src/components/button.tsx @@ -5,7 +5,7 @@ import { Icon, IconProps } from "./icon" export interface ButtonProps extends ComponentProps<typeof Kobalte>, Pick<ComponentProps<"button">, "class" | "classList" | "children"> { - size?: "normal" | "large" + size?: "small" | "normal" | "large" variant?: "primary" | "secondary" | "ghost" icon?: IconProps["name"] } diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 1ccee7320..d5906050b 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -29,6 +29,16 @@ } } +[data-component="reasoning-part"] { + width: 100%; + opacity: 0.5; + + [data-component="markdown"] { + margin-top: 24px; + font-style: italic !important; + } +} + [data-component="tool-error"] { display: flex; align-items: start; diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index a28e36aa8..1a33d15c5 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -18,7 +18,6 @@ import { DiffChanges } from "./diff-changes" import { Markdown } from "./markdown" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { sanitizePart } from "@opencode-ai/util/sanitize" -import { unwrap } from "solid-js/store" export interface MessageProps { message: MessageType @@ -63,7 +62,6 @@ export function Message(props: MessageProps) { export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[]; sanitize?: RegExp }) { const filteredParts = createMemo(() => { return props.parts?.filter((x) => { - if (x.type === "reasoning") return false return x.type !== "tool" || (x as ToolPart).tool !== "todoread" }) }) @@ -84,7 +82,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp export function Part(props: MessagePartProps) { const component = createMemo(() => PART_MAPPING[props.part.type]) - const part = createMemo(() => sanitizePart(unwrap(props.part), props.sanitize)) + const part = createMemo(() => sanitizePart(props.part, props.sanitize)) return ( <Show when={component()}> <Dynamic component={component()} part={part()} message={props.message} hideDetails={props.hideDetails} /> @@ -176,7 +174,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { PART_MAPPING["text"] = function TextPartDisplay(props) { const part = props.part as TextPart - const sanitized = createMemo(() => (props.sanitize ? (sanitizePart(unwrap(part), props.sanitize) as TextPart) : part)) + const sanitized = createMemo(() => (props.sanitize ? (sanitizePart(part, props.sanitize) as TextPart) : part)) return ( <Show when={part.text.trim()}> <div data-component="text-part"> diff --git a/packages/ui/src/components/message-progress.tsx b/packages/ui/src/components/message-progress.tsx index ef3548ab3..a6d56b397 100644 --- a/packages/ui/src/components/message-progress.tsx +++ b/packages/ui/src/components/message-progress.tsx @@ -86,30 +86,30 @@ export function MessageProgress(props: MessageProgressProps) { if (last.type === "tool") { switch (last.tool) { case "task": - return "Delegating work..." + return "Delegating work" case "todowrite": case "todoread": - return "Planning next steps..." + return "Planning next steps" case "read": - return "Gathering context..." + return "Gathering context" case "list": case "grep": case "glob": - return "Searching the codebase..." + return "Searching the codebase" case "webfetch": - return "Searching the web..." + return "Searching the web" case "edit": case "write": - return "Making edits..." + return "Making edits" case "bash": - return "Running commands..." + return "Running commands" default: break } } else if (last.type === "reasoning") { - return "Thinking..." + return "Thinking" } else if (last.type === "text") { - return "Gathering thoughts..." + return "Gathering thoughts" } return undefined }) diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index d2a3d618a..3b3a0399a 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -274,22 +274,27 @@ min-width: 0; } + [data-slot="session-turn-collapsible"] { + gap: 32px; + } + [data-slot="session-turn-collapsible-trigger-content"] { - color: var(--text-weak); - cursor: pointer; - background: none; - border: none; - padding: 0; + width: fit-content; display: flex; align-items: center; + gap: 4px; + color: var(--text-weak); - &:hover { - color: var(--text-strong); + [data-component="spinner"] { + width: 12px; + height: 12px; + margin-right: 4px; + } + + [data-component="icon"] { + width: 14px; + height: 14px; } - display: flex; - align-items: center; - gap: 4px; - align-self: stretch; } [data-slot="session-turn-details-text"] { diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index f97a3224c..0043719e0 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -1,9 +1,9 @@ -import { AssistantMessage } from "@opencode-ai/sdk/v2" +import { AssistantMessage, ToolPart } from "@opencode-ai/sdk/v2/client" import { useData } from "../context" import { useDiffComponent } from "../context/diff" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { checksum } from "@opencode-ai/util/encode" -import { createEffect, createMemo, createSignal, For, Match, onCleanup, ParentProps, Show, Switch } from "solid-js" +import { createEffect, createMemo, For, Match, onCleanup, ParentProps, Show, Switch } from "solid-js" import { DiffChanges } from "./diff-changes" import { Typewriter } from "./typewriter" import { Message } from "./message-part" @@ -13,16 +13,12 @@ import { StickyAccordionHeader } from "./sticky-accordion-header" import { FileIcon } from "./file-icon" import { Icon } from "./icon" import { Card } from "./card" -import { MessageProgress } from "./message-progress" import { Collapsible } from "./collapsible" import { Dynamic } from "solid-js/web" - -// Track animation state per message ID - persists across re-renders -// "empty" = first saw with no value (should animate when value arrives) -// "animating" = currently animating (keep returning true) -// "done" = already animated or first saw with value (never animate) -const titleAnimationState = new Map<string, "empty" | "animating" | "done">() -const summaryAnimationState = new Map<string, "empty" | "animating" | "done">() +import { Button } from "./button" +import { Spinner } from "./spinner" +import { createStore } from "solid-js/store" +import { DateTime, DurationUnit, Interval } from "luxon" export function SessionTurn( props: ParentProps<{ @@ -44,11 +40,7 @@ export function SessionTurn( .filter((m) => m.role === "user") .sort((a, b) => a.id.localeCompare(b.id)), ) - const lastUserMessage = createMemo(() => { - return userMessages()?.at(-1) - }) const message = createMemo(() => userMessages()?.find((m) => m.id === props.messageID)) - const status = createMemo( () => data.store.session_status[props.sessionID] ?? { @@ -61,114 +53,231 @@ export function SessionTurn( <div data-component="session-turn" class={props.classes?.root}> <div data-slot="session-turn-content" class={props.classes?.content}> <Show when={message()}> - {(msg) => { - const [detailsExpanded, setDetailsExpanded] = createSignal(false) - - // Animation logic: only animate if we witness the value transition from empty to non-empty - // Track in module-level Maps keyed by message ID so it persists across re-renders - - // Initialize animation state for current message (reactive - runs when msg().id changes) - createEffect(() => { - const id = msg().id - if (!titleAnimationState.has(id)) { - titleAnimationState.set(id, msg().summary?.title ? "done" : "empty") - } - if (!summaryAnimationState.has(id)) { - const assistantMsgs = messages()?.filter( - (m) => m.role === "assistant" && m.parentID == id, - ) as AssistantMessage[] - const parts = assistantMsgs?.flatMap((m) => data.store.part[m.id]) - const lastText = parts?.filter((p) => p?.type === "text")?.at(-1) - const summaryValue = msg().summary?.body ?? lastText?.text - summaryAnimationState.set(id, summaryValue ? "done" : "empty") - } - - // When message changes or component unmounts, mark any "animating" states as "done" - onCleanup(() => { - if (titleAnimationState.get(id) === "animating") { - titleAnimationState.set(id, "done") - } - if (summaryAnimationState.get(id) === "animating") { - summaryAnimationState.set(id, "done") - } - }) - }) - + {(message) => { const assistantMessages = createMemo(() => { - return messages()?.filter((m) => m.role === "assistant" && m.parentID == msg().id) as AssistantMessage[] + 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[msg().id]) + const parts = createMemo(() => data.store.part[message().id]) const lastTextPart = createMemo(() => assistantMessageParts() .filter((p) => p?.type === "text") ?.at(-1), ) - const hasToolPart = createMemo(() => assistantMessageParts().some((p) => p?.type === "tool")) - const messageWorking = createMemo(() => msg().id === lastUserMessage()?.id && working()) - const initialCompleted = !(msg().id === lastUserMessage()?.id && working()) - const [completed, setCompleted] = createSignal(initialCompleted) - const summary = createMemo(() => msg().summary?.body ?? lastTextPart()?.text) - const lastTextPartShown = createMemo(() => !msg().summary?.body && (lastTextPart()?.text?.length ?? 0) > 0) + const summary = createMemo(() => message().summary?.body ?? lastTextPart()?.text) + const lastTextPartShown = createMemo( + () => !message().summary?.body && (lastTextPart()?.text?.length ?? 0) > 0, + ) - // Should animate: state is "empty" AND value now exists, or state is "animating" - // Transition: empty -> animating -> done (done happens on cleanup) - const animateTitle = createMemo(() => { - const id = msg().id - const state = titleAnimationState.get(id) - const title = msg().summary?.title - if (state === "animating") { - return true - } - if (state === "empty" && title) { - titleAnimationState.set(id, "animating") - return true + 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 false + return resolved }) - const animateSummary = createMemo(() => { - const id = msg().id - const state = summaryAnimationState.get(id) - const value = summary() - if (state === "animating") { - return true - } - if (state === "empty" && value) { - summaryAnimationState.set(id, "animating") - return true + 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") { + return "Thinking" + } else if (last.type === "text") { + return "Gathering thoughts" } - return false + 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(), + detailsExpanded: true, + duration: duration(), }) createEffect(() => { - const done = !messageWorking() - setTimeout(() => setCompleted(done), 1200) + 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 + }, 1000 - timeSinceLastChange) as unknown as number + } }) return ( - <div data-message={msg().id} data-slot="session-turn-message-container" class={props.classes?.container}> + <div + data-message={message().id} + data-slot="session-turn-message-container" + class={props.classes?.container} + > {/* Title */} <div data-slot="session-turn-message-header"> <div data-slot="session-turn-message-title"> - <Show - when={!animateTitle()} - fallback={<Typewriter as="h1" text={msg().summary?.title} data-slot="session-turn-typewriter" />} - > - <h1>{msg().summary?.title}</h1> - </Show> + <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 data-slot="session-turn-message-content"> - <Message message={msg()} parts={parts()} sanitize={sanitizer()} /> + <Message message={message()} parts={parts()} sanitize={sanitizer()} /> + </div> + {/* Response */} + <div data-slot="session-turn-response-section"> + <Collapsible + variant="ghost" + open={store.detailsExpanded} + onOpenChange={(open) => setStore("detailsExpanded", open)} + data-slot="session-turn-collapsible" + > + <Collapsible.Trigger + as={Button} + data-slot="session-turn-collapsible-trigger-content" + variant="ghost" + size="small" + > + <Show when={working()}> + <Spinner /> + </Show> + <Switch> + <Match when={working()}>{store.status ?? "Considering next steps..."}</Match> + <Match when={store.detailsExpanded}>Hide steps</Match> + <Match when={!store.detailsExpanded}>Show steps</Match> + </Switch> + <span>ยท</span> + <span>{store.duration}</span> + <Icon name="chevron-grabber-vertical" size="small" /> + </Collapsible.Trigger> + <Collapsible.Content> + <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)} + sanitize={sanitizer()} + /> + </Match> + <Match when={true}> + <Message message={assistantMessage} parts={parts()} sanitize={sanitizer()} /> + </Match> + </Switch> + ) + }} + </For> + <Show when={error()}> + <Card variant="error" class="error-card"> + {error()?.data?.message as string} + </Card> + </Show> + </div> + </Collapsible.Content> + </Collapsible> </div> {/* Summary */} - <Show when={completed()}> + <Show when={!working()}> <div data-slot="session-turn-summary-section"> <div data-slot="session-turn-summary-header"> <h2 data-slot="session-turn-summary-title"> <Switch> - <Match when={msg().summary?.diffs?.length}>Summary</Match> + <Match when={message().summary?.diffs?.length}>Summary</Match> <Match when={true}>Response</Match> </Switch> </h2> @@ -176,15 +285,14 @@ export function SessionTurn( {(summary) => ( <Markdown data-slot="session-turn-markdown" - data-diffs={!!msg().summary?.diffs?.length} - data-fade={!msg().summary?.diffs?.length && animateSummary()} + data-diffs={!!message().summary?.diffs?.length} text={summary()} /> )} </Show> </div> <Accordion data-slot="session-turn-accordion" multiple> - <For each={msg().summary?.diffs ?? []}> + <For each={message().summary?.diffs ?? []}> {(diff) => ( <Accordion.Item value={diff.file}> <StickyAccordionHeader> @@ -230,63 +338,11 @@ export function SessionTurn( </Accordion> </div> </Show> - <Show when={error() && !detailsExpanded()}> + <Show when={error() && !store.detailsExpanded}> <Card variant="error" class="error-card"> {error()?.data?.message as string} </Card> </Show> - {/* Response */} - <div data-slot="session-turn-response-section"> - <Switch> - <Match when={!completed()}> - <MessageProgress assistantMessages={assistantMessages} done={!messageWorking()} /> - </Match> - <Match when={completed() && hasToolPart()}> - <Collapsible variant="ghost" open={detailsExpanded()} onOpenChange={setDetailsExpanded}> - <Collapsible.Trigger> - <div data-slot="session-turn-collapsible-trigger-content"> - <div data-slot="session-turn-details-text"> - <Switch> - <Match when={detailsExpanded()}>Hide details</Match> - <Match when={!detailsExpanded()}>Show details</Match> - </Switch> - </div> - <Collapsible.Arrow /> - </div> - </Collapsible.Trigger> - <Collapsible.Content> - <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), - ) - if (lastTextPartShown() && lastTextPart()?.id === last()?.id) { - return ( - <Message - message={assistantMessage} - parts={parts().filter((p) => p?.id !== last()?.id)} - sanitize={sanitizer()} - /> - ) - } - return <Message message={assistantMessage} parts={parts()} sanitize={sanitizer()} /> - }} - </For> - <Show when={error()}> - <Card variant="error" class="error-card"> - {error()?.data?.message as string} - </Card> - </Show> - </div> - </Collapsible.Content> - </Collapsible> - </Match> - </Switch> - </div> </div> ) }} |
