From 239a70040c18fdf1f28af3be955da09ee2cb45bb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:04:58 -0800 Subject: [PATCH] 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 * 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 --- pnpm-lock.yaml | 25 +++ .../construction/runtime-identity-state.ts | 2 + .../session-graph/session-graph-promotion.ts | 7 + .../session-graph-reload-lifecycle.ts | 18 +- .../session-graph/session-window-graph.ts | 51 ++---- .../session-graph/session-window-publisher.ts | 45 +++++ .../prune-mobile-session-tab-group-layout.ts | 2 + ...time-mobile-last-assistant-message.test.ts | 1 + ...ile-session-incarnation-projection.test.ts | 1 + ...time-mobile-session-projection-contract.ts | 1 + .../runtime-mobile-session-projection.ts | 3 + ...me-mobile-session-title-projection.test.ts | 27 +++ ...runtime-mobile-session-title-projection.ts | 23 +++ .../session-tabs/main-session-tab-rows.ts | 4 + .../mobile-session-reconciliation.ts | 24 ++- .../session-title-publisher.test.ts | 167 ++++++++++++++++++ .../session-tabs/session-title-publisher.ts | 62 +++++++ .../useTabGroupItemProjections.test.ts | 74 ++++++++ .../tab-group/useTabGroupItemProjections.ts | 2 + .../src/lib/ai-vault-tab-title-requests.ts | 8 +- .../src/lib/ai-vault-tab-title-sync-inputs.ts | 1 + .../src/lib/ai-vault-tab-title-sync.test.ts | 41 +++++ .../mobile-session-terminal-tabs.ts | 8 +- ...tabs-sync-remote-status-title-flap.test.ts | 109 +++++++++++- .../state-equality-tabs.ts | 1 + .../web-session-tabs-sync/terminal-build.ts | 16 +- .../agent-row-conversation-name.ts | 44 ++--- .../runtime-mobile-session-tab-contracts.ts | 2 + src/shared/tab-title-resolution.ts | 56 ++++-- .../workspace-layout/terminal-tab-types.ts | 2 + .../workspace-layout-disk-fields.ts | 3 +- 31 files changed, 741 insertions(+), 89 deletions(-) create mode 100644 src/main/runtime/session-graph/session-window-publisher.ts create mode 100644 src/main/runtime/session-lifecycle/projection/runtime-mobile-session-title-projection.test.ts create mode 100644 src/main/runtime/session-lifecycle/projection/runtime-mobile-session-title-projection.ts create mode 100644 src/main/runtime/session-tabs/session-title-publisher.test.ts create mode 100644 src/main/runtime/session-tabs/session-title-publisher.ts create mode 100644 src/renderer/src/components/tab-group/useTabGroupItemProjections.test.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47ef7b32eaf..996cc0c34df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/main/runtime/construction/runtime-identity-state.ts b/src/main/runtime/construction/runtime-identity-state.ts index 95394b56399..60c576fb864 100644 --- a/src/main/runtime/construction/runtime-identity-state.ts +++ b/src/main/runtime/construction/runtime-identity-state.ts @@ -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 + titlePublisher?: SessionTitlePublisher } >() diff --git a/src/main/runtime/session-graph/session-graph-promotion.ts b/src/main/runtime/session-graph/session-graph-promotion.ts index 56cc60cc57b..60616058e97 100644 --- a/src/main/runtime/session-graph/session-graph-promotion.ts +++ b/src/main/runtime/session-graph/session-graph-promotion.ts @@ -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 + ): void + protected abstract agentSenderGraphReply( ...args: Parameters ): ReturnType diff --git a/src/main/runtime/session-graph/session-graph-reload-lifecycle.ts b/src/main/runtime/session-graph/session-graph-reload-lifecycle.ts index 75a7aec9348..78048c14491 100644 --- a/src/main/runtime/session-graph/session-graph-reload-lifecycle.ts +++ b/src/main/runtime/session-graph/session-graph-reload-lifecycle.ts @@ -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 ): ReturnType @@ -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 { + const republished = new Set() 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 { diff --git a/src/main/runtime/session-graph/session-window-graph.ts b/src/main/runtime/session-graph/session-window-graph.ts index 14531b9b8cc..58d2a0f9345 100644 --- a/src/main/runtime/session-graph/session-window-graph.ts +++ b/src/main/runtime/session-graph/session-window-graph.ts @@ -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() 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)) diff --git a/src/main/runtime/session-graph/session-window-publisher.ts b/src/main/runtime/session-graph/session-window-publisher.ts new file mode 100644 index 00000000000..f49c8d26f3e --- /dev/null +++ b/src/main/runtime/session-graph/session-window-publisher.ts @@ -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 + } +} diff --git a/src/main/runtime/session-lifecycle/projection/prune-mobile-session-tab-group-layout.ts b/src/main/runtime/session-lifecycle/projection/prune-mobile-session-tab-group-layout.ts index 784d67692d1..d7991a16682 100644 --- a/src/main/runtime/session-lifecycle/projection/prune-mobile-session-tab-group-layout.ts +++ b/src/main/runtime/session-lifecycle/projection/prune-mobile-session-tab-group-layout.ts @@ -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), diff --git a/src/main/runtime/session-lifecycle/projection/runtime-mobile-last-assistant-message.test.ts b/src/main/runtime/session-lifecycle/projection/runtime-mobile-last-assistant-message.test.ts index c3e5d567d1b..c885bcfbe47 100644 --- a/src/main/runtime/session-lifecycle/projection/runtime-mobile-last-assistant-message.test.ts +++ b/src/main/runtime/session-lifecycle/projection/runtime-mobile-last-assistant-message.test.ts @@ -146,6 +146,7 @@ describe('mobile completion status projection', () => { ]), ptysById: new Map([[pty.ptyId, pty]]), getPtyAvailability: () => null, + isSessionTitlePublisher: () => false, getLiveBrowserTabs: () => new Map(), getProviderSessionRows: () => [], getProviderSessionSnapshot: () => [], diff --git a/src/main/runtime/session-lifecycle/projection/runtime-mobile-session-incarnation-projection.test.ts b/src/main/runtime/session-lifecycle/projection/runtime-mobile-session-incarnation-projection.test.ts index 9f80e858496..ad78762b6b3 100644 --- a/src/main/runtime/session-lifecycle/projection/runtime-mobile-session-incarnation-projection.test.ts +++ b/src/main/runtime/session-lifecycle/projection/runtime-mobile-session-incarnation-projection.test.ts @@ -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: () => [], diff --git a/src/main/runtime/session-lifecycle/projection/runtime-mobile-session-projection-contract.ts b/src/main/runtime/session-lifecycle/projection/runtime-mobile-session-projection-contract.ts index 2154dde0800..b0929d7ca5b 100644 --- a/src/main/runtime/session-lifecycle/projection/runtime-mobile-session-projection-contract.ts +++ b/src/main/runtime/session-lifecycle/projection/runtime-mobile-session-projection-contract.ts @@ -24,6 +24,7 @@ export type RuntimeMobileSessionProjectionHost = { leaves: ReadonlyMap ptysById: ReadonlyMap getPtyAvailability(ptyId: string): boolean | null + isSessionTitlePublisher(worktreeId: string, surfaceId: string): boolean getLiveBrowserTabs(worktreeId: string): Map isRendererOwnedMobileBrowserTab( snapshot: RuntimeMobileSessionTabsSnapshot, diff --git a/src/main/runtime/session-lifecycle/projection/runtime-mobile-session-projection.ts b/src/main/runtime/session-lifecycle/projection/runtime-mobile-session-projection.ts index 6e23ebd1158..8073736488a 100644 --- a/src/main/runtime/session-lifecycle/projection/runtime-mobile-session-projection.ts +++ b/src/main/runtime/session-lifecycle/projection/runtime-mobile-session-projection.ts @@ -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 } : {}), diff --git a/src/main/runtime/session-lifecycle/projection/runtime-mobile-session-title-projection.test.ts b/src/main/runtime/session-lifecycle/projection/runtime-mobile-session-title-projection.test.ts new file mode 100644 index 00000000000..85c495ea2e2 --- /dev/null +++ b/src/main/runtime/session-lifecycle/projection/runtime-mobile-session-title-projection.test.ts @@ -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') + }) +}) diff --git a/src/main/runtime/session-lifecycle/projection/runtime-mobile-session-title-projection.ts b/src/main/runtime/session-lifecycle/projection/runtime-mobile-session-title-projection.ts new file mode 100644 index 00000000000..2d859f259e9 --- /dev/null +++ b/src/main/runtime/session-lifecycle/projection/runtime-mobile-session-title-projection.ts @@ -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, + tab: RuntimeMobileSessionTerminalTab, + host: Pick +) { + // 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 } + : {}) + } +} diff --git a/src/main/runtime/session-tabs/main-session-tab-rows.ts b/src/main/runtime/session-tabs/main-session-tab-rows.ts index 0fd652a42fe..2b20254874e 100644 --- a/src/main/runtime/session-tabs/main-session-tab-rows.ts +++ b/src/main/runtime/session-tabs/main-session-tab-rows.ts @@ -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 } : {}) diff --git a/src/main/runtime/session-tabs/mobile-session-reconciliation.ts b/src/main/runtime/session-tabs/mobile-session-reconciliation.ts index 1ded588b8f7..fc32e5ef17a 100644 --- a/src/main/runtime/session-tabs/mobile-session-reconciliation.ts +++ b/src/main/runtime/session-tabs/mobile-session-reconciliation.ts @@ -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(), - rendererGeneration?: string | null + rendererGeneration?: string | null, + publishedSnapshots?: RuntimeMobileSessionTabsSnapshot[] ): Set { const changedWorktreeIds = new Set() 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)) { diff --git a/src/main/runtime/session-tabs/session-title-publisher.test.ts b/src/main/runtime/session-tabs/session-title-publisher.test.ts new file mode 100644 index 00000000000..40f9f2f12f3 --- /dev/null +++ b/src/main/runtime/session-tabs/session-title-publisher.test.ts @@ -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() + } + } + ) +}) diff --git a/src/main/runtime/session-tabs/session-title-publisher.ts b/src/main/runtime/session-tabs/session-title-publisher.ts new file mode 100644 index 00000000000..9342199e7e6 --- /dev/null +++ b/src/main/runtime/session-tabs/session-title-publisher.ts @@ -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 +} + +export type SessionTitleAuthority = ReadonlyMap> + +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>() + 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 = 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) + } + } + } +} diff --git a/src/renderer/src/components/tab-group/useTabGroupItemProjections.test.ts b/src/renderer/src/components/tab-group/useTabGroupItemProjections.test.ts new file mode 100644 index 00000000000..f1853968203 --- /dev/null +++ b/src/renderer/src/components/tab-group/useTabGroupItemProjections.test.ts @@ -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') +}) diff --git a/src/renderer/src/components/tab-group/useTabGroupItemProjections.ts b/src/renderer/src/components/tab-group/useTabGroupItemProjections.ts index 1d8810ae9c0..6608f5324de 100644 --- a/src/renderer/src/components/tab-group/useTabGroupItemProjections.ts +++ b/src/renderer/src/components/tab-group/useTabGroupItemProjections.ts @@ -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, diff --git a/src/renderer/src/lib/ai-vault-tab-title-requests.ts b/src/renderer/src/lib/ai-vault-tab-title-requests.ts index 8b4016f9d8e..3803a592d39 100644 --- a/src/renderer/src/lib/ai-vault-tab-title-requests.ts +++ b/src/renderer/src/lib/ai-vault-tab-title-requests.ts @@ -135,7 +135,7 @@ function conversationRecords(records: Record | 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( 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[] { const addressed = options.pane @@ -227,6 +229,10 @@ export function collectAgentPaneConversations( 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) } diff --git a/src/renderer/src/lib/ai-vault-tab-title-sync-inputs.ts b/src/renderer/src/lib/ai-vault-tab-title-sync-inputs.ts index 13f1ce4cb12..5a5cf14d8ba 100644 --- a/src/renderer/src/lib/ai-vault-tab-title-sync-inputs.ts +++ b/src/renderer/src/lib/ai-vault-tab-title-sync-inputs.ts @@ -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 diff --git a/src/renderer/src/lib/ai-vault-tab-title-sync.test.ts b/src/renderer/src/lib/ai-vault-tab-title-sync.test.ts index fdb7ed1829c..8c37c38bfbf 100644 --- a/src/renderer/src/lib/ai-vault-tab-title-sync.test.ts +++ b/src/renderer/src/lib/ai-vault-tab-title-sync.test.ts @@ -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((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' }, diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-terminal-tabs.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-terminal-tabs.ts index 9d2d130ae64..f9e501e7b79 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-terminal-tabs.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-terminal-tabs.ts @@ -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() } diff --git a/src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts index 941011c0bd3..422f7564a8a 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts @@ -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 + 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() + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state-equality-tabs.ts b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-tabs.ts index a8f9e24375e..e0a8cb26e2f 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/state-equality-tabs.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-tabs.ts @@ -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 && diff --git a/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts b/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts index f902c864a88..b5474b03992 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts @@ -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 } : {}), diff --git a/src/shared/agent-status/presentation/agent-row-conversation-name.ts b/src/shared/agent-status/presentation/agent-row-conversation-name.ts index 71a86f8eec6..da6880f50c0 100644 --- a/src/shared/agent-status/presentation/agent-row-conversation-name.ts +++ b/src/shared/agent-status/presentation/agent-row-conversation-name.ts @@ -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 diff --git a/src/shared/runtime-mobile-session-tab-contracts.ts b/src/shared/runtime-mobile-session-tab-contracts.ts index 3a64709ee26..2f4379451de 100644 --- a/src/shared/runtime-mobile-session-tab-contracts.ts +++ b/src/shared/runtime-mobile-session-tab-contracts.ts @@ -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 diff --git a/src/shared/tab-title-resolution.ts b/src/shared/tab-title-resolution.ts index fc345259243..0cc41146029 100644 --- a/src/shared/tab-title-resolution.ts +++ b/src/shared/tab-title-resolution.ts @@ -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 & { 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 ) diff --git a/src/shared/workspace-layout/terminal-tab-types.ts b/src/shared/workspace-layout/terminal-tab-types.ts index 8a8147d9089..18cd4d6984e 100644 --- a/src/shared/workspace-layout/terminal-tab-types.ts +++ b/src/shared/workspace-layout/terminal-tab-types.ts @@ -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 diff --git a/src/shared/workspace-layout/workspace-layout-disk-fields.ts b/src/shared/workspace-layout/workspace-layout-disk-fields.ts index ca46899b1a4..a9fa8830526 100644 --- a/src/shared/workspace-layout/workspace-layout-disk-fields.ts +++ b/src/shared/workspace-layout/workspace-layout-disk-fields.ts @@ -132,7 +132,8 @@ const ROW = { agentLaunchPane: 'unwritten', pendingActivationSpawn: 'unwritten', recovery: 'unwritten', - restoredFromSession: 'unwritten' + restoredFromSession: 'unwritten', + sessionTitleFromHost: 'unwritten' } as const satisfies Record const ENTRY_FIELDS = {