🎯 Use case
This file lives under “hooks/”, which covers reusable UI or integration hooks. On the API surface it exposes useLogMessages — mainly functions, hooks, or classes. Dependencies touch crypto and React UI. It composes internal code from state, types, and utils (relative imports).
Generated from folder role, exports, dependency roots, and inline comments — not hand-reviewed for every path.
🧠 Inline summary
import type { UUID } from 'crypto' import { useEffect, useRef } from 'react' import { useAppState } from '../state/AppState.js' import type { Message } from '../types/message.js' import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js'
📤 Exports (heuristic)
useLogMessages
📚 External import roots
Package roots from from "…" (relative paths omitted).
cryptoreact
🖥️ Source preview
import type { UUID } from 'crypto'
import { useEffect, useRef } from 'react'
import { useAppState } from '../state/AppState.js'
import type { Message } from '../types/message.js'
import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js'
import {
cleanMessagesForLogging,
isChainParticipant,
recordTranscript,
} from '../utils/sessionStorage.js'
/**
* Hook that logs messages to the transcript
* conversation ID that only changes when a new conversation is started.
*
* @param messages The current conversation messages
* @param ignore When true, messages will not be recorded to the transcript
*/
export function useLogMessages(messages: Message[], ignore: boolean = false) {
const teamContext = useAppState(s => s.teamContext)
// messages is append-only between compactions, so track where we left off
// and only pass the new tail to recordTranscript. Avoids O(n) filter+scan
// on every setMessages (~20x/turn, so n=3000 was ~120k wasted iterations).
const lastRecordedLengthRef = useRef(0)
const lastParentUuidRef = useRef<UUID | undefined>(undefined)
// First-uuid change = compaction or /clear rebuilt the array; length alone
// can't detect this since post-compact [CB,summary,...keep,new] may be longer.
const firstMessageUuidRef = useRef<UUID | undefined>(undefined)
// Guard against stale async .then() overwriting a fresher sync update when
// an incremental render fires before the compaction .then() resolves.
const callSeqRef = useRef(0)
useEffect(() => {
if (ignore) return
const currentFirstUuid = messages[0]?.uuid as UUID | undefined
const prevLength = lastRecordedLengthRef.current
// First-render: firstMessageUuidRef is undefined. Compaction: first uuid changes.
// Both are !isIncremental, but first-render sync-walk is safe (no messagesToKeep).
const wasFirstRender = firstMessageUuidRef.current === undefined
const isIncremental =
currentFirstUuid !== undefined &&
!wasFirstRender &&
currentFirstUuid === firstMessageUuidRef.current &&
prevLength <= messages.length
// Same-head shrink: tombstone filter, rewind, snip, partial-compact.
// Distinguished from compaction (first uuid changes) because the tail
// is either an existing on-disk message or a fresh message that this
// same effect's recordTranscript(fullArray) will write — see sync-walk
// guard below.
const isSameHeadShrink =
currentFirstUuid !== undefined &&
!wasFirstRender &&
currentFirstUuid === firstMessageUuidRef.current &&
prevLength > messages.length
const startIndex = isIncremental ? prevLength : 0
if (startIndex === messages.length) return
// Full array on first call + after compaction: recordTranscript's own
// O(n) dedup loop handles messagesToKeep interleaving correctly there.
const slice = startIndex === 0 ? messages : messages.slice(startIndex)
const parentHint = isIncremental ? lastParentUuidRef.current : undefined
// Fire and forget - we don't want to block the UI.
const seq = ++callSeqRef.current
void recordTranscript(
slice,
isAgentSwarmsEnabled()
? {
teamName: teamContext?.teamName,
agentName: teamContext?.selfAgentName,
}
: {},
parentHint,
messages,
).then(lastRecordedUuid => {
// For compaction/full array case (!isIncremental): use the async return
// value. After compaction, messagesToKeep in the array are skipped
// (already in transcript), so the sync loop would find a wrong UUID.
// Skip if a newer effect already ran (stale closure would overwrite the
// fresher sync update from the subsequent incremental render).
if (seq !== callSeqRef.current) return
if (lastRecordedUuid && !isIncremental) {
lastParentUuidRef.current = lastRecordedUuid
}
})
// Sync-walk safe for: incremental (pure new-tail slice), first-render
// (no messagesToKeep interleaving), and same-head shrink. Shrink is the
// subtle one: the picked uuid is either already on disk (tombstone/rewind
// — survivors were written before) or is being written by THIS effect's
// recordTranscript(fullArray) call (snip boundary / partial-compact tail
// — enqueueWrite ordering guarantees it lands before any later write that
// chains to it). Without this, the ref stays stale at a tombstoned uuid:
// the async .then() correction is raced out by the next effect's seq bump
// on large sessions where recordTranscript(fullArray) is slow. Only the
// compaction case (first uuid changed) remains unsafe — tail may be
// messagesToKeep whose last-actually-recorded uuid differs.
if (isIncremental || wasFirstRender || isSameHeadShrink) {
// Match EXACTLY what recordTranscript persists: cleanMessagesForLogging
// applies both the isLoggableMessage filter and (for external users) the
// REPL-strip + isVirtual-promote transform. Using the raw predicate here
// would pick a UUID that the transform drops, leaving the parent hint
// pointing at a message that never reached disk. Pass full messages as
// replId context — REPL tool_use and its tool_result land in separate
// render cycles, so the slice alone can't pair them.
const last = cleanMessagesForLogging(slice, messages).findLast(
isChainParticipant,
)
if (last) lastParentUuidRef.current = last.uuid as UUID
}
lastRecordedLengthRef.current = messages.length
firstMessageUuidRef.current = currentFirstUuid
}, [messages, ignore, teamContext?.teamName, teamContext?.selfAgentName])
}