mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Desktop users need the same branch-from-here workflow that the CLI already exposed, so the branch creation logic now lives in a shared transcript utility and the desktop app routes completed message actions through the server API. The UI hydrates transcript ids after live completions so newly generated turns can be branched immediately without a refresh. Constraint: Source sessions must remain unmodified while branch sessions inherit the active transcript chain and persistence metadata. Rejected: Keep a desktop-only branch implementation | it would drift from CLI /branch semantics and duplicate transcript filtering rules Confidence: high Scope-risk: moderate Directive: Do not remove the post-completion transcript hydration without a real-model desktop E2E for just-finished messages Tested: bun run verify; Chrome Web UI E2E with real gpt-5.5 provider on ports 45678/45679 Not-tested: Provider-specific behavior beyond the configured Sub2API-ChatGPT route
555 lines
15 KiB
TypeScript
555 lines
15 KiB
TypeScript
import { randomUUID, type UUID } from 'crypto'
|
|
import { mkdir, readFile, readdir, writeFile } from 'fs/promises'
|
|
import * as path from 'node:path'
|
|
import type {
|
|
AttributionSnapshotMessage,
|
|
PersistedWorktreeSession,
|
|
SerializedMessage,
|
|
TranscriptMessage,
|
|
} from '../types/logs.js'
|
|
import type { ContentReplacementRecord } from './toolResultStorage.js'
|
|
import { parseJSONL } from './json.js'
|
|
import { buildConversationChain, loadTranscriptFile } from './sessionStorage.js'
|
|
import { jsonStringify } from './slowOperations.js'
|
|
import { escapeRegExp } from './stringUtils.js'
|
|
|
|
type SessionMetaEntry = {
|
|
type: 'session-meta'
|
|
isMeta?: boolean
|
|
workDir?: string
|
|
repository?: unknown
|
|
timestamp?: string
|
|
[key: string]: unknown
|
|
}
|
|
|
|
type WorktreeStateEntry = {
|
|
type: 'worktree-state'
|
|
sessionId: string
|
|
worktreeSession: PersistedWorktreeSession | null
|
|
[key: string]: unknown
|
|
}
|
|
|
|
type ModeEntry = {
|
|
type: 'mode'
|
|
sessionId: string
|
|
mode: string
|
|
[key: string]: unknown
|
|
}
|
|
|
|
type PrLinkEntry = {
|
|
type: 'pr-link'
|
|
sessionId: string
|
|
prNumber: number
|
|
prUrl: string
|
|
prRepository: string
|
|
timestamp: string
|
|
[key: string]: unknown
|
|
}
|
|
|
|
type FileHistorySnapshotEntry = {
|
|
type: 'file-history-snapshot'
|
|
messageId: string
|
|
snapshot?: {
|
|
messageId?: string
|
|
trackedFileBackups?: Record<string, unknown>
|
|
timestamp?: string
|
|
}
|
|
[key: string]: unknown
|
|
}
|
|
|
|
type ContentReplacementEntry = {
|
|
type: 'content-replacement'
|
|
sessionId: string
|
|
replacements: ContentReplacementRecord[]
|
|
[key: string]: unknown
|
|
}
|
|
|
|
type TranscriptEntry = TranscriptMessage & {
|
|
forkedFrom?: {
|
|
sessionId: string
|
|
messageUuid: UUID
|
|
}
|
|
}
|
|
|
|
type RawEntry =
|
|
| SessionMetaEntry
|
|
| WorktreeStateEntry
|
|
| ModeEntry
|
|
| PrLinkEntry
|
|
| FileHistorySnapshotEntry
|
|
| AttributionSnapshotMessage
|
|
| ContentReplacementEntry
|
|
| Record<string, unknown>
|
|
|
|
export type SessionBranchResult = {
|
|
sessionId: UUID
|
|
title: string
|
|
forkPath: string
|
|
workDir: string | null
|
|
serializedMessages: SerializedMessage[]
|
|
contentReplacementRecords: ContentReplacementRecord[]
|
|
}
|
|
|
|
export type CreateSessionBranchOptions = {
|
|
sourceSessionId: string
|
|
sourceTranscriptPath: string
|
|
title?: string
|
|
targetMessageId?: string
|
|
sourceWorkDir?: string | null
|
|
sourceRepository?: unknown
|
|
sourceWorktreeSession?: PersistedWorktreeSession | null
|
|
}
|
|
|
|
export class SessionBranchingError extends Error {
|
|
constructor(
|
|
readonly code:
|
|
| 'SOURCE_NOT_FOUND'
|
|
| 'INVALID_TARGET'
|
|
| 'NO_BRANCHABLE_MESSAGES',
|
|
message: string,
|
|
) {
|
|
super(message)
|
|
this.name = 'SessionBranchingError'
|
|
}
|
|
}
|
|
|
|
function isTranscriptEntry(entry: RawEntry): entry is TranscriptMessage {
|
|
return (
|
|
typeof entry === 'object' &&
|
|
entry !== null &&
|
|
(entry.type === 'user' ||
|
|
entry.type === 'assistant' ||
|
|
entry.type === 'attachment' ||
|
|
entry.type === 'system') &&
|
|
typeof (entry as TranscriptMessage).uuid === 'string'
|
|
)
|
|
}
|
|
|
|
function isSessionMetaEntry(entry: RawEntry): entry is SessionMetaEntry {
|
|
return entry.type === 'session-meta'
|
|
}
|
|
|
|
function isWorktreeStateEntry(entry: RawEntry): entry is WorktreeStateEntry {
|
|
return entry.type === 'worktree-state'
|
|
}
|
|
|
|
function isModeEntry(entry: RawEntry): entry is ModeEntry {
|
|
return entry.type === 'mode'
|
|
}
|
|
|
|
function isPrLinkEntry(entry: RawEntry): entry is PrLinkEntry {
|
|
return entry.type === 'pr-link'
|
|
}
|
|
|
|
function isFileHistorySnapshotEntry(entry: RawEntry): entry is FileHistorySnapshotEntry {
|
|
return entry.type === 'file-history-snapshot'
|
|
}
|
|
|
|
function isAttributionSnapshotEntry(entry: RawEntry): entry is AttributionSnapshotMessage {
|
|
return entry.type === 'attribution-snapshot'
|
|
}
|
|
|
|
function getSnapshotMessageId(entry: FileHistorySnapshotEntry): string | null {
|
|
if (typeof entry.snapshot?.messageId === 'string') return entry.snapshot.messageId
|
|
if (typeof entry.messageId === 'string') return entry.messageId
|
|
return null
|
|
}
|
|
|
|
function getAttributionMessageId(entry: AttributionSnapshotMessage): string | null {
|
|
return typeof entry.messageId === 'string' ? entry.messageId : null
|
|
}
|
|
|
|
function findLatestActiveLeaf(
|
|
messages: Map<UUID, TranscriptMessage>,
|
|
leafUuids: Set<UUID>,
|
|
): TranscriptMessage | undefined {
|
|
let latest: TranscriptMessage | undefined
|
|
let latestTimestamp = -Infinity
|
|
|
|
const candidates = leafUuids.size > 0
|
|
? [...leafUuids]
|
|
.map((uuid) => messages.get(uuid))
|
|
.filter((message): message is TranscriptMessage => !!message)
|
|
: [...messages.values()]
|
|
|
|
for (const message of candidates) {
|
|
if (message.isSidechain) continue
|
|
const timestamp = Date.parse(message.timestamp)
|
|
if (timestamp >= latestTimestamp) {
|
|
latest = message
|
|
latestTimestamp = timestamp
|
|
}
|
|
}
|
|
|
|
return latest
|
|
}
|
|
|
|
function extractToolResultIds(message: TranscriptMessage): string[] {
|
|
if (message.type !== 'user' || !Array.isArray(message.message.content)) {
|
|
return []
|
|
}
|
|
|
|
return message.message.content.flatMap((block) => (
|
|
block.type === 'tool_result' && typeof block.tool_use_id === 'string'
|
|
? [block.tool_use_id]
|
|
: []
|
|
))
|
|
}
|
|
|
|
async function readRawEntries(filePath: string): Promise<RawEntry[]> {
|
|
const content = await readFile(filePath)
|
|
return parseJSONL<RawEntry>(content)
|
|
}
|
|
|
|
async function listExistingTitles(projectDirPath: string): Promise<string[]> {
|
|
let dirents: import('fs').Dirent[]
|
|
try {
|
|
dirents = await readdir(projectDirPath, { withFileTypes: true })
|
|
} catch {
|
|
return []
|
|
}
|
|
|
|
const titles: string[] = []
|
|
for (const dirent of dirents) {
|
|
if (!dirent.isFile() || !dirent.name.endsWith('.jsonl')) continue
|
|
try {
|
|
const entries = await readRawEntries(path.join(projectDirPath, dirent.name))
|
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
const entry = entries[i]
|
|
if (
|
|
entry?.type === 'custom-title' &&
|
|
typeof (entry as Record<string, unknown>).customTitle === 'string'
|
|
) {
|
|
titles.push((entry as Record<string, unknown>).customTitle as string)
|
|
break
|
|
}
|
|
}
|
|
} catch {
|
|
continue
|
|
}
|
|
}
|
|
|
|
return titles
|
|
}
|
|
|
|
export function deriveFirstPrompt(
|
|
firstUserMessage: Extract<SerializedMessage, { type: 'user' }> | undefined,
|
|
): string {
|
|
const content = firstUserMessage?.message?.content
|
|
if (!content) return 'Branched conversation'
|
|
const raw =
|
|
typeof content === 'string'
|
|
? content
|
|
: content.find(
|
|
(block): block is { type: 'text'; text: string } =>
|
|
block.type === 'text',
|
|
)?.text
|
|
if (!raw) return 'Branched conversation'
|
|
return (
|
|
raw.replace(/\s+/g, ' ').trim().slice(0, 100) || 'Branched conversation'
|
|
)
|
|
}
|
|
|
|
export async function getUniqueForkName(
|
|
baseName: string,
|
|
projectDirPath: string,
|
|
): Promise<string> {
|
|
const existingTitles = new Set(
|
|
(await listExistingTitles(projectDirPath))
|
|
.map((title) => title.trim())
|
|
.filter(Boolean),
|
|
)
|
|
|
|
const candidateName = `${baseName} (Branch)`
|
|
if (!existingTitles.has(candidateName)) {
|
|
return candidateName
|
|
}
|
|
|
|
const usedNumbers = new Set<number>([1])
|
|
const forkNumberPattern = new RegExp(
|
|
`^${escapeRegExp(baseName)} \\(Branch(?: (\\d+))?\\)$`,
|
|
)
|
|
|
|
for (const title of existingTitles) {
|
|
const match = title.match(forkNumberPattern)
|
|
if (!match) continue
|
|
usedNumbers.add(match[1] ? parseInt(match[1], 10) : 1)
|
|
}
|
|
|
|
let nextNumber = 2
|
|
while (usedNumbers.has(nextNumber)) {
|
|
nextNumber++
|
|
}
|
|
return `${baseName} (Branch ${nextNumber})`
|
|
}
|
|
|
|
function buildPreservedMetadataEntries(
|
|
sourceEntries: RawEntry[],
|
|
copiedMessageIds: Set<string>,
|
|
forkSessionId: UUID,
|
|
): RawEntry[] {
|
|
return sourceEntries.flatMap((entry) => {
|
|
if (isSessionMetaEntry(entry)) {
|
|
return [entry]
|
|
}
|
|
|
|
if (isWorktreeStateEntry(entry) && entry.worktreeSession) {
|
|
return [{
|
|
...entry,
|
|
sessionId: forkSessionId,
|
|
worktreeSession: {
|
|
...entry.worktreeSession,
|
|
sessionId: forkSessionId,
|
|
},
|
|
}]
|
|
}
|
|
|
|
if (isModeEntry(entry)) {
|
|
return [{ ...entry, sessionId: forkSessionId }]
|
|
}
|
|
|
|
if (isPrLinkEntry(entry)) {
|
|
return [{ ...entry, sessionId: forkSessionId }]
|
|
}
|
|
|
|
if (isFileHistorySnapshotEntry(entry)) {
|
|
const messageId = getSnapshotMessageId(entry)
|
|
return messageId && copiedMessageIds.has(messageId) ? [entry] : []
|
|
}
|
|
|
|
if (isAttributionSnapshotEntry(entry)) {
|
|
const messageId = getAttributionMessageId(entry)
|
|
return messageId && copiedMessageIds.has(messageId) ? [entry] : []
|
|
}
|
|
|
|
return []
|
|
})
|
|
}
|
|
|
|
function ensureSyntheticSessionMeta(
|
|
preservedMetadataEntries: RawEntry[],
|
|
sourceWorkDir: string | null | undefined,
|
|
sourceRepository: unknown,
|
|
): RawEntry[] {
|
|
const hasSessionMeta = preservedMetadataEntries.some(isSessionMetaEntry)
|
|
if (hasSessionMeta || (!sourceWorkDir && sourceRepository === undefined)) {
|
|
return preservedMetadataEntries
|
|
}
|
|
|
|
return [{
|
|
type: 'session-meta',
|
|
isMeta: true,
|
|
...(sourceWorkDir ? { workDir: sourceWorkDir } : {}),
|
|
...(sourceRepository !== undefined ? { repository: sourceRepository } : {}),
|
|
timestamp: new Date().toISOString(),
|
|
}, ...preservedMetadataEntries]
|
|
}
|
|
|
|
function ensureSyntheticWorktreeState(
|
|
preservedMetadataEntries: RawEntry[],
|
|
forkSessionId: UUID,
|
|
sourceWorktreeSession: PersistedWorktreeSession | null | undefined,
|
|
): RawEntry[] {
|
|
const hasWorktreeState = preservedMetadataEntries.some(isWorktreeStateEntry)
|
|
if (hasWorktreeState || !sourceWorktreeSession) {
|
|
return preservedMetadataEntries
|
|
}
|
|
|
|
return [{
|
|
type: 'worktree-state',
|
|
sessionId: forkSessionId,
|
|
worktreeSession: {
|
|
...sourceWorktreeSession,
|
|
sessionId: forkSessionId,
|
|
},
|
|
}, ...preservedMetadataEntries]
|
|
}
|
|
|
|
export async function createSessionBranch(
|
|
options: CreateSessionBranchOptions,
|
|
): Promise<SessionBranchResult> {
|
|
const {
|
|
sourceSessionId,
|
|
sourceTranscriptPath,
|
|
title,
|
|
targetMessageId,
|
|
sourceWorkDir,
|
|
sourceRepository,
|
|
sourceWorktreeSession,
|
|
} = options
|
|
|
|
const projectDirPath = path.dirname(sourceTranscriptPath)
|
|
const sourceEntries = await readRawEntries(sourceTranscriptPath).catch((error) => {
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
throw new SessionBranchingError(
|
|
'SOURCE_NOT_FOUND',
|
|
`Session not found: ${sourceSessionId}`,
|
|
)
|
|
}
|
|
throw error
|
|
})
|
|
|
|
const transcript = await loadTranscriptFile(sourceTranscriptPath, {
|
|
keepAllLeaves: true,
|
|
})
|
|
const activeLeaf = findLatestActiveLeaf(transcript.messages, transcript.leafUuids)
|
|
if (!activeLeaf) {
|
|
throw new SessionBranchingError(
|
|
'NO_BRANCHABLE_MESSAGES',
|
|
'No conversation to branch',
|
|
)
|
|
}
|
|
|
|
const activeChain = buildConversationChain(transcript.messages, activeLeaf)
|
|
.filter((message) => !message.isSidechain)
|
|
|
|
if (activeChain.length === 0) {
|
|
throw new SessionBranchingError(
|
|
'NO_BRANCHABLE_MESSAGES',
|
|
'No conversation to branch',
|
|
)
|
|
}
|
|
|
|
const targetIndex = targetMessageId
|
|
? activeChain.findIndex((message) => message.uuid === targetMessageId)
|
|
: activeChain.length - 1
|
|
|
|
if (targetIndex < 0) {
|
|
throw new SessionBranchingError(
|
|
'INVALID_TARGET',
|
|
'targetMessageId must reference a main conversation message in the active chain',
|
|
)
|
|
}
|
|
|
|
const copiedMessages = activeChain.slice(0, targetIndex + 1)
|
|
if (copiedMessages.length === 0) {
|
|
throw new SessionBranchingError(
|
|
'NO_BRANCHABLE_MESSAGES',
|
|
'No messages to branch',
|
|
)
|
|
}
|
|
|
|
const copiedMessageIds = new Set(copiedMessages.map((message) => message.uuid))
|
|
const sourceMessageEntriesById = new Map(
|
|
sourceEntries
|
|
.filter(
|
|
(entry): entry is TranscriptMessage =>
|
|
isTranscriptEntry(entry) &&
|
|
entry.isSidechain !== true &&
|
|
copiedMessageIds.has(entry.uuid),
|
|
)
|
|
.map((entry) => [entry.uuid, entry]),
|
|
)
|
|
const branchMessageEntries = copiedMessages.flatMap((message) => {
|
|
const entry = sourceMessageEntriesById.get(message.uuid)
|
|
return entry ? [entry] : []
|
|
})
|
|
|
|
if (branchMessageEntries.length === 0) {
|
|
throw new SessionBranchingError(
|
|
'NO_BRANCHABLE_MESSAGES',
|
|
'No messages to branch',
|
|
)
|
|
}
|
|
|
|
const copiedToolResultIds = new Set(
|
|
copiedMessages.flatMap((message) => extractToolResultIds(message)),
|
|
)
|
|
const contentReplacementRecords = (
|
|
transcript.contentReplacements.get(sourceSessionId as UUID) ?? []
|
|
).filter((record) => copiedToolResultIds.has(record.toolUseId))
|
|
|
|
const forkSessionId = randomUUID() as UUID
|
|
const forkPath = path.join(projectDirPath, `${forkSessionId}.jsonl`)
|
|
await mkdir(projectDirPath, { recursive: true, mode: 0o700 })
|
|
|
|
const serializedMessages: SerializedMessage[] = []
|
|
const messageLines: string[] = []
|
|
let parentUuid: UUID | null = null
|
|
|
|
for (const entry of branchMessageEntries) {
|
|
const forkedEntry: TranscriptEntry = {
|
|
...entry,
|
|
sessionId: forkSessionId,
|
|
parentUuid,
|
|
isSidechain: false,
|
|
forkedFrom: {
|
|
sessionId: sourceSessionId,
|
|
messageUuid: entry.uuid,
|
|
},
|
|
}
|
|
|
|
serializedMessages.push({
|
|
...entry,
|
|
sessionId: forkSessionId,
|
|
})
|
|
messageLines.push(jsonStringify(forkedEntry))
|
|
parentUuid = entry.uuid
|
|
}
|
|
|
|
const firstPrompt = deriveFirstPrompt(
|
|
serializedMessages.find(
|
|
(message): message is Extract<SerializedMessage, { type: 'user' }> =>
|
|
message.type === 'user',
|
|
),
|
|
)
|
|
const effectiveTitle = await getUniqueForkName(title ?? firstPrompt, projectDirPath)
|
|
|
|
let metadataEntries = buildPreservedMetadataEntries(
|
|
sourceEntries,
|
|
copiedMessageIds,
|
|
forkSessionId,
|
|
)
|
|
metadataEntries = ensureSyntheticSessionMeta(
|
|
metadataEntries,
|
|
sourceWorkDir ?? null,
|
|
sourceRepository,
|
|
)
|
|
metadataEntries = ensureSyntheticWorktreeState(
|
|
metadataEntries,
|
|
forkSessionId,
|
|
sourceWorktreeSession,
|
|
)
|
|
|
|
const lines = [
|
|
...metadataEntries.map((entry) => jsonStringify(entry)),
|
|
...messageLines,
|
|
]
|
|
|
|
if (contentReplacementRecords.length > 0) {
|
|
lines.push(jsonStringify({
|
|
type: 'content-replacement',
|
|
sessionId: forkSessionId,
|
|
replacements: contentReplacementRecords,
|
|
} satisfies ContentReplacementEntry))
|
|
}
|
|
|
|
lines.push(jsonStringify({
|
|
type: 'custom-title',
|
|
sessionId: forkSessionId,
|
|
customTitle: effectiveTitle,
|
|
}))
|
|
|
|
await writeFile(forkPath, `${lines.join('\n')}\n`, {
|
|
encoding: 'utf8',
|
|
mode: 0o600,
|
|
})
|
|
|
|
const workDirFromMetadata =
|
|
(
|
|
metadataEntries.find(isSessionMetaEntry)?.workDir as string | undefined
|
|
) ??
|
|
sourceWorkDir ??
|
|
branchMessageEntries.findLast((entry) => typeof entry.cwd === 'string')?.cwd ??
|
|
null
|
|
|
|
return {
|
|
sessionId: forkSessionId,
|
|
title: effectiveTitle,
|
|
forkPath,
|
|
workDir: workDirFromMetadata,
|
|
serializedMessages,
|
|
contentReplacementRecords,
|
|
}
|
|
}
|