@frankieai/react 0.1.1 → 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.d.cts CHANGED
@@ -30,6 +30,16 @@ interface FrankieClientOptions {
30
30
  * set false for an ephemeral chat that starts fresh every load.
31
31
  */
32
32
  persist?: boolean;
33
+ /** Override localStorage namespace for session and transcript. */
34
+ storageNamespace?: string;
35
+ /** Stable id for a server-backed conversation. */
36
+ conversationId?: string;
37
+ }
38
+ interface ChatConversation {
39
+ id: string;
40
+ title: string;
41
+ createdAt: number;
42
+ updatedAt: number;
33
43
  }
34
44
  declare class FrankieApiError extends Error {
35
45
  readonly status: number;
@@ -43,45 +53,39 @@ declare class FrankieClient {
43
53
  private session?;
44
54
  constructor(options: FrankieClientOptions);
45
55
  get sessionId(): string | undefined;
56
+ get conversationId(): string | undefined;
46
57
  /** Whether this client persists across reloads (browser only, opt-out via `persist:false`). */
47
58
  get persist(): boolean;
48
- /** localStorage namespace for this tenant, shared with the chat hook. */
59
+ /** localStorage namespace for this tenant/conversation, shared with the chat hook. */
49
60
  get namespace(): string;
50
61
  private readStored;
51
62
  private writeStored;
52
63
  getConfig(signal?: AbortSignal): Promise<PublicWidgetConfig>;
53
- /** Opens (or reuses) a chat session. */
54
64
  ensureSession(context?: PageContext): Promise<CreateSessionResponse>;
55
- /** Forget the current session so the next send starts a fresh conversation. */
65
+ listConversations(): Promise<ChatConversation[]>;
66
+ resumeConversation(id?: string | undefined): Promise<{
67
+ messages: unknown[];
68
+ title: string;
69
+ } | undefined>;
70
+ syncConversation(messages: unknown[], title?: string): Promise<void>;
71
+ deleteConversation(id: string): Promise<void>;
72
+ private requireUserToken;
56
73
  resetSession(): void;
57
- /**
58
- * Sends a message and invokes `onEvent` for each server event as it arrives.
59
- * Resolves when the turn ends.
60
- */
61
74
  send(input: {
62
75
  message: string;
63
76
  context?: PageContext;
64
- /** Model id chosen by the user, honoured only if the workspace allows it. */
65
77
  model?: string;
66
- /** Files attached to this turn. */
67
78
  attachments?: ChatAttachment[];
68
79
  signal?: AbortSignal;
69
80
  }, onEvent: (event: FrankieServerEvent) => void): Promise<void>;
70
81
  private unwrap;
71
82
  }
72
- /** Parse the server's `data:`-only SSE stream into typed events. */
73
83
  declare function readEventStream(body: ReadableStream<Uint8Array>): AsyncGenerator<FrankieServerEvent>;
74
84
 
75
85
  interface FrankieProviderProps extends FrankieClientOptions {
76
86
  children: ReactNode;
77
- /**
78
- * Page context sent with every turn — current route, plan tier, whatever the
79
- * agent should know. Treated as data by the server, never as instructions.
80
- */
81
87
  context?: PageContext;
82
- /** Overrides for the server-side theme, so a host can restyle without a redeploy. */
83
88
  theme?: WidgetTheme;
84
- /** Skip the `/v1/config` fetch by supplying the config yourself. */
85
89
  config?: PublicWidgetConfig;
86
90
  }
87
91
  interface FrankieContextValue {
@@ -94,7 +98,6 @@ interface FrankieContextValue {
94
98
  }
95
99
  declare function FrankieProvider({ children, context, theme, config: providedConfig, ...clientOptions }: FrankieProviderProps): react.JSX.Element;
96
100
  declare function useFrankie(): FrankieContextValue;
97
- /** The theme actually in effect: provider override wins over server config. */
98
101
  declare function useFrankieTheme(): WidgetTheme | undefined;
99
102
 
100
103
  interface ToolActivity {
@@ -215,6 +218,16 @@ interface FrankieInlineProps extends ChatPanelProps {
215
218
  */
216
219
  declare function FrankieInline({ className, style, ...panelProps }: FrankieInlineProps): react.JSX.Element;
217
220
 
221
+ /**
222
+ * A complete chat route in one component. This is the simplest way to add a
223
+ * dedicated /chat page to an app while keeping the existing floating widget.
224
+ */
225
+ interface FrankieChatPageProps extends Omit<FrankieProviderProps, "children">, Pick<ChatPanelProps, "header" | "footer" | "emptyState" | "renderMessage" | "renderTool" | "placeholder" | "autoFocus"> {
226
+ className?: string;
227
+ style?: CSSProperties;
228
+ }
229
+ declare function FrankieChatPage({ className, style, header, footer, emptyState, renderMessage, renderTool, placeholder, autoFocus, ...providerProps }: FrankieChatPageProps): react.JSX.Element;
230
+
218
231
  interface ComposerAttachmentsConfig {
219
232
  enabled: boolean;
220
233
  /** Accepted MIME patterns, e.g. "image/*". */
@@ -344,4 +357,4 @@ declare function resolveMode(mode: WidgetTheme["mode"]): "light" | "dark";
344
357
  /** shadcn/ui's class helper: conditional classes with Tailwind conflict resolution. */
345
358
  declare function cn(...inputs: ClassValue[]): string;
346
359
 
347
- export { type AssistantChatMessage, Avatar, Badge, Button, type ButtonProps, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, type ChatMessage, ChatPanel, type ChatPanelProps, Composer, FRANKIE_CLOUD_URL, FrankieApiError, FrankieClient, type FrankieClientOptions, FrankieInline, type FrankieInlineProps, FrankieProvider, type FrankieProviderProps, FrankieWidget, type FrankieWidgetProps, Markdown, type MessageAttachment, MessageBubble, type PageContext, ScrollArea, type SendOptions, Separator, SuggestedPrompts, Textarea, type ToolActivity, ToolActivityList, ToolActivityRow, TypingDots, type UseFrankieChatResult, type UserChatMessage, applyEvent, buttonVariants, cn, newAssistantMessage, newUserMessage, readEventStream, resolveMode, themeToCssVars, useFrankie, useFrankieChat, useFrankieTheme };
360
+ export { type AssistantChatMessage, Avatar, Badge, Button, type ButtonProps, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, type ChatMessage, ChatPanel, type ChatPanelProps, Composer, FRANKIE_CLOUD_URL, FrankieApiError, FrankieChatPage, type FrankieChatPageProps, FrankieClient, type FrankieClientOptions, FrankieInline, type FrankieInlineProps, FrankieProvider, type FrankieProviderProps, FrankieWidget, type FrankieWidgetProps, Markdown, type MessageAttachment, MessageBubble, type PageContext, ScrollArea, type SendOptions, Separator, SuggestedPrompts, Textarea, type ToolActivity, ToolActivityList, ToolActivityRow, TypingDots, type UseFrankieChatResult, type UserChatMessage, applyEvent, buttonVariants, cn, newAssistantMessage, newUserMessage, readEventStream, resolveMode, themeToCssVars, useFrankie, useFrankieChat, useFrankieTheme };
package/dist/index.d.ts CHANGED
@@ -30,6 +30,16 @@ interface FrankieClientOptions {
30
30
  * set false for an ephemeral chat that starts fresh every load.
31
31
  */
32
32
  persist?: boolean;
33
+ /** Override localStorage namespace for session and transcript. */
34
+ storageNamespace?: string;
35
+ /** Stable id for a server-backed conversation. */
36
+ conversationId?: string;
37
+ }
38
+ interface ChatConversation {
39
+ id: string;
40
+ title: string;
41
+ createdAt: number;
42
+ updatedAt: number;
33
43
  }
34
44
  declare class FrankieApiError extends Error {
35
45
  readonly status: number;
@@ -43,45 +53,39 @@ declare class FrankieClient {
43
53
  private session?;
44
54
  constructor(options: FrankieClientOptions);
45
55
  get sessionId(): string | undefined;
56
+ get conversationId(): string | undefined;
46
57
  /** Whether this client persists across reloads (browser only, opt-out via `persist:false`). */
47
58
  get persist(): boolean;
48
- /** localStorage namespace for this tenant, shared with the chat hook. */
59
+ /** localStorage namespace for this tenant/conversation, shared with the chat hook. */
49
60
  get namespace(): string;
50
61
  private readStored;
51
62
  private writeStored;
52
63
  getConfig(signal?: AbortSignal): Promise<PublicWidgetConfig>;
53
- /** Opens (or reuses) a chat session. */
54
64
  ensureSession(context?: PageContext): Promise<CreateSessionResponse>;
55
- /** Forget the current session so the next send starts a fresh conversation. */
65
+ listConversations(): Promise<ChatConversation[]>;
66
+ resumeConversation(id?: string | undefined): Promise<{
67
+ messages: unknown[];
68
+ title: string;
69
+ } | undefined>;
70
+ syncConversation(messages: unknown[], title?: string): Promise<void>;
71
+ deleteConversation(id: string): Promise<void>;
72
+ private requireUserToken;
56
73
  resetSession(): void;
57
- /**
58
- * Sends a message and invokes `onEvent` for each server event as it arrives.
59
- * Resolves when the turn ends.
60
- */
61
74
  send(input: {
62
75
  message: string;
63
76
  context?: PageContext;
64
- /** Model id chosen by the user, honoured only if the workspace allows it. */
65
77
  model?: string;
66
- /** Files attached to this turn. */
67
78
  attachments?: ChatAttachment[];
68
79
  signal?: AbortSignal;
69
80
  }, onEvent: (event: FrankieServerEvent) => void): Promise<void>;
70
81
  private unwrap;
71
82
  }
72
- /** Parse the server's `data:`-only SSE stream into typed events. */
73
83
  declare function readEventStream(body: ReadableStream<Uint8Array>): AsyncGenerator<FrankieServerEvent>;
74
84
 
75
85
  interface FrankieProviderProps extends FrankieClientOptions {
76
86
  children: ReactNode;
77
- /**
78
- * Page context sent with every turn — current route, plan tier, whatever the
79
- * agent should know. Treated as data by the server, never as instructions.
80
- */
81
87
  context?: PageContext;
82
- /** Overrides for the server-side theme, so a host can restyle without a redeploy. */
83
88
  theme?: WidgetTheme;
84
- /** Skip the `/v1/config` fetch by supplying the config yourself. */
85
89
  config?: PublicWidgetConfig;
86
90
  }
87
91
  interface FrankieContextValue {
@@ -94,7 +98,6 @@ interface FrankieContextValue {
94
98
  }
95
99
  declare function FrankieProvider({ children, context, theme, config: providedConfig, ...clientOptions }: FrankieProviderProps): react.JSX.Element;
96
100
  declare function useFrankie(): FrankieContextValue;
97
- /** The theme actually in effect: provider override wins over server config. */
98
101
  declare function useFrankieTheme(): WidgetTheme | undefined;
99
102
 
100
103
  interface ToolActivity {
@@ -215,6 +218,16 @@ interface FrankieInlineProps extends ChatPanelProps {
215
218
  */
216
219
  declare function FrankieInline({ className, style, ...panelProps }: FrankieInlineProps): react.JSX.Element;
217
220
 
221
+ /**
222
+ * A complete chat route in one component. This is the simplest way to add a
223
+ * dedicated /chat page to an app while keeping the existing floating widget.
224
+ */
225
+ interface FrankieChatPageProps extends Omit<FrankieProviderProps, "children">, Pick<ChatPanelProps, "header" | "footer" | "emptyState" | "renderMessage" | "renderTool" | "placeholder" | "autoFocus"> {
226
+ className?: string;
227
+ style?: CSSProperties;
228
+ }
229
+ declare function FrankieChatPage({ className, style, header, footer, emptyState, renderMessage, renderTool, placeholder, autoFocus, ...providerProps }: FrankieChatPageProps): react.JSX.Element;
230
+
218
231
  interface ComposerAttachmentsConfig {
219
232
  enabled: boolean;
220
233
  /** Accepted MIME patterns, e.g. "image/*". */
@@ -344,4 +357,4 @@ declare function resolveMode(mode: WidgetTheme["mode"]): "light" | "dark";
344
357
  /** shadcn/ui's class helper: conditional classes with Tailwind conflict resolution. */
345
358
  declare function cn(...inputs: ClassValue[]): string;
346
359
 
347
- export { type AssistantChatMessage, Avatar, Badge, Button, type ButtonProps, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, type ChatMessage, ChatPanel, type ChatPanelProps, Composer, FRANKIE_CLOUD_URL, FrankieApiError, FrankieClient, type FrankieClientOptions, FrankieInline, type FrankieInlineProps, FrankieProvider, type FrankieProviderProps, FrankieWidget, type FrankieWidgetProps, Markdown, type MessageAttachment, MessageBubble, type PageContext, ScrollArea, type SendOptions, Separator, SuggestedPrompts, Textarea, type ToolActivity, ToolActivityList, ToolActivityRow, TypingDots, type UseFrankieChatResult, type UserChatMessage, applyEvent, buttonVariants, cn, newAssistantMessage, newUserMessage, readEventStream, resolveMode, themeToCssVars, useFrankie, useFrankieChat, useFrankieTheme };
360
+ export { type AssistantChatMessage, Avatar, Badge, Button, type ButtonProps, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, type ChatMessage, ChatPanel, type ChatPanelProps, Composer, FRANKIE_CLOUD_URL, FrankieApiError, FrankieChatPage, type FrankieChatPageProps, FrankieClient, type FrankieClientOptions, FrankieInline, type FrankieInlineProps, FrankieProvider, type FrankieProviderProps, FrankieWidget, type FrankieWidgetProps, Markdown, type MessageAttachment, MessageBubble, type PageContext, ScrollArea, type SendOptions, Separator, SuggestedPrompts, Textarea, type ToolActivity, ToolActivityList, ToolActivityRow, TypingDots, type UseFrankieChatResult, type UserChatMessage, applyEvent, buttonVariants, cn, newAssistantMessage, newUserMessage, readEventStream, resolveMode, themeToCssVars, useFrankie, useFrankieChat, useFrankieTheme };
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,30 +65,80 @@ 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 = {
@@ -171,9 +224,8 @@ function FrankieProvider({
171
224
  }) {
172
225
  const client = useMemo(
173
226
  () => new FrankieClient(clientOptions),
174
- // The client holds session state; rebuild it only when the endpoint changes.
175
227
  // eslint-disable-next-line react-hooks/exhaustive-deps
176
- [clientOptions.baseUrl, clientOptions.publishableKey]
228
+ [clientOptions.baseUrl, clientOptions.publishableKey, clientOptions.storageNamespace, clientOptions.conversationId]
177
229
  );
178
230
  const [config, setConfig] = useState(providedConfig);
179
231
  const [configError, setConfigError] = useState(void 0);
@@ -299,6 +351,7 @@ function useFrankieChat() {
299
351
  const [messages, setMessages] = useState2([]);
300
352
  const [busy, setBusy] = useState2(false);
301
353
  const [error, setError] = useState2(void 0);
354
+ const [restored, setRestored] = useState2(false);
302
355
  const abortRef = useRef(null);
303
356
  const mounted = useRef(true);
304
357
  useEffect2(() => {
@@ -310,22 +363,38 @@ function useFrankieChat() {
310
363
  }, []);
311
364
  const storageKey = `${client.namespace}.messages`;
312
365
  useEffect2(() => {
313
- if (!client.persist) return;
314
- try {
315
- const raw = window.localStorage.getItem(storageKey);
316
- const stored = raw ? JSON.parse(raw) : [];
317
- if (Array.isArray(stored) && stored.length) setMessages(stored);
318
- } catch {
319
- }
320
- }, [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]);
321
389
  useEffect2(() => {
322
- if (!client.persist || busy) return;
390
+ if (!client.persist || !restored || busy) return;
323
391
  try {
324
392
  if (messages.length) window.localStorage.setItem(storageKey, JSON.stringify(messages));
325
393
  else window.localStorage.removeItem(storageKey);
326
394
  } catch {
327
395
  }
328
- }, [messages, busy, client, storageKey]);
396
+ if (messages.length) void client.syncConversation(messages, conversationTitle(messages)).catch(() => void 0);
397
+ }, [messages, busy, client, storageKey, restored]);
329
398
  const patchAssistant = useCallback((id, update) => {
330
399
  setMessages(
331
400
  (current) => current.map(
@@ -354,6 +423,8 @@ function useFrankieChat() {
354
423
  const abort = new AbortController();
355
424
  abortRef.current = abort;
356
425
  try {
426
+ let receivedText = false;
427
+ let receivedToolActivity = false;
357
428
  await client.send(
358
429
  {
359
430
  message: outgoing,
@@ -364,9 +435,18 @@ function useFrankieChat() {
364
435
  },
365
436
  (event) => {
366
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;
367
440
  patchAssistant(assistantId, (message) => applyEvent(message, event));
368
441
  }
369
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
+ }
370
450
  } catch (caught) {
371
451
  if (abort.signal.aborted) {
372
452
  patchAssistant(assistantId, (message) => ({
@@ -375,7 +455,7 @@ function useFrankieChat() {
375
455
  text: message.text || "Stopped."
376
456
  }));
377
457
  } else {
378
- 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.";
379
459
  if (mounted.current) setError(message);
380
460
  patchAssistant(assistantId, (current) => ({ ...current, status: "error", error: message }));
381
461
  }
@@ -399,6 +479,7 @@ function useFrankieChat() {
399
479
  setMessages([]);
400
480
  setError(void 0);
401
481
  setBusy(false);
482
+ setRestored(true);
402
483
  try {
403
484
  if (client.persist) window.localStorage.removeItem(`${client.namespace}.messages`);
404
485
  } catch {
@@ -406,10 +487,16 @@ function useFrankieChat() {
406
487
  }, [client]);
407
488
  return { messages, busy, ...error ? { error } : {}, send, stop, reset };
408
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
+ }
409
496
 
410
497
  // src/components/chat/FrankieWidget.tsx
411
498
  import { MessageCircle, X as X4 } from "lucide-react";
412
- import { useEffect as useEffect5, useState as useState5 } from "react";
499
+ import { useEffect as useEffect5, useState as useState6 } from "react";
413
500
 
414
501
  // src/lib/theme.ts
415
502
  function themeToCssVars(theme) {
@@ -470,7 +557,7 @@ function Button({ className, variant, size, asChild = false, ...props }) {
470
557
 
471
558
  // src/components/chat/ChatPanel.tsx
472
559
  import { RotateCcw, X as X3 } from "lucide-react";
473
- import { useEffect as useEffect4, useRef as useRef3, useState as useState4 } from "react";
560
+ import { useEffect as useEffect4, useRef as useRef3, useState as useState5 } from "react";
474
561
 
475
562
  // src/components/ui/avatar.tsx
476
563
  import { jsx as jsx3 } from "react/jsx-runtime";
@@ -731,7 +818,8 @@ function readAsBase64(file) {
731
818
  }
732
819
 
733
820
  // src/components/chat/MessageBubble.tsx
734
- import { Paperclip as Paperclip2 } from "lucide-react";
821
+ import { Check as Check2, Copy, Paperclip as Paperclip2 } from "lucide-react";
822
+ import { useState as useState4 } from "react";
735
823
 
736
824
  // src/components/chat/Markdown.tsx
737
825
  import { Fragment as Fragment2 } from "react";
@@ -836,7 +924,7 @@ function renderToken(token) {
836
924
  }
837
925
 
838
926
  // src/components/chat/ToolActivityList.tsx
839
- import { Check, Loader2, ShieldAlert, X as X2 } from "lucide-react";
927
+ import { Check, ChevronDown, Loader2, ShieldAlert, X as X2 } from "lucide-react";
840
928
 
841
929
  // src/components/ui/badge.tsx
842
930
  import { cva as cva2 } from "class-variance-authority";
@@ -867,7 +955,20 @@ function Badge({
867
955
  import { jsx as jsx10, jsxs as jsxs2 } from "react/jsx-runtime";
868
956
  function ToolActivityList({ tools, renderTool }) {
869
957
  if (tools.length === 0) return null;
870
- 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
+ ] });
871
972
  }
872
973
  function ToolActivityRow({ tool }) {
873
974
  return /* @__PURE__ */ jsxs2(Badge, { variant: badgeVariant(tool.status), title: tool.detail ?? tool.name, children: [
@@ -916,18 +1017,36 @@ function MessageBubble({
916
1017
  },
917
1018
  `${file.name}-${index}`
918
1019
  )) }) : null,
919
- message.text ? /* @__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 }) : 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
920
1024
  ] });
921
1025
  }
922
1026
  const empty = message.text.length === 0 && message.tools.length === 0;
923
1027
  return /* @__PURE__ */ jsxs3("div", { className: "flex gap-2", children: [
924
- /* @__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
+ ] }),
925
1032
  /* @__PURE__ */ jsxs3("div", { className: "min-w-0 max-w-[85%]", children: [
926
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" }),
927
1034
  showToolActivity ? /* @__PURE__ */ jsx11(ToolActivityList, { tools: message.tools, ...renderTool ? { renderTool } : {} }) : null
928
1035
  ] })
929
1036
  ] });
930
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
+ }
931
1050
  function TypingDots() {
932
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(
933
1052
  "span",
@@ -981,7 +1100,7 @@ function ChatPanel({
981
1100
  const scrollRef = useRef3(null);
982
1101
  const features = config?.features;
983
1102
  const pickerModels = features?.modelPicker.enabled ? features.modelPicker.models : [];
984
- const [model, setModel] = useState4(void 0);
1103
+ const [model, setModel] = useState5(void 0);
985
1104
  useEffect4(() => {
986
1105
  const node = scrollRef.current;
987
1106
  if (!node) return;
@@ -1011,7 +1130,11 @@ function ChatPanel({
1011
1130
  /* @__PURE__ */ jsx13(Button, { size: "icon", variant: "ghost", onClick: reset, "aria-label": "Start a new conversation", title: "New conversation", children: /* @__PURE__ */ jsx13(RotateCcw, {}) }),
1012
1131
  onClose ? /* @__PURE__ */ jsx13(Button, { size: "icon", variant: "ghost", onClick: onClose, "aria-label": "Close", children: /* @__PURE__ */ jsx13(X3, {}) }) : null
1013
1132
  ] }),
1014
- /* @__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: [
1015
1138
  /* @__PURE__ */ jsx13("p", { className: "text-sm text-muted-foreground", children: config?.greeting }),
1016
1139
  /* @__PURE__ */ jsx13(SuggestedPrompts, { prompts: config?.suggestedPrompts ?? [], onPick: send, disabled: busy })
1017
1140
  ] }) : /* @__PURE__ */ jsx13("div", { className: "flex flex-col gap-4", children: messages.map(
@@ -1062,7 +1185,7 @@ function FrankieWidget({
1062
1185
  }) {
1063
1186
  const { config } = useFrankie();
1064
1187
  const theme = useFrankieTheme();
1065
- const [uncontrolledOpen, setUncontrolledOpen] = useState5(defaultOpen);
1188
+ const [uncontrolledOpen, setUncontrolledOpen] = useState6(defaultOpen);
1066
1189
  const open = controlledOpen ?? uncontrolledOpen;
1067
1190
  const setOpen = (next) => {
1068
1191
  if (controlledOpen === void 0) setUncontrolledOpen(next);
@@ -1120,10 +1243,45 @@ function FrankieInline({ className, style, ...panelProps }) {
1120
1243
  );
1121
1244
  }
1122
1245
 
1123
- // src/components/ui/separator.tsx
1246
+ // src/components/chat/FrankieChatPage.tsx
1124
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";
1125
1283
  function Separator({ className }) {
1126
- 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) });
1127
1285
  }
1128
1286
  export {
1129
1287
  Avatar,
@@ -1139,6 +1297,7 @@ export {
1139
1297
  Composer,
1140
1298
  FRANKIE_CLOUD_URL,
1141
1299
  FrankieApiError,
1300
+ FrankieChatPage,
1142
1301
  FrankieClient,
1143
1302
  FrankieInline,
1144
1303
  FrankieProvider,