From 95e20e8a350e3f2a90a518f6cb8ee8df6b133386 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Tue, 17 Mar 2026 23:42:22 -0500 Subject: [PATCH] UI: add expand-to-canvas button and session navigation from sessions/cron views --- ui/src/styles/chat/grouped.css | 65 ++++++++++++++--------- ui/src/styles/components.css | 90 ++++++++++++++++++++++++++++++++ ui/src/ui/app-render.helpers.ts | 2 +- ui/src/ui/app-render.ts | 14 +++-- ui/src/ui/app-view-state.ts | 1 - ui/src/ui/chat/grouped-render.ts | 26 ++++++++- ui/src/ui/icons.ts | 7 +++ ui/src/ui/views/cron.ts | 1 + ui/src/ui/views/sessions.ts | 46 +++++++++++++++- 9 files changed, 219 insertions(+), 33 deletions(-) diff --git a/ui/src/styles/chat/grouped.css b/ui/src/styles/chat/grouped.css index 16cf15d51ee..bce1c2422cf 100644 --- a/ui/src/styles/chat/grouped.css +++ b/ui/src/styles/chat/grouped.css @@ -194,10 +194,31 @@ img.chat-avatar { padding-right: 36px; } -.chat-copy-btn { +.chat-bubble-actions { position: absolute; top: 6px; right: 8px; + display: flex; + gap: 4px; + opacity: 0; + pointer-events: none; + transition: opacity 120ms ease-out; +} + +.chat-bubble:hover .chat-bubble-actions { + opacity: 1; + pointer-events: auto; +} + +@media (hover: none) { + .chat-bubble-actions { + opacity: 1; + pointer-events: auto; + } +} + +.chat-copy-btn, +.chat-expand-btn { border: 1px solid var(--border); background: var(--bg); color: var(--muted); @@ -206,11 +227,7 @@ img.chat-avatar { font-size: 14px; line-height: 1; cursor: pointer; - opacity: 0; - pointer-events: none; - transition: - opacity 120ms ease-out, - background 120ms ease-out; + transition: background 120ms ease-out; } .chat-copy-btn__icon { @@ -250,12 +267,8 @@ img.chat-avatar { opacity: 1; } -.chat-bubble:hover .chat-copy-btn { - opacity: 1; - pointer-events: auto; -} - -.chat-copy-btn:hover { +.chat-copy-btn:hover, +.chat-expand-btn:hover { background: var(--bg-hover); } @@ -265,33 +278,37 @@ img.chat-avatar { } .chat-copy-btn[data-error="1"] { - opacity: 1; - pointer-events: auto; border-color: var(--danger-subtle); background: var(--danger-subtle); color: var(--danger); } .chat-copy-btn[data-copied="1"] { - opacity: 1; - pointer-events: auto; border-color: var(--ok-subtle); background: var(--ok-subtle); color: var(--ok); } -.chat-copy-btn:focus-visible { - opacity: 1; - pointer-events: auto; +.chat-copy-btn:focus-visible, +.chat-expand-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } -@media (hover: none) { - .chat-copy-btn { - opacity: 1; - pointer-events: auto; - } +.chat-expand-btn__icon { + display: inline-flex; + width: 14px; + height: 14px; +} + +.chat-expand-btn__icon svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; } /* Light mode: restore borders */ diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index d4835d42aad..4edba864cd3 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -1312,6 +1312,96 @@ font: inherit; } +/* Code block wrapper chrome (generated by markdown renderer) */ + +.code-block-wrapper { + position: relative; + border-radius: 6px; + overflow: hidden; + margin-top: 0.75em; +} + +.code-block-wrapper pre { + margin: 0; + border-radius: 0 0 6px 6px; +} + +.code-block-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 4px 8px 4px 12px; + background: rgba(0, 0, 0, 0.25); + font-size: 12px; + line-height: 1; +} + +:root[data-theme-mode="light"] .code-block-header { + background: rgba(0, 0, 0, 0.08); +} + +.code-block-lang { + color: var(--muted); + font-family: var(--mono); + font-size: 11px; + text-transform: lowercase; + user-select: none; +} + +.code-block-copy { + appearance: none; + border: none; + background: transparent; + color: var(--muted); + font-size: 11px; + font-family: var(--font-body); + cursor: pointer; + padding: 2px 6px; + border-radius: var(--radius-sm); + transition: + color 150ms ease, + background 150ms ease; +} + +.code-block-copy:hover { + color: var(--text); + background: rgba(255, 255, 255, 0.1); +} + +:root[data-theme-mode="light"] .code-block-copy:hover { + background: rgba(0, 0, 0, 0.08); +} + +.code-block-copy__done { + display: none; +} + +.code-block-copy.copied .code-block-copy__idle { + display: none; +} + +.code-block-copy.copied .code-block-copy__done { + display: inline; + color: var(--success, #22c55e); +} + +.json-collapse { + margin-top: 0.75em; +} + +.json-collapse summary { + cursor: pointer; + font-size: 12px; + color: var(--muted); + padding: 4px 8px; + user-select: none; +} + +.json-collapse summary:hover { + color: var(--text); +} + /* =========================================== Lists =========================================== */ diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index e83825ab899..d27fb221582 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -487,7 +487,7 @@ export function renderChatMobileToggle(state: AppViewState) { `; } -function switchChatSession(state: AppViewState, nextSessionKey: string) { +export function switchChatSession(state: AppViewState, nextSessionKey: string) { state.sessionKey = nextSessionKey; state.chatMessage = ""; state.chatStream = null; diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index dd9ac932a2e..c0535cd6c30 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -14,6 +14,7 @@ import { renderTab, renderSidebarConnectionStatus, renderTopbarThemeModeToggle, + switchChatSession, } from "./app-render.helpers.ts"; import type { AppViewState } from "./app-view-state.ts"; import { loadAgentFileContent, loadAgentFiles, saveAgentFile } from "./controllers/agent-files.ts"; @@ -765,6 +766,10 @@ export function renderApp(state: AppViewState) { onRefresh: () => loadSessions(state), onPatch: (key, patch) => patchSession(state, key, patch), onDelete: (key) => deleteSessionAndRefresh(state, key), + onNavigateToChat: (sessionKey) => { + switchChatSession(state, sessionKey); + state.setTab("chat" as import("./navigation.ts").Tab); + }, }), ) : nothing @@ -865,6 +870,10 @@ export function renderApp(state: AppViewState) { } await loadCronRuns(state, state.cronRunsJobId); }, + onNavigateToChat: (sessionKey) => { + switchChatSession(state, sessionKey); + state.setTab("chat" as import("./navigation.ts").Tab); + }, }), ) : nothing @@ -1432,10 +1441,7 @@ export function renderApp(state: AppViewState) { state.setTab("agents" as import("./navigation.ts").Tab); }, onSessionSelect: (key: string) => { - state.setSessionKey(key); - state.chatMessages = []; - void loadChatHistory(state); - void state.loadAssistantIdentity(); + switchChatSession(state, key); }, showNewMessages: state.chatNewMessagesBelow && !state.chatManualRefreshInFlight, onScrollToBottom: () => state.scrollToBottom(), diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 4e9742fbdbc..df806794645 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -357,7 +357,6 @@ export type AppViewState = { handleDebugCall: () => Promise; handleRunUpdate: () => Promise; setPassword: (next: string) => void; - setSessionKey: (next: string) => void; setChatMessage: (next: string) => void; handleSendChat: (messageOverride?: string, opts?: { restoreDraft?: boolean }) => Promise; handleAbortChat: () => Promise; diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index 7dcc0b62e19..7271ccdf394 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -620,6 +620,20 @@ function jsonSummaryLabel(parsed: unknown): string { return "JSON"; } +function renderExpandButton(markdown: string, onOpenSidebar: (content: string) => void) { + return html` + + `; +} + function renderGroupedMessage( message: unknown, opts: { isStreaming: boolean; showReasoning: boolean; showToolCalls?: boolean }, @@ -647,6 +661,7 @@ function renderGroupedMessage( const reasoningMarkdown = extractedThinking ? formatReasoningMarkdown(extractedThinking) : null; const markdown = markdownBase; const canCopyMarkdown = role === "assistant" && Boolean(markdown?.trim()); + const canExpand = Boolean(onOpenSidebar && markdown?.trim()); // Detect pure-JSON messages and render as collapsible block const jsonResult = markdown && !opts.isStreaming ? detectJson(markdown) : null; @@ -674,9 +689,18 @@ function renderGroupedMessage( const toolPreview = markdown && !toolSummaryLabel ? markdown.trim().replace(/\s+/g, " ").slice(0, 120) : ""; + const hasActions = canCopyMarkdown || canExpand; + return html`
- ${canCopyMarkdown ? html`
${renderCopyAsMarkdownButton(markdown!)}
` : nothing} + ${ + hasActions + ? html`
+ ${canExpand ? renderExpandButton(markdown!, onOpenSidebar!) : nothing} + ${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing} +
` + : nothing + } ${ isToolMessage ? html` diff --git a/ui/src/ui/icons.ts b/ui/src/ui/icons.ts index de594541110..39815b8aa0b 100644 --- a/ui/src/ui/icons.ts +++ b/ui/src/ui/icons.ts @@ -441,6 +441,13 @@ export const icons = { `, + panelRightOpen: html` + + + + + + `, } as const; export type IconName = keyof typeof icons; diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index 1509637b46f..d105bbf0b2c 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -91,6 +91,7 @@ export type CronProps = { cronRunsQuery?: string; cronRunsSortDir?: CronSortDir; }) => void | Promise; + onNavigateToChat?: (sessionKey: string) => void; }; function getRunStatusOptions(): Array<{ value: CronRunsStatusValue; label: string }> { diff --git a/ui/src/ui/views/sessions.ts b/ui/src/ui/views/sessions.ts index 2620ec35acf..e028b7b4c85 100644 --- a/ui/src/ui/views/sessions.ts +++ b/ui/src/ui/views/sessions.ts @@ -43,6 +43,7 @@ export type SessionsProps = { }, ) => void; onDelete: (key: string) => void; + onNavigateToChat?: (sessionKey: string) => void; }; const THINK_LEVELS = ["", "off", "minimal", "low", "medium", "high", "xhigh"] as const; @@ -337,6 +338,7 @@ export function renderSessions(props: SessionsProps) { props.onActionsOpenChange, props.actionsOpenKey, props.loading, + props.onNavigateToChat, ), ) } @@ -391,6 +393,7 @@ function renderRow( onActionsOpenChange: (key: string | null) => void, actionsOpenKey: string | null, disabled: boolean, + onNavigateToChat?: (sessionKey: string) => void, ) { const updated = row.updatedAt ? formatRelativeTimestamp(row.updatedAt) : "n/a"; const rawThinking = row.thinkingLevel ?? ""; @@ -430,7 +433,30 @@ function renderRow(