Skip to content

fix(agents): shared-workspace panes, and a second agent pane that deleted itself - #2394

Merged
2witstudios merged 14 commits into
masterfrom
pu/shared-workspace-panes
Aug 10, 2026
Merged

2witstudios merged 14 commits into
masterfrom
pu/shared-workspace-panes

Conversation

@2witstudios

@2witstudios 2witstudios commented Aug 10, 2026 •

Copy link
Copy Markdown
Owner

Three fixes on the Agents pane surface. They are separate defects, but they share one root: the node-model cutover (3c12c9e75, "membership moves to the tree") gave the server a job the client was still doing, and three places were never revisited.

Two of them made a second agent pane unusable; the third made a shared workspace's pane half-dead.


1. The pane read its conversations from an ownership-filtered listing

A conversation of yours can live in a workspace another drive member owns — decideAgentSessionAccess grants by drive membership, not workspace ownership, which is what makes workspaces shared working contexts.

AgentPanes reached such a workspace through checkSessionAccess (drive membership — the nodes route's gate) and then read its conversation directory from GET /api/agent-workspaces, where ownerId rides every filter. That workspace is never in that listing, so readiness never armed: the agent switcher and New Conversation stayed disabled and closing a non-last chat pane was a silent no-op.

Two access models for one workspace, and the grid read the wrong one. Since such a workspace also never appears in your sidebar, the history list is the only route back to that conversation — so the broken path was the only path.

The fix is a deletion. useWorkspaceLayoutSync already reads the tree from the membership-gated nodes route and keeps it live on session:<id>, the per-workspace room every member joins. WorkspaceNodeTarget already carries {agentPageId, lastMessageAt}, and the redaction rule preserves both for a foreign private thread, masking only the title. So the grid reads the tree, and the second source goes — with recordMintedConversation, recordClosedConversation and their cache patches. The tree write is the update.

Two constraints that shape it:

  • Membership from nodes, facts from targets. A structural broadcast updates nodes and carries targets forward unchanged (it cannot redact per viewer), so a thread another member just placed is a member immediately with no agent known. Deriving membership from targets would hide it from the switch decision, which would then mint a duplicate. An unresolved member is absent from the agent-keyed list, never padded with agentPageId: null — null is the Global Assistant, a real agent.
  • Readiness is two facts: the tree was server-read and no member is missing its facts. workspaces[id] !== undefined cannot answer the first (runCommand seeds a root locally at rev 0), so hasServerSnapshot is exposed on the tree view rather than letting components reach into sync.

The sidebar still reads the ownership listing, correctly — it lists only workspaces you own. Nothing changes about which sessions appear there, or whose sandbox a conversation uses.

2. A mint's own admission looked like somebody else stealing its pane

Opening a second chat pane made it appear and then vanish, silently. Only the session's first conversation survived. Confirmed in production by a DELETE /api/agent-workspaces/{id}/conversations/{id} returning 200 moments after the pane rendered.

A mint's own server write admits what it created, and admitting places: admit → place fills exactly the pane the user picked into. The session:<id> broadcast binds that pane before the mint's POST resolves.

stillMinting asked whether the pane was still unbound — so the success case became indistinguishable from the abandonment case it was written to catch. Both callers answer abandonment by destroying what they just made: the chat mint DELETEs its conversation, the shell mint kills its shell. Both cleanups are deliberately silent, which is why there was no toast and nothing in the console.

eceeb956f wrote that check while the client was the only thing that could bind a pane, and it was right then. 3c12c9e75 made the server bind too and never revisited it. The stale comment above it gives it away — it justifies itself by a parked state the same epic later deleted.

A pane carrying this mint's own target is still this mint's pane, so both call sites now name what they produced. Bound to anything else is still a genuine loss and still cleans up — pinned by its own test, so the guard is corrected rather than disabled.

Terminals had the identical defect (spawnShell admits the same way); pages never did, having no server-side admit — which is exactly the asymmetry the report described.

3. A mint placed blind when more than one pane was empty

With two empty panes, picking an agent filled the wrong one and left the pane you clicked blank. open() prefers input.activeNodeId and otherwise falls to panes.find(canReplace) — the first qualifying pane in grid order. The mint carried no preference.

admit has always forwarded activeNodeId to the placement policy; it was simply never given one. This threads the picked pane from the client through both mint routes into admitConversationNode. A preference, not an instruction: open() still refuses a pane it may not give up, and an id naming nothing in the tree loses to the default.

Deliberately not solved by a client-side compensating move — that would reinstate the second writer this epic exists to delete.



4. The review round: five more, same family

Two later review passes (Codex, CodeRabbit, and a full-branch pass) turned up five findings. Each was verified against the code before acting; all five were real. Two were regressions this PR introduced, three were surfaces it left one step short of its own reasoning.

The blind placement was only fixed for chat. spawnShell admits exactly the way a conversation does and was never given a pane either, so a terminal opened in whichever pane qualified first — defect 3, unfixed on the surface this PR already treats as symmetric. The History claim had it too, and there the client cannot compensate: openConversation runs after the claim, by which time the thread is showing somewhere, which that call reads as "focus it there". The preference has to travel with the write. Both now thread it, client → route → runtime, like the two mint routes.

The await guard had a victim, and then a blind spot. Closing the MRU window by claiming the pane's assignment token was wrong: the token is what an in-flight mint reads to decide it was superseded, and that mint answers supersession by DELETING what it just created. Since handleSwitchAgent can still decide noop — re-picking the agent a pane already shows — a claim taken to find out destroys a conversation. It now peeks across the await and leaves the claiming to the branches that commit. Conversely focus-existing was committing without claiming, so a newer switch that landed an existing thread was invisible to the guard, and the earlier suspended one would act on top of it.

The end-session rollback stopped reaching as far as its own removal. forgetWorkspaceInCache strips the row from every entry a predicate matches; the snapshot beside it read one built key. Those agreed only while the grid subscribed to that key itself — which §1 removed. A failed DELETE could drop a still-live workspace from the sidebar everywhere and restore it nowhere. (mutate was also the bare top-level import, targeting SWR's default cache while cache came from useSWRConfig(); both now come from the same config.)

A dropped probe read was being treated as an answer. refreshWorkspaceSnapshot reports failure as false, and arming readiness on the first settled read conflated "asked, and these facts are not ours to have" with "never got to ask" — one dropped request as another member's thread lands would mint a duplicate of a conversation sitting on the grid. One more ask, then arm either way.

Testing

Every new test mutation-checked (break the source, watch the named test go red, restore). Notably:

  • Restoring the pre-fix stillMinting rule fails the new "admission bound this pane first" test and only that one — so the guard is corrected, not disabled.
  • Dropping place's activeNodeId forward fails the "fills the pane the caller pointed at" test.
  • One store test initially claimed to guard the stability comparison but survived its mutation (the node lists already differed). Rewritten to feed the server the exact tree the client seeded, which isolates the flag, then re-verified.

Every fix above is mutation-checked in both directions. Worth naming, because the alternative is a clean story that is not true: three test versions were discarded for passing vacuously — one hand-seeded an SWR cache entry that SWR's predicate mutate never reaches (so nothing was removed and nothing needed restoring); its replacement was rescued by the rollback's own revalidate re-fetching the row; and the probe-retry test let the mount's /nodes read absorb the failure, so the retry was never exercised. One fix was also dropped rather than kept unpinned — claiming the token on focus-existing looked right, but went back in only once I had an observable that could fail without it.

Gates: eslint clean on every touched file · tsc --noEmit clean · @pagespace/lib 0 test failures (13 DB-only files) · web 16696 passing, 10 failures across the same 16 DB-dependent files as the pre-change baseline (ECONNREFUSED …:5432, no test Postgres). CI's own Lint & TypeScript Check and Unit Tests are green on every pushed commit.

Local-environment note: this worktree's .next/types/app/** is missing, so bun run typecheck reports TS6053 for generated route types and turbo's green replays are cache hits. Verified with tsc --noEmit directly instead — CI is unaffected.

Reviewer notes

  • Not verified in a browser. Defect 2 was confirmed live via its DELETE; the fixes themselves are covered by tests only. Worth a manual pass: open a second and third agent pane; pick an agent with two empty panes on screen; open a terminal the same way; and reopen a past conversation from a pane's History with more than one pane empty.
  • Behaviour change worth a look (workspace-tree-view.ts): the listing filtered conversations.isActive; target resolution does not. A pane pointing at a history-deleted conversation therefore becomes evictable where it was previously protected. I judged that an improvement — a dead pane should be reusable — but it is a deliberate change, not an oversight.
  • Not in scope, filed for later: decideClosePane's listing guard could go away entirely if "the node IS membership" is trusted; and an ended workspace whose tree destroy failed keeps a workspaceId the listing excludes and the sidebar cannot show, leaving it permanently un-endable.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Fixed panes and terminals disappearing when opened within active sessions.
    • New conversations and terminals now open in the selected pane when available, with reliable fallback placement.
    • Prevented successfully opened conversations from being removed during concurrent session updates.
    • Fixed disabled controls when opening conversations from another session.
    • Improved pane switching, closing, reopening, and replacement behavior across multi-pane workspaces.
    • Improved handling of incomplete workspace information to prevent premature actions.
    • Improved workspace conversation tracking for more reliable pane readiness and session updates.

2witstudios and others added 5 commits August 10, 2026 12:55
…ownership listing

A conversation of yours can live in a workspace ANOTHER drive member owns —
`decideAgentSessionAccess` grants by drive membership, not workspace ownership,
which is what makes workspaces shared working contexts. Opening one of those
from the past-conversations list rendered a working chat surrounded by dead
controls: the agent switcher and New Conversation disabled, and closing a
non-last chat pane a silent no-op.

THE ASYMMETRY. `AgentPanes` reaches a workspace through `checkSessionAccess`
(drive MEMBERSHIP — the nodes route's gate) and then read its conversation
directory from `GET /api/agent-workspaces`, where `ownerId` rides every filter.
A workspace owned by someone else is never in that listing, so
`sessionKnownToConversationsCache` never armed. Since such a workspace also
never appears in your sidebar, the history list is the ONLY route back to that
conversation — the broken path was the only path.

Two access models for one workspace, and the grid was reading the wrong one.

THE FIX is a deletion. `useWorkspaceLayoutSync` already reads the tree from the
membership-gated nodes route at mount and keeps it live on `session:<id>`, the
per-workspace room every member joins. `WorkspaceNodeTarget` already carries
`{agentPageId, lastMessageAt}` — a superset of what the deciders need — and the
redaction rule keeps both for a foreign private thread, masking only the title.
Both sets derive from the same rows. So the grid reads the tree, and the second
source goes, along with `recordMintedConversation`, `recordClosedConversation`
and their cache patches: the tree write IS the update.

MEMBERSHIP FROM `nodes`, FACTS FROM `targets`, and it has to be that way. A
structural `session:<id>` broadcast updates `nodes` and carries `targets`
forward unchanged (it cannot redact per viewer), so a thread another member just
placed is a member IMMEDIATELY with no agent known. Deriving membership from
`targets` would hide it from the switch decision, which would then read "no
thread for this agent" and mint a duplicate. An unresolved member is ABSENT from
the agent-keyed list, never padded with `agentPageId: null` — null is the Global
Assistant, a real agent, and a placeholder would focus the wrong thread.

READINESS IS TWO FACTS: the tree was read from the server AND no member is
missing its facts. `workspaces[id] !== undefined` cannot answer the first —
`runCommand` seeds a root locally at rev 0 — so `hasServerSnapshot` is exposed
on the tree view rather than letting components reach into `sync`.

MRU is preserved where it can decide anything: `findOpenForAgent` picks the most
recently active match, and tree `lastMessageAt` is only as fresh as the last
snapshot (the bump that keeps the listing live rides the OWNER's room, which a
member working in someone else's workspace never joins). With one match recency
picks nothing, so the switch stays instant; with two or more it is the whole
decision, so it re-reads first.

The sidebar still reads the ownership listing, correctly — it lists only
workspaces you own. Nothing changes about which sessions appear there, or about
whose sandbox a conversation uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JLHPJBFNbCCcrL3F83PoBX
Master cut 1.7.1 while this branch was in flight, so `## [Unreleased]` became
`## [1.7.1]` under the merge and this entry — which has not shipped — landed
inside a released section.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JLHPJBFNbCCcrL3F83PoBX
Opening a SECOND chat pane in a workspace — global assistant or page agent —
made the pane appear and then vanish, silently. Only the session's first
conversation survived. Pages were unaffected. Confirmed in production by the
`DELETE /api/agent-workspaces/{id}/conversations/{id}` that fires 200 moments
after the pane renders.

WHAT HAPPENS. A mint's own server write ADMITS what it created, and admitting
places: `admit` -> `place` fills exactly the pane the user picked into, because
"only an unbound pane may be filled ... is what makes the picker path land in
the very pane the user is looking at". The `session:<id>` broadcast then binds
that pane here, before the mint's POST has even resolved.

`stillMinting` asked whether the pane was still UNBOUND. So the SUCCESS case —
the server placed what we asked for, where we asked for it — became
indistinguishable from the abandonment case it was written to catch. Both
callers answer abandonment by destroying what they just made: the chat mint
DELETEs its conversation, the shell mint kills its shell. Both cleanups are
deliberately silent, which is why there was no toast and nothing in the console.

WHY NOW. `eceeb956f` ("one live client plane") wrote that check while the client
was the only thing that could bind a pane, and it was right then. `3c12c9e75`
("membership moves to the tree") made the server bind it too and never revisited
the check. The stale comment above it gives the game away — it justifies itself
by a PARKED state that the same epic later deleted.

THE FIX. The question is still the right one; `target === null` stopped being
the way to answer it. A pane carrying THIS MINT'S OWN target is still this
mint's pane, so both call sites now name what they produced (`conversationId`,
`shell.shellId`). Bound to anything else is still a genuine loss and still
cleans up — pinned by its own test, so the guard is corrected rather than
disabled.

Regression tests cover both directions and were mutation-checked against the
pre-fix rule: it fails the new "admission bound this pane first" test and only
that one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JLHPJBFNbCCcrL3F83PoBX
Review follow-up (codex) on the previous commit, verified before acting: the
symptom is real, the mechanism reported for it was not.

THE SYMPTOM. With two empty panes open, picking an agent filled the WRONG one
and left the pane the user clicked blank. `open()`'s policy prefers
`input.activeNodeId` and otherwise falls to `panes.find(canReplace)` — the first
pane in grid order that qualifies (`workspace-node-commands.ts:627-628`). The
mint carried no preference, so the server placed blind.

NOT what the review said, and it matters because it changes the fix.
`stillMinting` was reported to "search the entire tree" and so read a
different-pane placement as success. It does not: `findNode(nodes, nodeId)` is a
lookup BY ID, and the pane-scoped target match added in the previous commit is
already exactly the "verify the node's id matches" the review asked for. What
actually happens is the other branch — the user's pane is still UNBOUND, so the
guard correctly says "still mine", and the misplacement is entirely upstream in
the blind server placement.

NOT a regression from the previous commit either. Pre-fix the guard read
`node.target === null`, which is true in this same scenario, so it returned true
then too. Identical before and after; this is a pre-existing gap the previous
fix merely made visible by keeping the conversation alive long enough to see
where it landed.

THE FIX is the one the model already implies rather than a client-side
compensating move: the caller says WHERE. `admit` has always forwarded
`activeNodeId` to the placement policy — it was simply never given one — so this
threads the picked pane from the client through both mint routes and
`createConversationInSession` into `admitConversationNode`. A preference, not an
instruction: `open()` still refuses a pane it may not give up, and an id naming
nothing in the tree loses to the default. Compensating client-side instead would
have reinstated the second writer this epic exists to delete.

Pure tests pin both directions (fills the pointed-at pane; ignores a preference
naming a pane it may not take) and were mutation-checked against dropping the
forward in `place`. The client test asserts the mint carries the node id.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JLHPJBFNbCCcrL3F83PoBX
@coderabbitai

coderabbitai Bot commented Aug 10, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The change adds server-backed workspace conversation membership, selected-pane placement during admission, and tree-based pane lifecycle coordination. It updates tests and changelog entries for pane persistence, replacement, switching, and cross-session conversation behavior.

Changes

Workspace conversation lifecycle

Layer / File(s) Summary
Server-backed conversation directory
apps/web/src/stores/agent-workspace/*, apps/web/src/stores/agent-workspace/__tests__/*
Workspace trees now record server hydration state. conversationDirectoryOf derives chat membership and resolved target metadata.
Selected-pane conversation and shell admission
apps/web/src/app/api/..., apps/web/src/lib/agent-workspaces/*, packages/lib/src/agent-workspaces/__tests__/workspace-membership.test.ts
Conversation and shell creation accept activeNodeId and forward valid preferences through admission. Invalid or bound panes use fallback placement.
Tree-driven pane lifecycle
apps/web/src/components/agents/panes/*, CHANGELOG.md
AgentPanes uses tree membership for readiness, closing, minting, switching, replacement, rollback, and admission-race cleanup. Tests cover pane targeting and unresolved state.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AgentPanes
  participant ConversationRoute
  participant Admission
  participant WorkspaceTree
  AgentPanes->>ConversationRoute: Create conversation with activeNodeId
  ConversationRoute->>Admission: Forward selected-pane preference
  Admission->>WorkspaceTree: Bind conversation to eligible pane
  WorkspaceTree-->>AgentPanes: Update conversation membership
  AgentPanes->>WorkspaceTree: Read readiness and placement state
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 55.56% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title identifies the main agent-pane fixes, including shared-workspace behavior and panes that incorrectly deleted themselves.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pu/shared-workspace-panes

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ca8da22da1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/web/src/components/agents/panes/AgentPanes.tsx
Comment thread apps/web/src/components/agents/panes/AgentPanes.tsx Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
packages/lib/src/agent-workspaces/__tests__/workspace-membership.test.ts (1)

129-158: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Consider one more case: activeNodeId naming no node in the tree.

These two tests pin the honored preference and the bound-pane fall-through. The route comment in apps/web/src/app/api/ai/page-agents/[agentId]/conversations/route.ts (Line 217) also claims that an id naming nothing in this tree loses to the default. A stale client produces exactly that shape after its pane closes mid-mint. No test pins it.

♻️ Proposed additional test
+  it('ignores a preference naming no node in this tree', () => {
+    const before = [root(), unbound('p1', 'root-1', 0)];
+
+    const next = applied(
+      before,
+      admit(before, { ...ids, target: { kind: 'chat', id: 'conv-a' }, activeNodeId: 'gone' }),
+    );
+
+    expect(memberNode(next, { kind: 'chat', id: 'conv-a' })?.id).toBe('p1');
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/lib/src/agent-workspaces/__tests__/workspace-membership.test.ts`
around lines 129 - 158, Add a test in workspace-membership.test.ts covering
admit with activeNodeId set to an ID absent from the pane tree, using otherwise
eligible panes. Assert the admission falls back to the default eligible-pane
selection rather than failing or honoring the stale ID, while preserving
existing membership behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/web/src/components/agents/panes/__tests__/AgentPanes.test.tsx`:
- Around line 685-705: Update the comments in the test case around the picker
click and request assertion to describe the current behavior: the selected pane
is propagated as activeNodeId, so the conversation lands in the user-picked
pane. Remove the outdated explanation about missing placement preference and
server fallback to the first unbound pane; leave the test logic and assertion
unchanged.

In `@apps/web/src/components/agents/panes/AgentPanes.tsx`:
- Around line 1120-1150: Protect the async switch callback around the refresh
await by using the existing per-node assignment token: start or capture the
invocation token before awaiting, then discard the invocation if a newer
assignment superseded it before selecting or closing conversations. Apply the
guard to the focus-existing path while preserving the mint delegation behavior,
and include beginPaneAssign in the callback dependencies.
- Around line 473-479: Update the readiness and refresh flow around
conversationDirectoryOf and the useEffect in AgentPanes so deleted or unreadable
targets are handled as terminally absent rather than remaining unresolved
indefinitely. Mark those targets resolved or otherwise exclude them from pending
hydration while preserving valid unresolved targets for refresh, and do not
represent absence with agentPageId: null because that means Global Assistant.

---

Nitpick comments:
In `@packages/lib/src/agent-workspaces/__tests__/workspace-membership.test.ts`:
- Around line 129-158: Add a test in workspace-membership.test.ts covering admit
with activeNodeId set to an ID absent from the pane tree, using otherwise
eligible panes. Assert the admission falls back to the default eligible-pane
selection rather than failing or honoring the stale ID, while preserving
existing membership behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2932cf77-8158-42e7-8c62-126759ece517

📥 Commits

Reviewing files that changed from the base of the PR and between bc8fea2 and ca8da22.

📒 Files selected for processing (13)
  • CHANGELOG.md
  • apps/web/src/app/api/agent-workspaces/[workspaceId]/conversations/route.ts
  • apps/web/src/app/api/ai/page-agents/[agentId]/conversations/route.ts
  • apps/web/src/components/agents/panes/AgentPanes.tsx
  • apps/web/src/components/agents/panes/__tests__/AgentPanes.test.tsx
  • apps/web/src/lib/agent-workspaces/agent-workspaces-runtime.ts
  • apps/web/src/lib/agent-workspaces/claim-conversation-in-workspace.ts
  • apps/web/src/lib/agent-workspaces/create-conversation-in-workspace.ts
  • apps/web/src/stores/agent-workspace/__tests__/useAgentWorkspaceStore.test.ts
  • apps/web/src/stores/agent-workspace/__tests__/workspace-tree-view.test.ts
  • apps/web/src/stores/agent-workspace/useAgentWorkspaceStore.ts
  • apps/web/src/stores/agent-workspace/workspace-tree-view.ts
  • packages/lib/src/agent-workspaces/__tests__/workspace-membership.test.ts

Comment thread apps/web/src/components/agents/panes/__tests__/AgentPanes.test.tsx
Comment thread apps/web/src/components/agents/panes/AgentPanes.tsx Outdated
Comment thread apps/web/src/components/agents/panes/AgentPanes.tsx
…MRU re-read

Four findings from Codex and CodeRabbit on #2394. Each verified against the
code first; all four were real, and two are defects this PR introduced.

1. THE AWAIT WINDOW (P1, both reviewers). Adding the MRU re-read turned
`handleSwitchAgent` from synchronous into suspending, and nothing invalidated an
earlier invocation across it — so a pick the user had already replaced could
resume and act on a decision computed two selections ago, taking the focus off
what they actually chose. It now claims the node with the same
`beginPaneAssign` token `handlePickAgent` next to it already uses, and bails if
a newer pick landed. Regression-tested, and the test earns it: an earlier
version of that test PASSED with the guard removed (the stale handler's cleanup
no-ops because the node it captured is gone by then), so it was retargeted at
the real harm — the focus jumping to the superseded agent's thread.

2 & 3. READINESS COULD NEVER COME BACK (P2 + Major). A missing target entry
means "gone, or not readable by this viewer" exactly as often as "not fetched
yet" — `readWorkspaceNodes` omits both, deliberately. Blocking on
`hasUnresolved` alone therefore left the switcher and New Conversation dead for
the rest of the session on a thread that was merely deleted, and the refresh
dedupe (recorded BEFORE the read returned) did the same after one transient
failure. The probe now records that it was ANSWERED, failures included, and
readiness arms on that: once we have asked and been told, whatever is still
missing is missing for good. Recovery does not lean on this effect retrying —
the socket's reconnect re-reads, and a changed member set starts a fresh probe.

The residual risk is bounded and preferred: a thread nobody here can identify
fails to match and mints one extra conversation, which beats controls that never
return.

4. A stale test comment that contradicted the assertion under it (minor).

The old "disabled while unresolved" test is SPLIT rather than deleted — one
pinning the pending window (the re-read held open), one pinning that readiness
arms once it is answered. Both mutation-checked, as is the guard in 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JLHPJBFNbCCcrL3F83PoBX
@2witstudios

Copy link
Copy Markdown
Owner Author

All five threads addressed in aab9479ab — each verified against the code before acting, and all four distinct findings were real.

1. The await window (P1, flagged by both). Correct, and it was mine: adding the MRU re-read turned handleSwitchAgent from synchronous into suspending without adding the beginPaneAssign guard that handlePickAgent right beside it already uses. Fixed and regression-tested — and the test earns its place: my first version of it passed with the guard removed (the stale handler's cleanup no-ops because the node it captured is already gone), so I retargeted it at the actual harm, the focus jumping to the superseded agent's thread.

2 + 3. Readiness could never come back (P2 + Major). Both correct and they share one cause: a missing target entry means "gone, or not readable by this viewer" exactly as often as "not fetched yet" — readWorkspaceNodes omits both deliberately — so hasUnresolved alone left the controls dead for the session on a merely-deleted thread, and the dedupe (recorded before the read returned) did the same after one transient failure. The probe now records that it was answered, failures included, and readiness arms on that. Recovery doesn't lean on this effect retrying: the socket reconnect re-reads, and a changed member set starts a fresh probe.

On the retry-vs-arm choice — I went with arm rather than retry-on-failure, because an unconditional retry loops on a persistent failure. The residual risk is bounded and I think preferable: a thread nobody here can identify fails to match and mints one extra conversation, versus controls that never return.

4. Stale test comment. Fixed.

The old "disabled while unresolved" test is split rather than dropped — one pins the pending window (re-read held open), one pins that readiness arms once answered. Both mutation-checked, as is the guard in (1).

Gates: typecheck 17/17 · lint 15/15 · knip within baseline · web 16686 passing · lib 0 test failures (remaining failures in both are the DB-dependent suites, which pass in CI).

…ng it

Self-review of the previous commit's P1 fix, mutation-verified in both
directions: it closed the await window by CLAIMING the pane's assignment token
at the top of `handleSwitchAgent`, and that claim is a write with a victim.

WHAT THE CLAIM COSTS. `paneAssignTokens` is what an in-flight mint reads to
decide it was superseded, and `handlePickAgent` answers supersession by DELETING
the conversation it just created — silently. The rule the codebase already
states at `decideClosePane` is therefore "only once a decision ACTUALLY commits
to altering this node", and `handleSwitchAgent` cannot honour it with a claim
taken on entry: it does not yet know whether it will act. `selectPaneAgent` can
still answer `noop`, which is exactly what re-picking the agent a pane already
shows returns.

So: pane bound to Researcher, user switches to Writer (a mint, in flight — the
pane is bound, so it keeps showing Researcher for the whole round trip), then
re-picks Researcher. That second pick decides `noop` and changes nothing, but it
has already invalidated the mint's token, so the mint resolves, reads itself
superseded, and DELETEs Writer's brand-new conversation. The pane appears and
vanishes with no toast and nothing in the console — defect 2 of this very PR,
arriving through a narrower door.

THE FIX. The guard needs to OBSERVE ownership across the await, not take it.
`peekPaneAssign` reads the token without bumping it; the guard compares it
before and after and bails if anyone claimed the pane meanwhile — a newer switch
that committed, a History pick, a mint. A newer switch that decided `noop` is
correctly invisible to it, having changed the pane no more than this one has.
The claim stays where it belongs, in `handlePickAgent`'s mint branch.

MUTATION-CHECKED BOTH WAYS, which is what makes this a correction rather than a
loosening:
- Restoring the claim fails the new "re-picking the agent a pane already shows"
  test, and ONLY that one.
- Removing the peek guard fails the previous commit's "a superseding pick
  cancels the one waiting on the MRU re-read" test, and only that one — so the
  P1 window is still closed.

Also moves `markAnswered` below `isSessionListingBody`'s doc block, which the
previous commit split from the function it documents.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XuyYoTEPucZHnBiZZNYCa
@2witstudios

Copy link
Copy Markdown
Owner Author

Self-review finding: the P1 fix in aab9479ab had a victim, fixed in 3ccc65b07

Re-reviewing my own fix for the await window, I found it closed that window by claiming the pane's assignment token at the top of handleSwitchAgent — and that claim is a write with a victim.

paneAssignTokens is what an in-flight mint reads to decide it was superseded, and handlePickAgent answers supersession by DELETING the conversation it just created, silently. The codebase already states the rule at decideClosePane: "only once a decision ACTUALLY commits to altering this node". handleSwitchAgent can't honour that with a claim taken on entry, because it doesn't yet know whether it will act — selectPaneAgent can still answer noop, which is exactly what re-picking the agent a pane already shows returns.

Reachable sequence: pane bound to Researcher → switch to Writer (mint in flight; the pane is bound, so it keeps showing Researcher for the whole round trip) → re-pick Researcher. The second pick decides noop and changes nothing, but it has already invalidated the mint's token — so the mint resolves, reads itself superseded, and DELETEs Writer's brand-new conversation. Pane appears, then vanishes, no toast, nothing in the console. That is defect 2 of this PR, arriving through a narrower door.

The fix: the guard needs to observe ownership across the await, not take it. peekPaneAssign reads the token without bumping it; the guard compares before/after and bails if anyone actually claimed the pane meanwhile (a newer switch that committed, a History pick, a mint). A newer switch that decided noop is correctly invisible to it — it changed the pane no more than this one did. The claim stays where it belongs, in handlePickAgent's mint branch.

Mutation-checked both directions, which is what makes this a correction rather than a loosening of the P1 guard:

  • Restoring the claim fails the new re-picking the agent a pane already shows does not cancel a mint in flight for it test — and only that one.
  • Removing the peek guard fails the previous commit's a superseding pick on the same pane cancels the one waiting on the MRU re-read test — and only that one, so the P1 window is still closed.

Also moved markAnswered below isSessionListingBody's doc block; aab9479ab had inserted it between that comment and the function it documents.

Gates: typecheck 17/17 · lint 15/15 · AgentPanes + agent-workspace store suites 147/147.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/src/components/agents/panes/AgentPanes.tsx (1)

1215-1226: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Handle a failed MRU refresh before selecting a conversation.

refreshWorkspaceSnapshot(sessionId) can reject. The readiness probe handles this at Lines 537-543. At Line 1216, a rejection exits handleSwitchAgent before it uses the stale candidate order or checks the pane token. Lines 1345-1347 discard the promise, so a transient failure prevents the switch and produces an unhandled rejection.

Catch the refresh failure, retain candidates, and then run the existing token check. Add a test for two matching conversations with a rejected refresh.

Proposed fix
       if (candidates.filter((candidate) => candidate.agentPageId === nextAgentPageId).length > 1) {
-        await useAgentWorkspaceStore.getState().refreshWorkspaceSnapshot(sessionId);
+        try {
+          await useAgentWorkspaceStore.getState().refreshWorkspaceSnapshot(sessionId);
+        } catch {
+          // Keep the current directory order when the refresh fails.
+        }
         const refreshed = conversationDirectoryOf(useAgentWorkspaceStore.getState().workspaces[sessionId]);

Also applies to: 1345-1347

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/agents/panes/AgentPanes.tsx` around lines 1215 -
1226, Update handleSwitchAgent around refreshWorkspaceSnapshot so a rejected
refresh is caught, candidates remains unchanged, and the existing peekPaneAssign
token check still runs. Apply the same rejection handling to the
promise-discarding refresh path around the related refresh call, preventing
unhandled rejections. Add a test covering two matching conversations with a
rejected refresh and verifying the switch proceeds using stale candidate
ordering.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@apps/web/src/components/agents/panes/AgentPanes.tsx`:
- Around line 1215-1226: Update handleSwitchAgent around
refreshWorkspaceSnapshot so a rejected refresh is caught, candidates remains
unchanged, and the existing peekPaneAssign token check still runs. Apply the
same rejection handling to the promise-discarding refresh path around the
related refresh call, preventing unhandled rejections. Add a test covering two
matching conversations with a rejected refresh and verifying the switch proceeds
using stale candidate ordering.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f32179a2-0f9c-4349-8ad3-1e0c61f57b54

📥 Commits

Reviewing files that changed from the base of the PR and between aab9479 and 3ccc65b.

📒 Files selected for processing (2)
  • apps/web/src/components/agents/panes/AgentPanes.tsx
  • apps/web/src/components/agents/panes/__tests__/AgentPanes.test.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/src/components/agents/panes/tests/AgentPanes.test.tsx

2witstudios and others added 5 commits August 10, 2026 16:44
Threading `activeNodeId` through the page-agents route wedged its declaration —
and its own comment — between the session-binding comment block and the
`workspaceIdFromBody`/`sessionId` parsing that block describes. The reader met
"The contract PR drops the legacy key." followed immediately by "WHERE the
caller wants it", then a declaration belonging to neither.

Moved to where it is used, inside the `sessionId !== null` branch, which also
puts it after the access check its "cannot address anything outside the
workspace" claim depends on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XuyYoTEPucZHnBiZZNYCa
Review follow-up (CodeRabbit) on #2394, verified against the code first. The
finding's premise does not hold, but the invariant it is worried about was
unguarded, so this pins it rather than dismissing it.

THE FINDING: `refreshWorkspaceSnapshot(sessionId)` can reject, so a transient
failure would exit `handleSwitchAgent` before it uses the stale candidate order
or checks the pane token — refusing the user's switch and producing an unhandled
rejection out of the `void`-ed call.

WHY IT DOES NOT HAPPEN TODAY: that function's entire body is inside one
try/catch whose handler returns `false`, and it is typed `Promise<boolean>`. It
reports failure as a value; it cannot reject. So the handler resumes normally,
keeps the candidates it had, runs the token check, and decides — which is what
the comment above it already promised ("a failed re-read is not a reason to
refuse the switch") and what nothing was checking.

Adding a try/catch around a call that cannot throw would be dead code. A test is
the honest form of the same guarantee, and it makes the contract load-bearing:
if `refreshWorkspaceSnapshot` is ever changed to throw, this goes red.

Mutation-checked by making that catch rethrow — i.e. by making the finding's
premise true. The new test fails and vitest reports an Unhandled Rejection,
reproducing both harms exactly as described. Restored, it passes.

The assertions are the two halves that matter: reaching the outgoing thread's
DELETE proves the handler resumed past the failed re-read, and never POSTing
proves it found the agent's thread in the stale list rather than concluding
there was none and minting a duplicate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XuyYoTEPucZHnBiZZNYCa
…ms it

Two findings from a review pass over the branch, both verified against the code
and both the same shape as defects this PR already fixes — one surface short.

1. THE SHELL MINT STILL PLACED BLIND. Defect 3 was fixed for chat by threading
the picked pane through both conversation routes, but `spawnShell` admits
exactly the way a conversation does and was never given one: `handlePickShell`
POSTed `{}`, the route parsed only `name`, and `admit` fell to "the first pane
that qualifies". With two empty panes the terminal opened in the one the user
did not click, and their own pane stayed empty — the identical symptom, on the
surface this PR already recognised as symmetric when it fixed the mint-cleanup
half for terminals. Threaded client → route → runtime.

2. THE `focus-existing` BRANCH NEVER CLAIMED THE PANE. The guard added in
`3ccc65b07` says a newer switch that COMMITTED is visible to it, and that was
aspirational: only the mint branch took the token, so a newer switch that landed
an EXISTING thread was invisible. Two switches that both take the MRU await, the
later one commits — and the earlier then resumes against an unchanged token and
acts on top of it, DELETEing the outgoing thread a second time and dragging the
console selection back to the agent the user had already replaced. It now claims
on the same rule `decideClosePane` states: once a decision actually commits to
altering this node. The `noop` path still takes nothing, so the correction in
`3ccc65b07` stands.

Mutation-checked: dropping the `focus-existing` claim fails the new "superseding
pick that lands an EXISTING thread" test and only that one; dropping the route's
forward fails the new shells-route test and only that one.

Coverage boundary, stated plainly: the route's forward and `admit`'s handling of
`activeNodeId` are each pinned by a test (the latter by the existing lib test);
the one-line `spawnShell` → `admit` glue between them is covered by typecheck
alone, there being no unit harness for that runtime.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XuyYoTEPucZHnBiZZNYCa
The third and last placement path a human drives, and the one the previous
commits left behind. Review finding, verified against the code first.

`3ccc65b07`'s predecessor added `activeNodeId` to `AdmitConversationInput` and
documented it as "the pane a HUMAN pointed at" — but the CLAIM path never
populated it, so the doc described behaviour no caller could produce. Reopening
a never-bound thread from a pane's History ADMITS it, and admitting places, so
it landed in whichever pane qualified first rather than the one whose History
the user opened.

The client cannot correct it afterwards, which is what makes this a real defect
rather than a cosmetic one: `openConversation(..., { activeNodeId })` runs after
the claim, and by then the thread is already showing somewhere — which that call
reads as "focus it there", not "move it here". The preference has to travel with
the write or it does not exist.

Threaded client → route → `claimConversationInSession` →
`claimConversationInSessionWith` → `admitConversation`, matching the two mint
routes and the shell spawn. The claim route now reads a body it never had; an
absent or unusable one is still a valid claim, since a preference must not
become the first way to fail one.

Mutation-checked: dropping the pure module's forward fails the new
`claimConversationInSessionWith` test and only that one; dropping the route's
fails the new route test and only that one.

Local `tsc` note for reviewers: this worktree's `.next/types/app/**` is missing,
so `bun run typecheck` reports TS6053 for generated route types and turbo's
green replays were cache hits. Verified instead by running `tsc --noEmit`
directly and confirming no non-TS6053 diagnostics — and CI's own Lint &
TypeScript Check is green on every pushed commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XuyYoTEPucZHnBiZZNYCa
…al did

Review finding, verified against the code. A regression this PR introduced by
deleting the grid's own subscription to the session listing.

THE ASYMMETRY. `forgetWorkspaceInCache` strips this workspace's row from EVERY
listing entry — it takes an `isWorkspaceListingKey` PREDICATE. The rollback
snapshot beside it read ONE built key, `agentWorkspacesKey(driveId)`. While the
grid subscribed to that key itself the two always agreed; once the subscription
went, the read depends on some other surface having populated that exact key.
This grid is handed the WORKSPACE's drive, and the listing may well have been
fetched under another scope — the reused-global case `agentWorkspacesKey`'s own
doc comment warns about — or under none.

The snapshot then comes back null for a row that was nonetheless removed
everywhere, and a failed DELETE leaves a workspace that is still very much alive
missing from the sidebar, with only `mutate(isWorkspaceListingKey)` to bring it
back — the revalidate `restoreWorkspaceInCache` exists precisely because it
cannot be trusted to, since the network that failed the DELETE fails it too.

The read is now the same scan the writers do.

AND THE CACHE THEY EACH TALK TO. `mutate` was the bare top-level import, which
targets SWR's DEFAULT cache whatever provider this tree is mounted under, while
`cache` came from `useSWRConfig()`. Snapshot read from one, removal written to
another. `forgetWorkspaceInCache`'s own doc asks callers for the mutate bound to
their cache for exactly this reason; both now come from the same config.
Identical in production, where there is one cache — but it is what makes the
rollback coherent, and it is what lets a test observe it at all.

Mutation-checked: restoring the single-key read fails the new test with the row
dropped everywhere and restored nowhere. Two earlier versions of that test were
DISCARDED for passing vacuously, which is why the fixture looks the way it does:
SWR's predicate `mutate` only reaches keys SWR itself tracks, so a hand-seeded
cache entry is never removed; and the rollback's own revalidate re-fetched the
row back, hiding the difference. The listing is therefore subscribed by a real
`useSWR` standing in for the sidebar, and goes offline after its first read —
the same dead network that failed the DELETE.

Gates: `tsc --noEmit` clean, `eslint` clean on the touched file (the five
`useCallback`s that close over `mutate` now name it), full web unit suite 16696
passing with the same 10 failures across the same 16 DB-dependent files as the
pre-change baseline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XuyYoTEPucZHnBiZZNYCa

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
apps/web/src/components/agents/panes/__tests__/AgentPanes.test.tsx (1)

1141-1148: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the superseded first half of this comment.

Two comment blocks now state the same claim. The first block also says "the focus lands on its pane", which this test does not assert; it asserts the outgoing DELETE and the absence of a POST. Keep the second block only.

📝 Proposed comment fix
-    // It still decides, on the ordering it already had: conv-3 is the most
-    // recently active of Researcher's two, so the focus lands on its pane.
     // IT STILL DECIDED, on the ordering it already had. Both halves matter:
     // reaching the outgoing thread's DELETE means the handler resumed past the
     // failed re-read and ran its token check rather than rejecting out of a
     // `void`-ed call; and never POSTing means it found Researcher's thread in
     // the stale candidate list instead of concluding there was none and minting
     // a duplicate.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/agents/panes/__tests__/AgentPanes.test.tsx` around
lines 1141 - 1148, In the test comment near the ordering assertion, remove the
superseded first comment block describing focus landing on conv-3’s pane. Keep
only the following block that explains the outgoing DELETE and absence of a
POST, matching the behaviors actually asserted by the test.
apps/web/src/app/api/agent-workspaces/[workspaceId]/conversations/[conversationId]/claim/route.ts (1)

89-91: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The activeNodeId narrowing rule is written twice. Both routes repeat the same "object body, string value, non-empty, otherwise no preference" logic. Extract it once so a future third route cannot drift from the rule.

  • apps/web/src/app/api/agent-workspaces/[workspaceId]/conversations/[conversationId]/claim/route.ts#L89-L91: replace the two-step extraction with a call to a shared readActiveNodeId(body) helper.
  • apps/web/src/app/api/agent-workspaces/[workspaceId]/shells/route.ts#L152-L154: replace the identical extraction with the same helper call.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/web/src/app/api/agent-workspaces/`[workspaceId]/conversations/[conversationId]/claim/route.ts
around lines 89 - 91, Extract the duplicated activeNodeId narrowing logic into a
shared readActiveNodeId(body) helper, preserving the object check, non-empty
string validation, and null fallback. In
apps/web/src/app/api/agent-workspaces/[workspaceId]/conversations/[conversationId]/claim/route.ts:89-91
and apps/web/src/app/api/agent-workspaces/[workspaceId]/shells/route.ts:152-154,
replace the two-step extraction with calls to that helper.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In
`@apps/web/src/app/api/agent-workspaces/`[workspaceId]/conversations/[conversationId]/claim/route.ts:
- Around line 89-91: Extract the duplicated activeNodeId narrowing logic into a
shared readActiveNodeId(body) helper, preserving the object check, non-empty
string validation, and null fallback. In
apps/web/src/app/api/agent-workspaces/[workspaceId]/conversations/[conversationId]/claim/route.ts:89-91
and apps/web/src/app/api/agent-workspaces/[workspaceId]/shells/route.ts:152-154,
replace the two-step extraction with calls to that helper.

In `@apps/web/src/components/agents/panes/__tests__/AgentPanes.test.tsx`:
- Around line 1141-1148: In the test comment near the ordering assertion, remove
the superseded first comment block describing focus landing on conv-3’s pane.
Keep only the following block that explains the outgoing DELETE and absence of a
POST, matching the behaviors actually asserted by the test.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c1ed0cea-9a95-407b-91df-7619959270af

📥 Commits

Reviewing files that changed from the base of the PR and between 0eb4632 and 7218ab8.

📒 Files selected for processing (10)
  • apps/web/src/app/api/agent-workspaces/[workspaceId]/conversations/[conversationId]/claim/__tests__/route.test.ts
  • apps/web/src/app/api/agent-workspaces/[workspaceId]/conversations/[conversationId]/claim/route.ts
  • apps/web/src/app/api/agent-workspaces/[workspaceId]/shells/__tests__/route.test.ts
  • apps/web/src/app/api/agent-workspaces/[workspaceId]/shells/route.ts
  • apps/web/src/components/agents/panes/AgentPanes.tsx
  • apps/web/src/components/agents/panes/__tests__/AgentPanes.test.tsx
  • apps/web/src/lib/agent-workspaces/__tests__/claim-conversation-in-workspace.test.ts
  • apps/web/src/lib/agent-workspaces/agent-workspaces-runtime.ts
  • apps/web/src/lib/agent-workspaces/claim-conversation-in-workspace.ts
  • apps/web/src/lib/agent-workspaces/workspace-shells-runtime.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/src/components/agents/panes/AgentPanes.tsx

The last of the review findings, and the one the previous commit's own doc
comment argued the other side of — correctly, for the case it was reasoning
about, and not for this one.

`refreshWorkspaceSnapshot` reports failure as `false` rather than throwing, and
that boolean is the only thing here that can tell "we asked, and these facts are
not ours to have" from "we never got to ask". Arming readiness on the first
settled read conflated them. The permanently-unreadable case is why arming has
to happen at all — a deleted thread never gets an entry however many times you
ask, and blocking on it left the switcher and New Conversation dead for the rest
of the session. But a DROPPED request answers nothing, and treating it as an
answer has a specific cost: one failed read at the moment another member's
thread lands arms readiness against a list missing that thread, and the switch
then reads "no thread for this agent" and mints a DUPLICATE of a conversation
sitting on the grid in front of the user.

One more ask, then arm either way. The point is to stop conflating the two
states, not to keep asking until the network agrees — the retry is bounded at
one, and everything the previous commit said about recovery still holds (the
socket's reconnect re-reads, a changed member set starts a fresh probe).

Mutation-checked: dropping the retry fails the new test with "expected spy to
not be called at all, but actually been called 1 times" — the duplicate mint,
exactly as described. An earlier version of the test passed under that mutation
and was rewritten: the MOUNT's own `/nodes` read was absorbing the failure, so
the probe's read always succeeded and the retry was never exercised. The fixture
now answers the mount read without the facts (which is what starts the probe)
and drops the probe's read specifically. Its `nodeShowingChat('conv-2')`
assertion went too — conv-2 is a node from the start, so it could never fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XuyYoTEPucZHnBiZZNYCa
@2witstudios

Copy link
Copy Markdown
Owner Author

Review pass over the branch: five findings, all verified, all closed

I ran an independent review over the full branch diff and worked each finding against the code before acting. All five were real. Two were regressions this PR introduced; three were surfaces it left one step short of its own reasoning.

# Finding Commit
1 The shell mint still placed blind — defect 3 unfixed for terminals 5a9c1ecb8
2 focus-existing never claimed the pane, so the new guard couldn't see it 5a9c1ecb8
3 A History reopen never named its pane, and the doc described unreachable behaviour a02a8940b
4 The end-session rollback read one cache key while its removal stripped every one 7218ab800
5 A dropped probe read armed readiness against a partial list fd67bff62

1 — terminals placed blind. The PR threaded activeNodeId through both conversation routes but not the shell spawn, which admits identically. With two empty panes the terminal opened in the one the user didn't click. The PR already recognised this chat/terminal symmetry when it fixed the mint-cleanup half for shells; this closes the other half.

2 — focus-existing never claimed. My guard in 3ccc65b07 claimed to catch "a newer switch that committed" — aspirational, since only the mint branch took the token. Two switches both taking the MRU await, the later commits, and the earlier resumes against an unchanged token and acts on top: a second DELETE for the outgoing thread, and the console selection dragged back to the replaced agent.

3 — the History claim. A claim admits, and admitting places. The client can't correct it afterwards: openConversation runs after the claim, by which time the thread is showing somewhere — which that call reads as "focus it there", not "move it here". The preference has to travel with the write.

4 — the rollback's reach. forgetWorkspaceInCache strips the row from every entry matching a predicate; the snapshot beside it read one built key. That agreed only while the grid subscribed to that key itself — which this PR removed. A failed DELETE could drop a live workspace from the sidebar everywhere and restore it nowhere. Also fixed the cache the two halves talk to: mutate was the bare top-level import (SWR's default cache) while cache came from useSWRConfig().

5 — the dropped read. refreshWorkspaceSnapshot reports failure as false, and arming on the first settled read conflated "asked, and these facts aren't ours" with "never got to ask". One dropped request as another member's thread lands would mint a duplicate of a conversation sitting on the grid. Now one more ask, then arm either way.

On the tests

Every fix is mutation-checked in both directions — break the source, watch the named test go red, restore. Three test versions were discarded for passing vacuously, and I'd rather record that than present a clean story:

  • The rollback test hand-seeded an SWR cache entry. SWR's predicate mutate only reaches keys it tracks, so nothing was ever removed and nothing needed restoring. Rewritten with a real useSWR subscriber standing in for the sidebar.
  • Its second version was rescued by the rollback's own revalidate re-fetching the row. The listing now goes offline after its first read — the same dead network that failed the DELETE, which is the whole reason restoreWorkspaceInCache exists.
  • The probe-retry test let the mount's /nodes read absorb the failure, so the retry was never exercised.

I also dropped one fix I'd written for finding 2's neighbourhood: claiming the token on focus-existing looked right, but I could not pin the behaviour I claimed for it, and an unpinned behaviour change is what I'd object to as a reviewer. It went back in only once I had the right observable (the duplicate DELETE).

Gates

tsc --noEmit clean · eslint clean on every touched file · full web unit suite 16696 passing, with the same 10 failures across the same 16 DB-dependent files as the pre-change baseline (ECONNREFUSED …:5432, no test Postgres).

One environment note for anyone reproducing locally: this worktree's .next/types/app/** is missing, so bun run typecheck reports TS6053 for generated route types and turbo's green replays are cache hits. I verified with tsc --noEmit directly instead. CI's own Lint & TypeScript Check is green on every pushed commit.

…tory too

The blind-placement entry was written when only the agent picker carried a pane
preference. Terminals and History reopens had the identical defect and now carry
one as well, so this is its own note rather than a clause inside the vanishing-
pane entry it never really belonged to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XuyYoTEPucZHnBiZZNYCa
@2witstudios
2witstudios merged commit 745a8d2 into master Aug 10, 2026
9 checks passed
@2witstudios
2witstudios deleted the pu/shared-workspace-panes branch August 12, 2026 20:53
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