@frankieai/react 0.2.7 → 0.2.9
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 +394 -190
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +63 -9
- package/dist/index.d.ts +63 -9
- package/dist/index.js +377 -175
- package/dist/index.js.map +1 -1
- package/package.json +5 -2
- package/src/components/chat/AgentContext.tsx +2 -1
- package/src/components/chat/ChatPanel.tsx +9 -1
- package/src/components/chat/Choices.tsx +52 -0
- package/src/components/chat/Composer.tsx +12 -2
- package/src/components/chat/FrankieChatPage.tsx +4 -1
- package/src/components/chat/FrankieInline.tsx +7 -2
- package/src/components/chat/FrankieWidget.tsx +11 -2
- package/src/components/chat/Markdown.tsx +15 -8
- package/src/components/chat/MessageBubble.tsx +29 -5
- package/src/hooks/useFrankieChat.ts +65 -3
- package/src/index.ts +3 -0
- package/src/lib/choices.ts +49 -0
- package/src/lib/styles-ready.ts +71 -0
- package/src/styles/frankie.css +106 -3
- package/src/styles-ready.test.tsx +71 -0
- package/src/widget.test.tsx +17 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useState, type RefObject } from "react";
|
|
2
|
+
|
|
3
|
+
/** `useLayoutEffect` warns during SSR, where there is nothing to measure anyway. */
|
|
4
|
+
const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
5
|
+
|
|
6
|
+
/** The property `frankie.css` sets on `.frankie-root`, and the value it sets. */
|
|
7
|
+
const SENTINEL = "--frankie-styles-ready";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Whether this widget's stylesheet has actually been applied to `node`.
|
|
11
|
+
*
|
|
12
|
+
* The widget is mounted inside someone else's application and bundler. A host
|
|
13
|
+
* that imports `@frankieai/react/styles.css` from a client component ships it
|
|
14
|
+
* in that component's chunk, and on a client-side navigation React can render
|
|
15
|
+
* the panel before the CSS arrives — the panel then paints as raw HTML: native
|
|
16
|
+
* `<details>` markers, no pills, browser-default type. A full reload hides this
|
|
17
|
+
* because the stylesheet is in `<head>` before first paint.
|
|
18
|
+
*
|
|
19
|
+
* Probing a custom property costs nothing and asks the only question that
|
|
20
|
+
* matters — are our rules in effect for this element — rather than guessing at
|
|
21
|
+
* `<link>` elements the host may have inlined, bundled or renamed.
|
|
22
|
+
*
|
|
23
|
+
* Callers hide the root with `opacity` for the wait rather than `visibility` or
|
|
24
|
+
* unmounting: the sentinel must stay measurable on that element, and both of the
|
|
25
|
+
* alternatives would also drop the widget out of the accessibility tree.
|
|
26
|
+
*
|
|
27
|
+
* The wait is deliberately bounded. A Tailwind v4 host is told it may skip our
|
|
28
|
+
* stylesheet and compile the utilities itself, and such a page never sets the
|
|
29
|
+
* sentinel; it must still get a widget. After `timeoutMs` we render regardless,
|
|
30
|
+
* which is exactly today's behaviour.
|
|
31
|
+
*/
|
|
32
|
+
export function useStylesReady(ref: RefObject<HTMLElement | null>, timeoutMs = 600): boolean {
|
|
33
|
+
const [ready, setReady] = useState(false);
|
|
34
|
+
|
|
35
|
+
useIsomorphicLayoutEffect(() => {
|
|
36
|
+
if (ready) return;
|
|
37
|
+
const node = ref.current;
|
|
38
|
+
if (!node || typeof window === "undefined" || typeof window.getComputedStyle !== "function") {
|
|
39
|
+
setReady(true);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const applied = () => window.getComputedStyle(node).getPropertyValue(SENTINEL).trim() === "1";
|
|
44
|
+
// The common case — stylesheet already in `<head>` — resolves here, inside a
|
|
45
|
+
// layout effect, so nothing unstyled is ever painted and nothing flickers.
|
|
46
|
+
if (applied()) {
|
|
47
|
+
setReady(true);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const deadline = Date.now() + timeoutMs;
|
|
52
|
+
let frame = 0;
|
|
53
|
+
const poll = () => {
|
|
54
|
+
if (applied() || Date.now() >= deadline) {
|
|
55
|
+
setReady(true);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
frame = window.requestAnimationFrame(poll);
|
|
59
|
+
};
|
|
60
|
+
// `requestAnimationFrame` is missing in some test environments; fall back to
|
|
61
|
+
// rendering rather than hiding the widget forever.
|
|
62
|
+
if (typeof window.requestAnimationFrame !== "function") {
|
|
63
|
+
setReady(true);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
frame = window.requestAnimationFrame(poll);
|
|
67
|
+
return () => window.cancelAnimationFrame(frame);
|
|
68
|
+
}, [ref, ready, timeoutMs]);
|
|
69
|
+
|
|
70
|
+
return ready;
|
|
71
|
+
}
|
package/src/styles/frankie.css
CHANGED
|
@@ -71,6 +71,13 @@
|
|
|
71
71
|
.frankie-root button:disabled { opacity:.5; cursor:not-allowed; }
|
|
72
72
|
@media(max-width:760px) { .frankie-history-desktop { display:none; }.frankie-mobile-only { display:inline-flex!important; }.frankie-header-caption { display:none; }.frankie-page-header { padding:6px 10px; }.frankie-history-heading button { border:0; background:transparent; min-width:44px; min-height:44px; align-items:center; justify-content:center; }.frankie-page-prompts { gap:8px; }.frankie-widget-panel { width:calc(100vw - 32px); } }
|
|
73
73
|
.frankie-root {
|
|
74
|
+
/* Sentinel. The widget mounts into someone else's bundler, where this sheet
|
|
75
|
+
can land after React has already rendered — a client-side navigation that
|
|
76
|
+
code-splits it is enough. `useStylesReady` probes this property and holds
|
|
77
|
+
the first paint, so the panel never shows as raw HTML. Keep the name and
|
|
78
|
+
the value in step with that hook. */
|
|
79
|
+
--frankie-styles-ready: 1;
|
|
80
|
+
|
|
74
81
|
--frankie-background: #ffffff;
|
|
75
82
|
--frankie-foreground: #0a0a0a;
|
|
76
83
|
--frankie-primary: #171717;
|
|
@@ -136,22 +143,60 @@
|
|
|
136
143
|
/* Typography for model output. Scoped so the host's prose styles do not leak
|
|
137
144
|
in and ours do not leak out. */
|
|
138
145
|
.frankie-prose > * + * {
|
|
139
|
-
margin-top: 0.
|
|
146
|
+
margin-top: 0.6rem;
|
|
140
147
|
}
|
|
141
148
|
|
|
142
149
|
.frankie-prose p {
|
|
143
150
|
margin: 0;
|
|
151
|
+
line-height: 1.6;
|
|
144
152
|
white-space: pre-wrap;
|
|
145
153
|
overflow-wrap: anywhere;
|
|
146
154
|
}
|
|
147
155
|
|
|
156
|
+
/* Headings give replies a real hierarchy instead of a wall of bold text.
|
|
157
|
+
Extra space above sets each section apart; the first one never adds a gap. */
|
|
158
|
+
.frankie-prose > .frankie-h {
|
|
159
|
+
margin-top: 1.15rem;
|
|
160
|
+
line-height: 1.3;
|
|
161
|
+
letter-spacing: -0.01em;
|
|
162
|
+
}
|
|
163
|
+
.frankie-prose > .frankie-h:first-child {
|
|
164
|
+
margin-top: 0;
|
|
165
|
+
}
|
|
166
|
+
.frankie-prose h3.frankie-h {
|
|
167
|
+
font-size: 1.06em;
|
|
168
|
+
font-weight: 700;
|
|
169
|
+
}
|
|
170
|
+
.frankie-prose h4.frankie-h {
|
|
171
|
+
font-size: 1em;
|
|
172
|
+
font-weight: 600;
|
|
173
|
+
}
|
|
174
|
+
.frankie-prose h5.frankie-h {
|
|
175
|
+
font-size: 0.92em;
|
|
176
|
+
font-weight: 600;
|
|
177
|
+
color: var(--frankie-muted-foreground);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.frankie-prose strong {
|
|
181
|
+
font-weight: 600;
|
|
182
|
+
}
|
|
183
|
+
|
|
148
184
|
.frankie-prose ul,
|
|
149
185
|
.frankie-prose ol {
|
|
150
186
|
margin: 0;
|
|
151
|
-
padding-left: 1.
|
|
187
|
+
padding-left: 1.4rem;
|
|
152
188
|
display: flex;
|
|
153
189
|
flex-direction: column;
|
|
154
|
-
gap: 0.
|
|
190
|
+
gap: 0.35rem;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.frankie-prose li {
|
|
194
|
+
line-height: 1.55;
|
|
195
|
+
padding-left: 0.15rem;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.frankie-prose li::marker {
|
|
199
|
+
color: var(--frankie-muted-foreground);
|
|
155
200
|
}
|
|
156
201
|
|
|
157
202
|
.frankie-prose ul {
|
|
@@ -353,6 +398,23 @@
|
|
|
353
398
|
|
|
354
399
|
/* Shared conversation surface: SDK pages, embedded panels and Frankie's app. */
|
|
355
400
|
.frankie-root.frankie-chat-panel { min-height:0; position:relative; }
|
|
401
|
+
|
|
402
|
+
/* Floating widget panel: a pronounced, layered lift plus a hairline ring so the
|
|
403
|
+
assistant clearly reads as an overlay hovering above the host page — not a
|
|
404
|
+
flat box flush with it. Scoped to the widget so the full-page /chat surface
|
|
405
|
+
(which is the page, not an overlay) stays shadow-free. */
|
|
406
|
+
.frankie-root.frankie-widget-panel {
|
|
407
|
+
box-shadow:
|
|
408
|
+
0 32px 72px -18px color-mix(in srgb, var(--frankie-primary) 22%, rgba(15,23,42,0.42)),
|
|
409
|
+
0 12px 28px -12px rgba(15,23,42,0.28),
|
|
410
|
+
0 0 0 1px color-mix(in srgb, var(--frankie-foreground) 8%, transparent);
|
|
411
|
+
}
|
|
412
|
+
.frankie-root.frankie-widget-panel.dark {
|
|
413
|
+
box-shadow:
|
|
414
|
+
0 34px 80px -16px rgba(0,0,0,0.72),
|
|
415
|
+
0 14px 32px -12px rgba(0,0,0,0.6),
|
|
416
|
+
0 0 0 1px color-mix(in srgb, var(--frankie-foreground) 12%, transparent);
|
|
417
|
+
}
|
|
356
418
|
.frankie-root .frankie-agent-context-wrap { flex:none; display:flex; justify-content:center; padding:12px 10px; }
|
|
357
419
|
.frankie-root .frankie-agent-context { position:relative; max-width:100%; color:var(--frankie-foreground); font-size:13px; }
|
|
358
420
|
.frankie-root .frankie-agent-context > summary { display:flex; align-items:center; justify-content:center; gap:10px; min-height:44px; padding:7px 13px; border:1px solid var(--frankie-border); border-radius:999px; background:var(--frankie-background); box-shadow:0 1px 3px #0000000d; cursor:pointer; list-style:none; white-space:nowrap; }
|
|
@@ -412,3 +474,44 @@
|
|
|
412
474
|
|
|
413
475
|
.frankie-root .frankie-agent-context > summary > span:first-of-type { border-right:1px solid var(--frankie-border); padding-right:10px; }
|
|
414
476
|
@media(max-width:760px) { .frankie-root .frankie-page-header button > span { display:none; } }
|
|
477
|
+
|
|
478
|
+
/* Interactive answer options offered via a frankie:choices block. Numbered,
|
|
479
|
+
full-width rows that read as pick-one buttons, matching the message surface. */
|
|
480
|
+
.frankie-root .frankie-choices { display:flex; flex-direction:column; gap:8px; margin-top:12px; }
|
|
481
|
+
.frankie-root .frankie-choice {
|
|
482
|
+
display:flex; align-items:center; gap:12px; width:100%; padding:11px 14px; text-align:left; cursor:pointer;
|
|
483
|
+
border:1px solid var(--frankie-border); border-radius:var(--frankie-radius); background:var(--frankie-background);
|
|
484
|
+
color:var(--frankie-foreground); font:inherit; font-size:14px; line-height:1.4;
|
|
485
|
+
transition:border-color .15s ease, background-color .15s ease, transform .12s ease;
|
|
486
|
+
}
|
|
487
|
+
.frankie-root .frankie-choice:hover:not(:disabled) {
|
|
488
|
+
border-color:color-mix(in srgb, var(--frankie-primary) 55%, var(--frankie-border));
|
|
489
|
+
background:color-mix(in srgb, var(--frankie-primary) 6%, var(--frankie-background));
|
|
490
|
+
}
|
|
491
|
+
.frankie-root .frankie-choice:active:not(:disabled) { transform:scale(.99); }
|
|
492
|
+
.frankie-root .frankie-choice:focus-visible { outline:none; box-shadow:0 0 0 2px color-mix(in srgb, var(--frankie-primary) 40%, transparent); }
|
|
493
|
+
.frankie-root .frankie-choice:disabled { opacity:.6; cursor:default; }
|
|
494
|
+
.frankie-root .frankie-choice-index {
|
|
495
|
+
flex:none; display:grid; place-items:center; width:24px; height:24px; border-radius:50%;
|
|
496
|
+
border:1px solid var(--frankie-border); background:var(--frankie-muted);
|
|
497
|
+
color:var(--frankie-muted-foreground); font-size:12px; font-weight:600;
|
|
498
|
+
transition:background-color .15s ease, color .15s ease, border-color .15s ease;
|
|
499
|
+
}
|
|
500
|
+
.frankie-root .frankie-choice:hover:not(:disabled) .frankie-choice-index {
|
|
501
|
+
background:var(--frankie-primary); border-color:var(--frankie-primary); color:var(--frankie-primary-foreground);
|
|
502
|
+
}
|
|
503
|
+
.frankie-root .frankie-choice-label { flex:1; min-width:0; }
|
|
504
|
+
.frankie-root .frankie-choice-go { flex:none; color:var(--frankie-muted-foreground); opacity:0; transform:translateX(-4px); transition:opacity .15s ease, transform .15s ease; }
|
|
505
|
+
.frankie-root .frankie-choice:hover:not(:disabled) .frankie-choice-go { opacity:1; transform:none; color:var(--frankie-primary); }
|
|
506
|
+
@media (prefers-reduced-motion: reduce) {
|
|
507
|
+
.frankie-root .frankie-choice, .frankie-root .frankie-choice-go, .frankie-root .frankie-choice-index { transition:none; }
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/* "Something else" escape hatch under the offered options: visually set apart
|
|
511
|
+
(dashed, muted) so it reads as "none of these — let me type" rather than a
|
|
512
|
+
real option. It focuses the composer instead of sending. */
|
|
513
|
+
.frankie-root .frankie-choice-other { border-style:dashed; color:var(--frankie-muted-foreground); }
|
|
514
|
+
.frankie-root .frankie-choice-other .frankie-choice-index { background:transparent; }
|
|
515
|
+
.frankie-root .frankie-choice-other:hover:not(:disabled) { color:var(--frankie-foreground); }
|
|
516
|
+
.frankie-root .frankie-choice-other:hover:not(:disabled) .frankie-choice-index { background:var(--frankie-muted); border-color:var(--frankie-border); color:var(--frankie-foreground); }
|
|
517
|
+
.frankie-root .frankie-choice-hint { flex:none; font-size:12px; color:var(--frankie-muted-foreground); }
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { useRef } from "react";
|
|
4
|
+
import { useStylesReady } from "./lib/styles-ready.js";
|
|
5
|
+
|
|
6
|
+
/** Stand-in for a widget root: reports what the hook decided. */
|
|
7
|
+
function Probe({ timeoutMs }: { timeoutMs?: number }) {
|
|
8
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
9
|
+
const ready = useStylesReady(ref, timeoutMs);
|
|
10
|
+
return (
|
|
11
|
+
<div ref={ref} data-testid="root" data-frankie-styles={ready ? "ready" : "pending"}>
|
|
12
|
+
{ready ? "panel" : null}
|
|
13
|
+
</div>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Make `getComputedStyle` answer as a browser would with/without our sheet. */
|
|
18
|
+
function withSentinel(present: boolean | (() => boolean)) {
|
|
19
|
+
const real = window.getComputedStyle.bind(window);
|
|
20
|
+
vi.spyOn(window, "getComputedStyle").mockImplementation((el: Element, pseudo?: string | null) => {
|
|
21
|
+
const style = real(el, pseudo ?? undefined);
|
|
22
|
+
return {
|
|
23
|
+
...style,
|
|
24
|
+
getPropertyValue: (property: string) =>
|
|
25
|
+
property === "--frankie-styles-ready"
|
|
26
|
+
? ((typeof present === "function" ? present() : present) ? "1" : "")
|
|
27
|
+
: style.getPropertyValue(property),
|
|
28
|
+
} as CSSStyleDeclaration;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
afterEach(() => vi.restoreAllMocks());
|
|
33
|
+
|
|
34
|
+
describe("useStylesReady", () => {
|
|
35
|
+
it("renders immediately when the stylesheet is already applied", () => {
|
|
36
|
+
withSentinel(true);
|
|
37
|
+
render(<Probe />);
|
|
38
|
+
// Resolved inside a layout effect, so nothing unstyled is ever painted.
|
|
39
|
+
expect(screen.getByTestId("root").dataset.frankieStyles).toBe("ready");
|
|
40
|
+
expect(screen.getByText("panel")).toBeTruthy();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("holds the first paint while the stylesheet is still in flight", () => {
|
|
44
|
+
withSentinel(false);
|
|
45
|
+
render(<Probe />);
|
|
46
|
+
const root = screen.getByTestId("root");
|
|
47
|
+
expect(root.dataset.frankieStyles).toBe("pending");
|
|
48
|
+
expect(screen.queryByText("panel")).toBeNull();
|
|
49
|
+
// Hidden, not unmounted — the sentinel has to stay measurable on this node.
|
|
50
|
+
expect(root.isConnected).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("renders as soon as a late stylesheet lands", async () => {
|
|
54
|
+
let applied = false;
|
|
55
|
+
withSentinel(() => applied);
|
|
56
|
+
render(<Probe />);
|
|
57
|
+
expect(screen.getByTestId("root").dataset.frankieStyles).toBe("pending");
|
|
58
|
+
|
|
59
|
+
applied = true; // the CSS chunk arrives
|
|
60
|
+
await waitFor(() => expect(screen.getByTestId("root").dataset.frankieStyles).toBe("ready"));
|
|
61
|
+
expect(screen.getByText("panel")).toBeTruthy();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("gives up and renders for a host that ships no Frankie stylesheet", async () => {
|
|
65
|
+
withSentinel(false);
|
|
66
|
+
render(<Probe timeoutMs={0} />);
|
|
67
|
+
// A Tailwind v4 host compiles the utilities itself and never sets the
|
|
68
|
+
// sentinel; it must still get a widget rather than an invisible one.
|
|
69
|
+
await waitFor(() => expect(screen.getByTestId("root").dataset.frankieStyles).toBe("ready"));
|
|
70
|
+
});
|
|
71
|
+
});
|
package/src/widget.test.tsx
CHANGED
|
@@ -261,6 +261,23 @@ describe("ChatPanel", () => {
|
|
|
261
261
|
await waitFor(() => expect(scrollTop).toBe(scroller.scrollHeight));
|
|
262
262
|
cleanup();
|
|
263
263
|
});
|
|
264
|
+
it("shows the active API spec hash and operation count once tools_active arrives", async () => {
|
|
265
|
+
cleanup();
|
|
266
|
+
const fetchImpl = vi.fn(async (url: string | URL | Request) =>
|
|
267
|
+
String(url).endsWith("/v1/session")
|
|
268
|
+
? Response.json({ sessionId: "apis", sessionToken: "fixture", expiresAt: new Date(Date.now() + 600_000).toISOString(), user: { id: "one", anonymous: true } })
|
|
269
|
+
: new Response(sseStream([{ type: "tools_active", apis: [{ name: "acme", specHash: "abc123def456", operationCount: 4 }] }, { type: "text_delta", text: "Done" }, { type: "turn_end", stopReason: "end_turn" }])),
|
|
270
|
+
);
|
|
271
|
+
render(<FrankieProvider publishableKey="pk_apis_fixture" config={config} fetchImpl={fetchImpl} persist={false}><ChatPanel /></FrankieProvider>);
|
|
272
|
+
await waitFor(() => expect((screen.getByLabelText("Message") as HTMLTextAreaElement).disabled).toBe(false));
|
|
273
|
+
fireEvent.change(screen.getByLabelText("Message"), { target: { value: "Hi" } });
|
|
274
|
+
fireEvent.click(screen.getByRole("button", { name: "Send" }));
|
|
275
|
+
await screen.findByText("Done");
|
|
276
|
+
fireEvent.click(screen.getByLabelText("Agent context"));
|
|
277
|
+
expect(screen.getByText("acme")).toBeTruthy();
|
|
278
|
+
expect(screen.getByText("spec abc123def456 · 4 operations")).toBeTruthy();
|
|
279
|
+
});
|
|
280
|
+
|
|
264
281
|
it("refreshes the context count and revisions from each server turn", async () => {
|
|
265
282
|
cleanup();
|
|
266
283
|
let turn = 0;
|