@frankieai/react 0.2.6 → 0.2.8
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/frankie.css +1 -1
- package/dist/index.cjs +32 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +32 -5
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/AgentContext.tsx +2 -1
- package/src/components/chat/ChatPanel.tsx +21 -3
- package/src/hooks/useFrankieChat.ts +8 -2
- package/src/widget.test.tsx +51 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frankieai/react",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
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",
|
|
@@ -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>
|
|
@@ -52,17 +52,35 @@ export function ChatPanel({
|
|
|
52
52
|
const theme = useFrankieTheme();
|
|
53
53
|
const { messages, busy, restoring, error, send, stop, reset } = useFrankieChat();
|
|
54
54
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
55
|
+
// Tracks whether the reader was at the bottom just before this render, not
|
|
56
|
+
// where the bottom ends up after it. Measuring post-render (against the
|
|
57
|
+
// already-grown scrollHeight) breaks as soon as one update — a sent message
|
|
58
|
+
// plus the reply's opening burst of text, say — adds more than the near-
|
|
59
|
+
// bottom threshold in a single paint: the check then reads "scrolled away"
|
|
60
|
+
// even though the reader never touched the scrollbar, and every later
|
|
61
|
+
// delta stops following. A live scroll listener captures the real, current
|
|
62
|
+
// position instead of re-deriving a stale one from updated content.
|
|
63
|
+
const atBottomRef = useRef(true);
|
|
55
64
|
|
|
56
65
|
const features = config?.features;
|
|
57
66
|
const pickerModels = features?.modelPicker.enabled ? features.modelPicker.models : [];
|
|
58
67
|
const [model, setModel] = useState<string | undefined>(undefined);
|
|
59
68
|
|
|
60
|
-
// Follow the stream, but only while the reader is already near the bottom.
|
|
61
69
|
useEffect(() => {
|
|
62
70
|
const node = scrollRef.current;
|
|
63
71
|
if (!node) return;
|
|
64
|
-
const
|
|
65
|
-
|
|
72
|
+
const onScroll = () => {
|
|
73
|
+
atBottomRef.current = node.scrollHeight - node.scrollTop - node.clientHeight < 160;
|
|
74
|
+
};
|
|
75
|
+
node.addEventListener("scroll", onScroll, { passive: true });
|
|
76
|
+
return () => node.removeEventListener("scroll", onScroll);
|
|
77
|
+
}, []);
|
|
78
|
+
|
|
79
|
+
// Follow the stream, but only while the reader is already near the bottom.
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
const node = scrollRef.current;
|
|
82
|
+
if (!node || !atBottomRef.current) return;
|
|
83
|
+
node.scrollTop = node.scrollHeight;
|
|
66
84
|
}, [messages]);
|
|
67
85
|
|
|
68
86
|
const name = config?.branding.name ?? "Assistant";
|
|
@@ -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,
|
|
@@ -171,6 +175,7 @@ export function useFrankieChatState(): UseFrankieChatResult {
|
|
|
171
175
|
},
|
|
172
176
|
(event) => {
|
|
173
177
|
if (event.type === "skills_loaded") { setLoadedSkills(event.skills); return; }
|
|
178
|
+
if (event.type === "tools_active") { setActiveApis(event.apis); return; }
|
|
174
179
|
if (event.type === "ping" || event.type === "session") return;
|
|
175
180
|
if (event.type === "text_delta") receivedText = true;
|
|
176
181
|
if (event.type === "tool_start" || event.type === "tool_end") receivedToolActivity = true;
|
|
@@ -236,6 +241,7 @@ export function useFrankieChatState(): UseFrankieChatResult {
|
|
|
236
241
|
setBusy(false);
|
|
237
242
|
setRestored(true);
|
|
238
243
|
setLoadedSkills(null);
|
|
244
|
+
setActiveApis(null);
|
|
239
245
|
try {
|
|
240
246
|
if (client.persist) window.localStorage.removeItem(`${client.namespace}.messages`);
|
|
241
247
|
} catch {
|
|
@@ -243,7 +249,7 @@ export function useFrankieChatState(): UseFrankieChatResult {
|
|
|
243
249
|
}
|
|
244
250
|
}, [client]);
|
|
245
251
|
|
|
246
|
-
return { messages, busy, loadedSkills, restoring: !restored, ...(error ? { error } : {}), send, stop, reset };
|
|
252
|
+
return { messages, busy, loadedSkills, activeApis, restoring: !restored, ...(error ? { error } : {}), send, stop, reset };
|
|
247
253
|
}
|
|
248
254
|
|
|
249
255
|
|
package/src/widget.test.tsx
CHANGED
|
@@ -227,6 +227,57 @@ describe("ChatPanel", () => {
|
|
|
227
227
|
expect(screen.queryByText("1 skill loaded")).toBeNull();
|
|
228
228
|
cleanup();
|
|
229
229
|
});
|
|
230
|
+
it("keeps following new messages even when one update grows the transcript past the near-bottom threshold", async () => {
|
|
231
|
+
cleanup();
|
|
232
|
+
const fetchImpl = vi.fn(async (url: string | URL | Request) =>
|
|
233
|
+
String(url).endsWith("/v1/session")
|
|
234
|
+
? Response.json({ sessionId: "scroll", sessionToken: "fixture", expiresAt: new Date(Date.now() + 600_000).toISOString(), user: { id: "one", anonymous: true } })
|
|
235
|
+
: new Response(sseStream([{ type: "text_delta", text: "A substantially longer reply than the one before it, to grow the transcript by more than the near-bottom threshold in a single update." }, { type: "turn_end", stopReason: "end_turn" }])),
|
|
236
|
+
);
|
|
237
|
+
const { container } = render(<FrankieProvider publishableKey="pk_scroll_fixture" config={config} fetchImpl={fetchImpl} persist={false}><ChatPanel fill /></FrankieProvider>);
|
|
238
|
+
const scroller = container.querySelector(".frankie-transcript") as HTMLElement;
|
|
239
|
+
let scrollTop = 0;
|
|
240
|
+
// jsdom has no layout engine, so scrollHeight/clientHeight are always 0.
|
|
241
|
+
// Tie scrollHeight to the actual rendered text so it grows the way a real
|
|
242
|
+
// browser's would as messages stream in, without hand-stepping it.
|
|
243
|
+
Object.defineProperty(scroller, "clientHeight", { configurable: true, get: () => 400 });
|
|
244
|
+
Object.defineProperty(scroller, "scrollHeight", { configurable: true, get: () => (scroller.textContent?.length ?? 0) * 50 });
|
|
245
|
+
Object.defineProperty(scroller, "scrollTop", { configurable: true, get: () => scrollTop, set: (value: number) => { scrollTop = value; } });
|
|
246
|
+
|
|
247
|
+
await waitFor(() => expect((screen.getByLabelText("Message") as HTMLTextAreaElement).disabled).toBe(false));
|
|
248
|
+
fireEvent.change(screen.getByLabelText("Message"), { target: { value: "First question" } });
|
|
249
|
+
fireEvent.click(screen.getByRole("button", { name: "Send" }));
|
|
250
|
+
await waitFor(() => expect(scrollTop).toBe(scroller.scrollHeight));
|
|
251
|
+
|
|
252
|
+
// The reader never touched the scrollbar between turns — they were left
|
|
253
|
+
// exactly at the bottom by the first turn. A second turn whose reply
|
|
254
|
+
// alone adds far more than 160px of content in one commit must still be
|
|
255
|
+
// followed: recomputing "distance from bottom" from the post-update DOM
|
|
256
|
+
// (the pre-fix approach) misreads that jump as the reader having
|
|
257
|
+
// scrolled away, and stops following for the rest of the turn.
|
|
258
|
+
fireEvent.change(screen.getByLabelText("Message"), { target: { value: "Second question" } });
|
|
259
|
+
fireEvent.click(screen.getByRole("button", { name: "Send" }));
|
|
260
|
+
await screen.findByText(/A substantially longer reply/);
|
|
261
|
+
await waitFor(() => expect(scrollTop).toBe(scroller.scrollHeight));
|
|
262
|
+
cleanup();
|
|
263
|
+
});
|
|
264
|
+
it("shows the active API spec hash and operation count once tools_active arrives", async () => {
|
|
265
|
+
cleanup();
|
|
266
|
+
const fetchImpl = vi.fn(async (url: string | URL | Request) =>
|
|
267
|
+
String(url).endsWith("/v1/session")
|
|
268
|
+
? Response.json({ sessionId: "apis", sessionToken: "fixture", expiresAt: new Date(Date.now() + 600_000).toISOString(), user: { id: "one", anonymous: true } })
|
|
269
|
+
: new Response(sseStream([{ type: "tools_active", apis: [{ name: "acme", specHash: "abc123def456", operationCount: 4 }] }, { type: "text_delta", text: "Done" }, { type: "turn_end", stopReason: "end_turn" }])),
|
|
270
|
+
);
|
|
271
|
+
render(<FrankieProvider publishableKey="pk_apis_fixture" config={config} fetchImpl={fetchImpl} persist={false}><ChatPanel /></FrankieProvider>);
|
|
272
|
+
await waitFor(() => expect((screen.getByLabelText("Message") as HTMLTextAreaElement).disabled).toBe(false));
|
|
273
|
+
fireEvent.change(screen.getByLabelText("Message"), { target: { value: "Hi" } });
|
|
274
|
+
fireEvent.click(screen.getByRole("button", { name: "Send" }));
|
|
275
|
+
await screen.findByText("Done");
|
|
276
|
+
fireEvent.click(screen.getByLabelText("Agent context"));
|
|
277
|
+
expect(screen.getByText("acme")).toBeTruthy();
|
|
278
|
+
expect(screen.getByText("spec abc123def456 · 4 operations")).toBeTruthy();
|
|
279
|
+
});
|
|
280
|
+
|
|
230
281
|
it("refreshes the context count and revisions from each server turn", async () => {
|
|
231
282
|
cleanup();
|
|
232
283
|
let turn = 0;
|