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
This commit is contained in:
Mohamed Boudra
2026-06-04 01:20:20 +08:00
committed by GitHub
parent 71ce96b913
commit 31ae545289
65 changed files with 3758 additions and 290 deletions
+11 -3
View File
@@ -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",
+137 -15
View File
@@ -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<typeof useShortcutKeys>;
}) {
return (
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>{label}</Text>
{shortcutKeys ? <Shortcut chord={shortcutKeys} /> : null}
</View>
);
}
function SidebarFooter({
theme,
activeServerId,
@@ -518,6 +542,7 @@ function MobileSidebar({
isInitialLoad,
isRevalidating,
isManualRefresh,
groupMode,
collapsedProjectKeys,
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
@@ -698,13 +723,16 @@ function MobileSidebar({
<GestureDetector gesture={closeGesture} touchAction="pan-y">
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
<View style={styles.sidebarContent} pointerEvents="auto">
<SidebarHeaderRow
icon={MessagesSquare}
label="Sessions"
onPress={handleViewMore}
isActive={isSessionsActive}
testID="sidebar-sessions"
/>
<View style={styles.sidebarHeaderRow}>
<SidebarHeaderRow
icon={MessagesSquare}
label="Sessions"
onPress={handleViewMore}
isActive={isSessionsActive}
testID="sidebar-sessions"
/>
</View>
<WorkspacesSectionHeader serverId={activeServerId} />
<Pressable
style={styles.mobileCloseButton}
onPress={closeToAgent}
@@ -733,6 +761,7 @@ function MobileSidebar({
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
projects={projects}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
@@ -777,6 +806,7 @@ function DesktopSidebar({
isInitialLoad,
isRevalidating,
isManualRefresh,
groupMode,
collapsedProjectKeys,
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
@@ -860,14 +890,17 @@ function DesktopSidebar({
<View style={styles.sidebarDragArea}>
<TitlebarDragRegion />
{padding.top > 0 ? <View style={paddingTopSpacerStyle} /> : null}
<SidebarHeaderRow
icon={MessagesSquare}
label="Sessions"
onPress={handleViewMore}
isActive={isSessionsActive}
testID="sidebar-sessions"
/>
<View style={styles.sidebarHeaderRow}>
<SidebarHeaderRow
icon={MessagesSquare}
label="Sessions"
onPress={handleViewMore}
isActive={isSessionsActive}
testID="sidebar-sessions"
/>
</View>
</View>
<WorkspacesSectionHeader serverId={activeServerId} />
{isInitialLoad ? (
<SidebarAgentListSkeleton />
@@ -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 (
<View style={styles.workspacesSectionHeader}>
<Text style={styles.workspacesSectionTitle}>Workspaces</Text>
<View style={styles.workspacesSectionActions}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel="Open command center"
testID="sidebar-command-center-search"
style={searchButtonStyle}
onPress={handleSearchPress}
>
{({ hovered, pressed }) => (
<Search
size={14}
color={
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
}
/>
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<HeaderIconTooltipContent label="Search" shortcutKeys={commandCenterKeys} />
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<View>
<SidebarGroupingSelector serverId={serverId} />
</View>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<HeaderIconTooltipContent label="Display preferences" />
</TooltipContent>
</Tooltip>
</View>
</View>
);
}
// 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,
@@ -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 ?? [],
@@ -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<string>;
onToggleProjectCollapsed: (projectKey: string) => void;
shortcutIndexByWorkspaceKey: Map<string, number>;
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 (
<View style={checksBadgeStyles.badge}>
<ThemedGitHubIcon size={10} uniProps={redColorMapping} />
<Text style={checksBadgeStyles.text}>{label}</Text>
</View>
);
}
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 (
<View style={styles.workspaceStatusDot}>
<ThemedActivityIndicator size={8} uniProps={foregroundMutedColorMapping} />
</View>
);
}
if (shouldShowSyncedLoader) {
return (
<View style={styles.workspaceStatusDot}>
<ThemedSyncedLoader size={11} uniProps={syncedLoaderColorMapping} />
</View>
);
}
if (bucket === "needs_input") {
return (
<View style={styles.workspaceStatusDot}>
<ThemedCircleAlert size={14} uniProps={amberColorMapping} />
</View>
);
}
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 (
<View style={styles.workspaceStatusDot}>
<KindIcon size={14} uniProps={foregroundMutedColorMapping} />
{dotColorStyle ? (
<StatusDotOverlay
dotColorStyle={dotColorStyle}
size={statusDotSize}
offset={statusDotOffset}
/>
) : null}
</View>
);
}
function StatusDotOverlay({
dotColorStyle,
size,
@@ -620,6 +551,9 @@ function ProjectRowTrailingActions({
const trash2LeadingIcon = <ThemedTrash2 size={14} uniProps={foregroundMutedColorMapping} />;
const settingsLeadingIcon = <ThemedSettings size={14} uniProps={foregroundMutedColorMapping} />;
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
const markAsReadLeadingIcon = (
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
);
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
@@ -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 (
<View style={styles.workspaceRowRight}>
<>
{showScriptsIcon ? (
<View testID="workspace-globe-icon" accessibilityLabel="Scripts available">
{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 ? (
<View style={styles.shortcutBadge}>
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
<View style={sidebarWorkspaceRowStyles.shortcutBadge}>
<Text style={sidebarWorkspaceRowStyles.shortcutBadgeText}>{shortcutNumber}</Text>
</View>
) : null}
</View>
</>
);
}
@@ -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
</DropdownMenuItem>
) : null}
{onMarkAsRead ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
leading={markAsReadLeadingIcon}
onSelect={onMarkAsRead}
>
Mark as read
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
leading={archiveLeadingIcon}
@@ -1404,9 +1352,7 @@ function WorkspaceRowInner({
archiveShortcutKeys,
}: WorkspaceRowInnerProps) {
const _isCompact = useIsCompactFormFactor();
const [isHovered, setIsHovered] = useState(false);
const isTouchPlatform = platformIsNative;
const prHint = workspace.prHint;
const interaction = useLongPressDragInteraction({
drag,
menuController,
@@ -1426,96 +1372,71 @@ function WorkspaceRowInner({
onPress();
}, [interaction.didLongPressRef, onPress]);
const handlePointerEnter = useCallback(() => 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 (
<WorkspaceHoverCard workspace={workspace} prHint={prHint} isDragging={isDragging}>
<View
{...dragAttributes}
{...dragHandleProps?.listeners}
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
style={styles.workspaceRowContainer}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
<Pressable
disabled={isArchiving}
aria-selected={selected}
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
onPressIn={interaction.handlePressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut}
onPress={handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
<View style={styles.workspaceRowMain}>
<View style={styles.workspaceRowLeft}>
<WorkspaceStatusIndicator
bucket={workspace.statusBucket}
workspaceKind={workspace.workspaceKind}
loading={isArchiving || isCreating}
/>
<Text style={workspaceBranchTextStyle} numberOfLines={1}>
{workspace.name}
</Text>
</View>
<WorkspaceRowRightGroup
workspace={workspace}
isHovered={isHovered}
isTouchPlatform={isTouchPlatform}
showScriptsIcon={showScriptsIcon}
hasRunningService={hasRunningService}
isCreating={isCreating}
showShortcutBadge={showShortcutBadge}
shortcutNumber={shortcutNumber}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
onArchive={onArchive}
onCopyBranchName={onCopyBranchName}
onCopyPath={onCopyPath}
onRename={onRename}
/>
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
{({ 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 (
<View
{...dragAttributes}
{...dragHandleProps?.listeners}
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
style={styles.workspaceRowContainer}
{...hoverHandlers}
>
<Pressable
disabled={isArchiving}
aria-selected={selected}
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
onPressIn={interaction.handlePressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut}
onPress={handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
<SidebarWorkspaceRowContent
workspace={workspace}
isHovered={isHovered}
isLoading={isArchiving || isCreating}
isCreating={isCreating}
>
<WorkspaceRowRightGroup
workspace={workspace}
isHovered={isHovered}
isTouchPlatform={isTouchPlatform}
showScriptsIcon={showScriptsIcon}
hasRunningService={hasRunningService}
isCreating={isCreating}
showShortcutBadge={showShortcutBadge}
shortcutNumber={shortcutNumber}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
onArchive={onArchive}
onCopyBranchName={onCopyBranchName}
onCopyPath={onCopyPath}
onRename={onRename}
/>
</SidebarWorkspaceRowContent>
</Pressable>
</View>
{prHint ? (
<View style={styles.workspacePrBadgeRow}>
<PrBadge hint={prHint} />
<ChecksBadge checks={prHint.checks} />
</View>
) : null}
</Pressable>
</View>
</WorkspaceHoverCard>
);
}}
</SidebarWorkspaceRowFrame>
);
}
@@ -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}
/>
<AdaptiveRenameModal
@@ -2429,6 +2360,7 @@ export function SidebarWorkspaceList({
collapsedProjectKeys,
onToggleProjectCollapsed,
shortcutIndexByWorkspaceKey,
groupMode,
isRefreshing: _isRefreshing = false,
onRefresh: _onRefresh,
onWorkspacePress,
@@ -2437,6 +2369,72 @@ export function SidebarWorkspaceList({
parentGestureRef,
}: SidebarWorkspaceListProps) {
const pathname = usePathname();
if (groupMode === "status") {
return (
<SidebarStatusModeWrapper
serverId={serverId}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
onWorkspacePress={onWorkspacePress}
/>
);
}
return (
<ProjectModeList
projects={projects}
serverId={serverId}
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={onToggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
onWorkspacePress={onWorkspacePress}
onAddProject={onAddProject}
listFooterComponent={listFooterComponent}
parentGestureRef={parentGestureRef}
pathname={pathname}
/>
);
}
function SidebarStatusModeWrapper({
serverId,
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
onWorkspacePress,
}: {
serverId: string | null;
shortcutIndexByWorkspaceKey: Map<string, number>;
onWorkspacePress?: () => void;
}) {
const hydratedWorkspaces = useHydratedWorkspaceEntries(serverId);
const projectNamesByKey = useProjectNamesMap(serverId);
const showShortcutBadges = useShowShortcutBadges();
return (
<SidebarStatusWorkspaceList
workspaces={hydratedWorkspaces}
projectNamesByKey={projectNamesByKey}
serverId={serverId}
shortcutIndexByWorkspaceKey={_projectShortcutIndex}
showShortcutBadges={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
/>
);
}
function ProjectModeList({
projects,
serverId,
collapsedProjectKeys,
onToggleProjectCollapsed,
shortcutIndexByWorkspaceKey,
onWorkspacePress,
onAddProject,
listFooterComponent,
parentGestureRef,
pathname,
}: Omit<SidebarWorkspaceListProps, "groupMode" | "isRefreshing" | "onRefresh"> & {
pathname: string;
}) {
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
new Map(),
@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger
style={triggerStyle}
accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel="Sidebar grouping"
testID="sidebar-grouping-selector"
>
<ThemedSettings2 size={14} uniProps={filterColorMapping} />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" width={180} testID="sidebar-grouping-menu">
<View style={styles.menuHeader}>
<Text style={styles.menuHeaderLabel}>Group by</Text>
</View>
{GROUP_MODE_ITEMS.map((item) => (
<GroupModeMenuItem
key={item.value}
item={item}
isSelected={groupMode === item.value}
onSelect={handleSelect}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
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 (
<DropdownMenuItem
testID={`sidebar-grouping-${item.value}`}
selected={isSelected}
onSelect={handleSelect}
>
{item.label}
</DropdownMenuItem>
);
}
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,
},
}));
@@ -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 = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
const markAsReadLeadingIcon = (
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
);
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
interface StatusWorkspaceListProps {
workspaces: SidebarWorkspaceEntry[];
projectNamesByKey: Map<string, string>;
serverId: string | null;
shortcutIndexByWorkspaceKey: Map<string, number>;
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<string, number>(),
[collapsedStatusGroupKeys, groups, showShortcutBadges],
);
return (
<View style={styles.container}>
{platformIsNative ? (
<NestableScrollContainer
style={styles.list}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
testID="sidebar-status-list-scroll"
>
<StatusGroupList
groups={groups}
collapsedStatusGroupKeys={collapsedStatusGroupKeys}
projectNamesByKey={projectNamesByKey}
serverId={serverId}
shortcutIndex={statusShortcutIndex}
showShortcutBadges={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
/>
</NestableScrollContainer>
) : (
<ScrollView
style={styles.list}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
testID="sidebar-status-list-scroll"
>
<StatusGroupList
groups={groups}
collapsedStatusGroupKeys={collapsedStatusGroupKeys}
projectNamesByKey={projectNamesByKey}
serverId={serverId}
shortcutIndex={statusShortcutIndex}
showShortcutBadges={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
/>
</ScrollView>
)}
</View>
);
}
function StatusGroupList({
groups,
collapsedStatusGroupKeys,
projectNamesByKey,
serverId,
shortcutIndex,
showShortcutBadges,
onWorkspacePress,
}: {
groups: StatusGroup[];
collapsedStatusGroupKeys: ReadonlySet<string>;
projectNamesByKey: Map<string, string>;
serverId: string | null;
shortcutIndex: Map<string, number>;
showShortcutBadges: boolean;
onWorkspacePress?: () => void;
}) {
return (
<>
{groups.map((group) => (
<View key={group.bucket} style={styles.statusGroupBlock}>
<StatusGroupHeader group={group} collapsed={collapsedStatusGroupKeys.has(group.bucket)} />
{!collapsedStatusGroupKeys.has(group.bucket) ? (
<View style={styles.statusWorkspaceListContainer}>
{group.rows.map((workspace) => (
<StatusWorkspaceRow
key={workspace.workspaceKey}
workspace={workspace}
projectName={projectNamesByKey.get(workspace.projectKey) ?? ""}
serverId={serverId}
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
/>
))}
</View>
) : null}
</View>
))}
</>
);
}
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 (
<View onPointerEnter={handleHoverIn} onPointerLeave={handleHoverOut}>
<Pressable
accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel={`${group.label} status group`}
accessibilityState={accessibilityState}
style={rowStyle}
onPress={handlePress}
testID={`sidebar-status-group-${group.bucket}`}
>
<View style={styles.statusGroupRowLeft}>
<View style={styles.statusGroupLeadingVisualSlot}>
<StatusGroupLeadingVisual
bucket={group.bucket}
collapsed={collapsed}
showChevron={isHovered}
/>
</View>
<View style={styles.statusGroupTitleGroup}>
<Text style={styles.statusGroupTitle} numberOfLines={1}>
{group.label}
</Text>
</View>
</View>
</Pressable>
</View>
);
}
function StatusGroupLeadingVisual({
bucket,
collapsed,
showChevron,
}: {
bucket: StatusGroup["bucket"];
collapsed: boolean;
showChevron: boolean;
}) {
if (!showChevron) {
return <StatusGroupIcon bucket={bucket} />;
}
if (collapsed) {
return <ThemedChevronRight size={14} uniProps={foregroundMutedColorMapping} />;
}
return <ThemedChevronDown size={14} uniProps={foregroundMutedColorMapping} />;
}
function StatusGroupIcon({ bucket }: { bucket: StatusGroup["bucket"] }) {
switch (bucket) {
case "needs_input":
return <ThemedCircleAlert size={14} uniProps={amberColorMapping} />;
case "failed":
return <ThemedCircleX size={14} uniProps={redColorMapping} />;
case "attention":
return <ThemedCircleCheck size={14} uniProps={greenColorMapping} />;
case "running":
return <ThemedCircleDot size={14} uniProps={blueColorMapping} />;
case "done":
return <ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />;
}
}
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 (
<StatusWorkspaceRowWithMenu
workspace={hydratedWorkspace}
projectName={projectName}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
onPress={handlePress}
/>
);
});
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 (
<>
<StatusWorkspaceRowInner
workspace={workspace}
projectName={projectName}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
onPress={onPress}
isArchiving={isArchiving}
archiveLabel={isWorktree ? "Archive worktree" : "Hide from sidebar"}
archiveStatus={computedArchiveStatus}
archivePendingLabel={isWorktree ? "Archiving..." : "Hiding..."}
onArchive={isWorktree ? archiveWorktreeAfterConfirmation : hideWorkspaceAfterConfirmation}
onCopyBranchName={workspace.projectKind === "git" ? handleCopyBranchName : undefined}
onCopyPath={handleCopyPath}
onRename={workspace.projectKind === "git" ? handleOpenRename : undefined}
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
/>
<AdaptiveRenameModal
visible={isRenameOpen}
title="Rename workspace"
initialValue={workspace.name}
placeholder="branch-name"
submitLabel="Rename"
validate={validateRenameSlug}
maxLength={MAX_SLUG_LENGTH}
onClose={handleCloseRename}
onSubmit={handleSubmitRename}
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
/>
</>
);
}
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 (
<SidebarWorkspaceRowFrame workspace={workspace}>
{({ isHovered, hoverHandlers }) => {
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
const workspaceRowStyle = getStatusWorkspaceRowStyle({ selected, isHovered });
return (
<View style={styles.workspaceRowContainer} {...hoverHandlers}>
<Pressable
disabled={isArchiving}
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
onPress={onPress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
<SidebarWorkspaceRowContent
workspace={workspace}
subtitle={projectName}
isHovered={isHovered}
isLoading={isArchiving}
>
<>
{showScriptsIcon ? (
<View accessibilityLabel="Scripts available">
{hasRunningService ? (
<ThemedGlobe size={12} uniProps={blueColorMapping} />
) : (
<ThemedSquareTerminal size={12} uniProps={blueColorMapping} />
)}
</View>
) : null}
{showKebab && onArchive ? (
<StatusKebabMenu
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
/>
) : null}
{!showKebab && workspace.diffStat ? (
<DiffStat
additions={workspace.diffStat.additions}
deletions={workspace.diffStat.deletions}
/>
) : null}
{showShortcutBadge && shortcutNumber !== null ? (
<View style={sidebarWorkspaceRowStyles.shortcutBadge}>
<Text style={sidebarWorkspaceRowStyles.shortcutBadgeText}>
{shortcutNumber}
</Text>
</View>
) : null}
</>
</SidebarWorkspaceRowContent>
</Pressable>
</View>
);
}}
</SidebarWorkspaceRowFrame>
);
}
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 ? <Shortcut chord={archiveShortcutKeys} /> : null),
[archiveShortcutKeys],
);
return (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={kebabStyle}
accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel="Workspace actions"
testID={`sidebar-workspace-kebab-${workspaceKey}`}
>
{({ hovered }: { hovered?: boolean }) => (
<ThemedMoreVertical
size={14}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260}>
{onCopyPath ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
leading={copyLeadingIcon}
onSelect={onCopyPath}
>
Copy path
</DropdownMenuItem>
) : null}
{onCopyBranchName ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
leading={copyLeadingIcon}
onSelect={onCopyBranchName}
>
Copy branch name
</DropdownMenuItem>
) : null}
{onRename ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
leading={renameLeadingIcon}
onSelect={onRename}
>
Rename workspace
</DropdownMenuItem>
) : null}
{onMarkAsRead ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
leading={markAsReadLeadingIcon}
onSelect={onMarkAsRead}
>
Mark as read
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
leading={archiveLeadingIcon}
trailing={archiveTrailing}
status={archiveStatus}
pendingLabel={archivePendingLabel}
onSelect={onArchive}
>
{archiveLabel ?? "Archive"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
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,
},
}));
@@ -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 (
<WorkspaceHoverCard workspace={workspace} prHint={workspace.prHint} isDragging={isDragging}>
{children({ isHovered, hoverHandlers })}
</WorkspaceHoverCard>
);
}
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 (
<>
<View style={styles.workspaceRowMain}>
<View style={styles.workspaceRowLeft}>
<WorkspaceStatusIndicator
bucket={workspace.statusBucket}
workspaceKind={workspace.workspaceKind}
loading={isLoading}
/>
<Text style={workspaceBranchTextStyle} numberOfLines={1}>
{workspace.name}
</Text>
</View>
<View style={styles.workspaceRowRight}>{children}</View>
</View>
{subtitle ? (
<Text style={styles.workspaceSubtitle} numberOfLines={1}>
{subtitle}
</Text>
) : null}
{workspace.prHint ? (
<View style={styles.workspacePrBadgeRow}>
<PrBadge hint={workspace.prHint} />
<ChecksBadge checks={workspace.prHint.checks} />
</View>
) : null}
</>
);
});
function WorkspaceStatusIndicator({
bucket,
workspaceKind,
loading = false,
}: {
bucket: SidebarWorkspaceEntry["statusBucket"];
workspaceKind: SidebarWorkspaceEntry["workspaceKind"];
loading?: boolean;
}) {
const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket });
if (loading) {
return (
<View style={styles.workspaceStatusDot}>
<ThemedActivityIndicator size={8} uniProps={foregroundMutedColorMapping} />
</View>
);
}
if (shouldShowSyncedLoader) {
return (
<View style={styles.workspaceStatusDot}>
<ThemedSyncedLoader size={11} uniProps={syncedLoaderColorMapping} />
</View>
);
}
if (bucket === "needs_input") {
return (
<View style={styles.workspaceStatusDot}>
<ThemedCircleAlert size={14} uniProps={amberColorMapping} />
</View>
);
}
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 (
<View style={styles.workspaceStatusDot}>
<KindIcon size={14} uniProps={foregroundMutedColorMapping} />
{dotColorStyle ? (
<StatusDotOverlay
dotColorStyle={dotColorStyle}
size={statusDotSize}
offset={statusDotOffset}
/>
) : null}
</View>
);
}
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 <View style={overlayStyle} />;
}
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 (
<Pressable
accessibilityRole="link"
accessibilityLabel={`Pull request #${hint.number}`}
hitSlop={4}
onPressIn={handlePressIn}
onPress={handlePress}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
style={pressableStyle}
>
{isHovered ? (
<ThemedExternalLink size={12} uniProps={iconUniProps} />
) : (
<ThemedGitPullRequest size={12} uniProps={iconUniProps} />
)}
<Text style={textStyle} numberOfLines={1}>
#{hint.number}
</Text>
</Pressable>
);
}
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 (
<View style={checksBadgeStyles.badge}>
<ThemedGitHubIcon size={10} uniProps={redColorMapping} />
<Text style={checksBadgeStyles.text}>{failed} failed</Text>
</View>
);
}
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,
},
}));
@@ -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(<WorkspaceShortcutTargetsSubscriber enabled={true} serverId="srv" />);
});
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(<WorkspaceShortcutTargetsSubscriber enabled={true} serverId="srv" />);
@@ -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) {
@@ -19,6 +19,7 @@ function workspace(input: {
name: "main",
status: "running",
archivingAt: null,
statusEnteredAt: null,
diffStat: null,
scripts: input.scripts ?? [],
};
@@ -18,6 +18,7 @@ const baseWorkspace: WorkspaceDescriptor = {
name: "feature",
status: "done",
archivingAt: "2026-04-30T00:00:00.000Z",
statusEnteredAt: null,
diffStat: null,
scripts: [],
};
@@ -43,6 +43,7 @@ function workspace(input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescripto
name: input.name ?? input.id,
status: input.status ?? "done",
archivingAt: input.archivingAt ?? null,
statusEnteredAt: null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
} satisfies WorkspaceDescriptor;
@@ -0,0 +1,213 @@
import { describe, expect, it } from "vitest";
import type { SidebarWorkspaceEntry } from "./sidebar-workspaces-view-model";
import {
buildStatusGroups,
buildStatusShortcutIndex,
STATUS_BUCKET_LABELS,
STATUS_BUCKET_ORDER,
type StatusGroup,
} from "./sidebar-status-view-model";
function ws(
input: Partial<SidebarWorkspaceEntry> & { 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<string, string>();
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<string, string>([
["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);
});
});
@@ -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<StatusBucket, string> = {
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<string, string>,
): StatusGroup[] {
const bucketRows = new Map<StatusBucket, SidebarWorkspaceEntry[]>();
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<string, string>,
): 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<string, number> {
const index = new Map<string, number>();
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;
}
@@ -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,
@@ -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<void>;
}
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],
);
}
@@ -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<ActiveWorkspaceSelection | null>(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,
},
@@ -17,6 +17,7 @@ function buildWorkspacePayload() {
name: "project",
archivingAt: null,
status: "done" as const,
statusEnteredAt: null,
activityAt: null,
diffStat: null,
scripts: [],
@@ -84,6 +84,7 @@ function workspace(input: {
name: input.id,
archivingAt: null,
status: "done",
statusEnteredAt: null,
activityAt: null,
diffStat: null,
scripts: [],
@@ -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),
@@ -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<string, string> {
const workspaces = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.workspaces : undefined,
);
return useMemo(() => {
const map = new Map<string, string>();
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]);
}
@@ -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",
@@ -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<ShortcutAction>({
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<ShortcutAction>({
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<ShortcutAction>({
kind: "navigate-workspace",
serverId: "srv",
workspaceId: "running-new",
});
});
it("falls back to the pathname workspace when no retained selection exists", () => {
expect(
routeKeyboardShortcut(
@@ -16,6 +16,7 @@ function createWorkspaceDescriptor(): WorkspaceDescriptor {
diffStat: null,
scripts: [],
archivingAt: null,
statusEnteredAt: null,
};
}
@@ -25,6 +25,7 @@ function createWorkspaceDescriptor(input: Partial<WorkspaceDescriptor> = {}): Wo
status: "running",
diffStat: null,
scripts: [],
statusEnteredAt: null,
...input,
archivingAt: input.archivingAt ?? null,
};
@@ -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 ?? [],
};
@@ -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<WorkspaceDescriptorPayload, "statusEnteredAt" | "archivingAt">;
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: [],
+7
View File
@@ -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)),
@@ -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<SidebarCollapsedSectionsState>()(
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",
@@ -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", () => {
@@ -1,9 +1,11 @@
export interface CollapsedProjectsState {
collapsedProjectKeys: Set<string>;
collapsedStatusGroupKeys: Set<string>;
}
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<S extends CollapsedProjectsState>(
@@ -44,16 +63,24 @@ export function mergePersistedCollapsedProjects<S extends CollapsedProjectsState
current: S,
): S {
if (!persisted?.collapsedProjectKeys) {
if (!persisted?.collapsedStatusGroupKeys) return current;
}
const restoredProjects = deserializeCollapsedKeys(persisted.collapsedProjectKeys);
const restoredStatusGroups = deserializeCollapsedKeys(persisted.collapsedStatusGroupKeys);
if (
areSetsEqual(current.collapsedProjectKeys, restoredProjects) &&
areSetsEqual(current.collapsedStatusGroupKeys, restoredStatusGroups)
) {
return current;
}
const restored = deserializeCollapsedProjectKeys(persisted.collapsedProjectKeys);
if (areSetsEqual(current.collapsedProjectKeys, restored)) {
return current;
}
return { ...current, collapsedProjectKeys: restored };
return {
...current,
collapsedProjectKeys: restoredProjects,
collapsedStatusGroupKeys: restoredStatusGroups,
};
}
function deserializeCollapsedProjectKeys(value: unknown): Set<string> {
function deserializeCollapsedKeys(value: unknown): Set<string> {
if (!Array.isArray(value)) {
return new Set();
}
@@ -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<string, SidebarGroupMode>;
getGroupMode: (serverId: string) => SidebarGroupMode;
setGroupMode: (serverId: string, mode: SidebarGroupMode) => void;
}
export const useSidebarViewStore = create<SidebarViewStoreState>()(
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,
}),
},
),
);
@@ -23,6 +23,7 @@ function createWorkspace(): WorkspaceDescriptor {
name: "worktree",
status: "done",
archivingAt: null,
statusEnteredAt: null,
diffStat: null,
scripts: [],
};
+1
View File
@@ -44,6 +44,7 @@ function workspace(input: {
name: input.id,
status: "done",
archivingAt: null,
statusEnteredAt: null,
diffStat: null,
scripts: [],
gitRuntime: {
@@ -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");
});
});
@@ -25,6 +25,7 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorks
archiveUnpushedCommitCount: null,
scripts: [],
hasRunningScripts: false,
statusEnteredAt: null,
...overrides,
archivingAt: overrides.archivingAt ?? null,
};
@@ -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" },
+27 -1
View File
@@ -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<string, string>;
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<string, number>();
for (const group of groups) {
for (const workspace of group.rows) {
if (shortcutTargets.length >= maxShortcuts) {
break;
}
const shortcutNumber = shortcutTargets.length + 1;
@@ -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 ?? [],
};
@@ -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 ?? [],
};
@@ -39,6 +39,7 @@ function workspace(input?: Partial<WorkspaceDescriptor>): WorkspaceDescriptor {
name: "workspace-1",
status: "done",
archivingAt: null,
statusEnteredAt: null,
diffStat: null,
scripts: [],
...input,
+4 -2
View File
@@ -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",
+4 -2
View File
@@ -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"
+27
View File
@@ -1522,6 +1522,33 @@ export class DaemonClient {
});
}
async clearWorkspaceAttention(workspaceId: string | string[]): Promise<void> {
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;
+1
View File
@@ -125,6 +125,7 @@ function createWorkspace(input: Partial<PaseoWorkspace> = {}): PaseoWorkspace {
name: "sdk",
archivingAt: null,
status: "done",
statusEnteredAt: null,
activityAt: "2026-05-16T00:00:00.000Z",
scripts: [],
gitRuntime: null,
+1 -1
View File
@@ -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",
+18 -1
View File
@@ -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)"
+1 -1
View File
@@ -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}"
+4 -2
View File
@@ -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"
},
+4 -2
View File
@@ -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"
},
@@ -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");
});
});
+2 -1
View File
@@ -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) {
+36
View File
@@ -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<typeof LegacyOpenInEditorRequestSchema>;
export type OpenProjectRequest = z.infer<typeof OpenProjectRequestSchema>;
export type ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSchema>;
export type WorkspaceClearAttentionRequest = z.infer<typeof WorkspaceClearAttentionRequestSchema>;
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
export type FileExplorerResponse = z.infer<typeof FileExplorerResponseSchema>;
export type ProjectIconRequest = z.infer<typeof ProjectIconRequestSchema>;
@@ -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",
+4 -2
View File
@@ -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"
+4 -2
View File
@@ -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",
+195 -25
View File
@@ -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<string, number>;
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<string, number>();
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<void> {
try {
const workspace = await this.findOrCreateWorkspaceForDirectory(cwd);
@@ -7329,6 +7365,140 @@ export class Session {
}
}
private async handleWorkspaceClearAttentionRequest(
request: Extract<SessionInboundMessage, { type: "workspace.clear_attention.request" }>,
): Promise<void> {
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<void> {
const resolved = await this.resolveAgentIdentifier(agentIdOrIdentifier);
if (!resolved.ok) {
@@ -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<unknown[]>;
get(agentId: string): Promise<unknown>;
upsert(record: unknown): Promise<void>;
};
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",
@@ -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);
});
});
@@ -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;
}
@@ -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<FetchWorkspace
export class WorkspaceDirectory {
private readonly archivingByWorkspaceId = new Map<string, string>();
/**
* 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<string, WorkspaceBucketHistoryEntry>();
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);
+3 -2
View File
@@ -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"
+17
View File
@@ -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 });
+4
View File
@@ -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
+5 -2
View File
@@ -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"