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
7 changes: 5 additions & 2 deletions apps/web/src/chat/transcript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,10 @@ export function Transcript(
pinned.current = distance < 40;
}}
>
<div className="flex min-h-full flex-col justify-end gap-4" data-chat-stack>
<div
className="flex min-h-full flex-col gap-4 [&>*:first-child]:mt-auto"
data-chat-stack
>
{groups.map(item =>
item.kind === "system"
? <SystemEntry item={item} key={item.id} />
Expand All @@ -236,7 +239,7 @@ export function Transcript(
/>
)
)}
<div ref={bottom} />
<div className="h-4 shrink-0" ref={bottom} />
</div>
</div>
);
Expand Down
20 changes: 20 additions & 0 deletions docs/superpowers/specs/2026-08-28-chat-composer-spacing-design.md
Original file line number Diff line number Diff line change
@@ -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.
63 changes: 63 additions & 0 deletions e2e/smoke.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
Loading