From 3ac40c275443aa9caa40604e52ea5a27940efd78 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:05:17 -0700 Subject: [PATCH] fix(tabs): bound the transcript scan and requeue the newest title frame Cross-referenced against community PR #12319 by @lafraia. - Scan a 256 KB tail first and only widen to a full stream scan when the tail holds no rename record. Measured on real transcripts, Claude re-appends the record most turns (2.8 MB session, 37 records, newest 403 B from EOF), but a rename made once at the top of a long session still has to resolve. - `findClaudeSessionRenameRecord` separates "no record in this range" from "the rename was cleared", so the widening pass cannot resurface a name the agent already removed. - A title frame that arrives while a scan is open is now requeued instead of dropped. Claude stops auto-titling after a rename, so a dropped frame could leave the tab on the generated title with no later change to retry on. Co-authored-by: Orca --- .../agent-session-rename-title-read.test.ts | 15 ++++++- src/main/agent-session-rename-title-read.ts | 29 ++++++++++++-- .../src/lib/agent-session-rename-refresh.ts | 16 +++++++- .../agent-session-rename-tab-title.test.ts | 24 ++++++++++++ src/shared/agent-session-rename-title.test.ts | 18 +++++++++ src/shared/agent-session-rename-title.ts | 39 +++++++++++++------ 6 files changed, 123 insertions(+), 18 deletions(-) diff --git a/src/main/agent-session-rename-title-read.test.ts b/src/main/agent-session-rename-title-read.test.ts index f8d3577ef5e..c9b7fd23395 100644 --- a/src/main/agent-session-rename-title-read.test.ts +++ b/src/main/agent-session-rename-title-read.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -37,6 +37,19 @@ describe('readAgentSessionRenamedTitle', () => { await expect(readAgentSessionRenamedTitle({ transcriptPath })).resolves.toBeNull() }) + it('widens past the tail for a rename that was never re-appended', async () => { + // Why: the scan reads a short tail first because Claude re-appends the + // rename most turns. A session renamed once at the top and never again must + // still resolve, so an empty tail has to widen instead of answering null. + const filler = { type: 'user', message: { role: 'user', content: 'x'.repeat(4096) } } + const transcriptPath = await writeTranscript([ + { type: 'custom-title', customTitle: 'billing-fix' }, + ...Array.from({ length: 80 }, () => filler) + ]) + expect((await stat(transcriptPath)).size).toBeGreaterThan(256 * 1024) + await expect(readAgentSessionRenamedTitle({ transcriptPath })).resolves.toBe('billing-fix') + }) + it('resolves null instead of rejecting when the transcript is gone', async () => { await expect( readAgentSessionRenamedTitle({ transcriptPath: join(tmpdir(), 'orca-missing-session.jsonl') }) diff --git a/src/main/agent-session-rename-title-read.ts b/src/main/agent-session-rename-title-read.ts index dedb7129266..5576e9a7dec 100644 --- a/src/main/agent-session-rename-title-read.ts +++ b/src/main/agent-session-rename-title-read.ts @@ -3,10 +3,17 @@ import { stat } from 'node:fs/promises' import { createInterface } from 'node:readline' import { CLAUDE_CUSTOM_TITLE_RECORD_MARKER, - readClaudeSessionRenamedTitle + 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. +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 @@ -44,12 +51,26 @@ export async function readAgentSessionRenamedTitle( async function readLocalRenamedTitle(transcriptPath: string): Promise { const { size } = await stat(transcriptPath) - const start = Math.max(0, size - MAX_TRANSCRIPT_SCAN_BYTES) + const tailStart = Math.max(0, size - TRANSCRIPT_TAIL_SCAN_BYTES) + const fromTail = await scanTranscriptFrom(transcriptPath, tailStart) + if (fromTail || tailStart === 0) { + return fromTail?.customTitle ?? null + } + const widened = await scanTranscriptFrom( + transcriptPath, + Math.max(0, size - MAX_TRANSCRIPT_SCAN_BYTES) + ) + return widened?.customTitle ?? null +} + +async function scanTranscriptFrom( + transcriptPath: string, + start: number +): Promise { 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)) + return findClaudeSessionRenameRecord(await collectLines(lines)) } finally { stream.destroy() } diff --git a/src/renderer/src/lib/agent-session-rename-refresh.ts b/src/renderer/src/lib/agent-session-rename-refresh.ts index 2992e006159..1a0fce08ce0 100644 --- a/src/renderer/src/lib/agent-session-rename-refresh.ts +++ b/src/renderer/src/lib/agent-session-rename-refresh.ts @@ -3,8 +3,11 @@ import { isAgentRenamedTerminalTitle } from '../../../shared/agent-session-renam // 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. +// 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() export type AgentRenamedTabTitleRefreshArgs = { tabId: string @@ -21,7 +24,11 @@ export function scheduleAgentRenamedTabTitleRefresh(args: AgentRenamedTabTitleRe // 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)) { + if (!getRenamedTitle || args.transcriptPaths.length === 0) { + return + } + if (inFlightTabIds.has(args.tabId)) { + queuedByTabId.set(args.tabId, args) return } inFlightTabIds.add(args.tabId) @@ -42,6 +49,11 @@ export function scheduleAgentRenamedTabTitleRefresh(args: AgentRenamedTabTitleRe // Best-effort: an unreadable transcript leaves the generated title in place. } finally { inFlightTabIds.delete(args.tabId) + const queued = queuedByTabId.get(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 d220b6e3e1e..d938637d18c 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 @@ -106,6 +106,30 @@ describe('deliberate in-agent rename vs generated tab title', () => { ) }) + it('rescans for the newest title of a burst instead of dropping it', async () => { + const store = createTestStore() + const tabId = seedAgentTab(store) + let releaseFirstScan = (): void => {} + const firstScan = new Promise((resolve) => { + releaseFirstScan = () => resolve(null) + }) + getRenamedTitle.mockReturnValueOnce(firstScan).mockResolvedValue('billing-fix') + + // Why: the rename can land while the previous frame's scan is still open. + // Dropping it would leave the tab on the generated title with no later title + // change to trigger a retry — Claude stops auto-titling once renamed. + store.getState().updateTabTitle(tabId, '✳ Answer simple arithmetic question') + store.getState().updateTabTitle(tabId, '✳ billing-fix') + releaseFirstScan() + + await vi.waitFor(() => + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].agentRenamedTitle).toBe('billing-fix') + ) + expect(resolveTerminalTabTitle(store.getState().tabsByWorktree[WORKTREE_ID][0], true)).toBe( + '✳ billing-fix' + ) + }) + it('does not scan transcripts while generated titles are off', async () => { const store = createTestStore() const tabId = seedAgentTab(store) diff --git a/src/shared/agent-session-rename-title.test.ts b/src/shared/agent-session-rename-title.test.ts index eb542bfcfdf..b0bc0cf1851 100644 --- a/src/shared/agent-session-rename-title.test.ts +++ b/src/shared/agent-session-rename-title.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + findClaudeSessionRenameRecord, isAgentRenamedTerminalTitle, readClaudeSessionRenamedTitle } from './agent-session-rename-title' @@ -54,6 +55,23 @@ describe('readClaudeSessionRenamedTitle', () => { }) }) +describe('findClaudeSessionRenameRecord', () => { + it('tells a cleared rename apart from a range that holds no record', () => { + const cleared = JSON.stringify({ type: 'custom-title', customTitle: '' }) + expect(findClaudeSessionRenameRecord([CUSTOM_TITLE_LINE, cleared])).toEqual({ + customTitle: null + }) + expect(findClaudeSessionRenameRecord([AI_TITLE_LINE])).toBeNull() + }) + + it('ignores neighbouring record types that also name the session', () => { + const agentName = JSON.stringify({ type: 'agent-name', agentName: 'billing-fix' }) + const malformedTitle = JSON.stringify({ type: 'custom-title', customTitle: 42 }) + expect(findClaudeSessionRenameRecord([agentName])).toBeNull() + expect(findClaudeSessionRenameRecord([malformedTitle])).toEqual({ customTitle: null }) + }) +}) + describe('isAgentRenamedTerminalTitle', () => { it('matches the live OSC title through the agent status glyph', () => { expect(isAgentRenamedTerminalTitle('✳ billing-fix', 'billing-fix')).toBe(true) diff --git a/src/shared/agent-session-rename-title.ts b/src/shared/agent-session-rename-title.ts index 86241ff3884..6612fe5e76c 100644 --- a/src/shared/agent-session-rename-title.ts +++ b/src/shared/agent-session-rename-title.ts @@ -9,35 +9,52 @@ 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. */ +export type ClaudeSessionRenameRecord = { customTitle: string | null } + /** - * 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. + * The newest `custom-title` record among transcript lines, or null when the + * range holds none. Separating "no record here" from "the name was cleared" + * lets a reader scan a cheap tail first and only widen when nothing was found. */ -export function readClaudeSessionRenamedTitle(lines: Iterable): string | null { - let renamedTitle: string | null = null +export function findClaudeSessionRenameRecord( + lines: Iterable +): ClaudeSessionRenameRecord | null { + let record: ClaudeSessionRenameRecord | 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 + let parsed: unknown try { - record = JSON.parse(line) + // A tail scan starts mid-line; JSON.parse rejects that fragment on its own. + parsed = JSON.parse(line) } catch { continue } - if (!record || typeof record !== 'object') { + if (!parsed || typeof parsed !== 'object') { continue } - const { type, customTitle } = record as { type?: unknown; customTitle?: unknown } + const { type, customTitle } = parsed as { type?: unknown; customTitle?: unknown } if (type !== 'custom-title') { continue } - renamedTitle = typeof customTitle === 'string' ? customTitle.trim() || null : null + record = { customTitle: typeof customTitle === 'string' ? customTitle.trim() || null : null } } - return renamedTitle + 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 } /**