Skip to content

feat(ai): deterministic chat rendering + synchronous send with contextRef - #2072

Merged
2witstudios merged 5 commits into
masterfrom
pu/e1-flash-and-send
Jul 14, 2026
Merged

feat(ai): deterministic chat rendering + synchronous send with contextRef#2072
2witstudios merged 5 commits into
masterfrom
pu/e1-flash-and-send

Conversation

@2witstudios

@2witstudios 2witstudios commented Jul 14, 2026

Copy link
Copy Markdown
Owner

Summary

Both PRs from the "Deterministic Chat Rendering & Send" epic (pagespace pages read l4w75179xdk5flyfkin184c2) are now combined in this single PR against master. PR #2074 (leaves 2.1-2.7) was originally stacked on this branch and has since been merged into it, so this PR now carries the full epic scope (leaves 1.1-2.7) plus two post-merge fixes from CI/self-review convergence.

Part 1 — flash-free chat rendering (leaves 1.1-1.9)

  • selectMessagesAreaMode (pure fn, 100% branch): skeleton only when loading AND zero messages AND zero streams. Wired into ChatMessagesArea (replacing a bare isLoading ternary that also hid remote streams/the streaming indicator on every loading tick) and into SidebarChatTab's in-place messages indicator.
  • resolveInputPosition (pure fn, 100% branch): latches the docked composer position per conversationId so a mid-refetch race can't flash the input back to centered for a conversation that plainly has content. Wired into ChatLayout; the messages-panel AnimatePresence is keyed by conversationId.
  • SidebarChatTab: no longer gates the whole tab on isMessagesLoading — only !isInitialized does. isMessagesLoading is an in-place indicator inside the messages pane.
  • useSendHandoff.wrapSend: async rejections (not just sync throws) now clear pendingSend immediately and surface the error via toast.error(getAIErrorMessage(...)).

Part 2 — synchronous send with server-resolved context (leaves 2.1-2.7)

  • buildContextRef (pure fn, 100% branch): synchronously parses the current pathname into { routeType, pageId?, driveId?, dmConversationId? } — no network, no await.
  • resolveRequestContext (server, lib/ai/core): permission-checks the ref at request time via canPrincipalViewPage/isPrincipalDriveMember and DENIES (resolves to null) a contextRef pointing at a page/drive the caller cannot view — closing an information-disclosure gap the old client-trusted pageContext/locationContext fields had no defense against.
  • getPageBreadcrumbTrail: the recursive-CTE breadcrumb walk factored out of the breadcrumbs route into a shared function.
  • Both /api/ai/chat and /api/ai/global/[id]/messages accept a top-level contextRef; legacy pageContext/locationContext fields are honored only when no contextRef is sent (kept 1+ release).
  • All 3 surfaces (AiChatView, SidebarChatTab, GlobalAssistantView) + voice-send + ask-user-answer body builders build contextRef synchronously — nothing is awaited between click and sendMessage.
  • Deleted the now-dead buildPageContext() builder and its test file.

Post-merge convergence fixes (this session)

  • CI-caught regression, fixed: ChatMessagesArea.remoteStreams.test.tsx had a pre-existing assertion pinning the OLD "suppress remote streams whenever isLoading" behavior that selectMessagesAreaMode deliberately supersedes. remoteStreams is already conversation-scoped by every caller before reaching this component, so showing an already-scoped stream during a loading tick is correct per the epic's own goal ("streams stay visible through conversation switches, refreshes..."). Split the stale assertion into two that match the new, intentional contract.
  • Self-review finding, fixed: both AI routes resolved contextRef (a permission check + DB round-trip) before the messages/chatId required-field validation. Reordered so invalid requests fail fast without the extra DB round-trip.

Spec -> test mapping

Leaf Test Coverage
1.2/1.3 selectMessagesAreaMode streams/__tests__/selectMessagesAreaMode.test.ts 100% branch
1.4/1.5 resolveInputPosition streams/__tests__/resolveInputPosition.test.ts 100% branch
1.6 SidebarChatTab gate component wiring only — validated by typecheck + existing SidebarChatTab.test.tsx (46 tests) n/a
1.7/1.8 useSendHandoff async rejection isThenable.test.ts (100% branch) + hooks/__tests__/useSendHandoff.test.ts 100% branch on isThenable
2.2/2.3 buildContextRef shared/__tests__/buildContextRef.test.ts 100% branch
2.4/2.5 resolveRequestContext core/__tests__/resolve-request-context.test.ts (13 cases incl. DENY paths) 100% branch
2.6 send-path cutover existing SidebarChatTab.test.tsx/GlobalAssistantView.test.tsx (unaffected), global-chat-request-body.test.ts extended n/a

D tasks filed

None.

Validation

  • bun run typecheck (apps/web, direct tsc --noEmit): green, repeatedly confirmed.
  • Targeted bun vitest run: all new/changed unit + route tests green; pre-existing chat/global route suites (mcp-scope, credit-gate, credit-abort, stream-socket-events, sandbox-github-suppression, conversation-id-resolution, resolve-or-create-conversation — 103 tests) re-confirmed unaffected after the reordering fix.
  • eslint on all touched files: clean, zero warnings.
  • CI: initial run (13313 tests) surfaced exactly 1 failure — the stale ChatMessagesArea.remoteStreams.test.tsx assertion above, now fixed. Note that render tests which fail locally in this .pu worktree with ReferenceError: React is not defined (an environment-specific quirk) run and pass fine in real CI — confirmed since only 1/13313 tests failed in the actual CI environment.
  • Root-level bun run typecheck (turbo) intermittently fails with TS6053 against stale .next/types/** generated stubs, reproducing even after a clean next build — an environmental turbo/.next cache flake (correlates with concurrent next builds from other worktrees on this machine), not a defect in this diff. Direct tsc --noEmit is the authoritative signal and is clean.
  • Manual verification (bubble+Stop in same tick on cold cache) not performed by me — no browser/UI automation tool available in this environment; flagged for reviewer.

Test plan

  • Reviewer manually verifies: sidebar/AiChatView/GlobalAssistantView sends show optimistic bubble + Stop button in the same tick, even on a cold location-context cache
  • Reviewer manually verifies: no list-to-skeleton flash, no composer centered/docked jump, New Chat still centers
  • Reviewer spot-checks the DENY path: a contextRef pointing at a page/drive the requester cannot view resolves to no location context (not an error, not leaked data)

🤖 Generated with Claude Code

https://claude.ai/code/session_01MQpdoGj1j2gRPYE9n9cypX

Closes the structural-gate symptoms from the "Deterministic Chat Rendering
& Send" epic (leaves 1.1-1.9):

- selectMessagesAreaMode: pure decision — skeleton only when loading AND
  zero messages AND zero streams; keeps the list/content on screen for
  loading-with-content (background refetch, rejoin) instead of swapping to
  a skeleton. Wired into ChatMessagesArea (replacing the bare `isLoading`
  ternary that also unintentionally hid remote streams and the streaming
  indicator during any loading tick) and into SidebarChatTab's in-place
  messages-pane indicator.
- resolveInputPosition: pure decision with an explicit, caller-owned latch
  — docked position is latched per conversationId so a mid-refetch race
  (isLoading/hasMessages both transiently false) can't flash the composer
  back to centered for a conversation that plainly has content. A genuinely
  fresh, empty conversation (New Chat) still centers correctly since the
  latch never carries over to a different conversationId. Wired into
  ChatLayout; the messages-panel AnimatePresence is now keyed by
  conversationId (clean remount on conversation switch) and gated on the
  latched docked decision instead of raw hasMessages/isLoading.
- SidebarChatTab no longer gates its entire subtree (header + input +
  messages) on `isMessagesLoading` — only `!isInitialized` does that now.
  `isMessagesLoading` becomes an in-place indicator inside the messages
  pane, reusing the same selectMessagesAreaMode rule and the same rendering
  slot the global-mode fetch indicator already used.
- useSendHandoff.wrapSend: the old synchronous try/catch missed
  asynchronous rejections (sendFn is often `async () => { await
  contextPromise; sendMessage(...) }`), leaving pendingSend registered
  until the 15s safety timeout with no visible error. A new isThenable()
  pure guard routes both the synchronous-throw and async-rejection paths
  through one settle function that clears pendingSend immediately and
  surfaces the error via toast + getAIErrorMessage (never raw
  error.message).

Spec -> test mapping:
- 1.2/1.3 selectMessagesAreaMode -> streams/__tests__/selectMessagesAreaMode.test.ts
  (100% branch), wired in ChatMessagesArea.tsx + SidebarChatTab.tsx
- 1.4/1.5 resolveInputPosition -> streams/__tests__/resolveInputPosition.test.ts
  (100% branch), wired in ChatLayout.tsx
- 1.6 SidebarChatTab gate -> component change, no new pure fn; validated by
  typecheck + existing SidebarChatTab.test.tsx pure-fn suite (unaffected)
- 1.7/1.8 useSendHandoff async rejection -> isThenable.test.ts (100% branch)
  + hooks/__tests__/useSendHandoff.test.ts (renderHook, real
  useEditingStore, mocked sonner)

Validation: `bun run typecheck` (apps/web) green. Targeted vitest green:
isThenable, selectMessagesAreaMode, resolveInputPosition (all 100% branch),
useSendHandoff, ChatLayout remoteStreams plumbing, SidebarChatTab pure-fn
suite (46 tests, unaffected).

Known-broken in this .pu worktree (environmental, pre-existing, unrelated
to this diff — confirmed via git stash before touching any code):
SidebarMessagesContent.test.tsx fails with "ReferenceError: React is not
defined" on every `render()` call. Not modified by this PR; must be
re-verified on the main checkout/CI per the epic's stated render-test
caveat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQpdoGj1j2gRPYE9n9cypX
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@2witstudios, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 37 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8c686f07-876a-4530-85e2-3d2dfbf7ef89

📥 Commits

Reviewing files that changed from the base of the PR and between d50475e and 5c85721.

📒 Files selected for processing (27)
  • apps/web/src/app/api/ai/chat/route.ts
  • apps/web/src/app/api/ai/global/[id]/messages/route.ts
  • apps/web/src/app/api/pages/[pageId]/breadcrumbs/route.ts
  • apps/web/src/components/ai/chat/layouts/ChatLayout.tsx
  • apps/web/src/components/ai/chat/layouts/__tests__/ChatLayout.remoteStreams.test.tsx
  • apps/web/src/components/ai/shared/chat/ChatMessagesArea.tsx
  • apps/web/src/components/ai/shared/chat/__tests__/ChatMessagesArea.remoteStreams.test.tsx
  • apps/web/src/components/layout/middle-content/page-views/ai-page/AiChatView.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/lib/ai/core/__tests__/resolve-request-context.test.ts
  • apps/web/src/lib/ai/core/resolve-request-context.ts
  • apps/web/src/lib/ai/shared/__tests__/buildContextRef.test.ts
  • apps/web/src/lib/ai/shared/__tests__/buildPageContext.test.ts
  • apps/web/src/lib/ai/shared/__tests__/global-chat-request-body.test.ts
  • apps/web/src/lib/ai/shared/buildContextRef.ts
  • apps/web/src/lib/ai/shared/buildPageContext.ts
  • apps/web/src/lib/ai/shared/global-chat-request-body.ts
  • apps/web/src/lib/ai/shared/hooks/__tests__/useSendHandoff.test.ts
  • apps/web/src/lib/ai/shared/hooks/useSendHandoff.ts
  • apps/web/src/lib/ai/streams/__tests__/isThenable.test.ts
  • apps/web/src/lib/ai/streams/__tests__/resolveInputPosition.test.ts
  • apps/web/src/lib/ai/streams/__tests__/selectMessagesAreaMode.test.ts
  • apps/web/src/lib/ai/streams/isThenable.ts
  • apps/web/src/lib/ai/streams/resolveInputPosition.ts
  • apps/web/src/lib/ai/streams/selectMessagesAreaMode.ts
  • apps/web/src/lib/pages/get-page-breadcrumb-trail.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pu/e1-flash-and-send

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.

2witstudios and others added 3 commits July 14, 2026 14:56
…2074)

PR 2 of 2 from the "Deterministic Chat Rendering & Send" epic (leaves
2.1-2.7), stacked on pu/e1-flash-and-send. Removes the #2060-regression
awaited context fetch (resolveLocationContext -> /api/pages/:id +
/breadcrumbs, 0.5-3s, deliberately uncached) that delayed the optimistic
bubble, status:'submitted', streaming registration, and Stop button on
every send from the sidebar and global assistant.

- buildContextRef (new pure fn, 100% branch): synchronously parses the
  current pathname into { routeType, pageId?, driveId?, dmConversationId? }
  — no network, no await. Reuses parseTabPath (tab-title.ts) rather than
  re-deriving route parsing.
- resolveRequestContext (new, apps/web/src/lib/ai/core): server-side
  counterpart. Permission-checks the ref at request time via
  canPrincipalViewPage / isPrincipalDriveMember (principal-permissions.ts,
  MCP + OAuth + session aware) and DENIES (resolves to null/undefined,
  never throws) a contextRef pointing at a page/drive the caller cannot
  view — closing an information-disclosure gap the old client-trusted
  pageContext/locationContext body fields had no defense against at all.
- getPageBreadcrumbTrail (apps/web/src/lib/pages): the recursive-CTE
  breadcrumb walk factored out of /api/pages/[pageId]/breadcrumbs/route.ts
  into a shared function — one source of truth for both the breadcrumb
  bar and the AI resolver, instead of two independent walks.
- Both /api/ai/chat and /api/ai/global/[id]/messages accept a top-level
  contextRef field; when present, it is the sole source of location
  context (resolved + permission-checked server-side) — the legacy
  pageContext/locationContext body fields are still honored ONLY when no
  contextRef is sent, kept 1+ release for any client bundle that hasn't
  picked up this change yet.
- All 3 surfaces (AiChatView, SidebarChatTab, GlobalAssistantView) plus
  their voice-send and ask-user-answer body builders now build contextRef
  synchronously (AiChatView: page.id/driveId already known as props, no
  pathname parsing needed; Sidebar/GlobalAssistantView: buildContextRef
  from pathname+drives) and send it inline with wrapSend — nothing is
  awaited between click and sendMessage. GlobalAssistantView's UI-display
  `locationContext` state/effect (welcome text, mention-picker driveId) is
  untouched; only its send-time value changed from that effect-derived
  state to a synchronous contextRef, per the exact pattern documented in
  resolveLocationContext.ts's own docstring (effect for display, fresh
  synchronous value for sends).
- Deleted the now-fully-dead buildPageContext() (fetchBreadcrumbs-awaiting
  builder in buildPageContext.ts) and its test file — zero remaining
  callers after the AiChatView/Sidebar cutover. locationContextToPageContext
  (still used by chat/route.ts to adapt the resolver's nested output to the
  flat pageContext shape) is unchanged.

Spec -> test mapping:
- 2.2/2.3 buildContextRef -> shared/__tests__/buildContextRef.test.ts (100% branch)
- 2.4/2.5 resolveRequestContext -> core/__tests__/resolve-request-context.test.ts
  (13 cases incl. the page-caller-cannot-view and drive-caller-not-member DENY
  paths; 100% branch on the new module)
- 2.6 send-path cutover -> existing SidebarChatTab.test.tsx (46 tests, pure-fn
  suite, unaffected), existing GlobalAssistantView.test.tsx (22 tests,
  unaffected), global-chat-request-body.test.ts extended with contextRef
  passthrough cases (14 tests)
- Refactor-only: breadcrumbs route.test.ts (8 tests) verified unchanged
  behavior after factoring getPageBreadcrumbTrail out

Validation:
- `bun run typecheck` (apps/web, direct `tsc --noEmit`): green, repeatedly
  confirmed. The ROOT `bun run typecheck` (turbo) intermittently fails with
  TS6053 "file not found" against `.next/types/**` generated stubs — this
  reproduces even on a totally clean `next build` immediately beforehand
  (confirmed: every failure is TS6053 against Next's generated route-type
  stubs, zero real semantic errors) and correlates with other worktrees on
  this machine running concurrent `next build`s. Environmental turbo/.next
  cache flake, not a defect in this diff.
- Targeted `bun vitest run`: all new/changed unit + route tests green (see
  mapping above); pre-existing chat/global route test suites (mcp-scope,
  credit-gate, credit-abort, stream-socket-events, sandbox-github-suppression,
  conversation-id-resolution, resolve-or-create-conversation — 90 tests)
  re-run and confirmed unaffected by the route wiring changes.
- Manual verification ("sidebar send shows bubble+Stop in same tick on cold
  cache") NOT performed by me — no browser/UI automation tool is available
  in this environment. Automated coverage (route + pure-fn tests) is green;
  this click-through needs a reviewer with browser access, same caveat
  pattern as PR 1's render-path verification.


Claude-Session: https://claude.ai/code/session_01MQpdoGj1j2gRPYE9n9cypX

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…tract

ChatMessagesArea.remoteStreams.test.tsx had a pre-existing assertion
("given remoteStreams but isLoading=true, suppresses synthesized renders")
that pinned the OLD behavior selectMessagesAreaMode (leaf 1.2/1.3 of this
epic) deliberately replaces: skeleton only when loading AND zero messages
AND zero streams.

CI caught this as a genuine regression (this render test doesn't hit the
"React is not defined" environment issue that blocks it in .pu worktrees,
so it never ran locally during implementation) — 1 failed, 13306 passed.

The old assertion encoded a stale assumption: that a loading conversation
has nothing worth showing yet. But `remoteStreams` is already
conversation-scoped by every caller before it reaches this component (see
AiChatView's `remoteStreams` selector comment) — a stream present here
genuinely belongs to the conversation being displayed, so hiding it behind
a skeleton during a switch/refresh is exactly the flash this epic exists
to eliminate. Epic goal: "streams stay visible through conversation
switches, refreshes..."; epic-level verification: "switch conversation and
back shows live stream."

Split the one stale assertion into two that match the new, intentional
contract:
- loading + a stream present -> stream still renders (content beats skeleton)
- loading + zero messages + zero streams -> still suppressed (skeleton owns
  the area)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQpdoGj1j2gRPYE9n9cypX
Self-review finding (proactive pass, no reviewer comment yet): both routes
computed pageContext/locationContext from contextRef (a permission check +
DB round-trip) BEFORE the "messages required" / "chatId required" checks.
An invalid request (missing messages or chatId) paid for that DB work
before being rejected with 400. Moved both resolutions to right after their
respective required-field checks so invalid requests fail fast.

No behavior change for valid requests — same resolution, same result,
just later in the function. Confirmed via existing route test suites
(chat + global messages, 103 tests) all still green, plus a full
typecheck pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQpdoGj1j2gRPYE9n9cypX
@2witstudios 2witstudios changed the title fix(ai): flash-free chat rendering (structural gates + send handoff) feat(ai): deterministic chat rendering + synchronous send with contextRef Jul 14, 2026
Self-review finding (proactive pass): resolveRequestContext denied a
contextRef pointing at a page the caller can't view or a drive they aren't
a member of by silently resolving to null — correct for the AI prompt
(never leak the resource), but unlike the sibling authz check right next
to it in the same route (checkMCPPageScope, which audits
'mcp_page_scope_denied'), the denial left no audit trail at all. Repeated
contextRef probing would go completely unobserved.

Added an optional onAccessDenied callback to resolveRequestContext, fired
only for an actual authorization denial (view/membership check failed) —
not for benign misses (routeType 'dm'/'other', missing pageId/driveId, or
a page that passed the view check but no longer exists). Both routes wire
it to auditRequest with the same eventType/shape convention already used
for every other denial in these routes (authz.access.denied, riskScore
0.3 — lower than the outright-blocked mcp_page_scope_denied at 0.5, since
this degrades gracefully to "no context" rather than blocking the
request).

10 test cases added (20 total, still 100% branch) covering: denial fires
for page/drive, does NOT fire for successful resolution, empty trail,
missing contextRef, 'other' routeType, or when no callback is passed.

Also folds in a small stale-comment tidy in SidebarChatTab.tsx
(buildSidebarChatRequestBody's docblock still said "freshly-resolved
location" after the contextRef cutover).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQpdoGj1j2gRPYE9n9cypX
@2witstudios
2witstudios merged commit da307ac into master Jul 14, 2026
10 checks passed
@2witstudios
2witstudios deleted the pu/e1-flash-and-send branch July 14, 2026 21:02
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