@frankieai/react 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,53 +1,93 @@
1
1
  "use client";
2
2
 
3
- import type { CSSProperties } from "react";
3
+ import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
4
+ import { History, Menu, Plus, Settings, X } from "lucide-react";
4
5
  import type { FrankieProviderProps } from "../../hooks/FrankieProvider.jsx";
5
- import { FrankieProvider } from "../../hooks/FrankieProvider.jsx";
6
+ import { FrankieProvider, useFrankie, useFrankieTheme } from "../../hooks/FrankieProvider.jsx";
7
+ import { useFrankieChat } from "../../hooks/useFrankieChat.js";
8
+ import { themeToCssVars, resolveMode } from "../../lib/theme.js";
9
+ import type { ChatConversation } from "../../lib/client.js";
6
10
  import { FrankieInline } from "./FrankieInline.js";
7
11
  import type { ChatPanelProps } from "./ChatPanel.js";
8
12
 
9
- /**
10
- * A complete chat route in one component. This is the simplest way to add a
11
- * dedicated /chat page to an app while keeping the existing floating widget.
12
- */
13
- export interface FrankieChatPageProps
14
- extends Omit<FrankieProviderProps, "children">,
15
- Pick<
16
- ChatPanelProps,
17
- "header" | "footer" | "emptyState" | "renderMessage" | "renderTool" | "placeholder" | "autoFocus"
18
- > {
13
+ export interface FrankieChatPageProps extends Omit<FrankieProviderProps, "children">,
14
+ Pick<ChatPanelProps, "header" | "footer" | "emptyState" | "renderMessage" | "renderTool" | "placeholder" | "autoFocus"> {
19
15
  className?: string;
20
16
  style?: CSSProperties;
17
+ /** Include the built-in conversation sidebar. Defaults to true. */
18
+ showHistory?: boolean;
19
+ /** Optional host navigation, e.g. a Back to dashboard link. */
20
+ navigation?: React.ReactNode;
21
21
  }
22
22
 
23
- export function FrankieChatPage({
24
- className,
25
- style,
26
- header,
27
- footer,
28
- emptyState,
29
- renderMessage,
30
- renderTool,
31
- placeholder,
32
- autoFocus,
33
- ...providerProps
34
- }: FrankieChatPageProps) {
35
- return (
36
- <main
37
- className={className}
38
- style={{ height: "100dvh", minHeight: "100dvh", width: "100%", ...style }}
39
- >
40
- <FrankieProvider {...providerProps}>
41
- <FrankieInline
42
- header={header}
43
- footer={footer}
44
- emptyState={emptyState}
45
- renderMessage={renderMessage}
46
- renderTool={renderTool}
47
- placeholder={placeholder}
48
- autoFocus={autoFocus}
49
- />
50
- </FrankieProvider>
51
- </main>
52
- );
23
+ function PageEmptyState() {
24
+ const { config } = useFrankie();
25
+ const { send, busy, restoring } = useFrankieChat();
26
+ const name = config?.branding.name ?? "Frankie";
27
+ return <section className="frankie-page-empty" aria-label="Start a conversation">
28
+ <span className="frankie-page-avatar" aria-hidden>{name.trim().slice(0, 1).toUpperCase()}</span>
29
+ <div><p className="frankie-page-kicker">Your product assistant</p><h1>How can I help?</h1><p>{config?.greeting ?? "Ask a question or choose a task to get started."}</p></div>
30
+ <div className="frankie-page-prompts">{config?.suggestedPrompts?.slice(0, 3).map(prompt => <button key={prompt} type="button" disabled={busy || restoring} onClick={() => void send(prompt)}><strong>{prompt}</strong><b aria-hidden>↗</b></button>)}</div>
31
+ </section>;
32
+ }
33
+
34
+ function PageChrome({ showHistory, navigation, currentId, onSelect, onNew, ...panel }: Pick<FrankieChatPageProps, "header" | "footer" | "emptyState" | "renderMessage" | "renderTool" | "placeholder" | "autoFocus" | "navigation"> & { showHistory: boolean; currentId: string; onSelect: (id: string) => void; onNew: () => void }) {
35
+ const { client, config } = useFrankie();
36
+ const { busy } = useFrankieChat();
37
+ const theme = useFrankieTheme();
38
+ const [conversations, setConversations] = useState<ChatConversation[]>([]);
39
+ const [historyError, setHistoryError] = useState("");
40
+ const [historyLoading, setHistoryLoading] = useState(false);
41
+ const [drawerOpen, setDrawerOpen] = useState(false);
42
+ const [query, setQuery] = useState("");
43
+ const dialog = useRef<HTMLDialogElement>(null);
44
+ const menu = useRef<HTMLButtonElement>(null);
45
+ const requestVersion = useRef(0);
46
+ const name = config?.branding.name ?? "Frankie";
47
+ const refresh = useCallback(async () => {
48
+ if (!showHistory || !config || config.allowAnonymous) { setHistoryError(""); return; }
49
+ const version = ++requestVersion.current;
50
+ setHistoryLoading(true); setHistoryError("");
51
+ try { const items = await client.listConversations(); if (version === requestVersion.current) setConversations(items); }
52
+ catch { if (version === requestVersion.current) setHistoryError("History couldn’t load. Check your sign-in and try again."); }
53
+ finally { if (version === requestVersion.current) setHistoryLoading(false); }
54
+ }, [client, showHistory, config?.allowAnonymous]);
55
+ useEffect(() => { if (!busy) void refresh(); return () => { requestVersion.current++; }; }, [refresh, busy]);
56
+ useEffect(() => {
57
+ const node = dialog.current;
58
+ if (drawerOpen) { node?.showModal?.(); }
59
+ else { node?.close?.(); }
60
+ }, [drawerOpen]);
61
+ const close = () => { setDrawerOpen(false); menu.current?.focus(); };
62
+ const select = (id: string) => { if (!busy) { onSelect(id); close(); } };
63
+ const history = <>
64
+ <div className="frankie-history-heading"><strong>Conversations</strong><button type="button" className="frankie-mobile-only" aria-label="Close conversations" onClick={close}><X size={18} /></button></div>
65
+ <button type="button" className="frankie-new-chat" disabled={busy} onClick={() => { onNew(); close(); }}><Plus size={17} />New chat</button>
66
+ <label className="frankie-history-search"><span>Search conversations</span><input type="search" placeholder="Search chats…" value={query} onChange={event => setQuery(event.target.value)} /></label>
67
+ <nav className="frankie-history-list" aria-label="Conversations">{conversations.filter(item => item.title.toLowerCase().includes(query.toLowerCase())).map(item => <button type="button" key={item.id} aria-current={item.id === currentId ? "page" : undefined} disabled={busy} onClick={() => select(item.id)}><span>{item.title}</span><small>{new Date(item.updatedAt).toLocaleDateString()}</small></button>)}</nav>
68
+ {historyLoading ? <p role="status">Loading history…</p> : historyError ? <p role="status">{historyError}<button type="button" onClick={() => void refresh()}>Try again</button></p> : !conversations.length ? <p>{config?.allowAnonymous ? "Sign in to sync conversations across devices." : "Your conversations will appear here."}</p> : null}
69
+ {navigation ? <div className="frankie-host-navigation">{navigation}</div> : null}
70
+ </>;
71
+ return <div className={"frankie-page-shell" + (resolveMode(theme?.mode) === "dark" ? " dark" : "")} style={themeToCssVars(theme)}>
72
+ {showHistory ? <aside className="frankie-history-desktop">{history}</aside> : null}
73
+ <div className="frankie-page-main">
74
+ <header className="frankie-page-header"><div>{showHistory ? <button type="button" className="frankie-mobile-only" ref={menu} aria-label="Open conversations" aria-haspopup="dialog" aria-expanded={drawerOpen} onClick={() => setDrawerOpen(true)}><Menu size={20} /></button> : null}<strong>{name}</strong><span className="frankie-header-caption">Product assistant</span></div><div>
75
+ <button type="button" disabled={busy} onClick={onNew} aria-label="Start a new conversation"><Plus size={16} /><span>New chat</span></button>
76
+ <details className="frankie-chat-settings"><summary aria-label="Chat settings"><Settings size={19} /></summary><div><strong>Chat settings</strong><p>History syncs automatically for signed-in users.</p><button type="button" onClick={() => void refresh()} disabled={historyLoading || !showHistory}><History size={16} />Refresh history</button><small>Skills are checked at the start of each message.</small></div></details>
77
+ </div></header>
78
+ <div className="frankie-page-canvas"><FrankieInline {...panel} header={panel.header ?? false} emptyState={panel.emptyState ?? <PageEmptyState />} /></div>
79
+ </div>
80
+ {showHistory ? <dialog ref={dialog} className="frankie-history-dialog frankie-root" aria-label="Chat conversations" style={themeToCssVars(theme)} onCancel={close} onClick={event => { if (event.target === event.currentTarget) close(); }}><aside>{history}</aside></dialog> : null}
81
+ </div>;
82
+ }
83
+
84
+ /** Reusable product chat with one shared conversation state and scoped styling. */
85
+ export function FrankieChatPage({ className, style, header, footer, emptyState, renderMessage, renderTool, placeholder, autoFocus, showHistory = true, navigation, ...provider }: FrankieChatPageProps) {
86
+ const [id, setId] = useState(() => provider.conversationId ?? crypto.randomUUID());
87
+ const onNew = () => setId(crypto.randomUUID());
88
+ return <div className={["frankie-root", "frankie-chat-page", className].filter(Boolean).join(" ")} style={{ height: "100%", minHeight: "min(36rem, 100dvh)", width: "100%", ...style }}>
89
+ <FrankieProvider {...provider} conversationId={id} storageNamespace={(provider.storageNamespace ?? "frankie." + provider.publishableKey) + "." + id}>
90
+ <PageChrome showHistory={showHistory} navigation={navigation} currentId={id} onSelect={setId} onNew={onNew} header={header} footer={footer} emptyState={emptyState} renderMessage={renderMessage} renderTool={renderTool} placeholder={placeholder} autoFocus={autoFocus} />
91
+ </FrankieProvider>
92
+ </div>;
53
93
  }
@@ -13,6 +13,8 @@ export interface FrankieWidgetProps extends ChatPanelProps {
13
13
  /** Distance from the viewport edges, in pixels. */
14
14
  offset?: number;
15
15
  defaultOpen?: boolean;
16
+ /** Host overlays should sit above the assistant. Override for your layer system. */
17
+ zIndex?: number;
16
18
  /** Controlled mode: pass both to drive open state from the host app. */
17
19
  open?: boolean;
18
20
  onOpenChange?: (open: boolean) => void;
@@ -30,6 +32,7 @@ export function FrankieWidget({
30
32
  position = "bottom-right",
31
33
  offset = 24,
32
34
  defaultOpen = false,
35
+ zIndex = 20,
33
36
  open: controlledOpen,
34
37
  onOpenChange,
35
38
  renderLauncher,
@@ -63,11 +66,12 @@ export function FrankieWidget({
63
66
  return (
64
67
  <div
65
68
  className={cn("frankie-root frankie-widget", mode === "dark" && "dark")}
66
- style={{ ...themeToCssVars(theme), position: "fixed", bottom: offset, ...side, zIndex: 2147483000 }}
69
+ style={{ ...themeToCssVars(theme), position: "fixed", bottom: offset, ...side, zIndex, pointerEvents: "none" }}
67
70
  data-frankie-mode={mode}
68
71
  >
69
72
  <div className={cn("flex flex-col gap-3", position === "bottom-left" ? "items-start" : "items-end")}>
70
- {open ? <ChatPanel {...panelProps} onClose={() => setOpen(false)} autoFocus /> : null}
73
+ {open ? <div style={{ pointerEvents: "auto", maxHeight: "calc(100dvh - 100px)", overflow: "hidden" }}><ChatPanel {...panelProps} className={cn("frankie-widget-panel", panelProps.className)} onClose={() => setOpen(false)} autoFocus /></div> : null}
74
+ <div style={{ pointerEvents: "auto" }}>
71
75
  {renderLauncher ? (
72
76
  renderLauncher({ open, toggle: () => setOpen(!open), label })
73
77
  ) : (
@@ -81,7 +85,7 @@ export function FrankieWidget({
81
85
  >
82
86
  {open ? <X className="size-5" /> : <MessageCircle className="size-5" />}
83
87
  </Button>
84
- )}
88
+ )}</div>
85
89
  </div>
86
90
  </div>
87
91
  );
@@ -1,8 +1,9 @@
1
1
  "use client";
2
2
 
3
- import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
3
+ import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
4
4
  import type { PublicWidgetConfig, WidgetTheme } from "@frankieai/core";
5
5
  import { FrankieClient, type FrankieClientOptions, type PageContext } from "../lib/client.js";
6
+ import { FrankieChatContext, useFrankieChatState } from "./useFrankieChat.js";
6
7
 
7
8
  export interface FrankieProviderProps extends FrankieClientOptions {
8
9
  children: ReactNode;
@@ -29,10 +30,12 @@ export function FrankieProvider({
29
30
  config: providedConfig,
30
31
  ...clientOptions
31
32
  }: FrankieProviderProps) {
33
+ const tokenCallback = useRef(clientOptions.getUserToken);
34
+ tokenCallback.current = clientOptions.getUserToken;
32
35
  const client = useMemo(
33
- () => new FrankieClient(clientOptions),
36
+ () => new FrankieClient({ ...clientOptions, ...(clientOptions.getUserToken ? { getUserToken: () => tokenCallback.current?.() } : {}) }),
34
37
  // eslint-disable-next-line react-hooks/exhaustive-deps
35
- [clientOptions.baseUrl, clientOptions.publishableKey, clientOptions.storageNamespace, clientOptions.conversationId],
38
+ [clientOptions.baseUrl, clientOptions.publishableKey, clientOptions.storageNamespace, clientOptions.conversationId, clientOptions.persist, clientOptions.fetchImpl, Boolean(clientOptions.getUserToken)],
36
39
  );
37
40
 
38
41
  const [config, setConfig] = useState<PublicWidgetConfig | undefined>(providedConfig);
@@ -71,7 +74,12 @@ export function FrankieProvider({
71
74
  [client, config, configError, loading, context, theme],
72
75
  );
73
76
 
74
- return <FrankieContext.Provider value={value}>{children}</FrankieContext.Provider>;
77
+ return <FrankieContext.Provider value={value}><ConversationState key={client.namespace + ":" + (client.conversationId ?? "")}>{children}</ConversationState></FrankieContext.Provider>;
78
+ }
79
+
80
+ function ConversationState({ children }: { children: ReactNode }) {
81
+ const chat = useFrankieChatState();
82
+ return <FrankieChatContext.Provider value={chat}>{children}</FrankieChatContext.Provider>;
75
83
  }
76
84
 
77
85
  export function useFrankie(): FrankieContextValue {
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useEffect, useRef, useState } from "react";
4
- import type { ChatAttachment } from "@frankieai/core";
3
+ import { createContext, useContext, useCallback, useEffect, useRef, useState } from "react";
4
+ import type { ChatAttachment, LoadedSkill } from "@frankieai/core";
5
5
  import { FrankieApiError } from "../lib/client.js";
6
6
  import {
7
7
  applyEvent,
@@ -19,6 +19,8 @@ export interface SendOptions {
19
19
  }
20
20
 
21
21
  export interface UseFrankieChatResult {
22
+ loadedSkills: LoadedSkill[] | null;
23
+ restoring: boolean;
22
24
  messages: ChatMessage[];
23
25
  /** True from the moment a message is sent until the turn ends. */
24
26
  busy: boolean;
@@ -34,14 +36,25 @@ export interface UseFrankieChatResult {
34
36
  let counter = 0;
35
37
  const nextId = (prefix: string) => `${prefix}_${Date.now().toString(36)}_${(counter += 1)}`;
36
38
 
39
+ export const FrankieChatContext = createContext<UseFrankieChatResult | null>(null);
37
40
  export function useFrankieChat(): UseFrankieChatResult {
41
+ const value = useContext(FrankieChatContext);
42
+ if (!value) throw new Error("useFrankieChat requires FrankieProvider.");
43
+ return value;
44
+ }
45
+
46
+ export function useFrankieChatState(): UseFrankieChatResult {
38
47
  const { client, context } = useFrankie();
39
48
  const [messages, setMessages] = useState<ChatMessage[]>([]);
40
49
  const [busy, setBusy] = useState(false);
41
50
  const [error, setError] = useState<string | undefined>(undefined);
42
51
  const [restored, setRestored] = useState(false);
52
+ const [loadedSkills, setLoadedSkills] = useState<LoadedSkill[] | null>(null);
43
53
  const abortRef = useRef<AbortController | null>(null);
44
54
  const mounted = useRef(true);
55
+ // State updates can be batched while a stream is arriving. Keep the latest
56
+ // transcript synchronously so the final server sync cannot save a stale copy.
57
+ const messagesRef = useRef<ChatMessage[]>([]);
45
58
 
46
59
  useEffect(() => {
47
60
  mounted.current = true;
@@ -63,16 +76,23 @@ export function useFrankieChat(): UseFrankieChatResult {
63
76
  try {
64
77
  const raw = window.localStorage.getItem(storageKey);
65
78
  const stored = raw ? (JSON.parse(raw) as ChatMessage[]) : [];
66
- if (Array.isArray(stored) && stored.length && active) setMessages(stored);
79
+ if (Array.isArray(stored) && stored.length && active) {
80
+ messagesRef.current = stored;
81
+ setMessages(stored);
82
+ }
67
83
  } catch {
68
84
  /* storage unavailable; server history can still restore */
69
85
  }
70
86
  }
71
87
  try {
72
88
  const remote = await client.resumeConversation();
73
- if (active && remote?.messages.length) setMessages(remote.messages as ChatMessage[]);
89
+ if (active && remote?.messages.length) {
90
+ const restoredMessages = remote.messages as ChatMessage[];
91
+ messagesRef.current = restoredMessages;
92
+ setMessages(restoredMessages);
93
+ }
74
94
  } catch {
75
- /* A new conversation does not exist remotely until its first message. */
95
+ if (active) setError("Could not restore this conversation. Check your sign-in and connection, then try opening it again.");
76
96
  } finally {
77
97
  if (active) setRestored(true);
78
98
  }
@@ -96,33 +116,36 @@ export function useFrankieChat(): UseFrankieChatResult {
96
116
  }, [messages, busy, client, storageKey, restored]);
97
117
 
98
118
  const patchAssistant = useCallback((id: string, update: (m: AssistantChatMessage) => AssistantChatMessage) => {
99
- setMessages((current) =>
100
- current.map((message) =>
101
- message.id === id && message.role === "assistant" ? update(message) : message,
102
- ),
119
+ const next = messagesRef.current.map((message) =>
120
+ message.id === id && message.role === "assistant" ? update(message) : message,
103
121
  );
122
+ messagesRef.current = next;
123
+ setMessages(next);
104
124
  }, []);
105
125
 
106
126
  const send = useCallback(
107
127
  async (text: string, opts?: SendOptions) => {
108
128
  const trimmed = text.trim();
109
129
  const attachments = opts?.attachments ?? [];
110
- if ((!trimmed && attachments.length === 0) || busy) return;
130
+ if ((!trimmed && attachments.length === 0) || abortRef.current || !restored) return;
111
131
  // The server needs a message; an attachment-only turn gets a stand-in.
112
132
  const outgoing = trimmed || "Please take a look at the attached file(s).";
113
133
 
114
134
  const assistantId = nextId("a");
115
135
  setError(undefined);
136
+ setLoadedSkills(null);
116
137
  setBusy(true);
117
- setMessages((current) => [
118
- ...current,
138
+ const next = [
139
+ ...messagesRef.current,
119
140
  newUserMessage(
120
141
  nextId("u"),
121
142
  trimmed,
122
143
  attachments.map((file) => ({ name: file.name, mimeType: file.mimeType })),
123
144
  ),
124
145
  newAssistantMessage(assistantId),
125
- ]);
146
+ ];
147
+ messagesRef.current = next;
148
+ setMessages(next);
126
149
 
127
150
  const abort = new AbortController();
128
151
  abortRef.current = abort;
@@ -139,6 +162,7 @@ export function useFrankieChat(): UseFrankieChatResult {
139
162
  signal: abort.signal,
140
163
  },
141
164
  (event) => {
165
+ if (event.type === "skills_loaded") { setLoadedSkills(event.skills); return; }
142
166
  if (event.type === "ping" || event.type === "session") return;
143
167
  if (event.type === "text_delta") receivedText = true;
144
168
  if (event.type === "tool_start" || event.type === "tool_end") receivedToolActivity = true;
@@ -175,17 +199,22 @@ export function useFrankieChat(): UseFrankieChatResult {
175
199
  patchAssistant(assistantId, (current) => ({ ...current, status: "error", error: message }));
176
200
  }
177
201
  } finally {
178
- if (mounted.current) {
179
- setBusy(false);
180
- // A turn that ended without a `turn_end` event still has to settle.
181
- patchAssistant(assistantId, (message) =>
182
- message.status === "streaming" ? { ...message, status: "done" } : message,
183
- );
202
+ // A turn that ended without a `turn_end` event still has to settle.
203
+ patchAssistant(assistantId, (message) =>
204
+ message.status === "streaming" ? { ...message, status: "done" } : message,
205
+ );
206
+ // Persist before send() resolves. This closes the previous gap where a
207
+ // route change or tab close could leave a server conversation with no
208
+ // rendered messages even though the agent transcript existed.
209
+ const transcript = messagesRef.current;
210
+ if (transcript.length) {
211
+ await client.syncConversation(transcript, conversationTitle(transcript)).catch(() => undefined);
184
212
  }
213
+ if (mounted.current) setBusy(false);
185
214
  abortRef.current = null;
186
215
  }
187
216
  },
188
- [busy, client, context, patchAssistant],
217
+ [busy, client, context, patchAssistant, restored],
189
218
  );
190
219
 
191
220
  const stop = useCallback(() => abortRef.current?.abort(), []);
@@ -193,10 +222,12 @@ export function useFrankieChat(): UseFrankieChatResult {
193
222
  const reset = useCallback(() => {
194
223
  abortRef.current?.abort();
195
224
  client.resetSession();
225
+ messagesRef.current = [];
196
226
  setMessages([]);
197
227
  setError(undefined);
198
228
  setBusy(false);
199
229
  setRestored(true);
230
+ setLoadedSkills(null);
200
231
  try {
201
232
  if (client.persist) window.localStorage.removeItem(`${client.namespace}.messages`);
202
233
  } catch {
@@ -204,7 +235,7 @@ export function useFrankieChat(): UseFrankieChatResult {
204
235
  }
205
236
  }, [client]);
206
237
 
207
- return { messages, busy, ...(error ? { error } : {}), send, stop, reset };
238
+ return { messages, busy, loadedSkills, restoring: !restored, ...(error ? { error } : {}), send, stop, reset };
208
239
  }
209
240
 
210
241
 
package/src/lib/client.ts CHANGED
@@ -21,15 +21,15 @@ export interface FrankieClientOptions {
21
21
  publishableKey: string;
22
22
  /**
23
23
  * Fetches a short-lived JWT for the signed-in user from the host's own
24
- * backend. Called once per session, and again when a session expires.
24
+ * backend. Called before each turn; return undefined after sign-out.
25
25
  * Omit only for tenants that allow anonymous chat.
26
26
  */
27
27
  getUserToken?: () => Promise<string | undefined> | string | undefined;
28
28
  fetchImpl?: typeof fetch;
29
29
  /**
30
30
  * Remember the session (and, in the hook, the transcript) in localStorage so a
31
- * page reload resumes the same conversation. On by default in the browser;
32
- * set false for an ephemeral chat that starts fresh every load.
31
+ * page reload resumes the same anonymous conversation. Authenticated chat
32
+ * never writes tokens or transcripts to localStorage; it uses server history.
33
33
  */
34
34
  persist?: boolean;
35
35
  /** Override localStorage namespace for session and transcript. */
@@ -59,11 +59,15 @@ export class FrankieClient {
59
59
  private readonly baseUrl: string;
60
60
  private readonly doFetch: typeof fetch;
61
61
  private session?: CreateSessionResponse;
62
+ private sessionUserToken?: string;
62
63
 
63
64
  constructor(private readonly options: FrankieClientOptions) {
64
65
  this.baseUrl = (options.baseUrl ?? FRANKIE_CLOUD_URL).replace(/\/+$/, "");
65
66
  this.doFetch = options.fetchImpl ?? globalThis.fetch.bind(globalThis);
66
67
  this.session = this.readStored();
68
+ if (options.getUserToken && typeof window !== "undefined") {
69
+ try { for (const suffix of ["session", "messages"]) window.localStorage.removeItem(`${this.namespace}.${suffix}`); } catch { /* storage may be unavailable */ }
70
+ }
67
71
  }
68
72
 
69
73
  get sessionId(): string | undefined {
@@ -76,7 +80,9 @@ export class FrankieClient {
76
80
 
77
81
  /** Whether this client persists across reloads (browser only, opt-out via `persist:false`). */
78
82
  get persist(): boolean {
79
- return this.options.persist !== false && typeof window !== "undefined";
83
+ // Authenticated history comes from the identity-checked server. Do not leave
84
+ // a previous user's bearer token or transcript in shared browser storage.
85
+ return !this.options.getUserToken && this.options.persist !== false && typeof window !== "undefined";
80
86
  }
81
87
 
82
88
  /** localStorage namespace for this tenant/conversation, shared with the chat hook. */
@@ -115,15 +121,17 @@ export class FrankieClient {
115
121
  }
116
122
 
117
123
  async ensureSession(context?: PageContext): Promise<CreateSessionResponse> {
118
- if (this.session && new Date(this.session.expiresAt).getTime() > Date.now() + 30_000) return this.session;
119
124
  const userToken = await this.options.getUserToken?.();
120
125
  if (this.options.getUserToken && !userToken) {
126
+ this.resetSession();
121
127
  throw new FrankieApiError(
122
128
  "Your signed-in Frankie session could not be verified. Refresh the page and sign in again.",
123
129
  401,
124
130
  "user_token_unavailable",
125
131
  );
126
132
  }
133
+ if (userToken !== this.sessionUserToken) this.resetSession();
134
+ if (this.session && new Date(this.session.expiresAt).getTime() > Date.now() + 30_000) return this.session;
127
135
  const response = await this.doFetch(`${this.baseUrl}/v1/session`, {
128
136
  method: "POST",
129
137
  headers: { "content-type": "application/json", "x-frankie-key": this.options.publishableKey },
@@ -134,6 +142,7 @@ export class FrankieClient {
134
142
  }),
135
143
  });
136
144
  this.session = (await this.unwrap(response)) as CreateSessionResponse;
145
+ this.sessionUserToken = userToken;
137
146
  this.writeStored();
138
147
  return this.session;
139
148
  }
@@ -148,7 +157,7 @@ export class FrankieClient {
148
157
  }
149
158
 
150
159
  async resumeConversation(id = this.options.conversationId): Promise<{ messages: unknown[]; title: string } | undefined> {
151
- if (!id) return undefined;
160
+ if (!id || !this.options.getUserToken) return undefined;
152
161
  const userToken = await this.requireUserToken();
153
162
  const response = await this.doFetch(`${this.baseUrl}/v1/conversations/${encodeURIComponent(id)}/resume`, {
154
163
  method: "POST",
@@ -157,6 +166,7 @@ export class FrankieClient {
157
166
  if (response.status === 404) return undefined;
158
167
  const body = (await this.unwrap(response)) as { session: CreateSessionResponse; messages?: unknown[]; title?: string };
159
168
  this.session = body.session;
169
+ this.sessionUserToken = userToken;
160
170
  this.writeStored();
161
171
  return { messages: Array.isArray(body.messages) ? body.messages : [], title: body.title ?? "New chat" };
162
172
  }