diff --git a/src/main/agent-session-rename-title-read.test.ts b/src/main/agent-session-rename-title-read.test.ts new file mode 100644 index 00000000000..f8d3577ef5e --- /dev/null +++ b/src/main/agent-session-rename-title-read.test.ts @@ -0,0 +1,49 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { readAgentSessionRenamedTitle } from './agent-session-rename-title-read' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +async function writeTranscript(lines: readonly unknown[]): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-agent-rename-')) + tempRoots.push(root) + const transcriptPath = join(root, 'session.jsonl') + await writeFile(transcriptPath, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`) + return transcriptPath +} + +describe('readAgentSessionRenamedTitle', () => { + it('finds the rename recorded after the auto-generated title', async () => { + const transcriptPath = await writeTranscript([ + { type: 'user', message: { role: 'user', content: 'What is 2+2? Answer in one word.' } }, + { type: 'ai-title', aiTitle: 'Answer simple arithmetic question' }, + { type: 'custom-title', customTitle: 'billing-fix' }, + { type: 'agent-name', agentName: 'billing-fix' } + ]) + await expect(readAgentSessionRenamedTitle({ transcriptPath })).resolves.toBe('billing-fix') + }) + + it('resolves null for a session with only auto-generated titles', async () => { + const transcriptPath = await writeTranscript([ + { type: 'ai-title', aiTitle: 'Answer simple arithmetic question' } + ]) + await expect(readAgentSessionRenamedTitle({ transcriptPath })).resolves.toBeNull() + }) + + it('resolves null instead of rejecting when the transcript is gone', async () => { + await expect( + readAgentSessionRenamedTitle({ transcriptPath: join(tmpdir(), 'orca-missing-session.jsonl') }) + ).resolves.toBeNull() + }) + + it('ignores an empty transcript path', async () => { + await expect(readAgentSessionRenamedTitle({ transcriptPath: ' ' })).resolves.toBeNull() + }) +}) diff --git a/src/main/agent-session-rename-title-read.ts b/src/main/agent-session-rename-title-read.ts new file mode 100644 index 00000000000..dedb7129266 --- /dev/null +++ b/src/main/agent-session-rename-title-read.ts @@ -0,0 +1,89 @@ +import { createReadStream } from 'node:fs' +import { stat } from 'node:fs/promises' +import { createInterface } from 'node:readline' +import { + CLAUDE_CUSTOM_TITLE_RECORD_MARKER, + readClaudeSessionRenamedTitle +} from '../shared/agent-session-rename-title' +import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch' + +// Why: a rename record is one short line anywhere in the transcript, so the scan +// streams the file rather than buffering it. Very long sessions are tail-scanned +// from this offset; a rename that old is already superseded by the tab's +// persisted value. +const MAX_TRANSCRIPT_SCAN_BYTES = 64 * 1024 * 1024 +const MAX_REMOTE_TRANSCRIPT_READ_BYTES = 8 * 1024 * 1024 + +export type AgentSessionRenamedTitleArgs = { + transcriptPath: string + /** SSH connection owning the transcript; omitted for local/WSL panes. */ + connectionId?: string +} + +/** + * The deliberate rename (Claude `/rename`) recorded in one agent transcript, or + * null when the session has none. Best-effort: any read failure resolves null so + * the tab keeps its generated title instead of the call rejecting. + */ +export async function readAgentSessionRenamedTitle( + args: AgentSessionRenamedTitleArgs +): Promise { + const transcriptPath = args.transcriptPath.trim() + if (!transcriptPath) { + return null + } + const connectionId = args.connectionId?.trim() + try { + return connectionId + ? await readRemoteRenamedTitle(transcriptPath, connectionId) + : await readLocalRenamedTitle(transcriptPath) + } catch { + return null + } +} + +async function readLocalRenamedTitle(transcriptPath: string): Promise { + const { size } = await stat(transcriptPath) + const start = Math.max(0, size - MAX_TRANSCRIPT_SCAN_BYTES) + const stream = createReadStream(transcriptPath, { encoding: 'utf8', start }) + try { + const lines = createInterface({ input: stream, crlfDelay: Infinity }) + // A tail scan starts mid-line; JSON.parse rejects that fragment on its own. + return readClaudeSessionRenamedTitle(await collectLines(lines)) + } finally { + stream.destroy() + } +} + +async function collectLines(lines: AsyncIterable): Promise { + const collected: string[] = [] + for await (const line of lines) { + if (line.includes(CLAUDE_CUSTOM_TITLE_RECORD_MARKER)) { + collected.push(line) + } + } + return collected +} + +async function readRemoteRenamedTitle( + transcriptPath: string, + connectionId: string +): Promise { + // Why: SSH-hosted agents write the transcript on the remote host, so the local + // filesystem has nothing to scan. No provider (disconnected) degrades to null. + const provider = getSshFilesystemProvider(connectionId) + if (!provider) { + return null + } + // Why: the remote read has no tail seek, so cap it well below the local budget + // rather than pulling a long session over the link for one short record. + const { size } = await provider.stat(transcriptPath) + if (size > MAX_REMOTE_TRANSCRIPT_READ_BYTES) { + return null + } + const { content, isBinary } = await provider.readFile(transcriptPath) + if (isBinary || !content) { + return null + } + return readClaudeSessionRenamedTitle(content.split('\n')) +} diff --git a/src/main/ipc/agent-session-rename.ts b/src/main/ipc/agent-session-rename.ts new file mode 100644 index 00000000000..8d408dfb319 --- /dev/null +++ b/src/main/ipc/agent-session-rename.ts @@ -0,0 +1,27 @@ +import { ipcMain } from 'electron' +import { + readAgentSessionRenamedTitle, + type AgentSessionRenamedTitleArgs +} from '../agent-session-rename-title-read' + +/** + * Why: Claude publishes a deliberate `/rename` and its auto-generated summaries + * on one OSC title channel, so the renderer cannot tell them apart from the live + * title. It asks main to check the session transcript, which records the rename + * as `custom-title`, and stores the answer on the tab. + */ +export function registerAgentSessionRenameHandlers(): void { + ipcMain.removeHandler('agentSession:getRenamedTitle') + ipcMain.handle( + 'agentSession:getRenamedTitle', + async (_event, args?: AgentSessionRenamedTitleArgs): Promise => { + if (!args || typeof args.transcriptPath !== 'string') { + return null + } + return readAgentSessionRenamedTitle({ + transcriptPath: args.transcriptPath, + ...(typeof args.connectionId === 'string' ? { connectionId: args.connectionId } : {}) + }) + } + ) +} diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 707c6fd7c69..b9c6468ac5b 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -36,6 +36,7 @@ const { registerCodexAccountHandlersMock, registerAgentHookHandlersMock, registerAgentTrustHandlersMock, + registerAgentSessionRenameHandlersMock, registerClaudeAccountHandlersMock, registerMiniMaxCredentialsHandlersMock, registerGrokAccountHandlersMock, @@ -101,6 +102,7 @@ const { registerCodexAccountHandlersMock: vi.fn(), registerAgentHookHandlersMock: vi.fn(), registerAgentTrustHandlersMock: vi.fn(), + registerAgentSessionRenameHandlersMock: vi.fn(), registerClaudeAccountHandlersMock: vi.fn(), registerMiniMaxCredentialsHandlersMock: vi.fn(), registerGrokAccountHandlersMock: vi.fn(), @@ -323,6 +325,10 @@ vi.mock('./agent-trust', () => ({ registerAgentTrustHandlers: registerAgentTrustHandlersMock })) +vi.mock('./agent-session-rename', () => ({ + registerAgentSessionRenameHandlers: registerAgentSessionRenameHandlersMock +})) + vi.mock('./claude-accounts', () => ({ registerClaudeAccountHandlers: registerClaudeAccountHandlersMock })) @@ -419,6 +425,7 @@ describe('registerCoreHandlers', () => { registerCodexAccountHandlersMock.mockReset() registerAgentHookHandlersMock.mockReset() registerAgentTrustHandlersMock.mockReset() + registerAgentSessionRenameHandlersMock.mockReset() registerClaudeAccountHandlersMock.mockReset() registerMiniMaxCredentialsHandlersMock.mockReset() registerClipboardHandlersMock.mockReset() diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index ef903dcae05..790ce207947 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -63,6 +63,7 @@ import { registerAgentHookHandlers } from './agent-hooks' import { registerCodexConfigSyncHandlers } from './codex-config-sync' import { getPtyIdForPaneKey } from './pty' import { registerAgentTrustHandlers } from './agent-trust' +import { registerAgentSessionRenameHandlers } from './agent-session-rename' import { registerClaudeAccountHandlers } from './claude-accounts' import { registerMiniMaxCredentialsHandlers } from './minimax-credentials' import { registerGrokAccountHandlers } from './grok-accounts' @@ -148,6 +149,7 @@ export function registerCoreHandlers( registerAgentHookHandlers(runtime, { getPtyIdForPaneKey }) registerCodexConfigSyncHandlers(codexAccounts.runtimeHomeService) registerAgentTrustHandlers() + registerAgentSessionRenameHandlers() registerClaudeAccountHandlers(claudeAccounts) registerMiniMaxCredentialsHandlers(rateLimits) registerGrokAccountHandlers() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 849cfdc9c72..51df142b3e4 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -10,6 +10,7 @@ import { normalizeTerminalTitle } from '../../shared/agent-detection' import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail' +import { isAgentRenamedTerminalTitle } from '../../shared/agent-session-rename-title' import { isServerDriveListRequest, listWindowsDrives } from './windows-drive-listing' import { extractLastOsc7Uri, extractOscScanTail } from '../daemon/osc7-uri-extraction' import { parseFileUriPathParts } from '../daemon/osc7-file-uri' @@ -6707,10 +6708,14 @@ export class OrcaRuntimeService { return leafIds.flatMap((leafId) => { const ptyId = layout?.ptyIdsByLeafId?.[leafId] ?? (leafIds.length === 1 ? tab.ptyId : null) + const liveTitle = tab.title?.trim() ?? '' const title = tab.customTitle?.trim() || + // Why: a deliberate agent `/rename` outranks the first-prompt + // generated title here too, or paired clients keep the stale label. + (isAgentRenamedTerminalTitle(liveTitle, tab.agentRenamedTitle) ? liveTitle : '') || tab.generatedTitle?.trim() || - tab.title?.trim() || + liveTitle || tab.defaultTitle?.trim() || `Terminal ${index + 1}` return [ diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index c0c57731e1e..af7204eaeb9 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2467,6 +2467,13 @@ export type PreloadApi = { connectionId?: string }) => Promise } + agentSession: { + /** Deliberate in-agent rename (Claude `/rename`) recorded in a transcript. */ + getRenamedTitle: (args: { + transcriptPath: string + connectionId?: string + }) => Promise + } preflight: PreflightApi notifications: { dispatch: (args: NotificationDispatchRequest) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index d54af396aca..11e1ea98849 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2139,6 +2139,13 @@ const api = { }): Promise => ipcRenderer.invoke('agentTrust:markTrusted', args) }, + agentSession: { + getRenamedTitle: (args: { + transcriptPath: string + connectionId?: string + }): Promise => ipcRenderer.invoke('agentSession:getRenamedTitle', args) + }, + preflight: { check: (args?: { force?: boolean diff --git a/src/renderer/src/lib/agent-session-rename-refresh.ts b/src/renderer/src/lib/agent-session-rename-refresh.ts new file mode 100644 index 00000000000..2992e006159 --- /dev/null +++ b/src/renderer/src/lib/agent-session-rename-refresh.ts @@ -0,0 +1,47 @@ +import { isAgentRenamedTerminalTitle } from '../../../shared/agent-session-rename-title' + +// Why: a live OSC title change is the only immediate signal that a deliberate +// `/rename` may have happened — the agent fires no hook for it. One refresh per +// tab is in flight at a time so a burst of title frames cannot fan out into +// concurrent transcript scans. +const inFlightTabIds = new Set() + +export type AgentRenamedTabTitleRefreshArgs = { + tabId: string + /** Live title the refresh is explaining; a rename must still match it. */ + liveTitle: string + /** Transcript per pane of the tab — a split tab can run several sessions. */ + transcriptPaths: readonly string[] + connectionId: string | null + apply: (agentRenamedTitle: string | null) => void +} + +export function scheduleAgentRenamedTabTitleRefresh(args: AgentRenamedTabTitleRefreshArgs): void { + // Why: the store also runs in the headless/web harness, where `window` and the + // desktop preload bridge may be absent. + const getRenamedTitle = + typeof window === 'undefined' ? undefined : window.api?.agentSession?.getRenamedTitle + if (!getRenamedTitle || args.transcriptPaths.length === 0 || inFlightTabIds.has(args.tabId)) { + return + } + inFlightTabIds.add(args.tabId) + void (async () => { + try { + for (const transcriptPath of args.transcriptPaths) { + const renamedTitle = await getRenamedTitle({ + transcriptPath, + ...(args.connectionId ? { connectionId: args.connectionId } : {}) + }) + if (isAgentRenamedTerminalTitle(args.liveTitle, renamedTitle)) { + args.apply(renamedTitle) + return + } + } + args.apply(null) + } catch { + // Best-effort: an unreadable transcript leaves the generated title in place. + } finally { + inFlightTabIds.delete(args.tabId) + } + })() +} diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 1966bed1df1..eac67963946 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -514,6 +514,7 @@ function buildRuntimeMobileTabsProjection(tabsByWorktree: AppState['tabsByWorktr title: tab.title, quickCommandLabel: tab.quickCommandLabel, generatedTitle: tab.generatedTitle, + agentRenamedTitle: tab.agentRenamedTitle, customTitle: tab.customTitle, launchAgent: tab.launchAgent })) @@ -532,7 +533,10 @@ function buildRuntimeMobileTabsProjection(tabsByWorktree: AppState['tabsByWorktr } function resolveRuntimeTerminalTitle( - tab: Pick, + tab: Pick< + TerminalTab, + 'customTitle' | 'quickCommandLabel' | 'generatedTitle' | 'title' | 'agentRenamedTitle' + >, generatedTitlesEnabled: boolean, liveTitle = tab.title ): string { diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index d944272accb..3b2b2a87bec 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -652,6 +652,8 @@ function buildMirroredTerminalTabs( ...(quickCommandLabel ? { quickCommandLabel } : {}), ...(startupCwd ? { startupCwd } : {}), customTitle: existing?.customTitle ?? null, + // Why: the agent's deliberate rename is client-derived from the transcript; host snapshots never carry it. + ...(existing?.agentRenamedTitle ? { agentRenamedTitle: existing.agentRenamedTitle } : {}), color, isPinned, ...(viewMode ? { viewMode } : {}), @@ -840,6 +842,7 @@ function buildTerminalUnifiedTab( label: tab.title, ...(tab.quickCommandLabel?.trim() ? { quickCommandLabel: tab.quickCommandLabel.trim() } : {}), ...(tab.generatedTitle?.trim() ? { generatedLabel: tab.generatedTitle.trim() } : {}), + ...(tab.agentRenamedTitle?.trim() ? { agentRenamedLabel: tab.agentRenamedTitle.trim() } : {}), customLabel: tab.customTitle, color: tab.color, sortOrder: tab.sortOrder, @@ -1531,6 +1534,7 @@ function terminalTabEqual(a: TerminalTab, b: TerminalTab): boolean { a.quickCommandLabel === b.quickCommandLabel && a.startupCwd === b.startupCwd && a.generatedTitle === b.generatedTitle && + a.agentRenamedTitle === b.agentRenamedTitle && a.customTitle === b.customTitle && a.color === b.color && a.sortOrder === b.sortOrder && diff --git a/src/renderer/src/store/slices/agent-session-rename-tab-title.test.ts b/src/renderer/src/store/slices/agent-session-rename-tab-title.test.ts new file mode 100644 index 00000000000..d220b6e3e1e --- /dev/null +++ b/src/renderer/src/store/slices/agent-session-rename-tab-title.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { + resolveTerminalTabTitle, + resolveUnifiedTabLabel +} from '../../../../shared/tab-title-resolution' +import { createTestStore, makeWorktree, seedStore } from './store-test-helpers' + +const WORKTREE_ID = 'repo1::/path/wt1' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const TRANSCRIPT_PATH = '/home/dev/.claude/projects/wt1/session.jsonl' + +const getRenamedTitle = vi.fn<(args: { transcriptPath: string }) => Promise>() +const originalWindow = globalThis.window + +beforeEach(() => { + getRenamedTitle.mockReset() + globalThis.window = { api: { agentSession: { getRenamedTitle } } } as never +}) + +afterEach(() => { + globalThis.window = originalWindow +}) + +function seedAgentTab(store: ReturnType): string { + seedStore(store, { + settings: { ...getDefaultSettings('/tmp'), tabAutoGenerateTitle: true }, + worktreesByRepo: { + repo1: [makeWorktree({ id: WORKTREE_ID, repoId: 'repo1', path: '/path/wt1' })] + } + }) + const tabId = store.getState().createTab(WORKTREE_ID).id + const paneKey = makePaneKey(tabId, LEAF_ID) + store.getState().setAgentStatus(paneKey, { + state: 'working', + prompt: 'What is 2+2? Answer in one word.', + agentType: 'claude' + }) + // Why: hook-reported provider sessions only reach the store through the hook + // path; seed it directly so the transcript lookup has a path to scan. + store.setState({ + agentStatusByPaneKey: { + ...store.getState().agentStatusByPaneKey, + [paneKey]: { + ...store.getState().agentStatusByPaneKey[paneKey]!, + providerSession: { key: 'session_id', id: 'session-1', transcriptPath: TRANSCRIPT_PATH } + } + } + }) + return tabId +} + +async function flushRenameRefresh(): Promise { + await vi.waitFor(() => expect(getRenamedTitle).toHaveBeenCalled()) + await Promise.resolve() + await Promise.resolve() +} + +describe('deliberate in-agent rename vs generated tab title', () => { + it('lets a mid-session /rename win the tab label', async () => { + const store = createTestStore() + const tabId = seedAgentTab(store) + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].generatedTitle).toBe('What is 2 2') + + getRenamedTitle.mockResolvedValue('billing-fix') + store.getState().updateTabTitle(tabId, '✳ billing-fix') + await flushRenameRefresh() + + expect(getRenamedTitle).toHaveBeenCalledWith({ transcriptPath: TRANSCRIPT_PATH }) + const tab = store.getState().tabsByWorktree[WORKTREE_ID][0] + expect(resolveTerminalTabTitle(tab, true)).toBe('✳ billing-fix') + expect( + resolveUnifiedTabLabel(store.getState().unifiedTabsByWorktree[WORKTREE_ID][0], true) + ).toBe('✳ billing-fix') + }) + + it('keeps the generated title when the agent only auto-summarized', async () => { + const store = createTestStore() + const tabId = seedAgentTab(store) + + getRenamedTitle.mockResolvedValue(null) + store.getState().updateTabTitle(tabId, '✳ Answer simple arithmetic question') + await flushRenameRefresh() + + const tab = store.getState().tabsByWorktree[WORKTREE_ID][0] + expect(tab.agentRenamedTitle ?? null).toBeNull() + expect(resolveTerminalTabTitle(tab, true)).toBe('What is 2 2') + }) + + it('drops the rename once the agent moves its title elsewhere', async () => { + const store = createTestStore() + const tabId = seedAgentTab(store) + + getRenamedTitle.mockResolvedValue('billing-fix') + store.getState().updateTabTitle(tabId, '✳ billing-fix') + await flushRenameRefresh() + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].agentRenamedTitle).toBe('billing-fix') + + store.getState().updateTabTitle(tabId, '✳ Fix the intake flow') + await vi.waitFor(() => + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].agentRenamedTitle).toBeNull() + ) + expect(resolveTerminalTabTitle(store.getState().tabsByWorktree[WORKTREE_ID][0], true)).toBe( + 'What is 2 2' + ) + }) + + it('does not scan transcripts while generated titles are off', async () => { + const store = createTestStore() + const tabId = seedAgentTab(store) + store.setState({ settings: { ...getDefaultSettings('/tmp'), tabAutoGenerateTitle: false } }) + + store.getState().updateTabTitle(tabId, '✳ billing-fix') + await Promise.resolve() + + expect(getRenamedTitle).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/store/slices/tabs-hydration.ts b/src/renderer/src/store/slices/tabs-hydration.ts index bdf11e82288..8d5c7f1464f 100644 --- a/src/renderer/src/store/slices/tabs-hydration.ts +++ b/src/renderer/src/store/slices/tabs-hydration.ts @@ -219,6 +219,7 @@ function hydrateLegacyFormat( label: tt.title, ...(tt.quickCommandLabel?.trim() ? { quickCommandLabel: tt.quickCommandLabel.trim() } : {}), ...(tt.generatedTitle?.trim() ? { generatedLabel: tt.generatedTitle.trim() } : {}), + ...(tt.agentRenamedTitle?.trim() ? { agentRenamedLabel: tt.agentRenamedTitle.trim() } : {}), customLabel: tt.customTitle, color: tt.color, sortOrder: tt.sortOrder, diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index 5a2456c24c0..9891548a4d7 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -666,6 +666,9 @@ export function projectWorktreeTabModelReconciliation( ? { quickCommandLabel: tab.quickCommandLabel.trim() } : {}), ...(tab.generatedTitle?.trim() ? { generatedLabel: tab.generatedTitle.trim() } : {}), + ...(tab.agentRenamedTitle?.trim() + ? { agentRenamedLabel: tab.agentRenamedTitle.trim() } + : {}), customLabel: tab.customTitle, color: tab.color, sortOrder: tab.sortOrder, diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 8c41cedab6e..332e78bb661 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -29,6 +29,7 @@ import { worktreeWorkspaceKey } from '../../../../shared/workspace-scope' import { deriveGeneratedTabTitle } from '../../../../shared/agent-tab-title' +import { scheduleAgentRenamedTabTitleRefresh } from '@/lib/agent-session-rename-refresh' import { isDecorativeAgentTitleFrameChange } from '../../../../shared/agent-decorative-title-signature' import { isTerminalLeafId, @@ -346,6 +347,25 @@ function updateUnifiedTerminalLabel( return unifiedTabs.map((entry, index) => (index === unifiedIndex ? { ...entry, label } : entry)) } +function updateUnifiedTerminalAgentRenamedLabel( + unifiedTabs: Tab[], + terminalTabId: string, + agentRenamedLabel: string | null +): Tab[] | null { + const unifiedIndex = unifiedTabs.findIndex( + (entry) => entry.contentType === 'terminal' && entry.entityId === terminalTabId + ) + if ( + unifiedIndex === -1 || + (unifiedTabs[unifiedIndex]?.agentRenamedLabel ?? null) === agentRenamedLabel + ) { + return null + } + return unifiedTabs.map((entry, index) => + index === unifiedIndex ? { ...entry, agentRenamedLabel } : entry + ) +} + function updateUnifiedTerminalGeneratedLabel( unifiedTabs: Tab[], terminalTabId: string, @@ -467,6 +487,33 @@ function getRemoteConnectionIdForWorktree( return repo?.connectionId?.trim() || null } +/** + * Ask main whether the tab's new live title is a deliberate in-agent rename. + * Only matters while generated titles are on: with the setting off the live + * title already outranks everything below the manual/quick-command labels. + */ +function requestAgentRenamedTabTitle(get: () => AppState, tabId: string, liveTitle: string): void { + const state = get() + if (state.settings?.tabAutoGenerateTitle !== true) { + return + } + const ownerWorktreeId = getTerminalTabOwnerWorktreeId(state.tabsByWorktree, tabId) + if (!ownerWorktreeId) { + return + } + const transcriptPaths = Object.entries(state.agentStatusByPaneKey).flatMap(([paneKey, entry]) => { + const transcriptPath = entry?.providerSession?.transcriptPath?.trim() + return transcriptPath && getTabIdFromPaneKey(paneKey) === tabId ? [transcriptPath] : [] + }) + scheduleAgentRenamedTabTitleRefresh({ + tabId, + liveTitle, + transcriptPaths, + connectionId: getRemoteConnectionIdForWorktree(state, ownerWorktreeId), + apply: (agentRenamedTitle) => get().setTabAgentRenamedTitle(tabId, agentRenamedTitle) + }) +} + function resolveTerminalStopRuntimeEnvironmentId( state: Pick, worktreeId: string @@ -687,6 +734,8 @@ export type TerminalSlice = { prompt: string, options?: { replaceExistingGeneratedTitle?: boolean } ) => void + /** Record the agent's own deliberate `/rename` so it outranks the generated title. */ + setTabAgentRenamedTitle: (tabId: string, agentRenamedTitle: string | null) => void clearTabLaunchAgent: (tabId: string) => void setRuntimePaneTitle: (tabId: string, paneId: number, title: string) => void clearRuntimePaneTitle: (tabId: string, paneId: number) => void @@ -1982,7 +2031,44 @@ export const createTerminalSlice: StateCreator })) }, + setTabAgentRenamedTitle: (tabId, agentRenamedTitle) => { + set((s) => { + const ownerWorktreeId = getTerminalTabOwnerWorktreeId(s.tabsByWorktree, tabId) + if (!ownerWorktreeId) { + return s + } + const tabs = s.tabsByWorktree[ownerWorktreeId] ?? [] + const currentTab = tabs.find((tab) => tab.id === tabId) + if (!currentTab || (currentTab.agentRenamedTitle ?? null) === agentRenamedTitle) { + return s + } + const unifiedTabsWithRenamedLabel = updateUnifiedTerminalAgentRenamedLabel( + s.unifiedTabsByWorktree[ownerWorktreeId] ?? [], + tabId, + agentRenamedTitle + ) + scheduleRuntimeGraphSync() + return { + tabsByWorktree: { + ...s.tabsByWorktree, + [ownerWorktreeId]: tabs.map((tab) => + tab.id === tabId ? { ...tab, agentRenamedTitle } : tab + ) + }, + ...(unifiedTabsWithRenamedLabel + ? { + unifiedTabsByWorktree: { + ...s.unifiedTabsByWorktree, + [ownerWorktreeId]: unifiedTabsWithRenamedLabel + } + } + : {}) + } + }) + }, + updateTabTitle: (tabId, title) => { + let changedLiveTitle: string | null = null set((s) => { // Why: update only the owner to preserve selector equality for background worktrees. const ownerWorktreeId = getTerminalTabOwnerWorktreeId(s.tabsByWorktree, tabId) @@ -2040,6 +2126,7 @@ export const createTerminalSlice: StateCreator } : tab ) + changedLiveTitle = nextTitle scheduleRuntimeGraphSync() const nextTabsByWorktree = { ...s.tabsByWorktree, [ownerWorktreeId]: ownerTabs } // Why: title changes affect sorting, except active-worktree remount side effects. @@ -2055,6 +2142,9 @@ export const createTerminalSlice: StateCreator } return nextState }) + if (changedLiveTitle) { + requestAgentRenamedTabTitle(get, tabId, changedLiveTitle) + } }, setGeneratedTabTitleFromAgentPrompt: (paneKey, prompt, options) => { diff --git a/src/shared/agent-row-conversation-name.test.ts b/src/shared/agent-row-conversation-name.test.ts index d838ff53dcf..0414a9ea952 100644 --- a/src/shared/agent-row-conversation-name.test.ts +++ b/src/shared/agent-row-conversation-name.test.ts @@ -31,6 +31,24 @@ describe('getAgentRowConversationName', () => { ) }) + it('prefers a deliberate in-agent rename over the generated title', () => { + const tab = makeTab({ + generatedTitle: 'What is 2 2', + title: '✳ billing-fix', + agentRenamedTitle: 'billing-fix' + }) + expect(getAgentRowConversationName(tab, 'claude', true)).toBe('billing-fix') + }) + + it('keeps the generated title ahead of the agent auto-generated summary', () => { + const tab = makeTab({ + generatedTitle: 'What is 2 2', + title: '✳ Answer simple arithmetic question', + agentRenamedTitle: 'billing-fix' + }) + expect(getAgentRowConversationName(tab, 'claude', true)).toBe('What is 2 2') + }) + it('uses the generated title only when generated titles are enabled', () => { const tab = makeTab({ generatedTitle: 'Fix intake flow', title: '✳ Investigate replay bug' }) expect(getAgentRowConversationName(tab, 'claude', true)).toBe('Fix intake flow') diff --git a/src/shared/agent-row-conversation-name.ts b/src/shared/agent-row-conversation-name.ts index 95e5dfcfb28..7ecb15a05fd 100644 --- a/src/shared/agent-row-conversation-name.ts +++ b/src/shared/agent-row-conversation-name.ts @@ -1,11 +1,13 @@ // Resolves the stable "conversation name" an agent row can show instead of the // live last-message preview. Sources, in the same precedence the tab bar uses -// (tab-title-resolution.ts): manual rename → quick-command label → OpenCode's -// semantic session title → Orca's generated title → the agent-set live title. +// (tab-title-resolution.ts): manual rename → quick-command label → the agent's +// own deliberate `/rename` → OpenCode's semantic session title → Orca's +// generated title → the agent-set live title. // Live titles are accepted only when they carry a real name — pure status, // identity-echo, and spinner/cwd titles yield null so callers keep the // last-message label. import type { AgentType } from './agent-status-types' +import { isAgentRenamedTerminalTitle } from './agent-session-rename-title' import { isClaudeManagementTitle } from './agent-title-core' import { stripLeadingAgentTitleDecorationOrEmpty } from './agent-title-decoration' import { formatAgentTypeLabel } from './agent-type-label' @@ -15,7 +17,12 @@ import type { TerminalTab } from './types' export type ConversationNameTab = Pick< TerminalTab, - 'customTitle' | 'quickCommandLabel' | 'generatedTitle' | 'title' | 'defaultTitle' + | 'customTitle' + | 'quickCommandLabel' + | 'generatedTitle' + | 'title' + | 'defaultTitle' + | 'agentRenamedTitle' > // Why: synthetic status titles ("Codex ready", "Cursor - action required") are @@ -124,6 +131,9 @@ export function getAgentRowConversationName( return quickCommandLabel } const liveTitle = tab.title?.trim() ?? '' + if (isAgentRenamedTerminalTitle(liveTitle, tab.agentRenamedTitle)) { + return stripLeadingAgentTitleDecorationOrEmpty(liveTitle) || liveTitle + } if (isMeaningfulOpenCodeTerminalTitle(liveTitle)) { return liveTitle } diff --git a/src/shared/agent-session-rename-title.test.ts b/src/shared/agent-session-rename-title.test.ts new file mode 100644 index 00000000000..eb542bfcfdf --- /dev/null +++ b/src/shared/agent-session-rename-title.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { + isAgentRenamedTerminalTitle, + readClaudeSessionRenamedTitle +} from './agent-session-rename-title' + +// Records copied from a real Claude transcript for a session that answered one +// prompt and was then renamed with `/rename billing-fix`. +const AI_TITLE_LINE = JSON.stringify({ + type: 'ai-title', + aiTitle: 'Answer simple arithmetic question', + sessionId: 'ac1a3f53-82b1-41ab-8db8-028c1fc15c48' +}) +const CUSTOM_TITLE_LINE = JSON.stringify({ + type: 'custom-title', + customTitle: 'billing-fix', + sessionId: 'ac1a3f53-82b1-41ab-8db8-028c1fc15c48' +}) + +describe('readClaudeSessionRenamedTitle', () => { + it('reads the deliberate rename and ignores auto-generated titles', () => { + expect(readClaudeSessionRenamedTitle([AI_TITLE_LINE, CUSTOM_TITLE_LINE])).toBe('billing-fix') + }) + + it('returns null for a session that was never renamed', () => { + expect(readClaudeSessionRenamedTitle([AI_TITLE_LINE])).toBeNull() + }) + + it('keeps the newest rename', () => { + const laterRename = JSON.stringify({ type: 'custom-title', customTitle: 'intake-flow' }) + expect(readClaudeSessionRenamedTitle([CUSTOM_TITLE_LINE, AI_TITLE_LINE, laterRename])).toBe( + 'intake-flow' + ) + }) + + it('treats a cleared rename as no rename', () => { + const cleared = JSON.stringify({ type: 'custom-title', customTitle: ' ' }) + expect(readClaudeSessionRenamedTitle([CUSTOM_TITLE_LINE, cleared])).toBeNull() + }) + + it('skips truncated and unrelated lines', () => { + const truncated = CUSTOM_TITLE_LINE.slice(10) + expect(readClaudeSessionRenamedTitle([truncated, 'not json', '', CUSTOM_TITLE_LINE])).toBe( + 'billing-fix' + ) + }) + + it('ignores a message that merely quotes the record type', () => { + const userTurn = JSON.stringify({ + type: 'user', + message: { role: 'user', content: 'what does "custom-title" mean?' } + }) + expect(readClaudeSessionRenamedTitle([userTurn])).toBeNull() + }) +}) + +describe('isAgentRenamedTerminalTitle', () => { + it('matches the live OSC title through the agent status glyph', () => { + expect(isAgentRenamedTerminalTitle('✳ billing-fix', 'billing-fix')).toBe(true) + expect(isAgentRenamedTerminalTitle('⠂ billing-fix', 'billing-fix')).toBe(true) + expect(isAgentRenamedTerminalTitle('billing-fix', 'billing-fix')).toBe(true) + }) + + it('stops matching once the agent moves its title elsewhere', () => { + expect(isAgentRenamedTerminalTitle('✳ Fix the intake flow', 'billing-fix')).toBe(false) + }) + + it('is false without a recorded rename', () => { + expect(isAgentRenamedTerminalTitle('✳ billing-fix', null)).toBe(false) + expect(isAgentRenamedTerminalTitle('', 'billing-fix')).toBe(false) + }) +}) diff --git a/src/shared/agent-session-rename-title.ts b/src/shared/agent-session-rename-title.ts new file mode 100644 index 00000000000..86241ff3884 --- /dev/null +++ b/src/shared/agent-session-rename-title.ts @@ -0,0 +1,60 @@ +// Why: Claude publishes a deliberate `/rename` and its auto-generated task +// summaries on the same OSC title channel, so the live title alone cannot say +// which one arrived. Only the transcript separates them — `custom-title` for +// the rename, `ai-title` for the summary. Orca records the latest deliberate +// rename on the tab so title resolution can rank it above the first-prompt +// generated title, the same rung `isMeaningfulOpenCodeTerminalTitle` occupies. +import { stripLeadingAgentTitleDecorationOrEmpty } from './agent-title-decoration' + +/** Cheap prefilter for transcript readers before they buffer or parse a line. */ +export const CLAUDE_CUSTOM_TITLE_RECORD_MARKER = '"custom-title"' + +/** + * The session's current deliberate rename from Claude transcript lines, or null + * when none survives. Later records win; an empty `customTitle` is Claude + * clearing the name, which must fall back to Orca's generated title. + */ +export function readClaudeSessionRenamedTitle(lines: Iterable): string | null { + let renamedTitle: string | null = null + for (const line of lines) { + // Why: transcripts are mostly large message records; skip the JSON parse + // unless the line can carry the marker. + if (!line.includes(CLAUDE_CUSTOM_TITLE_RECORD_MARKER)) { + continue + } + let record: unknown + try { + record = JSON.parse(line) + } catch { + continue + } + if (!record || typeof record !== 'object') { + continue + } + const { type, customTitle } = record as { type?: unknown; customTitle?: unknown } + if (type !== 'custom-title') { + continue + } + renamedTitle = typeof customTitle === 'string' ? customTitle.trim() || null : null + } + return renamedTitle +} + +/** + * True when the pane's live OSC title still carries the recorded deliberate + * rename. Matching (rather than substituting the recorded name) keeps the label + * self-correcting: once the agent moves the title elsewhere, the rename stops + * outranking the generated title. + */ +export function isAgentRenamedTerminalTitle( + liveTitle: string | null | undefined, + agentRenamedTitle: string | null | undefined +): boolean { + const renamedTitle = agentRenamedTitle?.trim() + const title = liveTitle?.trim() + if (!renamedTitle || !title) { + return false + } + // Why: the live title carries the agent's status glyph ("✳ billing-fix"). + return title === renamedTitle || stripLeadingAgentTitleDecorationOrEmpty(title) === renamedTitle +} diff --git a/src/shared/tab-title-resolution.test.ts b/src/shared/tab-title-resolution.test.ts index 82e4a086245..3d0bacfca64 100644 --- a/src/shared/tab-title-resolution.test.ts +++ b/src/shared/tab-title-resolution.test.ts @@ -48,6 +48,61 @@ describe('tab title resolution', () => { ).toBe('Refactor auth') }) + it('lets a deliberate in-agent rename win after the first prompt', () => { + expect( + resolveTerminalTabTitle( + { + customTitle: null, + generatedTitle: 'What is 2 2', + title: '✳ billing-fix', + agentRenamedTitle: 'billing-fix' + }, + true + ) + ).toBe('✳ billing-fix') + }) + + it('keeps generated titles ahead of the agent auto-generated summary', () => { + expect( + resolveTerminalTabTitle( + { + customTitle: null, + generatedTitle: 'What is 2 2', + title: '✳ Answer simple arithmetic question', + agentRenamedTitle: 'billing-fix' + }, + true + ) + ).toBe('What is 2 2') + }) + + it('keeps manual and quick command titles ahead of an in-agent rename', () => { + expect( + resolveTerminalTabTitle( + { + customTitle: 'Payments', + quickCommandLabel: 'Run tests', + generatedTitle: 'What is 2 2', + title: '✳ billing-fix', + agentRenamedTitle: 'billing-fix' + }, + true + ) + ).toBe('Payments') + expect( + resolveTerminalTabTitle( + { + customTitle: null, + quickCommandLabel: 'Run tests', + generatedTitle: 'What is 2 2', + title: '✳ billing-fix', + agentRenamedTitle: 'billing-fix' + }, + true + ) + ).toBe('Run tests') + }) + it('places quick command labels between manual and generated titles', () => { expect( resolveTerminalTabTitle( @@ -109,6 +164,31 @@ describe('tab title resolution', () => { ).toBe('OC | Native Stable Session') }) + it('lets a deliberate in-agent rename win for unified labels', () => { + expect( + resolveUnifiedTabLabel( + { + customLabel: null, + generatedLabel: 'What is 2 2', + label: '✳ billing-fix', + agentRenamedLabel: 'billing-fix' + }, + true + ) + ).toBe('✳ billing-fix') + expect( + resolveUnifiedTabLabel( + { + customLabel: null, + generatedLabel: 'What is 2 2', + label: '✳ Answer simple arithmetic question', + agentRenamedLabel: 'billing-fix' + }, + true + ) + ).toBe('What is 2 2') + }) + it('keeps manual and quick command labels ahead of native OpenCode labels', () => { expect( resolveUnifiedTabLabel( diff --git a/src/shared/tab-title-resolution.ts b/src/shared/tab-title-resolution.ts index 6f1d51042ce..28792bbacef 100644 --- a/src/shared/tab-title-resolution.ts +++ b/src/shared/tab-title-resolution.ts @@ -1,8 +1,12 @@ import type { Tab, TerminalTab } from './types' +import { isAgentRenamedTerminalTitle } from './agent-session-rename-title' import { isMeaningfulOpenCodeTerminalTitle } from './opencode-terminal-title' export function resolveTerminalTabTitle( - tab: Pick, + tab: Pick< + TerminalTab, + 'customTitle' | 'quickCommandLabel' | 'generatedTitle' | 'title' | 'agentRenamedTitle' + >, generatedTitlesEnabled: boolean, fallback = '' ): string { @@ -10,6 +14,7 @@ export function resolveTerminalTabTitle( return ( tab.customTitle?.trim() || tab.quickCommandLabel?.trim() || + (isAgentRenamedTerminalTitle(liveTitle, tab.agentRenamedTitle) ? liveTitle : '') || (isMeaningfulOpenCodeTerminalTitle(liveTitle) ? liveTitle : '') || (generatedTitlesEnabled ? tab.generatedTitle?.trim() : '') || liveTitle || @@ -18,7 +23,12 @@ export function resolveTerminalTabTitle( } export function resolveUnifiedTabLabel( - tab: Pick | undefined, + tab: + | Pick< + Tab, + 'customLabel' | 'quickCommandLabel' | 'generatedLabel' | 'label' | 'agentRenamedLabel' + > + | undefined, generatedTitlesEnabled: boolean, fallback = '' ): string { @@ -26,6 +36,7 @@ export function resolveUnifiedTabLabel( return ( tab?.customLabel?.trim() || tab?.quickCommandLabel?.trim() || + (isAgentRenamedTerminalTitle(liveLabel, tab?.agentRenamedLabel) ? liveLabel : '') || (isMeaningfulOpenCodeTerminalTitle(liveLabel) ? liveLabel : '') || (generatedTitlesEnabled ? tab?.generatedLabel?.trim() : '') || liveLabel || diff --git a/src/shared/types.ts b/src/shared/types.ts index 1ba5aeb7919..b6f09c24de5 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -840,6 +840,8 @@ export type Tab = { label: string // display title (auto-derived from PTY or filename) generatedLabel?: string | null quickCommandLabel?: string | null + /** Mirrors `TerminalTab.agentRenamedTitle` for unified-tab label resolution. */ + agentRenamedLabel?: string | null customLabel: string | null color: string | null sortOrder: number @@ -881,6 +883,11 @@ export type TerminalTab = { generatedTitle?: string | null /** Stable label from the tab-bar Quick Command that created this terminal. */ quickCommandLabel?: string | null + /** Latest name the agent's own rename command set for this session (Claude's + * `/rename`). Distinct from `customTitle`, which is the user renaming the tab + * inside Orca: this one only outranks `generatedTitle` while the agent's live + * title still carries it. See agent-session-rename-title.ts. */ + agentRenamedTitle?: string | null customTitle: string | null color: string | null /** Pinned tabs survive "close others"; host-persisted for remote servers. */ diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index da5fe4ef08e..3e07f5063e2 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -73,6 +73,7 @@ const terminalTabSchema = z.object({ defaultTitle: z.string().optional(), generatedTitle: z.string().nullable().optional(), quickCommandLabel: z.string().nullable().optional(), + agentRenamedTitle: z.string().nullable().optional(), customTitle: z.string().nullable(), color: z.string().nullable(), isPinned: z.boolean().optional(), @@ -113,6 +114,7 @@ const tabSchema = z.object({ label: z.string(), generatedLabel: z.string().nullable().optional(), quickCommandLabel: z.string().nullable().optional(), + agentRenamedLabel: z.string().nullable().optional(), customLabel: z.string().nullable(), color: z.string().nullable(), sortOrder: z.number(),