diff --git a/DESIGN.md b/DESIGN.md index 765a63d2d..491239416 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -46,13 +46,21 @@ thin orange arc orbits a worker while it works, concentric with the mark. ## Workbench No top bar. A frosted pill centered at the top shows the bench's worker, its -name, and live status; it opens the bench drawer. The drawer is a floating -card that pushes the chat and composer left rather than covering them. Its +name, and live status; it opens the bench drawer. On desktop the drawer is +a flush side panel separated by a border, pushing the chat and composer +left. On phones it covers the chat as a full-width overlay. Its tabs: Information (an overview of everything, including active workflows), Artifacts, Tools, Grants, Insights, Members, Workflows. -The thread spans the sheet; prose holds a ~76ch measure and cards cap at -640px. The composer is one lean full-width row: `+`, text, voice, send. +With both side panels closed, the timeline, day divider, and composer share +a centered column capped at 1200px, with at least 32px side gutters. +Workers align left and user bubbles align right with a 16px inset; prose +holds a ~76ch measure and cards cap at 640px. The composer is one lean row: +`+`, text, voice, send. +When Replies or the bench drawer is open, the timeline's right gutter +collapses; the composer keeps a 16px gap from the nearest side panel. +User bubbles retain their 16px inset within the timeline, which fits the +available scroll width. Closing both panels restores centering. Voice mode covers the thread with the worker's mark over a level-reactive glow, a speaking/listening state line, and live captions. Controls: diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 37a6dd404..0d9546f8a 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -1845,13 +1845,8 @@ tr.insights-row-clickable:hover { } .drawer { position: absolute; - top: 8px; - right: 8px; - bottom: 8px; - width: calc(min(420px, 42vw) - 8px); + inset: 0; background: var(--surface); - border-radius: var(--r-lg); - box-shadow: var(--overlay); display: flex; flex-direction: column; min-height: 0; @@ -1870,6 +1865,11 @@ tr.insights-row-clickable:hover { .drawer-scrim { display: none; } +@media (min-width: 861px) { + .drawer { + border-left: 1px solid var(--border); + } +} .drawer-head { display: flex; align-items: center; diff --git a/apps/web/src/chat/thread.css b/apps/web/src/chat/thread.css index f65c3202e..19f1f0159 100644 --- a/apps/web/src/chat/thread.css +++ b/apps/web/src/chat/thread.css @@ -1,29 +1,44 @@ -/* The bench conversation: a left-aligned timeline above the composer. */ +/* The bench conversation: one centered column for the timeline and composer. */ .workbench-layout { display: flex; min-height: 0; flex: 1; } .workbench-main { - display: flex; - flex-direction: column; + display: grid; + grid-template-columns: minmax(32px, 1fr) minmax(0, 1200px) minmax(32px, 1fr); + grid-template-rows: minmax(0, 1fr) auto; flex: 1; min-width: 0; min-height: 0; } +.workbench-main:has(+ .workbench-subthread), +.wb[data-drawer="open"] .workbench-main { + grid-template-columns: minmax(32px, 1fr) minmax(0, 1200px) 0; +} .workbench-main-scroll { - flex: 1; + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; min-height: 0; overflow-y: auto; - padding: 72px 24px 16px; + padding: 72px 0 16px; +} +.workbench-main:has(+ .workbench-subthread) .workbench-main-scroll, +.wb[data-drawer="open"] .workbench-main-scroll { + display: block; + padding-inline-start: max(32px, calc(100% - 1200px)); } .timeline-inner { - max-width: 1200px; - padding: 0 8px; + grid-column: 2; } .workbench-main-composer { - flex-shrink: 0; - padding: 0 16px 16px; + grid-column: 2; + padding-bottom: 16px; +} +.workbench-main:has(+ .workbench-subthread) .workbench-main-composer, +.wb[data-drawer="open"] .workbench-main-composer { + padding-inline-end: 16px; } .wb-corner { @@ -87,6 +102,7 @@ } .chat-thread-message[data-author="me"] { justify-content: flex-end; + padding-inline-end: 16px; } .chat-thread-message[data-author="me"] .chat-thread-body { flex: 0 1 auto; diff --git a/e2e/browser/zero-to-one.test.ts b/e2e/browser/zero-to-one.test.ts index e45ed1850..571ac6dc7 100644 --- a/e2e/browser/zero-to-one.test.ts +++ b/e2e/browser/zero-to-one.test.ts @@ -21,7 +21,9 @@ const BRIEF = "Brief: nothing shipped this week."; // fallback — which the gate's own first reply still falls through to. const ADA_REPLY: Fixture = { match: { predicate: () => true }, - response: { content: `${MOCK_REPLY}\n\nName: Ada` }, + response: { + content: `${MOCK_REPLY}\n\n${"This longer reply keeps the conversation scrolling while its prose stays readable.\n\n".repeat(16)}Name: Ada`, + }, }; const FIXTURES: Fixture[] = [ @@ -148,6 +150,212 @@ describeBrowser("0-to-1 gate", () => { await waitForText(page, "Ada"); await page.goto(benchUrl, { waitUntil: "networkidle0" }); + await page.emulateMediaFeatures([{ name: "prefers-reduced-motion", value: "reduce" }]); + const waitForLayout = () => + page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); + for (const { width, reservedSpace } of [ + { width: 2048, reservedSpace: 0 }, + { width: 1800, reservedSpace: 0 }, + { width: 1200, reservedSpace: 0 }, + { width: 900, reservedSpace: 0 }, + { width: 861, reservedSpace: 0 }, + { width: 860, reservedSpace: 0 }, + { width: 800, reservedSpace: 0 }, + { width: 390, reservedSpace: 0 }, + { width: 1800, reservedSpace: 16 }, + { width: 900, reservedSpace: 16 }, + { width: 1800, reservedSpace: 20 }, + { width: 900, reservedSpace: 20 }, + ]) { + await page.setViewport({ width, height: 900 }); + // Simulate reserved scrollbar space on hosts with overlay scrollbars. + await page.$eval( + ".workbench-main-scroll", + (element, space) => { + if (!(element instanceof HTMLElement)) throw new Error("Missing scroll pane"); + element.style.borderInlineEnd = `${space}px solid transparent`; + }, + reservedSpace, + ); + for (const drawerOpen of [false, true]) { + if (drawerOpen) { + await page.click(".bench-pill"); + await waitForLayout(); + } + const layout = await page.evaluate(() => { + const element = (selector: string) => { + const found = document.querySelector(selector); + if (found === null) throw new Error(`Missing layout element: ${selector}`); + return found; + }; + const bounds = (selector: string) => { + const { left, right, width } = element(selector).getBoundingClientRect(); + return { left, right, width }; + }; + const scroll = element(".workbench-main-scroll"); + const prose = element( + '.timeline-inner .chat-thread-message:not([data-author="me"]) .chat-thread-body', + ); + return { + main: bounds(".workbench-main"), + timeline: bounds(".timeline-inner"), + divider: bounds(".timeline-inner .chat-day"), + composer: bounds(".workbench-main-composer .chat-composer-box"), + worker: bounds(".timeline-inner .chat-thread-avatar"), + user: bounds(".timeline-inner .chat-thread-own-bubble"), + proseWidth: prose.getBoundingClientRect().width, + proseLimit: Number.parseFloat(getComputedStyle(prose).maxWidth), + reservedWidth: scroll.getBoundingClientRect().width - scroll.clientWidth, + scrolls: scroll.scrollHeight > scroll.clientHeight, + overflows: scroll.scrollWidth > scroll.clientWidth, + pageOverflows: document.documentElement.scrollWidth > innerWidth, + }; + }); + if (drawerOpen) { + const drawer = await page.evaluate(() => { + const cell = document.querySelector(".drawer-cell")?.getBoundingClientRect(); + const element = document.querySelector(".drawer"); + if (cell === undefined || element === null) throw new Error("Missing drawer"); + const card = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + left: card.left - cell.left, + right: cell.right - card.right, + shadow: style.boxShadow, + radius: Number.parseFloat(style.borderRadius), + divider: Number.parseFloat(style.borderLeftWidth), + }; + }); + expect(Math.abs(drawer.left), `${width}px drawer left edge`).toBeLessThanOrEqual(1); + expect(Math.abs(drawer.right), `${width}px drawer right edge`).toBeLessThanOrEqual(1); + if (width > 860) { + expect(drawer.shadow, `${width}px nested drawer surface`).toBe("none"); + expect(drawer.radius, `${width}px flat drawer edge`).toBe(0); + expect(drawer.divider, `${width}px visible drawer divider`).toBe(1); + } + } + const context = `${width}px, drawer ${drawerOpen ? "open" : "closed"}, reserved space ${reservedSpace}px`; + for (const box of [layout.timeline, layout.divider]) { + expect(Math.abs(box.left - layout.composer.left), context).toBeLessThanOrEqual(1); + const offset = drawerOpen ? 16 - layout.reservedWidth : 0; + expect( + Math.abs(box.right - layout.composer.right - offset), + context, + ).toBeLessThanOrEqual(1); + } + const leftGutter = layout.composer.left - layout.main.left; + const rightGutter = layout.main.right - layout.composer.right; + expect( + Math.abs(drawerOpen ? rightGutter - 16 : leftGutter - rightGutter), + context, + ).toBeLessThanOrEqual(1); + expect(leftGutter, context).toBeGreaterThanOrEqual(32); + expect(layout.composer.width, context).toBeLessThanOrEqual(1200); + expect(Math.abs(layout.worker.left - layout.composer.left), context).toBeLessThanOrEqual( + 1, + ); + expect( + Math.abs( + layout.composer.right - layout.user.right - (drawerOpen ? layout.reservedWidth : 16), + ), + context, + ).toBeLessThanOrEqual(1); + expect(layout.proseWidth, context).toBeLessThanOrEqual(layout.proseLimit + 1); + expect(layout.scrolls, context).toBe(true); + expect(layout.overflows, context).toBe(false); + expect(layout.pageOverflows, context).toBe(false); + if (drawerOpen) { + await page.click('#bench-drawer button[aria-label="Close drawer"]'); + await waitForLayout(); + } + } + if (width >= 900) { + await page.click('.timeline-inner .chat-thread-message[data-author="me"] button'); + await page.waitForSelector(".workbench-subthread"); + await waitForLayout(); + const replyLayout = await page.evaluate(() => { + const bounds = (selector: string) => { + const element = document.querySelector(selector); + if (element === null) throw new Error(`Missing layout element: ${selector}`); + return element.getBoundingClientRect(); + }; + const composer = bounds(".workbench-main-composer .chat-composer-box"); + const scroll = document.querySelector(".workbench-main-scroll"); + if (scroll === null) throw new Error("Missing scroll pane"); + return { + gap: bounds(".workbench-subthread").left - composer.right, + dividerGap: composer.right - bounds(".timeline-inner .chat-day").right, + userInset: composer.right - bounds(".timeline-inner .chat-thread-own-bubble").right, + reservedWidth: scroll.getBoundingClientRect().width - scroll.clientWidth, + timelineLeft: bounds(".timeline-inner").left - composer.left, + leftGutter: composer.left - bounds(".workbench-main").left, + width: composer.width, + overflows: scroll.scrollWidth > scroll.clientWidth, + pageOverflows: document.documentElement.scrollWidth > innerWidth, + }; + }); + const context = `${width}px, replies open, reserved space ${reservedSpace}px`; + expect(Math.abs(replyLayout.gap - 16), context).toBeLessThanOrEqual(1); + expect(Math.abs(replyLayout.timelineLeft), context).toBeLessThanOrEqual(1); + expect( + Math.abs(replyLayout.dividerGap + 16 - replyLayout.reservedWidth), + context, + ).toBeLessThanOrEqual(1); + expect( + Math.abs(replyLayout.userInset - replyLayout.reservedWidth), + context, + ).toBeLessThanOrEqual(1); + expect(replyLayout.leftGutter, context).toBeGreaterThanOrEqual(32); + expect(replyLayout.width, context).toBeLessThanOrEqual(1200); + expect(replyLayout.overflows, context).toBe(false); + expect(replyLayout.pageOverflows, context).toBe(false); + await page.click(".bench-pill"); + await waitForLayout(); + const drawerWithReplies = await page.evaluate(() => { + const cell = document.querySelector(".drawer-cell")?.getBoundingClientRect(); + const card = document.querySelector(".drawer")?.getBoundingClientRect(); + const body = document.querySelector(".drawer-body"); + if (cell === undefined || card === undefined || body === null) { + throw new Error("Missing drawer"); + } + return { + left: card.left - cell.left, + right: cell.right - card.right, + overflows: body.scrollWidth > body.clientWidth, + }; + }); + expect(Math.abs(drawerWithReplies.left), context).toBeLessThanOrEqual(1); + expect(Math.abs(drawerWithReplies.right), context).toBeLessThanOrEqual(1); + expect(drawerWithReplies.overflows, context).toBe(false); + await page.click('#bench-drawer button[aria-label="Close drawer"]'); + await page.click(".workbench-subthread-head button"); + await page.waitForSelector(".workbench-subthread", { hidden: true }); + await waitForLayout(); + const restoredCenter = await page.evaluate(() => { + const main = document.querySelector(".workbench-main")?.getBoundingClientRect(); + const composer = document + .querySelector(".workbench-main-composer .chat-composer-box") + ?.getBoundingClientRect(); + if (main === undefined || composer === undefined) throw new Error("Missing layout"); + return composer.left - main.left - (main.right - composer.right); + }); + expect(Math.abs(restoredCenter), context).toBeLessThanOrEqual(1); + } + } + await page.setViewport({ width: 1400, height: 900 }); + + await page.$eval(".workbench-main-scroll", (element) => { + if (!(element instanceof HTMLElement)) throw new Error("Missing scroll pane"); + element.style.removeProperty("border-inline-end"); + }); + await page.emulateMediaFeatures([]); + await waitForLayout(); + await send(page, FOLLOW_UP); await waitForText(page, FOLLOW_UP_REPLY); @@ -196,6 +404,7 @@ describeBrowser("0-to-1 gate", () => { ); throw cause; } + expect(errors).toEqual([]); await page.close(); }, 300_000); });