diff options
| author | Adam <[email protected]> | 2025-12-12 14:32:18 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-12-12 15:24:42 -0600 |
| commit | 41e234c6d0f7f0e8cfb137ef1a4d9c9c9eea7d06 (patch) | |
| tree | 36ca726cf1aae84ccf515160e38a0dbe560ec545 | |
| parent | 3e03646e42bd02d2d69144f4790e0e0df69af403 (diff) | |
| download | opencode-41e234c6d0f7f0e8cfb137ef1a4d9c9c9eea7d06.tar.gz opencode-41e234c6d0f7f0e8cfb137ef1a4d9c9c9eea7d06.zip | |
fix: desktop layout
| -rw-r--r-- | bun.lock | 4 | ||||
| -rw-r--r-- | packages/ui/package.json | 2 | ||||
| -rw-r--r-- | packages/ui/src/components/session-turn.tsx | 593 |
3 files changed, 323 insertions, 276 deletions
@@ -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)}‎</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)}‎</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> ) |
