diff --git a/packages/app/e2e/browser/agent-relative-time.spec.ts b/packages/app/e2e/browser/agent-relative-time.spec.ts index 4fe1efe887..9a34350782 100644 --- a/packages/app/e2e/browser/agent-relative-time.spec.ts +++ b/packages/app/e2e/browser/agent-relative-time.spec.ts @@ -26,7 +26,7 @@ test("agent list age advances and command center says just now for a fresh agent const agentResult = commandCenter.getByTestId( `command-center-agent-${getServerId()}:${session.agentId}`, ); - await expect(agentResult.getByTestId("command-center-agent-subtitle")).toContainText( + await expect(agentResult.getByTestId("command-center-result-timestamp")).toContainText( "just now", ); await page.keyboard.press("Escape"); diff --git a/packages/app/e2e/support/helpers/command-center-file-search.ts b/packages/app/e2e/support/helpers/command-center-file-search.ts index 781470b582..ec6ade4102 100644 --- a/packages/app/e2e/support/helpers/command-center-file-search.ts +++ b/packages/app/e2e/support/helpers/command-center-file-search.ts @@ -203,7 +203,8 @@ export async function expectOneLineTruncatedFileResult( expect(lineBox).not.toBeNull(); expect(filenameBox).not.toBeNull(); expect(pathBox).not.toBeNull(); - expect(rowBox?.height).toBe(36); + // COMMAND_CENTER_LIST_METRICS.desktop.singleLineRow (list-metrics imports react-native). + expect(rowBox?.height).toBe(32); expect(Math.abs((filenameBox?.y ?? 0) - (pathBox?.y ?? 0))).toBeLessThanOrEqual(1); expect(overflow.scrollWidth).toBeGreaterThan(overflow.clientWidth); expect(overflow.overflow).toBe("hidden"); diff --git a/packages/app/src/command-center/command-center.tsx b/packages/app/src/command-center/command-center.tsx index 607f24c214..721e913811 100644 --- a/packages/app/src/command-center/command-center.tsx +++ b/packages/app/src/command-center/command-center.tsx @@ -16,7 +16,10 @@ import { Check, ChevronRight, Folder, X } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { BottomSheetFlatList, type BottomSheetFlatListMethods } from "@gorhom/bottom-sheet"; import { AgentStatusDot } from "@/components/agent-status-dot"; +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 { ProjectIconView } from "@/components/project-icon-view"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { EditingTextInput as TextInput, @@ -29,8 +32,11 @@ import { } from "@/components/ui/isolated-bottom-sheet-modal"; import { useIsCompactFormFactor } from "@/constants/layout"; import { isNative, isWeb } from "@/constants/platform"; -import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents"; +import { useAggregatedAgents } 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, useGlobalWebOverlayLayer, @@ -49,8 +55,14 @@ import { } from "@/utils/command-center-focus-restore"; import { focusWithRetries } from "@/utils/web-focus"; import { navigateToAgent } from "@/utils/navigate-to-agent"; -import { formatTimeAgo } from "@/utils/time"; import { shortenPath } from "@/utils/shorten-path"; +import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; +import { + COMMAND_CENTER_LINE_HEIGHT, + COMMAND_CENTER_LIST_METRICS, + COMMAND_CENTER_SECTION_GAP, + type CommandCenterListMetrics, +} from "./list-metrics"; import { useCommandCenterContributions } from "./provider"; import { filterAndRankWorkspaces } from "./workspace-search"; import { @@ -89,42 +101,14 @@ const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, (theme) => ({ const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"]; const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const; -function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number { - const leftNeedsInput = (left.pendingPermissionCount ?? 0) > 0 ? 1 : 0; - const rightNeedsInput = (right.pendingPermissionCount ?? 0) > 0 ? 1 : 0; - if (leftNeedsInput !== rightNeedsInput) return rightNeedsInput - leftNeedsInput; - const leftAttention = left.requiresAttention ? 1 : 0; - const rightAttention = right.requiresAttention ? 1 : 0; - if (leftAttention !== rightAttention) return rightAttention - leftAttention; - const leftRunning = left.status === "running" ? 1 : 0; - const rightRunning = right.status === "running" ? 1 : 0; - if (leftRunning !== rightRunning) return rightRunning - leftRunning; - return right.lastActivityAt.getTime() - left.lastActivityAt.getTime(); -} - -function compareWorkspacesByTitle( - left: CommandCenterWorkspaceResult, - right: CommandCenterWorkspaceResult, -): number { - const titleDelta = left.title.localeCompare(right.title, undefined, { - numeric: true, - sensitivity: "base", - }); - return titleDelta || left.subtitle.localeCompare(right.subtitle); -} - /** `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] }; } /** - * Build every pinned row, in its default order. Deliberately not keyed on `query`: none of this - * work depends on what was typed, and rebuilding it per keystroke means re-running an Intl - * collation sort over every workspace. - * - * The cost is that `formatTimeAgo` is baked into the agent subtitle here, so relative timestamps - * now refresh when agents or projects change rather than on every keystroke. + * 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[]; @@ -134,11 +118,32 @@ function useBuiltInRows(open: boolean): { const { agents } = useAggregatedAgents({ demand: open }); const { projects } = useProjects({ enabled: open }); const showHost = useHosts().length > 1; + const iconTargets = useMemo( + () => + open + ? projects.flatMap((project) => + project.hosts.flatMap((host) => { + const target = createProjectIconTarget({ + projectViewKey: project.viewKey, + placement: { ...host, iconWorkingDir: host.repoRoot }, + }); + return target ? [target] : []; + }), + ) + : [], + [open, projects], + ); + const iconDataByProjectViewKey = useProjectIcons({ projects: iconTargets }); return useMemo(() => { if (!open) return { workspaces: [], agents: [] }; const allWorkspaces: CommandCenterWorkspaceResult[] = []; for (const project of projects) { + const workspaceProject = { + viewKey: project.viewKey, + name: project.projectName, + iconDataUri: iconDataByProjectViewKey.get(project.viewKey) ?? null, + }; for (const host of project.hosts) { for (const workspace of host.workspaces) { if (workspace.archivingAt) continue; @@ -151,7 +156,9 @@ function useBuiltInRows(open: boolean): { project.projectName, workspace.currentBranch, ]), + project: workspaceProject, changeRequestNumber: workspace.changeRequestNumber, + activityAt: workspace.statusEnteredAt, run: () => { clearCommandCenterFocusRestoreElement(); navigateToWorkspace({ serverId: host.serverId, workspaceId: workspace.id }); @@ -160,34 +167,31 @@ function useBuiltInRows(open: boolean): { } } } - allWorkspaces.sort(compareWorkspacesByTitle); const workspaceTitleByKey = new Map( allWorkspaces.map((workspace) => [workspace.id.slice("workspace:".length), workspace.title]), ); - const agentRows = agents - .map((agent) => { - const workspaceTitle = agent.workspaceId - ? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`) - : undefined; - return { - kind: "agent", - id: `agent:${agent.serverId}:${agent.id}`, - agent, - title: agent.title || t("shell.commandCenter.newAgent"), - subtitle: joinSubtitleParts([ - showHost ? agent.serverLabel : null, - workspaceTitle ?? shortenPath(agent.cwd), - formatTimeAgo(agent.lastActivityAt), - ]), - run: () => { - clearCommandCenterFocusRestoreElement(); - navigateToAgent({ serverId: agent.serverId, agentId: agent.id }); - }, - }; - }) - .sort((left, right) => sortAgents(left.agent, right.agent)); + const agentRows = agents.map((agent) => { + const workspaceTitle = agent.workspaceId + ? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`) + : undefined; + return { + kind: "agent", + id: `agent:${agent.serverId}:${agent.id}`, + agent, + title: agent.title || t("shell.commandCenter.newAgent"), + subtitle: joinSubtitleParts([ + showHost ? agent.serverLabel : null, + workspaceTitle ?? shortenPath(agent.cwd), + ]), + activityAt: agent.lastActivityAt, + run: () => { + clearCommandCenterFocusRestoreElement(); + navigateToAgent({ serverId: agent.serverId, agentId: agent.id }); + }, + }; + }); return { workspaces: allWorkspaces, agents: agentRows }; - }, [agents, open, projects, showHost, t]); + }, [agents, iconDataByProjectViewKey, open, projects, showHost, t]); } function useBuiltInSections(open: boolean, query: string): CommandCenterResultSection[] { @@ -202,16 +206,14 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe band: PINNED_SECTION_BAND, rank: 2, title: t("shell.commandCenter.workspaces"), - results: filterAndRankWorkspaces(rows.workspaces, query, compareWorkspacesByTitle), + results: filterAndRankWorkspaces(rows.workspaces, query), }, { id: "agents", band: PINNED_SECTION_BAND, rank: 3, title: t("shell.commandCenter.agents"), - results: filterAndRankBuiltInResults(rows.agents, query, agentSearchFields, (left, right) => - sortAgents(left.agent, right.agent), - ), + results: filterAndRankBuiltInResults(rows.agents, query, agentSearchFields), }, ]; }, [open, query, rows, t]); @@ -236,7 +238,7 @@ interface CommandCenterState { key(key: string): boolean; } -function useCommandCenterState(): CommandCenterState { +function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCenterState { const keyboardActionDispatcher = useKeyboardActionDispatcher(); const { t } = useTranslation(); const open = useKeyboardShortcutsStore((state) => state.commandCenterOpen); @@ -290,8 +292,9 @@ function useCommandCenterState(): CommandCenterState { scope === "files" ? fileSections : [...contributionSections, ...fileSections, ...builtInSections], + listMetrics, ), - [builtInSections, contributionSections, fileSections, scope], + [builtInSections, contributionSections, fileSections, listMetrics, scope], ); const resolvedActiveId = preserveActiveResultId(activeId, projection.selectableResults); @@ -381,11 +384,19 @@ function useCommandCenterState(): CommandCenterState { interface ResultRowProps { result: CommandCenterResult; + height: number; + query: string; active: boolean; onSelect(result: CommandCenterResult): void; } -const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRowProps) { +const ResultRow = memo(function ResultRow({ + result, + height, + query, + active, + onSelect, +}: ResultRowProps) { const press = useCallback(() => onSelect(result), [onSelect, result]); const choice = result.kind === "contribution" && result.contribution.presentation.kind === "choice" @@ -402,15 +413,10 @@ const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRo const style = useCallback( ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ styles.row, - (result.kind === "agent" || - result.kind === "workspace" || - (result.kind === "contribution" && - result.contribution.presentation.kind === "action" && - Boolean(result.contribution.presentation.subtitle))) && - styles.tallRow, + { height }, (Boolean(hovered) || pressed || active) && styles.activeRow, ], - [active, result], + [active, height], ); return ( - + ); }); -function ResultContent({ result }: { result: CommandCenterResult }) { +/** + * Text with the query's terms marked. Marks use the same matcher options the rows were ranked + * with, so a mark always points at something that made the row match. + */ +function QueryHighlightedText({ + query, + text, + ...props +}: { query: string } & Omit) { + const ranges = useMemo(() => findHighlightRanges(query, text), [query, text]); + return ; +} + +/** + * The subtitle line of a workspace or agent row. The timestamp is its own text so a long branch + * truncates without pushing the time out of view, and so the time is never part of what the + * query matches against. It keeps itself current while the command center stays open. + */ +function SubtitleWithTimestamp({ + query, + subtitle, + activityAt, + testID, +}: { + query: string; + subtitle: string; + activityAt: Date | null; + testID: string; +}) { + const timeAgo = useTimeAgo(activityAt); + return ( + + + {timeAgo ? ( + + {/* Non-breaking: web collapses a leading plain space in its own flex item. */} + {subtitle ? `\u00a0· ${timeAgo}` : timeAgo} + + ) : null} + + ); +} + +/** The same project icon the sidebar's workspace rows lead with: its image, or a lettered square. */ +function WorkspaceProjectIcon({ project }: { project: CommandCenterWorkspaceResult["project"] }) { + const initial = projectIconPlaceholderLabelFromDisplayName(project.name).charAt(0).toUpperCase(); + return ( + + ); +} + +function ResultContent({ result, query }: { result: CommandCenterResult; query: string }) { if (result.kind === "file") { return ( @@ -438,13 +507,16 @@ function ResultContent({ result }: { result: CommandCenterResult }) { - - {result.title} - + {result.subtitle ? ( {" "} - {result.subtitle} + ) : null} @@ -465,12 +537,18 @@ function ResultContent({ result }: { result: CommandCenterResult }) { /> - - {result.title} - - - {result.subtitle} - + + @@ -482,19 +560,21 @@ function ResultContent({ result }: { result: CommandCenterResult }) { - + - - {result.title} - - + - {result.subtitle} - + /> @@ -512,11 +592,19 @@ function ResultContent({ result }: { result: CommandCenterResult }) { ) : null} - - {presentation.title} - + {presentation.subtitle ? ( - {presentation.subtitle} + ) : null} @@ -541,14 +629,14 @@ function ResultContent({ result }: { result: CommandCenterResult }) { style={styles.breadcrumbPart} > {index > 0 ? : null} - - {part} - + /> ))} @@ -563,11 +651,9 @@ function ResultContent({ result }: { result: CommandCenterResult }) { } function SectionRow({ row }: { row: Extract }) { - let sizeStyle = styles.dividerSection; - if (row.title && row.divider) sizeStyle = styles.dividedSection; - if (row.title && !row.divider) sizeStyle = styles.titledSection; + const style = useMemo(() => [styles.section, { height: row.height }], [row.height]); return ( - + {row.divider ? : null} {row.title ? {row.title} : null} @@ -576,8 +662,10 @@ function SectionRow({ row }: { row: Extract>(null); @@ -644,11 +732,13 @@ export function CommandCenter() { ) : ( ), - [state.activeId, state.select], + [state.activeId, state.query, state.select], ); const getItemLayout = useCallback( (_data: ArrayLike | null | undefined, index: number) => ({ @@ -901,20 +991,21 @@ const styles = StyleSheet.create((theme) => ({ 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" }, sectionLabel: { paddingHorizontal: theme.spacing[4], - paddingBottom: theme.spacing[2], + paddingBottom: COMMAND_CENTER_SECTION_GAP, fontSize: theme.fontSize.sm, + lineHeight: COMMAND_CENTER_LINE_HEIGHT.sectionLabel, color: theme.colors.foregroundMuted, }, sectionDivider: { height: 1, - marginTop: theme.spacing[2], - marginBottom: theme.spacing[2], + marginVertical: COMMAND_CENTER_SECTION_GAP, backgroundColor: theme.colors.border, }, - row: { height: 36, paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[2] }, - tallRow: { height: 56 }, + row: { paddingHorizontal: theme.spacing[4], justifyContent: "center" }, activeRow: { backgroundColor: theme.colors.surface1 }, rowContent: { flexDirection: "row", @@ -933,14 +1024,31 @@ const styles = StyleSheet.create((theme) => ({ title: { color: theme.colors.foreground, fontSize: theme.fontSize.base, - lineHeight: 18, + lineHeight: COMMAND_CENTER_LINE_HEIGHT.title, flexShrink: 1, }, - fileLine: { flex: 1, minWidth: 0, fontSize: theme.fontSize.base, lineHeight: 20 }, + fileLine: { + flex: 1, + minWidth: 0, + fontSize: theme.fontSize.base, + lineHeight: COMMAND_CENTER_LINE_HEIGHT.singleLine, + }, fileName: { color: theme.colors.foreground }, filePath: { color: theme.colors.foregroundMuted }, - subtitle: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, lineHeight: 16 }, - iconSlot: { width: 16, height: 20, alignItems: "center", justifyContent: "center" }, + subtitle: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + lineHeight: COMMAND_CENTER_LINE_HEIGHT.subtitle, + }, + subtitleLine: { flexDirection: "row", minWidth: 0 }, + subtitleText: { flexShrink: 1, minWidth: 0 }, + iconSlot: { + width: 16, + height: COMMAND_CENTER_LINE_HEIGHT.singleLine, + alignItems: "center", + justifyContent: "center", + }, + projectIconFallbackText: { fontSize: 9 }, rowShortcut: { flexShrink: 0 }, breadcrumb: { flex: 1, @@ -958,7 +1066,7 @@ const styles = StyleSheet.create((theme) => ({ breadcrumbGroup: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, - lineHeight: 20, + lineHeight: COMMAND_CENTER_LINE_HEIGHT.singleLine, flexShrink: 0, }, emptyText: { @@ -978,7 +1086,4 @@ const styles = StyleSheet.create((theme) => ({ }, sheetBackground: { backgroundColor: theme.colors.surface0 }, sheetHandle: { backgroundColor: theme.colors.palette.zinc[600] }, - titledSection: { height: 32, justifyContent: "flex-end" }, - dividedSection: { height: 49, justifyContent: "flex-end" }, - dividerSection: { height: 17, justifyContent: "flex-end" }, })); diff --git a/packages/app/src/command-center/list-metrics.ts b/packages/app/src/command-center/list-metrics.ts new file mode 100644 index 0000000000..603fc91e4c --- /dev/null +++ b/packages/app/src/command-center/list-metrics.ts @@ -0,0 +1,53 @@ +import { SPACING } from "@/styles/theme"; + +/** + * Every vertical size in the Command Center list, in one place. + * + * The list is virtualized, so it needs each row's height before rendering it. Rather than keep + * those numbers in sync with the styles by hand, rows render at exactly the height computed here + * and center their content. The styles read the line heights below, so this arithmetic is the + * layout. + * + * A layout chooses one thing: the inset above and below a row's text. Both layouts share the same + * shape, so nothing downstream asks which one it got. + */ +export const COMMAND_CENTER_LINE_HEIGHT = { + /** A row with no subtitle. Also the icon slot, so the icon centers on the title. */ + singleLine: 20, + title: 18, + subtitle: 16, + sectionLabel: 16, +} as const; + +/** Space around a section's label and around the rule between sections. */ +export const COMMAND_CENTER_SECTION_GAP = SPACING[2]; +const SECTION_DIVIDER_THICKNESS = 1; + +export interface CommandCenterListMetrics { + singleLineRow: number; + twoLineRow: number; + /** A section label and the gaps around it. */ + sectionTitle: number; + /** The rule between two sections and the gaps around it. */ + sectionDivider: number; +} + +function listMetrics(rowInset: number): CommandCenterListMetrics { + const line = COMMAND_CENTER_LINE_HEIGHT; + return { + singleLineRow: rowInset + line.singleLine + rowInset, + twoLineRow: rowInset + line.title + line.subtitle + rowInset, + sectionTitle: COMMAND_CENTER_SECTION_GAP + line.sectionLabel + COMMAND_CENTER_SECTION_GAP, + sectionDivider: + COMMAND_CENTER_SECTION_GAP + SECTION_DIVIDER_THICKNESS + COMMAND_CENTER_SECTION_GAP, + }; +} + +/** + * Desktop is dense for a pointer. Compact keeps single-line rows at the 44pt touch height that + * `density.rowHeight` uses on compact layouts. + */ +export const COMMAND_CENTER_LIST_METRICS = { + desktop: listMetrics(SPACING[1.5]), + compact: listMetrics(SPACING[3]), +} as const satisfies Record; diff --git a/packages/app/src/command-center/results.test.ts b/packages/app/src/command-center/results.test.ts index 4f6a43d704..688352f00f 100644 --- a/packages/app/src/command-center/results.test.ts +++ b/packages/app/src/command-center/results.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { CommandCenterContribution } from "./contributions"; +import { COMMAND_CENTER_LIST_METRICS } from "./list-metrics"; import { buildContributionSections, filterAndRankBuiltInResults, @@ -101,17 +102,28 @@ function autoModeChoice(): CommandCenterContribution { }); } -function workspace(id: string, title = id, subtitle = "host"): CommandCenterWorkspaceResult { +function workspace( + id: string, + title = id, + subtitle = "host", + activityAt: Date | null = null, +): CommandCenterWorkspaceResult { return { kind: "workspace", id, title, subtitle, + project: { viewKey: "project", name: "project", iconDataUri: null }, changeRequestNumber: null, + activityAt, run: () => undefined, }; } +function minutesAgo(minutes: number): Date { + return new Date(Date.UTC(2026, 9, 18, 12, 0) - minutes * 60_000); +} + function sectionResultIds(sections: ReturnType): string[] { const ids: string[] = []; for (const section of sections) { @@ -120,6 +132,8 @@ function sectionResultIds(sections: ReturnType return ids; } +const metrics = COMMAND_CENTER_LIST_METRICS.desktop; + describe("Command Center result projection", () => { it("query-gates model choices and creates one flat row index", () => { const contributions = [ @@ -130,16 +144,19 @@ describe("Command Center result projection", () => { expect(sectionResultIds(emptySections)).toEqual(["settings"]); const sections = buildContributionSections(contributions, "o"); - const projection = projectCommandCenterRows([ - ...sections, - { - id: "workspaces", - band: PINNED_SECTION_BAND, - rank: 2, - title: "Workspaces", - results: [workspace("workspace:1")], - }, - ]); + const projection = projectCommandCenterRows( + [ + ...sections, + { + id: "workspaces", + band: PINNED_SECTION_BAND, + rank: 2, + title: "Workspaces", + results: [workspace("workspace:1")], + }, + ], + metrics, + ); expect(projection.rows.map((row) => row.key)).toEqual([ "section:models", "opus", @@ -147,7 +164,12 @@ describe("Command Center result projection", () => { "workspace:1", ]); expect(projection.rowIndexByResultId.get("workspace:1")).toBe(3); - expect(projection.offsets).toEqual([0, 32, 68, 117]); + expect(projection.offsets).toEqual([ + 0, + metrics.sectionTitle, + metrics.sectionTitle + metrics.singleLineRow, + metrics.sectionTitle + metrics.singleLineRow + metrics.sectionDivider + metrics.sectionTitle, + ]); }); it("shows query-only actions only when their group matches a search", () => { @@ -177,15 +199,18 @@ describe("Command Center result projection", () => { it("keeps keyboard selection aligned with rows beyond the render window", () => { const workspaces = Array.from({ length: 200 }, (_, index) => workspace(`workspace:${index}`)); - const projection = projectCommandCenterRows([ - { - id: "workspaces", - band: PINNED_SECTION_BAND, - rank: 0, - title: "Workspaces", - results: workspaces, - }, - ]); + const projection = projectCommandCenterRows( + [ + { + id: "workspaces", + band: PINNED_SECTION_BAND, + rank: 0, + title: "Workspaces", + results: workspaces, + }, + ], + metrics, + ); let activeId: string | null = workspaces[0].id; for (let index = 0; index < 150; index += 1) { @@ -195,7 +220,7 @@ describe("Command Center result projection", () => { const targetId = workspaces[150].id; expect(activeId).toBe(targetId); expect(projection.rowIndexByResultId.get(targetId)).toBe(151); - expect(projection.offsets[151]).toBe(32 + 150 * 56); + expect(projection.offsets[151]).toBe(metrics.sectionTitle + 150 * metrics.twoLineRow); }); }); @@ -248,7 +273,7 @@ describe("Command Center relevance ordering", () => { schedulesAction(), action({ id: "new-agent", group: "workspace", groupRank: -1, title: "New agent" }), ]; - const projection = projectCommandCenterRows(buildContributionSections(shuffled, "")); + const projection = projectCommandCenterRows(buildContributionSections(shuffled, ""), metrics); // Query-gated choices stay hidden; the rest fall back to groupRank, unchanged by this feature. expect(projection.selectableResults.map((result) => result.id)).toEqual([ @@ -301,16 +326,19 @@ describe("Command Center relevance ordering", () => { }); it("keeps Workspaces below contributions even on a perfect match", () => { - const projection = projectCommandCenterRows([ - ...buildContributionSections([schedulesAction()], "auto"), - { - id: "workspaces", - band: PINNED_SECTION_BAND, - rank: 2, - title: "Workspaces", - results: [workspace("auto")], - }, - ]); + const projection = projectCommandCenterRows( + [ + ...buildContributionSections([schedulesAction()], "auto"), + { + id: "workspaces", + band: PINNED_SECTION_BAND, + rank: 2, + title: "Workspaces", + results: [workspace("auto")], + }, + ], + metrics, + ); expect(projection.selectableResults.map((result) => result.id)).toEqual(["schedules", "auto"]); }); @@ -324,7 +352,7 @@ describe("Command Center relevance ordering", () => { title: "Workspaces", results: [workspace("auto")], }; - const projection = projectCommandCenterRows([workspaces, ...sections.toReversed()]); + const projection = projectCommandCenterRows([workspaces, ...sections.toReversed()], metrics); expect(projection.selectableResults.map((result) => result.id)).toEqual([ "mode:auto", @@ -461,15 +489,19 @@ describe("filterAndRankBuiltInResults", () => { visible: [row.title, row.subtitle], hidden: [], }); - const byTitle = (left: CommandCenterWorkspaceResult, right: CommandCenterWorkspaceResult) => - left.title.localeCompare(right.title); - it("caps an empty query and preserves the caller's order", () => { - const rows = Array.from({ length: 8 }, (_, index) => workspace(`w${index}`)); + 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, byTitle).map((row) => row.id), - ).toEqual(["w0", "w1", "w2", "w3", "w4"]); + expect(filterAndRankBuiltInResults(rows, "", visibleOnly).map((row) => row.id)).toEqual([ + "w7", + "w6", + "w5", + "w4", + "w3", + ]); }); it("puts the best match first even when it sorts last alphabetically", () => { @@ -480,9 +512,9 @@ describe("filterAndRankBuiltInResults", () => { ]; // Alphabetically this is Alpha, Label, Zebra. "lab des" only matches Label as Design. - expect( - filterAndRankBuiltInResults(rows, "lab des", visibleOnly, byTitle).map((row) => row.id), - ).toEqual(["c"]); + 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", () => { @@ -492,24 +524,41 @@ describe("filterAndRankBuiltInResults", () => { ? { visible: [row.title], hidden: ["deploy"] } : { visible: [row.title], hidden: [] }; - expect( - filterAndRankBuiltInResults(rows, "deploy", fields, byTitle).map((row) => row.id), - ).toEqual(["title-hit", "hidden-hit"]); + expect(filterAndRankBuiltInResults(rows, "deploy", fields).map((row) => row.id)).toEqual([ + "title-hit", + "hidden-hit", + ]); }); - it("breaks an equal score with the caller's tiebreak, not with match order", () => { - // Both titles are an exact prefix match for "task", so only the tiebreak separates them. - const rows = [workspace("second", "Task zebra"), workspace("first", "Task alpha")]; + it("orders rows in the same match tier by recency, not by where the word sits", () => { + // Both are whole-word matches for "usage"; the older one finds it earlier in the title. + const rows = [ + workspace("older", "Deliver sidebar usage", "host", minutesAgo(60)), + workspace("newer", "design the cli interface to expose usage data", "host", minutesAgo(5)), + ]; - expect( - filterAndRankBuiltInResults(rows, "task", visibleOnly, byTitle).map((row) => row.id), - ).toEqual(["first", "second"]); + expect(filterAndRankBuiltInResults(rows, "usage", visibleOnly).map((row) => row.id)).toEqual([ + "newer", + "older", + ]); + }); + + it("keeps a better match tier above a more recent weaker match", () => { + const rows = [ + workspace("subsequence", "Fix stuck toast", "fix/updating-messages", minutesAgo(1)), + workspace("whole-word", "Usage hover card", "host", minutesAgo(600)), + ]; + + expect(filterAndRankBuiltInResults(rows, "usage", visibleOnly).map((row) => row.id)).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, byTitle)).toEqual([]); + expect(filterAndRankBuiltInResults(rows, "lab zzz", visibleOnly)).toEqual([]); }); }); diff --git a/packages/app/src/command-center/results.ts b/packages/app/src/command-center/results.ts index 8f3528cd2c..84fc91d3f0 100644 --- a/packages/app/src/command-center/results.ts +++ b/packages/app/src/command-center/results.ts @@ -5,6 +5,7 @@ import { scoreTextFields, } from "@getpaseo/protocol/search/text-match"; import type { CommandCenterContribution } from "./contributions"; +import type { CommandCenterListMetrics } from "./list-metrics"; /** Sections that reorder by how well they match the query. */ export const CONTRIBUTION_SECTION_BAND = 0; @@ -43,13 +44,21 @@ export function compareCommandCenterScores( return left.fieldRank - right.fieldRank || compareMatchScores(left.match, right.match); } +export interface CommandCenterWorkspaceProject { + viewKey: string; + name: string; + iconDataUri: string | null; +} + export interface CommandCenterWorkspaceResult { kind: "workspace"; id: string; title: string; subtitle: string; + project: CommandCenterWorkspaceProject; /** PR/MR number backing this workspace, so a number query can match it exactly. */ changeRequestNumber: number | null; + activityAt: Date | null; run(): void; } @@ -59,6 +68,7 @@ export interface CommandCenterAgentResult { agent: AggregatedAgent; title: string; subtitle: string; + activityAt: Date | null; run(): void; } @@ -169,13 +179,12 @@ function contributionSearchFields( return { visible, hidden: contribution.keywords }; } -function resultHeight(result: CommandCenterResult): number { - if (result.kind === "workspace" || result.kind === "agent") return 56; - if (result.kind === "file") return 36; - if (result.contribution.presentation.kind === "action") { - return result.contribution.presentation.subtitle ? 56 : 36; - } - return 36; +/** 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; + const presentation = result.contribution.presentation; + return presentation.kind === "action" && Boolean(presentation.subtitle); } /** @@ -203,22 +212,42 @@ function resultScore(result: CommandCenterResult): CommandCenterScore | undefine /** 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. */ +export interface CommandCenterRecentRow { + activityAt: Date | null; +} + +/** Most recent first. A row with no timestamp sorts after every row that has one. */ +export function compareByRecency(left: CommandCenterRecentRow, right: CommandCenterRecentRow) { + return (right.activityAt?.getTime() ?? 0) - (left.activityAt?.getTime() ?? 0); +} + +/** + * Rank built-in rows by the kind of match they made: which fields matched, then the matcher's + * tier. Offset and spread are deliberately left out. They only say where in the title the word + * happened to sit, so "usage" in "Deliver sidebar usage" and in "expose usage data" would be + * ordered by sentence position. Leaving them out makes those rows equal, and recency decides. + */ +export function compareBuiltInScores(left: CommandCenterScore, right: CommandCenterScore): number { + return left.fieldRank - right.fieldRank || left.match.tier - right.match.tier; +} + /** * Filter and rank already-built rows. Rows in, rows out — building them needs translations, * navigation callbacks and the project tree, none of which matching cares about, so that stays * with the caller and this stays testable without mounting React. * - * `tiebreak` is the caller's own ordering (alphabetical for workspaces, status and recency for - * agents). It decides equal-scoring rows, so an agent awaiting input still outranks an idle one - * that matched exactly as well. + * Rows rank by `compareBuiltInScores`; rows that rank equally, including every row of an empty + * query, go most recent first. */ -export function filterAndRankBuiltInResults( +export function filterAndRankBuiltInResults( rows: readonly Row[], query: string, fieldsOf: (row: Row) => CommandCenterSearchFields, - tiebreak: (left: Row, right: Row) => number, ): Row[] { - if (!query.trim()) return rows.slice(0, DEFAULT_CATEGORY_RESULT_LIMIT); + if (!query.trim()) { + return [...rows].sort(compareByRecency).slice(0, DEFAULT_CATEGORY_RESULT_LIMIT); + } const scored: { row: Row; score: CommandCenterScore }[] = []; for (const row of rows) { @@ -227,7 +256,7 @@ export function filterAndRankBuiltInResults( } scored.sort( (left, right) => - compareCommandCenterScores(left.score, right.score) || tiebreak(left.row, right.row), + compareBuiltInScores(left.score, right.score) || compareByRecency(left.row, right.row), ); return scored.map((entry) => entry.row); } @@ -293,6 +322,7 @@ export function buildContributionSections( export function projectCommandCenterRows( sections: readonly CommandCenterResultSection[], + metrics: CommandCenterListMetrics, ): CommandCenterListProjection { const populated = sections .filter((section) => section.results.length > 0) @@ -305,10 +335,8 @@ export function projectCommandCenterRows( for (const [sectionIndex, section] of populated.entries()) { const divider = sectionIndex > 0; - let sectionHeight = 0; - if (section.title && divider) sectionHeight = 49; - if (section.title && !divider) sectionHeight = 32; - if (!section.title && divider) sectionHeight = 17; + const sectionHeight = + (divider ? metrics.sectionDivider : 0) + (section.title ? metrics.sectionTitle : 0); if (sectionHeight > 0) { offsets.push(offset); rows.push({ @@ -321,7 +349,7 @@ export function projectCommandCenterRows( offset += sectionHeight; } for (const result of section.results) { - const height = resultHeight(result); + const height = hasSubtitleLine(result) ? metrics.twoLineRow : metrics.singleLineRow; offsets.push(offset); rowIndexByResultId.set(result.id, rows.length); rows.push({ kind: "result", key: result.id, result, height }); diff --git a/packages/app/src/command-center/workspace-search.test.ts b/packages/app/src/command-center/workspace-search.test.ts index 2cdae53dd2..93bb5fc6bf 100644 --- a/packages/app/src/command-center/workspace-search.test.ts +++ b/packages/app/src/command-center/workspace-search.test.ts @@ -7,21 +7,22 @@ function workspace(input: { title?: string; subtitle?: string; changeRequestNumber?: number | null; + activityAt?: Date | null; }): CommandCenterWorkspaceResult { return { kind: "workspace", id: input.id, title: input.title ?? input.id, subtitle: input.subtitle ?? "", + project: { viewKey: "project", name: "project", iconDataUri: null }, changeRequestNumber: input.changeRequestNumber ?? null, + activityAt: input.activityAt ?? null, run: () => undefined, }; } function ids(rows: readonly CommandCenterWorkspaceResult[], query: string): string[] { - return filterAndRankWorkspaces(rows, query, (left, right) => left.id.localeCompare(right.id)).map( - (row) => row.id, - ); + return filterAndRankWorkspaces(rows, query).map((row) => row.id); } describe("filterAndRankWorkspaces", () => { @@ -79,4 +80,18 @@ describe("filterAndRankWorkspaces", () => { const text = workspace({ id: "a-text", title: "Migration 42" }); expect(ids([text, subject], "42")).toEqual(["payments", "a-text"]); }); + + it("orders workspaces in the same match tier by recency", () => { + const older = workspace({ + id: "older", + title: "Usage hover card", + activityAt: new Date("2026-10-01T00:00:00Z"), + }); + const newer = workspace({ + id: "newer", + title: "Deliver sidebar usage", + activityAt: new Date("2026-10-17T00:00:00Z"), + }); + expect(ids([older, newer], "usage")).toEqual(["newer", "older"]); + }); }); diff --git a/packages/app/src/command-center/workspace-search.ts b/packages/app/src/command-center/workspace-search.ts index 317d1f1561..40c7c34d01 100644 --- a/packages/app/src/command-center/workspace-search.ts +++ b/packages/app/src/command-center/workspace-search.ts @@ -1,5 +1,6 @@ import { - compareCommandCenterScores, + compareBuiltInScores, + compareByRecency, filterAndRankBuiltInResults, scoreSearchFields, type CommandCenterScore, @@ -33,29 +34,22 @@ function searchFields(workspace: CommandCenterWorkspaceResult) { return { visible: [workspace.title, workspace.subtitle], hidden: [] }; } -function compareScoredWorkspaces( - left: ScoredWorkspace, - right: ScoredWorkspace, - tiebreak: (left: CommandCenterWorkspaceResult, right: CommandCenterWorkspaceResult) => number, -): number { +function compareScoredWorkspaces(left: ScoredWorkspace, right: ScoredWorkspace): number { if (left.changeRequestHit !== right.changeRequestHit) { return left.changeRequestHit ? -1 : 1; } if (left.score && right.score) { - const scoreDelta = compareCommandCenterScores(left.score, right.score); + const scoreDelta = compareBuiltInScores(left.score, right.score); if (scoreDelta !== 0) return scoreDelta; } - return tiebreak(left.workspace, right.workspace); + return compareByRecency(left.workspace, right.workspace); } export function filterAndRankWorkspaces( workspaces: readonly CommandCenterWorkspaceResult[], query: string, - tiebreak: (left: CommandCenterWorkspaceResult, right: CommandCenterWorkspaceResult) => number, ): CommandCenterWorkspaceResult[] { - if (!query.trim()) { - return filterAndRankBuiltInResults(workspaces, query, searchFields, tiebreak); - } + if (!query.trim()) return filterAndRankBuiltInResults(workspaces, query, searchFields); const changeRequestNumber = parseChangeRequestQuery(query); const matches: ScoredWorkspace[] = []; @@ -65,6 +59,6 @@ export function filterAndRankWorkspaces( changeRequestNumber !== null && workspace.changeRequestNumber === changeRequestNumber; if (score || changeRequestHit) matches.push({ workspace, score, changeRequestHit }); } - matches.sort((left, right) => compareScoredWorkspaces(left, right, tiebreak)); + matches.sort(compareScoredWorkspaces); return matches.map((match) => match.workspace); } diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 7da36742ae..b487e53660 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -48,6 +48,8 @@ const DATE_SECTION_ORDER = [ "older", ] as const satisfies readonly DateSectionKey[]; +const HISTORY_MATCH_OPTIONS = { typoTolerant: true } as const; + type FlatListItem = | { type: "header"; key: string; section: DateSectionKey } | { type: "agent"; key: string; agent: AggregatedAgent }; @@ -190,10 +192,11 @@ function SessionRow({ const pendingPermissionCount = agent.pendingPermissionCount ?? 0; const ranges = useMemo( () => ({ - workspace: findHighlightRanges(search ?? "", workspaceName), - title: findHighlightRanges(search ?? "", agent.title ?? ""), - branch: findHighlightRanges(search ?? "", branch), - project: findHighlightRanges(search ?? "", projectName), + // History search matches typo-tolerantly on the daemon, so the marks do too. + workspace: findHighlightRanges(search ?? "", workspaceName, HISTORY_MATCH_OPTIONS), + title: findHighlightRanges(search ?? "", agent.title ?? "", HISTORY_MATCH_OPTIONS), + branch: findHighlightRanges(search ?? "", branch, HISTORY_MATCH_OPTIONS), + project: findHighlightRanges(search ?? "", projectName, HISTORY_MATCH_OPTIONS), }), [search, workspaceName, agent.title, branch, projectName], ); diff --git a/packages/app/src/components/ui/highlighted-text-segments.test.ts b/packages/app/src/components/ui/highlighted-text-segments.test.ts index 6f42fe823d..4e23389fde 100644 --- a/packages/app/src/components/ui/highlighted-text-segments.test.ts +++ b/packages/app/src/components/ui/highlighted-text-segments.test.ts @@ -63,7 +63,9 @@ describe("findHighlightRanges", () => { ); }); it("uses the search matcher for typo and scattered-character matches", () => { - expect(marked("billing", findHighlightRanges("bulling", "billing"))).toBe("[billing]"); + expect( + marked("billing", findHighlightRanges("bulling", "billing", { typoTolerant: true })), + ).toBe("[billing]"); expect(marked("paseo-babysit", findHighlightRanges("pasbab", "paseo-babysit"))).toBe( "[pas]eo-[bab]ysit", ); @@ -79,6 +81,9 @@ describe("findHighlightRanges", () => { "check [conf]i[g]uration", ); }); + it("marks typos only when the list was matched typo-tolerantly", () => { + expect(findHighlightRanges("bulling", "billing")).toEqual([]); + }); it("merges overlapping tokens and ignores empty or unmatched queries", () => { expect(marked("billing", findHighlightRanges("bill billing", "billing"))).toBe("[billing]"); expect(findHighlightRanges("", "billing")).toEqual([]); diff --git a/packages/app/src/components/ui/highlighted-text-segments.ts b/packages/app/src/components/ui/highlighted-text-segments.ts index 0bd4f1e0bd..f65f6c44eb 100644 --- a/packages/app/src/components/ui/highlighted-text-segments.ts +++ b/packages/app/src/components/ui/highlighted-text-segments.ts @@ -4,6 +4,7 @@ import { scoreMatch, tokenizeQuery, type MatchRange, + type TextFieldsOptions, } from "@getpaseo/protocol/search/text-match"; export interface HighlightSegment { @@ -40,11 +41,22 @@ export function toHighlightSegments( return segments; } -/** Best-effort presentation of each query token in this field, independently of other fields. */ -export function findHighlightRanges(query: string, text: string): MatchRange[] { +/** + * Best-effort presentation of each query token in this field, independently of other fields. + * Pass the same options the list was matched with, so a mark never claims a match the list did + * not make. + */ +export function findHighlightRanges( + query: string, + text: string, + options: TextFieldsOptions = {}, +): MatchRange[] { const ranges = tokenizeQuery(query) .flatMap((token) => { - const score = scoreMatch(token, text, { fuzzy: fuzzyPolicyForToken(token) }); + const score = scoreMatch(token, text, { + fuzzy: options.typoTolerant ? fuzzyPolicyForToken(token) : null, + subsequence: options.subsequence, + }); return score ? matchRanges(token, text, score) : []; }) .sort((left, right) => left.start - right.start); diff --git a/packages/app/src/screens/projects-screen.test.tsx b/packages/app/src/screens/projects-screen.test.tsx index 0e3337e240..a91043a709 100644 --- a/packages/app/src/screens/projects-screen.test.tsx +++ b/packages/app/src/screens/projects-screen.test.tsx @@ -182,6 +182,7 @@ function workspaceSummary(overrides: Partial = {}): WorkspaceS status: "done", currentBranch: "main", changeRequestNumber: null, + statusEnteredAt: null, ...overrides, }; } diff --git a/packages/app/src/utils/projects.ts b/packages/app/src/utils/projects.ts index f5ee1142c4..647561c56f 100644 --- a/packages/app/src/utils/projects.ts +++ b/packages/app/src/utils/projects.ts @@ -13,6 +13,7 @@ export interface WorkspaceSummary { archivingAt?: string; /** Pull/merge request number backing this workspace, so a query can find it. */ changeRequestNumber: number | null; + statusEnteredAt: Date | null; } export interface ProjectHostEntry { @@ -161,6 +162,7 @@ function toWorkspaceSummary(workspace: WorkspaceDescriptor): WorkspaceSummary { currentBranch: currentBranch && currentBranch !== "HEAD" ? currentBranch : null, ...(workspace.archivingAt ? { archivingAt: workspace.archivingAt } : {}), changeRequestNumber: toChangeRequestNumber(workspace), + statusEnteredAt: workspace.statusEnteredAt, }; }