import * as api from '@/api/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
export function createSessionActions({ state, messageHelpers, content, tasks, sessionsStore }) {
const {
currentId,
currentProfileId,
messages,
artifacts,
files,
streaming,
loading,
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
_lastLoadId = id
// Session switch: the viewed-images group belongs to the session's
// artifacts panel — start over from this session's history.
viewedImages.value = []
_viewedImageKeys.clear()
loading.value = true
messages.value = []
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
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),
api.getSessionHistory(id, null, 200),
])
// Ignore stale responses — user may have switched to a different session while this loaded
if (_lastLoadId !== id) return
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).
if (targetIndex != null) {
let guard = 0
while (
!messages.value.some(m => m.rawIndices?.includes(targetIndex)) &&
archiveHasMore.value && guard < 20
) {
await loadArchivedMessages()
guard++
}
}
await Promise.all([mergeFeedback(id), fetchArtifacts(id), fetchFiles(id)])
// 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) {
console.error('[chat] loadSession failed', err)
} finally {
loading.value = false
}
}
function clearSession() {
currentId.value = null
currentProfileId.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
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 set streaming to true while we were waiting.
if (streaming.value) 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 (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() {
if (!currentId.value || 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
try {
// Full-history pagination (archive ∪ unarchived old messages) — the
// archive table alone misses sessions whose threshold never moved.
const data = await api.getSessionHistory(currentId.value, archiveNextBefore.value)
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,
reloadSession,
dropStaleStream,
checkForUpdates,
loadArchivedMessages,
initArchiveState,
}
}