fix(runtime): tab property edits live with the other tab properties, and main typechecks again (#32168)

#32018 pushed headless-terminal-tab-close.ts over the 300-line lint limit,
and its new test passed sleepWorktree to RuntimeNotifier after #31564 removed
it. setMobileSessionTabProps moves unchanged into RuntimeHeadlessTabProperties,
which already owns the helpers it calls and directly extends the close class.
This commit is contained in:
Jinwoo Hong
2026-10-22 23:59:41 -04:00
committed by GitHub
parent c089ca5812
commit 28c726855e
3 changed files with 72 additions and 82 deletions
@@ -23,7 +23,6 @@ function fixture() {
renameTerminal: vi.fn(),
focusTerminal: vi.fn(),
closeTerminal: vi.fn(),
sleepWorktree: vi.fn(),
terminalFitOverrideChanged: vi.fn(),
terminalDriverChanged: vi.fn()
} satisfies RuntimeNotifier
@@ -6,6 +6,7 @@ import type {
TerminalLayoutSnapshot,
TerminalPaneLayoutNode
} from '../../../shared/terminal-tab-types'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
import { cloneTerminalLayoutSnapshot } from '../mobile-session-layout-projection'
import { sameStringRecord, terminalLayoutNodeEqual } from '../../../shared/terminal-layout-equality'
@@ -32,6 +33,77 @@ function paneLayoutEditIsNoOp(
}
export abstract class RuntimeHeadlessTabProperties extends OrcaRuntimeWithCloseHeadlessMobileTerminalTab {
// Why: tab color/pin are host-authoritative for remote-server tabs but had no
// push path, so pinning or coloring a tab reverted on the next snapshot and
// was never persisted. Persist to the workspace session + live snapshot.
async setMobileSessionTabProps(
worktreeSelector: string,
args: {
tabId: string
color?: string | null
isPinned?: boolean
viewMode?: 'terminal' | 'chat'
customTitle?: string | null
}
): Promise<{ updated: true }> {
const explicitWorktreeId = this.getExplicitSessionWorktreeIdSelector(worktreeSelector)
const worktreeId =
explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id
// Why: a renderer-authoritative host owns + republishes tab props, so a
// headless write would be overwritten. Persist only when headless.
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
const hostTabId = snapshot
? (this.resolveMobileSessionHostTabId(snapshot, args.tabId) ?? args.tabId)
: args.tabId
// Why by tab: a sleeping or starting pane has no handle for terminal.rename to name (#29975).
const customTitle =
args.customTitle === undefined ? undefined : args.customTitle?.trim() || null
// Switch point: a terminal tab main owns takes its props in main's layout, window or not.
if (ORCA_LAYOUT_OWNER_IS_MAIN && (await this.setMainTabProps(worktreeId, hostTabId, args))) {
return { updated: true }
}
if (this.getAvailableAuthoritativeWindow()) {
if (args.color !== undefined || args.isPinned !== undefined || args.viewMode !== undefined) {
throw Object.assign(
new Error(
'Tab color, pin, and view mode edits are unavailable while a desktop window owns the layout'
),
{ code: 'capability_unsupported' }
)
}
if (customTitle !== undefined) {
this.notifier?.renameTerminal(hostTabId, customTitle)
}
return { updated: true }
}
this.persistHeadlessSessionTabProps(worktreeId, hostTabId, args)
// Why: a live pane's own title outranks the snapshot's, so name it the way terminal.rename does.
const liveHandles =
customTitle === undefined
? []
: this.getPtyIdsForExplicitTabClose(worktreeId, hostTabId).flatMap((ptyId) => {
const handle = this.handleByPtyId.get(ptyId)
return handle ? [handle] : []
})
for (const handle of liveHandles) {
await this.renameTerminal(handle, customTitle ?? null)
}
if (customTitle !== undefined && liveHandles.length === 0) {
this.persistHeadlessTerminalTitle(worktreeId, hostTabId, customTitle)
}
// A live pane's rename above already set its title; only a paneless tab needs `title` here.
this.applyHeadlessSessionTabPropsToSnapshot(worktreeId, hostTabId, {
...args,
...(customTitle !== undefined && liveHandles.length === 0
? {
customTitle,
title: customTitle ?? this.headlessTerminalFallbackTitle(worktreeId, hostTabId)
}
: {})
})
return { updated: true }
}
/** What `title` reads once a manual name is cleared; readers that predate `customTitle` show it. */
protected headlessTerminalFallbackTitle(worktreeId: string, tabId: string): string {
const tab = this.getWorkspaceSessionForWorktree(worktreeId)?.tabsByWorktree[worktreeId]?.find(
@@ -19,10 +19,6 @@ import type { RuntimeTerminalRename } from '../../../shared/runtime-terminal-con
import type { RuntimeSessionTabCloseReason } from '../../../shared/runtime-session-contracts'
export abstract class OrcaRuntimeWithCloseHeadlessMobileTerminalTab extends OrcaRuntimeWithMainLayoutTabMoves {
protected abstract applyHeadlessSessionTabPropsToSnapshot(
...args: Parameters<HeadlessTabPropertiesMethods['applyHeadlessSessionTabPropsToSnapshot']>
): ReturnType<HeadlessTabPropertiesMethods['applyHeadlessSessionTabPropsToSnapshot']>
protected abstract applyHeadlessTerminalPaneLayoutToSnapshot(
...args: Parameters<HeadlessTabPropertiesMethods['applyHeadlessTerminalPaneLayoutToSnapshot']>
): ReturnType<HeadlessTabPropertiesMethods['applyHeadlessTerminalPaneLayoutToSnapshot']>
@@ -35,10 +31,6 @@ export abstract class OrcaRuntimeWithCloseHeadlessMobileTerminalTab extends Orca
...args: Parameters<PersistHeadlessTerminalTitleMethods['normalizeMobileSessionTabOrder']>
): ReturnType<PersistHeadlessTerminalTitleMethods['normalizeMobileSessionTabOrder']>
protected abstract persistHeadlessSessionTabProps(
...args: Parameters<HeadlessTabPropertiesMethods['persistHeadlessSessionTabProps']>
): ReturnType<HeadlessTabPropertiesMethods['persistHeadlessSessionTabProps']>
protected abstract persistHeadlessTerminalPaneLayout(
...args: Parameters<HeadlessTabPropertiesMethods['persistHeadlessTerminalPaneLayout']>
): ReturnType<HeadlessTabPropertiesMethods['persistHeadlessTerminalPaneLayout']>
@@ -275,77 +267,4 @@ export abstract class OrcaRuntimeWithCloseHeadlessMobileTerminalTab extends Orca
}
return { updated: true }
}
protected abstract headlessTerminalFallbackTitle(worktreeId: string, tabId: string): string
// Why: tab color/pin are host-authoritative for remote-server tabs but had no
// push path, so pinning or coloring a tab reverted on the next snapshot and
// was never persisted. Persist to the workspace session + live snapshot.
async setMobileSessionTabProps(
worktreeSelector: string,
args: {
tabId: string
color?: string | null
isPinned?: boolean
viewMode?: 'terminal' | 'chat'
customTitle?: string | null
}
): Promise<{ updated: true }> {
const explicitWorktreeId = this.getExplicitSessionWorktreeIdSelector(worktreeSelector)
const worktreeId =
explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id
// Why: a renderer-authoritative host owns + republishes tab props, so a
// headless write would be overwritten. Persist only when headless.
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
const hostTabId = snapshot
? (this.resolveMobileSessionHostTabId(snapshot, args.tabId) ?? args.tabId)
: args.tabId
// Why by tab: a sleeping or starting pane has no handle for terminal.rename to name (#29975).
const customTitle =
args.customTitle === undefined ? undefined : args.customTitle?.trim() || null
// Switch point: a terminal tab main owns takes its props in main's layout, window or not.
if (ORCA_LAYOUT_OWNER_IS_MAIN && (await this.setMainTabProps(worktreeId, hostTabId, args))) {
return { updated: true }
}
if (this.getAvailableAuthoritativeWindow()) {
if (args.color !== undefined || args.isPinned !== undefined || args.viewMode !== undefined) {
throw Object.assign(
new Error(
'Tab color, pin, and view mode edits are unavailable while a desktop window owns the layout'
),
{ code: 'capability_unsupported' }
)
}
if (customTitle !== undefined) {
this.notifier?.renameTerminal(hostTabId, customTitle)
}
return { updated: true }
}
this.persistHeadlessSessionTabProps(worktreeId, hostTabId, args)
// Why: a live pane's own title outranks the snapshot's, so name it the way terminal.rename does.
const liveHandles =
customTitle === undefined
? []
: this.getPtyIdsForExplicitTabClose(worktreeId, hostTabId).flatMap((ptyId) => {
const handle = this.handleByPtyId.get(ptyId)
return handle ? [handle] : []
})
for (const handle of liveHandles) {
await this.renameTerminal(handle, customTitle ?? null)
}
if (customTitle !== undefined && liveHandles.length === 0) {
this.persistHeadlessTerminalTitle(worktreeId, hostTabId, customTitle)
}
// A live pane's rename above already set its title; only a paneless tab needs `title` here.
this.applyHeadlessSessionTabPropsToSnapshot(worktreeId, hostTabId, {
...args,
...(customTitle !== undefined && liveHandles.length === 0
? {
customTitle,
title: customTitle ?? this.headlessTerminalFallbackTitle(worktreeId, hostTabId)
}
: {})
})
return { updated: true }
}
}