Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions apps/desktop/src/app/chat/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/stored-1']}>
<button onClick={() => setTick(value => value + 1)} type="button">
parent tick {tick}
</button>
<ChatView {...props} />
</MemoryRouter>
</QueryClientProvider>
)
}

render(<ParentTickHarness />)

expect(screen.getByTestId('thread')).toBeTruthy()
expect(threadRenderCount.current).toBe(1)

fireEvent.click(screen.getByRole('button', { name: /parent tick/i }))

expect(threadRenderCount.current).toBe(1)
})
})
41 changes: 26 additions & 15 deletions apps/desktop/src/app/chat/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -154,7 +154,7 @@ function ChatHeader({
)
}

export function ChatView({
export const ChatView = memo(function ChatView({
className,
gateway,
onToggleSelectedPin,
Expand Down Expand Up @@ -288,18 +288,29 @@ export function ChatView({
return ExportedMessageRepository.fromBranchableArray(items, { headId })
}, [messages])

const runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({
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<ThreadMessage>(runtimeAdapter)

// Drop files anywhere in the conversation area, not just on the composer
// input. In-app drags (project tree / gutter) carry workspace-relative paths
Expand Down Expand Up @@ -399,4 +410,4 @@ export function ChatView({
</div>
</div>
)
}
})
72 changes: 57 additions & 15 deletions apps/desktop/src/app/desktop-controller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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 = (
<PersistentTerminal cwd={currentCwd} onAddSelectionToChat={composer.addTerminalSelectionAttachment} />
<PersistentTerminal cwd={currentCwd} onAddSelectionToChat={addTerminalSelectionAttachment} />
)

const overlays = (
Expand Down Expand Up @@ -889,24 +935,20 @@ export function DesktopController() {
<ChatView
gateway={gatewayRef.current}
maxVoiceRecordingSeconds={voiceMaxRecordingSeconds}
onAddContextRef={composer.addContextRefAttachment}
onAddUrl={url => 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}
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/lib/incremental-external-store-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ export function useIncrementalExternalStoreRuntime<T extends ThreadMessage>(

useEffect(() => {
runtime.setAdapter(store as ExternalStoreAdapter)
})
}, [runtime, store])

const { modelContext } = useRuntimeAdapters() ?? {}

Expand Down