Skip to content

feat(platform): add optimistic messages for existing chat threads#627

Merged
larryro merged 2 commits into
mainfrom
feat/chat-optimistic-messages-existing-threads
Mar 2, 2026
Merged

feat(platform): add optimistic messages for existing chat threads#627
larryro merged 2 commits into
mainfrom
feat/chat-optimistic-messages-existing-threads

Conversation

@larryro

@larryro larryro commented Mar 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Adds optimistic (instant) user messages when sending in existing threads, not just new ones. Uses a lastMessageKey field on pending messages to anchor optimistic messages to the correct position and detect when the real message arrives.
  • Improves loading state reliability by tracking terminalAssistantCount — the hook now clears isPending when a new assistant response is committed to the thread, covering edge cases where isGenerating never flips to true.
  • Backend: adds structured error logging and retry-safe message creation in internal_actions.ts to prevent silent failures during agent chat processing.

Test plan

  • Unit tests for useChatLoadingState covering terminal assistant count transitions, baseline resets, and subsequent sends
  • Unit tests for usePendingMessages covering existing thread append, real message replacement, pagination stability, cross-thread isolation, and no-duplicate guarantees
  • Manual: send a message in an existing thread and verify the optimistic message appears instantly, then is replaced by the real message without duplication
  • Manual: verify loading spinner clears reliably when the assistant responds

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Enhanced optimistic message handling that displays messages immediately while awaiting server confirmation, with intelligent clearing based on actual message delivery
    • Failed assistant messages are now properly persisted when generation errors occur
  • Bug Fixes

    • Improved loading state detection with fallback mechanisms for slow network scenarios

…ove loading reliability

Extend optimistic message support to existing threads using a lastMessageKey
baseline captured at send time to detect when real messages arrive. Add
terminalAssistantCount as a React 18 batching-immune backup for clearing
isPending on slow networks. Save failed assistant messages on generation errors
so the frontend loading state always resolves.

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

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@coderabbitai

coderabbitai Bot commented Mar 2, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

The PR introduces a terminalAssistantCount metric that counts completed (successful or failed) assistant messages across the chat system. This count is propagated from useMessageProcessing through useChatLoadingState to enable a network-resilience fallback that clears pending state when assistant messages complete. Additionally, the PR reworks optimistic message handling by introducing a lastMessageKey baseline captured at send time, allowing proper synchronization of optimistic messages for both new and existing threads—appending them during flight and removing them when real messages arrive. Backend error handling in agent actions is enhanced to signal failures and persist failed message records to prevent duplicates.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately describes the main change: adding optimistic messages for existing chat threads, which is the primary feature across the file modifications.
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/chat-optimistic-messages-existing-threads

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

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

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@services/platform/app/features/chat/hooks/__tests__/use-chat-loading-state.test.ts`:
- Around line 559-583: Add a second assertion in the existing test that
simulates hydration jump from terminalAssistantCount 0 to a positive N while
isPending remains true and isGenerating is false: renderHook using
useChatLoadingState with initialProps terminalAssistantCount: 0 and isPending:
true, then rerender with terminalAssistantCount: (e.g. 1) and the same
isPending/isGenerating/pendingThreadId/threadId, and assert setIsPending was not
called with false; reference the existing renderHook/rerender calls, the
useChatLoadingState hook, and the setIsPending mock to implement this 0 -> N
regression case.

In `@services/platform/app/features/chat/hooks/use-chat-loading-state.ts`:
- Around line 29-32: Update the JSDoc in use-chat-loading-state.ts to reference
the correct React versions: replace the "React 18" mention with a
version-agnostic phrase like "React 18+" (or explicitly "React 18+ / React
19.2.4") so the comment about batching behavior that affects isGenerating and
the terminalAssistantCount fallback is accurate for the project's React 19.2.4;
ensure the sentence still explains that batching can coalesce the isGenerating
true→false toggle and that terminalAssistantCount is a monotonically-increasing
backup.
- Around line 75-81: The conditional in use-chat-loading-state.ts redundantly
null-coalesces pendingThreadId; update the if-statement so that after checking
pendingThreadId !== null you directly compare pendingThreadId to threadId (e.g.,
use pendingThreadId !== threadId) instead of (pendingThreadId ?? null) !==
(threadId ?? null), leaving the surrounding checks (isPending, pendingThreadId
!== null) and the call to setIsPending(false) intact to preserve behavior;
reference the variables isPending, pendingThreadId, threadId and the
setIsPending call when locating the change.

In `@services/platform/app/features/chat/hooks/use-pending-messages.ts`:
- Around line 37-41: The current isMatchingThread logic incorrectly treats
threadId === undefined as a wildcard; change the predicate in
use-pending-messages (the isMatchingThread check using pendingMessage.threadId
and threadId) to only match when pendingMessage.threadId === threadId or
pendingMessage.threadId === 'pending' (remove the threadId === undefined
condition) so new-chat route doesn't clear/surface unrelated threads; apply the
same fix to the second occurrence of the same check later in the file (the other
isMatchingThread instance around the 65-71 logic).

In `@services/platform/convex/lib/agent_chat/internal_actions.ts`:
- Around line 331-345: The current caller-side guard in the block that calls
ctx.runQuery(components.agent.streams.list, ...) then
ctx.runMutation(internal.streaming.internal_mutations.errorStream, ...) still
leaves a race where a stream can become aborted after the query; move the
protection into the mutation itself by implementing the check+write atomically
inside the errorStream mutation (read the stream status and only set error if
status !== 'aborted' in the same transaction) so callers (including this code
path using ctx.runMutation(internal.streaming.internal_mutations.errorStream, {
streamId })) no longer need the non-atomic pre-check; alternatively, if you
prefer the existing pattern, add a short comment referencing PR `#397` and
explicitly document that caller-side silent-early-return guards are the chosen
convention.

ℹ️ Review info

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4e94f86 and 6e2d0bc.

📒 Files selected for processing (9)
  • services/platform/app/features/chat/components/chat-interface.tsx
  • services/platform/app/features/chat/context/chat-layout-context.tsx
  • services/platform/app/features/chat/hooks/__tests__/use-chat-loading-state.test.ts
  • services/platform/app/features/chat/hooks/__tests__/use-pending-messages.test.ts
  • services/platform/app/features/chat/hooks/use-chat-loading-state.ts
  • services/platform/app/features/chat/hooks/use-message-processing.ts
  • services/platform/app/features/chat/hooks/use-pending-messages.ts
  • services/platform/app/features/chat/hooks/use-send-message.ts
  • services/platform/convex/lib/agent_chat/internal_actions.ts

Comment on lines +559 to +583
it('handles terminalAssistantCount of 0 when messages not yet loaded', () => {
const { rerender } = renderHook((props) => useChatLoadingState(props), {
initialProps: {
isPending: true,
setIsPending,
isGenerating: false,
threadId: THREAD_A,
pendingThreadId: THREAD_A,
terminalAssistantCount: 0,
},
});

expect(setIsPending).not.toHaveBeenCalledWith(false);

rerender({
isPending: true,
setIsPending,
isGenerating: false,
threadId: THREAD_A,
pendingThreadId: THREAD_A,
terminalAssistantCount: 0,
});

expect(setIsPending).not.toHaveBeenCalledWith(false);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Add a hydration-jump regression case for terminal baseline logic.

This case only validates 0 -> 0. Please add 0 -> N (with isPending: true, isGenerating: false) to ensure historical message hydration doesn’t clear pending prematurely.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@services/platform/app/features/chat/hooks/__tests__/use-chat-loading-state.test.ts`
around lines 559 - 583, Add a second assertion in the existing test that
simulates hydration jump from terminalAssistantCount 0 to a positive N while
isPending remains true and isGenerating is false: renderHook using
useChatLoadingState with initialProps terminalAssistantCount: 0 and isPending:
true, then rerender with terminalAssistantCount: (e.g. 1) and the same
isPending/isGenerating/pendingThreadId/threadId, and assert setIsPending was not
called with false; reference the existing renderHook/rerender calls, the
useChatLoadingState hook, and the setIsPending mock to implement this 0 -> N
regression case.

Comment on lines +29 to +32
* On slow networks (3G), React 18 may batch the isGenerating true→false toggle
* into a single render, so the handoff never fires. `terminalAssistantCount`
* acts as a backup: it's a monotonically-increasing integer that cannot be
* coalesced away by batching.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Minor: JSDoc references React 18 but project uses React 19.

The batching behavior described applies to React 18+, including React 19.2.4 which this project uses. Consider updating the reference for accuracy.

📝 Suggested documentation update
- * On slow networks (3G), React 18 may batch the isGenerating true→false toggle
+ * On slow networks (3G), React may batch the isGenerating true→false toggle
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
* On slow networks (3G), React 18 may batch the isGenerating true→false toggle
* into a single render, so the handoff never fires. `terminalAssistantCount`
* acts as a backup: it's a monotonically-increasing integer that cannot be
* coalesced away by batching.
* On slow networks (3G), React may batch the isGenerating true→false toggle
* into a single render, so the handoff never fires. `terminalAssistantCount`
* acts as a backup: it's a monotonically-increasing integer that cannot be
* coalesced away by batching.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/app/features/chat/hooks/use-chat-loading-state.ts` around
lines 29 - 32, Update the JSDoc in use-chat-loading-state.ts to reference the
correct React versions: replace the "React 18" mention with a version-agnostic
phrase like "React 18+" (or explicitly "React 18+ / React 19.2.4") so the
comment about batching behavior that affects isGenerating and the
terminalAssistantCount fallback is accurate for the project's React 19.2.4;
ensure the sentence still explains that batching can coalesce the isGenerating
true→false toggle and that terminalAssistantCount is a monotonically-increasing
backup.

Comment on lines +75 to +81
if (
isPending &&
pendingThreadId !== null &&
(pendingThreadId ?? null) !== (threadId ?? null)
) {
setIsPending(false);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Nit: Redundant nullish coalescing on pendingThreadId.

Since line 77 already checks pendingThreadId !== null, the (pendingThreadId ?? null) on line 78 always evaluates to pendingThreadId. This can be simplified.

♻️ Simplified condition
     // Thread mismatch: navigated away from the pending thread
     if (
       isPending &&
       pendingThreadId !== null &&
-      (pendingThreadId ?? null) !== (threadId ?? null)
+      pendingThreadId !== (threadId ?? null)
     ) {
       setIsPending(false);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/app/features/chat/hooks/use-chat-loading-state.ts` around
lines 75 - 81, The conditional in use-chat-loading-state.ts redundantly
null-coalesces pendingThreadId; update the if-statement so that after checking
pendingThreadId !== null you directly compare pendingThreadId to threadId (e.g.,
use pendingThreadId !== threadId) instead of (pendingThreadId ?? null) !==
(threadId ?? null), leaving the surrounding checks (isPending, pendingThreadId
!== null) and the call to setIsPending(false) intact to preserve behavior;
reference the variables isPending, pendingThreadId, threadId and the
setIsPending call when locating the change.

Comment on lines +37 to +41
const isMatchingThread =
pendingMessage.threadId === threadId ||
threadId === undefined ||
pendingMessage.threadId === 'pending';
if (!isMatchingThread) return;

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.

⚠️ Potential issue | 🟠 Major

Do not treat threadId === undefined as a wildcard thread match.

Current matching can surface or clear pending state from unrelated threads on the new-chat route.

💡 Proposed fix
-    const isMatchingThread =
-      pendingMessage.threadId === threadId ||
-      threadId === undefined ||
-      pendingMessage.threadId === 'pending';
+    const isMatchingThread =
+      pendingMessage.threadId === threadId ||
+      (threadId === undefined && pendingMessage.threadId === 'pending');
...
-    const isMatchingThread =
-      pendingMessage &&
-      (pendingMessage.threadId === threadId ||
-        threadId === undefined ||
-        pendingMessage.threadId === 'pending');
+    const isMatchingThread =
+      pendingMessage &&
+      (pendingMessage.threadId === threadId ||
+        (threadId === undefined && pendingMessage.threadId === 'pending'));

Also applies to: 65-71

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/app/features/chat/hooks/use-pending-messages.ts` around
lines 37 - 41, The current isMatchingThread logic incorrectly treats threadId
=== undefined as a wildcard; change the predicate in use-pending-messages (the
isMatchingThread check using pendingMessage.threadId and threadId) to only match
when pendingMessage.threadId === threadId or pendingMessage.threadId ===
'pending' (remove the threadId === undefined condition) so new-chat route
doesn't clear/surface unrelated threads; apply the same fix to the second
occurrence of the same check later in the file (the other isMatchingThread
instance around the 65-71 logic).

Comment on lines +331 to +345
try {
// Check for user-initiated abort to avoid overwriting cancellation status
const streams = await ctx.runQuery(components.agent.streams.list, {
threadId,
statuses: ['aborted'],
});
const alreadyAborted = streams.some(
(s: { streamId: string }) => s.streamId === streamId,
);
if (!alreadyAborted) {
await ctx.runMutation(
internal.streaming.internal_mutations.errorStream,
{ streamId },
);
}

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.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find streaming internal mutation files
echo "=== Searching for streaming internal mutations ==="
fd "internal_mutations" services/platform/convex --type f

# Search for errorStream implementation
echo -e "\n=== Searching for errorStream definition ==="
rg -n "errorStream|export.*errorStream" services/platform/convex --type ts

# Look for stream status handling logic
echo -e "\n=== Searching for stream status transitions and aborted checks ==="
rg -n "status.*aborted|aborted.*status|streamStatus" services/platform/convex --type ts -A 3 -B 1

# Inspect the specific mutation function
echo -e "\n=== Looking for error/fail stream mutations ==="
rg -n "export.*const.*(error|fail)Stream|function.*Stream" services/platform/convex/lib --type ts -A 5

Repository: tale-project/tale

Length of output: 14162


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the errorStream mutation implementation
echo "=== errorStream mutation implementation ==="
sed -n '70,120p' services/platform/convex/streaming/internal_mutations.ts

# Also check stream schema to understand status field
echo -e "\n=== Stream status schema and mutations ==="
rg -n "streamStatus|status.*streaming|finished|aborted" services/platform/convex/streaming -A 2 -B 2 --type ts | head -80

# Look at full errorStream definition
echo -e "\n=== Full errorStream context ==="
rg -n "export const errorStream" services/platform/convex/streaming/internal_mutations.ts -A 20

Repository: tale-project/tale

Length of output: 1212


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for setStreamStatus implementation
echo "=== Searching for setStreamStatus implementation ==="
rg -n "setStreamStatus|export.*setStreamStatus" services/platform/convex --type ts -A 10 | head -60

# Check persistentTextStreaming lib
echo -e "\n=== Looking in persistentTextStreaming ==="
find services/platform/convex -path "*persistentTextStreaming*" -name "*.ts" -type f | head -20

# Check for any status validation before setting
echo -e "\n=== Checking for status transition logic ==="
rg -n "status.*error|error.*status|transition" services/platform/convex/streaming --type ts -B 2 -A 2

Repository: tale-project/tale

Length of output: 5488


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check test file that mentions skipping errorStream for aborted streams
echo "=== Test context for aborted stream handling ==="
sed -n '523,570p' services/platform/convex/lib/agent_response/__tests__/generate_response_error_handling.test.ts

# Check if there are comments or docs about race conditions
echo -e "\n=== Searching for race condition handling comments ==="
rg -n "race|race condition|abort.*before|concurrent" services/platform/convex/lib/agent_chat services/platform/convex/lib/agent_response --type ts -B 2 -A 2

# Look at the internal_actions.ts code more fully to see context
echo -e "\n=== Full context of the guard in internal_actions.ts ==="
sed -n '325,350p' services/platform/convex/lib/agent_chat/internal_actions.ts

Repository: tale-project/tale

Length of output: 5104


The guard is already present, but the race window persists with current best-effort approach.

The code at line 340 already checks if (!alreadyAborted) before calling errorStream—this is intentional to avoid overwriting cancellation status (as stated in the comment). However, the race condition between the list query (lines 333–336) and the mutation invocation (line 342) still exists: the stream can transition to aborted after the check completes.

For truly atomic semantics, move the guard into the errorStream mutation itself (read the current status + conditionally write in a single transaction). This would close the race window entirely. Alternatively, document that the caller-side guard is the accepted pattern for this codebase (similar to approval mutations in PR #397, which use silent early returns for race protection).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/convex/lib/agent_chat/internal_actions.ts` around lines 331
- 345, The current caller-side guard in the block that calls
ctx.runQuery(components.agent.streams.list, ...) then
ctx.runMutation(internal.streaming.internal_mutations.errorStream, ...) still
leaves a race where a stream can become aborted after the query; move the
protection into the mutation itself by implementing the check+write atomically
inside the errorStream mutation (read the stream status and only set error if
status !== 'aborted' in the same transaction) so callers (including this code
path using ctx.runMutation(internal.streaming.internal_mutations.errorStream, {
streamId })) no longer need the non-atomic pre-check; alternatively, if you
prefer the existing pattern, add a short comment referencing PR `#397` and
explicitly document that caller-side silent-early-return guards are the chosen
convention.

…cleanup

Extract derived scalars (currentLastKey, hasMessages) outside the effect
to avoid re-running on every streaming content update. Export the params
type for proper test typing.
@larryro larryro merged commit c60e236 into main Mar 2, 2026
16 checks passed
@larryro larryro deleted the feat/chat-optimistic-messages-existing-threads branch March 2, 2026 06:34
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