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() ?? {}