diff --git a/src/main/runtime/mobile-session-terminal-projection.ts b/src/main/runtime/mobile-session-terminal-projection.ts index e5afc2a93d9..9380b5474b1 100644 --- a/src/main/runtime/mobile-session-terminal-projection.ts +++ b/src/main/runtime/mobile-session-terminal-projection.ts @@ -1,6 +1,7 @@ import type { RuntimeMobileSessionTerminalTab } from '../../shared/runtime-types' import type { TerminalTab } from '../../shared/terminal-tab-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { resolveTerminalTabTitle } from '../../shared/tab-title-resolution' import { readCanonicalPtyOwners, resolveCanonicalPtyClaim @@ -44,7 +45,8 @@ function withoutAbsorbedLegacyRows( export function buildHeadlessMobileSessionTerminalTabs( worktreeId: string, persistedTabs: readonly TerminalTab[], - session: WorkspaceSessionState + session: WorkspaceSessionState, + generatedTitlesEnabled = false ): RuntimeMobileSessionTerminalTab[] { const { rows, selection } = withoutAbsorbedLegacyRows(worktreeId, persistedTabs, session) return rows @@ -55,14 +57,19 @@ export function buildHeadlessMobileSessionTerminalTabs( if (leafIds.length === 0) { leafIds.push(deriveHeadlessLegacyTerminalLeafId(tab.id)) } + // As the window publishes them: tab-wide names describe a sole pane. + const tabWide = leafIds.length === 1 return leafIds.flatMap((leafId) => { const ptyId = layout?.ptyIdsByLeafId?.[leafId] ?? (leafIds.length === 1 ? tab.ptyId : null) - const title = - tab.customTitle?.trim() || - tab.generatedTitle?.trim() || - tab.title?.trim() || - tab.defaultTitle?.trim() || - `Terminal ${index + 1}` + const fallbackTitle = tab.defaultTitle?.trim() || `Terminal ${index + 1}` + const liveTitle = layout?.titlesByLeafId?.[leafId]?.trim() || tab.title?.trim() + const title = tabWide + ? resolveTerminalTabTitle( + { ...tab, title: liveTitle }, + generatedTitlesEnabled, + fallbackTitle + ) + : tab.customTitle?.trim() || liveTitle || fallbackTitle return [ { type: 'terminal' as const, @@ -71,6 +78,15 @@ export function buildHeadlessMobileSessionTerminalTabs( leafId, title, customTitle: tab.customTitle?.trim() || null, + ...(tabWide && tab.quickCommandLabel?.trim() + ? { quickCommandLabel: tab.quickCommandLabel.trim() } + : {}), + ...(tabWide && tab.aiVaultTitle?.title.trim() + ? { aiVaultTitle: tab.aiVaultTitle } + : {}), + ...(tabWide && generatedTitlesEnabled && tab.generatedTitle?.trim() + ? { generatedTitle: tab.generatedTitle.trim() } + : {}), ...(ptyId ? { ptyId } : {}), ...(tab.startupCwd ? { startupCwd: tab.startupCwd } : {}), ...(tab.launchAgent ? { launchAgent: tab.launchAgent } : {}), diff --git a/src/main/runtime/orca-runtime-desktop-tab-names.test.ts b/src/main/runtime/orca-runtime-desktop-tab-names.test.ts new file mode 100644 index 00000000000..e686ecb030c --- /dev/null +++ b/src/main/runtime/orca-runtime-desktop-tab-names.test.ts @@ -0,0 +1,174 @@ +// A phone shows the name the desktop shows for a tab (#8705, #30538). +import { afterEach, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { Tab } from '../../shared/tab-types' +import type { TerminalTab } from '../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { buildHeadlessMobileSessionTerminalTabs } from './mobile-session-terminal-projection' + +// Fragments stay side-effect ordered: mocks, then lifecycle, then fixtures. +const { OrcaRuntimeService } = await import('./orca-runtime-test-mocks.spec') +await import('./orca-runtime-test-lifecycle.spec') +const { store, TEST_WORKTREE_ID, HEADLESS_LEAF_ID, makeRuntimeStoreWithWorkspaceSession } = + await import('./orca-runtime-test-fixtures.spec') + +afterEach(() => setStructuredAgentSessionHost(null)) + +// #8705: a chat tab renamed on the desktop. +it("publishes the desktop's chat tab name over the saved name, and the saved name once cleared", () => { + let session: WorkspaceSessionState = getDefaultWorkspaceSession() + const listeners = new Set<(hostId: ExecutionHostId | null) => void>() + const runtime = new OrcaRuntimeService({ + ...store, + getWorkspaceSession: () => session, + onWorkspaceSessionWritten: (listener: (hostId: ExecutionHostId | null) => void) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + } + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: title projection reads only the record from this host stub. + setStructuredAgentSessionHost({ + deps: { + store: { + getRecord: () => ({ + location: { workspaceId: TEST_WORKTREE_ID }, + conversationName: 'auth/login' + }) + } + } + } as never) + runtime.projectStructuredAgentSessionTab({ + activate: true, + sessionId: 'session-1', + workspaceId: TEST_WORKTREE_ID, + agent: 'claude' + }) + const titles: string[] = [] + runtime.onMobileSessionTabsChanged((snapshot) => { + const tab = snapshot.tabs.find((candidate) => candidate.type === 'agent-session') + if (tab) { + titles.push(tab.title) + } + }) + const saveChatLabel = (customLabel: string | null): void => { + const chat: Tab = { + id: 'chat-tab', + entityId: 'session-1', + groupId: 'group-1', + worktreeId: TEST_WORKTREE_ID, + contentType: 'agent-session', + label: 'Claude Chat', + customLabel, + color: null, + sortOrder: 0, + createdAt: 0 + } + session = { ...session, unifiedTabs: { [TEST_WORKTREE_ID]: [chat] } } + listeners.forEach((listener) => listener(null)) + } + + saveChatLabel('Release notes') + saveChatLabel(null) + + expect(titles).toEqual(['Release notes', 'auth/login']) +}) + +// #30538: a headless host ranks a saved tab's names as the window does. +async function listRestartedHeadlessCursorTab( + generatedTitlesEnabled: boolean, + liveFrame: string | null +): Promise { + const { runtimeStore, getSession, setSession } = makeRuntimeStoreWithWorkspaceSession( + getDefaultWorkspaceSession() + ) + const runtime = new OrcaRuntimeService({ + ...runtimeStore, + getSettings: () => ({ + ...runtimeStore.getSettings(), + tabAutoGenerateTitle: generatedTitlesEnabled + }) + }) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'cursor-pty' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'cursor-tab', + leafId: HEADLESS_LEAF_ID, + launchAgent: 'cursor' + }) + const session = getSession() + setSession({ + ...session, + tabsByWorktree: { + ...session.tabsByWorktree, + [TEST_WORKTREE_ID]: (session.tabsByWorktree[TEST_WORKTREE_ID] ?? []).map((tab) => + tab.id === 'cursor-tab' ? { ...tab, title: 'bash', generatedTitle: 'Fix tab titles' } : tab + ) + } + }) + // Rebuilt from the saved session, as a restarted headless host does. + runtime.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(TEST_WORKTREE_ID, { force: true }) + if (liveFrame) { + runtime.onPtyData('cursor-pty', `\x1b]0;${liveFrame}\x07`, Date.now()) + } + return (await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs[0] +} + +it("publishes a headless tab's generated name over the agent's live frames", async () => { + expect(await listRestartedHeadlessCursorTab(true, '⠋ Cursor Agent')).toEqual( + expect.objectContaining({ title: 'Fix tab titles' }) + ) +}) + +it('keeps a headless tab on its saved title while generated names are off', async () => { + expect(await listRestartedHeadlessCursorTab(false, null)).toEqual( + expect.objectContaining({ title: 'bash' }) + ) +}) + +const LEAF_A = '11111111-1111-4111-8111-111111111111' +const LEAF_B = '22222222-2222-4222-8222-222222222222' + +it("keeps a headless split's tab-wide names off its panes, as the window does", () => { + const tab: TerminalTab = { + id: 'split-tab', + ptyId: null, + worktreeId: TEST_WORKTREE_ID, + title: 'bash', + quickCommandLabel: 'Run tests', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + const session: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + terminalLayoutsByTabId: { + 'split-tab': { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_A }, + second: { type: 'leaf', leafId: LEAF_B } + }, + activeLeafId: LEAF_A, + expandedLeafId: null, + titlesByLeafId: { [LEAF_A]: 'api-server', [LEAF_B]: 'bash' } + } + } + } + + const rows = buildHeadlessMobileSessionTerminalTabs(TEST_WORKTREE_ID, [tab], session, true) + + expect(rows.map((row) => [row.title, row.quickCommandLabel])).toEqual([ + ['api-server', undefined], + ['bash', undefined] + ]) +}) diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-05.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-05.spec.ts index d610bc4ff24..1fb06e3dce0 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-05.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-05.spec.ts @@ -89,7 +89,7 @@ describe('OrcaRuntimeService', () => { expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.terminalTitle).toBeUndefined() }) - it('publishes a renamed tab over the live agent title, and the live title once cleared', async () => { + it("publishes the desktop's tab name over the live agent title, and the live title without one", async () => { const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ spawn: vi.fn().mockResolvedValue({ id: 'renamed-pty' }), @@ -100,7 +100,10 @@ describe('OrcaRuntimeService', () => { const leafId = HEADLESS_LEAF_ID await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { tabId: 'renamed-tab', leafId }) runtime.attachWindow(1) - const publish = (snapshotVersion: number, customTitle: string | null) => + const publish = ( + snapshotVersion: number, + names: { customTitle: string | null; generatedTitle?: string } + ) => runtime.syncWindowGraph(1, { tabs: [ { @@ -127,20 +130,25 @@ describe('OrcaRuntimeService', () => { parentTabId: 'renamed-tab', leafId, title: '✳ Claude Code', - customTitle, + ...names, isActive: true } ] } ] }) - publish(1, 'Build fixer') + publish(1, { customTitle: 'Build fixer' }) runtime.onPtyData('renamed-pty', '\x1b]0;⠂ Claude Code\x07', 124) const renamed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) expect(renamed.tabs[0]).toEqual(expect.objectContaining({ title: 'Build fixer' })) - publish(2, null) + // #30538: a generated name the desktop shows outranks every OSC frame, as it does there. + publish(2, { customTitle: null, generatedTitle: 'Fix tab titles' }) + const generated = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + expect(generated.tabs[0]).toEqual(expect.objectContaining({ title: 'Fix tab titles' })) + + publish(3, { customTitle: null }) const cleared = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) expect(cleared.tabs[0]).toEqual(expect.objectContaining({ title: '⠂ Claude Code' })) }) diff --git a/src/main/runtime/runtime-mobile-session-projection.ts b/src/main/runtime/runtime-mobile-session-projection.ts index f31994b57b2..4ab04465aaf 100644 --- a/src/main/runtime/runtime-mobile-session-projection.ts +++ b/src/main/runtime/runtime-mobile-session-projection.ts @@ -11,6 +11,7 @@ import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' import { isTerminalLeafId, makePaneKey } from '../../shared/stable-pane-id' +import { resolveTerminalTabTitle } from '../../shared/tab-title-resolution' import { indexAgentStatusRowsByPaneKey } from '../agent-hooks/agent-status-pane-index' import { renewRuntimeMobileAgentStatusFromPtyTitle, @@ -180,10 +181,10 @@ export function projectRuntimeMobileSessionTabs( ownerRecord?.agent ?? liveLeafPty?.foregroundAgent ?? pty?.foregroundAgent ?? null const ownerOptions = { ownerIsLaunch: ownerRecord?.ownerIsLaunch === true } const liveTitle = trackerOnlyTitle ?? leafTitle ?? ptyTitle ?? syncedTab?.title ?? tab.title - // Why: agents repaint their title constantly, so a user's rename must outrank it. - const title = - tab.customTitle?.trim() || - normalizeCompatibleAgentTitleForOwner(liveTitle, ownerAgent, ownerOptions) + const agentTitle = normalizeCompatibleAgentTitleForOwner(liveTitle, ownerAgent, ownerOptions) + // Why: the desktop's ranking over main's fresher live title; the window sends a generated + // name only while the desktop shows it, so it is passed as enabled. + const title = resolveTerminalTabTitle({ ...tab, title: agentTitle }, true, agentTitle) const liveTitleEvidence = leafTitle ?? ptyTitle // Why: renderer status can precede hook session identity, leaving native chat with no transcript address. const rendererStatusAgent = 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 9c5b9e7410a..798684ab2f2 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 @@ -28,8 +28,25 @@ import { retitleStructuredConversationTab, titleStructuredConversationTabs } from '../../structured-conversation-tab-title' +import { structuredChatDisplayName } from '../../../../shared/structured-chat-row-name' export abstract class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends OrcaRuntimeWithScheduleMobileSessionTabsChanged { + constructor( + ...args: ConstructorParameters + ) { + super(...args) + // Why: a desktop rename of a chat tab reaches main only through the window's saved session. + this.store?.onWorkspaceSessionWritten?.(() => { + for (const [workspaceId, snapshot] of this.mobileSessionTabsByWorktree) { + for (const tab of snapshot.tabs) { + if (tab.type === 'agent-session') { + this.refreshStructuredConversationTabTitle(workspaceId, tab.sessionId) + } + } + } + }) + } + protected pruneMobileSessionTabGroupLayout( layout: TabGroupLayoutNode | null | undefined, validGroupIds: ReadonlySet @@ -79,11 +96,9 @@ export abstract class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends Or protected toMobileSessionTabsResult( snapshot: RuntimeMobileSessionTabsSnapshot ): RuntimeMobileSessionTabsResult { - const host = getStructuredAgentSessionHost() - snapshot = titleStructuredConversationTabs(snapshot, (sessionId) => { - const record = host?.deps?.store?.getRecord(sessionId) - return record?.location.workspaceId === snapshot.worktree ? record.conversationName : null - }) + snapshot = titleStructuredConversationTabs(snapshot, (sessionId) => + this.structuredConversationTabName(snapshot.worktree, sessionId) + ) return projectRuntimeMobileSessionTabs(snapshot, this.getMobileSessionProjectionHost()) } @@ -92,14 +107,27 @@ export abstract class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends Or if (!snapshot) { return } - const record = getStructuredAgentSessionHost()?.deps?.store?.getRecord(sessionId) - const name = record?.location.workspaceId === workspaceId ? record.conversationName : null - const next = retitleStructuredConversationTab(snapshot, sessionId, name) + const next = retitleStructuredConversationTab( + snapshot, + sessionId, + this.structuredConversationTabName(workspaceId, sessionId) + ) if (next) { this.emitMobileSessionTabsSnapshot(this.storeMobileSessionSnapshot(workspaceId, next)) } } + /** The desktop tab's rename, else the chat's saved name; null leaves the agent's default label. */ + private structuredConversationTabName(workspaceId: string, sessionId: string): string | null { + const record = getStructuredAgentSessionHost()?.deps?.store?.getRecord(sessionId) + const customLabel = this.getWorkspaceSessionForWorktree(workspaceId)?.unifiedTabs?.[ + workspaceId + ]?.find((tab) => tab.contentType === 'agent-session' && tab.entityId === sessionId)?.customLabel + const conversationName = + record?.location.workspaceId === workspaceId ? record.conversationName : null + return structuredChatDisplayName(customLabel, conversationName, '') || null + } + protected getMobileSessionProjectionHost(): RuntimeMobileSessionProjectionHost { return { tabs: this.tabs, diff --git a/src/main/runtime/session-tabs/mobile-session-hydration.ts b/src/main/runtime/session-tabs/mobile-session-hydration.ts index 56b560eb6e2..8c9006a701d 100644 --- a/src/main/runtime/session-tabs/mobile-session-hydration.ts +++ b/src/main/runtime/session-tabs/mobile-session-hydration.ts @@ -212,7 +212,8 @@ export abstract class OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspa const terminalTabs = buildHeadlessMobileSessionTerminalTabs( entryWorktreeId, persistedTabs, - session + session, + this.store?.getSettings?.()?.tabAutoGenerateTitle === true ).filter( (tab) => !runtimeOwnedOnly || 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 300cc11932c..602d3865e94 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 @@ -110,6 +110,12 @@ export function buildMobileTerminalSurfaceTabs( ...(tabWideFallbackSafe && terminal.quickCommandLabel?.trim() ? { quickCommandLabel: terminal.quickCommandLabel.trim() } : {}), + ...(tabWideFallbackSafe && terminal.aiVaultTitle?.title.trim() + ? { aiVaultTitle: terminal.aiVaultTitle } + : {}), + ...(tabWideFallbackSafe && inputs.generatedTitlesEnabled && terminal.generatedTitle?.trim() + ? { generatedTitle: terminal.generatedTitle.trim() } + : {}), parentTabId: terminal.id, leafId, ptyId, diff --git a/src/shared/runtime-mobile-session-tab-contracts.ts b/src/shared/runtime-mobile-session-tab-contracts.ts index fb28a681b33..afc7f72de5d 100644 --- a/src/shared/runtime-mobile-session-tab-contracts.ts +++ b/src/shared/runtime-mobile-session-tab-contracts.ts @@ -2,7 +2,7 @@ import type { AgentStatusEntry } from './agent-status-types' import type { BrowserCertificateFailure, BrowserLoadError } from './browser-workspace-types' import type { RuntimeBrowserPlacement } from './runtime-browser-placement' import type { TerminalColorOverrides } from './terminal-color-overrides' -import type { TerminalLayoutSnapshot } from './terminal-tab-types' +import type { TerminalLayoutSnapshot, TerminalTab } from './terminal-tab-types' import type { TuiAgent } from './tui-agent' import type { StructuredAgentId } from './agent-session-provider-handle' @@ -14,6 +14,10 @@ export type RuntimeMobileSessionTerminalTab = { * client keeps its own record then rather than reading absence as a cleared name. */ customTitle?: string | null quickCommandLabel?: string | null + /** Window-to-main only: the names the desktop ranks above the live title. `generatedTitle` is + * sent only while the desktop shows generated names. */ + aiVaultTitle?: TerminalTab['aiVaultTitle'] + generatedTitle?: string | null parentTabId: string leafId: string ptyId?: string | null diff --git a/src/shared/tab-title-resolution.ts b/src/shared/tab-title-resolution.ts index ffcab13925d..c7d3653b85e 100644 --- a/src/shared/tab-title-resolution.ts +++ b/src/shared/tab-title-resolution.ts @@ -3,10 +3,9 @@ import type { TerminalTab } from './terminal-tab-types' import { isMeaningfulOpenCodeTerminalTitle } from './opencode-terminal-title' export function resolveTerminalTabTitle( - tab: Pick< - TerminalTab, - 'customTitle' | 'quickCommandLabel' | 'aiVaultTitle' | 'generatedTitle' | 'title' - >, + tab: Pick & { + customTitle?: string | null + }, generatedTitlesEnabled: boolean, fallback = '' ): string { diff --git a/src/shared/workspace-layout/old-client/old-client-session-tabs-projection.ts b/src/shared/workspace-layout/old-client/old-client-session-tabs-projection.ts index 923e04bc9de..8b1eaf2611d 100644 --- a/src/shared/workspace-layout/old-client/old-client-session-tabs-projection.ts +++ b/src/shared/workspace-layout/old-client/old-client-session-tabs-projection.ts @@ -215,6 +215,10 @@ function terminalRows(tab: LayoutTerminalTab, scope: Scope): RuntimeMobileSessio ...(tabWide && tab.quickCommandLabel?.trim() ? { quickCommandLabel: tab.quickCommandLabel.trim() } : {}), + ...(tabWide && tab.aiVaultTitle?.title.trim() ? { aiVaultTitle: tab.aiVaultTitle } : {}), + ...(tabWide && scope.generatedTitlesEnabled && tab.generatedTitle?.trim() + ? { generatedTitle: tab.generatedTitle.trim() } + : {}), parentTabId: tab.entityId, leafId, ...(leaf?.ptyId ? { ptyId: leaf.ptyId } : {}),