mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
Reference in New Issue
Block a user