@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.
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,15 +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
- (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)(() => {
420
383
  mounted.current = true;
421
384
  return () => {
422
385
  mounted.current = false;
@@ -424,21 +387,29 @@ function useFrankieChat() {
424
387
  };
425
388
  }, []);
426
389
  const storageKey = `${client.namespace}.messages`;
427
- (0, import_react2.useEffect)(() => {
390
+ (0, import_react.useEffect)(() => {
428
391
  let active = true;
429
392
  const restore = async () => {
430
393
  if (client.persist) {
431
394
  try {
432
395
  const raw = window.localStorage.getItem(storageKey);
433
396
  const stored = raw ? JSON.parse(raw) : [];
434
- if (Array.isArray(stored) && stored.length && active) setMessages(stored);
397
+ if (Array.isArray(stored) && stored.length && active) {
398
+ messagesRef.current = stored;
399
+ setMessages(stored);
400
+ }
435
401
  } catch {
436
402
  }
437
403
  }
438
404
  try {
439
405
  const remote = await client.resumeConversation();
440
- if (active && remote?.messages.length) setMessages(remote.messages);
406
+ if (active && remote?.messages.length) {
407
+ const restoredMessages = remote.messages;
408
+ messagesRef.current = restoredMessages;
409
+ setMessages(restoredMessages);
410
+ }
441
411
  } catch {
412
+ if (active) setError("Could not restore this conversation. Check your sign-in and connection, then try opening it again.");
442
413
  } finally {
443
414
  if (active) setRestored(true);
444
415
  }
@@ -448,7 +419,7 @@ function useFrankieChat() {
448
419
  active = false;
449
420
  };
450
421
  }, [client, storageKey]);
451
- (0, import_react2.useEffect)(() => {
422
+ (0, import_react.useEffect)(() => {
452
423
  if (!client.persist || !restored || busy) return;
453
424
  try {
454
425
  if (messages.length) window.localStorage.setItem(storageKey, JSON.stringify(messages));
@@ -457,31 +428,34 @@ function useFrankieChat() {
457
428
  }
458
429
  if (messages.length) void client.syncConversation(messages, conversationTitle(messages)).catch(() => void 0);
459
430
  }, [messages, busy, client, storageKey, restored]);
460
- const patchAssistant = (0, import_react2.useCallback)((id, update) => {
461
- setMessages(
462
- (current) => current.map(
463
- (message) => message.id === id && message.role === "assistant" ? update(message) : message
464
- )
431
+ const patchAssistant = (0, import_react.useCallback)((id, update) => {
432
+ const next = messagesRef.current.map(
433
+ (message) => message.id === id && message.role === "assistant" ? update(message) : message
465
434
  );
435
+ messagesRef.current = next;
436
+ setMessages(next);
466
437
  }, []);
467
- const send = (0, import_react2.useCallback)(
438
+ const send = (0, import_react.useCallback)(
468
439
  async (text, opts) => {
469
440
  const trimmed = text.trim();
470
441
  const attachments = opts?.attachments ?? [];
471
- if (!trimmed && attachments.length === 0 || busy) return;
442
+ if (!trimmed && attachments.length === 0 || abortRef.current || !restored) return;
472
443
  const outgoing = trimmed || "Please take a look at the attached file(s).";
473
444
  const assistantId = nextId("a");
474
445
  setError(void 0);
446
+ setLoadedSkills(null);
475
447
  setBusy(true);
476
- setMessages((current) => [
477
- ...current,
448
+ const next = [
449
+ ...messagesRef.current,
478
450
  newUserMessage(
479
451
  nextId("u"),
480
452
  trimmed,
481
453
  attachments.map((file) => ({ name: file.name, mimeType: file.mimeType }))
482
454
  ),
483
455
  newAssistantMessage(assistantId)
484
- ]);
456
+ ];
457
+ messagesRef.current = next;
458
+ setMessages(next);
485
459
  const abort = new AbortController();
486
460
  abortRef.current = abort;
487
461
  try {
@@ -496,6 +470,10 @@ function useFrankieChat() {
496
470
  signal: abort.signal
497
471
  },
498
472
  (event) => {
473
+ if (event.type === "skills_loaded") {
474
+ setLoadedSkills(event.skills);
475
+ return;
476
+ }
499
477
  if (event.type === "ping" || event.type === "session") return;
500
478
  if (event.type === "text_delta") receivedText = true;
501
479
  if (event.type === "tool_start" || event.type === "tool_end") receivedToolActivity = true;
@@ -522,32 +500,36 @@ function useFrankieChat() {
522
500
  patchAssistant(assistantId, (current) => ({ ...current, status: "error", error: message }));
523
501
  }
524
502
  } finally {
525
- if (mounted.current) {
526
- setBusy(false);
527
- patchAssistant(
528
- assistantId,
529
- (message) => message.status === "streaming" ? { ...message, status: "done" } : message
530
- );
503
+ patchAssistant(
504
+ assistantId,
505
+ (message) => message.status === "streaming" ? { ...message, status: "done" } : message
506
+ );
507
+ const transcript = messagesRef.current;
508
+ if (transcript.length) {
509
+ await client.syncConversation(transcript, conversationTitle(transcript)).catch(() => void 0);
531
510
  }
511
+ if (mounted.current) setBusy(false);
532
512
  abortRef.current = null;
533
513
  }
534
514
  },
535
- [busy, client, context, patchAssistant]
515
+ [busy, client, context, patchAssistant, restored]
536
516
  );
537
- const stop = (0, import_react2.useCallback)(() => abortRef.current?.abort(), []);
538
- const reset = (0, import_react2.useCallback)(() => {
517
+ const stop = (0, import_react.useCallback)(() => abortRef.current?.abort(), []);
518
+ const reset = (0, import_react.useCallback)(() => {
539
519
  abortRef.current?.abort();
540
520
  client.resetSession();
521
+ messagesRef.current = [];
541
522
  setMessages([]);
542
523
  setError(void 0);
543
524
  setBusy(false);
544
525
  setRestored(true);
526
+ setLoadedSkills(null);
545
527
  try {
546
528
  if (client.persist) window.localStorage.removeItem(`${client.namespace}.messages`);
547
529
  } catch {
548
530
  }
549
531
  }, [client]);
550
- return { messages, busy, ...error ? { error } : {}, send, stop, reset };
532
+ return { messages, busy, loadedSkills, restoring: !restored, ...error ? { error } : {}, send, stop, reset };
551
533
  }
552
534
  function conversationTitle(messages) {
553
535
  const first = messages.find((message) => message.role === "user" && message.text.trim());
@@ -556,6 +538,68 @@ function conversationTitle(messages) {
556
538
  return title.length > 42 ? `${title.slice(0, 42)}\u2026` : title;
557
539
  }
558
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
+
559
603
  // src/components/chat/FrankieWidget.tsx
560
604
  var import_lucide_react5 = require("lucide-react");
561
605
  var import_react9 = require("react");
@@ -1158,7 +1202,7 @@ function ChatPanel({
1158
1202
  }) {
1159
1203
  const { config, configError, loading } = useFrankie();
1160
1204
  const theme = useFrankieTheme();
1161
- const { messages, busy, error, send, stop, reset } = useFrankieChat();
1205
+ const { messages, busy, restoring, loadedSkills, error, send, stop, reset } = useFrankieChat();
1162
1206
  const scrollRef = (0, import_react8.useRef)(null);
1163
1207
  const features = config?.features;
1164
1208
  const pickerModels = features?.modelPicker.enabled ? features.modelPicker.models : [];
@@ -1213,6 +1257,16 @@ function ChatPanel({
1213
1257
  )
1214
1258
  ) }) }) }),
1215
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,
1216
1270
  error ? /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("p", { className: "w-full text-xs text-red-600 dark:text-red-400", children: error }) : null,
1217
1271
  /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("div", { className: "w-full", children: /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
1218
1272
  Composer,
@@ -1220,7 +1274,7 @@ function ChatPanel({
1220
1274
  onSend: send,
1221
1275
  onStop: stop,
1222
1276
  busy,
1223
- disabled: Boolean(configError),
1277
+ disabled: loading || restoring || Boolean(configError),
1224
1278
  ...placeholder ? { placeholder } : {},
1225
1279
  ...autoFocus ? { autoFocus } : {},
1226
1280
  ...features?.attachments.enabled ? { attachments: features.attachments } : {},
@@ -1240,6 +1294,7 @@ function FrankieWidget({
1240
1294
  position = "bottom-right",
1241
1295
  offset = 24,
1242
1296
  defaultOpen = false,
1297
+ zIndex = 20,
1243
1298
  open: controlledOpen,
1244
1299
  onOpenChange,
1245
1300
  renderLauncher,
@@ -1268,11 +1323,11 @@ function FrankieWidget({
1268
1323
  "div",
1269
1324
  {
1270
1325
  className: cn("frankie-root frankie-widget", mode === "dark" && "dark"),
1271
- style: { ...themeToCssVars(theme), position: "fixed", bottom: offset, ...side, zIndex: 2147483e3 },
1326
+ style: { ...themeToCssVars(theme), position: "fixed", bottom: offset, ...side, zIndex, pointerEvents: "none" },
1272
1327
  "data-frankie-mode": mode,
1273
1328
  children: /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("div", { className: cn("flex flex-col gap-3", position === "bottom-left" ? "items-start" : "items-end"), children: [
1274
- open ? /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(ChatPanel, { ...panelProps, onClose: () => setOpen(false), autoFocus: true }) : null,
1275
- 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)(
1276
1331
  Button,
1277
1332
  {
1278
1333
  size: "icon",
@@ -1283,7 +1338,7 @@ function FrankieWidget({
1283
1338
  title: label,
1284
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" })
1285
1340
  }
1286
- )
1341
+ ) })
1287
1342
  ] })
1288
1343
  }
1289
1344
  );
@@ -1306,38 +1361,145 @@ function FrankieInline({ className, style, ...panelProps }) {
1306
1361
  }
1307
1362
 
1308
1363
  // src/components/chat/FrankieChatPage.tsx
1364
+ var import_react10 = require("react");
1365
+ var import_lucide_react6 = require("lucide-react");
1309
1366
  var import_jsx_runtime16 = require("react/jsx-runtime");
1310
- function FrankieChatPage({
1311
- className,
1312
- style,
1313
- header,
1314
- footer,
1315
- emptyState,
1316
- renderMessage,
1317
- renderTool,
1318
- placeholder,
1319
- autoFocus,
1320
- ...providerProps
1321
- }) {
1322
- return /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
1323
- "main",
1324
- {
1325
- className,
1326
- style: { height: "100dvh", minHeight: "100dvh", width: "100%", ...style },
1327
- children: /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(FrankieProvider, { ...providerProps, children: /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
1328
- FrankieInline,
1329
- {
1330
- header,
1331
- footer,
1332
- emptyState,
1333
- renderMessage,
1334
- renderTool,
1335
- placeholder,
1336
- autoFocus
1337
- }
1338
- ) })
1367
+ function PageEmptyState() {
1368
+ const { config } = useFrankie();
1369
+ const { send, busy, restoring } = useFrankieChat();
1370
+ const name = config?.branding.name ?? "Frankie";
1371
+ return /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("section", { className: "frankie-page-empty", "aria-label": "Start a conversation", children: [
1372
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { className: "frankie-page-avatar", "aria-hidden": true, children: name.trim().slice(0, 1).toUpperCase() }),
1373
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { children: [
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?" }),
1376
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("p", { children: config?.greeting ?? "Ask a question or choose a task to get started." })
1377
+ ] }),
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: [
1379
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("strong", { children: prompt }),
1380
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("b", { "aria-hidden": true, children: "\u2197" })
1381
+ ] }, prompt)) })
1382
+ ] });
1383
+ }
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);
1396
+ const name = config?.branding.name ?? "Frankie";
1397
+ const refresh = (0, import_react10.useCallback)(async () => {
1398
+ if (!showHistory || !config || config.allowAnonymous) {
1399
+ setHistoryError("");
1400
+ return;
1339
1401
  }
1340
- );
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
+ ] })
1491
+ ] }),
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, {}) }) })
1493
+ ] }),
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
1497
+ ] });
1498
+ }
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 }) }) });
1341
1503
  }
1342
1504
 
1343
1505
  // src/components/ui/separator.tsx