source dump of claude code
at main 119 lines 5.7 kB view raw
1import type { UUID } from 'crypto' 2import { useEffect, useRef } from 'react' 3import { useAppState } from '../state/AppState.js' 4import type { Message } from '../types/message.js' 5import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js' 6import { 7 cleanMessagesForLogging, 8 isChainParticipant, 9 recordTranscript, 10} from '../utils/sessionStorage.js' 11 12/** 13 * Hook that logs messages to the transcript 14 * conversation ID that only changes when a new conversation is started. 15 * 16 * @param messages The current conversation messages 17 * @param ignore When true, messages will not be recorded to the transcript 18 */ 19export function useLogMessages(messages: Message[], ignore: boolean = false) { 20 const teamContext = useAppState(s => s.teamContext) 21 22 // messages is append-only between compactions, so track where we left off 23 // and only pass the new tail to recordTranscript. Avoids O(n) filter+scan 24 // on every setMessages (~20x/turn, so n=3000 was ~120k wasted iterations). 25 const lastRecordedLengthRef = useRef(0) 26 const lastParentUuidRef = useRef<UUID | undefined>(undefined) 27 // First-uuid change = compaction or /clear rebuilt the array; length alone 28 // can't detect this since post-compact [CB,summary,...keep,new] may be longer. 29 const firstMessageUuidRef = useRef<UUID | undefined>(undefined) 30 // Guard against stale async .then() overwriting a fresher sync update when 31 // an incremental render fires before the compaction .then() resolves. 32 const callSeqRef = useRef(0) 33 34 useEffect(() => { 35 if (ignore) return 36 37 const currentFirstUuid = messages[0]?.uuid as UUID | undefined 38 const prevLength = lastRecordedLengthRef.current 39 40 // First-render: firstMessageUuidRef is undefined. Compaction: first uuid changes. 41 // Both are !isIncremental, but first-render sync-walk is safe (no messagesToKeep). 42 const wasFirstRender = firstMessageUuidRef.current === undefined 43 const isIncremental = 44 currentFirstUuid !== undefined && 45 !wasFirstRender && 46 currentFirstUuid === firstMessageUuidRef.current && 47 prevLength <= messages.length 48 // Same-head shrink: tombstone filter, rewind, snip, partial-compact. 49 // Distinguished from compaction (first uuid changes) because the tail 50 // is either an existing on-disk message or a fresh message that this 51 // same effect's recordTranscript(fullArray) will write — see sync-walk 52 // guard below. 53 const isSameHeadShrink = 54 currentFirstUuid !== undefined && 55 !wasFirstRender && 56 currentFirstUuid === firstMessageUuidRef.current && 57 prevLength > messages.length 58 59 const startIndex = isIncremental ? prevLength : 0 60 if (startIndex === messages.length) return 61 62 // Full array on first call + after compaction: recordTranscript's own 63 // O(n) dedup loop handles messagesToKeep interleaving correctly there. 64 const slice = startIndex === 0 ? messages : messages.slice(startIndex) 65 const parentHint = isIncremental ? lastParentUuidRef.current : undefined 66 67 // Fire and forget - we don't want to block the UI. 68 const seq = ++callSeqRef.current 69 void recordTranscript( 70 slice, 71 isAgentSwarmsEnabled() 72 ? { 73 teamName: teamContext?.teamName, 74 agentName: teamContext?.selfAgentName, 75 } 76 : {}, 77 parentHint, 78 messages, 79 ).then(lastRecordedUuid => { 80 // For compaction/full array case (!isIncremental): use the async return 81 // value. After compaction, messagesToKeep in the array are skipped 82 // (already in transcript), so the sync loop would find a wrong UUID. 83 // Skip if a newer effect already ran (stale closure would overwrite the 84 // fresher sync update from the subsequent incremental render). 85 if (seq !== callSeqRef.current) return 86 if (lastRecordedUuid && !isIncremental) { 87 lastParentUuidRef.current = lastRecordedUuid 88 } 89 }) 90 91 // Sync-walk safe for: incremental (pure new-tail slice), first-render 92 // (no messagesToKeep interleaving), and same-head shrink. Shrink is the 93 // subtle one: the picked uuid is either already on disk (tombstone/rewind 94 // — survivors were written before) or is being written by THIS effect's 95 // recordTranscript(fullArray) call (snip boundary / partial-compact tail 96 // — enqueueWrite ordering guarantees it lands before any later write that 97 // chains to it). Without this, the ref stays stale at a tombstoned uuid: 98 // the async .then() correction is raced out by the next effect's seq bump 99 // on large sessions where recordTranscript(fullArray) is slow. Only the 100 // compaction case (first uuid changed) remains unsafe — tail may be 101 // messagesToKeep whose last-actually-recorded uuid differs. 102 if (isIncremental || wasFirstRender || isSameHeadShrink) { 103 // Match EXACTLY what recordTranscript persists: cleanMessagesForLogging 104 // applies both the isLoggableMessage filter and (for external users) the 105 // REPL-strip + isVirtual-promote transform. Using the raw predicate here 106 // would pick a UUID that the transform drops, leaving the parent hint 107 // pointing at a message that never reached disk. Pass full messages as 108 // replId context — REPL tool_use and its tool_result land in separate 109 // render cycles, so the slice alone can't pair them. 110 const last = cleanMessagesForLogging(slice, messages).findLast( 111 isChainParticipant, 112 ) 113 if (last) lastParentUuidRef.current = last.uuid as UUID 114 } 115 116 lastRecordedLengthRef.current = messages.length 117 firstMessageUuidRef.current = currentFirstUuid 118 }, [messages, ignore, teamContext?.teamName, teamContext?.selfAgentName]) 119}