mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
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:
+11
-3
@@ -36,7 +36,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "./scripts/dev.sh",
|
"dev": "./scripts/dev.sh",
|
||||||
"dev:win": "powershell ./scripts/dev.ps1",
|
"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:server:raw": "npm run dev --workspace=@getpaseo/server",
|
||||||
"dev:app": "npm run start --workspace=@getpaseo/app",
|
"dev:app": "npm run start --workspace=@getpaseo/app",
|
||||||
"dev:website": "npm run dev --workspace=@getpaseo/website",
|
"dev:website": "npm run dev --workspace=@getpaseo/website",
|
||||||
@@ -44,12 +45,19 @@
|
|||||||
"prepare": "lefthook install --force",
|
"prepare": "lefthook install --force",
|
||||||
"build": "npm run build --workspaces --if-present",
|
"build": "npm run build --workspaces --if-present",
|
||||||
"build:highlight": "npm run build --workspace=@getpaseo/highlight",
|
"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": "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": "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": "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": "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": "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": "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:protocol": "tsc -p packages/protocol/tsconfig.json --watch --preserveWatchOutput",
|
||||||
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
|
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
|
||||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||||
@@ -76,7 +84,7 @@
|
|||||||
"web": "npm run web --workspace=@getpaseo/app",
|
"web": "npm run web --workspace=@getpaseo/app",
|
||||||
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
|
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
|
||||||
"dev:win:desktop": "npm run dev:win --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 --",
|
"db:query": "npm run db:query --workspace=@getpaseo/server --",
|
||||||
"cli": "npx tsx packages/cli/src/index.js",
|
"cli": "npx tsx packages/cli/src/index.js",
|
||||||
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
"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: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:beta:next": "node scripts/set-release-version.mjs --mode beta-next",
|
||||||
"version:all:promote": "node scripts/set-release-version.mjs --mode promote",
|
"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: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: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",
|
"release:push": "node scripts/push-current-release-tag.mjs",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { router, usePathname } from "expo-router";
|
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 {
|
import {
|
||||||
type Dispatch,
|
type Dispatch,
|
||||||
memo,
|
memo,
|
||||||
@@ -34,6 +34,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
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 { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
@@ -47,6 +48,8 @@ import {
|
|||||||
type SidebarProjectEntry,
|
type SidebarProjectEntry,
|
||||||
useSidebarWorkspacesList,
|
useSidebarWorkspacesList,
|
||||||
} from "@/hooks/use-sidebar-workspaces-list";
|
} 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 { useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
|
||||||
import {
|
import {
|
||||||
MAX_SIDEBAR_WIDTH,
|
MAX_SIDEBAR_WIDTH,
|
||||||
@@ -89,6 +92,7 @@ interface SidebarSharedProps {
|
|||||||
isInitialLoad: boolean;
|
isInitialLoad: boolean;
|
||||||
isRevalidating: boolean;
|
isRevalidating: boolean;
|
||||||
isManualRefresh: boolean;
|
isManualRefresh: boolean;
|
||||||
|
groupMode: SidebarGroupMode;
|
||||||
collapsedProjectKeys: SidebarShortcutModel["collapsedProjectKeys"];
|
collapsedProjectKeys: SidebarShortcutModel["collapsedProjectKeys"];
|
||||||
shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"];
|
shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"];
|
||||||
toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"];
|
toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"];
|
||||||
@@ -192,6 +196,10 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
|
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
|
||||||
useSidebarShortcutModel({ projects, isInitialLoad });
|
useSidebarShortcutModel({ projects, isInitialLoad });
|
||||||
|
|
||||||
|
const groupMode = useSidebarViewStore((state) =>
|
||||||
|
activeServerId ? state.getGroupMode(activeServerId) : "project",
|
||||||
|
);
|
||||||
|
|
||||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
@@ -268,6 +276,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
isInitialLoad,
|
isInitialLoad,
|
||||||
isRevalidating,
|
isRevalidating,
|
||||||
isManualRefresh,
|
isManualRefresh,
|
||||||
|
groupMode,
|
||||||
collapsedProjectKeys,
|
collapsedProjectKeys,
|
||||||
shortcutIndexByWorkspaceKey,
|
shortcutIndexByWorkspaceKey,
|
||||||
toggleProjectCollapsed,
|
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({
|
function SidebarFooter({
|
||||||
theme,
|
theme,
|
||||||
activeServerId,
|
activeServerId,
|
||||||
@@ -518,6 +542,7 @@ function MobileSidebar({
|
|||||||
isInitialLoad,
|
isInitialLoad,
|
||||||
isRevalidating,
|
isRevalidating,
|
||||||
isManualRefresh,
|
isManualRefresh,
|
||||||
|
groupMode,
|
||||||
collapsedProjectKeys,
|
collapsedProjectKeys,
|
||||||
shortcutIndexByWorkspaceKey,
|
shortcutIndexByWorkspaceKey,
|
||||||
toggleProjectCollapsed,
|
toggleProjectCollapsed,
|
||||||
@@ -698,13 +723,16 @@ function MobileSidebar({
|
|||||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||||
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
|
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
|
||||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||||
<SidebarHeaderRow
|
<View style={styles.sidebarHeaderRow}>
|
||||||
icon={MessagesSquare}
|
<SidebarHeaderRow
|
||||||
label="Sessions"
|
icon={MessagesSquare}
|
||||||
onPress={handleViewMore}
|
label="Sessions"
|
||||||
isActive={isSessionsActive}
|
onPress={handleViewMore}
|
||||||
testID="sidebar-sessions"
|
isActive={isSessionsActive}
|
||||||
/>
|
testID="sidebar-sessions"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<WorkspacesSectionHeader serverId={activeServerId} />
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.mobileCloseButton}
|
style={styles.mobileCloseButton}
|
||||||
onPress={closeToAgent}
|
onPress={closeToAgent}
|
||||||
@@ -733,6 +761,7 @@ function MobileSidebar({
|
|||||||
collapsedProjectKeys={collapsedProjectKeys}
|
collapsedProjectKeys={collapsedProjectKeys}
|
||||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||||
|
groupMode={groupMode}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
isRefreshing={isManualRefresh && isRevalidating}
|
isRefreshing={isManualRefresh && isRevalidating}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
@@ -777,6 +806,7 @@ function DesktopSidebar({
|
|||||||
isInitialLoad,
|
isInitialLoad,
|
||||||
isRevalidating,
|
isRevalidating,
|
||||||
isManualRefresh,
|
isManualRefresh,
|
||||||
|
groupMode,
|
||||||
collapsedProjectKeys,
|
collapsedProjectKeys,
|
||||||
shortcutIndexByWorkspaceKey,
|
shortcutIndexByWorkspaceKey,
|
||||||
toggleProjectCollapsed,
|
toggleProjectCollapsed,
|
||||||
@@ -860,14 +890,17 @@ function DesktopSidebar({
|
|||||||
<View style={styles.sidebarDragArea}>
|
<View style={styles.sidebarDragArea}>
|
||||||
<TitlebarDragRegion />
|
<TitlebarDragRegion />
|
||||||
{padding.top > 0 ? <View style={paddingTopSpacerStyle} /> : null}
|
{padding.top > 0 ? <View style={paddingTopSpacerStyle} /> : null}
|
||||||
<SidebarHeaderRow
|
<View style={styles.sidebarHeaderRow}>
|
||||||
icon={MessagesSquare}
|
<SidebarHeaderRow
|
||||||
label="Sessions"
|
icon={MessagesSquare}
|
||||||
onPress={handleViewMore}
|
label="Sessions"
|
||||||
isActive={isSessionsActive}
|
onPress={handleViewMore}
|
||||||
testID="sidebar-sessions"
|
isActive={isSessionsActive}
|
||||||
/>
|
testID="sidebar-sessions"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
<WorkspacesSectionHeader serverId={activeServerId} />
|
||||||
|
|
||||||
{isInitialLoad ? (
|
{isInitialLoad ? (
|
||||||
<SidebarAgentListSkeleton />
|
<SidebarAgentListSkeleton />
|
||||||
@@ -877,6 +910,7 @@ function DesktopSidebar({
|
|||||||
collapsedProjectKeys={collapsedProjectKeys}
|
collapsedProjectKeys={collapsedProjectKeys}
|
||||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||||
|
groupMode={groupMode}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
isRefreshing={isManualRefresh && isRevalidating}
|
isRefreshing={isManualRefresh && isRevalidating}
|
||||||
onRefresh={handleRefresh}
|
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
|
// 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
|
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
|
||||||
// tries to patch the native node that Reanimated also manages.
|
// tries to patch the native node that Reanimated also manages.
|
||||||
@@ -932,6 +1021,39 @@ const staticStyles = RNStyleSheet.create({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
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: {
|
sidebarContent: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ function workspace(input: {
|
|||||||
workspaceKind: input.name === "main" ? "local_checkout" : "worktree",
|
workspaceKind: input.name === "main" ? "local_checkout" : "worktree",
|
||||||
name: input.name,
|
name: input.name,
|
||||||
status: input.status ?? "done",
|
status: input.status ?? "done",
|
||||||
|
statusEnteredAt: null,
|
||||||
archivingAt: null,
|
archivingAt: null,
|
||||||
diffStat: null,
|
diffStat: null,
|
||||||
scripts: input.scripts ?? [],
|
scripts: input.scripts ?? [],
|
||||||
|
|||||||
@@ -41,17 +41,16 @@ import { DiffStat } from "@/components/diff-stat";
|
|||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
CircleAlert,
|
CircleAlert,
|
||||||
|
CircleCheck,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Copy,
|
Copy,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
FolderGit2,
|
|
||||||
GitPullRequest,
|
GitPullRequest,
|
||||||
Globe,
|
Globe,
|
||||||
Settings,
|
Settings,
|
||||||
SquareTerminal,
|
SquareTerminal,
|
||||||
Monitor,
|
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -98,12 +97,23 @@ import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-disp
|
|||||||
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
||||||
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
|
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
|
||||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
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 type { PrHint } from "@/git/use-pr-status-query";
|
||||||
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
|
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
|
||||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
@@ -119,8 +129,6 @@ import {
|
|||||||
archiveWorkspaceOptimistically,
|
archiveWorkspaceOptimistically,
|
||||||
archiveWorkspacesOptimistically,
|
archiveWorkspacesOptimistically,
|
||||||
} from "@/workspace/workspace-archive";
|
} 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";
|
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
|
||||||
|
|
||||||
const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey;
|
const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey;
|
||||||
@@ -134,12 +142,10 @@ const DEFAULT_STATUS_DOT_OFFSET = 0;
|
|||||||
const EMPHASIZED_STATUS_DOT_OFFSET = -1;
|
const EMPHASIZED_STATUS_DOT_OFFSET = -1;
|
||||||
const ThemedExternalLink = withUnistyles(ExternalLink);
|
const ThemedExternalLink = withUnistyles(ExternalLink);
|
||||||
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
||||||
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
|
|
||||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||||
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
||||||
|
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||||
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
|
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
|
||||||
const ThemedMonitor = withUnistyles(Monitor);
|
|
||||||
const ThemedFolderGit2 = withUnistyles(FolderGit2);
|
|
||||||
const ThemedFolderPlus = withUnistyles(FolderPlus);
|
const ThemedFolderPlus = withUnistyles(FolderPlus);
|
||||||
const ThemedGlobe = withUnistyles(Globe);
|
const ThemedGlobe = withUnistyles(Globe);
|
||||||
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
||||||
@@ -229,6 +235,7 @@ interface SidebarWorkspaceListProps {
|
|||||||
collapsedProjectKeys: ReadonlySet<string>;
|
collapsedProjectKeys: ReadonlySet<string>;
|
||||||
onToggleProjectCollapsed: (projectKey: string) => void;
|
onToggleProjectCollapsed: (projectKey: string) => void;
|
||||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||||
|
groupMode: "project" | "status";
|
||||||
isRefreshing?: boolean;
|
isRefreshing?: boolean;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
onWorkspacePress?: () => void;
|
onWorkspacePress?: () => void;
|
||||||
@@ -281,6 +288,7 @@ interface WorkspaceRowInnerProps {
|
|||||||
onCopyBranchName?: () => void;
|
onCopyBranchName?: () => void;
|
||||||
onCopyPath?: () => void;
|
onCopyPath?: () => void;
|
||||||
onRename?: () => void;
|
onRename?: () => void;
|
||||||
|
onMarkAsRead?: () => void;
|
||||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,7 +302,7 @@ function getWorkspaceArchiveStatus(
|
|||||||
return "idle";
|
return "idle";
|
||||||
}
|
}
|
||||||
|
|
||||||
function useSidebarWorkspaceEntry(
|
export function useSidebarWorkspaceEntry(
|
||||||
serverId: string | null,
|
serverId: string | null,
|
||||||
workspaceId: string | null,
|
workspaceId: string | null,
|
||||||
): SidebarWorkspaceEntry | null {
|
): SidebarWorkspaceEntry | null {
|
||||||
@@ -367,6 +375,23 @@ function workspaceKebabStyle({
|
|||||||
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
|
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() {}
|
function noop() {}
|
||||||
|
|
||||||
const prBadgeStyles = StyleSheet.create((theme) => ({
|
const prBadgeStyles = StyleSheet.create((theme) => ({
|
||||||
@@ -391,100 +416,6 @@ const prBadgeStyles = StyleSheet.create((theme) => ({
|
|||||||
|
|
||||||
const prBadgeTextHoveredCombined = [prBadgeStyles.text, prBadgeStyles.textHovered];
|
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({
|
function StatusDotOverlay({
|
||||||
dotColorStyle,
|
dotColorStyle,
|
||||||
size,
|
size,
|
||||||
@@ -620,6 +551,9 @@ function ProjectRowTrailingActions({
|
|||||||
const trash2LeadingIcon = <ThemedTrash2 size={14} uniProps={foregroundMutedColorMapping} />;
|
const trash2LeadingIcon = <ThemedTrash2 size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
const settingsLeadingIcon = <ThemedSettings size={14} uniProps={foregroundMutedColorMapping} />;
|
const settingsLeadingIcon = <ThemedSettings size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
const copyLeadingIcon = <ThemedCopy 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 archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
|
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
|
|
||||||
@@ -695,6 +629,7 @@ function WorkspaceRowRightGroup({
|
|||||||
archivePendingLabel,
|
archivePendingLabel,
|
||||||
archiveShortcutKeys,
|
archiveShortcutKeys,
|
||||||
onArchive,
|
onArchive,
|
||||||
|
onMarkAsRead,
|
||||||
onCopyBranchName,
|
onCopyBranchName,
|
||||||
onCopyPath,
|
onCopyPath,
|
||||||
onRename,
|
onRename,
|
||||||
@@ -712,13 +647,14 @@ function WorkspaceRowRightGroup({
|
|||||||
archivePendingLabel?: string;
|
archivePendingLabel?: string;
|
||||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||||
onArchive?: () => void;
|
onArchive?: () => void;
|
||||||
|
onMarkAsRead?: () => void;
|
||||||
onCopyBranchName?: () => void;
|
onCopyBranchName?: () => void;
|
||||||
onCopyPath?: () => void;
|
onCopyPath?: () => void;
|
||||||
onRename?: () => void;
|
onRename?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
|
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
|
||||||
return (
|
return (
|
||||||
<View style={styles.workspaceRowRight}>
|
<>
|
||||||
{showScriptsIcon ? (
|
{showScriptsIcon ? (
|
||||||
<View testID="workspace-globe-icon" accessibilityLabel="Scripts available">
|
<View testID="workspace-globe-icon" accessibilityLabel="Scripts available">
|
||||||
{hasRunningService ? (
|
{hasRunningService ? (
|
||||||
@@ -735,6 +671,7 @@ function WorkspaceRowRightGroup({
|
|||||||
onCopyPath={onCopyPath}
|
onCopyPath={onCopyPath}
|
||||||
onCopyBranchName={onCopyBranchName}
|
onCopyBranchName={onCopyBranchName}
|
||||||
onRename={onRename}
|
onRename={onRename}
|
||||||
|
onMarkAsRead={onMarkAsRead}
|
||||||
onArchive={onArchive}
|
onArchive={onArchive}
|
||||||
archiveLabel={archiveLabel}
|
archiveLabel={archiveLabel}
|
||||||
archiveStatus={archiveStatus}
|
archiveStatus={archiveStatus}
|
||||||
@@ -749,11 +686,11 @@ function WorkspaceRowRightGroup({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{showShortcutBadge && shortcutNumber !== null ? (
|
{showShortcutBadge && shortcutNumber !== null ? (
|
||||||
<View style={styles.shortcutBadge}>
|
<View style={sidebarWorkspaceRowStyles.shortcutBadge}>
|
||||||
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
|
<Text style={sidebarWorkspaceRowStyles.shortcutBadgeText}>{shortcutNumber}</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -762,6 +699,7 @@ function WorkspaceKebabMenu({
|
|||||||
onCopyPath,
|
onCopyPath,
|
||||||
onCopyBranchName,
|
onCopyBranchName,
|
||||||
onRename,
|
onRename,
|
||||||
|
onMarkAsRead,
|
||||||
onArchive,
|
onArchive,
|
||||||
archiveLabel,
|
archiveLabel,
|
||||||
archiveStatus,
|
archiveStatus,
|
||||||
@@ -772,6 +710,7 @@ function WorkspaceKebabMenu({
|
|||||||
onCopyPath?: () => void;
|
onCopyPath?: () => void;
|
||||||
onCopyBranchName?: () => void;
|
onCopyBranchName?: () => void;
|
||||||
onRename?: () => void;
|
onRename?: () => void;
|
||||||
|
onMarkAsRead?: () => void;
|
||||||
onArchive: () => void;
|
onArchive: () => void;
|
||||||
archiveLabel?: string;
|
archiveLabel?: string;
|
||||||
archiveStatus?: "idle" | "pending" | "success";
|
archiveStatus?: "idle" | "pending" | "success";
|
||||||
@@ -821,6 +760,15 @@ function WorkspaceKebabMenu({
|
|||||||
Rename workspace
|
Rename workspace
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
|
{onMarkAsRead ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
|
||||||
|
leading={markAsReadLeadingIcon}
|
||||||
|
onSelect={onMarkAsRead}
|
||||||
|
>
|
||||||
|
Mark as read
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
|
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
|
||||||
leading={archiveLeadingIcon}
|
leading={archiveLeadingIcon}
|
||||||
@@ -1404,9 +1352,7 @@ function WorkspaceRowInner({
|
|||||||
archiveShortcutKeys,
|
archiveShortcutKeys,
|
||||||
}: WorkspaceRowInnerProps) {
|
}: WorkspaceRowInnerProps) {
|
||||||
const _isCompact = useIsCompactFormFactor();
|
const _isCompact = useIsCompactFormFactor();
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
|
||||||
const isTouchPlatform = platformIsNative;
|
const isTouchPlatform = platformIsNative;
|
||||||
const prHint = workspace.prHint;
|
|
||||||
const interaction = useLongPressDragInteraction({
|
const interaction = useLongPressDragInteraction({
|
||||||
drag,
|
drag,
|
||||||
menuController,
|
menuController,
|
||||||
@@ -1426,96 +1372,71 @@ function WorkspaceRowInner({
|
|||||||
onPress();
|
onPress();
|
||||||
}, [interaction.didLongPressRef, 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 accessibilityState = useMemo(() => ({ selected }), [selected]);
|
||||||
const workspaceBranchTextStyle = useMemo(
|
|
||||||
() => [
|
|
||||||
styles.workspaceBranchText,
|
|
||||||
isHovered && styles.workspaceBranchTextHovered,
|
|
||||||
isCreating && styles.workspaceBranchTextCreating,
|
|
||||||
],
|
|
||||||
[isHovered, isCreating],
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceHoverCard workspace={workspace} prHint={prHint} isDragging={isDragging}>
|
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
|
||||||
<View
|
{({ isHovered, hoverHandlers }) => {
|
||||||
{...dragAttributes}
|
const isDesktop = !isTouchPlatform;
|
||||||
{...dragHandleProps?.listeners}
|
const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
|
||||||
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
|
const hasRunningService = workspace.scripts.some(
|
||||||
style={styles.workspaceRowContainer}
|
(s) => s.lifecycle === "running" && (s.type ?? "service") === "service",
|
||||||
onPointerEnter={handlePointerEnter}
|
);
|
||||||
onPointerLeave={handlePointerLeave}
|
const workspaceRowStyle = getProjectWorkspaceRowStyle({
|
||||||
>
|
isDragging,
|
||||||
<Pressable
|
selected,
|
||||||
disabled={isArchiving}
|
isHovered,
|
||||||
aria-selected={selected}
|
});
|
||||||
accessibilityRole="button"
|
return (
|
||||||
accessibilityState={accessibilityState}
|
<View
|
||||||
style={workspaceRowStyle}
|
{...dragAttributes}
|
||||||
onPressIn={interaction.handlePressIn}
|
{...dragHandleProps?.listeners}
|
||||||
onTouchMove={interaction.handleTouchMove}
|
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
|
||||||
onPressOut={interaction.handlePressOut}
|
style={styles.workspaceRowContainer}
|
||||||
onPress={handlePress}
|
{...hoverHandlers}
|
||||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
>
|
||||||
>
|
<Pressable
|
||||||
<View style={styles.workspaceRowMain}>
|
disabled={isArchiving}
|
||||||
<View style={styles.workspaceRowLeft}>
|
aria-selected={selected}
|
||||||
<WorkspaceStatusIndicator
|
accessibilityRole="button"
|
||||||
bucket={workspace.statusBucket}
|
accessibilityState={accessibilityState}
|
||||||
workspaceKind={workspace.workspaceKind}
|
style={workspaceRowStyle}
|
||||||
loading={isArchiving || isCreating}
|
onPressIn={interaction.handlePressIn}
|
||||||
/>
|
onTouchMove={interaction.handleTouchMove}
|
||||||
<Text style={workspaceBranchTextStyle} numberOfLines={1}>
|
onPressOut={interaction.handlePressOut}
|
||||||
{workspace.name}
|
onPress={handlePress}
|
||||||
</Text>
|
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||||
</View>
|
>
|
||||||
<WorkspaceRowRightGroup
|
<SidebarWorkspaceRowContent
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
isHovered={isHovered}
|
isHovered={isHovered}
|
||||||
isTouchPlatform={isTouchPlatform}
|
isLoading={isArchiving || isCreating}
|
||||||
showScriptsIcon={showScriptsIcon}
|
isCreating={isCreating}
|
||||||
hasRunningService={hasRunningService}
|
>
|
||||||
isCreating={isCreating}
|
<WorkspaceRowRightGroup
|
||||||
showShortcutBadge={showShortcutBadge}
|
workspace={workspace}
|
||||||
shortcutNumber={shortcutNumber}
|
isHovered={isHovered}
|
||||||
archiveLabel={archiveLabel}
|
isTouchPlatform={isTouchPlatform}
|
||||||
archiveStatus={archiveStatus}
|
showScriptsIcon={showScriptsIcon}
|
||||||
archivePendingLabel={archivePendingLabel}
|
hasRunningService={hasRunningService}
|
||||||
archiveShortcutKeys={archiveShortcutKeys}
|
isCreating={isCreating}
|
||||||
onArchive={onArchive}
|
showShortcutBadge={showShortcutBadge}
|
||||||
onCopyBranchName={onCopyBranchName}
|
shortcutNumber={shortcutNumber}
|
||||||
onCopyPath={onCopyPath}
|
archiveLabel={archiveLabel}
|
||||||
onRename={onRename}
|
archiveStatus={archiveStatus}
|
||||||
/>
|
archivePendingLabel={archivePendingLabel}
|
||||||
|
archiveShortcutKeys={archiveShortcutKeys}
|
||||||
|
onArchive={onArchive}
|
||||||
|
onCopyBranchName={onCopyBranchName}
|
||||||
|
onCopyPath={onCopyPath}
|
||||||
|
onRename={onRename}
|
||||||
|
/>
|
||||||
|
</SidebarWorkspaceRowContent>
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
{prHint ? (
|
);
|
||||||
<View style={styles.workspacePrBadgeRow}>
|
}}
|
||||||
<PrBadge hint={prHint} />
|
</SidebarWorkspaceRowFrame>
|
||||||
<ChecksBadge checks={prHint.checks} />
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</WorkspaceHoverCard>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1722,6 +1643,15 @@ function WorkspaceRowWithMenu({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
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({
|
useKeyboardActionHandler({
|
||||||
handlerId: `worktree-archive-${workspace.workspaceKey}`,
|
handlerId: `worktree-archive-${workspace.workspaceKey}`,
|
||||||
@@ -1759,6 +1689,7 @@ function WorkspaceRowWithMenu({
|
|||||||
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
|
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
|
||||||
onCopyPath={handleCopyPath}
|
onCopyPath={handleCopyPath}
|
||||||
onRename={canCopyBranchName ? handleOpenRename : undefined}
|
onRename={canCopyBranchName ? handleOpenRename : undefined}
|
||||||
|
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
|
||||||
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
||||||
/>
|
/>
|
||||||
<AdaptiveRenameModal
|
<AdaptiveRenameModal
|
||||||
@@ -2429,6 +2360,7 @@ export function SidebarWorkspaceList({
|
|||||||
collapsedProjectKeys,
|
collapsedProjectKeys,
|
||||||
onToggleProjectCollapsed,
|
onToggleProjectCollapsed,
|
||||||
shortcutIndexByWorkspaceKey,
|
shortcutIndexByWorkspaceKey,
|
||||||
|
groupMode,
|
||||||
isRefreshing: _isRefreshing = false,
|
isRefreshing: _isRefreshing = false,
|
||||||
onRefresh: _onRefresh,
|
onRefresh: _onRefresh,
|
||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
@@ -2437,6 +2369,72 @@ export function SidebarWorkspaceList({
|
|||||||
parentGestureRef,
|
parentGestureRef,
|
||||||
}: SidebarWorkspaceListProps) {
|
}: SidebarWorkspaceListProps) {
|
||||||
const pathname = usePathname();
|
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 [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
|
||||||
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
||||||
new Map(),
|
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 { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||||
|
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
|
||||||
import { WorkspaceShortcutTargetsSubscriber } from "./workspace-shortcut-targets-subscriber";
|
import { WorkspaceShortcutTargetsSubscriber } from "./workspace-shortcut-targets-subscriber";
|
||||||
|
|
||||||
vi.hoisted(() => {
|
vi.hoisted(() => {
|
||||||
@@ -20,18 +21,22 @@ function workspaceDescriptor(input: {
|
|||||||
id: string;
|
id: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
projectDisplayName?: string;
|
||||||
|
status?: WorkspaceDescriptor["status"];
|
||||||
|
statusEnteredAt?: Date | null;
|
||||||
}): WorkspaceDescriptor {
|
}): WorkspaceDescriptor {
|
||||||
return {
|
return {
|
||||||
id: input.id,
|
id: input.id,
|
||||||
projectId: input.projectId ?? "project-1",
|
projectId: input.projectId ?? "project-1",
|
||||||
projectDisplayName: "Project 1",
|
projectDisplayName: input.projectDisplayName ?? "Project 1",
|
||||||
projectRootPath: "/repo/main",
|
projectRootPath: "/repo/main",
|
||||||
workspaceDirectory: `/repo/main/${input.id}`,
|
workspaceDirectory: `/repo/main/${input.id}`,
|
||||||
projectKind: "git",
|
projectKind: "git",
|
||||||
workspaceKind: "worktree",
|
workspaceKind: "worktree",
|
||||||
name: input.name ?? input.id,
|
name: input.name ?? input.id,
|
||||||
status: "done",
|
status: input.status ?? "done",
|
||||||
archivingAt: null,
|
archivingAt: null,
|
||||||
|
statusEnteredAt: input.statusEnteredAt ?? null,
|
||||||
diffStat: null,
|
diffStat: null,
|
||||||
scripts: [],
|
scripts: [],
|
||||||
};
|
};
|
||||||
@@ -56,6 +61,9 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
|||||||
projectOrderByServerId: {},
|
projectOrderByServerId: {},
|
||||||
workspaceOrderByServerAndProject: {},
|
workspaceOrderByServerAndProject: {},
|
||||||
});
|
});
|
||||||
|
useSidebarViewStore.setState({
|
||||||
|
groupModeByServerId: {},
|
||||||
|
});
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
useSessionStore.getState().initializeSession("srv", null as unknown as DaemonClient);
|
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 () => {
|
it("clears targets when disabled", async () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} serverId="srv" />);
|
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} serverId="srv" />);
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
useHydratedWorkspaceEntries,
|
||||||
|
useProjectNamesMap,
|
||||||
|
} from "@/hooks/use-status-mode-workspaces";
|
||||||
import { useSidebarWorkspacesList } from "@/hooks/use-sidebar-workspaces-list";
|
import { useSidebarWorkspacesList } from "@/hooks/use-sidebar-workspaces-list";
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-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({
|
export function WorkspaceShortcutTargetsSubscriber({
|
||||||
enabled,
|
enabled,
|
||||||
@@ -12,6 +20,11 @@ export function WorkspaceShortcutTargetsSubscriber({
|
|||||||
serverId: string | null;
|
serverId: string | null;
|
||||||
}) {
|
}) {
|
||||||
const { projects } = useSidebarWorkspacesList({ serverId, enabled });
|
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(
|
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
|
||||||
(state) => state.collapsedProjectKeys,
|
(state) => state.collapsedProjectKeys,
|
||||||
);
|
);
|
||||||
@@ -19,14 +32,19 @@ export function WorkspaceShortcutTargetsSubscriber({
|
|||||||
(state) => state.setSidebarShortcutWorkspaceTargets,
|
(state) => state.setSidebarShortcutWorkspaceTargets,
|
||||||
);
|
);
|
||||||
|
|
||||||
const shortcutModel = useMemo(
|
const shortcutModel = useMemo(() => {
|
||||||
() =>
|
if (groupMode === "status") {
|
||||||
buildSidebarShortcutModel({
|
return buildStatusSidebarShortcutModel({
|
||||||
projects,
|
workspaces: statusWorkspaces,
|
||||||
collapsedProjectKeys,
|
projectNamesByKey,
|
||||||
}),
|
});
|
||||||
[collapsedProjectKeys, projects],
|
}
|
||||||
);
|
|
||||||
|
return buildSidebarShortcutModel({
|
||||||
|
projects,
|
||||||
|
collapsedProjectKeys,
|
||||||
|
});
|
||||||
|
}, [collapsedProjectKeys, groupMode, projectNamesByKey, projects, statusWorkspaces]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled || !serverId) {
|
if (!enabled || !serverId) {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ function workspace(input: {
|
|||||||
name: "main",
|
name: "main",
|
||||||
status: "running",
|
status: "running",
|
||||||
archivingAt: null,
|
archivingAt: null,
|
||||||
|
statusEnteredAt: null,
|
||||||
diffStat: null,
|
diffStat: null,
|
||||||
scripts: input.scripts ?? [],
|
scripts: input.scripts ?? [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const baseWorkspace: WorkspaceDescriptor = {
|
|||||||
name: "feature",
|
name: "feature",
|
||||||
status: "done",
|
status: "done",
|
||||||
archivingAt: "2026-04-30T00:00:00.000Z",
|
archivingAt: "2026-04-30T00:00:00.000Z",
|
||||||
|
statusEnteredAt: null,
|
||||||
diffStat: null,
|
diffStat: null,
|
||||||
scripts: [],
|
scripts: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ function workspace(input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescripto
|
|||||||
name: input.name ?? input.id,
|
name: input.name ?? input.id,
|
||||||
status: input.status ?? "done",
|
status: input.status ?? "done",
|
||||||
archivingAt: input.archivingAt ?? null,
|
archivingAt: input.archivingAt ?? null,
|
||||||
|
statusEnteredAt: null,
|
||||||
diffStat: input.diffStat ?? null,
|
diffStat: input.diffStat ?? null,
|
||||||
scripts: input.scripts ?? [],
|
scripts: input.scripts ?? [],
|
||||||
} satisfies WorkspaceDescriptor;
|
} 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"];
|
workspaceKind: WorkspaceDescriptor["workspaceKind"];
|
||||||
name: string;
|
name: string;
|
||||||
statusBucket: SidebarStateBucket;
|
statusBucket: SidebarStateBucket;
|
||||||
|
statusEnteredAt: Date | null;
|
||||||
archivingAt: string | null;
|
archivingAt: string | null;
|
||||||
diffStat: { additions: number; deletions: number } | null;
|
diffStat: { additions: number; deletions: number } | null;
|
||||||
prHint: PrHint | null;
|
prHint: PrHint | null;
|
||||||
@@ -50,6 +51,7 @@ function createStructuralWorkspaceEntry(input: {
|
|||||||
workspaceKind: "checkout",
|
workspaceKind: "checkout",
|
||||||
name: input.workspaceId,
|
name: input.workspaceId,
|
||||||
statusBucket: "done",
|
statusBucket: "done",
|
||||||
|
statusEnteredAt: null,
|
||||||
archivingAt: null,
|
archivingAt: null,
|
||||||
diffStat: null,
|
diffStat: null,
|
||||||
prHint: 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 { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||||
import { useActiveServerId } from "@/hooks/use-active-server-id";
|
import { useActiveServerId } from "@/hooks/use-active-server-id";
|
||||||
import {
|
import {
|
||||||
|
type ActiveWorkspaceSelection,
|
||||||
navigateToLastWorkspace,
|
navigateToLastWorkspace,
|
||||||
useActiveWorkspaceSelection,
|
useActiveWorkspaceSelection,
|
||||||
} from "@/stores/navigation-active-workspace-store";
|
} from "@/stores/navigation-active-workspace-store";
|
||||||
@@ -56,6 +57,13 @@ export function useKeyboardShortcuts({
|
|||||||
const activeServerId = useActiveServerId();
|
const activeServerId = useActiveServerId();
|
||||||
const openProjectPickerAction = useOpenProjectPicker(activeServerId);
|
const openProjectPickerAction = useOpenProjectPicker(activeServerId);
|
||||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||||
|
const keyboardWorkspaceSelectionRef = useRef<ActiveWorkspaceSelection | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeWorkspaceSelection) {
|
||||||
|
keyboardWorkspaceSelectionRef.current = activeWorkspaceSelection;
|
||||||
|
}
|
||||||
|
}, [activeWorkspaceSelection]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
@@ -95,6 +103,10 @@ export function useKeyboardShortcuts({
|
|||||||
case "dispatch":
|
case "dispatch":
|
||||||
return keyboardActionDispatcher.dispatch(action.action);
|
return keyboardActionDispatcher.dispatch(action.action);
|
||||||
case "navigate-workspace":
|
case "navigate-workspace":
|
||||||
|
keyboardWorkspaceSelectionRef.current = {
|
||||||
|
serverId: action.serverId,
|
||||||
|
workspaceId: action.workspaceId,
|
||||||
|
};
|
||||||
navigateToWorkspace(action.serverId, action.workspaceId, { currentPathname: pathname });
|
navigateToWorkspace(action.serverId, action.workspaceId, { currentPathname: pathname });
|
||||||
return true;
|
return true;
|
||||||
case "navigate-last-workspace":
|
case "navigate-last-workspace":
|
||||||
@@ -197,7 +209,8 @@ export function useKeyboardShortcuts({
|
|||||||
pathname,
|
pathname,
|
||||||
isMobile,
|
isMobile,
|
||||||
sidebarShortcutTargets: store.sidebarShortcutWorkspaceTargets,
|
sidebarShortcutTargets: store.sidebarShortcutWorkspaceTargets,
|
||||||
navigationActiveWorkspace: activeWorkspaceSelection,
|
navigationActiveWorkspace:
|
||||||
|
keyboardWorkspaceSelectionRef.current ?? activeWorkspaceSelection,
|
||||||
commandCenterOpen: store.commandCenterOpen,
|
commandCenterOpen: store.commandCenterOpen,
|
||||||
shortcutsDialogOpen: store.shortcutsDialogOpen,
|
shortcutsDialogOpen: store.shortcutsDialogOpen,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ function buildWorkspacePayload() {
|
|||||||
name: "project",
|
name: "project",
|
||||||
archivingAt: null,
|
archivingAt: null,
|
||||||
status: "done" as const,
|
status: "done" as const,
|
||||||
|
statusEnteredAt: null,
|
||||||
activityAt: null,
|
activityAt: null,
|
||||||
diffStat: null,
|
diffStat: null,
|
||||||
scripts: [],
|
scripts: [],
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ function workspace(input: {
|
|||||||
name: input.id,
|
name: input.id,
|
||||||
archivingAt: null,
|
archivingAt: null,
|
||||||
status: "done",
|
status: "done",
|
||||||
|
statusEnteredAt: null,
|
||||||
activityAt: null,
|
activityAt: null,
|
||||||
diffStat: null,
|
diffStat: null,
|
||||||
scripts: [],
|
scripts: [],
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export function createSidebarWorkspaceEntry(input: {
|
|||||||
workspaceKind: input.workspace.workspaceKind,
|
workspaceKind: input.workspace.workspaceKind,
|
||||||
name: input.workspace.name,
|
name: input.workspace.name,
|
||||||
statusBucket: input.workspace.status,
|
statusBucket: input.workspace.status,
|
||||||
|
statusEnteredAt: input.workspace.statusEnteredAt,
|
||||||
archivingAt: input.workspace.archivingAt,
|
archivingAt: input.workspace.archivingAt,
|
||||||
diffStat: input.workspace.diffStat,
|
diffStat: input.workspace.diffStat,
|
||||||
prHint: selectPrHintFromStatus(input.workspace.githubRuntime?.pullRequest),
|
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 },
|
context: { isMac: true, isDesktop: false },
|
||||||
action: "workspace.navigate.relative",
|
action: "workspace.navigate.relative",
|
||||||
payload: { delta: -1 },
|
payload: { delta: -1 },
|
||||||
|
preventDefault: true,
|
||||||
|
stopPropagation: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "matches Alt+] to next workspace on macOS web when Option substitutes event.key",
|
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 },
|
context: { isMac: true, isDesktop: false },
|
||||||
action: "workspace.navigate.relative",
|
action: "workspace.navigate.relative",
|
||||||
payload: { delta: 1 },
|
payload: { delta: 1 },
|
||||||
|
preventDefault: true,
|
||||||
|
stopPropagation: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "matches Alt+Shift+W to close current tab on macOS web when Option substitutes event.key",
|
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", () => {
|
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", () => {
|
it("uses the retained navigation workspace selection over a stale pathname", () => {
|
||||||
expect(
|
expect(
|
||||||
routeKeyboardShortcut(
|
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", () => {
|
it("falls back to the pathname workspace when no retained selection exists", () => {
|
||||||
expect(
|
expect(
|
||||||
routeKeyboardShortcut(
|
routeKeyboardShortcut(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ function createWorkspaceDescriptor(): WorkspaceDescriptor {
|
|||||||
diffStat: null,
|
diffStat: null,
|
||||||
scripts: [],
|
scripts: [],
|
||||||
archivingAt: null,
|
archivingAt: null,
|
||||||
|
statusEnteredAt: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ function createWorkspaceDescriptor(input: Partial<WorkspaceDescriptor> = {}): Wo
|
|||||||
status: "running",
|
status: "running",
|
||||||
diffStat: null,
|
diffStat: null,
|
||||||
scripts: [],
|
scripts: [],
|
||||||
|
statusEnteredAt: null,
|
||||||
...input,
|
...input,
|
||||||
archivingAt: input.archivingAt ?? null,
|
archivingAt: input.archivingAt ?? null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ function createWorkspace(
|
|||||||
name: input.name ?? "main",
|
name: input.name ?? "main",
|
||||||
status: input.status ?? "done",
|
status: input.status ?? "done",
|
||||||
archivingAt: input.archivingAt ?? null,
|
archivingAt: input.archivingAt ?? null,
|
||||||
|
statusEnteredAt: null,
|
||||||
diffStat: input.diffStat ?? null,
|
diffStat: input.diffStat ?? null,
|
||||||
scripts: input.scripts ?? [],
|
scripts: input.scripts ?? [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ function createWorkspace(
|
|||||||
workspaceKind: input.workspaceKind ?? "local_checkout",
|
workspaceKind: input.workspaceKind ?? "local_checkout",
|
||||||
name: input.name ?? "main",
|
name: input.name ?? "main",
|
||||||
status: input.status ?? "done",
|
status: input.status ?? "done",
|
||||||
|
statusEnteredAt: input.statusEnteredAt ?? null,
|
||||||
archivingAt: input.archivingAt ?? null,
|
archivingAt: input.archivingAt ?? null,
|
||||||
diffStat: input.diffStat ?? null,
|
diffStat: input.diffStat ?? null,
|
||||||
scripts: input.scripts ?? [],
|
scripts: input.scripts ?? [],
|
||||||
@@ -76,6 +77,7 @@ describe("normalizeWorkspaceDescriptor", () => {
|
|||||||
name: "main",
|
name: "main",
|
||||||
archivingAt: null,
|
archivingAt: null,
|
||||||
status: "running",
|
status: "running",
|
||||||
|
statusEnteredAt: null,
|
||||||
activityAt: "not-a-date",
|
activityAt: "not-a-date",
|
||||||
diffStat: null,
|
diffStat: null,
|
||||||
scripts,
|
scripts,
|
||||||
@@ -109,6 +111,7 @@ describe("normalizeWorkspaceDescriptor", () => {
|
|||||||
name: "main",
|
name: "main",
|
||||||
archivingAt: null,
|
archivingAt: null,
|
||||||
status: "done",
|
status: "done",
|
||||||
|
statusEnteredAt: null,
|
||||||
activityAt: null,
|
activityAt: null,
|
||||||
diffStat: null,
|
diffStat: null,
|
||||||
scripts: [],
|
scripts: [],
|
||||||
@@ -140,6 +143,42 @@ describe("normalizeWorkspaceDescriptor", () => {
|
|||||||
expect(workspace.archivingAt).toBeNull();
|
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", () => {
|
it("preserves project placement from workspace descriptor payloads", () => {
|
||||||
const workspace = normalizeWorkspaceDescriptor({
|
const workspace = normalizeWorkspaceDescriptor({
|
||||||
id: "1",
|
id: "1",
|
||||||
@@ -152,6 +191,7 @@ describe("normalizeWorkspaceDescriptor", () => {
|
|||||||
name: "main",
|
name: "main",
|
||||||
archivingAt: null,
|
archivingAt: null,
|
||||||
status: "done",
|
status: "done",
|
||||||
|
statusEnteredAt: null,
|
||||||
activityAt: null,
|
activityAt: null,
|
||||||
diffStat: null,
|
diffStat: null,
|
||||||
scripts: [],
|
scripts: [],
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ export interface WorkspaceDescriptor {
|
|||||||
workspaceKind: WorkspaceDescriptorPayload["workspaceKind"];
|
workspaceKind: WorkspaceDescriptorPayload["workspaceKind"];
|
||||||
name: string;
|
name: string;
|
||||||
status: WorkspaceDescriptorPayload["status"];
|
status: WorkspaceDescriptorPayload["status"];
|
||||||
|
statusEnteredAt: Date | null;
|
||||||
archivingAt: string | null;
|
archivingAt: string | null;
|
||||||
diffStat: { additions: number; deletions: number } | null;
|
diffStat: { additions: number; deletions: number } | null;
|
||||||
scripts: WorkspaceDescriptorPayload["scripts"];
|
scripts: WorkspaceDescriptorPayload["scripts"];
|
||||||
@@ -137,6 +138,11 @@ export interface WorkspaceDescriptor {
|
|||||||
export function normalizeWorkspaceDescriptor(
|
export function normalizeWorkspaceDescriptor(
|
||||||
payload: WorkspaceDescriptorPayload,
|
payload: WorkspaceDescriptorPayload,
|
||||||
): WorkspaceDescriptor {
|
): WorkspaceDescriptor {
|
||||||
|
const statusEnteredAtRaw = payload.statusEnteredAt;
|
||||||
|
const statusEnteredAt: Date | null =
|
||||||
|
typeof statusEnteredAtRaw === "string" && statusEnteredAtRaw.length > 0
|
||||||
|
? new Date(statusEnteredAtRaw)
|
||||||
|
: null;
|
||||||
return {
|
return {
|
||||||
id: normalizeWorkspaceOpaqueId(payload.id) ?? payload.id,
|
id: normalizeWorkspaceOpaqueId(payload.id) ?? payload.id,
|
||||||
projectId: payload.projectId,
|
projectId: payload.projectId,
|
||||||
@@ -148,6 +154,7 @@ export function normalizeWorkspaceDescriptor(
|
|||||||
workspaceKind: payload.workspaceKind,
|
workspaceKind: payload.workspaceKind,
|
||||||
name: payload.name,
|
name: payload.name,
|
||||||
status: payload.status,
|
status: payload.status,
|
||||||
|
statusEnteredAt,
|
||||||
archivingAt: payload.archivingAt ?? null,
|
archivingAt: payload.archivingAt ?? null,
|
||||||
diffStat: payload.diffStat ?? null,
|
diffStat: payload.diffStat ?? null,
|
||||||
scripts: (payload.scripts ?? []).map((s) => Object.assign({}, s)),
|
scripts: (payload.scripts ?? []).map((s) => Object.assign({}, s)),
|
||||||
|
|||||||
@@ -7,21 +7,26 @@ import {
|
|||||||
serializeCollapsedProjects,
|
serializeCollapsedProjects,
|
||||||
setProjectCollapsed,
|
setProjectCollapsed,
|
||||||
toggleProjectCollapsed,
|
toggleProjectCollapsed,
|
||||||
|
toggleStatusGroupCollapsed,
|
||||||
} from "./state";
|
} from "./state";
|
||||||
|
|
||||||
interface SidebarCollapsedSectionsState extends CollapsedProjectsState {
|
interface SidebarCollapsedSectionsState extends CollapsedProjectsState {
|
||||||
toggleProjectCollapsed: (projectKey: string) => void;
|
toggleProjectCollapsed: (projectKey: string) => void;
|
||||||
setProjectCollapsed: (projectKey: string, collapsed: boolean) => void;
|
setProjectCollapsed: (projectKey: string, collapsed: boolean) => void;
|
||||||
|
toggleStatusGroupCollapsed: (statusGroupKey: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSidebarCollapsedSectionsStore = create<SidebarCollapsedSectionsState>()(
|
export const useSidebarCollapsedSectionsStore = create<SidebarCollapsedSectionsState>()(
|
||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set) => ({
|
||||||
collapsedProjectKeys: new Set(),
|
collapsedProjectKeys: new Set(),
|
||||||
|
collapsedStatusGroupKeys: new Set(),
|
||||||
toggleProjectCollapsed: (projectKey) =>
|
toggleProjectCollapsed: (projectKey) =>
|
||||||
set((state) => toggleProjectCollapsed(state, projectKey)),
|
set((state) => toggleProjectCollapsed(state, projectKey)),
|
||||||
setProjectCollapsed: (projectKey, collapsed) =>
|
setProjectCollapsed: (projectKey, collapsed) =>
|
||||||
set((state) => setProjectCollapsed(state, projectKey, collapsed)),
|
set((state) => setProjectCollapsed(state, projectKey, collapsed)),
|
||||||
|
toggleStatusGroupCollapsed: (statusGroupKey) =>
|
||||||
|
set((state) => toggleStatusGroupCollapsed(state, statusGroupKey)),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "sidebar-collapsed-sections",
|
name: "sidebar-collapsed-sections",
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import {
|
|||||||
serializeCollapsedProjects,
|
serializeCollapsedProjects,
|
||||||
setProjectCollapsed,
|
setProjectCollapsed,
|
||||||
toggleProjectCollapsed,
|
toggleProjectCollapsed,
|
||||||
|
toggleStatusGroupCollapsed,
|
||||||
} from "@/stores/sidebar-collapsed-sections-store/state";
|
} from "@/stores/sidebar-collapsed-sections-store/state";
|
||||||
|
|
||||||
function emptyState(): CollapsedProjectsState {
|
function emptyState(): CollapsedProjectsState {
|
||||||
return { collapsedProjectKeys: new Set() };
|
return { collapsedProjectKeys: new Set(), collapsedStatusGroupKeys: new Set() };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("sidebar collapsed projects transitions", () => {
|
describe("sidebar collapsed projects transitions", () => {
|
||||||
@@ -18,17 +19,21 @@ describe("sidebar collapsed projects transitions", () => {
|
|||||||
state = setProjectCollapsed(state, "project-a", true);
|
state = setProjectCollapsed(state, "project-a", true);
|
||||||
state = toggleProjectCollapsed(state, "project-b");
|
state = toggleProjectCollapsed(state, "project-b");
|
||||||
state = toggleProjectCollapsed(state, "project-a");
|
state = toggleProjectCollapsed(state, "project-a");
|
||||||
|
state = toggleStatusGroupCollapsed(state, "running");
|
||||||
|
|
||||||
expect(Array.from(state.collapsedProjectKeys)).toEqual(["project-b"]);
|
expect(Array.from(state.collapsedProjectKeys)).toEqual(["project-b"]);
|
||||||
|
expect(Array.from(state.collapsedStatusGroupKeys)).toEqual(["running"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serializes collapsed project keys for preference storage", () => {
|
it("serializes collapsed project keys for preference storage", () => {
|
||||||
const state: CollapsedProjectsState = {
|
const state: CollapsedProjectsState = {
|
||||||
collapsedProjectKeys: new Set(["project-a", "project-b"]),
|
collapsedProjectKeys: new Set(["project-a", "project-b"]),
|
||||||
|
collapsedStatusGroupKeys: new Set(["running"]),
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(serializeCollapsedProjects(state)).toEqual({
|
expect(serializeCollapsedProjects(state)).toEqual({
|
||||||
collapsedProjectKeys: ["project-a", "project-b"],
|
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.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", () => {
|
it("keeps the existing state object when persisted preferences do not change collapsed keys", () => {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
export interface CollapsedProjectsState {
|
export interface CollapsedProjectsState {
|
||||||
collapsedProjectKeys: Set<string>;
|
collapsedProjectKeys: Set<string>;
|
||||||
|
collapsedStatusGroupKeys: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PersistedCollapsedProjects {
|
export interface PersistedCollapsedProjects {
|
||||||
collapsedProjectKeys?: unknown;
|
collapsedProjectKeys?: unknown;
|
||||||
|
collapsedStatusGroupKeys?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toggleProjectCollapsed(
|
export function toggleProjectCollapsed(
|
||||||
@@ -16,7 +18,20 @@ export function toggleProjectCollapsed(
|
|||||||
} else {
|
} else {
|
||||||
next.add(projectKey);
|
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(
|
export function setProjectCollapsed(
|
||||||
@@ -30,13 +45,17 @@ export function setProjectCollapsed(
|
|||||||
} else {
|
} else {
|
||||||
next.delete(projectKey);
|
next.delete(projectKey);
|
||||||
}
|
}
|
||||||
return { collapsedProjectKeys: next };
|
return { ...state, collapsedProjectKeys: next };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serializeCollapsedProjects(state: CollapsedProjectsState): {
|
export function serializeCollapsedProjects(state: CollapsedProjectsState): {
|
||||||
collapsedProjectKeys: string[];
|
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>(
|
export function mergePersistedCollapsedProjects<S extends CollapsedProjectsState>(
|
||||||
@@ -44,16 +63,24 @@ export function mergePersistedCollapsedProjects<S extends CollapsedProjectsState
|
|||||||
current: S,
|
current: S,
|
||||||
): S {
|
): S {
|
||||||
if (!persisted?.collapsedProjectKeys) {
|
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;
|
return current;
|
||||||
}
|
}
|
||||||
const restored = deserializeCollapsedProjectKeys(persisted.collapsedProjectKeys);
|
return {
|
||||||
if (areSetsEqual(current.collapsedProjectKeys, restored)) {
|
...current,
|
||||||
return current;
|
collapsedProjectKeys: restoredProjects,
|
||||||
}
|
collapsedStatusGroupKeys: restoredStatusGroups,
|
||||||
return { ...current, collapsedProjectKeys: restored };
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function deserializeCollapsedProjectKeys(value: unknown): Set<string> {
|
function deserializeCollapsedKeys(value: unknown): Set<string> {
|
||||||
if (!Array.isArray(value)) {
|
if (!Array.isArray(value)) {
|
||||||
return new Set();
|
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",
|
name: "worktree",
|
||||||
status: "done",
|
status: "done",
|
||||||
archivingAt: null,
|
archivingAt: null,
|
||||||
|
statusEnteredAt: null,
|
||||||
diffStat: null,
|
diffStat: null,
|
||||||
scripts: [],
|
scripts: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ function workspace(input: {
|
|||||||
name: input.id,
|
name: input.id,
|
||||||
status: "done",
|
status: "done",
|
||||||
archivingAt: null,
|
archivingAt: null,
|
||||||
|
statusEnteredAt: null,
|
||||||
diffStat: null,
|
diffStat: null,
|
||||||
scripts: [],
|
scripts: [],
|
||||||
gitRuntime: {
|
gitRuntime: {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ describe("deriveSidebarStateBucket", () => {
|
|||||||
).toBe("attention");
|
).toBe("attention");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats initializing agents as done", () => {
|
it("treats initializing agents as running", () => {
|
||||||
expect(
|
expect(
|
||||||
deriveSidebarStateBucket({
|
deriveSidebarStateBucket({
|
||||||
status: "initializing",
|
status: "initializing",
|
||||||
@@ -43,6 +43,6 @@ describe("deriveSidebarStateBucket", () => {
|
|||||||
requiresAttention: false,
|
requiresAttention: false,
|
||||||
attentionReason: null,
|
attentionReason: null,
|
||||||
}),
|
}),
|
||||||
).toBe("done");
|
).toBe("running");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorks
|
|||||||
archiveUnpushedCommitCount: null,
|
archiveUnpushedCommitCount: null,
|
||||||
scripts: [],
|
scripts: [],
|
||||||
hasRunningScripts: false,
|
hasRunningScripts: false,
|
||||||
|
statusEnteredAt: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
archivingAt: overrides.archivingAt ?? null,
|
archivingAt: overrides.archivingAt ?? null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import type {
|
|||||||
SidebarWorkspaceEntry,
|
SidebarWorkspaceEntry,
|
||||||
} from "@/hooks/use-sidebar-workspaces-list";
|
} from "@/hooks/use-sidebar-workspaces-list";
|
||||||
|
|
||||||
import { buildSidebarShortcutModel, getRelativeSidebarShortcutTarget } from "./sidebar-shortcuts";
|
import {
|
||||||
|
buildSidebarShortcutModel,
|
||||||
|
buildStatusSidebarShortcutModel,
|
||||||
|
getRelativeSidebarShortcutTarget,
|
||||||
|
} from "./sidebar-shortcuts";
|
||||||
|
|
||||||
function workspace(input: {
|
function workspace(input: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -12,6 +16,8 @@ function workspace(input: {
|
|||||||
workspaceDirectory: string;
|
workspaceDirectory: string;
|
||||||
name: string;
|
name: string;
|
||||||
projectKey?: string;
|
projectKey?: string;
|
||||||
|
statusBucket?: SidebarWorkspaceEntry["statusBucket"];
|
||||||
|
statusEnteredAt?: Date | null;
|
||||||
}): SidebarWorkspaceEntry {
|
}): SidebarWorkspaceEntry {
|
||||||
return {
|
return {
|
||||||
workspaceKey: `${input.serverId}:${input.workspaceId}`,
|
workspaceKey: `${input.serverId}:${input.workspaceId}`,
|
||||||
@@ -22,8 +28,9 @@ function workspace(input: {
|
|||||||
projectKind: "git",
|
projectKind: "git",
|
||||||
workspaceKind: "checkout",
|
workspaceKind: "checkout",
|
||||||
name: input.name,
|
name: input.name,
|
||||||
statusBucket: "done",
|
statusBucket: input.statusBucket ?? "done",
|
||||||
archivingAt: null,
|
archivingAt: null,
|
||||||
|
statusEnteredAt: input.statusEnteredAt ?? null,
|
||||||
diffStat: null,
|
diffStat: null,
|
||||||
prHint: null,
|
prHint: null,
|
||||||
archiveHasUncommittedChanges: 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", () => {
|
describe("getRelativeSidebarShortcutTarget", () => {
|
||||||
const targets = [
|
const targets = [
|
||||||
{ serverId: "s1", workspaceId: "ws-1" },
|
{ serverId: "s1", workspaceId: "ws-1" },
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
SidebarProjectEntry,
|
SidebarProjectEntry,
|
||||||
SidebarWorkspaceEntry,
|
SidebarWorkspaceEntry,
|
||||||
} from "@/hooks/use-sidebar-workspaces-list";
|
} from "@/hooks/use-sidebar-workspaces-list";
|
||||||
|
import { buildStatusGroups } from "@/hooks/sidebar-status-view-model";
|
||||||
import { isSidebarProjectFlattened } from "./sidebar-project-row-model";
|
import { isSidebarProjectFlattened } from "./sidebar-project-row-model";
|
||||||
|
|
||||||
export interface SidebarShortcutWorkspaceTarget {
|
export interface SidebarShortcutWorkspaceTarget {
|
||||||
@@ -37,7 +38,32 @@ export function buildSidebarShortcutModel(input: {
|
|||||||
|
|
||||||
for (const workspace of project.workspaces) {
|
for (const workspace of project.workspaces) {
|
||||||
if (shortcutTargets.length >= maxShortcuts) {
|
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;
|
const shortcutNumber = shortcutTargets.length + 1;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ function workspace(
|
|||||||
name: input.name ?? input.id,
|
name: input.name ?? input.id,
|
||||||
status: input.status ?? "done",
|
status: input.status ?? "done",
|
||||||
archivingAt: input.archivingAt ?? null,
|
archivingAt: input.archivingAt ?? null,
|
||||||
|
statusEnteredAt: null,
|
||||||
diffStat: input.diffStat ?? null,
|
diffStat: input.diffStat ?? null,
|
||||||
scripts: input.scripts ?? [],
|
scripts: input.scripts ?? [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ function createWorkspace(
|
|||||||
name: input.name ?? "main",
|
name: input.name ?? "main",
|
||||||
status: input.status ?? "running",
|
status: input.status ?? "running",
|
||||||
archivingAt: input.archivingAt ?? null,
|
archivingAt: input.archivingAt ?? null,
|
||||||
|
statusEnteredAt: null,
|
||||||
diffStat: input.diffStat ?? null,
|
diffStat: input.diffStat ?? null,
|
||||||
scripts: input.scripts ?? [],
|
scripts: input.scripts ?? [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ function workspace(input?: Partial<WorkspaceDescriptor>): WorkspaceDescriptor {
|
|||||||
name: "workspace-1",
|
name: "workspace-1",
|
||||||
status: "done",
|
status: "done",
|
||||||
archivingAt: null,
|
archivingAt: null,
|
||||||
|
statusEnteredAt: null,
|
||||||
diffStat: null,
|
diffStat: null,
|
||||||
scripts: [],
|
scripts: [],
|
||||||
...input,
|
...input,
|
||||||
|
|||||||
@@ -15,8 +15,10 @@
|
|||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && tsc -p tsconfig.json --incremental false",
|
"clean": "node ../../scripts/clean-package-dist.mjs",
|
||||||
"prepack": "npm run build",
|
"build": "tsc -p tsconfig.json --incremental false",
|
||||||
|
"build:clean": "npm run clean && npm run build",
|
||||||
|
"prepack": "npm run build:clean",
|
||||||
"typecheck": "tsgo --noEmit",
|
"typecheck": "tsgo --noEmit",
|
||||||
"test": "npm run test:local",
|
"test": "npm run test:local",
|
||||||
"test:local": "tsx tests/run-all.ts",
|
"test:local": "tsx tests/run-all.ts",
|
||||||
|
|||||||
@@ -26,8 +26,10 @@
|
|||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && tsc -p tsconfig.json --incremental false",
|
"clean": "node ../../scripts/clean-package-dist.mjs",
|
||||||
"prepack": "npm run build",
|
"build": "tsc -p tsconfig.json --incremental false",
|
||||||
|
"build:clean": "npm run clean && npm run build",
|
||||||
|
"prepack": "npm run build:clean",
|
||||||
"typecheck": "tsgo --noEmit",
|
"typecheck": "tsgo --noEmit",
|
||||||
"typecheck:examples": "tsgo -p tsconfig.examples.json --noEmit",
|
"typecheck:examples": "tsgo -p tsconfig.examples.json --noEmit",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
|
|||||||
@@ -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: {
|
sendHeartbeat(params: {
|
||||||
deviceType: "web" | "mobile";
|
deviceType: "web" | "mobile";
|
||||||
focusedAgentId: string | null;
|
focusedAgentId: string | null;
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ function createWorkspace(input: Partial<PaseoWorkspace> = {}): PaseoWorkspace {
|
|||||||
name: "sdk",
|
name: "sdk",
|
||||||
archivingAt: null,
|
archivingAt: null,
|
||||||
status: "done",
|
status: "done",
|
||||||
|
statusEnteredAt: null,
|
||||||
activityAt: "2026-05-16T00:00:00.000Z",
|
activityAt: "2026-05-16T00:00:00.000Z",
|
||||||
scripts: [],
|
scripts: [],
|
||||||
gitRuntime: null,
|
gitRuntime: null,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
},
|
},
|
||||||
"main": "dist/main.js",
|
"main": "dist/main.js",
|
||||||
"scripts": {
|
"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",
|
"build:main": "tsc -p tsconfig.json",
|
||||||
"dev": "./scripts/dev.sh",
|
"dev": "./scripts/dev.sh",
|
||||||
"dev:win": "powershell ./scripts/dev.ps1",
|
"dev:win": "powershell ./scripts/dev.ps1",
|
||||||
|
|||||||
@@ -10,7 +10,24 @@ npm run build:main
|
|||||||
|
|
||||||
# Prefer Metro's stable default port so dev browser storage keeps the same
|
# 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.
|
# 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
|
# Set EXPO_DEV_URL in the environment so Electron inherits it
|
||||||
$env:EXPO_DEV_URL = "http://localhost:$($env:EXPO_PORT)"
|
$env:EXPO_DEV_URL = "http://localhost:$($env:EXPO_PORT)"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ npm run build:main
|
|||||||
|
|
||||||
# Prefer Metro's stable default port so dev browser storage keeps the same
|
# 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.
|
# 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
|
export EXPO_PORT
|
||||||
|
|
||||||
REMOTE_DEBUGGING_PORT="${PASEO_ELECTRON_REMOTE_DEBUGGING_PORT:-9223}"
|
REMOTE_DEBUGGING_PORT="${PASEO_ELECTRON_REMOTE_DEBUGGING_PORT:-9223}"
|
||||||
|
|||||||
@@ -19,8 +19,10 @@
|
|||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && tsc -p tsconfig.json --incremental false",
|
"clean": "node ../../scripts/clean-package-dist.mjs",
|
||||||
"prepack": "npm run build",
|
"build": "tsc -p tsconfig.json --incremental false",
|
||||||
|
"build:clean": "npm run clean && npm run build",
|
||||||
|
"prepack": "npm run build:clean",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"typecheck": "tsgo --noEmit"
|
"typecheck": "tsgo --noEmit"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,8 +18,10 @@
|
|||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && tsc -p tsconfig.json --incremental false",
|
"clean": "node ../../scripts/clean-package-dist.mjs",
|
||||||
"prepack": "npm run build",
|
"build": "tsc -p tsconfig.json --incremental false",
|
||||||
|
"build:clean": "npm run clean && npm run build",
|
||||||
|
"prepack": "npm run build:clean",
|
||||||
"typecheck": "tsgo --noEmit",
|
"typecheck": "tsgo --noEmit",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ describe("deriveAgentStateBucket", () => {
|
|||||||
).toBe("attention");
|
).toBe("attention");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats initializing agents as done for workspace buckets", () => {
|
it("treats initializing agents as running for workspace buckets", () => {
|
||||||
expect(
|
expect(
|
||||||
deriveAgentStateBucket({
|
deriveAgentStateBucket({
|
||||||
status: "initializing",
|
status: "initializing",
|
||||||
@@ -58,7 +58,7 @@ describe("deriveAgentStateBucket", () => {
|
|||||||
requiresAttention: false,
|
requiresAttention: false,
|
||||||
attentionReason: null,
|
attentionReason: null,
|
||||||
}),
|
}),
|
||||||
).toBe("done");
|
).toBe("running");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { AgentLifecycleStatus } from "./agent-lifecycle.js";
|
import type { AgentLifecycleStatus } from "./agent-lifecycle.js";
|
||||||
import type { WorkspaceStateBucket } from "./messages.js";
|
import type { WorkspaceStateBucket } from "./messages.js";
|
||||||
|
|
||||||
|
export type { WorkspaceStateBucket };
|
||||||
export type AgentAttentionReason = "finished" | "error" | "permission" | null | undefined;
|
export type AgentAttentionReason = "finished" | "error" | "permission" | null | undefined;
|
||||||
|
|
||||||
export interface AgentStateBucketInput {
|
export interface AgentStateBucketInput {
|
||||||
@@ -25,7 +26,7 @@ export function deriveAgentStateBucket(input: AgentStateBucketInput): WorkspaceS
|
|||||||
if (input.status === "error" || input.attentionReason === "error") {
|
if (input.status === "error" || input.attentionReason === "error") {
|
||||||
return "failed";
|
return "failed";
|
||||||
}
|
}
|
||||||
if (input.status === "running") {
|
if (input.status === "running" || input.status === "initializing") {
|
||||||
return "running";
|
return "running";
|
||||||
}
|
}
|
||||||
if (input.requiresAttention) {
|
if (input.requiresAttention) {
|
||||||
|
|||||||
@@ -1595,6 +1595,12 @@ export const ArchiveWorkspaceRequestSchema = z.object({
|
|||||||
requestId: z.string(),
|
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
|
// Highlighted diff token schema
|
||||||
// Note: style can be a compound class name (e.g., "heading meta") from the syntax highlighter
|
// Note: style can be a compound class name (e.g., "heading meta") from the syntax highlighter
|
||||||
const HighlightTokenSchema = z.object({
|
const HighlightTokenSchema = z.object({
|
||||||
@@ -1898,6 +1904,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
LegacyOpenInEditorRequestSchema,
|
LegacyOpenInEditorRequestSchema,
|
||||||
OpenProjectRequestSchema,
|
OpenProjectRequestSchema,
|
||||||
ArchiveWorkspaceRequestSchema,
|
ArchiveWorkspaceRequestSchema,
|
||||||
|
WorkspaceClearAttentionRequestSchema,
|
||||||
FileExplorerRequestSchema,
|
FileExplorerRequestSchema,
|
||||||
ProjectIconRequestSchema,
|
ProjectIconRequestSchema,
|
||||||
FileDownloadTokenRequestSchema,
|
FileDownloadTokenRequestSchema,
|
||||||
@@ -2385,6 +2392,14 @@ export const WorkspaceDescriptorPayloadSchema = z
|
|||||||
name: z.string(),
|
name: z.string(),
|
||||||
archivingAt: z.string().nullable().optional().default(null),
|
archivingAt: z.string().nullable().optional().default(null),
|
||||||
status: WorkspaceStateBucketSchema,
|
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(),
|
activityAt: z.string().nullable(),
|
||||||
diffStat: z
|
diffStat: z
|
||||||
.object({
|
.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({
|
export const SendAgentMessageResponseMessageSchema = z.object({
|
||||||
type: z.literal("send_agent_message_response"),
|
type: z.literal("send_agent_message_response"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
@@ -3667,6 +3701,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
FetchAgentTimelineResponseMessageSchema,
|
FetchAgentTimelineResponseMessageSchema,
|
||||||
CancelAgentResponseMessageSchema,
|
CancelAgentResponseMessageSchema,
|
||||||
ClearAgentAttentionResponseMessageSchema,
|
ClearAgentAttentionResponseMessageSchema,
|
||||||
|
WorkspaceClearAttentionResponseSchema,
|
||||||
SendAgentMessageResponseMessageSchema,
|
SendAgentMessageResponseMessageSchema,
|
||||||
SetVoiceModeResponseMessageSchema,
|
SetVoiceModeResponseMessageSchema,
|
||||||
DaemonGetStatusResponseSchema,
|
DaemonGetStatusResponseSchema,
|
||||||
@@ -4010,6 +4045,7 @@ export type LegacyListAvailableEditorsRequest = z.infer<
|
|||||||
export type LegacyOpenInEditorRequest = z.infer<typeof LegacyOpenInEditorRequestSchema>;
|
export type LegacyOpenInEditorRequest = z.infer<typeof LegacyOpenInEditorRequestSchema>;
|
||||||
export type OpenProjectRequest = z.infer<typeof OpenProjectRequestSchema>;
|
export type OpenProjectRequest = z.infer<typeof OpenProjectRequestSchema>;
|
||||||
export type ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSchema>;
|
export type ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSchema>;
|
||||||
|
export type WorkspaceClearAttentionRequest = z.infer<typeof WorkspaceClearAttentionRequestSchema>;
|
||||||
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
|
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
|
||||||
export type FileExplorerResponse = z.infer<typeof FileExplorerResponseSchema>;
|
export type FileExplorerResponse = z.infer<typeof FileExplorerResponseSchema>;
|
||||||
export type ProjectIconRequest = z.infer<typeof ProjectIconRequestSchema>;
|
export type ProjectIconRequest = z.infer<typeof ProjectIconRequestSchema>;
|
||||||
|
|||||||
@@ -411,6 +411,73 @@ describe("workspace message schemas", () => {
|
|||||||
).toBe(archivingAt);
|
).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", () => {
|
test("parses legacy workspace descriptor enum values", () => {
|
||||||
const parsed = SessionOutboundMessageSchema.parse({
|
const parsed = SessionOutboundMessageSchema.parse({
|
||||||
type: "workspace_update",
|
type: "workspace_update",
|
||||||
|
|||||||
@@ -32,8 +32,10 @@
|
|||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && tsc -p tsconfig.json --incremental false",
|
"clean": "node ../../scripts/clean-package-dist.mjs",
|
||||||
"prepack": "npm run build",
|
"build": "tsc -p tsconfig.json --incremental false",
|
||||||
|
"build:clean": "npm run clean && npm run build",
|
||||||
|
"prepack": "npm run build:clean",
|
||||||
"typecheck": "tsgo --noEmit",
|
"typecheck": "tsgo --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
|
|||||||
@@ -30,10 +30,12 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "cross-env PASEO_NODE_ENV=development node --import tsx scripts/dev-runner.ts",
|
"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",
|
"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: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');\"",
|
"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",
|
"start": "node dist/scripts/supervisor-entrypoint.js",
|
||||||
"typecheck": "tsgo -p tsconfig.server.typecheck.json --noEmit",
|
"typecheck": "tsgo -p tsconfig.server.typecheck.json --noEmit",
|
||||||
"generate:config-schema": "tsx scripts/generate-config-schema.ts",
|
"generate:config-schema": "tsx scripts/generate-config-schema.ts",
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ import {
|
|||||||
WorkspaceDirectory,
|
WorkspaceDirectory,
|
||||||
type WorkspaceUpdatesFilter,
|
type WorkspaceUpdatesFilter,
|
||||||
} from "./workspace-directory.js";
|
} from "./workspace-directory.js";
|
||||||
|
import { shouldEmitPendingBootstrapUpdate } from "./workspace-bootstrap-dedupe.js";
|
||||||
import {
|
import {
|
||||||
attemptFirstAgentBranchAutoName,
|
attemptFirstAgentBranchAutoName,
|
||||||
createPaseoWorktree,
|
createPaseoWorktree,
|
||||||
@@ -2099,6 +2100,8 @@ export class Session {
|
|||||||
return this.handleOpenProjectRequest(msg);
|
return this.handleOpenProjectRequest(msg);
|
||||||
case "archive_workspace_request":
|
case "archive_workspace_request":
|
||||||
return this.handleArchiveWorkspaceRequest(msg);
|
return this.handleArchiveWorkspaceRequest(msg);
|
||||||
|
case "workspace.clear_attention.request":
|
||||||
|
return this.handleWorkspaceClearAttentionRequest(msg);
|
||||||
case "file_explorer_request":
|
case "file_explorer_request":
|
||||||
return this.handleFileExplorerRequest(msg);
|
return this.handleFileExplorerRequest(msg);
|
||||||
case "project_icon_request":
|
case "project_icon_request":
|
||||||
@@ -5963,7 +5966,9 @@ export class Session {
|
|||||||
if (filter?.labels) {
|
if (filter?.labels) {
|
||||||
const filterLabels = filter.labels;
|
const filterLabels = filter.labels;
|
||||||
agents = agents.filter((agent) =>
|
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,
|
name: workspace.displayName,
|
||||||
archivingAt: null,
|
archivingAt: null,
|
||||||
status: "done",
|
status: "done",
|
||||||
|
statusEnteredAt: null,
|
||||||
activityAt: null,
|
activityAt: null,
|
||||||
diffStat,
|
diffStat,
|
||||||
scripts:
|
scripts:
|
||||||
@@ -6347,6 +6353,7 @@ export class Session {
|
|||||||
name: result.worktree.branchName || result.workspace.displayName,
|
name: result.worktree.branchName || result.workspace.displayName,
|
||||||
archivingAt: null,
|
archivingAt: null,
|
||||||
status: "done",
|
status: "done",
|
||||||
|
statusEnteredAt: null,
|
||||||
activityAt: null,
|
activityAt: null,
|
||||||
diffStat: { additions: 0, deletions: 0 },
|
diffStat: { additions: 0, deletions: 0 },
|
||||||
scripts: [],
|
scripts: [],
|
||||||
@@ -6437,7 +6444,10 @@ export class Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private flushBootstrappedWorkspaceUpdates(options?: {
|
private flushBootstrappedWorkspaceUpdates(options?: {
|
||||||
snapshotLatestActivityByWorkspaceId?: Map<string, number>;
|
snapshotByWorkspaceId?: Map<
|
||||||
|
string,
|
||||||
|
{ status: string; statusEnteredAt: string | null; activityAtMs: number | null }
|
||||||
|
>;
|
||||||
}): void {
|
}): void {
|
||||||
const subscription = this.workspaceUpdatesSubscription;
|
const subscription = this.workspaceUpdatesSubscription;
|
||||||
if (!subscription || !subscription.isBootstrapping) {
|
if (!subscription || !subscription.isBootstrapping) {
|
||||||
@@ -6450,19 +6460,26 @@ export class Session {
|
|||||||
|
|
||||||
for (const payload of pending) {
|
for (const payload of pending) {
|
||||||
if (payload.kind === "upsert") {
|
if (payload.kind === "upsert") {
|
||||||
const snapshotLatestActivity = options?.snapshotLatestActivityByWorkspaceId?.get(
|
const snapshot = options?.snapshotByWorkspaceId?.get(payload.workspace.id);
|
||||||
payload.workspace.id,
|
const updateActivityAtMs = payload.workspace.activityAt
|
||||||
);
|
? Date.parse(payload.workspace.activityAt)
|
||||||
if (typeof snapshotLatestActivity === "number") {
|
: null;
|
||||||
const updateLatestActivity = payload.workspace.activityAt
|
const shouldEmit = shouldEmitPendingBootstrapUpdate({
|
||||||
? Date.parse(payload.workspace.activityAt)
|
snapshot: snapshot
|
||||||
: Number.NEGATIVE_INFINITY;
|
? {
|
||||||
if (
|
status: snapshot.status,
|
||||||
!Number.isNaN(updateLatestActivity) &&
|
statusEnteredAt: snapshot.statusEnteredAt,
|
||||||
updateLatestActivity <= snapshotLatestActivity
|
activityAtMs: snapshot.activityAtMs,
|
||||||
) {
|
}
|
||||||
continue;
|
: null,
|
||||||
}
|
update: {
|
||||||
|
status: payload.workspace.status,
|
||||||
|
statusEnteredAt: payload.workspace.statusEnteredAt ?? null,
|
||||||
|
activityAtMs: Number.isNaN(updateActivityAtMs) ? null : updateActivityAtMs,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!shouldEmit) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.emit({
|
this.emit({
|
||||||
@@ -6985,15 +7002,7 @@ export class Session {
|
|||||||
},
|
},
|
||||||
"fetch_workspaces_response_ready",
|
"fetch_workspaces_response_ready",
|
||||||
);
|
);
|
||||||
const snapshotLatestActivityByWorkspaceId = new Map<string, number>();
|
const snapshot = this.buildBootstrapSnapshot(payload.entries);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "fetch_workspaces_response",
|
type: "fetch_workspaces_response",
|
||||||
@@ -7005,7 +7014,7 @@ export class Session {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) {
|
if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) {
|
||||||
this.flushBootstrappedWorkspaceUpdates({ snapshotLatestActivityByWorkspaceId });
|
this.flushBootstrappedWorkspaceUpdates(snapshot);
|
||||||
void this.reconcileAndEmitWorkspaceUpdates();
|
void this.reconcileAndEmitWorkspaceUpdates();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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> {
|
private async registerWorkspaceForImportedAgent(cwd: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const workspace = await this.findOrCreateWorkspaceForDirectory(cwd);
|
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> {
|
private async handleFetchAgent(agentIdOrIdentifier: string, requestId: string): Promise<void> {
|
||||||
const resolved = await this.resolveAgentIdentifier(agentIdOrIdentifier);
|
const resolved = await this.resolveAgentIdentifier(agentIdOrIdentifier);
|
||||||
if (!resolved.ok) {
|
if (!resolved.ok) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { z } from "zod";
|
|||||||
import { Session } from "./session.js";
|
import { Session } from "./session.js";
|
||||||
import type { AgentSnapshotPayload, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
import type { AgentSnapshotPayload, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||||
import { AgentManager } from "./agent/agent-manager.js";
|
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 {
|
import type {
|
||||||
AgentClient,
|
AgentClient,
|
||||||
AgentCreateSessionOptions,
|
AgentCreateSessionOptions,
|
||||||
@@ -59,6 +59,7 @@ interface SessionTestAccess {
|
|||||||
agentStorage: {
|
agentStorage: {
|
||||||
list(...args: unknown[]): Promise<unknown[]>;
|
list(...args: unknown[]): Promise<unknown[]>;
|
||||||
get(agentId: string): Promise<unknown>;
|
get(agentId: string): Promise<unknown>;
|
||||||
|
upsert(record: unknown): Promise<void>;
|
||||||
};
|
};
|
||||||
agentManager: {
|
agentManager: {
|
||||||
listAgents(): unknown[];
|
listAgents(): unknown[];
|
||||||
@@ -155,6 +156,7 @@ function makeAgent(input: {
|
|||||||
pendingPermissions?: number;
|
pendingPermissions?: number;
|
||||||
requiresAttention?: boolean;
|
requiresAttention?: boolean;
|
||||||
attentionReason?: AgentSnapshotPayload["attentionReason"];
|
attentionReason?: AgentSnapshotPayload["attentionReason"];
|
||||||
|
attentionTimestamp?: string | null;
|
||||||
}): AgentSnapshotPayload {
|
}): AgentSnapshotPayload {
|
||||||
const pendingPermissionCount = input.pendingPermissions ?? 0;
|
const pendingPermissionCount = input.pendingPermissions ?? 0;
|
||||||
return {
|
return {
|
||||||
@@ -193,7 +195,39 @@ function makeAgent(input: {
|
|||||||
labels: {},
|
labels: {},
|
||||||
requiresAttention: input.requiresAttention ?? false,
|
requiresAttention: input.requiresAttention ?? false,
|
||||||
attentionReason: input.attentionReason ?? null,
|
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,
|
archivedAt: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -459,6 +493,7 @@ function createSessionForWorkspaceTests(
|
|||||||
agentStorage: asAgentStorage({
|
agentStorage: asAgentStorage({
|
||||||
list: async () => [],
|
list: async () => [],
|
||||||
get: async () => null,
|
get: async () => null,
|
||||||
|
upsert: async () => {},
|
||||||
}),
|
}),
|
||||||
projectRegistry: {
|
projectRegistry: {
|
||||||
initialize: async () => {},
|
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 () => {
|
test("close_items_request archives agents and kills terminals in one batch", async () => {
|
||||||
const emitted: SessionOutboundMessage[] = [];
|
const emitted: SessionOutboundMessage[] = [];
|
||||||
const archivedAt = "2026-04-01T00:00:00.000Z";
|
const archivedAt = "2026-04-01T00:00:00.000Z";
|
||||||
@@ -3885,6 +4114,7 @@ test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline desc
|
|||||||
name: "main",
|
name: "main",
|
||||||
archivingAt: null,
|
archivingAt: null,
|
||||||
status: "done",
|
status: "done",
|
||||||
|
statusEnteredAt: null,
|
||||||
activityAt: null,
|
activityAt: null,
|
||||||
diffStat: null,
|
diffStat: null,
|
||||||
} as const;
|
} as const;
|
||||||
@@ -3911,6 +4141,203 @@ test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline desc
|
|||||||
expect(descriptors).toEqual([baselineDescriptor]);
|
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 () => {
|
test("buildWorkspaceDescriptorMap stamps workspace archiving state", async () => {
|
||||||
const session = createSessionForWorkspaceTests();
|
const session = createSessionForWorkspaceTests();
|
||||||
const archivingAt = "2026-04-30T20:45:00.000Z";
|
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) }),
|
createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }),
|
||||||
);
|
);
|
||||||
session.projectRegistry.get = async () => null;
|
session.projectRegistry.get = async () => null;
|
||||||
|
|
||||||
await session.handleMessage({
|
await session.handleMessage({
|
||||||
type: "project.rename.request",
|
type: "project.rename.request",
|
||||||
projectId: "does-not-exist",
|
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 {
|
import {
|
||||||
deriveAgentStateBucket,
|
deriveAgentStateBucket,
|
||||||
getWorkspaceStateBucketPriority,
|
getWorkspaceStateBucketPriority,
|
||||||
|
type WorkspaceStateBucket,
|
||||||
} from "@getpaseo/protocol/agent-state-bucket";
|
} from "@getpaseo/protocol/agent-state-bucket";
|
||||||
import { getParentAgentIdFromLabels, isDelegatedAgent } from "@getpaseo/protocol/agent-labels";
|
import { getParentAgentIdFromLabels, isDelegatedAgent } from "@getpaseo/protocol/agent-labels";
|
||||||
import { SortablePager } from "./pagination/sortable-pager.js";
|
import { SortablePager } from "./pagination/sortable-pager.js";
|
||||||
@@ -23,6 +24,19 @@ const FETCH_WORKSPACES_SORT_KEYS = [
|
|||||||
"project_id",
|
"project_id",
|
||||||
] as const;
|
] 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<
|
type FetchWorkspacesRequestMessage = Extract<
|
||||||
SessionInboundMessage,
|
SessionInboundMessage,
|
||||||
{ type: "fetch_workspaces_request" }
|
{ type: "fetch_workspaces_request" }
|
||||||
@@ -93,6 +107,12 @@ export function summarizeFetchWorkspacesEntries(entries: Iterable<FetchWorkspace
|
|||||||
|
|
||||||
export class WorkspaceDirectory {
|
export class WorkspaceDirectory {
|
||||||
private readonly archivingByWorkspaceId = new Map<string, string>();
|
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<
|
private readonly pager = new SortablePager<
|
||||||
WorkspaceDescriptorPayload,
|
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;
|
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 {
|
resolveRegisteredWorkspaceIdForCwd(cwd: string, workspaces: PersistedWorkspaceRecord[]): string {
|
||||||
const normalizedCwd = normalizeWorkspaceId(cwd);
|
const normalizedCwd = normalizeWorkspaceId(cwd);
|
||||||
const exact = workspaces.find((workspace) => workspace.cwd === normalizedCwd);
|
const exact = workspaces.find((workspace) => workspace.cwd === normalizedCwd);
|
||||||
|
|||||||
+3
-2
@@ -3,17 +3,18 @@
|
|||||||
"setup": [
|
"setup": [
|
||||||
"npm ci",
|
"npm ci",
|
||||||
"npm run build:server",
|
"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\""
|
"cp \"$PASEO_SOURCE_CHECKOUT_PATH/packages/server/.env\" \"$PASEO_WORKTREE_PATH/packages/server/.env\""
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"daemon": {
|
"daemon": {
|
||||||
"type": "service",
|
"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": {
|
"app": {
|
||||||
"type": "service",
|
"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": {
|
"typecheck": {
|
||||||
"command": "npm run typecheck"
|
"command": "npm run typecheck"
|
||||||
|
|||||||
@@ -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 });
|
||||||
@@ -22,4 +22,8 @@ echo "════════════════════════
|
|||||||
export PASEO_CORS_ORIGINS="${PASEO_CORS_ORIGINS:-*}"
|
export PASEO_CORS_ORIGINS="${PASEO_CORS_ORIGINS:-*}"
|
||||||
export PASEO_NODE_INSPECT="${PASEO_NODE_INSPECT:---inspect=0}"
|
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
|
exec npm run dev:server
|
||||||
|
|||||||
+5
-2
@@ -21,6 +21,9 @@ echo " Home: ${PASEO_HOME}"
|
|||||||
echo " Models: ${PASEO_LOCAL_MODELS_DIR}"
|
echo " Models: ${PASEO_LOCAL_MODELS_DIR}"
|
||||||
echo "══════════════════════════════════════════════════════"
|
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
|
# 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.
|
# through the daemon's Portless URL instead of a fixed localhost port.
|
||||||
APP_ORIGIN="$(portless get app)"
|
APP_ORIGIN="$(portless get app)"
|
||||||
@@ -36,5 +39,5 @@ export PASEO_CORS_ORIGINS="*"
|
|||||||
concurrently \
|
concurrently \
|
||||||
--names "daemon,metro" \
|
--names "daemon,metro" \
|
||||||
--prefix-colors "cyan,magenta" \
|
--prefix-colors "cyan,magenta" \
|
||||||
"portless run --name daemon sh -c 'PASEO_LISTEN=0.0.0.0:\$PORT exec ./scripts/dev-daemon.sh'" \
|
"portless run --name daemon sh -c 'PASEO_SKIP_DEV_SERVER_BUILD=1 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"
|
"BROWSER=none APP_VARIANT=development EXPO_PUBLIC_LOCAL_DAEMON='${DAEMON_ENDPOINT}' portless run --name app npm run start:expo --workspace=@getpaseo/app"
|
||||||
|
|||||||
Reference in New Issue
Block a user