@frankieai/react 0.2.20 → 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.
@@ -614,3 +614,41 @@
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); }
@@ -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
  });