Summary
deleteThread only runs worktree cleanup for threads present in the main thread store. Archived threads are excluded from that store, so deleting one from Settings → Archived deletes the thread and leaves its git worktree on disk permanently, with no prompt.
Why archived threads miss the cleanup
deleteThread resolves its target via resolveThreadTarget → readThreadShell (apps/web/src/hooks/useThreadActions.ts:204). On a miss it takes an early-return branch at useThreadActions.ts:285-295:
// Thread not in main store (e.g. archived thread) — dispatch delete directly.
That returns before the worktree block at useThreadActions.ts:318-430 (getOrphanedWorktreePathForThread → confirm dialog → removeWorktree).
The main store is hydrated from getShellSnapshot, whose thread query filters archived rows — listActiveThreadRows in apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts:481:
WHERE deleted_at IS NULL
AND archived_at IS NULL
So once the client has re-hydrated, an archived thread is by construction absent from the store the delete path reads. The archived panel is fed by a separate archivedShellSnapshot (apps/web/src/lib/archivedThreadsState.ts), and its delete action calls confirmAndDeleteThread (apps/web/src/components/settings/SettingsPanels.tsx:2641) → deleteThread.
Repro
- Create a thread with New worktree — worktree lands in
~/.t3/worktrees/<repo>/<branch>.
- Archive the thread.
- Restart the app (or otherwise let the shell snapshot re-hydrate).
- Settings → Archived → delete the thread, accepting the generic
Delete thread "…"? prompt.
Expected: same as deleting an unarchived thread — This thread is the only one linked to this worktree… Delete the worktree too?
Actual: no worktree prompt. The thread is deleted; the worktree directory and its git admin entry remain.
Step 3 matters. Deleting immediately after archiving within the same session can still hit the normal path, because thread.archived does not evict the thread from the client store (apps/web/src/orchestrationEventEffects.ts:49 sets clearDeletedThread: false). That's likely why this is easy to miss in manual testing.
Impact
apps/web/src/hooks/useThreadActions.ts:425 is the only call site in the app that removes a worktree, so nothing else reclaims these. Archive-then-delete is a natural workflow, and worktrees accumulate silently.
The cost isn't trivial: each is a full working tree, and if the project has a setup script it runs inside every new worktree (apps/server/src/git/GitManager.ts:2091) — so a node_modules per worktree. git worktree prune doesn't help; it only drops admin entries for directories that are already gone.
Verification
I exercised the real deleteThread with mocked dependencies (vitest, apps/web):
- active thread with a worktree → confirm dialog shown once,
removeWorktree called once
readThreadShell returning null (the archived case) → delete dispatched, confirm never shown, removeWorktree never called
readThreadShell was the only difference between the two cases.
Observed at commit c17d02cf.
Summary
deleteThreadonly runs worktree cleanup for threads present in the main thread store. Archived threads are excluded from that store, so deleting one from Settings → Archived deletes the thread and leaves its git worktree on disk permanently, with no prompt.Why archived threads miss the cleanup
deleteThreadresolves its target viaresolveThreadTarget→readThreadShell(apps/web/src/hooks/useThreadActions.ts:204). On a miss it takes an early-return branch atuseThreadActions.ts:285-295:That returns before the worktree block at
useThreadActions.ts:318-430(getOrphanedWorktreePathForThread→ confirm dialog →removeWorktree).The main store is hydrated from
getShellSnapshot, whose thread query filters archived rows —listActiveThreadRowsinapps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts:481:So once the client has re-hydrated, an archived thread is by construction absent from the store the delete path reads. The archived panel is fed by a separate
archivedShellSnapshot(apps/web/src/lib/archivedThreadsState.ts), and its delete action callsconfirmAndDeleteThread(apps/web/src/components/settings/SettingsPanels.tsx:2641) →deleteThread.Repro
~/.t3/worktrees/<repo>/<branch>.Delete thread "…"?prompt.Expected: same as deleting an unarchived thread —
This thread is the only one linked to this worktree… Delete the worktree too?Actual: no worktree prompt. The thread is deleted; the worktree directory and its git admin entry remain.
Step 3 matters. Deleting immediately after archiving within the same session can still hit the normal path, because
thread.archiveddoes not evict the thread from the client store (apps/web/src/orchestrationEventEffects.ts:49setsclearDeletedThread: false). That's likely why this is easy to miss in manual testing.Impact
apps/web/src/hooks/useThreadActions.ts:425is the only call site in the app that removes a worktree, so nothing else reclaims these. Archive-then-delete is a natural workflow, and worktrees accumulate silently.The cost isn't trivial: each is a full working tree, and if the project has a setup script it runs inside every new worktree (
apps/server/src/git/GitManager.ts:2091) — so anode_modulesper worktree.git worktree prunedoesn't help; it only drops admin entries for directories that are already gone.Verification
I exercised the real
deleteThreadwith mocked dependencies (vitest,apps/web):removeWorktreecalled oncereadThreadShellreturningnull(the archived case) → delete dispatched, confirm never shown,removeWorktreenever calledreadThreadShellwas the only difference between the two cases.Observed at commit
c17d02cf.