From 0b5bc550672dcfd37bd1d913eda1f9e4c4f69be7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:40:29 -0700 Subject: [PATCH 1/2] feat(native-chat): desktop reads chat visuals as ordinary files on the chat's host Inline visuals on hosts that publish the chat's scratch folder and read files by path are read as the file at that folder plus the reply's bare name, with the chat-visual access kind: desktop IPC for a local chat, the host's files.read for paired and SSH chats. They read again on reveal, turn end, host return and an editor save of the same file; unchanged pages keep their frame and a lost host keeps the shown page. Older hosts keep the per-session reader. --- .../NativeChatInlineVisual.host-file.test.tsx | 278 ++++++++++++++++++ .../native-chat/NativeChatInlineVisual.tsx | 88 +++++- .../NativeChatStructuredSession.tsx | 2 + .../native-chat/NativeChatVisualFrame.tsx | 4 +- .../native-chat/NativeChatVisualTab.tsx | 18 +- .../native-chat-visual-owner.test.tsx | 146 +++++++++ .../native-chat/native-chat-visual-owner.tsx | 119 +++++++- ...ative-chat-visual-path-read-client.test.ts | 176 +++++++++++ .../native-chat-visual-path-read-client.ts | 169 +++++++++++ .../native-chat/native-chat-visual-refresh.ts | 99 +++++++ .../use-native-chat-visual-document.ts | 118 ++++++-- .../native-chat-visual-host-path.test.ts | 40 +++ src/shared/native-chat-visual-host-path.ts | 15 + 13 files changed, 1216 insertions(+), 56 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatInlineVisual.host-file.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-visual-owner.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-visual-path-read-client.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-visual-path-read-client.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-visual-refresh.ts create mode 100644 src/shared/native-chat-visual-host-path.test.ts create mode 100644 src/shared/native-chat-visual-host-path.ts diff --git a/src/renderer/src/components/native-chat/NativeChatInlineVisual.host-file.test.tsx b/src/renderer/src/components/native-chat/NativeChatInlineVisual.host-file.test.tsx new file mode 100644 index 000000000000..04e6e29b6324 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatInlineVisual.host-file.test.tsx @@ -0,0 +1,278 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { act, cleanup, render, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const callRuntimeRpc = vi.fn() +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: (...args: unknown[]) => callRuntimeRpc(...args) +})) + +import { NativeChatMarkdown } from './NativeChatMarkdown' +import { + NativeChatVisualOwnerContext, + NativeChatVisualReadingContext, + useRefreshNativeChatVisualsWhenTurnEnds, + type NativeChatVisualOwner, + type NativeChatVisualReadRoute, + type NativeChatVisualReading +} from './native-chat-visual-owner' +import { createNativeChatVisualRefresh } from './native-chat-visual-refresh' +import { clearNativeChatVisualCacheForTests } from './native-chat-visual-read-client' +import { clearNativeChatVisualFileCacheForTests } from './native-chat-visual-path-read-client' +import { ORCA_EDITOR_FILE_SAVED_EVENT } from '@/components/editor/editor-autosave' +import { TooltipProvider } from '@/components/ui/tooltip' +import { useAppStore } from '@/store' +import type { OpenFile } from '@/store/slices/editor' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' + +const LOCAL_FOLDER = '/state/chat-scratch/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' +const WINDOWS_FOLDER = 'C:\\orca\\chat-scratch\\bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' +const LINE = '::orca-visual{file="usage.html" title="Usage"}' +const LOCAL_PATH = `${LOCAL_FOLDER}/usage.html` + +const readFile = vi.fn() + +// Every visual reports itself in view; `setVisible` scrolls them away and back. +const observed = new Map[]) => void>() +class ControlledObserver { + constructor(private readonly callback: (entries: Partial[]) => void) {} + observe(target: Element): void { + observed.set(target, this.callback) + this.callback([{ target, isIntersecting: true }]) + } + unobserve(target: Element): void { + observed.delete(target) + } + disconnect(): void {} +} +function setVisible(isIntersecting: boolean): void { + act(() => { + for (const [target, callback] of observed) { + callback([{ target, isIntersecting }]) + } + }) +} + +// One owner per host, as the provider memoizes it: a new owner would remount the reply's visuals. +const owners = new Map() +function owner(target: RuntimeClientTarget): NativeChatVisualOwner { + const key = JSON.stringify(target) + const held = owners.get(key) ?? { + target, + sessionId: 'session-alpha', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID + } + owners.set(key, held) + return held +} + +function TurnState({ working }: { working: boolean }): null { + useRefreshNativeChatVisualsWhenTurnEnds(working) + return null +} + +function chat( + target: RuntimeClientTarget, + reading: NativeChatVisualReading | null, + extra: ReactNode = null +): ReactNode { + const chatOwner = owner(target) + return ( + + + + + {extra} + + + + ) +} + +function reading(route: NativeChatVisualReadRoute): NativeChatVisualReading { + return { route, refresh: createNativeChatVisualRefresh() } +} + +const fileRoute = (scratchDirectory: string): NativeChatVisualReadRoute => ({ + kind: 'file', + scratchDirectory +}) + +function localPage(html: string): { content: string; isBinary: boolean } { + return { content: html, isBinary: false } +} + +function frameSource(container: HTMLElement): string { + return container.querySelector('iframe')?.getAttribute('srcdoc') ?? '' +} + +beforeEach(() => { + observed.clear() + vi.stubGlobal('IntersectionObserver', ControlledObserver) + vi.stubGlobal('api', { fs: { readFile } }) + clearNativeChatVisualCacheForTests() + clearNativeChatVisualFileCacheForTests() + callRuntimeRpc.mockReset() + readFile.mockReset() + readFile.mockResolvedValue(localPage('

first

')) +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +describe('inline visuals read as files on the chat host', () => { + it("reads a local chat's visual from its scratch folder with the chat-visual kind", async () => { + const { container } = render(chat({ kind: 'local' }, reading(fileRoute(LOCAL_FOLDER)))) + await waitFor(() => expect(frameSource(container)).toContain('

first

')) + expect(readFile).toHaveBeenCalledWith({ filePath: LOCAL_PATH, access: { kind: 'chat-visual' } }) + expect(callRuntimeRpc).not.toHaveBeenCalled() + }) + + it("reads a host chat's visual on that host, joined in the host's own path syntax", async () => { + callRuntimeRpc.mockResolvedValue({ path: 'p', content: '

h

', truncated: false }) + const target: RuntimeClientTarget = { kind: 'environment', environmentId: 'env-ssh' } + const { container } = render(chat(target, reading(fileRoute(WINDOWS_FOLDER)))) + await waitFor(() => expect(frameSource(container)).toContain('

h

')) + expect(callRuntimeRpc).toHaveBeenCalledWith( + target, + 'files.read', + { path: `${WINDOWS_FOLDER}\\usage.html`, access: { kind: 'chat-visual' } }, + { timeoutMs: 15_000 } + ) + expect(readFile).not.toHaveBeenCalled() + }) + + it.each([ + ['an older host', reading({ kind: 'legacy' })], + ['no published folder', null] + ])('keeps the per-session reader for %s', async (_, value) => { + callRuntimeRpc.mockResolvedValue({ ok: true, revision: 'r1', sizeBytes: 3, html: '

old

' }) + const target: RuntimeClientTarget = { kind: 'environment', environmentId: 'env-old' } + const { container } = render(chat(target, value)) + await waitFor(() => expect(frameSource(container)).toContain('

old

')) + expect(callRuntimeRpc).toHaveBeenCalledWith( + target, + 'agentSession.readVisual', + { sessionId: 'session-alpha', file: 'usage.html' }, + { timeoutMs: 15_000 } + ) + expect(readFile).not.toHaveBeenCalled() + }) + + it('reads nothing while the host has not said how it reads files', async () => { + const { container } = render(chat({ kind: 'local' }, reading({ kind: 'pending' }))) + await Promise.resolve() + expect(container.querySelector('[role="status"]')).not.toBeNull() + expect(readFile).not.toHaveBeenCalled() + expect(callRuntimeRpc).not.toHaveBeenCalled() + }) + + it('shows a rewritten file when the turn ends, and keeps the frame when nothing changed', async () => { + const value = reading(fileRoute(LOCAL_FOLDER)) + const view = render(chat({ kind: 'local' }, value, )) + await waitFor(() => expect(frameSource(view.container)).toContain('

first

')) + const frame = view.container.querySelector('iframe') + + view.rerender(chat({ kind: 'local' }, value, )) + await waitFor(() => expect(readFile).toHaveBeenCalledTimes(2)) + await act(async () => {}) + expect(view.container.querySelector('iframe')).toBe(frame) + + readFile.mockResolvedValue(localPage('

second

')) + view.rerender(chat({ kind: 'local' }, value, )) + view.rerender(chat({ kind: 'local' }, value, )) + await waitFor(() => expect(frameSource(view.container)).toContain('

second

')) + }) + + it('reads again when the host comes back, and keeps the page while it is out of reach', async () => { + callRuntimeRpc.mockResolvedValueOnce({ path: 'p', content: '

h

', truncated: false }) + const value = reading(fileRoute(LOCAL_FOLDER)) + const target: RuntimeClientTarget = { kind: 'environment', environmentId: 'env-1' } + const { container } = render(chat(target, value)) + await waitFor(() => expect(frameSource(container)).toContain('

h

')) + + vi.useFakeTimers() + try { + callRuntimeRpc.mockRejectedValue(new Error('timeout')) + act(() => value.refresh.refresh()) + // Through every bounded retry: losing contact never blanks a shown page. + for (let step = 0; step < 4; step += 1) { + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000) + }) + } + expect(callRuntimeRpc).toHaveBeenCalledTimes(5) + expect(frameSource(container)).toContain('

h

') + expect(container).not.toHaveTextContent('Visualization unavailable') + } finally { + vi.useRealTimers() + } + }) + + it('reads again when it scrolls back into view after being hidden', async () => { + const { container } = render(chat({ kind: 'local' }, reading(fileRoute(LOCAL_FOLDER)))) + await waitFor(() => expect(frameSource(container)).toContain('

first

')) + readFile.mockResolvedValue(localPage('

revealed

')) + setVisible(false) + setVisible(true) + await waitFor(() => expect(frameSource(container)).toContain('

revealed

')) + expect(readFile).toHaveBeenCalledTimes(2) + }) + + it("reads again when the Orca editor saves this visual's file, and only that file", async () => { + const openFiles = useAppStore.getState().openFiles + const editorFile = (id: string, filePath: string): OpenFile => ({ + id, + filePath, + relativePath: filePath, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + runtimeEnvironmentId: null, + language: 'html', + isDirty: false, + mode: 'edit' + }) + useAppStore.setState({ + openFiles: [ + editorFile('visual', LOCAL_PATH), + editorFile('other', `${LOCAL_FOLDER}/other.html`), + { ...editorFile('remote', LOCAL_PATH), runtimeEnvironmentId: 'env-1' } + ] + }) + const save = (fileId: string): void => { + act(() => { + window.dispatchEvent( + new CustomEvent(ORCA_EDITOR_FILE_SAVED_EVENT, { detail: { fileId, content: '' } }) + ) + }) + } + try { + const { container } = render(chat({ kind: 'local' }, reading(fileRoute(LOCAL_FOLDER)))) + await waitFor(() => expect(frameSource(container)).toContain('

first

')) + save('other') + save('remote') + await Promise.resolve() + expect(readFile).toHaveBeenCalledTimes(1) + + readFile.mockResolvedValue(localPage('

saved

')) + save('visual') + await waitFor(() => expect(frameSource(container)).toContain('

saved

')) + expect(readFile).toHaveBeenCalledTimes(2) + } finally { + useAppStore.setState({ openFiles }) + } + }) + + it('shows one muted line when the host refuses the file', async () => { + readFile.mockRejectedValue(new Error('binary_file')) + const { container } = render(chat({ kind: 'local' }, reading(fileRoute(LOCAL_FOLDER)))) + await waitFor(() => expect(container).toHaveTextContent('Visualization unavailable')) + expect(container.querySelector('iframe')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatInlineVisual.tsx b/src/renderer/src/components/native-chat/NativeChatInlineVisual.tsx index d8b6d4e381ed..c4d52db1f8dc 100644 --- a/src/renderer/src/components/native-chat/NativeChatInlineVisual.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInlineVisual.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' import { SquareArrowOutUpRight } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' @@ -6,8 +6,51 @@ import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import { NATIVE_CHAT_VISUAL_RESERVED_HEIGHT, NativeChatVisualFrame } from './NativeChatVisualFrame' import { observeTranscriptVisibility } from './NativeChatTranscriptChrome' -import type { NativeChatVisualOwner } from './native-chat-visual-owner' -import { useNativeChatVisualDocument } from './use-native-chat-visual-document' +import { + useNativeChatVisualReading, + type NativeChatVisualOwner, + type NativeChatVisualReading +} from './native-chat-visual-owner' +import { + useNativeChatVisualDocument, + type NativeChatVisualSource +} from './use-native-chat-visual-document' +import { nativeChatVisualHostPath } from '../../../../shared/native-chat-visual-host-path' +import { + markNativeChatVisualReads, + type NativeChatVisualFile +} from './native-chat-visual-path-read-client' +import { useNativeChatVisualSavedMark } from './native-chat-visual-refresh' + +const subscribeToNothing = (): (() => void) => () => {} +const noMark = (): number => 0 + +type NativeChatVisualHostFile = + | Extract + | { kind: 'file'; file: NativeChatVisualFile } + +/** The visual's file on the chat's own host, or the older endpoint where the host has no folder. */ +function useNativeChatVisualHostFile( + owner: NativeChatVisualOwner, + reading: NativeChatVisualReading | null, + file: string +): NativeChatVisualHostFile { + const route = reading?.route ?? null + return useMemo(() => { + const legacy: NativeChatVisualHostFile = { + kind: 'legacy', + identity: { target: owner.target, sessionId: owner.sessionId, file } + } + if (route === null || route.kind === 'legacy') { + return legacy + } + if (route.kind === 'pending') { + return { kind: 'pending' } + } + const path = nativeChatVisualHostPath(route.scratchDirectory, file) + return path === null ? legacy : { kind: 'file', file: { target: owner.target, path } } + }, [file, owner.sessionId, owner.target, route]) +} export function NativeChatVisualUnavailable(): React.JSX.Element { return ( @@ -32,26 +75,49 @@ export function NativeChatInlineVisual({ }): React.JSX.Element { const boxRef = useRef(null) const [near, setNear] = useState(false) + const [revealedMark, setRevealedMark] = useState(0) const [retired, setRetired] = useState(false) const openChatVisualTab = useAppStore((state) => state.openChatVisualTab) - const state = useNativeChatVisualDocument( - { target: owner.target, sessionId: owner.sessionId, file }, - near + const reading = useNativeChatVisualReading() + const hostFile = useNativeChatVisualHostFile(owner, reading, file) + const chatRefreshMark = useSyncExternalStore( + reading?.refresh.subscribe ?? subscribeToNothing, + reading?.refresh.mark ?? noMark, + reading?.refresh.mark ?? noMark ) + const savedMark = useNativeChatVisualSavedMark(hostFile.kind === 'file' ? hostFile.file : null) + // Marks only grow, so the largest is the latest trigger. + const freshAfter = Math.max(chatRefreshMark, savedMark, revealedMark) + const source = useMemo( + () => (hostFile.kind === 'file' ? { ...hostFile, freshAfter } : hostFile), + [freshAfter, hostFile] + ) + const state = useNativeChatVisualDocument(source, near) + const boxShown = !retired && state.status !== 'unavailable' useEffect(() => { const element = boxRef.current - if (!element || near) { + if (!element || !boxShown) { return } + let seen = false + let hidden = false return observeTranscriptVisibility(element, (visible) => { - if (visible) { - setNear(true) + if (!visible) { + hidden = seen + return + } + setNear(true) + // Brought back into view after being scrolled away or hidden: the file may have changed. + if (hidden) { + setRevealedMark(markNativeChatVisualReads()) } + seen = true + hidden = false }) - }, [near]) + }, [boxShown]) - if (retired || state.status === 'unavailable') { + if (!boxShown) { return } const label = title ?? file diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 2445b0d70b17..f7bac876941f 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -24,6 +24,7 @@ import { LinkActionPopover } from '@/components/link-actions/LinkActionPopover' import { useNativeChatLinkActions } from './use-native-chat-link-actions' import { useNativeChatFileLinkContext } from './use-native-chat-file-link-context' import { useRecheckNativeChatFileLinksWhenTurnEnds } from './use-native-chat-file-link-existence' +import { useRefreshNativeChatVisualsWhenTurnEnds } from './native-chat-visual-owner' import { useNativeChatTabOwnerWorktreeId } from './use-native-chat-tab-owner' import { useStructuredAgentSession } from './use-structured-agent-session' import { useNativeChatImageRuntimeContext } from './native-chat-image-runtime-context' @@ -94,6 +95,7 @@ export function NativeChatStructuredSession( ...(provisionalLaunch.launch ? { launch: provisionalLaunch.launch } : {}) }) useRecheckNativeChatFileLinksWhenTurnEnds(controller.isWorking) + useRefreshNativeChatVisualsWhenTurnEnds(controller.isWorking) const stopControls = nativeChatStructuredStopControls(controller, hostExecution.stopping) const launchDraftSignal = useNativeChatLaunchDraftSignal({ terminalTabId: props.tabId, diff --git a/src/renderer/src/components/native-chat/NativeChatVisualFrame.tsx b/src/renderer/src/components/native-chat/NativeChatVisualFrame.tsx index e17e27caff14..98ac41c80818 100644 --- a/src/renderer/src/components/native-chat/NativeChatVisualFrame.tsx +++ b/src/renderer/src/components/native-chat/NativeChatVisualFrame.tsx @@ -10,7 +10,7 @@ import { readNativeChatVisualFrameMessage, readNativeChatVisualPong } from '../../../../shared/native-chat-visual-shell' -import type { NativeChatVisualDocument } from './native-chat-visual-read-client' +import type { NativeChatVisualPage } from './native-chat-visual-path-read-client' import { createNativeChatVisualHeightGovernor } from '../../../../shared/native-chat-visual-height-governor' import { useNativeChatVisualTheme } from './use-native-chat-visual-theme' import { useWebviewDragPassthroughActive } from '../browser-pane/host-guest/use-webview-drag-passthrough-active' @@ -41,7 +41,7 @@ export function NativeChatVisualFrame({ themeScope, onRetired }: { - document: NativeChatVisualDocument + document: NativeChatVisualPage title: string layout: 'inline' | 'panel' /** An element already mounted where the frame sits, so the first paint wears that scope's theme. */ diff --git a/src/renderer/src/components/native-chat/NativeChatVisualTab.tsx b/src/renderer/src/components/native-chat/NativeChatVisualTab.tsx index 49fccd75ea7c..0d208fd65a74 100644 --- a/src/renderer/src/components/native-chat/NativeChatVisualTab.tsx +++ b/src/renderer/src/components/native-chat/NativeChatVisualTab.tsx @@ -1,8 +1,11 @@ -import { useRef, useState } from 'react' +import { useMemo, useRef, useState } from 'react' import { NativeChatVisualFrame } from './NativeChatVisualFrame' import { NativeChatVisualUnavailable } from './NativeChatInlineVisual' import type { OpenChatVisualTabState } from '../../../../shared/editor-tab-view-types' -import { useNativeChatVisualDocument } from './use-native-chat-visual-document' +import { + useNativeChatVisualDocument, + type NativeChatVisualSource +} from './use-native-chat-visual-document' /** A chat visual in its own tab: the same isolated frame as inline, filling the tab. */ export function NativeChatVisualTab({ @@ -14,11 +17,14 @@ export function NativeChatVisualTab({ const [retired, setRetired] = useState(false) // Why `visual` as refresh key: each "Open in new tab" stores a fresh record, so a tab already on // screen re-asks the host; an unchanged revision keeps the page as it is. - const state = useNativeChatVisualDocument( - { target: visual.target, sessionId: visual.sessionId, file: visual.file }, - true, - visual + const source = useMemo( + () => ({ + kind: 'legacy', + identity: { target: visual.target, sessionId: visual.sessionId, file: visual.file } + }), + [visual.file, visual.sessionId, visual.target] ) + const state = useNativeChatVisualDocument(source, true, visual) if (retired || state.status === 'unavailable') { return ( diff --git a/src/renderer/src/components/native-chat/native-chat-visual-owner.test.tsx b/src/renderer/src/components/native-chat/native-chat-visual-owner.test.tsx new file mode 100644 index 000000000000..52260909a597 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-visual-owner.test.tsx @@ -0,0 +1,146 @@ +// @vitest-environment happy-dom + +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import type { StructuredAgentSessionHostCapabilityState } from '@/runtime/structured-agent-session-host-capability' + +const statusFeedFor = vi.fn() +let summaries = new Map() +vi.mock('@/runtime/structured-agent-session-status-feed', () => ({ + getStructuredAgentSessionStatusFeed: (target: RuntimeClientTarget) => { + statusFeedFor(target) + return { + activate: () => () => {}, + subscribe: () => () => {}, + getSnapshot: () => summaries + } + } +})) +let hostPathFiles: StructuredAgentSessionHostCapabilityState = 'supported' +const capabilityAskedOf = vi.fn() +vi.mock('@/runtime/structured-agent-session-host-capability', () => ({ + useStructuredAgentSessionHostCapabilityState: ( + target: RuntimeClientTarget, + capability: string + ) => { + capabilityAskedOf(target, capability) + return hostPathFiles + } +})) +const contactListeners = new Map void>() +vi.mock('@/runtime/runtime-host-contact-regained', () => ({ + subscribeRuntimeHostContactRegained: (environmentId: string, listener: () => void) => { + contactListeners.set(environmentId, listener) + return () => contactListeners.delete(environmentId) + } +})) +let tabWorktreeId: string | null = 'wt-1' +vi.mock('./use-native-chat-tab-owner', () => ({ + useNativeChatTabOwnerWorktreeId: () => tabWorktreeId +})) + +import { + NativeChatVisualOwnerProvider, + useNativeChatVisualOwner, + useNativeChatVisualReading, + type NativeChatVisualOwner, + type NativeChatVisualReading +} from './native-chat-visual-owner' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' + +const FOLDER = '/state/chat-scratch/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' +const HOST: RuntimeClientTarget = { kind: 'environment', environmentId: 'env-1' } + +let seenOwner: NativeChatVisualOwner | null = null +let seenReading: NativeChatVisualReading | null = null +function Probe(): null { + seenOwner = useNativeChatVisualOwner() + seenReading = useNativeChatVisualReading() + return null +} + +function chat(target: RuntimeClientTarget): React.JSX.Element { + return ( + + + + ) +} + +beforeEach(() => { + summaries = new Map([['session-alpha', { scratchDirectory: FOLDER }]]) + hostPathFiles = 'supported' + tabWorktreeId = 'wt-1' + contactListeners.clear() + statusFeedFor.mockReset() + capabilityAskedOf.mockReset() + seenOwner = null + seenReading = null +}) + +afterEach(() => { + cleanup() +}) + +describe('NativeChatVisualOwnerProvider', () => { + it("reads visuals by path from the folder the chat's recorded host publishes", () => { + render(chat(HOST)) + expect(seenOwner?.target).toEqual(HOST) + expect(seenReading?.route).toEqual({ kind: 'file', scratchDirectory: FOLDER }) + expect(statusFeedFor).toHaveBeenCalledWith(HOST) + expect(capabilityAskedOf).toHaveBeenCalledWith(HOST, 'files.host-path.v1') + }) + + it.each<[string, () => void, NativeChatVisualReading['route']['kind']]>([ + [ + 'a host that reads files by path but has not said so yet', + () => (hostPathFiles = 'unknown'), + 'pending' + ], + ['a host without path reads', () => (hostPathFiles = 'unsupported'), 'legacy'], + [ + 'a host that publishes no folder (older, or a WSL chat)', + () => (summaries = new Map()), + 'legacy' + ] + ])('routes %s', (_, arrange, kind) => { + arrange() + render(chat(HOST)) + expect(seenReading?.route.kind).toBe(kind) + }) + + it('serves a floating chat, whose workspace is the floating one, the same way', () => { + tabWorktreeId = FLOATING_TERMINAL_WORKTREE_ID + render(chat({ kind: 'local' })) + expect(seenOwner).toEqual({ + target: { kind: 'local' }, + sessionId: 'session-alpha', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID + }) + expect(seenReading?.route).toEqual({ kind: 'file', scratchDirectory: FOLDER }) + }) + + it("refreshes the chat's visuals when its host answers again", () => { + render(chat(HOST)) + const listener = vi.fn() + seenReading?.refresh.subscribe(listener) + act(() => contactListeners.get('env-1')?.()) + expect(listener).toHaveBeenCalledTimes(1) + }) + + it('a local chat has no host to come back', () => { + render(chat({ kind: 'local' })) + expect(contactListeners.size).toBe(0) + }) + + it('keeps the owner when the route changes, so shown visuals are not remounted', () => { + hostPathFiles = 'unknown' + const view = render(chat(HOST)) + const owner = seenOwner + hostPathFiles = 'supported' + view.rerender(chat({ kind: 'environment', environmentId: 'env-1' })) + expect(seenReading?.route.kind).toBe('file') + expect(seenOwner).toBe(owner) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-visual-owner.tsx b/src/renderer/src/components/native-chat/native-chat-visual-owner.tsx index d8a99778e28f..a0e29b8ad2f9 100644 --- a/src/renderer/src/components/native-chat/native-chat-visual-owner.tsx +++ b/src/renderer/src/components/native-chat/native-chat-visual-owner.tsx @@ -1,6 +1,22 @@ -import { createContext, useContext, useMemo, type ReactNode } from 'react' +import { + createContext, + useContext, + useEffect, + useMemo, + useRef, + useSyncExternalStore, + type ReactNode +} from 'react' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import { FILES_HOST_PATH_RUNTIME_CAPABILITY } from '../../../../shared/runtime-file-contracts' +import { getStructuredAgentSessionStatusFeed } from '@/runtime/structured-agent-session-status-feed' +import { useStructuredAgentSessionHostCapabilityState } from '@/runtime/structured-agent-session-host-capability' +import { subscribeRuntimeHostContactRegained } from '@/runtime/runtime-host-contact-regained' import { useNativeChatTabOwnerWorktreeId } from './use-native-chat-tab-owner' +import { + createNativeChatVisualRefresh, + type NativeChatVisualRefresh +} from './native-chat-visual-refresh' /** The structured chat a transcript belongs to: where its visuals are read and what owns them. */ export type NativeChatVisualOwner = { @@ -9,13 +25,83 @@ export type NativeChatVisualOwner = { worktreeId: string } +/** + * How the chat's visuals are read: as files in its scratch folder on its host, through the older + * per-session endpoint (a host without path reads or without a published folder), or not yet + * known (the host has not said whether it reads by path). + */ +export type NativeChatVisualReadRoute = + | { kind: 'file'; scratchDirectory: string } + | { kind: 'legacy' } + | { kind: 'pending' } + +/** Kept apart from the owner so a change here re-renders a visual without remounting its frame. */ +export type NativeChatVisualReading = { + route: NativeChatVisualReadRoute + refresh: NativeChatVisualRefresh +} + export const NativeChatVisualOwnerContext = createContext(null) +export const NativeChatVisualReadingContext = createContext(null) + +const LEGACY_ROUTE: NativeChatVisualReadRoute = { kind: 'legacy' } +const PENDING_ROUTE: NativeChatVisualReadRoute = { kind: 'pending' } /** Null outside a structured chat's transcript, where no visual can be read. */ export function useNativeChatVisualOwner(): NativeChatVisualOwner | null { return useContext(NativeChatVisualOwnerContext) } +/** Null outside a chat that knows its host's folder, where visuals keep the older endpoint. */ +export function useNativeChatVisualReading(): NativeChatVisualReading | null { + return useContext(NativeChatVisualReadingContext) +} + +/** For the chat view that knows its turn state: a finished turn may have rewritten its visuals. */ +export function useRefreshNativeChatVisualsWhenTurnEnds(isWorking: boolean): void { + const reading = useContext(NativeChatVisualReadingContext) + const wasWorking = useRef(isWorking) + useEffect(() => { + const finished = wasWorking.current && !isWorking + wasWorking.current = isWorking + if (finished) { + reading?.refresh.refresh() + } + }, [isWorking, reading]) +} + +/** The chat's scratch folder as its host publishes it; null until then and from older hosts. */ +function useNativeChatScratchDirectory( + sessionId: string, + target: RuntimeClientTarget +): string | null { + const feed = useMemo(() => getStructuredAgentSessionStatusFeed(target), [target]) + useEffect(() => feed.activate(), [feed]) + return useSyncExternalStore( + feed.subscribe, + () => feed.getSnapshot().get(sessionId)?.scratchDirectory ?? null, + () => null + ) +} + +function useNativeChatVisualReadRoute( + sessionId: string, + target: RuntimeClientTarget +): NativeChatVisualReadRoute { + const scratchDirectory = useNativeChatScratchDirectory(sessionId, target) + const hostPathFiles = useStructuredAgentSessionHostCapabilityState( + target, + FILES_HOST_PATH_RUNTIME_CAPABILITY + ) + return useMemo(() => { + if (scratchDirectory === null || hostPathFiles === 'unsupported') { + return LEGACY_ROUTE + } + // Why wait on `unknown`: reading through the older endpoint first would read every visual twice. + return hostPathFiles === 'supported' ? { kind: 'file', scratchDirectory } : PENDING_ROUTE + }, [hostPathFiles, scratchDirectory]) +} + /** Names the structured chat below it as the owner of the visuals its replies show. */ export function NativeChatVisualOwnerProvider({ target, @@ -31,21 +117,30 @@ export function NativeChatVisualOwnerProvider({ // Until the tab's workspace is known there is no workspace to open a visual's tab in. const worktreeId = useNativeChatTabOwnerWorktreeId(tabId) const environmentId = target.kind === 'environment' ? target.environmentId : null + // The chat's recorded host, never its workspace's: two hosts can share a workspace id. + const ownerTarget = useMemo( + () => (environmentId === null ? { kind: 'local' } : { kind: 'environment', environmentId }), + [environmentId] + ) const owner = useMemo( - () => - worktreeId - ? { - target: - environmentId === null ? { kind: 'local' } : { kind: 'environment', environmentId }, - sessionId, - worktreeId - } - : null, - [environmentId, sessionId, worktreeId] + () => (worktreeId ? { target: ownerTarget, sessionId, worktreeId } : null), + [ownerTarget, sessionId, worktreeId] ) + const route = useNativeChatVisualReadRoute(sessionId, ownerTarget) + const refresh = useMemo(() => createNativeChatVisualRefresh(), []) + useEffect(() => { + if (environmentId === null) { + return + } + // A host that answers again may hold files rewritten while it was out of reach. + return subscribeRuntimeHostContactRegained(environmentId, refresh.refresh) + }, [environmentId, refresh]) + const reading = useMemo(() => ({ route, refresh }), [route, refresh]) return ( - {children} + + {children} + ) } diff --git a/src/renderer/src/components/native-chat/native-chat-visual-path-read-client.test.ts b/src/renderer/src/components/native-chat/native-chat-visual-path-read-client.test.ts new file mode 100644 index 000000000000..20e255809342 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-visual-path-read-client.test.ts @@ -0,0 +1,176 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const callRuntimeRpc = vi.fn() +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: (...args: unknown[]) => callRuntimeRpc(...args) +})) + +import { + clearNativeChatVisualFileCacheForTests, + markNativeChatVisualReads, + peekNativeChatVisualFile, + readNativeChatVisualFile, + type NativeChatVisualFile +} from './native-chat-visual-path-read-client' +import { PATH_OUTSIDE_ALLOWED_DIRECTORIES } from '../../../../shared/local-file-access' + +const readFile = vi.fn() +const CHAT_A = '/state/chat-scratch/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' +const CHAT_B = '/state/chat-scratch/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' +const local = (path: string): NativeChatVisualFile => ({ target: { kind: 'local' }, path }) +const onHost = (environmentId: string, path: string): NativeChatVisualFile => ({ + target: { kind: 'environment', environmentId }, + path +}) +const hostRead = (content: string) => ({ path: 'p', content, truncated: false, byteLength: 1 }) + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve: (value: T) => void = () => {} + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +beforeEach(() => { + clearNativeChatVisualFileCacheForTests() + callRuntimeRpc.mockReset() + readFile.mockReset() + vi.stubGlobal('window', { api: { fs: { readFile } } }) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('readNativeChatVisualFile', () => { + it("reads a local chat's visual through this computer with the chat-visual kind", async () => { + readFile.mockResolvedValueOnce({ content: '

a

', isBinary: false }) + const file = local(`${CHAT_A}/usage.html`) + expect(await readNativeChatVisualFile(file)).toEqual({ ok: true, page: { html: '

a

' } }) + expect(readFile).toHaveBeenCalledWith({ + filePath: `${CHAT_A}/usage.html`, + access: { kind: 'chat-visual' } + }) + expect(callRuntimeRpc).not.toHaveBeenCalled() + }) + + it.each([ + ['a paired host', 'env-paired', `${CHAT_A}/usage.html`], + [ + 'a managed SSH host on Windows', + 'env-ssh', + 'C:\\Users\\me\\AppData\\Roaming\\orca\\chat-scratch\\aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\\usage.html' + ] + ])( + "reads a visual on %s by its host path, never from this computer's disk", + async (_, env, path) => { + callRuntimeRpc.mockResolvedValueOnce(hostRead('

h

')) + expect(await readNativeChatVisualFile(onHost(env, path))).toEqual({ + ok: true, + page: { html: '

h

' } + }) + expect(callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: env }, + 'files.read', + { path, access: { kind: 'chat-visual' } }, + { timeoutMs: 15_000 } + ) + expect(readFile).not.toHaveBeenCalled() + } + ) + + it('keeps the same file name in two chats, and the same path on two hosts, apart', async () => { + readFile.mockImplementation(({ filePath }: { filePath: string }) => + Promise.resolve({ content: filePath, isBinary: false }) + ) + callRuntimeRpc.mockImplementation((target: { environmentId: string }) => + Promise.resolve(hostRead(target.environmentId)) + ) + const files = [ + local(`${CHAT_A}/v.html`), + local(`${CHAT_B}/v.html`), + onHost('env-1', `${CHAT_A}/v.html`), + onHost('env-2', `${CHAT_A}/v.html`) + ] + for (const file of files) { + await readNativeChatVisualFile(file) + } + expect(files.map((file) => peekNativeChatVisualFile(file)?.html)).toEqual([ + `${CHAT_A}/v.html`, + `${CHAT_B}/v.html`, + 'env-1', + 'env-2' + ]) + }) + + it('shares one read between visuals of the same file asking at once', async () => { + const pending = deferred<{ content: string; isBinary: boolean }>() + readFile.mockReturnValueOnce(pending.promise) + const file = local(`${CHAT_A}/v.html`) + const first = readNativeChatVisualFile(file) + const second = readNativeChatVisualFile(file) + pending.resolve({ content: 'x', isBinary: false }) + expect(await first).toEqual(await second) + expect(readFile).toHaveBeenCalledTimes(1) + }) + + it('a fresh read never joins one begun before its mark, and shares one begun after', async () => { + const stale = deferred<{ content: string; isBinary: boolean }>() + readFile + .mockReturnValueOnce(stale.promise) + .mockResolvedValueOnce({ content: 'new', isBinary: false }) + const file = local(`${CHAT_A}/v.html`) + const before = readNativeChatVisualFile(file) + const mark = markNativeChatVisualReads() + const fresh = readNativeChatVisualFile(file, { startedAfter: mark }) + const joined = readNativeChatVisualFile(file, { startedAfter: mark }) + expect(await fresh).toEqual({ ok: true, page: { html: 'new' } }) + expect(await joined).toEqual(await fresh) + expect(readFile).toHaveBeenCalledTimes(2) + // The older read lands last; the cache keeps the newer page. + stale.resolve({ content: 'old', isBinary: false }) + expect(await before).toEqual({ ok: true, page: { html: 'old' } }) + expect(peekNativeChatVisualFile(file)?.html).toBe('new') + }) + + it.each([ + ['ENOENT: no such file or directory', 'not_found'], + [`${PATH_OUTSIDE_ALLOWED_DIRECTORIES}. If this blocks a legitimate workflow, ...`, 'refused'], + ['Not a regular file', 'refused'], + ['file_too_large', 'refused'], + ['binary_file', 'refused'], + ['socket hang up', 'unavailable'] + ])('reads "%s" as %s', async (message, reason) => { + callRuntimeRpc.mockRejectedValueOnce(new Error(message)) + expect(await readNativeChatVisualFile(onHost('env-1', `${CHAT_A}/v.html`))).toEqual({ + ok: false, + reason + }) + }) + + it('refuses a binary local file and a cut-off host read', async () => { + readFile.mockResolvedValueOnce({ content: '', isBinary: true }) + callRuntimeRpc.mockResolvedValueOnce({ ...hostRead(' { + const file = onHost('env-1', `${CHAT_A}/v.html`) + callRuntimeRpc.mockResolvedValueOnce(hostRead('

kept

')) + await readNativeChatVisualFile(file) + callRuntimeRpc.mockRejectedValueOnce(new Error('timeout')) + await readNativeChatVisualFile(file) + expect(peekNativeChatVisualFile(file)?.html).toBe('

kept

') + callRuntimeRpc.mockRejectedValueOnce(new Error('ENOENT: no such file or directory')) + await readNativeChatVisualFile(file) + expect(peekNativeChatVisualFile(file)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-visual-path-read-client.ts b/src/renderer/src/components/native-chat/native-chat-visual-path-read-client.ts new file mode 100644 index 000000000000..5969d2b3a017 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-visual-path-read-client.ts @@ -0,0 +1,169 @@ +// Reading a chat visual as an ordinary file on the chat's own host: the host-published scratch +// folder joined with the reply's bare file name, read with the `chat-visual` access kind. +// +// The cache only paints a remounted visual at once; every load still reads the file. Failures are +// never cached. + +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { isMissingRuntimePathError } from '@/runtime/runtime-file-metadata-client' +import { chatVisualAccess } from '@/lib/local-file-access' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import type { RuntimeHostPathFileReadResult } from '../../../../shared/runtime-file-contracts' +import { isPathOutsideAllowedDirectoriesError } from '../../../../shared/local-file-access' + +/** A visual's identity: the host that owns the chat and the file's absolute path there. */ +export type NativeChatVisualFile = { target: RuntimeClientTarget; path: string } + +export type NativeChatVisualPage = { html: string } + +/** + * `not_found`: the file is not there (yet). `refused`: the host will not show it (outside a chat + * folder, not a file, too large, not text). `unavailable`: no answer, never a verdict on the file. + */ +export type NativeChatVisualPathReadOutcome = + | { ok: true; page: NativeChatVisualPage } + | { ok: false; reason: 'not_found' | 'refused' | 'unavailable' } + +const MAX_CACHED_PAGES = 24 +const MAX_CACHED_CHARS = 6 * 1024 * 1024 +const READ_TIMEOUT_MS = 15_000 +// The host's typed refusals of a content read, which it also gives for a visual it won't show. +const REFUSAL_MESSAGES = ['Not a regular file', 'file_too_large', 'binary_file'] + +type CachedPage = { page: NativeChatVisualPage; read: number } +type PendingRead = { read: number; outcome: Promise } + +const cache = new Map() +const inFlight = new Map() +let reads = 0 + +export function nativeChatVisualHostKey(target: RuntimeClientTarget): string { + return target.kind === 'local' ? 'local' : `environment:${target.environmentId}` +} + +export function nativeChatVisualFileKey(file: NativeChatVisualFile): string { + return JSON.stringify([nativeChatVisualHostKey(file.target), file.path]) +} + +/** + * The newest read begun so far. A caller that knows the file may have changed (a turn ended, a save + * landed) passes it as `startedAfter`, so its read never joins one begun before the change. + */ +export function markNativeChatVisualReads(): number { + return reads +} + +export function peekNativeChatVisualFile(file: NativeChatVisualFile): NativeChatVisualPage | null { + return cache.get(nativeChatVisualFileKey(file))?.page ?? null +} + +function remember(key: string, entry: CachedPage): void { + const held = cache.get(key) + // Why: reads may finish out of order; an older read must not replace a newer read's page. + if (held && held.read > entry.read) { + return + } + cache.delete(key) + cache.set(key, entry) + let chars = 0 + for (const cached of cache.values()) { + chars += cached.page.html.length + } + for (const [oldestKey, oldest] of cache) { + if (cache.size <= MAX_CACHED_PAGES && chars <= MAX_CACHED_CHARS) { + break + } + cache.delete(oldestKey) + chars -= oldest.page.html.length + } +} + +function forget(key: string, read: number): void { + const held = cache.get(key) + if (held && held.read < read) { + cache.delete(key) + } +} + +async function readContent(file: NativeChatVisualFile): Promise { + if (file.target.kind === 'local') { + const result = await window.api.fs.readFile({ filePath: file.path, access: chatVisualAccess() }) + if (result.isBinary) { + throw new Error('binary_file') + } + return result.content + } + const result = await callRuntimeRpc( + file.target, + 'files.read', + { path: file.path, access: chatVisualAccess() }, + { timeoutMs: READ_TIMEOUT_MS } + ) + // Why refuse: a cut-off page would show as if whole; the host never truncates this kind anyway. + if (result.truncated || typeof result.content !== 'string') { + throw new Error('file_too_large') + } + return result.content +} + +function failureReason(error: unknown): 'not_found' | 'refused' | 'unavailable' { + if (isMissingRuntimePathError(error)) { + return 'not_found' + } + const message = error instanceof Error ? error.message : '' + if ( + isPathOutsideAllowedDirectoriesError(error) || + REFUSAL_MESSAGES.some((refusal) => message.includes(refusal)) + ) { + return 'refused' + } + return 'unavailable' +} + +async function readOnce( + key: string, + read: number, + file: NativeChatVisualFile +): Promise { + try { + const page = { html: await readContent(file) } + remember(key, { page, read }) + return { ok: true, page } + } catch (error) { + const reason = failureReason(error) + // Why keep the cached page on `unavailable`: losing contact says nothing about the file. + if (reason !== 'unavailable') { + forget(key, read) + } + return { ok: false, reason } + } +} + +/** + * Reads the visual's file. Visuals of the same file asking at once share one read, unless the + * caller passes `startedAfter` (from {@link markNativeChatVisualReads}) and that read began earlier. + */ +export function readNativeChatVisualFile( + file: NativeChatVisualFile, + { startedAfter }: { startedAfter?: number } = {} +): Promise { + const key = nativeChatVisualFileKey(file) + const pending = inFlight.get(key) + if (pending && (startedAfter === undefined || pending.read > startedAfter)) { + return pending.outcome + } + reads += 1 + const read = reads + const outcome = readOnce(key, read, file).finally(() => { + if (inFlight.get(key)?.read === read) { + inFlight.delete(key) + } + }) + inFlight.set(key, { read, outcome }) + return outcome +} + +export function clearNativeChatVisualFileCacheForTests(): void { + cache.clear() + inFlight.clear() +} diff --git a/src/renderer/src/components/native-chat/native-chat-visual-refresh.ts b/src/renderer/src/components/native-chat/native-chat-visual-refresh.ts new file mode 100644 index 000000000000..5ce010da490f --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-visual-refresh.ts @@ -0,0 +1,99 @@ +// When a chat visual reads its file again: the triggers after which the file may have changed. +// Nothing here watches the file; a rewrite shows at the next trigger. + +import { useEffect, useState } from 'react' +import { useAppStore } from '@/store' +import type { OpenFile } from '@/store/slices/editor' +import { getRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target' +import type { WorktreeOperationRouteState } from '@/lib/worktree-operation-route' +import { ORCA_EDITOR_FILE_SAVED_EVENT } from '@/components/editor/editor-autosave' +import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' +import { + markNativeChatVisualReads, + nativeChatVisualHostKey, + type NativeChatVisualFile +} from './native-chat-visual-path-read-client' + +/** One chat's refreshes (a turn ended, its host came back), as the read mark each was taken at. */ +export type NativeChatVisualRefresh = { + /** The read mark of the latest refresh; 0 before any. */ + mark: () => number + subscribe: (listener: () => void) => () => void + refresh: () => void +} + +export function createNativeChatVisualRefresh(): NativeChatVisualRefresh { + let mark = 0 + const listeners = new Set<() => void>() + return { + mark: () => mark, + subscribe: (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + refresh: () => { + mark = markNativeChatVisualReads() + for (const listener of listeners) { + listener() + } + } + } +} + +/** Whether the editor file `fileId` is this visual's file: the same host and the same path. */ +export function editorFileIsNativeChatVisual( + state: WorktreeOperationRouteState & { openFiles: OpenFile[] }, + fileId: string, + visual: NativeChatVisualFile +): boolean { + const file = state.openFiles.find((candidate) => candidate.id === fileId) + // Why not an external SSH file: it lives on a host this chat's target does not name. + if (!file || file.externalSshTargetId?.trim()) { + return false + } + const target = getRuntimeTargetForFileOwner(state, file.worktreeId, file.runtimeEnvironmentId) + return ( + target !== null && + nativeChatVisualHostKey(target) === nativeChatVisualHostKey(visual.target) && + normalizeRuntimePathForComparison(file.filePath) === + normalizeRuntimePathForComparison(visual.path) + ) +} + +function savedFileId(event: Event): string | null { + if (!(event instanceof CustomEvent)) { + return null + } + const detail: unknown = event.detail + return typeof detail === 'object' && + detail !== null && + 'fileId' in detail && + typeof detail.fileId === 'string' + ? detail.fileId + : null +} + +/** The read mark at the latest Orca editor save of this visual's file; 0 before any. */ +export function useNativeChatVisualSavedMark(file: NativeChatVisualFile | null): number { + const [mark, setMark] = useState(0) + const environmentId = file?.target.kind === 'environment' ? file.target.environmentId : null + const path = file?.path ?? null + useEffect(() => { + if (path === null) { + return + } + const visual: NativeChatVisualFile = { + target: environmentId === null ? { kind: 'local' } : { kind: 'environment', environmentId }, + path + } + const onSaved = (event: Event): void => { + const fileId = savedFileId(event) + if (fileId && editorFileIsNativeChatVisual(useAppStore.getState(), fileId, visual)) { + setMark(markNativeChatVisualReads()) + } + } + window.addEventListener(ORCA_EDITOR_FILE_SAVED_EVENT, onSaved) + return () => window.removeEventListener(ORCA_EDITOR_FILE_SAVED_EVENT, onSaved) + }, [environmentId, path]) + return mark +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-visual-document.ts b/src/renderer/src/components/native-chat/use-native-chat-visual-document.ts index e140ce969603..9f6e691fc5e3 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-visual-document.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-visual-document.ts @@ -1,17 +1,33 @@ import { useEffect, useState } from 'react' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' import { - isRetryableNativeChatVisualFailure, peekCachedNativeChatVisual, readNativeChatVisual, - type NativeChatVisualDocument, type NativeChatVisualIdentity } from './native-chat-visual-read-client' +import { + peekNativeChatVisualFile, + readNativeChatVisualFile, + type NativeChatVisualFile, + type NativeChatVisualPage +} from './native-chat-visual-path-read-client' export type NativeChatVisualDocumentState = | { status: 'loading' } - | { status: 'ready'; document: NativeChatVisualDocument } + | { status: 'ready'; document: NativeChatVisualPage } | { status: 'unavailable' } +/** + * Where a visual's page comes from: its file on the chat's host, read again after each + * `freshAfter` mark; the older per-session endpoint; or nowhere yet (the route is still unknown). + */ +export type NativeChatVisualSource = + | { kind: 'file'; file: NativeChatVisualFile; freshAfter: number } + | { kind: 'legacy'; identity: NativeChatVisualIdentity } + | { kind: 'pending' } + +type VisualReadOutcome = { ok: true; page: NativeChatVisualPage } | { ok: false; reason: string } + /** Waits before each retry of a read that may yet succeed; bounded, then the failure shows. */ export const NATIVE_CHAT_VISUAL_RETRY_DELAYS_MS = [1_500, 4_000, 10_000] as const @@ -29,28 +45,53 @@ function waitFor(ms: number, signal: AbortSignal): Promise { }) } +function peekSource(source: NativeChatVisualSource): NativeChatVisualPage | null { + if (source.kind === 'file') { + return peekNativeChatVisualFile(source.file) + } + return source.kind === 'legacy' ? peekCachedNativeChatVisual(source.identity) : null +} + +async function readSource( + source: Exclude +): Promise { + if (source.kind === 'file') { + return readNativeChatVisualFile(source.file, { startedAfter: source.freshAfter }) + } + const outcome = await readNativeChatVisual(source.identity) + return outcome.ok ? { ok: true, page: outcome.document } : outcome +} + +function showPage( + shown: NativeChatVisualDocumentState, + page: NativeChatVisualPage +): NativeChatVisualDocumentState { + // Why compare the text: an unchanged page keeps its frame, and what the reader did in it. + return shown.status === 'ready' && shown.document.html === page.html + ? shown + : { status: 'ready', document: { html: page.html } } +} + async function loadWithRetries( - identity: NativeChatVisualIdentity, + source: Exclude, signal: AbortSignal, setState: ( update: (shown: NativeChatVisualDocumentState) => NativeChatVisualDocumentState ) => void ): Promise { for (let retry = 0; ; retry += 1) { - const outcome = await readNativeChatVisual(identity) + const outcome = await readSource(source) if (signal.aborted) { return } if (outcome.ok) { - setState((shown) => - shown.status === 'ready' && shown.document.revision === outcome.document.revision - ? shown - : { status: 'ready', document: outcome.document } - ) + setState((shown) => showPage(shown, outcome.page)) return } const delay = NATIVE_CHAT_VISUAL_RETRY_DELAYS_MS[retry] - if (!isRetryableNativeChatVisualFailure(outcome.reason) || delay === undefined) { + // Contact restored or the file landing may yet answer; any other refusal is final. + const retryable = outcome.reason === 'unavailable' || outcome.reason === 'not_found' + if (!retryable || delay === undefined) { // Why keep a shown document on `unavailable`: losing contact says nothing about the file. setState((shown) => shown.status === 'ready' && outcome.reason === 'unavailable' @@ -65,37 +106,64 @@ async function loadWithRetries( } } +function sourceFromParts( + kind: NativeChatVisualSource['kind'], + environmentId: string | null, + name: string, + sessionId: string, + freshAfter: number +): NativeChatVisualSource { + const target: RuntimeClientTarget = + environmentId === null ? { kind: 'local' } : { kind: 'environment', environmentId } + if (kind === 'file') { + return { kind, file: { target, path: name }, freshAfter } + } + return kind === 'legacy' ? { kind, identity: { target, sessionId, file: name } } : { kind } +} + /** - * The visual's current document. Starts from the cached revision when there is one, then asks the - * host (revalidating it) once `enabled`, and again whenever `refreshKey` changes. A later failure - * keeps a document already shown. + * The visual's current page. Starts from the cached copy when there is one, then reads once + * `enabled`, and again whenever the source, its `freshAfter` mark or `refreshKey` changes. A page + * that comes back unchanged keeps its frame, and a later failure keeps a page already shown. */ export function useNativeChatVisualDocument( - identity: NativeChatVisualIdentity, + source: NativeChatVisualSource, enabled: boolean, refreshKey?: unknown ): NativeChatVisualDocumentState { - const { sessionId, file } = identity - const environmentId = - identity.target.kind === 'environment' ? identity.target.environmentId : null const [state, setState] = useState(() => { - const cached = peekCachedNativeChatVisual(identity) + const cached = peekSource(source) return cached ? { status: 'ready', document: cached } : { status: 'loading' } }) + const kind = source.kind + const environmentId = + source.kind === 'file' + ? targetEnvironmentId(source.file.target) + : source.kind === 'legacy' + ? targetEnvironmentId(source.identity.target) + : null + const name = + source.kind === 'file' ? source.file.path : source.kind === 'legacy' ? source.identity.file : '' + const sessionId = source.kind === 'legacy' ? source.identity.sessionId : '' + const freshAfter = source.kind === 'file' ? source.freshAfter : 0 useEffect(() => { if (!enabled) { return } - const identityNow: NativeChatVisualIdentity = { - target: environmentId === null ? { kind: 'local' } : { kind: 'environment', environmentId }, - sessionId, - file + const sourceNow = sourceFromParts(kind, environmentId, name, sessionId, freshAfter) + // Why keep the state on `pending`: the route is unknown, which says nothing about the page. + if (sourceNow.kind === 'pending') { + return } const abort = new AbortController() - void loadWithRetries(identityNow, abort.signal, setState) + void loadWithRetries(sourceNow, abort.signal, setState) return () => abort.abort() - }, [enabled, environmentId, file, sessionId, refreshKey]) + }, [enabled, kind, environmentId, name, sessionId, freshAfter, refreshKey]) return state } + +function targetEnvironmentId(target: RuntimeClientTarget): string | null { + return target.kind === 'environment' ? target.environmentId : null +} diff --git a/src/shared/native-chat-visual-host-path.test.ts b/src/shared/native-chat-visual-host-path.test.ts new file mode 100644 index 000000000000..a653d10909e1 --- /dev/null +++ b/src/shared/native-chat-visual-host-path.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { nativeChatVisualHostPath } from './native-chat-visual-host-path' + +const HEX = '0123456789abcdef0123456789abcdef' + +describe('nativeChatVisualHostPath', () => { + it('joins a POSIX host folder with a slash', () => { + expect(nativeChatVisualHostPath(`/home/me/.config/orca/scratch/${HEX}`, 'chart.html')).toBe( + `/home/me/.config/orca/scratch/${HEX}/chart.html` + ) + }) + + it('joins a Windows host folder with a backslash, whatever this client runs on', () => { + expect( + nativeChatVisualHostPath(`C:\\Users\\me\\AppData\\Roaming\\orca\\scratch\\${HEX}`, 'a.html') + ).toBe(`C:\\Users\\me\\AppData\\Roaming\\orca\\scratch\\${HEX}\\a.html`) + expect(nativeChatVisualHostPath(`\\\\server\\share\\orca\\${HEX}`, 'a.html')).toBe( + `\\\\server\\share\\orca\\${HEX}\\a.html` + ) + }) + + it('builds nothing for a name that is not a bare visual file', () => { + for (const file of [ + '', + '../x.html', + 'sub/x.html', + 'sub\\x.html', + '.hidden.html', + 'x.htm', + 'con.html', + 'a..html' + ]) { + expect(nativeChatVisualHostPath(`/state/${HEX}`, file)).toBeNull() + } + }) + + it('builds nothing without a folder', () => { + expect(nativeChatVisualHostPath('', 'chart.html')).toBeNull() + }) +}) diff --git a/src/shared/native-chat-visual-host-path.ts b/src/shared/native-chat-visual-host-path.ts new file mode 100644 index 000000000000..1019473ebf34 --- /dev/null +++ b/src/shared/native-chat-visual-host-path.ts @@ -0,0 +1,15 @@ +import { isWindowsAbsolutePathLike } from './cross-platform-path' +import { isNativeChatVisualFileName } from './native-chat-visual-directive' + +/** + * The host path of a chat's visual: `file` directly inside the chat's host-published + * `scratchDirectory`, joined in the host's own path syntax (clients may run on another OS). + * Null when `file` is not a valid bare visual name, so no other path is ever built. + */ +export function nativeChatVisualHostPath(scratchDirectory: string, file: string): string | null { + if (scratchDirectory.length === 0 || !isNativeChatVisualFileName(file)) { + return null + } + const separator = isWindowsAbsolutePathLike(scratchDirectory) ? '\\' : '/' + return `${scratchDirectory}${separator}${file}` +} From b41ef57b0b7ba047156be07e4c1843164f196ccf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:09:26 -0700 Subject: [PATCH 2/2] fix(native-chat): wait on a host's path-read probe only while it runs A failed or silent probe left a capable host's visuals as blank placeholders. Visuals now wait only while the files.host-path.v1 probe is running, for at most 15 s, then read through the older endpoint, which every host serves; a later answer on regained contact moves them to path reads. A local chat never waits, and a pending visual paints a page either reader already cached. The hook calls the shared retry rule. --- .../NativeChatInlineVisual.host-file.test.tsx | 26 ++- .../native-chat/NativeChatInlineVisual.tsx | 22 +-- .../native-chat-visual-owner.test.tsx | 155 +++++++++++++----- .../native-chat/native-chat-visual-owner.tsx | 43 +++-- .../native-chat-visual-read-client.ts | 4 +- .../use-native-chat-visual-document.ts | 46 +++--- ...tructured-agent-session-host-capability.ts | 43 ++++- 7 files changed, 241 insertions(+), 98 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatInlineVisual.host-file.test.tsx b/src/renderer/src/components/native-chat/NativeChatInlineVisual.host-file.test.tsx index 04e6e29b6324..b4a9537291f1 100644 --- a/src/renderer/src/components/native-chat/NativeChatInlineVisual.host-file.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInlineVisual.host-file.test.tsx @@ -167,13 +167,37 @@ describe('inline visuals read as files on the chat host', () => { }) it('reads nothing while the host has not said how it reads files', async () => { - const { container } = render(chat({ kind: 'local' }, reading({ kind: 'pending' }))) + const pending = reading({ kind: 'pending', scratchDirectory: LOCAL_FOLDER }) + const { container } = render(chat({ kind: 'local' }, pending)) await Promise.resolve() expect(container.querySelector('[role="status"]')).not.toBeNull() expect(readFile).not.toHaveBeenCalled() expect(callRuntimeRpc).not.toHaveBeenCalled() }) + it.each([ + ['by path', fileRoute(LOCAL_FOLDER)], + ['through the older endpoint', { kind: 'legacy' as const }] + ])('paints a page cached %s while the route is still being learned', async (_, route) => { + callRuntimeRpc.mockResolvedValue({ + ok: true, + revision: 'r1', + sizeBytes: 3, + html: '

first

' + }) + const first = render(chat({ kind: 'local' }, reading(route))) + await waitFor(() => expect(frameSource(first.container)).toContain('

first

')) + cleanup() + const reads = readFile.mock.calls.length + callRuntimeRpc.mock.calls.length + + // The windowed transcript remounts the reply while the host's answer is still pending. + const pending = reading({ kind: 'pending', scratchDirectory: LOCAL_FOLDER }) + const { container } = render(chat({ kind: 'local' }, pending)) + expect(frameSource(container)).toContain('

first

') + await Promise.resolve() + expect(readFile.mock.calls.length + callRuntimeRpc.mock.calls.length).toBe(reads) + }) + it('shows a rewritten file when the turn ends, and keeps the frame when nothing changed', async () => { const value = reading(fileRoute(LOCAL_FOLDER)) const view = render(chat({ kind: 'local' }, value, )) diff --git a/src/renderer/src/components/native-chat/NativeChatInlineVisual.tsx b/src/renderer/src/components/native-chat/NativeChatInlineVisual.tsx index c4d52db1f8dc..4c2c22f833a3 100644 --- a/src/renderer/src/components/native-chat/NativeChatInlineVisual.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInlineVisual.tsx @@ -37,18 +37,18 @@ function useNativeChatVisualHostFile( ): NativeChatVisualHostFile { const route = reading?.route ?? null return useMemo(() => { - const legacy: NativeChatVisualHostFile = { - kind: 'legacy', - identity: { target: owner.target, sessionId: owner.sessionId, file } + const identity = { target: owner.target, sessionId: owner.sessionId, file } + const path = + route === null || route.kind === 'legacy' + ? null + : nativeChatVisualHostPath(route.scratchDirectory, file) + if (route === null || path === null) { + return { kind: 'legacy', identity } } - if (route === null || route.kind === 'legacy') { - return legacy - } - if (route.kind === 'pending') { - return { kind: 'pending' } - } - const path = nativeChatVisualHostPath(route.scratchDirectory, file) - return path === null ? legacy : { kind: 'file', file: { target: owner.target, path } } + const hostFile = { target: owner.target, path } + return route.kind === 'pending' + ? { kind: 'pending', file: hostFile, identity } + : { kind: 'file', file: hostFile } }, [file, owner.sessionId, owner.target, route]) } diff --git a/src/renderer/src/components/native-chat/native-chat-visual-owner.test.tsx b/src/renderer/src/components/native-chat/native-chat-visual-owner.test.tsx index 52260909a597..23d4f4fcafaf 100644 --- a/src/renderer/src/components/native-chat/native-chat-visual-owner.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-visual-owner.test.tsx @@ -1,9 +1,10 @@ // @vitest-environment happy-dom -import { act, cleanup, render } from '@testing-library/react' +import '@testing-library/jest-dom/vitest' + +import { act, cleanup, render, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' -import type { StructuredAgentSessionHostCapabilityState } from '@/runtime/structured-agent-session-host-capability' const statusFeedFor = vi.fn() let summaries = new Map() @@ -17,36 +18,44 @@ vi.mock('@/runtime/structured-agent-session-status-feed', () => ({ } } })) -let hostPathFiles: StructuredAgentSessionHostCapabilityState = 'supported' -const capabilityAskedOf = vi.fn() -vi.mock('@/runtime/structured-agent-session-host-capability', () => ({ - useStructuredAgentSessionHostCapabilityState: ( - target: RuntimeClientTarget, - capability: string - ) => { - capabilityAskedOf(target, capability) - return hostPathFiles - } +const supports = vi.fn() +const callRuntimeRpc = vi.fn() +vi.mock('@/runtime/runtime-rpc-client', () => ({ + runtimeEnvironmentSupportsCapability: (...args: unknown[]) => supports(...args), + callRuntimeRpc: (...args: unknown[]) => callRuntimeRpc(...args) })) -const contactListeners = new Map void>() +const contactListeners = new Map void>>() vi.mock('@/runtime/runtime-host-contact-regained', () => ({ subscribeRuntimeHostContactRegained: (environmentId: string, listener: () => void) => { - contactListeners.set(environmentId, listener) - return () => contactListeners.delete(environmentId) + const listeners = contactListeners.get(environmentId) ?? new Set() + listeners.add(listener) + contactListeners.set(environmentId, listeners) + return () => listeners.delete(listener) } })) +function regainContact(environmentId: string): void { + for (const listener of contactListeners.get(environmentId) ?? []) { + listener() + } +} let tabWorktreeId: string | null = 'wt-1' vi.mock('./use-native-chat-tab-owner', () => ({ useNativeChatTabOwnerWorktreeId: () => tabWorktreeId })) import { + NATIVE_CHAT_VISUAL_PROBE_WAIT_MS, NativeChatVisualOwnerProvider, useNativeChatVisualOwner, useNativeChatVisualReading, type NativeChatVisualOwner, type NativeChatVisualReading } from './native-chat-visual-owner' +import { NativeChatMarkdown } from './NativeChatMarkdown' +import { clearNativeChatVisualCacheForTests } from './native-chat-visual-read-client' +import { clearNativeChatVisualFileCacheForTests } from './native-chat-visual-path-read-client' +import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' +import { TooltipProvider } from '@/components/ui/tooltip' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' const FOLDER = '/state/chat-scratch/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' @@ -68,46 +77,100 @@ function chat(target: RuntimeClientTarget): React.JSX.Element { ) } +const never = (): Promise => new Promise(() => {}) + +// The transcript mounts a visual once it scrolls near; here every visual is in view at once. +class InViewObserver { + constructor(private readonly callback: (entries: Partial[]) => void) {} + observe(target: Element): void { + this.callback([{ target, isIntersecting: true }]) + } + unobserve(): void {} + disconnect(): void {} +} + beforeEach(() => { summaries = new Map([['session-alpha', { scratchDirectory: FOLDER }]]) - hostPathFiles = 'supported' + supports.mockReset() + supports.mockResolvedValue(true) + callRuntimeRpc.mockReset() tabWorktreeId = 'wt-1' contactListeners.clear() statusFeedFor.mockReset() - capabilityAskedOf.mockReset() seenOwner = null seenReading = null + // Unknown local capabilities: a local chat must not wait on them. + setLocalRuntimeCapabilitiesForTests(null) + vi.stubGlobal('api', { runtime: { getStatus: never } }) }) afterEach(() => { cleanup() + vi.useRealTimers() + vi.unstubAllGlobals() }) describe('NativeChatVisualOwnerProvider', () => { - it("reads visuals by path from the folder the chat's recorded host publishes", () => { + it("reads visuals by path from the folder the chat's recorded host publishes", async () => { render(chat(HOST)) + await waitFor(() => + expect(seenReading?.route).toEqual({ kind: 'file', scratchDirectory: FOLDER }) + ) expect(seenOwner?.target).toEqual(HOST) - expect(seenReading?.route).toEqual({ kind: 'file', scratchDirectory: FOLDER }) expect(statusFeedFor).toHaveBeenCalledWith(HOST) - expect(capabilityAskedOf).toHaveBeenCalledWith(HOST, 'files.host-path.v1') + expect(supports).toHaveBeenCalledWith('env-1', 'files.host-path.v1') }) - it.each<[string, () => void, NativeChatVisualReading['route']['kind']]>([ - [ - 'a host that reads files by path but has not said so yet', - () => (hostPathFiles = 'unknown'), - 'pending' - ], - ['a host without path reads', () => (hostPathFiles = 'unsupported'), 'legacy'], - [ - 'a host that publishes no folder (older, or a WSL chat)', - () => (summaries = new Map()), - 'legacy' - ] - ])('routes %s', (_, arrange, kind) => { + it('waits while the probe runs, then uses the older endpoint once the wait is up', async () => { + vi.useFakeTimers() + supports.mockImplementation(never) + render(chat(HOST)) + expect(seenReading?.route).toEqual({ kind: 'pending', scratchDirectory: FOLDER }) + await act(async () => { + await vi.advanceTimersByTimeAsync(NATIVE_CHAT_VISUAL_PROBE_WAIT_MS - 1) + }) + expect(seenReading?.route.kind).toBe('pending') + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + }) + expect(seenReading?.route.kind).toBe('legacy') + }) + + it.each<[string, () => void]>([ + ['a probe that fails', () => supports.mockRejectedValue(new Error('timeout'))], + ['a host without path reads', () => supports.mockResolvedValue(false)], + ['a host that publishes no folder (older, or a WSL chat)', () => (summaries = new Map())] + ])('uses the older endpoint for %s', async (_, arrange) => { arrange() render(chat(HOST)) - expect(seenReading?.route.kind).toBe(kind) + await waitFor(() => expect(seenReading?.route.kind).toBe('legacy')) + }) + + it('reads through the older endpoint, not a blank box, when the probe fails', async () => { + supports.mockRejectedValue(new Error('runtime call queue overloaded')) + callRuntimeRpc.mockResolvedValue({ ok: true, revision: 'r1', sizeBytes: 3, html: '

v

' }) + clearNativeChatVisualCacheForTests() + clearNativeChatVisualFileCacheForTests() + vi.stubGlobal('IntersectionObserver', InViewObserver) + const { container } = render( + + + + + + ) + await waitFor(() => expect(container.querySelector('iframe')).not.toBeNull()) + expect(callRuntimeRpc).toHaveBeenCalledWith( + HOST, + 'agentSession.readVisual', + { sessionId: 'session-alpha', file: 'usage.html' }, + { timeoutMs: 15_000 } + ) + }) + + it('a local chat reads by path at once, without waiting on any probe', () => { + render(chat({ kind: 'local' })) + expect(seenReading?.route).toEqual({ kind: 'file', scratchDirectory: FOLDER }) }) it('serves a floating chat, whose workspace is the floating one, the same way', () => { @@ -121,25 +184,33 @@ describe('NativeChatVisualOwnerProvider', () => { expect(seenReading?.route).toEqual({ kind: 'file', scratchDirectory: FOLDER }) }) - it("refreshes the chat's visuals when its host answers again", () => { + it("refreshes the chat's visuals when its host answers again, and asks it again", async () => { render(chat(HOST)) + await waitFor(() => expect(supports).toHaveBeenCalledTimes(1)) const listener = vi.fn() seenReading?.refresh.subscribe(listener) - act(() => contactListeners.get('env-1')?.()) + act(() => regainContact('env-1')) expect(listener).toHaveBeenCalledTimes(1) + await waitFor(() => expect(supports).toHaveBeenCalledTimes(2)) }) it('a local chat has no host to come back', () => { render(chat({ kind: 'local' })) - expect(contactListeners.size).toBe(0) + expect([...contactListeners.values()].every((listeners) => listeners.size === 0)).toBe(true) }) - it('keeps the owner when the route changes, so shown visuals are not remounted', () => { - hostPathFiles = 'unknown' - const view = render(chat(HOST)) + it('keeps the owner when the route changes, so shown visuals are not remounted', async () => { + let answer: (supported: boolean) => void = () => {} + supports.mockImplementation( + () => + new Promise((resolve) => { + answer = resolve + }) + ) + render(chat(HOST)) + expect(seenReading?.route.kind).toBe('pending') const owner = seenOwner - hostPathFiles = 'supported' - view.rerender(chat({ kind: 'environment', environmentId: 'env-1' })) + await act(async () => answer(true)) expect(seenReading?.route.kind).toBe('file') expect(seenOwner).toBe(owner) }) diff --git a/src/renderer/src/components/native-chat/native-chat-visual-owner.tsx b/src/renderer/src/components/native-chat/native-chat-visual-owner.tsx index a0e29b8ad2f9..e0d0be585405 100644 --- a/src/renderer/src/components/native-chat/native-chat-visual-owner.tsx +++ b/src/renderer/src/components/native-chat/native-chat-visual-owner.tsx @@ -4,13 +4,14 @@ import { useEffect, useMemo, useRef, + useState, useSyncExternalStore, type ReactNode } from 'react' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' import { FILES_HOST_PATH_RUNTIME_CAPABILITY } from '../../../../shared/runtime-file-contracts' import { getStructuredAgentSessionStatusFeed } from '@/runtime/structured-agent-session-status-feed' -import { useStructuredAgentSessionHostCapabilityState } from '@/runtime/structured-agent-session-host-capability' +import { useStructuredAgentSessionHostCapabilityProbe } from '@/runtime/structured-agent-session-host-capability' import { subscribeRuntimeHostContactRegained } from '@/runtime/runtime-host-contact-regained' import { useNativeChatTabOwnerWorktreeId } from './use-native-chat-tab-owner' import { @@ -27,13 +28,13 @@ export type NativeChatVisualOwner = { /** * How the chat's visuals are read: as files in its scratch folder on its host, through the older - * per-session endpoint (a host without path reads or without a published folder), or not yet - * known (the host has not said whether it reads by path). + * per-session endpoint (no published folder, or a host not known to read by path), or not yet + * (a probe of whether the host reads by path is still running). */ export type NativeChatVisualReadRoute = | { kind: 'file'; scratchDirectory: string } | { kind: 'legacy' } - | { kind: 'pending' } + | { kind: 'pending'; scratchDirectory: string } /** Kept apart from the owner so a change here re-renders a visual without remounting its frame. */ export type NativeChatVisualReading = { @@ -45,7 +46,6 @@ export const NativeChatVisualOwnerContext = createContext(null) const LEGACY_ROUTE: NativeChatVisualReadRoute = { kind: 'legacy' } -const PENDING_ROUTE: NativeChatVisualReadRoute = { kind: 'pending' } /** Null outside a structured chat's transcript, where no visual can be read. */ export function useNativeChatVisualOwner(): NativeChatVisualOwner | null { @@ -84,22 +84,45 @@ function useNativeChatScratchDirectory( ) } +/** How long a visual waits on a running capability probe before using the older endpoint. */ +export const NATIVE_CHAT_VISUAL_PROBE_WAIT_MS = 15_000 + +/** True while the host's capability probe runs, for at most {@link NATIVE_CHAT_VISUAL_PROBE_WAIT_MS}. */ +function useWaitingOnProbe(probe: string, probing: boolean): boolean { + const [expiredProbe, setExpiredProbe] = useState(null) + useEffect(() => { + if (!probing) { + return + } + const timer = setTimeout(() => setExpiredProbe(probe), NATIVE_CHAT_VISUAL_PROBE_WAIT_MS) + return () => clearTimeout(timer) + }, [probe, probing]) + return probing && expiredProbe !== probe +} + function useNativeChatVisualReadRoute( sessionId: string, target: RuntimeClientTarget ): NativeChatVisualReadRoute { const scratchDirectory = useNativeChatScratchDirectory(sessionId, target) - const hostPathFiles = useStructuredAgentSessionHostCapabilityState( + const hostPathFiles = useStructuredAgentSessionHostCapabilityProbe( target, FILES_HOST_PATH_RUNTIME_CAPABILITY ) + const waiting = useWaitingOnProbe(hostPathFiles.probe, hostPathFiles.probing) + const local = target.kind === 'local' return useMemo(() => { - if (scratchDirectory === null || hostPathFiles === 'unsupported') { + if (scratchDirectory === null) { return LEGACY_ROUTE } - // Why wait on `unknown`: reading through the older endpoint first would read every visual twice. - return hostPathFiles === 'supported' ? { kind: 'file', scratchDirectory } : PENDING_ROUTE - }, [hostPathFiles, scratchDirectory]) + // Why local reads at once: this build's own desktop IPC always serves the chat-visual kind. + if (local || hostPathFiles.state === 'supported') { + return { kind: 'file', scratchDirectory } + } + // Why wait only on a running probe: reading through the older endpoint first would read every + // visual twice, but a failed or silent probe says nothing, and that endpoint works on every host. + return waiting ? { kind: 'pending', scratchDirectory } : LEGACY_ROUTE + }, [hostPathFiles.state, local, scratchDirectory, waiting]) } /** Names the structured chat below it as the owner of the visuals its replies show. */ diff --git a/src/renderer/src/components/native-chat/native-chat-visual-read-client.ts b/src/renderer/src/components/native-chat/native-chat-visual-read-client.ts index 9d78ec3d9e92..56e98b9a58f4 100644 --- a/src/renderer/src/components/native-chat/native-chat-visual-read-client.ts +++ b/src/renderer/src/components/native-chat/native-chat-visual-read-client.ts @@ -146,9 +146,7 @@ export function readNativeChatVisual( } /** Whether a failed read may succeed if asked again shortly: contact restored, or the file landing. */ -export function isRetryableNativeChatVisualFailure( - reason: AgentSessionVisualReadError | 'unavailable' -): boolean { +export function isRetryableNativeChatVisualFailure(reason: string): boolean { return reason === 'unavailable' || reason === 'not_found' } diff --git a/src/renderer/src/components/native-chat/use-native-chat-visual-document.ts b/src/renderer/src/components/native-chat/use-native-chat-visual-document.ts index 9f6e691fc5e3..141bbb0e7746 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-visual-document.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-visual-document.ts @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' import { + isRetryableNativeChatVisualFailure, peekCachedNativeChatVisual, readNativeChatVisual, type NativeChatVisualIdentity @@ -19,12 +20,15 @@ export type NativeChatVisualDocumentState = /** * Where a visual's page comes from: its file on the chat's host, read again after each - * `freshAfter` mark; the older per-session endpoint; or nowhere yet (the route is still unknown). + * `freshAfter` mark; the older per-session endpoint; or neither yet (the route is still being + * learned), which reads nothing but shows a page either reader already cached. */ export type NativeChatVisualSource = | { kind: 'file'; file: NativeChatVisualFile; freshAfter: number } | { kind: 'legacy'; identity: NativeChatVisualIdentity } - | { kind: 'pending' } + | { kind: 'pending'; file: NativeChatVisualFile; identity: NativeChatVisualIdentity } + +type ReadableNativeChatVisualSource = Exclude type VisualReadOutcome = { ok: true; page: NativeChatVisualPage } | { ok: false; reason: string } @@ -49,12 +53,13 @@ function peekSource(source: NativeChatVisualSource): NativeChatVisualPage | null if (source.kind === 'file') { return peekNativeChatVisualFile(source.file) } - return source.kind === 'legacy' ? peekCachedNativeChatVisual(source.identity) : null + if (source.kind === 'legacy') { + return peekCachedNativeChatVisual(source.identity) + } + return peekNativeChatVisualFile(source.file) ?? peekCachedNativeChatVisual(source.identity) } -async function readSource( - source: Exclude -): Promise { +async function readSource(source: ReadableNativeChatVisualSource): Promise { if (source.kind === 'file') { return readNativeChatVisualFile(source.file, { startedAfter: source.freshAfter }) } @@ -73,7 +78,7 @@ function showPage( } async function loadWithRetries( - source: Exclude, + source: ReadableNativeChatVisualSource, signal: AbortSignal, setState: ( update: (shown: NativeChatVisualDocumentState) => NativeChatVisualDocumentState @@ -89,9 +94,7 @@ async function loadWithRetries( return } const delay = NATIVE_CHAT_VISUAL_RETRY_DELAYS_MS[retry] - // Contact restored or the file landing may yet answer; any other refusal is final. - const retryable = outcome.reason === 'unavailable' || outcome.reason === 'not_found' - if (!retryable || delay === undefined) { + if (!isRetryableNativeChatVisualFailure(outcome.reason) || delay === undefined) { // Why keep a shown document on `unavailable`: losing contact says nothing about the file. setState((shown) => shown.status === 'ready' && outcome.reason === 'unavailable' @@ -106,19 +109,20 @@ async function loadWithRetries( } } -function sourceFromParts( +/** The source the effect reads, rebuilt from its primitive parts; null while the route is pending. */ +function readableSourceFromParts( kind: NativeChatVisualSource['kind'], environmentId: string | null, name: string, sessionId: string, freshAfter: number -): NativeChatVisualSource { +): ReadableNativeChatVisualSource | null { const target: RuntimeClientTarget = environmentId === null ? { kind: 'local' } : { kind: 'environment', environmentId } if (kind === 'file') { return { kind, file: { target, path: name }, freshAfter } } - return kind === 'legacy' ? { kind, identity: { target, sessionId, file: name } } : { kind } + return kind === 'legacy' ? { kind, identity: { target, sessionId, file: name } } : null } /** @@ -136,14 +140,10 @@ export function useNativeChatVisualDocument( return cached ? { status: 'ready', document: cached } : { status: 'loading' } }) const kind = source.kind - const environmentId = - source.kind === 'file' - ? targetEnvironmentId(source.file.target) - : source.kind === 'legacy' - ? targetEnvironmentId(source.identity.target) - : null - const name = - source.kind === 'file' ? source.file.path : source.kind === 'legacy' ? source.identity.file : '' + const environmentId = targetEnvironmentId( + source.kind === 'legacy' ? source.identity.target : source.file.target + ) + const name = source.kind === 'legacy' ? source.identity.file : source.file.path const sessionId = source.kind === 'legacy' ? source.identity.sessionId : '' const freshAfter = source.kind === 'file' ? source.freshAfter : 0 @@ -151,9 +151,9 @@ export function useNativeChatVisualDocument( if (!enabled) { return } - const sourceNow = sourceFromParts(kind, environmentId, name, sessionId, freshAfter) + const sourceNow = readableSourceFromParts(kind, environmentId, name, sessionId, freshAfter) // Why keep the state on `pending`: the route is unknown, which says nothing about the page. - if (sourceNow.kind === 'pending') { + if (sourceNow === null) { return } const abort = new AbortController() diff --git a/src/renderer/src/runtime/structured-agent-session-host-capability.ts b/src/renderer/src/runtime/structured-agent-session-host-capability.ts index e10d12804cfd..cfbc42120161 100644 --- a/src/renderer/src/runtime/structured-agent-session-host-capability.ts +++ b/src/renderer/src/runtime/structured-agent-session-host-capability.ts @@ -23,19 +23,42 @@ export function structuredAgentSessionHostKey(target: RuntimeClientTarget): stri * evidence about the host. */ export type StructuredAgentSessionHostCapabilityState = 'unknown' | 'supported' | 'unsupported' +/** A host capability answer, and whether the probe that may change it is still running. */ +export type StructuredAgentSessionHostCapabilityProbe = { + state: StructuredAgentSessionHostCapabilityState + /** Names the probe now running or last settled; a new probe starts on each regained contact. */ + probe: string + /** From a probe's start until it settles; a failed probe has settled. */ + probing: boolean +} + /** Whether the session's host advertises `capability`. A failed probe stays `unknown` and a remote * host is asked again each time this client regains contact with it. */ export function useStructuredAgentSessionHostCapabilityState( target: RuntimeClientTarget, capability: RuntimeCapability ): StructuredAgentSessionHostCapabilityState { + return useStructuredAgentSessionHostCapabilityProbe(target, capability).state +} + +/** {@link useStructuredAgentSessionHostCapabilityState}, also saying whether its probe still runs. */ +export function useStructuredAgentSessionHostCapabilityProbe( + target: RuntimeClientTarget, + capability: RuntimeCapability +): StructuredAgentSessionHostCapabilityProbe { const key = structuredAgentSessionHostKey(target) + const environmentId = target.kind === 'environment' ? target.environmentId : null + const [contact, setContact] = useState(0) + const probe = `${key}|${capability}|${contact}` const [answer, setAnswer] = useState<{ key: string + probe: string | null state: StructuredAgentSessionHostCapabilityState - }>(() => ({ key, state: target.kind === 'local' ? localCapabilityState(capability) : 'unknown' })) - const environmentId = target.kind === 'environment' ? target.environmentId : null - const [contact, setContact] = useState(0) + }>(() => ({ + key, + probe: null, + state: target.kind === 'local' ? localCapabilityState(capability) : 'unknown' + })) useEffect(() => { if (environmentId === null) { return @@ -44,26 +67,30 @@ export function useStructuredAgentSessionHostCapabilityState( }, [environmentId]) useEffect(() => { let cancelled = false - const probe: Promise = + const probing: Promise = environmentId === null ? ensureLocalRuntimeCapabilities().then(() => localCapabilityState(capability)) : runtimeEnvironmentSupportsCapability(environmentId, capability).then((supported) => supported ? 'supported' : 'unsupported' ) - void probe + void probing .catch((): StructuredAgentSessionHostCapabilityState => 'unknown') .then((state) => { if (!cancelled) { setAnswer((current) => - current.key === key && current.state === state ? current : { key, state } + current.key === key && current.state === state && current.probe === probe + ? current + : { key, probe, state } ) } }) return () => { cancelled = true } - }, [capability, contact, environmentId, key]) - return answer.key === key ? answer.state : 'unknown' + }, [capability, environmentId, key, probe]) + return answer.key === key + ? { state: answer.state, probe, probing: answer.probe !== probe } + : { state: 'unknown', probe, probing: true } } function localCapabilityState(