@frankieai/react 0.1.1 → 0.1.5

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,121 @@
1
+ "use client";
2
+
3
+ import type { CSSProperties } from "react";
4
+ import type { FrankieProviderProps } from "../../hooks/FrankieProvider.jsx";
5
+ import { FrankieProvider, useFrankie } from "../../hooks/FrankieProvider.jsx";
6
+ import { useFrankieChat } from "../../hooks/useFrankieChat.js";
7
+ import { FrankieInline } from "./FrankieInline.js";
8
+ import type { ChatPanelProps } from "./ChatPanel.js";
9
+
10
+ export interface FrankieChatPageProps
11
+ extends Omit<FrankieProviderProps, "children">,
12
+ Pick<
13
+ ChatPanelProps,
14
+ "header" | "footer" | "emptyState" | "renderMessage" | "renderTool" | "placeholder" | "autoFocus"
15
+ > {
16
+ className?: string;
17
+ style?: CSSProperties;
18
+ }
19
+
20
+ function PageEmptyState() {
21
+ const { config } = useFrankie();
22
+ const { send, busy } = useFrankieChat();
23
+ 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
+ );
47
+ }
48
+
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
+ );
84
+ }
85
+
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
+ );
121
+ }
@@ -1,7 +1,8 @@
1
1
  "use client";
2
2
 
3
- import { Paperclip } from "lucide-react";
4
- 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";
5
6
  import { cn } from "../../lib/utils.js";
6
7
  import type { ChatMessage, ToolActivity } from "../../lib/messages.js";
7
8
  import { Avatar } from "../ui/avatar.js";
@@ -40,8 +41,9 @@ export function MessageBubble({
40
41
  </div>
41
42
  ) : null}
42
43
  {message.text ? (
43
- <div className="max-w-[85%] rounded-[var(--frankie-radius)] bg-primary px-3 py-2 text-sm text-primary-foreground">
44
- {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} />
45
47
  </div>
46
48
  ) : null}
47
49
  </div>
@@ -52,7 +54,10 @@ export function MessageBubble({
52
54
 
53
55
  return (
54
56
  <div className="flex gap-2">
55
- <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>
56
61
  <div className="min-w-0 max-w-[85%]">
57
62
  {message.status === "error" ? (
58
63
  <p className="rounded-[var(--frankie-radius)] bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-400">
@@ -71,6 +76,18 @@ export function MessageBubble({
71
76
  );
72
77
  }
73
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
+
74
91
  export function TypingDots() {
75
92
  return (
76
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]);
@@ -39,8 +39,12 @@ export function useFrankieChat(): UseFrankieChatResult {
39
39
  const [messages, setMessages] = useState<ChatMessage[]>([]);
40
40
  const [busy, setBusy] = useState(false);
41
41
  const [error, setError] = useState<string | undefined>(undefined);
42
+ const [restored, setRestored] = useState(false);
42
43
  const abortRef = useRef<AbortController | null>(null);
43
44
  const mounted = useRef(true);
45
+ // State updates can be batched while a stream is arriving. Keep the latest
46
+ // transcript synchronously so the final server sync cannot save a stale copy.
47
+ const messagesRef = useRef<ChatMessage[]>([]);
44
48
 
45
49
  useEffect(() => {
46
50
  mounted.current = true;
@@ -52,37 +56,61 @@ export function useFrankieChat(): UseFrankieChatResult {
52
56
 
53
57
  const storageKey = `${client.namespace}.messages`;
54
58
 
55
- // Restore the transcript on mount, so a page reload keeps the conversation.
56
- // The client resumes the matching server session, so context is kept too.
59
+ // Restore local storage immediately, then hydrate the authoritative server
60
+ // transcript. The server path makes the same signed-in user's chats available
61
+ // on another browser or device.
57
62
  useEffect(() => {
58
- if (!client.persist) return;
59
- try {
60
- const raw = window.localStorage.getItem(storageKey);
61
- const stored = raw ? (JSON.parse(raw) as ChatMessage[]) : [];
62
- if (Array.isArray(stored) && stored.length) setMessages(stored);
63
- } catch {
64
- /* storage unavailable; start empty */
65
- }
66
- // eslint-disable-next-line react-hooks/exhaustive-deps
67
- }, [storageKey]);
63
+ let active = true;
64
+ const restore = async () => {
65
+ if (client.persist) {
66
+ try {
67
+ const raw = window.localStorage.getItem(storageKey);
68
+ const stored = raw ? (JSON.parse(raw) as ChatMessage[]) : [];
69
+ if (Array.isArray(stored) && stored.length && active) {
70
+ messagesRef.current = stored;
71
+ setMessages(stored);
72
+ }
73
+ } catch {
74
+ /* storage unavailable; server history can still restore */
75
+ }
76
+ }
77
+ try {
78
+ const remote = await client.resumeConversation();
79
+ if (active && remote?.messages.length) {
80
+ const restoredMessages = remote.messages as ChatMessage[];
81
+ messagesRef.current = restoredMessages;
82
+ setMessages(restoredMessages);
83
+ }
84
+ } catch {
85
+ /* A new conversation does not exist remotely until its first message. */
86
+ } finally {
87
+ if (active) setRestored(true);
88
+ }
89
+ };
90
+ void restore();
91
+ return () => { active = false; };
92
+ }, [client, storageKey]);
68
93
 
69
94
  // Persist once a turn settles — never mid-stream — so the saved copy is clean.
95
+ // Wait until the initial restore has completed so an empty initial state never
96
+ // deletes an existing transcript.
70
97
  useEffect(() => {
71
- if (!client.persist || busy) return;
98
+ if (!client.persist || !restored || busy) return;
72
99
  try {
73
100
  if (messages.length) window.localStorage.setItem(storageKey, JSON.stringify(messages));
74
101
  else window.localStorage.removeItem(storageKey);
75
102
  } catch {
76
103
  /* ignore */
77
104
  }
78
- }, [messages, busy, client, storageKey]);
105
+ if (messages.length) void client.syncConversation(messages, conversationTitle(messages)).catch(() => undefined);
106
+ }, [messages, busy, client, storageKey, restored]);
79
107
 
80
108
  const patchAssistant = useCallback((id: string, update: (m: AssistantChatMessage) => AssistantChatMessage) => {
81
- setMessages((current) =>
82
- current.map((message) =>
83
- message.id === id && message.role === "assistant" ? update(message) : message,
84
- ),
109
+ const next = messagesRef.current.map((message) =>
110
+ message.id === id && message.role === "assistant" ? update(message) : message,
85
111
  );
112
+ messagesRef.current = next;
113
+ setMessages(next);
86
114
  }, []);
87
115
 
88
116
  const send = useCallback(
@@ -96,20 +124,24 @@ export function useFrankieChat(): UseFrankieChatResult {
96
124
  const assistantId = nextId("a");
97
125
  setError(undefined);
98
126
  setBusy(true);
99
- setMessages((current) => [
100
- ...current,
127
+ const next = [
128
+ ...messagesRef.current,
101
129
  newUserMessage(
102
130
  nextId("u"),
103
131
  trimmed,
104
132
  attachments.map((file) => ({ name: file.name, mimeType: file.mimeType })),
105
133
  ),
106
134
  newAssistantMessage(assistantId),
107
- ]);
135
+ ];
136
+ messagesRef.current = next;
137
+ setMessages(next);
108
138
 
109
139
  const abort = new AbortController();
110
140
  abortRef.current = abort;
111
141
 
112
142
  try {
143
+ let receivedText = false;
144
+ let receivedToolActivity = false;
113
145
  await client.send(
114
146
  {
115
147
  message: outgoing,
@@ -120,9 +152,23 @@ export function useFrankieChat(): UseFrankieChatResult {
120
152
  },
121
153
  (event) => {
122
154
  if (event.type === "ping" || event.type === "session") return;
155
+ if (event.type === "text_delta") receivedText = true;
156
+ if (event.type === "tool_start" || event.type === "tool_end") receivedToolActivity = true;
123
157
  patchAssistant(assistantId, (message) => applyEvent(message, event));
124
158
  },
125
159
  );
160
+ // A terminated stream with no text previously looked like Frankie did
161
+ // nothing. Preserve visible tool activity, but never leave an empty
162
+ // assistant bubble for the user to interpret.
163
+ if (!receivedText) {
164
+ patchAssistant(assistantId, (message) => ({
165
+ ...message,
166
+ status: "done",
167
+ text: receivedToolActivity
168
+ ? "Completed. Open Agent activity for the details."
169
+ : "Frankie didn’t return an answer. Please try again.",
170
+ }));
171
+ }
126
172
  } catch (caught) {
127
173
  if (abort.signal.aborted) {
128
174
  patchAssistant(assistantId, (message) => ({
@@ -134,18 +180,25 @@ export function useFrankieChat(): UseFrankieChatResult {
134
180
  const message =
135
181
  caught instanceof FrankieApiError
136
182
  ? caught.message
137
- : "Could not reach the assistant. Check your connection and try again.";
183
+ : caught instanceof Error && caught.message.startsWith("Frankie")
184
+ ? caught.message
185
+ : "Could not reach the assistant. Check your connection and try again.";
138
186
  if (mounted.current) setError(message);
139
187
  patchAssistant(assistantId, (current) => ({ ...current, status: "error", error: message }));
140
188
  }
141
189
  } finally {
142
- if (mounted.current) {
143
- setBusy(false);
144
- // A turn that ended without a `turn_end` event still has to settle.
145
- patchAssistant(assistantId, (message) =>
146
- message.status === "streaming" ? { ...message, status: "done" } : message,
147
- );
190
+ // A turn that ended without a `turn_end` event still has to settle.
191
+ patchAssistant(assistantId, (message) =>
192
+ message.status === "streaming" ? { ...message, status: "done" } : message,
193
+ );
194
+ // Persist before send() resolves. This closes the previous gap where a
195
+ // route change or tab close could leave a server conversation with no
196
+ // rendered messages even though the agent transcript existed.
197
+ const transcript = messagesRef.current;
198
+ if (transcript.length) {
199
+ await client.syncConversation(transcript, conversationTitle(transcript)).catch(() => undefined);
148
200
  }
201
+ if (mounted.current) setBusy(false);
149
202
  abortRef.current = null;
150
203
  }
151
204
  },
@@ -157,9 +210,11 @@ export function useFrankieChat(): UseFrankieChatResult {
157
210
  const reset = useCallback(() => {
158
211
  abortRef.current?.abort();
159
212
  client.resetSession();
213
+ messagesRef.current = [];
160
214
  setMessages([]);
161
215
  setError(undefined);
162
216
  setBusy(false);
217
+ setRestored(true);
163
218
  try {
164
219
  if (client.persist) window.localStorage.removeItem(`${client.namespace}.messages`);
165
220
  } catch {
@@ -169,3 +224,11 @@ export function useFrankieChat(): UseFrankieChatResult {
169
224
 
170
225
  return { messages, busy, ...(error ? { error } : {}), send, stop, reset };
171
226
  }
227
+
228
+
229
+ function conversationTitle(messages: ChatMessage[]): string | undefined {
230
+ const first = messages.find((message) => message.role === "user" && message.text.trim());
231
+ if (!first) return undefined;
232
+ const title = first.text.trim().replace(/\s+/g, " ");
233
+ return title.length > 42 ? `${title.slice(0, 42)}…` : title;
234
+ }
package/src/index.ts CHANGED
@@ -7,6 +7,8 @@ export { FrankieWidget } from "./components/chat/FrankieWidget.js";
7
7
  export type { FrankieWidgetProps } from "./components/chat/FrankieWidget.js";
8
8
  export { FrankieInline } from "./components/chat/FrankieInline.js";
9
9
  export type { FrankieInlineProps } from "./components/chat/FrankieInline.js";
10
+ export { FrankieChatPage } from "./components/chat/FrankieChatPage.js";
11
+ export type { FrankieChatPageProps } from "./components/chat/FrankieChatPage.js";
10
12
  export { ChatPanel } from "./components/chat/ChatPanel.js";
11
13
  export type { ChatPanelProps } from "./components/chat/ChatPanel.js";
12
14
  export { Composer } from "./components/chat/Composer.js";
package/src/lib/client.ts CHANGED
@@ -32,6 +32,17 @@ export interface FrankieClientOptions {
32
32
  * set false for an ephemeral chat that starts fresh every load.
33
33
  */
34
34
  persist?: boolean;
35
+ /** Override localStorage namespace for session and transcript. */
36
+ storageNamespace?: string;
37
+ /** Stable id for a server-backed conversation. */
38
+ conversationId?: string;
39
+ }
40
+
41
+ export interface ChatConversation {
42
+ id: string;
43
+ title: string;
44
+ createdAt: number;
45
+ updatedAt: number;
35
46
  }
36
47
 
37
48
  export class FrankieApiError extends Error {
@@ -59,14 +70,18 @@ export class FrankieClient {
59
70
  return this.session?.sessionId;
60
71
  }
61
72
 
73
+ get conversationId(): string | undefined {
74
+ return this.options.conversationId;
75
+ }
76
+
62
77
  /** Whether this client persists across reloads (browser only, opt-out via `persist:false`). */
63
78
  get persist(): boolean {
64
79
  return this.options.persist !== false && typeof window !== "undefined";
65
80
  }
66
81
 
67
- /** localStorage namespace for this tenant, shared with the chat hook. */
82
+ /** localStorage namespace for this tenant/conversation, shared with the chat hook. */
68
83
  get namespace(): string {
69
- return `frankie.${this.options.publishableKey}`;
84
+ return this.options.storageNamespace ?? `frankie.${this.options.publishableKey}`;
70
85
  }
71
86
 
72
87
  private readStored(): CreateSessionResponse | undefined {
@@ -75,7 +90,6 @@ export class FrankieClient {
75
90
  const raw = window.localStorage.getItem(`${this.namespace}.session`);
76
91
  if (!raw) return undefined;
77
92
  const session = JSON.parse(raw) as CreateSessionResponse;
78
- // A stored session past its expiry is useless; drop it.
79
93
  return new Date(session.expiresAt).getTime() > Date.now() + 30_000 ? session : undefined;
80
94
  } catch {
81
95
  return undefined;
@@ -100,39 +114,92 @@ export class FrankieClient {
100
114
  return (await this.unwrap(response)) as PublicWidgetConfig;
101
115
  }
102
116
 
103
- /** Opens (or reuses) a chat session. */
104
117
  async ensureSession(context?: PageContext): Promise<CreateSessionResponse> {
105
- if (this.session && new Date(this.session.expiresAt).getTime() > Date.now() + 30_000) {
106
- return this.session;
107
- }
118
+ if (this.session && new Date(this.session.expiresAt).getTime() > Date.now() + 30_000) return this.session;
108
119
  const userToken = await this.options.getUserToken?.();
120
+ if (this.options.getUserToken && !userToken) {
121
+ throw new FrankieApiError(
122
+ "Your signed-in Frankie session could not be verified. Refresh the page and sign in again.",
123
+ 401,
124
+ "user_token_unavailable",
125
+ );
126
+ }
109
127
  const response = await this.doFetch(`${this.baseUrl}/v1/session`, {
110
128
  method: "POST",
111
129
  headers: { "content-type": "application/json", "x-frankie-key": this.options.publishableKey },
112
- body: JSON.stringify({ ...(userToken ? { userToken } : {}), ...(context ? { context } : {}) }),
130
+ body: JSON.stringify({
131
+ ...(userToken ? { userToken } : {}),
132
+ ...(context ? { context } : {}),
133
+ ...(this.options.conversationId ? { conversationId: this.options.conversationId } : {}),
134
+ }),
113
135
  });
114
136
  this.session = (await this.unwrap(response)) as CreateSessionResponse;
115
137
  this.writeStored();
116
138
  return this.session;
117
139
  }
118
140
 
119
- /** Forget the current session so the next send starts a fresh conversation. */
141
+ async listConversations(): Promise<ChatConversation[]> {
142
+ const userToken = await this.requireUserToken();
143
+ const response = await this.doFetch(`${this.baseUrl}/v1/conversations`, {
144
+ headers: { "x-frankie-key": this.options.publishableKey, authorization: `Bearer ${userToken}` },
145
+ });
146
+ const body = (await this.unwrap(response)) as { conversations?: ChatConversation[] };
147
+ return Array.isArray(body.conversations) ? body.conversations : [];
148
+ }
149
+
150
+ async resumeConversation(id = this.options.conversationId): Promise<{ messages: unknown[]; title: string } | undefined> {
151
+ if (!id) return undefined;
152
+ const userToken = await this.requireUserToken();
153
+ const response = await this.doFetch(`${this.baseUrl}/v1/conversations/${encodeURIComponent(id)}/resume`, {
154
+ method: "POST",
155
+ headers: { "x-frankie-key": this.options.publishableKey, authorization: `Bearer ${userToken}` },
156
+ });
157
+ if (response.status === 404) return undefined;
158
+ const body = (await this.unwrap(response)) as { session: CreateSessionResponse; messages?: unknown[]; title?: string };
159
+ this.session = body.session;
160
+ this.writeStored();
161
+ return { messages: Array.isArray(body.messages) ? body.messages : [], title: body.title ?? "New chat" };
162
+ }
163
+
164
+ async syncConversation(messages: unknown[], title?: string): Promise<void> {
165
+ if (!this.session || !this.options.conversationId) return;
166
+ const response = await this.doFetch(`${this.baseUrl}/v1/conversations/${encodeURIComponent(this.options.conversationId)}`, {
167
+ method: "PUT",
168
+ headers: {
169
+ "content-type": "application/json",
170
+ "x-frankie-key": this.options.publishableKey,
171
+ authorization: `Bearer ${this.session.sessionToken}`,
172
+ },
173
+ body: JSON.stringify({ messages, ...(title ? { title } : {}) }),
174
+ });
175
+ await this.unwrap(response);
176
+ }
177
+
178
+ async deleteConversation(id: string): Promise<void> {
179
+ const userToken = await this.requireUserToken();
180
+ const response = await this.doFetch(`${this.baseUrl}/v1/conversations/${encodeURIComponent(id)}`, {
181
+ method: "DELETE",
182
+ headers: { "x-frankie-key": this.options.publishableKey, authorization: `Bearer ${userToken}` },
183
+ });
184
+ if (response.status !== 404) await this.unwrap(response);
185
+ }
186
+
187
+ private async requireUserToken(): Promise<string> {
188
+ const token = await this.options.getUserToken?.();
189
+ if (!token) throw new FrankieApiError("Sign in to access your chat history.", 401, "unauthorized");
190
+ return token;
191
+ }
192
+
120
193
  resetSession(): void {
121
194
  this.session = undefined;
122
195
  this.writeStored();
123
196
  }
124
197
 
125
- /**
126
- * Sends a message and invokes `onEvent` for each server event as it arrives.
127
- * Resolves when the turn ends.
128
- */
129
198
  async send(
130
199
  input: {
131
200
  message: string;
132
201
  context?: PageContext;
133
- /** Model id chosen by the user, honoured only if the workspace allows it. */
134
202
  model?: string;
135
- /** Files attached to this turn. */
136
203
  attachments?: ChatAttachment[];
137
204
  signal?: AbortSignal;
138
205
  },
@@ -159,7 +226,6 @@ export class FrankieClient {
159
226
  });
160
227
 
161
228
  if (!response.ok || !response.body) {
162
- // A dropped session is recoverable: clear it so the next send re-auths.
163
229
  if (response.status === 401 || response.status === 404) this.resetSession();
164
230
  await this.unwrap(response);
165
231
  return;
@@ -185,7 +251,6 @@ export class FrankieClient {
185
251
  }
186
252
  }
187
253
 
188
- /** Parse the server's `data:`-only SSE stream into typed events. */
189
254
  export async function* readEventStream(
190
255
  body: ReadableStream<Uint8Array>,
191
256
  ): AsyncGenerator<FrankieServerEvent> {