From 31ae545289400557acd2a3d4c908c21b439e8f6e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 4 Jun 2026 01:20:20 +0800 Subject: [PATCH] Group and clear workspace status in the sidebar (#1317) * Add status grouping to the workspace sidebar * Mark workspaces as read from the sidebar * Clean package builds before release checks * Fix sidebar status follow-up checks --- package.json | 14 +- packages/app/src/components/left-sidebar.tsx | 152 ++- .../sidebar-workspace-list.test.tsx | 1 + .../src/components/sidebar-workspace-list.tsx | 388 ++++---- .../sidebar/sidebar-grouping-selector.tsx | 113 +++ .../sidebar/sidebar-status-list.tsx | 888 ++++++++++++++++++ .../sidebar/sidebar-workspace-row-content.tsx | 446 +++++++++ ...space-shortcut-targets-subscriber.test.tsx | 78 +- .../workspace-shortcut-targets-subscriber.tsx | 36 +- .../session-context.service-status.test.ts | 1 + .../session-workspace-upserts.test.ts | 1 + packages/app/src/git/actions-store.test.ts | 1 + .../hooks/sidebar-status-view-model.test.ts | 213 +++++ .../src/hooks/sidebar-status-view-model.ts | 94 ++ .../hooks/sidebar-workspaces-view-model.ts | 2 + .../hooks/use-clear-workspace-attention.ts | 37 + .../app/src/hooks/use-keyboard-shortcuts.ts | 15 +- .../app/src/hooks/use-open-project.test.ts | 1 + packages/app/src/hooks/use-projects.test.ts | 1 + .../src/hooks/use-sidebar-workspaces-list.ts | 1 + .../src/hooks/use-status-mode-workspaces.ts | 37 + .../src/keyboard/keyboard-shortcuts.test.ts | 4 + .../app/src/keyboard/route-shortcut.test.ts | 58 ++ .../workspace/workspace-route-state.test.ts | 1 + .../workspace-source-of-truth.test.ts | 1 + .../session-store-hooks/selectors.test.ts | 1 + packages/app/src/stores/session-store.test.ts | 40 + packages/app/src/stores/session-store.ts | 7 + .../sidebar-collapsed-sections-store/index.ts | 5 + .../state.test.ts | 8 +- .../sidebar-collapsed-sections-store/state.ts | 45 +- packages/app/src/stores/sidebar-view-store.ts | 41 + .../utils/navigate-to-agent/resolve.test.ts | 1 + packages/app/src/utils/projects.test.ts | 1 + .../app/src/utils/sidebar-agent-state.test.ts | 4 +- .../utils/sidebar-project-row-model.test.ts | 1 + .../app/src/utils/sidebar-shortcuts.test.ts | 73 +- packages/app/src/utils/sidebar-shortcuts.ts | 28 +- .../workspace-archive-navigation.test.ts | 1 + .../app/src/utils/workspace-execution.test.ts | 1 + .../src/workspace/workspace-archive.test.ts | 1 + packages/cli/package.json | 6 +- packages/client/package.json | 6 +- packages/client/src/daemon-client.ts | 27 + packages/client/src/index.test.ts | 1 + packages/desktop/package.json | 2 +- packages/desktop/scripts/dev.ps1 | 19 +- packages/desktop/scripts/dev.sh | 2 +- packages/highlight/package.json | 6 +- packages/protocol/package.json | 6 +- .../protocol/src/agent-state-bucket.test.ts | 4 +- packages/protocol/src/agent-state-bucket.ts | 3 +- packages/protocol/src/messages.ts | 36 + .../protocol/src/messages.workspaces.test.ts | 67 ++ packages/relay/package.json | 6 +- packages/server/package.json | 6 +- packages/server/src/server/session.ts | 220 ++++- .../src/server/session.workspaces.test.ts | 432 ++++++++- .../server/workspace-bootstrap-dedupe.test.ts | 128 +++ .../src/server/workspace-bootstrap-dedupe.ts | 59 ++ .../server/src/server/workspace-directory.ts | 137 +++ paseo.json | 5 +- scripts/clean-package-dist.mjs | 17 + scripts/dev-daemon.sh | 4 + scripts/dev.sh | 7 +- 65 files changed, 3758 insertions(+), 290 deletions(-) create mode 100644 packages/app/src/components/sidebar/sidebar-grouping-selector.tsx create mode 100644 packages/app/src/components/sidebar/sidebar-status-list.tsx create mode 100644 packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx create mode 100644 packages/app/src/hooks/sidebar-status-view-model.test.ts create mode 100644 packages/app/src/hooks/sidebar-status-view-model.ts create mode 100644 packages/app/src/hooks/use-clear-workspace-attention.ts create mode 100644 packages/app/src/hooks/use-status-mode-workspaces.ts create mode 100644 packages/app/src/stores/sidebar-view-store.ts create mode 100644 packages/server/src/server/workspace-bootstrap-dedupe.test.ts create mode 100644 packages/server/src/server/workspace-bootstrap-dedupe.ts create mode 100644 scripts/clean-package-dist.mjs diff --git a/package.json b/package.json index 72ecd62eb..d59c6745c 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ "scripts": { "dev": "./scripts/dev.sh", "dev:win": "powershell ./scripts/dev.ps1", - "dev:server": "npm run build:server-deps && concurrently --kill-others --names protocol,client,server --prefix-colors yellow,blue,cyan \"npm run watch:protocol\" \"npm run watch:client\" \"npm run dev:server:raw\"", + "dev:server": "npm run build:server-deps && npm run dev:server:watch", + "dev:server:watch": "concurrently --kill-others --names protocol,client,server --prefix-colors yellow,blue,cyan \"npm run watch:protocol\" \"npm run watch:client\" \"npm run dev:server:raw\"", "dev:server:raw": "npm run dev --workspace=@getpaseo/server", "dev:app": "npm run start --workspace=@getpaseo/app", "dev:website": "npm run dev --workspace=@getpaseo/website", @@ -44,12 +45,19 @@ "prepare": "lefthook install --force", "build": "npm run build --workspaces --if-present", "build:highlight": "npm run build --workspace=@getpaseo/highlight", + "build:highlight:clean": "npm run build:clean --workspace=@getpaseo/highlight", "build:relay": "npm run build --workspace=@getpaseo/relay", + "build:relay:clean": "npm run build:clean --workspace=@getpaseo/relay", "build:protocol": "npm run build --workspace=@getpaseo/protocol", + "build:protocol:clean": "npm run build:clean --workspace=@getpaseo/protocol", "build:client": "npm run build:protocol && npm run build --workspace=@getpaseo/client", + "build:client:clean": "npm run build:protocol:clean && npm run build:clean --workspace=@getpaseo/client", "build:server-deps": "npm run build:highlight && npm run build:relay && npm run build:client", + "build:server-deps:clean": "npm run build:highlight:clean && npm run build:relay:clean && npm run build:client:clean", "build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli", + "build:server:clean": "npm run build:server-deps:clean && npm run build:clean --workspace=@getpaseo/server && npm run build:clean --workspace=@getpaseo/cli", "build:app-deps": "npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio", + "build:app-deps:clean": "npm run build:highlight:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio", "watch:protocol": "tsc -p packages/protocol/tsconfig.json --watch --preserveWatchOutput", "watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput", "typecheck": "npm run typecheck --workspaces --if-present", @@ -76,7 +84,7 @@ "web": "npm run web --workspace=@getpaseo/app", "dev:desktop": "npm run dev --workspace=@getpaseo/desktop", "dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop", - "build:desktop": "npm run build:app-deps && npm run build:server-deps && npm run build --workspace=@getpaseo/server && cd packages/app && cross-env PASEO_WEB_PLATFORM=electron npx expo export --platform web && cd ../.. && npm run build --workspace=@getpaseo/desktop --", + "build:desktop": "npm run build:app-deps:clean && cd packages/app && cross-env PASEO_WEB_PLATFORM=electron npx expo export --platform web && cd ../.. && npm run build --workspace=@getpaseo/desktop --", "db:query": "npm run db:query --workspace=@getpaseo/server --", "cli": "npx tsx packages/cli/src/index.js", "version": "npm run version:sync-internal && npm run release:prepare && git add -A", @@ -90,7 +98,7 @@ "version:all:beta:major": "node scripts/set-release-version.mjs --mode beta-major", "version:all:beta:next": "node scripts/set-release-version.mjs --mode beta-next", "version:all:promote": "node scripts/set-release-version.mjs --mode promote", - "release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run build:client && npm run typecheck --workspace=@getpaseo/client && npm run build:server && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/protocol && npm pack --dry-run --workspace=@getpaseo/client && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli", + "release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run build:client:clean && npm run typecheck --workspace=@getpaseo/client && npm run build:server:clean && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/protocol && npm pack --dry-run --workspace=@getpaseo/client && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli", "release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/protocol --access public && npm publish --dry-run --workspace=@getpaseo/client --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public", "release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/protocol --access public && npm publish --workspace=@getpaseo/client --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public", "release:push": "node scripts/push-current-release-tag.mjs", diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 70b52905b..529583de8 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -1,5 +1,5 @@ import { router, usePathname } from "expo-router"; -import { FolderPlus, Home, MessagesSquare, Settings, X } from "lucide-react-native"; +import { FolderPlus, Home, MessagesSquare, Search, Settings, X } from "lucide-react-native"; import { type Dispatch, memo, @@ -34,6 +34,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; +import { SidebarGroupingSelector } from "@/components/sidebar/sidebar-grouping-selector"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -47,6 +48,8 @@ import { type SidebarProjectEntry, useSidebarWorkspacesList, } from "@/hooks/use-sidebar-workspaces-list"; +import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store"; +import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime"; import { MAX_SIDEBAR_WIDTH, @@ -89,6 +92,7 @@ interface SidebarSharedProps { isInitialLoad: boolean; isRevalidating: boolean; isManualRefresh: boolean; + groupMode: SidebarGroupMode; collapsedProjectKeys: SidebarShortcutModel["collapsedProjectKeys"]; shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"]; toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"]; @@ -192,6 +196,10 @@ export const LeftSidebar = memo(function LeftSidebar({ const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } = useSidebarShortcutModel({ projects, isInitialLoad }); + const groupMode = useSidebarViewStore((state) => + activeServerId ? state.getGroupMode(activeServerId) : "project", + ); + const [isManualRefresh, setIsManualRefresh] = useState(false); const handleRefresh = useCallback(() => { @@ -268,6 +276,7 @@ export const LeftSidebar = memo(function LeftSidebar({ isInitialLoad, isRevalidating, isManualRefresh, + groupMode, collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed, @@ -417,6 +426,21 @@ function AddProjectTooltipContent({ ); } +function HeaderIconTooltipContent({ + label, + shortcutKeys, +}: { + label: string; + shortcutKeys?: ReturnType; +}) { + return ( + + {label} + {shortcutKeys ? : null} + + ); +} + function SidebarFooter({ theme, activeServerId, @@ -518,6 +542,7 @@ function MobileSidebar({ isInitialLoad, isRevalidating, isManualRefresh, + groupMode, collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed, @@ -698,13 +723,16 @@ function MobileSidebar({ - + + + + {padding.top > 0 ? : null} - + + + + {isInitialLoad ? ( @@ -877,6 +910,7 @@ function DesktopSidebar({ collapsedProjectKeys={collapsedProjectKeys} onToggleProjectCollapsed={toggleProjectCollapsed} shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey} + groupMode={groupMode} projects={projects} isRefreshing={isManualRefresh && isRevalidating} onRefresh={handleRefresh} @@ -911,6 +945,61 @@ function DesktopSidebar({ ); } +function WorkspacesSectionHeader({ serverId }: { serverId: string | null }) { + const { theme } = useUnistyles(); + const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen); + const commandCenterKeys = useShortcutKeys("toggle-command-center"); + const handleSearchPress = useCallback(() => setCommandCenterOpen(true), [setCommandCenterOpen]); + const searchButtonStyle = useCallback( + ({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.workspacesHeaderIconButton, + (hovered || pressed) && styles.workspacesHeaderIconButtonHovered, + ], + [], + ); + + return ( + + Workspaces + + + + + {({ hovered, pressed }) => ( + + )} + + + + + + + + + + + + + + + + + + + ); +} + // Static styles for Animated.Views — must NOT use Unistyles dynamic theme to // avoid the "Unable to find node on an unmounted component" crash when Unistyles // tries to patch the native node that Reanimated also manages. @@ -932,6 +1021,39 @@ const staticStyles = RNStyleSheet.create({ }); const styles = StyleSheet.create((theme) => ({ + sidebarHeaderRow: { + position: "relative", + }, + workspacesSectionHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[2], + paddingLeft: theme.spacing[2] + theme.spacing[3], + paddingRight: theme.spacing[4], + paddingTop: theme.spacing[2], + paddingBottom: theme.spacing[1], + }, + workspacesSectionTitle: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.normal, + }, + workspacesSectionActions: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, + workspacesHeaderIconButton: { + width: 28, + height: 28, + alignItems: "center", + justifyContent: "center", + borderRadius: theme.borderRadius.md, + }, + workspacesHeaderIconButtonHovered: { + backgroundColor: theme.colors.surfaceSidebarHover, + }, sidebarContent: { flex: 1, minHeight: 0, diff --git a/packages/app/src/components/sidebar-workspace-list.test.tsx b/packages/app/src/components/sidebar-workspace-list.test.tsx index cf38bc493..f475772d4 100644 --- a/packages/app/src/components/sidebar-workspace-list.test.tsx +++ b/packages/app/src/components/sidebar-workspace-list.test.tsx @@ -90,6 +90,7 @@ function workspace(input: { workspaceKind: input.name === "main" ? "local_checkout" : "worktree", name: input.name, status: input.status ?? "done", + statusEnteredAt: null, archivingAt: null, diffStat: null, scripts: input.scripts ?? [], diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index a1c7e6d05..0ca57b98e 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -41,17 +41,16 @@ import { DiffStat } from "@/components/diff-stat"; import { Archive, CircleAlert, + CircleCheck, ChevronDown, ChevronRight, Copy, ExternalLink, FolderPlus, - FolderGit2, GitPullRequest, Globe, Settings, SquareTerminal, - Monitor, MoreVertical, Pencil, Plus, @@ -98,12 +97,23 @@ import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-disp 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 { + SidebarWorkspaceRowFrame, + SidebarWorkspaceRowContent, + sidebarWorkspaceRowStyles, +} from "@/components/sidebar/sidebar-workspace-row-content"; +import { + useHydratedWorkspaceEntries, + useProjectNamesMap, +} from "@/hooks/use-status-mode-workspaces"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Shortcut } from "@/components/ui/shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; +import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention"; import type { PrHint } from "@/git/use-pr-status-query"; import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; @@ -119,8 +129,6 @@ import { archiveWorkspaceOptimistically, archiveWorkspacesOptimistically, } from "@/workspace/workspace-archive"; -import { WorkspaceHoverCard } from "@/components/workspace-hover-card"; -import { GitHubIcon } from "@/components/icons/github-icon"; import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform"; const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey; @@ -134,12 +142,10 @@ const DEFAULT_STATUS_DOT_OFFSET = 0; const EMPHASIZED_STATUS_DOT_OFFSET = -1; const ThemedExternalLink = withUnistyles(ExternalLink); const ThemedGitPullRequest = withUnistyles(GitPullRequest); -const ThemedGitHubIcon = withUnistyles(GitHubIcon); const ThemedActivityIndicator = withUnistyles(ActivityIndicator); const ThemedCircleAlert = withUnistyles(CircleAlert); +const ThemedCircleCheck = withUnistyles(CircleCheck); const ThemedSyncedLoader = withUnistyles(SyncedLoader); -const ThemedMonitor = withUnistyles(Monitor); -const ThemedFolderGit2 = withUnistyles(FolderGit2); const ThemedFolderPlus = withUnistyles(FolderPlus); const ThemedGlobe = withUnistyles(Globe); const ThemedSquareTerminal = withUnistyles(SquareTerminal); @@ -229,6 +235,7 @@ interface SidebarWorkspaceListProps { collapsedProjectKeys: ReadonlySet; onToggleProjectCollapsed: (projectKey: string) => void; shortcutIndexByWorkspaceKey: Map; + groupMode: "project" | "status"; isRefreshing?: boolean; onRefresh?: () => void; onWorkspacePress?: () => void; @@ -281,6 +288,7 @@ interface WorkspaceRowInnerProps { onCopyBranchName?: () => void; onCopyPath?: () => void; onRename?: () => void; + onMarkAsRead?: () => void; archiveShortcutKeys?: ShortcutKey[][] | null; } @@ -294,7 +302,7 @@ function getWorkspaceArchiveStatus( return "idle"; } -function useSidebarWorkspaceEntry( +export function useSidebarWorkspaceEntry( serverId: string | null, workspaceId: string | null, ): SidebarWorkspaceEntry | null { @@ -367,6 +375,23 @@ function workspaceKebabStyle({ return [styles.kebabButton, hovered && styles.kebabButtonHovered]; } +function getProjectWorkspaceRowStyle({ + isDragging, + selected, + isHovered, +}: { + isDragging: boolean; + selected: boolean; + isHovered: boolean; +}) { + return [ + styles.workspaceRow, + isDragging && styles.workspaceRowDragging, + selected && styles.sidebarRowSelected, + isHovered && styles.workspaceRowHovered, + ]; +} + function noop() {} const prBadgeStyles = StyleSheet.create((theme) => ({ @@ -391,100 +416,6 @@ const prBadgeStyles = StyleSheet.create((theme) => ({ const prBadgeTextHoveredCombined = [prBadgeStyles.text, prBadgeStyles.textHovered]; -function ChecksBadge({ checks }: { checks: PrHint["checks"] }): ReactElement | null { - if (!checks || checks.length === 0) return null; - - const failed = checks.filter((c) => c.status === "failure").length; - if (failed === 0) return null; - - const label = `${failed} failed`; - - return ( - - - {label} - - ); -} - -const checksBadgeStyles = StyleSheet.create((theme) => ({ - badge: { - flexDirection: "row", - alignItems: "center", - gap: 2, - }, - text: { - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.normal, - lineHeight: 14, - color: theme.colors.palette.red[500], - }, -})); - -function WorkspaceStatusIndicator({ - bucket, - workspaceKind, - loading = false, -}: { - bucket: SidebarWorkspaceEntry["statusBucket"]; - workspaceKind: SidebarWorkspaceEntry["workspaceKind"]; - loading?: boolean; -}) { - const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket }); - - if (loading) { - return ( - - - - ); - } - - if (shouldShowSyncedLoader) { - return ( - - - - ); - } - - if (bucket === "needs_input") { - return ( - - - - ); - } - - let KindIcon: typeof ThemedMonitor | null; - if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor; - else if (workspaceKind === "worktree") KindIcon = ThemedFolderGit2; - else KindIcon = null; - if (!KindIcon) return null; - - const dotColorStyle = getStatusDotColorStyle(bucket); - const statusDotSize = isEmphasizedStatusDotBucket(bucket) - ? 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 StatusDotOverlay({ dotColorStyle, size, @@ -620,6 +551,9 @@ function ProjectRowTrailingActions({ const trash2LeadingIcon = ; const settingsLeadingIcon = ; const copyLeadingIcon = ; +const markAsReadLeadingIcon = ( + +); const archiveLeadingIcon = ; const renameLeadingIcon = ; @@ -695,6 +629,7 @@ function WorkspaceRowRightGroup({ archivePendingLabel, archiveShortcutKeys, onArchive, + onMarkAsRead, onCopyBranchName, onCopyPath, onRename, @@ -712,13 +647,14 @@ function WorkspaceRowRightGroup({ archivePendingLabel?: string; archiveShortcutKeys?: ShortcutKey[][] | null; onArchive?: () => void; + onMarkAsRead?: () => void; onCopyBranchName?: () => void; onCopyPath?: () => void; onRename?: () => void; }) { const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform)); return ( - + <> {showScriptsIcon ? ( {hasRunningService ? ( @@ -735,6 +671,7 @@ function WorkspaceRowRightGroup({ onCopyPath={onCopyPath} onCopyBranchName={onCopyBranchName} onRename={onRename} + onMarkAsRead={onMarkAsRead} onArchive={onArchive} archiveLabel={archiveLabel} archiveStatus={archiveStatus} @@ -749,11 +686,11 @@ function WorkspaceRowRightGroup({ /> ) : null} {showShortcutBadge && shortcutNumber !== null ? ( - - {shortcutNumber} + + {shortcutNumber} ) : null} - + ); } @@ -762,6 +699,7 @@ function WorkspaceKebabMenu({ onCopyPath, onCopyBranchName, onRename, + onMarkAsRead, onArchive, archiveLabel, archiveStatus, @@ -772,6 +710,7 @@ function WorkspaceKebabMenu({ onCopyPath?: () => void; onCopyBranchName?: () => void; onRename?: () => void; + onMarkAsRead?: () => void; onArchive: () => void; archiveLabel?: string; archiveStatus?: "idle" | "pending" | "success"; @@ -821,6 +760,15 @@ function WorkspaceKebabMenu({ Rename workspace ) : null} + {onMarkAsRead ? ( + + Mark as read + + ) : null} setIsHovered(true), []); - const handlePointerLeave = useCallback(() => setIsHovered(false), []); - - const workspaceRowStyle = useCallback( - ({ pressed }: PressableStateCallbackType) => [ - styles.workspaceRow, - isDragging && styles.workspaceRowDragging, - selected && styles.sidebarRowSelected, - isHovered && styles.workspaceRowHovered, - pressed && styles.workspaceRowPressed, - ], - [isDragging, selected, isHovered], - ); - - const isDesktop = !isTouchPlatform; - const showScriptsIcon = isDesktop && workspace.hasRunningScripts; - const hasRunningService = workspace.scripts.some( - (s) => s.lifecycle === "running" && (s.type ?? "service") === "service", - ); - const accessibilityState = useMemo(() => ({ selected }), [selected]); - const workspaceBranchTextStyle = useMemo( - () => [ - styles.workspaceBranchText, - isHovered && styles.workspaceBranchTextHovered, - isCreating && styles.workspaceBranchTextCreating, - ], - [isHovered, isCreating], - ); + return ( - - } - style={styles.workspaceRowContainer} - onPointerEnter={handlePointerEnter} - onPointerLeave={handlePointerLeave} - > - - - - - - {workspace.name} - - - + + {({ isHovered, hoverHandlers }) => { + const isDesktop = !isTouchPlatform; + const showScriptsIcon = isDesktop && workspace.hasRunningScripts; + const hasRunningService = workspace.scripts.some( + (s) => s.lifecycle === "running" && (s.type ?? "service") === "service", + ); + const workspaceRowStyle = getProjectWorkspaceRowStyle({ + isDragging, + selected, + isHovered, + }); + return ( + } + style={styles.workspaceRowContainer} + {...hoverHandlers} + > + + + + + - {prHint ? ( - - - - - ) : null} - - - + ); + }} + ); } @@ -1722,6 +1643,15 @@ function WorkspaceRowWithMenu({ }, []); const archiveShortcutKeys = useShortcutKeys("archive-worktree"); + const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({ + serverId: workspace.serverId, + workspaceId: workspace.workspaceId, + }); + const handleMarkAsRead = useCallback(() => { + void clearAttention().catch((error) => { + toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read"); + }); + }, [clearAttention, toast]); useKeyboardActionHandler({ handlerId: `worktree-archive-${workspace.workspaceKey}`, @@ -1759,6 +1689,7 @@ function WorkspaceRowWithMenu({ onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined} onCopyPath={handleCopyPath} onRename={canCopyBranchName ? handleOpenRename : undefined} + onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined} archiveShortcutKeys={selected ? archiveShortcutKeys : null} /> + ); + } + + return ( + + ); +} + +function SidebarStatusModeWrapper({ + serverId, + shortcutIndexByWorkspaceKey: _projectShortcutIndex, + onWorkspacePress, +}: { + serverId: string | null; + shortcutIndexByWorkspaceKey: Map; + onWorkspacePress?: () => void; +}) { + const hydratedWorkspaces = useHydratedWorkspaceEntries(serverId); + const projectNamesByKey = useProjectNamesMap(serverId); + const showShortcutBadges = useShowShortcutBadges(); + + return ( + + ); +} + +function ProjectModeList({ + projects, + serverId, + collapsedProjectKeys, + onToggleProjectCollapsed, + shortcutIndexByWorkspaceKey, + onWorkspacePress, + onAddProject, + listFooterComponent, + parentGestureRef, + pathname, +}: Omit & { + pathname: string; +}) { const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState>(() => new Set()); const creatingWorkspaceTimeoutsRef = useRef>>( new Map(), diff --git a/packages/app/src/components/sidebar/sidebar-grouping-selector.tsx b/packages/app/src/components/sidebar/sidebar-grouping-selector.tsx new file mode 100644 index 000000000..216a01109 --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-grouping-selector.tsx @@ -0,0 +1,113 @@ +import { useCallback } from "react"; +import { Text, View, type PressableStateCallbackType } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { Settings2 } from "lucide-react-native"; +import type { Theme } from "@/styles/theme"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from "@/components/ui/dropdown-menu"; +import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store"; +import { isWeb as platformIsWeb } from "@/constants/platform"; + +const ThemedSettings2 = withUnistyles(Settings2); +const filterColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +const GROUP_MODE_ITEMS: Array<{ value: SidebarGroupMode; label: string }> = [ + { value: "project", label: "Project" }, + { value: "status", label: "Status" }, +]; + +export function SidebarGroupingSelector({ serverId }: { serverId: string | null }) { + const groupMode = useSidebarViewStore((state) => + serverId ? state.getGroupMode(serverId) : "project", + ); + const setGroupMode = useSidebarViewStore((state) => state.setGroupMode); + + const handleSelect = useCallback( + (mode: SidebarGroupMode) => { + if (!serverId) return; + setGroupMode(serverId, mode); + }, + [serverId, setGroupMode], + ); + + const triggerStyle = useCallback( + ({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.trigger, + hovered && styles.triggerHovered, + ], + [], + ); + + return ( + + + + + + + Group by + + {GROUP_MODE_ITEMS.map((item) => ( + + ))} + + + ); +} + +function GroupModeMenuItem({ + item, + isSelected, + onSelect, +}: { + item: { value: SidebarGroupMode; label: string }; + isSelected: boolean; + onSelect: (mode: SidebarGroupMode) => void; +}) { + const handleSelect = useCallback(() => onSelect(item.value), [item.value, onSelect]); + return ( + + {item.label} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + trigger: { + width: 28, + height: 28, + alignItems: "center", + justifyContent: "center", + borderRadius: theme.borderRadius.md, + }, + triggerHovered: { + backgroundColor: theme.colors.surfaceSidebarHover, + }, + menuHeader: { + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + }, + menuHeaderLabel: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.medium, + color: theme.colors.foregroundMuted, + }, +})); diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx new file mode 100644 index 000000000..8fabec4bd --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -0,0 +1,888 @@ +import { memo, useCallback, useMemo, useState } from "react"; +import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native"; +import { NestableScrollContainer } from "react-native-draggable-flatlist"; +import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; +import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; +import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; +import { + buildStatusGroups, + buildStatusShortcutIndex, + type StatusGroup, +} from "@/hooks/sidebar-status-view-model"; +import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform"; +import { StyleSheet } from "react-native-unistyles"; +import type { Theme } from "@/styles/theme"; +import { withUnistyles } from "react-native-unistyles"; +import { + ChevronDown, + ChevronRight, + CircleAlert, + CircleCheck, + CircleDot, + CircleX, + MoreVertical, + SquareTerminal, + Globe, + Copy, + Archive, + Pencil, +} from "lucide-react-native"; +import { DiffStat } from "@/components/diff-stat"; +import { useSidebarWorkspaceEntry } from "../sidebar-workspace-list"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from "@/components/ui/dropdown-menu"; +import { useToast } from "@/contexts/toast-context"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys"; +import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@getpaseo/protocol/branch-slug"; +import { AdaptiveRenameModal } from "@/components/rename-modal"; +import { + requireWorkspaceExecutionDirectory, + resolveWorkspaceExecutionDirectory, +} from "@/utils/workspace-execution"; +import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; +import { archiveWorkspaceOptimistically } from "@/workspace/workspace-archive"; +import { useCheckoutGitActionsStore } from "@/git/actions-store"; +import { confirmRiskyWorktreeArchive } from "@/git/worktree-archive-warning"; +import { confirmDialog } from "@/utils/confirm-dialog"; +import * as Clipboard from "expo-clipboard"; +import { Shortcut } from "@/components/ui/shortcut"; +import type { ShortcutKey } from "@/utils/format-shortcut"; +import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; +import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; +import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention"; +import { + SidebarWorkspaceRowFrame, + SidebarWorkspaceRowContent, + sidebarWorkspaceRowStyles, +} from "@/components/sidebar/sidebar-workspace-row-content"; +import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; + +// Themed icon wrappers +const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); +const foregroundMutedColorMapping = (theme: Theme) => ({ + color: theme.colors.foregroundMuted, +}); +const blueColorMapping = (theme: Theme) => ({ color: theme.colors.palette.blue[500] }); +const amberColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] }); +const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] }); +const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] }); + +const ThemedChevronDown = withUnistyles(ChevronDown); +const ThemedChevronRight = withUnistyles(ChevronRight); +const ThemedCircleAlert = withUnistyles(CircleAlert); +const ThemedCircleCheck = withUnistyles(CircleCheck); +const ThemedCircleDot = withUnistyles(CircleDot); +const ThemedCircleX = withUnistyles(CircleX); +const ThemedGlobe = withUnistyles(Globe); +const ThemedSquareTerminal = withUnistyles(SquareTerminal); +const ThemedMoreVertical = withUnistyles(MoreVertical); +const ThemedCopy = withUnistyles(Copy); +const ThemedArchive = withUnistyles(Archive); +const ThemedPencil = withUnistyles(Pencil); + +const copyLeadingIcon = ; +const markAsReadLeadingIcon = ( + +); +const archiveLeadingIcon = ; +const renameLeadingIcon = ; + +interface StatusWorkspaceListProps { + workspaces: SidebarWorkspaceEntry[]; + projectNamesByKey: Map; + serverId: string | null; + shortcutIndexByWorkspaceKey: Map; + showShortcutBadges: boolean; + onWorkspacePress?: () => void; +} + +export function SidebarStatusWorkspaceList({ + workspaces, + projectNamesByKey, + serverId, + shortcutIndexByWorkspaceKey: _projectShortcutIndex, + showShortcutBadges, + onWorkspacePress, +}: StatusWorkspaceListProps) { + const groups = useMemo( + () => buildStatusGroups(workspaces, projectNamesByKey), + [workspaces, projectNamesByKey], + ); + const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore( + (state) => state.collapsedStatusGroupKeys, + ); + + const statusShortcutIndex = useMemo( + () => + showShortcutBadges + ? buildStatusShortcutIndex( + groups.filter((group) => !collapsedStatusGroupKeys.has(group.bucket)), + ) + : new Map(), + [collapsedStatusGroupKeys, groups, showShortcutBadges], + ); + + return ( + + {platformIsNative ? ( + + + + ) : ( + + + + )} + + ); +} + +function StatusGroupList({ + groups, + collapsedStatusGroupKeys, + projectNamesByKey, + serverId, + shortcutIndex, + showShortcutBadges, + onWorkspacePress, +}: { + groups: StatusGroup[]; + collapsedStatusGroupKeys: ReadonlySet; + projectNamesByKey: Map; + serverId: string | null; + shortcutIndex: Map; + showShortcutBadges: boolean; + onWorkspacePress?: () => void; +}) { + return ( + <> + {groups.map((group) => ( + + + {!collapsedStatusGroupKeys.has(group.bucket) ? ( + + {group.rows.map((workspace) => ( + + ))} + + ) : null} + + ))} + + ); +} + +function StatusGroupHeader({ group, collapsed }: { group: StatusGroup; collapsed: boolean }) { + const [isHovered, setIsHovered] = useState(false); + const toggleStatusGroupCollapsed = useSidebarCollapsedSectionsStore( + (state) => state.toggleStatusGroupCollapsed, + ); + const handlePress = useCallback(() => { + toggleStatusGroupCollapsed(group.bucket); + }, [group.bucket, toggleStatusGroupCollapsed]); + const handleHoverIn = useCallback(() => setIsHovered(true), []); + const handleHoverOut = useCallback(() => setIsHovered(false), []); + const rowStyle = useCallback( + ({ pressed }: PressableStateCallbackType) => [ + styles.statusGroupRow, + isHovered && styles.statusGroupRowHovered, + pressed && styles.statusGroupRowPressed, + ], + [isHovered], + ); + const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]); + + return ( + + + + + + + + + {group.label} + + + + + + ); +} + +function StatusGroupLeadingVisual({ + bucket, + collapsed, + showChevron, +}: { + bucket: StatusGroup["bucket"]; + collapsed: boolean; + showChevron: boolean; +}) { + if (!showChevron) { + return ; + } + if (collapsed) { + return ; + } + return ; +} + +function StatusGroupIcon({ bucket }: { bucket: StatusGroup["bucket"] }) { + switch (bucket) { + case "needs_input": + return ; + case "failed": + return ; + case "attention": + return ; + case "running": + return ; + case "done": + return ; + } +} + +const StatusWorkspaceRow = memo(function StatusWorkspaceRow({ + workspace, + projectName, + serverId, + shortcutNumber, + showShortcutBadge, + onWorkspacePress, +}: { + workspace: SidebarWorkspaceEntry; + projectName: string; + serverId: string | null; + shortcutNumber: number | null; + showShortcutBadge: boolean; + onWorkspacePress?: () => void; +}) { + const hydratedWorkspace = useSidebarWorkspaceEntry(serverId, workspace.workspaceId); + const activeWorkspaceSelection = useActiveWorkspaceSelection(); + const selected = + activeWorkspaceSelection?.serverId === workspace.serverId && + activeWorkspaceSelection?.workspaceId === workspace.workspaceId; + + const handlePress = useCallback(() => { + if (!serverId) return; + onWorkspacePress?.(); + navigateToWorkspace(serverId, workspace.workspaceId); + }, [serverId, onWorkspacePress, workspace.workspaceId]); + + if (!hydratedWorkspace) return null; + + return ( + + ); +}); + +function StatusWorkspaceRowWithMenu({ + workspace, + projectName, + selected, + shortcutNumber, + showShortcutBadge, + onPress, +}: { + workspace: SidebarWorkspaceEntry; + projectName: string; + selected: boolean; + shortcutNumber: number | null; + showShortcutBadge: boolean; + onPress: () => void; +}) { + const toast = useToast(); + const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree); + const queryClient = useQueryClient(); + const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false); + const [isRenameOpen, setIsRenameOpen] = useState(false); + const workspaceDirectory = resolveWorkspaceExecutionDirectory({ + workspaceDirectory: workspace.workspaceDirectory, + }); + const archiveStatus = useCheckoutGitActionsStore((state) => + workspaceDirectory + ? state.getStatus({ + serverId: workspace.serverId, + cwd: workspaceDirectory, + actionId: "archive-worktree", + }) + : "idle", + ); + const isWorktree = workspace.workspaceKind === "worktree"; + const isArchiving = isWorktree ? workspace.archivingAt !== null : isArchivingWorkspace; + + const redirectAfterArchive = useCallback(() => { + redirectIfArchivingActiveWorkspace({ + serverId: workspace.serverId, + workspaceId: workspace.workspaceId, + activeWorkspaceSelection: selected + ? { serverId: workspace.serverId, workspaceId: workspace.workspaceId } + : null, + }); + }, [selected, workspace]); + + const archiveWorktreeAfterConfirmation = useCallback(async () => { + if (isArchiving) return; + const confirmed = await confirmRiskyWorktreeArchive({ + worktreeName: workspace.name, + isDirty: workspace.archiveHasUncommittedChanges, + aheadOfOrigin: workspace.archiveUnpushedCommitCount, + diffStat: workspace.diffStat, + }); + if (!confirmed) return; + let archiveDirectory: string; + try { + archiveDirectory = requireWorkspaceExecutionDirectory({ + workspaceId: workspace.workspaceId, + workspaceDirectory: workspace.workspaceDirectory, + }); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Workspace path not available"); + return; + } + redirectAfterArchive(); + void archiveWorktree({ + serverId: workspace.serverId, + cwd: archiveDirectory, + worktreePath: archiveDirectory, + }).catch((error) => { + toast.error(error instanceof Error ? error.message : "Failed to archive worktree"); + }); + }, [archiveWorktree, isArchiving, redirectAfterArchive, toast, workspace]); + + const hideWorkspaceAfterConfirmation = useCallback(async () => { + if (isArchivingWorkspace) return; + const confirmed = await confirmDialog({ + title: "Hide workspace?", + message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`, + confirmLabel: "Hide", + cancelLabel: "Cancel", + destructive: true, + }); + if (!confirmed) return; + const client = getHostRuntimeStore().getClient(workspace.serverId); + if (!client) { + toast.error("Host is not connected"); + return; + } + setIsArchivingWorkspace(true); + try { + await archiveWorkspaceOptimistically({ + client, + workspace, + afterHide: redirectAfterArchive, + }); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to hide workspace"); + } finally { + setIsArchivingWorkspace(false); + } + }, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]); + + const handleCopyPath = useCallback(() => { + let copyTargetDirectory: string; + try { + copyTargetDirectory = requireWorkspaceExecutionDirectory({ + workspaceId: workspace.workspaceId, + workspaceDirectory: workspace.workspaceDirectory, + }); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Workspace path not available"); + return; + } + void Clipboard.setStringAsync(copyTargetDirectory); + toast.copied("Path copied"); + }, [toast, workspace.workspaceDirectory, workspace.workspaceId]); + + const handleCopyBranchName = useCallback(() => { + void Clipboard.setStringAsync(workspace.name); + toast.copied("Branch name copied"); + }, [toast, workspace.name]); + + const renameMutation = useMutation({ + mutationFn: async (branch: string) => { + const client = getHostRuntimeStore().getClient(workspace.serverId); + if (!client) throw new Error("Host is not connected"); + const targetCwd = requireWorkspaceExecutionDirectory({ + workspaceId: workspace.workspaceId, + workspaceDirectory: workspace.workspaceDirectory, + }); + const payload = await client.renameBranch({ cwd: targetCwd, branch }); + if (!payload.success || payload.error) { + throw new Error(payload.error?.message ?? "Failed to rename branch"); + } + return { targetCwd }; + }, + onSuccess: async ({ targetCwd }) => { + await invalidateCheckoutGitQueriesForClient(queryClient, { + serverId: workspace.serverId, + cwd: targetCwd, + }); + }, + }); + + const handleOpenRename = useCallback(() => setIsRenameOpen(true), []); + const handleCloseRename = useCallback(() => setIsRenameOpen(false), []); + const handleSubmitRename = useCallback( + async (value: string) => { + await renameMutation.mutateAsync(slugify(value)); + }, + [renameMutation], + ); + const validateRenameSlug = useCallback((value: string): string | null => { + const result = validateBranchSlug(slugify(value)); + if (result.valid) return null; + return result.error ?? "Invalid branch name"; + }, []); + + const archiveShortcutKeys = useShortcutKeys("archive-worktree"); + const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({ + serverId: workspace.serverId, + workspaceId: workspace.workspaceId, + }); + const handleMarkAsRead = useCallback(() => { + void clearAttention().catch((error) => { + toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read"); + }); + }, [clearAttention, toast]); + + useKeyboardActionHandler({ + handlerId: `worktree-archive-${workspace.workspaceKey}`, + actions: ["worktree.archive"], + enabled: selected && !isArchiving, + priority: 0, + handle: () => { + if (isWorktree) { + void archiveWorktreeAfterConfirmation(); + } else { + void hideWorkspaceAfterConfirmation(); + } + return true; + }, + }); + + let computedArchiveStatus: "idle" | "pending" | "success" = "idle"; + if (isWorktree) { + computedArchiveStatus = archiveStatus; + } else if (isArchivingWorkspace) { + computedArchiveStatus = "pending"; + } + + return ( + <> + + + + ); +} + +function StatusWorkspaceRowInner({ + workspace, + projectName, + selected, + shortcutNumber, + showShortcutBadge, + onPress, + isArchiving, + archiveLabel, + archiveStatus = "idle", + archivePendingLabel, + onArchive, + onCopyBranchName, + onCopyPath, + onRename, + onMarkAsRead, + archiveShortcutKeys, +}: { + workspace: SidebarWorkspaceEntry; + projectName: string; + selected: boolean; + shortcutNumber: number | null; + showShortcutBadge: boolean; + onPress: () => void; + isArchiving: boolean; + archiveLabel?: string; + archiveStatus?: "idle" | "pending" | "success"; + archivePendingLabel?: string; + onArchive?: () => void; + onCopyBranchName?: () => void; + onCopyPath?: () => void; + onRename?: () => void; + onMarkAsRead?: () => void; + archiveShortcutKeys?: ShortcutKey[][] | null; +}) { + const isTouchPlatform = platformIsNative; + + const isDesktop = !isTouchPlatform; + const showScriptsIcon = isDesktop && workspace.hasRunningScripts; + const hasRunningService = workspace.scripts.some( + (s) => s.lifecycle === "running" && (s.type ?? "service") === "service", + ); + + const accessibilityState = useMemo(() => ({ selected }), [selected]); + + return ( + + {({ isHovered, hoverHandlers }) => { + const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform)); + const workspaceRowStyle = getStatusWorkspaceRowStyle({ selected, isHovered }); + return ( + + + + <> + {showScriptsIcon ? ( + + {hasRunningService ? ( + + ) : ( + + )} + + ) : null} + {showKebab && onArchive ? ( + + ) : null} + {!showKebab && workspace.diffStat ? ( + + ) : null} + {showShortcutBadge && shortcutNumber !== null ? ( + + + {shortcutNumber} + + + ) : null} + + + + + ); + }} + + ); +} + +function StatusKebabMenu({ + workspaceKey, + onCopyPath, + onCopyBranchName, + onRename, + onMarkAsRead, + onArchive, + archiveLabel, + archiveStatus, + archivePendingLabel, + archiveShortcutKeys, +}: { + workspaceKey: string; + onCopyPath?: () => void; + onCopyBranchName?: () => void; + onRename?: () => void; + onMarkAsRead?: () => void; + onArchive: () => void; + archiveLabel?: string; + archiveStatus?: "idle" | "pending" | "success"; + archivePendingLabel?: string; + archiveShortcutKeys?: ShortcutKey[][] | null; +}) { + const archiveTrailing = useMemo( + () => (archiveShortcutKeys ? : null), + [archiveShortcutKeys], + ); + return ( + + + {({ hovered }: { hovered?: boolean }) => ( + + )} + + + {onCopyPath ? ( + + Copy path + + ) : null} + {onCopyBranchName ? ( + + Copy branch name + + ) : null} + {onRename ? ( + + Rename workspace + + ) : null} + {onMarkAsRead ? ( + + Mark as read + + ) : null} + + {archiveLabel ?? "Archive"} + + + + ); +} + +function kebabStyle({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) { + return [styles.kebabButton, hovered && styles.kebabButtonHovered]; +} + +function getStatusWorkspaceRowStyle({ + selected, + isHovered, +}: { + selected: boolean; + isHovered: boolean; +}) { + return [ + styles.workspaceRow, + selected && styles.sidebarRowSelected, + isHovered && styles.workspaceRowHovered, + ]; +} + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + }, + list: { + flex: 1, + }, + listContent: { + paddingHorizontal: theme.spacing[2], + paddingTop: theme.spacing[2], + paddingBottom: theme.spacing[4], + }, + statusGroupBlock: { + marginBottom: theme.spacing[1], + }, + statusWorkspaceListContainer: {}, + statusGroupRow: { + minHeight: 36, + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[2], + borderRadius: theme.borderRadius.lg, + marginBottom: theme.spacing[2], + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[2], + userSelect: "none", + }, + statusGroupRowHovered: { + backgroundColor: theme.colors.surfaceSidebarHover, + }, + statusGroupRowPressed: { + backgroundColor: theme.colors.surface2, + }, + statusGroupRowLeft: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + flex: 1, + minWidth: 0, + }, + statusGroupLeadingVisualSlot: { + position: "relative", + width: theme.iconSize.md, + height: theme.iconSize.md, + flexShrink: 0, + alignItems: "center", + justifyContent: "center", + }, + statusGroupTitleGroup: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + flex: 1, + minWidth: 0, + }, + statusGroupTitle: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: "400", + minWidth: 0, + flexShrink: 1, + }, + workspaceRowContainer: { + position: "relative", + }, + workspaceRow: { + minHeight: 36, + marginBottom: theme.spacing[1], + paddingVertical: theme.spacing[2], + paddingLeft: theme.spacing[3] + theme.spacing[3], + paddingRight: theme.spacing[3], + borderRadius: theme.borderRadius.lg, + flexDirection: "column", + alignItems: "stretch", + justifyContent: "flex-start", + gap: theme.spacing[1], + userSelect: "none", + }, + workspaceRowHovered: { + backgroundColor: theme.colors.surfaceSidebarHover, + }, + workspaceRowPressed: { + backgroundColor: theme.colors.surface2, + }, + sidebarRowSelected: { + backgroundColor: theme.colors.surfaceSidebarHover, + }, + kebabButton: { + padding: 2, + borderRadius: 4, + marginLeft: 2, + }, + kebabButtonHovered: { + backgroundColor: theme.colors.surface2, + }, +})); diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx new file mode 100644 index 000000000..7a8112ae7 --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx @@ -0,0 +1,446 @@ +import { memo, useCallback, useMemo, useState, type ReactNode } from "react"; +import { + ActivityIndicator, + Pressable, + Text, + View, + type GestureResponderEvent, + type ViewStyle, +} from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { + CircleAlert, + ExternalLink, + Folder, + FolderGit2, + GitPullRequest, + Monitor, +} from "lucide-react-native"; +import { GitHubIcon } from "@/components/icons/github-icon"; +import { WorkspaceHoverCard } from "@/components/workspace-hover-card"; +import { SyncedLoader } from "@/components/synced-loader"; +import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; +import type { Theme } from "@/styles/theme"; +import type { PrHint } from "@/git/use-pr-status-query"; +import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; +import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color"; +import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader"; +import { openExternalUrl } from "@/utils/open-external-url"; + +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 foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); +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], +}); +const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] }); +const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] }); +const purpleColorMapping = (theme: Theme) => ({ color: theme.colors.palette.purple[500] }); + +const ThemedExternalLink = withUnistyles(ExternalLink); +const ThemedGitPullRequest = withUnistyles(GitPullRequest); +const ThemedGitHubIcon = withUnistyles(GitHubIcon); +const ThemedActivityIndicator = withUnistyles(ActivityIndicator); +const ThemedCircleAlert = withUnistyles(CircleAlert); +const ThemedSyncedLoader = withUnistyles(SyncedLoader); +const ThemedMonitor = withUnistyles(Monitor); +const ThemedFolder = withUnistyles(Folder); +const ThemedFolderGit2 = withUnistyles(FolderGit2); + +export function SidebarWorkspaceRowFrame({ + workspace, + isDragging = false, + children, +}: { + workspace: SidebarWorkspaceEntry; + isDragging?: boolean; + children: (input: { + isHovered: boolean; + hoverHandlers: { onPointerEnter: () => void; onPointerLeave: () => void }; + }) => ReactNode; +}) { + const [isHovered, setIsHovered] = useState(false); + const handlePointerEnter = useCallback(() => setIsHovered(true), []); + const handlePointerLeave = useCallback(() => setIsHovered(false), []); + const hoverHandlers = useMemo( + () => ({ onPointerEnter: handlePointerEnter, onPointerLeave: handlePointerLeave }), + [handlePointerEnter, handlePointerLeave], + ); + + return ( + + {children({ isHovered, hoverHandlers })} + + ); +} + +export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowContent({ + workspace, + subtitle, + isHovered, + isLoading, + isCreating = false, + children, +}: { + workspace: SidebarWorkspaceEntry; + subtitle?: string | null; + isHovered: boolean; + isLoading: boolean; + isCreating?: boolean; + children?: ReactNode; +}) { + const workspaceBranchTextStyle = useMemo( + () => [ + styles.workspaceBranchText, + isHovered && styles.workspaceBranchTextHovered, + isCreating && styles.workspaceBranchTextCreating, + ], + [isHovered, isCreating], + ); + + return ( + <> + + + + + {workspace.name} + + + {children} + + {subtitle ? ( + + {subtitle} + + ) : null} + {workspace.prHint ? ( + + + + + ) : null} + + ); +}); + +function WorkspaceStatusIndicator({ + bucket, + workspaceKind, + loading = false, +}: { + bucket: SidebarWorkspaceEntry["statusBucket"]; + workspaceKind: SidebarWorkspaceEntry["workspaceKind"]; + loading?: boolean; +}) { + const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket }); + + if (loading) { + return ( + + + + ); + } + + if (shouldShowSyncedLoader) { + return ( + + + + ); + } + + if (bucket === "needs_input") { + return ( + + + + ); + } + + let KindIcon: typeof ThemedMonitor; + if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor; + else if (workspaceKind === "worktree") KindIcon = ThemedFolderGit2; + else KindIcon = ThemedFolder; + + const dotColorStyle = getStatusDotColorStyle(bucket); + const statusDotSize = isEmphasizedStatusDotBucket(bucket) + ? 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 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, offset, size], + ); + return ; +} + +function PrBadge({ hint }: { hint: PrHint }) { + const [isHovered, setIsHovered] = useState(false); + const handlePress = useCallback( + (event: GestureResponderEvent) => { + event.stopPropagation(); + void openExternalUrl(hint.url); + }, + [hint.url], + ); + const textStyle = useMemo( + () => (isHovered ? [prBadgeStyles.text, prBadgeStyles.textHovered] : prBadgeStyles.text), + [isHovered], + ); + const iconUniProps = isHovered ? foregroundColorMapping : getPrIconUniMapping(hint.state); + + const handlePressIn = useCallback((event: GestureResponderEvent) => event.stopPropagation(), []); + const handleHoverIn = useCallback(() => setIsHovered(true), []); + const handleHoverOut = useCallback(() => setIsHovered(false), []); + + const pressableStyle = useMemo( + () => [prBadgeStyles.badge, isHovered && prBadgeStyles.badgePressed], + [isHovered], + ); + + return ( + + {isHovered ? ( + + ) : ( + + )} + + #{hint.number} + + + ); +} + +function ChecksBadge({ checks }: { checks: PrHint["checks"] }) { + if (!checks || checks.length === 0) return null; + const failed = checks.filter((check) => check.status === "failure").length; + if (failed === 0) return null; + return ( + + + {failed} failed + + ); +} + +function getPrIconUniMapping(state: PrHint["state"]) { + switch (state) { + case "merged": + return purpleColorMapping; + case "open": + return greenColorMapping; + case "closed": + return redColorMapping; + } +} + +function getStatusDotColorStyle(bucket: SidebarStateBucket) { + 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; + } +} + +const prBadgeStyles = StyleSheet.create((theme) => ({ + badge: { + flexDirection: "row", + alignItems: "center", + gap: 2, + }, + badgePressed: { + opacity: 0.82, + }, + text: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + lineHeight: 14, + color: theme.colors.foregroundMuted, + }, + textHovered: { + color: theme.colors.foreground, + }, +})); + +const checksBadgeStyles = StyleSheet.create((theme) => ({ + badge: { + flexDirection: "row", + alignItems: "center", + gap: 2, + }, + text: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + lineHeight: 14, + color: theme.colors.palette.red[500], + }, +})); + +export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({ + rowRight: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + flexShrink: 0, + }, + shortcutBadge: { + minWidth: 18, + height: 18, + paddingHorizontal: theme.spacing[1], + alignItems: "center", + justifyContent: "center", + borderRadius: theme.borderRadius.sm, + borderWidth: 1, + borderColor: theme.colors.surface2, + backgroundColor: theme.colors.surface0, + flexShrink: 0, + }, + shortcutBadgeText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.medium, + lineHeight: 14, + }, +})); + +const styles = StyleSheet.create((theme) => ({ + workspaceRowMain: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[2], + width: "100%", + }, + workspaceRowLeft: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + flex: 1, + minWidth: 0, + }, + workspaceRowRight: sidebarWorkspaceRowStyles.rowRight, + workspaceStatusDot: { + position: "relative", + width: WORKSPACE_STATUS_DOT_WIDTH, + height: 16, + borderRadius: theme.borderRadius.full, + flexShrink: 0, + alignItems: "center", + justifyContent: "center", + }, + statusDotOverlay: { + position: "absolute", + borderRadius: theme.borderRadius.full, + borderWidth: 1, + }, + workspaceBranchText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: "400", + lineHeight: 20, + opacity: 0.76, + flex: 1, + minWidth: 0, + }, + workspaceBranchTextCreating: { + opacity: 0.92, + }, + workspaceBranchTextHovered: { + opacity: 1, + }, + workspaceSubtitle: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + lineHeight: 14, + marginLeft: WORKSPACE_STATUS_DOT_WIDTH + theme.spacing[2], + }, + workspacePrBadgeRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingLeft: WORKSPACE_STATUS_DOT_WIDTH + theme.spacing[2], + }, + statusDotNeedsInput: { + backgroundColor: theme.colors.palette.amber[500], + borderColor: theme.colors.surface0, + }, + 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, + }, +})); diff --git a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx index 86e2ac3e8..2f7075d18 100644 --- a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx +++ b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx @@ -10,6 +10,7 @@ import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; +import { useSidebarViewStore } from "@/stores/sidebar-view-store"; import { WorkspaceShortcutTargetsSubscriber } from "./workspace-shortcut-targets-subscriber"; vi.hoisted(() => { @@ -20,18 +21,22 @@ function workspaceDescriptor(input: { id: string; name?: string; projectId?: string; + projectDisplayName?: string; + status?: WorkspaceDescriptor["status"]; + statusEnteredAt?: Date | null; }): WorkspaceDescriptor { return { id: input.id, projectId: input.projectId ?? "project-1", - projectDisplayName: "Project 1", + projectDisplayName: input.projectDisplayName ?? "Project 1", projectRootPath: "/repo/main", workspaceDirectory: `/repo/main/${input.id}`, projectKind: "git", workspaceKind: "worktree", name: input.name ?? input.id, - status: "done", + status: input.status ?? "done", archivingAt: null, + statusEnteredAt: input.statusEnteredAt ?? null, diffStat: null, scripts: [], }; @@ -56,6 +61,9 @@ describe("WorkspaceShortcutTargetsSubscriber", () => { projectOrderByServerId: {}, workspaceOrderByServerAndProject: {}, }); + useSidebarViewStore.setState({ + groupModeByServerId: {}, + }); act(() => { useSessionStore.getState().initializeSession("srv", null as unknown as DaemonClient); @@ -95,6 +103,72 @@ describe("WorkspaceShortcutTargetsSubscriber", () => { ]); }); + it("publishes status-mode shortcut targets in visual status order", async () => { + act(() => { + useSidebarViewStore.getState().setGroupMode("srv", "status"); + useSessionStore.getState().setWorkspaces( + "srv", + new Map([ + [ + "ws-done", + workspaceDescriptor({ + id: "ws-done", + name: "Done", + projectId: "project-1", + projectDisplayName: "Project 1", + status: "done", + statusEnteredAt: new Date("2026-01-01T00:00:00.000Z"), + }), + ], + [ + "ws-running-old", + workspaceDescriptor({ + id: "ws-running-old", + name: "Running old", + projectId: "project-2", + projectDisplayName: "Project 2", + status: "running", + statusEnteredAt: new Date("2026-02-01T00:00:00.000Z"), + }), + ], + [ + "ws-needs-input", + workspaceDescriptor({ + id: "ws-needs-input", + name: "Needs input", + projectId: "project-1", + projectDisplayName: "Project 1", + status: "needs_input", + statusEnteredAt: new Date("2026-01-15T00:00:00.000Z"), + }), + ], + [ + "ws-running-new", + workspaceDescriptor({ + id: "ws-running-new", + name: "Running new", + projectId: "project-2", + projectDisplayName: "Project 2", + status: "running", + statusEnteredAt: new Date("2026-03-01T00:00:00.000Z"), + }), + ], + ]), + ); + }); + + await act(async () => { + root?.render(); + }); + + expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([ + { serverId: "srv", workspaceId: "ws-needs-input" }, + { serverId: "srv", workspaceId: "ws-running-new" }, + { serverId: "srv", workspaceId: "ws-running-old" }, + { serverId: "srv", workspaceId: "ws-done" }, + ]); + }); + it("clears targets when disabled", async () => { await act(async () => { root?.render(); diff --git a/packages/app/src/components/workspace-shortcut-targets-subscriber.tsx b/packages/app/src/components/workspace-shortcut-targets-subscriber.tsx index be91cd606..f90e7940f 100644 --- a/packages/app/src/components/workspace-shortcut-targets-subscriber.tsx +++ b/packages/app/src/components/workspace-shortcut-targets-subscriber.tsx @@ -1,8 +1,16 @@ import { useEffect, useMemo } from "react"; +import { + useHydratedWorkspaceEntries, + useProjectNamesMap, +} from "@/hooks/use-status-mode-workspaces"; import { useSidebarWorkspacesList } from "@/hooks/use-sidebar-workspaces-list"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; -import { buildSidebarShortcutModel } from "@/utils/sidebar-shortcuts"; +import { useSidebarViewStore } from "@/stores/sidebar-view-store"; +import { + buildSidebarShortcutModel, + buildStatusSidebarShortcutModel, +} from "@/utils/sidebar-shortcuts"; export function WorkspaceShortcutTargetsSubscriber({ enabled, @@ -12,6 +20,11 @@ export function WorkspaceShortcutTargetsSubscriber({ serverId: string | null; }) { const { projects } = useSidebarWorkspacesList({ serverId, enabled }); + const statusWorkspaces = useHydratedWorkspaceEntries(enabled ? serverId : null); + const projectNamesByKey = useProjectNamesMap(enabled ? serverId : null); + const groupMode = useSidebarViewStore((state) => + enabled && serverId ? state.getGroupMode(serverId) : "project", + ); const collapsedProjectKeys = useSidebarCollapsedSectionsStore( (state) => state.collapsedProjectKeys, ); @@ -19,14 +32,19 @@ export function WorkspaceShortcutTargetsSubscriber({ (state) => state.setSidebarShortcutWorkspaceTargets, ); - const shortcutModel = useMemo( - () => - buildSidebarShortcutModel({ - projects, - collapsedProjectKeys, - }), - [collapsedProjectKeys, projects], - ); + const shortcutModel = useMemo(() => { + if (groupMode === "status") { + return buildStatusSidebarShortcutModel({ + workspaces: statusWorkspaces, + projectNamesByKey, + }); + } + + return buildSidebarShortcutModel({ + projects, + collapsedProjectKeys, + }); + }, [collapsedProjectKeys, groupMode, projectNamesByKey, projects, statusWorkspaces]); useEffect(() => { if (!enabled || !serverId) { diff --git a/packages/app/src/contexts/session-context.service-status.test.ts b/packages/app/src/contexts/session-context.service-status.test.ts index 4365d2c25..2c57c744c 100644 --- a/packages/app/src/contexts/session-context.service-status.test.ts +++ b/packages/app/src/contexts/session-context.service-status.test.ts @@ -19,6 +19,7 @@ function workspace(input: { name: "main", status: "running", archivingAt: null, + statusEnteredAt: null, diffStat: null, scripts: input.scripts ?? [], }; diff --git a/packages/app/src/contexts/session-workspace-upserts.test.ts b/packages/app/src/contexts/session-workspace-upserts.test.ts index a976ef840..c4e255581 100644 --- a/packages/app/src/contexts/session-workspace-upserts.test.ts +++ b/packages/app/src/contexts/session-workspace-upserts.test.ts @@ -18,6 +18,7 @@ const baseWorkspace: WorkspaceDescriptor = { name: "feature", status: "done", archivingAt: "2026-04-30T00:00:00.000Z", + statusEnteredAt: null, diffStat: null, scripts: [], }; diff --git a/packages/app/src/git/actions-store.test.ts b/packages/app/src/git/actions-store.test.ts index b42e01da4..f13cc4341 100644 --- a/packages/app/src/git/actions-store.test.ts +++ b/packages/app/src/git/actions-store.test.ts @@ -43,6 +43,7 @@ function workspace(input: Partial & Pick & { workspaceKey: string }, +): SidebarWorkspaceEntry { + return { + serverId: input.serverId ?? "srv", + workspaceId: input.workspaceId ?? input.workspaceKey.split(":")[1] ?? "ws", + projectKey: input.projectKey ?? "proj", + projectRootPath: input.projectRootPath, + workspaceDirectory: input.workspaceDirectory, + projectKind: input.projectKind ?? "git", + workspaceKind: input.workspaceKind ?? "worktree", + name: input.name ?? "main", + statusBucket: input.statusBucket ?? "done", + statusEnteredAt: input.statusEnteredAt ?? null, + archivingAt: null, + diffStat: null, + prHint: null, + archiveHasUncommittedChanges: null, + archiveUnpushedCommitCount: null, + scripts: [], + hasRunningScripts: false, + workspaceKey: input.workspaceKey, + }; +} + +function d(iso: string): Date { + return new Date(iso); +} + +const emptyProjectNames = new Map(); + +describe("buildStatusGroups", () => { + it("groups workspaces by status bucket in fixed order", () => { + const workspaces = [ + ws({ workspaceKey: "srv:done-ws", statusBucket: "done", name: "done-ws" }), + ws({ + workspaceKey: "srv:needs-input-ws", + statusBucket: "needs_input", + name: "needs-input-ws", + }), + ws({ workspaceKey: "srv:running-ws", statusBucket: "running", name: "running-ws" }), + ]; + + const groups = buildStatusGroups(workspaces, emptyProjectNames); + + expect(groups.map((g) => g.bucket)).toEqual(["needs_input", "running", "done"]); + expect(groups[0]?.label).toBe("Needs input"); + expect(groups[1]?.label).toBe("Working"); + expect(groups[2]?.label).toBe("Done"); + }); + + it("omits empty buckets", () => { + const workspaces = [ + ws({ workspaceKey: "srv:a", statusBucket: "done" }), + ws({ workspaceKey: "srv:b", statusBucket: "running" }), + ]; + + const groups = buildStatusGroups(workspaces, emptyProjectNames); + + expect(groups.map((g) => g.bucket)).toEqual(["running", "done"]); + }); + + it("sorts by statusEnteredAt desc within a bucket", () => { + const workspaces = [ + ws({ + workspaceKey: "srv:old", + statusBucket: "done", + statusEnteredAt: d("2026-01-01T00:00:00Z"), + }), + ws({ + workspaceKey: "srv:new", + statusBucket: "done", + statusEnteredAt: d("2026-06-01T00:00:00Z"), + }), + ws({ + workspaceKey: "srv:mid", + statusBucket: "done", + statusEnteredAt: d("2026-03-01T00:00:00Z"), + }), + ]; + + const groups = buildStatusGroups(workspaces, emptyProjectNames); + + expect(groups[0]?.rows.map((r) => r.workspaceKey)).toEqual(["srv:new", "srv:mid", "srv:old"]); + }); + + it("sorts null timestamps last within a bucket", () => { + const workspaces = [ + ws({ workspaceKey: "srv:null-a", statusBucket: "done", statusEnteredAt: null }), + ws({ + workspaceKey: "srv:ts", + statusBucket: "done", + statusEnteredAt: d("2026-01-01T00:00:00Z"), + }), + ws({ workspaceKey: "srv:null-b", statusBucket: "done", statusEnteredAt: null }), + ]; + + const groups = buildStatusGroups(workspaces, emptyProjectNames); + + expect(groups[0]?.rows.map((r) => r.workspaceKey)).toEqual([ + "srv:ts", + "srv:null-a", + "srv:null-b", + ]); + }); + + it("tie-breaks by project name, then workspace name, then workspaceKey", () => { + const projectNames = new Map([ + ["proj-b", "Beta"], + ["proj-a", "Alpha"], + ]); + + const workspaces = [ + ws({ workspaceKey: "srv:1", statusBucket: "done", projectKey: "proj-b", name: "zebra" }), + ws({ workspaceKey: "srv:2", statusBucket: "done", projectKey: "proj-a", name: "alpha" }), + ws({ workspaceKey: "srv:3", statusBucket: "done", projectKey: "proj-a", name: "alpha" }), + ]; + + const groups = buildStatusGroups(workspaces, projectNames); + + expect(groups[0]?.rows.map((r) => r.workspaceKey)).toEqual(["srv:2", "srv:3", "srv:1"]); + }); + + it("returns empty array for no workspaces", () => { + const groups = buildStatusGroups([], emptyProjectNames); + expect(groups).toEqual([]); + }); + + it("uses hydrated workspace entries with real status, not structural placeholders", () => { + const workspaces = [ + ws({ + workspaceKey: "srv:ni", + statusBucket: "needs_input", + statusEnteredAt: d("2026-01-01T00:00:00Z"), + }), + ws({ + workspaceKey: "srv:fail", + statusBucket: "failed", + statusEnteredAt: d("2026-01-01T00:00:00Z"), + }), + ws({ + workspaceKey: "srv:att", + statusBucket: "attention", + statusEnteredAt: d("2026-01-01T00:00:00Z"), + }), + ws({ + workspaceKey: "srv:run", + statusBucket: "running", + statusEnteredAt: d("2026-01-01T00:00:00Z"), + }), + ws({ workspaceKey: "srv:dn", statusBucket: "done", statusEnteredAt: null }), + ]; + + const groups = buildStatusGroups(workspaces, emptyProjectNames); + + expect(groups.map((g) => g.bucket)).toEqual(STATUS_BUCKET_ORDER); + expect(groups.map((g) => g.label)).toEqual( + STATUS_BUCKET_ORDER.map((b) => STATUS_BUCKET_LABELS[b]), + ); + // Each group has exactly one row with the matching bucket + for (const group of groups) { + expect(group.rows).toHaveLength(1); + expect(group.rows[0]?.statusBucket).toBe(group.bucket); + } + }); +}); + +describe("buildStatusShortcutIndex", () => { + it("assigns sequential numbers in status visual order", () => { + const groups: StatusGroup[] = [ + { bucket: "needs_input", label: "Needs input", rows: [ws({ workspaceKey: "srv:ni" })] }, + { + bucket: "running", + label: "Working", + rows: [ws({ workspaceKey: "srv:run" }), ws({ workspaceKey: "srv:run2" })], + }, + { bucket: "done", label: "Done", rows: [ws({ workspaceKey: "srv:dn" })] }, + ]; + + const index = buildStatusShortcutIndex(groups); + + expect(index.get("srv:ni")).toBe(1); + expect(index.get("srv:run")).toBe(2); + expect(index.get("srv:run2")).toBe(3); + expect(index.get("srv:dn")).toBe(4); + }); + + it("stops at 9 shortcuts", () => { + const rows = Array.from({ length: 12 }, (_, i) => ws({ workspaceKey: `srv:ws${i}` })); + const groups: StatusGroup[] = [{ bucket: "done", label: "Done", rows }]; + + const index = buildStatusShortcutIndex(groups); + + expect(index.size).toBe(9); + expect(index.has("srv:ws8")).toBe(true); + expect(index.has("srv:ws9")).toBe(false); + }); + + it("returns empty map for empty groups", () => { + const index = buildStatusShortcutIndex([]); + expect(index.size).toBe(0); + }); +}); diff --git a/packages/app/src/hooks/sidebar-status-view-model.ts b/packages/app/src/hooks/sidebar-status-view-model.ts new file mode 100644 index 000000000..f22539edb --- /dev/null +++ b/packages/app/src/hooks/sidebar-status-view-model.ts @@ -0,0 +1,94 @@ +import type { SidebarWorkspaceEntry } from "@/hooks/sidebar-workspaces-view-model"; + +export type StatusBucket = SidebarWorkspaceEntry["statusBucket"]; + +export const STATUS_BUCKET_ORDER: readonly StatusBucket[] = [ + "needs_input", + "failed", + "attention", + "running", + "done", +] as const; + +export const STATUS_BUCKET_LABELS: Record = { + needs_input: "Needs input", + failed: "Failed", + attention: "Ready to review", + running: "Working", + done: "Done", +}; + +export interface StatusGroup { + bucket: StatusBucket; + label: string; + rows: SidebarWorkspaceEntry[]; +} + +export function buildStatusGroups( + workspaces: SidebarWorkspaceEntry[], + projectNamesByKey: Map, +): StatusGroup[] { + const bucketRows = new Map(); + + for (const ws of workspaces) { + const bucket: StatusBucket = ws.statusBucket; + let rows = bucketRows.get(bucket); + if (!rows) { + rows = []; + bucketRows.set(bucket, rows); + } + rows.push(ws); + } + + const groups: StatusGroup[] = []; + + for (const bucket of STATUS_BUCKET_ORDER) { + const rows = bucketRows.get(bucket); + if (!rows || rows.length === 0) continue; + + rows.sort((a, b) => compareStatusRows(a, b, projectNamesByKey)); + groups.push({ bucket, label: STATUS_BUCKET_LABELS[bucket], rows }); + } + + return groups; +} + +function compareStatusRows( + a: SidebarWorkspaceEntry, + b: SidebarWorkspaceEntry, + projectNamesByKey: Map, +): number { + const aTime = a.statusEnteredAt?.getTime() ?? null; + const bTime = b.statusEnteredAt?.getTime() ?? null; + + if (aTime !== null && bTime !== null) { + if (aTime !== bTime) return bTime - aTime; + } else if (aTime !== null) { + return -1; + } else if (bTime !== null) { + return 1; + } + + const aProject = projectNamesByKey.get(a.projectKey) ?? ""; + const bProject = projectNamesByKey.get(b.projectKey) ?? ""; + const projectCmp = aProject.localeCompare(bProject); + if (projectCmp !== 0) return projectCmp; + + const nameCmp = a.name.localeCompare(b.name); + if (nameCmp !== 0) return nameCmp; + + return a.workspaceKey.localeCompare(b.workspaceKey); +} + +export function buildStatusShortcutIndex(groups: StatusGroup[]): Map { + const index = new Map(); + let shortcutNumber = 1; + for (const group of groups) { + for (const row of group.rows) { + if (shortcutNumber > 9) return index; + index.set(row.workspaceKey, shortcutNumber); + shortcutNumber += 1; + } + } + return index; +} diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts index 62f547958..6fe179c4a 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.ts @@ -17,6 +17,7 @@ export interface SidebarWorkspaceEntry { workspaceKind: WorkspaceDescriptor["workspaceKind"]; name: string; statusBucket: SidebarStateBucket; + statusEnteredAt: Date | null; archivingAt: string | null; diffStat: { additions: number; deletions: number } | null; prHint: PrHint | null; @@ -50,6 +51,7 @@ function createStructuralWorkspaceEntry(input: { workspaceKind: "checkout", name: input.workspaceId, statusBucket: "done", + statusEnteredAt: null, archivingAt: null, diffStat: null, prHint: null, diff --git a/packages/app/src/hooks/use-clear-workspace-attention.ts b/packages/app/src/hooks/use-clear-workspace-attention.ts new file mode 100644 index 000000000..3ec4ecb2d --- /dev/null +++ b/packages/app/src/hooks/use-clear-workspace-attention.ts @@ -0,0 +1,37 @@ +import { useCallback, useMemo } from "react"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { useSessionStore } from "@/stores/session-store"; + +export interface ClearWorkspaceAttentionController { + hasClearableAttention: boolean; + clearAttention: () => Promise; +} + +export function useClearWorkspaceAttention({ + serverId, + workspaceId, +}: { + serverId: string; + workspaceId: string; +}): ClearWorkspaceAttentionController { + const hasClearableAttention = useSessionStore((state) => { + const workspace = state.sessions[serverId]?.workspaces.get(workspaceId); + return workspace?.status === "attention" || workspace?.status === "failed"; + }); + + const clearAttention = useCallback(async () => { + if (!hasClearableAttention) { + return; + } + const client = getHostRuntimeStore().getClient(serverId); + if (!client) { + throw new Error("Host is not connected"); + } + await client.clearWorkspaceAttention(workspaceId); + }, [hasClearableAttention, serverId, workspaceId]); + + return useMemo( + () => ({ hasClearableAttention, clearAttention }), + [clearAttention, hasClearableAttention], + ); +} diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index f2bdc894b..ae0671c25 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -24,6 +24,7 @@ import { getDesktopHost, isElectronRuntime } from "@/desktop/host"; import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; import { useActiveServerId } from "@/hooks/use-active-server-id"; import { + type ActiveWorkspaceSelection, navigateToLastWorkspace, useActiveWorkspaceSelection, } from "@/stores/navigation-active-workspace-store"; @@ -56,6 +57,13 @@ export function useKeyboardShortcuts({ const activeServerId = useActiveServerId(); const openProjectPickerAction = useOpenProjectPicker(activeServerId); const activeWorkspaceSelection = useActiveWorkspaceSelection(); + const keyboardWorkspaceSelectionRef = useRef(null); + + useEffect(() => { + if (activeWorkspaceSelection) { + keyboardWorkspaceSelectionRef.current = activeWorkspaceSelection; + } + }, [activeWorkspaceSelection]); useEffect(() => { if (!enabled) return; @@ -95,6 +103,10 @@ export function useKeyboardShortcuts({ case "dispatch": return keyboardActionDispatcher.dispatch(action.action); case "navigate-workspace": + keyboardWorkspaceSelectionRef.current = { + serverId: action.serverId, + workspaceId: action.workspaceId, + }; navigateToWorkspace(action.serverId, action.workspaceId, { currentPathname: pathname }); return true; case "navigate-last-workspace": @@ -197,7 +209,8 @@ export function useKeyboardShortcuts({ pathname, isMobile, sidebarShortcutTargets: store.sidebarShortcutWorkspaceTargets, - navigationActiveWorkspace: activeWorkspaceSelection, + navigationActiveWorkspace: + keyboardWorkspaceSelectionRef.current ?? activeWorkspaceSelection, commandCenterOpen: store.commandCenterOpen, shortcutsDialogOpen: store.shortcutsDialogOpen, }, diff --git a/packages/app/src/hooks/use-open-project.test.ts b/packages/app/src/hooks/use-open-project.test.ts index b291dad7a..2b6e50e49 100644 --- a/packages/app/src/hooks/use-open-project.test.ts +++ b/packages/app/src/hooks/use-open-project.test.ts @@ -17,6 +17,7 @@ function buildWorkspacePayload() { name: "project", archivingAt: null, status: "done" as const, + statusEnteredAt: null, activityAt: null, diffStat: null, scripts: [], diff --git a/packages/app/src/hooks/use-projects.test.ts b/packages/app/src/hooks/use-projects.test.ts index 0a0d36385..658baad03 100644 --- a/packages/app/src/hooks/use-projects.test.ts +++ b/packages/app/src/hooks/use-projects.test.ts @@ -84,6 +84,7 @@ function workspace(input: { name: input.id, archivingAt: null, status: "done", + statusEnteredAt: null, activityAt: null, diffStat: null, scripts: [], diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index 5d6d0c4e1..0b469f380 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -46,6 +46,7 @@ export function createSidebarWorkspaceEntry(input: { workspaceKind: input.workspace.workspaceKind, name: input.workspace.name, statusBucket: input.workspace.status, + statusEnteredAt: input.workspace.statusEnteredAt, archivingAt: input.workspace.archivingAt, diffStat: input.workspace.diffStat, prHint: selectPrHintFromStatus(input.workspace.githubRuntime?.pullRequest), diff --git a/packages/app/src/hooks/use-status-mode-workspaces.ts b/packages/app/src/hooks/use-status-mode-workspaces.ts new file mode 100644 index 000000000..55cb1d1ab --- /dev/null +++ b/packages/app/src/hooks/use-status-mode-workspaces.ts @@ -0,0 +1,37 @@ +import { useMemo } from "react"; +import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; +import { + createSidebarWorkspaceEntry, + type SidebarWorkspaceEntry, +} from "./use-sidebar-workspaces-list"; + +export function useHydratedWorkspaceEntries(serverId: string | null): SidebarWorkspaceEntry[] { + const workspaces = useSessionStore((state) => + serverId ? state.sessions[serverId]?.workspaces : undefined, + ); + + return useMemo(() => { + if (!serverId || !workspaces || workspaces.size === 0) return []; + return Array.from(workspaces.values()).map((workspace: WorkspaceDescriptor) => + createSidebarWorkspaceEntry({ serverId, workspace }), + ); + }, [serverId, workspaces]); +} + +export function useProjectNamesMap(serverId: string | null): Map { + const workspaces = useSessionStore((state) => + serverId ? state.sessions[serverId]?.workspaces : undefined, + ); + + return useMemo(() => { + const map = new Map(); + if (!serverId || !workspaces) return map; + for (const workspace of workspaces.values()) { + const key = workspace.project?.projectKey ?? workspace.projectId; + if (!map.has(key)) { + map.set(key, workspace.projectCustomName ?? workspace.projectDisplayName); + } + } + return map; + }, [serverId, workspaces]); +} diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index 8508fbda5..6d675ac4c 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -329,6 +329,8 @@ describe("keyboard-shortcuts", () => { context: { isMac: true, isDesktop: false }, action: "workspace.navigate.relative", payload: { delta: -1 }, + preventDefault: true, + stopPropagation: true, }, { name: "matches Alt+] to next workspace on macOS web when Option substitutes event.key", @@ -336,6 +338,8 @@ describe("keyboard-shortcuts", () => { context: { isMac: true, isDesktop: false }, action: "workspace.navigate.relative", payload: { delta: 1 }, + preventDefault: true, + stopPropagation: true, }, { name: "matches Alt+Shift+W to close current tab on macOS web when Option substitutes event.key", diff --git a/packages/app/src/keyboard/route-shortcut.test.ts b/packages/app/src/keyboard/route-shortcut.test.ts index cf7c4d470..7aa63a210 100644 --- a/packages/app/src/keyboard/route-shortcut.test.ts +++ b/packages/app/src/keyboard/route-shortcut.test.ts @@ -134,6 +134,13 @@ describe("routeKeyboardShortcut — workspace.navigate.index", () => { }); describe("routeKeyboardShortcut — workspace.navigate.relative", () => { + const STATUS_VISUAL_TARGETS = [ + { serverId: "srv", workspaceId: "needs-input" }, + { serverId: "srv", workspaceId: "running-new" }, + { serverId: "srv", workspaceId: "running-old" }, + { serverId: "srv", workspaceId: "done" }, + ] as const; + it("uses the retained navigation workspace selection over a stale pathname", () => { expect( routeKeyboardShortcut( @@ -150,6 +157,57 @@ describe("routeKeyboardShortcut — workspace.navigate.relative", () => { }); }); + it("moves from status row 2 to row 3 using status visual target order", () => { + expect( + routeKeyboardShortcut( + { action: "workspace.navigate.relative", payload: { delta: 1 } }, + makeCtx({ + pathname: "/h/srv/workspace/ui-expose-archive-worktrees-on-merge", + sidebarShortcutTargets: STATUS_VISUAL_TARGETS, + navigationActiveWorkspace: { serverId: "srv", workspaceId: "running-new" }, + }), + ), + ).toEqual({ + kind: "navigate-workspace", + serverId: "srv", + workspaceId: "running-old", + }); + }); + + it("moves backward from status row 2 to row 1 using status visual target order", () => { + expect( + routeKeyboardShortcut( + { action: "workspace.navigate.relative", payload: { delta: -1 } }, + makeCtx({ + pathname: "/h/srv/workspace/ui-expose-archive-worktrees-on-merge", + sidebarShortcutTargets: STATUS_VISUAL_TARGETS, + navigationActiveWorkspace: { serverId: "srv", workspaceId: "running-new" }, + }), + ), + ).toEqual({ + kind: "navigate-workspace", + serverId: "srv", + workspaceId: "needs-input", + }); + }); + + it("moves backward from status row 3 to row 2 using status visual target order", () => { + expect( + routeKeyboardShortcut( + { action: "workspace.navigate.relative", payload: { delta: -1 } }, + makeCtx({ + pathname: "/h/srv/workspace/running-old", + sidebarShortcutTargets: STATUS_VISUAL_TARGETS, + navigationActiveWorkspace: { serverId: "srv", workspaceId: "running-old" }, + }), + ), + ).toEqual({ + kind: "navigate-workspace", + serverId: "srv", + workspaceId: "running-new", + }); + }); + it("falls back to the pathname workspace when no retained selection exists", () => { expect( routeKeyboardShortcut( diff --git a/packages/app/src/screens/workspace/workspace-route-state.test.ts b/packages/app/src/screens/workspace/workspace-route-state.test.ts index bd3ba6a29..dc4ea674d 100644 --- a/packages/app/src/screens/workspace/workspace-route-state.test.ts +++ b/packages/app/src/screens/workspace/workspace-route-state.test.ts @@ -16,6 +16,7 @@ function createWorkspaceDescriptor(): WorkspaceDescriptor { diffStat: null, scripts: [], archivingAt: null, + statusEnteredAt: null, }; } diff --git a/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts index b90a9eb17..f188ffb32 100644 --- a/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts +++ b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts @@ -25,6 +25,7 @@ function createWorkspaceDescriptor(input: Partial = {}): Wo status: "running", diffStat: null, scripts: [], + statusEnteredAt: null, ...input, archivingAt: input.archivingAt ?? null, }; diff --git a/packages/app/src/stores/session-store-hooks/selectors.test.ts b/packages/app/src/stores/session-store-hooks/selectors.test.ts index 9f4012482..5be68fe84 100644 --- a/packages/app/src/stores/session-store-hooks/selectors.test.ts +++ b/packages/app/src/stores/session-store-hooks/selectors.test.ts @@ -35,6 +35,7 @@ function createWorkspace( name: input.name ?? "main", status: input.status ?? "done", archivingAt: input.archivingAt ?? null, + statusEnteredAt: null, diffStat: input.diffStat ?? null, scripts: input.scripts ?? [], }; diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index ef64c124e..076a92712 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -23,6 +23,7 @@ function createWorkspace( workspaceKind: input.workspaceKind ?? "local_checkout", name: input.name ?? "main", status: input.status ?? "done", + statusEnteredAt: input.statusEnteredAt ?? null, archivingAt: input.archivingAt ?? null, diffStat: input.diffStat ?? null, scripts: input.scripts ?? [], @@ -76,6 +77,7 @@ describe("normalizeWorkspaceDescriptor", () => { name: "main", archivingAt: null, status: "running", + statusEnteredAt: null, activityAt: "not-a-date", diffStat: null, scripts, @@ -109,6 +111,7 @@ describe("normalizeWorkspaceDescriptor", () => { name: "main", archivingAt: null, status: "done", + statusEnteredAt: null, activityAt: null, diffStat: null, scripts: [], @@ -140,6 +143,42 @@ describe("normalizeWorkspaceDescriptor", () => { expect(workspace.archivingAt).toBeNull(); }); + it("normalizes statusEnteredAt strings to Date and missing or null values to null", () => { + const basePayload = { + id: "1", + projectId: "1", + projectDisplayName: "Project 1", + projectRootPath: "/repo", + workspaceDirectory: "/repo", + projectKind: "git", + workspaceKind: "checkout", + name: "main", + status: "running", + activityAt: null, + diffStat: null, + scripts: [], + } satisfies Omit; + + const withString = normalizeWorkspaceDescriptor({ + ...basePayload, + archivingAt: null, + statusEnteredAt: "2026-05-12T09:30:00.000Z", + }); + const withNull = normalizeWorkspaceDescriptor({ + ...basePayload, + archivingAt: null, + statusEnteredAt: null, + }); + const missing = normalizeWorkspaceDescriptor({ + ...basePayload, + archivingAt: null, + } as unknown as WorkspaceDescriptorPayload); + + expect(withString.statusEnteredAt).toEqual(new Date("2026-05-12T09:30:00.000Z")); + expect(withNull.statusEnteredAt).toBeNull(); + expect(missing.statusEnteredAt).toBeNull(); + }); + it("preserves project placement from workspace descriptor payloads", () => { const workspace = normalizeWorkspaceDescriptor({ id: "1", @@ -152,6 +191,7 @@ describe("normalizeWorkspaceDescriptor", () => { name: "main", archivingAt: null, status: "done", + statusEnteredAt: null, activityAt: null, diffStat: null, scripts: [], diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index ea7def716..42bbed725 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -126,6 +126,7 @@ export interface WorkspaceDescriptor { workspaceKind: WorkspaceDescriptorPayload["workspaceKind"]; name: string; status: WorkspaceDescriptorPayload["status"]; + statusEnteredAt: Date | null; archivingAt: string | null; diffStat: { additions: number; deletions: number } | null; scripts: WorkspaceDescriptorPayload["scripts"]; @@ -137,6 +138,11 @@ export interface WorkspaceDescriptor { export function normalizeWorkspaceDescriptor( payload: WorkspaceDescriptorPayload, ): WorkspaceDescriptor { + const statusEnteredAtRaw = payload.statusEnteredAt; + const statusEnteredAt: Date | null = + typeof statusEnteredAtRaw === "string" && statusEnteredAtRaw.length > 0 + ? new Date(statusEnteredAtRaw) + : null; return { id: normalizeWorkspaceOpaqueId(payload.id) ?? payload.id, projectId: payload.projectId, @@ -148,6 +154,7 @@ export function normalizeWorkspaceDescriptor( workspaceKind: payload.workspaceKind, name: payload.name, status: payload.status, + statusEnteredAt, archivingAt: payload.archivingAt ?? null, diffStat: payload.diffStat ?? null, scripts: (payload.scripts ?? []).map((s) => Object.assign({}, s)), diff --git a/packages/app/src/stores/sidebar-collapsed-sections-store/index.ts b/packages/app/src/stores/sidebar-collapsed-sections-store/index.ts index ce56b7d6a..20363ac87 100644 --- a/packages/app/src/stores/sidebar-collapsed-sections-store/index.ts +++ b/packages/app/src/stores/sidebar-collapsed-sections-store/index.ts @@ -7,21 +7,26 @@ import { serializeCollapsedProjects, setProjectCollapsed, toggleProjectCollapsed, + toggleStatusGroupCollapsed, } from "./state"; interface SidebarCollapsedSectionsState extends CollapsedProjectsState { toggleProjectCollapsed: (projectKey: string) => void; setProjectCollapsed: (projectKey: string, collapsed: boolean) => void; + toggleStatusGroupCollapsed: (statusGroupKey: string) => void; } export const useSidebarCollapsedSectionsStore = create()( persist( (set) => ({ collapsedProjectKeys: new Set(), + collapsedStatusGroupKeys: new Set(), toggleProjectCollapsed: (projectKey) => set((state) => toggleProjectCollapsed(state, projectKey)), setProjectCollapsed: (projectKey, collapsed) => set((state) => setProjectCollapsed(state, projectKey, collapsed)), + toggleStatusGroupCollapsed: (statusGroupKey) => + set((state) => toggleStatusGroupCollapsed(state, statusGroupKey)), }), { name: "sidebar-collapsed-sections", diff --git a/packages/app/src/stores/sidebar-collapsed-sections-store/state.test.ts b/packages/app/src/stores/sidebar-collapsed-sections-store/state.test.ts index f9ccb17e7..0936dfcd8 100644 --- a/packages/app/src/stores/sidebar-collapsed-sections-store/state.test.ts +++ b/packages/app/src/stores/sidebar-collapsed-sections-store/state.test.ts @@ -5,10 +5,11 @@ import { serializeCollapsedProjects, setProjectCollapsed, toggleProjectCollapsed, + toggleStatusGroupCollapsed, } from "@/stores/sidebar-collapsed-sections-store/state"; function emptyState(): CollapsedProjectsState { - return { collapsedProjectKeys: new Set() }; + return { collapsedProjectKeys: new Set(), collapsedStatusGroupKeys: new Set() }; } describe("sidebar collapsed projects transitions", () => { @@ -18,17 +19,21 @@ describe("sidebar collapsed projects transitions", () => { state = setProjectCollapsed(state, "project-a", true); state = toggleProjectCollapsed(state, "project-b"); state = toggleProjectCollapsed(state, "project-a"); + state = toggleStatusGroupCollapsed(state, "running"); expect(Array.from(state.collapsedProjectKeys)).toEqual(["project-b"]); + expect(Array.from(state.collapsedStatusGroupKeys)).toEqual(["running"]); }); it("serializes collapsed project keys for preference storage", () => { const state: CollapsedProjectsState = { collapsedProjectKeys: new Set(["project-a", "project-b"]), + collapsedStatusGroupKeys: new Set(["running"]), }; expect(serializeCollapsedProjects(state)).toEqual({ collapsedProjectKeys: ["project-a", "project-b"], + collapsedStatusGroupKeys: ["running"], }); }); @@ -39,6 +44,7 @@ describe("sidebar collapsed projects transitions", () => { ); expect(Array.from(restored.collapsedProjectKeys)).toEqual(["project-a", "project-b"]); + expect(Array.from(restored.collapsedStatusGroupKeys)).toEqual([]); }); it("keeps the existing state object when persisted preferences do not change collapsed keys", () => { diff --git a/packages/app/src/stores/sidebar-collapsed-sections-store/state.ts b/packages/app/src/stores/sidebar-collapsed-sections-store/state.ts index 1807e068b..877d218fe 100644 --- a/packages/app/src/stores/sidebar-collapsed-sections-store/state.ts +++ b/packages/app/src/stores/sidebar-collapsed-sections-store/state.ts @@ -1,9 +1,11 @@ export interface CollapsedProjectsState { collapsedProjectKeys: Set; + collapsedStatusGroupKeys: Set; } export interface PersistedCollapsedProjects { collapsedProjectKeys?: unknown; + collapsedStatusGroupKeys?: unknown; } export function toggleProjectCollapsed( @@ -16,7 +18,20 @@ export function toggleProjectCollapsed( } else { next.add(projectKey); } - return { collapsedProjectKeys: next }; + return { ...state, collapsedProjectKeys: next }; +} + +export function toggleStatusGroupCollapsed( + state: CollapsedProjectsState, + statusGroupKey: string, +): CollapsedProjectsState { + const next = new Set(state.collapsedStatusGroupKeys); + if (next.has(statusGroupKey)) { + next.delete(statusGroupKey); + } else { + next.add(statusGroupKey); + } + return { ...state, collapsedStatusGroupKeys: next }; } export function setProjectCollapsed( @@ -30,13 +45,17 @@ export function setProjectCollapsed( } else { next.delete(projectKey); } - return { collapsedProjectKeys: next }; + return { ...state, collapsedProjectKeys: next }; } export function serializeCollapsedProjects(state: CollapsedProjectsState): { collapsedProjectKeys: string[]; + collapsedStatusGroupKeys: string[]; } { - return { collapsedProjectKeys: Array.from(state.collapsedProjectKeys) }; + return { + collapsedProjectKeys: Array.from(state.collapsedProjectKeys), + collapsedStatusGroupKeys: Array.from(state.collapsedStatusGroupKeys), + }; } export function mergePersistedCollapsedProjects( @@ -44,16 +63,24 @@ export function mergePersistedCollapsedProjects { +function deserializeCollapsedKeys(value: unknown): Set { if (!Array.isArray(value)) { return new Set(); } diff --git a/packages/app/src/stores/sidebar-view-store.ts b/packages/app/src/stores/sidebar-view-store.ts new file mode 100644 index 000000000..de915ddcc --- /dev/null +++ b/packages/app/src/stores/sidebar-view-store.ts @@ -0,0 +1,41 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +export type SidebarGroupMode = "project" | "status"; + +interface SidebarViewStoreState { + groupModeByServerId: Record; + getGroupMode: (serverId: string) => SidebarGroupMode; + setGroupMode: (serverId: string, mode: SidebarGroupMode) => void; +} + +export const useSidebarViewStore = create()( + persist( + (set, get) => ({ + groupModeByServerId: {}, + getGroupMode: (serverId) => { + const key = serverId.trim(); + if (!key) return "project"; + return get().groupModeByServerId[key] ?? "project"; + }, + setGroupMode: (serverId, mode) => { + const key = serverId.trim(); + if (!key) return; + set((state) => ({ + groupModeByServerId: { + ...state.groupModeByServerId, + [key]: mode, + }, + })); + }, + }), + { + name: "sidebar-group-mode", + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ + groupModeByServerId: state.groupModeByServerId, + }), + }, + ), +); diff --git a/packages/app/src/utils/navigate-to-agent/resolve.test.ts b/packages/app/src/utils/navigate-to-agent/resolve.test.ts index de61b33be..3f0c43a0a 100644 --- a/packages/app/src/utils/navigate-to-agent/resolve.test.ts +++ b/packages/app/src/utils/navigate-to-agent/resolve.test.ts @@ -23,6 +23,7 @@ function createWorkspace(): WorkspaceDescriptor { name: "worktree", status: "done", archivingAt: null, + statusEnteredAt: null, diffStat: null, scripts: [], }; diff --git a/packages/app/src/utils/projects.test.ts b/packages/app/src/utils/projects.test.ts index 203fd7174..27b37bf3b 100644 --- a/packages/app/src/utils/projects.test.ts +++ b/packages/app/src/utils/projects.test.ts @@ -44,6 +44,7 @@ function workspace(input: { name: input.id, status: "done", archivingAt: null, + statusEnteredAt: null, diffStat: null, scripts: [], gitRuntime: { diff --git a/packages/app/src/utils/sidebar-agent-state.test.ts b/packages/app/src/utils/sidebar-agent-state.test.ts index bd62055c8..74f02eba8 100644 --- a/packages/app/src/utils/sidebar-agent-state.test.ts +++ b/packages/app/src/utils/sidebar-agent-state.test.ts @@ -35,7 +35,7 @@ describe("deriveSidebarStateBucket", () => { ).toBe("attention"); }); - it("treats initializing agents as done", () => { + it("treats initializing agents as running", () => { expect( deriveSidebarStateBucket({ status: "initializing", @@ -43,6 +43,6 @@ describe("deriveSidebarStateBucket", () => { requiresAttention: false, attentionReason: null, }), - ).toBe("done"); + ).toBe("running"); }); }); diff --git a/packages/app/src/utils/sidebar-project-row-model.test.ts b/packages/app/src/utils/sidebar-project-row-model.test.ts index d60d38ceb..5ea1405dd 100644 --- a/packages/app/src/utils/sidebar-project-row-model.test.ts +++ b/packages/app/src/utils/sidebar-project-row-model.test.ts @@ -25,6 +25,7 @@ function workspace(overrides: Partial = {}): SidebarWorks archiveUnpushedCommitCount: null, scripts: [], hasRunningScripts: false, + statusEnteredAt: null, ...overrides, archivingAt: overrides.archivingAt ?? null, }; diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts index 403653eda..f191471a1 100644 --- a/packages/app/src/utils/sidebar-shortcuts.test.ts +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -4,7 +4,11 @@ import type { SidebarWorkspaceEntry, } from "@/hooks/use-sidebar-workspaces-list"; -import { buildSidebarShortcutModel, getRelativeSidebarShortcutTarget } from "./sidebar-shortcuts"; +import { + buildSidebarShortcutModel, + buildStatusSidebarShortcutModel, + getRelativeSidebarShortcutTarget, +} from "./sidebar-shortcuts"; function workspace(input: { serverId: string; @@ -12,6 +16,8 @@ function workspace(input: { workspaceDirectory: string; name: string; projectKey?: string; + statusBucket?: SidebarWorkspaceEntry["statusBucket"]; + statusEnteredAt?: Date | null; }): SidebarWorkspaceEntry { return { workspaceKey: `${input.serverId}:${input.workspaceId}`, @@ -22,8 +28,9 @@ function workspace(input: { projectKind: "git", workspaceKind: "checkout", name: input.name, - statusBucket: "done", + statusBucket: input.statusBucket ?? "done", archivingAt: null, + statusEnteredAt: input.statusEnteredAt ?? null, diffStat: null, prHint: null, archiveHasUncommittedChanges: null, @@ -132,6 +139,68 @@ describe("buildSidebarShortcutModel", () => { }); }); +describe("buildStatusSidebarShortcutModel", () => { + it("builds shortcut targets in status visual order", () => { + const workspaces = [ + workspace({ + serverId: "s1", + workspaceId: "done-old", + workspaceDirectory: "/repo/done-old", + name: "done old", + projectKey: "p1", + statusBucket: "done", + statusEnteredAt: new Date("2026-01-01T00:00:00.000Z"), + }), + workspace({ + serverId: "s1", + workspaceId: "running-new", + workspaceDirectory: "/repo/running-new", + name: "running new", + projectKey: "p2", + statusBucket: "running", + statusEnteredAt: new Date("2026-03-01T00:00:00.000Z"), + }), + workspace({ + serverId: "s1", + workspaceId: "needs-input", + workspaceDirectory: "/repo/needs-input", + name: "needs input", + projectKey: "p1", + statusBucket: "needs_input", + statusEnteredAt: new Date("2026-02-01T00:00:00.000Z"), + }), + workspace({ + serverId: "s1", + workspaceId: "running-old", + workspaceDirectory: "/repo/running-old", + name: "running old", + projectKey: "p2", + statusBucket: "running", + statusEnteredAt: new Date("2026-01-15T00:00:00.000Z"), + }), + ]; + + const model = buildStatusSidebarShortcutModel({ + workspaces, + projectNamesByKey: new Map([ + ["p1", "Project 1"], + ["p2", "Project 2"], + ]), + }); + + expect(model.shortcutTargets).toEqual([ + { serverId: "s1", workspaceId: "needs-input" }, + { serverId: "s1", workspaceId: "running-new" }, + { serverId: "s1", workspaceId: "running-old" }, + { serverId: "s1", workspaceId: "done-old" }, + ]); + expect(model.shortcutIndexByWorkspaceKey.get("s1:needs-input")).toBe(1); + expect(model.shortcutIndexByWorkspaceKey.get("s1:running-new")).toBe(2); + expect(model.shortcutIndexByWorkspaceKey.get("s1:running-old")).toBe(3); + expect(model.shortcutIndexByWorkspaceKey.get("s1:done-old")).toBe(4); + }); +}); + describe("getRelativeSidebarShortcutTarget", () => { const targets = [ { serverId: "s1", workspaceId: "ws-1" }, diff --git a/packages/app/src/utils/sidebar-shortcuts.ts b/packages/app/src/utils/sidebar-shortcuts.ts index 22237756c..eff5f9cdf 100644 --- a/packages/app/src/utils/sidebar-shortcuts.ts +++ b/packages/app/src/utils/sidebar-shortcuts.ts @@ -2,6 +2,7 @@ import type { SidebarProjectEntry, SidebarWorkspaceEntry, } from "@/hooks/use-sidebar-workspaces-list"; +import { buildStatusGroups } from "@/hooks/sidebar-status-view-model"; import { isSidebarProjectFlattened } from "./sidebar-project-row-model"; export interface SidebarShortcutWorkspaceTarget { @@ -37,7 +38,32 @@ export function buildSidebarShortcutModel(input: { for (const workspace of project.workspaces) { if (shortcutTargets.length >= maxShortcuts) { - continue; + break; + } + + const shortcutNumber = shortcutTargets.length + 1; + shortcutTargets.push(createShortcutTarget(workspace)); + shortcutIndexByWorkspaceKey.set(workspace.workspaceKey, shortcutNumber); + } + } + + return { shortcutTargets, shortcutIndexByWorkspaceKey }; +} + +export function buildStatusSidebarShortcutModel(input: { + workspaces: SidebarWorkspaceEntry[]; + projectNamesByKey: Map; + shortcutLimit?: number; +}): SidebarShortcutModel { + const maxShortcuts = Math.max(0, Math.floor(input.shortcutLimit ?? 9)); + const groups = buildStatusGroups(input.workspaces, input.projectNamesByKey); + const shortcutTargets: SidebarShortcutWorkspaceTarget[] = []; + const shortcutIndexByWorkspaceKey = new Map(); + + for (const group of groups) { + for (const workspace of group.rows) { + if (shortcutTargets.length >= maxShortcuts) { + break; } const shortcutNumber = shortcutTargets.length + 1; diff --git a/packages/app/src/utils/workspace-archive-navigation.test.ts b/packages/app/src/utils/workspace-archive-navigation.test.ts index ed37fabc7..c52420228 100644 --- a/packages/app/src/utils/workspace-archive-navigation.test.ts +++ b/packages/app/src/utils/workspace-archive-navigation.test.ts @@ -21,6 +21,7 @@ function workspace( name: input.name ?? input.id, status: input.status ?? "done", archivingAt: input.archivingAt ?? null, + statusEnteredAt: null, diffStat: input.diffStat ?? null, scripts: input.scripts ?? [], }; diff --git a/packages/app/src/utils/workspace-execution.test.ts b/packages/app/src/utils/workspace-execution.test.ts index 9d96e8f1e..10aa2c22a 100644 --- a/packages/app/src/utils/workspace-execution.test.ts +++ b/packages/app/src/utils/workspace-execution.test.ts @@ -22,6 +22,7 @@ function createWorkspace( name: input.name ?? "main", status: input.status ?? "running", archivingAt: input.archivingAt ?? null, + statusEnteredAt: null, diffStat: input.diffStat ?? null, scripts: input.scripts ?? [], }; diff --git a/packages/app/src/workspace/workspace-archive.test.ts b/packages/app/src/workspace/workspace-archive.test.ts index 265a8e1ff..a1dfe7e30 100644 --- a/packages/app/src/workspace/workspace-archive.test.ts +++ b/packages/app/src/workspace/workspace-archive.test.ts @@ -39,6 +39,7 @@ function workspace(input?: Partial): WorkspaceDescriptor { name: "workspace-1", status: "done", archivingAt: null, + statusEnteredAt: null, diffStat: null, scripts: [], ...input, diff --git a/packages/cli/package.json b/packages/cli/package.json index a8f947269..5e9697a25 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -15,8 +15,10 @@ "access": "public" }, "scripts": { - "build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && tsc -p tsconfig.json --incremental false", - "prepack": "npm run build", + "clean": "node ../../scripts/clean-package-dist.mjs", + "build": "tsc -p tsconfig.json --incremental false", + "build:clean": "npm run clean && npm run build", + "prepack": "npm run build:clean", "typecheck": "tsgo --noEmit", "test": "npm run test:local", "test:local": "tsx tests/run-all.ts", diff --git a/packages/client/package.json b/packages/client/package.json index a61534a37..232d728e2 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -26,8 +26,10 @@ "access": "public" }, "scripts": { - "build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && tsc -p tsconfig.json --incremental false", - "prepack": "npm run build", + "clean": "node ../../scripts/clean-package-dist.mjs", + "build": "tsc -p tsconfig.json --incremental false", + "build:clean": "npm run clean && npm run build", + "prepack": "npm run build:clean", "typecheck": "tsgo --noEmit", "typecheck:examples": "tsgo -p tsconfig.examples.json --noEmit", "test": "vitest run" diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 5a90120cc..74f1b8685 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1522,6 +1522,33 @@ export class DaemonClient { }); } + async clearWorkspaceAttention(workspaceId: string | string[]): Promise { + const requestId = this.createRequestId(); + const message = SessionInboundMessageSchema.parse({ + type: "workspace.clear_attention.request", + workspaceId, + requestId, + }); + const response = await this.sendRequest({ + requestId, + message, + timeout: 15000, + options: { skipQueue: true }, + select: (msg) => { + if (msg.type !== "workspace.clear_attention.response") { + return null; + } + if (msg.payload.requestId !== requestId) { + return null; + } + return msg.payload; + }, + }); + if (!response.success) { + throw new Error(response.error ?? "Failed to clear workspace attention"); + } + } + sendHeartbeat(params: { deviceType: "web" | "mobile"; focusedAgentId: string | null; diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 39d511234..658e69123 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -125,6 +125,7 @@ function createWorkspace(input: Partial = {}): PaseoWorkspace { name: "sdk", archivingAt: null, status: "done", + statusEnteredAt: null, activityAt: "2026-05-16T00:00:00.000Z", scripts: [], gitRuntime: null, diff --git a/packages/desktop/package.json b/packages/desktop/package.json index d621b7634..d14a6347d 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -15,7 +15,7 @@ }, "main": "dist/main.js", "scripts": { - "build": "npm --prefix ../.. run build:server && npm run build:main && electron-builder --config electron-builder.yml", + "build": "npm --prefix ../.. run build:server:clean && npm run build:main && electron-builder --config electron-builder.yml", "build:main": "tsc -p tsconfig.json", "dev": "./scripts/dev.sh", "dev:win": "powershell ./scripts/dev.ps1", diff --git a/packages/desktop/scripts/dev.ps1 b/packages/desktop/scripts/dev.ps1 index 3b08bcae7..70db1044e 100644 --- a/packages/desktop/scripts/dev.ps1 +++ b/packages/desktop/scripts/dev.ps1 @@ -10,7 +10,24 @@ npm run build:main # Prefer Metro's stable default port so dev browser storage keeps the same # localhost origin across restarts. Fall back only when earlier ports are busy. -$env:EXPO_PORT = (npx get-port-cli 8081 8082 8083 8084 8085).Trim() +$PreviousNoColor = $env:NO_COLOR +$PreviousForceColor = $env:FORCE_COLOR +try { + $env:NO_COLOR = "1" + $env:FORCE_COLOR = "0" + $env:EXPO_PORT = (npx get-port-cli 8081 8082 8083 8084 8085).Trim() +} finally { + if ($null -eq $PreviousNoColor) { + Remove-Item Env:\NO_COLOR -ErrorAction SilentlyContinue + } else { + $env:NO_COLOR = $PreviousNoColor + } + if ($null -eq $PreviousForceColor) { + Remove-Item Env:\FORCE_COLOR -ErrorAction SilentlyContinue + } else { + $env:FORCE_COLOR = $PreviousForceColor + } +} # Set EXPO_DEV_URL in the environment so Electron inherits it $env:EXPO_DEV_URL = "http://localhost:$($env:EXPO_PORT)" diff --git a/packages/desktop/scripts/dev.sh b/packages/desktop/scripts/dev.sh index d6f45a448..efce05667 100755 --- a/packages/desktop/scripts/dev.sh +++ b/packages/desktop/scripts/dev.sh @@ -11,7 +11,7 @@ npm run build:main # Prefer Metro's stable default port so dev browser storage keeps the same # localhost origin across restarts. Fall back only when earlier ports are busy. -EXPO_PORT=$("$ROOT_DIR/node_modules/.bin/get-port" 8081 8082 8083 8084 8085) +EXPO_PORT=$(NO_COLOR=1 FORCE_COLOR=0 "$ROOT_DIR/node_modules/.bin/get-port" 8081 8082 8083 8084 8085) export EXPO_PORT REMOTE_DEBUGGING_PORT="${PASEO_ELECTRON_REMOTE_DEBUGGING_PORT:-9223}" diff --git a/packages/highlight/package.json b/packages/highlight/package.json index d926f5115..7b8e40b7f 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -19,8 +19,10 @@ "access": "public" }, "scripts": { - "build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && tsc -p tsconfig.json --incremental false", - "prepack": "npm run build", + "clean": "node ../../scripts/clean-package-dist.mjs", + "build": "tsc -p tsconfig.json --incremental false", + "build:clean": "npm run clean && npm run build", + "prepack": "npm run build:clean", "test": "vitest run", "typecheck": "tsgo --noEmit" }, diff --git a/packages/protocol/package.json b/packages/protocol/package.json index b44967b39..84337b828 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -18,8 +18,10 @@ "access": "public" }, "scripts": { - "build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && tsc -p tsconfig.json --incremental false", - "prepack": "npm run build", + "clean": "node ../../scripts/clean-package-dist.mjs", + "build": "tsc -p tsconfig.json --incremental false", + "build:clean": "npm run clean && npm run build", + "prepack": "npm run build:clean", "typecheck": "tsgo --noEmit", "test": "vitest run" }, diff --git a/packages/protocol/src/agent-state-bucket.test.ts b/packages/protocol/src/agent-state-bucket.test.ts index 1a778d39d..86add2e4c 100644 --- a/packages/protocol/src/agent-state-bucket.test.ts +++ b/packages/protocol/src/agent-state-bucket.test.ts @@ -50,7 +50,7 @@ describe("deriveAgentStateBucket", () => { ).toBe("attention"); }); - it("treats initializing agents as done for workspace buckets", () => { + it("treats initializing agents as running for workspace buckets", () => { expect( deriveAgentStateBucket({ status: "initializing", @@ -58,7 +58,7 @@ describe("deriveAgentStateBucket", () => { requiresAttention: false, attentionReason: null, }), - ).toBe("done"); + ).toBe("running"); }); }); diff --git a/packages/protocol/src/agent-state-bucket.ts b/packages/protocol/src/agent-state-bucket.ts index 0273562e9..9fe42bda8 100644 --- a/packages/protocol/src/agent-state-bucket.ts +++ b/packages/protocol/src/agent-state-bucket.ts @@ -1,6 +1,7 @@ import type { AgentLifecycleStatus } from "./agent-lifecycle.js"; import type { WorkspaceStateBucket } from "./messages.js"; +export type { WorkspaceStateBucket }; export type AgentAttentionReason = "finished" | "error" | "permission" | null | undefined; export interface AgentStateBucketInput { @@ -25,7 +26,7 @@ export function deriveAgentStateBucket(input: AgentStateBucketInput): WorkspaceS if (input.status === "error" || input.attentionReason === "error") { return "failed"; } - if (input.status === "running") { + if (input.status === "running" || input.status === "initializing") { return "running"; } if (input.requiresAttention) { diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 8af157fee..b6124749e 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1595,6 +1595,12 @@ export const ArchiveWorkspaceRequestSchema = z.object({ requestId: z.string(), }); +export const WorkspaceClearAttentionRequestSchema = z.object({ + type: z.literal("workspace.clear_attention.request"), + workspaceId: z.union([z.string(), z.array(z.string())]), + requestId: z.string(), +}); + // Highlighted diff token schema // Note: style can be a compound class name (e.g., "heading meta") from the syntax highlighter const HighlightTokenSchema = z.object({ @@ -1898,6 +1904,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ LegacyOpenInEditorRequestSchema, OpenProjectRequestSchema, ArchiveWorkspaceRequestSchema, + WorkspaceClearAttentionRequestSchema, FileExplorerRequestSchema, ProjectIconRequestSchema, FileDownloadTokenRequestSchema, @@ -2385,6 +2392,14 @@ export const WorkspaceDescriptorPayloadSchema = z name: z.string(), archivingAt: z.string().nullable().optional().default(null), status: WorkspaceStateBucketSchema, + // Best-effort workspace status entry timestamp. Old daemons omit the + // field; old clients treat missing and null equivalently. The transform + // coerces a missing field to `null` so downstream code never has to + // handle `undefined`. + statusEnteredAt: z + .string() + .nullish() + .transform((value) => value ?? null), activityAt: z.string().nullable(), diffStat: z .object({ @@ -2668,6 +2683,25 @@ export const ClearAgentAttentionResponseMessageSchema = z.object({ }), }); +export const WorkspaceClearAttentionResponseSchema = z.object({ + type: z.literal("workspace.clear_attention.response"), + payload: z.object({ + requestId: z.string(), + workspaceId: z.union([z.string(), z.array(z.string())]), + clearedAgentIds: z.array(z.string()), + results: z.array( + z.object({ + workspaceId: z.string(), + clearedAgentIds: z.array(z.string()), + success: z.boolean(), + error: z.string().nullable(), + }), + ), + success: z.boolean(), + error: z.string().nullable(), + }), +}); + export const SendAgentMessageResponseMessageSchema = z.object({ type: z.literal("send_agent_message_response"), payload: z.object({ @@ -3667,6 +3701,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ FetchAgentTimelineResponseMessageSchema, CancelAgentResponseMessageSchema, ClearAgentAttentionResponseMessageSchema, + WorkspaceClearAttentionResponseSchema, SendAgentMessageResponseMessageSchema, SetVoiceModeResponseMessageSchema, DaemonGetStatusResponseSchema, @@ -4010,6 +4045,7 @@ export type LegacyListAvailableEditorsRequest = z.infer< export type LegacyOpenInEditorRequest = z.infer; export type OpenProjectRequest = z.infer; export type ArchiveWorkspaceRequest = z.infer; +export type WorkspaceClearAttentionRequest = z.infer; export type FileExplorerRequest = z.infer; export type FileExplorerResponse = z.infer; export type ProjectIconRequest = z.infer; diff --git a/packages/protocol/src/messages.workspaces.test.ts b/packages/protocol/src/messages.workspaces.test.ts index a74148325..c126f37ad 100644 --- a/packages/protocol/src/messages.workspaces.test.ts +++ b/packages/protocol/src/messages.workspaces.test.ts @@ -411,6 +411,73 @@ describe("workspace message schemas", () => { ).toBe(archivingAt); }); + // The protocol `statusEnteredAt` field is optional and defaults to null. + // Old daemons omit it entirely and old clients must keep accepting that. + test("defaults statusEnteredAt to null for legacy descriptors", () => { + const baseWorkspace = { + id: "ws-status-entered", + projectId: "proj", + projectDisplayName: "repo", + projectRootPath: "/repo", + workspaceDirectory: "/repo", + projectKind: "git", + workspaceKind: "worktree", + name: "feature", + status: "running", + activityAt: null, + scripts: [], + } as const; + expect(WorkspaceDescriptorPayloadSchema.parse(baseWorkspace).statusEnteredAt).toBeNull(); + }); + + test("preserves statusEnteredAt when present", () => { + const baseWorkspace = { + id: "ws-status-entered", + projectId: "proj", + projectDisplayName: "repo", + projectRootPath: "/repo", + workspaceDirectory: "/repo", + projectKind: "git", + workspaceKind: "worktree", + name: "feature", + status: "running", + activityAt: null, + scripts: [], + } as const; + const statusEnteredAt = "2026-05-12T10:00:00.000Z"; + expect( + WorkspaceDescriptorPayloadSchema.parse({ + ...baseWorkspace, + statusEnteredAt, + }).statusEnteredAt, + ).toBe(statusEnteredAt); + }); + + test("preserves explicit statusEnteredAt: null for empty workspaces", () => { + // The server emits `statusEnteredAt: null` for workspaces with no + // contributing agents (the "done with no agents" case). The client must + // distinguish this from "field omitted" — both parse to null, but the + // round-trip must not lose the explicit null. + const baseWorkspace = { + id: "ws-status-entered", + projectId: "proj", + projectDisplayName: "repo", + projectRootPath: "/repo", + workspaceDirectory: "/repo", + projectKind: "git", + workspaceKind: "worktree", + name: "feature", + status: "done", + activityAt: null, + scripts: [], + } as const; + const parsed = WorkspaceDescriptorPayloadSchema.parse({ + ...baseWorkspace, + statusEnteredAt: null, + }); + expect(parsed.statusEnteredAt).toBeNull(); + }); + test("parses legacy workspace descriptor enum values", () => { const parsed = SessionOutboundMessageSchema.parse({ type: "workspace_update", diff --git a/packages/relay/package.json b/packages/relay/package.json index 753fe0631..271620af3 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -32,8 +32,10 @@ "access": "public" }, "scripts": { - "build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && tsc -p tsconfig.json --incremental false", - "prepack": "npm run build", + "clean": "node ../../scripts/clean-package-dist.mjs", + "build": "tsc -p tsconfig.json --incremental false", + "build:clean": "npm run clean && npm run build", + "prepack": "npm run build:clean", "typecheck": "tsgo --noEmit", "test": "vitest run", "test:watch": "vitest" diff --git a/packages/server/package.json b/packages/server/package.json index cb5292e56..8fcecb9d1 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -30,10 +30,12 @@ "scripts": { "dev": "cross-env PASEO_NODE_ENV=development node --import tsx scripts/dev-runner.ts", "dev:tsx": "cross-env PASEO_NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' scripts/dev-runner.ts", - "build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && npm run build:lib && npm run build:scripts", + "clean": "node ../../scripts/clean-package-dist.mjs", + "build": "npm run build:lib && npm run build:scripts", + "build:clean": "npm run clean && npm run build", "build:lib": "tsc -p tsconfig.server.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true}); fs.copyFileSync('src/terminal/terminal-ts-loader.mjs','dist/server/terminal/terminal-ts-loader.mjs');\"", "build:scripts": "tsc -p tsconfig.scripts.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/scripts',{recursive:true}); fs.copyFileSync('scripts/mcp-stdio-socket-bridge-cli.mjs','dist/scripts/mcp-stdio-socket-bridge-cli.mjs');\"", - "prepack": "npm run build", + "prepack": "npm run build:clean", "start": "node dist/scripts/supervisor-entrypoint.js", "typecheck": "tsgo -p tsconfig.server.typecheck.json --noEmit", "generate:config-schema": "tsx scripts/generate-config-schema.ts", diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 302d98ccf..94f3d5bbf 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -233,6 +233,7 @@ import { WorkspaceDirectory, type WorkspaceUpdatesFilter, } from "./workspace-directory.js"; +import { shouldEmitPendingBootstrapUpdate } from "./workspace-bootstrap-dedupe.js"; import { attemptFirstAgentBranchAutoName, createPaseoWorktree, @@ -2099,6 +2100,8 @@ export class Session { return this.handleOpenProjectRequest(msg); case "archive_workspace_request": return this.handleArchiveWorkspaceRequest(msg); + case "workspace.clear_attention.request": + return this.handleWorkspaceClearAttentionRequest(msg); case "file_explorer_request": return this.handleFileExplorerRequest(msg); case "project_icon_request": @@ -5963,7 +5966,9 @@ export class Session { if (filter?.labels) { const filterLabels = filter.labels; agents = agents.filter((agent) => - Object.entries(filterLabels).every(([key, value]) => agent.labels[key] === value), + Object.entries(filterLabels).every( + ([key, _value]) => agent.labels[key] === filterLabels[key], + ), ); } @@ -6255,6 +6260,7 @@ export class Session { name: workspace.displayName, archivingAt: null, status: "done", + statusEnteredAt: null, activityAt: null, diffStat, scripts: @@ -6347,6 +6353,7 @@ export class Session { name: result.worktree.branchName || result.workspace.displayName, archivingAt: null, status: "done", + statusEnteredAt: null, activityAt: null, diffStat: { additions: 0, deletions: 0 }, scripts: [], @@ -6437,7 +6444,10 @@ export class Session { } private flushBootstrappedWorkspaceUpdates(options?: { - snapshotLatestActivityByWorkspaceId?: Map; + snapshotByWorkspaceId?: Map< + string, + { status: string; statusEnteredAt: string | null; activityAtMs: number | null } + >; }): void { const subscription = this.workspaceUpdatesSubscription; if (!subscription || !subscription.isBootstrapping) { @@ -6450,19 +6460,26 @@ export class Session { for (const payload of pending) { if (payload.kind === "upsert") { - const snapshotLatestActivity = options?.snapshotLatestActivityByWorkspaceId?.get( - payload.workspace.id, - ); - if (typeof snapshotLatestActivity === "number") { - const updateLatestActivity = payload.workspace.activityAt - ? Date.parse(payload.workspace.activityAt) - : Number.NEGATIVE_INFINITY; - if ( - !Number.isNaN(updateLatestActivity) && - updateLatestActivity <= snapshotLatestActivity - ) { - continue; - } + const snapshot = options?.snapshotByWorkspaceId?.get(payload.workspace.id); + const updateActivityAtMs = payload.workspace.activityAt + ? Date.parse(payload.workspace.activityAt) + : null; + const shouldEmit = shouldEmitPendingBootstrapUpdate({ + snapshot: snapshot + ? { + status: snapshot.status, + statusEnteredAt: snapshot.statusEnteredAt, + activityAtMs: snapshot.activityAtMs, + } + : null, + update: { + status: payload.workspace.status, + statusEnteredAt: payload.workspace.statusEnteredAt ?? null, + activityAtMs: Number.isNaN(updateActivityAtMs) ? null : updateActivityAtMs, + }, + }); + if (!shouldEmit) { + continue; } } this.emit({ @@ -6985,15 +7002,7 @@ export class Session { }, "fetch_workspaces_response_ready", ); - const snapshotLatestActivityByWorkspaceId = new Map(); - for (const entry of payload.entries) { - const parsedLatestActivity = entry.activityAt - ? Date.parse(entry.activityAt) - : Number.NEGATIVE_INFINITY; - if (!Number.isNaN(parsedLatestActivity)) { - snapshotLatestActivityByWorkspaceId.set(entry.id, parsedLatestActivity); - } - } + const snapshot = this.buildBootstrapSnapshot(payload.entries); this.emit({ type: "fetch_workspaces_response", @@ -7005,7 +7014,7 @@ export class Session { }); if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) { - this.flushBootstrappedWorkspaceUpdates({ snapshotLatestActivityByWorkspaceId }); + this.flushBootstrappedWorkspaceUpdates(snapshot); void this.reconcileAndEmitWorkspaceUpdates(); } } catch (error) { @@ -7027,6 +7036,33 @@ export class Session { } } + // Build the bootstrap snapshot used by `flushBootstrappedWorkspaceUpdates` + // to decide which pending updates to drop. Captures the status, + // statusEnteredAt, and activityAt (parsed to ms) for each workspace entry + // so a status-only change (e.g. the unmask case), a statusEnteredAt-only + // change (e.g. a fresh unmask time), AND a fresher activity all still + // ship to the client. + private buildBootstrapSnapshot(entries: FetchWorkspacesResponseEntry[]): { + snapshotByWorkspaceId: Map< + string, + { status: string; statusEnteredAt: string | null; activityAtMs: number | null } + >; + } { + const snapshotByWorkspaceId = new Map< + string, + { status: string; statusEnteredAt: string | null; activityAtMs: number | null } + >(); + for (const entry of entries) { + const parsedActivity = entry.activityAt ? Date.parse(entry.activityAt) : null; + snapshotByWorkspaceId.set(entry.id, { + status: entry.status, + statusEnteredAt: entry.statusEnteredAt ?? null, + activityAtMs: Number.isNaN(parsedActivity) ? null : parsedActivity, + }); + } + return { snapshotByWorkspaceId }; + } + private async registerWorkspaceForImportedAgent(cwd: string): Promise { try { const workspace = await this.findOrCreateWorkspaceForDirectory(cwd); @@ -7329,6 +7365,140 @@ export class Session { } } + private async handleWorkspaceClearAttentionRequest( + request: Extract, + ): Promise { + const { requestId, workspaceId } = request; + const requestedWorkspaceIds = Array.isArray(workspaceId) ? workspaceId : [workspaceId]; + let agents: AgentSnapshotPayload[]; + try { + agents = await this.listAgentPayloads(); + } catch (error) { + const message = getErrorMessage(error); + const results = requestedWorkspaceIds.map((requestedWorkspaceId) => ({ + workspaceId: requestedWorkspaceId, + clearedAgentIds: [], + success: false, + error: message, + })); + this.emit({ + type: "workspace.clear_attention.response", + payload: { + requestId, + workspaceId, + clearedAgentIds: [], + results, + success: false, + error: message, + }, + }); + return; + } + const results: Array<{ + workspaceId: string; + clearedAgentIds: string[]; + success: boolean; + error: string | null; + }> = []; + + for (const requestedWorkspaceId of requestedWorkspaceIds) { + const clearedAgentIds: string[] = []; + try { + const workspace = await this.workspaceRegistry.get(requestedWorkspaceId); + if (!workspace || workspace.archivedAt) { + throw new Error(`Workspace not found: ${requestedWorkspaceId}`); + } + + const workspaceCwd = normalizePersistedWorkspaceId(workspace.cwd); + const clearableAgentIds = agents + .filter((agent) => !agent.archivedAt) + .filter((agent) => normalizePersistedWorkspaceId(agent.cwd) === workspaceCwd) + .filter((agent) => agent.requiresAttention === true) + .filter((agent) => (agent.pendingPermissions?.length ?? 0) === 0) + .filter((agent) => agent.attentionReason !== "permission") + .map((agent) => agent.id); + + for (const agentId of clearableAgentIds) { + const liveAgent = this.agentManager.getAgent(agentId); + if (liveAgent) { + await this.agentManager.clearAgentAttention(agentId); + clearedAgentIds.push(agentId); + continue; + } + + const record = await this.agentStorage.get(agentId); + if ( + !record || + record.internal || + record.archivedAt || + record.requiresAttention !== true + ) { + continue; + } + const nextRecord: StoredAgentRecord = { + ...record, + updatedAt: new Date().toISOString(), + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + }; + await this.agentStorage.upsert(nextRecord); + const agent = this.buildStoredAgentPayload(nextRecord); + const project = await this.buildProjectPlacementForCwd(agent.cwd); + this.emit({ + type: "agent_update", + payload: { + kind: "upsert", + agent, + project, + }, + }); + clearedAgentIds.push(agentId); + } + + await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId); + results.push({ + workspaceId: requestedWorkspaceId, + clearedAgentIds, + success: true, + error: null, + }); + } catch (error) { + const message = getErrorMessage(error); + this.sessionLogger.error( + { err: error, workspaceId: requestedWorkspaceId }, + "Failed to clear workspace attention", + ); + results.push({ + workspaceId: requestedWorkspaceId, + clearedAgentIds, + success: false, + error: message, + }); + } + } + + const clearedAgentIds = results.flatMap((result) => result.clearedAgentIds); + const failedResults = results.filter((result) => !result.success); + this.emit({ + type: "workspace.clear_attention.response", + payload: { + requestId, + workspaceId, + clearedAgentIds, + results, + success: failedResults.length === 0, + error: + failedResults.length === 0 + ? null + : failedResults + .map((result) => result.error) + .filter((error) => error !== null) + .join("; "), + }, + }); + } + private async handleFetchAgent(agentIdOrIdentifier: string, requestId: string): Promise { const resolved = await this.resolveAgentIdentifier(agentIdOrIdentifier); if (!resolved.ok) { diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 172a74245..99d668119 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -7,7 +7,7 @@ import { z } from "zod"; import { Session } from "./session.js"; import type { AgentSnapshotPayload, SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { AgentManager } from "./agent/agent-manager.js"; -import { AgentStorage } from "./agent/agent-storage.js"; +import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js"; import type { AgentClient, AgentCreateSessionOptions, @@ -59,6 +59,7 @@ interface SessionTestAccess { agentStorage: { list(...args: unknown[]): Promise; get(agentId: string): Promise; + upsert(record: unknown): Promise; }; agentManager: { listAgents(): unknown[]; @@ -155,6 +156,7 @@ function makeAgent(input: { pendingPermissions?: number; requiresAttention?: boolean; attentionReason?: AgentSnapshotPayload["attentionReason"]; + attentionTimestamp?: string | null; }): AgentSnapshotPayload { const pendingPermissionCount = input.pendingPermissions ?? 0; return { @@ -193,7 +195,39 @@ function makeAgent(input: { labels: {}, requiresAttention: input.requiresAttention ?? false, attentionReason: input.attentionReason ?? null, - attentionTimestamp: null, + attentionTimestamp: input.attentionTimestamp ?? null, + archivedAt: null, + }; +} + +function makeStoredAgent(input: { + id: string; + cwd: string; + updatedAt: string; + requiresAttention?: boolean; + attentionReason?: StoredAgentRecord["attentionReason"]; +}): StoredAgentRecord { + return { + id: input.id, + provider: "codex", + cwd: input.cwd, + createdAt: input.updatedAt, + updatedAt: input.updatedAt, + lastActivityAt: input.updatedAt, + lastUserMessageAt: null, + title: null, + labels: {}, + lastStatus: "closed", + lastModeId: null, + config: { provider: "codex", cwd: input.cwd }, + runtimeInfo: { provider: "codex", sessionId: null }, + features: [], + persistence: null, + lastError: null, + requiresAttention: input.requiresAttention ?? false, + attentionReason: input.attentionReason ?? null, + attentionTimestamp: input.requiresAttention ? input.updatedAt : null, + internal: false, archivedAt: null, }; } @@ -459,6 +493,7 @@ function createSessionForWorkspaceTests( agentStorage: asAgentStorage({ list: async () => [], get: async () => null, + upsert: async () => {}, }), projectRegistry: { initialize: async () => {}, @@ -1024,6 +1059,200 @@ test("archive emits an authoritative agent_update upsert for subscribed clients" }); }); +test("workspace clear attention clears stored-only agents and responds", async () => { + const emitted: SessionOutboundMessage[] = []; + const workspace = createPersistedWorkspaceRecord({ + workspaceId: REPO_CWD, + projectId: REPO_CWD, + cwd: REPO_CWD, + kind: "directory", + displayName: "repo", + createdAt: "2026-03-30T15:00:00.000Z", + updatedAt: "2026-03-30T15:00:00.000Z", + }); + const project = createPersistedProjectRecord({ + projectId: REPO_CWD, + rootPath: REPO_CWD, + kind: "non_git", + displayName: "repo", + createdAt: "2026-03-30T15:00:00.000Z", + updatedAt: "2026-03-30T15:00:00.000Z", + }); + let storedRecord = makeStoredAgent({ + id: "stored-agent-1", + cwd: REPO_CWD, + updatedAt: "2026-03-30T15:00:00.000Z", + requiresAttention: true, + attentionReason: "finished", + }); + const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }); + + session.workspaceRegistry.list = async () => [workspace]; + session.workspaceRegistry.get = async (id: string) => + id === workspace.workspaceId ? workspace : null; + session.projectRegistry.list = async () => [project]; + session.projectRegistry.get = async (id: string) => (id === project.projectId ? project : null); + session.agentStorage.get = async (agentId: string) => + agentId === storedRecord.id ? storedRecord : null; + session.agentStorage.upsert = async (record: unknown) => { + storedRecord = record as StoredAgentRecord; + }; + session.listAgentPayloads = async () => [ + makeAgent({ + id: storedRecord.id, + cwd: storedRecord.cwd, + status: "closed", + updatedAt: storedRecord.updatedAt, + requiresAttention: true, + attentionReason: "finished", + }), + ]; + + await session.handleMessage({ + type: "workspace.clear_attention.request", + workspaceId: workspace.workspaceId, + requestId: "req-1", + }); + + expect(storedRecord.requiresAttention).toBe(false); + expect(storedRecord.attentionReason).toBeNull(); + expect(storedRecord.attentionTimestamp).toBeNull(); + expect(findByType(emitted, "workspace.clear_attention.response").payload).toMatchObject({ + requestId: "req-1", + workspaceId: workspace.workspaceId, + clearedAgentIds: [storedRecord.id], + success: true, + error: null, + }); + const agentUpdate = findByType(emitted, "agent_update"); + expect(agentUpdate.payload.kind).toBe("upsert"); + if (agentUpdate.payload.kind === "upsert") { + expect(agentUpdate.payload.agent.requiresAttention).toBe(false); + } +}); + +test("workspace clear attention responds with an error instead of timing out", async () => { + const emitted: SessionOutboundMessage[] = []; + const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }); + session.workspaceRegistry.get = async () => null; + + await session.handleMessage({ + type: "workspace.clear_attention.request", + workspaceId: "missing-workspace", + requestId: "req-1", + }); + + expect(findByType(emitted, "workspace.clear_attention.response").payload).toMatchObject({ + requestId: "req-1", + workspaceId: "missing-workspace", + clearedAgentIds: [], + success: false, + error: "Workspace not found: missing-workspace", + }); +}); + +test("workspace clear attention can clear multiple workspaces in one request", async () => { + const emitted: SessionOutboundMessage[] = []; + const workspaces = [ + createPersistedWorkspaceRecord({ + workspaceId: "/tmp/repo-a", + projectId: "/tmp/repo-a", + cwd: "/tmp/repo-a", + kind: "directory", + displayName: "repo-a", + createdAt: "2026-03-30T15:00:00.000Z", + updatedAt: "2026-03-30T15:00:00.000Z", + }), + createPersistedWorkspaceRecord({ + workspaceId: "/tmp/repo-b", + projectId: "/tmp/repo-b", + cwd: "/tmp/repo-b", + kind: "directory", + displayName: "repo-b", + createdAt: "2026-03-30T15:00:00.000Z", + updatedAt: "2026-03-30T15:00:00.000Z", + }), + ]; + const projects = workspaces.map((workspace) => + createPersistedProjectRecord({ + projectId: workspace.projectId, + rootPath: workspace.cwd, + kind: "non_git", + displayName: workspace.displayName, + createdAt: "2026-03-30T15:00:00.000Z", + updatedAt: "2026-03-30T15:00:00.000Z", + }), + ); + const storedRecords = new Map( + workspaces.map((workspace, index) => [ + `stored-agent-${index + 1}`, + makeStoredAgent({ + id: `stored-agent-${index + 1}`, + cwd: workspace.cwd, + updatedAt: "2026-03-30T15:00:00.000Z", + requiresAttention: true, + attentionReason: "finished", + }), + ]), + ); + const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }); + + session.workspaceRegistry.list = async () => workspaces; + session.workspaceRegistry.get = async (id: string) => + workspaces.find((workspace) => workspace.workspaceId === id) ?? null; + session.projectRegistry.list = async () => projects; + session.projectRegistry.get = async (id: string) => + projects.find((project) => project.projectId === id) ?? null; + session.agentStorage.get = async (agentId: string) => storedRecords.get(agentId) ?? null; + session.agentStorage.upsert = async (record: unknown) => { + const storedRecord = record as StoredAgentRecord; + storedRecords.set(storedRecord.id, storedRecord); + }; + session.listAgentPayloads = async () => + Array.from(storedRecords.values()).map((record) => + makeAgent({ + id: record.id, + cwd: record.cwd, + status: "closed", + updatedAt: record.updatedAt, + requiresAttention: record.requiresAttention, + attentionReason: record.attentionReason, + }), + ); + + await session.handleMessage({ + type: "workspace.clear_attention.request", + workspaceId: workspaces.map((workspace) => workspace.workspaceId), + requestId: "req-1", + }); + + expect(Array.from(storedRecords.values()).map((record) => record.requiresAttention)).toEqual([ + false, + false, + ]); + expect(findByType(emitted, "workspace.clear_attention.response").payload).toMatchObject({ + requestId: "req-1", + workspaceId: workspaces.map((workspace) => workspace.workspaceId), + clearedAgentIds: ["stored-agent-1", "stored-agent-2"], + results: [ + { + workspaceId: workspaces[0].workspaceId, + clearedAgentIds: ["stored-agent-1"], + success: true, + error: null, + }, + { + workspaceId: workspaces[1].workspaceId, + clearedAgentIds: ["stored-agent-2"], + success: true, + error: null, + }, + ], + success: true, + error: null, + }); +}); + test("close_items_request archives agents and kills terminals in one batch", async () => { const emitted: SessionOutboundMessage[] = []; const archivedAt = "2026-04-01T00:00:00.000Z"; @@ -3885,6 +4114,7 @@ test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline desc name: "main", archivingAt: null, status: "done", + statusEnteredAt: null, activityAt: null, diffStat: null, } as const; @@ -3911,6 +4141,203 @@ test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline desc expect(descriptors).toEqual([baselineDescriptor]); }); +test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fields", async () => { + const setupSession = () => { + const session = createSessionForWorkspaceTests(); + const project = createPersistedProjectRecord({ + projectId: "proj-status-entered", + rootPath: REPO_CWD, + kind: "git", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-status-entered", + projectId: project.projectId, + cwd: "/tmp/repo", + kind: "worktree", + displayName: "feature", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + session.projectRegistry.list = async () => [project]; + session.workspaceRegistry.list = async () => [workspace]; + return { session, workspace }; + }; + + const buildDescriptor = (session: TestSession, workspaceId: string) => + session.buildWorkspaceDescriptorMap({ includeGitData: false }).then((map) => { + const descriptor = map.get(workspaceId); + expect(descriptor).toBeDefined(); + return descriptor!; + }); + + // 1. Empty workspace — no agents contribute. statusEnteredAt must be null + // and the workspace status is "done". + { + const { session, workspace } = setupSession(); + session.listAgentPayloads = async () => []; + const descriptor = await buildDescriptor(session, workspace.workspaceId); + expect(descriptor.status).toBe("done"); + expect(descriptor.statusEnteredAt).toBeNull(); + } + + // 2. Single idle agent (derives to "done") — statusEnteredAt uses the + // agent's updatedAt as a best-effort timestamp. + { + const { session, workspace } = setupSession(); + const updatedAt = "2026-05-12T09:30:00.000Z"; + session.listAgentPayloads = async () => [ + makeAgent({ + id: "agent-done", + cwd: workspace.cwd, + status: "idle", + updatedAt, + }), + ]; + const descriptor = await buildDescriptor(session, workspace.workspaceId); + expect(descriptor.status).toBe("done"); + expect(descriptor.statusEnteredAt).toBe(updatedAt); + } + + // 3. Highest-priority across all buckets: a "needs_input" agent beats + // a "running" agent beats a "done" agent. statusEnteredAt is the winning + // bucket's newest agent timestamp. + { + const { session, workspace } = setupSession(); + const doneUpdatedAt = "2026-05-12T09:30:00.000Z"; + const runningUpdatedAt = "2026-05-12T10:00:00.000Z"; + const needsInputUpdatedAt = "2026-05-12T10:15:00.000Z"; + session.listAgentPayloads = async () => [ + makeAgent({ + id: "agent-done", + cwd: workspace.cwd, + status: "idle", + updatedAt: doneUpdatedAt, + }), + makeAgent({ + id: "agent-running", + cwd: workspace.cwd, + status: "running", + updatedAt: runningUpdatedAt, + }), + makeAgent({ + id: "agent-needs-input", + cwd: workspace.cwd, + status: "idle", + updatedAt: needsInputUpdatedAt, + pendingPermissions: 1, + }), + ]; + const descriptor = await buildDescriptor(session, workspace.workspaceId); + expect(descriptor.status).toBe("needs_input"); + expect(descriptor.statusEnteredAt).toBe(needsInputUpdatedAt); + } + + // 4. Same-bucket: keep the previous bucket entry time even when newer + // agents contribute to the same winning bucket. + { + const { session, workspace } = setupSession(); + const earlyUpdatedAt = "2026-05-12T08:00:00.000Z"; + const lateUpdatedAt = "2026-05-12T08:30:00.000Z"; + session.listAgentPayloads = async () => [ + makeAgent({ + id: "agent-done-early", + cwd: workspace.cwd, + status: "idle", + updatedAt: earlyUpdatedAt, + }), + ]; + const first = await buildDescriptor(session, workspace.workspaceId); + expect(first.status).toBe("done"); + expect(first.statusEnteredAt).toBe(earlyUpdatedAt); + + // Second call: same winning bucket, newer agent updatedAt must not move + // the workspace bucket entry time forward. + session.listAgentPayloads = async () => [ + makeAgent({ + id: "agent-done-early", + cwd: workspace.cwd, + status: "idle", + updatedAt: earlyUpdatedAt, + }), + makeAgent({ + id: "agent-done-late", + cwd: workspace.cwd, + status: "idle", + updatedAt: lateUpdatedAt, + }), + ]; + const second = await buildDescriptor(session, workspace.workspaceId); + expect(second.status).toBe("done"); + expect(second.statusEnteredAt).toBe(earlyUpdatedAt); + } + + // 5. Priority unmasking: a higher-priority bucket clears, revealing a + // lower-priority one. The unmask time must be "now". + { + const { session, workspace } = setupSession(); + const unmaskTime = "2026-05-12T12:00:00.000Z"; + vi.setSystemTime(new Date(unmaskTime)); + const doneUpdatedAt = "2026-05-12T08:00:00.000Z"; + const needsInputUpdatedAt = "2026-05-12T07:00:00.000Z"; + session.listAgentPayloads = async () => [ + makeAgent({ + id: "agent-done", + cwd: workspace.cwd, + status: "idle", + updatedAt: doneUpdatedAt, + }), + makeAgent({ + id: "agent-needs-input", + cwd: workspace.cwd, + status: "idle", + updatedAt: needsInputUpdatedAt, + pendingPermissions: 1, + }), + ]; + const first = await buildDescriptor(session, workspace.workspaceId); + expect(first.status).toBe("needs_input"); + + // Drop the needs_input agent. The unmask time is "now", not doneUpdatedAt. + session.listAgentPayloads = async () => [ + makeAgent({ + id: "agent-done", + cwd: workspace.cwd, + status: "idle", + updatedAt: doneUpdatedAt, + }), + ]; + const second = await buildDescriptor(session, workspace.workspaceId); + expect(second.status).toBe("done"); + expect(second.statusEnteredAt).toBe(unmaskTime); + vi.useRealTimers(); + } + + // 6. Attention agent uses attentionTimestamp as the entered-at signal. + { + const { session, workspace } = setupSession(); + const attentionTs = "2026-05-12T11:00:00.000Z"; + const updatedAt = "2026-05-12T10:00:00.000Z"; + session.listAgentPayloads = async () => [ + makeAgent({ + id: "agent-attention", + cwd: workspace.cwd, + status: "idle", + updatedAt, + requiresAttention: true, + attentionReason: "finished", + attentionTimestamp: attentionTs, + }), + ]; + const descriptor = await buildDescriptor(session, workspace.workspaceId); + expect(descriptor.status).toBe("attention"); + // attentionTimestamp takes priority over updatedAt + expect(descriptor.statusEnteredAt).toBe(attentionTs); + } +}); + test("buildWorkspaceDescriptorMap stamps workspace archiving state", async () => { const session = createSessionForWorkspaceTests(); const archivingAt = "2026-04-30T20:45:00.000Z"; @@ -4513,7 +4940,6 @@ test("project.rename.request returns accepted=false when project is not found", createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }), ); session.projectRegistry.get = async () => null; - await session.handleMessage({ type: "project.rename.request", projectId: "does-not-exist", diff --git a/packages/server/src/server/workspace-bootstrap-dedupe.test.ts b/packages/server/src/server/workspace-bootstrap-dedupe.test.ts new file mode 100644 index 000000000..1484c1cb3 --- /dev/null +++ b/packages/server/src/server/workspace-bootstrap-dedupe.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, test } from "vitest"; +import { + shouldEmitPendingBootstrapUpdate, + type BootstrapUpdateSnapshot, +} from "./workspace-bootstrap-dedupe.js"; + +const SNAPSHOT_DONE_10_30: BootstrapUpdateSnapshot = { + status: "done", + statusEnteredAt: "2026-05-12T10:30:00.000Z", + activityAtMs: Date.parse("2026-05-12T10:00:00.000Z"), +}; + +describe("shouldEmitPendingBootstrapUpdate", () => { + test("emits when there is no snapshot (first-time subscription)", () => { + expect( + shouldEmitPendingBootstrapUpdate({ + snapshot: null, + update: { status: "done", statusEnteredAt: null, activityAtMs: null }, + }), + ).toBe(true); + }); + + test("emits when status changed (unmask case: needs_input → done)", () => { + expect( + shouldEmitPendingBootstrapUpdate({ + snapshot: { ...SNAPSHOT_DONE_10_30, status: "needs_input" }, + update: { + status: "done", + statusEnteredAt: SNAPSHOT_DONE_10_30.statusEnteredAt, + activityAtMs: null, + }, + }), + ).toBe(true); + }); + + test("emits when statusEnteredAt changed (fresh unmask time)", () => { + expect( + shouldEmitPendingBootstrapUpdate({ + snapshot: SNAPSHOT_DONE_10_30, + update: { + status: "done", + statusEnteredAt: "2026-05-12T11:00:00.000Z", + activityAtMs: SNAPSHOT_DONE_10_30.activityAtMs, + }, + }), + ).toBe(true); + }); + + test("emits when statusEnteredAt transitions from null to a value (unmask)", () => { + expect( + shouldEmitPendingBootstrapUpdate({ + snapshot: { ...SNAPSHOT_DONE_10_30, statusEnteredAt: null }, + update: { ...SNAPSHOT_DONE_10_30 }, + }), + ).toBe(true); + }); + + test("emits when statusEnteredAt transitions from value to null", () => { + expect( + shouldEmitPendingBootstrapUpdate({ + snapshot: SNAPSHOT_DONE_10_30, + update: { ...SNAPSHOT_DONE_10_30, statusEnteredAt: null }, + }), + ).toBe(true); + }); + + test("emits when update activity is strictly newer than snapshot activity", () => { + expect( + shouldEmitPendingBootstrapUpdate({ + snapshot: SNAPSHOT_DONE_10_30, + update: { + status: "done", + statusEnteredAt: SNAPSHOT_DONE_10_30.statusEnteredAt, + activityAtMs: Date.parse("2026-05-12T10:30:00.000Z"), + }, + }), + ).toBe(true); + }); + + test("emits when snapshot has no activity and update does (new activity)", () => { + expect( + shouldEmitPendingBootstrapUpdate({ + snapshot: { ...SNAPSHOT_DONE_10_30, activityAtMs: null }, + update: { ...SNAPSHOT_DONE_10_30 }, + }), + ).toBe(true); + }); + + test("drops when status pair matches and update activity is older", () => { + expect( + shouldEmitPendingBootstrapUpdate({ + snapshot: SNAPSHOT_DONE_10_30, + update: { + status: "done", + statusEnteredAt: SNAPSHOT_DONE_10_30.statusEnteredAt, + activityAtMs: Date.parse("2026-05-12T09:30:00.000Z"), + }, + }), + ).toBe(false); + }); + + test("drops when status pair matches and activity is equal", () => { + expect( + shouldEmitPendingBootstrapUpdate({ + snapshot: SNAPSHOT_DONE_10_30, + update: { ...SNAPSHOT_DONE_10_30 }, + }), + ).toBe(false); + }); + + test("drops when status pair matches and both activities are null", () => { + expect( + shouldEmitPendingBootstrapUpdate({ + snapshot: { ...SNAPSHOT_DONE_10_30, activityAtMs: null }, + update: { ...SNAPSHOT_DONE_10_30, activityAtMs: null }, + }), + ).toBe(false); + }); + + test("drops when update has no activity but snapshot did (lost activity)", () => { + expect( + shouldEmitPendingBootstrapUpdate({ + snapshot: SNAPSHOT_DONE_10_30, + update: { ...SNAPSHOT_DONE_10_30, activityAtMs: null }, + }), + ).toBe(false); + }); +}); diff --git a/packages/server/src/server/workspace-bootstrap-dedupe.ts b/packages/server/src/server/workspace-bootstrap-dedupe.ts new file mode 100644 index 000000000..6e046ad05 --- /dev/null +++ b/packages/server/src/server/workspace-bootstrap-dedupe.ts @@ -0,0 +1,59 @@ +/** + * Pure dedupe decision for the bootstrap flush. + * + * During the bootstrap window the session buffers workspace updates that + * race with the initial `fetch_workspaces_response`. The flush step decides + * which buffered updates still carry new information and which are + * redundant with what the client just received in the snapshot. + * + * Returns `true` (emit) when ANY of: + * - the status changed from the snapshot + * - the statusEnteredAt changed from the snapshot (including the + * null↔value transition that the unmask case produces) + * - the update's activityAtMs is strictly newer than the snapshot's + * - the snapshot has no activityAtMs and the update has one (new activity + * where there was none) + * + * Returns `false` (drop) when the status pair is unchanged AND the update + * is not strictly newer than the snapshot in activity. The both-null + * activity case falls through to drop — there is genuinely no new info. + */ +export interface BootstrapUpdateSnapshot { + status: string; + statusEnteredAt: string | null; + activityAtMs: number | null; +} + +export interface BootstrapUpdateCheckInput { + /** Snapshot captured from the fetch_workspaces_response. `null` means + * the workspace was not in the snapshot (first-time subscription). */ + snapshot: BootstrapUpdateSnapshot | null; + /** Pending update buffered during the bootstrap window. */ + update: BootstrapUpdateSnapshot; +} + +export function shouldEmitPendingBootstrapUpdate(input: BootstrapUpdateCheckInput): boolean { + const { snapshot, update } = input; + if (!snapshot) { + return true; + } + + if (snapshot.status !== update.status) { + return true; + } + + const snapshotEnteredAt = snapshot.statusEnteredAt ?? null; + const updateEnteredAt = update.statusEnteredAt ?? null; + if (snapshotEnteredAt !== updateEnteredAt) { + return true; + } + + // Status pair is unchanged. The only remaining signal is activity. + if (update.activityAtMs === null) { + return false; + } + if (snapshot.activityAtMs === null) { + return true; + } + return update.activityAtMs > snapshot.activityAtMs; +} diff --git a/packages/server/src/server/workspace-directory.ts b/packages/server/src/server/workspace-directory.ts index 272bb7d50..b969c88e7 100644 --- a/packages/server/src/server/workspace-directory.ts +++ b/packages/server/src/server/workspace-directory.ts @@ -10,6 +10,7 @@ import type { import { deriveAgentStateBucket, getWorkspaceStateBucketPriority, + type WorkspaceStateBucket, } from "@getpaseo/protocol/agent-state-bucket"; import { getParentAgentIdFromLabels, isDelegatedAgent } from "@getpaseo/protocol/agent-labels"; import { SortablePager } from "./pagination/sortable-pager.js"; @@ -23,6 +24,19 @@ const FETCH_WORKSPACES_SORT_KEYS = [ "project_id", ] as const; +/** + * Per-workspace bucket history. Drives the priority-unmasking semantic for + * `statusEnteredAt`: when the winning bucket changes from a higher-priority + * mask to a lower-priority bucket, the new entry time is the unmask time + * (i.e., the moment the higher-priority bucket cleared), not when the + * underlying agent originally entered the lower-priority bucket. Cleared when + * the workspace becomes `done` with no contributing agents. + */ +interface WorkspaceBucketHistoryEntry { + bucket: WorkspaceStateBucket; + enteredAt: string; +} + type FetchWorkspacesRequestMessage = Extract< SessionInboundMessage, { type: "fetch_workspaces_request" } @@ -93,6 +107,12 @@ export function summarizeFetchWorkspacesEntries(entries: Iterable(); + /** + * Per-workspace last-seen winning bucket + entered-at. Persists across + * `buildDescriptorMap` calls inside the daemon process; reset on cold start. + * Server-internal; never crosses the wire. + */ + private readonly bucketHistoryByWorkspaceId = new Map(); private readonly pager = new SortablePager< WorkspaceDescriptorPayload, @@ -224,9 +244,126 @@ export class WorkspaceDirectory { } } + // Resolve the workspace-level `statusEnteredAt` (see aggregate semantics + // on `resolveStatusEnteredAt`). + const nowIso = new Date().toISOString(); + for (const [workspaceId, descriptor] of descriptorsByWorkspaceId) { + const contributingAgents = agents.filter( + (agent) => + !agent.archivedAt && + this.deps.isProviderVisibleToClient(agent.provider) && + workspaceIdsByDirectory.get(normalizeWorkspaceId(agent.cwd)) === workspaceId, + ); + const result = this.resolveStatusEnteredAt({ + workspaceId, + winningBucket: descriptor.status, + contributingAgents, + previous: this.bucketHistoryByWorkspaceId.get(workspaceId) ?? null, + nowIso, + }); + descriptor.statusEnteredAt = result.statusEnteredAt; + if (result.recordUpdate) { + this.bucketHistoryByWorkspaceId.set(workspaceId, result.recordUpdate); + } else if (result.recordDelete) { + this.bucketHistoryByWorkspaceId.delete(workspaceId); + } + } + return descriptorsByWorkspaceId; } + // Aggregate the workspace-level `statusEnteredAt` from its contributing + // agents. Aggregate semantics: + // - winning bucket = highest-priority across contributing agents; + // - entry time = best-effort timestamp from agents in the winning bucket; + // - priority unmasking: when the winning bucket transitions (e.g. a + // higher-priority bucket cleared), the new entry time is "now"; + // - same-bucket emits reuse the previous entered-at; + // - empty workspaces (no contributing agents) get `statusEnteredAt: null`. + private resolveStatusEnteredAt(params: { + workspaceId: string; + winningBucket: WorkspaceStateBucket; + contributingAgents: AgentSnapshotPayload[]; + previous: WorkspaceBucketHistoryEntry | null; + nowIso: string; + }): { + statusEnteredAt: string | null; + recordUpdate?: WorkspaceBucketHistoryEntry; + recordDelete?: true; + } { + const { winningBucket, contributingAgents, previous, nowIso } = params; + + if (contributingAgents.length === 0) { + if (previous && previous.bucket !== "done") { + return { statusEnteredAt: null, recordDelete: true }; + } + if (previous) { + return { + statusEnteredAt: null, + recordUpdate: { bucket: "done", enteredAt: previous.enteredAt }, + }; + } + return { statusEnteredAt: null }; + } + + if (!previous) { + const newestInWinningBucket = this.findNewestAgentTimestampInBucket( + contributingAgents, + winningBucket, + ); + const enteredAt = newestInWinningBucket ?? nowIso; + return { + statusEnteredAt: enteredAt, + recordUpdate: { bucket: winningBucket, enteredAt }, + }; + } + + if (previous.bucket !== winningBucket) { + return { + statusEnteredAt: nowIso, + recordUpdate: { bucket: winningBucket, enteredAt: nowIso }, + }; + } + + return { + statusEnteredAt: previous.enteredAt, + recordUpdate: previous, + }; + } + + // Best-effort newest timestamp across contributing agents whose derived + // bucket matches `winningBucket`. Uses available agent fields: + // - `attentionTimestamp` when attention is set (covers attention/failed) + // - `updatedAt` as a general fallback for any bucket + // Returns `null` if no matching agent has a parseable timestamp. + private findNewestAgentTimestampInBucket( + contributingAgents: AgentSnapshotPayload[], + winningBucket: WorkspaceStateBucket, + ): string | null { + const candidates = contributingAgents + .filter((agent) => { + const derived = deriveAgentStateBucket({ + status: agent.status, + pendingPermissionCount: agent.pendingPermissions?.length ?? 0, + requiresAttention: agent.requiresAttention, + attentionReason: agent.attentionReason ?? null, + }); + return derived === winningBucket; + }) + .map((agent) => { + // Prefer attentionTimestamp when the agent has attention set — this is + // the most accurate "entered current status" signal. + if (agent.attentionTimestamp) { + return agent.attentionTimestamp; + } + // Fall back to updatedAt as a general proxy for recent activity. + return agent.updatedAt; + }) + .filter((value): value is string => typeof value === "string" && value.length > 0) + .sort(); + return candidates.at(-1) ?? null; + } + resolveRegisteredWorkspaceIdForCwd(cwd: string, workspaces: PersistedWorkspaceRecord[]): string { const normalizedCwd = normalizeWorkspaceId(cwd); const exact = workspaces.find((workspace) => workspace.cwd === normalizedCwd); diff --git a/paseo.json b/paseo.json index 6e3f392f6..0d4d78899 100644 --- a/paseo.json +++ b/paseo.json @@ -3,17 +3,18 @@ "setup": [ "npm ci", "npm run build:server", + "npm run build --workspace=@getpaseo/expo-two-way-audio", "cp \"$PASEO_SOURCE_CHECKOUT_PATH/packages/server/.env\" \"$PASEO_WORKTREE_PATH/packages/server/.env\"" ] }, "scripts": { "daemon": { "type": "service", - "command": "PASEO_LISTEN=0.0.0.0:$PASEO_PORT ./scripts/dev-daemon.sh" + "command": "PASEO_SKIP_DEV_SERVER_BUILD=1 PASEO_LISTEN=0.0.0.0:$PASEO_PORT ./scripts/dev-daemon.sh" }, "app": { "type": "service", - "command": "cd packages/app && BROWSER=none EXPO_PUBLIC_LOCAL_DAEMON=\"localhost:${PASEO_SERVICE_DAEMON_PORT}\" npx expo start --port $PASEO_PORT" + "command": "BROWSER=none EXPO_PUBLIC_LOCAL_DAEMON=\"localhost:${PASEO_SERVICE_DAEMON_PORT}\" npm run start:expo --workspace=@getpaseo/app -- --port $PASEO_PORT" }, "typecheck": { "command": "npm run typecheck" diff --git a/scripts/clean-package-dist.mjs b/scripts/clean-package-dist.mjs new file mode 100644 index 000000000..bdf58d8ed --- /dev/null +++ b/scripts/clean-package-dist.mjs @@ -0,0 +1,17 @@ +import { rmSync } from "node:fs"; +import { basename, dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const packageRoot = resolve(process.cwd()); +const distPath = resolve(packageRoot, "dist"); + +if (packageRoot === repoRoot) { + throw new Error("Refusing to clean dist from the repository root"); +} + +if (dirname(distPath) !== packageRoot || basename(distPath) !== "dist") { + throw new Error(`Refusing to clean unexpected path: ${distPath}`); +} + +rmSync(distPath, { recursive: true, force: true }); diff --git a/scripts/dev-daemon.sh b/scripts/dev-daemon.sh index e737dd906..783ae9062 100755 --- a/scripts/dev-daemon.sh +++ b/scripts/dev-daemon.sh @@ -22,4 +22,8 @@ echo "════════════════════════ export PASEO_CORS_ORIGINS="${PASEO_CORS_ORIGINS:-*}" export PASEO_NODE_INSPECT="${PASEO_NODE_INSPECT:---inspect=0}" +if [ "${PASEO_SKIP_DEV_SERVER_BUILD:-0}" = "1" ]; then + exec npm run dev:server:watch +fi + exec npm run dev:server diff --git a/scripts/dev.sh b/scripts/dev.sh index 5e59e525d..127ed57d3 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -21,6 +21,9 @@ echo " Home: ${PASEO_HOME}" echo " Models: ${PASEO_LOCAL_MODELS_DIR}" echo "══════════════════════════════════════════════════════" +npm run build:server +npm run build --workspace=@getpaseo/expo-two-way-audio + # Configure the daemon for the Portless app origin and let the app bootstrap # through the daemon's Portless URL instead of a fixed localhost port. APP_ORIGIN="$(portless get app)" @@ -36,5 +39,5 @@ export PASEO_CORS_ORIGINS="*" concurrently \ --names "daemon,metro" \ --prefix-colors "cyan,magenta" \ - "portless run --name daemon sh -c 'PASEO_LISTEN=0.0.0.0:\$PORT exec ./scripts/dev-daemon.sh'" \ - "cd packages/app && BROWSER=none APP_VARIANT=development EXPO_PUBLIC_LOCAL_DAEMON='${DAEMON_ENDPOINT}' portless run --name app npx expo start" + "portless run --name daemon sh -c 'PASEO_SKIP_DEV_SERVER_BUILD=1 PASEO_LISTEN=0.0.0.0:\$PORT exec ./scripts/dev-daemon.sh'" \ + "BROWSER=none APP_VARIANT=development EXPO_PUBLIC_LOCAL_DAEMON='${DAEMON_ENDPOINT}' portless run --name app npm run start:expo --workspace=@getpaseo/app"