Skip to content

Commit 18a7d72

Browse files
authored
🤖 feat: add workspace archiving support (#1267)
Workspace archiving replaces sidebar deletion with a safe, reversible action. ## Changes - Add `archived` / `archivedAt` to workspace schemas + propagate through metadata - Add `workspace.archive` / `workspace.unarchive` ORPC endpoints - Sidebar: replace **Delete** with **Archive** and hide archived workspaces - Project page (create-workspace flow): show **Archived Workspaces** with search + timeline grouping - Archived list supports: - Restore - Permanent delete (only from archived view; bulk delete always uses `force: true`) - Bulk selection via checkboxes (+ shift-click range select) with a progress modal - Archiving interrupts any active stream for that workspace (prevents "headless" streams) ## Behavior 1. Archiving removes a workspace from the sidebar (and it’s filtered out of the frontend workspace tracking map). 2. Archived workspaces are visible on the **project page** (create-workspace view), where they can be restored. 3. Permanent deletion is only available for archived workspaces. 4. Single-delete tries `force: false` without confirmation; only shows the force-delete modal if the non-force delete fails. --- _Generated with `mux` • Model: `openai:gpt-5.2` • Thinking: `high`_
1 parent 9975b99 commit 18a7d72

File tree

22 files changed

+1227
-186
lines changed

22 files changed

+1227
-186
lines changed

.storybook/mocks/orpc.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
type TaskSettings,
2222
} from "@/common/types/tasks";
2323
import { createAsyncMessageQueue } from "@/common/utils/asyncMessageQueue";
24+
import { isWorkspaceArchived } from "@/common/utils/archive";
2425

2526
/** Session usage data structure matching SessionUsageFileSchema */
2627
export interface MockSessionUsage {
@@ -251,7 +252,14 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
251252
},
252253
},
253254
workspace: {
254-
list: async () => workspaces,
255+
list: async (input?: { archived?: boolean }) => {
256+
if (input?.archived) {
257+
return workspaces.filter((w) => isWorkspaceArchived(w.archivedAt, w.unarchivedAt));
258+
}
259+
return workspaces.filter((w) => !isWorkspaceArchived(w.archivedAt, w.unarchivedAt));
260+
},
261+
archive: async () => ({ success: true }),
262+
unarchive: async () => ({ success: true }),
255263
create: async (input: { projectPath: string; branchName: string }) => ({
256264
success: true,
257265
metadata: {

src/browser/App.tsx

Lines changed: 36 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,12 @@ import { buildSortedWorkspacesByProject } from "./utils/ui/workspaceFiltering";
1717
import { useResumeManager } from "./hooks/useResumeManager";
1818
import { useUnreadTracking } from "./hooks/useUnreadTracking";
1919
import { useWorkspaceStoreRaw, useWorkspaceRecency } from "./stores/WorkspaceStore";
20-
import { ChatInput } from "./components/ChatInput/index";
21-
import type { ChatInputAPI } from "./components/ChatInput/types";
2220

2321
import { useStableReference, compareMaps } from "./hooks/useStableReference";
2422
import { CommandRegistryProvider, useCommandRegistry } from "./contexts/CommandRegistryContext";
2523
import { useOpenTerminal } from "./hooks/useOpenTerminal";
2624
import type { CommandAction } from "./contexts/CommandRegistryContext";
27-
import { ModeProvider } from "./contexts/ModeContext";
28-
import { ProviderOptionsProvider } from "./contexts/ProviderOptionsContext";
2925
import { ThemeProvider, useTheme, type ThemeMode } from "./contexts/ThemeContext";
30-
import { ThinkingProvider } from "./contexts/ThinkingContext";
3126
import { CommandPalette } from "./components/CommandPalette";
3227
import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sources";
3328

@@ -48,12 +43,12 @@ import { getRuntimeTypeForTelemetry } from "@/common/telemetry";
4843
import { useStartWorkspaceCreation, getFirstProjectPath } from "./hooks/useStartWorkspaceCreation";
4944
import { useAPI } from "@/browser/contexts/API";
5045
import { AuthTokenModal } from "@/browser/components/AuthTokenModal";
46+
import { ProjectPage } from "@/browser/components/ProjectPage";
5147

5248
import { SettingsProvider, useSettings } from "./contexts/SettingsContext";
5349
import { SettingsModal } from "./components/Settings/SettingsModal";
5450
import { SplashScreenProvider } from "./components/splashScreens/SplashScreenProvider";
5551
import { TutorialProvider } from "./contexts/TutorialContext";
56-
import { ConnectionStatusIndicator } from "./components/ConnectionStatusIndicator";
5752
import { TooltipProvider } from "./components/ui/tooltip";
5853
import { useFeatureFlags } from "./contexts/FeatureFlagsContext";
5954
import { FeatureFlagsProvider } from "./contexts/FeatureFlagsContext";
@@ -102,25 +97,16 @@ function AppInner() {
10297
const isMobile = typeof window !== "undefined" && window.innerWidth <= 768;
10398
const [sidebarCollapsed, setSidebarCollapsed] = usePersistedState("sidebarCollapsed", isMobile);
10499
const defaultProjectPath = getFirstProjectPath(projects);
105-
const creationChatInputRef = useRef<ChatInputAPI | null>(null);
106100
const creationProjectPath = !selectedWorkspace
107101
? (pendingNewWorkspaceProject ?? (projects.size === 1 ? defaultProjectPath : null))
108102
: null;
109-
const handleCreationChatReady = useCallback((api: ChatInputAPI) => {
110-
creationChatInputRef.current = api;
111-
api.focus();
112-
}, []);
113103

114104
const startWorkspaceCreation = useStartWorkspaceCreation({
115105
projects,
116106
beginWorkspaceCreation,
117107
});
118108

119-
useEffect(() => {
120-
if (creationProjectPath) {
121-
creationChatInputRef.current?.focus();
122-
}
123-
}, [creationProjectPath]);
109+
// ProjectPage handles its own focus when mounted
124110

125111
const handleToggleSidebar = useCallback(() => {
126112
setSidebarCollapsed((prev) => !prev);
@@ -502,12 +488,6 @@ function AppInner() {
502488
} else if (matchesKeybind(e, KEYBINDS.OPEN_SETTINGS)) {
503489
e.preventDefault();
504490
openSettings();
505-
} else if (matchesKeybind(e, KEYBINDS.FOCUS_CHAT)) {
506-
// Focus creation chat when on new chat page (no workspace selected)
507-
if (creationProjectPath && creationChatInputRef.current) {
508-
e.preventDefault();
509-
creationChatInputRef.current.focus();
510-
}
511491
}
512492
};
513493

@@ -661,51 +641,40 @@ function AppInner() {
661641
const projectName =
662642
projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "Project";
663643
return (
664-
<ModeProvider projectPath={projectPath}>
665-
<ProviderOptionsProvider>
666-
<ThinkingProvider projectPath={projectPath}>
667-
<ConnectionStatusIndicator />
668-
<ChatInput
669-
variant="creation"
670-
projectPath={projectPath}
671-
projectName={projectName}
672-
onProviderConfig={handleProviderConfig}
673-
onReady={handleCreationChatReady}
674-
onWorkspaceCreated={(metadata) => {
675-
// IMPORTANT: Add workspace to store FIRST (synchronous) to ensure
676-
// the store knows about it before React processes the state updates.
677-
// This prevents race conditions where the UI tries to access the
678-
// workspace before the store has created its aggregator.
679-
workspaceStore.addWorkspace(metadata);
680-
681-
// Add to workspace metadata map (triggers React state update)
682-
setWorkspaceMetadata((prev) =>
683-
new Map(prev).set(metadata.id, metadata)
684-
);
685-
686-
// Only switch to new workspace if user hasn't selected another one
687-
// during the creation process (selectedWorkspace was null when creation started)
688-
setSelectedWorkspace((current) => {
689-
if (current !== null) {
690-
// User has already selected another workspace - don't override
691-
return current;
692-
}
693-
return toWorkspaceSelection(metadata);
694-
});
695-
696-
// Track telemetry
697-
telemetry.workspaceCreated(
698-
metadata.id,
699-
getRuntimeTypeForTelemetry(metadata.runtimeConfig)
700-
);
701-
702-
// Clear pending state
703-
clearPendingWorkspaceCreation();
704-
}}
705-
/>
706-
</ThinkingProvider>
707-
</ProviderOptionsProvider>
708-
</ModeProvider>
644+
<ProjectPage
645+
projectPath={projectPath}
646+
projectName={projectName}
647+
onProviderConfig={handleProviderConfig}
648+
onWorkspaceCreated={(metadata) => {
649+
// IMPORTANT: Add workspace to store FIRST (synchronous) to ensure
650+
// the store knows about it before React processes the state updates.
651+
// This prevents race conditions where the UI tries to access the
652+
// workspace before the store has created its aggregator.
653+
workspaceStore.addWorkspace(metadata);
654+
655+
// Add to workspace metadata map (triggers React state update)
656+
setWorkspaceMetadata((prev) => new Map(prev).set(metadata.id, metadata));
657+
658+
// Only switch to new workspace if user hasn't selected another one
659+
// during the creation process (selectedWorkspace was null when creation started)
660+
setSelectedWorkspace((current) => {
661+
if (current !== null) {
662+
// User has already selected another workspace - don't override
663+
return current;
664+
}
665+
return toWorkspaceSelection(metadata);
666+
});
667+
668+
// Track telemetry
669+
telemetry.workspaceCreated(
670+
metadata.id,
671+
getRuntimeTypeForTelemetry(metadata.runtimeConfig)
672+
);
673+
674+
// Clear pending state
675+
clearPendingWorkspaceCreation();
676+
}}
677+
/>
709678
);
710679
})()
711680
) : (

0 commit comments

Comments
 (0)