import { useSessionsStore } from '@/stores/sessions.js'
/**
* Tool cards inside a turn: created from tool_started, resolved by tool_call,
* matched by tool_call_id through the non-reactive index (which is what lets a
* result land even after stream_end closed the turn).
*/
export function createToolTracking({ state, content, recall: recallActions, messageHelpers }) {
const { messages, streamingMsg, toolIndex, terminals, currentId, recall } = state
const { upsertArtifact, fetchFiles, fetchArtifacts } = content
const { loadRecall } = recallActions
const { _addViewedImage } = messageHelpers
// Find the last spawn_agent card that's still collecting subagent events
function _lastSpawnCard(msg) {
// Custom findLast for older browser compatibility (ES2023 polyfill-free)
for (let i = msg.tools.length - 1; i >= 0; i--) {
const t = msg.tools[i]
if (t.kind === 'tool' && t.name === 'spawn_agent') return t
}
return undefined
}
function onToolStarted(data) {
const msg = streamingMsg.value
if (!msg) return
const card = {
kind: 'tool',
id: `tool_${Date.now()}`,
toolCallId: data.tool_call_id ?? null,
name: data.tool,
args: data.args,
result: null,
success: null,
pending: true,
startedAt: Date.now(),
isSubagent: data.is_subagent ?? false,
steps: [],
terminalName: data.tool === 'terminal' ? (data.args?.terminal_name || null) : null,
}
card.parentMsg = msg
// The index holds the reactive proxy, not `card`: results are applied
// through this reference long after the card was pushed, and a write to
// the raw object never wakes a computed that read it through the proxy
// (a running content_publish card stayed an error card until the turn's
// message was rebuilt).
if (data.is_subagent) {
const spawn = _lastSpawnCard(msg)
if (spawn) {
spawn.steps.push(card)
if (card.toolCallId) toolIndex.set(card.toolCallId, spawn.steps[spawn.steps.length - 1])
return
}
}
msg.tools.push(card)
if (card.toolCallId) toolIndex.set(card.toolCallId, msg.tools[msg.tools.length - 1])
// Pre-seed terminal metadata when a terminal open starts
if (data.tool === 'terminal' && data.args?.action === 'open') {
const name = data.args.terminal_name
if (name) {
const prev = terminals.value[name] || {}
terminals.value = {
...terminals.value,
[name]: {
...prev,
name,
description: data.args.description || '',
command: data.args.command || '',
background: data.args.background || false,
output: prev.output || '',
closed: false,
}
}
}
}
}
function onToolCall(data) {
// A vision tool read an image (metadata carries the payload) — capture it
// live for the artifacts panel's viewed-images group.
if (data.metadata?.is_image && data.metadata?.base64) {
_addViewedImage([data.metadata.data_url ?? `data:image/jpeg;base64,${data.metadata.base64}`])
}
// Sub-agent tool call → update inside spawn_agent steps. Legacy events
// without tool_call_id need the name+pending search.
if (data.is_subagent && !data.tool_call_id) {
const msg = streamingMsg.value ?? _lastAssistantMsg()
const spawn = msg ? _lastSpawnCard(msg) : null
if (spawn) {
for (let i = spawn.steps.length - 1; i >= 0; i--) {
const t = spawn.steps[i]
if (t.kind === 'tool' && t.name === data.tool && t.pending) {
_applyToolResult(t, data)
return
}
}
}
}
// Id-first: exact card match — works even for events arriving after the
// turn's stream_end (long tools finishing late).
let card = data.tool_call_id ? (toolIndex.get(data.tool_call_id) ?? null) : null
// Legacy fallback: name+pending reverse search in the live turn
if (!card && !data.tool_call_id) {
const msg = streamingMsg.value
if (msg) {
for (let i = msg.tools.length - 1; i >= 0; i--) {
const t = msg.tools[i]
if (t.kind === 'tool' && t.name === data.tool && t.pending) { card = t; break }
}
}
}
// Bare tool_call (no tool_started was seen — late event after the index
// was cleared, or a synthetic result): synthesize a card so it's visible.
if (!card) card = _synthesizeToolCard(data)
if (!card) return
_applyToolResult(card, data)
}
// Shared tool-result application: completes the card and runs the side
// effects (UI components, artifacts, file refresh, terminal metadata, recall).
function _applyToolResult(card, data) {
{
card.result = data.result
card.success = data.success !== false
card.pending = false
if (data.metadata) card.metadata = data.metadata
// Any tool may hand the client a component — navi_ui is the general
// route, and a builtin like `skills` returns one directly for a protocol
// proposal. The client renders only names it knows.
if (card.success && data.metadata?.ui_component) {
// Render the UI component inline as part of the same assistant turn.
// The card knows the turn it belongs to (parentMsg set at creation);
// subagent cards stay nested inside their spawn card.
const parentMsg = card.parentMsg ?? (streamingMsg.value ?? _lastAssistantMsg())
if (parentMsg && !card.isSubagent) {
parentMsg.tools.push({
kind: 'ui_component',
component: data.metadata.ui_component.component ?? '',
payload: data.metadata.ui_component.payload ?? {},
})
}
}
if (data.tool === 'content_publish' && card.success && data.metadata) {
upsertArtifact(data.metadata)
}
if (data.tool === 'filesystem' && card.success && currentId.value) {
const action = typeof card.args === 'object' ? card.args?.action : null
if (['write', 'edit', 'append', 'mkdir', 'rm', 'cp', 'mv'].includes(action)) {
fetchFiles(currentId.value)
}
if (['write', 'edit'].includes(action)) fetchArtifacts(currentId.value)
}
if ((data.tool === 'terminal' || data.tool === 'code_exec') && card.success && currentId.value) {
fetchFiles(currentId.value)
}
// Merge terminal metadata from tool results into terminals store
if (data.tool === 'terminal' && card.success && currentId.value) {
const action = typeof card.args === 'object' ? card.args?.action : null
const name = card.args?.terminal_name
if (data.metadata) {
if (action === 'open' || action === 'status') {
if (name) {
const prev = terminals.value[name] || {}
terminals.value = {
...terminals.value,
[name]: { ...prev, ...data.metadata, name, closed: false },
}
}
} else if (action === 'list' && Array.isArray(data.metadata.terminals)) {
for (const t of data.metadata.terminals) {
if (!t.name) continue
const prev = terminals.value[t.name] || {}
terminals.value = {
...terminals.value,
[t.name]: { ...prev, ...t, closed: false },
}
}
} else if (action === 'close' && name) {
const prev = terminals.value[name] || {}
terminals.value = {
...terminals.value,
[name]: { ...prev, closed: true, closeReason: 'explicit' },
}
}
}
}
// Update recall banner live when scheduling or managing recalls
if (data.tool === 'schedule_recall' && card.success && currentId.value) {
loadRecall(currentId.value)
useSessionsStore().updateRecallStatus(currentId.value, true)
}
if (data.tool === 'manage_recall' && card.success && currentId.value) {
const action = card.args?.action
if (action === 'cancel') {
recall.value = null
useSessionsStore().updateRecallStatus(currentId.value, false)
} else if (action === 'skip') {
loadRecall(currentId.value)
}
}
}
}
// Last assistant message in the transcript with a tools array — target for
// tool events that arrive after the live turn has ended.
function _lastAssistantMsg() {
for (let i = messages.value.length - 1; i >= 0; i--) {
const m = messages.value[i]
if (m.role === 'assistant' && Array.isArray(m.tools)) return m
}
return null
}
// Create a card for a tool_call event with no matching tool_started.
function _synthesizeToolCard(data) {
const msg = streamingMsg.value ?? _lastAssistantMsg()
if (!msg) return null
const card = {
kind: 'tool',
id: `tool_${Date.now()}`,
toolCallId: data.tool_call_id ?? null,
name: data.tool,
args: null,
result: null,
success: null,
pending: false,
isSubagent: data.is_subagent ?? false,
steps: [],
}
// Keep the owning message on the card: a late tool_result can arrive
// after streamingMsg moved on, and side effects like inline ui_component
// still need to land in the turn that owns this card.
card.parentMsg = msg
// Return and index the array's proxy, so the result this card is about to
// receive goes through reactivity (see onToolStarted).
if (data.is_subagent) {
const spawn = _lastSpawnCard(msg)
if (spawn) {
spawn.steps.push(card)
const stored = spawn.steps[spawn.steps.length - 1]
if (card.toolCallId) toolIndex.set(card.toolCallId, stored)
return stored
}
}
msg.tools.push(card)
const stored = msg.tools[msg.tools.length - 1]
if (card.toolCallId) toolIndex.set(card.toolCallId, stored)
return stored
}
return {
_lastSpawnCard,
onToolStarted,
onToolCall,
_applyToolResult,
_lastAssistantMsg,
_synthesizeToolCard,
}
}