From 5cefb440bfc004a3098a911bba6aaf274699cdc5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:25:40 -0700 Subject: [PATCH 01/74] fix(native-chat): stop an unanswered host from reading as one that refuses structured chat (#19321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(native-chat): stop an unanswered host from reading as one that refuses structured chat `readLocalRuntimeCapabilities()` returned `[]` both before the first status probe landed and after one failed, so "not asked yet" and "host says no" were the same value. Every structured-chat launch route consumed it, and an unprobed host was routed to legacy chat exactly as a refusing one is. Keep the two apart: the cache holds `null` until a probe succeeds, a failed probe leaves it `null` rather than emptying it, and the launch route names the case with its own blocker instead of borrowing `runtime-capability`. No routing outcome changes — both cases still decline structured chat. The point is that the reason is now truthful, which is what the routing work needs to build on: once a launch can target a runtime peer, capabilities come from that host, and an unanswered remote must not be indistinguishable from one that refuses. `hostCapabilities` on the launch route stays local-only at every call site; a per-target resolver replaces it when the route learns to reach a peer. * test: cover unknown runtime capability lifecycle and launch fallback --------- Co-authored-by: Merge Sim --- .../orchestration-worker-start-mode.ts | 5 +++- ...-vault-session-resume-in-chat-workspace.ts | 7 +++-- .../folder-workspace-composer-submit.ts | 4 +-- .../composer-state/full-creation-execution.ts | 4 +-- .../quick-creation-execution.ts | 4 +-- .../src/lib/agent-launch-routing.test.ts | 1 + src/renderer/src/lib/agent-launch-routing.ts | 3 ++- .../src/lib/launch-agent-in-new-tab.ts | 4 +-- ...launch-agent-structured-chat-guard.test.ts | 23 +++++++++------- ...unch-work-item-direct-route-preparation.ts | 4 +-- .../lib/onboarding-folder-agent-startup.ts | 4 +-- .../local-runtime-capabilities.test.ts | 26 +++++++++++++++++++ .../src/runtime/local-runtime-capabilities.ts | 16 +++++++++--- ...tructured-native-chat-launch-route.test.ts | 3 ++- .../structured-native-chat-launch-route.ts | 9 ++++++- 15 files changed, 86 insertions(+), 31 deletions(-) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts index c1f22a2c3f4..d079b48de25 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts @@ -88,7 +88,10 @@ const BLOCKER_REASON: Record< 'tui-launch-customization': 'tui_launch_customization', 'remote-execution-host': 'remote_execution_host', 'project-runtime': 'wsl_execution_runtime', - 'runtime-capability': 'structured_sessions_unavailable' + 'runtime-capability': 'structured_sessions_unavailable', + // Orchestration passes its own host's list, so this is unreachable there; the map is + // exhaustive by type and must still name it. + 'runtime-capability-unknown': 'structured_sessions_unavailable' } /** The host's own create-support verdict (`agentSession.createSupport`) in this vocabulary. */ diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts index a1f2a296748..a16e0d74c63 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts @@ -4,7 +4,10 @@ import { } from '@/lib/agent-launch-routing' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' -import { readLocalRuntimeCapabilities } from '@/runtime/local-runtime-capabilities' +import { + readLocalRuntimeCapabilities, + readLocalRuntimeCapabilitiesOrUnknown +} from '@/runtime/local-runtime-capabilities' import { useAppStore } from '@/store' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' @@ -46,7 +49,7 @@ export function resolveAiVaultSessionResumeInChatForWorkspace(args: { useAppStore.getState(), targetWorkspaceId as string ), - hostCapabilities: readLocalRuntimeCapabilities(), + hostCapabilities: readLocalRuntimeCapabilitiesOrUnknown(), workspaceKind: (targetWorkspaceId as string).startsWith('folder:') ? 'folder' : 'git-worktree', diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index c016538cac9..80ab7ad16b7 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -23,7 +23,7 @@ import { hasExplicitTuiAgentArgs, resolveAgentLaunchRoute } from '@/lib/agent-launch-routing' -import { readLocalRuntimeCapabilities } from '@/runtime/local-runtime-capabilities' +import { readLocalRuntimeCapabilitiesOrUnknown } from '@/runtime/local-runtime-capabilities' import { startStructuredAgentLaunch } from '@/lib/structured-agent-session-launch' import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' @@ -147,7 +147,7 @@ export async function submitFolderWorkspaceCreate({ executionHostId: runtimeEnvironmentId ? `runtime:${encodeURIComponent(runtimeEnvironmentId)}` : (projectGroup.connectionId ?? 'local'), - hostCapabilities: readLocalRuntimeCapabilities(), + hostCapabilities: readLocalRuntimeCapabilitiesOrUnknown(), workspaceKind: 'folder', promptDelivery: launchDraftPrompt ? 'draft' : 'auto-submit', launchText: launchDraftPrompt ?? note, diff --git a/src/renderer/src/hooks/composer-state/full-creation-execution.ts b/src/renderer/src/hooks/composer-state/full-creation-execution.ts index 0118f6c2236..f85488b0d49 100644 --- a/src/renderer/src/hooks/composer-state/full-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/full-creation-execution.ts @@ -42,7 +42,7 @@ import { hasExplicitTuiLaunchCustomization, resolveAgentLaunchRoute } from '@/lib/agent-launch-routing' -import { readLocalRuntimeCapabilities } from '@/runtime/local-runtime-capabilities' +import { readLocalRuntimeCapabilitiesOrUnknown } from '@/runtime/local-runtime-capabilities' import { settleFullCreationStructuredLaunch } from './full-creation-structured-launch' import { finalizeFullCreation } from './full-creation-finalization' import { buildFullCreationIssueCommand } from './full-creation-issue-command' @@ -140,7 +140,7 @@ export function useFullCreationExecution(input: FullCreationExecutionInput) { agent: tuiAgent, settings, executionHostId: selectedRepoExecutionHostId ?? 'local', - hostCapabilities: readLocalRuntimeCapabilities(), + hostCapabilities: readLocalRuntimeCapabilitiesOrUnknown(), workspaceKind: selectedRepoIsGit ? 'git-worktree' : 'folder', promptDelivery: startupPlan?.draftPrompt ? 'draft' : 'auto-submit', launchText: startupPlan?.draftPrompt ?? submitStartupPrompt, diff --git a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts index a25afd9106c..d13f52ca268 100644 --- a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts @@ -50,7 +50,7 @@ import { hasExplicitTuiLaunchCustomization, resolveAgentLaunchRoute } from '@/lib/agent-launch-routing' -import { readLocalRuntimeCapabilities } from '@/runtime/local-runtime-capabilities' +import { readLocalRuntimeCapabilitiesOrUnknown } from '@/runtime/local-runtime-capabilities' export function useQuickCreationExecution(input: QuickCreationExecutionInput) { const { @@ -205,7 +205,7 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { executionHostId: ephemeralVmRecipe ? 'runtime:pending-ephemeral-vm' : (workspaceRunContext?.hostId ?? selectedRepoExecutionHostId ?? 'local'), - hostCapabilities: readLocalRuntimeCapabilities(), + hostCapabilities: readLocalRuntimeCapabilitiesOrUnknown(), workspaceKind: selectedRepoIsGit ? 'git-worktree' : 'folder', promptDelivery: quickDraftPrompt ? 'draft' : 'auto-submit', launchText: quickDraftPrompt ?? quickPrompt, diff --git a/src/renderer/src/lib/agent-launch-routing.test.ts b/src/renderer/src/lib/agent-launch-routing.test.ts index cb3a2b70b00..6bcc5e97287 100644 --- a/src/renderer/src/lib/agent-launch-routing.test.ts +++ b/src/renderer/src/lib/agent-launch-routing.test.ts @@ -76,6 +76,7 @@ describe('resolveAgentLaunchRoute', () => { it('fails closed for missing capability, unsupported providers, and explicit TUI options', () => { expect(route({ hostCapabilities: [] })).toBe('legacy-native-chat') + expect(route({ hostCapabilities: null })).toBe('legacy-native-chat') // openclaude and grok render native chat but have no structured adapter. expect(route({ agent: 'openclaude' })).toBe('legacy-native-chat') expect(route({ agent: 'grok' })).toBe('legacy-native-chat') diff --git a/src/renderer/src/lib/agent-launch-routing.ts b/src/renderer/src/lib/agent-launch-routing.ts index 090ef3c9108..5763cccc1f7 100644 --- a/src/renderer/src/lib/agent-launch-routing.ts +++ b/src/renderer/src/lib/agent-launch-routing.ts @@ -30,7 +30,8 @@ export type AgentLaunchRoutingInput = { | null | undefined executionHostId: string - hostCapabilities: readonly string[] + /** Capabilities of the target host; `null` = not yet established. */ + hostCapabilities: readonly string[] | null workspaceKind?: 'git-worktree' | 'folder' | 'floating' projectRuntime?: ProjectExecutionRuntimeResolution | null promptDelivery?: NativeChatLaunchPromptDelivery diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index cb3187878ef..02b47d6d2bc 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -38,7 +38,7 @@ import { hasExplicitTuiAgentArgs, resolveAgentLaunchRoute } from '@/lib/agent-launch-routing' -import { readLocalRuntimeCapabilities } from '@/runtime/local-runtime-capabilities' +import { readLocalRuntimeCapabilitiesOrUnknown } from '@/runtime/local-runtime-capabilities' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' export type LaunchAgentInNewTabArgs = { @@ -213,7 +213,7 @@ function launchAgentInNewTabInternal( agent, settings: store.settings, executionHostId: getExecutionHostIdForWorktree(store, worktreeId), - hostCapabilities: readLocalRuntimeCapabilities(), + hostCapabilities: readLocalRuntimeCapabilitiesOrUnknown(), workspaceKind, projectRuntime: getLocalProjectExecutionRuntimeContext(store, worktreeId), promptDelivery: viewModePromptDelivery, diff --git a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts index 179f6203d0b..8e083618925 100644 --- a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts +++ b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts @@ -14,7 +14,7 @@ const mockRefreshLocalStructuredSessionTabs = vi.fn() const mockToastError = vi.fn() const mockCallStructuredAgentSession = vi.fn() const STRUCTURED_HOST_CAPABILITIES = ['agent-session.structured.v1'] -let hostCapabilities: readonly string[] = STRUCTURED_HOST_CAPABILITIES +let hostCapabilities: readonly string[] | null = STRUCTURED_HOST_CAPABILITIES function structuredLaunchIntent(worktreeId: string, sessionId = 'codex-session-1') { return { @@ -113,7 +113,7 @@ vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({ LOCAL_STRUCTURED_SESSION_OWNER: 'local-structured-session' })) vi.mock('@/runtime/local-runtime-capabilities', () => ({ - readLocalRuntimeCapabilities: () => hostCapabilities + readLocalRuntimeCapabilitiesOrUnknown: () => hostCapabilities })) vi.mock('@/lib/worktree-runtime-owner', () => ({ getExecutionHostIdForWorktree: () => @@ -233,16 +233,19 @@ describe('structured chat adoption guard on the launch path', () => { expect(mockToastError).not.toHaveBeenCalled() }) - it('routes every structured launch through the shared host capability gate', async () => { - hostCapabilities = [] - const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + it.each([[], null])( + 'preserves terminal-backed launches with capability answer %s', + async (capabilities) => { + hostCapabilities = capabilities + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - launchAgentInNewTab({ agent: 'claude', worktreeId: 'wt-1' }) - launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + launchAgentInNewTab({ agent: 'claude', worktreeId: 'wt-1' }) + launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) - expect(mockCreateStructuredCodexSessionLaunchIntent).not.toHaveBeenCalled() - expect(mockCreateTab).toHaveBeenCalledTimes(2) - }) + expect(mockCreateStructuredCodexSessionLaunchIntent).not.toHaveBeenCalled() + expect(mockCreateTab).toHaveBeenCalledTimes(2) + } + ) /** The toggle is hidden under Terminal chat but its persisted value survives, so the launch * path must re-check the default view rather than trust a stale opt-in. */ diff --git a/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts b/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts index 1fdc3b6fa69..8282ab33120 100644 --- a/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts +++ b/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts @@ -10,7 +10,7 @@ import { type AgentLaunchRoute, type AgentLaunchRoutingInput } from '@/lib/agent-launch-routing' -import { readLocalRuntimeCapabilities } from '@/runtime/local-runtime-capabilities' +import { readLocalRuntimeCapabilitiesOrUnknown } from '@/runtime/local-runtime-capabilities' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { buildDirectWorkItemStartup, @@ -97,7 +97,7 @@ export async function prepareDirectWorkItemAgentLaunch(args: { agent: effectiveAgent, settings: args.settings, executionHostId: getExecutionHostIdForWorktree(args.latestStore, args.worktreeId), - hostCapabilities: readLocalRuntimeCapabilities(), + hostCapabilities: readLocalRuntimeCapabilitiesOrUnknown(), workspaceKind: 'git-worktree', projectRuntime: getLocalProjectExecutionRuntimeContext( args.latestStore, diff --git a/src/renderer/src/lib/onboarding-folder-agent-startup.ts b/src/renderer/src/lib/onboarding-folder-agent-startup.ts index a4341a4dc87..07e99a0bb80 100644 --- a/src/renderer/src/lib/onboarding-folder-agent-startup.ts +++ b/src/renderer/src/lib/onboarding-folder-agent-startup.ts @@ -18,7 +18,7 @@ import { resolveAgentLaunchRoute, type AgentLaunchRoute } from '@/lib/agent-launch-routing' -import { readLocalRuntimeCapabilities } from '@/runtime/local-runtime-capabilities' +import { readLocalRuntimeCapabilitiesOrUnknown } from '@/runtime/local-runtime-capabilities' export type OnboardingFolderAgentStartup = { command: string @@ -135,7 +135,7 @@ export function resolveDismissedOnboardingFolderAgentLaunch(args: { agent, settings: args.settings, executionHostId: args.executionHostId, - hostCapabilities: readLocalRuntimeCapabilities(), + hostCapabilities: readLocalRuntimeCapabilitiesOrUnknown(), workspaceKind: 'folder', nativeChatTranscriptIsLocalReadable: args.nativeChatTranscriptIsLocalReadable, requiresTuiLaunchCustomization: hasExplicitTuiLaunchCustomization(args.settings, agent), diff --git a/src/renderer/src/runtime/local-runtime-capabilities.test.ts b/src/renderer/src/runtime/local-runtime-capabilities.test.ts index 264fcdb1403..eedd31a748a 100644 --- a/src/renderer/src/runtime/local-runtime-capabilities.test.ts +++ b/src/renderer/src/runtime/local-runtime-capabilities.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { readLocalRuntimeCapabilities, + readLocalRuntimeCapabilitiesOrUnknown, refreshLocalRuntimeCapabilities, setLocalRuntimeCapabilitiesForTests } from './local-runtime-capabilities' @@ -12,6 +13,22 @@ describe('local runtime capabilities', () => { setLocalRuntimeCapabilitiesForTests([]) }) + it('starts unknown while the array reader stays compatible', async () => { + vi.resetModules() + const fresh = await import('./local-runtime-capabilities') + expect(fresh.readLocalRuntimeCapabilitiesOrUnknown()).toBeNull() + expect(fresh.readLocalRuntimeCapabilities()).toEqual([]) + }) + + it.each([{}, { capabilities: [] }])( + 'treats a successful legacy or empty response as known denial: %j', + async (status) => { + Object.assign(window, { api: { runtime: { getStatus: vi.fn(async () => status) } } }) + await expect(refreshLocalRuntimeCapabilities()).resolves.toEqual([]) + expect(readLocalRuntimeCapabilitiesOrUnknown()).toEqual([]) + } + ) + it('fails closed until the live host advertises support', async () => { const getStatus = vi.fn(async () => ({ capabilities: ['agent-session.structured.v1'] })) Object.assign(window, { api: { runtime: { getStatus } } }) @@ -21,6 +38,7 @@ describe('local runtime capabilities', () => { 'agent-session.structured.v1' ]) expect(readLocalRuntimeCapabilities()).toEqual(['agent-session.structured.v1']) + expect(readLocalRuntimeCapabilitiesOrUnknown()).toEqual(['agent-session.structured.v1']) }) it('coalesces concurrent live status reads', async () => { @@ -38,6 +56,7 @@ describe('local runtime capabilities', () => { ['agent-session.structured.v1'], ['agent-session.structured.v1'] ]) + expect(first).toBe(second) expect(getStatus).toHaveBeenCalledOnce() }) @@ -55,5 +74,12 @@ describe('local runtime capabilities', () => { await expect(refreshLocalRuntimeCapabilities()).resolves.toEqual([]) expect(readLocalRuntimeCapabilities()).toEqual([]) + expect(readLocalRuntimeCapabilitiesOrUnknown()).toBeNull() + + window.api.runtime.getStatus = vi + .fn() + .mockResolvedValue({ capabilities: ['agent-session.structured.v1'] }) + await refreshLocalRuntimeCapabilities() + expect(readLocalRuntimeCapabilitiesOrUnknown()).toEqual(['agent-session.structured.v1']) }) }) diff --git a/src/renderer/src/runtime/local-runtime-capabilities.ts b/src/renderer/src/runtime/local-runtime-capabilities.ts index 6750d13072f..2bb0e1916d3 100644 --- a/src/renderer/src/runtime/local-runtime-capabilities.ts +++ b/src/renderer/src/runtime/local-runtime-capabilities.ts @@ -1,9 +1,17 @@ import type { RuntimeCapability } from '../../../shared/protocol-version' -let localRuntimeCapabilities: readonly RuntimeCapability[] = [] +// `null` while no successful probe has landed. "Not asked yet" and "host says no" are +// different answers, and a caller that routes on them must be able to tell them apart. +let localRuntimeCapabilities: readonly RuntimeCapability[] | null = null let refreshPromise: Promise | null = null export function readLocalRuntimeCapabilities(): readonly RuntimeCapability[] { + return localRuntimeCapabilities ?? [] +} + +/** `null` when the local runtime has not answered yet, so a routing decision can wait + * instead of reading an unprobed host as unsupported. */ +export function readLocalRuntimeCapabilitiesOrUnknown(): readonly RuntimeCapability[] | null { return localRuntimeCapabilities } @@ -15,8 +23,10 @@ export function refreshLocalRuntimeCapabilities(): Promise { - localRuntimeCapabilities = [] - return localRuntimeCapabilities + // Stays unknown rather than becoming an empty (== unsupported) list: a failed probe + // is not evidence about the host. + localRuntimeCapabilities = null + return [] }) .finally(() => { refreshPromise = null diff --git a/src/shared/structured-native-chat-launch-route.test.ts b/src/shared/structured-native-chat-launch-route.test.ts index ee13a8fb590..e796cc0e2a5 100644 --- a/src/shared/structured-native-chat-launch-route.test.ts +++ b/src/shared/structured-native-chat-launch-route.test.ts @@ -63,7 +63,8 @@ describe('per-launch structured feasibility', () => { ['a floating workspace', { workspaceKind: 'floating' }, 'floating-workspace'], ['a custom TUI launch', { requiresTuiLaunchCustomization: true }, 'tui-launch-customization'], ['an SSH host', { executionHostId: 'ssh:host-a' }, 'remote-execution-host'], - ['a missing capability', { hostCapabilities: [] }, 'runtime-capability'] + ['a missing capability', { hostCapabilities: [] }, 'runtime-capability'], + ['an unanswered host', { hostCapabilities: null }, 'runtime-capability-unknown'] ] as [string, Partial, string][])( 'names %s as the blocker', (_name, overrides, blocker) => { diff --git a/src/shared/structured-native-chat-launch-route.ts b/src/shared/structured-native-chat-launch-route.ts index 8498fe674ce..f9006db44a9 100644 --- a/src/shared/structured-native-chat-launch-route.ts +++ b/src/shared/structured-native-chat-launch-route.ts @@ -28,6 +28,9 @@ export type StructuredNativeChatBlocker = | 'remote-execution-host' | 'project-runtime' | 'runtime-capability' + /** The owning host has not answered yet. Distinct from `runtime-capability`, which is the + * host saying no: an unestablished answer must not read as a refusal. */ + | 'runtime-capability-unknown' export type StructuredNativeChatSupport = | { supported: true } @@ -36,7 +39,8 @@ export type StructuredNativeChatSupport = export type StructuredNativeChatSupportInput = { agent: TuiAgent executionHostId: string - hostCapabilities: readonly string[] + /** Capabilities of the host this launch would run on. `null` = not yet established. */ + hostCapabilities: readonly string[] | null workspaceKind?: 'git-worktree' | 'folder' | 'floating' projectRuntime?: ProjectExecutionRuntimeResolution | null /** A draft stays terminal-backed: the composer, not a turn, owns unsent text. */ @@ -84,6 +88,9 @@ export function resolveStructuredNativeChatSupport( if (projectRuntime?.status === 'repair-required' || projectRuntime?.runtime.kind === 'wsl') { return { supported: false, blocker: 'project-runtime' } } + if (input.hostCapabilities === null) { + return { supported: false, blocker: 'runtime-capability-unknown' } + } if (!input.hostCapabilities.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)) { return { supported: false, blocker: 'runtime-capability' } } From 6a47d2831f1f2687861ad1b50974a55e8dd32770 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:34:23 -0700 Subject: [PATCH 02/74] fix(native-chat): scope composer file drops to the pane that received them (#19328) * fix(native-chat): scope composer file drops to the pane that received them A native OS file drop resolving to `target: 'composer'` carried no pane identity, so the window-wide payload was attached by every mounted composer. Because inactive chat tabs stay mounted (hidden), one drop populated every chat pane's attachment cache, and those chips replayed whenever the user returned to a tab they never dropped into. The workspace-creation composer and chat composers also leaked into each other, since neither could tell which surface actually received the drop. Composer drops now carry a `scopeKey` the way a terminal drop carries its tab and pane leaf id: the composer publishes its pane key as `data-composer-scope-key`, the preload harvests it during the composedPath walk, and each composer attaches only its own. The workspace composer's last-wins ownership stack now claims unscoped payloads only. * test(native-chat): supersede the bug-asserting drop repro with the scoping test The repro that landed on main asserts the pre-fix behavior (a drop reaching every mounted composer), so it fails once drops are scoped to the pane that received them. Its scoping cases now live in native-chat-composer-drop-scope.test.tsx, which keeps its editor-target control case verbatim and adds coverage for unscoped composers and a scope key published inside the drop-target marker. * test(native-chat): cover workspace composer drop isolation * fix(native-chat): authorize external attachment paths before preview --------- Co-authored-by: Merge Sim --- src/preload/preload-runtime-support.ts | 1 + .../native-chat/NativeChatComposer.tsx | 3 +- .../native-chat/NativeChatComposerField.tsx | 5 + .../native-chat-composer-autogrow.test.tsx | 1 + .../native-chat-composer-composition.test.tsx | 13 + .../native-chat-composer-drop-scope.test.tsx | 313 ++++++++++++++++++ ...-chat-cross-pane-image-drop-repro.test.tsx | 142 -------- ...-native-chat-external-attachments.test.tsx | 48 ++- .../use-native-chat-external-attachments.ts | 18 +- ...use-native-chat-file-attachment-actions.ts | 8 +- .../composer-state/composer-drop-listener.ts | 3 + src/shared/native-file-drop.test.ts | 43 +++ src/shared/native-file-drop.ts | 29 +- 13 files changed, 474 insertions(+), 153 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx delete mode 100644 src/renderer/src/components/native-chat/native-chat-cross-pane-image-drop-repro.test.tsx diff --git a/src/preload/preload-runtime-support.ts b/src/preload/preload-runtime-support.ts index b4ea3260ac8..27ce7d77bc2 100644 --- a/src/preload/preload-runtime-support.ts +++ b/src/preload/preload-runtime-support.ts @@ -77,6 +77,7 @@ function resolveNativeFileDrop(event: DragEvent): NativeDropResolution | null { pathEntries.push({ nativeFileDropTarget: entry.dataset.nativeFileDropTarget, nativeFileDropDir: entry.dataset.nativeFileDropDir, + composerScopeKey: entry.dataset.composerScopeKey, terminalTabId: entry.dataset.terminalTabId, terminalPaneLeafId: entry.dataset.terminalPaneLeafId ?? entry.dataset.leafId }) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index fc45c8e6241..79ca7cbd851 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -217,7 +217,7 @@ const NativeChatComposerPane = forwardRef draft: string disabled: boolean @@ -87,6 +90,7 @@ function imeComposedSegment(base: string, settled: string): string { } export function NativeChatComposerField({ + composerScopeKey, textareaRef, draft, disabled, @@ -180,6 +184,7 @@ export function NativeChatComposerField({ ) : null}
()} draft={draft} disabled={false} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx index 1e21b26321c..8b47907843a 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx @@ -34,6 +34,7 @@ function TestField(props: TestFieldProps): React.JSX.Element { function fieldProps(overrides: Partial = {}): TestFieldProps { return { + composerScopeKey: 'pane-test', textareaRef: createRef(), draft: '', disabled: false, @@ -74,6 +75,18 @@ function textarea(): HTMLTextAreaElement { return screen.getByRole('textbox') as HTMLTextAreaElement } +describe('native chat composer drop-scope marker', () => { + // The drop pipeline stops walking at the drop-target marker, so a scope key on + // any other element would never reach the payload. + it('publishes the scope key on the same element as the drop-target marker', () => { + const view = render() + const marker = view.container.querySelector('[data-native-file-drop-target="composer"]') + expect(marker).not.toBeNull() + expect(marker?.getAttribute('data-composer-scope-key')).toBe('tab-7:pane-9') + expect(view.container.querySelectorAll('[data-composer-scope-key]')).toHaveLength(1) + }) +}) + describe('native chat composer composition ownership', () => { it('preserves the focused browser preedit through 120 stale streaming rerenders', () => { const textareaRef = createRef() diff --git a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx new file mode 100644 index 00000000000..e535a59fad0 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx @@ -0,0 +1,313 @@ +// @vitest-environment happy-dom + +import { EventEmitter } from 'node:events' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, render, screen } from '@testing-library/react' +import { useRef } from 'react' +import { useNativeChatExternalAttachments } from './use-native-chat-external-attachments' +import { NativeChatImageAttachmentPreview } from './NativeChatImageAttachmentPreview' +import { resetLocalImageSrcStateForTests } from '../editor/useLocalImageSrc' +import { useComposerDropListener } from '../../hooks/composer-state/composer-drop-listener' +import type { NativeFileDropPayload } from '../../../../shared/native-file-drop' +import { useNativeChatFileAttachmentActions } from './use-native-chat-file-attachment-actions' +import { + clearNativeChatAttachmentCacheForTests, + readNativeChatAttachmentCache, + useNativeChatComposerAttachments +} from './use-native-chat-composer-attachments' + +const electron = vi.hoisted(() => ({ + on: vi.fn(), + removeListener: vi.fn(), + send: vi.fn(), + getPathForFile: vi.fn((file: File) => `/repro/${file.name}`) +})) + +const intake = vi.hoisted(() => ({ + owner: { kind: 'local' } as { kind: string; connectionId?: string }, + authorizeExternalPath: vi.fn(), + readFile: vi.fn(), + upload: vi.fn() +})) +vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } })) +vi.mock('./native-chat-attachment-upload', () => ({ + resolveNativeChatAttachmentOwner: () => intake.owner, + uploadNativeChatAttachmentPaths: intake.upload +})) + +vi.mock('electron', () => ({ + ipcRenderer: electron, + webUtils: { getPathForFile: electron.getPathForFile } +})) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ isRemoteRuntimePtyId: () => false })) + +import { + installNativeFileDropHandlers, + subscribeNativeFileDrop +} from '../../../../preload/preload-runtime-support' + +// Uses the production drop listener, subscriber fan-out, attachment hook, and scope cache. +function ComposerProbe({ pane, hidden = false }: { pane: string; hidden?: boolean }) { + const textareaRef = useRef(null) + const attachments = useNativeChatComposerAttachments({ + attachmentScopeKey: pane, + allowWithoutTarget: true, + caret: 0, + disabled: false, + isComposing: () => false, + resolveTarget: () => null, + textareaRef, + setCaret: () => {}, + setDraft: () => {}, + setNotice: () => {} + }) + const { attachExternalPaths } = useNativeChatExternalAttachments({ + terminalTabId: pane, + disabled: false, + attachResolvedPaths: attachments.attachResolvedPaths, + setNotice: () => {} + }) + useNativeChatFileAttachmentActions(pane, attachExternalPaths) + return ( +