summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-12 14:32:18 -0600
committerAdam <[email protected]>2025-12-12 15:24:42 -0600
commit41e234c6d0f7f0e8cfb137ef1a4d9c9c9eea7d06 (patch)
tree36ca726cf1aae84ccf515160e38a0dbe560ec545
parent3e03646e42bd02d2d69144f4790e0e0df69af403 (diff)
downloadopencode-41e234c6d0f7f0e8cfb137ef1a4d9c9c9eea7d06.tar.gz
opencode-41e234c6d0f7f0e8cfb137ef1a4d9c9c9eea7d06.zip
fix: desktop layout
-rw-r--r--bun.lock4
-rw-r--r--packages/ui/package.json2
-rw-r--r--packages/ui/src/components/session-turn.tsx593
3 files changed, 323 insertions, 276 deletions
diff --git a/bun.lock b/bun.lock
index b970c0e68..e15a3ba36 100644
--- a/bun.lock
+++ b/bun.lock
@@ -382,6 +382,8 @@
"@opencode-ai/util": "workspace:*",
"@pierre/precision-diffs": "catalog:",
"@shikijs/transformers": "3.9.2",
+ "@solid-primitives/bounds": "0.1.3",
+ "@solid-primitives/resize-observer": "2.1.3",
"@solidjs/meta": "catalog:",
"@typescript/native-preview": "catalog:",
"fuzzysort": "catalog:",
@@ -1553,6 +1555,8 @@
"@solid-primitives/audio": ["@solid-primitives/[email protected]", "", { "dependencies": { "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-UMD3ORQfI5Ky8yuKPxidDiEazsjv/dsoiKK5yZxLnsgaeNR1Aym3/77h/qT1jBYeXUgj4DX6t7NMpFUSVr14OQ=="],
+ "@solid-primitives/bounds": ["@solid-primitives/[email protected]", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/resize-observer": "^2.1.3", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-UbiyKMdSPmtijcEDnYLQL3zzaejpwWDAJJ4Gt5P0hgVs6A72piov0GyNw7V2SroH7NZFwxlYS22YmOr8A5xc1Q=="],
+
"@solid-primitives/event-bus": ["@solid-primitives/[email protected]", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-l+n10/51neGcMaP3ypYt21bXfoeWh8IaC8k7fYuY3ww2a8S1Zv2N2a7FF5Qn+waTu86l0V8/nRHjkyqVIZBYwA=="],
"@solid-primitives/event-listener": ["@solid-primitives/[email protected]", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-h4VqkYFv6Gf+L7SQj+Y6puigL/5DIi7x5q07VZET7AWcS+9/G3WfIE9WheniHWJs51OEkRB43w6lDys5YeFceg=="],
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 874384efa..c4902e96f 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -37,6 +37,8 @@
"@opencode-ai/util": "workspace:*",
"@pierre/precision-diffs": "catalog:",
"@shikijs/transformers": "3.9.2",
+ "@solid-primitives/bounds": "0.1.3",
+ "@solid-primitives/resize-observer": "2.1.3",
"@solidjs/meta": "catalog:",
"@typescript/native-preview": "catalog:",
"fuzzysort": "catalog:",
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index 8612fc0b6..f57a0509b 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/packages/ui/src/components/session-turn.tsx
@@ -3,7 +3,19 @@ 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, For, Match, onCleanup, ParentProps, Show, Switch } from "solid-js"
+import {
+ createEffect,
+ createMemo,
+ createSignal,
+ For,
+ Match,
+ onCleanup,
+ onMount,
+ ParentProps,
+ Show,
+ Switch,
+} from "solid-js"
+import { createResizeObserver } from "@solid-primitives/resize-observer"
import { DiffChanges } from "./diff-changes"
import { Typewriter } from "./typewriter"
import { Message } from "./message-part"
@@ -48,304 +60,333 @@ export function SessionTurn(
)
const working = createMemo(() => status()?.type !== "idle")
+ let scrollRef: HTMLDivElement | undefined
+ let contentRef: HTMLDivElement | undefined
+ const [userScrolled, setUserScrolled] = createSignal(false)
+
+ function handleScroll() {
+ if (!scrollRef) return
+ const { scrollTop, scrollHeight, clientHeight } = scrollRef
+ const atBottom = scrollHeight - scrollTop - clientHeight < 50
+ if (!atBottom && working()) {
+ setUserScrolled(true)
+ }
+ }
+
+ createEffect(() => {
+ if (!working()) {
+ setUserScrolled(false)
+ }
+ })
+
+ onMount(() => {
+ if (!contentRef) return
+ createResizeObserver(contentRef, () => {
+ if (!scrollRef || userScrolled() || !working()) return
+ scrollRef.scrollTop = scrollRef.scrollHeight
+ })
+ })
+
return (
<div data-component="session-turn" class={props.classes?.root}>
- <div data-slot="session-turn-content" class={props.classes?.content}>
- <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,
- )
+ <div ref={scrollRef} onScroll={handleScroll} data-slot="session-turn-content" class={props.classes?.content}>
+ <div ref={contentRef}>
+ <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
+ 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
+ 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"
}
- } else if (last.type === "reasoning") {
- return "Thinking"
- } else if (last.type === "text") {
- return "Gathering thoughts"
- }
- return undefined
- })
+ 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"]
+ 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,
- })
- }
+ return interval.toDuration(unit).normalize().toHuman({
+ notation: "compact",
+ unitDisplay: "narrow",
+ compactDisplay: "short",
+ showZeros: false,
+ })
+ }
- const [store, setStore] = createStore({
- status: rawStatus(),
- detailsExpanded: true,
- duration: duration(),
- })
+ const [store, setStore] = createStore({
+ status: rawStatus(),
+ detailsExpanded: true,
+ duration: duration(),
+ })
- 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())
+ if (timeSinceLastChange >= 2500) {
+ setStore("status", newStatus)
lastStatusChange = Date.now()
- statusTimeout = undefined
- }, 2500 - timeSinceLastChange) as unknown as number
- }
- })
+ 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
+ }
+ })
- return (
- <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">
- <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>
+ return (
+ <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">
+ <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>
- <div data-slot="session-turn-message-content">
- <Message message={message()} parts={parts()} />
- </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"
+ <div data-slot="session-turn-message-content">
+ <Message message={message()} parts={parts()} />
+ </div>
+ {/* Response */}
+ <div data-slot="session-turn-response-section">
+ <Collapsible
variant="ghost"
- size="small"
+ open={store.detailsExpanded}
+ onOpenChange={(open) => setStore("detailsExpanded", open)}
+ data-slot="session-turn-collapsible"
>
- <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)}
- />
- </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>
+ <Collapsible.Trigger
+ as={Button}
+ data-slot="session-turn-collapsible-trigger-content"
+ variant="ghost"
+ size="small"
+ >
+ <Show when={working()}>
+ <Spinner />
</Show>
- </div>
- </Collapsible.Content>
- </Collapsible>
- </div>
- {/* 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">
<Switch>
- <Match when={message().summary?.diffs?.length}>Summary</Match>
- <Match when={true}>Response</Match>
+ <Match when={working()}>{store.status ?? "Considering next steps..."}</Match>
+ <Match when={store.detailsExpanded}>Hide steps</Match>
+ <Match when={!store.detailsExpanded}>Show steps</Match>
</Switch>
- </h2>
- <Show when={summary()}>
- {(summary) => (
- <Markdown
- data-slot="session-turn-markdown"
- data-diffs={!!message().summary?.diffs?.length}
- 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"
+ <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)}
/>
- <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>
+ </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>
+ </Collapsible.Content>
+ </Collapsible>
+ </div>
+ {/* 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">
+ <Switch>
+ <Match when={message().summary?.diffs?.length}>Summary</Match>
+ <Match when={true}>Response</Match>
+ </Switch>
+ </h2>
+ <Show when={summary()}>
+ {(summary) => (
+ <Markdown
+ data-slot="session-turn-markdown"
+ data-diffs={!!message().summary?.diffs?.length}
+ 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>
- <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.detailsExpanded}>
- <Card variant="error" class="error-card">
- {error()?.data?.message as string}
- </Card>
- </Show>
- </div>
- )
- }}
- </Show>
- {props.children}
+ </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.detailsExpanded}>
+ <Card variant="error" class="error-card">
+ {error()?.data?.message as string}
+ </Card>
+ </Show>
+ </div>
+ )
+ }}
+ </Show>
+ {props.children}
+ </div>
</div>
</div>
)