mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 19:52:49 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0edd8c39b9 | ||
|
|
4bfb682cd9 | ||
|
|
c4dc701411 | ||
|
|
f73f65cd78 | ||
|
|
bffb33ed32 | ||
|
|
8bfae15855 | ||
|
|
c778c47b30 |
@@ -348,12 +348,12 @@ jobs:
|
|||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
run: npx playwright install chromium
|
run: npx playwright install chromium
|
||||||
|
|
||||||
- name: Build app dependencies
|
|
||||||
run: npm run build:app-deps
|
|
||||||
|
|
||||||
- name: Build server stack
|
- name: Build server stack
|
||||||
run: npm run build:server
|
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
|
- 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
|
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.
|
- 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.
|
- 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.
|
- 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`.
|
- 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.
|
- 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.
|
- 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("Add Project command-center flow", () => {
|
||||||
test.describe.configure({ timeout: 180_000 });
|
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 gotoAppShell(page);
|
||||||
|
|
||||||
await openAddProjectFlow(page);
|
await openAddProjectFlow(page);
|
||||||
|
|
||||||
await expect(addProjectFlowMethod(page, "directory-search")).toBeVisible();
|
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(addProjectFlowInput(page)).toHaveCount(0);
|
||||||
await expect(addProjectFlow(page).getByRole("textbox")).toHaveCount(0);
|
await expect(addProjectFlow(page).getByRole("textbox")).toHaveCount(0);
|
||||||
await expect(page.getByTestId("add-project-flow-page-host")).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 }) => {
|
test("an offline extra host neither appears nor forces host selection", async ({ page }) => {
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await addOfflineHostAndReload(page, {
|
await addOfflineHostAndReload(page, {
|
||||||
@@ -273,14 +240,6 @@ test.describe("Add Project command-center flow", () => {
|
|||||||
await expectProjectHasNoWorkspaces(projectId);
|
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 ({
|
test("a complete repository URL remains selectable without a GitHub search result", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -1,30 +1,9 @@
|
|||||||
import type { Locator } from "@playwright/test";
|
import type { Locator } from "@playwright/test";
|
||||||
import { expect, test, type Page } from "../support/fixtures";
|
import { expect, test, type Page } from "../support/fixtures";
|
||||||
import { expectAgentIdle, expectAgentReadyToInterrupt } from "../support/helpers/agent-stream";
|
import { expectAgentIdle, expectAgentReadyToInterrupt } from "../support/helpers/agent-stream";
|
||||||
import {
|
import { cancelAgent, expectComposerVisible, submitMessage } from "../support/helpers/composer";
|
||||||
cancelAgent,
|
|
||||||
expectComposerDraft,
|
|
||||||
expectComposerVisible,
|
|
||||||
submitMessage,
|
|
||||||
} from "../support/helpers/composer";
|
|
||||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
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> {
|
async function rewindConversation(page: Page, userMessage: Locator, prompt: string): Promise<void> {
|
||||||
await userMessage.getByText(prompt, { exact: true }).hover();
|
await userMessage.getByText(prompt, { exact: true }).hover();
|
||||||
await userMessage.getByRole("button", { name: "Rewind to this message", exact: true }).click();
|
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.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("keeps a pre-acknowledgement turn running after rewind is rejected", async ({ page }) => {
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
const prompt = "Delay synthetic user message by 2000ms.";
|
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("Agent profiles repair modeless provider preferences", () => {
|
||||||
test.describe.configure({ timeout: 240_000 });
|
test.describe.configure({ timeout: 240_000 });
|
||||||
|
|
||||||
@@ -207,6 +219,9 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
|
|||||||
|
|
||||||
await openModelPicker(page);
|
await openModelPicker(page);
|
||||||
await applyProfileFromPicker(page, "Fast profile");
|
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.");
|
await submitNewWorkspacePrompt(page, "Create an agent with profile features.");
|
||||||
|
|
||||||
const createAgentRequest = await createAgentRecorder.waitForRequest();
|
const createAgentRequest = await createAgentRecorder.waitForRequest();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ async function openMockAgentAtMobileBreakpoint(page: Page) {
|
|||||||
repoPrefix: "bottom-sheet-reopen-",
|
repoPrefix: "bottom-sheet-reopen-",
|
||||||
title: "Bottom sheet reopen e2e",
|
title: "Bottom sheet reopen e2e",
|
||||||
initialPrompt: "Prepare a bottom sheet reopen test agent.",
|
initialPrompt: "Prepare a bottom sheet reopen test agent.",
|
||||||
|
model: "ten-second-stream",
|
||||||
});
|
});
|
||||||
await openAgentRoute(page, session);
|
await openAgentRoute(page, session);
|
||||||
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toBeVisible({
|
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toBeVisible({
|
||||||
@@ -22,16 +23,6 @@ async function openMockAgentAtMobileBreakpoint(page: Page) {
|
|||||||
return session;
|
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) {
|
function bottomSheetBackdrop(page: Page) {
|
||||||
return page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
|
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.describe("mobile bottom sheet reopen", () => {
|
||||||
test("tab switcher can open, close, reopen, and close again", async ({ page }) => {
|
test("sheets reopen and model search returns to configuration", async ({ page }) => {
|
||||||
await withMobileMockAgent(page, async () => {
|
const session = await openMockAgentAtMobileBreakpoint(page);
|
||||||
await openAndCloseTabSwitcherTwice(page);
|
try {
|
||||||
});
|
await test.step("tab switcher opens, closes, and reopens", 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,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await model.click();
|
await test.step("model selector opens, closes, and reopens", async () => {
|
||||||
|
await openAndCloseModelSelectorTwice(page);
|
||||||
|
});
|
||||||
|
|
||||||
await expect(sheet).toBeVisible();
|
await test.step("model search returns to configuration", async () => {
|
||||||
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible();
|
await openModelSelector(page);
|
||||||
await expect(page.getByTestId("agent-controls-model")).toContainText("Ten second stream");
|
const sheet = page.getByTestId("agent-controls-model-sheet");
|
||||||
await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible();
|
|
||||||
});
|
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.
|
// releases stale sessions from the previous spec; one retry stabilizes it.
|
||||||
test.describe.configure({ retries: 1 });
|
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 ({
|
test("a custom workspace title stays in the header while the diff panel switches the real branch", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
clickChatOutlineRowEdge,
|
clickChatOutlineRowEdge,
|
||||||
disableChatOutlineFromAppearance,
|
disableChatOutlineFromAppearance,
|
||||||
expectActiveChatOutlinePrompt,
|
expectActiveChatOutlinePrompt,
|
||||||
|
expectActiveChatOutlinePromptMovedFrom,
|
||||||
expectChatOutlinePreview,
|
expectChatOutlinePreview,
|
||||||
expectChatOutlinePrompts,
|
expectChatOutlinePrompts,
|
||||||
expectChatOutlinePromptToRemainBare,
|
expectChatOutlinePromptToRemainBare,
|
||||||
@@ -29,7 +30,6 @@ import {
|
|||||||
openAgentTimeline,
|
openAgentTimeline,
|
||||||
scrollThroughOlderHistoryPages,
|
scrollThroughOlderHistoryPages,
|
||||||
scrollTimelineToNewestLoadedEdge,
|
scrollTimelineToNewestLoadedEdge,
|
||||||
scrollTimelineToOldestLoadedEdge,
|
|
||||||
seedLongMockAgentTimeline,
|
seedLongMockAgentTimeline,
|
||||||
type LongTimelineAgent,
|
type LongTimelineAgent,
|
||||||
} from "../support/helpers/timeline-pagination";
|
} from "../support/helpers/timeline-pagination";
|
||||||
@@ -130,11 +130,12 @@ test.describe("desktop chat outline", () => {
|
|||||||
await expectOneActiveChatOutlinePrompt(page);
|
await expectOneActiveChatOutlinePrompt(page);
|
||||||
|
|
||||||
await clickChatOutlineRowEdge(page, 4);
|
await clickChatOutlineRowEdge(page, 4);
|
||||||
await expectTimelinePromptVisible(page, agent.prompts[3]);
|
await expectTimelinePromptLandedBelowTop(page, agent.prompts[3]);
|
||||||
await expectActiveChatOutlinePrompt(page, 4);
|
await expectActiveChatOutlinePrompt(page, 4);
|
||||||
|
|
||||||
await scrollTimelineToOldestLoadedEdge(page);
|
await scrollTimelineToNewestLoadedEdge(page);
|
||||||
await expectActiveChatOutlinePrompt(page, 1);
|
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||||
|
await expectActiveChatOutlinePromptMovedFrom(page, 4);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps a clicked prompt free of persistent selection chrome", async ({ page }) => {
|
test("keeps a clicked prompt free of persistent selection chrome", async ({ page }) => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { closeCommandCenter, openCommandCenter } from "../support/helpers/comman
|
|||||||
import {
|
import {
|
||||||
applyCommandCenterAgentControls,
|
applyCommandCenterAgentControls,
|
||||||
chooseCommandCenterAgentControl,
|
chooseCommandCenterAgentControl,
|
||||||
|
openCommandCenterForAgent,
|
||||||
expectCommandCenterAgentControlSelected,
|
expectCommandCenterAgentControlSelected,
|
||||||
expectFocusedAgentControls,
|
expectFocusedAgentControls,
|
||||||
expectWorkspaceAgentConfiguration,
|
expectWorkspaceAgentConfiguration,
|
||||||
@@ -12,7 +13,9 @@ import {
|
|||||||
} from "../support/helpers/command-center-agent-controls";
|
} from "../support/helpers/command-center-agent-controls";
|
||||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
|
import { expectAppRoute } from "../support/helpers/route-assertions";
|
||||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
|
import { buildSchedulesRoute } from "@/utils/host-routes";
|
||||||
|
|
||||||
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
|
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 }) => {
|
test("applies draft model and setting choices to the created agent", async ({ page }) => {
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "command-center-draft-controls-" });
|
const workspace = await seedWorkspace({ repoPrefix: "command-center-draft-controls-" });
|
||||||
await seedMockDraftPreferences(page);
|
await seedMockDraftPreferences(page);
|
||||||
|
|||||||
@@ -1,53 +1,14 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { expect } from "@playwright/test";
|
|
||||||
import { test } from "../support/fixtures";
|
import { test } from "../support/fixtures";
|
||||||
import { gotoAppShell } from "../support/helpers/app";
|
import { gotoAppShell } from "../support/helpers/app";
|
||||||
import { createIdleAgent } from "../support/helpers/archive-tab";
|
import { createIdleAgent } from "../support/helpers/archive-tab";
|
||||||
import { openCommandCenter } from "../support/helpers/command-center";
|
import { openCommandCenter } from "../support/helpers/command-center";
|
||||||
import { addOfflineHostAndReload } from "../support/helpers/hosts";
|
|
||||||
import { expectAgentTabActive } from "../support/helpers/launcher";
|
import { expectAgentTabActive } from "../support/helpers/launcher";
|
||||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
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("Command center host labels", () => {
|
||||||
test.describe.configure({ timeout: 180_000 });
|
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 }) => {
|
test("selecting an agent result opens its workspace tab", async ({ page }) => {
|
||||||
const seeded = await seedWorkspace({ repoPrefix: "command-center-agent-navigation-" });
|
const seeded = await seedWorkspace({ repoPrefix: "command-center-agent-navigation-" });
|
||||||
const title = `cc-navigation-${randomUUID().slice(0, 8)}`;
|
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-",
|
repoPrefix: "composer-bottom-anchor-",
|
||||||
title: "Composer bottom anchor",
|
title: "Composer bottom anchor",
|
||||||
initialPrompt: "Produce enough content to make the chat scrollable.",
|
initialPrompt: "Produce enough content to make the chat scrollable.",
|
||||||
|
model: "ten-second-stream",
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
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 test.step("launching a terminal profile preselects it the next time New workspace opens", async () => {
|
||||||
await openNewWorkspace();
|
await openNewWorkspace();
|
||||||
await selectLaunchOption(page, PROMPT_PROFILE.id);
|
await selectLaunchOption(page, PROMPT_PROFILE.id);
|
||||||
|
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
|
||||||
await fillTerminalPrompt(page, "remember me");
|
await fillTerminalPrompt(page, "remember me");
|
||||||
await submitTerminalLaunch(page);
|
await submitTerminalLaunch(page);
|
||||||
await expectWorkspaceOpensWithTerminalTab(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 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 selectLaunchOption(page, PROMPT_PROFILE.id);
|
||||||
|
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
|
||||||
await fillTerminalPrompt(page, "second run");
|
await fillTerminalPrompt(page, "second run");
|
||||||
await submitTerminalLaunch(page);
|
await submitTerminalLaunch(page);
|
||||||
await expectWorkspaceOpensWithTerminalTab(page);
|
await expectWorkspaceOpensWithTerminalTab(page);
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ test.describe("Rewind sheet", () => {
|
|||||||
repoPrefix: "rewind-e2e-",
|
repoPrefix: "rewind-e2e-",
|
||||||
title: "Rewind e2e",
|
title: "Rewind e2e",
|
||||||
initialPrompt: firstPrompt,
|
initialPrompt: firstPrompt,
|
||||||
|
model: "ten-second-stream",
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -70,73 +70,55 @@ test.describe("History search", () => {
|
|||||||
await tempRepo?.cleanup();
|
await tempRepo?.cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("typing narrows history to matching sessions and clearing restores them", async ({
|
test("searches, ranks, highlights, and clears session history", async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await resetSeededPageState(page);
|
await resetSeededPageState(page);
|
||||||
await openSessions(page);
|
await openSessions(page);
|
||||||
|
|
||||||
// Seeded newest-first, and at rest history is chronological.
|
await test.step("narrows history and restores chronological grouping", async () => {
|
||||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
||||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, { timeout: 30_000 });
|
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
await search(page, `${NONCE} billing`);
|
await search(page, `${NONCE} billing`);
|
||||||
await expectVisibleTitles(page, [TITLES.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
|
await page.getByTestId("sessions-search-clear").click();
|
||||||
// the list no longer has.
|
await expect(page.getByTestId("sessions-search-input")).toHaveValue("");
|
||||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(0, { timeout: 30_000 });
|
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 test.step("ranks whole-word matches above substrings", async () => {
|
||||||
await expect(page.getByTestId("sessions-search-input")).toHaveValue("");
|
await search(page, `${NONCE} bill`);
|
||||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled]);
|
||||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, { timeout: 30_000 });
|
});
|
||||||
});
|
|
||||||
|
|
||||||
test("ranks a whole-word hit above one buried inside a word", async ({ page }) => {
|
await test.step("highlights exact and typo-resolved matches", async () => {
|
||||||
await resetSeededPageState(page);
|
await search(page, `${NONCE} billing`);
|
||||||
await openSessions(page);
|
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
|
await search(page, `${NONCE} bulling`);
|
||||||
// stronger match leads even though both sessions are equally recent.
|
await expectVisibleTitles(page, [TITLES.billing]);
|
||||||
await search(page, `${NONCE} bill`);
|
const typoRow = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
|
||||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled]);
|
await expect(typoRow.getByText("billing", { exact: true })).toBeVisible({
|
||||||
});
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("marks the characters each result matched on", async ({ page }) => {
|
await test.step("distinguishes no matches from empty history", async () => {
|
||||||
await resetSeededPageState(page);
|
await search(page, `${NONCE} kubernetes`);
|
||||||
await openSessions(page);
|
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 page.getByText("Clear search").click();
|
||||||
await search(page, `${NONCE} billing`);
|
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
||||||
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]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -121,6 +121,41 @@ test.describe("Settings sidebar navigation", () => {
|
|||||||
await clickSettingsBackToWorkspace(page);
|
await clickSettingsBackToWorkspace(page);
|
||||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
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", () => {
|
test.describe("Settings — compact master-detail", () => {
|
||||||
|
|||||||
@@ -41,46 +41,37 @@ async function expectExternalPage(
|
|||||||
await popup.close();
|
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 gotoAppShell(page);
|
||||||
await expect(page.getByTestId("sidebar-help")).toBeVisible();
|
await expect(page.getByTestId("sidebar-help")).toBeVisible();
|
||||||
|
|
||||||
await openHelpMenu(page);
|
await test.step("opens diagnostics and keyboard shortcuts", async () => {
|
||||||
const triggerBox = await page.getByTestId("sidebar-help").evaluate((element) => {
|
await openHelpMenu(page);
|
||||||
const { y, height } = element.getBoundingClientRect();
|
await expect(page.getByText("Help", { exact: true })).toBeVisible();
|
||||||
return { y, height };
|
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();
|
await test.step("opens support and release pages", async () => {
|
||||||
return { y, height };
|
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 }) => {
|
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.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({
|
const workspace = await seedWorkspace({
|
||||||
repoPrefix: "pin-project-creation-",
|
repoPrefix: "pin-project-creation-",
|
||||||
title: "Pinned workspace",
|
title: "Pinned workspace",
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const gate = await installPinRpcGate(page);
|
||||||
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openWorkspace(page, workspace.workspaceId);
|
await openWorkspace(page, workspace.workspaceId);
|
||||||
await page.keyboard.press(PIN_SHORTCUT);
|
|
||||||
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
|
||||||
|
|
||||||
await openNewWorkspaceComposer(page, workspace);
|
await test.step("sends one RPC for each pin transition", async () => {
|
||||||
await expectNewWorkspaceProjectSelected(page, workspace.projectDisplayName);
|
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 {
|
} finally {
|
||||||
await workspace.cleanup();
|
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 }) => {
|
test("pins the active workspace while its status group is collapsed", async ({ page }) => {
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-status-" });
|
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 }) => {
|
test("non-git project shows directory name", async ({ page }) => {
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-directory-", git: false });
|
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.
|
// One retry is enough for the daemon to stabilize.
|
||||||
test.describe.configure({ retries: 1 });
|
test.describe.configure({ retries: 1 });
|
||||||
|
|
||||||
test.describe("Main checkout", () => {
|
test("main checkout creates an agent chat and terminal in the project root", async ({
|
||||||
test("creates an agent chat via New Chat", async ({ page, withWorkspace }) => {
|
page,
|
||||||
test.setTimeout(60_000);
|
withWorkspace,
|
||||||
const workspace = await withWorkspace({ prefix: "lifecycle-main-chat-" });
|
}) => {
|
||||||
await workspace.navigateTo();
|
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);
|
await createAgentChatFromLauncher(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => {
|
await test.step("creates a terminal in the project root", async () => {
|
||||||
test.setTimeout(60_000);
|
|
||||||
const workspace = await withWorkspace({ prefix: "lifecycle-main-shell-" });
|
|
||||||
await workspace.navigateTo();
|
|
||||||
await createStandaloneTerminalFromLauncher(page);
|
await createStandaloneTerminalFromLauncher(page);
|
||||||
await expectTerminalCwd(page, workspace.repoPath);
|
await expectTerminalCwd(page, workspace.repoPath);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Worktree workspace", () => {
|
test("worktree creates an agent chat and terminal in its directory", async ({
|
||||||
test("creates an agent chat via New Chat", async ({ page, withWorkspace }) => {
|
page,
|
||||||
test.setTimeout(90_000);
|
withWorkspace,
|
||||||
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-chat-" });
|
}) => {
|
||||||
await workspace.navigateTo();
|
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);
|
await createAgentChatFromLauncher(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => {
|
await test.step("creates a terminal in the worktree", async () => {
|
||||||
test.setTimeout(90_000);
|
|
||||||
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-shell-" });
|
|
||||||
await workspace.navigateTo();
|
|
||||||
await createStandaloneTerminalFromLauncher(page);
|
await createStandaloneTerminalFromLauncher(page);
|
||||||
await expectTerminalCwd(page, workspace.repoPath);
|
await expectTerminalCwd(page, workspace.repoPath);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
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 { expect, test } from "../support/fixtures";
|
||||||
import { createIdleAgent } from "../support/helpers/archive-tab";
|
import { createIdleAgent } from "../support/helpers/archive-tab";
|
||||||
import { expectComposerVisible } from "../support/helpers/composer";
|
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.describe("Workspace pane mounting", () => {
|
||||||
test("opening the first split pane keeps the existing agent composer mounted", async ({
|
test("workspace navigation keeps the existing agent composer mounted", async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
const serverId = getServerId();
|
const serverId = getServerId();
|
||||||
|
|
||||||
@@ -65,55 +77,35 @@ test.describe("Workspace pane mounting", () => {
|
|||||||
.first()
|
.first()
|
||||||
.elementHandle();
|
.elementHandle();
|
||||||
expect(originalComposer).not.toBeNull();
|
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 runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||||
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(
|
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(
|
||||||
emptyPanesBeforeSplit + 1,
|
emptyPanesBeforeSplit + 1,
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
);
|
);
|
||||||
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toHaveCount(1);
|
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toHaveCount(
|
||||||
|
1,
|
||||||
const originalStillConnected = await originalComposer!.evaluate((node) => node.isConnected);
|
);
|
||||||
expect(originalStillConnected).toBe(true);
|
await expectNodeConnected(originalComposer!);
|
||||||
} 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 page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId));
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
|
||||||
const composer = page.getByTestId("message-input-root").filter({ visible: true }).first();
|
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();
|
const settingsShortcut = await getSettingsShortcut(page);
|
||||||
await tab.click({ button: "right" });
|
await page.keyboard.press(settingsShortcut);
|
||||||
await page.getByTestId(`workspace-tab-context-agent_${agent.id}-rename`).click();
|
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||||
const renameInput = renameModalInput(page, `workspace-tab-rename-modal-agent-${agent.id}`);
|
await expect(renameInput).not.toBeVisible();
|
||||||
await expect(renameInput).toBeVisible();
|
await clickSettingsBackToWorkspace(page);
|
||||||
|
await expectSameRenderedNode(originalComposer!, composer);
|
||||||
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);
|
|
||||||
} finally {
|
} finally {
|
||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
}
|
}
|
||||||
@@ -140,18 +132,17 @@ test.describe("Workspace pane mounting", () => {
|
|||||||
const composer = page.getByTestId("message-input-root").filter({ visible: true }).first();
|
const composer = page.getByTestId("message-input-root").filter({ visible: true }).first();
|
||||||
const originalComposer = await captureRenderedNode(composer);
|
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.goBack();
|
||||||
await page.waitForURL(
|
await waitForWorkspaceRoute(page, workspaceRoute);
|
||||||
(url) => url.pathname === buildHostWorkspaceRoute(serverId, workspace.workspaceId),
|
|
||||||
);
|
|
||||||
await expectSameRenderedNode(originalComposer, composer);
|
await expectSameRenderedNode(originalComposer, composer);
|
||||||
} finally {
|
} finally {
|
||||||
await workspace.cleanup();
|
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);
|
test.setTimeout(90_000);
|
||||||
const serverId = getServerId();
|
const serverId = getServerId();
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "terminal-pane-retention-" });
|
const workspace = await seedWorkspace({ repoPrefix: "terminal-pane-retention-" });
|
||||||
@@ -170,39 +161,20 @@ test.describe("Workspace pane mounting", () => {
|
|||||||
const terminalSurface = terminalSurfaceLocator(page);
|
const terminalSurface = terminalSurfaceLocator(page);
|
||||||
const originalTerminal = await captureRenderedNode(terminalSurface);
|
const originalTerminal = await captureRenderedNode(terminalSurface);
|
||||||
|
|
||||||
await page.getByTestId(`workspace-tab-agent_${agent.id}`).click();
|
await test.step("switching tabs preserves the terminal", async () => {
|
||||||
await expectComposerVisible(page);
|
await page.getByTestId(`workspace-tab-agent_${agent.id}`).click();
|
||||||
expect(await originalTerminal.evaluate((node) => node.isConnected)).toBe(true);
|
await expectComposerVisible(page);
|
||||||
|
await expectNodeConnected(originalTerminal);
|
||||||
|
|
||||||
await clickFirstTerminalTab(page);
|
await clickFirstTerminalTab(page);
|
||||||
await expectSameRenderedNode(originalTerminal, terminalSurface);
|
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 page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId));
|
await test.step("opening Settings preserves the terminal", async () => {
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await openSettings(page);
|
||||||
await clickNewTerminal(page);
|
await clickSettingsBackToWorkspace(page);
|
||||||
await expectTerminalSurfaceVisible(page);
|
await expectSameRenderedNode(originalTerminal, terminalSurface);
|
||||||
const terminalSurface = terminalSurfaceLocator(page);
|
});
|
||||||
const originalTerminal = await captureRenderedNode(terminalSurface);
|
|
||||||
|
|
||||||
await openSettings(page);
|
|
||||||
await clickSettingsBackToWorkspace(page);
|
|
||||||
await expectSameRenderedNode(originalTerminal, terminalSurface);
|
|
||||||
} finally {
|
} finally {
|
||||||
await workspace.cleanup();
|
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 { createTempGitRepo } from "../support/helpers/workspace";
|
||||||
import {
|
import {
|
||||||
closeSetupTab,
|
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 client = await connectWorkspaceSetupClient();
|
||||||
const repo = await createTempGitRepo("setup-success-", {
|
const repo = await createTempGitRepo("setup-success-", {
|
||||||
paseoConfig: {
|
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 client = await connectWorkspaceSetupClient();
|
||||||
const repo = await createTempGitRepo("setup-none-");
|
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 client = await connectWorkspaceSetupClient();
|
||||||
const repo = await createTempGitRepo("setup-scripts-", {
|
const repo = await createTempGitRepo("setup-scripts-", {
|
||||||
paseoConfig: {
|
paseoConfig: {
|
||||||
|
|||||||
@@ -39,28 +39,6 @@ test.describe("Workspace archive with worktree backing", () => {
|
|||||||
await tempRepo?.cleanup().catch(() => undefined);
|
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 }) => {
|
test("a managed worktree remains until its last workspace is archived", async ({ page }) => {
|
||||||
const serverId = getServerId();
|
const serverId = getServerId();
|
||||||
await openProjectViaDaemon(client, tempRepo.path);
|
await openProjectViaDaemon(client, tempRepo.path);
|
||||||
|
|||||||
@@ -32,16 +32,8 @@ const metroTest = base.extend({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const test = metroTest.extend<
|
const daemonTest = metroTest.extend<
|
||||||
{
|
{ projectOwnership: void },
|
||||||
paseoE2ESetup: void;
|
|
||||||
projectOwnership: void;
|
|
||||||
outdatedDaemon: OutdatedDaemon;
|
|
||||||
desktopManagedOutdatedDaemon: OutdatedDaemon;
|
|
||||||
relayConfigOutdatedDaemon: OutdatedDaemon;
|
|
||||||
projectPickerFixture: TrackedProjectPickerFixture;
|
|
||||||
withWorkspace: WithWorkspace;
|
|
||||||
},
|
|
||||||
{ e2eForkProviders: string[]; e2eWorker: void; e2eWorkerClient: SeedDaemonClient }
|
{ e2eForkProviders: string[]; e2eWorker: void; e2eWorkerClient: SeedDaemonClient }
|
||||||
>({
|
>({
|
||||||
e2eForkProviders: [[], { scope: "worker", option: true }],
|
e2eForkProviders: [[], { scope: "worker", option: true }],
|
||||||
@@ -94,6 +86,16 @@ const test = metroTest.extend<
|
|||||||
},
|
},
|
||||||
{ auto: true },
|
{ auto: true },
|
||||||
],
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const test = daemonTest.extend<{
|
||||||
|
paseoE2ESetup: void;
|
||||||
|
outdatedDaemon: OutdatedDaemon;
|
||||||
|
desktopManagedOutdatedDaemon: OutdatedDaemon;
|
||||||
|
relayConfigOutdatedDaemon: OutdatedDaemon;
|
||||||
|
projectPickerFixture: TrackedProjectPickerFixture;
|
||||||
|
withWorkspace: WithWorkspace;
|
||||||
|
}>({
|
||||||
paseoE2ESetup: [
|
paseoE2ESetup: [
|
||||||
async ({ page }, provide, testInfo) => {
|
async ({ page }, provide, testInfo) => {
|
||||||
const daemonPort = getE2EDaemonPort();
|
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(
|
export async function expectLiveTurnPromptAboveFoldAndActive(
|
||||||
page: Page,
|
page: Page,
|
||||||
prompt: string,
|
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 type { SeededWorkspace } from "./seed-client";
|
||||||
import { openCommandCenter } from "./command-center";
|
import { openCommandCenter } from "./command-center";
|
||||||
|
import { openAgentRoute, type MockAgentWorkspace } from "./mock-agent";
|
||||||
|
|
||||||
export interface CommandCenterAgentControlChoice {
|
export interface CommandCenterAgentControlChoice {
|
||||||
query: string;
|
query: string;
|
||||||
choice: 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: {
|
export async function chooseCommandCenterAgentControl(input: {
|
||||||
page: Page;
|
page: Page;
|
||||||
query: string;
|
query: string;
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ export async function sampleTabsDuringTransition(
|
|||||||
function sample() {
|
function sample() {
|
||||||
const tabs = Array.from(
|
const tabs = Array.from(
|
||||||
document.querySelectorAll<HTMLElement>(
|
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);
|
).filter((element) => element.getClientRects().length > 0);
|
||||||
scope.__paseoTabTrackFrames?.push(
|
scope.__paseoTabTrackFrames?.push(
|
||||||
@@ -188,7 +188,10 @@ export async function sampleTabsDuringTransition(
|
|||||||
requestAnimationFrame(sample);
|
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);
|
}, durationMs);
|
||||||
await action();
|
await action();
|
||||||
await page.waitForTimeout(durationMs + 100);
|
await page.waitForTimeout(durationMs + 100);
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export async function seedMockAgentWorkspace(
|
|||||||
workspaceId: workspace.workspaceId,
|
workspaceId: workspace.workspaceId,
|
||||||
title: options.title,
|
title: options.title,
|
||||||
modeId: options.modeId ?? "load-test",
|
modeId: options.modeId ?? "load-test",
|
||||||
model: options.model ?? "ten-second-stream",
|
model: options.model ?? "e2e-fast-stream",
|
||||||
initialPrompt: options.initialPrompt,
|
initialPrompt: options.initialPrompt,
|
||||||
featureValues: options.featureValues,
|
featureValues: options.featureValues,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ const baseURL =
|
|||||||
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
|
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
|
||||||
const relayDeploymentSpec = "**/relay-deployment-reconnect.real.spec.ts";
|
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({
|
export default defineConfig({
|
||||||
testDir: "./e2e/browser",
|
testDir: "./e2e/browser",
|
||||||
globalSetup: "./e2e/support/global-setup.ts",
|
globalSetup: "./e2e/support/global-setup.ts",
|
||||||
@@ -21,9 +27,9 @@ export default defineConfig({
|
|||||||
reporter: [["list"]],
|
reporter: [["list"]],
|
||||||
use: {
|
use: {
|
||||||
baseURL,
|
baseURL,
|
||||||
trace: "retain-on-failure",
|
trace: process.env.CI ? "on-first-retry" : "retain-on-failure",
|
||||||
screenshot: "only-on-failure",
|
screenshot: "only-on-failure",
|
||||||
video: process.env.E2E_RECORD_VIDEO === "1" ? "on" : "retain-on-failure",
|
video: videoMode(),
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -58,8 +58,10 @@ import { shortenPath } from "@/utils/shorten-path";
|
|||||||
import { useCommandCenterContributions } from "./provider";
|
import { useCommandCenterContributions } from "./provider";
|
||||||
import {
|
import {
|
||||||
buildContributionSections,
|
buildContributionSections,
|
||||||
|
filterAndRankBuiltInResults,
|
||||||
joinSubtitleParts,
|
joinSubtitleParts,
|
||||||
moveActiveResultId,
|
moveActiveResultId,
|
||||||
|
PINNED_SECTION_BAND,
|
||||||
preserveActiveResultId,
|
preserveActiveResultId,
|
||||||
projectCommandCenterRows,
|
projectCommandCenterRows,
|
||||||
type CommandCenterAgentResult,
|
type CommandCenterAgentResult,
|
||||||
@@ -67,6 +69,7 @@ import {
|
|||||||
type CommandCenterListRow,
|
type CommandCenterListRow,
|
||||||
type CommandCenterResult,
|
type CommandCenterResult,
|
||||||
type CommandCenterResultSection,
|
type CommandCenterResultSection,
|
||||||
|
type CommandCenterSearchFields,
|
||||||
type CommandCenterWorkspaceResult,
|
type CommandCenterWorkspaceResult,
|
||||||
} from "./results";
|
} from "./results";
|
||||||
import { useWorkspaceFileSearch } from "./workspace-file-search";
|
import { useWorkspaceFileSearch } from "./workspace-file-search";
|
||||||
@@ -88,7 +91,6 @@ const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, (theme) => ({
|
|||||||
}));
|
}));
|
||||||
const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"];
|
const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"];
|
||||||
const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const;
|
const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const;
|
||||||
const DEFAULT_CATEGORY_RESULT_LIMIT = 5;
|
|
||||||
|
|
||||||
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
|
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
|
||||||
const leftNeedsInput = (left.pendingPermissionCount ?? 0) > 0 ? 1 : 0;
|
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();
|
return right.lastActivityAt.getTime() - left.lastActivityAt.getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchesQuery(searchText: string, query: string): boolean {
|
function compareWorkspacesByTitle(
|
||||||
const normalized = query.trim().toLowerCase();
|
left: CommandCenterWorkspaceResult,
|
||||||
return !normalized || searchText.includes(normalized);
|
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[] {
|
/** `cwd` is not rendered, so a path match must never outrank a match on text the user can see. */
|
||||||
return query.trim() ? results : results.slice(0, DEFAULT_CATEGORY_RESULT_LIMIT);
|
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 { t } = useTranslation();
|
||||||
const { agents } = useAggregatedAgents();
|
const { agents } = useAggregatedAgents();
|
||||||
const { projects } = useProjects({ enabled: open });
|
const { projects } = useProjects({ enabled: open });
|
||||||
const showHost = useHosts().length > 1;
|
const showHost = useHosts().length > 1;
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
if (!open) return [];
|
if (!open) return { workspaces: [], agents: [] };
|
||||||
const allWorkspaces: CommandCenterWorkspaceResult[] = [];
|
const allWorkspaces: CommandCenterWorkspaceResult[] = [];
|
||||||
for (const project of projects) {
|
for (const project of projects) {
|
||||||
for (const host of project.hosts) {
|
for (const host of project.hosts) {
|
||||||
for (const workspace of host.workspaces) {
|
for (const workspace of host.workspaces) {
|
||||||
if (workspace.archivingAt) continue;
|
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({
|
allWorkspaces.push({
|
||||||
kind: "workspace",
|
kind: "workspace",
|
||||||
id: `workspace:${host.serverId}:${workspace.id}`,
|
id: `workspace:${host.serverId}:${workspace.id}`,
|
||||||
title,
|
title: workspace.title ?? workspace.name,
|
||||||
subtitle,
|
subtitle: joinSubtitleParts([
|
||||||
searchText,
|
showHost ? host.serverName : null,
|
||||||
|
project.projectName,
|
||||||
|
workspace.currentBranch,
|
||||||
|
]),
|
||||||
run: () => {
|
run: () => {
|
||||||
clearCommandCenterFocusRestoreElement();
|
clearCommandCenterFocusRestoreElement();
|
||||||
navigateToWorkspace({ serverId: host.serverId, workspaceId: workspace.id });
|
navigateToWorkspace({ serverId: host.serverId, workspaceId: workspace.id });
|
||||||
@@ -146,60 +166,66 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
allWorkspaces.sort((left, right) => {
|
allWorkspaces.sort(compareWorkspacesByTitle);
|
||||||
const titleDelta = left.title.localeCompare(right.title, undefined, {
|
|
||||||
numeric: true,
|
|
||||||
sensitivity: "base",
|
|
||||||
});
|
|
||||||
return titleDelta || left.subtitle.localeCompare(right.subtitle);
|
|
||||||
});
|
|
||||||
const workspaceTitleByKey = new Map(
|
const workspaceTitleByKey = new Map(
|
||||||
allWorkspaces.map((workspace) => [workspace.id.slice("workspace:".length), workspace.title]),
|
allWorkspaces.map((workspace) => [workspace.id.slice("workspace:".length), workspace.title]),
|
||||||
);
|
);
|
||||||
const workspaces = limitDefaultCategoryResults(
|
const agentRows = agents
|
||||||
allWorkspaces.filter((workspace) => matchesQuery(workspace.searchText, query)),
|
.map<CommandCenterAgentResult>((agent) => {
|
||||||
query,
|
const workspaceTitle = agent.workspaceId
|
||||||
);
|
? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`)
|
||||||
const agentResults = limitDefaultCategoryResults(
|
: undefined;
|
||||||
agents
|
return {
|
||||||
.map<CommandCenterAgentResult>((agent) => {
|
kind: "agent",
|
||||||
const title = agent.title || t("shell.commandCenter.newAgent");
|
id: `agent:${agent.serverId}:${agent.id}`,
|
||||||
const workspaceTitle = agent.workspaceId
|
agent,
|
||||||
? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`)
|
title: agent.title || t("shell.commandCenter.newAgent"),
|
||||||
: undefined;
|
subtitle: joinSubtitleParts([
|
||||||
const location = workspaceTitle ?? shortenPath(agent.cwd);
|
|
||||||
const subtitle = joinSubtitleParts([
|
|
||||||
showHost ? agent.serverLabel : null,
|
showHost ? agent.serverLabel : null,
|
||||||
location,
|
workspaceTitle ?? shortenPath(agent.cwd),
|
||||||
formatTimeAgo(agent.lastActivityAt),
|
formatTimeAgo(agent.lastActivityAt),
|
||||||
]);
|
]),
|
||||||
return {
|
run: () => {
|
||||||
kind: "agent",
|
clearCommandCenterFocusRestoreElement();
|
||||||
id: `agent:${agent.serverId}:${agent.id}`,
|
navigateToAgent({ serverId: agent.serverId, agentId: agent.id });
|
||||||
agent,
|
},
|
||||||
title,
|
};
|
||||||
subtitle,
|
})
|
||||||
searchText: `${title} ${subtitle} ${agent.cwd}`.toLowerCase(),
|
.sort((left, right) => sortAgents(left.agent, right.agent));
|
||||||
run: () => {
|
return { workspaces: allWorkspaces, agents: agentRows };
|
||||||
clearCommandCenterFocusRestoreElement();
|
}, [agents, open, projects, showHost, t]);
|
||||||
navigateToAgent({ serverId: agent.serverId, agentId: agent.id });
|
}
|
||||||
},
|
|
||||||
};
|
function useBuiltInSections(open: boolean, query: string): CommandCenterResultSection[] {
|
||||||
})
|
const { t } = useTranslation();
|
||||||
.filter((agent) => matchesQuery(agent.searchText, query))
|
const rows = useBuiltInRows(open);
|
||||||
.sort((left, right) => sortAgents(left.agent, right.agent)),
|
|
||||||
query,
|
return useMemo(() => {
|
||||||
);
|
if (!open) return [];
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: "workspaces",
|
id: "workspaces",
|
||||||
|
band: PINNED_SECTION_BAND,
|
||||||
rank: 2,
|
rank: 2,
|
||||||
title: t("shell.commandCenter.workspaces"),
|
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 {
|
interface CommandCenterState {
|
||||||
@@ -231,7 +257,7 @@ function useCommandCenterState(): CommandCenterState {
|
|||||||
const snapshot = useCommandCenterContributions();
|
const snapshot = useCommandCenterContributions();
|
||||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||||
const previousOpenRef = useRef(open);
|
const previousOpenRef = useRef(open);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQueryState] = useState("");
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const builtInSections = useBuiltInSections(open, query);
|
const builtInSections = useBuiltInSections(open, query);
|
||||||
const {
|
const {
|
||||||
@@ -250,10 +276,20 @@ function useCommandCenterState(): CommandCenterState {
|
|||||||
filePath: entry.path,
|
filePath: entry.path,
|
||||||
title: entry.name,
|
title: entry.name,
|
||||||
subtitle: entry.directory,
|
subtitle: entry.directory,
|
||||||
searchText: entry.path.toLowerCase(),
|
|
||||||
run: () => openFile(entry.path),
|
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]);
|
}, [fileSearchEntries, openFile, t]);
|
||||||
const contributionSections = useMemo(
|
const contributionSections = useMemo(
|
||||||
() => buildContributionSections(snapshot.contributions, query),
|
() => buildContributionSections(snapshot.contributions, query),
|
||||||
@@ -270,6 +306,14 @@ function useCommandCenterState(): CommandCenterState {
|
|||||||
);
|
);
|
||||||
const resolvedActiveId = preserveActiveResultId(activeId, projection.selectableResults);
|
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 close = useCallback(() => setOpen(false), [setOpen]);
|
||||||
const select = useCallback(
|
const select = useCallback(
|
||||||
(result: CommandCenterResult) => {
|
(result: CommandCenterResult) => {
|
||||||
@@ -312,7 +356,7 @@ function useCommandCenterState(): CommandCenterState {
|
|||||||
const timer = setTimeout(() => inputRef.current?.focus(), 0);
|
const timer = setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
setQuery("");
|
setQueryState("");
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
if (!wasOpen) return;
|
if (!wasOpen) return;
|
||||||
const element = takeCommandCenterFocusRestoreElement();
|
const element = takeCommandCenterFocusRestoreElement();
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest";
|
|||||||
import type { CommandCenterContribution } from "./contributions";
|
import type { CommandCenterContribution } from "./contributions";
|
||||||
import {
|
import {
|
||||||
buildContributionSections,
|
buildContributionSections,
|
||||||
|
filterAndRankBuiltInResults,
|
||||||
joinSubtitleParts,
|
joinSubtitleParts,
|
||||||
moveActiveResultId,
|
moveActiveResultId,
|
||||||
|
PINNED_SECTION_BAND,
|
||||||
preserveActiveResultId,
|
preserveActiveResultId,
|
||||||
projectCommandCenterRows,
|
projectCommandCenterRows,
|
||||||
type CommandCenterWorkspaceResult,
|
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 {
|
return {
|
||||||
kind: "workspace",
|
id: input.id,
|
||||||
id,
|
group: input.group,
|
||||||
title: id,
|
groupRank: input.groupRank,
|
||||||
subtitle: "host",
|
rank: input.rank ?? 0,
|
||||||
searchText: id,
|
keywords: input.keywords ?? [],
|
||||||
|
visibility: input.visibility ?? "always",
|
||||||
run: () => undefined,
|
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[] {
|
function sectionResultIds(sections: ReturnType<typeof buildContributionSections>): string[] {
|
||||||
const ids: string[] = [];
|
const ids: string[] = [];
|
||||||
for (const section of sections) {
|
for (const section of sections) {
|
||||||
@@ -60,7 +125,13 @@ describe("Command Center result projection", () => {
|
|||||||
const sections = buildContributionSections(contributions, "o");
|
const sections = buildContributionSections(contributions, "o");
|
||||||
const projection = projectCommandCenterRows([
|
const projection = projectCommandCenterRows([
|
||||||
...sections,
|
...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([
|
expect(projection.rows.map((row) => row.key)).toEqual([
|
||||||
"section:models",
|
"section:models",
|
||||||
@@ -100,7 +171,13 @@ describe("Command Center result projection", () => {
|
|||||||
it("keeps keyboard selection aligned with rows beyond the render window", () => {
|
it("keeps keyboard selection aligned with rows beyond the render window", () => {
|
||||||
const workspaces = Array.from({ length: 200 }, (_, index) => workspace(`workspace:${index}`));
|
const workspaces = Array.from({ length: 200 }, (_, index) => workspace(`workspace:${index}`));
|
||||||
const projection = projectCommandCenterRows([
|
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;
|
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", () => {
|
describe("joinSubtitleParts", () => {
|
||||||
it("joins all present parts with a middle dot", () => {
|
it("joins all present parts with a middle dot", () => {
|
||||||
expect(joinSubtitleParts(["a", "b", "c"])).toBe("a · b · c");
|
expect(joinSubtitleParts(["a", "b", "c"])).toBe("a · b · c");
|
||||||
|
|||||||
@@ -1,12 +1,53 @@
|
|||||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||||
|
import {
|
||||||
|
compareMatchScores,
|
||||||
|
type MatchScore,
|
||||||
|
scoreTextFields,
|
||||||
|
} from "@getpaseo/protocol/search/text-match";
|
||||||
import type { CommandCenterContribution } from "./contributions";
|
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 {
|
export interface CommandCenterWorkspaceResult {
|
||||||
kind: "workspace";
|
kind: "workspace";
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
searchText: string;
|
|
||||||
run(): void;
|
run(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,7 +57,6 @@ export interface CommandCenterAgentResult {
|
|||||||
agent: AggregatedAgent;
|
agent: AggregatedAgent;
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
searchText: string;
|
|
||||||
run(): void;
|
run(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,7 +66,6 @@ export interface CommandCenterFileResult {
|
|||||||
filePath: string;
|
filePath: string;
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
searchText: string;
|
|
||||||
run(): void;
|
run(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +73,8 @@ export interface CommandCenterContributionResult {
|
|||||||
kind: "contribution";
|
kind: "contribution";
|
||||||
id: string;
|
id: string;
|
||||||
contribution: CommandCenterContribution;
|
contribution: CommandCenterContribution;
|
||||||
searchText: string;
|
/** How well this row matches the query. Absent when the query is empty. */
|
||||||
|
score?: CommandCenterScore;
|
||||||
run(): void | Promise<void>;
|
run(): void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,15 +86,21 @@ export type CommandCenterResult =
|
|||||||
|
|
||||||
export interface CommandCenterResultSection {
|
export interface CommandCenterResultSection {
|
||||||
id: string;
|
id: string;
|
||||||
|
/** CONTRIBUTION_SECTION_BAND or PINNED_SECTION_BAND. Compared before anything else. */
|
||||||
|
band: number;
|
||||||
rank: number;
|
rank: number;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
/** Best score among this section's results. Absent when the query is empty. */
|
||||||
|
score?: CommandCenterScore;
|
||||||
results: readonly CommandCenterResult[];
|
results: readonly CommandCenterResult[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MutableCommandCenterResultSection {
|
interface MutableCommandCenterResultSection {
|
||||||
id: string;
|
id: string;
|
||||||
|
band: number;
|
||||||
rank: number;
|
rank: number;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
score?: CommandCenterScore;
|
||||||
results: CommandCenterResult[];
|
results: CommandCenterResult[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,9 +115,41 @@ export interface CommandCenterListProjection {
|
|||||||
offsets: readonly number[];
|
offsets: readonly number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchesQuery(searchText: string, query: string): boolean {
|
/**
|
||||||
const normalized = query.trim().toLowerCase();
|
* Fields a row can be matched on, split by whether the user can read them. A row that matches
|
||||||
return !normalized || searchText.includes(normalized);
|
* 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. */
|
/** 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(" · ");
|
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.kind === "action"
|
||||||
? [contribution.presentation.title, contribution.presentation.subtitle ?? ""]
|
? [contribution.presentation.title, contribution.presentation.subtitle ?? ""]
|
||||||
: contribution.presentation.path;
|
: [...contribution.presentation.path];
|
||||||
return [...presentationText, ...contribution.keywords].join(" ").toLowerCase();
|
return { visible, hidden: contribution.keywords };
|
||||||
}
|
}
|
||||||
|
|
||||||
function resultHeight(result: CommandCenterResult): number {
|
function resultHeight(result: CommandCenterResult): number {
|
||||||
@@ -96,17 +180,77 @@ function resultHeight(result: CommandCenterResult): number {
|
|||||||
return 36;
|
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(
|
export function buildContributionSections(
|
||||||
contributions: readonly CommandCenterContribution[],
|
contributions: readonly CommandCenterContribution[],
|
||||||
query: string,
|
query: string,
|
||||||
): CommandCenterResultSection[] {
|
): CommandCenterResultSection[] {
|
||||||
const groups = new Map<string, MutableCommandCenterResultSection>();
|
const groups = new Map<string, MutableCommandCenterResultSection>();
|
||||||
|
const registryIndexById = new Map<string, number>();
|
||||||
const hasQuery = Boolean(query.trim());
|
const hasQuery = Boolean(query.trim());
|
||||||
|
|
||||||
for (const contribution of contributions) {
|
for (const [index, contribution] of contributions.entries()) {
|
||||||
if (contribution.visibility === "query" && !hasQuery) continue;
|
if (contribution.visibility === "query" && !hasQuery) continue;
|
||||||
const searchText = contributionSearchText(contribution);
|
let score: CommandCenterScore | undefined;
|
||||||
if (!matchesQuery(searchText, query)) continue;
|
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 existing = groups.get(contribution.group);
|
||||||
const title =
|
const title =
|
||||||
contribution.presentation.kind === "action"
|
contribution.presentation.kind === "action"
|
||||||
@@ -114,23 +258,39 @@ export function buildContributionSections(
|
|||||||
: undefined;
|
: undefined;
|
||||||
const section = existing ?? {
|
const section = existing ?? {
|
||||||
id: contribution.group,
|
id: contribution.group,
|
||||||
|
band: CONTRIBUTION_SECTION_BAND,
|
||||||
rank: contribution.groupRank,
|
rank: contribution.groupRank,
|
||||||
title,
|
title,
|
||||||
results: [],
|
results: [],
|
||||||
};
|
};
|
||||||
|
registryIndexById.set(contribution.id, index);
|
||||||
section.results.push({
|
section.results.push({
|
||||||
kind: "contribution",
|
kind: "contribution",
|
||||||
id: contribution.id,
|
id: contribution.id,
|
||||||
contribution,
|
contribution,
|
||||||
searchText,
|
score,
|
||||||
run: contribution.run,
|
run: contribution.run,
|
||||||
});
|
});
|
||||||
|
if (score && (!section.score || compareCommandCenterScores(score, section.score) < 0)) {
|
||||||
|
section.score = score;
|
||||||
|
}
|
||||||
groups.set(contribution.group, section);
|
groups.set(contribution.group, section);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...groups.values()].sort(
|
const sections = [...groups.values()];
|
||||||
(left, right) => left.rank - right.rank || left.id.localeCompare(right.id),
|
// 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(
|
export function projectCommandCenterRows(
|
||||||
@@ -138,7 +298,7 @@ export function projectCommandCenterRows(
|
|||||||
): CommandCenterListProjection {
|
): CommandCenterListProjection {
|
||||||
const populated = sections
|
const populated = sections
|
||||||
.filter((section) => section.results.length > 0)
|
.filter((section) => section.results.length > 0)
|
||||||
.sort((left, right) => left.rank - right.rank || left.id.localeCompare(right.id));
|
.sort(compareResultSections);
|
||||||
const rows: CommandCenterListRow[] = [];
|
const rows: CommandCenterListRow[] = [];
|
||||||
const selectableResults: CommandCenterResult[] = [];
|
const selectableResults: CommandCenterResult[] = [];
|
||||||
const rowIndexByResultId = new Map<string, number>();
|
const rowIndexByResultId = new Map<string, number>();
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import { isNative } from "@/constants/platform";
|
|||||||
import { keyboardShortcutsAvailable } from "@/keyboard/availability";
|
import { keyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||||
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
|
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
|
||||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||||
|
import { buildOpenProjectRoute } from "@/utils/host-routes";
|
||||||
|
import { hasActiveWebOverlay } from "@/lib/overlay-root";
|
||||||
import {
|
import {
|
||||||
type ActiveWorkspaceSelection,
|
type ActiveWorkspaceSelection,
|
||||||
navigateToLastWorkspace,
|
navigateToLastWorkspace,
|
||||||
@@ -179,7 +181,11 @@ export function useKeyboardShortcuts({
|
|||||||
navigateToWorkspace({ serverId: action.serverId, workspaceId: action.workspaceId });
|
navigateToWorkspace({ serverId: action.serverId, workspaceId: action.workspaceId });
|
||||||
return true;
|
return true;
|
||||||
case "navigate-last-workspace":
|
case "navigate-last-workspace":
|
||||||
return navigateToLastWorkspace();
|
if (navigateToLastWorkspace()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
router.replace(buildOpenProjectRoute());
|
||||||
|
return true;
|
||||||
case "router-replace":
|
case "router-replace":
|
||||||
router.replace(action.route as Parameters<typeof router.replace>[0]);
|
router.replace(action.route as Parameters<typeof router.replace>[0]);
|
||||||
return true;
|
return true;
|
||||||
@@ -330,6 +336,14 @@ export function useKeyboardShortcuts({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const key = event.key ?? "";
|
const key = event.key ?? "";
|
||||||
|
if (
|
||||||
|
key === "Escape" &&
|
||||||
|
pathname.startsWith("/settings") &&
|
||||||
|
!isMobile &&
|
||||||
|
hasActiveWebOverlay()
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (key === badgeModifierKey && !event.shiftKey) {
|
if (key === badgeModifierKey && !event.shiftKey) {
|
||||||
setBadgeModifierDown(true);
|
setBadgeModifierDown(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,27 @@ describe("routeKeyboardShortcut — dispatch passthroughs", () => {
|
|||||||
action: expected,
|
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", () => {
|
describe("routeKeyboardShortcut — workspace.tab.navigate", () => {
|
||||||
|
|||||||
@@ -177,6 +177,13 @@ export function routeKeyboardShortcut(
|
|||||||
): ShortcutAction {
|
): ShortcutAction {
|
||||||
const passthrough = PASSTHROUGH_DISPATCH[input.action];
|
const passthrough = PASSTHROUGH_DISPATCH[input.action];
|
||||||
if (passthrough) {
|
if (passthrough) {
|
||||||
|
if (
|
||||||
|
input.action === "agent.interrupt" &&
|
||||||
|
ctx.pathname.startsWith("/settings") &&
|
||||||
|
!ctx.isMobile
|
||||||
|
) {
|
||||||
|
return { kind: "navigate-last-workspace" };
|
||||||
|
}
|
||||||
return dispatch(passthrough);
|
return dispatch(passthrough);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,10 @@ function getTopWebOverlay(): WebOverlayEntry | undefined {
|
|||||||
}, undefined);
|
}, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hasActiveWebOverlay(): boolean {
|
||||||
|
return getTopWebOverlay() !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function getFocusableElements(scope: HTMLElement): HTMLElement[] {
|
function getFocusableElements(scope: HTMLElement): HTMLElement[] {
|
||||||
const selector = [
|
const selector = [
|
||||||
"a[href]",
|
"a[href]",
|
||||||
|
|||||||
@@ -77,11 +77,16 @@ function useWorkspaceLayoutIds(...values: string[]) {
|
|||||||
workspaceLayoutIds.useValues(values);
|
workspaceLayoutIds.useValues(values);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTab(tabId: string, target?: WorkspaceTab["target"]): WorkspaceTab {
|
function createTab(
|
||||||
|
tabId: string,
|
||||||
|
target?: WorkspaceTab["target"],
|
||||||
|
state?: WorkspaceTab["state"],
|
||||||
|
): WorkspaceTab {
|
||||||
return {
|
return {
|
||||||
tabId,
|
tabId,
|
||||||
target: target ?? { kind: "draft", draftId: tabId },
|
target: target ?? { kind: "draft", draftId: tabId },
|
||||||
createdAt: 1,
|
createdAt: 1,
|
||||||
|
...(state === undefined ? {} : { state }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,8 +96,11 @@ function createPane(input: {
|
|||||||
focusedTabId?: string | null;
|
focusedTabId?: string | null;
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
targetsByTabId?: Record<string, WorkspaceTab["target"]>;
|
targetsByTabId?: Record<string, WorkspaceTab["target"]>;
|
||||||
|
stateByTabId?: Record<string, WorkspaceTab["state"]>;
|
||||||
}): SplitNode {
|
}): 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 {
|
return {
|
||||||
kind: "pane",
|
kind: "pane",
|
||||||
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", () => {
|
describe("workspace-layout-store tree transforms", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
workspaceLayoutIds.reset();
|
workspaceLayoutIds.reset();
|
||||||
@@ -2712,7 +2945,6 @@ describe("workspace-layout-store actions", () => {
|
|||||||
layoutByWorkspace: { [workspaceKey]: layout },
|
layoutByWorkspace: { [workspaceKey]: layout },
|
||||||
splitSizesByWorkspace: currentState.splitSizesByWorkspace,
|
splitSizesByWorkspace: currentState.splitSizesByWorkspace,
|
||||||
explorerSidebarWidthByWorkspace: currentState.explorerSidebarWidthByWorkspace,
|
explorerSidebarWidthByWorkspace: currentState.explorerSidebarWidthByWorkspace,
|
||||||
// The persisted key keeps its pre-rename spelling for older clients.
|
|
||||||
explorerPaneIdByWorkspace: {},
|
explorerPaneIdByWorkspace: {},
|
||||||
sidePaneIdByWorkspace: currentState.sidePaneIdByWorkspace,
|
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
|
// 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.
|
// rename here would fail every existing blob and wipe the layout it describes.
|
||||||
explorerPaneIdByWorkspace: z.record(z.string(), z.string().nullable()).optional(),
|
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(),
|
sidePaneIdByWorkspace: z.record(z.string(), z.string().nullable()).optional(),
|
||||||
// COMPAT(pullRequestAutoAdd): PR detection stopped opening a tab in v0.5; accepted
|
// 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.
|
// 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 LEGACY_EXPLORER_SIDEBAR_REFERENCE_WIDTH = 1440;
|
||||||
|
const WORKSPACE_LAYOUT_PERSIST_VERSION = 2;
|
||||||
|
|
||||||
function convertLegacyExplorerSidebarRatios(
|
function convertLegacyExplorerSidebarRatios(
|
||||||
ratiosByWorkspace: Record<string, number>,
|
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 {
|
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
return null;
|
return null;
|
||||||
@@ -424,21 +598,15 @@ export function resolveExplorerSidebarPaneId(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ensurePersistedExplorerSidebarPane(input: {
|
function ensurePersistedExplorerSidebarPane(input: {
|
||||||
workspaceKey: string;
|
|
||||||
layout: WorkspaceLayout;
|
layout: WorkspaceLayout;
|
||||||
registeredPaneId: string | null | undefined;
|
registeredPaneId: string | null | undefined;
|
||||||
ids: WorkspaceLayoutIdSource;
|
ids: WorkspaceLayoutIdSource;
|
||||||
}): { layout: WorkspaceLayout; paneId: string } | null {
|
}): { layout: WorkspaceLayout; paneId: string } | null {
|
||||||
const existingPaneId = resolveExplorerSidebarPaneId(input.layout, input.registeredPaneId);
|
const existingPaneId = resolveExplorerSidebarPaneId(input.layout, input.registeredPaneId);
|
||||||
if (existingPaneId) {
|
if (existingPaneId) {
|
||||||
const migratedLayout = migrateExplorerSidebarTabs(
|
|
||||||
input.workspaceKey,
|
|
||||||
input.layout,
|
|
||||||
existingPaneId,
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
layout: keepWorkspaceFocusOutOfExplorerSidebar(
|
layout: keepWorkspaceFocusOutOfExplorerSidebar(
|
||||||
migratedLayout,
|
input.layout,
|
||||||
existingPaneId,
|
existingPaneId,
|
||||||
input.layout.focusedPaneId,
|
input.layout.focusedPaneId,
|
||||||
),
|
),
|
||||||
@@ -471,83 +639,11 @@ function ensurePersistedExplorerSidebarPane(input: {
|
|||||||
split.paneId,
|
split.paneId,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
layout: migrateExplorerSidebarTabs(input.workspaceKey, seededLayout, split.paneId),
|
layout: seededLayout,
|
||||||
paneId: split.paneId,
|
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(
|
function getOpenTabPlacement(
|
||||||
state: WorkspaceLayoutStore,
|
state: WorkspaceLayoutStore,
|
||||||
workspaceKey: string,
|
workspaceKey: string,
|
||||||
@@ -1722,8 +1818,10 @@ export function createWorkspaceLayoutStore(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "workspace-layout-state",
|
name: "workspace-layout-state",
|
||||||
version: 1,
|
version: WORKSPACE_LAYOUT_PERSIST_VERSION,
|
||||||
storage: createValidatedPersistStorage(AsyncStorage, WorkspaceLayoutPersistedStateSchema),
|
storage: createValidatedPersistStorage(AsyncStorage, WorkspaceLayoutPersistedStateSchema),
|
||||||
|
migrate: (persistedState, version) =>
|
||||||
|
migrateWorkspaceLayoutPersistedState(persistedState, version, ids),
|
||||||
partialize: (state) => {
|
partialize: (state) => {
|
||||||
const layoutByWorkspace: Record<string, WorkspaceLayout> = {};
|
const layoutByWorkspace: Record<string, WorkspaceLayout> = {};
|
||||||
for (const key in state.layoutByWorkspace) {
|
for (const key in state.layoutByWorkspace) {
|
||||||
@@ -1748,14 +1846,14 @@ export function createWorkspaceLayoutStore(
|
|||||||
}
|
}
|
||||||
const layoutByWorkspace: Record<string, WorkspaceLayout> = {};
|
const layoutByWorkspace: Record<string, WorkspaceLayout> = {};
|
||||||
const explorerSidebarPaneIdByWorkspace: Record<string, string | null> = {
|
const explorerSidebarPaneIdByWorkspace: Record<string, string | null> = {
|
||||||
...result.data.explorerPaneIdByWorkspace,
|
...(result.data.explorerSidebarPaneIdByWorkspace ??
|
||||||
|
result.data.explorerPaneIdByWorkspace),
|
||||||
};
|
};
|
||||||
for (const [workspaceKey, persistedLayout] of Object.entries(
|
for (const [workspaceKey, persistedLayout] of Object.entries(
|
||||||
result.data.layoutByWorkspace,
|
result.data.layoutByWorkspace,
|
||||||
)) {
|
)) {
|
||||||
const strippedLayout = stripEphemeralTabsFromLayout(persistedLayout);
|
const strippedLayout = stripEphemeralTabsFromLayout(persistedLayout);
|
||||||
const explorerSidebar = ensurePersistedExplorerSidebarPane({
|
const explorerSidebar = ensurePersistedExplorerSidebarPane({
|
||||||
workspaceKey,
|
|
||||||
layout: strippedLayout,
|
layout: strippedLayout,
|
||||||
registeredPaneId: explorerSidebarPaneIdByWorkspace[workspaceKey],
|
registeredPaneId: explorerSidebarPaneIdByWorkspace[workspaceKey],
|
||||||
ids,
|
ids,
|
||||||
|
|||||||
@@ -113,6 +113,15 @@ describe("scoreMatch typo tolerance", () => {
|
|||||||
expect(scoreMatch("confug", "revamp configuration editor", typoTolerant)?.tier).toBe(6);
|
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", () => {
|
it("reports the offset and edit distance of the word it matched", () => {
|
||||||
expect(scoreMatch("bulling", "add stripe billing", typoTolerant)).toEqual({
|
expect(scoreMatch("bulling", "add stripe billing", typoTolerant)).toEqual({
|
||||||
tier: 6,
|
tier: 6,
|
||||||
@@ -210,6 +219,14 @@ describe("scoreTextFields", () => {
|
|||||||
).not.toBeNull();
|
).not.toBeNull();
|
||||||
expect(scoreTextFields("stipe bulling", ["add stripe billing"])).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", () => {
|
describe("compareMatchScores", () => {
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ export interface MatchScore {
|
|||||||
export interface MatchOptions {
|
export interface MatchOptions {
|
||||||
/** Omit or pass null to match exactly. `fuzzyPolicyForToken` picks a policy. */
|
/** Omit or pass null to match exactly. `fuzzyPolicyForToken` picks a policy. */
|
||||||
fuzzy?: FuzzyPolicy | null;
|
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. */
|
/** 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();
|
const t = text.toLowerCase();
|
||||||
if (t === q) return { tier: TIER_EXACT, offset: 0 };
|
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;
|
if (exact) return exact;
|
||||||
|
|
||||||
const fuzzy = options.fuzzy;
|
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.
|
* query mixes long words that can absorb an edit with short ones that cannot.
|
||||||
*/
|
*/
|
||||||
typoTolerant?: boolean;
|
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(
|
export function scoreTextFields(
|
||||||
@@ -322,7 +337,7 @@ export function scoreTextFields(
|
|||||||
const fuzzy = options.typoTolerant ? fuzzyPolicyForToken(token) : null;
|
const fuzzy = options.typoTolerant ? fuzzyPolicyForToken(token) : null;
|
||||||
let best: MatchScore | null = null;
|
let best: MatchScore | null = null;
|
||||||
for (const field of fields) {
|
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)) {
|
if (score && (!best || compareMatchScores(score, best) < 0)) {
|
||||||
best = score;
|
best = score;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,6 +131,17 @@ const MODELS: AgentModelDefinition[] = [
|
|||||||
intervalMs: 5,
|
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 {
|
interface ActiveTurn {
|
||||||
|
|||||||
@@ -253,6 +253,7 @@ function createRealOutcomeHarness(input: {
|
|||||||
archivedWorkspaceIds: Set<string>;
|
archivedWorkspaceIds: Set<string>;
|
||||||
}) {
|
}) {
|
||||||
const active = [...input.activeWorkspaces];
|
const active = [...input.activeWorkspaces];
|
||||||
|
const autoArchivedChangeRequestUrls = new Map<string, string>();
|
||||||
const logger = pino({ level: "silent" });
|
const logger = pino({ level: "silent" });
|
||||||
vi.spyOn(logger, "info").mockImplementation(() => undefined);
|
vi.spyOn(logger, "info").mockImplementation(() => undefined);
|
||||||
vi.spyOn(logger, "warn").mockImplementation(() => undefined);
|
vi.spyOn(logger, "warn").mockImplementation(() => undefined);
|
||||||
@@ -311,8 +312,12 @@ function createRealOutcomeHarness(input: {
|
|||||||
},
|
},
|
||||||
listActiveWorkspaces: async () =>
|
listActiveWorkspaces: async () =>
|
||||||
active.filter((workspace) => !input.archivedWorkspaceIds.has(workspace.workspaceId)),
|
active.filter((workspace) => !input.archivedWorkspaceIds.has(workspace.workspaceId)),
|
||||||
getAutoArchivedChangeRequestUrl: async () => null,
|
getAutoArchivedChangeRequestUrl: async (workspaceId: string) =>
|
||||||
archiveWorkspaceRecord: async (workspaceId: string) => {
|
autoArchivedChangeRequestUrls.get(workspaceId) ?? null,
|
||||||
|
archiveWorkspaceRecord: async (workspaceId: string, context) => {
|
||||||
|
if (context?.autoArchivedChangeRequestUrl) {
|
||||||
|
autoArchivedChangeRequestUrls.set(workspaceId, context.autoArchivedChangeRequestUrl);
|
||||||
|
}
|
||||||
input.archivedWorkspaceIds.add(workspaceId);
|
input.archivedWorkspaceIds.add(workspaceId);
|
||||||
const index = active.findIndex((workspace) => workspace.workspaceId === workspaceId);
|
const index = active.findIndex((workspace) => workspace.workspaceId === workspaceId);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
@@ -327,6 +332,10 @@ function createRealOutcomeHarness(input: {
|
|||||||
return {
|
return {
|
||||||
options,
|
options,
|
||||||
log: logger,
|
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(archivedWorkspaceIds.has(workspaceA)).toBe(true);
|
||||||
expect(existsSync(worktree.worktreePath)).toBe(false);
|
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";
|
} from "./index.js";
|
||||||
import type { WorkspaceGitRuntimeSnapshot } from "../workspace-git-service.js";
|
import type { WorkspaceGitRuntimeSnapshot } from "../workspace-git-service.js";
|
||||||
|
|
||||||
function createSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
|
function createSnapshot(
|
||||||
|
cwd: string,
|
||||||
|
state: "open" | "merged" = "merged",
|
||||||
|
): WorkspaceGitRuntimeSnapshot {
|
||||||
return {
|
return {
|
||||||
cwd,
|
cwd,
|
||||||
git: {
|
git: {
|
||||||
@@ -32,11 +35,11 @@ function createSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
|
|||||||
authState: "authenticated",
|
authState: "authenticated",
|
||||||
pullRequest: {
|
pullRequest: {
|
||||||
url: "https://github.com/acme/repo/pull/12",
|
url: "https://github.com/acme/repo/pull/12",
|
||||||
title: "Merged",
|
title: "Feature",
|
||||||
state: "merged",
|
state,
|
||||||
baseRefName: "main",
|
baseRefName: "main",
|
||||||
headRefName: "feature",
|
headRefName: "feature",
|
||||||
isMerged: true,
|
isMerged: state === "merged",
|
||||||
},
|
},
|
||||||
error: null,
|
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;
|
let onSnapshotUpdated: ((snapshot: WorkspaceGitRuntimeSnapshot) => void) | null = null;
|
||||||
const eventSnapshot = createSnapshot("/repo/worktree/.");
|
const eventSnapshot = createSnapshot("/repo/worktree/.");
|
||||||
const freshSnapshot = 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 getSnapshot = vi.fn(async () => freshSnapshot);
|
||||||
const options = {
|
const options = {
|
||||||
logger: { child: () => ({ warn: vi.fn() }) } as unknown as Logger,
|
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);
|
setupAutoArchiveOnMerge(options, deps);
|
||||||
if (!onSnapshotUpdated) throw new Error("Snapshot listener was not registered");
|
if (!onSnapshotUpdated) throw new Error("Snapshot listener was not registered");
|
||||||
|
onSnapshotUpdated(createSnapshot("/repo/worktree/.", "open"));
|
||||||
onSnapshotUpdated(eventSnapshot);
|
onSnapshotUpdated(eventSnapshot);
|
||||||
await finished;
|
await finished;
|
||||||
|
|
||||||
@@ -135,6 +136,7 @@ test("serializes the complete fan-out for duplicate merge events on one cwd", as
|
|||||||
|
|
||||||
setupAutoArchiveOnMerge(options, deps);
|
setupAutoArchiveOnMerge(options, deps);
|
||||||
if (!onSnapshotUpdated) throw new Error("Snapshot listener was not registered");
|
if (!onSnapshotUpdated) throw new Error("Snapshot listener was not registered");
|
||||||
|
onSnapshotUpdated(createSnapshot("/repo/worktree/.", "open"));
|
||||||
onSnapshotUpdated(snapshot);
|
onSnapshotUpdated(snapshot);
|
||||||
await vi.waitFor(() => expect(archivedWorkspaceIds).toEqual(["workspace-a"]));
|
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 });
|
setupAutoArchiveOnMerge(options, { archiveIfSafe, resolvePath: resolve });
|
||||||
if (!onSnapshotUpdated) throw new Error("Snapshot listener was not registered");
|
if (!onSnapshotUpdated) throw new Error("Snapshot listener was not registered");
|
||||||
|
onSnapshotUpdated(createSnapshot("/repo/worktree", "open"));
|
||||||
onSnapshotUpdated(eventSnapshot);
|
onSnapshotUpdated(eventSnapshot);
|
||||||
await vi.waitFor(() => expect(getSnapshot).toHaveBeenCalledTimes(1));
|
await vi.waitFor(() => expect(getSnapshot).toHaveBeenCalledTimes(1));
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
@@ -199,6 +202,7 @@ test("logs and skips when the fresh observation cannot be read", async () => {
|
|||||||
|
|
||||||
setupAutoArchiveOnMerge(options, { archiveIfSafe, resolvePath: resolve });
|
setupAutoArchiveOnMerge(options, { archiveIfSafe, resolvePath: resolve });
|
||||||
if (!onSnapshotUpdated) throw new Error("Snapshot listener was not registered");
|
if (!onSnapshotUpdated) throw new Error("Snapshot listener was not registered");
|
||||||
|
onSnapshotUpdated(createSnapshot("/repo/worktree", "open"));
|
||||||
onSnapshotUpdated(createSnapshot("/repo/worktree"));
|
onSnapshotUpdated(createSnapshot("/repo/worktree"));
|
||||||
await vi.waitFor(() =>
|
await vi.waitFor(() =>
|
||||||
expect(warn).toHaveBeenCalledWith(
|
expect(warn).toHaveBeenCalledWith(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
|
import { LRUCache } from "lru-cache";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
|
|
||||||
import { archiveIfSafe, type AutoArchiveArchiveOptions } from "./archive-if-safe.js";
|
import { archiveIfSafe, type AutoArchiveArchiveOptions } from "./archive-if-safe.js";
|
||||||
@@ -16,6 +17,8 @@ export interface AutoArchiveOnMergeDependencies {
|
|||||||
resolvePath: typeof resolve;
|
resolvePath: typeof resolve;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const OPEN_PULL_REQUEST_LATCH_MAX = 1_024;
|
||||||
|
|
||||||
const defaultDependencies: AutoArchiveOnMergeDependencies = {
|
const defaultDependencies: AutoArchiveOnMergeDependencies = {
|
||||||
archiveIfSafe,
|
archiveIfSafe,
|
||||||
resolvePath: resolve,
|
resolvePath: resolve,
|
||||||
@@ -27,16 +30,30 @@ export function setupAutoArchiveOnMerge(
|
|||||||
): WorkspaceGitSubscription {
|
): WorkspaceGitSubscription {
|
||||||
const log = options.logger.child({ module: "auto-archive-on-merge" });
|
const log = options.logger.child({ module: "auto-archive-on-merge" });
|
||||||
const inFlightCwds = new Set<string>();
|
const inFlightCwds = new Set<string>();
|
||||||
|
const openPullRequestUrlsByCwd = new LRUCache<string, string>({
|
||||||
|
max: OPEN_PULL_REQUEST_LATCH_MAX,
|
||||||
|
});
|
||||||
|
|
||||||
return options.workspaceGitService.onSnapshotUpdated((snapshot) => {
|
return options.workspaceGitService.onSnapshotUpdated((snapshot) => {
|
||||||
if (!snapshot.forge.pullRequest?.isMerged) {
|
const snapshotCwd = deps.resolvePath(snapshot.cwd);
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (options.daemonConfigStore.get().autoArchiveAfterMerge !== true) {
|
if (options.daemonConfigStore.get().autoArchiveAfterMerge !== true) {
|
||||||
|
openPullRequestUrlsByCwd.delete(snapshotCwd);
|
||||||
return;
|
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)) {
|
if (inFlightCwds.has(snapshotCwd)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -55,7 +72,15 @@ export function setupAutoArchiveOnMerge(
|
|||||||
);
|
);
|
||||||
return;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-5
@@ -66,7 +66,7 @@ afterEach(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
githubTest(
|
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 () => {
|
async () => {
|
||||||
const repository = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-github-merge-")));
|
const repository = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-github-merge-")));
|
||||||
cleanupPaths.add(repository);
|
cleanupPaths.add(repository);
|
||||||
@@ -154,6 +154,13 @@ githubTest(
|
|||||||
"Producer-level auto-archive reproduction.",
|
"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);
|
const beforeMerge = await activeWorkspaceIds(context);
|
||||||
expect(beforeMerge).toEqual(
|
expect(beforeMerge).toEqual(
|
||||||
expect.arrayContaining([mergedWorkspace.workspace.id, unrelatedWorkspace.workspace.id]),
|
expect.arrayContaining([mergedWorkspace.workspace.id, unrelatedWorkspace.workspace.id]),
|
||||||
@@ -176,13 +183,17 @@ githubTest(
|
|||||||
expect(unrelatedStatus.status).toBeNull();
|
expect(unrelatedStatus.status).toBeNull();
|
||||||
expect(await activeWorkspaceIds(context)).toContain(unrelatedWorkspace.workspace.id);
|
expect(await activeWorkspaceIds(context)).toContain(unrelatedWorkspace.workspace.id);
|
||||||
|
|
||||||
// The workspace remains identified by the branch it was created for. Moving the
|
// The current checkout's PR remains visible, but arriving after its merge is not
|
||||||
// checkout does not silently retarget its PR association or archive identity.
|
// a merge transition and must not archive this workspace.
|
||||||
run(merged.worktreePath, "git", ["checkout", "--detach"]);
|
|
||||||
run(unrelated.worktreePath, "git", ["checkout", "merged-feature"]);
|
run(unrelated.worktreePath, "git", ["checkout", "merged-feature"]);
|
||||||
expect((await context.client.checkoutRefresh(unrelated.worktreePath)).success).toBe(true);
|
expect((await context.client.checkoutRefresh(unrelated.worktreePath)).success).toBe(true);
|
||||||
const switchedStatus = await context.client.checkoutPrStatus(unrelated.worktreePath);
|
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);
|
expect(await activeWorkspaceIds(context)).toContain(unrelatedWorkspace.workspace.id);
|
||||||
},
|
},
|
||||||
180_000,
|
180_000,
|
||||||
|
|||||||
@@ -1087,7 +1087,7 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
|||||||
expect(dedupedRemoteBranch.status).toBe(1);
|
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();
|
const { tempDir, repoDir, remoteDir, paseoHome } = createSameRepoGitHubPrRemoteRepo();
|
||||||
cleanupPaths.push(tempDir);
|
cleanupPaths.push(tempDir);
|
||||||
execFileSync("git", ["remote", "set-url", "origin", `file://${remoteDir}`], {
|
execFileSync("git", ["remote", "set-url", "origin", `file://${remoteDir}`], {
|
||||||
@@ -1139,13 +1139,20 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
|||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const currentHead = execFileSync("git", ["rev-parse", "HEAD"], {
|
||||||
|
cwd: second.worktree.worktreePath,
|
||||||
|
encoding: "utf8",
|
||||||
|
}).trim();
|
||||||
const facts = await getCheckoutSnapshotFacts(second.worktree.worktreePath, { paseoHome });
|
const facts = await getCheckoutSnapshotFacts(second.worktree.worktreePath, { paseoHome });
|
||||||
|
|
||||||
expect(second.worktree.branchName).toBe("daemon-shutdown-diagnostics-1");
|
expect(second.worktree.branchName).toBe("daemon-shutdown-diagnostics-1");
|
||||||
expect(facts).toMatchObject({
|
expect(facts).toMatchObject({
|
||||||
isGit: true,
|
isGit: true,
|
||||||
currentBranch: "daemon-shutdown-diagnostics-1",
|
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}$/);
|
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"], {
|
execFileSync("git", ["remote", "add", "origin", "https://github.com/acme/repo.git"], {
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
});
|
});
|
||||||
execFileSync("git", ["branch", "contributor/old-change"], { cwd: repoDir });
|
execFileSync("git", ["branch", "placeholder"], { cwd: repoDir });
|
||||||
execFileSync("git", ["branch", "new-change"], { cwd: repoDir });
|
const workspaceDir = join(paseoHome, "worktrees", "repo", "renamed-by-agent");
|
||||||
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 });
|
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
|
||||||
execFileSync("git", ["worktree", "add", workspaceDir, "contributor/old-change"], {
|
execFileSync("git", ["worktree", "add", workspaceDir, "placeholder"], { cwd: repoDir });
|
||||||
cwd: repoDir,
|
|
||||||
});
|
|
||||||
const staleLookupTarget = {
|
|
||||||
headRef: "old-change",
|
|
||||||
headRepositoryOwner: "contributor",
|
|
||||||
changeRequestNumber: 41,
|
|
||||||
};
|
|
||||||
writePaseoWorktreeMetadata(workspaceDir, {
|
writePaseoWorktreeMetadata(workspaceDir, {
|
||||||
baseRefName: "main",
|
baseRefName: "main",
|
||||||
changeRequestLookupTarget: {
|
changeRequestLookupTarget: {
|
||||||
...staleLookupTarget,
|
headRef: "placeholder",
|
||||||
localBranchName: "contributor/old-change",
|
localBranchName: "placeholder",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome)).toMatchObject({
|
execFileSync("git", ["branch", "-m", "agent-chosen-name"], { cwd: workspaceDir });
|
||||||
headRef: "old-change",
|
const requestedTargets: RequestedPullRequestTarget[] = [];
|
||||||
headRepositoryOwner: "contributor",
|
const facts = await getCheckoutSnapshotFacts(workspaceDir, { paseoHome });
|
||||||
});
|
const result = await getPullRequestStatus(
|
||||||
|
workspaceDir,
|
||||||
execFileSync("git", ["checkout", "new-change"], { cwd: workspaceDir });
|
createGitHubServiceRecordingPullRequestTargets({ requestedTargets }),
|
||||||
startGitCommandMetrics();
|
{ force: true, reason: "agent-renamed-branch-pr" },
|
||||||
const switchedTarget = await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome);
|
{ paseoHome, facts },
|
||||||
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", ["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 });
|
expect(requestedTargets).toEqual([expect.objectContaining({ headRef: "agent-chosen-name" })]);
|
||||||
execFileSync("git", ["config", "branch.new-change.remote", "."], { cwd: repoDir });
|
expect(result.status).toMatchObject({
|
||||||
execFileSync("git", ["config", "branch.new-change.merge", "refs/heads/local-upstream"], {
|
headRefName: "agent-chosen-name",
|
||||||
cwd: repoDir,
|
state: "open",
|
||||||
});
|
isMerged: false,
|
||||||
expect(await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome)).toMatchObject({
|
|
||||||
headRef: "old-change",
|
|
||||||
headRepositoryOwner: "contributor",
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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 });
|
execFileSync("git", ["branch", "feature/unpinned"], { cwd: repoDir });
|
||||||
const workspaceDir = join(paseoHome, "worktrees", "repo", "unpinned-worktree");
|
const workspaceDir = join(paseoHome, "worktrees", "repo", "unpinned-worktree");
|
||||||
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
|
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
|
||||||
@@ -2739,10 +2744,12 @@ const x = 1;
|
|||||||
cwd: repoDir,
|
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 });
|
execFileSync("git", ["branch", "contributor/old-change-1"], { cwd: repoDir });
|
||||||
const workspaceDir = join(paseoHome, "worktrees", "repo", "legacy-pr-worktree");
|
const workspaceDir = join(paseoHome, "worktrees", "repo", "legacy-pr-worktree");
|
||||||
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
|
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
|
||||||
@@ -2760,7 +2767,7 @@ const x = 1;
|
|||||||
|
|
||||||
const lookupTarget = await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome);
|
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 () => {
|
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 },
|
{ 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 () => {
|
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 });
|
execFileSync("git", ["branch", "feature/gitlab-mr"], { cwd: repoDir });
|
||||||
const workspaceDir = join(paseoHome, "worktrees", "repo", "gitlab-mr-worktree");
|
const workspaceDir = join(paseoHome, "worktrees", "repo", "gitlab-mr-worktree");
|
||||||
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
|
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
|
||||||
@@ -2918,7 +2932,10 @@ const x = 1;
|
|||||||
{ paseoHome, facts: switchedFacts },
|
{ 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 () => {
|
it("moves a managed branch identity pin when its branch is renamed", async () => {
|
||||||
|
|||||||
@@ -1813,7 +1813,6 @@ function buildPullRequestLookupTargetFromMetadata(
|
|||||||
|
|
||||||
function buildInitialPullRequestLookupTarget(input: {
|
function buildInitialPullRequestLookupTarget(input: {
|
||||||
currentBranch: string | null;
|
currentBranch: string | null;
|
||||||
metadata: PaseoWorktreeMetadata | null;
|
|
||||||
branchRemoteName: string | null;
|
branchRemoteName: string | null;
|
||||||
branchMergeRef: string | null;
|
branchMergeRef: string | null;
|
||||||
branchRemoteUrl: string | null;
|
branchRemoteUrl: string | null;
|
||||||
@@ -1824,12 +1823,6 @@ function buildInitialPullRequestLookupTarget(input: {
|
|||||||
return null;
|
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(
|
const hasConfiguredBranchTarget = Boolean(
|
||||||
input.branchRemoteName && parseBranchMergeHeadRef(input.branchMergeRef),
|
input.branchRemoteName && parseBranchMergeHeadRef(input.branchMergeRef),
|
||||||
);
|
);
|
||||||
@@ -1844,17 +1837,14 @@ function buildInitialPullRequestLookupTarget(input: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return buildPullRequestLookupTargetFromBranchConfig({
|
||||||
buildPullRequestLookupTargetFromMetadata(input.metadata, input.currentBranch) ??
|
currentBranch: input.currentBranch,
|
||||||
buildPullRequestLookupTargetFromBranchConfig({
|
branchRemoteName: input.branchRemoteName,
|
||||||
currentBranch: input.currentBranch,
|
branchMergeRef: input.branchMergeRef,
|
||||||
branchRemoteName: input.branchRemoteName,
|
branchRemoteUrl: input.branchRemoteUrl,
|
||||||
branchMergeRef: input.branchMergeRef,
|
originRemoteUrl: input.originRemoteUrl,
|
||||||
branchRemoteUrl: input.branchRemoteUrl,
|
resolvedBaseRef: input.resolvedBaseRef,
|
||||||
originRemoteUrl: input.originRemoteUrl,
|
});
|
||||||
resolvedBaseRef: input.resolvedBaseRef,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolvePullRequestLookupTargetFromPushConfig(
|
async function resolvePullRequestLookupTargetFromPushConfig(
|
||||||
@@ -1900,13 +1890,15 @@ async function resolveFactsPullRequestLookupTarget(input: {
|
|||||||
context?: CheckoutContext;
|
context?: CheckoutContext;
|
||||||
}): Promise<PullRequestStatusLookupTarget | null> {
|
}): Promise<PullRequestStatusLookupTarget | null> {
|
||||||
const { cwd, inspected, metadata, context } = input;
|
const { cwd, inspected, metadata, context } = input;
|
||||||
if (inspected.paseoWorktree.isPaseoOwnedWorktree) {
|
const metadataTarget = inspected.currentBranch
|
||||||
return buildPullRequestLookupTargetFromMetadata(metadata, inspected.currentBranch ?? "");
|
? buildPullRequestLookupTargetFromMetadata(metadata, inspected.currentBranch)
|
||||||
|
: null;
|
||||||
|
if (metadataTarget) {
|
||||||
|
return metadataTarget;
|
||||||
}
|
}
|
||||||
|
|
||||||
let target = buildInitialPullRequestLookupTarget({
|
let target = buildInitialPullRequestLookupTarget({
|
||||||
currentBranch: inspected.currentBranch,
|
currentBranch: inspected.currentBranch,
|
||||||
metadata,
|
|
||||||
branchRemoteName: input.branchRemoteName,
|
branchRemoteName: input.branchRemoteName,
|
||||||
branchMergeRef: input.branchMergeRef,
|
branchMergeRef: input.branchMergeRef,
|
||||||
branchRemoteUrl: input.branchRemoteUrl,
|
branchRemoteUrl: input.branchRemoteUrl,
|
||||||
|
|||||||
Reference in New Issue
Block a user