@frankieai/react 0.2.5 → 0.2.7
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 +28 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +29 -4
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/ChatPanel.tsx +21 -3
- package/src/components/chat/FrankieChatPage.tsx +13 -2
- package/src/styles/frankie.css +6 -1
- package/src/widget.test.tsx +68 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frankieai/react",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
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",
|
|
@@ -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 { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
|
|
4
|
-
import { History, Menu, Plus, Settings, X } from "lucide-react";
|
|
4
|
+
import { ChevronsLeft, ChevronsRight, History, Menu, Plus, Settings, X } from "lucide-react";
|
|
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";
|
|
@@ -39,6 +39,10 @@ function PageChrome({ showHistory, navigation, currentId, onSelect, onNew, ...pa
|
|
|
39
39
|
const [historyError, setHistoryError] = useState("");
|
|
40
40
|
const [historyLoading, setHistoryLoading] = useState(false);
|
|
41
41
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
42
|
+
const [collapsed, setCollapsed] = useState(() => {
|
|
43
|
+
try { return window.localStorage.getItem("frankie.history-collapsed") === "1"; }
|
|
44
|
+
catch { return false; }
|
|
45
|
+
});
|
|
42
46
|
const [query, setQuery] = useState("");
|
|
43
47
|
const dialog = useRef<HTMLDialogElement>(null);
|
|
44
48
|
const menu = useRef<HTMLButtonElement>(null);
|
|
@@ -61,6 +65,10 @@ function PageChrome({ showHistory, navigation, currentId, onSelect, onNew, ...pa
|
|
|
61
65
|
finally { if (version === requestVersion.current) setHistoryLoading(false); }
|
|
62
66
|
}, [client, showHistory, config?.allowAnonymous]);
|
|
63
67
|
useEffect(() => { if (!busy) void refresh(); return () => { requestVersion.current++; }; }, [refresh, busy]);
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
try { window.localStorage.setItem("frankie.history-collapsed", collapsed ? "1" : "0"); }
|
|
70
|
+
catch { /* storage unavailable */ }
|
|
71
|
+
}, [collapsed]);
|
|
64
72
|
useEffect(() => {
|
|
65
73
|
const node = dialog.current;
|
|
66
74
|
if (drawerOpen) { node?.showModal?.(); }
|
|
@@ -77,7 +85,10 @@ function PageChrome({ showHistory, navigation, currentId, onSelect, onNew, ...pa
|
|
|
77
85
|
{navigation ? <div className="frankie-host-navigation">{navigation}</div> : null}
|
|
78
86
|
</>;
|
|
79
87
|
return <div className={"frankie-page-shell" + (resolveMode(theme?.mode) === "dark" ? " dark" : "")} style={themeToCssVars(theme)}>
|
|
80
|
-
{showHistory ? <aside className="frankie-history-desktop"
|
|
88
|
+
{showHistory ? <aside className={"frankie-history-desktop" + (collapsed ? " frankie-history-collapsed" : "")}>
|
|
89
|
+
<button type="button" className="frankie-history-toggle" aria-label={collapsed ? "Expand conversations" : "Collapse conversations"} aria-expanded={!collapsed} onClick={() => setCollapsed(value => !value)}>{collapsed ? <ChevronsRight size={16} /> : <ChevronsLeft size={16} />}</button>
|
|
90
|
+
{collapsed ? null : history}
|
|
91
|
+
</aside> : null}
|
|
81
92
|
<div className="frankie-page-main">
|
|
82
93
|
<header className="frankie-page-header"><div>{showHistory ? <button type="button" className="frankie-mobile-only" ref={menu} aria-label="Open conversations" aria-haspopup="dialog" aria-expanded={drawerOpen} onClick={() => setDrawerOpen(true)}><Menu size={20} /></button> : null}<strong className="frankie-thread-title">{conversations.find(item => item.id === currentId)?.title ?? messages.find(message => message.role === "user")?.text ?? "New chat"}</strong></div><div>
|
|
83
94
|
<button type="button" disabled={busy} onClick={onNew} aria-label="Start a new conversation"><Plus size={16} /><span>New chat</span></button>
|
package/src/styles/frankie.css
CHANGED
|
@@ -47,7 +47,11 @@
|
|
|
47
47
|
@layer components {
|
|
48
48
|
.frankie-page-shell { display:flex; flex:1; min-height:0; height:100%; background:var(--frankie-background); color:var(--frankie-foreground); }
|
|
49
49
|
.frankie-page-main { display:flex; flex:1; min-width:0; min-height:0; flex-direction:column; }
|
|
50
|
-
.frankie-history-desktop { width:260px; flex-shrink:0; display:flex; flex-direction:column; gap:16px; padding:20px 14px; border-right:1px solid var(--frankie-border); background:var(--frankie-secondary); overflow:auto; }
|
|
50
|
+
.frankie-history-desktop { width:260px; flex-shrink:0; display:flex; flex-direction:column; gap:16px; padding:20px 14px; border-right:1px solid var(--frankie-border); background:var(--frankie-secondary); overflow:auto; transition:width .16s ease, padding .16s ease; }
|
|
51
|
+
.frankie-history-toggle { display:flex; align-items:center; justify-content:center; flex-shrink:0; width:32px; height:32px; border:1px solid var(--frankie-border); border-radius:8px; background:var(--frankie-background); color:var(--frankie-muted-foreground); cursor:pointer; align-self:flex-end; }
|
|
52
|
+
.frankie-history-toggle:hover { color:var(--frankie-foreground); }
|
|
53
|
+
.frankie-history-collapsed { width:56px; padding:20px 8px; overflow:visible; }
|
|
54
|
+
.frankie-history-collapsed .frankie-history-toggle { align-self:center; }
|
|
51
55
|
.frankie-history-heading { display:flex; align-items:center; justify-content:space-between; font-size:14px; }
|
|
52
56
|
.frankie-new-chat { display:flex; align-items:center; justify-content:center; gap:8px; border:1px solid var(--frankie-border); border-radius:10px; background:var(--frankie-background); min-height:44px; font-weight:600; cursor:pointer; }
|
|
53
57
|
.frankie-history-search > span { display:block; font-size:12px; color:var(--frankie-muted-foreground); margin-bottom:6px; }
|
|
@@ -381,6 +385,7 @@
|
|
|
381
385
|
.frankie-root.frankie-chat-panel-fill .frankie-composer button[aria-label="Send"],.frankie-root.frankie-chat-panel-fill .frankie-composer button[aria-label="Stop"] { margin-left:auto; width:42px; height:42px; border-radius:50%; background:var(--frankie-primary); color:var(--frankie-primary-foreground); border:0; }
|
|
382
386
|
.frankie-root.frankie-chat-panel-fill .frankie-composer button[aria-label="Attach files"] { width:42px; height:42px; border-radius:50%; }
|
|
383
387
|
.frankie-root .frankie-history-desktop { width:290px; background:var(--frankie-background); padding:20px 12px; }
|
|
388
|
+
.frankie-root .frankie-history-collapsed { width:56px; padding:20px 8px; }
|
|
384
389
|
.frankie-root .frankie-new-chat { justify-content:flex-start; border:0; background:transparent; padding:8px 12px; font-size:15px; }
|
|
385
390
|
.frankie-root .frankie-history-heading { font-size:12px; font-weight:400; color:var(--frankie-muted-foreground); padding:0 12px; }
|
|
386
391
|
.frankie-root .frankie-history-list button { min-height:44px; padding:10px 12px; }
|
package/src/widget.test.tsx
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
1
|
+
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
2
2
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
3
|
import type { FrankieServerEvent, PublicWidgetConfig } from "@frankieai/core";
|
|
4
4
|
import { FrankieProvider } from "./hooks/FrankieProvider.jsx";
|
|
5
5
|
import { ChatPanel } from "./components/chat/ChatPanel.js";
|
|
6
|
+
import { FrankieChatPage } from "./components/chat/FrankieChatPage.js";
|
|
6
7
|
import { Markdown } from "./components/chat/Markdown.js";
|
|
7
8
|
import { FrankieClient, readEventStream } from "./lib/client.js";
|
|
8
9
|
import { applyEvent, newAssistantMessage } from "./lib/messages.js";
|
|
@@ -226,6 +227,40 @@ describe("ChatPanel", () => {
|
|
|
226
227
|
expect(screen.queryByText("1 skill loaded")).toBeNull();
|
|
227
228
|
cleanup();
|
|
228
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
|
+
});
|
|
229
264
|
it("refreshes the context count and revisions from each server turn", async () => {
|
|
230
265
|
cleanup();
|
|
231
266
|
let turn = 0;
|
|
@@ -282,3 +317,35 @@ describe("ChatPanel", () => {
|
|
|
282
317
|
expect(root.classList.contains("dark")).toBe(true);
|
|
283
318
|
});
|
|
284
319
|
});
|
|
320
|
+
|
|
321
|
+
describe("FrankieChatPage", () => {
|
|
322
|
+
it("collapses and expands the desktop conversations sidebar, remembering the choice", async () => {
|
|
323
|
+
cleanup();
|
|
324
|
+
window.localStorage.clear();
|
|
325
|
+
const fetchImpl = vi.fn(async (url: string | URL | Request) => String(url).endsWith("/v1/session") ? Response.json({ sessionId: "page", sessionToken: "fixture", expiresAt: new Date(Date.now() + 600_000).toISOString(), user: { id: "one", anonymous: true } }) : new Response(sseStream([{ type: "turn_end", stopReason: "end_turn" }])));
|
|
326
|
+
const { container } = render(<FrankieChatPage publishableKey="pk_page_fixture" config={config} fetchImpl={fetchImpl} persist={false} />);
|
|
327
|
+
const aside = () => container.querySelector(".frankie-history-desktop") as HTMLElement;
|
|
328
|
+
await waitFor(() => expect(within(aside()).getByText("Conversations")).toBeTruthy());
|
|
329
|
+
fireEvent.click(within(aside()).getByRole("button", { name: "Collapse conversations" }));
|
|
330
|
+
expect(within(aside()).queryByText("Conversations")).toBeNull();
|
|
331
|
+
expect(aside().classList.contains("frankie-history-collapsed")).toBe(true);
|
|
332
|
+
expect(window.localStorage.getItem("frankie.history-collapsed")).toBe("1");
|
|
333
|
+
fireEvent.click(within(aside()).getByRole("button", { name: "Expand conversations" }));
|
|
334
|
+
expect(within(aside()).getByText("Conversations")).toBeTruthy();
|
|
335
|
+
expect(aside().classList.contains("frankie-history-collapsed")).toBe(false);
|
|
336
|
+
expect(window.localStorage.getItem("frankie.history-collapsed")).toBe("0");
|
|
337
|
+
cleanup();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("starts collapsed when a prior visit left the sidebar collapsed", async () => {
|
|
341
|
+
cleanup();
|
|
342
|
+
window.localStorage.setItem("frankie.history-collapsed", "1");
|
|
343
|
+
const fetchImpl = vi.fn(async (url: string | URL | Request) => String(url).endsWith("/v1/session") ? Response.json({ sessionId: "page-2", sessionToken: "fixture", expiresAt: new Date(Date.now() + 600_000).toISOString(), user: { id: "one", anonymous: true } }) : new Response(sseStream([{ type: "turn_end", stopReason: "end_turn" }])));
|
|
344
|
+
const { container } = render(<FrankieChatPage publishableKey="pk_page_fixture_2" config={config} fetchImpl={fetchImpl} persist={false} />);
|
|
345
|
+
const aside = () => container.querySelector(".frankie-history-desktop") as HTMLElement;
|
|
346
|
+
await waitFor(() => expect(within(aside()).getByRole("button", { name: "Expand conversations" })).toBeTruthy());
|
|
347
|
+
expect(within(aside()).queryByText("Conversations")).toBeNull();
|
|
348
|
+
window.localStorage.clear();
|
|
349
|
+
cleanup();
|
|
350
|
+
});
|
|
351
|
+
});
|