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.
This commit is contained in:
Neil
2026-10-28 01:51:57 -07:00
committed by GitHub
parent ce595faadc
commit fac8a7454a
7 changed files with 224 additions and 9 deletions
@@ -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(() => {
+11 -3
View File
@@ -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> | 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<void> {
async function assertReattachesLiveSession(
sessionId: string | undefined,
worktreeId: string | undefined
): Promise<void> {
// 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
)
}
}
@@ -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'
}
}
@@ -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<HTMLAttributes<HTMLDivElement>, '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) {
@@ -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()
})
})
@@ -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
}
}
@@ -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<string> {
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<ReturnType<typeof session.launch>> | 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 })
}
})