@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.
@@ -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");