From a65478d0bfd25fa8b08adac54273e3e490ca341a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 18 Oct 2026 14:24:14 +0700 Subject: [PATCH] Rank Command Center results by match then recency, with highlights, timestamps, and project icons (#7277) * feat(app): rank, highlight, and timestamp Command Center results Workspace and agent rows now show when they were last active, rank by match quality and then recency, mark the query terms in every result kind, and lead workspaces with their project icon. Ties used to be broken by where the match landed in the string, so "Deliver sidebar usage" beat "expose usage data" on sentence position and recency almost never decided anything. Built-in rows now compare only which fields matched and the matcher tier, then the most recent row wins. The empty query follows the same rule, so it lists the most recent workspaces and agents instead of alphabetical workspaces and status-first agents. Row and section heights live in list-metrics: a layout picks one row inset and every height derives from it plus shared line boxes, which the styles also read. The layout is chosen once at the top, so rows never branch on it, and two-line rows are now vertically centered. Desktop is denser (32/46); compact keeps 44pt touch rows (44/58). findHighlightRanges takes the matcher's options so the marks match what the list matched: the Command Center is not typo-tolerant, the history list is. * fix(app): keep the project icon out of Command Center row names The fallback project icon draws the project's initial as text, which became the first word of the row's accessible name ("C Command Center ..."), so screen readers announced it. The icon is decorative. The scroll spec now reads the newest seeded workspace: the empty query lists the most recent workspaces, so the first one seeded is no longer among them. --- .../e2e/browser/agent-relative-time.spec.ts | 2 +- .../e2e/browser/command-center-scroll.spec.ts | 3 +- .../helpers/command-center-file-search.ts | 3 +- .../app/src/command-center/command-center.tsx | 349 ++++++++++++------ .../app/src/command-center/list-metrics.ts | 53 +++ .../app/src/command-center/results.test.ts | 157 +++++--- packages/app/src/command-center/results.ts | 66 +++- .../command-center/workspace-search.test.ts | 21 +- .../src/command-center/workspace-search.ts | 20 +- packages/app/src/components/agent-list.tsx | 11 +- .../ui/highlighted-text-segments.test.ts | 7 +- .../ui/highlighted-text-segments.ts | 18 +- .../app/src/screens/projects-screen.test.tsx | 1 + packages/app/src/utils/projects.ts | 2 + 14 files changed, 494 insertions(+), 219 deletions(-) create mode 100644 packages/app/src/command-center/list-metrics.ts 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/browser/command-center-scroll.spec.ts b/packages/app/e2e/browser/command-center-scroll.spec.ts index 598e35e592..f6762bb4cc 100644 --- a/packages/app/e2e/browser/command-center-scroll.spec.ts +++ b/packages/app/e2e/browser/command-center-scroll.spec.ts @@ -45,7 +45,8 @@ test("opening the command center keeps its first result fully visible", async ({ await expectCommandCenterToRemainAtTheTop(page); await expectPrimaryCommandCenterActions(panel); await expectVisibleKeyboardNavigationNotToScroll(page, panel); - await expectTightTwoLineResultSpacing(panel, "Command Center Scroll 00"); + // The empty query lists the most recent workspaces, so the last one seeded leads it. + await expectTightTwoLineResultSpacing(panel, "Command Center Scroll 08"); await expectWorkspaceResultsToBeLimitedUntilSearch(panel); } finally { await seeded.cleanup(); 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..febac60a14 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} - + + @@ -481,20 +559,28 @@ function ResultContent({ result }: { result: CommandCenterResult }) { return ( - - + {/* Decorative: the fallback icon's initial must not prefix the row's accessible name. */} + + - - {result.title} - - + - {result.subtitle} - + /> @@ -512,11 +598,19 @@ function ResultContent({ result }: { result: CommandCenterResult }) { ) : null} - - {presentation.title} - + {presentation.subtitle ? ( - {presentation.subtitle} + ) : null} @@ -541,14 +635,14 @@ function ResultContent({ result }: { result: CommandCenterResult }) { style={styles.breadcrumbPart} > {index > 0 ? : null} - - {part} - + /> ))} @@ -563,11 +657,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 +668,10 @@ function SectionRow({ row }: { row: Extract>(null); @@ -644,11 +738,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 +997,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 +1030,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 +1072,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 +1092,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, }; }