mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
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:
@@ -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',
|
||||
)
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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?.()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user