From fac8a7454a514369ceaf43459f253ebd336cd29e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:51:57 -0700 Subject: [PATCH] fix(wsl-mover): a moved project's chat opens and answers here; a fenced terminal names its host (#34880) * fix(wsl-mover): a moved project's chat opens and answers here; a fenced terminal names its host New e2e: a moved project's existing chat still answers after the move, run by this computer's agent, with no host involved. Writing it surfaced two product gaps: - Opening the chat from the moved project's sidebar entry asked its composer for focus before the prompt editor mounted; tiptap threw and the terminal workbench crashed. The editor handle now focuses (and answers contains) only once its view is mounted. - A new terminal in a project its WSL host now owns (one a chat's agent might ask for) was refused with "is moving ... try again when the move finishes". It now names the host and the way back: use the project on this computer again. * test(wsl-mover): wait for the moved chat's composer before typing and sending Like #34573: the composer must be editable before typing, and hold the typed text before Send. --- .../pty/host-env/spawn-cwd-wsl-move.test.ts | 14 ++ src/main/ipc/pty/host-env/spawn-cwd.ts | 14 +- .../wsl-move-scope-guard.ts | 9 +- .../native-chat/NativeChatPromptEditor.tsx | 12 +- .../native-chat-mounted-editor-view.test.ts | 22 +++ .../native-chat-mounted-editor-view.ts | 16 ++ .../wsl-move-moved-project-chat-send.spec.ts | 146 ++++++++++++++++++ 7 files changed, 224 insertions(+), 9 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-mounted-editor-view.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-mounted-editor-view.ts create mode 100644 tests/e2e/wsl-move-moved-project-chat-send.spec.ts diff --git a/src/main/ipc/pty/host-env/spawn-cwd-wsl-move.test.ts b/src/main/ipc/pty/host-env/spawn-cwd-wsl-move.test.ts index 6d69400bd01..956e9a5246e 100644 --- a/src/main/ipc/pty/host-env/spawn-cwd-wsl-move.test.ts +++ b/src/main/ipc/pty/host-env/spawn-cwd-wsl-move.test.ts @@ -60,6 +60,20 @@ describe('assertWorkspaceAcceptsNewPty under a WSL move', () => { }) }) +// A moved project's chat, or an agent in it, asking for a terminal here after the move committed. +describe('a fresh terminal in a project its WSL host now owns', () => { + beforeEach(() => { + seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed')) + }) + + it('refuses, naming the host instead of a move that already finished', async () => { + const refusal = await accept('repo-1::/x').catch((error: unknown) => error) + expect(refusal).toBeInstanceOf(WslMoveProjectFencedError) + expect(String(refusal)).toContain('now runs on its Ubuntu WSL host') + expect(String(refusal)).not.toContain('Try again when the move finishes') + }) +}) + // R46: a terminal the workspace fence misses would otherwise outlive the commit in a moved folder. describe('a spawn by its folder, between quiesce and commit', () => { beforeEach(() => { diff --git a/src/main/ipc/pty/host-env/spawn-cwd.ts b/src/main/ipc/pty/host-env/spawn-cwd.ts index 37b8229eb20..90649272d7c 100644 --- a/src/main/ipc/pty/host-env/spawn-cwd.ts +++ b/src/main/ipc/pty/host-env/spawn-cwd.ts @@ -13,6 +13,8 @@ import { import { isWslMoveCwdFenced, isWslMoveWorkspaceBlocked, + wslMoveHostDistro, + wslMoveProjectRefForWorkspace, WslMoveProjectFencedError } from '../../../persistence/moving-wsl-projects/wsl-move-scope-guard' import { listLiveDaemonPtyIds } from '../../../daemon/daemon-provider-state' @@ -31,7 +33,7 @@ export function assertWorkspaceAcceptsNewPty( ): Promise | void { const fencedByCwd = !!cwd && isWslMoveCwdFenced(cwd) if (fencedByCwd || isWslMoveWorkspaceBlocked(store, worktreeId)) { - return assertReattachesLiveSession(sessionId).then(() => + return assertReattachesLiveSession(sessionId, worktreeId).then(() => assertFolderWorkspacePtyPathUsable(store, worktreeId) ) } @@ -46,11 +48,17 @@ export function localPtySpawnCwd(args: { return args.connectionId ? undefined : args.cwd } -async function assertReattachesLiveSession(sessionId: string | undefined): Promise { +async function assertReattachesLiveSession( + sessionId: string | undefined, + worktreeId: string | undefined +): Promise { // An unanswered census is never proof the session lives, so it refuses. const live = sessionId ? await listLiveDaemonPtyIds() : null if (!sessionId || !live?.includes(sessionId)) { - throw new WslMoveProjectFencedError() + // A committed move's project names its host: "try again when the move finishes" never comes true. + throw new WslMoveProjectFencedError( + worktreeId ? wslMoveHostDistro(wslMoveProjectRefForWorkspace(worktreeId)) : null + ) } } diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-scope-guard.ts b/src/main/persistence/moving-wsl-projects/wsl-move-scope-guard.ts index 9de59891dfa..78322c54074 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-scope-guard.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-scope-guard.ts @@ -16,8 +16,13 @@ export type WslMoveProjectRef = { repoId: string } | { folderWorkspaceId: string export class WslMoveProjectFencedError extends Error { readonly code = 'wsl_move_in_progress' - constructor() { - super('This project is moving to its WSL host. Try again when the move finishes.') + /** `hostDistro`: the move already committed, so the host, not a retry, is the way forward. */ + constructor(hostDistro?: string | null) { + super( + hostDistro + ? `This project now runs on its ${hostDistro} WSL host, so its terminals open there. Use it on this computer again to open one here.` + : 'This project is moving to its WSL host. Try again when the move finishes.' + ) this.name = 'WslMoveProjectFencedError' } } diff --git a/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx b/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx index b6f0ad00dcb..b9284604289 100644 --- a/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx @@ -35,6 +35,7 @@ import { } from './native-chat-prompt-document' import type { NativeChatComposerInput } from './native-chat-composer-input' import { NativeChatImageRowProvider } from './NativeChatImageReferencePill' +import { mountedEditorView } from './native-chat-mounted-editor-view' type Props = Omit, 'onChange' | 'onSelect'> & { scopeKey?: string @@ -244,13 +245,16 @@ export function NativeChatPromptEditor({ return promptTextOffset(editor.state.doc, editor.state.selection.to) }, focus: () => { + // A focus request can arrive before the view mounts (a chat opened while its + // workspace switches in); tiptap throws then, so wait for the next reveal instead. + const view = mountedEditorView(editor) // Focus the editor view so an inserted caret replaces the stale DOM selection. - editor.view.focus() - if (!editor.isEditable) { - editor.view.dom.focus() + view?.focus() + if (view && !editor.isEditable) { + view.dom.focus() } }, - contains: (node) => editor.view.dom.contains(node), + contains: (node) => mountedEditorView(editor)?.dom.contains(node) === true, isCaretOnVisualEdge: (edge) => { const { selection, doc } = editor.state if (!selection.empty) { diff --git a/src/renderer/src/components/native-chat/native-chat-mounted-editor-view.test.ts b/src/renderer/src/components/native-chat/native-chat-mounted-editor-view.test.ts new file mode 100644 index 00000000000..f8a42b74acf --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-mounted-editor-view.test.ts @@ -0,0 +1,22 @@ +// @vitest-environment happy-dom +import { Editor } from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' +import { describe, expect, it } from 'vitest' +import { mountedEditorView } from './native-chat-mounted-editor-view' + +// A moved project's chat, opened from the sidebar, asked its composer for focus before the +// editor mounted; tiptap threw and took the whole terminal workbench down. +describe('the prompt editor view before and after mount', () => { + it('is null before mount and after destroy, never a throw', () => { + const editor = new Editor({ element: null, extensions: [StarterKit] }) + expect(mountedEditorView(editor)).toBeNull() + editor.destroy() + expect(mountedEditorView(editor)).toBeNull() + }) + + it('is the view once mounted', () => { + const editor = new Editor({ element: document.createElement('div'), extensions: [StarterKit] }) + expect(mountedEditorView(editor)).toBe(editor.view) + editor.destroy() + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-mounted-editor-view.ts b/src/renderer/src/components/native-chat/native-chat-mounted-editor-view.ts new file mode 100644 index 00000000000..48ac21f20f5 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-mounted-editor-view.ts @@ -0,0 +1,16 @@ +import type { Editor } from '@tiptap/react' +import type { EditorView } from '@tiptap/pm/view' + +/** The editor's view once mounted; null before mount or after destroy, when tiptap's view throws. */ +export function mountedEditorView(editor: Editor): EditorView | null { + if (editor.isDestroyed) { + return null + } + try { + const { view } = editor + // Unmounted, tiptap hands back a stand-in whose DOM access throws. + return view.dom ? view : null + } catch { + return null + } +} diff --git a/tests/e2e/wsl-move-moved-project-chat-send.spec.ts b/tests/e2e/wsl-move-moved-project-chat-send.spec.ts new file mode 100644 index 00000000000..789bd9a0bb3 --- /dev/null +++ b/tests/e2e/wsl-move-moved-project-chat-send.spec.ts @@ -0,0 +1,146 @@ +/** + * A moved project's existing chats stay on this computer (owner decision, option C). Listing and + * opening them is covered by wsl-move-moved-project-chats.spec.ts; this proves one still works: + * a new message after the move runs the agent here, against the same files, and reaches no host. + */ +import { execFileSync } from 'node:child_process' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, dirname, join } from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' +import { createRestartSession } from './helpers/orca-restart' +import { buildFakeAgentCommandOverride } from './helpers/fake-agent-command-override' +import { SUPPORTED, createChatOnHost } from './helpers/structured-claude-stub' +import { wslMoveCutoverFixture } from '../../src/main/persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { seedWslMoveCutover } from '../../src/main/persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding' + +test.use({ seedTestRepo: false }) + +const STUB_SCRIPT = join(process.cwd(), 'tests', 'e2e', 'fixtures', 'structured-claude-stub.cjs') +const SYSTEM_DIRS = ['/usr/bin', '/bin', '/usr/sbin', '/sbin'] + +/** The Claude stub's wrapper; each launch appends its arguments to the log. */ +function writeClaudeStub(dir: string): { command: string; launchLog: string } { + const command = join(dir, 'claude') + const launchLog = join(dir, 'launches.jsonl') + const quote = (value: string): string => `'${value.replaceAll("'", `'\\''`)}'` + writeFileSync( + command, + `#!/bin/sh\nexec ${quote(process.execPath)} ${quote(STUB_SCRIPT)} ${quote(launchLog)} "$@"\n` + ) + chmodSync(command, 0o755) + return { command, launchLog } +} + +function launches(launchLog: string): { args: string[] }[] { + return existsSync(launchLog) + ? readFileSync(launchLog, 'utf8') + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) + : [] +} + +async function addRepo(page: Page, repoPath: string): Promise { + return page.evaluate(async (path) => { + const result = await window.api.repos.add({ path }) + if ('error' in result) { + throw new Error(result.error) + } + return result.repo.id + }, repoPath) +} + +test("a moved project's existing chat still answers here after the move", async (// oxlint-disable-next-line no-empty-pattern -- Playwright requires destructuring to request no fixtures. +{}, testInfo) => { + test.skip(!SUPPORTED, 'the Claude stub wrapper is POSIX-only') + const root = mkdtempSync(join(tmpdir(), 'orca-wsl-moved-chat-send-')) + const repoPath = join(root, 'moved-repo') + mkdirSync(repoPath) + const gitIdentity = ['-c', 'user.name=e2e', '-c', 'user.email=e2e@example.invalid'] + execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: repoPath }) + execFileSync('git', [...gitIdentity, 'commit', '-q', '--allow-empty', '-m', 'init'], { + cwd: repoPath + }) + const stub = writeClaudeStub(root) + // Only the stub's directory and the system tool dirs, so no real Claude can answer. + const session = createRestartSession(testInfo, { + PATH: [dirname(stub.command), ...SYSTEM_DIRS].join(delimiter) + }) + let launched: Awaited> | undefined + try { + launched = await session.launch() + await waitForSessionReady(launched.page) + const repoId = await addRepo(launched.page, repoPath) + const worktreeId = `${repoId}::${realpathSync(repoPath)}` + await launched.page.evaluate(async (command) => { + await window.__store?.getState().updateSettings({ + experimentalNativeChat: true, + defaultTuiAgent: 'claude', + agentCmdOverrides: { claude: command } + }) + }, buildFakeAgentCommandOverride(stub.command)) + // The chat the project had before the move. + const sessionId = await createChatOnHost(launched.page, worktreeId) + await expect + .poll( + () => + launched!.page.evaluate( + ({ worktreeId, sessionId }) => + (window.__store?.getState().unifiedTabsByWorktree[worktreeId] ?? []).some( + (tab) => tab.entityId === sessionId + ), + { worktreeId, sessionId } + ), + { timeout: 30_000 } + ) + .toBe(true) + await session.close(launched.app) + launched = undefined + + seedWslMoveCutover( + session.userDataDir, + wslMoveCutoverFixture('move-1', 'destination-committed', {}, { repoIds: [repoId] }) + ) + const launchesBefore = launches(stub.launchLog).length + + launched = await session.launch() + const { page } = launched + await waitForSessionReady(page) + const entry = page.getByTestId('wsl-moved-project') + await expect(entry).toBeVisible({ timeout: 30_000 }) + await entry.getByTestId('wsl-moved-chat').click() + const chat = page.locator('[data-native-chat-root="true"]') + const composer = chat.getByRole('textbox') + await expect(composer).toBeEditable() + await composer.click() + await page.keyboard.type('hello after the move') + await expect(composer).toContainText('hello after the move') + await chat.getByRole('button', { name: 'Send', exact: true }).click() + await expect(chat.getByText('Stub reply.')).toBeVisible({ timeout: 30_000 }) + + // The reply came from the agent this computer launched for the chat, after the move. + expect(launches(stub.launchLog).length).toBeGreaterThan(launchesBefore) + // No host: no environment was added, so nothing of the chat went to one. + expect( + await page.evaluate(async () => (await window.api.runtimeEnvironments.list()).length) + ).toBe(0) + } finally { + if (launched) { + await session.close(launched.app) + } + await session.dispose() + rmSync(root, { recursive: true, force: true }) + } +})