2026-01-04 10:25:19 +09:00
|
|
|
"use client"
|
|
|
|
|
|
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react"
|
2026-10-05 22:39:32 +09:00
|
|
|
import { toast } from "sonner"
|
|
|
|
|
import { useDictionary } from "@/hooks/use-dictionary"
|
2026-01-04 10:25:19 +09:00
|
|
|
import {
|
|
|
|
|
type ChatSession,
|
|
|
|
|
createEmptySession,
|
|
|
|
|
deleteSession as deleteSessionFromDB,
|
|
|
|
|
enforceSessionLimit,
|
|
|
|
|
extractTitle,
|
|
|
|
|
getAllSessionMetadata,
|
|
|
|
|
getSession,
|
|
|
|
|
isIndexedDBAvailable,
|
|
|
|
|
migrateFromLocalStorage,
|
2026-10-05 22:39:32 +09:00
|
|
|
readSessionCount,
|
2026-01-04 10:25:19 +09:00
|
|
|
type SessionMetadata,
|
|
|
|
|
type StoredMessage,
|
|
|
|
|
saveSession,
|
|
|
|
|
} from "@/lib/session-storage"
|
2026-10-05 22:39:32 +09:00
|
|
|
import { STORAGE_KEYS } from "@/lib/storage"
|
2026-01-04 10:25:19 +09:00
|
|
|
|
|
|
|
|
export interface SessionData {
|
|
|
|
|
messages: StoredMessage[]
|
|
|
|
|
xmlSnapshots: [number, string][]
|
|
|
|
|
diagramXml: string
|
|
|
|
|
thumbnailDataUrl?: string
|
|
|
|
|
diagramHistory?: { svg: string; xml: string }[]
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-05 22:39:32 +09:00
|
|
|
// Taken right before a save's data is read: the chat on screen then, and
|
|
|
|
|
// the order of the reads
|
|
|
|
|
export interface SaveTicket {
|
|
|
|
|
generation: number
|
|
|
|
|
seq: number
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-04 10:25:19 +09:00
|
|
|
export interface UseSessionManagerReturn {
|
|
|
|
|
// State
|
|
|
|
|
sessions: SessionMetadata[]
|
|
|
|
|
currentSessionId: string | null
|
|
|
|
|
currentSession: ChatSession | null
|
|
|
|
|
isLoading: boolean
|
|
|
|
|
isAvailable: boolean
|
|
|
|
|
|
|
|
|
|
// Actions
|
|
|
|
|
switchSession: (id: string) => Promise<SessionData | null>
|
|
|
|
|
deleteSession: (id: string) => Promise<{ wasCurrentSession: boolean }>
|
2026-10-05 22:39:32 +09:00
|
|
|
// ticket: getSaveTicket() before the data was read (by default, now).
|
|
|
|
|
// The save is dropped if another chat is on screen when its turn comes,
|
|
|
|
|
// or if a copy of this chat read later was saved already.
|
|
|
|
|
// Resolves to false when the save failed (the user was told)
|
2026-01-04 10:25:19 +09:00
|
|
|
saveCurrentSession: (
|
|
|
|
|
data: SessionData,
|
2026-10-05 22:39:32 +09:00
|
|
|
ticket?: SaveTicket,
|
|
|
|
|
) => Promise<boolean>
|
2026-01-04 10:25:19 +09:00
|
|
|
refreshSessions: () => Promise<void>
|
|
|
|
|
clearCurrentSession: () => void
|
2026-10-05 22:39:32 +09:00
|
|
|
getChatGeneration: () => number
|
|
|
|
|
getSaveTicket: () => SaveTicket
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Reading the session list loads every stored session in full, and window
|
|
|
|
|
// focus also fires each time the user clicks back from the draw.io iframe
|
|
|
|
|
const FOCUS_REFRESH_INTERVAL_MS = 30_000
|
|
|
|
|
|
|
|
|
|
function notifySaveFailed(message: string) {
|
|
|
|
|
// Same id, so repeated failures update one toast instead of stacking
|
|
|
|
|
toast.error(message, { id: "session-save-failed", duration: 8000 })
|
2026-01-04 10:25:19 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface UseSessionManagerOptions {
|
|
|
|
|
/** Session ID from URL param - if provided, load this session; if null, start blank */
|
|
|
|
|
initialSessionId?: string | null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function useSessionManager(
|
|
|
|
|
options: UseSessionManagerOptions = {},
|
|
|
|
|
): UseSessionManagerReturn {
|
|
|
|
|
const { initialSessionId } = options
|
2026-10-05 22:39:32 +09:00
|
|
|
const dict = useDictionary()
|
2026-01-04 10:25:19 +09:00
|
|
|
const [sessions, setSessions] = useState<SessionMetadata[]>([])
|
|
|
|
|
const [currentSessionId, setCurrentSessionId] = useState<string | null>(
|
|
|
|
|
null,
|
|
|
|
|
)
|
|
|
|
|
const [currentSession, setCurrentSession] = useState<ChatSession | null>(
|
|
|
|
|
null,
|
|
|
|
|
)
|
|
|
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
|
|
|
const [isAvailable, setIsAvailable] = useState(false)
|
|
|
|
|
|
|
|
|
|
const isInitializedRef = useRef(false)
|
|
|
|
|
// Sequence guard for URL changes - prevents out-of-order async resolution
|
|
|
|
|
const urlChangeSequenceRef = useRef(0)
|
2026-10-05 22:39:32 +09:00
|
|
|
// The chat on screen, read by saves that run after a render or a wait
|
|
|
|
|
const currentSessionRef = useRef<ChatSession | null>(null)
|
|
|
|
|
// Goes up each time another chat is put on screen (creating the
|
|
|
|
|
// session of the chat on screen does not count)
|
|
|
|
|
const chatGenerationRef = useRef(0)
|
|
|
|
|
// Saves run one at a time, so two saves of a new chat create it once
|
|
|
|
|
const saveQueueRef = useRef<Promise<unknown>>(Promise.resolve())
|
|
|
|
|
// The last ticket number, and that of the newest data saved
|
|
|
|
|
const saveSeqRef = useRef(0)
|
|
|
|
|
const savedSeqRef = useRef(0)
|
|
|
|
|
|
|
|
|
|
const changeChat = useCallback((session: ChatSession | null) => {
|
|
|
|
|
chatGenerationRef.current++
|
|
|
|
|
currentSessionRef.current = session
|
|
|
|
|
setCurrentSession(session)
|
|
|
|
|
setCurrentSessionId(session?.id ?? null)
|
|
|
|
|
}, [])
|
2026-01-04 10:25:19 +09:00
|
|
|
|
|
|
|
|
// Load sessions list
|
|
|
|
|
const refreshSessions = useCallback(async () => {
|
|
|
|
|
if (!isIndexedDBAvailable()) return
|
|
|
|
|
try {
|
|
|
|
|
const metadata = await getAllSessionMetadata()
|
|
|
|
|
setSessions(metadata)
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Failed to refresh sessions:", error)
|
|
|
|
|
}
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
// Initialize on mount
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (isInitializedRef.current) return
|
|
|
|
|
isInitializedRef.current = true
|
|
|
|
|
|
|
|
|
|
async function init() {
|
|
|
|
|
setIsLoading(true)
|
|
|
|
|
|
|
|
|
|
if (!isIndexedDBAvailable()) {
|
|
|
|
|
setIsAvailable(false)
|
|
|
|
|
setIsLoading(false)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setIsAvailable(true)
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Run migration first (one-time conversion from localStorage)
|
|
|
|
|
await migrateFromLocalStorage()
|
|
|
|
|
|
|
|
|
|
// Load sessions list
|
|
|
|
|
const metadata = await getAllSessionMetadata()
|
|
|
|
|
setSessions(metadata)
|
2026-10-05 22:39:32 +09:00
|
|
|
// The desktop app may try its other port next launch, where
|
|
|
|
|
// an older version may have saved the chats: only when this
|
|
|
|
|
// origin surely has none (a failed read is not "none") and
|
|
|
|
|
// keeps no model settings or keys either
|
|
|
|
|
if (window.electronAPI?.chatsLoaded) {
|
|
|
|
|
const count = await readSessionCount()
|
|
|
|
|
// The app saves an empty config on its first load; the
|
|
|
|
|
// providers are what holds the keys, besides an access
|
|
|
|
|
// code
|
|
|
|
|
let hasSettings = true
|
|
|
|
|
try {
|
|
|
|
|
const config = JSON.parse(
|
|
|
|
|
localStorage.getItem(STORAGE_KEYS.modelConfigs) ??
|
|
|
|
|
"{}",
|
|
|
|
|
)
|
|
|
|
|
hasSettings =
|
|
|
|
|
(config.providers?.length ?? 0) > 0 ||
|
|
|
|
|
!!localStorage.getItem(STORAGE_KEYS.accessCode)
|
|
|
|
|
} catch {
|
|
|
|
|
// Unreadable: treat as settings, and stay
|
|
|
|
|
}
|
|
|
|
|
if (count !== null && !hasSettings) {
|
|
|
|
|
window.electronAPI.chatsLoaded(count).catch(() => {})
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-04 10:25:19 +09:00
|
|
|
|
|
|
|
|
// Only load a session if initialSessionId is provided (from URL param)
|
|
|
|
|
if (initialSessionId) {
|
|
|
|
|
const session = await getSession(initialSessionId)
|
2026-10-05 22:39:32 +09:00
|
|
|
if (session) changeChat(session)
|
2026-01-04 10:25:19 +09:00
|
|
|
// If session not found, stay in blank state (URL has invalid session ID)
|
|
|
|
|
}
|
|
|
|
|
// If no initialSessionId, start with blank state (no auto-restore)
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Failed to initialize session manager:", error)
|
|
|
|
|
} finally {
|
|
|
|
|
setIsLoading(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
init()
|
2026-10-05 22:39:32 +09:00
|
|
|
}, [initialSessionId, changeChat])
|
2026-01-04 10:25:19 +09:00
|
|
|
|
|
|
|
|
// Handle URL session ID changes after initialization
|
|
|
|
|
// Note: intentionally NOT including currentSessionId in deps to avoid race conditions
|
|
|
|
|
// when clearCurrentSession() is called before URL updates
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!isInitializedRef.current) return // Wait for initial load
|
|
|
|
|
if (!isAvailable) return
|
|
|
|
|
|
|
|
|
|
// Increment sequence to invalidate any pending async operations
|
|
|
|
|
urlChangeSequenceRef.current++
|
|
|
|
|
const currentSequence = urlChangeSequenceRef.current
|
|
|
|
|
|
|
|
|
|
async function handleSessionIdChange() {
|
|
|
|
|
if (initialSessionId) {
|
2026-10-05 22:39:32 +09:00
|
|
|
const generation = chatGenerationRef.current
|
2026-01-04 10:25:19 +09:00
|
|
|
// URL has session ID - load it
|
|
|
|
|
const session = await getSession(initialSessionId)
|
|
|
|
|
|
|
|
|
|
// Check if this request is still the latest (sequence guard)
|
|
|
|
|
// If not, a newer URL change happened while we were loading
|
|
|
|
|
if (currentSequence !== urlChangeSequenceRef.current) {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-05 22:39:32 +09:00
|
|
|
// Another chat was put on screen meanwhile (New Chat right
|
|
|
|
|
// after this one got its session id in the URL): keep it
|
|
|
|
|
if (generation !== chatGenerationRef.current) return
|
2026-01-04 10:25:19 +09:00
|
|
|
|
2026-10-05 22:39:32 +09:00
|
|
|
// Only update if the session is different from current
|
|
|
|
|
if (session && currentSessionRef.current?.id !== session.id) {
|
|
|
|
|
changeChat(session)
|
2026-01-04 10:25:19 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Removed: else clause that clears session
|
|
|
|
|
// Clearing is now handled explicitly by clearCurrentSession()
|
|
|
|
|
// This prevents race conditions when URL update is async
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
handleSessionIdChange()
|
2026-10-05 22:39:32 +09:00
|
|
|
}, [initialSessionId, isAvailable, changeChat])
|
2026-01-04 10:25:19 +09:00
|
|
|
|
2026-10-05 22:39:32 +09:00
|
|
|
// Refresh sessions on window focus (multi-tab sync), at most once per interval
|
|
|
|
|
const lastFocusRefreshRef = useRef(0)
|
2026-01-04 10:25:19 +09:00
|
|
|
useEffect(() => {
|
|
|
|
|
const handleFocus = () => {
|
2026-10-05 22:39:32 +09:00
|
|
|
const now = Date.now()
|
|
|
|
|
if (now - lastFocusRefreshRef.current < FOCUS_REFRESH_INTERVAL_MS) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
lastFocusRefreshRef.current = now
|
2026-01-04 10:25:19 +09:00
|
|
|
refreshSessions()
|
|
|
|
|
}
|
|
|
|
|
window.addEventListener("focus", handleFocus)
|
|
|
|
|
return () => window.removeEventListener("focus", handleFocus)
|
|
|
|
|
}, [refreshSessions])
|
|
|
|
|
|
|
|
|
|
// Switch to a different session
|
|
|
|
|
const switchSession = useCallback(
|
|
|
|
|
async (id: string): Promise<SessionData | null> => {
|
|
|
|
|
if (id === currentSessionId) return null
|
|
|
|
|
|
2026-10-05 22:39:32 +09:00
|
|
|
// Save current session first if it has messages (as saved
|
|
|
|
|
// last: the caller may have just saved it)
|
|
|
|
|
const current = currentSessionRef.current
|
|
|
|
|
if (current && current.messages.length > 0) {
|
|
|
|
|
await saveSession(current)
|
2026-01-04 10:25:19 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Load the target session
|
|
|
|
|
const session = await getSession(id)
|
|
|
|
|
if (!session) {
|
|
|
|
|
console.error("Session not found:", id)
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-05 22:39:32 +09:00
|
|
|
changeChat(session)
|
2026-01-04 10:25:19 +09:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
messages: session.messages,
|
|
|
|
|
xmlSnapshots: session.xmlSnapshots,
|
|
|
|
|
diagramXml: session.diagramXml,
|
|
|
|
|
thumbnailDataUrl: session.thumbnailDataUrl,
|
|
|
|
|
diagramHistory: session.diagramHistory,
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-10-05 22:39:32 +09:00
|
|
|
[currentSessionId, changeChat],
|
2026-01-04 10:25:19 +09:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Delete a session
|
|
|
|
|
const deleteSession = useCallback(
|
|
|
|
|
async (id: string): Promise<{ wasCurrentSession: boolean }> => {
|
|
|
|
|
const wasCurrentSession = id === currentSessionId
|
|
|
|
|
await deleteSessionFromDB(id)
|
|
|
|
|
|
|
|
|
|
// If deleting current session, clear state (caller will show new empty session)
|
2026-10-05 22:39:32 +09:00
|
|
|
if (wasCurrentSession) changeChat(null)
|
2026-01-04 10:25:19 +09:00
|
|
|
|
|
|
|
|
await refreshSessions()
|
|
|
|
|
|
|
|
|
|
return { wasCurrentSession }
|
|
|
|
|
},
|
2026-10-05 22:39:32 +09:00
|
|
|
[currentSessionId, refreshSessions, changeChat],
|
2026-01-04 10:25:19 +09:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Save current session data (debounced externally by caller)
|
|
|
|
|
const saveCurrentSession = useCallback(
|
2026-10-05 22:39:32 +09:00
|
|
|
(data: SessionData, ticket?: SaveTicket): Promise<boolean> => {
|
|
|
|
|
// The data is of the chat on screen when it was read
|
|
|
|
|
const { generation, seq } = ticket ?? {
|
|
|
|
|
generation: chatGenerationRef.current,
|
|
|
|
|
seq: ++saveSeqRef.current,
|
2026-01-04 10:25:19 +09:00
|
|
|
}
|
2026-10-05 22:39:32 +09:00
|
|
|
const run = async (): Promise<boolean> => {
|
|
|
|
|
// That chat is no longer on screen (leaving it saved it)
|
|
|
|
|
if (generation !== chatGenerationRef.current) return true
|
|
|
|
|
// A copy read later was saved already (one that waited for
|
|
|
|
|
// its thumbnail must not undo it)
|
|
|
|
|
if (seq < savedSeqRef.current) return true
|
|
|
|
|
// Nothing can be stored without IndexedDB
|
|
|
|
|
if (!isIndexedDBAvailable()) return true
|
|
|
|
|
// The user may put another chat on screen while this one is
|
|
|
|
|
// written; the stored copy is still right, the state is not
|
|
|
|
|
const stillOnScreen = () =>
|
|
|
|
|
chatGenerationRef.current === generation
|
|
|
|
|
const currentSession = currentSessionRef.current
|
2026-01-04 10:25:19 +09:00
|
|
|
|
2026-10-05 22:39:32 +09:00
|
|
|
if (!currentSession) {
|
|
|
|
|
// Create a new session if none exists
|
|
|
|
|
const newSession: ChatSession = {
|
|
|
|
|
...createEmptySession(),
|
|
|
|
|
messages: data.messages,
|
|
|
|
|
xmlSnapshots: data.xmlSnapshots,
|
|
|
|
|
diagramXml: data.diagramXml,
|
|
|
|
|
thumbnailDataUrl: data.thumbnailDataUrl,
|
|
|
|
|
diagramHistory: data.diagramHistory,
|
|
|
|
|
title: extractTitle(data.messages),
|
|
|
|
|
}
|
|
|
|
|
// Without a stored session, keep no session id (it would end
|
|
|
|
|
// up in the URL and point to nothing after a reload)
|
|
|
|
|
if (!(await saveSession(newSession))) {
|
|
|
|
|
notifySaveFailed(dict.errors.sessionSaveFailed)
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
savedSeqRef.current = seq
|
|
|
|
|
await enforceSessionLimit()
|
|
|
|
|
if (stillOnScreen()) {
|
|
|
|
|
currentSessionRef.current = newSession
|
|
|
|
|
setCurrentSession(newSession)
|
|
|
|
|
setCurrentSessionId(newSession.id)
|
|
|
|
|
}
|
|
|
|
|
await refreshSessions()
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update existing session
|
|
|
|
|
const updatedSession: ChatSession = {
|
|
|
|
|
...currentSession,
|
2026-01-04 10:25:19 +09:00
|
|
|
messages: data.messages,
|
|
|
|
|
xmlSnapshots: data.xmlSnapshots,
|
|
|
|
|
diagramXml: data.diagramXml,
|
2026-10-05 22:39:32 +09:00
|
|
|
thumbnailDataUrl:
|
|
|
|
|
data.thumbnailDataUrl ??
|
|
|
|
|
currentSession.thumbnailDataUrl,
|
|
|
|
|
diagramHistory:
|
|
|
|
|
data.diagramHistory ?? currentSession.diagramHistory,
|
|
|
|
|
updatedAt: Date.now(),
|
|
|
|
|
// Update title if it's still default and we have messages
|
|
|
|
|
title:
|
|
|
|
|
currentSession.title === "New Chat" &&
|
|
|
|
|
data.messages.length > 0
|
|
|
|
|
? extractTitle(data.messages)
|
|
|
|
|
: currentSession.title,
|
2026-01-04 10:25:19 +09:00
|
|
|
}
|
2026-10-05 22:39:32 +09:00
|
|
|
|
|
|
|
|
if (!(await saveSession(updatedSession))) {
|
|
|
|
|
notifySaveFailed(dict.errors.sessionSaveFailed)
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
savedSeqRef.current = seq
|
|
|
|
|
if (stillOnScreen()) {
|
|
|
|
|
currentSessionRef.current = updatedSession
|
|
|
|
|
setCurrentSession(updatedSession)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update sessions list metadata
|
|
|
|
|
setSessions((prev) =>
|
|
|
|
|
prev.map((s) =>
|
|
|
|
|
s.id === updatedSession.id
|
|
|
|
|
? {
|
|
|
|
|
...s,
|
|
|
|
|
title: updatedSession.title,
|
|
|
|
|
updatedAt: updatedSession.updatedAt,
|
|
|
|
|
messageCount: updatedSession.messages.length,
|
|
|
|
|
hasDiagram:
|
|
|
|
|
!!updatedSession.diagramXml &&
|
|
|
|
|
updatedSession.diagramXml.trim().length >
|
|
|
|
|
0,
|
|
|
|
|
thumbnailDataUrl:
|
|
|
|
|
updatedSession.thumbnailDataUrl,
|
|
|
|
|
}
|
|
|
|
|
: s,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
return true
|
2026-01-04 10:25:19 +09:00
|
|
|
}
|
2026-10-05 22:39:32 +09:00
|
|
|
const result = saveQueueRef.current.then(run)
|
|
|
|
|
saveQueueRef.current = result.catch(() => {})
|
|
|
|
|
return result
|
2026-01-04 10:25:19 +09:00
|
|
|
},
|
2026-10-05 22:39:32 +09:00
|
|
|
[refreshSessions, dict],
|
2026-01-04 10:25:19 +09:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Clear current session state (for starting fresh without loading another session)
|
|
|
|
|
const clearCurrentSession = useCallback(() => {
|
2026-10-05 22:39:32 +09:00
|
|
|
changeChat(null)
|
|
|
|
|
}, [changeChat])
|
|
|
|
|
|
|
|
|
|
const getChatGeneration = useCallback(() => chatGenerationRef.current, [])
|
|
|
|
|
|
|
|
|
|
const getSaveTicket = useCallback(
|
|
|
|
|
(): SaveTicket => ({
|
|
|
|
|
generation: chatGenerationRef.current,
|
|
|
|
|
seq: ++saveSeqRef.current,
|
|
|
|
|
}),
|
|
|
|
|
[],
|
|
|
|
|
)
|
2026-01-04 10:25:19 +09:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
sessions,
|
|
|
|
|
currentSessionId,
|
|
|
|
|
currentSession,
|
|
|
|
|
isLoading,
|
|
|
|
|
isAvailable,
|
|
|
|
|
switchSession,
|
|
|
|
|
deleteSession,
|
|
|
|
|
saveCurrentSession,
|
|
|
|
|
refreshSessions,
|
|
|
|
|
clearCurrentSession,
|
2026-10-05 22:39:32 +09:00
|
|
|
getChatGeneration,
|
|
|
|
|
getSaveTicket,
|
2026-01-04 10:25:19 +09:00
|
|
|
}
|
|
|
|
|
}
|