From 166d0ce7843f33d355caddf9c49473d9d1133446 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Wed, 1 Jul 2026 00:36:15 +0200 Subject: [PATCH] feat(resume): group branched sessions in picker (#1824) * feat(resume): group branched sessions in picker * test(resume): stabilize branch metadata fixtures * fix(resume): keep branch base titles searchable * test(resume): release picker lock on setup failure * fix(resume): count expanded branch rows for load more * fix(resume): keep branch metadata reads bounded * test(resume): assert hidden branch log is loaded --- src/commands/branch/branch.test.ts | 4 + src/commands/branch/branch.ts | 3 +- .../LogSelector.resumeBranches.test.ts | 425 ++++++++++++++++++ src/components/LogSelector.tsx | 246 ++++++---- src/screens/ResumeConversation.test.ts | 65 +++ src/screens/ResumeConversation.tsx | 27 +- src/screens/resumeFilters.ts | 36 ++ src/utils/sessionStorage.test.ts | 252 ++++++++++- src/utils/sessionStorage.ts | 96 +++- 9 files changed, 1047 insertions(+), 107 deletions(-) create mode 100644 src/components/LogSelector.resumeBranches.test.ts create mode 100644 src/screens/ResumeConversation.test.ts create mode 100644 src/screens/resumeFilters.ts diff --git a/src/commands/branch/branch.test.ts b/src/commands/branch/branch.test.ts index 2ab25b508..63644af07 100644 --- a/src/commands/branch/branch.test.ts +++ b/src/commands/branch/branch.test.ts @@ -465,6 +465,10 @@ test('/branch creates a new session, copies messages, keeps the source transcrip expect(forkPath).toBeString() expect((await stat(forkPath!)).mode & 0o777).toBe(0o600) const entries = await readEntries(forkPath!) + expect(entries[0]).toMatchObject({ + type: 'session-branch', + sessionId: newSessionId, + }) const persistedMessages = entries.filter( entry => entry.type === 'user' || entry.type === 'assistant', ) diff --git a/src/commands/branch/branch.ts b/src/commands/branch/branch.ts index 26c28a019..78da53b3b 100644 --- a/src/commands/branch/branch.ts +++ b/src/commands/branch/branch.ts @@ -354,6 +354,7 @@ async function createFork( ? { branchedAtMessageId: branchedAtMessage.uuid } : {}), } + lines.unshift(jsonStringify(branchMetadata)) // Append content-replacement entry (if any) with the fork's sessionId. // Written as a SINGLE entry (same shape as insertContentReplacement) so @@ -367,8 +368,6 @@ async function createFork( lines.push(jsonStringify(forkedReplacementEntry)) } - lines.push(jsonStringify(branchMetadata)) - // Write the fork session file await writeFile(forkSessionPath, lines.join('\n') + '\n', { encoding: 'utf8', diff --git a/src/components/LogSelector.resumeBranches.test.ts b/src/components/LogSelector.resumeBranches.test.ts new file mode 100644 index 000000000..9a9170095 --- /dev/null +++ b/src/components/LogSelector.resumeBranches.test.ts @@ -0,0 +1,425 @@ +import { PassThrough } from 'node:stream' + +import { expect, test } from 'bun:test' +import type { UUID } from 'node:crypto' +import React from 'react' +import { stripVTControlCharacters as stripAnsi } from 'node:util' + +import { getOriginalCwd } from '../bootstrap/state.js' +import { createRoot } from '../ink.js' +import instances from '../ink/instances.js' +import type { ParsedKey } from '../ink/parse-keypress.js' +import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js' +import { AppStateProvider } from '../state/AppState.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' +import type { LogOption, SessionBranchEntry } from '../types/logs.js' +import { + LogSelector, + countVisibleResumeTreeRows, + getResumeLogDisplayTitle, + groupLogsByResumeBranch, + logMatchesResumePickerSearch, + shouldLoadMoreResumeLogs, +} from './LogSelector.js' + +const ts = '2026-06-30T00:00:00.000Z' +const SYNC_START = '\x1B[?2026h' +const SYNC_END = '\x1B[?2026l' + +function id(n: number): UUID { + return `00000000-0000-4000-8000-${String(n).padStart(12, '0')}` as UUID +} + +function branchMeta( + sessionId: UUID, + parentSessionId: UUID, + rootSessionId: UUID, + branchName: string, +): SessionBranchEntry { + return { + type: 'session-branch', + sessionId, + parentSessionId, + rootSessionId, + branchedFromSessionId: parentSessionId, + branchName, + branchedAt: ts, + } +} + +function log( + sessionId: UUID, + title: string, + modifiedOffset: number, + options: Partial = {}, +): LogOption { + const modified = new Date(Date.parse(ts) + modifiedOffset) + return { + date: modified.toISOString(), + messages: [], + fullPath: `/tmp/${sessionId}.jsonl`, + value: modifiedOffset, + created: new Date(ts), + modified, + firstPrompt: title, + messageCount: 1, + isSidechain: false, + sessionId, + ...options, + } +} + +function extractLastFrame(output: string): string { + let lastFrame: string | null = null + let cursor = 0 + while (cursor < output.length) { + const start = output.indexOf(SYNC_START, cursor) + if (start === -1) break + const contentStart = start + SYNC_START.length + const end = output.indexOf(SYNC_END, contentStart) + if (end === -1) break + const frame = output.slice(contentStart, end) + if (frame.trim().length > 0) lastFrame = frame + cursor = end + SYNC_END.length + } + return lastFrame ?? output +} + +function createTestStreams(): { + stdout: PassThrough + stdin: PassThrough & { + isTTY: boolean + setRawMode: (mode: boolean) => void + ref: () => void + unref: () => void + } + getOutput: () => string +} { + let output = '' + const stdout = new PassThrough() + const stdin = new PassThrough() as PassThrough & { + isTTY: boolean + setRawMode: (mode: boolean) => void + ref: () => void + unref: () => void + } + stdin.isTTY = true + stdin.setRawMode = () => {} + stdin.ref = () => {} + stdin.unref = () => {} + ;(stdout as unknown as { columns: number }).columns = 120 + stdout.on('data', chunk => { + output += chunk.toString() + }) + return { stdout, stdin, getOutput: () => output } +} + +function dispatchKeyboard( + stdout: PassThrough, + key: Pick, +): void { + const instance = instances.get(stdout as unknown as NodeJS.WriteStream) as + | { dispatchKeyboardEvent: (parsedKey: ParsedKey) => void } + | undefined + if (!instance) { + throw new Error('Ink instance not found') + } + instance.dispatchKeyboardEvent({ + kind: 'key', + fn: false, + ctrl: false, + meta: false, + shift: false, + option: false, + super: false, + isPasted: false, + ...key, + }) +} + +async function waitForFrame( + getOutput: () => string, + predicate: (frame: string) => boolean, +): Promise { + const startedAt = Date.now() + let frame = '' + while (Date.now() - startedAt < 2500) { + frame = stripAnsi(extractLastFrame(getOutput())) + if (predicate(frame)) return frame + await Bun.sleep(10) + } + throw new Error(`Timed out waiting for LogSelector output:\n${frame}`) +} + +test('groups root sessions with their branches without moving the group behind newer branches', () => { + const rootId = id(1) + const branchAId = id(2) + const branchBId = id(3) + const soloId = id(4) + const root = log(rootId, 'Root planning session', 10, { + customTitle: 'Root planning session', + }) + const branchA = log(branchAId, 'Copied root prompt', 40, { + sessionBranch: branchMeta(branchAId, rootId, rootId, 'Branch A'), + }) + const branchB = log(branchBId, 'Copied root prompt', 100, { + sessionBranch: branchMeta(branchBId, rootId, rootId, 'Branch B'), + }) + const solo = log(soloId, 'Unrelated session', 80, { + customTitle: 'Unrelated session', + }) + + const groups = groupLogsByResumeBranch([branchB, solo, root, branchA]) + + expect(groups.map(group => group.headerLog.sessionId)).toEqual([ + rootId, + soloId, + ]) + expect(groups[0]?.childLogs.map(child => child.sessionId)).toEqual([ + branchBId, + branchAId, + ]) + expect(groups[0]?.firstIndex).toBe(0) + expect(groups[1]?.childLogs).toEqual([]) +}) + +test('shows branches with missing parents as standalone sessions', () => { + const missingRootId = id(20) + const missingParentId = id(21) + const branchId = id(22) + const branch = log(branchId, 'Copied missing parent prompt', 10, { + sessionBranch: branchMeta( + branchId, + missingParentId, + missingRootId, + 'Detached branch', + ), + }) + + const groups = groupLogsByResumeBranch([branch]) + + expect(groups).toHaveLength(1) + expect(groups[0]?.headerLog.sessionId).toBe(branchId) + expect(groups[0]?.childLogs).toEqual([]) +}) + +test('search and display include branch names and session titles', () => { + const rootId = id(30) + const branchId = id(31) + const root = log(rootId, 'Investigate OAuth callback', 10, { + customTitle: 'OAuth callback fix', + }) + const branch = log(branchId, 'Copied root prompt', 20, { + sessionBranch: branchMeta( + branchId, + rootId, + rootId, + 'Retry token exchange', + ), + }) + + expect(getResumeLogDisplayTitle(branch)).toBe('Retry token exchange') + expect(logMatchesResumePickerSearch(branch, 'token exchange')).toBe(true) + expect(logMatchesResumePickerSearch(branch, 'copied root prompt')).toBe(true) + expect(logMatchesResumePickerSearch(root, 'callback fix')).toBe(true) +}) + +test('requests more logs when grouped branch rows underfill the visible picker', () => { + expect( + shouldLoadMoreResumeLogs({ + displayedLogCount: 50, + focusedIndex: 1, + visibleCount: 10, + visibleNodeCount: 1, + }), + ).toBe(true) + expect( + shouldLoadMoreResumeLogs({ + displayedLogCount: 50, + focusedIndex: 1, + visibleCount: 10, + visibleNodeCount: 10, + }), + ).toBe(false) + expect( + shouldLoadMoreResumeLogs({ + displayedLogCount: 50, + focusedIndex: 35, + visibleCount: 10, + visibleNodeCount: 10, + }), + ).toBe(true) +}) + +test('counts expanded branch rows before requesting more logs', () => { + const rootId = id(50) + const visibleCount = 5 + const treeNodes = [ + { + id: `group:${rootId}`, + value: null, + label: 'Root implementation session', + children: [1, 2, 3, 4].map(index => ({ + id: `log:${rootId}:${index}`, + value: null, + label: `Branch ${index}`, + children: + index === 4 + ? [ + { + id: `log:${rootId}:${index}:1`, + value: null, + label: `Nested branch ${index}`, + }, + ] + : undefined, + })), + }, + ] + const collapsedCount = countVisibleResumeTreeRows(treeNodes, { + expandedGroupSessionIds: new Set(), + forceExpanded: false, + }) + const manuallyExpandedCount = countVisibleResumeTreeRows(treeNodes, { + expandedGroupSessionIds: new Set([rootId]), + forceExpanded: false, + }) + const forcedExpandedCount = countVisibleResumeTreeRows(treeNodes, { + expandedGroupSessionIds: new Set(), + forceExpanded: true, + }) + + expect(collapsedCount).toBe(1) + expect(manuallyExpandedCount).toBe(visibleCount) + expect(forcedExpandedCount).toBe(visibleCount + 1) + expect( + shouldLoadMoreResumeLogs({ + displayedLogCount: 50, + focusedIndex: 0, + visibleCount, + visibleNodeCount: collapsedCount, + }), + ).toBe(true) + expect( + shouldLoadMoreResumeLogs({ + displayedLogCount: 50, + focusedIndex: 0, + visibleCount, + visibleNodeCount: forcedExpandedCount, + }), + ).toBe(false) +}) + +test('rendered picker expands branch groups and selects child branch logs', async () => { + await acquireSharedMutationLock( + 'components/LogSelector.resumeBranches.test.tsx', + ) + let rootRenderer: Awaited> | null = null + const rootId = id(40) + const branchId = id(41) + const projectPath = getOriginalCwd() + const root = log(rootId, 'Root implementation session', 10, { + customTitle: 'Root implementation session', + projectPath, + }) + const branch = log(branchId, 'Branch copied prompt', 20, { + projectPath, + sessionBranch: branchMeta( + branchId, + rootId, + rootId, + 'Branch implementation session', + ), + }) + const selected: LogOption[] = [] + const { stdout, stdin, getOutput } = createTestStreams() + + try { + rootRenderer = await createRoot({ + stdout: stdout as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + patchConsole: false, + }) + + rootRenderer.render( + React.createElement( + AppStateProvider, + null, + React.createElement( + KeybindingSetup, + null, + React.createElement(LogSelector, { + logs: [branch, root], + maxHeight: 30, + forceWidth: 100, + onSelect: selectedLog => { + selected.push(selectedLog) + }, + }), + ), + ), + ) + + await waitForFrame( + getOutput, + frame => + frame.includes('Root implementation session') && + frame.includes('(+1 other session)') && + !frame.includes('Branch implementation session'), + ) + await Bun.sleep(50) + + dispatchKeyboard(stdout, { + name: 'right', + sequence: '\x1B[C', + raw: '\x1B[C', + }) + await waitForFrame( + getOutput, + frame => + frame.includes('Root implementation session') && + frame.includes('Branch implementation session'), + ) + + dispatchKeyboard(stdout, { + name: 'left', + sequence: '\x1B[D', + raw: '\x1B[D', + }) + await waitForFrame( + getOutput, + frame => + frame.includes('Root implementation session') && + !frame.includes('Branch implementation session'), + ) + + dispatchKeyboard(stdout, { + name: 'right', + sequence: '\x1B[C', + raw: '\x1B[C', + }) + await waitForFrame( + getOutput, + frame => + frame.includes('Root implementation session') && + frame.includes('Branch implementation session'), + ) + + stdin.write('2') + + const startedAt = Date.now() + while (Date.now() - startedAt < 2500 && selected.length === 0) { + await Bun.sleep(10) + } + expect(selected.map(selectedLog => selectedLog.sessionId)).toEqual([ + branchId, + ]) + } finally { + rootRenderer?.unmount() + stdin.end() + releaseSharedMutationLock() + } +}) diff --git a/src/components/LogSelector.tsx b/src/components/LogSelector.tsx index 1f0c5561f..9e29b3210 100644 --- a/src/components/LogSelector.tsx +++ b/src/components/LogSelector.tsx @@ -59,6 +59,13 @@ type LogTreeNode = TreeNode<{ log: LogOption; indexInFiltered: number; }>; +export type ResumeLogGroup = { + id: string; + headerLog: LogOption; + childLogs: LogOption[]; + logs: LogOption[]; + firstIndex: number; +}; type ViewMode = 'list' | 'preview' | 'rename' | 'search'; type DeepSearchResult = { log: LogOption; @@ -141,7 +148,7 @@ function buildLogLabel(log: LogOption, maxLabelWidth: number, options?: { const sessionCountSuffix = isGroupHeader && forkCount > 0 ? ` (+${forkCount} other ${forkCount === 1 ? 'session' : 'sessions'})` : ''; const sidechainSuffix = log.isSidechain ? ' (sidechain)' : ''; const maxSummaryWidth = maxLabelWidth - prefixWidth - sidechainSuffix.length - sessionCountSuffix.length; - const truncatedSummary = normalizeAndTruncateToWidth(getLogDisplayTitle(log), maxSummaryWidth); + const truncatedSummary = normalizeAndTruncateToWidth(getResumeLogDisplayTitle(log), maxSummaryWidth); return `${truncatedSummary}${sidechainSuffix}${sessionCountSuffix}`; } function buildLogMetadata(log: LogOption, options?: { @@ -158,6 +165,72 @@ function buildLogMetadata(log: LogOption, options?: { const projectSuffix = showProjectPath && log.projectPath ? ` ยท ${log.projectPath}` : ''; return childPadding + baseMetadata + projectSuffix; } +export function getResumeLogDisplayTitle(log: LogOption): string { + const branchName = log.sessionBranch?.branchName?.trim() + if (branchName) { + const sessionTitle = log.agentName || log.customTitle + if (sessionTitle) return getLogDisplayTitle(log) + return branchName + } + return getLogDisplayTitle(log) +} +export function logMatchesResumePickerSearch(log: LogOption, rawQuery: string): boolean { + const query = rawQuery.trim().toLowerCase() + if (!query) return true + const displayedTitle = getResumeLogDisplayTitle(log).toLowerCase() + const baseDisplayTitle = getLogDisplayTitle(log).toLowerCase() + const branchName = (log.sessionBranch?.branchName || "").toLowerCase() + const branch = (log.gitBranch || "").toLowerCase() + const tag = (log.tag || "").toLowerCase() + const prInfo = log.prNumber ? `pr #${log.prNumber} ${log.prRepository || ""}`.toLowerCase() : "" + return displayedTitle.includes(query) || baseDisplayTitle.includes(query) || branchName.includes(query) || branch.includes(query) || tag.includes(query) || prInfo.includes(query) +} +export function shouldLoadMoreResumeLogs(options: { + displayedLogCount: number; + focusedIndex: number; + visibleCount: number; + visibleNodeCount: number; +}): boolean { + const { + displayedLogCount, + focusedIndex, + visibleCount, + visibleNodeCount + } = options + const buffer = visibleCount * 2 + return visibleNodeCount < visibleCount || focusedIndex + buffer >= displayedLogCount +} +export function countVisibleResumeTreeRows( + nodes: readonly TreeNode[], + options: { + expandedGroupSessionIds: ReadonlySet; + forceExpanded: boolean; + }, +): number { + const { expandedGroupSessionIds, forceExpanded } = options + const isExpanded = (nodeId: string | number): boolean => { + if (forceExpanded) return true + const groupSessionId = typeof nodeId === "string" && nodeId.startsWith("group:") ? nodeId.slice(6) : null + return groupSessionId ? expandedGroupSessionIds.has(groupSessionId) : false + } + const countNode = (node: TreeNode): number => { + const children = node.children ?? [] + if (children.length === 0 || !isExpanded(node.id)) return 1 + return 1 + children.reduce((count, child) => count + countNode(child), 0) + } + + return nodes.reduce((count, node) => count + countNode(node), 0) +} +function findContainingGroupNode(nodes: LogTreeNode[], nodeId?: string | number): LogTreeNode | null { + if (!nodeId) return null + for (const node of nodes) { + if (node.id === nodeId) return node + if (node.children?.some(child => child.id === nodeId)) { + return node + } + } + return null +} export function LogSelector(t0: LogSelectorProps) { const $ = _c(247); const { @@ -444,14 +517,7 @@ export function LogSelector(t0: LogSelectorProps) { } let t23; if ($[39] !== baseFilteredLogs || $[40] !== searchQuery) { - const query = searchQuery.toLowerCase(); - t23 = baseFilteredLogs.filter(log_5 => { - const displayedTitle = getLogDisplayTitle(log_5).toLowerCase(); - const branch_0 = (log_5.gitBranch || "").toLowerCase(); - const tag = (log_5.tag || "").toLowerCase(); - const prInfo = log_5.prNumber ? `pr #${log_5.prNumber} ${log_5.prRepository || ""}`.toLowerCase() : ""; - return displayedTitle.includes(query) || branch_0.includes(query) || tag.includes(query) || prInfo.includes(query); - }); + t23 = baseFilteredLogs.filter(log_5 => logMatchesResumePickerSearch(log_5, searchQuery)); $[39] = baseFilteredLogs; $[40] = searchQuery; $[41] = t23; @@ -596,19 +662,18 @@ export function LogSelector(t0: LogSelectorProps) { } let t30; if ($[66] !== displayedLogs || $[67] !== highlightColor || $[68] !== maxLabelWidth || $[69] !== showAllProjects || $[70] !== snippets) { - const sessionGroups = groupLogsBySessionId(displayedLogs); - t30 = Array.from(sessionGroups.entries()).map(t31 => { - const [sessionId, groupLogs] = t31; - const latestLog = groupLogs[0]; - const indexInFiltered = displayedLogs.indexOf(latestLog); + const sessionGroups = groupLogsByResumeBranch(displayedLogs); + t30 = sessionGroups.map(group => { + const latestLog = group.headerLog; + const indexInFiltered = group.firstIndex; const snippet_0 = snippets.get(latestLog); const snippetStr = snippet_0 ? formatSnippet(snippet_0, highlightColor) : null; - if (groupLogs.length === 1) { + if (group.childLogs.length === 0) { const metadata = buildLogMetadata(latestLog, { showProjectPath: showAllProjects }); return { - id: `log:${sessionId}:0`, + id: `log:${group.id}:0`, value: { log: latestLog, indexInFiltered @@ -618,8 +683,8 @@ export function LogSelector(t0: LogSelectorProps) { dimDescription: true }; } - const forkCount = groupLogs.length - 1; - const children = groupLogs.slice(1).map((log_8, index) => { + const forkCount = group.childLogs.length; + const children = group.childLogs.map((log_8, index) => { const childIndexInFiltered = displayedLogs.indexOf(log_8); const childSnippet = snippets.get(log_8); const childSnippetStr = childSnippet ? formatSnippet(childSnippet, highlightColor) : null; @@ -628,7 +693,7 @@ export function LogSelector(t0: LogSelectorProps) { showProjectPath: showAllProjects }); return { - id: `log:${sessionId}:${index + 1}`, + id: `log:${group.id}:${index + 1}`, value: { log: log_8, indexInFiltered: childIndexInFiltered @@ -644,7 +709,7 @@ export function LogSelector(t0: LogSelectorProps) { showProjectPath: showAllProjects }); return { - id: `group:${sessionId}`, + id: `group:${group.id}`, value: { log: latestLog, indexInFiltered @@ -688,7 +753,7 @@ export function LogSelector(t0: LogSelectorProps) { let t32; if ($[79] !== highlightColor || $[80] !== maxLabelWidth || $[81] !== showAllProjects || $[82] !== snippets) { t32 = (log_9, index_0) => { - const rawSummary = getLogDisplayTitle(log_9); + const rawSummary = getResumeLogDisplayTitle(log_9); const summaryWithSidechain = rawSummary + (log_9.isSidechain ? " (sidechain)" : ""); const summary = normalizeAndTruncateToWidth(summaryWithSidechain, maxLabelWidth); const baseDescription = formatLogMetadata(log_9); @@ -725,30 +790,26 @@ export function LogSelector(t0: LogSelectorProps) { const flatOptions = t30; const focusedLog = focusedNode?.value.log ?? null; let t31; - if ($[84] !== displayedLogs || $[85] !== expandedGroupSessionIds || $[86] !== focusedLog) { + if ($[84] !== treeNodes || $[85] !== expandedGroupSessionIds || $[86] !== focusedNode?.id) { t31 = () => { - if (!isResumeWithRenameEnabled || !focusedLog) { + if (!isResumeWithRenameEnabled || !focusedNode) { return ""; } - const sessionId_0 = getSessionIdFromLog(focusedLog); - if (!sessionId_0) { + const groupNode = findContainingGroupNode(treeNodes, focusedNode.id); + if (!groupNode?.children?.length || !String(groupNode.id).startsWith("group:")) { return ""; } - const sessionLogs = displayedLogs.filter(log_10 => getSessionIdFromLog(log_10) === sessionId_0); - const hasMultipleLogs = sessionLogs.length > 1; - if (!hasMultipleLogs) { - return ""; - } - const isExpanded = expandedGroupSessionIds.has(sessionId_0); - const isChildNode = sessionLogs.indexOf(focusedLog) > 0; + const groupId = String(groupNode.id).substring(6); + const isExpanded = expandedGroupSessionIds.has(groupId); + const isChildNode = focusedNode.id !== groupNode.id; if (isChildNode) { return "\u2190 to collapse"; } return isExpanded ? "\u2190 to collapse" : "\u2192 to expand"; }; - $[84] = displayedLogs; + $[84] = treeNodes; $[85] = expandedGroupSessionIds; - $[86] = focusedLog; + $[86] = focusedNode?.id; $[87] = t31; } else { t31 = $[87]; @@ -973,10 +1034,7 @@ export function LogSelector(t0: LogSelectorProps) { if ($[118] !== displayedLogs) { t43 = (node: LogTreeNode) => { setFocusedNode(node); - const index_2 = displayedLogs.findIndex(log_12 => getSessionIdFromLog(log_12) === getSessionIdFromLog(node.value.log)); - if (index_2 >= 0) { - setFocusedIndex(index_2 + 1); - } + setFocusedIndex(node.value.indexInFiltered + 1); }; $[118] = displayedLogs; $[119] = t43; @@ -1218,30 +1276,23 @@ export function LogSelector(t0: LogSelectorProps) { const showAdditionalFilterLine = filterIndicators.length > 0 && viewMode !== "search"; const headerLines = 8 + (showAdditionalFilterLine ? 1 : 0) + tagTabsLines; const visibleCount = Math.max(1, Math.floor((maxHeight - headerLines - 2) / 3)); - let t55; - let t56; - if ($[154] !== displayedLogs.length || $[155] !== focusedIndex || $[156] !== onLoadMore || $[157] !== visibleCount) { - t55 = () => { - if (!onLoadMore) { - return; - } - const buffer = visibleCount * 2; - if (focusedIndex + buffer >= displayedLogs.length) { - onLoadMore(visibleCount * 3); - } - }; - t56 = [focusedIndex, visibleCount, displayedLogs.length, onLoadMore]; - $[154] = displayedLogs.length; - $[155] = focusedIndex; - $[156] = onLoadMore; - $[157] = visibleCount; - $[158] = t55; - $[159] = t56; - } else { - t55 = $[158]; - t56 = $[159]; - } - React.useEffect(t55, t56); + const loadMoreVisibleCount = isResumeWithRenameEnabled ? countVisibleResumeTreeRows(treeNodes, { + expandedGroupSessionIds, + forceExpanded: viewMode === "search" || branchFilterEnabled + }) : displayedLogs.length; + React.useEffect(() => { + if (!onLoadMore) { + return; + } + if (shouldLoadMoreResumeLogs({ + displayedLogCount: displayedLogs.length, + focusedIndex, + visibleCount, + visibleNodeCount: loadMoreVisibleCount + })) { + onLoadMore(visibleCount * 3); + } + }, [displayedLogs.length, focusedIndex, loadMoreVisibleCount, onLoadMore, visibleCount]); if (logs.length === 0) { return null; } @@ -1375,7 +1426,7 @@ export function LogSelector(t0: LogSelectorProps) { } let t70; if ($[202] !== agenticSearchState.status || $[203] !== branchFilterEnabled || $[204] !== columns || $[205] !== displayedLogs || $[206] !== expandedGroupSessionIds || $[207] !== flatOptions || $[208] !== focusedLog || $[209] !== focusedNode?.id || $[210] !== handleFlatOptionsSelectFocus || $[211] !== handleRenameSubmit || $[212] !== handleTreeSelectFocus || $[213] !== isAgenticSearchOptionFocused || $[214] !== onCancel || $[215] !== onSelect || $[216] !== renameCursorOffset || $[217] !== renameValue || $[218] !== treeNodes || $[219] !== viewMode || $[220] !== visibleCount) { - t70 = agenticSearchState.status === "searching" ? null : viewMode === "rename" && focusedLog ? Rename session: : isResumeWithRenameEnabled ? { + t70 = agenticSearchState.status === "searching" ? null : viewMode === "rename" && focusedLog ? Rename session: : isResumeWithRenameEnabled ? { onSelect(node_0.value.log); }} onFocus={handleTreeSelectFocus} onCancel={onCancel} focusNodeId={focusedNode?.id} visibleOptionCount={visibleCount} layout="expanded" isDisabled={viewMode === "search" || isAgenticSearchOptionFocused} hideIndexes={false} isNodeExpanded={nodeId => { if (viewMode === "search" || branchFilterEnabled) { @@ -1513,6 +1564,9 @@ function _temp2(log_1: LogOption): boolean { if (log_1.customTitle) { return true; } + if (log_1.sessionBranch?.branchName?.trim()) { + return true; + } const fromMessages = getFirstMeaningfulUserMessageTextContent(log_1.messages); if (fromMessages) { return true; @@ -1558,27 +1612,65 @@ function extractSearchableText(message: SerializedMessage): string { function buildSearchableText(log: LogOption): string { const searchableMessages = log.messages.length <= DEEP_SEARCH_MAX_MESSAGES ? log.messages : [...log.messages.slice(0, DEEP_SEARCH_CROP_SIZE), ...log.messages.slice(-DEEP_SEARCH_CROP_SIZE)]; const messageText = searchableMessages.map(extractSearchableText).filter(Boolean).join(' '); - const metadata = [log.customTitle, log.summary, log.firstPrompt, log.gitBranch, log.tag, log.prNumber ? `PR #${log.prNumber}` : undefined, log.prRepository].filter(Boolean).join(' '); + const metadata = [getResumeLogDisplayTitle(log), log.customTitle, log.sessionBranch?.branchName, log.summary, log.firstPrompt, log.gitBranch, log.tag, log.prNumber ? `PR #${log.prNumber}` : undefined, log.prRepository].filter(Boolean).join(' '); const fullText = `${metadata} ${messageText}`.trim(); return fullText.length > DEEP_SEARCH_MAX_TEXT_LENGTH ? fullText.slice(0, DEEP_SEARCH_MAX_TEXT_LENGTH) : fullText; } -function groupLogsBySessionId(filteredLogs: LogOption[]): Map { - const groups = new Map(); +export function groupLogsByResumeBranch(filteredLogs: LogOption[]): ResumeLogGroup[] { + type MutableGroup = { + id: string; + headerSessionId: string; + logs: LogOption[]; + firstIndex: number; + }; + + const visibleSessionIds = new Set(); for (const log of filteredLogs) { const sessionId = getSessionIdFromLog(log); - if (sessionId) { - const existing = groups.get(sessionId); - if (existing) { - existing.push(log); - } else { - groups.set(sessionId, [log]); - } - } + if (sessionId) visibleSessionIds.add(sessionId); } - // Sort logs within each group by modified date (newest first) - groups.forEach(logs => logs.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime())); - return groups; + const groups = new Map(); + for (const [index, log] of filteredLogs.entries()) { + const sessionId = getSessionIdFromLog(log); + if (!sessionId) continue; + + const branch = log.sessionBranch; + let headerSessionId = sessionId; + if (branch?.rootSessionId && visibleSessionIds.has(branch.rootSessionId)) { + headerSessionId = branch.rootSessionId; + } else if (branch?.parentSessionId && visibleSessionIds.has(branch.parentSessionId)) { + headerSessionId = branch.parentSessionId; + } + + let group = groups.get(headerSessionId); + if (!group) { + group = { + id: headerSessionId, + headerSessionId, + logs: [], + firstIndex: index, + }; + groups.set(headerSessionId, group); + } else { + group.firstIndex = Math.min(group.firstIndex, index); + } + group.logs.push(log); + } + + return Array.from(groups.values()).map(group => { + const headerLog = + group.logs.find(log => getSessionIdFromLog(log) === group.headerSessionId) ?? + group.logs[0]!; + const childLogs = group.logs.filter(log => log !== headerLog); + return { + id: group.id, + headerLog, + childLogs, + logs: [headerLog, ...childLogs], + firstIndex: group.firstIndex, + }; + }).sort((a, b) => a.firstIndex - b.firstIndex); } /** diff --git a/src/screens/ResumeConversation.test.ts b/src/screens/ResumeConversation.test.ts new file mode 100644 index 000000000..bae819a9e --- /dev/null +++ b/src/screens/ResumeConversation.test.ts @@ -0,0 +1,65 @@ +import { expect, test } from 'bun:test' +import type { UUID } from 'node:crypto' + +import type { LogOption } from '../types/logs.js' +import { filterResumeLogs } from './resumeFilters.js' + +const ts = '2026-06-30T00:00:00.000Z' + +function id(n: number): UUID { + return `00000000-0000-4000-8000-${String(n).padStart(12, '0')}` as UUID +} + +function log( + sessionId: UUID, + options: Partial = {}, +): LogOption { + return { + date: ts, + messages: [], + fullPath: `/tmp/${sessionId}.jsonl`, + value: 0, + created: new Date(ts), + modified: new Date(ts), + firstPrompt: 'session', + messageCount: 1, + isSidechain: false, + sessionId, + ...options, + } +} + +test('filterResumeLogs preserves PR filters before picker grouping', () => { + const prLog = log(id(1), { + prNumber: 42, + prUrl: 'https://github.com/Gitlawb/openclaude/pull/42', + prRepository: 'Gitlawb/openclaude', + }) + const otherPrLog = log(id(2), { prNumber: 77 }) + const nonPrLog = log(id(3)) + const sidechainLog = log(id(4), { isSidechain: true, prNumber: 42 }) + + expect(filterResumeLogs([prLog, nonPrLog, sidechainLog], undefined)).toEqual([ + prLog, + nonPrLog, + ]) + expect(filterResumeLogs([prLog, nonPrLog, sidechainLog], false)).toEqual([ + prLog, + nonPrLog, + ]) + expect( + filterResumeLogs([prLog, nonPrLog, sidechainLog], 'not-a-pr'), + ).toEqual([prLog, nonPrLog]) + expect(filterResumeLogs([prLog, otherPrLog, nonPrLog], true)).toEqual([ + prLog, + otherPrLog, + ]) + expect(filterResumeLogs([prLog, otherPrLog, nonPrLog], 42)).toEqual([prLog]) + expect( + filterResumeLogs( + [prLog, otherPrLog, nonPrLog], + 'https://github.com/Gitlawb/openclaude/pull/42', + ), + ).toEqual([prLog]) + expect(filterResumeLogs([prLog, sidechainLog], 42)).toEqual([prLog]) +}) diff --git a/src/screens/ResumeConversation.tsx b/src/screens/ResumeConversation.tsx index 195c811d8..af56b80d6 100644 --- a/src/screens/ResumeConversation.tsx +++ b/src/screens/ResumeConversation.tsx @@ -35,17 +35,7 @@ import type { ModelSetting } from '../utils/model/model.js'; import type { ThinkingConfig } from '../utils/thinking.js'; import { filterContentReplacementsForMessages, type ContentReplacementRecord } from '../utils/toolResultStorage.js'; import { REPL } from './REPL.js'; -function parsePrIdentifier(value: string): number | null { - const directNumber = parseInt(value, 10); - if (!isNaN(directNumber) && directNumber > 0) { - return directNumber; - } - const urlMatch = value.match(/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/); - if (urlMatch?.[1]) { - return parseInt(urlMatch[1], 10); - } - return null; -} +import { filterResumeLogs } from './resumeFilters.js'; type Props = { commands: Command[]; worktreePaths: string[]; @@ -114,20 +104,7 @@ export function ResumeConversation({ // the setLogs updater (keeping it pure per React's contract). const logCountRef = React.useRef(0); const filteredLogs = React.useMemo(() => { - let result = logs.filter(l => !l.isSidechain); - if (filterByPr !== undefined) { - if (filterByPr === true) { - result = result.filter(l_0 => l_0.prNumber !== undefined); - } else if (typeof filterByPr === 'number') { - result = result.filter(l_1 => l_1.prNumber === filterByPr); - } else if (typeof filterByPr === 'string') { - const prNumber = parsePrIdentifier(filterByPr); - if (prNumber !== null) { - result = result.filter(l_2 => l_2.prNumber === prNumber); - } - } - } - return result; + return filterResumeLogs(logs, filterByPr); }, [logs, filterByPr]); const isResumeWithRenameEnabled = isCustomTitleEnabled(); React.useEffect(() => { diff --git a/src/screens/resumeFilters.ts b/src/screens/resumeFilters.ts new file mode 100644 index 000000000..62564bf37 --- /dev/null +++ b/src/screens/resumeFilters.ts @@ -0,0 +1,36 @@ +import type { LogOption } from '../types/logs.js' + +export type ResumePrFilter = boolean | number | string | undefined + +export function parsePrIdentifier(value: string): number | null { + const directNumber = parseInt(value, 10) + if (!isNaN(directNumber) && directNumber > 0) { + return directNumber + } + const urlMatch = value.match(/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/) + if (urlMatch?.[1]) { + return parseInt(urlMatch[1], 10) + } + return null +} + +export function filterResumeLogs( + logs: LogOption[], + filterByPr: ResumePrFilter, +): LogOption[] { + let result = logs.filter(l => !l.isSidechain) + if (filterByPr === undefined || filterByPr === false) { + return result + } + if (filterByPr === true) { + return result.filter(l => l.prNumber !== undefined) + } + if (typeof filterByPr === 'number') { + return result.filter(l => l.prNumber === filterByPr) + } + const prNumber = parsePrIdentifier(filterByPr) + if (prNumber !== null) { + result = result.filter(l => l.prNumber === prNumber) + } + return result +} diff --git a/src/utils/sessionStorage.test.ts b/src/utils/sessionStorage.test.ts index 8c9bc02b9..7045407dd 100644 --- a/src/utils/sessionStorage.test.ts +++ b/src/utils/sessionStorage.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, expect, test } from 'bun:test' import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' import { type UUID } from 'node:crypto' -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -13,6 +13,8 @@ import { import { adoptResumedSessionFile, buildConversationChain, + getProjectDir, + loadSameRepoMessageLogsProgressive, loadTranscriptFile, recordGoalState, recordTranscript, @@ -32,7 +34,11 @@ import { switchSession, } from '../bootstrap/state.js' import type { GoalState } from '../services/goal/types.js' -import { setClaudeConfigHomeDirForTesting } from './envUtils.js' +import type { SessionBranchEntry } from '../types/logs.js' +import { + getClaudeConfigHomeDir, + setClaudeConfigHomeDirForTesting, +} from './envUtils.js' import { resetSettingsCache } from './settings/settingsCache.js' const tempDirs: string[] = [] @@ -172,6 +178,14 @@ function readGoalStateEntries(text: string): Array<{ goal: GoalState | null }> { ) } +function readSessionBranchEntries(text: string): SessionBranchEntry[] { + return text + .split('\n') + .filter(Boolean) + .map(line => JSON.parse(line) as SessionBranchEntry) + .filter(entry => entry.type === 'session-branch') +} + async function withSessionPersistence(fn: () => Promise): Promise { const originalPersistence = process.env.TEST_ENABLE_SESSION_PERSISTENCE const originalSessionPersistence = process.env.ENABLE_SESSION_PERSISTENCE @@ -600,6 +614,37 @@ test('restoreSessionMetadata re-appends the resumed active goal instead of stale }) }) +test('restoreSessionMetadata clears cached branch when resumed transcript has no branch metadata', async () => { + await withSessionPersistence(async () => { + const staleBranch: SessionBranchEntry = { + type: 'session-branch', + sessionId: sessionId as UUID, + parentSessionId: id(54), + rootSessionId: id(54), + branchedFromSessionId: id(54), + branchName: 'stale branch', + branchedAt: ts, + } + restoreSessionMetadata({ sessionBranch: staleBranch }) + + const dir = await mkdtemp(join(tmpdir(), 'openclaude-session-storage-')) + tempDirs.push(dir) + const filePath = join(dir, `${sessionId}.jsonl`) + await writeFile( + filePath, + `${JSON.stringify(user(id(55), null, 'resume non-branch'))}\n`, + ) + + switchSession(sessionId as never, dir) + await resetSessionFilePointer() + restoreSessionMetadata({}) + adoptResumedSessionFile() + + const text = await readFile(filePath, 'utf8') + expect(readSessionBranchEntries(text)).toEqual([]) + }) +}) + test('recordGoalState writes goal metadata durably before resolving', async () => { await withSessionPersistence(async () => { const dir = await mkdtemp(join(tmpdir(), 'openclaude-session-storage-')) @@ -629,3 +674,206 @@ test('recordGoalState writes goal metadata durably before resolving', async () = expect(text).toContain('durable goal') }) }) + +test('loadSameRepoMessageLogsProgressive preserves branch metadata across worktrees', async () => { + const configDir = await mkdtemp( + join(tmpdir(), 'openclaude-session-storage-config-'), + ) + tempDirs.push(configDir) + const worktreesRoot = await mkdtemp( + join(tmpdir(), 'openclaude-session-storage-worktrees-'), + ) + tempDirs.push(worktreesRoot) + const rootProject = join(worktreesRoot, 'main') + const branchProject = join(worktreesRoot, 'worktree-feature') + const rootId = id(61) + const branchId = id(62) + + try { + setClaudeConfigHomeDirForTesting(configDir) + getClaudeConfigHomeDir.cache?.clear?.() + const rootProjectDir = getProjectDir(rootProject) + const branchProjectDir = getProjectDir(branchProject) + await mkdir(rootProjectDir, { recursive: true }) + await mkdir(branchProjectDir, { recursive: true }) + await writeFile( + join(rootProjectDir, `${rootId}.jsonl`), + `${JSON.stringify({ + ...user(id(63), null, 'root prompt'), + sessionId: rootId, + cwd: rootProject, + })}\n`, + ) + await writeFile( + join(branchProjectDir, `${branchId}.jsonl`), + `${JSON.stringify({ + ...user(id(64), null, 'branch prompt'), + sessionId: branchId, + cwd: branchProject, + })}\n${JSON.stringify({ + type: 'session-branch', + sessionId: branchId, + parentSessionId: rootId, + rootSessionId: rootId, + branchedFromSessionId: rootId, + branchName: 'Worktree branch', + branchedAt: ts, + })}\n`, + ) + const result = await loadSameRepoMessageLogsProgressive( + [rootProject, branchProject], + undefined, + 10, + ) + + const branchLog = result.logs.find(log => log.sessionId === branchId) + expect(new Set(result.logs.map(log => log.projectPath))).toEqual( + new Set([branchProject, rootProject]), + ) + expect(branchLog?.sessionBranch?.branchName).toBe('Worktree branch') + expect(branchLog?.sessionBranch?.rootSessionId).toBe(rootId) + } finally { + setClaudeConfigHomeDirForTesting(undefined) + getClaudeConfigHomeDir.cache?.clear?.() + } +}) + +test('loadSameRepoMessageLogsProgressive preserves branch metadata from the lite head window', async () => { + const configDir = await mkdtemp( + join(tmpdir(), 'openclaude-session-storage-config-'), + ) + tempDirs.push(configDir) + const worktreesRoot = await mkdtemp( + join(tmpdir(), 'openclaude-session-storage-worktrees-'), + ) + tempDirs.push(worktreesRoot) + const rootProject = join(worktreesRoot, 'main') + const branchProject = join(worktreesRoot, 'worktree-feature') + const rootId = id(71) + const branchId = id(72) + const branchMetadata: SessionBranchEntry = { + type: 'session-branch', + sessionId: branchId, + parentSessionId: rootId, + rootSessionId: rootId, + branchedFromSessionId: rootId, + branchName: 'Long-lived branch', + branchedAt: ts, + } + + try { + setClaudeConfigHomeDirForTesting(configDir) + getClaudeConfigHomeDir.cache?.clear?.() + const rootProjectDir = getProjectDir(rootProject) + const branchProjectDir = getProjectDir(branchProject) + await mkdir(rootProjectDir, { recursive: true }) + await mkdir(branchProjectDir, { recursive: true }) + await writeFile( + join(rootProjectDir, `${rootId}.jsonl`), + `${JSON.stringify({ + ...user(id(73), null, 'root prompt'), + sessionId: rootId, + cwd: rootProject, + })}\n`, + ) + const largeText = 'x'.repeat(70 * 1024) + await writeFile( + join(branchProjectDir, `${branchId}.jsonl`), + `${JSON.stringify(branchMetadata)}\n${JSON.stringify({ + ...user(id(74), null, 'branch prompt'), + sessionId: branchId, + cwd: branchProject, + })}\n${JSON.stringify({ + ...user(id(75), id(74), largeText), + sessionId: branchId, + cwd: branchProject, + })}\n${JSON.stringify({ + ...assistant(id(76), id(75), largeText), + sessionId: branchId, + cwd: branchProject, + })}\n`, + ) + const result = await loadSameRepoMessageLogsProgressive( + [rootProject, branchProject], + undefined, + 10, + ) + + const branchLog = result.logs.find(log => log.sessionId === branchId) + expect(branchLog?.sessionBranch?.branchName).toBe('Long-lived branch') + expect(branchLog?.sessionBranch?.rootSessionId).toBe(rootId) + } finally { + setClaudeConfigHomeDirForTesting(undefined) + getClaudeConfigHomeDir.cache?.clear?.() + } +}) + +test('loadSameRepoMessageLogsProgressive ignores branch metadata outside lite read windows', async () => { + const configDir = await mkdtemp( + join(tmpdir(), 'openclaude-session-storage-config-'), + ) + tempDirs.push(configDir) + const worktreesRoot = await mkdtemp( + join(tmpdir(), 'openclaude-session-storage-worktrees-'), + ) + tempDirs.push(worktreesRoot) + const rootProject = join(worktreesRoot, 'main') + const branchProject = join(worktreesRoot, 'worktree-feature') + const rootId = id(81) + const branchId = id(82) + const branchMetadata: SessionBranchEntry = { + type: 'session-branch', + sessionId: branchId, + parentSessionId: rootId, + rootSessionId: rootId, + branchedFromSessionId: rootId, + branchName: 'Hidden branch metadata', + branchedAt: ts, + } + + try { + setClaudeConfigHomeDirForTesting(configDir) + getClaudeConfigHomeDir.cache?.clear?.() + const rootProjectDir = getProjectDir(rootProject) + const branchProjectDir = getProjectDir(branchProject) + await mkdir(rootProjectDir, { recursive: true }) + await mkdir(branchProjectDir, { recursive: true }) + await writeFile( + join(rootProjectDir, `${rootId}.jsonl`), + `${JSON.stringify({ + ...user(id(83), null, 'root prompt'), + sessionId: rootId, + cwd: rootProject, + })}\n`, + ) + const largeText = 'x'.repeat(70 * 1024) + await writeFile( + join(branchProjectDir, `${branchId}.jsonl`), + `${JSON.stringify({ + ...user(id(84), null, 'branch prompt'), + sessionId: branchId, + cwd: branchProject, + })}\n${JSON.stringify({ + ...user(id(85), id(84), largeText), + sessionId: branchId, + cwd: branchProject, + })}\n${JSON.stringify(branchMetadata)}\n${JSON.stringify({ + ...assistant(id(86), id(85), largeText), + sessionId: branchId, + cwd: branchProject, + })}\n`, + ) + const result = await loadSameRepoMessageLogsProgressive( + [rootProject, branchProject], + undefined, + 10, + ) + + const branchLog = result.logs.find(log => log.sessionId === branchId) + expect(branchLog).toBeDefined() + expect(branchLog?.sessionBranch).toBeUndefined() + } finally { + setClaudeConfigHomeDirForTesting(undefined) + getClaudeConfigHomeDir.cache?.clear?.() + } +}) diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index be3ebd2b3..a6ac10e70 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -571,6 +571,7 @@ class Project { currentSessionPrUrl: string | undefined currentSessionPrRepository: string | undefined currentSessionGoal: GoalStateEntry['goal'] | undefined + currentSessionBranch: SessionBranchEntry | undefined sessionFile: string | null = null // Entries buffered while sessionFile is null. Flushed by materializeSessionFile @@ -873,6 +874,12 @@ class Project { goal: this.currentSessionGoal, }) } + if (this.currentSessionBranch) { + appendEntryToFile(this.sessionFile, { + ...this.currentSessionBranch, + sessionId, + }) + } } async flush(): Promise { @@ -3126,6 +3133,7 @@ export function restoreSessionMetadata(meta: { prUrl?: string prRepository?: string goal?: GoalStateEntry['goal'] + sessionBranch?: SessionBranchEntry }): void { const project = getProject() // ??= so --name (cacheSessionTitle) wins over the resumed @@ -3146,6 +3154,9 @@ export function restoreSessionMetadata(meta: { // resumed session has no goal. Clear any cached goal so adopt/re-append // cannot persist a previous session's active goal into this transcript. project.currentSessionGoal = meta.goal ?? undefined + // Branch lineage is structural metadata. Absence means this session is not + // a branch, so clear stale branch cache before it can be re-appended. + project.currentSessionBranch = meta.sessionBranch ?? undefined } /** @@ -3167,6 +3178,7 @@ export function clearSessionMetadata(): void { project.currentSessionPrUrl = undefined project.currentSessionPrRepository = undefined project.currentSessionGoal = undefined + project.currentSessionBranch = undefined } /** @@ -4987,6 +4999,79 @@ type LiteMetadata = { prNumber?: number prUrl?: string prRepository?: string + sessionBranch?: SessionBranchEntry +} + +const SESSION_BRANCH_ENTRY_PREFIX = '{"type":"session-branch"' +const SESSION_BRANCH_ENTRY_PREFIX_SPACED = '{"type": "session-branch"' + +function startsWithSessionBranchEntryPrefix(lineStart: string): boolean { + return ( + lineStart.startsWith(SESSION_BRANCH_ENTRY_PREFIX) || + lineStart.startsWith(SESSION_BRANCH_ENTRY_PREFIX_SPACED) + ) +} + +function isSessionBranchEntry( + entry: unknown, + sessionId?: string, +): entry is SessionBranchEntry { + if (typeof entry !== 'object' || entry === null) return false + const candidate = entry as Partial + return ( + candidate.type === 'session-branch' && + typeof candidate.sessionId === 'string' && + (sessionId === undefined || candidate.sessionId === sessionId) && + typeof candidate.parentSessionId === 'string' && + typeof candidate.rootSessionId === 'string' && + typeof candidate.branchedFromSessionId === 'string' && + typeof candidate.branchedAt === 'string' && + (candidate.branchName === undefined || + typeof candidate.branchName === 'string') && + (candidate.branchedAtMessageId === undefined || + typeof candidate.branchedAtMessageId === 'string') + ) +} + +function parseSessionBranchMetadataLine( + line: string, + sessionId?: string, +): SessionBranchEntry | undefined { + const trimmed = line.trim() + if (!startsWithSessionBranchEntryPrefix(trimmed)) { + return undefined + } + try { + const entry = jsonParse(trimmed) + return isSessionBranchEntry(entry, sessionId) ? entry : undefined + } catch { + return undefined + } +} + +function extractSessionBranchMetadataFromChunk( + chunk: string, + sessionId?: string, +): SessionBranchEntry | undefined { + const lines = chunk.split('\n') + for (let i = lines.length - 1; i >= 0; i--) { + const entry = parseSessionBranchMetadataLine(lines[i] ?? '', sessionId) + if (entry) return entry + } + return undefined +} + +function extractSessionBranchMetadata( + head: string, + tail: string, + sessionId?: string, +): SessionBranchEntry | undefined { + return ( + extractSessionBranchMetadataFromChunk(tail, sessionId) ?? + (head === tail + ? undefined + : extractSessionBranchMetadataFromChunk(head, sessionId)) + ) } /** @@ -5142,6 +5227,7 @@ async function readLiteMetadata( filePath: string, fileSize: number, buf: Buffer, + sessionId?: string, ): Promise { const { head, tail } = await readHeadAndTail(filePath, fileSize, buf) if (!head) return { firstPrompt: '', isSidechain: false } @@ -5197,6 +5283,7 @@ async function readLiteMetadata( if (num > 0) prNumber = num } } + const sessionBranch = extractSessionBranchMetadata(head, tail, sessionId) return { firstPrompt, @@ -5211,6 +5298,7 @@ async function readLiteMetadata( prNumber, prUrl, prRepository, + sessionBranch, } } @@ -5428,7 +5516,12 @@ async function enrichLog( ): Promise { if (!log.isLite || !log.fullPath) return log - const meta = await readLiteMetadata(log.fullPath, log.fileSize ?? 0, readBuf) + const meta = await readLiteMetadata( + log.fullPath, + log.fileSize ?? 0, + readBuf, + log.sessionId, + ) const enriched: LogOption = { ...log, @@ -5444,6 +5537,7 @@ async function enrichLog( prNumber: meta.prNumber, prUrl: meta.prUrl, prRepository: meta.prRepository, + sessionBranch: meta.sessionBranch, projectPath: meta.projectPath ?? log.projectPath, }