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