Files
Jyotisha/frontend/src/hooks/use-session-management.ts
T
jesse-uxandGrok (agent loop) 6350943152 回答生成时侧栏不再跟着每一帧重画
外壳只在会话、当前项或账户这些数据变了才更新。60 帧计数从注册 60 次、提交 60 次降到 0 次,点下去仍是最新一次操作。BUG-1329。

Co-Authored-By: Grok (agent loop)
2026-10-14 04:12:02 +08:00

667 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { showChatNotice as setComposerNotice } from "@/lib/chat-notice";
import { writeChatSession } from "@/lib/chat-session-write-contract";
import {
SESSION_LOOKUP_FAILED_NOTICE,
SESSION_MISSING_NOTICE,
parseSessionUrlQuery,
writeSessionUrl,
} from "@/lib/chat-session-url";
import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model";
import { currentSubjectCatalogReady, readCurrentSubjectId, readCurrentSubjectScope, subscribeCurrentSubject, subscribeSubjectCatalog, useCurrentSubject } from "@/lib/current-subject";
import { mergeSubjectSessionRows } from "@/lib/subject-session-merge";
import { consultationReportMarkdown } from "@/lib/consultation-report-export";
import { fallbackSessionId } from "@/lib/rectification-session-composer-guard";
import { sortSessions } from "@/lib/session-groups";
import {
clearBirthTimeConsultationConsent,
type BirthTimeConsultationConsentState,
} from "@/lib/birth-time-consultation-consent";
import {
createSession,
fetchSessionDetail,
fetchSessions,
LoginRedirectError,
lookupSessionById,
mergeHydratedSession,
patchSessionModel,
readSessions,
} from "@/lib/home-cloud-sync";
import {
isListedSidebarSession,
isUnsavedEmptyConsultation,
replaceUnsavedEmptyConsultations,
} from "@/lib/session-list-filter";
import {
chatSessionSubjectLocked,
emptySessionBindingWrite,
NEW_CHAT_SUBJECT_ID,
} from "@/lib/chat-profile-picker-model";
import { isConsultationSubjectProfileComplete } from "@/lib/consultation-subject-resolver";
import { chartSnapshotForSession } from "@/lib/home-profile";
import { beginSessionPageLoad, mergeSessionPage } from "@/lib/session-groups";
import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint";
import {
persistSessionModelSelection,
type SessionModelPersistenceQueue,
} from "@/lib/session-model-persistence";
import type { PublicLanguageModelCatalog } from "@/lib/public-models";
import {
type Account,
type ChartLibraryRecord,
type ChatSession,
type Profile,
type RequestError,
type Theme,
} from "@/lib/home-types";
export type SessionManagementParams = {
account: Account | null;
accountId: string | undefined;
activeSession: ChatSession | undefined;
activeSessionId: string;
activeSessionIdRef: MutableRefObject<string>;
applySessionPopStateRef: MutableRefObject<(search: string) => void>;
cancellationPending: boolean;
chartLibrary: ChartLibraryRecord[];
creatingSession: boolean;
modelCatalog: PublicLanguageModelCatalog | null;
modelPersistence: MutableRefObject<SessionModelPersistenceQueue>;
modelSelectionVersions: MutableRefObject<Map<string, number>>;
modelSyncFailures: MutableRefObject<Set<string>>;
pendingSessionId: string | null;
profile: Profile;
rectificationSessionId: string | null;
sessionDetailInFlight: MutableRefObject<Set<string>>;
sessionSelectionSource: MutableRefObject<"user" | "history">;
sessions: ChatSession[];
sessionsRef: MutableRefObject<ChatSession[]>;
setActiveSessionId: Dispatch<SetStateAction<string>>;
setBirthTimeConsultationConsent: Dispatch<SetStateAction<BirthTimeConsultationConsentState>>;
setCreatingSession: Dispatch<SetStateAction<boolean>>;
setDraft: (value: string) => void;
setDraftEntrypoint: (entrypoint: ConsultationEntrypoint | null) => void;
setDraftTheme: (theme: Theme | null) => void;
setRectificationError: Dispatch<SetStateAction<string>>;
setRectificationErrorSessionId: Dispatch<SetStateAction<string | null>>;
setRequestError: Dispatch<SetStateAction<RequestError | null>>;
setSessions: Dispatch<SetStateAction<ChatSession[]>>;
uiPreview: MutableRefObject<boolean>;
openRectificationSession: (exactSessionId: string) => Promise<void> | void;
};
export function useSessionManagement(params: SessionManagementParams) {
const {
account,
activeSession,
activeSessionId,
activeSessionIdRef,
applySessionPopStateRef,
cancellationPending,
chartLibrary,
creatingSession,
modelCatalog,
modelPersistence,
modelSelectionVersions,
modelSyncFailures,
pendingSessionId,
profile,
rectificationSessionId,
sessionDetailInFlight,
sessionSelectionSource,
sessions,
sessionsRef,
setActiveSessionId,
setBirthTimeConsultationConsent,
setDraft,
setDraftEntrypoint,
setDraftTheme,
setRectificationError,
setRectificationErrorSessionId,
setRequestError,
setSessions,
uiPreview,
openRectificationSession,
} = params;
// Owned here since 2026-09-16 (state lowering batch 2). Home reads back only
// what its own JSX renders; the rest never leaves this hook.
const [sessionsCursor, setSessionsCursor] = useState<string | null>(null);
const pageGeneration = useRef(0);
const loadMoreInFlight = useRef(false);
useEffect(() => {
let scope = readCurrentSubjectScope();
const invalidatePage = () => {
const next = readCurrentSubjectScope();
if (next === scope) return;
scope = next;
pageGeneration.current += 1;
loadMoreInFlight.current = false;
setSessionsCursor(null);
};
const offSubject = subscribeCurrentSubject(invalidatePage);
const offCatalog = subscribeSubjectCatalog(invalidatePage);
return () => { pageGeneration.current += 1; offSubject(); offCatalog(); };
}, []);
useEffect(() => {
if (!account?.user.id) return;
let cancelled = false;
let requestGeneration = 0;
const pull = () => {
if (!currentSubjectCatalogReady()) return;
const subject = readCurrentSubjectId();
const generation = ++requestGeneration;
void fetchSessions(undefined, { subject }).then((page) => {
if (cancelled || generation !== requestGeneration || subject !== readCurrentSubjectId()) return;
const incoming = readSessions(page.sessions, modelCatalog).sessions;
setSessions((current) => mergeSubjectSessionRows(current, incoming, {
subjectId: subject,
complete: page.nextCursor == null,
}));
setSessionsCursor(page.nextCursor);
}).catch(() => undefined);
};
const unsubscribe = subscribeCurrentSubject(pull);
return () => {
cancelled = true;
unsubscribe();
};
}, [account?.user.id, modelCatalog, setSessions]);
const [sessionMenuId, setSessionMenuId] = useState<string | null>(null);
const [pendingSessionDeletion, setPendingSessionDeletion] = useState<ChatSession | null>(null);
const [sessionDetailLoadingId, setSessionDetailLoadingId] = useState<string | null>(null);
const [sessionFullPrompt, setSessionFullPrompt] = useState<{ question: string; theme: Theme } | null>(null);
sessionsRef.current = sessions;
const pendingCreateById = useRef(new Map<string, { continuedFromSessionId?: string }>());
const cloudCreatedIds = useRef(new Set<string>());
for (const session of sessions) {
if (!isUnsavedEmptyConsultation(session)) cloudCreatedIds.current.add(session.id);
}
const subjectId = useCurrentSubject();
const visibleSessions = useMemo(() => sortSessions(sessions.filter((session) => isListedSidebarSession(session)
&& sessionMatchesSubject(session, subjectId))), [sessions, subjectId]);
function updateSession(sessionId: string, change: (session: ChatSession) => ChatSession) {
setSessions((current) => current.map((session) => (session.id === sessionId ? change(session) : session)));
}
async function persistSession(
session: ChatSession,
mode: "create" | "update" = "update",
options?: { continuedFromSessionId?: string },
) {
if (!account) throw new Error("账户尚未加载完成");
if (process.env.NODE_ENV === "development" && uiPreview.current) return;
if (mode === "create" && cloudCreatedIds.current.has(session.id)) return;
const continuedFromSessionId = options?.continuedFromSessionId
?? pendingCreateById.current.get(session.id)?.continuedFromSessionId;
const values = mode === "create"
? {
title: session.title,
theme: session.theme,
model_id: session.modelId,
messages: [] as const,
session_type: session.sessionType,
rectification_case_id: session.rectificationCaseId,
chart_profile_id: session.chartProfileId,
chart_profile_name: session.chartProfileName,
chart_profile_role: session.chartProfileRole,
...(continuedFromSessionId
? { continued_from_session_id: continuedFromSessionId }
: {}),
}
: {
title: session.title,
theme: session.theme,
model_id: session.modelId,
chart_profile_id: session.chartProfileId,
chart_profile_name: session.chartProfileName,
chart_profile_role: session.chartProfileRole,
};
await writeChatSession(session.id, values, mode);
if (mode === "create") {
pendingCreateById.current.delete(session.id);
cloudCreatedIds.current.add(session.id);
if (!uiPreview.current) writeSessionUrl(session.id, "push");
}
}
async function ensureSessionMessages(sessionId: string) {
if (!sessionId || uiPreview.current) return;
if (pendingSessionId === sessionId) return;
if (sessionDetailInFlight.current.has(sessionId)) return;
// A warm return's landing consultation (BUG-1040) is active one frame
// before it is in the list; it is local and empty, never a server row.
const known = sessionsRef.current.find((session) => session.id === sessionId)
?? (activeSession?.id === sessionId ? activeSession : undefined);
if (known?.messagesHydrated || known?.sessionType === "birth_time_rectification") return;
sessionDetailInFlight.current.add(sessionId);
setSessionDetailLoadingId(sessionId);
try {
const detailed = await fetchSessionDetail(sessionId, modelCatalog);
if (!detailed) return;
setSessions((existing) => {
const live = existing.find((session) => session.id === sessionId);
if (live?.messagesHydrated) return existing;
return mergeHydratedSession(existing, detailed);
});
} catch (caught) {
if (caught instanceof LoginRedirectError) return;
setComposerNotice(caught instanceof Error ? caught.message : "暂时无法读取聊天记录");
} finally {
sessionDetailInFlight.current.delete(sessionId);
setSessionDetailLoadingId((currentId) => currentId === sessionId ? null : currentId);
}
}
async function continueInNewChat(prompt: { question: string; theme: Theme }) {
setSessionFullPrompt(null);
const sourceSessionId = activeSessionId;
const created = await startNewChat(
sourceSessionId ? { continuedFromSessionId: sourceSessionId } : undefined,
);
if (!created) return;
setDraft(prompt.question);
setDraftTheme(prompt.theme);
}
async function renameSession(session: ChatSession) {
const title = window.prompt("重命名聊天记录", session.title)?.trim();
if (!title || title === session.title) return;
const nextSession = { ...session, title };
updateSession(session.id, () => nextSession);
try {
await persistSession(nextSession);
} catch (caught) {
setComposerNotice(caught instanceof Error ? caught.message : "重命名同步失败");
}
}
async function deleteSession(session: ChatSession) {
if (!account) return;
const previousSessions = sessions;
const nextSessions = sessions.filter((item) => item.id !== session.id);
setSessions(nextSessions);
setBirthTimeConsultationConsent((current) => clearBirthTimeConsultationConsent(current, session.id));
if (activeSessionId === session.id) {
activateFallbackSession(nextSessions);
}
try {
const response = await fetch(`/api/sessions/${encodeURIComponent(session.id)}`, { method: "DELETE" });
const payload = await response.json().catch(() => null) as { error?: string } | null;
if (!response.ok) throw new Error(payload?.error || "删除聊天记录失败");
} catch (caught) {
setSessions(previousSessions);
setComposerNotice(caught instanceof Error ? `删除失败:${caught.message}` : "删除失败");
}
}
function togglePinnedSession(sessionId: string) {
const session = sessions.find((item) => item.id === sessionId);
if (!session) return;
const nextPinned = !session.pinned;
updateSession(sessionId, (current) => ({ ...current, pinned: nextPinned }));
void writeChatSession(sessionId, { pinned: nextPinned }, "update").catch((caught) => {
updateSession(sessionId, (current) => ({ ...current, pinned: session.pinned }));
setComposerNotice(caught instanceof Error ? caught.message : "置顶同步失败");
});
}
async function shareSession(session: ChatSession) {
const sharePayload = {
share_payload_version: 1,
exported_at: new Date().toISOString(),
title: session.title,
theme: session.theme,
message_count: session.messages.length,
messages: session.messages.map((message) => ({ role: message.role, text: message.text })),
};
const reportMarkdown = consultationReportMarkdown({ title: session.title, messages: session.messages });
const transcript = [
`Jyotisha 对话:${session.title}`,
"",
...session.messages.map((message) => `${message.role === "user" ? "我" : "Jyotisha"}:${message.text}`),
"",
"---- Markdown 报告 ----",
reportMarkdown,
"",
"---- JSON 分享包 ----",
JSON.stringify(sharePayload, null, 2),
].join("\n");
try {
await navigator.clipboard.writeText(transcript);
setComposerNotice("已复制当前聊天,可粘贴转发。");
} catch {
setComposerNotice("无法访问剪贴板,请手动复制聊天内容。");
}
}
async function startNewChat(options?: { continuedFromSessionId?: string; subjectId?: "self" }): Promise<ChatSession | null> {
if (!account || !modelCatalog || creatingSession) return null;
const nextSession = {
...createSession(modelCatalog.defaultModelId),
...chartSnapshotForSession(options?.subjectId ?? NEW_CHAT_SUBJECT_ID(), chartLibrary, profile),
};
for (const session of sessions) {
if (isUnsavedEmptyConsultation(session)) pendingCreateById.current.delete(session.id);
}
pendingCreateById.current.set(nextSession.id, {
continuedFromSessionId: options?.continuedFromSessionId,
});
setSessions((current) => replaceUnsavedEmptyConsultations(current, nextSession));
setActiveSessionId(nextSession.id);
setDraft("");
setDraftTheme(null);
setDraftEntrypoint(null);
setComposerNotice("");
setRequestError(null);
return nextSession;
}
function selectSession(sessionId: string) {
const nextSession = sessions.find((session) => session.id === sessionId);
// A rectification session that is not the open one is not switched to
// here: the surface hook opens the Case, hydrates turns and snapshot, and
// only then makes it active and writes the URL, so the current view stays
// put instead of flashing a plain transcript and then an empty panel.
// It reads the selection source before we reset it below.
const deferredRectificationSwitch = nextSession?.sessionType === "birth_time_rectification"
&& nextSession.id !== rectificationSessionId;
if (!deferredRectificationSwitch) setActiveSessionId(sessionId);
setDraft("");
setDraftEntrypoint(null);
setComposerNotice("");
setRectificationError("");
setRectificationErrorSessionId(null);
// Opening a stored session keeps that session's binding. It does not
// follow or rewrite the account-level active chart.
if (nextSession?.sessionType === "birth_time_rectification") {
setRectificationError("");
if (deferredRectificationSwitch) {
// The exact sessionId is passed to the server; the server resolves
// the exact Case and never switches to another rectification record.
void openRectificationSession(nextSession.id);
}
} else {
void ensureSessionMessages(sessionId);
}
if (!deferredRectificationSwitch && !uiPreview.current && sessionSelectionSource.current === "user") {
writeSessionUrl(sessionId, "push");
}
sessionSelectionSource.current = "user";
}
function activateFallbackSession(candidates: ChatSession[]) {
const scoped = candidates.filter((session) => sessionMatchesSubject(session, readCurrentSubjectId()));
const fallbackId = fallbackSessionId(scoped);
if (!fallbackId) {
void startNewChat().then((created) => {
if (created) {
if (!uiPreview.current) writeSessionUrl(created.id, "replace");
return;
}
setActiveSessionId("");
if (!uiPreview.current) writeSessionUrl(null, "replace");
});
return;
}
const fallback = candidates.find((session) => session.id === fallbackId);
if (fallback?.sessionType === "birth_time_rectification") {
selectSession(fallbackId);
return;
}
setActiveSessionId(fallbackId);
if (!uiPreview.current) writeSessionUrl(fallbackId, "replace");
}
async function loadMoreSessions() {
if (!currentSubjectCatalogReady() || !beginSessionPageLoad(loadMoreInFlight, sessionsCursor)) return;
const scope = readCurrentSubjectScope();
const generation = pageGeneration.current;
const stillCurrent = () => scope === readCurrentSubjectScope() && generation === pageGeneration.current;
try {
const page = await fetchSessions(undefined, {
before: sessionsCursor,
subject: readCurrentSubjectId(),
});
if (!stillCurrent()) return;
const incoming = readSessions(page.sessions, modelCatalog).sessions;
for (const session of incoming) cloudCreatedIds.current.add(session.id);
setSessions((current) => mergeSessionPage(current, incoming));
setSessionsCursor(page.nextCursor);
} catch (caught) {
if (!stillCurrent() || caught instanceof LoginRedirectError) return;
setComposerNotice(caught instanceof Error ? caught.message : "暂时无法读取聊天记录");
} finally {
if (stillCurrent()) loadMoreInFlight.current = false;
}
}
applySessionPopStateRef.current = (search: string) => {
if (uiPreview.current) return;
const listed = sessionsRef.current;
const query = parseSessionUrlQuery(search);
const fallbackId = fallbackSessionId(listed.filter((session) => sessionMatchesSubject(session, readCurrentSubjectId())));
const requestedId = query.sessionId;
if (query.present && !requestedId) {
writeSessionUrl(null, "replace");
sessionSelectionSource.current = "history";
if (fallbackId) selectSession(fallbackId);
else setActiveSessionId("");
setComposerNotice(SESSION_MISSING_NOTICE);
return;
}
if (query.present && requestedId && !listed.some((session) => session.id === requestedId)) {
void lookupSessionById(requestedId, modelCatalog).then((looked) => {
if (looked.status === "found") {
cloudCreatedIds.current.add(looked.session.id);
setSessions((current) => mergeHydratedSession(current, looked.session));
sessionSelectionSource.current = "history";
selectSession(looked.session.id);
return;
}
if (looked.status === "missing") {
writeSessionUrl(null, "replace");
sessionSelectionSource.current = "history";
if (fallbackId) selectSession(fallbackId);
else setActiveSessionId("");
setComposerNotice(SESSION_MISSING_NOTICE);
return;
}
setComposerNotice(SESSION_LOOKUP_FAILED_NOTICE);
}).catch((caught) => {
if (caught instanceof LoginRedirectError) return;
setComposerNotice(SESSION_LOOKUP_FAILED_NOTICE);
});
return;
}
if (!requestedId) {
if (fallbackId) {
sessionSelectionSource.current = "history";
selectSession(fallbackId);
} else {
setActiveSessionId("");
}
return;
}
sessionSelectionSource.current = "history";
selectSession(requestedId);
};
async function selectSessionModel(modelId: string) {
const userId = account?.user.id;
if (!activeSession || !modelCatalog || !userId || pendingSessionId || cancellationPending || creatingSession) return;
const selectedModel = modelCatalog.models.find((model) => model.id === modelId);
const retryingFailedSync = activeSession.modelId === modelId && modelSyncFailures.current.has(activeSession.id);
if (!selectedModel || (activeSession.modelId === modelId && !retryingFailedSync)) return;
const nextSession: ChatSession = retryingFailedSync
? activeSession
: { ...activeSession, modelId };
const selectionVersion = (modelSelectionVersions.current.get(nextSession.id) ?? 0) + 1;
modelSelectionVersions.current.set(nextSession.id, selectionVersion);
if (!retryingFailedSync) updateSession(activeSession.id, () => nextSession);
setRequestError(null);
setComposerNotice("");
// BUG-1259: a new chat is saved on its first send (BUG-989) and that create
// already carries the session's model; there is no row to patch yet, so
// the choice stays local instead of reporting a sync failure.
if (isUnsavedEmptyConsultation(nextSession) && !cloudCreatedIds.current.has(nextSession.id)) {
modelSelectionVersions.current.delete(nextSession.id);
modelSyncFailures.current.delete(nextSession.id);
return;
}
try {
await modelPersistence.current.enqueue(nextSession.id, () => persistSessionModelSelection(
async ({ values, sessionId }) => {
if (process.env.NODE_ENV === "development" && uiPreview.current) {
return { found: true, error: null };
}
try {
await patchSessionModel(sessionId, values.model_id);
return { found: true, error: null };
} catch (error) {
return {
found: false,
error: error instanceof Error ? error.message : "模型选择暂时无法同步到云端。",
};
}
},
userId,
nextSession.id,
modelId,
));
if (modelSelectionVersions.current.get(nextSession.id) !== selectionVersion) return;
modelSelectionVersions.current.delete(nextSession.id);
modelSyncFailures.current.delete(nextSession.id);
} catch (caught) {
if (modelSelectionVersions.current.get(nextSession.id) !== selectionVersion) return;
modelSelectionVersions.current.delete(nextSession.id);
modelSyncFailures.current.add(nextSession.id);
if (activeSessionIdRef.current === nextSession.id) {
setComposerNotice(`已在当前页面选择 ${selectedModel.label},但云端同步失败;再次选择当前模型即可重试。`);
}
setRequestError({
sessionId: nextSession.id,
message: caught instanceof Error ? caught.message : "模型选择暂时无法同步到云端。",
});
}
}
function selectableChartRecord(chartProfileId: string) {
const record = chartProfileId === "self"
? chartLibrary.find((item) => item.id === "self" || item.role === "self")
: chartLibrary.find((item) => item.id === chartProfileId && item.role === "other");
if (!record || !isConsultationSubjectProfileComplete(record.profile)) return null;
return record;
}
async function bindEmptyChatSubject(chartProfileId: string): Promise<boolean> {
const session = sessionsRef.current.find((item) => item.id === activeSession?.id) ?? activeSession;
if (!session || chatSessionSubjectLocked(session)) return false;
const record = selectableChartRecord(chartProfileId);
if (!record) return false;
const requestedId = record.role === "self" ? "self" : record.id;
if (session.chartProfileId === requestedId && session.chartProfileRole === record.role) return true;
if (cloudCreatedIds.current.has(session.id)) {
const previousRole = session.chartProfileRole === "self" || session.chartProfileRole === "other"
? session.chartProfileRole
: null;
try {
await writeChatSession(session.id, emptySessionBindingWrite(requestedId, record.role), "update");
const detailed = await fetchSessionDetail(session.id, modelCatalog);
if (detailed?.chartProfileId !== requestedId || detailed.chartProfileRole !== record.role) {
throw new Error("人物没有绑上");
}
updateSession(session.id, (current) => {
if (chatSessionSubjectLocked(current)) return current;
return {
...current,
chartProfileId: detailed.chartProfileId,
chartProfileName: detailed.chartProfileName,
chartProfileRole: detailed.chartProfileRole,
};
});
return true;
} catch (caught) {
if (previousRole) {
try {
await writeChatSession(
session.id,
emptySessionBindingWrite(session.chartProfileId ?? "self", previousRole),
"update",
);
} catch {
setComposerNotice("人物没有绑上");
return false;
}
}
setComposerNotice(caught instanceof Error && caught.message !== "人物没有绑上"
? caught.message
: "人物没有绑上");
return false;
}
}
const snapshot = chartSnapshotForSession(requestedId, chartLibrary, profile);
updateSession(session.id, (current) => {
if (chatSessionSubjectLocked(current)) return current;
return { ...current, ...snapshot };
});
return true;
}
async function openChatBoundToProfile(chartProfileId: string): Promise<boolean> {
if (!account || !modelCatalog || creatingSession) return false;
if (pendingSessionId || cancellationPending) return false;
const record = selectableChartRecord(chartProfileId);
if (!record) return false;
const requestedId = record.role === "self" ? "self" : record.id;
const nextSession = {
...createSession(modelCatalog.defaultModelId),
...chartSnapshotForSession(requestedId, chartLibrary, profile),
};
for (const existing of sessions) {
if (isUnsavedEmptyConsultation(existing)) pendingCreateById.current.delete(existing.id);
}
pendingCreateById.current.set(nextSession.id, {});
setSessions((current) => replaceUnsavedEmptyConsultations(current, nextSession));
setActiveSessionId(nextSession.id);
setDraft("");
setDraftTheme(null);
setDraftEntrypoint(null);
setComposerNotice("");
setRequestError(null);
return true;
}
return {
visibleSessions,
sessionsCursor,
setSessionsCursor,
sessionMenuId,
setSessionMenuId,
pendingSessionDeletion,
setPendingSessionDeletion,
setSessionFullPrompt,
updateSession,
persistSession,
ensureSessionMessages,
continueInNewChat,
renameSession,
deleteSession,
togglePinnedSession,
shareSession,
startNewChat,
bindEmptyChatSubject,
openChatBoundToProfile,
selectSession,
selectSessionModel,
loadMoreSessions,
};
}