fix(sidebar): persist projects/workspaces and add remove project menu
Stop auto-archiving workspaces when all their agents are archived — only archive when the directory no longer exists on disk. This prevents projects from disappearing when users archive their last agent. Add a kebab dropdown menu to all project rows (desktop) with a "Remove project" action that archives all workspaces in the project.
This commit is contained in:
@@ -38,6 +38,7 @@ import {
|
|||||||
Monitor,
|
Monitor,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
Plus,
|
Plus,
|
||||||
|
Trash2,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||||
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
||||||
@@ -53,13 +54,7 @@ import {
|
|||||||
} from "@/hooks/use-sidebar-workspaces-list";
|
} from "@/hooks/use-sidebar-workspaces-list";
|
||||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import {
|
import { ContextMenuTrigger, useContextMenu } from "@/components/ui/context-menu";
|
||||||
ContextMenu,
|
|
||||||
ContextMenuContent,
|
|
||||||
ContextMenuItem,
|
|
||||||
ContextMenuTrigger,
|
|
||||||
useContextMenu,
|
|
||||||
} from "@/components/ui/context-menu";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
@@ -145,6 +140,8 @@ interface ProjectHeaderRowProps {
|
|||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
isArchiving?: boolean;
|
isArchiving?: boolean;
|
||||||
menuController: ReturnType<typeof useContextMenu> | null;
|
menuController: ReturnType<typeof useContextMenu> | null;
|
||||||
|
onRemoveProject?: () => void;
|
||||||
|
removeProjectStatus?: "idle" | "pending";
|
||||||
dragHandleProps?: DraggableListDragHandleProps;
|
dragHandleProps?: DraggableListDragHandleProps;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -704,8 +701,11 @@ function ProjectHeaderRow({
|
|||||||
isDragging,
|
isDragging,
|
||||||
isArchiving = false,
|
isArchiving = false,
|
||||||
menuController,
|
menuController,
|
||||||
|
onRemoveProject,
|
||||||
|
removeProjectStatus = "idle",
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
}: ProjectHeaderRowProps) {
|
}: ProjectHeaderRowProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const isMobileBreakpoint = isCompactFormFactor();
|
const isMobileBreakpoint = isCompactFormFactor();
|
||||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||||
@@ -792,16 +792,55 @@ function ProjectHeaderRow({
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{canCreateWorktree ? (
|
<View style={styles.projectTrailingActions}>
|
||||||
<NewWorktreeButton
|
{canCreateWorktree ? (
|
||||||
displayName={displayName}
|
<NewWorktreeButton
|
||||||
onPress={() => createWorktreeMutation.mutate()}
|
displayName={displayName}
|
||||||
visible={isHovered || isMobileBreakpoint}
|
onPress={() => createWorktreeMutation.mutate()}
|
||||||
loading={createWorktreeMutation.isPending}
|
visible={isHovered || isMobileBreakpoint}
|
||||||
showShortcutHint={isProjectActive}
|
loading={createWorktreeMutation.isPending}
|
||||||
testID={`sidebar-project-new-worktree-${project.projectKey}`}
|
showShortcutHint={isProjectActive}
|
||||||
/>
|
testID={`sidebar-project-new-worktree-${project.projectKey}`}
|
||||||
) : null}
|
/>
|
||||||
|
) : null}
|
||||||
|
{onRemoveProject ? (
|
||||||
|
<View
|
||||||
|
style={!(isHovered || isMobileBreakpoint) && styles.projectKebabButtonHidden}
|
||||||
|
pointerEvents={isHovered || isMobileBreakpoint ? "auto" : "none"}
|
||||||
|
>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
hitSlop={8}
|
||||||
|
style={({ hovered = false }) => [
|
||||||
|
styles.projectKebabButton,
|
||||||
|
hovered && styles.projectKebabButtonHovered,
|
||||||
|
]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Project actions"
|
||||||
|
testID={`sidebar-project-kebab-${project.projectKey}`}
|
||||||
|
>
|
||||||
|
{({ hovered }) => (
|
||||||
|
<MoreVertical
|
||||||
|
size={14}
|
||||||
|
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" width={220}>
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={`sidebar-project-menu-remove-${project.projectKey}`}
|
||||||
|
leading={<Trash2 size={14} color={theme.colors.foregroundMuted} />}
|
||||||
|
status={removeProjectStatus}
|
||||||
|
pendingLabel="Removing..."
|
||||||
|
onSelect={onRemoveProject}
|
||||||
|
>
|
||||||
|
Remove project
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
{showShortcutBadge && shortcutNumber !== null ? (
|
{showShortcutBadge && shortcutNumber !== null ? (
|
||||||
<View style={styles.shortcutBadge}>
|
<View style={styles.shortcutBadge}>
|
||||||
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
|
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
|
||||||
@@ -1209,160 +1248,6 @@ function WorkspaceRowWithMenu({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NonGitProjectRowWithMenuContent({
|
|
||||||
project,
|
|
||||||
displayName,
|
|
||||||
iconDataUri,
|
|
||||||
workspace,
|
|
||||||
selected,
|
|
||||||
onPress,
|
|
||||||
shortcutNumber,
|
|
||||||
showShortcutBadge,
|
|
||||||
drag,
|
|
||||||
isDragging,
|
|
||||||
dragHandleProps,
|
|
||||||
}: {
|
|
||||||
project: SidebarProjectEntry;
|
|
||||||
displayName: string;
|
|
||||||
iconDataUri: string | null;
|
|
||||||
workspace: SidebarWorkspaceEntry;
|
|
||||||
selected: boolean;
|
|
||||||
onPress: () => void;
|
|
||||||
shortcutNumber: number | null;
|
|
||||||
showShortcutBadge: boolean;
|
|
||||||
drag: () => void;
|
|
||||||
isDragging: boolean;
|
|
||||||
dragHandleProps?: DraggableListDragHandleProps;
|
|
||||||
}) {
|
|
||||||
const toast = useToast();
|
|
||||||
const contextMenu = useContextMenu();
|
|
||||||
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
|
|
||||||
const sessionWorkspaces = useSessionStore(
|
|
||||||
(state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES,
|
|
||||||
);
|
|
||||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
|
||||||
const redirectAfterArchive = useCallback(() => {
|
|
||||||
if (
|
|
||||||
activeWorkspaceSelection?.serverId !== workspace.serverId ||
|
|
||||||
activeWorkspaceSelection.workspaceId !== workspace.workspaceId
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
router.replace(
|
|
||||||
buildWorkspaceArchiveRedirectRoute({
|
|
||||||
serverId: workspace.serverId,
|
|
||||||
archivedWorkspaceId: workspace.workspaceId,
|
|
||||||
workspaces: sessionWorkspaces.values(),
|
|
||||||
}) as any,
|
|
||||||
);
|
|
||||||
}, [activeWorkspaceSelection, sessionWorkspaces, workspace.serverId, workspace.workspaceId]);
|
|
||||||
|
|
||||||
const handleArchiveWorkspace = useCallback(() => {
|
|
||||||
if (isArchivingWorkspace) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
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 {
|
|
||||||
const payload = await client.archiveWorkspace(workspace.workspaceId);
|
|
||||||
if (payload.error) {
|
|
||||||
throw new Error(payload.error);
|
|
||||||
}
|
|
||||||
redirectAfterArchive();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
|
|
||||||
} finally {
|
|
||||||
setIsArchivingWorkspace(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [
|
|
||||||
isArchivingWorkspace,
|
|
||||||
redirectAfterArchive,
|
|
||||||
toast,
|
|
||||||
workspace.name,
|
|
||||||
workspace.serverId,
|
|
||||||
workspace.workspaceId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ProjectHeaderRow
|
|
||||||
project={project}
|
|
||||||
displayName={displayName}
|
|
||||||
iconDataUri={iconDataUri}
|
|
||||||
workspace={workspace}
|
|
||||||
selected={selected}
|
|
||||||
chevron={null}
|
|
||||||
onPress={onPress}
|
|
||||||
serverId={null}
|
|
||||||
canCreateWorktree={false}
|
|
||||||
shortcutNumber={shortcutNumber}
|
|
||||||
showShortcutBadge={showShortcutBadge}
|
|
||||||
drag={drag}
|
|
||||||
isDragging={isDragging}
|
|
||||||
isArchiving={isArchivingWorkspace}
|
|
||||||
menuController={contextMenu}
|
|
||||||
dragHandleProps={dragHandleProps}
|
|
||||||
/>
|
|
||||||
<ContextMenuContent
|
|
||||||
align="start"
|
|
||||||
width={220}
|
|
||||||
mobileMode="sheet"
|
|
||||||
testID={`sidebar-workspace-context-${workspace.workspaceKey}`}
|
|
||||||
>
|
|
||||||
<ContextMenuItem
|
|
||||||
testID={`sidebar-workspace-context-${workspace.workspaceKey}-archive`}
|
|
||||||
status={isArchivingWorkspace ? "pending" : "idle"}
|
|
||||||
pendingLabel="Hiding..."
|
|
||||||
destructive
|
|
||||||
onSelect={handleArchiveWorkspace}
|
|
||||||
>
|
|
||||||
Hide from sidebar
|
|
||||||
</ContextMenuItem>
|
|
||||||
</ContextMenuContent>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NonGitProjectRowWithMenu(props: {
|
|
||||||
project: SidebarProjectEntry;
|
|
||||||
displayName: string;
|
|
||||||
iconDataUri: string | null;
|
|
||||||
workspace: SidebarWorkspaceEntry;
|
|
||||||
selected: boolean;
|
|
||||||
onPress: () => void;
|
|
||||||
shortcutNumber: number | null;
|
|
||||||
showShortcutBadge: boolean;
|
|
||||||
drag: () => void;
|
|
||||||
isDragging: boolean;
|
|
||||||
dragHandleProps?: DraggableListDragHandleProps;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<ContextMenu>
|
|
||||||
<NonGitProjectRowWithMenuContent {...props} />
|
|
||||||
</ContextMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FlattenedProjectRow({
|
function FlattenedProjectRow({
|
||||||
project,
|
project,
|
||||||
displayName,
|
displayName,
|
||||||
@@ -1378,6 +1263,8 @@ function FlattenedProjectRow({
|
|||||||
isDragging,
|
isDragging,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
isProjectActive = false,
|
isProjectActive = false,
|
||||||
|
onRemoveProject,
|
||||||
|
removeProjectStatus,
|
||||||
}: {
|
}: {
|
||||||
project: SidebarProjectEntry;
|
project: SidebarProjectEntry;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -1393,25 +1280,9 @@ function FlattenedProjectRow({
|
|||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
dragHandleProps?: DraggableListDragHandleProps;
|
dragHandleProps?: DraggableListDragHandleProps;
|
||||||
isProjectActive?: boolean;
|
isProjectActive?: boolean;
|
||||||
|
onRemoveProject?: () => void;
|
||||||
|
removeProjectStatus?: "idle" | "pending";
|
||||||
}) {
|
}) {
|
||||||
if (project.projectKind === "non_git") {
|
|
||||||
return (
|
|
||||||
<NonGitProjectRowWithMenu
|
|
||||||
project={project}
|
|
||||||
displayName={displayName}
|
|
||||||
iconDataUri={iconDataUri}
|
|
||||||
workspace={rowModel.workspace}
|
|
||||||
selected={rowModel.selected}
|
|
||||||
onPress={onPress}
|
|
||||||
shortcutNumber={shortcutNumber}
|
|
||||||
showShortcutBadge={showShortcutBadge}
|
|
||||||
drag={drag}
|
|
||||||
isDragging={isDragging}
|
|
||||||
dragHandleProps={dragHandleProps}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProjectHeaderRow
|
<ProjectHeaderRow
|
||||||
project={project}
|
project={project}
|
||||||
@@ -1431,6 +1302,8 @@ function FlattenedProjectRow({
|
|||||||
drag={drag}
|
drag={drag}
|
||||||
isDragging={isDragging}
|
isDragging={isDragging}
|
||||||
menuController={null}
|
menuController={null}
|
||||||
|
onRemoveProject={onRemoveProject}
|
||||||
|
removeProjectStatus={removeProjectStatus}
|
||||||
dragHandleProps={dragHandleProps}
|
dragHandleProps={dragHandleProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -1601,6 +1474,48 @@ function ProjectBlock({
|
|||||||
[onWorkspaceReorder, project.projectKey],
|
[onWorkspaceReorder, project.projectKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
const [isRemovingProject, setIsRemovingProject] = useState(false);
|
||||||
|
|
||||||
|
const handleRemoveProject = useCallback(() => {
|
||||||
|
if (isRemovingProject || !serverId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const confirmed = await confirmDialog({
|
||||||
|
title: "Remove project?",
|
||||||
|
message: `Remove "${displayName}" from the sidebar?\n\nFiles on disk will not be changed.`,
|
||||||
|
confirmLabel: "Remove",
|
||||||
|
cancelLabel: "Cancel",
|
||||||
|
destructive: true,
|
||||||
|
});
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = getHostRuntimeStore().getClient(serverId);
|
||||||
|
if (!client) {
|
||||||
|
toast.error("Host is not connected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRemovingProject(true);
|
||||||
|
try {
|
||||||
|
for (const ws of project.workspaces) {
|
||||||
|
const payload = await client.archiveWorkspace(ws.workspaceId);
|
||||||
|
if (payload.error) {
|
||||||
|
throw new Error(payload.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "Failed to remove project");
|
||||||
|
} finally {
|
||||||
|
setIsRemovingProject(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [isRemovingProject, serverId, displayName, toast, project.workspaces]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.projectBlock}>
|
<View style={styles.projectBlock}>
|
||||||
{rowModel.kind === "workspace_link" ? (
|
{rowModel.kind === "workspace_link" ? (
|
||||||
@@ -1625,6 +1540,8 @@ function ProjectBlock({
|
|||||||
isDragging={isDragging}
|
isDragging={isDragging}
|
||||||
dragHandleProps={dragHandleProps}
|
dragHandleProps={dragHandleProps}
|
||||||
isProjectActive={isProjectActive}
|
isProjectActive={isProjectActive}
|
||||||
|
onRemoveProject={handleRemoveProject}
|
||||||
|
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -1643,7 +1560,10 @@ function ProjectBlock({
|
|||||||
onWorktreeCreated={onWorktreeCreated}
|
onWorktreeCreated={onWorktreeCreated}
|
||||||
drag={drag}
|
drag={drag}
|
||||||
isDragging={isDragging}
|
isDragging={isDragging}
|
||||||
|
isArchiving={isRemovingProject}
|
||||||
menuController={null}
|
menuController={null}
|
||||||
|
onRemoveProject={handleRemoveProject}
|
||||||
|
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
|
||||||
dragHandleProps={dragHandleProps}
|
dragHandleProps={dragHandleProps}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -2144,6 +2064,26 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
projectIconActionButtonHidden: {
|
projectIconActionButtonHidden: {
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
},
|
},
|
||||||
|
projectTrailingActions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 2,
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
projectKebabButton: {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
projectKebabButtonHidden: {
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
projectKebabButtonHovered: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
projectTrailingControlSlot: {
|
projectTrailingControlSlot: {
|
||||||
width: 24,
|
width: 24,
|
||||||
height: 24,
|
height: 24,
|
||||||
|
|||||||
@@ -1363,10 +1363,6 @@ export class Session {
|
|||||||
);
|
);
|
||||||
const staleWorkspaceIds = await detectStaleWorkspaces({
|
const staleWorkspaceIds = await detectStaleWorkspaces({
|
||||||
activeWorkspaces,
|
activeWorkspaces,
|
||||||
agentRecords: (await this.agentStorage.list()).map((agent) => ({
|
|
||||||
cwd: agent.cwd,
|
|
||||||
archivedAt: agent.archivedAt ?? null,
|
|
||||||
})),
|
|
||||||
checkDirectoryExists: async (cwd) => {
|
checkDirectoryExists: async (cwd) => {
|
||||||
try {
|
try {
|
||||||
await stat(cwd);
|
await stat(cwd);
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ describe("detectStaleWorkspaces", () => {
|
|||||||
createWorkspaceRecord("/tmp/existing"),
|
createWorkspaceRecord("/tmp/existing"),
|
||||||
createWorkspaceRecord("/tmp/missing"),
|
createWorkspaceRecord("/tmp/missing"),
|
||||||
],
|
],
|
||||||
agentRecords: [],
|
|
||||||
checkDirectoryExists,
|
checkDirectoryExists,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -32,41 +31,21 @@ describe("detectStaleWorkspaces", () => {
|
|||||||
expect(checkDirectoryExists.mock.calls).toEqual([["/tmp/existing"], ["/tmp/missing"]]);
|
expect(checkDirectoryExists.mock.calls).toEqual([["/tmp/existing"], ["/tmp/missing"]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns workspace ids when all matching agents are archived", async () => {
|
test("keeps workspaces whose directories exist even when all agents are archived", async () => {
|
||||||
const staleWorkspaceIds = await detectStaleWorkspaces({
|
const staleWorkspaceIds = await detectStaleWorkspaces({
|
||||||
activeWorkspaces: [createWorkspaceRecord("/tmp/repo"), createWorkspaceRecord("/tmp/other")],
|
activeWorkspaces: [createWorkspaceRecord("/tmp/repo"), createWorkspaceRecord("/tmp/other")],
|
||||||
agentRecords: [
|
|
||||||
{
|
|
||||||
cwd: "/tmp/repo",
|
|
||||||
archivedAt: "2026-03-02T00:00:00.000Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
cwd: "/tmp/other",
|
|
||||||
archivedAt: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
checkDirectoryExists: async () => true,
|
checkDirectoryExists: async () => true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(Array.from(staleWorkspaceIds)).toEqual(["/tmp/repo"]);
|
expect(Array.from(staleWorkspaceIds)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps workspaces with no agents or at least one active agent", async () => {
|
test("keeps workspaces with no agents when directory exists", async () => {
|
||||||
const staleWorkspaceIds = await detectStaleWorkspaces({
|
const staleWorkspaceIds = await detectStaleWorkspaces({
|
||||||
activeWorkspaces: [
|
activeWorkspaces: [
|
||||||
createWorkspaceRecord("/tmp/active"),
|
createWorkspaceRecord("/tmp/active"),
|
||||||
createWorkspaceRecord("/tmp/no-agents"),
|
createWorkspaceRecord("/tmp/no-agents"),
|
||||||
],
|
],
|
||||||
agentRecords: [
|
|
||||||
{
|
|
||||||
cwd: "/tmp/active",
|
|
||||||
archivedAt: "2026-03-02T00:00:00.000Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
cwd: "/tmp/active/../active",
|
|
||||||
archivedAt: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
checkDirectoryExists: async () => true,
|
checkDirectoryExists: async () => true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,8 @@ import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
|
|||||||
|
|
||||||
export type PersistedProjectKind = "git" | "non_git";
|
export type PersistedProjectKind = "git" | "non_git";
|
||||||
export type PersistedWorkspaceKind = "local_checkout" | "worktree" | "directory";
|
export type PersistedWorkspaceKind = "local_checkout" | "worktree" | "directory";
|
||||||
export type StaleWorkspaceAgentRecord = {
|
|
||||||
cwd: string;
|
|
||||||
archivedAt: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DetectStaleWorkspacesInput = {
|
export type DetectStaleWorkspacesInput = {
|
||||||
activeWorkspaces: PersistedWorkspaceRecord[];
|
activeWorkspaces: PersistedWorkspaceRecord[];
|
||||||
agentRecords: StaleWorkspaceAgentRecord[];
|
|
||||||
checkDirectoryExists: (cwd: string) => Promise<boolean>;
|
checkDirectoryExists: (cwd: string) => Promise<boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -143,28 +137,11 @@ export async function detectStaleWorkspaces(
|
|||||||
input: DetectStaleWorkspacesInput,
|
input: DetectStaleWorkspacesInput,
|
||||||
): Promise<Set<string>> {
|
): Promise<Set<string>> {
|
||||||
const staleWorkspaceIds = new Set<string>();
|
const staleWorkspaceIds = new Set<string>();
|
||||||
const cwdsWithActiveAgents = new Set<string>();
|
|
||||||
const cwdsWithAnyAgent = new Set<string>();
|
|
||||||
|
|
||||||
for (const agent of input.agentRecords) {
|
|
||||||
const normalizedCwd = normalizeWorkspaceId(agent.cwd);
|
|
||||||
cwdsWithAnyAgent.add(normalizedCwd);
|
|
||||||
if (!agent.archivedAt) {
|
|
||||||
cwdsWithActiveAgents.add(normalizedCwd);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const workspace of input.activeWorkspaces) {
|
for (const workspace of input.activeWorkspaces) {
|
||||||
const dirExists = await input.checkDirectoryExists(workspace.cwd);
|
const dirExists = await input.checkDirectoryExists(workspace.cwd);
|
||||||
if (!dirExists) {
|
if (!dirExists) {
|
||||||
staleWorkspaceIds.add(workspace.workspaceId);
|
staleWorkspaceIds.add(workspace.workspaceId);
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasAgents = cwdsWithAnyAgent.has(workspace.workspaceId);
|
|
||||||
const hasActiveAgents = cwdsWithActiveAgents.has(workspace.workspaceId);
|
|
||||||
if (hasAgents && !hasActiveAgents) {
|
|
||||||
staleWorkspaceIds.add(workspace.workspaceId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user