@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.
- package/dist/frankie.css +1 -1
- package/dist/index.cjs +260 -156
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +44 -5
- package/dist/index.d.ts +44 -5
- package/dist/index.js +259 -156
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/chat/ActionConfirm.tsx +85 -0
- package/src/components/chat/ChatPanel.tsx +3 -2
- package/src/components/chat/Markdown.tsx +2 -1
- package/src/components/chat/MessageBubble.tsx +12 -0
- package/src/generated/css-content.ts +1 -1
- package/src/hooks/useFrankieChat.ts +26 -2
- package/src/index.ts +2 -1
- package/src/lib/client.ts +14 -4
- package/src/lib/messages.ts +21 -1
- package/src/lib/theme-inherit.test.ts +10 -0
- package/src/lib/theme-inherit.ts +7 -1
- package/src/styles/frankie.css +38 -0
- package/src/widget.test.tsx +52 -3
package/src/styles/frankie.css
CHANGED
|
@@ -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); }
|
package/src/widget.test.tsx
CHANGED
|
@@ -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("
|
|
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(
|
|
675
|
-
expect(body.result.error).toMatch(/no host API client/i);
|
|
724
|
+
expect(body.result.status).toBe(200);
|
|
676
725
|
});
|
|
677
726
|
});
|