Compare commits

...
Author SHA1 Message Date
Mohamed Boudra d50e51ed87 fix(app): settle archived search when no history host is reachable
With no searchable host online, the history query is disabled under a new
key, and keepPreviousData kept serving the old key's rows as placeholder
data. Archived search read that as still waiting for results, so the
command center selection never settled and followed every re-rank.
Previous results now stand in only while the query can run.
2026-11-14 17:22:00 +01:00
Mohamed Boudra 80c09fd38f style(app): format command center workspace search import 2026-11-14 17:10:29 +01:00
Mohamed Boudra 88ab1d8e01 fix(app): decide command center agent matches by match class
One rule now picks both what a workspace row shows and how it ranks. A
match falls in a class: every word at a word boundary, a word inside a
word, or scattered/misspelled. A row names its agent only when the agent
matches in a closer class than the workspace's own text, never on a
change-request hit. Rows rank by class, then own text ahead of an agent,
then score, then recency.

The previous rules chose the ranking candidate and the displayed
candidate separately, so each fix moved the edge case: positional tier
gaps named agents mid-word, and ranking by a hidden agent let a mid-word
name outrank a word-boundary one with nothing on screen to explain it.
2026-11-14 16:56:20 +01:00
Mohamed Boudra 00f33faa27 fix(app): keep Projects loading honest, rank own-name matches by their agents too, refresh archived search after archive
- The command center waits for online hosts' workspaces itself instead of
  through useProjects. useProjects held isLoading until workspaces
  hydrated, but a failed workspace request never hydrates, so the
  Projects screen could spin forever.
- A workspace that matches on its own name is ranked by the better of
  that match and its agents', so typing into an agent title shared with
  another workspace no longer drops it below that workspace and back.
- Archiving a workspace marks agent history stale. Archived search reused
  a cached page that still listed the agents as active, so a workspace
  archived in the last 30 seconds matched in neither list.
2026-11-14 16:37:32 +01:00
Mohamed Boudra b39b004b0c fix(app): settle command center selection, name agents only for loose matches, drop Files off-workspace
- Name a workspace's agent only when the workspace's own text does not match
  contiguously and the agent does; a change-request hit is always an own match.
- Follow the first row only until every source for the query and tab has
  loaded, then keep the selection by ID so reorders do not move it.
- Projects stay loading until an online host's workspaces have arrived; the
  agent directory reports ready first.
- Outside a workspace there is no Files tab and All lists no files; the All
  placeholder names only what the available tabs search.
- The unqueried Workspaces list follows the sidebar's Show background setting.
2026-11-14 16:18:07 +01:00
Mohamed Boudra 788fe8556b fix(app): rank agent matches by quality, keep background workspaces out of Recent, explain Files off-workspace
- Score each agent together with its workspace's title and subtitle, so a
  query split across them ("payments login") finds the row, and pick the
  workspace's own match only when it is as good as its best agent match.
  Across workspaces, match quality now ranks before own-versus-agent.
- Carry the workspace's background flag through the project summary and
  leave background workspaces out of Recent workspaces, as the sidebar
  does. A query still finds them.
- The Files tab outside a workspace says "Open a workspace to search
  files" instead of "No matches".
2026-11-14 15:23:40 +01:00
Mohamed Boudra cf46337fa6 fix(app): put the command center divider between the field and the tabs
The tabs belong to the results, not the search field, so the rule now
separates the field from the tab bar. The tab-rotation test also pins the
list's top, since the tabs no longer live inside the header it measured.
2026-11-14 14:49:47 +01:00
Mohamed Boudra 362b4bd465 feat(app): archived workspaces in command center search
A query in All or Workspaces now also finds archived workspaces through
agent history, appended after the active matches. Archived rows show the
archive time and an Archived badge, and open the workspace's latest agent
where it can be unarchived. Hosts without history search contribute
nothing. History's archived badge moves into a shared ArchivedBadge.
2026-11-14 10:32:47 +01:00
Mohamed Boudra cea8a07e62 feat(app): command center tabs, recent workspaces, and agents in workspace search
The command center gets All · Workspaces · Files · Actions tabs under the search
field. Tab and Shift+Tab rotate them on web, a tab keeps the query and starts at
its top, and Backspace on an empty field returns to All. ⌘P opens on Files in
place of the Files scope chip.

All leads with the three most recent workspaces other than the open one. Agent
rows are gone: an agent's title now finds its workspace, which shows the agent on
a third line and opens that agent's tab.

The selection now follows the first result until the user moves it, so rows that
load above it do not leave it on the row below.
2026-11-14 10:15:40 +01:00
52 changed files with 2326 additions and 643 deletions
+2
View File
@@ -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 };
}
+412 -295
View File
@@ -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([]);
+30 -11
View File
@@ -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,
+47 -45
View File
@@ -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([]);
});
});
+48 -51
View File
@@ -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",
]);
});
});
+120
View File
@@ -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");
});
});
+39
View File
@@ -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,
);
};
}
+4 -14
View File
@@ -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} />;
}
+27 -3
View File
@@ -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,
+4 -4
View File
@@ -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");
+11 -2
View File
@@ -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}} في اللوحة الجانبية",
+11 -2
View File
@@ -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",
+11 -2
View File
@@ -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",
+11 -2
View File
@@ -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",
+11 -2
View File
@@ -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}}をサイドパネルで開く",
+11 -2
View File
@@ -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}} 열기",
+11 -2
View File
@@ -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",
+11 -2
View File
@@ -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}} в боковой панели",
+11 -2
View File
@@ -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", () => {
+2 -2
View File
@@ -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":
+1 -3
View File
@@ -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) => {
+3
View File
@@ -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);
+59 -11
View File
@@ -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;
}