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; + }), + ); +}