/**
* The message transcript: turning stored history into display messages,
* appending what this tab sent, collecting the images Navi looked at, and
* patching a protocol proposal once it is decided.
*/
export function createMessageHelpers(state) {
const { messages, viewedImages, _viewedImageKeys } = state
// Add viewed-by-Navi images (data URIs), skipping ones already collected —
// live tool_call events and history reload both carry the same payload.
function _addViewedImage(imgs) {
for (const src of imgs) {
if (!src || _viewedImageKeys.has(src)) continue
_viewedImageKeys.add(src)
viewedImages.value.push(src)
}
}
// The server returns a flat array:
// { role:'assistant', tool_calls:[{id,name,arguments}] } ← tool request (no content)
// { role:'tool', tool_call_id, name, content } ← tool result
// { role:'assistant', content } ← final text response
//
// We group them into single AssistantMessage objects with tools[] + text.
function buildMessageList(raw) {
const result = []
let i = 0
// Global display position of raw item idx. Server pages (history endpoint)
// stamp each item with metadata.display_index = rank in the FULL display
// history; h_ ids and rawIndices must use that global rank so ids stay
// stable across paged loads (feedback keys, message jumps).
const gi = idx => {
const m = raw[idx]
return m?.metadata?.display_index ?? idx
}
while (i < raw.length) {
const m = raw[i]
const base = gi(i)
// Compression notice
if (m.is_compression) {
result.push({ id: `h_${base}`, role: 'system', type: 'compression_notice', summary: m.content ?? '' })
i++; continue
}
// Synapse reaction event — the service record of a platform event. It is a
// system-role message, but it is addressed to the reader (and to the model)
// rather than being infrastructure, so it gets its own card.
if (m.role === 'system' && m.metadata?.source === 'synapse_event') {
result.push({
id: `h_${base}`,
role: 'system',
type: 'synapse_event',
text: m.content ?? '',
eventType: m.metadata?.event_type ?? '',
time: m.created_at ?? null,
rawIndices: [base],
})
i++; continue
}
// Skip bare system messages
if (m.role === 'system') { i++; continue }
if (m.is_summary) {
result.push({ id: `h_${base}`, role: 'assistant', type: 'summary', text: m.content ?? '', rawIndices: [base] })
i++; continue
}
if (m.role === 'user') {
const imgs = (m.images ?? []).map(b =>
(typeof b === 'string' && b.startsWith('data:')) ? b : `data:image/jpeg;base64,${String(b ?? '')}`
)
// Synthetic "load this image" messages created by the server when a
// vision tool ran (tool_executor/subagent_runner). Not a real user
// turn — strip from the chat feed; the images land in the artifacts
// panel's viewed-images group instead.
if (m.content?.startsWith?.('[Image loaded via') && imgs.length) {
_addViewedImage(imgs)
i++; continue
}
result.push({ id: `h_${base}`, role: 'user', text: m.content ?? '', images: imgs, files: m.files ?? [], time: m.created_at ?? null, rawIndices: [base], isRecall: m.is_recall ?? false })
i++; continue
}
if (m.role === 'assistant') {
// Collect all tool-call rounds + final content into one message
const firstIdx = i
const firstG = base
const tools = []
let thinking = null
let text = ''
let time = null
while (i < raw.length && raw[i].role === 'assistant') {
const am = raw[i]
// Plan card — stored as a separate is_plan message, inject into tools array
if (am.is_plan) {
tools.push({ kind: 'plan', text: am.content ?? '' })
i++
continue
}
// Accumulate thinking from any assistant turn (first one wins for display)
if (am.thinking && !thinking) {
thinking = { text: am.thinking, done: true }
}
if (am.tool_calls?.length) {
// Build a lookup map for this round's tool calls
const callMap = {}
for (const tc of am.tool_calls) {
const card = {
kind: 'tool',
id: tc.id,
name: tc.name,
args: tc.arguments ?? {},
result: null,
success: true,
pending: false,
isSubagent: false,
steps: []
}
tools.push(card)
callMap[tc.id] = card
}
i++
// Collect matching tool results
while (i < raw.length && raw[i].role === 'tool') {
const tr = raw[i]
const card = callMap[tr.tool_call_id]
if (card) {
card.result = tr.content ?? ''
card.success = !tr.content?.startsWith('Error:')
card.metadata = tr.metadata ?? {}
// UI components are rendered from the tool result metadata so they
// stay inside the same assistant turn after page reload. Any tool
// can carry one; the registry decides whether it is known.
if (card.success && card.metadata?.ui_component) {
tools.push({
kind: 'ui_component',
component: card.metadata.ui_component.component ?? '',
payload: card.metadata.ui_component.payload ?? {},
})
}
}
i++
}
} else {
const content = am.content ?? ''
if (!time && am.created_at) time = am.created_at
i++
if (content) {
// Non-empty text → this is the final response
text = content
break
}
// Empty content: if no more assistant messages follow, we're done
if (i >= raw.length || raw[i].role !== 'assistant') {
break
}
// Otherwise skip this empty intermediate message and keep accumulating
}
}
// Only add the message if there's something to show
if (thinking || tools.length || text) {
const msgId = `h_${firstG}`
// Scan the group for metrics (saved on the final assistant turn)
let elapsed_seconds = null, tool_call_count = null, token_count = null
for (let j = firstIdx; j < i; j++) {
const am = raw[j]
if (!am || am.role !== 'assistant') continue
if (am.elapsed_seconds != null) elapsed_seconds = am.elapsed_seconds
if (am.tool_call_count != null) tool_call_count = am.tool_call_count
if (am.token_count != null) token_count = am.token_count
}
result.push({ id: msgId, role: 'assistant', type: 'history', thinking, tools, text, done: true, time, elapsed_seconds, tool_call_count, token_count, rawIndices: Array.from({ length: i - firstIdx }, (_, j) => gi(firstIdx + j)) })
}
continue
}
// Orphan tool message (shouldn't happen)
i++
}
return result
}
// `queued` marks a message the user sent while a turn was already running.
// It rides in the socket until the server finishes that turn and reads it —
// the same "write now, it is processed next" model navi-code uses. The mark
// disappears on the stream_start of the turn that consumes it.
//
// `clientKey` is the id this message carries on the wire (`client_id`). The
// server echoes it in its ack, and delivery events find the bubble by it.
function appendUserMessage(text, images, files, { queued = false, clientKey = null } = {}) {
const msg = {
id: `user_${Date.now()}`,
role: 'user',
text,
images: [...images],
files: [...files],
time: new Date().toISOString(),
queued,
clientKey,
animate: true
}
messages.value.push(msg)
// Read it back so the caller gets the reactive proxy: mutating the raw
// object would not wake the bubble that has to show the failure.
// The caller marks the bubble when the socket refuses it (queue full).
return messages.value[messages.value.length - 1]
}
/**
* Mark the bubble for `clientKey` as not delivered and say why.
*
* `reason` is one of 'unconfirmed' (written to the socket, no ack within the
* deadline), 'dropped' (the server's queue overflowed and evicted it) or
* 'queue_full' (the socket queue here refused it). Returns whether a bubble
* was found — an ack for a message whose transcript was replaced is not an
* error.
*/
function markMessageFailed(clientKey, reason = 'queue_full') {
if (!clientKey) return false
const msg = messages.value.find(m => m.clientKey === clientKey)
if (!msg) return false
msg.failed = true
msg.queued = false
msg.failReason = msg.failReason ?? reason
return true
}
// A protocol proposal the user accepted or rejected in the chat card. The
// server performed the catalog write; patching every card that shows this
// proposal is what turns the buttons into a decided state — including cards
// duplicated across turns.
function onProposalDecided(data) {
if (!data?.proposal_id) return
for (const msg of messages.value) {
for (const item of msg.tools || []) {
if (
item.kind === 'ui_component'
&& item.component === 'skill_proposal'
&& item.payload?.proposal_id === data.proposal_id
) {
item.payload.status = data.status
item.payload.skill_id = data.skill_id ?? null
item.payload.error = data.error || ''
}
}
}
}
return { _addViewedImage, buildMessageList, appendUserMessage, markMessageFailed, onProposalDecided }
}