diff --git a/.storybook/mocks/orpc.ts b/.storybook/mocks/orpc.ts index 7318d3d302..0f4229ba9c 100644 --- a/.storybook/mocks/orpc.ts +++ b/.storybook/mocks/orpc.ts @@ -21,6 +21,7 @@ import { type TaskSettings, } from "@/common/types/tasks"; import { createAsyncMessageQueue } from "@/common/utils/asyncMessageQueue"; +import { isWorkspaceArchived } from "@/common/utils/archive"; /** Session usage data structure matching SessionUsageFileSchema */ export interface MockSessionUsage { @@ -251,7 +252,14 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl }, }, workspace: { - list: async () => workspaces, + list: async (input?: { archived?: boolean }) => { + if (input?.archived) { + return workspaces.filter((w) => isWorkspaceArchived(w.archivedAt, w.unarchivedAt)); + } + return workspaces.filter((w) => !isWorkspaceArchived(w.archivedAt, w.unarchivedAt)); + }, + archive: async () => ({ success: true }), + unarchive: async () => ({ success: true }), create: async (input: { projectPath: string; branchName: string }) => ({ success: true, metadata: { diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 32a9b46024..3267245f95 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -17,17 +17,12 @@ import { buildSortedWorkspacesByProject } from "./utils/ui/workspaceFiltering"; import { useResumeManager } from "./hooks/useResumeManager"; import { useUnreadTracking } from "./hooks/useUnreadTracking"; import { useWorkspaceStoreRaw, useWorkspaceRecency } from "./stores/WorkspaceStore"; -import { ChatInput } from "./components/ChatInput/index"; -import type { ChatInputAPI } from "./components/ChatInput/types"; import { useStableReference, compareMaps } from "./hooks/useStableReference"; import { CommandRegistryProvider, useCommandRegistry } from "./contexts/CommandRegistryContext"; import { useOpenTerminal } from "./hooks/useOpenTerminal"; import type { CommandAction } from "./contexts/CommandRegistryContext"; -import { ModeProvider } from "./contexts/ModeContext"; -import { ProviderOptionsProvider } from "./contexts/ProviderOptionsContext"; import { ThemeProvider, useTheme, type ThemeMode } from "./contexts/ThemeContext"; -import { ThinkingProvider } from "./contexts/ThinkingContext"; import { CommandPalette } from "./components/CommandPalette"; import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sources"; @@ -48,12 +43,12 @@ import { getRuntimeTypeForTelemetry } from "@/common/telemetry"; import { useStartWorkspaceCreation, getFirstProjectPath } from "./hooks/useStartWorkspaceCreation"; import { useAPI } from "@/browser/contexts/API"; import { AuthTokenModal } from "@/browser/components/AuthTokenModal"; +import { ProjectPage } from "@/browser/components/ProjectPage"; import { SettingsProvider, useSettings } from "./contexts/SettingsContext"; import { SettingsModal } from "./components/Settings/SettingsModal"; import { SplashScreenProvider } from "./components/splashScreens/SplashScreenProvider"; import { TutorialProvider } from "./contexts/TutorialContext"; -import { ConnectionStatusIndicator } from "./components/ConnectionStatusIndicator"; import { TooltipProvider } from "./components/ui/tooltip"; import { useFeatureFlags } from "./contexts/FeatureFlagsContext"; import { FeatureFlagsProvider } from "./contexts/FeatureFlagsContext"; @@ -102,25 +97,16 @@ function AppInner() { const isMobile = typeof window !== "undefined" && window.innerWidth <= 768; const [sidebarCollapsed, setSidebarCollapsed] = usePersistedState("sidebarCollapsed", isMobile); const defaultProjectPath = getFirstProjectPath(projects); - const creationChatInputRef = useRef(null); const creationProjectPath = !selectedWorkspace ? (pendingNewWorkspaceProject ?? (projects.size === 1 ? defaultProjectPath : null)) : null; - const handleCreationChatReady = useCallback((api: ChatInputAPI) => { - creationChatInputRef.current = api; - api.focus(); - }, []); const startWorkspaceCreation = useStartWorkspaceCreation({ projects, beginWorkspaceCreation, }); - useEffect(() => { - if (creationProjectPath) { - creationChatInputRef.current?.focus(); - } - }, [creationProjectPath]); + // ProjectPage handles its own focus when mounted const handleToggleSidebar = useCallback(() => { setSidebarCollapsed((prev) => !prev); @@ -502,12 +488,6 @@ function AppInner() { } else if (matchesKeybind(e, KEYBINDS.OPEN_SETTINGS)) { e.preventDefault(); openSettings(); - } else if (matchesKeybind(e, KEYBINDS.FOCUS_CHAT)) { - // Focus creation chat when on new chat page (no workspace selected) - if (creationProjectPath && creationChatInputRef.current) { - e.preventDefault(); - creationChatInputRef.current.focus(); - } } }; @@ -661,51 +641,40 @@ function AppInner() { const projectName = projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "Project"; return ( - - - - - { - // IMPORTANT: Add workspace to store FIRST (synchronous) to ensure - // the store knows about it before React processes the state updates. - // This prevents race conditions where the UI tries to access the - // workspace before the store has created its aggregator. - workspaceStore.addWorkspace(metadata); - - // Add to workspace metadata map (triggers React state update) - setWorkspaceMetadata((prev) => - new Map(prev).set(metadata.id, metadata) - ); - - // Only switch to new workspace if user hasn't selected another one - // during the creation process (selectedWorkspace was null when creation started) - setSelectedWorkspace((current) => { - if (current !== null) { - // User has already selected another workspace - don't override - return current; - } - return toWorkspaceSelection(metadata); - }); - - // Track telemetry - telemetry.workspaceCreated( - metadata.id, - getRuntimeTypeForTelemetry(metadata.runtimeConfig) - ); - - // Clear pending state - clearPendingWorkspaceCreation(); - }} - /> - - - + { + // IMPORTANT: Add workspace to store FIRST (synchronous) to ensure + // the store knows about it before React processes the state updates. + // This prevents race conditions where the UI tries to access the + // workspace before the store has created its aggregator. + workspaceStore.addWorkspace(metadata); + + // Add to workspace metadata map (triggers React state update) + setWorkspaceMetadata((prev) => new Map(prev).set(metadata.id, metadata)); + + // Only switch to new workspace if user hasn't selected another one + // during the creation process (selectedWorkspace was null when creation started) + setSelectedWorkspace((current) => { + if (current !== null) { + // User has already selected another workspace - don't override + return current; + } + return toWorkspaceSelection(metadata); + }); + + // Track telemetry + telemetry.workspaceCreated( + metadata.id, + getRuntimeTypeForTelemetry(metadata.runtimeConfig) + ); + + // Clear pending state + clearPendingWorkspaceCreation(); + }} + /> ); })() ) : ( diff --git a/src/browser/components/ArchivedWorkspaces.tsx b/src/browser/components/ArchivedWorkspaces.tsx new file mode 100644 index 0000000000..5137ddee2b --- /dev/null +++ b/src/browser/components/ArchivedWorkspaces.tsx @@ -0,0 +1,598 @@ +import React from "react"; +import { cn } from "@/common/lib/utils"; +import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; +import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; +import { Trash2, Search } from "lucide-react"; +import { ArchiveIcon, ArchiveRestoreIcon } from "./icons/ArchiveIcon"; +import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip"; +import { RuntimeBadge } from "./RuntimeBadge"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/browser/components/ui/dialog"; +import { ForceDeleteModal } from "./ForceDeleteModal"; +import { Button } from "@/browser/components/ui/button"; + +interface ArchivedWorkspacesProps { + projectPath: string; + projectName: string; + workspaces: FrontendWorkspaceMetadata[]; + /** Called after a workspace is unarchived or deleted to refresh the list */ + onWorkspacesChanged?: () => void; +} + +interface BulkOperationState { + type: "restore" | "delete"; + total: number; + completed: number; + current: string | null; + errors: string[]; +} + +/** Group workspaces by time period for timeline display */ +function groupByTimePeriod( + workspaces: FrontendWorkspaceMetadata[] +): Map { + const groups = new Map(); + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(today.getTime() - 86400000); + const lastWeek = new Date(today.getTime() - 7 * 86400000); + const lastMonth = new Date(today.getTime() - 30 * 86400000); + + // Sort by archivedAt descending (most recent first) + const sorted = [...workspaces].sort((a, b) => { + const aTime = a.archivedAt ? new Date(a.archivedAt).getTime() : 0; + const bTime = b.archivedAt ? new Date(b.archivedAt).getTime() : 0; + return bTime - aTime; + }); + + for (const ws of sorted) { + const archivedDate = ws.archivedAt ? new Date(ws.archivedAt) : null; + let period: string; + + if (!archivedDate) { + period = "Unknown"; + } else if (archivedDate >= today) { + period = "Today"; + } else if (archivedDate >= yesterday) { + period = "Yesterday"; + } else if (archivedDate >= lastWeek) { + period = "This Week"; + } else if (archivedDate >= lastMonth) { + period = "This Month"; + } else { + // Group by month/year for older items + period = archivedDate.toLocaleDateString(undefined, { month: "long", year: "numeric" }); + } + + const existing = groups.get(period) ?? []; + existing.push(ws); + groups.set(period, existing); + } + + return groups; +} + +/** Flatten grouped workspaces back to ordered array for index-based selection */ +function flattenGrouped( + grouped: Map +): FrontendWorkspaceMetadata[] { + const result: FrontendWorkspaceMetadata[] = []; + for (const workspaces of grouped.values()) { + result.push(...workspaces); + } + return result; +} + +/** Progress modal for bulk operations */ +const BulkProgressModal: React.FC<{ + operation: BulkOperationState; + onClose: () => void; +}> = ({ operation, onClose }) => { + const percentage = Math.round((operation.completed / operation.total) * 100); + const isComplete = operation.completed === operation.total; + const actionVerb = operation.type === "restore" ? "Restoring" : "Deleting"; + const actionPast = operation.type === "restore" ? "restored" : "deleted"; + + return ( + !open && isComplete && onClose()}> + + + {isComplete ? "Complete" : `${actionVerb} Workspaces`} + + {isComplete ? ( + <> + Successfully {actionPast} {operation.completed} workspace + {operation.completed !== 1 && "s"} + {operation.errors.length > 0 && ` (${operation.errors.length} failed)`} + + ) : ( + <> + {operation.completed} of {operation.total} complete + {operation.current && <> — {operation.current}} + + )} + + + + {/* Progress bar */} +
+
+
+ + {/* Errors */} + {operation.errors.length > 0 && ( +
+ {operation.errors.map((err, i) => ( +
{err}
+ ))} +
+ )} + + {isComplete && ( + + + + )} + +
+ ); +}; + +/** + * Section showing archived workspaces for a project. + * Appears on the project page when there are archived workspaces. + */ +export const ArchivedWorkspaces: React.FC = ({ + projectPath: _projectPath, + projectName: _projectName, + workspaces, + onWorkspacesChanged, +}) => { + const { unarchiveWorkspace, removeWorkspace, setSelectedWorkspace } = useWorkspaceContext(); + const [searchQuery, setSearchQuery] = React.useState(""); + const [processingIds, setProcessingIds] = React.useState>(new Set()); + const [forceDeleteModal, setForceDeleteModal] = React.useState<{ + workspaceId: string; + error: string; + } | null>(null); + + // Bulk selection state + const [selectedIds, setSelectedIds] = React.useState>(new Set()); + const [lastClickedId, setLastClickedId] = React.useState(null); + const [bulkOperation, setBulkOperation] = React.useState(null); + const [bulkDeleteConfirm, setBulkDeleteConfirm] = React.useState(false); + + // workspaces prop should already be filtered to archived only + if (workspaces.length === 0) { + return null; + } + + // Filter workspaces by search query (frontend-only) + const filteredWorkspaces = searchQuery.trim() + ? workspaces.filter((ws) => { + const query = searchQuery.toLowerCase(); + const title = (ws.title ?? ws.name).toLowerCase(); + const name = ws.name.toLowerCase(); + return title.includes(query) || name.includes(query); + }) + : workspaces; + + // Group filtered workspaces by time period + const groupedWorkspaces = groupByTimePeriod(filteredWorkspaces); + const flatWorkspaces = flattenGrouped(groupedWorkspaces); + + // Handle checkbox click with shift-click range selection + const handleCheckboxClick = (workspaceId: string, event: React.MouseEvent) => { + const isShiftClick = event.shiftKey; + + setSelectedIds((prev) => { + const next = new Set(prev); + + if (isShiftClick && lastClickedId) { + // Range selection + const lastIndex = flatWorkspaces.findIndex((w) => w.id === lastClickedId); + const currentIndex = flatWorkspaces.findIndex((w) => w.id === workspaceId); + + if (lastIndex !== -1 && currentIndex !== -1) { + const start = Math.min(lastIndex, currentIndex); + const end = Math.max(lastIndex, currentIndex); + + for (let i = start; i <= end; i++) { + next.add(flatWorkspaces[i].id); + } + } + } else { + // Toggle single selection + if (next.has(workspaceId)) { + next.delete(workspaceId); + } else { + next.add(workspaceId); + } + } + + return next; + }); + + setLastClickedId(workspaceId); + setBulkDeleteConfirm(false); // Clear confirmation when selection changes + }; + + // Select/deselect all filtered workspaces + const handleSelectAll = () => { + const allFilteredIds = new Set(filteredWorkspaces.map((w) => w.id)); + const allSelected = filteredWorkspaces.every((w) => selectedIds.has(w.id)); + + if (allSelected) { + // Deselect all filtered + setSelectedIds((prev) => { + const next = new Set(prev); + for (const id of allFilteredIds) { + next.delete(id); + } + return next; + }); + } else { + // Select all filtered + setSelectedIds((prev) => new Set([...prev, ...allFilteredIds])); + } + setBulkDeleteConfirm(false); // Clear confirmation when selection changes + }; + + // Bulk restore + const handleBulkRestore = async () => { + const idsToRestore = Array.from(selectedIds); + setBulkOperation({ + type: "restore", + total: idsToRestore.length, + completed: 0, + current: null, + errors: [], + }); + + for (let i = 0; i < idsToRestore.length; i++) { + const id = idsToRestore[i]; + const ws = workspaces.find((w) => w.id === id); + setBulkOperation((prev) => (prev ? { ...prev, current: ws?.title ?? ws?.name ?? id } : prev)); + + try { + const result = await unarchiveWorkspace(id); + if (!result.success) { + setBulkOperation((prev) => + prev + ? { + ...prev, + errors: [ + ...prev.errors, + `Failed to restore ${ws?.name ?? id}${result.error ? `: ${result.error}` : ""}`, + ], + } + : prev + ); + } + } catch { + setBulkOperation((prev) => + prev ? { ...prev, errors: [...prev.errors, `Failed to restore ${ws?.name ?? id}`] } : prev + ); + } + + setBulkOperation((prev) => (prev ? { ...prev, completed: i + 1 } : prev)); + } + + setSelectedIds(new Set()); + onWorkspacesChanged?.(); + }; + + // Bulk delete (always force: true) - requires confirmation + const handleBulkDelete = async () => { + setBulkDeleteConfirm(false); + const idsToDelete = Array.from(selectedIds); + setBulkOperation({ + type: "delete", + total: idsToDelete.length, + completed: 0, + current: null, + errors: [], + }); + + for (let i = 0; i < idsToDelete.length; i++) { + const id = idsToDelete[i]; + const ws = workspaces.find((w) => w.id === id); + setBulkOperation((prev) => (prev ? { ...prev, current: ws?.title ?? ws?.name ?? id } : prev)); + + try { + const result = await removeWorkspace(id, { force: true }); + if (!result.success) { + setBulkOperation((prev) => + prev + ? { + ...prev, + errors: [ + ...prev.errors, + `Failed to delete ${ws?.name ?? id}${result.error ? `: ${result.error}` : ""}`, + ], + } + : prev + ); + } + } catch { + setBulkOperation((prev) => + prev ? { ...prev, errors: [...prev.errors, `Failed to delete ${ws?.name ?? id}`] } : prev + ); + } + + setBulkOperation((prev) => (prev ? { ...prev, completed: i + 1 } : prev)); + } + + setSelectedIds(new Set()); + onWorkspacesChanged?.(); + }; + + const handleUnarchive = async (workspaceId: string) => { + setProcessingIds((prev) => new Set(prev).add(workspaceId)); + try { + const result = await unarchiveWorkspace(workspaceId); + if (result.success) { + // Select the workspace after unarchiving + const workspace = workspaces.find((w) => w.id === workspaceId); + if (workspace) { + setSelectedWorkspace({ + workspaceId: workspace.id, + projectPath: workspace.projectPath, + projectName: workspace.projectName, + namedWorkspacePath: workspace.namedWorkspacePath, + }); + } + onWorkspacesChanged?.(); + } + } finally { + setProcessingIds((prev) => { + const next = new Set(prev); + next.delete(workspaceId); + return next; + }); + } + }; + + const handleDelete = async (workspaceId: string) => { + setProcessingIds((prev) => new Set(prev).add(workspaceId)); + try { + const result = await removeWorkspace(workspaceId); + if (result.success) { + onWorkspacesChanged?.(); + } else { + setForceDeleteModal({ + workspaceId, + error: result.error ?? "Failed to remove workspace", + }); + } + } finally { + setProcessingIds((prev) => { + const next = new Set(prev); + next.delete(workspaceId); + return next; + }); + } + }; + + const hasSelection = selectedIds.size > 0; + const allFilteredSelected = + filteredWorkspaces.length > 0 && filteredWorkspaces.every((w) => selectedIds.has(w.id)); + + return ( + <> + {/* Bulk operation progress modal */} + + setForceDeleteModal(null)} + onForceDelete={async (workspaceId) => { + const result = await removeWorkspace(workspaceId, { force: true }); + if (!result.success) { + throw new Error(result.error ?? "Force delete failed"); + } + onWorkspacesChanged?.(); + }} + /> + {bulkOperation && ( + setBulkOperation(null)} /> + )} + +
+ {/* Header with bulk actions */} +
+ + + Archived Workspaces ({workspaces.length}) + + {hasSelection && ( +
+ {selectedIds.size} selected + {bulkDeleteConfirm ? ( + <> + Delete permanently? + + + + ) : ( + <> + + + + + Restore selected + + + + + + Delete selected permanently + + + + )} +
+ )} +
+ +
+ {/* Search input with select all */} + {workspaces.length > 1 && ( +
+ + {workspaces.length > 3 && ( +
+ + setSearchQuery(e.target.value)} + className="bg-bg-dark placeholder:text-muted text-foreground focus:border-border-light w-full rounded border border-transparent py-1.5 pr-3 pl-8 text-sm focus:outline-none" + /> +
+ )} +
+ )} + + {/* Timeline grouped list */} +
+ {filteredWorkspaces.length === 0 ? ( +
+ No workspaces match {`"${searchQuery}"`} +
+ ) : ( + Array.from(groupedWorkspaces.entries()).map(([period, periodWorkspaces]) => ( +
+ {/* Period header */} +
+ {period} +
+ {/* Workspaces in this period */} + {periodWorkspaces.map((workspace) => { + const isProcessing = processingIds.has(workspace.id); + const isSelected = selectedIds.has(workspace.id); + const displayTitle = workspace.title ?? workspace.name; + + return ( +
+ handleCheckboxClick(workspace.id, e)} + onChange={() => undefined} // Controlled by onClick for shift-click support + className="h-4 w-4 rounded border-gray-600 bg-transparent" + aria-label={`Select ${displayTitle}`} + /> + +
+
+ {displayTitle} +
+ {workspace.archivedAt && ( +
+ {new Date(workspace.archivedAt).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + })} +
+ )} +
+ +
+ + + + + Restore to sidebar + + + + + + Delete permanently + +
+
+ ); + })} +
+ )) + )} +
+
+
+ + ); +}; diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 5bf988b0e1..90b42c4c2f 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -1515,12 +1515,9 @@ const ChatInputInner: React.FC = (props) => { return `Type a message... (${hints.join(", ")})`; })(); - // Wrapper for creation variant to enable full-height flex layout with vertical centering - const Wrapper = variant === "creation" ? "div" : React.Fragment; - const wrapperProps = - variant === "creation" - ? { className: "relative flex h-full flex-1 flex-col items-center justify-center p-4" } - : {}; + // No wrapper needed - parent controls layout for both variants + const Wrapper = React.Fragment; + const wrapperProps = {}; return ( diff --git a/src/browser/components/ProjectPage.tsx b/src/browser/components/ProjectPage.tsx new file mode 100644 index 0000000000..783ca841ae --- /dev/null +++ b/src/browser/components/ProjectPage.tsx @@ -0,0 +1,106 @@ +import React, { useRef, useCallback, useState, useEffect } from "react"; +import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; +import { ModeProvider } from "@/browser/contexts/ModeContext"; +import { ProviderOptionsProvider } from "@/browser/contexts/ProviderOptionsContext"; +import { ThinkingProvider } from "@/browser/contexts/ThinkingContext"; +import { ConnectionStatusIndicator } from "./ConnectionStatusIndicator"; +import { ChatInput } from "./ChatInput/index"; +import type { ChatInputAPI } from "./ChatInput/types"; +import { ArchivedWorkspaces } from "./ArchivedWorkspaces"; +import { useAPI } from "@/browser/contexts/API"; + +interface ProjectPageProps { + projectPath: string; + projectName: string; + onProviderConfig: (provider: string, keyPath: string[], value: string) => Promise; + onWorkspaceCreated: (metadata: FrontendWorkspaceMetadata) => void; +} + +/** + * Project page shown when a project is selected but no workspace is active. + * Combines workspace creation with archived workspaces view. + */ +export const ProjectPage: React.FC = ({ + projectPath, + projectName, + onProviderConfig, + onWorkspaceCreated, +}) => { + const { api } = useAPI(); + const chatInputRef = useRef(null); + const [archivedWorkspaces, setArchivedWorkspaces] = useState([]); + + // Fetch archived workspaces for this project + useEffect(() => { + if (!api) return; + let cancelled = false; + + const loadArchived = async () => { + try { + const allArchived = await api.workspace.list({ archived: true }); + if (cancelled) return; + // Filter to just this project's archived workspaces + const projectArchived = allArchived.filter((w) => w.projectPath === projectPath); + setArchivedWorkspaces(projectArchived); + } catch (error) { + console.error("Failed to load archived workspaces:", error); + } + }; + + void loadArchived(); + return () => { + cancelled = true; + }; + }, [api, projectPath]); + + const handleChatReady = useCallback((api: ChatInputAPI) => { + chatInputRef.current = api; + api.focus(); + }, []); + + return ( + + + + + {/* Scrollable content area */} +
+ {/* + IMPORTANT: Keep vertical centering off the scroll container. + When a flex scroll container uses justify-center and content becomes tall, + browsers can end up with a scroll origin that makes the top feel "cut off". + */} +
+ {/* Chat input card */} + + {/* Archived workspaces below chat */} + {archivedWorkspaces.length > 0 && ( +
+ { + // Refresh archived list after unarchive/delete + if (!api) return; + void api.workspace.list({ archived: true }).then((all) => { + setArchivedWorkspaces(all.filter((w) => w.projectPath === projectPath)); + }); + }} + /> +
+ )} +
+
+
+
+
+ ); +}; diff --git a/src/browser/components/ProjectSidebar.tsx b/src/browser/components/ProjectSidebar.tsx index 311cf570e2..4f73c31d66 100644 --- a/src/browser/components/ProjectSidebar.tsx +++ b/src/browser/components/ProjectSidebar.tsx @@ -23,7 +23,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip"; import { SidebarCollapseButton } from "./ui/SidebarCollapseButton"; import SecretsModal from "./SecretsModal"; import type { Secret } from "@/common/types/secrets"; -import { ForceDeleteModal } from "./ForceDeleteModal"; + import { WorkspaceListItem, type WorkspaceSelection } from "./WorkspaceListItem"; import { RenameProvider } from "@/browser/contexts/WorkspaceRenameContext"; import { useProjectContext } from "@/browser/contexts/ProjectContext"; @@ -197,7 +197,7 @@ const ProjectSidebarInner: React.FC = ({ const { selectedWorkspace, setSelectedWorkspace: onSelectWorkspace, - removeWorkspace: onRemoveWorkspace, + archiveWorkspace: onArchiveWorkspace, renameWorkspace: onRenameWorkspace, beginWorkspaceCreation: onAddWorkspace, } = useWorkspaceContext(); @@ -253,8 +253,8 @@ const ProjectSidebarInner: React.FC = ({ const [expandedOldWorkspaces, setExpandedOldWorkspaces] = usePersistedState< Record >("expandedOldWorkspaces", {}); - const [deletingWorkspaceIds, setDeletingWorkspaceIds] = useState>(new Set()); - const workspaceRemoveError = usePopoverError(); + const [archivingWorkspaceIds, setArchivingWorkspaceIds] = useState>(new Set()); + const workspaceArchiveError = usePopoverError(); const projectRemoveError = usePopoverError(); const [secretsModalState, setSecretsModalState] = useState<{ isOpen: boolean; @@ -262,12 +262,6 @@ const ProjectSidebarInner: React.FC = ({ projectName: string; secrets: Secret[]; } | null>(null); - const [forceDeleteModal, setForceDeleteModal] = useState<{ - isOpen: boolean; - workspaceId: string; - error: string; - anchor: { top: number; left: number } | null; - } | null>(null); const getProjectName = (path: string) => { if (!path || typeof path !== "string") { @@ -300,40 +294,32 @@ const ProjectSidebarInner: React.FC = ({ })); }; - const handleRemoveWorkspace = useCallback( + const handleArchiveWorkspace = useCallback( async (workspaceId: string, buttonElement: HTMLElement) => { - // Mark workspace as being deleted for UI feedback - setDeletingWorkspaceIds((prev) => new Set(prev).add(workspaceId)); + // Mark workspace as being archived for UI feedback + setArchivingWorkspaceIds((prev) => new Set(prev).add(workspaceId)); try { - const result = await onRemoveWorkspace(workspaceId); + const result = await onArchiveWorkspace(workspaceId); if (!result.success) { - const error = result.error ?? "Failed to remove workspace"; + const error = result.error ?? "Failed to archive workspace"; const rect = buttonElement.getBoundingClientRect(); const anchor = { top: rect.top + window.scrollY, - left: rect.right + 10, // 10px to the right of button + left: rect.right + 10, }; - - // Show force delete modal on any error to handle all cases - // (uncommitted changes, submodules, etc.) - setForceDeleteModal({ - isOpen: true, - workspaceId, - error, - anchor, - }); + workspaceArchiveError.showError(workspaceId, error, anchor); } } finally { - // Clear deleting state (workspace removed or error shown) - setDeletingWorkspaceIds((prev) => { + // Clear archiving state + setArchivingWorkspaceIds((prev) => { const next = new Set(prev); next.delete(workspaceId); return next; }); } }, - [onRemoveWorkspace] + [onArchiveWorkspace, workspaceArchiveError] ); const handleOpenSecrets = async (projectPath: string) => { @@ -346,33 +332,6 @@ const ProjectSidebarInner: React.FC = ({ }); }; - const handleForceDelete = async (workspaceId: string) => { - const modalState = forceDeleteModal; - // Close modal immediately to show that action is in progress - setForceDeleteModal(null); - - // Mark workspace as being deleted for UI feedback - setDeletingWorkspaceIds((prev) => new Set(prev).add(workspaceId)); - - try { - // Use the same state update logic as regular removal - const result = await onRemoveWorkspace(workspaceId, { force: true }); - if (!result.success) { - const errorMessage = result.error ?? "Failed to remove workspace"; - console.error("Force delete failed:", result.error); - - workspaceRemoveError.showError(workspaceId, errorMessage, modalState?.anchor ?? undefined); - } - } finally { - // Clear deleting state - setDeletingWorkspaceIds((prev) => { - const next = new Set(prev); - next.delete(workspaceId); - return next; - }); - } - }; - const handleSaveSecrets = async (secrets: Secret[]) => { if (secretsModalState) { await onUpdateSecrets(secretsModalState.projectPath, secrets); @@ -608,6 +567,7 @@ const ProjectSidebarInner: React.FC = ({ className="pt-1" > {(() => { + // Archived workspaces are excluded from workspaceMetadata so won't appear here const allWorkspaces = sortedWorkspacesByProject.get(projectPath) ?? []; const depthByWorkspaceId = computeWorkspaceDepthMap(allWorkspaces); @@ -623,10 +583,10 @@ const ProjectSidebarInner: React.FC = ({ projectPath={projectPath} projectName={projectName} isSelected={selectedWorkspace?.workspaceId === metadata.id} - isDeleting={deletingWorkspaceIds.has(metadata.id)} + isArchiving={archivingWorkspaceIds.has(metadata.id)} lastReadTimestamp={lastReadTimestamps[metadata.id] ?? 0} onSelectWorkspace={handleSelectWorkspace} - onRemoveWorkspace={handleRemoveWorkspace} + onArchiveWorkspace={handleArchiveWorkspace} onToggleUnread={_onToggleUnread} depth={depthByWorkspaceId[metadata.id] ?? 0} /> @@ -734,19 +694,10 @@ const ProjectSidebarInner: React.FC = ({ onSave={handleSaveSecrets} /> )} - {forceDeleteModal && ( - setForceDeleteModal(null)} - onForceDelete={handleForceDelete} - /> - )} void; - onRemoveWorkspace: (workspaceId: string, button: HTMLElement) => Promise; + onArchiveWorkspace: (workspaceId: string, button: HTMLElement) => Promise; /** @deprecated No longer used since status dot was removed, kept for API compatibility */ onToggleUnread?: (workspaceId: string) => void; } @@ -38,17 +39,17 @@ const WorkspaceListItemInner: React.FC = ({ projectPath, projectName, isSelected, - isDeleting, + isArchiving, depth, lastReadTimestamp: _lastReadTimestamp, onSelectWorkspace, - onRemoveWorkspace, + onArchiveWorkspace, onToggleUnread: _onToggleUnread, }) => { // Destructure metadata for convenience const { id: workspaceId, namedWorkspacePath, status } = metadata; const isCreating = status === "creating"; - const isDisabled = isCreating || isDeleting; + const isDisabled = isCreating || isArchiving; const gitStatus = useGitStatus(workspaceId); // Get title edit context (renamed from rename context since we now edit titles, not names) @@ -115,7 +116,7 @@ const WorkspaceListItemInner: React.FC = ({ ? "cursor-default opacity-70" : "cursor-pointer hover:bg-hover [&:hover_button]:opacity-100", isSelected && !isDisabled && "bg-hover border-l-blue-400", - isDeleting && "pointer-events-none" + isArchiving && "pointer-events-none" )} style={{ paddingLeft }} onClick={() => { @@ -145,34 +146,35 @@ const WorkspaceListItemInner: React.FC = ({ aria-label={ isCreating ? `Creating workspace ${displayTitle}` - : isDeleting - ? `Deleting workspace ${displayTitle}` + : isArchiving + ? `Archiving workspace ${displayTitle}` : `Select workspace ${displayTitle}` } aria-disabled={isDisabled} data-workspace-path={namedWorkspacePath} data-workspace-id={workspaceId} > + {/* Archive button - vertically centered against entire item */} + {!isCreating && !isEditing && ( + + + + + Archive workspace + + )}
-
- {!isCreating && !isEditing && ( - - - - - Remove workspace - - )} +
{isEditing ? ( = ({ )}
{!isCreating && ( -
- {isDeleting ? ( +
+ {isArchiving ? (
- 🗑️ - Deleting... + 📦 + Archiving...
) : ( diff --git a/src/browser/components/icons/ArchiveIcon.tsx b/src/browser/components/icons/ArchiveIcon.tsx new file mode 100644 index 0000000000..badb7a4baf --- /dev/null +++ b/src/browser/components/icons/ArchiveIcon.tsx @@ -0,0 +1,54 @@ +import React from "react"; + +interface ArchiveIconProps { + className?: string; +} + +/** + * Simple monochrome archive box icon. + * Box with a lid and a down-arrow indicating storage. + */ +export const ArchiveIcon: React.FC = ({ className }) => ( + + {/* Box lid */} + + {/* Box body */} + + {/* Down arrow into box */} + + + +); + +/** + * Archive restore icon - box with up-arrow indicating retrieval. + */ +export const ArchiveRestoreIcon: React.FC = ({ className }) => ( + + {/* Box lid */} + + {/* Box body */} + + {/* Up arrow out of box */} + + + +); diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index ca720652e3..f32fd45fe5 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -29,6 +29,7 @@ import { useProjectContext } from "@/browser/contexts/ProjectContext"; import { useWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore"; import { isExperimentEnabled } from "@/browser/hooks/useExperiments"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; +import { isWorkspaceArchived } from "@/common/utils/archive"; /** * Seed per-workspace localStorage from backend workspace metadata. @@ -108,6 +109,8 @@ export interface WorkspaceContext { workspaceId: string, newName: string ) => Promise<{ success: boolean; error?: string }>; + archiveWorkspace: (workspaceId: string) => Promise<{ success: boolean; error?: string }>; + unarchiveWorkspace: (workspaceId: string) => Promise<{ success: boolean; error?: string }>; refreshWorkspaceMetadata: () => Promise; setWorkspaceMetadata: React.Dispatch< React.SetStateAction> @@ -183,6 +186,8 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { ); const metadataMap = new Map(); for (const metadata of metadataList) { + // Skip archived workspaces - they should not be tracked by the app + if (isWorkspaceArchived(metadata.archivedAt, metadata.unarchivedAt)) continue; ensureCreatedAt(metadata); // Use stable workspace ID as key (not path, which can change) seedWorkspaceLocalStorageFromBackend(metadata); @@ -522,6 +527,54 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { [loadWorkspaceMetadata, api] ); + const archiveWorkspace = useCallback( + async (workspaceId: string): Promise<{ success: boolean; error?: string }> => { + if (!api) return { success: false, error: "API not connected" }; + try { + const result = await api.workspace.archive({ workspaceId }); + if (result.success) { + // Reload workspace metadata to get the updated state + await loadWorkspaceMetadata(); + // Clear selected workspace if it was archived + setSelectedWorkspace((current) => + current?.workspaceId === workspaceId ? null : current + ); + return { success: true }; + } else { + console.error("Failed to archive workspace:", result.error); + return { success: false, error: result.error }; + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error("Failed to archive workspace:", errorMessage); + return { success: false, error: errorMessage }; + } + }, + [loadWorkspaceMetadata, setSelectedWorkspace, api] + ); + + const unarchiveWorkspace = useCallback( + async (workspaceId: string): Promise<{ success: boolean; error?: string }> => { + if (!api) return { success: false, error: "API not connected" }; + try { + const result = await api.workspace.unarchive({ workspaceId }); + if (result.success) { + // Reload workspace metadata to get the updated state + await loadWorkspaceMetadata(); + return { success: true }; + } else { + console.error("Failed to unarchive workspace:", result.error); + return { success: false, error: result.error }; + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error("Failed to unarchive workspace:", errorMessage); + return { success: false, error: errorMessage }; + } + }, + [loadWorkspaceMetadata, api] + ); + const refreshWorkspaceMetadata = useCallback(async () => { await loadWorkspaceMetadata(); }, [loadWorkspaceMetadata]); @@ -558,6 +611,8 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { createWorkspace, removeWorkspace, renameWorkspace, + archiveWorkspace, + unarchiveWorkspace, refreshWorkspaceMetadata, setWorkspaceMetadata, selectedWorkspace, @@ -573,6 +628,8 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { createWorkspace, removeWorkspace, renameWorkspace, + archiveWorkspace, + unarchiveWorkspace, refreshWorkspaceMetadata, setWorkspaceMetadata, selectedWorkspace, diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index 2aa5c774d9..a0ed65f960 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -1338,7 +1338,11 @@ export class WorkspaceStore { `Workspace ${workspaceId} missing createdAt - backend contract violated` ); - const aggregator = this.getOrCreateAggregator(workspaceId, metadata.createdAt); + const aggregator = this.getOrCreateAggregator( + workspaceId, + metadata.createdAt, + metadata.unarchivedAt + ); // Initialize recency cache and bump derived store immediately // This ensures UI sees correct workspace order before messages load @@ -1536,12 +1540,19 @@ export class WorkspaceStore { */ private getOrCreateAggregator( workspaceId: string, - createdAt: string + createdAt: string, + unarchivedAt?: string ): StreamingMessageAggregator { if (!this.aggregators.has(workspaceId)) { // Create new aggregator with required createdAt and workspaceId for localStorage persistence - this.aggregators.set(workspaceId, new StreamingMessageAggregator(createdAt, workspaceId)); + this.aggregators.set( + workspaceId, + new StreamingMessageAggregator(createdAt, workspaceId, unarchivedAt) + ); this.workspaceCreatedAt.set(workspaceId, createdAt); + } else if (unarchivedAt) { + // Update unarchivedAt on existing aggregator (e.g., after restore from archive) + this.aggregators.get(workspaceId)!.setUnarchivedAt(unarchivedAt); } return this.aggregators.get(workspaceId)!; diff --git a/src/browser/stories/App.welcome.stories.tsx b/src/browser/stories/App.welcome.stories.tsx index 6b69466768..4fba6f8130 100644 --- a/src/browser/stories/App.welcome.stories.tsx +++ b/src/browser/stories/App.welcome.stories.tsx @@ -5,6 +5,7 @@ import { appMeta, AppWithMocks, type AppStory } from "./meta.js"; import { createMockORPCClient } from "../../../.storybook/mocks/orpc"; import { expandProjects } from "./storyHelpers"; +import { createArchivedWorkspace, NOW } from "./mockFactory"; import type { ProjectConfig } from "@/node/config"; export default { @@ -68,3 +69,81 @@ export const CreateWorkspaceMultipleProjects: AppStory = { /> ), }; + +/** Helper to generate archived workspaces with varied dates for timeline grouping */ +function generateArchivedWorkspaces(projectPath: string, projectName: string) { + const MINUTE = 60000; + const HOUR = 3600000; + const DAY = 86400000; + + // Intentionally large set to exercise ProjectPage scrolling + bulk selection UX. + // Keep timestamps deterministic (based on NOW constant). + const result = Array.from({ length: 34 }, (_, i) => { + const n = i + 1; + + // Mix timeframes: + // - first ~6: today (minutes/hours) + // - next ~8: last week + // - next ~10: last month + // - remaining: older (spans multiple month/year buckets) + let archivedDeltaMs: number; + if (n <= 3) { + archivedDeltaMs = n * 15 * MINUTE; + } else if (n <= 6) { + archivedDeltaMs = n * 2 * HOUR; + } else if (n <= 14) { + archivedDeltaMs = n * DAY; + } else if (n <= 24) { + archivedDeltaMs = n * 3 * DAY; + } else { + // Older: jump further back to create multiple month/year group headers + archivedDeltaMs = (n - 10) * 15 * DAY; + } + + const kind = n % 6; + const name = + kind === 0 + ? `feature/batch-${n}` + : kind === 1 + ? `bugfix/issue-${n}` + : kind === 2 + ? `refactor/cleanup-${n}` + : kind === 3 + ? `chore/deps-${n}` + : kind === 4 + ? `feature/ui-${n}` + : `bugfix/regression-${n}`; + + return createArchivedWorkspace({ + id: `archived-${n}`, + name, + projectName, + projectPath, + archivedAt: new Date(NOW - archivedDeltaMs).toISOString(), + }); + }); + + return result; +} + +/** + * Project page with archived workspaces - demonstrates: + * - Timeline grouping (Today, Yesterday, This Week, etc.) + * - Search bar (visible with >3 workspaces) + * - Bulk selection with checkboxes + * - Select all checkbox + * - Restore and delete actions + */ +export const ProjectPageWithArchivedWorkspaces: AppStory = { + render: () => ( + { + expandProjects(["/Users/dev/my-project"]); + return createMockORPCClient({ + projects: new Map([projectWithNoWorkspaces("/Users/dev/my-project")]), + workspaces: generateArchivedWorkspaces("/Users/dev/my-project", "my-project"), + }); + }} + /> + ), +}; diff --git a/src/browser/stories/mockFactory.ts b/src/browser/stories/mockFactory.ts index 97499d3712..52cf8c3eb7 100644 --- a/src/browser/stories/mockFactory.ts +++ b/src/browser/stories/mockFactory.ts @@ -104,6 +104,22 @@ export function createIncompatibleWorkspace( }; } +/** Create an archived workspace (archived = archivedAt set, no unarchivedAt) */ +export function createArchivedWorkspace( + opts: Partial & { + id: string; + name: string; + projectName: string; + archivedAt?: string; + } +): FrontendWorkspaceMetadata { + return { + ...createWorkspace(opts), + archivedAt: opts.archivedAt ?? new Date(NOW - 86400000).toISOString(), // 1 day ago + // No unarchivedAt means it's archived (archivedAt > unarchivedAt where unarchivedAt is undefined) + }; +} + // ═══════════════════════════════════════════════════════════════════════════════ // PROJECT FACTORY // ═══════════════════════════════════════════════════════════════════════════════ diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index 7e4c663039..0b7ef0205a 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -243,10 +243,13 @@ export class StreamingMessageAggregator { // Workspace creation timestamp (used for recency calculation) // REQUIRED: Backend guarantees every workspace has createdAt via config.ts private readonly createdAt: string; + // Workspace unarchived timestamp (used for recency calculation to bump restored workspaces) + private unarchivedAt?: string; - constructor(createdAt: string, workspaceId?: string) { + constructor(createdAt: string, workspaceId?: string, unarchivedAt?: string) { this.createdAt = createdAt; this.workspaceId = workspaceId; + this.unarchivedAt = unarchivedAt; // Load persisted state from localStorage if (workspaceId) { const persistedStatus = this.loadPersistedAgentStatus(); @@ -258,6 +261,12 @@ export class StreamingMessageAggregator { this.updateRecency(); } + /** Update unarchivedAt timestamp (called when workspace is restored from archive) */ + setUnarchivedAt(unarchivedAt: string | undefined): void { + this.unarchivedAt = unarchivedAt; + this.updateRecency(); + } + /** Load persisted agent status from localStorage */ private loadPersistedAgentStatus(): AgentStatus | undefined { if (!this.workspaceId) return undefined; @@ -337,7 +346,7 @@ export class StreamingMessageAggregator { */ private updateRecency(): void { const messages = this.getAllMessages(); - this.recencyTimestamp = computeRecencyTimestamp(messages, this.createdAt); + this.recencyTimestamp = computeRecencyTimestamp(messages, this.createdAt, this.unarchivedAt); } /** diff --git a/src/browser/utils/messages/recency.ts b/src/browser/utils/messages/recency.ts index 2dab84a3d6..1b416594f9 100644 --- a/src/browser/utils/messages/recency.ts +++ b/src/browser/utils/messages/recency.ts @@ -3,18 +3,28 @@ import { computeRecencyFromMessages } from "@/common/utils/recency"; /** * Compute recency timestamp for workspace sorting. - * Wrapper that handles string createdAt parsing for frontend use. + * Wrapper that handles string timestamp parsing for frontend use. * * Returns the maximum of: * - Workspace creation timestamp (ensures newly created/forked workspaces appear at top) + * - Workspace unarchived timestamp (ensures restored workspaces appear at top) * - Last user message timestamp (most recent user interaction) * - Last compacted message timestamp (fallback for compacted histories) */ -export function computeRecencyTimestamp(messages: MuxMessage[], createdAt?: string): number | null { +export function computeRecencyTimestamp( + messages: MuxMessage[], + createdAt?: string, + unarchivedAt?: string +): number | null { let createdTimestamp: number | undefined; if (createdAt) { const parsed = new Date(createdAt).getTime(); createdTimestamp = !isNaN(parsed) ? parsed : undefined; } - return computeRecencyFromMessages(messages, createdTimestamp); + let unarchivedTimestamp: number | undefined; + if (unarchivedAt) { + const parsed = new Date(unarchivedAt).getTime(); + unarchivedTimestamp = !isNaN(parsed) ? parsed : undefined; + } + return computeRecencyFromMessages(messages, createdTimestamp, unarchivedTimestamp); } diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index dfef9cb113..5121e31d3b 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -219,6 +219,8 @@ export const workspace = { input: z .object({ includePostCompaction: z.boolean().optional(), + /** When true, only return archived workspaces. Default returns only non-archived. */ + archived: z.boolean().optional(), }) .optional(), output: z.array(FrontendWorkspaceMetadataSchema), @@ -260,6 +262,14 @@ export const workspace = { }), output: ResultSchema(z.void(), z.string()), }, + archive: { + input: z.object({ workspaceId: z.string() }), + output: ResultSchema(z.void(), z.string()), + }, + unarchive: { + input: z.object({ workspaceId: z.string() }), + output: ResultSchema(z.void(), z.string()), + }, fork: { input: z.object({ sourceWorkspaceId: z.string(), newName: z.string() }), output: z.discriminatedUnion("success", [ diff --git a/src/common/orpc/schemas/project.ts b/src/common/orpc/schemas/project.ts index 1996233639..82281a71dc 100644 --- a/src/common/orpc/schemas/project.ts +++ b/src/common/orpc/schemas/project.ts @@ -60,6 +60,14 @@ export const WorkspaceConfigSchema = z.object({ mcp: WorkspaceMCPOverridesSchema.optional().meta({ description: "Per-workspace MCP overrides (disabled servers, tool allowlists)", }), + archivedAt: z.string().optional().meta({ + description: + "ISO 8601 timestamp when workspace was last archived. Workspace is considered archived if archivedAt > unarchivedAt (or unarchivedAt is absent).", + }), + unarchivedAt: z.string().optional().meta({ + description: + "ISO 8601 timestamp when workspace was last unarchived. Used for recency calculation to bump restored workspaces to top.", + }), }); export const ProjectConfigSchema = z.object({ diff --git a/src/common/orpc/schemas/workspace.ts b/src/common/orpc/schemas/workspace.ts index 13d66280f7..d6dfdbb13c 100644 --- a/src/common/orpc/schemas/workspace.ts +++ b/src/common/orpc/schemas/workspace.ts @@ -64,6 +64,14 @@ export const WorkspaceMetadataSchema = z.object({ description: "Workspace creation status. 'creating' = pending setup (ephemeral, not persisted). Absent = ready.", }), + archivedAt: z.string().optional().meta({ + description: + "ISO 8601 timestamp when workspace was last archived. Workspace is considered archived if archivedAt > unarchivedAt (or unarchivedAt is absent).", + }), + unarchivedAt: z.string().optional().meta({ + description: + "ISO 8601 timestamp when workspace was last unarchived. Used for recency calculation to bump restored workspaces to top.", + }), }); export const FrontendWorkspaceMetadataSchema = WorkspaceMetadataSchema.extend({ diff --git a/src/common/utils/archive.ts b/src/common/utils/archive.ts new file mode 100644 index 0000000000..b7de64a325 --- /dev/null +++ b/src/common/utils/archive.ts @@ -0,0 +1,13 @@ +/** + * Determine if a workspace is archived based on timestamps. + * A workspace is archived if archivedAt exists and is more recent than unarchivedAt. + * + * @param archivedAt - ISO timestamp when workspace was archived + * @param unarchivedAt - ISO timestamp when workspace was unarchived + * @returns true if workspace is currently archived + */ +export function isWorkspaceArchived(archivedAt?: string, unarchivedAt?: string): boolean { + if (!archivedAt) return false; + if (!unarchivedAt) return true; + return new Date(archivedAt).getTime() > new Date(unarchivedAt).getTime(); +} diff --git a/src/common/utils/recency.ts b/src/common/utils/recency.ts index 76f1b2a13c..07007d981e 100644 --- a/src/common/utils/recency.ts +++ b/src/common/utils/recency.ts @@ -12,15 +12,18 @@ function isIdleCompactionRequest(msg: MuxMessage): boolean { /** * Compute recency timestamp from messages. - * Returns max of: createdAt, last user message timestamp, last compacted message timestamp. + * Returns max of: createdAt, unarchivedAt, last user message timestamp, last compacted message timestamp. * This is the single source of truth for workspace recency. * * Excludes idle compaction requests since they shouldn't hoist the workspace * in the sidebar (they're background operations, not user activity). + * + * @param unarchivedAt - When workspace was last unarchived (bumps to top of recency) */ export function computeRecencyFromMessages( messages: MuxMessage[], - createdAt?: number + createdAt?: number, + unarchivedAt?: number ): number | null { const reversed = [...messages].reverse(); const lastUserMsg = reversed.find( @@ -30,6 +33,7 @@ export function computeRecencyFromMessages( const lastCompactedMsg = reversed.find((m) => m.metadata?.compacted && m.metadata?.timestamp); const candidates = [ createdAt ?? null, + unarchivedAt ?? null, lastUserMsg?.metadata?.timestamp ?? null, lastCompactedMsg?.metadata?.timestamp ?? null, ].filter((t): t is number => t !== null); diff --git a/src/node/config.ts b/src/node/config.ts index a7ccccff2a..0593c560db 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -364,6 +364,8 @@ export class Config { taskThinkingLevel: workspace.taskThinkingLevel, taskPrompt: workspace.taskPrompt, taskTrunkBranch: workspace.taskTrunkBranch, + archivedAt: workspace.archivedAt, + unarchivedAt: workspace.unarchivedAt, }; // Migrate missing createdAt to config for next load @@ -415,6 +417,9 @@ export class Config { metadata.taskThinkingLevel ??= workspace.taskThinkingLevel; metadata.taskPrompt ??= workspace.taskPrompt; metadata.taskTrunkBranch ??= workspace.taskTrunkBranch; + // Preserve archived timestamps from config + metadata.archivedAt ??= workspace.archivedAt; + metadata.unarchivedAt ??= workspace.unarchivedAt; // Migrate to config for next load workspace.id = metadata.id; workspace.name = metadata.name; @@ -447,6 +452,8 @@ export class Config { taskThinkingLevel: workspace.taskThinkingLevel, taskPrompt: workspace.taskPrompt, taskTrunkBranch: workspace.taskTrunkBranch, + archivedAt: workspace.archivedAt, + unarchivedAt: workspace.unarchivedAt, }; // Save to config for next load diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index d4b59d0439..6c30d3f3d1 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -25,6 +25,7 @@ import { normalizeSubagentAiDefaults, normalizeTaskSettings, } from "@/common/types/tasks"; +import { isWorkspaceArchived } from "@/common/utils/archive"; export const router = (authToken?: string) => { const t = os.$context().use(createAuthMiddleware(authToken)); @@ -582,8 +583,16 @@ export const router = (authToken?: string) => { list: t .input(schemas.workspace.list.input) .output(schemas.workspace.list.output) - .handler(({ context, input }) => { - return context.workspaceService.list(input ?? undefined); + .handler(async ({ context, input }) => { + const allWorkspaces = await context.workspaceService.list({ + includePostCompaction: input?.includePostCompaction, + }); + // Filter by archived status (derived from timestamps via shared utility) + if (input?.archived) { + return allWorkspaces.filter((w) => isWorkspaceArchived(w.archivedAt, w.unarchivedAt)); + } + // Default: return non-archived workspaces + return allWorkspaces.filter((w) => !isWorkspaceArchived(w.archivedAt, w.unarchivedAt)); }), create: t .input(schemas.workspace.create.input) @@ -632,6 +641,18 @@ export const router = (authToken?: string) => { .handler(async ({ context, input }) => { return context.workspaceService.updateAISettings(input.workspaceId, input.aiSettings); }), + archive: t + .input(schemas.workspace.archive.input) + .output(schemas.workspace.archive.output) + .handler(async ({ context, input }) => { + return context.workspaceService.archive(input.workspaceId); + }), + unarchive: t + .input(schemas.workspace.unarchive.input) + .output(schemas.workspace.unarchive.output) + .handler(async ({ context, input }) => { + return context.workspaceService.unarchive(input.workspaceId); + }), fork: t .input(schemas.workspace.fork.input) .output(schemas.workspace.fork.output) diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 48d0739f6d..9f8303f288 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -914,6 +914,109 @@ export class WorkspaceService extends EventEmitter { } } + /** + * Archive a workspace. Archived workspaces are hidden from the main sidebar + * but can be viewed on the project page. Safe and reversible. + */ + + async archive(workspaceId: string): Promise> { + try { + const workspace = this.config.findWorkspace(workspaceId); + if (!workspace) { + return Err("Workspace not found"); + } + const { projectPath, workspacePath } = workspace; + + // Archiving removes the workspace from the sidebar; ensure we don't leave a stream running + // "headless" with no obvious UI affordance to interrupt it. + if (this.aiService.isStreaming(workspaceId)) { + const stopResult = await this.interruptStream(workspaceId); + if (!stopResult.success) { + log.debug("Failed to stop stream during workspace archive", { + workspaceId, + error: stopResult.error, + }); + } + } + + await this.config.editConfig((config) => { + const projectConfig = config.projects.get(projectPath); + if (projectConfig) { + const workspaceEntry = + projectConfig.workspaces.find((w) => w.id === workspaceId) ?? + projectConfig.workspaces.find((w) => w.path === workspacePath); + if (workspaceEntry) { + // Just set archivedAt - archived state is derived from archivedAt > unarchivedAt + workspaceEntry.archivedAt = new Date().toISOString(); + } + } + return config; + }); + + // Emit updated metadata + const allMetadata = await this.config.getAllWorkspaceMetadata(); + const updatedMetadata = allMetadata.find((m) => m.id === workspaceId); + if (updatedMetadata) { + const session = this.sessions.get(workspaceId); + if (session) { + session.emitMetadata(updatedMetadata); + } else { + this.emit("metadata", { workspaceId, metadata: updatedMetadata }); + } + } + + return Ok(undefined); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return Err(`Failed to archive workspace: ${message}`); + } + } + + /** + * Unarchive a workspace. Restores it to the main sidebar view. + */ + async unarchive(workspaceId: string): Promise> { + try { + const workspace = this.config.findWorkspace(workspaceId); + if (!workspace) { + return Err("Workspace not found"); + } + const { projectPath, workspacePath } = workspace; + + await this.config.editConfig((config) => { + const projectConfig = config.projects.get(projectPath); + if (projectConfig) { + const workspaceEntry = + projectConfig.workspaces.find((w) => w.id === workspaceId) ?? + projectConfig.workspaces.find((w) => w.path === workspacePath); + if (workspaceEntry) { + // Just set unarchivedAt - archived state is derived from archivedAt > unarchivedAt + // This also bumps workspace to top of recency + workspaceEntry.unarchivedAt = new Date().toISOString(); + } + } + return config; + }); + + // Emit updated metadata + const allMetadata = await this.config.getAllWorkspaceMetadata(); + const updatedMetadata = allMetadata.find((m) => m.id === workspaceId); + if (updatedMetadata) { + const session = this.sessions.get(workspaceId); + if (session) { + session.emitMetadata(updatedMetadata); + } else { + this.emit("metadata", { workspaceId, metadata: updatedMetadata }); + } + } + + return Ok(undefined); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return Err(`Failed to unarchive workspace: ${message}`); + } + } + private normalizeWorkspaceAISettings( aiSettings: WorkspaceAISettings ): Result {