From 0e4207400b3ace4844868049d95cbf674c56bc69 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:25:55 -0400 Subject: [PATCH] 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. --- ...orca-runtime-build-pty-terminal-summary.ts | 15 +++- ...untime-terminal-title-saved-layout.test.ts | 71 +++++++++++++++++++ src/main/runtime/runtime-pane-admission.ts | 5 +- .../runtime-terminal-spawn-placement.ts | 28 +++++--- tests/e2e/layout-compat-baselines.json | 20 +++--- .../workspace-layout-oracle-known-on-main.ts | 15 ---- 6 files changed, 117 insertions(+), 37 deletions(-) create mode 100644 src/main/runtime/orca-runtime-terminal-title-saved-layout.test.ts diff --git a/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts b/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts index 5158625a7a2..937c0d62063 100644 --- a/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts +++ b/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts @@ -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 diff --git a/src/main/runtime/orca-runtime-terminal-title-saved-layout.test.ts b/src/main/runtime/orca-runtime-terminal-title-saved-layout.test.ts new file mode 100644 index 00000000000..546a3d3d4e2 --- /dev/null +++ b/src/main/runtime/orca-runtime-terminal-title-saved-layout.test.ts @@ -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' + ) + }) +}) diff --git a/src/main/runtime/runtime-pane-admission.ts b/src/main/runtime/runtime-pane-admission.ts index 16ca7e22405..0dda455757b 100644 --- a/src/main/runtime/runtime-pane-admission.ts +++ b/src/main/runtime/runtime-pane-admission.ts @@ -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 } } : {}) } } diff --git a/src/main/runtime/runtime-terminal-spawn-placement.ts b/src/main/runtime/runtime-terminal-spawn-placement.ts index 3141cdf813b..bfdcf75753f 100644 --- a/src/main/runtime/runtime-terminal-spawn-placement.ts +++ b/src/main/runtime/runtime-terminal-spawn-placement.ts @@ -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 } : {}) } } diff --git a/tests/e2e/layout-compat-baselines.json b/tests/e2e/layout-compat-baselines.json index 8cfba39c480..1f4f3bfdbfb 100644 --- a/tests/e2e/layout-compat-baselines.json +++ b/tests/e2e/layout-compat-baselines.json @@ -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 ", + "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": [ { diff --git a/tests/e2e/workspace-layout-oracle-known-on-main.ts b/tests/e2e/workspace-layout-oracle-known-on-main.ts index 5e3bda39a82..df3dd4280ec 100644 --- a/tests/e2e/workspace-layout-oracle-known-on-main.ts +++ b/tests/e2e/workspace-layout-oracle-known-on-main.ts @@ -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) => ({