Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 0edd8c39b9 chore(repo): open dummy pull request 2026-08-25 21:05:13 +02:00
Mohamed Boudra 4bfb682cd9 Cut Playwright feedback time without losing behavioral coverage (#3852)
* test(app): consolidate Playwright journeys

* test(app): stabilize consolidated Playwright flows

* test(app): make streaming dependencies explicit

* test(app): await profile feature persistence

* test(app): sample only actual workspace tabs
2026-08-25 20:55:05 +02:00
Mohamed Boudra c4dc701411 fix(app): migrate persisted Explorer layouts once (#3861)
The first-class Explorer reused the old Side panel persistence identity, so upgrades hydrated ordinary persisted tabs into the dock. Move those tabs into an ordinary side pane during the versioned migration while preserving main layouts, and keep the persisted key spelling stable at the storage boundary.
2026-08-25 20:50:26 +02:00
Matt Cowgerandpaseo-bot[bot] f73f65cd78 feat(app): close settings with Escape (#2828)
* feat(app): close settings with Escape

* test(app): cover settings Escape overlay ownership

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-08-25 18:02:18 +00:00
Christoph Leiter bffb33ed32 feat(app): match and rank Command Center results by query tokens (#2971)
The Command Center tested the whole trimmed query as one substring
against one concatenated haystack, so a query only matched when its
words appeared consecutively, in that order, with that spacing. Typing
`shortcuts` found "Keyboard shortcuts"; typing `short key` found
nothing. Survivors were then ordered by a fixed group rank, so the
best match was rarely first.

Route both through `scoreTextFields`, the matcher the combobox,
provider selection, directory suggestions and the daemon's history
search already share. It splits on whitespace, scores each token
against each field on its own, and requires every token to land
somewhere. `short key`, `key short`, `workspace new` and `project add`
all find their row now.

The score is the filter: `scoreSearchFields` returns null when a token
finds no home, so filtering and ranking are one pass rather than a
boolean predicate that throws the scan away. Workspaces and agents are
scored too, not just filtered, with each row's own ordering kept as the
tiebreak. Fields are split into visible and hidden, so a match on an
agent's `cwd` — which is never rendered — cannot outrank a match on its
title.

Subsequence matching is deliberately off here, unlike the composer's
slash-command list where it makes `/pasbab` find `/paseo-babysit`. It
widens far enough that three-letter typos hit several rows each, and
this list preselects its first row, so a query that should find nothing
would put an unrelated action under Enter. `MatchOptions.subsequence`
defaults to on, leaving every other caller unchanged. The tier has to
be suppressed during scoring rather than filtered afterwards, because
a two-token total of tier 5 is either 0+5 or 4+1.

Sections carry a band so the ones that reorder by relevance are
compared before the pinned ones, which keeps the comparator total: file
rows are matched by the daemon and carry no client-side score, so they
cannot be relevance-compared against rows that do.

Contiguous per-token matching is a strict widening of the old test.
Tokens hold no whitespace and the old haystack joined fields with a
space, so any token found there lay entirely within one field and is
still found. Verified across a query set at zero rows lost, and pinned
by a test.
2026-08-25 17:58:41 +00:00
Mohamed Boudra 8bfae15855 Keep current pull requests visible without wrongful auto-archive (#3799)
* fix(server): keep current PR visible without unsafe archival

PR discovery and merge-triggered archival answer different questions. Follow the checked-out branch for status, while requiring the daemon to observe the same PR move from open to merged before archiving a workspace.

* test(server): align managed worktree PR lookup expectation

Managed metadata without a matching PR target must not hide the unambiguous PR identity from the current branch tracking configuration.
2026-08-25 19:43:18 +02:00
paseo-ai[bot] c778c47b30 fix: update lockfile signatures and Nix hash [skip ci] 2026-08-25 14:08:49 +00:00
50 changed files with 2087 additions and 825 deletions
+3 -3
View File
@@ -348,12 +348,12 @@ jobs:
timeout-minutes: 10
run: npx playwright install chromium
- name: Build app dependencies
run: npm run build:app-deps
- name: Build server stack
run: npm run build:server
- name: Build native app dependency
run: npm run build --workspace=@getpaseo/expo-two-way-audio
- name: Install agent CLIs for provider tests
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
+1
View File
@@ -186,6 +186,7 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
- For full-suite confidence, push to CI and check GitHub Actions.
- Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow.
- App Playwright shares one warmed Metro server per run and gives every Playwright worker its own isolated daemon and `PASEO_HOME`. Spec files run concurrently without exposing one file's projects, agents, terminals, history, or provider configuration to another worker; tests within a file remain together so file-level setup is not repeated.
- Playwright specs that exercise only the daemon import `daemonTest` from the shared fixtures so they do not create a browser context or page.
- Helpers that create projects or workspaces own those records until cleanup. Their clients remove the daemon project on close, and an automatic fixture fails any test that still leaks a project record. Deleting only the temporary directory is not cleanup. Agent helpers pass the intended `workspaceId` through to agent creation; they never infer ownership from `cwd`.
- Tests whose subject is daemon-global state, such as an empty history or daemon restart, start a dedicated host explicitly. Filenames and directories describe product behavior, never execution order or isolation mechanics.
- Global setup accepts Metro as ready only when `/status` returns `packager-status:running`, then fetches the document's scripts so the cold bundle compilation finishes before Playwright's per-test timeout starts. A generic TCP listener is not sufficient readiness evidence. The browser suite uses direct local daemon connections and does not start a relay.
+1 -1
View File
@@ -1 +1 @@
sha256-TRZej2L43C3go4NWe496Dqs/4A+0GivCRtGzt3pX2dw=
sha256-gZ6egDKIDiDi9jmDlPamM+or2Tthwr17DbJ+P66t4RE=
@@ -66,54 +66,21 @@ async function expectProjectHasNoWorkspaces(projectId: string): Promise<void> {
test.describe("Add Project command-center flow", () => {
test.describe.configure({ timeout: 180_000 });
test("method selection has no search field", async ({ page }) => {
test("method selection shows the daemon's available project sources without search", async ({
page,
}) => {
await gotoAppShell(page);
await openAddProjectFlow(page);
await expect(addProjectFlowMethod(page, "directory-search")).toBeVisible();
await expect(addProjectFlowMethod(page, "github")).toContainText("Clone from GitHub");
await expect(addProjectFlowMethod(page, "new-directory")).toContainText("New directory");
await expect(addProjectFlowInput(page)).toHaveCount(0);
await expect(addProjectFlow(page).getByRole("textbox")).toHaveCount(0);
await expect(page.getByTestId("add-project-flow-page-host")).toHaveCount(0);
});
test("the back arrow, search input, and result glyph share one left edge", async ({ page }) => {
await gotoAppShell(page);
await openAddProjectFlow(page);
await page.keyboard.press("Enter");
await expectAddProjectPage(page, "directory-search");
await addProjectFlowInput(page).fill("/tmp");
const backGlyph = addProjectFlowBack(page).locator("svg");
const resultGlyph = addProjectFlow(page)
.locator('[data-testid^="add-project-flow-path-"]')
.first()
.locator("svg");
await expect(resultGlyph).toBeVisible();
const [backBox, inputBox, resultBox, titleBox, resultsBox, footerBox] = await Promise.all([
backGlyph.boundingBox(),
addProjectFlowInput(page).boundingBox(),
resultGlyph.boundingBox(),
page.getByTestId("add-project-flow-title").boundingBox(),
page.getByTestId("add-project-flow-results").boundingBox(),
page.getByTestId("add-project-flow-footer").boundingBox(),
]);
expect(backBox).not.toBeNull();
expect(inputBox).not.toBeNull();
expect(resultBox).not.toBeNull();
expect(titleBox).not.toBeNull();
expect(resultsBox).not.toBeNull();
expect(footerBox).not.toBeNull();
if (!backBox || !inputBox || !resultBox || !titleBox || !resultsBox || !footerBox) return;
expect(Math.abs(backBox.x - inputBox.x)).toBeLessThanOrEqual(2);
expect(Math.abs(resultBox.x - inputBox.x)).toBeLessThanOrEqual(2);
expect(titleBox.height).toBeLessThanOrEqual(24);
expect(resultsBox.y + resultsBox.height).toBeLessThanOrEqual(footerBox.y + 1);
});
test("an offline extra host neither appears nor forces host selection", async ({ page }) => {
await gotoAppShell(page);
await addOfflineHostAndReload(page, {
@@ -273,14 +240,6 @@ test.describe("Add Project command-center flow", () => {
await expectProjectHasNoWorkspaces(projectId);
});
test("the current daemon advertises Clone from GitHub and New directory", async ({ page }) => {
await gotoAppShell(page);
await openAddProjectFlow(page);
await expect(addProjectFlowMethod(page, "github")).toContainText("Clone from GitHub");
await expect(addProjectFlowMethod(page, "new-directory")).toContainText("New directory");
});
test("a complete repository URL remains selectable without a GitHub search result", async ({
page,
}) => {
@@ -1,30 +1,9 @@
import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { expectAgentIdle, expectAgentReadyToInterrupt } from "../support/helpers/agent-stream";
import {
cancelAgent,
expectComposerDraft,
expectComposerVisible,
submitMessage,
} from "../support/helpers/composer";
import { cancelAgent, expectComposerVisible, submitMessage } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
async function completeSubmittedTurn(
page: Page,
agent: Awaited<ReturnType<typeof seedMockAgentWorkspace>>,
prompt: string,
): Promise<Locator> {
await submitMessage(page, prompt);
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(userMessage).toBeVisible();
const finish = await agent.client.waitForFinish(agent.agentId, 30_000);
expect(finish.status).toBe("idle");
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
await expect(userMessage).toHaveAttribute("aria-busy", "false");
return userMessage;
}
async function rewindConversation(page: Page, userMessage: Locator, prompt: string): Promise<void> {
await userMessage.getByText(prompt, { exact: true }).hover();
await userMessage.getByRole("button", { name: "Rewind to this message", exact: true }).click();
@@ -42,29 +21,6 @@ async function expectTurnCompletesNormally(
}
test.describe("Agent message rewind", () => {
test("rewinds a submitted prompt and restores it to the composer", async ({ page }) => {
test.setTimeout(90_000);
const prompt = "Restore this submitted prompt after rewind.";
const agent = await seedMockAgentWorkspace({
repoPrefix: "message-rewind-roundtrip-",
title: "Message rewind roundtrip",
model: "ten-second-stream",
});
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
const userMessage = await completeSubmittedTurn(page, agent, prompt);
await rewindConversation(page, userMessage, prompt);
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toHaveCount(0);
await expectComposerDraft(page, prompt);
} finally {
await agent.cleanup();
}
});
test("keeps a pre-acknowledgement turn running after rewind is rejected", async ({ page }) => {
test.setTimeout(90_000);
const prompt = "Delay synthetic user message by 2000ms.";
@@ -103,6 +103,18 @@ async function readProviderModePreference(page: Page, provider: string): Promise
);
}
async function readProviderFeaturePreferences(page: Page, provider: string): Promise<unknown> {
return page.evaluate(
({ preferencesKey, providerId }) => {
const raw = localStorage.getItem(preferencesKey);
if (!raw) return null;
const preferences = JSON.parse(raw) as FormPreferences;
return preferences.providerPreferences?.[providerId]?.featureValues ?? null;
},
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, providerId: provider },
);
}
test.describe("Agent profiles repair modeless provider preferences", () => {
test.describe.configure({ timeout: 240_000 });
@@ -207,6 +219,9 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
await openModelPicker(page);
await applyProfileFromPicker(page, "Fast profile");
await expect
.poll(() => readProviderFeaturePreferences(page, featureProviderId), { timeout: 10_000 })
.toEqual({ fast_mode: true });
await submitNewWorkspacePrompt(page, "Create an agent with profile features.");
const createAgentRequest = await createAgentRecorder.waitForRequest();
@@ -10,6 +10,7 @@ async function openMockAgentAtMobileBreakpoint(page: Page) {
repoPrefix: "bottom-sheet-reopen-",
title: "Bottom sheet reopen e2e",
initialPrompt: "Prepare a bottom sheet reopen test agent.",
model: "ten-second-stream",
});
await openAgentRoute(page, session);
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toBeVisible({
@@ -22,16 +23,6 @@ async function openMockAgentAtMobileBreakpoint(page: Page) {
return session;
}
async function withMobileMockAgent(page: Page, run: () => Promise<void>) {
const session = await openMockAgentAtMobileBreakpoint(page);
try {
await run();
} finally {
await session.cleanup();
}
}
function bottomSheetBackdrop(page: Page) {
return page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
}
@@ -108,35 +99,36 @@ async function openAndCloseModelSelectorTwice(page: Page) {
}
test.describe("mobile bottom sheet reopen", () => {
test("tab switcher can open, close, reopen, and close again", async ({ page }) => {
await withMobileMockAgent(page, async () => {
await openAndCloseTabSwitcherTwice(page);
});
});
test("model selector can open, close, reopen, and close again", async ({ page }) => {
await withMobileMockAgent(page, async () => {
await openAndCloseModelSelectorTwice(page);
});
});
test("search selection returns to configuration options", async ({ page }) => {
await withMobileMockAgent(page, async () => {
await openModelSelector(page);
const sheet = page.getByTestId("agent-controls-model-sheet");
await page.getByTestId("model-search-all-input").click();
const model = page.getByRole("button", { name: /^Ten second stream/ });
await expect(model).toBeVisible({
timeout: 10_000,
test("sheets reopen and model search returns to configuration", async ({ page }) => {
const session = await openMockAgentAtMobileBreakpoint(page);
try {
await test.step("tab switcher opens, closes, and reopens", async () => {
await openAndCloseTabSwitcherTwice(page);
});
await model.click();
await test.step("model selector opens, closes, and reopens", async () => {
await openAndCloseModelSelectorTwice(page);
});
await expect(sheet).toBeVisible();
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible();
await expect(page.getByTestId("agent-controls-model")).toContainText("Ten second stream");
await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible();
});
await test.step("model search returns to configuration", async () => {
await openModelSelector(page);
const sheet = page.getByTestId("agent-controls-model-sheet");
await page.getByTestId("model-search-all-input").click();
const model = page.getByRole("button", { name: /^Ten second stream/ });
await expect(model).toBeVisible({
timeout: 10_000,
});
await model.click();
await expect(sheet).toBeVisible();
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible();
await expect(page.getByTestId("agent-controls-model")).toContainText("Ten second stream");
await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible();
});
} finally {
await session.cleanup();
}
});
});
@@ -46,38 +46,6 @@ test.describe("Branch switcher", () => {
// releases stale sessions from the previous spec; one retry stabilizes it.
test.describe.configure({ retries: 1 });
test("switches the workspace branch from the git diff panel for an opaque workspace id", async ({
page,
}) => {
test.setTimeout(90_000);
const serverId = getServerId();
const workspace = await seedWorkspace({
repoPrefix: "branch-switch-",
repo: { branches: ["main", "dev"] },
});
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: workspace.workspaceId });
await openChangesPanel(page);
await expectWorkspaceBranch(page, "main");
await switchBranchFromChangesPanel(page, { from: "main", to: "dev" });
await expectWorkspaceBranch(page, "dev");
await expect
.poll(
async () =>
(await readWorktreeBranchInfo({ worktreePath: workspace.repoPath })).currentBranch,
{ timeout: 30_000 },
)
.toBe("dev");
} finally {
await workspace.cleanup();
}
});
test("a custom workspace title stays in the header while the diff panel switches the real branch", async ({
page,
}) => {
@@ -4,6 +4,7 @@ import {
clickChatOutlineRowEdge,
disableChatOutlineFromAppearance,
expectActiveChatOutlinePrompt,
expectActiveChatOutlinePromptMovedFrom,
expectChatOutlinePreview,
expectChatOutlinePrompts,
expectChatOutlinePromptToRemainBare,
@@ -29,7 +30,6 @@ import {
openAgentTimeline,
scrollThroughOlderHistoryPages,
scrollTimelineToNewestLoadedEdge,
scrollTimelineToOldestLoadedEdge,
seedLongMockAgentTimeline,
type LongTimelineAgent,
} from "../support/helpers/timeline-pagination";
@@ -130,11 +130,12 @@ test.describe("desktop chat outline", () => {
await expectOneActiveChatOutlinePrompt(page);
await clickChatOutlineRowEdge(page, 4);
await expectTimelinePromptVisible(page, agent.prompts[3]);
await expectTimelinePromptLandedBelowTop(page, agent.prompts[3]);
await expectActiveChatOutlinePrompt(page, 4);
await scrollTimelineToOldestLoadedEdge(page);
await expectActiveChatOutlinePrompt(page, 1);
await scrollTimelineToNewestLoadedEdge(page);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await expectActiveChatOutlinePromptMovedFrom(page, 4);
});
test("keeps a clicked prompt free of persistent selection chrome", async ({ page }) => {
@@ -4,6 +4,7 @@ import { closeCommandCenter, openCommandCenter } from "../support/helpers/comman
import {
applyCommandCenterAgentControls,
chooseCommandCenterAgentControl,
openCommandCenterForAgent,
expectCommandCenterAgentControlSelected,
expectFocusedAgentControls,
expectWorkspaceAgentConfiguration,
@@ -12,7 +13,9 @@ import {
} from "../support/helpers/command-center-agent-controls";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { expectAppRoute } from "../support/helpers/route-assertions";
import { seedWorkspace } from "../support/helpers/seed-client";
import { buildSchedulesRoute } from "@/utils/host-routes";
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
@@ -76,6 +79,107 @@ test.describe("Command Center agent controls", () => {
}
});
test("activates the best-matching row, not the highest-ranked group", async ({ page }) => {
// "Mode › Load Test" matches "load" at offset 0; every "Model › Mock Load Test › …" row
// matches at offset 5. Models used to outrank modes by fixed group rank, so Enter picked a
// model. Asserting both model and mode catches either row winning.
const workspace = await seedMockAgentWorkspace({
repoPrefix: "command-center-relevance-",
title: "Command Center relevance",
model: "five-minute-stream",
});
try {
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
const input = await openCommandCenterForAgent(page, workspace);
await input.fill("load");
await page.keyboard.press("Enter");
await expectWorkspaceAgentConfiguration(workspace, {
id: workspace.agentId,
provider: "mock",
model: "five-minute-stream",
modeId: "load-test",
});
} finally {
await workspace.cleanup();
}
});
test("matches query tokens in any order", async ({ page }) => {
// "Mode › Load test" flattens to "mode load test", so the reversed query "test load" is never
// a substring of it. Only per-token matching finds this row.
const workspace = await seedMockAgentWorkspace({
repoPrefix: "command-center-tokenized-",
title: "Command Center tokenized search",
model: "five-minute-stream",
});
try {
await workspace.client.setAgentMode(workspace.agentId, "legacy-mode");
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
await openCommandCenter(page);
await chooseCommandCenterAgentControl({
page,
query: "test load",
choice: "Mode › Load test",
});
await expectWorkspaceAgentConfiguration(workspace, {
id: workspace.agentId,
provider: "mock",
model: "five-minute-stream",
modeId: "load-test",
});
} finally {
await workspace.cleanup();
}
});
test("keeps an action first when the action is the best match", async ({ page }) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "command-center-action-priority-",
title: "Command Center action priority",
model: "five-minute-stream",
});
try {
const input = await openCommandCenterForAgent(page, workspace);
await input.fill("sched");
await page.keyboard.press("Enter");
await expectAppRoute(page, buildSchedulesRoute(), { timeout: 30_000 });
} finally {
await workspace.cleanup();
}
});
test("drops an arrow-key selection once the query changes", async ({ page }) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "command-center-active-reset-",
title: "Command Center active reset",
model: "five-minute-stream",
});
try {
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
const input = await openCommandCenterForAgent(page, workspace);
await input.fill("loa");
// Moves the highlight onto a model row, which still matches after the next keystroke.
await page.keyboard.press("ArrowDown");
await input.fill("load");
await page.keyboard.press("Enter");
await expectWorkspaceAgentConfiguration(workspace, {
id: workspace.agentId,
provider: "mock",
model: "five-minute-stream",
modeId: "load-test",
});
} finally {
await workspace.cleanup();
}
});
test("applies draft model and setting choices to the created agent", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "command-center-draft-controls-" });
await seedMockDraftPreferences(page);
@@ -1,53 +1,14 @@
import { randomUUID } from "node:crypto";
import { expect } from "@playwright/test";
import { test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { createIdleAgent } from "../support/helpers/archive-tab";
import { openCommandCenter } from "../support/helpers/command-center";
import { addOfflineHostAndReload } from "../support/helpers/hosts";
import { expectAgentTabActive } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
const PRIMARY_HOST_LABEL = "Primary Host";
const SECONDARY_HOST_ID = "host-command-center-secondary";
test.describe("Command center host labels", () => {
test.describe.configure({ timeout: 180_000 });
test("agent results show the host they live on when more than one host exists", async ({
page,
}) => {
const seeded = await seedWorkspace({ repoPrefix: "command-center-host-" });
const title = `cc-host-${randomUUID().slice(0, 8)}`;
try {
const agent = await createIdleAgent(seeded.client, {
cwd: seeded.repoPath,
workspaceId: seeded.workspaceId,
title,
});
// A second (offline) host makes the view multi-host, which is when the host label earns its space.
await gotoAppShell(page);
await addOfflineHostAndReload(page, {
serverId: SECONDARY_HOST_ID,
label: "Secondary Host",
primaryLabel: PRIMARY_HOST_LABEL,
});
const panel = await openCommandCenter(page);
// The shared daemon may carry agents from other specs, so target this agent by its id.
const row = panel.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row).toContainText(title);
await expect(row).toContainText(PRIMARY_HOST_LABEL);
} finally {
await seeded.cleanup();
}
});
test("selecting an agent result opens its workspace tab", async ({ page }) => {
const seeded = await seedWorkspace({ repoPrefix: "command-center-agent-navigation-" });
const title = `cc-navigation-${randomUUID().slice(0, 8)}`;
@@ -99,6 +99,7 @@ test("composer growth keeps a bottom-pinned chat at the bottom", async ({ page }
repoPrefix: "composer-bottom-anchor-",
title: "Composer bottom anchor",
initialPrompt: "Produce enough content to make the chat scrollable.",
model: "ten-second-stream",
});
try {
@@ -64,6 +64,7 @@ test.describe("New workspace: launch target memory", () => {
await test.step("launching a terminal profile preselects it the next time New workspace opens", async () => {
await openNewWorkspace();
await selectLaunchOption(page, PROMPT_PROFILE.id);
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
await fillTerminalPrompt(page, "remember me");
await submitTerminalLaunch(page);
await expectWorkspaceOpensWithTerminalTab(page);
@@ -88,6 +89,7 @@ test.describe("New workspace: launch target memory", () => {
await test.step("re-selecting the terminal profile, then removing it daemon-side, falls back to Chat rather than a dead entry", async () => {
await selectLaunchOption(page, PROMPT_PROFILE.id);
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
await fillTerminalPrompt(page, "second run");
await submitTerminalLaunch(page);
await expectWorkspaceOpensWithTerminalTab(page);
@@ -153,6 +153,7 @@ test.describe("Rewind sheet", () => {
repoPrefix: "rewind-e2e-",
title: "Rewind e2e",
initialPrompt: firstPrompt,
model: "ten-second-stream",
});
try {
@@ -70,73 +70,55 @@ test.describe("History search", () => {
await tempRepo?.cleanup();
});
test("typing narrows history to matching sessions and clearing restores them", async ({
page,
}) => {
test("searches, ranks, highlights, and clears session history", async ({ page }) => {
await resetSeededPageState(page);
await openSessions(page);
// Seeded newest-first, and at rest history is chronological.
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, { timeout: 30_000 });
await test.step("narrows history and restores chronological grouping", async () => {
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, {
timeout: 30_000,
});
await search(page, `${NONCE} billing`);
await expectVisibleTitles(page, [TITLES.billing]);
await search(page, `${NONCE} billing`);
await expectVisibleTitles(page, [TITLES.billing]);
await expect(page.getByText("Today", { exact: true })).toHaveCount(0, {
timeout: 30_000,
});
// Ranked results are one flat list — a day heading would claim an order
// the list no longer has.
await expect(page.getByText("Today", { exact: true })).toHaveCount(0, { timeout: 30_000 });
await page.getByTestId("sessions-search-clear").click();
await expect(page.getByTestId("sessions-search-input")).toHaveValue("");
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, {
timeout: 30_000,
});
});
await page.getByTestId("sessions-search-clear").click();
await expect(page.getByTestId("sessions-search-input")).toHaveValue("");
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, { timeout: 30_000 });
});
await test.step("ranks whole-word matches above substrings", async () => {
await search(page, `${NONCE} bill`);
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled]);
});
test("ranks a whole-word hit above one buried inside a word", async ({ page }) => {
await resetSeededPageState(page);
await openSessions(page);
await test.step("highlights exact and typo-resolved matches", async () => {
await search(page, `${NONCE} billing`);
const row = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
await expect(row.getByText("billing", { exact: true })).toBeVisible({ timeout: 30_000 });
// "bill" starts a word in "billing" and hides inside "unbilled", so the
// stronger match leads even though both sessions are equally recent.
await search(page, `${NONCE} bill`);
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled]);
});
await search(page, `${NONCE} bulling`);
await expectVisibleTitles(page, [TITLES.billing]);
const typoRow = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
await expect(typoRow.getByText("billing", { exact: true })).toBeVisible({
timeout: 30_000,
});
});
test("marks the characters each result matched on", async ({ page }) => {
await resetSeededPageState(page);
await openSessions(page);
await test.step("distinguishes no matches from empty history", async () => {
await search(page, `${NONCE} kubernetes`);
await expect(page.getByTestId("sessions-empty")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("No sessions match")).toBeVisible({ timeout: 30_000 });
// The mark is a nested Text run, so the matched slice is its own element.
await search(page, `${NONCE} billing`);
const row = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row.getByText("billing", { exact: true })).toBeVisible({ timeout: 30_000 });
// A typo has no characters in the text to point at, so the whole word it
// resolved to is marked.
await search(page, `${NONCE} bulling`);
const typoRow = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
await expect(typoRow.getByText("billing", { exact: true })).toBeVisible({ timeout: 30_000 });
});
test("finds a session through a typo", async ({ page }) => {
await resetSeededPageState(page);
await openSessions(page);
await search(page, `${NONCE} bulling`);
await expectVisibleTitles(page, [TITLES.billing]);
});
test("says the query found nothing, not that history is empty", async ({ page }) => {
await resetSeededPageState(page);
await openSessions(page);
await search(page, `${NONCE} kubernetes`);
await expect(page.getByTestId("sessions-empty")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("No sessions match")).toBeVisible({ timeout: 30_000 });
await page.getByText("Clear search").click();
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
await page.getByText("Clear search").click();
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
});
});
});
@@ -121,6 +121,41 @@ test.describe("Settings sidebar navigation", () => {
await clickSettingsBackToWorkspace(page);
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
});
test("pressing Escape closes settings", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await page.keyboard.press("Escape");
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
});
test("Escape lets settings dropdowns and modals close before leaving settings", async ({
page,
}) => {
await gotoAppShell(page);
await openSettings(page);
await test.step("a dropdown owns Escape", async () => {
await openSettingsSection(page, "appearance");
await page.getByLabel(/Theme:/).click();
await expect(page.getByRole("menuitem", { name: "System", exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("menuitem", { name: "System", exact: true })).toHaveCount(0);
await expect(page).toHaveURL(/\/settings(\/|$)/);
});
await test.step("a modal owns Escape", async () => {
await openAddHostFlow(page);
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByText("Add connection", { exact: true })).toHaveCount(0);
await expect(page).toHaveURL(/\/settings(\/|$)/);
});
});
});
test.describe("Settings — compact master-detail", () => {
+26 -35
View File
@@ -41,46 +41,37 @@ async function expectExternalPage(
await popup.close();
}
test("opens troubleshooting tools from the sidebar help menu", async ({ page }) => {
test("opens troubleshooting, support, and release destinations", async ({ page }) => {
await gotoAppShell(page);
await expect(page.getByTestId("sidebar-help")).toBeVisible();
await openHelpMenu(page);
const triggerBox = await page.getByTestId("sidebar-help").evaluate((element) => {
const { y, height } = element.getBoundingClientRect();
return { y, height };
await test.step("opens diagnostics and keyboard shortcuts", async () => {
await openHelpMenu(page);
await expect(page.getByText("Help", { exact: true })).toBeVisible();
await expect(page.getByText("Report an issue", { exact: true })).toBeVisible();
await expect(page.getByText("What's new", { exact: true })).toBeVisible();
await expect(page.getByTestId("sidebar-help-version")).toHaveText(APP_VERSION);
await page.getByTestId("sidebar-help-diagnostics").click();
await expectDiagnosticReport(page);
await closeSheet(page, "app-diagnostic-sheet");
await openHelpMenu(page);
await page.getByTestId("sidebar-help-shortcuts").click();
await expect(page.getByTestId("keyboard-shortcuts-dialog")).toBeVisible();
await closeSheet(page, "keyboard-shortcuts-dialog");
});
const menuBox = await page.getByTestId("sidebar-help-menu").evaluate((element) => {
const { y, height } = element.getBoundingClientRect();
return { y, height };
await test.step("opens support and release pages", async () => {
await openHelpMenu(page);
await expectExternalPage(page, "sidebar-help-discord", DISCORD_DESTINATION);
await openHelpMenu(page);
await expectExternalPage(page, "sidebar-help-github", GITHUB_ISSUE_DESTINATION);
await openHelpMenu(page);
await expectExternalPage(page, "sidebar-help-changelog", CHANGELOG_DESTINATION);
});
expect(menuBox.y + menuBox.height).toBeLessThanOrEqual(triggerBox.y);
await expect(page.getByText("Help", { exact: true })).toBeVisible();
await expect(page.getByText("Report an issue", { exact: true })).toBeVisible();
await expect(page.getByText("What's new", { exact: true })).toBeVisible();
await expect(page.getByTestId("sidebar-help-version")).toHaveText(APP_VERSION);
await page.getByTestId("sidebar-help-diagnostics").click();
await expectDiagnosticReport(page);
await closeSheet(page, "app-diagnostic-sheet");
await openHelpMenu(page);
await page.getByTestId("sidebar-help-shortcuts").click();
await expect(page.getByTestId("keyboard-shortcuts-dialog")).toBeVisible();
await closeSheet(page, "keyboard-shortcuts-dialog");
});
test("opens support and release destinations", async ({ page }) => {
await gotoAppShell(page);
await openHelpMenu(page);
await expectExternalPage(page, "sidebar-help-discord", DISCORD_DESTINATION);
await openHelpMenu(page);
await expectExternalPage(page, "sidebar-help-github", GITHUB_ISSUE_DESTINATION);
await openHelpMenu(page);
await expectExternalPage(page, "sidebar-help-changelog", CHANGELOG_DESTINATION);
});
test("searches keyboard shortcuts from the sidebar help menu", async ({ page }) => {
@@ -1,39 +0,0 @@
import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
test.use({ viewport: { width: 1600, height: 900 } });
async function expectBorderHighlight(
page: Page,
testID: string,
hoverTarget: Locator,
expectedWidth: string,
) {
const handle = page.getByTestId(testID);
await expect(handle).toBeVisible();
await expect(page.getByTestId(`${testID}-highlight`)).toHaveCount(0);
await hoverTarget.hover();
const highlight = page.getByTestId(`${testID}-highlight`);
await expect(highlight).toBeVisible();
await expect(highlight).toHaveCSS("width", expectedWidth);
await expect(highlight).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
}
test("sidebar and workspace pane borders highlight on hover", async ({ page, withWorkspace }) => {
const workspace = await withWorkspace({ prefix: "sidebar-resize-handle-" });
await workspace.navigateTo();
const sidebarHandle = page.getByTestId("left-sidebar-resize-handle");
await expectBorderHighlight(page, "left-sidebar-resize-handle", sidebarHandle, "1px");
await page.getByTestId("workspace-explorer-toggle").first().click();
const splitHandle = page.getByTestId("workspace-explorer-sidebar-resize-handle");
await expectBorderHighlight(
page,
"workspace-explorer-sidebar-resize-handle",
splitHandle.getByRole("separator"),
"3px",
);
});
@@ -146,20 +146,37 @@ async function installPinRpcGate(
}
test.describe("Pin workspace shortcut", () => {
test("keeps project creation reachable after pinning its only workspace", async ({ page }) => {
test("dispatches pin once and keeps project creation reachable", async ({ page }) => {
const workspace = await seedWorkspace({
repoPrefix: "pin-project-creation-",
title: "Pinned workspace",
});
try {
const gate = await installPinRpcGate(page);
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
await page.keyboard.press(PIN_SHORTCUT);
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
await openNewWorkspaceComposer(page, workspace);
await expectNewWorkspaceProjectSelected(page, workspace.projectDisplayName);
await test.step("sends one RPC for each pin transition", async () => {
await page.keyboard.press(PIN_SHORTCUT);
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
expect(gate.sentCount()).toBe(1);
await page.keyboard.press(PIN_SHORTCUT);
await expect(pinnedSection(page)).toHaveCount(0, { timeout: 10_000 });
expect(gate.sentCount()).toBe(2);
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(1);
});
await test.step("keeps the pinned project available to workspace creation", async () => {
await page.keyboard.press(PIN_SHORTCUT);
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
expect(gate.sentCount()).toBe(3);
await openNewWorkspaceComposer(page, workspace);
await expectNewWorkspaceProjectSelected(page, workspace.projectDisplayName);
});
} finally {
await workspace.cleanup();
}
@@ -207,34 +224,6 @@ test.describe("Pin workspace shortcut", () => {
}
});
test("sends exactly one pin RPC per press when the row is rendered and selected", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-expanded-" });
try {
const gate = await installPinRpcGate(page);
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
await page.keyboard.press(PIN_SHORTCUT);
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
// Counting frames catches a press that produces zero or two RPCs — a misfiring in-flight
// guard, or a second dispatch path. It cannot detect a duplicate handler registration:
// `keyboardActionDispatcher.dispatch` returns at the first handler that returns true, so a
// shadowed second handler is unobservable from outside by design.
expect(gate.sentCount()).toBe(1);
await page.keyboard.press(PIN_SHORTCUT);
await expect(pinnedSection(page)).toHaveCount(0, { timeout: 10_000 });
expect(gate.sentCount()).toBe(2);
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(1);
} finally {
await workspace.cleanup();
}
});
test("pins the active workspace while its status group is collapsed", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-status-" });
@@ -118,19 +118,6 @@ test.describe("Sidebar workspace list", () => {
}
});
test("project shows workspace under it", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-workspace-under-project-" });
try {
await gotoAppShell(page);
await waitForSidebarProject(page, path.basename(workspace.repoPath));
await waitForSidebarWorkspace(page, workspace.workspaceId);
} finally {
await workspace.cleanup();
}
});
test("non-git project shows directory name", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-directory-", git: false });
@@ -11,35 +11,37 @@ test.describe("Workspace lifecycle", () => {
// One retry is enough for the daemon to stabilize.
test.describe.configure({ retries: 1 });
test.describe("Main checkout", () => {
test("creates an agent chat via New Chat", async ({ page, withWorkspace }) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "lifecycle-main-chat-" });
await workspace.navigateTo();
test("main checkout creates an agent chat and terminal in the project root", async ({
page,
withWorkspace,
}) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "lifecycle-main-" });
await workspace.navigateTo();
await test.step("creates an agent chat", async () => {
await createAgentChatFromLauncher(page);
});
test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "lifecycle-main-shell-" });
await workspace.navigateTo();
await test.step("creates a terminal in the project root", async () => {
await createStandaloneTerminalFromLauncher(page);
await expectTerminalCwd(page, workspace.repoPath);
});
});
test.describe("Worktree workspace", () => {
test("creates an agent chat via New Chat", async ({ page, withWorkspace }) => {
test.setTimeout(90_000);
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-chat-" });
await workspace.navigateTo();
test("worktree creates an agent chat and terminal in its directory", async ({
page,
withWorkspace,
}) => {
test.setTimeout(90_000);
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-" });
await workspace.navigateTo();
await test.step("creates an agent chat", async () => {
await createAgentChatFromLauncher(page);
});
test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => {
test.setTimeout(90_000);
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-shell-" });
await workspace.navigateTo();
await test.step("creates a terminal in the worktree", async () => {
await createStandaloneTerminalFromLauncher(page);
await expectTerminalCwd(page, workspace.repoPath);
});
@@ -1,5 +1,5 @@
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import type { Locator } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { createIdleAgent } from "../support/helpers/archive-tab";
import { expectComposerVisible } from "../support/helpers/composer";
@@ -35,10 +35,22 @@ async function expectSameRenderedNode(
);
}
async function expectNodeConnected(node: Awaited<ReturnType<typeof captureRenderedNode>>) {
expect(await node.evaluate((candidate) => candidate.isConnected)).toBe(true);
}
async function getSettingsShortcut(page: Page) {
return page.evaluate(() =>
navigator.platform.toLowerCase().includes("mac") ? "Meta+," : "Control+,",
);
}
async function waitForWorkspaceRoute(page: Page, route: string) {
await page.waitForURL((url) => url.pathname === route);
}
test.describe("Workspace pane mounting", () => {
test("opening the first split pane keeps the existing agent composer mounted", async ({
page,
}) => {
test("workspace navigation keeps the existing agent composer mounted", async ({ page }) => {
test.setTimeout(90_000);
const serverId = getServerId();
@@ -65,55 +77,35 @@ test.describe("Workspace pane mounting", () => {
.first()
.elementHandle();
expect(originalComposer).not.toBeNull();
const emptyPanesBeforeSplit = await page.getByTestId("workspace-new-tab-panel").count();
await test.step("opening the first split pane preserves the composer", async () => {
const emptyPanesBeforeSplit = await page.getByTestId("workspace-new-tab-panel").count();
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(
emptyPanesBeforeSplit + 1,
{ timeout: 30_000 },
);
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toHaveCount(1);
const originalStillConnected = await originalComposer!.evaluate((node) => node.isConnected);
expect(originalStillConnected).toBe(true);
} finally {
await workspace.cleanup();
}
});
test("opening Settings hides workspace overlays and keeps the agent composer mounted", async ({
page,
}) => {
test.setTimeout(90_000);
const serverId = getServerId();
const workspace = await seedWorkspace({ repoPrefix: "settings-pane-retention-" });
try {
const agent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `settings-pane-retention-${Date.now()}`,
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(
emptyPanesBeforeSplit + 1,
{ timeout: 30_000 },
);
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toHaveCount(
1,
);
await expectNodeConnected(originalComposer!);
});
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId));
await waitForWorkspaceTabsVisible(page);
const composer = page.getByTestId("message-input-root").filter({ visible: true }).first();
const originalComposer = await captureRenderedNode(composer);
await test.step("desktop Settings closes overlays and preserves the composer", async () => {
const tab = page.getByTestId(`workspace-tab-agent_${agent.id}`).first();
await tab.click({ button: "right" });
await page.getByTestId(`workspace-tab-context-agent_${agent.id}-rename`).click();
const renameInput = renameModalInput(page, `workspace-tab-rename-modal-agent-${agent.id}`);
await expect(renameInput).toBeVisible();
const tab = page.getByTestId(`workspace-tab-agent_${agent.id}`).first();
await tab.click({ button: "right" });
await page.getByTestId(`workspace-tab-context-agent_${agent.id}-rename`).click();
const renameInput = renameModalInput(page, `workspace-tab-rename-modal-agent-${agent.id}`);
await expect(renameInput).toBeVisible();
const settingsShortcut = await page.evaluate(() =>
navigator.platform.toLowerCase().includes("mac") ? "Meta+," : "Control+,",
);
await page.keyboard.press(settingsShortcut);
await expect(page).toHaveURL(/\/settings\/general$/);
await expect(renameInput).not.toBeVisible();
await clickSettingsBackToWorkspace(page);
await expectSameRenderedNode(originalComposer, composer);
const settingsShortcut = await getSettingsShortcut(page);
await page.keyboard.press(settingsShortcut);
await expect(page).toHaveURL(/\/settings\/general$/);
await expect(renameInput).not.toBeVisible();
await clickSettingsBackToWorkspace(page);
await expectSameRenderedNode(originalComposer!, composer);
});
} finally {
await workspace.cleanup();
}
@@ -140,18 +132,17 @@ test.describe("Workspace pane mounting", () => {
const composer = page.getByTestId("message-input-root").filter({ visible: true }).first();
const originalComposer = await captureRenderedNode(composer);
await openCompactSettings(page, buildHostWorkspaceRoute(serverId, workspace.workspaceId));
const workspaceRoute = buildHostWorkspaceRoute(serverId, workspace.workspaceId);
await openCompactSettings(page, workspaceRoute);
await page.goBack();
await page.waitForURL(
(url) => url.pathname === buildHostWorkspaceRoute(serverId, workspace.workspaceId),
);
await waitForWorkspaceRoute(page, workspaceRoute);
await expectSameRenderedNode(originalComposer, composer);
} finally {
await workspace.cleanup();
}
});
test("switching away from a terminal tab keeps its emulator mounted", async ({ page }) => {
test("workspace navigation keeps the terminal emulator mounted", async ({ page }) => {
test.setTimeout(90_000);
const serverId = getServerId();
const workspace = await seedWorkspace({ repoPrefix: "terminal-pane-retention-" });
@@ -170,39 +161,20 @@ test.describe("Workspace pane mounting", () => {
const terminalSurface = terminalSurfaceLocator(page);
const originalTerminal = await captureRenderedNode(terminalSurface);
await page.getByTestId(`workspace-tab-agent_${agent.id}`).click();
await expectComposerVisible(page);
expect(await originalTerminal.evaluate((node) => node.isConnected)).toBe(true);
await test.step("switching tabs preserves the terminal", async () => {
await page.getByTestId(`workspace-tab-agent_${agent.id}`).click();
await expectComposerVisible(page);
await expectNodeConnected(originalTerminal);
await clickFirstTerminalTab(page);
await expectSameRenderedNode(originalTerminal, terminalSurface);
} finally {
await workspace.cleanup();
}
});
test("opening Settings and returning keeps the terminal emulator mounted", async ({ page }) => {
test.setTimeout(90_000);
const serverId = getServerId();
const workspace = await seedWorkspace({ repoPrefix: "settings-terminal-retention-" });
try {
const agent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `settings-terminal-retention-${Date.now()}`,
await clickFirstTerminalTab(page);
await expectSameRenderedNode(originalTerminal, terminalSurface);
});
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId));
await waitForWorkspaceTabsVisible(page);
await clickNewTerminal(page);
await expectTerminalSurfaceVisible(page);
const terminalSurface = terminalSurfaceLocator(page);
const originalTerminal = await captureRenderedNode(terminalSurface);
await openSettings(page);
await clickSettingsBackToWorkspace(page);
await expectSameRenderedNode(originalTerminal, terminalSurface);
await test.step("opening Settings preserves the terminal", async () => {
await openSettings(page);
await clickSettingsBackToWorkspace(page);
await expectSameRenderedNode(originalTerminal, terminalSurface);
});
} finally {
await workspace.cleanup();
}
@@ -1,4 +1,4 @@
import { test, expect } from "../support/fixtures";
import { daemonTest, test, expect } from "../support/fixtures";
import { createTempGitRepo } from "../support/helpers/workspace";
import {
closeSetupTab,
@@ -117,7 +117,7 @@ test.describe("Workspace setup streaming", () => {
}
});
test("streams running and completed setup snapshots for a successful setup", async () => {
daemonTest("streams running and completed setup snapshots for a successful setup", async () => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-success-", {
paseoConfig: {
@@ -204,7 +204,7 @@ test.describe("Workspace setup streaming", () => {
}
});
test("emits a completed empty snapshot when no setup commands exist", async () => {
daemonTest("emits a completed empty snapshot when no setup commands exist", async () => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-none-");
@@ -283,7 +283,7 @@ test.describe("Workspace setup streaming", () => {
}
});
test("launches workspace scripts through an explicit daemon request", async () => {
daemonTest("launches workspace scripts through an explicit daemon request", async () => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-scripts-", {
paseoConfig: {
@@ -39,28 +39,6 @@ test.describe("Workspace archive with worktree backing", () => {
await tempRepo?.cleanup().catch(() => undefined);
});
test("archiving the final workspace removes its managed worktree directory", async ({ page }) => {
const serverId = getServerId();
await openProjectViaDaemon(client, tempRepo.path);
const worktree = await createWorktreeViaDaemon(client, {
cwd: tempRepo.path,
slug: `archive-${Date.now()}`,
});
createdWorktreeDirectories.add(worktree.workspaceDirectory);
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId });
await archiveWorkspaceFromSidebar(page, worktree.workspaceId);
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
await expect
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
.toBe(false);
});
test("a managed worktree remains until its last workspace is archived", async ({ page }) => {
const serverId = getServerId();
await openProjectViaDaemon(client, tempRepo.path);
+13 -11
View File
@@ -32,16 +32,8 @@ const metroTest = base.extend({
},
});
const test = metroTest.extend<
{
paseoE2ESetup: void;
projectOwnership: void;
outdatedDaemon: OutdatedDaemon;
desktopManagedOutdatedDaemon: OutdatedDaemon;
relayConfigOutdatedDaemon: OutdatedDaemon;
projectPickerFixture: TrackedProjectPickerFixture;
withWorkspace: WithWorkspace;
},
const daemonTest = metroTest.extend<
{ projectOwnership: void },
{ e2eForkProviders: string[]; e2eWorker: void; e2eWorkerClient: SeedDaemonClient }
>({
e2eForkProviders: [[], { scope: "worker", option: true }],
@@ -94,6 +86,16 @@ const test = metroTest.extend<
},
{ auto: true },
],
});
const test = daemonTest.extend<{
paseoE2ESetup: void;
outdatedDaemon: OutdatedDaemon;
desktopManagedOutdatedDaemon: OutdatedDaemon;
relayConfigOutdatedDaemon: OutdatedDaemon;
projectPickerFixture: TrackedProjectPickerFixture;
withWorkspace: WithWorkspace;
}>({
paseoE2ESetup: [
async ({ page }, provide, testInfo) => {
const daemonPort = getE2EDaemonPort();
@@ -227,4 +229,4 @@ const test = metroTest.extend<
},
});
export { test, metroTest, expect, type Page };
export { daemonTest, test, metroTest, expect, type Page };
@@ -140,6 +140,15 @@ export async function expectActiveChatOutlinePrompt(page: Page, position: number
);
}
export async function expectActiveChatOutlinePromptMovedFrom(
page: Page,
position: number,
): Promise<void> {
const activePrompt = chatOutlineRail(page).getByRole("tab", { selected: true });
await expect(activePrompt).toHaveCount(1);
await expect(activePrompt).not.toHaveAccessibleName(new RegExp(`^${position} of `));
}
export async function expectLiveTurnPromptAboveFoldAndActive(
page: Page,
prompt: string,
@@ -1,12 +1,27 @@
import { expect, type Page } from "@playwright/test";
import { expect, type Locator, type Page } from "@playwright/test";
import type { SeededWorkspace } from "./seed-client";
import { openCommandCenter } from "./command-center";
import { openAgentRoute, type MockAgentWorkspace } from "./mock-agent";
export interface CommandCenterAgentControlChoice {
query: string;
choice: string;
}
/**
* Focuses a seeded mock agent — which is what makes its model and mode contributions register —
* opens the Command Center over it, and hands back the query input. Seeding stays in the test so
* the workspace lifecycle is owned by the `try`/`finally` that cleans it up.
*/
export async function openCommandCenterForAgent(
page: Page,
workspace: Pick<MockAgentWorkspace, "workspaceId" | "agentId">,
): Promise<Locator> {
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
const panel = await openCommandCenter(page);
return panel.getByTestId("command-center-input");
}
export async function chooseCommandCenterAgentControl(input: {
page: Page;
query: string;
+5 -2
View File
@@ -175,7 +175,7 @@ export async function sampleTabsDuringTransition(
function sample() {
const tabs = Array.from(
document.querySelectorAll<HTMLElement>(
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])',
'[data-testid^="workspace-tab-"][role="button"][aria-selected]',
),
).filter((element) => element.getClientRects().length > 0);
scope.__paseoTabTrackFrames?.push(
@@ -188,7 +188,10 @@ export async function sampleTabsDuringTransition(
requestAnimationFrame(sample);
}
}
requestAnimationFrame(sample);
// Establish the known-good pre-action state synchronously. Starting on the
// next animation frame lets the action race the first sample, which can
// misclassify a not-yet-painted test harness frame as a transition blank.
sample();
}, durationMs);
await action();
await page.waitForTimeout(durationMs + 100);
@@ -42,7 +42,7 @@ export async function seedMockAgentWorkspace(
workspaceId: workspace.workspaceId,
title: options.title,
modeId: options.modeId ?? "load-test",
model: options.model ?? "ten-second-stream",
model: options.model ?? "e2e-fast-stream",
initialPrompt: options.initialPrompt,
featureValues: options.featureValues,
});
+8 -2
View File
@@ -6,6 +6,12 @@ const baseURL =
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
const relayDeploymentSpec = "**/relay-deployment-reconnect.real.spec.ts";
function videoMode(): "on" | "on-first-retry" | "retain-on-failure" {
if (process.env.E2E_RECORD_VIDEO === "1") return "on";
if (process.env.CI) return "on-first-retry";
return "retain-on-failure";
}
export default defineConfig({
testDir: "./e2e/browser",
globalSetup: "./e2e/support/global-setup.ts",
@@ -21,9 +27,9 @@ export default defineConfig({
reporter: [["list"]],
use: {
baseURL,
trace: "retain-on-failure",
trace: process.env.CI ? "on-first-retry" : "retain-on-failure",
screenshot: "only-on-failure",
video: process.env.E2E_RECORD_VIDEO === "1" ? "on" : "retain-on-failure",
video: videoMode(),
},
projects: [
{
@@ -58,8 +58,10 @@ import { shortenPath } from "@/utils/shorten-path";
import { useCommandCenterContributions } from "./provider";
import {
buildContributionSections,
filterAndRankBuiltInResults,
joinSubtitleParts,
moveActiveResultId,
PINNED_SECTION_BAND,
preserveActiveResultId,
projectCommandCenterRows,
type CommandCenterAgentResult,
@@ -67,6 +69,7 @@ import {
type CommandCenterListRow,
type CommandCenterResult,
type CommandCenterResultSection,
type CommandCenterSearchFields,
type CommandCenterWorkspaceResult,
} from "./results";
import { useWorkspaceFileSearch } from "./workspace-file-search";
@@ -88,7 +91,6 @@ const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, (theme) => ({
}));
const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"];
const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const;
const DEFAULT_CATEGORY_RESULT_LIMIT = 5;
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
const leftNeedsInput = (left.pendingPermissionCount ?? 0) > 0 ? 1 : 0;
@@ -103,41 +105,59 @@ function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
return right.lastActivityAt.getTime() - left.lastActivityAt.getTime();
}
function matchesQuery(searchText: string, query: string): boolean {
const normalized = query.trim().toLowerCase();
return !normalized || searchText.includes(normalized);
function compareWorkspacesByTitle(
left: CommandCenterWorkspaceResult,
right: CommandCenterWorkspaceResult,
): number {
const titleDelta = left.title.localeCompare(right.title, undefined, {
numeric: true,
sensitivity: "base",
});
return titleDelta || left.subtitle.localeCompare(right.subtitle);
}
function limitDefaultCategoryResults<Result>(results: Result[], query: string): Result[] {
return query.trim() ? results : results.slice(0, DEFAULT_CATEGORY_RESULT_LIMIT);
/** `cwd` is not rendered, so a path match must never outrank a match on text the user can see. */
function agentSearchFields(result: CommandCenterAgentResult): CommandCenterSearchFields {
return { visible: [result.title, result.subtitle], hidden: [result.agent.cwd] };
}
function useBuiltInSections(open: boolean, query: string): CommandCenterResultSection[] {
function workspaceSearchFields(result: CommandCenterWorkspaceResult): CommandCenterSearchFields {
return { visible: [result.title, result.subtitle], hidden: [] };
}
/**
* Build every pinned row, in its default order. Deliberately not keyed on `query`: none of this
* work depends on what was typed, and rebuilding it per keystroke means re-running an Intl
* collation sort over every workspace.
*
* The cost is that `formatTimeAgo` is baked into the agent subtitle here, so relative timestamps
* now refresh when agents or projects change rather than on every keystroke.
*/
function useBuiltInRows(open: boolean): {
workspaces: CommandCenterWorkspaceResult[];
agents: CommandCenterAgentResult[];
} {
const { t } = useTranslation();
const { agents } = useAggregatedAgents();
const { projects } = useProjects({ enabled: open });
const showHost = useHosts().length > 1;
return useMemo(() => {
if (!open) return [];
if (!open) return { workspaces: [], agents: [] };
const allWorkspaces: CommandCenterWorkspaceResult[] = [];
for (const project of projects) {
for (const host of project.hosts) {
for (const workspace of host.workspaces) {
if (workspace.archivingAt) continue;
const title = workspace.title ?? workspace.name;
const subtitle = joinSubtitleParts([
showHost ? host.serverName : null,
project.projectName,
workspace.currentBranch,
]);
const searchText = `${title} ${subtitle}`.toLowerCase();
allWorkspaces.push({
kind: "workspace",
id: `workspace:${host.serverId}:${workspace.id}`,
title,
subtitle,
searchText,
title: workspace.title ?? workspace.name,
subtitle: joinSubtitleParts([
showHost ? host.serverName : null,
project.projectName,
workspace.currentBranch,
]),
run: () => {
clearCommandCenterFocusRestoreElement();
navigateToWorkspace({ serverId: host.serverId, workspaceId: workspace.id });
@@ -146,60 +166,66 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe
}
}
}
allWorkspaces.sort((left, right) => {
const titleDelta = left.title.localeCompare(right.title, undefined, {
numeric: true,
sensitivity: "base",
});
return titleDelta || left.subtitle.localeCompare(right.subtitle);
});
allWorkspaces.sort(compareWorkspacesByTitle);
const workspaceTitleByKey = new Map(
allWorkspaces.map((workspace) => [workspace.id.slice("workspace:".length), workspace.title]),
);
const workspaces = limitDefaultCategoryResults(
allWorkspaces.filter((workspace) => matchesQuery(workspace.searchText, query)),
query,
);
const agentResults = limitDefaultCategoryResults(
agents
.map<CommandCenterAgentResult>((agent) => {
const title = agent.title || t("shell.commandCenter.newAgent");
const workspaceTitle = agent.workspaceId
? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`)
: undefined;
const location = workspaceTitle ?? shortenPath(agent.cwd);
const subtitle = joinSubtitleParts([
const agentRows = agents
.map<CommandCenterAgentResult>((agent) => {
const workspaceTitle = agent.workspaceId
? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`)
: undefined;
return {
kind: "agent",
id: `agent:${agent.serverId}:${agent.id}`,
agent,
title: agent.title || t("shell.commandCenter.newAgent"),
subtitle: joinSubtitleParts([
showHost ? agent.serverLabel : null,
location,
workspaceTitle ?? shortenPath(agent.cwd),
formatTimeAgo(agent.lastActivityAt),
]);
return {
kind: "agent",
id: `agent:${agent.serverId}:${agent.id}`,
agent,
title,
subtitle,
searchText: `${title} ${subtitle} ${agent.cwd}`.toLowerCase(),
run: () => {
clearCommandCenterFocusRestoreElement();
navigateToAgent({ serverId: agent.serverId, agentId: agent.id });
},
};
})
.filter((agent) => matchesQuery(agent.searchText, query))
.sort((left, right) => sortAgents(left.agent, right.agent)),
query,
);
]),
run: () => {
clearCommandCenterFocusRestoreElement();
navigateToAgent({ serverId: agent.serverId, agentId: agent.id });
},
};
})
.sort((left, right) => sortAgents(left.agent, right.agent));
return { workspaces: allWorkspaces, agents: agentRows };
}, [agents, open, projects, showHost, t]);
}
function useBuiltInSections(open: boolean, query: string): CommandCenterResultSection[] {
const { t } = useTranslation();
const rows = useBuiltInRows(open);
return useMemo(() => {
if (!open) return [];
return [
{
id: "workspaces",
band: PINNED_SECTION_BAND,
rank: 2,
title: t("shell.commandCenter.workspaces"),
results: workspaces,
results: filterAndRankBuiltInResults(
rows.workspaces,
query,
workspaceSearchFields,
compareWorkspacesByTitle,
),
},
{
id: "agents",
band: PINNED_SECTION_BAND,
rank: 3,
title: t("shell.commandCenter.agents"),
results: filterAndRankBuiltInResults(rows.agents, query, agentSearchFields, (left, right) =>
sortAgents(left.agent, right.agent),
),
},
{ id: "agents", rank: 3, title: t("shell.commandCenter.agents"), results: agentResults },
];
}, [agents, open, projects, query, showHost, t]);
}, [open, query, rows, t]);
}
interface CommandCenterState {
@@ -231,7 +257,7 @@ function useCommandCenterState(): CommandCenterState {
const snapshot = useCommandCenterContributions();
const inputRef = useRef<EditingTextInputHandle>(null);
const previousOpenRef = useRef(open);
const [query, setQuery] = useState("");
const [query, setQueryState] = useState("");
const [activeId, setActiveId] = useState<string | null>(null);
const builtInSections = useBuiltInSections(open, query);
const {
@@ -250,10 +276,20 @@ function useCommandCenterState(): CommandCenterState {
filePath: entry.path,
title: entry.name,
subtitle: entry.directory,
searchText: entry.path.toLowerCase(),
run: () => openFile(entry.path),
}));
return [{ id: "files", rank: 4, title: t("shell.commandCenter.files"), results }];
// File rows are matched by the daemon, so they carry no client-side score and cannot be
// relevance-compared against the rows that do. Pinning them keeps the section comparator
// total: band is decided before any score is read.
return [
{
id: "files",
band: PINNED_SECTION_BAND,
rank: 4,
title: t("shell.commandCenter.files"),
results,
},
];
}, [fileSearchEntries, openFile, t]);
const contributionSections = useMemo(
() => buildContributionSections(snapshot.contributions, query),
@@ -270,6 +306,14 @@ function useCommandCenterState(): CommandCenterState {
);
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);
}, []);
const close = useCallback(() => setOpen(false), [setOpen]);
const select = useCallback(
(result: CommandCenterResult) => {
@@ -312,7 +356,7 @@ function useCommandCenterState(): CommandCenterState {
const timer = setTimeout(() => inputRef.current?.focus(), 0);
return () => clearTimeout(timer);
}
setQuery("");
setQueryState("");
setActiveId(null);
if (!wasOpen) return;
const element = takeCommandCenterFocusRestoreElement();
+399 -8
View File
@@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest";
import type { CommandCenterContribution } from "./contributions";
import {
buildContributionSections,
filterAndRankBuiltInResults,
joinSubtitleParts,
moveActiveResultId,
PINNED_SECTION_BAND,
preserveActiveResultId,
projectCommandCenterRows,
type CommandCenterWorkspaceResult,
@@ -29,17 +31,80 @@ function contribution(input: {
};
}
function workspace(id: string): CommandCenterWorkspaceResult {
function action(input: {
id: string;
group: string;
groupRank: number;
rank?: number;
title: string;
subtitle?: string;
keywords?: readonly string[];
visibility?: "always" | "query";
}): CommandCenterContribution {
return {
kind: "workspace",
id,
title: id,
subtitle: "host",
searchText: id,
id: input.id,
group: input.group,
groupRank: input.groupRank,
rank: input.rank ?? 0,
keywords: input.keywords ?? [],
visibility: input.visibility ?? "always",
run: () => undefined,
presentation: {
kind: "action",
title: input.title,
subtitle: input.subtitle,
sectionTitle: input.group,
},
};
}
function choice(input: {
id: string;
group: string;
groupRank: number;
rank?: number;
path: readonly [string, ...string[]];
keywords?: readonly string[];
}): CommandCenterContribution {
return {
id: input.id,
group: input.group,
groupRank: input.groupRank,
rank: input.rank ?? 0,
keywords: input.keywords ?? [],
visibility: "query",
run: () => undefined,
presentation: { kind: "choice", path: input.path, selected: false },
};
}
/** The real Actions › Schedules contribution, shape-for-shape (root-registration.tsx). */
function schedulesAction(): CommandCenterContribution {
return action({
id: "schedules",
group: "actions",
groupRank: 0,
rank: 4,
title: "Schedules",
keywords: ["schedules", "scheduled", "automation", "recurring"],
});
}
/** The real Mode › Auto mode contribution, shape-for-shape (agent-control-contributions.ts). */
function autoModeChoice(): CommandCenterContribution {
return choice({
id: "mode:auto",
group: "modes",
groupRank: 1.2,
path: ["Mode", "Auto mode"],
keywords: ["access", "permission", "approval", "mode"],
});
}
function workspace(id: string, title = id, subtitle = "host"): CommandCenterWorkspaceResult {
return { kind: "workspace", id, title, subtitle, run: () => undefined };
}
function sectionResultIds(sections: ReturnType<typeof buildContributionSections>): string[] {
const ids: string[] = [];
for (const section of sections) {
@@ -60,7 +125,13 @@ describe("Command Center result projection", () => {
const sections = buildContributionSections(contributions, "o");
const projection = projectCommandCenterRows([
...sections,
{ id: "workspaces", rank: 2, title: "Workspaces", results: [workspace("workspace:1")] },
{
id: "workspaces",
band: PINNED_SECTION_BAND,
rank: 2,
title: "Workspaces",
results: [workspace("workspace:1")],
},
]);
expect(projection.rows.map((row) => row.key)).toEqual([
"section:models",
@@ -100,7 +171,13 @@ describe("Command Center result projection", () => {
it("keeps keyboard selection aligned with rows beyond the render window", () => {
const workspaces = Array.from({ length: 200 }, (_, index) => workspace(`workspace:${index}`));
const projection = projectCommandCenterRows([
{ id: "workspaces", rank: 0, title: "Workspaces", results: workspaces },
{
id: "workspaces",
band: PINNED_SECTION_BAND,
rank: 0,
title: "Workspaces",
results: workspaces,
},
]);
let activeId: string | null = workspaces[0].id;
@@ -115,6 +192,320 @@ describe("Command Center result projection", () => {
});
});
describe("Command Center relevance ordering", () => {
it("ranks a label match above a hidden-keyword match at every prefix", () => {
// Reported bug: "Schedules" matched only via its hidden keyword "automation" yet outranked
// "Auto mode", so Enter navigated to Schedules instead of switching permission mode.
// Every prefix counts, not just the full word — you read the list while you type, and "aut"
// is a string-prefix of both "Auto mode" and "automation", so match quality alone ties them.
for (const query of ["a", "au", "aut", "auto"]) {
const sections = buildContributionSections([schedulesAction(), autoModeChoice()], query);
expect(
sections.map((section) => section.id),
`query ${query}`,
).toEqual(["modes", "actions"]);
expect(sectionResultIds(sections), `query ${query}`).toEqual(["mode:auto", "schedules"]);
}
});
it("prefers a visible label over an exact hidden keyword", () => {
const sections = buildContributionSections(
[
action({
id: "schedules",
group: "actions",
groupRank: 0,
rank: 0,
title: "Schedules",
keywords: ["automation"],
}),
action({
id: "automation-hub",
group: "actions",
groupRank: 0,
rank: 1,
title: "Automation hub",
}),
],
"automation",
);
// "automation" is an exact keyword on Schedules and only a prefix of the other label.
// The visible match still wins — the deliberate trade-off of ranking by field role first.
expect(sectionResultIds(sections)).toEqual(["automation-hub", "schedules"]);
});
it("keeps registry order when the query is empty", () => {
const shuffled = [
choice({ id: "mode:auto", group: "modes", groupRank: 1.2, path: ["Mode", "Auto mode"] }),
schedulesAction(),
action({ id: "new-agent", group: "workspace", groupRank: -1, title: "New agent" }),
];
const projection = projectCommandCenterRows(buildContributionSections(shuffled, ""));
// Query-gated choices stay hidden; the rest fall back to groupRank, unchanged by this feature.
expect(projection.selectableResults.map((result) => result.id)).toEqual([
"new-agent",
"schedules",
]);
});
it("still ranks Schedules first for 'sched'", () => {
const sections = buildContributionSections(
[
schedulesAction(),
choice({
id: "mode:auto",
group: "modes",
groupRank: 1.2,
path: ["Mode", "Auto mode"],
keywords: ["rescheduled"],
}),
],
"sched",
);
expect(sectionResultIds(sections)).toEqual(["schedules", "mode:auto"]);
});
it("orders rows within a section by match quality", () => {
const sections = buildContributionSections(
[
action({ id: "reset", group: "actions", groupRank: 0, rank: 0, title: "Reset device" }),
action({ id: "settings", group: "actions", groupRank: 0, rank: 1, title: "Settings" }),
],
"set",
);
// "Settings" is a prefix match; "Reset device" only matches mid-word.
expect(sectionResultIds(sections)).toEqual(["settings", "reset"]);
});
it("breaks an equal-score tie with registry order, not alphabetically", () => {
const sections = buildContributionSections(
[
action({ id: "z-first", group: "actions", groupRank: 0, rank: 0, title: "Auto one" }),
action({ id: "a-second", group: "actions", groupRank: 0, rank: 1, title: "Auto two" }),
],
"auto",
);
expect(sectionResultIds(sections)).toEqual(["z-first", "a-second"]);
});
it("keeps Workspaces below contributions even on a perfect match", () => {
const projection = projectCommandCenterRows([
...buildContributionSections([schedulesAction()], "auto"),
{
id: "workspaces",
band: PINNED_SECTION_BAND,
rank: 2,
title: "Workspaces",
results: [workspace("auto")],
},
]);
expect(projection.selectableResults.map((result) => result.id)).toEqual(["schedules", "auto"]);
});
it("sorts sections handed to it out of order", () => {
const sections = buildContributionSections([schedulesAction(), autoModeChoice()], "auto");
const workspaces = {
id: "workspaces",
band: PINNED_SECTION_BAND,
rank: 2,
title: "Workspaces",
results: [workspace("auto")],
};
const projection = projectCommandCenterRows([workspaces, ...sections.toReversed()]);
expect(projection.selectableResults.map((result) => result.id)).toEqual([
"mode:auto",
"schedules",
"auto",
]);
});
it("still matches a query spanning two fields", () => {
// Each token is scored against each field on its own, so "mode auto" lands one token in
// path[0] and one in path[1]. Order is not significant — the reverse matches too.
for (const query of ["mode auto", "auto mode"]) {
expect(sectionResultIds(buildContributionSections([autoModeChoice()], query)), query).toEqual(
["mode:auto"],
);
}
});
});
describe("Command Center query tokenization", () => {
const labelAsDesign = () =>
action({
id: "label:design",
group: "workspace",
groupRank: 0,
title: "Label as Design",
});
it("matches a multi-token query in any order", () => {
// The reported bug: "design" found this row and "lab des" found nothing, because the whole
// query was tested as one substring.
for (const query of ["design", "lab des", "des lab", "Label as Design"]) {
expect(sectionResultIds(buildContributionSections([labelAsDesign()], query)), query).toEqual([
"label:design",
]);
}
});
it("requires each token to be adjacent characters, not a subsequence", () => {
// Unlike the composer's slash-command list, where "/pasbab" finds "/paseo-babysit". Dropping
// the subsequence tier is what keeps "No matches" meaning no matches: this list preselects
// its first row, so a query that should find nothing must not put an action under Enter.
for (const query of ["labdes", "lbl", "lasdsgn"]) {
expect(sectionResultIds(buildContributionSections([labelAsDesign()], query)), query).toEqual(
[],
);
}
});
it("rejects the row when any single token matches nothing", () => {
expect(sectionResultIds(buildContributionSections([labelAsDesign()], "lab zzz"))).toEqual([]);
});
it("treats a repeated token as idempotent", () => {
// Each token is scored independently, so both find the same occurrence. Documented, not a bug.
expect(sectionResultIds(buildContributionSections([labelAsDesign()], "lab lab"))).toEqual([
"label:design",
]);
});
it("never drops a row that the old substring filter would have matched", () => {
// Tokens hold no whitespace and the old haystack joined fields with a space, so any token
// found in that haystack lay entirely within one field. Tokenizing can only widen.
const rows = [labelAsDesign(), schedulesAction(), autoModeChoice()];
const haystack = (row: CommandCenterContribution): string =>
[
...(row.presentation.kind === "action"
? [row.presentation.title, row.presentation.subtitle ?? ""]
: row.presentation.path),
...row.keywords,
]
.join(" ")
.toLowerCase();
for (const query of ["design", "sched", "auto mode", "label as", "recurring", "mode"]) {
const substringMatches = rows
.filter((row) => haystack(row).includes(query.toLowerCase()))
.map((row) => row.id);
const tokenMatches = new Set(sectionResultIds(buildContributionSections(rows, query)));
for (const id of substringMatches) {
expect(tokenMatches.has(id), `${query} -> ${id}`).toBe(true);
}
}
});
it("matches one token against the title and another against the subtitle", () => {
const sections = buildContributionSections(
[
action({
id: "open-changes",
group: "workspace",
groupRank: 0,
title: "Open Changes",
subtitle: "Review the working tree",
}),
],
"changes tree",
);
expect(sectionResultIds(sections)).toEqual(["open-changes"]);
});
it("ranks a visible match above one that only a hidden keyword reached", () => {
const sections = buildContributionSections(
[
action({
id: "hidden-only",
group: "actions",
groupRank: 0,
rank: 0,
title: "Unrelated",
keywords: ["label", "design"],
}),
labelAsDesign(),
],
"lab des",
);
expect(sectionResultIds(sections)).toEqual(["label:design", "hidden-only"]);
});
it("returns everything for an empty or whitespace-only query", () => {
for (const query of ["", " "]) {
expect(
sectionResultIds(buildContributionSections([labelAsDesign(), schedulesAction()], query)),
JSON.stringify(query),
).toEqual(["label:design", "schedules"]);
}
});
});
describe("filterAndRankBuiltInResults", () => {
const visibleOnly = (row: CommandCenterWorkspaceResult) => ({
visible: [row.title, row.subtitle],
hidden: [],
});
const byTitle = (left: CommandCenterWorkspaceResult, right: CommandCenterWorkspaceResult) =>
left.title.localeCompare(right.title);
it("caps an empty query and preserves the caller's order", () => {
const rows = Array.from({ length: 8 }, (_, index) => workspace(`w${index}`));
expect(
filterAndRankBuiltInResults(rows, "", visibleOnly, byTitle).map((row) => row.id),
).toEqual(["w0", "w1", "w2", "w3", "w4"]);
});
it("puts the best match first even when it sorts last alphabetically", () => {
const rows = [
workspace("a", "Zebra design system"),
workspace("b", "Alpha lab notes"),
workspace("c", "Label as Design"),
];
// Alphabetically this is Alpha, Label, Zebra. "lab des" only matches Label as Design.
expect(
filterAndRankBuiltInResults(rows, "lab des", visibleOnly, byTitle).map((row) => row.id),
).toEqual(["c"]);
});
it("ranks a title match above a match that only the hidden field reached", () => {
const rows = [workspace("hidden-hit", "Unrelated"), workspace("title-hit", "Deploy")];
const fields = (row: CommandCenterWorkspaceResult) =>
row.id === "hidden-hit"
? { visible: [row.title], hidden: ["deploy"] }
: { visible: [row.title], hidden: [] };
expect(
filterAndRankBuiltInResults(rows, "deploy", fields, byTitle).map((row) => row.id),
).toEqual(["title-hit", "hidden-hit"]);
});
it("breaks an equal score with the caller's tiebreak, not with match order", () => {
// Both titles are an exact prefix match for "task", so only the tiebreak separates them.
const rows = [workspace("second", "Task zebra"), workspace("first", "Task alpha")];
expect(
filterAndRankBuiltInResults(rows, "task", visibleOnly, byTitle).map((row) => row.id),
).toEqual(["first", "second"]);
});
it("drops a row when one token matches nothing", () => {
const rows = [workspace("a", "Label as Design")];
expect(filterAndRankBuiltInResults(rows, "lab zzz", visibleOnly, byTitle)).toEqual([]);
});
});
describe("joinSubtitleParts", () => {
it("joins all present parts with a middle dot", () => {
expect(joinSubtitleParts(["a", "b", "c"])).toBe("a · b · c");
+179 -19
View File
@@ -1,12 +1,53 @@
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import {
compareMatchScores,
type MatchScore,
scoreTextFields,
} from "@getpaseo/protocol/search/text-match";
import type { CommandCenterContribution } from "./contributions";
/** Sections that reorder by how well they match the query. */
export const CONTRIBUTION_SECTION_BAND = 0;
/** Sections pinned below every contribution section regardless of match quality. */
export const PINNED_SECTION_BAND = 1;
/** Every query token matched something the user can read on the row. */
const VISIBLE_FIELD_RANK = 0;
/** At least one token only matched a hidden keyword. */
const KEYWORD_FIELD_RANK = 1;
export interface CommandCenterScore {
/** VISIBLE_FIELD_RANK or KEYWORD_FIELD_RANK. Compared before match quality. */
fieldRank: number;
match: MatchScore;
}
/**
* Rank by which fields matched before ranking by how well they matched. A row whose visible
* label matches always outranks one that only matches through a hidden keyword, however good
* the keyword match is.
*
* Scoring purely by tier is not enough: "aut" is a string-prefix of both "Auto mode" and
* Schedules' hidden keyword "automation", so the tiers tie and the order falls back to group
* rank, which puts Schedules first. It only came out right at the full word "auto". Order that
* flips mid-word as you type is worse than order that is merely wrong.
*
* The cost is deliberate: an exact hidden-keyword match now loses to any visible match. No
* query over the contributions registered today hits that case — hidden keywords here are
* synonyms nothing else displays ("automation", "recurring", "repo", "shell", "hotkeys").
*/
export function compareCommandCenterScores(
left: CommandCenterScore,
right: CommandCenterScore,
): number {
return left.fieldRank - right.fieldRank || compareMatchScores(left.match, right.match);
}
export interface CommandCenterWorkspaceResult {
kind: "workspace";
id: string;
title: string;
subtitle: string;
searchText: string;
run(): void;
}
@@ -16,7 +57,6 @@ export interface CommandCenterAgentResult {
agent: AggregatedAgent;
title: string;
subtitle: string;
searchText: string;
run(): void;
}
@@ -26,7 +66,6 @@ export interface CommandCenterFileResult {
filePath: string;
title: string;
subtitle: string;
searchText: string;
run(): void;
}
@@ -34,7 +73,8 @@ export interface CommandCenterContributionResult {
kind: "contribution";
id: string;
contribution: CommandCenterContribution;
searchText: string;
/** How well this row matches the query. Absent when the query is empty. */
score?: CommandCenterScore;
run(): void | Promise<void>;
}
@@ -46,15 +86,21 @@ export type CommandCenterResult =
export interface CommandCenterResultSection {
id: string;
/** CONTRIBUTION_SECTION_BAND or PINNED_SECTION_BAND. Compared before anything else. */
band: number;
rank: number;
title?: string;
/** Best score among this section's results. Absent when the query is empty. */
score?: CommandCenterScore;
results: readonly CommandCenterResult[];
}
interface MutableCommandCenterResultSection {
id: string;
band: number;
rank: number;
title?: string;
score?: CommandCenterScore;
results: CommandCenterResult[];
}
@@ -69,9 +115,41 @@ export interface CommandCenterListProjection {
offsets: readonly number[];
}
function matchesQuery(searchText: string, query: string): boolean {
const normalized = query.trim().toLowerCase();
return !normalized || searchText.includes(normalized);
/**
* Fields a row can be matched on, split by whether the user can read them. A row that matches
* only through `hidden` still matches, but always ranks below one whose `visible` text did.
*/
export interface CommandCenterSearchFields {
visible: readonly string[];
hidden: readonly string[];
}
const MATCH_OPTIONS = { subsequence: false } as const;
/**
* The one definition of "how well did this row match", shared by every scored row kind. Null
* means the row is filtered out, so scoring and filtering are the same pass — a separate boolean
* predicate would throw this result away and force the token scan a second time.
*
* Matching is per-token (`scoreTextFields` splits on whitespace and every token must land in
* some field), so token order does not matter and `lab des` finds "Label as Design". This can
* only ever widen the result set: tokens hold no whitespace, so any token that was found in the
* old space-joined haystack lies entirely within one field and is still found here.
*
* Subsequence matching is off, unlike the composer's slash-command list. Each token has to be a
* run of adjacent characters, so `lab des` matches and `labdes` does not. It widens hard enough
* that almost any typo finds something, and this list preselects its first row: a query the user
* expects to find nothing would instead put an unrelated action under Enter.
*/
export function scoreSearchFields(
query: string,
fields: CommandCenterSearchFields,
): CommandCenterScore | null {
const visible = scoreTextFields(query, [...fields.visible], MATCH_OPTIONS);
if (visible) return { fieldRank: VISIBLE_FIELD_RANK, match: visible };
if (fields.hidden.length === 0) return null;
const match = scoreTextFields(query, [...fields.visible, ...fields.hidden], MATCH_OPTIONS);
return match ? { fieldRank: KEYWORD_FIELD_RANK, match } : null;
}
/** Join non-empty subtitle parts with " · ", dropping null/undefined/empty. */
@@ -79,12 +157,18 @@ export function joinSubtitleParts(parts: readonly (string | null | undefined)[])
return parts.filter((part): part is string => Boolean(part)).join(" · ");
}
function contributionSearchText(contribution: CommandCenterContribution): string {
const presentationText =
/**
* Field order carries no meaning: every token is scored against each field on its own, so
* ["Mode", "Auto mode"] and its reverse both match "mode auto" and "auto mode".
*/
function contributionSearchFields(
contribution: CommandCenterContribution,
): CommandCenterSearchFields {
const visible =
contribution.presentation.kind === "action"
? [contribution.presentation.title, contribution.presentation.subtitle ?? ""]
: contribution.presentation.path;
return [...presentationText, ...contribution.keywords].join(" ").toLowerCase();
: [...contribution.presentation.path];
return { visible, hidden: contribution.keywords };
}
function resultHeight(result: CommandCenterResult): number {
@@ -96,17 +180,77 @@ function resultHeight(result: CommandCenterResult): number {
return 36;
}
/**
* Order two sections. Total and transitive: the mixed case (one score present, one absent)
* cannot occur, because scores exist exactly on band-0 sections with a non-empty query, band is
* compared first so cross-band pairs never reach the score clause, and within band 0 either
* every section has a score or none does.
*/
function compareResultSections(
left: CommandCenterResultSection,
right: CommandCenterResultSection,
): number {
if (left.band !== right.band) return left.band - right.band;
if (left.score && right.score) {
const delta = compareCommandCenterScores(left.score, right.score);
if (delta !== 0) return delta;
}
return left.rank - right.rank || left.id.localeCompare(right.id);
}
function resultScore(result: CommandCenterResult): CommandCenterScore | undefined {
return result.kind === "contribution" ? result.score : undefined;
}
/** Rows shown when nothing has been typed, per pinned category. */
const DEFAULT_CATEGORY_RESULT_LIMIT = 5;
/**
* Filter and rank already-built rows. Rows in, rows out — building them needs translations,
* navigation callbacks and the project tree, none of which matching cares about, so that stays
* with the caller and this stays testable without mounting React.
*
* `tiebreak` is the caller's own ordering (alphabetical for workspaces, status and recency for
* agents). It decides equal-scoring rows, so an agent awaiting input still outranks an idle one
* that matched exactly as well.
*/
export function filterAndRankBuiltInResults<Row>(
rows: readonly Row[],
query: string,
fieldsOf: (row: Row) => CommandCenterSearchFields,
tiebreak: (left: Row, right: Row) => number,
): Row[] {
if (!query.trim()) return rows.slice(0, DEFAULT_CATEGORY_RESULT_LIMIT);
const scored: { row: Row; score: CommandCenterScore }[] = [];
for (const row of rows) {
const score = scoreSearchFields(query, fieldsOf(row));
if (score) scored.push({ row, score });
}
scored.sort(
(left, right) =>
compareCommandCenterScores(left.score, right.score) || tiebreak(left.row, right.row),
);
return scored.map((entry) => entry.row);
}
export function buildContributionSections(
contributions: readonly CommandCenterContribution[],
query: string,
): CommandCenterResultSection[] {
const groups = new Map<string, MutableCommandCenterResultSection>();
const registryIndexById = new Map<string, number>();
const hasQuery = Boolean(query.trim());
for (const contribution of contributions) {
for (const [index, contribution] of contributions.entries()) {
if (contribution.visibility === "query" && !hasQuery) continue;
const searchText = contributionSearchText(contribution);
if (!matchesQuery(searchText, query)) continue;
let score: CommandCenterScore | undefined;
if (hasQuery) {
// The score is the filter: null means no token found a home, so the row is out.
const matched = scoreSearchFields(query, contributionSearchFields(contribution));
if (!matched) continue;
score = matched;
}
const existing = groups.get(contribution.group);
const title =
contribution.presentation.kind === "action"
@@ -114,23 +258,39 @@ export function buildContributionSections(
: undefined;
const section = existing ?? {
id: contribution.group,
band: CONTRIBUTION_SECTION_BAND,
rank: contribution.groupRank,
title,
results: [],
};
registryIndexById.set(contribution.id, index);
section.results.push({
kind: "contribution",
id: contribution.id,
contribution,
searchText,
score,
run: contribution.run,
});
if (score && (!section.score || compareCommandCenterScores(score, section.score) < 0)) {
section.score = score;
}
groups.set(contribution.group, section);
}
return [...groups.values()].sort(
(left, right) => left.rank - right.rank || left.id.localeCompare(right.id),
);
const sections = [...groups.values()];
// With no query there is nothing to rank by, so leave the registry order untouched.
if (!hasQuery) return sections;
for (const section of sections) {
section.results.sort((left, right) => {
const leftScore = resultScore(left);
const rightScore = resultScore(right);
const delta = leftScore && rightScore ? compareCommandCenterScores(leftScore, rightScore) : 0;
if (delta !== 0) return delta;
return (registryIndexById.get(left.id) ?? 0) - (registryIndexById.get(right.id) ?? 0);
});
}
return sections.sort(compareResultSections);
}
export function projectCommandCenterRows(
@@ -138,7 +298,7 @@ export function projectCommandCenterRows(
): CommandCenterListProjection {
const populated = sections
.filter((section) => section.results.length > 0)
.sort((left, right) => left.rank - right.rank || left.id.localeCompare(right.id));
.sort(compareResultSections);
const rows: CommandCenterListRow[] = [];
const selectableResults: CommandCenterResult[] = [];
const rowIndexByResultId = new Map<string, number>();
@@ -32,6 +32,8 @@ import { isNative } from "@/constants/platform";
import { keyboardShortcutsAvailable } from "@/keyboard/availability";
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { buildOpenProjectRoute } from "@/utils/host-routes";
import { hasActiveWebOverlay } from "@/lib/overlay-root";
import {
type ActiveWorkspaceSelection,
navigateToLastWorkspace,
@@ -179,7 +181,11 @@ export function useKeyboardShortcuts({
navigateToWorkspace({ serverId: action.serverId, workspaceId: action.workspaceId });
return true;
case "navigate-last-workspace":
return navigateToLastWorkspace();
if (navigateToLastWorkspace()) {
return true;
}
router.replace(buildOpenProjectRoute());
return true;
case "router-replace":
router.replace(action.route as Parameters<typeof router.replace>[0]);
return true;
@@ -330,6 +336,14 @@ export function useKeyboardShortcuts({
}
const key = event.key ?? "";
if (
key === "Escape" &&
pathname.startsWith("/settings") &&
!isMobile &&
hasActiveWebOverlay()
) {
return;
}
if (key === badgeModifierKey && !event.shiftKey) {
setBadgeModifierDown(true);
}
@@ -59,6 +59,27 @@ describe("routeKeyboardShortcut — dispatch passthroughs", () => {
action: expected,
});
});
it("closes desktop settings when Escape routes through agent interrupt", () => {
expect(
routeKeyboardShortcut(
{ action: "agent.interrupt", payload: null },
makeCtx({ pathname: "/settings/general" }),
),
).toEqual<ShortcutAction>({ kind: "navigate-last-workspace" });
});
it("keeps agent interrupt behavior on compact settings layouts", () => {
expect(
routeKeyboardShortcut(
{ action: "agent.interrupt", payload: null },
makeCtx({ pathname: "/settings/general", isMobile: true }),
),
).toEqual<ShortcutAction>({
kind: "dispatch",
action: { id: "agent.interrupt", scope: "global" },
});
});
});
describe("routeKeyboardShortcut — workspace.tab.navigate", () => {
@@ -177,6 +177,13 @@ export function routeKeyboardShortcut(
): ShortcutAction {
const passthrough = PASSTHROUGH_DISPATCH[input.action];
if (passthrough) {
if (
input.action === "agent.interrupt" &&
ctx.pathname.startsWith("/settings") &&
!ctx.isMobile
) {
return { kind: "navigate-last-workspace" };
}
return dispatch(passthrough);
}
+4
View File
@@ -107,6 +107,10 @@ function getTopWebOverlay(): WebOverlayEntry | undefined {
}, undefined);
}
export function hasActiveWebOverlay(): boolean {
return getTopWebOverlay() !== undefined;
}
function getFocusableElements(scope: HTMLElement): HTMLElement[] {
const selector = [
"a[href]",
@@ -77,11 +77,16 @@ function useWorkspaceLayoutIds(...values: string[]) {
workspaceLayoutIds.useValues(values);
}
function createTab(tabId: string, target?: WorkspaceTab["target"]): WorkspaceTab {
function createTab(
tabId: string,
target?: WorkspaceTab["target"],
state?: WorkspaceTab["state"],
): WorkspaceTab {
return {
tabId,
target: target ?? { kind: "draft", draftId: tabId },
createdAt: 1,
...(state === undefined ? {} : { state }),
};
}
@@ -91,8 +96,11 @@ function createPane(input: {
focusedTabId?: string | null;
hidden?: boolean;
targetsByTabId?: Record<string, WorkspaceTab["target"]>;
stateByTabId?: Record<string, WorkspaceTab["state"]>;
}): SplitNode {
const tabs = input.tabIds.map((tabId) => createTab(tabId, input.targetsByTabId?.[tabId]));
const tabs = input.tabIds.map((tabId) =>
createTab(tabId, input.targetsByTabId?.[tabId], input.stateByTabId?.[tabId]),
);
return {
kind: "pane",
pane: {
@@ -276,6 +284,231 @@ describe("workspace-layout-store helpers", () => {
});
});
describe("workspace-layout-store version 2 migration", () => {
const workspaceKey = "workspace";
const preservedFileState = { treeVisible: true, treeWidth: 333 };
function createVersionOneLayout(includeExplorerDefaults: boolean) {
const explorerTabIds = [
...(includeExplorerDefaults ? ["files", "changes_tree"] : []),
"legacy-file",
"legacy-agent",
];
return {
root: {
kind: "group" as const,
group: {
id: "workspace-root",
direction: "horizontal" as const,
sizes: [0.61, 0.39],
children: [
createPane({
id: "main",
tabIds: ["main-draft"],
targetsByTabId: {
"main-draft": { kind: "draft", draftId: "main-draft" },
},
}),
createPane({
id: "explorer",
tabIds: explorerTabIds,
focusedTabId: "legacy-file",
hidden: true,
targetsByTabId: {
files: { kind: "files" },
changes_tree: { kind: "changes_tree" },
"legacy-file": { kind: "file", path: "/repo/preserved.ts" },
"legacy-agent": { kind: "agent", agentId: "preserved-agent" },
},
stateByTabId: { "legacy-file": preservedFileState },
}),
],
},
},
focusedPaneId: "main",
};
}
async function persistVersionOneLayout(
source: "v0.5" | "v0.6",
options: { withSidePane?: boolean } = {},
) {
const layout = createVersionOneLayout(source === "v0.6");
const persistedLayout = options.withSidePane
? {
...layout,
root: {
kind: "group" as const,
group: {
id: "side-root",
direction: "horizontal" as const,
sizes: [0.7, 0.3],
children: [
layout.root,
createPane({
id: "existing-side",
tabIds: ["existing-side-file"],
targetsByTabId: {
"existing-side-file": { kind: "file", path: "/repo/already-beside.ts" },
},
}),
],
},
},
}
: layout;
await AsyncStorage.removeItem("workspace-layout-state");
await AsyncStorage.setItem(
"workspace-layout-state",
JSON.stringify({
state: {
layoutByWorkspace: {
[workspaceKey]: persistedLayout,
},
splitSizesByWorkspace: {},
explorerPaneIdByWorkspace: { [workspaceKey]: "explorer" },
...(source === "v0.6"
? {
explorerSidebarWidthByWorkspace: {},
sidePaneIdByWorkspace: options.withSidePane
? { [workspaceKey]: "existing-side" }
: {},
}
: {}),
},
version: 1,
}),
);
}
function expectMigratedLayout(
store: {
getState: () => {
layoutByWorkspace: Record<string, { root: SplitNode }>;
explorerSidebarPaneIdByWorkspace: Record<string, string | null>;
sidePaneIdByWorkspace: Record<string, string | null>;
};
},
expected: {
sidePaneId?: string;
sideTabIds?: string[];
} = {},
) {
const state = store.getState();
const layout = state.layoutByWorkspace[workspaceKey];
const explorerPaneId = state.explorerSidebarPaneIdByWorkspace[workspaceKey];
const sidePaneId = state.sidePaneIdByWorkspace[workspaceKey];
expect(explorerPaneId).toBe("explorer");
expect(sidePaneId).toBeTruthy();
expect(sidePaneId).not.toBe(explorerPaneId);
const tabsById = new Map(collectAllTabs(layout.root).map((tab) => [tab.tabId, tab]));
const explorerPane = findPaneById(layout.root, explorerPaneId);
expect(explorerPane?.tabIds.map((tabId) => tabsById.get(tabId)?.target)).toEqual([
{ kind: "files" },
{ kind: "changes_tree" },
]);
const sidePane = findPaneById(layout.root, sidePaneId);
expect(sidePane?.hidden).not.toBe(true);
expect(sidePane?.tabIds).toEqual(expected.sideTabIds ?? ["legacy-file", "legacy-agent"]);
expect(tabsById.get("legacy-file")).toMatchObject({
target: { kind: "file", path: "/repo/preserved.ts" },
state: preservedFileState,
});
expect(sidePaneId).toBe(expected.sidePaneId ?? sidePaneId);
expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual(["main", sidePaneId]);
}
it.each(["v0.5", "v0.6"] as const)(
"replaces Explorer once and preserves persisted side content when upgrading from %s",
async (source) => {
await persistVersionOneLayout(source);
const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds());
await restored.persist.rehydrate();
expectMigratedLayout(restored);
await vi.waitFor(async () => {
const persisted = JSON.parse(
(await AsyncStorage.getItem("workspace-layout-state")) ?? "{}",
);
expect(persisted.version).toBe(2);
});
},
);
it("reuses an ordinary side pane already created by v0.6", async () => {
await persistVersionOneLayout("v0.6", { withSidePane: true });
const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds());
await restored.persist.rehydrate();
expectMigratedLayout(restored, {
sidePaneId: "existing-side",
sideTabIds: ["existing-side-file", "legacy-file", "legacy-agent"],
});
});
it("writes version 2 with the persisted Explorer key accepted by released v0.6", async () => {
await persistVersionOneLayout("v0.6");
const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds());
await restored.persist.rehydrate();
await vi.waitFor(async () => {
const persisted = JSON.parse((await AsyncStorage.getItem("workspace-layout-state")) ?? "{}");
expect(persisted.version).toBe(2);
expect(Object.keys(persisted.state).sort()).toEqual([
"explorerPaneIdByWorkspace",
"explorerSidebarWidthByWorkspace",
"layoutByWorkspace",
"sidePaneIdByWorkspace",
"splitSizesByWorkspace",
]);
expect(persisted.state.explorerPaneIdByWorkspace).toEqual({
[workspaceKey]: "explorer",
});
});
});
it("does not replace Explorer again when the migrated version 2 layout reloads", async () => {
await persistVersionOneLayout("v0.6");
const migrated = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds());
await migrated.persist.rehydrate();
expectMigratedLayout(migrated);
const explorerPaneId = migrated.getState().explorerSidebarPaneIdByWorkspace[workspaceKey];
if (!explorerPaneId) {
throw new Error("Migrated Explorer pane is missing");
}
const addedTabId = migrated.getState().openTab({
workspaceKey,
target: { kind: "agent", agentId: "added-after-migration" },
intent: "new",
placement: { mode: "pane", paneId: explorerPaneId },
});
expect(addedTabId).toBeTruthy();
await vi.waitFor(async () => {
const persisted = JSON.parse((await AsyncStorage.getItem("workspace-layout-state")) ?? "{}");
expect(persisted.version).toBe(2);
});
const reloaded = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds());
await reloaded.persist.rehydrate();
const reloadedState = reloaded.getState();
const reloadedLayout = reloadedState.layoutByWorkspace[workspaceKey];
const reloadedExplorer = findPaneById(
reloadedLayout.root,
reloadedState.explorerSidebarPaneIdByWorkspace[workspaceKey],
);
expect(reloadedExplorer?.tabIds).toContain(addedTabId);
expect(findPaneContainingTab(reloadedLayout.root, addedTabId as string)?.id).toBe(
reloadedExplorer?.id,
);
});
});
describe("workspace-layout-store tree transforms", () => {
beforeEach(() => {
workspaceLayoutIds.reset();
@@ -2712,7 +2945,6 @@ describe("workspace-layout-store actions", () => {
layoutByWorkspace: { [workspaceKey]: layout },
splitSizesByWorkspace: currentState.splitSizesByWorkspace,
explorerSidebarWidthByWorkspace: currentState.explorerSidebarWidthByWorkspace,
// The persisted key keeps its pre-rename spelling for older clients.
explorerPaneIdByWorkspace: {},
sidePaneIdByWorkspace: currentState.sidePaneIdByWorkspace,
});
+181 -83
View File
@@ -279,6 +279,7 @@ const WorkspaceLayoutPersistedStateSchema = z.strictObject({
// The persisted keys keep their pre-rename spelling: the schema is strict, so a
// rename here would fail every existing blob and wipe the layout it describes.
explorerPaneIdByWorkspace: z.record(z.string(), z.string().nullable()).optional(),
explorerSidebarPaneIdByWorkspace: z.record(z.string(), z.string().nullable()).optional(),
sidePaneIdByWorkspace: z.record(z.string(), z.string().nullable()).optional(),
// COMPAT(pullRequestAutoAdd): PR detection stopped opening a tab in v0.5; accepted
// and ignored so upgrading does not discard the layout. Remove after 2027-08-20.
@@ -286,6 +287,7 @@ const WorkspaceLayoutPersistedStateSchema = z.strictObject({
});
const LEGACY_EXPLORER_SIDEBAR_REFERENCE_WIDTH = 1440;
const WORKSPACE_LAYOUT_PERSIST_VERSION = 2;
function convertLegacyExplorerSidebarRatios(
ratiosByWorkspace: Record<string, number>,
@@ -298,6 +300,178 @@ function convertLegacyExplorerSidebarRatios(
);
}
function replacePaneInNode(node: SplitNode, paneId: string, replacement: SplitPane): SplitNode {
if (node.kind === "pane") {
return node.pane.id === paneId ? { kind: "pane", pane: replacement } : node;
}
return {
kind: "group",
group: {
...node.group,
children: node.group.children.map((child) => replacePaneInNode(child, paneId, replacement)),
},
};
}
function visiblePane<TPane extends SplitPane>(pane: TPane): Omit<TPane, "hidden"> {
const { hidden: _hidden, ...visible } = pane;
return visible;
}
function createExplorerSidebarNode(): SplitNode {
const layout = createWorkspaceLayoutWithExplorerSidebar();
const pane = findPaneById(layout.root, EXPLORER_SIDEBAR_PANE_ID);
if (!pane) {
throw new Error("Default Explorer pane is missing");
}
return { kind: "pane", pane };
}
function preserveVersionOneSideTabs(input: {
layout: WorkspaceLayout;
legacyExplorerPane: SplitPane;
rememberedSidePane: SplitPane | null;
tabs: WorkspaceTab[];
ids: WorkspaceLayoutIdSource;
}): { root: SplitNode; sidePaneId: string | null } {
const rootWithoutExplorer = removePaneFromTree(input.layout.root, input.legacyExplorerPane.id);
if (input.tabs.length === 0) {
return {
root: findPaneById(rootWithoutExplorer, input.legacyExplorerPane.id)
? createDefaultLayout().root
: rootWithoutExplorer,
sidePaneId: input.rememberedSidePane?.id ?? null,
};
}
if (input.rememberedSidePane) {
const rememberedTabs = collectAllTabs(input.layout.root).filter((tab) =>
input.rememberedSidePane?.tabIds.includes(tab.tabId),
);
const nextSidePane = visiblePane({
...input.rememberedSidePane,
tabIds: [...input.rememberedSidePane.tabIds, ...input.tabs.map((tab) => tab.tabId)],
tabs: [...rememberedTabs, ...input.tabs],
focusedTabId: input.rememberedSidePane.focusedTabId ?? input.tabs[0]?.tabId ?? null,
});
return {
root: replacePaneInNode(rootWithoutExplorer, input.rememberedSidePane.id, nextSidePane),
sidePaneId: input.rememberedSidePane.id,
};
}
const sidePaneId = input.ids.createNodeId("pane");
const sidePane = visiblePane({
...input.legacyExplorerPane,
id: sidePaneId,
tabIds: input.tabs.map((tab) => tab.tabId),
tabs: input.tabs,
focusedTabId:
input.tabs.find((tab) => tab.tabId === input.legacyExplorerPane.focusedTabId)?.tabId ??
input.tabs[0]?.tabId ??
null,
});
return {
root: replacePaneInNode(input.layout.root, input.legacyExplorerPane.id, sidePane),
sidePaneId,
};
}
function migrateVersionOneWorkspaceLayout(input: {
layout: WorkspaceLayout;
legacyExplorerPaneId: string | null | undefined;
rememberedSidePaneId: string | null | undefined;
ids: WorkspaceLayoutIdSource;
}): { layout: WorkspaceLayout; explorerPaneId: string; sidePaneId: string | null } {
const strippedLayout = stripEphemeralTabsFromLayout(input.layout);
const legacyExplorerPaneId = resolveExplorerSidebarPaneId(
strippedLayout,
input.legacyExplorerPaneId,
);
const legacyExplorerPane = findPaneById(strippedLayout.root, legacyExplorerPaneId);
if (!legacyExplorerPane) {
const ensured = ensurePersistedExplorerSidebarPane({
layout: strippedLayout,
registeredPaneId: null,
ids: input.ids,
});
return {
layout: ensured?.layout ?? strippedLayout,
explorerPaneId: ensured?.paneId ?? EXPLORER_SIDEBAR_PANE_ID,
sidePaneId: input.rememberedSidePaneId ?? null,
};
}
const preservedTabs = collectAllTabs(strippedLayout.root).filter(
(tab) =>
legacyExplorerPane.tabIds.includes(tab.tabId) &&
tab.target.kind !== "files" &&
tab.target.kind !== "changes_tree",
);
const preservedSide = preserveVersionOneSideTabs({
layout: strippedLayout,
legacyExplorerPane,
rememberedSidePane: findPaneById(strippedLayout.root, input.rememberedSidePaneId ?? null),
tabs: preservedTabs,
ids: input.ids,
});
const explorerNode = createExplorerSidebarNode();
const focusedPaneId =
strippedLayout.focusedPaneId === legacyExplorerPane.id
? (preservedSide.sidePaneId ?? collectAllPanes(preservedSide.root)[0]?.id ?? DEFAULT_PANE_ID)
: strippedLayout.focusedPaneId;
return {
layout: normalizeLayout({
root: {
kind: "group",
group: {
id: input.ids.createNodeId("group"),
direction: "horizontal",
children: [preservedSide.root, explorerNode],
sizes: [0.78, 0.22],
},
},
focusedPaneId,
parentTabIdByTabId: strippedLayout.parentTabIdByTabId,
}),
explorerPaneId: EXPLORER_SIDEBAR_PANE_ID,
sidePaneId: preservedSide.sidePaneId,
};
}
function migrateWorkspaceLayoutPersistedState(
persistedState: unknown,
version: number,
ids: WorkspaceLayoutIdSource,
): z.infer<typeof WorkspaceLayoutPersistedStateSchema> {
const result = WorkspaceLayoutPersistedStateSchema.safeParse(persistedState);
if (!result.success || version >= WORKSPACE_LAYOUT_PERSIST_VERSION) {
return result.success ? result.data : { layoutByWorkspace: {} };
}
const layoutByWorkspace: Record<string, WorkspaceLayout> = {};
const explorerPaneIdByWorkspace: Record<string, string | null> = {};
const sidePaneIdByWorkspace = { ...result.data.sidePaneIdByWorkspace };
for (const [workspaceKey, layout] of Object.entries(result.data.layoutByWorkspace)) {
const migrated = migrateVersionOneWorkspaceLayout({
layout,
legacyExplorerPaneId: result.data.explorerPaneIdByWorkspace?.[workspaceKey],
rememberedSidePaneId: result.data.sidePaneIdByWorkspace?.[workspaceKey],
ids,
});
layoutByWorkspace[workspaceKey] = migrated.layout;
explorerPaneIdByWorkspace[workspaceKey] = migrated.explorerPaneId;
sidePaneIdByWorkspace[workspaceKey] = migrated.sidePaneId;
}
return {
...result.data,
layoutByWorkspace,
explorerPaneIdByWorkspace,
sidePaneIdByWorkspace,
};
}
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
@@ -424,21 +598,15 @@ export function resolveExplorerSidebarPaneId(
}
function ensurePersistedExplorerSidebarPane(input: {
workspaceKey: string;
layout: WorkspaceLayout;
registeredPaneId: string | null | undefined;
ids: WorkspaceLayoutIdSource;
}): { layout: WorkspaceLayout; paneId: string } | null {
const existingPaneId = resolveExplorerSidebarPaneId(input.layout, input.registeredPaneId);
if (existingPaneId) {
const migratedLayout = migrateExplorerSidebarTabs(
input.workspaceKey,
input.layout,
existingPaneId,
);
return {
layout: keepWorkspaceFocusOutOfExplorerSidebar(
migratedLayout,
input.layout,
existingPaneId,
input.layout.focusedPaneId,
),
@@ -471,83 +639,11 @@ function ensurePersistedExplorerSidebarPane(input: {
split.paneId,
);
return {
layout: migrateExplorerSidebarTabs(input.workspaceKey, seededLayout, split.paneId),
layout: seededLayout,
paneId: split.paneId,
};
}
/** Keeps hydration compatible while enforcing the Explorer's navigation-only contract. */
function migrateExplorerSidebarTabs(
workspaceKey: string,
layout: WorkspaceLayout,
paneId: string,
): WorkspaceLayout {
const changesMigrated = migrateLegacyExplorerSidebarChanges(layout, paneId);
const pane = findPaneById(changesMigrated.root, paneId);
if (!pane) return changesMigrated;
const targetPane =
findPaneById(changesMigrated.root, DEFAULT_PANE_ID) ??
collectAllPanes(changesMigrated.root).find((candidate) => candidate.id !== paneId);
if (!targetPane) return changesMigrated;
const tabsById = new Map(collectAllTabs(changesMigrated.root).map((tab) => [tab.tabId, tab]));
let nextLayout = changesMigrated;
for (const tabId of pane.tabIds) {
const tab = tabsById.get(tabId);
if (
!tab ||
tab.target.kind === "new_tab" ||
panelTargetSupportsHostForWorkspaceKey(workspaceKey, tab.target, "explorer")
) {
continue;
}
nextLayout =
moveTabToPaneInLayout({ layout: nextLayout, tabId, toPaneId: targetPane.id }) ?? nextLayout;
}
return nextLayout;
}
function migrateLegacyExplorerSidebarChanges(
layout: WorkspaceLayout,
paneId: string,
): WorkspaceLayout {
const pane = findPaneById(layout.root, paneId);
if (!pane) return layout;
const tabsById = new Map(collectAllTabs(layout.root).map((tab) => [tab.tabId, tab]));
const paneTabs = pane.tabIds.flatMap((tabId) => {
const tab = tabsById.get(tabId);
return tab ? [tab] : [];
});
const legacyTabs = paneTabs.filter((tab) => tab.target.kind === "working_diff");
if (legacyTabs.length === 0) return layout;
let nextLayout = layout;
let hasChangesTree = paneTabs.some((tab) => tab.target.kind === "changes_tree");
for (const legacyTab of legacyTabs) {
if (hasChangesTree) {
nextLayout =
closeTabInLayout({
layout: nextLayout,
tabId: legacyTab.tabId,
preserveEmptyPaneId: paneId,
}) ?? nextLayout;
continue;
}
const replacement = replaceTabTargetInLayout({
layout: nextLayout,
tabId: legacyTab.tabId,
target: { kind: "changes_tree" },
createTabId: createWorkspaceTabInstanceId,
});
if (replacement) {
nextLayout = replacement.layout;
hasChangesTree = true;
}
}
return nextLayout;
}
function getOpenTabPlacement(
state: WorkspaceLayoutStore,
workspaceKey: string,
@@ -1722,8 +1818,10 @@ export function createWorkspaceLayoutStore(
}),
{
name: "workspace-layout-state",
version: 1,
version: WORKSPACE_LAYOUT_PERSIST_VERSION,
storage: createValidatedPersistStorage(AsyncStorage, WorkspaceLayoutPersistedStateSchema),
migrate: (persistedState, version) =>
migrateWorkspaceLayoutPersistedState(persistedState, version, ids),
partialize: (state) => {
const layoutByWorkspace: Record<string, WorkspaceLayout> = {};
for (const key in state.layoutByWorkspace) {
@@ -1748,14 +1846,14 @@ export function createWorkspaceLayoutStore(
}
const layoutByWorkspace: Record<string, WorkspaceLayout> = {};
const explorerSidebarPaneIdByWorkspace: Record<string, string | null> = {
...result.data.explorerPaneIdByWorkspace,
...(result.data.explorerSidebarPaneIdByWorkspace ??
result.data.explorerPaneIdByWorkspace),
};
for (const [workspaceKey, persistedLayout] of Object.entries(
result.data.layoutByWorkspace,
)) {
const strippedLayout = stripEphemeralTabsFromLayout(persistedLayout);
const explorerSidebar = ensurePersistedExplorerSidebarPane({
workspaceKey,
layout: strippedLayout,
registeredPaneId: explorerSidebarPaneIdByWorkspace[workspaceKey],
ids,
@@ -113,6 +113,15 @@ describe("scoreMatch typo tolerance", () => {
expect(scoreMatch("confug", "revamp configuration editor", typoTolerant)?.tier).toBe(6);
});
it("drops the subsequence tier when the caller opts out", () => {
// Callers that preselect a row need a near miss to read as no match, so they turn this off.
// Only the subsequence tier goes; every contiguous tier still scores the same.
expect(scoreMatch("confg", "revamp configuration editor", { subsequence: false })).toBeNull();
expect(scoreMatch("config", "revamp configuration editor", { subsequence: false })?.tier).toBe(
3,
);
});
it("reports the offset and edit distance of the word it matched", () => {
expect(scoreMatch("bulling", "add stripe billing", typoTolerant)).toEqual({
tier: 6,
@@ -210,6 +219,14 @@ describe("scoreTextFields", () => {
).not.toBeNull();
expect(scoreTextFields("stipe bulling", ["add stripe billing"])).toBeNull();
});
it("carries the subsequence opt-out down to each token", () => {
const fields = ["Label as Design"];
// Splitting on whitespace is what the opt-out keeps; only the run-together form goes.
expect(scoreTextFields("lab des", fields, { subsequence: false })).not.toBeNull();
expect(scoreTextFields("labdes", fields, { subsequence: false })).toBeNull();
expect(scoreTextFields("labdes", fields)).not.toBeNull();
});
});
describe("compareMatchScores", () => {
+17 -2
View File
@@ -17,6 +17,14 @@ export interface MatchScore {
export interface MatchOptions {
/** Omit or pass null to match exactly. `fuzzyPolicyForToken` picks a policy. */
fuzzy?: FuzzyPolicy | null;
/**
* Match characters that appear in order but not adjacently, so `pasbab` finds
* `paseo-babysit`. Defaults to on. Turn it off where a near miss has to read as
* no match at all: it widens far enough that most typos hit something, and a
* list that preselects its first row would then act on that row when the user
* presses Enter expecting nothing to happen.
*/
subsequence?: boolean;
}
/** Exact tiers, best to worst. The fuzzy tier always sorts after all of them. */
@@ -194,7 +202,8 @@ export function scoreMatch(
const t = text.toLowerCase();
if (t === q) return { tier: TIER_EXACT, offset: 0 };
const exact = scoreSubstringMatch(q, t) ?? scoreSubsequenceMatch(q, t);
const substring = scoreSubstringMatch(q, t);
const exact = substring ?? (options.subsequence === false ? null : scoreSubsequenceMatch(q, t));
if (exact) return exact;
const fuzzy = options.fuzzy;
@@ -307,6 +316,12 @@ export interface TextFieldsOptions {
* query mixes long words that can absorb an edit with short ones that cannot.
*/
typoTolerant?: boolean;
/**
* See `MatchOptions.subsequence`. Applied per token, so turning it off means
* every token has to appear as a run of adjacent characters in some field —
* `lab des` still matches "Label as Design", `labdes` no longer does.
*/
subsequence?: boolean;
}
export function scoreTextFields(
@@ -322,7 +337,7 @@ export function scoreTextFields(
const fuzzy = options.typoTolerant ? fuzzyPolicyForToken(token) : null;
let best: MatchScore | null = null;
for (const field of fields) {
const score = scoreMatch(token, field, { fuzzy });
const score = scoreMatch(token, field, { fuzzy, subsequence: options.subsequence });
if (score && (!best || compareMatchScores(score, best) < 0)) {
best = score;
}
@@ -131,6 +131,17 @@ const MODELS: AgentModelDefinition[] = [
intervalMs: 5,
},
},
{
provider: MOCK_LOAD_TEST_PROVIDER_ID,
id: "e2e-fast-stream",
label: "E2E fast stream",
description: "Short deterministic stream for browser tests.",
isSelectable: false,
metadata: {
durationMs: 2_000,
intervalMs: 20,
},
},
];
interface ActiveTurn {
@@ -253,6 +253,7 @@ function createRealOutcomeHarness(input: {
archivedWorkspaceIds: Set<string>;
}) {
const active = [...input.activeWorkspaces];
const autoArchivedChangeRequestUrls = new Map<string, string>();
const logger = pino({ level: "silent" });
vi.spyOn(logger, "info").mockImplementation(() => undefined);
vi.spyOn(logger, "warn").mockImplementation(() => undefined);
@@ -311,8 +312,12 @@ function createRealOutcomeHarness(input: {
},
listActiveWorkspaces: async () =>
active.filter((workspace) => !input.archivedWorkspaceIds.has(workspace.workspaceId)),
getAutoArchivedChangeRequestUrl: async () => null,
archiveWorkspaceRecord: async (workspaceId: string) => {
getAutoArchivedChangeRequestUrl: async (workspaceId: string) =>
autoArchivedChangeRequestUrls.get(workspaceId) ?? null,
archiveWorkspaceRecord: async (workspaceId: string, context) => {
if (context?.autoArchivedChangeRequestUrl) {
autoArchivedChangeRequestUrls.set(workspaceId, context.autoArchivedChangeRequestUrl);
}
input.archivedWorkspaceIds.add(workspaceId);
const index = active.findIndex((workspace) => workspace.workspaceId === workspaceId);
if (index !== -1) {
@@ -327,6 +332,10 @@ function createRealOutcomeHarness(input: {
return {
options,
log: logger,
unarchiveWorkspace(workspace: ActiveWorkspaceRef) {
input.archivedWorkspaceIds.delete(workspace.workspaceId);
active.push(workspace);
},
};
}
@@ -550,4 +559,48 @@ describe("archiveIfSafe", () => {
expect(archivedWorkspaceIds.has(workspaceA)).toBe(true);
expect(existsSync(worktree.worktreePath)).toBe(false);
});
test("real outcome: an unarchived workspace is not archived again for the same merged PR", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "merged-then-unarchived");
const workspace = {
workspaceId: "ws-merged-then-unarchived",
cwd: worktree.worktreePath,
kind: "worktree" as const,
};
const sibling = {
workspaceId: "ws-directory-preserving-sibling",
cwd: worktree.worktreePath,
kind: "local_checkout" as const,
};
const archivedWorkspaceIds = new Set<string>();
const harness = createRealOutcomeHarness({
paseoHome,
repoDir,
worktreePath: worktree.worktreePath,
activeWorkspaces: [workspace, sibling],
archivedWorkspaceIds,
});
const mergedSnapshot = { ...createSnapshot(), cwd: worktree.worktreePath };
await archiveIfSafe({
workspaceId: workspace.workspaceId,
snapshot: mergedSnapshot,
options: harness.options,
log: harness.log,
});
expect(archivedWorkspaceIds.has(workspace.workspaceId)).toBe(true);
harness.unarchiveWorkspace(workspace);
await archiveIfSafe({
workspaceId: workspace.workspaceId,
snapshot: mergedSnapshot,
options: harness.options,
log: harness.log,
});
expect(archivedWorkspaceIds.has(workspace.workspaceId)).toBe(false);
expect(existsSync(worktree.worktreePath)).toBe(true);
});
});
@@ -0,0 +1,246 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Logger } from "pino";
import { afterEach, describe, expect, test, vi } from "vitest";
import {
setupAutoArchiveOnMerge,
type AutoArchiveOnMergeDependencies,
type AutoArchiveOnMergeOptions,
} from "./index.js";
import type { WorkspaceGitRuntimeSnapshot } from "../workspace-git-service.js";
import { createWorktree } from "../../utils/worktree.js";
type PullRequestState = "open" | "closed" | "merged";
interface PullRequestObservation {
url: string;
headRefName: string;
state: PullRequestState;
}
const cleanupPaths: string[] = [];
function run(cwd: string, args: string[]): void {
execFileSync("git", args, { cwd, stdio: "pipe" });
}
async function settleObservation(): Promise<void> {
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => setImmediate(resolve));
}
async function createWorkspaceJourney() {
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "auto-archive-behavior-")));
cleanupPaths.push(tempDir);
const repoDir = path.join(tempDir, "repo");
run(tempDir, ["init", "-b", "main", repoDir]);
run(repoDir, ["config", "user.email", "test@getpaseo.local"]);
run(repoDir, ["config", "user.name", "Paseo Test"]);
writeFileSync(path.join(repoDir, "README.md"), "workspace journey\n");
run(repoDir, ["add", "README.md"]);
run(repoDir, ["-c", "commit.gpgsign=false", "commit", "-m", "initial"]);
const paseoHome = path.join(tempDir, ".paseo");
const worktree = await createWorktree({
cwd: repoDir,
worktreeSlug: "workspace",
source: { kind: "branch-off", baseBranch: "main", branchName: "workspace" },
runSetup: false,
paseoHome,
});
const workspaceId = "workspace-under-test";
let active = true;
let currentPullRequest: PullRequestObservation | null = null;
let listener: ((snapshot: WorkspaceGitRuntimeSnapshot) => void) | null = null;
let subscription: { unsubscribe: () => void } | null = null;
function snapshot(): WorkspaceGitRuntimeSnapshot {
const branch = execFileSync("git", ["branch", "--show-current"], {
cwd: worktree.worktreePath,
encoding: "utf8",
}).trim();
return {
cwd: worktree.worktreePath,
git: {
isGit: true,
repoRoot: worktree.worktreePath,
mainRepoRoot: repoDir,
currentBranch: branch,
remoteUrl: "https://github.com/acme/repo.git",
isPaseoOwnedWorktree: true,
isDirty: false,
baseRef: "main",
aheadBehind: { ahead: 0, behind: 0 },
upstreamRef: `origin/${branch}`,
aheadOfOrigin: 0,
behindOfOrigin: 0,
hasRemote: true,
diffStat: { additions: 0, deletions: 0 },
},
forge: {
featuresEnabled: true,
authState: "authenticated",
pullRequest: currentPullRequest
? {
url: currentPullRequest.url,
title: currentPullRequest.headRefName,
state: currentPullRequest.state,
baseRefName: "main",
headRefName: currentPullRequest.headRefName,
isMerged: currentPullRequest.state === "merged",
}
: null,
error: null,
},
};
}
const options = {
logger: { child: () => ({ warn: vi.fn() }) } as unknown as Logger,
daemonConfigStore: { get: () => ({ autoArchiveAfterMerge: true }) },
workspaceGitService: {
onSnapshotUpdated: (nextListener: (next: WorkspaceGitRuntimeSnapshot) => void) => {
listener = nextListener;
return {
unsubscribe: () => {
if (listener === nextListener) listener = null;
},
};
},
getSnapshot: async () => snapshot(),
},
listActiveWorkspaces: async () =>
active ? [{ workspaceId, cwd: worktree.worktreePath, kind: "worktree" as const }] : [],
} as unknown as AutoArchiveOnMergeOptions;
const dependencies: AutoArchiveOnMergeDependencies = {
archiveIfSafe: async () => {
active = false;
},
resolvePath: path.resolve,
};
function startDaemon(): void {
subscription?.unsubscribe();
subscription = setupAutoArchiveOnMerge(options, dependencies);
}
async function observe(observation: PullRequestObservation | null): Promise<void> {
currentPullRequest = observation;
const activeListener = listener;
if (!activeListener) throw new Error("Daemon is not observing the workspace");
activeListener(snapshot());
await settleObservation();
}
function checkout(branch: string): void {
run(worktree.worktreePath, ["checkout", "-B", branch]);
}
startDaemon();
return {
checkout,
isActive: () => active,
observe,
restartDaemon: startDaemon,
};
}
function pullRequest(
headRefName: string,
state: PullRequestState,
number = 1,
): PullRequestObservation {
return {
url: `https://github.com/acme/repo/pull/${number}`,
headRefName,
state,
};
}
afterEach(() => {
for (const target of cleanupPaths.splice(0)) {
rmSync(target, { recursive: true, force: true });
}
});
describe("workspace auto-archive behavior", () => {
test("archives when the checked-out PR changes from open to merged", async () => {
const workspace = await createWorkspaceJourney();
workspace.checkout("feature");
await workspace.observe(pullRequest("feature", "open"));
expect(workspace.isActive()).toBe(true);
await workspace.observe(pullRequest("feature", "merged"));
expect(workspace.isActive()).toBe(false);
});
test("archives an existing open PR checked out after workspace creation when it later merges", async () => {
const workspace = await createWorkspaceJourney();
workspace.checkout("existing-open-pr");
await workspace.observe(pullRequest("existing-open-pr", "open"));
expect(workspace.isActive()).toBe(true);
await workspace.observe(pullRequest("existing-open-pr", "merged"));
expect(workspace.isActive()).toBe(false);
});
test("does not archive when an already-merged PR is checked out", async () => {
const workspace = await createWorkspaceJourney();
workspace.checkout("already-merged-pr");
await workspace.observe(pullRequest("already-merged-pr", "merged"));
expect(workspace.isActive()).toBe(true);
});
test("does not archive when an open PR is replaced by a different already-merged PR", async () => {
const workspace = await createWorkspaceJourney();
workspace.checkout("open-pr");
await workspace.observe(pullRequest("open-pr", "open", 1));
workspace.checkout("already-merged-pr");
await workspace.observe(pullRequest("already-merged-pr", "merged", 2));
expect(workspace.isActive()).toBe(true);
});
test("does not archive after leaving an open PR and later checking it out already merged", async () => {
const workspace = await createWorkspaceJourney();
workspace.checkout("eventually-merged-pr");
await workspace.observe(pullRequest("eventually-merged-pr", "open"));
workspace.checkout("unrelated-work");
await workspace.observe(null);
workspace.checkout("eventually-merged-pr");
await workspace.observe(pullRequest("eventually-merged-pr", "merged"));
expect(workspace.isActive()).toBe(true);
});
test("does not archive when an open PR closes without merging", async () => {
const workspace = await createWorkspaceJourney();
workspace.checkout("closed-pr");
await workspace.observe(pullRequest("closed-pr", "open"));
await workspace.observe(pullRequest("closed-pr", "closed"));
expect(workspace.isActive()).toBe(true);
});
test("does not infer a merge transition from the first observation after daemon restart", async () => {
const workspace = await createWorkspaceJourney();
workspace.checkout("merged-while-offline");
await workspace.observe(pullRequest("merged-while-offline", "open"));
workspace.restartDaemon();
await workspace.observe(pullRequest("merged-while-offline", "merged"));
expect(workspace.isActive()).toBe(true);
});
});
@@ -9,7 +9,10 @@ import {
} from "./index.js";
import type { WorkspaceGitRuntimeSnapshot } from "../workspace-git-service.js";
function createSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
function createSnapshot(
cwd: string,
state: "open" | "merged" = "merged",
): WorkspaceGitRuntimeSnapshot {
return {
cwd,
git: {
@@ -32,11 +35,11 @@ function createSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
authState: "authenticated",
pullRequest: {
url: "https://github.com/acme/repo/pull/12",
title: "Merged",
state: "merged",
title: "Feature",
state,
baseRefName: "main",
headRefName: "feature",
isMerged: true,
isMerged: state === "merged",
},
error: null,
},
@@ -47,9 +50,6 @@ test("fans one fresh observation out to every workspace attached to its exact cw
let onSnapshotUpdated: ((snapshot: WorkspaceGitRuntimeSnapshot) => void) | null = null;
const eventSnapshot = createSnapshot("/repo/worktree/.");
const freshSnapshot = createSnapshot("/repo/worktree");
if (freshSnapshot.forge.pullRequest) {
freshSnapshot.forge.pullRequest.url = "https://github.com/acme/repo/pull/13";
}
const getSnapshot = vi.fn(async () => freshSnapshot);
const options = {
logger: { child: () => ({ warn: vi.fn() }) } as unknown as Logger,
@@ -85,6 +85,7 @@ test("fans one fresh observation out to every workspace attached to its exact cw
setupAutoArchiveOnMerge(options, deps);
if (!onSnapshotUpdated) throw new Error("Snapshot listener was not registered");
onSnapshotUpdated(createSnapshot("/repo/worktree/.", "open"));
onSnapshotUpdated(eventSnapshot);
await finished;
@@ -135,6 +136,7 @@ test("serializes the complete fan-out for duplicate merge events on one cwd", as
setupAutoArchiveOnMerge(options, deps);
if (!onSnapshotUpdated) throw new Error("Snapshot listener was not registered");
onSnapshotUpdated(createSnapshot("/repo/worktree/.", "open"));
onSnapshotUpdated(snapshot);
await vi.waitFor(() => expect(archivedWorkspaceIds).toEqual(["workspace-a"]));
@@ -171,6 +173,7 @@ test("does not fan out a stale merged event when the fresh observation has no PR
setupAutoArchiveOnMerge(options, { archiveIfSafe, resolvePath: resolve });
if (!onSnapshotUpdated) throw new Error("Snapshot listener was not registered");
onSnapshotUpdated(createSnapshot("/repo/worktree", "open"));
onSnapshotUpdated(eventSnapshot);
await vi.waitFor(() => expect(getSnapshot).toHaveBeenCalledTimes(1));
await Promise.resolve();
@@ -199,6 +202,7 @@ test("logs and skips when the fresh observation cannot be read", async () => {
setupAutoArchiveOnMerge(options, { archiveIfSafe, resolvePath: resolve });
if (!onSnapshotUpdated) throw new Error("Snapshot listener was not registered");
onSnapshotUpdated(createSnapshot("/repo/worktree", "open"));
onSnapshotUpdated(createSnapshot("/repo/worktree"));
await vi.waitFor(() =>
expect(warn).toHaveBeenCalledWith(
@@ -1,4 +1,5 @@
import { resolve } from "node:path";
import { LRUCache } from "lru-cache";
import type { Logger } from "pino";
import { archiveIfSafe, type AutoArchiveArchiveOptions } from "./archive-if-safe.js";
@@ -16,6 +17,8 @@ export interface AutoArchiveOnMergeDependencies {
resolvePath: typeof resolve;
}
const OPEN_PULL_REQUEST_LATCH_MAX = 1_024;
const defaultDependencies: AutoArchiveOnMergeDependencies = {
archiveIfSafe,
resolvePath: resolve,
@@ -27,16 +30,30 @@ export function setupAutoArchiveOnMerge(
): WorkspaceGitSubscription {
const log = options.logger.child({ module: "auto-archive-on-merge" });
const inFlightCwds = new Set<string>();
const openPullRequestUrlsByCwd = new LRUCache<string, string>({
max: OPEN_PULL_REQUEST_LATCH_MAX,
});
return options.workspaceGitService.onSnapshotUpdated((snapshot) => {
if (!snapshot.forge.pullRequest?.isMerged) {
return;
}
const snapshotCwd = deps.resolvePath(snapshot.cwd);
if (options.daemonConfigStore.get().autoArchiveAfterMerge !== true) {
openPullRequestUrlsByCwd.delete(snapshotCwd);
return;
}
const snapshotCwd = deps.resolvePath(snapshot.cwd);
const pullRequest = snapshot.forge.pullRequest;
if (!pullRequest?.isMerged) {
if (pullRequest?.state.toLowerCase() === "open") {
openPullRequestUrlsByCwd.set(snapshotCwd, pullRequest.url);
} else {
openPullRequestUrlsByCwd.delete(snapshotCwd);
}
return;
}
if (openPullRequestUrlsByCwd.get(snapshotCwd) !== pullRequest.url) {
openPullRequestUrlsByCwd.delete(snapshotCwd);
return;
}
if (inFlightCwds.has(snapshotCwd)) {
return;
}
@@ -55,7 +72,15 @@ export function setupAutoArchiveOnMerge(
);
return;
}
if (!freshSnapshot?.forge.pullRequest?.isMerged) {
const freshPullRequest = freshSnapshot?.forge.pullRequest;
if (
!freshPullRequest?.isMerged ||
freshPullRequest.url !== pullRequest.url ||
openPullRequestUrlsByCwd.get(snapshotCwd) !== pullRequest.url
) {
if (openPullRequestUrlsByCwd.get(snapshotCwd) === pullRequest.url) {
openPullRequestUrlsByCwd.delete(snapshotCwd);
}
return;
}
@@ -66,7 +66,7 @@ afterEach(async () => {
});
githubTest(
"does not retarget a never-pushed workspace when its checkout moves onto a merged PR branch",
"shows but does not archive an already-merged PR checked out in another workspace",
async () => {
const repository = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-github-merge-")));
cleanupPaths.add(repository);
@@ -154,6 +154,13 @@ githubTest(
"Producer-level auto-archive reproduction.",
]);
expect((await context.client.checkoutRefresh(merged.worktreePath)).success).toBe(true);
expect((await context.client.checkoutPrStatus(merged.worktreePath)).status).toMatchObject({
url: pullRequestUrl,
state: "open",
isMerged: false,
});
const beforeMerge = await activeWorkspaceIds(context);
expect(beforeMerge).toEqual(
expect.arrayContaining([mergedWorkspace.workspace.id, unrelatedWorkspace.workspace.id]),
@@ -176,13 +183,17 @@ githubTest(
expect(unrelatedStatus.status).toBeNull();
expect(await activeWorkspaceIds(context)).toContain(unrelatedWorkspace.workspace.id);
// The workspace remains identified by the branch it was created for. Moving the
// checkout does not silently retarget its PR association or archive identity.
run(merged.worktreePath, "git", ["checkout", "--detach"]);
// The current checkout's PR remains visible, but arriving after its merge is not
// a merge transition and must not archive this workspace.
run(unrelated.worktreePath, "git", ["checkout", "merged-feature"]);
expect((await context.client.checkoutRefresh(unrelated.worktreePath)).success).toBe(true);
const switchedStatus = await context.client.checkoutPrStatus(unrelated.worktreePath);
expect(switchedStatus.status).toBeNull();
expect(switchedStatus.status).toMatchObject({
url: pullRequestUrl,
headRefName: "merged-feature",
state: "merged",
isMerged: true,
});
expect(await activeWorkspaceIds(context)).toContain(unrelatedWorkspace.workspace.id);
},
180_000,
@@ -1087,7 +1087,7 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
expect(dedupedRemoteBranch.status).toBe(1);
});
test("does not derive a deduped PR lookup target when managed metadata has no target", async () => {
test("derives the tracked PR lookup target when managed metadata has no target", async () => {
const { tempDir, repoDir, remoteDir, paseoHome } = createSameRepoGitHubPrRemoteRepo();
cleanupPaths.push(tempDir);
execFileSync("git", ["remote", "set-url", "origin", `file://${remoteDir}`], {
@@ -1139,13 +1139,20 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
"utf8",
);
const currentHead = execFileSync("git", ["rev-parse", "HEAD"], {
cwd: second.worktree.worktreePath,
encoding: "utf8",
}).trim();
const facts = await getCheckoutSnapshotFacts(second.worktree.worktreePath, { paseoHome });
expect(second.worktree.branchName).toBe("daemon-shutdown-diagnostics-1");
expect(facts).toMatchObject({
isGit: true,
currentBranch: "daemon-shutdown-diagnostics-1",
pullRequestLookupTarget: null,
pullRequestLookupTarget: {
headRef: "daemon-shutdown-diagnostics",
headSha: currentHead,
},
});
});
+91 -74
View File
@@ -2619,85 +2619,90 @@ const x = 1;
expect(lookupTarget?.headSha).toMatch(/^[0-9a-f]{40}$/);
});
it("does not retarget a PR worktree lookup from current branch tracking", async () => {
it.each([
{ state: "open", isMerged: false },
{ state: "closed", isMerged: false },
{ state: "merged", isMerged: true },
])(
"shows a $state PR for the branch currently checked out after workspace creation",
async ({ state, isMerged }) => {
execFileSync("git", ["remote", "add", "origin", "https://github.com/acme/repo.git"], {
cwd: repoDir,
});
execFileSync("git", ["branch", "contributor/old-change"], { cwd: repoDir });
execFileSync("git", ["branch", "new-change"], { cwd: repoDir });
execFileSync("git", ["config", "branch.new-change.remote", "origin"], { cwd: repoDir });
execFileSync("git", ["config", "branch.new-change.merge", "refs/heads/new-change"], {
cwd: repoDir,
});
const workspaceDir = join(paseoHome, "worktrees", "repo", "pr-worktree");
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
execFileSync("git", ["worktree", "add", workspaceDir, "contributor/old-change"], {
cwd: repoDir,
});
writePaseoWorktreeMetadata(workspaceDir, {
baseRefName: "main",
changeRequestLookupTarget: {
headRef: "old-change",
headRepositoryOwner: "contributor",
changeRequestNumber: 41,
localBranchName: "contributor/old-change",
},
});
execFileSync("git", ["checkout", "new-change"], { cwd: workspaceDir });
const requestedTargets: RequestedPullRequestTarget[] = [];
const facts = await getCheckoutSnapshotFacts(workspaceDir, { paseoHome });
const result = await getPullRequestStatus(
workspaceDir,
createGitHubServiceRecordingPullRequestTargets({
requestedTargets,
statusOverrides: { state, isMerged },
}),
{ force: true, reason: "current-checkout-pr" },
{ paseoHome, facts },
);
expect(requestedTargets).toEqual([expect.objectContaining({ headRef: "new-change" })]);
expect(result.status).toMatchObject({
headRefName: "new-change",
state,
isMerged,
});
},
);
it("shows the PR after an agent renames the branch directly with git", async () => {
execFileSync("git", ["remote", "add", "origin", "https://github.com/acme/repo.git"], {
cwd: repoDir,
});
execFileSync("git", ["branch", "contributor/old-change"], { cwd: repoDir });
execFileSync("git", ["branch", "new-change"], { cwd: repoDir });
execFileSync("git", ["config", "branch.new-change.remote", "origin"], { cwd: repoDir });
execFileSync("git", ["config", "branch.new-change.merge", "refs/heads/new-change"], {
cwd: repoDir,
});
const workspaceDir = join(paseoHome, "worktrees", "repo", "pr-worktree");
execFileSync("git", ["branch", "placeholder"], { cwd: repoDir });
const workspaceDir = join(paseoHome, "worktrees", "repo", "renamed-by-agent");
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
execFileSync("git", ["worktree", "add", workspaceDir, "contributor/old-change"], {
cwd: repoDir,
});
const staleLookupTarget = {
headRef: "old-change",
headRepositoryOwner: "contributor",
changeRequestNumber: 41,
};
execFileSync("git", ["worktree", "add", workspaceDir, "placeholder"], { cwd: repoDir });
writePaseoWorktreeMetadata(workspaceDir, {
baseRefName: "main",
changeRequestLookupTarget: {
...staleLookupTarget,
localBranchName: "contributor/old-change",
headRef: "placeholder",
localBranchName: "placeholder",
},
});
expect(await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome)).toMatchObject({
headRef: "old-change",
headRepositoryOwner: "contributor",
});
execFileSync("git", ["checkout", "new-change"], { cwd: workspaceDir });
startGitCommandMetrics();
const switchedTarget = await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome);
const commands = stopGitCommandMetrics().commands.map((command) => command.args[0]);
expect(switchedTarget).toBeNull();
expect(commands).not.toContain("fetch");
writePaseoWorktreeMetadata(workspaceDir, {
baseRefName: "main",
changeRequestLookupTarget: {
...staleLookupTarget,
localBranchName: "new-change",
},
});
expect(await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome)).toMatchObject({
headRef: "old-change",
headRepositoryOwner: "contributor",
});
execFileSync(
"git",
["remote", "add", "enterprise-fork", "git@github.acme.internal:contributor/repo.git"],
{
cwd: repoDir,
},
execFileSync("git", ["branch", "-m", "agent-chosen-name"], { cwd: workspaceDir });
const requestedTargets: RequestedPullRequestTarget[] = [];
const facts = await getCheckoutSnapshotFacts(workspaceDir, { paseoHome });
const result = await getPullRequestStatus(
workspaceDir,
createGitHubServiceRecordingPullRequestTargets({ requestedTargets }),
{ force: true, reason: "agent-renamed-branch-pr" },
{ paseoHome, facts },
);
execFileSync("git", ["config", "branch.new-change.remote", "enterprise-fork"], {
cwd: repoDir,
});
execFileSync("git", ["config", "branch.new-change.merge", "refs/heads/old-change"], {
cwd: repoDir,
});
expect(await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome)).toMatchObject({
headRef: "old-change",
headRepositoryOwner: "contributor",
});
execFileSync("git", ["branch", "local-upstream"], { cwd: repoDir });
execFileSync("git", ["config", "branch.new-change.remote", "."], { cwd: repoDir });
execFileSync("git", ["config", "branch.new-change.merge", "refs/heads/local-upstream"], {
cwd: repoDir,
});
expect(await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome)).toMatchObject({
headRef: "old-change",
headRepositoryOwner: "contributor",
expect(requestedTargets).toEqual([expect.objectContaining({ headRef: "agent-chosen-name" })]);
expect(result.status).toMatchObject({
headRefName: "agent-chosen-name",
state: "open",
isMerged: false,
});
});
@@ -2731,7 +2736,7 @@ const x = 1;
});
});
it("fails closed when a managed worktree has no metadata", async () => {
it("uses the checked-out branch when a managed worktree has no metadata", async () => {
execFileSync("git", ["branch", "feature/unpinned"], { cwd: repoDir });
const workspaceDir = join(paseoHome, "worktrees", "repo", "unpinned-worktree");
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
@@ -2739,10 +2744,12 @@ const x = 1;
cwd: repoDir,
});
expect(await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome)).toBeNull();
expect(await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome)).toMatchObject({
headRef: "feature/unpinned",
});
});
it("does not apply ambiguous legacy PR metadata to a suffixed branch", async () => {
it("uses the checked-out branch instead of ambiguous legacy PR metadata", async () => {
execFileSync("git", ["branch", "contributor/old-change-1"], { cwd: repoDir });
const workspaceDir = join(paseoHome, "worktrees", "repo", "legacy-pr-worktree");
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
@@ -2760,7 +2767,7 @@ const x = 1;
const lookupTarget = await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome);
expect(lookupTarget).toBeNull();
expect(lookupTarget).toMatchObject({ headRef: "contributor/old-change-1" });
});
it("does not apply a legacy fork hint to an ownerless branch with the same head", async () => {
@@ -2803,7 +2810,14 @@ const x = 1;
{ paseoHome, facts: ownerlessFacts },
);
expect(requestedTargets).toHaveLength(1);
expect(requestedTargets).toEqual([
expect.objectContaining({
headRef: "old-change",
headRepositoryOwner: "contributor",
}),
expect.objectContaining({ headRef: "old-change" }),
]);
expect(requestedTargets[1]).not.toHaveProperty("headRepositoryOwner");
});
it("recognizes a normalized GitHub owner branch from legacy Enterprise metadata", async () => {
@@ -2874,7 +2888,7 @@ const x = 1;
});
});
it("keeps a ref-only change request bound across rename but not branch switch", async () => {
it("keeps a ref-only change request across rename and follows a later branch switch", async () => {
execFileSync("git", ["branch", "feature/gitlab-mr"], { cwd: repoDir });
const workspaceDir = join(paseoHome, "worktrees", "repo", "gitlab-mr-worktree");
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
@@ -2918,7 +2932,10 @@ const x = 1;
{ paseoHome, facts: switchedFacts },
);
expect(requestedTargets).toHaveLength(1);
expect(requestedTargets).toEqual([
expect.objectContaining({ headRef: "feature/gitlab-mr" }),
expect.objectContaining({ headRef: "other-branch" }),
]);
});
it("moves a managed branch identity pin when its branch is renamed", async () => {
+13 -21
View File
@@ -1813,7 +1813,6 @@ function buildPullRequestLookupTargetFromMetadata(
function buildInitialPullRequestLookupTarget(input: {
currentBranch: string | null;
metadata: PaseoWorktreeMetadata | null;
branchRemoteName: string | null;
branchMergeRef: string | null;
branchRemoteUrl: string | null;
@@ -1824,12 +1823,6 @@ function buildInitialPullRequestLookupTarget(input: {
return null;
}
// Paseo worktree metadata owns PR identity. A checkout drift must not fall
// through to branch config and silently retarget the workspace.
if (input.metadata) {
return buildPullRequestLookupTargetFromMetadata(input.metadata, input.currentBranch);
}
const hasConfiguredBranchTarget = Boolean(
input.branchRemoteName && parseBranchMergeHeadRef(input.branchMergeRef),
);
@@ -1844,17 +1837,14 @@ function buildInitialPullRequestLookupTarget(input: {
});
}
return (
buildPullRequestLookupTargetFromMetadata(input.metadata, input.currentBranch) ??
buildPullRequestLookupTargetFromBranchConfig({
currentBranch: input.currentBranch,
branchRemoteName: input.branchRemoteName,
branchMergeRef: input.branchMergeRef,
branchRemoteUrl: input.branchRemoteUrl,
originRemoteUrl: input.originRemoteUrl,
resolvedBaseRef: input.resolvedBaseRef,
})
);
return buildPullRequestLookupTargetFromBranchConfig({
currentBranch: input.currentBranch,
branchRemoteName: input.branchRemoteName,
branchMergeRef: input.branchMergeRef,
branchRemoteUrl: input.branchRemoteUrl,
originRemoteUrl: input.originRemoteUrl,
resolvedBaseRef: input.resolvedBaseRef,
});
}
async function resolvePullRequestLookupTargetFromPushConfig(
@@ -1900,13 +1890,15 @@ async function resolveFactsPullRequestLookupTarget(input: {
context?: CheckoutContext;
}): Promise<PullRequestStatusLookupTarget | null> {
const { cwd, inspected, metadata, context } = input;
if (inspected.paseoWorktree.isPaseoOwnedWorktree) {
return buildPullRequestLookupTargetFromMetadata(metadata, inspected.currentBranch ?? "");
const metadataTarget = inspected.currentBranch
? buildPullRequestLookupTargetFromMetadata(metadata, inspected.currentBranch)
: null;
if (metadataTarget) {
return metadataTarget;
}
let target = buildInitialPullRequestLookupTarget({
currentBranch: inspected.currentBranch,
metadata,
branchRemoteName: input.branchRemoteName,
branchMergeRef: input.branchMergeRef,
branchRemoteUrl: input.branchRemoteUrl,