@frankieai/react 0.2.8 → 0.2.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frankieai/react",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
4
4
  "description": "Embeddable, themeable chat widget for the Frankie onboarding agent — shadcn/ui components, works with or without Tailwind in the host app.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,7 +31,7 @@
31
31
  "clsx": "^2.1.1",
32
32
  "lucide-react": "^0.460.0",
33
33
  "tailwind-merge": "^2.5.0",
34
- "@frankieai/core": "0.2.1"
34
+ "@frankieai/core": "0.2.2"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@tailwindcss/cli": "^4.0.0",
@@ -46,6 +46,9 @@
46
46
  "typescript": "^5.7.0",
47
47
  "vitest": "^3.0.0"
48
48
  },
49
+ "engines": {
50
+ "node": ">=20"
51
+ },
49
52
  "scripts": {
50
53
  "build": "tsup && pnpm build:css",
51
54
  "build:css": "tailwindcss -i src/styles/frankie.css -o dist/frankie.css --minify",
@@ -65,6 +65,9 @@ export function ChatPanel({
65
65
  const features = config?.features;
66
66
  const pickerModels = features?.modelPicker.enabled ? features.modelPicker.models : [];
67
67
  const [model, setModel] = useState<string | undefined>(undefined);
68
+ // A "Something else" choice focuses the composer through this handle.
69
+ const composerFocus = useRef<(() => void) | null>(null);
70
+ const focusComposer = () => composerFocus.current?.();
68
71
 
69
72
  useEffect(() => {
70
73
  const node = scrollRef.current;
@@ -138,7 +141,7 @@ export function ChatPanel({
138
141
  ))
139
142
  ) : (
140
143
  <div className="frankie-message-list flex flex-col gap-4">
141
- {messages.map((message) =>
144
+ {messages.map((message, index) =>
142
145
  renderMessage ? (
143
146
  <div key={message.id}>{renderMessage(message)}</div>
144
147
  ) : (
@@ -149,6 +152,10 @@ export function ChatPanel({
149
152
  {...(config?.branding.avatarUrl ? { avatarUrl: config.branding.avatarUrl } : {})}
150
153
  showToolActivity={config?.showToolActivity ?? true}
151
154
  {...(renderTool ? { renderTool } : {})}
155
+ onChoose={send}
156
+ onRequestInput={focusComposer}
157
+ active={index === messages.length - 1}
158
+ busy={busy}
152
159
  />
153
160
  ),
154
161
  )}
@@ -164,6 +171,7 @@ export function ChatPanel({
164
171
  onSend={send}
165
172
  onStop={stop}
166
173
  busy={busy}
174
+ focusHandleRef={composerFocus}
167
175
  disabled={loading || restoring || Boolean(configError)}
168
176
  {...(placeholder ? { placeholder } : {})}
169
177
  {...(autoFocus ? { autoFocus } : {})}
@@ -0,0 +1,52 @@
1
+ "use client";
2
+
3
+ import { ArrowRight, PencilLine } from "lucide-react";
4
+
5
+ /**
6
+ * Clickable answer options the agent offered via a `frankie:choices` block.
7
+ * Picking one sends it as the next message. A trailing "Something else" row
8
+ * doesn't send — it moves the caret to the composer so the user can type their
9
+ * own answer, while the offered options stay put in case they change their mind.
10
+ */
11
+ export function Choices({
12
+ options,
13
+ onPick,
14
+ onSomethingElse,
15
+ disabled,
16
+ }: {
17
+ options: string[];
18
+ onPick: (choice: string) => void;
19
+ onSomethingElse?: () => void;
20
+ disabled?: boolean;
21
+ }) {
22
+ if (options.length === 0) return null;
23
+ return (
24
+ <div className="frankie-choices" role="group" aria-label="Choose an option">
25
+ {options.map((option, index) => (
26
+ <button
27
+ key={`${index}-${option}`}
28
+ type="button"
29
+ className="frankie-choice"
30
+ disabled={disabled}
31
+ onClick={() => onPick(option)}
32
+ >
33
+ <span className="frankie-choice-index" aria-hidden>{index + 1}</span>
34
+ <span className="frankie-choice-label">{option}</span>
35
+ <ArrowRight className="frankie-choice-go size-4" aria-hidden />
36
+ </button>
37
+ ))}
38
+ {onSomethingElse ? (
39
+ <button
40
+ type="button"
41
+ className="frankie-choice frankie-choice-other"
42
+ disabled={disabled}
43
+ onClick={onSomethingElse}
44
+ >
45
+ <span className="frankie-choice-index" aria-hidden><PencilLine className="size-3.5" /></span>
46
+ <span className="frankie-choice-label">Something else…</span>
47
+ <span className="frankie-choice-hint" aria-hidden>Type your own</span>
48
+ </button>
49
+ ) : null}
50
+ </div>
51
+ );
52
+ }
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { ArrowUp, Paperclip, Plus, Square, X } from "lucide-react";
4
- import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from "react";
4
+ import { useCallback, useEffect, useRef, useState, type KeyboardEvent, type MutableRefObject } from "react";
5
5
  import type { ChatAttachment } from "@frankieai/core";
6
6
  import { Button } from "../ui/button.js";
7
7
  import { Textarea } from "../ui/textarea.js";
@@ -31,6 +31,8 @@ export interface ComposerProps {
31
31
  attachments?: ComposerAttachmentsConfig;
32
32
  /** Enables a model dropdown when provided with at least one model. */
33
33
  modelPicker?: ComposerModelPicker;
34
+ /** Populated with a function that focuses the input, so a "type your own" choice can jump here. */
35
+ focusHandleRef?: MutableRefObject<(() => void) | null>;
34
36
  }
35
37
 
36
38
  interface PickedFile extends ChatAttachment {
@@ -39,13 +41,21 @@ interface PickedFile extends ChatAttachment {
39
41
 
40
42
  const MAX_ROWS_PX = 140;
41
43
 
42
- export function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocus, attachments, modelPicker }: ComposerProps) {
44
+ export function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocus, attachments, modelPicker, focusHandleRef }: ComposerProps) {
43
45
  const [value, setValue] = useState("");
44
46
  const [files, setFiles] = useState<PickedFile[]>([]);
45
47
  const [fileError, setFileError] = useState<string | undefined>(undefined);
46
48
  const textareaRef = useRef<HTMLTextAreaElement>(null);
47
49
  const fileInputRef = useRef<HTMLInputElement>(null);
48
50
 
51
+ // Publish a focus handle so a "Something else" choice elsewhere in the panel
52
+ // can move the caret here without reaching into the DOM.
53
+ useEffect(() => {
54
+ if (!focusHandleRef) return;
55
+ focusHandleRef.current = () => textareaRef.current?.focus();
56
+ return () => { focusHandleRef.current = null; };
57
+ }, [focusHandleRef]);
58
+
49
59
  const canAttach = Boolean(attachments?.enabled);
50
60
  const models = modelPicker?.models ?? [];
51
61
 
@@ -5,6 +5,7 @@ import { ChevronsLeft, ChevronsRight, History, Menu, Plus, Settings, X } from "l
5
5
  import type { FrankieProviderProps } from "../../hooks/FrankieProvider.jsx";
6
6
  import { FrankieProvider, useFrankie, useFrankieTheme } from "../../hooks/FrankieProvider.jsx";
7
7
  import { useFrankieChat } from "../../hooks/useFrankieChat.js";
8
+ import { useStylesReady } from "../../lib/styles-ready.js";
8
9
  import { themeToCssVars, resolveMode } from "../../lib/theme.js";
9
10
  import { FrankieApiError, type ChatConversation } from "../../lib/client.js";
10
11
  import { FrankieInline } from "./FrankieInline.js";
@@ -104,7 +105,9 @@ function PageChrome({ showHistory, navigation, currentId, onSelect, onNew, ...pa
104
105
  export function FrankieChatPage({ className, style, header, footer, emptyState, renderMessage, renderTool, placeholder, autoFocus, showHistory = true, navigation, ...provider }: FrankieChatPageProps) {
105
106
  const [id, setId] = useState(() => provider.conversationId ?? crypto.randomUUID());
106
107
  const onNew = () => setId(crypto.randomUUID());
107
- return <div className={["frankie-root", "frankie-chat-page", className].filter(Boolean).join(" ")} style={{ height: "100%", minHeight: "min(36rem, 100dvh)", width: "100%", ...style }}>
108
+ const pageRef = useRef<HTMLDivElement>(null);
109
+ const stylesReady = useStylesReady(pageRef);
110
+ return <div ref={pageRef} className={["frankie-root", "frankie-chat-page", className].filter(Boolean).join(" ")} style={{ height: "100%", minHeight: "min(36rem, 100dvh)", width: "100%", ...style, ...(stylesReady ? null : { opacity: 0 as const }) }} data-frankie-styles={stylesReady ? "ready" : "pending"}>
108
111
  <FrankieProvider {...provider} conversationId={id} storageNamespace={(provider.storageNamespace ?? "frankie." + provider.publishableKey) + "." + id}>
109
112
  <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} />
110
113
  </FrankieProvider>
@@ -1,7 +1,8 @@
1
1
  "use client";
2
2
 
3
- import type { CSSProperties } from "react";
3
+ import { useRef, type CSSProperties } from "react";
4
4
  import { useFrankieTheme } from "../../hooks/FrankieProvider.jsx";
5
+ import { useStylesReady } from "../../lib/styles-ready.js";
5
6
  import { resolveMode, themeToCssVars } from "../../lib/theme.js";
6
7
  import { cn } from "../../lib/utils.js";
7
8
  import { ChatPanel, type ChatPanelProps } from "./ChatPanel.js";
@@ -23,11 +24,15 @@ export interface FrankieInlineProps extends ChatPanelProps {
23
24
  export function FrankieInline({ className, style, ...panelProps }: FrankieInlineProps) {
24
25
  const theme = useFrankieTheme();
25
26
  const mode = resolveMode(theme?.mode);
27
+ const rootRef = useRef<HTMLDivElement>(null);
28
+ const stylesReady = useStylesReady(rootRef);
26
29
  return (
27
30
  <div
31
+ ref={rootRef}
28
32
  className={cn("frankie-root frankie-inline", mode === "dark" && "dark", className)}
29
- style={{ ...themeToCssVars(theme), display: "flex", height: "100%", width: "100%", ...style }}
33
+ style={{ ...themeToCssVars(theme), display: "flex", height: "100%", width: "100%", ...style, ...(stylesReady ? null : { opacity: 0 as const }) }}
30
34
  data-frankie-mode={mode}
35
+ data-frankie-styles={stylesReady ? "ready" : "pending"}
31
36
  >
32
37
  <ChatPanel {...panelProps} fill />
33
38
  </div>
@@ -1,8 +1,9 @@
1
1
  "use client";
2
2
 
3
3
  import { MessageCircle, X } from "lucide-react";
4
- import { useEffect, useState } from "react";
4
+ import { useEffect, useRef, useState } from "react";
5
5
  import { useFrankie, useFrankieTheme } from "../../hooks/FrankieProvider.jsx";
6
+ import { useStylesReady } from "../../lib/styles-ready.js";
6
7
  import { resolveMode, themeToCssVars } from "../../lib/theme.js";
7
8
  import { cn } from "../../lib/utils.js";
8
9
  import { Button } from "../ui/button.js";
@@ -40,6 +41,9 @@ export function FrankieWidget({
40
41
  }: FrankieWidgetProps) {
41
42
  const { config } = useFrankie();
42
43
  const theme = useFrankieTheme();
44
+ const rootRef = useRef<HTMLDivElement>(null);
45
+ // Never paint the panel as raw HTML while the host's CSS chunk is still in flight.
46
+ const stylesReady = useStylesReady(rootRef);
43
47
  const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
44
48
  const open = controlledOpen ?? uncontrolledOpen;
45
49
 
@@ -65,9 +69,14 @@ export function FrankieWidget({
65
69
 
66
70
  return (
67
71
  <div
72
+ ref={rootRef}
68
73
  className={cn("frankie-root frankie-widget", mode === "dark" && "dark")}
69
- style={{ ...themeToCssVars(theme), position: "fixed", bottom: offset, ...side, zIndex, pointerEvents: "none" }}
74
+ // `opacity` rather than `visibility` or unmounting: the sentinel has to stay
75
+ // measurable on this element, and the two alternatives both drop the widget
76
+ // out of the accessibility tree for the wait.
77
+ style={{ ...themeToCssVars(theme), position: "fixed", bottom: offset, ...side, zIndex, pointerEvents: "none", ...(stylesReady ? null : { opacity: 0 as const }) }}
70
78
  data-frankie-mode={mode}
79
+ data-frankie-styles={stylesReady ? "ready" : "pending"}
71
80
  >
72
81
  <div className={cn("flex flex-col gap-3", position === "bottom-left" ? "items-start" : "items-end")}>
73
82
  {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}
@@ -1,19 +1,24 @@
1
1
  "use client";
2
2
 
3
- import { Fragment, type ReactNode } from "react";
3
+ import { Fragment, memo, useMemo, type ReactNode } from "react";
4
4
  import { cn } from "../../lib/utils.js";
5
5
 
6
6
  /**
7
- * A deliberately small Markdown subset — paragraphs, lists, fenced code, bold,
8
- * italic, inline code and links.
7
+ * A deliberately small Markdown subset — headings, paragraphs, lists, fenced
8
+ * code, bold, italic, inline code and links.
9
9
  *
10
10
  * Everything is rendered as React elements; nothing goes through
11
11
  * `dangerouslySetInnerHTML`. Model output lands in someone else's production
12
12
  * page, so it is never trusted as markup.
13
+ *
14
+ * Memoized on `text`: while a reply streams, the parent re-renders every frame,
15
+ * but the block tree is only recomputed when the text actually grows — the
16
+ * settled messages above never re-parse at all.
13
17
  */
14
- export function Markdown({ text, className }: { text: string; className?: string }) {
15
- return <div className={cn("frankie-prose", className)}>{renderBlocks(text)}</div>;
16
- }
18
+ export const Markdown = memo(function Markdown({ text, className }: { text: string; className?: string }) {
19
+ const blocks = useMemo(() => renderBlocks(text), [text]);
20
+ return <div className={cn("frankie-prose", className)}>{blocks}</div>;
21
+ });
17
22
 
18
23
  function renderBlocks(text: string): ReactNode[] {
19
24
  const lines = text.split("\n");
@@ -75,11 +80,13 @@ function renderBlocks(text: string): ReactNode[] {
75
80
  continue;
76
81
  }
77
82
 
78
- const heading = /^#{1,6}\s+(.*)$/.exec(line);
83
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line);
79
84
  if (heading) {
80
85
  flushParagraph();
81
86
  flushList();
82
- blocks.push(<p key={`h${blocks.length}`} className="font-semibold">{renderInline(heading[1] ?? "")}</p>);
87
+ const level = (heading[1] ?? "#").length;
88
+ const Tag = level <= 1 ? "h3" : level === 2 ? "h4" : "h5";
89
+ blocks.push(<Tag key={`h${blocks.length}`} className="frankie-h">{renderInline(heading[2] ?? "")}</Tag>);
83
90
  continue;
84
91
  }
85
92
 
@@ -2,10 +2,12 @@
2
2
 
3
3
  import { Check, Copy, Paperclip } from "lucide-react";
4
4
  import "./message-actions.css";
5
- import { useState, type ReactNode } from "react";
5
+ import { memo, useState, type ReactNode } from "react";
6
6
  import { cn } from "../../lib/utils.js";
7
+ import { extractChoices } from "../../lib/choices.js";
7
8
  import type { ChatMessage, ToolActivity } from "../../lib/messages.js";
8
9
  import { Avatar } from "../ui/avatar.js";
10
+ import { Choices } from "./Choices.js";
9
11
  import { Markdown } from "./Markdown.js";
10
12
  import { ToolActivityList } from "./ToolActivityList.js";
11
13
 
@@ -15,14 +17,29 @@ export interface MessageBubbleProps {
15
17
  avatarUrl?: string;
16
18
  showToolActivity: boolean;
17
19
  renderTool?: (tool: ToolActivity) => ReactNode;
20
+ /** Send a picked choice as the next message. Omitted when the host renders its own. */
21
+ onChoose?: (choice: string) => void;
22
+ /** Focus the composer so the user can type their own answer instead of picking. */
23
+ onRequestInput?: () => void;
24
+ /** True only for the newest message, so stale choices from earlier turns aren't clickable. */
25
+ active?: boolean;
26
+ /** A turn is in flight, so choices should be disabled. */
27
+ busy?: boolean;
18
28
  }
19
29
 
20
- export function MessageBubble({
30
+ // Memoized: during a stream only the message whose object identity changed (the
31
+ // one being written) re-renders. Every earlier bubble keeps its parsed markdown,
32
+ // which is what keeps a long transcript from re-laying-out on every token.
33
+ export const MessageBubble = memo(function MessageBubble({
21
34
  message,
22
35
  assistantName,
23
36
  avatarUrl,
24
37
  showToolActivity,
25
38
  renderTool,
39
+ onChoose,
40
+ onRequestInput,
41
+ active,
42
+ busy,
26
43
  }: MessageBubbleProps) {
27
44
  if (message.role === "user") {
28
45
  return (
@@ -51,12 +68,18 @@ export function MessageBubble({
51
68
  }
52
69
 
53
70
  const empty = message.text.length === 0 && message.tools.length === 0;
71
+ // Lift any offered options out of the prose. While the reply is still
72
+ // streaming, a half-written block is hidden (options stays null) so the raw
73
+ // fence never flashes. Options become clickable only on the newest, settled
74
+ // message so answering an earlier turn's question can't jump the transcript.
75
+ const { body, options } = extractChoices(message.text);
76
+ const showChoices = Boolean(active && onChoose && options && message.status === "done");
54
77
 
55
78
  return (
56
79
  <div className="frankie-assistant-message flex gap-2">
57
80
  <div className="frankie-assistant-actions mt-0.5 flex shrink-0 flex-col items-center gap-1">
58
81
  <Avatar {...(avatarUrl ? { src: avatarUrl } : {})} name={assistantName} />
59
- {message.text ? <MessageCopyButton text={message.text} /> : null}
82
+ {body ? <MessageCopyButton text={body} /> : null}
60
83
  </div>
61
84
  <div className="frankie-assistant-content min-w-0 max-w-[85%]">
62
85
  {message.status === "error" ? (
@@ -66,15 +89,16 @@ export function MessageBubble({
66
89
  ) : empty && message.status === "streaming" ? (
67
90
  <TypingDots />
68
91
  ) : (
69
- <Markdown text={message.text} className="text-sm" />
92
+ <Markdown text={body} className="text-sm" />
70
93
  )}
94
+ {showChoices ? <Choices options={options ?? []} onPick={onChoose!} {...(onRequestInput ? { onSomethingElse: onRequestInput } : {})} disabled={busy} /> : null}
71
95
  {showToolActivity ? (
72
96
  <ToolActivityList tools={message.tools} {...(renderTool ? { renderTool } : {})} />
73
97
  ) : null}
74
98
  </div>
75
99
  </div>
76
100
  );
77
- }
101
+ });
78
102
 
79
103
  function MessageCopyButton({ text }: { text: string }) {
80
104
  const [copied, setCopied] = useState(false);
@@ -162,6 +162,49 @@ export function useFrankieChatState(): UseFrankieChatResult {
162
162
  const abort = new AbortController();
163
163
  abortRef.current = abort;
164
164
 
165
+ // Models stream text in uneven bursts — a few characters, then a lull,
166
+ // then a whole paragraph at once — which reads as a stutter no matter how
167
+ // efficiently React renders it. So we don't paint what arrives; we buffer
168
+ // it and *reveal* it at a steady cadence, a slice per animation frame, so
169
+ // the answer types out smoothly regardless of how lumpy the network is.
170
+ // The slice is a fraction of what's waiting, so a big burst drains fast
171
+ // (and never lags far behind) while a trickle still advances a few chars a
172
+ // frame. Thinking isn't rendered, so it commits in full without pacing.
173
+ // Any structural event, the stream's end, or an abort flushes the whole
174
+ // remaining buffer at once, so ordering holds and no token is dropped.
175
+ let bufferedText = "";
176
+ let pendingThinking = "";
177
+ let frame: number | null = null;
178
+ const raf = typeof requestAnimationFrame === "function" ? requestAnimationFrame : (cb: FrameRequestCallback) => setTimeout(() => cb(performance.now()), 16) as unknown as number;
179
+ const cancelRaf = typeof cancelAnimationFrame === "function" ? cancelAnimationFrame : (clearTimeout as (id: number) => void);
180
+ const revealTick = () => {
181
+ frame = null;
182
+ let addText = "";
183
+ if (bufferedText) {
184
+ // Reveal ~1/6 of what's waiting (at least a few chars) so the pace
185
+ // eases with the backlog: snappy on a burst, gentle on a trickle.
186
+ const step = Math.max(3, Math.ceil(bufferedText.length / 6));
187
+ addText = bufferedText.slice(0, step);
188
+ bufferedText = bufferedText.slice(step);
189
+ }
190
+ const addThinking = pendingThinking;
191
+ pendingThinking = "";
192
+ if (addText || addThinking) {
193
+ patchAssistant(assistantId, (message) => ({ ...message, text: message.text + addText, thinking: message.thinking + addThinking }));
194
+ }
195
+ if (bufferedText) scheduleReveal();
196
+ };
197
+ const scheduleReveal = () => { if (frame === null) frame = raf(revealTick); };
198
+ const flushDeltas = () => {
199
+ if (frame !== null) { cancelRaf(frame); frame = null; }
200
+ if (!bufferedText && !pendingThinking) return;
201
+ const addText = bufferedText;
202
+ const addThinking = pendingThinking;
203
+ bufferedText = "";
204
+ pendingThinking = "";
205
+ patchAssistant(assistantId, (message) => ({ ...message, text: message.text + addText, thinking: message.thinking + addThinking }));
206
+ };
207
+
165
208
  try {
166
209
  let receivedText = false;
167
210
  let receivedToolActivity = false;
@@ -177,11 +220,18 @@ export function useFrankieChatState(): UseFrankieChatResult {
177
220
  if (event.type === "skills_loaded") { setLoadedSkills(event.skills); return; }
178
221
  if (event.type === "tools_active") { setActiveApis(event.apis); return; }
179
222
  if (event.type === "ping" || event.type === "session") return;
180
- if (event.type === "text_delta") receivedText = true;
223
+ if (event.type === "text_delta") { receivedText = true; bufferedText += event.text; scheduleReveal(); return; }
224
+ if (event.type === "thinking_delta") { pendingThinking += event.text; scheduleReveal(); return; }
181
225
  if (event.type === "tool_start" || event.type === "tool_end") receivedToolActivity = true;
226
+ // A structural event: land the buffered text before it so the tool
227
+ // row or terminal status sits in the right place in the transcript.
228
+ flushDeltas();
182
229
  patchAssistant(assistantId, (message) => applyEvent(message, event));
183
230
  },
184
231
  );
232
+ // Commit anything still buffered when the stream ends, so the closing
233
+ // words appear at once instead of trickling after the reply is done.
234
+ flushDeltas();
185
235
  // A terminated stream with no text previously looked like Frankie did
186
236
  // nothing. Preserve visible tool activity, but never leave an empty
187
237
  // assistant bubble for the user to interpret.
@@ -195,6 +245,9 @@ export function useFrankieChatState(): UseFrankieChatResult {
195
245
  }));
196
246
  }
197
247
  } catch (caught) {
248
+ // Land any tokens buffered when the stream broke off, so a stopped or
249
+ // failed turn still shows everything that had arrived.
250
+ flushDeltas();
198
251
  if (abort.signal.aborted) {
199
252
  patchAssistant(assistantId, (message) => ({
200
253
  ...message,
@@ -212,6 +265,9 @@ export function useFrankieChatState(): UseFrankieChatResult {
212
265
  patchAssistant(assistantId, (current) => ({ ...current, status: "error", error: message }));
213
266
  }
214
267
  } finally {
268
+ // Ensure no frame is left pending and every buffered token is committed
269
+ // before the turn settles and the transcript is persisted.
270
+ flushDeltas();
215
271
  // A turn that ended without a `turn_end` event still has to settle.
216
272
  patchAssistant(assistantId, (message) =>
217
273
  message.status === "streaming" ? { ...message, status: "done" } : message,
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export type { ChatPanelProps } from "./components/chat/ChatPanel.js";
14
14
  export { Composer } from "./components/chat/Composer.js";
15
15
  export { MessageBubble, TypingDots } from "./components/chat/MessageBubble.js";
16
16
  export { Markdown } from "./components/chat/Markdown.js";
17
+ export { Choices } from "./components/chat/Choices.js";
17
18
  export { SuggestedPrompts } from "./components/chat/SuggestedPrompts.js";
18
19
  export { ToolActivityList, ToolActivityRow } from "./components/chat/ToolActivityList.js";
19
20
 
@@ -30,6 +31,8 @@ export { FrankieClient, FrankieApiError, readEventStream, FRANKIE_CLOUD_URL } fr
30
31
  export type { FrankieClientOptions, PageContext } from "./lib/client.js";
31
32
  export { applyEvent, newAssistantMessage, newUserMessage } from "./lib/messages.js";
32
33
  export type { AssistantChatMessage, ChatMessage, MessageAttachment, ToolActivity, UserChatMessage } from "./lib/messages.js";
34
+ export { extractChoices } from "./lib/choices.js";
35
+ export type { ExtractedChoices } from "./lib/choices.js";
33
36
  export { resolveMode, themeToCssVars } from "./lib/theme.js";
34
37
  export { cn } from "./lib/utils.js";
35
38
 
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Interactive choice blocks.
3
+ *
4
+ * The agent can end a reply with a fenced block to offer the user a small set
5
+ * of discrete answers:
6
+ *
7
+ * ```frankie:choices
8
+ * British
9
+ * Indian
10
+ * Australian
11
+ * ```
12
+ *
13
+ * The SDK lifts that block out of the prose and renders it as clickable options
14
+ * (the composer stays open, so the user can always type their own answer). The
15
+ * block is a plain fenced code block, so a host that doesn't understand it just
16
+ * shows a harmless code listing instead — never broken markup.
17
+ */
18
+ export interface ExtractedChoices {
19
+ /** The reply text with the choices block removed. */
20
+ body: string;
21
+ /** The offered options, or null when there is no complete block. */
22
+ options: string[] | null;
23
+ }
24
+
25
+ const CLOSED = /```frankie:choices[ \t]*\r?\n([\s\S]*?)```/;
26
+ const OPEN = /```frankie:choices[ \t]*\r?\n?[\s\S]*$/;
27
+
28
+ export function extractChoices(text: string): ExtractedChoices {
29
+ const closed = CLOSED.exec(text);
30
+ if (closed) {
31
+ const options = parseOptionLines(closed[1] ?? "");
32
+ const body = (text.slice(0, closed.index) + text.slice(closed.index + closed[0].length))
33
+ .replace(/\n{3,}/g, "\n\n")
34
+ .trimEnd();
35
+ return { body, options: options.length ? options : null };
36
+ }
37
+ // An unterminated block means the reply is still streaming the options in.
38
+ // Hide the half-written fence so the user never sees the raw ``` marker.
39
+ const open = OPEN.exec(text);
40
+ if (open) return { body: text.slice(0, open.index).trimEnd(), options: null };
41
+ return { body: text, options: null };
42
+ }
43
+
44
+ function parseOptionLines(block: string): string[] {
45
+ return block
46
+ .split("\n")
47
+ .map((line) => line.trim().replace(/^([-*+]|\d+[.)])\s+/, "").trim())
48
+ .filter(Boolean);
49
+ }
@@ -0,0 +1,71 @@
1
+ import { useEffect, useLayoutEffect, useState, type RefObject } from "react";
2
+
3
+ /** `useLayoutEffect` warns during SSR, where there is nothing to measure anyway. */
4
+ const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
5
+
6
+ /** The property `frankie.css` sets on `.frankie-root`, and the value it sets. */
7
+ const SENTINEL = "--frankie-styles-ready";
8
+
9
+ /**
10
+ * Whether this widget's stylesheet has actually been applied to `node`.
11
+ *
12
+ * The widget is mounted inside someone else's application and bundler. A host
13
+ * that imports `@frankieai/react/styles.css` from a client component ships it
14
+ * in that component's chunk, and on a client-side navigation React can render
15
+ * the panel before the CSS arrives — the panel then paints as raw HTML: native
16
+ * `<details>` markers, no pills, browser-default type. A full reload hides this
17
+ * because the stylesheet is in `<head>` before first paint.
18
+ *
19
+ * Probing a custom property costs nothing and asks the only question that
20
+ * matters — are our rules in effect for this element — rather than guessing at
21
+ * `<link>` elements the host may have inlined, bundled or renamed.
22
+ *
23
+ * Callers hide the root with `opacity` for the wait rather than `visibility` or
24
+ * unmounting: the sentinel must stay measurable on that element, and both of the
25
+ * alternatives would also drop the widget out of the accessibility tree.
26
+ *
27
+ * The wait is deliberately bounded. A Tailwind v4 host is told it may skip our
28
+ * stylesheet and compile the utilities itself, and such a page never sets the
29
+ * sentinel; it must still get a widget. After `timeoutMs` we render regardless,
30
+ * which is exactly today's behaviour.
31
+ */
32
+ export function useStylesReady(ref: RefObject<HTMLElement | null>, timeoutMs = 600): boolean {
33
+ const [ready, setReady] = useState(false);
34
+
35
+ useIsomorphicLayoutEffect(() => {
36
+ if (ready) return;
37
+ const node = ref.current;
38
+ if (!node || typeof window === "undefined" || typeof window.getComputedStyle !== "function") {
39
+ setReady(true);
40
+ return;
41
+ }
42
+
43
+ const applied = () => window.getComputedStyle(node).getPropertyValue(SENTINEL).trim() === "1";
44
+ // The common case — stylesheet already in `<head>` — resolves here, inside a
45
+ // layout effect, so nothing unstyled is ever painted and nothing flickers.
46
+ if (applied()) {
47
+ setReady(true);
48
+ return;
49
+ }
50
+
51
+ const deadline = Date.now() + timeoutMs;
52
+ let frame = 0;
53
+ const poll = () => {
54
+ if (applied() || Date.now() >= deadline) {
55
+ setReady(true);
56
+ return;
57
+ }
58
+ frame = window.requestAnimationFrame(poll);
59
+ };
60
+ // `requestAnimationFrame` is missing in some test environments; fall back to
61
+ // rendering rather than hiding the widget forever.
62
+ if (typeof window.requestAnimationFrame !== "function") {
63
+ setReady(true);
64
+ return;
65
+ }
66
+ frame = window.requestAnimationFrame(poll);
67
+ return () => window.cancelAnimationFrame(frame);
68
+ }, [ref, ready, timeoutMs]);
69
+
70
+ return ready;
71
+ }