mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-14 20:35:02 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d50e51ed87 | ||
|
|
80c09fd38f | ||
|
|
88ab1d8e01 | ||
|
|
00f33faa27 | ||
|
|
b39b004b0c | ||
|
|
788fe8556b | ||
|
|
cf46337fa6 | ||
|
|
362b4bd465 | ||
|
|
cea8a07e62 |
@@ -59,6 +59,8 @@ Register keyboard handling with `useOverlayRegistration` from `@/keyboard`.
|
||||
Pass the surface's open state, layer, handled keys and `onKeyDown`. The highest
|
||||
layer gets first refusal; the latest registration wins a tie. Native registration
|
||||
does not need a DOM ref. Web adds focus trapping and restoration through its adapter.
|
||||
Declaring Tab takes it from the web focus trap and hands it to `onKeyDown`; the command
|
||||
center rotates its tabs with it. Native sources drop Tab from the declarations.
|
||||
|
||||
Capture a global overlay’s layer in state for its opening lifetime. Memo caches can
|
||||
be discarded or removed by React Compiler; recomputing against the registry lets an
|
||||
|
||||
@@ -5,7 +5,7 @@ import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { resetSeededPageState, openSessions } from "../support/helpers/archive-tab";
|
||||
|
||||
test("agent list age advances and command center says just now for a fresh agent", async ({
|
||||
test("agent list age advances and command center says just now for a fresh workspace", async ({
|
||||
page,
|
||||
}) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
@@ -23,10 +23,10 @@ test("agent list age advances and command center says just now for a fresh agent
|
||||
|
||||
const commandCenter = await openCommandCenter(page);
|
||||
await commandCenter.getByTestId("command-center-input").fill("Relative time agent");
|
||||
const agentResult = commandCenter.getByTestId(
|
||||
`command-center-agent-${getServerId()}:${session.agentId}`,
|
||||
const workspaceResult = commandCenter.getByTestId(
|
||||
`command-center-workspace-${getServerId()}:${session.workspaceId}`,
|
||||
);
|
||||
await expect(agentResult.getByTestId("command-center-result-timestamp")).toContainText(
|
||||
await expect(workspaceResult.getByTestId("command-center-result-timestamp")).toContainText(
|
||||
"just now",
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import { openSettings } from "../support/helpers/app";
|
||||
import {
|
||||
closeCommandCenter,
|
||||
expectCommandCenterTab,
|
||||
expectCommandCenterTabs,
|
||||
openCommandCenter,
|
||||
selectCommandCenterTab,
|
||||
} from "../support/helpers/command-center";
|
||||
import {
|
||||
delayDirectorySuggestionResponses,
|
||||
holdDirectorySuggestionResponses,
|
||||
@@ -17,6 +24,7 @@ import {
|
||||
} from "../support/helpers/command-center-scroll";
|
||||
import { expectFileTabOpen } from "../support/helpers/file-explorer";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
const FILE_PATH =
|
||||
@@ -67,7 +75,7 @@ test("workspace file search stays geometrically stable through delayed loading a
|
||||
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
await expect(panel.getByRole("button", { name: "Files" })).toBeVisible();
|
||||
await expectCommandCenterTab(panel, "Files");
|
||||
await startCommandCenterLayoutObservation(page);
|
||||
|
||||
await panel.getByTestId("command-center-input").fill(FILE_NAME);
|
||||
@@ -97,26 +105,90 @@ test("workspace file search stays geometrically stable through delayed loading a
|
||||
}
|
||||
});
|
||||
|
||||
test("dropping the files scope leaves the search row the same height", async ({ page }) => {
|
||||
test("command center tabs rotate with Tab and never move the header", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({
|
||||
repoPrefix: "command-center-file-search-scope-",
|
||||
title: "Scope chip height",
|
||||
repoPrefix: "command-center-tabs-",
|
||||
title: "Tab rotation",
|
||||
});
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, seeded.workspaceId);
|
||||
await page.keyboard.press("Meta+P");
|
||||
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
const header = panel.getByTestId("command-center-header");
|
||||
const scoped = await header.boundingBox();
|
||||
const input = panel.getByTestId("command-center-input");
|
||||
|
||||
await page.getByTestId("command-center-files-scope").click();
|
||||
await expect(page.getByTestId("command-center-files-scope")).toHaveCount(0);
|
||||
const unscoped = await header.boundingBox();
|
||||
await test.step("⌘P opens on Files and Backspace on an empty field returns to All", async () => {
|
||||
await page.keyboard.press("Meta+P");
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
await expectCommandCenterTab(panel, "Files");
|
||||
await page.keyboard.press("Backspace");
|
||||
await expectCommandCenterTab(panel, "All");
|
||||
});
|
||||
|
||||
expect(scoped?.height).toBe(unscoped?.height);
|
||||
const results = panel.getByTestId("command-center-results");
|
||||
const headerOnAll = await header.boundingBox();
|
||||
const resultsTopOnAll = (await results.boundingBox())?.y;
|
||||
|
||||
await test.step("Tab walks forward and wraps; Shift+Tab walks back", async () => {
|
||||
await page.keyboard.press("Tab");
|
||||
await expectCommandCenterTab(panel, "Workspaces");
|
||||
await page.keyboard.press("Tab");
|
||||
await expectCommandCenterTab(panel, "Files");
|
||||
await page.keyboard.press("Tab");
|
||||
await expectCommandCenterTab(panel, "Actions");
|
||||
expect(await header.boundingBox()).toEqual(headerOnAll);
|
||||
expect((await results.boundingBox())?.y).toBe(resultsTopOnAll);
|
||||
await page.keyboard.press("Tab");
|
||||
await expectCommandCenterTab(panel, "All");
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expectCommandCenterTab(panel, "Actions");
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expectCommandCenterTab(panel, "Files");
|
||||
});
|
||||
|
||||
await test.step("rotating keeps the query", async () => {
|
||||
await input.fill("tab rot");
|
||||
await page.keyboard.press("Tab");
|
||||
await expectCommandCenterTab(panel, "Actions");
|
||||
await expect(input).toHaveValue("tab rot");
|
||||
});
|
||||
|
||||
await test.step("clicking a tab keeps typing in the search field", async () => {
|
||||
await selectCommandCenterTab(panel, "Workspaces");
|
||||
await page.keyboard.press("Backspace");
|
||||
await expect(input).toHaveValue("tab ro");
|
||||
await expect(
|
||||
panel.getByTestId(`command-center-workspace-${getServerId()}:${seeded.workspaceId}`),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("⌘K opens on All", async () => {
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(panel).not.toBeVisible();
|
||||
await page.keyboard.press("Meta+K");
|
||||
await expectCommandCenterTab(panel, "All");
|
||||
});
|
||||
|
||||
await test.step("in a workspace every tab is offered", async () => {
|
||||
await expectCommandCenterTabs(panel, ["All", "Workspaces", "Files", "Actions"]);
|
||||
});
|
||||
|
||||
await test.step("outside a workspace there is no Files tab, and Tab skips it", async () => {
|
||||
const outside = { workspaceOpen: false };
|
||||
await closeCommandCenter(page);
|
||||
await openSettings(page);
|
||||
await page.keyboard.press("Meta+K");
|
||||
await expectCommandCenterTab(panel, "All", outside);
|
||||
await expectCommandCenterTabs(panel, ["All", "Workspaces", "Actions"]);
|
||||
await page.keyboard.press("Tab");
|
||||
await expectCommandCenterTab(panel, "Workspaces", outside);
|
||||
await page.keyboard.press("Tab");
|
||||
await expectCommandCenterTab(panel, "Actions", outside);
|
||||
await page.keyboard.press("Tab");
|
||||
await expectCommandCenterTab(panel, "All", outside);
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expectCommandCenterTab(panel, "Actions", outside);
|
||||
});
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
searchCommandCenter,
|
||||
warmCommandCenterRows,
|
||||
expectOnlyCommandCenterRowRendered,
|
||||
expectCommandCenterAgentWindow,
|
||||
scrollToOldestCommandCenterAgent,
|
||||
expectCommandCenterWorkspaceWindow,
|
||||
scrollToOldestCommandCenterWorkspace,
|
||||
} from "../support/helpers/command-center-performance";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
expectPrimaryCommandCenterActions,
|
||||
expectTightTwoLineResultSpacing,
|
||||
expectVisibleKeyboardNavigationNotToScroll,
|
||||
expectWorkspaceResultsToBeLimitedUntilSearch,
|
||||
expectRecentWorkspacesUntilSearch,
|
||||
observeCommandCenterScroll,
|
||||
openCommandCenterWithKeyboard,
|
||||
} from "../support/helpers/command-center-scroll";
|
||||
@@ -63,9 +63,9 @@ test("Command Center preserves manual scrolling through live updates and resumes
|
||||
await expectCommandCenterToRemainAtTheTop(page);
|
||||
await expectPrimaryCommandCenterActions(panel);
|
||||
await expectVisibleKeyboardNavigationNotToScroll(page, panel);
|
||||
// The empty query lists the most recent workspaces, so the last one seeded leads it.
|
||||
// All leads with the most recent workspaces, so the last one seeded leads it.
|
||||
await expectTightTwoLineResultSpacing(panel, "Command Center Scroll 08");
|
||||
await expectWorkspaceResultsToBeLimitedUntilSearch(panel);
|
||||
await expectRecentWorkspacesUntilSearch(panel);
|
||||
const offset = await scrollCommandCenterAwayFromSelection(page, panel);
|
||||
await seeded.client.setWorkspaceTitle(newestWorkspaceId, "Command Center Scroll renamed");
|
||||
await expect(panel.getByText("Command Center Scroll renamed", { exact: true })).toHaveCount(1);
|
||||
@@ -76,42 +76,50 @@ test("Command Center preserves manual scrolling through live updates and resumes
|
||||
}
|
||||
});
|
||||
|
||||
test("live search updates render only the changed row and keep agents virtualized", async ({
|
||||
test("live search updates render only the changed row and keep workspaces virtualized", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const seeded = await seedSearchableCommandCenter();
|
||||
const agentWorkspaceRowId = `workspace:${getServerId()}:${seeded.workspaceId}`;
|
||||
try {
|
||||
await observeCommandCenterRowRenders(page);
|
||||
await gotoAppShell(page);
|
||||
const panel = await openCommandCenterWithKeyboard(page);
|
||||
await searchCommandCenter(panel, "Search agent");
|
||||
await expect(panel.getByText("Search agent 059", { exact: true })).toBeVisible();
|
||||
await warmCommandCenterRows(page);
|
||||
await expectCommandCenterAgentWindow(panel, seeded.agents.length);
|
||||
|
||||
await seeded.client.updateAgent(seeded.agents[59].id, { name: "Search agent 059 revised" });
|
||||
await expect(panel.getByText("Search agent 059 revised", { exact: true })).toBeVisible();
|
||||
await expectOnlyCommandCenterRowRendered(
|
||||
page,
|
||||
`agent:${getServerId()}:${seeded.agents[59].id}`,
|
||||
);
|
||||
await test.step("renaming a matched agent re-renders only its workspace", async () => {
|
||||
await searchCommandCenter(panel, "Search agent 002");
|
||||
const agentLine = panel
|
||||
.getByTestId(`command-center-workspace-${getServerId()}:${seeded.workspaceId}`)
|
||||
.getByTestId("command-center-workspace-agent");
|
||||
await expect(agentLine).toHaveText("Search agent 002");
|
||||
await warmCommandCenterRows(page);
|
||||
await seeded.client.updateAgent(seeded.agents[2].id, { name: "Search agent 002 revised" });
|
||||
await expect(agentLine).toHaveText("Search agent 002 revised");
|
||||
await expectOnlyCommandCenterRowRendered(page, agentWorkspaceRowId);
|
||||
});
|
||||
|
||||
await searchCommandCenter(panel, "Search workspace");
|
||||
await expect(panel.getByText("Search workspace 039", { exact: true })).toBeVisible();
|
||||
await warmCommandCenterRows(page);
|
||||
await seeded.client.setWorkspaceTitle(seeded.workspaces[39].id, "Search workspace 039 revised");
|
||||
await expect(panel.getByText("Search workspace 039 revised", { exact: true })).toBeVisible();
|
||||
await expectOnlyCommandCenterRowRendered(
|
||||
page,
|
||||
`workspace:${getServerId()}:${seeded.workspaces[39].id}`,
|
||||
);
|
||||
await test.step("renaming a workspace re-renders only its row", async () => {
|
||||
await searchCommandCenter(panel, "Search workspace");
|
||||
await expect(panel.getByText("Search workspace 039", { exact: true })).toBeVisible();
|
||||
await warmCommandCenterRows(page);
|
||||
await seeded.client.setWorkspaceTitle(
|
||||
seeded.workspaces[39].id,
|
||||
"Search workspace 039 revised",
|
||||
);
|
||||
await expect(panel.getByText("Search workspace 039 revised", { exact: true })).toBeVisible();
|
||||
await expectOnlyCommandCenterRowRendered(
|
||||
page,
|
||||
`workspace:${getServerId()}:${seeded.workspaces[39].id}`,
|
||||
);
|
||||
});
|
||||
|
||||
await searchCommandCenter(panel, "Search agent");
|
||||
await warmCommandCenterRows(page);
|
||||
await scrollToOldestCommandCenterAgent(panel);
|
||||
await expect(panel.getByText("Search agent 000", { exact: true })).toBeVisible();
|
||||
await expectCommandCenterAgentWindow(panel, seeded.agents.length);
|
||||
await test.step("a long list mounts only a window of rows", async () => {
|
||||
await expectCommandCenterWorkspaceWindow(panel, seeded.workspaces.length + 1);
|
||||
await scrollToOldestCommandCenterWorkspace(panel);
|
||||
await expect(panel.getByText("Search workspace 000", { exact: true })).toBeVisible();
|
||||
await expectCommandCenterWorkspaceWindow(panel, seeded.workspaces.length + 1);
|
||||
});
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
|
||||
@@ -4,7 +4,12 @@ import { test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { createIdleAgent } from "../support/helpers/archive-tab";
|
||||
import { expectAgentTabActive } from "../support/helpers/launcher";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import {
|
||||
commandCenterSection,
|
||||
expectCommandCenterTab,
|
||||
openCommandCenter,
|
||||
selectCommandCenterTab,
|
||||
} from "../support/helpers/command-center";
|
||||
import { addOfflineHostAndReload } from "../support/helpers/hosts";
|
||||
import { expectAppRoute } from "../support/helpers/route-assertions";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
@@ -21,9 +26,7 @@ const AGENT_TITLE = "Fix checkout retries";
|
||||
test.describe("Command center workspaces", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("agent and workspace results retain their icons and overlay live status", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("workspace results overlay live status", async ({ page }) => {
|
||||
const workspace = await seedRunningMockAgentWorkspace({
|
||||
repoPrefix: "command-center-status-",
|
||||
title: "Review checkout retries",
|
||||
@@ -34,15 +37,13 @@ test.describe("Command center workspaces", () => {
|
||||
await openAgentRoute(page, workspace);
|
||||
await expectAgentTabActive(page, workspace.agentId);
|
||||
const panel = await openCommandCenter(page);
|
||||
const agent = panel.getByTestId(`command-center-agent-${getServerId()}:${workspace.agentId}`);
|
||||
// Recent workspaces leaves out the open one; the Workspaces tab lists every workspace.
|
||||
await selectCommandCenterTab(panel, "Workspaces");
|
||||
const row = panel.getByTestId(
|
||||
`command-center-workspace-${getServerId()}:${workspace.workspaceId}`,
|
||||
);
|
||||
await expect(agent.getByRole("progressbar", { name: "Agent running" })).toBeVisible();
|
||||
await expect(row.getByRole("progressbar", { name: "Agent running" })).toBeVisible();
|
||||
await expect(agent.getByRole("img", { name: "Mock agent" })).toBeVisible();
|
||||
await workspace.client.cancelAgent(workspace.agentId);
|
||||
await expect(agent.getByRole("progressbar")).toHaveCount(0);
|
||||
await expect(row.getByRole("progressbar")).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
@@ -80,9 +81,11 @@ test.describe("Command center workspaces", () => {
|
||||
});
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
await selectCommandCenterTab(panel, "Workspaces");
|
||||
const row = panel.getByTestId(
|
||||
`command-center-workspace-${getServerId()}:${seeded.workspaceId}`,
|
||||
);
|
||||
const agentLine = row.getByTestId("command-center-workspace-agent");
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row).toContainText(WORKSPACE_TITLE);
|
||||
await expect(row).toContainText(WORKSPACE_BRANCH);
|
||||
@@ -92,65 +95,151 @@ test.describe("Command center workspaces", () => {
|
||||
await expect(subtitle).toContainText(PRIMARY_HOST_LABEL);
|
||||
await expect(subtitle).toContainText(seeded.projectDisplayName);
|
||||
await expect(subtitle).toContainText(WORKSPACE_BRANCH);
|
||||
await expect(agentLine).toHaveCount(0);
|
||||
|
||||
// The agent subtitle is unchanged by the shared-helper refactor.
|
||||
const agentRow = panel.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`);
|
||||
await expect(agentRow).toContainText(AGENT_TITLE);
|
||||
await expect(agentRow).toContainText(PRIMARY_HOST_LABEL);
|
||||
await expect(agentRow).toContainText(WORKSPACE_TITLE);
|
||||
await expect(agentRow).not.toContainText(seeded.repoPath);
|
||||
|
||||
const workspaceSectionTop = await panel
|
||||
.getByText("Workspaces", { exact: true })
|
||||
.evaluate((element) => element.getBoundingClientRect().top);
|
||||
const agentSectionTop = await panel
|
||||
.getByText("Agents", { exact: true })
|
||||
.evaluate((element) => element.getBoundingClientRect().top);
|
||||
expect(workspaceSectionTop).toBeLessThan(agentSectionTop);
|
||||
|
||||
const input = panel.getByTestId("command-center-input");
|
||||
await input.fill(PRIMARY_HOST_LABEL);
|
||||
await expect(row).toBeVisible();
|
||||
await expect(agentRow).toBeVisible();
|
||||
|
||||
await input.fill(WORKSPACE_BRANCH);
|
||||
await expect(row).toBeVisible();
|
||||
await expect(agentRow).not.toBeVisible();
|
||||
|
||||
await input.fill(WORKSPACE_TITLE);
|
||||
await expect(row).toBeVisible();
|
||||
await expect(agentRow).toBeVisible();
|
||||
|
||||
await input.fill(seeded.repoPath);
|
||||
await expect(agentRow).toBeVisible();
|
||||
await expect(row).not.toBeVisible();
|
||||
|
||||
// The project name is now part of the workspace searchText.
|
||||
await input.fill(seeded.projectDisplayName);
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
await input.fill(AGENT_TITLE);
|
||||
await expect(agentRow).toBeVisible();
|
||||
await expect(row).not.toBeVisible();
|
||||
|
||||
await input.fill(WORKSPACE_TITLE);
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), seeded.workspaceId), {
|
||||
timeout: 30_000,
|
||||
await test.step("a workspace matches on its own fields", async () => {
|
||||
const input = panel.getByTestId("command-center-input");
|
||||
for (const query of [
|
||||
PRIMARY_HOST_LABEL,
|
||||
WORKSPACE_BRANCH,
|
||||
WORKSPACE_TITLE,
|
||||
seeded.projectDisplayName,
|
||||
]) {
|
||||
await input.fill(query);
|
||||
await expect(row).toBeVisible();
|
||||
await expect(agentLine).toHaveCount(0);
|
||||
}
|
||||
// `cwd` is never shown, so it must never match.
|
||||
await input.fill(seeded.repoPath);
|
||||
await expect(row).not.toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("find an agent and open its workspace tab", async () => {
|
||||
const agentPanel = await openCommandCenter(page);
|
||||
await agentPanel.getByTestId("command-center-input").fill(AGENT_TITLE);
|
||||
await test.step("an agent title finds its workspace and names the agent", async () => {
|
||||
await panel.getByTestId("command-center-input").fill(AGENT_TITLE);
|
||||
await expect(agentLine).toHaveText(AGENT_TITLE);
|
||||
await expect(panel.getByText("Agents", { exact: true })).toHaveCount(0);
|
||||
await page.keyboard.press("Enter");
|
||||
await expectAgentTabActive(page, agent.id);
|
||||
});
|
||||
|
||||
await test.step("a workspace match opens the workspace", async () => {
|
||||
const searchAgain = await openCommandCenter(page);
|
||||
await expectCommandCenterTab(searchAgain, "All");
|
||||
await searchAgain.getByTestId("command-center-input").fill(WORKSPACE_TITLE);
|
||||
await page.keyboard.press("Enter");
|
||||
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), seeded.workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("All leads with the three most recent workspaces other than the open one", async ({
|
||||
page,
|
||||
}) => {
|
||||
const older = [];
|
||||
for (const title of ["Recent oldest", "Recent third", "Recent second", "Recent newest"]) {
|
||||
older.push(await seedWorkspace({ repoPrefix: "command-center-recent-", title }));
|
||||
}
|
||||
const current = await seedWorkspace({
|
||||
repoPrefix: "command-center-recent-",
|
||||
title: "Open one",
|
||||
});
|
||||
try {
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), current.workspaceId));
|
||||
const panel = await openCommandCenter(page);
|
||||
await expectCommandCenterTab(panel, "All");
|
||||
await expect(commandCenterSection(panel, "Recent workspaces")).toBeVisible();
|
||||
const rowOf = (workspaceId: string) =>
|
||||
panel.getByTestId(`command-center-workspace-${getServerId()}:${workspaceId}`);
|
||||
|
||||
await expect(rowOf(older[3].workspaceId)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(rowOf(older[2].workspaceId)).toBeVisible();
|
||||
await expect(rowOf(older[1].workspaceId)).toBeVisible();
|
||||
await expect(rowOf(older[0].workspaceId)).toHaveCount(0);
|
||||
await expect(rowOf(current.workspaceId)).toHaveCount(0);
|
||||
await expect(commandCenterSection(panel, "Workspaces")).toHaveCount(0);
|
||||
|
||||
// The newest recent workspace is selected, so Enter goes straight back to it.
|
||||
await page.keyboard.press("Enter");
|
||||
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), older[3].workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
} finally {
|
||||
for (const seeded of [current, ...older]) await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("archived workspaces follow active matches and open where they can be unarchived", async ({
|
||||
page,
|
||||
}) => {
|
||||
const active = await seedWorkspace({
|
||||
repoPrefix: "command-center-archived-active-",
|
||||
title: "Billing dashboard",
|
||||
});
|
||||
const archived = await seedWorkspace({
|
||||
repoPrefix: "command-center-archived-",
|
||||
title: "Billing cleanup",
|
||||
});
|
||||
try {
|
||||
await createIdleAgent(archived.client, {
|
||||
cwd: archived.repoPath,
|
||||
workspaceId: archived.workspaceId,
|
||||
title: "Audit invoice totals",
|
||||
});
|
||||
expect((await archived.client.archiveWorkspace(archived.workspaceId)).error).toBeNull();
|
||||
await gotoAppShell(page);
|
||||
const panel = await openCommandCenter(page);
|
||||
const input = panel.getByTestId("command-center-input");
|
||||
const activeRow = panel.getByTestId(
|
||||
`command-center-workspace-${getServerId()}:${active.workspaceId}`,
|
||||
);
|
||||
const archivedRow = panel.getByTestId(
|
||||
`command-center-workspace-${getServerId()}:${archived.workspaceId}`,
|
||||
);
|
||||
|
||||
await test.step("an empty query never lists archived workspaces", async () => {
|
||||
await selectCommandCenterTab(panel, "Workspaces");
|
||||
await expect(activeRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(archivedRow).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("a query lists the archived workspace after the active one", async () => {
|
||||
await input.fill("billing");
|
||||
await expect(archivedRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(archivedRow).toContainText("Archived");
|
||||
await expect(activeRow).not.toContainText("Archived");
|
||||
const activeTop = (await activeRow.boundingBox())?.y ?? Number.NaN;
|
||||
const archivedTop = (await archivedRow.boundingBox())?.y ?? Number.NaN;
|
||||
expect(archivedTop).toBeGreaterThan(activeTop);
|
||||
});
|
||||
|
||||
await test.step("an agent title finds the archived workspace on All", async () => {
|
||||
// Opened from the app shell, outside any workspace.
|
||||
await selectCommandCenterTab(panel, "All", { workspaceOpen: false });
|
||||
await input.fill("invoice totals");
|
||||
await expect(archivedRow.getByTestId("command-center-workspace-agent")).toHaveText(
|
||||
"Audit invoice totals",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expect(activeRow).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("opening it lands where the workspace can be unarchived", async () => {
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByRole("button", { name: "Unarchive", exact: true })).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
await active.cleanup();
|
||||
await archived.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("single-host workspace subtitle omits the host and shows project · branch", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -229,7 +318,7 @@ test.describe("Compact sidebar inspection", () => {
|
||||
title: "Compact destination",
|
||||
});
|
||||
try {
|
||||
const agent = await createIdleAgent(seeded.client, {
|
||||
await createIdleAgent(seeded.client, {
|
||||
cwd: seeded.repoPath,
|
||||
workspaceId: seeded.workspaceId,
|
||||
title: "Compact root agent",
|
||||
@@ -246,7 +335,10 @@ test.describe("Compact sidebar inspection", () => {
|
||||
await openSidebar(page);
|
||||
const agentSearch = await openCommandCenter(page);
|
||||
await agentSearch.getByTestId("command-center-input").fill("Compact root agent");
|
||||
await agentSearch.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`).click();
|
||||
await agentSearch
|
||||
.getByTestId(`command-center-workspace-${getServerId()}:${seeded.workspaceId}`)
|
||||
.getByTestId("command-center-workspace-agent")
|
||||
.click();
|
||||
await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: "Message agent...", exact: true }),
|
||||
|
||||
@@ -26,7 +26,7 @@ const SURFACE_SELECTORS = {
|
||||
input: '[data-testid="command-center-input"]',
|
||||
fileSearchStatus: '[data-testid="command-center-file-search-status"]',
|
||||
results: '[data-testid="command-center-results"]',
|
||||
scope: '[data-testid="command-center-files-scope"]',
|
||||
tabs: '[data-testid="command-center-tabs"]',
|
||||
fileRow: '[data-testid^="command-center-file-row-"]',
|
||||
fileIcon: '[data-testid="command-center-file-icon"]',
|
||||
fileLine: '[data-testid="command-center-file-line"]',
|
||||
|
||||
@@ -11,7 +11,7 @@ export async function seedSearchableCommandCenter() {
|
||||
});
|
||||
try {
|
||||
const agents = [];
|
||||
for (let index = 0; index < 60; index += 1) {
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
agents.push(
|
||||
await createMockIdleAgent(seed.client, {
|
||||
cwd: seed.repoPath,
|
||||
@@ -73,16 +73,17 @@ export async function expectOnlyCommandCenterRowRendered(page: Page, resultId: s
|
||||
expect(renders).toEqual({ [`CommandCenterResultRow:${resultId}`]: { render: 1 } });
|
||||
}
|
||||
|
||||
export async function expectCommandCenterAgentWindow(panel: Locator, total: number) {
|
||||
const rows = panel.locator(
|
||||
'[data-testid^="command-center-agent-"]:not([data-testid="command-center-agent-subtitle"])',
|
||||
);
|
||||
const count = await rows.count();
|
||||
const WORKSPACE_ROWS =
|
||||
'[data-testid^="command-center-workspace-"]:not([data-testid="command-center-workspace-subtitle"]):not([data-testid="command-center-workspace-agent"])';
|
||||
|
||||
/** The list mounts a window of rows, not every match. */
|
||||
export async function expectCommandCenterWorkspaceWindow(panel: Locator, total: number) {
|
||||
const count = await panel.locator(WORKSPACE_ROWS).count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
expect(count).toBeLessThan(total);
|
||||
}
|
||||
|
||||
export async function scrollToOldestCommandCenterAgent(panel: Locator) {
|
||||
export async function scrollToOldestCommandCenterWorkspace(panel: Locator) {
|
||||
await panel.getByTestId("command-center-results").evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
|
||||
@@ -22,14 +22,9 @@ export async function observeCommandCenterScroll(page: Page): Promise<void> {
|
||||
|
||||
const discoverPanel = new MutationObserver(() => {
|
||||
const panel = document.querySelector<HTMLElement>('[data-testid="command-center-panel"]');
|
||||
const firstResult = panel?.querySelector<HTMLElement>('[role="button"]');
|
||||
if (!panel || !firstResult) return;
|
||||
|
||||
let viewport = firstResult.parentElement;
|
||||
while (viewport && viewport !== panel && viewport.scrollHeight <= viewport.clientHeight) {
|
||||
viewport = viewport.parentElement;
|
||||
}
|
||||
if (!viewport || viewport === panel) return;
|
||||
const viewport = panel?.querySelector<HTMLElement>('[data-testid="command-center-results"]');
|
||||
const firstResult = viewport?.querySelector<HTMLElement>('[role="button"]');
|
||||
if (!viewport || !firstResult) return;
|
||||
|
||||
discoverPanel.disconnect();
|
||||
let frame = 0;
|
||||
@@ -119,15 +114,8 @@ export async function expectVisibleKeyboardNavigationNotToScroll(
|
||||
for (let index = 0; index < 4; index += 1) await page.keyboard.press("ArrowDown");
|
||||
|
||||
const scrollSamples = await panel
|
||||
.getByRole("button")
|
||||
.first()
|
||||
.evaluate(async (firstResult) => {
|
||||
let viewport = firstResult.parentElement;
|
||||
while (viewport && viewport.scrollHeight <= viewport.clientHeight) {
|
||||
viewport = viewport.parentElement;
|
||||
}
|
||||
if (!viewport) throw new Error("Could not find the command center scroll viewport");
|
||||
|
||||
.getByTestId("command-center-results")
|
||||
.evaluate(async (viewport) => {
|
||||
const samples: number[] = [];
|
||||
for (let frame = 0; frame < 20; frame += 1) {
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
@@ -139,7 +127,8 @@ export async function expectVisibleKeyboardNavigationNotToScroll(
|
||||
expect(Math.max(...scrollSamples)).toBe(0);
|
||||
}
|
||||
|
||||
export async function expectWorkspaceResultsToBeLimitedUntilSearch(panel: Locator): Promise<void> {
|
||||
/** All leads with three recent workspaces; a query searches every workspace. */
|
||||
export async function expectRecentWorkspacesUntilSearch(panel: Locator): Promise<void> {
|
||||
const workspaces = panel.locator(
|
||||
'[data-testid^="command-center-workspace-"]:not([data-testid="command-center-workspace-subtitle"])',
|
||||
);
|
||||
@@ -150,9 +139,11 @@ export async function expectWorkspaceResultsToBeLimitedUntilSearch(panel: Locato
|
||||
),
|
||||
).size;
|
||||
|
||||
await expect.poll(distinctWorkspaceCount).toBe(5);
|
||||
await expect(panel.getByText("Recent workspaces", { exact: true })).toBeVisible();
|
||||
await expect.poll(distinctWorkspaceCount).toBe(3);
|
||||
|
||||
await panel.getByTestId("command-center-input").fill("Command Center Scroll");
|
||||
await expect(panel.getByText("Recent workspaces", { exact: true })).toHaveCount(0);
|
||||
await expect.poll(distinctWorkspaceCount).toBe(9);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
export type CommandCenterTabName = "All" | "Workspaces" | "Files" | "Actions";
|
||||
|
||||
const TAB_PLACEHOLDERS: Record<CommandCenterTabName, string> = {
|
||||
All: "Search workspaces, files, and actions...",
|
||||
Workspaces: "Search workspaces...",
|
||||
Files: "Search files...",
|
||||
Actions: "Search actions...",
|
||||
};
|
||||
|
||||
/** Outside a workspace there are no files to search, so All does not offer them. */
|
||||
const TAB_PLACEHOLDERS_WITHOUT_WORKSPACE: Record<CommandCenterTabName, string> = {
|
||||
...TAB_PLACEHOLDERS,
|
||||
All: "Search workspaces and actions...",
|
||||
};
|
||||
|
||||
// Opens the command center / global search palette from the sidebar and returns its panel.
|
||||
export async function openCommandCenter(page: Page): Promise<Locator> {
|
||||
await page.getByTestId("sidebar-search").click();
|
||||
@@ -12,3 +27,46 @@ export async function closeCommandCenter(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("command-center-panel")).not.toBeVisible();
|
||||
}
|
||||
|
||||
function commandCenterTab(panel: Locator, name: CommandCenterTabName): Locator {
|
||||
return panel.getByTestId("command-center-tabs").getByRole("button", { name, exact: true });
|
||||
}
|
||||
|
||||
export async function selectCommandCenterTab(
|
||||
panel: Locator,
|
||||
name: CommandCenterTabName,
|
||||
options: { workspaceOpen?: boolean } = {},
|
||||
): Promise<void> {
|
||||
await commandCenterTab(panel, name).click();
|
||||
await expectCommandCenterTab(panel, name, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab is selected and the search field asks for what that tab lists. Pass
|
||||
* `{ workspaceOpen: false }` when the command center was opened outside a workspace.
|
||||
*/
|
||||
export async function expectCommandCenterTab(
|
||||
panel: Locator,
|
||||
name: CommandCenterTabName,
|
||||
options: { workspaceOpen?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const placeholders =
|
||||
options.workspaceOpen === false ? TAB_PLACEHOLDERS_WITHOUT_WORKSPACE : TAB_PLACEHOLDERS;
|
||||
await expect(commandCenterTab(panel, name)).toHaveAttribute("aria-selected", "true");
|
||||
await expect(panel.getByTestId("command-center-input")).toHaveAttribute(
|
||||
"placeholder",
|
||||
placeholders[name],
|
||||
);
|
||||
}
|
||||
|
||||
/** Exactly these tabs are offered, in this order. */
|
||||
export async function expectCommandCenterTabs(
|
||||
panel: Locator,
|
||||
names: readonly CommandCenterTabName[],
|
||||
): Promise<void> {
|
||||
await expect(panel.getByTestId("command-center-tabs").getByRole("button")).toHaveText([...names]);
|
||||
}
|
||||
|
||||
export function commandCenterSection(panel: Locator, title: string): Locator {
|
||||
return panel.getByTestId("command-center-results").getByText(title, { exact: true });
|
||||
}
|
||||
|
||||
@@ -33,8 +33,10 @@ async function createRunningMockAgent(
|
||||
return { id: agent.id, title };
|
||||
}
|
||||
|
||||
async function openCommandCenter(page: Page): Promise<void> {
|
||||
// Agents are found through their workspace: the query names the agent, the row shows it.
|
||||
async function searchCommandCenter(page: Page, query: string): Promise<void> {
|
||||
await page.getByTestId("sidebar-search").click();
|
||||
await page.getByTestId("command-center-input").fill(query);
|
||||
}
|
||||
|
||||
export class DirectoryBootstrapScenario {
|
||||
@@ -78,8 +80,10 @@ export class DirectoryBootstrapScenario {
|
||||
const workspaceLink = this.page.getByText(workspace.projectDisplayName, { exact: true });
|
||||
await expect(workspaceLink).toHaveCount(1);
|
||||
await expect(workspaceLink).toBeVisible();
|
||||
await openCommandCenter(this.page);
|
||||
const agentLink = this.page.getByText(agent.title, { exact: true });
|
||||
await searchCommandCenter(this.page, agent.title);
|
||||
const agentLink = this.page
|
||||
.getByTestId("command-center-workspace-agent")
|
||||
.getByText(agent.title, { exact: true });
|
||||
await expect(agentLink).toHaveCount(1);
|
||||
await expect(agentLink).toBeVisible();
|
||||
await this.page.keyboard.press("Escape");
|
||||
@@ -111,8 +115,10 @@ export class DirectoryBootstrapScenario {
|
||||
const workspaceLink = this.page.getByText(workspace.projectDisplayName, { exact: true });
|
||||
await expect(workspaceLink).toHaveCount(1);
|
||||
await expect(workspaceLink).toBeVisible();
|
||||
await openCommandCenter(this.page);
|
||||
const agentLink = this.page.getByText(agent.title, { exact: true });
|
||||
await searchCommandCenter(this.page, agent.title);
|
||||
const agentLink = this.page
|
||||
.getByTestId("command-center-workspace-agent")
|
||||
.getByText(agent.title, { exact: true });
|
||||
await expect(agentLink).toHaveCount(1);
|
||||
await expect(agentLink).toBeVisible();
|
||||
await agentLink.click();
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { ArchivedWorkspace, ArchivedWorkspaceAgent } from "./internal/group";
|
||||
export {
|
||||
useArchivedWorkspaceSearch,
|
||||
type ArchivedWorkspaceSearch,
|
||||
} from "./internal/use-archived-workspace-search";
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { groupArchivedWorkspaces } from "./group";
|
||||
|
||||
const T0 = Date.parse("2026-11-01T10:00:00.000Z");
|
||||
const minutes = (count: number) => new Date(T0 + count * 60_000);
|
||||
|
||||
function historyAgent(input: {
|
||||
id: string;
|
||||
serverId?: string;
|
||||
workspaceId?: string | null;
|
||||
title?: string | null;
|
||||
activeAt: number;
|
||||
archivedAt?: number | null;
|
||||
workspaceName?: string | null;
|
||||
branch?: string | null;
|
||||
cwd?: string;
|
||||
}): AggregatedAgent {
|
||||
const cwd = input.cwd ?? "/repos/payments";
|
||||
return {
|
||||
id: input.id,
|
||||
serverId: input.serverId ?? "host-a",
|
||||
serverLabel: input.serverId ?? "host-a",
|
||||
title: input.title ?? null,
|
||||
status: "closed",
|
||||
turn: { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null },
|
||||
lastActivityAt: minutes(input.activeAt),
|
||||
cwd,
|
||||
workspaceId: input.workspaceId === undefined ? "ws-1" : input.workspaceId,
|
||||
provider: "claude",
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
attentionTimestamp: null,
|
||||
archivedAt: input.archivedAt == null ? null : minutes(input.archivedAt),
|
||||
createdAt: minutes(0),
|
||||
labels: {},
|
||||
projectPlacement: {
|
||||
projectKey: "prj-payments",
|
||||
projectName: "payments",
|
||||
workspaceName: input.workspaceName === undefined ? "Billing cleanup" : input.workspaceName,
|
||||
checkout: {
|
||||
cwd,
|
||||
isGit: true,
|
||||
currentBranch: input.branch === undefined ? "billing-cleanup" : input.branch,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: cwd,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
},
|
||||
} as AggregatedAgent;
|
||||
}
|
||||
|
||||
const NO_ACTIVE = new Map<string, ReadonlySet<string>>();
|
||||
|
||||
describe("groupArchivedWorkspaces", () => {
|
||||
it("folds a workspace's agents into one entry led by its most recent agent", () => {
|
||||
const [workspace] = groupArchivedWorkspaces({
|
||||
agents: [
|
||||
historyAgent({ id: "older", title: "Audit invoices", activeAt: 1, archivedAt: 5 }),
|
||||
historyAgent({ id: "newer", title: "Fix totals", activeAt: 3, archivedAt: 4 }),
|
||||
],
|
||||
activeIds: NO_ACTIVE,
|
||||
});
|
||||
|
||||
expect(workspace).toMatchObject({
|
||||
key: "host-a:ws-1",
|
||||
serverId: "host-a",
|
||||
workspaceId: "ws-1",
|
||||
name: "Billing cleanup",
|
||||
projectName: "payments",
|
||||
projectKey: "prj-payments",
|
||||
branch: "billing-cleanup",
|
||||
archivedAt: minutes(5),
|
||||
});
|
||||
expect(workspace.agents.map((agent) => agent.id)).toEqual(["newer", "older"]);
|
||||
});
|
||||
|
||||
it("leaves out workspaces that are still active on their host", () => {
|
||||
const workspaces = groupArchivedWorkspaces({
|
||||
agents: [historyAgent({ id: "a", activeAt: 1, archivedAt: 2 })],
|
||||
activeIds: new Map([["host-a", new Set(["ws-1"])]]),
|
||||
});
|
||||
|
||||
expect(workspaces).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves out an inactive workspace none of whose agents were archived", () => {
|
||||
const workspaces = groupArchivedWorkspaces({
|
||||
agents: [historyAgent({ id: "a", activeAt: 1, archivedAt: null })],
|
||||
activeIds: NO_ACTIVE,
|
||||
});
|
||||
|
||||
expect(workspaces).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the same workspace ID on two hosts apart and lists the latest archive first", () => {
|
||||
const workspaces = groupArchivedWorkspaces({
|
||||
agents: [
|
||||
historyAgent({ id: "a", serverId: "host-a", activeAt: 1, archivedAt: 2 }),
|
||||
historyAgent({ id: "b", serverId: "host-b", activeAt: 1, archivedAt: 9 }),
|
||||
],
|
||||
activeIds: NO_ACTIVE,
|
||||
});
|
||||
|
||||
expect(workspaces.map((workspace) => workspace.key)).toEqual(["host-b:ws-1", "host-a:ws-1"]);
|
||||
});
|
||||
|
||||
it("skips agents with no workspace and names an unnamed workspace after its folder", () => {
|
||||
const workspaces = groupArchivedWorkspaces({
|
||||
agents: [
|
||||
historyAgent({ id: "loose", workspaceId: null, activeAt: 1, archivedAt: 2 }),
|
||||
historyAgent({
|
||||
id: "a",
|
||||
workspaceName: null,
|
||||
branch: null,
|
||||
cwd: "/repos/receipts",
|
||||
activeAt: 1,
|
||||
archivedAt: 2,
|
||||
}),
|
||||
],
|
||||
activeIds: NO_ACTIVE,
|
||||
});
|
||||
|
||||
expect(workspaces).toHaveLength(1);
|
||||
expect(workspaces[0]).toMatchObject({ name: "receipts", branch: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
|
||||
export interface ArchivedWorkspaceAgent {
|
||||
id: string;
|
||||
title: string | null;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
/** A workspace the user archived, as agent history describes it. */
|
||||
export interface ArchivedWorkspace {
|
||||
/** `serverId:workspaceId` */
|
||||
key: string;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
projectName: string | null;
|
||||
/** The host's project ID. */
|
||||
projectKey: string | null;
|
||||
branch: string | null;
|
||||
/** The latest archive time among its agents. */
|
||||
archivedAt: Date;
|
||||
/** Its agents in this history page, most recently active first. Never empty. */
|
||||
agents: ArchivedWorkspaceAgent[];
|
||||
}
|
||||
|
||||
/** Which workspaces each host still lists as active. */
|
||||
export type ActiveWorkspaceIdsByServer = ReadonlyMap<string, ReadonlySet<string>>;
|
||||
|
||||
function lastPathSegment(path: string): string {
|
||||
const parts = path.split(/[\\/]/).filter(Boolean);
|
||||
return parts[parts.length - 1] ?? path;
|
||||
}
|
||||
|
||||
function checkoutBranch(agent: AggregatedAgent): string | null {
|
||||
const checkout = agent.projectPlacement?.checkout;
|
||||
return checkout?.isGit ? checkout.currentBranch : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds agent history into one entry per archived workspace, newest archive first. A
|
||||
* workspace its host still lists is active, so it is left out. So is an inactive workspace
|
||||
* with no archived agent: the user never put it away (a background workspace, for example).
|
||||
*/
|
||||
export function groupArchivedWorkspaces(input: {
|
||||
agents: readonly AggregatedAgent[];
|
||||
activeIds: ActiveWorkspaceIdsByServer;
|
||||
}): ArchivedWorkspace[] {
|
||||
const byKey = new Map<string, ArchivedWorkspace & { archivedAtOrNull: Date | null }>();
|
||||
const newestFirst = [...input.agents].sort(
|
||||
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime(),
|
||||
);
|
||||
for (const agent of newestFirst) {
|
||||
const workspaceId = agent.workspaceId;
|
||||
if (!workspaceId || input.activeIds.get(agent.serverId)?.has(workspaceId)) continue;
|
||||
const key = `${agent.serverId}:${workspaceId}`;
|
||||
const archivedAt = agent.archivedAt ? new Date(agent.archivedAt) : null;
|
||||
const summary = { id: agent.id, title: agent.title ?? null, provider: agent.provider };
|
||||
const existing = byKey.get(key);
|
||||
if (existing) {
|
||||
existing.agents.push(summary);
|
||||
if (archivedAt && (!existing.archivedAtOrNull || archivedAt > existing.archivedAtOrNull)) {
|
||||
existing.archivedAtOrNull = archivedAt;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// The most recent agent describes the workspace: it saw its latest name and branch.
|
||||
const placement = agent.projectPlacement;
|
||||
byKey.set(key, {
|
||||
key,
|
||||
serverId: agent.serverId,
|
||||
workspaceId,
|
||||
name: placement?.workspaceName || lastPathSegment(agent.cwd),
|
||||
projectName: placement?.projectName ?? null,
|
||||
projectKey: placement?.projectKey ?? null,
|
||||
branch: checkoutBranch(agent),
|
||||
archivedAt: archivedAt ?? new Date(0),
|
||||
archivedAtOrNull: archivedAt,
|
||||
agents: [summary],
|
||||
});
|
||||
}
|
||||
|
||||
const archived: ArchivedWorkspace[] = [];
|
||||
for (const { archivedAtOrNull, ...workspace } of byKey.values()) {
|
||||
if (archivedAtOrNull) archived.push({ ...workspace, archivedAt: archivedAtOrNull });
|
||||
}
|
||||
return archived.sort((left, right) => right.archivedAt.getTime() - left.archivedAt.getTime());
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useMemo } from "react";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useAgentHistory } from "@/hooks/use-agent-history";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import {
|
||||
groupArchivedWorkspaces,
|
||||
type ActiveWorkspaceIdsByServer,
|
||||
type ArchivedWorkspace,
|
||||
} from "./group";
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 200;
|
||||
const NO_WORKSPACES: Record<string, ReadonlyMap<string, WorkspaceDescriptor>> = {};
|
||||
const NO_ARCHIVED_WORKSPACES: ArchivedWorkspace[] = [];
|
||||
|
||||
export interface ArchivedWorkspaceSearch {
|
||||
/** Newest archive first. Empty without a query. */
|
||||
workspaces: ArchivedWorkspace[];
|
||||
/**
|
||||
* The host matched these for the current query. Its search forgives typos, so its matches
|
||||
* can include rows the caller cannot match itself. False while the previous query's
|
||||
* results stand in for the next one; those rows must still match the query on their own.
|
||||
*/
|
||||
matchedByHost: boolean;
|
||||
/** Still searching for the current query, debounce included. */
|
||||
pending: boolean;
|
||||
}
|
||||
|
||||
function useActiveWorkspaceIds(enabled: boolean): ActiveWorkspaceIdsByServer {
|
||||
// Each host's workspace map is replaced only when its workspaces change, so this does not
|
||||
// re-render on agent activity.
|
||||
const workspacesByServer = useSessionStore(
|
||||
useShallow((state) =>
|
||||
enabled
|
||||
? Object.fromEntries(
|
||||
Object.entries(state.sessions).map(([serverId, session]) => [
|
||||
serverId,
|
||||
session.workspaces,
|
||||
]),
|
||||
)
|
||||
: NO_WORKSPACES,
|
||||
),
|
||||
);
|
||||
return useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
Object.entries(workspacesByServer).map(([serverId, workspaces]) => [
|
||||
serverId,
|
||||
new Set(workspaces.keys()),
|
||||
]),
|
||||
),
|
||||
[workspacesByServer],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Archived workspaces whose name, project, branch, or agent titles match `query`, searched on
|
||||
* every host that supports history search. Hosts that predate it contribute nothing.
|
||||
*/
|
||||
export function useArchivedWorkspaceSearch(
|
||||
query: string,
|
||||
options: { enabled: boolean },
|
||||
): ArchivedWorkspaceSearch {
|
||||
const trimmed = query.trim();
|
||||
const search = useDebouncedValue(trimmed, SEARCH_DEBOUNCE_MS);
|
||||
const enabled = options.enabled && trimmed.length > 0;
|
||||
const activeIds = useActiveWorkspaceIds(enabled);
|
||||
const history = useAgentHistory({
|
||||
search,
|
||||
enabled: enabled && search.length > 0,
|
||||
searchableHostsOnly: true,
|
||||
keepPreviousResults: true,
|
||||
});
|
||||
const workspaces = useMemo(
|
||||
() =>
|
||||
enabled
|
||||
? groupArchivedWorkspaces({ agents: history.agents, activeIds })
|
||||
: NO_ARCHIVED_WORKSPACES,
|
||||
[activeIds, enabled, history.agents],
|
||||
);
|
||||
const matchedByHost = search === trimmed && !history.isLoading && !history.isPreviousResults;
|
||||
return { workspaces, matchedByHost, pending: enabled && !matchedByHost };
|
||||
}
|
||||
@@ -24,10 +24,12 @@ import {
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, ChevronRight, Folder, X } from "lucide-react-native";
|
||||
import { Check, ChevronRight } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { BottomSheetFlatList, type BottomSheetFlatListMethods } from "@gorhom/bottom-sheet";
|
||||
import { useAgentDescriptor, resolveAgentLabel } from "@/agent-presentation";
|
||||
import { resolveAgentLabel } from "@/agent-presentation";
|
||||
import { useArchivedWorkspaceSearch, type ArchivedWorkspace } from "@/archived-workspaces";
|
||||
import { ArchivedBadge } from "@/components/archived-badge";
|
||||
import { StatusIcon } from "@/components/status-icon";
|
||||
import { useWorkspaceStatus } from "@/workspace-status";
|
||||
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
@@ -35,12 +37,14 @@ import type { Theme } from "@/styles/theme";
|
||||
import { HighlightedText, type HighlightedTextProps } from "@/components/ui/highlighted-text";
|
||||
import { findHighlightRanges } from "@/components/ui/highlighted-text-segments";
|
||||
import { MaterialFileIcon } from "@/components/material-file-icon";
|
||||
import { useProviderIcon } from "@/components/provider-icons";
|
||||
import { ProjectIconView } from "@/components/project-icon-view";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import {
|
||||
EditingTextInput as TextInput,
|
||||
type EditingTextInputHandle,
|
||||
} from "@/components/ui/text-input";
|
||||
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
@@ -48,18 +52,20 @@ import {
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
|
||||
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useProjects } from "@/hooks/use-projects";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { createProjectIconTarget } from "@/projects/icon-target";
|
||||
import { useProjectIcons } from "@/projects/icons";
|
||||
import { OverlayLayerProvider, useGlobalOverlayLayer } from "@/lib/overlay-root";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useHostRuntimeConnectionStatuses, useHosts } from "@/runtime/host-runtime";
|
||||
import { useHydratedWorkspaceServerIds } from "@/stores/session-store-hooks";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
|
||||
import {
|
||||
useKeyboardShortcutsStore,
|
||||
type CommandCenterScope,
|
||||
} from "@/stores/keyboard-shortcuts-store";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
navigateToWorkspace,
|
||||
useActiveWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
|
||||
import {
|
||||
clearCommandCenterFocusRestoreElement,
|
||||
@@ -67,8 +73,8 @@ import {
|
||||
} from "@/utils/command-center-focus-restore";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
||||
import { getProjectSummaryForHostProject } from "@/utils/projects";
|
||||
import {
|
||||
COMMAND_CENTER_LINE_HEIGHT,
|
||||
COMMAND_CENTER_LIST_METRICS,
|
||||
@@ -76,21 +82,23 @@ import {
|
||||
type CommandCenterListMetrics,
|
||||
} from "./list-metrics";
|
||||
import { useCommandCenterContributions } from "./provider";
|
||||
import { filterAndRankWorkspaces } from "./workspace-search";
|
||||
import { createWorkspaceRanker } from "./workspace-search";
|
||||
import {
|
||||
buildContributionSections,
|
||||
filterAndRankBuiltInResults,
|
||||
joinSubtitleParts,
|
||||
PINNED_SECTION_BAND,
|
||||
projectCommandCenterRows,
|
||||
type CommandCenterAgentResult,
|
||||
type CommandCenterFileResult,
|
||||
type CommandCenterListRow,
|
||||
type CommandCenterResult,
|
||||
type CommandCenterResultSection,
|
||||
type CommandCenterSearchFields,
|
||||
type CommandCenterWorkspaceAgent,
|
||||
type CommandCenterWorkspaceResult,
|
||||
} from "./results";
|
||||
import { buildCommandCenterSections } from "./sections";
|
||||
import {
|
||||
availableCommandCenterTabs,
|
||||
resolveCommandCenterTab,
|
||||
rotateCommandCenterTab,
|
||||
type CommandCenterTab,
|
||||
} from "./tabs";
|
||||
import { createCommandCenterInteraction, type CommandCenterInteraction } from "./interaction";
|
||||
import { useWorkspaceFileSearch } from "./workspace-file-search";
|
||||
|
||||
@@ -100,23 +108,16 @@ const ThemedBottomSheetTextInput = withUnistyles(TextInput, (theme) => ({
|
||||
const ThemedTextInput = withUnistyles(TextInput, (theme) => ({
|
||||
placeholderTextColor: theme.colors.foregroundMuted,
|
||||
}));
|
||||
const ThemedFolder = withUnistyles(Folder, (theme) => ({ color: theme.colors.foregroundMuted }));
|
||||
const ThemedCheck = withUnistyles(Check, (theme) => ({ color: theme.colors.foreground }));
|
||||
const ThemedChevronRight = withUnistyles(ChevronRight, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
const ThemedX = withUnistyles(X, (theme) => ({ color: theme.colors.foregroundMuted }));
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"];
|
||||
const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const;
|
||||
|
||||
/** `cwd` is not rendered, so a path match must never outrank a match on text the user can see. */
|
||||
function agentSearchFields(result: CommandCenterAgentResult): CommandCenterSearchFields {
|
||||
return { visible: [result.title, result.subtitle], hidden: [result.agent.cwd] };
|
||||
}
|
||||
|
||||
function createWorkspaceResultAction(serverId: string, workspaceId: string) {
|
||||
return () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
@@ -124,28 +125,73 @@ function createWorkspaceResultAction(serverId: string, workspaceId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function createAgentResultAction(serverId: string, agentId: string) {
|
||||
return () => {
|
||||
function createAgentResultAction(serverId: string) {
|
||||
return (agentId: string) => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
navigateToAgent({ serverId, agentId });
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build every pinned row. Deliberately not keyed on `query`: none of this work depends on what
|
||||
* was typed. Ordering belongs to `filterAndRankBuiltInResults`, which ranks on every keystroke.
|
||||
*/
|
||||
function useBuiltInRows(open: boolean): {
|
||||
workspaces: CommandCenterWorkspaceResult[];
|
||||
agents: CommandCenterAgentResult[];
|
||||
} {
|
||||
const { t } = useTranslation();
|
||||
const previousRows = useRef(
|
||||
new Map<string, CommandCenterAgentResult | CommandCenterWorkspaceResult>(),
|
||||
const NO_WORKSPACE_AGENTS: readonly CommandCenterWorkspaceAgent[] = [];
|
||||
|
||||
/** Each workspace's titled agents, most recent first, keyed `serverId:workspaceId`. */
|
||||
function groupWorkspaceAgents(
|
||||
agents: readonly AggregatedAgent[],
|
||||
): Map<string, CommandCenterWorkspaceAgent[]> {
|
||||
const byWorkspace = new Map<string, CommandCenterWorkspaceAgent[]>();
|
||||
const newestFirst = [...agents].sort(
|
||||
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime(),
|
||||
);
|
||||
const { agents } = useAggregatedAgents({ demand: open });
|
||||
const { projects } = useProjects({ enabled: open });
|
||||
const showHost = useHosts().length > 1;
|
||||
for (const agent of newestFirst) {
|
||||
const title = resolveAgentLabel(agent.title);
|
||||
if (!agent.workspaceId || !title) continue;
|
||||
const key = `${agent.serverId}:${agent.workspaceId}`;
|
||||
const list = byWorkspace.get(key) ?? [];
|
||||
list.push({ id: agent.id, title, provider: agent.provider });
|
||||
byWorkspace.set(key, list);
|
||||
}
|
||||
return byWorkspace;
|
||||
}
|
||||
|
||||
function createArchivedAgentAction(serverId: string, workspaceId: string) {
|
||||
// Opening on an agent with the tab pinned is what offers Unarchive, as History does.
|
||||
return (agentId: string) => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
navigateToAgent({ serverId, agentId, workspaceId, pin: true });
|
||||
};
|
||||
}
|
||||
|
||||
function titledAgents(
|
||||
agents: readonly { id: string; title: string | null; provider: string }[],
|
||||
): CommandCenterWorkspaceAgent[] {
|
||||
return agents.flatMap((agent) => {
|
||||
const title = resolveAgentLabel(agent.title);
|
||||
return title ? [{ id: agent.id, title, provider: agent.provider }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
interface WorkspaceRows {
|
||||
active: CommandCenterWorkspaceResult[];
|
||||
archived: CommandCenterWorkspaceResult[];
|
||||
/** The workspace list has not loaded yet, so rows are still arriving. */
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build every workspace row. Deliberately not keyed on `query`: none of this work depends on
|
||||
* what was typed. Ordering belongs to the workspace ranker, which ranks on every keystroke.
|
||||
* Archived rows come from the host's history search, so they change with its results.
|
||||
*/
|
||||
function useWorkspaceRows(
|
||||
open: boolean,
|
||||
archivedWorkspaces: readonly ArchivedWorkspace[],
|
||||
): WorkspaceRows {
|
||||
const previousRows = useRef(new Map<string, CommandCenterWorkspaceResult>());
|
||||
const { agents, isInitialLoad: agentsLoading } = useAggregatedAgents({ demand: open });
|
||||
const { projects, isLoading: projectsLoading } = useProjects({ enabled: open });
|
||||
const hosts = useHosts();
|
||||
const workspacesArriving = useWorkspacesArriving(open, hosts);
|
||||
const showHost = hosts.length > 1;
|
||||
const iconTargets = useMemo(
|
||||
() =>
|
||||
open
|
||||
@@ -163,24 +209,26 @@ function useBuiltInRows(open: boolean): {
|
||||
);
|
||||
const iconDataByProjectViewKey = useProjectIcons({ projects: iconTargets });
|
||||
|
||||
return useMemo(() => {
|
||||
const active = useMemo(() => {
|
||||
if (!open) {
|
||||
previousRows.current.clear();
|
||||
return { workspaces: [], agents: [] };
|
||||
return [];
|
||||
}
|
||||
// Built-in IDs identify immutable navigation targets. Reuse their callbacks and unchanged
|
||||
// Workspace IDs identify immutable navigation targets. Reuse their callbacks and unchanged
|
||||
// row data so a directory update does not invalidate every mounted ResultRow.
|
||||
const nextRows = new Map<string, CommandCenterAgentResult | CommandCenterWorkspaceResult>();
|
||||
const preserve = <T extends CommandCenterAgentResult | CommandCenterWorkspaceResult>(
|
||||
row: T,
|
||||
): T => {
|
||||
const nextRows = new Map<string, CommandCenterWorkspaceResult>();
|
||||
const preserve = (row: CommandCenterWorkspaceResult): CommandCenterWorkspaceResult => {
|
||||
const previous = previousRows.current.get(row.id);
|
||||
if (previous) row.run = previous.run;
|
||||
const stable = previous && equal(previous, row) ? (previous as T) : row;
|
||||
if (previous) {
|
||||
row.run = previous.run;
|
||||
row.openAgent = previous.openAgent;
|
||||
}
|
||||
const stable = previous && equal(previous, row) ? previous : row;
|
||||
nextRows.set(row.id, stable);
|
||||
return stable;
|
||||
};
|
||||
const allWorkspaces: CommandCenterWorkspaceResult[] = [];
|
||||
const agentsByWorkspace = groupWorkspaceAgents(agents);
|
||||
const rows: CommandCenterWorkspaceResult[] = [];
|
||||
for (const project of projects) {
|
||||
const workspaceProject = {
|
||||
viewKey: project.viewKey,
|
||||
@@ -190,10 +238,11 @@ function useBuiltInRows(open: boolean): {
|
||||
for (const host of project.hosts) {
|
||||
for (const workspace of host.workspaces) {
|
||||
if (workspace.archivingAt) continue;
|
||||
allWorkspaces.push(
|
||||
const key = `${host.serverId}:${workspace.id}`;
|
||||
rows.push(
|
||||
preserve({
|
||||
kind: "workspace",
|
||||
id: `workspace:${host.serverId}:${workspace.id}`,
|
||||
id: `workspace:${key}`,
|
||||
title: workspace.title ?? workspace.name,
|
||||
subtitle: joinSubtitleParts([
|
||||
showHost ? host.serverName : null,
|
||||
@@ -205,66 +254,93 @@ function useBuiltInRows(open: boolean): {
|
||||
project: workspaceProject,
|
||||
changeRequestNumber: workspace.changeRequestNumber,
|
||||
activityAt: workspace.statusEnteredAt,
|
||||
agents: agentsByWorkspace.get(key) ?? NO_WORKSPACE_AGENTS,
|
||||
matchedAgent: null,
|
||||
archived: false,
|
||||
background: workspace.background,
|
||||
run: createWorkspaceResultAction(host.serverId, workspace.id),
|
||||
openAgent: createAgentResultAction(host.serverId),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const workspaceTitleByKey = new Map(
|
||||
allWorkspaces.map((workspace) => [workspace.id.slice("workspace:".length), workspace.title]),
|
||||
);
|
||||
const agentRows = agents.map<CommandCenterAgentResult>((agent) => {
|
||||
const workspaceTitle = agent.workspaceId
|
||||
? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`)
|
||||
: undefined;
|
||||
return preserve({
|
||||
kind: "agent",
|
||||
id: `agent:${agent.serverId}:${agent.id}`,
|
||||
agent,
|
||||
title: resolveAgentLabel(agent.title) ?? t("workspace.tabs.loading"),
|
||||
subtitle: joinSubtitleParts([
|
||||
showHost ? agent.serverLabel : null,
|
||||
workspaceTitle ?? shortenPath(agent.cwd),
|
||||
]),
|
||||
activityAt: agent.lastActivityAt,
|
||||
run: createAgentResultAction(agent.serverId, agent.id),
|
||||
});
|
||||
});
|
||||
previousRows.current = nextRows;
|
||||
return { workspaces: allWorkspaces, agents: agentRows };
|
||||
}, [agents, iconDataByProjectViewKey, open, projects, showHost, t]);
|
||||
return rows;
|
||||
}, [agents, iconDataByProjectViewKey, open, projects, showHost]);
|
||||
|
||||
const archived = useMemo(() => {
|
||||
const activeIds = new Set(active.map((row) => row.id));
|
||||
const hostLabels = new Map(hosts.map((host) => [host.serverId, host.label]));
|
||||
return archivedWorkspaces.flatMap<CommandCenterWorkspaceResult>((workspace) => {
|
||||
const id = `workspace:${workspace.key}`;
|
||||
// The host's history can trail its workspace list; the active row wins.
|
||||
if (activeIds.has(id)) return [];
|
||||
const project = workspace.projectKey
|
||||
? getProjectSummaryForHostProject(projects, workspace.serverId, workspace.projectKey)
|
||||
: undefined;
|
||||
const projectName = project?.projectName ?? workspace.projectName ?? workspace.name;
|
||||
const openAgent = createArchivedAgentAction(workspace.serverId, workspace.workspaceId);
|
||||
const latestAgentId = workspace.agents[0].id;
|
||||
return [
|
||||
{
|
||||
kind: "workspace",
|
||||
id,
|
||||
title: workspace.name,
|
||||
subtitle: joinSubtitleParts([
|
||||
showHost ? (hostLabels.get(workspace.serverId) ?? workspace.serverId) : null,
|
||||
projectName,
|
||||
workspace.branch,
|
||||
]),
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
project: {
|
||||
viewKey: project?.viewKey ?? `archived:${workspace.key}`,
|
||||
name: projectName,
|
||||
iconDataUri: project ? (iconDataByProjectViewKey.get(project.viewKey) ?? null) : null,
|
||||
},
|
||||
changeRequestNumber: null,
|
||||
activityAt: workspace.archivedAt,
|
||||
agents: titledAgents(workspace.agents),
|
||||
matchedAgent: null,
|
||||
archived: true,
|
||||
background: false,
|
||||
run: () => openAgent(latestAgentId),
|
||||
openAgent,
|
||||
},
|
||||
];
|
||||
});
|
||||
}, [active, archivedWorkspaces, hosts, iconDataByProjectViewKey, projects, showHost]);
|
||||
|
||||
return {
|
||||
active,
|
||||
archived,
|
||||
// Opening a workspace route loads that workspace before the rest of the directory, so rows
|
||||
// are no sign that the list is complete.
|
||||
loading: agentsLoading || projectsLoading || workspacesArriving,
|
||||
};
|
||||
}
|
||||
|
||||
function useBuiltInSections(open: boolean, query: string): CommandCenterResultSection[] {
|
||||
const { t } = useTranslation();
|
||||
const rows = useBuiltInRows(open);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!open) return [];
|
||||
return [
|
||||
{
|
||||
id: "workspaces",
|
||||
band: PINNED_SECTION_BAND,
|
||||
rank: 2,
|
||||
title: t("shell.commandCenter.workspaces"),
|
||||
results: filterAndRankWorkspaces(rows.workspaces, query),
|
||||
},
|
||||
{
|
||||
id: "agents",
|
||||
band: PINNED_SECTION_BAND,
|
||||
rank: 3,
|
||||
title: t("shell.commandCenter.agents"),
|
||||
results: filterAndRankBuiltInResults(rows.agents, query, agentSearchFields),
|
||||
},
|
||||
];
|
||||
}, [open, query, rows, t]);
|
||||
/**
|
||||
* An online host's workspaces have not arrived yet. Its agent directory reports ready before
|
||||
* the workspace snapshot lands, so until then the workspace rows are incomplete.
|
||||
*/
|
||||
function useWorkspacesArriving(open: boolean, hosts: readonly { serverId: string }[]): boolean {
|
||||
const serverIds = useMemo(() => (open ? hosts.map((host) => host.serverId) : []), [hosts, open]);
|
||||
const connectionStatuses = useHostRuntimeConnectionStatuses(serverIds);
|
||||
const hydratedServerIds = useHydratedWorkspaceServerIds(serverIds);
|
||||
return serverIds.some(
|
||||
(serverId) =>
|
||||
connectionStatuses.get(serverId) === "online" && !hydratedServerIds.includes(serverId),
|
||||
);
|
||||
}
|
||||
|
||||
interface CommandCenterState {
|
||||
open: boolean;
|
||||
scope: CommandCenterScope;
|
||||
clearScope(): void;
|
||||
/** The tabs on offer, in the order Tab walks them. */
|
||||
tabs: readonly CommandCenterTab[];
|
||||
tab: CommandCenterTab;
|
||||
selectTab(tab: CommandCenterTab): void;
|
||||
query: string;
|
||||
setQuery(query: string): void;
|
||||
activeId: string | null;
|
||||
@@ -275,7 +351,7 @@ interface CommandCenterState {
|
||||
fileSearchError: string | null;
|
||||
close(): void;
|
||||
select(result: CommandCenterResult): void;
|
||||
key(key: string): boolean;
|
||||
key(event: { key: string; shiftKey?: boolean }): boolean;
|
||||
}
|
||||
|
||||
function useCommandCenterState(
|
||||
@@ -285,62 +361,95 @@ function useCommandCenterState(
|
||||
const keyboardActionDispatcher = useKeyboardActionDispatcher();
|
||||
const { t } = useTranslation();
|
||||
const open = useKeyboardShortcutsStore((state) => state.commandCenterOpen);
|
||||
const scope = useKeyboardShortcutsStore((state) => state.commandCenterScope);
|
||||
const requestedTab = useKeyboardShortcutsStore((state) => state.commandCenterTab);
|
||||
const setOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
|
||||
const setScope = useKeyboardShortcutsStore((state) => state.setCommandCenterScope);
|
||||
const setTab = useKeyboardShortcutsStore((state) => state.setCommandCenterTab);
|
||||
const snapshot = useCommandCenterContributions();
|
||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||
const previousOpenRef = useRef(open);
|
||||
const { query, activeId } = useSyncExternalStore(interaction.subscribe, interaction.getSnapshot);
|
||||
const builtInSections = useBuiltInSections(open, query);
|
||||
const activeWorkspace = useActiveWorkspaceSelection();
|
||||
const hasWorkspace = activeWorkspace !== null;
|
||||
const tabs = useMemo(() => availableCommandCenterTabs({ hasWorkspace }), [hasWorkspace]);
|
||||
const tab = resolveCommandCenterTab(requestedTab, tabs);
|
||||
const showBackground = useSidebarViewStore((state) => state.showBackground);
|
||||
const listsWorkspaces = tab === "all" || tab === "workspaces";
|
||||
const archivedSearch = useArchivedWorkspaceSearch(query, {
|
||||
enabled: open && listsWorkspaces,
|
||||
});
|
||||
const workspaceRows = useWorkspaceRows(open, archivedSearch.workspaces);
|
||||
const [rankWorkspaces] = useState(createWorkspaceRanker);
|
||||
const currentWorkspaceId = activeWorkspace
|
||||
? `workspace:${activeWorkspace.serverId}:${activeWorkspace.workspaceId}`
|
||||
: null;
|
||||
const {
|
||||
entries: fileSearchEntries,
|
||||
loading: fileSearchLoading,
|
||||
error: fileSearchError,
|
||||
openFile,
|
||||
} = useWorkspaceFileSearch({
|
||||
enabled: open && (scope === "files" || Boolean(query.trim())),
|
||||
enabled:
|
||||
open &&
|
||||
tabs.includes("files") &&
|
||||
(tab === "files" || (tab === "all" && Boolean(query.trim()))),
|
||||
query,
|
||||
});
|
||||
const fileSections = useMemo<CommandCenterResultSection[]>(() => {
|
||||
const results = fileSearchEntries.map<CommandCenterFileResult>((entry) => ({
|
||||
kind: "file",
|
||||
id: `file:${entry.path}`,
|
||||
filePath: entry.path,
|
||||
title: entry.name,
|
||||
subtitle: entry.directory,
|
||||
run: () => openFile(entry.path),
|
||||
}));
|
||||
// File rows are matched by the daemon, so they carry no client-side score and cannot be
|
||||
// relevance-compared against the rows that do. Pinning them keeps the section comparator
|
||||
// total: band is decided before any score is read.
|
||||
return [
|
||||
{
|
||||
id: "files",
|
||||
band: PINNED_SECTION_BAND,
|
||||
rank: 4,
|
||||
title: t("shell.commandCenter.files"),
|
||||
results,
|
||||
},
|
||||
];
|
||||
}, [fileSearchEntries, openFile, t]);
|
||||
const contributionSections = useMemo(
|
||||
() => buildContributionSections(snapshot.contributions, query),
|
||||
[query, snapshot.contributions],
|
||||
const files = useMemo(
|
||||
() =>
|
||||
fileSearchEntries.map<CommandCenterFileResult>((entry) => ({
|
||||
kind: "file",
|
||||
id: `file:${entry.path}`,
|
||||
filePath: entry.path,
|
||||
title: entry.name,
|
||||
subtitle: entry.directory,
|
||||
run: () => openFile(entry.path),
|
||||
})),
|
||||
[fileSearchEntries, openFile],
|
||||
);
|
||||
const sections = useMemo(
|
||||
() =>
|
||||
open
|
||||
? buildCommandCenterSections({
|
||||
tab,
|
||||
query,
|
||||
rankWorkspaces,
|
||||
workspaces: workspaceRows.active,
|
||||
archivedWorkspaces: workspaceRows.archived,
|
||||
archivedMatchedByHost: archivedSearch.matchedByHost,
|
||||
showBackground,
|
||||
currentWorkspaceId,
|
||||
contributions: snapshot.contributions,
|
||||
files,
|
||||
titles: {
|
||||
recentWorkspaces: t("shell.commandCenter.recentWorkspaces"),
|
||||
workspaces: t("shell.commandCenter.workspaces"),
|
||||
files: t("shell.commandCenter.files"),
|
||||
},
|
||||
})
|
||||
: [],
|
||||
[
|
||||
archivedSearch.matchedByHost,
|
||||
currentWorkspaceId,
|
||||
files,
|
||||
open,
|
||||
query,
|
||||
rankWorkspaces,
|
||||
showBackground,
|
||||
snapshot.contributions,
|
||||
t,
|
||||
tab,
|
||||
workspaceRows,
|
||||
],
|
||||
);
|
||||
const projection = useMemo(
|
||||
() =>
|
||||
projectCommandCenterRows(
|
||||
scope === "files"
|
||||
? fileSections
|
||||
: [...contributionSections, ...fileSections, ...builtInSections],
|
||||
listMetrics,
|
||||
),
|
||||
[builtInSections, contributionSections, fileSections, listMetrics, scope],
|
||||
() => projectCommandCenterRows(sections, listMetrics),
|
||||
[listMetrics, sections],
|
||||
);
|
||||
const loading =
|
||||
fileSearchLoading || (listsWorkspaces && (archivedSearch.pending || workspaceRows.loading));
|
||||
useLayoutEffect(() => {
|
||||
interaction.update({ open, scope, query, projection, loading: fileSearchLoading });
|
||||
}, [interaction, open, scope, query, projection, fileSearchLoading]);
|
||||
interaction.update({ open, tab, query, projection, loading });
|
||||
}, [interaction, open, tab, query, projection, loading]);
|
||||
|
||||
const close = useCallback(() => setOpen(false), [setOpen]);
|
||||
const select = useCallback(
|
||||
@@ -350,16 +459,29 @@ function useCommandCenterState(
|
||||
},
|
||||
[setOpen],
|
||||
);
|
||||
const selectTab = useCallback(
|
||||
(next: CommandCenterTab) => {
|
||||
setTab(next);
|
||||
// A tab click must not take typing away from the search field.
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
[setTab],
|
||||
);
|
||||
const key = useCallback(
|
||||
(pressed: string): boolean => {
|
||||
(event: { key: string; shiftKey?: boolean }): boolean => {
|
||||
if (!open) return false;
|
||||
const pressed = event.key;
|
||||
const results = projection.selectableResults;
|
||||
if (pressed === "Escape") {
|
||||
close();
|
||||
return true;
|
||||
}
|
||||
if (pressed === "Backspace" && !query.trim() && scope) {
|
||||
setScope(null);
|
||||
if (pressed === "Tab") {
|
||||
setTab(rotateCommandCenterTab(tab, event.shiftKey ? "previous" : "next", tabs));
|
||||
return true;
|
||||
}
|
||||
if (pressed === "Backspace" && !query.trim() && tab !== "all") {
|
||||
setTab("all");
|
||||
return true;
|
||||
}
|
||||
if (pressed === "Enter") {
|
||||
@@ -377,9 +499,10 @@ function useCommandCenterState(
|
||||
projection.selectableResults,
|
||||
query,
|
||||
activeId,
|
||||
scope,
|
||||
tab,
|
||||
tabs,
|
||||
select,
|
||||
setScope,
|
||||
setTab,
|
||||
interaction,
|
||||
],
|
||||
);
|
||||
@@ -405,8 +528,9 @@ function useCommandCenterState(
|
||||
|
||||
return {
|
||||
open,
|
||||
scope,
|
||||
clearScope: () => setScope(null),
|
||||
tabs,
|
||||
tab,
|
||||
selectTab,
|
||||
query,
|
||||
setQuery: interaction.setQuery,
|
||||
activeId,
|
||||
@@ -536,55 +660,38 @@ function SubtitleWithTimestamp({
|
||||
);
|
||||
}
|
||||
|
||||
const providerIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
function AgentResultContent({
|
||||
result,
|
||||
/**
|
||||
* The third line of a workspace that matched only through one of its agents: which agent, so the
|
||||
* user can tell why the row is here. It starts on the title's rail.
|
||||
*/
|
||||
function MatchedAgentLine({
|
||||
agent,
|
||||
serverId,
|
||||
query,
|
||||
backdrop,
|
||||
}: {
|
||||
result: CommandCenterAgentResult;
|
||||
agent: CommandCenterWorkspaceAgent;
|
||||
serverId: string;
|
||||
query: string;
|
||||
backdrop: SurfaceBackdrop;
|
||||
}) {
|
||||
const agent = result.agent;
|
||||
const descriptor = useAgentDescriptor(
|
||||
{ kind: "agent", agentId: agent.id },
|
||||
{ serverId: agent.serverId },
|
||||
);
|
||||
const Icon = useMemo(
|
||||
() => withUnistyles(descriptor.icon, providerIconMapping),
|
||||
[descriptor.icon],
|
||||
);
|
||||
const ProviderIcon = useProviderIcon(agent.provider, serverId);
|
||||
const Icon = useMemo(() => withUnistyles(ProviderIcon, providerIconMapping), [ProviderIcon]);
|
||||
return (
|
||||
<View style={styles.rowContent} testID={`command-center-agent-${agent.serverId}:${agent.id}`}>
|
||||
<View style={styles.rowMain}>
|
||||
<View style={styles.iconSlot}>
|
||||
<StatusIcon statusBucket={descriptor.statusBucket} size={16} backdrop={backdrop}>
|
||||
<View accessibilityRole="image" accessibilityLabel={descriptor.subtitle}>
|
||||
<Icon size={16} />
|
||||
</View>
|
||||
</StatusIcon>
|
||||
</View>
|
||||
<View style={styles.textContent}>
|
||||
<QueryHighlightedText
|
||||
query={query}
|
||||
text={result.title}
|
||||
style={styles.title}
|
||||
numberOfLines={1}
|
||||
/>
|
||||
<SubtitleWithTimestamp
|
||||
query={query}
|
||||
subtitle={result.subtitle}
|
||||
activityAt={result.activityAt}
|
||||
testID="command-center-agent-subtitle"
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.agentLine} testID="command-center-workspace-agent">
|
||||
<View style={styles.agentLineIcon}>
|
||||
<Icon size={12} />
|
||||
</View>
|
||||
<QueryHighlightedText
|
||||
query={query}
|
||||
text={agent.title}
|
||||
style={[styles.subtitle, styles.subtitleText]}
|
||||
numberOfLines={1}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const providerIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
function WorkspaceResultIcon({
|
||||
result,
|
||||
backdrop,
|
||||
@@ -595,24 +702,27 @@ function WorkspaceResultIcon({
|
||||
const statusBucket = useWorkspaceStatus(result.serverId, result.workspaceId);
|
||||
return (
|
||||
<StatusIcon statusBucket={statusBucket} size={16} backdrop={backdrop}>
|
||||
<View accessibilityElementsHidden importantForAccessibility="no-hide-descendants" aria-hidden>
|
||||
<WorkspaceProjectIcon project={result.project} />
|
||||
</View>
|
||||
<DecorativeProjectIcon project={result.project} />
|
||||
</StatusIcon>
|
||||
);
|
||||
}
|
||||
|
||||
/** The same project icon the sidebar's workspace rows lead with: its image, or a lettered square. */
|
||||
function WorkspaceProjectIcon({ project }: { project: CommandCenterWorkspaceResult["project"] }) {
|
||||
/**
|
||||
* The same project icon the sidebar's workspace rows lead with: its image, or a lettered square.
|
||||
* Hidden from assistive tech so the fallback's initial does not prefix the row's name.
|
||||
*/
|
||||
function DecorativeProjectIcon({ project }: { project: CommandCenterWorkspaceResult["project"] }) {
|
||||
const initial = projectIconPlaceholderLabelFromDisplayName(project.name).charAt(0).toUpperCase();
|
||||
return (
|
||||
<ProjectIconView
|
||||
iconDataUri={project.iconDataUri}
|
||||
initial={initial}
|
||||
projectViewKey={project.viewKey}
|
||||
size={16}
|
||||
textStyle={styles.projectIconFallbackText}
|
||||
/>
|
||||
<View accessibilityElementsHidden importantForAccessibility="no-hide-descendants" aria-hidden>
|
||||
<ProjectIconView
|
||||
iconDataUri={project.iconDataUri}
|
||||
initial={initial}
|
||||
projectViewKey={project.viewKey}
|
||||
size={16}
|
||||
textStyle={styles.projectIconFallbackText}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -650,17 +760,18 @@ function ResultContent({
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (result.kind === "agent") {
|
||||
return <AgentResultContent result={result} query={query} backdrop={backdrop} />;
|
||||
}
|
||||
if (result.kind === "workspace") {
|
||||
const key = result.id.slice("workspace:".length);
|
||||
return (
|
||||
<View style={styles.rowContent} testID={`command-center-workspace-${key}`}>
|
||||
<View style={styles.rowMain}>
|
||||
{/* Decorative: the fallback icon's initial must not prefix the row's accessible name. */}
|
||||
<View style={styles.iconSlot}>
|
||||
<WorkspaceResultIcon result={result} backdrop={backdrop} />
|
||||
{/* An archived workspace runs nothing, so it has no status to show. */}
|
||||
{result.archived ? (
|
||||
<DecorativeProjectIcon project={result.project} />
|
||||
) : (
|
||||
<WorkspaceResultIcon result={result} backdrop={backdrop} />
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.textContent}>
|
||||
<QueryHighlightedText
|
||||
@@ -675,8 +786,16 @@ function ResultContent({
|
||||
activityAt={result.activityAt}
|
||||
testID="command-center-workspace-subtitle"
|
||||
/>
|
||||
{result.matchedAgent ? (
|
||||
<MatchedAgentLine
|
||||
agent={result.matchedAgent}
|
||||
serverId={result.serverId}
|
||||
query={query}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
{result.archived ? <ArchivedBadge /> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -761,9 +880,24 @@ function SectionRow({ row }: { row: Extract<CommandCenterListRow, { kind: "secti
|
||||
}
|
||||
|
||||
// Escape arrives without asking; it is registered for `agent.interrupt` anyway.
|
||||
// Backspace stays off deliberately — the palette pops its scope with it on web,
|
||||
// Backspace stays off deliberately — on web it returns a tab to All when the field is empty,
|
||||
// but a registered command would stop the search field deleting text.
|
||||
|
||||
const TAB_PLACEHOLDER_KEYS = {
|
||||
all: "shell.commandCenter.placeholder",
|
||||
workspaces: "shell.commandCenter.workspacePlaceholder",
|
||||
files: "shell.commandCenter.filePlaceholder",
|
||||
actions: "shell.commandCenter.actionPlaceholder",
|
||||
} as const satisfies Record<CommandCenterTab, string>;
|
||||
|
||||
/** The search field names what the tab searches; All names only the kinds on offer. */
|
||||
function placeholderKey(tab: CommandCenterTab, tabs: readonly CommandCenterTab[]) {
|
||||
if (tab === "all" && !tabs.includes("files")) {
|
||||
return "shell.commandCenter.placeholderWithoutFiles";
|
||||
}
|
||||
return TAB_PLACEHOLDER_KEYS[tab];
|
||||
}
|
||||
|
||||
export function CommandCenter() {
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
@@ -875,22 +1009,42 @@ export function CommandCenter() {
|
||||
: {}),
|
||||
scrollEventThrottle: 16,
|
||||
};
|
||||
const submit = useCallback(() => state.key("Enter"), [state]);
|
||||
const submit = useCallback(() => state.key({ key: "Enter" }), [state]);
|
||||
const handleOverlayKeyDown = useCallback(
|
||||
(event: KeyboardInputEvent) => {
|
||||
if (!state.key(event.key)) return false;
|
||||
if (!state.key(event)) return false;
|
||||
event.preventDefault();
|
||||
return true;
|
||||
},
|
||||
[state],
|
||||
);
|
||||
const setOverlayScope = useOverlayRegistration({
|
||||
keys: ["Escape", "Enter", "ArrowUp", "ArrowDown"],
|
||||
keys: ["Escape", "Enter", "ArrowUp", "ArrowDown", "Tab"],
|
||||
active: state.open,
|
||||
layer: modalLayer,
|
||||
onKeyDown: handleOverlayKeyDown,
|
||||
});
|
||||
|
||||
const tabOptions = useMemo<SegmentedControlOption<CommandCenterTab>[]>(
|
||||
() =>
|
||||
state.tabs.map((tab) => ({
|
||||
value: tab,
|
||||
label: t(`shell.commandCenter.tabs.${tab}`),
|
||||
testID: `command-center-tab-${tab}`,
|
||||
})),
|
||||
[state.tabs, t],
|
||||
);
|
||||
const tabs = (
|
||||
<SegmentedControl
|
||||
options={tabOptions}
|
||||
value={state.tab}
|
||||
onValueChange={state.selectTab}
|
||||
size="sm"
|
||||
testID="command-center-tabs"
|
||||
/>
|
||||
);
|
||||
const placeholder = t(placeholderKey(state.tab, state.tabs));
|
||||
|
||||
if (showBottomSheet) {
|
||||
return (
|
||||
<IsolatedBottomSheetModal
|
||||
@@ -910,32 +1064,28 @@ export function CommandCenter() {
|
||||
accessible={false}
|
||||
>
|
||||
<OverlayLayerProvider layer={modalLayer}>
|
||||
<View style={[styles.bottomSheetHeader, styles.searchRow]} testID="command-center-header">
|
||||
{state.scope === "files" ? (
|
||||
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
|
||||
) : null}
|
||||
<ThemedBottomSheetTextInput
|
||||
testID="command-center-input"
|
||||
ref={bottomSheetInputRef}
|
||||
initialValue={state.query}
|
||||
variant="bottom-sheet"
|
||||
onChangeText={state.setQuery}
|
||||
onSubmitEditing={submit}
|
||||
placeholder={
|
||||
state.scope === "files"
|
||||
? t("shell.commandCenter.filePlaceholder")
|
||||
: t("shell.commandCenter.placeholder")
|
||||
}
|
||||
style={[styles.input, styles.growingInput]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
<FileSearchLoadingIndicator
|
||||
loading={state.fileSearchLoading}
|
||||
label={t("shell.commandCenter.searchingFiles")}
|
||||
/>
|
||||
<View style={styles.header} testID="command-center-header">
|
||||
<View style={styles.searchRow}>
|
||||
<ThemedBottomSheetTextInput
|
||||
testID="command-center-input"
|
||||
ref={bottomSheetInputRef}
|
||||
initialValue={state.query}
|
||||
variant="bottom-sheet"
|
||||
onChangeText={state.setQuery}
|
||||
onSubmitEditing={submit}
|
||||
placeholder={placeholder}
|
||||
style={[styles.input, styles.growingInput]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
<FileSearchLoadingIndicator
|
||||
loading={state.fileSearchLoading}
|
||||
label={t("shell.commandCenter.searchingFiles")}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.tabBar}>{tabs}</View>
|
||||
{fileSearchError}
|
||||
<BottomSheetFlatList ref={bottomSheetListRef} {...commonListProps} />
|
||||
</OverlayLayerProvider>
|
||||
@@ -949,30 +1099,26 @@ export function CommandCenter() {
|
||||
<View style={styles.overlay}>
|
||||
<Pressable style={styles.backdrop} onPress={state.close} />
|
||||
<View ref={setOverlayScope} testID="command-center-panel" style={styles.panel}>
|
||||
<View style={[styles.header, styles.searchRow]} testID="command-center-header">
|
||||
{state.scope === "files" ? (
|
||||
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
|
||||
) : null}
|
||||
<ThemedTextInput
|
||||
testID="command-center-input"
|
||||
ref={state.inputRef}
|
||||
initialValue={state.query}
|
||||
onChangeText={state.setQuery}
|
||||
placeholder={
|
||||
state.scope === "files"
|
||||
? t("shell.commandCenter.filePlaceholder")
|
||||
: t("shell.commandCenter.placeholder")
|
||||
}
|
||||
style={[styles.input, styles.growingInput]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
<FileSearchLoadingIndicator
|
||||
loading={state.fileSearchLoading}
|
||||
label={t("shell.commandCenter.searchingFiles")}
|
||||
/>
|
||||
<View style={styles.header} testID="command-center-header">
|
||||
<View style={styles.searchRow}>
|
||||
<ThemedTextInput
|
||||
testID="command-center-input"
|
||||
ref={state.inputRef}
|
||||
initialValue={state.query}
|
||||
onChangeText={state.setQuery}
|
||||
placeholder={placeholder}
|
||||
style={[styles.input, styles.growingInput]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
<FileSearchLoadingIndicator
|
||||
loading={state.fileSearchLoading}
|
||||
label={t("shell.commandCenter.searchingFiles")}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.tabBar}>{tabs}</View>
|
||||
{fileSearchError}
|
||||
<FlatList ref={listRef} {...commonListProps} />
|
||||
</View>
|
||||
@@ -998,25 +1144,6 @@ function FileSearchLoadingIndicator({ loading, label }: { loading: boolean; labe
|
||||
);
|
||||
}
|
||||
|
||||
// The chip sits at the input's own height and type size so it reads as the scope of the field
|
||||
// rather than a badge dropped into the query. Its 28px matches the input's line box, so showing or
|
||||
// dropping the scope cannot resize the header.
|
||||
function ScopeChip({ label, onRemove }: { label: string; onRemove(): void }) {
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
onPress={onRemove}
|
||||
style={styles.scopeChip}
|
||||
testID="command-center-files-scope"
|
||||
>
|
||||
<ThemedFolder size={14} strokeWidth={2.2} />
|
||||
<Text style={styles.scopeChipLabel}>{label}</Text>
|
||||
<ThemedX size={12} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
@@ -1043,11 +1170,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
bottomSheetHeader: {
|
||||
// Below the divider, above the list. The first section label brings its own top gap, so the
|
||||
// pills sit with equal space above and below. A fixed bar, so switching tabs never moves the
|
||||
// field or the list.
|
||||
tabBar: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
paddingTop: COMMAND_CENTER_SECTION_GAP,
|
||||
},
|
||||
input: {
|
||||
fontSize: theme.fontSize.base,
|
||||
@@ -1058,25 +1186,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
growingInput: { flex: 1, minWidth: 0 },
|
||||
searchRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
|
||||
fileSearchStatus: { width: 14, height: 14, alignItems: "center", justifyContent: "center" },
|
||||
scopeChip: {
|
||||
// Stretching to the row rather than setting a height keeps the chip exactly as tall as the
|
||||
// input's line box, so showing or dropping the scope can never resize the header.
|
||||
alignSelf: "stretch",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1.5],
|
||||
marginRight: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
scopeChipLabel: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: "500",
|
||||
},
|
||||
results: { flex: 1 },
|
||||
// Sections and rows take their height from list-metrics; these styles only place content in it.
|
||||
section: { justifyContent: "flex-end" },
|
||||
@@ -1129,6 +1238,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
subtitleLine: { flexDirection: "row", minWidth: 0 },
|
||||
subtitleText: { flexShrink: 1, minWidth: 0 },
|
||||
agentLine: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1.5],
|
||||
minWidth: 0,
|
||||
height: COMMAND_CENTER_LINE_HEIGHT.agent,
|
||||
},
|
||||
agentLineIcon: { width: 12, alignItems: "center", justifyContent: "center" },
|
||||
iconSlot: {
|
||||
width: 16,
|
||||
height: COMMAND_CENTER_LINE_HEIGHT.singleLine,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createCommandCenterInteraction } from "./interaction";
|
||||
import { projectCommandCenterRows, type CommandCenterFileResult } from "./results";
|
||||
import type { CommandCenterScope } from "@/stores/keyboard-shortcuts-store";
|
||||
import type { CommandCenterTab } from "./tabs";
|
||||
|
||||
function results(ids = ["a", "b", "c", "d"]) {
|
||||
return projectCommandCenterRows(
|
||||
@@ -21,17 +21,17 @@ function results(ids = ["a", "b", "c", "d"]) {
|
||||
})),
|
||||
},
|
||||
],
|
||||
{ singleLineRow: 40, twoLineRow: 60, sectionTitle: 20, sectionDivider: 10 },
|
||||
{ singleLineRow: 40, twoLineRow: 60, threeLineRow: 80, sectionTitle: 20, sectionDivider: 10 },
|
||||
);
|
||||
}
|
||||
|
||||
function setup(options: { scope?: CommandCenterScope; measured?: boolean } = {}) {
|
||||
function setup(options: { tab?: CommandCenterTab; measured?: boolean } = {}) {
|
||||
const offsets: number[] = [];
|
||||
const model = createCommandCenterInteraction((offset) => offsets.push(offset));
|
||||
const publish = (ids = ["a", "b", "c", "d"], loading = false) =>
|
||||
model.update({
|
||||
open: true,
|
||||
scope: options.scope ?? null,
|
||||
tab: options.tab ?? "all",
|
||||
query: model.getSnapshot().query,
|
||||
projection: results(ids),
|
||||
loading,
|
||||
@@ -53,6 +53,45 @@ describe("Command Center interaction", () => {
|
||||
expect(offsets).toEqual([]);
|
||||
});
|
||||
|
||||
it("follows the first result while results load, so Enter does not run what rendered first", () => {
|
||||
const { model, publish } = setup();
|
||||
model.setQuery("");
|
||||
// Recent workspaces lead All and arrive after the actions below them.
|
||||
publish(["new-workspace", "settings"], true);
|
||||
expect(model.getSnapshot().activeId).toBe("new-workspace");
|
||||
publish(["recent", "new-workspace", "settings"]);
|
||||
expect(model.getSnapshot().activeId).toBe("recent");
|
||||
});
|
||||
|
||||
it("keeps the selected row when settled results reorder", () => {
|
||||
const { model, publish } = setup();
|
||||
expect(model.getSnapshot().activeId).toBe("a");
|
||||
// Another workspace's agent changed status, so it moves to the top of Recent workspaces.
|
||||
publish(["b", "a", "c", "d"]);
|
||||
expect(model.getSnapshot().activeId).toBe("a");
|
||||
});
|
||||
|
||||
it("keeps the selected row when results land above it after the query settled", () => {
|
||||
const { model, publish } = setup();
|
||||
model.setQuery("invoice");
|
||||
// Files arrive while archived workspaces are still being searched.
|
||||
publish(["file-1", "file-2"], true);
|
||||
expect(model.getSnapshot().activeId).toBe("file-1");
|
||||
publish(["archived", "file-1", "file-2"]);
|
||||
expect(model.getSnapshot().activeId).toBe("archived");
|
||||
publish(["archived-2", "archived", "file-1", "file-2"]);
|
||||
expect(model.getSnapshot().activeId).toBe("archived");
|
||||
});
|
||||
|
||||
it("keeps the user's pick while results are still loading", () => {
|
||||
const { model, publish } = setup();
|
||||
model.setQuery("q");
|
||||
publish(["a", "b"], true);
|
||||
model.move("next");
|
||||
publish(["z", "a", "b"], true);
|
||||
expect(model.getSnapshot().activeId).toBe("b");
|
||||
});
|
||||
|
||||
it("reveals only clipped keyboard selections and wraps around", () => {
|
||||
const { model, offsets } = setup();
|
||||
expect(model.move("next")).toBe(true);
|
||||
@@ -120,7 +159,7 @@ describe("Command Center interaction", () => {
|
||||
});
|
||||
|
||||
it("waits for scoped search results instead of revealing the previous query's rows", () => {
|
||||
const { model, offsets, publish } = setup({ scope: "files" });
|
||||
const { model, offsets, publish } = setup({ tab: "files" });
|
||||
model.scrolled(100);
|
||||
model.setQuery("new");
|
||||
publish(["a", "b"], true);
|
||||
@@ -132,7 +171,7 @@ describe("Command Center interaction", () => {
|
||||
});
|
||||
|
||||
it("manual interaction cancels a reveal awaiting search results", () => {
|
||||
const { model, offsets, publish } = setup({ scope: "files" });
|
||||
const { model, offsets, publish } = setup({ tab: "files" });
|
||||
model.setQuery("new");
|
||||
publish([], true);
|
||||
model.beginManualScroll();
|
||||
@@ -153,13 +192,29 @@ describe("Command Center interaction", () => {
|
||||
expect(offsets).toEqual([]);
|
||||
});
|
||||
|
||||
it("scope changes reset selection and request a reveal", () => {
|
||||
it("tab changes select the first result and return the list to the top", () => {
|
||||
const { model, offsets } = setup();
|
||||
model.move("next");
|
||||
model.move("next");
|
||||
model.move("next");
|
||||
model.scrolled(100);
|
||||
model.update({ open: true, scope: "files", query: "", projection: results(), loading: false });
|
||||
model.update({ open: true, tab: "files", query: "", projection: results(), loading: false });
|
||||
expect(model.getSnapshot().activeId).toBe("a");
|
||||
expect(offsets).toEqual([20, 20]);
|
||||
expect(offsets.at(-1)).toBe(0);
|
||||
});
|
||||
|
||||
it("tab changes keep the query", () => {
|
||||
const { model, publish } = setup();
|
||||
model.setQuery("b");
|
||||
publish(["b"]);
|
||||
model.update({
|
||||
open: true,
|
||||
tab: "workspaces",
|
||||
query: "b",
|
||||
projection: results(["b"]),
|
||||
loading: false,
|
||||
});
|
||||
expect(model.getSnapshot()).toEqual({ query: "b", activeId: "b" });
|
||||
});
|
||||
|
||||
it("close discards the old query, metrics, and deferred reveal", () => {
|
||||
@@ -167,7 +222,7 @@ describe("Command Center interaction", () => {
|
||||
model.setQuery("old");
|
||||
publish();
|
||||
model.move("previous");
|
||||
model.update({ open: false, scope: null, query: "old", projection: results(), loading: false });
|
||||
model.update({ open: false, tab: "all", query: "old", projection: results(), loading: false });
|
||||
model.layout(80);
|
||||
expect(model.getSnapshot()).toEqual({ query: "", activeId: null });
|
||||
expect(offsets).toEqual([]);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { CommandCenterScope } from "@/stores/keyboard-shortcuts-store";
|
||||
import {
|
||||
moveActiveResultId,
|
||||
preserveActiveResultId,
|
||||
type CommandCenterListProjection,
|
||||
} from "./results";
|
||||
import type { CommandCenterTab } from "./tabs";
|
||||
|
||||
interface Snapshot {
|
||||
query: string;
|
||||
@@ -12,21 +12,28 @@ interface Snapshot {
|
||||
|
||||
interface ResultsInput {
|
||||
open: boolean;
|
||||
scope: CommandCenterScope;
|
||||
tab: CommandCenterTab;
|
||||
query: string;
|
||||
projection: CommandCenterListProjection;
|
||||
/** A source of this query's and tab's results is still loading. */
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
/** Selection persists; permission to move the viewport lasts for one user command. */
|
||||
/**
|
||||
* While a query's or tab's results are still loading, the selection follows the first result,
|
||||
* so Enter right after opening runs what ends up on top rather than whatever rendered first.
|
||||
* Once they have all loaded, or the user moves the selection, it stays on its row by ID through
|
||||
* any later reordering. Permission to move the viewport lasts for one user command.
|
||||
*/
|
||||
export function createCommandCenterInteraction(scrollToOffset: (offset: number) => void) {
|
||||
let snapshot: Snapshot = { query: "", activeId: null };
|
||||
const listeners = new Set<() => void>();
|
||||
let open = false;
|
||||
let scope: CommandCenterScope = null;
|
||||
let tab: CommandCenterTab = "all";
|
||||
let projection: CommandCenterListProjection | null = null;
|
||||
let loading = false;
|
||||
let revealPending = false;
|
||||
let followingFirst = true;
|
||||
let offset = 0;
|
||||
let visibleLength = 0;
|
||||
|
||||
@@ -38,8 +45,8 @@ export function createCommandCenterInteraction(scrollToOffset: (offset: number)
|
||||
|
||||
function reveal() {
|
||||
if (!open || !revealPending || !projection || visibleLength <= 0) return;
|
||||
// Scoped file search keeps its previous entries while the next query is loading.
|
||||
if (scope === "files" && loading) return;
|
||||
// The Files tab keeps its previous entries while the next query is loading.
|
||||
if (tab === "files" && loading) return;
|
||||
if (!snapshot.activeId) {
|
||||
if (!loading) revealPending = false;
|
||||
return;
|
||||
@@ -67,9 +74,10 @@ export function createCommandCenterInteraction(scrollToOffset: (offset: number)
|
||||
};
|
||||
},
|
||||
update(input: ResultsInput) {
|
||||
const resetSelection = input.open && (!open || scope !== input.scope);
|
||||
const tabChanged = open && input.open && tab !== input.tab;
|
||||
const resetSelection = input.open && (!open || tabChanged);
|
||||
open = input.open;
|
||||
scope = input.scope;
|
||||
tab = input.tab;
|
||||
if (!open) {
|
||||
projection = null;
|
||||
revealPending = false;
|
||||
@@ -80,33 +88,44 @@ export function createCommandCenterInteraction(scrollToOffset: (offset: number)
|
||||
}
|
||||
if (resetSelection) {
|
||||
revealPending = true;
|
||||
followingFirst = true;
|
||||
publish({ ...snapshot, activeId: null });
|
||||
}
|
||||
// A tab is a different list: it starts at its top, section label included.
|
||||
if (tabChanged && offset !== 0) {
|
||||
offset = 0;
|
||||
scrollToOffset(0);
|
||||
}
|
||||
// A query command invalidates the old projection until React supplies its new results.
|
||||
if (input.query !== snapshot.query) return;
|
||||
projection = input.projection;
|
||||
loading = input.loading;
|
||||
if (scope !== "files" || !loading) {
|
||||
if (tab !== "files" || !loading) {
|
||||
publish({
|
||||
...snapshot,
|
||||
activeId: preserveActiveResultId(snapshot.activeId, projection.selectableResults),
|
||||
activeId: followingFirst
|
||||
? (projection.selectableResults[0]?.id ?? null)
|
||||
: preserveActiveResultId(snapshot.activeId, projection.selectableResults),
|
||||
});
|
||||
if (!loading) followingFirst = false;
|
||||
}
|
||||
reveal();
|
||||
},
|
||||
setQuery(query: string) {
|
||||
projection = null;
|
||||
revealPending = true;
|
||||
followingFirst = true;
|
||||
publish({ query, activeId: null });
|
||||
},
|
||||
move(direction: "next" | "previous"): boolean {
|
||||
if (
|
||||
!open ||
|
||||
!projection ||
|
||||
(scope === "files" && loading) ||
|
||||
(tab === "files" && loading) ||
|
||||
projection.selectableResults.length === 0
|
||||
)
|
||||
return false;
|
||||
followingFirst = false;
|
||||
publish({
|
||||
...snapshot,
|
||||
activeId: moveActiveResultId(snapshot.activeId, projection.selectableResults, direction),
|
||||
|
||||
@@ -16,6 +16,8 @@ export const COMMAND_CENTER_LINE_HEIGHT = {
|
||||
singleLine: 20,
|
||||
title: 18,
|
||||
subtitle: 16,
|
||||
/** The matched-agent line under a workspace's subtitle. */
|
||||
agent: 16,
|
||||
sectionLabel: 16,
|
||||
} as const;
|
||||
|
||||
@@ -26,6 +28,8 @@ const SECTION_DIVIDER_THICKNESS = 1;
|
||||
export interface CommandCenterListMetrics {
|
||||
singleLineRow: number;
|
||||
twoLineRow: number;
|
||||
/** A workspace row that also names the agent the query matched. */
|
||||
threeLineRow: number;
|
||||
/** A section label and the gaps around it. */
|
||||
sectionTitle: number;
|
||||
/** The rule between two sections and the gaps around it. */
|
||||
@@ -37,6 +41,7 @@ function listMetrics(rowInset: number): CommandCenterListMetrics {
|
||||
return {
|
||||
singleLineRow: rowInset + line.singleLine + rowInset,
|
||||
twoLineRow: rowInset + line.title + line.subtitle + rowInset,
|
||||
threeLineRow: rowInset + line.title + line.subtitle + line.agent + rowInset,
|
||||
sectionTitle: COMMAND_CENTER_SECTION_GAP + line.sectionLabel + COMMAND_CENTER_SECTION_GAP,
|
||||
sectionDivider:
|
||||
COMMAND_CENTER_SECTION_GAP + SECTION_DIVIDER_THICKNESS + COMMAND_CENTER_SECTION_GAP,
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { CommandCenterContribution } from "./contributions";
|
||||
import { COMMAND_CENTER_LIST_METRICS } from "./list-metrics";
|
||||
import {
|
||||
buildContributionSections,
|
||||
filterAndRankBuiltInResults,
|
||||
joinSubtitleParts,
|
||||
moveActiveResultId,
|
||||
PINNED_SECTION_BAND,
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
projectCommandCenterRows,
|
||||
type CommandCenterWorkspaceResult,
|
||||
} from "./results";
|
||||
import { createWorkspaceRanker } from "./workspace-search";
|
||||
|
||||
function contribution(input: {
|
||||
id: string;
|
||||
@@ -118,7 +118,12 @@ function workspace(
|
||||
project: { viewKey: "project", name: "project", iconDataUri: null },
|
||||
changeRequestNumber: null,
|
||||
activityAt,
|
||||
agents: [],
|
||||
matchedAgent: null,
|
||||
archived: false,
|
||||
background: false,
|
||||
run: () => undefined,
|
||||
openAgent: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -184,6 +189,40 @@ describe("Command Center result projection", () => {
|
||||
expect(sectionResultIds(buildContributionSections(contributions, "home"))).toEqual(["home"]);
|
||||
});
|
||||
|
||||
it("lists query-only actions without a query when asked to browse them", () => {
|
||||
const contributions = [
|
||||
contribution({ id: "settings", group: "actions", groupRank: 0 }),
|
||||
contribution({ id: "opus", group: "models", groupRank: 1, visibility: "query" }),
|
||||
];
|
||||
|
||||
expect(
|
||||
sectionResultIds(buildContributionSections(contributions, "", { includeQueryOnly: true })),
|
||||
).toEqual(["settings", "opus"]);
|
||||
});
|
||||
|
||||
it("gives a workspace row matched through an agent room for its agent line", () => {
|
||||
const matched = {
|
||||
...workspace("workspace:matched"),
|
||||
matchedAgent: { id: "agent", title: "Add receipt emails", provider: "mock" },
|
||||
};
|
||||
const projection = projectCommandCenterRows(
|
||||
[
|
||||
{
|
||||
id: "workspaces",
|
||||
band: PINNED_SECTION_BAND,
|
||||
rank: 2,
|
||||
results: [workspace("workspace:plain"), matched],
|
||||
},
|
||||
],
|
||||
metrics,
|
||||
);
|
||||
expect(projection.rows.map((row) => row.height)).toEqual([
|
||||
metrics.twoLineRow,
|
||||
metrics.threeLineRow,
|
||||
]);
|
||||
expect(metrics.threeLineRow).toBeGreaterThan(metrics.twoLineRow);
|
||||
});
|
||||
|
||||
it("preserves active selection by id and falls back to the first result", () => {
|
||||
const first = workspace("workspace:first");
|
||||
const second = workspace("workspace:second");
|
||||
@@ -486,25 +525,9 @@ describe("Command Center query tokenization", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterAndRankBuiltInResults", () => {
|
||||
const visibleOnly = (row: CommandCenterWorkspaceResult) => ({
|
||||
visible: [row.title, row.subtitle],
|
||||
hidden: [],
|
||||
});
|
||||
|
||||
it("shows the five most recent rows for an empty query", () => {
|
||||
const rows = Array.from({ length: 8 }, (_, index) =>
|
||||
workspace(`w${index}`, `w${index}`, "host", minutesAgo(8 - index)),
|
||||
);
|
||||
|
||||
expect(filterAndRankBuiltInResults(rows, "", visibleOnly).map((row) => row.id)).toEqual([
|
||||
"w7",
|
||||
"w6",
|
||||
"w5",
|
||||
"w4",
|
||||
"w3",
|
||||
]);
|
||||
});
|
||||
describe("workspace ranking", () => {
|
||||
const rank = (rows: readonly CommandCenterWorkspaceResult[], query: string) =>
|
||||
createWorkspaceRanker()(rows, query).map((row) => row.id);
|
||||
|
||||
it("puts the best match first even when it sorts last alphabetically", () => {
|
||||
const rows = [
|
||||
@@ -514,22 +537,7 @@ describe("filterAndRankBuiltInResults", () => {
|
||||
];
|
||||
|
||||
// Alphabetically this is Alpha, Label, Zebra. "lab des" only matches Label as Design.
|
||||
expect(filterAndRankBuiltInResults(rows, "lab des", visibleOnly).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).map((row) => row.id)).toEqual([
|
||||
"title-hit",
|
||||
"hidden-hit",
|
||||
]);
|
||||
expect(rank(rows, "lab des")).toEqual(["c"]);
|
||||
});
|
||||
|
||||
it("orders rows in the same match tier by recency, not by where the word sits", () => {
|
||||
@@ -539,10 +547,7 @@ describe("filterAndRankBuiltInResults", () => {
|
||||
workspace("newer", "design the cli interface to expose usage data", "host", minutesAgo(5)),
|
||||
];
|
||||
|
||||
expect(filterAndRankBuiltInResults(rows, "usage", visibleOnly).map((row) => row.id)).toEqual([
|
||||
"newer",
|
||||
"older",
|
||||
]);
|
||||
expect(rank(rows, "usage")).toEqual(["newer", "older"]);
|
||||
});
|
||||
|
||||
it("keeps a better match tier above a more recent weaker match", () => {
|
||||
@@ -551,16 +556,13 @@ describe("filterAndRankBuiltInResults", () => {
|
||||
workspace("whole-word", "Usage hover card", "host", minutesAgo(600)),
|
||||
];
|
||||
|
||||
expect(filterAndRankBuiltInResults(rows, "usage", visibleOnly).map((row) => row.id)).toEqual([
|
||||
"whole-word",
|
||||
"subsequence",
|
||||
]);
|
||||
expect(rank(rows, "usage")).toEqual(["whole-word", "subsequence"]);
|
||||
});
|
||||
|
||||
it("drops a row when one token matches nothing", () => {
|
||||
const rows = [workspace("a", "Label as Design")];
|
||||
|
||||
expect(filterAndRankBuiltInResults(rows, "lab zzz", visibleOnly)).toEqual([]);
|
||||
expect(rank(rows, "lab zzz")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import {
|
||||
compareMatchScores,
|
||||
type MatchClass,
|
||||
type MatchScore,
|
||||
scoreTextFields,
|
||||
textFieldsMatchClass,
|
||||
} from "@getpaseo/protocol/search/text-match";
|
||||
import type { CommandCenterContribution } from "./contributions";
|
||||
import type { CommandCenterListMetrics } from "./list-metrics";
|
||||
|
||||
/** Sections pinned above every contribution section. */
|
||||
export const LEADING_SECTION_BAND = -1;
|
||||
/** 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. */
|
||||
@@ -50,6 +53,13 @@ export interface CommandCenterWorkspaceProject {
|
||||
iconDataUri: string | null;
|
||||
}
|
||||
|
||||
/** An agent a workspace can be found by. Agents are never results of their own. */
|
||||
export interface CommandCenterWorkspaceAgent {
|
||||
id: string;
|
||||
title: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
export interface CommandCenterWorkspaceResult {
|
||||
kind: "workspace";
|
||||
id: string;
|
||||
@@ -60,18 +70,19 @@ export interface CommandCenterWorkspaceResult {
|
||||
project: CommandCenterWorkspaceProject;
|
||||
/** PR/MR number backing this workspace, so a number query can match it exactly. */
|
||||
changeRequestNumber: number | null;
|
||||
/** Last activity, or when it was archived for an archived workspace. */
|
||||
activityAt: Date | null;
|
||||
/** The workspace's agents, most recent first. Part of what the workspace matches on. */
|
||||
agents: readonly CommandCenterWorkspaceAgent[];
|
||||
/** The agent the query matched better than the workspace's own text. Drawn as a third line. */
|
||||
matchedAgent: CommandCenterWorkspaceAgent | null;
|
||||
archived: boolean;
|
||||
/** Started by automation rather than the user. Found by a query, never listed as recent. */
|
||||
background: boolean;
|
||||
/** Opens the workspace and lets its default tab selection apply. */
|
||||
run(): void;
|
||||
}
|
||||
|
||||
export interface CommandCenterAgentResult {
|
||||
kind: "agent";
|
||||
id: string;
|
||||
agent: AggregatedAgent;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
activityAt: Date | null;
|
||||
run(): void;
|
||||
/** Opens the workspace on this agent's tab. */
|
||||
openAgent(agentId: string): void;
|
||||
}
|
||||
|
||||
export interface CommandCenterFileResult {
|
||||
@@ -94,13 +105,12 @@ export interface CommandCenterContributionResult {
|
||||
|
||||
export type CommandCenterResult =
|
||||
| CommandCenterWorkspaceResult
|
||||
| CommandCenterAgentResult
|
||||
| CommandCenterFileResult
|
||||
| CommandCenterContributionResult;
|
||||
|
||||
export interface CommandCenterResultSection {
|
||||
id: string;
|
||||
/** CONTRIBUTION_SECTION_BAND or PINNED_SECTION_BAND. Compared before anything else. */
|
||||
/** LEADING, CONTRIBUTION or PINNED_SECTION_BAND. Compared before anything else. */
|
||||
band: number;
|
||||
rank: number;
|
||||
title?: string;
|
||||
@@ -162,6 +172,14 @@ export function scoreSearchFields(
|
||||
return match ? { fieldRank: KEYWORD_FIELD_RANK, match } : null;
|
||||
}
|
||||
|
||||
/** How closely the query matched the fields; see `MatchClass`. Null when it does not match. */
|
||||
export function searchFieldsMatchClass(
|
||||
query: string,
|
||||
fields: CommandCenterSearchFields,
|
||||
): MatchClass | null {
|
||||
return textFieldsMatchClass(query, [...fields.visible, ...fields.hidden]);
|
||||
}
|
||||
|
||||
/** Join non-empty subtitle parts with " · ", dropping null/undefined/empty. */
|
||||
export function joinSubtitleParts(parts: readonly (string | null | undefined)[]): string {
|
||||
return parts.filter((part): part is string => Boolean(part)).join(" · ");
|
||||
@@ -181,12 +199,18 @@ function contributionSearchFields(
|
||||
return { visible, hidden: contribution.keywords };
|
||||
}
|
||||
|
||||
/** Whether the row draws a second, subtitle line under its title. */
|
||||
function hasSubtitleLine(result: CommandCenterResult): boolean {
|
||||
if (result.kind === "workspace" || result.kind === "agent") return true;
|
||||
if (result.kind === "file") return false;
|
||||
/** How many lines of text the row draws, which decides its height. */
|
||||
function resultLineCount(result: CommandCenterResult): 1 | 2 | 3 {
|
||||
if (result.kind === "workspace") return result.matchedAgent ? 3 : 2;
|
||||
if (result.kind === "file") return 1;
|
||||
const presentation = result.contribution.presentation;
|
||||
return presentation.kind === "action" && Boolean(presentation.subtitle);
|
||||
return presentation.kind === "action" && presentation.subtitle ? 2 : 1;
|
||||
}
|
||||
|
||||
function resultHeight(result: CommandCenterResult, metrics: CommandCenterListMetrics): number {
|
||||
const lines = resultLineCount(result);
|
||||
if (lines === 3) return metrics.threeLineRow;
|
||||
return lines === 2 ? metrics.twoLineRow : metrics.singleLineRow;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -211,10 +235,7 @@ function resultScore(result: CommandCenterResult): CommandCenterScore | undefine
|
||||
return result.kind === "contribution" ? result.score : undefined;
|
||||
}
|
||||
|
||||
/** Rows shown when nothing has been typed, per pinned category. */
|
||||
const DEFAULT_CATEGORY_RESULT_LIMIT = 5;
|
||||
|
||||
/** A built-in row (workspace or agent) that orders by how recently it saw activity. */
|
||||
/** A built-in row that orders by how recently it saw activity. */
|
||||
export interface CommandCenterRecentRow {
|
||||
activityAt: Date | null;
|
||||
}
|
||||
@@ -235,44 +256,20 @@ export function compareBuiltInScores(left: CommandCenterScore, right: CommandCen
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Rows rank by `compareBuiltInScores`; rows that rank equally, including every row of an empty
|
||||
* query, go most recent first.
|
||||
* Group contributions into their sections, ranked by the query. `includeQueryOnly` lists the
|
||||
* contributions that otherwise wait for a query, for a list whose whole job is browsing them.
|
||||
*/
|
||||
export function filterAndRankBuiltInResults<Row extends CommandCenterRecentRow>(
|
||||
rows: readonly Row[],
|
||||
query: string,
|
||||
fieldsOf: (row: Row) => CommandCenterSearchFields,
|
||||
): Row[] {
|
||||
if (!query.trim()) {
|
||||
return [...rows].sort(compareByRecency).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) =>
|
||||
compareBuiltInScores(left.score, right.score) || compareByRecency(left.row, right.row),
|
||||
);
|
||||
return scored.map((entry) => entry.row);
|
||||
}
|
||||
|
||||
export function buildContributionSections(
|
||||
contributions: readonly CommandCenterContribution[],
|
||||
query: string,
|
||||
options: { includeQueryOnly?: boolean } = {},
|
||||
): CommandCenterResultSection[] {
|
||||
const groups = new Map<string, MutableCommandCenterResultSection>();
|
||||
const registryIndexById = new Map<string, number>();
|
||||
const hasQuery = Boolean(query.trim());
|
||||
|
||||
for (const [index, contribution] of contributions.entries()) {
|
||||
if (contribution.visibility === "query" && !hasQuery) continue;
|
||||
if (contribution.visibility === "query" && !hasQuery && !options.includeQueryOnly) continue;
|
||||
let score: CommandCenterScore | undefined;
|
||||
if (hasQuery) {
|
||||
// The score is the filter: null means no token found a home, so the row is out.
|
||||
@@ -351,7 +348,7 @@ export function projectCommandCenterRows(
|
||||
offset += sectionHeight;
|
||||
}
|
||||
for (const result of section.results) {
|
||||
const height = hasSubtitleLine(result) ? metrics.twoLineRow : metrics.singleLineRow;
|
||||
const height = resultHeight(result, metrics);
|
||||
offsets.push(offset);
|
||||
rowIndexByResultId.set(result.id, rows.length);
|
||||
rows.push({ kind: "result", key: result.id, result, height });
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CommandCenterContribution } from "./contributions";
|
||||
import type { CommandCenterFileResult, CommandCenterWorkspaceResult } from "./results";
|
||||
import { COMMAND_CENTER_LIST_METRICS } from "./list-metrics";
|
||||
import { projectCommandCenterRows } from "./results";
|
||||
import { buildCommandCenterSections } from "./sections";
|
||||
import type { CommandCenterTab } from "./tabs";
|
||||
import { createWorkspaceRanker } from "./workspace-search";
|
||||
|
||||
function workspace(
|
||||
id: string,
|
||||
options: {
|
||||
daysAgo?: number;
|
||||
title?: string;
|
||||
archived?: boolean;
|
||||
background?: boolean;
|
||||
agentTitle?: string;
|
||||
} = {},
|
||||
): CommandCenterWorkspaceResult {
|
||||
return {
|
||||
kind: "workspace",
|
||||
id: `workspace:server:${id}`,
|
||||
serverId: "server",
|
||||
workspaceId: id,
|
||||
title: options.title ?? id,
|
||||
subtitle: "project · main",
|
||||
project: { viewKey: "project", name: "project", iconDataUri: null },
|
||||
changeRequestNumber: null,
|
||||
activityAt: new Date(Date.UTC(2026, 9, 30) - (options.daysAgo ?? 0) * 86_400_000),
|
||||
agents: options.agentTitle
|
||||
? [{ id: `${id}-agent`, title: options.agentTitle, provider: "mock" }]
|
||||
: [],
|
||||
matchedAgent: null,
|
||||
archived: options.archived ?? false,
|
||||
background: options.background ?? false,
|
||||
run: () => undefined,
|
||||
openAgent: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function action(id: string, group: string, visibility: "always" | "query" = "always") {
|
||||
return {
|
||||
id,
|
||||
group,
|
||||
groupRank: group === "workspace" ? 0 : 1,
|
||||
rank: 0,
|
||||
keywords: [],
|
||||
visibility,
|
||||
run: () => undefined,
|
||||
presentation: { kind: "action", title: id, sectionTitle: group },
|
||||
} satisfies CommandCenterContribution;
|
||||
}
|
||||
|
||||
const file: CommandCenterFileResult = {
|
||||
kind: "file",
|
||||
id: "file:src/index.ts",
|
||||
filePath: "src/index.ts",
|
||||
title: "index.ts",
|
||||
subtitle: "src",
|
||||
run: () => undefined,
|
||||
};
|
||||
|
||||
const workspaces = [
|
||||
workspace("current", { daysAgo: 0 }),
|
||||
workspace("payments", { daysAgo: 1, title: "Payments refactor" }),
|
||||
// Started by automation; more recent than the workspaces below it.
|
||||
workspace("nightly", { daysAgo: 1.5, title: "Nightly triage", background: true }),
|
||||
workspace("onboarding", { daysAgo: 2, title: "Onboarding flow", agentTitle: "Add receipts" }),
|
||||
workspace("docs", { daysAgo: 3, title: "Docs site" }),
|
||||
workspace("search", { daysAgo: 4, title: "Search ranking" }),
|
||||
];
|
||||
const archived = [workspace("billing", { title: "Billing cleanup", archived: true })];
|
||||
const contributions = [
|
||||
action("New agent", "workspace"),
|
||||
action("Settings", "actions"),
|
||||
action("Group by status", "actions", "query"),
|
||||
];
|
||||
|
||||
function listed(
|
||||
tab: CommandCenterTab,
|
||||
query: string,
|
||||
options: { archivedMatchedByHost?: boolean; showBackground?: boolean } = {},
|
||||
) {
|
||||
const sections = buildCommandCenterSections({
|
||||
tab,
|
||||
query,
|
||||
rankWorkspaces: createWorkspaceRanker(),
|
||||
workspaces,
|
||||
archivedWorkspaces: query ? archived : [],
|
||||
archivedMatchedByHost: options.archivedMatchedByHost ?? true,
|
||||
showBackground: options.showBackground ?? false,
|
||||
currentWorkspaceId: "workspace:server:current",
|
||||
contributions,
|
||||
files: [file],
|
||||
titles: { recentWorkspaces: "Recent workspaces", workspaces: "Workspaces", files: "Files" },
|
||||
});
|
||||
return projectCommandCenterRows(sections, COMMAND_CENTER_LIST_METRICS.desktop).rows.map((row) =>
|
||||
row.kind === "section" ? `# ${row.title ?? ""}` : row.key,
|
||||
);
|
||||
}
|
||||
|
||||
describe("command center tabs", () => {
|
||||
it("All leads with the three most recent workspaces, then actions, before a query", () => {
|
||||
expect(listed("all", "")).toEqual([
|
||||
"# Recent workspaces",
|
||||
"workspace:server:payments",
|
||||
"workspace:server:onboarding",
|
||||
"workspace:server:docs",
|
||||
"# workspace",
|
||||
"New agent",
|
||||
"# actions",
|
||||
"Settings",
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists background workspaces unqueried only as the sidebar does; a query finds them", () => {
|
||||
expect(listed("all", "")).not.toContain("workspace:server:nightly");
|
||||
expect(listed("all", "", { showBackground: true })).not.toContain("workspace:server:nightly");
|
||||
expect(listed("workspaces", "")).not.toContain("workspace:server:nightly");
|
||||
expect(listed("workspaces", "", { showBackground: true })).toContain(
|
||||
"workspace:server:nightly",
|
||||
);
|
||||
expect(listed("all", "nightly")).toContain("workspace:server:nightly");
|
||||
expect(listed("workspaces", "nightly")).toContain("workspace:server:nightly");
|
||||
});
|
||||
|
||||
it("All ranks every kind of result for a query, archived workspaces after active ones", () => {
|
||||
expect(listed("all", "i")).toEqual(
|
||||
expect.arrayContaining(["Settings", "file:src/index.ts", "workspace:server:billing"]),
|
||||
);
|
||||
const rows = listed("all", "ing");
|
||||
const workspaceRows = rows.filter((row) => row.startsWith("workspace:"));
|
||||
expect(workspaceRows.at(-1)).toBe("workspace:server:billing");
|
||||
expect(rows).not.toContain("# Recent workspaces");
|
||||
});
|
||||
|
||||
it("Workspaces lists every workspace, current included, most recent first", () => {
|
||||
expect(listed("workspaces", "")).toEqual([
|
||||
"# Workspaces",
|
||||
"workspace:server:current",
|
||||
"workspace:server:payments",
|
||||
"workspace:server:onboarding",
|
||||
"workspace:server:docs",
|
||||
"workspace:server:search",
|
||||
]);
|
||||
});
|
||||
|
||||
it("Workspaces finds a workspace through its agent and appends archived matches", () => {
|
||||
expect(listed("workspaces", "receipts")).toEqual([
|
||||
"# Workspaces",
|
||||
"workspace:server:onboarding",
|
||||
"workspace:server:billing",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps archived rows the host did not match for this query only if they match it", () => {
|
||||
// The previous query's archived results stand in while the next search loads.
|
||||
expect(listed("workspaces", "receipts", { archivedMatchedByHost: false })).toEqual([
|
||||
"# Workspaces",
|
||||
"workspace:server:onboarding",
|
||||
]);
|
||||
expect(listed("workspaces", "billing", { archivedMatchedByHost: false })).toEqual([
|
||||
"# Workspaces",
|
||||
"workspace:server:billing",
|
||||
]);
|
||||
});
|
||||
|
||||
it("Files lists only files", () => {
|
||||
expect(listed("files", "")).toEqual(["# Files", "file:src/index.ts"]);
|
||||
});
|
||||
|
||||
it("Actions lists every action, including those that otherwise wait for a query", () => {
|
||||
expect(listed("actions", "")).toEqual([
|
||||
"# workspace",
|
||||
"New agent",
|
||||
"# actions",
|
||||
"Settings",
|
||||
"Group by status",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { CommandCenterContribution } from "./contributions";
|
||||
import {
|
||||
buildContributionSections,
|
||||
LEADING_SECTION_BAND,
|
||||
PINNED_SECTION_BAND,
|
||||
type CommandCenterFileResult,
|
||||
type CommandCenterResultSection,
|
||||
type CommandCenterWorkspaceResult,
|
||||
} from "./results";
|
||||
import type { CommandCenterTab } from "./tabs";
|
||||
import type { WorkspaceRanker } from "./workspace-search";
|
||||
|
||||
/**
|
||||
* How many workspaces All leads with before anything is typed. Background workspaces are left
|
||||
* out, as the sidebar leaves them out by default.
|
||||
*/
|
||||
const RECENT_WORKSPACE_LIMIT = 3;
|
||||
|
||||
export interface CommandCenterSectionTitles {
|
||||
recentWorkspaces: string;
|
||||
workspaces: string;
|
||||
files: string;
|
||||
}
|
||||
|
||||
export interface CommandCenterSectionsInput {
|
||||
tab: CommandCenterTab;
|
||||
query: string;
|
||||
rankWorkspaces: WorkspaceRanker;
|
||||
workspaces: readonly CommandCenterWorkspaceResult[];
|
||||
/** Archived workspaces the host found for the query. Empty without a query. */
|
||||
archivedWorkspaces: readonly CommandCenterWorkspaceResult[];
|
||||
/**
|
||||
* The host matched the archived workspaces for this exact query. Its search forgives typos,
|
||||
* so its matches stay even when the client cannot score them. Results from the previous
|
||||
* query must match on their own.
|
||||
*/
|
||||
archivedMatchedByHost: boolean;
|
||||
/**
|
||||
* The sidebar's Show background preference. Without a query, Workspaces lists background
|
||||
* workspaces only when the sidebar does; a query finds them either way.
|
||||
*/
|
||||
showBackground: boolean;
|
||||
/** The open workspace's result ID. Recent workspaces leaves it out: the user is already there. */
|
||||
currentWorkspaceId: string | null;
|
||||
contributions: readonly CommandCenterContribution[];
|
||||
files: readonly CommandCenterFileResult[];
|
||||
titles: CommandCenterSectionTitles;
|
||||
}
|
||||
|
||||
function recentWorkspacesSection(input: CommandCenterSectionsInput): CommandCenterResultSection {
|
||||
const recent = input
|
||||
.rankWorkspaces(input.workspaces, "")
|
||||
.filter((workspace) => workspace.id !== input.currentWorkspaceId && !workspace.background)
|
||||
.slice(0, RECENT_WORKSPACE_LIMIT);
|
||||
return {
|
||||
id: "recent-workspaces",
|
||||
band: LEADING_SECTION_BAND,
|
||||
rank: 0,
|
||||
title: input.titles.recentWorkspaces,
|
||||
results: recent,
|
||||
};
|
||||
}
|
||||
|
||||
/** Active matches first; archived ones are appended, so active rows never move when they land. */
|
||||
function workspacesSection(input: CommandCenterSectionsInput): CommandCenterResultSection {
|
||||
const listed =
|
||||
input.query.trim() || input.showBackground
|
||||
? input.workspaces
|
||||
: input.workspaces.filter((workspace) => !workspace.background);
|
||||
const active = input.rankWorkspaces(listed, input.query);
|
||||
const archived = input.query.trim()
|
||||
? input.rankWorkspaces(input.archivedWorkspaces, input.query, {
|
||||
keepUnmatched: input.archivedMatchedByHost,
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
id: "workspaces",
|
||||
band: PINNED_SECTION_BAND,
|
||||
rank: 2,
|
||||
title: input.titles.workspaces,
|
||||
results: [...active, ...archived],
|
||||
};
|
||||
}
|
||||
|
||||
function filesSection(input: CommandCenterSectionsInput): CommandCenterResultSection {
|
||||
// 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: input.titles.files,
|
||||
results: input.files,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The sections a tab shows. All mixes every kind and leads with recent workspaces until
|
||||
* something is typed; every other tab lists everything of its one kind.
|
||||
*/
|
||||
export function buildCommandCenterSections(
|
||||
input: CommandCenterSectionsInput,
|
||||
): CommandCenterResultSection[] {
|
||||
switch (input.tab) {
|
||||
case "all": {
|
||||
const actions = buildContributionSections(input.contributions, input.query);
|
||||
if (!input.query.trim()) return [recentWorkspacesSection(input), ...actions];
|
||||
return [...actions, workspacesSection(input), filesSection(input)];
|
||||
}
|
||||
case "workspaces":
|
||||
return [workspacesSection(input)];
|
||||
case "files":
|
||||
return [filesSection(input)];
|
||||
case "actions":
|
||||
return buildContributionSections(input.contributions, input.query, {
|
||||
includeQueryOnly: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
availableCommandCenterTabs,
|
||||
resolveCommandCenterTab,
|
||||
rotateCommandCenterTab,
|
||||
type CommandCenterTab,
|
||||
} from "./tabs";
|
||||
|
||||
const IN_WORKSPACE = availableCommandCenterTabs({ hasWorkspace: true });
|
||||
const NO_WORKSPACE = availableCommandCenterTabs({ hasWorkspace: false });
|
||||
|
||||
function walk(available: readonly CommandCenterTab[], direction: "next" | "previous") {
|
||||
const walked: CommandCenterTab[] = ["all"];
|
||||
for (let step = 0; step < available.length; step += 1) {
|
||||
walked.push(rotateCommandCenterTab(walked[walked.length - 1], direction, available));
|
||||
}
|
||||
return walked;
|
||||
}
|
||||
|
||||
describe("command center tabs", () => {
|
||||
it("walks All, Workspaces, Files, Actions in a workspace and wraps", () => {
|
||||
expect(walk(IN_WORKSPACE, "next")).toEqual(["all", "workspaces", "files", "actions", "all"]);
|
||||
expect(walk(IN_WORKSPACE, "previous")).toEqual([
|
||||
"all",
|
||||
"actions",
|
||||
"files",
|
||||
"workspaces",
|
||||
"all",
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers no Files tab outside a workspace, and Tab skips it", () => {
|
||||
expect(NO_WORKSPACE).toEqual(["all", "workspaces", "actions"]);
|
||||
expect(walk(NO_WORKSPACE, "next")).toEqual(["all", "workspaces", "actions", "all"]);
|
||||
expect(walk(NO_WORKSPACE, "previous")).toEqual(["all", "actions", "workspaces", "all"]);
|
||||
});
|
||||
|
||||
it("opens a requested tab only when it is on offer", () => {
|
||||
expect(resolveCommandCenterTab("files", IN_WORKSPACE)).toBe("files");
|
||||
expect(resolveCommandCenterTab("files", NO_WORKSPACE)).toBe("all");
|
||||
expect(resolveCommandCenterTab("actions", NO_WORKSPACE)).toBe("actions");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* The command center's tabs, in the order Tab walks them. All shows every kind of result; each
|
||||
* other tab shows one kind.
|
||||
*/
|
||||
const COMMAND_CENTER_TABS = ["all", "workspaces", "files", "actions"] as const;
|
||||
|
||||
export type CommandCenterTab = (typeof COMMAND_CENTER_TABS)[number];
|
||||
|
||||
/**
|
||||
* The tabs on offer, in the order Tab walks them. Files searches the open workspace, so it is
|
||||
* offered only when a workspace is open; without one, All lists no files either.
|
||||
*/
|
||||
export function availableCommandCenterTabs(context: {
|
||||
hasWorkspace: boolean;
|
||||
}): readonly CommandCenterTab[] {
|
||||
return context.hasWorkspace
|
||||
? COMMAND_CENTER_TABS
|
||||
: COMMAND_CENTER_TABS.filter((tab) => tab !== "files");
|
||||
}
|
||||
|
||||
/** The tab to show for a requested one: itself when on offer, otherwise All. */
|
||||
export function resolveCommandCenterTab(
|
||||
requested: CommandCenterTab,
|
||||
available: readonly CommandCenterTab[],
|
||||
): CommandCenterTab {
|
||||
return available.includes(requested) ? requested : "all";
|
||||
}
|
||||
|
||||
/** The available tab after (or before) `tab`, wrapping at either end. */
|
||||
export function rotateCommandCenterTab(
|
||||
tab: CommandCenterTab,
|
||||
direction: "next" | "previous",
|
||||
available: readonly CommandCenterTab[],
|
||||
): CommandCenterTab {
|
||||
const index = available.indexOf(tab);
|
||||
const delta = direction === "next" ? 1 : -1;
|
||||
const count = available.length;
|
||||
return available[(index + delta + count) % count];
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CommandCenterWorkspaceResult } from "./results";
|
||||
import { filterAndRankWorkspaces } from "./workspace-search";
|
||||
import type { CommandCenterWorkspaceAgent, CommandCenterWorkspaceResult } from "./results";
|
||||
import { createWorkspaceRanker } from "./workspace-search";
|
||||
|
||||
function workspace(input: {
|
||||
id: string;
|
||||
@@ -8,6 +8,8 @@ function workspace(input: {
|
||||
subtitle?: string;
|
||||
changeRequestNumber?: number | null;
|
||||
activityAt?: Date | null;
|
||||
agents?: readonly CommandCenterWorkspaceAgent[];
|
||||
opened?: string[];
|
||||
}): CommandCenterWorkspaceResult {
|
||||
return {
|
||||
kind: "workspace",
|
||||
@@ -19,15 +21,24 @@ function workspace(input: {
|
||||
project: { viewKey: "project", name: "project", iconDataUri: null },
|
||||
changeRequestNumber: input.changeRequestNumber ?? null,
|
||||
activityAt: input.activityAt ?? null,
|
||||
run: () => undefined,
|
||||
agents: input.agents ?? [],
|
||||
matchedAgent: null,
|
||||
archived: false,
|
||||
background: false,
|
||||
run: () => input.opened?.push("workspace"),
|
||||
openAgent: (agentId) => input.opened?.push(agentId),
|
||||
};
|
||||
}
|
||||
|
||||
function ids(rows: readonly CommandCenterWorkspaceResult[], query: string): string[] {
|
||||
return filterAndRankWorkspaces(rows, query).map((row) => row.id);
|
||||
function agent(id: string, title: string): CommandCenterWorkspaceAgent {
|
||||
return { id, title, provider: "mock" };
|
||||
}
|
||||
|
||||
describe("filterAndRankWorkspaces", () => {
|
||||
function ids(rows: readonly CommandCenterWorkspaceResult[], query: string): string[] {
|
||||
return createWorkspaceRanker()(rows, query).map((row) => row.id);
|
||||
}
|
||||
|
||||
describe("workspace ranking", () => {
|
||||
const subject = workspace({
|
||||
id: "payments",
|
||||
title: "Payments refactor",
|
||||
@@ -96,4 +107,214 @@ describe("filterAndRankWorkspaces", () => {
|
||||
});
|
||||
expect(ids([older, newer], "usage")).toEqual(["newer", "older"]);
|
||||
});
|
||||
|
||||
it("lists every workspace most recent first when the query is empty", () => {
|
||||
const rows = Array.from({ length: 8 }, (_, index) =>
|
||||
workspace({ id: `w${index}`, activityAt: new Date(2026, 0, index + 1) }),
|
||||
);
|
||||
expect(ids(rows, "")).toEqual(["w7", "w6", "w5", "w4", "w3", "w2", "w1", "w0"]);
|
||||
});
|
||||
|
||||
it("matches a workspace through one of its agents and names that agent", () => {
|
||||
const receipts = agent("agent-receipts", "Add receipt emails");
|
||||
const row = workspace({
|
||||
id: "onboarding",
|
||||
title: "Onboarding flow",
|
||||
agents: [agent("agent-welcome", "Polish welcome screen"), receipts],
|
||||
});
|
||||
const [ranked] = createWorkspaceRanker()([row], "receipt");
|
||||
expect(ranked.id).toBe("onboarding");
|
||||
expect(ranked.matchedAgent).toEqual(receipts);
|
||||
});
|
||||
|
||||
it("names no agent when the workspace's own text matched", () => {
|
||||
const row = workspace({
|
||||
id: "payments",
|
||||
title: "Payments refactor",
|
||||
agents: [agent("agent-payments", "Payments retries")],
|
||||
});
|
||||
const [ranked] = createWorkspaceRanker()([row], "payments");
|
||||
expect(ranked.matchedAgent).toBeNull();
|
||||
});
|
||||
|
||||
it("ranks a workspace's own match above an equally good match through an agent", () => {
|
||||
const viaAgent = workspace({
|
||||
id: "via-agent",
|
||||
title: "Onboarding flow",
|
||||
activityAt: new Date("2026-10-17T00:00:00Z"),
|
||||
agents: [agent("a", "Receipt emails")],
|
||||
});
|
||||
const own = workspace({
|
||||
id: "own",
|
||||
title: "Receipt redesign",
|
||||
activityAt: new Date("2026-10-01T00:00:00Z"),
|
||||
});
|
||||
expect(ids([viaAgent, own], "receipt")).toEqual(["own", "via-agent"]);
|
||||
});
|
||||
|
||||
it("picks the best-matching agent for the line", () => {
|
||||
const row = workspace({
|
||||
id: "w",
|
||||
title: "Onboarding",
|
||||
agents: [agent("loose", "Reconcile cash receipts later"), agent("exact", "receipts")],
|
||||
});
|
||||
const [ranked] = createWorkspaceRanker()([row], "receipts");
|
||||
expect(ranked.matchedAgent?.id).toBe("exact");
|
||||
});
|
||||
|
||||
it("matches a query split between the workspace's text and an agent's title", () => {
|
||||
const login = agent("agent-login", "Fix login retries");
|
||||
const row = workspace({ id: "payments", title: "payments", agents: [login] });
|
||||
const [ranked] = createWorkspaceRanker()([row], "payments login");
|
||||
expect(ranked?.id).toBe("payments");
|
||||
expect(ranked?.matchedAgent).toEqual(login);
|
||||
});
|
||||
|
||||
it("names the agent when it matches better than a loose match on the workspace's text", () => {
|
||||
const opened: string[] = [];
|
||||
const login = agent("agent-login", "Fix login retries");
|
||||
const row = workspace({
|
||||
id: "long-input",
|
||||
title: "Long input",
|
||||
// "login" is a subsequence of "long-input", the weakest kind of match.
|
||||
subtitle: "Local · paseo · feature/long-input",
|
||||
agents: [login],
|
||||
opened,
|
||||
});
|
||||
const [ranked] = createWorkspaceRanker()([row], "login");
|
||||
expect(ranked?.matchedAgent).toEqual(login);
|
||||
ranked?.run();
|
||||
expect(opened).toEqual(["agent-login"]);
|
||||
});
|
||||
|
||||
it("keeps a change-request hit on the workspace even when an agent's title names the number", () => {
|
||||
const opened: string[] = [];
|
||||
const row = workspace({
|
||||
id: "payments",
|
||||
title: "Payments refactor",
|
||||
changeRequestNumber: 42,
|
||||
agents: [agent("agent-review", "Address PR 42 review comments")],
|
||||
opened,
|
||||
});
|
||||
const rank = createWorkspaceRanker();
|
||||
for (const query of ["42", "pr 42", "#42"]) {
|
||||
const [ranked] = rank([row], query);
|
||||
expect(ranked?.matchedAgent).toBeNull();
|
||||
ranked?.run();
|
||||
}
|
||||
expect(opened).toEqual(["workspace", "workspace", "workspace"]);
|
||||
});
|
||||
|
||||
it("names no agent while the workspace's own text matches every keystroke contiguously", () => {
|
||||
const row = workspace({
|
||||
id: "fix-login-bug",
|
||||
title: "fix-login-bug",
|
||||
subtitle: "paseo · fix/login-bug",
|
||||
agents: [agent("agent-login", "Login page fix")],
|
||||
});
|
||||
const rank = createWorkspaceRanker();
|
||||
for (const query of ["l", "lo", "log", "logi", "login"]) {
|
||||
expect(rank([row], query)[0]?.matchedAgent).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("names no agent whose one-word title matches better than the workspace's whole word", () => {
|
||||
const row = workspace({
|
||||
id: "review-queue",
|
||||
title: "Review queue",
|
||||
agents: [agent("agent-review", "Review")],
|
||||
});
|
||||
expect(createWorkspaceRanker()([row], "review")[0]?.matchedAgent).toBeNull();
|
||||
});
|
||||
|
||||
it("ranks a better match through an agent above a looser match on a workspace's own text", () => {
|
||||
const loose = workspace({
|
||||
id: "loose-own",
|
||||
title: "Long input",
|
||||
subtitle: "Local · paseo · feature/long-input",
|
||||
activityAt: new Date("2026-10-17T00:00:00Z"),
|
||||
});
|
||||
const viaAgent = workspace({
|
||||
id: "via-agent",
|
||||
title: "Auth",
|
||||
activityAt: new Date("2026-10-01T00:00:00Z"),
|
||||
agents: [agent("a", "login page")],
|
||||
});
|
||||
expect(ids([loose, viaAgent], "login")).toEqual(["via-agent", "loose-own"]);
|
||||
});
|
||||
|
||||
it("keeps a workspace that also matches on its own name first while typing an agent's title", () => {
|
||||
const ownAndAgent = workspace({
|
||||
id: "oauth-refactor",
|
||||
activityAt: new Date("2026-10-17T00:00:00Z"),
|
||||
agents: [agent("agent-a", "Auth flow fix")],
|
||||
});
|
||||
const agentOnly = workspace({
|
||||
id: "cleanup",
|
||||
activityAt: new Date("2026-10-01T00:00:00Z"),
|
||||
agents: [agent("agent-b", "Auth flow fix")],
|
||||
});
|
||||
for (const query of ["a", "au", "auth", "auth f", "auth fl", "auth flow"]) {
|
||||
expect(ids([ownAndAgent, agentOnly], query)).toEqual(["oauth-refactor", "cleanup"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("ranks a word match on a workspace's name above a newer one that matches only inside a word", () => {
|
||||
const rank = createWorkspaceRanker();
|
||||
const authService = workspace({
|
||||
id: "auth-service",
|
||||
activityAt: new Date("2026-10-01T00:00:00Z"),
|
||||
});
|
||||
const oauthRefactor = workspace({
|
||||
id: "oauth-refactor",
|
||||
activityAt: new Date("2026-10-17T00:00:00Z"),
|
||||
agents: [agent("agent-auth", "Auth flow fix")],
|
||||
});
|
||||
const authRows = rank([oauthRefactor, authService], "auth");
|
||||
expect(authRows.map((row) => row.id)).toEqual(["auth-service", "oauth-refactor"]);
|
||||
// The row that matched through its agent says so.
|
||||
expect(authRows[1]?.matchedAgent?.title).toBe("Auth flow fix");
|
||||
|
||||
const fixLogin = workspace({ id: "fix-login", activityAt: new Date("2026-10-17T00:00:00Z") });
|
||||
const prefixedThing = workspace({
|
||||
id: "prefixed-thing",
|
||||
activityAt: new Date("2026-10-01T00:00:00Z"),
|
||||
agents: [agent("agent-fix", "Fix")],
|
||||
});
|
||||
const fixRows = rank([prefixedThing, fixLogin], "fix");
|
||||
expect(fixRows.map((row) => row.id)).toEqual(["fix-login", "prefixed-thing"]);
|
||||
expect(fixRows[1]?.matchedAgent?.title).toBe("Fix");
|
||||
});
|
||||
|
||||
it("opens the matched agent's tab, and the workspace otherwise", () => {
|
||||
const opened: string[] = [];
|
||||
const row = workspace({
|
||||
id: "w",
|
||||
title: "Onboarding flow",
|
||||
agents: [agent("agent-receipts", "Add receipt emails")],
|
||||
opened,
|
||||
});
|
||||
const rank = createWorkspaceRanker();
|
||||
rank([row], "receipt")[0].run();
|
||||
rank([row], "onboarding")[0].run();
|
||||
expect(opened).toEqual(["agent-receipts", "workspace"]);
|
||||
});
|
||||
|
||||
it("returns the same row object across keystrokes while the matched agent holds", () => {
|
||||
const row = workspace({
|
||||
id: "w",
|
||||
title: "Onboarding flow",
|
||||
agents: [agent("agent-receipts", "Add receipt emails")],
|
||||
});
|
||||
const rank = createWorkspaceRanker();
|
||||
expect(rank([row], "rece")[0]).toBe(rank([row], "receipt")[0]);
|
||||
expect(rank([row], "onboard")[0]).toBe(row);
|
||||
});
|
||||
|
||||
it("keeps rows the host already matched when asked to, without an agent line", () => {
|
||||
const row = workspace({ id: "archived", title: "Billing cleanup" });
|
||||
const rank = createWorkspaceRanker();
|
||||
expect(rank([row], "billign")).toEqual([]);
|
||||
expect(rank([row], "billign", { keepUnmatched: true })).toEqual([row]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { compareMatchClasses, type MatchClass } from "@getpaseo/protocol/search/text-match";
|
||||
import {
|
||||
compareBuiltInScores,
|
||||
compareByRecency,
|
||||
filterAndRankBuiltInResults,
|
||||
scoreSearchFields,
|
||||
searchFieldsMatchClass,
|
||||
type CommandCenterScore,
|
||||
type CommandCenterSearchFields,
|
||||
type CommandCenterWorkspaceAgent,
|
||||
type CommandCenterWorkspaceResult,
|
||||
} from "./results";
|
||||
|
||||
@@ -24,41 +27,176 @@ function parseChangeRequestQuery(query: string): number | null {
|
||||
return Number.isSafeInteger(number) ? number : null;
|
||||
}
|
||||
|
||||
/** Within a match class, a match on the workspace's own text ranks ahead of one through an agent. */
|
||||
const OWN_MATCH = 0;
|
||||
const AGENT_MATCH = 1;
|
||||
/** The host matched it (typo-tolerant, server-side) but nothing here does. Archived rows only. */
|
||||
const HOST_MATCH = 2;
|
||||
|
||||
/** How a row matched: its class decides first, its score within the class. */
|
||||
interface Candidate {
|
||||
matchClass: MatchClass;
|
||||
score: CommandCenterScore;
|
||||
}
|
||||
|
||||
interface ScoredWorkspace {
|
||||
workspace: CommandCenterWorkspaceResult;
|
||||
score: CommandCenterScore | null;
|
||||
via: typeof OWN_MATCH | typeof AGENT_MATCH | typeof HOST_MATCH;
|
||||
/** The match the row shows, which is also the one it ranks by. */
|
||||
candidate: Candidate | null;
|
||||
agent: CommandCenterWorkspaceAgent | null;
|
||||
changeRequestHit: boolean;
|
||||
}
|
||||
|
||||
function searchFields(workspace: CommandCenterWorkspaceResult) {
|
||||
function ownSearchFields(workspace: CommandCenterWorkspaceResult) {
|
||||
return { visible: [workspace.title, workspace.subtitle], hidden: [] };
|
||||
}
|
||||
|
||||
/** An agent is found through its own title and its workspace's text together. */
|
||||
function agentSearchFields(
|
||||
workspace: CommandCenterWorkspaceResult,
|
||||
agent: CommandCenterWorkspaceAgent,
|
||||
) {
|
||||
return { visible: [workspace.title, workspace.subtitle, agent.title], hidden: [] };
|
||||
}
|
||||
|
||||
function scoreCandidate(query: string, fields: CommandCenterSearchFields): Candidate | null {
|
||||
const score = scoreSearchFields(query, fields);
|
||||
const matchClass = searchFieldsMatchClass(query, fields);
|
||||
return score && matchClass ? { matchClass, score } : null;
|
||||
}
|
||||
|
||||
function compareCandidates(left: Candidate, right: Candidate): number {
|
||||
return (
|
||||
compareMatchClasses(left.matchClass, right.matchClass) ||
|
||||
compareBuiltInScores(left.score, right.score)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Change-request hits first, host-only matches last; then match class, the workspace's own text
|
||||
* ahead of an agent, match quality, and recency.
|
||||
*/
|
||||
function compareScoredWorkspaces(left: ScoredWorkspace, right: ScoredWorkspace): number {
|
||||
if (left.changeRequestHit !== right.changeRequestHit) {
|
||||
return left.changeRequestHit ? -1 : 1;
|
||||
}
|
||||
if (left.score && right.score) {
|
||||
const scoreDelta = compareBuiltInScores(left.score, right.score);
|
||||
if ((left.via === HOST_MATCH) !== (right.via === HOST_MATCH)) {
|
||||
return left.via === HOST_MATCH ? 1 : -1;
|
||||
}
|
||||
if (left.candidate && right.candidate) {
|
||||
const classDelta = compareMatchClasses(left.candidate.matchClass, right.candidate.matchClass);
|
||||
if (classDelta !== 0) return classDelta;
|
||||
}
|
||||
if (left.via !== right.via) return left.via - right.via;
|
||||
if (left.candidate && right.candidate) {
|
||||
const scoreDelta = compareBuiltInScores(left.candidate.score, right.candidate.score);
|
||||
if (scoreDelta !== 0) return scoreDelta;
|
||||
}
|
||||
return compareByRecency(left.workspace, right.workspace);
|
||||
}
|
||||
|
||||
export function filterAndRankWorkspaces(
|
||||
interface AgentMatch extends Candidate {
|
||||
agent: CommandCenterWorkspaceAgent;
|
||||
}
|
||||
|
||||
/**
|
||||
* The workspace's best-matching agent. Each agent is scored together with the workspace's
|
||||
* text, so a query split across the two finds it. Agents arrive most recent first, so a tie
|
||||
* keeps the newer one.
|
||||
*/
|
||||
function agentMatch(workspace: CommandCenterWorkspaceResult, query: string): AgentMatch | null {
|
||||
let best: AgentMatch | null = null;
|
||||
for (const agent of workspace.agents) {
|
||||
const candidate = scoreCandidate(query, agentSearchFields(workspace, agent));
|
||||
if (candidate && (!best || compareCandidates(candidate, best) < 0)) {
|
||||
best = { agent, ...candidate };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export type WorkspaceRanker = (
|
||||
workspaces: readonly CommandCenterWorkspaceResult[],
|
||||
query: string,
|
||||
): CommandCenterWorkspaceResult[] {
|
||||
if (!query.trim()) return filterAndRankBuiltInResults(workspaces, query, searchFields);
|
||||
options?: { keepUnmatched?: boolean },
|
||||
) => CommandCenterWorkspaceResult[];
|
||||
|
||||
const changeRequestNumber = parseChangeRequestQuery(query);
|
||||
const matches: ScoredWorkspace[] = [];
|
||||
for (const workspace of workspaces) {
|
||||
const score = scoreSearchFields(query, searchFields(workspace));
|
||||
const changeRequestHit =
|
||||
changeRequestNumber !== null && workspace.changeRequestNumber === changeRequestNumber;
|
||||
if (score || changeRequestHit) matches.push({ workspace, score, changeRequestHit });
|
||||
/**
|
||||
* Rank workspaces for a query, best match first. A workspace matches on its own title, project,
|
||||
* branch and change request, or on those together with the title of one of its agents. It is
|
||||
* matched through an agent only when the agent matches in a closer class than its own text
|
||||
* (see `MatchClass`), never on a change-request hit; such a match comes back naming that agent,
|
||||
* and opens it. Each row ranks by the match it shows.
|
||||
*
|
||||
* An empty query lists every workspace, most recent first. `keepUnmatched` keeps rows nothing
|
||||
* here matches, ranked last: archived rows come from the host's own search, which is
|
||||
* typo-tolerant, so the host is the authority on whether they match.
|
||||
*
|
||||
* The ranker remembers the row it made for each workspace and agent, so a row matched through
|
||||
* the same agent is the same object on every keystroke and its list row does not re-render.
|
||||
*/
|
||||
export function createWorkspaceRanker(): WorkspaceRanker {
|
||||
const viaAgent = new WeakMap<
|
||||
CommandCenterWorkspaceResult,
|
||||
Map<string, CommandCenterWorkspaceResult>
|
||||
>();
|
||||
|
||||
function matchedThrough(
|
||||
workspace: CommandCenterWorkspaceResult,
|
||||
agent: CommandCenterWorkspaceAgent,
|
||||
): CommandCenterWorkspaceResult {
|
||||
let rows = viaAgent.get(workspace);
|
||||
if (!rows) {
|
||||
rows = new Map();
|
||||
viaAgent.set(workspace, rows);
|
||||
}
|
||||
let row = rows.get(agent.id);
|
||||
if (!row) {
|
||||
row = { ...workspace, matchedAgent: agent, run: () => workspace.openAgent(agent.id) };
|
||||
rows.set(agent.id, row);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
matches.sort(compareScoredWorkspaces);
|
||||
return matches.map((match) => match.workspace);
|
||||
|
||||
return (workspaces, query, options = {}) => {
|
||||
if (!query.trim()) return [...workspaces].sort(compareByRecency);
|
||||
|
||||
const changeRequestNumber = parseChangeRequestQuery(query);
|
||||
const matches: ScoredWorkspace[] = [];
|
||||
for (const workspace of workspaces) {
|
||||
const changeRequestHit =
|
||||
changeRequestNumber !== null && workspace.changeRequestNumber === changeRequestNumber;
|
||||
const own = scoreCandidate(query, ownSearchFields(workspace));
|
||||
// A change-request hit is the workspace's own answer to the query.
|
||||
const throughAgent = changeRequestHit ? null : agentMatch(workspace, query);
|
||||
// An agent is named only when it matches in a closer class than the workspace's own text.
|
||||
if (
|
||||
throughAgent &&
|
||||
(!own || compareMatchClasses(throughAgent.matchClass, own.matchClass) < 0)
|
||||
) {
|
||||
matches.push({
|
||||
workspace,
|
||||
via: AGENT_MATCH,
|
||||
candidate: throughAgent,
|
||||
agent: throughAgent.agent,
|
||||
changeRequestHit,
|
||||
});
|
||||
} else if (own || changeRequestHit) {
|
||||
matches.push({ workspace, via: OWN_MATCH, candidate: own, agent: null, changeRequestHit });
|
||||
} else if (options.keepUnmatched) {
|
||||
matches.push({
|
||||
workspace,
|
||||
via: HOST_MATCH,
|
||||
candidate: null,
|
||||
agent: null,
|
||||
changeRequestHit,
|
||||
});
|
||||
}
|
||||
}
|
||||
matches.sort(compareScoredWorkspaces);
|
||||
return matches.map((match) =>
|
||||
match.agent ? matchedThrough(match.workspace, match.agent) : match.workspace,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,11 +17,12 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { Archive, ChevronRight } from "lucide-react-native";
|
||||
import { ChevronRight } from "lucide-react-native";
|
||||
import { useProviderIcon } from "@/components/provider-icons";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { HighlightedText } from "@/components/ui/highlighted-text";
|
||||
import { ArchivedBadge } from "@/components/archived-badge";
|
||||
import { StatusBadge, type StatusBadgeVariant } from "@/components/ui/status-badge";
|
||||
import { findHighlightRanges } from "@/components/ui/highlighted-text-segments";
|
||||
|
||||
@@ -99,36 +100,30 @@ function formatDateSectionLabel(t: TFunction, section: DateSectionKey): string {
|
||||
|
||||
function SessionBadge({
|
||||
label,
|
||||
icon,
|
||||
tone = "neutral",
|
||||
}: {
|
||||
label: string;
|
||||
icon?: ReactElement;
|
||||
tone?: "neutral" | "warning" | "danger";
|
||||
}) {
|
||||
let variant: StatusBadgeVariant = "muted";
|
||||
if (tone === "warning") variant = "warning";
|
||||
else if (tone === "danger") variant = "error";
|
||||
return <StatusBadge label={label} variant={variant} leading={icon} />;
|
||||
return <StatusBadge label={label} variant={variant} />;
|
||||
}
|
||||
|
||||
function SessionRowBadges({
|
||||
agent,
|
||||
archivedIcon,
|
||||
pendingPermissionCount,
|
||||
showDesktopAttention,
|
||||
}: {
|
||||
agent: AggregatedAgent;
|
||||
archivedIcon: ReactElement;
|
||||
pendingPermissionCount: number;
|
||||
showDesktopAttention: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
{agent.archivedAt ? (
|
||||
<SessionBadge label={t("agentList.badges.archived")} icon={archivedIcon} />
|
||||
) : null}
|
||||
{agent.archivedAt ? <ArchivedBadge /> : null}
|
||||
{pendingPermissionCount > 0 ? (
|
||||
<SessionBadge
|
||||
label={t("agentList.badges.pending", { count: pendingPermissionCount })}
|
||||
@@ -214,10 +209,6 @@ function SessionRow({
|
||||
const handlePress = useCallback(() => onPress(agent), [onPress, agent]);
|
||||
const handleLongPress = useCallback(() => onLongPress(agent), [onLongPress, agent]);
|
||||
|
||||
const archivedIcon = useMemo(
|
||||
() => <Archive size={theme.fontSize.sm} color={theme.colors.foregroundMuted} />,
|
||||
[theme.fontSize.sm, theme.colors.foregroundMuted],
|
||||
);
|
||||
const showDesktopAttention =
|
||||
!isMobile && showAttentionIndicator && Boolean(agent.requiresAttention);
|
||||
|
||||
@@ -261,7 +252,6 @@ function SessionRow({
|
||||
) : null}
|
||||
<SessionRowBadges
|
||||
agent={agent}
|
||||
archivedIcon={archivedIcon}
|
||||
pendingPermissionCount={pendingPermissionCount}
|
||||
showDesktopAttention={showDesktopAttention}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Archive } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
|
||||
const ThemedArchive = withUnistyles(Archive, (theme) => ({
|
||||
size: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
|
||||
const ARCHIVE_ICON = <ThemedArchive />;
|
||||
|
||||
/** Marks a session or workspace the user archived, wherever archived items are listed. */
|
||||
export function ArchivedBadge() {
|
||||
const { t } = useTranslation();
|
||||
return <StatusBadge label={t("agentList.badges.archived")} leading={ARCHIVE_ICON} />;
|
||||
}
|
||||
@@ -3,9 +3,10 @@ import type {
|
||||
FetchAgentHistoryOptions,
|
||||
FetchAgentHistoryPageInfo,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
@@ -34,6 +35,8 @@ export interface AgentHistoryResult {
|
||||
isSearchSupported: boolean;
|
||||
/** More sessions matched than the ranked page holds. Narrow the query. */
|
||||
isSearchTruncated: boolean;
|
||||
/** The agents answer the previous query; the current one is loading (`keepPreviousResults`). */
|
||||
isPreviousResults: boolean;
|
||||
/** Hosts that failed while others succeeded. The list still renders. */
|
||||
hostErrors: AgentHistoryHostError[];
|
||||
refreshAll: () => Promise<void>;
|
||||
@@ -222,6 +225,13 @@ export function useAgentHistory(options: {
|
||||
serverId?: string | null;
|
||||
enabled?: boolean;
|
||||
search?: string;
|
||||
/**
|
||||
* Leave out hosts that predate search, instead of searching no host. For a surface that adds
|
||||
* history matches to other results, where a missing host is not a claim of "no matches".
|
||||
*/
|
||||
searchableHostsOnly?: boolean;
|
||||
/** Keep the previous query's results until the next query's arrive. */
|
||||
keepPreviousResults?: boolean;
|
||||
}): AgentHistoryResult {
|
||||
const { t } = useTranslation();
|
||||
const daemons = useHosts();
|
||||
@@ -231,10 +241,18 @@ export function useAgentHistory(options: {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}, [options.serverId]);
|
||||
const enabled = options.enabled ?? true;
|
||||
const serverIds = useMemo(
|
||||
const requestedServerIds = useMemo(
|
||||
() => (serverId ? [serverId] : daemons.map((daemon) => daemon.serverId)),
|
||||
[daemons, serverId],
|
||||
);
|
||||
const searchableServerIds = useSessionStore(
|
||||
useShallow((state) =>
|
||||
requestedServerIds.filter(
|
||||
(id) => state.sessions[id]?.serverInfo?.features?.agentHistorySearch === true,
|
||||
),
|
||||
),
|
||||
);
|
||||
const serverIds = options.searchableHostsOnly ? searchableServerIds : requestedServerIds;
|
||||
const connectionStatuses = useHostRuntimeConnectionStatuses(serverIds);
|
||||
// A host the user asked about splits two ways: one this fetch can reach, and
|
||||
// one whose sessions will be missing from the answer. Both come out of here,
|
||||
@@ -287,6 +305,7 @@ export function useAgentHistory(options: {
|
||||
[daemons],
|
||||
);
|
||||
|
||||
const queryEnabled = Boolean(enabled && targetHosts.length > 0);
|
||||
const historyQuery = useInfiniteQuery<
|
||||
AgentHistoryBatchPage,
|
||||
Error,
|
||||
@@ -295,8 +314,11 @@ export function useAgentHistory(options: {
|
||||
AgentHistoryCursorByServerId | null
|
||||
>({
|
||||
queryKey,
|
||||
enabled: Boolean(enabled && targetHosts.length > 0),
|
||||
enabled: queryEnabled,
|
||||
staleTime: 30_000,
|
||||
// Previous results stand in only until the next query's arrive. With no host to search,
|
||||
// nothing will arrive, so they would stand in, and read as still loading, indefinitely.
|
||||
...(options.keepPreviousResults && queryEnabled ? { placeholderData: keepPreviousData } : {}),
|
||||
initialPageParam: null,
|
||||
getNextPageParam: getNextAgentHistoryPageParam,
|
||||
queryFn: async ({ pageParam }) => {
|
||||
@@ -318,6 +340,7 @@ export function useAgentHistory(options: {
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
isLoading,
|
||||
isPlaceholderData,
|
||||
refetch,
|
||||
} = historyQuery;
|
||||
|
||||
@@ -363,6 +386,7 @@ export function useAgentHistory(options: {
|
||||
isLoadingMore: isFetchingNextPage,
|
||||
isSearchSupported,
|
||||
isSearchTruncated,
|
||||
isPreviousResults: isPlaceholderData,
|
||||
hostErrors,
|
||||
refreshAll,
|
||||
loadMore,
|
||||
|
||||
@@ -319,15 +319,15 @@ describe("translation resources", () => {
|
||||
expect(en.shell.menu.toggleSidebar).toBe("Toggle sidebar");
|
||||
expect(en.shell.menu.open).toBe("Open menu");
|
||||
expect(en.shell.menu.close).toBe("Close menu");
|
||||
expect(en.shell.commandCenter.placeholder).toBe(
|
||||
"Search commands, files, workspaces, and agents...",
|
||||
);
|
||||
expect(en.shell.commandCenter.placeholder).toBe("Search workspaces, files, and actions...");
|
||||
expect(en.shell.commandCenter.placeholderWithoutFiles).toBe("Search workspaces and actions...");
|
||||
expect(en.shell.commandCenter.filePlaceholder).toBe("Search files...");
|
||||
expect(en.shell.commandCenter.files).toBe("Files");
|
||||
expect(en.shell.commandCenter.noMatches).toBe("No matches");
|
||||
expect(en.shell.commandCenter.actions).toBe("Actions");
|
||||
expect(en.shell.commandCenter.workspaces).toBe("Workspaces");
|
||||
expect(en.shell.commandCenter.agents).toBe("Agents");
|
||||
expect(en.shell.commandCenter.recentWorkspaces).toBe("Recent workspaces");
|
||||
expect(en.shell.commandCenter.tabs.all).toBe("All");
|
||||
expect(en.shell.commandCenter.newAgent).toBe("New agent");
|
||||
expect(en.shell.commandCenter.addProject).toBe("Add project");
|
||||
expect(en.shell.commandCenter.home).toBe("Home");
|
||||
|
||||
@@ -90,14 +90,23 @@ export const ar: TranslationResources = {
|
||||
close: "إغلاق القائمة",
|
||||
},
|
||||
commandCenter: {
|
||||
placeholder: "ابحث في الأوامر والملفات ومساحات العمل والوكلاء...",
|
||||
placeholder: "البحث في مساحات العمل والملفات والإجراءات...",
|
||||
placeholderWithoutFiles: "البحث في مساحات العمل والإجراءات...",
|
||||
filePlaceholder: "البحث في الملفات...",
|
||||
workspacePlaceholder: "البحث في مساحات العمل...",
|
||||
actionPlaceholder: "البحث في الإجراءات...",
|
||||
searchingFiles: "جارٍ البحث في الملفات...",
|
||||
noMatches: "لا توجد مباريات",
|
||||
actions: "الإجراءات",
|
||||
files: "الملفات",
|
||||
workspaces: "مساحات العمل",
|
||||
agents: "الوكلاء",
|
||||
recentWorkspaces: "مساحات العمل الأخيرة",
|
||||
tabs: {
|
||||
all: "الكل",
|
||||
workspaces: "مساحات العمل",
|
||||
files: "الملفات",
|
||||
actions: "الإجراءات",
|
||||
},
|
||||
newAgent: "وكيل جديد",
|
||||
open: "فتح {{name}}",
|
||||
openInSidePane: "فتح {{name}} في اللوحة الجانبية",
|
||||
|
||||
@@ -86,14 +86,23 @@ export const en = {
|
||||
close: "Close menu",
|
||||
},
|
||||
commandCenter: {
|
||||
placeholder: "Search commands, files, workspaces, and agents...",
|
||||
placeholder: "Search workspaces, files, and actions...",
|
||||
placeholderWithoutFiles: "Search workspaces and actions...",
|
||||
filePlaceholder: "Search files...",
|
||||
workspacePlaceholder: "Search workspaces...",
|
||||
actionPlaceholder: "Search actions...",
|
||||
searchingFiles: "Searching files...",
|
||||
noMatches: "No matches",
|
||||
actions: "Actions",
|
||||
files: "Files",
|
||||
workspaces: "Workspaces",
|
||||
agents: "Agents",
|
||||
recentWorkspaces: "Recent workspaces",
|
||||
tabs: {
|
||||
all: "All",
|
||||
workspaces: "Workspaces",
|
||||
files: "Files",
|
||||
actions: "Actions",
|
||||
},
|
||||
newAgent: "New agent",
|
||||
open: "Open {{name}}",
|
||||
openInSidePane: "Open {{name}} in side pane",
|
||||
|
||||
@@ -91,14 +91,23 @@ export const es: TranslationResources = {
|
||||
close: "Cerrar menú",
|
||||
},
|
||||
commandCenter: {
|
||||
placeholder: "Buscar comandos, archivos, espacios de trabajo y agentes...",
|
||||
placeholder: "Buscar espacios de trabajo, archivos y acciones...",
|
||||
placeholderWithoutFiles: "Buscar espacios de trabajo y acciones...",
|
||||
filePlaceholder: "Buscar archivos...",
|
||||
workspacePlaceholder: "Buscar espacios de trabajo...",
|
||||
actionPlaceholder: "Buscar acciones...",
|
||||
searchingFiles: "Buscando archivos...",
|
||||
noMatches: "No hay coincidencias",
|
||||
actions: "Comportamiento",
|
||||
files: "Archivos",
|
||||
workspaces: "Espacios de trabajo",
|
||||
agents: "Agentes",
|
||||
recentWorkspaces: "Espacios de trabajo recientes",
|
||||
tabs: {
|
||||
all: "Todo",
|
||||
workspaces: "Espacios de trabajo",
|
||||
files: "Archivos",
|
||||
actions: "Acciones",
|
||||
},
|
||||
newAgent: "Nuevo agente",
|
||||
open: "Abrir {{name}}",
|
||||
openInSidePane: "Abrir {{name}} en el panel lateral",
|
||||
|
||||
@@ -91,14 +91,23 @@ export const fr: TranslationResources = {
|
||||
close: "Fermer le menu",
|
||||
},
|
||||
commandCenter: {
|
||||
placeholder: "Rechercher des commandes, fichiers, espaces de travail et agents…",
|
||||
placeholder: "Rechercher des espaces de travail, fichiers et actions…",
|
||||
placeholderWithoutFiles: "Rechercher des espaces de travail et des actions…",
|
||||
filePlaceholder: "Rechercher des fichiers…",
|
||||
workspacePlaceholder: "Rechercher des espaces de travail…",
|
||||
actionPlaceholder: "Rechercher des actions…",
|
||||
searchingFiles: "Recherche de fichiers…",
|
||||
noMatches: "Aucun résultat",
|
||||
actions: "Actions",
|
||||
files: "Fichiers",
|
||||
workspaces: "Espaces de travail",
|
||||
agents: "Agents",
|
||||
recentWorkspaces: "Espaces de travail récents",
|
||||
tabs: {
|
||||
all: "Tout",
|
||||
workspaces: "Espaces de travail",
|
||||
files: "Fichiers",
|
||||
actions: "Actions",
|
||||
},
|
||||
newAgent: "Nouvel agent",
|
||||
open: "Ouvrir {{name}}",
|
||||
openInSidePane: "Ouvrir {{name}} dans le panneau latéral",
|
||||
|
||||
@@ -91,14 +91,23 @@ export const ja: TranslationResources = {
|
||||
close: "メニューを閉じる",
|
||||
},
|
||||
commandCenter: {
|
||||
placeholder: "コマンド、ファイル、ワークスペース、エージェントを検索...",
|
||||
placeholder: "ワークスペース、ファイル、アクションを検索...",
|
||||
placeholderWithoutFiles: "ワークスペースとアクションを検索...",
|
||||
filePlaceholder: "ファイルを検索...",
|
||||
workspacePlaceholder: "ワークスペースを検索...",
|
||||
actionPlaceholder: "アクションを検索...",
|
||||
searchingFiles: "ファイルを検索中...",
|
||||
noMatches: "一致なし",
|
||||
actions: "アクション",
|
||||
files: "ファイル",
|
||||
workspaces: "ワークスペース",
|
||||
agents: "エージェント",
|
||||
recentWorkspaces: "最近のワークスペース",
|
||||
tabs: {
|
||||
all: "すべて",
|
||||
workspaces: "ワークスペース",
|
||||
files: "ファイル",
|
||||
actions: "アクション",
|
||||
},
|
||||
newAgent: "新しいエージェント",
|
||||
open: "{{name}}を開く",
|
||||
openInSidePane: "{{name}}をサイドパネルで開く",
|
||||
|
||||
@@ -90,14 +90,23 @@ export const ko: TranslationResources = {
|
||||
close: "메뉴 닫기",
|
||||
},
|
||||
commandCenter: {
|
||||
placeholder: "명령, 파일, 워크스페이스 및 에이전트 검색...",
|
||||
placeholder: "워크스페이스, 파일 및 작업 검색...",
|
||||
placeholderWithoutFiles: "워크스페이스 및 작업 검색...",
|
||||
filePlaceholder: "파일 검색...",
|
||||
workspacePlaceholder: "워크스페이스 검색...",
|
||||
actionPlaceholder: "작업 검색...",
|
||||
searchingFiles: "파일 검색 중...",
|
||||
noMatches: "일치 항목 없음",
|
||||
actions: "작업",
|
||||
files: "파일",
|
||||
workspaces: "워크스페이스",
|
||||
agents: "에이전트",
|
||||
recentWorkspaces: "최근 워크스페이스",
|
||||
tabs: {
|
||||
all: "전체",
|
||||
workspaces: "워크스페이스",
|
||||
files: "파일",
|
||||
actions: "작업",
|
||||
},
|
||||
newAgent: "새 에이전트",
|
||||
open: "{{name}} 열기",
|
||||
openInSidePane: "사이드 패널에서 {{name}} 열기",
|
||||
|
||||
@@ -91,14 +91,23 @@ export const ptBR: TranslationResources = {
|
||||
close: "Fechar menu",
|
||||
},
|
||||
commandCenter: {
|
||||
placeholder: "Buscar comandos, arquivos, espaços de trabalho e agentes...",
|
||||
placeholder: "Pesquisar espaços de trabalho, arquivos e ações...",
|
||||
placeholderWithoutFiles: "Pesquisar espaços de trabalho e ações...",
|
||||
filePlaceholder: "Pesquisar arquivos...",
|
||||
workspacePlaceholder: "Pesquisar espaços de trabalho...",
|
||||
actionPlaceholder: "Pesquisar ações...",
|
||||
searchingFiles: "Pesquisando arquivos...",
|
||||
noMatches: "Nenhuma correspondência",
|
||||
actions: "Ações",
|
||||
files: "Arquivos",
|
||||
workspaces: "Espaços de trabalho",
|
||||
agents: "Agentes",
|
||||
recentWorkspaces: "Espaços de trabalho recentes",
|
||||
tabs: {
|
||||
all: "Tudo",
|
||||
workspaces: "Espaços de trabalho",
|
||||
files: "Arquivos",
|
||||
actions: "Ações",
|
||||
},
|
||||
newAgent: "Novo agente",
|
||||
open: "Abrir {{name}}",
|
||||
openInSidePane: "Abrir {{name}} no painel lateral",
|
||||
|
||||
@@ -91,14 +91,23 @@ export const ru: TranslationResources = {
|
||||
close: "Закрыть меню",
|
||||
},
|
||||
commandCenter: {
|
||||
placeholder: "Поиск команд, файлов, рабочих пространств и агентов...",
|
||||
placeholder: "Поиск рабочих пространств, файлов и действий...",
|
||||
placeholderWithoutFiles: "Поиск рабочих пространств и действий...",
|
||||
filePlaceholder: "Поиск файлов...",
|
||||
workspacePlaceholder: "Поиск рабочих пространств...",
|
||||
actionPlaceholder: "Поиск действий...",
|
||||
searchingFiles: "Поиск файлов...",
|
||||
noMatches: "Нет совпадений",
|
||||
actions: "Действия",
|
||||
files: "Файлы",
|
||||
workspaces: "Рабочие пространства",
|
||||
agents: "Агенты",
|
||||
recentWorkspaces: "Недавние рабочие пространства",
|
||||
tabs: {
|
||||
all: "Все",
|
||||
workspaces: "Рабочие пространства",
|
||||
files: "Файлы",
|
||||
actions: "Действия",
|
||||
},
|
||||
newAgent: "Новый агент",
|
||||
open: "Открыть {{name}}",
|
||||
openInSidePane: "Открыть {{name}} в боковой панели",
|
||||
|
||||
@@ -90,14 +90,23 @@ export const zhCN: TranslationResources = {
|
||||
close: "关闭菜单",
|
||||
},
|
||||
commandCenter: {
|
||||
placeholder: "搜索命令、文件、工作区和 Agent...",
|
||||
placeholder: "搜索工作区、文件和操作...",
|
||||
placeholderWithoutFiles: "搜索工作区和操作...",
|
||||
filePlaceholder: "搜索文件...",
|
||||
workspacePlaceholder: "搜索工作区...",
|
||||
actionPlaceholder: "搜索操作...",
|
||||
searchingFiles: "正在搜索文件...",
|
||||
noMatches: "没有匹配项",
|
||||
actions: "操作",
|
||||
files: "文件",
|
||||
workspaces: "工作区",
|
||||
agents: "Agents",
|
||||
recentWorkspaces: "最近的工作区",
|
||||
tabs: {
|
||||
all: "全部",
|
||||
workspaces: "工作区",
|
||||
files: "文件",
|
||||
actions: "操作",
|
||||
},
|
||||
newAgent: "新建 Agent",
|
||||
open: "打开{{name}}",
|
||||
openInSidePane: "在侧边面板中打开{{name}}",
|
||||
|
||||
@@ -3,6 +3,10 @@ import type { KeyboardInputEvent } from "./overlays";
|
||||
export interface OverlayRegistration {
|
||||
active: boolean;
|
||||
layer: number;
|
||||
/**
|
||||
* Keys the overlay handles. Native forwards only these. On web, declaring "Tab" hands Tab to
|
||||
* `onKeyDown` instead of the focus trap.
|
||||
*/
|
||||
keys: readonly string[];
|
||||
onKeyDown(event: KeyboardInputEvent): boolean;
|
||||
restoreFocusRef?: RefObject<unknown>;
|
||||
|
||||
@@ -85,7 +85,9 @@ function dispatchOverlayEntry(entry: WebOverlayEntry, event: KeyboardInputEvent)
|
||||
const scope = entry.getScope();
|
||||
if (!scope) return top.getKeyHandler()(event);
|
||||
|
||||
if (top.manageFocus && event.key === "Tab") {
|
||||
// An overlay that declares Tab owns it (the command center rotates its tabs); the focus trap
|
||||
// only moves focus for overlays that leave Tab alone.
|
||||
if (top.manageFocus && event.key === "Tab" && !top.getKeys().includes("Tab")) {
|
||||
const focusable = getFocusableElements(scope);
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
|
||||
@@ -376,10 +376,10 @@ describe("routeKeyboardShortcut — callbacks and pickers", () => {
|
||||
});
|
||||
|
||||
describe("routeKeyboardShortcut — toggle dialogs", () => {
|
||||
it("opens the command center scoped to files from a workspace", () => {
|
||||
it("opens the command center on the Files tab from a workspace", () => {
|
||||
expect(
|
||||
routeKeyboardShortcut({ action: "command-center.files", payload: null }, makeCtx()),
|
||||
).toEqual<ShortcutAction>({ kind: "command-center-toggle", nextOpen: true, scope: "files" });
|
||||
).toEqual<ShortcutAction>({ kind: "command-center-toggle", nextOpen: true, tab: "files" });
|
||||
});
|
||||
|
||||
it("leaves the file-search shortcut to the project-picker host outside a workspace", () => {
|
||||
|
||||
@@ -32,7 +32,7 @@ export type ShortcutAction =
|
||||
| { kind: "router-push"; route: string }
|
||||
| { kind: "open-project-picker" }
|
||||
| { kind: "callback"; name: ShortcutCallbackName }
|
||||
| { kind: "command-center-toggle"; nextOpen: boolean; scope?: "files" }
|
||||
| { kind: "command-center-toggle"; nextOpen: boolean; tab?: "files" }
|
||||
| { kind: "shortcuts-dialog-toggle"; nextOpen: boolean };
|
||||
|
||||
const NONE: ShortcutAction = { kind: "none" };
|
||||
@@ -212,7 +212,7 @@ export function routeKeyboardShortcut(
|
||||
return { kind: "command-center-toggle", nextOpen: !ctx.commandCenterOpen };
|
||||
case "command-center.files":
|
||||
if (parseHostWorkspaceRouteFromPathname(ctx.pathname)) {
|
||||
return { kind: "command-center-toggle", nextOpen: true, scope: "files" };
|
||||
return { kind: "command-center-toggle", nextOpen: true, tab: "files" };
|
||||
}
|
||||
return dispatch({ id: "workspace.project.pick", scope: "workspace" });
|
||||
case "shortcuts.dialog.toggle":
|
||||
|
||||
@@ -152,9 +152,7 @@ export function useKeyboardShortcuts({
|
||||
if (action.nextOpen) {
|
||||
captureFocusRestore();
|
||||
}
|
||||
useKeyboardShortcutsStore
|
||||
.getState()
|
||||
.setCommandCenterOpen(action.nextOpen, action.scope ?? null);
|
||||
useKeyboardShortcutsStore.getState().setCommandCenterOpen(action.nextOpen, action.tab);
|
||||
return true;
|
||||
}
|
||||
case "shortcuts-dialog-toggle":
|
||||
|
||||
@@ -182,6 +182,7 @@ function workspaceSummary(overrides: Partial<WorkspaceSummary> = {}): WorkspaceS
|
||||
status: "done",
|
||||
currentBranch: "main",
|
||||
changeRequestNumber: null,
|
||||
background: false,
|
||||
statusEnteredAt: null,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useKeyboardShortcutsStore } from "./keyboard-shortcuts-store";
|
||||
beforeEach(() => {
|
||||
useKeyboardShortcutsStore.setState({
|
||||
commandCenterOpen: false,
|
||||
commandCenterScope: null,
|
||||
commandCenterTab: "all",
|
||||
shortcutsDialogOpen: false,
|
||||
capturingShortcut: false,
|
||||
altDown: false,
|
||||
@@ -20,18 +20,21 @@ describe("keyboard-shortcuts-store", () => {
|
||||
expect(useKeyboardShortcutsStore.getState().commandCenterOpen).toBe(true);
|
||||
});
|
||||
|
||||
it("opens the command center with a scope and clears it when closed", () => {
|
||||
it("opens the command center on a tab and returns to All when closed", () => {
|
||||
useKeyboardShortcutsStore.getState().setCommandCenterOpen(true, "files");
|
||||
expect(useKeyboardShortcutsStore.getState()).toMatchObject({
|
||||
commandCenterOpen: true,
|
||||
commandCenterScope: "files",
|
||||
commandCenterTab: "files",
|
||||
});
|
||||
|
||||
useKeyboardShortcutsStore.getState().setCommandCenterOpen(false);
|
||||
expect(useKeyboardShortcutsStore.getState()).toMatchObject({
|
||||
commandCenterOpen: false,
|
||||
commandCenterScope: null,
|
||||
commandCenterTab: "all",
|
||||
});
|
||||
|
||||
useKeyboardShortcutsStore.getState().setCommandCenterOpen(true);
|
||||
expect(useKeyboardShortcutsStore.getState().commandCenterTab).toBe("all");
|
||||
});
|
||||
|
||||
it("toggles shortcut capture state", () => {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { create } from "zustand";
|
||||
import type { CommandCenterTab } from "@/command-center/tabs";
|
||||
import type { SidebarShortcutWorkspaceTarget } from "@/utils/sidebar-shortcuts";
|
||||
|
||||
const SHORTCUT_BADGE_DELAY_MS = 150;
|
||||
|
||||
export type CommandCenterScope = "files" | null;
|
||||
|
||||
interface KeyboardShortcutsState {
|
||||
commandCenterOpen: boolean;
|
||||
commandCenterScope: CommandCenterScope;
|
||||
/** The tab the open command center shows. Every opening starts on the tab it asks for. */
|
||||
commandCenterTab: CommandCenterTab;
|
||||
shortcutsDialogOpen: boolean;
|
||||
capturingShortcut: boolean;
|
||||
altDown: boolean;
|
||||
@@ -16,8 +16,8 @@ interface KeyboardShortcutsState {
|
||||
/** Sidebar-visible workspace targets (up to 9), in top-to-bottom visual order. */
|
||||
sidebarShortcutWorkspaceTargets: SidebarShortcutWorkspaceTarget[];
|
||||
|
||||
setCommandCenterOpen: (open: boolean, scope?: CommandCenterScope) => void;
|
||||
setCommandCenterScope: (scope: CommandCenterScope) => void;
|
||||
setCommandCenterOpen: (open: boolean, tab?: CommandCenterTab) => void;
|
||||
setCommandCenterTab: (tab: CommandCenterTab) => void;
|
||||
setShortcutsDialogOpen: (open: boolean) => void;
|
||||
setCapturingShortcut: (capturing: boolean) => void;
|
||||
setAltDown: (down: boolean) => void;
|
||||
@@ -51,7 +51,7 @@ function updateBadgeTimer(
|
||||
|
||||
export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>((set, get) => ({
|
||||
commandCenterOpen: false,
|
||||
commandCenterScope: null,
|
||||
commandCenterTab: "all",
|
||||
shortcutsDialogOpen: false,
|
||||
capturingShortcut: false,
|
||||
altDown: false,
|
||||
@@ -59,9 +59,9 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>((set, ge
|
||||
showShortcutBadges: false,
|
||||
sidebarShortcutWorkspaceTargets: [],
|
||||
|
||||
setCommandCenterOpen: (open, scope = null) =>
|
||||
set({ commandCenterOpen: open, commandCenterScope: open ? scope : null }),
|
||||
setCommandCenterScope: (scope) => set({ commandCenterScope: scope }),
|
||||
setCommandCenterOpen: (open, tab = "all") =>
|
||||
set({ commandCenterOpen: open, commandCenterTab: open ? tab : "all" }),
|
||||
setCommandCenterTab: (tab) => set({ commandCenterTab: tab }),
|
||||
setShortcutsDialogOpen: (open) => set({ shortcutsDialogOpen: open }),
|
||||
setCapturingShortcut: (capturing) => set({ capturingShortcut: capturing }),
|
||||
setAltDown: (down) => {
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface WorkspaceSummary {
|
||||
name: string;
|
||||
title?: string;
|
||||
workspaceKind: WorkspaceDescriptor["workspaceKind"];
|
||||
/** Started by automation rather than the user. The sidebar hides it unless asked. */
|
||||
background: boolean;
|
||||
status: WorkspaceDescriptor["status"];
|
||||
currentBranch: string | null;
|
||||
archivingAt?: string;
|
||||
@@ -158,6 +160,7 @@ function toWorkspaceSummary(workspace: WorkspaceDescriptor): WorkspaceSummary {
|
||||
name: workspace.name,
|
||||
...(workspace.title ? { title: workspace.title } : {}),
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
background: workspace.background ?? false,
|
||||
status: workspace.status,
|
||||
currentBranch: currentBranch && currentBranch !== "HEAD" ? currentBranch : null,
|
||||
...(workspace.archivingAt ? { archivingAt: workspace.archivingAt } : {}),
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
isWorkspaceArchivePending,
|
||||
isWorkspaceArchiveUndoPending,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
import { queryClient } from "@/data/query-client";
|
||||
import { allAgentHistoryQueryKey } from "@/hooks/agent-history-query-key";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import {
|
||||
archiveWorkspaceOptimistically,
|
||||
@@ -133,6 +135,17 @@ describe("archiveWorkspaceOptimistically", () => {
|
||||
expect(storedWorkspace(archived.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("marks cached agent history stale once the host has archived the workspace", async () => {
|
||||
const historyKey = [...allAgentHistoryQueryKey([SERVER_ID]), "workspace-1"];
|
||||
queryClient.setQueryData(historyKey, { pages: [], pageParams: [] });
|
||||
const client = createClient(vi.fn(async () => archivePayload({ workspaceId: "workspace-1" })));
|
||||
|
||||
await archiveWorkspaceOptimistically({ client, workspace: target() });
|
||||
|
||||
expect(queryClient.getQueryState(historyKey)?.isInvalidated).toBe(true);
|
||||
queryClient.removeQueries({ queryKey: historyKey });
|
||||
});
|
||||
|
||||
it("restores the workspace and clears pending state when the daemon rejects the archive", async () => {
|
||||
const archived = workspace();
|
||||
getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [archived]);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { queryClient } from "@/data/query-client";
|
||||
import { agentHistoryQueryKey, allAgentHistoryQueryRootKey } from "@/hooks/agent-history-query-key";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import {
|
||||
isWorkspaceArchiveIntentCurrent,
|
||||
@@ -96,6 +98,12 @@ function restoreOptimisticallyHiddenWorkspace(input: {
|
||||
}
|
||||
}
|
||||
|
||||
/** History fetched before the archive still lists the workspace's agents as active. */
|
||||
function invalidateAgentHistory(serverId: string): void {
|
||||
void queryClient.invalidateQueries({ queryKey: agentHistoryQueryKey(serverId) });
|
||||
void queryClient.invalidateQueries({ queryKey: allAgentHistoryQueryRootKey() });
|
||||
}
|
||||
|
||||
async function archiveWorkspaceOrThrow(input: {
|
||||
client: WorkspaceArchiveClient;
|
||||
workspaceId: string;
|
||||
@@ -127,6 +135,8 @@ export function beginOptimisticWorkspaceArchive(input: {
|
||||
});
|
||||
if (outcome === "canceled") {
|
||||
restoreOptimisticallyHiddenWorkspace({ serverId: input.workspace.serverId, snapshot });
|
||||
} else {
|
||||
invalidateAgentHistory(input.workspace.serverId);
|
||||
}
|
||||
return outcome;
|
||||
} catch (error) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
scoreMatch,
|
||||
scorePathMatch,
|
||||
scoreTextFields,
|
||||
textFieldsMatchClass,
|
||||
} from "./text-match.js";
|
||||
|
||||
function markUp(query: string, text: string, options?: Parameters<typeof scoreMatch>[2]): string {
|
||||
@@ -248,6 +249,24 @@ describe("scoreTextFields", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("textFieldsMatchClass", () => {
|
||||
it("keeps positional tier differences inside one class", () => {
|
||||
expect(textFieldsMatchClass("lo", ["fix-login-bug"])).toBe("word");
|
||||
expect(textFieldsMatchClass("lo", ["Login page fix"])).toBe("word");
|
||||
});
|
||||
|
||||
it("takes the loosest token's class", () => {
|
||||
expect(textFieldsMatchClass("auth", ["oauth-refactor"])).toBe("inside-word");
|
||||
expect(textFieldsMatchClass("fix auth", ["fix", "oauth"])).toBe("inside-word");
|
||||
expect(textFieldsMatchClass("fix lgn", ["fix", "login"])).toBe("scattered");
|
||||
expect(textFieldsMatchClass("stipe", ["stripe"], { typoTolerant: true })).toBe("scattered");
|
||||
});
|
||||
|
||||
it("returns null when a token matches no field", () => {
|
||||
expect(textFieldsMatchClass("fix zebra", ["fix"])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("compareMatchScores", () => {
|
||||
it("sorts by tier ascending", () => {
|
||||
expect(compareMatchScores({ tier: 1, offset: 10 }, { tier: 2, offset: 0 })).toBeLessThan(0);
|
||||
|
||||
@@ -331,16 +331,14 @@ export interface TextFieldsOptions {
|
||||
subsequence?: boolean;
|
||||
}
|
||||
|
||||
export function scoreTextFields(
|
||||
/** Each token's best score across the fields, or null when some token matches none. */
|
||||
function scoreTokens(
|
||||
query: string,
|
||||
fields: string[],
|
||||
options: TextFieldsOptions = {},
|
||||
): MatchScore | null {
|
||||
const tokens = tokenizeQuery(query);
|
||||
if (tokens.length === 0) return { tier: TIER_EXACT, offset: 0, spread: 0 };
|
||||
|
||||
const aggregate: MatchScore = { tier: TIER_EXACT, offset: 0, spread: 0 };
|
||||
for (const token of tokens) {
|
||||
options: TextFieldsOptions,
|
||||
): { token: string; score: MatchScore }[] | null {
|
||||
const scores: { token: string; score: MatchScore }[] = [];
|
||||
for (const token of tokenizeQuery(query)) {
|
||||
const fuzzy = options.typoTolerant ? fuzzyPolicyForToken(token) : null;
|
||||
let best: MatchScore | null = null;
|
||||
for (const field of fields) {
|
||||
@@ -350,9 +348,59 @@ export function scoreTextFields(
|
||||
}
|
||||
}
|
||||
if (!best) return null;
|
||||
aggregate.tier += best.tier;
|
||||
aggregate.offset += best.offset;
|
||||
aggregate.spread = (aggregate.spread ?? 0) + (best.spread ?? token.length);
|
||||
scores.push({ token, score: best });
|
||||
}
|
||||
return scores;
|
||||
}
|
||||
|
||||
export function scoreTextFields(
|
||||
query: string,
|
||||
fields: string[],
|
||||
options: TextFieldsOptions = {},
|
||||
): MatchScore | null {
|
||||
const scores = scoreTokens(query, fields, options);
|
||||
if (!scores) return null;
|
||||
const aggregate: MatchScore = { tier: TIER_EXACT, offset: 0, spread: 0 };
|
||||
for (const { token, score } of scores) {
|
||||
aggregate.tier += score.tier;
|
||||
aggregate.offset += score.offset;
|
||||
aggregate.spread = (aggregate.spread ?? 0) + (score.spread ?? token.length);
|
||||
}
|
||||
return aggregate;
|
||||
}
|
||||
|
||||
/**
|
||||
* How closely a query matched, coarser than a tier: every token at a word boundary, some token
|
||||
* inside a word, or some token only scattered across a word or misspelled. Positional tier
|
||||
* differences ("lo" at a title's start versus a branch's middle word) stay within one class.
|
||||
*/
|
||||
export type MatchClass = "word" | "inside-word" | "scattered";
|
||||
|
||||
const MATCH_CLASS_ORDER: readonly MatchClass[] = ["word", "inside-word", "scattered"];
|
||||
|
||||
function matchClassOfTier(tier: number): MatchClass {
|
||||
if (tier <= TIER_WORD_START) return "word";
|
||||
if (tier === TIER_SUBSTRING) return "inside-word";
|
||||
return "scattered";
|
||||
}
|
||||
|
||||
/** Closer classes first. */
|
||||
export function compareMatchClasses(a: MatchClass, b: MatchClass): number {
|
||||
return MATCH_CLASS_ORDER.indexOf(a) - MATCH_CLASS_ORDER.indexOf(b);
|
||||
}
|
||||
|
||||
/** The class of the query's loosest token, or null when some token matches no field. */
|
||||
export function textFieldsMatchClass(
|
||||
query: string,
|
||||
fields: string[],
|
||||
options: TextFieldsOptions = {},
|
||||
): MatchClass | null {
|
||||
const scores = scoreTokens(query, fields, options);
|
||||
if (!scores) return null;
|
||||
let loosest: MatchClass = "word";
|
||||
for (const { score } of scores) {
|
||||
const tokenClass = matchClassOfTier(score.tier);
|
||||
if (compareMatchClasses(tokenClass, loosest) > 0) loosest = tokenClass;
|
||||
}
|
||||
return loosest;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user