@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.cjs CHANGED
@@ -63,7 +63,7 @@ __export(index_exports, {
63
63
  module.exports = __toCommonJS(index_exports);
64
64
 
65
65
  // src/hooks/FrankieProvider.tsx
66
- var import_react = require("react");
66
+ var import_react2 = require("react");
67
67
 
68
68
  // src/lib/client.ts
69
69
  var FRANKIE_CLOUD_URL = "https://frankie-data-gq5o5syq2q-uc.a.run.app";
@@ -82,11 +82,18 @@ var FrankieClient = class {
82
82
  this.baseUrl = (options.baseUrl ?? FRANKIE_CLOUD_URL).replace(/\/+$/, "");
83
83
  this.doFetch = options.fetchImpl ?? globalThis.fetch.bind(globalThis);
84
84
  this.session = this.readStored();
85
+ if (options.getUserToken && typeof window !== "undefined") {
86
+ try {
87
+ for (const suffix of ["session", "messages"]) window.localStorage.removeItem(`${this.namespace}.${suffix}`);
88
+ } catch {
89
+ }
90
+ }
85
91
  }
86
92
  options;
87
93
  baseUrl;
88
94
  doFetch;
89
95
  session;
96
+ sessionUserToken;
90
97
  get sessionId() {
91
98
  return this.session?.sessionId;
92
99
  }
@@ -95,7 +102,7 @@ var FrankieClient = class {
95
102
  }
96
103
  /** Whether this client persists across reloads (browser only, opt-out via `persist:false`). */
97
104
  get persist() {
98
- return this.options.persist !== false && typeof window !== "undefined";
105
+ return !this.options.getUserToken && this.options.persist !== false && typeof window !== "undefined";
99
106
  }
100
107
  /** localStorage namespace for this tenant/conversation, shared with the chat hook. */
101
108
  get namespace() {
@@ -128,15 +135,17 @@ var FrankieClient = class {
128
135
  return await this.unwrap(response);
129
136
  }
130
137
  async ensureSession(context) {
131
- if (this.session && new Date(this.session.expiresAt).getTime() > Date.now() + 3e4) return this.session;
132
138
  const userToken = await this.options.getUserToken?.();
133
139
  if (this.options.getUserToken && !userToken) {
140
+ this.resetSession();
134
141
  throw new FrankieApiError(
135
142
  "Your signed-in Frankie session could not be verified. Refresh the page and sign in again.",
136
143
  401,
137
144
  "user_token_unavailable"
138
145
  );
139
146
  }
147
+ if (userToken !== this.sessionUserToken) this.resetSession();
148
+ if (this.session && new Date(this.session.expiresAt).getTime() > Date.now() + 3e4) return this.session;
140
149
  const response = await this.doFetch(`${this.baseUrl}/v1/session`, {
141
150
  method: "POST",
142
151
  headers: { "content-type": "application/json", "x-frankie-key": this.options.publishableKey },
@@ -147,6 +156,7 @@ var FrankieClient = class {
147
156
  })
148
157
  });
149
158
  this.session = await this.unwrap(response);
159
+ this.sessionUserToken = userToken;
150
160
  this.writeStored();
151
161
  return this.session;
152
162
  }
@@ -159,7 +169,7 @@ var FrankieClient = class {
159
169
  return Array.isArray(body.conversations) ? body.conversations : [];
160
170
  }
161
171
  async resumeConversation(id = this.options.conversationId) {
162
- if (!id) return void 0;
172
+ if (!id || !this.options.getUserToken) return void 0;
163
173
  const userToken = await this.requireUserToken();
164
174
  const response = await this.doFetch(`${this.baseUrl}/v1/conversations/${encodeURIComponent(id)}/resume`, {
165
175
  method: "POST",
@@ -168,6 +178,7 @@ var FrankieClient = class {
168
178
  if (response.status === 404) return void 0;
169
179
  const body = await this.unwrap(response);
170
180
  this.session = body.session;
181
+ this.sessionUserToken = userToken;
171
182
  this.writeStored();
172
183
  return { messages: Array.isArray(body.messages) ? body.messages : [], title: body.title ?? "New chat" };
173
184
  }
@@ -274,64 +285,8 @@ function parseFrame(frame) {
274
285
  }
275
286
  }
276
287
 
277
- // src/hooks/FrankieProvider.tsx
278
- var import_jsx_runtime = require("react/jsx-runtime");
279
- var FrankieContext = (0, import_react.createContext)(null);
280
- function FrankieProvider({
281
- children,
282
- context,
283
- theme,
284
- config: providedConfig,
285
- ...clientOptions
286
- }) {
287
- const client = (0, import_react.useMemo)(
288
- () => new FrankieClient(clientOptions),
289
- // eslint-disable-next-line react-hooks/exhaustive-deps
290
- [clientOptions.baseUrl, clientOptions.publishableKey, clientOptions.storageNamespace, clientOptions.conversationId]
291
- );
292
- const [config, setConfig] = (0, import_react.useState)(providedConfig);
293
- const [configError, setConfigError] = (0, import_react.useState)(void 0);
294
- const [loading, setLoading] = (0, import_react.useState)(!providedConfig);
295
- (0, import_react.useEffect)(() => {
296
- if (providedConfig) return;
297
- const abort = new AbortController();
298
- setLoading(true);
299
- client.getConfig(abort.signal).then((next) => {
300
- setConfig(next);
301
- setConfigError(void 0);
302
- }).catch((error) => {
303
- if (abort.signal.aborted) return;
304
- setConfigError(error instanceof Error ? error.message : "Could not load the assistant.");
305
- }).finally(() => {
306
- if (!abort.signal.aborted) setLoading(false);
307
- });
308
- return () => abort.abort();
309
- }, [client, providedConfig]);
310
- const value = (0, import_react.useMemo)(
311
- () => ({
312
- client,
313
- ...config ? { config } : {},
314
- ...configError ? { configError } : {},
315
- loading,
316
- ...context ? { context } : {},
317
- ...theme ? { theme } : {}
318
- }),
319
- [client, config, configError, loading, context, theme]
320
- );
321
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FrankieContext.Provider, { value, children });
322
- }
323
- function useFrankie() {
324
- const value = (0, import_react.useContext)(FrankieContext);
325
- if (!value) throw new Error("Frankie components must be rendered inside <FrankieProvider>.");
326
- return value;
327
- }
328
- function useFrankieTheme() {
329
- const { config, theme } = useFrankie();
330
- return (0, import_react.useMemo)(() => ({ ...config?.theme, ...theme }), [config?.theme, theme]);
331
- }
332
-
333
288
  // src/hooks/useFrankieChat.ts
334
- var import_react2 = require("react");
289
+ var import_react = require("react");
335
290
 
336
291
  // src/lib/messages.ts
337
292
  function applyEvent(message, event) {
@@ -408,16 +363,23 @@ function newUserMessage(id, text, attachments) {
408
363
  // src/hooks/useFrankieChat.ts
409
364
  var counter = 0;
410
365
  var nextId = (prefix) => `${prefix}_${Date.now().toString(36)}_${counter += 1}`;
366
+ var FrankieChatContext = (0, import_react.createContext)(null);
411
367
  function useFrankieChat() {
368
+ const value = (0, import_react.useContext)(FrankieChatContext);
369
+ if (!value) throw new Error("useFrankieChat requires FrankieProvider.");
370
+ return value;
371
+ }
372
+ function useFrankieChatState() {
412
373
  const { client, context } = useFrankie();
413
- const [messages, setMessages] = (0, import_react2.useState)([]);
414
- const [busy, setBusy] = (0, import_react2.useState)(false);
415
- const [error, setError] = (0, import_react2.useState)(void 0);
416
- const [restored, setRestored] = (0, import_react2.useState)(false);
417
- const abortRef = (0, import_react2.useRef)(null);
418
- const mounted = (0, import_react2.useRef)(true);
419
- const messagesRef = (0, import_react2.useRef)([]);
420
- (0, import_react2.useEffect)(() => {
374
+ const [messages, setMessages] = (0, import_react.useState)([]);
375
+ const [busy, setBusy] = (0, import_react.useState)(false);
376
+ const [error, setError] = (0, import_react.useState)(void 0);
377
+ const [restored, setRestored] = (0, import_react.useState)(false);
378
+ const [loadedSkills, setLoadedSkills] = (0, import_react.useState)(null);
379
+ const abortRef = (0, import_react.useRef)(null);
380
+ const mounted = (0, import_react.useRef)(true);
381
+ const messagesRef = (0, import_react.useRef)([]);
382
+ (0, import_react.useEffect)(() => {
421
383
  mounted.current = true;
422
384
  return () => {
423
385
  mounted.current = false;
@@ -425,7 +387,7 @@ function useFrankieChat() {
425
387
  };
426
388
  }, []);
427
389
  const storageKey = `${client.namespace}.messages`;
428
- (0, import_react2.useEffect)(() => {
390
+ (0, import_react.useEffect)(() => {
429
391
  let active = true;
430
392
  const restore = async () => {
431
393
  if (client.persist) {
@@ -447,6 +409,7 @@ function useFrankieChat() {
447
409
  setMessages(restoredMessages);
448
410
  }
449
411
  } catch {
412
+ if (active) setError("Could not restore this conversation. Check your sign-in and connection, then try opening it again.");
450
413
  } finally {
451
414
  if (active) setRestored(true);
452
415
  }
@@ -456,7 +419,7 @@ function useFrankieChat() {
456
419
  active = false;
457
420
  };
458
421
  }, [client, storageKey]);
459
- (0, import_react2.useEffect)(() => {
422
+ (0, import_react.useEffect)(() => {
460
423
  if (!client.persist || !restored || busy) return;
461
424
  try {
462
425
  if (messages.length) window.localStorage.setItem(storageKey, JSON.stringify(messages));
@@ -465,21 +428,22 @@ function useFrankieChat() {
465
428
  }
466
429
  if (messages.length) void client.syncConversation(messages, conversationTitle(messages)).catch(() => void 0);
467
430
  }, [messages, busy, client, storageKey, restored]);
468
- const patchAssistant = (0, import_react2.useCallback)((id, update) => {
431
+ const patchAssistant = (0, import_react.useCallback)((id, update) => {
469
432
  const next = messagesRef.current.map(
470
433
  (message) => message.id === id && message.role === "assistant" ? update(message) : message
471
434
  );
472
435
  messagesRef.current = next;
473
436
  setMessages(next);
474
437
  }, []);
475
- const send = (0, import_react2.useCallback)(
438
+ const send = (0, import_react.useCallback)(
476
439
  async (text, opts) => {
477
440
  const trimmed = text.trim();
478
441
  const attachments = opts?.attachments ?? [];
479
- if (!trimmed && attachments.length === 0 || busy) return;
442
+ if (!trimmed && attachments.length === 0 || abortRef.current || !restored) return;
480
443
  const outgoing = trimmed || "Please take a look at the attached file(s).";
481
444
  const assistantId = nextId("a");
482
445
  setError(void 0);
446
+ setLoadedSkills(null);
483
447
  setBusy(true);
484
448
  const next = [
485
449
  ...messagesRef.current,
@@ -506,6 +470,10 @@ function useFrankieChat() {
506
470
  signal: abort.signal
507
471
  },
508
472
  (event) => {
473
+ if (event.type === "skills_loaded") {
474
+ setLoadedSkills(event.skills);
475
+ return;
476
+ }
509
477
  if (event.type === "ping" || event.type === "session") return;
510
478
  if (event.type === "text_delta") receivedText = true;
511
479
  if (event.type === "tool_start" || event.type === "tool_end") receivedToolActivity = true;
@@ -544,10 +512,10 @@ function useFrankieChat() {
544
512
  abortRef.current = null;
545
513
  }
546
514
  },
547
- [busy, client, context, patchAssistant]
515
+ [busy, client, context, patchAssistant, restored]
548
516
  );
549
- const stop = (0, import_react2.useCallback)(() => abortRef.current?.abort(), []);
550
- const reset = (0, import_react2.useCallback)(() => {
517
+ const stop = (0, import_react.useCallback)(() => abortRef.current?.abort(), []);
518
+ const reset = (0, import_react.useCallback)(() => {
551
519
  abortRef.current?.abort();
552
520
  client.resetSession();
553
521
  messagesRef.current = [];
@@ -555,12 +523,13 @@ function useFrankieChat() {
555
523
  setError(void 0);
556
524
  setBusy(false);
557
525
  setRestored(true);
526
+ setLoadedSkills(null);
558
527
  try {
559
528
  if (client.persist) window.localStorage.removeItem(`${client.namespace}.messages`);
560
529
  } catch {
561
530
  }
562
531
  }, [client]);
563
- return { messages, busy, ...error ? { error } : {}, send, stop, reset };
532
+ return { messages, busy, loadedSkills, restoring: !restored, ...error ? { error } : {}, send, stop, reset };
564
533
  }
565
534
  function conversationTitle(messages) {
566
535
  const first = messages.find((message) => message.role === "user" && message.text.trim());
@@ -569,6 +538,68 @@ function conversationTitle(messages) {
569
538
  return title.length > 42 ? `${title.slice(0, 42)}\u2026` : title;
570
539
  }
571
540
 
541
+ // src/hooks/FrankieProvider.tsx
542
+ var import_jsx_runtime = require("react/jsx-runtime");
543
+ var FrankieContext = (0, import_react2.createContext)(null);
544
+ function FrankieProvider({
545
+ children,
546
+ context,
547
+ theme,
548
+ config: providedConfig,
549
+ ...clientOptions
550
+ }) {
551
+ const tokenCallback = (0, import_react2.useRef)(clientOptions.getUserToken);
552
+ tokenCallback.current = clientOptions.getUserToken;
553
+ const client = (0, import_react2.useMemo)(
554
+ () => new FrankieClient({ ...clientOptions, ...clientOptions.getUserToken ? { getUserToken: () => tokenCallback.current?.() } : {} }),
555
+ // eslint-disable-next-line react-hooks/exhaustive-deps
556
+ [clientOptions.baseUrl, clientOptions.publishableKey, clientOptions.storageNamespace, clientOptions.conversationId, clientOptions.persist, clientOptions.fetchImpl, Boolean(clientOptions.getUserToken)]
557
+ );
558
+ const [config, setConfig] = (0, import_react2.useState)(providedConfig);
559
+ const [configError, setConfigError] = (0, import_react2.useState)(void 0);
560
+ const [loading, setLoading] = (0, import_react2.useState)(!providedConfig);
561
+ (0, import_react2.useEffect)(() => {
562
+ if (providedConfig) return;
563
+ const abort = new AbortController();
564
+ setLoading(true);
565
+ client.getConfig(abort.signal).then((next) => {
566
+ setConfig(next);
567
+ setConfigError(void 0);
568
+ }).catch((error) => {
569
+ if (abort.signal.aborted) return;
570
+ setConfigError(error instanceof Error ? error.message : "Could not load the assistant.");
571
+ }).finally(() => {
572
+ if (!abort.signal.aborted) setLoading(false);
573
+ });
574
+ return () => abort.abort();
575
+ }, [client, providedConfig]);
576
+ const value = (0, import_react2.useMemo)(
577
+ () => ({
578
+ client,
579
+ ...config ? { config } : {},
580
+ ...configError ? { configError } : {},
581
+ loading,
582
+ ...context ? { context } : {},
583
+ ...theme ? { theme } : {}
584
+ }),
585
+ [client, config, configError, loading, context, theme]
586
+ );
587
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FrankieContext.Provider, { value, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ConversationState, { children }, client.namespace + ":" + (client.conversationId ?? "")) });
588
+ }
589
+ function ConversationState({ children }) {
590
+ const chat = useFrankieChatState();
591
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FrankieChatContext.Provider, { value: chat, children });
592
+ }
593
+ function useFrankie() {
594
+ const value = (0, import_react2.useContext)(FrankieContext);
595
+ if (!value) throw new Error("Frankie components must be rendered inside <FrankieProvider>.");
596
+ return value;
597
+ }
598
+ function useFrankieTheme() {
599
+ const { config, theme } = useFrankie();
600
+ return (0, import_react2.useMemo)(() => ({ ...config?.theme, ...theme }), [config?.theme, theme]);
601
+ }
602
+
572
603
  // src/components/chat/FrankieWidget.tsx
573
604
  var import_lucide_react5 = require("lucide-react");
574
605
  var import_react9 = require("react");
@@ -1171,7 +1202,7 @@ function ChatPanel({
1171
1202
  }) {
1172
1203
  const { config, configError, loading } = useFrankie();
1173
1204
  const theme = useFrankieTheme();
1174
- const { messages, busy, error, send, stop, reset } = useFrankieChat();
1205
+ const { messages, busy, restoring, loadedSkills, error, send, stop, reset } = useFrankieChat();
1175
1206
  const scrollRef = (0, import_react8.useRef)(null);
1176
1207
  const features = config?.features;
1177
1208
  const pickerModels = features?.modelPicker.enabled ? features.modelPicker.models : [];
@@ -1226,6 +1257,16 @@ function ChatPanel({
1226
1257
  )
1227
1258
  ) }) }) }),
1228
1259
  /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)(CardFooter, { className: "flex flex-col gap-2", children: [
1260
+ loadedSkills !== null ? /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("details", { className: "frankie-loaded-skills", children: [
1261
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("summary", { role: "status", children: loadedSkills.length ? `${loadedSkills.length} ${loadedSkills.length === 1 ? "skill" : "skills"} loaded` : "No skills loaded for this turn" }),
1262
+ loadedSkills.length ? /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("ul", { children: loadedSkills.map((skill) => /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("li", { children: [
1263
+ skill.name,
1264
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("small", { children: [
1265
+ "v",
1266
+ skill.revision
1267
+ ] })
1268
+ ] }, skill.id)) }) : /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("p", { children: "Your assistant can still answer using its available tools." })
1269
+ ] }) : null,
1229
1270
  error ? /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("p", { className: "w-full text-xs text-red-600 dark:text-red-400", children: error }) : null,
1230
1271
  /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("div", { className: "w-full", children: /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
1231
1272
  Composer,
@@ -1233,7 +1274,7 @@ function ChatPanel({
1233
1274
  onSend: send,
1234
1275
  onStop: stop,
1235
1276
  busy,
1236
- disabled: Boolean(configError),
1277
+ disabled: loading || restoring || Boolean(configError),
1237
1278
  ...placeholder ? { placeholder } : {},
1238
1279
  ...autoFocus ? { autoFocus } : {},
1239
1280
  ...features?.attachments.enabled ? { attachments: features.attachments } : {},
@@ -1253,6 +1294,7 @@ function FrankieWidget({
1253
1294
  position = "bottom-right",
1254
1295
  offset = 24,
1255
1296
  defaultOpen = false,
1297
+ zIndex = 20,
1256
1298
  open: controlledOpen,
1257
1299
  onOpenChange,
1258
1300
  renderLauncher,
@@ -1281,11 +1323,11 @@ function FrankieWidget({
1281
1323
  "div",
1282
1324
  {
1283
1325
  className: cn("frankie-root frankie-widget", mode === "dark" && "dark"),
1284
- style: { ...themeToCssVars(theme), position: "fixed", bottom: offset, ...side, zIndex: 2147483e3 },
1326
+ style: { ...themeToCssVars(theme), position: "fixed", bottom: offset, ...side, zIndex, pointerEvents: "none" },
1285
1327
  "data-frankie-mode": mode,
1286
1328
  children: /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("div", { className: cn("flex flex-col gap-3", position === "bottom-left" ? "items-start" : "items-end"), children: [
1287
- open ? /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(ChatPanel, { ...panelProps, onClose: () => setOpen(false), autoFocus: true }) : null,
1288
- renderLauncher ? renderLauncher({ open, toggle: () => setOpen(!open), label }) : /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
1329
+ open ? /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { style: { pointerEvents: "auto", maxHeight: "calc(100dvh - 100px)", overflow: "hidden" }, children: /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(ChatPanel, { ...panelProps, className: cn("frankie-widget-panel", panelProps.className), onClose: () => setOpen(false), autoFocus: true }) }) : null,
1330
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { style: { pointerEvents: "auto" }, children: renderLauncher ? renderLauncher({ open, toggle: () => setOpen(!open), label }) : /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
1289
1331
  Button,
1290
1332
  {
1291
1333
  size: "icon",
@@ -1296,7 +1338,7 @@ function FrankieWidget({
1296
1338
  title: label,
1297
1339
  children: open ? /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_lucide_react5.X, { className: "size-5" }) : /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_lucide_react5.MessageCircle, { className: "size-5" })
1298
1340
  }
1299
- )
1341
+ ) })
1300
1342
  ] })
1301
1343
  }
1302
1344
  );
@@ -1319,94 +1361,145 @@ function FrankieInline({ className, style, ...panelProps }) {
1319
1361
  }
1320
1362
 
1321
1363
  // src/components/chat/FrankieChatPage.tsx
1364
+ var import_react10 = require("react");
1365
+ var import_lucide_react6 = require("lucide-react");
1322
1366
  var import_jsx_runtime16 = require("react/jsx-runtime");
1323
1367
  function PageEmptyState() {
1324
1368
  const { config } = useFrankie();
1325
- const { send, busy } = useFrankieChat();
1369
+ const { send, busy, restoring } = useFrankieChat();
1326
1370
  const name = config?.branding.name ?? "Frankie";
1327
- const prompts = config?.suggestedPrompts?.slice(0, 3) ?? [];
1328
1371
  return /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("section", { className: "frankie-page-empty", "aria-label": "Start a conversation", children: [
1329
1372
  /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { className: "frankie-page-avatar", "aria-hidden": true, children: name.trim().slice(0, 1).toUpperCase() }),
1330
1373
  /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { children: [
1331
- /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("p", { className: "frankie-page-kicker", children: [
1332
- name,
1333
- " assistant"
1334
- ] }),
1335
- /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("h1", { children: "What would you like to do?" }),
1374
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("p", { className: "frankie-page-kicker", children: "Your product assistant" }),
1375
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("h1", { children: "How can I help?" }),
1336
1376
  /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("p", { children: config?.greeting ?? "Ask a question or choose a task to get started." })
1337
1377
  ] }),
1338
- prompts.length ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { className: "frankie-page-prompts", children: prompts.map((prompt, index) => /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("button", { type: "button", disabled: busy, onClick: () => send(prompt), children: [
1339
- /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { children: String(index + 1).padStart(2, "0") }),
1378
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { className: "frankie-page-prompts", children: config?.suggestedPrompts?.slice(0, 3).map((prompt) => /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("button", { type: "button", disabled: busy || restoring, onClick: () => void send(prompt), children: [
1340
1379
  /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("strong", { children: prompt }),
1341
1380
  /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("b", { "aria-hidden": true, children: "\u2197" })
1342
- ] }, prompt)) }) : null
1381
+ ] }, prompt)) })
1343
1382
  ] });
1344
1383
  }
1345
- function PageChrome({
1346
- header,
1347
- footer,
1348
- emptyState,
1349
- renderMessage,
1350
- renderTool,
1351
- placeholder,
1352
- autoFocus
1353
- }) {
1354
- const { config } = useFrankie();
1355
- const { reset } = useFrankieChat();
1384
+ function PageChrome({ showHistory, navigation, currentId, onSelect, onNew, ...panel }) {
1385
+ const { client, config } = useFrankie();
1386
+ const { busy } = useFrankieChat();
1387
+ const theme = useFrankieTheme();
1388
+ const [conversations, setConversations] = (0, import_react10.useState)([]);
1389
+ const [historyError, setHistoryError] = (0, import_react10.useState)("");
1390
+ const [historyLoading, setHistoryLoading] = (0, import_react10.useState)(false);
1391
+ const [drawerOpen, setDrawerOpen] = (0, import_react10.useState)(false);
1392
+ const [query, setQuery] = (0, import_react10.useState)("");
1393
+ const dialog = (0, import_react10.useRef)(null);
1394
+ const menu = (0, import_react10.useRef)(null);
1395
+ const requestVersion = (0, import_react10.useRef)(0);
1356
1396
  const name = config?.branding.name ?? "Frankie";
1357
- return /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(import_jsx_runtime16.Fragment, { children: [
1358
- /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("header", { className: "frankie-page-header", children: [
1359
- /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { children: [
1360
- /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { className: "frankie-page-status", "aria-hidden": true }),
1361
- /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("strong", { children: name })
1397
+ const refresh = (0, import_react10.useCallback)(async () => {
1398
+ if (!showHistory || !config || config.allowAnonymous) {
1399
+ setHistoryError("");
1400
+ return;
1401
+ }
1402
+ const version = ++requestVersion.current;
1403
+ setHistoryLoading(true);
1404
+ setHistoryError("");
1405
+ try {
1406
+ const items = await client.listConversations();
1407
+ if (version === requestVersion.current) setConversations(items);
1408
+ } catch {
1409
+ if (version === requestVersion.current) setHistoryError("History couldn\u2019t load. Check your sign-in and try again.");
1410
+ } finally {
1411
+ if (version === requestVersion.current) setHistoryLoading(false);
1412
+ }
1413
+ }, [client, showHistory, config?.allowAnonymous]);
1414
+ (0, import_react10.useEffect)(() => {
1415
+ if (!busy) void refresh();
1416
+ return () => {
1417
+ requestVersion.current++;
1418
+ };
1419
+ }, [refresh, busy]);
1420
+ (0, import_react10.useEffect)(() => {
1421
+ const node = dialog.current;
1422
+ if (drawerOpen) {
1423
+ node?.showModal?.();
1424
+ } else {
1425
+ node?.close?.();
1426
+ }
1427
+ }, [drawerOpen]);
1428
+ const close = () => {
1429
+ setDrawerOpen(false);
1430
+ menu.current?.focus();
1431
+ };
1432
+ const select = (id) => {
1433
+ if (!busy) {
1434
+ onSelect(id);
1435
+ close();
1436
+ }
1437
+ };
1438
+ const history = /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(import_jsx_runtime16.Fragment, { children: [
1439
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { className: "frankie-history-heading", children: [
1440
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("strong", { children: "Conversations" }),
1441
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("button", { type: "button", className: "frankie-mobile-only", "aria-label": "Close conversations", onClick: close, children: /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(import_lucide_react6.X, { size: 18 }) })
1442
+ ] }),
1443
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("button", { type: "button", className: "frankie-new-chat", disabled: busy, onClick: () => {
1444
+ onNew();
1445
+ close();
1446
+ }, children: [
1447
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(import_lucide_react6.Plus, { size: 17 }),
1448
+ "New chat"
1449
+ ] }),
1450
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("label", { className: "frankie-history-search", children: [
1451
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { children: "Search conversations" }),
1452
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("input", { type: "search", placeholder: "Search chats\u2026", value: query, onChange: (event) => setQuery(event.target.value) })
1453
+ ] }),
1454
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("nav", { className: "frankie-history-list", "aria-label": "Conversations", children: conversations.filter((item) => item.title.toLowerCase().includes(query.toLowerCase())).map((item) => /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("button", { type: "button", "aria-current": item.id === currentId ? "page" : void 0, disabled: busy, onClick: () => select(item.id), children: [
1455
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { children: item.title }),
1456
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("small", { children: new Date(item.updatedAt).toLocaleDateString() })
1457
+ ] }, item.id)) }),
1458
+ historyLoading ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("p", { role: "status", children: "Loading history\u2026" }) : historyError ? /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("p", { role: "status", children: [
1459
+ historyError,
1460
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("button", { type: "button", onClick: () => void refresh(), children: "Try again" })
1461
+ ] }) : !conversations.length ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("p", { children: config?.allowAnonymous ? "Sign in to sync conversations across devices." : "Your conversations will appear here." }) : null,
1462
+ navigation ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { className: "frankie-host-navigation", children: navigation }) : null
1463
+ ] });
1464
+ return /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { className: "frankie-page-shell" + (resolveMode(theme?.mode) === "dark" ? " dark" : ""), style: themeToCssVars(theme), children: [
1465
+ showHistory ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("aside", { className: "frankie-history-desktop", children: history }) : null,
1466
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { className: "frankie-page-main", children: [
1467
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("header", { className: "frankie-page-header", children: [
1468
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { children: [
1469
+ showHistory ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("button", { type: "button", className: "frankie-mobile-only", ref: menu, "aria-label": "Open conversations", "aria-haspopup": "dialog", "aria-expanded": drawerOpen, onClick: () => setDrawerOpen(true), children: /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(import_lucide_react6.Menu, { size: 20 }) }) : null,
1470
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("strong", { children: name }),
1471
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { className: "frankie-header-caption", children: "Product assistant" })
1472
+ ] }),
1473
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { children: [
1474
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("button", { type: "button", disabled: busy, onClick: onNew, "aria-label": "Start a new conversation", children: [
1475
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(import_lucide_react6.Plus, { size: 16 }),
1476
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { children: "New chat" })
1477
+ ] }),
1478
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("details", { className: "frankie-chat-settings", children: [
1479
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("summary", { "aria-label": "Chat settings", children: /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(import_lucide_react6.Settings, { size: 19 }) }),
1480
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { children: [
1481
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("strong", { children: "Chat settings" }),
1482
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("p", { children: "History syncs automatically for signed-in users." }),
1483
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("button", { type: "button", onClick: () => void refresh(), disabled: historyLoading || !showHistory, children: [
1484
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(import_lucide_react6.History, { size: 16 }),
1485
+ "Refresh history"
1486
+ ] }),
1487
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("small", { children: "Skills are checked at the start of each message." })
1488
+ ] })
1489
+ ] })
1490
+ ] })
1362
1491
  ] }),
1363
- /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("button", { type: "button", onClick: reset, "aria-label": "Start a new conversation", children: "New chat" })
1492
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { className: "frankie-page-canvas", children: /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(FrankieInline, { ...panel, header: panel.header ?? false, emptyState: panel.emptyState ?? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(PageEmptyState, {}) }) })
1364
1493
  ] }),
1365
- /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { className: "frankie-page-canvas", children: /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
1366
- FrankieInline,
1367
- {
1368
- header: header ?? false,
1369
- footer,
1370
- emptyState: emptyState ?? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(PageEmptyState, {}),
1371
- renderMessage,
1372
- renderTool,
1373
- placeholder,
1374
- autoFocus
1375
- }
1376
- ) })
1494
+ showHistory ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("dialog", { ref: dialog, className: "frankie-history-dialog frankie-root", "aria-label": "Chat conversations", style: themeToCssVars(theme), onCancel: close, onClick: (event) => {
1495
+ if (event.target === event.currentTarget) close();
1496
+ }, children: /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("aside", { children: history }) }) : null
1377
1497
  ] });
1378
1498
  }
1379
- function FrankieChatPage({
1380
- className,
1381
- style,
1382
- header,
1383
- footer,
1384
- emptyState,
1385
- renderMessage,
1386
- renderTool,
1387
- placeholder,
1388
- autoFocus,
1389
- ...providerProps
1390
- }) {
1391
- return /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
1392
- "main",
1393
- {
1394
- className: ["frankie-root", "frankie-chat-page", className].filter(Boolean).join(" "),
1395
- style: { height: "100dvh", minHeight: "100dvh", width: "100%", ...style },
1396
- children: /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(FrankieProvider, { ...providerProps, children: /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
1397
- PageChrome,
1398
- {
1399
- header,
1400
- footer,
1401
- emptyState,
1402
- renderMessage,
1403
- renderTool,
1404
- placeholder,
1405
- autoFocus
1406
- }
1407
- ) })
1408
- }
1409
- );
1499
+ function FrankieChatPage({ className, style, header, footer, emptyState, renderMessage, renderTool, placeholder, autoFocus, showHistory = true, navigation, ...provider }) {
1500
+ const [id, setId] = (0, import_react10.useState)(() => provider.conversationId ?? crypto.randomUUID());
1501
+ const onNew = () => setId(crypto.randomUUID());
1502
+ return /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { className: ["frankie-root", "frankie-chat-page", className].filter(Boolean).join(" "), style: { height: "100%", minHeight: "min(36rem, 100dvh)", width: "100%", ...style }, children: /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(FrankieProvider, { ...provider, conversationId: id, storageNamespace: (provider.storageNamespace ?? "frankie." + provider.publishableKey) + "." + id, children: /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(PageChrome, { showHistory, navigation, currentId: id, onSelect: setId, onNew, header, footer, emptyState, renderMessage, renderTool, placeholder, autoFocus }) }) });
1410
1503
  }
1411
1504
 
1412
1505
  // src/components/ui/separator.tsx