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) => ({