@frankieai/react 0.1.5 → 0.2.1

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.
@@ -0,0 +1,22 @@
1
+ "use client";
2
+ import { ArrowUpRight, Sparkles } from "lucide-react";
3
+ import { useFrankie } from "../../hooks/FrankieProvider.jsx";
4
+ import { useFrankieChat } from "../../hooks/useFrankieChat.js";
5
+
6
+ /** Reports server-confirmed skills for the current turn; never implies drafts are active. */
7
+ export function AgentContext() {
8
+ const { loading, configError } = useFrankie();
9
+ const { loadedSkills, busy, restoring, error } = useFrankieChat();
10
+ const status = configError || error ? "Unavailable" : loading || restoring ? "Connecting" : busy && loadedSkills === null ? "Checking" : loadedSkills?.length ? "Active" : loadedSkills ? "Tools only" : "Ready";
11
+ return <div className="frankie-agent-context-wrap">
12
+ <details className="frankie-agent-context">
13
+ <summary aria-label="Agent context"><Sparkles size={17} aria-hidden="true" /><span>Agent context</span><span className="frankie-context-count">{loadedSkills === null ? "Not checked" : `${loadedSkills.length} ${loadedSkills.length === 1 ? "skill" : "skills"}`}</span><span className="frankie-context-status" data-active={status === "Active"}><i aria-hidden="true" />{status}</span><ArrowUpRight size={15} aria-hidden="true" /></summary>
14
+ <div className="frankie-context-detail">
15
+ <strong>{loadedSkills === null ? "Skills are checked when you send a message" : loadedSkills.length ? `${loadedSkills.length} ${loadedSkills.length === 1 ? "skill" : "skills"} loaded` : "No skills loaded for this turn"}</strong>
16
+ {loadedSkills?.length ? <ul>{loadedSkills.map(skill => <li key={skill.id}><span>{skill.name}</span><small>v{skill.revision}</small></li>)}</ul> : <p>Frankie can use the tools approved for this workspace. Drafts and skills without available tools are not loaded.</p>}
17
+ <p>Published skills and updates are checked on every message. Only approved, available tools can run.</p>
18
+ </div>
19
+ </details>
20
+ <span className="frankie-sr-only" role="status">{loadedSkills === null ? status : `${loadedSkills.length} ${loadedSkills.length === 1 ? "skill" : "skills"} loaded. ${status}.`}</span>
21
+ </div>;
22
+ }
@@ -11,6 +11,7 @@ import { Avatar } from "../ui/avatar.js";
11
11
  import { Button } from "../ui/button.js";
12
12
  import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "../ui/card.js";
13
13
  import { ScrollArea } from "../ui/scroll-area.js";
14
+ import { AgentContext } from "./AgentContext.js";
14
15
  import { Composer } from "./Composer.js";
15
16
  import { MessageBubble } from "./MessageBubble.js";
16
17
  import { SuggestedPrompts } from "./SuggestedPrompts.js";
@@ -49,7 +50,7 @@ export function ChatPanel({
49
50
  }: ChatPanelProps) {
50
51
  const { config, configError, loading } = useFrankie();
51
52
  const theme = useFrankieTheme();
52
- const { messages, busy, error, send, stop, reset } = useFrankieChat();
53
+ const { messages, busy, restoring, error, send, stop, reset } = useFrankieChat();
53
54
  const scrollRef = useRef<HTMLDivElement>(null);
54
55
 
55
56
  const features = config?.features;
@@ -70,7 +71,8 @@ export function ChatPanel({
70
71
  return (
71
72
  <Card
72
73
  className={cn(
73
- "frankie-root",
74
+ "frankie-root frankie-chat-panel",
75
+ fill && "frankie-chat-panel-fill",
74
76
  fill ? "h-full w-full rounded-none border-0 shadow-none" : "h-[32rem] w-[22rem] max-w-[calc(100vw-2rem)]",
75
77
  mode === "dark" && "dark",
76
78
  className,
@@ -98,8 +100,9 @@ export function ChatPanel({
98
100
  </CardHeader>
99
101
  )}
100
102
 
101
- <CardContent>
102
- <ScrollArea ref={scrollRef} className="flex h-full flex-col gap-4 p-4" aria-live="polite">
103
+ <AgentContext />
104
+ <CardContent className="frankie-conversation-content">
105
+ <ScrollArea ref={scrollRef} className="frankie-transcript flex h-full flex-col gap-4 p-4" aria-live="polite">
103
106
  {configError ? (
104
107
  <div className="frankie-config-error" role="alert">
105
108
  <strong>Frankie couldn’t start</strong>
@@ -116,7 +119,7 @@ export function ChatPanel({
116
119
  </div>
117
120
  ))
118
121
  ) : (
119
- <div className="flex flex-col gap-4">
122
+ <div className="frankie-message-list flex flex-col gap-4">
120
123
  {messages.map((message) =>
121
124
  renderMessage ? (
122
125
  <div key={message.id}>{renderMessage(message)}</div>
@@ -136,14 +139,14 @@ export function ChatPanel({
136
139
  </ScrollArea>
137
140
  </CardContent>
138
141
 
139
- <CardFooter className="flex flex-col gap-2">
142
+ <CardFooter className="frankie-composer-footer flex flex-col gap-2">
140
143
  {error ? <p className="w-full text-xs text-red-600 dark:text-red-400">{error}</p> : null}
141
144
  <div className="w-full">
142
145
  <Composer
143
146
  onSend={send}
144
147
  onStop={stop}
145
148
  busy={busy}
146
- disabled={Boolean(configError)}
149
+ disabled={loading || restoring || Boolean(configError)}
147
150
  {...(placeholder ? { placeholder } : {})}
148
151
  {...(autoFocus ? { autoFocus } : {})}
149
152
  {...(features?.attachments.enabled ? { attachments: features.attachments } : {})}
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { ArrowUp, Paperclip, Square, X } from "lucide-react";
3
+ import { ArrowUp, Paperclip, Plus, Square, X } from "lucide-react";
4
4
  import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from "react";
5
5
  import type { ChatAttachment } from "@frankieai/core";
6
6
  import { Button } from "../ui/button.js";
@@ -107,7 +107,7 @@ export function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocu
107
107
  }, [busy, disabled, files, modelPicker?.value, onSend, value]);
108
108
 
109
109
  const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
110
- if (event.key === "Enter" && !event.shiftKey) {
110
+ if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
111
111
  event.preventDefault();
112
112
  submit();
113
113
  }
@@ -117,7 +117,7 @@ export function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocu
117
117
 
118
118
  return (
119
119
  <form
120
- className="flex flex-col gap-2"
120
+ className="frankie-composer flex flex-col gap-2"
121
121
  onSubmit={(event) => {
122
122
  event.preventDefault();
123
123
  submit();
@@ -147,7 +147,7 @@ export function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocu
147
147
 
148
148
  {fileError ? <p className="text-xs text-red-600 dark:text-red-400">{fileError}</p> : null}
149
149
 
150
- <div className="flex items-end gap-2">
150
+ <div className="frankie-composer-controls flex items-end gap-2">
151
151
  {canAttach ? (
152
152
  <>
153
153
  <input
@@ -170,7 +170,7 @@ export function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocu
170
170
  aria-label="Attach files"
171
171
  title="Attach files"
172
172
  >
173
- <Paperclip />
173
+ <Plus />
174
174
  </Button>
175
175
  </>
176
176
  ) : null}
@@ -236,3 +236,4 @@ function readAsBase64(file: File): Promise<string> {
236
236
  reader.readAsDataURL(file);
237
237
  });
238
238
  }
239
+
@@ -1,121 +1,92 @@
1
1
  "use client";
2
2
 
3
- import type { CSSProperties } from "react";
3
+ import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
4
+ import { History, Menu, Plus, Settings, X } from "lucide-react";
4
5
  import type { FrankieProviderProps } from "../../hooks/FrankieProvider.jsx";
5
- import { FrankieProvider, useFrankie } from "../../hooks/FrankieProvider.jsx";
6
+ import { FrankieProvider, useFrankie, useFrankieTheme } from "../../hooks/FrankieProvider.jsx";
6
7
  import { useFrankieChat } from "../../hooks/useFrankieChat.js";
8
+ import { themeToCssVars, resolveMode } from "../../lib/theme.js";
9
+ import type { ChatConversation } from "../../lib/client.js";
7
10
  import { FrankieInline } from "./FrankieInline.js";
8
11
  import type { ChatPanelProps } from "./ChatPanel.js";
9
12
 
10
- export interface FrankieChatPageProps
11
- extends Omit<FrankieProviderProps, "children">,
12
- Pick<
13
- ChatPanelProps,
14
- "header" | "footer" | "emptyState" | "renderMessage" | "renderTool" | "placeholder" | "autoFocus"
15
- > {
13
+ export interface FrankieChatPageProps extends Omit<FrankieProviderProps, "children">,
14
+ Pick<ChatPanelProps, "header" | "footer" | "emptyState" | "renderMessage" | "renderTool" | "placeholder" | "autoFocus"> {
16
15
  className?: string;
17
16
  style?: CSSProperties;
17
+ /** Include the built-in conversation sidebar. Defaults to true. */
18
+ showHistory?: boolean;
19
+ /** Optional host navigation, e.g. a Back to dashboard link. */
20
+ navigation?: React.ReactNode;
18
21
  }
19
22
 
20
23
  function PageEmptyState() {
21
24
  const { config } = useFrankie();
22
- const { send, busy } = useFrankieChat();
25
+ const { send, busy, restoring } = useFrankieChat();
23
26
  const name = config?.branding.name ?? "Frankie";
24
- const prompts = config?.suggestedPrompts?.slice(0, 3) ?? [];
25
-
26
- return (
27
- <section className="frankie-page-empty" aria-label="Start a conversation">
28
- <span className="frankie-page-avatar" aria-hidden>{name.trim().slice(0, 1).toUpperCase()}</span>
29
- <div>
30
- <p className="frankie-page-kicker">{name} assistant</p>
31
- <h1>What would you like to do?</h1>
32
- <p>{config?.greeting ?? "Ask a question or choose a task to get started."}</p>
33
- </div>
34
- {prompts.length ? (
35
- <div className="frankie-page-prompts">
36
- {prompts.map((prompt, index) => (
37
- <button key={prompt} type="button" disabled={busy} onClick={() => send(prompt)}>
38
- <span>{String(index + 1).padStart(2, "0")}</span>
39
- <strong>{prompt}</strong>
40
- <b aria-hidden>↗</b>
41
- </button>
42
- ))}
43
- </div>
44
- ) : null}
45
- </section>
46
- );
27
+ return <section className="frankie-page-empty" aria-label="Start a conversation">
28
+ <span className="frankie-page-avatar" aria-hidden>{name.trim().slice(0, 1).toUpperCase()}</span>
29
+ <div><p className="frankie-page-kicker">Your product assistant</p><h1>How can I help?</h1><p>{config?.greeting ?? "Ask a question or choose a task to get started."}</p></div>
30
+ <div className="frankie-page-prompts">{config?.suggestedPrompts?.slice(0, 3).map(prompt => <button key={prompt} type="button" disabled={busy || restoring} onClick={() => void send(prompt)}><strong>{prompt}</strong><b aria-hidden>↗</b></button>)}</div>
31
+ </section>;
47
32
  }
48
33
 
49
- function PageChrome({
50
- header,
51
- footer,
52
- emptyState,
53
- renderMessage,
54
- renderTool,
55
- placeholder,
56
- autoFocus,
57
- }: Pick<FrankieChatPageProps, "header" | "footer" | "emptyState" | "renderMessage" | "renderTool" | "placeholder" | "autoFocus">) {
58
- const { config } = useFrankie();
59
- const { reset } = useFrankieChat();
60
- const name = config?.branding.name ?? "Frankie";
61
-
62
- return (
63
- <>
64
- <header className="frankie-page-header">
65
- <div>
66
- <span className="frankie-page-status" aria-hidden />
67
- <strong>{name}</strong>
68
- </div>
69
- <button type="button" onClick={reset} aria-label="Start a new conversation">New chat</button>
70
- </header>
71
- <div className="frankie-page-canvas">
72
- <FrankieInline
73
- header={header ?? false}
74
- footer={footer}
75
- emptyState={emptyState ?? <PageEmptyState />}
76
- renderMessage={renderMessage}
77
- renderTool={renderTool}
78
- placeholder={placeholder}
79
- autoFocus={autoFocus}
80
- />
81
- </div>
82
- </>
83
- );
34
+ function PageChrome({ showHistory, navigation, currentId, onSelect, onNew, ...panel }: Pick<FrankieChatPageProps, "header" | "footer" | "emptyState" | "renderMessage" | "renderTool" | "placeholder" | "autoFocus" | "navigation"> & { showHistory: boolean; currentId: string; onSelect: (id: string) => void; onNew: () => void }) {
35
+ const { client, config } = useFrankie();
36
+ const { busy, messages } = useFrankieChat();
37
+ const theme = useFrankieTheme();
38
+ const [conversations, setConversations] = useState<ChatConversation[]>([]);
39
+ const [historyError, setHistoryError] = useState("");
40
+ const [historyLoading, setHistoryLoading] = useState(false);
41
+ const [drawerOpen, setDrawerOpen] = useState(false);
42
+ const [query, setQuery] = useState("");
43
+ const dialog = useRef<HTMLDialogElement>(null);
44
+ const menu = useRef<HTMLButtonElement>(null);
45
+ const requestVersion = useRef(0);
46
+ const refresh = useCallback(async () => {
47
+ if (!showHistory || !config || config.allowAnonymous) { setHistoryError(""); return; }
48
+ const version = ++requestVersion.current;
49
+ setHistoryLoading(true); setHistoryError("");
50
+ try { const items = await client.listConversations(); if (version === requestVersion.current) setConversations(items); }
51
+ catch { if (version === requestVersion.current) setHistoryError("History couldn’t load. Check your sign-in and try again."); }
52
+ finally { if (version === requestVersion.current) setHistoryLoading(false); }
53
+ }, [client, showHistory, config?.allowAnonymous]);
54
+ useEffect(() => { if (!busy) void refresh(); return () => { requestVersion.current++; }; }, [refresh, busy]);
55
+ useEffect(() => {
56
+ const node = dialog.current;
57
+ if (drawerOpen) { node?.showModal?.(); }
58
+ else { node?.close?.(); }
59
+ }, [drawerOpen]);
60
+ const close = () => { setDrawerOpen(false); menu.current?.focus(); };
61
+ const select = (id: string) => { if (!busy) { onSelect(id); close(); } };
62
+ const history = <>
63
+ <div className="frankie-history-heading"><strong>Conversations</strong><button type="button" className="frankie-mobile-only" aria-label="Close conversations" onClick={close}><X size={18} /></button></div>
64
+ <button type="button" className="frankie-new-chat" disabled={busy} onClick={() => { onNew(); close(); }}><Plus size={17} />New chat</button>
65
+ <label className="frankie-history-search"><span>Search conversations</span><input type="search" placeholder="Search chats…" value={query} onChange={event => setQuery(event.target.value)} /></label>
66
+ <nav className="frankie-history-list" aria-label="Conversations">{conversations.filter(item => item.title.toLowerCase().includes(query.toLowerCase())).map(item => <button type="button" key={item.id} aria-current={item.id === currentId ? "page" : undefined} disabled={busy} onClick={() => select(item.id)}><span>{item.title}</span><small>{new Date(item.updatedAt).toLocaleDateString()}</small></button>)}</nav>
67
+ {historyLoading ? <p role="status">Loading history…</p> : historyError ? <p role="status">{historyError}<button type="button" onClick={() => void refresh()}>Try again</button></p> : !conversations.length ? <p>{config?.allowAnonymous ? "Sign in to sync conversations across devices." : "Your conversations will appear here."}</p> : null}
68
+ {navigation ? <div className="frankie-host-navigation">{navigation}</div> : null}
69
+ </>;
70
+ return <div className={"frankie-page-shell" + (resolveMode(theme?.mode) === "dark" ? " dark" : "")} style={themeToCssVars(theme)}>
71
+ {showHistory ? <aside className="frankie-history-desktop">{history}</aside> : null}
72
+ <div className="frankie-page-main">
73
+ <header className="frankie-page-header"><div>{showHistory ? <button type="button" className="frankie-mobile-only" ref={menu} aria-label="Open conversations" aria-haspopup="dialog" aria-expanded={drawerOpen} onClick={() => setDrawerOpen(true)}><Menu size={20} /></button> : null}<strong className="frankie-thread-title">{conversations.find(item => item.id === currentId)?.title ?? messages.find(message => message.role === "user")?.text ?? "New chat"}</strong></div><div>
74
+ <button type="button" disabled={busy} onClick={onNew} aria-label="Start a new conversation"><Plus size={16} /><span>New chat</span></button>
75
+ <details className="frankie-chat-settings"><summary aria-label="Chat settings"><Settings size={19} /></summary><div><strong>Chat settings</strong><p>History syncs automatically for signed-in users.</p><button type="button" onClick={() => void refresh()} disabled={historyLoading || !showHistory}><History size={16} />Refresh history</button><small>Skills are checked at the start of each message.</small></div></details>
76
+ </div></header>
77
+ <div className="frankie-page-canvas"><FrankieInline {...panel} header={panel.header ?? false} emptyState={panel.emptyState ?? <PageEmptyState />} /></div>
78
+ </div>
79
+ {showHistory ? <dialog ref={dialog} className="frankie-history-dialog frankie-root" aria-label="Chat conversations" style={themeToCssVars(theme)} onCancel={close} onClick={event => { if (event.target === event.currentTarget) close(); }}><aside>{history}</aside></dialog> : null}
80
+ </div>;
84
81
  }
85
82
 
86
- /**
87
- * A complete, polished chat route matching Frankie's own conversation canvas.
88
- * The shell, header, empty state and composer are all scoped under
89
- * `.frankie-root`, so host application styles remain untouched.
90
- */
91
- export function FrankieChatPage({
92
- className,
93
- style,
94
- header,
95
- footer,
96
- emptyState,
97
- renderMessage,
98
- renderTool,
99
- placeholder,
100
- autoFocus,
101
- ...providerProps
102
- }: FrankieChatPageProps) {
103
- return (
104
- <main
105
- className={["frankie-root", "frankie-chat-page", className].filter(Boolean).join(" ")}
106
- style={{ height: "100dvh", minHeight: "100dvh", width: "100%", ...style }}
107
- >
108
- <FrankieProvider {...providerProps}>
109
- <PageChrome
110
- header={header}
111
- footer={footer}
112
- emptyState={emptyState}
113
- renderMessage={renderMessage}
114
- renderTool={renderTool}
115
- placeholder={placeholder}
116
- autoFocus={autoFocus}
117
- />
118
- </FrankieProvider>
119
- </main>
120
- );
83
+ /** Reusable product chat with one shared conversation state and scoped styling. */
84
+ export function FrankieChatPage({ className, style, header, footer, emptyState, renderMessage, renderTool, placeholder, autoFocus, showHistory = true, navigation, ...provider }: FrankieChatPageProps) {
85
+ const [id, setId] = useState(() => provider.conversationId ?? crypto.randomUUID());
86
+ const onNew = () => setId(crypto.randomUUID());
87
+ return <div className={["frankie-root", "frankie-chat-page", className].filter(Boolean).join(" ")} style={{ height: "100%", minHeight: "min(36rem, 100dvh)", width: "100%", ...style }}>
88
+ <FrankieProvider {...provider} conversationId={id} storageNamespace={(provider.storageNamespace ?? "frankie." + provider.publishableKey) + "." + id}>
89
+ <PageChrome showHistory={showHistory} navigation={navigation} currentId={id} onSelect={setId} onNew={onNew} header={header} footer={footer} emptyState={emptyState} renderMessage={renderMessage} renderTool={renderTool} placeholder={placeholder} autoFocus={autoFocus} />
90
+ </FrankieProvider>
91
+ </div>;
121
92
  }
@@ -13,6 +13,8 @@ export interface FrankieWidgetProps extends ChatPanelProps {
13
13
  /** Distance from the viewport edges, in pixels. */
14
14
  offset?: number;
15
15
  defaultOpen?: boolean;
16
+ /** Host overlays should sit above the assistant. Override for your layer system. */
17
+ zIndex?: number;
16
18
  /** Controlled mode: pass both to drive open state from the host app. */
17
19
  open?: boolean;
18
20
  onOpenChange?: (open: boolean) => void;
@@ -30,6 +32,7 @@ export function FrankieWidget({
30
32
  position = "bottom-right",
31
33
  offset = 24,
32
34
  defaultOpen = false,
35
+ zIndex = 20,
33
36
  open: controlledOpen,
34
37
  onOpenChange,
35
38
  renderLauncher,
@@ -63,11 +66,12 @@ export function FrankieWidget({
63
66
  return (
64
67
  <div
65
68
  className={cn("frankie-root frankie-widget", mode === "dark" && "dark")}
66
- style={{ ...themeToCssVars(theme), position: "fixed", bottom: offset, ...side, zIndex: 2147483000 }}
69
+ style={{ ...themeToCssVars(theme), position: "fixed", bottom: offset, ...side, zIndex, pointerEvents: "none" }}
67
70
  data-frankie-mode={mode}
68
71
  >
69
72
  <div className={cn("flex flex-col gap-3", position === "bottom-left" ? "items-start" : "items-end")}>
70
- {open ? <ChatPanel {...panelProps} onClose={() => setOpen(false)} autoFocus /> : null}
73
+ {open ? <div style={{ pointerEvents: "auto", maxHeight: "calc(100dvh - 100px)", overflow: "hidden" }}><ChatPanel {...panelProps} className={cn("frankie-widget-panel", panelProps.className)} onClose={() => setOpen(false)} autoFocus /></div> : null}
74
+ <div style={{ pointerEvents: "auto" }}>
71
75
  {renderLauncher ? (
72
76
  renderLauncher({ open, toggle: () => setOpen(!open), label })
73
77
  ) : (
@@ -81,7 +85,7 @@ export function FrankieWidget({
81
85
  >
82
86
  {open ? <X className="size-5" /> : <MessageCircle className="size-5" />}
83
87
  </Button>
84
- )}
88
+ )}</div>
85
89
  </div>
86
90
  </div>
87
91
  );
@@ -26,7 +26,7 @@ export function MessageBubble({
26
26
  }: MessageBubbleProps) {
27
27
  if (message.role === "user") {
28
28
  return (
29
- <div className="flex flex-col items-end gap-1">
29
+ <div className="frankie-user-message flex flex-col items-end gap-1">
30
30
  {message.attachments?.length ? (
31
31
  <div className="flex max-w-[85%] flex-wrap justify-end gap-1">
32
32
  {message.attachments.map((file, index) => (
@@ -42,7 +42,7 @@ export function MessageBubble({
42
42
  ) : null}
43
43
  {message.text ? (
44
44
  <div className="flex max-w-[85%] items-end gap-1">
45
- <div className="rounded-[var(--frankie-radius)] bg-primary px-3 py-2 text-sm text-primary-foreground">{message.text}</div>
45
+ <div className="frankie-user-bubble rounded-[var(--frankie-radius)] bg-primary px-3 py-2 text-sm text-primary-foreground">{message.text}</div>
46
46
  <MessageCopyButton text={message.text} />
47
47
  </div>
48
48
  ) : null}
@@ -53,12 +53,12 @@ export function MessageBubble({
53
53
  const empty = message.text.length === 0 && message.tools.length === 0;
54
54
 
55
55
  return (
56
- <div className="flex gap-2">
56
+ <div className="frankie-assistant-message flex gap-2">
57
57
  <div className="frankie-assistant-actions mt-0.5 flex shrink-0 flex-col items-center gap-1">
58
58
  <Avatar {...(avatarUrl ? { src: avatarUrl } : {})} name={assistantName} />
59
59
  {message.text ? <MessageCopyButton text={message.text} /> : null}
60
60
  </div>
61
- <div className="min-w-0 max-w-[85%]">
61
+ <div className="frankie-assistant-content min-w-0 max-w-[85%]">
62
62
  {message.status === "error" ? (
63
63
  <p className="rounded-[var(--frankie-radius)] bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-400">
64
64
  {message.error}
@@ -90,7 +90,8 @@ function MessageCopyButton({ text }: { text: string }) {
90
90
 
91
91
  export function TypingDots() {
92
92
  return (
93
- <span className="inline-flex items-center gap-1 py-2" role="status" aria-label="Thinking">
93
+ <span className="frankie-thinking inline-flex items-center gap-1 py-2" role="status" aria-label="Thinking">
94
+ <span className="frankie-thinking-label">Thinking</span>
94
95
  {[0, 1, 2].map((index) => (
95
96
  <span
96
97
  key={index}
@@ -101,3 +102,4 @@ export function TypingDots() {
101
102
  </span>
102
103
  );
103
104
  }
105
+
@@ -1,8 +1,9 @@
1
1
  "use client";
2
2
 
3
- import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
3
+ import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
4
4
  import type { PublicWidgetConfig, WidgetTheme } from "@frankieai/core";
5
5
  import { FrankieClient, type FrankieClientOptions, type PageContext } from "../lib/client.js";
6
+ import { FrankieChatContext, useFrankieChatState } from "./useFrankieChat.js";
6
7
 
7
8
  export interface FrankieProviderProps extends FrankieClientOptions {
8
9
  children: ReactNode;
@@ -29,10 +30,12 @@ export function FrankieProvider({
29
30
  config: providedConfig,
30
31
  ...clientOptions
31
32
  }: FrankieProviderProps) {
33
+ const tokenCallback = useRef(clientOptions.getUserToken);
34
+ tokenCallback.current = clientOptions.getUserToken;
32
35
  const client = useMemo(
33
- () => new FrankieClient(clientOptions),
36
+ () => new FrankieClient({ ...clientOptions, ...(clientOptions.getUserToken ? { getUserToken: () => tokenCallback.current?.() } : {}) }),
34
37
  // eslint-disable-next-line react-hooks/exhaustive-deps
35
- [clientOptions.baseUrl, clientOptions.publishableKey, clientOptions.storageNamespace, clientOptions.conversationId],
38
+ [clientOptions.baseUrl, clientOptions.publishableKey, clientOptions.storageNamespace, clientOptions.conversationId, clientOptions.persist, clientOptions.fetchImpl, Boolean(clientOptions.getUserToken)],
36
39
  );
37
40
 
38
41
  const [config, setConfig] = useState<PublicWidgetConfig | undefined>(providedConfig);
@@ -71,7 +74,12 @@ export function FrankieProvider({
71
74
  [client, config, configError, loading, context, theme],
72
75
  );
73
76
 
74
- return <FrankieContext.Provider value={value}>{children}</FrankieContext.Provider>;
77
+ return <FrankieContext.Provider value={value}><ConversationState key={client.namespace + ":" + (client.conversationId ?? "")}>{children}</ConversationState></FrankieContext.Provider>;
78
+ }
79
+
80
+ function ConversationState({ children }: { children: ReactNode }) {
81
+ const chat = useFrankieChatState();
82
+ return <FrankieChatContext.Provider value={chat}>{children}</FrankieChatContext.Provider>;
75
83
  }
76
84
 
77
85
  export function useFrankie(): FrankieContextValue {
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useEffect, useRef, useState } from "react";
4
- import type { ChatAttachment } from "@frankieai/core";
3
+ import { createContext, useContext, useCallback, useEffect, useRef, useState } from "react";
4
+ import type { ChatAttachment, LoadedSkill } from "@frankieai/core";
5
5
  import { FrankieApiError } from "../lib/client.js";
6
6
  import {
7
7
  applyEvent,
@@ -19,6 +19,8 @@ export interface SendOptions {
19
19
  }
20
20
 
21
21
  export interface UseFrankieChatResult {
22
+ loadedSkills: LoadedSkill[] | null;
23
+ restoring: boolean;
22
24
  messages: ChatMessage[];
23
25
  /** True from the moment a message is sent until the turn ends. */
24
26
  busy: boolean;
@@ -34,12 +36,20 @@ export interface UseFrankieChatResult {
34
36
  let counter = 0;
35
37
  const nextId = (prefix: string) => `${prefix}_${Date.now().toString(36)}_${(counter += 1)}`;
36
38
 
39
+ export const FrankieChatContext = createContext<UseFrankieChatResult | null>(null);
37
40
  export function useFrankieChat(): UseFrankieChatResult {
41
+ const value = useContext(FrankieChatContext);
42
+ if (!value) throw new Error("useFrankieChat requires FrankieProvider.");
43
+ return value;
44
+ }
45
+
46
+ export function useFrankieChatState(): UseFrankieChatResult {
38
47
  const { client, context } = useFrankie();
39
48
  const [messages, setMessages] = useState<ChatMessage[]>([]);
40
49
  const [busy, setBusy] = useState(false);
41
50
  const [error, setError] = useState<string | undefined>(undefined);
42
51
  const [restored, setRestored] = useState(false);
52
+ const [loadedSkills, setLoadedSkills] = useState<LoadedSkill[] | null>(null);
43
53
  const abortRef = useRef<AbortController | null>(null);
44
54
  const mounted = useRef(true);
45
55
  // State updates can be batched while a stream is arriving. Keep the latest
@@ -82,7 +92,7 @@ export function useFrankieChat(): UseFrankieChatResult {
82
92
  setMessages(restoredMessages);
83
93
  }
84
94
  } catch {
85
- /* A new conversation does not exist remotely until its first message. */
95
+ if (active) setError("Could not restore this conversation. Check your sign-in and connection, then try opening it again.");
86
96
  } finally {
87
97
  if (active) setRestored(true);
88
98
  }
@@ -117,12 +127,13 @@ export function useFrankieChat(): UseFrankieChatResult {
117
127
  async (text: string, opts?: SendOptions) => {
118
128
  const trimmed = text.trim();
119
129
  const attachments = opts?.attachments ?? [];
120
- if ((!trimmed && attachments.length === 0) || busy) return;
130
+ if ((!trimmed && attachments.length === 0) || abortRef.current || !restored) return;
121
131
  // The server needs a message; an attachment-only turn gets a stand-in.
122
132
  const outgoing = trimmed || "Please take a look at the attached file(s).";
123
133
 
124
134
  const assistantId = nextId("a");
125
135
  setError(undefined);
136
+ setLoadedSkills(null);
126
137
  setBusy(true);
127
138
  const next = [
128
139
  ...messagesRef.current,
@@ -151,6 +162,7 @@ export function useFrankieChat(): UseFrankieChatResult {
151
162
  signal: abort.signal,
152
163
  },
153
164
  (event) => {
165
+ if (event.type === "skills_loaded") { setLoadedSkills(event.skills); return; }
154
166
  if (event.type === "ping" || event.type === "session") return;
155
167
  if (event.type === "text_delta") receivedText = true;
156
168
  if (event.type === "tool_start" || event.type === "tool_end") receivedToolActivity = true;
@@ -202,7 +214,7 @@ export function useFrankieChat(): UseFrankieChatResult {
202
214
  abortRef.current = null;
203
215
  }
204
216
  },
205
- [busy, client, context, patchAssistant],
217
+ [busy, client, context, patchAssistant, restored],
206
218
  );
207
219
 
208
220
  const stop = useCallback(() => abortRef.current?.abort(), []);
@@ -215,6 +227,7 @@ export function useFrankieChat(): UseFrankieChatResult {
215
227
  setError(undefined);
216
228
  setBusy(false);
217
229
  setRestored(true);
230
+ setLoadedSkills(null);
218
231
  try {
219
232
  if (client.persist) window.localStorage.removeItem(`${client.namespace}.messages`);
220
233
  } catch {
@@ -222,7 +235,7 @@ export function useFrankieChat(): UseFrankieChatResult {
222
235
  }
223
236
  }, [client]);
224
237
 
225
- return { messages, busy, ...(error ? { error } : {}), send, stop, reset };
238
+ return { messages, busy, loadedSkills, restoring: !restored, ...(error ? { error } : {}), send, stop, reset };
226
239
  }
227
240
 
228
241