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
This commit is contained in:
Bogdan
2026-07-01 06:36:15 +08:00
committed by GitHub
parent 64955db0fb
commit 166d0ce784
9 changed files with 1047 additions and 107 deletions
+4
View File
@@ -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',
)
+1 -2
View File
@@ -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',
@@ -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> = {},
): 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<ParsedKey, 'name' | 'sequence' | 'raw'>,
): 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<string> {
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<ReturnType<typeof createRoot>> | 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()
}
})
+169 -77
View File
@@ -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<unknown>[],
options: {
expandedGroupSessionIds: ReadonlySet<string>;
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<unknown>): 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 ? <Box paddingLeft={2} flexDirection="column"><Text bold={true}>Rename session:</Text><Box paddingTop={1}><TextInput value={renameValue} onChange={setRenameValue} onSubmit={handleRenameSubmit} placeholder={getLogDisplayTitle(focusedLog, "Enter new session name")} columns={columns} cursorOffset={renameCursorOffset} onChangeCursorOffset={setRenameCursorOffset} showCursor={true} /></Box></Box> : isResumeWithRenameEnabled ? <TreeSelect nodes={treeNodes} onSelect={node_0 => {
t70 = agenticSearchState.status === "searching" ? null : viewMode === "rename" && focusedLog ? <Box paddingLeft={2} flexDirection="column"><Text bold={true}>Rename session:</Text><Box paddingTop={1}><TextInput value={renameValue} onChange={setRenameValue} onSubmit={handleRenameSubmit} placeholder={getResumeLogDisplayTitle(focusedLog) || "Enter new session name"} columns={columns} cursorOffset={renameCursorOffset} onChangeCursorOffset={setRenameCursorOffset} showCursor={true} /></Box></Box> : isResumeWithRenameEnabled ? <TreeSelect nodes={treeNodes} onSelect={node_0 => {
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<string, LogOption[]> {
const groups = new Map<string, LogOption[]>();
export function groupLogsByResumeBranch(filteredLogs: LogOption[]): ResumeLogGroup[] {
type MutableGroup = {
id: string;
headerSessionId: string;
logs: LogOption[];
firstIndex: number;
};
const visibleSessionIds = new Set<string>();
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<string, MutableGroup>();
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);
}
/**
+65
View File
@@ -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> = {},
): 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])
})
+2 -25
View File
@@ -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(() => {
+36
View File
@@ -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
}
+250 -2
View File
@@ -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<T>(fn: () => Promise<T>): Promise<T> {
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?.()
}
})
+95 -1
View File
@@ -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<void> {
@@ -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<SessionBranchEntry>
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<LiteMetadata> {
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<LogOption | null> {
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,
}