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
9 changes: 9 additions & 0 deletions apps/web/src/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,11 @@ body {
}

/* The split workspace is one elevated surface; its panes only own the divider. */
.workspace-root[data-workspace-mode="split"] .workspace-frame {
margin-left: calc(var(--spacing) * 3 + env(safe-area-inset-left));
margin-right: calc(var(--spacing) * 3 + env(safe-area-inset-right));
}

.workspace-frame .plan-document {
box-shadow: none;
}
Expand All @@ -492,6 +497,10 @@ body {
--panel-direction: -1;
}

.workspace-root[data-workspace-mode="split"] .workspace-frame > main {
min-width: 9rem;
}

.motion-panel {
opacity: 0;
transform: translateX(calc(var(--panel-distance) * var(--panel-direction, 1)));
Expand Down
14 changes: 6 additions & 8 deletions apps/web/src/workspace-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ const documentPresentation = { type: "document" } as const;

function mediaAt(width: number): MatchMedia {
return query => {
let maximum = /\(max-width: (\d+)px\)/.exec(query);
return { matches: maximum !== null && width <= Number(maximum[1]) };
let boundary = /\(width < (\d+(?:\.\d+)?)px\)/.exec(query);
return { matches: boundary !== null && width < Number(boundary[1]) };
};
}

Expand Down Expand Up @@ -95,11 +95,9 @@ describe("adaptive workspace", () => {
expect(workspaceHeadingId("plan", "child-room")).toBe("child-room-workspace-plan-heading");
});

it("classifies the media queries production reads at each boundary", () => {
expect([1023, 1024].map(width => workspaceMode(mediaAt(width)))).toEqual([
"compact",
"split",
]);
it("classifies fractional widths on either side of the media boundary", () => {
expect([499.5, 500].map(width => workspaceMode(mediaAt(width))))
.toEqual(["compact", "split"]);
});

it("closing Chat leaves the visible document view untouched", () => {
Expand Down Expand Up @@ -137,7 +135,7 @@ describe("adaptive workspace", () => {
expect(presentWorkspace(state, "split", "plan").chatVisible).toBe(true);
});

it("shows Chat as the only compact destination on tablets", () => {
it("shows Chat as the only compact destination", () => {
let state: WorkspaceState = {
chatOpen: true,
desktopChatOpen: true,
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/workspace-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export type WorkspaceEvent =
| { type: "set-chat"; open: boolean }
| { type: "set-desktop-chat"; open: boolean };

export const WORKSPACE_MEDIA = ["(max-width: 1023px)"] as const;
export const WORKSPACE_MEDIA = ["(width < 500px)"] as const;

export function initialWorkspaceState(
profile: WorkspaceProfile,
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,16 @@ export function Workspace(
<div
className="flex h-[46px] shrink-0 items-center overflow-x-auto overflow-y-hidden px-2.5 hairline-b"
data-document-toolbar
onFocusCapture={event => {
if (!(event.target instanceof HTMLElement)) return;
let control = event.target.getBoundingClientRect();
let toolbar = event.currentTarget.getBoundingClientRect();
if (control.left < toolbar.left) {
event.currentTarget.scrollLeft += Math.floor(control.left - toolbar.left);
} else if (control.right > toolbar.right) {
event.currentTarget.scrollLeft += Math.ceil(control.right - toolbar.right);
}
}}
>
{chat && !presentation.chatVisible && (
<ChatToggle
Expand Down
2 changes: 1 addition & 1 deletion e2e/conversation-plan-ui.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ test("another message hover stays quiet and code buttons keep one analysis porta
});

test("analysis Escape dismisses before compact Chat and narrow split popovers stay in Chat", async ({ join, room }) => {
let page = await join("ana", { viewport: { width: 649, height: 900 } });
let page = await join("ana", { viewport: { width: 390, height: 900 } });
await openJevWire(page, room);
await page.getByRole("navigation", { name: "Workspace view" })
.getByRole("button", { name: /^Chat/ }).click();
Expand Down
208 changes: 164 additions & 44 deletions e2e/responsive-workspace.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,124 @@ test("a representative compact phone exposes one mounted destination at a time",
await expectNoHorizontalOverflow(page);
});

test(
"the 500px boundary preserves mounted panes, drafts, and keyboard controls",
async ({ join, seed }) => {
await seed(RESPONSIVE_SOURCE);
let page = await join("ana", { viewport: { width: 900, height: 850 } });
let editor = content(page);
let chat = chatPane(page);
let draft = chat.locator("textarea");
await draft.fill("An unfinished thought across layouts");
await page.evaluate(() => {
let saved = window as typeof window & {
__workspaceEditor?: Element | null;
__workspaceChat?: Element | null;
};
saved.__workspaceEditor = document.querySelector('[aria-label="editable markdown"]');
saved.__workspaceChat = document.querySelector(".workspace-chat-panel");
});

let fromCompactChat = false;
for (let width of [500, 499, 500]) {
await page.setViewportSize({ width, height: 850 });
let split = width >= 500;
await expect(page.locator("[data-workspace-mode]")).toHaveAttribute(
"data-workspace-mode",
split ? "split" : "compact",
);
if (split) {
await expect(chat).toBeVisible();
await expect(page.getByRole("separator", { name: "Resize chat" })).toBeVisible();
await expect(page.getByRole("navigation", { name: "Workspace view" })).toHaveCount(0);
let views = page.getByRole("group", { name: "Document view" });
await expect(views).toBeVisible();
if (width === 500 && fromCompactChat) {
await page.getByRole("button", { name: "Hide chat pane" }).click();
await expect(chat).toBeHidden();
let showChat = page.getByRole("button", { name: "Show chat pane" });
await expect(showChat).toBeFocused();
await showChat.click();
await expect(chat).toBeVisible();
await expect(page.getByRole("heading", { name: "Chat", exact: true })).toBeFocused();
fromCompactChat = false;
}
if (width === 500) {
let decisions = views.getByRole("button", { name: /^Decisions/ });
let documentButton = views.getByRole("button", { name: "Document" });
await documentButton.focus();
await documentButton.press("Tab");
await expect(decisions).toBeFocused();
await expectInsideViewport(decisions);
let toolbarBounds = (await page.locator("[data-document-toolbar]").boundingBox())!;
let decisionBounds = (await decisions.boundingBox())!;
expect(decisionBounds.x).toBeGreaterThanOrEqual(toolbarBounds.x);
expect(decisionBounds.x + decisionBounds.width)
.toBeLessThanOrEqual(toolbarBounds.x + toolbarBounds.width);
await decisions.press("Enter");
await expect(page.locator('[data-document-view="decisions"]')).toBeVisible();
await documentButton.focus();
await expectInsideViewport(documentButton);
await documentButton.press("Enter");
await expect(editor).toBeEditable();
}
} else {
let nav = page.getByRole("navigation", { name: "Workspace view" });
await expect(nav).toBeVisible();
await expect(page.getByRole("separator", { name: "Resize chat" })).toHaveCount(0);
await nav.getByRole("button", { name: /^Chat/ }).click();
await expect(chat).toBeVisible();
await expect(draft).toHaveValue("An unfinished thought across layouts");
await nav.getByRole("button", { name: "Document" }).click();
await expect(editor).toBeEditable();
if (width === 499) {
await nav.getByRole("button", { name: /^Chat/ }).click();
fromCompactChat = true;
}
}
expect(
await page.evaluate(() => {
let saved = window as typeof window & {
__workspaceEditor?: Element | null;
__workspaceChat?: Element | null;
};
return saved.__workspaceEditor
=== document.querySelector('[aria-label="editable markdown"]')
&& saved.__workspaceChat === document.querySelector(".workspace-chat-panel");
}),
).toBe(true);
await expect(draft).toHaveValue("An unfinished thought across layouts");
await expectNoHorizontalOverflow(page);
}

await page.setViewportSize({ width: 900, height: 850 });
let handle = page.getByRole("separator", { name: "Resize chat" });
let preferredWidth = Number(await handle.getAttribute("aria-valuemax"));
await handle.press("End");
await expect.poll(async () => (await chat.boundingBox())!.width)
.toBeCloseTo(preferredWidth, 0);
await page.setViewportSize({ width: 500, height: 850 });
let main = page.locator(".workspace-frame main");
await expect.poll(async () => (await main.boundingBox())!.width).toBeGreaterThan(0);
let views = page.getByRole("group", { name: "Document view" });
let documentButton = views.getByRole("button", { name: "Document" });
let decisions = views.getByRole("button", { name: /^Decisions/ });
await documentButton.focus();
await documentButton.press("Tab");
await expect(decisions).toBeFocused();
await expectInsideViewport(decisions);
let toolbarBounds = (await page.locator("[data-document-toolbar]").boundingBox())!;
let decisionBounds = (await decisions.boundingBox())!;
expect(decisionBounds.x).toBeGreaterThanOrEqual(toolbarBounds.x);
expect(decisionBounds.x + decisionBounds.width)
.toBeLessThanOrEqual(toolbarBounds.x + toolbarBounds.width);
await page.setViewportSize({ width: 900, height: 850 });
await expect.poll(async () => (await chat.boundingBox())!.width).toBeCloseTo(preferredWidth, 0);
await expect.poll(() => page.evaluate(() => localStorage.getItem("chopin:pane:chat")))
.toBe(String(preferredWidth));
},
);

test("content swaps retain one interactive destination across pointer and immediate paths", async ({ join, seed }) => {
await seed(RESPONSIVE_SOURCE);
let page = await join("ana", { hasTouch: true, viewport: { width: 390, height: 844 } });
Expand Down Expand Up @@ -283,83 +401,82 @@ test("the safe area shell contains nonzero top and bottom insets on a 430px phon
await expectNoHorizontalOverflow(page);
});

test("phone landscape header and navigation respect inline safe areas", async ({ join, page, seed }) => {
test("landscape split controls respect inline safe areas", async ({ join, page, seed }) => {
await seed(RESPONSIVE_SOURCE);
await page.setViewportSize({ width: 844, height: 390 });
let cdp = await page.context().newCDPSession(page);
let safeArea = { bottom: 0, left: 32, right: 24, top: 0 };
await cdp.send("Emulation.setSafeAreaInsetsOverride", { insets: safeArea });
page = await join("ana");
let nav = page.getByRole("navigation", { name: "Workspace view" });
await expect(page.getByRole("navigation", { name: "Workspace view" })).toHaveCount(0);
let header = page.getByRole("banner");
let headerControls = header.getByRole("button");
let navControls = nav.getByRole("button");
let [headerFirst, headerLast, navFirst, navLast] = await Promise.all([
let chatToggle = page.getByRole("button", { name: "Hide chat pane" });
let viewControls = page.getByRole("group", { name: "Document view" }).getByRole("button");
let [headerFirst, headerLast, chatButton, viewFirst, viewLast] = await Promise.all([
headerControls.first().boundingBox(),
headerControls.last().boundingBox(),
navControls.first().boundingBox(),
navControls.last().boundingBox(),
chatToggle.boundingBox(),
viewControls.first().boundingBox(),
viewControls.last().boundingBox(),
]);
expect(headerFirst).toBeTruthy();
expect(headerLast).toBeTruthy();
expect(navFirst).toBeTruthy();
expect(navLast).toBeTruthy();
expect(chatButton).toBeTruthy();
expect(viewFirst).toBeTruthy();
expect(viewLast).toBeTruthy();
let viewportWidth = await page.evaluate(() => visualViewport!.width);
expect(headerFirst!.x).toBeGreaterThanOrEqual(safeArea.left);
expect(headerLast!.x + headerLast!.width).toBeLessThanOrEqual(
viewportWidth - safeArea.right,
);
expect(navFirst!.x).toBeGreaterThanOrEqual(safeArea.left);
expect(navLast!.x + navLast!.width).toBeLessThanOrEqual(viewportWidth - safeArea.right);
await expectInsideViewport(nav);
expect(chatButton!.x).toBeGreaterThanOrEqual(safeArea.left);
expect(viewFirst!.x).toBeGreaterThanOrEqual(safeArea.left);
expect(viewLast!.x + viewLast!.width).toBeLessThanOrEqual(viewportWidth - safeArea.right);
await expectInsideViewport(chatToggle);
await expectInsideViewport(viewControls.last());
await expectNoHorizontalOverflow(page);
});

test("a phone landscape still exposes one destination at a time", async ({ join, seed }) => {
test("an 844px landscape viewport keeps the split workspace", async ({ join, seed }) => {
await seed(RESPONSIVE_SOURCE);
let page = await join("ana", { viewport: { width: 844, height: 390 } });
let nav = page.getByRole("navigation", { name: "Workspace view" });
await nav.getByRole("button", { name: /Chat/ }).click();
await expect(page.getByRole("navigation", { name: "Workspace view" })).toHaveCount(0);
await expect(page.getByRole("complementary", { name: "Chat" })).toBeVisible();
await expect(page.getByRole("dialog", { name: "Chat" })).toHaveCount(0);
await expect(content(page)).toBeHidden();
await expect(page.getByRole("separator", { name: "Resize chat" })).toHaveCount(0);
await expect(content(page)).toBeEditable();
await expect(page.getByRole("separator", { name: "Resize chat" })).toBeVisible();
await expectNoHorizontalOverflow(page);
});

test("the compact side of the Projects transition keeps phone navigation", async ({ join, seed }) => {
test("the Projects drawer below 1024px keeps the split workspace", async ({ join, seed }) => {
let viewport = { width: 1023, height: 964 };
await seed(RESPONSIVE_SOURCE);
let page = await join("ana", { viewport });
let nav = page.getByRole("navigation", { name: "Workspace view" });
let destinations = nav.getByRole("button");
await expect(page.getByRole("button", { name: /chat pane/ })).toHaveCount(0);
await expect(page.getByRole("group", { name: "Document view" })).toHaveCount(0);
await expect(destinations.nth(0)).toHaveAccessibleName(/^Chat/);
await expect(destinations.nth(1)).toHaveAccessibleName("Document");
await expect(destinations.nth(2)).toHaveAccessibleName(/^Decisions/);

let opener = nav.getByRole("button", { name: /^Chat/ });
await opener.click();
await expect(page.getByRole("navigation", { name: "Workspace view" })).toHaveCount(0);
let projects = page.getByRole("button", { name: "Open Projects sidebar" });
await expect(projects).toBeVisible();
await projects.click();
let drawer = page.getByRole("dialog", { name: "Projects" });
await expect(drawer).toBeVisible();
await page.keyboard.press("Escape");
await expect(drawer).toBeHidden();
await expect(projects).toBeFocused();
await expect(page.getByRole("group", { name: "Document view" })).toBeVisible();
let chat = page.getByRole("complementary", { name: "Chat" });
await expect(chat).toBeVisible();
await expect(page.getByRole("dialog", { name: "Chat" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Close chat" })).toHaveCount(0);
await expect(content(page)).toBeHidden();
await expect(page.getByRole("separator", { name: "Resize chat" })).toHaveCount(0);
await expect(page.getByRole("heading", { name: "Chat", exact: true })).toBeFocused();

await nav.getByRole("button", { name: "Document", exact: true }).click();
await expect(content(page)).toBeEditable();
await expect(page.getByRole("separator", { name: "Resize chat" })).toBeVisible();
await page.getByRole("button", { name: "Hide chat pane" }).click();
await expect(chat).toBeHidden();
await expect(content(page)).toBeVisible();
await expect(page.getByRole("heading", { name: "Document", exact: true })).toBeFocused();

let opener = page.getByRole("button", { name: "Show chat pane" });
await expect(opener).toBeFocused();
await opener.click();
await expect(chat).toBeVisible();
await expect(page.getByRole("heading", { name: "Chat", exact: true })).toBeFocused();
await page.keyboard.press("Escape");
await expect(chat).toBeHidden();
await expect(opener).toBeFocused();
await chat.locator("textarea").press("Escape");
await expect(chat).toBeVisible();
await expectNoHorizontalOverflow(page);
});

Expand Down Expand Up @@ -415,17 +532,20 @@ test("a representative desktop retains the split Chat layout", async ({ join, se
await expect(content(page)).toBeEditable();
});

test("200% zoom resolves to the compact presentation without clipping", async ({ join, seed }) => {
test("200% zoom at 640 CSS pixels keeps split controls without clipping", async ({ join, seed }) => {
await seed(RESPONSIVE_SOURCE);
let page = await join("ana", {
screen: { width: 1280, height: 900 },
viewport: { width: 640, height: 450 },
});
let nav = page.getByRole("navigation", { name: "Workspace view" });
await expect(nav).toBeVisible();
await nav.getByRole("button", { name: /Chat/ }).click();
await expect(page.getByRole("navigation", { name: "Workspace view" })).toHaveCount(0);
await expect(page.getByRole("complementary", { name: "Chat" })).toBeVisible();
await expect(content(page)).toBeHidden();
await expect(page.getByRole("group", { name: "Document view" })).toBeVisible();
await expect(content(page)).toBeEditable();
await expectNoHorizontalOverflow(page);
await page.setViewportSize({ width: 480, height: 450 });
await expect(page.getByRole("navigation", { name: "Workspace view" })).toBeVisible();
await expect(page.getByRole("separator", { name: "Resize chat" })).toHaveCount(0);
await expectNoHorizontalOverflow(page);
});

Expand Down
2 changes: 1 addition & 1 deletion e2e/shell.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ test("the chat rail edge follows the pointer", async ({ join, page }) => {
});

test("the compact workspace keeps the document unobstructed", async ({ join, page }) => {
await page.setViewportSize({ width: 640, height: 800 });
await page.setViewportSize({ width: 499, height: 800 });
await join("ana");

await expect(page.getByRole("separator", { name: "Resize chat" })).toHaveCount(0);
Expand Down
2 changes: 1 addition & 1 deletion e2e/sidecar.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,7 @@ test("an edit received while compact Plan is hidden appears when it returns", as

for (
let example of [
{ compact: true, name: "compact", width: 768 },
{ compact: true, name: "compact", width: 390 },
{ compact: false, name: "desktop", width: 1280 },
]
) {
Expand Down
Loading
Loading