mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 23:44:41 +00:00
merge: sync main into PR #7495
Integrate getpaseo/paseo main at b8bb335c56.
Co-authored-by: RoundRedDot <roundreddot@gmail.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"ios": {
|
||||
"image": "macos-tahoe-26.6-xcode-27.1"
|
||||
},
|
||||
"env": {
|
||||
"APP_VARIANT": "production"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
+77
@@ -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()
|
||||
}
|
||||
}
|
||||
+36
@@ -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) }
|
||||
}
|
||||
+38
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -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))
|
||||
}
|
||||
|
||||
+61
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -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)
|
||||
}
|
||||
}
|
||||
+59
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user