mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 00:04:01 +00:00
fix(sidebar): stamp launch leaf on mirrored first layout
Host snapshots skip setTabLayout, so the original-leaf pin never armed on paired web/SSH and could linger after launchAgent dropped. Stamp and drop the pin with the same resolver the store uses. STA-4774
This commit is contained in:
@@ -373,6 +373,20 @@ describe('buildTitleDerivedAgentRows', () => {
|
||||
expect(rows).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not treat a bare Terminal title as exit unless it matches the tab defaultTitle', () => {
|
||||
const rows = buildWorktreeAgentRows({
|
||||
tabs: [makeTab('tab-1', { launchAgent: 'codex', launchAgentLeafId: LEAF_ID_1 })],
|
||||
entries: [],
|
||||
retained: [],
|
||||
runtimePaneTitlesByTabId: { 'tab-1': { 1: 'Terminal 1' } },
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-codex'] },
|
||||
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
|
||||
now: 2000
|
||||
})
|
||||
|
||||
expect(rows.map((row) => [row.agentType, row.state])).toEqual([['codex', 'idle']])
|
||||
})
|
||||
|
||||
it('keeps a launched Cursor pane on a sole leaf even before launchAgentLeafId is stamped', () => {
|
||||
const rows = buildWorktreeAgentRows({
|
||||
tabs: [makeTab('tab-1', { launchAgent: 'cursor' })],
|
||||
@@ -415,6 +429,20 @@ describe('buildTitleDerivedAgentRows', () => {
|
||||
expect(rows).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps launch identity on the original leaf after a sibling closes', () => {
|
||||
const rows = buildWorktreeAgentRows({
|
||||
tabs: [makeTab('tab-1', { launchAgent: 'cursor', launchAgentLeafId: LEAF_ID_1 })],
|
||||
entries: [],
|
||||
retained: [],
|
||||
runtimePaneTitlesByTabId: { 'tab-1': { 1: 'Rename the auth helper' } },
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-cursor'] },
|
||||
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
|
||||
now: 2000
|
||||
})
|
||||
|
||||
expect(rows.map((row) => [row.agentType, row.state])).toEqual([['cursor', 'idle']])
|
||||
})
|
||||
|
||||
// #8940: an OpenCode pane's own task text must not hand the row to Claude Code.
|
||||
it('keeps an OpenCode-launched pane OpenCode across its own status frames', () => {
|
||||
const frames: [string, string][] = [
|
||||
|
||||
@@ -55,6 +55,7 @@ describe('applyWebSessionTabsSnapshot', () => {
|
||||
id: mirroredId,
|
||||
ptyId: 'remote:web-env-1@@terminal-1',
|
||||
launchAgent: 'codex',
|
||||
launchAgentLeafId: LEAF_ID,
|
||||
startupCwd: '/worktree/packages/web',
|
||||
title: 'host shell',
|
||||
worktreeId: WT
|
||||
@@ -87,13 +88,21 @@ describe('applyWebSessionTabsSnapshot', () => {
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: NOW,
|
||||
launchAgent: 'codex'
|
||||
launchAgent: 'codex',
|
||||
launchAgentLeafId: LEAF_ID
|
||||
}
|
||||
|
||||
const patch = applyWebSessionTabsSnapshot(
|
||||
makeState({
|
||||
tabsByWorktree: { [WT]: [existingTab] },
|
||||
ptyIdsByTabId: { [existingTab.id]: ['remote:web-env-1@@terminal-1'] }
|
||||
ptyIdsByTabId: { [existingTab.id]: ['remote:web-env-1@@terminal-1'] },
|
||||
terminalLayoutsByTabId: {
|
||||
[existingTab.id]: {
|
||||
root: { type: 'leaf', leafId: LEAF_ID },
|
||||
activeLeafId: LEAF_ID,
|
||||
expandedLeafId: null
|
||||
}
|
||||
}
|
||||
}),
|
||||
makeSnapshot([
|
||||
{
|
||||
@@ -116,6 +125,60 @@ describe('applyWebSessionTabsSnapshot', () => {
|
||||
title: 'zsh'
|
||||
})
|
||||
expect(patch.tabsByWorktree?.[WT]?.[0]?.launchAgent).toBeUndefined()
|
||||
expect(patch.tabsByWorktree?.[WT]?.[0]?.launchAgentLeafId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not recycle launch provenance onto a remaining mirrored sibling', () => {
|
||||
const existingTab: TerminalTab = {
|
||||
id: toWebTerminalSurfaceTabId('host-tab-1'),
|
||||
ptyId: 'remote:web-env-1@@terminal-1',
|
||||
worktreeId: WT,
|
||||
title: 'Rename the auth helper',
|
||||
defaultTitle: 'Codex',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: NOW,
|
||||
launchAgent: 'codex',
|
||||
launchAgentLeafId: LEAF_ID
|
||||
}
|
||||
|
||||
const patch = applyWebSessionTabsSnapshot(
|
||||
makeState({
|
||||
tabsByWorktree: { [WT]: [existingTab] },
|
||||
ptyIdsByTabId: { [existingTab.id]: ['remote:web-env-1@@terminal-2'] },
|
||||
terminalLayoutsByTabId: {
|
||||
[existingTab.id]: {
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', leafId: LEAF_ID },
|
||||
second: { type: 'leaf', leafId: SECOND_LEAF_ID }
|
||||
},
|
||||
activeLeafId: SECOND_LEAF_ID,
|
||||
expandedLeafId: null
|
||||
}
|
||||
}
|
||||
}),
|
||||
makeSnapshot([
|
||||
{
|
||||
type: 'terminal',
|
||||
id: `host-tab-1::${SECOND_LEAF_ID}`,
|
||||
title: 'Rename the auth helper',
|
||||
parentTabId: 'host-tab-1',
|
||||
leafId: SECOND_LEAF_ID,
|
||||
isActive: true,
|
||||
launchAgent: 'codex',
|
||||
status: 'ready',
|
||||
terminal: 'terminal-2'
|
||||
}
|
||||
]),
|
||||
ENV,
|
||||
NOW + 1
|
||||
) as Partial<WebSessionTabsSyncState>
|
||||
|
||||
expect(patch.tabsByWorktree?.[WT]?.[0]?.launchAgent).toBe('codex')
|
||||
expect(patch.tabsByWorktree?.[WT]?.[0]?.launchAgentLeafId).toBe(LEAF_ID)
|
||||
})
|
||||
|
||||
it('drops mirrored startup cwd when a later host snapshot omits it', () => {
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
buildRetiredTerminalTabStateSweepPatch,
|
||||
type RetiredTerminalTabSweepState
|
||||
} from '../store/slices/retired-terminal-tab-state-sweep'
|
||||
import { resolveLaunchAgentLeafId } from '../store/slices/launch-agent-leaf-stamp'
|
||||
import { getRemoteRuntimePtyEnvironmentId, toRemoteRuntimePtyId } from './runtime-terminal-stream'
|
||||
import { sanitizeTerminalLayoutPaneTitlesForLabels } from '@/lib/terminal-pane-title-sanitization'
|
||||
import { terminalLayoutEqual } from '@/lib/terminal-layout-equality'
|
||||
@@ -1071,6 +1072,14 @@ function buildMirroredTerminalTabs(
|
||||
// Why: viewMode echoes back through host snapshots, so prefer the client's record during the echo window and adopt the host value only without a prior tab.
|
||||
const hostViewModeSurface = surfaces.find((surface) => surface.viewMode)
|
||||
const viewMode = existing ? existing.viewMode : hostViewModeSurface?.viewMode
|
||||
// Why: host snapshots omit this pin; stamp the first sole leaf and keep it
|
||||
// only while launchAgent remains so a remaining sibling cannot inherit it.
|
||||
const launchAgentLeafId = resolveLaunchAgentLeafId({
|
||||
launchAgent,
|
||||
existingLeafId: existing?.launchAgentLeafId,
|
||||
previousLayout: existingLayout,
|
||||
nextLayout: layout
|
||||
})
|
||||
return {
|
||||
tab: {
|
||||
id: localTabId,
|
||||
@@ -1092,9 +1101,7 @@ function buildMirroredTerminalTabs(
|
||||
createdAt: existing?.createdAt ?? now + index,
|
||||
// Why: launchAgent is host-owned lifecycle metadata; once the host omits it, don't resurrect stale startup intent.
|
||||
...(launchAgent ? { launchAgent } : {}),
|
||||
// Why: launchAgentLeafId is client-stamped from the first sole leaf; the
|
||||
// host snapshot does not carry it, so dropping it would recycle identity.
|
||||
...(existing?.launchAgentLeafId ? { launchAgentLeafId: existing.launchAgentLeafId } : {})
|
||||
...(launchAgentLeafId ? { launchAgentLeafId } : {})
|
||||
},
|
||||
hostTabId: parentTabId,
|
||||
ptyIds,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types'
|
||||
import { emptyLayoutSnapshot, singlePaneLayoutSnapshot } from './terminal-helpers'
|
||||
import { stampLaunchAgentLeafIdOnFirstLayout } from './launch-agent-leaf-stamp'
|
||||
import {
|
||||
resolveLaunchAgentLeafId,
|
||||
stampLaunchAgentLeafIdOnFirstLayout
|
||||
} from './launch-agent-leaf-stamp'
|
||||
|
||||
const LEAF_A = '11111111-1111-4111-8111-111111111111'
|
||||
const LEAF_B = '22222222-2222-4222-8222-222222222222'
|
||||
@@ -78,3 +81,38 @@ describe('stampLaunchAgentLeafIdOnFirstLayout', () => {
|
||||
expect(stamped).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveLaunchAgentLeafId', () => {
|
||||
it('stamps the first sole leaf while launch identity is live', () => {
|
||||
expect(
|
||||
resolveLaunchAgentLeafId({
|
||||
launchAgent: 'cursor',
|
||||
existingLeafId: undefined,
|
||||
previousLayout: emptyLayoutSnapshot(),
|
||||
nextLayout: singlePaneLayoutSnapshot(LEAF_A)
|
||||
})
|
||||
).toBe(LEAF_A)
|
||||
})
|
||||
|
||||
it('keeps the original pin after a remaining sibling becomes the sole leaf', () => {
|
||||
expect(
|
||||
resolveLaunchAgentLeafId({
|
||||
launchAgent: 'cursor',
|
||||
existingLeafId: LEAF_A,
|
||||
previousLayout: splitLayout(),
|
||||
nextLayout: singlePaneLayoutSnapshot(LEAF_B)
|
||||
})
|
||||
).toBe(LEAF_A)
|
||||
})
|
||||
|
||||
it('drops the pin when launch identity is gone', () => {
|
||||
expect(
|
||||
resolveLaunchAgentLeafId({
|
||||
launchAgent: undefined,
|
||||
existingLeafId: LEAF_A,
|
||||
previousLayout: singlePaneLayoutSnapshot(LEAF_A),
|
||||
nextLayout: singlePaneLayoutSnapshot(LEAF_A)
|
||||
})
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
import { isTerminalLeafId } from '../../../../shared/stable-pane-id'
|
||||
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types'
|
||||
|
||||
/**
|
||||
* Next persisted launch-leaf pin while rebuilding a tab.
|
||||
* Drops with launchAgent so a later relaunch cannot inherit a stale leaf;
|
||||
* never overwrites an existing pin — a remaining sibling is also a sole leaf.
|
||||
*/
|
||||
export function resolveLaunchAgentLeafId(args: {
|
||||
launchAgent: TerminalTab['launchAgent']
|
||||
existingLeafId: TerminalTab['launchAgentLeafId']
|
||||
previousLayout: TerminalLayoutSnapshot | undefined
|
||||
nextLayout: TerminalLayoutSnapshot
|
||||
}): string | undefined {
|
||||
if (!args.launchAgent) {
|
||||
return undefined
|
||||
}
|
||||
if (args.existingLeafId) {
|
||||
return args.existingLeafId
|
||||
}
|
||||
return soleLeafIdOnFirstLayout(args.previousLayout, args.nextLayout)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind tab-scoped launchAgent to the first sole leaf the layout describes.
|
||||
* Later topologies must not overwrite it — a remaining sibling after a close
|
||||
@@ -12,19 +32,32 @@ export function stampLaunchAgentLeafIdOnFirstLayout(args: {
|
||||
previousLayout: TerminalLayoutSnapshot | undefined
|
||||
nextLayout: TerminalLayoutSnapshot
|
||||
}): TerminalTab[] | null {
|
||||
if (args.previousLayout?.root) {
|
||||
return null
|
||||
}
|
||||
const nextRoot = args.nextLayout.root
|
||||
if (nextRoot?.type !== 'leaf' || !isTerminalLeafId(nextRoot.leafId)) {
|
||||
return null
|
||||
}
|
||||
const tabIndex = args.tabs.findIndex((tab) => tab.id === args.tabId)
|
||||
const tab = args.tabs[tabIndex]
|
||||
if (!tab?.launchAgent || tab.launchAgentLeafId) {
|
||||
const launchAgentLeafId = resolveLaunchAgentLeafId({
|
||||
launchAgent: tab?.launchAgent,
|
||||
existingLeafId: tab?.launchAgentLeafId,
|
||||
previousLayout: args.previousLayout,
|
||||
nextLayout: args.nextLayout
|
||||
})
|
||||
if (!tab || !launchAgentLeafId || launchAgentLeafId === tab.launchAgentLeafId) {
|
||||
return null
|
||||
}
|
||||
const nextTabs = [...args.tabs]
|
||||
nextTabs[tabIndex] = { ...tab, launchAgentLeafId: nextRoot.leafId }
|
||||
nextTabs[tabIndex] = { ...tab, launchAgentLeafId }
|
||||
return nextTabs
|
||||
}
|
||||
|
||||
function soleLeafIdOnFirstLayout(
|
||||
previousLayout: TerminalLayoutSnapshot | undefined,
|
||||
nextLayout: TerminalLayoutSnapshot
|
||||
): string | undefined {
|
||||
if (previousLayout?.root) {
|
||||
return undefined
|
||||
}
|
||||
const nextRoot = nextLayout.root
|
||||
if (nextRoot?.type !== 'leaf' || !isTerminalLeafId(nextRoot.leafId)) {
|
||||
return undefined
|
||||
}
|
||||
return nextRoot.leafId
|
||||
}
|
||||
|
||||
@@ -200,6 +200,36 @@ describe('parseWorkspaceSession', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('drops an invalid launchAgentLeafId without failing the whole session', () => {
|
||||
const result = parseWorkspaceSession({
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
activeTabId: null,
|
||||
tabsByWorktree: {
|
||||
wt: [
|
||||
{
|
||||
id: 'tab1',
|
||||
ptyId: null,
|
||||
worktreeId: 'wt',
|
||||
title: 'codex',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1,
|
||||
launchAgent: 'cursor',
|
||||
launchAgentLeafId: 'not-a-leaf-id'
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {}
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.value.tabsByWorktree.wt[0].launchAgentLeafId).toBeUndefined()
|
||||
expect(result.value.tabsByWorktree.wt[0].launchAgent).toBe('cursor')
|
||||
}
|
||||
})
|
||||
|
||||
it('drops an unknown launchAgent without failing the whole session', () => {
|
||||
const result = parseWorkspaceSession({
|
||||
activeRepoId: null,
|
||||
|
||||
Reference in New Issue
Block a user