Skip to content

agents: show draft cards only for drafts that exist on disk - #176

Open
morgmart wants to merge 3 commits into
mainfrom
fix/stale-agent-draft-delete
Open

agents: show draft cards only for drafts that exist on disk#176
morgmart wants to merge 3 commits into
mainfrom
fix/stale-agent-draft-delete

Conversation

@morgmart

@morgmart morgmart commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Category

Bug fix / UX behavior (Agents gallery, agent builder)

User Impact

  • A draft card whose file was moved or deleted no longer gets stuck on the Agents page with a Delete that fails with Source "…" not found. The gallery now shows exactly the drafts that exist on disk — file gone, card gone on the next refresh.
  • Clicking New agent and leaving without typing anything no longer asks "Save this agent draft?" and no longer leaves an untitled-agent-*.md file (and an empty chat) behind. Untouched drafts are discarded silently.
  • Deleting a draft card still works when its builder chat is gone: it removes the file directly instead of requiring a session.

Editing an existing agent without changes still shows the save/discard modal — that is intentionally out of scope here and will be a separate PR.

Problem

Draft cards were built from open build-agent chat sessions plus an in-memory cache of draft metadata (localDraftSourcesByPath). When the draft file disappeared out from under the app, the card survived on the cache, and Delete went through Goose's sources/delete, which fails when the file can't be canonicalized. The lookup in findAgentBuilderSource kept returning the cached entry, so the card could never be removed.

Separately, every new draft is seeded with modelProviderId, and the placeholder check treated that as user content. So a fresh "New agent" draft was never considered empty: leaving it prompted to save, and declining still left the file on disk. Over time this piled up untitled-agent-* files — the exact files that become stuck cards when cleaned up by hand.

Solution

Gallery drafts come from disk. listAgentGallery() makes a single listAgentSources() call and splits it into personas and drafts by properties.draft === true. The agent store gains draftSources; usePersonas and AgentBuilderCapability.refreshPersonas populate both lists from that one call. AgentsView renders one card per draft file (hiding untouched placeholders) and joins each to its builder session by path or builderSessionId when one is still open. PersonaDraftCard renders from the source entry rather than a session.

Placeholder rule. modelProviderId is exempt from isPlaceholderDraftForSession, so a draft with only seeded metadata counts as untouched.

Silent discard of untouched drafts. guardNavigation checks isDiscardableAgentBuilderSession (draft or no file) when there's no user content, runs the navigation first, then discards the draft and closes its chat. Navigating first matters: closing the active chat redirects home, which would stomp on where the user was going. Existing-agent edits with no changes just navigate away.

Lookup no longer deadlocks. findAgentBuilderSource compares against the backend's listed paths; a cached draft that isn't listed and can't be read is dropped from the cache and treated as missing, so delete/discard paths can complete.

Delete without a session. handleDeleteDraft uses deleteDraftAgentSession when a chat exists, otherwise discardAgentBuilderSource(path), then removes the draft from the store immediately.

Also removes a duplicate useVoiceConversationStore import in AppShell.navigation.test.tsx that was failing lint on main.

Review follow-up: two timing races (second commit)

Review surfaced two gaps where the promises above could break under timing:

A draft could be deleted based on a stale "it's empty" check. The guard decided "untouched", awaited one more lookup, then deleted — anything typed in that gap was lost silently. discardUntouchedDraftAgentSession now owns re-check-then-discard: the user-content check is the last step before the file goes, and a draft that picked up content returns "kept" so the save/discard prompt shows instead. Both the Back guard and the New agent button use it (isDiscardableAgentBuilderSession folded in).

A slow gallery refresh could repaint a just-deleted card. usePersonas already fenced stale disk listings behind a mutation counter, but that fence was private to the hook; draft deletion in AgentsView and promotion writes in AgentBuilderCapability bypassed it. The fence now lives in the agent store as refreshGallery(fetch) / mutateGallery(work), and every gallery writer goes through one of the two. A refresh that started before a delete or promotion is dropped when it lands.

Review follow-up 2 (third commit)

  • The "final" content check still awaited a disk read after its in-memory look. The in-memory look is now a synchronous helper (hasLocalAgentBuilderUserContent), re-run with no await between it and the delete. Test types during the held read inside the content check and asserts "kept".
  • completeBuilder started its disk refresh before the seeding mutation released the fence, so the fence dropped every post-promotion refresh. The refresh now chains after the mutation; a capability test drives a real save through the real store and asserts the disk listing is applied. The ChatRightRail store mock now models the fence instead of always applying.

Testing

  • just check passes.
  • Full pnpm vitest run: 576 files, 6822 passed, 1 skipped.
  • Race tests added: deferred-lookup test for the guard (type while the lookup is pending → "kept", nothing deleted), store fence semantics (stale snapshot dropped, in-flight mutation blocks apply, fence released on throw), and an AgentsView test where a refresh started before Delete resolves afterwards and the card stays gone.
  • Manual check in just dev was done against the earlier version of this branch (moved a draft file out of ~/.agents/agents, confirmed Delete worked). The reworked behavior (card disappears on refresh, untouched draft leaves no prompt/file) is covered by the tests above; a fresh manual pass is still worth doing before merge.
File changes
  • src/shared/api/agents.tsAgentGalleryListing, listAgentGallery(), refreshAgentGallery(); listPersonas() delegates to the gallery call.
  • src/features/agents/stores/agentStore.tsdraftSources, setDraftSources, removeDraftSource; gallery fence (galleryRevision, galleryMutationsInFlight, refreshGallery, mutateGallery).
  • src/features/agents/hooks/usePersonas.ts — loads personas and drafts from one gallery fetch through the store fence (private mutation refs removed).
  • src/features/agents/capabilities/AgentBuilderCapability.tsxcompleteBuilder writes and its follow-up refresh go through the store fence.
  • src/features/agents/ui/PersonaGallery.tsxGalleryDraft type; PersonaDraftCard renders from the source entry.
  • src/features/agents/ui/AgentsView.tsx — drafts derived from draftSources, joined to sessions; continue/delete handlers work with or without a session; delete runs as a gallery mutation.
  • src/features/agents/lib/agentBuilderIdentity.tsmodelProviderId exempt from placeholder detection.
  • src/features/agents/lib/agentBuilderSession.tsdiscardUntouchedDraftAgentSession (re-check-then-discard).
  • src/features/agents/lib/agentBuilderSourceLifecycle.tsfindAgentBuilderSource drops unlisted, unreadable cached drafts.
  • src/features/agents/hooks/useAgentBuilderCoordinator.tsguardNavigation and start use discardUntouchedDraftAgentSession.
  • Tests: AppShell.navigation.test.tsx, usePersonas.test.ts, AgentBuilderCapability.test.tsx, AgentsView.entry.test.tsx, agentBuilderSession.test.ts, agentStore.test.ts, ChatRightRail.test.tsx.

@morgmart
morgmart requested a review from a team August 23, 2026 20:27
The Agents gallery used to build draft cards from open build-agent chat
sessions plus an in-memory cache of draft metadata. When a draft file
was moved or deleted out from under the app, the card stayed behind and
Delete failed with `Source "…" not found`, leaving a card that could
never be removed.

Drafts are now read from disk like finished agents: `listAgentGallery()`
splits a single `listAgentSources()` call into personas and drafts, the
agent store keeps `draftSources`, and `AgentsView` renders a card per
draft file, joining it to its builder session when one is still open.
File gone -> card gone on the next refresh. Deleting a draft whose chat
is gone discards the file directly instead of going through a session.

Untouched drafts no longer pile up or prompt. `modelProviderId` is
seeded on every new draft and was being counted as user content, so
leaving a fresh "New agent" draft asked "Save this agent draft?" and
kept an `untitled-agent-*.md` around. It is now exempt from the
placeholder check, and the navigation guard silently discards a draft
with no user content (navigating first so closing the empty chat does
not redirect home). Editing an existing agent without changes still
just navigates away.

`findAgentBuilderSource` drops a cached draft from the in-memory cache
when its file is no longer listed by the backend and cannot be read, so
a missing file can't deadlock delete again.

Also removes a duplicate `useVoiceConversationStore` import in the
AppShell navigation test that was failing lint on main.

Co-Authored-By: Claude <noreply@anthropic.com>
@morgmart morgmart changed the title fix(agents): let stale draft cards be deleted when their file is gone agents: show draft cards only for drafts that exist on disk Aug 23, 2026
@morgmart
morgmart force-pushed the fix/stale-agent-draft-delete branch from 0467a75 to 85babd0 Compare August 23, 2026 22:25
morgmart and others added 2 commits August 23, 2026 16:51
Navigation guard: the "is this draft untouched?" decision was made, then
another lookup awaited, then the draft deleted — anything typed in that
gap was discarded silently. `discardUntouchedDraftAgentSession` now owns
re-check-then-discard: the user-content check is the last step before the
file goes, and a draft that picked up content returns "kept" so the caller
shows the save/discard prompt instead. Both the Back guard and the New
agent button use it; `isDiscardableAgentBuilderSession` is folded in.

Gallery refresh: `usePersonas` fenced stale disk listings behind a private
mutation counter, but draft deletion in AgentsView and the promotion writes
in AgentBuilderCapability bypassed it, so a focus/interval refresh that
began before Delete could land afterwards and repaint the deleted card. The
fence now lives in the agent store as `refreshGallery` / `mutateGallery`;
every gallery writer goes through one of the two.

Tests: deferred-lookup race for the guard (type while pending → kept, no
delete), store fence semantics (stale snapshot dropped, in-flight mutation
blocks apply, fence released on throw), and an AgentsView test where a
refresh started before Delete resolves afterwards and the card stays gone.

Co-Authored-By: Claude <noreply@anthropic.com>
…ion refresh

Review of efb8993 found two gaps in the race fixes.

The "final" user-content check still awaited a disk read after its
in-memory look, so text typed during that read was invisible to it and the
draft was still deleted. The in-memory look is now its own synchronous
helper (`hasLocalAgentBuilderUserContent`) and
`discardUntouchedDraftAgentSession` runs it once more with no await
between it and the delete. Test holds the read inside the content check,
types during it, and asserts "kept" with no delete/navigate/close; it fails
without the re-check.

`completeBuilder` started its disk refresh before the seeding mutation had
released the gallery fence, so the fence (correctly) dropped every
post-promotion refresh and the gallery stayed on the optimistic copy until
the next timed refresh. The refresh now chains after the mutation. A
capability test drives a real save through the real store and asserts the
listing from disk is applied; the ChatRightRail store mock now models the
fence instead of always applying, so it can no longer mask ordering bugs.

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant