Terminal tabs show the agent session's name instead of the first-prompt title (#38518)

* fix(tabs): show the agent session's name on terminal tabs

The tab strip rebuilt each terminal tab without its session title and then re-ranked it, so the first-prompt title won over the session's own name. Carry the session title through the projection, and give the tab strip, unified tab label and sidebar agent row one shared name order so they cannot drift.

Co-authored-by: AJIADb9 <ajiadb9@bgd.team>

* fix(tabs): paired desktops take the host's session name for mirrored terminal tabs

The host projection now forwards the window's session title to paired clients, and the client's rebuild adopts it (older hosts omit it, so the client keeps its own). Without it a mirrored tab re-ranked to the client's first-prompt title.

* fix(tabs): one writer per session for a mirrored tab's session name

The paired client's own title sync keeps a session it already tracks current; the host's name only fills a session the client doesn't know, so a stale host snapshot can't revert a rename the client already read.

* fix(tabs): a paired host is the only writer of a session name it publishes

* fix(tabs): only a host window that resolves session names speaks for them

* fix(tabs): derive session title authority from live publishers

* fix(tabs): keep the session title publisher check concrete for runtime subclasses

* fix(tabs): one publication when a closing window drops browsers and title authority

* test(tabs): follow main's runtime test helper layout

* fix(tabs): paired desktops keep a quick-command name ahead of the session name

* fix(tabs): a name the host cleared doesn't come back as the idle pane's title

* test(tabs): build the idle-pane fixture as its own surface variant

* fix(tabs): point the moved session-title projection at its new shared path

---------

Co-authored-by: AJIADb9 <ajiadb9@bgd.team>
This commit is contained in:
Brennan Benson
2026-11-05 11:04:58 -08:00
committed by GitHub
co-authored by AJIADb9
parent 799b60b074
commit 239a70040c
31 changed files with 741 additions and 89 deletions
+25
View File
@@ -6,6 +6,9 @@ importers:
.:
configDependencies: {}
packageManagerDependencies:
'@pnpm/exe':
specifier: 12.14.0
version: 12.14.0
pnpm:
specifier: 12.14.0
version: 12.14.0
@@ -89,6 +92,11 @@ packages:
cpu: [x64]
os: [win32]
'@pnpm/exe@12.14.0':
resolution: {integrity: sha512-iZCbiKegYUapNVkZY4O8vjXt9EF/j34LbDvew6qUM5g/HrxMY38cFJaoE8+5ZWQfbt2YCsLfufI5TTh+GxUbWw==}
engines: {node: '>=18.*'}
hasBin: true
pnpm@12.14.0:
resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==}
engines: {node: '>=18.*'}
@@ -138,6 +146,23 @@ snapshots:
'@pnpm/exe.win32-x64@12.14.0':
optional: true
'@pnpm/exe@12.14.0':
optionalDependencies:
'@pnpm/exe.android-arm64': 12.14.0
'@pnpm/exe.android-x64': 12.14.0
'@pnpm/exe.darwin-arm64': 12.14.0
'@pnpm/exe.darwin-x64': 12.14.0
'@pnpm/exe.freebsd-x64': 12.14.0
'@pnpm/exe.linux-arm64': 12.14.0
'@pnpm/exe.linux-arm64-musl': 12.14.0
'@pnpm/exe.linux-ppc64': 12.14.0
'@pnpm/exe.linux-riscv64': 12.14.0
'@pnpm/exe.linux-s390x': 12.14.0
'@pnpm/exe.linux-x64': 12.14.0
'@pnpm/exe.linux-x64-musl': 12.14.0
'@pnpm/exe.win32-arm64': 12.14.0
'@pnpm/exe.win32-x64': 12.14.0
pnpm@12.14.0:
optionalDependencies:
'@pnpm/exe.android-arm64': 12.14.0
@@ -1,4 +1,5 @@
import type { TerminalIdentitySource } from '../terminal/identity/terminal-identity-source'
import type { SessionTitlePublisher } from '../session-tabs/session-title-publisher'
import { randomUUID } from 'node:crypto'
import { reconcileChatTabIdentities } from '../agent-session/agent-session-tab-identity'
import { preserveTerminalRetirementProofs } from '../terminal/surface/mobile-session-terminal-retirement-proof'
@@ -177,6 +178,7 @@ export abstract class OrcaRuntimeWithRuntimeId extends RuntimeConstructionServic
rendererVersion: number
rendererTabCount: number
rendererTabIdentityKeys: ReadonlySet<string>
titlePublisher?: SessionTitlePublisher
}
>()
@@ -17,8 +17,15 @@ import { ActiveGroupViewFacts } from '../view-facts/active-group-view-facts'
import type { ExecutionHostId } from '../../../shared/execution-host'
import type { WorkspaceSessionState } from '../../../shared/workspace-session/workspace-session-state-types'
import type { RuntimePtyWorktreeRecord } from '../terminal/records/runtime-terminal-state-records'
import type { SessionTitleAuthority } from '../session-tabs/session-title-publisher'
export abstract class OrcaRuntimeWithAttachWindow extends OrcaRuntimeWithNotifySshStateChanged {
protected abstract captureSessionTitleAuthority(): SessionTitleAuthority
protected abstract republishSessionTitleAuthorityChanges(
before: SessionTitleAuthority,
alreadyChanged?: ReadonlySet<string>
): void
protected abstract agentSenderGraphReply(
...args: Parameters<ScheduleMobileSessionTabsChangedMethods['agentSenderGraphReply']>
): ReturnType<ScheduleMobileSessionTabsChangedMethods['agentSenderGraphReply']>
@@ -1,12 +1,12 @@
import type { OrcaRuntimeWithTransitionGraphReloadToTerminalState as TransitionGraphReloadToTerminalStateMethods } from './session-graph-authority'
import type { OrcaRuntimeWithBindPtyIncarnationHandle as BindPtyIncarnationHandleMethods } from '../terminal/identity/terminal-handle-bindings'
import type { OrcaRuntimeWithResolveWaiter as ResolveWaiterMethods } from '../orca-runtime-resolve-waiter'
import { OrcaRuntimeWithStopTerminalsForProject } from '../terminal/stop/project-terminal-stop'
import { OrcaRuntimeWithSessionTitleAuthority } from '../session-tabs/session-title-publisher'
import type { RuntimeRendererReloadFence } from './renderer-reload-fence'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
import { HEADLESS_RUNTIME_WINDOW_ID } from '../../../shared/runtime-types'
export abstract class OrcaRuntimeWithHasTerminalsForWorktree extends OrcaRuntimeWithStopTerminalsForProject {
export abstract class OrcaRuntimeWithHasTerminalsForWorktree extends OrcaRuntimeWithSessionTitleAuthority {
protected abstract assertStableReadyGraph(
...args: Parameters<TransitionGraphReloadToTerminalStateMethods['assertStableReadyGraph']>
): ReturnType<TransitionGraphReloadToTerminalStateMethods['assertStableReadyGraph']>
@@ -96,6 +96,7 @@ export abstract class OrcaRuntimeWithHasTerminalsForWorktree extends OrcaRuntime
}
protected beginGraphReload(windowId: number): number {
const titleAuthority = this.captureSessionTitleAuthority()
// Compare incarnations against the rebuilt graph before staling proven process identities.
this.terminalIdentity.advanceRendererEpoch()
this.graphStatus = 'reloading'
@@ -112,6 +113,7 @@ export abstract class OrcaRuntimeWithHasTerminalsForWorktree extends OrcaRuntime
this.terminalIdentity.clearLeafBindings()
// Preserve CLI handles and incarnation waiters across reload; graph adoption may rejoin the same PTY.
this.refreshWritableFlags()
this.republishSessionTitleAuthorityChanges(titleAuthority)
return revision
}
@@ -128,6 +130,7 @@ export abstract class OrcaRuntimeWithHasTerminalsForWorktree extends OrcaRuntime
return false
}
if (fence.recovery === 'renderer') {
const titleAuthority = this.captureSessionTitleAuthority()
const restoresPublishedInventory =
this.sessionTabsInventoryPublicationEpoch === this.rendererGraphEpoch - 1
this.graphStatus = 'ready'
@@ -140,6 +143,7 @@ export abstract class OrcaRuntimeWithHasTerminalsForWorktree extends OrcaRuntime
if (restoresPublishedInventory) {
this.markSessionTabsInventoryPublished()
}
this.republishSessionTitleAuthorityChanges(titleAuthority)
return true
}
this.graphReloadLifecycle.begin(windowId)
@@ -175,6 +179,7 @@ export abstract class OrcaRuntimeWithHasTerminalsForWorktree extends OrcaRuntime
}
markGraphUnavailable(windowId: number): void {
const titleAuthority = this.captureSessionTitleAuthority()
if (
this.authoritativeWindowId === HEADLESS_RUNTIME_WINDOW_ID &&
windowId === this.pendingHeadlessPromotionWindowId
@@ -193,7 +198,7 @@ export abstract class OrcaRuntimeWithHasTerminalsForWorktree extends OrcaRuntime
if (this.shouldRestoreHeadlessGraph(windowId)) {
this.pendingHeadlessPromotionWindowId = null
this.restoreHeadlessGraphAuthority()
this.republishBrowserSessionTabs()
this.republishSessionTitleAuthorityChanges(titleAuthority, this.republishBrowserSessionTabs())
return
}
// Without an authoritative graph, live-terminal operations must fail closed.
@@ -217,16 +222,19 @@ export abstract class OrcaRuntimeWithHasTerminalsForWorktree extends OrcaRuntime
this.clearPtyIncarnationHandles()
// Keep pre-allocated CLI handles for reconnect adoption.
this.rejectAllWaiters('terminal_handle_stale')
this.republishBrowserSessionTabs()
this.republishSessionTitleAuthorityChanges(titleAuthority, this.republishBrowserSessionTabs())
}
// Renderer browser rows are listed only while their window exists; a version bump lets every client apply the drop.
private republishBrowserSessionTabs(): void {
private republishBrowserSessionTabs(): Set<string> {
const republished = new Set<string>()
for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) {
if (snapshot.tabs.some((tab) => tab.type === 'browser')) {
this.republishMobileSessionTabsSnapshot(worktreeId)
republished.add(worktreeId)
}
}
return republished
}
protected handleGraphReloadTimeout(windowId: number): void {
@@ -1,4 +1,4 @@
import { OrcaRuntimeWithAttachWindow } from './session-graph-promotion'
import { OrcaRuntimeWithAdmitWindowPublisher } from './session-window-publisher'
import type {
RuntimeRendererSyncWindowGraph,
RuntimeSyncedTab,
@@ -23,7 +23,7 @@ function assertUniqueRuntimeGraphTabIds(tabs: readonly RuntimeSyncedTab[]): void
}
}
export abstract class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAttachWindow {
export abstract class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAdmitWindowPublisher {
/** Layout-owner builds: main's panes in place of the window's (session-tabs-inventory.ts). */
protected abstract withMainSurfaces(windowId: number, graph: Graph): Graph
declare protected terminalGroupGridSizes?: RuntimeSyncWindowGraph['terminalGroupGridSizes']
@@ -52,36 +52,9 @@ export abstract class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAtta
): RuntimeSyncWindowGraphResult {
// Reject duplicate bare tab ids before mutating authority/state or indexes could route PTYs to the wrong worktree.
assertUniqueRuntimeGraphTabIds(graph.tabs)
if (
windowId !== HEADLESS_RUNTIME_WINDOW_ID &&
this.authoritativeWindowId === HEADLESS_RUNTIME_WINDOW_ID &&
this.headlessGraphFallbackAvailable
) {
if (windowId !== this.pendingHeadlessPromotionWindowId) {
throw new Error('Runtime graph publisher does not match the pending desktop promotion')
}
// Accept late healthy graphs after failed promotion restores headless authority.
this.attachWindow(windowId)
}
if (this.authoritativeWindowId === null) {
this.authoritativeWindowId = windowId
}
if (windowId !== this.authoritativeWindowId) {
throw new Error('Runtime graph publisher does not match the authoritative window')
}
const rendererGeneration =
windowId === HEADLESS_RUNTIME_WINDOW_ID
? null
: 'rendererGeneration' in graph && typeof graph.rendererGeneration === 'string'
? graph.rendererGeneration
: undefined
if (
typeof rendererGeneration === 'string' &&
rendererGeneration === this.rendererGeneration &&
this.graphStatus !== 'ready'
) {
throw new Error('Runtime graph publisher belongs to a superseded renderer generation')
}
const rendererGeneration = this.admitWindowPublisher(windowId, graph)
const previousTitleAuthority = this.captureSessionTitleAuthority()
const publishedMobileSessionTabs = graph.mobileSessionTabs
if (windowId === HEADLESS_RUNTIME_WINDOW_ID) {
this.headlessGraphFallbackAvailable = true
this.servesHeadlessGraph = true
@@ -110,7 +83,8 @@ export abstract class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAtta
graph.mobileSessionTabs,
graph.unchangedMobileSessionWorktrees,
mobileSessionResyncWorktrees,
rendererGeneration
rendererGeneration,
publishedMobileSessionTabs
)
const nextLeaves = new Map<string, RuntimeLeafRecord>()
const graphSyncedAt = this.nextTitleObservationSequence()
@@ -278,11 +252,6 @@ export abstract class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAtta
})
changedMobileWorktrees.add(worktreeId)
}
for (const worktreeId of changedMobileWorktrees) {
if (this.mobileSessionTabsByWorktree.has(worktreeId)) {
this.scheduleMobileSessionTabsChanged(worktreeId)
}
}
const isAuthoritativeGraphPublisher = windowId === this.authoritativeWindowId
this.markGraphReady(windowId)
if (
@@ -298,6 +267,12 @@ export abstract class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAtta
if (rendererGeneration !== undefined) {
this.rendererGeneration = rendererGeneration
}
this.republishSessionTitleAuthorityChanges(previousTitleAuthority, changedMobileWorktrees)
for (const worktreeId of changedMobileWorktrees) {
if (this.mobileSessionTabsByWorktree.has(worktreeId)) {
this.scheduleMobileSessionTabsChanged(worktreeId)
}
}
for (const leaf of this.leaves.values()) {
this.adoptPreAllocatedHandle(leaf)
const previousLeaf = previousLeaves.get(this.getLeafKey(leaf.tabId, leaf.leafId))
@@ -0,0 +1,45 @@
import { OrcaRuntimeWithAttachWindow } from './session-graph-promotion'
import type {
RuntimeRendererSyncWindowGraph,
RuntimeSyncWindowGraph
} from '../../../shared/runtime-session-contracts'
import { HEADLESS_RUNTIME_WINDOW_ID } from '../../../shared/runtime-types'
export abstract class OrcaRuntimeWithAdmitWindowPublisher extends OrcaRuntimeWithAttachWindow {
protected admitWindowPublisher(
windowId: number,
graph: RuntimeSyncWindowGraph | RuntimeRendererSyncWindowGraph
): string | null | undefined {
if (
windowId !== HEADLESS_RUNTIME_WINDOW_ID &&
this.authoritativeWindowId === HEADLESS_RUNTIME_WINDOW_ID &&
this.headlessGraphFallbackAvailable
) {
if (windowId !== this.pendingHeadlessPromotionWindowId) {
throw new Error('Runtime graph publisher does not match the pending desktop promotion')
}
// Accept late healthy graphs after failed promotion restores headless authority.
this.attachWindow(windowId)
}
if (this.authoritativeWindowId === null) {
this.authoritativeWindowId = windowId
}
if (windowId !== this.authoritativeWindowId) {
throw new Error('Runtime graph publisher does not match the authoritative window')
}
const rendererGeneration =
windowId === HEADLESS_RUNTIME_WINDOW_ID
? null
: 'rendererGeneration' in graph && typeof graph.rendererGeneration === 'string'
? graph.rendererGeneration
: undefined
if (
typeof rendererGeneration === 'string' &&
rendererGeneration === this.rendererGeneration &&
this.graphStatus !== 'ready'
) {
throw new Error('Runtime graph publisher belongs to a superseded renderer generation')
}
return rendererGeneration
}
}
@@ -132,6 +132,8 @@ export abstract class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends Or
leaves: this.leaves,
ptysById: this.ptysById,
getPtyAvailability: (ptyId) => this.ptyController?.getPtyAvailability?.(ptyId) ?? null,
isSessionTitlePublisher: (worktreeId, surfaceId) =>
this.isSessionTitlePublisher(worktreeId, surfaceId),
getLiveBrowserTabs: (worktreeId) => this.getListedBrowserTabsByPageId(worktreeId),
isRendererOwnedMobileBrowserTab: (...args) => this.isRendererOwnedMobileBrowserTab(...args),
getProviderSessionRows: (paneKey) => this.getAgentProviderSessionRowsForPaneFn?.(paneKey),
@@ -146,6 +146,7 @@ describe('mobile completion status projection', () => {
]),
ptysById: new Map([[pty.ptyId, pty]]),
getPtyAvailability: () => null,
isSessionTitlePublisher: () => false,
getLiveBrowserTabs: () => new Map(),
getProviderSessionRows: () => [],
getProviderSessionSnapshot: () => [],
@@ -19,6 +19,7 @@ it.each(['pty', 'leaf', 'unknown', 'disconnected'] as const)(
leaves: new Map(kind === 'leaf' ? [['leaf', { ptyId: pty.ptyId, connected: true }]] : []),
ptysById: new Map([[pty.ptyId, pty]]),
getPtyAvailability: () => null,
isSessionTitlePublisher: () => false,
getLiveBrowserTabs: () => new Map(),
getProviderSessionRows: () => [],
getProviderSessionSnapshot: () => [],
@@ -24,6 +24,7 @@ export type RuntimeMobileSessionProjectionHost = {
leaves: ReadonlyMap<string, RuntimeLeafRecord>
ptysById: ReadonlyMap<string, RuntimePtyWorktreeRecord>
getPtyAvailability(ptyId: string): boolean | null
isSessionTitlePublisher(worktreeId: string, surfaceId: string): boolean
getLiveBrowserTabs(worktreeId: string): Map<string, BrowserTabInfo>
isRendererOwnedMobileBrowserTab(
snapshot: RuntimeMobileSessionTabsSnapshot,
@@ -29,6 +29,7 @@ import {
terminalTitleBlocksExplicitAgentStatus
} from '../../workspaces/process-listing/runtime-worktree-status-projection'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import { projectRuntimeMobileSessionTitle } from './runtime-mobile-session-title-projection'
export function projectRuntimeMobileSessionTabs(
snapshot: RuntimeMobileSessionTabsSnapshot,
@@ -290,6 +291,8 @@ export function projectRuntimeMobileSessionTabs(
leafId: tab.leafId,
title,
...(tab.customTitle !== undefined ? { customTitle: tab.customTitle } : {}),
// A paired desktop re-ranks the title with its own generated name; the session name must reach it.
...projectRuntimeMobileSessionTitle(snapshot, tab, host),
...(tab.ptyId ? { ptyId: tab.ptyId } : {}),
// Bind identity to the handle's live owner, never a stale persisted surface.
...(terminalPty?.incarnationId ? { incarnationId: terminalPty.incarnationId } : {}),
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import type { RuntimeMobileSessionTerminalTab } from '../../../../shared/runtime-types'
import { projectRuntimeMobileSessionTitle } from './runtime-mobile-session-title-projection'
import { resolveTerminalTabTitle } from '../../../../shared/tab-title-resolution'
const tab: RuntimeMobileSessionTerminalTab = {
type: 'terminal',
id: 'tab-1::leaf-1',
parentTabId: 'tab-1',
leafId: 'leaf-1',
title: 'Codex 公司 High',
quickCommandLabel: 'Codex 公司 High',
aiVaultTitle: { agent: 'codex', sessionId: 'session-1', title: 'Login cleanup' },
resolvesSessionTitle: true,
isActive: true
}
describe('paired desktop tab names', () => {
it('sends a quick-command name with the session name so the paired desktop keeps the same order', () => {
const projected = projectRuntimeMobileSessionTitle({ worktree: 'wt-1' }, tab, {
isSessionTitlePublisher: () => true
})
expect(
resolveTerminalTabTitle({ title: tab.title, customTitle: null, ...projected }, true)
).toBe('Codex 公司 High')
})
})
@@ -0,0 +1,23 @@
import type {
RuntimeMobileSessionTabsSnapshot,
RuntimeMobileSessionTerminalTab
} from '../../../../shared/runtime-types'
import type { RuntimeMobileSessionProjectionHost } from './runtime-mobile-session-projection-contract'
export function projectRuntimeMobileSessionTitle(
snapshot: Pick<RuntimeMobileSessionTabsSnapshot, 'worktree'>,
tab: RuntimeMobileSessionTerminalTab,
host: Pick<RuntimeMobileSessionProjectionHost, 'isSessionTitlePublisher'>
) {
// A paired desktop re-ranks these itself, so the quick-command name must travel with the session name.
const quickCommandLabel = tab.quickCommandLabel?.trim()
return {
...(quickCommandLabel ? { quickCommandLabel } : {}),
// Retained names are caches; only their current publisher may authoritatively clear them.
...(host.isSessionTitlePublisher(snapshot.worktree, tab.id)
? { aiVaultTitle: tab.aiVaultTitle ?? null, resolvesSessionTitle: true as const }
: tab.aiVaultTitle
? { aiVaultTitle: tab.aiVaultTitle }
: {})
}
}
@@ -68,6 +68,10 @@ function withWindowFacts(row: Row, windowRow: Row | undefined, rules: MergeRules
...(windowRow.agentStatus !== undefined ? { agentStatus: windowRow.agentStatus } : {}),
...(keepsWindowPtyId(row.ptyId, windowRow.ptyId, rules) ? { ptyId: windowRow.ptyId } : {}),
...(windowRow.launchDraft !== undefined ? { launchDraft: windowRow.launchDraft } : {}),
// Main's row holds the saved name; only the window resolves it.
...(windowRow.resolvesSessionTitle
? { aiVaultTitle: windowRow.aiVaultTitle ?? null, resolvesSessionTitle: true as const }
: {}),
...(windowRow.launchDraftCreatedAt !== undefined
? { launchDraftCreatedAt: windowRow.launchDraftCreatedAt }
: {})
@@ -4,6 +4,7 @@ import { OrcaRuntimeWithWriteOrchestrationPointerPty } from '../orchestration/te
import type { RuntimeMobileSessionTabsSnapshot } from '../../../shared/runtime-types'
import type { WorkspaceSessionState } from '../../../shared/workspace-session/workspace-session-state-types'
import { getMobileSessionSnapshotTabIdentityKeys } from '../session-lifecycle/projection/mobile-session-tab-merge'
import { HEADLESS_RUNTIME_WINDOW_ID } from '../../../shared/runtime-types'
export abstract class OrcaRuntimeWithSyncMobileSessionTabs extends OrcaRuntimeWithWriteOrchestrationPointerPty {
protected abstract buildPreservedHeadlessMobileSessionSnapshot(
@@ -44,7 +45,8 @@ export abstract class OrcaRuntimeWithSyncMobileSessionTabs extends OrcaRuntimeWi
snapshots: RuntimeMobileSessionTabsSnapshot[] | undefined,
unchangedWorktreeIds?: string[],
resyncWorktreeIds = new Set<string>(),
rendererGeneration?: string | null
rendererGeneration?: string | null,
publishedSnapshots?: RuntimeMobileSessionTabsSnapshot[]
): Set<string> {
const changedWorktreeIds = new Set<string>()
if (snapshots === undefined) {
@@ -185,7 +187,25 @@ export abstract class OrcaRuntimeWithSyncMobileSessionTabs extends OrcaRuntimeWi
rendererTabCount: fencedSnapshot.tabs.length,
rendererTabIdentityKeys: new Set(
fencedSnapshot.tabs.flatMap((tab) => getMobileSessionSnapshotTabIdentityKeys(tab))
)
),
...(this.authoritativeWindowId !== null &&
this.authoritativeWindowId !== HEADLESS_RUNTIME_WINDOW_ID &&
typeof rendererGeneration === 'string'
? {
titlePublisher: {
windowId: this.authoritativeWindowId,
rendererGeneration,
// Main's merge can add rows the window never published or resolved.
surfaceIds: new Set(
publishedSnapshots
?.find((published) => published.worktree === snapshot.worktree)
?.tabs.flatMap((tab) =>
tab.type === 'terminal' && tab.resolvesSessionTitle ? [tab.id] : []
) ?? []
)
}
}
: {})
})
}
for (const [worktreeId, existing] of Array.from(this.mobileSessionTabsByWorktree)) {
@@ -0,0 +1,167 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
RuntimeMobileSessionTabsResult,
RuntimeMobileSessionTerminalTab
} from '../../../shared/runtime-types'
const layoutOwner = vi.hoisted(() => ({ isMain: false }))
vi.mock('../../../shared/layout-owner-switch', () => ({
get ORCA_LAYOUT_OWNER_IS_MAIN() {
return layoutOwner.isMain
}
}))
// Preserve side-effect order: mocks, lifecycle, fixtures.
await import('../orca-runtime-tests/runtime-test-module-registration.spec')
const { electronMocks } = await import('../orca-runtime-test-mocks/setup.spec')
const { OrcaRuntimeService } = await import('../orca-runtime')
await import('../orca-runtime-tests/orca-runtime-test-lifecycle.spec')
const {
HEADLESS_LEAF_ID,
TEST_WORKTREE_ID,
makeHeadlessTerminalLayout,
makeRuntimeStoreWithWorkspaceSession,
makeWorkspaceSessionWithHeadlessTerminal
} = await import('../orca-runtime-tests/orca-runtime-test-fixtures.spec')
const name = { agent: 'codex' as const, sessionId: 'session-1', title: 'Saved task' }
const surface: RuntimeMobileSessionTerminalTab = {
type: 'terminal',
id: `host-tab::${HEADLESS_LEAF_ID}`,
parentTabId: 'host-tab',
leafId: HEADLESS_LEAF_ID,
ptyId: 'serve-retained-pty',
title: 'Codex',
aiVaultTitle: name,
resolvesSessionTitle: true,
isActive: true
}
describe.each([false, true])('session title publisher, main layout=%s', (isMain) => {
beforeEach(() => {
layoutOwner.isMain = isMain
vi.useFakeTimers()
electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false })
})
afterEach(() => vi.useRealTimers())
function host() {
const session = makeWorkspaceSessionWithHeadlessTerminal()
const saved = session.tabsByWorktree[TEST_WORKTREE_ID][0]
saved.ptyId = surface.ptyId!
saved.aiVaultTitle = name
session.terminalLayoutsByTabId['host-tab'] = makeHeadlessTerminalLayout({
[HEADLESS_LEAF_ID]: surface.ptyId!
})
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(session)
const runtime = new OrcaRuntimeService(runtimeStore)
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
runtime.registerPty(surface.ptyId!, TEST_WORKTREE_ID, null, {
tabId: surface.parentTabId,
leafId: surface.leafId
})
runtime.attachWindow(1)
const events: RuntimeMobileSessionTabsResult[] = []
const unsubscribe = runtime.onMobileSessionTabsChanged((frame) => events.push(frame))
function publish(
windowId: number,
version: number,
rows: RuntimeMobileSessionTerminalTab[] = [surface],
includeWorktree = true,
generation = `renderer-${windowId}`
) {
runtime.syncWindowGraph(windowId, {
rendererGeneration: generation,
tabs: [],
leaves: [],
mobileSessionTabs: includeWorktree
? [
{
worktree: TEST_WORKTREE_ID,
publicationEpoch: generation,
snapshotVersion: version,
activeGroupId: null,
activeTabId: rows[0]?.id ?? null,
activeTabType: rows.length ? 'terminal' : null,
tabs: rows
}
]
: []
})
}
async function latest() {
await vi.advanceTimersByTimeAsync(100)
const frame = events.findLast((event) => event.worktree === TEST_WORKTREE_ID)!
expect(frame).toBeDefined()
expect(frame.tabs).toEqual([expect.objectContaining({ id: surface.id })])
return frame
}
return { runtime, events, unsubscribe, publish, latest }
}
it('revokes authoritative null and publishes a newer terminal-only frame when its window closes', async () => {
const h = host()
try {
h.publish(1, 1, [{ ...surface, aiVaultTitle: null }])
const before = await h.latest()
expect(before.tabs[0]).toMatchObject({ resolvesSessionTitle: true, aiVaultTitle: null })
h.events.length = 0
// Window destruction precedes main's close callback.
electronMocks.BrowserWindow.fromId.mockReturnValue(null)
h.runtime.markGraphUnavailable(1)
const after = await h.latest()
expect(after.snapshotVersion).toBeGreaterThan(before.snapshotVersion)
expect(after.tabs[0]).not.toHaveProperty('resolvesSessionTitle')
expect(after.tabs[0]).not.toHaveProperty('aiVaultTitle', null)
} finally {
h.unsubscribe()
}
})
it.each(['window', 'renderer'])(
'requires a successor %s to publish the surface before owning its retained name',
async (successor) => {
const h = host()
try {
h.publish(1, 1)
expect((await h.latest()).tabs[0]).toHaveProperty('resolvesSessionTitle', true)
const windowId = successor === 'window' ? 2 : 1
if (successor === 'window') {
h.runtime.markGraphUnavailable(1)
h.runtime.attachWindow(windowId)
} else {
h.runtime.markRendererReloading(1)
expect((await h.latest()).tabs[0]).not.toHaveProperty('resolvesSessionTitle')
}
h.publish(windowId, 1, [], true, 'successor-renderer')
const retained = await h.latest()
expect(retained.tabs[0]).not.toHaveProperty('resolvesSessionTitle')
expect(retained.tabs[0]).toHaveProperty('aiVaultTitle', name)
h.publish(windowId, 2, [surface], true, 'successor-renderer')
expect((await h.latest()).tabs[0]).toHaveProperty('resolvesSessionTitle', true)
} finally {
h.unsubscribe()
}
}
)
it.each([true, false])(
'revokes an omitted retained surface when workspace remains=%s',
async (includeWorktree) => {
const h = host()
try {
h.publish(1, 1)
const before = await h.latest()
expect(before.tabs[0]).toHaveProperty('resolvesSessionTitle', true)
h.events.length = 0
h.publish(1, 2, [], includeWorktree)
const after = await h.latest()
expect(after.snapshotVersion).toBeGreaterThan(before.snapshotVersion)
expect(after.tabs[0]).not.toHaveProperty('resolvesSessionTitle')
expect(after.tabs[0]).toHaveProperty('aiVaultTitle', name)
} finally {
h.unsubscribe()
}
}
)
})
@@ -0,0 +1,62 @@
import { OrcaRuntimeWithStopTerminalsForProject } from '../terminal/stop/project-terminal-stop'
import { HEADLESS_RUNTIME_WINDOW_ID } from '../../../shared/runtime-types'
export type SessionTitlePublisher = {
windowId: number
rendererGeneration: string
surfaceIds: ReadonlySet<string>
}
export type SessionTitleAuthority = ReadonlyMap<string, ReadonlySet<string>>
export abstract class OrcaRuntimeWithSessionTitleAuthority extends OrcaRuntimeWithStopTerminalsForProject {
private currentSessionTitlePublisher(worktreeId: string): SessionTitlePublisher | undefined {
const publisher = this.acceptedRendererMobileSnapshotByWorktree.get(worktreeId)?.titlePublisher
return (
(publisher &&
this.graphStatus === 'ready' &&
publisher.windowId !== HEADLESS_RUNTIME_WINDOW_ID &&
publisher.windowId === this.authoritativeWindowId &&
publisher.rendererGeneration === this.rendererGeneration &&
publisher) ||
undefined
)
}
protected isSessionTitlePublisher(worktreeId: string, surfaceId: string): boolean {
return !!(
this.getAvailableAuthoritativeWindow() &&
this.currentSessionTitlePublisher(worktreeId)?.surfaceIds.has(surfaceId)
)
}
protected captureSessionTitleAuthority(): SessionTitleAuthority {
const authority = new Map<string, ReadonlySet<string>>()
for (const worktreeId of this.acceptedRendererMobileSnapshotByWorktree.keys()) {
// The close callback can run after the OS window is gone; ready still names its last publication.
const ids = this.currentSessionTitlePublisher(worktreeId)?.surfaceIds
if (ids?.size) {
authority.set(worktreeId, ids)
}
}
return authority
}
protected republishSessionTitleAuthorityChanges(
before: SessionTitleAuthority,
alreadyChanged: ReadonlySet<string> = new Set()
): void {
const after = this.captureSessionTitleAuthority()
for (const worktreeId of new Set([...before.keys(), ...after.keys()])) {
const previous = before.get(worktreeId)
const next = after.get(worktreeId)
if (
!alreadyChanged.has(worktreeId) &&
(previous?.size !== next?.size || (previous && [...previous].some((id) => !next?.has(id))))
) {
// Retained rows can stay identical while their publisher stops resolving titles.
this.touchMobileSessionTabsForWorktree(worktreeId)
}
}
}
}
@@ -0,0 +1,74 @@
// @vitest-environment happy-dom
import { renderHook } from '@testing-library/react'
import { expect, it } from 'vitest'
import type { Tab } from '../../../../shared/tab-types'
import type { TerminalTab } from '../../../../shared/workspace-layout/terminal-tab-types'
import { tabItemLabel } from '../tab-bar/tab-bar-item-label'
import { useTabGroupItemProjections } from './useTabGroupItemProjections'
const aiVaultTitle = { agent: 'claude' as const, sessionId: 'session-1', title: 'fix-tab-title' }
const unifiedTab: Tab = {
id: 'unified-1',
entityId: 'terminal-1',
groupId: 'group-1',
worktreeId: 'wt-1',
contentType: 'terminal',
label: 'fix-tab-title',
generatedLabel: 'First prompt',
aiVaultTitle,
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 0
}
const terminalTab: TerminalTab = {
id: 'terminal-1',
ptyId: null,
worktreeId: 'wt-1',
title: 'fix-tab-title',
generatedTitle: 'First prompt',
aiVaultTitle,
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 0
}
it('labels a terminal tab with its session title over the first-prompt title', () => {
const { result } = renderHook(() =>
useTabGroupItemProjections({
groupId: 'group-1',
worktreeId: 'wt-1',
worktreeState: {
groups: [
{ id: 'group-1', worktreeId: 'wt-1', activeTabId: 'unified-1', tabOrder: ['unified-1'] }
],
unifiedTabs: [unifiedTab],
terminalTabs: [terminalTab],
openFiles: [],
browserTabs: [],
expandedPaneByTabId: {},
terminalLayoutsByTabId: {},
generatedTabTitlesEnabled: true,
mobileEmulatorEnabled: false
}
})
)
const [projected] = result.current.terminalTabs
expect(
tabItemLabel(
{
type: 'terminal',
id: projected.id,
unifiedTabId: projected.unifiedTabId,
isPinned: false,
data: projected
},
true,
null
)
).toBe('fix-tab-title')
})
@@ -85,6 +85,8 @@ export function useTabGroupItemProjections({
defaultTitle: terminalTab?.defaultTitle,
quickCommandLabel: terminalTab?.quickCommandLabel ?? item.quickCommandLabel ?? null,
generatedTitle: terminalTab?.generatedTitle ?? item.generatedLabel ?? null,
// Why: the strip re-resolves this title; without the session title the first-prompt title wins.
aiVaultTitle: item.aiVaultTitle ?? terminalTab?.aiVaultTitle ?? null,
customTitle: item.customLabel ?? terminalTab?.customTitle ?? null,
color: item.color ?? terminalTab?.color ?? null,
sortOrder: item.sortOrder,
@@ -135,7 +135,7 @@ function conversationRecords<T>(records: Record<string, T> | undefined, paneKey?
}
export function collectAiVaultTitleRequests(state: AppState): AiVaultTitleRequest[] {
return collectAgentPaneConversations(state, isAiVaultTitleAgent).map(
return collectAgentPaneConversations(state, isAiVaultTitleAgent, { skipHostNamedTabs: true }).map(
({ paneKey: _paneKey, ...request }) => request
)
}
@@ -150,6 +150,8 @@ export function collectAgentPaneConversations<Agent extends AgentType>(
includeLastReported?: boolean
pane?: { worktreeId: string; paneKey: string }
tab?: { worktreeId: string; tabId: string }
/** A paired host that publishes a tab's session name is its only writer; an in-flight result rechecks this. */
skipHostNamedTabs?: boolean
} = {}
): AgentPaneConversation<Agent>[] {
const addressed = options.pane
@@ -227,6 +229,10 @@ export function collectAgentPaneConversations<Agent extends AgentType>(
return [...candidates.values()]
.filter((candidate) => !options.pane || candidate.paneKey === options.pane.paneKey)
.filter(
(candidate) =>
!options.skipHostNamedTabs || !tabsById.get(candidate.tabId)?.sessionTitleFromHost
)
.map(({ priority: _priority, ...request }) => request)
}
@@ -130,6 +130,7 @@ function terminalTabsEqual(current: AppState, previous: AppState): boolean {
if (
left.id !== right.id ||
left.worktreeId !== right.worktreeId ||
left.sessionTitleFromHost !== right.sessionTitleFromHost ||
!titleEqual(left.aiVaultTitle, right.aiVaultTitle)
) {
return false
@@ -146,6 +146,22 @@ function makeState(args: {
listener(state, previous)
}
},
nameFromHost: (title: string) => {
const previous = state
state = {
...state,
tabsByWorktree: {
[args.worktreeId]: state.tabsByWorktree[args.worktreeId].map((entry: TerminalTab) => ({
...entry,
aiVaultTitle: { agent, sessionId: providerSession.id, title },
sessionTitleFromHost: true as const
}))
}
}
for (const listener of listeners) {
listener(state, previous)
}
},
removeSleepingRecord: () => {
const previous = state
state = { ...state, sleepingAgentSessionsByPaneKey: {} }
@@ -182,6 +198,31 @@ describe('AI Vault tab title sync', () => {
stop()
})
it('drops a client result that returns after the paired host started naming the tab', async () => {
const store = makeState({
executionHostId: 'runtime:server-1',
worktreeId: 'worktree-1',
path: '/workspace/albacore'
})
let respond: ((result: AiVaultSessionTitlesResult) => void) | undefined
const resolveSessionTitles = vi.fn(
() =>
new Promise<AiVaultSessionTitlesResult>((resolve) => {
respond = resolve
})
)
const stop = startAiVaultTabTitleSync({ ...store, resolveSessionTitles })
await vi.waitFor(() => expect(respond).toBeTypeOf('function'))
store.nameFromHost('Host name')
respond?.(titleResult('codex', 'Client name'))
await new Promise((resolve) => setTimeout(resolve, 0))
expect(store.getState().tabsByWorktree['worktree-1'][0].aiVaultTitle?.title).toBe('Host name')
expect(resolveSessionTitles).toHaveBeenCalledTimes(1)
stop()
})
it('refreshes a live title when the AI Vault name changes', async () => {
const store = makeState({
aiVaultTitle: { agent: 'codex', sessionId: 'codex-session', title: 'First name' },
@@ -109,8 +109,12 @@ export function buildMobileTerminalSurfaceTabs(
...(tabWideFallbackSafe && terminal.quickCommandLabel?.trim()
? { quickCommandLabel: terminal.quickCommandLabel.trim() }
: {}),
...(tabWideFallbackSafe && terminal.aiVaultTitle?.title.trim()
? { aiVaultTitle: terminal.aiVaultTitle }
// This window's title sync owns the tab's session name; null tells paired desktops it has none.
...(tabWideFallbackSafe
? {
aiVaultTitle: terminal.aiVaultTitle?.title.trim() ? terminal.aiVaultTitle : null,
resolvesSessionTitle: true as const
}
: {}),
...(tabWideFallbackSafe && inputs.generatedTitlesEnabled && terminal.generatedTitle?.trim()
? { generatedTitle: terminal.generatedTitle.trim() }
@@ -8,6 +8,7 @@ import { makePaneKey } from '../../../shared/workspace-layout/stable-pane-id'
import { toWebTerminalSurfaceTabId } from '../../../shared/workspace-layout/terminal-surface-id'
import { deriveGeneratedTabTitle } from '../../../shared/agent-status/presentation/agent-tab-title'
import { getDefaultSettings } from '../../../shared/constants'
import { resolveTerminalTabTitle } from '../../../shared/tab-title-resolution'
import type { AppState } from '../store/types'
import { createTestStore, makeWorktree, seedStore } from '../store/slices/store-test-helpers'
import {
@@ -52,6 +53,10 @@ function makeHostSnapshot(args: {
hostNow: number
includeAgentStatus: boolean
agentStatusOverrides?: Partial<AgentStatusEntry>
aiVaultTitle?: { agent: 'claude'; sessionId: string; title: string } | null
resolvesSessionTitle?: true
title?: string
status?: 'ready' | 'pending-handle'
}): RuntimeMobileSessionTabsResult {
return {
worktree: WT,
@@ -64,12 +69,15 @@ function makeHostSnapshot(args: {
{
type: 'terminal',
id: HOST_SURFACE_ID,
title: 'Terminal',
title: args.title ?? 'Terminal',
parentTabId: HOST_TAB_ID,
leafId: LEAF_ID,
isActive: true,
status: 'ready',
terminal: 'terminal-1',
...(args.status === 'pending-handle'
? { status: 'pending-handle' as const, terminal: null }
: { status: 'ready' as const, terminal: 'terminal-1' }),
...(args.aiVaultTitle !== undefined ? { aiVaultTitle: args.aiVaultTitle } : {}),
...(args.resolvesSessionTitle ? { resolvesSessionTitle: true as const } : {}),
...(args.includeAgentStatus
? {
agentStatus: {
@@ -433,3 +441,98 @@ describe('remote-paired pane: host snapshot mirror vs client byte-derived status
})
})
})
describe('remote-paired pane: host session name', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(T0)
resetWebSessionTabsSnapshotFreshnessForTests()
resetRendererOwnedAgentStatusPanesForTests()
})
afterEach(() => {
vi.useRealTimers()
resetRendererOwnedAgentStatusPanesForTests()
})
it('labels the mirrored tab with the host session name over the client first-prompt title', () => {
const store = seedPairedClientStore()
applyHostSnapshot(
store,
makeHostSnapshot({ snapshotVersion: 1, hostNow: T0, includeAgentStatus: true }),
T0
)
replayClientOscWorking(store, T0 + 1_000)()
applyHostSnapshot(
store,
makeHostSnapshot({
snapshotVersion: 2,
hostNow: T0 + 2_000,
includeAgentStatus: true,
aiVaultTitle: { agent: 'claude', sessionId: 'session-1', title: 'Login cleanup' },
resolvesSessionTitle: true
}),
T0 + 2_000
)
const tab = (store.getState().tabsByWorktree[WT] ?? []).find((t) => t.id === MIRROR_TAB_ID)
expect(tab?.generatedTitle).toBe(EXPECTED_GENERATED_TITLE)
expect(resolveTerminalTabTitle(tab!, true)).toBe('Login cleanup')
})
it('follows a resolving host through a rename and a clear', () => {
const store = seedPairedClientStore()
const publish = (
snapshotVersion: number,
aiVaultTitle: { agent: 'claude'; sessionId: string; title: string } | null,
surface: { title?: string; status?: 'ready' | 'pending-handle' } = {}
) =>
applyHostSnapshot(
store,
makeHostSnapshot({
snapshotVersion,
hostNow: T0 + snapshotVersion * 1_000,
includeAgentStatus: true,
aiVaultTitle,
resolvesSessionTitle: true,
...surface
}),
T0 + snapshotVersion * 1_000
)
const mirrored = () =>
(store.getState().tabsByWorktree[WT] ?? []).find((t) => t.id === MIRROR_TAB_ID)
publish(1, { agent: 'claude', sessionId: 'session-1', title: 'Old' }, { title: 'Old' })
// The host's own title already ranks the session name in.
publish(2, { agent: 'claude', sessionId: 'session-1', title: 'New' }, { title: 'New' })
expect(mirrored()?.aiVaultTitle?.title).toBe('New')
// An idle pane publishes the placeholder title while the host clears the name.
publish(3, null, { title: 'Terminal', status: 'pending-handle' })
expect(mirrored()?.aiVaultTitle).toBeUndefined()
expect(mirrored()?.sessionTitleFromHost).toBe(true)
expect(resolveTerminalTabTitle(mirrored()!, false)).toBe('Terminal')
})
it('takes a saved name back as cache once the host stops resolving it', () => {
const store = seedPairedClientStore()
const publish = (snapshotVersion: number, title: string, resolvesSessionTitle?: true) =>
applyHostSnapshot(
store,
makeHostSnapshot({
snapshotVersion,
hostNow: T0 + snapshotVersion * 1_000,
includeAgentStatus: true,
aiVaultTitle: { agent: 'claude', sessionId: 'session-1', title },
...(resolvesSessionTitle ? { resolvesSessionTitle } : {})
}),
T0 + snapshotVersion * 1_000
)
// The window resolves "New"; the host then restarts headless and republishes its saved "Old".
publish(1, 'New', true)
publish(2, 'Old')
const tab = (store.getState().tabsByWorktree[WT] ?? []).find((t) => t.id === MIRROR_TAB_ID)
expect(tab?.aiVaultTitle?.title).toBe('New')
expect(tab?.sessionTitleFromHost).toBeUndefined()
})
})
@@ -24,6 +24,7 @@ export function terminalTabEqual(a: TerminalTab, b: TerminalTab): boolean {
a.aiVaultTitle?.agent === b.aiVaultTitle?.agent &&
a.aiVaultTitle?.sessionId === b.aiVaultTitle?.sessionId &&
a.aiVaultTitle?.title === b.aiVaultTitle?.title &&
a.sessionTitleFromHost === b.sessionTitleFromHost &&
a.customTitle === b.customTitle &&
a.color === b.color &&
a.sortOrder === b.sortOrder &&
@@ -145,11 +145,22 @@ export function buildMirroredTerminalTabs(
surfaces
.map((surface) => existingById.get(toWebTerminalSurfaceTabId(surface.id)))
.find((tab): tab is TerminalTab => Boolean(tab))
// A host window that resolves the session name is its only writer (null clears); a headless or older host's name is a saved cache, so the client resolves its own.
const hostResolvesSessionTitle = activeSurface.resolvesSessionTitle === true
const sessionTitle = hostResolvesSessionTitle
? activeSurface.aiVaultTitle?.title.trim()
? activeSurface.aiVaultTitle
: null
: existing?.aiVaultTitle
// The host's titles rank its session names in, so when it clears a name none of the retained ones is a safe placeholder.
const sessionNameCleared = hostResolvesSessionTitle && !sessionTitle && !!existing?.aiVaultTitle
// Idle headless panes publish default Terminal; retain known title until a ready surface supplies one.
const hostTitle = activeSurface.title.trim() || surfaces[0]?.title.trim() || ''
const hostTitleIsPlaceholder =
hostTitle === '' || (activeSurface.status === 'pending-handle' && hostTitle === 'Terminal')
const retainedTitle = existing?.title?.trim() || existing?.defaultTitle?.trim() || ''
const retainedTitle = sessionNameCleared
? ''
: existing?.title?.trim() || existing?.defaultTitle?.trim() || ''
const title = normalizeCompatibleAgentTitleForOwner(
(hostTitleIsPlaceholder ? retainedTitle || hostTitle : hostTitle) || 'Terminal',
ownerRecord?.agent,
@@ -180,7 +191,8 @@ export function buildMirroredTerminalTabs(
defaultTitle: existing?.defaultTitle ?? title,
// Host omits generated titles; retain client agent-prompt labels across rebuilds.
...(existing?.generatedTitle ? { generatedTitle: existing.generatedTitle } : {}),
...(existing?.aiVaultTitle ? { aiVaultTitle: existing.aiVaultTitle } : {}),
...(sessionTitle ? { aiVaultTitle: sessionTitle } : {}),
...(hostResolvesSessionTitle ? { sessionTitleFromHost: true as const } : {}),
// Host omits client recovery ledger; preserve it or every snapshot rearms remount allowance (b5cfc6ca).
...(existing?.recovery ? { recovery: existing.recovery } : {}),
...(quickCommandLabel ? { quickCommandLabel } : {}),
@@ -1,12 +1,12 @@
// Follow tab-title-resolution precedence: manual, quick command, semantic, provider, generated, then live title. Reject status/identity/cwd live titles so rows retain message previews.
// Uses the tab's own name order, then a live title only when it names the conversation, so rows retain message previews.
import type { AgentType } from '../contracts/agent-status-types'
import { isClaudeManagementTitle } from '../title/agent-title-core'
import { stripLeadingAgentTitleDecorationOrEmpty } from '../title/agent-title-decoration'
import { formatAgentTypeLabel } from './agent-type-label'
import { isJcodeIdentityTerminalTitle } from '../title/providers/jcode-terminal-title'
import { isMeaningfulOpenCodeTerminalTitle } from '../title/providers/opencode-terminal-title'
import { SYNTHETIC_AGENT_TITLE_PROFILES } from '../title/synthetic-agent-title'
import type { TerminalTab } from '../../workspace-layout/terminal-tab-types'
import { resolveTabNameSource } from '../../tab-title-resolution'
export type ConversationNameTab = Pick<
TerminalTab,
@@ -109,33 +109,27 @@ export function getAgentRowConversationName(
paneLiveTitle?: string | null,
providerSessionId?: string
): string | null {
const customTitle = tab.customTitle?.trim()
if (customTitle) {
return customTitle
}
const quickCommandLabel = tab.quickCommandLabel?.trim()
if (quickCommandLabel) {
return quickCommandLabel
}
const liveTitle =
paneLiveTitle === undefined ? (tab.title?.trim() ?? '') : (paneLiveTitle?.trim() ?? '')
if (isMeaningfulOpenCodeTerminalTitle(liveTitle)) {
return liveTitle
}
// Provider titles belong to their session, not every pane in the tab.
const aiVaultTitle = tab.aiVaultTitle
const providerTitle = aiVaultTitle?.title.trim()
if (
aiVaultTitle &&
providerTitle &&
aiVaultTitle.agent === agentType &&
aiVaultTitle.sessionId === providerSessionId
) {
return providerTitle
}
const generatedTitle = generatedTitlesEnabled ? tab.generatedTitle?.trim() : ''
if (generatedTitle) {
return generatedTitle
const named = resolveTabNameSource(
{
customTitle: tab.customTitle,
quickCommandLabel: tab.quickCommandLabel,
liveTitle,
sessionTitle:
aiVaultTitle &&
aiVaultTitle.agent === agentType &&
aiVaultTitle.sessionId === providerSessionId
? aiVaultTitle.title
: null,
generatedTitle: tab.generatedTitle
},
generatedTitlesEnabled
)
if (named) {
return named
}
if (!liveTitle) {
return null
@@ -15,6 +15,8 @@ export type RuntimeMobileSessionTerminalTab = {
quickCommandLabel?: string | null
/** Window-to-main names ranked above live titles; generatedTitle is sent only when enabled. */
aiVaultTitle?: TerminalTab['aiVaultTitle']
/** The publishing window resolves this surface's session name, so aiVaultTitle (null clears) is authoritative. Headless and older hosts omit it; their saved names are only cache. */
resolvesSessionTitle?: true
generatedTitle?: string | null
parentTabId: string
leafId: string
+46 -10
View File
@@ -2,6 +2,32 @@ import type { Tab } from './tab-types'
import type { TerminalTab } from './workspace-layout/terminal-tab-types'
import { isMeaningfulOpenCodeTerminalTitle } from './agent-status/title/providers/opencode-terminal-title'
export type TabNameSources = {
/** A name the user typed into the tab. */
customTitle?: string | null
quickCommandLabel?: string | null
/** The program's own terminal title right now. */
liveTitle: string
/** The agent session's own name, already checked to belong to this tab's session. */
sessionTitle?: string | null
generatedTitle?: string | null
}
/** The one name order every tab and agent-row surface uses; '' leaves the plain live title to the caller. */
export function resolveTabNameSource(
sources: TabNameSources,
generatedTitlesEnabled: boolean
): string {
return (
sources.customTitle?.trim() ||
sources.quickCommandLabel?.trim() ||
(isMeaningfulOpenCodeTerminalTitle(sources.liveTitle) ? sources.liveTitle : '') ||
sources.sessionTitle?.trim() ||
(generatedTitlesEnabled ? sources.generatedTitle?.trim() : '') ||
''
)
}
export function resolveTerminalTabTitle(
tab: Pick<TerminalTab, 'quickCommandLabel' | 'aiVaultTitle' | 'generatedTitle' | 'title'> & {
customTitle?: string | null
@@ -11,11 +37,16 @@ export function resolveTerminalTabTitle(
): string {
const liveTitle = tab.title?.trim() ?? ''
return (
tab.customTitle?.trim() ||
tab.quickCommandLabel?.trim() ||
(isMeaningfulOpenCodeTerminalTitle(liveTitle) ? liveTitle : '') ||
tab.aiVaultTitle?.title.trim() ||
(generatedTitlesEnabled ? tab.generatedTitle?.trim() : '') ||
resolveTabNameSource(
{
customTitle: tab.customTitle,
quickCommandLabel: tab.quickCommandLabel,
liveTitle,
sessionTitle: tab.aiVaultTitle?.title,
generatedTitle: tab.generatedTitle
},
generatedTitlesEnabled
) ||
liveTitle ||
fallback
)
@@ -30,11 +61,16 @@ export function resolveUnifiedTabLabel(
): string {
const liveLabel = tab?.label?.trim() ?? ''
return (
tab?.customLabel?.trim() ||
tab?.quickCommandLabel?.trim() ||
(isMeaningfulOpenCodeTerminalTitle(liveLabel) ? liveLabel : '') ||
tab?.aiVaultTitle?.title.trim() ||
(generatedTitlesEnabled ? tab?.generatedLabel?.trim() : '') ||
resolveTabNameSource(
{
customTitle: tab?.customLabel,
quickCommandLabel: tab?.quickCommandLabel,
liveTitle: liveLabel,
sessionTitle: tab?.aiVaultTitle?.title,
generatedTitle: tab?.generatedLabel
},
generatedTitlesEnabled
) ||
liveLabel ||
fallback
)
@@ -51,6 +51,8 @@ export type TerminalTab = {
generatedTitle?: string | null
/** Stable AI Vault conversation name, bound to its provider session identity. */
aiVaultTitle?: AiVaultSessionTitle | null
/** Mirror-only, never saved: the paired host publishes this tab's session name, so this client doesn't resolve its own. */
sessionTitleFromHost?: true
/** Stable label from the tab-bar Quick Command that created this terminal. */
quickCommandLabel?: string | null
customTitle: string | null
@@ -132,7 +132,8 @@ const ROW = {
agentLaunchPane: 'unwritten',
pendingActivationSpawn: 'unwritten',
recovery: 'unwritten',
restoredFromSession: 'unwritten'
restoredFromSession: 'unwritten',
sessionTitleFromHost: 'unwritten'
} as const satisfies Record<keyof TerminalTab, DiskFieldWritten>
const ENTRY_FIELDS = {