Skip to content

Remove per-thread scope pickers from workspace-bound chat panes - #211

Open
Zeus-Deus wants to merge 2 commits into
mainfrom
fix-duplicate-worktree-prompt
Open

Remove per-thread scope pickers from workspace-bound chat panes#211
Zeus-Deus wants to merge 2 commits into
mainfrom
fix-duplicate-worktree-prompt

Conversation

@Zeus-Deus

Copy link
Copy Markdown
Owner

The bug

Opening a second Agent Chat tab inside an existing workspace re-asked "current checkout or New worktree?" — and offered a project picker — even though the tab was already inside a workspace of a project.

That question has no valid answer there. A workspace owns exactly one project root and one checkout (cwd / worktree_path), shared by every tab, surface and pane inside it. So neither control rescoped the thread:

  • "New worktree" created a SECOND workspace off the parent repo mid-send and relocated the user into it, orphaning the tab (and its already-started session) behind an app-wide workspace flip. Because the row renders on every messages.length === 0 thread, it re-asked on each additional chat tab — including tabs of a workspace that was already worktree-backed.
  • The location picker activated another project's first workspace (or opened the new-workspace dialog), abandoning whatever had been typed in the pane.

Both were app-wide relocations wearing per-thread clothing.

The fix

ThreadScopeRow is now DraftChatSurface-only — the one surface where "what should the first send create?" has an answer, because nothing exists yet. AgentChatPane renders the read-only Context Row from its first render, so the strip never changes shape across the first send.

Creating a worktree belongs to the surfaces honest about creating a workspace (the draft surface, the sidebar worktree flow); switching project or workspace belongs to the sidebar.

With one consumer left, the two-surface machinery comes out — ThreadScopeLocation / ThreadScope discriminated props back to flat props, and the trailing slot, FixedBranchChip and LocationControl's workspace-mode branches deleted, along with handleDeferredWorktreeSubmit, prestart-worktree-session.ts, and the pane's scope state and callbacks. Net +634 / −1179.

Backend: adopt-don't-duplicate guard

Found while tracing the above. git_create_worktree can return an ALREADY-EXISTING checkout without creating anything (its path-reuse short-circuit, when the conventional path is on disk and registered to the same branch), but create_worktree_workspace_impl called create_workspace_with_layout regardless — two workspaces over one on-disk worktree, i.e. two agents editing the same files. Reachable by typing a worktree name that already exists, since typed names skip the deconfliction auto-names get.

It now looks for a live local workspace already claiming that path (worktree_path, falling back to cwd; canonicalized comparison; remote/attach-only rows excluded) and adopts + focuses it instead, skipping PTY spawn, setup scripts, .mcp.json rewrite and preset launch. initial_prompt / agent_preset_id are deliberately dropped rather than injected into a session someone else is already using, and archived workspaces neither block creation nor get silently un-archived.

Behavior changes

Surface Before After
Chat pane, empty thread project ⌄ · Current checkout ⌄ · from ⑂ branch ⌄ project · ⑂ branch (read-only)
Chat pane, with messages Context Row unchanged
Draft surface (New agent) full scope controls unchanged

Hopping to another project and "Open another project…" are now sidebar-only from within a chat.

Verification

  • npm run check clean; npm run test — 208 files / 3026 tests
  • cargo check clean; cargo test — 2261 tests, including 6 new adopt-guard cases (same-path adopt, distinct paths still create, non-canonical spelling, cwd fallback, remote rows, archived rows)
  • Dev-mock browser drive of both surfaces
  • New pane regression tests pin the inverse guarantees: no interactive scope row on an empty thread, identical strip before/after first send, and a first send that creates no worktree, no workspace and no relocation

Docs: docs/features/agent-chat.md § Thread Scope rewritten, plus docs/INDEX.md and a docs/core/STATUS.md unreleased entry.

Known follow-ups (not in this PR)

  • AgentChatPane's fallbackCwd resolves from active_workspace_id rather than the pane's own workspace — much smaller exposure now that nothing flips the active workspace mid-send, but still wrong.
  • git_create_worktree prefers origin/<base> when creating a branch, so a draft-surface worktree forked off a branch with unpushed local commits starts behind.

A workspace owns exactly one project root and one checkout (cwd /
worktree_path), shared by every tab, surface and pane inside it. But
AgentChatPane's empty-thread ThreadScopeRow offered both a "New
worktree" checkout control and a project location picker, on EVERY
additional chat tab — including tabs of a workspace that was already
worktree-backed, i.e. offering a choice that workspace could not honour.

Neither control rescoped the thread. "New worktree" created a SECOND
workspace off the parent repo mid-send and relocated the user into it,
orphaning the tab (and its already-started session) behind an app-wide
workspace flip. The location picker activated another project's first
workspace, abandoning whatever had been typed in the pane. Both were
app-wide relocations wearing per-thread clothing.

ThreadScopeRow is now DraftChatSurface-only — the one surface where
"what should the first send create?" has an answer, because nothing
exists yet — and the pane renders the read-only Context Row from its
first render, so the strip never changes shape across the first send.
Creating a worktree belongs to the draft surface and the sidebar
worktree flow; switching project or workspace belongs to the sidebar.

With one consumer left, the two-surface machinery comes out: the
ThreadScopeLocation / ThreadScope discriminated props go back to flat
props, and the trailing slot plus LocationControl's workspace-mode
branches are deleted along with handleDeferredWorktreeSubmit,
prestart-worktree-session.ts, and the pane's scope state and callbacks.

Backing this up in the backend: create_worktree_workspace_impl gains an
adopt-existing guard. git_create_worktree can return an ALREADY-EXISTING
checkout without creating anything (its path-reuse short-circuit, when
the conventional path is on disk and registered to the same branch), and
the impl called create_workspace_with_layout regardless — minting two
workspaces over one on-disk worktree, i.e. two agents editing the same
files. It now looks for a live local workspace already claiming that
path (worktree_path, falling back to cwd; canonicalized comparison;
remote/attach-only rows excluded) and adopts + focuses it instead,
skipping PTY spawn, setup scripts, .mcp.json rewrite and preset launch.
initial_prompt / agent_preset_id are deliberately dropped rather than
injected into a session someone else is already using, and archived
workspaces neither block creation nor get silently un-archived.

Verified with npm run check, npm run test (3026), cargo check,
cargo test (2261), and a dev-mock browser drive of both surfaces.
@Zeus-Deus

Copy link
Copy Markdown
Owner Author

Review — Opus 5 (high effort)

The diagnosis is right and the fix is the correct shape — a workspace owns exactly one project root and one checkout, so a per-thread control offering to change either was always an app-wide relocation in disguise. Deleting the two-surface machinery rather than gating it is the right instinct.

The backend adopt-guard is well built. I verified the archived-workspace reasoning independently: ArchivedWorkspaceSnapshot really is metadata-only (archive_id, title, cwd, worktree_path, project_root, … — no surfaces), and archiving removes the workspace from snapshot.workspaces, so archived entries are invisible to find_live_workspace_for_worktree_path by construction exactly as the doc comment claims. The remote/attach_only exclusion is right too.

CI is red

web-checks (windows-latest) fails on:

src/components/overlays/new-workspace-dialog.test.tsx
  > Default base branch > does not override the pill once the user has manually picked a branch
TestingLibraryElementError: Unable to find an element with the text: feature-x

That file isn't in this diff, the Linux job passes, and the other ten open PRs all pass the same job — so this is very likely a Windows-only timing flake in the branch-picker's async default resolution (deleting prestart-worktree-session.test.ts changes vitest's file scheduling, which is enough to shake a race loose). Almost certainly not yours, but it needs a green re-run before merge rather than a shrug.

The adopt path loses the user's typed prompt silently

// `initial_prompt` and `agent_preset_id` are DROPPED on the adopt path,
// by design.

The reasoning is correct — injecting a prompt into somebody's in-flight session is unrecoverable, and dropping is the safer failure. But the user-visible result is: they type a prompt in the New Workspace dialog, type a worktree name that already exists, hit create, and the app silently focuses a different workspace with their prompt gone. No toast, no error, no trace. And this path is reachable precisely because typed names skip the deconfliction auto-names get, i.e. by the user doing something deliberate.

Returning something the frontend can act on — an adopted: bool on WorkspaceCreated, or a distinct outcome enum — would let the dialog say "Reusing the existing workspace for feature — your prompt wasn't sent". Same safe behaviour, no silent loss.

The cwd fallback widens the guard past the collision it targets

let claimed = w.worktree_path.as_deref().unwrap_or(w.cwd.as_str());

For the case you're aiming at (a plain workspace opened directly on a worktree directory) this is right. But it also means "create worktree feature" can resolve to a workspace that is not on feature at all — any local workspace whose cwd happens to be that directory, whatever it's checked out to. git_create_worktree has already run by then and only short-circuits when the path maps to the same branch, so in practice the paths agree; but the guard itself doesn't check. Comparing w.git_branch against branch before adopting would make the guard say what its name says.

Small things

@Zeus-Deus

Copy link
Copy Markdown
Owner Author

Review by GPT 5.6 Sol xhigh found one backend race in the adopt guard.

create_worktree_workspace_impl does find_live_workspace_for_worktree_path(&state.snapshot(), ...) and, if absent, later calls state.create_workspace_with_layout(...) as a separate lock acquisition (workspace.rs:581-595). Two concurrent create requests for the same already-created/reused worktree can both observe no workspace in their snapshots and both insert one. This is exactly the two-workspaces/one-checkout state the guard is intended to prevent; the pure lookup tests cannot exercise the check-then-insert race.

Please make lookup/adopt-or-create one atomic AppStateStore operation under the state mutex (and add a barrier-based concurrent regression), rather than composing snapshot() with a later insert.

The frontend scope-removal behavior otherwise looks consistent, and the focused frontend suites passed 100/100 tests. The currently red Windows web-check is a timeout in unchanged new-workspace-dialog.test.tsx; that whole file passed locally, and the exact timed-out test passed three additional consecutive runs, so I found no evidence that failure comes from this diff.

Coordination note: this head has pairwise textual conflicts with #205 and #212.

- Make the lookup-adopt-or-create decision one atomic operation under
  the state lock (AppStateStore::adopt_or_create_worktree_workspace),
  which also stamps the new workspace's worktree_path claim before
  releasing. Two concurrent creates for the same worktree path could
  previously both miss the probe and both insert; git worktree add
  stays outside the lock. Covered by a two-thread barrier regression
  test asserting exactly one workspace claims the path.
- Require a matching git_branch before adopting via the cwd fallback,
  so the guard stays no wider than git_create_worktree's same-branch
  path reuse; explicit worktree_path claims still adopt on path
  identity alone. Extended the lookup tests both ways.
- Return an additive adopted flag from create_worktree_workspace
  (WorkspaceCreated) and the control-socket JSON. The new-workspace
  dialog now toasts when adoption dropped a typed prompt instead of
  silently focusing another workspace.
- Hoist the duplicated paths_equal helper into workspace_paths and use
  it from scripts.rs and the adopt guard.
@Zeus-Deus

Copy link
Copy Markdown
Owner Author

Verified: all four substantive review issues are fixed at the head — adoption is now surfaced to the user (adopted flag through the command/control-socket/frontend with toasts + tests), the cwd fallback requires a branch match and declines when unverifiable, probe+insert+stamp now happen under a single mutex acquisition with a 2-thread barrier regression test, and paths_equal is hoisted to a shared module.

Two blockers before merge:

  1. Rebase onto main: feat(chat): fuzzy-search the "Run in" project picker #205 rewrote LocationControl in ThreadScopeRow.tsx (fuzzy-search popover), and this branch rewrites the same region. The fuzzy search must be re-threaded onto the flattened draft-only LocationControl — the draft surface keeps the popover, so the feature survives. docs/features/agent-chat.md also conflicts. (state_impl.rs and the test file auto-merge cleanly.)
  2. CI: no checks have run on the current head commit — needs a green run after the rebase.

Also coordinate with #212, which touches the same component; whichever lands second inherits the harder rebase.

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