@frankieai/react 0.2.7 → 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.7",
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",
@@ -6,7 +6,7 @@ import { useFrankieChat } from "../../hooks/useFrankieChat.js";
6
6
  /** Reports server-confirmed skills for the current turn; never implies drafts are active. */
7
7
  export function AgentContext() {
8
8
  const { loading, configError } = useFrankie();
9
- const { loadedSkills, busy, restoring, error } = useFrankieChat();
9
+ const { loadedSkills, activeApis, busy, restoring, error } = useFrankieChat();
10
10
  const status = configError || error ? "Unavailable" : loading || restoring ? "Connecting" : busy && loadedSkills === null ? "Checking" : loadedSkills?.length ? "Active" : loadedSkills ? "Tools only" : "Ready";
11
11
  return <div className="frankie-agent-context-wrap">
12
12
  <details className="frankie-agent-context">
@@ -15,6 +15,7 @@ export function AgentContext() {
15
15
  <strong>{loadedSkills === null ? "Skills are checked when you send a message" : loadedSkills.length ? `${loadedSkills.length} ${loadedSkills.length === 1 ? "skill" : "skills"} loaded` : "No skills loaded for this turn"}</strong>
16
16
  {loadedSkills?.length ? <ul>{loadedSkills.map(skill => <li key={skill.id}><span>{skill.name}</span><small>v{skill.revision}</small></li>)}</ul> : <p>Frankie can use the tools approved for this workspace. Drafts and skills without available tools are not loaded.</p>}
17
17
  <p>Published skills and updates are checked on every message. Only approved, available tools can run.</p>
18
+ {activeApis?.length ? <ul className="frankie-active-apis">{activeApis.map(api => <li key={api.name}><span>{api.name}</span><small>spec {api.specHash} · {api.operationCount} {api.operationCount === 1 ? "operation" : "operations"}</small></li>)}</ul> : null}
18
19
  </div>
19
20
  </details>
20
21
  <span className="frankie-sr-only" role="status">{loadedSkills === null ? status : `${loadedSkills.length} ${loadedSkills.length === 1 ? "skill" : "skills"} loaded. ${status}.`}</span>
@@ -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);
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { createContext, useContext, useCallback, useEffect, useRef, useState } from "react";
4
- import type { ChatAttachment, LoadedSkill } from "@frankieai/core";
4
+ import type { ApiToolInventory, ChatAttachment, LoadedSkill } from "@frankieai/core";
5
5
  import { FrankieApiError } from "../lib/client.js";
6
6
  import {
7
7
  applyEvent,
@@ -20,6 +20,8 @@ export interface SendOptions {
20
20
 
21
21
  export interface UseFrankieChatResult {
22
22
  loadedSkills: LoadedSkill[] | null;
23
+ /** Which OpenAPI sources are live for this turn, and a content fingerprint of each. */
24
+ activeApis: ApiToolInventory[] | null;
23
25
  restoring: boolean;
24
26
  messages: ChatMessage[];
25
27
  /** True from the moment a message is sent until the turn ends. */
@@ -50,6 +52,7 @@ export function useFrankieChatState(): UseFrankieChatResult {
50
52
  const [error, setError] = useState<string | undefined>(undefined);
51
53
  const [restored, setRestored] = useState(false);
52
54
  const [loadedSkills, setLoadedSkills] = useState<LoadedSkill[] | null>(null);
55
+ const [activeApis, setActiveApis] = useState<ApiToolInventory[] | null>(null);
53
56
  const abortRef = useRef<AbortController | null>(null);
54
57
  const mounted = useRef(true);
55
58
  // State updates can be batched while a stream is arriving. Keep the latest
@@ -142,6 +145,7 @@ export function useFrankieChatState(): UseFrankieChatResult {
142
145
  const assistantId = nextId("a");
143
146
  setError(undefined);
144
147
  setLoadedSkills(null);
148
+ setActiveApis(null);
145
149
  setBusy(true);
146
150
  const next = [
147
151
  ...messagesRef.current,
@@ -158,6 +162,49 @@ export function useFrankieChatState(): UseFrankieChatResult {
158
162
  const abort = new AbortController();
159
163
  abortRef.current = abort;
160
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
+
161
208
  try {
162
209
  let receivedText = false;
163
210
  let receivedToolActivity = false;
@@ -171,12 +218,20 @@ export function useFrankieChatState(): UseFrankieChatResult {
171
218
  },
172
219
  (event) => {
173
220
  if (event.type === "skills_loaded") { setLoadedSkills(event.skills); return; }
221
+ if (event.type === "tools_active") { setActiveApis(event.apis); return; }
174
222
  if (event.type === "ping" || event.type === "session") return;
175
- 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; }
176
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();
177
229
  patchAssistant(assistantId, (message) => applyEvent(message, event));
178
230
  },
179
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();
180
235
  // A terminated stream with no text previously looked like Frankie did
181
236
  // nothing. Preserve visible tool activity, but never leave an empty
182
237
  // assistant bubble for the user to interpret.
@@ -190,6 +245,9 @@ export function useFrankieChatState(): UseFrankieChatResult {
190
245
  }));
191
246
  }
192
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();
193
251
  if (abort.signal.aborted) {
194
252
  patchAssistant(assistantId, (message) => ({
195
253
  ...message,
@@ -207,6 +265,9 @@ export function useFrankieChatState(): UseFrankieChatResult {
207
265
  patchAssistant(assistantId, (current) => ({ ...current, status: "error", error: message }));
208
266
  }
209
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();
210
271
  // A turn that ended without a `turn_end` event still has to settle.
211
272
  patchAssistant(assistantId, (message) =>
212
273
  message.status === "streaming" ? { ...message, status: "done" } : message,
@@ -236,6 +297,7 @@ export function useFrankieChatState(): UseFrankieChatResult {
236
297
  setBusy(false);
237
298
  setRestored(true);
238
299
  setLoadedSkills(null);
300
+ setActiveApis(null);
239
301
  try {
240
302
  if (client.persist) window.localStorage.removeItem(`${client.namespace}.messages`);
241
303
  } catch {
@@ -243,7 +305,7 @@ export function useFrankieChatState(): UseFrankieChatResult {
243
305
  }
244
306
  }, [client]);
245
307
 
246
- return { messages, busy, loadedSkills, restoring: !restored, ...(error ? { error } : {}), send, stop, reset };
308
+ return { messages, busy, loadedSkills, activeApis, restoring: !restored, ...(error ? { error } : {}), send, stop, reset };
247
309
  }
248
310
 
249
311
 
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
+ }