From 0b1041ddd174694f0d5ef4bd7a1c444d1a7673d7 Mon Sep 17 00:00:00 2001 From: test Date: Mon, 7 Sep 2026 23:40:42 -0700 Subject: [PATCH 01/26] web-ui: the transcript clips at its edges instead of dissolving into them The chat scroller painted a gradient plus a mask at both edges, the pinned user row faded the transcript beneath it, and a collapsed plain-text fence faded its own overflow. Three variations on the same idea: dissolve content into the background so a hard edge never shows. They cost more than they bought. The edge pseudo-elements sat above the transcript with a backdrop blur, so text passing under them went soft before it left the viewport, and the sticky row needed a scroll listener toggling a class on the last user row to know when to switch its fade on. Replace all of it with the plain thing: the pinned row paints var(--background) and occludes what it covers, and the scroller clips at its own bounds. --chat-edge-fade becomes --chat-prompt-gap, which is what the surviving use was always for: the space under the pinned prompt. sticky-prompt-fade.test.ts asserted the fade existed, so it goes; a new occlusion test asserts the replacement and that no transcript surface dissolves content into the background. Co-Authored-By: Claude Opus 5 (1M context) --- plugins/web-ui/package-lock.json | 7 ++ plugins/web-ui/src/chat.ts | 11 --- plugins/web-ui/src/shell.css | 76 +------------------ .../web-ui/test/plain-text-code-wrap.test.ts | 3 +- .../web-ui/test/sticky-prompt-fade.test.ts | 14 ---- .../test/sticky-prompt-occlusion.test.ts | 25 ++++++ 6 files changed, 38 insertions(+), 98 deletions(-) delete mode 100644 plugins/web-ui/test/sticky-prompt-fade.test.ts create mode 100644 plugins/web-ui/test/sticky-prompt-occlusion.test.ts diff --git a/plugins/web-ui/package-lock.json b/plugins/web-ui/package-lock.json index 5bd6f43ed..49d6b5535 100644 --- a/plugins/web-ui/package-lock.json +++ b/plugins/web-ui/package-lock.json @@ -664,6 +664,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -712,6 +713,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -1333,6 +1335,7 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/@mariozechner/mini-lit/-/mini-lit-0.2.1.tgz", "integrity": "sha512-u300euLgCsDDlb8o2Wbz+55eSJga5X2vB58s9XBuFIr2Bi3iI+GMR7t/NYo/O6Vr6obXShXgYjR3SRUJVgo+kQ==", + "peer": true, "dependencies": { "@preact/signals-core": "^1.12.1", "class-variance-authority": "^0.7.1", @@ -2952,6 +2955,7 @@ "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz", "integrity": "sha512-fycuvZg/hkpozL00lm1pEJH5nN/lr9ZXd6mJI2HSN4+Bzc+LDNdEApJ6HFbPkdFNHLvOplIIuJvxkS4XUxqirw==", "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", @@ -3189,6 +3193,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3449,6 +3454,7 @@ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/dcastil" @@ -3829,6 +3835,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/plugins/web-ui/src/chat.ts b/plugins/web-ui/src/chat.ts index cf49fc66f..35e0c91a8 100644 --- a/plugins/web-ui/src/chat.ts +++ b/plugins/web-ui/src/chat.ts @@ -2660,15 +2660,6 @@ export function createChatSurface( function onTranscriptScroll(e: Event): void { const s = e.currentTarget as HTMLElement; stickToBottom = s.scrollHeight - s.scrollTop - s.clientHeight <= 120; - markStuckUserRow(s); - } - - function markStuckUserRow(scroller: HTMLElement): void { - const rows = scroller.querySelectorAll(".message-stack .user-row"); - const row = rows.length ? rows[rows.length - 1] : undefined; - if (!row) return; - const stuck = row.getBoundingClientRect().top <= scroller.getBoundingClientRect().top + 0.5; - row.classList.toggle("stuck", stuck); } function scrollToBottom(): void { @@ -2679,10 +2670,8 @@ export function createChatSurface( function scrollTranscript(force = false): void { const scroller = ctx.container()?.querySelector(".chat-scroll"); if (!scroller) return; - markStuckUserRow(scroller); if (!force && !stickToBottom) return; requestAnimationFrame(() => { - markStuckUserRow(scroller); if (force) { const prev = scroller.style.scrollBehavior; scroller.style.scrollBehavior = "auto"; diff --git a/plugins/web-ui/src/shell.css b/plugins/web-ui/src/shell.css index b63343211..3abe86d88 100644 --- a/plugins/web-ui/src/shell.css +++ b/plugins/web-ui/src/shell.css @@ -1312,7 +1312,7 @@ a.chat-row-open { --chat-scroll-pad-top: 28px; container-type: size; - --chat-edge-fade: 24px; + --chat-prompt-gap: 24px; min-height: 0; overflow-y: auto; scrollbar-gutter: stable; @@ -1321,46 +1321,6 @@ a.chat-row-open { scroll-behavior: smooth; } -.chat-scroll::before, -.chat-scroll::after { - content: ""; - display: block; - position: sticky; - z-index: 2; - height: var(--chat-edge-fade); - margin-left: calc(-1 * var(--chat-pad)); - margin-right: calc(-1 * var(--chat-pad)); - pointer-events: none; - backdrop-filter: blur(1px); - -webkit-backdrop-filter: blur(1px); -} -.chat-scroll::before { - top: calc(-1 * var(--chat-scroll-pad-top)); - margin-top: calc(-1 * var(--chat-scroll-pad-top)); - margin-bottom: calc(var(--chat-scroll-pad-top) - var(--chat-edge-fade)); - background: linear-gradient( - to bottom, - var(--background), - color-mix(in srgb, var(--background) 74%, transparent) 38%, - color-mix(in srgb, var(--background) 32%, transparent) 68%, - transparent - ); - mask-image: linear-gradient(to bottom, #000, rgb(0 0 0 / 0.74) 38%, rgb(0 0 0 / 0.32) 68%, transparent); - -webkit-mask-image: linear-gradient(to bottom, #000, rgb(0 0 0 / 0.74) 38%, rgb(0 0 0 / 0.32) 68%, transparent); -} -.chat-scroll::after { - bottom: 0; - background: linear-gradient( - to top, - var(--background), - color-mix(in srgb, var(--background) 74%, transparent) 38%, - color-mix(in srgb, var(--background) 32%, transparent) 68%, - transparent - ); - mask-image: linear-gradient(to top, #000, rgb(0 0 0 / 0.74) 38%, rgb(0 0 0 / 0.32) 68%, transparent); - -webkit-mask-image: linear-gradient(to top, #000, rgb(0 0 0 / 0.74) 38%, rgb(0 0 0 / 0.32) 68%, transparent); -} - .fork-origin-row { display: flex; align-items: center; @@ -1571,36 +1531,10 @@ a.chat-row-open { z-index: 3; padding-top: 14px; margin-top: -14px; - margin-bottom: var(--chat-edge-fade); + margin-bottom: var(--chat-prompt-gap); background: var(--background); } -.message-stack .user-row:not(:has(~ .user-row))::after { - content: ""; - position: absolute; - left: calc(-1 * var(--chat-pad)); - right: calc(-1 * var(--chat-pad)); - top: 100%; - height: var(--chat-edge-fade, 20px); - pointer-events: none; - opacity: 0; - background: linear-gradient( - to bottom, - var(--background), - color-mix(in srgb, var(--background) 74%, transparent) 38%, - color-mix(in srgb, var(--background) 32%, transparent) 68%, - transparent - ); - mask-image: linear-gradient(to bottom, #000, rgb(0 0 0 / 0.74) 38%, rgb(0 0 0 / 0.32) 68%, transparent); - -webkit-mask-image: linear-gradient(to bottom, #000, rgb(0 0 0 / 0.74) 38%, rgb(0 0 0 / 0.32) 68%, transparent); - backdrop-filter: blur(3px); - -webkit-backdrop-filter: blur(3px); - transition: opacity 120ms ease; -} -.message-stack .user-row.stuck:not(:has(~ .user-row))::after { - opacity: 1; -} - .assistant-row + .user-row { margin-top: 10px; } @@ -2801,8 +2735,6 @@ a.chat-row-open { .user-bubble code-block.text-code-collapsible[data-expanded="false"] .text-code-body { max-height: 76px; overflow: hidden; - mask-image: linear-gradient(to bottom, #000, rgb(0 0 0 / 0.74) 38%, rgb(0 0 0 / 0.32) 68%, transparent); - -webkit-mask-image: linear-gradient(to bottom, #000, rgb(0 0 0 / 0.74) 38%, rgb(0 0 0 / 0.32) 68%, transparent); } .text-code-toggle { margin-left: auto; @@ -7242,7 +7174,7 @@ h3.ambient-field-label { [data-density="compact"] .chat-scroll { --chat-scroll-pad-top: 14px; - --chat-edge-fade: 14px; + --chat-prompt-gap: 14px; padding: var(--chat-scroll-pad-top) var(--chat-pad) 0; } [data-density="compact"] .message-row { @@ -8772,7 +8704,7 @@ h3.ambient-field-label { } .mini-convo-body .chat-scroll { --chat-scroll-pad-top: 12px; - --chat-edge-fade: 12px; + --chat-prompt-gap: 12px; --chat-pad: 12px; container-type: normal; max-height: 250px; diff --git a/plugins/web-ui/test/plain-text-code-wrap.test.ts b/plugins/web-ui/test/plain-text-code-wrap.test.ts index b87f51771..09d2cd960 100644 --- a/plugins/web-ui/test/plain-text-code-wrap.test.ts +++ b/plugins/web-ui/test/plain-text-code-wrap.test.ts @@ -41,6 +41,7 @@ test("long plain-text fences collapse by default and remain expandable", () => { assert.match(chat, /normalizePlainTextFences/); assert.match( css, - /code-block\.text-code-collapsible\[data-expanded="false"\] \.text-code-body[^}]*max-height: 76px;[^}]*overflow: hidden;[^}]*mask-image: linear-gradient\(to bottom, #000, rgb\(0 0 0 \/ 0\.74\) 38%, rgb\(0 0 0 \/ 0\.32\) 68%, transparent\);/s, + /code-block\.text-code-collapsible\[data-expanded="false"\] \.text-code-body[^}]*max-height: 76px;[^}]*overflow: hidden;/s, ); + assert.doesNotMatch(css, /code-block\.text-code-collapsible[^}]*mask-image:/s); }); diff --git a/plugins/web-ui/test/sticky-prompt-fade.test.ts b/plugins/web-ui/test/sticky-prompt-fade.test.ts deleted file mode 100644 index fd185a999..000000000 --- a/plugins/web-ui/test/sticky-prompt-fade.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import test from "node:test"; - -const css = readFileSync(new URL("../src/shell.css", import.meta.url), "utf8"); - -test("the sticky prompt reserves the space painted by its lower fade", () => { - const prompt = css.match(/\.message-stack \.user-row:not\(:has\(~ \.user-row\)\) \{[^}]*\}/)?.[0] ?? ""; - const fade = css.match(/\.message-stack \.user-row:not\(:has\(~ \.user-row\)\)::after \{[^}]*\}/)?.[0] ?? ""; - - assert.match(prompt, /margin-bottom: var\(--chat-edge-fade\);/); - assert.match(fade, /top: 100%;/); - assert.match(fade, /height: var\(--chat-edge-fade, 20px\);/); -}); diff --git a/plugins/web-ui/test/sticky-prompt-occlusion.test.ts b/plugins/web-ui/test/sticky-prompt-occlusion.test.ts new file mode 100644 index 000000000..19e62462d --- /dev/null +++ b/plugins/web-ui/test/sticky-prompt-occlusion.test.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const css = readFileSync(new URL("../src/shell.css", import.meta.url), "utf8"); +const chat = readFileSync(new URL("../src/chat.ts", import.meta.url), "utf8"); + +test("the pinned prompt hides the transcript by painting over it, not by fading it", () => { + const prompt = css.match(/\.message-stack \.user-row:not\(:has\(~ \.user-row\)\) \{[^}]*\}/)?.[0] ?? ""; + + assert.match(prompt, /position: sticky;/); + assert.match(prompt, /background: var\(--background\);/); + assert.match(prompt, /margin-bottom: var\(--chat-prompt-gap\);/); + assert.doesNotMatch(css, /\.message-stack \.user-row:not\(:has\(~ \.user-row\)\)::after/); + assert.doesNotMatch(chat, /classList\.toggle\("stuck"/); +}); + +test("the transcript clips at its own edges — no fade at the topbar or the composer", () => { + assert.doesNotMatch(css, /\.chat-scroll::(before|after)/); +}); + +test("no transcript surface dissolves content into the background", () => { + assert.doesNotMatch(css, /linear-gradient\(\s*to (top|bottom),\s*var\(--background\)/s); + assert.doesNotMatch(css, /mask-image:/); +}); From 76f605a83ed0bcbfe849d677ca498c58efeb9568 Mon Sep 17 00:00:00 2001 From: test Date: Mon, 7 Sep 2026 23:57:27 -0700 Subject: [PATCH 02/26] web-ui: a long pinned prompt gets Show more instead of its own scrollbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pinned user row is capped at 40% of the scroller. Past that the body scrolled inside the bubble: a second scrollbar inside the transcript's own, with no sign of how much it hid. It now clips and offers Show more, and the toggle lifts the cap. markClampedPrompts measures the pinned bubble after each render and marks it data-clamped, so the toggle only appears when the text actually overflows. Both transcript surfaces (live and read-only) re-measure. The expanded index lives on chatState and resets when the session changes, so a prompt never comes back expanded under a different conversation. One subtlety the first cut got wrong: settled rows render through settledRowCache, whose key is a hand-written list of the row's mutable render inputs. expandedPrompt was a new one and was missing, so clicking Show more mutated the state, redrew, and got the stale row back from cache — the bubble only changed once some unrelated field (a new message, a work status) happened to invalidate it. The key now covers it, and the test that claims to cover every mutable input has been given the four fields it had drifted past. Co-Authored-By: Claude Opus 5 (1M context) --- plugins/web-ui/src/chat.ts | 39 ++++++- plugins/web-ui/src/prompt-clamp.ts | 11 ++ plugins/web-ui/src/shell.css | 31 +++++- .../test/chat-settled-row-cache.test.ts | 5 + .../web-ui/test/plain-text-code-wrap.test.ts | 2 +- plugins/web-ui/test/prompt-clamp.test.ts | 101 ++++++++++++++++++ plugins/web-ui/test/user-message-cap.test.ts | 2 +- 7 files changed, 184 insertions(+), 7 deletions(-) create mode 100644 plugins/web-ui/src/prompt-clamp.ts create mode 100644 plugins/web-ui/test/prompt-clamp.test.ts diff --git a/plugins/web-ui/src/chat.ts b/plugins/web-ui/src/chat.ts index 35e0c91a8..f8ccd6117 100644 --- a/plugins/web-ui/src/chat.ts +++ b/plugins/web-ui/src/chat.ts @@ -128,6 +128,7 @@ import { createForkOriginController, forkOriginView } from "./fork-origin"; import { base64ToBytes } from "./paste-text"; import { tip } from "./tooltip"; import { workSeconds, workedLabel } from "./work-duration"; +import { markClampedPrompts } from "./prompt-clamp"; import { decorateTextCodeBlocks, normalizePlainTextFences } from "./text-code"; installMarkdownSanitizer(); @@ -148,6 +149,7 @@ interface SettledRowKey { speakerLabel: string | undefined; edited: boolean; deleted: boolean; + expanded: boolean; tpl: TemplateResult | typeof nothing; } const settledRowCache = new WeakMap(); @@ -216,6 +218,7 @@ export function createChatSurface( inheritedLoaded: false, pins: [] as SessionPin[], pinsExpanded: false, + expandedPrompt: null as number | null, labelSpeakers: false, }; @@ -394,6 +397,7 @@ export function createChatSurface( chatState.earlierCount = 0; chatState.loadingEarlier = false; chatState.pins = []; + chatState.expandedPrompt = null; chatState.host = document.createElement("div"); chatState.host.className = "custom-chat"; @@ -924,6 +928,7 @@ export function createChatSurface( if (!sameSession) { chatState.inheritedExpanded = false; chatState.pins = []; + chatState.expandedPrompt = null; } syncLocation(); @@ -1022,7 +1027,10 @@ export function createChatSurface( `, host, ); - requestAnimationFrame(() => decorateTextCodeBlocks(host)); + requestAnimationFrame(() => { + decorateTextCodeBlocks(host); + markClampedPrompts(host); + }); }; readonlyRedraw = draw; draw(); @@ -1061,6 +1069,12 @@ export function createChatSurface( else readonlyRedraw?.(); } + function togglePromptExpanded(index: number): void { + chatState.expandedPrompt = chatState.expandedPrompt === index ? null : index; + if (chatState.agent) drawActiveChat(chatState.agent); + else readonlyRedraw?.(); + } + function linkifiedText(text: string): TemplateResult { return html`${splitLinks(text).map((seg) => seg.kind === "link" @@ -1278,7 +1292,10 @@ export function createChatSurface( chatState.host, ); decorateStreamingTail(); - requestAnimationFrame(() => decorateTextCodeBlocks(chatState.host)); + requestAnimationFrame(() => { + decorateTextCodeBlocks(chatState.host); + markClampedPrompts(chatState.host); + }); ctx.composer.resizeComposer(); scrollTranscript(opts.forceScroll); postCurrentPaneState(); @@ -1414,6 +1431,7 @@ export function createChatSurface( const speakerLabel = speakerLabelFor(message); const edited = Boolean((message as { edited?: boolean }).edited); const deleted = Boolean((message as { deleted?: boolean }).deleted); + const expanded = chatState.expandedPrompt === index; const hit = settledRowCache.get(message as object); if ( hit && @@ -1429,7 +1447,8 @@ export function createChatSurface( hit.forkable === forkable && hit.speakerLabel === speakerLabel && hit.edited === edited && - hit.deleted === deleted + hit.deleted === deleted && + hit.expanded === expanded ) { return hit.tpl; } @@ -1448,6 +1467,7 @@ export function createChatSurface( speakerLabel, edited, deleted, + expanded, tpl, }); return tpl; @@ -1468,10 +1488,21 @@ export function createChatSurface(
${steered ? html`
↪ steered the running task
` : nothing} ${speaker ? html`
${speaker}
` : nothing} -
+
${isReadOnlySlackView() ? slackWireBubble(messageText(message)) : markdown(messageText(message))} ${attachments.length ? html`
${attachments.map(userAttachmentBadge)}
` : nothing} ${edited || deleted ? html`(${deleted ? "deleted" : "edited"})` : nothing} +
${ sendFailure diff --git a/plugins/web-ui/src/prompt-clamp.ts b/plugins/web-ui/src/prompt-clamp.ts new file mode 100644 index 000000000..4458bc8b9 --- /dev/null +++ b/plugins/web-ui/src/prompt-clamp.ts @@ -0,0 +1,11 @@ +const PINNED_BUBBLE = ".message-stack .user-row:not(:has(~ .user-row)) .user-bubble"; + +export function markClampedPrompts(root: ParentNode | null): void { + if (!root) return; + for (const bubble of root.querySelectorAll(PINNED_BUBBLE)) { + if (bubble.dataset.expanded === "true") continue; + const body = bubble.querySelector(":scope > markdown-block, :scope > .slack-wire-text"); + if (!body) continue; + bubble.dataset.clamped = String(body.scrollHeight - body.clientHeight > 1); + } +} diff --git a/plugins/web-ui/src/shell.css b/plugins/web-ui/src/shell.css index 3abe86d88..183f39841 100644 --- a/plugins/web-ui/src/shell.css +++ b/plugins/web-ui/src/shell.css @@ -1726,17 +1726,46 @@ a.chat-row-open { flex-direction: column; max-height: 40cqh; } +.message-stack .user-row:not(:has(~ .user-row)) .user-bubble[data-expanded="true"] { + max-height: none; +} .message-stack .user-row:not(:has(~ .user-row)) .user-bubble > markdown-block, .message-stack .user-row:not(:has(~ .user-row)) .user-bubble > .slack-wire-text { flex-shrink: 1000; min-height: 0; - overflow-y: auto; + overflow: hidden; +} +.message-stack .user-row:not(:has(~ .user-row)) .user-bubble[data-expanded="true"] > markdown-block, +.message-stack .user-row:not(:has(~ .user-row)) .user-bubble[data-expanded="true"] > .slack-wire-text { + overflow: visible; } .message-stack .user-row:not(:has(~ .user-row)) .user-bubble > .message-files { min-height: 0; max-height: 20cqh; overflow-y: auto; } +.prompt-toggle { + display: none; + align-self: flex-start; + flex-shrink: 0; + margin-top: 6px; + padding: 0; + border: 0; + background: transparent; + color: var(--muted-foreground); + font: inherit; + font-size: 12px; + cursor: pointer; +} +.prompt-toggle:hover { + color: var(--foreground); + text-decoration: underline; +} +.message-stack .user-row:not(:has(~ .user-row)) .user-bubble[data-clamped="true"] > .prompt-toggle, +.message-stack .user-row:not(:has(~ .user-row)) .user-bubble[data-expanded="true"] > .prompt-toggle { + display: block; +} + .message-files, .attachment-strip { display: flex; diff --git a/plugins/web-ui/test/chat-settled-row-cache.test.ts b/plugins/web-ui/test/chat-settled-row-cache.test.ts index cc7b1d467..9e3fce457 100644 --- a/plugins/web-ui/test/chat-settled-row-cache.test.ts +++ b/plugins/web-ui/test/chat-settled-row-cache.test.ts @@ -26,7 +26,12 @@ test("the cache key covers every mutable render input of a settled row", () => { "hit.stopReason === msg.stopReason", "hit.errorMessage === msg.errorMessage", "hit.approvalDecision === msg.approvalDecision", + "hit.sendFailure === msg.sendFailure", "hit.forkable === forkable", + "hit.speakerLabel === speakerLabel", + "hit.edited === edited", + "hit.deleted === deleted", + "hit.expanded === expanded", ]) { assert.ok(chat.includes(field), `cache key must compare: ${field}`); } diff --git a/plugins/web-ui/test/plain-text-code-wrap.test.ts b/plugins/web-ui/test/plain-text-code-wrap.test.ts index 09d2cd960..613fff817 100644 --- a/plugins/web-ui/test/plain-text-code-wrap.test.ts +++ b/plugins/web-ui/test/plain-text-code-wrap.test.ts @@ -37,7 +37,7 @@ test("plain-text code fences stay compact without changing source-code fences", }); test("long plain-text fences collapse by default and remain expandable", () => { - assert.match(chat, /requestAnimationFrame\(\(\) => decorateTextCodeBlocks\(/); + assert.match(chat, /requestAnimationFrame\(\(\) => \{\s*decorateTextCodeBlocks\(/); assert.match(chat, /normalizePlainTextFences/); assert.match( css, diff --git a/plugins/web-ui/test/prompt-clamp.test.ts b/plugins/web-ui/test/prompt-clamp.test.ts new file mode 100644 index 000000000..987346f2c --- /dev/null +++ b/plugins/web-ui/test/prompt-clamp.test.ts @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { JSDOM } from "jsdom"; + +import { markClampedPrompts } from "../src/prompt-clamp.ts"; + +const css = readFileSync(new URL("../src/shell.css", import.meta.url), "utf8"); +const chat = readFileSync(new URL("../src/chat.ts", import.meta.url), "utf8"); + +const pinned = String.raw`\.message-stack \.user-row:not\(:has\(~ \.user-row\)\) \.user-bubble`; + +function bubbleDom(scrollHeight: number, clientHeight: number, expanded = "false"): HTMLElement { + const dom = new JSDOM(`
+
+
+ +
+
+
`); + const body = dom.window.document.querySelector("markdown-block")!; + Object.defineProperty(body, "scrollHeight", { value: scrollHeight }); + Object.defineProperty(body, "clientHeight", { value: clientHeight }); + markClampedPrompts(dom.window.document); + return dom.window.document.querySelector(".user-bubble")!; +} + +test("a prompt taller than its cap is marked clamped; one that fits is not", () => { + assert.equal(bubbleDom(800, 240).dataset.clamped, "true"); + assert.equal(bubbleDom(240, 240).dataset.clamped, "false"); +}); + +test("an expanded prompt keeps its clamped mark so Show less stays reachable", () => { + const dom = new JSDOM(`
+
+
+ +
+
+
`); + const body = dom.window.document.querySelector("markdown-block")!; + Object.defineProperty(body, "scrollHeight", { value: 800 }); + Object.defineProperty(body, "clientHeight", { value: 800 }); + markClampedPrompts(dom.window.document); + assert.equal(dom.window.document.querySelector(".user-bubble")!.dataset.clamped, "true"); +}); + +test("only the last user row is measured — earlier prompts are never clamped", () => { + const dom = new JSDOM(`
+
+
+
`); + for (const body of dom.window.document.querySelectorAll("markdown-block")) { + Object.defineProperty(body, "scrollHeight", { value: 800 }); + Object.defineProperty(body, "clientHeight", { value: 240 }); + } + markClampedPrompts(dom.window.document); + const bubbles = dom.window.document.querySelectorAll(".user-bubble"); + assert.equal(bubbles[0].dataset.clamped, undefined); + assert.equal(bubbles[1].dataset.clamped, "true"); +}); + +test("the clamped prompt clips instead of scrolling, and expanding lifts the cap", () => { + const text = + css.match(new RegExp(String.raw`\n${pinned} > markdown-block,\n${pinned} > \.slack-wire-text \{[^}]*\}`))?.[0] ?? + ""; + assert.match(text, /overflow: hidden;/); + assert.doesNotMatch(text, /overflow-y: auto;/); + assert.match(css, new RegExp(String.raw`\n${pinned}\[data-expanded="true"\] \{[^}]*max-height: none;`)); +}); + +test("the toggle is hidden until the prompt actually overflows", () => { + const base = css.match(/\n\.prompt-toggle \{[^}]*\}/)?.[0] ?? ""; + assert.match(base, /display: none;/); + assert.match( + css, + new RegExp( + String.raw`${pinned}\[data-clamped="true"\] > \.prompt-toggle,\n${pinned}\[data-expanded="true"\] > \.prompt-toggle \{\s*display: block;`, + ), + ); +}); + +test("the toggle names its own next action and reports state to assistive tech", () => { + assert.match(chat, /chatState\.expandedPrompt === index \? "Show less" : "Show more"/); + assert.match(chat, /aria-expanded=\$\{chatState\.expandedPrompt === index \? "true" : "false"\}/); + assert.match(chat, /@click=\$\{\(\) => togglePromptExpanded\(index\)\}/); +}); + +test("toggling a prompt redraws through whichever surface is mounted", () => { + const fn = chat.match(/function togglePromptExpanded\(index: number\): void \{[\s\S]*?\n {2}\}/)?.[0] ?? ""; + assert.match(fn, /chatState\.expandedPrompt = chatState\.expandedPrompt === index \? null : index;/); + assert.match(fn, /if \(chatState\.agent\) drawActiveChat\(chatState\.agent\);\s*else readonlyRedraw\?\.\(\);/); +}); + +test("both transcript surfaces re-measure after every render", () => { + assert.equal(chat.match(/markClampedPrompts\((host|chatState\.host)\)/g)?.length, 2); +}); + +test("switching sessions collapses the prompt again — the index never carries over", () => { + assert.equal(chat.match(/chatState\.expandedPrompt = null;/g)?.length, 2); +}); diff --git a/plugins/web-ui/test/user-message-cap.test.ts b/plugins/web-ui/test/user-message-cap.test.ts index b5ccdcedc..48719bdd0 100644 --- a/plugins/web-ui/test/user-message-cap.test.ts +++ b/plugins/web-ui/test/user-message-cap.test.ts @@ -16,7 +16,7 @@ test("only the pinned prompt is capped, at 40% of its own scroller, and its text css.match(new RegExp(String.raw`\n${pinned} > markdown-block,\n${pinned} > \.slack-wire-text \{[^}]*\}`))?.[0] ?? ""; assert.match(text, /flex-shrink: 1000;/); - assert.match(text, /overflow-y: auto;/); + assert.match(text, /overflow: hidden;/); const files = css.match(new RegExp(String.raw`\n${pinned} > \.message-files \{[^}]*\}`))?.[0] ?? ""; assert.match(files, /min-height: 0;/); assert.match(files, /max-height: 20cqh;/); From 0a8b111e58454343f36fe15b6f474e33dab6ecc0 Mon Sep 17 00:00:00 2001 From: test Date: Mon, 7 Sep 2026 23:57:37 -0700 Subject: [PATCH 03/26] web-ui: the message hover actions get a real target to hit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-message action lane reserved 18px (16px compact), so the copy button was an 18px square with a 6px gap to its neighbour — under every pointer-target minimum, and fussy to hit on a trackpad. The lane goes to 24px (20px compact) and the gap to 8px. Both are the same variable the row already reserves as its rhythm, so the buttons and the space between message blocks grow together. Co-Authored-By: Claude Opus 5 (1M context) --- plugins/web-ui/src/shell.css | 6 +++--- plugins/web-ui/test/message-meta-lane.test.ts | 7 +++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/plugins/web-ui/src/shell.css b/plugins/web-ui/src/shell.css index 183f39841..885677b25 100644 --- a/plugins/web-ui/src/shell.css +++ b/plugins/web-ui/src/shell.css @@ -13,7 +13,7 @@ --composer-inset: 13px; /* Height reserved at the bottom of every chat message for its own hover footer; it doubles as the rhythm between message blocks. */ - --meta-lane: 18px; + --meta-lane: 24px; --brand-accent: #4f46e5; --dev-accent: #8a5a00; --brand-mark: none; @@ -1596,7 +1596,7 @@ a.chat-row-open { z-index: 4; display: flex; align-items: center; - gap: 6px; + gap: 8px; height: var(--meta-lane); padding: 0 2px; border-radius: 6px; @@ -7207,7 +7207,7 @@ h3.ambient-field-label { padding: var(--chat-scroll-pad-top) var(--chat-pad) 0; } [data-density="compact"] .message-row { - --meta-lane: 16px; + --meta-lane: 20px; } [data-density="compact"] .message-stack { gap: 0; diff --git a/plugins/web-ui/test/message-meta-lane.test.ts b/plugins/web-ui/test/message-meta-lane.test.ts index 2dddea2ca..50f74195c 100644 --- a/plugins/web-ui/test/message-meta-lane.test.ts +++ b/plugins/web-ui/test/message-meta-lane.test.ts @@ -8,3 +8,10 @@ test("message actions fit their lane and stack above sticky rows", () => { assert.match(css, /\.message-meta\s*{[^}]*z-index: 4;[^}]*height: var\(--meta-lane\);/s); assert.match(css, /\.msg-copy\s*{[^}]*width: var\(--meta-lane\);[^}]*height: var\(--meta-lane\);/s); }); + +test("the action buttons clear the 24px minimum target, and the lane they reserve grows with them", () => { + assert.match(css, /\n:root \{[^}]*--meta-lane: 24px;/s); + assert.match(css, /\[data-density="compact"\][^{]*\{[^}]*--meta-lane: 20px;/s); + assert.match(css, /\.message-meta\s*\{[^}]*gap: 8px;/s); + assert.doesNotMatch(css, /\.msg-copy::after/); +}); From e010bae47a72649670c8ee61f130f8ea930ae045 Mon Sep 17 00:00:00 2001 From: test Date: Mon, 7 Sep 2026 23:57:43 -0700 Subject: [PATCH 04/26] web-ui: dark mode defines the accent foreground it was falling back on Three rules read var(--accent-foreground, #fff). The light palette defines the token; the dark one never did, so all three took the fallback and painted pure white on an accent that is already light in dark mode. Define it alongside --accent so the fallback stops being load-bearing. Co-Authored-By: Claude Opus 5 (1M context) --- plugins/web-ui/src/shell.css | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/web-ui/src/shell.css b/plugins/web-ui/src/shell.css index 885677b25..587c45201 100644 --- a/plugins/web-ui/src/shell.css +++ b/plugins/web-ui/src/shell.css @@ -39,6 +39,7 @@ --muted: oklch(0.245 0.024 250); --muted-foreground: oklch(0.72 0.021 250); --accent: oklch(0.36 0.03 250); + --accent-foreground: oklch(0.8 0.07 250); --border: oklch(0.275 0.024 250); --input: oklch(0.325 0.026 250); --sidebar: oklch(0.205 0.024 250); From ee715f6bf6e033a3545ee467be6380685c10abc4 Mon Sep 17 00:00:00 2001 From: test Date: Mon, 7 Sep 2026 23:57:50 -0700 Subject: [PATCH 05/26] web-ui: the theme picker lines up with the other settings, and stops shouting Two problems in the Theme row, both from the row being built for a short note. .settings-row is space-between with wrap, and .settings-row-copy had no flex rule, so the Theme note (the only long one on the page) took its full max-content width and pushed the controls onto a second line. A lone item on a wrapped line sits at the line's start, so the picker ended up hard left while every other row's control sat right. The copy column now shrinks, and the controls keep margin-left: auto for the widths where they still wrap. The import affordance was a bordered button with an icon, the same weight as Open admin and Sign out, which put it on equal footing with the light/dark/ system choice next to it. It becomes a small underlined link under the picker. Still a real button, so the keyboard and the remove control's aria-label are unchanged; only its clothes are. Co-Authored-By: Claude Opus 5 (1M context) --- plugins/web-ui/src/settings.ts | 10 +++++----- plugins/web-ui/src/shell.css | 28 +++++++++++++++++++++++----- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/plugins/web-ui/src/settings.ts b/plugins/web-ui/src/settings.ts index 8da128889..5ea9e37d3 100644 --- a/plugins/web-ui/src/settings.ts +++ b/plugins/web-ui/src/settings.ts @@ -1,5 +1,5 @@ import { html, nothing, render, type TemplateResult } from "lit"; -import { BookOpen, ExternalLink, LogOut, Monitor, Moon, ShieldUser, Sun, Trash2, Upload, type IconNode } from "lucide"; +import { BookOpen, ExternalLink, LogOut, Monitor, Moon, ShieldUser, Sun, type IconNode } from "lucide"; import { icon } from "./ui"; import { ADMIN_HOME_URL, appState, can, signOut } from "./shell"; import { sessionsState, setWebOnly } from "./sessions"; @@ -171,25 +171,25 @@ function themeRow(): TemplateResult { @change=${(e: Event) => void onThemeFileChosen(e)} /> ${ custom ? html` ` : nothing diff --git a/plugins/web-ui/src/shell.css b/plugins/web-ui/src/shell.css index 587c45201..eed3b586b 100644 --- a/plugins/web-ui/src/shell.css +++ b/plugins/web-ui/src/shell.css @@ -968,6 +968,10 @@ a.chat-row-open { .settings-row:last-child { border-bottom: 0; } +.settings-row-copy { + flex: 1 1 260px; + min-width: 0; +} .settings-row-title { font-size: 14px; } @@ -1015,14 +1019,28 @@ a.chat-row-open { } .settings-theme-controls { display: flex; - align-items: center; - justify-content: flex-end; - flex-wrap: wrap; - gap: 8px; + flex-direction: column; + align-items: flex-end; + margin-left: auto; + gap: 7px; } .settings-theme-import { display: inline-flex; - gap: 6px; + gap: 12px; +} +.settings-theme-link { + padding: 0; + border: 0; + background: none; + color: var(--muted-foreground); + cursor: pointer; + font: inherit; + font-size: 12.5px; + text-decoration: underline; + text-underline-offset: 2px; +} +.settings-theme-link:hover { + color: var(--foreground); } .settings-row-error { color: var(--destructive, #c0392b); From ec711146145fc776daa84d96aeeea35c1d51ce34 Mon Sep 17 00:00:00 2001 From: test Date: Mon, 7 Sep 2026 23:57:56 -0700 Subject: [PATCH 06/26] web-ui: a list filter's caption stops bolding the dropdown it labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .list-select carried font-size: 11px and font-weight: 600 for the small caption it wraps (Scope, Source, Show). fieldSelect renders its select with font: inherit, and the shorthand takes the inherited weight with it, so every dropdown built through it came out semibold. The compact rule re-set font-size, which is why only the weight looked wrong. Move the caption's type onto the caption. The :not(.field-select) matters: fieldSelect's own wrapper is a span too, and a bare > span rule feeds the weight straight back into the control. All five call sites had it — All surfaces on the chats list, Active only on projects, All scopes and All sources on skills, and the files filters. Co-Authored-By: Claude Opus 5 (1M context) --- plugins/web-ui/src/shell.css | 2 ++ plugins/web-ui/test/field-select-source.test.ts | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/plugins/web-ui/src/shell.css b/plugins/web-ui/src/shell.css index eed3b586b..a49585908 100644 --- a/plugins/web-ui/src/shell.css +++ b/plugins/web-ui/src/shell.css @@ -4888,6 +4888,8 @@ a.chat-row-open { flex-direction: column; gap: 4px; color: var(--muted-foreground); +} +.list-select > span:not(.field-select) { font-size: 11px; font-weight: 600; } diff --git a/plugins/web-ui/test/field-select-source.test.ts b/plugins/web-ui/test/field-select-source.test.ts index a508b7464..20fea7688 100644 --- a/plugins/web-ui/test/field-select-source.test.ts +++ b/plugins/web-ui/test/field-select-source.test.ts @@ -48,6 +48,13 @@ test("list dropdowns share enough width to show their selected value", () => { assert.match(css, /\.list-select \.field-select \{\s*min-width: 124px;/); }); +test("the list caption's weight stays on the caption and off the control it labels", () => { + const start = css.indexOf(".list-select {"); + const block = css.slice(start, css.indexOf("}", start)); + assert.doesNotMatch(block, /font-weight/, "a weight on .list-select reaches the select through font: inherit"); + assert.match(css, /\.list-select > span:not\(\.field-select\) \{\s*font-size: 11px;\s*font-weight: 600;/); +}); + test("the dropdown holds the caller's value against re-renders (live) and stale DOM state", () => { // .value on a editDraft(item, { to: splitAddresses((e.currentTarget as HTMLInputElement).value) })} - @blur=${() => void persistDraft(item)} - /> - - ${ - showCc - ? html`` - : nothing - } - +
+ ${headerPeek(item, draft)} + + ${ + showCc + ? html`` + : nothing + } + +
` : nothing } diff --git a/plugins/web-ui/src/shell.css b/plugins/web-ui/src/shell.css index 1bb7c8936..dd9a88c48 100644 --- a/plugins/web-ui/src/shell.css +++ b/plugins/web-ui/src/shell.css @@ -8159,6 +8159,48 @@ h3.ambient-field-label { margin-left: auto; font-size: 11px; } +.inbox-draft-headers { + display: flex; + flex-direction: column; + gap: 8px; +} +.inbox-draft-headers > summary { + display: flex; + align-items: center; + gap: 6px; + width: fit-content; + max-width: 100%; + padding: 2px 0; + color: var(--muted-foreground); + font-size: 12px; + cursor: pointer; + list-style: none; +} +.inbox-draft-headers > summary::-webkit-details-marker { + display: none; +} +.inbox-draft-headers > summary::before { + content: ""; + flex: none; + width: 5px; + height: 5px; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: rotate(-45deg); + transition: transform 0.12s ease; +} +.inbox-draft-headers[open] > summary::before { + transform: rotate(45deg); +} +.inbox-draft-headers > summary:hover { + color: var(--foreground); +} +.inbox-draft-headers-peek { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} .inbox-field { display: flex; align-items: center; From c0ba6b1515c780b21aef887fc6f4a2a970858508 Mon Sep 17 00:00:00 2001 From: test Date: Tue, 8 Sep 2026 11:09:18 -0700 Subject: [PATCH 11/26] web-ui: the folded draft fields get their spacing back The gap belonged to the details element itself, which does not work: a details lays its content out inside an anonymous box, so summary and that box are the flex items and the three fields inside it are spaced by nothing. They rendered flush, 31px tall and 31px apart. Give the fields a real wrapper and put the flex column on that. The details goes back to being a plain block whose only job is opening and shutting. Co-Authored-By: Claude Opus 5 (1M context) --- plugins/web-ui/src/inbox.ts | 66 +++++++++++++++++++----------------- plugins/web-ui/src/shell.css | 3 +- 2 files changed, 36 insertions(+), 33 deletions(-) diff --git a/plugins/web-ui/src/inbox.ts b/plugins/web-ui/src/inbox.ts index bcb3c3f93..d12468c6d 100644 --- a/plugins/web-ui/src/inbox.ts +++ b/plugins/web-ui/src/inbox.ts @@ -865,38 +865,40 @@ export function draftEditorTpl(item: InboxItem, opts: { chat?: boolean } = {}): ? html`
${headerPeek(item, draft)} - - ${ - showCc - ? html`` - : nothing - } - +
+ + ${ + showCc + ? html`` + : nothing + } + +
` : nothing diff --git a/plugins/web-ui/src/shell.css b/plugins/web-ui/src/shell.css index dd9a88c48..63d55bfa2 100644 --- a/plugins/web-ui/src/shell.css +++ b/plugins/web-ui/src/shell.css @@ -8159,10 +8159,11 @@ h3.ambient-field-label { margin-left: auto; font-size: 11px; } -.inbox-draft-headers { +.inbox-draft-headers-fields { display: flex; flex-direction: column; gap: 8px; + padding-top: 8px; } .inbox-draft-headers > summary { display: flex; From 5253c94f9ac227bf0513bfb9dcc7de4f86c1df7b Mon Sep 17 00:00:00 2001 From: test Date: Tue, 8 Sep 2026 11:27:12 -0700 Subject: [PATCH 12/26] web-ui: an inbox item stops squeezing itself into the viewport The item page was a fixed frame: a grid whose second row took whatever the viewport had left, with the thread column scrolling inside it. Opening the draft's To and Subject took that space from the thread, which shrank until a three-message conversation showed one and a half messages behind its own scrollbar. The page never scrolled; everything else did. Let the page scroll instead. The thread renders at its natural height and the draft falls below the fold when it must, which is the ordinary way to read a long thread and write under it. A thread still needs a ceiling, or forty messages bury the reply. It caps at 480px and offers to show the rest. The toggle only appears when there is more than the cap shows, measured after the render that drew it, the same way the pinned prompt decides whether it needs Show more. The assistant keeps its full height by sticking to the top of the scrolling page rather than by being a row in a frame. Its height is the viewport below where it starts, set from the pane's own padding so it does not assume the pane fills the window. Co-Authored-By: Claude Opus 5 (1M context) --- plugins/web-ui/src/inbox.ts | 83 ++++++++++++++++++++++++++++-------- plugins/web-ui/src/shell.css | 32 +++++++++++--- 2 files changed, 90 insertions(+), 25 deletions(-) diff --git a/plugins/web-ui/src/inbox.ts b/plugins/web-ui/src/inbox.ts index d12468c6d..1f1b76a40 100644 --- a/plugins/web-ui/src/inbox.ts +++ b/plugins/web-ui/src/inbox.ts @@ -130,6 +130,8 @@ export const inboxState = { syncBusy: false, }; +const expandedThreads = new Set(); +const clampedThreads = new Set(); const draftEdits = new Map(); const sending = new Set(); const acting = new Set(); @@ -174,6 +176,7 @@ export function resetInboxState(): void { inboxState.fetchedAt = 0; inboxState.notice = null; inboxState.syncBusy = false; + expandedThreads.clear(); draftEdits.clear(); sending.clear(); acting.clear(); @@ -741,26 +744,43 @@ function itemImagesTpl(item: InboxItem, urls: string[] | undefined, ctxIndex: nu export function contextTpl(item: InboxItem): TemplateResult | typeof nothing { const rows = item.context ?? []; if (!rows.length) return nothing; - return html`
- ${rows.map((m, i) => { - const name = participantName(m.author) || m.author; - return html` -
- -
-
- ${name} - ${m.at ? html`${relTime(m.at)}` : nothing} + const expanded = expandedThreads.has(item.id); + return html`
+ ${rows.map((m, i) => { + const name = participantName(m.author) || m.author; + return html` +
+ +
+
+ ${name} + ${m.at ? html`${relTime(m.at)}` : nothing} +
+
${slackTextTpl(item, m.text)}
+ ${itemImagesTpl(item, m.images, i)}
-
${slackTextTpl(item, m.text)}
- ${itemImagesTpl(item, m.images, i)}
-
- `; - })} -
`; + `; + })} +
+ ${threadMoreTpl(item, expanded)}`; +} + +function threadMoreTpl(item: InboxItem, expanded: boolean): TemplateResult | typeof nothing { + if (!clampedThreads.has(item.id) && !expanded) return nothing; + return html``; } function sendLabel(item: InboxItem, busy: boolean): string { @@ -1262,6 +1282,33 @@ function drawFull(): void { host, ), ); + markClampedThread(host, openItem?.id ?? null); + sizeAside(host); +} + +/** + * The thread caps its height, so the toggle only earns its place when there is + * more thread than the cap shows. Measured after the render that drew it. + */ +function markClampedThread(host: HTMLElement, itemId: string | null): void { + if (!itemId) return; + const context = host.querySelector(".inbox-item-surface .inbox-context"); + if (!context) return; + const clamped = context.scrollHeight - context.clientHeight > 1; + const was = clampedThreads.has(itemId); + if (clamped) clampedThreads.add(itemId); + else if (!expandedThreads.has(itemId)) clampedThreads.delete(itemId); + if (clamped !== was && !expandedThreads.has(itemId)) drawFull(); +} + +/** + * The assistant sticks to the top of a page that now scrolls, so its height is + * the viewport below wherever it starts rather than a share of a fixed frame. + */ +function sizeAside(host: HTMLElement): void { + if (!host.querySelector(".inbox-item-aside")) return; + const padTop = Number.parseFloat(getComputedStyle(host).paddingTop) || 0; + host.style.setProperty("--inbox-aside-height", `${Math.max(320, host.clientHeight - padTop - 8)}px`); } function syncItemUrl(itemId: string | null, push = false): void { diff --git a/plugins/web-ui/src/shell.css b/plugins/web-ui/src/shell.css index 63d55bfa2..81c4a0b44 100644 --- a/plugins/web-ui/src/shell.css +++ b/plugins/web-ui/src/shell.css @@ -8029,10 +8029,9 @@ h3.ambient-field-label { display: grid; grid-template-columns: minmax(0, 960px) minmax(0, 330px); - grid-template-rows: auto minmax(0, 1fr); + grid-template-rows: auto auto; column-gap: 18px; justify-content: center; - overflow: hidden; } .inbox-page:has(.inbox-item-aside) .pane-head, .inbox-page:has(.inbox-item-aside) .inbox-item-surface { @@ -8046,16 +8045,18 @@ h3.ambient-field-label { } .inbox-page:has(.inbox-item-aside) .inbox-item-surface { grid-area: 2 / 1; - min-height: 0; + height: auto; } .inbox-item-thread { - min-height: 0; - overflow-y: auto; - padding-right: 4px; + flex: none; + overflow: visible; } .inbox-item-aside { grid-area: 1 / 2 / span 2; - min-height: 0; + position: sticky; + top: 0; + align-self: start; + height: var(--inbox-aside-height, 70vh); } .inbox-item-aside .inbox-chat { flex: 1; @@ -8078,8 +8079,25 @@ h3.ambient-field-label { } } .inbox-item-surface .inbox-context { + max-height: 480px; + overflow: hidden; +} +.inbox-item-surface .inbox-context[data-expanded="true"] { max-height: none; } +.inbox-thread-more { + align-self: flex-start; + padding: 2px 0; + border: 0; + background: none; + color: var(--muted-foreground); + font: inherit; + font-size: 12.5px; + cursor: pointer; +} +.inbox-thread-more:hover { + color: var(--foreground); +} .inbox-item-surface .inbox-draft-body { min-height: 200px; } From 2dd85e03470da4cc74eb84f3ec19f0781a480bb0 Mon Sep 17 00:00:00 2001 From: test Date: Tue, 8 Sep 2026 12:02:02 -0700 Subject: [PATCH 13/26] web-ui: the inbox item head stops floating in a row the assistant stretched The assistant spans both grid rows, and once the page stopped being a fixed frame the rows sized themselves to fit it, so the header row grew to most of the assistant's height. .pane-head centres its content, which left the title and subject adrift in the middle of a 209px row: 71px of nothing between the subject line and the rule under it. Anchor the head to the top of its row and let the second row take the slack. The gap under the subject is the 12px padding it was always meant to be. Co-Authored-By: Claude Opus 5 (1M context) --- plugins/web-ui/src/shell.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/web-ui/src/shell.css b/plugins/web-ui/src/shell.css index 81c4a0b44..f296b4a65 100644 --- a/plugins/web-ui/src/shell.css +++ b/plugins/web-ui/src/shell.css @@ -8029,7 +8029,7 @@ h3.ambient-field-label { display: grid; grid-template-columns: minmax(0, 960px) minmax(0, 330px); - grid-template-rows: auto auto; + grid-template-rows: auto 1fr; column-gap: 18px; justify-content: center; } @@ -8042,6 +8042,7 @@ h3.ambient-field-label { } .inbox-page:has(.inbox-item-aside) .pane-head { grid-area: 1 / 1; + align-self: start; } .inbox-page:has(.inbox-item-aside) .inbox-item-surface { grid-area: 2 / 1; From 230421d2be76e375ddda765ba94fb9eaa0e56d60 Mon Sep 17 00:00:00 2001 From: test Date: Tue, 8 Sep 2026 12:46:10 -0700 Subject: [PATCH 14/26] web-ui: the inbox shows the same working wave the rest of the app does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While the agent answers an inbox ask, the panel printed a bare "Thinking…". Everywhere else a working agent is the blue swell — the sidebar row mark, the chat transcript's typing row — so the inbox now leads that line with the same workingWave() the sidebar uses, in the same --working-dot blue. Co-Authored-By: Claude Opus 5 (1M context) --- plugins/web-ui/src/inbox.ts | 4 ++-- plugins/web-ui/src/shell.css | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/plugins/web-ui/src/inbox.ts b/plugins/web-ui/src/inbox.ts index 1f1b76a40..83458bb54 100644 --- a/plugins/web-ui/src/inbox.ts +++ b/plugins/web-ui/src/inbox.ts @@ -27,7 +27,7 @@ import { listBackLink } from "./list-page"; import { registerPaneKind } from "./pane-kinds"; import { beginPaneKindDrag, endPaneDrag, exitSplitIfActive, notifyPanesChanged } from "./split"; import { tip } from "./tooltip"; -import { brandName, icon, initials, relTime, slackMark } from "./ui"; +import { brandName, icon, initials, relTime, slackMark, workingWave } from "./ui"; export type InboxSource = "gmail" | "slack"; @@ -816,7 +816,7 @@ export function chatTpl(item: InboxItem): TemplateResult { )}
` } - ${busy ? html`
Thinking…
` : nothing} + ${busy ? html`
${workingWave()}Thinking…
` : nothing}