From 6405edd8dc3d8ee2556b219ee22fc95fa3cc947a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 23 Oct 2026 14:35:27 +0700 Subject: [PATCH] feat(app): show consistent agent and workspace status in search (#7690) Share tab agent presentation and effective workspace status with Command Center. Preserve unchanged result identities so live directory updates only rerender the changed row, with bounded retention and browser regression coverage. --- .../e2e/browser/command-center-scroll.spec.ts | 51 ++++ .../browser/command-center-workspaces.spec.ts | 29 +++ .../helpers/command-center-performance.ts | 90 ++++++++ .../app/e2e/support/helpers/seed-client.ts | 1 + packages/app/src/agent-presentation/index.ts | 84 +++++++ .../app/src/command-center/command-center.tsx | 218 +++++++++++++----- .../app/src/command-center/results.test.ts | 2 + packages/app/src/command-center/results.ts | 2 + .../command-center/workspace-search.test.ts | 2 + packages/app/src/components/status-icon.tsx | 119 ++++++++++ .../hooks/sidebar-workspaces-view-model.ts | 51 +--- .../panels/agent-panel-descriptor.test.tsx | 55 +++++ packages/app/src/panels/agent-panel.tsx | 73 +----- .../workspace/workspace-tab-presentation.tsx | 105 +-------- packages/app/src/workspace-status/index.ts | 74 ++++++ 15 files changed, 679 insertions(+), 277 deletions(-) create mode 100644 packages/app/e2e/support/helpers/command-center-performance.ts create mode 100644 packages/app/src/agent-presentation/index.ts create mode 100644 packages/app/src/components/status-icon.tsx create mode 100644 packages/app/src/workspace-status/index.ts diff --git a/packages/app/e2e/browser/command-center-scroll.spec.ts b/packages/app/e2e/browser/command-center-scroll.spec.ts index 993dd60835..0a66b59856 100644 --- a/packages/app/e2e/browser/command-center-scroll.spec.ts +++ b/packages/app/e2e/browser/command-center-scroll.spec.ts @@ -1,5 +1,15 @@ import { expect } from "@playwright/test"; import { test } from "../support/fixtures"; +import { getServerId } from "../support/helpers/server-id"; +import { + seedSearchableCommandCenter, + observeCommandCenterRowRenders, + searchCommandCenter, + warmCommandCenterRows, + expectOnlyCommandCenterRowRendered, + expectCommandCenterAgentWindow, + scrollToOldestCommandCenterAgent, +} from "../support/helpers/command-center-performance"; import { gotoAppShell } from "../support/helpers/app"; import { expectCommandCenterToRemainAtTheTop, @@ -65,3 +75,44 @@ test("Command Center preserves manual scrolling through live updates and resumes await seeded.cleanup(); } }); + +test("live search updates render only the changed row and keep agents virtualized", async ({ + page, +}) => { + test.setTimeout(120_000); + const seeded = await seedSearchableCommandCenter(); + try { + await observeCommandCenterRowRenders(page); + await gotoAppShell(page); + const panel = await openCommandCenterWithKeyboard(page); + await searchCommandCenter(panel, "Search agent"); + await expect(panel.getByText("Search agent 059", { exact: true })).toBeVisible(); + await warmCommandCenterRows(page); + await expectCommandCenterAgentWindow(panel, seeded.agents.length); + + await seeded.client.updateAgent(seeded.agents[59].id, { name: "Search agent 059 revised" }); + await expect(panel.getByText("Search agent 059 revised", { exact: true })).toBeVisible(); + await expectOnlyCommandCenterRowRendered( + page, + `agent:${getServerId()}:${seeded.agents[59].id}`, + ); + + await searchCommandCenter(panel, "Search workspace"); + await expect(panel.getByText("Search workspace 039", { exact: true })).toBeVisible(); + await warmCommandCenterRows(page); + await seeded.client.setWorkspaceTitle(seeded.workspaces[39].id, "Search workspace 039 revised"); + await expect(panel.getByText("Search workspace 039 revised", { exact: true })).toBeVisible(); + await expectOnlyCommandCenterRowRendered( + page, + `workspace:${getServerId()}:${seeded.workspaces[39].id}`, + ); + + await searchCommandCenter(panel, "Search agent"); + await warmCommandCenterRows(page); + await scrollToOldestCommandCenterAgent(panel); + await expect(panel.getByText("Search agent 000", { exact: true })).toBeVisible(); + await expectCommandCenterAgentWindow(panel, seeded.agents.length); + } finally { + await seeded.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/command-center-workspaces.spec.ts b/packages/app/e2e/browser/command-center-workspaces.spec.ts index 4b01b0c67d..b47f8a9a4f 100644 --- a/packages/app/e2e/browser/command-center-workspaces.spec.ts +++ b/packages/app/e2e/browser/command-center-workspaces.spec.ts @@ -9,6 +9,7 @@ import { addOfflineHostAndReload } from "../support/helpers/hosts"; import { expectAppRoute } from "../support/helpers/route-assertions"; import { seedWorkspace } from "../support/helpers/seed-client"; import { getServerId } from "../support/helpers/server-id"; +import { seedRunningMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent"; import { buildHostWorkspaceRoute } from "@/utils/host-routes"; const PRIMARY_HOST_LABEL = "Primary Host"; @@ -20,6 +21,34 @@ const AGENT_TITLE = "Fix checkout retries"; test.describe("Command center workspaces", () => { test.describe.configure({ timeout: 180_000 }); + test("agent and workspace results retain their icons and overlay live status", async ({ + page, + }) => { + const workspace = await seedRunningMockAgentWorkspace({ + repoPrefix: "command-center-status-", + title: "Review checkout retries", + model: "five-minute-stream", + initialPrompt: "Keep working on checkout retries", + }); + try { + await openAgentRoute(page, workspace); + await expectAgentTabActive(page, workspace.agentId); + const panel = await openCommandCenter(page); + const agent = panel.getByTestId(`command-center-agent-${getServerId()}:${workspace.agentId}`); + const row = panel.getByTestId( + `command-center-workspace-${getServerId()}:${workspace.workspaceId}`, + ); + await expect(agent.getByRole("progressbar", { name: "Agent running" })).toBeVisible(); + await expect(row.getByRole("progressbar", { name: "Agent running" })).toBeVisible(); + await expect(agent.getByRole("img", { name: "Mock agent" })).toBeVisible(); + await workspace.client.cancelAgent(workspace.agentId); + await expect(agent.getByRole("progressbar")).toHaveCount(0); + await expect(row.getByRole("progressbar")).toHaveCount(0); + } finally { + await workspace.cleanup(); + } + }); + test("workspace results show their title, host, and branch and open the workspace", async ({ page, }) => { diff --git a/packages/app/e2e/support/helpers/command-center-performance.ts b/packages/app/e2e/support/helpers/command-center-performance.ts new file mode 100644 index 0000000000..b61771c05e --- /dev/null +++ b/packages/app/e2e/support/helpers/command-center-performance.ts @@ -0,0 +1,90 @@ +import { mkdirSync } from "node:fs"; +import { expect, type Locator, type Page } from "@playwright/test"; +import { createMockIdleAgent } from "./archive-tab"; +import { seedWorkspace } from "./seed-client"; + +export async function seedSearchableCommandCenter() { + const seed = await seedWorkspace({ + repoPrefix: "command-center-performance-", + title: "Search workspace", + git: false, + }); + try { + const agents = []; + for (let index = 0; index < 60; index += 1) { + agents.push( + await createMockIdleAgent(seed.client, { + cwd: seed.repoPath, + workspaceId: seed.workspaceId, + title: `Search agent ${String(index).padStart(3, "0")}`, + }), + ); + } + const workspaces = []; + for (let index = 0; index < 40; index += 1) { + const path = `${seed.repoPath}/workspace-${index}`; + mkdirSync(path); + const result = await seed.client.createWorkspace({ + source: { kind: "directory", path, projectId: seed.projectId }, + title: `Search workspace ${String(index).padStart(3, "0")}`, + }); + if (!result.workspace) throw new Error(result.error ?? "Workspace creation failed"); + workspaces.push(result.workspace); + } + return { ...seed, agents, workspaces }; + } catch (error) { + await seed.cleanup(); + throw error; + } +} + +export async function observeCommandCenterRowRenders(page: Page) { + await page.addInitScript(() => { + globalThis.__PASEO_RENDER_PROFILE_ENABLED__ = true; + }); +} + +export async function searchCommandCenter(panel: Locator, query: string) { + await panel.getByTestId("command-center-input").fill(query); +} + +export async function warmCommandCenterRows(page: Page) { + // FlatList fills its overscan window in timed batches after the first visible frame. + await page.waitForTimeout(600); + await page.evaluate(() => { + globalThis.__PASEO_RENDER_PROFILE_REASONS__ = {}; + }); +} + +export async function expectOnlyCommandCenterRowRendered(page: Page, resultId: string) { + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); + const renders = await page.evaluate(() => + Object.fromEntries( + Object.entries(globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {}).filter(([id]) => + id.startsWith("CommandCenterResultRow:"), + ), + ), + ); + expect(renders).toEqual({ [`CommandCenterResultRow:${resultId}`]: { render: 1 } }); +} + +export async function expectCommandCenterAgentWindow(panel: Locator, total: number) { + const rows = panel.locator( + '[data-testid^="command-center-agent-"]:not([data-testid="command-center-agent-subtitle"])', + ); + const count = await rows.count(); + expect(count).toBeGreaterThan(0); + expect(count).toBeLessThan(total); +} + +export async function scrollToOldestCommandCenterAgent(panel: Locator) { + await panel.getByTestId("command-center-results").evaluate((element) => { + element.scrollTop = element.scrollHeight; + element.dispatchEvent(new Event("scroll")); + }); +} diff --git a/packages/app/e2e/support/helpers/seed-client.ts b/packages/app/e2e/support/helpers/seed-client.ts index 8cf2bebb81..4419e5193f 100644 --- a/packages/app/e2e/support/helpers/seed-client.ts +++ b/packages/app/e2e/support/helpers/seed-client.ts @@ -148,6 +148,7 @@ export interface SeedDaemonClient { predicate: (snapshot: { status: string }) => boolean, timeout?: number, ): Promise<{ status: string }>; + cancelAgent(agentId: string): Promise; sendAgentMessage(agentId: string, text: string): Promise; waitForFinish( agentId: string, diff --git a/packages/app/src/agent-presentation/index.ts b/packages/app/src/agent-presentation/index.ts new file mode 100644 index 0000000000..f3c3f139f7 --- /dev/null +++ b/packages/app/src/agent-presentation/index.ts @@ -0,0 +1,84 @@ +import { useShallow } from "zustand/shallow"; +import { useProviderIcon } from "@/components/provider-icons"; +import type { PanelDescriptor } from "@/panels/panel-registry"; +import { useSessionStore, selectAgentTurnPresentation, type Agent } from "@/stores/session-store"; +import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; + +function formatProviderLabel(provider: Agent["provider"]): string { + if (!provider) { + return "Agent"; + } + return provider + .split(/[-_\s]+/) + .filter((part) => part.length > 0) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +export function resolveAgentLabel(title: string | null | undefined): string | null { + if (typeof title !== "string") { + return null; + } + const normalized = title.trim(); + if (!normalized) { + return null; + } + if (normalized.toLowerCase() === "new agent") { + return null; + } + return normalized; +} + +export function useAgentDescriptor( + target: { kind: "agent"; agentId: string }, + context: { serverId: string }, +): PanelDescriptor { + const descriptorState = useSessionStore( + useShallow((state) => { + const session = state.sessions[context.serverId]; + const agent = + session?.agents?.get(target.agentId) ?? session?.agentDetails?.get(target.agentId) ?? null; + return { + provider: agent?.provider ?? "codex", + title: agent?.title ?? null, + status: agent?.status ?? null, + pendingPermissionCount: agent?.pendingPermissions.length ?? 0, + requiresAttention: agent?.requiresAttention ?? false, + attentionReason: agent?.attentionReason ?? null, + isTurnActive: selectAgentTurnPresentation(session, target.agentId).isActive, + }; + }), + ); + const icon = useProviderIcon(descriptorState.provider, context.serverId); + return buildAgentDescriptor({ ...descriptorState, icon }); +} + +export function buildAgentDescriptor(input: { + provider: Agent["provider"]; + title: string | null; + status: Agent["status"] | null; + pendingPermissionCount: number; + requiresAttention: boolean; + attentionReason: Agent["attentionReason"]; + isTurnActive: boolean; + icon: PanelDescriptor["icon"]; +}): PanelDescriptor { + const provider = input.provider; + const label = resolveAgentLabel(input.title); + + return { + label: label ?? "", + subtitle: `${formatProviderLabel(provider)} agent`, + tooltip: label ?? `${formatProviderLabel(provider)} agent`, + titleState: label ? "ready" : "loading", + icon: input.icon, + statusBucket: input.status + ? deriveSidebarStateBucket({ + status: input.isTurnActive ? "running" : input.status, + pendingPermissionCount: input.pendingPermissionCount, + requiresAttention: input.requiresAttention, + attentionReason: input.attentionReason, + }) + : null, + }; +} diff --git a/packages/app/src/command-center/command-center.tsx b/packages/app/src/command-center/command-center.tsx index 88ee10d8bb..7a44f8ccd9 100644 --- a/packages/app/src/command-center/command-center.tsx +++ b/packages/app/src/command-center/command-center.tsx @@ -1,3 +1,5 @@ +import equal from "fast-deep-equal"; +import { recordRenderProfileReasons } from "@/utils/render-profiler"; import { FlatList, Modal, @@ -24,7 +26,11 @@ import { useTranslation } from "react-i18next"; 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 { useAgentDescriptor, resolveAgentLabel } from "@/agent-presentation"; +import { StatusIcon } from "@/components/status-icon"; +import { useWorkspaceStatus } from "@/workspace-status"; +import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; +import type { Theme } from "@/styles/theme"; import { HighlightedText, type HighlightedTextProps } from "@/components/ui/highlighted-text"; import { findHighlightRanges } from "@/components/ui/highlighted-text-segments"; import { MaterialFileIcon } from "@/components/material-file-icon"; @@ -114,6 +120,20 @@ function agentSearchFields(result: CommandCenterAgentResult): CommandCenterSearc return { visible: [result.title, result.subtitle], hidden: [result.agent.cwd] }; } +function createWorkspaceResultAction(serverId: string, workspaceId: string) { + return () => { + clearCommandCenterFocusRestoreElement(); + navigateToWorkspace({ serverId, workspaceId }); + }; +} + +function createAgentResultAction(serverId: string, agentId: string) { + return () => { + clearCommandCenterFocusRestoreElement(); + navigateToAgent({ serverId, agentId }); + }; +} + /** * Build every pinned row. Deliberately not keyed on `query`: none of this work depends on what * was typed. Ordering belongs to `filterAndRankBuiltInResults`, which ranks on every keystroke. @@ -123,6 +143,9 @@ function useBuiltInRows(open: boolean): { agents: CommandCenterAgentResult[]; } { const { t } = useTranslation(); + const previousRows = useRef( + new Map(), + ); const { agents } = useAggregatedAgents({ demand: open }); const { projects } = useProjects({ enabled: open }); const showHost = useHosts().length > 1; @@ -144,7 +167,22 @@ function useBuiltInRows(open: boolean): { const iconDataByProjectViewKey = useProjectIcons({ projects: iconTargets }); return useMemo(() => { - if (!open) return { workspaces: [], agents: [] }; + if (!open) { + previousRows.current.clear(); + return { workspaces: [], agents: [] }; + } + // Built-in IDs identify immutable navigation targets. Reuse their callbacks and unchanged + // row data so a directory update does not invalidate every mounted ResultRow. + const nextRows = new Map(); + const preserve = ( + row: T, + ): T => { + const previous = previousRows.current.get(row.id); + if (previous) row.run = previous.run; + const stable = previous && equal(previous, row) ? (previous as T) : row; + nextRows.set(row.id, stable); + return stable; + }; const allWorkspaces: CommandCenterWorkspaceResult[] = []; for (const project of projects) { const workspaceProject = { @@ -155,23 +193,24 @@ function useBuiltInRows(open: boolean): { for (const host of project.hosts) { for (const workspace of host.workspaces) { if (workspace.archivingAt) continue; - allWorkspaces.push({ - kind: "workspace", - id: `workspace:${host.serverId}:${workspace.id}`, - title: workspace.title ?? workspace.name, - subtitle: joinSubtitleParts([ - showHost ? host.serverName : null, - project.projectName, - workspace.currentBranch, - ]), - project: workspaceProject, - changeRequestNumber: workspace.changeRequestNumber, - activityAt: workspace.statusEnteredAt, - run: () => { - clearCommandCenterFocusRestoreElement(); - navigateToWorkspace({ serverId: host.serverId, workspaceId: workspace.id }); - }, - }); + allWorkspaces.push( + preserve({ + kind: "workspace", + id: `workspace:${host.serverId}:${workspace.id}`, + title: workspace.title ?? workspace.name, + subtitle: joinSubtitleParts([ + showHost ? host.serverName : null, + project.projectName, + workspace.currentBranch, + ]), + serverId: host.serverId, + workspaceId: workspace.id, + project: workspaceProject, + changeRequestNumber: workspace.changeRequestNumber, + activityAt: workspace.statusEnteredAt, + run: createWorkspaceResultAction(host.serverId, workspace.id), + }), + ); } } } @@ -182,22 +221,20 @@ function useBuiltInRows(open: boolean): { const workspaceTitle = agent.workspaceId ? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`) : undefined; - return { + return preserve({ kind: "agent", id: `agent:${agent.serverId}:${agent.id}`, agent, - title: agent.title || t("shell.commandCenter.newAgent"), + title: resolveAgentLabel(agent.title) ?? t("workspace.tabs.loading"), subtitle: joinSubtitleParts([ showHost ? agent.serverLabel : null, workspaceTitle ?? shortenPath(agent.cwd), ]), activityAt: agent.lastActivityAt, - run: () => { - clearCommandCenterFocusRestoreElement(); - navigateToAgent({ serverId: agent.serverId, agentId: agent.id }); - }, - }; + run: createAgentResultAction(agent.serverId, agent.id), + }); }); + previousRows.current = nextRows; return { workspaces: allWorkspaces, agents: agentRows }; }, [agents, iconDataByProjectViewKey, open, projects, showHost, t]); } @@ -402,6 +439,7 @@ const ResultRow = memo(function ResultRow({ active, onSelect, }: ResultRowProps) { + recordRenderProfileReasons(`CommandCenterResultRow:${result.id}`, ["render"]); const press = useCallback(() => onSelect(result), [onSelect, result]); const choice = result.kind === "contribution" && result.contribution.presentation.kind === "choice" @@ -435,7 +473,19 @@ const ResultRow = memo(function ResultRow({ result.kind === "file" ? `command-center-file-row-${result.filePath}` : choice?.testId } > - + {(state) => ( + + )} ); }); @@ -489,6 +539,72 @@ function SubtitleWithTimestamp({ ); } +const providerIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +function AgentResultContent({ + result, + query, + backdrop, +}: { + result: CommandCenterAgentResult; + query: string; + backdrop: SurfaceBackdrop; +}) { + const agent = result.agent; + const descriptor = useAgentDescriptor( + { kind: "agent", agentId: agent.id }, + { serverId: agent.serverId }, + ); + const Icon = useMemo( + () => withUnistyles(descriptor.icon, providerIconMapping), + [descriptor.icon], + ); + return ( + + + + + + + + + + + + + + + + ); +} + +function WorkspaceResultIcon({ + result, + backdrop, +}: { + result: CommandCenterWorkspaceResult; + backdrop: SurfaceBackdrop; +}) { + const statusBucket = useWorkspaceStatus(result.serverId, result.workspaceId); + return ( + + + + + + ); +} + /** 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(); @@ -503,7 +619,15 @@ function WorkspaceProjectIcon({ project }: { project: CommandCenterWorkspaceResu ); } -function ResultContent({ result, query }: { result: CommandCenterResult; query: string }) { +function ResultContent({ + result, + query, + backdrop, +}: { + result: CommandCenterResult; + query: string; + backdrop: SurfaceBackdrop; +}) { if (result.kind === "file") { return ( @@ -530,34 +654,7 @@ function ResultContent({ result, query }: { result: CommandCenterResult; query: ); } if (result.kind === "agent") { - const agent = result.agent; - return ( - - - - - - - - - - - - ); + return ; } if (result.kind === "workspace") { const key = result.id.slice("workspace:".length); @@ -565,13 +662,8 @@ function ResultContent({ result, query }: { result: CommandCenterResult; query: {/* Decorative: the fallback icon's initial must not prefix the row's accessible name. */} - - + + ({ + color: theme.colors.surface0, + fill: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined, +}); + +export function StatusIcon({ + children, + statusBucket, + size = 14, + statusDotBorderColor, + backdrop, +}: StatusIconProps): ReactElement { + const bucket = statusBucket; + const isRunning = bucket === "running"; + let statusDotColor: string | undefined; + if (bucket === "failed") statusDotColor = styles.statusDotFailed.color; + else if (bucket === "attention") statusDotColor = styles.statusDotAttention.color; + const showNeedsInputAlert = bucket === "needs_input"; + const agentIconWrapperStyle = useMemo( + () => [styles.agentIconWrapper, { width: size, height: size }], + [size], + ); + const statusDotStyle = useMemo( + () => [ + styles.statusDot, + { + backgroundColor: statusDotColor, + borderColor: statusDotBorderColor ?? styles[backdrop].borderColor, + }, + ], + [statusDotColor, statusDotBorderColor, backdrop], + ); + + return ( + + {children} + {isRunning ? ( + + + + ) : null} + {statusDotColor ? : null} + {showNeedsInputAlert ? ( + + + + ) : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + agentIconWrapper: { + position: "relative", + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + }, + statusDot: { + position: "absolute", + right: DEFAULT_STATUS_DOT_OFFSET, + bottom: DEFAULT_STATUS_DOT_OFFSET, + width: STATUS_INDICATOR_DOT_SIZE, + height: STATUS_INDICATOR_DOT_SIZE, + borderRadius: theme.borderRadius.full, + borderWidth: 1, + }, + statusRing: { + position: "absolute", + right: getStatusRingOffset(DEFAULT_STATUS_DOT_OFFSET, STATUS_INDICATOR_DOT_SIZE), + bottom: getStatusRingOffset(DEFAULT_STATUS_DOT_OFFSET, STATUS_INDICATOR_DOT_SIZE), + }, + surface0: { borderColor: theme.colors.surface0 }, + surface1: { borderColor: theme.colors.surface1 }, + surface2: { borderColor: theme.colors.surface2 }, + surfaceSidebar: { borderColor: theme.colors.surfaceSidebar }, + surfaceSidebarHover: { borderColor: theme.colors.surfaceSidebarHover }, + surfaceSidebarSelected: { borderColor: theme.colors.surfaceSidebarSelected }, + statusAlertOverlay: { + position: "absolute", + right: STATUS_ALERT_OFFSET, + bottom: STATUS_ALERT_OFFSET, + }, + statusDotFailed: { + color: getStatusDotColor({ theme, bucket: "failed" }) ?? undefined, + }, + statusDotAttention: { + color: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined, + }, +})); diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts index cec59e071b..3c4ce4e2da 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.ts @@ -1,3 +1,4 @@ +import { deriveEffectiveWorkspaceStatus } from "@/workspace-status"; import equal from "fast-deep-equal"; import type { PrHint } from "@/git/pr-hint"; import { selectPrHintFromStatus } from "@/git/pr-hint"; @@ -124,11 +125,6 @@ export function areSidebarWorkspaceSessionsEqual( return true; } -interface EffectiveWorkspaceStatus { - status: WorkspaceDescriptor["status"]; - enteredAt: Date | null; -} - function projectNameForWorkspace(workspace: WorkspaceDescriptor): string { return ( workspace.projectCustomName ?? @@ -189,51 +185,6 @@ export function createSidebarWorkspaceEntry(input: { const EMPTY_WORKSPACE_LABELS: string[] = []; -function deriveEffectiveWorkspaceStatus(input: { - serverId: string; - workspace: WorkspaceDescriptor; - pendingCreateAttempts?: Record; - workspaceAgentActivity?: ReadonlyMap; -}): EffectiveWorkspaceStatus { - if (input.workspace.status !== "done") { - return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt }; - } - - const pendingStartedAt = getPendingInitialAgentCreateStartedAt({ - serverId: input.serverId, - workspaceId: input.workspace.id, - pendingCreateAttempts: input.pendingCreateAttempts, - }); - if (pendingStartedAt) { - return { status: "running", enteredAt: pendingStartedAt }; - } - - const rootAgentActivity = input.workspaceAgentActivity?.get(input.workspace.id); - if (rootAgentActivity && rootAgentActivity.status !== "done") { - return rootAgentActivity; - } - - return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt }; -} - -function getPendingInitialAgentCreateStartedAt(input: { - serverId: string; - workspaceId: string; - pendingCreateAttempts: Record | undefined; -}): Date | null { - let latestStartedAt: Date | null = null; - for (const pending of Object.values(input.pendingCreateAttempts ?? {})) { - if (pending.serverId !== input.serverId) continue; - if (pending.workspaceId !== input.workspaceId) continue; - if (pending.lifecycle === "abandoned") continue; - const startedAt = new Date(pending.timestamp); - if (!latestStartedAt || startedAt > latestStartedAt) { - latestStartedAt = startedAt; - } - } - return latestStartedAt; -} - export interface ProjectStatusSession { workspaces: Map; workspaceAgentActivity: Map; diff --git a/packages/app/src/panels/agent-panel-descriptor.test.tsx b/packages/app/src/panels/agent-panel-descriptor.test.tsx index 30daf5ffa9..ac57778111 100644 --- a/packages/app/src/panels/agent-panel-descriptor.test.tsx +++ b/packages/app/src/panels/agent-panel-descriptor.test.tsx @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { buildAgentDescriptor, resolveAgentLabel } from "@/agent-presentation"; import { i18n } from "@/i18n/i18next"; import { buildDraftPanelDescriptor } from "@/panels/draft-panel-descriptor"; @@ -66,3 +67,57 @@ describe("buildDraftPanelDescriptor", () => { await i18n.changeLanguage("en"); }); }); + +describe("shared agent descriptor", () => { + function describeAgent(overrides: Partial[0]> = {}) { + return buildAgentDescriptor({ + provider: "codex", + title: " Review checkout retries ", + status: "idle", + pendingPermissionCount: 0, + requiresAttention: false, + attentionReason: null, + isTurnActive: false, + icon: TestIcon, + ...overrides, + }); + } + + it("keeps the normalized title and provider identity together", () => { + expect(describeAgent()).toMatchObject({ + label: "Review checkout retries", + subtitle: "Codex agent", + icon: TestIcon, + titleState: "ready", + statusBucket: "done", + }); + }); + + it.each([null, "", " ", "New Agent", " new agent "])( + "leaves an untitled agent loading: %s", + (title) => { + expect(resolveAgentLabel(title)).toBeNull(); + expect(describeAgent({ title })).toMatchObject({ label: "", titleState: "loading" }); + }, + ); + + it("shows an active turn as running before the lifecycle update arrives", () => { + expect(describeAgent({ isTurnActive: true }).statusBucket).toBe("running"); + }); + + it("surfaces pending permissions even while the turn is running", () => { + expect(describeAgent({ isTurnActive: true, pendingPermissionCount: 1 }).statusBucket).toBe( + "needs_input", + ); + }); + + it.each([ + ["finished", "attention"], + ["error", "failed"], + ["permission", "needs_input"], + ] as const)("preserves %s attention", (attentionReason, statusBucket) => { + expect(describeAgent({ requiresAttention: true, attentionReason }).statusBucket).toBe( + statusBucket, + ); + }); +}); diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 2fa2624fb4..af14b94229 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -39,7 +39,7 @@ import { } from "@/composer/pill-styles"; import { getActiveMessageSubmissions } from "@/composer/submission/model"; import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore"; -import { useProviderIcon } from "@/components/provider-icons"; +import { useAgentDescriptor } from "@/agent-presentation"; import { useToastHost, type ToastApi, type ToastState } from "@/components/toast-host"; import type { WorkspaceComposerAttachment } from "@/attachments/types"; import { useWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; @@ -62,7 +62,7 @@ import { TimelineSyncStatus } from "@/timeline/sync-status"; import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; import { useAgentFactsLine } from "@/panels/agent-facts-line"; import { PaneSubtitle } from "@/panels/pane-subtitle"; -import { definePanel, type PanelDescriptor } from "@/panels/panel-registry"; +import { definePanel } from "@/panels/panel-registry"; import { RenderProfile } from "@/utils/render-profiler"; import { useHasPluginComposerPills } from "@/plugins"; import { buildDraftPanelDescriptor } from "@/panels/draft-panel-descriptor"; @@ -101,7 +101,6 @@ import { getInitDeferred, getInitKey } from "@/utils/agent-initialization"; import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces"; import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; -import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; import { buildDraftAgentSetup, type ClientSlashCommand } from "@/client-slash-commands"; interface ChatAgentStateShape { @@ -256,31 +255,6 @@ function renderChatAgentNonReadyView(args: { return null; } -function formatProviderLabel(provider: Agent["provider"]): string { - if (!provider) { - return "Agent"; - } - return provider - .split(/[-_\s]+/) - .filter((part) => part.length > 0) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); -} - -function resolveWorkspaceAgentTabLabel(title: string | null | undefined): string | null { - if (typeof title !== "string") { - return null; - } - const normalized = title.trim(); - if (!normalized) { - return null; - } - if (normalized.toLowerCase() === "new agent") { - return null; - } - return normalized; -} - function shouldStoreFetchedAgentInActiveDirectory(agent: Agent): boolean { return !agent.archivedAt && Boolean(agent.projectPlacement); } @@ -328,47 +302,6 @@ function storeFetchedAgentDetail(input: { return hydrated; } -function useAgentPanelDescriptor( - target: { kind: "agent"; agentId: string }, - context: { serverId: string }, -): PanelDescriptor { - const descriptorState = useSessionStore( - useShallow((state) => { - const session = state.sessions[context.serverId]; - const agent = - session?.agents?.get(target.agentId) ?? session?.agentDetails?.get(target.agentId) ?? null; - return { - provider: agent?.provider ?? "codex", - title: agent?.title ?? null, - status: agent?.status ?? null, - pendingPermissionCount: agent?.pendingPermissions.length ?? 0, - requiresAttention: agent?.requiresAttention ?? false, - attentionReason: agent?.attentionReason ?? null, - isTurnActive: selectAgentTurnPresentation(session, target.agentId).isActive, - }; - }), - ); - const provider = descriptorState.provider; - const label = resolveWorkspaceAgentTabLabel(descriptorState.title); - const icon = useProviderIcon(provider, context.serverId); - - return { - label: label ?? "", - subtitle: `${formatProviderLabel(provider)} agent`, - tooltip: label ?? `${formatProviderLabel(provider)} agent`, - titleState: label ? "ready" : "loading", - icon, - statusBucket: descriptorState.status - ? deriveSidebarStateBucket({ - status: descriptorState.isTurnActive ? "running" : descriptorState.status, - pendingPermissionCount: descriptorState.pendingPermissionCount, - requiresAttention: descriptorState.requiresAttention, - attentionReason: descriptorState.attentionReason, - }) - : null, - }; -} - function AgentPanel() { const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext(); const { isInteractive } = usePaneFocus(); @@ -438,7 +371,7 @@ export function AgentConversationPanel() { export const agentPanelRegistration = definePanel("agent", { component: AgentConversationPanel, - useDescriptor: useAgentPanelDescriptor, + useDescriptor: useAgentDescriptor, }); export function useDraftPanelDescriptor( diff --git a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx index 8b499429f0..fb73376b8b 100644 --- a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx +++ b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx @@ -1,6 +1,8 @@ +import type { Theme } from "@/styles/theme"; +import { StatusIcon } from "@/components/status-icon"; import { useCallback, useMemo, type ReactElement, type ReactNode } from "react"; import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; -import { Check, CircleAlert } from "lucide-react-native"; +import { Check } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import invariant from "tiny-invariant"; @@ -9,14 +11,6 @@ import { getPanelRegistration, type PanelIconProps } from "@/panels/panel-regist import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; -import { getStatusDotColor } from "@/utils/status-dot-color"; -import { StatusRing } from "@/components/status-ring"; -import { getStatusRingOffset } from "@/components/status-ring/geometry"; -import { - STATUS_INDICATOR_ALERT_SIZE, - STATUS_INDICATOR_DOT_SIZE, -} from "@/utils/status-indicator-geometry"; -import type { Theme } from "@/styles/theme"; import { usePanelInstanceAttributes } from "@/panels/panel-instance-attributes"; export interface WorkspaceTabPresentation { @@ -32,9 +26,6 @@ export interface WorkspaceTabPresentation { statusBucket: SidebarStateBucket | null; } -const DEFAULT_STATUS_DOT_OFFSET = -2; -const STATUS_ALERT_OFFSET = -3; - interface WorkspaceTabPresentationResolverProps { tab: WorkspaceTabDescriptor; serverId: string; @@ -129,13 +120,7 @@ interface WorkspaceTabIconProps { } const ThemedCheckIcon = withUnistyles(Check); -const ThemedCircleAlert = withUnistyles(CircleAlert); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); -const needsInputAlertMapping = (theme: Theme) => ({ - color: theme.colors.surface0, - fill: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined, -}); - export function WorkspaceTabIcon({ presentation, active = false, @@ -144,48 +129,17 @@ export function WorkspaceTabIcon({ statusDotBorderColor, backdrop, }: WorkspaceTabIconProps): ReactElement { - const iconColor = active ? styles.iconActive.color : styles.iconInactive.color; - const bucket = presentation.statusBucket; - const isRunning = bucket === "running"; - let statusDotColor: string | undefined; - if (bucket === "failed") statusDotColor = styles.statusDotFailed.color; - else if (bucket === "attention") statusDotColor = styles.statusDotAttention.color; - const showNeedsInputAlert = bucket === "needs_input"; const Icon = presentation.icon; - const agentIconWrapperStyle = useMemo( - () => [styles.agentIconWrapper, { width: size, height: size }], - [size], - ); - const statusDotStyle = useMemo( - () => [ - styles.statusDot, - { - backgroundColor: statusDotColor, - borderColor: statusDotBorderColor ?? styles.statusDotBorderDefault.borderColor, - }, - ], - [statusDotColor, statusDotBorderColor], - ); - + const iconColor = active ? styles.iconActive.color : styles.iconInactive.color; return ( - + - {isRunning ? ( - - - - ) : null} - {statusDotColor ? : null} - {showNeedsInputAlert ? ( - - - - ) : null} - + ); } @@ -262,43 +216,6 @@ export function WorkspaceTabOptionRow({ } const styles = StyleSheet.create((theme) => ({ - agentIconWrapper: { - position: "relative", - alignItems: "center", - justifyContent: "center", - flexShrink: 0, - }, - statusDot: { - position: "absolute", - right: DEFAULT_STATUS_DOT_OFFSET, - bottom: DEFAULT_STATUS_DOT_OFFSET, - width: STATUS_INDICATOR_DOT_SIZE, - height: STATUS_INDICATOR_DOT_SIZE, - borderRadius: theme.borderRadius.full, - borderWidth: 1, - }, - statusRing: { - position: "absolute", - right: getStatusRingOffset(DEFAULT_STATUS_DOT_OFFSET, STATUS_INDICATOR_DOT_SIZE), - bottom: getStatusRingOffset(DEFAULT_STATUS_DOT_OFFSET, STATUS_INDICATOR_DOT_SIZE), - }, - statusDotBorderDefault: { - borderColor: theme.colors.surface0, - }, - statusAlertOverlay: { - position: "absolute", - right: STATUS_ALERT_OFFSET, - bottom: STATUS_ALERT_OFFSET, - }, - statusDotFailed: { - color: getStatusDotColor({ theme, bucket: "failed" }) ?? undefined, - }, - statusDotRunning: { - color: getStatusDotColor({ theme, bucket: "running" }) ?? undefined, - }, - statusDotAttention: { - color: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined, - }, iconActive: { color: theme.colors.foreground, }, diff --git a/packages/app/src/workspace-status/index.ts b/packages/app/src/workspace-status/index.ts new file mode 100644 index 0000000000..45bf68ab8e --- /dev/null +++ b/packages/app/src/workspace-status/index.ts @@ -0,0 +1,74 @@ +import { useShallow } from "zustand/shallow"; +import { useCreateFlowStore, type PendingCreateAttempt } from "@/stores/create-flow-store"; +import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; +import type { WorkspaceAgentActivity } from "@/utils/workspace-agent-activity"; +import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; + +interface EffectiveWorkspaceStatus { + status: WorkspaceDescriptor["status"]; + enteredAt: Date | null; +} + +export function deriveEffectiveWorkspaceStatus(input: { + serverId: string; + workspace: WorkspaceDescriptor; + pendingCreateAttempts?: Record; + workspaceAgentActivity?: ReadonlyMap; +}): EffectiveWorkspaceStatus { + if (input.workspace.status !== "done") { + return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt }; + } + + const pendingStartedAt = getPendingInitialAgentCreateStartedAt({ + serverId: input.serverId, + workspaceId: input.workspace.id, + pendingCreateAttempts: input.pendingCreateAttempts, + }); + if (pendingStartedAt) { + return { status: "running", enteredAt: pendingStartedAt }; + } + + const rootAgentActivity = input.workspaceAgentActivity?.get(input.workspace.id); + if (rootAgentActivity && rootAgentActivity.status !== "done") { + return rootAgentActivity; + } + + return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt }; +} + +function getPendingInitialAgentCreateStartedAt(input: { + serverId: string; + workspaceId: string; + pendingCreateAttempts: Record | undefined; +}): Date | null { + let latestStartedAt: Date | null = null; + for (const pending of Object.values(input.pendingCreateAttempts ?? {})) { + if (pending.serverId !== input.serverId) continue; + if (pending.workspaceId !== input.workspaceId) continue; + if (pending.lifecycle === "abandoned") continue; + const startedAt = new Date(pending.timestamp); + if (!latestStartedAt || startedAt > latestStartedAt) { + latestStartedAt = startedAt; + } + } + return latestStartedAt; +} + +export function useWorkspaceStatus(serverId: string, workspaceId: string) { + const pendingCreateAttempts = useCreateFlowStore((state) => state.pendingByDraftId); + return useSessionStore( + useShallow((state) => { + const session = state.sessions[serverId]; + if (!session) return null; + const key = resolveWorkspaceMapKeyByIdentity({ workspaces: session.workspaces, workspaceId }); + const workspace = key ? session.workspaces.get(key) : null; + if (!workspace) return null; + return deriveEffectiveWorkspaceStatus({ + serverId, + workspace, + pendingCreateAttempts, + workspaceAgentActivity: session.workspaceAgentActivity, + }).status; + }), + ); +}