@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.
@@ -1,24 +1,53 @@
1
1
  "use client";
2
2
 
3
- import { ArrowUp, Square } from "lucide-react";
3
+ import { ArrowUp, Paperclip, Square, X } from "lucide-react";
4
4
  import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from "react";
5
+ import type { ChatAttachment } from "@frankieai/core";
5
6
  import { Button } from "../ui/button.js";
6
7
  import { Textarea } from "../ui/textarea.js";
7
8
 
9
+ export interface ComposerAttachmentsConfig {
10
+ enabled: boolean;
11
+ /** Accepted MIME patterns, e.g. "image/*". */
12
+ accept: string[];
13
+ maxCount: number;
14
+ maxMb: number;
15
+ }
16
+
17
+ export interface ComposerModelPicker {
18
+ models: { id: string; label: string }[];
19
+ value?: string;
20
+ onChange: (id: string) => void;
21
+ }
22
+
8
23
  export interface ComposerProps {
9
- onSend: (text: string) => void;
24
+ onSend: (text: string, opts?: { attachments?: ChatAttachment[]; model?: string }) => void;
10
25
  onStop: () => void;
11
26
  busy: boolean;
12
27
  placeholder?: string;
13
28
  disabled?: boolean;
14
29
  autoFocus?: boolean;
30
+ /** Enables the attach button when provided and `enabled`. */
31
+ attachments?: ComposerAttachmentsConfig;
32
+ /** Enables a model dropdown when provided with at least one model. */
33
+ modelPicker?: ComposerModelPicker;
34
+ }
35
+
36
+ interface PickedFile extends ChatAttachment {
37
+ id: string;
15
38
  }
16
39
 
17
40
  const MAX_ROWS_PX = 140;
18
41
 
19
- export function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocus }: ComposerProps) {
42
+ export function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocus, attachments, modelPicker }: ComposerProps) {
20
43
  const [value, setValue] = useState("");
44
+ const [files, setFiles] = useState<PickedFile[]>([]);
45
+ const [fileError, setFileError] = useState<string | undefined>(undefined);
21
46
  const textareaRef = useRef<HTMLTextAreaElement>(null);
47
+ const fileInputRef = useRef<HTMLInputElement>(null);
48
+
49
+ const canAttach = Boolean(attachments?.enabled);
50
+ const models = modelPicker?.models ?? [];
22
51
 
23
52
  // Grow with the content up to a cap, then scroll.
24
53
  useEffect(() => {
@@ -28,12 +57,54 @@ export function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocu
28
57
  node.style.height = `${Math.min(node.scrollHeight, MAX_ROWS_PX)}px`;
29
58
  }, [value]);
30
59
 
60
+ const addFiles = useCallback(
61
+ async (list: FileList | null) => {
62
+ if (!list || !attachments) return;
63
+ setFileError(undefined);
64
+ const room = attachments.maxCount - files.length;
65
+ const chosen = Array.from(list).slice(0, Math.max(0, room));
66
+ if (Array.from(list).length > room) setFileError(`You can attach up to ${attachments.maxCount} file(s).`);
67
+
68
+ const next: PickedFile[] = [];
69
+ for (const file of chosen) {
70
+ if (!mimeAccepted(file.type || "application/octet-stream", attachments.accept)) {
71
+ setFileError(`Files of type ${file.type || "unknown"} aren't allowed.`);
72
+ continue;
73
+ }
74
+ if (file.size > attachments.maxMb * 1024 * 1024) {
75
+ setFileError(`Each file must be under ${attachments.maxMb} MB.`);
76
+ continue;
77
+ }
78
+ try {
79
+ next.push({
80
+ id: `${file.name}-${file.size}-${Math.random().toString(36).slice(2, 7)}`,
81
+ name: file.name,
82
+ mimeType: file.type || "application/octet-stream",
83
+ data: await readAsBase64(file),
84
+ });
85
+ } catch {
86
+ setFileError("Couldn't read that file.");
87
+ }
88
+ }
89
+ if (next.length) setFiles((current) => [...current, ...next]);
90
+ },
91
+ [attachments, files.length],
92
+ );
93
+
94
+ const removeFile = (id: string) => setFiles((current) => current.filter((file) => file.id !== id));
95
+
31
96
  const submit = useCallback(() => {
32
97
  const text = value.trim();
33
- if (!text || busy || disabled) return;
98
+ if ((!text && files.length === 0) || busy || disabled) return;
34
99
  setValue("");
35
- onSend(text);
36
- }, [busy, disabled, onSend, value]);
100
+ const attached = files.map(({ name, mimeType, data }) => ({ name, mimeType, data }));
101
+ setFiles([]);
102
+ setFileError(undefined);
103
+ onSend(text, {
104
+ ...(attached.length ? { attachments: attached } : {}),
105
+ ...(modelPicker?.value ? { model: modelPicker.value } : {}),
106
+ });
107
+ }, [busy, disabled, files, modelPicker?.value, onSend, value]);
37
108
 
38
109
  const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
39
110
  if (event.key === "Enter" && !event.shiftKey) {
@@ -42,34 +113,126 @@ export function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocu
42
113
  }
43
114
  };
44
115
 
116
+ const nothingToSend = value.trim().length === 0 && files.length === 0;
117
+
45
118
  return (
46
119
  <form
47
- className="flex items-end gap-2"
120
+ className="flex flex-col gap-2"
48
121
  onSubmit={(event) => {
49
122
  event.preventDefault();
50
123
  submit();
51
124
  }}
52
125
  >
53
- <Textarea
54
- ref={textareaRef}
55
- rows={1}
56
- value={value}
57
- disabled={disabled}
58
- autoFocus={autoFocus}
59
- onChange={(event) => setValue(event.target.value)}
60
- onKeyDown={onKeyDown}
61
- placeholder={placeholder ?? "Ask anything…"}
62
- aria-label="Message"
63
- />
64
- {busy ? (
65
- <Button type="button" size="icon" variant="outline" onClick={onStop} aria-label="Stop">
66
- <Square />
67
- </Button>
68
- ) : (
69
- <Button type="submit" size="icon" disabled={disabled || value.trim().length === 0} aria-label="Send">
70
- <ArrowUp />
71
- </Button>
72
- )}
126
+ {files.length > 0 ? (
127
+ <div className="flex flex-wrap gap-2">
128
+ {files.map((file) => (
129
+ <span
130
+ key={file.id}
131
+ 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"
132
+ >
133
+ <Paperclip className="size-3 shrink-0 opacity-70" />
134
+ <span className="truncate">{file.name}</span>
135
+ <button
136
+ type="button"
137
+ onClick={() => removeFile(file.id)}
138
+ aria-label={`Remove ${file.name}`}
139
+ className="frankie-focus shrink-0 opacity-60 hover:opacity-100"
140
+ >
141
+ <X className="size-3" />
142
+ </button>
143
+ </span>
144
+ ))}
145
+ </div>
146
+ ) : null}
147
+
148
+ {fileError ? <p className="text-xs text-red-600 dark:text-red-400">{fileError}</p> : null}
149
+
150
+ <div className="flex items-end gap-2">
151
+ {canAttach ? (
152
+ <>
153
+ <input
154
+ ref={fileInputRef}
155
+ type="file"
156
+ multiple
157
+ hidden
158
+ accept={attachments?.accept.join(",")}
159
+ onChange={(event) => {
160
+ void addFiles(event.target.files);
161
+ event.target.value = "";
162
+ }}
163
+ />
164
+ <Button
165
+ type="button"
166
+ size="icon"
167
+ variant="outline"
168
+ disabled={disabled || files.length >= (attachments?.maxCount ?? 0)}
169
+ onClick={() => fileInputRef.current?.click()}
170
+ aria-label="Attach files"
171
+ title="Attach files"
172
+ >
173
+ <Paperclip />
174
+ </Button>
175
+ </>
176
+ ) : null}
177
+
178
+ <Textarea
179
+ ref={textareaRef}
180
+ rows={1}
181
+ value={value}
182
+ disabled={disabled}
183
+ autoFocus={autoFocus}
184
+ onChange={(event) => setValue(event.target.value)}
185
+ onKeyDown={onKeyDown}
186
+ placeholder={placeholder ?? "Ask anything…"}
187
+ aria-label="Message"
188
+ />
189
+
190
+ {models.length > 0 ? (
191
+ <select
192
+ value={modelPicker?.value ?? models[0]?.id}
193
+ onChange={(event) => modelPicker?.onChange(event.target.value)}
194
+ disabled={disabled}
195
+ aria-label="Model"
196
+ className="frankie-focus h-9 max-w-[8rem] rounded-[var(--frankie-radius)] border border-border bg-background px-2 text-sm"
197
+ >
198
+ {models.map((model) => (
199
+ <option key={model.id} value={model.id}>
200
+ {model.label}
201
+ </option>
202
+ ))}
203
+ </select>
204
+ ) : null}
205
+
206
+ {busy ? (
207
+ <Button type="button" size="icon" variant="outline" onClick={onStop} aria-label="Stop">
208
+ <Square />
209
+ </Button>
210
+ ) : (
211
+ <Button type="submit" size="icon" disabled={disabled || nothingToSend} aria-label="Send">
212
+ <ArrowUp />
213
+ </Button>
214
+ )}
215
+ </div>
73
216
  </form>
74
217
  );
75
218
  }
219
+
220
+ function mimeAccepted(mimeType: string, accept: string[]): boolean {
221
+ return accept.some((pattern) =>
222
+ pattern.endsWith("/*") ? mimeType.startsWith(pattern.slice(0, -1)) : pattern === mimeType,
223
+ );
224
+ }
225
+
226
+ /** Read a File to a base64 string (no data-URL prefix), for the wire protocol. */
227
+ function readAsBase64(file: File): Promise<string> {
228
+ return new Promise((resolve, reject) => {
229
+ const reader = new FileReader();
230
+ reader.onload = () => {
231
+ const result = String(reader.result);
232
+ const comma = result.indexOf(",");
233
+ resolve(comma >= 0 ? result.slice(comma + 1) : result);
234
+ };
235
+ reader.onerror = () => reject(reader.error);
236
+ reader.readAsDataURL(file);
237
+ });
238
+ }
@@ -0,0 +1,53 @@
1
+ "use client";
2
+
3
+ import type { CSSProperties } from "react";
4
+ import type { FrankieProviderProps } from "../../hooks/FrankieProvider.jsx";
5
+ import { FrankieProvider } from "../../hooks/FrankieProvider.jsx";
6
+ import { FrankieInline } from "./FrankieInline.js";
7
+ import type { ChatPanelProps } from "./ChatPanel.js";
8
+
9
+ /**
10
+ * A complete chat route in one component. This is the simplest way to add a
11
+ * dedicated /chat page to an app while keeping the existing floating widget.
12
+ */
13
+ export interface FrankieChatPageProps
14
+ extends Omit<FrankieProviderProps, "children">,
15
+ Pick<
16
+ ChatPanelProps,
17
+ "header" | "footer" | "emptyState" | "renderMessage" | "renderTool" | "placeholder" | "autoFocus"
18
+ > {
19
+ className?: string;
20
+ style?: CSSProperties;
21
+ }
22
+
23
+ export function FrankieChatPage({
24
+ className,
25
+ style,
26
+ header,
27
+ footer,
28
+ emptyState,
29
+ renderMessage,
30
+ renderTool,
31
+ placeholder,
32
+ autoFocus,
33
+ ...providerProps
34
+ }: FrankieChatPageProps) {
35
+ return (
36
+ <main
37
+ className={className}
38
+ style={{ height: "100dvh", minHeight: "100dvh", width: "100%", ...style }}
39
+ >
40
+ <FrankieProvider {...providerProps}>
41
+ <FrankieInline
42
+ header={header}
43
+ footer={footer}
44
+ emptyState={emptyState}
45
+ renderMessage={renderMessage}
46
+ renderTool={renderTool}
47
+ placeholder={placeholder}
48
+ autoFocus={autoFocus}
49
+ />
50
+ </FrankieProvider>
51
+ </main>
52
+ );
53
+ }
@@ -1,6 +1,8 @@
1
1
  "use client";
2
2
 
3
- import type { ReactNode } from "react";
3
+ import { Check, Copy, Paperclip } from "lucide-react";
4
+ import "./message-actions.css";
5
+ import { useState, type ReactNode } from "react";
4
6
  import { cn } from "../../lib/utils.js";
5
7
  import type { ChatMessage, ToolActivity } from "../../lib/messages.js";
6
8
  import { Avatar } from "../ui/avatar.js";
@@ -24,10 +26,26 @@ export function MessageBubble({
24
26
  }: MessageBubbleProps) {
25
27
  if (message.role === "user") {
26
28
  return (
27
- <div className="flex justify-end">
28
- <div className="max-w-[85%] rounded-[var(--frankie-radius)] bg-primary px-3 py-2 text-sm text-primary-foreground">
29
- {message.text}
30
- </div>
29
+ <div className="flex flex-col items-end gap-1">
30
+ {message.attachments?.length ? (
31
+ <div className="flex max-w-[85%] flex-wrap justify-end gap-1">
32
+ {message.attachments.map((file, index) => (
33
+ <span
34
+ key={`${file.name}-${index}`}
35
+ 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"
36
+ >
37
+ <Paperclip className="size-3 shrink-0 opacity-70" />
38
+ <span className="truncate">{file.name}</span>
39
+ </span>
40
+ ))}
41
+ </div>
42
+ ) : null}
43
+ {message.text ? (
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>
46
+ <MessageCopyButton text={message.text} />
47
+ </div>
48
+ ) : null}
31
49
  </div>
32
50
  );
33
51
  }
@@ -36,7 +54,10 @@ export function MessageBubble({
36
54
 
37
55
  return (
38
56
  <div className="flex gap-2">
39
- <Avatar {...(avatarUrl ? { src: avatarUrl } : {})} name={assistantName} className="mt-0.5" />
57
+ <div className="frankie-assistant-actions mt-0.5 flex shrink-0 flex-col items-center gap-1">
58
+ <Avatar {...(avatarUrl ? { src: avatarUrl } : {})} name={assistantName} />
59
+ {message.text ? <MessageCopyButton text={message.text} /> : null}
60
+ </div>
40
61
  <div className="min-w-0 max-w-[85%]">
41
62
  {message.status === "error" ? (
42
63
  <p className="rounded-[var(--frankie-radius)] bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-400">
@@ -55,6 +76,18 @@ export function MessageBubble({
55
76
  );
56
77
  }
57
78
 
79
+ function MessageCopyButton({ text }: { text: string }) {
80
+ const [copied, setCopied] = useState(false);
81
+ const copy = async () => {
82
+ try {
83
+ await navigator.clipboard.writeText(text);
84
+ setCopied(true);
85
+ window.setTimeout(() => setCopied(false), 1500);
86
+ } catch { /* Clipboard access can be disabled by an embedding host. */ }
87
+ };
88
+ return <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"}>{copied ? <Check className="size-3.5" aria-hidden="true" /> : <Copy className="size-3.5" aria-hidden="true" />}</button>;
89
+ }
90
+
58
91
  export function TypingDots() {
59
92
  return (
60
93
  <span className="inline-flex items-center gap-1 py-2" role="status" aria-label="Thinking">
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { Check, Loader2, ShieldAlert, X } from "lucide-react";
3
+ import { Check, ChevronDown, Loader2, ShieldAlert, X } from "lucide-react";
4
4
  import { Badge } from "../ui/badge.js";
5
5
  import type { ToolActivity } from "../../lib/messages.js";
6
6
 
@@ -13,12 +13,16 @@ export interface ToolActivityListProps {
13
13
  /** The "what the assistant is doing" strip under a streaming answer. */
14
14
  export function ToolActivityList({ tools, renderTool }: ToolActivityListProps) {
15
15
  if (tools.length === 0) return null;
16
+ const running = tools.some((tool) => tool.status === "running");
16
17
  return (
17
- <ul className="mt-2 flex flex-col gap-1">
18
- {tools.map((tool) => (
19
- <li key={tool.id}>{renderTool ? renderTool(tool) : <ToolActivityRow tool={tool} />}</li>
20
- ))}
21
- </ul>
18
+ <details className="frankie-tool-activity mt-2" open={running}>
19
+ <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">
20
+ <ChevronDown className="frankie-tool-chevron size-3.5 transition-transform" aria-hidden="true" />
21
+ <span>{running ? "Working" : "Activity"}</span>
22
+ <span className="text-[11px]">· {tools.length} tool call{tools.length === 1 ? "" : "s"}</span>
23
+ </summary>
24
+ <ul className="mt-1 flex flex-col gap-1 border-l border-border pl-2">{tools.map((tool) => <li key={tool.id}>{renderTool ? renderTool(tool) : <ToolActivityRow tool={tool} />}</li>)}</ul>
25
+ </details>
22
26
  );
23
27
  }
24
28
 
@@ -0,0 +1,13 @@
1
+ .frankie-root .frankie-message-copy { opacity: .72; }
2
+ .frankie-root .frankie-message-copy:hover { opacity: 1; }
3
+ .frankie-root .frankie-tool-activity[open] .frankie-tool-chevron { transform: rotate(180deg); }
4
+
5
+ .frankie-root .frankie-assistant-actions .frankie-message-copy {
6
+ width: 28px; height: 28px; min-width: 28px; min-height: 28px;
7
+ border: 0; background: transparent; opacity: .46;
8
+ }
9
+ .frankie-root .frankie-assistant-actions .frankie-message-copy:hover,
10
+ .frankie-root .frankie-assistant-actions .frankie-message-copy:focus-visible { opacity: 1; }
11
+ .frankie-root .frankie-tool-activity { max-width: 100%; }
12
+ .frankie-root .frankie-tool-activity[open] > summary { color: var(--frankie-foreground); }
13
+ .frankie-root .frankie-tool-activity li .frankie-badge { max-width: 100%; }
@@ -6,14 +6,8 @@ import { FrankieClient, type FrankieClientOptions, type PageContext } from "../l
6
6
 
7
7
  export interface FrankieProviderProps extends FrankieClientOptions {
8
8
  children: ReactNode;
9
- /**
10
- * Page context sent with every turn — current route, plan tier, whatever the
11
- * agent should know. Treated as data by the server, never as instructions.
12
- */
13
9
  context?: PageContext;
14
- /** Overrides for the server-side theme, so a host can restyle without a redeploy. */
15
10
  theme?: WidgetTheme;
16
- /** Skip the `/v1/config` fetch by supplying the config yourself. */
17
11
  config?: PublicWidgetConfig;
18
12
  }
19
13
 
@@ -37,9 +31,8 @@ export function FrankieProvider({
37
31
  }: FrankieProviderProps) {
38
32
  const client = useMemo(
39
33
  () => new FrankieClient(clientOptions),
40
- // The client holds session state; rebuild it only when the endpoint changes.
41
34
  // eslint-disable-next-line react-hooks/exhaustive-deps
42
- [clientOptions.baseUrl, clientOptions.publishableKey],
35
+ [clientOptions.baseUrl, clientOptions.publishableKey, clientOptions.storageNamespace, clientOptions.conversationId],
43
36
  );
44
37
 
45
38
  const [config, setConfig] = useState<PublicWidgetConfig | undefined>(providedConfig);
@@ -87,7 +80,6 @@ export function useFrankie(): FrankieContextValue {
87
80
  return value;
88
81
  }
89
82
 
90
- /** The theme actually in effect: provider override wins over server config. */
91
83
  export function useFrankieTheme(): WidgetTheme | undefined {
92
84
  const { config, theme } = useFrankie();
93
85
  return useMemo(() => ({ ...config?.theme, ...theme }), [config?.theme, theme]);
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useCallback, useEffect, useRef, useState } from "react";
4
+ import type { ChatAttachment } from "@frankieai/core";
4
5
  import { FrankieApiError } from "../lib/client.js";
5
6
  import {
6
7
  applyEvent,
@@ -11,13 +12,19 @@ import {
11
12
  } from "../lib/messages.js";
12
13
  import { useFrankie } from "./FrankieProvider.jsx";
13
14
 
15
+ /** Extra inputs a turn can carry: attached files and a chosen model. */
16
+ export interface SendOptions {
17
+ attachments?: ChatAttachment[];
18
+ model?: string;
19
+ }
20
+
14
21
  export interface UseFrankieChatResult {
15
22
  messages: ChatMessage[];
16
23
  /** True from the moment a message is sent until the turn ends. */
17
24
  busy: boolean;
18
25
  /** Set when the turn failed outright (network, auth, rate limit). */
19
26
  error?: string;
20
- send: (text: string) => Promise<void>;
27
+ send: (text: string, opts?: SendOptions) => Promise<void>;
21
28
  /** Abort the in-flight turn. The partial answer stays in the transcript. */
22
29
  stop: () => void;
23
30
  /** Clear the transcript and start a brand-new server session. */
@@ -32,6 +39,7 @@ export function useFrankieChat(): UseFrankieChatResult {
32
39
  const [messages, setMessages] = useState<ChatMessage[]>([]);
33
40
  const [busy, setBusy] = useState(false);
34
41
  const [error, setError] = useState<string | undefined>(undefined);
42
+ const [restored, setRestored] = useState(false);
35
43
  const abortRef = useRef<AbortController | null>(null);
36
44
  const mounted = useRef(true);
37
45
 
@@ -45,30 +53,47 @@ export function useFrankieChat(): UseFrankieChatResult {
45
53
 
46
54
  const storageKey = `${client.namespace}.messages`;
47
55
 
48
- // Restore the transcript on mount, so a page reload keeps the conversation.
49
- // The client resumes the matching server session, so context is kept too.
56
+ // Restore local storage immediately, then hydrate the authoritative server
57
+ // transcript. The server path makes the same signed-in user's chats available
58
+ // on another browser or device.
50
59
  useEffect(() => {
51
- if (!client.persist) return;
52
- try {
53
- const raw = window.localStorage.getItem(storageKey);
54
- const stored = raw ? (JSON.parse(raw) as ChatMessage[]) : [];
55
- if (Array.isArray(stored) && stored.length) setMessages(stored);
56
- } catch {
57
- /* storage unavailable; start empty */
58
- }
59
- // eslint-disable-next-line react-hooks/exhaustive-deps
60
- }, [storageKey]);
60
+ let active = true;
61
+ const restore = async () => {
62
+ if (client.persist) {
63
+ try {
64
+ const raw = window.localStorage.getItem(storageKey);
65
+ const stored = raw ? (JSON.parse(raw) as ChatMessage[]) : [];
66
+ if (Array.isArray(stored) && stored.length && active) setMessages(stored);
67
+ } catch {
68
+ /* storage unavailable; server history can still restore */
69
+ }
70
+ }
71
+ try {
72
+ const remote = await client.resumeConversation();
73
+ if (active && remote?.messages.length) setMessages(remote.messages as ChatMessage[]);
74
+ } catch {
75
+ /* A new conversation does not exist remotely until its first message. */
76
+ } finally {
77
+ if (active) setRestored(true);
78
+ }
79
+ };
80
+ void restore();
81
+ return () => { active = false; };
82
+ }, [client, storageKey]);
61
83
 
62
84
  // Persist once a turn settles — never mid-stream — so the saved copy is clean.
85
+ // Wait until the initial restore has completed so an empty initial state never
86
+ // deletes an existing transcript.
63
87
  useEffect(() => {
64
- if (!client.persist || busy) return;
88
+ if (!client.persist || !restored || busy) return;
65
89
  try {
66
90
  if (messages.length) window.localStorage.setItem(storageKey, JSON.stringify(messages));
67
91
  else window.localStorage.removeItem(storageKey);
68
92
  } catch {
69
93
  /* ignore */
70
94
  }
71
- }, [messages, busy, client, storageKey]);
95
+ if (messages.length) void client.syncConversation(messages, conversationTitle(messages)).catch(() => undefined);
96
+ }, [messages, busy, client, storageKey, restored]);
72
97
 
73
98
  const patchAssistant = useCallback((id: string, update: (m: AssistantChatMessage) => AssistantChatMessage) => {
74
99
  setMessages((current) =>
@@ -79,16 +104,23 @@ export function useFrankieChat(): UseFrankieChatResult {
79
104
  }, []);
80
105
 
81
106
  const send = useCallback(
82
- async (text: string) => {
107
+ async (text: string, opts?: SendOptions) => {
83
108
  const trimmed = text.trim();
84
- if (!trimmed || busy) return;
109
+ const attachments = opts?.attachments ?? [];
110
+ if ((!trimmed && attachments.length === 0) || busy) return;
111
+ // The server needs a message; an attachment-only turn gets a stand-in.
112
+ const outgoing = trimmed || "Please take a look at the attached file(s).";
85
113
 
86
114
  const assistantId = nextId("a");
87
115
  setError(undefined);
88
116
  setBusy(true);
89
117
  setMessages((current) => [
90
118
  ...current,
91
- newUserMessage(nextId("u"), trimmed),
119
+ newUserMessage(
120
+ nextId("u"),
121
+ trimmed,
122
+ attachments.map((file) => ({ name: file.name, mimeType: file.mimeType })),
123
+ ),
92
124
  newAssistantMessage(assistantId),
93
125
  ]);
94
126
 
@@ -96,10 +128,35 @@ export function useFrankieChat(): UseFrankieChatResult {
96
128
  abortRef.current = abort;
97
129
 
98
130
  try {
99
- await client.send({ message: trimmed, ...(context ? { context } : {}), signal: abort.signal }, (event) => {
100
- if (event.type === "ping" || event.type === "session") return;
101
- patchAssistant(assistantId, (message) => applyEvent(message, event));
102
- });
131
+ let receivedText = false;
132
+ let receivedToolActivity = false;
133
+ await client.send(
134
+ {
135
+ message: outgoing,
136
+ ...(context ? { context } : {}),
137
+ ...(opts?.model ? { model: opts.model } : {}),
138
+ ...(attachments.length ? { attachments } : {}),
139
+ signal: abort.signal,
140
+ },
141
+ (event) => {
142
+ if (event.type === "ping" || event.type === "session") return;
143
+ if (event.type === "text_delta") receivedText = true;
144
+ if (event.type === "tool_start" || event.type === "tool_end") receivedToolActivity = true;
145
+ patchAssistant(assistantId, (message) => applyEvent(message, event));
146
+ },
147
+ );
148
+ // A terminated stream with no text previously looked like Frankie did
149
+ // nothing. Preserve visible tool activity, but never leave an empty
150
+ // assistant bubble for the user to interpret.
151
+ if (!receivedText) {
152
+ patchAssistant(assistantId, (message) => ({
153
+ ...message,
154
+ status: "done",
155
+ text: receivedToolActivity
156
+ ? "Completed. Open Agent activity for the details."
157
+ : "Frankie didn’t return an answer. Please try again.",
158
+ }));
159
+ }
103
160
  } catch (caught) {
104
161
  if (abort.signal.aborted) {
105
162
  patchAssistant(assistantId, (message) => ({
@@ -111,7 +168,9 @@ export function useFrankieChat(): UseFrankieChatResult {
111
168
  const message =
112
169
  caught instanceof FrankieApiError
113
170
  ? caught.message
114
- : "Could not reach the assistant. Check your connection and try again.";
171
+ : caught instanceof Error && caught.message.startsWith("Frankie")
172
+ ? caught.message
173
+ : "Could not reach the assistant. Check your connection and try again.";
115
174
  if (mounted.current) setError(message);
116
175
  patchAssistant(assistantId, (current) => ({ ...current, status: "error", error: message }));
117
176
  }
@@ -137,6 +196,7 @@ export function useFrankieChat(): UseFrankieChatResult {
137
196
  setMessages([]);
138
197
  setError(undefined);
139
198
  setBusy(false);
199
+ setRestored(true);
140
200
  try {
141
201
  if (client.persist) window.localStorage.removeItem(`${client.namespace}.messages`);
142
202
  } catch {
@@ -146,3 +206,11 @@ export function useFrankieChat(): UseFrankieChatResult {
146
206
 
147
207
  return { messages, busy, ...(error ? { error } : {}), send, stop, reset };
148
208
  }
209
+
210
+
211
+ function conversationTitle(messages: ChatMessage[]): string | undefined {
212
+ const first = messages.find((message) => message.role === "user" && message.text.trim());
213
+ if (!first) return undefined;
214
+ const title = first.text.trim().replace(/\s+/g, " ");
215
+ return title.length > 42 ? `${title.slice(0, 42)}…` : title;
216
+ }