Skip to content

Refactor streaming and add message edit/delete endpoints - #395

Merged
2witstudios merged 3 commits into
masterfrom
claude/fix-conversation-state-reset-9vtBh
Feb 6, 2026
Merged

2witstudios merged 3 commits into
masterfrom
claude/fix-conversation-state-reset-9vtBh

Conversation

@2witstudios

@2witstudios 2witstudios commented Feb 5, 2026 •

Copy link
Copy Markdown
Owner

Summary

This PR refactors the AI chat streaming implementation to use the new createUIMessageStream API for better server-side processing continuity, adds message edit/delete endpoints for page agents, optimizes transport recreation in chat components to prevent unnecessary state resets, and extracts shared chat hooks to reduce duplication.

Key Changes

Streaming Architecture

  • Global Assistant Chat API: Refactored to use createUIMessageStream and createUIMessageStreamResponse instead of direct toUIMessageStreamResponse() call
    • Ensures server-side processing continues even if client disconnects
    • Properly handles usage tracking via promise-based approach
    • Sends server-generated message ID to client at stream start

New Endpoints

  • Page Agent Message Edit (PATCH /api/ai/page-agents/[agentId]/conversations/[conversationId]/messages/[messageId])

    • Updates message content and sets editedAt timestamp
    • Includes permission checks, isActive guard, and activity logging
  • Page Agent Message Delete (DELETE /api/ai/page-agents/[agentId]/conversations/[conversationId]/messages/[messageId])

    • Soft deletes messages by setting isActive to false
    • Returns 404 for already-deleted messages
    • Includes permission checks and activity logging

Shared Chat Hooks (new)

Extracted 3 composable hooks from duplicated patterns across 4 chat components:

  • useChatTransport — Stable DefaultChatTransport instance that only recreates when conversation ID or API endpoint changes
  • useStreamingRegistration — Registers/unregisters streaming state with useEditingStore
  • useChatStop — Memoized stop function composing server abort + client stop via try/finally

Component Optimizations

  • Transport Ref Pattern: Replaced manual ref patterns in AiChatView, GlobalAssistantView, SidebarChatTab, GlobalChatContext with useChatTransport hook
  • Streaming Registration: Replaced identical useEffect blocks with useStreamingRegistration hook
  • Stop Handler: Replaced duplicated stop callbacks with useChatStop hook

Message Actions

  • Optimistic Updates: Enhanced useMessageActions hook
    • Edit: Optimistically updates local state immediately, with separate error handling for patch vs refetch failures
    • Delete: Optimistically removes message, reverts on failure

Code Quality

  • Removed emoji prefixes from log messages for consistency
  • Simplified logging statements
  • Merged duplicate imports
  • Removed identity .then() transform

How to Validate

  1. Open a page agent chat, send a message, verify streaming works and stop button works
  2. Open global assistant, send a message, verify streaming completes and message is saved
  3. Switch between global and agent mode in GlobalAssistantView — verify no state reset
  4. Open sidebar chat, switch agents — verify clean transitions
  5. Edit a message — verify optimistic update shows immediately
  6. Delete a message — verify optimistic removal with rollback on failure

Implementation Notes

  • Usage tracking uses promise-based approach (usagePromise) for async token counting
  • Stream chunks continue to be written to client even if connection drops, ensuring onFinish fires
  • Transport refs prevent recreation during streaming, which was causing useChat to reset and lose message state
  • Net reduction: ~96 lines removed across modified files

… message recovery

- Add missing PATCH/DELETE routes for page-agent messages that caused 404 HTML errors
  when editing/deleting messages in agent conversation mode
- Wrap Global Assistant streaming in createUIMessageStream so server-side processing
  continues even if client disconnects (matches page chat route pattern)
- Stabilize DefaultChatTransport creation using refs in all chat views
  (AiChatView, GlobalAssistantView, SidebarChatTab, GlobalChatContext) to prevent
  useChat from resetting state when transport instances are recreated
- Add optimistic local updates for message editing so edits are preserved
  even when the server save fails

https://claude.ai/code/session_019a3TCSNUwiz1SCRXePfUvG
@coderabbitai

coderabbitai Bot commented Feb 5, 2026 •

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

This PR refactors AI chat streaming and message handling by extracting transport, streaming state, and stop logic into reusable hooks. It introduces a new API route for editing and deleting conversation messages with permissions validation, and adds optimistic UI updates for message actions to improve user experience.

Changes

Cohort / File(s) Summary
API Streaming & Message Routes
apps/web/src/app/api/ai/global/[id]/messages/route.ts, apps/web/src/app/api/ai/page-agents/.../messages/[messageId]/route.ts
Updated global messages route to use createUIMessageStream wrapper with usage tracking. Added new PATCH and DELETE handlers for message editing/deletion with permission checks, content validation, and audit logging.
Shared Hooks Infrastructure
apps/web/src/lib/ai/shared/hooks/index.ts, apps/web/src/lib/ai/shared/hooks/useChatTransport.ts, apps/web/src/lib/ai/shared/hooks/useChatStop.ts, apps/web/src/lib/ai/shared/hooks/useStreamingRegistration.ts
Created three new hooks: useChatTransport provides stable transport instances, useChatStop coordinates server abort with client stop, and useStreamingRegistration manages streaming state lifecycle. Updated exports index.
UI Component Hook Integration
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/contexts/GlobalChatContext.tsx
Replaced direct DefaultChatTransport instantiation with useChatTransport hook, removed manual streaming state management in favor of useStreamingRegistration, and integrated useChatStop for unified stop handling.
Message Actions Optimization
apps/web/src/lib/ai/shared/hooks/useMessageActions.ts
Added optimistic UI updates for message edits and deletions; updates local state immediately before API calls with rollback on failure.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 With hooks extracted and state aligned,
Transport and streaming now redesigned,
Messages dance—edit, delete with grace,
Optimistic updates shine in place,
A rabbit hops through refactored space! ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely summarizes the main changes: refactoring streaming implementation and adding message edit/delete endpoints. It clearly identifies the primary objectives without being vague or overly broad.
Docstring Coverage ✅ Passed Docstring coverage is 87.50% which is sufficient. The required threshold is 80.00%.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch claude/fix-conversation-state-reset-9vtBh

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 and usage tips.

@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.

… stop patterns

Deduplicates transport stability, streaming registration, and stop handler
patterns that were copy-pasted across 4 chat components into 3 composable hooks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@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

🤖 Fix all issues with AI agents
In
`@apps/web/src/app/api/ai/page-agents/`[agentId]/conversations/[conversationId]/messages/[messageId]/route.ts:
- Around line 52-61: The message retrieval uses
chatMessageRepository.getMessageById which does not check the soft-delete flag,
so add an isActive guard after fetching the message: after const message = await
chatMessageRepository.getMessageById(messageId) and the not-found check, verify
message.isActive === true and return a 404 JSON (same shape as other errors) if
false; apply the same isActive check to the DELETE handler path (so deleting an
already soft-deleted message returns 404 instead of re-soft-deleting).

In `@apps/web/src/lib/ai/shared/hooks/useMessageActions.ts`:
- Around line 118-123: The current catch around the entire edit flow conflates
failures of patch() and the subsequent fetchWithAuth(), causing a misleading
"failed to sync" toast even when patch() succeeded; update useMessageActions.ts
to split the operations into two try/catch blocks: first await patch(...) and on
its failure show the existing "failed to edit" toast and revert/handle as
before, then in a separate try call fetchWithAuth(...) to refresh the cache and
on its failure log the error and show a distinct, accurate toast (e.g., "Edit
saved to server but failed to refresh local data") without undoing the
optimistic update; reference the existing patch() call and fetchWithAuth() call
in your changes and ensure console.error/processLogger entries reflect which
step failed.
🧹 Nitpick comments (7)
apps/web/src/app/api/ai/page-agents/[agentId]/conversations/[conversationId]/messages/[messageId]/route.ts (2)

3-4: Merge duplicate import sources.

canUserEditPage and loggers are both imported from @pagespace/lib/server on separate lines.

♻️ Suggested fix
-import { canUserEditPage } from '@pagespace/lib/server';
-import { loggers } from '@pagespace/lib/server';
+import { canUserEditPage, loggers } from '@pagespace/lib/server';

72-90: Activity logging is actually blocking despite the "non-blocking" comment.

await getActorInfo(userId) on line 74 is awaited, which delays the response. The same pattern appears in the DELETE handler (line 160). If latency matters, fire-and-forget the entire block:

♻️ Suggested approach (for both PATCH and DELETE)
-    // Log activity for audit trail (non-blocking)
-    try {
-      const actorInfo = await getActorInfo(userId);
-      logMessageActivity(userId, 'message_update', { ... }, actorInfo, { ... });
-    } catch (loggingError) {
-      loggers.api.error('Failed to log agent message update activity', loggingError as Error, { ... });
-    }
+    // Log activity for audit trail (non-blocking)
+    getActorInfo(userId)
+      .then((actorInfo) => {
+        logMessageActivity(userId, 'message_update', { ... }, actorInfo, { ... });
+      })
+      .catch((loggingError) => {
+        loggers.api.error('Failed to log agent message update activity', loggingError as Error, { ... });
+      });

Alternatively, if the await is intentional, update the comment to say "// Log activity for audit trail".

apps/web/src/lib/ai/shared/hooks/useMessageActions.ts (2)

67-126: messages in useCallback deps causes handler instability during streaming.

All three handlers capture messages directly and include it in their dependency arrays, producing a new function identity on every message update. During streaming this means rapid re-creation, which can trigger unnecessary re-renders in any component that receives these handlers as props.

A useRef for the latest messages avoids the dependency while keeping reads fresh:

♻️ Suggested refactor
+import { useCallback, useRef, useEffect } from 'react';
-import { useCallback } from 'react';

 export function useMessageActions({
   ...
   messages,
   setMessages,
   ...
 }: UseMessageActionsOptions): UseMessageActionsResult {
+  const messagesRef = useRef(messages);
+  useEffect(() => { messagesRef.current = messages; }, [messages]);

   const handleEdit = useCallback(
     async (messageId: string, newContent: string) => {
       if (!conversationId) return;
-      const updatedMessages = messages.map((m) => {
+      const updatedMessages = messagesRef.current.map((m) => {
         ...
       });
       setMessages(updatedMessages);
       ...
     },
-    [isAgentMode, agentId, conversationId, messages, setMessages, onEditVersionChange]
+    [isAgentMode, agentId, conversationId, setMessages, onEditVersionChange]
   );

Apply the same pattern to handleDelete and handleRetry.


160-203: Sequential deletes in handleRetry could be parallelized.

Lines 173-185 await each deletion sequentially. When multiple assistant messages need cleanup, Promise.allSettled would be faster and still handle partial failures gracefully.

♻️ Suggested refactor
-      for (const msg of assistantMessagesToDelete) {
-        try {
-          if (isAgentMode) {
-            await del(
-              `/api/ai/page-agents/${agentId}/conversations/${conversationId}/messages/${msg.id}`
-            );
-          } else {
-            await del(`/api/ai/global/${conversationId}/messages/${msg.id}`);
-          }
-        } catch (error) {
-          console.error('Failed to delete old assistant message:', error);
-        }
-      }
+      await Promise.allSettled(
+        assistantMessagesToDelete.map((msg) => {
+          const url = isAgentMode
+            ? `/api/ai/page-agents/${agentId}/conversations/${conversationId}/messages/${msg.id}`
+            : `/api/ai/global/${conversationId}/messages/${msg.id}`;
+          return del(url).catch((error) =>
+            console.error('Failed to delete old assistant message:', error)
+          );
+        })
+      );
apps/web/src/lib/ai/shared/hooks/useChatTransport.ts (1)

23-29: api parameter changes are not tracked — transport won't update if only api changes.

The recreation condition at Line 23 only checks conversationId. If api changes while conversationId stays the same, the stale transport with the old api will be returned. Currently this is safe because callers always couple api with conversationId, but it's a subtle implicit contract.

Consider including api in the tracking check to make the hook robust against future misuse:

♻️ Suggested fix
- const trackingIdRef = useRef<string | null>(null);
+ const trackingIdRef = useRef<string | null>(null);
+ const apiRef = useRef<string>(api);

  if (!conversationId) {
    return null;
  }

- if (trackingIdRef.current !== conversationId || !transportRef.current) {
+ if (trackingIdRef.current !== conversationId || apiRef.current !== api || !transportRef.current) {
    transportRef.current = new DefaultChatTransport({
      api,
      fetch: createStreamTrackingFetch({ chatId: conversationId }),
    });
    trackingIdRef.current = conversationId;
+   apiRef.current = api;
  }
apps/web/src/app/api/ai/global/[id]/messages/route.ts (1)

740-801: Solid streaming refactor using createUIMessageStream for server-side processing continuity.

The architecture correctly ensures onFinish callbacks (DB save, usage tracking) run even if the client disconnects. The usagePromise closure timing is correct — execute completes before onFinish fires, so the promise is always assigned.

One minor nit: the .then((usage) => usage) on Line 786 is an identity transform that can be dropped:

♻️ Simplify usagePromise
-      usagePromise = aiResult.totalUsage
-        .then((usage) => usage)
-        .catch((error) => {
+      usagePromise = aiResult.totalUsage
+        .catch((error) => {
apps/web/src/components/layout/middle-content/page-views/ai-page/AiChatView.tsx (1)

153-166: Non-null assertion on transport! is fragile — prefer an explicit guard.

transport is DefaultChatTransport | null per useChatTransport's return type. It's currently safe because streamTrackingId is always a string (currentConversationId || page.id), so the hook never returns null. However, the ! assertion silently suppresses null checking and could mask a runtime error if streamTrackingId becomes nullable in the future.

In contrast, GlobalAssistantView.tsx uses the safer pattern: the agentChatConfig memo returns null when transport is null (line 265), and useChat receives agentChatConfig || {}.

Consider matching that pattern here:

Proposed guard for null transport
  const chatConfig = useMemo(
-    () => ({
+    () => !transport ? null : ({
       id: page.id,
       messages: initialMessages,
-      transport: transport!,
+      transport,
       experimental_throttle: 100,
       onError: (error: Error) => {
         console.error('AiChatView: Chat error:', error);
       },
     }),
     [page.id, transport, initialMessages]
   );
 
   const { messages, sendMessage, status, error, regenerate, setMessages, stop: chatStop } =
-    useChat(chatConfig);
+    useChat(chatConfig || {});

Comment thread apps/web/src/lib/ai/shared/hooks/useMessageActions.ts
…picks

- Add isActive check to PATCH/DELETE handlers to prevent editing/deleting
  soft-deleted messages
- Split edit error handling in useMessageActions so patch() failure and
  refetch failure show accurate error toasts
- Merge duplicate imports in route.ts
- Track api parameter changes in useChatTransport for robustness
- Remove transport! non-null assertion in AiChatView, use null guard
- Remove identity .then() transform in global messages route
- Fix "non-blocking" comment on blocking activity logging

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@2witstudios

Copy link
Copy Markdown
Owner Author

Review Feedback Addressed (193e497)

Actionable Comments

1. Missing isActive check (route.ts PATCH/DELETE) — Fixed. Both handlers now return 404 for soft-deleted messages. Also merged duplicate @pagespace/lib/server imports and corrected the "non-blocking" comment on blocking activity logging.

2. Misleading error toast in useMessageActions edit flow — Fixed. Split into two try/catch blocks: patch() failure shows "Failed to save edit", while refetch failure is silently caught (edit already persisted on server, optimistic update remains).

Nitpicks Addressed

  • Merge duplicate imports (route.ts:3-4) — Merged into single import.
  • Activity logging comment (route.ts:72,158) — Removed "non-blocking" since it awaits.
  • api parameter tracking (useChatTransport.ts) — Added apiRef so transport recreates if API endpoint changes independently.
  • Identity .then((usage) => usage) (global messages route:786) — Removed.
  • transport! non-null assertion (AiChatView.tsx:160) — Replaced with null guard pattern matching GlobalAssistantView's safer approach.

Nitpicks Acknowledged (not changed)

  • messages in useCallback deps — Acknowledged as a real issue but out of scope for this PR (noted in plan). Will address separately.
  • Sequential deletes in handleRetry — Valid optimization, but Promise.allSettled changes error semantics. Will consider in a follow-up.

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.

2 participants