mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
A CLI-started terminal shows once in its workspace, and a pane close main has no pane for closes in the window (dark, layout-owner build only) (#30391)
* fix(layout): a workspace only main held shows main's tab on the window's first visit (dark, layout-owner build only) * fix(layout): a live terminal no pane shows goes through main, which names the tab it holds; a pane close main has no pane for closes here (dark, layout-owner build only) * test: no type assertions in the CLI-first tests * refactor: the switch-off build keeps today's live-terminal mint as it was * review: frame sync under the line limit; the first-visit test names exactly main's tabs * fix(layout): adopt takes the recorded pane only while it is unbound * fix(layout): a refused live-terminal adoption is declined, not counted as shown
This commit is contained in:
@@ -76,7 +76,7 @@ export async function applyWindowLayoutCommand(
|
||||
markAgentLaunchesClosedByUser(command.workspace, { kind: 'tab', tabId })
|
||||
}
|
||||
const reply = await store.applyLayoutCommand({ ...command, force: true }, holders)
|
||||
// Why: a workspace main lacks records nothing; today's close records it in the routed partition.
|
||||
// Why: a workspace main lacks has no partition record to write; the window already removed the tab.
|
||||
return reply.ok && reply.result.alreadyClosed
|
||||
? { ok: false, code: 'workspace_not_found' }
|
||||
: reply
|
||||
|
||||
@@ -16,7 +16,8 @@ export function sendPaneCloseToLayoutOwner(
|
||||
asToday: () => void
|
||||
/** Main refused the pane as its last. */
|
||||
lastPane: () => void
|
||||
/** Main closed it but no frame is coming (the stream is down): close it here. */
|
||||
/** Main closed it but no frame is coming (the stream is down, or main held no such pane):
|
||||
* close it here. */
|
||||
closeHere: () => void
|
||||
}
|
||||
): boolean {
|
||||
@@ -26,8 +27,9 @@ export function sendPaneCloseToLayoutOwner(
|
||||
pane.worktreeId,
|
||||
{ type: 'closePane', tabId, leafId, ...(pane.kind === 'exit' && { automatic: true }) },
|
||||
{
|
||||
apply: () => {
|
||||
if (!layoutFramesFlowing()) {
|
||||
apply: (result) => {
|
||||
// Already gone from main's copy (a workspace it lacks): no frame will drop it.
|
||||
if (result.alreadyClosed || !layoutFramesFlowing()) {
|
||||
forget()
|
||||
on.closeHere()
|
||||
}
|
||||
|
||||
@@ -83,6 +83,20 @@ it('a layout-owner build closes the pane itself when main closed it and no frame
|
||||
expect(mocks.commitTerminalSurfaceClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a layout-owner build closes the pane itself when main holds no such pane, so no frame drops it', async () => {
|
||||
mocks.layoutOwnerIsMain = true
|
||||
const p = await preparePendingSplitClose()
|
||||
;(await import('@/runtime/layout-frame-position')).noteLayoutFramePosition(1)
|
||||
|
||||
p.actions.executeClosePane(1)
|
||||
mocks.sendLayoutCommandToOwner.mock.calls[0]?.[2].apply({ alreadyClosed: true })
|
||||
|
||||
const intents = await import('@/runtime/layout-owner-pane-intents')
|
||||
expect(intents.takePaneCloseIntent('tab-parent', LEAF)).toBeNull()
|
||||
expect(p.transports.has(1)).toBe(false)
|
||||
expect(mocks.commitTerminalSurfaceClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a layout-owner build closes as today, forgetting its close, when main lacks the pane', async () => {
|
||||
mocks.layoutOwnerIsMain = true
|
||||
const p = await preparePendingSplitClose()
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
// A CLI-started terminal the window never mounted reads as unowned to the host's census. With the
|
||||
// layout-owner switch on, every path that finds it shows exactly main's one tab for it.
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
import { applyLayoutCommand } from '../../../shared/workspace-layout/workspace-layout-commands'
|
||||
import type { LayoutCommand } from '../../../shared/workspace-layout/workspace-layout-command-types'
|
||||
import { loadWorkspaceLayoutWithoutReport } from '../../../shared/workspace-layout/workspace-layout-load'
|
||||
import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids'
|
||||
import type { WorkspaceLayoutModel } from '../../../shared/workspace-layout/workspace-layout-model'
|
||||
import { publishWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published'
|
||||
import {
|
||||
addWorkspace,
|
||||
emptySession,
|
||||
GIT_KEY,
|
||||
leaf
|
||||
} from '../../../shared/workspace-layout/workspace-layout-session.test-fixture'
|
||||
import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames'
|
||||
import { bootWindowFromPartitions } from '../app-shell/startup-window-view.test-fixture'
|
||||
import { provideLayoutFrameTerminalPanes } from '../runtime/layout-frame-terminal-panes'
|
||||
import {
|
||||
openWorkspaceLayoutFrameSync,
|
||||
type WorkspaceLayoutFrameSync
|
||||
} from '../runtime/workspace-layout-frame-sync'
|
||||
import { createStoreSessionMockApi } from '../store/slices/store-session-test-harness'
|
||||
import { createTestStore } from '../store/slices/store-test-helpers'
|
||||
import { adoptLiveWorkspacePtySurfaces } from './worktree-agent-live-surface-adoption'
|
||||
import { runWorktreeAgentActivationGate } from './worktree-agent-activation-gate'
|
||||
import type { LiveTerminalSurfaceOwnerIndex } from './worktree-live-terminal-surface-owners'
|
||||
|
||||
vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
|
||||
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
|
||||
|
||||
type StreamDouble = {
|
||||
onFrame: ((frame: WorkspaceLayoutChangeFrame) => void) | null
|
||||
onStatus: ((status: { state: string }) => void) | null
|
||||
}
|
||||
const stream = vi.hoisted((): StreamDouble => ({ onFrame: null, onStatus: null }))
|
||||
vi.mock('../runtime/runtime-layout-client', () => ({
|
||||
subscribeRuntimeLayout: (
|
||||
_target: unknown,
|
||||
_params: unknown,
|
||||
onFrame: (frame: WorkspaceLayoutChangeFrame) => void,
|
||||
onStatus: (status: { state: string }) => void
|
||||
) => {
|
||||
stream.onFrame = onFrame
|
||||
stream.onStatus = onStatus
|
||||
return { close: () => {} }
|
||||
}
|
||||
}))
|
||||
|
||||
/** Main: applies the window's layout commands to its own model, publishing when the stream is up. */
|
||||
const main = vi.hoisted((): { apply: ((command: LayoutCommand) => unknown) | null } => ({
|
||||
apply: null
|
||||
}))
|
||||
vi.mock('@/runtime/runtime-rpc-client', () => ({
|
||||
callRuntimeRpc: async (_target: unknown, method: string, params: { command: LayoutCommand }) => {
|
||||
if (method !== 'layout.apply' || !main.apply) {
|
||||
throw new Error(`unexpected ${method}`)
|
||||
}
|
||||
return main.apply(params.command)
|
||||
}
|
||||
}))
|
||||
|
||||
createStoreSessionMockApi()
|
||||
|
||||
const CLI_PTY = `${GIT_KEY}@@c1100001`
|
||||
const CLI_PANE = `tab-cli:${leaf(1)}`
|
||||
|
||||
function cliSession(bound = true): WorkspaceSessionState {
|
||||
return addWorkspace(emptySession(), GIT_KEY, [
|
||||
{ id: 'group-cli', tabs: [{ id: 'tab-cli', leaves: [[leaf(1), bound ? CLI_PTY : undefined]] }] }
|
||||
])
|
||||
}
|
||||
|
||||
const unowned: LiveTerminalSurfaceOwnerIndex = new Map([
|
||||
[CLI_PTY, { unowned: true, recorded: { paneKey: CLI_PANE, ptyId: CLI_PTY, tabId: 'tab-cli' } }]
|
||||
])
|
||||
|
||||
const flushMicrotasks = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
function frameOf(model: WorkspaceLayoutModel): WorkspaceLayoutChangeFrame {
|
||||
return {
|
||||
type: 'snapshot',
|
||||
workspaces: Object.entries(model.workspaces).map(([key, workspace]) => ({
|
||||
key,
|
||||
layout: publishWorkspaceLayout(workspace, 'local')
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
describe('a CLI terminal the window never mounted, layout-owner build', () => {
|
||||
let sync: WorkspaceLayoutFrameSync | null = null
|
||||
let model: WorkspaceLayoutModel
|
||||
let streamUp: boolean
|
||||
|
||||
beforeEach(() => {
|
||||
sync?.close()
|
||||
sync = null
|
||||
streamUp = true
|
||||
model = loadWorkspaceLayoutWithoutReport('local', cliSession(), nameBasedLoadContext()).layout
|
||||
main.apply = (command) => {
|
||||
const applied = applyLayoutCommand(model, command, {
|
||||
mintId: () => 'minted-tab',
|
||||
mintLeafId: () => leaf(9),
|
||||
now: () => 1
|
||||
})
|
||||
if (!applied.ok) {
|
||||
return { ok: false, code: applied.code }
|
||||
}
|
||||
model = applied.model
|
||||
if (streamUp) {
|
||||
stream.onFrame!(frameOf(model))
|
||||
}
|
||||
return { ok: true, result: applied.result }
|
||||
}
|
||||
})
|
||||
|
||||
async function bootWindow() {
|
||||
const store = createTestStore()
|
||||
await bootWindowFromPartitions(store, [{ hostId: 'local', session: emptySession() }])
|
||||
sync = openWorkspaceLayoutFrameSync(store)
|
||||
provideLayoutFrameTerminalPanes(sync)
|
||||
sync.start()
|
||||
return store
|
||||
}
|
||||
|
||||
const terminalTabIds = (store: Awaited<ReturnType<typeof bootWindow>>) =>
|
||||
(store.getState().tabsByWorktree[GIT_KEY] ?? []).map((tab) => tab.id)
|
||||
const mainTabIds = () => (model.workspaces[GIT_KEY]?.tabs ?? []).map((tab) => tab.id)
|
||||
|
||||
it('the stream is closed during the visit: main names its tab, and the reopened stream draws it', async () => {
|
||||
const store = await bootWindow()
|
||||
stream.onFrame!(frameOf(model))
|
||||
await flushMicrotasks()
|
||||
stream.onStatus!({ state: 'lost' })
|
||||
streamUp = false
|
||||
store.getState().ensureWorktreeRootGroup(GIT_KEY)
|
||||
await flushMicrotasks()
|
||||
expect(terminalTabIds(store)).toEqual([])
|
||||
const createTab = vi.spyOn(store.getState(), 'createTab')
|
||||
|
||||
await adoptLiveWorkspacePtySurfaces(store.getState, GIT_KEY, [CLI_PTY], async () => unowned)
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
expect(mainTabIds()).toEqual(['tab-cli'])
|
||||
stream.onStatus!({ state: 'connecting' })
|
||||
streamUp = true
|
||||
stream.onFrame!(frameOf(model))
|
||||
await flushMicrotasks()
|
||||
expect(terminalTabIds(store)).toEqual(['tab-cli'])
|
||||
})
|
||||
|
||||
it('main’s frame arrives after the census: main names its tab, and that frame draws it', async () => {
|
||||
const store = await bootWindow()
|
||||
const before = loadWorkspaceLayoutWithoutReport('local', emptySession(), nameBasedLoadContext())
|
||||
stream.onFrame!(frameOf(before.layout))
|
||||
store.getState().ensureWorktreeRootGroup(GIT_KEY)
|
||||
// Main admitted the CLI pane before its spawn bound it; no frame of it has arrived yet.
|
||||
model = loadWorkspaceLayoutWithoutReport(
|
||||
'local',
|
||||
cliSession(false),
|
||||
nameBasedLoadContext()
|
||||
).layout
|
||||
streamUp = false
|
||||
const createTab = vi.spyOn(store.getState(), 'createTab')
|
||||
|
||||
await adoptLiveWorkspacePtySurfaces(store.getState, GIT_KEY, [CLI_PTY], async () => unowned)
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
expect(mainTabIds()).toEqual(['tab-cli'])
|
||||
streamUp = true
|
||||
stream.onFrame!(frameOf(model))
|
||||
await flushMicrotasks()
|
||||
expect(terminalTabIds(store)).toEqual(['tab-cli'])
|
||||
})
|
||||
|
||||
it('a background wake on a workspace the window does not hold: the visit shows main’s one tab', async () => {
|
||||
const store = await bootWindow()
|
||||
stream.onFrame!(frameOf(model))
|
||||
await flushMicrotasks()
|
||||
const createTab = vi.spyOn(store.getState(), 'createTab')
|
||||
|
||||
const outcome = await runWorktreeAgentActivationGate(GIT_KEY, {
|
||||
getState: store.getState,
|
||||
awaitReady: async () => true,
|
||||
listSessions: async () => [
|
||||
{ id: CLI_PTY, cwd: '/', title: '', worktreeId: GIT_KEY, agentOwnership: 'absent' }
|
||||
],
|
||||
listSurfaceOwners: async () => unowned,
|
||||
hasStructuredSession: async () => false,
|
||||
resume: () => 0
|
||||
})
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(outcome).toBe('adopted')
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
expect(mainTabIds()).toEqual(['tab-cli'])
|
||||
store.getState().ensureWorktreeRootGroup(GIT_KEY)
|
||||
await flushMicrotasks()
|
||||
expect(terminalTabIds(store)).toEqual(['tab-cli'])
|
||||
})
|
||||
|
||||
it('main refusing the adoption (a WSL move) leaves the terminal declined, not shown, and makes no tab', async () => {
|
||||
const store = await bootWindow()
|
||||
stream.onFrame!(frameOf(model))
|
||||
await flushMicrotasks()
|
||||
main.apply = () => ({ ok: false, code: 'wsl_move_in_progress' })
|
||||
const createTab = vi.spyOn(store.getState(), 'createTab')
|
||||
|
||||
const adoption = await adoptLiveWorkspacePtySurfaces(
|
||||
store.getState,
|
||||
GIT_KEY,
|
||||
[CLI_PTY],
|
||||
async () => unowned
|
||||
)
|
||||
|
||||
expect(adoption).toEqual({ surfaced: false, declinedPtyIds: [CLI_PTY] })
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,8 @@ import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
|
||||
import { parsePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { worktreeIdsEqual } from '../../../shared/worktree/id'
|
||||
import type { useAppStore } from '@/store'
|
||||
import type { WorktreeOperationRouteState } from '@/lib/worktree-operation-route'
|
||||
import { adoptLiveTerminalThroughLayoutOwner } from '@/store/terminals/terminal-tab-create-through-layout-owner'
|
||||
import { layoutFramesHoldTerminalPane } from '@/runtime/layout-frame-terminal-panes'
|
||||
import { resolveTerminalTabPtyOwnership } from './terminal-tab-for-pty-id'
|
||||
import type {
|
||||
@@ -9,16 +11,17 @@ import type {
|
||||
LiveTerminalSurfaceOwnerIndex
|
||||
} from './worktree-live-terminal-surface-owners'
|
||||
|
||||
export type LiveSurfaceAdoptionStore = Pick<
|
||||
ReturnType<typeof useAppStore.getState>,
|
||||
| 'createTab'
|
||||
| 'ptyIdsByTabId'
|
||||
| 'setTabLayout'
|
||||
| 'tabsByWorktree'
|
||||
| 'terminalLayoutsByTabId'
|
||||
| 'updateTabPtyId'
|
||||
| 'replaceTerminalLayoutPanePtyId'
|
||||
>
|
||||
export type LiveSurfaceAdoptionStore = WorktreeOperationRouteState &
|
||||
Pick<
|
||||
ReturnType<typeof useAppStore.getState>,
|
||||
| 'createTab'
|
||||
| 'ptyIdsByTabId'
|
||||
| 'setTabLayout'
|
||||
| 'tabsByWorktree'
|
||||
| 'terminalLayoutsByTabId'
|
||||
| 'updateTabPtyId'
|
||||
| 'replaceTerminalLayoutPanePtyId'
|
||||
>
|
||||
|
||||
function layoutContainsLeaf(
|
||||
root: LiveSurfaceAdoptionStore['terminalLayoutsByTabId'][string]['root'],
|
||||
@@ -201,6 +204,25 @@ export async function adoptLiveWorkspacePtySurfaces(
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Switch point: main shows it unless this window holds its recorded pane; a refusal leaves
|
||||
// it without one, so it is declined rather than counted as shown.
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
const outcome =
|
||||
owner.recorded && bindToRecordedSurface(getState(), worktreeId, owner.recorded)
|
||||
? 'shown'
|
||||
: await adoptLiveTerminalThroughLayoutOwner(getState, worktreeId, {
|
||||
ptyId,
|
||||
...(owner.recorded ? { recordedPaneKey: owner.recorded.paneKey } : {})
|
||||
})
|
||||
if (outcome === 'shown') {
|
||||
surfaced = true
|
||||
continue
|
||||
}
|
||||
if (outcome === 'refused') {
|
||||
declinedPtyIds.push(ptyId)
|
||||
continue
|
||||
}
|
||||
}
|
||||
surfaced = true
|
||||
if (owner.recorded && bindToRecordedSurface(getState(), worktreeId, owner.recorded)) {
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// Which workspaces main's layout frames draw in the window, when the window starts holding one,
|
||||
// and which terminal panes a frame shows.
|
||||
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { PublishedWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published'
|
||||
import { pairedServerMirrors } from './paired-server-workspace'
|
||||
|
||||
type HeldWorkspacesStore = {
|
||||
getState: () => AppState
|
||||
subscribe: (listener: (state: AppState) => void) => () => void
|
||||
}
|
||||
|
||||
/** Which workspaces frames draw: ones the window holds that no paired server mirrors. */
|
||||
export function framesDrawWorkspace(state: AppState): (key: string) => boolean {
|
||||
const mirrored = pairedServerMirrors(state)
|
||||
return (key) =>
|
||||
(key in state.groupsByWorktree || key in state.unifiedTabsByWorktree) && !mirrored(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls `onDrawn` when frames start drawing one of `unshown` (main's workspaces the window does not
|
||||
* show): the window's first visit to a workspace only main held, after which no frame need follow.
|
||||
*/
|
||||
export function onWorkspaceStartsDrawing(
|
||||
store: HeldWorkspacesStore,
|
||||
unshown: () => string[],
|
||||
onDrawn: () => void
|
||||
): () => void {
|
||||
let held = store.getState()
|
||||
return store.subscribe((state) => {
|
||||
if (
|
||||
state.groupsByWorktree === held.groupsByWorktree &&
|
||||
state.unifiedTabsByWorktree === held.unifiedTabsByWorktree
|
||||
) {
|
||||
return
|
||||
}
|
||||
held = state
|
||||
const drawn = framesDrawWorkspace(state)
|
||||
if (unshown().some(drawn)) {
|
||||
onDrawn()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Whether a frame shows this terminal in this pane. An unbound pane does not count. */
|
||||
export function layoutHoldsBoundPane(
|
||||
layout: PublishedWorkspaceLayout | undefined,
|
||||
{ tabId, leafId, ptyId }: { tabId: string; leafId: string; ptyId: string }
|
||||
): boolean {
|
||||
return (
|
||||
layout?.tabs.some(
|
||||
(tab) =>
|
||||
tab.entityId === tabId &&
|
||||
(tab.terminal?.panes.some((pane) => pane.leafId === leafId && pane.ptyId === ptyId) ??
|
||||
false)
|
||||
) ?? false
|
||||
)
|
||||
}
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
type PublishedWorkspaceLayout
|
||||
} from '../../../shared/workspace-layout/workspace-layout-published'
|
||||
import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames'
|
||||
import { GIT_KEY } from '../../../shared/workspace-layout/workspace-layout-session.test-fixture'
|
||||
import {
|
||||
emptySession,
|
||||
GIT_KEY
|
||||
} from '../../../shared/workspace-layout/workspace-layout-session.test-fixture'
|
||||
import { buildOwnedEditorFileId } from '../store/slices/editor/file-ids/editor-file-ids'
|
||||
import { bootWindowFromPartitions } from '../app-shell/startup-window-view.test-fixture'
|
||||
import { createStoreSessionMockApi } from '../store/slices/store-session-test-harness'
|
||||
@@ -399,6 +402,27 @@ describe('the window drawn from layout frames', () => {
|
||||
sync.close()
|
||||
})
|
||||
|
||||
it('shows a workspace only main held (a CLI-started tab) once the window first opens it', async () => {
|
||||
const store = createTestStore()
|
||||
await bootWindowFromPartitions(store, [{ hostId: 'local', session: emptySession() }])
|
||||
const sync = openWorkspaceLayoutFrameSync(store)
|
||||
const layouts = published('local', localDesktopSession())
|
||||
stream.onFrame!(snapshot(layouts))
|
||||
sync.start()
|
||||
await flushMicrotasks()
|
||||
expect(store.getState().tabsByWorktree[GIT_KEY]).toBeUndefined()
|
||||
// The first visit gives the workspace its empty root group; no frame arrives after it.
|
||||
store.getState().ensureWorktreeRootGroup(GIT_KEY)
|
||||
await flushMicrotasks()
|
||||
// Exactly main's terminal tabs: the visit adds none of its own.
|
||||
const mainTerminals = layouts[GIT_KEY]!.tabs.flatMap((tab) =>
|
||||
tab.terminal ? [tab.entityId] : []
|
||||
)
|
||||
expect(mainTerminals).toContain('tab-shell')
|
||||
expect(store.getState().tabsByWorktree[GIT_KEY]?.map((tab) => tab.id)).toEqual(mainTerminals)
|
||||
sync.close()
|
||||
})
|
||||
|
||||
it('started before its snapshot, shows the snapshot when it arrives', async () => {
|
||||
const store = createTestStore()
|
||||
await bootWindowFromPartitions(store, [{ hostId: 'local', session: localDesktopSession() }])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// The window's layout slices drawn from main's layout stream (design phase E2b), in a layout-owner
|
||||
// build only. Main publishes every workspace it holds; this window shows a frame's workspace when
|
||||
// it writes that workspace to a partition this machine owns (a paired server's stay the mirror's)
|
||||
// and already holds it (startup restore keeps only workspaces the catalog knows).
|
||||
// and holds it (startup restore keeps only workspaces the catalog knows; a first visit adds one).
|
||||
//
|
||||
// A tab the window drew, closed or moved before main answered stays as drawn until the answer is
|
||||
// in (layout-owner-drawn-tab-holds); everything else shows as each frame arrives.
|
||||
@@ -16,7 +16,11 @@ import {
|
||||
terminalLayoutsPatch
|
||||
} from '@/store/workspace-layout-frame-store-patch'
|
||||
import { buildWorkspaceSessionPayload } from '@/lib/workspace-session'
|
||||
import { pairedServerMirrors } from './paired-server-workspace'
|
||||
import {
|
||||
framesDrawWorkspace,
|
||||
layoutHoldsBoundPane,
|
||||
onWorkspaceStartsDrawing
|
||||
} from './workspace-layout-frame-drawn-workspaces'
|
||||
import { splitWorkspaceSessionByHost } from '@/lib/workspace-session-host-split'
|
||||
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { TerminalTabTitleUpdate } from '@/store/slices/terminal-tab-title-batch'
|
||||
@@ -48,6 +52,7 @@ import { subscribeRuntimeLayout } from './runtime-layout-client'
|
||||
type FrameSyncStore = {
|
||||
getState: () => AppState
|
||||
setState: (patch: Partial<AppState>) => void
|
||||
subscribe: (listener: (state: AppState) => void) => () => void
|
||||
}
|
||||
|
||||
export type WorkspaceLayoutFrameSync = LayoutFrameTerminalQueries & {
|
||||
@@ -110,13 +115,6 @@ function liveTitleUpdates(
|
||||
return updates
|
||||
}
|
||||
|
||||
/** Which workspaces frames draw: ones the window holds that no paired server mirrors. */
|
||||
function framesDrawWorkspace(state: AppState): (key: string) => boolean {
|
||||
const mirrored = pairedServerMirrors(state)
|
||||
return (key) =>
|
||||
(key in state.groupsByWorktree || key in state.unifiedTabsByWorktree) && !mirrored(key)
|
||||
}
|
||||
|
||||
export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLayoutFrameSync {
|
||||
let cache: WorkspaceLayoutCache = EMPTY_WORKSPACE_LAYOUT_CACHE
|
||||
/** Workspace key → the frame the store shows. */
|
||||
@@ -303,6 +301,14 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa
|
||||
scheduleFlush()
|
||||
})
|
||||
|
||||
// A workspace only main held (a CLI-started tab) shows on the window's first visit, so the
|
||||
// activation finds main's tab rather than seeding a second one.
|
||||
const stopHeldWorkspacesListener = onWorkspaceStartsDrawing(
|
||||
store,
|
||||
() => Object.keys(cache).filter((key) => !(key in shown)),
|
||||
() => streamOpen && scheduleFlush()
|
||||
)
|
||||
|
||||
return {
|
||||
start: () => {
|
||||
started = true
|
||||
@@ -314,20 +320,15 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa
|
||||
},
|
||||
close: () => {
|
||||
live = false
|
||||
stopHeldWorkspacesListener()
|
||||
stopHeldTabsListener()
|
||||
stopReleaseListener()
|
||||
stream.close()
|
||||
},
|
||||
// Only a pane flush draws: the same workspaces it shows.
|
||||
holdsTerminalPane: (worktreeId, { tabId, leafId, ptyId }) =>
|
||||
holdsTerminalPane: (worktreeId, pane) =>
|
||||
framesDrawWorkspace(store.getState())(worktreeId) &&
|
||||
(cache[worktreeId]?.tabs.some(
|
||||
(tab) =>
|
||||
tab.entityId === tabId &&
|
||||
(tab.terminal?.panes.some((pane) => pane.leafId === leafId && pane.ptyId === ptyId) ??
|
||||
false)
|
||||
) ??
|
||||
false),
|
||||
layoutHoldsBoundPane(cache[worktreeId], pane),
|
||||
// A closed stream's cache is frozen: main may have closed the tab since.
|
||||
terminalTab: (worktreeId, tabId) =>
|
||||
streamOpen
|
||||
|
||||
@@ -181,5 +181,47 @@ export function createInitialTerminalThroughLayoutOwner(
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* A live terminal the host's census finds no pane for, shown through main: main answers with the
|
||||
* pane it already holds for it (a CLI tab this window never mounted), else makes a tab bound to it,
|
||||
* and its frame draws either. 'as-today': the window shows it itself, since a paired server owns
|
||||
* the workspace, main does not own it (a twin), or the call threw (a thrown write is rolled back).
|
||||
* 'refused': main showed nothing, so the terminal stays without a pane.
|
||||
*/
|
||||
export async function adoptLiveTerminalThroughLayoutOwner(
|
||||
getState: () => Parameters<typeof runtimeEnvironmentIdUnlessTwin>[0],
|
||||
worktreeId: string,
|
||||
terminal: { ptyId: string; recordedPaneKey?: string }
|
||||
): Promise<'shown' | 'refused' | 'as-today'> {
|
||||
if (runtimeEnvironmentIdUnlessTwin(getState(), worktreeId) !== null) {
|
||||
return 'as-today'
|
||||
}
|
||||
try {
|
||||
const reply = await callRuntimeRpc<LayoutApplyReply>({ kind: 'local' }, 'layout.apply', {
|
||||
command: {
|
||||
type: 'createTerminalTab',
|
||||
workspace: worktreeId,
|
||||
tabId: createBrowserUuid(),
|
||||
leafId: createBrowserUuid(),
|
||||
adopt: terminal
|
||||
}
|
||||
})
|
||||
if (reply.ok) {
|
||||
return 'shown'
|
||||
}
|
||||
if (reply.code === 'host_not_owned') {
|
||||
return 'as-today'
|
||||
}
|
||||
console.warn('[layout-owner] live terminal adoption refused:', reply.code)
|
||||
return 'refused'
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[layout-owner] live terminal adoption failed:',
|
||||
error instanceof Error ? error.message : error
|
||||
)
|
||||
return 'as-today'
|
||||
}
|
||||
}
|
||||
|
||||
/** The window's own mark on a first terminal: its spawn does not count as workspace activity. */
|
||||
const ACTIVATION_ROW_FIELDS = { pendingActivationSpawn: true } as const
|
||||
|
||||
@@ -184,6 +184,10 @@ const CreateTerminalTabCommand = z.object({
|
||||
.optional(),
|
||||
/** Only into a workspace with no tab in main's copy; otherwise nothing is created. */
|
||||
initial: z.literal('activation').optional(),
|
||||
/** A live terminal no pane shows: its pane when main holds one, else this tab, bound to it. */
|
||||
adopt: z
|
||||
.object({ ptyId: z.string().min(1), recordedPaneKey: z.string().min(1).optional() })
|
||||
.optional(),
|
||||
split: NewGroupSplit
|
||||
})
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// `startup_agent_conflict` (host support), `file_outside_workspace` (paths) and
|
||||
// `terminal_liveness_unavailable` (PTY host).
|
||||
|
||||
import type { LiveTerminalAdoption } from './workspace-layout-terminal-adoption'
|
||||
import type { SleepingAgentSessionRecord } from '../agent-session-resume'
|
||||
import type { AgentType } from '../agent-status-types'
|
||||
import type { AiVaultSessionTitle } from '../ai-vault-session-title'
|
||||
@@ -44,6 +45,8 @@ export type LayoutCommand =
|
||||
quickCommandLabel?: string
|
||||
creation?: Partial<LayoutTerminalCreation>
|
||||
initial?: InitialSurfaceReason
|
||||
/** A live terminal no pane shows: its pane when main holds one, else this tab, bound to it. */
|
||||
adopt?: LiveTerminalAdoption
|
||||
split?: NewGroupSplit
|
||||
})
|
||||
| (On & {
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
type WorkspaceLayoutModel
|
||||
} from './workspace-layout-model'
|
||||
import { admitsInitialSurface } from './workspace-layout-initial-surface'
|
||||
import { heldPaneOfTerminal } from './workspace-layout-terminal-adoption'
|
||||
import { removeTabFromWorkspace, withWorkspace } from './workspace-layout-removal'
|
||||
|
||||
type RefusedClose = NonNullable<LayoutCommandResult['refused']>[number]
|
||||
@@ -43,6 +44,11 @@ export function createTerminalTab(
|
||||
if (command.initial && !admitsInitialSurface(model, command.workspace, command.initial)) {
|
||||
return applied(model)
|
||||
}
|
||||
const held =
|
||||
command.adopt && heldPaneOfTerminal(model.workspaces[command.workspace], command.adopt)
|
||||
if (held) {
|
||||
return applied(model, held)
|
||||
}
|
||||
const workspace = workspaceOrEmpty(model, command.workspace)
|
||||
const terminals = workspace.tabs.flatMap((tab) => (tab.kind === 'terminal' ? [tab] : []))
|
||||
const ordinal = getNextTerminalOrdinal(
|
||||
@@ -64,6 +70,12 @@ export function createTerminalTab(
|
||||
}
|
||||
const placed = placeNewTab(workspace, command.workspace, tab, command, context)
|
||||
const paneKey = paneKeyOf(id, leafId)
|
||||
if (command.adopt) {
|
||||
// Its terminal already runs: the pane binds to it and nothing starts.
|
||||
const leaves = { ...placed.leaves, [leafId]: { ptyId: command.adopt.ptyId } }
|
||||
const adopted = withWorkspace(model, command.workspace, { ...placed, leaves })
|
||||
return applied(adopted, { tabId: id, leafId, paneKey })
|
||||
}
|
||||
return applied(
|
||||
withWorkspace(model, command.workspace, placed),
|
||||
{ tabId: id, leafId, paneKey },
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { applyLayoutCommand } from './workspace-layout-commands'
|
||||
import { asLoaded, build, testContext, WS } from './workspace-layout-command.test-fixture'
|
||||
import type { WorkspaceLayoutModel } from './workspace-layout-model'
|
||||
import { saveWorkspaceLayout } from './workspace-layout-save'
|
||||
|
||||
const PTY = `${WS}@@cli00001`
|
||||
|
||||
/** A tab main made for a CLI terminal: bound to it, or still unbound (its spawn not yet bound). */
|
||||
function withCliTab(bound: boolean) {
|
||||
const context = testContext()
|
||||
const { model, results } = build(context, [{ type: 'createTerminalTab', workspace: WS }])
|
||||
const { tabId, leafId, paneKey } = results[0]!
|
||||
const workspace = model.workspaces[WS]!
|
||||
const next: WorkspaceLayoutModel = bound
|
||||
? {
|
||||
...model,
|
||||
workspaces: { [WS]: { ...workspace, leaves: { [leafId!]: { ptyId: PTY } } } }
|
||||
}
|
||||
: model
|
||||
return { context, model: next, tabId: tabId!, leafId: leafId!, paneKey: paneKey! }
|
||||
}
|
||||
|
||||
const adopt = (extra: { recordedPaneKey?: string } = {}) => ({
|
||||
type: 'createTerminalTab' as const,
|
||||
workspace: WS,
|
||||
tabId: 'window-tab',
|
||||
leafId: '00000000-0000-4000-8000-0000000000ff',
|
||||
adopt: { ptyId: PTY, ...extra }
|
||||
})
|
||||
|
||||
describe('a live terminal no pane shows, through main', () => {
|
||||
it('names the pane main already shows it in, and makes no tab', () => {
|
||||
const { context, model, tabId, leafId, paneKey } = withCliTab(true)
|
||||
const applied = applyLayoutCommand(model, adopt(), context)
|
||||
expect(applied).toMatchObject({ ok: true, result: { tabId, leafId, paneKey } })
|
||||
expect(applied.ok && applied.model.workspaces[WS]!.tabs).toHaveLength(1)
|
||||
expect(applied.ok && applied.effects.startPaneKeys).toEqual([])
|
||||
})
|
||||
|
||||
it('names the pane the host recorded for it while main has not bound it yet', () => {
|
||||
const { context, model, tabId, paneKey } = withCliTab(false)
|
||||
const applied = applyLayoutCommand(model, adopt({ recordedPaneKey: paneKey }), context)
|
||||
expect(applied).toMatchObject({ ok: true, result: { tabId, paneKey } })
|
||||
expect(applied.ok && applied.model.workspaces[WS]!.tabs).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('makes a new tab when the recorded pane now shows another terminal', () => {
|
||||
const { context, model, paneKey, leafId } = withCliTab(false)
|
||||
const rebound: WorkspaceLayoutModel = {
|
||||
...model,
|
||||
workspaces: {
|
||||
[WS]: { ...model.workspaces[WS]!, leaves: { [leafId]: { ptyId: `${WS}@@other0001` } } }
|
||||
}
|
||||
}
|
||||
const applied = applyLayoutCommand(rebound, adopt({ recordedPaneKey: paneKey }), context)
|
||||
if (!applied.ok) {
|
||||
throw new Error(applied.code)
|
||||
}
|
||||
expect(applied.result).toMatchObject({ tabId: 'window-tab' })
|
||||
expect(applied.model.workspaces[WS]!.tabs).toHaveLength(2)
|
||||
expect(applied.model.workspaces[WS]!.leaves?.['00000000-0000-4000-8000-0000000000ff']).toEqual({
|
||||
ptyId: PTY
|
||||
})
|
||||
})
|
||||
|
||||
it('otherwise shows it in a new tab bound to it, starting nothing', () => {
|
||||
const context = testContext()
|
||||
const applied = applyLayoutCommand(build(context, []).model, adopt(), context)
|
||||
if (!applied.ok) {
|
||||
throw new Error(applied.code)
|
||||
}
|
||||
expect(applied.result).toMatchObject({ tabId: 'window-tab' })
|
||||
expect(applied.effects.startPaneKeys).toEqual([])
|
||||
expect(applied.model.workspaces[WS]!.leaves).toEqual({
|
||||
'00000000-0000-4000-8000-0000000000ff': { ptyId: PTY }
|
||||
})
|
||||
const saved = saveWorkspaceLayout(asLoaded(applied.model))
|
||||
expect(saved.tabsByWorktree?.[WS]?.map((row) => row.ptyId)).toEqual([PTY])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
// A live terminal no pane shows (the host's census found it unowned), shown through main: in the
|
||||
// pane main already holds for it, else in a new tab bound to it. Never a second tab for it.
|
||||
|
||||
import { leafIdsOf, locatePane } from './workspace-layout-command-steps'
|
||||
import type { LayoutCommandResult } from './workspace-layout-command-types'
|
||||
import { paneKeyOf, type WorkspaceLayout } from './workspace-layout-model'
|
||||
|
||||
export type LiveTerminalAdoption = {
|
||||
ptyId: string
|
||||
/** The pane the host last recorded for it: main holds it, still unbound, before the spawn binds it. */
|
||||
recordedPaneKey?: string
|
||||
}
|
||||
|
||||
/** The pane of `workspace` that already shows the terminal, or null. */
|
||||
export function heldPaneOfTerminal(
|
||||
workspace: WorkspaceLayout | undefined,
|
||||
{ ptyId, recordedPaneKey }: LiveTerminalAdoption
|
||||
): LayoutCommandResult | null {
|
||||
if (!workspace) {
|
||||
return null
|
||||
}
|
||||
for (const tab of workspace.tabs) {
|
||||
if (tab.kind !== 'terminal') {
|
||||
continue
|
||||
}
|
||||
const leafId = leafIdsOf(tab).find((leaf) => workspace.leaves?.[leaf]?.ptyId === ptyId)
|
||||
if (leafId) {
|
||||
return { tabId: tab.id, leafId, paneKey: paneKeyOf(tab.entityId, leafId) }
|
||||
}
|
||||
}
|
||||
const recorded = recordedPaneKey ? locatePane(workspace, recordedPaneKey) : null
|
||||
// A recorded pane bound since to another terminal shows that one, not this.
|
||||
return recorded && !workspace.leaves?.[recorded.leafId]?.ptyId
|
||||
? {
|
||||
tabId: recorded.tab.id,
|
||||
leafId: recorded.leafId,
|
||||
paneKey: paneKeyOf(recorded.tab.entityId, recorded.leafId)
|
||||
}
|
||||
: null
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* A terminal the CLI starts in a worktree the window never opened shows as that one tab on the
|
||||
* first visit, and again after a relaunch: no empty workspace, no second "Terminal 1" beside it
|
||||
* (layout-owner build only; main holds the tab before the window ever draws the workspace).
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart'
|
||||
import { switchToWorktree, waitForSessionReady } from './helpers/store'
|
||||
|
||||
const LAYOUT_OWNER_BUILD = existsSync(
|
||||
path.join(process.cwd(), 'out', 'main', 'ORCA_LAYOUT_OWNER_MAIN')
|
||||
)
|
||||
|
||||
type Tabs = { window: string[]; storedRows: string[]; storedTabBar: string[] }
|
||||
|
||||
async function createWorktree(page: Page, name: string): Promise<string> {
|
||||
return page.evaluate(async (worktreeName) => {
|
||||
const state = window.__store!.getState()
|
||||
const repoId = state.allWorktrees()[0]?.repoId
|
||||
if (!repoId) {
|
||||
throw new Error('seed repo was not loaded')
|
||||
}
|
||||
return (await state.createWorktree(repoId, worktreeName, undefined, 'skip')).worktree.id
|
||||
}, name)
|
||||
}
|
||||
|
||||
function createdTabId(reply: unknown): string | undefined {
|
||||
const result = reply && typeof reply === 'object' && 'result' in reply ? reply.result : null
|
||||
const terminal =
|
||||
result && typeof result === 'object' && 'terminal' in result ? result.terminal : null
|
||||
const tabId =
|
||||
terminal && typeof terminal === 'object' && 'tabId' in terminal ? terminal.tabId : null
|
||||
return typeof tabId === 'string' ? tabId : undefined
|
||||
}
|
||||
|
||||
/** The CLI's create: the runtime's `terminal.create`, in the background as a worker's would be. */
|
||||
async function createCliTerminal(page: Page, worktreeId: string): Promise<string> {
|
||||
const reply = await page.evaluate(
|
||||
(id) =>
|
||||
window.api.runtime.call({
|
||||
method: 'terminal.create',
|
||||
params: {
|
||||
worktree: `id:${id}`,
|
||||
command: 'sleep 600',
|
||||
focus: false,
|
||||
activate: false,
|
||||
presentation: 'background'
|
||||
}
|
||||
}),
|
||||
worktreeId
|
||||
)
|
||||
const tabId = createdTabId(reply)
|
||||
if (!tabId) {
|
||||
throw new Error(`terminal.create gave no tab: ${JSON.stringify(reply).slice(0, 300)}`)
|
||||
}
|
||||
return tabId
|
||||
}
|
||||
|
||||
async function readTabs(page: Page, worktreeId: string): Promise<Tabs> {
|
||||
return page.evaluate(async (id) => {
|
||||
const session = await window.api.session.get()
|
||||
return {
|
||||
window: (window.__store!.getState().tabsByWorktree[id] ?? []).map((tab) => tab.id),
|
||||
storedRows: (session.tabsByWorktree?.[id] ?? []).map((row) => row.id),
|
||||
storedTabBar: (session.unifiedTabs?.[id] ?? [])
|
||||
.filter((entry) => entry.contentType === 'terminal')
|
||||
.map((entry) => entry.entityId)
|
||||
}
|
||||
}, worktreeId)
|
||||
}
|
||||
|
||||
/** Polls until the tabs read `expected` on three reads in a row: a late duplicate fails it. */
|
||||
async function expectSettledTabs(page: Page, worktreeId: string, expected: Tabs): Promise<void> {
|
||||
let streak = 0
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const tabs = await readTabs(page, worktreeId)
|
||||
streak = JSON.stringify(tabs) === JSON.stringify(expected) ? streak + 1 : 0
|
||||
return streak >= 3 ? tabs : { ...tabs, streak }
|
||||
},
|
||||
{ timeout: 30_000, intervals: [500] }
|
||||
)
|
||||
.toEqual(expected)
|
||||
}
|
||||
|
||||
test('a CLI-started terminal in a never-opened worktree shows as its one tab, before and after a relaunch', async ({
|
||||
testRepoPath
|
||||
}, testInfo) => {
|
||||
test.skip(!LAYOUT_OWNER_BUILD, 'layout-owner build only')
|
||||
const session = createRestartSession(testInfo)
|
||||
let app: Awaited<ReturnType<typeof session.launch>>['app'] | undefined
|
||||
try {
|
||||
const first = await session.launch()
|
||||
app = first.app
|
||||
await waitForSessionReady(first.page)
|
||||
await attachRepoAndOpenTerminal(first.page, testRepoPath)
|
||||
const worktreeId = await createWorktree(first.page, `cli-first-${Date.now()}`)
|
||||
const cliTabId = await createCliTerminal(first.page, worktreeId)
|
||||
const one: Tabs = { window: [cliTabId], storedRows: [cliTabId], storedTabBar: [cliTabId] }
|
||||
// Main holds it before the window has ever drawn the workspace.
|
||||
await expect
|
||||
.poll(async () => (await readTabs(first.page, worktreeId)).storedRows, { timeout: 15_000 })
|
||||
.toEqual([cliTabId])
|
||||
expect((await readTabs(first.page, worktreeId)).window).toEqual([])
|
||||
|
||||
await switchToWorktree(first.page, worktreeId)
|
||||
await expectSettledTabs(first.page, worktreeId, one)
|
||||
await session.close(app)
|
||||
app = undefined
|
||||
|
||||
const second = await session.launch()
|
||||
app = second.app
|
||||
await waitForSessionReady(second.page)
|
||||
await switchToWorktree(second.page, worktreeId)
|
||||
await expectSettledTabs(second.page, worktreeId, one)
|
||||
} finally {
|
||||
await session.dispose(app)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user