diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 6b4ae04c01e1..01aa908d0fd6 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -2586,6 +2586,9 @@ export function VirtualTranscript({ aria-busy={seeking || undefined} data-fade-top={renderScroll.top > 0.5 || undefined} data-fade-bottom={(!renderFollowing && layout.totalHeight - renderScroll.top - height > 0.5) || undefined} + // Jump to latest stands in that band: the fade reaches past it, so rows (a date line) pass + // out of sight behind it instead of reading through its glass. + data-jump-shown={(jumpButton && behind) || undefined} // Under the bottom stack: rows there stay out of sight (conversation.css), and focus or Find // scroll a row into view above it. data-docked={dockSpace > 0 || undefined} diff --git a/webviews/src/agent-session/acpmux/Composer.tsx b/webviews/src/agent-session/acpmux/Composer.tsx index 2b6f337ec96a..fea8109eeb3b 100644 --- a/webviews/src/agent-session/acpmux/Composer.tsx +++ b/webviews/src/agent-session/acpmux/Composer.tsx @@ -1304,7 +1304,7 @@ export function Composer({ {t(remote.note)}

)} - {!remote.note && blocked?.reason && ( + {!remote.note && blocked?.reason && !blocked.shownAbove && (

{t(blocked.reason)}

diff --git a/webviews/src/agent-session/acpmux/composerLocation.css b/webviews/src/agent-session/acpmux/composerLocation.css index 33794134a4cd..7f0faa57ea94 100644 --- a/webviews/src/agent-session/acpmux/composerLocation.css +++ b/webviews/src/agent-session/acpmux/composerLocation.css @@ -338,6 +338,12 @@ .acpmux-access-grant-remove:hover { color: var(--agent-text); background: var(--agent-control-hover); } .acpmux-access-grant-remove:focus-visible { outline: 1.5px solid var(--agent-text); outline-offset: -1px; } +/* The card's corner. A question in the stack over it (the folder trust bar) takes the same token, + so the two always match. */ +.acpmux-composer-box, +.acpmux-asks-stack { + --acpmux-composer-radius: 18px; +} /* The card over a backdrop (a background image or a clear window): the card mixes its tint into the opaque base at 72% instead of into the clear page, so the prompt, the chips and the footer stay legible over a busy painting while the backdrop still shows through. On an opaque page the base is @@ -346,7 +352,6 @@ .acpmux-composer-box { --acpmux-composer-glass: color-mix(in srgb, var(--acpmux-base) 72%, transparent); overflow: visible; - --acpmux-composer-radius: 18px; border-radius: var(--acpmux-composer-radius); background: color-mix(in srgb, var(--agent-text) 13%, var(--acpmux-composer-glass)); } @@ -1096,12 +1101,16 @@ display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: end; - --acpmux-composer-radius: 11px; } /* The strip and the queue over the box take its corners. */ .acpmux-composer:has(.acpmux-composer-box > .acpmux-chrome-area > .acpmux-composer-inner) { --acpmux-strip-radius: 11px; } +/* The box's corner, which the trust bar in the stack over it shares. */ +.acpmux-composer-box:has(> .acpmux-chrome-area > .acpmux-composer-inner), +.acpmux-dock:has(.acpmux-composer-box > .acpmux-chrome-area > .acpmux-composer-inner) > .acpmux-asks-stack { + --acpmux-composer-radius: 11px; +} .acpmux-composer-inner { display: contents; } .acpmux-composer-box > .acpmux-attachments { grid-column: 1 / -1; grid-row: 1; } .acpmux-composer-box:has(> .acpmux-chrome-area > .acpmux-composer-inner) > .acpmux-composer-field-menu > * { diff --git a/webviews/src/agent-session/acpmux/conversation/conversation.css b/webviews/src/agent-session/acpmux/conversation/conversation.css index f08e78c8a719..7250d8f2c5e0 100644 --- a/webviews/src/agent-session/acpmux/conversation/conversation.css +++ b/webviews/src/agent-session/acpmux/conversation/conversation.css @@ -2600,6 +2600,10 @@ button.cv-received__name:focus-visible { .acpmux-scroll[data-fade-bottom] { --acpmux-fade-bottom: 18px; } +/* Jump to latest (12px up, 32px tall) stands in the bottom band: the fade runs a gap past its top. */ +.acpmux-scroll[data-fade-bottom][data-jump-shown] { + --acpmux-fade-bottom: 56px; +} /* Find in Chat's bar sits in the top band: it keeps full ink, and rows pass under it. */ .acpmux-scroll[data-fade-top]:has(> .acpmux-find) { --acpmux-fade-top: 0px; diff --git a/webviews/src/agent-session/acpmux/styles.css b/webviews/src/agent-session/acpmux/styles.css index cd01d91e5d03..9ad61ec663a7 100644 --- a/webviews/src/agent-session/acpmux/styles.css +++ b/webviews/src/agent-session/acpmux/styles.css @@ -268,7 +268,7 @@ } /* Context (ComposerContext.tsx): composerLocation.css. */ /* The folder trust ask (TrustAsk.tsx): one quiet line among the chat's permission asks, its actions text buttons that step on hover and press. */ -.acpmux-trust-ask{display:flex;align-items:center;gap:4px;min-height:34px;margin-bottom:8px;padding:4px 4px 4px 12px;box-sizing:border-box;border-radius:12px;background:var(--acpmux-composer-tray);color:var(--acpmux-composer-dim);font-size:13px}.acpmux-trust-ask-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.acpmux-tool-card .acpmux-trust-ask-text{padding:4px 0;white-space:normal;overflow-wrap:anywhere}.acpmux-tool-card-input{flex:0 1 220px;min-width:80px;height:26px;padding:0 8px;box-sizing:border-box;border:1px solid var(--acpmux-composer-hover);border-radius:6px;background:var(--agent-page-bg,var(--acpmux-base));color:var(--agent-text);font:inherit}.acpmux-tool-card-input:focus{outline:none}.acpmux-tool-card:focus-within .acpmux-tool-card-input{border-color:var(--agent-text)}.acpmux-trust-ask-action:disabled{opacity:.5;cursor:default}.acpmux-trust-ask-action{flex:none;height:26px;padding:0 10px;border:0;border-radius:6px;background:none;color:var(--agent-text);font:inherit;cursor:pointer}.acpmux-trust-ask-action:hover{background:var(--acpmux-composer-hover)}.acpmux-trust-ask-action:active{background:color-mix(in srgb,var(--agent-text) 20%,var(--agent-page-bg,var(--acpmux-base)))}.acpmux-trust-ask-action:focus-visible{outline:1.5px solid var(--agent-text);outline-offset:1px}@media (prefers-reduced-motion:no-preference){.acpmux-trust-ask-action{transition:background-color var(--agent-motion-hover,80ms) var(--agent-ease-out,ease-out)}} +.acpmux-trust-ask{display:flex;align-items:center;gap:4px;min-height:34px;margin-bottom:8px;padding:4px 4px 4px 12px;box-sizing:border-box;border-radius:var(--acpmux-composer-radius,12px);background:var(--acpmux-composer-tray);color:var(--acpmux-composer-dim);font-size:13px}.acpmux-trust-ask-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.acpmux-tool-card .acpmux-trust-ask-text{padding:4px 0;white-space:normal;overflow-wrap:anywhere}.acpmux-tool-card-input{flex:0 1 220px;min-width:80px;height:26px;padding:0 8px;box-sizing:border-box;border:1px solid var(--acpmux-composer-hover);border-radius:6px;background:var(--agent-page-bg,var(--acpmux-base));color:var(--agent-text);font:inherit}.acpmux-tool-card-input:focus{outline:none}.acpmux-tool-card:focus-within .acpmux-tool-card-input{border-color:var(--agent-text)}.acpmux-trust-ask-action:disabled{opacity:.5;cursor:default}.acpmux-trust-ask-action{flex:none;height:26px;padding:0 10px;border:0;border-radius:6px;background:none;color:var(--agent-text);font:inherit;cursor:pointer}.acpmux-trust-ask-action:hover{background:var(--acpmux-composer-hover)}.acpmux-trust-ask-action:active{background:color-mix(in srgb,var(--agent-text) 20%,var(--agent-page-bg,var(--acpmux-base)))}.acpmux-trust-ask-action:focus-visible{outline:1.5px solid var(--agent-text);outline-offset:1px}@media (prefers-reduced-motion:no-preference){.acpmux-trust-ask-action{transition:background-color var(--agent-motion-hover,80ms) var(--agent-ease-out,ease-out)}} /* Search files: a quick-open palette, centered over the transcript's upper part (main column). */ .acpmux-file-search{position:absolute;z-index:20;top:max(48px,18%);left:50%;right:auto;margin:0;border:0;width:min(600px,calc(100vw - 32px));transform:translateX(-50%);padding:6px;box-sizing:border-box;border-radius:14px;background:var(--surface-popup);box-shadow:inset 0 0 0 var(--cmux-hairline, .5px) var(--acpmux-composer-edge),var(--elevation-2);color:var(--agent-text);font-size:14px} .acpmux-file-search input{display:block;width:100%;height:34px;padding:0 10px;box-sizing:border-box;border:0;background:none;color:var(--agent-text);font:inherit;outline:none} diff --git a/webviews/src/agent-session/acpmux/useFolderTrustAsk.ts b/webviews/src/agent-session/acpmux/useFolderTrustAsk.ts index 6b1a1368caa5..8756edd983ac 100644 --- a/webviews/src/agent-session/acpmux/useFolderTrustAsk.ts +++ b/webviews/src/agent-session/acpmux/useFolderTrustAsk.ts @@ -16,8 +16,9 @@ export type FolderTrustAsk = | { cwd: string; state: "ask" | "failed" | "remote" } | { cwd: string; state: "decided"; level: Exclude }; -/// Why Send is off; no `reason` while the folder's trust is still being read. -export type SendBlock = { reason?: StringKey }; +/// Why Send is off; no `reason` while the folder's trust is still being read. `shownAbove`: the +/// question over the card already says it, so the card draws no caption (Send's tooltip keeps it). +export type SendBlock = { reason?: StringKey; shownAbove?: boolean }; /// The last answer read for each folder, kept for the next chat there (another tab, workspace, or /// a new page from the pool): a switch draws the folder's known trust from its first render, and @@ -231,7 +232,7 @@ export function useFolderTrustAsk( const blocked: SendBlock | undefined = reading ? {} : ask?.state === "ask" || ask?.state === "failed" - ? { reason: "trust.answerFirst" } + ? { reason: "trust.answerFirst", shownAbove: true } : ask?.state === "remote" ? { reason: "trust.remote" } : ask?.state === "decided" && ask.level === "untrusted" diff --git a/webviews/test/agent-pane-column-edges.test.ts b/webviews/test/agent-pane-column-edges.test.ts index 97e8af03716a..80c9e2e4ae25 100644 --- a/webviews/test/agent-pane-column-edges.test.ts +++ b/webviews/test/agent-pane-column-edges.test.ts @@ -36,7 +36,6 @@ await requireBrowserLane("agent-pane-column-edges.test.ts", async () => { edge: ".acpmux-composer-box", blocks: { "trust ask": ".acpmux-trust-ask", - "trust note": ".acpmux-composer-remote-note", queue: ".acpmux-composer-queue", }, }, diff --git a/webviews/test/agent-pane-trust-ask-corner.test.ts b/webviews/test/agent-pane-trust-ask-corner.test.ts new file mode 100644 index 000000000000..5dbae99a7f75 --- /dev/null +++ b/webviews/test/agent-pane-trust-ask-corner.test.ts @@ -0,0 +1,74 @@ +// l10n-allow-file: asserts the English tooltip the gallery renders, not shipped UI. +// The folder trust bar over the composer (layout pass, hq#2696): it takes the composer card's corner +// (both read --acpmux-composer-radius, so whatever corner the card's look sets, the bar matches it), +// and no caption repeats its question over the card. Real gallery frames at a wide and a narrow pane. +// +// Real Chromium from Playwright; CI fails the file when it cannot launch (requireBrowserLane). +import { afterAll, beforeAll, expect, setDefaultTimeout, test } from "bun:test"; +import path from "node:path"; +import { chromium, type Browser } from "playwright"; +import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; + +await requireBrowserLane("agent-pane-trust-ask-corner.test.ts", async () => { + setDefaultTimeout(240_000); + const webviews = path.resolve(import.meta.dir, ".."); + const { createServer } = await import("vite"); + let vite: Awaited> | undefined; + let browser: Browser | undefined; + + beforeAll(async () => { + vite = await createServer({ + configFile: path.join(webviews, "vite.config.gallery-dev.ts"), + server: { port: 0, host: "127.0.0.1", strictPort: false }, + logLevel: "error", + }); + await vite.listen(); + await warmViteServer(vite, "/gallery/frame.html"); + browser = await chromium.launch({ headless: true }); + }); + afterAll(async () => { + await browser?.close(); + await vite?.close(); + }); + + test("the trust bar's corner is the composer card's", async () => { + const port = (vite!.httpServer!.address() as { port: number }).port; + const found: string[] = []; + for (const width of [1100, 420]) { + const page = await browser!.newPage({ viewport: { width, height: 760 } }); + await page.goto( + `http://127.0.0.1:${port}/gallery/frame.html?entry=agent-pane.bottom-stack&variant=trust-permission-queue`, + ); + // The first page waits on a cold Vite compiling the gallery, past 30 s on a busy runner. + await page.locator(".acpmux-trust-ask").first().waitFor({ timeout: 90_000 }); + await page.waitForFunction(() => document.documentElement.dataset.galleryReady === "1", undefined, { + timeout: 30_000, + }); + const corners = await page.evaluate(() => ({ + bar: getComputedStyle(document.querySelector(".acpmux-trust-ask")!).borderTopLeftRadius, + card: getComputedStyle(document.querySelector(".acpmux-composer-box")!).borderTopLeftRadius, + })); + await page.close(); + if (corners.bar !== corners.card) found.push(`@${width}: bar ${corners.bar}, card ${corners.card}`); + } + expect(found).toEqual([]); + }); + + // The layout pass's "orphan caption": "Answer the trust question first" stood between the bar + // that asks it and the card. The question says it; Send's tooltip keeps the reason. + test("no caption stands between the open trust question and the card", async () => { + const port = (vite!.httpServer!.address() as { port: number }).port; + const page = await browser!.newPage({ viewport: { width: 1100, height: 760 } }); + await page.goto( + `http://127.0.0.1:${port}/gallery/frame.html?entry=agent-pane.bottom-stack&variant=trust-permission-queue`, + ); + await page.locator(".acpmux-trust-ask").first().waitFor({ timeout: 90_000 }); + await page.waitForFunction(() => document.documentElement.dataset.galleryReady === "1", undefined, { + timeout: 30_000, + }); + expect(await page.locator(".acpmux-composer-remote-note").count()).toBe(0); + expect(await page.locator(".acpmux-send").first().getAttribute("title")).toBe("Answer the trust question first"); + await page.close(); + }); +}); diff --git a/webviews/test/agent-pane-wheel-pass-through.test.ts b/webviews/test/agent-pane-wheel-pass-through.test.ts index 02ab68079d8d..df98b237e21b 100644 --- a/webviews/test/agent-pane-wheel-pass-through.test.ts +++ b/webviews/test/agent-pane-wheel-pass-through.test.ts @@ -67,6 +67,12 @@ await requireBrowserLane("agent-pane-wheel-pass-through.test.ts", async () => { // Up from the latest message, so the button shows. expect(await wheelOver(page, ".acpmux-scroll", -1200)).toBeLessThan(0); await page.locator(".acpmux-jump-latest[data-shown]").waitFor(); + // The bottom fade reaches past the button, so rows pass out of sight behind it (layout pass, + // hq#2696: "overlaps the date line, with no fade"). + const fade = await page.evaluate(() => + getComputedStyle(document.querySelector(".acpmux-scroll")!).getPropertyValue("--acpmux-fade-bottom").trim(), + ); + expect(fade).toBe("56px"); expect(await wheelOver(page, ".acpmux-jump-latest", -300)).toBeLessThan(-100); expect(await wheelOver(page, ".acpmux-jump-latest", 200)).toBeGreaterThan(100); await page.locator(".acpmux-jump-latest").click(); diff --git a/webviews/test/trust-read-keeps-permission.test.tsx b/webviews/test/trust-read-keeps-permission.test.tsx index 7600563167e9..1a4a84fe2fdc 100644 --- a/webviews/test/trust-read-keeps-permission.test.tsx +++ b/webviews/test/trust-read-keeps-permission.test.tsx @@ -129,7 +129,7 @@ test("an answer saved for another folder leaves this folder's read standing", as await trust.saved(); await trust.answer("unknown", second); expect(shown!.ask).toEqual({ cwd: second, state: "ask" }); - expect(shown!.blocked).toEqual({ reason: "trust.answerFirst" }); + expect(shown!.blocked).toEqual({ reason: "trust.answerFirst", shownAbove: true }); }); // Leo 2026-10-19: a switch to another chat in the very same folder checked the folder's trust