From ad008d2151b13b7dd858fa7dc557748a1b7d4d27 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 12 Dec 2025 13:24:11 -0600 Subject: [PATCH 01/14] wip: desktop timeline changes --- bun.lock | 1 + packages/ui/package.json | 1 + packages/ui/src/components/button.css | 20 + packages/ui/src/components/button.tsx | 2 +- packages/ui/src/components/message-part.css | 10 + packages/ui/src/components/message-part.tsx | 6 +- .../ui/src/components/message-progress.tsx | 18 +- packages/ui/src/components/session-turn.css | 29 +- packages/ui/src/components/session-turn.tsx | 350 ++++++++++-------- 9 files changed, 264 insertions(+), 173 deletions(-) diff --git a/bun.lock b/bun.lock index 9d5819b153..b970c0e687 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 7aede1dcd9..874384efaa 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 3a32672fea..c5bd2c6964 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 0802c36296..7f974b2f76 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, Pick, "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 1ccee73201..d5906050bb 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 a28e36aa8e..1a33d15c5d 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 ( @@ -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 (
diff --git a/packages/ui/src/components/message-progress.tsx b/packages/ui/src/components/message-progress.tsx index ef3548ab3b..a6d56b3979 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 d2a3d618ac..3b3a0399a5 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-trigger-content"] { - color: var(--text-weak); - cursor: pointer; - background: none; - border: none; - padding: 0; - display: flex; - align-items: center; + [data-slot="session-turn-collapsible"] { + gap: 32px; + } - &:hover { - color: var(--text-strong); - } + [data-slot="session-turn-collapsible-trigger-content"] { + width: fit-content; display: flex; align-items: center; gap: 4px; - align-self: stretch; + color: var(--text-weak); + + [data-component="spinner"] { + width: 12px; + height: 12px; + margin-right: 4px; + } + + [data-component="icon"] { + width: 14px; + height: 14px; + } } [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 f97a3224cd..0043719e03 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() -const summaryAnimationState = new Map() +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(
- {(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 + 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() } - if (state === "empty" && title) { - titleAnimationState.set(id, "animating") - return true - } - return false + return resolved }) - const animateSummary = createMemo(() => { - const id = msg().id - const state = summaryAnimationState.get(id) - const value = summary() - if (state === "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" } - if (state === "empty" && value) { - summaryAnimationState.set(id, "animating") - return true - } - 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 ( -
+
{/* Title */}
- } - > -

{msg().summary?.title}

-
+ + + + + +

{message().summary?.title}

+
+
- + +
+ {/* Response */} +
+ setStore("detailsExpanded", open)} + data-slot="session-turn-collapsible" + > + + + + + + {store.status ?? "Considering next steps..."} + Hide steps + Show steps + + · + {store.duration} + + + +
+ + {(assistantMessage) => { + const parts = createMemo(() => data.store.part[assistantMessage.id] ?? []) + const last = createMemo(() => + parts() + .filter((p) => p?.type === "text") + .at(-1), + ) + return ( + + + p?.id !== last()?.id)} + sanitize={sanitizer()} + /> + + + + + + ) + }} + + + + {error()?.data?.message as string} + + +
+
+
{/* Summary */} - +

- Summary + Summary Response

@@ -176,15 +285,14 @@ export function SessionTurn( {(summary) => ( )}
- + {(diff) => ( @@ -230,63 +338,11 @@ export function SessionTurn(
- + {error()?.data?.message as string} - {/* Response */} -
- - - - - - - -
-
- - Hide details - Show details - -
- -
-
- -
- - {(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 ( - p?.id !== last()?.id)} - sanitize={sanitizer()} - /> - ) - } - return - }} - - - - {error()?.data?.message as string} - - -
-
-
-
-
-
) }} From 78484f545cd6a0a1d326079907d51bee4e871936 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 12 Dec 2025 13:27:19 -0600 Subject: [PATCH 02/14] chore: cleanup --- .../ui/src/components/message-progress.css | 50 ----- .../ui/src/components/message-progress.tsx | 179 ------------------ packages/ui/src/styles/index.css | 1 - 3 files changed, 230 deletions(-) delete mode 100644 packages/ui/src/components/message-progress.css delete mode 100644 packages/ui/src/components/message-progress.tsx diff --git a/packages/ui/src/components/message-progress.css b/packages/ui/src/components/message-progress.css deleted file mode 100644 index 0b84e0393c..0000000000 --- a/packages/ui/src/components/message-progress.css +++ /dev/null @@ -1,50 +0,0 @@ -[data-component="message-progress"] { - display: flex; - flex-direction: column; - gap: 12px; -} - -[data-component="message-progress"] [data-slot="message-progress-status"] { - display: flex; - align-items: center; - column-gap: 20px; - padding-left: 12px; - border: 1px solid transparent; - color: var(--text-base); -} - -[data-component="message-progress"] [data-slot="message-progress-status-text"] { - font-size: 12px; - font-weight: 500; - line-height: 1.5; -} - -[data-component="message-progress"] [data-slot="message-progress-list-container"] { - height: 120px; - overflow: hidden; - pointer-events: none; - padding-bottom: 4px; - - mask-image: linear-gradient(to bottom, transparent 0%, black 33%, black 95%, transparent 100%); - -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 33%, black 95%, transparent 100%); -} - -[data-component="message-progress"] [data-slot="message-progress-list"] { - width: 100%; - display: flex; - flex-direction: column; - align-items: flex-start; - align-self: stretch; - gap: 8px; - padding-top: 32px; - padding-bottom: 32px; - - transition: transform 500ms cubic-bezier(0.22, 1, 0.36, 1); -} - -[data-component="message-progress"] [data-slot="message-progress-item"] { - height: 32px; - display: flex; - align-items: center; - width: 100%; -} diff --git a/packages/ui/src/components/message-progress.tsx b/packages/ui/src/components/message-progress.tsx deleted file mode 100644 index a6d56b3979..0000000000 --- a/packages/ui/src/components/message-progress.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import { For, JSXElement, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js" -import { Part } from "./message-part" -import { Spinner } from "./spinner" -import { useData } from "../context/data" -import type { AssistantMessage as AssistantMessageType, ToolPart } from "@opencode-ai/sdk/v2" - -export interface MessageProgressProps { - assistantMessages: () => AssistantMessageType[] - done?: boolean -} - -export function MessageProgress(props: MessageProgressProps) { - const data = useData() - const sanitizer = createMemo(() => (data.directory ? new RegExp(`${data.directory}/`, "g") : undefined)) - const parts = createMemo(() => props.assistantMessages().flatMap((m) => data.store.part[m.id])) - const done = createMemo(() => props.done ?? false) - const currentTask = createMemo( - () => - parts().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 = parts() - 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]) ?? parts() - } - return resolved - }) - - const eligibleItems = createMemo(() => { - return resolvedParts().filter((p) => p?.type === "tool" && p?.state.status === "completed") as ToolPart[] - }) - const finishedItems = createMemo<(JSXElement | ToolPart)[]>(() => [ -
, -
, -
, - ...eligibleItems(), - ...(done() - ? [ -
, -
, -
, - ] - : []), - ]) - - const delay = createMemo(() => (done() ? 220 : 400)) - const [visibleCount, setVisibleCount] = createSignal(eligibleItems().length) - - createEffect(() => { - const total = finishedItems().length - if (total > visibleCount()) { - const timer = setTimeout(() => { - setVisibleCount((prev) => prev + 1) - }, delay()) - onCleanup(() => clearTimeout(timer)) - } else if (total < visibleCount()) { - setVisibleCount(total) - } - }) - - const translateY = createMemo(() => { - const total = visibleCount() - if (total < 2) return "0px" - return `-${(total - 2) * 40 - 8}px` - }) - - 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 undefined - }) - - const [status, setStatus] = createSignal(rawStatus()) - let lastStatusChange = Date.now() - let statusTimeout: number | undefined - - createEffect(() => { - const newStatus = rawStatus() - if (newStatus === status() || !newStatus) return - - const timeSinceLastChange = Date.now() - lastStatusChange - - if (timeSinceLastChange >= 1500) { - setStatus(newStatus) - lastStatusChange = Date.now() - if (statusTimeout) { - clearTimeout(statusTimeout) - statusTimeout = undefined - } - } else { - if (statusTimeout) clearTimeout(statusTimeout) - statusTimeout = setTimeout(() => { - setStatus(rawStatus()) - lastStatusChange = Date.now() - statusTimeout = undefined - }, 1000 - timeSinceLastChange) as unknown as number - } - }) - - return ( -
-
- {status() ?? "Considering next steps..."} -
- 0}> -
-
- - {(part) => ( - - - {(p) => { - const part = p() as ToolPart - const message = createMemo(() => - data.store.message[part.sessionID].find((m) => m.id === part.messageID), - ) - return ( -
- -
- ) - }} -
- -
{part as JSXElement}
-
-
- )} -
-
-
-
-
- ) -} diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index d60082d931..ba2c954bcc 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -26,7 +26,6 @@ @import "../components/logo.css" layer(components); @import "../components/markdown.css" layer(components); @import "../components/message-part.css" layer(components); -@import "../components/message-progress.css" layer(components); @import "../components/message-nav.css" layer(components); @import "../components/progress-circle.css" layer(components); @import "../components/resize-handle.css" layer(components); From bf420e7df6c9c01e445bcd262fbc6b4480a9c312 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 12 Dec 2025 13:55:46 -0600 Subject: [PATCH 03/14] chore: cleanup --- packages/ui/src/components/message-part.tsx | 41 ++++++++++++--------- packages/ui/src/components/session-turn.tsx | 6 +-- packages/util/src/sanitize.ts | 28 -------------- 3 files changed, 25 insertions(+), 50 deletions(-) delete mode 100644 packages/util/src/sanitize.ts diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 1a33d15c5d..d69432caaf 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -8,6 +8,7 @@ import { ToolPart, UserMessage, } from "@opencode-ai/sdk/v2" +import { useData } from "../context" import { useDiffComponent } from "../context/diff" import { BasicTool } from "./basic-tool" import { GenericTool } from "./basic-tool" @@ -16,26 +17,34 @@ import { Icon } from "./icon" import { Checkbox } from "./checkbox" 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 { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path" export interface MessageProps { message: MessageType parts: PartType[] - sanitize?: RegExp } export interface MessagePartProps { part: PartType message: MessageType hideDetails?: boolean - sanitize?: RegExp } export type PartComponent = Component export const PART_MAPPING: Record = {} +function relativizeProjectPaths(text: string, directory?: string) { + if (!text) return "" + if (!directory) return text + return text.split(directory).join("") +} + +function getDirectory(path: string | undefined) { + const data = useData() + return relativizeProjectPaths(_getDirectory(path), data.directory) +} + export function registerPartComponent(type: string, component: PartComponent) { PART_MAPPING[type] = component } @@ -48,26 +57,20 @@ export function Message(props: MessageProps) { {(assistantMessage) => ( - + )} ) } -export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[]; sanitize?: RegExp }) { +export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[] }) { const filteredParts = createMemo(() => { return props.parts?.filter((x) => { return x.type !== "tool" || (x as ToolPart).tool !== "todoread" }) }) - return ( - {(part) => } - ) + return {(part) => } } export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) { @@ -82,10 +85,9 @@ 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(props.part, props.sanitize)) return ( - + ) } @@ -173,12 +175,15 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { } PART_MAPPING["text"] = function TextPartDisplay(props) { + const data = useData() const part = props.part as TextPart - const sanitized = createMemo(() => (props.sanitize ? (sanitizePart(part, props.sanitize) as TextPart) : part)) + const content = createMemo(() => (part.text ?? "").trim()) + const displayText = createMemo(() => relativizeProjectPaths(content(), data.directory)) + return ( - +
- +
) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 0043719e03..d038ef1428 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -33,7 +33,6 @@ export function SessionTurn( ) { const data = useData() const diffComponent = useDiffComponent() - const sanitizer = createMemo(() => (data.directory ? new RegExp(`${data.directory}/`, "g") : undefined)) const messages = createMemo(() => (props.sessionID ? (data.store.message[props.sessionID] ?? []) : [])) const userMessages = createMemo(() => messages() @@ -208,7 +207,7 @@ export function SessionTurn(
- +
{/* Response */}
@@ -252,11 +251,10 @@ export function SessionTurn( p?.id !== last()?.id)} - sanitize={sanitizer()} /> - + ) diff --git a/packages/util/src/sanitize.ts b/packages/util/src/sanitize.ts deleted file mode 100644 index 4bb7623933..0000000000 --- a/packages/util/src/sanitize.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { Part } from "@opencode-ai/sdk/v2/client" - -export const sanitize = (text: string | undefined, remove?: RegExp) => (remove ? text?.replace(remove, "") : text) ?? "" - -export const sanitizePart = (part: Part, remove: RegExp | undefined) => { - if (part.type === "text") { - part.text = sanitize(part.text, remove) - } else if (part.type === "reasoning") { - part.text = sanitize(part.text, remove) - } else if (part.type === "tool") { - if (part.state.status === "completed" || part.state.status === "error") { - for (const key in part.state.metadata) { - if (typeof part.state.metadata[key] === "string") { - part.state.metadata[key] = sanitize(part.state.metadata[key] as string, remove) - } - } - for (const key in part.state.input) { - if (typeof part.state.input[key] === "string") { - part.state.input[key] = sanitize(part.state.input[key] as string, remove) - } - } - if ("error" in part.state) { - part.state.error = sanitize(part.state.error as string, remove) - } - } - } - return part -} From f7acc343278ed14df007b3c5d9ee2dbbfef3dcbe Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 12 Dec 2025 14:16:36 -0600 Subject: [PATCH 04/14] fix: desktop layout --- packages/ui/src/components/session-turn.css | 6 +++++- packages/ui/src/components/session-turn.tsx | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index 3b3a0399a5..dac4c1ca63 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -270,12 +270,15 @@ } [data-slot="session-turn-response-section"] { - width: 100%; + width: calc(100% + 9px); min-width: 0; + margin-left: -9px; + padding-left: 9px; } [data-slot="session-turn-collapsible"] { gap: 32px; + overflow: visible; } [data-slot="session-turn-collapsible-trigger-content"] { @@ -284,6 +287,7 @@ align-items: center; gap: 4px; color: var(--text-weak); + margin-left: -9px; [data-component="spinner"] { width: 12px; diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index d038ef1428..8612fc0b66 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -183,7 +183,7 @@ export function SessionTurn( setStore("status", rawStatus()) lastStatusChange = Date.now() statusTimeout = undefined - }, 1000 - timeSinceLastChange) as unknown as number + }, 2500 - timeSinceLastChange) as unknown as number } }) From 3e03646e42bd02d2d69144f4790e0e0df69af403 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 12 Dec 2025 14:20:37 -0600 Subject: [PATCH 05/14] fix: desktop layout --- packages/ui/src/components/session-turn.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index dac4c1ca63..2203b60331 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -317,5 +317,9 @@ flex-direction: column; align-self: stretch; gap: 12px; + + > :first-child > [data-component="markdown"]:first-child { + margin-top: 0; + } } } From 41e234c6d0f7f0e8cfb137ef1a4d9c9c9eea7d06 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 12 Dec 2025 14:32:18 -0600 Subject: [PATCH 06/14] fix: desktop layout --- bun.lock | 4 + packages/ui/package.json | 2 + packages/ui/src/components/session-turn.tsx | 615 +++++++++++--------- 3 files changed, 334 insertions(+), 287 deletions(-) diff --git a/bun.lock b/bun.lock index b970c0e687..e15a3ba36f 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/audio@1.4.2", "", { "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/bounds@0.1.3", "", { "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/event-bus@1.1.2", "", { "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/event-listener@2.4.3", "", { "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 874384efaa..c4902e96f7 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 8612fc0b66..f57a0509be 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 (
-
- - {(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") { - 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, +
+
+ + {(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 [store, setStore] = createStore({ - status: rawStatus(), - detailsExpanded: true, - duration: duration(), - }) - - 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 - - const timeSinceLastChange = Date.now() - lastStatusChange - - if (timeSinceLastChange >= 2500) { - setStore("status", newStatus) - lastStatusChange = Date.now() - if (statusTimeout) { - clearTimeout(statusTimeout) - statusTimeout = 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() } - } else { - if (statusTimeout) clearTimeout(statusTimeout) - statusTimeout = setTimeout(() => { - setStore("status", rawStatus()) - lastStatusChange = Date.now() - statusTimeout = undefined - }, 2500 - timeSinceLastChange) as unknown as number - } - }) + return resolved + }) + const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0)) + const rawStatus = createMemo(() => { + const last = lastPart() + if (!last) return undefined - return ( -
- {/* Title */} -
-
- - - - - -

{message().summary?.title}

-
-
-
-
-
- -
- {/* Response */} -
- setStore("detailsExpanded", open)} - data-slot="session-turn-collapsible" - > - - - - + 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 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 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 + }, 2500 - timeSinceLastChange) as unknown as number + } + }) + + return ( +
+ {/* Title */} +
+
- {store.status ?? "Considering next steps..."} - Hide steps - Show steps + + + + +

{message().summary?.title}

+
- · - {store.duration} - - - -
- - {(assistantMessage) => { - const parts = createMemo(() => data.store.part[assistantMessage.id] ?? []) - const last = createMemo(() => - parts() - .filter((p) => p?.type === "text") - .at(-1), - ) - return ( - - - p?.id !== last()?.id)} - /> - - - - - - ) - }} - - - - {error()?.data?.message as string} - +
+
+
+ +
+ {/* Response */} +
+ setStore("detailsExpanded", open)} + data-slot="session-turn-collapsible" + > + + + + + + {store.status ?? "Considering next steps..."} + Hide steps + Show steps + + · + {store.duration} + + + +
+ + {(assistantMessage) => { + const parts = createMemo(() => data.store.part[assistantMessage.id] ?? []) + const last = createMemo(() => + parts() + .filter((p) => p?.type === "text") + .at(-1), + ) + return ( + + + p?.id !== last()?.id)} + /> + + + + + + ) + }} + + + + {error()?.data?.message as string} + + +
+
+
+
+ {/* Summary */} + +
+
+

+ + Summary + Response + +

+ + {(summary) => ( + + )}
- - -
- {/* Summary */} - -
-
-

- - Summary - Response - -

- - {(summary) => ( - - )} - -
- - - {(diff) => ( - - - -
-
- -
- - {getDirectory(diff.file)}‎ - - {getFilename(diff.file)} + + + {(diff) => ( + + + +
+
+ +
+ + {getDirectory(diff.file)}‎ + + {getFilename(diff.file)} +
+
+
+ +
-
- - -
-
- - - - - - - )} - - -
- - - - {error()?.data?.message as string} - - -
- ) - }} - - {props.children} +
+
+ + + +
+ )} +
+
+
+
+ + + {error()?.data?.message as string} + + +
+ ) + }} + + {props.children} +
) From ccdd77032aedf9ce9fdf48ad74e79a9e308a370c Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 12 Dec 2025 14:44:04 -0600 Subject: [PATCH 07/14] fix: desktop layout --- packages/ui/src/components/session-turn.css | 24 +++- packages/ui/src/components/session-turn.tsx | 121 ++++++++++---------- 2 files changed, 79 insertions(+), 66 deletions(-) diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index 2203b60331..6bead9b57b 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -29,20 +29,32 @@ gap: 32px; } + [data-slot="session-turn-sticky-header"] { + position: sticky; + top: 0; + background-color: var(--background-stronger); + z-index: 20; + display: flex; + flex-direction: column; + gap: 8px; + padding-bottom: 8px; + } + [data-slot="session-turn-message-header"] { display: flex; align-items: center; gap: 8px; align-self: stretch; - position: sticky; - top: 0; - background-color: var(--background-stronger); - z-index: 20; height: 32px; } - [data-slot="session-turn-message-content"] { - margin-top: -24px; + /* [data-slot="session-turn-message-content"] { */ + /* } */ + + [data-slot="session-turn-response-trigger"] { + width: calc(100% + 9px); + margin-left: -9px; + padding-left: 9px; } [data-slot="session-turn-message-title"] { diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index f57a0509be..c09754a59b 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -73,6 +73,12 @@ export function SessionTurn( } } + function handleInteraction() { + if (working()) { + setUserScrolled(true) + } + } + createEffect(() => { if (!working()) { setUserScrolled(false) @@ -90,7 +96,7 @@ export function SessionTurn( return (
-
+
{(message) => { const assistantMessages = createMemo(() => { @@ -233,35 +239,29 @@ export function SessionTurn( data-slot="session-turn-message-container" class={props.classes?.container} > - {/* Title */} -
-
- - - - - -

{message().summary?.title}

-
-
+ {/* Sticky Header */} +
+
+
+ + + + + +

{message().summary?.title}

+
+
+
-
-
- -
- {/* Response */} -
- setStore("detailsExpanded", open)} - data-slot="session-turn-collapsible" - > - + +
+
+ +
+ {/* Response */} + +
+ + {(assistantMessage) => { + const parts = createMemo(() => data.store.part[assistantMessage.id] ?? []) + const last = createMemo(() => + parts() + .filter((p) => p?.type === "text") + .at(-1), + ) + return ( + + + p?.id !== last()?.id)} + /> + + + + + + ) + }} + + + + {error()?.data?.message as string} + + +
+
{/* Summary */}
From 9efe09564bc0f6fca488f165a991ed9d90548457 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 12 Dec 2025 14:48:56 -0600 Subject: [PATCH 08/14] fix: desktop layout --- packages/ui/src/components/session-turn.css | 4 ++-- packages/ui/src/components/session-turn.tsx | 12 +++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index 6bead9b57b..3b7d74dc27 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -214,10 +214,10 @@ } [data-component="sticky-accordion-header"] { - top: 40px; + top: var(--sticky-header-height, 40px); &[data-expanded]::before { - top: -40px; + top: calc(-1 * var(--sticky-header-height, 40px)); } } diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index c09754a59b..a1c3b97b77 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -62,7 +62,9 @@ export function SessionTurn( let scrollRef: HTMLDivElement | undefined let contentRef: HTMLDivElement | undefined + let stickyHeaderRef: HTMLDivElement | undefined const [userScrolled, setUserScrolled] = createSignal(false) + const [stickyHeaderHeight, setStickyHeaderHeight] = createSignal(0) function handleScroll() { if (!scrollRef) return @@ -93,6 +95,13 @@ export function SessionTurn( }) }) + onMount(() => { + if (!stickyHeaderRef) return + createResizeObserver(stickyHeaderRef, ({ height }) => { + setStickyHeaderHeight(height + 8) + }) + }) + return (
@@ -238,9 +247,10 @@ export function SessionTurn( data-message={message().id} data-slot="session-turn-message-container" class={props.classes?.container} + style={{ "--sticky-header-height": `${stickyHeaderHeight()}px` }} > {/* Sticky Header */} -
+
From a16edb4ea09777ae180cc21646469c017636e7e5 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 12 Dec 2025 14:52:36 -0600 Subject: [PATCH 09/14] fix: desktop layout --- packages/ui/src/components/session-turn.css | 1 + packages/ui/src/components/session-turn.tsx | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index 3b7d74dc27..c4dd2b839b 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -30,6 +30,7 @@ } [data-slot="session-turn-sticky-header"] { + width: 100%; position: sticky; top: 0; background-color: var(--background-stronger); diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index a1c3b97b77..71032be926 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -242,6 +242,15 @@ export function SessionTurn( } }) + // Auto-collapse steps when done working (if user hasn't interacted) + createEffect((prev) => { + const isWorking = working() + if (prev && !isWorking && !userScrolled()) { + setStore("detailsExpanded", false) + } + return isWorking + }, working()) + return (
Date: Fri, 12 Dec 2025 14:59:41 -0600 Subject: [PATCH 10/14] fix: desktop layout --- packages/ui/src/components/spinner.tsx | 20 +++++++++++--------- packages/ui/src/components/typewriter.tsx | 13 +++++++++---- packages/ui/src/styles/animations.css | 10 ++++++++++ 3 files changed, 30 insertions(+), 13 deletions(-) diff --git a/packages/ui/src/components/spinner.tsx b/packages/ui/src/components/spinner.tsx index 5e787d86b5..41f4d9e714 100644 --- a/packages/ui/src/components/spinner.tsx +++ b/packages/ui/src/components/spinner.tsx @@ -1,14 +1,16 @@ import { ComponentProps, For } from "solid-js" -export function Spinner(props: { class?: string; classList?: ComponentProps<"div">["classList"] }) { - const squares = Array.from({ length: 16 }, (_, i) => ({ - id: i, - x: (i % 4) * 4, - y: Math.floor(i / 4) * 4, - delay: Math.random() * 3, - duration: 2 + Math.random() * 2, - })) +const outerIndices = new Set([0, 1, 2, 3, 4, 7, 8, 11, 12, 13, 14, 15]) +const squares = Array.from({ length: 16 }, (_, i) => ({ + id: i, + x: (i % 4) * 4, + y: Math.floor(i / 4) * 4, + delay: Math.random() * 1.5, + duration: 1 + Math.random() * 1, + outer: outerIndices.has(i), +})) +export function Spinner(props: { class?: string; classList?: ComponentProps<"div">["classList"] }) { return ( diff --git a/packages/ui/src/components/typewriter.tsx b/packages/ui/src/components/typewriter.tsx index 2f6ecb0162..16c85a110f 100644 --- a/packages/ui/src/components/typewriter.tsx +++ b/packages/ui/src/components/typewriter.tsx @@ -1,4 +1,4 @@ -import { createEffect, Show, type ValidComponent } from "solid-js" +import { createEffect, onCleanup, Show, type ValidComponent } from "solid-js" import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web" @@ -14,6 +14,7 @@ export const Typewriter = (props: { text?: strin if (!text) return let i = 0 + const timeouts: ReturnType[] = [] setStore("typing", true) setStore("displayed", "") setStore("cursor", true) @@ -29,14 +30,18 @@ export const Typewriter = (props: { text?: strin if (i < text.length) { setStore("displayed", text.slice(0, i + 1)) i++ - setTimeout(type, getTypingDelay()) + timeouts.push(setTimeout(type, getTypingDelay())) } else { setStore("typing", false) - setTimeout(() => setStore("cursor", false), 2000) + timeouts.push(setTimeout(() => setStore("cursor", false), 2000)) } } - setTimeout(type, 200) + timeouts.push(setTimeout(type, 200)) + + onCleanup(() => { + for (const timeout of timeouts) clearTimeout(timeout) + }) }) return ( diff --git a/packages/ui/src/styles/animations.css b/packages/ui/src/styles/animations.css index 5fcebb93f6..0ae3493ebc 100644 --- a/packages/ui/src/styles/animations.css +++ b/packages/ui/src/styles/animations.css @@ -12,6 +12,16 @@ } } +@keyframes pulse-opacity-dim { + 0%, + 100% { + opacity: 0; + } + 50% { + opacity: 0.3; + } +} + @keyframes fadeUp { from { opacity: 0; From d463ade028211e7bcfcb69338ecb82b2dbcef51c Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 12 Dec 2025 15:18:07 -0600 Subject: [PATCH 11/14] fix: desktop layout --- packages/ui/src/components/basic-tool.tsx | 3 ++- packages/ui/src/components/message-part.tsx | 1 + packages/ui/src/components/session-turn.tsx | 16 +++++++--------- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index 596eef00b4..4fab331a5a 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -21,12 +21,13 @@ export interface BasicToolProps { trigger: TriggerTitle | JSX.Element children?: JSX.Element hideDetails?: boolean + defaultOpen?: boolean } export function BasicTool(props: BasicToolProps) { const resolved = children(() => props.children) return ( - +
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index d69432caaf..b70a68de8c 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -343,6 +343,7 @@ ToolRegistry.register({ const diffComponent = useDiffComponent() return ( diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 71032be926..0cbe1a0d28 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -25,7 +25,6 @@ import { StickyAccordionHeader } from "./sticky-accordion-header" import { FileIcon } from "./file-icon" import { Icon } from "./icon" import { Card } from "./card" -import { Collapsible } from "./collapsible" import { Dynamic } from "solid-js/web" import { Button } from "./button" import { Spinner } from "./spinner" @@ -206,7 +205,7 @@ export function SessionTurn( const [store, setStore] = createStore({ status: rawStatus(), - detailsExpanded: true, + stepsExpanded: true, duration: duration(), }) @@ -242,11 +241,10 @@ export function SessionTurn( } }) - // Auto-collapse steps when done working (if user hasn't interacted) createEffect((prev) => { const isWorking = working() if (prev && !isWorking && !userScrolled()) { - setStore("detailsExpanded", false) + setStore("stepsExpanded", false) } return isWorking }, working()) @@ -280,15 +278,15 @@ export function SessionTurn( data-slot="session-turn-collapsible-trigger-content" variant="ghost" size="small" - onClick={() => setStore("detailsExpanded", !store.detailsExpanded)} + onClick={() => setStore("stepsExpanded", !store.stepsExpanded)} > {store.status ?? "Considering next steps..."} - Hide steps - Show steps + Hide steps + Show steps · {store.duration} @@ -297,7 +295,7 @@ export function SessionTurn(
{/* Response */} - +
{(assistantMessage) => { @@ -396,7 +394,7 @@ export function SessionTurn(
- + {error()?.data?.message as string} From d6ba6af6f31b02da879d2c98a3125bc1df7a0255 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 12 Dec 2025 15:24:38 -0600 Subject: [PATCH 12/14] fix: desktop layout --- packages/ui/src/components/session-turn.tsx | 24 ++++++++------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 0cbe1a0d28..708ac5b83d 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -60,8 +60,8 @@ export function SessionTurn( const working = createMemo(() => status()?.type !== "idle") let scrollRef: HTMLDivElement | undefined - let contentRef: HTMLDivElement | undefined - let stickyHeaderRef: HTMLDivElement | undefined + const [contentRef, setContentRef] = createSignal() + const [stickyHeaderRef, setStickyHeaderRef] = createSignal() const [userScrolled, setUserScrolled] = createSignal(false) const [stickyHeaderHeight, setStickyHeaderHeight] = createSignal(0) @@ -86,25 +86,19 @@ export function SessionTurn( } }) - onMount(() => { - if (!contentRef) return - createResizeObserver(contentRef, () => { - if (!scrollRef || userScrolled() || !working()) return - scrollRef.scrollTop = scrollRef.scrollHeight - }) + createResizeObserver(contentRef, () => { + if (!scrollRef || userScrolled() || !working()) return + scrollRef.scrollTop = scrollRef.scrollHeight }) - onMount(() => { - if (!stickyHeaderRef) return - createResizeObserver(stickyHeaderRef, ({ height }) => { - setStickyHeaderHeight(height + 8) - }) + createResizeObserver(stickyHeaderRef, ({ height }) => { + setStickyHeaderHeight(height + 8) }) return (
-
+
{(message) => { const assistantMessages = createMemo(() => { @@ -257,7 +251,7 @@ export function SessionTurn( style={{ "--sticky-header-height": `${stickyHeaderHeight()}px` }} > {/* Sticky Header */} -
+
From 9846b26be75a33fd71335c2673e456c53a478a5a Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 12 Dec 2025 15:26:53 -0600 Subject: [PATCH 13/14] fix: desktop layout --- packages/ui/src/components/message-part.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index b70a68de8c..1e00a93128 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -321,6 +321,7 @@ ToolRegistry.register({ render(props) { return ( Date: Fri, 12 Dec 2025 21:28:16 +0000 Subject: [PATCH 14/14] Update Nix flake.lock and hashes --- nix/hashes.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/hashes.json b/nix/hashes.json index b640219471..e28f98d051 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,3 +1,3 @@ { - "nodeModules": "sha256-3CG0wAMQp2E6ghPUXbYaYifJorp9b1WvCtHD+o8Nhck=" + "nodeModules": "sha256-nWSAnQEm/t1ESZe23dr4JnIOJQ0JLN0w4NVoMJajbVQ=" }