summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-12 13:24:11 -0600
committerAdam <[email protected]>2025-12-12 15:24:41 -0600
commitad008d2151b13b7dd858fa7dc557748a1b7d4d27 (patch)
tree261c1cc5c0f2c0e4a3d831ec4ed7a7e5daf2c282
parent651a10d6dbfbcf5112a8072459907463b7e3c577 (diff)
downloadopencode-ad008d2151b13b7dd858fa7dc557748a1b7d4d27.tar.gz
opencode-ad008d2151b13b7dd858fa7dc557748a1b7d4d27.zip
wip: desktop timeline changes
-rw-r--r--bun.lock1
-rw-r--r--packages/ui/package.json1
-rw-r--r--packages/ui/src/components/button.css20
-rw-r--r--packages/ui/src/components/button.tsx2
-rw-r--r--packages/ui/src/components/message-part.css10
-rw-r--r--packages/ui/src/components/message-part.tsx6
-rw-r--r--packages/ui/src/components/message-progress.tsx18
-rw-r--r--packages/ui/src/components/session-turn.css27
-rw-r--r--packages/ui/src/components/session-turn.tsx350
9 files changed, 263 insertions, 172 deletions
diff --git a/bun.lock b/bun.lock
index 9d5819b15..b970c0e68 100644
--- a/bun.lock
+++ b/bun.lock
@@ -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>
)
}}