merge: sync main into PR #7495

Integrate getpaseo/paseo main at b8bb335c56.

Co-authored-by: RoundRedDot <roundreddot@gmail.com>
This commit is contained in:
luke
2026-10-24 19:18:10 +08:00
co-authored by RoundRedDot
654 changed files with 33136 additions and 11830 deletions
+5 -1
View File
@@ -10,6 +10,7 @@ const withAndroidScroll = require("./modules/paseo-scroll/app.plugin");
const { getNativeReleaseVersion } = require("./native-release-version");
const appVariant = process.env.APP_VARIANT ?? "production";
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
const isWatchdogBuild = process.env.PASEO_WATCHDOG_BUILD === "1";
const isProfileBuild = process.env.PASEO_PROFILE_BUILD === "1";
const buildProfile = isFdroidBuild
@@ -181,7 +182,7 @@ export default {
},
],
...buildProfile.fdroidPlugins,
...(isProfileBuild ? [withAndroidProfileable] : []),
...(isProfileBuild || isWatchdogBuild ? [withAndroidProfileable] : []),
],
experiments: {
typedRoutes: true,
@@ -191,6 +192,9 @@ export default {
extra: {
fdroidBuild: isFdroidBuild,
profileBuild: isProfileBuild,
watchdogBuild: isWatchdogBuild,
diagnosticRevision: process.env.PASEO_DIAGNOSTIC_REVISION,
diagnosticBuildTime: process.env.PASEO_DIAGNOSTIC_BUILD_TIME,
router: {},
eas: {
projectId: "0e7f65ce-0367-46c8-a238-2b65963d235a",
@@ -420,3 +420,40 @@ test.describe("Add Project command-center flow", () => {
}
});
});
test.describe("Compact Add project context", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("canceling Add project preserves the sidebar", async ({ page }) => {
await gotoAppShell(page);
await page.getByRole("button", { name: "Open menu", exact: true }).click();
await openAddProjectFlow(page);
await page.keyboard.press("Escape");
await expect(addProjectFlow(page)).not.toBeVisible();
await expect(page.getByRole("button", { name: "Close menu", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
);
await expect(page.getByTestId("sidebar-search")).toBeVisible();
});
test("successful Add project reveals the new workspace page", async ({
page,
projectPickerFixture,
}) => {
await gotoAppShell(page);
await page.getByRole("button", { name: "Open menu", exact: true }).click();
await openAddProjectFlow(page);
await chooseAddProjectMethod(page, "directory-search");
await addProjectFlowInput(page).fill(projectPickerFixture.fuzzyQuery);
await expect(addProjectFlow(page)).toContainText(projectPickerFixture.projectName);
await page.keyboard.press("Enter");
const projectId = await expectOpenedProject(page, projectPickerFixture.projectName);
projectPickerFixture.rememberProjectId(projectId);
await expectNewWorkspaceForAddedProject(page, {
serverId: getServerId(),
projectId,
projectName: projectPickerFixture.projectName,
projectPath: projectPickerFixture.projectPath,
});
await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible();
});
});
@@ -21,8 +21,10 @@ async function completeSubmittedTurn(
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(userMessage).toBeVisible();
await expectAgentReadyToInterrupt(page);
const finish = await agent.client.waitForFinish(agent.agentId, 30_000);
expect(finish.status).toBe("idle");
await expectAgentIdle(page);
await expect(page.getByText("(end of synthetic stream)", { exact: true }).last()).toBeVisible();
await expect(userMessage).toHaveAttribute("aria-busy", "false");
return userMessage;
+135 -5
View File
@@ -262,24 +262,74 @@ test.afterEach(async () => {
}
});
test("Changes opens the populated committed comparison for a clean checkout", async ({ page }) => {
test("Changes opens All changes for a clean checkout", async ({ page }) => {
const workspace = await createWorkspaceWithCommittedDiff();
await openWorkspaceChangesSurface(page, workspace, 90_000);
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("Committed");
await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("All changes");
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
});
test("Changes expires a manual comparison when checkout dirtiness changes", async ({ page }) => {
test("All changes combines committed and pending edits and preserves an unfinished review", async ({
page,
context,
}) => {
const workspace = await createWorkspaceWithNetDiff();
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await configureDiffPresentation(page, { layout: "unified", wrapLines: false });
await openWorkspaceChangesSurface(page, workspace);
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const mode = tree.getByRole("button", { name: "Diff mode", exact: true });
await expect(mode).toContainText("All changes");
await expectNetComparisonFiles(page, ["a-tracked.ts", "b-committed.ts"]);
await expectRenderedAddedLine(page, "committed");
await test.step("an unfinished review survives the first dirty edit", async () => {
await startReviewOnFirstChangedLine(page);
await page
.getByRole("textbox", { name: "Review comment" })
.fill("Unfinished review stays here");
await writeFile(path.join(workspace.repoPath, "c-untracked.txt"), "untracked\n");
await expectNetComparisonFiles(page, ["a-tracked.ts", "b-committed.ts", "c-untracked.txt"]);
await expect(mode).toContainText("All changes");
await expect(page.getByRole("textbox", { name: "Review comment" })).toHaveValue(
"Unfinished review stays here",
);
await cancelInlineReview(page);
});
await test.step("pending edits form a live net patch over the committed edit", async () => {
await writeFile(path.join(workspace.repoPath, "a-tracked.ts"), "working\n");
await expectRenderedAddedLine(page, "working");
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("a-tracked.ts, +1, -1");
});
await test.step("committing the same contents preserves the rendered comparison", async () => {
execFileSync("git", ["add", "--all"], { cwd: workspace.repoPath });
execFileSync("git", ["commit", "-m", "Commit pending changes"], { cwd: workspace.repoPath });
await expect(page.getByTestId("changes-primary-cta")).toContainText("Merge", {
timeout: 30_000,
});
await expect(mode).toContainText("All changes");
await expectNetComparisonFiles(page, ["a-tracked.ts", "b-committed.ts", "c-untracked.txt"]);
await expectRenderedAddedLine(page, "working");
});
await test.step("undoing to the comparison base removes the net change", async () => {
await writeFile(path.join(workspace.repoPath, "a-tracked.ts"), "base\n");
await expectNetComparisonFiles(page, ["b-committed.ts", "c-untracked.txt"]);
await expect(mode).toContainText("All changes");
});
});
test("Changes keeps a manual comparison when checkout dirtiness changes", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await openWorkspaceChanges(page, workspace);
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
const mode = tree.getByTestId("changes-diff-status-trigger");
await expect(mode).toContainText("Uncommitted");
await expect(mode).toContainText("All changes");
await mode.click();
await page.getByTestId("changes-diff-mode-committed").click();
@@ -294,7 +344,23 @@ test("Changes expires a manual comparison when checkout dirtiness changes", asyn
});
await writeFile(path.join(workspace.repoPath, "new-working-change.txt"), "uncommitted\n");
await expect(mode).toContainText("Uncommitted", { timeout: 30_000 });
await expect(tree.getByRole("button", { name: "See uncommitted changes" })).toBeVisible({
timeout: 30_000,
});
await expect(mode).toContainText("Committed");
await selectChangesComparison(page, "Uncommitted");
execFileSync("git", ["add", "--all"], { cwd: workspace.repoPath });
execFileSync("git", ["commit", "-m", "Commit new working change"], { cwd: workspace.repoPath });
await expect(tree.getByText("No changes to display", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(mode).toContainText("Uncommitted");
await writeFile(path.join(workspace.repoPath, "another-working-change.txt"), "pending\n");
await expect(tree.getByText("another-working-change.txt", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(mode).toContainText("Uncommitted");
});
test("an empty Changes comparison links to the populated comparison", async ({ page }) => {
@@ -633,6 +699,7 @@ test("changes context menus expose folder revert and restore a file after confir
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await selectChangesComparison(page, "Uncommitted");
const tree = changesTree(page);
await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" });
@@ -680,6 +747,7 @@ test("discarding a staged rename restores its source path", async ({ page }) =>
const workspace = await createWorkspaceWithMountedTabDiff({ includeRenamedFile: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await selectChangesComparison(page, "Uncommitted");
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const renamedHeader = diffHeaderForPath(panel, "src/zz-renamed.ts");
@@ -710,6 +778,7 @@ test("discarding an untracked file removes it from the working tree", async ({ p
const workspace = await createWorkspaceWithMountedTabDiff({ includeUntrackedFile: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await selectChangesComparison(page, "Uncommitted");
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const untrackedHeader = diffHeaderForPath(panel, "zz-untracked.txt");
@@ -741,6 +810,7 @@ test("shows a revert error returned by the daemon", async ({ page }) => {
await failNextDiscardRequest(page);
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await selectChangesComparison(page, "Uncommitted");
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
const confirmation = new Promise<void>((resolve) => {
@@ -892,6 +962,8 @@ test("compact Explorer renders inline hunks with a saved tree-only desktop prefe
{ key: CHANGES_PREFERENCES_KEY },
);
const explorer = await openCompactChanges(page, workspace);
await explorer.getByRole("button", { name: "Diff mode", exact: true }).click();
await page.getByRole("menuitem", { name: "Uncommitted", exact: true }).click();
await expect(explorer.getByTestId("git-diff-canvas")).toBeVisible();
await expect(explorer.getByTestId("diff-file-0-toggle")).toBeVisible();
await explorer.getByTestId("diff-file-0-toggle").click({ button: "right" });
@@ -1525,6 +1597,64 @@ async function createWorkspaceWithMountedTabDiff(
return { id: createdWorkspace.workspace.id, repoPath: repo.path };
}
async function createWorkspaceWithNetDiff(): Promise<DirtyWorkspace> {
const repo = await createTempGitRepo("changes-net-", {
files: [{ path: "a-tracked.ts", content: "base\n" }],
});
const client = await connectSeedClient();
cleanupTasks.push({
run: async () => {
await client.close();
await repo.cleanup();
},
});
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repo.path });
await writeFile(path.join(repo.path, "a-tracked.ts"), "committed\n");
await writeFile(path.join(repo.path, "b-committed.ts"), "committed only\n");
execFileSync("git", ["add", "--all"], { cwd: repo.path });
execFileSync("git", ["commit", "-m", "Committed changes"], { cwd: repo.path });
const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
if (!created.workspace) throw new Error(created.error ?? "Failed to create net diff workspace");
return { id: created.workspace.id, repoPath: repo.path };
}
async function expectNetComparisonFiles(page: Page, files: string[]): Promise<void> {
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
await expect(tree.locator('[data-testid^="diff-tree-file-"][data-testid$="-name"]')).toHaveText(
files,
);
await expect(panel.getByTestId(/^diff-file-\d+$/)).toHaveCount(files.length);
for (const file of files) {
await expect(diffHeaderForPath(panel, file)).toBeVisible();
}
}
async function expectRenderedAddedLine(page: Page, contents: string): Promise<void> {
await changesTree(page).getByText("a-tracked.ts", { exact: true }).click();
await expect(async () => {
await page.keyboard.press("Escape");
await page.evaluate(() => navigator.clipboard.writeText(""));
const body = page.getByTestId("diff-file-0-body");
const canvas = page.getByTestId("git-diff-canvas");
const bounds = await body.boundingBox();
if (!bounds) throw new Error("Expanded diff body has no bounds");
const fontSize = await canvas.evaluate((element) =>
Number.parseFloat(getComputedStyle(element).fontSize),
);
// Replacement: hunk header, removed line, added line.
await page.mouse.click(bounds.x + 120, bounds.y + Math.round(fontSize * 1.5) * 2.5, {
button: "right",
});
const copyLine = page.getByRole("menuitem", { name: "Copy line", exact: true });
await expect(copyLine).toBeEnabled({ timeout: 1000 });
await copyLine.click();
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()), { timeout: 1000 })
.toBe(contents);
}).toPass({ timeout: 15_000 });
}
async function createWorkspaceWithCommittedDiff(): Promise<DirtyWorkspace> {
const repo = await createTempGitRepo("changes-committed-", {
files: [{ path: "tracked.ts", content: "export const tracked = 1;\n" }],
@@ -5,6 +5,7 @@ import {
applyCommandCenterAgentControls,
chooseCommandCenterAgentControl,
openCommandCenterForAgent,
searchAgentControls,
expectCommandCenterAgentControlSelected,
expectFocusedAgentControls,
expectWorkspaceAgentConfiguration,
@@ -111,8 +112,8 @@ test.describe("Command Center agent controls", () => {
});
try {
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
const input = await openCommandCenterForAgent(page, workspace);
await input.fill("load");
await openCommandCenterForAgent(page, workspace);
await searchAgentControls(page, "load");
await page.keyboard.press("Enter");
await expectWorkspaceAgentConfiguration(workspace, {
@@ -184,7 +185,7 @@ test.describe("Command Center agent controls", () => {
try {
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
const input = await openCommandCenterForAgent(page, workspace);
await input.fill("loa");
await searchAgentControls(page, "loa");
// Moves the highlight onto a model row, which still matches after the next keystroke.
await page.keyboard.press("ArrowDown");
await input.fill("load");
@@ -2,11 +2,17 @@ import { expect, test } from "../support/fixtures";
import { openCommandCenter } from "../support/helpers/command-center";
import {
delayDirectorySuggestionResponses,
holdDirectorySuggestionResponses,
expectOneLineTruncatedFileResult,
expectStableCommandCenterLayout,
failDirectorySuggestionRequests,
startCommandCenterLayoutObservation,
} from "../support/helpers/command-center-file-search";
import {
scrollCommandCenterAwayFromSelection,
expectCommandCenterScrollToStayAt,
expectKeyboardNavigationToResume,
} from "../support/helpers/command-center-scroll";
import { expectFileTabOpen } from "../support/helpers/file-explorer";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
@@ -137,3 +143,94 @@ test("unscoped command search keeps commands visible and reports file-search fai
await Promise.allSettled(seeded.map((workspace) => workspace.cleanup()));
}
});
test("manual scrolling cancels a pending file-search reveal and query editing restores it", async ({
page,
}) => {
const seeded = await seedWorkspace({
repoPrefix: "command-center-delayed-scroll-",
title: "Delayed file search scroll",
repo: { files: paseoSizedFiles() },
});
try {
const responses = await holdDirectorySuggestionResponses(page);
await gotoWorkspace(page, seeded.workspaceId);
await page.keyboard.press("Meta+P");
const panel = page.getByTestId("command-center-panel");
await expect(panel).toBeVisible();
await panel.getByTestId("command-center-input").fill("index");
await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden();
await expect(
panel.getByRole("button", {
name: "index.ts packages/app/src/features/feature-000",
exact: true,
}),
).toBeVisible();
responses.hold();
await panel.getByTestId("command-center-input").fill("index.");
await expect(panel.getByTestId("command-center-file-search-loading")).toBeVisible();
const offset = await scrollCommandCenterAwayFromSelection(page, panel);
await responses.release();
await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden();
await expectCommandCenterScrollToStayAt(panel, offset);
await expectKeyboardNavigationToResume(page, panel, offset);
const nextOffset = await scrollCommandCenterAwayFromSelection(page, panel);
await panel.getByTestId("command-center-input").fill("index.ts");
await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden();
await expect
.poll(() => panel.getByTestId("command-center-results").evaluate((el) => el.scrollTop))
.toBeLessThan(nextOffset);
await expect(
panel.getByRole("button", {
name: "index.ts packages/app/src/features/feature-000",
exact: true,
}),
).toBeInViewport({ ratio: 1 });
await page.keyboard.press("Escape");
await page.keyboard.press("Meta+P");
await expect(panel.getByTestId("command-center-input")).toHaveValue("");
await panel.getByTestId("command-center-input").fill("index");
await expect(
panel.getByRole("button", {
name: "index.ts packages/app/src/features/feature-000",
exact: true,
}),
).toBeInViewport({ ratio: 1 });
} finally {
await seeded.cleanup();
}
});
test.describe("Compact file search context", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("a file result opens a sheet above the left sidebar", async ({ page }) => {
const seeded = await seedWorkspace({
repoPrefix: "compact-search-file-",
title: "Compact file lookup",
repo: { files: [{ path: "lookup.ts", content: "export const lookup = true;\n" }] },
});
try {
await page.goto(`/h/${process.env.E2E_SERVER_ID}/workspace/${seeded.workspaceId}`);
await page.getByRole("button", { name: "Open menu", exact: true }).click();
const search = await openCommandCenter(page);
await search.getByTestId("command-center-input").fill("lookup.ts");
await search.getByRole("button", { name: "lookup.ts", exact: true }).click();
await expect(page.getByTestId("sheet-tab-body")).toBeVisible();
await expect(page.getByTestId("sheet-tab-header")).toContainText("lookup.ts");
await page
.getByTestId("sheet-tab-header")
.getByRole("button", { name: "Close", exact: true })
.click();
await expect(page.getByTestId("sheet-tab-body")).not.toBeVisible();
await expect(page.getByRole("button", { name: "Close menu", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
);
await expect(page.getByTestId("sidebar-search")).toBeVisible();
} finally {
await seeded.cleanup();
}
});
});
@@ -1,7 +1,21 @@
import { expect } from "@playwright/test";
import { test } from "../support/fixtures";
import { getServerId } from "../support/helpers/server-id";
import {
seedSearchableCommandCenter,
observeCommandCenterRowRenders,
searchCommandCenter,
warmCommandCenterRows,
expectOnlyCommandCenterRowRendered,
expectCommandCenterAgentWindow,
scrollToOldestCommandCenterAgent,
} from "../support/helpers/command-center-performance";
import { gotoAppShell } from "../support/helpers/app";
import {
expectCommandCenterToRemainAtTheTop,
scrollCommandCenterAwayFromSelection,
expectCommandCenterScrollToStayAt,
expectKeyboardNavigationToResume,
expectPrimaryCommandCenterActions,
expectTightTwoLineResultSpacing,
expectVisibleKeyboardNavigationNotToScroll,
@@ -16,12 +30,15 @@ test.use({
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/145.0 Safari/537.36",
});
test("opening the command center keeps its first result fully visible", async ({ page }) => {
test("Command Center preserves manual scrolling through live updates and resumes keyboard navigation", async ({
page,
}) => {
const seeded = await seedWorkspace({
repoPrefix: "command-center-scroll-",
title: "Command Center Scroll 00",
});
let newestWorkspaceId = seeded.workspaceId;
try {
for (let index = 1; index <= 8; index += 1) {
const created = await seeded.client.createWorkspace({
@@ -36,6 +53,7 @@ test("opening the command center keeps its first result fully visible", async ({
if (!created.workspace) {
throw new Error(created.error ?? `Failed to seed workspace ${index}`);
}
newestWorkspaceId = created.workspace.id;
}
await gotoAppShell(page);
@@ -48,6 +66,52 @@ test("opening the command center keeps its first result fully visible", async ({
// The empty query lists the most recent workspaces, so the last one seeded leads it.
await expectTightTwoLineResultSpacing(panel, "Command Center Scroll 08");
await expectWorkspaceResultsToBeLimitedUntilSearch(panel);
const offset = await scrollCommandCenterAwayFromSelection(page, panel);
await seeded.client.setWorkspaceTitle(newestWorkspaceId, "Command Center Scroll renamed");
await expect(panel.getByText("Command Center Scroll renamed", { exact: true })).toHaveCount(1);
await expectCommandCenterScrollToStayAt(panel, offset);
await expectKeyboardNavigationToResume(page, panel, offset);
} finally {
await seeded.cleanup();
}
});
test("live search updates render only the changed row and keep agents virtualized", async ({
page,
}) => {
test.setTimeout(120_000);
const seeded = await seedSearchableCommandCenter();
try {
await observeCommandCenterRowRenders(page);
await gotoAppShell(page);
const panel = await openCommandCenterWithKeyboard(page);
await searchCommandCenter(panel, "Search agent");
await expect(panel.getByText("Search agent 059", { exact: true })).toBeVisible();
await warmCommandCenterRows(page);
await expectCommandCenterAgentWindow(panel, seeded.agents.length);
await seeded.client.updateAgent(seeded.agents[59].id, { name: "Search agent 059 revised" });
await expect(panel.getByText("Search agent 059 revised", { exact: true })).toBeVisible();
await expectOnlyCommandCenterRowRendered(
page,
`agent:${getServerId()}:${seeded.agents[59].id}`,
);
await searchCommandCenter(panel, "Search workspace");
await expect(panel.getByText("Search workspace 039", { exact: true })).toBeVisible();
await warmCommandCenterRows(page);
await seeded.client.setWorkspaceTitle(seeded.workspaces[39].id, "Search workspace 039 revised");
await expect(panel.getByText("Search workspace 039 revised", { exact: true })).toBeVisible();
await expectOnlyCommandCenterRowRendered(
page,
`workspace:${getServerId()}:${seeded.workspaces[39].id}`,
);
await searchCommandCenter(panel, "Search agent");
await warmCommandCenterRows(page);
await scrollToOldestCommandCenterAgent(panel);
await expect(panel.getByText("Search agent 000", { exact: true })).toBeVisible();
await expectCommandCenterAgentWindow(panel, seeded.agents.length);
} finally {
await seeded.cleanup();
}
@@ -9,6 +9,7 @@ import { addOfflineHostAndReload } from "../support/helpers/hosts";
import { expectAppRoute } from "../support/helpers/route-assertions";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { seedRunningMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
const PRIMARY_HOST_LABEL = "Primary Host";
@@ -20,6 +21,34 @@ const AGENT_TITLE = "Fix checkout retries";
test.describe("Command center workspaces", () => {
test.describe.configure({ timeout: 180_000 });
test("agent and workspace results retain their icons and overlay live status", async ({
page,
}) => {
const workspace = await seedRunningMockAgentWorkspace({
repoPrefix: "command-center-status-",
title: "Review checkout retries",
model: "five-minute-stream",
initialPrompt: "Keep working on checkout retries",
});
try {
await openAgentRoute(page, workspace);
await expectAgentTabActive(page, workspace.agentId);
const panel = await openCommandCenter(page);
const agent = panel.getByTestId(`command-center-agent-${getServerId()}:${workspace.agentId}`);
const row = panel.getByTestId(
`command-center-workspace-${getServerId()}:${workspace.workspaceId}`,
);
await expect(agent.getByRole("progressbar", { name: "Agent running" })).toBeVisible();
await expect(row.getByRole("progressbar", { name: "Agent running" })).toBeVisible();
await expect(agent.getByRole("img", { name: "Mock agent" })).toBeVisible();
await workspace.client.cancelAgent(workspace.agentId);
await expect(agent.getByRole("progressbar")).toHaveCount(0);
await expect(row.getByRole("progressbar")).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
test("workspace results show their title, host, and branch and open the workspace", async ({
page,
}) => {
@@ -156,3 +185,74 @@ test.describe("Command center workspaces", () => {
}
});
});
test.describe("Compact sidebar inspection", () => {
test.use({ viewport: { width: 390, height: 844 } });
async function openSidebar(page: import("@playwright/test").Page) {
await page.getByRole("button", { name: "Open menu", exact: true }).click();
await expect(page.getByTestId("sidebar-search")).toBeVisible();
}
async function expectSidebarPreserved(page: import("@playwright/test").Page) {
await expect(page.getByRole("button", { name: "Close menu", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
);
await expect(page.getByTestId("sidebar-search")).toBeVisible();
}
test("canceling Search retains the compact sidebar", async ({ page }) => {
await gotoAppShell(page);
await openSidebar(page);
await openCommandCenter(page);
await page.keyboard.press("Escape");
await expect(page.getByTestId("command-center-panel")).not.toBeVisible();
await expectSidebarPreserved(page);
});
test("canceling Import session retains the compact sidebar", async ({ page }) => {
await gotoAppShell(page);
await openSidebar(page);
await page
.getByTestId("sidebar-project-empty-state")
.getByRole("button", { name: "Import session", exact: true })
.click();
await expect(page.getByTestId("import-session-sheet")).toBeVisible();
await page.getByRole("button", { name: "Close", exact: true }).click();
await expect(page.getByTestId("import-session-sheet")).not.toBeVisible();
await expectSidebarPreserved(page);
});
test("Search reveals the same workspace and a selected root agent", async ({ page }) => {
const seeded = await seedWorkspace({
repoPrefix: "compact-search-destination-",
title: "Compact destination",
});
try {
const agent = await createIdleAgent(seeded.client, {
cwd: seeded.repoPath,
workspaceId: seeded.workspaceId,
title: "Compact root agent",
});
await page.goto(buildHostWorkspaceRoute(getServerId(), seeded.workspaceId));
await openSidebar(page);
const search = await openCommandCenter(page);
await search.getByTestId("command-center-input").fill("Compact destination");
await search
.getByTestId(`command-center-workspace-${getServerId()}:${seeded.workspaceId}`)
.click();
await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible();
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), seeded.workspaceId));
await openSidebar(page);
const agentSearch = await openCommandCenter(page);
await agentSearch.getByTestId("command-center-input").fill("Compact root agent");
await agentSearch.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`).click();
await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible();
await expect(
page.getByRole("textbox", { name: "Message agent...", exact: true }),
).toBeVisible();
} finally {
await seeded.cleanup();
}
});
});
@@ -0,0 +1,58 @@
import { metroTest as test, expect } from "../support/fixtures";
import { hideDeveloperToasts } from "../support/developer-ui.mjs";
import type { Page } from "@playwright/test";
async function showTransientDeveloperToast(page: Page, shadow: boolean) {
await page.evaluate((useShadow) => {
const toast = document.createElement("div");
toast.id = "error-toast";
document.body.append(toast);
const root = useShadow ? toast.attachShadow({ mode: "open" }) : toast;
const button = document.createElement("button");
button.style.cssText = "width:32px;height:32px";
button.addEventListener("click", () => button.remove());
root.append(button);
}, shadow);
}
async function openHiddenDeveloperToast(page: Page) {
await page.setContent("<button>Continue</button>");
await showTransientDeveloperToast(page, false);
await hideDeveloperToasts(page);
}
async function navigateWithHiddenDeveloperToasts(page: Page) {
await hideDeveloperToasts(page);
await page.goto("data:text/html,<button>Continue</button>");
await showTransientDeveloperToast(page, true);
}
async function inspectLayoutWithHiddenToast(page: Page) {
const action = page.getByRole("button", { name: "Continue", exact: true });
const boxes = await Promise.all(Array.from({ length: 4 }, () => action.boundingBox()));
for (const box of boxes) expect(box).not.toBeNull();
await expect(page.locator("#error-toast")).toBeHidden();
await action.click();
}
test("a hidden developer toast does not stall concurrent layout checks", async ({ page }) => {
test.setTimeout(10000);
await openHiddenDeveloperToast(page);
await inspectLayoutWithHiddenToast(page);
});
test("developer toast hiding survives navigation and Expo shadow-root updates", async ({
page,
}) => {
test.setTimeout(10000);
await navigateWithHiddenDeveloperToasts(page);
await inspectLayoutWithHiddenToast(page);
await page.evaluate(() => {
const root = document.getElementById("error-toast")!.shadowRoot!;
const replacement = document.createElement("button");
replacement.style.cssText = "width:32px;height:32px";
replacement.addEventListener("click", () => replacement.remove());
root.append(replacement);
});
await inspectLayoutWithHiddenToast(page);
});
@@ -182,7 +182,8 @@ test.describe("Workspace file change conflicts", () => {
await expect(filePane(page).getByText("Before", { exact: true })).toBeVisible();
await unlink(filePath);
await expect(filePane(page).getByText("The file was deleted.", { exact: true })).toBeVisible();
await expectDeletedFileNotice(page);
await expect(filePane(page).getByText("Before", { exact: true })).toBeVisible();
await writeFile(filePath, "# After\n", "utf8");
await expect(filePane(page).getByText("After", { exact: true })).toBeVisible();
+52 -17
View File
@@ -1,5 +1,6 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import {
expandFolder,
@@ -55,10 +56,6 @@ async function openWorkspaceFile(page: Page, filename: string): Promise<void> {
await expectFileTabOpen(page, filename);
}
function filePanelSwitcher(page: Page) {
return page.getByTestId("file-panel-switcher").filter({ visible: true });
}
function htmlPreview(page: Page) {
return {
host: page.getByTestId("file-html-preview"),
@@ -66,13 +63,32 @@ function htmlPreview(page: Page) {
};
}
/** Switches the file tab to one of its panels from the bar's switcher. */
async function selectFilePanel(page: Page, title: string): Promise<void> {
const option = filePanelSwitcher(page).getByRole("button", { name: title, exact: true });
async function selectFileView(page: Page, view: "Preview" | "Source"): Promise<void> {
const option = page.getByTestId("file-panel-bar").getByRole("button", {
name: view,
exact: true,
});
await option.click();
await expect(option).toHaveAttribute("aria-selected", "true");
}
async function expectAbove(upper: Locator, lower: Locator): Promise<void> {
const [upperBox, lowerBox] = await Promise.all([upper.boundingBox(), lower.boundingBox()]);
expect(upperBox).not.toBeNull();
expect(lowerBox).not.toBeNull();
expect(upperBox!.y + upperBox!.height).toBeLessThanOrEqual(lowerBox!.y);
}
/** How many times the app has read the file's contents from the daemon. */
function fileReadCount(
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
fileName: string,
): number {
return gate
.getClientRequests("file_explorer_request")
.filter((request) => request.mode === "file" && String(request.path).endsWith(fileName)).length;
}
async function expectMarkdownImageLoaded(page: Page, name: string): Promise<void> {
const image = page.getByRole("button", { name, exact: true });
await expect(image).toBeVisible();
@@ -482,9 +498,9 @@ test.describe("CodeMirror workspace file editing", () => {
relativePath,
);
await expect(page.getByTestId("file-panel-bar")).not.toContainText("visuals.md");
const modeControl = filePanelSwitcher(page);
const modeControl = page.getByTestId("file-preview-mode");
await expect(modeControl).toBeVisible();
await selectFilePanel(page, "Source");
await selectFileView(page, "Source");
const editorHost = page.getByTestId("file-source-editor");
const content = editor(page);
@@ -546,7 +562,7 @@ test.describe("CodeMirror workspace file editing", () => {
);
await workspace.navigateTo();
await openWorkspaceFile(page, "notes.md");
await selectFilePanel(page, "Source");
await selectFileView(page, "Source");
const markdownScroller = page
.getByTestId("file-source-editor")
@@ -582,6 +598,11 @@ test.describe("CodeMirror workspace file editing", () => {
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await expect(page.getByLabel(/File size/)).toBeVisible();
await expect(page.getByLabel(/lines/)).toBeVisible();
await expect(page.getByTestId("file-preview-mode")).toHaveCount(0);
await expectAbove(
page.getByTestId("file-panel-bar").filter({ visible: true }),
page.getByTestId("file-source-editor").filter({ visible: true }),
);
await replaceEditorText(page, "const autosaved = 2;\n");
await expect(page.getByTestId("workspace-tab-modified-file_source.ts")).toBeVisible();
@@ -678,11 +699,12 @@ test.describe("CodeMirror workspace file editing", () => {
await expect(page.getByTestId("workspace-tab-modified-file_draft.ts")).toBeVisible();
});
test("refreshes Markdown and images while switching between Markdown and Source", async ({
test("refreshes Markdown and images while preserving Preview and Source behavior", async ({
page,
withWorkspace,
}) => {
test.setTimeout(90_000);
const gate = await installDaemonWebSocketGate(page);
const workspace = await withWorkspace({ prefix: "file-editing-preview-" });
const markdownPath = path.join(workspace.repoPath, "notes.md");
const imagePath = path.join(workspace.repoPath, "pixel.png");
@@ -692,17 +714,30 @@ test.describe("CodeMirror workspace file editing", () => {
await openWorkspaceFile(page, "notes.md");
const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
const toolbar = page.getByTestId("file-panel-bar").filter({ visible: true });
await expect(visibleFilePane.getByText("First heading", { exact: true })).toBeVisible();
await expect(filePanelSwitcher(page).getByRole("button", { name: "Markdown" })).toBeVisible();
await expect(page.getByRole("button", { name: "Preview", exact: true })).toBeVisible();
await expect(toolbar.getByLabel(/^File size/)).toBeVisible();
await expect(toolbar.getByLabel("2 lines")).toBeVisible();
await writeFile(markdownPath, "# Updated heading\n", "utf8");
await expect(visibleFilePane.getByText("Updated heading", { exact: true })).toBeVisible();
await selectFilePanel(page, "Source");
// Source shows the same read in the editor; the file is not read again.
const reads = fileReadCount(gate, "notes.md");
await selectFileView(page, "Source");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await expect(editor(page)).toContainText("# Updated heading");
await expect(toolbar.getByLabel("Line 1, column 1")).toBeVisible();
expect(fileReadCount(gate, "notes.md")).toBe(reads);
// Preview shows the edits made in Source.
await replaceEditorText(page, "# Saved from source\n");
await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n");
await selectFilePanel(page, "Markdown");
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeVisible();
await selectFileView(page, "Preview");
await expect(visibleFilePane.getByText("Saved from source", { exact: true })).toBeVisible();
await expect(toolbar.getByLabel("Line 1, column 1")).toHaveCount(0);
await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n");
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).not.toBeVisible();
await openWorkspaceFile(page, "pixel.png");
const image = visibleFilePane.locator("img");
@@ -784,10 +819,10 @@ test.describe("CodeMirror workspace file editing", () => {
);
await expect(preview.document.getByRole("heading", { name: "Updated plan" })).toBeVisible();
await selectFilePanel(page, "Source");
await selectFileView(page, "Source");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await expect(preview.host).toHaveCount(0);
await selectFilePanel(page, "HTML");
await selectFileView(page, "Preview");
await expect(preview.host).toBeVisible();
});
@@ -14,9 +14,8 @@ async function openMarkdownPreview(page: Page) {
await openFileFromExplorer(page, "README.md");
await expectFileTabOpen(page, "README.md");
await page
.getByTestId("file-panel-switcher")
.filter({ visible: true })
.getByRole("button", { name: "Markdown", exact: true })
.getByTestId("file-panel-bar")
.getByRole("button", { name: "Preview", exact: true })
.click();
await ensureWorkspaceAgentPaneVisible(page);
}
@@ -182,6 +182,7 @@ test("captures the compact import-session journey", async ({ page }, testInfo) =
await test.step("the selected row imports and opens the hydrated transcript", async () => {
await flow.importSession(scenario.importSessionId);
await flow.expectTranscript("Review the invoice migration", "The fixture transcript is ready.");
await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible();
await capture(page, testInfo, "08-mobile-agent-after-import.png");
});
@@ -95,7 +95,7 @@ test.describe("New workspace entry points", () => {
}
});
test("the New Workspace screen hides the host selector when there is only one host", async ({
test("the New Workspace screen shows the host selector with Add host when there is only one host", async ({
page,
}) => {
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-single-host-" });
@@ -120,7 +120,12 @@ test.describe("New workspace entry points", () => {
await expect(page.getByTestId("new-workspace-project-picker-trigger")).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("host-picker-trigger")).toHaveCount(0);
const hostTrigger = page.getByTestId("host-picker-trigger");
await expect(hostTrigger).toContainText("localhost", { timeout: 30_000 });
await hostTrigger.click();
await expect(page.getByTestId("new-workspace-host-picker-add-host")).toBeVisible();
await page.getByTestId("new-workspace-host-picker-add-host").click();
await expect(page).toHaveURL(/\/settings\/general\?addHost=/);
} finally {
await seeded.cleanup();
}
@@ -0,0 +1,100 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
delayBrowserAgentCreatedStatus,
openNewWorkspaceComposer,
submitNewWorkspacePrompt,
} from "../support/helpers/new-workspace";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
// The creation response carries the descriptor captured when the workspace was provisioned. With an
// initial agent it is sent only after the agent starts, so a pin made in between (by a plugin's
// workspace.created hook, another client, or the CLI) reaches the app first as a live update. The
// late response must not put the old, unpinned row back.
function pinnedRowTestId(workspaceId: string): string {
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
}
async function findCreatedWorkspaceId(seeded: SeededWorkspace): Promise<string> {
let createdId: string | undefined;
await expect
.poll(
async () => {
const { entries } = await seeded.client.fetchWorkspaces({
filter: { projectId: seeded.projectId },
});
createdId = entries.find((entry) => entry.id !== seeded.workspaceId)?.id;
return createdId;
},
{ timeout: 30_000 },
)
.toBeTruthy();
return createdId!;
}
// Samples every frame, so a row that leaves Pinned and comes back on a later update still counts.
async function watchPinnedRow(page: Page, testId: string): Promise<() => Promise<number>> {
await page.evaluate((rowTestId) => {
const state = { missingFrames: 0, stopped: false };
(window as unknown as { __pinnedRowWatch: typeof state }).__pinnedRowWatch = state;
const sample = () => {
if (state.stopped) return;
const section = document.querySelector('[data-testid="sidebar-pinned-section"]');
if (!section?.querySelector(`[data-testid="${rowTestId}"]`)) state.missingFrames += 1;
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
}, testId);
return () =>
page.evaluate(() => {
const state = (
window as unknown as { __pinnedRowWatch: { missingFrames: number; stopped: boolean } }
).__pinnedRowWatch;
state.stopped = true;
return state.missingFrames;
});
}
test.describe("New workspace live pin", () => {
test("keeps a pin made before the creation response arrives", async ({ page }) => {
test.setTimeout(120_000);
const creation = await delayBrowserAgentCreatedStatus(page);
const seeded = await seedWorkspace({ repoPrefix: "new-workspace-live-pin-" });
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, seeded);
await submitNewWorkspacePrompt(page, "Pin me while I start");
await creation.waitForCreateRequest();
// The response is held here, carrying the descriptor from before the pin.
await creation.waitForDelayedCreatedStatus();
const workspaceId = await findCreatedWorkspaceId(seeded);
const rowTestId = pinnedRowTestId(workspaceId);
await seeded.client.setWorkspacePinned(workspaceId, true);
const pinnedRow = page.getByTestId("sidebar-pinned-section").getByTestId(rowTestId);
await expect(pinnedRow).toBeVisible({ timeout: 10_000 });
const stopWatching = await watchPinnedRow(page, rowTestId);
creation.release();
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable({
timeout: 30_000,
});
// Leave time for the released response to be merged and rendered.
await page.waitForTimeout(1_000);
expect(await stopWatching()).toBe(0);
await expect(pinnedRow).toBeVisible();
} finally {
creation.release();
await seeded.cleanup();
}
});
});
@@ -1,15 +1,24 @@
import { verifyPluginToolTranslations } from "../support/helpers/plugin-translations";
import { test } from "../support/fixtures";
import { withButtonShowcase } from "../support/helpers/plugin-buttons";
test("plugin header and composer buttons share actions, menus, content, and updates", async ({
page,
}) => {
test.setTimeout(180_000);
await withButtonShowcase(page, async (buttons) => {
}, info) => {
test.setTimeout(240_000);
await withButtonShowcase(page, info, async (buttons) => {
await buttons.verifyObservationLifetime();
await buttons.openWideMenusAndPopovers();
await buttons.runAndUpdateActions();
await buttons.openCompactSheets();
await buttons.useCompactWorkspaceMenu();
await buttons.hideAndUnloadOpenButtons();
});
});
test("plugin tooltips and menu labels follow app language with a panel draft open", async ({
page,
}, info) => {
test.setTimeout(180_000);
await verifyPluginToolTranslations(page, info);
});
@@ -71,10 +71,6 @@ async function captureWideAndCompact(
await ready();
}
function filePanelSwitcher(page: Page) {
return page.getByTestId("file-panel-switcher").filter({ visible: true });
}
/** Turns on vim keybindings without discarding settings the test writes later. */
async function enableVimKeybindings(page: Page) {
await page.addInitScript(() => {
@@ -94,15 +90,29 @@ async function replaceMarkdown(page: Page, text: string) {
await page.getByRole("textbox", { name: "Rich Markdown editor", exact: true }).fill(text);
}
async function selectPanel(page: Page, title: string) {
await filePanelSwitcher(page).getByRole("button", { name: title, exact: true }).click();
async function openWithFromTabMenu(page: Page, name: string, option: string) {
await page.getByTestId(`workspace-tab-file_${name}`).first().click({ button: "right" });
await page.getByRole("menuitem", { name: "Open with…" }).hover();
await page.getByRole("menuitem", { name: option }).click();
}
async function expectPanels(page: Page, titles: string[]) {
for (const title of titles) {
await expect(
filePanelSwitcher(page).getByRole("button", { name: title, exact: true }),
).toBeInViewport({ ratio: 1 });
async function selectFileView(page: Page, view: "Preview" | "Source") {
const option = page
.getByTestId("file-preview-mode")
.filter({ visible: true })
.getByRole("button", { name: view, exact: true });
await option.click();
await expect(option).toHaveAttribute("aria-selected", "true");
}
async function rememberMarkdownViewAcrossReload(page: Page, view: "Preview" | "Source") {
const button = page.getByRole("button", { name: `Rich Markdown ${view}`, exact: true });
await button.click();
await page.reload();
if (view === "Source") {
await expectMarkdownEditor(page, "Edited by Rich Markdown");
} else {
await expect(page.getByRole("heading", { name: "Edited by Rich Markdown" })).toBeVisible();
}
}
@@ -126,9 +136,6 @@ async function expectMarkdownEditor(page: Page, text: string) {
await expect(editor).toBeVisible();
await expect(editor).toContainText(text);
await expect(editor).toBeInViewport({ ratio: 1 });
// Side by side on a wide screen; on a phone three panels do not fit, so the switcher is a menu.
await expect(filePanelSwitcher(page)).toContainText("Rich Markdown");
await expect(filePanelSwitcher(page)).toBeInViewport({ ratio: 1 });
}
test("directory examples mount the PDF frame and write Markdown through the file handle", async ({
@@ -197,7 +204,7 @@ test("directory examples mount the PDF frame and write Markdown through the file
await expect(
page.getByRole("radio", { name: "Rich Markdown — markdown-panel", exact: true }),
).toBeVisible();
await expect(page.getByRole("radio", { name: "Source — Paseo", exact: true })).toBeVisible();
await expect(page.getByRole("radio")).toHaveCount(2);
await captureWideAndCompact(page, testInfo, "markdown-example-picker", async () => {
// The previous layout's picker animates out (the sheet slides down, the dialog fades), so
// both pickers are mounted for a moment after a resize.
@@ -220,32 +227,26 @@ test("directory examples mount the PDF frame and write Markdown through the file
.poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8"))
.toBe("# Edited by Rich Markdown\n\nSaved by the panel.\n");
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeHidden();
await expectPanels(page, ["Rich Markdown", "Markdown", "Source"]);
await captureWideAndCompact(page, testInfo, "markdown-example-rich-markdown", async () => {
await expectMarkdownEditor(page, "Edited by Rich Markdown");
});
// Source is Paseo's editor; its cursor and vim mode are in its own status row.
await selectPanel(page, "Source");
await rememberMarkdownViewAcrossReload(page, "Preview");
await rememberMarkdownViewAcrossReload(page, "Source");
// "Open with…" switches to Paseo's Markdown panel; its Source view is the editor, with the
// cursor and vim mode in the panel's toolbar.
await openWithFromTabMenu(page, "notes.md", "Markdown — Paseo");
await expect(page.getByRole("heading", { name: "Edited by Rich Markdown" })).toBeVisible();
await selectFileView(page, "Source");
const source = page.getByTestId("file-source-editor").filter({ visible: true });
await expect(source.locator(".cm-content")).toContainText("Edited by Rich Markdown");
await source.locator(".cm-line").nth(2).click();
const bar = page.getByTestId("file-source-status").filter({ visible: true });
const bar = page.getByTestId("file-panel-bar").filter({ visible: true });
await expect(bar.getByText("NORMAL", { exact: true })).toBeVisible();
await expect(bar.getByText(/^Ln 3, Col \d+$/)).toBeVisible();
await capture(page, testInfo, "markdown-example-source-wide");
// The phone layout mounts the tab anew, so it opens the remembered panel; its menu offers Source.
await page.setViewportSize(COMPACT);
await expect(filePanelSwitcher(page)).toHaveText("Rich Markdown");
await filePanelSwitcher(page).click();
await page.getByRole("menuitem", { name: "Source", exact: true }).click();
await expect(filePanelSwitcher(page)).toHaveText("Source");
await source.locator(".cm-line").nth(2).click();
await expect(bar.getByText("NORMAL", { exact: true })).toBeInViewport();
await expect(bar.getByText(/^Ln 3, Col \d+$/)).toBeInViewport();
await capture(page, testInfo, "markdown-example-source-compact");
await page.setViewportSize(WIDE);
await selectPanel(page, "Rich Markdown");
await openWithFromTabMenu(page, "notes.md", "Rich Markdown — markdown-panel");
await writeFile(path.join(workspace.repoPath, "notes.md"), "# External update\n");
await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toHaveText(
"# External update",
@@ -266,7 +267,6 @@ test("directory examples mount the PDF frame and write Markdown through the file
await expect(page.getByText("Open .md files with", { exact: true })).toBeHidden();
await page.setViewportSize(COMPACT);
await expectMarkdownEditor(page, "Remembered panel");
await expect(filePanelSwitcher(page)).toHaveText("Rich Markdown");
} finally {
await client.removePlugin("pdf-panel");
await client.removePlugin("markdown-panel");
@@ -30,17 +30,26 @@ async function openWithFromTabMenu(page: Page, name: string, option: string) {
await page.getByRole("menuitem", { name: option }).click();
}
async function toggleAlwaysUseFromFileBar(page: Page) {
await page.getByTestId("file-panel-bar-menu").filter({ visible: true }).click();
await page.getByRole("menuitem", { name: "Always use for .md" }).click();
async function toggleAlwaysUseFromTabMenu(page: Page, name: string) {
await openWithFromTabMenu(page, name, "Always use for .md");
}
function filePanelSwitcher(page: Page) {
return page.getByTestId("file-panel-switcher").filter({ visible: true });
/** The built-in panel's toolbar control between its Preview and Source views. */
function viewControl(page: Page) {
return page.getByTestId("file-preview-mode").filter({ visible: true });
}
async function expectSwitcherPanels(page: Page, titles: string[]) {
await expect(filePanelSwitcher(page).getByRole("button")).toHaveText(titles);
async function selectFileView(page: Page, view: "Preview" | "Source") {
const option = viewControl(page).getByRole("button", { name: view, exact: true });
await option.click();
await expect(option).toHaveAttribute("aria-selected", "true");
}
async function expectNoOpenWith(page: Page, name: string) {
await page.getByTestId(`workspace-tab-file_${name}`).first().click({ button: "right" });
await expect(page.getByRole("menuitem", { name: "Close", exact: true })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "Open with…" })).toHaveCount(0);
await page.keyboard.press("Escape");
}
async function capture(page: Page, testInfo: TestInfo, name: string) {
@@ -49,7 +58,7 @@ async function capture(page: Page, testInfo: TestInfo, name: string) {
await testInfo.attach(name, { path: file, contentType: "image/png" });
}
test("opens Markdown with the built-in panel and Source beside it without asking", async ({
test("opens Markdown with the built-in panel and its Preview and Source without asking", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({
@@ -62,19 +71,20 @@ test("opens Markdown with the built-in panel and Source beside it without asking
await openFileExplorer(page);
await openMarkdown(page, "notes.md");
await expectBuiltinMarkdown(page, "notes heading");
await expectSwitcherPanels(page, ["Markdown", "Source"]);
await expect(viewControl(page).getByRole("button")).toHaveText(["Preview", "Source"]);
await expect(filePanelPicker(page)).toBeHidden();
await expectNoOpenWith(page, "notes.md");
await capture(page, testInfo, "file-panel-builtins-wide");
await filePanelSwitcher(page).getByRole("button", { name: "Source", exact: true }).click();
await selectFileView(page, "Source");
await expect(page.getByTestId("file-source-editor").filter({ visible: true })).toBeVisible();
await filePanelSwitcher(page).getByRole("button", { name: "Markdown", exact: true }).click();
await selectFileView(page, "Preview");
await expectBuiltinMarkdown(page, "notes heading");
await page.setViewportSize({ width: 390, height: 844 });
await expectBuiltinMarkdown(page, "notes heading");
await expect(
filePanelSwitcher(page).getByRole("button", { name: "Source", exact: true }),
viewControl(page).getByRole("button", { name: "Source", exact: true }),
).toBeInViewport({ ratio: 1 });
await expect(filePanelPicker(page)).toBeHidden();
await capture(page, testInfo, "file-panel-builtins-compact");
@@ -107,6 +117,7 @@ test("asks once which panel opens Markdown and remembers it when told to", async
await expect(filePanelPicker(page)).toBeVisible();
await expect(page.getByRole("radio", { name: BUILTIN_MARKDOWN_OPTION })).toBeChecked();
await expect(page.getByRole("radio", { name: PLUGIN_MARKDOWN_OPTION })).not.toBeChecked();
await expect(page.getByRole("radio")).toHaveCount(2);
await expectBuiltinMarkdown(page, "first heading");
await capture(page, testInfo, "file-panel-picker-wide");
@@ -118,8 +129,8 @@ test("asks once which panel opens Markdown and remembers it when told to", async
await expectPluginMarkdown(page, "second.md");
await expect(filePanelPicker(page)).toBeHidden();
// Forgetting from the file bar keeps the tab on the plugin.
await toggleAlwaysUseFromFileBar(page);
// Forgetting from the tab menu keeps the tab on the plugin.
await toggleAlwaysUseFromTabMenu(page, "second.md");
await expect.poll(() => storedFilePanelAssociations(page)).toEqual({});
await expectPluginMarkdown(page, "second.md");
@@ -130,10 +141,14 @@ test("asks once which panel opens Markdown and remembers it when told to", async
await expectPluginMarkdown(page, "third.md");
expect(await storedFilePanelAssociations(page)).toEqual({});
// "Open with…" switches the open tab back to the built-in in place.
// "Open with…" switches the open tab back to the built-in in place, with its own views.
await openWithFromTabMenu(page, "third.md", BUILTIN_MARKDOWN_OPTION);
await expectBuiltinMarkdown(page, "third heading");
await expectFileTabOpen(page, "third.md");
await selectFileView(page, "Source");
await expect(page.getByTestId("file-source-editor").filter({ visible: true })).toBeVisible();
await selectFileView(page, "Preview");
await expectBuiltinMarkdown(page, "third heading");
// Dismissing keeps the built-in; reopening the tab after a reload asks again.
await openMarkdown(page, "fourth.md");
@@ -7,7 +7,6 @@ import {
BUILTIN_MARKDOWN_OPTION,
MARKDOWN_PANEL_ID,
PLUGIN_MARKDOWN_OPTION,
SOURCE_OPTION,
expectPluginMarkdown,
filePanelPicker,
installMarkdownPanelPlugin,
@@ -18,6 +17,7 @@ import { seedWorkspace } from "../support/helpers/seed-client";
import { openSettingsSection } from "../support/helpers/settings";
const ASK_EVERY_TIME = "Ask every time";
const SOURCE_OPTION = "Source — Paseo";
function markdownRow(page: Page) {
return page.getByTestId("file-panel-settings-row-ext:md");
@@ -69,7 +69,7 @@ test("Settings > Files chooses which panel opens .md and asks again on request",
await markdownDropdown(page, ASK_EVERY_TIME).click();
await expect(page.getByRole("menuitem", { name: BUILTIN_MARKDOWN_OPTION })).toBeVisible();
await expect(page.getByRole("menuitem", { name: PLUGIN_MARKDOWN_OPTION })).toBeVisible();
await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeVisible();
await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toHaveCount(0);
await expectMenuOpen(page);
await capture(page, testInfo, "file-panel-settings-wide-menu");
await page.getByRole("menuitem", { name: PLUGIN_MARKDOWN_OPTION }).click();
@@ -94,11 +94,11 @@ test("Settings > Files chooses which panel opens .md and asks again on request",
await expect(askAgain(page)).toBeInViewport({ ratio: 1 });
await capture(page, testInfo, "file-panel-settings-compact");
await markdownDropdown(page, PLUGIN_MARKDOWN_OPTION).click();
await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeVisible();
await expect(page.getByRole("menuitem", { name: BUILTIN_MARKDOWN_OPTION })).toBeVisible();
await expectMenuOpen(page);
await capture(page, testInfo, "file-panel-settings-compact-menu");
await page.keyboard.press("Escape");
await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeHidden();
await expect(page.getByRole("menuitem", { name: BUILTIN_MARKDOWN_OPTION })).toBeHidden();
// Ask again forgets the choice; the next open asks.
await askAgain(page).click();
@@ -91,9 +91,13 @@ async function associateMarkdownWithPlugin(page: Page) {
async function editMarkdownThroughPlugin(page: Page) {
await page.getByRole("button", { name: "Edit Markdown through plugin", exact: true }).click();
}
async function selectSource(page: Page) {
/** Opens the tab with Paseo's Markdown panel from "Open with…" and shows its Source view. */
async function openBuiltinMarkdownSource(page: Page, name: string) {
await page.getByTestId(`workspace-tab-file_${name}`).first().click({ button: "right" });
await page.getByRole("menuitem", { name: "Open with…" }).hover();
await page.getByRole("menuitem", { name: "Markdown — Paseo" }).click();
await page
.getByTestId("file-panel-switcher")
.getByTestId("file-preview-mode")
.filter({ visible: true })
.getByRole("button", { name: "Source", exact: true })
.click();
@@ -210,7 +214,7 @@ test("directory plugins render PDF bytes and write Markdown through the file han
await page.getByRole("menuitem", { name: "Close", exact: true }).click();
await expect.poll(() => closePrompt).toContain("discard");
await selectSource(page);
await openBuiltinMarkdownSource(page, "notes.md");
await expect(
page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content"),
).toHaveText("# Changed outside");
@@ -1,3 +1,4 @@
import { verifyPluginTranslations } from "../support/helpers/plugin-translations";
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
@@ -9,7 +10,7 @@ import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspac
const PLUGIN_ID = "plugin-host-ui-e2e";
const PLUGIN_SOURCE = `import { usePaseo } from "@getpaseo/plugin/client";
import { Icon, Modal, useToast } from "@getpaseo/plugin/client/ui";
import { Icon, Modal, useToast, Markdown } from "@getpaseo/plugin/client/ui";
import { useQueryClient } from "@tanstack/react-query";
import React, { useState } from "react";
import { Pressable, Text, View } from "react-native";
@@ -26,6 +27,9 @@ function ModalBody({ onSaved }) {
return <View>
<Text>Plugin modal contexts ready</Text>
<Markdown>{"## Plugin report"}</Markdown>
<Markdown>{"Checks **passed**. [Documentation](https://paseo.sh/docs/plugins)"}</Markdown>
<Markdown>{""}</Markdown>
<Pressable accessibilityRole="button" onPress={save}>
<Text>Save issue</Text>
</Pressable>
@@ -91,10 +95,20 @@ async function openPluginModal(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open plugin modal", exact: true }).click();
}
async function expectPluginMarkdown(page: Page): Promise<void> {
await expect(page.getByRole("heading", { name: "Plugin report" })).toBeVisible();
await expect(page.getByText("Checks passed.", { exact: false })).toBeVisible();
await expect(page.getByRole("link", { name: "Documentation", exact: true })).toHaveAttribute(
"href",
"https://paseo.sh/docs/plugins",
);
}
async function expectCenteredPluginDialog(page: Page): Promise<void> {
const dialog = page.getByRole("dialog");
await expect(dialog.getByText("Edit plugin issue", { exact: true })).toBeVisible();
await expect(dialog.getByText("Plugin modal contexts ready", { exact: true })).toBeVisible();
await expectPluginMarkdown(page);
}
async function closeCenteredPluginDialog(page: Page): Promise<void> {
@@ -106,6 +120,7 @@ async function closeCenteredPluginDialog(page: Page): Promise<void> {
async function expectCompactPluginSheet(page: Page): Promise<void> {
await expect(page.getByText("Edit plugin issue", { exact: true })).toBeVisible();
await expect(page.getByText("Plugin modal contexts ready", { exact: true })).toBeVisible();
await expectPluginMarkdown(page);
}
async function savePluginIssue(page: Page): Promise<void> {
@@ -166,3 +181,10 @@ async function playPluginAudio(page: Page, button: string, result: string) {
await page.getByRole("button", { name: button, exact: true }).click();
await expect(page.getByText(result, { exact: true })).toBeVisible();
}
test("plugin content and host labels follow app language without losing an open draft", async ({
page,
}, info) => {
test.setTimeout(180_000);
await verifyPluginTranslations(page, info);
});
@@ -43,6 +43,8 @@ import {
} from "../support/helpers/settings";
import { getServerId } from "../support/helpers/server-id";
import { expectAppRoute } from "../support/helpers/route-assertions";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
async function openWorkspace(
page: import("@playwright/test").Page,
@@ -53,6 +55,74 @@ async function openWorkspace(
}
test.describe("Settings sidebar navigation", () => {
test("opening Settings closes the composer model selector until it is opened again", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "settings-model-selector-" });
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
const selector = page.getByTestId("combined-model-selector").filter({ visible: true });
const popover = page.getByTestId("combobox-desktop-container");
await selector.click();
await expect(popover).toBeVisible();
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+,`);
await expect(page).toHaveURL(/\/settings\/general$/);
await page.screenshot({ path: testInfo.outputPath("settings-after-model-selector.png") });
await expect(popover).toHaveCount(0);
await clickSettingsBackToWorkspace(page);
await expect(selector).toBeVisible();
await expect(popover).toHaveCount(0);
await selector.click();
await expect(popover).toBeVisible();
await page.keyboard.press("Escape");
await expect(popover).toHaveCount(0);
});
test("opening Settings closes an existing chat's model selector until it is opened again", async ({
page,
}, testInfo) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "settings-existing-model-",
title: "Settings model selector",
});
try {
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
const selector = page.getByTestId("combined-model-selector").filter({ visible: true });
const popover = page.getByTestId("combobox-desktop-container");
await selector.click();
await expect(popover).toBeVisible();
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+,`);
await expect(page).toHaveURL(/\/settings\/general$/);
await page.screenshot({
path: testInfo.outputPath("settings-after-existing-model-selector.png"),
});
await expect(popover).toHaveCount(0);
await clickSettingsBackToWorkspace(page);
await expect(selector).toBeVisible();
await expect(popover).toHaveCount(0);
await selector.click();
await expect(popover).toBeVisible();
await page.keyboard.press("Escape");
await expect(popover).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
@@ -147,6 +217,42 @@ test.describe("Settings sidebar navigation", () => {
test.describe("Settings — compact master-detail", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("the compact tab switcher reopens after Settings hides its workspace", async ({ page }) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "settings-tab-switcher-",
title: "Compact tab switcher",
});
try {
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
const switcher = page.getByTestId("workspace-tab-switcher-trigger").filter({ visible: true });
const newAgent = page.getByTestId("workspace-tab-switcher-new-agent");
await switcher.click();
await expect(newAgent).toBeInViewport();
// Navigate without dismissing the sheet through its backdrop first.
await page
.getByTestId("menu-button")
.filter({ visible: true })
.first()
.dispatchEvent("click");
await page.getByTestId("sidebar-settings").first().dispatchEvent("click");
await expectAppRoute(page, buildSettingsRoute());
await expect(newAgent).toHaveCount(0);
await closeCompactSettings(page);
await expect(switcher).toBeVisible();
await expect(newAgent).toHaveCount(0);
await switcher.click();
await expect(newAgent).toBeInViewport();
} finally {
await workspace.cleanup();
}
});
test("opens compact app and host details and returns through the settings list", async ({
page,
}) => {
@@ -10,6 +10,16 @@ async function pressSettingsToggleShortcut(page: import("@playwright/test").Page
await page.keyboard.press(`${modifier}+Comma`);
}
async function openComposerTab(page: import("@playwright/test").Page, agentId: string) {
await page
.getByTestId(`workspace-tab-agent_${agentId}`)
.filter({ visible: true })
.first()
.click();
await expectAgentTabActive(page, agentId);
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
}
interface ComposerFrame {
at: number;
visible: boolean;
@@ -27,7 +37,9 @@ async function startComposerFrameCapture(page: import("@playwright/test").Page)
const sample = () => {
if (capture.stopped) return;
const root = document.querySelector<HTMLElement>('[data-testid="message-input-root"]');
const root = Array.from(
document.querySelectorAll<HTMLElement>('[data-testid="message-input-root"]'),
).find((candidate) => candidate.offsetParent !== null);
const input = root?.querySelector<HTMLTextAreaElement>("textarea");
if (root && input) {
const style = getComputedStyle(input);
@@ -178,6 +190,8 @@ test.describe("Settings toggle tab regression", () => {
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
await waitForTabBar(page);
await openComposerTab(page, firstAgent.id);
await openComposerTab(page, secondAgent.id);
const input = page.getByRole("textbox", { name: "Message agent..." });
await input.fill("Keep this short draft in the composer");
const root = page.getByTestId("message-input-root").filter({ visible: true }).first();
@@ -194,6 +208,7 @@ test.describe("Settings toggle tab regression", () => {
const frames = await stopComposerFrameCapture(page);
const visibleFrames = frames.filter((frame) => frame.visible);
expect(visibleFrames.length, "Capture must observe the visible composer").toBeGreaterThan(0);
const tallestIndex = visibleFrames.reduce(
(index, frame, candidateIndex) =>
frame.rootHeight > visibleFrames[index].rootHeight ? candidateIndex : index,
@@ -25,6 +25,7 @@ test("project and workspace kebabs open action sheets on compact layouts", async
await page.getByTestId("sidebar-display-preferences-menu").click();
await expect(page.getByRole("menuitem")).toHaveText([
/^Grouping/,
/^Sort/,
/^Title/,
"Show",
"Project",
@@ -1,10 +1,12 @@
import { expect, test } from "../support/fixtures";
import type { Page } from "@playwright/test";
import { openAddProjectFlow, addProjectFlow } from "../support/helpers/add-project-flow";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
import {
claudeAndCodexReports,
expectPinnedUsage,
openCompactSidebar,
expectCompactSidebarOpen,
} from "../support/helpers/usage-sidebar-item";
import { gotoAppShell } from "../support/helpers/app";
import { SHOWCASE_PLUGIN_ID, installSidebarPlugins } from "../support/helpers/plugin-sidebar-items";
@@ -31,6 +33,28 @@ import {
setSidebarNavItemVisible,
} from "../support/helpers/sidebar-nav-settings";
async function showDeveloperToast(page: Page): Promise<void> {
await page.evaluate(() => {
document.getElementById("error-toast")?.remove();
const toast = document.createElement("div");
toast.id = "error-toast";
toast.style.cssText = "position:fixed;inset:0;z-index:2147483647;background:white";
// Expo renders its unnamed dismiss button in an open shadow root.
const button = document.createElement("button");
button.style.cssText = "width:24px;height:24px";
button.addEventListener("click", () => toast.remove());
toast.attachShadow({ mode: "open" }).appendChild(button);
document.body.appendChild(toast);
});
}
test("developer toasts leave concurrent sidebar layout checks uninterrupted", async ({ page }) => {
await gotoAppShell(page);
await showDeveloperToast(page);
await expectSidebarOrder(page, ["new-workspace", "history", "search", "schedules"]);
await expect(page.locator("#error-toast")).toBeHidden();
});
test("fixed footer line keeps its five icons, Help and Settings at the end", async ({ page }) => {
test.setTimeout(120_000);
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
@@ -67,12 +91,13 @@ test("fixed footer line keeps its five icons, Help and Settings at the end", asy
await openAddProjectFlow(page);
await page.keyboard.press("Escape");
await expect(addProjectFlow(page)).toBeHidden();
await expectCompactSidebarOpen(page);
await page.setViewportSize({ width: 1440, height: 900 });
await openSidebarNavSettings(page);
await setFooterItemVisible(page, "usage", true);
await leaveSettings(page);
await page.setViewportSize({ width: 390, height: 844 });
await openCompactSidebar(page);
await expectCompactSidebarOpen(page);
await expectFooterSeparator(page, true);
await expectFooterIconRow(page);
await footerScreenshot(page, "footer-compact-with-rows");
@@ -32,6 +32,7 @@ test.describe("Sidebar project filter", () => {
await page.getByTestId("sidebar-display-preferences-menu").click();
await expect(page.getByRole("menuitem")).toHaveText([
/^Grouping/,
/^Sort/,
/^Title/,
"Show",
"Project",
@@ -1,9 +1,10 @@
import type { Locator } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
import { getServerId } from "../support/helpers/server-id";
import { seedWorkspace } from "../support/helpers/seed-client";
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
async function rowTestIds(rows: Locator) {
@@ -57,7 +58,19 @@ async function quickDragFirstRowAfterSecond(
await expect.poll(() => rowTestIds(rows)).toEqual([before[1], before[0]]);
}
test("projects, workspaces, and pinned chats reorder with an immediate mouse drag", async ({
async function dragWithoutReordering(rows: Locator): Promise<void> {
const before = await rowTestIds(rows);
const source = await visibleBoundingBox(rows.nth(0));
const target = await visibleBoundingBox(rows.nth(1));
const page = rows.page();
await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2);
await page.mouse.down();
await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 5 });
await page.mouse.up();
await expect.poll(() => rowTestIds(rows)).toEqual(before);
}
test("projects and pinned chats reorder while ordinary workspaces stay newest first", async ({
page,
}) => {
const firstProject = await seedWorkspace({ repoPrefix: "sidebar-reorder-first-" });
@@ -87,12 +100,13 @@ test("projects, workspaces, and pinned chats reorder with an immediate mouse dra
);
const firstWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${firstProject.workspaceId}`;
const secondWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${secondWorkspace.workspace.id}`;
await quickDragFirstRowAfterSecond(
page.locator(
`[data-testid="${firstWorkspaceTestId}"], [data-testid="${secondWorkspaceTestId}"]`,
),
pressWorkspaceRow,
const ordinaryRows = page.locator(
`[data-testid="${firstWorkspaceTestId}"], [data-testid="${secondWorkspaceTestId}"]`,
);
await expect
.poll(() => rowTestIds(ordinaryRows))
.toEqual([secondWorkspaceTestId, firstWorkspaceTestId]);
await dragWithoutReordering(ordinaryRows);
await firstProject.client.setWorkspacePinned(firstProject.workspaceId, true);
await secondProject.client.setWorkspacePinned(secondProject.workspaceId, true);
@@ -108,3 +122,300 @@ test("projects, workspaces, and pinned chats reorder with an immediate mouse dra
await secondProject.cleanup();
}
});
async function selectWorkspaceSort(page: Page, name: "Manual" | "Created" | "Last prompt") {
await page.getByLabel("Display preferences", { exact: true }).click();
await page.getByRole("menuitem", { name: /^Sort/ }).click();
await page.getByRole("menuitem", { name, exact: true }).click();
}
async function reconnectSidebar(page: Page): Promise<void> {
await page.reload();
await waitForSidebarHydration(page);
}
async function expectNoWorkspaceSortChoice(page: Page): Promise<void> {
await page.getByLabel("Display preferences", { exact: true }).click();
await expect(page.getByRole("menuitem", { name: /^Sort/ })).toHaveCount(0);
}
async function promptWorkspace(seed: Awaited<ReturnType<typeof seedWorkspace>>, agentId: string) {
await seed.client.sendAgentMessage(agentId, "sidebar ordering check");
expect((await seed.client.waitForFinish(agentId, 30_000)).status).toBe("idle");
}
test("Created stays stable, Last prompt follows human submissions and survives tab archive", async ({
page,
}) => {
test.setTimeout(120_000);
const seed = await seedWorkspace({ repoPrefix: "sidebar-sort-" });
try {
const newer = await seed.client.createWorkspace({
source: { kind: "directory", path: seed.repoPath, projectId: seed.projectId },
title: "Newer workspace",
});
if (!newer.workspace) throw new Error(newer.error ?? "Workspace creation failed");
const agent = await seed.client.createAgent({
provider: "mock",
cwd: seed.repoPath,
workspaceId: seed.workspaceId,
modeId: "load-test",
model: "e2e-fast-stream",
title: "Ordering agent",
});
const newerAgent = await seed.client.createAgent({
provider: "mock",
cwd: seed.repoPath,
workspaceId: newer.workspace.id,
modeId: "load-test",
model: "e2e-fast-stream",
title: "Newer agent",
});
await gotoAppShell(page);
await waitForSidebarHydration(page);
const oldId = `sidebar-workspace-row-${getServerId()}:${seed.workspaceId}`;
const newId = `sidebar-workspace-row-${getServerId()}:${newer.workspace.id}`;
const rows = page.locator(`[data-testid="${oldId}"], [data-testid="${newId}"]`);
await expect.poll(() => rowTestIds(rows)).toEqual([newId, oldId]);
await promptWorkspace(seed, agent.id);
await expect.poll(() => rowTestIds(rows)).toEqual([newId, oldId]);
await selectWorkspaceSort(page, "Last prompt");
await expect.poll(() => rowTestIds(rows)).toEqual([oldId, newId]);
await promptWorkspace(seed, newerAgent.id);
await expect.poll(() => rowTestIds(rows)).toEqual([newId, oldId]);
await promptWorkspace(seed, agent.id);
await expect.poll(() => rowTestIds(rows)).toEqual([oldId, newId]);
await seed.client.archiveAgent(agent.id);
await expect.poll(() => rowTestIds(rows)).toEqual([oldId, newId]);
await reconnectSidebar(page);
await expect.poll(() => rowTestIds(rows)).toEqual([oldId, newId]);
await selectWorkspaceSort(page, "Created");
await expect.poll(() => rowTestIds(rows)).toEqual([newId, oldId]);
await selectSidebarStatusGrouping(page);
await expectNoWorkspaceSortChoice(page);
} finally {
await seed.cleanup();
}
});
async function seedCompactProject() {
const seed = await seedWorkspace({
repoPrefix: "sidebar-visibility-",
title: "Oldest workspace",
});
const ids = [seed.workspaceId];
for (let index = 2; index <= 12; index += 1) {
const created = await seed.client.createWorkspace({
source: { kind: "directory", path: seed.repoPath, projectId: seed.projectId },
title: `Workspace ${index}`,
});
if (!created.workspace) throw new Error(created.error ?? "Workspace creation failed");
ids.unshift(created.workspace.id);
}
const agent = await seed.client.createAgent({
provider: "mock",
model: "e2e-fast-stream",
modeId: "load-test",
cwd: seed.repoPath,
workspaceId: seed.workspaceId,
title: "Overflow agent",
});
return { seed, ids, agent };
}
async function expectWorkspaceOrder(rows: Locator, ids: string[]) {
await expect
.poll(() => rowTestIds(rows))
.toEqual(ids.map((id) => `sidebar-workspace-row-${getServerId()}:${id}`));
}
async function revealWorkspaces(list: Locator, action: "Show more" | "Show less") {
await list.getByLabel(action, { exact: true }).click();
}
async function selectProjectGrouping(page: Page) {
await page.getByLabel("Display preferences", { exact: true }).click();
await page.getByRole("menuitem", { name: /^Group/ }).click();
await page.getByTestId("sidebar-grouping-project").click();
}
type CompactProject = Awaited<ReturnType<typeof seedCompactProject>>;
function compactProjectRows(page: Page, seed: CompactProject["seed"]) {
const list = page.getByTestId(`sidebar-workspace-list-${seed.projectKey}`);
return {
rows: list.locator('[data-testid^="sidebar-workspace-row-"]'),
group: list.locator(".."),
project: page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(seed.projectKey)}`),
};
}
async function expectActiveOverflowTransitions(page: Page, { seed, ids, agent }: CompactProject) {
const { rows } = compactProjectRows(page, seed);
await expectWorkspaceOrder(rows, ids.slice(0, 7));
await seed.client.sendAgentMessage(agent.id, "show active overflow");
await expectWorkspaceOrder(rows, [...ids.slice(0, 7), ids[11]]);
await seed.client.waitForFinish(agent.id, 15_000);
await expectWorkspaceOrder(rows, [...ids.slice(0, 7), ids[11]]);
await seed.client.clearWorkspaceAttention(seed.workspaceId);
await expectWorkspaceOrder(rows, ids.slice(0, 7));
}
async function expectRevealSurvivesSortingAndCollapse(
page: Page,
{ seed, ids, agent }: CompactProject,
) {
const { rows, group, project } = compactProjectRows(page, seed);
const retainedRows = await rows.elementHandles();
await revealWorkspaces(group, "Show more");
await expectWorkspaceOrder(rows, ids);
for (const [index, retained] of retainedRows.entries()) {
expect(
await rows.nth(index).evaluate((current, previous) => current === previous, retained),
).toBe(true);
}
await project.click();
await expect(rows).toHaveCount(0);
await project.click();
await expectWorkspaceOrder(rows, ids);
await seed.client.sendAgentMessage(agent.id, "expanded overflow");
await seed.client.waitForFinish(agent.id, 15_000);
await seed.client.clearWorkspaceAttention(seed.workspaceId);
await expectWorkspaceOrder(rows, ids);
await selectWorkspaceSort(page, "Last prompt");
await expectWorkspaceOrder(rows, [ids[11], ...ids.slice(0, 11)]);
const revealedRow = await rows.nth(8).elementHandle();
await revealWorkspaces(group, "Show less");
await expectWorkspaceOrder(rows, [ids[11], ...ids.slice(0, 6)]);
await selectWorkspaceSort(page, "Created");
await expectWorkspaceOrder(rows, ids.slice(0, 7));
expect(await revealedRow!.evaluate((row) => row.isConnected)).toBe(false);
await reconnectSidebar(page);
await expectWorkspaceOrder(rows, ids.slice(0, 7));
}
async function expectStatusBucketsRetainActiveOverflow(
page: Page,
{ seed, ids, agent }: CompactProject,
) {
const { rows } = compactProjectRows(page, seed);
await selectSidebarStatusGrouping(page);
const done = page.getByTestId("sidebar-status-group-rows-done");
const doneRows = done.locator('[data-testid^="sidebar-workspace-row-"]');
await expect(doneRows).toHaveCount(7);
await revealWorkspaces(done, "Show more");
await expect(doneRows).toHaveCount(12);
await seed.client.sendAgentMessage(agent.id, "status overflow");
await seed.client.waitForFinish(agent.id, 15_000);
await expect(
page
.getByTestId("sidebar-status-group-rows-attention")
.getByText("Oldest workspace", { exact: true }),
).toBeVisible();
await expect(doneRows).toHaveCount(11);
await seed.client.clearWorkspaceAttention(seed.workspaceId);
await expect(doneRows).toHaveCount(12);
await revealWorkspaces(done, "Show less");
await expect(doneRows).toHaveCount(7);
const activeAgents: Array<{ id: string }> = [];
for (const workspaceId of ids.slice(0, 8)) {
activeAgents.push(
await seed.client.createAgent({
provider: "mock",
model: "e2e-fast-stream",
modeId: "load-test",
cwd: seed.repoPath,
workspaceId,
title: "Active overflow agent",
}),
);
}
await Promise.all(
activeAgents.map((item) => seed.client.sendAgentMessage(item.id, "active bucket")),
);
await Promise.all(activeAgents.map((item) => seed.client.waitForFinish(item.id, 15_000)));
const attention = page.getByTestId("sidebar-status-group-rows-attention");
await expect(attention.locator('[data-testid^="sidebar-workspace-row-"]')).toHaveCount(8);
await expect(attention.getByLabel("Show more", { exact: true })).toHaveCount(0);
for (const workspaceId of ids.slice(0, 8)) await seed.client.clearWorkspaceAttention(workspaceId);
await expect(doneRows).toHaveCount(7);
await selectProjectGrouping(page);
await expectWorkspaceOrder(rows, ids.slice(0, 7));
}
test("compact groups retain active overflow and reveal the current ordered rows", async ({
page,
}) => {
test.setTimeout(120_000);
const fixture = await seedCompactProject();
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await test.step("Active overflow appears without intervening Done rows", () =>
expectActiveOverflowTransitions(page, fixture));
await test.step("Reveal follows current sort and survives collapse", () =>
expectRevealSurvivesSortingAndCollapse(page, fixture));
await test.step("Status reveals Done rows and keeps every active row", () =>
expectStatusBucketsRetainActiveOverflow(page, fixture));
} finally {
await fixture.seed.cleanup();
}
});
async function restorePreBetaOrder(page: Page, projectKey: string, workspaceKeys: string[]) {
await page.evaluate(
({ projectKey: scope, workspaceKeys: order }) => {
localStorage.setItem(
"sidebar-project-workspace-order",
JSON.stringify({
version: 1,
state: {
projectOrder: [scope],
pinnedWorkspaceOrder: [],
workspaceOrderByProject: { [scope]: order },
},
}),
);
},
{ projectKey, workspaceKeys },
);
await reconnectSidebar(page);
}
test("Manual restores pre-beta order and keeps drag order across sorting and reloads", async ({
page,
}) => {
const seed = await seedWorkspace({ repoPrefix: "sidebar-manual-" });
try {
const second = await seed.client.createWorkspace({
source: { kind: "directory", path: seed.repoPath, projectId: seed.projectId },
title: "New manual workspace",
});
if (!second.workspace) throw new Error(second.error ?? "Workspace creation failed");
const keys = [seed.workspaceId, second.workspace.id].map((id) => `${getServerId()}:${id}`);
const ids = keys.map((key) => `sidebar-workspace-row-${key}`);
const rows = page.locator(ids.map((id) => `[data-testid="${id}"]`).join(", "));
await gotoAppShell(page);
await waitForSidebarHydration(page);
await restorePreBetaOrder(page, projectEquivalenceViewKey(seed.projectKey), keys);
await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]);
await selectWorkspaceSort(page, "Manual");
await expect.poll(() => rowTestIds(rows)).toEqual(ids);
await selectWorkspaceSort(page, "Created");
await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]);
await selectWorkspaceSort(page, "Last prompt");
await selectWorkspaceSort(page, "Manual");
await expect.poll(() => rowTestIds(rows)).toEqual(ids);
await quickDragFirstRowAfterSecond(rows, pressWorkspaceRow);
await reconnectSidebar(page);
await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]);
await quickDragFirstRowAfterSecond(rows, pressWorkspaceRow);
await selectWorkspaceSort(page, "Created");
await reconnectSidebar(page);
await selectWorkspaceSort(page, "Manual");
await expect.poll(() => rowTestIds(rows)).toEqual(ids);
} finally {
await seed.cleanup();
}
});
@@ -0,0 +1,87 @@
import type { Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { randomUUID } from "node:crypto";
import { metroTest as test, expect } from "../support/fixtures";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import { seedWorkspace } from "../support/helpers/seed-client";
import { buildSeededHost } from "../support/helpers/daemon-registry";
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
test("published old daemon keeps workspace navigation and gates sorting", async ({ page }) => {
test.setTimeout(180_000);
const daemon = await startIsolatedHostDaemon(`srv_sort_old_${randomUUID()}`, {
publishedVersion: "0.8.0",
});
try {
const project = await seedWorkspace({ repoPrefix: "sidebar-old-host-", port: daemon.port });
try {
const second = await project.client.createWorkspace({
source: { kind: "directory", path: project.repoPath, projectId: project.projectId },
title: "Second old workspace",
});
expect(second.workspace).toBeTruthy();
await openPublishedHost(page, daemon, project.workspaceId);
await openWorkspace(page, daemon.serverId, second.workspace!.id);
await expectSortingRequiresHostUpdate(page);
await selectSidebarStatusGrouping(page);
await expectStatusHasNoSorting(page);
} finally {
await project.cleanup();
}
} finally {
await daemon.close();
}
});
async function openPublishedHost(
page: Page,
daemon: { serverId: string; port: number },
workspaceId: string,
) {
const host = buildSeededHost({
serverId: daemon.serverId,
endpoint: `127.0.0.1:${daemon.port}`,
nowIso: new Date().toISOString(),
});
await page.addInitScript((seededHost) => {
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
localStorage.setItem(
"sidebar-view",
JSON.stringify({ version: 8, state: { groupMode: "project", workspaceSort: "lastPrompt" } }),
);
}, host);
await page.goto(buildHostWorkspaceRoute(daemon.serverId, workspaceId));
}
async function openWorkspace(page: Page, serverId: string, workspaceId: string) {
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`);
await expect(row).toBeVisible();
await row.click();
await expect(page).toHaveURL(new RegExp(workspaceId));
}
async function expectSortingRequiresHostUpdate(page: Page) {
await page.getByLabel("Display preferences", { exact: true }).click();
await page.getByRole("menuitem", { name: /^Sort/ }).click();
await expect(
page.getByRole("menuitem", { name: "Update hosts to sort workspaces" }),
).toBeVisible();
await expect(
page.getByRole("menuitem", { name: "Update hosts to sort workspaces" }),
).toBeDisabled();
await expect(page.getByRole("menuitem", { name: "Created", exact: true })).toHaveCount(0);
await expect(page.getByRole("menuitem", { name: "Last prompt", exact: true })).toHaveCount(0);
await page.getByRole("menuitem", { name: "Manual", exact: true }).click();
await page.getByLabel("Display preferences", { exact: true }).click();
await expect(page.getByRole("menuitem", { name: /^Sort/ })).toContainText("Manual");
await page.keyboard.press("Escape");
}
async function expectStatusHasNoSorting(page: Page) {
await page.getByLabel("Display preferences", { exact: true }).click();
await expect(page.getByRole("menuitem", { name: "Update hosts to sort workspaces" })).toHaveCount(
0,
);
await expect(page.getByRole("menuitem", { name: /^Sort/ })).toHaveCount(0);
}
@@ -6,6 +6,7 @@ import {
seedMockAgentWorkspace,
type MockAgentWorkspace,
} from "../support/helpers/mock-agent";
import { expectOpenTabCount } from "../support/helpers/sheet";
import { getServerId } from "../support/helpers/server-id";
import { closeMobileAgentSidebar, openMobileAgentSidebar } from "../support/helpers/sidebar";
@@ -179,6 +180,7 @@ async function addFinishedAgent(workspace: MockAgentWorkspace) {
async function openCompactWorkspace(page: Page, workspace: MockAgentWorkspace) {
await page.setViewportSize({ width: 390, height: 844 });
await openAgentRoute(page, workspace);
await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible();
await openMobileAgentSidebar(page);
}
@@ -227,3 +229,45 @@ test("manual unread survives leaving the current workspace on compact layout", a
await markUnreadThenResumeChat(page, workspaces.subject.workspaceId);
await completeTurnAndLeaveOnCompact(page, workspaces);
});
async function reopenMarkedWorkspaceAfterBackgroundTurn(page: Page, workspace: MockAgentWorkspace) {
const route = page.url();
// End this app session before the turn, so its cached transcript cannot contain the new prompt.
await page.goto("about:blank");
await workspace.client.sendAgentMessage(
workspace.agentId,
"A turn completed while the app was closed.",
);
await workspace.client.waitForFinish(workspace.agentId, 20_000);
// Reopen the ordinary workspace route, without an agent open intent that would repair focus.
await page.goto(route);
await expect(page.getByRole("button", { name: /Switch tabs/ })).toBeVisible();
}
async function expectRestoredCompactChat(page: Page) {
await expectOpenTabCount(page, 1);
await expect(
page.getByText("A turn completed while the app was closed.", { exact: true }),
).toBeVisible();
}
test("reopening a marked unread workspace restores only its agent tab and current history on compact layout", async ({
page,
workspaces,
}) => {
test.setTimeout(120_000);
await test.step("open a finished chat with one main tab", async () => {
await openCompactWorkspace(page, workspaces.subject);
await closeMobileAgentSidebar(page);
await expectOpenTabCount(page, 1);
await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible();
});
await test.step("mark the current workspace unread, then close and reopen the app", async () => {
await openMobileAgentSidebar(page);
await markAsUnread(page, workspaces.subject.workspaceId);
await reopenMarkedWorkspaceAfterBackgroundTurn(page, workspaces.subject);
});
await test.step("restore the main tab without Explorer entries and load its latest history", async () => {
await expectRestoredCompactChat(page);
});
});
@@ -1,10 +1,19 @@
import { buildSeededHost } from "../support/helpers/daemon-registry";
import { submitMessage } from "../support/helpers/composer";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openSubagentsTrack, seedParentWithSubagent } from "../support/helpers/subagents";
import {
delegateFleetThroughMcp,
expectManagedSubagentArchived,
openSubagentsTrack,
seedParentWithSubagent,
} from "../support/helpers/subagents";
test.use({ e2eInjectPaseoTools: true });
const SETTINGS_KEY = "@paseo:app-settings";
@@ -147,3 +156,189 @@ test("a preferred chat file link still opens beside its right-pane agent", async
await workspace.cleanup();
}
});
test("MCP delegates to self and B and opens both qualified children in ordinary conversations", async ({
page,
}) => {
const fleet = await delegateFleetThroughMcp();
let appSockets = 0;
let routeOpens = 0;
const activeDaemonSockets = new Set<unknown>();
const observationRequests: Record<string, number> = {};
page.on("websocket", (socket) => {
appSockets++;
if (new URL(socket.url()).pathname === "/ws") {
activeDaemonSockets.add(socket);
socket.on("close", () => activeDaemonSockets.delete(socket));
}
socket.on("framesent", ({ payload }) => {
if (typeof payload !== "string" || !payload.startsWith("{")) return;
if (String(payload).includes('"hosts.route.open.request"')) routeOpens++;
const outer = JSON.parse(String(payload));
const request = outer.type === "session" ? outer.message : null;
if (
request?.type &&
(request.subscribe !== undefined ||
request.observe === true ||
request.type.endsWith("set_subscription.request"))
)
observationRequests[request.type] = (observationRequests[request.type] ?? 0) + 1;
});
});
try {
await openAgentRoute(page, {
workspaceId: fleet.workspace.workspaceId,
agentId: fleet.parent.id,
});
await openSubagentsTrack(page);
await expect(page.getByTestId(`subagents-track-row-${fleet.local.agentId}`)).toContainText(
"MCP child on A",
);
await expect(page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`)).toContainText(
"MCP child on B",
);
await expect(page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`)).toContainText(
"Fleet B",
);
await page.getByTestId(`subagents-track-row-${fleet.local.agentId}`).click();
await expect(page.getByTestId(`workspace-tab-agent_${fleet.local.agentId}`)).toBeVisible();
await submitMessage(page, "LOCAL_UI_TARGET");
await expect(page.getByText("LOCAL_UI_TARGET", { exact: true })).toBeVisible();
expect((await fleet.b.fetchAgent(fleet.local.agentId))?.agent.lastUserMessageAt).toBeNull();
await page.getByTestId(`workspace-tab-agent_${fleet.parent.id}`).click();
await openSubagentsTrack(page);
await page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`).click();
await expect(page).toHaveURL((url) =>
url.pathname.includes(`/h/${fleet.remote.serverId}/workspace/`),
);
await submitMessage(page, "REMOTE_UI_TARGET");
await expect(page.getByText("REMOTE_UI_TARGET", { exact: true })).toBeVisible();
expect((await fleet.a.fetchAgent(fleet.remote.agentId))?.agent.lastUserMessageAt).toBeNull();
await fleet.expectCompletion(fleet.local);
await fleet.expectCompletion(fleet.remote);
await openAgentRoute(page, {
workspaceId: fleet.workspace.workspaceId,
agentId: fleet.parent.id,
});
await expect(
page.getByRole("button", { name: /Agent finished.*MCP child on A/ }),
).toBeVisible();
await expect(
page.getByRole("button", { name: /Agent finished.*MCP child on B/ }),
).toBeVisible();
const owners = await page.evaluate(() => {
const runtime = Reflect.get(globalThis, "__paseoHostRuntimeStore") as {
getHosts(): Array<{ serverId: string }>;
getSnapshot(
serverId: string,
): { connectionStatus: string; clientGeneration: number } | null;
};
return runtime.getHosts().map((host) => {
const snapshot = runtime.getSnapshot(host.serverId);
return {
serverId: host.serverId,
connectionStatus: snapshot?.connectionStatus,
clientGeneration: snapshot?.clientGeneration,
};
});
});
console.log("Fleet ordinary route counts", {
appSockets,
activeDaemonSockets: activeDaemonSockets.size,
routeOpens,
owners,
observationRequests,
});
} finally {
await fleet.close();
}
});
async function openFleetParentWithDirectB(
page: Page,
fleet: Awaited<ReturnType<typeof delegateFleetThroughMcp>>,
) {
await page.goto("/", { waitUntil: "commit" });
const host = buildSeededHost({
serverId: fleet.remote.serverId,
endpoint: `127.0.0.1:${fleet.destination.port}`,
label: "Fleet B",
nowIso: new Date().toISOString(),
});
await page.evaluate((extraHost) => {
localStorage.setItem("@paseo:e2e-extra-hosts", JSON.stringify([extraHost]));
}, host);
await openAgentRoute(page, {
workspaceId: fleet.workspace.workspaceId,
agentId: fleet.parent.id,
});
await openSubagentsTrack(page);
await expect(page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`)).toContainText(
"MCP child on B",
);
}
async function openFleetChild(
page: Page,
fleet: Awaited<ReturnType<typeof delegateFleetThroughMcp>>,
) {
await page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`).click();
await expect(page).toHaveURL(
(url) => url.pathname === `/h/${fleet.remote.serverId}/workspace/${fleet.remote.workspaceId}`,
);
}
test("a directly connected B opens and archives its MCP child from A's track", async ({ page }) => {
const fleet = await delegateFleetThroughMcp();
try {
await openFleetParentWithDirectB(page, fleet);
await openFleetChild(page, fleet);
await expect(page.getByRole("button", { name: "MCP child on B", exact: true })).toBeVisible();
await submitMessage(page, "DIRECT_B_TARGET");
await expect(page.getByText("DIRECT_B_TARGET", { exact: true })).toBeVisible();
expect((await fleet.a.fetchAgent(fleet.remote.agentId))?.agent.lastUserMessageAt).toBeNull();
await openFleetParentWithDirectB(page, fleet);
await page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`).hover();
page.once("dialog", (dialog) => {
void dialog.accept();
});
await page.getByRole("button", { name: "Archive MCP child on B", exact: true }).click();
await expectManagedSubagentArchived({ client: fleet.b }, fleet.remote.agentId);
await expect(page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`)).toHaveCount(0);
expect((await fleet.a.fetchAgent(fleet.remote.agentId))?.agent.archivedAt).toBeNull();
await expect(page.getByText(/already connected through another route/)).toHaveCount(0);
} finally {
await fleet.close();
}
});
test("an unavailable B child opens its cached workspace with existing reconnecting behavior", async ({
page,
}) => {
const fleet = await delegateFleetThroughMcp();
try {
await openFleetParentWithDirectB(page, fleet);
await fleet.destination.close();
await expect
.poll(() =>
page.evaluate((serverId) => {
const runtime = Reflect.get(globalThis, "__paseoHostRuntimeStore") as {
getSnapshot(id: string): { serverId: string; connectionStatus: string } | null;
};
return runtime.getSnapshot(serverId);
}, fleet.remote.serverId),
)
.toMatchObject({
serverId: fleet.remote.serverId,
connectionStatus: expect.stringMatching(/^(offline|connecting|error)$/),
});
await openFleetChild(page, fleet);
await expect(page.getByTestId("workspace-header-title").filter({ visible: true })).toHaveText(
"B workspace",
);
await expect(page.getByRole("button", { name: "MCP child on B", exact: true })).toBeVisible();
await expect(page.getByText(/already connected through another route/)).toHaveCount(0);
} finally {
await fleet.close();
}
});
@@ -1,6 +1,10 @@
import { createHash } from "node:crypto";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import type { Page } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { holdFileUploadResponse } from "../support/helpers/file-upload-gate";
import { installLocalDesktopHost } from "../support/helpers/local-desktop-host";
import { TerminalE2EHarness } from "../support/helpers/terminal-dsl";
import { getTerminalBufferText, waitForTerminalContent } from "../support/helpers/terminal-perf";
@@ -26,12 +30,130 @@ process.stdin.on("data", (chunk) => {
setTimeout(finish, 700);
`;
// A TUI stand-in: turns on bracketed paste, reads every quoted path in each
// bracketed paste, and echoes typed lines, so the PTY output proves what reached
// the terminal and where the file was read from.
const FILE_RECEIVER_SCRIPT = String.raw`
const fs = require("node:fs");
const crypto = require("node:crypto");
let buffer = "";
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdout.write("\x1b[?2004hFILE_RECEIVER_READY\r\n");
process.stdin.on("data", (chunk) => {
buffer += chunk.toString();
for (;;) {
const paste = /\x1b\[200~([^]*?)\x1b\[201~/.exec(buffer);
if (!paste) break;
for (const [, path] of paste[1].matchAll(/'([^']*)'/g)) {
const digest = crypto.createHash("sha256").update(fs.readFileSync(path)).digest("hex");
const location = /[\\/]uploads[\\/]upload_/.test(path) ? "upload" : "local";
process.stdout.write("FILE_READ:" + digest + ":" + location + "\r\n");
}
buffer = buffer.slice(0, paste.index) + buffer.slice(paste.index + paste[0].length);
}
if (buffer.includes("\x03")) {
process.stdout.write("INTERRUPTED\r\n");
buffer = buffer.replaceAll("\x03", "");
}
const enter = buffer.indexOf("\r");
if (enter >= 0 && !buffer.includes("'")) {
process.stdout.write("TYPED:" + buffer.slice(0, enter) + "\r\n");
buffer = buffer.slice(enter + 1);
}
});
`;
function pngBytes(seed: number): Buffer {
return Buffer.concat([Buffer.from("iVBORw0KGgo=", "base64"), Buffer.from([seed])]);
}
function fileRead(bytes: Buffer, location: "upload" | "local"): string {
const digest = createHash("sha256").update(bytes).digest("hex");
return `FILE_READ:${digest}:${location}`;
}
// Inactive terminal tabs retain their surfaces, and another page opening a terminal in
// the same workspace adds a tab here too. Only the selected tab's surface is visible.
function terminalSurface(page: Page) {
return page.getByTestId("terminal-surface").filter({ visible: true });
}
function terminalInput(page: Page) {
return terminalSurface(page).locator(".xterm-helper-textarea");
}
async function pasteImage(page: Page, bytes: Buffer): Promise<void> {
await terminalInput(page).evaluate(
(element, data) => {
(element as HTMLTextAreaElement).focus();
const clipboardData = new DataTransfer();
clipboardData.items.add(new File([new Uint8Array(data)], "image.png", { type: "image/png" }));
element.dispatchEvent(
new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true }),
);
},
[...bytes],
);
}
interface DroppedFile {
name: string;
type: string;
bytes: Buffer;
}
async function dragTransfer(page: Page, files: DroppedFile[]) {
return await page.evaluateHandle(
(entries) => {
const dataTransfer = new DataTransfer();
for (const entry of entries) {
dataTransfer.items.add(
new File([new Uint8Array(entry.bytes)], entry.name, { type: entry.type }),
);
}
return dataTransfer;
},
files.map((file) => ({ ...file, bytes: [...file.bytes] })),
);
}
async function dragFilesOver(page: Page, files: DroppedFile[]): Promise<void> {
const dataTransfer = await dragTransfer(page, files);
await terminalInput(page).dispatchEvent("dragenter", { dataTransfer });
await terminalInput(page).dispatchEvent("dragover", { dataTransfer });
await dataTransfer.dispose();
}
async function dropFiles(page: Page, files: DroppedFile[]): Promise<void> {
const dataTransfer = await dragTransfer(page, files);
await terminalInput(page).dispatchEvent("drop", { dataTransfer });
await dataTransfer.dispose();
}
/** The shared "Drop files here" overlay of the zone around the terminal. */
function terminalDropOverlay(page: Page) {
return terminalSurface(page)
.locator("xpath=ancestor::*[.//*[@data-testid='file-drop-backdrop']][1]")
.getByTestId("file-drop-backdrop");
}
/**
* Makes the app treat the e2e daemon as the desktop app's own daemon, so dropped
* files resolve to paths inside `directory` instead of uploading.
*/
async function typeLine(page: Page, text: string): Promise<void> {
await terminalInput(page).pressSequentially(text);
await terminalInput(page).press("Enter");
}
test.describe("Terminal protocol queries", () => {
let harness: TerminalE2EHarness;
test.beforeAll(async () => {
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-protocol-query-" });
await writeFile(path.join(harness.tempRepo.path, "osc11-capture.cjs"), OSC11_CAPTURE_SCRIPT);
await writeFile(path.join(harness.tempRepo.path, "file-receiver.cjs"), FILE_RECEIVER_SCRIPT);
});
test.afterAll(async () => {
@@ -58,4 +180,156 @@ test.describe("Terminal protocol queries", () => {
await harness.killTerminal(terminalInstance.id);
}
});
async function openFileReceiver(page: Page, name: string) {
const terminal = await harness.createTerminal({
name,
command: process.execPath,
args: ["file-receiver.cjs"],
});
await harness.openTerminal(page, { terminalId: terminal.id });
await expectTerminalOutput(terminal.id, "FILE_RECEIVER_READY");
return terminal;
}
// Reads the PTY itself, so output from one pane cannot satisfy another's check.
async function terminalOutput(terminalId: string): Promise<string> {
const capture = await harness.client.captureTerminal(terminalId);
return capture.lines.join("").replace(/\s/g, "");
}
async function expectTerminalOutput(terminalId: string, text: string): Promise<void> {
await expect.poll(() => terminalOutput(terminalId), { timeout: 10_000 }).toContain(text);
}
test("pastes images from two panes into their own terminals as uploaded host files", async ({
page,
}) => {
const secondPage = await page.context().newPage();
const first = await openFileReceiver(page, "image-paste-a");
const second = await openFileReceiver(secondPage, "image-paste-b");
try {
await Promise.all([pasteImage(page, pngBytes(1)), pasteImage(secondPage, pngBytes(2))]);
await expectTerminalOutput(first.id, fileRead(pngBytes(1), "upload"));
await expectTerminalOutput(second.id, fileRead(pngBytes(2), "upload"));
expect(await terminalOutput(first.id)).not.toContain(fileRead(pngBytes(2), "upload"));
expect(await terminalOutput(second.id)).not.toContain(fileRead(pngBytes(1), "upload"));
} finally {
await secondPage.close();
await harness.killTerminal(first.id);
await harness.killTerminal(second.id);
}
});
test("uploads dropped files of any type when the host is another machine", async ({ page }) => {
const terminal = await openFileReceiver(page, "file-drop-remote");
const files = [
{ name: "shot.png", type: "image/png", bytes: pngBytes(3) },
{ name: "notes.txt", type: "text/plain", bytes: Buffer.from("remote notes") },
];
try {
await dragFilesOver(page, files);
await expect(terminalDropOverlay(page)).toHaveCSS("opacity", "1");
await expect(terminalDropOverlay(page)).toHaveText("Drop files here");
await dropFiles(page, files);
await expect(terminalDropOverlay(page)).toHaveCSS("opacity", "0");
await expectTerminalOutput(terminal.id, fileRead(pngBytes(3), "upload"));
await expectTerminalOutput(terminal.id, fileRead(Buffer.from("remote notes"), "upload"));
} finally {
await harness.killTerminal(terminal.id);
}
});
test("pastes dropped files' own paths when the host is this machine", async ({ page }) => {
const files = [
{ name: "local-shot.png", type: "image/png", bytes: pngBytes(9) },
{ name: "local-notes.txt", type: "text/plain", bytes: Buffer.from("local notes") },
];
for (const file of files) {
await writeFile(path.join(harness.tempRepo.path, file.name), file.bytes);
}
await installLocalDesktopHost(page, harness.tempRepo.path);
const terminal = await openFileReceiver(page, "file-drop-local");
try {
await dropFiles(page, files);
await expectTerminalOutput(terminal.id, fileRead(pngBytes(9), "local"));
await expectTerminalOutput(terminal.id, fileRead(Buffer.from("local notes"), "local"));
expect(await terminalOutput(terminal.id)).not.toContain(":upload");
await expect(page.getByTestId("terminal-file-uploading")).toHaveCount(0);
} finally {
await harness.killTerminal(terminal.id);
}
});
test("lands image pastes in gesture order while typing stays live", async ({ page }) => {
const upload = await holdFileUploadResponse(page);
const terminal = await openFileReceiver(page, "image-order");
try {
await pasteImage(page, pngBytes(4));
await upload.waitUntilHeld();
await pasteImage(page, pngBytes(5));
await expect(page.getByTestId("terminal-file-uploading")).toHaveText("Uploading file…");
await typeLine(page, "describe");
await expectTerminalOutput(terminal.id, "TYPED:describe");
upload.release();
await expectTerminalOutput(terminal.id, fileRead(pngBytes(5), "upload"));
const output = await terminalOutput(terminal.id);
expect(output.indexOf(fileRead(pngBytes(4), "upload"))).toBeGreaterThanOrEqual(0);
expect(output.indexOf(fileRead(pngBytes(4), "upload"))).toBeLessThan(
output.indexOf(fileRead(pngBytes(5), "upload")),
);
await expect(page.getByTestId("terminal-file-uploading")).toHaveCount(0);
await expect(page.getByTestId("app-toast-message")).toHaveText("File uploaded");
} finally {
await harness.killTerminal(terminal.id);
}
});
test("Ctrl+C abandons an image paste that is still uploading", async ({ page }) => {
const upload = await holdFileUploadResponse(page);
const terminal = await openFileReceiver(page, "image-cancel");
try {
await pasteImage(page, pngBytes(6));
await upload.waitUntilHeld();
await terminalInput(page).press("Control+c");
await expectTerminalOutput(terminal.id, "INTERRUPTED");
await expect(page.getByTestId("terminal-file-uploading")).toHaveCount(0);
upload.release();
await pasteImage(page, pngBytes(7));
await expectTerminalOutput(terminal.id, fileRead(pngBytes(7), "upload"));
expect(await terminalOutput(terminal.id)).not.toContain(fileRead(pngBytes(6), "upload"));
} finally {
await harness.killTerminal(terminal.id);
}
});
test("a disconnect during upload reports one failure and input keeps working", async ({
page,
}) => {
const upload = await holdFileUploadResponse(page);
const terminal = await openFileReceiver(page, "image-disconnect");
try {
await pasteImage(page, pngBytes(8));
await upload.waitUntilHeld();
await upload.disconnect();
await expect(page.getByTestId("app-toast-message")).toHaveText(
"Failed to paste file: Host is not connected",
);
await typeLine(page, "after");
await expectTerminalOutput(terminal.id, "TYPED:after");
expect(await terminalOutput(terminal.id)).not.toContain(fileRead(pngBytes(8), "upload"));
} finally {
await harness.killTerminal(terminal.id);
}
});
});
@@ -404,7 +404,7 @@ async function fetchWorkspaceStatuses(
}
test.describe("Workspace model restart regressions", () => {
test("browser-created same-cwd workspace preserves restarted agent status and migrated tab ownership", async ({
test("browser-created same-cwd workspace preserves repaired restart status and migrated tab ownership", async ({
page,
baseURL,
}) => {
@@ -423,9 +423,13 @@ test.describe("Workspace model restart regressions", () => {
.toMatchObject({
id: LEGACY_AGENT_ID,
workspaceId: seeded.workspaceA,
status: "running",
status: "closed",
});
await expect
.poll(() => fetchWorkspaceStatuses(client, [seeded.workspaceA, seeded.workspaceB]))
.toEqual({ [seeded.workspaceA]: "done", [seeded.workspaceB]: "done" });
await page.goto(buildHostWorkspaceRoute(serverId, seeded.workspaceA));
await waitForSidebarHydration(page);
await expectWorkspaceRowDoesNotShowIndicator(page, {
@@ -472,15 +476,11 @@ test.describe("Workspace model restart regressions", () => {
]),
)
.toMatchObject({
[seeded.workspaceA]: "done",
[seeded.workspaceB]: "done",
[createdWorkspaceId]: "done",
});
// The restarted provider session may settle while the browser creates the sibling. Its
// initial running status is asserted above; this phase verifies that ownership never moves.
const workspaceStatuses = await fetchWorkspaceStatuses(client, [seeded.workspaceA]);
expect(["running", "done"]).toContain(workspaceStatuses[seeded.workspaceA]);
await expectWorkspaceRowDoesNotShowIndicator(page, {
serverId,
workspaceId: seeded.workspaceB,
@@ -11,6 +11,7 @@ import {
expectWorkspaceTabVisible,
openWorkspaceWithAgents,
} from "../support/helpers/archive-tab";
import { expectAgentAbsentFromWorkspaceLayout } from "../support/helpers/workspace-layout";
import { clickNewChat } from "../support/helpers/launcher";
import { expectComposerVisible } from "../support/helpers/composer";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
@@ -93,10 +94,20 @@ async function closeFirstVisibleDraftTab(page: Page): Promise<void> {
await closeButton.first().click();
}
async function openWorkspaceTabUrl(page: Page, route: string): Promise<void> {
await page.evaluate((url) => {
window.history.pushState(null, "", url);
window.dispatchEvent(new PopStateEvent("popstate"));
}, route);
await expectAppRoute(page, route.split("?")[0]);
}
test.describe("Workspace navigation regression", () => {
test.describe.configure({ timeout: 240_000 });
test("opens a notification's workspace on a different offline host", async ({ page }) => {
test("keeps an offline notification on the agent route until the host connects", async ({
page,
}) => {
const target = {
serverId: "notification-offline-host",
workspaceId: "notification-workspace",
@@ -123,14 +134,33 @@ test.describe("Workspace navigation regression", () => {
);
}, target);
await expectAppRoute(
page,
buildHostWorkspaceOpenRoute(target.serverId, target.workspaceId, `agent:${target.agentId}`),
{ timeout: 30_000 },
);
await expect(page.getByText("Connecting", { exact: true })).toBeVisible();
await expect(page.getByText("Notification Host", { exact: true })).toBeVisible();
await expect(page.getByText("Add a project", { exact: true })).toHaveCount(0);
await expectAppRoute(page, buildHostAgentDetailRoute(target.serverId, target.agentId), {
timeout: 30_000,
});
await expect(page.getByTestId("agent-route-waiting-for-host")).toBeVisible();
await expect(page.getByText(/Notification Host/).first()).toBeVisible();
});
test("cold agent targets hydrate their workspace without opening the compact sidebar", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "notification-cold-compact-" });
try {
const agent = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Cold notification agent",
});
await page.setViewportSize({ width: 390, height: 844 });
await gotoAppShell(page);
await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id));
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), workspace.workspaceId), {
timeout: 30_000,
});
await expectComposerVisible(page);
} finally {
await workspace.cleanup();
}
});
test("shows the New launcher after returning from settings and closing the last agent draft tab", async ({
@@ -321,7 +351,7 @@ test.describe("Workspace navigation regression", () => {
}
});
test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({
test("a stale notification workspace preserves retained workspace selection and tabs", async ({
page,
}) => {
const serverId = getServerId();
@@ -435,7 +465,7 @@ test.describe("Workspace navigation regression", () => {
}),
);
},
{ agentId: secondAgent.id, serverId, workspaceId: secondWorkspace.workspaceId },
{ agentId: secondAgent.id, serverId, workspaceId: firstWorkspace.workspaceId },
);
await waitForWorkspaceTabsVisible(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
@@ -463,6 +493,22 @@ test.describe("Workspace navigation regression", () => {
await expect(secondDeckEntry).toBeHidden();
await expectWorkspaceDeckEntryCount(page, 2);
await expectAgentAbsentFromWorkspaceLayout(page, {
serverId,
workspaceId: firstWorkspace.workspaceId,
agentId: secondAgent.id,
});
await openWorkspaceTabUrl(
page,
buildHostWorkspaceOpenRoute(serverId, firstWorkspace.workspaceId, `agent:${firstAgent.id}`),
);
await expectWorkspaceTabVisible(page, firstAgent.id);
await expectAgentAbsentFromWorkspaceLayout(page, {
serverId,
workspaceId: secondWorkspace.workspaceId,
agentId: firstAgent.id,
});
await page.reload();
await waitForSidebarHydration(page);
await waitForWorkspaceTabsVisible(page);
@@ -3,6 +3,7 @@ import { test, expect } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { revealSidebarProjectWorkspaces } from "../support/helpers/sidebar";
import {
expectSidebarWorkspaceSelected,
switchWorkspaceViaSidebar,
@@ -46,6 +47,7 @@ test("previous and next traverse every visible workspace while digit shortcuts s
}
await page.setViewportSize({ width: 1440, height: 1100 });
await gotoAppShell(page);
await revealSidebarProjectWorkspaces(page, project.projectKey);
const order = await readVisibleWorkspaceOrder(page);
await test.step("next crosses the ninth workspace and previous returns to it", async () => {
@@ -68,7 +70,25 @@ test("previous and next traverse every visible workspace while digit shortcuts s
workspaceId: order[8],
});
});
await test.step("hidden Done rows remain shortcut targets without expanding the project", async () => {
await expectHiddenWorkspaceShortcuts(page, order);
});
} finally {
await project.cleanup();
}
});
async function expectHiddenWorkspaceShortcuts(page: Page, order: string[]) {
await page.getByLabel("Show less", { exact: true }).click();
const rows = page.getByRole("button", { name: /^Workspace \d{2}$/ });
await expect(rows).toHaveCount(7);
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] });
await page.keyboard.press("Alt+9");
await expect(page).toHaveURL(new RegExp(`/workspace/${order[8]}$`));
await expect(page.getByTestId(`sidebar-workspace-row-${getServerId()}:${order[8]}`)).toHaveCount(
0,
);
await page.keyboard.press("Alt+]");
await expect(page).toHaveURL(new RegExp(`/workspace/${order[9]}$`));
await expect(rows).toHaveCount(7);
}
@@ -2,6 +2,7 @@ import { test, expect } from "../support/fixtures";
import { createTempGitRepo } from "../support/helpers/workspace";
import {
connectWorkspaceSetupClient,
setupCommandOutput,
createWorkspaceThroughDaemon,
openWorkspaceScriptsMenu,
seedProjectForWorkspaceSetup,
@@ -33,7 +34,9 @@ test("scripts menu resizes when a service row grows after launch", async ({ page
await seedProjectForWorkspaceSetup(client, repo.path);
const completed = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"),
(payload) =>
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
@@ -18,6 +18,7 @@ import {
} from "../support/helpers/terminal-perf";
import {
connectWorkspaceSetupClient,
setupCommandOutput,
createWorkspaceThroughDaemon,
expectSetupPanel,
openHomeWithProject,
@@ -92,7 +93,8 @@ test.describe("Workspace setup streaming", () => {
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
@@ -170,16 +172,19 @@ test.describe("Workspace setup streaming", () => {
await seedProjectForWorkspaceSetup(client, repo.path);
const initialRunning = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "running" && payload.detail.log === "",
(payload) => payload.status === "running" && payload.detail.commands.length === 0,
);
const runningWithOutput = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "running" && payload.detail.log.includes("starting setup"),
(payload) =>
payload.status === "running" &&
setupCommandOutput(payload.detail).includes("starting setup"),
);
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
);
await createWorkspaceThroughDaemon(client, {
@@ -192,8 +197,8 @@ test.describe("Workspace setup streaming", () => {
const completedPayload = await completed;
expect(initialPayload.detail.log).toBe("");
expect(runningPayload.detail.log).toContain("starting setup");
expect(completedPayload.detail.log).toContain("setup complete");
expect(setupCommandOutput(runningPayload.detail)).toContain("starting setup");
expect(setupCommandOutput(completedPayload.detail)).toContain("setup complete");
expect(completedPayload.error).toBeNull();
} finally {
await client.close();
@@ -215,7 +220,9 @@ test.describe("Workspace setup streaming", () => {
await seedProjectForWorkspaceSetup(client, repo.path);
const failed = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "failed" && payload.detail.log.includes("setup failed"),
(payload) =>
payload.status === "failed" &&
setupCommandOutput(payload.detail).includes("setup failed"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
@@ -224,8 +231,8 @@ test.describe("Workspace setup streaming", () => {
});
const failedPayload = await failed;
expect(failedPayload.detail.log).toContain("starting setup");
expect(failedPayload.detail.log).toContain("setup failed");
expect(setupCommandOutput(failedPayload.detail)).toContain("starting setup");
expect(setupCommandOutput(failedPayload.detail)).toContain("setup failed");
expect(failedPayload.error).toMatch(/failed/i);
await openHomeWithProject(page, repo.path);
@@ -331,7 +338,8 @@ test.describe("Workspace setup streaming", () => {
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
@@ -377,7 +385,8 @@ test.describe("Workspace setup streaming", () => {
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
);
const result = await client.createPaseoWorktree({
@@ -2,7 +2,8 @@ context platform=ios timeout=60000 retries=0
env APP_ID=sh.paseo.debug
# Start with the sidebar closed. WORKSPACE_ROW is the testID of a visible,
# unselected workspace row. Hold past drag arming (180ms), below menu opening (450ms).
# unselected PINNED workspace row. Ordinary workspace rows are not draggable.
# Hold past drag arming (180ms), then release.
open "${APP_ID}"
press "label=\"Open menu\""
wait "id=\"${WORKSPACE_ROW}\"" 10000
@@ -19,6 +19,13 @@ MESSAGE=$'keyboard invariant line one\nline two\nline three\nline four'
LONG_MESSAGE="$(node -e 'process.stdout.write(Array.from({ length: 180 }, (_, index) => `line${index + 1}`).join(" "))')"
BLANK_LINE_DRAFT="$(node -e 'process.stdout.write("\n".repeat(22) + "ddjdj")')"
AGENT_TITLE="Keyboard dismiss QA $(date +%s)"
# `overlays` runs only each host's autocomplete, model selector, attachment menu, and
# forge picker checks; `all` also runs the chat baseline and the full host scenario.
SCENARIO="${PASEO_COMPOSER_KEYBOARD_SCENARIO:-all}"
if [[ "${SCENARIO}" != all && "${SCENARIO}" != overlays ]]; then
echo "PASEO_COMPOSER_KEYBOARD_SCENARIO must be all or overlays" >&2
exit 1
fi
if [[ -z "${SERVER_ID}" ]]; then
if [[ ! -f "${DAEMON_HOME}/server-id" ]]; then
@@ -173,8 +180,10 @@ sleep 3
# Create the fixture while the client is connected so its directory replica observes the insert.
ad wait 'id="message-input-root"' 90000
# The compact header names the workspace, not the agent, so the chat waits below find
# the fixture by its prompt bubble.
run_json="$(env -u PASEO_CALLER_AGENT_ID -u PASEO_AGENT_ID -u PASEO_WORKSPACE_ID \
npm run --silent cli -- run "Exercise the composer keyboard invariant flow" \
npm run --silent cli -- run "${AGENT_TITLE}" \
--background \
--title "${AGENT_TITLE}" \
--provider mock \
@@ -206,238 +215,241 @@ clear_input
ad keyboard dismiss || true
ad wait 'label="Message, @files, /commands"' 10000
snapshot_json "${ARTIFACTS_DIR}/baseline.json"
capture_screen "${ARTIFACTS_DIR}/baseline.png"
read -r input_x input_y baseline_input_height < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "Message, @files, /commands"
)
read -r model_x model_y _ < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "combined-model-selector"
)
read -r attach_x attach_y _ < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "message-input-attach-button"
)
read -r _ header_y _ < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "workspace-tab-switcher-trigger"
)
# Chat-only invariants, then the long-draft checks on New workspace.
run_chat_baseline() {
snapshot_json "${ARTIFACTS_DIR}/baseline.json"
capture_screen "${ARTIFACTS_DIR}/baseline.png"
read -r input_x input_y baseline_input_height < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "Message, @files, /commands"
)
read -r model_x model_y _ < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "combined-model-selector"
)
read -r attach_x attach_y _ < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "message-input-attach-button"
)
read -r _ header_y _ < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "workspace-tab-switcher-trigger"
)
adb shell input keyevent HOME
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
sleep 1
open_gboard "${input_x}" "${input_y}"
clear_focused_input_with_adb
keyboard_shift="$(read_keyboard_shift)"
adb shell input keyevent 76
sleep 1
capture_screen "${ARTIFACTS_DIR}/command-popover.png"
ad wait 'id="composer-autocomplete-popover"' 10000
ad wait 'text="/exit"' 10000
model_open_y="$((model_y - keyboard_shift))"
adb shell input tap "${model_x}" "${model_open_y}"
sleep 1
capture_screen "${ARTIFACTS_DIR}/model-selector-open.png"
adb shell ime set "${HELPER_IME}" >/dev/null
ad wait 'id="compact-provider-list"' 10000
ad press 'label="Close"' --settle
adb shell input tap "${input_x}" "${input_y}"
clear_focused_input_with_adb
ad wait 'editable=true' 10000
adb shell input keyevent HOME
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
sleep 1
open_gboard "${input_x}" "${input_y}"
clear_focused_input_with_adb
keyboard_shift="$(read_keyboard_shift)"
adb shell input keyevent 76
sleep 1
capture_screen "${ARTIFACTS_DIR}/command-popover.png"
ad wait 'id="composer-autocomplete-popover"' 10000
ad wait 'text="/exit"' 10000
model_open_y="$((model_y - keyboard_shift))"
adb shell input tap "${model_x}" "${model_open_y}"
sleep 1
capture_screen "${ARTIFACTS_DIR}/model-selector-open.png"
adb shell ime set "${HELPER_IME}" >/dev/null
ad wait 'id="compact-provider-list"' 10000
ad press 'label="Close"' --settle
adb shell input tap "${input_x}" "${input_y}"
clear_focused_input_with_adb
ad wait 'editable=true' 10000
adb shell input keyevent HOME
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
sleep 1
open_gboard "${input_x}" "${input_y}"
clear_focused_input_with_adb
adb shell input keyevent 76
sleep 1
adb shell ime set "${HELPER_IME}" >/dev/null
ad wait 'id="composer-autocomplete-popover"' 10000
ad wait 'text="/exit"' 10000
snapshot_json "${ARTIFACTS_DIR}/command-popover.json"
node "${ASSERT}" above-y \
"${ARTIFACTS_DIR}/command-popover.json" \
"/exit, Archive the current agent" \
"$((input_y - baseline_input_height / 2))"
adb shell input keyevent 67
ad wait 'label="Message, @files, /commands"' 10000
adb shell input keyevent HOME
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
sleep 1
open_gboard "${input_x}" "${input_y}"
clear_focused_input_with_adb
adb shell input keyevent 76
sleep 1
adb shell ime set "${HELPER_IME}" >/dev/null
ad wait 'id="composer-autocomplete-popover"' 10000
ad wait 'text="/exit"' 10000
snapshot_json "${ARTIFACTS_DIR}/command-popover.json"
node "${ASSERT}" above-y \
"${ARTIFACTS_DIR}/command-popover.json" \
"/exit, Archive the current agent" \
"$((input_y - baseline_input_height / 2))"
adb shell input keyevent 67
ad wait 'label="Message, @files, /commands"' 10000
ad fill 'label="Message, @files, /commands"' "${MESSAGE}" --settle
snapshot_json "${ARTIFACTS_DIR}/multiline-first.json"
ad fill 'editable=true focused=true' "${MESSAGE}" --settle
snapshot_json "${ARTIFACTS_DIR}/multiline-second.json"
node "${ASSERT}" same-input-height \
"${ARTIFACTS_DIR}/multiline-first.json" \
"${ARTIFACTS_DIR}/multiline-second.json"
ad fill 'label="Message, @files, /commands"' "${MESSAGE}" --settle
snapshot_json "${ARTIFACTS_DIR}/multiline-first.json"
ad fill 'editable=true focused=true' "${MESSAGE}" --settle
snapshot_json "${ARTIFACTS_DIR}/multiline-second.json"
node "${ASSERT}" same-input-height \
"${ARTIFACTS_DIR}/multiline-first.json" \
"${ARTIFACTS_DIR}/multiline-second.json"
adb shell input keyevent HOME
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
sleep 1
open_gboard "${input_x}" "${input_y}"
adb shell input keyevent HOME
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
sleep 1
open_gboard "${input_x}" "${input_y}"
keyboard_shift="$(read_keyboard_shift)"
capture_screen "${ARTIFACTS_DIR}/keyboard-open.png"
keyboard_shift="$(read_keyboard_shift)"
capture_screen "${ARTIFACTS_DIR}/keyboard-open.png"
node "${ASSERT}" same-header \
"${ARTIFACTS_DIR}/baseline.png" \
"${ARTIFACTS_DIR}/keyboard-open.png" \
"$((header_y + 48))"
node "${ASSERT}" same-header \
"${ARTIFACTS_DIR}/baseline.png" \
"${ARTIFACTS_DIR}/keyboard-open.png" \
"$((header_y + 48))"
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true focused=true' "${LONG_MESSAGE}" --settle
open_gboard "${input_x}" "${input_y}"
capture_ui_xml "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml"
capture_screen "${ARTIFACTS_DIR}/long-draft-keyboard-open.png"
node "${ASSERT}" xml-above-y \
"${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \
"Add attachment" \
"$(read_ime_top)"
display_density="$(adb shell wm density | awk '/density:/ { density = $NF } END { print density }')"
node "${ASSERT}" xml-composer-contained \
"${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \
"$(read_ime_top)" \
"${display_density}"
# Emptying a capped blank-line draft with Gboard's hold-to-delete must return the input to
# its baseline height. Fabric only re-measures the Android input when its text prop changes,
# so the editing input renders again after every edit
# (packages/app/src/components/ui/text-input/text-input.native.tsx). The sequence mirrors the
# report: grow to the cap, close and reopen the keyboard, hold delete from the middle of the
# draft, jump to the end, hold delete again. The first hold-delete must start mid-draft: from
# the end, the pre-fix input also returns to baseline. Backspace sits on Gboard's third key
# row on the paseo-api35 layout.
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true' "${BLANK_LINE_DRAFT}" --settle
open_gboard "${input_x}" "${input_y}"
adb shell input keyevent BACK
wait_for_ime false
open_gboard "${input_x}" "${input_y}"
adb shell input keycombination 113 122
adb shell input keyevent 20 20 20 20 20 20 20 20
screen_width="$(adb shell wm size | sed -n 's/.*: \([0-9]*\)x.*/\1/p' | tail -1)"
backspace_x="$((screen_width - 85))"
backspace_y="$(($(read_ime_top) + 507))"
adb shell input swipe "${backspace_x}" "${backspace_y}" "${backspace_x}" "${backspace_y}" 8000
adb shell input keycombination 113 123
adb shell input swipe "${backspace_x}" "${backspace_y}" "${backspace_x}" "${backspace_y}" 12000
adb shell ime set "${HELPER_IME}" >/dev/null
ad wait 'label="Message, @files, /commands"' 10000
snapshot_json "${ARTIFACTS_DIR}/after-hold-delete.json"
node "${ASSERT}" same-input-height \
"${ARTIFACTS_DIR}/baseline.json" \
"${ARTIFACTS_DIR}/after-hold-delete.json"
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true focused=true' "${LONG_MESSAGE}" --settle
open_gboard "${input_x}" "${input_y}"
capture_ui_xml "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml"
capture_screen "${ARTIFACTS_DIR}/long-draft-keyboard-open.png"
node "${ASSERT}" xml-above-y \
"${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \
"Add attachment" \
"$(read_ime_top)"
display_density="$(adb shell wm density | awk '/density:/ { density = $NF } END { print density }')"
node "${ASSERT}" xml-composer-contained \
"${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \
"$(read_ime_top)" \
"${display_density}"
# Emptying a capped blank-line draft with Gboard's hold-to-delete must return the input to
# its baseline height. Fabric only re-measures the Android input when its text prop changes,
# so the editing input renders again after every edit
# (packages/app/src/components/ui/text-input/text-input.native.tsx). The sequence mirrors the
# report: grow to the cap, close and reopen the keyboard, hold delete from the middle of the
# draft, jump to the end, hold delete again. The first hold-delete must start mid-draft: from
# the end, the pre-fix input also returns to baseline. Backspace sits on Gboard's third key
# row on the paseo-api35 layout.
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true' "${BLANK_LINE_DRAFT}" --settle
open_gboard "${input_x}" "${input_y}"
adb shell input keyevent BACK
wait_for_ime false
open_gboard "${input_x}" "${input_y}"
adb shell input keycombination 113 122
adb shell input keyevent 20 20 20 20 20 20 20 20
screen_width="$(adb shell wm size | sed -n 's/.*: \([0-9]*\)x.*/\1/p' | tail -1)"
backspace_x="$((screen_width - 85))"
backspace_y="$(($(read_ime_top) + 507))"
adb shell input swipe "${backspace_x}" "${backspace_y}" "${backspace_x}" "${backspace_y}" 8000
adb shell input keycombination 113 123
adb shell input swipe "${backspace_x}" "${backspace_y}" "${backspace_x}" "${backspace_y}" 12000
adb shell ime set "${HELPER_IME}" >/dev/null
ad wait 'label="Message, @files, /commands"' 10000
snapshot_json "${ARTIFACTS_DIR}/after-hold-delete.json"
node "${ASSERT}" same-input-height \
"${ARTIFACTS_DIR}/baseline.json" \
"${ARTIFACTS_DIR}/after-hold-delete.json"
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true' "${MESSAGE}" --settle
open_gboard "${input_x}" "${input_y}"
keyboard_shift="$(read_keyboard_shift)"
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true' "${MESSAGE}" --settle
open_gboard "${input_x}" "${input_y}"
keyboard_shift="$(read_keyboard_shift)"
read -r changes_x changes_y _ < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/multiline-second.json" "Open Changes tab"
)
adb shell input tap "${changes_x}" "$((changes_y - keyboard_shift))"
ad wait 'id="changes-header"' 10000
capture_screen "${ARTIFACTS_DIR}/changes-control-open.png"
ad press 'label="Close Explorer sidebar"' --settle
ad wait "text=\"${AGENT_TITLE}\"" 10000
ad wait 'editable=true' 10000
read -r changes_x changes_y _ < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/multiline-second.json" "Open Changes tab"
)
adb shell input tap "${changes_x}" "$((changes_y - keyboard_shift))"
ad wait 'id="changes-header"' 10000
capture_screen "${ARTIFACTS_DIR}/changes-control-open.png"
ad press 'label="Close Explorer sidebar"' --settle
ad wait "text=\"${AGENT_TITLE}\"" 10000
ad wait 'editable=true' 10000
open_gboard "${input_x}" "${input_y}"
keyboard_shift="$(read_keyboard_shift)"
adb shell input tap "${attach_x}" "$((attach_y - keyboard_shift))"
sleep 1
adb shell ime set "${HELPER_IME}" >/dev/null
ad wait 'id="message-input-attachment-menu-content"' 10000
ad press 540 600 --settle
open_gboard "${input_x}" "${input_y}"
keyboard_shift="$(read_keyboard_shift)"
adb shell input tap "${attach_x}" "$((attach_y - keyboard_shift))"
sleep 1
adb shell ime set "${HELPER_IME}" >/dev/null
ad wait 'id="message-input-attachment-menu-content"' 10000
ad press 540 600 --settle
open_gboard "${input_x}" "${input_y}"
keyboard_shift="$(read_keyboard_shift)"
adb shell input tap 983 "$((attach_y - keyboard_shift))"
wait_for_ime false
adb shell ime set "${HELPER_IME}" >/dev/null
ad wait text "keyboard invariant line one" 10000
snapshot_json "${ARTIFACTS_DIR}/after-submit.json"
node "${ASSERT}" has-exact-text "${ARTIFACTS_DIR}/after-submit.json" "${MESSAGE}"
node "${ASSERT}" same-input-height \
"${ARTIFACTS_DIR}/baseline.json" \
"${ARTIFACTS_DIR}/after-submit.json"
open_gboard "${input_x}" "${input_y}"
keyboard_shift="$(read_keyboard_shift)"
adb shell input tap 983 "$((attach_y - keyboard_shift))"
wait_for_ime false
adb shell ime set "${HELPER_IME}" >/dev/null
ad wait text "keyboard invariant line one" 10000
snapshot_json "${ARTIFACTS_DIR}/after-submit.json"
node "${ASSERT}" has-exact-text "${ARTIFACTS_DIR}/after-submit.json" "${MESSAGE}"
node "${ASSERT}" same-input-height \
"${ARTIFACTS_DIR}/baseline.json" \
"${ARTIFACTS_DIR}/after-submit.json"
open_gboard "${input_x}" "${input_y}"
adb shell input keyevent BACK
wait_for_ime false
sleep 0.5
capture_screen "${ARTIFACTS_DIR}/hidden-focused-baseline.png"
open_gboard "${input_x}" "${input_y}"
adb shell input keyevent BACK
wait_for_ime false
sleep 0.5
capture_screen "${ARTIFACTS_DIR}/hidden-focused-baseline.png"
open_gboard "${input_x}" "${input_y}"
node "${STALL_HERMES}" "${METRO_PORT}" 3000 "${APP_ID}" &
stall_pid="$!"
sleep 0.25
adb shell input keyevent BACK
wait_for_ime false
sleep 0.5
capture_screen "${ARTIFACTS_DIR}/hidden-during-js-stall.png"
wait "${stall_pid}"
capture_screen "${ARTIFACTS_DIR}/hidden-after-js-stall.png"
node "${ASSERT}" same-region \
"${ARTIFACTS_DIR}/hidden-focused-baseline.png" \
"${ARTIFACTS_DIR}/hidden-during-js-stall.png" \
1400 \
430
node "${ASSERT}" same-region \
"${ARTIFACTS_DIR}/hidden-focused-baseline.png" \
"${ARTIFACTS_DIR}/hidden-after-js-stall.png" \
1400 \
430
open_gboard "${input_x}" "${input_y}"
node "${STALL_HERMES}" "${METRO_PORT}" 3000 "${APP_ID}" &
stall_pid="$!"
sleep 0.25
adb shell input keyevent BACK
wait_for_ime false
sleep 0.5
capture_screen "${ARTIFACTS_DIR}/hidden-during-js-stall.png"
wait "${stall_pid}"
capture_screen "${ARTIFACTS_DIR}/hidden-after-js-stall.png"
node "${ASSERT}" same-region \
"${ARTIFACTS_DIR}/hidden-focused-baseline.png" \
"${ARTIFACTS_DIR}/hidden-during-js-stall.png" \
1400 \
430
node "${ASSERT}" same-region \
"${ARTIFACTS_DIR}/hidden-focused-baseline.png" \
"${ARTIFACTS_DIR}/hidden-after-js-stall.png" \
1400 \
430
ad press 'id="menu-button"' --settle
ad press 'id="sidebar-global-new-workspace"' --settle
ad wait 'id="workspace-create-submit"' 10000
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true' "x" --settle
snapshot_json "${ARTIFACTS_DIR}/new-workspace-short-draft.json"
read -r new_input_x new_input_y _ < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-short-draft.json" "editable"
)
open_gboard "${new_input_x}" "${new_input_y}"
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-short-draft-keyboard-open.xml"
# Typing more lines moves the setup fields up with the composer instead of
# hiding their bottom rows behind it.
adb shell input keycombination 113 123
adb shell input keyevent 66 66 66
adb shell input text grown
sleep 1
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.xml"
capture_screen "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.png"
node "${ASSERT}" xml-fields-follow-growth \
"${ARTIFACTS_DIR}/new-workspace-short-draft-keyboard-open.xml" \
"${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.xml"
ad press 'id="menu-button"' --settle
ad press 'id="sidebar-global-new-workspace"' --settle
ad wait 'id="workspace-create-submit"' 10000
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true' "x" --settle
snapshot_json "${ARTIFACTS_DIR}/new-workspace-short-draft.json"
read -r new_input_x new_input_y _ < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-short-draft.json" "editable"
)
open_gboard "${new_input_x}" "${new_input_y}"
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-short-draft-keyboard-open.xml"
# Typing more lines moves the setup fields up with the composer instead of
# hiding their bottom rows behind it.
adb shell input keycombination 113 123
adb shell input keyevent 66 66 66
adb shell input text grown
sleep 1
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.xml"
capture_screen "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.png"
node "${ASSERT}" xml-fields-follow-growth \
"${ARTIFACTS_DIR}/new-workspace-short-draft-keyboard-open.xml" \
"${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.xml"
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true' "${LONG_MESSAGE}" --settle
snapshot_json "${ARTIFACTS_DIR}/new-workspace-long-draft.json"
read -r new_input_x new_input_y new_input_height < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-long-draft.json" "editable"
)
# The editor shrinks when the keyboard opens. Its bottom stays anchored, while
# its old center can move into the setup fields after the height constraint.
new_input_y="$((new_input_y + new_input_height / 2 - 8))"
open_gboard "${new_input_x}" "${new_input_y}"
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml"
capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.png"
node "${ASSERT}" xml-composer-contained \
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \
"$(read_ime_top)" \
"${display_density}"
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true' "${LONG_MESSAGE}" --settle
snapshot_json "${ARTIFACTS_DIR}/new-workspace-long-draft.json"
read -r new_input_x new_input_y new_input_height < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-long-draft.json" "editable"
)
# The editor shrinks when the keyboard opens. Its bottom stays anchored, while
# its old center can move into the setup fields after the height constraint.
new_input_y="$((new_input_y + new_input_height / 2 - 8))"
open_gboard "${new_input_x}" "${new_input_y}"
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml"
capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.png"
node "${ASSERT}" xml-composer-contained \
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \
"$(read_ime_top)" \
"${display_density}"
# Dismissing the keyboard moves the composer down at its editing height and
# brings the setup fields back fully visible above it.
adb shell input keyevent BACK
wait_for_ime false
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml"
capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.png"
node "${ASSERT}" same-input-height \
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml"
node "${ASSERT}" xml-fields-above-composer \
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml"
# Dismissing the keyboard moves the composer down at its editing height and
# brings the setup fields back fully visible above it.
adb shell input keyevent BACK
wait_for_ime false
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml"
capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.png"
node "${ASSERT}" same-input-height \
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml"
node "${ASSERT}" xml-fields-above-composer \
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml"
}
# All hosts run the same scenario; only navigation differs.
# For the shared scenario, tap the editor's blank right edge once. Tapping
@@ -470,20 +482,80 @@ press_composer_control() {
adb shell input tap "${x}" "${y}"
}
# Opens suggestions with the keyboard closed, before it opens, or after it settled;
# checks their bounds and taps the best match where it is drawn. Accessibility keeps
# reporting rows behind the keyboard, so existence is not evidence. It also drops the
# composer while suggestions are open, so the composer bounds come from a reference
# snapshot with an empty input in the keyboard state the suggestions are checked in.
check_autocomplete() {
local host="$1" order="$2" kind="$3" query="$4" expected="$5" inserted="$6"
local prefix="${ARTIFACTS_DIR}/${host}-${kind}-${order}"
if [[ "${order}" == keyboard-closed ]]; then
prepare_composer closed
snapshot_json "${prefix}-reference.json"
adb shell input text "${query}"
else
prepare_composer open
# The composer follows the keyboard animation, which ends after the IME frame settles.
sleep 1
snapshot_json "${prefix}-reference.json"
if [[ "${order}" == before-keyboard ]]; then
adb shell ime set "${HELPER_IME}" >/dev/null
wait_for_ime false
adb shell input text "${query}"
open_editor_keyboard "${overlay_x}" "${overlay_y}"
else
adb shell input text "${query}"
fi
fi
ad wait 'id="composer-autocomplete-popover"' 10000
# The popover remeasures once keyboard motion settles.
sleep 1
capture_screen "${prefix}.png"
snapshot_json "${prefix}.json"
local ime_top=""
if [[ "${order}" != keyboard-closed ]]; then ime_top="$(read_ime_top)"; fi
local best_x best_y
read -r best_x best_y < <(
node "${ASSERT}" autocomplete-best "${prefix}.json" "${prefix}-reference.json" "${expected}" "${ime_top}"
)
adb shell input tap "${best_x}" "${best_y}"
sleep 1
snapshot_json "${prefix}-selected.json"
node "${ASSERT}" composer-text "${prefix}-selected.json" "${inserted}"
clear_focused_input_with_adb
echo "PASS host=${host}: ${kind} ${order}, ${expected} drawn above composer and keyboard and inserted"
}
# Drafts list commands for their selected provider, and a fresh install selects none.
select_claude_model() {
local x y h
press_composer_control combined-model-selector
ad wait 'id="agent-controls-model"' 10000
ad press 'id="agent-controls-model"' --settle
ad press 'id="model-provider-claude"' --settle
snapshot_json "${ARTIFACTS_DIR}/model-list.json"
read -r x y h < <(node "${ASSERT}" rect "${ARTIFACTS_DIR}/model-list.json" 'model-row-claude-*')
adb shell input tap "${x}" "${y}"
ad wait 'id="agent-controls-thinking"' 10000
ad press 'label="Close"' --settle
}
run_host_overlays() {
local host="$1"
if [[ "${host}" != chat ]]; then
prepare_composer closed
select_claude_model
fi
# Chat lists the mock provider's command; drafts list the release skills in
# this repository's .claude/skills for the selected Claude model.
local command_query=/release command_match=/release-beta command_inserted="/release-beta "
if [[ "${host}" == chat ]]; then command_query=/mo command_match=/mock command_inserted="/mock "; fi
for order in keyboard-closed before-keyboard after-keyboard; do
check_autocomplete "${host}" "${order}" commands "${command_query}" "${command_match}" "${command_inserted}"
check_autocomplete "${host}" "${order}" files @app packages/app '"packages/app"'
done
for mode in closed open; do
for query in / @README; do
prepare_composer closed
adb shell input text "${query}"
if [[ "${mode}" == open ]]; then open_editor_keyboard "${overlay_x}" "${overlay_y}"; fi
ad wait 'id="composer-autocomplete-popover"' 10000
local kind=commands
if [[ "${query}" == @* ]]; then kind=files; fi
capture_screen "${ARTIFACTS_DIR}/${host}-${kind}-${mode}.png"
clear_focused_input_with_adb
echo "PASS host=${host}: ${kind} popover keyboard=${mode}"
done
prepare_composer "${mode}"
press_composer_control combined-model-selector
ad wait 'id="compact-provider-list"' 10000
@@ -656,19 +728,29 @@ run_host_scenario() {
run_host_scroll "${host}"
}
run_host() {
if [[ "${SCENARIO}" == overlays ]]; then
run_host_overlays "$1"
else
run_host_scenario "$1"
fi
}
if [[ "${SCENARIO}" == all ]]; then run_chat_baseline; fi
adb shell am start -a android.intent.action.VIEW -d "paseo://h/${SERVER_ID}/agent/${agent_id}" "${APP_ID}" >/dev/null
ad wait "text=\"${AGENT_TITLE}\"" 10000
run_host_scenario chat
run_host chat
ad keyboard dismiss || true
ad press 'id="workspace-tab-switcher-trigger"' --settle
ad wait 'id="workspace-tab-switcher-new-agent"' 10000
ad press 'id="workspace-tab-switcher-new-agent"' --settle
ad wait 'text="New Agent"' 10000
run_host_scenario workspace-draft
# Only a draft offers to import a session.
ad wait 'id="composer-import-agent-pill"' 10000
run_host workspace-draft
ad keyboard dismiss || true
ad press 'id="menu-button"' --settle
ad press 'id="sidebar-global-new-workspace"' --settle
ad wait 'id="workspace-create-submit"' 10000
run_host_scenario new-workspace
run_host new-workspace
echo "Composer keyboard invariants passed"
@@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import process from "node:process";
import sharp from "sharp";
import { resolveAutocompletePlacement } from "./autocomplete-placement.mjs";
const [, , command, ...args] = process.argv;
@@ -115,11 +116,14 @@ if (command === "stream-moved") {
`Composer contained: top=${composer.top}, header=${viewport.top}, bottom=${composer.bottom}, keyboard=${imeTopArgument}\n`,
);
} else if (command === "rect") {
// A trailing `*` matches the first identifier with that prefix.
const [snapshotPath, identifier, edge] = args;
const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
const prefix = identifier.endsWith("*") ? identifier.slice(0, -1) : null;
const node = snapshot.data.nodes.find(
(candidate) =>
candidate.identifier === identifier ||
(prefix !== null && candidate.identifier?.startsWith(prefix)) ||
candidate.label === identifier ||
(identifier === "editable" && candidate.type.endsWith("EditText")),
);
@@ -232,6 +236,27 @@ if (command === "stream-moved") {
if (!snapshot.data.nodes.some((candidate) => candidate.label === expected)) {
throw new Error(`Missing exact text in ${snapshotPath}: ${expected}`);
}
} else if (command === "autocomplete-best") {
// Prints the center of the expected best match after checking its placement
// against the composer in the reference snapshot.
const [snapshotPath, referencePath, expected, imeTopArgument] = args;
const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
const reference = JSON.parse(await fs.readFile(referencePath, "utf8"));
const best = resolveAutocompletePlacement(snapshot, {
expected,
keyboardTop: imeTopArgument ? Number(imeTopArgument) : undefined,
reference,
});
process.stdout.write(`${best.x} ${best.y}\n`);
} else if (command === "composer-text") {
// Accessibility drops a trailing space, so compare without it.
const [snapshotPath, expected] = args;
const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
const input = snapshot.data.nodes.find((candidate) => candidate.type.endsWith("EditText"));
const text = input?.value ?? input?.label ?? "";
if (text.trimEnd() !== expected.trimEnd()) {
throw new Error(`Composer text ${JSON.stringify(text)}, expected ${JSON.stringify(expected)}`);
}
} else if (command === "same-region") {
const [firstPath, secondPath, topArgument, heightArgument, failureMessage] = args;
const crop = {
@@ -0,0 +1,94 @@
// Shared by the iOS and Android composer keyboard harnesses. Reads an agent-device
// `snapshot --raw --json` result. Accessibility keeps reporting suggestion rows that
// sit behind the keyboard, so existence is not evidence: compare their screen bounds.
const bottomOf = (rect) => rect.y + rect.height;
function findById(nodes, identifier) {
const node = nodes.find((candidate) => candidate.identifier === identifier && candidate.rect);
if (!node) throw new Error(`Missing node: ${identifier}`);
return node;
}
function contains(outer, inner) {
return (
inner.x >= outer.x - 1 &&
inner.y >= outer.y - 1 &&
inner.x + inner.width <= outer.x + outer.width + 1 &&
bottomOf(inner) <= bottomOf(outer) + 1
);
}
/**
* The best match is the list row nearest the composer. Check that the popover sits
* between the app's header controls and the composer, that the keyboard (when shown)
* is below both, and that the row nearest the composer is fully drawn and is
* `expected`. A clipped best match fails: the nearest whole row is then another one.
* Return where to tap it. Android leaves the composer out of the accessibility tree
* while suggestions are open, so its callers read the composer from `reference`: a
* snapshot in the same keyboard state with an empty input.
*/
export function resolveAutocompletePlacement(snapshot, { expected, keyboardTop, reference }) {
if (!snapshot.success || !snapshot.data?.nodes?.length) throw new Error("Empty device snapshot");
const nodes = snapshot.data.nodes;
const popover = findById(nodes, "composer-autocomplete-popover").rect;
const composer = findById((reference ?? snapshot).data.nodes, "message-input-root").rect;
// The popover may cover the compact header, never the status bar above it.
const minimumTop =
nodes.find((node) => node.identifier === "menu-button" && node.rect)?.rect.y ?? 0;
// iOS reports the keyboard in the app's tree; Android callers pass the IME frame.
const keyboardY =
keyboardTop ?? nodes.find((node) => node.type === "Keyboard" && node.rect)?.rect.y;
const popoverBottom = bottomOf(popover);
if (popoverBottom > composer.y + 1) {
throw new Error(
`Autocomplete overlaps the composer: popover bottom=${popoverBottom}, composer top=${composer.y}`,
);
}
if (popover.y < minimumTop - 1) {
throw new Error(
`Autocomplete extends above the header: top=${popover.y}, minimum=${minimumTop}`,
);
}
if (keyboardY !== undefined && popoverBottom > keyboardY + 1) {
throw new Error(
`Autocomplete sits behind the keyboard: popover bottom=${popoverBottom}, keyboard top=${keyboardY}`,
);
}
// Rows are the innermost labelled boxes spanning the popover. The list scrolls to
// its end, so the lowest row is the best match nearest the composer.
const candidates = nodes.filter(
(node) =>
node.rect &&
node.label &&
node.identifier !== "composer-autocomplete-popover" &&
!/scroll bar/i.test(node.label) &&
node.rect.height > 0 &&
// The popover border insets rows by a few pixels; their labels are narrower.
node.rect.width >= popover.width * 0.97 &&
contains(popover, node.rect),
);
const area = (rect) => rect.width * rect.height;
const rows = candidates.filter(
(outer) =>
!candidates.some(
(inner) => area(inner.rect) < area(outer.rect) - 1 && contains(outer.rect, inner.rect),
),
);
if (rows.length === 0) throw new Error("Autocomplete has no visible rows");
const best = rows.reduce((lowest, row) =>
bottomOf(row.rect) > bottomOf(lowest.rect) ? row : lowest,
);
const label = best.label.split(", ")[0];
if (label !== expected) {
throw new Error(`Best visible match is ${label}, expected ${expected}`);
}
return {
label,
x: Math.round(best.rect.x + best.rect.width / 2),
y: Math.round(best.rect.y + best.rect.height / 2),
popover,
composer,
keyboardTop: keyboardY ?? null,
};
}
@@ -0,0 +1,229 @@
// Composer autocomplete placement on iOS with the software keyboard: chat, workspace
// draft tab, and New workspace. Run where agent-device reaches the simulator, with the
// development app connected to the fixture host. See docs/mobile-testing.md.
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { setTimeout as sleep } from "node:timers/promises";
import { fileURLToPath } from "node:url";
import { resolveAutocompletePlacement } from "./autocomplete-placement.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../../..");
const env = (name, fallback) => process.env[`PASEO_COMPOSER_KEYBOARD_${name}`] ?? fallback;
const serverId = env("SERVER_ID");
const agentId = env("AGENT_ID");
const workspaceId = env("WORKSPACE_ID");
if (!serverId || !agentId || !workspaceId) {
throw new Error(
"Set PASEO_COMPOSER_KEYBOARD_SERVER_ID, _AGENT_ID (an idle chat) and _WORKSPACE_ID (its workspace)",
);
}
const draftId = env("DRAFT_ID", "composer-keyboard");
const session = env("SESSION", "composer-keyboard-ios");
const device = env("DEVICE", "iPhone 16 Pro");
const artifactsDir = path.join(repoRoot, ".dev/agent-device-artifacts/composer-keyboard-ios");
const grownPrefix = "grown line one\ngrown line two\ngrown line three\n";
// Partial queries with known best matches. `inserted` is the composer text after
// tapping the best match.
const fileCase = { text: "@app", expected: "packages/app", inserted: '"packages/app"' };
// The release skills ship in this repository's .claude/skills.
const skillCase = { text: "/release", expected: "/release-beta", inserted: "/release-beta " };
// The chat fixture's mock provider lists one command.
const chatCommandCase = { text: "/mo", expected: "/mock", inserted: "/mock " };
fs.mkdirSync(artifactsDir, { recursive: true });
function ad(...args) {
return execFileSync("agent-device", [...args, "--session", session], {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
}
function snapshot(name) {
const output = ad("snapshot", "--raw", "--json");
fs.writeFileSync(path.join(artifactsDir, `${name}.json`), output);
ad("screenshot", path.join(artifactsDir, `${name}.png`));
return JSON.parse(output);
}
const nodeById = (snap, id) => snap.data.nodes.find((node) => node.identifier === id && node.rect);
const keyboardShown = (snap) => snap.data.nodes.some((node) => node.type === "Keyboard");
const composerText = (snap) =>
snap.data.nodes.find((node) => node.type === "TextView" && node.rect)?.value ?? "";
async function waitFor(name, predicate, timeoutMs = 10_000) {
const deadline = Date.now() + timeoutMs;
for (;;) {
const snap = snapshot(name);
if (predicate(snap)) return snap;
if (Date.now() > deadline) throw new Error(`Timed out waiting for ${name}`);
await sleep(500);
}
}
// Keyboard motion has settled once two snapshots agree on the composer position.
async function settledPlacement(name, keyboard) {
let previousTop;
return waitFor(name, (snap) => {
const composer = nodeById(snap, "message-input-root");
if (!nodeById(snap, "composer-autocomplete-popover") || !composer) return false;
if (keyboardShown(snap) !== keyboard) return false;
const stable = composer.rect.y === previousTop;
previousTop = composer.rect.y;
return stable;
});
}
function assertPlacement(name, snap, expected) {
const placement = resolveAutocompletePlacement(snap, { expected });
process.stdout.write(
`PASS ${name}: popover ${Math.round(placement.popover.y)}-${Math.round(
placement.popover.y + placement.popover.height,
)} above composer ${Math.round(placement.composer.y)}, ${expected} drawn\n`,
);
return placement;
}
// iOS has no generic keyboard dismissal. Tapping the chat stream dismisses it; a
// draft has no stream, so open and close the model sheet, which takes input focus.
async function dismissKeyboard(snap) {
const composer = nodeById(snap, "message-input-root").rect;
ad("press", "4", String(Math.round(composer.y - 8)));
await sleep(1_000);
if (!keyboardShown(snapshot("dismiss-keyboard"))) return;
const sheetOpen = (sheet) =>
sheet.data.nodes.some((node) => node.type === "Button" && node.label === "Close");
ad("press", 'id="combined-model-selector"');
await waitFor("dismiss-keyboard-sheet", sheetOpen);
ad("press", 'label="Close"');
// The input is not hittable until the sheet has gone.
await waitFor("dismiss-keyboard-done", (done) => !sheetOpen(done) && !keyboardShown(done));
}
// Tap past the end of the (short) last line so the caret lands after the mention.
// The input's right padding holds the fullscreen toggle; stay clear of it.
function focusAtEnd(snap) {
const input = snap.data.nodes.find((node) => node.type === "TextView" && node.rect)?.rect;
if (!input) throw new Error("Missing composer input");
ad(
"press",
String(Math.round(input.x + input.width * 0.7)),
String(Math.round(input.y + input.height - 6)),
);
}
// `fill` cannot replace a multi-line draft or write an empty one, so delete from the end.
async function clearInput(name) {
focusAtEnd(snapshot(`${name}-before-clear`));
const keyboard = await waitFor(`${name}-clear`, keyboardShown);
const deleteKey = keyboard.data.nodes.find(
(node) => node.type === "Key" && node.label === "delete" && node.rect,
)?.rect;
if (!deleteKey) throw new Error("Missing keyboard delete key");
const length = composerText(keyboard).length;
ad(
"press",
String(Math.round(deleteKey.x + deleteKey.width / 2)),
String(Math.round(deleteKey.y + deleteKey.height / 2)),
"--count",
String(length + 2),
"--interval-ms",
"40",
);
}
// The accessibility value drops a trailing space.
const sameText = (actual, expected) => actual.trimEnd() === expected.trimEnd();
async function runCase(host, kind, { text, expected, inserted }) {
const name = `${host}-${kind}`;
await clearInput(name);
await dismissKeyboard(snapshot(`${name}-empty`));
// fill taps the input and types at once, so suggestions open while the keyboard opens.
ad("fill", "editable=true", text);
assertPlacement(
`${name} opened while keyboard opens`,
await settledPlacement(`${name}-opening`, true),
expected,
);
await clearInput(`${name}-settled`);
await waitFor(`${name}-settled-empty`, (snap) => composerText(snap) === "");
ad("type", text);
assertPlacement(
`${name} opened after keyboard settled`,
await settledPlacement(`${name}-open`, true),
expected,
);
await dismissKeyboard(snapshot(`${name}-before-close`));
assertPlacement(
`${name} keyboard closed`,
await settledPlacement(`${name}-closed`, false),
expected,
);
focusAtEnd(snapshot(`${name}-before-reopen`));
const reopened = assertPlacement(
`${name} keyboard opened under open suggestions`,
await settledPlacement(`${name}-reopened`, true),
expected,
);
ad("press", String(reopened.x), String(reopened.y));
const selected = await waitFor(`${name}-selected`, (snap) =>
sameText(composerText(snap), inserted),
);
if (!keyboardShown(selected)) throw new Error(`Selecting ${expected} closed the keyboard`);
process.stdout.write(
`PASS ${name}: tapped ${expected}, composer now ${JSON.stringify(inserted)}\n`,
);
}
// Drafts list commands for their selected provider, and a fresh install selects none.
async function selectClaudeModel(host) {
ad("press", 'id="combined-model-selector"');
await waitFor(`${host}-model-sheet`, (snap) => Boolean(nodeById(snap, "agent-controls-model")));
ad("press", 'id="agent-controls-model"');
await waitFor(`${host}-providers`, (snap) => Boolean(nodeById(snap, "model-provider-claude")));
ad("press", 'id="model-provider-claude"');
const models = await waitFor(`${host}-models`, (snap) =>
snap.data.nodes.some((node) => node.identifier?.startsWith("model-row-claude-") && node.rect),
);
const row = models.data.nodes.find(
(node) => node.identifier?.startsWith("model-row-claude-") && node.rect,
).rect;
ad(
"press",
String(Math.round(row.x + row.width / 2)),
String(Math.round(row.y + row.height / 2)),
);
await waitFor(`${host}-model-selected`, (snap) =>
Boolean(nodeById(snap, "agent-controls-thinking")),
);
ad("press", 'label="Close"');
await waitFor(`${host}-model-closed`, (snap) => !nodeById(snap, "agent-controls-model"));
}
async function runHost(host) {
if (host !== "chat") await selectClaudeModel(host);
await runCase(host, "commands", host === "chat" ? chatCommandCase : skillCase);
await runCase(host, "files", fileCase);
await runCase(host, "files-grown", {
text: `${grownPrefix}${fileCase.text}`,
expected: fileCase.expected,
inserted: `${grownPrefix}${fileCase.inserted}`,
});
await clearInput(`${host}-done`);
await dismissKeyboard(snapshot(`${host}-done`));
}
const hosts = [
["chat", `paseo://h/${serverId}/agent/${agentId}`],
["workspace-draft", `paseo://h/${serverId}/workspace/${workspaceId}?open=draft%3A${draftId}`],
["new-workspace", `paseo://new?serverId=${serverId}`],
];
for (const [host, url] of hosts) {
ad("open", url, "--platform", "ios", "--device", device);
await waitFor(host, (snap) => Boolean(nodeById(snap, "message-input-root")), 60_000);
await runHost(host);
}
process.stdout.write(`Composer autocomplete placement passed. Artifacts: ${artifactsDir}\n`);
@@ -0,0 +1,40 @@
// Open a connected host with the wide sidebar visible, keyboard closed, and
// three-button navigation enabled on an Android tablet. Run against a fresh build:
// node packages/app/e2e/mobile/sidebar-system-insets/android.mjs <session> <adb-serial>
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
const [, , session, serial] = process.argv;
assert(session && serial, "Pass the agent-device session and adb serial");
const windowState = execFileSync("adb", ["-s", serial, "shell", "dumpsys", "window"], {
encoding: "utf8",
timeout: 10_000,
});
const navBar = windowState.match(/type=navigationBars frame=\[0,(\d+)\]\[\d+,\d+\] visible=true/);
assert(navBar, "A visible bottom navigation bar is required");
assert(!/type=ime .*visible=true/.test(windowState), "Close the keyboard first");
const navigationTop = Number(navBar[1]);
const { data } = JSON.parse(
execFileSync("agent-device", ["snapshot", "--raw", "--json", "--session", session], {
encoding: "utf8",
timeout: 60_000,
}),
);
const focusedApp = windowState.match(/mCurrentFocus=.*? u\d+ ([^/\s]+)\//)?.[1];
assert.equal(
focusedApp,
data.appBundleId,
"Snapshot must belong to the focused app on this device",
);
for (const id of [
"sidebar-add-project",
"sidebar-hosts-trigger",
"sidebar-help",
"sidebar-settings",
]) {
const button = data.nodes.find((node) => node.identifier === id);
assert(button?.rect, `${id} must be visible above the system navigation bar`);
const bottom = button.rect.y + button.rect.height;
assert(bottom <= navigationTop, `${id} ends at ${bottom}, below navigation top ${navigationTop}`);
console.log(`PASS: ${id} bottom=${bottom}, navigationTop=${navigationTop}`);
}
@@ -35,7 +35,8 @@ export const test = base.extend<{
import { readFile, rm } from "node:fs/promises";
export default function contribute(server) {
return server.before("agent.session_open", async ({ request }) => {
if (request.reason !== "create") return request;
// Metadata helpers have no workspace and must not consume the one-shot failure.
if (request.reason !== "create" || !request.workspaceId) return request;
const gate = ${JSON.stringify(gate)};
let command = await readFile(gate, "utf8").catch(() => "release");
while (command === "hold") {
@@ -18,10 +18,29 @@ export async function openCommandCenterForAgent(
workspace: Pick<MockAgentWorkspace, "workspaceId" | "agentId">,
): Promise<Locator> {
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
await expect(
page.getByRole("button", { name: /^Select agent mode \(/ }).filter({ visible: true }),
).toBeVisible({ timeout: 30_000 });
const panel = await openCommandCenter(page);
return panel.getByTestId("command-center-input");
}
/**
* Types a query that matches both the agent's modes and its models, and waits until rows from both
* groups are listed. The agent's controls register after the panel opens; a key pressed before
* then lands on whatever else matched, such as file search.
*/
export async function searchAgentControls(page: Page, query: string): Promise<void> {
const panel = page.getByTestId("command-center-panel");
await panel.getByTestId("command-center-input").fill(query);
await expect(panel.getByRole("button", { name: /^Mode › / }).first()).toBeVisible({
timeout: 30_000,
});
await expect(panel.getByRole("button", { name: /^Model › / }).first()).toBeVisible({
timeout: 30_000,
});
}
export async function chooseCommandCenterAgentControl(input: {
page: Page;
query: string;
@@ -210,3 +210,30 @@ export async function expectOneLineTruncatedFileResult(
expect(overflow.overflow).toBe("hidden");
expect(overflow.whiteSpace).toBe("nowrap");
}
/** Hold real daemon responses until the user has finished the interaction under test. */
export async function holdDirectorySuggestionResponses(page: Page) {
let holding = false;
const pending: Array<() => void> = [];
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => server.send(message));
server.onMessage((message) => {
if (holding && parseSessionMessage(message)?.type === "directory_suggestions_response") {
pending.push(() => ws.send(message));
} else {
ws.send(message);
}
});
});
return {
hold() {
holding = true;
},
async release() {
await expect.poll(() => pending.length).toBeGreaterThan(0);
holding = false;
for (const send of pending.splice(0)) send();
},
};
}
@@ -0,0 +1,90 @@
import { mkdirSync } from "node:fs";
import { expect, type Locator, type Page } from "@playwright/test";
import { createMockIdleAgent } from "./archive-tab";
import { seedWorkspace } from "./seed-client";
export async function seedSearchableCommandCenter() {
const seed = await seedWorkspace({
repoPrefix: "command-center-performance-",
title: "Search workspace",
git: false,
});
try {
const agents = [];
for (let index = 0; index < 60; index += 1) {
agents.push(
await createMockIdleAgent(seed.client, {
cwd: seed.repoPath,
workspaceId: seed.workspaceId,
title: `Search agent ${String(index).padStart(3, "0")}`,
}),
);
}
const workspaces = [];
for (let index = 0; index < 40; index += 1) {
const path = `${seed.repoPath}/workspace-${index}`;
mkdirSync(path);
const result = await seed.client.createWorkspace({
source: { kind: "directory", path, projectId: seed.projectId },
title: `Search workspace ${String(index).padStart(3, "0")}`,
});
if (!result.workspace) throw new Error(result.error ?? "Workspace creation failed");
workspaces.push(result.workspace);
}
return { ...seed, agents, workspaces };
} catch (error) {
await seed.cleanup();
throw error;
}
}
export async function observeCommandCenterRowRenders(page: Page) {
await page.addInitScript(() => {
globalThis.__PASEO_RENDER_PROFILE_ENABLED__ = true;
});
}
export async function searchCommandCenter(panel: Locator, query: string) {
await panel.getByTestId("command-center-input").fill(query);
}
export async function warmCommandCenterRows(page: Page) {
// FlatList fills its overscan window in timed batches after the first visible frame.
await page.waitForTimeout(600);
await page.evaluate(() => {
globalThis.__PASEO_RENDER_PROFILE_REASONS__ = {};
});
}
export async function expectOnlyCommandCenterRowRendered(page: Page, resultId: string) {
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
),
);
const renders = await page.evaluate(() =>
Object.fromEntries(
Object.entries(globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {}).filter(([id]) =>
id.startsWith("CommandCenterResultRow:"),
),
),
);
expect(renders).toEqual({ [`CommandCenterResultRow:${resultId}`]: { render: 1 } });
}
export async function expectCommandCenterAgentWindow(panel: Locator, total: number) {
const rows = panel.locator(
'[data-testid^="command-center-agent-"]:not([data-testid="command-center-agent-subtitle"])',
);
const count = await rows.count();
expect(count).toBeGreaterThan(0);
expect(count).toBeLessThan(total);
}
export async function scrollToOldestCommandCenterAgent(panel: Locator) {
await panel.getByTestId("command-center-results").evaluate((element) => {
element.scrollTop = element.scrollHeight;
element.dispatchEvent(new Event("scroll"));
});
}
@@ -169,3 +169,42 @@ export async function expectTightTwoLineResultSpacing(
.evaluate((element) => element.getBoundingClientRect().top);
expect(subtitleTop - titleTop).toBeLessThanOrEqual(18);
}
/** Wait for the browser to deliver wheel events before publishing a daemon update. */
export async function scrollCommandCenterAwayFromSelection(page: Page, panel: Locator) {
await page.setViewportSize({ width: 1280, height: 500 });
const viewport = panel.getByTestId("command-center-results");
await viewport.hover();
await page.mouse.wheel(0, 300);
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBeGreaterThan(100);
const samples = await sampleCommandCenterScroll(panel);
const offset = samples.at(-1)!;
expect(offset).toBeGreaterThan(100);
return offset;
}
async function sampleCommandCenterScroll(panel: Locator): Promise<number[]> {
return panel.getByTestId("command-center-results").evaluate(async (viewport) => {
const samples: number[] = [];
for (let frame = 0; frame < 30; frame += 1) {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
samples.push(viewport.scrollTop);
}
return samples;
});
}
export async function expectCommandCenterScrollToStayAt(panel: Locator, offset: number) {
const samples = await sampleCommandCenterScroll(panel);
expect(Math.min(...samples)).toBe(offset);
expect(Math.max(...samples)).toBe(offset);
}
export async function expectKeyboardNavigationToResume(page: Page, panel: Locator, offset: number) {
await page.keyboard.press("ArrowDown");
await expect
.poll(() => panel.getByTestId("command-center-results").evaluate((el) => el.scrollTop))
.toBeLessThan(offset);
const second = panel.getByTestId("command-center-results").getByRole("button").nth(1);
await expect(second).toBeInViewport({ ratio: 1 });
}
@@ -17,6 +17,7 @@ import {
workspaceDeckEntryLocator,
} from "./workspace-ui";
import { getServerId } from "./server-id";
import { revealSidebarProjectWorkspaces } from "./sidebar";
import { WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES } from "@/screens/workspace/workspace-deck-retention";
import type { installDaemonWebSocketGate } from "./daemon-websocket-gate";
@@ -282,6 +283,7 @@ export async function createCreationScenario(page: Page) {
});
}
await expect(workspaceDeckEntryLocator(page, getServerId(), workspaceId)).toHaveCount(0);
await revealSidebarProjectWorkspaces(page, project.projectKey);
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId });
await page
.getByTestId(draftTestId!)
@@ -0,0 +1,44 @@
import type { Page, WebSocketRoute } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
type WebSocketMessage = string | Buffer;
function isFileUploadResponse(message: WebSocketMessage): boolean {
if (typeof message !== "string") return false;
try {
const envelope = JSON.parse(message) as { type?: unknown; message?: { type?: unknown } };
return (envelope.message ?? envelope).type === "file.upload.response";
} catch {
return false;
}
}
/**
* Holds the daemon's first `file.upload.response`, so the upload is stored on
* the host but the app still sees it in flight.
*/
export async function holdFileUploadResponse(page: Page) {
let held: { browser: WebSocketRoute; message: WebSocketMessage } | null = null;
let resolveHeld: (() => void) | null = null;
const heldResponse = new Promise<void>((resolve) => {
resolveHeld = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((message) => server.send(message));
server.onMessage((message) => {
if (held || !isFileUploadResponse(message)) {
browser.send(message);
return;
}
held = { browser, message };
resolveHeld?.();
});
});
return {
waitUntilHeld: () => heldResponse,
release: () => held?.browser.send(held.message),
/** Drops the app's connection while the upload is in flight. */
disconnect: () => held?.browser.close({ code: 1001, reason: "Upload gate disconnect" }),
};
}
@@ -0,0 +1,38 @@
import type { Page } from "@playwright/test";
// Poses as the desktop app running the e2e daemon as its own managed daemon, so
// `useIsLocalDaemon` reports the host as this machine. `directory` is where the
// desktop bridge claims dropped files live on disk.
export async function installLocalDesktopHost(page: Page, directory: string): Promise<void> {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set");
}
await page.addInitScript(
(localHost: { serverId: string; directory: string }) => {
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
platform: "linux",
invoke: async (command: string) => {
if (command === "desktop_daemon_status") {
return {
serverId: localHost.serverId,
status: "running",
listen: null,
hostname: null,
pid: null,
home: "",
version: null,
desktopManaged: true,
error: null,
};
}
return null;
},
getPendingOpenProject: async () => null,
events: { on: async () => () => undefined },
webUtils: { getPathForFile: (file: File) => `${localHost.directory}/${file.name}` },
};
},
{ serverId, directory },
);
}
@@ -11,7 +11,6 @@ export const MARKDOWN_PANEL_PLUGIN_ID = "file-panel-picker-e2e";
export const MARKDOWN_PANEL_ID = `plugin:${MARKDOWN_PANEL_PLUGIN_ID}/rich-markdown`;
export const BUILTIN_MARKDOWN_OPTION = "Markdown — Paseo";
export const PLUGIN_MARKDOWN_OPTION = `Rich Markdown — ${MARKDOWN_PANEL_PLUGIN_ID}`;
export const SOURCE_OPTION = "Source — Paseo";
const SOURCE = `import React from "react";
import { Text, View } from "react-native";
@@ -1,11 +1,17 @@
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Locator } from "@playwright/test";
import type { Locator, TestInfo } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import { buildAgentRoute } from "./mock-agent";
import { openCommandCenter } from "./command-center";
import {
closeCompactWorkspaceMenu,
compactWorkspaceMenu,
goBackInMenu,
openCompactWorkspaceMenu,
} from "./compact-workspace-scripts";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { seedWorkspace } from "./seed-client";
import { pluginRequirements } from "./plugin-fixture";
@@ -60,7 +66,7 @@ export default function contribute(client) {
let finishReview = () => {};
let attempts = 0;
const action = { kind: "action", async onPress() {
if (++attempts === 1) throw new Error("Deployment failed. Try again.");
if (++attempts % 2 === 1) throw new Error("Deployment failed. Try again.");
await new Promise((resolve) => { finish = resolve; });
deploy.update({ label: "Deployed" });
} };
@@ -165,6 +171,13 @@ async function command(page: Page, title: string) {
await expect(panel).not.toBeVisible();
}
/** The command center opens from the sidebar, which compact layouts keep closed. */
async function commandFromCompact(page: Page, title: string) {
await page.setViewportSize(WIDE);
await command(page, title);
await page.setViewportSize(COMPACT);
}
async function expectSurfaceVisible(
page: Page,
subject: Locator = page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
@@ -176,6 +189,61 @@ async function expectDetails(page: Page) {
await expect(page.getByText("All systems operational", { exact: true })).toBeInViewport();
}
function workspaceMenuRow(page: Page, title: string): Locator {
return compactWorkspaceMenu(page).getByRole("menuitem", { name: title, exact: true });
}
async function expectWorkspaceMenuRows(page: Page, titles: readonly string[]) {
const rows = compactWorkspaceMenu(page).getByRole("menuitem");
for (const [index, title] of titles.entries()) {
await expect(rows.nth(index)).toHaveAccessibleName(title);
}
}
async function chooseInWorkspaceMenu(page: Page, title: string) {
await workspaceMenuRow(page, title).click();
}
async function expectWorkspaceMenuClosed(page: Page) {
await expect(compactWorkspaceMenu(page)).toBeHidden({ timeout: 10_000 });
}
async function expectCompactDetails(page: Page) {
const menu = compactWorkspaceMenu(page);
await expect(menu.getByText("All systems operational", { exact: true })).toBeInViewport();
await expect(menu.getByText("Presentation: compact", { exact: true })).toBeVisible();
}
/** Resolves once `subject` renders the same pixels twice in a row: entering animations are done. */
async function expectStill(subject: Locator) {
let previous: Buffer | null = null;
await expect
.poll(
async () => {
const current = await subject.screenshot();
const still = previous !== null && current.equals(previous);
previous = current;
return still;
},
{ intervals: [150] },
)
.toBe(true);
}
/** Attaches the screen once per colour scheme, for review alongside the run. */
async function captureSchemes(page: Page, info: TestInfo, name: string, subject: Locator) {
for (const colorScheme of ["light", "dark"] as const) {
await page.emulateMedia({ colorScheme });
await expectStill(subject);
const file = info.outputPath(`${name}-${colorScheme}.png`);
await info.attach(`${name}-${colorScheme}`, {
body: await page.screenshot({ path: file }),
contentType: "image/png",
});
}
await page.emulateMedia({ colorScheme: "light" });
}
async function expectHeaderTextTruncated(page: Page) {
const title = page.getByTestId("workspace-header-title");
const subtitle = page.getByTestId("workspace-header-subtitle");
@@ -204,10 +272,12 @@ async function expectHeaderTextTruncated(page: Page) {
export async function withButtonShowcase(
page: Page,
info: TestInfo,
run: (buttons: {
openWideMenusAndPopovers(): Promise<void>;
runAndUpdateActions(): Promise<void>;
openCompactSheets(): Promise<void>;
useCompactWorkspaceMenu(): Promise<void>;
hideAndUnloadOpenButtons(): Promise<void>;
verifyObservationLifetime(): Promise<void>;
}) => Promise<void>,
@@ -284,6 +354,12 @@ export async function withButtonShowcase(
.getByTestId("plugin-button-chevron"),
).toHaveCount(0);
await expectSurfaceVisible(page);
await captureSchemes(
page,
info,
"desktop-wide-header",
page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
);
await press(page, "Header checks");
await expect(
page.getByRole("menuitem", { name: "Run checks", exact: true }),
@@ -303,6 +379,12 @@ export async function withButtonShowcase(
await expect(
page.getByRole("menuitem", { name: "Production locked", exact: true }),
).toBeDisabled();
await captureSchemes(
page,
info,
"desktop-overflow",
page.getByRole("menuitem", { name: "Production locked", exact: true }),
);
await expectSurfaceVisible(
page,
page.getByRole("menuitem", { name: "Production locked", exact: true }),
@@ -372,7 +454,19 @@ export async function withButtonShowcase(
await expectSurfaceVisible(page);
await command(page, "Enable example buttons");
await command(page, "Use icon-only header");
await expect(
page.getByRole("button", { name: "Deploy application", exact: true }),
).toHaveText("");
await expectSurfaceVisible(page);
await expect(
page.getByText("Deployment failed. Try again.", { exact: true }),
).toHaveCount(0);
await captureSchemes(
page,
info,
"desktop-icon-only-header",
page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
);
await command(page, "Hide review button");
await expect(page.getByRole("button", { name: "Run review", exact: true })).toHaveCount(
0,
@@ -383,7 +477,7 @@ export async function withButtonShowcase(
);
}),
openCompactSheets: () =>
test.step("compact header omits plugin actions while composer triggers open sheets", async () => {
test.step("compact header leaves plugin actions to the workspace menu while composer triggers open sheets", async () => {
await page.setViewportSize(COMPACT);
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(0);
await expect(
@@ -422,6 +516,98 @@ export async function withButtonShowcase(
);
await choose(page, "Run checks");
}),
useCompactWorkspaceMenu: () =>
test.step("compact workspace menu carries header buttons with their actions, menus, and content", async () => {
await openCompactWorkspaceMenu(page);
await expectWorkspaceMenuRows(page, [
"Deploy application",
"Header checks",
"Header status",
"Open deployment logs",
"Production locked",
"Copy workspace path",
]);
await expect(workspaceMenuRow(page, "Production locked")).toBeDisabled();
// Deploy lost its label to "Use icon-only header" and logs never had one; rows read titles.
await expect(workspaceMenuRow(page, "Deploy application")).toHaveText(
"Deploy application",
);
await expect(workspaceMenuRow(page, "Open deployment logs")).toHaveText(
"Open deployment logs",
);
await captureSchemes(page, info, "compact-root-icon-only", compactWorkspaceMenu(page));
await showcase.setTitle("Compact checks");
await expect(workspaceMenuRow(page, "Deploy application")).toHaveCount(0);
await showcase.setTitle("Button showcase");
await expect(workspaceMenuRow(page, "Deploy application")).toBeVisible();
await chooseInWorkspaceMenu(page, "Deploy application");
await expectWorkspaceMenuClosed(page);
await expect(
page.getByText("Deployment failed. Try again.", { exact: true }),
).toBeVisible();
await openCompactWorkspaceMenu(page);
await chooseInWorkspaceMenu(page, "Deploy application");
await expectWorkspaceMenuClosed(page);
await openCompactWorkspaceMenu(page);
await expect(workspaceMenuRow(page, "Deploy application")).toBeDisabled();
await captureSchemes(page, info, "compact-row-states", compactWorkspaceMenu(page));
await closeCompactWorkspaceMenu(page);
await commandFromCompact(page, "Finish deployment");
await openCompactWorkspaceMenu(page);
await expect(workspaceMenuRow(page, "Deploy application")).toBeEnabled();
await captureSchemes(page, info, "compact-root", compactWorkspaceMenu(page));
await chooseInWorkspaceMenu(page, "Header checks");
await expectWorkspaceMenuRows(page, ["Run checks", "Environment", "Deployment details"]);
await captureSchemes(page, info, "compact-header-checks", compactWorkspaceMenu(page));
await goBackInMenu(page);
await expect(workspaceMenuRow(page, "Header status")).toBeVisible();
await chooseInWorkspaceMenu(page, "Header checks");
await chooseInWorkspaceMenu(page, "Environment");
await expectWorkspaceMenuRows(page, ["Staging", "Production"]);
await expect(workspaceMenuRow(page, "Production")).toBeDisabled();
await captureSchemes(page, info, "compact-environment", compactWorkspaceMenu(page));
await goBackInMenu(page);
await expect(workspaceMenuRow(page, "Run checks")).toBeVisible();
await goBackInMenu(page);
await expect(workspaceMenuRow(page, "Header checks")).toBeVisible();
await chooseInWorkspaceMenu(page, "Header checks");
await chooseInWorkspaceMenu(page, "Deployment details");
await expectCompactDetails(page);
await captureSchemes(
page,
info,
"compact-deployment-details",
compactWorkspaceMenu(page),
);
await goBackInMenu(page);
await expect(workspaceMenuRow(page, "Run checks")).toBeVisible();
await chooseInWorkspaceMenu(page, "Deployment details");
await expectCompactDetails(page);
await press(page, "Close deployment details");
await expectWorkspaceMenuClosed(page);
await openCompactWorkspaceMenu(page);
await chooseInWorkspaceMenu(page, "Header status");
await expectCompactDetails(page);
await captureSchemes(page, info, "compact-header-status", compactWorkspaceMenu(page));
await press(page, "Fail button content");
await expect(
compactWorkspaceMenu(page).getByText("Plugin failed: Button content failed", {
exact: true,
}),
).toBeVisible();
await goBackInMenu(page);
await expect(workspaceMenuRow(page, "Header status")).toBeVisible();
await chooseInWorkspaceMenu(page, "Open deployment logs");
await expectCompactDetails(page);
await captureSchemes(page, info, "compact-logs-popover", compactWorkspaceMenu(page));
await press(page, "Close deployment details");
await expectWorkspaceMenuClosed(page);
}),
hideAndUnloadOpenButtons: () =>
test.step("hiding an open button and unloading a plugin remove its surfaces", async () => {
await page.setViewportSize(WIDE);
@@ -454,6 +640,12 @@ export async function withButtonShowcase(
0,
);
await expect(page.getByText("All systems operational", { exact: true })).toHaveCount(0);
await page.setViewportSize(COMPACT);
await openCompactWorkspaceMenu(page);
await expect(workspaceMenuRow(page, "Header checks")).toHaveCount(0);
await expect(workspaceMenuRow(page, "Header status")).toHaveCount(0);
await closeCompactWorkspaceMenu(page);
await page.setViewportSize(WIDE);
await showcase.setTitle("Healthy app after plugin unload");
await expect(page.getByTestId("workspace-header-title")).toHaveText(
"Healthy app after plugin unload",
@@ -0,0 +1,164 @@
import { hideDeveloperToasts } from "../developer-ui.mjs";
import { seedWorkspace } from "./seed-client";
import { buildAgentRoute } from "./mock-agent";
import { openCommandCenter } from "./command-center";
import type { TestInfo, BrowserContext } from "@playwright/test";
import { expect, type Page } from "../fixtures";
import { gotoAppShell, openSettings } from "./app";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { copyPluginExample } from "./plugin-fixture";
async function chooseChinese(page: Page) {
await openSettings(page);
await page.getByRole("button", { name: "System", exact: true }).click();
await page.getByRole("menuitem", { name: "简体中文 - Simplified Chinese", exact: true }).click();
await page.goBack();
}
async function chooseEnglish(page: Page) {
await page.getByTestId("sidebar-settings").click();
await page.getByRole("button", { name: "简体中文", exact: true }).click();
await page.getByRole("menuitem", { name: "English - 英语", exact: true }).click();
await page.goBack();
}
async function capture(page: Page, info: TestInfo, name: string) {
const file = info.outputPath(`${name}.png`);
await page.screenshot({ path: file, animations: "disabled" });
await info.attach(name, { path: file, contentType: "image/png" });
}
export async function verifyPluginTranslations(page: Page, info: TestInfo) {
const example = await copyPluginExample("translations");
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const config = await client.getDaemonConfig();
const browser = page.context().browser();
if (!browser) throw new Error("Translation isolation requires a browser");
let secondContext: BrowserContext | undefined;
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(example.directory);
await page.setViewportSize({ width: 1440, height: 1000 });
await gotoAppShell(page);
await page.getByRole("button", { name: "Workspace notes", exact: true }).click();
await expect(page.getByText("App language: en", { exact: true })).toBeVisible();
await page
.getByRole("textbox", { name: "Your draft", exact: true })
.fill("Remember the language switch");
await capture(page, info, "01-english-draft");
await page.getByRole("button", { name: "Preview draft", exact: true }).click();
await expect(page.getByRole("button", { name: "Close preview", exact: true })).toBeVisible();
await capture(page, info, "02-english-preview");
await page.getByRole("button", { name: "Close preview", exact: true }).click();
secondContext = await browser.newContext({
storageState: await page.context().storageState(),
viewport: { width: 1440, height: 1000 },
});
const second = await secondContext.newPage();
await hideDeveloperToasts(second);
await second.goto(page.url());
await expect(second.getByText("App language: en", { exact: true })).toBeVisible();
await chooseChinese(second);
await expect(second.getByText("应用语言:zh-CN", { exact: true })).toBeVisible();
await second
.getByRole("textbox", { name: "你的草稿", exact: true })
.fill("A separate client draft");
await expect(page.getByText("App language: en", { exact: true })).toBeVisible();
await expect(page.getByTestId("plugin-surface-title")).toHaveText("Workspace notes");
await capture(second, info, "03-independent-chinese-client");
await chooseChinese(page);
await expect(page.getByRole("textbox", { name: "你的草稿", exact: true })).toHaveValue(
"Remember the language switch",
);
await expect(page.getByTestId("plugin-surface-title")).toHaveText("工作区笔记");
await expect(page.getByText("应用语言:zh-CN", { exact: true })).toBeVisible();
await expect(
page.getByText("This English-only message demonstrates fallback.", { exact: true }),
).toBeVisible();
await capture(page, info, "04-chinese-draft-preserved");
await page.getByRole("button", { name: "预览草稿", exact: true }).click();
await expect(page.getByText("草稿预览", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "关闭预览", exact: true })).toBeVisible();
await capture(page, info, "05-chinese-preview");
await page.getByRole("button", { name: "关闭预览", exact: true }).click();
await chooseEnglish(page);
await expect(page.getByRole("textbox", { name: "Your draft", exact: true })).toHaveValue(
"Remember the language switch",
);
await expect(page.getByTestId("plugin-surface-title")).toHaveText("Workspace notes");
await expect(second.getByRole("textbox", { name: "你的草稿", exact: true })).toHaveValue(
"A separate client draft",
);
await capture(page, info, "06-return-to-english-draft-preserved");
await page.getByRole("button", { name: "Add note", exact: true }).click();
await expect(page.getByText("1 note", { exact: true })).toBeVisible();
await page.getByRole("textbox", { name: "Your draft", exact: true }).fill("Second note");
await page.getByRole("button", { name: "Add note", exact: true }).click();
await expect(page.getByText("2 notes", { exact: true })).toBeVisible();
} finally {
await secondContext?.close().catch(() => undefined);
await client.removePlugin("translation-notes").catch(() => undefined);
await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled });
await client.close();
await example.cleanup();
}
}
export async function verifyPluginToolTranslations(page: Page, info: TestInfo) {
const workspace = await seedWorkspace({
repoPrefix: "translation-tools-",
title: "Notes translation QA",
});
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Notes",
model: "ten-second-stream",
modeId: "load-test",
});
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const config = await client.getDaemonConfig();
const example = await copyPluginExample("translations");
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(example.directory);
await page.setViewportSize({ width: 1440, height: 1000 });
await gotoAppShell(page);
await page.goto(buildAgentRoute(workspace.workspaceId, agent.id));
const commandCenter = await openCommandCenter(page);
await commandCenter.getByTestId("command-center-input").fill("Show notes tools");
await commandCenter.getByRole("button", { name: "Show notes tools", exact: true }).click();
const tools = page
.getByRole("toolbar", { name: "Workspace actions", exact: true })
.getByRole("button", { name: "Notes tools", exact: true });
await tools.hover();
await expect(page.getByRole("tooltip")).toContainText("Notes tools");
await capture(page, info, "07-english-tooltip");
await tools.click();
await page.getByRole("menuitem", { name: "Open notes panel", exact: true }).click();
await page
.getByRole("textbox", { name: "Your draft", exact: true })
.fill("A workspace panel draft");
await chooseChinese(page);
await expect(page.getByRole("textbox", { name: "你的草稿", exact: true })).toHaveValue(
"A workspace panel draft",
);
const translatedTools = page
.getByRole("toolbar")
.getByRole("button", { name: "笔记工具", exact: true });
await translatedTools.hover();
await expect(page.getByRole("tooltip")).toContainText("笔记工具");
await capture(page, info, "08-chinese-tooltip-and-panel-draft");
await translatedTools.click();
await expect(page.getByRole("menuitem", { name: "打开笔记面板", exact: true })).toBeVisible();
await capture(page, info, "09-chinese-menu");
await page.keyboard.press("Escape");
} finally {
await client.removePlugin("translation-notes").catch(() => undefined);
await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled });
await client.close();
await example.cleanup();
await workspace.cleanup();
}
}
@@ -44,6 +44,7 @@ export interface SeedDaemonClient {
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
entries: SeedWorkspaceDescriptor[];
}>;
setWorkspaceTitle(workspaceId: string, title: string | null): Promise<{ title: string | null }>;
setWorkspacePinned(workspaceId: string, pinned: boolean): Promise<{ pinnedAt: string | null }>;
clearWorkspaceAttention(workspaceId: string): Promise<void>;
setWorkspaceLabel(input: {
@@ -147,6 +148,7 @@ export interface SeedDaemonClient {
predicate: (snapshot: { status: string }) => boolean,
timeout?: number,
): Promise<{ status: string }>;
cancelAgent(agentId: string): Promise<void>;
sendAgentMessage(agentId: string, text: string): Promise<void>;
waitForFinish(
agentId: string,
@@ -1,5 +1,6 @@
import { expect, type Page } from "@playwright/test";
import { getServerId } from "./server-id";
import { projectEquivalenceViewKey } from "./project-view-key";
interface ContextMenuAnchor {
x: number;
@@ -295,3 +296,12 @@ export async function expectMobileExplorerHidden(page: Page): Promise<void> {
timeout: 5_000,
});
}
export async function revealSidebarProjectWorkspaces(
page: Page,
projectKey: string,
): Promise<void> {
await page
.getByTestId(`sidebar-project-show-more-${projectEquivalenceViewKey(projectKey)}`)
.click();
}
+153 -2
View File
@@ -1,3 +1,7 @@
import { connectDaemonClient } from "./daemon-client-loader";
import { startIsolatedHostDaemon } from "./isolated-host-daemon";
import { seedWorkspace } from "./seed-client";
import { getE2EDaemonPort } from "./daemon-port";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { expect, type Page } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
@@ -166,7 +170,7 @@ export async function expectSubagentRowGone(page: Page, childId: string): Promis
}
export async function expectManagedSubagentUnarchived(
workspace: Pick<SeededWorkspace, "client">,
workspace: { client: Pick<SeededWorkspace["client"], "fetchAgent"> },
childId: string,
): Promise<void> {
await expect(workspace.client.fetchAgent({ agentId: childId })).resolves.toMatchObject({
@@ -175,7 +179,7 @@ export async function expectManagedSubagentUnarchived(
}
export async function expectManagedSubagentArchived(
workspace: Pick<SeededWorkspace, "client">,
workspace: { client: Pick<SeededWorkspace["client"], "fetchAgent"> },
childId: string,
): Promise<void> {
await expect
@@ -320,3 +324,150 @@ export async function detachSubagentFromTrack(page: Page, childId: string): Prom
await expect(detachButton).toBeVisible({ timeout: 30_000 });
await detachButton.click();
}
/** The primary children come from the real agent MCP endpoint on A. */
export async function delegateFleetThroughMcp() {
const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
const { StreamableHTTPClientTransport } =
await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
const { z } = await import("zod");
type DaemonClient = import("@getpaseo/client/internal/daemon-client").DaemonClient;
const workspace = await seedWorkspace({ repoPrefix: "fleet-mcp-" });
const destination = await startIsolatedHostDaemon(`fleet_${crypto.randomUUID()}`);
const a = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "fleet-seed-A" });
const b = await connectDaemonClient<DaemonClient>({
clientIdPrefix: "fleet-observe-B",
port: destination.port,
});
const serverId = a.getLastServerInfoMessage()!.serverId;
const mcp = new Client({ name: "fleet-parent", version: "1" });
const close = async () => {
await mcp.close();
await Promise.allSettled([a.close(), b.close()]);
await destination.close();
await workspace.cleanup();
};
const call = async (name: string, args: Record<string, unknown> = {}) => {
const response = await mcp.callTool({ name, arguments: args });
expect(response, name).not.toMatchObject({ isError: true });
return z.record(z.string(), z.unknown()).parse(response.structuredContent);
};
try {
const parent = await a.createAgent({
provider: "mock",
model: "e2e-fast-stream",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Fleet parent",
modeId: "load-test",
});
const registration = await a.addHost({
connection: `127.0.0.1:${destination.port}`,
label: "Fleet B",
});
await expect
.poll(
async () =>
(await a.listHosts()).hosts.find(
(row) => row.registrationId === registration.registrationId,
)?.status,
)
.toBe("online");
await mcp.connect(
new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${getE2EDaemonPort()}/mcp/agents?callerAgentId=${parent.id}`),
),
);
expect(await call("list_hosts")).toMatchObject({
hosts: expect.arrayContaining([
expect.objectContaining({ serverId: destination.serverId, label: "Fleet B" }),
]),
});
const target = await call("create_workspace", {
serverId: destination.serverId,
isolation: "local",
path: workspace.repoPath,
title: "B workspace",
});
expect(await call("list_workspaces", { serverId: destination.serverId })).toMatchObject({
workspaces: expect.arrayContaining([
expect.objectContaining({ workspaceId: target.workspaceId }),
]),
});
const createChild = async (targetServerId: string, workspaceId: string, title: string) => {
const result = await call("create_agent", {
serverId: targetServerId,
workspaceId,
title,
provider: "mock/e2e-fast-stream",
settings: { modeId: "load-test" },
initialPrompt: `Finish ${title}`,
});
return {
serverId: targetServerId,
agentId: z.string().parse(result.agentId),
workspaceId,
title,
};
};
const local = await createChild(serverId, workspace.workspaceId, "MCP child on A");
const remote = await createChild(
destination.serverId,
z.string().parse(target.workspaceId),
"MCP child on B",
);
// Existing SDK reservation creates unrelated equal-ID fixtures; primary children remain MCP-created.
await b.createAgent({
agentId: local.agentId,
provider: "mock",
model: "e2e-fast-stream",
cwd: workspace.repoPath,
workspaceId: String(target.workspaceId),
title: "Unrelated on B",
modeId: "load-test",
});
await a.createAgent({
agentId: remote.agentId,
provider: "mock",
model: "e2e-fast-stream",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Unrelated on A",
modeId: "load-test",
});
return {
workspace,
destination,
parent,
serverId,
local,
remote,
a,
b,
close,
async expectCompletion(child: typeof local) {
await expect
.poll(async () => {
const timeline = await a.fetchAgentTimeline(parent.id, {
direction: "tail",
limit: 100,
});
return timeline.entries
.flatMap(({ item }) =>
item.type === "tool_call" && item.agentMessage ? [item.agentMessage] : [],
)
.filter(
(message) =>
message.event === "finished" &&
message.sender.id === child.agentId &&
message.text.includes(`Server: ${child.serverId}`),
).length;
})
.toBe(1);
},
};
} catch (error) {
await close();
throw error;
}
}
@@ -68,8 +68,8 @@ export async function navigateToTerminal(
const workspaceRoute = buildTerminalWorkspaceUrl(input.workspaceId, input.terminalId);
await page.goto(workspaceRoute);
// The workspace layout consumes `?open=...`, returns null during the effect,
// then replaces the URL with the clean workspace route after preparing the tab.
// The route seeds the legacy URL target into the pending-open model, then
// removes the query. Tab readiness below waits for that intent to be consumed.
// On CI, Expo Router's rootNavigationState may take time to initialize,
// so we allow a generous timeout here.
const cleanWorkspaceRoute = buildWorkspaceUrl(input.workspaceId);
@@ -86,7 +86,8 @@ export async function navigateToTerminal(
await terminalTab.waitFor({ state: "visible", timeout: 30_000 });
await terminalTab.click();
const terminalSurface = page.locator('[data-testid="terminal-surface"]');
// Inactive terminal tabs retain their surfaces; interact with the selected tab.
const terminalSurface = page.getByTestId("terminal-surface").filter({ visible: true });
await terminalSurface.waitFor({ state: "visible", timeout: 15_000 });
// Wait for loading overlay to disappear (terminal attached)
@@ -301,3 +301,10 @@ export async function refreshAllUsage(page: Page): Promise<void> {
export async function openCompactSidebar(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
}
export async function expectCompactSidebarOpen(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Close menu", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
);
}
@@ -1,4 +1,4 @@
import type { Page } from "@playwright/test";
import { expect, type Page } from "@playwright/test";
import { z } from "zod";
import { WorkspaceLayoutPersistedStateSchema } from "../../../src/stores/workspace-layout-storage";
import {
@@ -61,3 +61,26 @@ export async function seedCorruptedWorkspaceLayout(page: Page, workspaceId: stri
JSON.stringify(stored),
);
}
export async function expectAgentAbsentFromWorkspaceLayout(
page: Page,
input: { serverId: string; workspaceId: string; agentId: string },
): Promise<void> {
await expect
.poll(async () => {
const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state"));
if (!raw) throw new Error("Workspace layout was not persisted");
const stored = z
.object({ state: WorkspaceLayoutPersistedStateSchema })
.parse(JSON.parse(raw));
const key = `${input.serverId}:${input.workspaceId}`;
const layout = stored.state.layoutByWorkspace[key];
const pins = stored.state.pinnedAgentIdsByWorkspace?.[key] ?? [];
// The persisted tree is validated above; inspect all panes including hidden ones.
return {
containsAgent: JSON.stringify(layout).includes(input.agentId),
pinned: pins.includes(input.agentId),
};
})
.toEqual({ containsAgent: false, pinned: false });
}
@@ -376,3 +376,7 @@ export function observeSetupPanelTraffic(page: Page) {
},
};
}
export function setupCommandOutput(detail: { commands: Array<{ log: string }> }): string {
return detail.commands.map((command) => command.log).join("\n");
}
+3
View File
@@ -15,6 +15,9 @@
}
},
"production": {
"ios": {
"image": "macos-tahoe-26.6-xcode-27.1"
},
"env": {
"APP_VARIANT": "production"
},
+2
View File
@@ -9,6 +9,8 @@ import "@expo/metro-runtime";
// oxlint-disable-next-line import/no-unassigned-import -- Preserve Expo's entry side effects.
import "expo-router/build/fast-refresh";
import { renderRootComponent } from "expo-router/build/renderRootComponent";
import { startWatchdog } from "./src/watchdog";
import { RootApp } from "./src/root-app";
startWatchdog();
renderRootComponent(RootApp);
+10
View File
@@ -5,6 +5,7 @@ const path = require("path");
const projectRoot = __dirname;
const appNodeModulesRoot = path.resolve(projectRoot, "node_modules");
const appSrcRoot = path.resolve(projectRoot, "src");
const isWatchdogBuild = process.env.PASEO_WATCHDOG_BUILD === "1";
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
const fdroidModuleOverrides = {
"expo-camera": path.resolve(appSrcRoot, "fdroid/expo-camera.tsx"),
@@ -16,6 +17,7 @@ const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
.toLowerCase();
const config = getDefaultConfig(projectRoot);
config.cacheVersion = isWatchdogBuild ? "watchdog" : "standard";
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
// Keep app exports deterministic across dev machines and CI. Metro's Watchman
@@ -75,6 +77,14 @@ function resolveWithCustomWebOverlay(context, moduleName, platform) {
}
config.resolver.resolveRequest = (context, moduleName, platform) => {
if (
isWatchdogBuild &&
platform === "android" &&
/[/\\]Renderer[/\\]shims[/\\]/.test(context.originModulePath) &&
/implementations\/(ReactFabric|ReactNativeRenderer)-prod$/.test(moduleName)
) {
return defaultResolveRequest(context, moduleName.replace(/-prod$/, "-profiling"), platform);
}
if (isFdroidBuild && platform === "android" && fdroidModuleOverrides[moduleName]) {
return resolveWithCustomWebOverlay(context, fdroidModuleOverrides[moduleName], platform);
}
@@ -13,3 +13,7 @@ useDefaultAndroidSdkVersions()
android {
namespace "sh.paseo.trace"
}
dependencies {
testImplementation "junit:junit:4.13.2"
}
@@ -0,0 +1,77 @@
package sh.paseo.trace
import android.content.Context
import android.os.SystemClock
import android.util.Log
import org.json.JSONObject
import java.util.UUID
import java.util.concurrent.ArrayBlockingQueue
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
/** All serialization and storage belongs to the worker, never the JS/UI threads. */
internal class DiagnosticRecorder(private val context: Context, private val metadata: Map<String, String>) {
private data class Event(val name: String, val data: Map<String, String>, val monoMs: Long = SystemClock.elapsedRealtime(), val wallMs: Long = System.currentTimeMillis())
private val queue = ArrayBlockingQueue<Event>(2048)
private val dropped = AtomicLong()
private val worker = Executors.newSingleThreadScheduledExecutor()
private val session = UUID.randomUUID().toString()
@Volatile private var failed = false
private val watchdog = HeartbeatWatchdog(SystemClock.elapsedRealtime(), ::record)
private val log = RotatingLog(DownloadsLogStorage(context, session), { part ->
serialize(Event("file.start", metadata + mapOf("schema" to "1", "part" to "$part")))
}, { error ->
failed = true
queue.clear()
Log.e("PaseoDiagnostics", "Diagnostic recording stopped: storage unavailable", error)
})
init {
record("session.start", metadata + mapOf("session" to session, "schema" to "1", "heartbeatMs" to "${HeartbeatTiming.intervalMs}", "stallThresholdMs" to "${HeartbeatTiming.overdueMs}"))
worker.scheduleWithFixedDelay({ drain() }, 0, HeartbeatTiming.intervalMs, TimeUnit.MILLISECONDS)
}
fun record(name: String, data: Map<String, String>) {
if (failed) return
// Limit native memory even if an instrumentation caller accidentally supplies large fields.
val bounded = data.entries.take(24).associate { it.key.take(80) to it.value.take(256) }
if (!queue.offer(Event(name.take(120), bounded))) dropped.incrementAndGet()
}
fun heartbeat() = watchdog.heartbeat(SystemClock.elapsedRealtime())
fun active(value: Boolean) = watchdog.active(value, SystemClock.elapsedRealtime())
private fun drain() {
if (failed) return
watchdog.check(SystemClock.elapsedRealtime())
val lost = dropped.getAndSet(0)
if (lost > 0) write(Event("recorder.dropped", mapOf("count" to "$lost")))
var remaining = 2048
while (remaining-- > 0 && !failed) {
val event = queue.poll() ?: break
write(event)
}
log.finishBatch()
}
private fun write(event: Event) = log.write(serialize(event))
private fun serialize(event: Event): ByteArray = (JSONObject().apply {
put("event", event.name)
put("session", session)
put("wallMs", event.wallMs)
put("monoMs", event.monoMs)
put("data", JSONObject(event.data))
}.toString() + "\n").toByteArray(Charsets.UTF_8)
fun close() {
worker.execute {
record("session.end", emptyMap())
drain()
log.finishBatch()
}
worker.shutdown()
}
}
@@ -0,0 +1,36 @@
package sh.paseo.trace
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.provider.MediaStore
import java.io.OutputStream
internal class DownloadsLogStorage(private val context: Context, private val session: String) : LogStorage {
private val resolver = context.contentResolver
private val collection = MediaStore.Downloads.EXTERNAL_CONTENT_URI
private val directory = "Download/Paseo diagnostics/"
override fun create(part: Int): String {
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, "paseo-diagnostics-${System.currentTimeMillis()}-${session.take(8)}-$part.jsonl")
put(MediaStore.MediaColumns.MIME_TYPE, "application/x-ndjson")
put(MediaStore.MediaColumns.RELATIVE_PATH, directory)
}
return checkNotNull(resolver.insert(collection, values)).toString()
}
override fun append(id: String): OutputStream =
checkNotNull(resolver.openOutputStream(Uri.parse(id), "wa")).buffered(64 * 1024)
override fun newest(): List<String> = buildList {
resolver.query(collection, arrayOf(MediaStore.MediaColumns._ID),
"${MediaStore.MediaColumns.RELATIVE_PATH} = ? AND ${MediaStore.MediaColumns.OWNER_PACKAGE_NAME} = ?",
arrayOf(directory, context.packageName), "${MediaStore.MediaColumns._ID} DESC")?.use { cursor ->
while (cursor.moveToNext()) add(ContentUris.withAppendedId(collection, cursor.getLong(0)).toString())
}
}
override fun delete(id: String) { resolver.delete(Uri.parse(id), null, null) }
}
@@ -0,0 +1,38 @@
package sh.paseo.trace
internal object HeartbeatTiming {
const val intervalMs = 250L
const val overdueMs = 500L
const val gapMs = intervalMs + overdueMs
}
/** Monotonic time is supplied by the owner, so suspension and recovery are deterministic. */
internal class HeartbeatWatchdog(startMs: Long, private val emit: (String, Map<String, String>) -> Unit) {
private var foreground = true
private var lastHeartbeat = startMs
private var overdue = false
@Synchronized fun heartbeat(now: Long) {
if (foreground && now - lastHeartbeat > HeartbeatTiming.gapMs) {
emit("js.stall.recovered", mapOf("heartbeatGapMs" to "${now - lastHeartbeat}", "overdueMs" to "${now - lastHeartbeat - HeartbeatTiming.intervalMs}"))
}
lastHeartbeat = now
overdue = false
}
@Synchronized fun active(value: Boolean, now: Long) {
if (foreground == value) return
emit("app.lifecycle", mapOf("state" to if (value) "foreground" else "background", "stallOpen" to "$overdue"))
foreground = value
lastHeartbeat = now
overdue = false
}
@Synchronized fun check(now: Long) {
val gap = now - lastHeartbeat
if (foreground && gap > HeartbeatTiming.gapMs && !overdue) {
emit("js.stall.started", mapOf("heartbeatGapMs" to "$gap", "lastHeartbeatMonoMs" to "$lastHeartbeat"))
overdue = true
}
}
}
@@ -5,9 +5,23 @@ import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class PaseoNativeTraceModule : Module() {
private var recorder: DiagnosticRecorder? = null
override fun definition() = ModuleDefinition {
Name("PaseoNativeTrace")
Function("startRecorder") { metadata: Map<String, String> ->
if (recorder == null) {
appContext.reactContext?.applicationContext?.let { recorder = DiagnosticRecorder(it, metadata) }
}
HeartbeatTiming.intervalMs
}
Function("record") { name: String, data: Map<String, String> -> recorder?.record(name, data) }
Function("heartbeat") { recorder?.heartbeat() }
OnActivityEntersForeground { recorder?.active(true) }
OnActivityEntersBackground { recorder?.active(false) }
OnDestroy { recorder?.close(); recorder = null }
Function("beginSection") { name: String ->
Trace.beginSection(name.take(127))
}
@@ -0,0 +1,61 @@
package sh.paseo.trace
import java.io.OutputStream
/** Storage supplies installation-owned files, newest first. */
internal interface LogStorage {
fun create(part: Int): String
fun append(id: String): OutputStream
fun newest(): List<String>
fun delete(id: String)
}
/** Owns file identity, bounded retention and the terminal storage-failure state. */
internal class RotatingLog(
private val storage: LogStorage,
private val header: (Int) -> ByteArray,
private val onFailure: (Exception) -> Unit,
private val maxBytes: Int = 5 * 1024 * 1024,
) {
private var current: String? = null
private var output: OutputStream? = null
private var size = 0
private var part = 0
var failed = false
private set
fun write(bytes: ByteArray) = attempt {
if (current == null || size + bytes.size > maxBytes) {
finishBatch()
if (failed) return@attempt
val nextPart = part++
current = storage.create(nextPart)
output = storage.append(checkNotNull(current))
val first = header(nextPart)
output!!.write(first)
size = first.size
storage.newest().drop(8).forEach(storage::delete)
}
if (output == null) output = storage.append(checkNotNull(current))
output!!.write(bytes)
size += bytes.size
}
// Closing publishes size and modification time in Android Files.
fun finishBatch() = attempt {
output?.close()
output = null
}
private fun attempt(action: () -> Unit) {
if (failed) return
try {
action()
} catch (error: Exception) {
failed = true
runCatching { output?.close() }
output = null
onFailure(error)
}
}
}
@@ -0,0 +1,40 @@
package sh.paseo.trace
import org.junit.Assert.assertEquals
import org.junit.Test
class HeartbeatWatchdogTest {
@Test fun recordsOneStartAndRecoveryWithMeasuredGap() {
val events = mutableListOf<Pair<String, Map<String, String>>>()
val watchdog = HeartbeatWatchdog(0) { name, fields -> events.add(name to fields) }
watchdog.heartbeat(250)
watchdog.check(1000)
assertEquals(emptyList<Pair<String, Map<String, String>>>(), events)
watchdog.check(1001)
watchdog.check(2000)
watchdog.heartbeat(2250)
assertEquals(listOf(
"js.stall.started" to mapOf("heartbeatGapMs" to "751", "lastHeartbeatMonoMs" to "250"),
"js.stall.recovered" to mapOf("heartbeatGapMs" to "2000", "overdueMs" to "1750")
), events)
}
@Test fun backgroundAndResumeDoNotLookLikeStalls() {
val events = mutableListOf<String>()
val watchdog = HeartbeatWatchdog(0) { name, _ -> events.add(name) }
watchdog.active(false, 100)
watchdog.check(300000)
watchdog.heartbeat(300100)
watchdog.active(true, 400000)
watchdog.heartbeat(400250)
watchdog.check(400500)
assertEquals(listOf("app.lifecycle", "app.lifecycle"), events)
}
@Test fun recoveryIsReportedEvenIfNativeSamplingMissedTheGap() {
val events = mutableListOf<String>()
val watchdog = HeartbeatWatchdog(0) { name, _ -> events.add(name) }
watchdog.heartbeat(900)
assertEquals(listOf("js.stall.recovered"), events)
}
}
@@ -0,0 +1,59 @@
package sh.paseo.trace
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.OutputStream
import org.junit.Assert.*
import org.junit.Test
class RotatingLogTest {
private class MemoryStorage : LogStorage {
val files = linkedMapOf<String, ByteArrayOutputStream>()
var fail = false
var opens = 0
override fun create(part: Int): String {
val id = "part-$part"
files[id] = ByteArrayOutputStream()
return id
}
override fun append(id: String): OutputStream {
opens++
if (fail) throw IOException("disk full")
return files.getValue(id)
}
override fun newest() = files.keys.reversed()
override fun delete(id: String) { files.remove(id) }
}
@Test fun rotatesAtByteLimitAndKeepsEightIncludingCurrent() {
val storage = MemoryStorage()
val log = RotatingLog(storage, { "header:$it\n".toByteArray() }, { throw it }, 20)
repeat(10) { log.write("123456789012".toByteArray()); log.finishBatch() }
assertEquals((2..9).map { "part-$it" }, storage.files.keys.toList())
for (part in 2..9) assertEquals("header:$part\n123456789012", storage.files.getValue("part-$part").toString("UTF-8"))
assertFalse(log.failed)
}
@Test fun reopeningBatchAppendsWithoutRepeatingHeaderAndExactLimitFits() {
val storage = MemoryStorage()
val log = RotatingLog(storage, { "H".toByteArray() }, { throw it }, 5)
log.write("12".toByteArray())
log.finishBatch()
log.write("34".toByteArray())
assertEquals(listOf("part-0"), storage.files.keys.toList())
assertEquals("H1234", storage.files.getValue("part-0").toString("UTF-8"))
log.write("5".toByteArray())
assertEquals("H5", storage.files.getValue("part-1").toString("UTF-8"))
}
@Test fun storageFailureStopsRecordingWithoutRetryStorm() {
val storage = MemoryStorage()
var failures = 0
val log = RotatingLog(storage, { byteArrayOf() }, { failures++ })
storage.fail = true
repeat(100) { log.write(byteArrayOf(1)); log.finishBatch() }
assertTrue(log.failed)
assertEquals(1, failures)
assertEquals(1, storage.opens)
}
}
+11 -7
View File
@@ -1,19 +1,19 @@
{
"name": "@getpaseo/app",
"version": "0.12.0",
"version": "0.13.0",
"private": true,
"main": "index.ts",
"scripts": {
"start": "npm run start:expo",
"start:expo": "cross-env APP_VARIANT=development expo start",
"reset-project": "node ./scripts/reset-project.js",
"eas-build-post-install": "npm --prefix ../.. run build:app-deps && npm run build:terminal-webview",
"android": "npm run android:development",
"android:development": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug",
"eas-build-post-install": "npm --prefix ../.. run build:app-deps",
"android": "npm run dev:android",
"android:development": "npm run dev:android",
"android:production": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=production expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release",
"android:release": "npm run android:production",
"android:clear": "node -e \"require('node:fs').rmSync('android', { recursive: true, force: true })\"",
"ios": "npm --prefix ../.. run build:client && expo run:ios",
"ios": "npm run dev:ios",
"ios:release": "npm --prefix ../.. run build:client && expo run:ios --configuration Release",
"web": "npm --prefix ../.. run build:client && concurrently --kill-others --names protocol,client,expo --prefix-colors yellow,blue,magenta \"npm --prefix ../.. run watch:protocol\" \"npm --prefix ../.. run watch:client\" \"npm run web:expo\"",
"web:expo": "expo start --web",
@@ -35,8 +35,12 @@
"profile:workspace-switching": "node ./scripts/profile-workspace-switching.mjs",
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs",
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs",
"build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs"
"build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs",
"dev:android": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug",
"dev:ios": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo run:ios",
"build:apk": "npm --prefix ../.. run build:apk",
"build:apk:arm64": "npm --prefix ../.. run build:apk:arm64",
"test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest --no-daemon --max-workers=2 -Dorg.gradle.parallel=false"
},
"dependencies": {
"@codemirror/commands": "6.10.4",
@@ -1,89 +0,0 @@
import esbuild from "esbuild";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const appRoot = path.resolve(__dirname, "..");
const repoRoot = path.resolve(appRoot, "../..");
const entry = path.join(appRoot, "src/terminal/webview/terminal-emulator-webview-entry.ts");
const output = path.join(appRoot, "src/terminal/webview/terminal-emulator-webview-html.ts");
async function resolveTsPath(basePath) {
const candidates = [
basePath,
`${basePath}.ts`,
`${basePath}.tsx`,
`${basePath}.js`,
`${basePath}.jsx`,
path.join(basePath, "index.ts"),
path.join(basePath, "index.tsx"),
path.join(basePath, "index.js"),
path.join(basePath, "index.jsx"),
];
for (const candidate of candidates) {
try {
const stat = await fs.stat(candidate);
if (stat.isFile()) return candidate;
} catch {
// try next candidate
}
}
return basePath;
}
const aliasPlugin = {
name: "paseo-alias",
setup(build) {
build.onResolve({ filter: /^@\// }, async (args) => ({
path: await resolveTsPath(path.join(appRoot, "src", args.path.slice(2))),
}));
build.onResolve({ filter: /^@server\// }, async (args) => ({
path: await resolveTsPath(
path.join(repoRoot, "packages/server/src", args.path.slice("@server/".length)),
),
}));
},
};
const result = await esbuild.build({
entryPoints: [entry],
bundle: true,
write: false,
format: "iife",
platform: "browser",
target: ["ios15", "chrome100"],
loader: {
".css": "text",
},
plugins: [aliasPlugin],
logLevel: "info",
});
const js = result.outputFiles[0]?.text;
if (!js) {
throw new Error("terminal webview bundle produced no JavaScript");
}
const html = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
</head>
<body>
<script>${js}</script>
</body>
</html>`;
const contents = `// Generated by packages/app/scripts/build-terminal-webview-html.mjs.
// Do not edit by hand.
export const terminalEmulatorWebViewHtml = ${JSON.stringify(html)};
`;
await fs.writeFile(output, contents);
console.log(`Wrote ${path.relative(repoRoot, output)} (${html.length} bytes)`);
@@ -0,0 +1,84 @@
import { useShallow } from "zustand/shallow";
import { useProviderIcon } from "@/components/provider-icons";
import type { PanelDescriptor } from "@/panels/panel-registry";
import { useSessionStore, selectAgentTurnPresentation, type Agent } from "@/stores/session-store";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
function formatProviderLabel(provider: Agent["provider"]): string {
if (!provider) {
return "Agent";
}
return provider
.split(/[-_\s]+/)
.filter((part) => part.length > 0)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
export function resolveAgentLabel(title: string | null | undefined): string | null {
if (typeof title !== "string") {
return null;
}
const normalized = title.trim();
if (!normalized) {
return null;
}
if (normalized.toLowerCase() === "new agent") {
return null;
}
return normalized;
}
export function useAgentDescriptor(
target: { kind: "agent"; agentId: string },
context: { serverId: string },
): PanelDescriptor {
const descriptorState = useSessionStore(
useShallow((state) => {
const session = state.sessions[context.serverId];
const agent =
session?.agents?.get(target.agentId) ?? session?.agentDetails?.get(target.agentId) ?? null;
return {
provider: agent?.provider ?? "codex",
title: agent?.title ?? null,
status: agent?.status ?? null,
pendingPermissionCount: agent?.pendingPermissions.length ?? 0,
requiresAttention: agent?.requiresAttention ?? false,
attentionReason: agent?.attentionReason ?? null,
isTurnActive: selectAgentTurnPresentation(session, target.agentId).isActive,
};
}),
);
const icon = useProviderIcon(descriptorState.provider, context.serverId);
return buildAgentDescriptor({ ...descriptorState, icon });
}
export function buildAgentDescriptor(input: {
provider: Agent["provider"];
title: string | null;
status: Agent["status"] | null;
pendingPermissionCount: number;
requiresAttention: boolean;
attentionReason: Agent["attentionReason"];
isTurnActive: boolean;
icon: PanelDescriptor["icon"];
}): PanelDescriptor {
const provider = input.provider;
const label = resolveAgentLabel(input.title);
return {
label: label ?? "",
subtitle: `${formatProviderLabel(provider)} agent`,
tooltip: label ?? `${formatProviderLabel(provider)} agent`,
titleState: label ? "ready" : "loading",
icon: input.icon,
statusBucket: input.status
? deriveSidebarStateBucket({
status: input.isTurnActive ? "running" : input.status,
pendingPermissionCount: input.pendingPermissionCount,
requiresAttention: input.requiresAttention,
attentionReason: input.attentionReason,
})
: null,
};
}
@@ -1,3 +1,4 @@
import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host";
import { runPluginClientBundle, type PluginClientRuntime } from "@/plugins/evaluate";
import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/timeline/model";
import type { InstalledPlugin } from "@/plugins/types";
@@ -61,6 +62,8 @@ function installProbe(
serverId: "host-1",
clientBundle,
lifetime: new AbortController(),
locale: "en",
i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }),
queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined,
+14 -125
View File
@@ -2,12 +2,12 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useIsExplorerSidebarOpen } from "@/workspace-tabs/explorer-sidebar";
import { ComposerEditingSessionsProvider } from "@/composer/editing-session";
import { SheetHostScope } from "@/components/ui/isolated-bottom-sheet-modal";
import "@/styles/unistyles";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { PortalProvider } from "@gorhom/portal";
import { LucideProvider } from "lucide-react-native";
import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";
import { Stack, useNavigationContainerRef, usePathname, useRouter } from "expo-router";
import {
createContext,
@@ -16,7 +16,6 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
@@ -51,11 +50,7 @@ import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-short
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
import { HostConfirmationSheet } from "@/hosts/host-confirmation-sheet";
import {
getIsElectronRuntime,
HEADER_INNER_HEIGHT,
useIsCompactFormFactor,
} from "@/constants/layout";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
import {
canDesktopAppSidebarShare,
resolveDesktopAppChromeLayout,
@@ -97,7 +92,6 @@ import { KeyboardShiftProvider } from "@/keyboard/shift";
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
import { useOpenProject } from "@/hooks/use-open-project";
import { useAppSettings } from "@/hooks/use-settings";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useOpenAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelsProvider, useIsMobilePanelActive } from "@/mobile-panels/provider";
import {
@@ -132,14 +126,9 @@ import {
parseHostWorkspaceRouteFromPathname,
parseServerIdFromPathname,
} from "@/utils/host-routes";
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { PluginCatalogSync } from "@/plugins";
import {
ensureOsNotificationPermission,
WEB_NOTIFICATION_CLICK_EVENT,
type WebNotificationClickDetail,
} from "@/utils/os-notifications";
import { NotificationOpenRouter } from "@/notification-open/react";
import { ensureOsNotificationPermission } from "@/utils/os-notifications";
polyfillCrypto();
@@ -159,114 +148,6 @@ const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
startupBlocker: { kind: "none" },
});
function PushNotificationRouter() {
const router = useRouter();
const lastHandledIdRef = useRef<string | null>(null);
const openNotification = useStableEvent((data: Record<string, unknown> | undefined) => {
const target = resolveNotificationTarget(data);
const serverId = target.serverId;
const workspaceId = target.workspaceId;
const agentId = target.agentId;
if (serverId && workspaceId && agentId) {
navigateToAgent({ serverId, workspaceId, agentId, pin: true });
return;
}
router.navigate(buildNotificationRoute(data));
});
useEffect(() => {
if (isWeb) {
let removeDesktopNotificationListener: (() => void) | null = null;
let cancelled = false;
if (getIsElectronRuntime()) {
void ensureOsNotificationPermission();
const unlistenResult = getDesktopHost()?.events?.on?.(
"notification-click",
(payload: unknown) => {
const data =
typeof payload === "object" &&
payload !== null &&
"data" in payload &&
typeof (payload as { data?: unknown }).data === "object" &&
(payload as { data?: unknown }).data !== null
? (payload as { data: Record<string, unknown> }).data
: undefined;
openNotification(data);
},
);
void Promise.resolve(unlistenResult).then((unlisten) => {
if (typeof unlisten !== "function") {
return;
}
if (cancelled) {
unlisten();
return;
}
removeDesktopNotificationListener = unlisten;
return;
});
}
const openFromWebClick = (event: Event) => {
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
event.preventDefault();
openNotification(customEvent.detail?.data);
};
window.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
return () => {
cancelled = true;
removeDesktopNotificationListener?.();
window.removeEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
};
}
Notifications.setNotificationHandler({
handleNotification: async () => ({
// When the app is open, don't show OS banners.
shouldShowAlert: false,
shouldShowBanner: false,
shouldShowList: false,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});
const openFromResponse = (response: Notifications.NotificationResponse) => {
const identifier = response.notification.request.identifier;
if (lastHandledIdRef.current === identifier) {
return;
}
lastHandledIdRef.current = identifier;
const data = response.notification.request.content.data as
| Record<string, unknown>
| undefined;
openNotification(data);
};
const subscription = Notifications.addNotificationResponseReceivedListener(openFromResponse);
void Notifications.getLastNotificationResponseAsync().then((response) => {
if (response) {
openFromResponse(response);
}
return;
});
return () => {
subscription.remove();
};
}, [openNotification]);
return null;
}
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
const client = useHostRuntimeClient(daemon.serverId);
@@ -627,7 +508,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
return (
<AppContainerLifetime compactGesturesEnabled={chromeEnabled && isCompactLayout}>
{surface}
<SheetHostScope enabled={isCompactLayout}>{surface}</SheetHostScope>
</AppContainerLifetime>
);
}
@@ -945,6 +826,9 @@ function WorkspaceRouteNavigationBridge() {
}
function AppShell() {
useEffect(() => {
if (getDesktopHost()) void ensureOsNotificationPermission();
}, []);
return (
<MobilePanelsProvider>
<HorizontalScrollProvider>
@@ -959,10 +843,15 @@ function AppShell() {
);
}
function NotificationOpenBridge() {
const { startupBlocker } = useHostRuntimeBootstrapState();
return <NotificationOpenRouter startupBlocked={startupBlocker.kind !== "none"} />;
}
function RuntimeProviders({ children }: { children: ReactNode }) {
return (
<HostRuntimeBootstrapProvider>
<PushNotificationRouter />
<NotificationOpenBridge />
<SidebarCalloutProvider>
<ProvidersWrapper>{children}</ProvidersWrapper>
</SidebarCalloutProvider>
@@ -4,6 +4,8 @@ import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-bo
import { useFetchQuery } from "@/data/query";
import { resolveAgentRoute, type AgentRouteLookup } from "@/navigation/agent-route-resolution";
import { AgentRouteResolutionView } from "@/navigation/agent-route-resolution-view";
import { useHasHydratedWorkspaces } from "@/stores/session-store-hooks";
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
import { useSessionStore } from "@/stores/session-store";
import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
@@ -83,7 +85,16 @@ function HostAgentReadyRouteContent() {
lookupQuery.isSuccess,
shouldLookupAgent,
]);
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
const hasHydratedLayout = useWorkspaceLayoutStoreHydrated();
useEffect(() => {
if (!serverId) return;
// A compact app can open this route before its sidebar has demanded the directory.
return getHostRuntimeStore().acquireDirectoryDemand(serverId);
}, [serverId]);
const resolution = resolveAgentRoute({
hasHydratedWorkspaces,
hasHydratedLayout,
serverId,
agentId,
cachedWorkspaceId: agentWorkspaceId,
@@ -106,7 +117,7 @@ function HostAgentReadyRouteContent() {
handledNavigationRef.current = navigationKey;
if (resolution.kind === "resolved") {
navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId });
navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId, pin: true });
return;
}
router.replace(resolution.kind === "invalid" ? ("/" as Href) : buildHostRootRoute(serverId));
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react";
import { useNavigation } from "expo-router/react-navigation";
import { StyleSheet, View } from "react-native";
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
import { useLocalSearchParams } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { RetainedPanel } from "@/components/retained-panel";
import {
@@ -11,7 +11,7 @@ import {
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import {
areRetainedWorkspaceSelectionListsEqual,
areWorkspaceSelectionsEqual,
@@ -33,7 +33,7 @@ import {
replaceBrowserRouteWithCanonicalHostWorkspaceRoute,
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit,
} from "@/utils/host-route-browser";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { prepareWorkspaceTab } from "@/utils/prepare-workspace-tab";
import { isNative, isWeb } from "@/constants/platform";
import { RenderProfile } from "@/utils/render-profiler";
@@ -64,6 +64,8 @@ function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarge
return { kind: "draft", draftId: openIntent.draftId };
}
// COMPAT(workspace-open-url): added in v0.12.0, remove after 2027-01-22.
// Bookmarks and older clients still send ?open=; internal navigation uses pending model state.
function stripOpenSearchParamFromBrowserUrl() {
if (!isWeb || typeof window === "undefined") {
return;
@@ -81,7 +83,9 @@ function clearConsumedOpenIntent(input: {
}) {
input.navigation.setParams({ open: undefined });
if (isWeb) {
stripOpenSearchParamFromBrowserUrl();
// Expo commits the incoming browser URL after setParams. Strip the consumed legacy input
// after that commit so it cannot be restored into the address bar and replayed on reload.
window.setTimeout(stripOpenSearchParamFromBrowserUrl, 0);
}
}
@@ -95,15 +99,9 @@ export default function HostWorkspaceIndexRoute() {
function HostWorkspaceRouteContent() {
const navigation = useNavigation();
const rootNavigationState = useRootNavigationState();
const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated();
const consumedIntentRef = useRef<string | null>(null);
const [intentConsumed, setIntentConsumed] = useState(false);
const params = useLocalSearchParams<{
serverId?: string | string[];
workspaceId?: string | string[];
}>();
const globalParams = useGlobalSearchParams<{
open?: string | string[];
}>();
const serverId = getParamValue(params.serverId);
@@ -111,14 +109,7 @@ function HostWorkspaceRouteContent() {
const workspaceId = workspaceValue
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
: "";
const openValue = getParamValue(globalParams.open);
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
const workspaceExists = useWorkspaceExists(serverId, workspaceId);
const openIntent = useMemo(() => parseWorkspaceOpenIntent(openValue), [openValue]);
const isAgentOpenIntent = openIntent?.kind === "agent";
const isOpenIntentWaitingForWorkspace = Boolean(
isAgentOpenIntent && (!hasHydratedWorkspaces || !workspaceExists),
);
const openValue = getParamValue(params.open);
useEffect(() => {
if (!serverId || !workspaceId) {
return;
@@ -126,74 +117,31 @@ function HostWorkspaceRouteContent() {
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit();
}, [serverId, workspaceId]);
useEffect(() => {
if (!openValue) {
return;
}
if (!rootNavigationState?.key) {
return;
}
if (!hasHydratedWorkspaceLayoutStore) {
return;
}
if (isOpenIntentWaitingForWorkspace) {
return;
}
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
if (consumedIntentRef.current === consumptionKey) {
clearConsumedOpenIntent({
navigation: navigation as unknown as {
setParams: (params: { open?: string | undefined }) => void;
},
});
setIntentConsumed(true);
return;
}
consumedIntentRef.current = consumptionKey;
useLayoutEffect(() => {
if (!openValue || !serverId || !workspaceId) return;
const openIntent = parseWorkspaceOpenIntent(openValue);
if (openIntent) {
prepareWorkspaceTab({
serverId,
workspaceId,
target: getOpenIntentTarget(openIntent),
pin: openIntent.kind === "agent",
});
prepareWorkspaceTab(
{
serverId,
workspaceId,
target: getOpenIntentTarget(openIntent),
pin: openIntent.kind === "agent",
},
{ openTab: useWorkspaceLayoutStore.getState().deferOpenTab },
);
}
// Expo Router's replace ignores query-param-only changes (findDivergentState
// skips search params). Strip ?open from the browser URL directly so the
// address bar reflects the clean workspace route.
clearConsumedOpenIntent({
navigation: navigation as unknown as {
setParams: (params: { open?: string | undefined }) => void;
},
});
}, [navigation, openValue, serverId, workspaceId]);
setIntentConsumed(true);
}, [
hasHydratedWorkspaceLayoutStore,
isOpenIntentWaitingForWorkspace,
navigation,
openIntent,
openValue,
rootNavigationState?.key,
serverId,
workspaceId,
]);
if (
openValue &&
!isOpenIntentWaitingForWorkspace &&
(!intentConsumed || !hasHydratedWorkspaceLayoutStore)
) {
return null;
}
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} />;
return <WorkspaceDeck />;
}
function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
function WorkspaceDeck() {
const activeSelection = useActiveWorkspaceSelection();
const [retainedSelections, setRetainedSelections] = useState<RetainedWorkspaceSelection[]>(() =>
activeSelection ? [{ selection: activeSelection, inactiveSince: null }] : [],
@@ -264,7 +212,6 @@ function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
key={getWorkspaceSelectionKey(selection)}
selection={selection}
active={active}
recoveryRequested={recoveryRequested}
onUnmountInactive={unmountWorkspaceSelection}
/>
);
@@ -277,12 +224,10 @@ function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
function WorkspaceDeckEntry({
selection,
active,
recoveryRequested,
onUnmountInactive,
}: {
selection: ActiveWorkspaceSelection;
active: boolean;
recoveryRequested: boolean;
onUnmountInactive: (selection: ActiveWorkspaceSelection) => void;
}) {
const hasHydratedWorkspaces = useHasHydratedWorkspaces(selection.serverId);
@@ -312,7 +257,6 @@ function WorkspaceDeckEntry({
serverId={selection.serverId}
workspaceId={selection.workspaceId}
isRouteFocused={active}
recoveryRequested={active && recoveryRequested}
/>
</RetainedPanel>
);
@@ -6,6 +6,7 @@ export {
export {
AssistantFileLinkResolverProvider,
useAssistantFileLinkEnvironment,
useAssistantFileLinkEnvironmentBridge,
type AssistantFileLinkResolverProviderProps,
} from "./provider";
export type { AssistantFileLinkSource } from "./resolver";
@@ -39,6 +39,18 @@ export interface AssistantFileLinkResolverContextValue {
const AssistantFileLinkResolverContext =
createContext<AssistantFileLinkResolverContextValue | null>(null);
export function useAssistantFileLinkEnvironmentBridge() {
const value = useContext(AssistantFileLinkResolverContext);
return useCallback(
(children: ReactNode) => (
<AssistantFileLinkResolverContext.Provider value={value}>
{children}
</AssistantFileLinkResolverContext.Provider>
),
[value],
);
}
export function AssistantFileLinkResolverProvider({
client,
serverId,
@@ -2,9 +2,9 @@
* @vitest-environment jsdom
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import React, { useCallback, useMemo, useState, type ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ToastApi } from "@/components/toast-host";
import type { InlinePathTarget } from "./parse";
import { AssistantFileLinkResolverProvider } from "./provider";
@@ -99,6 +99,12 @@ function createWrapper(input: { client: TestClient; openedFiles: OpenedFile[]; t
}
describe("useFileLink", () => {
// Lookups resolve after a test's last assertion; an unmounted hook cannot re-render
// once jsdom is torn down.
afterEach(() => {
cleanup();
});
it("returns the same object across no-op parent rerenders", () => {
const getDirectorySuggestions = vi.fn(async () => resolvedSuggestions([]));
const queryClient = createQueryClient();
@@ -0,0 +1,2 @@
/** Largest file the app sends through `client.uploadFile`. */
export const FILE_UPLOAD_MAX_BYTES = 50 * 1024 * 1024;
+217 -135
View File
@@ -1,3 +1,5 @@
import equal from "fast-deep-equal";
import { recordRenderProfileReasons } from "@/utils/render-profiler";
import {
FlatList,
Modal,
@@ -10,12 +12,25 @@ import {
type NativeSyntheticEvent,
type PressableStateCallbackType,
} from "react-native";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
memo,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { useTranslation } from "react-i18next";
import { Check, ChevronRight, Folder, X } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { BottomSheetFlatList, type BottomSheetFlatListMethods } from "@gorhom/bottom-sheet";
import { AgentStatusDot } from "@/components/agent-status-dot";
import { useAgentDescriptor, resolveAgentLabel } from "@/agent-presentation";
import { StatusIcon } from "@/components/status-icon";
import { useWorkspaceStatus } from "@/workspace-status";
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
import type { Theme } from "@/styles/theme";
import { HighlightedText, type HighlightedTextProps } from "@/components/ui/highlighted-text";
import { findHighlightRanges } from "@/components/ui/highlighted-text-segments";
import { MaterialFileIcon } from "@/components/material-file-icon";
@@ -69,9 +84,7 @@ import {
buildContributionSections,
filterAndRankBuiltInResults,
joinSubtitleParts,
moveActiveResultId,
PINNED_SECTION_BAND,
preserveActiveResultId,
projectCommandCenterRows,
type CommandCenterAgentResult,
type CommandCenterFileResult,
@@ -81,6 +94,7 @@ import {
type CommandCenterSearchFields,
type CommandCenterWorkspaceResult,
} from "./results";
import { createCommandCenterInteraction, type CommandCenterInteraction } from "./interaction";
import { useWorkspaceFileSearch } from "./workspace-file-search";
const ThemedBottomSheetTextInput = withUnistyles(TextInput, (theme) => ({
@@ -106,6 +120,20 @@ function agentSearchFields(result: CommandCenterAgentResult): CommandCenterSearc
return { visible: [result.title, result.subtitle], hidden: [result.agent.cwd] };
}
function createWorkspaceResultAction(serverId: string, workspaceId: string) {
return () => {
clearCommandCenterFocusRestoreElement();
navigateToWorkspace({ serverId, workspaceId });
};
}
function createAgentResultAction(serverId: string, agentId: string) {
return () => {
clearCommandCenterFocusRestoreElement();
navigateToAgent({ serverId, agentId });
};
}
/**
* Build every pinned row. Deliberately not keyed on `query`: none of this work depends on what
* was typed. Ordering belongs to `filterAndRankBuiltInResults`, which ranks on every keystroke.
@@ -115,6 +143,9 @@ function useBuiltInRows(open: boolean): {
agents: CommandCenterAgentResult[];
} {
const { t } = useTranslation();
const previousRows = useRef(
new Map<string, CommandCenterAgentResult | CommandCenterWorkspaceResult>(),
);
const { agents } = useAggregatedAgents({ demand: open });
const { projects } = useProjects({ enabled: open });
const showHost = useHosts().length > 1;
@@ -136,7 +167,22 @@ function useBuiltInRows(open: boolean): {
const iconDataByProjectViewKey = useProjectIcons({ projects: iconTargets });
return useMemo(() => {
if (!open) return { workspaces: [], agents: [] };
if (!open) {
previousRows.current.clear();
return { workspaces: [], agents: [] };
}
// Built-in IDs identify immutable navigation targets. Reuse their callbacks and unchanged
// row data so a directory update does not invalidate every mounted ResultRow.
const nextRows = new Map<string, CommandCenterAgentResult | CommandCenterWorkspaceResult>();
const preserve = <T extends CommandCenterAgentResult | CommandCenterWorkspaceResult>(
row: T,
): T => {
const previous = previousRows.current.get(row.id);
if (previous) row.run = previous.run;
const stable = previous && equal(previous, row) ? (previous as T) : row;
nextRows.set(row.id, stable);
return stable;
};
const allWorkspaces: CommandCenterWorkspaceResult[] = [];
for (const project of projects) {
const workspaceProject = {
@@ -147,23 +193,24 @@ function useBuiltInRows(open: boolean): {
for (const host of project.hosts) {
for (const workspace of host.workspaces) {
if (workspace.archivingAt) continue;
allWorkspaces.push({
kind: "workspace",
id: `workspace:${host.serverId}:${workspace.id}`,
title: workspace.title ?? workspace.name,
subtitle: joinSubtitleParts([
showHost ? host.serverName : null,
project.projectName,
workspace.currentBranch,
]),
project: workspaceProject,
changeRequestNumber: workspace.changeRequestNumber,
activityAt: workspace.statusEnteredAt,
run: () => {
clearCommandCenterFocusRestoreElement();
navigateToWorkspace({ serverId: host.serverId, workspaceId: workspace.id });
},
});
allWorkspaces.push(
preserve({
kind: "workspace",
id: `workspace:${host.serverId}:${workspace.id}`,
title: workspace.title ?? workspace.name,
subtitle: joinSubtitleParts([
showHost ? host.serverName : null,
project.projectName,
workspace.currentBranch,
]),
serverId: host.serverId,
workspaceId: workspace.id,
project: workspaceProject,
changeRequestNumber: workspace.changeRequestNumber,
activityAt: workspace.statusEnteredAt,
run: createWorkspaceResultAction(host.serverId, workspace.id),
}),
);
}
}
}
@@ -174,22 +221,20 @@ function useBuiltInRows(open: boolean): {
const workspaceTitle = agent.workspaceId
? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`)
: undefined;
return {
return preserve({
kind: "agent",
id: `agent:${agent.serverId}:${agent.id}`,
agent,
title: agent.title || t("shell.commandCenter.newAgent"),
title: resolveAgentLabel(agent.title) ?? t("workspace.tabs.loading"),
subtitle: joinSubtitleParts([
showHost ? agent.serverLabel : null,
workspaceTitle ?? shortenPath(agent.cwd),
]),
activityAt: agent.lastActivityAt,
run: () => {
clearCommandCenterFocusRestoreElement();
navigateToAgent({ serverId: agent.serverId, agentId: agent.id });
},
};
run: createAgentResultAction(agent.serverId, agent.id),
});
});
previousRows.current = nextRows;
return { workspaces: allWorkspaces, agents: agentRows };
}, [agents, iconDataByProjectViewKey, open, projects, showHost, t]);
}
@@ -227,8 +272,6 @@ interface CommandCenterState {
setQuery(query: string): void;
activeId: string | null;
rows: readonly CommandCenterListRow[];
results: readonly CommandCenterResult[];
rowIndexByResultId: ReadonlyMap<string, number>;
offsets: readonly number[];
inputRef: React.RefObject<EditingTextInputHandle | null>;
fileSearchLoading: boolean;
@@ -238,7 +281,10 @@ interface CommandCenterState {
key(key: string): boolean;
}
function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCenterState {
function useCommandCenterState(
listMetrics: CommandCenterListMetrics,
interaction: CommandCenterInteraction,
): CommandCenterState {
const keyboardActionDispatcher = useKeyboardActionDispatcher();
const { t } = useTranslation();
const open = useKeyboardShortcutsStore((state) => state.commandCenterOpen);
@@ -248,8 +294,7 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe
const snapshot = useCommandCenterContributions();
const inputRef = useRef<EditingTextInputHandle>(null);
const previousOpenRef = useRef(open);
const [query, setQueryState] = useState("");
const [activeId, setActiveId] = useState<string | null>(null);
const { query, activeId } = useSyncExternalStore(interaction.subscribe, interaction.getSnapshot);
const builtInSections = useBuiltInSections(open, query);
const {
entries: fileSearchEntries,
@@ -296,15 +341,9 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe
),
[builtInSections, contributionSections, fileSections, listMetrics, scope],
);
const resolvedActiveId = preserveActiveResultId(activeId, projection.selectableResults);
// Editing the query re-ranks everything, so an arrow-key selection made under the previous
// query is no longer meaningful. Reset it in the same update as the query rather than in an
// effect, so there is no intermediate render holding the stale selection.
const setQuery = useCallback((next: string) => {
setQueryState(next);
setActiveId(null);
}, []);
useLayoutEffect(() => {
interaction.update({ open, scope, query, projection, loading: fileSearchLoading });
}, [interaction, open, scope, query, projection, fileSearchLoading]);
const close = useCallback(() => setOpen(false), [setOpen]);
const select = useCallback(
@@ -327,18 +366,25 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe
return true;
}
if (pressed === "Enter") {
const selected = results.find((result) => result.id === resolvedActiveId);
const selected = results.find((result) => result.id === activeId);
if (!selected) return false;
select(selected);
return true;
}
if (pressed !== "ArrowDown" && pressed !== "ArrowUp") return false;
if (results.length === 0) return false;
const direction = pressed === "ArrowDown" ? "next" : "previous";
setActiveId(moveActiveResultId(resolvedActiveId, results, direction));
return true;
return interaction.move(pressed === "ArrowDown" ? "next" : "previous");
},
[close, open, projection.selectableResults, query, resolvedActiveId, scope, select, setScope],
[
close,
open,
projection.selectableResults,
query,
activeId,
scope,
select,
setScope,
interaction,
],
);
useEffect(() => {
@@ -348,8 +394,6 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe
const timer = setTimeout(() => inputRef.current?.focus(), 0);
return () => clearTimeout(timer);
}
setQueryState("");
setActiveId(null);
if (!wasOpen) return;
const element = takeCommandCenterFocusRestoreElement();
if (!element) return;
@@ -367,11 +411,9 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe
scope,
clearScope: () => setScope(null),
query,
setQuery,
activeId: resolvedActiveId,
setQuery: interaction.setQuery,
activeId,
rows: projection.rows,
results: projection.selectableResults,
rowIndexByResultId: projection.rowIndexByResultId,
offsets: projection.offsets,
inputRef,
fileSearchLoading,
@@ -397,6 +439,7 @@ const ResultRow = memo(function ResultRow({
active,
onSelect,
}: ResultRowProps) {
recordRenderProfileReasons(`CommandCenterResultRow:${result.id}`, ["render"]);
const press = useCallback(() => onSelect(result), [onSelect, result]);
const choice =
result.kind === "contribution" && result.contribution.presentation.kind === "choice"
@@ -430,7 +473,19 @@ const ResultRow = memo(function ResultRow({
result.kind === "file" ? `command-center-file-row-${result.filePath}` : choice?.testId
}
>
<ResultContent result={result} query={query} />
{(state) => (
<ResultContent
result={result}
query={query}
backdrop={
active ||
state.pressed ||
Boolean((state as PressableStateCallbackType & { hovered?: boolean }).hovered)
? "surface1"
: "surface0"
}
/>
)}
</Pressable>
);
});
@@ -484,6 +539,72 @@ function SubtitleWithTimestamp({
);
}
const providerIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function AgentResultContent({
result,
query,
backdrop,
}: {
result: CommandCenterAgentResult;
query: string;
backdrop: SurfaceBackdrop;
}) {
const agent = result.agent;
const descriptor = useAgentDescriptor(
{ kind: "agent", agentId: agent.id },
{ serverId: agent.serverId },
);
const Icon = useMemo(
() => withUnistyles(descriptor.icon, providerIconMapping),
[descriptor.icon],
);
return (
<View style={styles.rowContent} testID={`command-center-agent-${agent.serverId}:${agent.id}`}>
<View style={styles.rowMain}>
<View style={styles.iconSlot}>
<StatusIcon statusBucket={descriptor.statusBucket} size={16} backdrop={backdrop}>
<View accessibilityRole="image" accessibilityLabel={descriptor.subtitle}>
<Icon size={16} />
</View>
</StatusIcon>
</View>
<View style={styles.textContent}>
<QueryHighlightedText
query={query}
text={result.title}
style={styles.title}
numberOfLines={1}
/>
<SubtitleWithTimestamp
query={query}
subtitle={result.subtitle}
activityAt={result.activityAt}
testID="command-center-agent-subtitle"
/>
</View>
</View>
</View>
);
}
function WorkspaceResultIcon({
result,
backdrop,
}: {
result: CommandCenterWorkspaceResult;
backdrop: SurfaceBackdrop;
}) {
const statusBucket = useWorkspaceStatus(result.serverId, result.workspaceId);
return (
<StatusIcon statusBucket={statusBucket} size={16} backdrop={backdrop}>
<View accessibilityElementsHidden importantForAccessibility="no-hide-descendants" aria-hidden>
<WorkspaceProjectIcon project={result.project} />
</View>
</StatusIcon>
);
}
/** The same project icon the sidebar's workspace rows lead with: its image, or a lettered square. */
function WorkspaceProjectIcon({ project }: { project: CommandCenterWorkspaceResult["project"] }) {
const initial = projectIconPlaceholderLabelFromDisplayName(project.name).charAt(0).toUpperCase();
@@ -498,7 +619,15 @@ function WorkspaceProjectIcon({ project }: { project: CommandCenterWorkspaceResu
);
}
function ResultContent({ result, query }: { result: CommandCenterResult; query: string }) {
function ResultContent({
result,
query,
backdrop,
}: {
result: CommandCenterResult;
query: string;
backdrop: SurfaceBackdrop;
}) {
if (result.kind === "file") {
return (
<View style={styles.rowContent}>
@@ -525,34 +654,7 @@ function ResultContent({ result, query }: { result: CommandCenterResult; query:
);
}
if (result.kind === "agent") {
const agent = result.agent;
return (
<View style={styles.rowContent} testID={`command-center-agent-${agent.serverId}:${agent.id}`}>
<View style={styles.rowMain}>
<View style={styles.iconSlot}>
<AgentStatusDot
status={agent.status}
requiresAttention={agent.requiresAttention}
showInactive
/>
</View>
<View style={styles.textContent}>
<QueryHighlightedText
query={query}
text={result.title}
style={styles.title}
numberOfLines={1}
/>
<SubtitleWithTimestamp
query={query}
subtitle={result.subtitle}
activityAt={result.activityAt}
testID="command-center-agent-subtitle"
/>
</View>
</View>
</View>
);
return <AgentResultContent result={result} query={query} backdrop={backdrop} />;
}
if (result.kind === "workspace") {
const key = result.id.slice("workspace:".length);
@@ -560,13 +662,8 @@ function ResultContent({ result, query }: { result: CommandCenterResult; query:
<View style={styles.rowContent} testID={`command-center-workspace-${key}`}>
<View style={styles.rowMain}>
{/* Decorative: the fallback icon's initial must not prefix the row's accessible name. */}
<View
style={styles.iconSlot}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
aria-hidden
>
<WorkspaceProjectIcon project={result.project} />
<View style={styles.iconSlot}>
<WorkspaceResultIcon result={result} backdrop={backdrop} />
</View>
<View style={styles.textContent}>
<QueryHighlightedText
@@ -669,62 +766,39 @@ function SectionRow({ row }: { row: Extract<CommandCenterListRow, { kind: "secti
export function CommandCenter() {
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const state = useCommandCenterState(
isCompact ? COMMAND_CENTER_LIST_METRICS.compact : COMMAND_CENTER_LIST_METRICS.desktop,
);
const showBottomSheet = isCompact && isNative;
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet);
const listRef = useRef<FlatList<CommandCenterListRow>>(null);
const bottomSheetListRef = useRef<BottomSheetFlatListMethods>(null);
const bottomSheetInputRef = useRef<EditingTextInputHandle>(null);
const scrollMetricsRef = useRef({ offset: 0, visibleLength: 0 });
const [interaction] = useState(() =>
createCommandCenterInteraction((offset) => {
const list = listRef.current ?? bottomSheetListRef.current;
list?.scrollToOffset({ offset, animated: false });
}),
);
const state = useCommandCenterState(
isCompact ? COMMAND_CENTER_LIST_METRICS.compact : COMMAND_CENTER_LIST_METRICS.desktop,
interaction,
);
const showBottomSheet = isCompact && isNative;
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet);
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
visible: state.open,
isEnabled: showBottomSheet,
onClose: state.close,
});
const revealActiveResult = useCallback(() => {
if (!state.open || !state.activeId) return;
const index = state.rowIndexByResultId.get(state.activeId);
if (index === undefined) return;
const { offset, visibleLength } = scrollMetricsRef.current;
if (visibleLength <= 0) return;
const rowTop = state.offsets[index];
const rowBottom = rowTop + state.rows[index].height;
let nextOffset: number | null = null;
if (rowTop < offset) nextOffset = rowTop;
if (rowBottom > offset + visibleLength) nextOffset = rowBottom - visibleLength;
if (nextOffset === null) return;
const ref = showBottomSheet ? bottomSheetListRef.current : listRef.current;
const boundedOffset = Math.max(0, nextOffset);
scrollMetricsRef.current.offset = boundedOffset;
ref?.scrollToOffset({ offset: boundedOffset, animated: false });
}, [
showBottomSheet,
state.activeId,
state.offsets,
state.open,
state.rowIndexByResultId,
state.rows,
]);
useEffect(() => {
if (!state.open) {
scrollMetricsRef.current = { offset: 0, visibleLength: 0 };
return;
}
revealActiveResult();
}, [revealActiveResult, state.open]);
const handleListLayout = useCallback(
(event: LayoutChangeEvent) => {
scrollMetricsRef.current.visibleLength = event.nativeEvent.layout.height;
revealActiveResult();
interaction.layout(event.nativeEvent.layout.height);
},
[revealActiveResult],
[interaction],
);
const handleListScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
interaction.scrolled(event.nativeEvent.contentOffset.y);
},
[interaction],
);
const handleListScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
scrollMetricsRef.current.offset = event.nativeEvent.contentOffset.y;
}, []);
useEffect(() => {
if (!showBottomSheet || !state.open) return;
const timer = setTimeout(() => bottomSheetInputRef.current?.focus(), 300);
@@ -790,6 +864,14 @@ export function CommandCenter() {
windowSize: 5,
onLayout: handleListLayout,
onScroll: handleListScroll,
onScrollBeginDrag: interaction.beginManualScroll,
onTouchStart: interaction.beginManualScroll,
...(isWeb
? {
onWheel: interaction.beginManualScroll,
onMouseDown: interaction.beginManualScroll,
}
: {}),
scrollEventThrottle: 16,
};
const keyPress = useCallback(
@@ -0,0 +1,178 @@
import { describe, expect, it } from "vitest";
import { createCommandCenterInteraction } from "./interaction";
import { projectCommandCenterRows, type CommandCenterFileResult } from "./results";
import type { CommandCenterScope } from "@/stores/keyboard-shortcuts-store";
function results(ids = ["a", "b", "c", "d"]) {
return projectCommandCenterRows(
[
{
id: "files",
band: 0,
rank: 0,
title: "Files",
results: ids.map<CommandCenterFileResult>((id) => ({
id,
kind: "file",
filePath: id,
title: id,
subtitle: "",
run() {},
})),
},
],
{ singleLineRow: 40, twoLineRow: 60, sectionTitle: 20, sectionDivider: 10 },
);
}
function setup(options: { scope?: CommandCenterScope; measured?: boolean } = {}) {
const offsets: number[] = [];
const model = createCommandCenterInteraction((offset) => offsets.push(offset));
const publish = (ids = ["a", "b", "c", "d"], loading = false) =>
model.update({
open: true,
scope: options.scope ?? null,
query: model.getSnapshot().query,
projection: results(ids),
loading,
});
publish();
if (options.measured !== false) model.layout(80);
return { model, offsets, publish };
}
describe("Command Center interaction", () => {
it("keeps manual scrolling through result refresh, removal, and resizing", () => {
const { model, offsets, publish } = setup();
model.beginManualScroll();
model.scrolled(100);
publish();
publish(["b", "c", "d"]);
model.layout(60);
expect(model.getSnapshot().activeId).toBe("b");
expect(offsets).toEqual([]);
});
it("reveals only clipped keyboard selections and wraps around", () => {
const { model, offsets } = setup();
expect(model.move("next")).toBe(true);
expect(offsets).toEqual([20]);
model.scrolled(20); // The programmatic scroll callback is only a measurement.
model.move("next");
expect(offsets).toEqual([20, 60]);
model.move("previous");
expect(offsets).toEqual([20, 60]);
model.move("previous");
expect(offsets).toEqual([20, 60, 20]);
model.move("previous");
expect(model.getSnapshot().activeId).toBe("d");
expect(offsets).toEqual([20, 60, 20, 100]);
});
it("keyboard navigation takes control again after manual scrolling", () => {
const { model, offsets } = setup();
model.beginManualScroll();
model.scrolled(100);
model.move("next");
expect(model.getSnapshot().activeId).toBe("b");
expect(offsets).toEqual([60]);
});
it("a new query reveals even when the active ID stays the same", () => {
const { model, offsets, publish } = setup();
model.scrolled(100);
model.setQuery("a");
expect(offsets).toEqual([]);
publish(["a", "b"]);
expect(model.getSnapshot()).toEqual({ query: "a", activeId: "a" });
expect(offsets).toEqual([20]);
model.scrolled(100);
publish(["a", "b"]);
expect(offsets).toEqual([20]);
});
it("an arrow command reveals a single result even when its ID cannot change", () => {
const { model, offsets, publish } = setup();
publish(["a"]);
model.scrolled(30);
model.move("next");
expect(offsets).toEqual([20]);
});
it("defers reveals until layout exists and consumes them only once", () => {
const { model, offsets, publish } = setup({ measured: false });
model.move("previous");
expect(offsets).toEqual([]);
model.layout(80);
expect(offsets).toEqual([100]);
model.scrolled(0);
publish();
model.layout(60);
expect(offsets).toEqual([100]);
});
it("manual interaction cancels an unmeasured request", () => {
const { model, offsets } = setup({ measured: false });
model.move("previous");
model.beginManualScroll();
model.layout(80);
expect(offsets).toEqual([]);
});
it("waits for scoped search results instead of revealing the previous query's rows", () => {
const { model, offsets, publish } = setup({ scope: "files" });
model.scrolled(100);
model.setQuery("new");
publish(["a", "b"], true);
expect(offsets).toEqual([]);
expect(model.getSnapshot().activeId).toBeNull();
publish(["b", "a"]);
expect(model.getSnapshot().activeId).toBe("b");
expect(offsets).toEqual([20]);
});
it("manual interaction cancels a reveal awaiting search results", () => {
const { model, offsets, publish } = setup({ scope: "files" });
model.setQuery("new");
publish([], true);
model.beginManualScroll();
model.scrolled(100);
publish(["new"]);
expect(model.getSnapshot().activeId).toBe("new");
expect(offsets).toEqual([]);
});
it("empty searches complete without granting future passive results permission to scroll", () => {
const { model, offsets, publish } = setup();
model.setQuery("missing");
publish([]);
expect(model.move("next")).toBe(false);
expect(model.getSnapshot().activeId).toBeNull();
model.scrolled(100);
publish(["late"]);
expect(offsets).toEqual([]);
});
it("scope changes reset selection and request a reveal", () => {
const { model, offsets } = setup();
model.move("next");
model.scrolled(100);
model.update({ open: true, scope: "files", query: "", projection: results(), loading: false });
expect(model.getSnapshot().activeId).toBe("a");
expect(offsets).toEqual([20, 20]);
});
it("close discards the old query, metrics, and deferred reveal", () => {
const { model, offsets, publish } = setup({ measured: false });
model.setQuery("old");
publish();
model.move("previous");
model.update({ open: false, scope: null, query: "old", projection: results(), loading: false });
model.layout(80);
expect(model.getSnapshot()).toEqual({ query: "", activeId: null });
expect(offsets).toEqual([]);
publish();
expect(model.getSnapshot().activeId).toBe("a");
expect(offsets).toEqual([]);
});
});
@@ -0,0 +1,131 @@
import type { CommandCenterScope } from "@/stores/keyboard-shortcuts-store";
import {
moveActiveResultId,
preserveActiveResultId,
type CommandCenterListProjection,
} from "./results";
interface Snapshot {
query: string;
activeId: string | null;
}
interface ResultsInput {
open: boolean;
scope: CommandCenterScope;
query: string;
projection: CommandCenterListProjection;
loading: boolean;
}
/** Selection persists; permission to move the viewport lasts for one user command. */
export function createCommandCenterInteraction(scrollToOffset: (offset: number) => void) {
let snapshot: Snapshot = { query: "", activeId: null };
const listeners = new Set<() => void>();
let open = false;
let scope: CommandCenterScope = null;
let projection: CommandCenterListProjection | null = null;
let loading = false;
let revealPending = false;
let offset = 0;
let visibleLength = 0;
function publish(next: Snapshot) {
if (next.query === snapshot.query && next.activeId === snapshot.activeId) return;
snapshot = next;
for (const listener of listeners) listener();
}
function reveal() {
if (!open || !revealPending || !projection || visibleLength <= 0) return;
// Scoped file search keeps its previous entries while the next query is loading.
if (scope === "files" && loading) return;
if (!snapshot.activeId) {
if (!loading) revealPending = false;
return;
}
const index = projection.rowIndexByResultId.get(snapshot.activeId);
if (index === undefined) return;
const top = projection.offsets[index];
const bottom = top + projection.rows[index].height;
revealPending = false;
let nextOffset = offset;
if (top < offset) nextOffset = top;
if (bottom > offset + visibleLength) nextOffset = bottom - visibleLength;
nextOffset = Math.max(0, nextOffset);
if (nextOffset === offset) return;
offset = nextOffset;
scrollToOffset(nextOffset);
}
return {
getSnapshot: () => snapshot,
subscribe(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
update(input: ResultsInput) {
const resetSelection = input.open && (!open || scope !== input.scope);
open = input.open;
scope = input.scope;
if (!open) {
projection = null;
revealPending = false;
offset = 0;
visibleLength = 0;
publish({ query: "", activeId: null });
return;
}
if (resetSelection) {
revealPending = true;
publish({ ...snapshot, activeId: null });
}
// A query command invalidates the old projection until React supplies its new results.
if (input.query !== snapshot.query) return;
projection = input.projection;
loading = input.loading;
if (scope !== "files" || !loading) {
publish({
...snapshot,
activeId: preserveActiveResultId(snapshot.activeId, projection.selectableResults),
});
}
reveal();
},
setQuery(query: string) {
projection = null;
revealPending = true;
publish({ query, activeId: null });
},
move(direction: "next" | "previous"): boolean {
if (
!open ||
!projection ||
(scope === "files" && loading) ||
projection.selectableResults.length === 0
)
return false;
publish({
...snapshot,
activeId: moveActiveResultId(snapshot.activeId, projection.selectableResults, direction),
});
revealPending = true;
reveal();
return true;
},
layout(height: number) {
visibleLength = height;
reveal();
},
scrolled(nextOffset: number) {
offset = nextOffset;
},
beginManualScroll() {
revealPending = false;
},
};
}
export type CommandCenterInteraction = ReturnType<typeof createCommandCenterInteraction>;
@@ -110,6 +110,8 @@ function workspace(
): CommandCenterWorkspaceResult {
return {
kind: "workspace",
serverId: "server",
workspaceId: "workspace",
id,
title,
subtitle,
@@ -55,6 +55,8 @@ export interface CommandCenterWorkspaceResult {
id: string;
title: string;
subtitle: string;
serverId: string;
workspaceId: string;
project: CommandCenterWorkspaceProject;
/** PR/MR number backing this workspace, so a number query can match it exactly. */
changeRequestNumber: number | null;
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { usePanelStore } from "@/stores/panel-store";
import { revealWorkspaceTab } from "@/workspace-tabs/navigation";
import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
@@ -131,8 +131,6 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string
openWorkspaceFileFromExplorer({
filePath: path,
persistenceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
closeExplorerAfterOpen: true,
showMobileAgent: usePanelStore.getState().showMobileAgent,
openWorkspaceTabInFocusedPane: (workspaceKey, target, placement) =>
useWorkspaceLayoutStore.getState().openTab({
workspaceKey,
@@ -140,7 +138,7 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string
intent: "reveal",
placement,
}),
focusWorkspaceTab: useWorkspaceLayoutStore.getState().focusTab,
revealWorkspaceTab,
});
},
[serverId, workspaceId],
@@ -11,6 +11,8 @@ function workspace(input: {
}): CommandCenterWorkspaceResult {
return {
kind: "workspace",
serverId: "server",
workspaceId: "workspace",
id: input.id,
title: input.title ?? input.id,
subtitle: input.subtitle ?? "",
@@ -1,3 +1,4 @@
import { usePanelStore } from "@/stores/panel-store";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { router } from "expo-router";
import type { WorkspaceProjectDescriptorPayload } from "@getpaseo/protocol/messages";
@@ -474,6 +475,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
const openNewWorkspaceForProject = useCallback(
(serverId: string, project: WorkspaceProjectDescriptorPayload) => {
onClose();
usePanelStore.getState().showMobileAgent();
router.push(
buildNewWorkspaceRoute({
serverId,
@@ -621,6 +623,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
testID: "add-project-flow-add-host",
select: () => {
onClose();
usePanelStore.getState().showMobileAgent();
router.push(buildSettingsAddHostRoute(Date.now()));
},
});
@@ -28,6 +28,23 @@ function failure(message: string) {
}
describe("direct connection failure copy", () => {
it.each([
"paseo-qa-nonexistent.invalid",
"ssl-paseo-qa.invalid",
"tls-paseo-qa.invalid",
"certificate-paseo-qa.invalid",
])(
"identifies Android DNS failures for %s without treating the hostname as TLS evidence",
(host) => {
const message = `Unable to resolve host "${host}": No address associated with hostname`;
expect(failure(message)).toEqual({
title: "Failed: tcp://example.test:443?ssl=true",
detail: labels.hostNotFound,
raw: `transport error — ${message}`,
});
},
);
it("identifies Android certificate validation errors without calling them DNS failures", () => {
const message =
"java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.";
@@ -43,6 +60,7 @@ describe("direct connection failure copy", () => {
["SSL handshake failed", labels.tlsError],
["TLS handshake failed", labels.tlsError],
["getaddrinfo ENOTFOUND example.test", labels.hostNotFound],
["getaddrinfo ENOTFOUND ssl.example.test", labels.hostNotFound],
["Host not found", labels.hostNotFound],
["connect ECONNREFUSED", labels.refused],
["connection timed out", labels.timedOut],
@@ -83,6 +83,8 @@ export function buildConnectionFailureCopy(input: {
rawLower.includes("err_connection_refused")
) {
detail = labels.refused;
} else if (rawLower.includes("unable to resolve host") || rawLower.includes("enotfound")) {
detail = labels.hostNotFound;
} else if (
rawLower.includes("certificate") ||
rawLower.includes("certpathvalidator") ||
@@ -91,7 +93,7 @@ export function buildConnectionFailureCopy(input: {
rawLower.includes("ssl")
) {
detail = labels.tlsError;
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
} else if (rawLower.includes("not found")) {
detail = labels.hostNotFound;
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
detail = labels.hostUnreachable;
@@ -34,7 +34,7 @@ export function FileDropBackdrop() {
// non-animated children (applying themed Unistyles styles to an Animated.View crashes
// on theme change — see docs/unistyles.md).
return (
<Animated.View style={overlayStyle} pointerEvents="none">
<Animated.View style={overlayStyle} pointerEvents="none" testID="file-drop-backdrop">
<View style={styles.backdrop} />
<View style={styles.content}>
<ThemedUpload size={32} uniProps={primaryIconColorMapping} />
@@ -13,11 +13,11 @@ export type DroppedItem = DroppedFileItem | DroppedPathItem;
/**
* What a consumer (e.g. a composer) registers to receive files dropped onto the
* surrounding FileDropZone. Raster images arrive already persisted via `onFiles`;
* everything else arrives raw via `onGenericFiles`.
* surrounding FileDropZone. Every file arrives raw via `onGenericFiles`; a sink that
* also provides `onFiles` gets its raster images persisted as attachments.
*/
export interface FileDropSink {
onFiles: (images: ImageAttachment[]) => void;
onFiles?: (images: ImageAttachment[]) => void;
onGenericFiles?: (items: DroppedItem[]) => void;
onWorkspaceFile?: (payload: WorkspaceFileDragPayload) => void;
}
@@ -148,8 +148,9 @@ export function useDropListeners({
sink.onGenericFiles(items);
}
const imagePaths = payload.paths.filter(isRasterImagePath);
if (imagePaths.length === 0) {
const onFiles = sink.onFiles;
const imagePaths = onFiles ? payload.paths.filter(isRasterImagePath) : [];
if (!onFiles || imagePaths.length === 0) {
return;
}
@@ -162,7 +163,7 @@ export function useDropListeners({
// composer the user dropped on (matches the web path below). No post-persist busy
// re-check: a mixed drop's own generic upload flips the busy flag, and re-checking
// would discard the image from the same drop.
sink.onFiles(attachments);
onFiles(attachments);
return;
})
.catch((error) => {
@@ -262,16 +263,17 @@ export function useDropListeners({
sink.onGenericFiles(genericItems);
}
const imageFiles = files.filter(isRasterImageFile);
const onFiles = sink.onFiles;
const imageFiles = onFiles ? files.filter(isRasterImageFile) : [];
if (imageFiles.length === 0) return;
if (!onFiles || imageFiles.length === 0) return;
try {
const attachments = await Promise.all(imageFiles.map(fileToImageAttachment));
// No post-persist busy re-check: a mixed drop's own generic upload flips the busy flag,
// and re-checking would discard the image from the same drop. The guard at drop start
// already rejects drops that begin while busy.
sink.onFiles(attachments);
onFiles(attachments);
} catch (error) {
console.error("[useDropListeners] Failed to process dropped files:", error);
}
@@ -0,0 +1,59 @@
import { X } from "lucide-react-native";
import type { StyleProp, ViewStyle } from "react-native";
import { withUnistyles } from "react-native-unistyles";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import {
IconButtonChromeProvider,
iconButtonChromeGlyphSize,
mutedIconColorMapping,
} from "@/components/ui/icon-button-chrome";
import type { ShortcutKey } from "@/utils/format-shortcut";
const ThemedX = withUnistyles(X);
const NO_SHORTCUT: ShortcutKey[] = [];
interface SidebarCloseButtonProps {
label: string;
onPress: () => void;
testID: string;
nativeID?: string;
accessibilityState?: { expanded: boolean };
style?: StyleProp<ViewStyle>;
}
/**
* The X that closes a compact sidebar overlay. It is the compact header's icon button, so the
* sidebar's close reads like the header it covers; every compact sidebar uses it.
*/
export function SidebarCloseButton({
label,
onPress,
testID,
nativeID,
accessibilityState,
style,
}: SidebarCloseButtonProps) {
return (
<IconButtonChromeProvider value="header-island">
<HeaderToggleButton
onPress={onPress}
tooltipLabel={label}
tooltipKeys={NO_SHORTCUT}
tooltipSide="bottom"
style={style}
testID={testID}
nativeID={nativeID}
accessible
accessibilityRole="button"
accessibilityLabel={label}
accessibilityState={accessibilityState}
>
<ThemedX
size={iconButtonChromeGlyphSize("large")}
strokeWidth={1.5}
uniProps={mutedIconColorMapping}
/>
</HeaderToggleButton>
</IconButtonChromeProvider>
);
}
+18 -46
View File
@@ -5,7 +5,6 @@ import {
GitBranch,
Server,
Settings,
X,
type LucideIcon,
} from "lucide-react-native";
import { useTranslation } from "react-i18next";
@@ -21,14 +20,9 @@ import { Gesture } from "react-native-gesture-handler";
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import {
IconButtonChromeProvider,
iconButtonChromeGlyphSize,
mutedIconColorMapping,
} from "@/components/ui/icon-button-chrome";
import { SidebarCloseButton } from "@/components/headers/sidebar-close-button";
import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout";
import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
@@ -68,7 +62,6 @@ import { usePanelStore } from "@/stores/panel-store";
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
import { openHostOverview } from "@/navigation/settings-navigation";
import {
@@ -81,8 +74,6 @@ import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
const ThemedX = withUnistyles(X);
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
const DEV_BUILD_LABEL = process.env.EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL?.trim() || null;
@@ -175,9 +166,8 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
const { open: openImportSession, sheet: importSessionSheet } = useImportSession();
const handleOpenProjectMobile = useCallback(() => {
showMobileAgent();
void openProjectPicker();
}, [showMobileAgent, openProjectPicker]);
}, [openProjectPicker]);
const handleOpenProjectDesktop = useCallback(() => {
void openProjectPicker();
@@ -214,9 +204,8 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
}, []);
const handleImportSessionMobile = useCallback(() => {
showMobileAgent();
openImportSession();
}, [openImportSession, showMobileAgent]);
}, [openImportSession]);
const labels = useMemo(
(): SidebarLabels => ({
@@ -555,7 +544,15 @@ function MobileSidebar({
}, [closeSidebar]);
const closeButton = useMemo(
() => <SidebarCloseButton label={labels.closeSidebar} onPress={closeSidebar} />,
() => (
<SidebarCloseButton
label={labels.closeSidebar}
onPress={closeSidebar}
testID="sidebar-close"
nativeID="sidebar-close"
style={styles.mobileCloseButton}
/>
),
[labels.closeSidebar, closeSidebar],
);
@@ -621,34 +618,6 @@ function MobileSidebar({
);
}
const NO_SHORTCUT: ShortcutKey[] = [];
/** The compact header's icon button, so the sidebar's close reads like the header it covers. */
function SidebarCloseButton({ label, onPress }: { label: string; onPress: () => void }) {
return (
<IconButtonChromeProvider value="header-island">
<HeaderToggleButton
onPress={onPress}
tooltipLabel={label}
tooltipKeys={NO_SHORTCUT}
tooltipSide="bottom"
style={styles.mobileCloseButton}
testID="sidebar-close"
nativeID="sidebar-close"
accessible
accessibilityRole="button"
accessibilityLabel={label}
>
<ThemedX
size={iconButtonChromeGlyphSize("large")}
strokeWidth={1.5}
uniProps={mutedIconColorMapping}
/>
</HeaderToggleButton>
</IconButtonChromeProvider>
);
}
function DesktopSidebar({
theme,
workspaceGroups,
@@ -750,8 +719,11 @@ function DesktopSidebar({
[active, resizeAnimatedStyle],
);
const desktopSidebarBorderStyle = useMemo(
() => [styles.desktopSidebarBorder, { flex: 1, paddingTop: insetsTop }],
[insetsTop],
() => [
styles.desktopSidebarBorder,
{ flex: 1, paddingTop: insetsTop, paddingBottom: insets.bottom },
],
[insetsTop, insets.bottom],
);
const sidebarHeaderGroupStyle = useMemo(
() => [styles.sidebarHeaderGroup, ownsTopLeft && styles.sidebarHeaderGroupBelowChrome],
@@ -1,7 +1,8 @@
/**
* @vitest-environment jsdom
*/
import { act } from "@testing-library/react";
import { act, renderHook } from "@testing-library/react";
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -83,6 +84,8 @@ function workspace(input: {
scripts?: WorkspaceDescriptor["scripts"];
}): WorkspaceDescriptor {
return {
createdAt: null,
lastUserMessageAt: null,
id: input.id,
projectId: input.projectId,
projectDisplayName: input.projectDisplayName,
@@ -171,10 +174,6 @@ function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void {
useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true);
useSidebarOrderStore.setState({
projectOrder: ["project-a", "project-b"],
workspaceOrderByProject: {
["project-a"]: [`${SERVER_ID}:a-main`, `${SERVER_ID}:a-one`, `${SERVER_ID}:a-two`],
["project-b"]: [`${SERVER_ID}:b-main`, `${SERVER_ID}:b-one`, `${SERVER_ID}:b-two`],
},
});
});
}
@@ -348,7 +347,6 @@ describe("sidebar workspace render isolation", () => {
useSessionStore.getState().clearSession(SERVER_ID);
useSidebarOrderStore.setState({
projectOrder: [],
workspaceOrderByProject: {},
});
});
});
@@ -439,3 +437,190 @@ describe("sidebar workspace render isolation", () => {
});
});
});
describe("sidebar group visibility", () => {
const entries = (statuses: WorkspaceDescriptor["status"][]) =>
statuses.map((status, index) =>
createSidebarWorkspaceEntry({
serverId: SERVER_ID,
workspace: workspace({
id: `row-${index + 1}`,
projectId: "project-a",
projectDisplayName: "Project A",
name: `Row ${index + 1}`,
status,
}),
}),
);
const policy = {
kind: "workspaces" as const,
getStatusBucket: (entry: ReturnType<typeof createSidebarWorkspaceEntry>) => entry.statusBucket,
};
const names = (items: readonly ReturnType<typeof createSidebarWorkspaceEntry>[]) =>
items.map((item) => item.workspaceId);
it.each([0, 7, 8])("limits %i Done rows to seven", (count) => {
const items = entries(Array(count).fill("done"));
const { result } = renderHook(() => useLimitedSidebarGroup(items, policy));
expect(result.current.visibleItems).toEqual(items.slice(0, 7));
expect(result.current.canToggle).toBe(count > 7);
});
it.each(["needs_input", "failed", "attention", "running"] as const)(
"keeps row twelve visible for %s without filling gaps",
(status) => {
const items = entries([...Array(11).fill("done"), status]);
const { result } = renderHook(() => useLimitedSidebarGroup(items, policy));
expect(names(result.current.visibleItems)).toEqual([
"row-1",
"row-2",
"row-3",
"row-4",
"row-5",
"row-6",
"row-7",
"row-12",
]);
expect(result.current.visibleItems[7]).toBe(items[11]);
expect(result.current.canToggle).toBe(true);
},
);
it("shows eight active rows once with no redundant toggle", () => {
const items = entries([
"running",
"needs_input",
"attention",
"failed",
"running",
"attention",
"needs_input",
"failed",
]);
const { result } = renderHook(() => useLimitedSidebarGroup(items, policy));
expect(result.current.visibleItems).toEqual(items);
expect(result.current.canToggle).toBe(false);
});
it("recomputes current ordered membership and retains reveal through status, order and size changes", () => {
const items = entries([
...Array(7).fill("done"),
"running",
"done",
"failed",
"done",
"attention",
]);
const { result, rerender, unmount } = renderHook(
({ rows }) => useLimitedSidebarGroup(rows, policy),
{ initialProps: { rows: items } },
);
expect(names(result.current.visibleItems)).toEqual([
"row-1",
"row-2",
"row-3",
"row-4",
"row-5",
"row-6",
"row-7",
"row-8",
"row-10",
"row-12",
]);
act(() => result.current.toggleExpanded());
expect(result.current.visibleItems).toEqual(items);
rerender({ rows: items.map((item) => ({ ...item, statusBucket: "running" })) });
expect(result.current.canToggle).toBe(false);
expect(result.current.expanded).toBe(true);
rerender({ rows: items.slice(0, 3) });
expect(result.current.canToggle).toBe(false);
rerender({ rows: items.toReversed() });
expect(result.current.visibleItems).toEqual(items.toReversed());
act(() => result.current.toggleExpanded());
expect(names(result.current.visibleItems)).toEqual([
"row-12",
"row-11",
"row-10",
"row-9",
"row-8",
"row-7",
"row-6",
]);
const newest = { ...items[0], workspaceId: "new", workspaceKey: "srv:new" };
rerender({ rows: [newest, ...items] });
expect(result.current.visibleItems[0]).toBe(newest);
expect(result.current.visibleItems).toHaveLength(10);
expect(result.current.expanded).toBe(false);
act(() => result.current.toggleExpanded());
unmount();
const remounted = renderHook(() => useLimitedSidebarGroup(items, policy));
expect(remounted.result.current.expanded).toBe(false);
expect(remounted.result.current.visibleItems).toHaveLength(10);
});
it("follows effective pending creation, recovery and agent activity without changing Created order", () => {
const items = entries(Array(12).fill("done"));
const descriptor = workspace({
id: "row-12",
projectId: "project-a",
projectDisplayName: "Project A",
name: "Row 12",
});
const pending = {
draftId: "draft",
serverId: SERVER_ID,
workspaceId: descriptor.id,
agentId: null,
clientMessageId: "message",
text: "Create",
timestamp: 1234,
lifecycle: "active" as const,
};
const { result, rerender } = renderHook(
({ last }) => useLimitedSidebarGroup([...items.slice(0, 11), last], policy),
{ initialProps: { last: items[11] } },
);
expect(result.current.visibleItems).toHaveLength(7);
const creating = createSidebarWorkspaceEntry({
serverId: SERVER_ID,
workspace: descriptor,
pendingCreateAttempts: { draft: pending },
});
expect(creating.statusBucket).toBe("running");
rerender({ last: creating });
expect(names(result.current.visibleItems)).toEqual([...names(items.slice(0, 7)), "row-12"]);
const abandoned = createSidebarWorkspaceEntry({
serverId: SERVER_ID,
workspace: descriptor,
pendingCreateAttempts: { draft: { ...pending, lifecycle: "abandoned" } },
});
rerender({ last: abandoned });
expect(result.current.visibleItems).toHaveLength(7);
rerender({ last: creating });
for (const status of ["running", "attention", "needs_input", "failed", "done"] as const) {
const last = createSidebarWorkspaceEntry({
serverId: SERVER_ID,
workspace: descriptor,
workspaceAgentActivity: new Map([
[descriptor.id, { agentId: "agent", status, enteredAt: null }],
]),
});
expect(last.statusBucket).toBe(status);
rerender({ last });
expect(names(result.current.visibleItems)).toEqual(
status === "done" ? names(items.slice(0, 7)) : [...names(items.slice(0, 7)), "row-12"],
);
}
});
it("preserves the twenty-row pinned policy regardless of status", () => {
const items = entries(Array(21).fill("running"));
const { result } = renderHook(() => useLimitedSidebarGroup(items));
expect(result.current.visibleItems).toEqual(items.slice(0, 20));
expect(result.current.canToggle).toBe(true);
act(() => result.current.toggleExpanded());
expect(result.current.visibleItems).toEqual(items);
act(() => result.current.toggleExpanded());
expect(result.current.visibleItems).toHaveLength(20);
});
});
@@ -272,7 +272,7 @@ interface WorkspaceRowInnerProps {
shortcutNumber: number | null;
showShortcutBadge: boolean;
onPress: () => void;
drag: () => void;
drag?: () => void;
isDragging: boolean;
isArchiving: boolean;
isCreating?: boolean;
@@ -381,8 +381,6 @@ function getProjectWorkspaceRowStyle({
];
}
function noop() {}
const prBadgeStyles = StyleSheet.create((theme) => ({
badge: {
flexDirection: "row",
@@ -1236,7 +1234,7 @@ function WorkspaceRowWithMenu({
shortcutNumber: number | null;
showShortcutBadge: boolean;
onPress: () => void;
drag: () => void;
drag?: () => void;
isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps;
canCopyBranchName: boolean;
@@ -1403,7 +1401,6 @@ function WorkspaceRowItem({
isCreating = false,
selectionEnabled,
activeWorkspaceSelection,
onWorkspacePress,
drag,
isDragging = false,
dragHandleProps,
@@ -1412,9 +1409,8 @@ function WorkspaceRowItem({
if (!workspace.serverId) {
return;
}
onWorkspacePress?.();
navigateToWorkspace({ serverId: workspace.serverId, workspaceId: workspace.workspaceId });
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
}, [workspace.serverId, workspace.workspaceId]);
return (
<WorkspaceRow
@@ -1436,7 +1432,7 @@ function WorkspaceRowItem({
enabled: selectionEnabled,
})}
onPress={handlePress}
drag={drag ?? noop}
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
/>
@@ -1465,7 +1461,7 @@ function areWorkspaceRowItemPropsEqual(
previous.hostBadge === next.hostBadge &&
previous.leadingProjectName === next.leadingProjectName &&
previous.leadingProjectIconDataUri === next.leadingProjectIconDataUri &&
previous.shortcutNumber === next.shortcutNumber &&
(!next.showShortcutBadge || previous.shortcutNumber === next.shortcutNumber) &&
previous.showShortcutBadge === next.showShortcutBadge &&
previous.canCopyBranchName === next.canCopyBranchName &&
previous.canPin === next.canPin &&
@@ -1507,7 +1503,7 @@ function WorkspaceRow({
shortcutNumber: number | null;
showShortcutBadge: boolean;
onPress: () => void;
drag: () => void;
drag?: () => void;
isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps;
canCopyBranchName: boolean;
@@ -1552,16 +1548,17 @@ function ProjectBlock({
selectionEnabled,
showShortcutBadges,
shortcutIndexByWorkspaceKey,
parentGestureRef,
onToggleCollapsed,
onWorkspacePress,
parentGestureRef,
onWorkspaceReorder,
useNestable,
dragGestureHostActive,
manualSort,
onWorktreeCreated,
drag,
isDragging,
dragHandleProps,
useNestable,
dragGestureHostActive,
creatingWorkspaceIds,
activeWorkspaceSelection,
hostBadgeByServerId,
@@ -1577,16 +1574,17 @@ function ProjectBlock({
selectionEnabled: boolean;
showShortcutBadges: boolean;
shortcutIndexByWorkspaceKey: Map<string, number>;
parentGestureRef?: MutableRefObject<GestureType | undefined>;
onToggleCollapsed: (projectViewKey: string) => void;
onWorkspacePress?: () => void;
parentGestureRef?: MutableRefObject<GestureType | undefined>;
onWorkspaceReorder: (projectViewKey: string, workspaces: SidebarWorkspacePlacement[]) => void;
useNestable: boolean;
dragGestureHostActive?: boolean;
manualSort: boolean;
onWorktreeCreated?: (workspaceId: string) => void;
drag: () => void;
isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps;
useNestable: boolean;
dragGestureHostActive?: boolean;
creatingWorkspaceIds: ReadonlySet<string>;
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
@@ -1594,12 +1592,20 @@ function ProjectBlock({
supportsPinningByServerId: ReadonlyMap<string, boolean>;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
}) {
const visibilityPolicy = useMemo(
() => ({
kind: "workspaces" as const,
getStatusBucket: (workspace: SidebarWorkspacePlacement) =>
workspaceEntriesByKey.get(workspace.workspaceKey)!.statusBucket,
}),
[workspaceEntriesByKey],
);
const {
visibleItems: visibleWorkspaces,
expanded: workspacesExpanded,
canToggle: canToggleWorkspaces,
toggleExpanded: toggleWorkspacesExpanded,
} = useLimitedSidebarGroup(project.workspaces);
} = useLimitedSidebarGroup(project.workspaces, visibilityPolicy);
const activeServerId = activeWorkspaceSelection?.serverId ?? null;
const activeHostStatus = useHostRuntimeConnectionStatus(activeServerId ?? "");
const preferredServerId = activeHostStatus === "online" ? activeServerId : null;
@@ -1638,6 +1644,7 @@ function ProjectBlock({
) => {
return (
<MemoWorkspaceRowItem
key={item.workspaceKey}
workspace={item}
workspaceEntry={workspaceEntriesByKey.get(item.workspaceKey) ?? null}
hostBadge={hostBadgeByServerId.get(item.serverId) ?? null}
@@ -1759,20 +1766,29 @@ function ProjectBlock({
if (project.workspaces.length > 0) {
projectChildren = (
<>
<DraggableList
testID={`sidebar-workspace-list-${project.viewKey}`}
data={visibleWorkspaces}
keyExtractor={workspaceKeyExtractor}
renderItem={renderWorkspace}
onDragEnd={handleWorkspaceDragEnd}
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
scrollEnabled={false}
useDragHandle
nestable={useNestable}
simultaneousGestureRef={parentGestureRef}
gestureHostPresented={dragGestureHostActive}
containerStyle={styles.workspaceListContainer}
/>
{manualSort ? (
<DraggableList
testID={`sidebar-workspace-list-${project.viewKey}`}
data={visibleWorkspaces}
keyExtractor={workspaceKeyExtractor}
renderItem={renderWorkspace}
onDragEnd={handleWorkspaceDragEnd}
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
scrollEnabled={false}
useDragHandle
nestable={useNestable}
simultaneousGestureRef={parentGestureRef}
gestureHostPresented={dragGestureHostActive}
containerStyle={styles.workspaceListContainer}
/>
) : (
<View
testID={`sidebar-workspace-list-${project.viewKey}`}
style={styles.workspaceListContainer}
>
{visibleWorkspaces.map((item) => renderWorkspaceRow(item))}
</View>
)}
{canToggleWorkspaces ? (
<SidebarGroupToggleRow
expanded={workspacesExpanded}
@@ -1834,33 +1850,59 @@ type ProjectBlockProps = Parameters<typeof ProjectBlock>[0];
function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean {
return (
previous.project === next.project &&
previous.workspaceEntriesByKey === next.workspaceEntriesByKey &&
projectWorkspaceEntriesEqual(previous, next) &&
previous.collapsed === next.collapsed &&
previous.displayName === next.displayName &&
previous.iconDataUri === next.iconDataUri &&
previous.selectionEnabled === next.selectionEnabled &&
previous.showShortcutBadges === next.showShortcutBadges &&
previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey &&
projectShortcutBadgesEqual(previous, next) &&
previous.hostBadgeByServerId === next.hostBadgeByServerId &&
previous.supportsMultiplicityByServerId === next.supportsMultiplicityByServerId &&
previous.supportsPinningByServerId === next.supportsPinningByServerId &&
previous.onToggleWorkspacePin === next.onToggleWorkspacePin &&
previous.parentGestureRef === next.parentGestureRef &&
previous.onToggleCollapsed === next.onToggleCollapsed &&
previous.onWorkspacePress === next.onWorkspacePress &&
previous.parentGestureRef === next.parentGestureRef &&
previous.onWorkspaceReorder === next.onWorkspaceReorder &&
previous.useNestable === next.useNestable &&
previous.dragGestureHostActive === next.dragGestureHostActive &&
previous.manualSort === next.manualSort &&
previous.onWorktreeCreated === next.onWorktreeCreated &&
previous.drag === next.drag &&
previous.isDragging === next.isDragging &&
previous.dragHandleProps === next.dragHandleProps &&
previous.useNestable === next.useNestable &&
previous.dragGestureHostActive === next.dragGestureHostActive &&
previous.creatingWorkspaceIds === next.creatingWorkspaceIds &&
previous.activeWorkspaceSelection?.serverId === next.activeWorkspaceSelection?.serverId &&
areProjectBlockSelectionsEqual(previous, next)
);
}
function projectShortcutBadgesEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean {
if (
!next.showShortcutBadges ||
previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey
)
return true;
return next.project.workspaces.every(
({ workspaceKey }) =>
previous.shortcutIndexByWorkspaceKey.get(workspaceKey) ===
next.shortcutIndexByWorkspaceKey.get(workspaceKey),
);
}
function projectWorkspaceEntriesEqual(
previous: ProjectBlockProps,
next: ProjectBlockProps,
): boolean {
if (previous.workspaceEntriesByKey === next.workspaceEntriesByKey) return true;
return next.project.workspaces.every(
({ workspaceKey }) =>
previous.workspaceEntriesByKey.get(workspaceKey) ===
next.workspaceEntriesByKey.get(workspaceKey),
);
}
function areProjectBlockSelectionsEqual(
previous: ProjectBlockProps,
next: ProjectBlockProps,
@@ -2135,10 +2177,11 @@ function ProjectModeList({
(state) => state.togglePinnedCollapsed,
);
const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder);
const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder);
const manualSort = useSidebarViewStore((state) => state.workspaceSort === "manual");
const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder);
const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder);
const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder);
const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder);
const isWorkspaceRoute = useMemo(
() => Boolean(pathname && parseHostWorkspaceRouteFromPathname(pathname)),
@@ -2306,16 +2349,17 @@ function ProjectModeList({
selectionEnabled={selectionEnabled}
showShortcutBadges={showShortcutBadges}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
parentGestureRef={parentGestureRef}
onToggleCollapsed={onToggleProjectCollapsed}
onWorkspacePress={onWorkspacePress}
manualSort={manualSort}
parentGestureRef={parentGestureRef}
onWorkspaceReorder={handleWorkspaceReorder}
useNestable={platformIsNative}
dragGestureHostActive={dragGestureHostActive}
onWorktreeCreated={handleWorktreeCreated}
drag={dragState.drag}
isDragging={dragState.isDragging}
dragHandleProps={dragState.dragHandleProps}
useNestable={platformIsNative}
dragGestureHostActive={dragGestureHostActive}
creatingWorkspaceIds={creatingWorkspaceIds}
activeWorkspaceSelection={activeWorkspaceSelection}
hostBadgeByServerId={hostBadgeByServerId}
@@ -2329,6 +2373,9 @@ function ProjectModeList({
collapsedProjectKeys,
activeWorkspaceSelection,
handleWorktreeCreated,
manualSort,
parentGestureRef,
dragGestureHostActive,
handleWorkspaceReorder,
hostBadgeByServerId,
supportsMultiplicityByServerId,
@@ -2336,8 +2383,6 @@ function ProjectModeList({
onToggleWorkspacePin,
onWorkspacePress,
onToggleProjectCollapsed,
parentGestureRef,
dragGestureHostActive,
projectIconByProjectViewKey,
selectionEnabled,
shortcutIndexByWorkspaceKey,
@@ -167,7 +167,7 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
const hosts = useHosts();
// `allProjects`, never `projects`: the model's `projects` is already filtered, so a picker fed
// from it would lose the row that undoes the filter as soon as the filter narrowed to one.
const { allProjects, resolvedProjectFilters } = useSidebarModel();
const { allProjects, resolvedProjectFilters, workspaceSortSupported } = useSidebarModel();
const { labels } = useWorkspaceLabelProjection();
const [managerOpen, setManagerOpen] = useState(false);
const openManager = useCallback(() => setManagerOpen(true), []);
@@ -240,6 +240,16 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
},
];
if (preferences.grouping === "project") {
definitions.push({
id: "sort",
title: t("sidebar.display.sort.label"),
content: (
<SortPage preferences={preferences} automaticSortSupported={workspaceSortSupported} />
),
});
}
if (showHostFilter) {
definitions.push({
id: "hostFilter",
@@ -273,6 +283,7 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
}, [
t,
preferences,
workspaceSortSupported,
hosts,
showHostFilter,
showProjectFilter,
@@ -308,6 +319,18 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
>
{t("sidebar.display.grouping.label")}
</MenuSubTrigger>
{preferences.grouping === "project" ? (
<MenuSubTrigger
id="sort"
value={
workspaceSortSupported || preferences.workspaceSort === "manual"
? t(`sidebar.display.sort.${preferences.workspaceSort}`)
: undefined
}
>
{t("sidebar.display.sort.label")}
</MenuSubTrigger>
) : null}
<MenuSubTrigger
id="titleSource"
value={t(TITLE_SOURCE_LABEL_KEYS[preferences.titleSource])}
@@ -374,6 +397,42 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
);
}
function SortPage({
preferences,
automaticSortSupported,
}: {
preferences: Preferences;
automaticSortSupported: boolean;
}): ReactElement {
const { t } = useTranslation();
const { setWorkspaceSort } = preferences;
const selectManual = useCallback(() => setWorkspaceSort("manual"), [setWorkspaceSort]);
const selectCreated = useCallback(() => setWorkspaceSort("created"), [setWorkspaceSort]);
const selectLastPrompt = useCallback(() => setWorkspaceSort("lastPrompt"), [setWorkspaceSort]);
return (
<>
<MenuItem selected={preferences.workspaceSort === "manual"} onSelect={selectManual}>
{t("sidebar.display.sort.manual")}
</MenuItem>
{automaticSortSupported ? (
<>
<MenuItem selected={preferences.workspaceSort === "created"} onSelect={selectCreated}>
{t("sidebar.display.sort.created")}
</MenuItem>
<MenuItem
selected={preferences.workspaceSort === "lastPrompt"}
onSelect={selectLastPrompt}
>
{t("sidebar.display.sort.lastPrompt")}
</MenuItem>
</>
) : (
<MenuItem disabled>{t("sidebar.display.sort.updateHost")}</MenuItem>
)}
</>
);
}
/**
* Every label you could filter by, wherever it lives, one row each.
*

Some files were not shown because too many files have changed in this diff Show More