Newer
Older
navi-1 / webclient / src / stores / chat / session.js
import * as api from '@/api/index.js'
import { t } from '@/i18n/index.js'

/**
 * Session lifecycle: loading a session (paged history), clearing it, the
 * forced reload used after a reconnect, the cheap "did another device move
 * this session" check, and archive pagination.
 */

// Guard against late completion of stale loadSession calls
let _lastLoadId = null
// The load currently in flight. A newer load (or a session switch) aborts it,
// so a slow response for the previous session can never land on the new one.
let _loadController = null
let _loadSeq = 0

export function createSessionActions({ state, messageHelpers, content, tasks, sessionsStore }) {
  const {
    currentId,
    currentProfileId,
    messages,
    artifacts,
    files,
    streaming,
    loading,
    loadError,
    loadErrorSessionId,
    awaitingStream,
    streamingMsg,
    contextTokens,
    maxContextTokens,
    servedModel,
    servedTarget,
    servedFallback,
    terminals,
    backgroundTasks,
    _terminalTaskIds,
    toolIndex,
    archiveLoading,
    archiveHasMore,
    archiveNextBefore,
    historyCount,
    archiveExtended,
    scrollToMessageIndex,
    viewedImages,
    _viewedImageKeys,
    replayMode,
  } = state
  const { buildMessageList } = messageHelpers
  const { mergeFeedback, fetchArtifacts, fetchFiles } = content
  const { fetchTasks } = tasks

  async function loadSession(id, targetIndex = null) {
    if (currentId.value === id && targetIndex == null) return
    const loadSeq = ++_loadSeq
    _lastLoadId = id
    // A switch aborts the load it replaced: without this the slower response
    // of the previous session could still be applied after the newer one.
    if (_loadController) _loadController.abort()
    const controller = new AbortController()
    _loadController = controller
    const isCurrent = () =>
      _lastLoadId === id && _loadSeq === loadSeq && !controller.signal.aborted

    loading.value = true
    loadError.value = null
    loadErrorSessionId.value = null
    scrollToMessageIndex.value = targetIndex

    try {
      // Paged load: light meta + newest page of display history. Older pages
      // arrive on scroll-up via getSessionHistory(before_seq).
      const [session, page] = await Promise.all([
        api.getSessionMeta(id, { signal: controller.signal }),
        api.getSessionHistory(id, null, 200, { signal: controller.signal }),
      ])
      // Ignore stale responses — user may have switched to a different session while this loaded
      if (!isCurrent()) return

      // Session switch: everything below belongs to the previous session, so
      // it is reset only once the new data is here. (Resetting up front is what
      // turned one failed request into an empty transcript with no way back.)
      // The viewed-images group included: it belongs to the session's artifacts
      // panel, so it starts over from this session's history.
      viewedImages.value = []
      _viewedImageKeys.clear()
      artifacts.value = []
      files.value = []
      streaming.value = false
      awaitingStream.value = false
      streamingMsg.value = null
      contextTokens.value = 0
      maxContextTokens.value = 0
      servedModel.value = null
      servedTarget.value = null
      servedFallback.value = false
      terminals.value = {}
      backgroundTasks.value = {}
      _terminalTaskIds.clear()
      toolIndex.clear()
      archiveExtended.value = false

      currentProfileId.value = session.profile_id ?? null
      historyCount.value = session.message_count ?? 0
      // The paged session list may not include this session — keep its name
      // in the persistent map for document.title lookups
      sessionsStore.rememberName(id, session.name)
      messages.value = buildMessageList(page.items ?? [])
      // Set archive state from the page itself — hot messages below the tail
      // are NOT archived yet, so archive_threshold alone can't tell.
      archiveLoading.value = false
      archiveHasMore.value = page.has_more ?? false
      archiveNextBefore.value = page.next_before_seq ?? null
      if (session.context_token_count) contextTokens.value = session.context_token_count
      if (session.max_context_tokens)  maxContextTokens.value = session.max_context_tokens

      // Search-jump target may sit below the loaded tail — pull pages until
      // the target raw index is covered (bounded, then give up quietly).
      // These pages are fetched for THIS id explicitly: the shared
      // loadArchivedMessages() reads currentId, which is still the previous
      // session here (it is set only after REST completes, see below).
      if (targetIndex != null) {
        let guard = 0
        while (
          !messages.value.some(m => m.rawIndices?.includes(targetIndex)) &&
          archiveHasMore.value && guard < 20
        ) {
          const data = await api.getSessionHistory(
            id, archiveNextBefore.value, 200, { signal: controller.signal }
          )
          if (!isCurrent()) return
          messages.value = [...buildMessageList(data.items ?? []), ...messages.value]
          archiveHasMore.value = data.has_more ?? false
          archiveNextBefore.value = data.next_before_seq ?? null
          archiveExtended.value = true
          guard++
        }
      }

      await Promise.all([mergeFeedback(id), fetchArtifacts(id), fetchFiles(id)])
      if (!isCurrent()) return
      // Set currentId and hash AFTER REST completes — this triggers ws.connect() via the
      // watch in ChatArea. Doing it last guarantees messages are populated before WS replay
      // starts, eliminating the race where WS replay arrives before REST and gets overwritten.
      currentId.value = id
      location.hash = id
      // Tasks snapshot goes after currentId: fetchTasks refuses to apply a
      // snapshot for a session other than the current one, and currentId was
      // null during the REST block above.
      fetchTasks(id)
    } catch (err) {
      if (controller.signal.aborted || _lastLoadId !== id) return
      console.error('[chat] loadSession failed', err)
      // The previous transcript is still in place; tell the user why the
      // session they asked for did not open, and keep the id for "Retry".
      const reason = err?.name === 'TimeoutError'
        ? t('chat.loadTimeout')
        : (err?.message || String(err))
      loadError.value = t('chat.loadFailed', { error: reason })
      loadErrorSessionId.value = id
    } finally {
      if (_lastLoadId === id && _loadSeq === loadSeq) {
        loading.value = false
        if (_loadController === controller) _loadController = null
      }
    }
  }

  function dismissLoadError() {
    loadError.value = null
    loadErrorSessionId.value = null
  }

  function clearSession() {
    currentId.value = null
    currentProfileId.value = null
    loadError.value = null
    loadErrorSessionId.value = null
    messages.value = []
    viewedImages.value = []
    _viewedImageKeys.clear()
    artifacts.value = []
    files.value = []
    streaming.value = false
    awaitingStream.value = false
    streamingMsg.value = null
    contextTokens.value = 0
    maxContextTokens.value = 0
    servedModel.value = null
    servedTarget.value = null
    servedFallback.value = false
    terminals.value = {}
    backgroundTasks.value = {}
    _terminalTaskIds.clear()
    toolIndex.clear()
    location.hash = ''
  }

  // Force-reload session history from the server, bypassing the same-id guard.
  // Called after WebSocket reconnect so the client sees the saved response
  // even if streaming finished while the client was disconnected.
  async function reloadSession(id) {
    if (!id) return
    // Guard: don't replace the messages array while a stream is active.
    // If we do, streamingMsg becomes an orphan and Vue stops rendering
    // further deltas / tool cards.
    if (streaming.value) return
    // A full load is in flight and owns the transcript until it settles.
    if (_loadController) return
    try {
      const [session, page] = await Promise.all([
        api.getSessionMeta(id),
        api.getSessionHistory(id, null, 200),
      ])
      // The await above is a yield point — onStreamStart may have fired, and
      // the user may have switched sessions. Either way this response is no
      // longer the truth for what is on screen.
      if (streaming.value || currentId.value !== id) return
      currentProfileId.value = session.profile_id ?? null
      historyCount.value = session.message_count ?? 0
      const built = buildMessageList(page.items ?? [])
      messages.value = built
      archiveHasMore.value = page.has_more ?? false
      archiveNextBefore.value = page.next_before_seq ?? null
      await Promise.all([mergeFeedback(id), fetchArtifacts(id), fetchFiles(id), fetchTasks(id)])
      if (currentId.value !== id) return
      if (session.context_token_count) contextTokens.value = session.context_token_count
      if (session.max_context_tokens) maxContextTokens.value = session.max_context_tokens
      streamingMsg.value = null
      streaming.value = false
      awaitingStream.value = false
    } catch {
      // Silently ignore — stale data is better than a crash
    }
  }

  // Called when the WebSocket reconnects. A turn that was streaming when the
  // socket died has no ending event coming: if the run is still alive the
  // server replays it from the start, and if it finished the only thing that
  // arrives is session_sync — which reloadSession refuses while `streaming`
  // is true. Dropping the orphan here is what lets the recovery through.
  function dropStaleStream() {
    const msg = streamingMsg.value
    if (msg) {
      const idx = messages.value.indexOf(msg)
      if (idx !== -1) messages.value.splice(idx, 1)
    }
    streamingMsg.value = null
    streaming.value = false
    awaitingStream.value = false
    replayMode.value = false
  }

  // Cheap catch-up for a session that may have moved on elsewhere (another
  // tab, the phone, a recall). Only the message count is fetched; history is
  // reloaded only when it actually changed.
  async function checkForUpdates(id = currentId.value) {
    if (!id || streaming.value || loading.value) return false
    let meta
    try {
      meta = await api.getSessionMeta(id)
    } catch {
      return false
    }
    // The await is a yield point — the session may have changed under us.
    if (currentId.value !== id || streaming.value) return false
    const count = meta.message_count ?? 0
    if (count === historyCount.value) return false
    // A reader deep in the archive must keep the pages they scrolled to. A
    // full reload replaces the list with the newest page, which drops them —
    // so while archive pages are open, only the changed tail is merged in.
    if (archiveExtended.value) {
      try {
        return await _mergeNewTail(id, count)
      } catch (err) {
        // A failed catch-up must not surface as an unhandled rejection; the
        // next sync (visibility, session_sync) retries — the count still differs.
        console.error('[chat] catch-up failed', err)
        return false
      }
    }
    await reloadSession(id)
    return true
  }

  // How far back a tail catch-up may walk to reach the transcript already on
  // screen. A larger gap falls back to a full reload rather than leaving a
  // hole in the middle of the conversation.
  const MAX_CATCHUP_PAGES = 5

  // [smallest, largest] global display index among built messages.
  function _rawBounds(items) {
    let min = Infinity
    let max = -Infinity
    for (const m of items) {
      for (const i of m.rawIndices ?? []) {
        if (i < min) min = i
        if (i > max) max = i
      }
    }
    return [min, max]
  }

  /**
   * Replace the tail of an archive-extended transcript without discarding the
   * older pages. Pages are fetched newest-first until they overlap what is on
   * screen, so no messages can fall into a gap between the two.
   */
  async function _mergeNewTail(id, count) {
    const [, ceiling] = _rawBounds(messages.value)
    let fetched = []
    let before = null
    for (let page = 0; page < MAX_CATCHUP_PAGES; page++) {
      const data = await api.getSessionHistory(id, before, 200)
      // Yield points: a stream or a session switch may have taken over.
      if (currentId.value !== id || streaming.value) return false
      fetched = [...buildMessageList(data.items ?? []), ...fetched]
      const [floor] = _rawBounds(fetched)
      if (floor <= ceiling) break  // the pages touch — no hole
      before = data.next_before_seq ?? null
      if (!(data.has_more ?? false) || before == null) break
    }
    const [floor] = _rawBounds(fetched)
    if (floor > ceiling) {
      // Either nothing came back or the gap is wider than this walk covers.
      // Correctness first: the full reload may scroll the reader, a hole in
      // the transcript would lie to them.
      await reloadSession(id)
      return true
    }
    // Everything older than the fetched window stays exactly as it is.
    const kept = messages.value.filter(m => {
      const raw = m.rawIndices ?? []
      return raw.length > 0 && Math.max(...raw) < floor
    })
    // Messages the server queued but has not stored yet exist only here, so
    // they go after the fresh tail instead of being replaced by it.
    const pending = messages.value.filter(
      m => m.queued && !(m.rawIndices ?? []).length
    )
    messages.value = [...kept, ...fetched, ...pending]
    historyCount.value = count
    return true
  }

  async function loadArchivedMessages() {
    const id = currentId.value
    if (!id || archiveLoading.value || !archiveHasMore.value) return
    archiveLoading.value = true
    // Older pages are in the list now. Automatic catch-up never replaces the
    // whole list after this point — it merges the changed tail instead, so the
    // pages loaded here survive (see _mergeNewTail).
    archiveExtended.value = true
    const target = messages.value
    try {
      // Full-history pagination (archive ∪ unarchived old messages) — the
      // archive table alone misses sessions whose threshold never moved.
      const data = await api.getSessionHistory(id, archiveNextBefore.value)
      // Another session may have been opened while this page was in flight;
      // prepending it would graft one session's history onto another.
      if (currentId.value !== id || messages.value !== target) return
      const built = buildMessageList(data.items ?? [])
      // Prepend older messages to the beginning of the list
      messages.value = [...built, ...messages.value]
      archiveHasMore.value = data.has_more ?? false
      archiveNextBefore.value = data.next_before_seq ?? null
    } catch (err) {
      console.error('[chat] loadArchivedMessages failed', err)
    } finally {
      archiveLoading.value = false
    }
  }

  function initArchiveState(hasMore = false, nextBefore = null) {
    archiveHasMore.value = hasMore
    archiveNextBefore.value = nextBefore
  }

  return {
    loadSession,
    clearSession,
    dismissLoadError,
    reloadSession,
    dropStaleStream,
    checkForUpdates,
    loadArchivedMessages,
    initArchiveState,
  }
}