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