fix(tabs): let a deliberate agent /rename win the tab label mid-session

Claude publishes both its `/rename` and its auto-generated task summaries on
one OSC title channel, so tab-title-resolution ranked Orca's first-prompt
generatedTitle above the live title — right for the summaries, wrong for a
deliberate rename. Only the transcript separates them: `custom-title` for the
rename, `ai-title` for the summary, and `/rename` fires no agent hook, so the
live title change is the only immediate trigger.

On a real title change for a tab with an agent session, main now scans that
session's transcript for the newest `custom-title` and the renderer records it
as `TerminalTab.agentRenamedTitle` (distinct from Orca's own `customTitle`).
The label ranks it on the same rung as the OpenCode native-title carve-out —
above generatedTitle, below the manual and quick-command labels — and only
while the live title still carries it, so an auto summary or a later revision
falls back to the generated title. A cleared rename clears the field.

All ranking sites move together: resolveTerminalTabTitle,
resolveUnifiedTabLabel, the dashboard agent rows, and the headless mobile
session tabs, which had no carve-out at all and kept the stale label on paired
clients.

SSH panes read the transcript through the pane's remote filesystem provider;
any unreadable transcript degrades to today's behavior.
This commit is contained in:
Neil
2026-08-04 00:55:05 -07:00
parent 29bfc1e22c
commit eb3a9265ee
23 changed files with 728 additions and 7 deletions
@@ -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<string> {
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()
})
})
@@ -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<string | null> {
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<string | null> {
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<string>): Promise<string[]> {
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<string | null> {
// 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'))
}
+27
View File
@@ -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<string | null> => {
if (!args || typeof args.transcriptPath !== 'string') {
return null
}
return readAgentSessionRenamedTitle({
transcriptPath: args.transcriptPath,
...(typeof args.connectionId === 'string' ? { connectionId: args.connectionId } : {})
})
}
)
}
@@ -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()
+2
View File
@@ -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()
+6 -1
View File
@@ -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 [
+7
View File
@@ -2467,6 +2467,13 @@ export type PreloadApi = {
connectionId?: string
}) => Promise<void>
}
agentSession: {
/** Deliberate in-agent rename (Claude `/rename`) recorded in a transcript. */
getRenamedTitle: (args: {
transcriptPath: string
connectionId?: string
}) => Promise<string | null>
}
preflight: PreflightApi
notifications: {
dispatch: (args: NotificationDispatchRequest) => Promise<NotificationDispatchResult>
+7
View File
@@ -2139,6 +2139,13 @@ const api = {
}): Promise<void> => ipcRenderer.invoke('agentTrust:markTrusted', args)
},
agentSession: {
getRenamedTitle: (args: {
transcriptPath: string
connectionId?: string
}): Promise<string | null> => ipcRenderer.invoke('agentSession:getRenamedTitle', args)
},
preflight: {
check: (args?: {
force?: boolean
@@ -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<string>()
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)
}
})()
}
@@ -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<TerminalTab, 'customTitle' | 'quickCommandLabel' | 'generatedTitle' | 'title'>,
tab: Pick<
TerminalTab,
'customTitle' | 'quickCommandLabel' | 'generatedTitle' | 'title' | 'agentRenamedTitle'
>,
generatedTitlesEnabled: boolean,
liveTitle = tab.title
): string {
@@ -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 &&
@@ -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<string | null>>()
const originalWindow = globalThis.window
beforeEach(() => {
getRenamedTitle.mockReset()
globalThis.window = { api: { agentSession: { getRenamedTitle } } } as never
})
afterEach(() => {
globalThis.window = originalWindow
})
function seedAgentTab(store: ReturnType<typeof createTestStore>): 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<void> {
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()
})
})
@@ -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,
+3
View File
@@ -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,
@@ -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<AppState, 'repos' | 'settings' | 'worktreesByRepo'>,
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<AppState, [], [], TerminalSlice>
}))
},
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<AppState, [], [], TerminalSlice>
}
: 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<AppState, [], [], TerminalSlice>
}
return nextState
})
if (changedLiveTitle) {
requestAgentRenamedTabTitle(get, tabId, changedLiveTitle)
}
},
setGeneratedTabTitleFromAgentPrompt: (paneKey, prompt, options) => {
@@ -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')
+13 -3
View File
@@ -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
}
@@ -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)
})
})
+60
View File
@@ -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>): 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
}
+80
View File
@@ -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(
+13 -2
View File
@@ -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<TerminalTab, 'customTitle' | 'quickCommandLabel' | 'generatedTitle' | 'title'>,
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<Tab, 'customLabel' | 'quickCommandLabel' | 'generatedLabel' | 'label'> | 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 ||
+7
View File
@@ -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. */
+2
View File
@@ -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(),