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 || archiveExtended.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
    if ((meta.message_count ?? 0) === historyCount.value) return false
    await reloadSession(id)
    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 would replace the
    // whole list and pull the reader back to the tail.
    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,
  }
}