Files
paseo/packages/app/e2e/browser/compact-workspace-sheet.spec.ts
T
Mohamed BoudraandClaude e5124b039a Put sheets on one stack and show an agent's facts in its sheet (#6958)
* Put sheet tabs on the one sheet stack

Sheets are one primitive with one stack. A sheet opened from any sheet
stacks on top of it, and closing it uncovers the sheet underneath. The
compact workspace's file and subagent sheets were an in-tree Gorhom
BottomSheet with their own stacking, so a subagent chosen from the
subagents list sheet replaced the list instead of covering it.

- SheetHostScope: a subtree hosts the sheets presented inside it, so the
  sheet tabs draw inside the compact column and read the workspace
  providers while still joining the app-wide stack. Needs Gorhom's
  BottomSheetModalInternalProvider, which the patch exports.
- IsolatedBottomSheetModal takes enableDismissOnClose; false keeps the
  content mounted through a close so the unsaved-changes guard can veto
  it. Its backdrop then closes the sheet rather than dismissing the top
  modal. The Gorhom patch also keeps a dismissing modal's status through
  its final onChange(-1), without which such a modal unmounting while
  presented never left the portal.
- SheetTabs (was WorkspaceSheetHost) renders one IsolatedBottomSheetModal
  per store entry. The store never reorders sheets; a reveal of a covered
  sheet drops the unmodified sheets above it.
- Subagent rows that open a sheet keep the list open (closeOnSelect
  false); rows that open a tab still close it.
- A menu closes when its retained panel goes inactive, instead of hiding
  and reappearing with the panel on the next tab switch.

Co-Authored-By: Claude <noreply@anthropic.com>

* Show an agent's model and thinking in its sheet, and fit Open as tab in the header rail

A Paseo agent shown in a sheet has no composer, so the facts the composer's
model and thinking controls would show now sit in one line under the sheet
header, the same row provider subagents already get from the daemon. The line
resolves exactly as the controls do (resolveAgentModelSelection), from the
session agent and the providers snapshot, and shows wherever the pane host is
a sheet.

Open as tab becomes the full-screen glyph beside Close, drawn by the shared
SheetHeaderIconButton, so the title keeps its width. The tab switcher sheet is
titled "Workspace tabs" so it reads as what it lists.

Co-Authored-By: Claude <noreply@anthropic.com>

* Show mode and context usage in the sheet facts line

The line under a sheet's header now carries everything the composer's
controls and context meter show: model, thinking, mode, and used/max context
tokens with the share, each resolved as its control resolves it.

Co-Authored-By: Claude <noreply@anthropic.com>

* Re-render subagent rows when the workspace's tabs change

A row decides at render time whether choosing it opens a sheet or reveals
a tab, and keeps the list open only for a sheet. The hook behind it only
subscribed to the layout mode, so a row rendered while its agent was a
full tab kept closing the list after that tab was gone: on iOS, promoting
a managed child, closing its tab, and reopening it from the list dropped
the list under the sheet. Subscribe to the workspace's layout too.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-15 12:16:45 +07:00

155 lines
6.5 KiB
TypeScript

import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test } from "../support/fixtures";
import { expectComposerVisible } from "../support/helpers/composer";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import {
expectSubagentRowVisible,
openSubagentFromCompactTrack,
seedSubagent,
} from "../support/helpers/subagents";
import {
closeSheet,
expectOpenTabCount,
openSheetAsTab,
expectDeclinedDiscardPrompt,
expectSheetAtSnap,
replaceSheetFileText,
selectTabFromSwitcher,
sheet,
sheetHeader,
tapSheetBackdrop,
} from "../support/helpers/sheet";
const FILE_LINK = "linked.ts:3";
function promptMentioning(target: string): string {
return [
"Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
"Return JSON only with fields 'title' and 'branch'.",
"",
"<user-prompt>",
`Open \`${target}\` now`,
"</user-prompt>",
].join("\n");
}
test("compact opens files and subagents in a sheet, leaving the tabs for agents", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
const workspace = await seedMockAgentWorkspace({
repoPrefix: "compact-workspace-sheet-",
title: "Sheet parent",
initialPrompt: promptMentioning(FILE_LINK),
});
try {
const gate = await installDaemonWebSocketGate(page);
const linkedPath = path.join(workspace.cwd, "linked.ts");
await writeFile(linkedPath, "one\ntwo\nthree\n", "utf8");
const child = await seedSubagent(
{ client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
{ parentId: workspace.agentId, title: "Sheet child" },
);
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
await expectOpenTabCount(page, 1);
await test.step("a chat file link opens the file in a sheet", async () => {
const fileLink = page.getByText(FILE_LINK, { exact: true });
await expect(fileLink).toBeVisible({ timeout: 15_000 });
await fileLink.click();
const fileSheet = sheet(page, "linked.ts");
await expect(fileSheet).toBeVisible();
await expect(fileSheet.getByText("three")).toBeVisible();
await expectSheetAtSnap(page, "linked.ts");
await expect(
sheetHeader(page, "linked.ts").getByRole("button", { name: "Open as tab" }),
).toHaveCount(0);
await expectOpenTabCount(page, 1);
await closeSheet(page, "linked.ts");
});
await test.step("a subagent opens in a sheet over the subagents list", async () => {
await openSubagentFromCompactTrack(page, child.id);
const childSheet = sheet(page, child.title);
await expect(childSheet).toBeVisible();
await expectSheetAtSnap(page, child.title);
await expect(childSheet.getByRole("textbox", { name: "Message agent..." })).toHaveCount(0);
// The composer's model, thinking, and mode stand in for it, resolved the same way. An idle
// seeded agent has no context usage yet.
await expect(childSheet.getByTestId("agent-pane-subtitle")).toHaveText(
"Ten second stream · Low · Load test",
);
await expectOpenTabCount(page, 1);
});
await test.step("closing the subagent's sheet uncovers the list", async () => {
await closeSheet(page, child.title);
await expectSubagentRowVisible(page, child.id);
});
await test.step("Open as tab promotes the subagent to a full tab and ends the list", async () => {
await page.getByTestId(`subagents-track-row-${child.id}`).click();
await expectSheetAtSnap(page, child.title);
await openSheetAsTab(page, child.title);
await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0);
await expectOpenTabCount(page, 2);
await expectComposerVisible(page);
});
await test.step("the list the subagent was opened from closed with its tab", async () => {
await selectTabFromSwitcher(page, "Sheet parent");
await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0);
});
await test.step("once its tab closes, the subagent reopens in a sheet that keeps the list", async () => {
await page
.getByRole("button", { name: /Switch tabs/ })
.filter({ visible: true })
.click();
await expect(page.getByText("Workspace tabs", { exact: true })).toBeVisible();
const menuBase = `workspace-tab-menu-agent_${child.id}`;
await page.getByTestId(`${menuBase}-trigger`).click();
await page.getByTestId(`${menuBase}-close`).click();
await expectOpenTabCount(page, 1);
// Closing a tab keeps the switcher open, with the parent as the tab left. Its backdrop is
// tapped once the row menu's own sheet has gone.
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(1);
await tapSheetBackdrop(page);
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
await openSubagentFromCompactTrack(page, child.id);
await expectSheetAtSnap(page, child.title);
await closeSheet(page, child.title);
await expectSubagentRowVisible(page, child.id);
await tapSheetBackdrop(page);
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
});
await test.step("a sheet with unsaved edits closes only through the guarded close", async () => {
await page.getByText(FILE_LINK, { exact: true }).click();
const fileSheet = sheet(page, "linked.ts");
await expect(fileSheet).toBeVisible();
// Hold the autosave so the edit stays unsaved.
gate.holdNextClientRequest("fs.file.write.request");
await replaceSheetFileText(page, "linked.ts", "edited in the sheet");
await gate.waitForHeldClientRequest();
await expectDeclinedDiscardPrompt(page, () => tapSheetBackdrop(page));
await expect(fileSheet.locator(".cm-content")).toHaveText("edited in the sheet");
await expectDeclinedDiscardPrompt(page, () =>
sheetHeader(page, "linked.ts").getByRole("button", { name: "Close" }).click(),
);
await expect(fileSheet.locator(".cm-content")).toHaveText("edited in the sheet");
gate.releaseHeldClientRequest();
await expect.poll(() => readFile(linkedPath, "utf8")).toBe("edited in the sheet");
});
} finally {
await workspace.cleanup();
}
});