Skip to content

fix(platform): improve chat streaming stability and UX#599

Merged
Israeltheminer merged 3 commits into
mainfrom
fix/chat-streaming-stability
Feb 27, 2026
Merged

fix(platform): improve chat streaming stability and UX#599
Israeltheminer merged 3 commits into
mainfrom
fix/chat-streaming-stability

Conversation

@Israeltheminer
Copy link
Copy Markdown
Collaborator

@Israeltheminer Israeltheminer commented Feb 27, 2026

Summary

  • Handle aborted stream status as terminal in useChatLoadingState and useMessageProcessing to prevent stuck loading indicators after stream cancellation
  • Move abortController initialization earlier in generate_response.ts to ensure availability across all error paths
  • Add early return for cancelled generation (finishReason === 'cancelled') in agent action to skip validation
  • Improve chat input with proper send Button component, Tooltip for attach, and aria-label accessibility
  • Fix sidebar rename to fall back to "Untitled" on empty input instead of showing error toast
  • Improve sidebar chat item click target area and add aria-label

Test plan

  • Send a message and verify streaming completes with loading indicator clearing correctly
  • Verify chat input send button is visible and accessible (keyboard navigation, screen reader)
  • Rename a chat to empty string — should save as "Untitled" instead of showing error
  • Verify sidebar chat items are clickable across the full row area
  • Verify attach button tooltip appears on hover

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Added send button with arrow icon to chat input
    • Added agent selector to chat header
    • New tooltip for attachment action
  • Improvements

    • Chat history rename now uses double-click to edit (single-click navigates)
    • Improved rename validation with auto-default for empty names
    • Send button intelligently requires text or attachments before enabling
    • Better handling of cancelled message states

Handle 'aborted' stream status as terminal in loading state and message
processing to prevent stuck loading indicators. Move abortController
initialization earlier to ensure availability in error paths. Add early
return for cancelled generation in agent action. Improve chat input with
proper send button, attach tooltip, and accessibility. Fix sidebar rename
to fall back to "Untitled" on empty input and improve click target area.
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Feb 27, 2026

Greptile Summary

This PR addresses chat streaming stability issues and improves UX with better accessibility and error handling.

Key Changes:

  • Fixed stuck loading indicators by treating aborted stream status as terminal in both useChatLoadingState and useMessageProcessing
  • Moved abortController initialization earlier in generate_response.ts to ensure it's available across all error paths
  • Added early return for cancelled generation to skip validation when user cancels
  • Enhanced chat input with visible send Button, Tooltip for attach button, and proper aria-label attributes
  • Improved sidebar rename UX by falling back to "Untitled" instead of showing error toast
  • Expanded sidebar chat item click target to full row area for better usability

All changes are well-structured, maintain consistency across the codebase, and follow existing patterns.

Confidence Score: 5/5

  • This PR is safe to merge with no issues found
  • All changes are focused improvements with clear intent, proper error handling, and consistent implementation. The streaming status handling is correctly implemented across both frontend hooks, the backend changes ensure proper cleanup, and UI improvements follow established patterns with good accessibility support.
  • No files require special attention

Important Files Changed

Filename Overview
services/platform/app/features/chat/components/chat-history-sidebar.tsx Removed empty title validation with toast, now falls back to "Untitled"; improved click target area with absolute positioning and proper z-index layering
services/platform/app/features/chat/components/chat-input.tsx Added send Button component with ArrowUp icon and aria-label; wrapped attach button in Tooltip; improved accessibility
services/platform/app/features/chat/hooks/use-chat-loading-state.ts Added aborted as terminal status to prevent stuck loading indicators after stream cancellation
services/platform/app/features/chat/hooks/use-message-processing.ts Added aborted status handling to remove streaming keys and prevent stuck streaming state
services/platform/convex/lib/agent_chat/internal_actions.ts Added early return for cancelled generation (finishReason === 'cancelled') to skip validation
services/platform/convex/lib/agent_response/generate_response.ts Moved abortController initialization before try block to ensure availability across all error paths
services/platform/messages/en.json Added "send": "Send message" translation key for chat input send button aria-label

Last reviewed commit: f7b953d

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Feb 27, 2026

📝 Walkthrough

Walkthrough

This pull request introduces multiple enhancements to the chat feature spanning UI interaction, component structure, and abort/cancel handling. Changes include: refactoring the chat history sidebar's rename interaction to use two-phase click detection (single-click navigate, double-click rename) with updated DOM structure; updating the chat input component with new UI primitives (Tooltip, Button) and adding a send button with ArrowUp icon; treating 'aborted' as a terminal status in message processing and loading-state hooks; adding early return handling for cancelled generation results; consolidating AbortController usage in response generation; and adding a new translation label for send messaging.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(platform): improve chat streaming stability and UX' directly aligns with the PR's main objectives: addressing streaming stability (aborted status handling, abort controller initialization) and UX improvements (chat input button, rename behavior, accessibility).

✏️ 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 fix/chat-streaming-stability

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


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

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
services/platform/app/features/chat/components/chat-history-sidebar.tsx (1)

58-58: ⚠️ Potential issue | 🟡 Minor

Clear pending click timeout on unmount to avoid delayed stale navigation.

clickTimeoutRef is set in Line 237 but never cleaned up on unmount. The delayed callback can still fire after teardown.

💡 Proposed fix
   useEffect(() => {
     const onKeyDown = (e: KeyboardEvent) => {
       const isMod = isMac ? e.metaKey : e.ctrlKey;
@@
     window.addEventListener('keydown', onKeyDown);
     return () => window.removeEventListener('keydown', onKeyDown);
   }, [isMac, onSearchOpen, onNewChat]);
+
+  useEffect(() => {
+    return () => {
+      if (clickTimeoutRef.current) {
+        clearTimeout(clickTimeoutRef.current);
+        clickTimeoutRef.current = null;
+      }
+    };
+  }, []);

Also applies to: 231-241

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

In `@services/platform/app/features/chat/components/chat-history-sidebar.tsx` at
line 58, The clickTimeoutRef used in chat-history-sidebar.tsx (set via
setTimeout in the click handler that schedules navigation) is never cleared on
unmount; add a cleanup to the component to clear and nullify clickTimeoutRef to
prevent delayed stale navigation: in the component body create or update a
useEffect with a return cleanup function that checks clickTimeoutRef.current,
calls clearTimeout on it if present, and then sets clickTimeoutRef.current to
null so the delayed callback cannot run after unmount.
🤖 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/use-chat-loading-state.ts`:
- Around line 61-67: The current return logic in use-chat-loading-state.ts can
keep loading true when a tool-turn was cancelled mid-run because it still
requires !isUnfinishedToolTurn(lastMessage) even for the 'aborted' status;
update the condition around lastMessage.status and isUnfinishedToolTurn so that
'aborted' is treated as a terminal state regardless of isUnfinishedToolTurn,
i.e., consider status === 'aborted' as sufficient to stop loading, while keeping
the existing requirement that status === 'success' || status === 'failed' also
require !isUnfinishedToolTurn(lastMessage); adjust the boolean expression that
references lastMessage, status, and isUnfinishedToolTurn accordingly.

---

Outside diff comments:
In `@services/platform/app/features/chat/components/chat-history-sidebar.tsx`:
- Line 58: The clickTimeoutRef used in chat-history-sidebar.tsx (set via
setTimeout in the click handler that schedules navigation) is never cleared on
unmount; add a cleanup to the component to clear and nullify clickTimeoutRef to
prevent delayed stale navigation: in the component body create or update a
useEffect with a return cleanup function that checks clickTimeoutRef.current,
calls clearTimeout on it if present, and then sets clickTimeoutRef.current to
null so the delayed callback cannot run after unmount.

ℹ️ Review info

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c85cc3c and f7b953d.

📒 Files selected for processing (7)
  • services/platform/app/features/chat/components/chat-history-sidebar.tsx
  • services/platform/app/features/chat/components/chat-input.tsx
  • services/platform/app/features/chat/hooks/use-chat-loading-state.ts
  • services/platform/app/features/chat/hooks/use-message-processing.ts
  • services/platform/convex/lib/agent_chat/internal_actions.ts
  • services/platform/convex/lib/agent_response/generate_response.ts
  • services/platform/messages/en.json

Comment thread services/platform/app/features/chat/hooks/use-chat-loading-state.ts
…rn state

When cancellation happens mid-tool-call, the last message part is a tool
part, causing isUnfinishedToolTurn to return true. Previously this kept
isLoading stuck at true even though the stream was aborted. Now aborted
status short-circuits to not-loading, bypassing the tool turn check.
@Israeltheminer Israeltheminer merged commit 9a802f8 into main Feb 27, 2026
17 checks passed
@Israeltheminer Israeltheminer deleted the fix/chat-streaming-stability branch February 27, 2026 22:53
@larryro
Copy link
Copy Markdown
Collaborator

larryro commented Feb 28, 2026

Post-merge review: streaming stability changes

Good intent — fixing stuck loading spinners on abort is important. The UI improvements (send button, tooltip, sidebar hit target) are solid. However, the core abort-handling logic is built on incorrect assumptions about the Convex Agent SDK, meaning the key streaming fixes are dead code and the original bug remains.


Core issue: 'aborted' never reaches UIMessage

The Convex Agent SDK's statusFromStreamStatus() in deltas.ts maps stream states to UIMessage statuses:

case "streaming": return "streaming";
case "finished":  return "success";
case "aborted":   return "failed";   // ← abort becomes "failed"

UIMessage.status is typed as "streaming" | "pending" | "success" | "failed" — there is no 'aborted' value. The SDK stores { kind: "aborted", reason: "..." } in the stream DB document, but this gets mapped to "failed" by the time it reaches the frontend.

This means three changes in this PR are dead code:

File Line Code Why it's dead
use-chat-loading-state.ts 64 if (status === 'aborted') return false UIMessage status is never 'aborted', always 'failed'
use-message-processing.ts 207 m.status === 'aborted' Same — 'failed' on line 206 already covers it
internal_actions.ts 277 if (result.finishReason === 'cancelled') 'cancelled' is not a valid FinishReason in AI SDK or Convex Agent ("stop" | "length" | "content-filter" | "tool-calls" | "error" | "other" | "unknown"). When abortController.abort() fires, the AI SDK throws an AbortError — it doesn't return a result.

The const status: string | undefined type widening on line 61 masks this — it bypasses TypeScript's type checking to allow comparing against a value that isn't in the union. The test's status: 'aborted' as UIMessage['status'] type cast is also a signal: the type system is correctly saying this value doesn't exist.

The real bug (unfixed)

The actual bug is: when status === 'failed' AND isUnfinishedToolTurn() returns true, the loading state gets stuck forever.

The old logic:

return !(
  lastMessage.role === 'assistant' &&
  !isUnfinishedToolTurn(lastMessage) &&       // false when tool turn incomplete
  (lastMessage.status === 'success' || lastMessage.status === 'failed')
);
// → !(true && false && true) → !(false) → true → loading forever

A failed/aborted generation mid-tool-call has unfinished tool parts, so isUnfinishedToolTurn returns true, and the whole condition never resolves. But a failed message will never complete its tool turn — no more steps will come.

Recommended fix

Make 'failed' unconditionally terminal, bypassing the tool-turn check:

if (lastMessage.role !== 'assistant') return true;
if (lastMessage.status === 'failed') return false;  // always terminal, even mid-tool-call
return !(!isUnfinishedToolTurn(lastMessage) && lastMessage.status === 'success');

Then remove the dead 'aborted' checks in use-message-processing.ts and the 'cancelled' check in internal_actions.ts. Update tests to cover status: 'failed' + unfinished tool parts (the actual bug scenario) instead of the fictional 'aborted' status.

What's correct in this PR

  • generate_response.ts: Moving abortController outside try is a real, necessary fix — ensures it's accessible in error/cleanup paths
  • chat-input.tsx: Send button with proper disable logic and a11y is good
  • chat-history-sidebar.tsx: Full-surface click target with absolute inset-0 is a nice UX improvement
  • messages/en.json: Translation addition is correct

Minor UI notes

  • Sidebar input min-h-[1.5rem] (24px) vs display span min-h-[20px] (20px) — slight visual jump when entering/exiting edit mode
  • Rename empty fallback to "Untitled" is fine UX-wise, but history.toast.titleEmpty may now be dead i18n

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