@frankieai/react 0.1.0 → 0.1.4

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
@@ -28,13 +28,16 @@ var FrankieClient = class {
28
28
  get sessionId() {
29
29
  return this.session?.sessionId;
30
30
  }
31
+ get conversationId() {
32
+ return this.options.conversationId;
33
+ }
31
34
  /** Whether this client persists across reloads (browser only, opt-out via `persist:false`). */
32
35
  get persist() {
33
36
  return this.options.persist !== false && typeof window !== "undefined";
34
37
  }
35
- /** localStorage namespace for this tenant, shared with the chat hook. */
38
+ /** localStorage namespace for this tenant/conversation, shared with the chat hook. */
36
39
  get namespace() {
37
- return `frankie.${this.options.publishableKey}`;
40
+ return this.options.storageNamespace ?? `frankie.${this.options.publishableKey}`;
38
41
  }
39
42
  readStored() {
40
43
  if (!this.persist) return void 0;
@@ -62,36 +65,88 @@ var FrankieClient = class {
62
65
  });
63
66
  return await this.unwrap(response);
64
67
  }
65
- /** Opens (or reuses) a chat session. */
66
68
  async ensureSession(context) {
67
- if (this.session && new Date(this.session.expiresAt).getTime() > Date.now() + 3e4) {
68
- return this.session;
69
- }
69
+ if (this.session && new Date(this.session.expiresAt).getTime() > Date.now() + 3e4) return this.session;
70
70
  const userToken = await this.options.getUserToken?.();
71
+ if (this.options.getUserToken && !userToken) {
72
+ throw new FrankieApiError(
73
+ "Your signed-in Frankie session could not be verified. Refresh the page and sign in again.",
74
+ 401,
75
+ "user_token_unavailable"
76
+ );
77
+ }
71
78
  const response = await this.doFetch(`${this.baseUrl}/v1/session`, {
72
79
  method: "POST",
73
80
  headers: { "content-type": "application/json", "x-frankie-key": this.options.publishableKey },
74
- body: JSON.stringify({ ...userToken ? { userToken } : {}, ...context ? { context } : {} })
81
+ body: JSON.stringify({
82
+ ...userToken ? { userToken } : {},
83
+ ...context ? { context } : {},
84
+ ...this.options.conversationId ? { conversationId: this.options.conversationId } : {}
85
+ })
75
86
  });
76
87
  this.session = await this.unwrap(response);
77
88
  this.writeStored();
78
89
  return this.session;
79
90
  }
80
- /** Forget the current session so the next send starts a fresh conversation. */
91
+ async listConversations() {
92
+ const userToken = await this.requireUserToken();
93
+ const response = await this.doFetch(`${this.baseUrl}/v1/conversations`, {
94
+ headers: { "x-frankie-key": this.options.publishableKey, authorization: `Bearer ${userToken}` }
95
+ });
96
+ const body = await this.unwrap(response);
97
+ return Array.isArray(body.conversations) ? body.conversations : [];
98
+ }
99
+ async resumeConversation(id = this.options.conversationId) {
100
+ if (!id) return void 0;
101
+ const userToken = await this.requireUserToken();
102
+ const response = await this.doFetch(`${this.baseUrl}/v1/conversations/${encodeURIComponent(id)}/resume`, {
103
+ method: "POST",
104
+ headers: { "x-frankie-key": this.options.publishableKey, authorization: `Bearer ${userToken}` }
105
+ });
106
+ if (response.status === 404) return void 0;
107
+ const body = await this.unwrap(response);
108
+ this.session = body.session;
109
+ this.writeStored();
110
+ return { messages: Array.isArray(body.messages) ? body.messages : [], title: body.title ?? "New chat" };
111
+ }
112
+ async syncConversation(messages, title) {
113
+ if (!this.session || !this.options.conversationId) return;
114
+ const response = await this.doFetch(`${this.baseUrl}/v1/conversations/${encodeURIComponent(this.options.conversationId)}`, {
115
+ method: "PUT",
116
+ headers: {
117
+ "content-type": "application/json",
118
+ "x-frankie-key": this.options.publishableKey,
119
+ authorization: `Bearer ${this.session.sessionToken}`
120
+ },
121
+ body: JSON.stringify({ messages, ...title ? { title } : {} })
122
+ });
123
+ await this.unwrap(response);
124
+ }
125
+ async deleteConversation(id) {
126
+ const userToken = await this.requireUserToken();
127
+ const response = await this.doFetch(`${this.baseUrl}/v1/conversations/${encodeURIComponent(id)}`, {
128
+ method: "DELETE",
129
+ headers: { "x-frankie-key": this.options.publishableKey, authorization: `Bearer ${userToken}` }
130
+ });
131
+ if (response.status !== 404) await this.unwrap(response);
132
+ }
133
+ async requireUserToken() {
134
+ const token = await this.options.getUserToken?.();
135
+ if (!token) throw new FrankieApiError("Sign in to access your chat history.", 401, "unauthorized");
136
+ return token;
137
+ }
81
138
  resetSession() {
82
139
  this.session = void 0;
83
140
  this.writeStored();
84
141
  }
85
- /**
86
- * Sends a message and invokes `onEvent` for each server event as it arrives.
87
- * Resolves when the turn ends.
88
- */
89
142
  async send(input, onEvent) {
90
143
  const session = await this.ensureSession(input.context);
91
144
  const body = {
92
145
  sessionId: session.sessionId,
93
146
  message: input.message,
94
- ...input.context ? { context: input.context } : {}
147
+ ...input.context ? { context: input.context } : {},
148
+ ...input.model ? { model: input.model } : {},
149
+ ...input.attachments?.length ? { attachments: input.attachments } : {}
95
150
  };
96
151
  const response = await this.doFetch(`${this.baseUrl}/v1/chat`, {
97
152
  method: "POST",
@@ -169,9 +224,8 @@ function FrankieProvider({
169
224
  }) {
170
225
  const client = useMemo(
171
226
  () => new FrankieClient(clientOptions),
172
- // The client holds session state; rebuild it only when the endpoint changes.
173
227
  // eslint-disable-next-line react-hooks/exhaustive-deps
174
- [clientOptions.baseUrl, clientOptions.publishableKey]
228
+ [clientOptions.baseUrl, clientOptions.publishableKey, clientOptions.storageNamespace, clientOptions.conversationId]
175
229
  );
176
230
  const [config, setConfig] = useState(providedConfig);
177
231
  const [configError, setConfigError] = useState(void 0);
@@ -285,8 +339,8 @@ function fallbackText(stopReason) {
285
339
  function newAssistantMessage(id) {
286
340
  return { id, role: "assistant", text: "", thinking: "", tools: [], status: "streaming", createdAt: Date.now() };
287
341
  }
288
- function newUserMessage(id, text) {
289
- return { id, role: "user", text, createdAt: Date.now() };
342
+ function newUserMessage(id, text, attachments) {
343
+ return { id, role: "user", text, ...attachments?.length ? { attachments } : {}, createdAt: Date.now() };
290
344
  }
291
345
 
292
346
  // src/hooks/useFrankieChat.ts
@@ -297,6 +351,7 @@ function useFrankieChat() {
297
351
  const [messages, setMessages] = useState2([]);
298
352
  const [busy, setBusy] = useState2(false);
299
353
  const [error, setError] = useState2(void 0);
354
+ const [restored, setRestored] = useState2(false);
300
355
  const abortRef = useRef(null);
301
356
  const mounted = useRef(true);
302
357
  useEffect2(() => {
@@ -308,22 +363,38 @@ function useFrankieChat() {
308
363
  }, []);
309
364
  const storageKey = `${client.namespace}.messages`;
310
365
  useEffect2(() => {
311
- if (!client.persist) return;
312
- try {
313
- const raw = window.localStorage.getItem(storageKey);
314
- const stored = raw ? JSON.parse(raw) : [];
315
- if (Array.isArray(stored) && stored.length) setMessages(stored);
316
- } catch {
317
- }
318
- }, [storageKey]);
366
+ let active = true;
367
+ const restore = async () => {
368
+ if (client.persist) {
369
+ try {
370
+ const raw = window.localStorage.getItem(storageKey);
371
+ const stored = raw ? JSON.parse(raw) : [];
372
+ if (Array.isArray(stored) && stored.length && active) setMessages(stored);
373
+ } catch {
374
+ }
375
+ }
376
+ try {
377
+ const remote = await client.resumeConversation();
378
+ if (active && remote?.messages.length) setMessages(remote.messages);
379
+ } catch {
380
+ } finally {
381
+ if (active) setRestored(true);
382
+ }
383
+ };
384
+ void restore();
385
+ return () => {
386
+ active = false;
387
+ };
388
+ }, [client, storageKey]);
319
389
  useEffect2(() => {
320
- if (!client.persist || busy) return;
390
+ if (!client.persist || !restored || busy) return;
321
391
  try {
322
392
  if (messages.length) window.localStorage.setItem(storageKey, JSON.stringify(messages));
323
393
  else window.localStorage.removeItem(storageKey);
324
394
  } catch {
325
395
  }
326
- }, [messages, busy, client, storageKey]);
396
+ if (messages.length) void client.syncConversation(messages, conversationTitle(messages)).catch(() => void 0);
397
+ }, [messages, busy, client, storageKey, restored]);
327
398
  const patchAssistant = useCallback((id, update) => {
328
399
  setMessages(
329
400
  (current) => current.map(
@@ -332,24 +403,50 @@ function useFrankieChat() {
332
403
  );
333
404
  }, []);
334
405
  const send = useCallback(
335
- async (text) => {
406
+ async (text, opts) => {
336
407
  const trimmed = text.trim();
337
- if (!trimmed || busy) return;
408
+ const attachments = opts?.attachments ?? [];
409
+ if (!trimmed && attachments.length === 0 || busy) return;
410
+ const outgoing = trimmed || "Please take a look at the attached file(s).";
338
411
  const assistantId = nextId("a");
339
412
  setError(void 0);
340
413
  setBusy(true);
341
414
  setMessages((current) => [
342
415
  ...current,
343
- newUserMessage(nextId("u"), trimmed),
416
+ newUserMessage(
417
+ nextId("u"),
418
+ trimmed,
419
+ attachments.map((file) => ({ name: file.name, mimeType: file.mimeType }))
420
+ ),
344
421
  newAssistantMessage(assistantId)
345
422
  ]);
346
423
  const abort = new AbortController();
347
424
  abortRef.current = abort;
348
425
  try {
349
- await client.send({ message: trimmed, ...context ? { context } : {}, signal: abort.signal }, (event) => {
350
- if (event.type === "ping" || event.type === "session") return;
351
- patchAssistant(assistantId, (message) => applyEvent(message, event));
352
- });
426
+ let receivedText = false;
427
+ let receivedToolActivity = false;
428
+ await client.send(
429
+ {
430
+ message: outgoing,
431
+ ...context ? { context } : {},
432
+ ...opts?.model ? { model: opts.model } : {},
433
+ ...attachments.length ? { attachments } : {},
434
+ signal: abort.signal
435
+ },
436
+ (event) => {
437
+ if (event.type === "ping" || event.type === "session") return;
438
+ if (event.type === "text_delta") receivedText = true;
439
+ if (event.type === "tool_start" || event.type === "tool_end") receivedToolActivity = true;
440
+ patchAssistant(assistantId, (message) => applyEvent(message, event));
441
+ }
442
+ );
443
+ if (!receivedText) {
444
+ patchAssistant(assistantId, (message) => ({
445
+ ...message,
446
+ status: "done",
447
+ text: receivedToolActivity ? "Completed. Open Agent activity for the details." : "Frankie didn\u2019t return an answer. Please try again."
448
+ }));
449
+ }
353
450
  } catch (caught) {
354
451
  if (abort.signal.aborted) {
355
452
  patchAssistant(assistantId, (message) => ({
@@ -358,7 +455,7 @@ function useFrankieChat() {
358
455
  text: message.text || "Stopped."
359
456
  }));
360
457
  } else {
361
- const message = caught instanceof FrankieApiError ? caught.message : "Could not reach the assistant. Check your connection and try again.";
458
+ const message = caught instanceof FrankieApiError ? caught.message : caught instanceof Error && caught.message.startsWith("Frankie") ? caught.message : "Could not reach the assistant. Check your connection and try again.";
362
459
  if (mounted.current) setError(message);
363
460
  patchAssistant(assistantId, (current) => ({ ...current, status: "error", error: message }));
364
461
  }
@@ -382,6 +479,7 @@ function useFrankieChat() {
382
479
  setMessages([]);
383
480
  setError(void 0);
384
481
  setBusy(false);
482
+ setRestored(true);
385
483
  try {
386
484
  if (client.persist) window.localStorage.removeItem(`${client.namespace}.messages`);
387
485
  } catch {
@@ -389,10 +487,16 @@ function useFrankieChat() {
389
487
  }, [client]);
390
488
  return { messages, busy, ...error ? { error } : {}, send, stop, reset };
391
489
  }
490
+ function conversationTitle(messages) {
491
+ const first = messages.find((message) => message.role === "user" && message.text.trim());
492
+ if (!first) return void 0;
493
+ const title = first.text.trim().replace(/\s+/g, " ");
494
+ return title.length > 42 ? `${title.slice(0, 42)}\u2026` : title;
495
+ }
392
496
 
393
497
  // src/components/chat/FrankieWidget.tsx
394
- import { MessageCircle, X as X3 } from "lucide-react";
395
- import { useEffect as useEffect5, useState as useState4 } from "react";
498
+ import { MessageCircle, X as X4 } from "lucide-react";
499
+ import { useEffect as useEffect5, useState as useState6 } from "react";
396
500
 
397
501
  // src/lib/theme.ts
398
502
  function themeToCssVars(theme) {
@@ -452,8 +556,8 @@ function Button({ className, variant, size, asChild = false, ...props }) {
452
556
  }
453
557
 
454
558
  // src/components/chat/ChatPanel.tsx
455
- import { RotateCcw, X as X2 } from "lucide-react";
456
- import { useEffect as useEffect4, useRef as useRef3 } from "react";
559
+ import { RotateCcw, X as X3 } from "lucide-react";
560
+ import { useEffect as useEffect4, useRef as useRef3, useState as useState5 } from "react";
457
561
 
458
562
  // src/components/ui/avatar.tsx
459
563
  import { jsx as jsx3 } from "react/jsx-runtime";
@@ -512,7 +616,7 @@ var ScrollArea = forwardRef(
512
616
  );
513
617
 
514
618
  // src/components/chat/Composer.tsx
515
- import { ArrowUp, Square } from "lucide-react";
619
+ import { ArrowUp, Paperclip, Square, X } from "lucide-react";
516
620
  import { useCallback as useCallback2, useEffect as useEffect3, useRef as useRef2, useState as useState3 } from "react";
517
621
 
518
622
  // src/components/ui/textarea.tsx
@@ -535,60 +639,190 @@ var Textarea = forwardRef2(
535
639
  );
536
640
 
537
641
  // src/components/chat/Composer.tsx
538
- import { jsx as jsx7, jsxs } from "react/jsx-runtime";
642
+ import { Fragment, jsx as jsx7, jsxs } from "react/jsx-runtime";
539
643
  var MAX_ROWS_PX = 140;
540
- function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocus }) {
644
+ function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocus, attachments, modelPicker }) {
541
645
  const [value, setValue] = useState3("");
646
+ const [files, setFiles] = useState3([]);
647
+ const [fileError, setFileError] = useState3(void 0);
542
648
  const textareaRef = useRef2(null);
649
+ const fileInputRef = useRef2(null);
650
+ const canAttach = Boolean(attachments?.enabled);
651
+ const models = modelPicker?.models ?? [];
543
652
  useEffect3(() => {
544
653
  const node = textareaRef.current;
545
654
  if (!node) return;
546
655
  node.style.height = "auto";
547
656
  node.style.height = `${Math.min(node.scrollHeight, MAX_ROWS_PX)}px`;
548
657
  }, [value]);
658
+ const addFiles = useCallback2(
659
+ async (list) => {
660
+ if (!list || !attachments) return;
661
+ setFileError(void 0);
662
+ const room = attachments.maxCount - files.length;
663
+ const chosen = Array.from(list).slice(0, Math.max(0, room));
664
+ if (Array.from(list).length > room) setFileError(`You can attach up to ${attachments.maxCount} file(s).`);
665
+ const next = [];
666
+ for (const file of chosen) {
667
+ if (!mimeAccepted(file.type || "application/octet-stream", attachments.accept)) {
668
+ setFileError(`Files of type ${file.type || "unknown"} aren't allowed.`);
669
+ continue;
670
+ }
671
+ if (file.size > attachments.maxMb * 1024 * 1024) {
672
+ setFileError(`Each file must be under ${attachments.maxMb} MB.`);
673
+ continue;
674
+ }
675
+ try {
676
+ next.push({
677
+ id: `${file.name}-${file.size}-${Math.random().toString(36).slice(2, 7)}`,
678
+ name: file.name,
679
+ mimeType: file.type || "application/octet-stream",
680
+ data: await readAsBase64(file)
681
+ });
682
+ } catch {
683
+ setFileError("Couldn't read that file.");
684
+ }
685
+ }
686
+ if (next.length) setFiles((current) => [...current, ...next]);
687
+ },
688
+ [attachments, files.length]
689
+ );
690
+ const removeFile = (id) => setFiles((current) => current.filter((file) => file.id !== id));
549
691
  const submit = useCallback2(() => {
550
692
  const text = value.trim();
551
- if (!text || busy || disabled) return;
693
+ if (!text && files.length === 0 || busy || disabled) return;
552
694
  setValue("");
553
- onSend(text);
554
- }, [busy, disabled, onSend, value]);
695
+ const attached = files.map(({ name, mimeType, data }) => ({ name, mimeType, data }));
696
+ setFiles([]);
697
+ setFileError(void 0);
698
+ onSend(text, {
699
+ ...attached.length ? { attachments: attached } : {},
700
+ ...modelPicker?.value ? { model: modelPicker.value } : {}
701
+ });
702
+ }, [busy, disabled, files, modelPicker?.value, onSend, value]);
555
703
  const onKeyDown = (event) => {
556
704
  if (event.key === "Enter" && !event.shiftKey) {
557
705
  event.preventDefault();
558
706
  submit();
559
707
  }
560
708
  };
709
+ const nothingToSend = value.trim().length === 0 && files.length === 0;
561
710
  return /* @__PURE__ */ jsxs(
562
711
  "form",
563
712
  {
564
- className: "flex items-end gap-2",
713
+ className: "flex flex-col gap-2",
565
714
  onSubmit: (event) => {
566
715
  event.preventDefault();
567
716
  submit();
568
717
  },
569
718
  children: [
570
- /* @__PURE__ */ jsx7(
571
- Textarea,
719
+ files.length > 0 ? /* @__PURE__ */ jsx7("div", { className: "flex flex-wrap gap-2", children: files.map((file) => /* @__PURE__ */ jsxs(
720
+ "span",
572
721
  {
573
- ref: textareaRef,
574
- rows: 1,
575
- value,
576
- disabled,
577
- autoFocus,
578
- onChange: (event) => setValue(event.target.value),
579
- onKeyDown,
580
- placeholder: placeholder ?? "Ask anything\u2026",
581
- "aria-label": "Message"
582
- }
583
- ),
584
- busy ? /* @__PURE__ */ jsx7(Button, { type: "button", size: "icon", variant: "outline", onClick: onStop, "aria-label": "Stop", children: /* @__PURE__ */ jsx7(Square, {}) }) : /* @__PURE__ */ jsx7(Button, { type: "submit", size: "icon", disabled: disabled || value.trim().length === 0, "aria-label": "Send", children: /* @__PURE__ */ jsx7(ArrowUp, {}) })
722
+ className: "inline-flex max-w-[14rem] items-center gap-1.5 rounded-[var(--frankie-radius)] border border-border bg-muted px-2 py-1 text-xs",
723
+ children: [
724
+ /* @__PURE__ */ jsx7(Paperclip, { className: "size-3 shrink-0 opacity-70" }),
725
+ /* @__PURE__ */ jsx7("span", { className: "truncate", children: file.name }),
726
+ /* @__PURE__ */ jsx7(
727
+ "button",
728
+ {
729
+ type: "button",
730
+ onClick: () => removeFile(file.id),
731
+ "aria-label": `Remove ${file.name}`,
732
+ className: "frankie-focus shrink-0 opacity-60 hover:opacity-100",
733
+ children: /* @__PURE__ */ jsx7(X, { className: "size-3" })
734
+ }
735
+ )
736
+ ]
737
+ },
738
+ file.id
739
+ )) }) : null,
740
+ fileError ? /* @__PURE__ */ jsx7("p", { className: "text-xs text-red-600 dark:text-red-400", children: fileError }) : null,
741
+ /* @__PURE__ */ jsxs("div", { className: "flex items-end gap-2", children: [
742
+ canAttach ? /* @__PURE__ */ jsxs(Fragment, { children: [
743
+ /* @__PURE__ */ jsx7(
744
+ "input",
745
+ {
746
+ ref: fileInputRef,
747
+ type: "file",
748
+ multiple: true,
749
+ hidden: true,
750
+ accept: attachments?.accept.join(","),
751
+ onChange: (event) => {
752
+ void addFiles(event.target.files);
753
+ event.target.value = "";
754
+ }
755
+ }
756
+ ),
757
+ /* @__PURE__ */ jsx7(
758
+ Button,
759
+ {
760
+ type: "button",
761
+ size: "icon",
762
+ variant: "outline",
763
+ disabled: disabled || files.length >= (attachments?.maxCount ?? 0),
764
+ onClick: () => fileInputRef.current?.click(),
765
+ "aria-label": "Attach files",
766
+ title: "Attach files",
767
+ children: /* @__PURE__ */ jsx7(Paperclip, {})
768
+ }
769
+ )
770
+ ] }) : null,
771
+ /* @__PURE__ */ jsx7(
772
+ Textarea,
773
+ {
774
+ ref: textareaRef,
775
+ rows: 1,
776
+ value,
777
+ disabled,
778
+ autoFocus,
779
+ onChange: (event) => setValue(event.target.value),
780
+ onKeyDown,
781
+ placeholder: placeholder ?? "Ask anything\u2026",
782
+ "aria-label": "Message"
783
+ }
784
+ ),
785
+ models.length > 0 ? /* @__PURE__ */ jsx7(
786
+ "select",
787
+ {
788
+ value: modelPicker?.value ?? models[0]?.id,
789
+ onChange: (event) => modelPicker?.onChange(event.target.value),
790
+ disabled,
791
+ "aria-label": "Model",
792
+ className: "frankie-focus h-9 max-w-[8rem] rounded-[var(--frankie-radius)] border border-border bg-background px-2 text-sm",
793
+ children: models.map((model) => /* @__PURE__ */ jsx7("option", { value: model.id, children: model.label }, model.id))
794
+ }
795
+ ) : null,
796
+ busy ? /* @__PURE__ */ jsx7(Button, { type: "button", size: "icon", variant: "outline", onClick: onStop, "aria-label": "Stop", children: /* @__PURE__ */ jsx7(Square, {}) }) : /* @__PURE__ */ jsx7(Button, { type: "submit", size: "icon", disabled: disabled || nothingToSend, "aria-label": "Send", children: /* @__PURE__ */ jsx7(ArrowUp, {}) })
797
+ ] })
585
798
  ]
586
799
  }
587
800
  );
588
801
  }
802
+ function mimeAccepted(mimeType, accept) {
803
+ return accept.some(
804
+ (pattern) => pattern.endsWith("/*") ? mimeType.startsWith(pattern.slice(0, -1)) : pattern === mimeType
805
+ );
806
+ }
807
+ function readAsBase64(file) {
808
+ return new Promise((resolve, reject) => {
809
+ const reader = new FileReader();
810
+ reader.onload = () => {
811
+ const result = String(reader.result);
812
+ const comma = result.indexOf(",");
813
+ resolve(comma >= 0 ? result.slice(comma + 1) : result);
814
+ };
815
+ reader.onerror = () => reject(reader.error);
816
+ reader.readAsDataURL(file);
817
+ });
818
+ }
819
+
820
+ // src/components/chat/MessageBubble.tsx
821
+ import { Check as Check2, Copy, Paperclip as Paperclip2 } from "lucide-react";
822
+ import { useState as useState4 } from "react";
589
823
 
590
824
  // src/components/chat/Markdown.tsx
591
- import { Fragment } from "react";
825
+ import { Fragment as Fragment2 } from "react";
592
826
  import { jsx as jsx8 } from "react/jsx-runtime";
593
827
  function Markdown({ text, className }) {
594
828
  return /* @__PURE__ */ jsx8("div", { className: cn("frankie-prose", className), children: renderBlocks(text) });
@@ -673,7 +907,7 @@ function renderInline(text) {
673
907
  const token = match[0];
674
908
  const start = match.index;
675
909
  if (start > lastIndex) nodes.push(text.slice(lastIndex, start));
676
- nodes.push(/* @__PURE__ */ jsx8(Fragment, { children: renderToken(token) }, start));
910
+ nodes.push(/* @__PURE__ */ jsx8(Fragment2, { children: renderToken(token) }, start));
677
911
  lastIndex = start + token.length;
678
912
  }
679
913
  if (lastIndex < text.length) nodes.push(text.slice(lastIndex));
@@ -690,7 +924,7 @@ function renderToken(token) {
690
924
  }
691
925
 
692
926
  // src/components/chat/ToolActivityList.tsx
693
- import { Check, Loader2, ShieldAlert, X } from "lucide-react";
927
+ import { Check, ChevronDown, Loader2, ShieldAlert, X as X2 } from "lucide-react";
694
928
 
695
929
  // src/components/ui/badge.tsx
696
930
  import { cva as cva2 } from "class-variance-authority";
@@ -721,7 +955,20 @@ function Badge({
721
955
  import { jsx as jsx10, jsxs as jsxs2 } from "react/jsx-runtime";
722
956
  function ToolActivityList({ tools, renderTool }) {
723
957
  if (tools.length === 0) return null;
724
- return /* @__PURE__ */ jsx10("ul", { className: "mt-2 flex flex-col gap-1", children: tools.map((tool) => /* @__PURE__ */ jsx10("li", { children: renderTool ? renderTool(tool) : /* @__PURE__ */ jsx10(ToolActivityRow, { tool }) }, tool.id)) });
958
+ const running = tools.some((tool) => tool.status === "running");
959
+ return /* @__PURE__ */ jsxs2("details", { className: "frankie-tool-activity mt-2", open: running, children: [
960
+ /* @__PURE__ */ jsxs2("summary", { className: "inline-flex min-h-7 cursor-pointer list-none items-center gap-1.5 rounded-md px-1 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground [&::-webkit-details-marker]:hidden", children: [
961
+ /* @__PURE__ */ jsx10(ChevronDown, { className: "frankie-tool-chevron size-3.5 transition-transform", "aria-hidden": "true" }),
962
+ /* @__PURE__ */ jsx10("span", { children: running ? "Working" : "Activity" }),
963
+ /* @__PURE__ */ jsxs2("span", { className: "text-[11px]", children: [
964
+ "\xB7 ",
965
+ tools.length,
966
+ " tool call",
967
+ tools.length === 1 ? "" : "s"
968
+ ] })
969
+ ] }),
970
+ /* @__PURE__ */ jsx10("ul", { className: "mt-1 flex flex-col gap-1 border-l border-border pl-2", children: tools.map((tool) => /* @__PURE__ */ jsx10("li", { children: renderTool ? renderTool(tool) : /* @__PURE__ */ jsx10(ToolActivityRow, { tool }) }, tool.id)) })
971
+ ] });
725
972
  }
726
973
  function ToolActivityRow({ tool }) {
727
974
  return /* @__PURE__ */ jsxs2(Badge, { variant: badgeVariant(tool.status), title: tool.detail ?? tool.name, children: [
@@ -738,7 +985,7 @@ function ToolIcon({ status }) {
738
985
  case "denied":
739
986
  return /* @__PURE__ */ jsx10(ShieldAlert, { className: "size-3", "aria-hidden": "true" });
740
987
  default:
741
- return /* @__PURE__ */ jsx10(X, { className: "size-3", "aria-hidden": "true" });
988
+ return /* @__PURE__ */ jsx10(X2, { className: "size-3", "aria-hidden": "true" });
742
989
  }
743
990
  }
744
991
  function badgeVariant(status) {
@@ -758,17 +1005,48 @@ function MessageBubble({
758
1005
  renderTool
759
1006
  }) {
760
1007
  if (message.role === "user") {
761
- return /* @__PURE__ */ jsx11("div", { className: "flex justify-end", children: /* @__PURE__ */ jsx11("div", { className: "max-w-[85%] rounded-[var(--frankie-radius)] bg-primary px-3 py-2 text-sm text-primary-foreground", children: message.text }) });
1008
+ return /* @__PURE__ */ jsxs3("div", { className: "flex flex-col items-end gap-1", children: [
1009
+ message.attachments?.length ? /* @__PURE__ */ jsx11("div", { className: "flex max-w-[85%] flex-wrap justify-end gap-1", children: message.attachments.map((file, index) => /* @__PURE__ */ jsxs3(
1010
+ "span",
1011
+ {
1012
+ className: "inline-flex max-w-[12rem] items-center gap-1 rounded-[var(--frankie-radius)] border border-border bg-muted px-2 py-1 text-xs",
1013
+ children: [
1014
+ /* @__PURE__ */ jsx11(Paperclip2, { className: "size-3 shrink-0 opacity-70" }),
1015
+ /* @__PURE__ */ jsx11("span", { className: "truncate", children: file.name })
1016
+ ]
1017
+ },
1018
+ `${file.name}-${index}`
1019
+ )) }) : null,
1020
+ message.text ? /* @__PURE__ */ jsxs3("div", { className: "flex max-w-[85%] items-end gap-1", children: [
1021
+ /* @__PURE__ */ jsx11("div", { className: "rounded-[var(--frankie-radius)] bg-primary px-3 py-2 text-sm text-primary-foreground", children: message.text }),
1022
+ /* @__PURE__ */ jsx11(MessageCopyButton, { text: message.text })
1023
+ ] }) : null
1024
+ ] });
762
1025
  }
763
1026
  const empty = message.text.length === 0 && message.tools.length === 0;
764
1027
  return /* @__PURE__ */ jsxs3("div", { className: "flex gap-2", children: [
765
- /* @__PURE__ */ jsx11(Avatar, { ...avatarUrl ? { src: avatarUrl } : {}, name: assistantName, className: "mt-0.5" }),
1028
+ /* @__PURE__ */ jsxs3("div", { className: "frankie-assistant-actions mt-0.5 flex shrink-0 flex-col items-center gap-1", children: [
1029
+ /* @__PURE__ */ jsx11(Avatar, { ...avatarUrl ? { src: avatarUrl } : {}, name: assistantName }),
1030
+ message.text ? /* @__PURE__ */ jsx11(MessageCopyButton, { text: message.text }) : null
1031
+ ] }),
766
1032
  /* @__PURE__ */ jsxs3("div", { className: "min-w-0 max-w-[85%]", children: [
767
1033
  message.status === "error" ? /* @__PURE__ */ jsx11("p", { className: "rounded-[var(--frankie-radius)] bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-400", children: message.error }) : empty && message.status === "streaming" ? /* @__PURE__ */ jsx11(TypingDots, {}) : /* @__PURE__ */ jsx11(Markdown, { text: message.text, className: "text-sm" }),
768
1034
  showToolActivity ? /* @__PURE__ */ jsx11(ToolActivityList, { tools: message.tools, ...renderTool ? { renderTool } : {} }) : null
769
1035
  ] })
770
1036
  ] });
771
1037
  }
1038
+ function MessageCopyButton({ text }) {
1039
+ const [copied, setCopied] = useState4(false);
1040
+ const copy = async () => {
1041
+ try {
1042
+ await navigator.clipboard.writeText(text);
1043
+ setCopied(true);
1044
+ window.setTimeout(() => setCopied(false), 1500);
1045
+ } catch {
1046
+ }
1047
+ };
1048
+ return /* @__PURE__ */ jsx11("button", { type: "button", onClick: () => void copy(), className: "frankie-message-copy inline-flex size-8 shrink-0 items-center justify-center rounded-md border border-transparent text-muted-foreground transition hover:border-border hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary", "aria-label": copied ? "Copied" : "Copy message", title: copied ? "Copied" : "Copy message", children: copied ? /* @__PURE__ */ jsx11(Check2, { className: "size-3.5", "aria-hidden": "true" }) : /* @__PURE__ */ jsx11(Copy, { className: "size-3.5", "aria-hidden": "true" }) });
1049
+ }
772
1050
  function TypingDots() {
773
1051
  return /* @__PURE__ */ jsx11("span", { className: "inline-flex items-center gap-1 py-2", role: "status", "aria-label": "Thinking", children: [0, 1, 2].map((index) => /* @__PURE__ */ jsx11(
774
1052
  "span",
@@ -820,6 +1098,9 @@ function ChatPanel({
820
1098
  const theme = useFrankieTheme();
821
1099
  const { messages, busy, error, send, stop, reset } = useFrankieChat();
822
1100
  const scrollRef = useRef3(null);
1101
+ const features = config?.features;
1102
+ const pickerModels = features?.modelPicker.enabled ? features.modelPicker.models : [];
1103
+ const [model, setModel] = useState5(void 0);
823
1104
  useEffect4(() => {
824
1105
  const node = scrollRef.current;
825
1106
  if (!node) return;
@@ -847,9 +1128,13 @@ function ChatPanel({
847
1128
  config?.branding.tagline ? /* @__PURE__ */ jsx13(CardDescription, { children: config.branding.tagline }) : null
848
1129
  ] }),
849
1130
  /* @__PURE__ */ jsx13(Button, { size: "icon", variant: "ghost", onClick: reset, "aria-label": "Start a new conversation", title: "New conversation", children: /* @__PURE__ */ jsx13(RotateCcw, {}) }),
850
- onClose ? /* @__PURE__ */ jsx13(Button, { size: "icon", variant: "ghost", onClick: onClose, "aria-label": "Close", children: /* @__PURE__ */ jsx13(X2, {}) }) : null
1131
+ onClose ? /* @__PURE__ */ jsx13(Button, { size: "icon", variant: "ghost", onClick: onClose, "aria-label": "Close", children: /* @__PURE__ */ jsx13(X3, {}) }) : null
851
1132
  ] }),
852
- /* @__PURE__ */ jsx13(CardContent, { children: /* @__PURE__ */ jsx13(ScrollArea, { ref: scrollRef, className: "flex h-full flex-col gap-4 p-4", "aria-live": "polite", children: configError ? /* @__PURE__ */ jsx13("p", { className: "text-sm text-muted-foreground", children: configError }) : loading ? /* @__PURE__ */ jsx13("p", { className: "text-sm text-muted-foreground", children: "Loading\u2026" }) : messages.length === 0 ? emptyState ?? /* @__PURE__ */ jsxs4("div", { className: "flex flex-col gap-4", children: [
1133
+ /* @__PURE__ */ jsx13(CardContent, { children: /* @__PURE__ */ jsx13(ScrollArea, { ref: scrollRef, className: "flex h-full flex-col gap-4 p-4", "aria-live": "polite", children: configError ? /* @__PURE__ */ jsxs4("div", { className: "frankie-config-error", role: "alert", children: [
1134
+ /* @__PURE__ */ jsx13("strong", { children: "Frankie couldn\u2019t start" }),
1135
+ /* @__PURE__ */ jsx13("p", { children: configError }),
1136
+ /* @__PURE__ */ jsx13("p", { children: "Refresh this page after checking the workspace\u2019s allowed origin and sign-in settings." })
1137
+ ] }) : loading ? /* @__PURE__ */ jsx13("p", { className: "text-sm text-muted-foreground", children: "Starting Frankie\u2026" }) : messages.length === 0 ? emptyState ?? /* @__PURE__ */ jsxs4("div", { className: "flex flex-col gap-4", children: [
853
1138
  /* @__PURE__ */ jsx13("p", { className: "text-sm text-muted-foreground", children: config?.greeting }),
854
1139
  /* @__PURE__ */ jsx13(SuggestedPrompts, { prompts: config?.suggestedPrompts ?? [], onPick: send, disabled: busy })
855
1140
  ] }) : /* @__PURE__ */ jsx13("div", { className: "flex flex-col gap-4", children: messages.map(
@@ -875,7 +1160,9 @@ function ChatPanel({
875
1160
  busy,
876
1161
  disabled: Boolean(configError),
877
1162
  ...placeholder ? { placeholder } : {},
878
- ...autoFocus ? { autoFocus } : {}
1163
+ ...autoFocus ? { autoFocus } : {},
1164
+ ...features?.attachments.enabled ? { attachments: features.attachments } : {},
1165
+ ...pickerModels.length ? { modelPicker: { models: pickerModels, value: model ?? pickerModels[0]?.id, onChange: setModel } } : {}
879
1166
  }
880
1167
  ) }),
881
1168
  footer
@@ -898,7 +1185,7 @@ function FrankieWidget({
898
1185
  }) {
899
1186
  const { config } = useFrankie();
900
1187
  const theme = useFrankieTheme();
901
- const [uncontrolledOpen, setUncontrolledOpen] = useState4(defaultOpen);
1188
+ const [uncontrolledOpen, setUncontrolledOpen] = useState6(defaultOpen);
902
1189
  const open = controlledOpen ?? uncontrolledOpen;
903
1190
  const setOpen = (next) => {
904
1191
  if (controlledOpen === void 0) setUncontrolledOpen(next);
@@ -932,7 +1219,7 @@ function FrankieWidget({
932
1219
  "aria-expanded": open,
933
1220
  "aria-label": open ? "Close the assistant" : label,
934
1221
  title: label,
935
- children: open ? /* @__PURE__ */ jsx14(X3, { className: "size-5" }) : /* @__PURE__ */ jsx14(MessageCircle, { className: "size-5" })
1222
+ children: open ? /* @__PURE__ */ jsx14(X4, { className: "size-5" }) : /* @__PURE__ */ jsx14(MessageCircle, { className: "size-5" })
936
1223
  }
937
1224
  )
938
1225
  ] })
@@ -956,10 +1243,45 @@ function FrankieInline({ className, style, ...panelProps }) {
956
1243
  );
957
1244
  }
958
1245
 
959
- // src/components/ui/separator.tsx
1246
+ // src/components/chat/FrankieChatPage.tsx
960
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
+ ) })
1277
+ }
1278
+ );
1279
+ }
1280
+
1281
+ // src/components/ui/separator.tsx
1282
+ import { jsx as jsx17 } from "react/jsx-runtime";
961
1283
  function Separator({ className }) {
962
- return /* @__PURE__ */ jsx16("div", { role: "separator", className: cn("h-px w-full bg-border", className) });
1284
+ return /* @__PURE__ */ jsx17("div", { role: "separator", className: cn("h-px w-full bg-border", className) });
963
1285
  }
964
1286
  export {
965
1287
  Avatar,
@@ -975,6 +1297,7 @@ export {
975
1297
  Composer,
976
1298
  FRANKIE_CLOUD_URL,
977
1299
  FrankieApiError,
1300
+ FrankieChatPage,
978
1301
  FrankieClient,
979
1302
  FrankieInline,
980
1303
  FrankieProvider,