Newer
Older
navi-1 / webclient / src / stores / chat / tasks.js
import * as api from '@/api/index.js'

/**
 * Background tasks: the server's snapshot plus the live task_update events,
 * which are never replayed on (re)connect.
 */

export function createTaskTracking(state) {
  const { currentId, backgroundTasks, _terminalTaskIds, toolIndex } = state

  // task_update events are delivered live over the WS and are never replayed
  // on (re)connect — fetch the server's snapshot to rebuild the backgrounds
  // tab. Snapshot entries fill gaps; live entries (from task_update) are kept
  // even if the snapshot doesn't know them yet.
  async function fetchTasks(id = currentId.value) {
    if (!id) {
      backgroundTasks.value = {}
      return
    }
    let tasks = []
    try {
      tasks = (await api.getSessionTasks(id)).tasks ?? []
    } catch {
      return
    }
    if (currentId.value !== id) return  // user switched meanwhile
    const next = {}
    for (const t of tasks) {
      next[t.task_id] = {
        taskId: t.task_id,
        tool: t.tool,
        status: t.status,
        startedAt: t.started_at ?? null,
        finishedAt: t.finished_at ?? null,
        argsSummary: t.args_summary ?? '',
        preview: t.result_preview ?? '',
        subagentTokens: t.subagent_tokens ?? null,
      }
      if (t.status !== 'running') _terminalTaskIds.add(t.task_id)
    }
    for (const live of Object.values(backgroundTasks.value)) {
      next[live.taskId] = live
    }
    backgroundTasks.value = next
  }

  // Out-of-band task_update: upsert/remove the background-task chip entry and
  // append progress to the card that started the task (spawn/terminal/etc.).
  function onTaskUpdate(data) {
    const taskId = data.task_id
    if (!taskId) return
    // Out-of-band events can arrive in the narrow window after this store was
    // reset for a new session but before the WS reconnects — ignore updates
    // that belong to an older session.
    if (data.session_id && currentId.value && data.session_id !== currentId.value) return
    if (data.parent_tool_call_id) {
      const card = toolIndex.get(data.parent_tool_call_id)
      if (card) {
        // A repeated 'running' publish must not duplicate the spinner row.
        const last = card.steps[card.steps.length - 1]
        const sameAsLast =
          last?.kind === 'task_update' &&
          last.status === data.status &&
          last.preview === (data.result_preview ?? '')
        if (!sameAsLast) {
          card.steps.push({
            kind: 'task_update',
            taskId,
            tool: data.tool,
            status: data.status,
            preview: data.result_preview ?? '',
          })
        }
        if (data.subagent_tokens != null) card.subagentTokens = data.subagent_tokens
      }
    }
    if (data.status !== 'running') {
      _terminalTaskIds.add(taskId)
      const prev = backgroundTasks.value[taskId]
      if (prev) {
        // Keep the entry — the Backgrounds tab shows recently finished tasks;
        // _terminalTaskIds is what stops a late 'running' from resurrecting it.
        backgroundTasks.value = {
          ...backgroundTasks.value,
          [taskId]: {
            ...prev,
            status: data.status,
            preview: data.result_preview ?? prev.preview,
            finishedAt: data.finished_at ?? prev.finishedAt ?? null,
            subagentTokens: data.subagent_tokens ?? prev.subagentTokens ?? null,
          },
        }
      }
    } else if (!_terminalTaskIds.has(taskId) && !backgroundTasks.value[taskId]) {
      backgroundTasks.value = {
        ...backgroundTasks.value,
        [taskId]: {
          taskId,
          tool: data.tool,
          status: data.status,
          startedAt: data.started_at ?? null,
          finishedAt: null,
          argsSummary: data.args_summary ?? '',
          preview: '',
          subagentTokens: null,
        },
      }
    }
  }

  return { fetchTasks, onTaskUpdate }
}