feat: persist chat history to backend (debounced PUT, hydrate on mount)
Browse files- components/chat/AppShell.tsx +10 -2
- components/chat/TopBar.tsx +48 -2
- lib/chatStore.ts +63 -0
- lib/sync.ts +58 -0
- lib/useBackendSync.ts +97 -0
components/chat/AppShell.tsx
CHANGED
|
@@ -4,24 +4,32 @@ import { Sidebar } from "./Sidebar";
|
|
| 4 |
import { TopBar } from "./TopBar";
|
| 5 |
import { Aurora } from "./Aurora";
|
| 6 |
import { useChatStore } from "@/lib/chatStore";
|
|
|
|
| 7 |
import clsx from "clsx";
|
| 8 |
import { usePathname } from "next/navigation";
|
| 9 |
import { useEffect } from "react";
|
| 10 |
|
| 11 |
export function AppShell({ children }: { children: React.ReactNode }) {
|
| 12 |
const sidebarOpen = useChatStore((s) => s.sidebarOpen);
|
|
|
|
| 13 |
const pathname = usePathname();
|
| 14 |
const conversations = useChatStore((s) => s.conversations);
|
| 15 |
const activeId = useChatStore((s) => s.activeId);
|
| 16 |
const newConversation = useChatStore((s) => s.newConversation);
|
| 17 |
|
| 18 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
useEffect(() => {
|
| 20 |
if (pathname !== "/") return;
|
|
|
|
| 21 |
const hasAny = Object.keys(conversations).length > 0;
|
| 22 |
const hasActive = activeId && conversations[activeId];
|
| 23 |
if (!hasAny || !hasActive) newConversation();
|
| 24 |
-
}, [pathname, conversations, activeId, newConversation]);
|
| 25 |
|
| 26 |
return (
|
| 27 |
<div className="relative min-h-screen flex flex-col">
|
|
|
|
| 4 |
import { TopBar } from "./TopBar";
|
| 5 |
import { Aurora } from "./Aurora";
|
| 6 |
import { useChatStore } from "@/lib/chatStore";
|
| 7 |
+
import { useBackendSync } from "@/lib/useBackendSync";
|
| 8 |
import clsx from "clsx";
|
| 9 |
import { usePathname } from "next/navigation";
|
| 10 |
import { useEffect } from "react";
|
| 11 |
|
| 12 |
export function AppShell({ children }: { children: React.ReactNode }) {
|
| 13 |
const sidebarOpen = useChatStore((s) => s.sidebarOpen);
|
| 14 |
+
const hydrated = useChatStore((s) => s.hydrated);
|
| 15 |
const pathname = usePathname();
|
| 16 |
const conversations = useChatStore((s) => s.conversations);
|
| 17 |
const activeId = useChatStore((s) => s.activeId);
|
| 18 |
const newConversation = useChatStore((s) => s.newConversation);
|
| 19 |
|
| 20 |
+
// Backend hydration + auto-save (debounced PUT to /conversations)
|
| 21 |
+
useBackendSync();
|
| 22 |
+
|
| 23 |
+
// Ensure there is always at least one conversation when on chat route.
|
| 24 |
+
// Wait until hydration completes so we don't create a placeholder before
|
| 25 |
+
// the remote state arrives (which would cause a flash + redundant save).
|
| 26 |
useEffect(() => {
|
| 27 |
if (pathname !== "/") return;
|
| 28 |
+
if (!hydrated) return;
|
| 29 |
const hasAny = Object.keys(conversations).length > 0;
|
| 30 |
const hasActive = activeId && conversations[activeId];
|
| 31 |
if (!hasAny || !hasActive) newConversation();
|
| 32 |
+
}, [pathname, hydrated, conversations, activeId, newConversation]);
|
| 33 |
|
| 34 |
return (
|
| 35 |
<div className="relative min-h-screen flex flex-col">
|
components/chat/TopBar.tsx
CHANGED
|
@@ -10,6 +10,8 @@ export function TopBar() {
|
|
| 10 |
const [pulse, setPulse] = useState<"alive" | "down" | "loading">("loading");
|
| 11 |
const [pingMs, setPingMs] = useState<number | null>(null);
|
| 12 |
const toggleSidebar = useChatStore((s) => s.toggleSidebar);
|
|
|
|
|
|
|
| 13 |
|
| 14 |
useEffect(() => {
|
| 15 |
let alive = true;
|
|
@@ -70,8 +72,9 @@ export function TopBar() {
|
|
| 70 |
</Link>
|
| 71 |
</div>
|
| 72 |
|
| 73 |
-
{/* Right: status pulse */}
|
| 74 |
-
<div className="flex items-center gap-
|
|
|
|
| 75 |
<span
|
| 76 |
className="hidden sm:flex items-center gap-2 rounded-pill px-3 py-1.5 bg-glass"
|
| 77 |
style={{ boxShadow: "0 0 0 1px rgba(255,255,255,0.08)" }}
|
|
@@ -101,6 +104,49 @@ export function TopBar() {
|
|
| 101 |
);
|
| 102 |
}
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
function Mark() {
|
| 105 |
return (
|
| 106 |
<span
|
|
|
|
| 10 |
const [pulse, setPulse] = useState<"alive" | "down" | "loading">("loading");
|
| 11 |
const [pingMs, setPingMs] = useState<number | null>(null);
|
| 12 |
const toggleSidebar = useChatStore((s) => s.toggleSidebar);
|
| 13 |
+
const syncStatus = useChatStore((s) => s.syncStatus);
|
| 14 |
+
const hydrated = useChatStore((s) => s.hydrated);
|
| 15 |
|
| 16 |
useEffect(() => {
|
| 17 |
let alive = true;
|
|
|
|
| 72 |
</Link>
|
| 73 |
</div>
|
| 74 |
|
| 75 |
+
{/* Right: sync + status pulse */}
|
| 76 |
+
<div className="flex items-center gap-2">
|
| 77 |
+
<SyncBadge status={syncStatus} hydrated={hydrated} />
|
| 78 |
<span
|
| 79 |
className="hidden sm:flex items-center gap-2 rounded-pill px-3 py-1.5 bg-glass"
|
| 80 |
style={{ boxShadow: "0 0 0 1px rgba(255,255,255,0.08)" }}
|
|
|
|
| 104 |
);
|
| 105 |
}
|
| 106 |
|
| 107 |
+
function SyncBadge({
|
| 108 |
+
status,
|
| 109 |
+
hydrated,
|
| 110 |
+
}: {
|
| 111 |
+
status: "idle" | "saving" | "error";
|
| 112 |
+
hydrated: boolean;
|
| 113 |
+
}) {
|
| 114 |
+
// Hide once steady-state idle to keep the chrome quiet β show only when
|
| 115 |
+
// there's something to communicate (loading, saving, error).
|
| 116 |
+
const visible = !hydrated || status === "saving" || status === "error";
|
| 117 |
+
if (!visible) return null;
|
| 118 |
+
|
| 119 |
+
const label =
|
| 120 |
+
!hydrated ? "Loading"
|
| 121 |
+
: status === "saving" ? "Saving"
|
| 122 |
+
: "Sync error";
|
| 123 |
+
const tone =
|
| 124 |
+
status === "error" ? "text-status-err" : "text-ink-50";
|
| 125 |
+
return (
|
| 126 |
+
<span
|
| 127 |
+
className={clsx(
|
| 128 |
+
"hidden sm:inline-flex items-center gap-1.5 rounded-pill px-2.5 py-1 text-micro font-mono uppercase tracking-[0.14em] transition-opacity",
|
| 129 |
+
tone
|
| 130 |
+
)}
|
| 131 |
+
title={
|
| 132 |
+
status === "error"
|
| 133 |
+
? "Couldn't save chat history to backend"
|
| 134 |
+
: status === "saving"
|
| 135 |
+
? "Saving chat historyβ¦"
|
| 136 |
+
: "Loading chat historyβ¦"
|
| 137 |
+
}
|
| 138 |
+
>
|
| 139 |
+
<span
|
| 140 |
+
className={clsx(
|
| 141 |
+
"inline-block w-1 h-1 rounded-full",
|
| 142 |
+
status === "error" ? "bg-status-err" : "bg-amber animate-pulse-soft"
|
| 143 |
+
)}
|
| 144 |
+
/>
|
| 145 |
+
{label}
|
| 146 |
+
</span>
|
| 147 |
+
);
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
function Mark() {
|
| 151 |
return (
|
| 152 |
<span
|
lib/chatStore.ts
CHANGED
|
@@ -42,6 +42,11 @@ type ChatStore = {
|
|
| 42 |
settings: Settings;
|
| 43 |
sidebarOpen: boolean;
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
// selectors
|
| 46 |
active: () => Conversation | null;
|
| 47 |
list: () => Conversation[];
|
|
@@ -59,6 +64,14 @@ type ChatStore = {
|
|
| 59 |
resetSettings: () => void;
|
| 60 |
toggleSidebar: () => void;
|
| 61 |
setSidebarOpen: (open: boolean) => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
};
|
| 63 |
|
| 64 |
const newId = () =>
|
|
@@ -81,6 +94,9 @@ export const useChatStore = create<ChatStore>()(
|
|
| 81 |
activeId: null,
|
| 82 |
settings: DEFAULT_SETTINGS,
|
| 83 |
sidebarOpen: true,
|
|
|
|
|
|
|
|
|
|
| 84 |
|
| 85 |
active: () => {
|
| 86 |
const { activeId, conversations } = get();
|
|
@@ -185,6 +201,53 @@ export const useChatStore = create<ChatStore>()(
|
|
| 185 |
resetSettings: () => set({ settings: DEFAULT_SETTINGS }),
|
| 186 |
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
|
| 187 |
setSidebarOpen: (open) => set({ sidebarOpen: open }),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
}),
|
| 189 |
{
|
| 190 |
name: "etiya-d2l-chat",
|
|
|
|
| 42 |
settings: Settings;
|
| 43 |
sidebarOpen: boolean;
|
| 44 |
|
| 45 |
+
// sync state
|
| 46 |
+
hydrated: boolean;
|
| 47 |
+
syncStatus: "idle" | "saving" | "error";
|
| 48 |
+
lastSavedAt: number;
|
| 49 |
+
|
| 50 |
// selectors
|
| 51 |
active: () => Conversation | null;
|
| 52 |
list: () => Conversation[];
|
|
|
|
| 64 |
resetSettings: () => void;
|
| 65 |
toggleSidebar: () => void;
|
| 66 |
setSidebarOpen: (open: boolean) => void;
|
| 67 |
+
|
| 68 |
+
// sync helpers (used by useBackendSync hook)
|
| 69 |
+
_setHydrated: (v: boolean) => void;
|
| 70 |
+
_setSyncStatus: (v: "idle" | "saving" | "error") => void;
|
| 71 |
+
_hydrateFromRemote: (payload: {
|
| 72 |
+
conversations: Record<string, Conversation>;
|
| 73 |
+
activeId: string | null;
|
| 74 |
+
}) => void;
|
| 75 |
};
|
| 76 |
|
| 77 |
const newId = () =>
|
|
|
|
| 94 |
activeId: null,
|
| 95 |
settings: DEFAULT_SETTINGS,
|
| 96 |
sidebarOpen: true,
|
| 97 |
+
hydrated: false,
|
| 98 |
+
syncStatus: "idle",
|
| 99 |
+
lastSavedAt: 0,
|
| 100 |
|
| 101 |
active: () => {
|
| 102 |
const { activeId, conversations } = get();
|
|
|
|
| 201 |
resetSettings: () => set({ settings: DEFAULT_SETTINGS }),
|
| 202 |
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
|
| 203 |
setSidebarOpen: (open) => set({ sidebarOpen: open }),
|
| 204 |
+
|
| 205 |
+
// ββ sync helpers βββββββββββββββββββββββββββββββββββββββββββββ
|
| 206 |
+
_setHydrated: (v) => set({ hydrated: v }),
|
| 207 |
+
_setSyncStatus: (v) => set({ syncStatus: v }),
|
| 208 |
+
_hydrateFromRemote: ({ conversations, activeId }) =>
|
| 209 |
+
set((s) => {
|
| 210 |
+
// Merge remote into local: remote wins per-id (server is the
|
| 211 |
+
// source of truth on hydrate). If remote is empty but local has
|
| 212 |
+
// pending in-flight turns, keep local β avoids losing a turn
|
| 213 |
+
// that's mid-flight when hydration races with the request.
|
| 214 |
+
const remoteIds = Object.keys(conversations);
|
| 215 |
+
if (remoteIds.length === 0) {
|
| 216 |
+
// Server is empty β keep local; the next save will populate it.
|
| 217 |
+
return { hydrated: true };
|
| 218 |
+
}
|
| 219 |
+
// Preserve any locally-pending turns that aren't yet on server.
|
| 220 |
+
const merged: Record<string, Conversation> = { ...conversations };
|
| 221 |
+
for (const [id, local] of Object.entries(s.conversations)) {
|
| 222 |
+
if (!merged[id]) {
|
| 223 |
+
merged[id] = local;
|
| 224 |
+
continue;
|
| 225 |
+
}
|
| 226 |
+
const localPending = local.turns.filter((t) => t.pending);
|
| 227 |
+
if (localPending.length === 0) continue;
|
| 228 |
+
const serverTurnIds = new Set(merged[id].turns.map((t) => t.id));
|
| 229 |
+
const extras = localPending.filter(
|
| 230 |
+
(t) => !serverTurnIds.has(t.id)
|
| 231 |
+
);
|
| 232 |
+
if (extras.length > 0) {
|
| 233 |
+
merged[id] = {
|
| 234 |
+
...merged[id],
|
| 235 |
+
turns: [...merged[id].turns, ...extras],
|
| 236 |
+
};
|
| 237 |
+
}
|
| 238 |
+
}
|
| 239 |
+
const nextActive =
|
| 240 |
+
activeId && merged[activeId]
|
| 241 |
+
? activeId
|
| 242 |
+
: s.activeId && merged[s.activeId]
|
| 243 |
+
? s.activeId
|
| 244 |
+
: Object.keys(merged)[0] ?? null;
|
| 245 |
+
return {
|
| 246 |
+
conversations: merged,
|
| 247 |
+
activeId: nextActive,
|
| 248 |
+
hydrated: true,
|
| 249 |
+
};
|
| 250 |
+
}),
|
| 251 |
}),
|
| 252 |
{
|
| 253 |
name: "etiya-d2l-chat",
|
lib/sync.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Backend persistence for chat history.
|
| 3 |
+
*
|
| 4 |
+
* Single shared store at /data/conversations.json on the backend Space.
|
| 5 |
+
* Frontend hydrates on mount and writes back on changes (debounced).
|
| 6 |
+
* Last-write-wins; no auth beyond the Space-level Bearer.
|
| 7 |
+
*/
|
| 8 |
+
import type { Conversation } from "./chatStore";
|
| 9 |
+
|
| 10 |
+
const PROXY = "/api/proxy/conversations";
|
| 11 |
+
|
| 12 |
+
export type RemoteConversationsPayload = {
|
| 13 |
+
conversations: Record<string, Conversation>;
|
| 14 |
+
activeId: string | null;
|
| 15 |
+
updatedAt?: number;
|
| 16 |
+
version?: number;
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
export async function loadConversations(): Promise<RemoteConversationsPayload | null> {
|
| 20 |
+
try {
|
| 21 |
+
const res = await fetch(PROXY, {
|
| 22 |
+
method: "GET",
|
| 23 |
+
headers: { "Content-Type": "application/json" },
|
| 24 |
+
});
|
| 25 |
+
if (!res.ok) {
|
| 26 |
+
console.warn("[sync] load failed", res.status);
|
| 27 |
+
return null;
|
| 28 |
+
}
|
| 29 |
+
const data = (await res.json()) as RemoteConversationsPayload;
|
| 30 |
+
return data;
|
| 31 |
+
} catch (err) {
|
| 32 |
+
console.warn("[sync] load error", err);
|
| 33 |
+
return null;
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
export async function saveConversations(
|
| 38 |
+
payload: RemoteConversationsPayload,
|
| 39 |
+
signal?: AbortSignal
|
| 40 |
+
): Promise<boolean> {
|
| 41 |
+
try {
|
| 42 |
+
const res = await fetch(PROXY, {
|
| 43 |
+
method: "PUT",
|
| 44 |
+
headers: { "Content-Type": "application/json" },
|
| 45 |
+
body: JSON.stringify(payload),
|
| 46 |
+
signal,
|
| 47 |
+
});
|
| 48 |
+
if (!res.ok) {
|
| 49 |
+
console.warn("[sync] save failed", res.status);
|
| 50 |
+
return false;
|
| 51 |
+
}
|
| 52 |
+
return true;
|
| 53 |
+
} catch (err) {
|
| 54 |
+
if ((err as { name?: string })?.name === "AbortError") return false;
|
| 55 |
+
console.warn("[sync] save error", err);
|
| 56 |
+
return false;
|
| 57 |
+
}
|
| 58 |
+
}
|
lib/useBackendSync.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Drives hydration of the chat store from the backend on mount and
|
| 5 |
+
* pushes changes back up via debounced PUT.
|
| 6 |
+
*
|
| 7 |
+
* Behavior:
|
| 8 |
+
* - On first mount: GET /conversations β merge into store β mark hydrated.
|
| 9 |
+
* - On any change after hydration: schedule a PUT (debounced 1500ms).
|
| 10 |
+
* - In-flight saves are aborted by the next save to avoid stale writes.
|
| 11 |
+
* - Pending turns are excluded from the saved snapshot β the assistant
|
| 12 |
+
* response lands as a normal change after pending=false flips.
|
| 13 |
+
*/
|
| 14 |
+
import { useEffect, useRef } from "react";
|
| 15 |
+
import { useChatStore } from "./chatStore";
|
| 16 |
+
import { loadConversations, saveConversations } from "./sync";
|
| 17 |
+
|
| 18 |
+
const SAVE_DEBOUNCE_MS = 1500;
|
| 19 |
+
|
| 20 |
+
export function useBackendSync() {
|
| 21 |
+
const hydrated = useChatStore((s) => s.hydrated);
|
| 22 |
+
const conversations = useChatStore((s) => s.conversations);
|
| 23 |
+
const activeId = useChatStore((s) => s.activeId);
|
| 24 |
+
|
| 25 |
+
const initialFetchStarted = useRef(false);
|
| 26 |
+
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
| 27 |
+
const inFlight = useRef<AbortController | null>(null);
|
| 28 |
+
|
| 29 |
+
// ββ 1) Hydrate on mount βββββββββββββββββββββββββββββββββββββββββββ
|
| 30 |
+
useEffect(() => {
|
| 31 |
+
if (initialFetchStarted.current) return;
|
| 32 |
+
initialFetchStarted.current = true;
|
| 33 |
+
|
| 34 |
+
let cancelled = false;
|
| 35 |
+
(async () => {
|
| 36 |
+
const remote = await loadConversations();
|
| 37 |
+
if (cancelled) return;
|
| 38 |
+
const store = useChatStore.getState();
|
| 39 |
+
if (remote && remote.conversations) {
|
| 40 |
+
store._hydrateFromRemote({
|
| 41 |
+
conversations: remote.conversations,
|
| 42 |
+
activeId: remote.activeId ?? null,
|
| 43 |
+
});
|
| 44 |
+
} else {
|
| 45 |
+
// No remote / fetch failed β proceed with whatever localStorage had.
|
| 46 |
+
store._setHydrated(true);
|
| 47 |
+
}
|
| 48 |
+
})();
|
| 49 |
+
|
| 50 |
+
return () => {
|
| 51 |
+
cancelled = true;
|
| 52 |
+
};
|
| 53 |
+
}, []);
|
| 54 |
+
|
| 55 |
+
// ββ 2) Debounced save on change (only after hydrated) βββββββββββββ
|
| 56 |
+
useEffect(() => {
|
| 57 |
+
if (!hydrated) return;
|
| 58 |
+
|
| 59 |
+
if (saveTimer.current) clearTimeout(saveTimer.current);
|
| 60 |
+
saveTimer.current = setTimeout(() => {
|
| 61 |
+
// Abort any in-flight save before starting a new one
|
| 62 |
+
if (inFlight.current) inFlight.current.abort();
|
| 63 |
+
const ctrl = new AbortController();
|
| 64 |
+
inFlight.current = ctrl;
|
| 65 |
+
|
| 66 |
+
// Strip in-flight pending turns from the saved snapshot β those
|
| 67 |
+
// resolve when the request returns and trigger their own save.
|
| 68 |
+
const sanitized: Record<string, typeof conversations[string]> = {};
|
| 69 |
+
for (const [id, conv] of Object.entries(conversations)) {
|
| 70 |
+
sanitized[id] = {
|
| 71 |
+
...conv,
|
| 72 |
+
turns: conv.turns.filter((t) => !t.pending),
|
| 73 |
+
};
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
const setStatus = useChatStore.getState()._setSyncStatus;
|
| 77 |
+
setStatus("saving");
|
| 78 |
+
saveConversations(
|
| 79 |
+
{
|
| 80 |
+
conversations: sanitized,
|
| 81 |
+
activeId,
|
| 82 |
+
},
|
| 83 |
+
ctrl.signal
|
| 84 |
+
).then((ok) => {
|
| 85 |
+
if (ctrl.signal.aborted) return;
|
| 86 |
+
setStatus(ok ? "idle" : "error");
|
| 87 |
+
if (ok) {
|
| 88 |
+
useChatStore.setState({ lastSavedAt: Date.now() });
|
| 89 |
+
}
|
| 90 |
+
});
|
| 91 |
+
}, SAVE_DEBOUNCE_MS);
|
| 92 |
+
|
| 93 |
+
return () => {
|
| 94 |
+
if (saveTimer.current) clearTimeout(saveTimer.current);
|
| 95 |
+
};
|
| 96 |
+
}, [hydrated, conversations, activeId]);
|
| 97 |
+
}
|