Skip to content

Commit 475370a

Browse files
authored
fix: replace optimistic workspace removal with destroying state indicator (Refs: beans-9s9h) (#155)
## Summary - When destroying a workspace, the optimistic removal caused a visual flash: the item disappeared from the sidebar, reappeared when the subscription delivered the still-existing worktree, then disappeared again once the backend finished cleanup. - Instead of eagerly removing, workspaces being destroyed now render at 30% opacity with interactions disabled, giving clear visual feedback while the backend processes the removal. - The subscription handler automatically cleans up the destroying state once the worktree is gone from the backend's list. ## Test plan - [ ] Create a workspace, then destroy it — verify it fades to low opacity and stays visible until fully removed (no flash/reappear) - [ ] Verify the destroy button in WorkspaceView is disabled and shows "Destroying..." tooltip during destruction - [ ] Verify that if the mutation fails, the workspace returns to normal opacity
1 parent c2fe3eb commit 475370a

4 files changed

Lines changed: 53 additions & 11 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
# beans-9s9h
3+
title: Replace optimistic workspace removal with destroying state indicator
4+
status: completed
5+
type: bug
6+
priority: normal
7+
created_at: 2026-03-18T10:20:34Z
8+
updated_at: 2026-03-18T10:22:23Z
9+
---
10+
11+
When closing a workspace, the optimistic removal causes it to flash (disappear then reappear from subscription, then disappear again). Replace with a 'destroying' visual state (low opacity) and let the backend subscription handle the actual removal.
12+
13+
## Summary of Changes
14+
15+
Replaced optimistic workspace removal with a 'destroying' state indicator:
16+
17+
- **worktrees.svelte.ts**: Added a `destroying` Set to track workspaces being destroyed. Instead of eagerly filtering them out of the list, they stay visible while the backend processes the removal. The subscription handler clears the destroying flag once the worktree disappears from the backend's list.
18+
- **Sidebar.svelte**: Workspaces being destroyed render at 30% opacity with `pointer-events-none`, preventing interaction while providing clear visual feedback.
19+
- **WorkspaceView.svelte**: The destroy button is disabled and shows 'Destroying...' tooltip during destruction.

frontend/src/lib/components/Sidebar.svelte

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,6 @@
143143
async function handleRemoveWorktree(id: string) {
144144
confirmingRemoveId = null;
145145
confirmingStatus = null;
146-
// Navigate away immediately since the store optimistically removes the item
147146
if (ui.activeView === id) {
148147
ui.navigateTo('planning');
149148
}
@@ -209,10 +208,12 @@
209208

210209
<div class="flex flex-col gap-1">
211210
{#each workspaceItems as item (item.id)}
211+
{@const destroying = worktreeStore.isDestroying(item.id)}
212212
<div
213213
transition:fade={{ duration: 150 }}
214214
class={[
215-
'rounded-md border transition-colors',
215+
'rounded-md border transition-all',
216+
destroying && 'pointer-events-none opacity-30',
216217
ui.activeView === item.id
217218
? 'border-accent/30 bg-surface'
218219
: 'border-border/50 bg-surface/50 hover:border-border hover:bg-surface'

frontend/src/lib/components/WorkspaceView.svelte

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@
109109
await worktreeStore.removeWorktree(worktreeId);
110110
}
111111
112+
const destroying = $derived(worktreeStore.isDestroying(worktreeId));
113+
112114
const worktree = $derived(
113115
worktreeId === MAIN_WORKSPACE_ID
114116
? undefined
@@ -254,9 +256,9 @@
254256
{/if}
255257
{#if isWorktree}
256258
<button
257-
class={["btn-toggle ml-1 cursor-pointer border-accent/30 bg-accent/10 text-accent", agentBusy ? "opacity-50" : "hover:bg-accent/20"]}
258-
title={agentBusy ? "Cannot destroy while agent is running" : "Close this workspace"}
259-
disabled={agentBusy}
259+
class={["btn-toggle ml-1 cursor-pointer border-accent/30 bg-accent/10 text-accent", (agentBusy || destroying) ? "opacity-50" : "hover:bg-accent/20"]}
260+
title={destroying ? "Destroying..." : agentBusy ? "Cannot destroy while agent is running" : "Close this workspace"}
261+
disabled={agentBusy || destroying}
260262
onclick={() => (confirmingDestroy = true)}
261263
>
262264
<span class="icon-[uil--archive] size-4"></span>

frontend/src/lib/worktrees.svelte.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export interface WorktreeStatus {
2828

2929
class WorktreeStore {
3030
worktrees = $state<Worktree[]>([]);
31+
/** IDs of worktrees currently being destroyed by the backend. */
32+
destroying = $state(new Set<string>());
3133
initialized = $state(false);
3234
loading = $state(false);
3335
error = $state<string | null>(null);
@@ -51,6 +53,18 @@ class WorktreeStore {
5153
if (wts) {
5254
this.worktrees = wts.map(mapWorktree);
5355
this.initialized = true;
56+
57+
// Clear destroying flags for worktrees the backend has fully removed
58+
if (this.destroying.size > 0) {
59+
const currentIds = new Set(wts.map((wt) => wt.id));
60+
const next = new Set<string>();
61+
for (const id of this.destroying) {
62+
if (currentIds.has(id)) next.add(id);
63+
}
64+
if (next.size !== this.destroying.size) {
65+
this.destroying = next;
66+
}
67+
}
5468
}
5569
})
5670
);
@@ -95,25 +109,31 @@ class WorktreeStore {
95109
this.loading = true;
96110
this.error = null;
97111

98-
// Eagerly remove from local state so the sidebar updates immediately
99-
// without waiting for the subscription to deliver the new list.
100-
const previous = this.worktrees;
101-
this.worktrees = this.worktrees.filter((wt) => wt.id !== id);
112+
// Mark as destroying so the UI can show a visual indicator (low opacity)
113+
// instead of optimistically removing. The subscription will handle the
114+
// actual removal once the backend finishes cleanup.
115+
this.destroying = new Set([...this.destroying, id]);
102116

103117
const result = await client.mutation(RemoveWorktreeDocument, { id }).toPromise();
104118

105119
this.loading = false;
106120

107121
if (result.error) {
108-
// Restore on failure so the item reappears
109-
this.worktrees = previous;
122+
// Clear destroying state on failure
123+
const next = new Set(this.destroying);
124+
next.delete(id);
125+
this.destroying = next;
110126
this.error = result.error.message;
111127
return false;
112128
}
113129

114130
return true;
115131
}
116132

133+
isDestroying(id: string): boolean {
134+
return this.destroying.has(id);
135+
}
136+
117137
hasWorktree(id: string): boolean {
118138
return this.worktrees.some((wt) => wt.id === id);
119139
}

0 commit comments

Comments
 (0)