fix(terminal): keep a named terminal's title across an orca serve restart (#28126)

* fix(terminal): keep a host-made terminal's title across a serve restart

A title given to terminal.create was kept only on the live process record, so
after an orca serve restart the tab read "Terminal N" and terminal.list
listed no title. The runtime now writes it as the tab's saved name when it
admits the pane, and terminal.list falls back to that saved name for a
terminal that has no title of its own yet, as a window does.

* test(e2e): record the kept terminal titles in the layout-compat baselines

* fix(terminal): save a created terminal's name only when the window would show it

A background create (a worker the window never reveals) keeps its live title:
a window reads a saved name on hydration and shows it over the agent's own
title. Also corrects two comments and drops a key count.
This commit is contained in:
Jinwoo Hong
2026-10-12 22:25:55 -04:00
committed by GitHub
parent 09ae6d225c
commit 0e4207400b
6 changed files with 117 additions and 37 deletions
@@ -25,9 +25,12 @@ export class OrcaRuntimeWithBuildPtyTerminalSummary extends OrcaRuntimeWithGetPt
): RuntimeTerminalSummary {
const worktree = worktreesById.get(pty.worktreeId)
const title = getLatestPtyTitle(this.getPtyDisplayRecord(pty))
const pane = parsePaneKey(pty.paneKey ?? '')
const orphaned = !ptyHoldsRecordedSurface(pty, this.ptySurfaceTopology())
// A restarted host's terminal has no title yet; until it has one, its tab's saved name stands in.
const title =
getLatestPtyTitle(this.getPtyDisplayRecord(pty)) ??
(orphaned ? null : this.getSavedTerminalTabCustomTitle(pty.worktreeId, pty.tabId))
// A live process awaiting its pane binding is not evidence of an orphan.
if (
orphaned &&
@@ -65,6 +68,16 @@ export class OrcaRuntimeWithBuildPtyTerminalSummary extends OrcaRuntimeWithGetPt
}
}
protected getSavedTerminalTabCustomTitle(
worktreeId: string,
tabId: string | null
): string | null {
const tab = this.getWorkspaceSessionForWorktree(worktreeId)?.tabsByWorktree[worktreeId]?.find(
(candidate) => candidate.id === tabId
)
return tab?.customTitle?.trim() || null
}
protected getLiveLeafForHandle(handle: string): {
record: TerminalHandleRecord
leaf: RuntimeLeafRecord
@@ -0,0 +1,71 @@
import './orca-runtime-test-lifecycle.spec'
import type { RuntimeStore } from './runtime-store-contract'
import { describe, expect, it, vi } from 'vitest'
import { getDefaultWorkspaceSession } from '../../shared/constants'
import { OrcaRuntimeService } from './orca-runtime-test-mocks.spec'
import {
TEST_REPO_ID,
TEST_WORKTREE_ID,
makeRuntimeStoreWithWorkspaceSession
} from './orca-runtime-test-fixtures.spec'
function headlessRuntime() {
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession({
...getDefaultWorkspaceSession(),
activeRepoId: TEST_REPO_ID,
activeWorktreeId: TEST_WORKTREE_ID
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The shared fixture supplies RuntimeStore methods; its legacy Mock return type loses callable signatures.
const runtime = new OrcaRuntimeService(runtimeStore as RuntimeStore)
runtime.setPtyController({
spawn: vi.fn(async () => ({ id: 'titled-pty' })),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
return { runtime, getSession }
}
describe('a titled terminal on a host with no window', () => {
it('saves the title as the tab name', async () => {
const { runtime, getSession } = headlessRuntime()
const created = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
title: 'cli-created'
})
const saved = getSession().tabsByWorktree[TEST_WORKTREE_ID]
expect(saved?.find((tab) => tab.id === created.tabId)?.customTitle).toBe('cli-created')
})
// A window reads a saved name on hydration and shows it over the agent's live title, so a tab
// the window never revealed (a background worker) must not get one.
it('saves no name for a background terminal', async () => {
const { runtime, getSession } = headlessRuntime()
const created = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
title: 'worker-1',
presentation: 'background'
})
const saved = getSession().tabsByWorktree[TEST_WORKTREE_ID]
expect(saved?.find((tab) => tab.id === created.tabId)?.customTitle ?? null).toBeNull()
})
it('lists the saved name once the terminal itself has no title, as after a restart', async () => {
const { runtime } = headlessRuntime()
const created = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
title: 'cli-created'
})
// A restarted host rebuilds the terminal's record from the process list, without a title.
const pty = runtime['ptysById'].get('titled-pty')!
pty.title = null
pty.titleUpdatedAt = null
const listed = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)
expect(listed.terminals.find((terminal) => terminal.handle === created.handle)?.title).toBe(
'cli-created'
)
})
})
+3 -2
View File
@@ -26,14 +26,15 @@ function admissionFor(args: AdmittedSpawnArgs): TerminalPaneAdmission {
newLeafId: leafId
}
}
const viewMode = placement?.kind === 'new-tab' ? placement.row?.viewMode : undefined
const row = placement?.kind === 'new-tab' ? placement.row : undefined
return {
type: 'createTerminalTab',
workspace,
tabId,
leafId,
...(args.groupId ? { groupId: args.groupId } : {}),
...(viewMode ? { viewMode } : {}),
...(row?.viewMode ? { viewMode: row.viewMode } : {}),
...(row?.customTitle ? { title: row.customTitle } : {}),
...(args.cwd ? { creation: { startupCwd: args.cwd } } : {})
}
}
@@ -1,19 +1,29 @@
import type { TerminalPanePlacement } from '../../shared/terminal-pane-placement'
import type { TerminalPaneSplitDirection } from '../../shared/terminal-tab-types'
import type { RuntimeTerminalPresentation } from '../../shared/runtime-terminal-contracts'
/** A main-side create always opens its own tab. */
export function runtimeNewTabPlacement(viewMode?: 'terminal' | 'chat'): TerminalPanePlacement {
return viewMode ? { kind: 'new-tab', row: { viewMode } } : { kind: 'new-tab' }
}
/** A runtime-started tab opens in the group asked for, else the first group (as the live view). */
export function newTabIn(opts: { viewMode?: 'terminal' | 'chat'; targetGroupId?: string }): {
/**
* A runtime-started tab opens in the group asked for, else the first group (as the live view). Its
* title is the caller's name for it when the window would show it too: a revealed tab keeps the
* name, a background tab (a worker the window never revealed) keeps its live title.
*/
export function newTabIn(opts: {
viewMode?: 'terminal' | 'chat'
targetGroupId?: string
title?: string
presentation?: RuntimeTerminalPresentation
}): {
placement: TerminalPanePlacement
groupId?: string
} {
const { targetGroupId } = opts
const { viewMode, targetGroupId } = opts
const title = opts.presentation === 'background' ? undefined : opts.title
const row = {
...(viewMode ? { viewMode } : {}),
...(title ? { customTitle: title } : {})
}
return {
placement: runtimeNewTabPlacement(opts.viewMode),
placement: viewMode || title ? { kind: 'new-tab', row } : { kind: 'new-tab' },
...(targetGroupId ? { groupId: targetGroupId } : {})
}
}
+10 -10
View File
@@ -414,7 +414,7 @@
{
"tab": "tab1",
"kind": "terminal",
"customTitle": null,
"customTitle": "host-made",
"tree": "leaf1",
"panes": [
{
@@ -516,7 +516,7 @@
{
"tab": "tab1",
"kind": "terminal",
"customTitle": null,
"customTitle": "host-made",
"tree": "leaf1",
"panes": [
{
@@ -602,7 +602,7 @@
{
"tab": "tab1",
"kind": "terminal",
"customTitle": null,
"customTitle": "host-made",
"tree": "leaf1",
"panes": [
{
@@ -644,7 +644,7 @@
"tab": "tab1",
"leaf": "leaf1",
"pty": "pty@@1",
"title": "Terminal 1",
"title": "host-made",
"tree": "leaf1",
"paneTitles": []
},
@@ -664,7 +664,7 @@
"tab": "tab1",
"leaf": "leaf1",
"pty": "pty@@1",
"title": null
"title": "host-made"
},
{
"tab": "tab2",
@@ -679,7 +679,7 @@
{
"tab": "tab1",
"kind": "terminal",
"customTitle": null,
"customTitle": "host-made",
"tree": "leaf1",
"panes": [
{
@@ -1408,7 +1408,7 @@
{
"tab": "tab2",
"kind": "terminal",
"customTitle": null,
"customTitle": "cli-created",
"tree": "leaf2",
"panes": [
{
@@ -1458,7 +1458,7 @@
"tab": "tab2",
"leaf": "leaf2",
"pty": "pty@@1",
"title": "Terminal <position>",
"title": "cli-created",
"tree": "leaf2",
"paneTitles": []
},
@@ -1478,7 +1478,7 @@
"tab": "tab2",
"leaf": "leaf2",
"pty": "pty@@1",
"title": null
"title": "cli-created"
},
{
"tab": "tab1",
@@ -1511,7 +1511,7 @@
{
"tab": "tab2",
"kind": "terminal",
"customTitle": null,
"customTitle": "cli-created",
"tree": "leaf2",
"panes": [
{
@@ -60,21 +60,6 @@ export const LAYOUT_ORACLE_KNOWN_ON_MAIN: readonly KnownOnMain[] = [
detail: /^group id differs: runtime \S+, client headless-terminals:\S+ \(tabs /,
cause: 'two group ids'
},
// A renamed terminal's title lists as null in terminal.list after a serve restart (session tabs
// keep it): orcad everywhere, Electron serve on Linux.
{
scenario: 'headless-orcad-rename-terminal',
check: 'expected',
step: 'rename after restart',
cause: 'title after restart'
},
{
scenario: 'headless-electron-rename-terminal',
check: 'expected',
step: 'rename after restart',
platform: 'linux',
cause: 'title after restart'
},
// After a headless move into an existing group, paired clients still see that group's previous
// tab selected, not the moved one. A split into a new group selects the moved tab.
...(['orcad', 'electron'] as const).map((kind) => ({