From f1a210773c5b1170c26cb483ceba66accf05df92 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 11 Feb 2026 08:49:59 -0600 Subject: [PATCH 001/121] wip(app): timeline changes --- packages/app/src/pages/session.tsx | 53 +- .../src/pages/session/message-timeline.tsx | 4 - .../pages/session/use-session-commands.tsx | 371 ++++++------- .../enterprise/src/routes/share/[shareID].tsx | 11 - packages/ui/src/components/message-part.tsx | 60 +- packages/ui/src/components/session-turn.css | 515 +----------------- packages/ui/src/components/session-turn.tsx | 408 ++------------ 7 files changed, 247 insertions(+), 1175 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index d958990c25ab..8bca1bff61fa 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -23,6 +23,7 @@ import { useSync } from "@/context/sync" import { useTerminal, type LocalPTY } from "@/context/terminal" import { useLayout } from "@/context/layout" import { checksum, base64Encode } from "@opencode-ai/util/encode" +import { findLast } from "@opencode-ai/util/array" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectFile } from "@/components/dialog-select-file" import FileTree from "@/components/file-tree" @@ -34,6 +35,7 @@ import { useSDK } from "@/context/sdk" import { usePrompt } from "@/context/prompt" import { useComments } from "@/context/comments" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" +import { usePermission } from "@/context/permission" import { showToast } from "@opencode-ai/ui/toast" import { SessionHeader, SessionContextTab, SortableTab, FileVisual, NewSessionView } from "@/components/session" import { navMark, navParams } from "@/utils/perf" @@ -99,6 +101,7 @@ export default function Page() { const sdk = useSDK() const prompt = usePrompt() const comments = useComments() + const permission = usePermission() const permRequest = createMemo(() => { const sessionID = params.id @@ -229,7 +232,7 @@ export default function Page() { }) } - const isDesktop = createMediaQuery("(min-width: 1024px)") + const isDesktop = createMediaQuery("(min-width: 768px)") const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened()) const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened()) const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen()) @@ -269,7 +272,6 @@ export default function Page() { if (!path) return file.load(path) openReviewPanel() - tabs().setActive(next) } createEffect(() => { @@ -554,7 +556,6 @@ export default function Page() { const [store, setStore] = createStore({ activeDraggable: undefined as string | undefined, activeTerminalDraggable: undefined as string | undefined, - expanded: {} as Record, messageId: undefined as string | undefined, turnStart: 0, mobileTab: "session" as "session" | "changes", @@ -732,7 +733,6 @@ export default function Page() { sessionKey, () => { setStore("messageId", undefined) - setStore("expanded", {}) setStore("changes", "session") setUi("autoCreated", false) }, @@ -751,12 +751,6 @@ export default function Page() { ), ) - createEffect(() => { - const id = lastUserMessage()?.id - if (!id) return - setStore("expanded", id, status().type !== "idle") - }) - const selectionPreview = (path: string, selection: FileSelection) => { const content = file.get(path)?.content?.content if (!content) return undefined @@ -767,6 +761,11 @@ export default function Page() { return lines.slice(0, 2).join("\n") } + const addSelectionToContext = (path: string, selection: FileSelection) => { + const preview = selectionPreview(path, selection) + prompt.context.add({ type: "file", path, selection, preview }) + } + const addCommentToContext = (input: { file: string selection: SelectedLineRange @@ -905,11 +904,29 @@ export default function Page() { const focusInput = () => inputRef?.focus() useSessionCommands({ - activeMessage, + command, + dialog, + file, + language, + local, + permission, + prompt, + sdk, + sync, + terminal, + layout, + params, + navigate, + tabs, + view, + info, + status, + userMessages, + visibleUserMessages, showAllFiles, navigateMessageByOffset, - setExpanded: (id, fn) => setStore("expanded", id, fn), setActiveMessage, + addSelectionToContext, focusInput, }) @@ -1524,13 +1541,7 @@ export default function Page() { return (
-
+
setStore("expanded", id, (open: boolean | undefined) => !open)} /> @@ -1731,7 +1740,7 @@ export default function Page() {
void onFirstTurnMount?: () => void lastUserMessageID?: string - expanded: Record - onToggleExpanded: (id: string) => void }) { let touchGesture: number | undefined @@ -316,8 +314,6 @@ export function MessageTimeline(props: { sessionID={props.sessionID} messageID={message.id} lastUserMessageID={props.lastUserMessageID} - stepsExpanded={props.expanded[message.id] ?? false} - onStepsExpandedToggle={() => props.onToggleExpanded(message.id)} classes={{ root: "min-w-0 w-full relative", content: "flex flex-col justify-between !overflow-visible", diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index d2f74288f6b0..1e36f23be660 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -19,14 +19,33 @@ import { showToast } from "@opencode-ai/ui/toast" import { findLast } from "@opencode-ai/util/array" import { extractPromptFromParts } from "@/utils/prompt" import { UserMessage } from "@opencode-ai/sdk/v2" +import { combineCommandSections } from "@/pages/session/helpers" import { canAddSelectionContext } from "@/pages/session/session-command-helpers" export type SessionCommandContext = { - activeMessage: () => UserMessage | undefined + command: ReturnType + dialog: ReturnType + file: ReturnType + language: ReturnType + local: ReturnType + permission: ReturnType + prompt: ReturnType + sdk: ReturnType + sync: ReturnType + terminal: ReturnType + layout: ReturnType + params: ReturnType + navigate: ReturnType + tabs: () => ReturnType["tabs"]> + view: () => ReturnType["view"]> + info: () => { revert?: { messageID?: string }; share?: { url?: string } } | undefined + status: () => { type: string } + userMessages: () => UserMessage[] + visibleUserMessages: () => UserMessage[] showAllFiles: () => void navigateMessageByOffset: (offset: number) => void - setExpanded: (id: string, fn: (open: boolean | undefined) => boolean) => void setActiveMessage: (message: UserMessage | undefined) => void + addSelectionToContext: (path: string, selection: FileSelection) => void focusInput: () => void } @@ -37,88 +56,45 @@ const withCategory = (category: string) => { }) } -export const useSessionCommands = (args: SessionCommandContext) => { - const command = useCommand() - const dialog = useDialog() - const file = useFile() - const language = useLanguage() - const local = useLocal() - const permission = usePermission() - const prompt = usePrompt() - const sdk = useSDK() - const sync = useSync() - const terminal = useTerminal() - const layout = useLayout() - const params = useParams() - const navigate = useNavigate() - - const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) - const tabs = createMemo(() => layout.tabs(sessionKey)) - const view = createMemo(() => layout.view(sessionKey)) - const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) - const idle = { type: "idle" as const } - const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle) - const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) - const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[]) - const visibleUserMessages = createMemo(() => { - const revert = info()?.revert?.messageID - if (!revert) return userMessages() - return userMessages().filter((m) => m.id < revert) - }) - - const selectionPreview = (path: string, selection: FileSelection) => { - const content = file.get(path)?.content?.content - if (!content) return undefined - const start = Math.max(1, Math.min(selection.startLine, selection.endLine)) - const end = Math.max(selection.startLine, selection.endLine) - const lines = content.split("\n").slice(start - 1, end) - if (lines.length === 0) return undefined - return lines.slice(0, 2).join("\n") - } - - const addSelectionToContext = (path: string, selection: FileSelection) => { - const preview = selectionPreview(path, selection) - prompt.context.add({ type: "file", path, selection, preview }) - } - - const sessionCommand = withCategory(language.t("command.category.session")) - const fileCommand = withCategory(language.t("command.category.file")) - const contextCommand = withCategory(language.t("command.category.context")) - const viewCommand = withCategory(language.t("command.category.view")) - const terminalCommand = withCategory(language.t("command.category.terminal")) - const modelCommand = withCategory(language.t("command.category.model")) - const mcpCommand = withCategory(language.t("command.category.mcp")) - const agentCommand = withCategory(language.t("command.category.agent")) - const permissionsCommand = withCategory(language.t("command.category.permissions")) +export const useSessionCommands = (input: SessionCommandContext) => { + const sessionCommand = withCategory(input.language.t("command.category.session")) + const fileCommand = withCategory(input.language.t("command.category.file")) + const contextCommand = withCategory(input.language.t("command.category.context")) + const viewCommand = withCategory(input.language.t("command.category.view")) + const terminalCommand = withCategory(input.language.t("command.category.terminal")) + const modelCommand = withCategory(input.language.t("command.category.model")) + const mcpCommand = withCategory(input.language.t("command.category.mcp")) + const agentCommand = withCategory(input.language.t("command.category.agent")) + const permissionsCommand = withCategory(input.language.t("command.category.permissions")) const sessionCommands = createMemo(() => [ sessionCommand({ id: "session.new", - title: language.t("command.session.new"), + title: input.language.t("command.session.new"), keybind: "mod+shift+s", slash: "new", - onSelect: () => navigate(`/${params.dir}/session`), + onSelect: () => input.navigate(`/${input.params.dir}/session`), }), ]) const fileCommands = createMemo(() => [ fileCommand({ id: "file.open", - title: language.t("command.file.open"), - description: language.t("palette.search.placeholder"), + title: input.language.t("command.file.open"), + description: input.language.t("palette.search.placeholder"), keybind: "mod+p", slash: "open", - onSelect: () => dialog.show(() => ), + onSelect: () => input.dialog.show(() => ), }), fileCommand({ id: "tab.close", - title: language.t("command.tab.close"), + title: input.language.t("command.tab.close"), keybind: "mod+w", - disabled: !tabs().active(), + disabled: !input.tabs().active(), onSelect: () => { - const active = tabs().active() + const active = input.tabs().active() if (!active) return - tabs().close(active) + input.tabs().close(active) }, }), ]) @@ -126,30 +102,30 @@ export const useSessionCommands = (args: SessionCommandContext) => { const contextCommands = createMemo(() => [ contextCommand({ id: "context.addSelection", - title: language.t("command.context.addSelection"), - description: language.t("command.context.addSelection.description"), + title: input.language.t("command.context.addSelection"), + description: input.language.t("command.context.addSelection.description"), keybind: "mod+shift+l", disabled: !canAddSelectionContext({ - active: tabs().active(), - pathFromTab: file.pathFromTab, - selectedLines: file.selectedLines, + active: input.tabs().active(), + pathFromTab: input.file.pathFromTab, + selectedLines: input.file.selectedLines, }), onSelect: () => { - const active = tabs().active() + const active = input.tabs().active() if (!active) return - const path = file.pathFromTab(active) + const path = input.file.pathFromTab(active) if (!path) return - const range = file.selectedLines(path) as SelectedLineRange | null | undefined + const range = input.file.selectedLines(path) as SelectedLineRange | null | undefined if (!range) { showToast({ - title: language.t("toast.context.noLineSelection.title"), - description: language.t("toast.context.noLineSelection.description"), + title: input.language.t("toast.context.noLineSelection.title"), + description: input.language.t("toast.context.noLineSelection.description"), }) return } - addSelectionToContext(path, selectionFromLines(range)) + input.addSelectionToContext(path, selectionFromLines(range)) }, }), ]) @@ -157,50 +133,37 @@ export const useSessionCommands = (args: SessionCommandContext) => { const viewCommands = createMemo(() => [ viewCommand({ id: "terminal.toggle", - title: language.t("command.terminal.toggle"), + title: input.language.t("command.terminal.toggle"), keybind: "ctrl+`", slash: "terminal", - onSelect: () => view().terminal.toggle(), + onSelect: () => input.view().terminal.toggle(), }), viewCommand({ id: "review.toggle", - title: language.t("command.review.toggle"), + title: input.language.t("command.review.toggle"), keybind: "mod+shift+r", - onSelect: () => view().reviewPanel.toggle(), + onSelect: () => input.view().reviewPanel.toggle(), }), viewCommand({ id: "fileTree.toggle", - title: language.t("command.fileTree.toggle"), + title: input.language.t("command.fileTree.toggle"), keybind: "mod+\\", - onSelect: () => layout.fileTree.toggle(), + onSelect: () => input.layout.fileTree.toggle(), }), viewCommand({ id: "input.focus", - title: language.t("command.input.focus"), + title: input.language.t("command.input.focus"), keybind: "ctrl+l", - onSelect: () => args.focusInput(), + onSelect: () => input.focusInput(), }), terminalCommand({ id: "terminal.new", - title: language.t("command.terminal.new"), - description: language.t("command.terminal.new.description"), + title: input.language.t("command.terminal.new"), + description: input.language.t("command.terminal.new.description"), keybind: "ctrl+alt+t", onSelect: () => { - if (terminal.all().length > 0) terminal.new() - view().terminal.open() - }, - }), - viewCommand({ - id: "steps.toggle", - title: language.t("command.steps.toggle"), - description: language.t("command.steps.toggle.description"), - keybind: "mod+e", - slash: "steps", - disabled: !params.id, - onSelect: () => { - const msg = args.activeMessage() - if (!msg) return - args.setExpanded(msg.id, (open: boolean | undefined) => !open) + if (input.terminal.all().length > 0) input.terminal.new() + input.view().terminal.open() }, }), ]) @@ -208,61 +171,61 @@ export const useSessionCommands = (args: SessionCommandContext) => { const messageCommands = createMemo(() => [ sessionCommand({ id: "message.previous", - title: language.t("command.message.previous"), - description: language.t("command.message.previous.description"), + title: input.language.t("command.message.previous"), + description: input.language.t("command.message.previous.description"), keybind: "mod+arrowup", - disabled: !params.id, - onSelect: () => args.navigateMessageByOffset(-1), + disabled: !input.params.id, + onSelect: () => input.navigateMessageByOffset(-1), }), sessionCommand({ id: "message.next", - title: language.t("command.message.next"), - description: language.t("command.message.next.description"), + title: input.language.t("command.message.next"), + description: input.language.t("command.message.next.description"), keybind: "mod+arrowdown", - disabled: !params.id, - onSelect: () => args.navigateMessageByOffset(1), + disabled: !input.params.id, + onSelect: () => input.navigateMessageByOffset(1), }), ]) const agentCommands = createMemo(() => [ modelCommand({ id: "model.choose", - title: language.t("command.model.choose"), - description: language.t("command.model.choose.description"), + title: input.language.t("command.model.choose"), + description: input.language.t("command.model.choose.description"), keybind: "mod+'", slash: "model", - onSelect: () => dialog.show(() => ), + onSelect: () => input.dialog.show(() => ), }), mcpCommand({ id: "mcp.toggle", - title: language.t("command.mcp.toggle"), - description: language.t("command.mcp.toggle.description"), + title: input.language.t("command.mcp.toggle"), + description: input.language.t("command.mcp.toggle.description"), keybind: "mod+;", slash: "mcp", - onSelect: () => dialog.show(() => ), + onSelect: () => input.dialog.show(() => ), }), agentCommand({ id: "agent.cycle", - title: language.t("command.agent.cycle"), - description: language.t("command.agent.cycle.description"), + title: input.language.t("command.agent.cycle"), + description: input.language.t("command.agent.cycle.description"), keybind: "mod+.", slash: "agent", - onSelect: () => local.agent.move(1), + onSelect: () => input.local.agent.move(1), }), agentCommand({ id: "agent.cycle.reverse", - title: language.t("command.agent.cycle.reverse"), - description: language.t("command.agent.cycle.reverse.description"), + title: input.language.t("command.agent.cycle.reverse"), + description: input.language.t("command.agent.cycle.reverse.description"), keybind: "shift+mod+.", - onSelect: () => local.agent.move(-1), + onSelect: () => input.local.agent.move(-1), }), modelCommand({ id: "model.variant.cycle", - title: language.t("command.model.variant.cycle"), - description: language.t("command.model.variant.cycle.description"), + title: input.language.t("command.model.variant.cycle"), + description: input.language.t("command.model.variant.cycle.description"), keybind: "shift+mod+d", onSelect: () => { - local.model.variant.cycle() + input.local.model.variant.cycle() }, }), ]) @@ -271,22 +234,22 @@ export const useSessionCommands = (args: SessionCommandContext) => { permissionsCommand({ id: "permissions.autoaccept", title: - params.id && permission.isAutoAccepting(params.id, sdk.directory) - ? language.t("command.permissions.autoaccept.disable") - : language.t("command.permissions.autoaccept.enable"), + input.params.id && input.permission.isAutoAccepting(input.params.id, input.sdk.directory) + ? input.language.t("command.permissions.autoaccept.disable") + : input.language.t("command.permissions.autoaccept.enable"), keybind: "mod+shift+a", - disabled: !params.id || !permission.permissionsEnabled(), + disabled: !input.params.id || !input.permission.permissionsEnabled(), onSelect: () => { - const sessionID = params.id + const sessionID = input.params.id if (!sessionID) return - permission.toggleAutoAccept(sessionID, sdk.directory) + input.permission.toggleAutoAccept(sessionID, input.sdk.directory) showToast({ - title: permission.isAutoAccepting(sessionID, sdk.directory) - ? language.t("toast.permissions.autoaccept.on.title") - : language.t("toast.permissions.autoaccept.off.title"), - description: permission.isAutoAccepting(sessionID, sdk.directory) - ? language.t("toast.permissions.autoaccept.on.description") - : language.t("toast.permissions.autoaccept.off.description"), + title: input.permission.isAutoAccepting(sessionID, input.sdk.directory) + ? input.language.t("toast.permissions.autoaccept.on.title") + : input.language.t("toast.permissions.autoaccept.off.title"), + description: input.permission.isAutoAccepting(sessionID, input.sdk.directory) + ? input.language.t("toast.permissions.autoaccept.on.description") + : input.language.t("toast.permissions.autoaccept.off.description"), }) }, }), @@ -295,71 +258,71 @@ export const useSessionCommands = (args: SessionCommandContext) => { const sessionActionCommands = createMemo(() => [ sessionCommand({ id: "session.undo", - title: language.t("command.session.undo"), - description: language.t("command.session.undo.description"), + title: input.language.t("command.session.undo"), + description: input.language.t("command.session.undo.description"), slash: "undo", - disabled: !params.id || visibleUserMessages().length === 0, + disabled: !input.params.id || input.visibleUserMessages().length === 0, onSelect: async () => { - const sessionID = params.id + const sessionID = input.params.id if (!sessionID) return - if (status()?.type !== "idle") { - await sdk.client.session.abort({ sessionID }).catch(() => {}) + if (input.status()?.type !== "idle") { + await input.sdk.client.session.abort({ sessionID }).catch(() => {}) } - const revert = info()?.revert?.messageID - const message = findLast(userMessages(), (x) => !revert || x.id < revert) + const revert = input.info()?.revert?.messageID + const message = findLast(input.userMessages(), (x) => !revert || x.id < revert) if (!message) return - await sdk.client.session.revert({ sessionID, messageID: message.id }) - const parts = sync.data.part[message.id] + await input.sdk.client.session.revert({ sessionID, messageID: message.id }) + const parts = input.sync.data.part[message.id] if (parts) { - const restored = extractPromptFromParts(parts, { directory: sdk.directory }) - prompt.set(restored) + const restored = extractPromptFromParts(parts, { directory: input.sdk.directory }) + input.prompt.set(restored) } - const priorMessage = findLast(userMessages(), (x) => x.id < message.id) - args.setActiveMessage(priorMessage) + const priorMessage = findLast(input.userMessages(), (x) => x.id < message.id) + input.setActiveMessage(priorMessage) }, }), sessionCommand({ id: "session.redo", - title: language.t("command.session.redo"), - description: language.t("command.session.redo.description"), + title: input.language.t("command.session.redo"), + description: input.language.t("command.session.redo.description"), slash: "redo", - disabled: !params.id || !info()?.revert?.messageID, + disabled: !input.params.id || !input.info()?.revert?.messageID, onSelect: async () => { - const sessionID = params.id + const sessionID = input.params.id if (!sessionID) return - const revertMessageID = info()?.revert?.messageID + const revertMessageID = input.info()?.revert?.messageID if (!revertMessageID) return - const nextMessage = userMessages().find((x) => x.id > revertMessageID) + const nextMessage = input.userMessages().find((x) => x.id > revertMessageID) if (!nextMessage) { - await sdk.client.session.unrevert({ sessionID }) - prompt.reset() - const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID) - args.setActiveMessage(lastMsg) + await input.sdk.client.session.unrevert({ sessionID }) + input.prompt.reset() + const lastMsg = findLast(input.userMessages(), (x) => x.id >= revertMessageID) + input.setActiveMessage(lastMsg) return } - await sdk.client.session.revert({ sessionID, messageID: nextMessage.id }) - const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id) - args.setActiveMessage(priorMsg) + await input.sdk.client.session.revert({ sessionID, messageID: nextMessage.id }) + const priorMsg = findLast(input.userMessages(), (x) => x.id < nextMessage.id) + input.setActiveMessage(priorMsg) }, }), sessionCommand({ id: "session.compact", - title: language.t("command.session.compact"), - description: language.t("command.session.compact.description"), + title: input.language.t("command.session.compact"), + description: input.language.t("command.session.compact.description"), slash: "compact", - disabled: !params.id || visibleUserMessages().length === 0, + disabled: !input.params.id || input.visibleUserMessages().length === 0, onSelect: async () => { - const sessionID = params.id + const sessionID = input.params.id if (!sessionID) return - const model = local.model.current() + const model = input.local.model.current() if (!model) { showToast({ - title: language.t("toast.model.none.title"), - description: language.t("toast.model.none.description"), + title: input.language.t("toast.model.none.title"), + description: input.language.t("toast.model.none.description"), }) return } - await sdk.client.session.summarize({ + await input.sdk.client.session.summarize({ sessionID, modelID: model.id, providerID: model.provider.id, @@ -368,27 +331,29 @@ export const useSessionCommands = (args: SessionCommandContext) => { }), sessionCommand({ id: "session.fork", - title: language.t("command.session.fork"), - description: language.t("command.session.fork.description"), + title: input.language.t("command.session.fork"), + description: input.language.t("command.session.fork.description"), slash: "fork", - disabled: !params.id || visibleUserMessages().length === 0, - onSelect: () => dialog.show(() => ), + disabled: !input.params.id || input.visibleUserMessages().length === 0, + onSelect: () => input.dialog.show(() => ), }), ]) const shareCommands = createMemo(() => { - if (sync.data.config.share === "disabled") return [] + if (input.sync.data.config.share === "disabled") return [] return [ sessionCommand({ id: "session.share", - title: info()?.share?.url ? language.t("session.share.copy.copyLink") : language.t("command.session.share"), - description: info()?.share?.url - ? language.t("toast.session.share.success.description") - : language.t("command.session.share.description"), + title: input.info()?.share?.url + ? input.language.t("session.share.copy.copyLink") + : input.language.t("command.session.share"), + description: input.info()?.share?.url + ? input.language.t("toast.session.share.success.description") + : input.language.t("command.session.share.description"), slash: "share", - disabled: !params.id, + disabled: !input.params.id, onSelect: async () => { - if (!params.id) return + if (!input.params.id) return const write = (value: string) => { const body = typeof document === "undefined" ? undefined : document.body @@ -418,7 +383,7 @@ export const useSessionCommands = (args: SessionCommandContext) => { const ok = await write(url) if (!ok) { showToast({ - title: language.t("toast.session.share.copyFailed.title"), + title: input.language.t("toast.session.share.copyFailed.title"), variant: "error", }) return @@ -426,27 +391,27 @@ export const useSessionCommands = (args: SessionCommandContext) => { showToast({ title: existing - ? language.t("session.share.copy.copied") - : language.t("toast.session.share.success.title"), - description: language.t("toast.session.share.success.description"), + ? input.language.t("session.share.copy.copied") + : input.language.t("toast.session.share.success.title"), + description: input.language.t("toast.session.share.success.description"), variant: "success", }) } - const existing = info()?.share?.url + const existing = input.info()?.share?.url if (existing) { await copy(existing, true) return } - const url = await sdk.client.session - .share({ sessionID: params.id }) + const url = await input.sdk.client.session + .share({ sessionID: input.params.id }) .then((res) => res.data?.share?.url) .catch(() => undefined) if (!url) { showToast({ - title: language.t("toast.session.share.failed.title"), - description: language.t("toast.session.share.failed.description"), + title: input.language.t("toast.session.share.failed.title"), + description: input.language.t("toast.session.share.failed.description"), variant: "error", }) return @@ -457,25 +422,25 @@ export const useSessionCommands = (args: SessionCommandContext) => { }), sessionCommand({ id: "session.unshare", - title: language.t("command.session.unshare"), - description: language.t("command.session.unshare.description"), + title: input.language.t("command.session.unshare"), + description: input.language.t("command.session.unshare.description"), slash: "unshare", - disabled: !params.id || !info()?.share?.url, + disabled: !input.params.id || !input.info()?.share?.url, onSelect: async () => { - if (!params.id) return - await sdk.client.session - .unshare({ sessionID: params.id }) + if (!input.params.id) return + await input.sdk.client.session + .unshare({ sessionID: input.params.id }) .then(() => showToast({ - title: language.t("toast.session.unshare.success.title"), - description: language.t("toast.session.unshare.success.description"), + title: input.language.t("toast.session.unshare.success.title"), + description: input.language.t("toast.session.unshare.success.description"), variant: "success", }), ) .catch(() => showToast({ - title: language.t("toast.session.unshare.failed.title"), - description: language.t("toast.session.unshare.failed.description"), + title: input.language.t("toast.session.unshare.failed.title"), + description: input.language.t("toast.session.unshare.failed.description"), variant: "error", }), ) @@ -484,8 +449,8 @@ export const useSessionCommands = (args: SessionCommandContext) => { ] }) - command.register("session", () => - [ + input.command.register("session", () => + combineCommandSections([ sessionCommands(), fileCommands(), contextCommands(), @@ -495,6 +460,6 @@ export const useSessionCommands = (args: SessionCommandContext) => { permissionCommands(), sessionActionCommands(), shareCommands(), - ].flatMap((section) => section), + ]), ) } diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx index a2607891c8a7..eb830e4a643e 100644 --- a/packages/enterprise/src/routes/share/[shareID].tsx +++ b/packages/enterprise/src/routes/share/[shareID].tsx @@ -224,7 +224,6 @@ export default function () { {iife(() => { const [store, setStore] = createStore({ messageId: undefined as string | undefined, - expandedSteps: {} as Record, }) const messages = createMemo(() => data().sessionID @@ -296,10 +295,7 @@ export default function () { {(message) => ( setStore("expandedSteps", message.id, (v) => !v)} classes={{ root: "min-w-0 w-full relative", content: "flex flex-col justify-between !overflow-visible", @@ -375,13 +371,6 @@ export default function () { { - const id = store.messageId ?? firstUserMessage()!.id! - setStore("expandedSteps", id, (v) => !v) - }} classes={{ root: "grow", content: "flex flex-col justify-between", diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 3f61b3186d30..062b05efcb0b 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -48,7 +48,6 @@ import { checksum } from "@opencode-ai/util/encode" import { Tooltip } from "./tooltip" import { IconButton } from "./icon-button" import { createAutoScroll } from "../hooks" -import { createResizeObserver } from "@solid-primitives/resize-observer" interface Diagnostic { range: { @@ -107,12 +106,6 @@ export const PART_MAPPING: Record = {} const TEXT_RENDER_THROTTLE_MS = 100 -function same(a: readonly T[], b: readonly T[]) { - if (a === b) return true - if (a.length !== b.length) return false - return a.every((x, i) => x === b[i]) -} - function createThrottledValue(getValue: () => string) { const [value, setValue] = createSignal(getValue()) let timeout: ReturnType | undefined @@ -289,39 +282,13 @@ export function Message(props: MessageProps) { } export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[] }) { - const emptyParts: PartType[] = [] - const filteredParts = createMemo( - () => - props.parts.filter((x) => { - return x.type !== "tool" || (x as ToolPart).tool !== "todoread" - }), - emptyParts, - { equals: same }, - ) - return {(part) => } + return {(part) => } } export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) { const dialog = useDialog() const i18n = useI18n() const [copied, setCopied] = createSignal(false) - const [expanded, setExpanded] = createSignal(false) - const [canExpand, setCanExpand] = createSignal(false) - let textRef: HTMLDivElement | undefined - - const updateCanExpand = () => { - const el = textRef - if (!el) return - if (expanded()) return - setCanExpand(el.scrollHeight > el.clientHeight + 2) - } - - createResizeObserver( - () => textRef, - () => { - updateCanExpand() - }, - ) const textPart = createMemo( () => props.parts?.find((p) => p.type === "text" && !(p as TextPart).synthetic) as TextPart | undefined, @@ -329,11 +296,6 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp const text = createMemo(() => textPart()?.text || "") - createEffect(() => { - text() - updateCanExpand() - }) - const files = createMemo(() => (props.parts?.filter((p) => p.type === "file") as FilePart[]) ?? []) const attachments = createMemo(() => @@ -364,13 +326,8 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp setTimeout(() => setCopied(false), 2000) } - const toggleExpanded = () => { - if (!canExpand()) return - setExpanded((value) => !value) - } - return ( -
+
0}>
@@ -404,19 +361,8 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
-
(textRef = el)} onClick={toggleExpanded}> +
-
* { - animation: fadeUp 0.4s ease-out forwards; - opacity: 0; - - &:nth-child(1) { - animation-delay: 0.1s; - } - - &:nth-child(2) { - animation-delay: 0.2s; - } - - &:nth-child(3) { - animation-delay: 0.3s; - } - - &:nth-child(4) { - animation-delay: 0.4s; - } - - &:nth-child(5) { - animation-delay: 0.5s; - } - - &:nth-child(6) { - animation-delay: 0.6s; - } - - &:nth-child(7) { - animation-delay: 0.7s; - } - - &:nth-child(8) { - animation-delay: 0.8s; - } - - &:nth-child(9) { - animation-delay: 0.9s; - } - - &:nth-child(10) { - animation-delay: 1s; - } - - &:nth-child(11) { - animation-delay: 1.1s; - } - - &:nth-child(12) { - animation-delay: 1.2s; - } - - &:nth-child(13) { - animation-delay: 1.3s; - } - - &:nth-child(14) { - animation-delay: 1.4s; - } - - &:nth-child(15) { - animation-delay: 1.5s; - } - - &:nth-child(16) { - animation-delay: 1.6s; - } - - &:nth-child(17) { - animation-delay: 1.7s; - } - - &:nth-child(18) { - animation-delay: 1.8s; - } - - &:nth-child(19) { - animation-delay: 1.9s; - } - - &:nth-child(20) { - animation-delay: 2s; - } - - &:nth-child(21) { - animation-delay: 2.1s; - } - - &:nth-child(22) { - animation-delay: 2.2s; - } - - &:nth-child(23) { - animation-delay: 2.3s; - } - - &:nth-child(24) { - animation-delay: 2.4s; - } - - &:nth-child(25) { - animation-delay: 2.5s; - } - - &:nth-child(26) { - animation-delay: 2.6s; - } - - &:nth-child(27) { - animation-delay: 2.7s; - } - - &:nth-child(28) { - animation-delay: 2.8s; - } - - &:nth-child(29) { - animation-delay: 2.9s; - } - - &:nth-child(30) { - animation-delay: 3s; - } - } - } - - [data-slot="session-turn-summary-section"] { - position: relative; - - [data-slot="session-turn-summary-copy"] { - position: absolute; - top: 0; - right: 0; - opacity: 0; - transition: opacity 0.15s ease; - } - - &:hover [data-slot="session-turn-summary-copy"] { - opacity: 1; - } - } - - [data-slot="session-turn-accordion"] { - width: 100%; - } - - [data-component="sticky-accordion-header"] { - top: var(--sticky-header-height, 0px); - } - - [data-component="sticky-accordion-header"][data-expanded]::before, - [data-slot="accordion-item"][data-expanded] [data-component="sticky-accordion-header"]::before { - top: calc(-1 * var(--sticky-header-height, 0px)); - } - - [data-slot="session-turn-accordion-trigger-content"] { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - gap: 20px; - - [data-expandable="false"] { - pointer-events: none; - } - } - - [data-slot="session-turn-file-info"] { - flex-grow: 1; - display: flex; - align-items: center; - gap: 20px; - min-width: 0; - } - - [data-slot="session-turn-file-icon"] { - flex-shrink: 0; - width: 16px; - height: 16px; - } - - [data-slot="session-turn-file-path"] { - display: flex; - flex-grow: 1; - min-width: 0; - } - - [data-slot="session-turn-directory"] { - color: var(--text-base); - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - direction: rtl; - text-align: left; - } - - [data-slot="session-turn-filename"] { - color: var(--text-strong); - flex-shrink: 0; - } - - [data-slot="session-turn-accordion-actions"] { - flex-shrink: 0; - display: flex; - gap: 16px; - align-items: center; - justify-content: flex-end; - } - - [data-slot="session-turn-accordion-content"] { - max-height: 240px; - /* max-h-60 */ - overflow-y: auto; - scrollbar-width: none; - } - - [data-slot="session-turn-accordion-content"]::-webkit-scrollbar { - display: none; - } - - [data-slot="session-turn-response-section"] { - 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"] { + [data-slot="session-turn-status-row"] { max-width: 100%; min-width: 0; display: flex; @@ -507,20 +43,11 @@ gap: 8px; color: var(--text-weak); - [data-slot="session-turn-trigger-icon"] { - color: var(--icon-base); - } - [data-component="spinner"] { width: 12px; height: 12px; margin-right: 4px; } - - [data-component="icon"] { - width: 14px; - height: 14px; - } } [data-slot="session-turn-retry-message"] { @@ -545,12 +72,6 @@ text-overflow: ellipsis; } - [data-slot="session-turn-details-text"] { - font-size: 13px; - /* text-12-medium */ - font-weight: 500; - } - .error-card { color: var(--text-on-critical-base); max-height: 240px; @@ -560,13 +81,7 @@ overflow-y: auto; } - .retry-error-link, - .error-card-link { - color: var(--text-strong); - text-decoration: underline; - } - - [data-slot="session-turn-collapsible-content-inner"] { + [data-slot="session-turn-assistant-content"] { width: 100%; min-width: 0; display: flex; @@ -582,28 +97,4 @@ margin-top: 0; } } - - [data-slot="session-turn-permission-parts"] { - width: 100%; - min-width: 0; - display: flex; - flex-direction: column; - gap: 12px; - } - - [data-slot="session-turn-question-parts"] { - width: 100%; - min-width: 0; - display: flex; - flex-direction: column; - gap: 12px; - } - - [data-slot="session-turn-answered-question-parts"] { - width: 100%; - min-width: 0; - display: flex; - flex-direction: column; - gap: 12px; - } } diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index c03622105916..3ce38b847311 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -1,29 +1,15 @@ -import { - AssistantMessage, - FilePart, - Message as MessageType, - Part as PartType, - type PermissionRequest, - type QuestionRequest, - TextPart, - ToolPart, -} from "@opencode-ai/sdk/v2/client" +import { AssistantMessage, Message as MessageType, Part as PartType, ToolPart } from "@opencode-ai/sdk/v2/client" import { useData } from "../context" import { type UiI18nKey, type UiI18nParams, useI18n } from "../context/i18n" import { Binary } from "@opencode-ai/util/binary" -import { createEffect, createMemo, createSignal, For, Match, on, onCleanup, ParentProps, Show, Switch } from "solid-js" +import { createEffect, createMemo, For, Match, onCleanup, ParentProps, Show, Switch } from "solid-js" import { Message, Part } from "./message-part" -import { Markdown } from "./markdown" -import { IconButton } from "./icon-button" import { Card } from "./card" -import { Button } from "./button" import { Spinner } from "./spinner" -import { Tooltip } from "./tooltip" import { createStore } from "solid-js/store" import { DateTime, DurationUnit, Interval } from "luxon" import { createAutoScroll } from "../hooks" -import { createResizeObserver } from "@solid-primitives/resize-observer" type Translator = (key: UiI18nKey, params?: UiI18nParams) => string @@ -125,72 +111,23 @@ function same(a: readonly T[], b: readonly T[]) { return a.every((x, i) => x === b[i]) } -function isAttachment(part: PartType | undefined) { - if (part?.type !== "file") return false - const mime = (part as FilePart).mime ?? "" - return mime.startsWith("image/") || mime === "application/pdf" -} - function list(value: T[] | undefined | null, fallback: T[]) { if (Array.isArray(value)) return value return fallback } -function AssistantMessageItem(props: { - message: AssistantMessage - responsePartId: string | undefined - hideResponsePart: boolean - hideReasoning: boolean - hidden?: () => readonly { messageID: string; callID: string }[] -}) { +function AssistantMessageItem(props: { message: AssistantMessage }) { const data = useData() const emptyParts: PartType[] = [] const msgParts = createMemo(() => list(data.store.part?.[props.message.id], emptyParts)) - const lastTextPart = createMemo(() => { - const parts = msgParts() - for (let i = parts.length - 1; i >= 0; i--) { - const part = parts[i] - if (part?.type === "text") return part as TextPart - } - return undefined - }) - - const filteredParts = createMemo(() => { - let parts = msgParts() - - if (props.hideReasoning) { - parts = parts.filter((part) => part?.type !== "reasoning") - } - - if (props.hideResponsePart) { - const responsePartId = props.responsePartId - if (responsePartId && responsePartId === lastTextPart()?.id) { - parts = parts.filter((part) => part?.id !== responsePartId) - } - } - - const hidden = props.hidden?.() ?? [] - if (hidden.length === 0) return parts - - const id = props.message.id - return parts.filter((part) => { - if (part?.type !== "tool") return true - const tool = part as ToolPart - return !hidden.some((h) => h.messageID === id && h.callID === tool.callID) - }) - }) - - return + return } export function SessionTurn( props: ParentProps<{ sessionID: string - sessionTitle?: string messageID: string lastUserMessageID?: string - stepsExpanded?: boolean - onStepsExpandedToggle?: () => void onUserInteracted?: () => void classes?: { root?: string @@ -204,11 +141,7 @@ export function SessionTurn( const emptyMessages: MessageType[] = [] const emptyParts: PartType[] = [] - const emptyFiles: FilePart[] = [] const emptyAssistant: AssistantMessage[] = [] - const emptyPermissions: PermissionRequest[] = [] - const emptyQuestions: QuestionRequest[] = [] - const emptyQuestionParts: { part: ToolPart; message: AssistantMessage }[] = [] const idle = { type: "idle" as const } const allMessages = createMemo(() => list(data.store.message?.[props.sessionID], emptyMessages)) @@ -256,19 +189,6 @@ export function SessionTurn( return list(data.store.part?.[msg.id], emptyParts) }) - const attachmentParts = createMemo(() => { - const msgParts = parts() - if (msgParts.length === 0) return emptyFiles - return msgParts.filter((part) => isAttachment(part)) as FilePart[] - }) - - const stickyParts = createMemo(() => { - const msgParts = parts() - if (msgParts.length === 0) return emptyParts - if (attachmentParts().length === 0) return msgParts - return msgParts.filter((part) => !isAttachment(part)) - }) - const assistantMessages = createMemo( () => { const msg = message() @@ -301,66 +221,6 @@ export function SessionTurn( return unwrap(String(msg)) }) - const lastTextPart = createMemo(() => { - const msgs = assistantMessages() - for (let mi = msgs.length - 1; mi >= 0; mi--) { - const msgParts = list(data.store.part?.[msgs[mi].id], emptyParts) - for (let pi = msgParts.length - 1; pi >= 0; pi--) { - const part = msgParts[pi] - if (part?.type === "text") return part as TextPart - } - } - return undefined - }) - - const hasSteps = createMemo(() => { - for (const m of assistantMessages()) { - const msgParts = list(data.store.part?.[m.id], emptyParts) - for (const p of msgParts) { - if (p?.type === "tool") return true - } - } - return false - }) - - const permissions = createMemo(() => list(data.store.permission?.[props.sessionID], emptyPermissions)) - const nextPermission = createMemo(() => permissions()[0]) - - const questions = createMemo(() => list(data.store.question?.[props.sessionID], emptyQuestions)) - const nextQuestion = createMemo(() => questions()[0]) - - const hidden = createMemo(() => { - const out: { messageID: string; callID: string }[] = [] - const perm = nextPermission() - if (perm?.tool) out.push(perm.tool) - const question = nextQuestion() - if (question?.tool) out.push(question.tool) - return out - }) - - const answeredQuestionParts = createMemo(() => { - if (props.stepsExpanded) return emptyQuestionParts - if (questions().length > 0) return emptyQuestionParts - - const result: { part: ToolPart; message: AssistantMessage }[] = [] - - for (const msg of assistantMessages()) { - const parts = list(data.store.part?.[msg.id], emptyParts) - for (const part of parts) { - if (part?.type !== "tool") continue - const tool = part as ToolPart - if (tool.tool !== "question") continue - // @ts-expect-error metadata may not exist on all tool states - const answers = tool.state?.metadata?.answers - if (answers && answers.length > 0) { - result.push({ part: tool, message: msg }) - } - } - } - - return result - }) - const shellModePart = createMemo(() => { const p = parts() if (p.length === 0) return @@ -436,36 +296,6 @@ export function SessionTurn( if (s.type !== "retry") return return s }) - const isRetryFreeUsageLimitError = createMemo(() => { - const r = retry() - if (!r) return false - return r.message.includes("Free usage exceeded") - }) - - const response = createMemo(() => lastTextPart()?.text) - const responsePartId = createMemo(() => lastTextPart()?.id) - const hasDiffs = createMemo(() => (message()?.summary?.diffs?.length ?? 0) > 0) - const hideResponsePart = createMemo(() => !working() && !!responsePartId()) - - const [copied, setCopied] = createSignal(false) - - const handleCopy = async () => { - const content = response() ?? "" - if (!content) return - await navigator.clipboard.writeText(content) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - } - - const [rootRef, setRootRef] = createSignal() - const [stickyRef, setStickyRef] = createSignal() - - const updateStickyHeight = (height: number) => { - const root = rootRef() - if (!root) return - const next = Math.ceil(height) - root.style.setProperty("--session-turn-sticky-height", `${next}px`) - } function duration() { const msg = message() @@ -492,24 +322,6 @@ export function SessionTurn( overflowAnchor: "auto", }) - createResizeObserver( - () => stickyRef(), - ({ height }) => { - updateStickyHeight(height) - }, - ) - - createEffect(() => { - const root = rootRef() - if (!root) return - const sticky = stickyRef() - if (!sticky) { - root.style.setProperty("--session-turn-sticky-height", "0px") - return - } - updateStickyHeight(sticky.getBoundingClientRect().height) - }) - const [store, setStore] = createStore({ retrySeconds: 0, status: rawStatus(), @@ -608,7 +420,7 @@ export function SessionTurn( }) return ( -
+
- 0}> -
- -
-
-
- {/* User Message */} -
- -
- - {/* Trigger (sticky) */} - -
- -
-
+
+
- {/* Response */} - 0}> -
- - {(assistantMessage) => ( - - - - {errorText()} - + +
+ + + + + + {(() => { + const r = retry() + if (!r) return "" + const msg = unwrap(r.message) + return msg.length > 60 ? msg.slice(0, 60) + "..." : msg + })()} + + + · {i18n.t("ui.sessionTurn.retry.retrying")} + {store.retrySeconds > 0 + ? " " + i18n.t("ui.sessionTurn.retry.inSeconds", { seconds: store.retrySeconds }) + : ""} + + (#{retry()?.attempt}) + + + + {store.status ?? i18n.t("ui.sessionTurn.status.consideringNextSteps")} + + + + + {store.duration}
- 0}> -
- - {({ part, message }) => } + 0}> +
+ + {(assistantMessage) => }
- {/* Response */} -
- {!working() && response() ? response() : ""} -
- -
-
-
-

{i18n.t("ui.sessionTurn.summary.response")}

- -
- - e.preventDefault()} - onClick={(event) => { - event.stopPropagation() - handleCopy() - }} - aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} - /> - -
-
-
-
- -
-
-
-
- + {errorText()} From efacb2dceb317d0d24af4ca9aa2ec86fa157e16d Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:32:15 -0600 Subject: [PATCH 002/121] wip(app): timeline changes --- .../src/pages/session/message-timeline.tsx | 3 +- packages/ui/src/components/markdown.css | 2 +- packages/ui/src/components/message-part.css | 57 ++++++--- packages/ui/src/components/message-part.tsx | 40 +++--- packages/ui/src/components/session-turn.css | 6 +- packages/ui/src/components/session-turn.tsx | 120 +++++++----------- 6 files changed, 113 insertions(+), 115 deletions(-) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index ce5181440739..22a21a37aa13 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -162,8 +162,9 @@ export function MessageTimeline(props: {
-
- +
+
+ +
e.preventDefault()} onClick={(event) => { event.stopPropagation() @@ -633,22 +635,22 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
-
- - e.preventDefault()} - onClick={handleCopy} - aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} - /> - -
+
+
+ + e.preventDefault()} + onClick={handleCopy} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} + /> +
diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index eda2f3b6d7a4..d4162a8ccc14 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -32,6 +32,8 @@ [data-slot="session-turn-message-content"] { margin-top: 0; + width: 100%; + min-width: 0; max-width: 100%; } @@ -88,10 +90,6 @@ flex-direction: column; align-self: stretch; gap: 12px; - margin-left: 12px; - padding-left: 12px; - padding-right: 12px; - border-left: 1px solid var(--border-base); > :first-child > [data-component="markdown"]:first-child { margin-top: 0; diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 3ce38b847311..646fedae5444 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -4,7 +4,7 @@ import { type UiI18nKey, type UiI18nParams, useI18n } from "../context/i18n" import { Binary } from "@opencode-ai/util/binary" import { createEffect, createMemo, For, Match, onCleanup, ParentProps, Show, Switch } from "solid-js" -import { Message, Part } from "./message-part" +import { Message } from "./message-part" import { Card } from "./card" import { Spinner } from "./spinner" import { createStore } from "solid-js/store" @@ -221,23 +221,6 @@ export function SessionTurn( return unwrap(String(msg)) }) - const shellModePart = createMemo(() => { - const p = parts() - if (p.length === 0) return - if (!p.every((part) => part?.type === "text" && part?.synthetic)) return - - const msgs = assistantMessages() - if (msgs.length !== 1) return - - const msgParts = list(data.store.part?.[msgs[0].id], emptyParts) - if (msgParts.length !== 1) return - - const assistantPart = msgParts[0] - if (assistantPart?.type === "tool" && assistantPart.tool === "bash") return assistantPart - }) - - const isShellMode = createMemo(() => !!shellModePart()) - const rawStatus = createMemo(() => { const msgs = assistantMessages() let last: PartType | undefined @@ -436,61 +419,54 @@ export function SessionTurn( data-slot="session-turn-message-container" class={props.classes?.container} > - - - - - -
- -
- -
- - - - - - - {(() => { - const r = retry() - if (!r) return "" - const msg = unwrap(r.message) - return msg.length > 60 ? msg.slice(0, 60) + "..." : msg - })()} - - - · {i18n.t("ui.sessionTurn.retry.retrying")} - {store.retrySeconds > 0 - ? " " + i18n.t("ui.sessionTurn.retry.inSeconds", { seconds: store.retrySeconds }) - : ""} - - (#{retry()?.attempt}) - - - - {store.status ?? i18n.t("ui.sessionTurn.status.consideringNextSteps")} - - - - - {store.duration} -
-
- 0}> -
- - {(assistantMessage) => } - -
-
- - - {errorText()} - +
+ +
+ +
+ + - - + + + + {(() => { + const r = retry() + if (!r) return "" + const msg = unwrap(r.message) + return msg.length > 60 ? msg.slice(0, 60) + "..." : msg + })()} + + + · {i18n.t("ui.sessionTurn.retry.retrying")} + {store.retrySeconds > 0 + ? " " + i18n.t("ui.sessionTurn.retry.inSeconds", { seconds: store.retrySeconds }) + : ""} + + (#{retry()?.attempt}) + + + + {store.status ?? i18n.t("ui.sessionTurn.status.consideringNextSteps")} + + + + + {store.duration} +
+
+ 0}> +
+ + {(assistantMessage) => } + +
+
+ + + {errorText()} + +
)} From 0a1ee03134df8754c982865452743bc7cd92ded8 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:17:04 -0600 Subject: [PATCH 003/121] wip(app): timeline changes --- packages/ui/src/components/basic-tool.css | 14 ++ packages/ui/src/components/basic-tool.tsx | 13 +- packages/ui/src/components/message-part.css | 65 ++++- packages/ui/src/components/message-part.tsx | 137 ++++++++-- packages/ui/src/components/session-turn.css | 36 +-- packages/ui/src/components/session-turn.tsx | 264 +------------------- 6 files changed, 225 insertions(+), 304 deletions(-) diff --git a/packages/ui/src/components/basic-tool.css b/packages/ui/src/components/basic-tool.css index 2c6bfeb6728c..18db1cf1c3c5 100644 --- a/packages/ui/src/components/basic-tool.css +++ b/packages/ui/src/components/basic-tool.css @@ -15,6 +15,20 @@ gap: 20px; } + [data-slot="basic-tool-tool-indicator"] { + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + [data-component="spinner"] { + width: 16px; + height: 16px; + } + } + [data-slot="icon-svg"] { flex-shrink: 0; } diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index 725a7d0d6e51..abe1734053f8 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -1,6 +1,7 @@ import { createEffect, createSignal, For, Match, Show, Switch, type JSX } from "solid-js" import { Collapsible } from "./collapsible" import { Icon, IconProps } from "./icon" +import { Spinner } from "./spinner" export type TriggerTitle = { title: string @@ -22,6 +23,7 @@ export interface BasicToolProps { icon: IconProps["name"] trigger: TriggerTitle | JSX.Element children?: JSX.Element + status?: string hideDetails?: boolean defaultOpen?: boolean forceOpen?: boolean @@ -31,6 +33,7 @@ export interface BasicToolProps { export function BasicTool(props: BasicToolProps) { const [open, setOpen] = createSignal(props.defaultOpen ?? false) + const pending = () => props.status === "pending" || props.status === "running" createEffect(() => { if (props.forceOpen) setOpen(true) @@ -46,7 +49,11 @@ export function BasicTool(props: BasicToolProps) {
- +
+ }> + + +
@@ -113,6 +120,6 @@ export function BasicTool(props: BasicToolProps) { ) } -export function GenericTool(props: { tool: string; hideDetails?: boolean }) { - return +export function GenericTool(props: { tool: string; status?: string; hideDetails?: boolean }) { + return } diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 6982646494a0..b1000890d783 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -21,7 +21,7 @@ align-self: flex-end; margin-left: auto; width: fit-content; - max-width: min(90%, 72ch); + max-width: min(82%, 64ch); gap: 8px; [data-slot="user-message-attachments"] { @@ -396,6 +396,20 @@ } } + [data-slot="task-tool-indicator"] { + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + [data-component="spinner"] { + width: 16px; + height: 16px; + } + } + [data-slot="task-tool-title"] { font-family: var(--font-family-sans); font-size: var(--font-size-small); @@ -416,6 +430,55 @@ } } +[data-component="context-tool-group-trigger"] { + width: 100%; + min-height: 24px; + display: flex; + align-items: center; + cursor: pointer; + + [data-slot="context-tool-group-title"] { + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + color: var(--text-weak); + } +} + +[data-component="context-tool-group-list"] { + padding: 6px 0 4px 0; + display: flex; + flex-direction: column; + gap: 8px; + + [data-slot="context-tool-group-item"] { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + } + + [data-slot="context-tool-group-item-title"] { + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + color: var(--text-base); + } + + [data-slot="context-tool-group-item-detail"] { + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); + color: var(--text-weaker); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + [data-component="diagnostics"] { display: flex; flex-direction: column; diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index cb60fd43e104..3a4a7a449a40 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -37,6 +37,7 @@ import { BasicTool } from "./basic-tool" import { GenericTool } from "./basic-tool" import { Button } from "./button" import { Card } from "./card" +import { Collapsible } from "./collapsible" import { Icon } from "./icon" import { Checkbox } from "./checkbox" import { DiffChanges } from "./diff-changes" @@ -47,6 +48,7 @@ import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/pa import { checksum } from "@opencode-ai/util/encode" import { Tooltip } from "./tooltip" import { IconButton } from "./icon-button" +import { Spinner } from "./spinner" import { createAutoScroll } from "../hooks" interface Diagnostic { @@ -262,6 +264,23 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo { } } +const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"]) + +function isContextGroupTool(part: PartType): part is ToolPart { + return part.type === "tool" && CONTEXT_GROUP_TOOLS.has(part.tool) +} + +function contextToolDetail(part: ToolPart): string | undefined { + const info = getToolInfo(part.tool, part.state.input ?? {}) + if (info.subtitle) return info.subtitle + if (part.state.status === "error") return part.state.error + if ((part.state.status === "running" || part.state.status === "completed") && part.state.title) + return part.state.title + const description = part.state.input?.description + if (typeof description === "string") return description + return undefined +} + export function registerPartComponent(type: string, component: PartComponent) { PART_MAPPING[type] = component } @@ -282,7 +301,67 @@ export function Message(props: MessageProps) { } export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[] }) { - return {(part) => } + const grouped = createMemo(() => + props.parts.reduce<({ type: "part"; part: PartType } | { type: "context"; parts: ToolPart[] })[]>((acc, part) => { + if (!isContextGroupTool(part)) { + acc.push({ type: "part", part }) + return acc + } + + const last = acc[acc.length - 1] + if (last && last.type === "context") { + last.parts.push(part) + return acc + } + + acc.push({ type: "context", parts: [part] }) + return acc + }, []), + ) + + return ( + + {(item) => { + if (item.type === "context") return + return + }} + + ) +} + +function ContextToolGroup(props: { parts: ToolPart[] }) { + const [open, setOpen] = createSignal(false) + const pending = createMemo(() => + props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"), + ) + + return ( + + +
+ {pending() ? "Gathering context" : "Gathered context"} +
+
+ +
+ + {(part) => { + const info = getToolInfo(part.tool, part.state.input ?? {}) + const detail = contextToolDetail(part) + return ( +
+
{info.title}
+ +
{detail}
+
+
+ ) + }} +
+
+
+
+ ) } export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) { @@ -620,6 +699,12 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { const part = props.part as TextPart const displayText = () => relativizeProjectPaths((part.text ?? "").trim(), data.directory) const throttledText = createThrottledValue(displayText) + const isLastTextPart = createMemo(() => { + const last = (data.store.part?.[props.message.id] ?? []) + .filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim()) + .at(-1) + return last?.id === part.id + }) const [copied, setCopied] = createSignal(false) const handleCopy = async () => { @@ -636,22 +721,24 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
-
- - e.preventDefault()} - onClick={handleCopy} - aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} - /> - -
+ +
+ + e.preventDefault()} + onClick={handleCopy} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} + /> + +
+
) @@ -967,7 +1054,10 @@ ToolRegistry.register({ <> - }> + } + > {renderChildToolPart()}
@@ -986,7 +1076,7 @@ ToolRegistry.register({ - +
- +
+ } + > + + +
{info().title} {subtitle()} diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index d4162a8ccc14..1f49899690d0 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -37,43 +37,23 @@ max-width: 100%; } - [data-slot="session-turn-status-row"] { - max-width: 100%; - min-width: 0; + [data-slot="session-turn-thinking"] { display: flex; align-items: center; gap: 8px; color: var(--text-weak); + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + min-height: 20px; [data-component="spinner"] { - width: 12px; - height: 12px; - margin-right: 4px; + width: 16px; + height: 16px; } } - [data-slot="session-turn-retry-message"] { - font-weight: 500; - color: var(--syntax-critical); - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - [data-slot="session-turn-retry-seconds"] { - color: var(--text-weak); - } - - [data-slot="session-turn-retry-attempt"] { - color: var(--text-weak); - } - - [data-slot="session-turn-status-text"] { - overflow: hidden; - text-overflow: ellipsis; - } - .error-card { color: var(--text-on-critical-base); max-height: 240px; diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 646fedae5444..ee7d3a2585d2 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -1,18 +1,13 @@ -import { AssistantMessage, Message as MessageType, Part as PartType, ToolPart } from "@opencode-ai/sdk/v2/client" +import { AssistantMessage, Message as MessageType, Part as PartType } from "@opencode-ai/sdk/v2/client" import { useData } from "../context" -import { type UiI18nKey, type UiI18nParams, useI18n } from "../context/i18n" import { Binary } from "@opencode-ai/util/binary" -import { createEffect, createMemo, For, Match, onCleanup, ParentProps, Show, Switch } from "solid-js" +import { createMemo, For, ParentProps, Show } from "solid-js" import { Message } from "./message-part" import { Card } from "./card" import { Spinner } from "./spinner" -import { createStore } from "solid-js/store" -import { DateTime, DurationUnit, Interval } from "luxon" import { createAutoScroll } from "../hooks" -type Translator = (key: UiI18nKey, params?: UiI18nParams) => string - function record(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value) } @@ -66,45 +61,6 @@ function unwrap(message: string) { return message } -function computeStatusFromPart(part: PartType | undefined, t: Translator): string | undefined { - if (!part) return undefined - - if (part.type === "tool") { - switch (part.tool) { - case "task": - return t("ui.sessionTurn.status.delegating") - case "todowrite": - case "todoread": - return t("ui.sessionTurn.status.planning") - case "read": - return t("ui.sessionTurn.status.gatheringContext") - case "list": - case "grep": - case "glob": - return t("ui.sessionTurn.status.searchingCodebase") - case "webfetch": - return t("ui.sessionTurn.status.searchingWeb") - case "edit": - case "write": - return t("ui.sessionTurn.status.makingEdits") - case "bash": - return t("ui.sessionTurn.status.runningCommands") - default: - return undefined - } - } - if (part.type === "reasoning") { - const text = part.text ?? "" - const match = text.trimStart().match(/^\*\*(.+?)\*\*/) - if (match) return t("ui.sessionTurn.status.thinkingWithTopic", { topic: match[1].trim() }) - return t("ui.sessionTurn.status.thinking") - } - if (part.type === "text") { - return t("ui.sessionTurn.status.gatheringThoughts") - } - return undefined -} - function same(a: readonly T[], b: readonly T[]) { if (a === b) return true if (a.length !== b.length) return false @@ -136,7 +92,6 @@ export function SessionTurn( } }>, ) { - const i18n = useI18n() const data = useData() const emptyMessages: MessageType[] = [] @@ -211,8 +166,6 @@ export function SessionTurn( { equals: same }, ) - const lastAssistantMessage = createMemo(() => assistantMessages().at(-1)) - const error = createMemo(() => assistantMessages().find((m) => m.error)?.error) const errorText = createMemo(() => { const msg = error()?.data?.message @@ -221,83 +174,14 @@ export function SessionTurn( return unwrap(String(msg)) }) - const rawStatus = createMemo(() => { - const msgs = assistantMessages() - let last: PartType | undefined - let currentTask: ToolPart | undefined - - for (let mi = msgs.length - 1; mi >= 0; mi--) { - const msgParts = list(data.store.part?.[msgs[mi].id], emptyParts) - for (let pi = msgParts.length - 1; pi >= 0; pi--) { - const part = msgParts[pi] - if (!part) continue - if (!last) last = part - - if ( - part.type === "tool" && - part.tool === "task" && - part.state && - "metadata" in part.state && - part.state.metadata?.sessionId && - part.state.status === "running" - ) { - currentTask = part as ToolPart - break - } - } - if (currentTask) break - } - - const taskSessionId = - currentTask?.state && "metadata" in currentTask.state - ? (currentTask.state.metadata?.sessionId as string | undefined) - : undefined - - if (taskSessionId) { - const taskMessages = list(data.store.message?.[taskSessionId], emptyMessages) - for (let mi = taskMessages.length - 1; mi >= 0; mi--) { - const msg = taskMessages[mi] - if (!msg || msg.role !== "assistant") continue - - const msgParts = list(data.store.part?.[msg.id], emptyParts) - for (let pi = msgParts.length - 1; pi >= 0; pi--) { - const part = msgParts[pi] - if (part) return computeStatusFromPart(part, i18n.t) - } - } - } - - return computeStatusFromPart(last, i18n.t) - }) - const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle) const working = createMemo(() => status().type !== "idle" && isLastUserMessage()) - const retry = createMemo(() => { - // session_status is session-scoped; only show retry on the active (last) turn - if (!isLastUserMessage()) return - const s = status() - if (s.type !== "retry") return - return s - }) - - function duration() { - const msg = message() - if (!msg) return "" - const completed = lastAssistantMessage()?.time.completed - const from = DateTime.fromMillis(msg.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"] - - const locale = i18n.locale() - const human = interval.toDuration(unit).normalize().reconfigure({ locale }).toHuman({ - notation: "compact", - unitDisplay: "narrow", - compactDisplay: "short", - showZeros: false, - }) - return locale.startsWith("zh") ? human.replaceAll("、", "") : human - } + const assistantPartCount = createMemo(() => + assistantMessages().reduce((count, message) => { + const parts = list(data.store.part?.[message.id], emptyParts) + return count + parts.filter(Boolean).length + }, 0), + ) const autoScroll = createAutoScroll({ working, @@ -305,103 +189,6 @@ export function SessionTurn( overflowAnchor: "auto", }) - const [store, setStore] = createStore({ - retrySeconds: 0, - status: rawStatus(), - duration: duration(), - }) - - createEffect(() => { - const r = retry() - if (!r) { - setStore("retrySeconds", 0) - return - } - const updateSeconds = () => { - const next = r.next - if (next) setStore("retrySeconds", Math.max(0, Math.round((next - Date.now()) / 1000))) - } - updateSeconds() - const timer = setInterval(updateSeconds, 1000) - onCleanup(() => clearInterval(timer)) - }) - - let retryLog = "" - createEffect(() => { - const r = retry() - if (!r) return - const key = `${r.attempt}:${r.next}:${r.message}` - if (key === retryLog) return - retryLog = key - console.warn("[session-turn] retry", { - sessionID: props.sessionID, - messageID: props.messageID, - attempt: r.attempt, - next: r.next, - raw: r.message, - parsed: unwrap(r.message), - }) - }) - - let errorLog = "" - createEffect(() => { - const value = error()?.data?.message - if (value === undefined || value === null) return - const raw = typeof value === "string" ? value : String(value) - if (!raw) return - if (raw === errorLog) return - errorLog = raw - console.warn("[session-turn] assistant-error", { - sessionID: props.sessionID, - messageID: props.messageID, - raw, - parsed: unwrap(raw), - }) - }) - - createEffect(() => { - const update = () => { - setStore("duration", duration()) - } - - update() - - // Only keep ticking while the active (in-progress) turn is running. - if (!working()) return - - const timer = setInterval(update, 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 - } - }) - - onCleanup(() => { - if (!statusTimeout) return - clearTimeout(statusTimeout) - }) - return (
- -
- - - - - - - {(() => { - const r = retry() - if (!r) return "" - const msg = unwrap(r.message) - return msg.length > 60 ? msg.slice(0, 60) + "..." : msg - })()} - - - · {i18n.t("ui.sessionTurn.retry.retrying")} - {store.retrySeconds > 0 - ? " " + i18n.t("ui.sessionTurn.retry.inSeconds", { seconds: store.retrySeconds }) - : ""} - - (#{retry()?.attempt}) - - - - {store.status ?? i18n.t("ui.sessionTurn.status.consideringNextSteps")} - - - - - {store.duration} + +
+ + Thinking
0}> From e34d609df78d1758483579703cd4b11c6a9fd522 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:39:30 -0600 Subject: [PATCH 004/121] wip(app): timeline changes --- packages/ui/src/components/message-part.css | 41 ++++---- packages/ui/src/components/message-part.tsx | 111 ++++++++++++++++++-- packages/ui/src/components/session-turn.tsx | 30 +++++- 3 files changed, 145 insertions(+), 37 deletions(-) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index b1000890d783..96f879b3a698 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -435,47 +435,42 @@ min-height: 24px; display: flex; align-items: center; + justify-content: space-between; + gap: 8px; cursor: pointer; [data-slot="context-tool-group-title"] { + min-width: 0; font-family: var(--font-family-sans); font-size: var(--font-size-small); font-weight: var(--font-weight-medium); line-height: var(--line-height-large); color: var(--text-weak); } + + [data-slot="collapsible-arrow"] { + opacity: 0; + color: var(--icon-weaker); + transition: opacity 0.15s ease; + } +} + +[data-slot="collapsible-trigger"]:hover [data-component="context-tool-group-trigger"] [data-slot="collapsible-arrow"], +[data-slot="collapsible-trigger"]:focus-visible + [data-component="context-tool-group-trigger"] + [data-slot="collapsible-arrow"] { + opacity: 1; } [data-component="context-tool-group-list"] { padding: 6px 0 4px 0; display: flex; flex-direction: column; - gap: 8px; + gap: 2px; [data-slot="context-tool-group-item"] { min-width: 0; - display: flex; - flex-direction: column; - gap: 2px; - } - - [data-slot="context-tool-group-item-title"] { - font-family: var(--font-family-sans); - font-size: var(--font-size-small); - font-weight: var(--font-weight-medium); - line-height: var(--line-height-large); - color: var(--text-base); - } - - [data-slot="context-tool-group-item-detail"] { - font-family: var(--font-family-sans); - font-size: var(--font-size-small); - font-weight: var(--font-weight-regular); - line-height: var(--line-height-large); - color: var(--text-weaker); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + padding: 6px 8px 6px 12px; } } diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 3a4a7a449a40..8ecab52a3d40 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -93,6 +93,7 @@ function DiagnosticsDisplay(props: { diagnostics: Diagnostic[] }): JSX.Element { export interface MessageProps { message: MessageType parts: PartType[] + showAssistantCopyPartID?: string } export interface MessagePartProps { @@ -100,6 +101,7 @@ export interface MessagePartProps { message: MessageType hideDetails?: boolean defaultOpen?: boolean + showAssistantCopyPartID?: string } export type PartComponent = Component @@ -281,6 +283,58 @@ function contextToolDetail(part: ToolPart): string | undefined { return undefined } +function contextToolTrigger(part: ToolPart, i18n: ReturnType) { + const input = (part.state.input ?? {}) as Record + const path = typeof input.path === "string" ? input.path : "/" + const filePath = typeof input.filePath === "string" ? input.filePath : undefined + const pattern = typeof input.pattern === "string" ? input.pattern : undefined + const include = typeof input.include === "string" ? input.include : undefined + const offset = typeof input.offset === "number" ? input.offset : undefined + const limit = typeof input.limit === "number" ? input.limit : undefined + + switch (part.tool) { + case "read": { + const args: string[] = [] + if (offset !== undefined) args.push("offset=" + offset) + if (limit !== undefined) args.push("limit=" + limit) + return { + title: i18n.t("ui.tool.read"), + subtitle: filePath ? getFilename(filePath) : "", + args, + } + } + case "list": + return { + title: i18n.t("ui.tool.list"), + subtitle: getDirectory(path), + } + case "glob": + return { + title: i18n.t("ui.tool.glob"), + subtitle: getDirectory(path), + args: pattern ? ["pattern=" + pattern] : [], + } + case "grep": { + const args: string[] = [] + if (pattern) args.push("pattern=" + pattern) + if (include) args.push("include=" + include) + return { + title: i18n.t("ui.tool.grep"), + subtitle: getDirectory(path), + args, + } + } + default: { + const info = getToolInfo(part.tool, input) + return { + title: info.title, + subtitle: info.subtitle || contextToolDetail(part), + args: [], + } + } + } +} + export function registerPartComponent(type: string, component: PartComponent) { PART_MAPPING[type] = component } @@ -293,14 +347,22 @@ export function Message(props: MessageProps) { {(assistantMessage) => ( - + )} ) } -export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[] }) { +export function AssistantMessageDisplay(props: { + message: AssistantMessage + parts: PartType[] + showAssistantCopyPartID?: string +}) { const grouped = createMemo(() => props.parts.reduce<({ type: "part"; part: PartType } | { type: "context"; parts: ToolPart[] })[]>((acc, part) => { if (!isContextGroupTool(part)) { @@ -323,13 +385,14 @@ export function AssistantMessageDisplay(props: { message: AssistantMessage; part {(item) => { if (item.type === "context") return - return + return }} ) } function ContextToolGroup(props: { parts: ToolPart[] }) { + const i18n = useI18n() const [open, setOpen] = createSignal(false) const pending = createMemo(() => props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"), @@ -340,6 +403,7 @@ function ContextToolGroup(props: { parts: ToolPart[] }) {
{pending() ? "Gathering context" : "Gathered context"} +
@@ -347,13 +411,34 @@ function ContextToolGroup(props: { parts: ToolPart[] }) { {(part) => { const info = getToolInfo(part.tool, part.state.input ?? {}) - const detail = contextToolDetail(part) + const trigger = contextToolTrigger(part, i18n) + const running = part.state.status === "pending" || part.state.status === "running" return (
-
{info.title}
- -
{detail}
-
+
+
+
+ }> + + +
+
+
+
+ {trigger.title} + + {trigger.subtitle} + + + + {(arg) => {arg}} + + +
+
+
+
+
) }} @@ -518,6 +603,7 @@ export function Part(props: MessagePartProps) { message={props.message} hideDetails={props.hideDetails} defaultOpen={props.defaultOpen} + showAssistantCopyPartID={props.showAssistantCopyPartID} />
) @@ -705,6 +791,11 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { .at(-1) return last?.id === part.id }) + const showCopy = createMemo(() => { + if (props.message.role !== "assistant") return isLastTextPart() + if (props.showAssistantCopyPartID) return props.showAssistantCopyPartID === part.id + return isLastTextPart() + }) const [copied, setCopied] = createSignal(false) const handleCopy = async () => { @@ -721,7 +812,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
- +
true, - overflowAnchor: "auto", + overflowAnchor: "dynamic", }) const childPermission = createMemo(() => { diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index ee7d3a2585d2..8e2bd3436f91 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -72,11 +72,11 @@ function list(value: T[] | undefined | null, fallback: T[]) { return fallback } -function AssistantMessageItem(props: { message: AssistantMessage }) { +function AssistantMessageItem(props: { message: AssistantMessage; showAssistantCopyPartID?: string }) { const data = useData() const emptyParts: PartType[] = [] const msgParts = createMemo(() => list(data.store.part?.[props.message.id], emptyParts)) - return + return } export function SessionTurn( @@ -167,6 +167,23 @@ export function SessionTurn( ) const error = createMemo(() => assistantMessages().find((m) => m.error)?.error) + const showAssistantCopyPartID = createMemo(() => { + const messages = assistantMessages() + + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i] + if (!message) continue + + const parts = list(data.store.part?.[message.id], emptyParts) + for (let j = parts.length - 1; j >= 0; j--) { + const part = parts[j] + if (!part || part.type !== "text" || !part.text?.trim()) continue + return part.id + } + } + + return undefined + }) const errorText = createMemo(() => { const msg = error()?.data?.message if (typeof msg === "string") return unwrap(msg) @@ -186,7 +203,7 @@ export function SessionTurn( const autoScroll = createAutoScroll({ working, onUserInteracted: props.onUserInteracted, - overflowAnchor: "auto", + overflowAnchor: "dynamic", }) return ( @@ -218,7 +235,12 @@ export function SessionTurn( 0}>
- {(assistantMessage) => } + {(assistantMessage) => ( + + )}
From f1930f52321747f3db6c7ca9be54516d0ac4f8d3 Mon Sep 17 00:00:00 2001 From: David Hill Date: Thu, 12 Feb 2026 14:02:20 +0000 Subject: [PATCH 005/121] tweak(ui): adjust user message bubble styling --- packages/ui/src/components/message-part.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 96f879b3a698..99924e39d2db 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -87,10 +87,10 @@ white-space: pre-wrap; word-break: break-word; overflow: hidden; - background: var(--surface-weak); + background: var(--surface-base); border: 1px solid var(--border-weak-base); padding: 8px 12px; - border-radius: 4px; + border-radius: 6px; [data-highlight="file"] { color: var(--syntax-property); From 8855d5940db352dbd7a87c436a1422f0af1dcb35 Mon Sep 17 00:00:00 2001 From: David Hill Date: Thu, 12 Feb 2026 14:04:25 +0000 Subject: [PATCH 006/121] tweak(ui): refine copy tooltip text and spacing --- packages/ui/src/components/message-part.tsx | 2 +- packages/ui/src/i18n/en.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 8ecab52a3d40..5c18f118034e 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -533,7 +533,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp Date: Thu, 12 Feb 2026 14:37:19 +0000 Subject: [PATCH 007/121] tweak(ui): flatten tool and collapsible UI --- packages/ui/src/components/basic-tool.css | 18 +++++----- packages/ui/src/components/basic-tool.tsx | 8 ++--- packages/ui/src/components/collapsible.css | 37 +++++++++++++++++++-- packages/ui/src/components/collapsible.tsx | 7 +++- packages/ui/src/components/message-part.css | 21 ++++-------- packages/ui/src/components/message-part.tsx | 11 +++--- 6 files changed, 63 insertions(+), 39 deletions(-) diff --git a/packages/ui/src/components/basic-tool.css b/packages/ui/src/components/basic-tool.css index 18db1cf1c3c5..5e2d38bcc7ee 100644 --- a/packages/ui/src/components/basic-tool.css +++ b/packages/ui/src/components/basic-tool.css @@ -4,11 +4,11 @@ display: flex; align-items: center; align-self: stretch; - gap: 20px; - justify-content: space-between; + gap: 8px; + justify-content: flex-start; [data-slot="basic-tool-tool-trigger-content"] { - width: 100%; + width: auto; display: flex; align-items: center; align-self: stretch; @@ -34,16 +34,16 @@ } [data-slot="basic-tool-tool-info"] { - flex-grow: 1; + flex: 0 1 auto; min-width: 0; } [data-slot="basic-tool-tool-info-structured"] { - width: 100%; + width: auto; display: flex; align-items: center; gap: 8px; - justify-content: space-between; + justify-content: flex-start; } [data-slot="basic-tool-tool-info-main"] { @@ -57,7 +57,7 @@ [data-slot="basic-tool-tool-title"] { flex-shrink: 0; font-family: var(--font-family-sans); - font-size: var(--font-size-small); + font-size: 14px; font-style: normal; font-weight: var(--font-weight-medium); line-height: var(--line-height-large); @@ -76,7 +76,7 @@ text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-family-sans); - font-size: var(--font-size-small); + font-size: 14px; font-style: normal; font-weight: var(--font-weight-medium); line-height: var(--line-height-large); @@ -101,7 +101,7 @@ text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-family-sans); - font-size: var(--font-size-small); + font-size: 14px; font-style: normal; font-weight: var(--font-weight-regular); line-height: var(--line-height-large); diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index abe1734053f8..807292918f2f 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -49,11 +49,11 @@ export function BasicTool(props: BasicToolProps) {
-
- }> + +
- -
+
+
diff --git a/packages/ui/src/components/collapsible.css b/packages/ui/src/components/collapsible.css index f23746bf15da..720297eb5142 100644 --- a/packages/ui/src/components/collapsible.css +++ b/packages/ui/src/components/collapsible.css @@ -2,8 +2,8 @@ width: 100%; display: flex; flex-direction: column; - background-color: var(--surface-inset-base); - border: 1px solid var(--border-weaker-base); + background-color: transparent; + border: none; transition: background-color 0.15s ease; border-radius: var(--radius-md); overflow: clip; @@ -12,13 +12,30 @@ width: 100%; display: flex; height: 32px; - padding: 6px 8px 6px 12px; + padding: 0; align-items: center; align-self: stretch; cursor: default; user-select: none; color: var(--text-base); + [data-slot="collapsible-arrow"] { + opacity: 0; + transition: opacity 0.15s ease; + } + + [data-slot="collapsible-arrow-icon"] { + display: none; + } + + [data-slot="collapsible-arrow-icon"][data-direction="right"] { + display: inline-flex; + } + + &:hover [data-slot="collapsible-arrow"] { + opacity: 1; + } + /* text-12-medium */ font-family: var(--font-family-sans); font-size: var(--font-size-small); @@ -48,6 +65,20 @@ } } + [data-slot="collapsible-trigger"][aria-expanded="true"] { + [data-slot="collapsible-arrow"] { + opacity: 1; + } + + [data-slot="collapsible-arrow-icon"][data-direction="right"] { + display: none; + } + + [data-slot="collapsible-arrow-icon"][data-direction="down"] { + display: inline-flex; + } + } + [data-slot="collapsible-content"] { overflow: hidden; /* animation: slideUp 250ms ease-out; */ diff --git a/packages/ui/src/components/collapsible.tsx b/packages/ui/src/components/collapsible.tsx index 903afc3085ee..548088287871 100644 --- a/packages/ui/src/components/collapsible.tsx +++ b/packages/ui/src/components/collapsible.tsx @@ -34,7 +34,12 @@ function CollapsibleContent(props: ComponentProps) { function CollapsibleArrow(props?: ComponentProps<"div">) { return (
- + + + + + +
) } diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 99924e39d2db..ea58c360bb10 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -167,7 +167,7 @@ [data-component="markdown"] { margin-top: 24px; - font-style: italic !important; + font-style: normal; p:has(strong) { margin-top: 24px; @@ -217,7 +217,7 @@ [data-component="tool-output"] { white-space: pre; - padding: 8px 12px; + padding: 0; height: fit-content; display: flex; flex-direction: column; @@ -379,7 +379,7 @@ } [data-component="task-tools"] { - padding: 8px 12px; + padding: 0; display: flex; flex-direction: column; gap: 6px; @@ -412,7 +412,7 @@ [data-slot="task-tool-title"] { font-family: var(--font-family-sans); - font-size: var(--font-size-small); + font-size: 14px; font-weight: var(--font-weight-medium); line-height: var(--line-height-large); color: var(--text-weak); @@ -420,7 +420,7 @@ [data-slot="task-tool-subtitle"] { font-family: var(--font-family-sans); - font-size: var(--font-size-small); + font-size: 14px; font-weight: var(--font-weight-regular); line-height: var(--line-height-large); color: var(--text-weaker); @@ -435,7 +435,7 @@ min-height: 24px; display: flex; align-items: center; - justify-content: space-between; + justify-content: flex-start; gap: 8px; cursor: pointer; @@ -449,19 +449,10 @@ } [data-slot="collapsible-arrow"] { - opacity: 0; color: var(--icon-weaker); - transition: opacity 0.15s ease; } } -[data-slot="collapsible-trigger"]:hover [data-component="context-tool-group-trigger"] [data-slot="collapsible-arrow"], -[data-slot="collapsible-trigger"]:focus-visible - [data-component="context-tool-group-trigger"] - [data-slot="collapsible-arrow"] { - opacity: 1; -} - [data-component="context-tool-group-list"] { padding: 6px 0 4px 0; display: flex; diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 5c18f118034e..971f83960e86 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1186,14 +1186,11 @@ ToolRegistry.register({ }) return (
-
- } - > + +
- -
+
+ {info().title} {subtitle()} From 93213166a61f5f676c5764d7b52f1752b3e24290 Mon Sep 17 00:00:00 2001 From: David Hill Date: Thu, 12 Feb 2026 14:42:30 +0000 Subject: [PATCH 008/121] tweak(ui): style agent title and link --- packages/ui/src/components/basic-tool.css | 23 +++++++++++++++++++++ packages/ui/src/components/message-part.tsx | 9 ++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/basic-tool.css b/packages/ui/src/components/basic-tool.css index 5e2d38bcc7ee..5d7ebd0afebd 100644 --- a/packages/ui/src/components/basic-tool.css +++ b/packages/ui/src/components/basic-tool.css @@ -67,6 +67,10 @@ &.capitalize { text-transform: capitalize; } + + &.agent-title { + color: var(--text-strong); + } } [data-slot="basic-tool-tool-subtitle"] { @@ -92,6 +96,25 @@ color: var(--text-base); } } + + &.subagent-link { + color: var(--text-interactive-base); + text-decoration: none; + text-underline-offset: 2px; + + &:hover { + color: var(--text-interactive-base); + text-decoration: underline; + } + + &:active { + color: var(--text-interactive-base); + } + + &:visited { + color: var(--text-interactive-base); + } + } } [data-slot="basic-tool-tool-arg"] { diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 971f83960e86..a8cb270b722d 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1050,14 +1050,19 @@ ToolRegistry.register({ const trigger = () => (
- + {i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool })} {(url) => ( - + {props.input.description} )} From cc3d2a9aa1cddd7bc7b9d690c8a0aab0f4363335 Mon Sep 17 00:00:00 2001 From: David Hill Date: Thu, 12 Feb 2026 14:44:09 +0000 Subject: [PATCH 009/121] tweak(ui): tighten chevron spacing --- packages/ui/src/components/basic-tool.css | 2 +- packages/ui/src/components/message-part.css | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/basic-tool.css b/packages/ui/src/components/basic-tool.css index 5d7ebd0afebd..62da7b11b957 100644 --- a/packages/ui/src/components/basic-tool.css +++ b/packages/ui/src/components/basic-tool.css @@ -4,7 +4,7 @@ display: flex; align-items: center; align-self: stretch; - gap: 8px; + gap: 0px; justify-content: flex-start; [data-slot="basic-tool-tool-trigger-content"] { diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index ea58c360bb10..b79c18f56d63 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -436,7 +436,7 @@ display: flex; align-items: center; justify-content: flex-start; - gap: 8px; + gap: 0px; cursor: pointer; [data-slot="context-tool-group-title"] { From 9a3ddb7b175183226864f513f4ea632ca97a9058 Mon Sep 17 00:00:00 2001 From: David Hill Date: Thu, 12 Feb 2026 14:46:29 +0000 Subject: [PATCH 010/121] fix(ui): allow explore agent output to fully expand --- packages/ui/src/components/message-part.css | 5 +++++ packages/ui/src/components/message-part.tsx | 1 + 2 files changed, 6 insertions(+) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index b79c18f56d63..0ad2e85d71f5 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -257,6 +257,11 @@ overflow: visible; } } + + &[data-scrollable][data-subagent="explore"] { + max-height: none; + overflow-y: visible; + } } [data-component="edit-trigger"], diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index a8cb270b722d..1baf6d75111d 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1178,6 +1178,7 @@ ToolRegistry.register({ onScroll={autoScroll.handleScroll} data-component="tool-output" data-scrollable + data-subagent={props.input.subagent_type || "task"} >
From 0d914a69cc7cdb645aab7bf98861d78781e17343 Mon Sep 17 00:00:00 2001 From: David Hill Date: Thu, 12 Feb 2026 14:55:12 +0000 Subject: [PATCH 011/121] tweak(ui): refine tool typography and truncation --- packages/ui/src/components/basic-tool.css | 12 +++++++----- packages/ui/src/components/message-part.css | 17 +++++++++++++---- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/components/basic-tool.css b/packages/ui/src/components/basic-tool.css index 62da7b11b957..42bbd14b294e 100644 --- a/packages/ui/src/components/basic-tool.css +++ b/packages/ui/src/components/basic-tool.css @@ -59,10 +59,10 @@ font-family: var(--font-family-sans); font-size: 14px; font-style: normal; - font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-regular); line-height: var(--line-height-large); letter-spacing: var(--letter-spacing-normal); - color: var(--text-base); + color: var(--text-strong); &.capitalize { text-transform: capitalize; @@ -70,6 +70,7 @@ &.agent-title { color: var(--text-strong); + font-weight: var(--font-weight-medium); } } @@ -82,10 +83,10 @@ font-family: var(--font-family-sans); font-size: 14px; font-style: normal; - font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-regular); line-height: var(--line-height-large); letter-spacing: var(--letter-spacing-normal); - color: var(--text-weak); + color: var(--text-base); &.clickable { cursor: pointer; @@ -101,6 +102,7 @@ color: var(--text-interactive-base); text-decoration: none; text-underline-offset: 2px; + font-weight: var(--font-weight-regular); &:hover { color: var(--text-interactive-base); @@ -129,6 +131,6 @@ font-weight: var(--font-weight-regular); line-height: var(--line-height-large); letter-spacing: var(--letter-spacing-normal); - color: var(--text-weak); + color: var(--text-base); } } diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 0ad2e85d71f5..44a1a5dafeb7 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -217,7 +217,7 @@ [data-component="tool-output"] { white-space: pre; - padding: 0; + padding: 8px 0; height: fit-content; display: flex; flex-direction: column; @@ -385,6 +385,8 @@ [data-component="task-tools"] { padding: 0; + width: 100%; + min-width: 0; display: flex; flex-direction: column; gap: 6px; @@ -394,6 +396,9 @@ align-items: center; gap: 8px; color: var(--text-weak); + min-width: 0; + width: 100%; + max-width: 100%; [data-slot="icon-svg"] { flex-shrink: 0; @@ -418,9 +423,10 @@ [data-slot="task-tool-title"] { font-family: var(--font-family-sans); font-size: 14px; - font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-regular); line-height: var(--line-height-large); - color: var(--text-weak); + color: var(--text-strong); + flex-shrink: 0; } [data-slot="task-tool-subtitle"] { @@ -428,7 +434,10 @@ font-size: 14px; font-weight: var(--font-weight-regular); line-height: var(--line-height-large); - color: var(--text-weaker); + color: var(--text-base); + flex: 1 1 auto; + min-width: 0; + max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; From 1e248d47d4ccfb8d2476b7141cd08143b5ab637f Mon Sep 17 00:00:00 2001 From: David Hill Date: Thu, 12 Feb 2026 14:56:48 +0000 Subject: [PATCH 012/121] tweak(ui): strengthen user message text --- packages/ui/src/components/message-part.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 44a1a5dafeb7..44a82fceb9ca 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -14,7 +14,7 @@ font-weight: var(--font-weight-regular); line-height: var(--line-height-large); letter-spacing: var(--letter-spacing-normal); - color: var(--text-base); + color: var(--text-strong); display: flex; flex-direction: column; align-items: flex-end; From 8ffce18fcb52cac0251334f8b4acd065a2354e97 Mon Sep 17 00:00:00 2001 From: David Hill Date: Thu, 12 Feb 2026 15:02:32 +0000 Subject: [PATCH 013/121] tweak(ui): tighten response copy tooltip gutter --- packages/ui/src/components/message-part.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 1baf6d75111d..92687fe7fdcf 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -817,7 +817,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { Date: Thu, 12 Feb 2026 15:08:50 +0000 Subject: [PATCH 014/121] tweak(ui): style edit/write tool content container --- packages/ui/src/components/message-part.css | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 44a82fceb9ca..d0fd58b7a826 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -264,6 +264,19 @@ } } +[data-slot="collapsible-content"]:has([data-component="edit-content"]), +[data-slot="collapsible-content"]:has([data-component="write-content"]) { + border: 1px solid var(--border-weak-base); + border-radius: 6px; + background: transparent; + overflow: hidden; +} + +[data-slot="collapsible-content"]:has([data-component="edit-content"]) [data-component="edit-content"], +[data-slot="collapsible-content"]:has([data-component="write-content"]) [data-component="write-content"] { + border-top: none; +} + [data-component="edit-trigger"], [data-component="write-trigger"] { display: flex; From 34add5e2df71bee2dd2c7c10d4c704994d016aa1 Mon Sep 17 00:00:00 2001 From: David Hill Date: Thu, 12 Feb 2026 15:26:32 +0000 Subject: [PATCH 015/121] tweak(ui): unify tool header typography --- packages/ui/src/components/basic-tool.css | 3 ++- packages/ui/src/components/diff-changes.css | 4 ++-- packages/ui/src/components/markdown.css | 2 +- packages/ui/src/components/message-part.css | 5 ++++- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/components/basic-tool.css b/packages/ui/src/components/basic-tool.css index 42bbd14b294e..c67c673051a7 100644 --- a/packages/ui/src/components/basic-tool.css +++ b/packages/ui/src/components/basic-tool.css @@ -12,7 +12,7 @@ display: flex; align-items: center; align-self: stretch; - gap: 20px; + gap: 8px; } [data-slot="basic-tool-tool-indicator"] { @@ -36,6 +36,7 @@ [data-slot="basic-tool-tool-info"] { flex: 0 1 auto; min-width: 0; + font-size: 14px; } [data-slot="basic-tool-tool-info-structured"] { diff --git a/packages/ui/src/components/diff-changes.css b/packages/ui/src/components/diff-changes.css index be3cca885d4b..2ff356284fe6 100644 --- a/packages/ui/src/components/diff-changes.css +++ b/packages/ui/src/components/diff-changes.css @@ -7,7 +7,7 @@ [data-slot="diff-changes-additions"] { font-family: var(--font-family-mono); font-feature-settings: var(--font-family-mono--font-feature-settings); - font-size: var(--font-size-small); + font-size: 14px; font-style: normal; font-weight: var(--font-weight-regular); line-height: var(--line-height-large); @@ -19,7 +19,7 @@ [data-slot="diff-changes-deletions"] { font-family: var(--font-family-mono); font-feature-settings: var(--font-family-mono--font-feature-settings); - font-size: var(--font-size-small); + font-size: 14px; font-style: normal; font-weight: var(--font-weight-regular); line-height: var(--line-height-large); diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index c7b74c765c1e..fbaf74cba1d6 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -117,7 +117,7 @@ .shiki { font-size: 13px; padding: 8px 12px; - border-radius: 4px; + border-radius: 6px; border: 0.5px solid var(--border-weak-base); } diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index d0fd58b7a826..dcfe1035a4dd 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -299,7 +299,7 @@ align-items: center; gap: 4px; font-family: var(--font-family-sans); - font-size: var(--font-size-base); + font-size: 14px; font-style: normal; font-weight: var(--font-weight-medium); line-height: var(--line-height-large); @@ -309,16 +309,19 @@ [data-slot="message-part-title-text"] { text-transform: capitalize; + color: var(--text-strong); } [data-slot="message-part-title-filename"] { /* No text-transform - preserve original filename casing */ + font-weight: var(--font-weight-regular); } [data-slot="message-part-path"] { display: flex; flex-grow: 1; min-width: 0; + font-weight: var(--font-weight-regular); } [data-slot="message-part-directory"] { From cdf2cdb714e34c70678673ba0f2185c0b4083c6a Mon Sep 17 00:00:00 2001 From: David Hill Date: Thu, 12 Feb 2026 15:40:42 +0000 Subject: [PATCH 016/121] tweak(ui): remove context tool icons and add tool spacing --- packages/ui/src/components/basic-tool.css | 2 +- packages/ui/src/components/basic-tool.tsx | 2 +- packages/ui/src/components/collapsible.css | 4 ++++ packages/ui/src/components/message-part.css | 2 +- packages/ui/src/components/message-part.tsx | 8 ++++---- 5 files changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/components/basic-tool.css b/packages/ui/src/components/basic-tool.css index c67c673051a7..95e473534102 100644 --- a/packages/ui/src/components/basic-tool.css +++ b/packages/ui/src/components/basic-tool.css @@ -60,7 +60,7 @@ font-family: var(--font-family-sans); font-size: 14px; font-style: normal; - font-weight: var(--font-weight-regular); + font-weight: var(--font-weight-medium); line-height: var(--line-height-large); letter-spacing: var(--letter-spacing-normal); color: var(--text-strong); diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index 807292918f2f..9a99aceb7b7e 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -45,7 +45,7 @@ export function BasicTool(props: BasicToolProps) { } return ( - +
diff --git a/packages/ui/src/components/collapsible.css b/packages/ui/src/components/collapsible.css index 720297eb5142..88f37ea7ffa3 100644 --- a/packages/ui/src/components/collapsible.css +++ b/packages/ui/src/components/collapsible.css @@ -8,6 +8,10 @@ border-radius: var(--radius-md); overflow: clip; + &.tool-collapsible { + gap: 8px; + } + [data-slot="collapsible-trigger"] { width: 100%; display: flex; diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index dcfe1035a4dd..67a2ed4003da 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -491,7 +491,7 @@ [data-slot="context-tool-group-item"] { min-width: 0; - padding: 6px 8px 6px 12px; + padding: 6px 0; } } diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 92687fe7fdcf..93258341dd26 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -417,11 +417,11 @@ function ContextToolGroup(props: { parts: ToolPart[] }) {
-
- }> + +
- -
+
+
From e3b4e885e658ce006f07e4656f8a5a856d641efe Mon Sep 17 00:00:00 2001 From: David Hill Date: Thu, 12 Feb 2026 15:47:29 +0000 Subject: [PATCH 017/121] tweak(ui): adjust tool output spacing and title weight --- packages/ui/src/components/message-part.css | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 67a2ed4003da..3075639f203a 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -217,7 +217,8 @@ [data-component="tool-output"] { white-space: pre; - padding: 8px 0; + padding: 0; + margin-bottom: 24px; height: fit-content; display: flex; flex-direction: column; @@ -439,7 +440,7 @@ [data-slot="task-tool-title"] { font-family: var(--font-family-sans); font-size: 14px; - font-weight: var(--font-weight-regular); + font-weight: var(--font-weight-medium); line-height: var(--line-height-large); color: var(--text-strong); flex-shrink: 0; From c52c3682ebc30da87d9f48b97f3355492cc47c34 Mon Sep 17 00:00:00 2001 From: David Hill Date: Thu, 12 Feb 2026 15:59:11 +0000 Subject: [PATCH 018/121] tweak(ui): prevent copy button layout shift --- packages/ui/src/components/message-part.css | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 3075639f203a..707f95a7f2ae 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -112,6 +112,12 @@ opacity: 0; pointer-events: none; transition: opacity 0.15s ease; + will-change: opacity; + + [data-component="tooltip-trigger"] { + display: inline-flex; + width: fit-content; + } } [data-slot="user-message-body"]:hover [data-slot="user-message-copy-wrapper"], @@ -145,6 +151,12 @@ opacity: 0; pointer-events: none; transition: opacity 0.15s ease; + will-change: opacity; + + [data-component="tooltip-trigger"] { + display: inline-flex; + width: fit-content; + } } &:hover [data-slot="text-part-copy-wrapper"], From 62afd705bd000f4911c60c06d8d9345d8f1bb9a8 Mon Sep 17 00:00:00 2001 From: David Hill Date: Thu, 12 Feb 2026 16:05:01 +0000 Subject: [PATCH 019/121] tweak(ui): add markdown copy tooltip and restyle button --- packages/ui/src/components/markdown.css | 48 +++++++++++++++++++++++-- packages/ui/src/components/markdown.tsx | 6 ++-- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index fbaf74cba1d6..1fe11a7de896 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -127,11 +127,55 @@ [data-slot="markdown-copy-button"] { position: absolute; - top: 8px; - right: 8px; + top: 4px; + right: 4px; opacity: 0; transition: opacity 0.15s ease; z-index: 1; + + &::after { + content: attr(data-tooltip); + position: absolute; + left: 50%; + bottom: calc(100% + 4px); + transform: translateX(-50%); + z-index: 1000; + + max-width: 320px; + border-radius: var(--radius-sm); + background: var(--surface-float-base); + color: var(--text-invert-strong); + padding: 2px 8px; + border: 1px solid var(--border-weak-base, rgba(0, 0, 0, 0.07)); + box-shadow: var(--shadow-md); + + pointer-events: none; + white-space: nowrap; + + 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); + letter-spacing: var(--letter-spacing-normal); + + opacity: 0; + transition: opacity 0.15s ease; + } + } + + [data-slot="markdown-copy-button"]:hover::after, + [data-slot="markdown-copy-button"]:focus-visible::after { + opacity: 1; + } + + [data-slot="markdown-copy-button"][data-variant="secondary"] { + box-shadow: none; + border: 1px solid var(--border-weak-base); + } + + [data-slot="markdown-copy-button"][data-variant="secondary"] [data-slot="icon-svg"] { + color: var(--icon-base); } [data-component="markdown-code"]:hover [data-slot="markdown-copy-button"] { diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 4c3d5628418b..be43eca81ad8 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -85,7 +85,7 @@ function createCopyButton(labels: CopyLabels) { button.setAttribute("data-size", "small") button.setAttribute("data-slot", "markdown-copy-button") button.setAttribute("aria-label", labels.copy) - button.setAttribute("title", labels.copy) + button.setAttribute("data-tooltip", labels.copy) button.appendChild(createIcon(iconPaths.copy, "copy-icon")) button.appendChild(createIcon(iconPaths.check, "check-icon")) return button @@ -95,12 +95,12 @@ function setCopyState(button: HTMLButtonElement, labels: CopyLabels, copied: boo if (copied) { button.setAttribute("data-copied", "true") button.setAttribute("aria-label", labels.copied) - button.setAttribute("title", labels.copied) + button.setAttribute("data-tooltip", labels.copied) return } button.removeAttribute("data-copied") button.setAttribute("aria-label", labels.copy) - button.setAttribute("title", labels.copy) + button.setAttribute("data-tooltip", labels.copy) } function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) { From 2eb8b0c6f9733c02a21bcdf75ffe28020465d13c Mon Sep 17 00:00:00 2001 From: David Hill Date: Thu, 12 Feb 2026 16:09:14 +0000 Subject: [PATCH 020/121] tweak(ui): expand user message hover area --- packages/ui/src/components/message-part.css | 24 +++++++++++++-------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 707f95a7f2ae..45aba4adb597 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -18,10 +18,9 @@ display: flex; flex-direction: column; align-items: flex-end; - align-self: flex-end; - margin-left: auto; - width: fit-content; - max-width: min(82%, 64ch); + align-self: stretch; + width: 100%; + max-width: 100%; gap: 8px; [data-slot="user-message-attachments"] { @@ -29,7 +28,9 @@ flex-wrap: wrap; justify-content: flex-end; gap: 8px; - max-width: 100%; + width: fit-content; + max-width: min(82%, 64ch); + margin-left: auto; } [data-slot="user-message-attachment"] { @@ -79,11 +80,16 @@ } [data-slot="user-message-body"] { - width: 100%; - max-width: 100%; + width: fit-content; + max-width: min(82%, 64ch); + margin-left: auto; + display: flex; + flex-direction: column; + align-items: flex-end; } [data-slot="user-message-text"] { + display: inline-block; white-space: pre-wrap; word-break: break-word; overflow: hidden; @@ -120,8 +126,8 @@ } } - [data-slot="user-message-body"]:hover [data-slot="user-message-copy-wrapper"], - [data-slot="user-message-body"]:focus-within [data-slot="user-message-copy-wrapper"] { + &:hover [data-slot="user-message-copy-wrapper"], + &:focus-within [data-slot="user-message-copy-wrapper"] { opacity: 1; pointer-events: auto; } From 7f8ee9d23957cb28bd0b4ca72708d641974c5bcc Mon Sep 17 00:00:00 2001 From: David Hill Date: Thu, 12 Feb 2026 16:19:07 +0000 Subject: [PATCH 021/121] refactor(ui): rework bash output container and copy --- packages/ui/src/components/message-part.css | 52 +++++++++++++++++++++ packages/ui/src/components/message-part.tsx | 39 ++++++++++++++-- 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 45aba4adb597..fa2fd94223d4 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -291,6 +291,58 @@ overflow: hidden; } +[data-component="bash-output"] { + width: 100%; + padding: 12px; + border: 1px solid var(--border-weak-base); + border-radius: 6px; + background: transparent; + position: relative; + + [data-slot="bash-copy"] { + position: absolute; + top: 4px; + right: 4px; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease; + } + + &:hover [data-slot="bash-copy"], + &:focus-within [data-slot="bash-copy"] { + opacity: 1; + pointer-events: auto; + } + + [data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] { + box-shadow: none; + border: 1px solid var(--border-weak-base); + } + + [data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] [data-slot="icon-svg"] { + color: var(--icon-base); + } + + [data-slot="bash-pre"] { + margin: 0; + overflow: auto; + max-height: 240px; + + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } + } + + [data-slot="bash-pre"] code { + font-family: var(--font-family-mono); + font-feature-settings: var(--font-family-mono--font-feature-settings); + font-size: 13px; + line-height: var(--line-height-large); + white-space: pre; + } +} + [data-slot="collapsible-content"]:has([data-component="edit-content"]) [data-component="edit-content"], [data-slot="collapsible-content"]:has([data-component="write-content"]) [data-component="write-content"] { border-top: none; diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 93258341dd26..4106fc7cfe8a 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1219,6 +1219,21 @@ ToolRegistry.register({ name: "bash", render(props) { const i18n = useI18n() + const text = createMemo(() => { + const cmd = props.input.command ?? props.metadata.command ?? "" + const out = stripAnsi(props.output || props.metadata.output || "") + return `$ ${cmd}${out ? "\n\n" + out : ""}` + }) + const [copied, setCopied] = createSignal(false) + + const handleCopy = async () => { + const content = text() + if (!content) return + await navigator.clipboard.writeText(content) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + return ( -
- +
+
+ + e.preventDefault()} + onClick={handleCopy} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} + /> + +
+
+            {text()}
+          
) From fbf447ca644672d41de2dd628286229743767a74 Mon Sep 17 00:00:00 2001 From: David Hill Date: Thu, 12 Feb 2026 16:25:47 +0000 Subject: [PATCH 022/121] tweak(ui): remove horizontal padding from todos and question answers --- packages/ui/src/components/message-part.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index fa2fd94223d4..c4861698a408 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -457,7 +457,7 @@ } [data-component="todos"] { - padding: 10px 12px 24px 48px; + padding: 10px 0 24px 0; display: flex; flex-direction: column; gap: 8px; @@ -891,7 +891,7 @@ display: flex; flex-direction: column; gap: 12px; - padding: 8px 12px; + padding: 8px 0; [data-slot="question-answer-item"] { display: flex; From 504a95f1e5ecaedf4339b741a845d774250dd1c0 Mon Sep 17 00:00:00 2001 From: David Hill Date: Thu, 12 Feb 2026 16:30:46 +0000 Subject: [PATCH 023/121] fix(ui): improve bash output scrolling and wrapping --- packages/ui/src/components/message-part.css | 17 ++++++++++++----- packages/ui/src/components/message-part.tsx | 8 +++++--- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index c4861698a408..d0cb0fa3d7e7 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -293,11 +293,11 @@ [data-component="bash-output"] { width: 100%; - padding: 12px; border: 1px solid var(--border-weak-base); border-radius: 6px; background: transparent; position: relative; + overflow: hidden; [data-slot="bash-copy"] { position: absolute; @@ -323,9 +323,10 @@ color: var(--icon-base); } - [data-slot="bash-pre"] { - margin: 0; - overflow: auto; + [data-slot="bash-scroll"] { + width: 100%; + overflow-y: auto; + overflow-x: hidden; max-height: 240px; scrollbar-width: none; @@ -334,12 +335,18 @@ } } + [data-slot="bash-pre"] { + margin: 0; + padding: 12px; + } + [data-slot="bash-pre"] code { font-family: var(--font-family-mono); font-feature-settings: var(--font-family-mono--font-feature-settings); font-size: 13px; line-height: var(--line-height-large); - white-space: pre; + white-space: pre-wrap; + overflow-wrap: anywhere; } } diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 4106fc7cfe8a..6137a4da18bd 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1260,9 +1260,11 @@ ToolRegistry.register({ />
-
-            {text()}
-          
+
+
+              {text()}
+            
+
) From f8c12ad7284e5dbb6f9d414d1d5a4a5928d41702 Mon Sep 17 00:00:00 2001 From: David Hill Date: Thu, 12 Feb 2026 16:56:49 +0000 Subject: [PATCH 024/121] tweak(ui): show spinners after titles and lock tools until done --- packages/ui/src/components/basic-tool.css | 15 +++++ packages/ui/src/components/basic-tool.tsx | 73 +++++++++++---------- packages/ui/src/components/message-part.css | 20 ++++++ packages/ui/src/components/message-part.tsx | 47 ++++++++----- packages/ui/src/components/session-turn.tsx | 2 +- 5 files changed, 105 insertions(+), 52 deletions(-) diff --git a/packages/ui/src/components/basic-tool.css b/packages/ui/src/components/basic-tool.css index 95e473534102..1240ad7b9950 100644 --- a/packages/ui/src/components/basic-tool.css +++ b/packages/ui/src/components/basic-tool.css @@ -29,6 +29,21 @@ } } + [data-slot="basic-tool-tool-spinner"] { + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--text-weak); + + [data-component="spinner"] { + width: 16px; + height: 16px; + } + } + [data-slot="icon-svg"] { flex-shrink: 0; } diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index 9a99aceb7b7e..2fd9746d062f 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -40,6 +40,7 @@ export function BasicTool(props: BasicToolProps) { }) const handleOpenChange = (value: boolean) => { + if (pending()) return if (props.locked && !value) return setOpen(value) } @@ -49,11 +50,6 @@ export function BasicTool(props: BasicToolProps) {
- -
- -
-
@@ -68,39 +64,46 @@ export function BasicTool(props: BasicToolProps) { > {trigger().title} - - { - if (props.onSubtitleClick) { - e.stopPropagation() - props.onSubtitleClick() - } - }} - > - {trigger().subtitle} + + + - - - {(arg) => ( - - {arg} - - )} - + + + { + if (props.onSubtitleClick) { + e.stopPropagation() + props.onSubtitleClick() + } + }} + > + {trigger().subtitle} + + + + + {(arg) => ( + + {arg} + + )} + +
- {trigger().action} + {trigger().action}
)} @@ -108,7 +111,7 @@ export function BasicTool(props: BasicToolProps) {
- +
diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index d0cb0fa3d7e7..7dc763266672 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -385,6 +385,22 @@ color: var(--text-base); } + [data-slot="message-part-title-spinner"] { + margin-left: 4px; + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--text-weak); + + [data-component="spinner"] { + width: 16px; + height: 16px; + } + } + [data-slot="message-part-title-text"] { text-transform: capitalize; color: var(--text-strong); @@ -507,6 +523,10 @@ align-items: center; justify-content: center; flex-shrink: 0; + color: var(--text-weak); + + margin-left: 0; + margin-right: 0; [data-component="spinner"] { width: 16px; diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 6137a4da18bd..9c0665765831 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -417,19 +417,19 @@ function ContextToolGroup(props: { parts: ToolPart[] }) {
- -
- -
-
{trigger.title} - + + + + + + {trigger.subtitle} - + {(arg) => {arg}} @@ -1185,19 +1185,18 @@ ToolRegistry.register({ {(item) => { const info = createMemo(() => getToolInfo(item.tool, item.state.input)) const subtitle = createMemo(() => { + if (item.state.status !== "completed") return if (info().subtitle) return info().subtitle - if (item.state.status === "completed" || item.state.status === "running") { - return item.state.title - } + return item.state.title }) return (
+ {info().title}
- {info().title} {subtitle()} @@ -1278,6 +1277,7 @@ ToolRegistry.register({ const diffComponent = useDiffComponent() const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) const filename = () => getFilename(props.input.filePath ?? "") + const pending = () => props.status === "pending" || props.status === "running" return (
{i18n.t("ui.messagePart.title.edit")} - {filename()} + + + + + + + {filename()} +
- +
{getDirectory(props.input.filePath!)}
- +
@@ -1331,6 +1338,7 @@ ToolRegistry.register({ const codeComponent = useCodeComponent() const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) const filename = () => getFilename(props.input.filePath ?? "") + const pending = () => props.status === "pending" || props.status === "running" return (
{i18n.t("ui.messagePart.title.write")} - {filename()} + + + + + + + {filename()} +
- +
{getDirectory(props.input.filePath!)}
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 8e2bd3436f91..d24d463252e8 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -228,8 +228,8 @@ export function SessionTurn(
- Thinking +
0}> From c35317e07f6746587872dba093cb6e89c03e4f40 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:59:04 -0600 Subject: [PATCH 025/121] wip(app): timeline changes --- packages/app/src/pages/directory-layout.tsx | 1 - packages/ui/src/components/message-part.css | 70 -------- packages/ui/src/components/message-part.tsx | 187 +++----------------- packages/ui/src/components/text-shimmer.css | 43 +++++ packages/ui/src/components/text-shimmer.tsx | 36 ++++ packages/ui/src/context/data.tsx | 4 - packages/ui/src/styles/index.css | 1 + 7 files changed, 100 insertions(+), 242 deletions(-) create mode 100644 packages/ui/src/components/text-shimmer.css create mode 100644 packages/ui/src/components/text-shimmer.tsx diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 2dee09dfb06c..4f1d93ab2829 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -30,7 +30,6 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { onQuestionReject={(input: { requestID: string }) => sdk.client.question.reject(input)} onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)} onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`} - onSyncSession={(sessionID: string) => sync.session.sync(sessionID)} > {props.children} diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 7dc763266672..1d8a5d38222c 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -276,11 +276,6 @@ overflow: visible; } } - - &[data-scrollable][data-subagent="explore"] { - max-height: none; - overflow-y: visible; - } } [data-slot="collapsible-content"]:has([data-component="edit-content"]), @@ -493,71 +488,6 @@ } } -[data-component="task-tools"] { - padding: 0; - width: 100%; - min-width: 0; - display: flex; - flex-direction: column; - gap: 6px; - - [data-slot="task-tool-item"] { - display: flex; - align-items: center; - gap: 8px; - color: var(--text-weak); - min-width: 0; - width: 100%; - max-width: 100%; - - [data-slot="icon-svg"] { - flex-shrink: 0; - color: var(--icon-weak); - } - } - - [data-slot="task-tool-indicator"] { - width: 16px; - height: 16px; - display: inline-flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - color: var(--text-weak); - - margin-left: 0; - margin-right: 0; - - [data-component="spinner"] { - width: 16px; - height: 16px; - } - } - - [data-slot="task-tool-title"] { - font-family: var(--font-family-sans); - font-size: 14px; - font-weight: var(--font-weight-medium); - line-height: var(--line-height-large); - color: var(--text-strong); - flex-shrink: 0; - } - - [data-slot="task-tool-subtitle"] { - font-family: var(--font-family-sans); - font-size: 14px; - font-weight: var(--font-weight-regular); - line-height: var(--line-height-large); - color: var(--text-base); - flex: 1 1 auto; - min-width: 0; - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } -} - [data-component="context-tool-group-trigger"] { width: 100%; min-height: 24px; diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 9c0665765831..2260992baad7 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -43,13 +43,12 @@ import { Checkbox } from "./checkbox" import { DiffChanges } from "./diff-changes" import { Markdown } from "./markdown" import { ImagePreview } from "./image-preview" -import { findLast } from "@opencode-ai/util/array" import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path" import { checksum } from "@opencode-ai/util/encode" import { Tooltip } from "./tooltip" import { IconButton } from "./icon-button" import { Spinner } from "./spinner" -import { createAutoScroll } from "../hooks" +import { TextShimmer } from "./text-shimmer" interface Diagnostic { range: { @@ -154,22 +153,6 @@ function getDirectory(path: string | undefined) { return relativizeProjectPaths(_getDirectory(path), data.directory) } -export function getSessionToolParts(store: ReturnType["store"], sessionId: string): ToolPart[] { - const messages = store.message[sessionId]?.filter((m) => m.role === "assistant") - if (!messages) return [] - - const parts: ToolPart[] = [] - for (const m of messages) { - const msgParts = store.part[m.id] - if (msgParts) { - for (const p of msgParts) { - if (p && p.type === "tool") parts.push(p as ToolPart) - } - } - } - return parts -} - import type { IconProps } from "./icon" export type ToolInfo = { @@ -402,7 +385,11 @@ function ContextToolGroup(props: { parts: ToolPart[] }) {
- {pending() ? "Gathering context" : "Gathered context"} + Gathered context}> + + + +
@@ -410,7 +397,6 @@ function ContextToolGroup(props: { parts: ToolPart[] }) {
{(part) => { - const info = getToolInfo(part.tool, part.state.input ?? {}) const trigger = contextToolTrigger(part, i18n) const running = part.state.status === "pending" || part.state.status === "running" return ( @@ -1003,6 +989,13 @@ ToolRegistry.register({ const data = useData() const i18n = useI18n() const childSessionId = () => props.metadata.sessionId as string | undefined + const title = createMemo(() => i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool })) + const description = createMemo(() => { + const value = props.input.description + if (typeof value === "string") return value + return undefined + }) + const running = createMemo(() => props.status === "pending" || props.status === "running") const href = createMemo(() => { const sessionId = childSessionId() @@ -1018,14 +1011,6 @@ ToolRegistry.register({ return `${path.slice(0, idx)}/session/${sessionId}` }) - createEffect(() => { - const sessionId = childSessionId() - if (!sessionId) return - const sync = data.syncSession - if (!sync) return - Promise.resolve(sync(sessionId)).catch(() => undefined) - }) - const handleLinkClick = (e: MouseEvent) => { const sessionId = childSessionId() const url = href() @@ -1047,13 +1032,15 @@ ToolRegistry.register({ }, 50) } + const titleContent = () => + const trigger = () => (
- {i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool })} + {titleContent()} - + {(url) => ( @@ -1063,12 +1050,12 @@ ToolRegistry.register({ href={url()} onClick={handleLinkClick} > - {props.input.description} + {description()} )} - {props.input.description} + {description()} @@ -1076,141 +1063,7 @@ ToolRegistry.register({
) - const childToolParts = createMemo(() => { - const sessionId = childSessionId() - if (!sessionId) return [] - return getSessionToolParts(data.store, sessionId) - }) - - const autoScroll = createAutoScroll({ - working: () => true, - overflowAnchor: "dynamic", - }) - - const childPermission = createMemo(() => { - const sessionId = childSessionId() - if (!sessionId) return undefined - const permissions = data.store.permission?.[sessionId] ?? [] - return permissions[0] - }) - - const childToolPart = createMemo(() => { - const perm = childPermission() - if (!perm || !perm.tool) return undefined - const sessionId = childSessionId() - if (!sessionId) return undefined - // Find the tool part that matches the permission's callID - const messages = data.store.message[sessionId] ?? [] - const message = findLast(messages, (m) => m.id === perm.tool!.messageID) - if (!message) return undefined - const parts = data.store.part[message.id] ?? [] - for (const part of parts) { - if (part.type === "tool" && (part as ToolPart).callID === perm.tool!.callID) { - return { part: part as ToolPart, message } - } - } - - return undefined - }) - - const respond = (response: "once" | "always" | "reject") => { - const perm = childPermission() - if (!perm || !data.respondToPermission) return - data.respondToPermission({ - sessionID: perm.sessionID, - permissionID: perm.id, - response, - }) - } - - const renderChildToolPart = () => { - const toolData = childToolPart() - if (!toolData) return null - const { part } = toolData - const render = ToolRegistry.render(part.tool) ?? GenericTool - // @ts-expect-error - const metadata = part.state?.metadata ?? {} - const input = part.state?.input ?? {} - return ( - - ) - } - - return ( -
- - - <> - } - > - {renderChildToolPart()} - -
-
- - - -
-
- -
- - -
-
- - {(item) => { - const info = createMemo(() => getToolInfo(item.tool, item.state.input)) - const subtitle = createMemo(() => { - if (item.state.status !== "completed") return - if (info().subtitle) return info().subtitle - return item.state.title - }) - return ( -
- {info().title} - -
- -
-
- - {subtitle()} - -
- ) - }} -
-
-
-
-
-
-
- ) + return }, }) diff --git a/packages/ui/src/components/text-shimmer.css b/packages/ui/src/components/text-shimmer.css new file mode 100644 index 000000000000..929a2d85161d --- /dev/null +++ b/packages/ui/src/components/text-shimmer.css @@ -0,0 +1,43 @@ +[data-component="text-shimmer"] { + --text-shimmer-step: 45ms; + --text-shimmer-duration: 1200ms; +} + +[data-component="text-shimmer"] [data-slot="text-shimmer-char"] { + white-space: pre; + color: inherit; +} + +[data-component="text-shimmer"][data-active="true"] [data-slot="text-shimmer-char"] { + animation-name: text-shimmer-char; + animation-duration: var(--text-shimmer-duration); + animation-iteration-count: infinite; + animation-timing-function: ease-in-out; + animation-delay: calc(var(--text-shimmer-step) * var(--text-shimmer-index)); +} + +@keyframes text-shimmer-char { + 0%, + 100% { + color: var(--text-weaker); + } + + 30% { + color: var(--text-weak); + } + + 55% { + color: var(--text-base); + } + + 75% { + color: var(--text-strong); + } +} + +@media (prefers-reduced-motion: reduce) { + [data-component="text-shimmer"] [data-slot="text-shimmer-char"] { + animation: none !important; + color: inherit; + } +} diff --git a/packages/ui/src/components/text-shimmer.tsx b/packages/ui/src/components/text-shimmer.tsx new file mode 100644 index 000000000000..6ee4ef4020f1 --- /dev/null +++ b/packages/ui/src/components/text-shimmer.tsx @@ -0,0 +1,36 @@ +import { For, createMemo, type ValidComponent } from "solid-js" +import { Dynamic } from "solid-js/web" + +export const TextShimmer = (props: { + text: string + class?: string + as?: T + active?: boolean + stepMs?: number + durationMs?: number +}) => { + const chars = createMemo(() => Array.from(props.text)) + const active = () => props.active ?? true + + return ( + + + {(char, index) => ( + + )} + + + ) +} diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index 51bffa0502ad..5ff3d9131456 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -50,8 +50,6 @@ export type NavigateToSessionFn = (sessionID: string) => void export type SessionHrefFn = (sessionID: string) => string -export type SyncSessionFn = (sessionID: string) => void | Promise - export const { use: useData, provider: DataProvider } = createSimpleContext({ name: "Data", init: (props: { @@ -62,7 +60,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ onQuestionReject?: QuestionRejectFn onNavigateToSession?: NavigateToSessionFn onSessionHref?: SessionHrefFn - onSyncSession?: SyncSessionFn }) => { return { get store() { @@ -76,7 +73,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ rejectQuestion: props.onQuestionReject, navigateToSession: props.onNavigateToSession, sessionHref: props.onSessionHref, - syncSession: props.onSyncSession, } }, }) diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 167eb64c80d0..f0a1275c3325 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -48,6 +48,7 @@ @import "../components/sticky-accordion-header.css" layer(components); @import "../components/tabs.css" layer(components); @import "../components/tag.css" layer(components); +@import "../components/text-shimmer.css" layer(components); @import "../components/toast.css" layer(components); @import "../components/tooltip.css" layer(components); @import "../components/typewriter.css" layer(components); From f5ca478c63b920fb27d13d041bfceea85cea0384 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 12 Feb 2026 15:07:55 -0600 Subject: [PATCH 026/121] wip(app): timeline changes --- packages/ui/src/components/message-part.tsx | 71 ++++++++++++++++----- 1 file changed, 54 insertions(+), 17 deletions(-) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 2260992baad7..4aa5214926d4 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -346,29 +346,66 @@ export function AssistantMessageDisplay(props: { parts: PartType[] showAssistantCopyPartID?: string }) { - const grouped = createMemo(() => - props.parts.reduce<({ type: "part"; part: PartType } | { type: "context"; parts: ToolPart[] })[]>((acc, part) => { - if (!isContextGroupTool(part)) { - acc.push({ type: "part", part }) - return acc + const grouped = createMemo(() => { + const keys: string[] = [] + const items: Record = {} + const push = (key: string, item: { type: "part"; part: PartType } | { type: "context"; parts: ToolPart[] }) => { + keys.push(key) + items[key] = item + } + + const parts = props.parts + let start = -1 + + const flush = (end: number) => { + if (start < 0) return + const first = parts[start] + const last = parts[end] + if (!first || !last) { + start = -1 + return } + push(`context:${first.id}:${last.id}`, { + type: "context", + parts: parts.slice(start, end + 1).filter((part): part is ToolPart => isContextGroupTool(part)), + }) + start = -1 + } - const last = acc[acc.length - 1] - if (last && last.type === "context") { - last.parts.push(part) - return acc + parts.forEach((part, index) => { + if (isContextGroupTool(part)) { + if (start < 0) start = index + return } - acc.push({ type: "context", parts: [part] }) - return acc - }, []), - ) + flush(index - 1) + push(`part:${part.id}`, { type: "part", part }) + }) + + flush(parts.length - 1) + + return { keys, items } + }) return ( - - {(item) => { - if (item.type === "context") return - return + + {(key) => { + const item = createMemo(() => grouped().items[key]) + return ( + + {(value) => { + const entry = value() + if (entry.type === "context") return + return ( + + ) + }} + + ) }} ) From f22ac01618fd769e609574500edc01f831f1a266 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 12 Feb 2026 15:19:54 -0600 Subject: [PATCH 027/121] wip(app): timeline changes --- packages/ui/src/components/message-part.tsx | 91 ++++++++++++++++----- packages/ui/src/components/session-turn.tsx | 13 ++- 2 files changed, 79 insertions(+), 25 deletions(-) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 4aa5214926d4..76c2daf140a0 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -293,7 +293,7 @@ function contextToolTrigger(part: ToolPart, i18n: ReturnType) { } case "glob": return { - title: i18n.t("ui.tool.glob"), + title: "Search", subtitle: getDirectory(path), args: pattern ? ["pattern=" + pattern] : [], } @@ -302,7 +302,7 @@ function contextToolTrigger(part: ToolPart, i18n: ReturnType) { if (pattern) args.push("pattern=" + pattern) if (include) args.push("include=" + include) return { - title: i18n.t("ui.tool.grep"), + title: "Search", subtitle: getDirectory(path), args, } @@ -318,6 +318,17 @@ function contextToolTrigger(part: ToolPart, i18n: ReturnType) { } } +function contextToolSummary(parts: ToolPart[]) { + const read = parts.filter((part) => part.tool === "read").length + const search = parts.filter((part) => part.tool === "glob" || part.tool === "grep").length + const list = parts.filter((part) => part.tool === "list").length + return [ + read ? `${read} ${read === 1 ? "read" : "reads"}` : undefined, + search ? `${search} ${search === 1 ? "search" : "searches"}` : undefined, + list ? `${list} ${list === 1 ? "list" : "lists"}` : undefined, + ].filter((value): value is string => !!value) +} + export function registerPartComponent(type: string, component: PartComponent) { PART_MAPPING[type] = component } @@ -365,7 +376,7 @@ export function AssistantMessageDisplay(props: { start = -1 return } - push(`context:${first.id}:${last.id}`, { + push(`context:${first.id}`, { type: "context", parts: parts.slice(start, end + 1).filter((part): part is ToolPart => isContextGroupTool(part)), }) @@ -417,14 +428,24 @@ function ContextToolGroup(props: { parts: ToolPart[] }) { const pending = createMemo(() => props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"), ) + const summary = createMemo(() => contextToolSummary(props.parts)) + const details = createMemo(() => { + const items = summary() + if (items.length === 0) return "" + return `: ${items.join(", ")}` + }) return (
- Gathered context}> + Gathered context{details()}} + > + {details()} @@ -993,29 +1014,55 @@ ToolRegistry.register({ name: "webfetch", render(props) { const i18n = useI18n() + const pending = createMemo(() => props.status === "pending" || props.status === "running") + const url = createMemo(() => { + const value = props.input.url + if (typeof value !== "string") return "" + return value + }) + const format = createMemo(() => { + const value = props.input.format + if (typeof value !== "string") return "" + return value + }) return ( - -
- ), - }} - > - - {(output) => ( -
- + trigger={ +
+
+ {i18n.t("ui.tool.webfetch")} + + + + + + + event.stopPropagation()} + > + {url()} + + + + format={format()} +
- )} - - + +
+ +
+
+
+ } + /> ) }, }) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index d24d463252e8..1e347dc8fd0d 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -72,6 +72,13 @@ function list(value: T[] | undefined | null, fallback: T[]) { return fallback } +function visible(part: PartType) { + if (part.type === "tool") return true + if (part.type === "text") return !!part.text?.trim() + if (part.type === "reasoning") return !!part.text?.trim() + return false +} + function AssistantMessageItem(props: { message: AssistantMessage; showAssistantCopyPartID?: string }) { const data = useData() const emptyParts: PartType[] = [] @@ -193,10 +200,10 @@ export function SessionTurn( const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle) const working = createMemo(() => status().type !== "idle" && isLastUserMessage()) - const assistantPartCount = createMemo(() => + const assistantVisible = createMemo(() => assistantMessages().reduce((count, message) => { const parts = list(data.store.part?.[message.id], emptyParts) - return count + parts.filter(Boolean).length + return count + parts.filter(visible).length }, 0), ) @@ -226,7 +233,7 @@ export function SessionTurn(
- +
Thinking From 47e6fec3a18bf44c2d5efa67c3453e72569cc213 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 12 Feb 2026 15:35:09 -0600 Subject: [PATCH 028/121] wip(app): timeline changes --- .../app/src/components/session-todo-dock.tsx | 85 +++++++++++++++++++ packages/app/src/context/global-sync.tsx | 23 +++++ .../app/src/context/global-sync/bootstrap.ts | 4 + .../src/context/global-sync/event-reducer.ts | 14 ++- packages/app/src/context/sync.tsx | 18 +++- packages/app/src/i18n/en.ts | 3 + packages/app/src/pages/session.tsx | 11 ++- .../src/pages/session/session-prompt-dock.tsx | 12 ++- packages/ui/src/components/message-part.tsx | 9 +- 9 files changed, 164 insertions(+), 15 deletions(-) create mode 100644 packages/app/src/components/session-todo-dock.tsx diff --git a/packages/app/src/components/session-todo-dock.tsx b/packages/app/src/components/session-todo-dock.tsx new file mode 100644 index 000000000000..e5877fed1e05 --- /dev/null +++ b/packages/app/src/components/session-todo-dock.tsx @@ -0,0 +1,85 @@ +import type { Todo } from "@opencode-ai/sdk/v2" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { For, Show, createMemo } from "solid-js" +import { createStore } from "solid-js/store" + +function color(status: string) { + if (status === "completed") return "var(--icon-success-base)" + if (status === "in_progress") return "var(--icon-info-base)" + if (status === "cancelled") return "var(--icon-critical-base)" + return "var(--icon-weaker)" +} + +export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseLabel: string; expandLabel: string }) { + const [store, setStore] = createStore({ + collapsed: false, + }) + + const progress = createMemo(() => { + const total = props.todos.length + if (total === 0) return "" + const completed = props.todos.filter((todo) => todo.status === "completed").length + return `${completed}/${total}` + }) + + const preview = createMemo(() => { + const active = + props.todos.find((todo) => todo.status === "in_progress") ?? + props.todos.find((todo) => todo.status === "pending") ?? + props.todos[0] + if (!active) return "" + return active.content + }) + + return ( +
+
+ {props.title} + + {progress()} + +
+ event.preventDefault()} + onClick={() => setStore("collapsed", (value) => !value)} + aria-label={store.collapsed ? props.expandLabel : props.collapseLabel} + /> +
+
+ + }> +
{preview()}
+
+
+ ) +} + +function TodoList(props: { todos: Todo[] }) { + return ( +
+ + {(todo) => ( +
+ + ● + + + {todo.content} + +
+ )} +
+
+ ) +} diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 62c7eb66ec9c..b2d03de962eb 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -4,6 +4,7 @@ import { type Project, type ProviderAuthResponse, type ProviderListResponse, + type Todo, createOpencodeClient, } from "@opencode-ai/sdk/v2/client" import { createStore, produce, reconcile } from "solid-js/store" @@ -41,6 +42,9 @@ type GlobalStore = { error?: InitError path: Path project: Project[] + session_todo: { + [sessionID: string]: Todo[] + } provider: ProviderListResponse provider_auth: ProviderAuthResponse config: Config @@ -87,12 +91,27 @@ function createGlobalSync() { ready: false, path: { state: "", config: "", worktree: "", directory: "", home: "" }, project: projectCache.value, + session_todo: {}, provider: { all: [], connected: [], default: {} }, provider_auth: {}, config: {}, reload: undefined, }) + const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => { + if (!sessionID) return + if (!todos) { + setGlobalStore( + "session_todo", + produce((draft) => { + delete draft[sessionID] + }), + ) + return + } + setGlobalStore("session_todo", sessionID, reconcile(todos, { key: "id" })) + } + const updateStats = (activeDirectoryStores: number) => { if (!import.meta.env.DEV) return setDevStats({ @@ -283,6 +302,7 @@ function createGlobalSync() { store, setStore, push: queue.push, + setSessionTodo, vcsCache: children.vcsCache.get(directory), loadLsp: () => { sdkFor(directory) @@ -353,6 +373,9 @@ function createGlobalSync() { bootstrap, updateConfig, project: projectApi, + todo: { + set: setSessionTodo, + }, } } diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 2137a19a823e..478bc02f57bc 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -6,6 +6,7 @@ import { type ProviderAuthResponse, type ProviderListResponse, type QuestionRequest, + type Todo, createOpencodeClient, } from "@opencode-ai/sdk/v2/client" import { batch } from "solid-js" @@ -20,6 +21,9 @@ type GlobalStore = { ready: boolean path: Path project: Project[] + session_todo: { + [sessionID: string]: Todo[] + } provider: ProviderListResponse provider_auth: ProviderAuthResponse config: Config diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 66fcac66d560..e15d71a05cee 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -39,7 +39,12 @@ export function applyGlobalEvent(input: { }) } -function cleanupSessionCaches(store: Store, setStore: SetStoreFunction, sessionID: string) { +function cleanupSessionCaches( + store: Store, + setStore: SetStoreFunction, + sessionID: string, + setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void, +) { if (!sessionID) return const hasAny = store.message[sessionID] !== undefined || @@ -48,6 +53,7 @@ function cleanupSessionCaches(store: Store, setStore: SetStoreFunction { @@ -77,6 +83,7 @@ export function applyDirectoryEvent(input: { directory: string loadLsp: () => void vcsCache?: VcsCache + setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void }) { const event = input.event switch (event.type) { @@ -110,7 +117,7 @@ export function applyDirectoryEvent(input: { }), ) } - cleanupSessionCaches(input.store, input.setStore, info.id) + cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo) if (info.parentID) break input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) break @@ -136,7 +143,7 @@ export function applyDirectoryEvent(input: { }), ) } - cleanupSessionCaches(input.store, input.setStore, info.id) + cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo) if (info.parentID) break input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) break @@ -149,6 +156,7 @@ export function applyDirectoryEvent(input: { case "todo.updated": { const props = event.properties as { sessionID: string; todos: Todo[] } input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" })) + input.setSessionTodo?.(props.sessionID, props.todos) break } case "session.status": { diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index e5916598b52d..24d8e843f1f9 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -289,12 +289,26 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const directory = sdk.directory const client = sdk.client const [store, setStore] = globalSync.child(directory) - if (store.todo[sessionID] !== undefined) return + const existing = store.todo[sessionID] + if (existing !== undefined) { + if (globalSync.data.session_todo[sessionID] === undefined) { + globalSync.todo.set(sessionID, existing) + } + return + } + + const cached = globalSync.data.session_todo[sessionID] + if (cached !== undefined) { + setStore("todo", sessionID, reconcile(cached, { key: "id" })) + return + } const key = keyFor(directory, sessionID) return runInflight(inflightTodo, key, () => retry(() => client.session.todo({ sessionID })).then((todo) => { - setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" })) + const list = todo.data ?? [] + setStore("todo", sessionID, reconcile(list, { key: "id" })) + globalSync.todo.set(sessionID, list) }), ) }, diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index cb42b016f1fb..682707921a4c 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -504,6 +504,9 @@ export const dict = { "session.messages.jumpToLatest": "Jump to latest", "session.context.addToContext": "Add {{selection}} to context", + "session.todo.title": "Todos", + "session.todo.collapse": "Collapse", + "session.todo.expand": "Expand", "session.new.worktree.main": "Main branch", "session.new.worktree.mainWithBranch": "Main branch ({{branch}})", diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 8bca1bff61fa..791cb7bf2004 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -20,6 +20,7 @@ import { Mark } from "@opencode-ai/ui/logo" import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd" import { useSync } from "@/context/sync" +import { useGlobalSync } from "@/context/global-sync" import { useTerminal, type LocalPTY } from "@/context/terminal" import { useLayout } from "@/context/layout" import { checksum, base64Encode } from "@opencode-ai/util/encode" @@ -91,6 +92,7 @@ export default function Page() { const local = useLocal() const file = useFile() const sync = useSync() + const globalSync = useGlobalSync() const terminal = useTerminal() const dialog = useDialog() const codeComponent = useCodeComponent() @@ -674,7 +676,8 @@ export default function Page() { sdk.directory const id = params.id if (!id) return - sync.session.sync(id) + void sync.session.sync(id) + void sync.session.todo(id) }) createEffect(() => { @@ -727,6 +730,11 @@ export default function Page() { ) const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle) + const todos = createMemo(() => { + const id = params.id + if (!id) return [] + return globalSync.data.session_todo[id] ?? [] + }) createEffect( on( @@ -1668,6 +1676,7 @@ export default function Page() { questionRequest={questionRequest} permissionRequest={permRequest} blocked={blocked()} + todos={todos()} promptReady={prompt.ready()} handoffPrompt={handoff.session.get(sessionKey())?.prompt} t={language.t as (key: string, vars?: Record) => string} diff --git a/packages/app/src/pages/session/session-prompt-dock.tsx b/packages/app/src/pages/session/session-prompt-dock.tsx index 8ec4f3b9f8c5..e3a08e8fa3ef 100644 --- a/packages/app/src/pages/session/session-prompt-dock.tsx +++ b/packages/app/src/pages/session/session-prompt-dock.tsx @@ -1,9 +1,10 @@ import { For, Show } from "solid-js" -import type { QuestionRequest } from "@opencode-ai/sdk/v2" +import type { QuestionRequest, Todo } from "@opencode-ai/sdk/v2" import { Button } from "@opencode-ai/ui/button" import { BasicTool } from "@opencode-ai/ui/basic-tool" import { PromptInput } from "@/components/prompt-input" import { QuestionDock } from "@/components/question-dock" +import { SessionTodoDock } from "@/components/session-todo-dock" import { questionSubtitle } from "@/pages/session/session-prompt-helpers" export function SessionPromptDock(props: { @@ -11,6 +12,7 @@ export function SessionPromptDock(props: { questionRequest: () => QuestionRequest | undefined permissionRequest: () => { patterns: string[]; permission: string } | undefined blocked: boolean + todos: Todo[] promptReady: boolean handoffPrompt?: string t: (key: string, vars?: Record) => string @@ -122,6 +124,14 @@ export function SessionPromptDock(props: {
} > + 0}> + + { const next = data.store.permission?.[props.message.sessionID]?.[0] @@ -1020,11 +1021,6 @@ ToolRegistry.register({ if (typeof value !== "string") return "" return value }) - const format = createMemo(() => { - const value = props.input.format - if (typeof value !== "string") return "" - return value - }) return (
- - format={format()} -
From f1f4e0f52660410d012dea6e4a96a2c3fe833977 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 12 Feb 2026 15:44:02 -0600 Subject: [PATCH 029/121] wip(app): timeline changes --- packages/ui/src/components/message-part.tsx | 1 + packages/ui/src/components/session-turn.tsx | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index d805e428c864..134d029bd4d9 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -694,6 +694,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { const i18n = useI18n() const part = props.part as ToolPart if (part.tool === "todowrite" || part.tool === "todoread") return null + if (part.tool === "question" && (part.state.status === "pending" || part.state.status === "running")) return null const permission = createMemo(() => { const next = data.store.permission?.[props.message.sessionID]?.[0] diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 1e347dc8fd0d..13f6f90c5d9f 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -72,8 +72,10 @@ function list(value: T[] | undefined | null, fallback: T[]) { return fallback } +const hidden = new Set(["todowrite", "todoread"]) + function visible(part: PartType) { - if (part.type === "tool") return true + if (part.type === "tool") return !hidden.has(part.tool) if (part.type === "text") return !!part.text?.trim() if (part.type === "reasoning") return !!part.text?.trim() return false From 6e73ca2ab4abf942c505d14a18da080a386ae27a Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 12 Feb 2026 15:46:08 -0600 Subject: [PATCH 030/121] wip(app): timeline changes --- packages/ui/src/components/message-part.tsx | 123 ++++++++++---------- packages/ui/src/components/session-turn.tsx | 6 +- 2 files changed, 69 insertions(+), 60 deletions(-) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 134d029bd4d9..7fc70982d424 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -694,7 +694,10 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { const i18n = useI18n() const part = props.part as ToolPart if (part.tool === "todowrite" || part.tool === "todoread") return null - if (part.tool === "question" && (part.state.status === "pending" || part.state.status === "running")) return null + + const hideQuestion = createMemo( + () => part.tool === "question" && (part.state.status === "pending" || part.state.status === "running"), + ) const permission = createMemo(() => { const next = data.store.permission?.[props.message.sessionID]?.[0] @@ -763,65 +766,67 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { const render = ToolRegistry.render(part.tool) ?? GenericTool return ( -
- - - {(error) => { - const cleaned = error().replace("Error: ", "") - const [title, ...rest] = cleaned.split(": ") - return ( - -
- - - -
-
{title}
- {rest.join(": ")} -
-
- - {cleaned} - -
-
-
- ) - }} -
- - - -
- -
-
- - - + +
+ + + {(error) => { + const cleaned = error().replace("Error: ", "") + const [title, ...rest] = cleaned.split(": ") + return ( + +
+ + + +
+
{title}
+ {rest.join(": ")} +
+
+ + {cleaned} + +
+
+
+ ) + }} +
+ + + +
+ +
+
+ + + +
-
-
- {(request) => } -
+ + {(request) => } +
+
) } diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 13f6f90c5d9f..cf3fe43c1cf4 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -75,7 +75,11 @@ function list(value: T[] | undefined | null, fallback: T[]) { const hidden = new Set(["todowrite", "todoread"]) function visible(part: PartType) { - if (part.type === "tool") return !hidden.has(part.tool) + if (part.type === "tool") { + if (hidden.has(part.tool)) return false + if (part.tool === "question") return part.state.status !== "pending" && part.state.status !== "running" + return true + } if (part.type === "text") return !!part.text?.trim() if (part.type === "reasoning") return !!part.text?.trim() return false From f8b39de5446b48a553bfeb2526904edaabf9a0cb Mon Sep 17 00:00:00 2001 From: David Hill Date: Thu, 12 Feb 2026 23:26:28 +0000 Subject: [PATCH 031/121] fix(app): only show prompt suggestion on empty session Show example placeholder only until the first user message in the current session; afterwards use a simple 'Ask anything...' placeholder and stop rotating examples. --- packages/app/src/components/prompt-input.tsx | 29 ++++++++++++++----- .../prompt-input/placeholder.test.ts | 19 ++++++++++-- .../components/prompt-input/placeholder.ts | 2 ++ packages/app/src/i18n/ar.ts | 1 + packages/app/src/i18n/br.ts | 1 + packages/app/src/i18n/bs.ts | 1 + packages/app/src/i18n/da.ts | 1 + packages/app/src/i18n/de.ts | 1 + packages/app/src/i18n/en.ts | 1 + packages/app/src/i18n/es.ts | 1 + packages/app/src/i18n/fr.ts | 1 + packages/app/src/i18n/ja.ts | 1 + packages/app/src/i18n/ko.ts | 1 + packages/app/src/i18n/no.ts | 1 + packages/app/src/i18n/pl.ts | 1 + packages/app/src/i18n/ru.ts | 1 + packages/app/src/i18n/th.ts | 1 + packages/app/src/i18n/zh.ts | 1 + packages/app/src/i18n/zht.ts | 1 + 19 files changed, 55 insertions(+), 11 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index e21798738175..94f3c926fb07 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -223,14 +223,14 @@ export const PromptInput: Component = (props) => { mode: "normal", applyingHistory: false, }) - const placeholder = createMemo(() => - promptPlaceholder({ - mode: store.mode, - commentCount: commentCount(), - example: language.t(EXAMPLES[store.placeholder]), - t: (key, params) => language.t(key as Parameters[0], params as never), - }), - ) + + const hasUserPrompt = createMemo(() => { + const sessionID = params.id + if (!sessionID) return false + const messages = sync.data.message[sessionID] + if (!messages) return false + return messages.some((m) => m.role === "user") + }) const MAX_HISTORY = 100 const [history, setHistory] = persisted( @@ -250,6 +250,18 @@ export const PromptInput: Component = (props) => { }), ) + const suggest = createMemo(() => !hasUserPrompt()) + + const placeholder = createMemo(() => + promptPlaceholder({ + mode: store.mode, + commentCount: commentCount(), + example: suggest() ? language.t(EXAMPLES[store.placeholder]) : "", + suggest: suggest(), + t: (key, params) => language.t(key as Parameters[0], params as never), + }), + ) + const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => { const length = position === "start" ? 0 : promptLength(p) setStore("applyingHistory", true) @@ -326,6 +338,7 @@ export const PromptInput: Component = (props) => { createEffect(() => { params.id if (params.id) return + if (!suggest()) return const interval = setInterval(() => { setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length) }, 6500) diff --git a/packages/app/src/components/prompt-input/placeholder.test.ts b/packages/app/src/components/prompt-input/placeholder.test.ts index b633df829561..5f6aa59e9a41 100644 --- a/packages/app/src/components/prompt-input/placeholder.test.ts +++ b/packages/app/src/components/prompt-input/placeholder.test.ts @@ -9,27 +9,40 @@ describe("promptPlaceholder", () => { mode: "shell", commentCount: 0, example: "example", + suggest: true, t, }) expect(value).toBe("prompt.placeholder.shell") }) test("returns summarize placeholders for comment context", () => { - expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", t })).toBe( + expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", suggest: true, t })).toBe( "prompt.placeholder.summarizeComment", ) - expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", t })).toBe( + expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", suggest: true, t })).toBe( "prompt.placeholder.summarizeComments", ) }) - test("returns default placeholder with example", () => { + test("returns default placeholder with example when suggestions enabled", () => { const value = promptPlaceholder({ mode: "normal", commentCount: 0, example: "translated-example", + suggest: true, t, }) expect(value).toBe("prompt.placeholder.normal:translated-example") }) + + test("returns simple placeholder when suggestions disabled", () => { + const value = promptPlaceholder({ + mode: "normal", + commentCount: 0, + example: "translated-example", + suggest: false, + t, + }) + expect(value).toBe("prompt.placeholder.simple") + }) }) diff --git a/packages/app/src/components/prompt-input/placeholder.ts b/packages/app/src/components/prompt-input/placeholder.ts index 07f6a43b510f..395fee51b1c1 100644 --- a/packages/app/src/components/prompt-input/placeholder.ts +++ b/packages/app/src/components/prompt-input/placeholder.ts @@ -2,6 +2,7 @@ type PromptPlaceholderInput = { mode: "normal" | "shell" commentCount: number example: string + suggest: boolean t: (key: string, params?: Record) => string } @@ -9,5 +10,6 @@ export function promptPlaceholder(input: PromptPlaceholderInput) { if (input.mode === "shell") return input.t("prompt.placeholder.shell") if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments") if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment") + if (!input.suggest) return input.t("prompt.placeholder.simple") return input.t("prompt.placeholder.normal", { example: input.example }) } diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 3d347c8423cb..53e494ca3040 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -206,6 +206,7 @@ export const dict = { "common.attachment": "مرفق", "prompt.placeholder.shell": "أدخل أمر shell...", "prompt.placeholder.normal": 'اسأل أي شيء... "{{example}}"', + "prompt.placeholder.simple": "اسأل أي شيء...", "prompt.placeholder.summarizeComments": "لخّص التعليقات…", "prompt.placeholder.summarizeComment": "لخّص التعليق…", "prompt.mode.shell": "Shell", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 730c01fdfffb..caffa9b80e5c 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -206,6 +206,7 @@ export const dict = { "common.attachment": "anexo", "prompt.placeholder.shell": "Digite comando do shell...", "prompt.placeholder.normal": 'Pergunte qualquer coisa... "{{example}}"', + "prompt.placeholder.simple": "Pergunte qualquer coisa...", "prompt.placeholder.summarizeComments": "Resumir comentários…", "prompt.placeholder.summarizeComment": "Resumir comentário…", "prompt.mode.shell": "Shell", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index d53c261126b7..4adf38ce387e 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -224,6 +224,7 @@ export const dict = { "prompt.placeholder.shell": "Unesi shell naredbu...", "prompt.placeholder.normal": 'Pitaj bilo šta... "{{example}}"', + "prompt.placeholder.simple": "Pitaj bilo šta...", "prompt.placeholder.summarizeComments": "Sažmi komentare…", "prompt.placeholder.summarizeComment": "Sažmi komentar…", "prompt.mode.shell": "Shell", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 9faa14d3da4b..0013385476a6 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -222,6 +222,7 @@ export const dict = { "prompt.placeholder.shell": "Indtast shell-kommando...", "prompt.placeholder.normal": 'Spørg om hvad som helst... "{{example}}"', + "prompt.placeholder.simple": "Spørg om hvad som helst...", "prompt.placeholder.summarizeComments": "Opsummér kommentarer…", "prompt.placeholder.summarizeComment": "Opsummér kommentar…", "prompt.mode.shell": "Shell", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index d350af6cf55f..76786e498546 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -211,6 +211,7 @@ export const dict = { "common.attachment": "Anhang", "prompt.placeholder.shell": "Shell-Befehl eingeben...", "prompt.placeholder.normal": 'Fragen Sie alles... "{{example}}"', + "prompt.placeholder.simple": "Fragen Sie alles...", "prompt.placeholder.summarizeComments": "Kommentare zusammenfassen…", "prompt.placeholder.summarizeComment": "Kommentar zusammenfassen…", "prompt.mode.shell": "Shell", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 682707921a4c..8d94e6a1ab24 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -224,6 +224,7 @@ export const dict = { "prompt.placeholder.shell": "Enter shell command...", "prompt.placeholder.normal": 'Ask anything... "{{example}}"', + "prompt.placeholder.simple": "Ask anything...", "prompt.placeholder.summarizeComments": "Summarize comments…", "prompt.placeholder.summarizeComment": "Summarize comment…", "prompt.mode.shell": "Shell", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index c4ec378dcdd4..498cefac7299 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -223,6 +223,7 @@ export const dict = { "prompt.placeholder.shell": "Introduce comando de shell...", "prompt.placeholder.normal": 'Pregunta cualquier cosa... "{{example}}"', + "prompt.placeholder.simple": "Pregunta cualquier cosa...", "prompt.placeholder.summarizeComments": "Resumir comentarios…", "prompt.placeholder.summarizeComment": "Resumir comentario…", "prompt.mode.shell": "Shell", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 7069fbd98fe1..543b2115e9d0 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -206,6 +206,7 @@ export const dict = { "common.attachment": "pièce jointe", "prompt.placeholder.shell": "Entrez une commande shell...", "prompt.placeholder.normal": 'Demandez n\'importe quoi... "{{example}}"', + "prompt.placeholder.simple": "Demandez n'importe quoi...", "prompt.placeholder.summarizeComments": "Résumer les commentaires…", "prompt.placeholder.summarizeComment": "Résumer le commentaire…", "prompt.mode.shell": "Shell", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index e7e24a9bd68f..afa351565272 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -205,6 +205,7 @@ export const dict = { "common.attachment": "添付ファイル", "prompt.placeholder.shell": "シェルコマンドを入力...", "prompt.placeholder.normal": '何でも聞いてください... "{{example}}"', + "prompt.placeholder.simple": "何でも聞いてください...", "prompt.placeholder.summarizeComments": "コメントを要約…", "prompt.placeholder.summarizeComment": "コメントを要約…", "prompt.mode.shell": "シェル", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 650b7e662a36..2c0402c7b537 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -209,6 +209,7 @@ export const dict = { "common.attachment": "첨부 파일", "prompt.placeholder.shell": "셸 명령어 입력...", "prompt.placeholder.normal": '무엇이든 물어보세요... "{{example}}"', + "prompt.placeholder.simple": "무엇이든 물어보세요...", "prompt.placeholder.summarizeComments": "댓글 요약…", "prompt.placeholder.summarizeComment": "댓글 요약…", "prompt.mode.shell": "셸", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index afc162ab1765..b6f0091542ca 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -226,6 +226,7 @@ export const dict = { "prompt.placeholder.shell": "Skriv inn shell-kommando...", "prompt.placeholder.normal": 'Spør om hva som helst... "{{example}}"', + "prompt.placeholder.simple": "Spør om hva som helst...", "prompt.placeholder.summarizeComments": "Oppsummer kommentarer…", "prompt.placeholder.summarizeComment": "Oppsummer kommentar…", "prompt.mode.shell": "Shell", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index d8572148a896..570b0c281622 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -207,6 +207,7 @@ export const dict = { "common.attachment": "załącznik", "prompt.placeholder.shell": "Wpisz polecenie terminala...", "prompt.placeholder.normal": 'Zapytaj o cokolwiek... "{{example}}"', + "prompt.placeholder.simple": "Zapytaj o cokolwiek...", "prompt.placeholder.summarizeComments": "Podsumuj komentarze…", "prompt.placeholder.summarizeComment": "Podsumuj komentarz…", "prompt.mode.shell": "Terminal", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 86d201cebcab..5df6c306a13a 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -223,6 +223,7 @@ export const dict = { "prompt.placeholder.shell": "Введите команду оболочки...", "prompt.placeholder.normal": 'Спросите что угодно... "{{example}}"', + "prompt.placeholder.simple": "Спросите что угодно...", "prompt.placeholder.summarizeComments": "Суммировать комментарии…", "prompt.placeholder.summarizeComment": "Суммировать комментарий…", "prompt.mode.shell": "Оболочка", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 83020bf8c07b..60f854edeec0 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -223,6 +223,7 @@ export const dict = { "prompt.placeholder.shell": "ป้อนคำสั่งเชลล์...", "prompt.placeholder.normal": 'ถามอะไรก็ได้... "{{example}}"', + "prompt.placeholder.simple": "ถามอะไรก็ได้...", "prompt.placeholder.summarizeComments": "สรุปความคิดเห็น…", "prompt.placeholder.summarizeComment": "สรุปความคิดเห็น…", "prompt.mode.shell": "เชลล์", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index d0bf86cbba6d..22dea05ed4ed 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -244,6 +244,7 @@ export const dict = { "prompt.placeholder.shell": "输入 shell 命令...", "prompt.placeholder.normal": '随便问点什么... "{{example}}"', + "prompt.placeholder.simple": "随便问点什么...", "prompt.placeholder.summarizeComments": "总结评论…", "prompt.placeholder.summarizeComment": "总结该评论…", "prompt.mode.shell": "Shell", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 349c90b0e111..cd0ec77e4a72 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -223,6 +223,7 @@ export const dict = { "prompt.placeholder.shell": "輸入 shell 命令...", "prompt.placeholder.normal": '隨便問點什麼... "{{example}}"', + "prompt.placeholder.simple": "隨便問點什麼...", "prompt.placeholder.summarizeComments": "摘要評論…", "prompt.placeholder.summarizeComment": "摘要這則評論…", "prompt.mode.shell": "Shell", From 0a1c78a10e6fd76f7391be5f5d41130374e1c7dd Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 13 Feb 2026 00:32:28 +0000 Subject: [PATCH 032/121] tweak(app): polish session feed indicators --- .../app/src/components/session-todo-dock.tsx | 52 +++++++++++--- packages/ui/src/components/basic-tool.tsx | 13 ++-- packages/ui/src/components/message-part.css | 21 +++++- packages/ui/src/components/message-part.tsx | 68 ++++++++++--------- packages/ui/src/components/session-turn.tsx | 5 +- 5 files changed, 102 insertions(+), 57 deletions(-) diff --git a/packages/app/src/components/session-todo-dock.tsx b/packages/app/src/components/session-todo-dock.tsx index e5877fed1e05..d4ef6ac6cc25 100644 --- a/packages/app/src/components/session-todo-dock.tsx +++ b/packages/app/src/components/session-todo-dock.tsx @@ -1,13 +1,40 @@ import type { Todo } from "@opencode-ai/sdk/v2" +import { Checkbox } from "@opencode-ai/ui/checkbox" import { IconButton } from "@opencode-ai/ui/icon-button" import { For, Show, createMemo } from "solid-js" import { createStore } from "solid-js/store" -function color(status: string) { - if (status === "completed") return "var(--icon-success-base)" - if (status === "in_progress") return "var(--icon-info-base)" - if (status === "cancelled") return "var(--icon-critical-base)" - return "var(--icon-weaker)" +function vars(status: Todo["status"]) { + if (status === "completed") { + return { + "--border-base": "var(--border-success-base)", + "--icon-base": "var(--icon-success-base)", + } + } + + if (status === "in_progress") { + return { + "--border-base": "var(--border-info-base)", + "--icon-base": "var(--icon-info-base)", + } + } + + if (status === "cancelled") { + return { + "--border-weak-base": "var(--border-critical-base)", + } + } + + return {} +} + +function icon(status: Todo["status"]) { + if (status !== "in_progress") return undefined + return ( + + + + ) } export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseLabel: string; expandLabel: string }) { @@ -63,12 +90,15 @@ function TodoList(props: { todos: Todo[] }) {
{(todo) => ( -
- - ● - + {todo.content} -
+ )}
diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index 2fd9746d062f..5cc4367a6881 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -1,7 +1,7 @@ import { createEffect, createSignal, For, Match, Show, Switch, type JSX } from "solid-js" import { Collapsible } from "./collapsible" -import { Icon, IconProps } from "./icon" -import { Spinner } from "./spinner" +import type { IconProps } from "./icon" +import { TextShimmer } from "./text-shimmer" export type TriggerTitle = { title: string @@ -62,13 +62,10 @@ export function BasicTool(props: BasicToolProps) { [trigger().titleClass ?? ""]: !!trigger().titleClass, }} > - {trigger().title} + + + - - - - - part.state.status === "pending" || part.state.status === "running"), ) const summary = createMemo(() => contextToolSummary(props.parts)) - const details = createMemo(() => { - const items = summary() - if (items.length === 0) return "" - return `: ${items.join(", ")}` - }) + const details = createMemo(() => summary().join(", ")) return ( @@ -441,11 +436,22 @@ function ContextToolGroup(props: { parts: ToolPart[] }) {
Gathered context{details()}} + fallback={ + + Gathered context + + {details()} + + + } > - - {details()} + + + + + {details()} + @@ -464,12 +470,11 @@ function ContextToolGroup(props: { parts: ToolPart[] }) {
- {trigger.title} - - - - - + + + + + {trigger.subtitle} @@ -1035,12 +1040,11 @@ ToolRegistry.register({ trigger={
- {i18n.t("ui.tool.webfetch")} - - - - - + + + + +
- {i18n.t("ui.messagePart.title.edit")} - - - - - + + + + + {filename()} @@ -1283,12 +1286,11 @@ ToolRegistry.register({
- {i18n.t("ui.messagePart.title.write")} - - - - - + + + + + {filename()} diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index cf3fe43c1cf4..468c97d24f0b 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -5,7 +5,7 @@ import { Binary } from "@opencode-ai/util/binary" import { createMemo, For, ParentProps, Show } from "solid-js" import { Message } from "./message-part" import { Card } from "./card" -import { Spinner } from "./spinner" +import { TextShimmer } from "./text-shimmer" import { createAutoScroll } from "../hooks" function record(value: unknown): value is Record { @@ -241,8 +241,7 @@ export function SessionTurn(
- Thinking - +
0}> From c7be547caa0dc876377748f5a711cc550ab27215 Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 13 Feb 2026 00:33:24 +0000 Subject: [PATCH 033/121] tweak(ui): set thinking text to 14px --- packages/ui/src/components/session-turn.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index 1f49899690d0..cc41b74a407a 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -43,7 +43,7 @@ gap: 8px; color: var(--text-weak); font-family: var(--font-family-sans); - font-size: var(--font-size-small); + font-size: var(--font-size-base); font-weight: var(--font-weight-medium); line-height: var(--line-height-large); min-height: 20px; From 170e92fe4a888b07517091c2fa0b39672cac0de8 Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 13 Feb 2026 00:45:19 +0000 Subject: [PATCH 034/121] fix(ui): only show response copy on finished last turn --- packages/ui/src/components/message-part.tsx | 9 +++++---- packages/ui/src/components/session-turn.tsx | 10 ++++++++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index d6bf64a07252..9d2201585cd6 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -91,7 +91,7 @@ function DiagnosticsDisplay(props: { diagnostics: Diagnostic[] }): JSX.Element { export interface MessageProps { message: MessageType parts: PartType[] - showAssistantCopyPartID?: string + showAssistantCopyPartID?: string | null } export interface MessagePartProps { @@ -99,7 +99,7 @@ export interface MessagePartProps { message: MessageType hideDetails?: boolean defaultOpen?: boolean - showAssistantCopyPartID?: string + showAssistantCopyPartID?: string | null } export type PartComponent = Component @@ -354,7 +354,7 @@ export function Message(props: MessageProps) { export function AssistantMessageDisplay(props: { message: AssistantMessage parts: PartType[] - showAssistantCopyPartID?: string + showAssistantCopyPartID?: string | null }) { const grouped = createMemo(() => { const keys: string[] = [] @@ -849,7 +849,8 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { }) const showCopy = createMemo(() => { if (props.message.role !== "assistant") return isLastTextPart() - if (props.showAssistantCopyPartID) return props.showAssistantCopyPartID === part.id + if (props.showAssistantCopyPartID === null) return false + if (typeof props.showAssistantCopyPartID === "string") return props.showAssistantCopyPartID === part.id return isLastTextPart() }) const [copied, setCopied] = createSignal(false) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 468c97d24f0b..efd020081f70 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -85,7 +85,7 @@ function visible(part: PartType) { return false } -function AssistantMessageItem(props: { message: AssistantMessage; showAssistantCopyPartID?: string }) { +function AssistantMessageItem(props: { message: AssistantMessage; showAssistantCopyPartID?: string | null }) { const data = useData() const emptyParts: PartType[] = [] const msgParts = createMemo(() => list(data.store.part?.[props.message.id], emptyParts)) @@ -206,6 +206,12 @@ export function SessionTurn( const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle) const working = createMemo(() => status().type !== "idle" && isLastUserMessage()) + + const assistantCopyPartID = createMemo(() => { + if (!isLastUserMessage()) return null + if (status().type !== "idle") return null + return showAssistantCopyPartID() ?? null + }) const assistantVisible = createMemo(() => assistantMessages().reduce((count, message) => { const parts = list(data.store.part?.[message.id], emptyParts) @@ -250,7 +256,7 @@ export function SessionTurn( {(assistantMessage) => ( )} From f6897987c60d6c3d8823b49f9ffde0075bb9de4c Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 13 Feb 2026 01:28:17 +0000 Subject: [PATCH 035/121] tweak(app): match pinned todos styling to prompt input --- .../app/src/components/session-todo-dock.tsx | 67 +++++++++---------- .../src/pages/session/session-prompt-dock.tsx | 14 ++-- 2 files changed, 38 insertions(+), 43 deletions(-) diff --git a/packages/app/src/components/session-todo-dock.tsx b/packages/app/src/components/session-todo-dock.tsx index d4ef6ac6cc25..1a25bf57fc1d 100644 --- a/packages/app/src/components/session-todo-dock.tsx +++ b/packages/app/src/components/session-todo-dock.tsx @@ -4,31 +4,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { For, Show, createMemo } from "solid-js" import { createStore } from "solid-js/store" -function vars(status: Todo["status"]) { - if (status === "completed") { - return { - "--border-base": "var(--border-success-base)", - "--icon-base": "var(--icon-success-base)", - } - } - - if (status === "in_progress") { - return { - "--border-base": "var(--border-info-base)", - "--icon-base": "var(--icon-info-base)", - } - } - - if (status === "cancelled") { - return { - "--border-weak-base": "var(--border-critical-base)", - } - } - - return {} -} - -function icon(status: Todo["status"]) { +function dot(status: Todo["status"]) { if (status !== "in_progress") return undefined return ( @@ -42,6 +18,8 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL collapsed: false, }) + const toggle = () => setStore("collapsed", (value) => !value) + const progress = createMemo(() => { const total = props.todos.length if (total === 0) return "" @@ -59,11 +37,21 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL }) return ( -
-
- {props.title} +
+
{ + if (event.key !== "Enter" && event.key !== " ") return + event.preventDefault() + toggle() + }} + > + {props.title} - {progress()} + {progress()}
event.preventDefault()} - onClick={() => setStore("collapsed", (value) => !value)} + onMouseDown={(event) => { + event.preventDefault() + event.stopPropagation() + }} + onClick={(event) => { + event.stopPropagation() + toggle() + }} aria-label={store.collapsed ? props.expandLabel : props.collapseLabel} />
}> -
{preview()}
+
{preview()}
) @@ -87,20 +81,19 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL function TodoList(props: { todos: Todo[] }) { return ( -
+
{(todo) => ( - +
+ +
From a4ed41a684081114cc1a72651795f7f332aef5f3 Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 13 Feb 2026 01:55:34 +0000 Subject: [PATCH 036/121] fix(app): keep jump-to-bottom above prompt tools --- packages/app/src/components/session-todo-dock.tsx | 6 +++--- packages/app/src/components/session/session-new-view.tsx | 2 +- packages/app/src/pages/session.tsx | 9 ++++----- packages/app/src/pages/session/message-timeline.tsx | 4 ++-- packages/app/src/pages/session/session-prompt-dock.tsx | 9 +++++++-- 5 files changed, 17 insertions(+), 13 deletions(-) diff --git a/packages/app/src/components/session-todo-dock.tsx b/packages/app/src/components/session-todo-dock.tsx index 1a25bf57fc1d..f7cdc569ff29 100644 --- a/packages/app/src/components/session-todo-dock.tsx +++ b/packages/app/src/components/session-todo-dock.tsx @@ -37,7 +37,7 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL }) return ( -
+
}> -
{preview()}
+
{preview()}
) @@ -81,7 +81,7 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL function TodoList(props: { todos: Todo[] }) { return ( -
+
{(todo) => ( lastUserMessage()?.summary?.diffs ?? []) @@ -654,6 +653,7 @@ export default function Page() { const idle = { type: "idle" as const } let inputRef!: HTMLDivElement let promptDock: HTMLDivElement | undefined + let dockHeight = 0 let scroller: HTMLDivElement | undefined let content: HTMLDivElement | undefined @@ -1446,12 +1446,12 @@ export default function Page() { ({ height }) => { const next = Math.ceil(height) - if (next === store.promptHeight) return + if (next === dockHeight) return const el = scroller const stick = el ? el.scrollHeight - el.clientHeight - el.scrollTop < 10 : false - setStore("promptHeight", next) + dockHeight = next if (stick && el) { requestAnimationFrame(() => { @@ -1569,7 +1569,6 @@ export default function Page() { }} style={{ width: sessionPanelWidth(), - "--prompt-height": store.promptHeight ? `${store.promptHeight}px` : undefined, }} >
@@ -1581,7 +1580,7 @@ export default function Page() { mobileFallback={reviewContent({ diffStyle: "unified", classes: { - root: "pb-[calc(var(--prompt-height,8rem)+32px)]", + root: "pb-8", header: "px-4", container: "px-4", }, diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 22a21a37aa13..3747b3b543b3 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -98,7 +98,7 @@ export function MessageTimeline(props: { >
-
+
0, + }} + > Date: Thu, 12 Feb 2026 20:43:29 -0600 Subject: [PATCH 037/121] wip(app): timeline changes --- packages/ui/src/components/session-turn.css | 78 +++++++++++++++++++++ packages/ui/src/components/session-turn.tsx | 72 ++++++++++++++++++- 2 files changed, 148 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index cc41b74a407a..6c19852d204b 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -75,4 +75,82 @@ margin-top: 0; } } + + [data-slot="session-turn-diffs"] { + width: 100%; + min-width: 0; + } + + [data-component="session-turn-diffs-trigger"] { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0 4px; + } + + [data-slot="session-turn-diffs-title"] { + color: var(--text-base); + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + } + + [data-slot="session-turn-diffs-meta"] { + display: inline-flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + } + + [data-component="session-turn-diffs-content"] { + padding-top: 8px; + display: flex; + flex-direction: column; + gap: 12px; + } + + [data-component="session-turn-diff"] { + background-color: var(--surface-inset-base); + border: 1px solid var(--border-weaker-base); + border-radius: var(--radius-md); + overflow: clip; + } + + [data-slot="session-turn-diff-header"] { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 6px 10px; + border-bottom: 1px solid var(--border-weaker-base); + } + + [data-slot="session-turn-diff-path"] { + display: inline-flex; + min-width: 0; + align-items: baseline; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + line-height: var(--line-height-large); + } + + [data-slot="session-turn-diff-directory"] { + color: var(--text-weak); + } + + [data-slot="session-turn-diff-filename"] { + color: var(--text-strong); + font-weight: var(--font-weight-medium); + } + + [data-slot="session-turn-diff-view"] { + width: 100%; + min-width: 0; + } } diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index efd020081f70..e21ad4274162 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -1,10 +1,15 @@ -import { AssistantMessage, Message as MessageType, Part as PartType } from "@opencode-ai/sdk/v2/client" +import { AssistantMessage, type FileDiff, Message as MessageType, Part as PartType } from "@opencode-ai/sdk/v2/client" import { useData } from "../context" +import { useDiffComponent } from "../context/diff" import { Binary } from "@opencode-ai/util/binary" -import { createMemo, For, ParentProps, Show } from "solid-js" +import { getDirectory, getFilename } from "@opencode-ai/util/path" +import { createMemo, createSignal, For, ParentProps, Show } from "solid-js" +import { Dynamic } from "solid-js/web" import { Message } from "./message-part" import { Card } from "./card" +import { Collapsible } from "./collapsible" +import { DiffChanges } from "./diff-changes" import { TextShimmer } from "./text-shimmer" import { createAutoScroll } from "../hooks" @@ -106,10 +111,12 @@ export function SessionTurn( }>, ) { const data = useData() + const diffComponent = useDiffComponent() const emptyMessages: MessageType[] = [] const emptyParts: PartType[] = [] const emptyAssistant: AssistantMessage[] = [] + const emptyDiffs: FileDiff[] = [] const idle = { type: "idle" as const } const allMessages = createMemo(() => list(data.store.message?.[props.sessionID], emptyMessages)) @@ -157,6 +164,20 @@ export function SessionTurn( return list(data.store.part?.[msg.id], emptyParts) }) + const diffs = createMemo(() => { + const files = message()?.summary?.diffs + if (!files?.length) return emptyDiffs + + const seen = new Set() + return files.filter((diff) => { + if (seen.has(diff.file)) return false + seen.add(diff.file) + return true + }) + }) + const edited = createMemo(() => diffs().length) + const [open, setOpen] = createSignal(false) + const assistantMessages = createMemo( () => { const msg = message() @@ -262,6 +283,53 @@ export function SessionTurn(
+ 0}> +
+ + +
+ + {edited() === 1 ? "Edited file" : "Edited files"} ({edited()}) + +
+ + +
+
+
+ + +
+ + {(diff) => ( +
+
+ + + {getDirectory(diff.file)} + + {getFilename(diff.file)} + + + + +
+
+ +
+
+ )} +
+
+
+
+
+
+
{errorText()} From e4fcd5a2c5235e0b6f7f9dca45429aec57a6055d Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 12 Feb 2026 20:51:34 -0600 Subject: [PATCH 038/121] wip(app): timeline changes --- packages/app/src/components/session-todo-dock.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/session-todo-dock.tsx b/packages/app/src/components/session-todo-dock.tsx index f7cdc569ff29..6b7b20fff33b 100644 --- a/packages/app/src/components/session-todo-dock.tsx +++ b/packages/app/src/components/session-todo-dock.tsx @@ -72,9 +72,13 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL
- }> -
{preview()}
-
+ + +
) } From 4a61cfef87c6759c805f18a6845105631f4e5eb6 Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 13 Feb 2026 11:03:29 +0000 Subject: [PATCH 039/121] tweak (ui): prompt button size --- packages/app/src/components/prompt-input.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 94f3c926fb07..58d4cba16f84 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1229,7 +1229,7 @@ export const PromptInput: Component = (props) => { disabled={!prompt.dirty() && !working() && commentCount() === 0} icon={working() ? "stop" : "arrow-up"} variant="primary" - class="h-6 w-4.5" + class="size-8" aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")} /> From 67f2f53bc109773ec4792e56fdffbe9a3eefe6aa Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 13 Feb 2026 11:08:16 +0000 Subject: [PATCH 040/121] tweak(app): move context toggle to session header --- packages/app/src/components/prompt-input.tsx | 2 -- packages/app/src/components/session-context-usage.tsx | 10 ++++++++-- packages/app/src/pages/session.tsx | 1 - packages/app/src/pages/session/message-timeline.tsx | 4 +++- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 58d4cba16f84..5b80ffa7cb0c 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -32,7 +32,6 @@ import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid import { useProviders } from "@/hooks/use-providers" import { useCommand } from "@/context/command" import { Persist, persisted } from "@/utils/persist" -import { SessionContextUsage } from "@/components/session-context-usage" import { usePermission } from "@/context/permission" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" @@ -1189,7 +1188,6 @@ export const PromptInput: Component = (props) => { }} />
-
{(id) => ( -
+
+ Date: Fri, 13 Feb 2026 11:13:32 +0000 Subject: [PATCH 041/121] tweak(app): adjust session header spacing --- packages/app/src/pages/session.tsx | 2 +- packages/app/src/pages/session/message-timeline.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 2fa09af104b6..4a821e953134 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1563,7 +1563,7 @@ export default function Page() {
-
+
+

{props.title}

} @@ -194,7 +194,7 @@ export function MessageTimeline(props: { ref={props.titleRef} value={props.titleState.draft} disabled={props.titleState.saving} - class="text-16-medium text-text-strong grow-1 min-w-0" + class="text-14-medium text-text-strong grow-1 min-w-0" onInput={(event) => props.onTitleDraft(event.currentTarget.value)} onKeyDown={(event) => { event.stopPropagation() From 2015fc35f6dac30157c711e32f79ca0cb94d9329 Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 13 Feb 2026 11:17:56 +0000 Subject: [PATCH 042/121] tweak(app): set 14px top-left radius --- packages/app/src/pages/layout.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 7d4a5c0cb81c..aec6041456d9 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1710,7 +1710,7 @@ export default function Layout(props: ParentProps) { return (
}> From 9ac7fe484749042afc71e5e69a144adce9cfd574 Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 13 Feb 2026 11:19:55 +0000 Subject: [PATCH 043/121] tweak(app): reduce sidebar project title size --- packages/app/src/pages/layout.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index aec6041456d9..a849b7166625 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1725,8 +1725,8 @@ export default function Layout(props: ParentProps) { id={`project:${projectId()}`} value={projectName} onSave={(next) => renameProject(p(), next)} - class="text-16-medium text-text-strong truncate" - displayClass="text-16-medium text-text-strong truncate" + class="text-14-medium text-text-strong truncate" + displayClass="text-14-medium text-text-strong truncate" stopPropagation /> From a62c04d2f96360fbd01572432a1f05fe43cf437f Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 13 Feb 2026 11:24:14 +0000 Subject: [PATCH 044/121] tweak(app): align session options menu --- .../src/pages/session/message-timeline.tsx | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 950cf8fcfc34..dc187928205f 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -4,7 +4,6 @@ import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { InlineInput } from "@opencode-ai/ui/inline-input" -import { Tooltip } from "@opencode-ai/ui/tooltip" import { SessionTurn } from "@opencode-ai/ui/session-turn" import type { UserMessage } from "@opencode-ai/sdk/v2" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" @@ -217,18 +216,22 @@ export function MessageTimeline(props: { {(id) => (
- - - - + + { if (!props.titleState.pendingRename) return event.preventDefault() From 78b69c52a16bc803a8486785f0982e2f7e1412f3 Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 13 Feb 2026 11:28:30 +0000 Subject: [PATCH 045/121] tweak(ui): match message copy button sizes --- packages/ui/src/components/message-part.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 9d2201585cd6..85b0ceb2192e 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -586,7 +586,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp > e.preventDefault()} onClick={(event) => { @@ -878,7 +878,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { > e.preventDefault()} onClick={handleCopy} From 062d8c380112df939a3f607730c815e5f3a24934 Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 13 Feb 2026 11:35:27 +0000 Subject: [PATCH 046/121] tweak(ui): restyle edited files indicator --- packages/ui/src/components/diff-changes.css | 3 ++- packages/ui/src/components/diff-changes.tsx | 4 ++-- packages/ui/src/components/session-turn.css | 22 +++++++++++++++++---- packages/ui/src/components/session-turn.tsx | 15 ++++++++------ 4 files changed, 31 insertions(+), 13 deletions(-) diff --git a/packages/ui/src/components/diff-changes.css b/packages/ui/src/components/diff-changes.css index 2ff356284fe6..1d1ed8c9d46c 100644 --- a/packages/ui/src/components/diff-changes.css +++ b/packages/ui/src/components/diff-changes.css @@ -31,11 +31,12 @@ [data-component="diff-changes"][data-variant="bars"] { width: 18px; + height: 14px; flex-shrink: 0; svg { display: block; width: 100%; - height: auto; + height: 100%; } } diff --git a/packages/ui/src/components/diff-changes.tsx b/packages/ui/src/components/diff-changes.tsx index 9e29dbb2b5ed..0794b972869f 100644 --- a/packages/ui/src/components/diff-changes.tsx +++ b/packages/ui/src/components/diff-changes.tsx @@ -96,10 +96,10 @@ export function DiffChanges(props: {
- + - {(color, i) => } + {(color, i) => } diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index 6c19852d204b..0c94011acfa1 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -85,19 +85,33 @@ width: 100%; display: flex; align-items: center; - justify-content: space-between; - gap: 12px; + justify-content: flex-start; + gap: 8px; padding: 0 4px; } [data-slot="session-turn-diffs-title"] { - color: var(--text-base); + display: inline-flex; + align-items: baseline; + gap: 8px; + } + + [data-slot="session-turn-diffs-label"] { + color: var(--text-strong); font-family: var(--font-family-sans); - font-size: var(--font-size-small); + font-size: var(--font-size-base); font-weight: var(--font-weight-medium); line-height: var(--line-height-large); } + [data-slot="session-turn-diffs-count"] { + color: var(--text-base); + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + font-weight: var(--font-weight-regular); + line-height: var(--line-height-x-large); + } + [data-slot="session-turn-diffs-meta"] { display: inline-flex; align-items: center; diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index e21ad4274162..cf0acb976d3b 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -288,12 +288,15 @@ export function SessionTurn(
- - {edited() === 1 ? "Edited file" : "Edited files"} ({edited()}) - -
- - +
+ Edited + + {edited()} {edited() === 1 ? "file" : "files"} + +
+ + +
From 547cc6d076d34a9cfd7558c74c09061272d2f041 Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 13 Feb 2026 11:38:09 +0000 Subject: [PATCH 047/121] tweak(app): use default cursor for context chips --- packages/app/src/components/prompt-input/context-items.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/prompt-input/context-items.tsx b/packages/app/src/components/prompt-input/context-items.tsx index b575c3961110..b138fe3ef690 100644 --- a/packages/app/src/components/prompt-input/context-items.tsx +++ b/packages/app/src/components/prompt-input/context-items.tsx @@ -41,10 +41,9 @@ export const PromptContextItems: Component = (props) => { >
props.openComment(item)} From 8d11464c771bba6decba8722ad7f402175a28bfd Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 13 Feb 2026 11:40:36 +0000 Subject: [PATCH 048/121] tweak(ui): remove diff header background --- packages/ui/src/components/session-turn.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index 0c94011acfa1..153648754acd 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -127,7 +127,6 @@ } [data-component="session-turn-diff"] { - background-color: var(--surface-inset-base); border: 1px solid var(--border-weaker-base); border-radius: var(--radius-md); overflow: clip; @@ -164,6 +163,7 @@ } [data-slot="session-turn-diff-view"] { + background-color: var(--surface-inset-base); width: 100%; min-width: 0; } From 1cd2ff1a8db00518e796f28c0d34fe3787868f85 Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 13 Feb 2026 11:42:10 +0000 Subject: [PATCH 049/121] tweak(ui): widen message part title gap --- packages/ui/src/components/message-part.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index f707a06ebfcb..151b4a5f598e 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -370,7 +370,7 @@ flex-shrink: 0; display: flex; align-items: center; - gap: 4px; + gap: 8px; font-family: var(--font-family-sans); font-size: 14px; font-style: normal; From af1f747314f59ee68968432cb4e86eef406cc85b Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 13 Feb 2026 11:50:21 +0000 Subject: [PATCH 050/121] tweak(app): set 12px radius for prompt and todos --- packages/app/src/components/prompt-input.tsx | 2 +- packages/app/src/components/session-todo-dock.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 5b80ffa7cb0c..a4da560bd15e 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -990,7 +990,7 @@ export const PromptInput: Component = (props) => { classList={{ "group/prompt-input": true, "bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true, - "rounded-[14px] overflow-clip focus-within:shadow-xs-border": true, + "rounded-[12px] overflow-clip focus-within:shadow-xs-border": true, "border-icon-info-active border-dashed": store.draggingType !== null, [props.class ?? ""]: !!props.class, }} diff --git a/packages/app/src/components/session-todo-dock.tsx b/packages/app/src/components/session-todo-dock.tsx index 6b7b20fff33b..f3fe120441d7 100644 --- a/packages/app/src/components/session-todo-dock.tsx +++ b/packages/app/src/components/session-todo-dock.tsx @@ -37,7 +37,7 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL }) return ( -
+
Date: Fri, 13 Feb 2026 11:52:57 +0000 Subject: [PATCH 051/121] tweak(app): update attach button icon and spacing --- packages/app/src/components/prompt-input.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index a4da560bd15e..8a474312a1f8 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1175,7 +1175,7 @@ export const PromptInput: Component = (props) => {
-
+
= (props) => { e.currentTarget.value = "" }} /> -
+
From f74fcdadac2218179120de259bfa1589187dbb81 Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 13 Feb 2026 12:07:32 +0000 Subject: [PATCH 052/121] feat(app): add add-file shortcut in prompt --- packages/app/src/components/prompt-input.tsx | 31 +++++++++++++++++--- packages/app/src/context/command.tsx | 2 +- packages/app/src/i18n/en.ts | 2 +- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 8a474312a1f8..5d50da123637 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -104,7 +104,7 @@ export const PromptInput: Component = (props) => { const language = useLanguage() const platform = usePlatform() let editorRef!: HTMLDivElement - let fileInputRef!: HTMLInputElement + let fileInputRef: HTMLInputElement | undefined let scrollRef!: HTMLDivElement let slashPopoverRef!: HTMLDivElement @@ -293,6 +293,19 @@ export const PromptInput: Component = (props) => { const isFocused = createFocusSignal(() => editorRef) const escBlur = () => platform.platform === "desktop" && platform.os === "macos" + const pick = () => fileInputRef?.click() + + command.register("prompt-input", () => [ + { + id: "file.attach", + title: language.t("prompt.action.attachFile"), + category: language.t("command.category.file"), + keybind: "mod+u", + disabled: store.mode !== "normal", + onSelect: pick, + }, + ]) + const closePopover = () => setStore("popover", null) const resetHistoryNavigation = (force = false) => { @@ -828,6 +841,12 @@ export const PromptInput: Component = (props) => { }) const handleKeyDown = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") { + pick() + event.preventDefault() + return + } + if (event.key === "Backspace") { const selection = window.getSelection() if (selection && selection.isCollapsed) { @@ -1189,17 +1208,21 @@ export const PromptInput: Component = (props) => { />
- + - +
Date: Fri, 13 Feb 2026 12:10:03 +0000 Subject: [PATCH 053/121] tweak(app): use 14px medium for changes dropdown --- packages/app/src/pages/session.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 4a821e953134..bbfedb2abbf5 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -957,7 +957,6 @@ export default function Page() { onSelect={(option) => option && setStore("changes", option)} variant="ghost" size="large" - triggerStyle={{ "font-size": "var(--font-size-large)" }} /> ) From 42fd0e8fd7a735eab65c5a5b60f0353771cf8dff Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 13 Feb 2026 12:18:15 +0000 Subject: [PATCH 054/121] tweak(app): reduce top-left radius to 12px --- packages/app/src/pages/layout.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index a849b7166625..cecb526171d5 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1710,7 +1710,7 @@ export default function Layout(props: ParentProps) { return (
}> From 61119afb4792ce7518c4e36f7ae824b8bf2c2cf8 Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 13 Feb 2026 12:23:17 +0000 Subject: [PATCH 055/121] tweak(app): adjust todo dock header spacing --- packages/app/src/components/session-todo-dock.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/session-todo-dock.tsx b/packages/app/src/components/session-todo-dock.tsx index f3fe120441d7..15dde66feb8e 100644 --- a/packages/app/src/components/session-todo-dock.tsx +++ b/packages/app/src/components/session-todo-dock.tsx @@ -39,7 +39,7 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL return (
{ From cad829837deed9d0e6625072102b3e47b83aefde Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 13 Feb 2026 12:28:00 +0000 Subject: [PATCH 056/121] tweak(app): improve collapsed todo preview --- packages/app/src/components/session-todo-dock.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/session-todo-dock.tsx b/packages/app/src/components/session-todo-dock.tsx index 15dde66feb8e..1693915dbc5d 100644 --- a/packages/app/src/components/session-todo-dock.tsx +++ b/packages/app/src/components/session-todo-dock.tsx @@ -31,6 +31,7 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL const active = props.todos.find((todo) => todo.status === "in_progress") ?? props.todos.find((todo) => todo.status === "pending") ?? + props.todos.filter((todo) => todo.status === "completed").at(-1) ?? props.todos[0] if (!active) return "" return active.content @@ -76,7 +77,7 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL
- From e2a2a235cd4e1fbba5f6a9d646fb286189a2d8e5 Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 13 Feb 2026 12:43:09 +0000 Subject: [PATCH 057/121] tweak(app): move todo preview into header --- .../app/src/components/session-todo-dock.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/app/src/components/session-todo-dock.tsx b/packages/app/src/components/session-todo-dock.tsx index 1693915dbc5d..063791616a67 100644 --- a/packages/app/src/components/session-todo-dock.tsx +++ b/packages/app/src/components/session-todo-dock.tsx @@ -38,7 +38,12 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL }) return ( -
+
{progress()} -
+
+ +
{preview()}
+
+
+
- -
) } From 64b19c681ab7735f9c5898db7711a351401e5695 Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 13 Feb 2026 12:48:22 +0000 Subject: [PATCH 058/121] tweak(app): update todo header summary text --- packages/app/src/components/session-todo-dock.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/app/src/components/session-todo-dock.tsx b/packages/app/src/components/session-todo-dock.tsx index 063791616a67..af7442a52f0f 100644 --- a/packages/app/src/components/session-todo-dock.tsx +++ b/packages/app/src/components/session-todo-dock.tsx @@ -20,11 +20,11 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL const toggle = () => setStore("collapsed", (value) => !value) - const progress = createMemo(() => { + const summary = createMemo(() => { const total = props.todos.length if (total === 0) return "" const completed = props.todos.filter((todo) => todo.status === "completed").length - return `${completed}/${total}` + return `${completed} of ${total} ${props.title.toLowerCase()} completed` }) const preview = createMemo(() => { @@ -55,10 +55,7 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL toggle() }} > - {props.title} - - {progress()} - + {summary()}
{preview()}
From bf746e87be33fa177dcc46553059dca579ce8565 Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 13 Feb 2026 12:49:52 +0000 Subject: [PATCH 059/121] tweak(app): use default cursor for todo header text --- packages/app/src/components/session-todo-dock.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/session-todo-dock.tsx b/packages/app/src/components/session-todo-dock.tsx index af7442a52f0f..21b51db2cf67 100644 --- a/packages/app/src/components/session-todo-dock.tsx +++ b/packages/app/src/components/session-todo-dock.tsx @@ -55,10 +55,10 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL toggle() }} > - {summary()} + {summary()}
-
{preview()}
+
{preview()}
From 343e6f364aa5b6d6aff34e6c19727179ec8cfe38 Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 13 Feb 2026 12:52:21 +0000 Subject: [PATCH 060/121] tweak(app): set collapsed todo height to 78px --- packages/app/src/components/session-todo-dock.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/session-todo-dock.tsx b/packages/app/src/components/session-todo-dock.tsx index 21b51db2cf67..f0c7ddcd21e7 100644 --- a/packages/app/src/components/session-todo-dock.tsx +++ b/packages/app/src/components/session-todo-dock.tsx @@ -41,7 +41,7 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL
Date: Fri, 13 Feb 2026 12:58:35 +0000 Subject: [PATCH 061/121] tweak(app): shimmer collapsed in-progress todo preview --- .../app/src/components/session-todo-dock.tsx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/app/src/components/session-todo-dock.tsx b/packages/app/src/components/session-todo-dock.tsx index f0c7ddcd21e7..cbd3bdd3117d 100644 --- a/packages/app/src/components/session-todo-dock.tsx +++ b/packages/app/src/components/session-todo-dock.tsx @@ -1,6 +1,7 @@ import type { Todo } from "@opencode-ai/sdk/v2" import { Checkbox } from "@opencode-ai/ui/checkbox" import { IconButton } from "@opencode-ai/ui/icon-button" +import { TextShimmer } from "@opencode-ai/ui/text-shimmer" import { For, Show, createMemo } from "solid-js" import { createStore } from "solid-js/store" @@ -27,15 +28,15 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL return `${completed} of ${total} ${props.title.toLowerCase()} completed` }) - const preview = createMemo(() => { - const active = + const active = createMemo( + () => props.todos.find((todo) => todo.status === "in_progress") ?? props.todos.find((todo) => todo.status === "pending") ?? props.todos.filter((todo) => todo.status === "completed").at(-1) ?? - props.todos[0] - if (!active) return "" - return active.content - }) + props.todos[0], + ) + + const preview = createMemo(() => active()?.content ?? "") return (
{summary()}
-
{preview()}
+
+ + + +
From f65ff498a1f38c43c37a4d2da6954784f5a27e62 Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 13 Feb 2026 14:25:34 +0000 Subject: [PATCH 062/121] tweak(app): pulse and shimmer in-progress todos --- .../app/src/components/session-todo-dock.tsx | 29 ++++++++++++++++--- packages/ui/src/styles/animations.css | 11 +++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/session-todo-dock.tsx b/packages/app/src/components/session-todo-dock.tsx index cbd3bdd3117d..6d8b662dbab9 100644 --- a/packages/app/src/components/session-todo-dock.tsx +++ b/packages/app/src/components/session-todo-dock.tsx @@ -8,8 +8,24 @@ import { createStore } from "solid-js/store" function dot(status: Todo["status"]) { if (status !== "in_progress") return undefined return ( - - + + ) } @@ -105,13 +121,18 @@ function TodoList(props: { todos: Todo[] }) { > - {todo.content} + + + )} diff --git a/packages/ui/src/styles/animations.css b/packages/ui/src/styles/animations.css index 3480976ddc21..f8d11e0e504f 100644 --- a/packages/ui/src/styles/animations.css +++ b/packages/ui/src/styles/animations.css @@ -1,5 +1,6 @@ :root { --animate-pulse: pulse-opacity 2s ease-in-out infinite; + --animate-pulse-scale: pulse-scale 1.2s ease-in-out infinite; } @keyframes pulse-opacity { @@ -12,6 +13,16 @@ } } +@keyframes pulse-scale { + 0%, + 100% { + transform: scale(1); + } + 50% { + transform: scale(0.6666667); + } +} + @keyframes pulse-opacity-dim { 0%, 100% { From 44140764838d51a8e1a4e9089142f374c99e2b77 Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 13 Feb 2026 14:28:43 +0000 Subject: [PATCH 063/121] tweak(app): add todo list top fade mask --- .../app/src/components/session-todo-dock.tsx | 68 +++++++++++-------- 1 file changed, 41 insertions(+), 27 deletions(-) diff --git a/packages/app/src/components/session-todo-dock.tsx b/packages/app/src/components/session-todo-dock.tsx index 6d8b662dbab9..8e89b94e42d4 100644 --- a/packages/app/src/components/session-todo-dock.tsx +++ b/packages/app/src/components/session-todo-dock.tsx @@ -2,7 +2,7 @@ import type { Todo } from "@opencode-ai/sdk/v2" import { Checkbox } from "@opencode-ai/ui/checkbox" import { IconButton } from "@opencode-ai/ui/icon-button" import { TextShimmer } from "@opencode-ai/ui/text-shimmer" -import { For, Show, createMemo } from "solid-js" +import { For, Show, createMemo, createSignal } from "solid-js" import { createStore } from "solid-js/store" function dot(status: Todo["status"]) { @@ -109,34 +109,48 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL } function TodoList(props: { todos: Todo[] }) { + const [stuck, setStuck] = createSignal(false) + return ( -
- - {(todo) => ( - - +
setStuck(e.currentTarget.scrollTop > 0)} + > + + {(todo) => ( + - - - - - - )} - + + + + + + + )} + +
+
) } From 2ac0b53a189bce8c0852a7c8eea0a75ca58bd41f Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 13 Feb 2026 14:42:14 +0000 Subject: [PATCH 064/121] fix(app): keep in-progress todo visible --- .../app/src/components/session-todo-dock.tsx | 58 +++++++++++++++++-- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/session-todo-dock.tsx b/packages/app/src/components/session-todo-dock.tsx index 8e89b94e42d4..836b6c3be0be 100644 --- a/packages/app/src/components/session-todo-dock.tsx +++ b/packages/app/src/components/session-todo-dock.tsx @@ -2,7 +2,7 @@ import type { Todo } from "@opencode-ai/sdk/v2" import { Checkbox } from "@opencode-ai/ui/checkbox" import { IconButton } from "@opencode-ai/ui/icon-button" import { TextShimmer } from "@opencode-ai/ui/text-shimmer" -import { For, Show, createMemo, createSignal } from "solid-js" +import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js" import { createStore } from "solid-js/store" function dot(status: Todo["status"]) { @@ -102,20 +102,69 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL
) } -function TodoList(props: { todos: Todo[] }) { +function TodoList(props: { todos: Todo[]; open: boolean }) { const [stuck, setStuck] = createSignal(false) + const [scrolling, setScrolling] = createSignal(false) + let scrollRef!: HTMLDivElement + let timer: number | undefined + + const inProgress = createMemo(() => props.todos.findIndex((todo) => todo.status === "in_progress")) + + const ensure = () => { + if (!props.open) return + if (scrolling()) return + if (!scrollRef || scrollRef.offsetParent === null) return + + const el = scrollRef.querySelector('[data-in-progress="true"]') + if (!(el instanceof HTMLElement)) return + + const topFade = 16 + const bottomFade = 44 + const container = scrollRef.getBoundingClientRect() + const rect = el.getBoundingClientRect() + const top = rect.top - container.top + scrollRef.scrollTop + const bottom = rect.bottom - container.top + scrollRef.scrollTop + const viewTop = scrollRef.scrollTop + topFade + const viewBottom = scrollRef.scrollTop + scrollRef.clientHeight - bottomFade + + if (top < viewTop) { + scrollRef.scrollTop = Math.max(0, top - topFade) + } else if (bottom > viewBottom) { + scrollRef.scrollTop = bottom - (scrollRef.clientHeight - bottomFade) + } + + setStuck(scrollRef.scrollTop > 0) + } + + createEffect( + on([() => props.open, inProgress], () => { + if (!props.open) return + requestAnimationFrame(ensure) + }), + ) + + onCleanup(() => { + if (!timer) return + window.clearTimeout(timer) + }) return (
setStuck(e.currentTarget.scrollTop > 0)} + ref={scrollRef} + onScroll={(e) => { + setStuck(e.currentTarget.scrollTop > 0) + setScrolling(true) + if (timer) window.clearTimeout(timer) + timer = window.setTimeout(() => setScrolling(false), 250) + }} > {(todo) => ( @@ -123,6 +172,7 @@ function TodoList(props: { todos: Todo[] }) { readOnly checked={todo.status === "completed"} indeterminate={todo.status === "in_progress"} + data-in-progress={todo.status === "in_progress"} icon={dot(todo.status)} > Date: Fri, 13 Feb 2026 15:04:35 +0000 Subject: [PATCH 065/121] fix(app): stabilize todo dock header and scroll --- .../app/src/components/session-todo-dock.tsx | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/packages/app/src/components/session-todo-dock.tsx b/packages/app/src/components/session-todo-dock.tsx index 836b6c3be0be..73b8e4af4f5f 100644 --- a/packages/app/src/components/session-todo-dock.tsx +++ b/packages/app/src/components/session-todo-dock.tsx @@ -73,16 +73,18 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL }} > {summary()} -
- -
- - - -
-
-
-
+ +
+ +
+ + + +
+
+
+
+
props.open, inProgress], () => { - if (!props.open) return + if (!props.open || inProgress() < 0) return requestAnimationFrame(ensure) }), ) @@ -159,11 +161,16 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
{ setStuck(e.currentTarget.scrollTop > 0) setScrolling(true) if (timer) window.clearTimeout(timer) - timer = window.setTimeout(() => setScrolling(false), 250) + timer = window.setTimeout(() => { + setScrolling(false) + if (inProgress() < 0) return + requestAnimationFrame(ensure) + }, 250) }} > @@ -172,7 +179,7 @@ function TodoList(props: { todos: Todo[]; open: boolean }) { readOnly checked={todo.status === "completed"} indeterminate={todo.status === "in_progress"} - data-in-progress={todo.status === "in_progress"} + data-in-progress={todo.status === "in_progress" ? "" : undefined} icon={dot(todo.status)} > Date: Fri, 13 Feb 2026 15:24:07 +0000 Subject: [PATCH 066/121] feat(app): auto-hide todo dock with smoother transition --- .../src/pages/session/session-prompt-dock.tsx | 102 ++++++++++++++++-- 1 file changed, 93 insertions(+), 9 deletions(-) diff --git a/packages/app/src/pages/session/session-prompt-dock.tsx b/packages/app/src/pages/session/session-prompt-dock.tsx index ba245f7e45f8..f8e1d5bab64e 100644 --- a/packages/app/src/pages/session/session-prompt-dock.tsx +++ b/packages/app/src/pages/session/session-prompt-dock.tsx @@ -1,4 +1,4 @@ -import { For, Show } from "solid-js" +import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js" import type { QuestionRequest, Todo } from "@opencode-ai/sdk/v2" import { Button } from "@opencode-ai/ui/button" import { BasicTool } from "@opencode-ai/ui/basic-tool" @@ -24,6 +24,78 @@ export function SessionPromptDock(props: { onSubmit: () => void setPromptDockRef: (el: HTMLDivElement) => void }) { + const done = createMemo( + () => + props.todos.length > 0 && props.todos.every((todo) => todo.status === "completed" || todo.status === "cancelled"), + ) + + const [dock, setDock] = createSignal(props.todos.length > 0) + const [closing, setClosing] = createSignal(false) + const [opening, setOpening] = createSignal(false) + let timer: number | undefined + let raf: number | undefined + + createEffect(() => { + if (timer) window.clearTimeout(timer) + if (raf) cancelAnimationFrame(raf) + + if (props.todos.length === 0) { + setDock(false) + setClosing(false) + setOpening(false) + timer = undefined + raf = undefined + return + } + + if (!done()) { + const wasHidden = !dock() || closing() + setDock(true) + setClosing(false) + if (wasHidden) { + setOpening(true) + raf = requestAnimationFrame(() => { + setOpening(false) + raf = undefined + }) + } else { + setOpening(false) + raf = undefined + } + timer = undefined + return + } + + setDock(true) + setOpening(false) + raf = undefined + if (!closing()) { + setClosing(true) + timer = window.setTimeout(() => { + setDock(false) + setClosing(false) + timer = undefined + }, 400) + return + } + + timer = window.setTimeout(() => { + setDock(false) + setClosing(false) + timer = undefined + }, 400) + }) + + onCleanup(() => { + if (!timer) return + window.clearTimeout(timer) + }) + + onCleanup(() => { + if (!raf) return + cancelAnimationFrame(raf) + }) + return (
} > - 0}> - + +
+ +
0, + "transition-[margin] duration-[400ms] ease-out": true, + "-mt-9": dock() && !closing(), + "mt-0": !dock() || closing(), }} > Date: Fri, 13 Feb 2026 16:09:25 +0000 Subject: [PATCH 067/121] tweak(app): move prompt controls into footer dock --- .../src/components/dialog-select-model.tsx | 2 +- packages/app/src/components/prompt-input.tsx | 244 +++++++++--------- 2 files changed, 125 insertions(+), 121 deletions(-) diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index a196db231a67..9f7afb8cd27d 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -121,7 +121,7 @@ export function ModelSelectorPopover(props: { }} modal={false} placement="top-start" - gutter={8} + gutter={4} > {props.children} diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 5d50da123637..2aa34c3493e0 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -987,8 +987,10 @@ export const PromptInput: Component = (props) => { } } + const variants = createMemo(() => ["default", ...local.model.variant.list()]) + return ( -
+
(slashPopoverRef = el)} @@ -1008,7 +1010,7 @@ export const PromptInput: Component = (props) => { onSubmit={handleSubmit} classList={{ "group/prompt-input": true, - "bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true, + "bg-surface-raised-stronger-non-alpha shadow-xs-border relative z-10": true, "rounded-[12px] overflow-clip focus-within:shadow-xs-border": true, "border-icon-info-active border-dashed": store.draggingType !== null, [props.class ?? ""]: !!props.class, @@ -1072,127 +1074,45 @@ export const PromptInput: Component = (props) => {
-
+
- - -
- - {language.t("prompt.mode.shell")} - {language.t("prompt.mode.shell.exit")} -
-
- - - = (props) => {
+ +
+
+ + (x === "default" ? language.t("common.default") : x)} + onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)} + class="capitalize max-w-[160px]" + valueClass="truncate text-13-regular" + variant="ghost" + /> + +
+
+
) } From 080eb09884d0d6119286d836ad9994f506436f05 Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 13 Feb 2026 16:44:59 +0000 Subject: [PATCH 068/121] tweak(app): refine prompt footer dock spacing --- packages/app/src/components/prompt-input.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 2aa34c3493e0..7f858b3e894f 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1178,8 +1178,8 @@ export const PromptInput: Component = (props) => {
-
-
+
+
= (props) => { onSelect={local.agent.set} class="capitalize max-w-[160px]" valueClass="truncate text-13-regular" + triggerStyle={{ height: "28px" }} variant="ghost" /> @@ -1210,6 +1211,7 @@ export const PromptInput: Component = (props) => { variant="ghost" size="normal" class="min-w-0 max-w-[320px] text-13-regular" + style={{ height: "28px" }} onClick={() => dialog.show(() => )} > @@ -1231,7 +1233,12 @@ export const PromptInput: Component = (props) => { > @@ -1255,6 +1262,7 @@ export const PromptInput: Component = (props) => { onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)} class="capitalize max-w-[160px]" valueClass="truncate text-13-regular" + triggerStyle={{ height: "28px" }} variant="ghost" /> From 34e3bf530117f60ef078d16376ffa525a47cc2f6 Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 13 Feb 2026 16:49:31 +0000 Subject: [PATCH 069/121] tweak(app): fade provider icon in model trigger --- packages/app/src/components/prompt-input.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 7f858b3e894f..03ef4997d92d 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1210,12 +1210,16 @@ export const PromptInput: Component = (props) => { as="div" variant="ghost" size="normal" - class="min-w-0 max-w-[320px] text-13-regular" + class="min-w-0 max-w-[320px] text-13-regular group" style={{ height: "28px" }} onClick={() => dialog.show(() => )} > - + {local.model.current()?.name ?? language.t("dialog.model.select.title")} @@ -1237,11 +1241,15 @@ export const PromptInput: Component = (props) => { variant: "ghost", size: "normal", style: { height: "28px" }, - class: "min-w-0 max-w-[320px] text-13-regular", + class: "min-w-0 max-w-[320px] text-13-regular group", }} > - + {local.model.current()?.name ?? language.t("dialog.model.select.title")} From f10ff3dd0a1511a7e24cd60d98d8a4539ef397ca Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 13 Feb 2026 16:55:33 +0000 Subject: [PATCH 070/121] tweak(app): align prompt input padding --- packages/app/src/components/prompt-input.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 03ef4997d92d..ff7a0fdeddda 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1062,14 +1062,14 @@ export const PromptInput: Component = (props) => { onKeyDown={handleKeyDown} classList={{ "select-text": true, - "w-full p-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true, + "w-full px-3 pt-2 pb-0 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true, "[&_[data-type=file]]:text-syntax-property": true, "[&_[data-type=agent]]:text-syntax-type": true, "font-mono!": store.mode === "shell", }} /> -
+
{placeholder()}
From 7ef06a132689b5d866af2d28ea6b15e6172f4933 Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 13 Feb 2026 16:57:26 +0000 Subject: [PATCH 071/121] tweak(app): adjust sidebar bottom icon group offset --- packages/app/src/pages/layout/sidebar-shell.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/pages/layout/sidebar-shell.tsx b/packages/app/src/pages/layout/sidebar-shell.tsx index 23abdf157b4a..42eae9bfe341 100644 --- a/packages/app/src/pages/layout/sidebar-shell.tsx +++ b/packages/app/src/pages/layout/sidebar-shell.tsx @@ -78,7 +78,7 @@ export const SidebarContent = (props: { {props.renderProjectOverlay()}
-
+
Date: Fri, 13 Feb 2026 17:04:51 +0000 Subject: [PATCH 072/121] tweak(app): restyle prompt suggestions popover --- packages/app/src/components/prompt-input/slash-popover.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx index 259883d61e84..65eb01c797b8 100644 --- a/packages/app/src/components/prompt-input/slash-popover.tsx +++ b/packages/app/src/components/prompt-input/slash-popover.tsx @@ -40,9 +40,9 @@ export const PromptPopover: Component = (props) => { ref={(el) => { if (props.popover === "slash") props.setSlashPopoverRef(el) }} - class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10 - overflow-auto no-scrollbar flex flex-col p-2 rounded-md - border border-border-base bg-surface-raised-stronger-non-alpha shadow-md" + class="absolute inset-x-0 -top-2 -translate-y-full origin-bottom-left max-h-80 min-h-10 + overflow-auto no-scrollbar flex flex-col p-2 rounded-[12px] + bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]" onMouseDown={(e) => e.preventDefault()} > From 01e3eeb025c20794d957aafb7706d246b857765e Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 13 Feb 2026 17:36:49 +0000 Subject: [PATCH 073/121] tweak(ui): soften aborted message styling --- packages/ui/src/components/message-part.css | 14 ++++++++++ packages/ui/src/components/message-part.tsx | 31 +++++++++++++++++---- packages/ui/src/components/session-turn.tsx | 7 +++-- packages/ui/src/i18n/ar.ts | 1 + packages/ui/src/i18n/br.ts | 1 + packages/ui/src/i18n/bs.ts | 1 + packages/ui/src/i18n/da.ts | 1 + packages/ui/src/i18n/de.ts | 1 + packages/ui/src/i18n/en.ts | 1 + packages/ui/src/i18n/es.ts | 1 + packages/ui/src/i18n/fr.ts | 1 + packages/ui/src/i18n/ja.ts | 1 + packages/ui/src/i18n/ko.ts | 1 + packages/ui/src/i18n/no.ts | 1 + packages/ui/src/i18n/pl.ts | 1 + packages/ui/src/i18n/ru.ts | 1 + packages/ui/src/i18n/th.ts | 1 + packages/ui/src/i18n/zh.ts | 1 + packages/ui/src/i18n/zht.ts | 1 + 19 files changed, 61 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 151b4a5f598e..2ba47b4db190 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -23,6 +23,10 @@ max-width: 100%; gap: 8px; + &[data-interrupted] { + color: var(--text-weak); + } + [data-slot="user-message-attachments"] { display: flex; flex-wrap: wrap; @@ -126,6 +130,10 @@ } } + [data-slot="user-message-copy-wrapper"][data-interrupted] { + gap: 12px; + } + &:hover [data-slot="user-message-copy-wrapper"], &:focus-within [data-slot="user-message-copy-wrapper"] { opacity: 1; @@ -165,6 +173,12 @@ } } + [data-slot="text-part-copy-wrapper"][data-interrupted] { + width: 100%; + justify-content: flex-end; + gap: 12px; + } + &:hover [data-slot="text-part-copy-wrapper"], &:focus-within [data-slot="text-part-copy-wrapper"] { opacity: 1; diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 85b0ceb2192e..e94665cbad6b 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -92,6 +92,7 @@ export interface MessageProps { message: MessageType parts: PartType[] showAssistantCopyPartID?: string | null + interrupted?: boolean } export interface MessagePartProps { @@ -336,7 +337,13 @@ export function Message(props: MessageProps) { return ( - {(userMessage) => } + {(userMessage) => ( + + )} {(assistantMessage) => ( @@ -498,7 +505,7 @@ function ContextToolGroup(props: { parts: ToolPart[] }) { ) } -export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) { +export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[]; interrupted?: boolean }) { const dialog = useDialog() const i18n = useI18n() const [copied, setCopied] = createSignal(false) @@ -540,7 +547,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp } return ( -
+
0}>
@@ -578,7 +585,12 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
-
+
+ + + {i18n.t("ui.message.interrupted")} + + + props.message.role === "assistant" && (props.message as AssistantMessage).error?.name === "MessageAbortedError", + ) const displayText = () => relativizeProjectPaths((part.text ?? "").trim(), data.directory) const throttledText = createThrottledValue(displayText) const isLastTextPart = createMemo(() => { @@ -870,7 +886,12 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
-
+
+ + + {i18n.t("ui.message.interrupted")} + + assistantMessages().find((m) => m.error)?.error) + const interrupted = createMemo(() => assistantMessages().some((m) => m.error?.name === "MessageAbortedError")) + const error = createMemo( + () => assistantMessages().find((m) => m.error && m.error.name !== "MessageAbortedError")?.error, + ) const showAssistantCopyPartID = createMemo(() => { const messages = assistantMessages() @@ -264,7 +267,7 @@ export function SessionTurn( class={props.classes?.container} >
- +
diff --git a/packages/ui/src/i18n/ar.ts b/packages/ui/src/i18n/ar.ts index 4a1525d468c0..ef484b04deb6 100644 --- a/packages/ui/src/i18n/ar.ts +++ b/packages/ui/src/i18n/ar.ts @@ -97,6 +97,7 @@ export const dict = { "ui.message.collapse": "طي الرسالة", "ui.message.copy": "نسخ", "ui.message.copied": "تم النسخ!", + "ui.message.interrupted": "تمت المقاطعة", "ui.message.attachment.alt": "مرفق", "ui.patch.action.deleted": "محذوف", diff --git a/packages/ui/src/i18n/br.ts b/packages/ui/src/i18n/br.ts index 160d07aee217..a6b2a1b863f0 100644 --- a/packages/ui/src/i18n/br.ts +++ b/packages/ui/src/i18n/br.ts @@ -97,6 +97,7 @@ export const dict = { "ui.message.collapse": "Recolher mensagem", "ui.message.copy": "Copiar", "ui.message.copied": "Copiado!", + "ui.message.interrupted": "Interrompido", "ui.message.attachment.alt": "anexo", "ui.patch.action.deleted": "Excluído", diff --git a/packages/ui/src/i18n/bs.ts b/packages/ui/src/i18n/bs.ts index 9a049c14bc75..5cc52e452966 100644 --- a/packages/ui/src/i18n/bs.ts +++ b/packages/ui/src/i18n/bs.ts @@ -101,6 +101,7 @@ export const dict = { "ui.message.collapse": "Sažmi poruku", "ui.message.copy": "Kopiraj", "ui.message.copied": "Kopirano!", + "ui.message.interrupted": "Prekinuto", "ui.message.attachment.alt": "prilog", "ui.patch.action.deleted": "Obrisano", diff --git a/packages/ui/src/i18n/da.ts b/packages/ui/src/i18n/da.ts index de0e854be9e7..162df01e6275 100644 --- a/packages/ui/src/i18n/da.ts +++ b/packages/ui/src/i18n/da.ts @@ -96,6 +96,7 @@ export const dict = { "ui.message.collapse": "Skjul besked", "ui.message.copy": "Kopier", "ui.message.copied": "Kopieret!", + "ui.message.interrupted": "Afbrudt", "ui.message.attachment.alt": "vedhæftning", "ui.patch.action.deleted": "Slettet", diff --git a/packages/ui/src/i18n/de.ts b/packages/ui/src/i18n/de.ts index 977065db4c88..7834efca8e3b 100644 --- a/packages/ui/src/i18n/de.ts +++ b/packages/ui/src/i18n/de.ts @@ -100,6 +100,7 @@ export const dict = { "ui.message.collapse": "Nachricht reduzieren", "ui.message.copy": "Kopieren", "ui.message.copied": "Kopiert!", + "ui.message.interrupted": "Unterbrochen", "ui.message.attachment.alt": "Anhang", "ui.patch.action.deleted": "Gelöscht", diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 6f5b9416c0c4..57d3b3082ee0 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -97,6 +97,7 @@ export const dict = { "ui.message.collapse": "Collapse message", "ui.message.copy": "Copy", "ui.message.copied": "Copied", + "ui.message.interrupted": "Interrupted", "ui.message.attachment.alt": "attachment", "ui.patch.action.deleted": "Deleted", diff --git a/packages/ui/src/i18n/es.ts b/packages/ui/src/i18n/es.ts index 6706515ecb38..29972ee2a873 100644 --- a/packages/ui/src/i18n/es.ts +++ b/packages/ui/src/i18n/es.ts @@ -97,6 +97,7 @@ export const dict = { "ui.message.collapse": "Colapsar mensaje", "ui.message.copy": "Copiar", "ui.message.copied": "¡Copiado!", + "ui.message.interrupted": "Interrumpido", "ui.message.attachment.alt": "adjunto", "ui.patch.action.deleted": "Eliminado", diff --git a/packages/ui/src/i18n/fr.ts b/packages/ui/src/i18n/fr.ts index 68a687e840f4..4d51ebc44c66 100644 --- a/packages/ui/src/i18n/fr.ts +++ b/packages/ui/src/i18n/fr.ts @@ -97,6 +97,7 @@ export const dict = { "ui.message.collapse": "Réduire le message", "ui.message.copy": "Copier", "ui.message.copied": "Copié !", + "ui.message.interrupted": "Interrompu", "ui.message.attachment.alt": "pièce jointe", "ui.patch.action.deleted": "Supprimé", diff --git a/packages/ui/src/i18n/ja.ts b/packages/ui/src/i18n/ja.ts index 6fff28cff435..b5984ba86e1c 100644 --- a/packages/ui/src/i18n/ja.ts +++ b/packages/ui/src/i18n/ja.ts @@ -96,6 +96,7 @@ export const dict = { "ui.message.collapse": "メッセージを折りたたむ", "ui.message.copy": "コピー", "ui.message.copied": "コピーしました!", + "ui.message.interrupted": "中断", "ui.message.attachment.alt": "添付ファイル", "ui.patch.action.deleted": "削除済み", diff --git a/packages/ui/src/i18n/ko.ts b/packages/ui/src/i18n/ko.ts index 6fac1590d794..2ea487d07b26 100644 --- a/packages/ui/src/i18n/ko.ts +++ b/packages/ui/src/i18n/ko.ts @@ -97,6 +97,7 @@ export const dict = { "ui.message.collapse": "메시지 접기", "ui.message.copy": "복사", "ui.message.copied": "복사됨!", + "ui.message.interrupted": "중단됨", "ui.message.attachment.alt": "첨부 파일", "ui.patch.action.deleted": "삭제됨", diff --git a/packages/ui/src/i18n/no.ts b/packages/ui/src/i18n/no.ts index 160f26a5468a..c6be5df28fe4 100644 --- a/packages/ui/src/i18n/no.ts +++ b/packages/ui/src/i18n/no.ts @@ -100,6 +100,7 @@ export const dict: Record = { "ui.message.collapse": "Skjul melding", "ui.message.copy": "Kopier", "ui.message.copied": "Kopiert!", + "ui.message.interrupted": "Avbrutt", "ui.message.attachment.alt": "vedlegg", "ui.patch.action.deleted": "Slettet", diff --git a/packages/ui/src/i18n/pl.ts b/packages/ui/src/i18n/pl.ts index 4882ba034849..1842ee04490f 100644 --- a/packages/ui/src/i18n/pl.ts +++ b/packages/ui/src/i18n/pl.ts @@ -96,6 +96,7 @@ export const dict = { "ui.message.collapse": "Zwiń wiadomość", "ui.message.copy": "Kopiuj", "ui.message.copied": "Skopiowano!", + "ui.message.interrupted": "Przerwano", "ui.message.attachment.alt": "załącznik", "ui.patch.action.deleted": "Usunięto", diff --git a/packages/ui/src/i18n/ru.ts b/packages/ui/src/i18n/ru.ts index 93a9883d26a7..d2f0a0d2698e 100644 --- a/packages/ui/src/i18n/ru.ts +++ b/packages/ui/src/i18n/ru.ts @@ -96,6 +96,7 @@ export const dict = { "ui.message.collapse": "Свернуть сообщение", "ui.message.copy": "Копировать", "ui.message.copied": "Скопировано!", + "ui.message.interrupted": "Прервано", "ui.message.attachment.alt": "вложение", "ui.patch.action.deleted": "Удалено", diff --git a/packages/ui/src/i18n/th.ts b/packages/ui/src/i18n/th.ts index 1a5438a2ae85..c2c54e7ce2bb 100644 --- a/packages/ui/src/i18n/th.ts +++ b/packages/ui/src/i18n/th.ts @@ -97,6 +97,7 @@ export const dict = { "ui.message.collapse": "ย่อข้อความ", "ui.message.copy": "คัดลอก", "ui.message.copied": "คัดลอกแล้ว!", + "ui.message.interrupted": "ถูกขัดจังหวะ", "ui.message.attachment.alt": "ไฟล์แนบ", "ui.patch.action.deleted": "ลบ", diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index dbebfb3f9f01..bf880b094140 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -101,6 +101,7 @@ export const dict = { "ui.message.collapse": "收起消息", "ui.message.copy": "复制", "ui.message.copied": "已复制!", + "ui.message.interrupted": "已中断", "ui.message.attachment.alt": "附件", "ui.patch.action.deleted": "已删除", diff --git a/packages/ui/src/i18n/zht.ts b/packages/ui/src/i18n/zht.ts index 5cec9c399ef2..44af0e6b63ff 100644 --- a/packages/ui/src/i18n/zht.ts +++ b/packages/ui/src/i18n/zht.ts @@ -101,6 +101,7 @@ export const dict = { "ui.message.collapse": "收合訊息", "ui.message.copy": "複製", "ui.message.copied": "已複製!", + "ui.message.interrupted": "已中斷", "ui.message.attachment.alt": "附件", "ui.patch.action.deleted": "已刪除", From 310c47135c302a6e5be3fe3c1622ae197ec2f40a Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 13 Feb 2026 17:43:46 +0000 Subject: [PATCH 074/121] tweak(ui): match patch tool styling to edit --- packages/ui/src/components/message-part.css | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 2ba47b4db190..5d136d96ae0d 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -293,7 +293,8 @@ } [data-slot="collapsible-content"]:has([data-component="edit-content"]), -[data-slot="collapsible-content"]:has([data-component="write-content"]) { +[data-slot="collapsible-content"]:has([data-component="write-content"]), +[data-slot="collapsible-content"]:has([data-component="apply-patch-files"]) { border: 1px solid var(--border-weak-base); border-radius: 6px; background: transparent; @@ -905,18 +906,13 @@ [data-component="apply-patch-file"] { display: flex; flex-direction: column; - border-top: 1px solid var(--border-weaker-base); - - &:first-child { - border-top: 1px solid var(--border-weaker-base); - } [data-slot="apply-patch-file-header"] { display: flex; align-items: center; gap: 8px; padding: 8px 12px; - background-color: var(--surface-inset-base); + background-color: transparent; } [data-slot="apply-patch-file-action"] { @@ -958,7 +954,12 @@ } } +[data-component="apply-patch-file"] + [data-component="apply-patch-file"] { + border-top: 1px solid var(--border-weaker-base); +} + [data-component="apply-patch-file-diff"] { + border-top: 1px solid var(--border-weaker-base); max-height: 420px; overflow-y: auto; scrollbar-width: none; From 0a5a4c3744cde6b3f2cb03528ca08e3063f79894 Mon Sep 17 00:00:00 2001 From: David Hill Date: Sat, 14 Feb 2026 17:49:47 +0000 Subject: [PATCH 075/121] tweak(app/ui): restyle questions dock --- packages/app/src/components/question-dock.tsx | 275 +++++++-------- .../src/pages/session/session-prompt-dock.tsx | 13 +- packages/ui/src/components/message-part.css | 326 +++++++++++------- packages/ui/src/i18n/ar.ts | 4 +- packages/ui/src/i18n/br.ts | 4 +- packages/ui/src/i18n/bs.ts | 4 +- packages/ui/src/i18n/da.ts | 4 +- packages/ui/src/i18n/de.ts | 4 +- packages/ui/src/i18n/en.ts | 4 +- packages/ui/src/i18n/es.ts | 4 +- packages/ui/src/i18n/fr.ts | 4 +- packages/ui/src/i18n/ja.ts | 4 +- packages/ui/src/i18n/ko.ts | 4 +- packages/ui/src/i18n/no.ts | 4 +- packages/ui/src/i18n/pl.ts | 4 +- packages/ui/src/i18n/ru.ts | 4 +- packages/ui/src/i18n/th.ts | 4 +- packages/ui/src/i18n/zh.ts | 4 +- packages/ui/src/i18n/zht.ts | 4 +- 19 files changed, 390 insertions(+), 288 deletions(-) diff --git a/packages/app/src/components/question-dock.tsx b/packages/app/src/components/question-dock.tsx index 5054253b87b5..1a4074e8c148 100644 --- a/packages/app/src/components/question-dock.tsx +++ b/packages/app/src/components/question-dock.tsx @@ -12,7 +12,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => const language = useLanguage() const questions = createMemo(() => props.request.questions) - const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true) + const total = createMemo(() => questions().length) const [store, setStore] = createStore({ tab: 0, @@ -23,16 +23,23 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => }) const question = createMemo(() => questions()[store.tab]) - const confirm = createMemo(() => !single() && store.tab === questions().length) const options = createMemo(() => question()?.options ?? []) const input = createMemo(() => store.custom[store.tab] ?? "") const multi = createMemo(() => question()?.multiple === true) + const answered = createMemo(() => (store.answers[store.tab]?.length ?? 0) > 0) const customPicked = createMemo(() => { const value = input() if (!value) return false return store.answers[store.tab]?.includes(value) ?? false }) + const summary = createMemo(() => { + const n = Math.min(store.tab + 1, total()) + return `${n} of ${total()} questions` + }) + + const last = createMemo(() => store.tab >= total() - 1) + const fail = (err: unknown) => { const message = err instanceof Error ? err.message : String(err) showToast({ title: language.t("common.requestFailed"), description: message }) @@ -64,23 +71,11 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => } } - const submit = () => { - void reply(questions().map((_, i) => store.answers[i] ?? [])) - } + const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? [])) const pick = (answer: string, custom: boolean = false) => { setStore("answers", store.tab, [answer]) - - if (custom) { - setStore("custom", store.tab, answer) - } - - if (single()) { - void reply([[answer]]) - return - } - - setStore("tab", store.tab + 1) + if (custom) setStore("custom", store.tab, answer) } const toggle = (answer: string) => { @@ -90,11 +85,6 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => }) } - const selectTab = (index: number) => { - setStore("tab", index) - setStore("editing", false) - } - const selectOption = (optIndex: number) => { if (store.sending) return @@ -112,10 +102,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => pick(opt.label) } - const handleCustomSubmit = (e: Event) => { - e.preventDefault() - if (store.sending) return - + const commitCustom = () => { const value = input().trim() if (!value) { setStore("editing", false) @@ -135,44 +122,49 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => setStore("editing", false) } + const next = () => { + if (store.sending) return + if (store.editing) commitCustom() + + if (store.tab >= total() - 1) { + submit() + return + } + + setStore("tab", store.tab + 1) + setStore("editing", false) + } + + const back = () => { + if (store.sending) return + if (store.tab <= 0) return + setStore("tab", store.tab - 1) + setStore("editing", false) + } + return (
- -
- - {(q, index) => { - const active = () => index() === store.tab - const answered = () => (store.answers[index()]?.length ?? 0) > 0 - return ( - - ) - }} - - +
+
+
{summary()}
+
- -
-
- {question()?.question} - {multi() ? " " + language.t("ui.question.multiHint") : ""} -
+
{question()?.question}
+ {language.t("ui.question.singleHint")}
}> +
{language.t("ui.question.multiHint")}
+
{(opt, i) => { @@ -181,106 +173,105 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => ) }} - - -
- setTimeout(() => el.focus(), 0)} - type="text" - data-slot="custom-input" - placeholder={language.t("ui.question.custom.placeholder")} - value={input()} - disabled={store.sending} - onInput={(e) => { - setStore("custom", store.tab, e.currentTarget.value) - }} - /> - - -
-
-
-
- - -
-
{language.t("ui.messagePart.review.title")}
- - {(q, index) => { - const value = () => store.answers[index()]?.join(", ") ?? "" - const answered = () => Boolean(value()) - return ( -
- {q.question} - - {answered() ? value() : language.t("ui.question.review.notAnswered")} +
+ + +
+ setTimeout(() => el.focus(), 0)} + type="text" + data-slot="question-custom-input" + placeholder={language.t("ui.question.custom.placeholder")} + value={input()} + disabled={store.sending} + onKeyDown={(e) => { + if (e.key === "Escape") { + setStore("editing", false) + return + } + if (e.key !== "Enter") return + e.preventDefault() + commitCustom() + }} + onInput={(e) => { + setStore("custom", store.tab, e.currentTarget.value) + }} + />
- ) - }} - +
+
+
-
+
-
- - - - - - - - + +
) diff --git a/packages/app/src/pages/session/session-prompt-dock.tsx b/packages/app/src/pages/session/session-prompt-dock.tsx index f8e1d5bab64e..853d0bb9b6a4 100644 --- a/packages/app/src/pages/session/session-prompt-dock.tsx +++ b/packages/app/src/pages/session/session-prompt-dock.tsx @@ -5,7 +5,6 @@ import { BasicTool } from "@opencode-ai/ui/basic-tool" import { PromptInput } from "@/components/prompt-input" import { QuestionDock } from "@/components/question-dock" import { SessionTodoDock } from "@/components/session-todo-dock" -import { questionSubtitle } from "@/pages/session/session-prompt-helpers" export function SessionPromptDock(props: { centered: boolean @@ -109,18 +108,8 @@ export function SessionPromptDock(props: { > {(req) => { - const subtitle = questionSubtitle(req.questions.length, (key) => props.t(key)) return ( -
- +
) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 5d136d96ae0d..f2118efdc6fb 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -709,170 +709,260 @@ } [data-component="question-prompt"] { + position: relative; display: flex; flex-direction: column; - padding: 12px; - background-color: var(--surface-inset-base); - border-radius: 0 0 6px 6px; - gap: 12px; + gap: 0; - [data-slot="question-tabs"] { + [data-slot="question-body"] { display: flex; - gap: 4px; - flex-wrap: wrap; + flex-direction: column; + gap: 16px; + padding: 8px; + background-color: var(--surface-raised-stronger-non-alpha); + border-radius: 12px; + box-shadow: var(--shadow-xs-border); + overflow: clip; + position: relative; + z-index: 10; + } - [data-slot="question-tab"] { - padding: 4px 12px; - font-size: 13px; - border-radius: 4px; - background-color: var(--surface-base); - color: var(--text-base); - border: none; - cursor: pointer; - transition: - color 0.15s, - background-color 0.15s; + [data-slot="question-header"] { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0 10px; + } - &:hover { - background-color: var(--surface-base-hover); - } + [data-slot="question-header-title"] { + font-family: var(--font-family-sans); + font-size: 14px; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + color: var(--text-strong); + min-width: 0; + } - &[data-active="true"] { - background-color: var(--surface-raised-base); - } + [data-slot="question-progress"] { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + } - &[data-answered="true"] { - color: var(--text-strong); - } + [data-slot="question-progress-segment"] { + width: 16px; + height: 2px; + border-radius: 999px; + background-color: var(--icon-weak-base); + transition: + width 0.2s ease, + background-color 0.2s ease; + + &[data-active="true"] { + width: 48px; + } + + &[data-answered="true"] { + background-color: var(--icon-interactive-base); } } [data-slot="question-content"] { display: flex; flex-direction: column; - gap: 8px; + gap: 4px; + } - [data-slot="question-text"] { - font-size: 14px; - color: var(--text-base); - line-height: 1.5; - } + [data-slot="question-text"] { + font-family: var(--font-family-sans); + font-size: 14px; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + color: var(--text-strong); + padding: 0 10px; + } + + [data-slot="question-hint"] { + font-family: var(--font-family-sans); + font-size: 13px; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); + color: var(--text-weak); + padding: 0 10px; } [data-slot="question-options"] { display: flex; flex-direction: column; - gap: 4px; + gap: 6px; + margin-top: 12px; + } - [data-slot="question-option"] { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 2px; - padding: 8px 12px; - background-color: var(--surface-base); - border: 1px solid var(--border-weaker-base); - border-radius: 6px; - cursor: pointer; - text-align: left; - width: 100%; - transition: - background-color 0.15s, - border-color 0.15s; - position: relative; - - &:hover { - background-color: var(--surface-base-hover); - border-color: var(--border-default); - } + [data-slot="question-option"] { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 8px 8px 8px 10px; + background-color: var(--surface-raised-stronger-non-alpha); + border: 1px solid var(--border-weak-base); + border-radius: 6px; + text-align: left; + width: 100%; + cursor: pointer; + transition: + background-color 0.15s ease, + border-color 0.15s ease; - &[data-picked="true"] { - [data-component="icon"] { - position: absolute; - right: 12px; - top: 50%; - transform: translateY(-50%); - color: var(--text-strong); - } - } + &:hover:not([data-picked="true"]) { + background-color: var(--background-base); + } - [data-slot="option-label"] { - font-size: 14px; - color: var(--text-base); - font-weight: 500; - } + &[data-picked="true"] { + background-color: var(--surface-base-interactive-active); + border-color: var(--border-weak-selected); + } - [data-slot="option-description"] { - font-size: 12px; - color: var(--text-weak); - } + &:disabled { + cursor: not-allowed; + opacity: 0.6; } + } - [data-slot="custom-input-form"] { - display: flex; - gap: 8px; - padding: 8px 0; - align-items: stretch; - - [data-slot="custom-input"] { - flex: 1; - padding: 8px 12px; - font-size: 14px; - border: 1px solid var(--border-default); - border-radius: 6px; - background-color: var(--surface-base); - color: var(--text-base); - outline: none; - - &:focus { - border-color: var(--border-focus); - } + [data-slot="question-option-check"] { + display: inline-flex; + transform: translateY(2px); + } - &::placeholder { - color: var(--text-weak); - } + [data-slot="question-option-box"] { + width: 16px; + height: 16px; + padding: 2px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-weak-base); + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + background-color: transparent; + transition: + background-color 0.15s ease, + border-color 0.15s ease; + + [data-component="icon"] { + opacity: 0; + color: var(--icon-base); + } + + &[data-type="radio"] { + border-radius: 999px; + } + + [data-slot="question-option-radio-dot"] { + width: 6px; + height: 6px; + border-radius: 999px; + background-color: var(--icon-interactive-base); + opacity: 0; + } + + &[data-picked="true"] { + border-color: var(--icon-interactive-base); + + [data-component="icon"] { + opacity: 1; + color: var(--icon-invert-base); + } + + &[data-type="checkbox"] { + background-color: var(--icon-interactive-base); } - [data-component="button"] { - height: auto; + &[data-type="radio"] { + background-color: transparent; + [data-slot="question-option-radio-dot"] { + opacity: 1; + } } } } - [data-slot="question-review"] { + [data-slot="question-option-main"] { display: flex; flex-direction: column; - gap: 12px; + gap: 2px; + min-width: 0; + } - [data-slot="review-title"] { - display: none; - } + [data-slot="option-label"] { + font-family: var(--font-family-sans); + font-size: 14px; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + color: var(--text-strong); + } - [data-slot="review-item"] { - display: flex; - flex-direction: column; - gap: 2px; - font-size: 13px; + [data-slot="option-description"] { + font-family: var(--font-family-sans); + font-size: 14px; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); + color: var(--text-base); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } - [data-slot="review-label"] { - color: var(--text-weak); - } + [data-slot="question-custom"] { + display: flex; + flex-direction: column; + gap: 8px; + } - [data-slot="review-value"] { - color: var(--text-strong); + [data-slot="question-custom-input-wrap"] { + padding-left: 36px; + } - &[data-answered="false"] { - color: var(--text-weak); - } - } + [data-slot="question-custom-input"] { + width: 100%; + padding: 10px 12px; + font-size: 16px; + border: 1px solid var(--border-weak-base); + border-radius: 10px; + background-color: var(--surface-raised-stronger-non-alpha); + color: var(--text-strong); + outline: none; + + &:focus { + border-color: var(--border-interactive-focus); + box-shadow: var(--shadow-xs-border-focus); + } + + &::placeholder { + color: var(--text-weak); } } - [data-slot="question-actions"] { + [data-slot="question-footer"] { + display: flex; + align-items: center; + justify-content: space-between; + padding: 32px 8px 8px; + background-color: var(--background-base); + border: 1px solid var(--border-weak-base); + border-radius: 12px; + overflow: clip; + margin-top: -24px; + position: relative; + z-index: 0; + } + + [data-slot="question-footer-actions"] { display: flex; align-items: center; gap: 8px; - justify-content: flex-end; } } diff --git a/packages/ui/src/i18n/ar.ts b/packages/ui/src/i18n/ar.ts index ef484b04deb6..f4d6c87886ed 100644 --- a/packages/ui/src/i18n/ar.ts +++ b/packages/ui/src/i18n/ar.ts @@ -82,6 +82,7 @@ export const dict = { "ui.common.question.other": "أسئلة", "ui.common.add": "إضافة", + "ui.common.back": "رجوع", "ui.common.cancel": "إلغاء", "ui.common.confirm": "تأكيد", "ui.common.dismiss": "رفض", @@ -108,6 +109,7 @@ export const dict = { "ui.question.subtitle.answered": "{{count}} أجيب", "ui.question.answer.none": "(لا توجد إجابة)", "ui.question.review.notAnswered": "(لم يتم الرد)", - "ui.question.multiHint": "(حدد كل ما ينطبق)", + "ui.question.multiHint": "حدد كل ما ينطبق", + "ui.question.singleHint": "حدد إجابة واحدة", "ui.question.custom.placeholder": "اكتب إجابتك...", } diff --git a/packages/ui/src/i18n/br.ts b/packages/ui/src/i18n/br.ts index a6b2a1b863f0..2dda9d92b447 100644 --- a/packages/ui/src/i18n/br.ts +++ b/packages/ui/src/i18n/br.ts @@ -82,6 +82,7 @@ export const dict = { "ui.common.question.other": "perguntas", "ui.common.add": "Adicionar", + "ui.common.back": "Voltar", "ui.common.cancel": "Cancelar", "ui.common.confirm": "Confirmar", "ui.common.dismiss": "Descartar", @@ -108,6 +109,7 @@ export const dict = { "ui.question.subtitle.answered": "{{count}} respondidas", "ui.question.answer.none": "(sem resposta)", "ui.question.review.notAnswered": "(não respondida)", - "ui.question.multiHint": "(selecione todas que se aplicam)", + "ui.question.multiHint": "Selecione todas que se aplicam", + "ui.question.singleHint": "Selecione uma resposta", "ui.question.custom.placeholder": "Digite sua resposta...", } diff --git a/packages/ui/src/i18n/bs.ts b/packages/ui/src/i18n/bs.ts index 5cc52e452966..21e9e5354287 100644 --- a/packages/ui/src/i18n/bs.ts +++ b/packages/ui/src/i18n/bs.ts @@ -86,6 +86,7 @@ export const dict = { "ui.common.question.other": "pitanja", "ui.common.add": "Dodaj", + "ui.common.back": "Nazad", "ui.common.cancel": "Otkaži", "ui.common.confirm": "Potvrdi", "ui.common.dismiss": "Odbaci", @@ -112,6 +113,7 @@ export const dict = { "ui.question.subtitle.answered": "{{count}} odgovoreno", "ui.question.answer.none": "(nema odgovora)", "ui.question.review.notAnswered": "(nije odgovoreno)", - "ui.question.multiHint": "(odaberi sve što važi)", + "ui.question.multiHint": "Odaberi sve što važi", + "ui.question.singleHint": "Odaberi jedan odgovor", "ui.question.custom.placeholder": "Unesi svoj odgovor...", } satisfies Partial> diff --git a/packages/ui/src/i18n/da.ts b/packages/ui/src/i18n/da.ts index 162df01e6275..9d8221698369 100644 --- a/packages/ui/src/i18n/da.ts +++ b/packages/ui/src/i18n/da.ts @@ -81,6 +81,7 @@ export const dict = { "ui.common.question.other": "spørgsmål", "ui.common.add": "Tilføj", + "ui.common.back": "Tilbage", "ui.common.cancel": "Annuller", "ui.common.confirm": "Bekræft", "ui.common.dismiss": "Afvis", @@ -107,6 +108,7 @@ export const dict = { "ui.question.subtitle.answered": "{{count}} besvaret", "ui.question.answer.none": "(intet svar)", "ui.question.review.notAnswered": "(ikke besvaret)", - "ui.question.multiHint": "(vælg alle der gælder)", + "ui.question.multiHint": "Vælg alle der gælder", + "ui.question.singleHint": "Vælg ét svar", "ui.question.custom.placeholder": "Skriv dit svar...", } diff --git a/packages/ui/src/i18n/de.ts b/packages/ui/src/i18n/de.ts index 7834efca8e3b..09d5141e3c2e 100644 --- a/packages/ui/src/i18n/de.ts +++ b/packages/ui/src/i18n/de.ts @@ -85,6 +85,7 @@ export const dict = { "ui.common.question.other": "Fragen", "ui.common.add": "Hinzufügen", + "ui.common.back": "Zurück", "ui.common.cancel": "Abbrechen", "ui.common.confirm": "Bestätigen", "ui.common.dismiss": "Verwerfen", @@ -111,6 +112,7 @@ export const dict = { "ui.question.subtitle.answered": "{{count}} beantwortet", "ui.question.answer.none": "(keine Antwort)", "ui.question.review.notAnswered": "(nicht beantwortet)", - "ui.question.multiHint": "(alle zutreffenden auswählen)", + "ui.question.multiHint": "Alle zutreffenden auswählen", + "ui.question.singleHint": "Eine Antwort auswählen", "ui.question.custom.placeholder": "Geben Sie Ihre Antwort ein...", } satisfies Partial> diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 57d3b3082ee0..b89fea3c330a 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -82,6 +82,7 @@ export const dict = { "ui.common.question.other": "questions", "ui.common.add": "Add", + "ui.common.back": "Back", "ui.common.cancel": "Cancel", "ui.common.confirm": "Confirm", "ui.common.dismiss": "Dismiss", @@ -108,6 +109,7 @@ export const dict = { "ui.question.subtitle.answered": "{{count}} answered", "ui.question.answer.none": "(no answer)", "ui.question.review.notAnswered": "(not answered)", - "ui.question.multiHint": "(select all that apply)", + "ui.question.multiHint": "Select all answers that apply", + "ui.question.singleHint": "Select one answer", "ui.question.custom.placeholder": "Type your answer...", } diff --git a/packages/ui/src/i18n/es.ts b/packages/ui/src/i18n/es.ts index 29972ee2a873..6dbf9f59975b 100644 --- a/packages/ui/src/i18n/es.ts +++ b/packages/ui/src/i18n/es.ts @@ -82,6 +82,7 @@ export const dict = { "ui.common.question.other": "preguntas", "ui.common.add": "Añadir", + "ui.common.back": "Atrás", "ui.common.cancel": "Cancelar", "ui.common.confirm": "Confirmar", "ui.common.dismiss": "Descartar", @@ -108,6 +109,7 @@ export const dict = { "ui.question.subtitle.answered": "{{count}} respondidas", "ui.question.answer.none": "(sin respuesta)", "ui.question.review.notAnswered": "(no respondida)", - "ui.question.multiHint": "(selecciona todas las que correspondan)", + "ui.question.multiHint": "Selecciona todas las que correspondan", + "ui.question.singleHint": "Selecciona una respuesta", "ui.question.custom.placeholder": "Escribe tu respuesta...", } diff --git a/packages/ui/src/i18n/fr.ts b/packages/ui/src/i18n/fr.ts index 4d51ebc44c66..6a6114dc116c 100644 --- a/packages/ui/src/i18n/fr.ts +++ b/packages/ui/src/i18n/fr.ts @@ -82,6 +82,7 @@ export const dict = { "ui.common.question.other": "questions", "ui.common.add": "Ajouter", + "ui.common.back": "Retour", "ui.common.cancel": "Annuler", "ui.common.confirm": "Confirmer", "ui.common.dismiss": "Ignorer", @@ -108,6 +109,7 @@ export const dict = { "ui.question.subtitle.answered": "{{count}} répondu(s)", "ui.question.answer.none": "(pas de réponse)", "ui.question.review.notAnswered": "(non répondu)", - "ui.question.multiHint": "(sélectionnez tout ce qui s'applique)", + "ui.question.multiHint": "Sélectionnez tout ce qui s'applique", + "ui.question.singleHint": "Sélectionnez une réponse", "ui.question.custom.placeholder": "Tapez votre réponse...", } diff --git a/packages/ui/src/i18n/ja.ts b/packages/ui/src/i18n/ja.ts index b5984ba86e1c..7cce41666900 100644 --- a/packages/ui/src/i18n/ja.ts +++ b/packages/ui/src/i18n/ja.ts @@ -81,6 +81,7 @@ export const dict = { "ui.common.question.other": "質問", "ui.common.add": "追加", + "ui.common.back": "戻る", "ui.common.cancel": "キャンセル", "ui.common.confirm": "確認", "ui.common.dismiss": "閉じる", @@ -107,6 +108,7 @@ export const dict = { "ui.question.subtitle.answered": "{{count}}件回答済み", "ui.question.answer.none": "(回答なし)", "ui.question.review.notAnswered": "(未回答)", - "ui.question.multiHint": "(該当するものをすべて選択)", + "ui.question.multiHint": "該当するものをすべて選択", + "ui.question.singleHint": "1 つ選択", "ui.question.custom.placeholder": "回答を入力...", } diff --git a/packages/ui/src/i18n/ko.ts b/packages/ui/src/i18n/ko.ts index 2ea487d07b26..108f98ae92ea 100644 --- a/packages/ui/src/i18n/ko.ts +++ b/packages/ui/src/i18n/ko.ts @@ -82,6 +82,7 @@ export const dict = { "ui.common.question.other": "질문", "ui.common.add": "추가", + "ui.common.back": "뒤로", "ui.common.cancel": "취소", "ui.common.confirm": "확인", "ui.common.dismiss": "닫기", @@ -108,6 +109,7 @@ export const dict = { "ui.question.subtitle.answered": "{{count}}개 답변됨", "ui.question.answer.none": "(답변 없음)", "ui.question.review.notAnswered": "(답변되지 않음)", - "ui.question.multiHint": "(해당하는 항목 모두 선택)", + "ui.question.multiHint": "해당하는 항목 모두 선택", + "ui.question.singleHint": "하나의 답변을 선택", "ui.question.custom.placeholder": "답변 입력...", } diff --git a/packages/ui/src/i18n/no.ts b/packages/ui/src/i18n/no.ts index c6be5df28fe4..70c5df5b053c 100644 --- a/packages/ui/src/i18n/no.ts +++ b/packages/ui/src/i18n/no.ts @@ -85,6 +85,7 @@ export const dict: Record = { "ui.common.question.other": "spørsmål", "ui.common.add": "Legg til", + "ui.common.back": "Tilbake", "ui.common.cancel": "Avbryt", "ui.common.confirm": "Bekreft", "ui.common.dismiss": "Avvis", @@ -111,6 +112,7 @@ export const dict: Record = { "ui.question.subtitle.answered": "{{count}} besvart", "ui.question.answer.none": "(ingen svar)", "ui.question.review.notAnswered": "(ikke besvart)", - "ui.question.multiHint": "(velg alle som gjelder)", + "ui.question.multiHint": "Velg alle som gjelder", + "ui.question.singleHint": "Velg ett svar", "ui.question.custom.placeholder": "Skriv svaret ditt...", } diff --git a/packages/ui/src/i18n/pl.ts b/packages/ui/src/i18n/pl.ts index 1842ee04490f..f017ac88095c 100644 --- a/packages/ui/src/i18n/pl.ts +++ b/packages/ui/src/i18n/pl.ts @@ -81,6 +81,7 @@ export const dict = { "ui.common.question.other": "pytania", "ui.common.add": "Dodaj", + "ui.common.back": "Wstecz", "ui.common.cancel": "Anuluj", "ui.common.confirm": "Potwierdź", "ui.common.dismiss": "Odrzuć", @@ -107,6 +108,7 @@ export const dict = { "ui.question.subtitle.answered": "{{count}} odpowiedzi", "ui.question.answer.none": "(brak odpowiedzi)", "ui.question.review.notAnswered": "(bez odpowiedzi)", - "ui.question.multiHint": "(zaznacz wszystkie pasujące)", + "ui.question.multiHint": "Zaznacz wszystkie pasujące", + "ui.question.singleHint": "Wybierz jedną odpowiedź", "ui.question.custom.placeholder": "Wpisz swoją odpowiedź...", } diff --git a/packages/ui/src/i18n/ru.ts b/packages/ui/src/i18n/ru.ts index d2f0a0d2698e..81e3f9fb5b79 100644 --- a/packages/ui/src/i18n/ru.ts +++ b/packages/ui/src/i18n/ru.ts @@ -81,6 +81,7 @@ export const dict = { "ui.common.question.other": "вопросов", "ui.common.add": "Добавить", + "ui.common.back": "Назад", "ui.common.cancel": "Отмена", "ui.common.confirm": "Подтвердить", "ui.common.dismiss": "Закрыть", @@ -107,6 +108,7 @@ export const dict = { "ui.question.subtitle.answered": "{{count}} отвечено", "ui.question.answer.none": "(нет ответа)", "ui.question.review.notAnswered": "(не отвечено)", - "ui.question.multiHint": "(выберите все подходящие)", + "ui.question.multiHint": "Выберите все подходящие", + "ui.question.singleHint": "Выберите один ответ", "ui.question.custom.placeholder": "Введите ваш ответ...", } diff --git a/packages/ui/src/i18n/th.ts b/packages/ui/src/i18n/th.ts index c2c54e7ce2bb..238b03782af3 100644 --- a/packages/ui/src/i18n/th.ts +++ b/packages/ui/src/i18n/th.ts @@ -82,6 +82,7 @@ export const dict = { "ui.common.question.other": "คำถาม", "ui.common.add": "เพิ่ม", + "ui.common.back": "ย้อนกลับ", "ui.common.cancel": "ยกเลิก", "ui.common.confirm": "ยืนยัน", "ui.common.dismiss": "ปิด", @@ -108,6 +109,7 @@ export const dict = { "ui.question.subtitle.answered": "{{count}} ตอบแล้ว", "ui.question.answer.none": "(ไม่มีคำตอบ)", "ui.question.review.notAnswered": "(ไม่ได้ตอบ)", - "ui.question.multiHint": "(เลือกทั้งหมดที่ใช้)", + "ui.question.multiHint": "เลือกทั้งหมดที่ใช้", + "ui.question.singleHint": "เลือกหนึ่งคำตอบ", "ui.question.custom.placeholder": "พิมพ์คำตอบของคุณ...", } diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index bf880b094140..5f6eaaaeb1bb 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -86,6 +86,7 @@ export const dict = { "ui.common.question.other": "个问题", "ui.common.add": "添加", + "ui.common.back": "返回", "ui.common.cancel": "取消", "ui.common.confirm": "确认", "ui.common.dismiss": "忽略", @@ -112,6 +113,7 @@ export const dict = { "ui.question.subtitle.answered": "{{count}} 已回答", "ui.question.answer.none": "(无答案)", "ui.question.review.notAnswered": "(未回答)", - "ui.question.multiHint": "(可多选)", + "ui.question.multiHint": "可多选", + "ui.question.singleHint": "选择一个答案", "ui.question.custom.placeholder": "输入你的答案...", } satisfies Partial> diff --git a/packages/ui/src/i18n/zht.ts b/packages/ui/src/i18n/zht.ts index 44af0e6b63ff..c413fe8cd7b2 100644 --- a/packages/ui/src/i18n/zht.ts +++ b/packages/ui/src/i18n/zht.ts @@ -86,6 +86,7 @@ export const dict = { "ui.common.question.other": "個問題", "ui.common.add": "新增", + "ui.common.back": "返回", "ui.common.cancel": "取消", "ui.common.confirm": "確認", "ui.common.dismiss": "忽略", @@ -112,6 +113,7 @@ export const dict = { "ui.question.subtitle.answered": "{{count}} 已回答", "ui.question.answer.none": "(無答案)", "ui.question.review.notAnswered": "(未回答)", - "ui.question.multiHint": "(可多選)", + "ui.question.multiHint": "可多選", + "ui.question.singleHint": "選擇一個答案", "ui.question.custom.placeholder": "輸入你的答案...", } satisfies Partial> From 4ac6377c6a16864e570d21b53ecddb94e2b86ef8 Mon Sep 17 00:00:00 2001 From: David Hill Date: Sat, 14 Feb 2026 17:57:10 +0000 Subject: [PATCH 076/121] refactor(app): overlay prompt actions and focus input --- packages/app/src/components/prompt-input.tsx | 174 +++++++++++-------- 1 file changed, 99 insertions(+), 75 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index ff7a0fdeddda..778bb015d07e 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1041,7 +1041,22 @@ export const PromptInput: Component = (props) => { onRemove={removeImageAttachment} removeLabel={language.t("prompt.attachment.remove")} /> -
(scrollRef = el)}> +
(scrollRef = el)} + onMouseDown={(e) => { + const target = e.target + if (!(target instanceof HTMLElement)) return + if ( + target.closest( + '[data-action="prompt-attach"], [data-action="prompt-submit"], [data-action="prompt-permissions"]', + ) + ) { + return + } + editorRef?.focus() + }} + >
{ @@ -1062,59 +1077,19 @@ export const PromptInput: Component = (props) => { onKeyDown={handleKeyDown} classList={{ "select-text": true, - "w-full px-3 pt-2 pb-0 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true, + "w-full pl-3 pr-2 pt-2 pb-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true, "[&_[data-type=file]]:text-syntax-property": true, "[&_[data-type=agent]]:text-syntax-type": true, "font-mono!": store.mode === "shell", }} /> -
+
{placeholder()}
-
-
-
- -
- - {language.t("prompt.mode.shell")} - {language.t("prompt.mode.shell.exit")} -
-
- - - - - -
-
+ +
= (props) => { e.currentTarget.value = "" }} /> -
+ +
= (props) => { keybind={command.keybind("file.attach")} > + + +
+ {language.t("prompt.action.stop")} + {language.t("common.key.esc")} +
+
+ +
+ {language.t("prompt.action.send")} + +
+
+ + } + > + +
- - -
- {language.t("prompt.action.stop")} - {language.t("common.key.esc")} -
-
- -
- {language.t("prompt.action.send")} - -
-
- - } - > - -
+ + +
+
+ + {language.t("prompt.mode.shell")} + {language.t("prompt.mode.shell.exit")} +
+
+
+ + +
+
+ + + +
+
+
From 9c8e1e41c083d970d75f51a4a4b3154605052e6e Mon Sep 17 00:00:00 2001 From: David Hill Date: Sat, 14 Feb 2026 18:06:17 +0000 Subject: [PATCH 077/121] tweak(app): show shell info in prompt footer dock --- packages/app/src/components/prompt-input.tsx | 162 ++++++++++--------- 1 file changed, 82 insertions(+), 80 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 778bb015d07e..874aa681a93f 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1154,16 +1154,6 @@ export const PromptInput: Component = (props) => {
- -
-
- - {language.t("prompt.mode.shell")} - {language.t("prompt.mode.shell.exit")} -
-
-
-
@@ -1201,42 +1191,81 @@ export const PromptInput: Component = (props) => {
- +
- - + + 0} + fallback={ + + + + } + > - + - } - > + - - - - - {local.model.current()?.name ?? language.t("dialog.model.select.title")} - - + (x === "default" ? language.t("common.default") : x)} - onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)} - class="capitalize max-w-[160px]" - valueClass="truncate text-13-regular" - triggerStyle={{ height: "28px" }} - variant="ghost" - /> -
From ef24b05ca4d401e53a980f380e5f524670be90dd Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 16 Feb 2026 13:52:14 +0000 Subject: [PATCH 078/121] tweak(app): add prompt icon --- packages/ui/src/components/icon.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 544c6abdd214..f422022eba5a 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -7,6 +7,7 @@ const icons = { "arrow-right": ``, archive: ``, "bubble-5": ``, + prompt: ``, brain: ``, "bullet-list": ``, "check-small": ``, From 9ce8c33cffcc05662bec9ed5280b3a0af3aa1919 Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 16 Feb 2026 13:52:42 +0000 Subject: [PATCH 079/121] tweak(app): add toggle for shell --- packages/app/src/components/prompt-input.tsx | 211 ++++++++++++------- packages/ui/src/components/inline-input.css | 2 +- packages/ui/src/components/inline-input.tsx | 14 +- 3 files changed, 144 insertions(+), 83 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 874aa681a93f..374ebef3160a 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -295,6 +295,12 @@ export const PromptInput: Component = (props) => { const pick = () => fileInputRef?.click() + const setMode = (mode: "normal" | "shell") => { + setStore("mode", mode) + setStore("popover", null) + requestAnimationFrame(() => editorRef?.focus()) + } + command.register("prompt-input", () => [ { id: "file.attach", @@ -1192,50 +1198,80 @@ export const PromptInput: Component = (props) => {
-
-
- -
- - {language.t("prompt.mode.shell")} - {language.t("prompt.mode.shell.exit")} -
-
+
+
+
+ +
+ {language.t("prompt.mode.shell")} +
+
+ - - - + + 0} + fallback={ + + + + } + > - + - } - > + - - - - - - {local.model.current()?.name ?? language.t("dialog.model.select.title")} - - - + (x === "default" ? language.t("common.default") : x)} - onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)} - class="capitalize max-w-[160px]" - valueClass="truncate text-13-regular" - triggerStyle={{ height: "28px" }} - variant="ghost" +
- - + + +
+
diff --git a/packages/ui/src/components/inline-input.css b/packages/ui/src/components/inline-input.css index 1d8a00e08d03..503dfe2863c4 100644 --- a/packages/ui/src/components/inline-input.css +++ b/packages/ui/src/components/inline-input.css @@ -12,6 +12,6 @@ &:focus { outline: none; - box-shadow: 0 0 0 1px var(--border-interactive-focus); + box-shadow: var(--inline-input-shadow, 0 0 0 1px var(--border-interactive-focus)); } } diff --git a/packages/ui/src/components/inline-input.tsx b/packages/ui/src/components/inline-input.tsx index 72711a197f11..2e78e2304ccd 100644 --- a/packages/ui/src/components/inline-input.tsx +++ b/packages/ui/src/components/inline-input.tsx @@ -6,6 +6,16 @@ export type InlineInputProps = ComponentProps<"input"> & { } export function InlineInput(props: InlineInputProps) { - const [local, others] = splitProps(props, ["class", "width"]) - return + const [local, others] = splitProps(props, ["class", "width", "style"]) + + const style = () => { + if (!local.style) return { width: local.width } + if (typeof local.style === "string") { + if (!local.width) return local.style + return `${local.style};width:${local.width}` + } + return { width: local.width, ...local.style } + } + + return } From 324db666da96b12d08481a7fc2d264a826a032fb Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 16 Feb 2026 13:55:21 +0000 Subject: [PATCH 080/121] tweak(app): improve message timeline title padding --- packages/app/src/pages/session/message-timeline.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index dc187928205f..c67e389541d0 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -170,7 +170,7 @@ export function MessageTimeline(props: { }} >
-
+
+

{props.title}

} @@ -193,7 +196,8 @@ export function MessageTimeline(props: { ref={props.titleRef} value={props.titleState.draft} disabled={props.titleState.saving} - class="text-14-medium text-text-strong grow-1 min-w-0" + class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]" + style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }} onInput={(event) => props.onTitleDraft(event.currentTarget.value)} onKeyDown={(event) => { event.stopPropagation() From 63b9fd502db897b524bd6a94f5df3fc08891f8ce Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 16 Feb 2026 13:56:23 +0000 Subject: [PATCH 081/121] tweak(ui): remove diffs trigger horizontal padding --- packages/ui/src/components/session-turn.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index 153648754acd..b154b1045eae 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -87,7 +87,7 @@ align-items: center; justify-content: flex-start; gap: 8px; - padding: 0 4px; + padding: 0; } [data-slot="session-turn-diffs-title"] { From 67fa93adee07359d66636901e56b98f4a6bddcf7 Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 16 Feb 2026 13:59:21 +0000 Subject: [PATCH 082/121] tweak(ui): nudge diffs bars lower --- 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 b154b1045eae..5d58f0f7118a 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -117,6 +117,10 @@ align-items: center; gap: 8px; flex-shrink: 0; + + [data-component="diff-changes"][data-variant="bars"] { + transform: translateY(1px); + } } [data-component="session-turn-diffs-content"] { From bce3551af284c8c9840a733dbb135338af5ea6c9 Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 16 Feb 2026 14:14:11 +0000 Subject: [PATCH 083/121] tweak(app/ui): use chevrons for history nav --- packages/app/src/components/titlebar.tsx | 66 ++++++++++++++++-------- packages/ui/src/components/icon.tsx | 3 +- 2 files changed, 46 insertions(+), 23 deletions(-) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 039a25faee80..ad50742bff09 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -1,6 +1,6 @@ import { createEffect, createMemo, Show, untrack } from "solid-js" import { createStore } from "solid-js/store" -import { useLocation, useNavigate } from "@solidjs/router" +import { useLocation, useNavigate, useParams } from "@solidjs/router" import { IconButton } from "@opencode-ai/ui/icon-button" import { Icon } from "@opencode-ai/ui/icon" import { Button } from "@opencode-ai/ui/button" @@ -43,6 +43,7 @@ export function Titlebar() { const theme = useTheme() const navigate = useNavigate() const location = useLocation() + const params = useParams() const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos") const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows") @@ -217,27 +218,48 @@ export function Titlebar() {
-
diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index f422022eba5a..864566d36b8a 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -12,7 +12,8 @@ const icons = { "bullet-list": ``, "check-small": ``, "chevron-down": ``, - "chevron-right": ``, + "chevron-left": ``, + "chevron-right": ``, "chevron-grabber-vertical": ``, "chevron-double-right": ``, "circle-x": ``, From 7b0deeb3523eb70b7b10c0c64dfb06fb194797fb Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 16 Feb 2026 14:17:37 +0000 Subject: [PATCH 084/121] tweak(app): prevent session title jump on edit --- packages/app/src/pages/session/message-timeline.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index c67e389541d0..53cf441d6d8c 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -165,7 +165,7 @@ export function MessageTimeline(props: { "sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true, "w-full": true, "pb-4": true, - "px-4 md:px-6": true, + "pl-2 pr-4 md:pl-4 md:pr-6": true, "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered, }} > @@ -185,7 +185,7 @@ export function MessageTimeline(props: { when={props.titleState.editing} fallback={

{props.title} From f8f81643159b8fa40de50b8059bea1f4c25d15cd Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 16 Feb 2026 14:30:07 +0000 Subject: [PATCH 085/121] tweak(app/ui): polish titlebar icon buttons --- packages/app/src/components/titlebar.tsx | 17 ++++++++++------- packages/ui/src/components/icon-button.css | 6 ++++++ packages/ui/src/components/icon.tsx | 1 + 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index ad50742bff09..fde63506ef13 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -172,7 +172,8 @@ export function Titlebar() { @@ -183,7 +184,8 @@ export function Titlebar() { @@ -198,7 +200,8 @@ export function Titlebar() { >

-
+
@@ -232,7 +232,7 @@ export function Titlebar() {
@@ -185,9 +185,9 @@ export function Titlebar() { icon="menu" variant="ghost" class="titlebar-icon rounded-md" - style={layout.mobileSidebar.opened() ? { "background-color": "var(--surface-base-active)" } : undefined} onClick={layout.mobileSidebar.toggle} aria-label={language.t("sidebar.menu.toggle")} + aria-expanded={layout.mobileSidebar.opened()} />
@@ -201,7 +201,6 @@ export function Titlebar() {
From 7118a69b90636a34f94f62dc527d8d3faac0c930 Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 16 Feb 2026 14:59:03 +0000 Subject: [PATCH 089/121] tweak(app/ui): update file tree toggle icon --- packages/app/src/components/session/session-header.tsx | 2 +- packages/ui/src/components/icon.tsx | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 4ed422076a39..16bc4cd1249d 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -632,7 +632,7 @@ export function SessionHeader() {
`, enter: ``, folder: ``, + "file-tree": ``, + "file-tree-active": ``, "magnifying-glass": ``, "plus-small": ``, plus: ``, From 1464c61a4f984c6ac46d92e3940df466e33c2c4b Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 16 Feb 2026 15:16:36 +0000 Subject: [PATCH 090/121] tweak(app/ui): update review toggle icon --- packages/ui/src/components/icon.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 90e86ed8e552..8fe2be71c7a8 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -46,9 +46,9 @@ const icons = { "layout-left": ``, "layout-left-partial": ``, "layout-left-full": ``, - "layout-right": ``, - "layout-right-partial": ``, - "layout-right-full": ``, + "layout-right": ``, + "layout-right-partial": ``, + "layout-right-full": ``, "square-arrow-top-right": ``, "speech-bubble": ``, comment: ``, From 045a8b557571c3e90d5bc9dcb75b7a0049db210b Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 16 Feb 2026 15:24:23 +0000 Subject: [PATCH 091/121] tweak(app): animate file tree toggle reveal --- .../src/components/session/session-header.tsx | 65 ++++++++++++------- 1 file changed, 41 insertions(+), 24 deletions(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 16bc4cd1249d..fa16e537422b 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -616,32 +616,49 @@ export function SessionHeader() {
From b88ffd60ceaf23e550188039dc8079750599a38f Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 16 Feb 2026 15:28:11 +0000 Subject: [PATCH 092/121] tweak(app): tighten open-in button padding --- packages/app/src/components/session/session-header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index fa16e537422b..68d19a22d126 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -372,7 +372,7 @@ export function SessionHeader() {
- - -
- {language.t("prompt.action.stop")} - {language.t("common.key.esc")} -
-
- -
- {language.t("prompt.action.send")} - -
-
- - } - > - -
+ + + +
+ {language.t("prompt.action.stop")} + {language.t("common.key.esc")} +
+
+ +
+ {language.t("prompt.action.send")} + +
+
+ + } + > + +
+
From f9f022cdd2ea08e28727f80e8df07a2c69b11859 Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 16 Feb 2026 16:00:28 +0000 Subject: [PATCH 099/121] fix(app): hide comments in shell mode --- packages/app/src/components/prompt-input.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 9a919853ab88..c26c9464400d 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -93,7 +93,6 @@ export const PromptInput: Component = (props) => { const local = useLocal() const files = useFile() const prompt = usePrompt() - const commentCount = createMemo(() => prompt.context.items().filter((item) => !!item.comment?.trim()).length) const layout = useLayout() const comments = useComments() const params = useParams() @@ -223,6 +222,17 @@ export const PromptInput: Component = (props) => { applyingHistory: false, }) + const commentCount = createMemo(() => { + if (store.mode === "shell") return 0 + return prompt.context.items().filter((item) => !!item.comment?.trim()).length + }) + + const contextItems = createMemo(() => { + const items = prompt.context.items() + if (store.mode !== "shell") return items + return items.filter((item) => !item.comment?.trim()) + }) + const hasUserPrompt = createMemo(() => { const sessionID = params.id if (!sessionID) return false @@ -1027,7 +1037,7 @@ export const PromptInput: Component = (props) => { label={language.t(store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label")} /> { const active = comments.active() return !!item.commentID && item.commentID === active?.id && item.path === active?.file From 0bbc919cf0c6b32ec772698db6bb7daeb0e917d8 Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 16 Feb 2026 16:08:29 +0000 Subject: [PATCH 100/121] tweak(app): strengthen shell mode toggle icon --- packages/app/src/components/prompt-input.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index c26c9464400d..f58dbff8f672 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1344,7 +1344,7 @@ export const PromptInput: Component = (props) => { name="console" size="normal" classList={{ - "text-icon-strong": store.mode === "shell", + "text-icon-strong-base": store.mode === "shell", "text-icon-weak": store.mode !== "shell", }} /> From 415e98c4476c1ea006db0fd62de826cb622adb19 Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 16 Feb 2026 16:12:05 +0000 Subject: [PATCH 101/121] tweak(app): animate prompt action buttons --- packages/app/src/components/prompt-input.tsx | 107 ++++++++++--------- 1 file changed, 57 insertions(+), 50 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index f58dbff8f672..21f87be87cc2 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1121,57 +1121,64 @@ export const PromptInput: Component = (props) => { }} /> -
- - - - - - - - -
- {language.t("prompt.action.stop")} - {language.t("common.key.esc")} -
-
- -
- {language.t("prompt.action.send")} - -
-
- - } +
+ + + + + + +
+ {language.t("prompt.action.stop")} + {language.t("common.key.esc")} +
+
+ +
+ {language.t("prompt.action.send")} + +
+
+ + } + > + +
From 7db5c938029a215c7381bff3d2b2145728143c1e Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 16 Feb 2026 16:22:12 +0000 Subject: [PATCH 102/121] tweak(ui): make question progress segments 16px --- packages/ui/src/components/message-part.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index f2118efdc6fb..771880c2bcab 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -761,7 +761,7 @@ background-color 0.2s ease; &[data-active="true"] { - width: 48px; + width: 16px; } &[data-answered="true"] { From 0cab45da6e8fb78fe2e36c38cab9bec8c1289257 Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 16 Feb 2026 16:23:24 +0000 Subject: [PATCH 103/121] tweak(app): remove question dock margin --- packages/app/src/pages/session/session-prompt-dock.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/pages/session/session-prompt-dock.tsx b/packages/app/src/pages/session/session-prompt-dock.tsx index 853d0bb9b6a4..474ebccbcef1 100644 --- a/packages/app/src/pages/session/session-prompt-dock.tsx +++ b/packages/app/src/pages/session/session-prompt-dock.tsx @@ -109,7 +109,7 @@ export function SessionPromptDock(props: { {(req) => { return ( -
+
) From 356f6b9b3f65915cfa349fb9f6487fb5c6b7933c Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 16 Feb 2026 16:26:41 +0000 Subject: [PATCH 104/121] tweak(ui): highlight active question segment --- packages/ui/src/components/message-part.css | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 771880c2bcab..21fd63576f97 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -762,6 +762,7 @@ &[data-active="true"] { width: 16px; + background-color: var(--icon-strong-base); } &[data-answered="true"] { From c0da5e79bb68009a6f5d1baf78b14d870480f4f1 Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 16 Feb 2026 16:31:33 +0000 Subject: [PATCH 105/121] tweak(ui): match question pick state to comment hover --- packages/ui/src/components/message-part.css | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 21fd63576f97..dea1bcc31002 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -809,20 +809,23 @@ background-color: var(--surface-raised-stronger-non-alpha); border: 1px solid var(--border-weak-base); border-radius: 6px; + box-shadow: none; text-align: left; width: 100%; cursor: pointer; transition: background-color 0.15s ease, - border-color 0.15s ease; + border-color 0.15s ease, + box-shadow 0.15s ease; &:hover:not([data-picked="true"]) { background-color: var(--background-base); } &[data-picked="true"] { - background-color: var(--surface-base-interactive-active); - border-color: var(--border-weak-selected); + background-color: var(--surface-interactive-weak); + border-color: transparent; + box-shadow: var(--shadow-xs-border-hover); } &:disabled { From 948ab25e11812c1b8b6997735bc3f1cf318258d6 Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 16 Feb 2026 16:34:08 +0000 Subject: [PATCH 106/121] tweak(ui): adjust question radio picked colors --- packages/ui/src/components/message-part.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index dea1bcc31002..8a0f74fd00e4 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -867,7 +867,7 @@ width: 6px; height: 6px; border-radius: 999px; - background-color: var(--icon-interactive-base); + background-color: var(--background-stronger); opacity: 0; } @@ -884,7 +884,7 @@ } &[data-type="radio"] { - background-color: transparent; + background-color: var(--icon-interactive-base); [data-slot="question-option-radio-dot"] { opacity: 1; } From 5bf98404b9d29c77e3aced87c006ab7cae1c84d7 Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 16 Feb 2026 16:38:40 +0000 Subject: [PATCH 107/121] tweak(app): make question progress segments clickable --- packages/app/src/components/question-dock.tsx | 14 +++++++-- packages/ui/src/components/message-part.css | 31 ++++++++++++++----- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/packages/app/src/components/question-dock.tsx b/packages/app/src/components/question-dock.tsx index 1a4074e8c148..6b2a71c14d21 100644 --- a/packages/app/src/components/question-dock.tsx +++ b/packages/app/src/components/question-dock.tsx @@ -142,18 +142,28 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => setStore("editing", false) } + const jump = (tab: number) => { + if (store.sending) return + setStore("tab", tab) + setStore("editing", false) + } + return (
{summary()}
-