diff --git a/src/main/wsl/wsl-host-e2e-fake-connector.test.ts b/src/main/wsl/wsl-host-e2e-fake-connector.test.ts index a3768f613ef..9bf9637869d 100644 --- a/src/main/wsl/wsl-host-e2e-fake-connector.test.ts +++ b/src/main/wsl/wsl-host-e2e-fake-connector.test.ts @@ -24,11 +24,12 @@ function writeState(state: 'running' | 'stopped'): void { writeFileSync(distrosFile, JSON.stringify([{ name: 'Ubuntu', state, version: 2 }])) } -function connector(onChange = vi.fn()) { - const fake = createFakeWslHostConnector({ distros: [], distrosFile, pairingCode }, onChange, { - isKnownDistro: () => true, - runtime - }) +function connector(onChange = vi.fn(), stopSilently = false) { + const fake = createFakeWslHostConnector( + { distros: [], distrosFile, pairingCode, stopSilently }, + onChange, + { isKnownDistro: () => true, runtime } + ) return { fake, onChange } } @@ -58,6 +59,14 @@ it('drops the bridge of a distro the test stops and refuses a no-boot reconnect' await expect(fake.connect('Ubuntu', { noBoot: true })).rejects.toThrow('Ubuntu is not running.') }) +it('leaves a silent stop for a runtime call to report, as when liveness saw only a quick reboot', async () => { + const { fake, onChange } = connector(vi.fn(), true) + await fake.connect('Ubuntu') + writeState('stopped') + expect(await fake.reachability('Ubuntu', 'wsl:ubuntu')).toBe('stopped') + expect(onChange).toHaveBeenLastCalledWith('Ubuntu', null, { state: 'closed' }) +}) + it('still reports a plain close for an explicit disconnect of a running distro', async () => { const { fake, onChange } = connector() await fake.connect('Ubuntu') diff --git a/src/main/wsl/wsl-host-e2e-fake-connector.ts b/src/main/wsl/wsl-host-e2e-fake-connector.ts index ee41bd87b84..87c7b73d80a 100644 --- a/src/main/wsl/wsl-host-e2e-fake-connector.ts +++ b/src/main/wsl/wsl-host-e2e-fake-connector.ts @@ -29,7 +29,9 @@ const FakeWslHostsSchema = z.object({ /** Each distro the fake is asked to start, one per line, so e2e can prove nothing respawns. */ connectLogFile: z.string().optional(), /** `missing` stands in for a build shipped without the orcad template. */ - serverFiles: z.enum(['present', 'missing']).optional() + serverFiles: z.enum(['present', 'missing']).optional(), + /** A stop the fake does not report, as when liveness sees only a quick reboot: a runtime call must. */ + stopSilently: z.boolean().optional() }) export function readFakeWslHostsConfig( @@ -123,8 +125,13 @@ export function createFakeWslHostConnector( // A running fake distro's missing server is reachable by a reconnect; a stopped one's bridge is gone. reachability: async (distro) => { if (isStopped(distro)) { - // As production liveness reports a stopped distro's lost bridge. - dropBridge(distro, { state: 'distro-stopped', terminals: 'exited', recovering: false }) + // As production liveness reports a stopped distro's lost bridge, unless told to stay quiet. + dropBridge( + distro, + config.stopSilently + ? { state: 'closed' } + : { state: 'distro-stopped', terminals: 'exited', recovering: false } + ) return 'stopped' } return active.has(key(distro)) ? 'available' : 'unreachable' diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 944b88bce19..dd08af83233 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -58,7 +58,7 @@ import { useStructuredAgentSessionDeliveryNotices } from './use-structured-agent import { dispatchWasWithdrawn } from '../../../../shared/structured-agent-session-dispatch-rejection' import { useNativeChatHostOutage } from './use-native-chat-host-outage' import { useNativeChatHostOutageNotice } from './use-native-chat-host-outage-notice' -import { useNativeChatHostPermissionGate } from './use-native-chat-host-permission-gate' +import { useNativeChatHostSendGate } from './use-native-chat-host-send-gate' import { useNativeChatAvailabilityNotice } from './use-native-chat-availability-notice' import { useStructuredAgentSessionStartFailureFacts } from './use-structured-agent-session-start-failure-facts' import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' @@ -123,8 +123,8 @@ export function NativeChatStructuredSession( hostOutage === null ) const agentLabel = structuredAgentLabel(props.agent) - const hostPermission = useNativeChatHostPermissionGate(props.target, props.agent, agentLabel) - const hostHeld = hostPermission.sendBlockedReason !== null + const hostSendGate = useNativeChatHostSendGate(props.target, props.agent, hostOutage) + const hostHeld = hostSendGate.sendBlockedReason !== null const submits = useStructuredNativeChatSubmitReveal(controller, provisionalLaunch.retry, hostHeld) const promptResponse = useStructuredPromptResponseHold(submits.respond) const needsFailureFacts = @@ -217,7 +217,7 @@ export function NativeChatStructuredSession( setOptionPickerRequest, onError: reportComposerError, onSubmitted: submits.revealLatest, - sendBlockedReason: hostPermission.sendBlockedReason ?? install.sendBlockedReason + sendBlockedReason: hostSendGate.sendBlockedReason ?? install.sendBlockedReason }) const asyncQuestion = { paneKey, session, send: transport.send } const continuation = useNativeChatInterruptedContinuation({ @@ -258,7 +258,7 @@ export function NativeChatStructuredSession( composerError: composerError ?? continuation.continueError, claudeSignIn, availability, - hostPermission: hostPermission.notice + hostPermission: hostSendGate.notice }) if (hostNotice) { notices.push(hostNotice) diff --git a/src/renderer/src/components/native-chat/use-native-chat-host-send-gate.test.ts b/src/renderer/src/components/native-chat/use-native-chat-host-send-gate.test.ts new file mode 100644 index 00000000000..eb2e7a8b732 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-host-send-gate.test.ts @@ -0,0 +1,41 @@ +// @vitest-environment happy-dom +import { renderHook } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import type { NativeChatHostPermissionGate } from './use-native-chat-host-permission-gate' +import type { NativeChatHostOutage } from './use-native-chat-host-outage' + +const permission = vi.hoisted(() => { + const gate: NativeChatHostPermissionGate = { sendBlockedReason: null, notice: null } + return { gate } +}) +vi.mock('./use-native-chat-host-permission-gate', () => ({ + useNativeChatHostPermissionGate: () => permission.gate +})) + +import { useNativeChatHostSendGate } from './use-native-chat-host-send-gate' + +const target = { kind: 'environment', environmentId: 'wsl:ubuntu' } as const + +function outage(kind: NativeChatHostOutage['kind']): NativeChatHostOutage { + return { kind, environmentId: 'wsl:ubuntu', hostLabel: 'Ubuntu', canReconnect: true } +} + +afterEach(() => { + permission.gate = { sendBlockedReason: null, notice: null } +}) + +it('holds Send on a stopped WSL distro, saying Start comes first', () => { + const gate = renderHook(() => useNativeChatHostSendGate(target, 'claude', outage('stopped'))) + expect(gate.result.current.sendBlockedReason).toBe('Start Ubuntu to send.') +}) + +it.each(['offline', 'reconnecting'] as const)('leaves Send to the host while it is %s', (kind) => { + const gate = renderHook(() => useNativeChatHostSendGate(target, 'claude', outage(kind))) + expect(gate.result.current.sendBlockedReason).toBeNull() +}) + +it("keeps the host permission check's own reason and notice first", () => { + permission.gate = { sendBlockedReason: "Checking this host's chat permissions…", notice: null } + const gate = renderHook(() => useNativeChatHostSendGate(target, 'claude', outage('stopped'))) + expect(gate.result.current).toBe(permission.gate) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-host-send-gate.ts b/src/renderer/src/components/native-chat/use-native-chat-host-send-gate.ts new file mode 100644 index 00000000000..b0788f0bee2 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-host-send-gate.ts @@ -0,0 +1,31 @@ +import { translate } from '@/i18n/i18n' +import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' +import type { AgentType } from '../../../../shared/agent-status-types' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import { + useNativeChatHostPermissionGate, + type NativeChatHostPermissionGate +} from './use-native-chat-host-permission-gate' +import type { NativeChatHostOutage } from './use-native-chat-host-outage' + +/** What holds Send on the chat's host: its permission check, or a stopped WSL distro only Start may boot. */ +export function useNativeChatHostSendGate( + target: RuntimeClientTarget, + agent: AgentType, + outage: NativeChatHostOutage | null +): NativeChatHostPermissionGate { + const permission = useNativeChatHostPermissionGate(target, agent, structuredAgentLabel(agent)) + if (permission.sendBlockedReason !== null || outage?.kind !== 'stopped') { + return permission + } + return { + ...permission, + sendBlockedReason: translate( + 'components.native-chat.hostOutage.startToSend', + 'Start {{distro}} to send.', + { + distro: outage.hostLabel + } + ) + } +} diff --git a/src/renderer/src/i18n/locales/de.json b/src/renderer/src/i18n/locales/de.json index 3268d5e6ba9..1373796adfa 100644 --- a/src/renderer/src/i18n/locales/de.json +++ b/src/renderer/src/i18n/locales/de.json @@ -19307,7 +19307,8 @@ "reconnect": "Erneut verbinden", "stopped": "{{distro}} ist gestoppt", "start": "Starten", - "unverifiable": "Orca konnte nicht prüfen, ob {{distro}} läuft" + "unverifiable": "Orca konnte nicht prüfen, ob {{distro}} läuft", + "startToSend": "Starte {{distro}}, um zu senden." }, "interruptedContinue": { "continue": "Fortfahren", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 165a8d20336..479554344b8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -19733,7 +19733,8 @@ "reconnect": "Reconnect", "stopped": "{{distro}} is stopped", "start": "Start", - "unverifiable": "Orca couldn't check whether {{distro}} is running" + "unverifiable": "Orca couldn't check whether {{distro}} is running", + "startToSend": "Start {{distro}} to send." }, "interruptedContinue": { "continue": "Continue", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 485ef9f482d..f63605470e4 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -19439,7 +19439,8 @@ "reconnect": "Reconectar", "stopped": "{{distro}} está detenido", "start": "Iniciar", - "unverifiable": "Orca no pudo comprobar si {{distro}} se está ejecutando" + "unverifiable": "Orca no pudo comprobar si {{distro}} se está ejecutando", + "startToSend": "Inicie {{distro}} para enviar." }, "interruptedContinue": { "continue": "Continuar", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index f91c40ac26d..bddcb24f070 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -19659,7 +19659,8 @@ "reconnect": "Reconnecter", "stopped": "{{distro}} est arrêté", "start": "Démarrer", - "unverifiable": "Orca n'a pas pu vérifier si {{distro}} est en cours d'exécution" + "unverifiable": "Orca n'a pas pu vérifier si {{distro}} est en cours d'exécution", + "startToSend": "Démarrez {{distro}} pour envoyer." }, "interruptedContinue": { "continue": "Continuer", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 89eb4f12bc7..bacd9960b09 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18920,7 +18920,8 @@ "reconnect": "再接続", "stopped": "{{distro}} は停止しています", "start": "起動", - "unverifiable": "Orca は {{distro}} が実行中かどうかを確認できませんでした" + "unverifiable": "Orca は {{distro}} が実行中かどうかを確認できませんでした", + "startToSend": "送信するには {{distro}} を開始してください。" }, "interruptedContinue": { "continue": "続行", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index f15b1f0e8cb..ffd8f483f46 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18920,7 +18920,8 @@ "reconnect": "다시 연결", "stopped": "{{distro}}이(가) 중지되었습니다", "start": "시작", - "unverifiable": "Orca가 {{distro}}이(가) 실행 중인지 확인하지 못했습니다" + "unverifiable": "Orca가 {{distro}}이(가) 실행 중인지 확인하지 못했습니다", + "startToSend": "보내려면 {{distro}}을(를) 시작하세요." }, "interruptedContinue": { "continue": "계속", diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 0041892035e..73d269b1f6e 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -20297,7 +20297,8 @@ "reconnect": "Reconectar", "stopped": "{{distro}} está parado", "start": "Iniciar", - "unverifiable": "O Orca não conseguiu verificar se {{distro}} está em execução" + "unverifiable": "O Orca não conseguiu verificar se {{distro}} está em execução", + "startToSend": "Inicie {{distro}} para enviar." }, "interruptedContinue": { "continue": "Continuar", diff --git a/src/renderer/src/i18n/locales/ru.json b/src/renderer/src/i18n/locales/ru.json index a7cd994c422..d0aa48a13af 100644 --- a/src/renderer/src/i18n/locales/ru.json +++ b/src/renderer/src/i18n/locales/ru.json @@ -19907,7 +19907,8 @@ "reconnect": "Подключиться снова", "stopped": "{{distro}} остановлен", "start": "Запустить", - "unverifiable": "Orca не удалось проверить, запущен ли {{distro}}" + "unverifiable": "Orca не удалось проверить, запущен ли {{distro}}", + "startToSend": "Запустите {{distro}}, чтобы отправить." }, "interruptedContinue": { "continue": "Продолжить", diff --git a/src/renderer/src/i18n/locales/tr.json b/src/renderer/src/i18n/locales/tr.json index 3a850b3cefa..1545778ed65 100644 --- a/src/renderer/src/i18n/locales/tr.json +++ b/src/renderer/src/i18n/locales/tr.json @@ -19291,7 +19291,8 @@ "reconnect": "Yeniden bağlan", "stopped": "{{distro}} durduruldu", "start": "Başlat", - "unverifiable": "Orca, {{distro}} öğesinin çalışıp çalışmadığını denetleyemedi" + "unverifiable": "Orca, {{distro}} öğesinin çalışıp çalışmadığını denetleyemedi", + "startToSend": "Göndermek için {{distro}} dağıtımını başlatın." }, "interruptedContinue": { "continue": "Devam et", diff --git a/src/renderer/src/i18n/locales/zh-Hant.json b/src/renderer/src/i18n/locales/zh-Hant.json index fbe024e0382..f643cd78aed 100644 --- a/src/renderer/src/i18n/locales/zh-Hant.json +++ b/src/renderer/src/i18n/locales/zh-Hant.json @@ -19032,7 +19032,8 @@ "reconnect": "重新連線", "stopped": "{{distro}} 已停止", "start": "啟動", - "unverifiable": "Orca 無法確認 {{distro}} 是否正在執行" + "unverifiable": "Orca 無法確認 {{distro}} 是否正在執行", + "startToSend": "請啟動 {{distro}} 後再傳送。" }, "interruptedContinue": { "continue": "繼續", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 4a498473c6f..1ce1e74d8ea 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18890,7 +18890,8 @@ "reconnect": "重新连接", "stopped": "{{distro}} 已停止", "start": "启动", - "unverifiable": "Orca 无法确认 {{distro}} 是否正在运行" + "unverifiable": "Orca 无法确认 {{distro}} 是否正在运行", + "startToSend": "请启动 {{distro}} 后再发送。" }, "interruptedContinue": { "continue": "继续", diff --git a/tests/e2e/wsl-host-chat.spec.ts b/tests/e2e/wsl-host-chat.spec.ts index 63fc5821195..c948793ff29 100644 --- a/tests/e2e/wsl-host-chat.spec.ts +++ b/tests/e2e/wsl-host-chat.spec.ts @@ -255,4 +255,122 @@ test.describe('native chat on a WSL host', () => { rmSync(projectPath, { recursive: true, force: true }) } }) + + test('holds Send and offers Start when a runtime call, not liveness, finds the distro stopped', async ({ + orcaPage: hub, + claudeStub + }, testInfo) => { + // Two Electron apps and two chat turns around one stop. + test.setTimeout(240_000) + await waitForSessionReady(hub) + await hub.evaluate(async (command) => { + await window.__store?.getState().updateSettings({ + experimentalNativeChat: true, + agentCmdOverrides: { claude: command } + }) + }, buildFakeAgentCommandOverride(claudeStub.command)) + const offer = await createRuntimeDesktopPairingOffer(hub) + const fakeDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-wsl-chat-parked-')) + const distrosFile = path.join(fakeDir, 'distros.json') + const connectLogFile = path.join(fakeDir, 'connects.log') + writeDistros(distrosFile, RUNNING) + // The fake reports no stop of its own, so only the runtime call's park can say it. + const desktop = await launchDesktopWithFakeWsl( + { + distros: RUNNING, + distrosFile, + connectLogFile, + pairingCode: offer.pairingUrl, + stopSilently: true + }, + testInfo + ) + const projectPath = createGitProject() + try { + const { page } = desktop + await page.evaluate(async () => { + const settings = await window.api.settings.set({ experimentalWslHosts: true }) + window.__store?.setState({ settings }) + await window.__store?.getState().updateSettings({ + experimentalNativeChat: true, + defaultTuiAgent: 'claude' + }) + }) + const added = await page.evaluate(() => + window.api.runtimeEnvironments.wslHosts?.add({ distro: 'Ubuntu' }) + ) + expect(added, JSON.stringify(added)).toMatchObject({ ok: true }) + await callEnvironment(page, WSL_ENVIRONMENT_ID, 'repo.add', { + path: projectPath, + kind: 'git' + }) + await expect.poll(() => focusProject(page, projectPath), { timeout: 60_000 }).toBeTruthy() + await page.getByRole('button', { name: 'New tab', exact: true }).first().click() + await page + .getByRole('menu') + .getByText('Claude', { exact: true }) + .first() + .click({ timeout: 60_000 }) + const chat = page.locator('[data-native-chat-root="true"]') + const composer = chat.getByRole('textbox') + const sendButton = chat.getByRole('button', { name: 'Send', exact: true }) + const type = async (text: string): Promise => { + await expect(composer).toBeEditable({ timeout: 60_000 }) + await composer.click() + await page.keyboard.type(text) + await expect(composer).toContainText(text) + } + const replies = chat.getByText('Stub reply.') + await type('hello') + await sendButton.click() + await expect(replies.first()).toBeVisible({ timeout: 120_000 }) + + // The distro stops; a runtime call (as a poll would) finds it stopped and parks the host. + const connectsBeforeStop = fakeWslConnects(connectLogFile).length + writeDistros(distrosFile, STOPPED) + await expect + .poll( + () => + callEnvironment(page, WSL_ENVIRONMENT_ID, 'agentSession.createSupport', { + worktree: `path:${projectPath}`, + agent: 'claude' + }).then( + () => 'answered', + (error: unknown) => String(error) + ), + { timeout: 30_000 } + ) + .toContain('Ubuntu was stopped') + const stoppedNotice = chat.getByText('Ubuntu is stopped', { exact: true }) + await expect(stoppedNotice).toBeVisible({ timeout: 30_000 }) + // Send waits for Start rather than failing against a stopped distro; while held, the button is + // named by why it waits, as for any host hold. + await type('while stopped') + await expect( + chat.getByRole('button', { name: 'Start Ubuntu to send.', exact: true }) + ).toBeDisabled() + await page.screenshot({ path: testInfo.outputPath('wsl-host-chat-parked.png') }) + expect(fakeWslConnects(connectLogFile)).toHaveLength(connectsBeforeStop) + expect(readDistroState(distrosFile)).toBe('stopped') + + await chat.getByRole('button', { name: 'Start', exact: true }).click() + await expect + .poll(() => fakeWslConnects(connectLogFile).length, { timeout: 60_000 }) + .toBeGreaterThan(connectsBeforeStop) + await expect(stoppedNotice).toBeHidden({ timeout: 60_000 }) + await expect(sendButton).toBeEnabled({ timeout: 60_000 }) + await sendButton.click() + // The held message goes out after Start and gets its own reply. + const held = chat + .locator('[data-native-chat-scroll]') + .getByText('while stopped', { exact: true }) + await expect( + held.last().locator('xpath=following::*[text()="Stub reply."]').first() + ).toBeVisible({ timeout: 120_000 }) + } finally { + await desktop.dispose() + rmSync(fakeDir, { recursive: true, force: true }) + rmSync(projectPath, { recursive: true, force: true }) + } + }) })