Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
# beans-9s9h
title: Replace optimistic workspace removal with destroying state indicator
status: completed
type: bug
priority: normal
created_at: 2026-03-18T10:20:34Z
updated_at: 2026-03-18T10:22:23Z
---

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.

## Summary of Changes

Replaced optimistic workspace removal with a 'destroying' state indicator:

- **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.
- **Sidebar.svelte**: Workspaces being destroyed render at 30% opacity with `pointer-events-none`, preventing interaction while providing clear visual feedback.
- **WorkspaceView.svelte**: The destroy button is disabled and shows 'Destroying...' tooltip during destruction.
5 changes: 3 additions & 2 deletions frontend/src/lib/components/Sidebar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,6 @@
async function handleRemoveWorktree(id: string) {
confirmingRemoveId = null;
confirmingStatus = null;
// Navigate away immediately since the store optimistically removes the item
if (ui.activeView === id) {
ui.navigateTo('planning');
}
Expand Down Expand Up @@ -209,10 +208,12 @@

<div class="flex flex-col gap-1">
{#each workspaceItems as item (item.id)}
{@const destroying = worktreeStore.isDestroying(item.id)}
<div
transition:fade={{ duration: 150 }}
class={[
'rounded-md border transition-colors',
'rounded-md border transition-all',
destroying && 'pointer-events-none opacity-30',
ui.activeView === item.id
? 'border-accent/30 bg-surface'
: 'border-border/50 bg-surface/50 hover:border-border hover:bg-surface'
Expand Down
8 changes: 5 additions & 3 deletions frontend/src/lib/components/WorkspaceView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@
await worktreeStore.removeWorktree(worktreeId);
}

const destroying = $derived(worktreeStore.isDestroying(worktreeId));

const worktree = $derived(
worktreeId === MAIN_WORKSPACE_ID
? undefined
Expand Down Expand Up @@ -254,9 +256,9 @@
{/if}
{#if isWorktree}
<button
class={["btn-toggle ml-1 cursor-pointer border-accent/30 bg-accent/10 text-accent", agentBusy ? "opacity-50" : "hover:bg-accent/20"]}
title={agentBusy ? "Cannot destroy while agent is running" : "Close this workspace"}
disabled={agentBusy}
class={["btn-toggle ml-1 cursor-pointer border-accent/30 bg-accent/10 text-accent", (agentBusy || destroying) ? "opacity-50" : "hover:bg-accent/20"]}
title={destroying ? "Destroying..." : agentBusy ? "Cannot destroy while agent is running" : "Close this workspace"}
disabled={agentBusy || destroying}
onclick={() => (confirmingDestroy = true)}
>
<span class="icon-[uil--archive] size-4"></span>
Expand Down
32 changes: 26 additions & 6 deletions frontend/src/lib/worktrees.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export interface WorktreeStatus {

class WorktreeStore {
worktrees = $state<Worktree[]>([]);
/** IDs of worktrees currently being destroyed by the backend. */
destroying = $state(new Set<string>());
initialized = $state(false);
loading = $state(false);
error = $state<string | null>(null);
Expand All @@ -51,6 +53,18 @@ class WorktreeStore {
if (wts) {
this.worktrees = wts.map(mapWorktree);
this.initialized = true;

// Clear destroying flags for worktrees the backend has fully removed
if (this.destroying.size > 0) {
const currentIds = new Set(wts.map((wt) => wt.id));
const next = new Set<string>();
for (const id of this.destroying) {
if (currentIds.has(id)) next.add(id);
}
if (next.size !== this.destroying.size) {
this.destroying = next;
}
}
}
})
);
Expand Down Expand Up @@ -95,25 +109,31 @@ class WorktreeStore {
this.loading = true;
this.error = null;

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

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

this.loading = false;

if (result.error) {
// Restore on failure so the item reappears
this.worktrees = previous;
// Clear destroying state on failure
const next = new Set(this.destroying);
next.delete(id);
this.destroying = next;
this.error = result.error.message;
return false;
}

return true;
}

isDestroying(id: string): boolean {
return this.destroying.has(id);
}

hasWorktree(id: string): boolean {
return this.worktrees.some((wt) => wt.id === id);
}
Expand Down
Loading