Skip to content

fix(ai-chat): close conversation-identity race causing sends to land in the wrong conversation#1849

Merged
2witstudios merged 8 commits into
masterfrom
pu/convo-bu
Jul 5, 2026
Merged

fix(ai-chat): close conversation-identity race causing sends to land in the wrong conversation#1849
2witstudios merged 8 commits into
masterfrom
pu/convo-bu

Conversation

@2witstudios

@2witstudios 2witstudios commented Jul 4, 2026

Copy link
Copy Markdown
Owner

Summary

Sending a message to an existing conversation could create a new one instead of appending, and starting a new conversation could append to the previous one instead. Root cause: conversation identity was tracked independently in useConversations.ts, usePageAgentDashboardStore.ts, GlobalChatContext.tsx, and AiChatView.tsx local state, each writing it asynchronously after a network round trip — a send fired mid-transition could carry a stale id, and a stale async response could overwrite an identity the user had already moved past.

  • Pure state machine (conversation-identity.ts): idle → resolving → ready/error. IDENTITY_SET always wins regardless of current state; a stale RESOLVED/RESOLVE_FAILED that arrives after a newer identity was set is a guaranteed no-op. One shared, independently-tested primitive.
  • Client-generated ids: conversation creation generates its id synchronously (cuid2) instead of waiting on the server to return one — the create/persist POST becomes fire-and-forget and idempotent. The page-agents create route now accepts (and the merged pu/cli-login ownership-guard fix protects) a client-supplied id.
  • Every surface wired to the same reducer via the mechanism appropriate to its container — not one-size-fits-all:
    • AiChatView.tsx: the new shared useConversationIdentity hook (single-instance component).
    • usePageAgentDashboardStore.ts: the reducer called directly inside Zustand actions (a store isn't a React component, so the hook doesn't apply — the plain reducer function does).
    • GlobalChatContext.tsx: a local useReducer in the provider (shared by every consumer via context, not duplicated per-consumer).
    • usePageAgentSidebarState.ts: a 4th independent surface (sidebar agent chat) discovered during proactive review — same reducer, same Zustand-action wiring pattern as the dashboard store.
  • AiChatView keyed by page.id in CenterPanel.tsx, matching the existing pattern already used for DocumentView/SheetView/etc. — the component can receive a new page prop without unmounting, and React's own documented pattern for resetting state on a new subject is key, not a hand-rolled reset-on-prop-change effect.
  • Folds in a second root cause: AiChatView's init effect misclassified a transient list-fetch failure as "no conversations exist," silently replacing a real conversation with a fresh disconnected one. It now surfaces a retry banner instead.

Hardening pass (proactive + reviewer-driven, post-initial-implementation)

  • Fixed a double-fetch on AiChatView history-select (skip-token set after, not before, adopting identity) and restored history-list memoization via a stable onSelectConversation.
  • resolveConversation's messages-prefetch exception no longer misclassifies as a full identity-resolution failure when the conversation itself was already found.
  • Guarded loadMostRecentConversation's catch/no-conversation-found paths, the late-joiner stream-reconciliation setIdentity call, and a stale Zustand no-op set() — all against clobbering a newer identity the user has since moved to.
  • Added a decoupled isConversationMessagesLoading / isMessagesLoading flag (store, context, sidebar-state hook, both consumer views) — adopting identity synchronously closed the race but also removed the window that used to cover the messages fetch, so a conversation switch could otherwise flash the previous conversation's messages under the new identity with no loading indicator.
  • Client-supplied conversationId on the create route is now validated with isCuid() before being trusted as a row id.
  • GlobalChatContext's non-ok messages-fetch path now clears initialMessages (previously only the thrown-exception path did).

Test plan

  • bun run typecheck — clean
  • 227+ new/updated unit tests across every touched file, all green (reducer, hook, store, context, sidebar-state hook, AiChatView, route)
  • bun run --filter web test — 736/740 files green project-wide (4 pre-existing failures unrelated to this change: a missing Postgres test role and a midnight-boundary date test)
  • Manual smoke test in a running instance: New Chat → immediate send lands in the new conversation; history-select → immediate send lands in the selected conversation; simulated network failure on load surfaces a retry banner instead of silently starting a disconnected conversation

🤖 Generated with Claude Code

https://claude.ai/code/session_01JT8umuwBmmtKiwmBhyq5uH

Summary by CodeRabbit

  • New Features

    • Conversation threads now appear immediately when created, with a smoother transition between chats.
    • Chat views show a clearer loading state while messages are being fetched.
  • Bug Fixes

    • Fixed cases where switching conversations could briefly show the wrong messages.
    • Improved reliability when loading, retrying, or restoring chat conversations.
    • Added better error handling so chat areas can recover more gracefully when conversation data fails to load.

2witstudios and others added 5 commits July 4, 2026 14:20
Sending a message to an existing conversation could create a new one
instead of appending, and vice versa, because conversationId was tracked
independently in several places and only updated after an async
create/switch round trip resolved -- a send fired mid-transition would
carry a stale id.

- Add a pure conversation-identity state machine (idle/resolving/ready/error)
  as the shared primitive later phases will wire in.
- Generate conversation ids client-side (cuid2) in useConversations.ts and
  usePageAgentDashboardStore.ts instead of waiting on the server response;
  the create POST becomes a fire-and-forget idempotent persist. The
  page-agents route now accepts an optional client-supplied conversationId.
- Fix two regressions caught in review: the global-chat branch of
  createConversation no longer diverges from the server-generated id (that
  route doesn't support a client-supplied id), and
  loadMostRecentConversation's fallback to createNewConversation no longer
  leaves isConversationLoading stuck true.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JT8umuwBmmtKiwmBhyq5uH
One hook to own the conversationIdentityReducer + dispatch-on-mount
wiring, so AiChatView/GlobalAssistantView/GlobalChatContext each call
it with their own resolve() function instead of hand-rolling the same
useReducer + effect boilerplate three times over.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JT8umuwBmmtKiwmBhyq5uH
Replaces the ad hoc currentConversationId/isInitialized useState pair
with state derived from useConversationIdentity, closing the two
remaining causes of the conversation-identity race in this surface:

- New Chat and history-select now call setIdentity synchronously —
  history-select no longer waits for useConversations.loadConversation's
  messages fetch to resolve before adopting the selected id, which was
  the last window where an immediate send could land under the wrong
  conversation.
- resolveConversation (passed to useConversationIdentity) distinguishes
  a genuine empty conversation list from a thrown fetch failure — a
  transient network error now surfaces a retry banner instead of
  silently falling back to the page-scoped placeholder id, fixing the
  "existing conversation treated as new" symptom at its second root
  cause.

Also keys AiChatView by page.id in CenterPanel.tsx, matching the
existing pattern already used for DocumentView/CodePageView/
TerminalView/CanvasPageView/SheetView. AiChatView can receive a new
page prop without unmounting; remounting is the idiomatic way to reset
a state machine for a new subject, so this replaces a manual "reset
state when page.id changes" effect rather than adding a resetKey/RESET
escape hatch to the reducer itself.

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

Completes the conversation-identity race fix across the remaining
surfaces (GlobalAssistantView agent mode, GlobalChatContext global
mode, SidebarChatTab verified as inheriting correctly).

- usePageAgentDashboardStore: conversationId/isConversationLoading are
  now derived fields kept in sync by routing every transition through
  conversationIdentityReducer as a plain function call inside store
  actions (Zustand stores aren't React components, so this is the
  correct application of the shared reducer, not the
  useConversationIdentity hook). loadConversation/createNewConversation
  dispatch IDENTITY_SET synchronously; loadMostRecentConversation is
  now provably immune to the exact stale-clobber race code review
  caught earlier (a slow loadMostRecentConversation resolving after a
  newer createNewConversation/loadConversation can no longer overwrite
  it — the reducer ignores RESOLVED/RESOLVE_FAILED once state has moved
  past 'resolving').
- GlobalChatContext: same reducer wired via a local useReducer in the
  provider (correct here since it's a single instance shared by every
  consumer, not duplicated per-consumer). loadConversation adopts an
  id synchronously instead of only after its messages fetch resolves.
- GlobalAssistantView: disabled now includes !isInitialized, closing a
  gap review flagged (isConversationLoading was computed into
  agentIsInitialized but never wired into the send-disabled state).
- Added conversationIdFrom/isResolving pure selectors to
  conversation-identity.ts, shared by all three surfaces plus AiChatView
  (retrofitted to use them instead of inline ternaries).

164/164 tests green across every file this epic touched; full
apps/web typecheck clean; 736/740 test files green project-wide (4
pre-existing, unrelated failures).

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

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR introduces a shared conversation-identity state machine (conversationIdentityReducer, useConversationIdentity) and shifts conversation ID generation to the client (via cuid2). It refactors the dashboard store, sidebar agent state, GlobalChatContext, and AiChatView to derive conversation state from this identity machine, adds separate isMessagesLoading tracking to prevent stale-fetch clobbering, and adds a per-page remount key for AiChatView in CenterPanel.

Changes

Conversation Identity Refactor

Layer / File(s) Summary
Conversation identity state machine and hook
apps/web/src/lib/ai/shared/conversation-identity.ts, apps/web/src/lib/ai/shared/hooks/useConversationIdentity.ts, apps/web/src/lib/ai/shared/hooks/index.ts, apps/web/src/lib/ai/shared/index.ts, apps/web/src/lib/ai/shared/__tests__/conversation-identity.test.ts, apps/web/src/lib/ai/shared/hooks/__tests__/useConversationIdentity.test.ts
Adds conversationIdentityReducer, canSend/conversationIdFrom/isResolving selectors, and useConversationIdentity hook with setIdentity/retry, exported via barrels, with full unit test coverage.
Server route and client conversation ID support
apps/web/src/app/api/ai/page-agents/[agentId]/conversations/route.ts, apps/web/src/lib/ai/shared/agent-conversations.ts
POST route and createAgentConversation accept an optional client-supplied conversationId, falling back to server-side generation when absent.
useConversations client-side ID generation
apps/web/src/lib/ai/shared/hooks/useConversations.ts, apps/web/src/lib/ai/shared/hooks/__tests__/useConversations.test.ts
createConversation generates a client-side id and invokes onConversationCreate synchronously in agent mode, before persistence resolves; global mode still awaits the server id.
Dashboard store identity integration
apps/web/src/stores/page-agents/usePageAgentDashboardStore.ts, apps/web/src/stores/page-agents/__tests__/usePageAgentDashboardStore.test.ts
AgentState now tracks identity and isConversationMessagesLoading; loadConversation, createNewConversation, and loadMostRecentConversation apply identity transitions synchronously and guard against stale async results.
Sidebar agent state identity integration
apps/web/src/hooks/page-agents/usePageAgentSidebarState.ts, apps/web/src/hooks/page-agents/__tests__/usePageAgentSidebarState.test.ts
Store actions replaced with applyIdentity/setMessagesLoading/setMessagesLoaded; conversation creation/loading generates client ids and guards stale results.
GlobalChatContext identity integration
apps/web/src/contexts/GlobalChatContext.tsx, apps/web/src/contexts/__tests__/GlobalChatContext.test.tsx
GlobalChatProvider derives currentConversationId/isInitialized from the identity reducer, adds isMessagesLoading, and reworks loadConversation/createNewConversation/init failure handling.
AiChatView identity-driven conversation loading
apps/web/src/components/layout/middle-content/page-views/ai-page/AiChatView.tsx, .../__tests__/AiChatView.test.tsx, apps/web/src/components/layout/middle-content/CenterPanel.tsx
AiChatView uses useConversationIdentity/resolveConversation, a centralized stale-guarded message loader, canSendMessage gating, an identity-error retry banner, and is remounted per page via a keyed render in CenterPanel.
GlobalAssistantView and SidebarChatTab messages-loading state
apps/web/src/components/layout/middle-content/page-views/dashboard/GlobalAssistantView.tsx, apps/web/src/components/layout/right-sidebar/ai-assistant/SidebarChatTab.tsx
Derives isMessagesLoading per mode (agent vs global) and updates loading/disabled UI gating to avoid cross-conversation message flash.

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

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant AiChatView
  participant IdentityHook as useConversationIdentity
  participant Store as ConversationStore/Context
  participant API

  User->>AiChatView: create/select conversation
  AiChatView->>IdentityHook: setIdentity(clientGeneratedId)
  IdentityHook-->>AiChatView: state = ready(id)
  AiChatView->>Store: persist conversation (async)
  AiChatView->>API: fetch messages(id)
  API-->>AiChatView: messages
  AiChatView->>AiChatView: loadMessagesForConversation (stale-guarded)
  Store-->>AiChatView: persistence result (ignored if stale)
Loading

Possibly related PRs

  • 2witstudios/PageSpace#101: Overlaps directly with this PR's usePageAgentSidebarState/usePageAgentDashboardStore/agent-conversations.ts client-conversationId flows.
  • 2witstudios/PageSpace#1645: Modifies the same GlobalChatContext.tsx provider logic around conversation identity and message loading during initialization.
  • 2witstudios/PageSpace#1693: Refactors AiChatView.tsx with a similar authoritative loadMessagesForConversation and stale-request guarding pattern.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main fix: an ai-chat conversation-identity race causing messages to be sent to the wrong conversation.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pu/convo-bu

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.

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

ℹ️ 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 +1118 to +1119
setIdentity(conversationId);
void loadConversation(conversationId);

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 double-loading selected history conversations

When selecting a history item, setIdentity() updates currentConversationId before anything sets skipLoadEffectRef, so the currentConversationId effect immediately calls loadMessagesForConversation() while loadConversation() below is already fetching the same messages. On a slow or flaky request, one fetch can succeed and the other can later fail, leaving messagesLoadError displayed over successfully loaded messages; it also doubles the message-load request for every history selection. Set the skip/in-flight marker before adopting the identity, or make the effect ignore IDs already being loaded by useConversations.

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 564c0de: handleSelectConversation now sets skipLoadEffectRef.current = conversationId before calling setIdentity, so the load-on-select effect sees the skip token already in place when it reacts to the identity change and no longer fires its own redundant fetch. onSelectConversation was also hoisted into a stable useCallback (was previously a fresh inline arrow every render, which separately broke the history list's memoization).

Verified with a regression test (AiChatView.test.tsx, "given a conversation is selected from history and onConversationLoad supplies messages, should NOT double-fetch that conversation over the network") that fails without the fix and passes with it.

Leaving this thread open for verification rather than resolving it myself, since the fix landed after this review comment was posted.

…1849

A full re-review of the combined diff (all prior commits together, not
just incrementally) surfaced 10 concrete issues the per-commit reviews
missed:

- usePageAgentSidebarState.ts was a 4th conversation-identity surface
  the original refactor never touched — createNewConversation/
  loadConversation both still awaited the server before adopting an
  id, reproducing the exact race this PR exists to close. Fixed with
  the same client-generated-id + synchronous-identity pattern used
  elsewhere.
- usePageAgentDashboardStore's loadMostRecentConversation unconditionally
  fell back to createNewConversation on any failure/empty-result path,
  with no check that a newer identity (from loadConversation/
  createNewConversation racing it) had already won — silently
  clobbering the user's more recent choice. Now guarded.
- AiChatView's history-select handler set identity before the
  skip-load-effect token, defeating its own double-fetch guard and
  double-fetching messages on every history click. Also passed a
  fresh inline callback each render, breaking the history list's
  memoization — both fixed by hoisting to one stable useCallback with
  the correct ordering.
- resolveConversation didn't catch an exception (vs. just a non-ok
  response) on the messages-prefetch fetch, so a network blip on that
  *secondary* request was misclassified as a full identity-resolution
  failure, blocking the whole chat even though the conversation was
  already known.
- AiChatView's late-joiner stream-reconciliation callback called
  setIdentity with no staleness guard, unlike every other
  reconciliation path in the file — a stale background sync could
  snap the user back to a conversation they'd already navigated away
  from, since IDENTITY_SET always wins.
- The reducer wrapper in usePageAgentDashboardStore wrote to the store
  even when the transition was a guaranteed no-op (a stale RESOLVED
  after a newer IDENTITY_SET), needlessly notifying every subscriber.
- Making loadConversation adopt identity synchronously removed the
  window where isInitialized/isConversationLoading covered the
  messages fetch — a conversation switch in agent/global mode could
  flash the previous conversation's messages under the new one with
  no loading indicator. Added a decoupled isMessagesLoading /
  isConversationMessagesLoading flag (mirroring AiChatView's existing
  isLoadingMessages) across the store, GlobalChatContext,
  usePageAgentSidebarState, and both consumer views.

227 tests (35 new/updated) across all 11 touched files, all green.
Full typecheck clean. Full web suite: 736/740 files, 11066/11082
tests green (same 4 pre-existing, unrelated failures as before this
change).

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

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

🧹 Nitpick comments (2)
apps/web/src/lib/ai/shared/hooks/useConversationIdentity.ts (1)

50-62: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Guard against redundant resolve() invocations on repeated retry.

runResolve always calls resolveRef.current() even when the corresponding dispatch is a no-op in the reducer (e.g. calling retry() again while already resolving, or RETRY from a non-error state). The reducer correctly prevents a stale result from clobbering state, but it doesn't stop the redundant network/resolve call from firing in the first place — wasteful, and risky if resolve() ever has a non-idempotent side effect.

Mirror the "skip the effect on a no-op transition" pattern already used in usePageAgentDashboardStore's applyIdentity.

♻️ Proposed guard
   const runResolve = useCallback((startAction: { type: 'RESOLVE_STARTED' } | { type: 'RETRY' }) => {
-    dispatch(startAction);
-    resolveRef.current().then(
+    dispatch(startAction);
+    setStateRef.current = conversationIdentityReducer(setStateRef.current, startAction);
+    // only invoke resolve() if the dispatch actually moved us into `resolving`
+    if (setStateRef.current.status !== 'resolving') return;
+    resolveRef.current().then(
       (result) => {
         if (isMountedRef.current) dispatch({ type: 'RESOLVED', conversationId: result.conversationId });
       },
       (error) => {
         if (!isMountedRef.current) return;
         const message = error instanceof Error ? error.message : 'Failed to resolve conversation';
         dispatch({ type: 'RESOLVE_FAILED', message });
       }
     );
   }, []);

(Requires tracking a ref mirroring state alongside the reducer, since runResolve's useCallback has no dependency on the latest state.)

Also applies to: 73-75

🤖 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/lib/ai/shared/hooks/useConversationIdentity.ts` around lines 50
- 62, `runResolve` in `useConversationIdentity` always triggers
`resolveRef.current()` even when the reducer transition is a no-op, so add a
state-mirroring ref and guard the call before dispatching `RESOLVE_STARTED` or
`RETRY`. Update the `retry`/resolve flow to skip invoking `resolve()` when
already resolving or when `RETRY` would not change state, following the same
skip-on-no-op pattern used by `usePageAgentDashboardStore`’s `applyIdentity`.
Make sure the guard covers both the initial retry path and any reused resolve
helper so redundant network calls are avoided.
apps/web/src/app/api/ai/page-agents/[agentId]/conversations/route.ts (1)

181-191: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Test coverage gap: no test for the new client-supplied conversationId path.

The existing route test only covers empty-body, custom-title, and invalid-JSON cases (per the supplied context snippet) — none exercise body.conversationId being honored, rejected, or malformed.

🤖 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/page-agents/`[agentId]/conversations/route.ts around
lines 181 - 191, Add test coverage for the new client-supplied conversation ID
branch in the conversations route. Update the route tests around the request
parsing in route.ts to verify that a valid string body.conversationId is used as
the conversationId instead of createId(), and add cases for malformed/empty
values to confirm the fallback behavior still generates a server-side ID. Keep
the focus on the request.json parsing and conversationId selection logic.
🤖 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/page-agents/`[agentId]/conversations/route.ts:
- Around line 181-191: Validate the client-supplied conversation identifier
before using it in the conversations route, since the current
`body.conversationId` check in `route.ts` accepts any non-empty string. Update
the `conversationId` selection logic to verify the value with `isCuid()` (or the
CUID2 regex) before assigning it, and fall back to `createId()` when validation
fails so only valid row ids are persisted.

In
`@apps/web/src/components/layout/middle-content/page-views/ai-page/AiChatView.tsx`:
- Around line 137-141: The currentConversationIdRef assignment is happening
during render, which can expose an uncommitted value to late-joiner sync logic.
Move the currentConversationIdRef.current update into a useEffect in AiChatView,
matching the pageIdRef pattern, and keep the ref in sync there using
currentConversationId so async callbacks only see committed state.

In `@apps/web/src/contexts/GlobalChatContext.tsx`:
- Around line 147-154: The non-ok path in GlobalChatContext’s message loading
flow leaves the previous conversation’s messages visible after switching
identities. Update the else branch in the fetch logic (alongside the existing
catch handling) to clear initialMessages when messagesResponse.ok is false, and
keep setIsMessagesLoading(false) in place so the new conversation does not
render stale content.

In `@apps/web/src/stores/page-agents/usePageAgentDashboardStore.ts`:
- Around line 275-298: The createNewConversation flow in
usePageAgentDashboardStore leaves isConversationMessagesLoading stuck true when
a new conversation replaces a prior load. Update the same set(...) block that
resets conversationMessages, conversationAgentId, and conversationLoadSignal to
also clear isConversationMessagesLoading, so GlobalAssistantView and
SidebarChatTab stop showing the spinner for the newly created conversation.

---

Nitpick comments:
In `@apps/web/src/app/api/ai/page-agents/`[agentId]/conversations/route.ts:
- Around line 181-191: Add test coverage for the new client-supplied
conversation ID branch in the conversations route. Update the route tests around
the request parsing in route.ts to verify that a valid string
body.conversationId is used as the conversationId instead of createId(), and add
cases for malformed/empty values to confirm the fallback behavior still
generates a server-side ID. Keep the focus on the request.json parsing and
conversationId selection logic.

In `@apps/web/src/lib/ai/shared/hooks/useConversationIdentity.ts`:
- Around line 50-62: `runResolve` in `useConversationIdentity` always triggers
`resolveRef.current()` even when the reducer transition is a no-op, so add a
state-mirroring ref and guard the call before dispatching `RESOLVE_STARTED` or
`RETRY`. Update the `retry`/resolve flow to skip invoking `resolve()` when
already resolving or when `RETRY` would not change state, following the same
skip-on-no-op pattern used by `usePageAgentDashboardStore`’s `applyIdentity`.
Make sure the guard covers both the initial retry path and any reused resolve
helper so redundant network calls are avoided.
🪄 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

Run ID: cb8b1955-d89d-4b90-be9d-3696c266c991

📥 Commits

Reviewing files that changed from the base of the PR and between 98433a0 and 564c0de.

📒 Files selected for processing (21)
  • apps/web/src/app/api/ai/page-agents/[agentId]/conversations/route.ts
  • apps/web/src/components/layout/middle-content/CenterPanel.tsx
  • apps/web/src/components/layout/middle-content/page-views/ai-page/AiChatView.tsx
  • apps/web/src/components/layout/middle-content/page-views/ai-page/__tests__/AiChatView.test.tsx
  • apps/web/src/components/layout/middle-content/page-views/dashboard/GlobalAssistantView.tsx
  • apps/web/src/components/layout/right-sidebar/ai-assistant/SidebarChatTab.tsx
  • apps/web/src/contexts/GlobalChatContext.tsx
  • apps/web/src/contexts/__tests__/GlobalChatContext.test.tsx
  • apps/web/src/hooks/page-agents/__tests__/usePageAgentSidebarState.test.ts
  • apps/web/src/hooks/page-agents/usePageAgentSidebarState.ts
  • apps/web/src/lib/ai/shared/__tests__/conversation-identity.test.ts
  • apps/web/src/lib/ai/shared/agent-conversations.ts
  • apps/web/src/lib/ai/shared/conversation-identity.ts
  • apps/web/src/lib/ai/shared/hooks/__tests__/useConversationIdentity.test.ts
  • apps/web/src/lib/ai/shared/hooks/__tests__/useConversations.test.ts
  • apps/web/src/lib/ai/shared/hooks/index.ts
  • apps/web/src/lib/ai/shared/hooks/useConversationIdentity.ts
  • apps/web/src/lib/ai/shared/hooks/useConversations.ts
  • apps/web/src/lib/ai/shared/index.ts
  • apps/web/src/stores/page-agents/__tests__/usePageAgentDashboardStore.test.ts
  • apps/web/src/stores/page-agents/usePageAgentDashboardStore.ts

Comment thread apps/web/src/components/layout/middle-content/page-views/ai-page/AiChatView.tsx Outdated
Comment thread apps/web/src/contexts/GlobalChatContext.tsx
Comment thread apps/web/src/stores/page-agents/usePageAgentDashboardStore.ts
…stale state

Three real issues from CodeRabbit's review of the previous fix commit:

- The conversations POST route accepted any non-empty string as a
  client-supplied conversationId, letting a malformed value get
  persisted as a row id. Now validated with isCuid() (already used
  elsewhere in this codebase for the same purpose), falling back to
  createId() when it fails.
- GlobalChatContext's loadConversation cleared initialMessages on a
  thrown fetch error but not on a non-ok response — the exact "flash
  the previous conversation's messages under the new identity" bug
  isMessagesLoading was added to prevent, just on a different branch.
  Now both paths clear it.
- usePageAgentDashboardStore's createNewConversation didn't reset
  isConversationMessagesLoading. If a loadConversation fetch was still
  in flight when the user started a new conversation, the flag could
  get stuck true forever (the superseded loadConversation's own
  resolution sees itself stale and skips clearing it), showing a
  permanent loading spinner on the new conversation.

Also evaluated and explicitly declined one CodeRabbit suggestion (move
currentConversationIdRef's update into a useEffect, matching pageIdRef):
doing so broke 2 existing tests because this component uses no
concurrent-rendering features that could discard an in-progress render
(the theoretical hazard the suggestion guards against), while an effect
introduces a real timing gap — an async callback firing before React
flushes a just-scheduled effect reads a stale ref. Replied on the PR
thread explaining the tradeoff instead of applying it.

10 new/updated tests across 5 files, all green. Full typecheck clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JT8umuwBmmtKiwmBhyq5uH
@2witstudios 2witstudios merged commit 53c498a into master Jul 5, 2026
10 checks passed
@2witstudios 2witstudios deleted the pu/convo-bu branch July 5, 2026 18:52
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