Skip to content

feat(desktop): add local session persistence and restore for chat#1287

Merged
Kitenite merged 2 commits into
mainfrom
kitenite/next-phase
Feb 8, 2026
Merged

feat(desktop): add local session persistence and restore for chat#1287
Kitenite merged 2 commits into
mainfrom
kitenite/next-phase

Conversation

@Kitenite
Copy link
Copy Markdown
Collaborator

@Kitenite Kitenite commented Feb 8, 2026

Summary

  • Session persistence: Chat sessions now survive pane close/reopen and app restart. Closing a pane deactivates (not deletes) the session — messages remain in LMDB and can be restored later.
  • Provider-agnostic architecture: Three cleanly separated layers — AgentProvider interface, session metadata store (lowdb), and session manager orchestrator — so future providers (e.g., OpenAI Codex) can be added without touching the UI or persistence layer.
  • SessionSelector UI: Dropdown in the ChatPane toolbar for browsing previous sessions, switching between them, creating new chats, and deleting old sessions.

Key changes

Layer What changed
Streams LMDB gets persistent dataDir, claude agent persists sessionId→claudeSessionId mapping to disk, new GET /sessions/:id endpoint
Agent Provider New AgentProvider interface + ClaudeSdkProvider implementation
Session Store New lowdb-backed ChatSessionMeta store at ~/.superset/chat-sessions.json
Session Manager Refactored to use provider + store; deactivateSession (preserve) vs deleteSession (destroy)
tRPC Router New procedures: restoreSession, deleteSession, renameSession, listSessions, getSession
Tabs Store New switchChatSession action
ChatInterface Restore-vs-start lifecycle on mount, auto-title from first user message
ChatPane Integrated SessionSelector dropdown, session switching/creation/deletion handlers

Test plan

  • Create a chat session → send a message → verify metadata in ~/.superset/chat-sessions.json
  • Close the chat pane → verify proxy session NOT deleted
  • Reopen chat pane → dropdown shows previous session → select it → messages load
  • Restart streams process → LMDB data persists → messages loadable
  • Restart desktop app → session list populates from metadata store
  • Send follow-up in restored session → Claude SDK resumes context
  • Delete a session → proxy session deleted, metadata archived
  • Create a new chat from the dropdown → fresh session starts

Summary by CodeRabbit

  • New Features
    • Full chat session management: start, restore, deactivate, delete, rename, and list sessions.
    • Session selector UI to pick, create, or delete previous sessions with timestamps and previews.
    • Sessions persist across restarts and can be restored automatically.
    • Automatic session titles generated from the first user message.
    • New provider support exposing a "Claude" agent option.

Sessions now survive pane close/reopen and app restart. The architecture
is provider-agnostic with three cleanly separated layers:

- AgentProvider interface (Claude SDK is first implementation)
- Session metadata store (lowdb-backed, persists to ~/.superset/)
- Session manager orchestrator (deactivate vs delete semantics)

Key changes:
- Closing a chat pane deactivates (not deletes) the session — messages
  persist in LMDB and can be restored later
- SessionSelector dropdown in ChatPane toolbar for browsing, switching,
  creating, and deleting chat sessions
- Claude agent endpoint persists sessionId mapping to disk and exposes
  GET /sessions/:id for provider session ID lookup
- DurableStreamTestServer configured with persistent dataDir
- Auto-title sessions from first user message
- switchChatSession action added to tabs store
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 8, 2026

📝 Walkthrough

Walkthrough

Replaces Claude-specific session management with a provider-driven ChatSessionManager, adds a persistent SessionStore, introduces AgentProvider interfaces and a ClaudeSdkProvider, expands TRPC ai-chat endpoints (start/restore/deactivate/delete/rename/list/get), and updates UI to support session selection, restoration, and auto-titling.

Changes

Cohort / File(s) Summary
AI chat router
apps/desktop/src/lib/trpc/routers/ai-chat/index.ts
Swapped claudeSessionManagerchatSessionManager; added sessionStore dependency; added public procedures: restoreSession, deleteSession, renameSession, listSessions, getSession; startSession input now requires workspaceId; stream event bindings use chatSessionManager.
Agent provider abstraction
apps/desktop/src/lib/trpc/routers/ai-chat/utils/agent-provider/types.ts, .../claude-sdk-provider.ts, .../index.ts
New types AgentProviderSpec, AgentRegistration, AgentProvider; implemented ClaudeSdkProvider and re-exported provider types and class.
Session manager
apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-manager.ts, .../index.ts
Renamed ClaudeSessionManager → exported ChatSessionManager; added constructor DI (AgentProvider, SessionStore); added lifecycle APIs: startSession (workspaceId), restoreSession, deactivateSession, deleteSession, updateSessionMeta; moved to provider-driven registration.
Session store
apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-store/session-store.ts, .../index.ts
New ChatSessionMeta interface and SessionStore using lowdb for on-disk persistence; supports create, update, get, listByWorkspace, and archive.
Frontend: session UX
apps/desktop/src/renderer/.../ChatInterface/ChatInterface.tsx, .../ChatPane/ChatPane.tsx
ChatInterface now accepts workspaceId, queries getSession, restores or starts sessions accordingly, and auto-generates a title after the first exchange; ChatPane integrates a SessionSelector and handlers for switching/deleting/creating sessions.
SessionSelector component
apps/desktop/src/renderer/.../SessionSelector/SessionSelector.tsx, .../index.ts
New SessionSelector dropdown component that lists workspace sessions, highlights active session, allows switching, deletion, and creating new chats; uses aiChat.listSessions query.
Tabs store
apps/desktop/src/renderer/stores/tabs/store.ts, .../types.ts
Added switchChatSession(paneId, sessionId) to update a pane's active chat session; type updates for TabsStore.
Streams server persistence
apps/streams/src/claude-agent.ts, apps/streams/src/index.ts
Streams server now persists agent session mappings to disk, loads them on startup, and exposes GET /sessions/:sessionId; startup passes dataDir into server.

Sequence Diagram(s)

sequenceDiagram
    participant UI as Client/UI
    participant TRPC as TRPC Router
    participant Store as SessionStore
    participant CSM as ChatSessionManager
    participant Provider as AgentProvider
    participant Proxy as Proxy Server

    UI->>TRPC: startSession({sessionId, workspaceId, cwd})
    TRPC->>Store: create(session meta)
    Store-->>TRPC: meta saved
    TRPC->>Provider: getAgentRegistration({sessionId, cwd})
    Provider-->>CSM: AgentRegistration
    TRPC->>CSM: startSession({sessionId, workspaceId, cwd})
    CSM->>Proxy: PUT /sessions/{sessionId} (registration)
    Proxy-->>CSM: created
    CSM->>CSM: emit('session_start')
    CSM-->>TRPC: success
    TRPC-->>UI: { success: true }
Loading
sequenceDiagram
    participant UI as Client/UI
    participant TRPC as TRPC Router
    participant Store as SessionStore
    participant CSM as ChatSessionManager
    participant Provider as AgentProvider
    participant Proxy as Proxy Server

    UI->>TRPC: restoreSession({sessionId, cwd})
    TRPC->>Store: get(sessionId)
    Store-->>TRPC: session meta
    TRPC->>CSM: restoreSession({sessionId, cwd})
    CSM->>Provider: getAgentRegistration({sessionId, cwd})
    Provider-->>CSM: AgentRegistration
    CSM->>Proxy: PUT /sessions/{sessionId} (registration)
    Proxy-->>CSM: restored
    CSM->>CSM: emit('session_start')
    CSM-->>TRPC: success
    TRPC-->>UI: { success: true }
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

  • PR #1204 — Refactor introducing provider-driven ChatSessionManager and sessionStore (strongly related).
  • PR #1258 — Modifies the same session-manager area; overlaps on proxy/agent orchestration changes.
  • PR #1283 — Modifies ChatInterface session lifecycle and mount behavior (related frontend changes).

Suggested reviewers

  • AviPeltz

Poem

🐰 I hopped into code with a twitchy nose,
Saved sessions like carrots in rows,
Providers now dance, flexible and spry,
Sessions restored with a happy sigh! 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.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
Title check ✅ Passed The title 'feat(desktop): add local session persistence and restore for chat' directly and clearly describes the main feature: persistent and restorable chat sessions in the desktop app. It is concise, specific, and reflects the primary objective of the changeset.
Description check ✅ Passed The PR description is comprehensive and well-structured. It includes a clear summary of changes organized by layer, specific test plan items (though unchecked), and detailed context. However, the template sections (Description, Related Issues, Type of Change, Testing, Screenshots, Additional Notes) are not explicitly filled in the structured format; the author provided a custom summary instead.

✏️ 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 kitenite/next-phase

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.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Feb 8, 2026

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ✅ Neon database branch
  • ✅ Electric Fly.io app

Thank you for your contribution! 🎉

Copy link
Copy Markdown
Contributor

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

Caution

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

⚠️ Outside diff range comments (1)
apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-manager.ts (1)

285-323: ⚠️ Potential issue | 🟠 Major

deleteSession can leave the session in a partial state if provider.cleanup or store.archive throws.

Lines 311 and 314 are not wrapped in try/catch. If provider.cleanup throws, the session metadata won't be archived and the in-memory map entry won't be removed, but the proxy session is already deleted. Consider wrapping these in individual try/catch blocks or at least ensuring this.sessions.delete and the session_end event always execute (e.g., in a finally block).

Proposed fix — ensure cleanup always completes
 	async deleteSession({ sessionId }: { sessionId: string }): Promise<void> {
 		console.log(`[chat/session] Deleting session ${sessionId}`);
 		const headers = buildProxyHeaders();

 		// Interrupt first
 		try {
 			await fetch(`${PROXY_URL}/v1/sessions/${sessionId}/stop`, {
 				method: "POST",
 				headers,
 				body: JSON.stringify({}),
 			});
 		} catch {
 			// Non-fatal
 		}

 		// Delete from proxy
 		try {
 			await fetch(`${PROXY_URL}/v1/sessions/${sessionId}`, {
 				method: "DELETE",
 				headers,
 			});
 		} catch {
 			// Non-fatal
 		}

-		// Provider cleanup
-		await this.provider.cleanup(sessionId);
-
-		// Archive metadata
-		await this.store.archive(sessionId);
+		// Provider cleanup (best-effort)
+		try {
+			await this.provider.cleanup(sessionId);
+		} catch (error) {
+			console.error(`[chat/session] Provider cleanup failed for ${sessionId}:`, error);
+		}
+
+		// Archive metadata (best-effort)
+		try {
+			await this.store.archive(sessionId);
+		} catch (error) {
+			console.error(`[chat/session] Metadata archive failed for ${sessionId}:`, error);
+		}

 		this.sessions.delete(sessionId);
🤖 Fix all issues with AI agents
In
`@apps/desktop/src/lib/trpc/routers/ai-chat/utils/agent-provider/claude-sdk-provider.ts`:
- Around line 55-57: The catch in getProviderSessionId is silently swallowing
errors; update the catch to log the error with context (e.g., include the
function name "getProviderSessionId", provider identifier or request details)
before returning undefined or rethrowing as appropriate; ensure you capture the
caught error object and pass it to your existing logger (e.g., processLogger,
logger, or console.error) so network/HTTP failures are visible during debugging.

In
`@apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-manager.ts`:
- Around line 296-298: In deleteSession, replace the silent catch blocks around
the interrupt call and the proxy DELETE call with warnings that log the error
and contextual info (session id and operation) before continuing; specifically,
inside deleteSession add calls to the existing logger (e.g., processLogger.warn
or this.logger.warn) in the catch blocks for the interrupt/proxy DELETE
sections, include the caught error object and a clear message like "failed to
interrupt session {sessionId}" and "failed to proxy DELETE for session
{sessionId}" so errors are not swallowed silently.
- Around line 245-271: Both catch blocks silently swallow errors; update them to
log the error with context (including sessionId and operation) before treating
them as non-fatal. Specifically, in the fetch POST to
`${PROXY_URL}/v1/sessions/${sessionId}/stop` (and the surrounding try/catch) log
the caught error and the sessionId and operation name (e.g., "stop proxy
session") using the module/class logger (e.g., this.logger.error or
processLogger.error) then continue; likewise, in the try/catch around
this.provider.getProviderSessionId(sessionId) and subsequent
this.store.update(sessionId, ...) log the error with context (e.g., "provider
session id fetch or metadata update failed", sessionId, provider identifier)
before allowing deactivation to proceed.

In
`@apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-store/session-store.ts`:
- Around line 54-66: ensureDb() can race when called concurrently because
multiple callers may call JSONFilePreset before this.db is assigned; introduce a
cached initialization promise (e.g., private dbInit?: Promise<SessionStoreData>)
and change ensureDb to: if (this.db) return it; if (this.dbInit) return await
it; otherwise set this.dbInit = JSONFilePreset<SessionStoreData>(STORE_PATH, {
sessions: [] }); await the promise, assign the resolved value to this.db, clear
this.dbInit, and ensure you catch errors to clear dbInit on failure so
subsequent calls can retry; reference ensureDb, JSONFilePreset, this.db,
this.dbInit, and STORE_PATH.

In
`@apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/ChatInterface.tsx`:
- Around line 163-170: The code unsafely asserts textPart.content as string and
misdetects truncation; fix by extracting the original text with a safe guard
(e.g., let originalText = textPart && "content" in textPart ?
String(textPart.content) : "") then compute truncated = originalText.slice(0,
80) and set title = originalText.length > 80 ? `${truncated}...` : (truncated ||
"Chat"); update the logic around the variables textPart, firstUserText (or
replace it with originalText/truncated), and title so you don't use a blind type
assertion and you compare originalText.length > 80 for truncation.
- Around line 150-178: The auto-title effect (uses hasAutoTitled ref and calls
renameSessionRef.current.mutate) is running on restored sessions and overwriting
existing titles; update the effect to early-return when the session already has
a non-default title (e.g., check session?.title and skip auto-rename if it's
present and not the placeholder like "Chat" or empty), so only sessions with no
meaningful title get renamed; keep the existing hasAutoTitled logic and
sessionId reset effect but add the title check before computing firstUserText
and calling renameSessionRef.current.mutate.

In
`@apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/SessionSelector.tsx`:
- Around line 47-55: The title shows "Chat" because
electronTrpc.aiChat.listSessions.useQuery is disabled by { enabled: isOpen } so
sessions is undefined until the dropdown opens; change this by either always
enabling the list query or adding a lightweight query (e.g.,
electronTrpc.aiChat.getSession.useQuery keyed on currentSessionId, run on mount)
to fetch the current session title immediately; update SessionSelector to derive
displayTitle from the getSession result (fallback to sessions if present) or
simply remove the enabled flag so sessions/currentSession/currentSessionId
provide the correct displayTitle on initial render.
🧹 Nitpick comments (13)
apps/streams/src/claude-agent.ts (2)

82-92: writeFileSync is non-atomic and blocks the event loop on the request path.

persistSessions() is called from setClaudeSessionId during SSE stream handling (line 131). Two concerns:

  1. Non-atomic write: If the process crashes mid-write, claude-sessions.json can be left truncated/corrupt, making loadPersistedSessions fail on next startup (gracefully, but sessions are lost).
  2. Blocking I/O on request path: writeFileSync blocks the event loop. With ≤1000 entries the impact is small, but it's still on a hot path.

Consider writing to a temp file and renaming atomically, or switching to async writeFile. Libraries like write-file-atomic or a simple write-then-rename pattern would address both.

♻️ Suggested async atomic write pattern
+import { writeFile, rename } from "node:fs/promises";
+
 function persistSessions(): void {
 	try {
 		if (!existsSync(SESSIONS_DIR)) {
 			mkdirSync(SESSIONS_DIR, { recursive: true });
 		}
 		const entries = Array.from(claudeSessions.entries());
-		writeFileSync(SESSIONS_FILE, JSON.stringify(entries), "utf-8");
+		const tmpFile = `${SESSIONS_FILE}.tmp`;
+		writeFileSync(tmpFile, JSON.stringify(entries), "utf-8");
+		renameSync(tmpFile, SESSIONS_FILE);
 	} catch (err) {
 		console.warn("[claude-agent] Failed to persist sessions:", err);
 	}
 }

(Use renameSync from node:fs for the sync approach, or convert the whole function to async for a non-blocking version.)


67-80: Parsed JSON is not validated before use.

loadPersistedSessions casts JSON.parse(raw) directly to Array<[string, SessionEntry]> without validation. If the file contains unexpected shapes (e.g. from a corrupted write or version mismatch), iterating with destructuring could throw a less helpful error than an explicit validation check. The outer try/catch does provide a safety net, so this is not critical, but a Zod schema or a simple shape check would make the code more robust.

apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-store/session-store.ts (1)

68-80: create() silently overwrites an existing session's fields on conflict.

When an existing session is found, Object.assign(existing, meta, { isArchived: false }) overwrites all fields from meta (including workspaceId, provider, cwd, createdAt). This is fine if it's the intended upsert semantic, but it means a caller can inadvertently change createdAt or provider on an existing session. Consider whether createdAt should be preserved on re-creation.

apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/SessionSelector.tsx (1)

96-138: Brief "No previous sessions" flash while data loads on first dropdown open.

When the dropdown opens for the first time, sessions is undefined (query just enabled), so the else branch at line 134 renders "No previous sessions" until data arrives. Consider showing a small loading indicator or deferring the content render until sessions !== undefined.

apps/desktop/src/lib/trpc/routers/ai-chat/utils/agent-provider/claude-sdk-provider.ts (1)

46-58: No timeout on fetch to Claude agent endpoint.

If the Claude agent is unresponsive, this fetch will hang indefinitely. Since getProviderSessionId is called during deactivateSession, a stalled request could block session cleanup.

Proposed fix
 	async getProviderSessionId(sessionId: string): Promise<string | undefined> {
 		try {
-			const res = await fetch(`${CLAUDE_AGENT_URL}/sessions/${sessionId}`);
+			const res = await fetch(`${CLAUDE_AGENT_URL}/sessions/${sessionId}`, {
+				signal: AbortSignal.timeout(5_000),
+			});
 			if (!res.ok) return undefined;
apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPane.tsx (1)

59-68: deleteSession in the dependency array defeats useCallback memoization.

The mutation object returned by useMutation() changes identity on each render (it carries mutable status fields). Including it in the deps array means handleDeleteSession is recreated every render. Extract a stable reference or use just the mutate function.

Proposed fix
-	const deleteSession = electronTrpc.aiChat.deleteSession.useMutation();
+	const deleteSessionMutation = electronTrpc.aiChat.deleteSession.useMutation();
+	const deleteSessionRef = useRef(deleteSessionMutation.mutate);
+	deleteSessionRef.current = deleteSessionMutation.mutate;

 	const handleDeleteSession = useCallback(
 		(sessionIdToDelete: string) => {
-			deleteSession.mutate({ sessionId: sessionIdToDelete });
+			deleteSessionRef.current({ sessionId: sessionIdToDelete });
 			// If deleting the current session, switch to a new one
 			if (sessionIdToDelete === sessionId) {
 				handleNewChat();
 			}
 		},
-		[deleteSession, sessionId, handleNewChat],
+		[sessionId, handleNewChat],
 	);

Note: you'd also need to add useRef to the import on line 1.

apps/desktop/src/lib/trpc/routers/ai-chat/index.ts (3)

77-84: deleteSession will surface provider/store errors as opaque INTERNAL_SERVER_ERROR.

In the manager, provider.cleanup and store.archive (lines 311–314 in session-manager.ts) are not wrapped in try/catch. If either throws, the tRPC layer will return a generic 500 with no actionable context. Consider catching here and rethrowing as a TRPCError with code INTERNAL_SERVER_ERROR and a meaningful message, or adding try/catch in the manager's deleteSession around those calls.


86-98: Positional arguments on updateSessionMeta — guideline preference for object params.

updateSessionMeta(sessionId, { title }) uses two positional parameters. The coding guidelines prefer object parameters for functions with 2+ arguments. This is a minor alignment concern since the method is already defined this way in the manager class.

As per coding guidelines, "Use object parameters for functions with 2 or more parameters instead of positional arguments".


100-110: listSessions and getSession bypass chatSessionManager and access sessionStore directly.

All other procedures go through chatSessionManager, but these two read directly from the store. This breaks the single-entry-point pattern and means the router now couples to two collaborators. Consider adding thin read-through methods on ChatSessionManager so the router only depends on one abstraction.

apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-manager.ts (4)

60-136: startSession and restoreSession duplicate ~30 lines of proxy setup logic.

Both methods perform the same proxy PUT → agent POST → sessions.set → emit sequence. Extract a shared helper (e.g., ensureProxySession(sessionId, cwd)) and call it from both methods to keep this DRY.

♻️ Sketch of extracted helper
+  /** Ensure proxy session exists and agent is registered. */
+  private async ensureProxySession({
+    sessionId,
+    cwd,
+  }: {
+    sessionId: string;
+    cwd: string;
+  }): Promise<void> {
+    const headers = buildProxyHeaders();
+
+    const createRes = await fetch(`${PROXY_URL}/v1/sessions/${sessionId}`, {
+      method: "PUT",
+      headers,
+    });
+    if (!createRes.ok) {
+      throw new Error(
+        `PUT /v1/sessions/${sessionId} failed: ${createRes.status}`,
+      );
+    }
+
+    const registration = this.provider.getAgentRegistration({
+      sessionId,
+      cwd,
+    });
+    const registerRes = await fetch(
+      `${PROXY_URL}/v1/sessions/${sessionId}/agents`,
+      {
+        method: "POST",
+        headers,
+        body: JSON.stringify({ agents: [registration] }),
+      },
+    );
+    if (!registerRes.ok) {
+      throw new Error(
+        `POST /v1/sessions/${sessionId}/agents failed: ${registerRes.status}`,
+      );
+    }
+  }

Also applies to: 142-207


79-106: No timeout on fetch calls to the proxy — requests may hang indefinitely.

All fetch calls to PROXY_URL lack an AbortSignal.timeout(). If the proxy is unresponsive, startSession, restoreSession, deactivateSession, and deleteSession will block forever. Consider adding a timeout signal:

const signal = AbortSignal.timeout(10_000); // 10s
await fetch(url, { method: "PUT", headers, signal });

Also applies to: 156-184


115-115: Hardcoded "New chat" magic string.

Extract to a named constant at module level for reuse and discoverability.

As per coding guidelines, "Extract hardcoded magic numbers, strings, and enums to named constants at module top instead of leaving them inline in logic".


328-337: updateSessionMeta uses positional arguments.

Per coding guidelines, prefer an object parameter when there are 2+ arguments. Consider updateSessionMeta({ sessionId, ...patch }).

As per coding guidelines, "Use object parameters for functions with 2 or more parameters instead of positional arguments".

Comment on lines +55 to +57
} catch {
return undefined;
}
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

Silent error swallowing in getProviderSessionId.

The catch block returns undefined without logging. Per coding guidelines, errors should at minimum be logged with context. A network failure here would be invisible during debugging.

Proposed fix
-		} catch {
-			return undefined;
+		} catch (error) {
+			console.error(`[agent-provider/claude] Failed to get provider session ID for ${sessionId}:`, error);
+			return undefined;
 		}

As per coding guidelines: "Never swallow errors silently; at minimum log errors with context before rethrowing or handling them explicitly."

📝 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
} catch {
return undefined;
}
} catch (error) {
console.error(`[agent-provider/claude] Failed to get provider session ID for ${sessionId}:`, error);
return undefined;
}
🤖 Prompt for AI Agents
In
`@apps/desktop/src/lib/trpc/routers/ai-chat/utils/agent-provider/claude-sdk-provider.ts`
around lines 55 - 57, The catch in getProviderSessionId is silently swallowing
errors; update the catch to log the error with context (e.g., include the
function name "getProviderSessionId", provider identifier or request details)
before returning undefined or rethrowing as appropriate; ensure you capture the
caught error object and pass it to your existing logger (e.g., processLogger,
logger, or console.error) so network/HTTP failures are visible during debugging.

Comment on lines +245 to +271
try {
await fetch(`${PROXY_URL}/v1/sessions/${sessionId}/stop`, {
method: "POST",
headers: buildProxyHeaders(),
body: JSON.stringify({}),
});
} catch {
// Non-fatal
}

// Capture provider session ID for future resume
try {
const providerSessionId =
await this.provider.getProviderSessionId(sessionId);
if (providerSessionId) {
await this.store.update(sessionId, {
providerSessionId,
lastActiveAt: Date.now(),
});
} else {
await this.store.update(sessionId, {
lastActiveAt: Date.now(),
});
}
} catch {
// Non-fatal — metadata update failure shouldn't block deactivation
}
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

Silent error swallowing — catch blocks with no logging.

Lines 251 and 269 catch errors without any logging. The coding guidelines require at minimum logging errors with context before handling them.

As per coding guidelines, "Never swallow errors silently; at minimum log errors with context before rethrowing or handling them explicitly".

Proposed fix
 		} catch {
-			// Non-fatal
+			// Non-fatal — best-effort interrupt
 		}

-		} catch {
-			// Non-fatal
+		} catch (error) {
+			console.warn(`[chat/session] Interrupt during deactivation failed:`, error);
 		}

And similarly for the metadata update catch:

-		} catch {
-			// Non-fatal — metadata update failure shouldn't block deactivation
+		} catch (error) {
+			console.warn(`[chat/session] Metadata update during deactivation failed:`, error);
 		}
🤖 Prompt for AI Agents
In
`@apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-manager.ts`
around lines 245 - 271, Both catch blocks silently swallow errors; update them
to log the error with context (including sessionId and operation) before
treating them as non-fatal. Specifically, in the fetch POST to
`${PROXY_URL}/v1/sessions/${sessionId}/stop` (and the surrounding try/catch) log
the caught error and the sessionId and operation name (e.g., "stop proxy
session") using the module/class logger (e.g., this.logger.error or
processLogger.error) then continue; likewise, in the try/catch around
this.provider.getProviderSessionId(sessionId) and subsequent
this.store.update(sessionId, ...) log the error with context (e.g., "provider
session id fetch or metadata update failed", sessionId, provider identifier)
before allowing deactivation to proceed.

Comment on lines 296 to 298
} catch {
// Non-fatal
}
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

More silent catch blocks without logging in deleteSession.

Same guideline concern as in deactivateSession — the interrupt and proxy DELETE catches should at least log a warning.

As per coding guidelines, "Never swallow errors silently; at minimum log errors with context before rethrowing or handling them explicitly".

Also applies to: 306-308

🤖 Prompt for AI Agents
In
`@apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-manager.ts`
around lines 296 - 298, In deleteSession, replace the silent catch blocks around
the interrupt call and the proxy DELETE call with warnings that log the error
and contextual info (session id and operation) before continuing; specifically,
inside deleteSession add calls to the existing logger (e.g., processLogger.warn
or this.logger.warn) in the catch blocks for the interrupt/proxy DELETE
sections, include the caught error object and a clear message like "failed to
interrupt session {sessionId}" and "failed to proxy DELETE for session
{sessionId}" so errors are not swallowed silently.

Comment on lines +54 to +66
private async ensureDb() {
if (this.db) return this.db;

const dir = dirname(STORE_PATH);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}

this.db = await JSONFilePreset<SessionStoreData>(STORE_PATH, {
sessions: [],
});
return this.db;
}
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

Race condition in lazy ensureDb() initialization.

If two callers invoke ensureDb() concurrently before this.db is assigned, both will see null, both will call JSONFilePreset, and one result will be silently discarded. This could lead to lost writes if the discarded instance had already been mutated.

A standard fix is to cache the initialization promise:

🐛 Proposed fix to prevent duplicate initialization
 export class SessionStore {
 	private db: Awaited<
 		ReturnType<typeof JSONFilePreset<SessionStoreData>>
 	> | null = null;
+	private dbInitPromise: Promise<
+		Awaited<ReturnType<typeof JSONFilePreset<SessionStoreData>>>
+	> | null = null;

 	private async ensureDb() {
 		if (this.db) return this.db;
+		if (this.dbInitPromise) return this.dbInitPromise;

-		const dir = dirname(STORE_PATH);
-		if (!existsSync(dir)) {
-			mkdirSync(dir, { recursive: true });
-		}
-
-		this.db = await JSONFilePreset<SessionStoreData>(STORE_PATH, {
-			sessions: [],
-		});
-		return this.db;
+		this.dbInitPromise = (async () => {
+			const dir = dirname(STORE_PATH);
+			if (!existsSync(dir)) {
+				mkdirSync(dir, { recursive: true });
+			}
+			const db = await JSONFilePreset<SessionStoreData>(STORE_PATH, {
+				sessions: [],
+			});
+			this.db = db;
+			return db;
+		})();
+
+		return this.dbInitPromise;
 	}
🤖 Prompt for AI Agents
In
`@apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-store/session-store.ts`
around lines 54 - 66, ensureDb() can race when called concurrently because
multiple callers may call JSONFilePreset before this.db is assigned; introduce a
cached initialization promise (e.g., private dbInit?: Promise<SessionStoreData>)
and change ensureDb to: if (this.db) return it; if (this.dbInit) return await
it; otherwise set this.dbInit = JSONFilePreset<SessionStoreData>(STORE_PATH, {
sessions: [] }); await the promise, assign the resolved value to this.db, clear
this.dbInit, and ensure you catch errors to clear dbInit on failure so
subsequent calls can retry; reference ensureDb, JSONFilePreset, this.db,
this.dbInit, and STORE_PATH.

Comment on lines +150 to +178
// Auto-title: after first user + assistant exchange, rename session
const hasAutoTitled = useRef(false);
useEffect(() => {
if (hasAutoTitled.current) return;
if (!sessionId) return;

const userMsg = messages.find((m) => m.role === "user");
const assistantMsg = messages.find((m) => m.role === "assistant");
if (!userMsg || !assistantMsg) return;

hasAutoTitled.current = true;

// Use first user message as title, truncated
const textPart = userMsg.parts?.find((p) => p.type === "text");
const firstUserText =
(textPart && "content" in textPart
? (textPart.content as string)
: undefined
)?.slice(0, 80) ?? "Chat";
const title =
firstUserText.length === 80 ? `${firstUserText}...` : firstUserText;

renameSessionRef.current.mutate({ sessionId, title });
}, [messages, sessionId]);

// Reset auto-title flag when session changes
useEffect(() => {
hasAutoTitled.current = false;
}, [sessionId]);
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

Auto-title will overwrite existing titles on restored sessions.

When a session is restored, its messages are reloaded from LMDB. Since hasAutoTitled is reset on sessionId change (line 177), and the restored session already has user + assistant messages, the auto-title effect will fire and overwrite whatever title the session already has.

Consider checking whether the session already has a non-default title before renaming:

Proposed fix sketch
 	// Auto-title: after first user + assistant exchange, rename session
 	const hasAutoTitled = useRef(false);
 	useEffect(() => {
 		if (hasAutoTitled.current) return;
 		if (!sessionId) return;
+		// Skip auto-titling for restored sessions that already have a real title
+		if (existingSession && existingSession.title && existingSession.title !== "New chat") return;

 		const userMsg = messages.find((m) => m.role === "user");
 		const assistantMsg = messages.find((m) => m.role === "assistant");
 		if (!userMsg || !assistantMsg) return;

 		hasAutoTitled.current = true;
🤖 Prompt for AI Agents
In
`@apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/ChatInterface.tsx`
around lines 150 - 178, The auto-title effect (uses hasAutoTitled ref and calls
renameSessionRef.current.mutate) is running on restored sessions and overwriting
existing titles; update the effect to early-return when the session already has
a non-default title (e.g., check session?.title and skip auto-rename if it's
present and not the placeholder like "Chat" or empty), so only sessions with no
meaningful title get renamed; keep the existing hasAutoTitled logic and
sessionId reset effect but add the title check before computing firstUserText
and calling renameSessionRef.current.mutate.

Comment on lines +163 to +170
const textPart = userMsg.parts?.find((p) => p.type === "text");
const firstUserText =
(textPart && "content" in textPart
? (textPart.content as string)
: undefined
)?.slice(0, 80) ?? "Chat";
const title =
firstUserText.length === 80 ? `${firstUserText}...` : firstUserText;
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

Unsafe type assertion and off-by-one in truncation.

Two minor issues:

  1. textPart.content as string (line 166) — content could be any shape; a type guard or String(...) coercion is safer.
  2. The truncation check firstUserText.length === 80 (line 170) also triggers for messages that are exactly 80 characters (not actually truncated). Compare against the original length instead.
Proposed fix
-		const textPart = userMsg.parts?.find((p) => p.type === "text");
-		const firstUserText =
-			(textPart && "content" in textPart
-				? (textPart.content as string)
-				: undefined
-			)?.slice(0, 80) ?? "Chat";
-		const title =
-			firstUserText.length === 80 ? `${firstUserText}...` : firstUserText;
+		const textPart = userMsg.parts?.find((p) => p.type === "text");
+		const rawText =
+			textPart && "content" in textPart ? String(textPart.content) : undefined;
+		const title = rawText
+			? rawText.length > 80
+				? `${rawText.slice(0, 80)}...`
+				: rawText
+			: "Chat";
🤖 Prompt for AI Agents
In
`@apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/ChatInterface.tsx`
around lines 163 - 170, The code unsafely asserts textPart.content as string and
misdetects truncation; fix by extracting the original text with a safe guard
(e.g., let originalText = textPart && "content" in textPart ?
String(textPart.content) : "") then compute truncated = originalText.slice(0,
80) and set title = originalText.length > 80 ? `${truncated}...` : (truncated ||
"Chat"); update the logic around the variables textPart, firstUserText (or
replace it with originalText/truncated), and title so you don't use a blind type
assertion and you compare originalText.length > 80 for truncation.

Comment on lines +47 to +55
const { data: sessions } = electronTrpc.aiChat.listSessions.useQuery(
{ workspaceId },
{ enabled: isOpen },
);

const currentSession = sessions?.find(
(s) => s.sessionId === currentSessionId,
);
const displayTitle = currentSession?.title ?? "Chat";
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

Session title defaults to "Chat" until dropdown is first opened.

Because enabled: isOpen, the listSessions query never fires until the user opens the dropdown. Until then, sessions is undefined, currentSession is undefined, and displayTitle is always "Chat" — even for a restored session that already has a title in the store.

Consider fetching the current session's title independently (e.g., a lightweight getSession query keyed on currentSessionId that runs on mount), or always enabling the list query, to show the correct title immediately.

🤖 Prompt for AI Agents
In
`@apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/SessionSelector.tsx`
around lines 47 - 55, The title shows "Chat" because
electronTrpc.aiChat.listSessions.useQuery is disabled by { enabled: isOpen } so
sessions is undefined until the dropdown opens; change this by either always
enabling the list query or adding a lightweight query (e.g.,
electronTrpc.aiChat.getSession.useQuery keyed on currentSessionId, run on mount)
to fetch the current session title immediately; update SessionSelector to derive
displayTitle from the getSession result (fallback to sessions if present) or
simply remove the enabled flag so sessions/currentSession/currentSessionId
provide the correct displayTitle on initial render.

Copy link
Copy Markdown
Contributor

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

Caution

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

⚠️ Outside diff range comments (1)
apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-manager.ts (1)

276-285: ⚠️ Potential issue | 🟠 Major

If provider.cleanup throws, session is left in inconsistent state.

Line 276: if this.provider.cleanup(sessionId) throws, store.archive, sessions.delete, and the session_end event are all skipped. The session remains in the active map but the proxy resources were already torn down (lines 261-274). Consider wrapping cleanup and archive independently so the method always completes the local tear-down.

Proposed fix
-		await this.provider.cleanup(sessionId);
-		await this.store.archive(sessionId);
+		try {
+			await this.provider.cleanup(sessionId);
+		} catch (error) {
+			console.warn(`[chat/session] Provider cleanup failed for ${sessionId}:`, error);
+		}
+
+		try {
+			await this.store.archive(sessionId);
+		} catch (error) {
+			console.warn(`[chat/session] Store archive failed for ${sessionId}:`, error);
+		}
🤖 Fix all issues with AI agents
In
`@apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/ChatInterface.tsx`:
- Around line 117-137: The effect is being retriggered by changes to
existingSession (react-query .data) causing sessions to bounce; add a ref-based
lifecycle guard to ensure the start/restore/stop sequence runs only once per
sessionId: create a ref like lifecycleRanForSessionRef that stores the last
sessionId for which the lifecycle has been executed, return early from the
useEffect if lifecycleRanForSessionRef.current === sessionId, set
lifecycleRanForSessionRef.current = sessionId right before calling
restoreSessionRef.current.mutate or startSessionRef.current.mutate, and reset
lifecycleRanForSessionRef.current to undefined when sessionId becomes falsy or
in the cleanup after stopSessionRef.current.mutate so a new sessionId can run
the lifecycle again; keep using sessionId, cwd, workspaceId, restoreSessionRef,
startSessionRef and stopSessionRef references as in the current effect.

In
`@apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPane.tsx`:
- Around line 59-67: The handleDeleteSession callback currently calls
deleteSession.mutate fire-and-forget and immediately switches to a new session
which can lose the user's session if deletion fails; change handleDeleteSession
to await the deletion (use deleteSession.mutateAsync in a try/catch) or use
deleteSession.mutate with an onSuccess/onError callback and only call
handleNewChat after a successful deletion, and on errors restore/keep the
current session and surface an error message to the user; update references in
handleDeleteSession, deleteSession.mutate/mutateAsync, and the success/error
handling to ensure no session switch occurs before confirmed deletion.
🧹 Nitpick comments (9)
apps/streams/src/index.ts (1)

19-21: Nit: existsSync guard is redundant with recursive: true.

mkdirSync(path, { recursive: true }) is a no-op when the directory already exists and won't throw, so the existsSync check can be dropped.

♻️ Suggested simplification
-if (!existsSync(DATA_DIR)) {
-	mkdirSync(DATA_DIR, { recursive: true });
-}
+mkdirSync(DATA_DIR, { recursive: true });

This also eliminates the (theoretical) TOCTOU race between the check and the create.

apps/streams/src/claude-agent.ts (2)

36-49: Disk data is deserialized with an unsafe cast; validate the shape.

JSON.parse(raw) as Array<[string, SessionEntry]> trusts the file contents completely. A corrupted or hand-edited file could inject entries missing claudeSessionId or lastAccessedAt, causing silent downstream failures (e.g., undefined passed to the Claude SDK resume option).

Additionally, stale sessions (older than SESSION_TTL_MS) loaded from disk won't be evicted until the next setClaudeSessionId call, meaning expired mappings can be served by getClaudeSessionId after a restart.

Consider adding schema validation and running eviction after loading:

Proposed fix
+const sessionEntrySchema = z.tuple([
+	z.string(),
+	z.object({
+		claudeSessionId: z.string(),
+		lastAccessedAt: z.number(),
+	}),
+]);
+
 function loadPersistedSessions(): void {
 	try {
 		if (existsSync(SESSIONS_FILE)) {
 			const raw = readFileSync(SESSIONS_FILE, "utf-8");
-			const entries = JSON.parse(raw) as Array<[string, SessionEntry]>;
-			for (const [key, entry] of entries) {
-				claudeSessions.set(key, entry);
+			const parsed = JSON.parse(raw);
+			if (!Array.isArray(parsed)) {
+				console.warn("[claude-agent] Persisted sessions file has unexpected shape, skipping");
+				return;
+			}
+			let loaded = 0;
+			for (const item of parsed) {
+				const result = sessionEntrySchema.safeParse(item);
+				if (result.success) {
+					const [key, entry] = result.data;
+					claudeSessions.set(key, entry);
+					loaded++;
+				}
 			}
-			console.log(`[claude-agent] Loaded ${entries.length} persisted sessions`);
+			console.log(`[claude-agent] Loaded ${loaded} persisted sessions`);
 		}
 	} catch (err) {
 		console.warn("[claude-agent] Failed to load persisted sessions:", err);
 	}
 }

And after loadPersistedSessions() on line 63:

 loadPersistedSessions();
+evictStaleSessions();

As per coding guidelines, "Validate external API data as untrusted by handling missing fields, unknown enums, and unexpected shapes with tolerant parsing and explicit fallbacks".


51-61: Non-atomic write risks file corruption on crash.

writeFileSync truncates the file before writing. If the process crashes mid-write, the sessions file will be left empty or partially written, and loadPersistedSessions will fail to restore any sessions on next startup.

A simple write-then-rename pattern avoids this:

Proposed fix
 function persistSessions(): void {
 	try {
 		if (!existsSync(SESSIONS_DIR)) {
 			mkdirSync(SESSIONS_DIR, { recursive: true });
 		}
 		const entries = Array.from(claudeSessions.entries());
-		writeFileSync(SESSIONS_FILE, JSON.stringify(entries), "utf-8");
+		const tmpFile = `${SESSIONS_FILE}.tmp`;
+		writeFileSync(tmpFile, JSON.stringify(entries), "utf-8");
+		renameSync(tmpFile, SESSIONS_FILE);
 	} catch (err) {
 		console.warn("[claude-agent] Failed to persist sessions:", err);
 	}
 }

Add renameSync to the import on line 1:

-import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-manager.ts (3)

288-297: updateSessionMeta uses positional parameters instead of an object parameter.

The coding guidelines require object parameters for functions with 2+ parameters.

Proposed fix
-	async updateSessionMeta(
-		sessionId: string,
-		patch: {
-			title?: string;
-			messagePreview?: string;
-			providerSessionId?: string;
-		},
-	): Promise<void> {
-		await this.store.update(sessionId, patch);
+	async updateSessionMeta({
+		sessionId,
+		patch,
+	}: {
+		sessionId: string;
+		patch: {
+			title?: string;
+			messagePreview?: string;
+			providerSessionId?: string;
+		};
+	}): Promise<void> {
+		await this.store.update(sessionId, patch);
 	}

Note: This would require updating the call site in ai-chat/index.ts (line 87) as well.

As per coding guidelines, "Use object parameters for functions with 2 or more parameters instead of positional arguments".


56-195: startSession and restoreSession duplicate the proxy PUT + agent POST pattern.

Both methods share the same proxy session creation (PUT) and agent registration (POST) flow. Consider extracting a private helper (e.g., ensureProxySession) to reduce duplication. The methods would then only differ in the store call (create vs update) and logging.


74-77: No timeouts on proxy fetch calls — could block indefinitely.

All fetch calls to the proxy (PUT, POST, DELETE) lack an AbortSignal with a timeout. If the proxy is unresponsive, startSession, restoreSession, deactivateSession, and deleteSession will hang. Consider adding signal: AbortSignal.timeout(ms) to each request.

apps/desktop/src/lib/trpc/routers/ai-chat/index.ts (3)

4-8: ClaudeStreamEvent type name is stale after the provider-agnostic refactor.

The type is still named ClaudeStreamEvent (imported on line 5, used on lines 118-119) even though the session manager is now provider-agnostic (ChatSessionManager). Consider renaming to ChatStreamEvent or SessionStreamEvent for consistency.


79-91: Consider adding basic validation to title input.

z.string() accepts empty strings and arbitrarily long values. A z.string().min(1).max(200) (or similar) would prevent blank titles and excessively long values from reaching the store.


93-103: listSessions and getSession bypass the session manager and call sessionStore directly.

This is fine for read-only queries, but it introduces a second dependency (sessionStore) into the router alongside chatSessionManager, which already holds a reference to the same store. If the manager later adds caching, filtering, or access checks, these queries won't benefit. Worth considering whether to route reads through the manager for consistency, though not blocking.

Comment on lines 117 to +137
useEffect(() => {
if (!sessionId || !cwd) return;
if (existingSession === undefined) return;

hasConnected.current = false;
setSessionReady(false);
startSessionRef.current.mutate({ sessionId, cwd });

if (existingSession) {
restoreSessionRef.current.mutate({ sessionId, cwd });
} else {
startSessionRef.current.mutate({
sessionId,
workspaceId,
cwd,
});
}

return () => {
stopSessionRef.current.mutate({ sessionId });
};
}, [sessionId, cwd]);
}, [sessionId, cwd, workspaceId, existingSession]);
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

existingSession in the dependency array can cause session re-creation on query refetches.

existingSession is the .data value from a react-query hook. Any background refetch that returns a new object reference (e.g., lastActiveAt changed by deactivateSession) will re-trigger this effect, running the cleanup (stopSession) and then restoreSession again — effectively bouncing the session.

Consider one of:

  1. Guard with a ref that tracks whether the lifecycle has already run for the current sessionId.
  2. Remove existingSession from the deps and instead read it inside the effect via a ref.
  3. Use staleTime: Infinity on the getSession query to prevent refetches after the initial load.
Proposed fix (option 1 — lifecycle guard ref)
+	const lifecycleRanRef = useRef<string | null>(null);
+
 	useEffect(() => {
 		if (!sessionId || !cwd) return;
 		if (existingSession === undefined) return;
+		if (lifecycleRanRef.current === sessionId) return;
+		lifecycleRanRef.current = sessionId;

 		hasConnected.current = false;
 		setSessionReady(false);

 		if (existingSession) {
 			restoreSessionRef.current.mutate({ sessionId, cwd });
 		} else {
 			startSessionRef.current.mutate({
 				sessionId,
 				workspaceId,
 				cwd,
 			});
 		}

 		return () => {
 			stopSessionRef.current.mutate({ sessionId });
+			lifecycleRanRef.current = null;
 		};
 	}, [sessionId, cwd, workspaceId, existingSession]);
🤖 Prompt for AI Agents
In
`@apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/ChatInterface.tsx`
around lines 117 - 137, The effect is being retriggered by changes to
existingSession (react-query .data) causing sessions to bounce; add a ref-based
lifecycle guard to ensure the start/restore/stop sequence runs only once per
sessionId: create a ref like lifecycleRanForSessionRef that stores the last
sessionId for which the lifecycle has been executed, return early from the
useEffect if lifecycleRanForSessionRef.current === sessionId, set
lifecycleRanForSessionRef.current = sessionId right before calling
restoreSessionRef.current.mutate or startSessionRef.current.mutate, and reset
lifecycleRanForSessionRef.current to undefined when sessionId becomes falsy or
in the cleanup after stopSessionRef.current.mutate so a new sessionId can run
the lifecycle again; keep using sessionId, cwd, workspaceId, restoreSessionRef,
startSessionRef and stopSessionRef references as in the current effect.

Comment on lines +59 to +67
const handleDeleteSession = useCallback(
(sessionIdToDelete: string) => {
deleteSession.mutate({ sessionId: sessionIdToDelete });
if (sessionIdToDelete === sessionId) {
handleNewChat();
}
},
[deleteSession, sessionId, handleNewChat],
);
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

Delete-then-switch has no error handling — user loses reference to the session on failure.

deleteSession.mutate is fire-and-forget. If it fails (network error, proxy down), the user has already been switched to a new session (line 63) and loses their reference to the original one. Consider using mutateAsync with a try/catch, or only switching after a successful deletion via the onSuccess callback.

Proposed fix (onSuccess approach)
 	const handleDeleteSession = useCallback(
 		(sessionIdToDelete: string) => {
-			deleteSession.mutate({ sessionId: sessionIdToDelete });
-			if (sessionIdToDelete === sessionId) {
-				handleNewChat();
-			}
+			deleteSession.mutate(
+				{ sessionId: sessionIdToDelete },
+				{
+					onSuccess: () => {
+						if (sessionIdToDelete === sessionId) {
+							handleNewChat();
+						}
+					},
+					onError: (err) => {
+						console.error("[chat/pane] Delete session failed:", err);
+					},
+				},
+			);
 		},
 		[deleteSession, sessionId, handleNewChat],
 	);
📝 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
const handleDeleteSession = useCallback(
(sessionIdToDelete: string) => {
deleteSession.mutate({ sessionId: sessionIdToDelete });
if (sessionIdToDelete === sessionId) {
handleNewChat();
}
},
[deleteSession, sessionId, handleNewChat],
);
const handleDeleteSession = useCallback(
(sessionIdToDelete: string) => {
deleteSession.mutate(
{ sessionId: sessionIdToDelete },
{
onSuccess: () => {
if (sessionIdToDelete === sessionId) {
handleNewChat();
}
},
onError: (err) => {
console.error("[chat/pane] Delete session failed:", err);
},
},
);
},
[deleteSession, sessionId, handleNewChat],
);
🤖 Prompt for AI Agents
In
`@apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPane.tsx`
around lines 59 - 67, The handleDeleteSession callback currently calls
deleteSession.mutate fire-and-forget and immediately switches to a new session
which can lose the user's session if deletion fails; change handleDeleteSession
to await the deletion (use deleteSession.mutateAsync in a try/catch) or use
deleteSession.mutate with an onSuccess/onError callback and only call
handleNewChat after a successful deletion, and on errors restore/keep the
current session and surface an error message to the user; update references in
handleDeleteSession, deleteSession.mutate/mutateAsync, and the success/error
handling to ensure no session switch occurs before confirmed deletion.

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