mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-08 01:02:29 +08:00
feat: add chat session history with IndexedDB persistence (#500)
* feat(session): add chat session history with IndexedDB storage - Add session-storage.ts with IndexedDB wrapper using idb library - Add use-session-manager.ts hook for session state management - Add session-history-dropdown.tsx for session selection UI - Integrate session system into chat-panel.tsx - Auto-generate session titles from first user message - Auto-save sessions on message completion - Support session switching, deletion, and creation - Migrate existing localStorage data to IndexedDB - Add i18n translations for session history UI * feat(session): improve history dropdown and persist diagram history - Add time-based grouping (Today, Yesterday, This Week, Earlier) - Add thumbnail previews using Next.js Image component - Add staggered entrance animations with fade-in effects - Improve active session indicator with left border accent - Fix scrolling by using native overflow instead of ScrollArea - Persist diagram version history to IndexedDB sessions - Remove redundant diagram XML from localStorage - Add i18n strings for time group labels (en, ja, zh) * fix(session): prevent data loss on theme change and tab close - Add isDrawioReady effect to restore diagram after DrawIO remount - Add visibilitychange handler to save session when page becomes hidden - Fix missing currentSessionId in saveCurrentSession dependency array - Remove unused sanitizeMessages import from use-session-manager * fix(session): fix diagram save and migration data loss bugs - Add diagramHistory to save effect dependency array so diagram-only edits trigger saves (previously only message changes did) - Destructure stable sessionManager values to prevent unnecessary effect re-runs on every render - Add try-catch wrapper around debounced async save operation - Make saveSession() return boolean to indicate success/failure - Verify IndexedDB write succeeded before deleting localStorage data during migration (prevents data loss if write silently fails) - Keep localStorage data for retry if migration fails instead of marking as complete anyway * refactor(session): extract helpers to reduce code duplication - Add syncUIWithSession helper to consolidate 4 duplicate UI sync blocks - Add buildSessionData helper to consolidate 4 duplicate save logic blocks - Remove unused saveTimeoutRef and its cleanup effect - Net reduction of ~80 lines of duplicate code * style(ui): improve history dropdown and delete dialog styling - Change destructive color from coral to muted rose for refined look - Make session history panel taller (400px fixed height) - Fix popover alignment to prevent truncation - Style delete button with soft red outline instead of solid fill - Make delete dialog more compact (max-w-sm) * fix(session): reset refs on new chat and show recent sessions - Fix cached example diagrams not displaying after creating new session - Reset previousXML, lastProcessedXmlRef and processedToolCalls when messages become empty (new chat or session switch) - Add recent chats section in empty chat state with collapsible examples - Pass sessions and onSelectSession to ChatMessageDisplay - Add loadedMessageIdsRef to skip animations on session restore - Add debug console.log for diagram processing flow * feat(session): add search bar and improve history UI - Remove session history dropdown, use main panel instead - Add search bar to filter history chats by title - Show minutes (Xm ago) instead of "Just now" for recent sessions - Scroll to top when switching to new/empty chat - Remove title truncation limit for better searchability - Remove debug console.log statements * refactor: remove redundant code and fix nested button hydration error - Remove unused 'sessions' from deleteSession dependency array - Remove unused 'switchedTo' variable and simplify return type - Remove unused 'restoredMessageIdsRef' (always empty) - Fix nested button hydration error by using div with role=button - Simplify handleDeleteSession callback * fix(session): fix migration bug, improve metadata perf, truncate titles - Fix migration retry loop when localStorage has empty array - Use cursor-based iteration for getAllSessionMetadata - Truncate session titles to 100 chars with ellipsis * refactor: remove dead code and extract diagram length constant - Remove unused exports: getAllSessions, createNewSession, updateSessionTitle - Remove write-only CURRENT_SESSION_KEY and all localStorage calls - Remove dead messagesEndRef and unused scroll effect - Extract magic number 300 to MIN_REAL_DIAGRAM_LENGTH constant - Add isRealDiagram() helper function for semantic clarity
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
Settings,
|
||||
} from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import type React from "react"
|
||||
import {
|
||||
useCallback,
|
||||
@@ -22,27 +23,25 @@ import { Toaster, toast } from "sonner"
|
||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||
import { ChatInput } from "@/components/chat-input"
|
||||
import { ModelConfigDialog } from "@/components/model-config-dialog"
|
||||
import { ResetWarningModal } from "@/components/reset-warning-modal"
|
||||
import { SettingsDialog } from "@/components/settings-dialog"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { useDiagramToolHandlers } from "@/hooks/use-diagram-tool-handlers"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config"
|
||||
import { useSessionManager } from "@/hooks/use-session-manager"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import { findCachedResponse } from "@/lib/cached-responses"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||
import { sanitizeMessages } from "@/lib/session-storage"
|
||||
import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
|
||||
import { useQuotaManager } from "@/lib/use-quota-manager"
|
||||
import { cn, formatXML } from "@/lib/utils"
|
||||
import { cn, formatXML, isRealDiagram } from "@/lib/utils"
|
||||
import { ChatMessageDisplay } from "./chat-message-display"
|
||||
import { DevXmlSimulator } from "./dev-xml-simulator"
|
||||
|
||||
// localStorage keys for persistence
|
||||
const STORAGE_MESSAGES_KEY = "next-ai-draw-io-messages"
|
||||
const STORAGE_XML_SNAPSHOTS_KEY = "next-ai-draw-io-xml-snapshots"
|
||||
const STORAGE_SESSION_ID_KEY = "next-ai-draw-io-session-id"
|
||||
export const STORAGE_DIAGRAM_XML_KEY = "next-ai-draw-io-diagram-xml"
|
||||
|
||||
// sessionStorage keys
|
||||
const SESSION_STORAGE_INPUT_KEY = "next-ai-draw-io-input"
|
||||
@@ -119,10 +118,17 @@ export default function ChatPanel({
|
||||
handleExportWithoutHistory,
|
||||
resolverRef,
|
||||
chartXML,
|
||||
latestSvg,
|
||||
clearDiagram,
|
||||
getThumbnailSvg,
|
||||
diagramHistory,
|
||||
setDiagramHistory,
|
||||
} = useDiagram()
|
||||
|
||||
const dict = useDictionary()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const urlSessionId = searchParams.get("session")
|
||||
|
||||
const onFetchChart = (saveToHistory = true) => {
|
||||
return Promise.race([
|
||||
@@ -158,11 +164,14 @@ export default function ChatPanel({
|
||||
|
||||
// Model configuration hook
|
||||
const modelConfig = useModelConfig()
|
||||
|
||||
// Session manager for chat history (pass URL session ID for restoration)
|
||||
const sessionManager = useSessionManager({ initialSessionId: urlSessionId })
|
||||
|
||||
const [input, setInput] = useState("")
|
||||
const [dailyRequestLimit, setDailyRequestLimit] = useState(0)
|
||||
const [dailyTokenLimit, setDailyTokenLimit] = useState(0)
|
||||
const [tpmLimit, setTpmLimit] = useState(0)
|
||||
const [showNewChatDialog, setShowNewChatDialog] = useState(false)
|
||||
const [minimalStyle, setMinimalStyle] = useState(false)
|
||||
|
||||
// Restore input from sessionStorage on mount (when ChatPanel remounts due to key change)
|
||||
@@ -222,10 +231,22 @@ export default function ChatPanel({
|
||||
|
||||
// Ref to track latest chartXML for use in callbacks (avoids stale closure)
|
||||
const chartXMLRef = useRef(chartXML)
|
||||
// Track session ID that was loaded without a diagram (to prevent thumbnail contamination)
|
||||
const justLoadedSessionIdRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
chartXMLRef.current = chartXML
|
||||
// Clear the no-diagram flag when a diagram is generated
|
||||
if (chartXML) {
|
||||
justLoadedSessionIdRef.current = null
|
||||
}
|
||||
}, [chartXML])
|
||||
|
||||
// Ref to track latest SVG for thumbnail generation
|
||||
const latestSvgRef = useRef(latestSvg)
|
||||
useEffect(() => {
|
||||
latestSvgRef.current = latestSvg
|
||||
}, [latestSvg])
|
||||
|
||||
// Ref to track consecutive auto-retry count (reset on user action)
|
||||
const autoRetryCountRef = useRef(0)
|
||||
// Ref to track continuation retry count (for truncation handling)
|
||||
@@ -307,32 +328,6 @@ export default function ChatPanel({
|
||||
// Silence access code error in console since it's handled by UI
|
||||
if (!error.message.includes("Invalid or missing access code")) {
|
||||
console.error("Chat error:", error)
|
||||
// Debug: Log messages structure when error occurs
|
||||
console.log("[onError] messages count:", messages.length)
|
||||
messages.forEach((msg, idx) => {
|
||||
console.log(`[onError] Message ${idx}:`, {
|
||||
role: msg.role,
|
||||
partsCount: msg.parts?.length,
|
||||
})
|
||||
if (msg.parts) {
|
||||
msg.parts.forEach((part: any, partIdx: number) => {
|
||||
console.log(
|
||||
`[onError] Part ${partIdx}:`,
|
||||
JSON.stringify({
|
||||
type: part.type,
|
||||
toolName: part.toolName,
|
||||
hasInput: !!part.input,
|
||||
inputType: typeof part.input,
|
||||
inputKeys:
|
||||
part.input &&
|
||||
typeof part.input === "object"
|
||||
? Object.keys(part.input)
|
||||
: null,
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Translate technical errors into user-friendly messages
|
||||
@@ -378,15 +373,7 @@ export default function ChatPanel({
|
||||
setShowSettingsDialog(true)
|
||||
}
|
||||
},
|
||||
onFinish: ({ message }) => {
|
||||
// Track actual token usage from server metadata
|
||||
const metadata = message?.metadata as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
|
||||
// DEBUG: Log finish reason to diagnose truncation
|
||||
console.log("[onFinish] finishReason:", metadata?.finishReason)
|
||||
},
|
||||
onFinish: () => {},
|
||||
sendAutomaticallyWhen: ({ messages }) => {
|
||||
const isInContinuationMode = partialXmlRef.current.length > 0
|
||||
|
||||
@@ -444,61 +431,199 @@ export default function ChatPanel({
|
||||
messagesRef.current = messages
|
||||
}, [messages])
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
// Track last synced session ID to detect external changes (e.g., URL back/forward)
|
||||
const lastSyncedSessionIdRef = useRef<string | null>(null)
|
||||
|
||||
// Restore messages and XML snapshots from localStorage on mount
|
||||
// useLayoutEffect runs synchronously before browser paint, so messages appear immediately
|
||||
// Helper: Sync UI state with session data (eliminates duplication)
|
||||
// Track message IDs that are being loaded from session (to skip animations/scroll)
|
||||
const loadedMessageIdsRef = useRef<Set<string>>(new Set())
|
||||
// Track when session was just loaded (to skip auto-save on load)
|
||||
const justLoadedSessionRef = useRef(false)
|
||||
|
||||
const syncUIWithSession = useCallback(
|
||||
(
|
||||
data: {
|
||||
messages: unknown[]
|
||||
xmlSnapshots: [number, string][]
|
||||
diagramXml: string
|
||||
diagramHistory?: { svg: string; xml: string }[]
|
||||
} | null,
|
||||
) => {
|
||||
const hasRealDiagram = isRealDiagram(data?.diagramXml)
|
||||
if (data) {
|
||||
// Mark all message IDs as loaded from session
|
||||
const messageIds = (data.messages as any[]).map(
|
||||
(m: any) => m.id,
|
||||
)
|
||||
loadedMessageIdsRef.current = new Set(messageIds)
|
||||
setMessages(data.messages as any)
|
||||
xmlSnapshotsRef.current = new Map(data.xmlSnapshots)
|
||||
if (hasRealDiagram) {
|
||||
onDisplayChart(data.diagramXml, true)
|
||||
chartXMLRef.current = data.diagramXml
|
||||
} else {
|
||||
clearDiagram()
|
||||
// Clear refs to prevent stale data from being saved
|
||||
chartXMLRef.current = ""
|
||||
latestSvgRef.current = ""
|
||||
}
|
||||
setDiagramHistory(data.diagramHistory || [])
|
||||
} else {
|
||||
loadedMessageIdsRef.current = new Set()
|
||||
setMessages([])
|
||||
xmlSnapshotsRef.current.clear()
|
||||
clearDiagram()
|
||||
// Clear refs to prevent stale data from being saved
|
||||
chartXMLRef.current = ""
|
||||
latestSvgRef.current = ""
|
||||
setDiagramHistory([])
|
||||
}
|
||||
},
|
||||
[setMessages, onDisplayChart, clearDiagram, setDiagramHistory],
|
||||
)
|
||||
|
||||
// Helper: Build session data object for saving (eliminates duplication)
|
||||
const buildSessionData = useCallback(
|
||||
async (options: { withThumbnail?: boolean } = {}) => {
|
||||
const currentDiagramXml = chartXMLRef.current || ""
|
||||
// Only capture thumbnail if there's a meaningful diagram (not just empty template)
|
||||
const hasRealDiagram = isRealDiagram(currentDiagramXml)
|
||||
let thumbnailDataUrl: string | undefined
|
||||
if (hasRealDiagram && options.withThumbnail) {
|
||||
const freshThumb = await getThumbnailSvg()
|
||||
if (freshThumb) {
|
||||
latestSvgRef.current = freshThumb
|
||||
thumbnailDataUrl = freshThumb
|
||||
} else if (latestSvgRef.current) {
|
||||
// Use cached thumbnail only if we have a real diagram
|
||||
thumbnailDataUrl = latestSvgRef.current
|
||||
}
|
||||
}
|
||||
return {
|
||||
messages: sanitizeMessages(messagesRef.current),
|
||||
xmlSnapshots: Array.from(xmlSnapshotsRef.current.entries()),
|
||||
diagramXml: currentDiagramXml,
|
||||
thumbnailDataUrl,
|
||||
diagramHistory,
|
||||
}
|
||||
},
|
||||
[diagramHistory, getThumbnailSvg],
|
||||
)
|
||||
|
||||
// Restore messages and XML snapshots from session manager on mount
|
||||
// This effect syncs with the session manager's loaded session
|
||||
useLayoutEffect(() => {
|
||||
if (hasRestoredRef.current) return
|
||||
if (sessionManager.isLoading) return // Wait for session manager to load
|
||||
|
||||
hasRestoredRef.current = true
|
||||
|
||||
try {
|
||||
// Restore messages
|
||||
const savedMessages = localStorage.getItem(STORAGE_MESSAGES_KEY)
|
||||
if (savedMessages) {
|
||||
const parsed = JSON.parse(savedMessages)
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
setMessages(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
// Restore XML snapshots
|
||||
const savedSnapshots = localStorage.getItem(
|
||||
STORAGE_XML_SNAPSHOTS_KEY,
|
||||
)
|
||||
if (savedSnapshots) {
|
||||
const parsed = JSON.parse(savedSnapshots)
|
||||
xmlSnapshotsRef.current = new Map(parsed)
|
||||
const currentSession = sessionManager.currentSession
|
||||
if (currentSession && currentSession.messages.length > 0) {
|
||||
// Restore from session manager (IndexedDB)
|
||||
justLoadedSessionRef.current = true
|
||||
syncUIWithSession(currentSession)
|
||||
}
|
||||
// Initialize lastSyncedSessionIdRef to prevent sync effect from firing immediately
|
||||
lastSyncedSessionIdRef.current = sessionManager.currentSessionId
|
||||
// Note: Migration from old localStorage format is handled by session-storage.ts
|
||||
} catch (error) {
|
||||
console.error("Failed to restore from localStorage:", error)
|
||||
// On complete failure, clear storage to allow recovery
|
||||
localStorage.removeItem(STORAGE_MESSAGES_KEY)
|
||||
localStorage.removeItem(STORAGE_XML_SNAPSHOTS_KEY)
|
||||
console.error("Failed to restore session:", error)
|
||||
toast.error(dict.errors.sessionCorrupted)
|
||||
} finally {
|
||||
setIsRestored(true)
|
||||
}
|
||||
}, [setMessages, dict.errors.sessionCorrupted])
|
||||
}, [
|
||||
sessionManager.isLoading,
|
||||
sessionManager.currentSession,
|
||||
syncUIWithSession,
|
||||
dict.errors.sessionCorrupted,
|
||||
])
|
||||
|
||||
// Sync UI when session changes externally (e.g., URL navigation via back/forward)
|
||||
// This handles changes AFTER initial restore
|
||||
useEffect(() => {
|
||||
if (!isRestored) return // Wait for initial restore to complete
|
||||
if (!sessionManager.isAvailable) return
|
||||
|
||||
const newSessionId = sessionManager.currentSessionId
|
||||
const newSession = sessionManager.currentSession
|
||||
|
||||
// Skip if session ID hasn't changed (our own saves don't change the ID)
|
||||
if (newSessionId === lastSyncedSessionIdRef.current) return
|
||||
|
||||
// Update last synced ID
|
||||
lastSyncedSessionIdRef.current = newSessionId
|
||||
|
||||
// Sync UI with new session
|
||||
if (newSession && newSession.messages.length > 0) {
|
||||
justLoadedSessionRef.current = true
|
||||
syncUIWithSession(newSession)
|
||||
} else if (!newSession) {
|
||||
syncUIWithSession(null)
|
||||
}
|
||||
}, [
|
||||
isRestored,
|
||||
sessionManager.isAvailable,
|
||||
sessionManager.currentSessionId,
|
||||
sessionManager.currentSession,
|
||||
syncUIWithSession,
|
||||
])
|
||||
|
||||
// Save messages to session manager (debounced, only when not streaming)
|
||||
// Destructure stable values to avoid effect re-running on every render
|
||||
const {
|
||||
isAvailable: sessionIsAvailable,
|
||||
currentSessionId,
|
||||
saveCurrentSession,
|
||||
} = sessionManager
|
||||
|
||||
// Use ref for saveCurrentSession to avoid infinite loop
|
||||
// (saveCurrentSession changes after each save, which would re-trigger the effect)
|
||||
const saveCurrentSessionRef = useRef(saveCurrentSession)
|
||||
saveCurrentSessionRef.current = saveCurrentSession
|
||||
|
||||
// Save messages to localStorage whenever they change (debounced to prevent blocking during streaming)
|
||||
useEffect(() => {
|
||||
if (!hasRestoredRef.current) return
|
||||
if (!sessionIsAvailable) return
|
||||
// Only save when not actively streaming to avoid write storms
|
||||
if (status === "streaming" || status === "submitted") return
|
||||
|
||||
// Skip auto-save if session was just loaded (to prevent re-ordering)
|
||||
if (justLoadedSessionRef.current) {
|
||||
justLoadedSessionRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
// Clear any pending save
|
||||
if (localStorageDebounceRef.current) {
|
||||
clearTimeout(localStorageDebounceRef.current)
|
||||
}
|
||||
|
||||
// Capture current session ID at schedule time to verify at save time
|
||||
const scheduledForSessionId = currentSessionId
|
||||
// Capture whether there's a REAL diagram NOW (not just empty template)
|
||||
const hasDiagramNow = isRealDiagram(chartXMLRef.current)
|
||||
// Check if this session was just loaded without a diagram
|
||||
const isNodiagramSession =
|
||||
justLoadedSessionIdRef.current === scheduledForSessionId
|
||||
|
||||
// Debounce: save after 1 second of no changes
|
||||
localStorageDebounceRef.current = setTimeout(() => {
|
||||
localStorageDebounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_MESSAGES_KEY,
|
||||
JSON.stringify(messages),
|
||||
)
|
||||
if (messages.length > 0) {
|
||||
const sessionData = await buildSessionData({
|
||||
// Only capture thumbnail if there was a diagram AND this isn't a no-diagram session
|
||||
withThumbnail: hasDiagramNow && !isNodiagramSession,
|
||||
})
|
||||
await saveCurrentSessionRef.current(
|
||||
sessionData,
|
||||
scheduledForSessionId,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save messages to localStorage:", error)
|
||||
console.error("Failed to save session:", error)
|
||||
}
|
||||
}, LOCAL_STORAGE_DEBOUNCE_MS)
|
||||
|
||||
@@ -508,63 +633,62 @@ export default function ChatPanel({
|
||||
clearTimeout(localStorageDebounceRef.current)
|
||||
}
|
||||
}
|
||||
}, [messages])
|
||||
}, [
|
||||
messages,
|
||||
status,
|
||||
sessionIsAvailable,
|
||||
currentSessionId,
|
||||
buildSessionData,
|
||||
])
|
||||
|
||||
// Save XML snapshots to localStorage whenever they change
|
||||
const saveXmlSnapshots = useCallback(() => {
|
||||
try {
|
||||
const snapshotsArray = Array.from(xmlSnapshotsRef.current.entries())
|
||||
localStorage.setItem(
|
||||
STORAGE_XML_SNAPSHOTS_KEY,
|
||||
JSON.stringify(snapshotsArray),
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Failed to save XML snapshots to localStorage:",
|
||||
error,
|
||||
)
|
||||
// Update URL when a new session is created (first message sent)
|
||||
useEffect(() => {
|
||||
if (sessionManager.currentSessionId && !urlSessionId) {
|
||||
// A session was created but URL doesn't have the session param yet
|
||||
router.replace(`?session=${sessionManager.currentSessionId}`, {
|
||||
scroll: false,
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
}, [sessionManager.currentSessionId, urlSessionId, router])
|
||||
|
||||
// Save session ID to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_SESSION_ID_KEY, sessionId)
|
||||
}, [sessionId])
|
||||
|
||||
// Save session when page becomes hidden (tab switch, close, navigate away)
|
||||
// This is more reliable than beforeunload for async IndexedDB operations
|
||||
useEffect(() => {
|
||||
if (messagesEndRef.current) {
|
||||
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
}, [messages])
|
||||
if (!sessionManager.isAvailable) return
|
||||
|
||||
// Save state right before page unload (refresh/close)
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = () => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_MESSAGES_KEY,
|
||||
JSON.stringify(messagesRef.current),
|
||||
)
|
||||
localStorage.setItem(
|
||||
STORAGE_XML_SNAPSHOTS_KEY,
|
||||
JSON.stringify(
|
||||
Array.from(xmlSnapshotsRef.current.entries()),
|
||||
),
|
||||
)
|
||||
const xml = chartXMLRef.current
|
||||
if (xml && xml.length > 300) {
|
||||
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, xml)
|
||||
const handleVisibilityChange = async () => {
|
||||
if (
|
||||
document.visibilityState === "hidden" &&
|
||||
messagesRef.current.length > 0
|
||||
) {
|
||||
try {
|
||||
// Attempt to save session - browser may not wait for completion
|
||||
// Skip thumbnail capture as it may not complete in time
|
||||
const sessionData = await buildSessionData({
|
||||
withThumbnail: false,
|
||||
})
|
||||
await sessionManager.saveCurrentSession(sessionData)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Failed to save session on visibility change:",
|
||||
error,
|
||||
)
|
||||
}
|
||||
localStorage.setItem(STORAGE_SESSION_ID_KEY, sessionId)
|
||||
} catch (error) {
|
||||
console.error("Failed to persist state before unload:", error)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload)
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange)
|
||||
return () =>
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload)
|
||||
}, [sessionId])
|
||||
document.removeEventListener(
|
||||
"visibilitychange",
|
||||
handleVisibilityChange,
|
||||
)
|
||||
}, [sessionManager, buildSessionData])
|
||||
|
||||
const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
@@ -648,7 +772,6 @@ export default function ChatPanel({
|
||||
// Save XML snapshot for this message (will be at index = current messages.length)
|
||||
const messageIndex = messages.length
|
||||
xmlSnapshotsRef.current.set(messageIndex, chartXml)
|
||||
saveXmlSnapshots()
|
||||
|
||||
sendChatMessage(parts, chartXml, previousXml, sessionId)
|
||||
|
||||
@@ -662,30 +785,97 @@ export default function ChatPanel({
|
||||
}
|
||||
}
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
// Handle session switching from history dropdown
|
||||
const handleSelectSession = useCallback(
|
||||
async (sessionId: string) => {
|
||||
if (!sessionManager.isAvailable) return
|
||||
|
||||
// Save current session before switching
|
||||
if (messages.length > 0) {
|
||||
const sessionData = await buildSessionData({
|
||||
withThumbnail: true,
|
||||
})
|
||||
await sessionManager.saveCurrentSession(sessionData)
|
||||
}
|
||||
|
||||
// Switch to selected session
|
||||
const sessionData = await sessionManager.switchSession(sessionId)
|
||||
if (sessionData) {
|
||||
const hasRealDiagram = isRealDiagram(sessionData.diagramXml)
|
||||
justLoadedSessionRef.current = true
|
||||
|
||||
// CRITICAL: Update latestSvgRef with the NEW session's thumbnail
|
||||
// This prevents stale thumbnail from previous session being used by auto-save
|
||||
latestSvgRef.current = sessionData.thumbnailDataUrl || ""
|
||||
|
||||
// Track if this session has no real diagram - to prevent thumbnail contamination
|
||||
if (!hasRealDiagram) {
|
||||
justLoadedSessionIdRef.current = sessionId
|
||||
} else {
|
||||
justLoadedSessionIdRef.current = null
|
||||
}
|
||||
syncUIWithSession(sessionData)
|
||||
router.replace(`?session=${sessionId}`, { scroll: false })
|
||||
}
|
||||
},
|
||||
[sessionManager, messages, buildSessionData, syncUIWithSession, router],
|
||||
)
|
||||
|
||||
// Handle session deletion from history dropdown
|
||||
const handleDeleteSession = useCallback(
|
||||
async (sessionId: string) => {
|
||||
if (!sessionManager.isAvailable) return
|
||||
const result = await sessionManager.deleteSession(sessionId)
|
||||
|
||||
if (result.wasCurrentSession) {
|
||||
// Deleted current session - clear UI and URL
|
||||
syncUIWithSession(null)
|
||||
router.replace(window.location.pathname, { scroll: false })
|
||||
}
|
||||
},
|
||||
[sessionManager, syncUIWithSession, router],
|
||||
)
|
||||
|
||||
const handleNewChat = useCallback(async () => {
|
||||
// Save current session before creating new one
|
||||
if (sessionManager.isAvailable && messages.length > 0) {
|
||||
const sessionData = await buildSessionData({ withThumbnail: true })
|
||||
await sessionManager.saveCurrentSession(sessionData)
|
||||
// Refresh sessions list to ensure dropdown shows the saved session
|
||||
await sessionManager.refreshSessions()
|
||||
}
|
||||
|
||||
// Clear session manager state BEFORE clearing URL to prevent race condition
|
||||
// (otherwise the URL update effect would restore the old session URL)
|
||||
sessionManager.clearCurrentSession()
|
||||
|
||||
// Clear UI state (can't use syncUIWithSession here because we also need to clear files)
|
||||
setMessages([])
|
||||
clearDiagram()
|
||||
setDiagramHistory([])
|
||||
handleFileChange([]) // Use handleFileChange to also clear pdfData
|
||||
const newSessionId = `session-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 9)}`
|
||||
setSessionId(newSessionId)
|
||||
xmlSnapshotsRef.current.clear()
|
||||
// Clear localStorage with error handling
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_MESSAGES_KEY)
|
||||
localStorage.removeItem(STORAGE_XML_SNAPSHOTS_KEY)
|
||||
localStorage.removeItem(STORAGE_DIAGRAM_XML_KEY)
|
||||
localStorage.setItem(STORAGE_SESSION_ID_KEY, newSessionId)
|
||||
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
|
||||
toast.success(dict.dialogs.clearSuccess)
|
||||
} catch (error) {
|
||||
console.error("Failed to clear localStorage:", error)
|
||||
toast.warning(dict.errors.storageUpdateFailed)
|
||||
}
|
||||
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
|
||||
toast.success(dict.dialogs.clearSuccess)
|
||||
|
||||
setShowNewChatDialog(false)
|
||||
}, [clearDiagram, handleFileChange, setMessages, setSessionId])
|
||||
// Clear URL param to show blank state
|
||||
router.replace(window.location.pathname, { scroll: false })
|
||||
}, [
|
||||
clearDiagram,
|
||||
handleFileChange,
|
||||
setMessages,
|
||||
setSessionId,
|
||||
sessionManager,
|
||||
messages,
|
||||
router,
|
||||
dict.dialogs.clearSuccess,
|
||||
buildSessionData,
|
||||
setDiagramHistory,
|
||||
])
|
||||
|
||||
const handleInputChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
@@ -722,7 +912,6 @@ export default function ChatPanel({
|
||||
xmlSnapshotsRef.current.delete(key)
|
||||
}
|
||||
}
|
||||
saveXmlSnapshots()
|
||||
}
|
||||
|
||||
// Send chat message with headers
|
||||
@@ -958,7 +1147,12 @@ export default function ChatPanel({
|
||||
className={`${isMobile ? "px-3 py-2" : "px-5 py-4"} border-b border-border/50`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 overflow-x-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewChat}
|
||||
className="flex items-center gap-2 overflow-x-hidden hover:opacity-80 transition-opacity cursor-pointer"
|
||||
title={dict.nav.newChat}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Image
|
||||
src={
|
||||
@@ -977,13 +1171,13 @@ export default function ChatPanel({
|
||||
Next AI Drawio
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex items-center gap-1 justify-end overflow-visible">
|
||||
<ButtonWithTooltip
|
||||
tooltipContent={dict.nav.newChat}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowNewChatDialog(true)}
|
||||
onClick={handleNewChat}
|
||||
className="hover:bg-accent"
|
||||
>
|
||||
<MessageSquarePlus
|
||||
@@ -1032,6 +1226,10 @@ export default function ChatPanel({
|
||||
status={status}
|
||||
onEditMessage={handleEditMessage}
|
||||
isRestored={isRestored}
|
||||
sessions={sessionManager.sessions}
|
||||
onSelectSession={handleSelectSession}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
loadedMessageIdsRef={loadedMessageIdsRef}
|
||||
/>
|
||||
</main>
|
||||
|
||||
@@ -1055,7 +1253,6 @@ export default function ChatPanel({
|
||||
status={status}
|
||||
onSubmit={onFormSubmit}
|
||||
onChange={handleInputChange}
|
||||
onClearChat={handleNewChat}
|
||||
files={files}
|
||||
onFileChange={handleFileChange}
|
||||
pdfData={pdfData}
|
||||
@@ -1086,12 +1283,6 @@ export default function ChatPanel({
|
||||
onOpenChange={setShowModelConfigDialog}
|
||||
modelConfig={modelConfig}
|
||||
/>
|
||||
|
||||
<ResetWarningModal
|
||||
open={showNewChatDialog}
|
||||
onOpenChange={setShowNewChatDialog}
|
||||
onClear={handleNewChat}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user