Skip to content

feat(agents): pane-level Chat/History/Settings tabs (+ history-deleted-send fix) - #2299

Merged
2witstudios merged 26 commits into
masterfrom
pu/agents-pane-tabs
Aug 1, 2026
Merged

feat(agents): pane-level Chat/History/Settings tabs (+ history-deleted-send fix)#2299
2witstudios merged 26 commits into
masterfrom
pu/agents-pane-tabs

Conversation

@2witstudios

@2witstudios 2witstudios commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Summary

Continuation of #2296 (merged) on a fresh branch: the one review finding that
hadn't been addressed before the merge, plus a follow-up feature requested
during that PR's review — Chat/History/Settings as real tabs inside each
console pane, sharing state with the page's own AgentPageView tabs rather
than duplicating them.

What's in this PR

  • Reject sends to history-deleted conversations, closed atomically. A
    conversation still open in another pane or browser tab when it's deleted
    from History could previously keep accepting messages — the send appeared
    to succeed but the row stayed excluded from every session listing. Fixed
    on both send paths, then hardened against a TOCTOU race: the active-
    conversation check now runs as a SELECT ... FOR UPDATE immediately
    adjacent to the write, inside a short transaction (never wrapping the
    credit-gate/mention/command work around it).

  • useAgentConfig: a new SWR-backed hook replacing AgentPageView's
    bespoke useEffect+useState fetch for the agent Settings config. SWR's
    URL-keyed cache makes this a genuine single source of truth — every
    consumer of the same pageId shares one cache entry, so a save from any
    one of them is instantly visible everywhere else.

  • Chat | History | Settings as real tabs inside every console pane
    (ChatPane in AgentPanes.tsx, replacing ChatPaneIdentity). Reuses the
    exact same useConversations/useAgentConfig hooks and
    PageAgentHistoryTab/PageAgentSettingsTab components the page's own
    tabs use — a pane and the page view showing the same agent are two views
    onto one cache entry, never two copies that can drift. Picking a History
    entry reopens it first only when bound to THIS session; focuses an
    existing pane instead of duplicating one if already open elsewhere; stays
    on History until the pick actually lands. Deleting from History resets
    every pane in the grid still showing that conversation. Save is disabled
    until the agent config has actually loaded.

Test plan

  • bunx tsc --noEmit clean
  • eslint clean on all changed files
  • Full relevant suite passing, including new hook-level coverage
    (useAgentConfig.test.ts, real SWR via a scoped SWRConfig), race
    regression tests (a concurrent History-delete committing between the
    active-conversation check and the message write), and pane-tab
    regression tests (Settings-disabled-while-loading, focus-not-
    duplicate, stay-on-History-on-failed-reopen, rebind-on-delete)
  • Manual click-through in a live browser (not completed in this session)

🤖 Generated with Claude Code

https://claude.ai/code/session_01PofpNe4dkeFdFrN3TjhVFd

Summary by CodeRabbit

  • New Features

    • Added integrated Chat, History, and Settings tabs to agent panes.
    • Added shared, cached agent configuration loading.
    • Reopen responses now indicate whether a conversation was already open.
    • Improved conversation reopening, duplicate-pane focusing, and deletion handling.
  • Bug Fixes

    • Prevented messages from being saved after conversations are deleted or deactivated.
    • Deleted or inactive conversations now return a clear not-found response.
    • Improved reliability during concurrent conversation updates and pane interactions.
    • Conversation deletion now reports success or failure more clearly.
  • Tests

    • Expanded coverage for pane tabs, history, deletion, configuration, and concurrent activity.

…nt config across panes

Two changes, on the new pu/agents-pane-tabs branch (created off master after
PR #2296 merged), continuing that PR's unaddressed review feedback plus new
work toward per-pane Chat/History/Settings tabs:

1. Reject sends to history-deleted conversations (both page-agent and global
   send paths). When History-delete deactivates a conversation's canonical
   row (not just its messages — a change from #2296's own review cycle)
   while it's still open in another pane or browser tab, a send to that
   conversation previously succeeded silently: the new message persisted,
   but the row stayed excluded from every open/closed session listing,
   leaving the transcript unreachable once the stale pane refreshed (review
   finding — chatgpt-codex-connector on PR #2296).

   - Page-agent path (apps/web/src/app/api/ai/chat/route.ts): the existing
     conversation lookup now checks `isActive === false` and 404s before
     the ownership/page checks.
   - Global-assistant path: a DIFFERENT mechanism produced the same bug —
     resolveOrCreateConversation's initial SELECT filters isActive: true,
     so a history-deleted row reads as "no row" and falls into the
     insert-then-conflict path; the INSERT collides on the row's own id
     and is swallowed by onConflictDoNothing, and the fallback "who won
     the race" SELECT had no isActive filter at all — silently handing
     back the STALE INACTIVE row mislabeled isNew: true. Added a new
     ConversationHistoryDeletedError, thrown from that fallback when the
     winner is inactive, caught by the route the same way
     ConversationOwnershipError already is (404).

2. useAgentConfig — a new SWR-backed hook replacing AgentPageView's bespoke
   useEffect+useState fetch for the Settings tab's agent config. SWR's
   cache is keyed by URL, so this is a genuine step toward one shared
   source of truth: every consumer of the same pageId (the page's own
   Settings tab, and — once the pane-tabs work lands — every pane showing
   that agent's Settings) reads the identical cache entry, and a save from
   any one of them (PageAgentSettingsTab already PATCHes itself and hands
   back the fresh value) writes through that same cache with no revalidate,
   instantly visible everywhere else. Two independent per-instance copies
   that could silently drift is exactly the gap this closes.

useAgentConfig's own fetch/cache-sharing behavior gets a dedicated hook-level
test (useAgentConfig.test.ts, real SWR via a scoped SWRConfig provider);
AgentPageView.test.tsx now mocks the hook directly (matching this file's
existing per-hook mocking pattern) rather than asserting on the raw fetch
call, since that's no longer this component's own responsibility.

386 tests passing across the affected suites.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PofpNe4dkeFdFrN3TjhVFd
@coderabbitai

coderabbitai Bot commented Aug 1, 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 PR adds transactional conversation activity checks, deleted-history 404 handling, shared agent configuration, session-aware conversation summaries, and tabbed agent panes with history and settings workflows.

Changes

Conversation activity validation

Layer / File(s) Summary
Deleted conversation resolution
apps/web/src/app/api/ai/global/[id]/messages/resolve-or-create-conversation.ts, apps/web/src/app/api/ai/global/[id]/messages/__tests__/*
Locks conversation lookups and raises ConversationHistoryDeletedError for inactive conflict winners.
Transactional message guards
apps/web/src/app/api/ai/chat/route.ts, apps/web/src/app/api/ai/global/[id]/messages/route.ts, apps/web/src/lib/ai/core/message-utils.ts
Rechecks conversation activity inside transactions and saves messages through the transaction client.
Stream and deletion validation
apps/web/src/app/api/ai/chat/__tests__/*, apps/web/src/app/api/ai/global/[id]/messages/__tests__/*, apps/web/src/lib/repositories/*
Verifies inactive and concurrently deleted conversations, missing-row handling, placeholder suppression, and conversation-first soft deletion.

Shared agent configuration

Layer / File(s) Summary
SWR configuration hook
apps/web/src/lib/ai/shared/hooks/useAgentConfig.ts, apps/web/src/lib/ai/shared/hooks/__tests__/useAgentConfig.test.ts
Adds authenticated configuration fetching, nullable page handling, shared cache updates, and cache isolation tests.
Agent page wiring
apps/web/src/components/agents/AgentPageView.tsx, apps/web/src/components/agents/__tests__/AgentPageView.test.tsx
Replaces local configuration fetching with useAgentConfig(page.id) and verifies settings wiring.

Tabbed agent pane workflow

Layer / File(s) Summary
Pane conversation coordination
apps/web/src/components/agents/panes/AgentPanes.tsx
Adds same-session reopening, duplicate-pane focusing, stale-result protection, mint restoration, and deletion resets across panes.
Tabbed chat pane
apps/web/src/components/agents/panes/AgentPanes.tsx
Integrates Chat, History, and Settings tabs with guarded switching, agent settings, and history actions.
Pane workflow validation
apps/web/src/components/agents/panes/__tests__/AgentPanes.test.tsx
Tests tab visibility, history selection, duplicate prevention, reopen failures, deletion races, pane closure, and settings loading.
Conversation list and deletion contract
apps/web/src/lib/ai/shared/hooks/useConversations.ts, apps/web/src/lib/ai/shared/hooks/__tests__/useConversations.test.ts, apps/web/src/lib/repositories/global-conversation-repository.ts
Uses paginated global conversation summaries, preserves session data, and reports deletion success or failure.

Reopen response state

Layer / File(s) Summary
Reopen status response
apps/web/src/app/api/agent-sessions/[sessionId]/conversations/[conversationId]/reopen/route.ts, apps/web/src/app/api/agent-sessions/[sessionId]/conversations/[conversationId]/reopen/__tests__/route.test.ts
Returns alreadyOpen to distinguish a reopen from an idempotent request.

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

Possibly related issues

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 58.33% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the pane-level tabs and the fix for sends to deleted conversations, which are central changes in the pull request.
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.
✨ 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/agents-pane-tabs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@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: 1

🤖 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/app/api/ai/global/`[id]/messages/resolve-or-create-conversation.ts:
- Line 131: Make the active-conversation validation atomic with the first
message write and History deletion: in
apps/web/src/app/api/ai/global/[id]/messages/resolve-or-create-conversation.ts:131
serialize the resolve/create flow with persistence, and in
apps/web/src/app/api/ai/chat/route.ts:550-564 use a row lock, transaction, or
conditional write immediately before saveMessageToDatabase that returns 404 when
the conversation is inactive. Add the race test in
apps/web/src/app/api/ai/chat/__tests__/stream-socket-events.test.ts:542-561,
covering History deletion after validation but before message persistence.
🪄 Autofix (Beta)

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: 7704a17d-d344-4b43-a0cd-567b1a0808f2

📥 Commits

Reviewing files that changed from the base of the PR and between 405eabc and 0b3852f.

📒 Files selected for processing (12)
  • apps/web/src/app/api/ai/chat/__tests__/stream-socket-events.test.ts
  • apps/web/src/app/api/ai/chat/route.ts
  • apps/web/src/app/api/ai/global/[id]/messages/__tests__/conversation-id-resolution.test.ts
  • apps/web/src/app/api/ai/global/[id]/messages/__tests__/resolve-or-create-conversation.test.ts
  • apps/web/src/app/api/ai/global/[id]/messages/resolve-or-create-conversation.ts
  • apps/web/src/app/api/ai/global/[id]/messages/route.ts
  • apps/web/src/app/api/ai/global/active/__tests__/route.test.ts
  • apps/web/src/components/agents/AgentPageView.tsx
  • apps/web/src/components/agents/__tests__/AgentPageView.test.tsx
  • apps/web/src/lib/ai/shared/hooks/__tests__/useAgentConfig.test.ts
  • apps/web/src/lib/ai/shared/hooks/useAgentConfig.ts
  • apps/web/src/lib/repositories/global-conversation-repository.ts

@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: 0b3852ffaa

ℹ️ 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".

// deactivates the CANONICAL row (not just its messages) while a
// conversation can still be open in another pane or browser tab
// (review finding — chatgpt-codex-connector on PR #2296).
if (existingConversation.isActive === false) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Serialize the active check with the page-message write

When History deletion commits after getConversation() but before saveMessageToDatabase() later in this route, this snapshot still reports active and the request persists the user message and starts generation beneath the now-inactive conversation. The transcript is then excluded from session listings—the exact data-loss scenario this guard aims to prevent—so the active check and message persistence need to serialize with deletion, such as through a row lock/transaction or an equivalent conditional write.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 10d80c7. The first (user) message's persist is now wrapped in db.transaction(async (tx) => { SELECT ... FOR UPDATE; if (!isActive) throw ConversationHistoryDeletedError; saveMessageToDatabase({..., dbClient: tx}); })saveMessageToDatabase gained an optional dbClient override so the insert runs on the SAME locked connection as the check, not a second unlocked one. Postgres's own row locking makes this correct without touching the DELETE side: FOR UPDATE blocks a concurrent UPDATE ... SET isActive = false on the same row (and vice versa), so whichever transaction reaches the row first wins and the other correctly observes the outcome.

Gated on existingConversation (not unconditional): a brand-new conversation's eager conversationRepository.createConversation call above is explicitly best-effort/non-fatal elsewhere in this same route (errors swallowed, "eventually consistent" by design) — an unconditional lock-check here would turn that tolerated absence into a hard rejection.

New regression test: conversation looks active at the early ownership check, mockConversationActiveAtLockTime.current = false simulates a delete committing in the gap before the write — asserts 404 and the lifecycle never starts.

.limit(1);

if (!winner) throw new Error(`Failed to resolve conversation ${conversationId}`);
if (!winner.isActive) throw new ConversationHistoryDeletedError();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Serialize global resolution with message persistence

When a global History deletion commits after the active-row SELECT in resolveOrCreateConversation() but before saveGlobalAssistantMessageToDatabase() in the route, execution never reaches this inactive-winner check because the resolver already returned the formerly active row. The send consequently appends an unreachable message and may start a billed generation, so resolution must remain atomic with persistence or the write must conditionally require the conversation to still be active.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 10d80c7, matching your diagnosis exactly — the initial isActive-filtered SELECT and the concurrent-winner fallback SELECT both now use .for('update'). That alone protects resolveOrCreateConversation's own internal race (a concurrent insert vs. a concurrent delete), but per your point 3 it does NOT cover the gap between resolution returning and the message write further down the route (credit-gate/mention/command work in between). Added a SEPARATE, second atomic re-check immediately adjacent to saveGlobalAssistantMessageToDatabase's call site — a short db.transaction doing SELECT ... FOR UPDATE + the insert, catching exactly the scenario you described (resolve returns active, delete commits before the write). New regression test in this route's stream-socket-events.test.ts reproduces it directly.

… pane

Requested during review on PR #2296/#2299: a console pane should be able to
reach the same history and settings AgentPageView's own Chat/History/Settings
tabs already give a plain page — not a narrower, session-scoped slice of it.
Checked what that actually requires (the client-side pane-assignment path,
`assignPane`, has never enforced session membership — only the create/close/
reopen cap-accounting paths care about `sessionId` — so a pane CAN already
render any conversation the caller has access to; the constraint I'd assumed
in an earlier round doesn't exist).

- `ChatPane` (apps/web/src/components/agents/panes/AgentPanes.tsx) replaces
  `ChatPaneIdentity`: still the AISelector, now followed by a compact
  Chat/History/Settings tab strip (icon-only — a pane bar is 30px tall, no
  room for AgentPageView's text pills). Owns which tab is active and renders
  all three bodies, since that state has to reach both the bar (the tab
  strip) and the body below it, and `renderPane` itself can't hold hooks (a
  plain function invoked per pane, not a component).
- History and Settings are NOT reimplemented per-pane — they reuse
  `useConversations` and the new `useAgentConfig` (both SWR-keyed, already
  shared with AgentPageView's own tabs) and the SAME `PageAgentHistoryTab`/
  `PageAgentSettingsTab` leaf components AgentPageView renders. A pane and
  the page view showing the same agent are now two views onto one cache
  entry, not two parallel implementations that could drift.
- Picking a History entry: `handlePickHistoryConversation` (new, in
  `AgentPanes`, alongside its sibling `handlePickAgent`/`handleSwitchAgent` —
  the container owns all pane IO, per this file's own doc comment) reopens
  first ONLY when the conversation is bound to THIS session (the reopen POST
  is idempotent either way, but unbound/foreign-session conversations were
  never subject to this session's cap or listing, so no server call is
  needed at all — straight to `assignPane`, the same client-only mechanism
  `openConversation`'s focus branch already uses).
- "New Conversation" from a pane's History tab reuses `handlePickAgent`
  directly — it's exactly the existing pane-picker mint flow, just triggered
  from a different affordance.
- `useAgentConfig` widened to accept `pageId: string | null` (the Assistant
  pane has no page) — disables the SWR key rather than fetching a bogus URL,
  so a pane can call the hook unconditionally across an agent switch without
  violating hook-order rules.

`AgentPanes.test.tsx` mocks `PageAgentHistoryTab`/`PageAgentSettingsTab` as
leaf renderers (matching this file's existing PaneChat/Shell mocking
philosophy — their own internals are that component's test file's job, not
this container's). 392 tests passing across the affected suites.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PofpNe4dkeFdFrN3TjhVFd
@2witstudios

Copy link
Copy Markdown
Owner Author

@codex review

Pushed 7cafcab: Chat | History | Settings as real tabs inside every console pane (ChatPane in AgentPanes.tsx), on top of the earlier commit (0b3852f) that fixed the history-deleted-send finding and introduced the shared useAgentConfig hook. See the PR description for the full breakdown.

392 tests passing, tsc/eslint clean.

@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: 7cafcabc03

ℹ️ 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".

*/
const handlePickHistoryConversation = useCallback(
async (paneId: string, agentPageId: string | null, conversation: { id: string; title: string | null; sessionId: string | null }) => {
if (conversation.sessionId === sessionId) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep history chats in their owning session

When a pane in session A selects a history entry whose sessionId is null or belongs to session B, this condition skips reopening and the unconditional assignPane below installs that conversation into session A's grid. Chat execution intentionally resolves the sandbox from the conversation row's persisted binding, so subsequent tool calls operate in session B's sandbox—or without session capabilities—while the UI presents the conversation beside session A's panes and shells. Filter these entries, navigate to their owning session, or otherwise avoid assigning them to the current session's workspace.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Investigated this in depth rather than applying the suggested fix, since it turned on a factual question I needed to verify: does tool-call sandbox resolution key off the pane grid's own session, or the conversation's own persisted binding?

Traced it fully: acquireSandbox (apps/web/src/lib/ai/tools/sandbox-tools-runtime.ts) resolves via findSessionForConversation(conversationId) — a fresh DB read (packages/lib/src/services/agent-sessions/agent-sessions-store.ts's findByConversation) joined purely on conversations.id = conversationId → conversations.sessionId → agentSessions.id. It takes no HTTP-request context, no route params, and no pane/session-grid signal whatsoever — the conversationId in the send request (which becomes the pane's own targetId via assignPane) is the ONLY input. The module's own doc comment states this as a load-bearing invariant: conversation→session resolution happens through conversations.sessionId, explicitly rejecting per-conversation/lazy sandbox minting as "exactly the conflation the session model removed."

Concretely: a pane in session A's grid displaying conversation X (bound to session B) sends conversationId: X on every message; tool calls for that message ALWAYS resolve session B's sandbox, never session A's — this is provably correct, not a leak, regardless of which grid displays it. A sessionId: null conversation gets a hard, explicit no_session denial (never a silent fallback to whichever session's grid it's opened in).

What IS real, and worth flagging separately as a UX-clarity item rather than a P1: a pane's tool activity streams into "every shell in THIS conversation's own session" (session B's shells) — a user watching session A's terminal tab, where the pane visually sits, would not see it. That's a discoverability surprise, not a correctness or security issue, since the routing itself never crosses session boundaries. Happy to open a follow-up for a visual indicator (e.g. a badge on a pane whose conversation belongs to a different/no session) if that's valuable, but did not treat it as blocking here given the underlying mechanism is sound.

e.stopPropagation();
settingsRef.current?.submitForm();
}}
disabled={isSettingsSaving}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Disable Save until the agent config has loaded

On a slow or failed config request, the Settings body shows its loading state but this new pane-level Save button remains enabled. PageAgentSettingsTab registers submitForm before returning that loader, and its form defaults contain an empty prompt/tool list, so clicking Save during this state PATCHes those defaults and can overwrite the agent's existing configuration. Include agentConfig === null in the disabled/loading condition and avoid exposing submission until the initial config exists.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 659581a — the Save button's disabled condition now includes agentConfig === null, exactly as you described (PageAgentSettingsTab registers submitForm before its own loaded-check returns, and its form defaults would PATCH an empty prompt/tool list over the real config). New regression test drives a pending /api/pages/agent-1/agent-config fetch, asserts the button is disabled while it's in flight, then resolves it and asserts the button becomes enabled.

currentConversationId={conversationId}
onSelectConversation={handleSelectHistoryConversation}
onCreateNew={handleCreateNewFromHistory}
onDeleteConversation={(id) => void deleteConversation(id)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Rebind panes after deleting a conversation from History

When History deletes the conversation displayed by this pane—or one displayed by another pane—this callback only invokes deleteConversation; no onConversationDelete callback or workspace update removes the corresponding pane binding. After the DELETE deactivates the canonical row, switching that pane back to Chat leaves it pointing at the deleted transcript, and sends now return the new 404 indefinitely until the user manually selects or creates another conversation. Successful deletion should replace/reset every pane showing that ID and refresh the session-list cache.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 659581a — added a new handleHistoryDeleteConversation in AgentPanes (alongside its sibling handlePickAgent/handlePickHistoryConversation, per this file's own "the container owns ALL the IO" doc comment), wired to every pane's onDeleteConversation regardless of which pane's History tab the delete came from. It resets every pane in the grid still pointing at the deleted id back to the picker — the simplest safe recovery, letting the user explicitly pick what's next rather than guessing a replacement. New regression test: two panes, delete the SECOND pane's conversation from the FIRST pane's History tab, assert the second pane's scope resets to null.

Comment on lines +651 to +656
assignPane(sessionId, paneId, {
kind: 'chat',
name: conversation.title || 'Conversation',
targetId: conversation.id,
agentPageId,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Focus an existing pane for a selected conversation

If the selected History conversation is already displayed in another pane, this direct assignment bypasses the store's deduplicating openConversation path and makes the same conversation appear in two panes. That mounts two independently interactive chat surfaces for one transcript and contradicts the existing focus behavior used by agent switching; use openConversation or explicitly focus the pane that already owns the target ID.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 659581ahandlePickHistoryConversation now checks for an existing pane in the grid already showing the picked conversationId, and focuses it instead of assigning to the pane History was opened from. One subtlety a regression test caught that inspection alone would have missed: a plain synchronous selectPane call there was getting immediately clobbered — ChatPane's outer group/pane div has its own bubble-phase onClick={onSelectPane} (selects the pane the click originated in), which fires AFTER the inner button's click handler in the SAME synchronous dispatch, re-selecting the wrong pane a moment after my code had already selected the right one. Deferred the focus call via a macrotask (setTimeout(..., 0)) so it runs after the entire bubble phase has finished, which is what actually wins. New regression test: pane B already shows the target conversation, pick it from pane A's History — asserts pane B becomes active and pane A's own binding is untouched.

Comment on lines +978 to +979
onPickHistoryConversation({ id, title: picked?.title ?? null, sessionId: picked?.sessionId ?? null });
setActiveTab('chat');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Stay in History until reopening succeeds

For a closed same-session conversation, onPickHistoryConversation starts the asynchronous reopen but returns immediately, so this line switches to Chat before the POST resolves. If reopening is rejected because the session is full, access changed, or the row was deleted, the parent correctly leaves the old pane binding untouched, but the user is nevertheless moved to the old Chat view after attempting to select a different entry. Return and await the reopen result before changing tabs.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 659581aonPickHistoryConversation now resolves to whether the pick actually landed (false on a failed reopen), and the pane's own tab-switch handler only follows it to Chat when it resolves true. A failed reopen leaves the user on History with the error toast already shown, rather than being bounced to the stale Chat view as if the pick had silently succeeded. New regression test: reopen POST rejects, asserts the pane stays on the History tab (never renders pane-chat).

2witstudios and others added 2 commits July 31, 2026 22:14
…eck and the message write

Round-2 findings on PR #2299 (chatgpt-codex-connector P1 x2, coderabbitai
major): the isActive checks added in the previous commit read the
conversation's state once, early — but a concurrent History-delete could
still commit in the (long, I/O-heavy) gap between that read and the actual
message persist, which happens after credit-gate checks, @mention
processing, and command resolution. The snapshot going stale meant the
exact scenario the earlier fix was meant to prevent could still happen: a
message silently persisted beneath a conversation now excluded from every
session listing.

Global path additionally had a SECOND, independent way to hit the same bug:
`resolveOrCreateConversation`'s initial SELECT filters `isActive: true`, so
a history-deleted row reads as "no existing row" and falls into the
insert-then-conflict path — the INSERT collides with the inactive row's own
id and is swallowed by `onConflictDoNothing`, and the fallback "who won the
race" SELECT (added last commit to check `isActive`) only runs when THAT
code path is reached at all. If the delete instead committed AFTER
`resolveOrCreateConversation` returned but BEFORE the message write, neither
existing check ever ran again.

Fix: a `SELECT ... FOR UPDATE` immediately adjacent to the write, inside a
short transaction that wraps ONLY the check + the insert — never the
credit-gate/mention/command work around it, which must not run inside an
open transaction (holding a row lock across that much unrelated I/O,
including its own DB round-trips, would trade a rare race for a routine
contention/pool-starvation risk). Postgres's own row locking makes this
correct without touching the DELETE side at all: `FOR UPDATE` blocks a
concurrent `UPDATE ... SET isActive = false` on the same row (and vice
versa), so whichever transaction reaches the row first wins and the other
correctly observes its outcome.

- `message-utils.ts`: `saveMessageToDatabase`/`saveGlobalAssistantMessageToDatabase`
  gain an optional `dbClient` override (defaults to the module `db`) so a
  caller already holding a transaction can run the insert on that SAME
  connection rather than a second, unlocked one.
- `resolve-or-create-conversation.ts`: both SELECTs (the initial isActive
  lookup and the concurrent-winner fallback) now lock via `.for('update')` —
  a no-op standalone, real protection when the caller wraps the call in an
  explicit transaction.
- Both chat routes: the first (user) message's persist is now wrapped in
  `db.transaction(async (tx) => { SELECT...FOR UPDATE; if (!isActive) throw;
  save({..., dbClient: tx}); })`. Page-agent route gates this on
  `existingConversation` — a brand-new conversation's eager-create is
  explicitly best-effort/non-fatal elsewhere in this same route, and this
  check must not turn that tolerated absence into a hard rejection.

New regression tests in both routes' test suites reproduce the exact race
(conversation looks active at the early check, a concurrent delete commits
before the write) and assert 404 + the lifecycle never starts. 345 tests
passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PofpNe4dkeFdFrN3TjhVFd
…ind on delete

Round-3 findings on PR #2299 (chatgpt-codex-connector: 1 more P1 + 3 P2;
one additional P1 investigated and confirmed a false alarm):

- Disable Save until the agent config has loaded (P1): PageAgentSettingsTab
  registers submitForm before its own config-loaded check returns, and its
  form defaults contain an empty prompt/tool list — clicking Save in that
  window would PATCH those defaults over the agent's real config. The
  button's disabled condition now includes `agentConfig === null`.

- Focus an existing pane instead of duplicating it (P2): picking a History
  entry already open in another pane bypassed the store's deduplicating
  `openConversation` path, mounting a second independently interactive
  surface for the same transcript. `handlePickHistoryConversation` now
  checks for an existing pane showing that conversationId first and
  focuses it — deferred via a macrotask, since a synchronous `selectPane`
  here was getting immediately clobbered by `ChatPane`'s own outer
  `onClick={onSelectPane}` firing during the SAME click's bubble phase
  (caught via a failing regression test, not by inspection — the fix
  wouldn't have been obvious from reading the code alone).

- Stay in History until reopening succeeds (P2): `onPickHistoryConversation`
  now resolves to whether the pick actually landed; the pane's own tab
  switch only follows it to Chat on success, so a failed reopen (session
  full, deleted, access changed) leaves the user on History with a toast,
  not silently bounced to the old Chat view as if nothing happened.

- Rebind panes after deleting a conversation from History (P2): a History
  delete deactivates the CANONICAL row, and previously only called
  `deleteConversation` — any pane still pointing at that id (in this pane
  OR another one, whichever pane's History tab the delete came from) was
  left showing a transcript that now 404s on send with no way out. New
  `handleHistoryDeleteConversation` (in `AgentPanes`, alongside its sibling
  `handlePickAgent`/`handlePickHistoryConversation`) resets every pane in
  the grid still showing the deleted id back to the picker.

- Investigated and refuted the sandbox-routing P1 (chatgpt-codex-connector):
  claimed a pane could execute tool calls against the WRONG session's
  sandbox when displaying a foreign-session conversation. Traced the actual
  resolution path (`acquireSandbox` → `findSessionForConversation`, a fresh
  DB read keyed only on `conversationId`, documented as intentionally
  ignoring any session-grid/pane context) and confirmed tool calls always
  resolve to the CONVERSATION's own persisted session, never the pane
  grid's — the concern is UX clarity (activity streams to a different
  session's shells than the one visually surrounding the pane) at most, not
  a functional or security bug. Replied with the full trace, not a code
  change.

4 new regression tests. 226 tests passing across the affected suites.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PofpNe4dkeFdFrN3TjhVFd
@2witstudios

Copy link
Copy Markdown
Owner Author

@codex review

Two more rounds pushed:

10d80c7 — closed the TOCTOU race in the isActive checks from the previous round: a SELECT ... FOR UPDATE immediately adjacent to the message write, inside a short transaction (never wrapping the credit-gate/mention/command work around it). Fixes the 3 P1 findings on this (2 x chatgpt-codex-connector, 1 x coderabbitai) — full mechanism explained inline on each thread.

659581a — pane-tabs round-3 findings: Save button now disabled until agent config loads (P1), History picks focus an existing pane instead of duplicating it (P2, with a real bug caught via a failing regression test — event-bubbling clobbering a synchronous selectPane call), stay on History until a reopen actually succeeds (P2), and every pane showing a conversation deleted from History gets reset (P2). Also investigated the sandbox-routing P1 in depth and refuted it with a full code trace — replied inline with the evidence; it's a UX-clarity question at most, not a correctness bug.

571 tests passing across the affected suites (tsc/eslint clean).

@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: 659581a4dc

ℹ️ 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 on lines +1154 to +1156
onDeleteConversation={(id) => {
void deleteConversation(id);
onHistoryDeleteConversation(id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Wait for History deletion before resetting panes

When the DELETE fails—most notably with the intentional 409 returned for the last open conversation in a session—deleteConversation catches the failure and resolves without indicating success, but this callback immediately resets every pane showing the conversation anyway. The server therefore retains the live conversation while the client discards its pane bindings and shows pickers; only reset the panes after a confirmed successful deletion.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 0df7444deleteConversation (apps/web/src/lib/ai/shared/hooks/useConversations.ts, shared with AgentPageView's own History tab) now resolves boolean instead of resolving regardless of outcome, and surfaces the server's own error message on a refusal (previously silent for a non-ok response). AgentPanes only calls onHistoryDeleteConversation when it resolves true. New regression test: a 409 refusal leaves the pane's binding untouched.

@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: 2

🧹 Nitpick comments (6)
apps/web/src/components/agents/panes/AgentPanes.tsx (3)

1027-1045: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Report a failed share toggle to the user.

toggleConversationShare logs failures to the console only. Every other failing action in this file raises a toast.error. A silent failure leaves the History row showing a share state the server never accepted. Add a toast, and use the same ApiRequestError-aware message shape the other handlers use.

♻️ Proposed change to surface the failure
-        if (!response.ok) console.error('Failed to update conversation sharing');
+        if (!response.ok) {
+          console.error('Failed to update conversation sharing');
+          toast.error('Could not update sharing for this conversation');
+        }
       } catch (error) {
         console.error('Failed to update conversation sharing:', error);
+        toast.error('Could not update sharing for this conversation', {
+          description: error instanceof Error ? error.message : 'Please try again.',
+        });
       }
🤖 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 1027 -
1045, Update toggleConversationShare to surface both non-OK responses and caught
errors with toast.error instead of console-only logging. Match the
ApiRequestError-aware message formatting already used by other handlers in
AgentPanes, while preserving the existing request and early-return behavior.

673-699: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Read the workspace from the live store after the awaited reopen.

workspace is the render-time snapshot captured when this callback was created. The await post(...) above can resolve much later. Two effects follow. The dedup lookup can miss a pane that another action bound to conversation.id during the request. The assignPane call can also overwrite a pane that the user repurposed during the request, which the file guards elsewhere with paneStillShows/paneStillLoading. Reading useAgentWorkspaceStore.getState() here matches that existing idiom and removes workspace from the dependency list.

♻️ Proposed change to read live pane state
-      const existingPane = workspace
-        ? panesOf(workspace).find(
-            (p) => p.id !== paneId && p.scope?.kind === 'chat' && p.scope.targetId === conversation.id,
-          )
-        : undefined;
+      const current = useAgentWorkspaceStore.getState().workspaces[sessionId];
+      const existingPane = current
+        ? panesOf(current).find(
+            (p) => p.id !== paneId && p.scope?.kind === 'chat' && p.scope.targetId === conversation.id,
+          )
+        : undefined;
       if (existingPane) {
@@
       return true;
     },
-    [sessionId, assignPane, workspace, selectPane],
+    [sessionId, assignPane, selectPane],
   );
🤖 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 673 - 699,
After the awaited reopen completes, read the current workspace from
useAgentWorkspaceStore.getState() before deduplication or assignment instead of
using the render-time workspace snapshot. Use this live state for panesOf lookup
and guard assignment against changes made while awaiting, following the existing
paneStillShows/paneStillLoading protections, and remove workspace from the
callback dependency list.

1192-1228: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Associate each tab with its panel.

The buttons carry role="tab" inside a role="tablist", but no element carries role="tabpanel", and the tabs have no aria-controls. Assistive technology therefore cannot move from a tab to the content it controls. Add an id to the pane body container at Line 1133, give it role="tabpanel", and point each tab at it with aria-controls. Arrow-key movement between tabs is the other half of the ARIA tabs pattern, and it is worth adding here as well.

🤖 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 1192 -
1228, Update PaneChatTabStrip and the pane body container to complete the ARIA
tabs relationship: assign the body a stable id and role="tabpanel", add matching
aria-controls to each tab button, and implement arrow-key navigation between
tabs while preserving the existing selection behavior.
apps/web/src/components/agents/panes/__tests__/AgentPanes.test.tsx (2)

1027-1036: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert the pane count to pin the no-duplicate claim.

The test verifies the focus move and the first pane's binding. It does not verify that the grid still holds exactly two panes. A regression that both focuses the existing pane and assigns a duplicate elsewhere would still pass. Add a count assertion.

♻️ Proposed additional assertion
       expect(firstPaneAfter?.scope?.targetId).toBe('conv-1');
+      const panesAfter = useAgentWorkspaceStore
+        .getState()
+        .workspaces['ses-1'].columns.flatMap((c) => c.panes);
+      expect(panesAfter).toHaveLength(2);
+      expect(panesAfter.filter((p) => p.scope?.targetId === 'conv-other')).toHaveLength(1);
     });
🤖 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 1027 - 1036, Update the test around the existing firstPaneAfter assertion
to count panes across all columns in workspace ses-1 and assert that exactly two
panes remain, preserving the existing focus and binding assertions.

1092-1104: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Select the deleting pane deterministically, and assert both panes reset.

The test title states that the delete comes from another pane's History tab. historyTabs[0] relies on DOM order, which the preceding test explicitly avoids. If the first match belongs to the pane bound to conv-doomed, the test then verifies that the deleting pane reset itself, which is a weaker case than the title states. Locate the pane bar whose body renders conv-1, the same way the dedup test does. Also assert that the pane bound to conv-1 keeps its binding, so the reset stays scoped to the deleted id.

🤖 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 1092 - 1104, Update the test to select the History tab from the pane whose
body is bound to conv-1, using the pane-specific lookup pattern from the
deduplication test instead of historyTabs[0]. After deleting delete-conv-doomed,
assert both that the targeted pane’s scope is null and that the conv-1 pane
still retains its original binding.
apps/web/src/app/api/ai/global/[id]/messages/__tests__/stream-socket-events.test.ts (1)

97-164: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the duplicated transactional db mock into a shared test helper.

Three test files each hand-roll the same makeDbLike() + transaction() mock shape (a select().from().where().for('update').limit() chain plus a transaction callback that runs against a fresh mock instance). The root cause is the absence of a shared test utility for this pattern. A future change to the real transaction contract requires updating all three copies independently.

  • apps/web/src/app/api/ai/global/[id]/messages/__tests__/stream-socket-events.test.ts#L97-L164: extract this version (it already has table-label detection) into a shared test helper, e.g. apps/web/src/lib/testing/create-transactional-db-mock.ts, parameterized by table-label-to-row mappings.
  • apps/web/src/app/api/ai/chat/__tests__/stream-socket-events.test.ts#L103-L157: replace this file's makeDbLike/transaction with the shared helper.
  • apps/web/src/app/api/ai/global/[id]/messages/__tests__/credit-gate.test.ts#L97-L131: replace this file's makeDbLike/transaction with the shared helper.
🤖 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/ai/global/`[id]/messages/__tests__/stream-socket-events.test.ts
around lines 97 - 164, The transactional database mock is duplicated across
three test files; extract the table-label-aware makeDbLike and fresh-client
transaction behavior into a shared create-transactional-db-mock helper,
parameterized by table-label-to-row mappings. Update
apps/web/src/app/api/ai/global/[id]/messages/__tests__/stream-socket-events.test.ts:97-164
to use the helper, and replace the corresponding makeDbLike/transaction
implementations in
apps/web/src/app/api/ai/chat/__tests__/stream-socket-events.test.ts:103-157 and
apps/web/src/app/api/ai/global/[id]/messages/__tests__/credit-gate.test.ts:97-131,
preserving each test’s existing rows, mocks, and assertions.
🤖 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/AgentPanes.tsx`:
- Around line 1154-1157: Update the onDeleteConversation callback in AgentPanes
so onHistoryDeleteConversation runs only after deleteConversation(id) resolves
successfully. Preserve the existing deletion error handling and avoid resetting
the panes when the promise rejects.
- Around line 1161-1181: Reset activeTab to 'chat' whenever the pane’s current
agent changes, including transitions where scope.agentPageId becomes null and
switches to a different agent. Update the effect or state logic controlling
activeTab in AgentPanes, using the agent identity/page identifier as the
dependency, so the Settings tab cannot remain selected across agent changes.

---

Nitpick comments:
In
`@apps/web/src/app/api/ai/global/`[id]/messages/__tests__/stream-socket-events.test.ts:
- Around line 97-164: The transactional database mock is duplicated across three
test files; extract the table-label-aware makeDbLike and fresh-client
transaction behavior into a shared create-transactional-db-mock helper,
parameterized by table-label-to-row mappings. Update
apps/web/src/app/api/ai/global/[id]/messages/__tests__/stream-socket-events.test.ts:97-164
to use the helper, and replace the corresponding makeDbLike/transaction
implementations in
apps/web/src/app/api/ai/chat/__tests__/stream-socket-events.test.ts:103-157 and
apps/web/src/app/api/ai/global/[id]/messages/__tests__/credit-gate.test.ts:97-131,
preserving each test’s existing rows, mocks, and assertions.

In `@apps/web/src/components/agents/panes/__tests__/AgentPanes.test.tsx`:
- Around line 1027-1036: Update the test around the existing firstPaneAfter
assertion to count panes across all columns in workspace ses-1 and assert that
exactly two panes remain, preserving the existing focus and binding assertions.
- Around line 1092-1104: Update the test to select the History tab from the pane
whose body is bound to conv-1, using the pane-specific lookup pattern from the
deduplication test instead of historyTabs[0]. After deleting delete-conv-doomed,
assert both that the targeted pane’s scope is null and that the conv-1 pane
still retains its original binding.

In `@apps/web/src/components/agents/panes/AgentPanes.tsx`:
- Around line 1027-1045: Update toggleConversationShare to surface both non-OK
responses and caught errors with toast.error instead of console-only logging.
Match the ApiRequestError-aware message formatting already used by other
handlers in AgentPanes, while preserving the existing request and early-return
behavior.
- Around line 673-699: After the awaited reopen completes, read the current
workspace from useAgentWorkspaceStore.getState() before deduplication or
assignment instead of using the render-time workspace snapshot. Use this live
state for panesOf lookup and guard assignment against changes made while
awaiting, following the existing paneStillShows/paneStillLoading protections,
and remove workspace from the callback dependency list.
- Around line 1192-1228: Update PaneChatTabStrip and the pane body container to
complete the ARIA tabs relationship: assign the body a stable id and
role="tabpanel", add matching aria-controls to each tab button, and implement
arrow-key navigation between tabs while preserving the existing selection
behavior.
🪄 Autofix (Beta)

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: 3f6a3a92-c0ac-4dc6-a88c-f0121534285d

📥 Commits

Reviewing files that changed from the base of the PR and between 0b3852f and 659581a.

📒 Files selected for processing (12)
  • apps/web/src/app/api/ai/chat/__tests__/stream-socket-events.test.ts
  • apps/web/src/app/api/ai/chat/route.ts
  • apps/web/src/app/api/ai/global/[id]/messages/__tests__/credit-gate.test.ts
  • apps/web/src/app/api/ai/global/[id]/messages/__tests__/resolve-or-create-conversation.test.ts
  • apps/web/src/app/api/ai/global/[id]/messages/__tests__/stream-socket-events.test.ts
  • apps/web/src/app/api/ai/global/[id]/messages/resolve-or-create-conversation.ts
  • apps/web/src/app/api/ai/global/[id]/messages/route.ts
  • apps/web/src/components/agents/panes/AgentPanes.tsx
  • apps/web/src/components/agents/panes/__tests__/AgentPanes.test.tsx
  • apps/web/src/lib/ai/core/message-utils.ts
  • apps/web/src/lib/ai/shared/hooks/__tests__/useAgentConfig.test.ts
  • apps/web/src/lib/ai/shared/hooks/useAgentConfig.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/src/app/api/ai/global/[id]/messages/tests/resolve-or-create-conversation.test.ts

Comment thread apps/web/src/components/agents/panes/AgentPanes.tsx
Comment thread apps/web/src/components/agents/panes/AgentPanes.tsx
… on agent switch

Round-4 findings on PR #2299 (chatgpt-codex-connector P1 + coderabbitai
major, same underlying bug from two angles; coderabbitai minor):

- Wait for History deletion before resetting panes: `deleteConversation`
  swallowed both a non-ok response (e.g. the never-empty guard's 409 for
  the session's last open listing) and a network failure, resolving either
  way with no signal — so the previous round's pane-rebind fix reset every
  pane showing the conversation regardless of whether the server actually
  deleted it, discarding a live, working pane binding for a conversation
  that was NEVER removed. `deleteConversation` (apps/web/src/lib/ai/shared/
  hooks/useConversations.ts — shared with AgentPageView's own History tab)
  now resolves `boolean`, surfaces the server's own error message on a
  refusal (previously silent), and `AgentPanes` only calls
  `onHistoryDeleteConversation` when it resolves `true`.

- Leave the Settings tab when the pane switches to a different agent
  (coderabbitai): `showSettings` hid the tab once `agentPageId` became null
  or changed, but `activeTab` itself stayed `'settings'` — the body then
  fell through to a spinner branch that could never resolve (the Assistant
  has no config to load, ever). A pane switched to a different agent would
  also silently keep showing Settings, presenting a config the user never
  asked for. `activeTab` now resets to `'chat'` whenever `scope.agentPageId`
  changes.

3 new regression tests for the delete-gating (success/refused/network-
failure, all in useConversations.test.ts directly) plus 2 in AgentPanes.test.tsx
(a refused 409 delete leaves panes untouched; switching agents leaves the
Settings tab). 234 tests passing across the affected suites.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PofpNe4dkeFdFrN3TjhVFd
@2witstudios

Copy link
Copy Markdown
Owner Author

@codex review

Round 4 pushed (0df7444): fixed the confirmed-delete-gating bug (P1 x1 + major x1, same root cause from two review passes) and the Settings-tab-stuck-after-agent-switch bug (minor). Full explanations replied inline on each thread.

234 tests passing, tsc/eslint clean.

@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: 0df74440a3

ℹ️ 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/app/api/ai/chat/route.ts Outdated
.select({ isActive: conversations.isActive })
.from(conversations)
.where(eq(conversations.id, conversationId!))
.for('update')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Lock the conversation before sweeping page messages

When a page-agent History DELETE begins first, softDeleteConversation() updates chatMessages before it updates the conversations row; a concurrent send can therefore acquire this row lock after that message sweep, observe isActive: true, insert a new active message, and commit before the DELETE finally tombstones the conversation. The deleted conversation then retains the newly inserted active message, and generation can continue against an unreachable transcript. Fresh evidence after the prior review is the delete transaction's child-before-parent lock ordering in conversation-repository.ts; make deletion acquire this same conversation lock before sweeping messages.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 22538c5, matching your diagnosis exactly — reordered softDeleteConversation's transaction to update conversations FIRST (acquiring the row lock immediately) and chatMessages second, so a concurrent send's own FOR UPDATE re-check contends for that lock across the WHOLE transaction, not just from the second statement onward. Existing ordering-assertion test in conversation-repository.test.ts updated to match; the round-2 atomic re-check's own race tests continue to exercise the (now-corrected) interaction.

Comment on lines +690 to +695
assignPane(sessionId, paneId, {
kind: 'chat',
name: conversation.title || 'Conversation',
targetId: conversation.id,
agentPageId,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Ignore stale History reopen completions

When a same-session reopen is slow, the user can select another History row or switch the pane's agent before this request resolves; this completion then unconditionally assigns the older conversation and overwrites the user's newer choice. Two rapid closed-conversation selections can likewise resolve out of order, making network timing determine the visible transcript. Verify that this pane still represents the initiating selection, or use a per-pane request token, before assigning.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 22538c5 — added a per-pane token (paneAssignTokens, bumped by both handlePickHistoryConversation and handlePickAgent), checked before applying a completion at each point a supersede could have happened. Covers both scenarios you named directly (two rapid History picks on the same pane; a pending reopen raced by a different agent's mint into that pane). One gap I want to flag rather than silently leave: handleSwitchAgent's FOCUS branch (switching to an agent that already has an OPEN conversation, vs. minting a new one) calls the store's openConversation directly, which can target a DIFFERENT pane than the one the switch originated from (it focuses whichever pane already shows that conversation, if any) — wiring that branch into the same token risked invalidating the wrong pane's token rather than the one it actually affects, so I left it out rather than guess. Happy to take a follow-up pass at that specific interaction if it's a real concern; the two explicitly-reported scenarios are fully covered by this fix. New regression test reproduces the same-pane race directly (a fast unbound pick superseding a slow same-session reopen).

Comment on lines +714 to +716
if (!workspace) return;
const affectedPanes = panesOf(workspace).filter(
(p) => p.scope?.kind === 'chat' && p.scope.targetId === deletedConversationId,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Re-read pane bindings after History deletion

When deletion succeeds after the user has already selected another conversation in an affected pane, this callback still reads the workspace snapshot captured before the DELETE and resets that pane based on its old binding, discarding the newer selection. History remains interactive while the request is pending, so this race is reachable; query useAgentWorkspaceStore.getState() at completion time and reset only panes that still show the deleted ID.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 22538c5, exactly as suggested — handleHistoryDeleteConversation now reads useAgentWorkspaceStore.getState().workspaces[sessionId] fresh at call time instead of the workspace it closed over (stale by definition, since this always runs after the DELETE's own async round trip). New regression test: reassign the affected pane to something else while the delete is still pending, resolve the delete, assert the pane keeps the newer assignment.

…aces

Round-5 findings on PR #2299 (chatgpt-codex-connector, 1 P1 + 2 P2):

- Lock the conversation before sweeping page messages (P1): the previous
  round's atomic re-check (a SELECT ... FOR UPDATE on the conversations row
  immediately before a message write) assumed softDeleteConversation's own
  transaction would contend for that SAME lock — but it updated
  chatMessages FIRST and conversations SECOND, so the lock stayed free for
  the whole message sweep. A concurrent send's FOR UPDATE could acquire it
  in that window, observe isActive: true (still true — the conversations
  UPDATE hadn't run yet), insert a new active message, and commit, all
  before the delete transaction finally flipped isActive. Reordered to
  conversations FIRST, then chatMessages — the lock is now held for the
  transaction's WHOLE duration, closing the window entirely. Existing
  ordering-assertion test updated to match; the fix is otherwise self-
  verified by the round-2 atomic re-check's own existing race tests (they
  now exercise the corrected order).

- Ignore stale History reopen completions (P2): a slow same-session reopen
  completing after the user has since picked something else on the SAME
  pane (another History row, or a different agent via mint) previously
  applied unconditionally, letting network timing decide the visible
  transcript. Added a per-pane token (`paneAssignTokens`, bumped by both
  `handlePickHistoryConversation` and `handlePickAgent`) — a completion
  checks it's still current before applying its result, at each of the 3
  points a supersede could have happened (post-reopen-error, post-reopen-
  success, pre-assign). Coverage is for the two explicitly reported
  scenarios (repeated History picks; a pending reopen raced by an agent
  mint) — the `handleSwitchAgent` FOCUS branch (an existing-conversation
  agent switch, not a mint) isn't wired into the same token, since which
  pane it actually touches isn't statically knowable without risking an
  incorrect invalidation.

- Re-read pane bindings after History deletion (P2): `handleHistoryDeleteConversation`
  closed over `workspace` from whenever the enclosing callback was created —
  stale by the time it runs, always after the DELETE's own async round
  trip. A pane reassigned to something else while the delete was in flight
  got reset based on its now-irrelevant old binding. Now reads
  `useAgentWorkspaceStore.getState().workspaces[sessionId]` fresh at call
  time instead.

2 new regression tests reproduce both AgentPanes races directly (a fast
second pick superseding a slow first reopen; a pane reassigned mid-delete
staying put). 140 tests passing across the affected suites.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PofpNe4dkeFdFrN3TjhVFd
@2witstudios

Copy link
Copy Markdown
Owner Author

@codex review

Round 5 pushed (22538c5): fixed the P1 lock-ordering bug in softDeleteConversation (conversations row now locked before the chatMessages sweep, closing the window your finding identified), plus two P2 races in AgentPanes (stale reopen completions ignored via a per-pane token; a stale workspace snapshot in the History-delete pane-rebind now reads fresh at completion time). One partial-coverage note on the reopen-token fix explained inline — flagging it rather than silently leaving it.

140 tests passing, tsc/eslint clean.

@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

if (decision.action === 'focus') {
openConversation(sessionId, {

P2 Badge Supersede pending reopens on selector focus

When a slow History reopen is pending for this pane and the user selects an agent whose conversation already exists in the session, this focus branch never advances the pane's assignment token. The older reopen therefore still considers itself current and later assigns its conversation back into the originating pane, despite the intervening selector action. Fresh evidence after the prior stale-reopen finding is this uncovered non-mint branch; invalidate the originating pane's token before calling openConversation.

ℹ️ 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/app/api/ai/chat/route.ts Outdated
uiMessage: userMessage,
dbClient: tx,
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Stop a send when deletion lands after the user write

When a History DELETE is permitted for a session-less conversation, or for a session with another open listing, it can acquire the row lock immediately after this transaction commits, mark the conversation and its existing messages inactive, and then return while this request continues into model generation. The later createStreamLifecycle and saveTerminalAssistantMessage calls do not recheck conversations.isActive, so they can persist an active assistant row beneath the now-hidden conversation and bill for generation the user deleted. Fresh evidence after the earlier lock fix is that the lock is released here, well before those unconditional assistant writes; deletion needs to abort the generation or terminal persistence must require an active conversation.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 0a81ae2. Page-agent route: saveTerminalAssistantMessage — the single existing choke point all 3 terminal-write sites (execute-end, onFinish, outer-catch cleanup) already route through for the mention-notification contract — now wraps its persist in the same locked-check-then-save pattern as the user's own message, so all 3 are covered by one change rather than three separate re-checks. Global route had no equivalent shared wrapper, so I added saveTerminalGlobalAssistantMessage mirroring it and switched its 3 terminal-write call sites over. A deleted conversation now silently drops the reply instead of persisting it orphaned. On the billing half of your finding: the provider call itself is untouched by this — it still gets billed/tracked via the existing trackUsage path regardless (the tokens were already consumed by the time this write would run), this only gates what gets WRITTEN to the now-gone conversation's transcript. Whether a deleted-mid-generation run should also skip billing/refund credits felt like a separate product decision rather than a pure bug, so I left it out — happy to take a follow-up pass if that's wanted.

});
return false;
}
if (!isCurrent()) return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Roll back stale successful reopens

When a same-session reopen succeeds after another History pick or agent mint has superseded it, returning here protects the pane assignment but does not undo the POST's server-side effect: the abandoned conversation now has closedInSessionAt cleared and occupies a session listing/cap slot with no pane showing it. This can make the newer reopen fail as “session full” or leave an invisible open listing. Fresh evidence after the prior stale-completion finding is this success path's early return without a compensating close or other serialization.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 0a81ae2 — the stale-completion branch now closes the conversation right back out via cleanupOrphanedConversation (the same helper an abandoned mint already uses for exactly this "undo a successful-but-now-unwanted server-side effect" purpose), freeing the cap slot it consumed. Strengthened the existing stale-reopen regression test to also assert this DELETE fires with the right URL.

Comment on lines +707 to +710
const existingPane = workspace
? panesOf(workspace).find(
(p) => p.id !== paneId && p.scope?.kind === 'chat' && p.scope.targetId === conversation.id,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Read live pane state when deduplicating reopens

When two panes select the same closed conversation while their reopen requests are pending, both invocations retain the pre-request workspace snapshot, so each can miss the other pane's completed assignment and assign the conversation twice. That recreates the two independently interactive chat surfaces this check is intended to prevent. Fresh evidence after the earlier deduplication fix is that this lookup still uses the render-time snapshot after an await; read useAgentWorkspaceStore.getState() at completion time before assigning or focusing.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 0a81ae2, exactly as suggested — the dedup lookup now reads useAgentWorkspaceStore.getState().workspaces[sessionId] fresh at the check instead of the workspace this callback closed over.

…g pane races

Round-6 findings on PR #2299 (chatgpt-codex-connector, P1 + 2 P2):

- Stop a send when deletion lands after the user write (P1): the round-2/5
  atomic re-check only covers the USER's message — persisted early, inside
  a lock held for a moment. The ASSISTANT's reply persists LATER, after
  model generation (which can run for seconds, during which nothing else
  in the route blocks a History delete from landing). Without a matching
  check there, a conversation deleted mid-generation still gets an ACTIVE
  assistant message written beneath it — a reply to a conversation the user
  already asked to delete, invisible in every session listing. Page-agent
  route: `saveTerminalAssistantMessage` (the ALREADY-existing single choke
  point all 3 terminal-write sites — execute-end, onFinish, outer-catch —
  route through, added for the mention-notification contract) now wraps its
  persist in the same locked-check-then-save pattern, so all 3 sites are
  covered by one change. Global route had no equivalent single choke point,
  so a new `saveTerminalGlobalAssistantMessage` wrapper was added mirroring
  it, and its 3 terminal-write call sites switched to use it. A deleted
  conversation now silently drops the reply rather than persisting it
  orphaned — the provider call itself is still billed/tracked via the
  existing trackUsage path, untouched by this change; only what gets
  WRITTEN to the (now-gone) conversation is affected.

- Roll back stale successful reopens (P2): the round-5 stale-completion
  token correctly stopped a superseded reopen from being APPLIED
  client-side, but didn't undo its SERVER-SIDE effect — a stale reopen that
  completed successfully still cleared `closedInSessionAt` and consumed a
  session cap slot with no pane showing it, which could make a later,
  legitimate reopen fail as "session full" for no visible reason. The stale
  branch now closes it right back out via the same `cleanupOrphanedConversation`
  helper an abandoned mint already uses.

- Read live pane state when deduplicating reopens (P2): the "already open in
  another pane" dedup check read the `workspace` this callback closed over
  — stale by the time two panes' own async reopens both reach it, so
  neither could see the other's just-completed assignment and both could
  independently assign the same conversation. Reads
  `useAgentWorkspaceStore.getState()` fresh at the check instead.

Existing stale-reopen regression test strengthened to also assert the
rollback DELETE fires. 464 tests passing across the affected suites.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PofpNe4dkeFdFrN3TjhVFd
@2witstudios

Copy link
Copy Markdown
Owner Author

@codex review

Round 6 pushed (0a81ae2): the P1 was a real gap — the atomic re-check from rounds 2/5 only covered the user's message, not the assistant's reply written later after generation. Fixed on both routes (page-agent via the existing saveTerminalAssistantMessage choke point; global via a new equivalent). Plus the 2 remaining P2 pane races (stale-reopen rollback, live-state dedup read). Full explanations inline.

464 tests passing, tsc/eslint clean.

@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: 0a81ae29c6

ℹ️ 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/app/api/ai/chat/route.ts Outdated
.where(eq(conversations.id, args.conversationId))
.for('update')
.limit(1);
if (!row?.isActive) return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Guard the streaming placeholder insert too

When a History deletion commits after the user-message transaction but before the unguarded placeholder insert at route.ts:1510, the delete has already swept existing messages, so this inactive branch silently skips the terminal write while leaving the newly inserted isActive: true, status: 'streaming' placeholder beneath the deleted conversation. Fresh evidence after the prior terminal-write finding is this earlier assistant-row write, which also exists in the global route at messages/route.ts:1251; guard that insert with the same conversation lock/check (or explicitly remove/deactivate the placeholder) so deletion cannot leave an orphaned streaming row.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 0f3fe93. You are right — the stream-start placeholder INSERT (both route.ts:1510 page-agent and messages/route.ts:1251 global) ran unguarded, on the plain db, with no isActive re-check. If a History delete commits in the window between the guarded user-message write and this insert, the row would have been planted as isActive: true, status: 'streaming' under an already-inactive conversation — and since it is a BRAND NEW row, the later terminal-write guard (saveTerminalAssistantMessage/saveTerminalGlobalAssistantMessage) checking isActive at that point would just skip its update, leaving it stuck at 'streaming' permanently. Visible again the moment this PR's reopen feature brings the conversation back.

Both inserts now run inside the same db.transaction + SELECT ... FOR UPDATE pattern already used for the terminal writes: lock the conversation row, check isActive, only insert if still active. Best-effort framing preserved (own try/catch, non-throwing) since a skipped placeholder just means history cannot show the in-flight entry mid-stream — generation still proceeds. Added regression tests on both routes covering the skip-on-race and no-false-positive-on-active cases.

// full" for no visible reason. Close it right back out, the same
// way an orphaned mint is cleaned up above (review finding —
// chatgpt-codex-connector on PR #2299).
void cleanupOrphanedConversation(conversation.id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve a concurrently selected reopen

When the same closed conversation is selected twice quickly, the newer reopen can finish first and legitimately assign the conversation, after which the older request is stale and this unconditional cleanup closes that now-visible conversation out of the session again. The same occurs if another pane selects it while the stale request is pending. Before rolling back, read the live workspace and only close the conversation if no pane currently displays it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 0f3fe93. Good catch — the rollback at AgentPanes.tsx:703 closed unconditionally whenever isCurrent() was false, which did not distinguish 'genuinely orphaned' from 'a newer pick on the same conversation already won and is now showing in a pane.' The same conversation picked twice quickly (or raced by a second pane) can have the newer reopen resolve first and legitimately land, with the older request's reopen resolving second and stale.

Now reads live workspace state across every pane in the grid (not just this pane) before closing — useAgentWorkspaceStore.getState().workspaces[sessionId], same live-read pattern already used a few lines below for the focus-existing-pane dedup check — and only calls cleanupOrphanedConversation when no pane currently shows that conversation. Added a regression test: same conversation picked twice on the same pane, older reopen resolves after the newer one already landed it, asserts no DELETE fires.

…ncurrently-landed reopen

Round 7 review findings on PR #2299:

- The 'streaming' placeholder assistant row inserted right after
  createStreamLifecycle resolves (both page-agent and global routes) had
  its own unguarded window: a History delete committing between the
  guarded user-message write and this insert could plant an isActive:true
  row under a now-inactive conversation. Since the row is brand new, the
  later terminal-write guard's isActive check would just skip its update,
  leaving it stuck at 'streaming' forever — visible again, permanently
  "in progress", once the conversation is reopened. Guarded with the same
  locked isActive re-check pattern as the terminal writes.

- handlePickHistoryConversation's stale-reopen rollback closed the
  conversation unconditionally whenever its own token was superseded.
  When the SAME conversation is picked twice quickly, the newer pick can
  win the race and legitimately land in a pane before the older, now-
  stale reopen resolves — the unconditional close would then rip that
  just-reopened, currently-visible conversation back out. Now reads live
  workspace state across the whole grid before closing, and skips it if
  any pane still shows the conversation.

620 tests passing, tsc/eslint clean.
@2witstudios

Copy link
Copy Markdown
Owner Author

@codex review

Round 7 pushed (0f3fe93): 2 findings, both real.

  • P1: the stream-start placeholder INSERT (both routes) was unguarded — same isActive TOCTOU as the earlier terminal-write fixes, just on an earlier write. Fixed with the same locked re-check pattern.
  • P2: the stale-reopen rollback could close a conversation a newer pick had just legitimately reopened into a pane. Fixed by checking live workspace state across the grid before closing.

620 tests passing, tsc/eslint clean.

@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: 1

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/app/api/ai/global/[id]/messages/route.ts (1)

1418-1447: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Gate assistantMessagePersisted and the conversations timestamp update on the actual persist result.

Both terminal-write sites call saveTerminalGlobalAssistantMessage(...) without capturing its Promise<boolean> result, then unconditionally set assistantMessagePersisted = true and unconditionally run db.update(conversations).set({ lastMessageAt: new Date(), updatedAt: new Date() }).

When the conversation was deleted between generation start and this terminal write — the exact race this PR closes — saveTerminalGlobalAssistantMessage returns false and skips the message insert. The two lines that follow it still run, so the "deleted" conversation row gets lastMessageAt/updatedAt bumped anyway, and assistantMessagePersisted is marked true even though nothing was saved. This defeats the isolation the new lock is meant to provide: the message content is protected, but the conversation row itself is still touched.

The sibling helper in apps/web/src/app/api/ai/chat/route.ts (saveTerminalAssistantMessage) gates its own post-write side effect the correct way: if (persisted && mentionNotify) mentionNotified = true;. Apply the same pattern here.

🐛 Proposed fix for both call sites
           try {
-            await saveTerminalGlobalAssistantMessage({
+            const persisted = await saveTerminalGlobalAssistantMessage({
               messageId: serverAssistantMessageId,
               conversationId,
               userId,
               role: 'assistant',
               ...payload,
               status: aborted ? 'interrupted' : 'complete',
             });
-            assistantMessagePersisted = true;
-
-            // A conversation with newly-written assistant content — even empty-content,
-            // interrupted content — must still surface as recently active in a list
-            // sorted/filtered by lastMessageAt. onFinish's own responseMessage branch below
-            // does the same bump when it gets to refine this write with richer content.
-            await db
-              .update(conversations)
-              .set({
-                lastMessageAt: new Date(),
-                updatedAt: new Date(),
-              })
-              .where(eq(conversations.id, conversationId));
+            if (persisted) {
+              assistantMessagePersisted = true;
+
+              // A conversation with newly-written assistant content — even empty-content,
+              // interrupted content — must still surface as recently active in a list
+              // sorted/filtered by lastMessageAt. onFinish's own responseMessage branch below
+              // does the same bump when it gets to refine this write with richer content.
+              await db
+                .update(conversations)
+                .set({
+                  lastMessageAt: new Date(),
+                  updatedAt: new Date(),
+                })
+                .where(eq(conversations.id, conversationId));
+            }
           } catch (e) {
             loggers.api.error('Global Assistant Chat API: execute-end persist failed', e as Error);
           }

Apply the equivalent change in onFinish (Lines 1488-1508):

           try {
             const messageContent = extractMessageContent(responseMessage);
             ...
-            await saveTerminalGlobalAssistantMessage({
+            const persisted = await saveTerminalGlobalAssistantMessage({
               messageId,
               conversationId,
               userId,
               role: 'assistant',
               content: messageContent,
               toolCalls: extractedToolCalls.length > 0 ? extractedToolCalls : undefined,
               toolResults: extractedToolResults.length > 0 ? extractedToolResults : undefined,
               uiMessage: responseMessage,
               status: aborted ? 'interrupted' : 'complete',
             });
-            assistantMessagePersisted = true;
-
-            // Update conversation lastMessageAt
-            await db
-              .update(conversations)
-              .set({
-                lastMessageAt: new Date(),
-                updatedAt: new Date(),
-              })
-              .where(eq(conversations.id, conversationId));
-
-            loggers.api.debug('Global Assistant Chat API: AI response message saved to database', {});
+            if (persisted) {
+              assistantMessagePersisted = true;
+
+              // Update conversation lastMessageAt
+              await db
+                .update(conversations)
+                .set({
+                  lastMessageAt: new Date(),
+                  updatedAt: new Date(),
+                })
+                .where(eq(conversations.id, conversationId));
+
+              loggers.api.debug('Global Assistant Chat API: AI response message saved to database', {});
+            }
           } catch (error) {
             loggers.api.error('Global Assistant Chat API: Failed to save AI response message', error as Error);
           }

Also applies to: 1477-1513

🤖 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/ai/global/`[id]/messages/route.ts around lines 1418 -
1447, Capture the boolean result returned by saveTerminalGlobalAssistantMessage
at both terminal-write sites, including the onFinish path, and only set
assistantMessagePersisted to true and update
conversations.lastMessageAt/updatedAt when that result is true. Preserve the
existing error handling and status behavior while preventing post-write side
effects when the persistence is skipped.
🧹 Nitpick comments (1)
apps/web/src/app/api/ai/chat/route.ts (1)

212-243: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated lock-then-skip transaction helper across the page-agent and global-assistant routes. Both routes implement the same SELECT ... FOR UPDATE on conversations.isActive followed by a skip-on-inactive write, as two separately maintained functions with the same correctness contract.

  • apps/web/src/app/api/ai/chat/route.ts#L212-L243: saveTerminalAssistantMessage duplicates the lock-and-skip transaction logic; extract it (and the isActive check) into a shared utility that returns whether the write happened.
  • apps/web/src/app/api/ai/global/[id]/messages/route.ts#L277-L309: saveTerminalGlobalAssistantMessage duplicates the same logic; migrate this to the shared utility too.

Consolidating this pattern into one tested utility would also make it harder to reintroduce the kind of bug flagged separately on apps/web/src/app/api/ai/global/[id]/messages/route.ts#L1418-L1447,L1477-L1513, where a caller of the duplicated helper forgot to honor its returned skip/persist flag.

🤖 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/ai/chat/route.ts` around lines 212 - 243, Extract the
lock-and-skip transaction, including the conversations.isActive check and
boolean persisted result, from saveTerminalAssistantMessage into a shared tested
utility. In apps/web/src/app/api/ai/chat/route.ts lines 212-243, replace the
duplicated transaction with that utility and preserve mentionNotified handling.
In apps/web/src/app/api/ai/global/[id]/messages/route.ts lines 277-309, migrate
saveTerminalGlobalAssistantMessage to the same utility and ensure every caller
honors its returned write status.
🤖 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/lib/ai/shared/hooks/__tests__/useConversations.test.ts`:
- Around line 154-170: Rename the test case containing deleteConversation with
enabled: false to describe only that a successful DELETE resolves true and
invokes onConversationDelete; remove the unsupported “invalidates the SWR cache”
claim from the test name while leaving its assertions unchanged.

---

Outside diff comments:
In `@apps/web/src/app/api/ai/global/`[id]/messages/route.ts:
- Around line 1418-1447: Capture the boolean result returned by
saveTerminalGlobalAssistantMessage at both terminal-write sites, including the
onFinish path, and only set assistantMessagePersisted to true and update
conversations.lastMessageAt/updatedAt when that result is true. Preserve the
existing error handling and status behavior while preventing post-write side
effects when the persistence is skipped.

---

Nitpick comments:
In `@apps/web/src/app/api/ai/chat/route.ts`:
- Around line 212-243: Extract the lock-and-skip transaction, including the
conversations.isActive check and boolean persisted result, from
saveTerminalAssistantMessage into a shared tested utility. In
apps/web/src/app/api/ai/chat/route.ts lines 212-243, replace the duplicated
transaction with that utility and preserve mentionNotified handling. In
apps/web/src/app/api/ai/global/[id]/messages/route.ts lines 277-309, migrate
saveTerminalGlobalAssistantMessage to the same utility and ensure every caller
honors its returned write status.
🪄 Autofix (Beta)

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: ba02b2b7-3724-4f10-b118-4f327e982dc7

📥 Commits

Reviewing files that changed from the base of the PR and between 659581a and 0a81ae2.

📒 Files selected for processing (8)
  • apps/web/src/app/api/ai/chat/route.ts
  • apps/web/src/app/api/ai/global/[id]/messages/route.ts
  • apps/web/src/components/agents/panes/AgentPanes.tsx
  • apps/web/src/components/agents/panes/__tests__/AgentPanes.test.tsx
  • apps/web/src/lib/ai/shared/hooks/__tests__/useConversations.test.ts
  • apps/web/src/lib/ai/shared/hooks/useConversations.ts
  • apps/web/src/lib/repositories/__tests__/conversation-repository.test.ts
  • apps/web/src/lib/repositories/conversation-repository.ts

Comment thread apps/web/src/lib/ai/shared/hooks/__tests__/useConversations.test.ts Outdated
Round 7 review (coderabbitai): the case is mounted with enabled: false,
so swrKey is null and mutate is never called — the title's "invalidates
the SWR cache" claim isn't actually exercised. Renamed to describe what
it verifies (resolves true, notifies the parent).
@2witstudios

Copy link
Copy Markdown
Owner Author

Round 7b pushed (a966237): fixed the test-name nit (misleading '''invalidates the SWR cache''' claim on a case where enabled: false means no SWR key is ever bound).

@2witstudios

Copy link
Copy Markdown
Owner Author

@codex review

Please review the latest push (a966237) as well — round 7b, the test-name fix noted above.

@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: a9662372da

ℹ️ 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 on lines +714 to +718
const stillOrphaned =
!liveWorkspace ||
!panesOf(liveWorkspace).some((p) => p.scope?.kind === 'chat' && p.scope.targetId === conversation.id);
if (stillOrphaned) {
void cleanupOrphanedConversation(conversation.id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid closing a newer in-flight reopen

When the same closed conversation is selected twice and the older reopen finishes first, that request is stale while the newer request is still pending, so no pane displays the conversation yet and this check declares it orphaned. The resulting cleanup DELETE can acquire the session-listing lock after the newer reopen, close the conversation the newer request then assigns, and leave the visible pane outside the session listing. Fresh evidence in the latest code is that orphan detection considers only landed pane bindings, not newer pending assignments for the same conversation.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in fef0119. Correct — the round-7 fix only checked whom currently shows the conversation, not whether something ELSE is about to. Added pendingReopenCounts, an in-flight count per conversationId (incremented before the reopen POST, decremented exactly once regardless of exit path). The rollback now closes the conversation only when nothing shows it AND no other reopen for that same conversationId is still pending — so the older request's stale completion defers to the newer one that's still in flight rather than racing it. Regression test: two picks of the same conversation, both left pending, older resolved first — asserts no DELETE fires, then the newer resolving lands it normally.

Comment on lines +791 to +795
const affectedPanes = panesOf(liveWorkspace).filter(
(p) => p.scope?.kind === 'chat' && p.scope.targetId === deletedConversationId,
);
for (const pane of affectedPanes) {
resetPane(sessionId, pane.id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Cancel pending reopens when History deletes their target

When a closed conversation's reopen has committed server-side but its response is delayed, the user can delete that conversation from the still-visible History tab before the completion assigns it. This scan finds no affected pane because the target is only pending, deletion does not invalidate the pane's assignment token, and the delayed reopen completion subsequently assigns the now-history-deleted conversation, producing a pane whose sends 404. Fresh evidence in the current code is that successful deletion resets only presently bound panes without accounting for pending picks of the deleted ID.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in fef0119. Good catch — this one's outside the pane-token mechanism entirely, since no pane is bound to the target while its reopen is still in flight, so handleHistoryDeleteConversation's affected-panes scan finds nothing to reset. Added a second ref, historyDeletedDuringReopen (a Set of conversationIds), unconditionally marked by handleHistoryDeleteConversation regardless of whether any pane is currently affected. The reopen completion now checks-and-consumes that mark (Set.delete returns whether it was present) right before assigning, and bails out with no assignment if it was set — treated the same as any other superseded pick. Regression test: reopen started (pending), same conversation deleted from History mid-flight, delayed reopen resolves — asserts the pane never gets assigned to the deleted id.

Review findings on PR #2299:

- The round-7 stale-reopen rollback checked whether any pane currently
  shows the conversation before closing it, but missed the case where a
  NEWER pick for the same conversation is still in flight when the OLDER
  one resolves — at that instant nothing shows it yet, but something is
  about to. Added an in-flight reopen counter per conversationId; the
  rollback now also checks that no other reopen for the same conversation
  is still pending before closing it.

- A reopen can commit server-side and then have its own HTTP response
  delayed long enough for the same conversation to be deleted from
  History before the completion assigns it — landing a pane on a
  transcript that already 404s on send. handleHistoryDeleteConversation
  now marks the deleted id; the reopen completion checks-and-consumes
  that mark before assigning, and bails out (no assignment) if it was set.

273 tests passing (agents), tsc/eslint clean.
@2witstudios

Copy link
Copy Markdown
Owner Author

@codex review

Round 8 pushed (fef0119): 2 more real races in the History-reopen flow.

  • P2: the round-7 rollback missed the case where a NEWER pick's reopen is still in flight when the OLDER one resolves — added an in-flight count per conversationId so the rollback defers to still-pending reopens.
  • P2: a reopen's delayed response could land after the same conversation was deleted from History — added a mark-and-consume set so a superseded-by-delete completion never assigns.

273 agents tests passing, tsc/eslint clean.

@2witstudios

Copy link
Copy Markdown
Owner Author

@codex review

Round 15 pushed (ea64377): 3 findings, all real.

  • P2: the reopen endpoint's response discarded the already-computed already_open vs reopened distinction, so a superseded pick's rollback could close a listing it never actually opened. Route now returns { ok, alreadyOpen }; client only rolls back on a genuine transition.
  • P2: a real bug in my own round-13/14 fix — the prior-scope capture re-read live state on every mint call, so two overlapping New Conversation clicks could have the second restore the first's own loading sentinel instead of the true original. Fixed with a reference-counted, captured-once-per-pane value.
  • P2: History deletion only invalidated the session listing cache when a pane in the current grid was affected, leaving other consumers (agent-switch focus, close fallback) trusting stale data. Now unconditional.

432 tests passing, tsc/eslint clean.

@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

if (decision.action === 'focus') {

P2 Badge Invalidate pending History picks on focus switches

When a History reopen is pending and the user switches this pane to an agent whose conversation is already open in the session, the focus branch bypasses handlePickAgent and never advances the pane's assignment token. openConversation can therefore repoint the pane to the selected agent, but the delayed reopen still considers itself current and subsequently overwrites that newer selection with the older History conversation. Supersede the pending assignment before taking the focus path as well.

ℹ️ 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".

// opened it, so rolling it back would close a listing still in
// use for a no-op this request didn't cause (review finding —
// chatgpt-codex-connector on PR #2299, round 15).
if (reopenResult.alreadyOpen || isShownSomewhere()) return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Drain deferred cleanup after an already-open completion

When two reopens for the same closed conversation are both superseded, the first request can genuinely reopen the listing and set deferredReopenSuccess while the second remains pending; the server then reports the second request as alreadyOpen. If the genuine response arrives first, the last no-op response returns here without consuming the deferred marker or closing the orphan, leaving an invisible open listing that occupies a session-cap slot. The last settler still needs to perform any cleanup deferred by the request that actually transitioned the listing.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in cf7173c. Confirmed — my own round-15 fix short-circuited on alreadyOpen BEFORE ever checking whether this was the last settler for the conversationId, so a sibling's deferred cleanup (a genuine transition that deferred because this request was still pending) never got drained if the LAST response happened to be a no-op. Reordered: the last-settler check (remaining <= 0 && deferredReopenSuccess.delete(...)) now runs FIRST, before deciding anything based on THIS request's own alreadyOpen/transition status — draining a sibling's deferred cleanup takes priority over this request's own (lack of) outcome. Regression test: older reopen genuinely transitions and defers (newer still pending), newer — also superseded — resolves reporting alreadyOpen: asserts the older's deferred cleanup still fires.

…er's own outcome is alreadyOpen (round 16)

Review finding on PR #2299: the round-15 alreadyOpen early-return
skipped the "am I the last settler for this conversationId, do I need
to finish a sibling's deferred cleanup" check entirely. Two overlapping
reopens for the same conversation, both superseded — the older
genuinely transitions the listing and defers its own rollback (a newer
sibling is still pending); if that newer sibling is the one to settle
last and its own outcome happens to be alreadyOpen, the deferred marker
from the older request was never drained, leaking an invisible open
listing (and the session cap slot it holds) forever.

The last-settler check now runs before deciding what to do with THIS
request's own outcome — draining any deferred sibling first regardless
of whether this specific reopen was itself a transition or a no-op.

282 tests passing (agents), tsc/eslint clean.
@2witstudios

Copy link
Copy Markdown
Owner Author

@codex review

Round 16 pushed (cf7173c): the alreadyOpen fix from round 15 had its own gap — the last-settler-drains-a-sibling's-deferred-cleanup check ran AFTER (and was short-circuited by) the alreadyOpen early-return, so a genuine transition that deferred could leak forever if the LAST response to arrive happened to be a no-op. Reordered so the last-settler drain always runs first, regardless of this specific request's own outcome.

282 tests passing, tsc/eslint clean.

@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: cf7173cc15

ℹ️ 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 on lines +48 to +49
setConfig: (next) => {
void mutate(next, { revalidate: false });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve dirty settings before broadcasting cache updates

When two Settings surfaces for the same agent are mounted and one contains unsaved edits, saving the other calls setConfig here and broadcasts the new object through the shared SWR entry. PageAgentSettingsTab resets its entire form whenever config changes, so the sibling surface silently loses all of its unsaved edits. Defer applying external cache updates while a form is dirty, or merge them without resetting locally edited fields.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 48cbd57. Confirmed and a real regression from the shared-identity design earlier in this PR (the user explicitly asked for one source of truth across panes) — PageAgentSettingsTab's config-reset effect had no dirty-check at all, so a SIBLING surface for the same agent saving would silently wipe unsaved edits here. Reused the useEditingStore dirty-tracking this same file already registers with for a DIFFERENT purpose (blocking SWR revalidation mid-edit): the reset effect now skips while the form is dirty, unless a ref (set right before THIS instance's own onConfigUpdate call) marks that the update is this instance's own save — in which case it still resets to the just-saved values as the new clean baseline, same as before. Note: PageAgentSettingsTab has no pre-existing test file (not something this PR introduced); verified via typecheck, lint, and a manual trace of both the own-save and sibling-update paths rather than a new test harness — happy to add one if you'd like it.

const hadDeferredSibling = remaining <= 0 && deferredReopenSuccess.current.delete(conversation.id);
if (shownSomewhere) return false;
if (hadDeferredSibling) {
void cleanupOrphanedConversation(conversation.id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Recheck pane use after orphan cleanup finishes

When this cleanup DELETE is delayed, the user can select the same conversation again after the shownSomewhere snapshot and after the pending count reached zero; that new reopen may return alreadyOpen and assign the pane before this older DELETE reaches the server, after which the DELETE closes the now-visible listing. Fresh evidence beyond the prior pending-reopen fix is that a reopen beginning after cleanup is launched is absent from both the snapshot and the settled counter. Serialize cleanup with later picks or compensate after it completes if the conversation is now displayed.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 48cbd57. Good catch — cleanupOrphanedConversation's DELETE had no serialization against a later pick landing the same conversation while it's in flight. Now rechecks isConversationShownSomewhere (hoisted out of handlePickHistoryConversation into a shared callback both call sites use) right after the DELETE resolves, and compensates with a reopen call if the conversation is displayed by then. Regression test: pick superseded and genuinely orphaned (cleanup DELETE pending/delayed), a fresh pick of the same conversation lands it in the pane while the DELETE is still in flight, DELETE then resolves — asserts a compensating reopen call fires.

…a delayed orphan-cleanup DELETE (round 17)

Review findings on PR #2299:

- PageAgentSettingsTab's config-reset effect fired unconditionally on
  every config change, including one propagated from a SIBLING Settings
  surface for the same agent saving through the shared useAgentConfig
  cache (the "one source of truth" identity the user explicitly asked
  for earlier in this PR). A form with unsaved edits would have them
  silently wiped the moment a different pane's Settings tab for the
  same agent saved. Reused the project's existing useEditingStore dirty-
  tracking pattern already present in this same file: the reset effect
  now skips when the form is dirty, UNLESS this instance's own save is
  what triggered the update (tracked via a ref set right before this
  instance's own onConfigUpdate call), so a save's own values still
  become its new clean baseline as before.

- cleanupOrphanedConversation's DELETE is fire-and-forget with no
  serialization against a LATER, independent pick of the exact same
  conversationId — if that DELETE is delayed, a fresh pick can land the
  conversation in a pane before the DELETE reaches the server, which
  then closes a listing that is now visibly displayed. Now rechecks
  right after its own DELETE resolves and compensates with a reopen
  call if the conversation is shown somewhere by then. Hoisted the
  shown-somewhere check (previously local to handlePickHistoryConversation)
  to a shared callback both call sites now use.

524 tests passing (agents + shared hooks + page-agents), tsc/eslint clean.
Note: PageAgentSettingsTab has no pre-existing test file (a gap this PR
did not introduce) — the fix follows the project's own established
useEditingStore pattern already in the same file; verified by typecheck,
lint, and manual trace, not a new dedicated test harness.
@2witstudios

Copy link
Copy Markdown
Owner Author

@codex review

Round 17 pushed (48cbd57): 2 findings, both real, in new areas (Settings shared-cache, orphan cleanup).

  • P2: PageAgentSettingsTab's config-reset effect had no dirty-check, so a sibling Settings surface for the same agent saving would silently wipe unsaved edits in this one via the shared config cache. Fixed by reusing the useEditingStore dirty-tracking this file already has for a different purpose, plus a ref distinguishing this instance's own save from an external update.
  • P2: cleanupOrphanedConversation's DELETE had no serialization against a later pick landing the same conversation while in flight. Fixed by rechecking and compensating with a reopen call after the DELETE resolves.

524 tests passing, tsc/eslint clean. Noted in the Settings fix reply: PageAgentSettingsTab has no pre-existing test file, so that fix is verified by typecheck/lint/manual trace rather than a new dedicated test.

@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: 48cbd570ac

ℹ️ 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 on lines +1358 to +1364
const {
selectedProvider,
setSelectedProvider,
selectedModel,
setSelectedModel,
isProviderConfigured,
} = useProviderSettings(scope.agentPageId ? { pageId: scope.agentPageId } : {});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Synchronize provider selections across settings surfaces

When two Settings surfaces for the same agent are mounted, each invocation of useProviderSettings owns independent selectedProvider/selectedModel state and only loads it once. If one surface saves a provider change, the shared config cache updates the sibling, but these values remain stale; that sibling's controls still show the old selection, and saving an unrelated setting later overrides the request data with the stale provider/model, reverting the first save. Derive these selections from the shared config or update every mounted provider state when that config changes.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 4a41524. Confirmed — useProviderSettings is a separate, per-mount hook with NO shared cache (unlike useAgentConfig), so its local selectedProvider/selectedModel never learn about a sibling's save. Rather than trying to synchronize that hook across instances (a larger change, and it's used by other surfaces beyond this one per its own file header), fixed it locally in PageAgentSettingsTab: two refs track whether THIS instance's provider/model controls were explicitly touched (wrapped onValueChange handlers set them). onSubmit now prefers the shared config's aiProvider/aiModel (reflecting any sibling's save) when untouched, falling back to the local selection only when the user actually picked something here — so an explicit local choice always wins, but a stale unused snapshot never silently overwrites a sibling's save.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Following up on my earlier reply — that fix (providerTouchedRef/modelTouchedRef in onSubmit) closed the data-loss half of this: a sibling's saved provider/model can no longer be silently reverted by an untouched instance's stale submit. But you were right that a real gap remained: the <Select value={selectedProvider}>/value={selectedModel} controls still rendered the raw per-mount useProviderSettings state directly, which is never resynced when a sibling saves — so an untouched sibling's dropdowns could keep displaying the wrong provider/model indefinitely (cosmetic, since a subsequent save from that instance already resolved correctly, but a real and confusing gap until then).

Fixed in 9084bb8: added displayedProvider/displayedModel, computed with the exact same touched/untouched resolution onSubmit already used (providerOrModelTouched ? selectedProvider : (config?.aiProvider || selectedProvider)), read at render time. Both Selects, the model list (getCurrentProviderModels), the SelectLabel, and the eager Ollama/LM Studio model-fetch effect now all key off displayedProvider/displayedModel instead of the raw per-mount props — so an untouched sibling's display now follows a saved change from another Settings surface for the same agent, matching what onSubmit would already correctly submit.

Leaving this thread open for verification per our process.

Comment on lines +657 to +661
if (isConversationShownSomewhere(conversationId)) {
await post(
`/api/agent-sessions/${encodeURIComponent(sessionId)}/conversations/${encodeURIComponent(conversationId)}/reopen`,
{},
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Recheck ownership after the compensating reopen

When this compensating POST is slow, the pane that caused isConversationShownSomewhere to return true can switch to another conversation before the POST resolves. The POST then successfully reopens the old listing after it has become orphaned, and nothing performs another check or cleanup, so the invisible conversation consumes a session-cap slot. The compensation needs serialization with subsequent pane assignments or a post-reopen orphan check.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 4a41524. Right — this is the same race the compensating reopen itself was built to close, just one level deeper. Added a re-check after the compensating reopen resolves; if the conversation is orphaned again by then, it recurses into another cleanup pass rather than leaving it unnoticed. This recurses (not loops) so it naturally terminates once the grid stops churning through the same conversation. Regression test: compensating reopen left pending, the pane moves to something else again before it resolves, then it resolves — asserts a SECOND cleanup DELETE fires.

…hot; re-check ownership after compensating reopen (round 18)

Review findings on PR #2299:

- PageAgentSettingsTab's onSubmit unconditionally sent useProviderSettings's
  local selectedProvider/selectedModel as part of every save's request
  body. That hook is a separate, per-mount instance with no shared cache
  (unlike useAgentConfig) — a sibling Settings surface for the same agent
  saving a provider change would leave THIS instance's copy stale, and
  the next unrelated save here would silently revert that sibling's
  change. Now tracks whether THIS instance's own provider/model controls
  were explicitly touched; if not, prefers the shared config's value
  (reflecting any sibling's save) instead of the stale local snapshot.
  An explicit local choice still always wins.

- cleanupOrphanedConversation's round-17 compensating reopen has the
  same race its own fix was closing: if that reopen itself is slow, the
  pane that made it necessary can move on to something else again before
  it lands, leaving the conversation orphaned a second time with nothing
  left to notice. Now re-checks once more after the compensating reopen
  resolves and recurses into another cleanup pass if still orphaned.

312 tests passing (agents + page-agents), tsc/eslint clean.
@2witstudios

Copy link
Copy Markdown
Owner Author

@codex review

Round 18 pushed (4a41524): 2 findings, both real, one in a new area (provider/model selection state).

  • P2: useProviderSettings' local provider/model state has no shared cache across mounts (unlike useAgentConfig), so a sibling Settings surface saving a provider change could get silently reverted by an unrelated save from a stale sibling. Fixed by tracking explicit local touches and preferring the shared config's value otherwise.
  • P2: the round-17 compensating reopen has the same race its own fix was built to close, one level deeper. Fixed with a re-check-and-recurse after the compensating reopen resolves.

312 tests passing, tsc/eslint clean.

@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

if (decision.action === 'focus') {
openConversation(sessionId, {
kind: 'chat',
name: 'Conversation',
targetId: decision.conversationId,

P2 Badge Invalidate pending reopens on focus-only agent switches

When a History reopen is pending and the user switches to an agent whose conversation is already open but currently unshown, this focus branch rebinds the pane through openConversation without advancing its assignment token. The earlier reopen therefore still sees isCurrent() as true when it resolves and overwrites the newer agent selection. Fresh evidence after the prior stale-completion fix is that only the mint branch enters handlePickAgent/beginPaneAssign; advance the token before this focus path as well.

ℹ️ 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 on lines +334 to +335
const resolvedProvider = providerTouchedRef.current ? selectedProvider : (config?.aiProvider || selectedProvider);
const resolvedModel = modelTouchedRef.current ? selectedModel : (config?.aiModel || selectedModel);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear provider touch markers after a successful save

When this surface changes its provider/model and successfully saves once, providerTouchedRef and modelTouchedRef remain true for the component's entire lifetime. If a sibling Settings surface subsequently saves a different provider/model, a later unrelated save here still chooses this surface's now-stale local values on these lines and reverts the sibling's change. Fresh evidence after the prior synchronization fix is that the new touch markers are set to true but never reset after establishing the saved baseline.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 3a950d6. Correct — I set the touch markers but never cleared them, so touching the provider dropdown even ONCE would pin this instance to its local snapshot for its entire lifetime, reintroducing the original round-18 bug for every subsequent save. Cleared both refs at the same point justSavedOwnConfigRef is set (right before onConfigUpdate) — config.aiProvider/aiModel now correctly reflect what THIS instance just saved, so preferring the shared config from that point forward is correct for both this instance's own value and any later sibling's save.

…ve (round 19)

Review finding on PR #2299: the round-18 fix set providerTouchedRef/
modelTouchedRef to true on the first local edit but never reset them,
so once a user ever touched the provider dropdown in a given Settings
surface, onSubmit would pin to that instance's local snapshot FOREVER
— reintroducing the original bug for every subsequent save, since a
later sibling save would always be reverted by this instance's now-stale
value on its next unrelated save.

Cleared both refs right alongside justSavedOwnConfigRef, at the same
point this instance's own save becomes its new clean baseline —
config.aiProvider/aiModel now correctly reflect what was just chosen,
so preferring the shared config from this point on is exactly as
correct for this instance's own save as for a later sibling's.

312 tests passing (agents + page-agents), tsc/eslint clean.
@2witstudios

Copy link
Copy Markdown
Owner Author

@codex review

Round 19 pushed (3a950d6): a follow-up gap in my own round-18 fix — the touch markers were set on first edit but never cleared, so they pinned a Settings surface to its local snapshot forever after the first touch, reverting every later sibling save. Cleared both refs at the point this instance's own save becomes its new clean baseline.

312 tests passing, tsc/eslint clean.

@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: 3a950d6b33

ℹ️ 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".

const resolvedProvider = providerTouchedRef.current ? selectedProvider : (config?.aiProvider || selectedProvider);
const resolvedModel = modelTouchedRef.current ? selectedModel : (config?.aiModel || selectedModel);
const requestData = {
...data,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Merge untouched fields from the latest config

When one Settings surface is dirty and a sibling saves a different field, the reset effect deliberately retains this surface's old form snapshot while its config prop advances. Saving the dirty surface later spreads every stale field from data into the PATCH, silently reverting sibling changes to fields the user never touched here. Fresh evidence after the round-17 dirty guard is this whole-form submission; merge the latest config for untouched fields or submit only dirtyFields plus this surface's edits.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in d5e52b8. Confirmed — this surface deliberately keeps its stale form snapshot while dirty (that's the point of the round-17 dirty guard), but onSubmit was still spreading the WHOLE data object into the PATCH, so any OTHER field a sibling had already saved would get silently reverted the moment this surface saved anything. Now merges per-field: react-hook-form's own dirtyFields says which fields THIS instance's form actually has a pending edit for; only those come from data, everything else comes from the freshest shared config.

Comment on lines +334 to +335
const resolvedProvider = providerTouchedRef.current ? selectedProvider : (config?.aiProvider || selectedProvider);
const resolvedModel = modelTouchedRef.current ? selectedModel : (config?.aiModel || selectedModel);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep provider and model from the same settings snapshot

When a sibling changes the provider, this mounted surface's useProviderSettings controls still display the old provider and its models. If the user then changes only the model here, providerTouchedRef is false while modelTouchedRef is true, so these lines combine the sibling's new provider with a model selected from the stale provider; the agent-config route rejects that pair with a 400 and the user's other edits cannot be saved. Fresh evidence after the round-18 fallback is this independently resolved provider/model pair; synchronize the controls or treat the pair as one atomic selection.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in d5e52b8 — good catch, this was a real gap in my round-18/19 fix resolving provider and model independently. They're now resolved as ONE atomic pair: if EITHER was touched in this instance, BOTH come from this instance's own current selection (internally consistent, since the model list is derived from the currently-selected provider); otherwise BOTH come from the shared config (also consistent). Never mixes one from each source anymore.

/>
) : scope.agentPageId !== null && agent ? (
<div className="h-full overflow-auto">
<PageAgentSettingsTab

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Give each mounted settings form a unique edit-session key

Adding another PageAgentSettingsTab mount here allows multiple forms for the same page, but every instance registers the identical page-agent-settings-${pageId} key in useEditingStore. If two surfaces are dirty and either one saves or unmounts, its cleanup deletes the shared Map entry while the other remains dirty; that remaining form is no longer counted by isAnyEditing(), so auth refresh and editing-paused background work can resume prematurely. Include a per-mount identifier in the registration key or make the store reference-count duplicate registrations.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in d5e52b8. Correct — added a stable per-mount id via React's useId(), folded into the useEditingStore registration key (page-agent-settings-${pageId}-${mountId}), so each mounted Settings surface for the same agent now registers and cleans up its OWN entry rather than sharing (and potentially deleting out from under) a sibling's.

…unt editing key (round 20)

Review findings on PR #2299, all in PageAgentSettingsTab's multi-instance
handling — the round-17/18/19 patches kept finding new derived symptoms
of the same underlying gap: this component was built assuming exactly
one mounted instance per agent, and the shared useAgentConfig cache
(earlier in this PR) is what first made multiple simultaneous mounts of
the same agent's Settings possible.

- onSubmit spread the WHOLE form's data into the PATCH regardless of
  which fields the user actually edited in THIS instance. A dirty
  surface's stale snapshot of every OTHER field would silently overwrite
  a sibling's already-saved change to any field this instance never
  touched. Now merges: only fields react-hook-form's own dirtyFields
  marks as actually edited here come from the form; everything else
  comes from the freshest shared config.

- Provider and model were resolved independently (round 18/19's touched-
  refs, checked separately) — touching only the model while a sibling
  changed the provider could combine this instance's stale-provider
  model with the sibling's new provider, an invalid pair the route
  rejects with a 400. Now resolved as ONE atomic pair: if EITHER was
  touched here, both come from this instance's own current selection
  (guaranteed internally consistent); otherwise both come from the
  shared config (also consistent).

- useEditingStore's registration key was `page-agent-settings-${pageId}`
  — identical across every mounted instance for the same agent. Either
  instance saving or unmounting deleted the shared map entry, dropping a
  still-dirty sibling out of isAnyEditing() and letting SWR revalidation
  resume while it still had unsaved edits. Now keyed by a stable
  per-mount id (React's useId()) as well.

312 tests passing (agents + page-agents), tsc/eslint clean. Same note as
rounds 17-19: PageAgentSettingsTab has no pre-existing test file; verified
by typecheck, lint, and manual trace.
@2witstudios

Copy link
Copy Markdown
Owner Author

@codex review

Round 20 pushed (d5e52b8): 3 findings, all continuing to surface the same underlying gap in PageAgentSettingsTab's multi-instance handling (rounds 17-19 already patched several derived symptoms of this).

  • P2: onSubmit spread the whole form into the PATCH regardless of which fields were actually edited here, silently reverting a sibling's already-saved change to any untouched field. Fixed by merging only react-hook-form's own dirtyFields, everything else from the freshest shared config.
  • P2: provider and model were resolved independently — could combine a stale provider with a fresh model (or vice versa), an invalid pair the route rejects. Fixed by resolving both as one atomic pair from the same source.
  • P2: useEditingStore's registration key was identical across every mounted instance for the same agent, so one instance saving/unmounting could drop a still-dirty sibling's dirty-tracking. Fixed with a per-mount id.

312 tests passing, tsc/eslint clean. Same note as prior rounds: PageAgentSettingsTab has no pre-existing test file.

@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: d5e52b83ef

ℹ️ 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".

// PR #2299, round 20).
const mergedFields = {
systemPrompt: dirtyFields.systemPrompt ? data.systemPrompt : (config?.systemPrompt ?? data.systemPrompt),
enabledTools: dirtyFields.enabledTools ? data.enabledTools : (config?.enabledTools ?? data.enabledTools),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Mark bulk tool selections dirty

When the user clicks Select All or Deselect All, those handlers call setValue('enabledTools', ...) without shouldDirty: true; React Hook Form therefore updates the displayed value but leaves dirtyFields.enabledTools false. This branch then takes config.enabledTools, so Save silently discards the bulk selection. Mark those setValue calls dirty or track bulk changes explicitly.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 6866f71 — added shouldDirty: true to both setValue calls. Correct, and a direct regression from the round-20 dirty-only merge specifically.

Comment on lines 376 to 380
const requestData = {
...data,
aiProvider: selectedProvider,
aiModel: selectedModel,
includeDrivePrompt: data.includeDrivePrompt,
agentDefinition: data.agentDefinition,
visibleToGlobalAssistant: data.visibleToGlobalAssistant,
includePageTree: data.includePageTree,
pageTreeScope: data.pageTreeScope,
...mergedFields,
aiProvider: resolvedProvider,
aiModel: resolvedModel,
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Submit only fields edited by this settings instance

When two mounted settings surfaces save different edits before either save's SWR update reaches the other, both build mergedFields from the same stale config snapshot and send every field; whichever PATCH finishes last overwrites the first surface's change with its old baseline. The route already supports partial PATCHes, so send only this instance's dirty fields (and the provider/model pair only when touched), or add revision-based conflict handling.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 6866f71. Right — and on reflection this is a strictly better fix than round 20's merge approach, not just a patch on top of it: the agent-config route already applies each field conditionally (if (field !== undefined) updateData.field = ...), so it fully supports sparse PATCHes. onSubmit now sends ONLY the fields this instance's own form has a dirty edit for (plus the provider/model pair, only when touched) — never a value merely copied from a snapshot that could already be stale. This removes the race entirely rather than narrowing its window: two surfaces saving different fields concurrently now literally cannot conflict, since neither PATCH mentions a field the other one touched. Kept the local optimistic-cache merge (display-only, never sent to the server) for immediate UI feedback.

…tale snapshot; mark bulk tool selections dirty (round 21)

Review findings on PR #2299, both derived from round 20's fix:

- handleSelectAllTools/handleDeselectAllTools called setValue without
  shouldDirty: true — dirtyFields.enabledTools stayed false even though
  the displayed selection changed, so round 20's dirty-only merge would
  silently discard a bulk selection and resubmit the stale config value
  instead. Added shouldDirty: true to both calls.

- Round 20's "merge untouched fields from the freshest config" still
  raced: two Settings surfaces saving different fields close enough
  together that neither's SWR update had reached the other yet would
  both build their "unedited" fields from the SAME stale snapshot,
  and whichever PATCH landed second would stomp the first's just-saved
  change. The agent-config route already applies each field only when
  present in the body (`if (field !== undefined) updateData.field = ...`),
  so onSubmit now sends a SPARSE PATCH — only fields this instance's own
  form actually has a dirty edit for (plus the provider/model pair, only
  when touched) — rather than ever resending a value merely copied from
  a snapshot that might already be stale. The local optimistic cache
  update (display-only, never sent to the server) still merges with the
  freshest config for immediate UI feedback.

312 tests passing (agents + page-agents), tsc/eslint clean. Same note as
rounds 17-20: PageAgentSettingsTab has no pre-existing test file.
@2witstudios

Copy link
Copy Markdown
Owner Author

@codex review

Round 21 pushed (6866f71): 2 findings, both good.

  • P2: bulk select-all/deselect-all setValue calls didn't mark dirtyFields, so round 20's dirty-only merge silently discarded them. Fixed with shouldDirty: true.
  • P2: round 20's merge-from-config approach still raced when two surfaces saved close together before either's SWR update propagated. Replaced with a sparse PATCH — only actually-dirty fields are sent at all, leaning on the route's existing partial-update support — which removes the race entirely rather than narrowing it.

312 tests passing, tsc/eslint clean.

@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: 6866f71497

ℹ️ 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".

// safe — this just makes sure THIS instance's own edits are visible
// immediately without waiting on a revalidation round trip.
const updatedConfig = {
...config,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Merge the cache update against live config

When two settings surfaces save different fields concurrently, each onSubmit retains the config snapshot from when its own save began. Although the sparse PATCHes now preserve both changes in the database, the request that finishes last spreads that stale snapshot here and publishes it through the shared SWR cache, making the other successful change disappear from every mounted form and potentially allowing a later edit of the displayed stale value to overwrite it server-side. Apply dirtyPatch through a functional update against the current SWR value, or revalidate/use the persisted response instead of rebuilding from this closure.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 7ff31b6. Correct — round 21 made the SERVER-side PATCH fully concurrency-safe, but the LOCAL cache update still built from a config value closed over when onSubmit was created, which could go stale relative to a sibling's meanwhile-arrived change. useAgentConfig's setConfig now also accepts an updater function, passed straight through to SWR's own mutate (which natively supports this form), so it merges against whatever the cache actually holds at apply time rather than a stale closure. PageAgentSettingsTab's onSubmit now uses that form. Added a regression test directly to useAgentConfig (which has existing coverage, unlike PageAgentSettingsTab) verifying two sequential updater calls each see the prior one's result rather than reverting it.

…ue, not a stale closure (round 22)

Review finding on PR #2299: round 21 made the server-side PATCH fully
concurrency-safe (sparse, field-scoped), but the LOCAL optimistic cache
update still built its merged object from `config` — a value closed
over when onSubmit was created. Two Settings surfaces for the same
agent saving DIFFERENT fields close enough together that neither's SWR
update had reached the other yet would each spread their own stale
closure; whichever finished last would publish a snapshot missing the
other's meanwhile-arrived change, making it disappear from the shared
cache (and every mounted consumer) even though both PATCHes persisted
correctly server-side.

useAgentConfig's setConfig now also accepts an updater function
`(current) => next`, passed straight through to SWR's own mutate (which
natively supports this form) so it merges against whatever the cache
actually holds at the moment it runs. PageAgentSettingsTab's onSubmit
now calls onConfigUpdate with an updater instead of a plain merged
value.

526 tests passing (agents + shared hooks + page-agents), tsc/eslint
clean. New regression test added directly to useAgentConfig (which,
unlike PageAgentSettingsTab, has existing coverage) verifying the
updater form merges against the live cache value.
@2witstudios

Copy link
Copy Markdown
Owner Author

@codex review

Round 22 pushed (7ff31b6): the round-21 sparse-PATCH fix made the server side fully concurrency-safe, but the LOCAL cache update still merged from a closure-captured config value. Fixed by giving useAgentConfig's setConfig a functional-updater form (passed straight through to SWR's own mutate) so it merges against the live cache value instead. Includes a new regression test in useAgentConfig itself.

526 tests passing, tsc/eslint clean.

@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: 7ff31b657d

ℹ️ 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".

// here routes that stale completion into the existing orphan-cleanup
// path (rounds 8-9) instead (review finding — chatgpt-codex-connector
// on PR #2299, round 10).
beginPaneAssign(paneId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Defer invalidation until the close is accepted

When the only pane is mid-mint and the session-conversation listing has not resolved, decideClosePane returns noop, but this call has already invalidated the mint's assignment token. If the mint then succeeds, its stale-completion path deletes the new conversation and returns without clearing the pane's loading scope; if it fails, the token guard likewise prevents restoration, leaving the pane spinning indefinitely even though the close itself did nothing. Only invalidate after a non-noop decision, or explicitly restore the pending pane when close cannot proceed.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 7a33d5f. Correct — my round-10 fix bumped the token unconditionally at the top, before decideClosePane even ran. Moved it to only fire once a decision ACTUALLY commits to altering the pane (close-pane, rebind-pane, closeConversationListing) — not for noop, and not for the end-session ask-first branch either (the user can still cancel that dialog; a CONFIRMED end-session tears down the whole workspace via forgetWorkspace, at which point the existing paneStillLoading shape-check already correctly detects this pane is gone, no token needed). Regression test: a mid-mint, grid-last pane closed while the session-conversation listing hasn't resolved (a genuine noop) — asserts the pane isn't removed AND the mint still lands normally afterward rather than being discarded as superseded.

Comment on lines +415 to +419
onConfigUpdate((current) => ({
...current,
...patchThisSave,
...providerModelPatch,
} as AgentConfig));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Revalidate after overlapping same-field saves

When two mounted settings surfaces save different values for the same field concurrently, PATCH response order need not match database-write order—for example, the earlier database write can have a slower post-write response. Each completion applies its own submitted value here, so the late response from the earlier write can overwrite the cache with a value that is no longer in the database. Because this mutate explicitly disables revalidation, every mounted form can retain the wrong value indefinitely; serialize same-field saves, use revisions, or revalidate after overlapping saves settle.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 7a33d5f. Right — round 22's live-value merge closes the 'missing the other field entirely' case, but not this one: two PATCHes to the SAME field race on HTTP response arrival order, which need not match DB write order, and no amount of client-side merging can tell which response reflects the true final DB state. Added revalidate() to useAgentConfig (a plain SWR re-fetch, no args) and call it right after the optimistic update in onSubmit, so the cache reconciles to server ground truth once any overlapping sibling save has also landed. Added a regression test directly to useAgentConfig verifying revalidate() replaces an incorrect optimistic value with the server's response.

…nfig revalidation for out-of-order saves (round 23)

Review findings on PR #2299:

- handleClosePane bumped the closing pane's assignment token BEFORE
  deciding what the close actually does — including when the decision
  turns out to be 'noop' (the grid-last pane's session-conversation
  listing hasn't resolved yet, so decideClosePane can't safely act) or
  when 'end-session' is only being ASKED about and the user can still
  cancel. Invalidating a pending mint/reopen's token for a close that
  never actually happened left its own completion treating itself as
  superseded, discarding its result without ever moving the pane out of
  its loading state — stuck spinning forever for nothing. The token is
  now only bumped once a decision actually commits to altering the pane
  (close-pane, rebind-pane, closeConversationListing); the end-session
  ask-first branch doesn't need it at all, since a confirmed end-session
  tears down the whole workspace and the existing paneStillLoading
  shape-check already handles that case without a token.

- Round 22 made the local config cache update merge against the LIVE SWR
  value instead of a stale closure, but that still can't fully resolve
  two sibling surfaces PATCHing the SAME field concurrently: HTTP
  response order need not match DB-write order (the earlier write can
  have the slower response), so whichever completion applies LAST here
  wins the cache regardless of which write actually landed last in the
  DB. Added a revalidate() method to useAgentConfig (a plain SWR
  re-fetch) and call it after every save, alongside the optimistic
  update, so the cache eventually reconciles to server ground truth
  once any overlapping sibling save has also had a chance to land.

528 tests passing (agents + shared hooks + page-agents), tsc/eslint
clean. New regression tests: a no-op-close-doesn't-strand-a-pending-mint
test in AgentPanes, and a revalidate()-corrects-an-optimistic-mismatch
test in useAgentConfig.
@2witstudios

Copy link
Copy Markdown
Owner Author

@codex review

Round 23 pushed (7a33d5f): 2 findings, both real.

  • P2: handleClosePane's own round-10 fix bumped the closing pane's token unconditionally, even for a noop decision or a cancellable end-session ask — stranding a pending mint/reopen that never actually got closed. Token now only bumps once a decision actually commits to altering the pane.
  • P2: round 22's live-value merge still couldn't resolve two sibling surfaces PATCHing the SAME field concurrently, since HTTP response order need not match DB-write order. Added a revalidate() re-fetch after every save so the cache eventually reconciles to server ground truth.

528 tests passing, tsc/eslint clean.

@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: 7a33d5f916

ℹ️ 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".

/>
) : scope.agentPageId !== null && agent ? (
<div className="h-full overflow-auto">
<PageAgentSettingsTab

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Synchronize membership changes across settings mounts

When two panes show Settings for the same agent, each PageAgentSettingsTab created here owns an independent membership snapshot loaded only on mount. Changing the Drive Membership role in one pane updates only that instance, so the sibling continues displaying the old role indefinitely and can later overwrite the newer role based on stale UI. Share or revalidate the membership state after handleMembershipRoleChange, as is done for the agent config.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Confirmed — this was a genuine gap (the only unresolved thread with no prior reply). Drive membership (membership/membershipUserRole/driveRoles) was local useState, fetched once per mount with no shared cache, so a role change in one Settings surface never reached a sibling instance for the same agent.

Fixed in 9084bb8: added useAgentMembership(driveId, pageId) — a shared, SWR-backed hook mirroring useAgentConfig, keyed by driveId so every mounted Settings surface reads the same agentMembers/roles cache entries. updateRole() now does PATCH + mutate() (revalidate) against that shared cache instead of a local setMembership(...) merge, so a role change from any instance is immediately visible in every other mounted instance for the same agent. PageAgentSettingsTab now consumes this hook instead of its own useEffect+useState pair. Covered by new unit tests in useAgentMembership.test.ts, including one asserting a second hook instance for the same agent sees an updated role with no extra fetch/local state.

Leaving this thread open for verification per our process.

… Settings

Two Settings surfaces for the same agent (e.g. two panes both open to that
agent's Settings tab) previously drifted:

- Drive membership (role/custom role) was loaded into per-instance
  useState with no shared cache, so a role change in one instance never
  reached a sibling instance. New useAgentMembership hook mirrors
  useAgentConfig's SWR-shared-cache approach, keyed by driveId, and
  updateRole() revalidates that shared cache instead of merging locally.

- The provider/model Selects rendered the raw per-mount selectedProvider/
  selectedModel prop, which is never resynced when a sibling saves a
  different provider — onSubmit already preferred the shared config in
  that case (round 18), but the display never caught up, so an untouched
  sibling could show the wrong provider/model indefinitely. Added
  displayedProvider/displayedModel, mirroring onSubmit's own resolution
  logic, for the Selects, the model list, and the eager Ollama/LM Studio
  model fetch.

review findings — chatgpt-codex-connector on PR #2299, round 26/27
(threads r3694897525, r3695144705)
@2witstudios

Copy link
Copy Markdown
Owner Author

Convergence status after round 24 (commit 9084bb8)

CI: all 10 checks green (CodeQL ×2, Secret Scanning, Security Summary, Security Test Suite, Static Security Analysis, Dependency Audit, CodeRabbit, Lint & TypeScript, Unit Tests).
Mergeability: MERGEABLE / CLEAN, 0 conflicts with master, up to date.
Review threads: 53 total, 6 already resolved, 47 currently unresolved — every one now has a concrete reply.

Ran two independent adversarial audits over all 47 unresolved threads this round (re-reading each comment against current source, not trusting prior "Fixed in <sha>" replies at face value):

  • 44 confirmed already-fixed in the current code — verified line-by-line, not just claimed. These span the reopen/mint concurrency hardening in AgentPanes.tsx (12+ threads, all closing out one coherent mechanism: per-pane tokens, per-conversation in-flight counts, delete-generation counters, deferred-success markers, recursive orphan cleanup) and the server-side TOCTOU hardening in chat/route.ts / resolve-or-create-conversation.ts (transactional FOR UPDATE locking around every message write, delete, and placeholder insert).
  • 2 fixed this round (threads r3694897525, r3695144705): drive membership and provider/model selection could drift between two Settings surfaces mounted for the same agent. Added a shared useAgentMembership SWR hook (mirroring useAgentConfig) and displayedProvider/displayedModel render-time resolution matching onSubmit's existing touched/untouched logic.
  • 1 accepted trade-off (thread r3694774841): Assistant History pagination capped at 100 items rather than building full cursor pagination — flagged as disproportionate to the actual risk (a user with >100 conversations for one agent).

Why 47 threads remain unresolved despite this: per this loop's own AUTO-RESOLVE SAFETY rule, only threads addressed before the loop started may be auto-resolved — threads fixed during the loop must stay open for human verification. All 47 remaining threads were addressed during this loop's 24 rounds, so none qualify for auto-resolution. This is intentional, not an oversight: the PR is code-complete, CI-green, and conflict-free, with a full paper trail on every finding, but final thread resolution is left to a human reviewer's judgment rather than the loop marking its own homework.

Three consecutive full scans (CI, mergeability, thread count, reply coverage) came back clean with no new actionable findings.

@2witstudios
2witstudios merged commit 40f5a37 into master Aug 1, 2026
10 checks passed
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