mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
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:
@@ -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(() => {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user