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,
}
}