Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 6 additions & 6 deletions apps/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
34 changes: 25 additions & 9 deletions apps/web/src/chat/thread.css
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
211 changes: 210 additions & 1 deletion e2e/browser/zero-to-one.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand Down Expand Up @@ -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<void>((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);

Expand Down Expand Up @@ -196,6 +404,7 @@ describeBrowser("0-to-1 gate", () => {
);
throw cause;
}
expect(errors).toEqual([]);
await page.close();
}, 300_000);
});
Loading