Files
paseo/packages/app/src/utils/projects.ts
T
Mohamed Boudra a65478d0bf 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.
2026-10-18 14:24:14 +07:00

301 lines
9.4 KiB
TypeScript

import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import type { HostProjectListItem } from "@/projects/host-project-model";
import { buildWorkspaceStructureProjects } from "@/projects/workspace-structure";
import { selectPrHintFromStatus } from "@/git/pr-hint";
export interface WorkspaceSummary {
id: string;
name: string;
title?: string;
workspaceKind: WorkspaceDescriptor["workspaceKind"];
status: WorkspaceDescriptor["status"];
currentBranch: string | null;
archivingAt?: string;
/** Pull/merge request number backing this workspace, so a query can find it. */
changeRequestNumber: number | null;
statusEnteredAt: Date | null;
}
export interface ProjectHostEntry {
serverId: string;
projectId: string;
projectName: string;
projectCustomName: string | null;
serverName: string;
isOnline: boolean;
repoRoot: string;
workspaceCount: number;
workspaces: WorkspaceSummary[];
gitRuntime?: WorkspaceDescriptor["gitRuntime"];
githubRuntime?: WorkspaceDescriptor["githubRuntime"];
customIconRevision?: string | null;
iconRevision?: string;
}
export interface ProjectSummary {
viewKey: string;
projectName: string;
projectCustomName?: string | null;
hosts: ProjectHostEntry[];
totalWorkspaceCount: number;
hostCount: number;
onlineHostCount: number;
githubUrl?: string;
}
export interface ProjectHost {
serverId: string;
serverName: string;
isOnline: boolean;
workspaces: WorkspaceDescriptor[];
projects: ProjectDescriptor[];
}
export interface BuildProjectsInput {
hosts: ProjectHost[];
}
export interface BuildProjectsResult {
projects: ProjectSummary[];
}
export function getProjectSummaryForHostProject(
projects: readonly ProjectSummary[],
serverId: string,
projectId: string,
): ProjectSummary | undefined {
for (const project of projects) {
for (const host of project.hosts) {
if (host.serverId === serverId && host.projectId === projectId) return project;
}
}
return undefined;
}
export function getProjectHostEntry(
project: ProjectSummary | undefined,
serverId: string,
projectId?: string,
): ProjectHostEntry | undefined {
for (const host of project?.hosts ?? []) {
if (host.serverId === serverId && (!projectId || host.projectId === projectId)) return host;
}
return undefined;
}
interface HostGroup {
serverId: string;
projectId: string;
projectName: string;
projectCustomName: string | null;
serverName: string;
isOnline: boolean;
workspaces: WorkspaceDescriptor[];
customIconRevision?: string | null;
iconRevision?: string;
// The project record exists before its first workspace. Keep its editable
// root at the project boundary instead of deriving it from child rows.
projectRoot: string;
}
interface ProjectGroup {
viewKey: string;
projectName: string;
projectCustomName: string | null;
hostsByServerId: Map<string, HostGroup>;
}
function findProjectMetadata(
host: ProjectHost,
projectId: string,
): { customName: string | null; displayName: string } | null {
for (const project of host.projects) {
if (project.projectId === projectId) {
return {
customName: project.projectCustomName ?? null,
displayName: project.projectDisplayName,
};
}
}
return null;
}
function buildHostProjectEntries(hosts: ProjectHost[]): HostProjectListItem[] {
return buildWorkspaceStructureProjects({
sessions: hosts.map((host) => ({
serverId: host.serverId,
projects: host.projects,
workspaces: host.workspaces,
})),
});
}
function resolveHostRepoRoot(input: {
projectRoot: string;
workspaces: readonly WorkspaceDescriptor[];
}): string {
return input.workspaces[0]?.projectRootPath || input.projectRoot;
}
/**
* Resolve the workspace's pull/merge request number.
*
* The wire carries the number directly, so prefer it. `selectPrHintFromStatus`
* is only the fallback: it derives the number by regex-parsing the PR/MR URL,
* which yields null for a noncanonical or absent URL even when the daemon sent
* a perfectly good number.
*/
function toChangeRequestNumber(workspace: WorkspaceDescriptor): number | null {
const pullRequest = workspace.githubRuntime?.pullRequest;
if (!pullRequest) return null;
return pullRequest.number ?? selectPrHintFromStatus(pullRequest, workspace.forge)?.number ?? null;
}
function toWorkspaceSummary(workspace: WorkspaceDescriptor): WorkspaceSummary {
const currentBranch = workspace.gitRuntime?.currentBranch?.trim();
return {
id: workspace.id,
name: workspace.name,
...(workspace.title ? { title: workspace.title } : {}),
workspaceKind: workspace.workspaceKind,
status: workspace.status,
currentBranch: currentBranch && currentBranch !== "HEAD" ? currentBranch : null,
...(workspace.archivingAt ? { archivingAt: workspace.archivingAt } : {}),
changeRequestNumber: toChangeRequestNumber(workspace),
statusEnteredAt: workspace.statusEnteredAt,
};
}
function toHostEntry(group: HostGroup): ProjectHostEntry {
const repoRoot = resolveHostRepoRoot(group);
const canonical =
group.workspaces.find((workspace) => workspace.projectRootPath === repoRoot) ??
group.workspaces[0];
return {
serverId: group.serverId,
projectId: group.projectId,
projectName: group.projectName,
projectCustomName: group.projectCustomName,
serverName: group.serverName,
isOnline: group.isOnline,
repoRoot,
workspaceCount: group.workspaces.length,
workspaces: group.workspaces.map(toWorkspaceSummary),
gitRuntime: canonical?.gitRuntime,
githubRuntime: canonical?.githubRuntime,
customIconRevision: canonical?.projectCustomIconRevision ?? group.customIconRevision,
iconRevision: group.iconRevision,
};
}
function compareHosts(left: ProjectHostEntry, right: ProjectHostEntry): number {
const name = left.serverName.localeCompare(right.serverName);
if (name !== 0) {
return name;
}
return left.serverId.localeCompare(right.serverId);
}
function toProjectSummary(draft: ProjectGroup): ProjectSummary {
const hosts = Array.from(draft.hostsByServerId.values()).map(toHostEntry).sort(compareHosts);
const totalWorkspaceCount = hosts.reduce((sum, host) => sum + host.workspaceCount, 0);
const onlineHostCount = hosts.filter((host) => host.isOnline).length;
return {
viewKey: draft.viewKey,
projectName: draft.projectName,
projectCustomName: draft.projectCustomName,
hosts,
totalWorkspaceCount,
hostCount: hosts.length,
onlineHostCount,
};
}
function addHostProjects(
groups: Map<string, ProjectGroup>,
host: ProjectHost,
hostProjects: HostProjectListItem[],
): void {
const repoRootByProjectId = new Map(
host.projects.map((project) => [project.projectId, project.projectRootPath]),
);
for (const hostProject of hostProjects) {
const placement = hostProject.hosts.find((entry) => entry.serverId === host.serverId);
if (!placement) continue;
const projectId = placement.projectId;
const customName = findProjectMetadata(host, projectId);
let group = groups.get(hostProject.viewKey);
if (!group) {
group = {
viewKey: hostProject.viewKey,
projectName: customName?.displayName ?? hostProject.projectName,
projectCustomName: customName?.customName ?? null,
hostsByServerId: new Map(),
};
groups.set(hostProject.viewKey, group);
} else if (customName?.customName && !group.projectCustomName) {
group.projectCustomName = customName.customName;
group.projectName = customName.displayName;
}
if (!group.hostsByServerId.has(host.serverId)) {
group.hostsByServerId.set(host.serverId, {
serverId: host.serverId,
projectId,
projectName: customName?.displayName ?? hostProject.projectName,
projectCustomName: customName?.customName ?? null,
serverName: host.serverName,
isOnline: host.isOnline,
workspaces: [],
customIconRevision: placement.customIconRevision,
iconRevision: placement.iconRevision,
projectRoot: repoRootByProjectId.get(projectId) ?? "",
});
}
}
}
function attachHostWorkspaces(
groups: Map<string, ProjectGroup>,
host: ProjectHost,
hostProjects: HostProjectListItem[],
): void {
const projectViewKeyByProjectId = new Map(
hostProjects.flatMap((project) =>
project.hosts
.filter((placement) => placement.serverId === host.serverId && placement.projectId)
.map((placement) => [placement.projectId!, project.viewKey] as const),
),
);
for (const workspace of host.workspaces) {
const key = projectViewKeyByProjectId.get(workspace.projectId);
if (key) groups.get(key)?.hostsByServerId.get(host.serverId)?.workspaces.push(workspace);
}
}
export function buildProjects(input: BuildProjectsInput): BuildProjectsResult {
const groups = new Map<string, ProjectGroup>();
const projectEntries = buildHostProjectEntries(input.hosts);
for (const host of input.hosts) {
const hostProjects = projectEntries.filter((project) =>
project.hosts.some((placement) => placement.serverId === host.serverId),
);
addHostProjects(groups, host, hostProjects);
attachHostWorkspaces(groups, host, hostProjects);
}
const projects = Array.from(groups.values()).map(toProjectSummary);
projects.sort((left, right) => {
const name = left.projectName.localeCompare(right.projectName);
if (name !== 0) {
return name;
}
return left.viewKey.localeCompare(right.viewKey);
});
return { projects };
}