diff --git a/apps/web/src/chat/transcript.tsx b/apps/web/src/chat/transcript.tsx index 37618a7c..d065c5a8 100644 --- a/apps/web/src/chat/transcript.tsx +++ b/apps/web/src/chat/transcript.tsx @@ -223,7 +223,10 @@ export function Transcript( pinned.current = distance < 40; }} > -
+
{groups.map(item => item.kind === "system" ? @@ -236,7 +239,7 @@ export function Transcript( /> ) )} -
+
); diff --git a/docs/superpowers/specs/2026-08-28-chat-composer-spacing-design.md b/docs/superpowers/specs/2026-08-28-chat-composer-spacing-design.md new file mode 100644 index 00000000..890f8810 --- /dev/null +++ b/docs/superpowers/specs/2026-08-28-chat-composer-spacing-design.md @@ -0,0 +1,20 @@ +# Chat composer spacing + +## Goal + +Keep at least 32 pixels between the final Chat message and the top of the composer field on +desktop and compact layouts. + +## Design + +The transcript leaves a 16-pixel flex gap before its bottom scroll marker. Make that marker a +16-pixel-high spacer. When Chat follows the marker to the bottom, the final message will sit 32 +pixels above the composer. + +This keeps the existing 16-pixel spacing between messages and does not change the composer's +height, padding, or reference-picker positioning. + +## Verification + +Extend the tall-transcript Playwright test to measure the rendered distance between the final +message and composer field. Require at least 32 pixels in desktop and compact layouts. diff --git a/e2e/smoke.e2e.ts b/e2e/smoke.e2e.ts index ea16535d..34cf857c 100644 --- a/e2e/smoke.e2e.ts +++ b/e2e/smoke.e2e.ts @@ -294,6 +294,69 @@ test("chat uses one room-message composer when the planner is off", async ({ joi await expect(chat.getByRole("button", { name: "Stop Planner" })).toHaveCount(0); }); +test("chat keeps both ends of a tall transcript clear across layouts", async ({ join, page }) => { + await injectChatHistory(page, frame => ({ + ...frame, + entries: Array.from({ length: 24 }, (_, index) => ({ + author: index % 2 === 0 + ? { kind: "member" as const, handle: "ana" } + : { kind: "agent" as const }, + id: `history-${index}`, + text: index === 0 + ? "The first message in a deliberately tall transcript." + : `Transcript message ${index} with enough text to occupy a complete line.`, + ts: 1_700_000_000 + index, + })), + })); + + let chat = chatPane(await join("ana")); + let stack = chat.locator("[data-chat-stack]"); + let scroller = stack.locator(".."); + let composer = chat.getByPlaceholder("Use @chopin to ask Chopin").locator(".."); + let lastMessageGap = async () => { + await scroller.evaluate(element => element.scrollTop = element.scrollHeight); + return chat.getByText("Transcript message 23", { exact: false }) + .evaluate( + (message, field) => + (field as Element).getBoundingClientRect().top - message.getBoundingClientRect().bottom, + await composer.elementHandle(), + ); + }; + let firstMessagePosition = async () => { + await scroller.evaluate(element => element.scrollTop = 0); + return chat.getByText("The first message in a deliberately tall transcript.") + .evaluate((message, scrollRoot) => { + let messageBox = message.getBoundingClientRect(); + let scroller = scrollRoot as Element; + let scrollerBox = scroller.getBoundingClientRect(); + return { + clientHeight: scroller.clientHeight, + messageBottom: messageBox.bottom, + messageTop: messageBox.top, + scrollerBottom: scrollerBox.bottom, + scrollerTop: scrollerBox.top, + scrollHeight: scroller.scrollHeight, + }; + }, await scroller.elementHandle()); + }; + await expect(chat.getByText("Transcript message 23", { exact: false })).toBeVisible(); + expect(await lastMessageGap()).toBeGreaterThanOrEqual(32); + let position = await firstMessagePosition(); + expect(position.scrollHeight).toBeGreaterThan(position.clientHeight); + expect(position.messageTop).toBeGreaterThanOrEqual(position.scrollerTop); + expect(position.messageBottom).toBeLessThanOrEqual(position.scrollerBottom); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.getByRole("navigation", { name: "Workspace view" }) + .getByRole("button", { name: /^Chat/ }).click(); + await expect(chat).toBeVisible(); + expect(await lastMessageGap()).toBeGreaterThanOrEqual(32); + position = await firstMessagePosition(); + expect(position.scrollHeight).toBeGreaterThan(position.clientHeight); + expect(position.messageTop).toBeGreaterThanOrEqual(position.scrollerTop); + expect(position.messageBottom).toBeLessThanOrEqual(position.scrollerBottom); +}); + test("chat disables Send when its socket disconnects", async ({ join, page }) => { let sockets: WebSocketRoute[] = []; await page.routeWebSocket("**/ws?**", route => {