@frankieai/react 0.1.5 → 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.
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  // src/hooks/FrankieProvider.tsx
4
- import { createContext, useContext, useEffect, useMemo, useState } from "react";
4
+ import { createContext as createContext2, useContext as useContext2, useEffect as useEffect2, useMemo, useRef as useRef2, useState as useState2 } from "react";
5
5
 
6
6
  // src/lib/client.ts
7
7
  var FRANKIE_CLOUD_URL = "https://frankie-data-gq5o5syq2q-uc.a.run.app";
@@ -20,11 +20,18 @@ var FrankieClient = class {
20
20
  this.baseUrl = (options.baseUrl ?? FRANKIE_CLOUD_URL).replace(/\/+$/, "");
21
21
  this.doFetch = options.fetchImpl ?? globalThis.fetch.bind(globalThis);
22
22
  this.session = this.readStored();
23
+ if (options.getUserToken && typeof window !== "undefined") {
24
+ try {
25
+ for (const suffix of ["session", "messages"]) window.localStorage.removeItem(`${this.namespace}.${suffix}`);
26
+ } catch {
27
+ }
28
+ }
23
29
  }
24
30
  options;
25
31
  baseUrl;
26
32
  doFetch;
27
33
  session;
34
+ sessionUserToken;
28
35
  get sessionId() {
29
36
  return this.session?.sessionId;
30
37
  }
@@ -33,7 +40,7 @@ var FrankieClient = class {
33
40
  }
34
41
  /** Whether this client persists across reloads (browser only, opt-out via `persist:false`). */
35
42
  get persist() {
36
- return this.options.persist !== false && typeof window !== "undefined";
43
+ return !this.options.getUserToken && this.options.persist !== false && typeof window !== "undefined";
37
44
  }
38
45
  /** localStorage namespace for this tenant/conversation, shared with the chat hook. */
39
46
  get namespace() {
@@ -66,15 +73,17 @@ var FrankieClient = class {
66
73
  return await this.unwrap(response);
67
74
  }
68
75
  async ensureSession(context) {
69
- if (this.session && new Date(this.session.expiresAt).getTime() > Date.now() + 3e4) return this.session;
70
76
  const userToken = await this.options.getUserToken?.();
71
77
  if (this.options.getUserToken && !userToken) {
78
+ this.resetSession();
72
79
  throw new FrankieApiError(
73
80
  "Your signed-in Frankie session could not be verified. Refresh the page and sign in again.",
74
81
  401,
75
82
  "user_token_unavailable"
76
83
  );
77
84
  }
85
+ if (userToken !== this.sessionUserToken) this.resetSession();
86
+ if (this.session && new Date(this.session.expiresAt).getTime() > Date.now() + 3e4) return this.session;
78
87
  const response = await this.doFetch(`${this.baseUrl}/v1/session`, {
79
88
  method: "POST",
80
89
  headers: { "content-type": "application/json", "x-frankie-key": this.options.publishableKey },
@@ -85,6 +94,7 @@ var FrankieClient = class {
85
94
  })
86
95
  });
87
96
  this.session = await this.unwrap(response);
97
+ this.sessionUserToken = userToken;
88
98
  this.writeStored();
89
99
  return this.session;
90
100
  }
@@ -97,7 +107,7 @@ var FrankieClient = class {
97
107
  return Array.isArray(body.conversations) ? body.conversations : [];
98
108
  }
99
109
  async resumeConversation(id = this.options.conversationId) {
100
- if (!id) return void 0;
110
+ if (!id || !this.options.getUserToken) return void 0;
101
111
  const userToken = await this.requireUserToken();
102
112
  const response = await this.doFetch(`${this.baseUrl}/v1/conversations/${encodeURIComponent(id)}/resume`, {
103
113
  method: "POST",
@@ -106,6 +116,7 @@ var FrankieClient = class {
106
116
  if (response.status === 404) return void 0;
107
117
  const body = await this.unwrap(response);
108
118
  this.session = body.session;
119
+ this.sessionUserToken = userToken;
109
120
  this.writeStored();
110
121
  return { messages: Array.isArray(body.messages) ? body.messages : [], title: body.title ?? "New chat" };
111
122
  }
@@ -212,64 +223,8 @@ function parseFrame(frame) {
212
223
  }
213
224
  }
214
225
 
215
- // src/hooks/FrankieProvider.tsx
216
- import { jsx } from "react/jsx-runtime";
217
- var FrankieContext = createContext(null);
218
- function FrankieProvider({
219
- children,
220
- context,
221
- theme,
222
- config: providedConfig,
223
- ...clientOptions
224
- }) {
225
- const client = useMemo(
226
- () => new FrankieClient(clientOptions),
227
- // eslint-disable-next-line react-hooks/exhaustive-deps
228
- [clientOptions.baseUrl, clientOptions.publishableKey, clientOptions.storageNamespace, clientOptions.conversationId]
229
- );
230
- const [config, setConfig] = useState(providedConfig);
231
- const [configError, setConfigError] = useState(void 0);
232
- const [loading, setLoading] = useState(!providedConfig);
233
- useEffect(() => {
234
- if (providedConfig) return;
235
- const abort = new AbortController();
236
- setLoading(true);
237
- client.getConfig(abort.signal).then((next) => {
238
- setConfig(next);
239
- setConfigError(void 0);
240
- }).catch((error) => {
241
- if (abort.signal.aborted) return;
242
- setConfigError(error instanceof Error ? error.message : "Could not load the assistant.");
243
- }).finally(() => {
244
- if (!abort.signal.aborted) setLoading(false);
245
- });
246
- return () => abort.abort();
247
- }, [client, providedConfig]);
248
- const value = useMemo(
249
- () => ({
250
- client,
251
- ...config ? { config } : {},
252
- ...configError ? { configError } : {},
253
- loading,
254
- ...context ? { context } : {},
255
- ...theme ? { theme } : {}
256
- }),
257
- [client, config, configError, loading, context, theme]
258
- );
259
- return /* @__PURE__ */ jsx(FrankieContext.Provider, { value, children });
260
- }
261
- function useFrankie() {
262
- const value = useContext(FrankieContext);
263
- if (!value) throw new Error("Frankie components must be rendered inside <FrankieProvider>.");
264
- return value;
265
- }
266
- function useFrankieTheme() {
267
- const { config, theme } = useFrankie();
268
- return useMemo(() => ({ ...config?.theme, ...theme }), [config?.theme, theme]);
269
- }
270
-
271
226
  // src/hooks/useFrankieChat.ts
272
- import { useCallback, useEffect as useEffect2, useRef, useState as useState2 } from "react";
227
+ import { createContext, useContext, useCallback, useEffect, useRef, useState } from "react";
273
228
 
274
229
  // src/lib/messages.ts
275
230
  function applyEvent(message, event) {
@@ -346,16 +301,23 @@ function newUserMessage(id, text, attachments) {
346
301
  // src/hooks/useFrankieChat.ts
347
302
  var counter = 0;
348
303
  var nextId = (prefix) => `${prefix}_${Date.now().toString(36)}_${counter += 1}`;
304
+ var FrankieChatContext = createContext(null);
349
305
  function useFrankieChat() {
306
+ const value = useContext(FrankieChatContext);
307
+ if (!value) throw new Error("useFrankieChat requires FrankieProvider.");
308
+ return value;
309
+ }
310
+ function useFrankieChatState() {
350
311
  const { client, context } = useFrankie();
351
- const [messages, setMessages] = useState2([]);
352
- const [busy, setBusy] = useState2(false);
353
- const [error, setError] = useState2(void 0);
354
- const [restored, setRestored] = useState2(false);
312
+ const [messages, setMessages] = useState([]);
313
+ const [busy, setBusy] = useState(false);
314
+ const [error, setError] = useState(void 0);
315
+ const [restored, setRestored] = useState(false);
316
+ const [loadedSkills, setLoadedSkills] = useState(null);
355
317
  const abortRef = useRef(null);
356
318
  const mounted = useRef(true);
357
319
  const messagesRef = useRef([]);
358
- useEffect2(() => {
320
+ useEffect(() => {
359
321
  mounted.current = true;
360
322
  return () => {
361
323
  mounted.current = false;
@@ -363,7 +325,7 @@ function useFrankieChat() {
363
325
  };
364
326
  }, []);
365
327
  const storageKey = `${client.namespace}.messages`;
366
- useEffect2(() => {
328
+ useEffect(() => {
367
329
  let active = true;
368
330
  const restore = async () => {
369
331
  if (client.persist) {
@@ -385,6 +347,7 @@ function useFrankieChat() {
385
347
  setMessages(restoredMessages);
386
348
  }
387
349
  } catch {
350
+ if (active) setError("Could not restore this conversation. Check your sign-in and connection, then try opening it again.");
388
351
  } finally {
389
352
  if (active) setRestored(true);
390
353
  }
@@ -394,7 +357,7 @@ function useFrankieChat() {
394
357
  active = false;
395
358
  };
396
359
  }, [client, storageKey]);
397
- useEffect2(() => {
360
+ useEffect(() => {
398
361
  if (!client.persist || !restored || busy) return;
399
362
  try {
400
363
  if (messages.length) window.localStorage.setItem(storageKey, JSON.stringify(messages));
@@ -414,10 +377,11 @@ function useFrankieChat() {
414
377
  async (text, opts) => {
415
378
  const trimmed = text.trim();
416
379
  const attachments = opts?.attachments ?? [];
417
- if (!trimmed && attachments.length === 0 || busy) return;
380
+ if (!trimmed && attachments.length === 0 || abortRef.current || !restored) return;
418
381
  const outgoing = trimmed || "Please take a look at the attached file(s).";
419
382
  const assistantId = nextId("a");
420
383
  setError(void 0);
384
+ setLoadedSkills(null);
421
385
  setBusy(true);
422
386
  const next = [
423
387
  ...messagesRef.current,
@@ -444,6 +408,10 @@ function useFrankieChat() {
444
408
  signal: abort.signal
445
409
  },
446
410
  (event) => {
411
+ if (event.type === "skills_loaded") {
412
+ setLoadedSkills(event.skills);
413
+ return;
414
+ }
447
415
  if (event.type === "ping" || event.type === "session") return;
448
416
  if (event.type === "text_delta") receivedText = true;
449
417
  if (event.type === "tool_start" || event.type === "tool_end") receivedToolActivity = true;
@@ -482,7 +450,7 @@ function useFrankieChat() {
482
450
  abortRef.current = null;
483
451
  }
484
452
  },
485
- [busy, client, context, patchAssistant]
453
+ [busy, client, context, patchAssistant, restored]
486
454
  );
487
455
  const stop = useCallback(() => abortRef.current?.abort(), []);
488
456
  const reset = useCallback(() => {
@@ -493,12 +461,13 @@ function useFrankieChat() {
493
461
  setError(void 0);
494
462
  setBusy(false);
495
463
  setRestored(true);
464
+ setLoadedSkills(null);
496
465
  try {
497
466
  if (client.persist) window.localStorage.removeItem(`${client.namespace}.messages`);
498
467
  } catch {
499
468
  }
500
469
  }, [client]);
501
- return { messages, busy, ...error ? { error } : {}, send, stop, reset };
470
+ return { messages, busy, loadedSkills, restoring: !restored, ...error ? { error } : {}, send, stop, reset };
502
471
  }
503
472
  function conversationTitle(messages) {
504
473
  const first = messages.find((message) => message.role === "user" && message.text.trim());
@@ -507,6 +476,68 @@ function conversationTitle(messages) {
507
476
  return title.length > 42 ? `${title.slice(0, 42)}\u2026` : title;
508
477
  }
509
478
 
479
+ // src/hooks/FrankieProvider.tsx
480
+ import { jsx } from "react/jsx-runtime";
481
+ var FrankieContext = createContext2(null);
482
+ function FrankieProvider({
483
+ children,
484
+ context,
485
+ theme,
486
+ config: providedConfig,
487
+ ...clientOptions
488
+ }) {
489
+ const tokenCallback = useRef2(clientOptions.getUserToken);
490
+ tokenCallback.current = clientOptions.getUserToken;
491
+ const client = useMemo(
492
+ () => new FrankieClient({ ...clientOptions, ...clientOptions.getUserToken ? { getUserToken: () => tokenCallback.current?.() } : {} }),
493
+ // eslint-disable-next-line react-hooks/exhaustive-deps
494
+ [clientOptions.baseUrl, clientOptions.publishableKey, clientOptions.storageNamespace, clientOptions.conversationId, clientOptions.persist, clientOptions.fetchImpl, Boolean(clientOptions.getUserToken)]
495
+ );
496
+ const [config, setConfig] = useState2(providedConfig);
497
+ const [configError, setConfigError] = useState2(void 0);
498
+ const [loading, setLoading] = useState2(!providedConfig);
499
+ useEffect2(() => {
500
+ if (providedConfig) return;
501
+ const abort = new AbortController();
502
+ setLoading(true);
503
+ client.getConfig(abort.signal).then((next) => {
504
+ setConfig(next);
505
+ setConfigError(void 0);
506
+ }).catch((error) => {
507
+ if (abort.signal.aborted) return;
508
+ setConfigError(error instanceof Error ? error.message : "Could not load the assistant.");
509
+ }).finally(() => {
510
+ if (!abort.signal.aborted) setLoading(false);
511
+ });
512
+ return () => abort.abort();
513
+ }, [client, providedConfig]);
514
+ const value = useMemo(
515
+ () => ({
516
+ client,
517
+ ...config ? { config } : {},
518
+ ...configError ? { configError } : {},
519
+ loading,
520
+ ...context ? { context } : {},
521
+ ...theme ? { theme } : {}
522
+ }),
523
+ [client, config, configError, loading, context, theme]
524
+ );
525
+ return /* @__PURE__ */ jsx(FrankieContext.Provider, { value, children: /* @__PURE__ */ jsx(ConversationState, { children }, client.namespace + ":" + (client.conversationId ?? "")) });
526
+ }
527
+ function ConversationState({ children }) {
528
+ const chat = useFrankieChatState();
529
+ return /* @__PURE__ */ jsx(FrankieChatContext.Provider, { value: chat, children });
530
+ }
531
+ function useFrankie() {
532
+ const value = useContext2(FrankieContext);
533
+ if (!value) throw new Error("Frankie components must be rendered inside <FrankieProvider>.");
534
+ return value;
535
+ }
536
+ function useFrankieTheme() {
537
+ const { config, theme } = useFrankie();
538
+ return useMemo(() => ({ ...config?.theme, ...theme }), [config?.theme, theme]);
539
+ }
540
+
510
541
  // src/components/chat/FrankieWidget.tsx
511
542
  import { MessageCircle, X as X4 } from "lucide-react";
512
543
  import { useEffect as useEffect5, useState as useState6 } from "react";
@@ -570,7 +601,7 @@ function Button({ className, variant, size, asChild = false, ...props }) {
570
601
 
571
602
  // src/components/chat/ChatPanel.tsx
572
603
  import { RotateCcw, X as X3 } from "lucide-react";
573
- import { useEffect as useEffect4, useRef as useRef3, useState as useState5 } from "react";
604
+ import { useEffect as useEffect4, useRef as useRef4, useState as useState5 } from "react";
574
605
 
575
606
  // src/components/ui/avatar.tsx
576
607
  import { jsx as jsx3 } from "react/jsx-runtime";
@@ -630,7 +661,7 @@ var ScrollArea = forwardRef(
630
661
 
631
662
  // src/components/chat/Composer.tsx
632
663
  import { ArrowUp, Paperclip, Square, X } from "lucide-react";
633
- import { useCallback as useCallback2, useEffect as useEffect3, useRef as useRef2, useState as useState3 } from "react";
664
+ import { useCallback as useCallback2, useEffect as useEffect3, useRef as useRef3, useState as useState3 } from "react";
634
665
 
635
666
  // src/components/ui/textarea.tsx
636
667
  import { forwardRef as forwardRef2 } from "react";
@@ -658,8 +689,8 @@ function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocus, atta
658
689
  const [value, setValue] = useState3("");
659
690
  const [files, setFiles] = useState3([]);
660
691
  const [fileError, setFileError] = useState3(void 0);
661
- const textareaRef = useRef2(null);
662
- const fileInputRef = useRef2(null);
692
+ const textareaRef = useRef3(null);
693
+ const fileInputRef = useRef3(null);
663
694
  const canAttach = Boolean(attachments?.enabled);
664
695
  const models = modelPicker?.models ?? [];
665
696
  useEffect3(() => {
@@ -1109,8 +1140,8 @@ function ChatPanel({
1109
1140
  }) {
1110
1141
  const { config, configError, loading } = useFrankie();
1111
1142
  const theme = useFrankieTheme();
1112
- const { messages, busy, error, send, stop, reset } = useFrankieChat();
1113
- const scrollRef = useRef3(null);
1143
+ const { messages, busy, restoring, loadedSkills, error, send, stop, reset } = useFrankieChat();
1144
+ const scrollRef = useRef4(null);
1114
1145
  const features = config?.features;
1115
1146
  const pickerModels = features?.modelPicker.enabled ? features.modelPicker.models : [];
1116
1147
  const [model, setModel] = useState5(void 0);
@@ -1164,6 +1195,16 @@ function ChatPanel({
1164
1195
  )
1165
1196
  ) }) }) }),
1166
1197
  /* @__PURE__ */ jsxs4(CardFooter, { className: "flex flex-col gap-2", children: [
1198
+ loadedSkills !== null ? /* @__PURE__ */ jsxs4("details", { className: "frankie-loaded-skills", children: [
1199
+ /* @__PURE__ */ jsx13("summary", { role: "status", children: loadedSkills.length ? `${loadedSkills.length} ${loadedSkills.length === 1 ? "skill" : "skills"} loaded` : "No skills loaded for this turn" }),
1200
+ loadedSkills.length ? /* @__PURE__ */ jsx13("ul", { children: loadedSkills.map((skill) => /* @__PURE__ */ jsxs4("li", { children: [
1201
+ skill.name,
1202
+ /* @__PURE__ */ jsxs4("small", { children: [
1203
+ "v",
1204
+ skill.revision
1205
+ ] })
1206
+ ] }, skill.id)) }) : /* @__PURE__ */ jsx13("p", { children: "Your assistant can still answer using its available tools." })
1207
+ ] }) : null,
1167
1208
  error ? /* @__PURE__ */ jsx13("p", { className: "w-full text-xs text-red-600 dark:text-red-400", children: error }) : null,
1168
1209
  /* @__PURE__ */ jsx13("div", { className: "w-full", children: /* @__PURE__ */ jsx13(
1169
1210
  Composer,
@@ -1171,7 +1212,7 @@ function ChatPanel({
1171
1212
  onSend: send,
1172
1213
  onStop: stop,
1173
1214
  busy,
1174
- disabled: Boolean(configError),
1215
+ disabled: loading || restoring || Boolean(configError),
1175
1216
  ...placeholder ? { placeholder } : {},
1176
1217
  ...autoFocus ? { autoFocus } : {},
1177
1218
  ...features?.attachments.enabled ? { attachments: features.attachments } : {},
@@ -1191,6 +1232,7 @@ function FrankieWidget({
1191
1232
  position = "bottom-right",
1192
1233
  offset = 24,
1193
1234
  defaultOpen = false,
1235
+ zIndex = 20,
1194
1236
  open: controlledOpen,
1195
1237
  onOpenChange,
1196
1238
  renderLauncher,
@@ -1219,11 +1261,11 @@ function FrankieWidget({
1219
1261
  "div",
1220
1262
  {
1221
1263
  className: cn("frankie-root frankie-widget", mode === "dark" && "dark"),
1222
- style: { ...themeToCssVars(theme), position: "fixed", bottom: offset, ...side, zIndex: 2147483e3 },
1264
+ style: { ...themeToCssVars(theme), position: "fixed", bottom: offset, ...side, zIndex, pointerEvents: "none" },
1223
1265
  "data-frankie-mode": mode,
1224
1266
  children: /* @__PURE__ */ jsxs5("div", { className: cn("flex flex-col gap-3", position === "bottom-left" ? "items-start" : "items-end"), children: [
1225
- open ? /* @__PURE__ */ jsx14(ChatPanel, { ...panelProps, onClose: () => setOpen(false), autoFocus: true }) : null,
1226
- renderLauncher ? renderLauncher({ open, toggle: () => setOpen(!open), label }) : /* @__PURE__ */ jsx14(
1267
+ open ? /* @__PURE__ */ jsx14("div", { style: { pointerEvents: "auto", maxHeight: "calc(100dvh - 100px)", overflow: "hidden" }, children: /* @__PURE__ */ jsx14(ChatPanel, { ...panelProps, className: cn("frankie-widget-panel", panelProps.className), onClose: () => setOpen(false), autoFocus: true }) }) : null,
1268
+ /* @__PURE__ */ jsx14("div", { style: { pointerEvents: "auto" }, children: renderLauncher ? renderLauncher({ open, toggle: () => setOpen(!open), label }) : /* @__PURE__ */ jsx14(
1227
1269
  Button,
1228
1270
  {
1229
1271
  size: "icon",
@@ -1234,7 +1276,7 @@ function FrankieWidget({
1234
1276
  title: label,
1235
1277
  children: open ? /* @__PURE__ */ jsx14(X4, { className: "size-5" }) : /* @__PURE__ */ jsx14(MessageCircle, { className: "size-5" })
1236
1278
  }
1237
- )
1279
+ ) })
1238
1280
  ] })
1239
1281
  }
1240
1282
  );
@@ -1257,94 +1299,145 @@ function FrankieInline({ className, style, ...panelProps }) {
1257
1299
  }
1258
1300
 
1259
1301
  // src/components/chat/FrankieChatPage.tsx
1302
+ import { useCallback as useCallback3, useEffect as useEffect6, useRef as useRef5, useState as useState7 } from "react";
1303
+ import { History, Menu, Plus, Settings, X as X5 } from "lucide-react";
1260
1304
  import { Fragment as Fragment3, jsx as jsx16, jsxs as jsxs6 } from "react/jsx-runtime";
1261
1305
  function PageEmptyState() {
1262
1306
  const { config } = useFrankie();
1263
- const { send, busy } = useFrankieChat();
1307
+ const { send, busy, restoring } = useFrankieChat();
1264
1308
  const name = config?.branding.name ?? "Frankie";
1265
- const prompts = config?.suggestedPrompts?.slice(0, 3) ?? [];
1266
1309
  return /* @__PURE__ */ jsxs6("section", { className: "frankie-page-empty", "aria-label": "Start a conversation", children: [
1267
1310
  /* @__PURE__ */ jsx16("span", { className: "frankie-page-avatar", "aria-hidden": true, children: name.trim().slice(0, 1).toUpperCase() }),
1268
1311
  /* @__PURE__ */ jsxs6("div", { children: [
1269
- /* @__PURE__ */ jsxs6("p", { className: "frankie-page-kicker", children: [
1270
- name,
1271
- " assistant"
1272
- ] }),
1273
- /* @__PURE__ */ jsx16("h1", { children: "What would you like to do?" }),
1312
+ /* @__PURE__ */ jsx16("p", { className: "frankie-page-kicker", children: "Your product assistant" }),
1313
+ /* @__PURE__ */ jsx16("h1", { children: "How can I help?" }),
1274
1314
  /* @__PURE__ */ jsx16("p", { children: config?.greeting ?? "Ask a question or choose a task to get started." })
1275
1315
  ] }),
1276
- prompts.length ? /* @__PURE__ */ jsx16("div", { className: "frankie-page-prompts", children: prompts.map((prompt, index) => /* @__PURE__ */ jsxs6("button", { type: "button", disabled: busy, onClick: () => send(prompt), children: [
1277
- /* @__PURE__ */ jsx16("span", { children: String(index + 1).padStart(2, "0") }),
1316
+ /* @__PURE__ */ jsx16("div", { className: "frankie-page-prompts", children: config?.suggestedPrompts?.slice(0, 3).map((prompt) => /* @__PURE__ */ jsxs6("button", { type: "button", disabled: busy || restoring, onClick: () => void send(prompt), children: [
1278
1317
  /* @__PURE__ */ jsx16("strong", { children: prompt }),
1279
1318
  /* @__PURE__ */ jsx16("b", { "aria-hidden": true, children: "\u2197" })
1280
- ] }, prompt)) }) : null
1319
+ ] }, prompt)) })
1281
1320
  ] });
1282
1321
  }
1283
- function PageChrome({
1284
- header,
1285
- footer,
1286
- emptyState,
1287
- renderMessage,
1288
- renderTool,
1289
- placeholder,
1290
- autoFocus
1291
- }) {
1292
- const { config } = useFrankie();
1293
- const { reset } = useFrankieChat();
1322
+ function PageChrome({ showHistory, navigation, currentId, onSelect, onNew, ...panel }) {
1323
+ const { client, config } = useFrankie();
1324
+ const { busy } = useFrankieChat();
1325
+ const theme = useFrankieTheme();
1326
+ const [conversations, setConversations] = useState7([]);
1327
+ const [historyError, setHistoryError] = useState7("");
1328
+ const [historyLoading, setHistoryLoading] = useState7(false);
1329
+ const [drawerOpen, setDrawerOpen] = useState7(false);
1330
+ const [query, setQuery] = useState7("");
1331
+ const dialog = useRef5(null);
1332
+ const menu = useRef5(null);
1333
+ const requestVersion = useRef5(0);
1294
1334
  const name = config?.branding.name ?? "Frankie";
1295
- return /* @__PURE__ */ jsxs6(Fragment3, { children: [
1296
- /* @__PURE__ */ jsxs6("header", { className: "frankie-page-header", children: [
1297
- /* @__PURE__ */ jsxs6("div", { children: [
1298
- /* @__PURE__ */ jsx16("span", { className: "frankie-page-status", "aria-hidden": true }),
1299
- /* @__PURE__ */ jsx16("strong", { children: name })
1335
+ const refresh = useCallback3(async () => {
1336
+ if (!showHistory || !config || config.allowAnonymous) {
1337
+ setHistoryError("");
1338
+ return;
1339
+ }
1340
+ const version = ++requestVersion.current;
1341
+ setHistoryLoading(true);
1342
+ setHistoryError("");
1343
+ try {
1344
+ const items = await client.listConversations();
1345
+ if (version === requestVersion.current) setConversations(items);
1346
+ } catch {
1347
+ if (version === requestVersion.current) setHistoryError("History couldn\u2019t load. Check your sign-in and try again.");
1348
+ } finally {
1349
+ if (version === requestVersion.current) setHistoryLoading(false);
1350
+ }
1351
+ }, [client, showHistory, config?.allowAnonymous]);
1352
+ useEffect6(() => {
1353
+ if (!busy) void refresh();
1354
+ return () => {
1355
+ requestVersion.current++;
1356
+ };
1357
+ }, [refresh, busy]);
1358
+ useEffect6(() => {
1359
+ const node = dialog.current;
1360
+ if (drawerOpen) {
1361
+ node?.showModal?.();
1362
+ } else {
1363
+ node?.close?.();
1364
+ }
1365
+ }, [drawerOpen]);
1366
+ const close = () => {
1367
+ setDrawerOpen(false);
1368
+ menu.current?.focus();
1369
+ };
1370
+ const select = (id) => {
1371
+ if (!busy) {
1372
+ onSelect(id);
1373
+ close();
1374
+ }
1375
+ };
1376
+ const history = /* @__PURE__ */ jsxs6(Fragment3, { children: [
1377
+ /* @__PURE__ */ jsxs6("div", { className: "frankie-history-heading", children: [
1378
+ /* @__PURE__ */ jsx16("strong", { children: "Conversations" }),
1379
+ /* @__PURE__ */ jsx16("button", { type: "button", className: "frankie-mobile-only", "aria-label": "Close conversations", onClick: close, children: /* @__PURE__ */ jsx16(X5, { size: 18 }) })
1380
+ ] }),
1381
+ /* @__PURE__ */ jsxs6("button", { type: "button", className: "frankie-new-chat", disabled: busy, onClick: () => {
1382
+ onNew();
1383
+ close();
1384
+ }, children: [
1385
+ /* @__PURE__ */ jsx16(Plus, { size: 17 }),
1386
+ "New chat"
1387
+ ] }),
1388
+ /* @__PURE__ */ jsxs6("label", { className: "frankie-history-search", children: [
1389
+ /* @__PURE__ */ jsx16("span", { children: "Search conversations" }),
1390
+ /* @__PURE__ */ jsx16("input", { type: "search", placeholder: "Search chats\u2026", value: query, onChange: (event) => setQuery(event.target.value) })
1391
+ ] }),
1392
+ /* @__PURE__ */ jsx16("nav", { className: "frankie-history-list", "aria-label": "Conversations", children: conversations.filter((item) => item.title.toLowerCase().includes(query.toLowerCase())).map((item) => /* @__PURE__ */ jsxs6("button", { type: "button", "aria-current": item.id === currentId ? "page" : void 0, disabled: busy, onClick: () => select(item.id), children: [
1393
+ /* @__PURE__ */ jsx16("span", { children: item.title }),
1394
+ /* @__PURE__ */ jsx16("small", { children: new Date(item.updatedAt).toLocaleDateString() })
1395
+ ] }, item.id)) }),
1396
+ historyLoading ? /* @__PURE__ */ jsx16("p", { role: "status", children: "Loading history\u2026" }) : historyError ? /* @__PURE__ */ jsxs6("p", { role: "status", children: [
1397
+ historyError,
1398
+ /* @__PURE__ */ jsx16("button", { type: "button", onClick: () => void refresh(), children: "Try again" })
1399
+ ] }) : !conversations.length ? /* @__PURE__ */ jsx16("p", { children: config?.allowAnonymous ? "Sign in to sync conversations across devices." : "Your conversations will appear here." }) : null,
1400
+ navigation ? /* @__PURE__ */ jsx16("div", { className: "frankie-host-navigation", children: navigation }) : null
1401
+ ] });
1402
+ return /* @__PURE__ */ jsxs6("div", { className: "frankie-page-shell" + (resolveMode(theme?.mode) === "dark" ? " dark" : ""), style: themeToCssVars(theme), children: [
1403
+ showHistory ? /* @__PURE__ */ jsx16("aside", { className: "frankie-history-desktop", children: history }) : null,
1404
+ /* @__PURE__ */ jsxs6("div", { className: "frankie-page-main", children: [
1405
+ /* @__PURE__ */ jsxs6("header", { className: "frankie-page-header", children: [
1406
+ /* @__PURE__ */ jsxs6("div", { children: [
1407
+ showHistory ? /* @__PURE__ */ jsx16("button", { type: "button", className: "frankie-mobile-only", ref: menu, "aria-label": "Open conversations", "aria-haspopup": "dialog", "aria-expanded": drawerOpen, onClick: () => setDrawerOpen(true), children: /* @__PURE__ */ jsx16(Menu, { size: 20 }) }) : null,
1408
+ /* @__PURE__ */ jsx16("strong", { children: name }),
1409
+ /* @__PURE__ */ jsx16("span", { className: "frankie-header-caption", children: "Product assistant" })
1410
+ ] }),
1411
+ /* @__PURE__ */ jsxs6("div", { children: [
1412
+ /* @__PURE__ */ jsxs6("button", { type: "button", disabled: busy, onClick: onNew, "aria-label": "Start a new conversation", children: [
1413
+ /* @__PURE__ */ jsx16(Plus, { size: 16 }),
1414
+ /* @__PURE__ */ jsx16("span", { children: "New chat" })
1415
+ ] }),
1416
+ /* @__PURE__ */ jsxs6("details", { className: "frankie-chat-settings", children: [
1417
+ /* @__PURE__ */ jsx16("summary", { "aria-label": "Chat settings", children: /* @__PURE__ */ jsx16(Settings, { size: 19 }) }),
1418
+ /* @__PURE__ */ jsxs6("div", { children: [
1419
+ /* @__PURE__ */ jsx16("strong", { children: "Chat settings" }),
1420
+ /* @__PURE__ */ jsx16("p", { children: "History syncs automatically for signed-in users." }),
1421
+ /* @__PURE__ */ jsxs6("button", { type: "button", onClick: () => void refresh(), disabled: historyLoading || !showHistory, children: [
1422
+ /* @__PURE__ */ jsx16(History, { size: 16 }),
1423
+ "Refresh history"
1424
+ ] }),
1425
+ /* @__PURE__ */ jsx16("small", { children: "Skills are checked at the start of each message." })
1426
+ ] })
1427
+ ] })
1428
+ ] })
1300
1429
  ] }),
1301
- /* @__PURE__ */ jsx16("button", { type: "button", onClick: reset, "aria-label": "Start a new conversation", children: "New chat" })
1430
+ /* @__PURE__ */ jsx16("div", { className: "frankie-page-canvas", children: /* @__PURE__ */ jsx16(FrankieInline, { ...panel, header: panel.header ?? false, emptyState: panel.emptyState ?? /* @__PURE__ */ jsx16(PageEmptyState, {}) }) })
1302
1431
  ] }),
1303
- /* @__PURE__ */ jsx16("div", { className: "frankie-page-canvas", children: /* @__PURE__ */ jsx16(
1304
- FrankieInline,
1305
- {
1306
- header: header ?? false,
1307
- footer,
1308
- emptyState: emptyState ?? /* @__PURE__ */ jsx16(PageEmptyState, {}),
1309
- renderMessage,
1310
- renderTool,
1311
- placeholder,
1312
- autoFocus
1313
- }
1314
- ) })
1432
+ showHistory ? /* @__PURE__ */ jsx16("dialog", { ref: dialog, className: "frankie-history-dialog frankie-root", "aria-label": "Chat conversations", style: themeToCssVars(theme), onCancel: close, onClick: (event) => {
1433
+ if (event.target === event.currentTarget) close();
1434
+ }, children: /* @__PURE__ */ jsx16("aside", { children: history }) }) : null
1315
1435
  ] });
1316
1436
  }
1317
- function FrankieChatPage({
1318
- className,
1319
- style,
1320
- header,
1321
- footer,
1322
- emptyState,
1323
- renderMessage,
1324
- renderTool,
1325
- placeholder,
1326
- autoFocus,
1327
- ...providerProps
1328
- }) {
1329
- return /* @__PURE__ */ jsx16(
1330
- "main",
1331
- {
1332
- className: ["frankie-root", "frankie-chat-page", className].filter(Boolean).join(" "),
1333
- style: { height: "100dvh", minHeight: "100dvh", width: "100%", ...style },
1334
- children: /* @__PURE__ */ jsx16(FrankieProvider, { ...providerProps, children: /* @__PURE__ */ jsx16(
1335
- PageChrome,
1336
- {
1337
- header,
1338
- footer,
1339
- emptyState,
1340
- renderMessage,
1341
- renderTool,
1342
- placeholder,
1343
- autoFocus
1344
- }
1345
- ) })
1346
- }
1347
- );
1437
+ function FrankieChatPage({ className, style, header, footer, emptyState, renderMessage, renderTool, placeholder, autoFocus, showHistory = true, navigation, ...provider }) {
1438
+ const [id, setId] = useState7(() => provider.conversationId ?? crypto.randomUUID());
1439
+ const onNew = () => setId(crypto.randomUUID());
1440
+ return /* @__PURE__ */ jsx16("div", { className: ["frankie-root", "frankie-chat-page", className].filter(Boolean).join(" "), style: { height: "100%", minHeight: "min(36rem, 100dvh)", width: "100%", ...style }, children: /* @__PURE__ */ jsx16(FrankieProvider, { ...provider, conversationId: id, storageNamespace: (provider.storageNamespace ?? "frankie." + provider.publishableKey) + "." + id, children: /* @__PURE__ */ jsx16(PageChrome, { showHistory, navigation, currentId: id, onSelect: setId, onNew, header, footer, emptyState, renderMessage, renderTool, placeholder, autoFocus }) }) });
1348
1441
  }
1349
1442
 
1350
1443
  // src/components/ui/separator.tsx