diff --git a/apps/desktop/src/app/chat/index.test.tsx b/apps/desktop/src/app/chat/index.test.tsx
new file mode 100644
index 0000000000000..f4ef7007e2587
--- /dev/null
+++ b/apps/desktop/src/app/chat/index.test.tsx
@@ -0,0 +1,161 @@
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { useState } from 'react'
+import { MemoryRouter } from 'react-router-dom'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { assistantTextPart, type ChatMessage } from '@/lib/chat-messages'
+import {
+ $activeSessionId,
+ $awaitingResponse,
+ $busy,
+ $contextSuggestions,
+ $currentCwd,
+ $currentModel,
+ $currentProvider,
+ $freshDraftReady,
+ $gatewayState,
+ $introPersonality,
+ $introSeed,
+ $messages,
+ $selectedStoredSessionId,
+ $sessions
+} from '@/store/session'
+
+import { ChatView } from './index'
+
+const threadRenderCount = vi.hoisted(() => ({ current: 0 }))
+
+vi.mock('@/components/assistant-ui/thread', async () => {
+ const React = await import('react')
+
+ return {
+ Thread: () => {
+ threadRenderCount.current += 1
+
+ return React.createElement('div', { 'data-testid': 'thread' })
+ }
+ }
+})
+
+vi.mock('@/components/Backdrop', async () => {
+ const React = await import('react')
+
+ return { Backdrop: () => React.createElement('div', { 'data-testid': 'backdrop' }) }
+})
+
+vi.mock('@/components/notifications', () => ({ NotificationStack: () => null }))
+vi.mock('./chat-drop-overlay', () => ({ ChatDropOverlay: () => null }))
+vi.mock('./composer', () => ({ ChatBar: () => null, ChatBarFallback: () => null }))
+vi.mock('./hooks/use-file-drop-zone', () => ({
+ useFileDropZone: () => ({ dragActive: false, dropHandlers: {} })
+}))
+vi.mock('./sidebar/session-actions-menu', async () => {
+ const React = await import('react')
+
+ return {
+ SessionActionsMenu: ({ children }: { children: React.ReactNode }) =>
+ React.createElement('div', { 'data-testid': 'session-actions-menu' }, children)
+ }
+})
+
+function assistantMessage(id: string, text: string): ChatMessage {
+ return {
+ id,
+ parts: [assistantTextPart(text)],
+ role: 'assistant'
+ }
+}
+
+describe('ChatView render isolation', () => {
+ beforeEach(() => {
+ threadRenderCount.current = 0
+ $activeSessionId.set('runtime-1')
+ $awaitingResponse.set(false)
+ $busy.set(false)
+ $contextSuggestions.set([])
+ $currentCwd.set('/work')
+ $currentModel.set('test-model')
+ $currentProvider.set('test-provider')
+ $freshDraftReady.set(false)
+ $gatewayState.set('closed')
+ $introPersonality.set('')
+ $introSeed.set(0)
+ $messages.set([assistantMessage('assistant-1', 'Stable historical answer')])
+ $selectedStoredSessionId.set('stored-1')
+ $sessions.set([{ id: 'stored-1', message_count: 1, title: 'Stable chat' } as never])
+ })
+
+ afterEach(() => {
+ cleanup()
+ vi.restoreAllMocks()
+ $activeSessionId.set(null)
+ $awaitingResponse.set(false)
+ $busy.set(false)
+ $contextSuggestions.set([])
+ $currentCwd.set('')
+ $currentModel.set('')
+ $currentProvider.set('')
+ $freshDraftReady.set(false)
+ $gatewayState.set('idle')
+ $introPersonality.set('')
+ $introSeed.set(0)
+ $messages.set([])
+ $selectedStoredSessionId.set(null)
+ $sessions.set([])
+ })
+
+ it('does not re-render chat history when an unrelated parent idle tick updates', () => {
+ const props = {
+ gateway: null,
+ maxVoiceRecordingSeconds: 120,
+ onAddContextRef: vi.fn(),
+ onAddUrl: vi.fn(),
+ onAttachDroppedItems: vi.fn(),
+ onAttachImageBlob: vi.fn(),
+ onBranchInNewChat: vi.fn(),
+ onCancel: vi.fn(),
+ onDeleteSelectedSession: vi.fn(),
+ onEdit: vi.fn(),
+ onPasteClipboardImage: vi.fn(),
+ onPickFiles: vi.fn(),
+ onPickFolders: vi.fn(),
+ onPickImages: vi.fn(),
+ onReload: vi.fn(),
+ onRemoveAttachment: vi.fn(),
+ onSteer: vi.fn(),
+ onSubmit: vi.fn(),
+ onThreadMessagesChange: vi.fn(),
+ onToggleSelectedPin: vi.fn(),
+ onTranscribeAudio: vi.fn()
+ }
+
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } }
+ })
+
+ function ParentTickHarness() {
+ const [tick, setTick] = useState(0)
+
+ return (
+
+
+
+
+
+
+ )
+ }
+
+ render()
+
+ expect(screen.getByTestId('thread')).toBeTruthy()
+ expect(threadRenderCount.current).toBe(1)
+
+ fireEvent.click(screen.getByRole('button', { name: /parent tick/i }))
+
+ expect(threadRenderCount.current).toBe(1)
+ })
+})
diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx
index 77d92248e3a47..221a7859f1714 100644
--- a/apps/desktop/src/app/chat/index.tsx
+++ b/apps/desktop/src/app/chat/index.tsx
@@ -7,7 +7,7 @@ import {
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import type * as React from 'react'
-import { Suspense, useCallback, useMemo, useRef } from 'react'
+import { Suspense, memo, useCallback, useMemo, useRef } from 'react'
import { useLocation } from 'react-router-dom'
import { Thread } from '@/components/assistant-ui/thread'
@@ -154,7 +154,7 @@ function ChatHeader({
)
}
-export function ChatView({
+export const ChatView = memo(function ChatView({
className,
gateway,
onToggleSelectedPin,
@@ -288,18 +288,29 @@ export function ChatView({
return ExportedMessageRepository.fromBranchableArray(items, { headId })
}, [messages])
- const runtime = useIncrementalExternalStoreRuntime({
- messageRepository: runtimeMessageRepository,
- isRunning: busy,
- setMessages: onThreadMessagesChange,
- onNew: async () => {
- // Submission is handled explicitly by ChatBar.
- // Keeping this no-op avoids duplicate prompt.submit calls.
- },
- onEdit,
- onCancel: async () => onCancel(),
- onReload
- })
+ const handleRuntimeNew = useCallback(async () => {
+ // Submission is handled explicitly by ChatBar.
+ // Keeping this no-op avoids duplicate prompt.submit calls.
+ }, [])
+
+ const handleRuntimeCancel = useCallback(async () => {
+ await onCancel()
+ }, [onCancel])
+
+ const runtimeAdapter = useMemo(
+ () => ({
+ isRunning: busy,
+ messageRepository: runtimeMessageRepository,
+ onCancel: handleRuntimeCancel,
+ onEdit,
+ onNew: handleRuntimeNew,
+ onReload,
+ setMessages: onThreadMessagesChange
+ }),
+ [busy, handleRuntimeCancel, handleRuntimeNew, onEdit, onReload, onThreadMessagesChange, runtimeMessageRepository]
+ )
+
+ const runtime = useIncrementalExternalStoreRuntime(runtimeAdapter)
// Drop files anywhere in the conversation area, not just on the composer
// input. In-app drags (project tree / gutter) carry workspace-relative paths
@@ -399,4 +410,4 @@ export function ChatView({
)
-}
+})
diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx
index 8652a6b833be8..cfa5cf7ef960a 100644
--- a/apps/desktop/src/app/desktop-controller.tsx
+++ b/apps/desktop/src/app/desktop-controller.tsx
@@ -638,6 +638,18 @@ export function DesktopController() {
currentCwd,
requestGateway
})
+ const {
+ addContextRefAttachment,
+ addTerminalSelectionAttachment,
+ attachContextFilePath,
+ attachContextFolderPath,
+ attachDroppedItems,
+ attachImageBlob,
+ pasteClipboardImage,
+ pickContextPaths,
+ pickImages,
+ removeAttachment
+ } = composer
const branchInNewChat = useCallback(
async (messageId?: string) => {
@@ -700,6 +712,40 @@ export function DesktopController() {
updateSessionState
})
+ const addUrlAttachment = useCallback(
+ (url: string) => addContextRefAttachment(`@url:${formatRefValue(url)}`, url),
+ [addContextRefAttachment]
+ )
+
+ const deleteSelectedSession = useCallback(() => {
+ if (selectedStoredSessionId) {
+ void removeSession(selectedStoredSessionId)
+ }
+ }, [removeSession, selectedStoredSessionId])
+
+ const pasteClipboardImageIntoChat = useCallback(() => {
+ void pasteClipboardImage()
+ }, [pasteClipboardImage])
+
+ const pickFileContext = useCallback(() => {
+ void pickContextPaths('file')
+ }, [pickContextPaths])
+
+ const pickFolderContext = useCallback(() => {
+ void pickContextPaths('folder')
+ }, [pickContextPaths])
+
+ const pickImageAttachments = useCallback(() => {
+ void pickImages()
+ }, [pickImages])
+
+ const removeChatAttachment = useCallback(
+ (id: string) => {
+ void removeAttachment(id)
+ },
+ [removeAttachment]
+ )
+
useGatewayBoot({
handleGatewayEvent: handleDesktopGatewayEvent,
onConnectionReady: c => {
@@ -804,7 +850,7 @@ export function DesktopController() {
// where it shows. Lives in main's stacking context (not the root overlay layer)
// so pane resize handles still paint above it. Toggling never rebuilds the shell.
const mainOverlays = (
-
+
)
const overlays = (
@@ -889,24 +935,20 @@ export function DesktopController() {
composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)}
- onAttachDroppedItems={composer.attachDroppedItems}
- onAttachImageBlob={composer.attachImageBlob}
+ onAddContextRef={addContextRefAttachment}
+ onAddUrl={addUrlAttachment}
+ onAttachDroppedItems={attachDroppedItems}
+ onAttachImageBlob={attachImageBlob}
onBranchInNewChat={branchInNewChat}
onCancel={cancelRun}
- onDeleteSelectedSession={() => {
- if (selectedStoredSessionId) {
- void removeSession(selectedStoredSessionId)
- }
- }}
+ onDeleteSelectedSession={deleteSelectedSession}
onEdit={editMessage}
- onPasteClipboardImage={() => void composer.pasteClipboardImage()}
- onPickFiles={() => void composer.pickContextPaths('file')}
- onPickFolders={() => void composer.pickContextPaths('folder')}
- onPickImages={() => void composer.pickImages()}
+ onPasteClipboardImage={pasteClipboardImageIntoChat}
+ onPickFiles={pickFileContext}
+ onPickFolders={pickFolderContext}
+ onPickImages={pickImageAttachments}
onReload={reloadFromMessage}
- onRemoveAttachment={id => void composer.removeAttachment(id)}
+ onRemoveAttachment={removeChatAttachment}
onSteer={steerPrompt}
onSubmit={submitText}
onThreadMessagesChange={handleThreadMessagesChange}
diff --git a/apps/desktop/src/lib/incremental-external-store-runtime.ts b/apps/desktop/src/lib/incremental-external-store-runtime.ts
index c055175091dd7..a2d7afd0ef7da 100644
--- a/apps/desktop/src/lib/incremental-external-store-runtime.ts
+++ b/apps/desktop/src/lib/incremental-external-store-runtime.ts
@@ -172,7 +172,7 @@ export function useIncrementalExternalStoreRuntime(
useEffect(() => {
runtime.setAdapter(store as ExternalStoreAdapter)
- })
+ }, [runtime, store])
const { modelContext } = useRuntimeAdapters() ?? {}