diff --git a/packages/app/e2e/helpers/project-status-badge.ts b/packages/app/e2e/helpers/project-status-badge.ts new file mode 100644 index 000000000..c3b573e1c --- /dev/null +++ b/packages/app/e2e/helpers/project-status-badge.ts @@ -0,0 +1,106 @@ +import { expect, type Page } from "@playwright/test"; +import { gotoAppShell } from "./app"; +import { seedWorkspace, type SeededWorkspace } from "./seed-client"; +import { waitForSidebarHydration } from "./workspace-ui"; + +export interface StatusProject { + seed: SeededWorkspace; + needsInputWorkspaceId: string; +} + +export async function seedStatusProject(): Promise { + const seed = await seedWorkspace({ + repoPrefix: "project-status-badge-", + title: "Working workspace", + }); + const created = await seed.client.createWorkspace({ + source: { kind: "directory", path: seed.repoPath, projectId: seed.projectId }, + title: "Needs input workspace", + }); + if (!created.workspace) { + await seed.cleanup(); + throw new Error(created.error ?? "Failed to create the needs-input workspace"); + } + return { seed, needsInputWorkspaceId: created.workspace.id }; +} + +export async function openAndCollapseStatusProject( + page: Page, + project: StatusProject, +): Promise { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await projectRow(page, project).click(); + await page.mouse.move(1200, 850); + await expect(workspaceRow(page, "Working workspace")).toHaveCount(0); +} + +export async function expandStatusProject(page: Page, project: StatusProject): Promise { + await projectRow(page, project).click(); + await expect(workspaceRow(page, "Working workspace")).toBeVisible(); +} + +export async function startWorkingWorkspace(project: StatusProject): Promise { + await project.seed.client.createAgent({ + provider: "mock", + cwd: project.seed.repoPath, + workspaceId: project.seed.workspaceId, + title: "Working agent", + modeId: "load-test", + model: "thirty-minute-stream", + initialPrompt: "keep streaming for the test", + }); +} + +export async function startNeedsInputWorkspace(project: StatusProject): Promise { + await project.seed.client.createAgent({ + provider: "mock", + cwd: project.seed.repoPath, + workspaceId: project.needsInputWorkspaceId, + title: "Needs input agent", + modeId: "load-test", + model: "thirty-minute-stream", + initialPrompt: "emit a synthetic plan approval", + }); +} + +export async function expectCollapsedProjectStatus( + page: Page, + project: StatusProject, + status: "Working" | "Needs input", +): Promise { + await expect(projectRow(page, project).getByRole("status", { name: status })).toBeVisible({ + timeout: 60_000, + }); +} + +export async function expectProjectStatusHidden(page: Page, project: StatusProject): Promise { + await expect(projectRow(page, project).getByRole("status")).toHaveCount(0, { + timeout: 60_000, + }); +} + +export async function expectWorkspaceStatus( + page: Page, + workspaceName: string, + status: "running" | "needs_input", +): Promise { + await expect( + workspaceRow(page, workspaceName).getByTestId(`workspace-status-indicator-${status}`), + ).toBeVisible({ timeout: 60_000 }); +} + +function projectGroup(page: Page, project: StatusProject) { + return page.getByRole("group", { name: project.seed.projectDisplayName, exact: true }); +} + +function projectRow(page: Page, project: StatusProject) { + return projectGroup(page, project) + .getByRole("button") + .filter({ hasText: project.seed.projectDisplayName }) + .first(); +} + +function workspaceRow(page: Page, workspaceName: string) { + return page.getByRole("button", { name: workspaceName, exact: true }); +} diff --git a/packages/app/e2e/project-status-badge.spec.ts b/packages/app/e2e/project-status-badge.spec.ts new file mode 100644 index 000000000..a2e7b837d --- /dev/null +++ b/packages/app/e2e/project-status-badge.spec.ts @@ -0,0 +1,34 @@ +import { test } from "./fixtures"; +import { + expandStatusProject, + expectCollapsedProjectStatus, + expectProjectStatusHidden, + expectWorkspaceStatus, + openAndCollapseStatusProject, + seedStatusProject, + startNeedsInputWorkspace, + startWorkingWorkspace, +} from "./helpers/project-status-badge"; + +test("a collapsed project surfaces its most urgent hidden workspace status", async ({ page }) => { + test.setTimeout(120_000); + const project = await seedStatusProject(); + + try { + await openAndCollapseStatusProject(page, project); + await expectProjectStatusHidden(page, project); + + await startWorkingWorkspace(project); + await expectCollapsedProjectStatus(page, project, "Working"); + + await startNeedsInputWorkspace(project); + await expectCollapsedProjectStatus(page, project, "Needs input"); + + await expandStatusProject(page, project); + await expectProjectStatusHidden(page, project); + await expectWorkspaceStatus(page, "Working workspace", "running"); + await expectWorkspaceStatus(page, "Needs input workspace", "needs_input"); + } finally { + await project.seed.cleanup().catch(() => undefined); + } +}); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 87d3c367c..095c42e4e 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -6,10 +6,8 @@ import { ScrollView, type GestureResponderEvent, type PressableStateCallbackType, - type ViewStyle, } from "react-native"; import { useMutation } from "@tanstack/react-query"; -import { ProjectIconView } from "@/components/project-icon-view"; import { AdaptiveRenameModal } from "@/components/rename-modal"; import { memo, @@ -35,9 +33,6 @@ import { type GestureType } from "react-native-gesture-handler"; import * as Clipboard from "expo-clipboard"; import { DiffStat } from "@/components/diff-stat"; import { - CircleAlert, - ChevronDown, - ChevronRight, ExternalLink, GitPullRequest, Settings, @@ -65,6 +60,7 @@ import { } from "@/utils/host-routes"; import { shouldShowSidebarHostLabels, + useSidebarProjectStatusBucket, type SidebarProjectEntry, type SidebarWorkspaceEntry, type SidebarWorkspacePlacement, @@ -79,15 +75,12 @@ import { DropdownMenuContent, DropdownMenuItem, } from "@/components/ui/dropdown-menu"; -import { SyncedLoader } from "@/components/synced-loader"; +import { ProjectLeadingVisual } from "@/components/sidebar/project-leading-visual"; import { useToast } from "@/contexts/toast-context"; import { getForgePresentation, normalizeForge } from "@/git/forge"; import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning"; import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder"; import { confirmDialog } from "@/utils/confirm-dialog"; -import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; -import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader"; -import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { SidebarStatusWorkspaceList } from "@/components/sidebar/sidebar-status-list"; import type { StatusGroup } from "@/hooks/sidebar-status-view-model"; @@ -140,15 +133,9 @@ const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspac const projectViewKeyExtractor = (project: SidebarProjectEntry) => project.viewKey; const WORKSPACE_STATUS_DOT_WIDTH = 14; -const DEFAULT_STATUS_DOT_SIZE = 7; -const EMPHASIZED_STATUS_DOT_SIZE = 9; -const DEFAULT_STATUS_DOT_OFFSET = 0; -const EMPHASIZED_STATUS_DOT_OFFSET = -1; const ThemedExternalLink = withUnistyles(ExternalLink); const ThemedGitPullRequest = withUnistyles(GitPullRequest); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); -const ThemedCircleAlert = withUnistyles(CircleAlert); -const ThemedSyncedLoader = withUnistyles(SyncedLoader); const ThemedPlus = withUnistyles(Plus); const ThemedMoreVertical = withUnistyles(MoreVertical); const ThemedTrash2 = withUnistyles(Trash2); @@ -163,21 +150,12 @@ const foregroundMutedColorMapping = (theme: Theme) => ({ const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500], }); -const amberColorMapping = (theme: Theme) => ({ - color: theme.colors.palette.amber[500], -}); const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500], }); const purpleColorMapping = (theme: Theme) => ({ color: theme.colors.palette.purple[500], }); -const syncedLoaderColorMapping = (theme: Theme) => ({ - color: - theme.colorScheme === "light" - ? theme.colors.palette.amber[700] - : theme.colors.palette.amber[500], -}); function getPrIconUniMapping(state: PrHint["state"]) { switch (state) { @@ -256,7 +234,7 @@ interface ProjectHeaderRowProps { project: SidebarProjectEntry; displayName: string; iconDataUri: string | null; - workspace: SidebarWorkspaceEntry | null; + statusBucket: SidebarStateBucket | null; selected?: boolean; chevron: "expand" | "collapse" | null; onPress: () => void; @@ -403,89 +381,6 @@ const prBadgeStyles = StyleSheet.create((theme) => ({ }, })); -function StatusDotOverlay({ - dotColorStyle, - size, - offset, -}: { - dotColorStyle: ViewStyle; - size: number; - offset: number; -}) { - const overlayStyle = useMemo( - () => [ - styles.statusDotOverlay, - dotColorStyle, - { - width: size, - height: size, - right: offset, - bottom: offset, - }, - ], - [dotColorStyle, size, offset], - ); - return ; -} - -function ProjectLeadingVisual({ - displayName, - iconDataUri, - workspace, - projectViewKey, - chevron = null, - showChevron = false, - isArchiving = false, -}: { - displayName: string; - iconDataUri: string | null; - workspace: SidebarWorkspaceEntry | null; - projectViewKey: string; - chevron?: "expand" | "collapse" | null; - showChevron?: boolean; - isArchiving?: boolean; -}) { - const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(displayName); - const placeholderInitial = placeholderLabel.charAt(0).toUpperCase(); - const activeWorkspace = workspace; - const shouldShowWorkspaceStatus = - activeWorkspace !== null && (isArchiving || activeWorkspace.statusBucket !== "done"); - const shouldShowSyncedLoader = activeWorkspace - ? shouldRenderSyncedStatusLoader({ bucket: activeWorkspace.statusBucket }) - : false; - - if (showChevron && chevron !== null) { - return ( - - - - ); - } - - if (!shouldShowWorkspaceStatus || !activeWorkspace) { - return ( - - - - ); - } - - return ( - - ); -} - function ProjectRowTrailingActions({ project, displayName, @@ -723,103 +618,6 @@ function WorkspaceRowRightGroup({ ); } -function ProjectIcon({ - iconDataUri, - placeholderInitial, - projectViewKey, -}: { - iconDataUri: string | null; - placeholderInitial: string; - projectViewKey: string; -}) { - return ( - - ); -} - -function ProjectLeadingVisualStatus({ - iconDataUri, - placeholderInitial, - projectViewKey, - isArchiving, - shouldShowSyncedLoader, - activeWorkspace, -}: { - iconDataUri: string | null; - placeholderInitial: string; - projectViewKey: string; - isArchiving: boolean; - shouldShowSyncedLoader: boolean; - activeWorkspace: SidebarWorkspaceEntry; -}) { - if (isArchiving) { - return ( - - - - ); - } - - if (shouldShowSyncedLoader) { - return ( - - - - ); - } - - if (activeWorkspace.statusBucket === "needs_input") { - return ( - - - - ); - } - - const dotColorStyle = getStatusDotColorStyle(activeWorkspace.statusBucket); - const statusDotSize = isEmphasizedStatusDotBucket(activeWorkspace.statusBucket) - ? EMPHASIZED_STATUS_DOT_SIZE - : DEFAULT_STATUS_DOT_SIZE; - const statusDotOffset = - statusDotSize === EMPHASIZED_STATUS_DOT_SIZE - ? EMPHASIZED_STATUS_DOT_OFFSET - : DEFAULT_STATUS_DOT_OFFSET; - - return ( - - - {dotColorStyle ? ( - - ) : null} - - ); -} - -function ProjectInlineChevron({ chevron }: { chevron: "expand" | "collapse" | null }) { - if (chevron === null) { - return null; - } - if (chevron === "collapse") { - return ; - } - return ; -} - function NewWorktreeButton({ displayName, onPress, @@ -967,7 +765,7 @@ function ProjectHeaderRow({ project, displayName, iconDataUri, - workspace, + statusBucket, selected = false, chevron, onPress, @@ -1040,7 +838,7 @@ function ProjectHeaderRow({ ({ flex: 1, minWidth: 0, }, - projectIcon: { - width: "100%", - height: "100%", - borderRadius: theme.borderRadius.sm, - }, - projectLeadingVisualSlot: { - position: "relative", - width: theme.iconSize.md, - height: theme.iconSize.md, - flexShrink: 0, - alignItems: "center", - justifyContent: "center", - }, - projectIconFallback: { - width: "100%", - height: "100%", - borderRadius: theme.borderRadius.sm, - alignItems: "center", - justifyContent: "center", - }, - projectIconFallbackText: { - fontSize: 9, - }, projectTitle: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, @@ -2728,15 +2510,6 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", justifyContent: "center", }, - statusDotOverlay: { - position: "absolute", - right: DEFAULT_STATUS_DOT_OFFSET, - bottom: DEFAULT_STATUS_DOT_OFFSET, - width: DEFAULT_STATUS_DOT_SIZE, - height: DEFAULT_STATUS_DOT_SIZE, - borderRadius: theme.borderRadius.full, - borderWidth: 1, - }, workspaceArchivingOverlay: { ...StyleSheet.absoluteFillObject, borderRadius: theme.borderRadius.lg, @@ -2778,35 +2551,12 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.xs, flexShrink: 0, }, - statusDotNeedsInput: { - backgroundColor: theme.colors.palette.amber[500], - borderColor: theme.colors.surface0, + kebabButton: { + padding: 2, + borderRadius: 4, + marginLeft: 2, }, - statusDotFailed: { - backgroundColor: theme.colors.palette.red[500], - borderColor: theme.colors.surface0, - }, - statusDotRunning: { - backgroundColor: theme.colors.palette.blue[500], - borderColor: theme.colors.surface0, - }, - statusDotAttention: { - backgroundColor: theme.colors.palette.green[500], - borderColor: theme.colors.surface0, + kebabButtonHovered: { + backgroundColor: theme.colors.surface2, }, })); - -function getStatusDotColorStyle(bucket: SidebarStateBucket): ViewStyle | null { - switch (bucket) { - case "needs_input": - return styles.statusDotNeedsInput; - case "failed": - return styles.statusDotFailed; - case "running": - return styles.statusDotRunning; - case "attention": - return styles.statusDotAttention; - case "done": - return null; - } -} diff --git a/packages/app/src/components/sidebar/project-leading-visual.tsx b/packages/app/src/components/sidebar/project-leading-visual.tsx new file mode 100644 index 000000000..280f9fcab --- /dev/null +++ b/packages/app/src/components/sidebar/project-leading-visual.tsx @@ -0,0 +1,276 @@ +import type { ReactNode } from "react"; +import { ActivityIndicator, View, type ViewStyle } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native"; +import { ProjectIconView } from "@/components/project-icon-view"; +import { SyncedLoader } from "@/components/synced-loader"; +import { STATUS_BUCKET_LABELS } from "@/hooks/sidebar-status-view-model"; +import type { Theme } from "@/styles/theme"; +import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; +import { getProjectStatusBadgeContent } from "@/utils/project-status-badge-content"; +import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; + +// Every surfaced status shares one badge shell, so the ring never changes size or position +// between states. Only the thing inside it changes. +const STATUS_BADGE_SIZE = 14; +const STATUS_BADGE_BORDER_WIDTH = 1; +// Centers the badge on the icon's bottom-right corner: half in, half out. +const STATUS_BADGE_OFFSET = -(STATUS_BADGE_SIZE / 2); +// Both glyphs must be EVEN. The shell's interior is 12pt (14 minus the 1pt ring on each side), +// so a centered glyph of size N sits at a (12 - N) / 2 offset — fractional for odd N, which the +// browser snaps to a device-pixel boundary and renders visibly off-center (an odd size measured +// 1.5 device px right and down at 3x, ~3px of asymmetry between opposite gaps). Even sizes +// divide the interior into whole pixels and land dead center with no correction. +// +// Lucide's circle-alert paints ~83% of its nominal size, so an alert of 10 draws a ~8.3pt circle +// against the 8pt dot — the two states read as the same-diameter disc. +const STATUS_BADGE_DOT_SIZE = 8; +const STATUS_BADGE_ALERT_SIZE = 10; +// SyncedLoader clamps its dot size at 2px, so sizes 5-10 all render the same 5x8 dot grid. +const STATUS_BADGE_LOADER_SIZE = 7; +// The dot-comet's 6 divs are geometrically centered, but the snake reads a hair right inside +// the round circle. This optical nudge (pt, leftward) lands its visual center on the circle +// center — the play-button-triangle trick. Dialed in visually: -0.67pt ≈ 2 device px left at +// 3x (a full -1pt / 3px read a touch too far left). +const STATUS_BADGE_LOADER_NUDGE_X = -0.67; + +const ThemedActivityIndicator = withUnistyles(ActivityIndicator); +const ThemedCircleAlert = withUnistyles(CircleAlert); +const ThemedSyncedLoader = withUnistyles(SyncedLoader); + +const foregroundMutedColorMapping = (theme: Theme) => ({ + color: theme.colors.foregroundMuted, +}); +const amberColorMapping = (theme: Theme) => ({ + color: theme.colors.palette.amber[500], +}); +const syncedLoaderColorMapping = (theme: Theme) => ({ + color: + theme.colorScheme === "light" + ? theme.colors.palette.amber[700] + : theme.colors.palette.amber[500], +}); + +/** + * Leading slot of a sidebar project row: chevron on hover, archive spinner while removing, + * otherwise the project icon carrying the project's aggregate workspace status. + */ +export function ProjectLeadingVisual({ + displayName, + iconDataUri, + statusBucket, + projectViewKey, + chevron = null, + showChevron = false, + isArchiving = false, +}: { + displayName: string; + iconDataUri: string | null; + /** Aggregate status of the project's workspaces; null when it shouldn't be surfaced. */ + statusBucket: SidebarStateBucket | null; + projectViewKey: string; + chevron?: "expand" | "collapse" | null; + showChevron?: boolean; + isArchiving?: boolean; +}) { + const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(displayName); + const placeholderInitial = placeholderLabel.charAt(0).toUpperCase(); + + if (showChevron && chevron !== null) { + return ( + + + + ); + } + + if (isArchiving) { + return ( + + + + ); + } + + return ( + + ); +} + +// The project icon (the lettered box) is what marks a row as a *project* rather than a +// workspace, so it always stays. Status rides along as a corner badge instead of replacing +// the icon: "working" and "needs input" keep the same amber loader/alert glyph a workspace +// row uses, the quieter buckets get a dot, and "done" shows nothing. Every one of those sits +// in the identical ringed badge, so the badge reads as one fixed shell across all states. +export function ProjectStatusIndicator({ + iconDataUri, + placeholderInitial, + projectViewKey, + statusBucket, +}: { + iconDataUri: string | null; + placeholderInitial: string; + projectViewKey: string; + statusBucket: SidebarStateBucket | null; +}) { + return ( + + + + + ); +} + +function ProjectStatusBadge({ statusBucket }: { statusBucket: SidebarStateBucket | null }) { + const content = getProjectStatusBadgeContent(statusBucket); + if (content === null || statusBucket === null) { + return null; + } + + return ( + + {renderProjectStatusBadgeContent(content)} + + ); +} + +function renderProjectStatusBadgeContent( + content: NonNullable>, +): ReactNode { + switch (content.kind) { + case "loader": + // Nudged left a hair so the comet's visual center sits on the circle center (see the + // nudge constant). The wrapper carries the transform; the loader itself is untouched. + return ( + + + + ); + case "alert": + return ; + case "dot": + return ; + } +} + +function ProjectIcon({ + iconDataUri, + placeholderInitial, + projectViewKey, +}: { + iconDataUri: string | null; + placeholderInitial: string; + projectViewKey: string; +}) { + return ( + + ); +} + +function ProjectInlineChevron({ chevron }: { chevron: "expand" | "collapse" | null }) { + if (chevron === null) { + return null; + } + if (chevron === "collapse") { + return ; + } + return ; +} + +function getStatusDotColorStyle(bucket: "failed" | "attention"): ViewStyle { + return bucket === "failed" ? styles.statusDotFailed : styles.statusDotAttention; +} + +const styles = StyleSheet.create((theme) => { + // Dot statuses sit *inside* the shell rather than replacing it, so "ready to review" and + // "failed" carry the same ring as the loader and alert. The geometry is shared here and + // baked into each colored variant so the style prop stays a single stable object. + const statusDotBase = { + width: STATUS_BADGE_DOT_SIZE, + height: STATUS_BADGE_DOT_SIZE, + borderRadius: theme.borderRadius.full, + } as const; + + return { + projectLeadingVisualSlot: { + position: "relative", + width: theme.iconSize.md, + height: theme.iconSize.md, + flexShrink: 0, + alignItems: "center", + justifyContent: "center", + }, + projectIcon: { + width: "100%", + height: "100%", + borderRadius: theme.borderRadius.sm, + }, + projectIconFallback: { + width: "100%", + height: "100%", + borderRadius: theme.borderRadius.sm, + alignItems: "center", + justifyContent: "center", + }, + projectIconFallbackText: { + fontSize: 9, + }, + // The one shell every status shares. It straddles the icon's bottom-right corner (half in, + // half out) so the lettered project box stays readable, and its ring is sidebar-colored so + // the badge separates from the icon underneath rather than blending into it. + statusBadge: { + position: "absolute", + right: STATUS_BADGE_OFFSET, + bottom: STATUS_BADGE_OFFSET, + width: STATUS_BADGE_SIZE, + height: STATUS_BADGE_SIZE, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.surface0, + borderWidth: STATUS_BADGE_BORDER_WIDTH, + borderColor: theme.colors.surfaceSidebar, + alignItems: "center", + justifyContent: "center", + overflow: "hidden", + }, + // Optical-centering nudge for the running dot-comet (see the nudge constant). + runningLoaderNudge: { + transform: [{ translateX: STATUS_BADGE_LOADER_NUDGE_X }], + }, + statusDotFailed: { + ...statusDotBase, + backgroundColor: theme.colors.palette.red[500], + }, + statusDotAttention: { + ...statusDotBase, + backgroundColor: theme.colors.palette.green[500], + }, + }; +}); diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts index 332f90a7d..f9d192dbb 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; -import type { WorkspaceDescriptor } from "@/stores/session-store"; +import type { Agent, WorkspaceDescriptor } from "@/stores/session-store"; import type { WorkspaceStructureProject } from "@/projects/workspace-structure"; +import { buildWorkspaceAgentActivityIndex } from "@/utils/workspace-agent-activity"; import { appendMissingOrderKeys, applyStoredOrdering, @@ -9,9 +10,12 @@ import { buildSidebarProjectsFromStructure, computeSidebarOrderUpdates, createSidebarWorkspaceEntry, + deriveProjectStatusBucket, deriveSidebarLoadingState, shouldShowSidebarHostLabels, + type ProjectStatusSession, type SidebarProjectEntry, + type SidebarWorkspacePlacement, } from "./sidebar-workspaces-view-model"; function workspaceWithForge(forge: string | undefined, prUrl: string): WorkspaceDescriptor { @@ -664,3 +668,274 @@ describe("deriveSidebarLoadingState", () => { ).toEqual({ isLoading: false, isInitialLoad: false, isRevalidating: false }); }); }); + +function workspacePlacement(input: { + serverId?: string; + workspaceId: string; + projectViewKey?: string; +}): SidebarWorkspacePlacement { + const serverId = input.serverId ?? "srv"; + const projectViewKey = input.projectViewKey ?? "project-a"; + return { + workspaceKey: `${serverId}:${input.workspaceId}`, + serverId, + workspaceId: input.workspaceId, + projectViewKey, + projectName: projectViewKey, + projectKind: "git", + workspaceKind: "worktree", + name: input.workspaceId, + }; +} + +function agent(input: { + id: string; + workspaceId: string; + status: Agent["status"]; + updatedAt?: Date; + parentAgentId?: string | null; + archivedAt?: Date | null; +}): Agent { + return { + serverId: "srv", + id: input.id, + provider: "claude" as Agent["provider"], + status: input.status, + activeTurn: null, + createdAt: new Date(0), + updatedAt: input.updatedAt ?? new Date(1_000), + lastUserMessageAt: null, + lastActivityAt: new Date(1_000), + capabilities: {} as Agent["capabilities"], + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: null, + cwd: "/repo", + workspaceId: input.workspaceId, + model: null, + parentAgentId: input.parentAgentId ?? null, + archivedAt: input.archivedAt ?? null, + labels: {}, + }; +} + +function sessionWith(input: { + workspaces: WorkspaceDescriptor[]; + agents?: Agent[]; +}): ProjectStatusSession { + return { + workspaces: new Map(input.workspaces.map((entry) => [entry.id, entry])), + workspaceAgentActivity: buildWorkspaceAgentActivityIndex( + new Map((input.agents ?? []).map((entry) => [entry.id, entry])), + ), + }; +} + +function projectWorkspace(id: string, status: WorkspaceDescriptor["status"]): WorkspaceDescriptor { + return workspace({ + id, + name: id, + projectId: "project-a", + projectDisplayName: "project-a", + status, + }); +} + +describe("deriveProjectStatusBucket", () => { + it("is done when the project has no workspaces", () => { + expect(deriveProjectStatusBucket({ workspaces: [], sessions: {} })).toBe("done"); + }); + + it("is done when every workspace is done", () => { + expect( + deriveProjectStatusBucket({ + workspaces: [ + workspacePlacement({ workspaceId: "ws-1" }), + workspacePlacement({ workspaceId: "ws-2" }), + ], + sessions: { + srv: sessionWith({ + workspaces: [projectWorkspace("ws-1", "done"), projectWorkspace("ws-2", "done")], + }), + }, + }), + ).toBe("done"); + }); + + it("surfaces the most urgent workspace status in the project", () => { + expect( + deriveProjectStatusBucket({ + workspaces: [ + workspacePlacement({ workspaceId: "ws-1" }), + workspacePlacement({ workspaceId: "ws-2" }), + workspacePlacement({ workspaceId: "ws-3" }), + ], + sessions: { + srv: sessionWith({ + workspaces: [ + projectWorkspace("ws-1", "done"), + projectWorkspace("ws-2", "running"), + projectWorkspace("ws-3", "needs_input"), + ], + }), + }, + }), + ).toBe("needs_input"); + }); + + it("keeps a working project on running when a finished workspace also awaits review", () => { + expect( + deriveProjectStatusBucket({ + workspaces: [ + workspacePlacement({ workspaceId: "ws-1" }), + workspacePlacement({ workspaceId: "ws-2" }), + ], + sessions: { + srv: sessionWith({ + workspaces: [ + projectWorkspace("ws-1", "running"), + projectWorkspace("ws-2", "attention"), + ], + }), + }, + }), + ).toBe("running"); + }); + + it("surfaces needs_input over a concurrently running workspace", () => { + expect( + deriveProjectStatusBucket({ + workspaces: [ + workspacePlacement({ workspaceId: "ws-1" }), + workspacePlacement({ workspaceId: "ws-2" }), + ], + sessions: { + srv: sessionWith({ + workspaces: [ + projectWorkspace("ws-1", "needs_input"), + projectWorkspace("ws-2", "running"), + ], + }), + }, + }), + ).toBe("needs_input"); + }); + + it("surfaces failed over a concurrently running workspace", () => { + expect( + deriveProjectStatusBucket({ + workspaces: [ + workspacePlacement({ workspaceId: "ws-1" }), + workspacePlacement({ workspaceId: "ws-2" }), + ], + sessions: { + srv: sessionWith({ + workspaces: [projectWorkspace("ws-1", "failed"), projectWorkspace("ws-2", "running")], + }), + }, + }), + ).toBe("failed"); + }); + + it("keeps a project on attention when only one workspace awaits review", () => { + expect( + deriveProjectStatusBucket({ + workspaces: [ + workspacePlacement({ workspaceId: "ws-1" }), + workspacePlacement({ workspaceId: "ws-2" }), + ], + sessions: { + srv: sessionWith({ + workspaces: [projectWorkspace("ws-1", "attention"), projectWorkspace("ws-2", "done")], + }), + }, + }), + ).toBe("attention"); + }); + + it("aggregates across the hosts a project spans", () => { + expect( + deriveProjectStatusBucket({ + workspaces: [ + workspacePlacement({ serverId: "srv", workspaceId: "ws-1" }), + workspacePlacement({ serverId: "other", workspaceId: "ws-9" }), + ], + sessions: { + srv: sessionWith({ workspaces: [projectWorkspace("ws-1", "done")] }), + other: sessionWith({ workspaces: [projectWorkspace("ws-9", "running")] }), + }, + }), + ).toBe("running"); + }); + + it("skips workspaces whose session has not hydrated yet", () => { + expect( + deriveProjectStatusBucket({ + workspaces: [ + workspacePlacement({ workspaceId: "ws-1" }), + workspacePlacement({ serverId: "offline", workspaceId: "ws-2" }), + ], + sessions: { + srv: sessionWith({ workspaces: [projectWorkspace("ws-1", "running")] }), + }, + }), + ).toBe("running"); + }); + + it("lifts a done workspace when one of its root agents is still working", () => { + expect( + deriveProjectStatusBucket({ + workspaces: [workspacePlacement({ workspaceId: "ws-1" })], + sessions: { + srv: sessionWith({ + workspaces: [projectWorkspace("ws-1", "done")], + agents: [agent({ id: "a1", workspaceId: "ws-1", status: "running" })], + }), + }, + }), + ).toBe("running"); + }); + + it("ignores archived agents and subagents", () => { + expect( + deriveProjectStatusBucket({ + workspaces: [workspacePlacement({ workspaceId: "ws-1" })], + sessions: { + srv: sessionWith({ + workspaces: [projectWorkspace("ws-1", "done")], + agents: [ + agent({ + id: "archived", + workspaceId: "ws-1", + status: "running", + archivedAt: new Date(2_000), + }), + agent({ + id: "subagent", + workspaceId: "ws-1", + status: "running", + parentAgentId: "a1", + }), + ], + }), + }, + }), + ).toBe("done"); + }); + + it("ignores agents belonging to workspaces outside the project", () => { + expect( + deriveProjectStatusBucket({ + workspaces: [workspacePlacement({ workspaceId: "ws-1" })], + sessions: { + srv: sessionWith({ + workspaces: [projectWorkspace("ws-1", "done"), projectWorkspace("ws-other", "done")], + agents: [agent({ id: "a1", workspaceId: "ws-other", status: "running" })], + }), + }, + }), + ).toBe("done"); + }); +}); diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts index 5c73b0356..b31129cea 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.ts @@ -8,6 +8,7 @@ import type { WorkspaceStructureProject, } from "@/projects/workspace-structure"; import { projectDisplayNameFromProjectId } from "@/utils/project-display-name"; +import { aggregateSidebarStateBuckets } from "@/utils/sidebar-agent-state"; import { shortenPath } from "@/utils/shorten-path"; import type { WorkspaceAgentActivity } from "@/utils/workspace-agent-activity"; import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; @@ -226,6 +227,60 @@ function getPendingInitialAgentCreateStartedAt(input: { return latestStartedAt; } +export interface ProjectStatusSession { + workspaces: Map; + workspaceAgentActivity: Map; +} + +/** + * Most urgent status among a project's workspaces. Backs the status dot on a collapsed + * project row, which otherwise hides every workspace-level signal it contains. + * + * Workspaces the session hasn't hydrated yet are skipped rather than counted as done — + * an unknown workspace shouldn't drag the aggregate anywhere. Reuses the same + * activity-index + effective-status pipeline as per-workspace rows (one pass over the + * session's agents per server, not per workspace) rather than re-deriving it. + */ +export function deriveProjectStatusBucket(input: { + workspaces: readonly SidebarWorkspacePlacement[]; + sessions: Record; + pendingCreateAttempts?: Record; +}): SidebarStateBucket { + const workspaceIdsByServer = new Map(); + for (const placement of input.workspaces) { + const existing = workspaceIdsByServer.get(placement.serverId); + if (existing) { + existing.push(placement.workspaceId); + } else { + workspaceIdsByServer.set(placement.serverId, [placement.workspaceId]); + } + } + + const buckets: SidebarStateBucket[] = []; + for (const [serverId, workspaceIds] of workspaceIdsByServer) { + const session = input.sessions[serverId]; + if (!session) continue; + for (const workspaceId of workspaceIds) { + const workspaceKey = resolveWorkspaceMapKeyByIdentity({ + workspaces: session.workspaces, + workspaceId, + }); + const workspace = workspaceKey ? session.workspaces.get(workspaceKey) : undefined; + if (!workspace) continue; + buckets.push( + deriveEffectiveWorkspaceStatus({ + serverId, + workspace, + pendingCreateAttempts: input.pendingCreateAttempts, + workspaceAgentActivity: session.workspaceAgentActivity, + }).status, + ); + } + } + + return aggregateSidebarStateBuckets(buckets); +} + export function buildSidebarWorkspacePlacementModel(input: { projects: readonly HostProjectListItem[]; }): SidebarWorkspacePlacementModel { diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index 5491aadf3..38e9d84ff 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -1,5 +1,9 @@ import { useCallback, useEffect, useMemo } from "react"; +import { useStoreWithEqualityFn } from "zustand/traditional"; +import { useCreateFlowStore } from "@/stores/create-flow-store"; +import { useSessionStore } from "@/stores/session-store"; import { useHydratedWorkspaceServerIds } from "@/stores/session-store-hooks"; +import { workspaceEqualityFns } from "@/stores/session-store-hooks/selectors"; import { useHostProjects } from "@/projects/host-projects"; import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; @@ -7,11 +11,15 @@ import { useSidebarViewStore } from "@/stores/sidebar-view-store"; import { buildSidebarWorkspacePlacementModel, computeSidebarOrderUpdates, + createSidebarWorkspaceEntry, + deriveProjectStatusBucket, deriveSidebarLoadingState, + type ProjectStatusSession, type SidebarProjectEntry, type SidebarWorkspaceEntry, type SidebarWorkspacePlacement, } from "./sidebar-workspaces-view-model"; +import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; export { appendMissingOrderKeys, @@ -21,6 +29,7 @@ export { createSidebarWorkspaceEntry, buildSidebarWorkspacePlacementModel, computeSidebarOrderUpdates, + deriveProjectStatusBucket, deriveSidebarLoadingState, shouldShowSidebarHostLabels, type SidebarLoadingState, @@ -33,6 +42,43 @@ export { type SidebarWorkspaceEntry, } from "./sidebar-workspaces-view-model"; +/** + * Aggregate status for a project's workspaces, for the collapsed project row. + * + * `SidebarProjectEntry` is structural — it carries workspace identity but no status — and + * `ProjectBlock` is memoized on that stable reference, so the row can't learn about a + * child's status without its own subscription. Returns a primitive, so status churn in a + * project only re-renders the row when the aggregate actually moves. + * + * Pass `enabled: false` while the project is expanded: the child rows show their own dots + * and the selector is pure cost. + */ +export function useSidebarProjectStatusBucket(input: { + workspaces: readonly SidebarWorkspacePlacement[]; + enabled: boolean; +}): SidebarStateBucket | null { + const { workspaces, enabled } = input; + const pendingCreateAttempts = useStoreWithEqualityFn( + useCreateFlowStore, + (state) => state.pendingByDraftId, + workspaceEqualityFns.deep, + ); + + const selector = useCallback( + (state: { sessions: Record }) => { + if (!enabled) return null; + return deriveProjectStatusBucket({ + workspaces, + sessions: state.sessions, + pendingCreateAttempts, + }); + }, + [enabled, pendingCreateAttempts, workspaces], + ); + + return useStoreWithEqualityFn(useSessionStore, selector, Object.is); +} + const EMPTY_ORDER: string[] = []; const EMPTY_PROJECTS: SidebarProjectEntry[] = []; const EMPTY_WORKSPACES: SidebarWorkspacePlacement[] = []; diff --git a/packages/app/src/utils/project-status-badge-content.test.ts b/packages/app/src/utils/project-status-badge-content.test.ts new file mode 100644 index 000000000..e487d1790 --- /dev/null +++ b/packages/app/src/utils/project-status-badge-content.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { getProjectStatusBadgeContent } from "./project-status-badge-content"; + +describe("getProjectStatusBadgeContent", () => { + it("shows the loader while running", () => { + expect(getProjectStatusBadgeContent("running")).toEqual({ kind: "loader" }); + }); + + it("shows the alert when input is needed", () => { + expect(getProjectStatusBadgeContent("needs_input")).toEqual({ kind: "alert" }); + }); + + it("shows a failed dot when a workspace failed", () => { + expect(getProjectStatusBadgeContent("failed")).toEqual({ kind: "dot", bucket: "failed" }); + }); + + it("shows an attention dot when a workspace awaits review", () => { + expect(getProjectStatusBadgeContent("attention")).toEqual({ + kind: "dot", + bucket: "attention", + }); + }); + + it("shows nothing when the project is done", () => { + expect(getProjectStatusBadgeContent("done")).toBeNull(); + }); + + it("shows nothing when there is no aggregate bucket to surface", () => { + expect(getProjectStatusBadgeContent(null)).toBeNull(); + }); +}); diff --git a/packages/app/src/utils/project-status-badge-content.ts b/packages/app/src/utils/project-status-badge-content.ts new file mode 100644 index 000000000..f8e8c296e --- /dev/null +++ b/packages/app/src/utils/project-status-badge-content.ts @@ -0,0 +1,30 @@ +import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; +import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader"; + +export type ProjectStatusBadgeContent = + | { kind: "loader" } + | { kind: "alert" } + | { kind: "dot"; bucket: "failed" | "attention" }; + +/** + * What the collapsed-project status badge should render for a project's aggregate bucket, + * or null when no badge should show at all. Kept as plain data (no React) so it's testable + * without JSDOM or component mounting — see docs/testing.md's two test categories. + * + * The "dot" variant is narrowed to failed/attention only: running renders the loader and + * needs_input renders the alert, so those two buckets can never reach a dot. + */ +export function getProjectStatusBadgeContent( + statusBucket: SidebarStateBucket | null, +): ProjectStatusBadgeContent | null { + if (shouldRenderSyncedStatusLoader({ bucket: statusBucket })) { + return { kind: "loader" }; + } + if (statusBucket === "needs_input") { + return { kind: "alert" }; + } + if (statusBucket === "failed" || statusBucket === "attention") { + return { kind: "dot", bucket: statusBucket }; + } + return null; +} diff --git a/packages/app/src/utils/sidebar-agent-state.test.ts b/packages/app/src/utils/sidebar-agent-state.test.ts index 53130f3d4..68003c362 100644 --- a/packages/app/src/utils/sidebar-agent-state.test.ts +++ b/packages/app/src/utils/sidebar-agent-state.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { deriveSidebarStateBucket } from "./sidebar-agent-state"; +import { + aggregateSidebarStateBuckets, + deriveSidebarStateBucket, + type SidebarStateBucket, +} from "./sidebar-agent-state"; describe("deriveSidebarStateBucket", () => { it("prioritizes pending permissions as needs_input", () => { @@ -46,3 +50,50 @@ describe("deriveSidebarStateBucket", () => { ).toBe("done"); }); }); + +describe("aggregateSidebarStateBuckets", () => { + it("returns done for a project with no workspaces", () => { + expect(aggregateSidebarStateBuckets([])).toBe("done"); + }); + + it("returns done when every workspace is done", () => { + expect(aggregateSidebarStateBuckets(["done", "done", "done"])).toBe("done"); + }); + + it("surfaces a single running workspace among finished ones", () => { + expect(aggregateSidebarStateBuckets(["done", "running", "done"])).toBe("running"); + }); + + it("prefers needs_input over every other bucket", () => { + expect(aggregateSidebarStateBuckets(["running", "attention", "failed", "needs_input"])).toBe( + "needs_input", + ); + }); + + it("prefers failed over attention and running", () => { + expect(aggregateSidebarStateBuckets(["running", "attention", "failed"])).toBe("failed"); + }); + + it("prefers running over ready-to-review so a working project keeps its loader", () => { + expect(aggregateSidebarStateBuckets(["attention", "running"])).toBe("running"); + }); + + it("prefers ready-to-review over done", () => { + expect(aggregateSidebarStateBuckets(["done", "attention", "done"])).toBe("attention"); + }); + + it("follows the full needs_input > failed > running > attention > done ordering", () => { + // Each pair of adjacent buckets: the more urgent one wins when both are present. + expect(aggregateSidebarStateBuckets(["failed", "needs_input"])).toBe("needs_input"); + expect(aggregateSidebarStateBuckets(["running", "failed"])).toBe("failed"); + expect(aggregateSidebarStateBuckets(["attention", "running"])).toBe("running"); + expect(aggregateSidebarStateBuckets(["done", "attention"])).toBe("attention"); + }); + + it("is order-independent", () => { + const buckets: SidebarStateBucket[] = ["attention", "running", "failed"]; + expect(aggregateSidebarStateBuckets(buckets)).toBe( + aggregateSidebarStateBuckets(buckets.toReversed()), + ); + }); +}); diff --git a/packages/app/src/utils/sidebar-agent-state.ts b/packages/app/src/utils/sidebar-agent-state.ts index 3d46e632a..c166b0276 100644 --- a/packages/app/src/utils/sidebar-agent-state.ts +++ b/packages/app/src/utils/sidebar-agent-state.ts @@ -14,3 +14,34 @@ export function deriveSidebarStateBucket(input: AgentStateBucketInput): SidebarS export function isSidebarActiveAgent(input: AgentStateBucketInput): boolean { return deriveSidebarStateBucket(input) !== "done"; } + +// Most urgent first, for collapsing a project's workspaces into one badge. This is +// deliberately NOT the flat status-list order (STATUS_BUCKET_ORDER in +// hooks/sidebar-status-view-model.ts), which ranks "attention" above "running": on a +// collapsed project row we want an actively-working project to keep showing the loader, +// so "running" outranks "attention" here. Blocked (needs_input) and failed still win over +// both; done stays last. +const STATUS_BUCKET_PRIORITY: readonly SidebarStateBucket[] = [ + "needs_input", + "failed", + "running", + "attention", + "done", +]; + +/** + * Collapses many workspace status buckets into the single most urgent one, so a + * collapsed project row can stand in for the child rows it hides. + */ +export function aggregateSidebarStateBuckets( + buckets: Iterable, +): SidebarStateBucket { + let bestRank = STATUS_BUCKET_PRIORITY.length - 1; + for (const bucket of buckets) { + const rank = STATUS_BUCKET_PRIORITY.indexOf(bucket); + if (rank !== -1 && rank < bestRank) { + bestRank = rank; + } + } + return STATUS_BUCKET_PRIORITY[bestRank] ?? "done"; +}