diff --git a/src/main/agent-session-rename-title-read.test.ts b/src/main/agent-session-rename-title-read.test.ts index c9b7fd23395..80f4e1753ab 100644 --- a/src/main/agent-session-rename-title-read.test.ts +++ b/src/main/agent-session-rename-title-read.test.ts @@ -59,4 +59,8 @@ describe('readAgentSessionRenamedTitle', () => { it('ignores an empty transcript path', async () => { await expect(readAgentSessionRenamedTitle({ transcriptPath: ' ' })).resolves.toBeNull() }) + + it('resolves null for an ill-typed transcript path instead of rejecting', async () => { + await expect(readAgentSessionRenamedTitle({ transcriptPath: 7 as never })).resolves.toBeNull() + }) }) diff --git a/src/main/agent-session-rename-title-read.ts b/src/main/agent-session-rename-title-read.ts index 5576e9a7dec..1eabfd44606 100644 --- a/src/main/agent-session-rename-title-read.ts +++ b/src/main/agent-session-rename-title-read.ts @@ -4,46 +4,32 @@ import { createInterface } from 'node:readline' import { CLAUDE_CUSTOM_TITLE_RECORD_MARKER, findClaudeSessionRenameRecord, - readClaudeSessionRenamedTitle, type ClaudeSessionRenameRecord } from '../shared/agent-session-rename-title' -import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch' // Why: Claude re-appends the rename record on most turns, so measured against // real transcripts (2.8 MB / 37 records, newest 403 B from EOF) a short tail -// answers nearly every scan. A rename that is never re-appended still exists -// only once, near the top, so an empty tail widens to a full scan. +// answers nearly every scan. A rename that is never re-appended exists only +// once, near the top, so an empty tail widens. const TRANSCRIPT_TAIL_SCAN_BYTES = 256 * 1024 -// 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. +// Why: the widened scan streams rather than buffers, but cap how far back it +// starts — 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 -} +/** Local transcripts only — the renderer skips panes hosted over SSH. */ +export type AgentSessionRenamedTitleArgs = { transcriptPath: 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. + * null when the session has none. Best-effort: any read failure — including an + * ill-typed IPC payload — resolves null so the tab keeps its generated title. */ 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) + const transcriptPath = args.transcriptPath.trim() + return transcriptPath ? await readLocalRenamedTitle(transcriptPath) : null } catch { return null } @@ -69,42 +55,16 @@ async function scanTranscriptFrom( ): Promise { const stream = createReadStream(transcriptPath, { encoding: 'utf8', start }) try { - const lines = createInterface({ input: stream, crlfDelay: Infinity }) - return findClaudeSessionRenameRecord(await collectLines(lines)) + // Why: transcripts are mostly long message records — keep only the lines + // that can carry a rename instead of buffering the file. + const candidates: string[] = [] + for await (const line of createInterface({ input: stream, crlfDelay: Infinity })) { + if (line.includes(CLAUDE_CUSTOM_TITLE_RECORD_MARKER)) { + candidates.push(line) + } + } + return findClaudeSessionRenameRecord(candidates) } 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 index 8d408dfb319..a329b0161f2 100644 --- a/src/main/ipc/agent-session-rename.ts +++ b/src/main/ipc/agent-session-rename.ts @@ -14,14 +14,7 @@ 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 } : {}) - }) - } + async (_event, args?: AgentSessionRenamedTitleArgs): Promise => + typeof args?.transcriptPath === 'string' ? readAgentSessionRenamedTitle(args) : null ) } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index af7204eaeb9..878efd4aa1a 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2468,11 +2468,8 @@ export type PreloadApi = { }) => Promise } agentSession: { - /** Deliberate in-agent rename (Claude `/rename`) recorded in a transcript. */ - getRenamedTitle: (args: { - transcriptPath: string - connectionId?: string - }) => Promise + /** Deliberate in-agent rename (Claude `/rename`) recorded in a local transcript. */ + getRenamedTitle: (args: { transcriptPath: string }) => Promise } preflight: PreflightApi notifications: { diff --git a/src/preload/index.ts b/src/preload/index.ts index 11e1ea98849..60c5d268d8f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2140,10 +2140,8 @@ const api = { }, agentSession: { - getRenamedTitle: (args: { - transcriptPath: string - connectionId?: string - }): Promise => ipcRenderer.invoke('agentSession:getRenamedTitle', args) + getRenamedTitle: (args: { transcriptPath: string }): Promise => + ipcRenderer.invoke('agentSession:getRenamedTitle', args) }, preflight: { diff --git a/src/renderer/src/lib/agent-session-rename-refresh.ts b/src/renderer/src/lib/agent-session-rename-refresh.ts index 1a0fce08ce0..b17e3a3099e 100644 --- a/src/renderer/src/lib/agent-session-rename-refresh.ts +++ b/src/renderer/src/lib/agent-session-rename-refresh.ts @@ -1,13 +1,11 @@ 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; the newest frame of the burst is queued rather -// than dropped, or the tab would keep a rename verdict for a title it no longer -// shows until some later title change happens to arrive. -const inFlightTabIds = new Set() -const queuedByTabId = new Map() +// `/rename` may have happened — the agent fires no hook for it. Keyed entry +// means a scan is open for that tab; its value is the newest frame waiting +// behind it. Queuing rather than dropping matters because Claude stops +// auto-titling after a rename, so a dropped frame has no later trigger. +const openRefreshByTabId = new Map() export type AgentRenamedTabTitleRefreshArgs = { tabId: string @@ -15,7 +13,6 @@ export type AgentRenamedTabTitleRefreshArgs = { 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 } @@ -27,18 +24,15 @@ export function scheduleAgentRenamedTabTitleRefresh(args: AgentRenamedTabTitleRe if (!getRenamedTitle || args.transcriptPaths.length === 0) { return } - if (inFlightTabIds.has(args.tabId)) { - queuedByTabId.set(args.tabId, args) + if (openRefreshByTabId.has(args.tabId)) { + openRefreshByTabId.set(args.tabId, args) return } - inFlightTabIds.add(args.tabId) + openRefreshByTabId.set(args.tabId, null) void (async () => { try { for (const transcriptPath of args.transcriptPaths) { - const renamedTitle = await getRenamedTitle({ - transcriptPath, - ...(args.connectionId ? { connectionId: args.connectionId } : {}) - }) + const renamedTitle = await getRenamedTitle({ transcriptPath }) if (isAgentRenamedTerminalTitle(args.liveTitle, renamedTitle)) { args.apply(renamedTitle) return @@ -48,10 +42,9 @@ export function scheduleAgentRenamedTabTitleRefresh(args: AgentRenamedTabTitleRe } catch { // Best-effort: an unreadable transcript leaves the generated title in place. } finally { - inFlightTabIds.delete(args.tabId) - const queued = queuedByTabId.get(args.tabId) + const queued = openRefreshByTabId.get(args.tabId) + openRefreshByTabId.delete(args.tabId) if (queued) { - queuedByTabId.delete(args.tabId) scheduleAgentRenamedTabTitleRefresh(queued) } } 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 index d938637d18c..006411f07cf 100644 --- 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 @@ -5,7 +5,7 @@ import { resolveTerminalTabTitle, resolveUnifiedTabLabel } from '../../../../shared/tab-title-resolution' -import { createTestStore, makeWorktree, seedStore } from './store-test-helpers' +import { createTestStore, makeWorktree, seedStore, TEST_REPO } from './store-test-helpers' const WORKTREE_ID = 'repo1::/path/wt1' const LEAF_ID = '11111111-1111-4111-8111-111111111111' @@ -130,6 +130,17 @@ describe('deliberate in-agent rename vs generated tab title', () => { ) }) + it('does not scan an SSH-hosted pane, whose transcript lives on the remote host', async () => { + const store = createTestStore() + const tabId = seedAgentTab(store) + store.setState({ repos: [{ ...TEST_REPO, connectionId: 'ssh-1' }] }) + + store.getState().updateTabTitle(tabId, '✳ billing-fix') + await Promise.resolve() + + expect(getRenamedTitle).not.toHaveBeenCalled() + }) + it('does not scan transcripts while generated titles are off', async () => { const store = createTestStore() const tabId = seedAgentTab(store) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 332e78bb661..bac6e78f2a9 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -333,52 +333,19 @@ function getTerminalTabOwnerWorktreeId( return terminalTabOwnerCache.get(tabId) ?? null } -function updateUnifiedTerminalLabel( - unifiedTabs: Tab[], - terminalTabId: string, - label: string -): Tab[] | null { +/** Null when the terminal has no unified tab or the field already holds `value`. */ +function updateUnifiedTerminalLabelField< + K extends 'label' | 'generatedLabel' | 'agentRenamedLabel' +>(unifiedTabs: Tab[], terminalTabId: string, field: K, value: Tab[K]): Tab[] | null { const unifiedIndex = unifiedTabs.findIndex( (entry) => entry.contentType === 'terminal' && entry.entityId === terminalTabId ) - if (unifiedIndex === -1 || unifiedTabs[unifiedIndex]?.label === label) { - return null - } - 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 - ) { + const current = unifiedTabs[unifiedIndex] + if (!current || (current[field] ?? null) === (value ?? null)) { return null } return unifiedTabs.map((entry, index) => - index === unifiedIndex ? { ...entry, agentRenamedLabel } : entry - ) -} - -function updateUnifiedTerminalGeneratedLabel( - unifiedTabs: Tab[], - terminalTabId: string, - generatedLabel: string -): Tab[] | null { - const unifiedIndex = unifiedTabs.findIndex( - (entry) => entry.contentType === 'terminal' && entry.entityId === terminalTabId - ) - if (unifiedIndex === -1 || unifiedTabs[unifiedIndex]?.generatedLabel === generatedLabel) { - return null - } - return unifiedTabs.map((entry, index) => - index === unifiedIndex ? { ...entry, generatedLabel } : entry + index === unifiedIndex ? { ...entry, [field]: value } : entry ) } @@ -501,6 +468,13 @@ function requestAgentRenamedTabTitle(get: () => AppState, tabId: string, liveTit if (!ownerWorktreeId) { return } + // Why: an SSH-hosted agent writes its transcript on the remote host, and the + // remote filesystem contract has no ranged read — scanning it would pull the + // whole transcript over the link on every title frame. Skip instead, and never + // read the remote path locally where it could resolve to an unrelated file. + if (getRemoteConnectionIdForWorktree(state, ownerWorktreeId)) { + return + } const transcriptPaths = Object.entries(state.agentStatusByPaneKey).flatMap(([paneKey, entry]) => { const transcriptPath = entry?.providerSession?.transcriptPath?.trim() return transcriptPath && getTabIdFromPaneKey(paneKey) === tabId ? [transcriptPath] : [] @@ -509,7 +483,6 @@ function requestAgentRenamedTabTitle(get: () => AppState, tabId: string, liveTit tabId, liveTitle, transcriptPaths, - connectionId: getRemoteConnectionIdForWorktree(state, ownerWorktreeId), apply: (agentRenamedTitle) => get().setTabAgentRenamedTitle(tabId, agentRenamedTitle) }) } @@ -2042,9 +2015,10 @@ export const createTerminalSlice: StateCreator if (!currentTab || (currentTab.agentRenamedTitle ?? null) === agentRenamedTitle) { return s } - const unifiedTabsWithRenamedLabel = updateUnifiedTerminalAgentRenamedLabel( + const unifiedTabsWithRenamedLabel = updateUnifiedTerminalLabelField( s.unifiedTabsByWorktree[ownerWorktreeId] ?? [], tabId, + 'agentRenamedLabel', agentRenamedTitle ) scheduleRuntimeGraphSync() @@ -2084,9 +2058,10 @@ export const createTerminalSlice: StateCreator const nextTitle = title.trim() || getFallbackTabTitle(currentTab) const currentUnifiedTabs = s.unifiedTabsByWorktree[ownerWorktreeId] ?? [] if (isDecorativeAgentTitleFrameChange(currentTab.title, nextTitle)) { - const unifiedTabsWithCurrentLabel = updateUnifiedTerminalLabel( + const unifiedTabsWithCurrentLabel = updateUnifiedTerminalLabelField( currentUnifiedTabs, tabId, + 'label', currentTab.title ) return unifiedTabsWithCurrentLabel @@ -2098,9 +2073,10 @@ export const createTerminalSlice: StateCreator } : s } - const unifiedTabsWithUpdatedLabel = updateUnifiedTerminalLabel( + const unifiedTabsWithUpdatedLabel = updateUnifiedTerminalLabelField( currentUnifiedTabs, tabId, + 'label', nextTitle ) if (currentTab.title === nextTitle) { @@ -2200,9 +2176,10 @@ export const createTerminalSlice: StateCreator tab.id === tabId ? { ...tab, generatedTitle } : tab ) const currentUnifiedTabs = s.unifiedTabsByWorktree[ownerWorktreeId] ?? [] - const unifiedTabsWithGeneratedLabel = updateUnifiedTerminalGeneratedLabel( + const unifiedTabsWithGeneratedLabel = updateUnifiedTerminalLabelField( currentUnifiedTabs, tabId, + 'generatedLabel', generatedTitle ) scheduleRuntimeGraphSync() diff --git a/src/shared/agent-row-conversation-name.ts b/src/shared/agent-row-conversation-name.ts index 7ecb15a05fd..eb4eb23a3f6 100644 --- a/src/shared/agent-row-conversation-name.ts +++ b/src/shared/agent-row-conversation-name.ts @@ -9,7 +9,10 @@ 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 { + stripLeadingAgentTitleDecoration, + stripLeadingAgentTitleDecorationOrEmpty +} from './agent-title-decoration' import { formatAgentTypeLabel } from './agent-type-label' import { isMeaningfulOpenCodeTerminalTitle } from './opencode-terminal-title' import { SYNTHETIC_AGENT_TITLE_PROFILES } from './synthetic-agent-title' @@ -132,7 +135,7 @@ export function getAgentRowConversationName( } const liveTitle = tab.title?.trim() ?? '' if (isAgentRenamedTerminalTitle(liveTitle, tab.agentRenamedTitle)) { - return stripLeadingAgentTitleDecorationOrEmpty(liveTitle) || liveTitle + return stripLeadingAgentTitleDecoration(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 index b0bc0cf1851..4d3990bebbf 100644 --- a/src/shared/agent-session-rename-title.test.ts +++ b/src/shared/agent-session-rename-title.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from 'vitest' import { findClaudeSessionRenameRecord, - isAgentRenamedTerminalTitle, - readClaudeSessionRenamedTitle + isAgentRenamedTerminalTitle } from './agent-session-rename-title' +const readClaudeSessionRenamedTitle = (lines: Iterable): string | null => + findClaudeSessionRenameRecord(lines)?.customTitle ?? null + // 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({ @@ -18,7 +20,7 @@ const CUSTOM_TITLE_LINE = JSON.stringify({ sessionId: 'ac1a3f53-82b1-41ab-8db8-028c1fc15c48' }) -describe('readClaudeSessionRenamedTitle', () => { +describe('the rename a Claude transcript currently records', () => { it('reads the deliberate rename and ignores auto-generated titles', () => { expect(readClaudeSessionRenamedTitle([AI_TITLE_LINE, CUSTOM_TITLE_LINE])).toBe('billing-fix') }) diff --git a/src/shared/agent-session-rename-title.ts b/src/shared/agent-session-rename-title.ts index 6612fe5e76c..c081c8f9113 100644 --- a/src/shared/agent-session-rename-title.ts +++ b/src/shared/agent-session-rename-title.ts @@ -9,9 +9,8 @@ import { stripLeadingAgentTitleDecorationOrEmpty } from './agent-title-decoratio /** Cheap prefilter for transcript readers before they buffer or parse a line. */ export const CLAUDE_CUSTOM_TITLE_RECORD_MARKER = '"custom-title"' -/** The newest rename record in a range of transcript lines. `customTitle` is - * null when the agent cleared the name — still a record, so callers must not - * keep searching earlier ranges for an older one. */ +/** `customTitle` is null when the agent cleared the name — still a record, so + * callers must not keep searching earlier ranges for an older one. */ export type ClaudeSessionRenameRecord = { customTitle: string | null } /** @@ -36,27 +35,14 @@ export function findClaudeSessionRenameRecord( } catch { continue } - if (!parsed || typeof parsed !== 'object') { - continue + const { type, customTitle } = (parsed ?? {}) as { type?: unknown; customTitle?: unknown } + if (type === 'custom-title') { + record = { customTitle: typeof customTitle === 'string' ? customTitle.trim() || null : null } } - const { type, customTitle } = parsed as { type?: unknown; customTitle?: unknown } - if (type !== 'custom-title') { - continue - } - record = { customTitle: typeof customTitle === 'string' ? customTitle.trim() || null : null } } return record } -/** - * 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 { - return findClaudeSessionRenameRecord(lines)?.customTitle ?? null -} - /** * True when the pane's live OSC title still carries the recorded deliberate * rename. Matching (rather than substituting the recorded name) keeps the label