@frankieai/react 0.2.15 → 0.2.16
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 +80 -16
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +80 -16
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/chat/ChatPanel.tsx +5 -1
- package/src/components/chat/FrankieChatPage.tsx +32 -8
- package/src/components/chat/Markdown.tsx +4 -1
- package/src/generated/css-content.ts +1 -1
- package/src/hooks/FrankieProvider.tsx +20 -4
- package/src/lib/theme-inherit.test.ts +53 -1
- package/src/lib/theme-inherit.ts +59 -3
- package/src/styles/frankie.css +12 -1
- package/src/styles/host-safety.test.ts +10 -0
- package/src/widget.test.tsx +91 -0
package/src/widget.test.tsx
CHANGED
|
@@ -261,6 +261,19 @@ describe("Markdown", () => {
|
|
|
261
261
|
expect(screen.getByRole("link", { name: "docs" })).toHaveProperty("rel", "noopener noreferrer nofollow");
|
|
262
262
|
});
|
|
263
263
|
|
|
264
|
+
it("renders *italic* but leaves a spaced asterisk and bold alone", () => {
|
|
265
|
+
render(<Markdown text="See *Key rotation* — 2 * 3 is **six**" />);
|
|
266
|
+
expect(screen.getByText("Key rotation").tagName).toBe("EM");
|
|
267
|
+
expect(screen.getByText("six").tagName).toBe("STRONG");
|
|
268
|
+
expect(screen.getByText(/2 \* 3 is/)).toBeTruthy();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("keeps runs of asterisks literal instead of eating them as italic", () => {
|
|
272
|
+
const { container } = render(<Markdown text="Divider *** here and *x* too" />);
|
|
273
|
+
expect(container.textContent).toContain("Divider *** here");
|
|
274
|
+
expect(screen.getByText("x").tagName).toBe("EM");
|
|
275
|
+
});
|
|
276
|
+
|
|
264
277
|
it("refuses to linkify a javascript: url", () => {
|
|
265
278
|
render(<Markdown text="[click](javascript:alert(1))" />);
|
|
266
279
|
expect(screen.queryByRole("link")).toBeNull();
|
|
@@ -297,6 +310,19 @@ describe("ChatPanel", () => {
|
|
|
297
310
|
expect(screen.queryByText("1 skill loaded")).toBeNull();
|
|
298
311
|
cleanup();
|
|
299
312
|
});
|
|
313
|
+
it("opens the welcome screen at its top, not scrolled to the bottom", async () => {
|
|
314
|
+
cleanup();
|
|
315
|
+
const scrolls: number[] = [];
|
|
316
|
+
const { container } = render(<FrankieProvider publishableKey="pk_empty_fixture" config={config} fetchImpl={vi.fn()} persist={false}><ChatPanel /></FrankieProvider>);
|
|
317
|
+
const scroller = container.querySelector(".frankie-transcript") as HTMLElement;
|
|
318
|
+
Object.defineProperty(scroller, "scrollHeight", { configurable: true, get: () => 900 });
|
|
319
|
+
Object.defineProperty(scroller, "scrollTop", { configurable: true, get: () => scrolls.at(-1) ?? 0, set: (value: number) => { scrolls.push(value); } });
|
|
320
|
+
// A re-render with still no messages (e.g. config settling) must not jump to the bottom.
|
|
321
|
+
fireEvent.scroll(scroller);
|
|
322
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
323
|
+
expect(scrolls.every((value) => value === 0)).toBe(true);
|
|
324
|
+
cleanup();
|
|
325
|
+
});
|
|
300
326
|
it("keeps following new messages even when one update grows the transcript past the near-bottom threshold", async () => {
|
|
301
327
|
cleanup();
|
|
302
328
|
const fetchImpl = vi.fn(async (url: string | URL | Request) =>
|
|
@@ -429,6 +455,32 @@ describe("ChatPanel", () => {
|
|
|
429
455
|
vi.restoreAllMocks();
|
|
430
456
|
});
|
|
431
457
|
|
|
458
|
+
it("follows the host when it switches between light and dark, mode included", async () => {
|
|
459
|
+
document.body.innerHTML = '<button data-stub="host-accent">Buy</button>';
|
|
460
|
+
let dark = false;
|
|
461
|
+
const real = window.getComputedStyle.bind(window);
|
|
462
|
+
vi.spyOn(window, "getComputedStyle").mockImplementation((el: Element) => {
|
|
463
|
+
if (el === document.body) return (dark ? { backgroundColor: "rgb(15, 15, 14)", color: "rgb(243, 242, 238)" } : { backgroundColor: "rgb(250, 250, 250)", color: "rgb(20, 20, 20)" }) as CSSStyleDeclaration;
|
|
464
|
+
if ((el as HTMLElement).dataset?.stub === "host-accent") return { backgroundColor: "rgb(16, 185, 129)", color: "rgb(255, 255, 255)" } as CSSStyleDeclaration;
|
|
465
|
+
return real(el);
|
|
466
|
+
});
|
|
467
|
+
const { container } = render(
|
|
468
|
+
<FrankieProvider baseUrl="https://agent.acme.com" publishableKey="pk_1" config={{ ...config, theme: { mode: "light", source: "inherit" } }}>
|
|
469
|
+
<ChatPanel />
|
|
470
|
+
</FrankieProvider>,
|
|
471
|
+
);
|
|
472
|
+
const root = () => container.querySelector(".frankie-root") as HTMLElement;
|
|
473
|
+
await waitFor(() => expect(root().style.getPropertyValue("--frankie-background")).toBe("rgb(250, 250, 250)"));
|
|
474
|
+
expect(root().classList.contains("dark")).toBe(false);
|
|
475
|
+
// The site flips its own theme (as Frankie's dashboard does with data-theme).
|
|
476
|
+
dark = true;
|
|
477
|
+
document.documentElement.dataset.theme = "dark";
|
|
478
|
+
await waitFor(() => expect(root().style.getPropertyValue("--frankie-background")).toBe("rgb(15, 15, 14)"));
|
|
479
|
+
expect(root().classList.contains("dark")).toBe(true); // the configured "light" doesn't win over the page
|
|
480
|
+
delete document.documentElement.dataset.theme;
|
|
481
|
+
vi.restoreAllMocks();
|
|
482
|
+
});
|
|
483
|
+
|
|
432
484
|
it("keeps the configured fallback when the host page can't be read reliably", async () => {
|
|
433
485
|
vi.spyOn(window, "getComputedStyle").mockImplementation(() => ({ backgroundColor: "rgba(0, 0, 0, 0)", color: "rgba(0, 0, 0, 0)" }) as CSSStyleDeclaration);
|
|
434
486
|
const { container } = render(
|
|
@@ -467,6 +519,45 @@ describe("FrankieChatPage", () => {
|
|
|
467
519
|
cleanup();
|
|
468
520
|
});
|
|
469
521
|
|
|
522
|
+
it("keeps the conversation list on screen while switching chats, and fetches the config once", async () => {
|
|
523
|
+
cleanup();
|
|
524
|
+
window.localStorage.clear();
|
|
525
|
+
const signedIn = { ...config, allowAnonymous: false };
|
|
526
|
+
const list = [
|
|
527
|
+
{ id: "conv-a", title: "Hi, can you tell me what things I can do", createdAt: 1, updatedAt: Date.now() },
|
|
528
|
+
{ id: "conv-b", title: "Hi", createdAt: 1, updatedAt: Date.now() - 3 * 86400000 },
|
|
529
|
+
];
|
|
530
|
+
const session = (id: string) => ({ sessionId: id, sessionToken: "fixture", expiresAt: new Date(Date.now() + 600_000).toISOString(), user: { id: "one", anonymous: false } });
|
|
531
|
+
const calls: string[] = [];
|
|
532
|
+
const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
|
|
533
|
+
const href = String(url);
|
|
534
|
+
calls.push(`${init?.method ?? "GET"} ${new URL(href).pathname}`);
|
|
535
|
+
if (href.endsWith("/v1/config")) return Response.json(signedIn);
|
|
536
|
+
if (href.endsWith("/v1/session")) return Response.json(session("s"));
|
|
537
|
+
if (href.endsWith("/v1/conversations")) return Response.json({ conversations: list });
|
|
538
|
+
if (/\/v1\/conversations\/[^/]+\/resume$/.test(href)) return Response.json({ session: session("r"), messages: [], title: "Hi" });
|
|
539
|
+
if (/\/v1\/conversations\/[^/]+$/.test(href)) return Response.json({ ok: true });
|
|
540
|
+
return new Response(sseStream([{ type: "turn_end", stopReason: "end_turn" }]));
|
|
541
|
+
});
|
|
542
|
+
const { container } = render(<FrankieChatPage publishableKey="pk_page_switch" conversationId="conv-a" fetchImpl={fetchImpl} persist={false} getUserToken={async () => "h.eyJzdWIiOiJvbmUifQ.s"} />);
|
|
543
|
+
const aside = () => container.querySelector(".frankie-history-desktop") as HTMLElement;
|
|
544
|
+
const items = () => within(aside()).queryAllByRole("button").filter((button) => button.textContent?.startsWith("Hi"));
|
|
545
|
+
await waitFor(() => expect(items()).toHaveLength(2));
|
|
546
|
+
|
|
547
|
+
// Click the other conversation. The list must still be there in the very
|
|
548
|
+
// next render — it used to empty and show "Loading history…" because the
|
|
549
|
+
// sidebar remounted with the conversation — and the page must not fetch
|
|
550
|
+
// the workspace config again.
|
|
551
|
+
const other = () => items().find((button) => button.querySelector("span")?.textContent === "Hi")!;
|
|
552
|
+
fireEvent.click(other());
|
|
553
|
+
expect(items()).toHaveLength(2);
|
|
554
|
+
expect(within(aside()).queryByText("Loading history…")).toBeNull();
|
|
555
|
+
await waitFor(() => expect(other().getAttribute("aria-current")).toBe("page"));
|
|
556
|
+
expect(items()).toHaveLength(2);
|
|
557
|
+
expect(calls.filter((call) => call.endsWith("/v1/config"))).toHaveLength(1);
|
|
558
|
+
cleanup();
|
|
559
|
+
});
|
|
560
|
+
|
|
470
561
|
it("starts collapsed when a prior visit left the sidebar collapsed", async () => {
|
|
471
562
|
cleanup();
|
|
472
563
|
window.localStorage.setItem("frankie.history-collapsed", "1");
|