@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.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,15 +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
- useEffect2(() => {
319
+ const messagesRef = useRef([]);
320
+ useEffect(() => {
358
321
  mounted.current = true;
359
322
  return () => {
360
323
  mounted.current = false;
@@ -362,21 +325,29 @@ function useFrankieChat() {
362
325
  };
363
326
  }, []);
364
327
  const storageKey = `${client.namespace}.messages`;
365
- useEffect2(() => {
328
+ useEffect(() => {
366
329
  let active = true;
367
330
  const restore = async () => {
368
331
  if (client.persist) {
369
332
  try {
370
333
  const raw = window.localStorage.getItem(storageKey);
371
334
  const stored = raw ? JSON.parse(raw) : [];
372
- if (Array.isArray(stored) && stored.length && active) setMessages(stored);
335
+ if (Array.isArray(stored) && stored.length && active) {
336
+ messagesRef.current = stored;
337
+ setMessages(stored);
338
+ }
373
339
  } catch {
374
340
  }
375
341
  }
376
342
  try {
377
343
  const remote = await client.resumeConversation();
378
- if (active && remote?.messages.length) setMessages(remote.messages);
344
+ if (active && remote?.messages.length) {
345
+ const restoredMessages = remote.messages;
346
+ messagesRef.current = restoredMessages;
347
+ setMessages(restoredMessages);
348
+ }
379
349
  } catch {
350
+ if (active) setError("Could not restore this conversation. Check your sign-in and connection, then try opening it again.");
380
351
  } finally {
381
352
  if (active) setRestored(true);
382
353
  }
@@ -386,7 +357,7 @@ function useFrankieChat() {
386
357
  active = false;
387
358
  };
388
359
  }, [client, storageKey]);
389
- useEffect2(() => {
360
+ useEffect(() => {
390
361
  if (!client.persist || !restored || busy) return;
391
362
  try {
392
363
  if (messages.length) window.localStorage.setItem(storageKey, JSON.stringify(messages));
@@ -396,30 +367,33 @@ function useFrankieChat() {
396
367
  if (messages.length) void client.syncConversation(messages, conversationTitle(messages)).catch(() => void 0);
397
368
  }, [messages, busy, client, storageKey, restored]);
398
369
  const patchAssistant = useCallback((id, update) => {
399
- setMessages(
400
- (current) => current.map(
401
- (message) => message.id === id && message.role === "assistant" ? update(message) : message
402
- )
370
+ const next = messagesRef.current.map(
371
+ (message) => message.id === id && message.role === "assistant" ? update(message) : message
403
372
  );
373
+ messagesRef.current = next;
374
+ setMessages(next);
404
375
  }, []);
405
376
  const send = useCallback(
406
377
  async (text, opts) => {
407
378
  const trimmed = text.trim();
408
379
  const attachments = opts?.attachments ?? [];
409
- if (!trimmed && attachments.length === 0 || busy) return;
380
+ if (!trimmed && attachments.length === 0 || abortRef.current || !restored) return;
410
381
  const outgoing = trimmed || "Please take a look at the attached file(s).";
411
382
  const assistantId = nextId("a");
412
383
  setError(void 0);
384
+ setLoadedSkills(null);
413
385
  setBusy(true);
414
- setMessages((current) => [
415
- ...current,
386
+ const next = [
387
+ ...messagesRef.current,
416
388
  newUserMessage(
417
389
  nextId("u"),
418
390
  trimmed,
419
391
  attachments.map((file) => ({ name: file.name, mimeType: file.mimeType }))
420
392
  ),
421
393
  newAssistantMessage(assistantId)
422
- ]);
394
+ ];
395
+ messagesRef.current = next;
396
+ setMessages(next);
423
397
  const abort = new AbortController();
424
398
  abortRef.current = abort;
425
399
  try {
@@ -434,6 +408,10 @@ function useFrankieChat() {
434
408
  signal: abort.signal
435
409
  },
436
410
  (event) => {
411
+ if (event.type === "skills_loaded") {
412
+ setLoadedSkills(event.skills);
413
+ return;
414
+ }
437
415
  if (event.type === "ping" || event.type === "session") return;
438
416
  if (event.type === "text_delta") receivedText = true;
439
417
  if (event.type === "tool_start" || event.type === "tool_end") receivedToolActivity = true;
@@ -460,32 +438,36 @@ function useFrankieChat() {
460
438
  patchAssistant(assistantId, (current) => ({ ...current, status: "error", error: message }));
461
439
  }
462
440
  } finally {
463
- if (mounted.current) {
464
- setBusy(false);
465
- patchAssistant(
466
- assistantId,
467
- (message) => message.status === "streaming" ? { ...message, status: "done" } : message
468
- );
441
+ patchAssistant(
442
+ assistantId,
443
+ (message) => message.status === "streaming" ? { ...message, status: "done" } : message
444
+ );
445
+ const transcript = messagesRef.current;
446
+ if (transcript.length) {
447
+ await client.syncConversation(transcript, conversationTitle(transcript)).catch(() => void 0);
469
448
  }
449
+ if (mounted.current) setBusy(false);
470
450
  abortRef.current = null;
471
451
  }
472
452
  },
473
- [busy, client, context, patchAssistant]
453
+ [busy, client, context, patchAssistant, restored]
474
454
  );
475
455
  const stop = useCallback(() => abortRef.current?.abort(), []);
476
456
  const reset = useCallback(() => {
477
457
  abortRef.current?.abort();
478
458
  client.resetSession();
459
+ messagesRef.current = [];
479
460
  setMessages([]);
480
461
  setError(void 0);
481
462
  setBusy(false);
482
463
  setRestored(true);
464
+ setLoadedSkills(null);
483
465
  try {
484
466
  if (client.persist) window.localStorage.removeItem(`${client.namespace}.messages`);
485
467
  } catch {
486
468
  }
487
469
  }, [client]);
488
- return { messages, busy, ...error ? { error } : {}, send, stop, reset };
470
+ return { messages, busy, loadedSkills, restoring: !restored, ...error ? { error } : {}, send, stop, reset };
489
471
  }
490
472
  function conversationTitle(messages) {
491
473
  const first = messages.find((message) => message.role === "user" && message.text.trim());
@@ -494,6 +476,68 @@ function conversationTitle(messages) {
494
476
  return title.length > 42 ? `${title.slice(0, 42)}\u2026` : title;
495
477
  }
496
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
+
497
541
  // src/components/chat/FrankieWidget.tsx
498
542
  import { MessageCircle, X as X4 } from "lucide-react";
499
543
  import { useEffect as useEffect5, useState as useState6 } from "react";
@@ -557,7 +601,7 @@ function Button({ className, variant, size, asChild = false, ...props }) {
557
601
 
558
602
  // src/components/chat/ChatPanel.tsx
559
603
  import { RotateCcw, X as X3 } from "lucide-react";
560
- 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";
561
605
 
562
606
  // src/components/ui/avatar.tsx
563
607
  import { jsx as jsx3 } from "react/jsx-runtime";
@@ -617,7 +661,7 @@ var ScrollArea = forwardRef(
617
661
 
618
662
  // src/components/chat/Composer.tsx
619
663
  import { ArrowUp, Paperclip, Square, X } from "lucide-react";
620
- 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";
621
665
 
622
666
  // src/components/ui/textarea.tsx
623
667
  import { forwardRef as forwardRef2 } from "react";
@@ -645,8 +689,8 @@ function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocus, atta
645
689
  const [value, setValue] = useState3("");
646
690
  const [files, setFiles] = useState3([]);
647
691
  const [fileError, setFileError] = useState3(void 0);
648
- const textareaRef = useRef2(null);
649
- const fileInputRef = useRef2(null);
692
+ const textareaRef = useRef3(null);
693
+ const fileInputRef = useRef3(null);
650
694
  const canAttach = Boolean(attachments?.enabled);
651
695
  const models = modelPicker?.models ?? [];
652
696
  useEffect3(() => {
@@ -1096,8 +1140,8 @@ function ChatPanel({
1096
1140
  }) {
1097
1141
  const { config, configError, loading } = useFrankie();
1098
1142
  const theme = useFrankieTheme();
1099
- const { messages, busy, error, send, stop, reset } = useFrankieChat();
1100
- const scrollRef = useRef3(null);
1143
+ const { messages, busy, restoring, loadedSkills, error, send, stop, reset } = useFrankieChat();
1144
+ const scrollRef = useRef4(null);
1101
1145
  const features = config?.features;
1102
1146
  const pickerModels = features?.modelPicker.enabled ? features.modelPicker.models : [];
1103
1147
  const [model, setModel] = useState5(void 0);
@@ -1151,6 +1195,16 @@ function ChatPanel({
1151
1195
  )
1152
1196
  ) }) }) }),
1153
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,
1154
1208
  error ? /* @__PURE__ */ jsx13("p", { className: "w-full text-xs text-red-600 dark:text-red-400", children: error }) : null,
1155
1209
  /* @__PURE__ */ jsx13("div", { className: "w-full", children: /* @__PURE__ */ jsx13(
1156
1210
  Composer,
@@ -1158,7 +1212,7 @@ function ChatPanel({
1158
1212
  onSend: send,
1159
1213
  onStop: stop,
1160
1214
  busy,
1161
- disabled: Boolean(configError),
1215
+ disabled: loading || restoring || Boolean(configError),
1162
1216
  ...placeholder ? { placeholder } : {},
1163
1217
  ...autoFocus ? { autoFocus } : {},
1164
1218
  ...features?.attachments.enabled ? { attachments: features.attachments } : {},
@@ -1178,6 +1232,7 @@ function FrankieWidget({
1178
1232
  position = "bottom-right",
1179
1233
  offset = 24,
1180
1234
  defaultOpen = false,
1235
+ zIndex = 20,
1181
1236
  open: controlledOpen,
1182
1237
  onOpenChange,
1183
1238
  renderLauncher,
@@ -1206,11 +1261,11 @@ function FrankieWidget({
1206
1261
  "div",
1207
1262
  {
1208
1263
  className: cn("frankie-root frankie-widget", mode === "dark" && "dark"),
1209
- style: { ...themeToCssVars(theme), position: "fixed", bottom: offset, ...side, zIndex: 2147483e3 },
1264
+ style: { ...themeToCssVars(theme), position: "fixed", bottom: offset, ...side, zIndex, pointerEvents: "none" },
1210
1265
  "data-frankie-mode": mode,
1211
1266
  children: /* @__PURE__ */ jsxs5("div", { className: cn("flex flex-col gap-3", position === "bottom-left" ? "items-start" : "items-end"), children: [
1212
- open ? /* @__PURE__ */ jsx14(ChatPanel, { ...panelProps, onClose: () => setOpen(false), autoFocus: true }) : null,
1213
- 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(
1214
1269
  Button,
1215
1270
  {
1216
1271
  size: "icon",
@@ -1221,7 +1276,7 @@ function FrankieWidget({
1221
1276
  title: label,
1222
1277
  children: open ? /* @__PURE__ */ jsx14(X4, { className: "size-5" }) : /* @__PURE__ */ jsx14(MessageCircle, { className: "size-5" })
1223
1278
  }
1224
- )
1279
+ ) })
1225
1280
  ] })
1226
1281
  }
1227
1282
  );
@@ -1244,38 +1299,145 @@ function FrankieInline({ className, style, ...panelProps }) {
1244
1299
  }
1245
1300
 
1246
1301
  // src/components/chat/FrankieChatPage.tsx
1247
- import { jsx as jsx16 } from "react/jsx-runtime";
1248
- function FrankieChatPage({
1249
- className,
1250
- style,
1251
- header,
1252
- footer,
1253
- emptyState,
1254
- renderMessage,
1255
- renderTool,
1256
- placeholder,
1257
- autoFocus,
1258
- ...providerProps
1259
- }) {
1260
- return /* @__PURE__ */ jsx16(
1261
- "main",
1262
- {
1263
- className,
1264
- style: { height: "100dvh", minHeight: "100dvh", width: "100%", ...style },
1265
- children: /* @__PURE__ */ jsx16(FrankieProvider, { ...providerProps, children: /* @__PURE__ */ jsx16(
1266
- FrankieInline,
1267
- {
1268
- header,
1269
- footer,
1270
- emptyState,
1271
- renderMessage,
1272
- renderTool,
1273
- placeholder,
1274
- autoFocus
1275
- }
1276
- ) })
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";
1304
+ import { Fragment as Fragment3, jsx as jsx16, jsxs as jsxs6 } from "react/jsx-runtime";
1305
+ function PageEmptyState() {
1306
+ const { config } = useFrankie();
1307
+ const { send, busy, restoring } = useFrankieChat();
1308
+ const name = config?.branding.name ?? "Frankie";
1309
+ return /* @__PURE__ */ jsxs6("section", { className: "frankie-page-empty", "aria-label": "Start a conversation", children: [
1310
+ /* @__PURE__ */ jsx16("span", { className: "frankie-page-avatar", "aria-hidden": true, children: name.trim().slice(0, 1).toUpperCase() }),
1311
+ /* @__PURE__ */ jsxs6("div", { children: [
1312
+ /* @__PURE__ */ jsx16("p", { className: "frankie-page-kicker", children: "Your product assistant" }),
1313
+ /* @__PURE__ */ jsx16("h1", { children: "How can I help?" }),
1314
+ /* @__PURE__ */ jsx16("p", { children: config?.greeting ?? "Ask a question or choose a task to get started." })
1315
+ ] }),
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: [
1317
+ /* @__PURE__ */ jsx16("strong", { children: prompt }),
1318
+ /* @__PURE__ */ jsx16("b", { "aria-hidden": true, children: "\u2197" })
1319
+ ] }, prompt)) })
1320
+ ] });
1321
+ }
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);
1334
+ const name = config?.branding.name ?? "Frankie";
1335
+ const refresh = useCallback3(async () => {
1336
+ if (!showHistory || !config || config.allowAnonymous) {
1337
+ setHistoryError("");
1338
+ return;
1277
1339
  }
1278
- );
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
+ ] })
1429
+ ] }),
1430
+ /* @__PURE__ */ jsx16("div", { className: "frankie-page-canvas", children: /* @__PURE__ */ jsx16(FrankieInline, { ...panel, header: panel.header ?? false, emptyState: panel.emptyState ?? /* @__PURE__ */ jsx16(PageEmptyState, {}) }) })
1431
+ ] }),
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
1435
+ ] });
1436
+ }
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 }) }) });
1279
1441
  }
1280
1442
 
1281
1443
  // src/components/ui/separator.tsx