Skip to content

Commit 735e650

Browse files
authored
🤖 feat: show pending workspace states in sidebar (#745)
## Summary Shows immediate visual feedback in the sidebar when creating workspaces, instead of the UI appearing frozen during title generation (2-5s). ## Changes - **Type**: Added `status?: 'creating'` field to `WorkspaceMetadata` for pending workspaces (ephemeral, not persisted) - **Backend**: Emits pending metadata immediately before slow AI title generation, clears on error - **Helper**: `generatePlaceholderName()` creates git-safe placeholder from user's message - **Frontend**: Shows shimmer on workspace name, disables selection/remove actions during creation ## Flow ``` User sends message → Backend emits pending metadata immediately → Sidebar shows workspace with shimmer → AI generates title (2-5s) → Backend creates workspace → Backend emits final metadata → Sidebar updates to real name, removes shimmer ``` ## Robustness The implementation handles concurrent and edge-case scenarios correctly. Each workspace creation generates a unique ID upfront (`crypto.randomBytes`), so multiple concurrent creations are fully isolated—the frontend's `workspaceMetadata` Map is keyed by workspace ID, and the `buildSortedWorkspacesByProject` function merges pending workspaces from metadata events with persisted config entries without duplication. If the user manually selects another workspace during creation, the `onWorkspaceCreated` callback uses a functional update (`setSelectedWorkspace((current) => current !== null ? current : newSelection)`) to avoid overwriting their choice. Window lifecycle is also handled safely. The backend's metadata event handler guards with `if (!this.mainWindow) return`, so events are silently dropped when the renderer is closed—no errors or orphan state. Backend creation continues to completion and saves to config, so workspaces appear correctly on next app launch. The pending `status: "creating"` field is ephemeral (never persisted), ensuring clean startup state regardless of when the window was closed. --- _Generated with `mux`_
1 parent 5874155 commit 735e650

File tree

9 files changed

+378
-122
lines changed

9 files changed

+378
-122
lines changed

src/browser/App.tsx

Lines changed: 29 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@ import "./styles/globals.css";
33
import { useWorkspaceContext } from "./contexts/WorkspaceContext";
44
import { useProjectContext } from "./contexts/ProjectContext";
55
import type { WorkspaceSelection } from "./components/ProjectSidebar";
6-
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
76
import { LeftSidebar } from "./components/LeftSidebar";
87
import { ProjectCreateModal } from "./components/ProjectCreateModal";
98
import { AIView } from "./components/AIView";
109
import { ErrorBoundary } from "./components/ErrorBoundary";
1110
import { usePersistedState, updatePersistedState } from "./hooks/usePersistedState";
1211
import { matchesKeybind, KEYBINDS } from "./utils/ui/keybinds";
12+
import { buildSortedWorkspacesByProject } from "./utils/ui/workspaceFiltering";
1313
import { useResumeManager } from "./hooks/useResumeManager";
1414
import { useUnreadTracking } from "./hooks/useUnreadTracking";
1515
import { useWorkspaceStoreRaw, useWorkspaceRecency } from "./stores/WorkspaceStore";
@@ -198,46 +198,24 @@ function AppInner() {
198198
// NEW: Get workspace recency from store
199199
const workspaceRecency = useWorkspaceRecency();
200200

201-
// Sort workspaces by recency (most recent first)
202-
// Returns Map<projectPath, FrontendWorkspaceMetadata[]> for direct component use
201+
// Build sorted workspaces map including pending workspaces
203202
// Use stable reference to prevent sidebar re-renders when sort order hasn't changed
204203
const sortedWorkspacesByProject = useStableReference(
205-
() => {
206-
const result = new Map<string, FrontendWorkspaceMetadata[]>();
207-
for (const [projectPath, config] of projects) {
208-
// Transform Workspace[] to FrontendWorkspaceMetadata[] using workspace ID
209-
const metadataList = config.workspaces
210-
.map((ws) => (ws.id ? workspaceMetadata.get(ws.id) : undefined))
211-
.filter((meta): meta is FrontendWorkspaceMetadata => meta !== undefined && meta !== null);
212-
213-
// Sort by recency
214-
metadataList.sort((a, b) => {
215-
const aTimestamp = workspaceRecency[a.id] ?? 0;
216-
const bTimestamp = workspaceRecency[b.id] ?? 0;
217-
return bTimestamp - aTimestamp;
204+
() => buildSortedWorkspacesByProject(projects, workspaceMetadata, workspaceRecency),
205+
(prev, next) =>
206+
compareMaps(prev, next, (a, b) => {
207+
if (a.length !== b.length) return false;
208+
// Check ID, name, and status to detect changes
209+
return a.every((meta, i) => {
210+
const other = b[i];
211+
return (
212+
other &&
213+
meta.id === other.id &&
214+
meta.name === other.name &&
215+
meta.status === other.status
216+
);
218217
});
219-
220-
result.set(projectPath, metadataList);
221-
}
222-
return result;
223-
},
224-
(prev, next) => {
225-
// Compare Maps: check if size, workspace order, and metadata content are the same
226-
if (
227-
!compareMaps(prev, next, (a, b) => {
228-
if (a.length !== b.length) return false;
229-
// Check both ID and name to detect renames
230-
return a.every((metadata, i) => {
231-
const bMeta = b[i];
232-
if (!bMeta || !metadata) return false; // Null-safe
233-
return metadata.id === bMeta.id && metadata.name === bMeta.name;
234-
});
235-
})
236-
) {
237-
return false;
238-
}
239-
return true;
240-
},
218+
}),
241219
[projects, workspaceMetadata, workspaceRecency]
242220
);
243221

@@ -605,12 +583,19 @@ function AppInner() {
605583
new Map(prev).set(metadata.id, metadata)
606584
);
607585

608-
// Switch to new workspace
609-
setSelectedWorkspace({
610-
workspaceId: metadata.id,
611-
projectPath: metadata.projectPath,
612-
projectName: metadata.projectName,
613-
namedWorkspacePath: metadata.namedWorkspacePath,
586+
// Only switch to new workspace if user hasn't selected another one
587+
// during the creation process (selectedWorkspace was null when creation started)
588+
setSelectedWorkspace((current) => {
589+
if (current !== null) {
590+
// User has already selected another workspace - don't override
591+
return current;
592+
}
593+
return {
594+
workspaceId: metadata.id,
595+
projectPath: metadata.projectPath,
596+
projectName: metadata.projectName,
597+
namedWorkspacePath: metadata.namedWorkspacePath,
598+
};
614599
});
615600

616601
// Track telemetry

src/browser/components/WorkspaceListItem.tsx

Lines changed: 68 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
4444
onToggleUnread: _onToggleUnread,
4545
}) => {
4646
// Destructure metadata for convenience
47-
const { id: workspaceId, name: workspaceName, namedWorkspacePath } = metadata;
47+
const { id: workspaceId, name: workspaceName, namedWorkspacePath, status } = metadata;
48+
const isCreating = status === "creating";
49+
const isDisabled = isCreating || isDeleting;
4850
const gitStatus = useGitStatus(workspaceId);
4951

5052
// Get rename context
@@ -100,19 +102,24 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
100102
<React.Fragment>
101103
<div
102104
className={cn(
103-
"py-1.5 pl-4 pr-2 cursor-pointer border-l-[3px] border-transparent transition-all duration-150 text-[13px] relative hover:bg-hover [&:hover_button]:opacity-100 flex gap-2",
104-
isSelected && "bg-hover border-l-blue-400",
105-
isDeleting && "opacity-50 pointer-events-none"
105+
"py-1.5 pl-4 pr-2 border-l-[3px] border-transparent transition-all duration-150 text-[13px] relative flex gap-2",
106+
isDisabled
107+
? "cursor-default opacity-70"
108+
: "cursor-pointer hover:bg-hover [&:hover_button]:opacity-100",
109+
isSelected && !isDisabled && "bg-hover border-l-blue-400",
110+
isDeleting && "pointer-events-none"
106111
)}
107-
onClick={() =>
112+
onClick={() => {
113+
if (isDisabled) return;
108114
onSelectWorkspace({
109115
projectPath,
110116
projectName,
111117
namedWorkspacePath,
112118
workspaceId,
113-
})
114-
}
119+
});
120+
}}
115121
onKeyDown={(e) => {
122+
if (isDisabled) return;
116123
if (e.key === "Enter" || e.key === " ") {
117124
e.preventDefault();
118125
onSelectWorkspace({
@@ -124,9 +131,16 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
124131
}
125132
}}
126133
role="button"
127-
tabIndex={0}
134+
tabIndex={isDisabled ? -1 : 0}
128135
aria-current={isSelected ? "true" : undefined}
129-
aria-label={`Select workspace ${displayName}`}
136+
aria-label={
137+
isCreating
138+
? `Creating workspace ${displayName}`
139+
: isDeleting
140+
? `Deleting workspace ${displayName}`
141+
: `Select workspace ${displayName}`
142+
}
143+
aria-disabled={isDisabled}
130144
data-workspace-path={namedWorkspacePath}
131145
data-workspace-id={workspaceId}
132146
>
@@ -147,14 +161,18 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
147161
/>
148162
) : (
149163
<span
150-
className="text-foreground -mx-1 min-w-0 flex-1 cursor-pointer truncate rounded-sm px-1 text-left text-[14px] transition-colors duration-200 hover:bg-white/5"
164+
className={cn(
165+
"text-foreground -mx-1 min-w-0 flex-1 truncate rounded-sm px-1 text-left text-[14px] transition-colors duration-200",
166+
!isDisabled && "cursor-pointer hover:bg-white/5"
167+
)}
151168
onDoubleClick={(e) => {
169+
if (isDisabled) return;
152170
e.stopPropagation();
153171
startRenaming();
154172
}}
155-
title="Double-click to rename"
173+
title={isDisabled ? undefined : "Double-click to rename"}
156174
>
157-
{canInterrupt ? (
175+
{canInterrupt || isCreating ? (
158176
<Shimmer className="w-full truncate" colorClass="var(--color-foreground)">
159177
{displayName}
160178
</Shimmer>
@@ -165,41 +183,47 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
165183
)}
166184

167185
<div className="ml-auto flex items-center gap-1">
168-
<GitStatusIndicator
169-
gitStatus={gitStatus}
170-
workspaceId={workspaceId}
171-
tooltipPosition="right"
172-
isWorking={canInterrupt}
173-
/>
186+
{!isCreating && (
187+
<>
188+
<GitStatusIndicator
189+
gitStatus={gitStatus}
190+
workspaceId={workspaceId}
191+
tooltipPosition="right"
192+
isWorking={canInterrupt}
193+
/>
174194

175-
<TooltipWrapper inline>
176-
<button
177-
className="text-muted hover:text-foreground col-start-1 flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center border-none bg-transparent p-0 text-base opacity-0 transition-all duration-200 hover:rounded-sm hover:bg-white/10"
178-
onClick={(e) => {
179-
e.stopPropagation();
180-
void onRemoveWorkspace(workspaceId, e.currentTarget);
181-
}}
182-
aria-label={`Remove workspace ${displayName}`}
183-
data-workspace-id={workspaceId}
184-
>
185-
×
186-
</button>
187-
<Tooltip className="tooltip" align="right">
188-
Remove workspace
189-
</Tooltip>
190-
</TooltipWrapper>
195+
<TooltipWrapper inline>
196+
<button
197+
className="text-muted hover:text-foreground col-start-1 flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center border-none bg-transparent p-0 text-base opacity-0 transition-all duration-200 hover:rounded-sm hover:bg-white/10"
198+
onClick={(e) => {
199+
e.stopPropagation();
200+
void onRemoveWorkspace(workspaceId, e.currentTarget);
201+
}}
202+
aria-label={`Remove workspace ${displayName}`}
203+
data-workspace-id={workspaceId}
204+
>
205+
×
206+
</button>
207+
<Tooltip className="tooltip" align="right">
208+
Remove workspace
209+
</Tooltip>
210+
</TooltipWrapper>
211+
</>
212+
)}
191213
</div>
192214
</div>
193-
<div className="min-w-0">
194-
{isDeleting ? (
195-
<div className="text-muted flex min-w-0 items-center gap-1.5 text-xs">
196-
<span className="-mt-0.5 shrink-0 text-[10px]">🗑️</span>
197-
<span className="min-w-0 truncate">Deleting...</span>
198-
</div>
199-
) : (
200-
<WorkspaceStatusIndicator workspaceId={workspaceId} />
201-
)}
202-
</div>
215+
{!isCreating && (
216+
<div className="min-w-0">
217+
{isDeleting ? (
218+
<div className="text-muted flex min-w-0 items-center gap-1.5 text-xs">
219+
<span className="-mt-0.5 shrink-0 text-[10px]">🗑️</span>
220+
<span className="min-w-0 truncate">Deleting...</span>
221+
</div>
222+
) : (
223+
<WorkspaceStatusIndicator workspaceId={workspaceId} />
224+
)}
225+
</div>
226+
)}
203227
</div>
204228
</div>
205229
{renameError && isEditing && (

src/browser/contexts/WorkspaceContext.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export interface WorkspaceContext {
6666

6767
// Selection
6868
selectedWorkspace: WorkspaceSelection | null;
69-
setSelectedWorkspace: (workspace: WorkspaceSelection | null) => void;
69+
setSelectedWorkspace: React.Dispatch<React.SetStateAction<WorkspaceSelection | null>>;
7070

7171
// Workspace creation flow
7272
pendingNewWorkspaceProject: string | null;
@@ -214,6 +214,9 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) {
214214
setWorkspaceMetadata((prev) => {
215215
const updated = new Map(prev);
216216
const isNewWorkspace = !prev.has(event.workspaceId) && event.metadata !== null;
217+
const existingMeta = prev.get(event.workspaceId);
218+
const wasCreating = existingMeta?.status === "creating";
219+
const isNowReady = event.metadata !== null && event.metadata.status !== "creating";
217220

218221
if (event.metadata === null) {
219222
// Workspace deleted - remove from map
@@ -223,9 +226,10 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) {
223226
updated.set(event.workspaceId, event.metadata);
224227
}
225228

226-
// If this is a new workspace (e.g., from fork), reload projects
227-
// to ensure the sidebar shows the updated workspace list
228-
if (isNewWorkspace) {
229+
// Reload projects when:
230+
// 1. New workspace appears (e.g., from fork)
231+
// 2. Workspace transitions from "creating" to ready (now saved to config)
232+
if (isNewWorkspace || (wasCreating && isNowReady)) {
229233
void refreshProjects();
230234
}
231235

0 commit comments

Comments
 (0)