diff --git a/src/renderer/src/components/native-chat/mobile-follow-up-preference.test.tsx b/mobile/src/session/mobile-follow-up-preference.test.tsx similarity index 85% rename from src/renderer/src/components/native-chat/mobile-follow-up-preference.test.tsx rename to mobile/src/session/mobile-follow-up-preference.test.tsx index c14f4a66d50..deb6e43cadb 100644 --- a/src/renderer/src/components/native-chat/mobile-follow-up-preference.test.tsx +++ b/mobile/src/session/mobile-follow-up-preference.test.tsx @@ -1,9 +1,8 @@ -// @vitest-environment happy-dom -import { act, cleanup, render } from '@testing-library/react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, expect, it, vi } from 'vitest' -import { useMobileNativeChatQueueFollowUps } from '../../../../../mobile/src/session/use-mobile-native-chat-queue-follow-ups' -import type { RpcClient } from '../../../../../mobile/src/transport/rpc-client' -import type { ConnectionState, RpcResponse } from '../../../../../mobile/src/transport/types' +import { useMobileNativeChatQueueFollowUps } from './use-mobile-native-chat-queue-follow-ups' +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState, RpcResponse } from '../transport/types' function connection() { let state: ConnectionState = 'connected' @@ -60,7 +59,15 @@ const reply = (behavior: string): RpcResponse => ({ _meta: { runtimeId: 'r' } }) -afterEach(cleanup) +const renderers = new Set() +afterEach(() => { + act(() => { + for (const renderer of renderers) { + renderer.unmount() + } + }) + renderers.clear() +}) it('shares one live scalar per connection, fences late reads/events, and isolates real hook renders', async () => { const a = connection(), @@ -72,13 +79,17 @@ it('shares one live scalar per connection, fences late reads/events, and isolate renders.set(id, (renders.get(id) ?? 0) + 1) return null } - const mounted = render( - <> - - - - - ) + let mounted: ReactTestRenderer | undefined + await act(async () => { + mounted = create( + <> + + + + + ) + renderers.add(mounted) + }) await act(async () => {}) expect(a.client.subscribe).toHaveBeenCalledTimes(1) expect(b.client.subscribe).toHaveBeenCalledTimes(1) @@ -135,7 +146,7 @@ it('shares one live scalar per connection, fences late reads/events, and isolate expect(values.get('a1')).toBe(true) await act(async () => a.replies[1]!(reply('steer'))) expect(values.get('a1')).toBe(true) - mounted.unmount() + await act(async () => mounted?.unmount()) expect(a.states.size).toBe(0) expect(b.states.size).toBe(0) expect(a.stops).toHaveBeenCalledTimes(3) @@ -143,8 +154,7 @@ it('shares one live scalar per connection, fences late reads/events, and isolate }) it('existing phone workspace event reader ignores additive preference snapshot and events', async () => { - const { startHostWorktreeRefresh } = - await import('../../../../../mobile/src/worktree/host-worktree-refresh') + const { startHostWorktreeRefresh } = await import('../worktree/host-worktree-refresh') const host = connection() const fetchWorktrees = vi.fn(async () => {}), fetchRepoMetadata = vi.fn(async () => {}), diff --git a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx index 2747bf8e1a2..d35306de123 100644 --- a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx @@ -376,7 +376,14 @@ it('keeps the draft through Continue admission, refusal, uncertainty and recorde fence: 1, submissions: [], queuedMessageIds: [], - queue: { capability: 'unsupported', enabled: false }, + queue: { + capability: 'unsupported', + followUpsQueue: false, + isWorking: false, + stopping: false, + session: {}, + prompts: [] + }, historyLoaded: false, sendOptions: () => options }) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index c3d274a6f6b..69721f29b2a 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -111,6 +111,7 @@ export function createStructuredSessionMocks() { retryLaunch: vi.fn<(worktreeId: string, sessionId: string) => unknown>(), relaunchWithMessage: vi.fn<(worktreeId: string, sessionId: string, text: string) => void>(), controllerProps: nullable<{ transportEnabled?: boolean }>(), + controllerRender: vi.fn<(sessionId: string, queueFollowUps: boolean | undefined) => void>(), send: vi.fn<(text: string, attachments?: unknown[]) => boolean>(() => true), status: 'ready' as 'idle' | 'loading' | 'ready' | 'error', readRefusal: absent(), @@ -182,7 +183,9 @@ export function createStructuredSessionMocks() { sessionId: string target: { kind: 'local' } | { kind: 'environment'; environmentId: string } transportEnabled?: boolean + queueFollowUps?: boolean }) => { + mocks.controllerRender(props.sessionId, props.queueFollowUps) mocks.controllerProps = props return { contextClear: { clearing: false, clears: 0 }, @@ -363,6 +366,7 @@ export function createStructuredSessionMocks() { mocks.relaunchWithMessage.mockReset() mocks.lifecycleLookup.mockReset() mocks.controllerProps = null + mocks.controllerRender.mockClear() mocks.send.mockClear() mocks.status = 'ready' mocks.readRefusal = undefined diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx index 67ab2ebff77..4219c045c5e 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx @@ -35,6 +35,61 @@ import { structuredAgentSessionPaneKey } from '../../../../shared/structured-age import { structuredAgentSessionDraftScopeKey } from './native-chat-composer-draft-store' describe('NativeChatStructuredSession', () => { + it('isolates mounted chats from unrelated settings and renders their live follow-up choice', async () => { + const original = useAppStore.getState().settings + const settings = { ...getDefaultSettings('/tmp'), nativeChatFollowUpBehavior: 'steer' as const } + useAppStore.setState({ settings }) + const view = render( + <> + {['local-chat', 'ssh-chat'].map((sessionId) => ( + + ))} + + ) + try { + await act(async () => {}) + mocks.controllerRender.mockClear() + act(() => + useAppStore.setState({ settings: { ...settings, nativeChatFollowUpBehavior: 'steer' } }) + ) + expect(mocks.controllerRender).not.toHaveBeenCalled() + act(() => + useAppStore.setState({ + settings: { ...settings, showTasksButton: !settings.showTasksButton } + }) + ) + expect(mocks.controllerRender).not.toHaveBeenCalled() + act(() => + useAppStore.setState({ settings: { ...settings, nativeChatFollowUpBehavior: 'queue' } }) + ) + expect(new Set(mocks.controllerRender.mock.calls.map(([sessionId]) => sessionId))).toEqual( + new Set(['local-chat', 'ssh-chat']) + ) + expect(mocks.controllerRender.mock.calls.every(([, queue]) => queue === true)).toBe(true) + mocks.controllerRender.mockClear() + act(() => useAppStore.setState({ settings })) + expect(new Set(mocks.controllerRender.mock.calls.map(([sessionId]) => sessionId))).toEqual( + new Set(['local-chat', 'ssh-chat']) + ) + expect(mocks.controllerRender.mock.calls.every(([, queue]) => queue === false)).toBe(true) + } finally { + view.unmount() + useAppStore.setState({ settings: original }) + } + }) + it('applies persisted appearance at the chat root and updates it live', () => { const original = useAppStore.getState().settings useAppStore.setState({ diff --git a/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts index 1369694c4b5..2740d5cb735 100644 --- a/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts @@ -177,6 +177,9 @@ export function registerRuntimeClientIpcBridge( if (event.type === 'sidebarInputsChanged') { return } + if (event.type !== 'activateWorktree') { + return + } // Older hosts broadcast local/CLI activation without an address; that is not this viewer's intent. if (!event.navigation || !navigationTargetsClients(event.navigation)) { return