From a385ecd0e8dd02c7623f634ed6544da9b9e31377 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 21 Dec 2025 00:38:53 -0600 Subject: [PATCH 01/26] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20workspace=20?= =?UTF-8?q?archiving=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace delete with archive in the sidebar for a safe, reversible operation. Archived workspaces are hidden from the sidebar but visible on the welcome page where they can be restored or permanently deleted. Changes: - Add archived/archivedAt fields to workspace schemas - Add archive/unarchive API endpoints and handlers - Update ProjectSidebar to archive instead of delete - Filter archived workspaces from sidebar display - Add ArchivedWorkspaces component for welcome page - Add custom ArchiveIcon/ArchiveRestoreIcon SVG components - Remove ForceDeleteModal from sidebar (archiving is safe) --- _Generated with `mux` • Model: `anthropic:claude-opus-4-5` • Thinking: `high`_ --- src/browser/App.tsx | 30 ++- src/browser/components/ArchivedWorkspaces.tsx | 174 ++++++++++++++++++ src/browser/components/ProjectSidebar.tsx | 94 +++------- src/browser/components/WorkspaceListItem.tsx | 28 +-- src/browser/components/icons/ArchiveIcon.tsx | 54 ++++++ src/browser/contexts/WorkspaceContext.tsx | 54 ++++++ src/common/orpc/schemas/api.ts | 8 + src/common/orpc/schemas/project.ts | 7 + src/common/orpc/schemas/workspace.ts | 7 + src/node/orpc/router.ts | 12 ++ src/node/services/workspaceService.ts | 89 +++++++++ 11 files changed, 467 insertions(+), 90 deletions(-) create mode 100644 src/browser/components/ArchivedWorkspaces.tsx create mode 100644 src/browser/components/icons/ArchiveIcon.tsx diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 32a9b46024..1dde71ffe9 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -48,6 +48,7 @@ import { getRuntimeTypeForTelemetry } from "@/common/telemetry"; import { useStartWorkspaceCreation, getFirstProjectPath } from "./hooks/useStartWorkspaceCreation"; import { useAPI } from "@/browser/contexts/API"; import { AuthTokenModal } from "@/browser/components/AuthTokenModal"; +import { ArchivedWorkspaces } from "@/browser/components/ArchivedWorkspaces"; import { SettingsProvider, useSettings } from "./contexts/SettingsContext"; import { SettingsModal } from "./components/Settings/SettingsModal"; @@ -710,16 +711,35 @@ function AppInner() { })() ) : (
-

- Welcome to Mux -

-

Select a workspace from the sidebar or add a new one to get started.

+
+

+ Welcome to Mux +

+

Select a workspace from the sidebar or add a new one to get started.

+
+ {/* Show archived workspaces for each project */} + {Array.from(sortedWorkspacesByProject.entries()).map( + ([projectPath, workspaces]) => { + const archivedWorkspaces = workspaces.filter((w) => w.archived); + if (archivedWorkspaces.length === 0) return null; + const projectName = + projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "Project"; + return ( + + ); + } + )}
)} diff --git a/src/browser/components/ArchivedWorkspaces.tsx b/src/browser/components/ArchivedWorkspaces.tsx new file mode 100644 index 0000000000..0dd8dad7fe --- /dev/null +++ b/src/browser/components/ArchivedWorkspaces.tsx @@ -0,0 +1,174 @@ +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, ChevronDown, ChevronRight } from "lucide-react"; +import { ArchiveIcon, ArchiveRestoreIcon } from "./icons/ArchiveIcon"; +import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip"; +import { RuntimeBadge } from "./RuntimeBadge"; +import { usePersistedState } from "@/browser/hooks/usePersistedState"; + +interface ArchivedWorkspacesProps { + projectPath: string; + projectName: string; + workspaces: FrontendWorkspaceMetadata[]; +} + +/** + * 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, +}) => { + const { unarchiveWorkspace, removeWorkspace, setSelectedWorkspace } = useWorkspaceContext(); + const [expanded, setExpanded] = usePersistedState("archivedWorkspacesExpanded", true); + const [processingIds, setProcessingIds] = React.useState>(new Set()); + const [deleteConfirmId, setDeleteConfirmId] = React.useState(null); + + const archivedWorkspaces = workspaces.filter((w) => w.archived); + + if (archivedWorkspaces.length === 0) { + return null; + } + + 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 = archivedWorkspaces.find((w) => w.id === workspaceId); + if (workspace) { + setSelectedWorkspace({ + workspaceId: workspace.id, + projectPath: workspace.projectPath, + projectName: workspace.projectName, + namedWorkspacePath: workspace.namedWorkspacePath, + }); + } + } + } 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)); + setDeleteConfirmId(null); + try { + await removeWorkspace(workspaceId, { force: true }); + } finally { + setProcessingIds((prev) => { + const next = new Set(prev); + next.delete(workspaceId); + return next; + }); + } + }; + + return ( +
+ + + {expanded && ( +
+ {archivedWorkspaces.map((workspace) => { + const isProcessing = processingIds.has(workspace.id); + const isDeleting = deleteConfirmId === workspace.id; + const displayTitle = workspace.title ?? workspace.name; + + return ( +
+ +
+
{displayTitle}
+
+ Archived{" "} + {workspace.archivedAt + ? new Date(workspace.archivedAt).toLocaleDateString() + : "recently"} +
+
+ + {isDeleting ? ( +
+ Delete permanently? + + +
+ ) : ( +
+ + + + + Restore to sidebar + + + + + + Delete permanently + +
+ )} +
+ ); + })} +
+ )} +
+ ); +}; diff --git a/src/browser/components/ProjectSidebar.tsx b/src/browser/components/ProjectSidebar.tsx index 311cf570e2..a5bd1aa102 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,8 +567,10 @@ const ProjectSidebarInner: React.FC = ({ className="pt-1" > {(() => { - const allWorkspaces = - sortedWorkspacesByProject.get(projectPath) ?? []; + // Filter out archived workspaces from the sidebar + const allWorkspaces = ( + sortedWorkspacesByProject.get(projectPath) ?? [] + ).filter((w) => !w.archived); const depthByWorkspaceId = computeWorkspaceDepthMap(allWorkspaces); const { recent, buckets } = partitionWorkspacesByAge( allWorkspaces, @@ -623,10 +584,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 +695,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 +38,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 +115,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,8 +145,8 @@ const WorkspaceListItemInner: React.FC = ({ aria-label={ isCreating ? `Creating workspace ${displayTitle}` - : isDeleting - ? `Deleting workspace ${displayTitle}` + : isArchiving + ? `Archiving workspace ${displayTitle}` : `Select workspace ${displayTitle}` } aria-disabled={isDisabled} @@ -162,15 +162,15 @@ const WorkspaceListItemInner: React.FC = ({ className="text-muted hover:text-foreground inline-flex cursor-pointer items-center border-none bg-transparent p-0 text-base leading-none opacity-0 transition-colors duration-200" onClick={(e) => { e.stopPropagation(); - void onRemoveWorkspace(workspaceId, e.currentTarget); + void onArchiveWorkspace(workspaceId, e.currentTarget); }} - aria-label={`Remove workspace ${displayTitle}`} + aria-label={`Archive workspace ${displayTitle}`} data-workspace-id={workspaceId} > × - Remove workspace + Archive workspace )} @@ -225,10 +225,10 @@ const WorkspaceListItemInner: React.FC = ({ {!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..3e60002a23 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -108,6 +108,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> @@ -522,6 +524,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 +608,8 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { createWorkspace, removeWorkspace, renameWorkspace, + archiveWorkspace, + unarchiveWorkspace, refreshWorkspaceMetadata, setWorkspaceMetadata, selectedWorkspace, @@ -573,6 +625,8 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { createWorkspace, removeWorkspace, renameWorkspace, + archiveWorkspace, + unarchiveWorkspace, refreshWorkspaceMetadata, setWorkspaceMetadata, selectedWorkspace, diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index dfef9cb113..fe7309aacd 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -260,6 +260,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..c03df722ab 100644 --- a/src/common/orpc/schemas/project.ts +++ b/src/common/orpc/schemas/project.ts @@ -60,6 +60,13 @@ export const WorkspaceConfigSchema = z.object({ mcp: WorkspaceMCPOverridesSchema.optional().meta({ description: "Per-workspace MCP overrides (disabled servers, tool allowlists)", }), + archived: z.boolean().optional().meta({ + description: + "When true, workspace is archived. Archived workspaces are hidden from the main sidebar but visible on the project page. Safe and reversible.", + }), + archivedAt: z.string().optional().meta({ + description: "ISO 8601 timestamp when workspace was archived (optional)", + }), }); export const ProjectConfigSchema = z.object({ diff --git a/src/common/orpc/schemas/workspace.ts b/src/common/orpc/schemas/workspace.ts index 13d66280f7..69ae742894 100644 --- a/src/common/orpc/schemas/workspace.ts +++ b/src/common/orpc/schemas/workspace.ts @@ -64,6 +64,13 @@ export const WorkspaceMetadataSchema = z.object({ description: "Workspace creation status. 'creating' = pending setup (ephemeral, not persisted). Absent = ready.", }), + archived: z.boolean().optional().meta({ + description: + "When true, workspace is archived. Archived workspaces are hidden from main sidebar but visible on project page.", + }), + archivedAt: z.string().optional().meta({ + description: "ISO 8601 timestamp when workspace was archived", + }), }); export const FrontendWorkspaceMetadataSchema = WorkspaceMetadataSchema.extend({ diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index d4b59d0439..a79e0882aa 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -632,6 +632,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..4825db1041 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -914,6 +914,95 @@ 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; + + 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) { + workspaceEntry.archived = true; + 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) { + workspaceEntry.archived = undefined; + workspaceEntry.archivedAt = undefined; + } + } + 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 { From 7ff7da03a6d6411a8d4fa95916432317e1916001 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 21 Dec 2025 00:40:00 -0600 Subject: [PATCH 02/26] =?UTF-8?q?fix:=20use=20ArchiveIcon=20instead=20of?= =?UTF-8?q?=20=C3=97=20in=20sidebar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/components/WorkspaceListItem.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/browser/components/WorkspaceListItem.tsx b/src/browser/components/WorkspaceListItem.tsx index 7857819e5d..1eb84db6e0 100644 --- a/src/browser/components/WorkspaceListItem.tsx +++ b/src/browser/components/WorkspaceListItem.tsx @@ -9,6 +9,7 @@ import { RuntimeBadge } from "./RuntimeBadge"; import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip"; import { WorkspaceStatusIndicator } from "./WorkspaceStatusIndicator"; import { Shimmer } from "./ai-elements/shimmer"; +import { ArchiveIcon } from "./icons/ArchiveIcon"; export interface WorkspaceSelection { projectPath: string; @@ -159,7 +160,7 @@ const WorkspaceListItemInner: React.FC = ({ Archive workspace From 531a7a18e11dca4e7adef1ebf1f2126a2e77d236 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 21 Dec 2025 00:42:25 -0600 Subject: [PATCH 03/26] fix: make archive icon smaller --- src/browser/components/WorkspaceListItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/components/WorkspaceListItem.tsx b/src/browser/components/WorkspaceListItem.tsx index 1eb84db6e0..9750753ef2 100644 --- a/src/browser/components/WorkspaceListItem.tsx +++ b/src/browser/components/WorkspaceListItem.tsx @@ -168,7 +168,7 @@ const WorkspaceListItemInner: React.FC = ({ aria-label={`Archive workspace ${displayTitle}`} data-workspace-id={workspaceId} > - + Archive workspace From 44dc9357032f26a57f69bb86a5da4fc75c7a36fd Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 21 Dec 2025 01:05:26 -0600 Subject: [PATCH 04/26] refactor: create ProjectPage component with archived workspaces Extract workspace creation + archived workspaces into a dedicated ProjectPage component that shows when a project is selected but no workspace is active. - Creates ProjectPage.tsx combining ChatInput (creation) with ArchivedWorkspaces - Simplifies App.tsx by delegating to ProjectPage - Removes unused imports and refs from App.tsx --- src/browser/App.tsx | 135 ++++++++----------------- src/browser/components/ProjectPage.tsx | 75 ++++++++++++++ 2 files changed, 118 insertions(+), 92 deletions(-) create mode 100644 src/browser/components/ProjectPage.tsx diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 1dde71ffe9..b25a6dee75 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,13 +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 { ArchivedWorkspaces } from "@/browser/components/ArchivedWorkspaces"; +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"; @@ -103,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); @@ -503,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,85 +640,57 @@ function AppInner() { const projectPath = creationProjectPath; const projectName = projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "Project"; + const projectWorkspaces = sortedWorkspacesByProject.get(projectPath) ?? []; 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(); + }} + /> ); })() ) : (
-
-

- Welcome to Mux -

-

Select a workspace from the sidebar or add a new one to get started.

-
- {/* Show archived workspaces for each project */} - {Array.from(sortedWorkspacesByProject.entries()).map( - ([projectPath, workspaces]) => { - const archivedWorkspaces = workspaces.filter((w) => w.archived); - if (archivedWorkspaces.length === 0) return null; - const projectName = - projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "Project"; - return ( - - ); - } - )} +

+ Welcome to Mux +

+

Select a workspace from the sidebar or add a new one to get started.

)}
diff --git a/src/browser/components/ProjectPage.tsx b/src/browser/components/ProjectPage.tsx new file mode 100644 index 0000000000..fc2e9d0e44 --- /dev/null +++ b/src/browser/components/ProjectPage.tsx @@ -0,0 +1,75 @@ +import React, { useRef, useCallback } 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"; + +interface ProjectPageProps { + projectPath: string; + projectName: string; + workspaces: FrontendWorkspaceMetadata[]; + 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, + workspaces, + onProviderConfig, + onWorkspaceCreated, +}) => { + const chatInputRef = useRef(null); + + const handleChatReady = useCallback((api: ChatInputAPI) => { + chatInputRef.current = api; + api.focus(); + }, []); + + const archivedWorkspaces = workspaces.filter((w) => w.archived); + + return ( + + + +
+ {/* Main content area */} +
+
+ {/* Archived workspaces section */} + {archivedWorkspaces.length > 0 && ( + + )} +
+
+ + {/* Chat input pinned to bottom */} +
+ + +
+
+
+
+
+ ); +}; From ae42ce3d5de5a51d21936d9d53391ac077a98dee Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 21 Dec 2025 01:11:12 -0600 Subject: [PATCH 05/26] fix: archived workspaces no longer tracked by app - Filter archived workspaces out of workspaceMetadata map during load - Backend workspace.list now filters by archived status (archivedOnly param) - ProjectPage fetches archived workspaces separately via API - Add onWorkspacesChanged callback to refresh after unarchive/delete - Remove redundant sidebar filter (archived already excluded from map) --- src/browser/App.tsx | 2 - src/browser/components/ArchivedWorkspaces.tsx | 5 +++ src/browser/components/ProjectPage.tsx | 41 ++++++++++++++++--- src/browser/components/ProjectSidebar.tsx | 7 ++-- src/browser/contexts/WorkspaceContext.tsx | 2 + src/common/orpc/schemas/api.ts | 2 + src/node/orpc/router.ts | 12 +++++- 7 files changed, 57 insertions(+), 14 deletions(-) diff --git a/src/browser/App.tsx b/src/browser/App.tsx index b25a6dee75..3267245f95 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -640,12 +640,10 @@ function AppInner() { const projectPath = creationProjectPath; const projectName = projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "Project"; - const projectWorkspaces = sortedWorkspacesByProject.get(projectPath) ?? []; return ( { // IMPORTANT: Add workspace to store FIRST (synchronous) to ensure diff --git a/src/browser/components/ArchivedWorkspaces.tsx b/src/browser/components/ArchivedWorkspaces.tsx index 0dd8dad7fe..b2a7ba6abd 100644 --- a/src/browser/components/ArchivedWorkspaces.tsx +++ b/src/browser/components/ArchivedWorkspaces.tsx @@ -12,6 +12,8 @@ interface ArchivedWorkspacesProps { projectPath: string; projectName: string; workspaces: FrontendWorkspaceMetadata[]; + /** Called after a workspace is unarchived or deleted to refresh the list */ + onWorkspacesChanged?: () => void; } /** @@ -22,6 +24,7 @@ export const ArchivedWorkspaces: React.FC = ({ projectPath: _projectPath, projectName: _projectName, workspaces, + onWorkspacesChanged, }) => { const { unarchiveWorkspace, removeWorkspace, setSelectedWorkspace } = useWorkspaceContext(); const [expanded, setExpanded] = usePersistedState("archivedWorkspacesExpanded", true); @@ -49,6 +52,7 @@ export const ArchivedWorkspaces: React.FC = ({ namedWorkspacePath: workspace.namedWorkspacePath, }); } + onWorkspacesChanged?.(); } } finally { setProcessingIds((prev) => { @@ -64,6 +68,7 @@ export const ArchivedWorkspaces: React.FC = ({ setDeleteConfirmId(null); try { await removeWorkspace(workspaceId, { force: true }); + onWorkspacesChanged?.(); } finally { setProcessingIds((prev) => { const next = new Set(prev); diff --git a/src/browser/components/ProjectPage.tsx b/src/browser/components/ProjectPage.tsx index fc2e9d0e44..55762b6778 100644 --- a/src/browser/components/ProjectPage.tsx +++ b/src/browser/components/ProjectPage.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useCallback } from "react"; +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"; @@ -7,11 +7,11 @@ 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; - workspaces: FrontendWorkspaceMetadata[]; onProviderConfig: (provider: string, keyPath: string[], value: string) => Promise; onWorkspaceCreated: (metadata: FrontendWorkspaceMetadata) => void; } @@ -23,19 +23,41 @@ interface ProjectPageProps { export const ProjectPage: React.FC = ({ projectPath, projectName, - workspaces, 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({ archivedOnly: 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(); }, []); - const archivedWorkspaces = workspaces.filter((w) => w.archived); - return ( @@ -49,7 +71,14 @@ export const ProjectPage: React.FC = ({ { + // Refresh archived list after unarchive/delete + if (!api) return; + void api.workspace.list({ archivedOnly: 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 a5bd1aa102..4f73c31d66 100644 --- a/src/browser/components/ProjectSidebar.tsx +++ b/src/browser/components/ProjectSidebar.tsx @@ -567,10 +567,9 @@ const ProjectSidebarInner: React.FC = ({ className="pt-1" > {(() => { - // Filter out archived workspaces from the sidebar - const allWorkspaces = ( - sortedWorkspacesByProject.get(projectPath) ?? [] - ).filter((w) => !w.archived); + // Archived workspaces are excluded from workspaceMetadata so won't appear here + const allWorkspaces = + sortedWorkspacesByProject.get(projectPath) ?? []; const depthByWorkspaceId = computeWorkspaceDepthMap(allWorkspaces); const { recent, buckets } = partitionWorkspacesByAge( allWorkspaces, diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index 3e60002a23..5a670d72cd 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -185,6 +185,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 (metadata.archived) continue; ensureCreatedAt(metadata); // Use stable workspace ID as key (not path, which can change) seedWorkspaceLocalStorageFromBackend(metadata); diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index fe7309aacd..52d452ab6f 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. */ + archivedOnly: z.boolean().optional(), }) .optional(), output: z.array(FrontendWorkspaceMetadataSchema), diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index a79e0882aa..80962dcfe5 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -582,8 +582,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 + if (input?.archivedOnly) { + return allWorkspaces.filter((w) => w.archived); + } + // Default: return non-archived workspaces + return allWorkspaces.filter((w) => !w.archived); }), create: t .input(schemas.workspace.create.input) From 14fdb1894611f7a13f89f2be6932fe1a4d5c22ec Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 21 Dec 2025 11:45:14 -0600 Subject: [PATCH 06/26] fix: vertically center archive icon in sidebar Move archive button outside the inner flex-col so it centers against the entire workspace item (title + status row) rather than just the title row. --- src/browser/components/WorkspaceListItem.tsx | 41 ++++++++++---------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/src/browser/components/WorkspaceListItem.tsx b/src/browser/components/WorkspaceListItem.tsx index 9750753ef2..9e733507c7 100644 --- a/src/browser/components/WorkspaceListItem.tsx +++ b/src/browser/components/WorkspaceListItem.tsx @@ -154,26 +154,27 @@ const WorkspaceListItemInner: React.FC = ({ data-workspace-path={namedWorkspacePath} data-workspace-id={workspaceId} > + {/* Archive button - vertically centered against entire item */} + {!isCreating && !isEditing && ( + + + + + Archive workspace + + )}
-
- {!isCreating && !isEditing && ( - - - - - Archive workspace - - )} +
{isEditing ? ( = ({ )}
{!isCreating && ( -
+
{isArchiving ? (
📦 From cff002cd20c0337bde31c53faaee80216b939b1c Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 21 Dec 2025 11:46:47 -0600 Subject: [PATCH 07/26] fix: simplify ArchivedWorkspaces - workspaces prop already filtered --- src/browser/components/ArchivedWorkspaces.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/browser/components/ArchivedWorkspaces.tsx b/src/browser/components/ArchivedWorkspaces.tsx index b2a7ba6abd..a2e5fc7dbd 100644 --- a/src/browser/components/ArchivedWorkspaces.tsx +++ b/src/browser/components/ArchivedWorkspaces.tsx @@ -31,9 +31,8 @@ export const ArchivedWorkspaces: React.FC = ({ const [processingIds, setProcessingIds] = React.useState>(new Set()); const [deleteConfirmId, setDeleteConfirmId] = React.useState(null); - const archivedWorkspaces = workspaces.filter((w) => w.archived); - - if (archivedWorkspaces.length === 0) { + // workspaces prop should already be filtered to archived only + if (workspaces.length === 0) { return null; } @@ -43,7 +42,7 @@ export const ArchivedWorkspaces: React.FC = ({ const result = await unarchiveWorkspace(workspaceId); if (result.success) { // Select the workspace after unarchiving - const workspace = archivedWorkspaces.find((w) => w.id === workspaceId); + const workspace = workspaces.find((w) => w.id === workspaceId); if (workspace) { setSelectedWorkspace({ workspaceId: workspace.id, @@ -86,7 +85,7 @@ export const ArchivedWorkspaces: React.FC = ({ > - Archived Workspaces ({archivedWorkspaces.length}) + Archived Workspaces ({workspaces.length}) {expanded ? ( @@ -97,7 +96,7 @@ export const ArchivedWorkspaces: React.FC = ({ {expanded && (
- {archivedWorkspaces.map((workspace) => { + {workspaces.map((workspace) => { const isProcessing = processingIds.has(workspace.id); const isDeleting = deleteConfirmId === workspace.id; const displayTitle = workspace.title ?? workspace.name; From c96bb53605694ab4f0557a4fc87b7433077dbc05 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 21 Dec 2025 11:49:10 -0600 Subject: [PATCH 08/26] fix: improve ProjectPage layout - center chat input, show archived above - Use flex spacers to vertically center content - Move ConnectionStatusIndicator to top - Chat input inside scrollable area (centered when no archived workspaces) - Archived workspaces shown above chat input with proper spacing --- src/browser/components/ProjectPage.tsx | 35 +++++++++++++++----------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/src/browser/components/ProjectPage.tsx b/src/browser/components/ProjectPage.tsx index 55762b6778..bf83315f5e 100644 --- a/src/browser/components/ProjectPage.tsx +++ b/src/browser/components/ProjectPage.tsx @@ -62,9 +62,14 @@ export const ProjectPage: React.FC = ({ +
- {/* Main content area */} -
+ {/* Scrollable content area with centered chat input */} +
+ {/* Spacer to push content toward center when no archived workspaces */} +
+ + {/* Main content container */}
{/* Archived workspaces section */} {archivedWorkspaces.length > 0 && ( @@ -81,20 +86,22 @@ export const ProjectPage: React.FC = ({ }} /> )} + + {/* Chat input for creating new workspace */} +
0 ? "mt-8" : ""}> + +
-
- {/* Chat input pinned to bottom */} -
- - + {/* Spacer to push content toward center */} +
From 3160f1c8b8511f658a7b17ae60f9173675d9775d Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 21 Dec 2025 11:55:27 -0600 Subject: [PATCH 09/26] fix: propagate archived/archivedAt fields in getAllWorkspaceMetadata The archived fields were being stored in config but not copied to the WorkspaceMetadata objects returned by getAllWorkspaceMetadata(). This caused archived workspaces to still appear in sidebar and prevented the archivedOnly filter from working. --- src/node/config.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/node/config.ts b/src/node/config.ts index a7ccccff2a..037316ca1b 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, + archived: workspace.archived, + archivedAt: workspace.archivedAt, }; // 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 status from config + metadata.archived ??= workspace.archived; + metadata.archivedAt ??= workspace.archivedAt; // 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, + archived: workspace.archived, + archivedAt: workspace.archivedAt, }; // Save to config for next load From 54295cb47ac57843b7b4c5d401ef604f1d155bb9 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 21 Dec 2025 11:58:50 -0600 Subject: [PATCH 10/26] fix: reorder ProjectPage - creation above archived, ensure centered --- src/browser/components/ProjectPage.tsx | 50 +++++++++++++------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/src/browser/components/ProjectPage.tsx b/src/browser/components/ProjectPage.tsx index bf83315f5e..baf359e276 100644 --- a/src/browser/components/ProjectPage.tsx +++ b/src/browser/components/ProjectPage.tsx @@ -69,35 +69,35 @@ export const ProjectPage: React.FC = ({ {/* Spacer to push content toward center when no archived workspaces */}
- {/* Main content container */} + {/* Main content container - horizontally centered */}
+ {/* Chat input for creating new workspace */} + + {/* Archived workspaces section */} {archivedWorkspaces.length > 0 && ( - { - // Refresh archived list after unarchive/delete - if (!api) return; - void api.workspace.list({ archivedOnly: true }).then((all) => { - setArchivedWorkspaces(all.filter((w) => w.projectPath === projectPath)); - }); - }} - /> +
+ { + // Refresh archived list after unarchive/delete + if (!api) return; + void api.workspace.list({ archivedOnly: true }).then((all) => { + setArchivedWorkspaces(all.filter((w) => w.projectPath === projectPath)); + }); + }} + /> +
)} - - {/* Chat input for creating new workspace */} -
0 ? "mt-8" : ""}> - -
{/* Spacer to push content toward center */} From 28e1a72eec91c6e1bf6c1022477bf426bc26c652 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 21 Dec 2025 12:00:38 -0600 Subject: [PATCH 11/26] fix: simplify ProjectPage layout to match main - let ChatInput handle centering --- src/browser/components/ProjectPage.tsx | 65 ++++++++++---------------- 1 file changed, 25 insertions(+), 40 deletions(-) diff --git a/src/browser/components/ProjectPage.tsx b/src/browser/components/ProjectPage.tsx index baf359e276..1a0723215b 100644 --- a/src/browser/components/ProjectPage.tsx +++ b/src/browser/components/ProjectPage.tsx @@ -63,47 +63,32 @@ export const ProjectPage: React.FC = ({ -
- {/* Scrollable content area with centered chat input */} -
- {/* Spacer to push content toward center when no archived workspaces */} -
- - {/* Main content container - horizontally centered */} -
- {/* Chat input for creating new workspace */} - - - {/* Archived workspaces section */} - {archivedWorkspaces.length > 0 && ( -
- { - // Refresh archived list after unarchive/delete - if (!api) return; - void api.workspace.list({ archivedOnly: true }).then((all) => { - setArchivedWorkspaces(all.filter((w) => w.projectPath === projectPath)); - }); - }} - /> -
- )} -
- - {/* Spacer to push content toward center */} -
+ {/* ChatInput with variant="creation" provides its own centered layout */} + + {/* Archived workspaces fixed at bottom */} + {archivedWorkspaces.length > 0 && ( +
+ { + // Refresh archived list after unarchive/delete + if (!api) return; + void api.workspace.list({ archivedOnly: true }).then((all) => { + setArchivedWorkspaces(all.filter((w) => w.projectPath === projectPath)); + }); + }} + />
-
+ )} From e295aa19f4c5bfc947984c227888c1ab0d15773a Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 21 Dec 2025 12:02:08 -0600 Subject: [PATCH 12/26] fix: properly center archived workspaces using translate-x-1/2 --- src/browser/components/ProjectPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/browser/components/ProjectPage.tsx b/src/browser/components/ProjectPage.tsx index 1a0723215b..6821c08450 100644 --- a/src/browser/components/ProjectPage.tsx +++ b/src/browser/components/ProjectPage.tsx @@ -72,9 +72,9 @@ export const ProjectPage: React.FC = ({ onReady={handleChatReady} onWorkspaceCreated={onWorkspaceCreated} /> - {/* Archived workspaces fixed at bottom */} + {/* Archived workspaces pinned to bottom, horizontally centered */} {archivedWorkspaces.length > 0 && ( -
+
Date: Sun, 21 Dec 2025 12:04:36 -0600 Subject: [PATCH 13/26] feat: add Storybook story for ProjectPage with archived workspaces --- .storybook/mocks/orpc.ts | 9 ++++++- src/browser/stories/App.welcome.stories.tsx | 29 +++++++++++++++++++++ src/browser/stories/mockFactory.ts | 16 ++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/.storybook/mocks/orpc.ts b/.storybook/mocks/orpc.ts index 7318d3d302..e40845913d 100644 --- a/.storybook/mocks/orpc.ts +++ b/.storybook/mocks/orpc.ts @@ -251,7 +251,14 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl }, }, workspace: { - list: async () => workspaces, + list: async (input?: { archivedOnly?: boolean }) => { + if (input?.archivedOnly) { + return workspaces.filter((w) => w.archived); + } + return workspaces.filter((w) => !w.archived); + }, + archive: async () => ({ success: true }), + unarchive: async () => ({ success: true }), create: async (input: { projectPath: string; branchName: string }) => ({ success: true, metadata: { diff --git a/src/browser/stories/App.welcome.stories.tsx b/src/browser/stories/App.welcome.stories.tsx index 6b69466768..185f7a57ac 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 } from "./mockFactory"; import type { ProjectConfig } from "@/node/config"; export default { @@ -68,3 +69,31 @@ export const CreateWorkspaceMultipleProjects: AppStory = { /> ), }; + +/** Creation view with archived workspaces - shows archived section at bottom */ +export const CreateWorkspaceWithArchived: AppStory = { + render: () => ( + { + expandProjects(["/Users/dev/my-project"]); + return createMockORPCClient({ + projects: new Map([projectWithNoWorkspaces("/Users/dev/my-project")]), + workspaces: [ + createArchivedWorkspace({ + id: "archived-1", + name: "feature/old-feature", + projectName: "my-project", + projectPath: "/Users/dev/my-project", + }), + createArchivedWorkspace({ + id: "archived-2", + name: "bugfix/resolved-issue", + projectName: "my-project", + projectPath: "/Users/dev/my-project", + }), + ], + }); + }} + /> + ), +}; diff --git a/src/browser/stories/mockFactory.ts b/src/browser/stories/mockFactory.ts index 97499d3712..04933fe2ae 100644 --- a/src/browser/stories/mockFactory.ts +++ b/src/browser/stories/mockFactory.ts @@ -104,6 +104,22 @@ export function createIncompatibleWorkspace( }; } +/** Create an archived workspace */ +export function createArchivedWorkspace( + opts: Partial & { + id: string; + name: string; + projectName: string; + archivedAt?: string; + } +): FrontendWorkspaceMetadata { + return { + ...createWorkspace(opts), + archived: true, + archivedAt: opts.archivedAt ?? new Date(NOW - 86400000).toISOString(), // 1 day ago + }; +} + // ═══════════════════════════════════════════════════════════════════════════════ // PROJECT FACTORY // ═══════════════════════════════════════════════════════════════════════════════ From d20d4b9dc526aee9e6fb66d411aafe682611f412 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 21 Dec 2025 12:11:37 -0600 Subject: [PATCH 14/26] fix: clean CSS layout for ProjectPage - siblings with flex distribution - Remove ChatInput's internal wrapper for creation variant - ProjectPage handles layout: scrollable flex column with spacers - Chat input centered, archived workspaces at bottom - No overlap when height constrained (content scrolls) - Renamed archivedOnly to archived in API - Added search and timeline grouping to ArchivedWorkspaces --- .storybook/mocks/orpc.ts | 4 +- src/browser/components/ArchivedWorkspaces.tsx | 247 ++++++++++++------ src/browser/components/ChatInput/index.tsx | 9 +- src/browser/components/ProjectPage.tsx | 61 +++-- src/browser/stories/App.welcome.stories.tsx | 80 ++++-- src/common/orpc/schemas/api.ts | 2 +- src/node/orpc/router.ts | 2 +- 7 files changed, 267 insertions(+), 138 deletions(-) diff --git a/.storybook/mocks/orpc.ts b/.storybook/mocks/orpc.ts index e40845913d..56fb7909dc 100644 --- a/.storybook/mocks/orpc.ts +++ b/.storybook/mocks/orpc.ts @@ -251,8 +251,8 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl }, }, workspace: { - list: async (input?: { archivedOnly?: boolean }) => { - if (input?.archivedOnly) { + list: async (input?: { archived?: boolean }) => { + if (input?.archived) { return workspaces.filter((w) => w.archived); } return workspaces.filter((w) => !w.archived); diff --git a/src/browser/components/ArchivedWorkspaces.tsx b/src/browser/components/ArchivedWorkspaces.tsx index a2e5fc7dbd..f06a95fa26 100644 --- a/src/browser/components/ArchivedWorkspaces.tsx +++ b/src/browser/components/ArchivedWorkspaces.tsx @@ -2,7 +2,7 @@ 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, ChevronDown, ChevronRight } from "lucide-react"; +import { Trash2, ChevronDown, ChevronRight, Search } from "lucide-react"; import { ArchiveIcon, ArchiveRestoreIcon } from "./icons/ArchiveIcon"; import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip"; import { RuntimeBadge } from "./RuntimeBadge"; @@ -16,6 +16,49 @@ interface ArchivedWorkspacesProps { onWorkspacesChanged?: () => void; } +/** 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; +} + /** * Section showing archived workspaces for a project. * Appears on the project page when there are archived workspaces. @@ -28,6 +71,7 @@ export const ArchivedWorkspaces: React.FC = ({ }) => { const { unarchiveWorkspace, removeWorkspace, setSelectedWorkspace } = useWorkspaceContext(); const [expanded, setExpanded] = usePersistedState("archivedWorkspacesExpanded", true); + const [searchQuery, setSearchQuery] = React.useState(""); const [processingIds, setProcessingIds] = React.useState>(new Set()); const [deleteConfirmId, setDeleteConfirmId] = React.useState(null); @@ -36,6 +80,19 @@ export const ArchivedWorkspaces: React.FC = ({ 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 handleUnarchive = async (workspaceId: string) => { setProcessingIds((prev) => new Set(prev).add(workspaceId)); try { @@ -78,7 +135,7 @@ export const ArchivedWorkspaces: React.FC = ({ }; return ( -
+
- -
- ) : ( -
- - - - - Restore to sidebar - - - - - - Delete permanently - -
- )} + {/* 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 isDeleting = deleteConfirmId === workspace.id; + const displayTitle = workspace.title ?? workspace.name; + + return ( +
+ +
+
+ {displayTitle} +
+ {workspace.archivedAt && ( +
+ {new Date(workspace.archivedAt).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + })} +
+ )} +
+ + {isDeleting ? ( +
+ Delete? + + +
+ ) : ( +
+ + + + + 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 index 6821c08450..e939e59060 100644 --- a/src/browser/components/ProjectPage.tsx +++ b/src/browser/components/ProjectPage.tsx @@ -37,7 +37,7 @@ export const ProjectPage: React.FC = ({ const loadArchived = async () => { try { - const allArchived = await api.workspace.list({ archivedOnly: true }); + 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); @@ -63,32 +63,39 @@ export const ProjectPage: React.FC = ({ - {/* ChatInput with variant="creation" provides its own centered layout */} - - {/* Archived workspaces pinned to bottom, horizontally centered */} - {archivedWorkspaces.length > 0 && ( -
- { - // Refresh archived list after unarchive/delete - if (!api) return; - void api.workspace.list({ archivedOnly: true }).then((all) => { - setArchivedWorkspaces(all.filter((w) => w.projectPath === projectPath)); - }); - }} - /> -
- )} + {/* Scrollable flex column: chat centered, archived at bottom */} +
+ {/* Spacer pushes chat toward center */} +
+ {/* Chat input card */} + + {/* Spacer between chat and archived */} +
+ {/* Archived workspaces at bottom */} + {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/stories/App.welcome.stories.tsx b/src/browser/stories/App.welcome.stories.tsx index 185f7a57ac..c81f99751f 100644 --- a/src/browser/stories/App.welcome.stories.tsx +++ b/src/browser/stories/App.welcome.stories.tsx @@ -5,7 +5,7 @@ import { appMeta, AppWithMocks, type AppStory } from "./meta.js"; import { createMockORPCClient } from "../../../.storybook/mocks/orpc"; import { expandProjects } from "./storyHelpers"; -import { createArchivedWorkspace } from "./mockFactory"; +import { createArchivedWorkspace, NOW } from "./mockFactory"; import type { ProjectConfig } from "@/node/config"; export default { @@ -70,30 +70,58 @@ export const CreateWorkspaceMultipleProjects: AppStory = { ), }; -/** Creation view with archived workspaces - shows archived section at bottom */ +/** Creation view with archived workspaces - shows timeline grouped archived section */ export const CreateWorkspaceWithArchived: AppStory = { - render: () => ( - { - expandProjects(["/Users/dev/my-project"]); - return createMockORPCClient({ - projects: new Map([projectWithNoWorkspaces("/Users/dev/my-project")]), - workspaces: [ - createArchivedWorkspace({ - id: "archived-1", - name: "feature/old-feature", - projectName: "my-project", - projectPath: "/Users/dev/my-project", - }), - createArchivedWorkspace({ - id: "archived-2", - name: "bugfix/resolved-issue", - projectName: "my-project", - projectPath: "/Users/dev/my-project", - }), - ], - }); - }} - /> - ), + render: () => { + // Use stable timestamp for deterministic visual tests + const DAY = 86400000; + return ( + { + expandProjects(["/Users/dev/my-project"]); + return createMockORPCClient({ + projects: new Map([projectWithNoWorkspaces("/Users/dev/my-project")]), + workspaces: [ + // Recent (shows relative time in timeline) + createArchivedWorkspace({ + id: "archived-1", + name: "feature/new-ui", + projectName: "my-project", + projectPath: "/Users/dev/my-project", + archivedAt: new Date(NOW - 2 * 3600000).toISOString(), + }), + createArchivedWorkspace({ + id: "archived-2", + name: "bugfix/login-issue", + projectName: "my-project", + projectPath: "/Users/dev/my-project", + archivedAt: new Date(NOW - DAY - 3600000).toISOString(), + }), + createArchivedWorkspace({ + id: "archived-3", + name: "feature/dark-mode", + projectName: "my-project", + projectPath: "/Users/dev/my-project", + archivedAt: new Date(NOW - 3 * DAY).toISOString(), + }), + createArchivedWorkspace({ + id: "archived-4", + name: "refactor/cleanup", + projectName: "my-project", + projectPath: "/Users/dev/my-project", + archivedAt: new Date(NOW - 5 * DAY).toISOString(), + }), + createArchivedWorkspace({ + id: "archived-5", + name: "feature/api-v2", + projectName: "my-project", + projectPath: "/Users/dev/my-project", + archivedAt: new Date(NOW - 15 * DAY).toISOString(), + }), + ], + }); + }} + /> + ); + }, }; diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 52d452ab6f..5121e31d3b 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -220,7 +220,7 @@ export const workspace = { .object({ includePostCompaction: z.boolean().optional(), /** When true, only return archived workspaces. Default returns only non-archived. */ - archivedOnly: z.boolean().optional(), + archived: z.boolean().optional(), }) .optional(), output: z.array(FrontendWorkspaceMetadataSchema), diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 80962dcfe5..4b750b0810 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -587,7 +587,7 @@ export const router = (authToken?: string) => { includePostCompaction: input?.includePostCompaction, }); // Filter by archived status - if (input?.archivedOnly) { + if (input?.archived) { return allWorkspaces.filter((w) => w.archived); } // Default: return non-archived workspaces From b15a72efd424bcb0ce459529c6252f1a6979f053 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 21 Dec 2025 12:12:45 -0600 Subject: [PATCH 15/26] fix: min gap between chat and archived, remove inner scrollbox --- src/browser/components/ArchivedWorkspaces.tsx | 6 +++--- src/browser/components/ProjectPage.tsx | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/browser/components/ArchivedWorkspaces.tsx b/src/browser/components/ArchivedWorkspaces.tsx index f06a95fa26..2f29dd5abe 100644 --- a/src/browser/components/ArchivedWorkspaces.tsx +++ b/src/browser/components/ArchivedWorkspaces.tsx @@ -169,8 +169,8 @@ export const ArchivedWorkspaces: React.FC = ({
)} - {/* Timeline grouped list */} -
+ {/* Timeline grouped list - no inner scroll, parent handles overflow */} +
{filteredWorkspaces.length === 0 ? (
No workspaces match "{searchQuery}" @@ -179,7 +179,7 @@ export const ArchivedWorkspaces: React.FC = ({ Array.from(groupedWorkspaces.entries()).map(([period, periodWorkspaces]) => (
{/* Period header */} -
+
{period}
{/* Workspaces in this period */} diff --git a/src/browser/components/ProjectPage.tsx b/src/browser/components/ProjectPage.tsx index e939e59060..278b43a78e 100644 --- a/src/browser/components/ProjectPage.tsx +++ b/src/browser/components/ProjectPage.tsx @@ -63,10 +63,10 @@ export const ProjectPage: React.FC = ({ - {/* Scrollable flex column: chat centered, archived at bottom */} -
+ {/* Scrollable flex column: chat centered, archived at bottom with min gap */} +
{/* Spacer pushes chat toward center */} -
+
{/* Chat input card */} = ({ onReady={handleChatReady} onWorkspaceCreated={onWorkspaceCreated} /> - {/* Spacer between chat and archived */} -
+ {/* Spacer between chat and archived - shrinks but gap-6 ensures min spacing */} +
{/* Archived workspaces at bottom */} {archivedWorkspaces.length > 0 && ( -
+
Date: Sun, 21 Dec 2025 12:14:23 -0600 Subject: [PATCH 16/26] fix: remove collapsible behavior from ArchivedWorkspaces --- src/browser/components/ArchivedWorkspaces.tsx | 237 +++++++++--------- 1 file changed, 113 insertions(+), 124 deletions(-) diff --git a/src/browser/components/ArchivedWorkspaces.tsx b/src/browser/components/ArchivedWorkspaces.tsx index 2f29dd5abe..a730f1e70e 100644 --- a/src/browser/components/ArchivedWorkspaces.tsx +++ b/src/browser/components/ArchivedWorkspaces.tsx @@ -2,11 +2,10 @@ 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, ChevronDown, ChevronRight, Search } from "lucide-react"; +import { Trash2, Search } from "lucide-react"; import { ArchiveIcon, ArchiveRestoreIcon } from "./icons/ArchiveIcon"; import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip"; import { RuntimeBadge } from "./RuntimeBadge"; -import { usePersistedState } from "@/browser/hooks/usePersistedState"; interface ArchivedWorkspacesProps { projectPath: string; @@ -70,7 +69,6 @@ export const ArchivedWorkspaces: React.FC = ({ onWorkspacesChanged, }) => { const { unarchiveWorkspace, removeWorkspace, setSelectedWorkspace } = useWorkspaceContext(); - const [expanded, setExpanded] = usePersistedState("archivedWorkspacesExpanded", true); const [searchQuery, setSearchQuery] = React.useState(""); const [processingIds, setProcessingIds] = React.useState>(new Set()); const [deleteConfirmId, setDeleteConfirmId] = React.useState(null); @@ -136,140 +134,131 @@ export const ArchivedWorkspaces: React.FC = ({ return (
- +
- {expanded && ( -
- {/* Search input */} - {workspaces.length > 3 && ( -
-
- - setSearchQuery(e.target.value)} - className="bg-bg-dark placeholder:text-muted text-foreground w-full rounded border border-transparent py-1.5 pl-8 pr-3 text-sm focus:border-border-light focus:outline-none" - /> -
+
+ {/* Search input */} + {workspaces.length > 3 && ( +
+
+ + setSearchQuery(e.target.value)} + className="bg-bg-dark placeholder:text-muted text-foreground w-full rounded border border-transparent py-1.5 pl-8 pr-3 text-sm focus:border-border-light focus:outline-none" + />
- )} +
+ )} - {/* Timeline grouped list - no inner scroll, parent handles overflow */} -
- {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 isDeleting = deleteConfirmId === workspace.id; - const displayTitle = workspace.title ?? workspace.name; + {/* 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 isDeleting = deleteConfirmId === workspace.id; + const displayTitle = workspace.title ?? workspace.name; - return ( -
- -
-
- {displayTitle} -
- {workspace.archivedAt && ( -
- {new Date(workspace.archivedAt).toLocaleString(undefined, { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - })} -
- )} + return ( +
+ +
+
+ {displayTitle}
- - {isDeleting ? ( -
- Delete? - - -
- ) : ( -
- - - - - Restore to sidebar - - - - - - Delete permanently - + {workspace.archivedAt && ( +
+ {new Date(workspace.archivedAt).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + })}
)}
- ); - })} -
- )) - )} -
+ + {isDeleting ? ( +
+ Delete? + + +
+ ) : ( +
+ + + + + Restore to sidebar + + + + + + Delete permanently + +
+ )} +
+ ); + })} +
+ )) + )}
- )} +
); }; From 32b7ed0d65e6da7e4f78040226e0043d07e8bd84 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 21 Dec 2025 12:16:28 -0600 Subject: [PATCH 17/26] fix: use justify-center instead of spacers - consistent gap regardless of height --- src/browser/components/ProjectPage.tsx | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/browser/components/ProjectPage.tsx b/src/browser/components/ProjectPage.tsx index 278b43a78e..492eb055e2 100644 --- a/src/browser/components/ProjectPage.tsx +++ b/src/browser/components/ProjectPage.tsx @@ -63,10 +63,8 @@ export const ProjectPage: React.FC = ({ - {/* Scrollable flex column: chat centered, archived at bottom with min gap */} -
- {/* Spacer pushes chat toward center */} -
+ {/* Scrollable content area */} +
{/* Chat input card */} = ({ onReady={handleChatReady} onWorkspaceCreated={onWorkspaceCreated} /> - {/* Spacer between chat and archived - shrinks but gap-6 ensures min spacing */} -
- {/* Archived workspaces at bottom */} + {/* Archived workspaces below chat */} {archivedWorkspaces.length > 0 && ( -
+
Date: Sun, 21 Dec 2025 12:21:35 -0600 Subject: [PATCH 18/26] feat: add bulk delete/restore for archived workspaces - Checkboxes for multi-select with shift+click range selection - Bulk action buttons in header when items selected - Progress modal showing operation status - Select all checkbox in search bar - Added story: ArchivedWorkspacesBulkSelection --- src/browser/components/ArchivedWorkspaces.tsx | 521 ++++++++++++++---- src/browser/stories/App.welcome.stories.tsx | 104 ++-- 2 files changed, 456 insertions(+), 169 deletions(-) diff --git a/src/browser/components/ArchivedWorkspaces.tsx b/src/browser/components/ArchivedWorkspaces.tsx index a730f1e70e..85724c4ce7 100644 --- a/src/browser/components/ArchivedWorkspaces.tsx +++ b/src/browser/components/ArchivedWorkspaces.tsx @@ -2,7 +2,7 @@ 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 { Trash2, Search, X } from "lucide-react"; import { ArchiveIcon, ArchiveRestoreIcon } from "./icons/ArchiveIcon"; import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip"; import { RuntimeBadge } from "./RuntimeBadge"; @@ -15,6 +15,14 @@ interface ArchivedWorkspacesProps { 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(); @@ -58,6 +66,89 @@ function groupByTimePeriod(workspaces: FrontendWorkspaceMetadata[]): 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 ( +
+
+
+

+ {isComplete ? "Complete" : `${actionVerb} Workspaces`} +

+ {isComplete && ( + + )} +
+ + {/* Progress bar */} +
+
+
+ +
+ {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} + )} + + )} +
+ + {/* 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. @@ -73,6 +164,11 @@ export const ArchivedWorkspaces: React.FC = ({ const [processingIds, setProcessingIds] = React.useState>(new Set()); const [deleteConfirmId, setDeleteConfirmId] = React.useState(null); + // Bulk selection state + const [selectedIds, setSelectedIds] = React.useState>(new Set()); + const [lastClickedId, setLastClickedId] = React.useState(null); + const [bulkOperation, setBulkOperation] = React.useState(null); + // workspaces prop should already be filtered to archived only if (workspaces.length === 0) { return null; @@ -90,6 +186,132 @@ export const ArchivedWorkspaces: React.FC = ({ // 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); + }; + + // 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])); + } + }; + + // 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 { + await unarchiveWorkspace(id); + } catch (err) { + 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) + const handleBulkDelete = async () => { + 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 { + await removeWorkspace(id, { force: true }); + } catch (err) { + 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)); @@ -132,133 +354,198 @@ export const ArchivedWorkspaces: React.FC = ({ } }; + const hasSelection = selectedIds.size > 0; + const allFilteredSelected = + filteredWorkspaces.length > 0 && filteredWorkspaces.every((w) => selectedIds.has(w.id)); + return ( -
- {/* Header - not collapsible */} -
- - - Archived Workspaces ({workspaces.length}) - -
+ <> + {/* Bulk operation progress modal */} + {bulkOperation && ( + setBulkOperation(null)} /> + )} + +
+ {/* Header with bulk actions */} +
+ + + Archived Workspaces ({workspaces.length}) + + {hasSelection && ( +
+ {selectedIds.size} selected + + + + + Restore selected + + + + + + Delete selected permanently + + +
+ )} +
-
- {/* Search input */} - {workspaces.length > 3 && ( -
-
- +
+ {/* Search input with select all */} + {workspaces.length > 1 && ( +
setSearchQuery(e.target.value)} - className="bg-bg-dark placeholder:text-muted text-foreground w-full rounded border border-transparent py-1.5 pl-8 pr-3 text-sm focus:border-border-light focus:outline-none" + type="checkbox" + checked={allFilteredSelected} + onChange={handleSelectAll} + className="h-4 w-4 rounded border-gray-600 bg-transparent" + aria-label="Select all" /> + {workspaces.length > 3 && ( +
+ + setSearchQuery(e.target.value)} + className="bg-bg-dark placeholder:text-muted text-foreground w-full rounded border border-transparent py-1.5 pl-8 pr-3 text-sm focus:border-border-light 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 isDeleting = deleteConfirmId === workspace.id; - const displayTitle = workspace.title ?? workspace.name; - - return ( -
- -
-
- {displayTitle} + {/* 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 isDeleting = deleteConfirmId === workspace.id; + const isSelected = selectedIds.has(workspace.id); + const displayTitle = workspace.title ?? workspace.name; + + return ( +
+ handleCheckboxClick(workspace.id, e)} + onChange={() => {}} // 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", + })} +
+ )}
- {workspace.archivedAt && ( -
- {new Date(workspace.archivedAt).toLocaleString(undefined, { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - })} + + {isDeleting ? ( +
+ Delete? + + +
+ ) : ( +
+ + + + + Restore to sidebar + + + + + + Delete permanently +
)}
- - {isDeleting ? ( -
- Delete? - - -
- ) : ( -
- - - - - Restore to sidebar - - - - - - Delete permanently - -
- )} -
- ); - })} -
- )) - )} + ); + })} +
+ )) + )} +
-
+ ); }; diff --git a/src/browser/stories/App.welcome.stories.tsx b/src/browser/stories/App.welcome.stories.tsx index c81f99751f..e44033919b 100644 --- a/src/browser/stories/App.welcome.stories.tsx +++ b/src/browser/stories/App.welcome.stories.tsx @@ -70,58 +70,58 @@ export const CreateWorkspaceMultipleProjects: AppStory = { ), }; +/** Helper to generate archived workspaces for bulk operation stories */ +function generateArchivedWorkspaces(count: number, projectPath: string, projectName: string) { + const DAY = 86400000; + const names = [ + "feature/new-ui", + "bugfix/login-issue", + "feature/dark-mode", + "refactor/cleanup", + "feature/api-v2", + "bugfix/memory-leak", + "feature/notifications", + "refactor/database", + "feature/search", + "bugfix/auth-flow", + ]; + return Array.from({ length: count }, (_, i) => + createArchivedWorkspace({ + id: `archived-${i + 1}`, + name: names[i % names.length], + projectName, + projectPath, + archivedAt: new Date(NOW - (i + 1) * DAY).toISOString(), + }) + ); +} + /** Creation view with archived workspaces - shows timeline grouped archived section */ export const CreateWorkspaceWithArchived: AppStory = { - render: () => { - // Use stable timestamp for deterministic visual tests - const DAY = 86400000; - return ( - { - expandProjects(["/Users/dev/my-project"]); - return createMockORPCClient({ - projects: new Map([projectWithNoWorkspaces("/Users/dev/my-project")]), - workspaces: [ - // Recent (shows relative time in timeline) - createArchivedWorkspace({ - id: "archived-1", - name: "feature/new-ui", - projectName: "my-project", - projectPath: "/Users/dev/my-project", - archivedAt: new Date(NOW - 2 * 3600000).toISOString(), - }), - createArchivedWorkspace({ - id: "archived-2", - name: "bugfix/login-issue", - projectName: "my-project", - projectPath: "/Users/dev/my-project", - archivedAt: new Date(NOW - DAY - 3600000).toISOString(), - }), - createArchivedWorkspace({ - id: "archived-3", - name: "feature/dark-mode", - projectName: "my-project", - projectPath: "/Users/dev/my-project", - archivedAt: new Date(NOW - 3 * DAY).toISOString(), - }), - createArchivedWorkspace({ - id: "archived-4", - name: "refactor/cleanup", - projectName: "my-project", - projectPath: "/Users/dev/my-project", - archivedAt: new Date(NOW - 5 * DAY).toISOString(), - }), - createArchivedWorkspace({ - id: "archived-5", - name: "feature/api-v2", - projectName: "my-project", - projectPath: "/Users/dev/my-project", - archivedAt: new Date(NOW - 15 * DAY).toISOString(), - }), - ], - }); - }} - /> - ); - }, + render: () => ( + { + expandProjects(["/Users/dev/my-project"]); + return createMockORPCClient({ + projects: new Map([projectWithNoWorkspaces("/Users/dev/my-project")]), + workspaces: generateArchivedWorkspaces(5, "/Users/dev/my-project", "my-project"), + }); + }} + /> + ), +}; + +/** Archived workspaces with bulk selection - click checkboxes to select multiple */ +export const ArchivedWorkspacesBulkSelection: AppStory = { + render: () => ( + { + expandProjects(["/Users/dev/my-project"]); + return createMockORPCClient({ + projects: new Map([projectWithNoWorkspaces("/Users/dev/my-project")]), + workspaces: generateArchivedWorkspaces(8, "/Users/dev/my-project", "my-project"), + }); + }} + /> + ), }; From 5b80c76a5399d40ddf5a49873355d10a49dc2ce7 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 21 Dec 2025 12:24:31 -0600 Subject: [PATCH 19/26] fix: use standard Dialog for BulkProgressModal Refactored BulkProgressModal to use the standard Dialog component pattern from ui/dialog.tsx instead of a custom fixed overlay. This aligns with other modals in the app (ForceDeleteModal, SecretsModal, etc.) and fixes the transparent/confusing background issue. --- src/browser/components/ArchivedWorkspaces.tsx | 78 +++++++++---------- 1 file changed, 38 insertions(+), 40 deletions(-) diff --git a/src/browser/components/ArchivedWorkspaces.tsx b/src/browser/components/ArchivedWorkspaces.tsx index 85724c4ce7..e325c9adeb 100644 --- a/src/browser/components/ArchivedWorkspaces.tsx +++ b/src/browser/components/ArchivedWorkspaces.tsx @@ -2,10 +2,19 @@ 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, X } from "lucide-react"; +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 { Button } from "@/browser/components/ui/button"; interface ArchivedWorkspacesProps { projectPath: string; @@ -86,21 +95,28 @@ const BulkProgressModal: React.FC<{ const actionPast = operation.type === "restore" ? "restored" : "deleted"; return ( -
-
-
-

- {isComplete ? "Complete" : `${actionVerb} Workspaces`} -

- {isComplete && ( - - )} -
+ !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 */} -
+
-
- {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} - )} - - )} -
- {/* Errors */} {operation.errors.length > 0 && ( -
+
{operation.errors.map((err, i) => (
{err}
))} @@ -137,15 +136,14 @@ const BulkProgressModal: React.FC<{ )} {isComplete && ( - + + + )} -
-
+ +
); }; From 977a13d878080b911ccf017b8a8f20ffee16e7fe Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 21 Dec 2025 12:26:25 -0600 Subject: [PATCH 20/26] fix: add confirmation for bulk delete since it uses force --- src/browser/components/ArchivedWorkspaces.tsx | 74 ++++++++++++------- 1 file changed, 49 insertions(+), 25 deletions(-) diff --git a/src/browser/components/ArchivedWorkspaces.tsx b/src/browser/components/ArchivedWorkspaces.tsx index e325c9adeb..8d9bdba2d9 100644 --- a/src/browser/components/ArchivedWorkspaces.tsx +++ b/src/browser/components/ArchivedWorkspaces.tsx @@ -166,6 +166,7 @@ export const ArchivedWorkspaces: React.FC = ({ 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) { @@ -219,6 +220,7 @@ export const ArchivedWorkspaces: React.FC = ({ }); setLastClickedId(workspaceId); + setBulkDeleteConfirm(false); // Clear confirmation when selection changes }; // Select/deselect all filtered workspaces @@ -239,6 +241,7 @@ export const ArchivedWorkspaces: React.FC = ({ // Select all filtered setSelectedIds((prev) => new Set([...prev, ...allFilteredIds])); } + setBulkDeleteConfirm(false); // Clear confirmation when selection changes }; // Bulk restore @@ -276,8 +279,9 @@ export const ArchivedWorkspaces: React.FC = ({ onWorkspacesChanged?.(); }; - // Bulk delete (always force: true) + // Bulk delete (always force: true) - requires confirmation const handleBulkDelete = async () => { + setBulkDeleteConfirm(false); const idsToDelete = Array.from(selectedIds); setBulkOperation({ type: "delete", @@ -373,36 +377,56 @@ export const ArchivedWorkspaces: React.FC = ({ {hasSelection && (
{selectedIds.size} selected - - + {bulkDeleteConfirm ? ( + <> + Delete permanently? - - Restore selected - - - + + ) : ( + <> + + + + + Restore selected + + + + + + Delete selected permanently + + - - Delete selected permanently - - + + )}
)}
From f56594d7d22627f1b900dab0a770b475c31ca046 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 21 Dec 2025 12:36:42 -0600 Subject: [PATCH 21/26] WIP: workspace archiving - bulk operations, layout fixes, stories --- src/browser/stories/App.welcome.stories.tsx | 112 +++++++++++++------- 1 file changed, 74 insertions(+), 38 deletions(-) diff --git a/src/browser/stories/App.welcome.stories.tsx b/src/browser/stories/App.welcome.stories.tsx index e44033919b..22f574d58b 100644 --- a/src/browser/stories/App.welcome.stories.tsx +++ b/src/browser/stories/App.welcome.stories.tsx @@ -70,56 +70,92 @@ export const CreateWorkspaceMultipleProjects: AppStory = { ), }; -/** Helper to generate archived workspaces for bulk operation stories */ -function generateArchivedWorkspaces(count: number, projectPath: string, projectName: string) { +/** Helper to generate archived workspaces with varied dates for timeline grouping */ +function generateArchivedWorkspaces(projectPath: string, projectName: string) { const DAY = 86400000; - const names = [ - "feature/new-ui", - "bugfix/login-issue", - "feature/dark-mode", - "refactor/cleanup", - "feature/api-v2", - "bugfix/memory-leak", - "feature/notifications", - "refactor/database", - "feature/search", - "bugfix/auth-flow", - ]; - return Array.from({ length: count }, (_, i) => + const HOUR = 3600000; + // Generate enough workspaces to show: search bar (>3), bulk selection, timeline grouping + return [ + // Today + createArchivedWorkspace({ + id: "archived-1", + name: "feature/new-ui", + projectName, + projectPath, + archivedAt: new Date(NOW - 2 * HOUR).toISOString(), + }), + createArchivedWorkspace({ + id: "archived-2", + name: "bugfix/login-issue", + projectName, + projectPath, + archivedAt: new Date(NOW - 5 * HOUR).toISOString(), + }), + // Yesterday + createArchivedWorkspace({ + id: "archived-3", + name: "feature/dark-mode", + projectName, + projectPath, + archivedAt: new Date(NOW - DAY - 3 * HOUR).toISOString(), + }), + // This week + createArchivedWorkspace({ + id: "archived-4", + name: "refactor/cleanup", + projectName, + projectPath, + archivedAt: new Date(NOW - 3 * DAY).toISOString(), + }), + createArchivedWorkspace({ + id: "archived-5", + name: "feature/api-v2", + projectName, + projectPath, + archivedAt: new Date(NOW - 5 * DAY).toISOString(), + }), + // This month + createArchivedWorkspace({ + id: "archived-6", + name: "bugfix/memory-leak", + projectName, + projectPath, + archivedAt: new Date(NOW - 12 * DAY).toISOString(), + }), + // Older createArchivedWorkspace({ - id: `archived-${i + 1}`, - name: names[i % names.length], + id: "archived-7", + name: "feature/notifications", projectName, projectPath, - archivedAt: new Date(NOW - (i + 1) * DAY).toISOString(), - }) - ); + archivedAt: new Date(NOW - 45 * DAY).toISOString(), + }), + createArchivedWorkspace({ + id: "archived-8", + name: "refactor/database", + projectName, + projectPath, + archivedAt: new Date(NOW - 60 * DAY).toISOString(), + }), + ]; } -/** Creation view with archived workspaces - shows timeline grouped archived section */ -export const CreateWorkspaceWithArchived: AppStory = { - render: () => ( - { - expandProjects(["/Users/dev/my-project"]); - return createMockORPCClient({ - projects: new Map([projectWithNoWorkspaces("/Users/dev/my-project")]), - workspaces: generateArchivedWorkspaces(5, "/Users/dev/my-project", "my-project"), - }); - }} - /> - ), -}; - -/** Archived workspaces with bulk selection - click checkboxes to select multiple */ -export const ArchivedWorkspacesBulkSelection: AppStory = { +/** + * 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(8, "/Users/dev/my-project", "my-project"), + workspaces: generateArchivedWorkspaces("/Users/dev/my-project", "my-project"), }); }} /> From bb367ce1f866f49c6d7c13a26f1fe56c4bf0ee29 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 21 Dec 2025 12:44:47 -0600 Subject: [PATCH 22/26] refactor: derive archived state from timestamps, not boolean - Remove `archived: boolean` from schemas - Archived state is now derived: archivedAt > unarchivedAt - Archive just sets archivedAt, unarchive just sets unarchivedAt - Add unarchivedAt to recency calculation (bumps restored workspaces to top) - Update aggregator to accept and track unarchivedAt - Update all filtering/checks to use timestamp comparison --- .storybook/mocks/orpc.ts | 10 ++++++++-- src/browser/contexts/WorkspaceContext.tsx | 7 ++++++- src/browser/stores/WorkspaceStore.ts | 17 ++++++++++++++--- src/browser/stories/mockFactory.ts | 4 ++-- .../messages/StreamingMessageAggregator.ts | 13 +++++++++++-- src/browser/utils/messages/recency.ts | 16 +++++++++++++--- src/common/orpc/schemas/project.ts | 9 +++++---- src/common/orpc/schemas/workspace.ts | 9 +++++---- src/common/utils/recency.ts | 8 ++++++-- src/node/config.ts | 8 ++++---- src/node/orpc/router.ts | 11 ++++++++--- src/node/services/workspaceService.ts | 7 ++++--- 12 files changed, 86 insertions(+), 33 deletions(-) diff --git a/.storybook/mocks/orpc.ts b/.storybook/mocks/orpc.ts index 56fb7909dc..8aae6c5ea1 100644 --- a/.storybook/mocks/orpc.ts +++ b/.storybook/mocks/orpc.ts @@ -252,10 +252,16 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl }, workspace: { list: async (input?: { archived?: boolean }) => { + // Archived = archivedAt exists and is more recent than unarchivedAt + const isArchived = (w: (typeof workspaces)[0]) => { + if (!w.archivedAt) return false; + if (!w.unarchivedAt) return true; + return new Date(w.archivedAt).getTime() > new Date(w.unarchivedAt).getTime(); + }; if (input?.archived) { - return workspaces.filter((w) => w.archived); + return workspaces.filter(isArchived); } - return workspaces.filter((w) => !w.archived); + return workspaces.filter((w) => !isArchived(w)); }, archive: async () => ({ success: true }), unarchive: async () => ({ success: true }), diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index 5a670d72cd..dbaffd9f9d 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -186,7 +186,12 @@ 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 (metadata.archived) continue; + // Archived = archivedAt exists and is more recent than unarchivedAt + const isArchived = + metadata.archivedAt && + (!metadata.unarchivedAt || + new Date(metadata.archivedAt).getTime() > new Date(metadata.unarchivedAt).getTime()); + if (isArchived) continue; ensureCreatedAt(metadata); // Use stable workspace ID as key (not path, which can change) seedWorkspaceLocalStorageFromBackend(metadata); 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/mockFactory.ts b/src/browser/stories/mockFactory.ts index 04933fe2ae..52cf8c3eb7 100644 --- a/src/browser/stories/mockFactory.ts +++ b/src/browser/stories/mockFactory.ts @@ -104,7 +104,7 @@ export function createIncompatibleWorkspace( }; } -/** Create an archived workspace */ +/** Create an archived workspace (archived = archivedAt set, no unarchivedAt) */ export function createArchivedWorkspace( opts: Partial & { id: string; @@ -115,8 +115,8 @@ export function createArchivedWorkspace( ): FrontendWorkspaceMetadata { return { ...createWorkspace(opts), - archived: true, archivedAt: opts.archivedAt ?? new Date(NOW - 86400000).toISOString(), // 1 day ago + // No unarchivedAt means it's archived (archivedAt > unarchivedAt where unarchivedAt is undefined) }; } 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/project.ts b/src/common/orpc/schemas/project.ts index c03df722ab..82281a71dc 100644 --- a/src/common/orpc/schemas/project.ts +++ b/src/common/orpc/schemas/project.ts @@ -60,12 +60,13 @@ export const WorkspaceConfigSchema = z.object({ mcp: WorkspaceMCPOverridesSchema.optional().meta({ description: "Per-workspace MCP overrides (disabled servers, tool allowlists)", }), - archived: z.boolean().optional().meta({ + archivedAt: z.string().optional().meta({ description: - "When true, workspace is archived. Archived workspaces are hidden from the main sidebar but visible on the project page. Safe and reversible.", + "ISO 8601 timestamp when workspace was last archived. Workspace is considered archived if archivedAt > unarchivedAt (or unarchivedAt is absent).", }), - archivedAt: z.string().optional().meta({ - description: "ISO 8601 timestamp when workspace was archived (optional)", + unarchivedAt: z.string().optional().meta({ + description: + "ISO 8601 timestamp when workspace was last unarchived. Used for recency calculation to bump restored workspaces to top.", }), }); diff --git a/src/common/orpc/schemas/workspace.ts b/src/common/orpc/schemas/workspace.ts index 69ae742894..d6dfdbb13c 100644 --- a/src/common/orpc/schemas/workspace.ts +++ b/src/common/orpc/schemas/workspace.ts @@ -64,12 +64,13 @@ export const WorkspaceMetadataSchema = z.object({ description: "Workspace creation status. 'creating' = pending setup (ephemeral, not persisted). Absent = ready.", }), - archived: z.boolean().optional().meta({ + archivedAt: z.string().optional().meta({ description: - "When true, workspace is archived. Archived workspaces are hidden from main sidebar but visible on project page.", + "ISO 8601 timestamp when workspace was last archived. Workspace is considered archived if archivedAt > unarchivedAt (or unarchivedAt is absent).", }), - archivedAt: z.string().optional().meta({ - description: "ISO 8601 timestamp when workspace was archived", + unarchivedAt: z.string().optional().meta({ + description: + "ISO 8601 timestamp when workspace was last unarchived. Used for recency calculation to bump restored workspaces to top.", }), }); 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 037316ca1b..0593c560db 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -364,8 +364,8 @@ export class Config { taskThinkingLevel: workspace.taskThinkingLevel, taskPrompt: workspace.taskPrompt, taskTrunkBranch: workspace.taskTrunkBranch, - archived: workspace.archived, archivedAt: workspace.archivedAt, + unarchivedAt: workspace.unarchivedAt, }; // Migrate missing createdAt to config for next load @@ -417,9 +417,9 @@ export class Config { metadata.taskThinkingLevel ??= workspace.taskThinkingLevel; metadata.taskPrompt ??= workspace.taskPrompt; metadata.taskTrunkBranch ??= workspace.taskTrunkBranch; - // Preserve archived status from config - metadata.archived ??= workspace.archived; + // 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; @@ -452,8 +452,8 @@ export class Config { taskThinkingLevel: workspace.taskThinkingLevel, taskPrompt: workspace.taskPrompt, taskTrunkBranch: workspace.taskTrunkBranch, - archived: workspace.archived, 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 4b750b0810..331756ff90 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -586,12 +586,17 @@ export const router = (authToken?: string) => { const allWorkspaces = await context.workspaceService.list({ includePostCompaction: input?.includePostCompaction, }); - // Filter by archived status + // Filter by archived status (derived from timestamps) + const isArchived = (w: (typeof allWorkspaces)[0]) => { + if (!w.archivedAt) return false; + if (!w.unarchivedAt) return true; + return new Date(w.archivedAt).getTime() > new Date(w.unarchivedAt).getTime(); + }; if (input?.archived) { - return allWorkspaces.filter((w) => w.archived); + return allWorkspaces.filter(isArchived); } // Default: return non-archived workspaces - return allWorkspaces.filter((w) => !w.archived); + return allWorkspaces.filter((w) => !isArchived(w)); }), create: t .input(schemas.workspace.create.input) diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 4825db1041..5404ff0c69 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -933,7 +933,7 @@ export class WorkspaceService extends EventEmitter { projectConfig.workspaces.find((w) => w.id === workspaceId) ?? projectConfig.workspaces.find((w) => w.path === workspacePath); if (workspaceEntry) { - workspaceEntry.archived = true; + // Just set archivedAt - archived state is derived from archivedAt > unarchivedAt workspaceEntry.archivedAt = new Date().toISOString(); } } @@ -977,8 +977,9 @@ export class WorkspaceService extends EventEmitter { projectConfig.workspaces.find((w) => w.id === workspaceId) ?? projectConfig.workspaces.find((w) => w.path === workspacePath); if (workspaceEntry) { - workspaceEntry.archived = undefined; - workspaceEntry.archivedAt = undefined; + // 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; From cbc0ec9260ac61f33b3fb46c49815dedc95e63c2 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 21 Dec 2025 13:42:21 -0600 Subject: [PATCH 23/26] fix: make ProjectPage scroll correctly with many archived workspaces --- .storybook/mocks/orpc.ts | 11 +- src/browser/components/ArchivedWorkspaces.tsx | 36 +++--- src/browser/components/ProjectPage.tsx | 61 +++++----- src/browser/contexts/WorkspaceContext.tsx | 8 +- src/browser/stories/App.welcome.stories.tsx | 112 ++++++++---------- src/node/orpc/router.ts | 12 +- 6 files changed, 108 insertions(+), 132 deletions(-) diff --git a/.storybook/mocks/orpc.ts b/.storybook/mocks/orpc.ts index 8aae6c5ea1..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 { @@ -252,16 +253,10 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl }, workspace: { list: async (input?: { archived?: boolean }) => { - // Archived = archivedAt exists and is more recent than unarchivedAt - const isArchived = (w: (typeof workspaces)[0]) => { - if (!w.archivedAt) return false; - if (!w.unarchivedAt) return true; - return new Date(w.archivedAt).getTime() > new Date(w.unarchivedAt).getTime(); - }; if (input?.archived) { - return workspaces.filter(isArchived); + return workspaces.filter((w) => isWorkspaceArchived(w.archivedAt, w.unarchivedAt)); } - return workspaces.filter((w) => !isArchived(w)); + return workspaces.filter((w) => !isWorkspaceArchived(w.archivedAt, w.unarchivedAt)); }, archive: async () => ({ success: true }), unarchive: async () => ({ success: true }), diff --git a/src/browser/components/ArchivedWorkspaces.tsx b/src/browser/components/ArchivedWorkspaces.tsx index 8d9bdba2d9..c133f302e4 100644 --- a/src/browser/components/ArchivedWorkspaces.tsx +++ b/src/browser/components/ArchivedWorkspaces.tsx @@ -33,7 +33,9 @@ interface BulkOperationState { } /** Group workspaces by time period for timeline display */ -function groupByTimePeriod(workspaces: FrontendWorkspaceMetadata[]): Map { +function groupByTimePeriod( + workspaces: FrontendWorkspaceMetadata[] +): Map { const groups = new Map(); const now = new Date(); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); @@ -76,7 +78,9 @@ function groupByTimePeriod(workspaces: FrontendWorkspaceMetadata[]): Map): FrontendWorkspaceMetadata[] { +function flattenGrouped( + grouped: Map +): FrontendWorkspaceMetadata[] { const result: FrontendWorkspaceMetadata[] = []; for (const workspaces of grouped.values()) { result.push(...workspaces); @@ -258,17 +262,13 @@ export const ArchivedWorkspaces: React.FC = ({ 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 - ); + setBulkOperation((prev) => (prev ? { ...prev, current: ws?.title ?? ws?.name ?? id } : prev)); try { await unarchiveWorkspace(id); - } catch (err) { + } catch { setBulkOperation((prev) => - prev - ? { ...prev, errors: [...prev.errors, `Failed to restore ${ws?.name ?? id}`] } - : prev + prev ? { ...prev, errors: [...prev.errors, `Failed to restore ${ws?.name ?? id}`] } : prev ); } @@ -294,17 +294,13 @@ export const ArchivedWorkspaces: React.FC = ({ 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 - ); + setBulkOperation((prev) => (prev ? { ...prev, current: ws?.title ?? ws?.name ?? id } : prev)); try { await removeWorkspace(id, { force: true }); - } catch (err) { + } catch { setBulkOperation((prev) => - prev - ? { ...prev, errors: [...prev.errors, `Failed to delete ${ws?.name ?? id}`] } - : prev + prev ? { ...prev, errors: [...prev.errors, `Failed to delete ${ws?.name ?? id}`] } : prev ); } @@ -444,13 +440,13 @@ export const ArchivedWorkspaces: React.FC = ({ /> {workspaces.length > 3 && (
- + setSearchQuery(e.target.value)} - className="bg-bg-dark placeholder:text-muted text-foreground w-full rounded border border-transparent py-1.5 pl-8 pr-3 text-sm focus:border-border-light focus:outline-none" + 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" />
)} @@ -461,7 +457,7 @@ export const ArchivedWorkspaces: React.FC = ({
{filteredWorkspaces.length === 0 ? (
- No workspaces match "{searchQuery}" + No workspaces match {`"${searchQuery}"`}
) : ( Array.from(groupedWorkspaces.entries()).map(([period, periodWorkspaces]) => ( @@ -490,7 +486,7 @@ export const ArchivedWorkspaces: React.FC = ({ type="checkbox" checked={isSelected} onClick={(e) => handleCheckboxClick(workspace.id, e)} - onChange={() => {}} // Controlled by onClick for shift-click support + onChange={() => undefined} // Controlled by onClick for shift-click support className="h-4 w-4 rounded border-gray-600 bg-transparent" aria-label={`Select ${displayTitle}`} /> diff --git a/src/browser/components/ProjectPage.tsx b/src/browser/components/ProjectPage.tsx index 492eb055e2..783ca841ae 100644 --- a/src/browser/components/ProjectPage.tsx +++ b/src/browser/components/ProjectPage.tsx @@ -64,33 +64,40 @@ export const ProjectPage: React.FC = ({ {/* Scrollable content area */} -
- {/* 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)); - }); - }} - /> -
- )} +
+ {/* + 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/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index dbaffd9f9d..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. @@ -186,12 +187,7 @@ 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 - // Archived = archivedAt exists and is more recent than unarchivedAt - const isArchived = - metadata.archivedAt && - (!metadata.unarchivedAt || - new Date(metadata.archivedAt).getTime() > new Date(metadata.unarchivedAt).getTime()); - if (isArchived) continue; + if (isWorkspaceArchived(metadata.archivedAt, metadata.unarchivedAt)) continue; ensureCreatedAt(metadata); // Use stable workspace ID as key (not path, which can change) seedWorkspaceLocalStorageFromBackend(metadata); diff --git a/src/browser/stories/App.welcome.stories.tsx b/src/browser/stories/App.welcome.stories.tsx index 22f574d58b..4fba6f8130 100644 --- a/src/browser/stories/App.welcome.stories.tsx +++ b/src/browser/stories/App.welcome.stories.tsx @@ -72,72 +72,58 @@ export const CreateWorkspaceMultipleProjects: AppStory = { /** Helper to generate archived workspaces with varied dates for timeline grouping */ function generateArchivedWorkspaces(projectPath: string, projectName: string) { - const DAY = 86400000; + const MINUTE = 60000; const HOUR = 3600000; - // Generate enough workspaces to show: search bar (>3), bulk selection, timeline grouping - return [ - // Today - createArchivedWorkspace({ - id: "archived-1", - name: "feature/new-ui", - projectName, - projectPath, - archivedAt: new Date(NOW - 2 * HOUR).toISOString(), - }), - createArchivedWorkspace({ - id: "archived-2", - name: "bugfix/login-issue", - projectName, - projectPath, - archivedAt: new Date(NOW - 5 * HOUR).toISOString(), - }), - // Yesterday - createArchivedWorkspace({ - id: "archived-3", - name: "feature/dark-mode", - projectName, - projectPath, - archivedAt: new Date(NOW - DAY - 3 * HOUR).toISOString(), - }), - // This week - createArchivedWorkspace({ - id: "archived-4", - name: "refactor/cleanup", - projectName, - projectPath, - archivedAt: new Date(NOW - 3 * DAY).toISOString(), - }), - createArchivedWorkspace({ - id: "archived-5", - name: "feature/api-v2", - projectName, - projectPath, - archivedAt: new Date(NOW - 5 * DAY).toISOString(), - }), - // This month - createArchivedWorkspace({ - id: "archived-6", - name: "bugfix/memory-leak", - projectName, - projectPath, - archivedAt: new Date(NOW - 12 * DAY).toISOString(), - }), - // Older - createArchivedWorkspace({ - id: "archived-7", - name: "feature/notifications", - projectName, - projectPath, - archivedAt: new Date(NOW - 45 * DAY).toISOString(), - }), - createArchivedWorkspace({ - id: "archived-8", - name: "refactor/database", + 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 - 60 * DAY).toISOString(), - }), - ]; + archivedAt: new Date(NOW - archivedDeltaMs).toISOString(), + }); + }); + + return result; } /** diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 331756ff90..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)); @@ -586,17 +587,12 @@ export const router = (authToken?: string) => { const allWorkspaces = await context.workspaceService.list({ includePostCompaction: input?.includePostCompaction, }); - // Filter by archived status (derived from timestamps) - const isArchived = (w: (typeof allWorkspaces)[0]) => { - if (!w.archivedAt) return false; - if (!w.unarchivedAt) return true; - return new Date(w.archivedAt).getTime() > new Date(w.unarchivedAt).getTime(); - }; + // Filter by archived status (derived from timestamps via shared utility) if (input?.archived) { - return allWorkspaces.filter(isArchived); + return allWorkspaces.filter((w) => isWorkspaceArchived(w.archivedAt, w.unarchivedAt)); } // Default: return non-archived workspaces - return allWorkspaces.filter((w) => !isArchived(w)); + return allWorkspaces.filter((w) => !isWorkspaceArchived(w.archivedAt, w.unarchivedAt)); }), create: t .input(schemas.workspace.create.input) From bda7262d7ca8cdf67460c6267ca4bbf15a2f2e37 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 21 Dec 2025 13:42:28 -0600 Subject: [PATCH 24/26] refactor: add shared isWorkspaceArchived helper --- src/common/utils/archive.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/common/utils/archive.ts 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(); +} From 4b5489f3469b859c4e1c0e552a9961151049d429 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 21 Dec 2025 13:56:38 -0600 Subject: [PATCH 25/26] fix: use force-delete modal for archived workspace deletion --- src/browser/components/ArchivedWorkspaces.tsx | 139 +++++++++++------- 1 file changed, 84 insertions(+), 55 deletions(-) diff --git a/src/browser/components/ArchivedWorkspaces.tsx b/src/browser/components/ArchivedWorkspaces.tsx index c133f302e4..5137ddee2b 100644 --- a/src/browser/components/ArchivedWorkspaces.tsx +++ b/src/browser/components/ArchivedWorkspaces.tsx @@ -14,6 +14,7 @@ import { DialogDescription, DialogFooter, } from "@/browser/components/ui/dialog"; +import { ForceDeleteModal } from "./ForceDeleteModal"; import { Button } from "@/browser/components/ui/button"; interface ArchivedWorkspacesProps { @@ -164,7 +165,10 @@ export const ArchivedWorkspaces: React.FC = ({ const { unarchiveWorkspace, removeWorkspace, setSelectedWorkspace } = useWorkspaceContext(); const [searchQuery, setSearchQuery] = React.useState(""); const [processingIds, setProcessingIds] = React.useState>(new Set()); - const [deleteConfirmId, setDeleteConfirmId] = React.useState(null); + const [forceDeleteModal, setForceDeleteModal] = React.useState<{ + workspaceId: string; + error: string; + } | null>(null); // Bulk selection state const [selectedIds, setSelectedIds] = React.useState>(new Set()); @@ -265,7 +269,20 @@ export const ArchivedWorkspaces: React.FC = ({ setBulkOperation((prev) => (prev ? { ...prev, current: ws?.title ?? ws?.name ?? id } : prev)); try { - await unarchiveWorkspace(id); + 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 @@ -297,7 +314,20 @@ export const ArchivedWorkspaces: React.FC = ({ setBulkOperation((prev) => (prev ? { ...prev, current: ws?.title ?? ws?.name ?? id } : prev)); try { - await removeWorkspace(id, { force: true }); + 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 @@ -339,10 +369,16 @@ export const ArchivedWorkspaces: React.FC = ({ const handleDelete = async (workspaceId: string) => { setProcessingIds((prev) => new Set(prev).add(workspaceId)); - setDeleteConfirmId(null); try { - await removeWorkspace(workspaceId, { force: true }); - onWorkspacesChanged?.(); + 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); @@ -359,6 +395,20 @@ export const ArchivedWorkspaces: React.FC = ({ 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)} /> )} @@ -469,7 +519,6 @@ export const ArchivedWorkspaces: React.FC = ({ {/* Workspaces in this period */} {periodWorkspaces.map((workspace) => { const isProcessing = processingIds.has(workspace.id); - const isDeleting = deleteConfirmId === workspace.id; const isSelected = selectedIds.has(workspace.id); const displayTitle = workspace.title ?? workspace.name; @@ -507,54 +556,34 @@ export const ArchivedWorkspaces: React.FC = ({ )}
- {isDeleting ? ( -
- Delete? - - -
- ) : ( -
- - - - - Restore to sidebar - - - - - - Delete permanently - -
- )} +
+ + + + + Restore to sidebar + + + + + + Delete permanently + +
); })} From 431520b270ff72c12584222914795aeebb4b70bc Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 21 Dec 2025 14:09:01 -0600 Subject: [PATCH 26/26] fix: stop active streams when archiving workspaces --- src/node/services/workspaceService.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 5404ff0c69..9f8303f288 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -918,6 +918,7 @@ 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); @@ -926,6 +927,18 @@ export class WorkspaceService extends EventEmitter { } 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) {