@frankieai/react 0.2.19 → 0.2.21

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.
@@ -1,4 +1,4 @@
1
- import type { FrankieServerEvent } from "@frankieai/core";
1
+ import type { ActionPreview, FrankieServerEvent } from "@frankieai/core";
2
2
 
3
3
  export interface ToolActivity {
4
4
  id: string;
@@ -22,6 +22,18 @@ export interface UserChatMessage {
22
22
  createdAt: number;
23
23
  }
24
24
 
25
+ /**
26
+ * An action that changes data, paused until the user approves it. `pending`
27
+ * shows Approve and Cancel; the other states record the answer.
28
+ */
29
+ export interface ActionRequest {
30
+ toolUseId: string;
31
+ name: string;
32
+ label: string;
33
+ preview: ActionPreview;
34
+ state: "pending" | "approved" | "cancelled";
35
+ }
36
+
25
37
  export interface AssistantChatMessage {
26
38
  id: string;
27
39
  role: "assistant";
@@ -31,6 +43,8 @@ export interface AssistantChatMessage {
31
43
  status: "streaming" | "done" | "error";
32
44
  error?: string;
33
45
  createdAt: number;
46
+ /** An action this reply is waiting on the user to approve. */
47
+ action?: ActionRequest;
34
48
  }
35
49
 
36
50
  export type ChatMessage = UserChatMessage | AssistantChatMessage;
@@ -72,6 +86,12 @@ export function applyEvent(message: AssistantChatMessage, event: FrankieServerEv
72
86
  ),
73
87
  };
74
88
 
89
+ case "action_confirm":
90
+ return {
91
+ ...message,
92
+ action: { toolUseId: event.toolUseId, name: event.name, label: event.label, preview: event.preview, state: "pending" },
93
+ };
94
+
75
95
  case "tool_denied":
76
96
  return {
77
97
  ...message,
@@ -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,42 @@
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
+ afterEach(cleanup);
14
+ const panel = () => document.querySelector(".frankie-widget-panel");
15
+ const renderWidget = (props: { closeOnOutsideClick?: boolean } = {}) => render(
16
+ <div>
17
+ <button type="button">Elsewhere</button>
18
+ <button type="button" data-frankie-open>Ask</button>
19
+ <FrankieProvider publishableKey="pk_outside" config={config} fetchImpl={vi.fn()} persist={false}><FrankieWidget defaultOpen {...props} /></FrankieProvider>
20
+ </div>,
21
+ );
22
+
23
+ describe("clicking outside the widget", () => {
24
+ it("closes the panel", () => {
25
+ renderWidget();
26
+ expect(panel()).toBeTruthy();
27
+ act(() => { fireEvent.pointerDown(screen.getByRole("button", { name: "Elsewhere" })); });
28
+ expect(panel()).toBeNull();
29
+ });
30
+ it("keeps it open for clicks inside it and on the host's own Ask button", () => {
31
+ renderWidget();
32
+ act(() => { fireEvent.pointerDown(panel()!); });
33
+ expect(panel()).toBeTruthy();
34
+ act(() => { fireEvent.pointerDown(screen.getByRole("button", { name: "Ask" })); });
35
+ expect(panel()).toBeTruthy();
36
+ });
37
+ it("can be turned off", () => {
38
+ renderWidget({ closeOnOutsideClick: false });
39
+ act(() => { fireEvent.pointerDown(screen.getByRole("button", { name: "Elsewhere" })); });
40
+ expect(panel()).toBeTruthy();
41
+ });
42
+ });
@@ -607,3 +607,48 @@
607
607
  animation:frankie-skeleton-pulse 1.6s ease-in-out infinite;
608
608
  }
609
609
  }
610
+ /* The compact composer (widget, inline) with an attach button or model picker:
611
+ the message box gets its own row and the tools + send sit below it, so the
612
+ row can't run past a narrow panel's edge. */
613
+ .frankie-root:not(.frankie-chat-panel-fill) .frankie-composer-controls:has(.frankie-composer-actions > :not(input[hidden])) { flex-wrap:wrap; align-items:center; row-gap:8px; }
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
+ .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
+ .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); }
@@ -33,4 +33,23 @@ describe("first paint", () => {
33
33
  await waitFor(() => expect(root.style.opacity).toBe(""));
34
34
  expect(root.dataset.frankieMode).toBe("light");
35
35
  });
36
+
37
+ it("shows the widget anyway when the config never answers, instead of hiding it for as long as the browser waits", async () => {
38
+ stylesLoaded();
39
+ vi.useFakeTimers({ shouldAdvanceTime: true });
40
+ try {
41
+ // A hung request: no response, no error, and the client sets no timeout of its own.
42
+ const fetchImpl = vi.fn(() => new Promise<Response>(() => {}));
43
+ const { container } = render(<FrankieProvider publishableKey="pk_test" baseUrl="https://frankie.test" fetchImpl={fetchImpl as unknown as typeof fetch}><FrankieWidget /></FrankieProvider>);
44
+ const root = container.querySelector(".frankie-widget") as HTMLElement;
45
+ await waitFor(() => expect(root.dataset.frankieStyles).toBe("ready"));
46
+ expect(root.style.opacity).toBe("0");
47
+ await act(async () => { await vi.advanceTimersByTimeAsync(4_000); });
48
+ expect(root.style.opacity).toBe("0");
49
+ await act(async () => { await vi.advanceTimersByTimeAsync(1_500); });
50
+ expect(root.style.opacity).toBe("");
51
+ } finally {
52
+ vi.useRealTimers();
53
+ }
54
+ });
36
55
  });
@@ -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
  });