fix(mobile): phone terminal and chat tab names match the desktop (#31384)

This commit is contained in:
Kelvin Amoaba
2026-10-21 18:23:12 +00:00
committed by GitHub
parent dde7bf8533
commit ba8f237ade
10 changed files with 271 additions and 30 deletions
@@ -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 } : {}),
@@ -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<unknown> {
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]
])
})
@@ -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' }))
})
@@ -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 =
@@ -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<typeof OrcaRuntimeWithScheduleMobileSessionTabsChanged>
) {
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<string>
@@ -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,
@@ -212,7 +212,8 @@ export abstract class OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspa
const terminalTabs = buildHeadlessMobileSessionTerminalTabs(
entryWorktreeId,
persistedTabs,
session
session,
this.store?.getSettings?.()?.tabAutoGenerateTitle === true
).filter(
(tab) =>
!runtimeOwnedOnly ||
@@ -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,
@@ -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
+3 -4
View File
@@ -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<TerminalTab, 'quickCommandLabel' | 'aiVaultTitle' | 'generatedTitle' | 'title'> & {
customTitle?: string | null
},
generatedTitlesEnabled: boolean,
fallback = ''
): string {
@@ -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 } : {}),