Skip to content

Conversation

xavierliang
Copy link

@xavierliang xavierliang commented Aug 17, 2025

Summary

This PR supersedes and replaces #159. It includes all improvements from #159 (stabilizing streaming message rendering and post-completion sync), and adds a robust Sidebar refresh mechanism for newly created sessions.

Brought over from #159

  • Streaming ownership guard: render incremental updates only for the active session using activeCursorStreamSessionIdRef and pendingCursorViewingSessionIdRef.
  • Auto-navigation stabilization: when session-created arrives, auto-navigate only from / and avoid double-handling by advancing the processed index before returning.
  • Post-completion authoritative sync (Cursor): after cursor-result, silently reload and replace history if the fetched log is richer or differs.
  • Prevent cross-session contamination and flicker during streaming; preserve current chat state when background updates arrive.

New in this PR

  • Sidebar refresh on session-created: schedule a single delayed refresh (same behavior as clicking the Sidebar refresh button), and cancel it if an incoming projects_updated already includes the new session.
  • Relax additive-update guard so Sidebar updates remain visible during active sessions (Cursor always treated as additive; Claude checks by id only).

Related issues

Test plan

  • Create new session → Sidebar shows the new session once; no infinite refresh.
  • Switch to another session while creating a new one → background refresh still occurs; no UI flicker; selected session unaffected.
  • Stream long outputs → increments only render for the correct session; no contamination across sessions.
  • After completion (Cursor), history silently reloaded and replaced only when richer/different.

Summary by CodeRabbit

  • New Features

    • Added full support for Cursor sessions, including persistent session data and selection from the Sidebar.
    • Expanded Cursor model options (auto, gpt-5, sonnet-4.5, sonnet-4.5-thinking, opus-4.1, grok) with an always-visible model selector when using Cursor.
  • Bug Fixes

    • Reduced duplicate and out-of-order messages during streaming; final messages are consolidated reliably.
    • Improved session navigation to avoid unexpected jumps and duplicate redirects.
  • Chores

    • Optimized background refreshes and debounced Sidebar updates to prevent unnecessary reloads and preserve selections.

- Live updates: incremental WS processing with immutable updates; dedupe final text; lock streaming ownership to the intended session; ignore increments when viewing another session; stable keys. (Claude unaffected)
- Post-completion sync: after cursor-result, silently reload authoritative session history and replace only if richer/different; guard by current viewed session and clear timers on session change.

File: src/components/ChatInterface.jsx
…n init

- Only navigate to /session/:id when current URL is '/'
- Avoid clearing selectedSession for Cursor when projects_updated lacks cursorSessions
- Sidebar refresh now also fetches cursorSessions to update sessions list properly

This addresses rapid page switches (flashing) and stale sidebar sessions.
- On receiving session-created with a sessionId, if current URL is '/',
  navigate to /session/:id and mark as system session change to preserve
  current chat content. This matches manual clicking behavior and stops
  the flicker loop.
- Navigate once to /session/:id when at '/' using a ref guard
- Avoid await/prefetch to prevent async issues and request bursts
- Remove automatic /api/cursor/config fetch from ChatInterface
- Mount only active tab (Files/Shell/Git not mounted on Chat)
- Avoid auto refreshProjects calls after cursor session completes
…n-update

- Trigger the exact same logic as clicking the Sidebar refresh button (`handleSidebarRefresh`) when a new real session id arrives via WebSocket.
- Schedule a single delayed refresh (800ms) to avoid racing backend persistence; cancel the pending refresh if a `projects_updated` containing the session arrives first.
- Loosen additive-update guard so Sidebar can update during active sessions (Cursor always additive; Claude checks existence by id only).
- Prevent WS message reprocessing loop in ChatInterface by advancing processed index before early return on auto navigation.
@xavierliang xavierliang changed the title fix(sidebar): refresh on session-created; stabilize streaming message render fix(sidebar): refresh on session-created; stabilize streaming and navigation (supersedes #159) Aug 17, 2025
@viper151
Copy link
Contributor

@xavierliang Can you check if the PR is still needed with the v1.8.1 changes in the sessions ?

@xavierliang
Copy link
Author

Yes, it's still needed

@viper151
Copy link
Contributor

@xavierliang thanks. are you able to resolve the conflicts and test it again?

- 将 sonnet-4 更新为 sonnet-4.5
- 添加更多支持的模型选项:auto, sonnet-4.5-thinking, grok
- 更新模型映射以支持从配置文件读取
- 修复 'Cannot use this model: sonnet-4' 错误
Copy link

coderabbitai bot commented Sep 30, 2025

Walkthrough

Introduces incremental WebSocket processing, additive-update gating, and timer-managed Sidebar refreshes. Adds comprehensive streaming/state management for Cursor sessions: chunk accumulation, finalization, session-guarded updates, silent post-stream refresh, expanded model mapping, and navigation deduping. Selection preservation and URL/session routing are adjusted to include Cursor sessions and provider metadata.

Changes

Cohort / File(s) Summary
Session, projects, and WebSocket control
src/App.jsx
Adds refs for projects and last processed index; incremental WebSocket handling; global debounced/cancelable Sidebar refresh timers; additive-only updates when a session is active (Cursor treated additive); selection preservation for project/session including Cursor sessions; merges Cursor sessions in fetch/compare; URL/session routing updated for Cursor.
Streaming, Cursor integration, and UI options
src/components/ChatInterface.jsx
Implements guarded navigation to new sessions; rich streaming state via refs; helpers to append/finalize streaming chunks; session-gated incremental updates; silent refresh after Cursor streaming; dedupe final content; buffered Claude streaming handling; guards on system-driven session changes; extended Cursor model mapping and selector; stable message keys.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant WS as WebSocket
  participant App as App.jsx
  participant Timers as window.__ensureTimers
  participant Sidebar as Sidebar (refresh)
  participant Store as Projects State

  WS->>App: projects_updated / session_created / message events
  App->>App: Check appLastProcessedIndexRef (incremental)
  App->>App: isUpdateAdditive? (Cursor sessions additive)
  alt Active session AND non-additive
    App--xWS: Skip update (protect active session)
  else Additive or no active session
    App->>Store: Merge projects with cursorSessions
    App->>App: Update projectsRef
  end

  opt session_created received
    App->>Timers: schedule one-time Sidebar refresh
  end

  WS-->>App: projects_updated includes session
  App->>Timers: cancel pending refresh (if includes session)

  Timers->>Sidebar: refresh
  Sidebar->>Store: fetch baseProjects
  Sidebar->>Store: attach Cursor session data
  Sidebar->>App: return updated list
  App->>App: Preserve selected project/session if possible
Loading
sequenceDiagram
  autonumber
  participant WS as WebSocket
  participant Chat as ChatInterface.jsx
  participant Cursor as Cursor Provider
  participant Router as Router
  participant Store as Chat State

  Note over Chat: Initialize streaming refs and guards

  WS-->>Chat: claude-response / claude-output deltas
  Chat->>Chat: buffer deltas (accumulate-and-flush)
  Chat->>Store: appendStreamingChunk(session-scoped)

  WS-->>Chat: final result / done
  Chat->>Store: finalizeStreaming -> stable assistant message
  Chat->>Chat: Update lastAssistantTextRef

  alt Provider == Cursor
    Chat->>Chat: Start silent refresh timer
    Chat-->>Cursor: fetch authoritative history (deferred)
    Cursor-->>Chat: history
    Chat->>Store: reconcile without duplicates
  end

  opt session_created or session switch
    Chat->>Chat: Dedup navigate (navigatedToSessionRef)
    alt At root path only
      Chat->>Router: navigate to new session
    else
      Chat--xRouter: no auto-navigation
    end
  end

  Note over Chat: Guards prevent cross-session updates and duplicate finals
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

A twitch of whiskers, streams align,
I nibble bugs where bytes entwine.
Chunks now cuddle, finals chime,
Sessions hop in tidy time.
Cursor’s burrow, models bloom—
Refreshes tiptoe, skip the boom.
Thump! The chat now sings in tune. 🐇✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Out of Scope Changes Check ⚠️ Warning In addition to the streaming fixes required by issue #153, this PR introduces sidebar refresh scheduling and cancellation logic, global timer management, navigation stabilization tied to session creation, expanded Cursor model UI selectors, and extended project/session fetch behavior, all of which lie outside the scope of the linked streaming bug objectives. Please isolate the streaming-rendering fixes into a dedicated PR and split the sidebar refresh, navigation stabilization, and Cursor integration features into separate changes or update the linked issues to explicitly cover these additional scopes.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title clearly and concisely summarizes the primary fixes in this changeset by indicating the sidebar refresh on session creation and the stabilization of streaming and navigation behavior, directly reflecting the main objectives without extraneous or misleading information.
Linked Issues Check ✅ Passed The pull request implements a comprehensive streaming ownership guard with explicit accumulate-and-flush logic (appendStreamingChunk/finalizeStreaming) to eliminate missing characters during live AI responses, ensures the final rendered message matches the post-refresh state without flicker, and validates long continuous streams in real time, fully addressing the objectives of issue #153.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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

@xavierliang
Copy link
Author

Updated PR with latest changes

I've updated this PR to resolve the merge conflicts with the latest main branch (v1.8.10).

Changes included:

  • ✅ Resolved merge conflicts in MainContent.jsx
  • ✅ Added Cursor CLI model name fixes (Previous models unavailable after sonnet 4.5):
    • Updated sonnet-4sonnet-4.5
    • Added support for auto, sonnet-4.5-thinking, and grok models

Ready for review! 🚀

Copy link

@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 (4)
src/components/ChatInterface.jsx (3)

2039-2078: Fix Biome “noSwitchDeclarations”: wrap case bodies with blocks.

Several switch cases declare const/let (e.g., const messageData, const cleaned, const raw, const statusData) without wrapping the clause in a block. This can leak bindings across cases and violates lints. Wrap each affected case body in { ... }.

Apply a representative patch (repeat this pattern for other cases shown in the ranges):

-        case 'claude-response':
-          const messageData = latestMessage.data.message || latestMessage.data;
+        case 'claude-response': {
+          const messageData = latestMessage.data.message || latestMessage.data;
           // ... existing logic ...
-          break;
+          break;
+        }

Do the same for:

  • session-created
  • claude-response
  • claude-output
  • cursor-output
  • cursor-result
  • claude-status

As per static analysis hints.

Also applies to: 2079-2241, 2266-2282, 2331-2423, 2424-2445, 2502-2537


3589-3616: Eliminate double-submit race from button handlers.

The submit button calls handleSubmit on onMouseDown/onTouchStart and the form also has onSubmit. This can send twice before isLoading flips.

Use only the form’s onSubmit (keeps Enter-key behavior) and remove the immediate handlers:

-            <button
-              type="submit"
-              disabled={!input.trim() || isLoading}
-              onMouseDown={(e) => {
-                e.preventDefault();
-                handleSubmit(e);
-              }}
-              onTouchStart={(e) => {
-                e.preventDefault();
-                handleSubmit(e);
-              }}
+            <button
+              type="submit"
+              disabled={!input.trim() || isLoading}
               className="absolute right-2 top-1/2 transform -translate-y-1/2 w-12 h-12 sm:w-12 sm:h-12 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed rounded-full flex items-center justify-center transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:ring-offset-gray-800"
             >

1800-1837: Standardize toolResult shape to match renderer expectations.

convertSessionMessages sets toolResult as a string and toolError separately, but MessageComponent expects message.toolResult to be an object { content, isError }. This mismatch breaks styling and content display.

-              converted.push({
+              converted.push({
                 type: 'assistant',
                 content: '',
                 timestamp: msg.timestamp || new Date().toISOString(),
                 isToolUse: true,
                 toolName: part.name,
                 toolInput: JSON.stringify(part.input),
-                toolResult: toolResult ? (typeof toolResult.content === 'string' ? toolResult.content : JSON.stringify(toolResult.content)) : null,
-                toolError: toolResult?.isError || false,
-                toolResultTimestamp: toolResult?.timestamp || new Date()
+                toolResult: toolResult
+                  ? {
+                      content: typeof toolResult.content === 'string'
+                        ? toolResult.content
+                        : JSON.stringify(toolResult.content),
+                      isError: !!toolResult.isError
+                    }
+                  : null,
+                toolResultTimestamp: toolResult?.timestamp || new Date()
               });
src/App.jsx (1)

233-236: Do not return from the effect mid-loop; advance the processed index and continue.

Early return inside the projects_updated branch exits the effect without updating appLastProcessedIndexRef, causing the same message to be reconsidered on future renders.

Continue the loop and mark the message as processed:

-          if (!isAdditiveUpdate) {
-            // Skip updates that would modify existing selected session/project
-            return;
-          }
+          if (!isAdditiveUpdate) {
+            // Skip updates that would modify existing selected session/project
+            appLastProcessedIndexRef.current = i + 1;
+            continue;
+          }
@@
-      appLastProcessedIndexRef.current = messages.length;
+      appLastProcessedIndexRef.current = messages.length;

Also applies to: 169-176, 268-271

🧹 Nitpick comments (5)
src/components/ChatInterface.jsx (3)

159-167: Avoid reading provider from localStorage in MessageComponent; pass it as a prop.

Reading localStorage inside a memoized child is non-reactive and can desync icons/labels. Pass provider down and use it.

-const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, autoExpandTools, showRawParameters }) => {
+const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, autoExpandTools, showRawParameters, provider }) => {
@@
-                  {(localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? (
+                  {provider === 'cursor' ? (
                     <CursorLogo className="w-full h-full" />
                   ) : (
                     <ClaudeLogo className="w-full h-full" />
                   )}
@@
-                {message.type === 'error' ? 'Error' : message.type === 'tool' ? 'Tool' : ((localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? 'Cursor' : 'Claude')}
+                {message.type === 'error' ? 'Error' : message.type === 'tool' ? 'Tool' : (provider === 'cursor' ? 'Cursor' : 'Claude')}
@@
-                <MessageComponent
+                <MessageComponent
                   key={message.id || index}
                   message={message}
                   index={index}
                   prevMessage={prevMessage}
                   createDiff={createDiff}
                   onFileOpen={onFileOpen}
                   onShowSettings={onShowSettings}
                   autoExpandTools={autoExpandTools}
-                  showRawParameters={showRawParameters}
+                  showRawParameters={showRawParameters}
+                  provider={provider}
                 />
@@
-                <div className="text-sm font-medium text-gray-900 dark:text-white">{(localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? 'Cursor' : 'Claude'}</div>
+                <div className="text-sm font-medium text-gray-900 dark:text-white">{provider === 'cursor' ? 'Cursor' : 'Claude'}</div>

Also applies to: 245-254, 3347-3354, 3326-3336


2113-2119: Remove redundant provider branch around finalizeStreaming.

Both branches call finalizeStreaming() identically; keep a single call for clarity.

-              // Only treat as finalize-with-tracking for Cursor flows
-              if ((localStorage.getItem('selected-provider') || 'claude') === 'cursor') {
-                finalizeStreaming();
-              } else {
-                // For Claude, just mark the streaming message complete without using cursor-specific trackers
-                finalizeStreaming();
-              }
+              finalizeStreaming();

1955-1963: Clear pending timers on unmount as well.

You clear cursorSilentRefreshTimerRef on session change; also clear on component unmount to prevent background updates after teardown.

+  useEffect(() => {
+    return () => {
+      if (cursorSilentRefreshTimerRef.current) {
+        clearTimeout(cursorSilentRefreshTimerRef.current);
+        cursorSilentRefreshTimerRef.current = null;
+      }
+      if (streamTimerRef.current) {
+        clearTimeout(streamTimerRef.current);
+        streamTimerRef.current = null;
+      }
+    };
+  }, []);
src/App.jsx (2)

176-195: Timer hygiene: ensure global Sidebar refresh timers are cleaned up on unmount.

window.__ensureTimers stores timeouts keyed by session id. Add an unmount cleanup to avoid orphan timers across navigations/hot reloads.

+  useEffect(() => {
+    return () => {
+      if (window.__ensureTimers) {
+        for (const t of window.__ensureTimers.values()) {
+          clearTimeout(t);
+        }
+        window.__ensureTimers.clear();
+      }
+    };
+  }, []);

Also applies to: 436-495


382-396: Prefer session-derived provider when selecting a session.

Rely on session.__provider if present; falls back to localStorage only when absent. Prevents mismatches when the global selection differs from session origin.

-  const provider = localStorage.getItem('selected-provider') || 'claude';
-  if (provider === 'cursor') {
+  const providerForSession = session.__provider || localStorage.getItem('selected-provider') || 'claude';
+  if (providerForSession === 'cursor') {
     // Cursor sessions have persistent IDs
     sessionStorage.setItem('cursorSessionId', session.id);
   }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3c9a4ca and 6982c4b.

📒 Files selected for processing (2)
  • src/App.jsx (5 hunks)
  • src/components/ChatInterface.jsx (22 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
src/App.jsx (3)
server/projects.js (4)
  • projects (377-377)
  • messages (724-724)
  • project (400-407)
  • project (473-482)
src/utils/websocket.js (1)
  • messages (5-5)
src/utils/api.js (5)
  • api (23-141)
  • api (23-141)
  • url (53-53)
  • authenticatedFetch (2-20)
  • authenticatedFetch (2-20)
src/components/ChatInterface.jsx (2)
src/utils/websocket.js (1)
  • messages (5-5)
src/App.jsx (2)
  • selectedSession (51-51)
  • selectedProject (50-50)
🪛 Biome (2.1.2)
src/components/ChatInterface.jsx

[error] 2080-2082: Other switch clauses can erroneously access this declaration.
Wrap the declaration in a block to restrict its access to the switch clause.

The declaration is defined in this switch clause:

Safe fix: Wrap the declaration in a block.

(lint/correctness/noSwitchDeclarations)

🔇 Additional comments (1)
src/App.jsx (1)

48-50: Projects ref for late WS handling: LGTM.

Keeping projectsRef synced to state for timer/WS closures avoids stale reads.

Comment on lines +2388 to +2417
try {
const providerNow = localStorage.getItem('selected-provider') || 'claude';
if (providerNow === 'cursor') {
const projectPath = selectedProject?.fullPath || selectedProject?.path;
const sessionToReload = currentSessionId || cursorSessionId;
if (projectPath && sessionToReload) {
// Clear any pending refresh for previous session
if (cursorSilentRefreshTimerRef.current) {
clearTimeout(cursorSilentRefreshTimerRef.current);
}
cursorSilentRefreshTimerRef.current = setTimeout(async () => {
try {
// Guard: only refresh if user is still viewing the same cursor session
const stillCursor = (localStorage.getItem('selected-provider') || 'claude') === 'cursor';
const stillSameSession = selectedSession?.id === sessionToReload;
if (!stillCursor || !stillSameSession) return;
const fetched = await loadCursorSessionMessages(projectPath, sessionToReload);
if (Array.isArray(fetched) && fetched.length) {
setChatMessages(prev => {
const lastFetched = fetched[fetched.length - 1] || {};
const lastPrev = prev[prev.length - 1] || {};
const shouldReplace = fetched.length > prev.length || normalizeText(JSON.stringify(lastFetched)) !== normalizeText(JSON.stringify(lastPrev));
return shouldReplace ? fetched : prev;
});
}
} catch (err) {
console.warn('Silent reload of cursor session failed:', err);
}
}, 250);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Guard silent refresh timer against stale session with a ref.

setTimeout closure in cursor-result checks selectedSession?.id, but it captures a stale value. If the user switches sessions quickly, the silent refresh can replace the wrong chat.

Track the current session id in a ref and read it inside the timer:

@@
-  // Cursor silent refresh timer
+  // Cursor silent refresh timer
   const cursorSilentRefreshTimerRef = useRef(null);
+  // Track current selected session id for async checks
+  const selectedSessionIdRef = useRef(null);
+  useEffect(() => {
+    selectedSessionIdRef.current = selectedSession?.id || null;
+  }, [selectedSession]);
@@
-                cursorSilentRefreshTimerRef.current = setTimeout(async () => {
+                cursorSilentRefreshTimerRef.current = setTimeout(async () => {
                   try {
                     // Guard: only refresh if user is still viewing the same cursor session
-                    const stillCursor = (localStorage.getItem('selected-provider') || 'claude') === 'cursor';
-                    const stillSameSession = selectedSession?.id === sessionToReload;
+                    const stillCursor = (localStorage.getItem('selected-provider') || 'claude') === 'cursor';
+                    const stillSameSession = selectedSessionIdRef.current === sessionToReload;
                     if (!stillCursor || !stillSameSession) return;

Also consider clearing cursorSilentRefreshTimerRef on unmount.

Also applies to: 1169-1176

🤖 Prompt for AI Agents
In src/components/ChatInterface.jsx around lines 2388 to 2417, the setTimeout
closure captures selectedSession?.id causing stale-session refreshes; change to
track the active session id in a ref (e.g., currentSessionIdRef.current) and
update that ref whenever selectedSession changes, then read that ref inside the
silent refresh timer instead of the captured variable; ensure you clear any
existing cursorSilentRefreshTimerRef before setting a new timeout and clear the
timer on component unmount (and apply the same ref/timer-guard pattern to the
other occurrence at lines ~1169-1176).

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.

Bug: Incomplete message rendering during live stream (corrects on refresh)

2 participants