@frankieai/react 0.2.20 → 0.2.22

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.
@@ -72,6 +72,16 @@ describe("inferHostTheme", () => {
72
72
  expect(theme?.primary).toBe("rgb(220, 38, 38)");
73
73
  });
74
74
 
75
+ it("skips a grey link's text and takes the next colorful element", () => {
76
+ document.body.innerHTML = '<a href="/lessons" data-stub="grey-link">Lessons</a><button data-stub="green">Start</button>';
77
+ stubComputedStyle({
78
+ body: { backgroundColor: "rgb(255, 255, 255)", color: "rgb(29, 27, 34)" },
79
+ "grey-link": { backgroundColor: "rgba(0, 0, 0, 0)", color: "rgb(90, 88, 96)" },
80
+ green: { backgroundColor: "rgb(18, 161, 80)", color: "rgb(255, 255, 255)" },
81
+ });
82
+ expect(inferHostTheme()?.primary).toBe("rgb(18, 161, 80)");
83
+ });
84
+
75
85
  it("returns null for a transparent body background — nothing real to read", () => {
76
86
  stubComputedStyle({ body: { backgroundColor: "rgba(0, 0, 0, 0)", color: "rgb(10, 10, 10)" } });
77
87
  expect(inferHostTheme()).toBeNull();
@@ -27,6 +27,12 @@ function luminance([r, g, b]: Rgba): number {
27
27
  return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
28
28
  }
29
29
 
30
+ /** Whether a color has a hue of its own: a grey or near-black link is text, not a brand color. */
31
+ function colorful([r, g, b]: Rgba): boolean {
32
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
33
+ return max - min >= 40;
34
+ }
35
+
30
36
  function contrastRatio(a: Rgba, b: Rgba): number {
31
37
  const la = luminance(a), lb = luminance(b);
32
38
  return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
@@ -193,7 +199,7 @@ export function inferHostTheme(): Partial<WidgetTheme> | null {
193
199
  const surface = parseRgb(style.backgroundColor);
194
200
  if (surface && surface[3] > 0.5 && !sameColor(surface, background)) { accent = surface; break; }
195
201
  const text = parseRgb(style.color);
196
- if (text && text[3] > 0.5 && !sameColor(text, foreground)) { accent = text; break; }
202
+ if (text && text[3] > 0.5 && !sameColor(text, foreground) && colorful(text)) { accent = text; break; }
197
203
  }
198
204
 
199
205
  // The page's light/dark decides the widget's, not the visitor's OS: a dark
@@ -0,0 +1,51 @@
1
+ import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import type { PublicWidgetConfig } from "@frankieai/core";
4
+ import { FrankieProvider } from "./hooks/FrankieProvider.jsx";
5
+ import { FrankieWidget } from "./components/chat/FrankieWidget.js";
6
+
7
+ const config: PublicWidgetConfig = {
8
+ tenantId: "acme", branding: { name: "Acme", tagline: "", launcherLabel: "Need a hand?" }, greeting: "Hi", suggestedPrompts: [],
9
+ theme: { mode: "light" }, allowAnonymous: true, showToolActivity: true,
10
+ features: { attachments: { enabled: false, accept: [], maxMb: 10, maxCount: 4 }, modelPicker: { enabled: false, models: [] } },
11
+ };
12
+
13
+ /** Pretend the viewport is (or isn't) phone-sized for `(max-width: 640px)`. */
14
+ function viewport(phone: boolean) {
15
+ vi.stubGlobal("matchMedia", (query: string) => ({
16
+ matches: phone && query.includes("max-width: 640px"), media: query,
17
+ addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), onchange: null, dispatchEvent: vi.fn(),
18
+ }));
19
+ }
20
+
21
+ afterEach(() => { cleanup(); vi.unstubAllGlobals(); document.documentElement.style.overflow = ""; });
22
+ const root = () => document.querySelector(".frankie-widget");
23
+ const renderWidget = () => render(
24
+ <FrankieProvider publishableKey="pk_sheet" config={config} fetchImpl={vi.fn()} persist={false}><FrankieWidget /></FrankieProvider>,
25
+ );
26
+
27
+ describe("the widget on a phone", () => {
28
+ it("marks itself open so the stylesheet can make it a full-screen sheet", () => {
29
+ viewport(true);
30
+ renderWidget();
31
+ expect(root()?.classList.contains("is-open")).toBe(false);
32
+ act(() => { fireEvent.click(screen.getByRole("button", { name: "Need a hand?" })); });
33
+ expect(root()?.classList.contains("is-open")).toBe(true);
34
+ });
35
+
36
+ it("stops the page behind it from scrolling, and gives scrolling back on close", () => {
37
+ viewport(true);
38
+ renderWidget();
39
+ act(() => { fireEvent.click(screen.getByRole("button", { name: "Need a hand?" })); });
40
+ expect(document.documentElement.style.overflow).toBe("hidden");
41
+ act(() => { fireEvent.click(screen.getByRole("button", { name: "Close" })); });
42
+ expect(document.documentElement.style.overflow).toBe("");
43
+ });
44
+
45
+ it("leaves page scrolling alone on a wider screen", () => {
46
+ viewport(false);
47
+ renderWidget();
48
+ act(() => { fireEvent.click(screen.getByRole("button", { name: "Need a hand?" })); });
49
+ expect(document.documentElement.style.overflow).toBe("");
50
+ });
51
+ });
@@ -614,3 +614,66 @@
614
614
  .frankie-root:not(.frankie-chat-panel-fill) .frankie-composer-controls:has(.frankie-composer-actions > :not(input[hidden])) > textarea { order:-1; flex:1 0 100%; width:100%; }
615
615
  .frankie-root:not(.frankie-chat-panel-fill) .frankie-composer-controls:has(.frankie-composer-actions > :not(input[hidden])) > .frankie-composer-actions { flex:1 1 auto; }
616
616
  .frankie-root:not(.frankie-chat-panel-fill) .frankie-composer-controls:has(.frankie-composer-actions > :not(input[hidden])) > button { margin-left:auto; }
617
+
618
+ /* An action that changes data, waiting for the user's approval. */
619
+ .frankie-root .frankie-action {
620
+ display:flex; flex-direction:column; gap:10px; margin-top:12px; padding:12px 14px;
621
+ border:1px solid var(--frankie-border); border-radius:var(--frankie-radius);
622
+ background:var(--frankie-background); color:var(--frankie-foreground); font-size:14px; line-height:1.4;
623
+ }
624
+ .frankie-root .frankie-action.is-pending {
625
+ border-color:color-mix(in srgb, var(--frankie-primary) 45%, var(--frankie-border));
626
+ background:color-mix(in srgb, var(--frankie-primary) 5%, var(--frankie-background));
627
+ }
628
+ .frankie-root .frankie-action-head { display:flex; align-items:flex-start; gap:10px; }
629
+ .frankie-root .frankie-action-icon { margin-top:2px; flex-shrink:0; color:var(--frankie-primary); }
630
+ .frankie-root .frankie-action-title { display:flex; flex-direction:column; gap:2px; min-width:0; }
631
+ .frankie-root .frankie-action-title strong { font-weight:600; overflow-wrap:anywhere; }
632
+ .frankie-root .frankie-action-title span { font-size:12px; color:var(--frankie-muted-foreground); }
633
+ .frankie-root .frankie-action-route {
634
+ display:block; padding:6px 8px; border-radius:calc(var(--frankie-radius) * .6);
635
+ background:var(--frankie-muted); font-size:12px; overflow-wrap:anywhere;
636
+ }
637
+ .frankie-root .frankie-action-route span { font-weight:600; }
638
+ .frankie-root .frankie-action-fields { display:grid; gap:6px; margin:0; }
639
+ .frankie-root .frankie-action-fields > div { display:grid; grid-template-columns:minmax(0,35%) minmax(0,1fr); gap:10px; }
640
+ .frankie-root .frankie-action-fields dt { color:var(--frankie-muted-foreground); font-size:12px; overflow-wrap:anywhere; }
641
+ .frankie-root .frankie-action-fields dd {
642
+ margin:0; font-size:13px; white-space:pre-wrap; overflow-wrap:anywhere; max-height:8.5em; overflow:auto;
643
+ }
644
+ .frankie-root .frankie-action-buttons { display:flex; gap:8px; flex-wrap:wrap; }
645
+ .frankie-root .frankie-action-buttons button {
646
+ display:inline-flex; align-items:center; gap:6px; padding:7px 14px; cursor:pointer; font:inherit; font-size:14px; font-weight:500;
647
+ border-radius:var(--frankie-radius); border:1px solid var(--frankie-border); transition:background-color .15s ease, border-color .15s ease;
648
+ }
649
+ .frankie-root .frankie-action-approve { background:var(--frankie-primary); color:var(--frankie-primary-foreground); border-color:var(--frankie-primary); }
650
+ .frankie-root .frankie-action-approve:hover:not(:disabled) { background:color-mix(in srgb, var(--frankie-primary) 88%, black); }
651
+ .frankie-root .frankie-action-cancel { background:var(--frankie-background); color:var(--frankie-foreground); }
652
+ .frankie-root .frankie-action-cancel:hover:not(:disabled) { background:var(--frankie-muted); }
653
+ .frankie-root .frankie-action-buttons button:disabled { opacity:.55; cursor:not-allowed; }
654
+ .frankie-root .frankie-action-buttons button:focus-visible { outline:none; box-shadow:0 0 0 2px color-mix(in srgb, var(--frankie-primary) 40%, transparent); }
655
+
656
+ /* Phones: the open widget is a full-screen sheet, like a native chat, instead of
657
+ a card floating over a sliver of the page. The panel's own close button is the
658
+ way out, so the launcher steps aside; safe-area insets keep the header clear of
659
+ the notch and the composer clear of the home indicator. `!important` because
660
+ the widget positions itself with inline styles. */
661
+ @media (max-width: 640px) {
662
+ .frankie-root.frankie-widget.is-open { inset: 0 !important; width: auto !important; }
663
+ .frankie-root.frankie-widget.is-open > div { height: 100%; gap: 0; }
664
+ .frankie-root.frankie-widget.is-open .frankie-launcher-slot { display: none; }
665
+ .frankie-root.frankie-widget.is-open .frankie-widget-sheet { width: 100%; height: 100%; }
666
+ .frankie-root.frankie-widget.is-open .frankie-widget-panel {
667
+ width: 100%; max-width: none; height: 100%; max-height: none;
668
+ border: 0; border-radius: 0; box-shadow: none;
669
+ padding-top: env(safe-area-inset-top);
670
+ }
671
+ .frankie-root.frankie-widget.is-open .frankie-widget-panel .frankie-composer-footer { padding-bottom: max(12px, env(safe-area-inset-bottom)); }
672
+ .frankie-root.frankie-widget.is-open .frankie-widget-panel [aria-label="Close"] { width: 44px; height: 44px; }
673
+ }
674
+
675
+ /* The floating panel is narrow at every viewport, so its agent-context pill
676
+ always uses the compact size; the full-size pill overflowed its right edge. */
677
+ .frankie-root.frankie-widget-panel .frankie-agent-context > summary { gap:6px; padding:7px 9px; font-size:12px; }
678
+ .frankie-root.frankie-widget-panel .frankie-agent-context > summary > span:first-of-type { padding-right:6px; }
679
+ .frankie-root.frankie-widget-panel .frankie-agent-context > summary > svg { width:14px; }
@@ -279,6 +279,11 @@ describe("Markdown", () => {
279
279
  expect(screen.queryByRole("link")).toBeNull();
280
280
  });
281
281
 
282
+ it("refuses protocol-relative links that would leave the site", () => {
283
+ render(<Markdown text={"[a](//evil.example/x) [b](/\\evil.example/y) [ok](/settings)"} />);
284
+ expect(screen.getAllByRole("link").map((link) => link.getAttribute("href"))).toEqual(["/settings"]);
285
+ });
286
+
282
287
  it("renders bullet lists", () => {
283
288
  const { container } = render(<Markdown text={"Steps:\n\n- one\n- two"} />);
284
289
  expect(container.querySelectorAll("li")).toHaveLength(2);
@@ -374,6 +379,49 @@ describe("ChatPanel", () => {
374
379
  expect(screen.getByText("spec abc123def456 · 4 operations")).toBeTruthy();
375
380
  });
376
381
 
382
+ it("asks before an action runs and answers with exactly that action", async () => {
383
+ cleanup();
384
+ const bodies: Array<Record<string, unknown>> = [];
385
+ let turn = 0;
386
+ const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
387
+ if (String(url).endsWith("/v1/session")) return Response.json({ sessionId: "act", sessionToken: "fixture", expiresAt: new Date(Date.now() + 600_000).toISOString(), user: { id: "one", anonymous: true } });
388
+ bodies.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
389
+ turn += 1;
390
+ return new Response(sseStream(turn === 1
391
+ ? [
392
+ { type: "action_confirm", toolUseId: "toolu_1", name: "api__acme__deleteProject", label: "Delete project", preview: { method: "DELETE", path: "/projects/{id}", input: { id: "p_7" } } },
393
+ { type: "text_delta", text: "Approve it and I'll delete it." },
394
+ { type: "turn_end", stopReason: "end_turn" },
395
+ ]
396
+ : [{ type: "text_delta", text: "Deleted p_7." }, { type: "turn_end", stopReason: "end_turn" }]));
397
+ });
398
+ render(<FrankieProvider publishableKey="pk_action_fixture" config={config} fetchImpl={fetchImpl} persist={false}><ChatPanel /></FrankieProvider>);
399
+ await waitFor(() => expect((screen.getByLabelText("Message") as HTMLTextAreaElement).disabled).toBe(false));
400
+ fireEvent.change(screen.getByLabelText("Message"), { target: { value: "Delete p_7" } });
401
+ fireEvent.click(screen.getByRole("button", { name: "Send" }));
402
+ const card = await screen.findByRole("group", { name: "Approve: Delete project" });
403
+ expect(within(card).getByText("p_7")).toBeTruthy();
404
+ expect(bodies[0]).toMatchObject({ confirmActions: true });
405
+ await waitFor(() => expect((within(card).getByRole("button", { name: "Approve" }) as HTMLButtonElement).disabled).toBe(false));
406
+ fireEvent.click(within(card).getByRole("button", { name: "Approve" }));
407
+ expect(await screen.findByText("Deleted p_7.")).toBeTruthy();
408
+ expect(bodies[1]).toMatchObject({ message: "Go ahead", confirm: { toolUseId: "toolu_1", approved: true } });
409
+ // Answered: the buttons are gone and the card says what was decided.
410
+ expect(screen.queryByRole("button", { name: "Approve" })).toBeNull();
411
+ expect(screen.getByText("Approved")).toBeTruthy();
412
+ cleanup();
413
+ });
414
+
415
+ it("leaves out the agent context bar when the agent says so (Support, Docs and other page agents)", async () => {
416
+ cleanup();
417
+ const fetchImpl = vi.fn(async () => Response.json({}));
418
+ render(<FrankieProvider publishableKey="pk_support_fixture" config={{ ...config, agentContext: false }} fetchImpl={fetchImpl} persist={false}><ChatPanel /></FrankieProvider>);
419
+ expect(screen.queryByLabelText("Agent context")).toBeNull();
420
+ cleanup();
421
+ render(<FrankieProvider publishableKey="pk_product_fixture" config={{ ...config, agentContext: true }} fetchImpl={fetchImpl} persist={false}><ChatPanel /></FrankieProvider>);
422
+ expect(screen.getByLabelText("Agent context")).toBeTruthy();
423
+ });
424
+
377
425
  it("refreshes the context count and revisions from each server turn", async () => {
378
426
  cleanup();
379
427
  let turn = 0;
@@ -644,7 +692,7 @@ describe("client-side tool execution", () => {
644
692
  expect(body.result.body).toContain("Bistro");
645
693
  });
646
694
 
647
- it("reports a transport failure when no host api client is configured", async () => {
695
+ it("calls the site the user is on, with the page's own sign-in, when no api option is set", async () => {
648
696
  const calls: Array<{ url: string; init?: RequestInit }> = [];
649
697
  const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
650
698
  const href = String(url);
@@ -669,9 +717,10 @@ describe("client-side tool execution", () => {
669
717
  await client.send({ message: "hi" }, () => {});
670
718
 
671
719
  await waitFor(() => expect(calls.some((c) => c.url.endsWith("/v1/chat/tool-result"))).toBe(true));
720
+ const hostCall = calls.find((c) => c.url === `${window.location.origin}/x`)!;
721
+ expect(hostCall.init?.credentials).toBe("same-origin");
672
722
  const post = calls.find((c) => c.url.endsWith("/v1/chat/tool-result"))!;
673
723
  const body = JSON.parse(String(post.init?.body)) as { result: { status: number; error?: string } };
674
- expect(body.result.status).toBe(0);
675
- expect(body.result.error).toMatch(/no host API client/i);
724
+ expect(body.result.status).toBe(200);
676
725
  });
677
726
  });