mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-17 06:42:40 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0edd8c39b9 | ||
|
|
4bfb682cd9 | ||
|
|
c4dc701411 | ||
|
|
f73f65cd78 | ||
|
|
bffb33ed32 | ||
|
|
8bfae15855 | ||
|
|
c778c47b30 |
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
|
||||
+16
-5
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user