@frankieai/react 0.1.0

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.
@@ -0,0 +1,158 @@
1
+ /*
2
+ * Prebuilt stylesheet for hosts that do not run Tailwind themselves.
3
+ *
4
+ * Preflight is deliberately omitted — this stylesheet is loaded into someone
5
+ * else's production page and must not restyle their elements. Only the
6
+ * utilities our components actually use are emitted, and every visual token is
7
+ * a CSS variable scoped to `.frankie-root`, so a host can restyle the widget
8
+ * without touching this file.
9
+ *
10
+ * Hosts that already run Tailwind v4 can skip this file entirely and instead
11
+ * add `@source "../node_modules/@frankieai/react/dist";` to their own CSS: the
12
+ * widget then compiles against their design tokens.
13
+ */
14
+
15
+ @layer theme, components, utilities;
16
+
17
+ @import "tailwindcss/theme.css" layer(theme);
18
+ @import "tailwindcss/utilities.css" layer(utilities) source(none);
19
+
20
+ @source "../";
21
+
22
+ /* shadcn/ui's class-based dark mode, scoped to the widget's own root. */
23
+ @custom-variant dark (&:where(.dark, .dark *));
24
+
25
+ /* Map Tailwind's color tokens onto the widget's variables so `bg-primary`
26
+ and friends resolve to whatever the host themed. */
27
+ @theme inline {
28
+ --color-background: var(--frankie-background);
29
+ --color-foreground: var(--frankie-foreground);
30
+ --color-primary: var(--frankie-primary);
31
+ --color-primary-foreground: var(--frankie-primary-foreground);
32
+ --color-secondary: var(--frankie-secondary);
33
+ --color-secondary-foreground: var(--frankie-secondary-foreground);
34
+ --color-muted: var(--frankie-muted);
35
+ --color-muted-foreground: var(--frankie-muted-foreground);
36
+ --color-border: var(--frankie-border);
37
+ }
38
+
39
+ @layer components {
40
+ .frankie-root {
41
+ --frankie-background: #ffffff;
42
+ --frankie-foreground: #0a0a0a;
43
+ --frankie-primary: #171717;
44
+ --frankie-primary-foreground: #fafafa;
45
+ --frankie-secondary: #f5f5f5;
46
+ --frankie-secondary-foreground: #171717;
47
+ --frankie-muted: #f5f5f5;
48
+ --frankie-muted-foreground: #737373;
49
+ --frankie-border: #e5e5e5;
50
+ --frankie-radius: 0.625rem;
51
+ --frankie-font:
52
+ ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
53
+
54
+ font-family: var(--frankie-font);
55
+ color: var(--frankie-foreground);
56
+ line-height: 1.5;
57
+ -webkit-font-smoothing: antialiased;
58
+ }
59
+
60
+ .frankie-root.dark {
61
+ --frankie-background: #0a0a0a;
62
+ --frankie-foreground: #fafafa;
63
+ --frankie-primary: #fafafa;
64
+ --frankie-primary-foreground: #171717;
65
+ --frankie-secondary: #262626;
66
+ --frankie-secondary-foreground: #fafafa;
67
+ --frankie-muted: #262626;
68
+ --frankie-muted-foreground: #a3a3a3;
69
+ --frankie-border: #262626;
70
+ }
71
+
72
+ /* The widget renders into the host's page, where `* { box-sizing }` and font
73
+ inheritance are not guaranteed. Reset only inside our own subtree. */
74
+ .frankie-root,
75
+ .frankie-root *,
76
+ .frankie-root *::before,
77
+ .frankie-root *::after {
78
+ box-sizing: border-box;
79
+ }
80
+
81
+ .frankie-root button,
82
+ .frankie-root textarea,
83
+ .frankie-root input {
84
+ font: inherit;
85
+ color: inherit;
86
+ margin: 0;
87
+ }
88
+
89
+ .frankie-root textarea {
90
+ background-color: var(--frankie-background);
91
+ }
92
+
93
+ .frankie-focus:focus-visible {
94
+ outline: 2px solid var(--frankie-primary);
95
+ outline-offset: 2px;
96
+ }
97
+
98
+ .frankie-scroll {
99
+ scrollbar-width: thin;
100
+ scrollbar-color: var(--frankie-border) transparent;
101
+ }
102
+
103
+ /* Typography for model output. Scoped so the host's prose styles do not leak
104
+ in and ours do not leak out. */
105
+ .frankie-prose > * + * {
106
+ margin-top: 0.5rem;
107
+ }
108
+
109
+ .frankie-prose p {
110
+ margin: 0;
111
+ white-space: pre-wrap;
112
+ overflow-wrap: anywhere;
113
+ }
114
+
115
+ .frankie-prose ul,
116
+ .frankie-prose ol {
117
+ margin: 0;
118
+ padding-left: 1.15rem;
119
+ display: flex;
120
+ flex-direction: column;
121
+ gap: 0.25rem;
122
+ }
123
+
124
+ .frankie-prose ul {
125
+ list-style: disc;
126
+ }
127
+
128
+ .frankie-prose ol {
129
+ list-style: decimal;
130
+ }
131
+
132
+ .frankie-prose a {
133
+ color: var(--frankie-primary);
134
+ text-decoration: underline;
135
+ text-underline-offset: 2px;
136
+ }
137
+
138
+ .frankie-prose code {
139
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
140
+ font-size: 0.85em;
141
+ background-color: var(--frankie-muted);
142
+ border-radius: 0.25rem;
143
+ padding: 0.1em 0.3em;
144
+ }
145
+
146
+ .frankie-prose pre {
147
+ margin: 0;
148
+ padding: 0.625rem 0.75rem;
149
+ border-radius: var(--frankie-radius);
150
+ background-color: var(--frankie-muted);
151
+ overflow-x: auto;
152
+ }
153
+
154
+ .frankie-prose pre code {
155
+ background: none;
156
+ padding: 0;
157
+ }
158
+ }
@@ -0,0 +1,217 @@
1
+ import { render, screen, waitFor } from "@testing-library/react";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import type { FrankieServerEvent, PublicWidgetConfig } from "@frankieai/core";
4
+ import { FrankieProvider } from "./hooks/FrankieProvider.jsx";
5
+ import { ChatPanel } from "./components/chat/ChatPanel.js";
6
+ import { Markdown } from "./components/chat/Markdown.js";
7
+ import { FrankieClient, readEventStream } from "./lib/client.js";
8
+ import { applyEvent, newAssistantMessage } from "./lib/messages.js";
9
+ import { themeToCssVars } from "./lib/theme.js";
10
+
11
+ const config: PublicWidgetConfig = {
12
+ tenantId: "acme",
13
+ branding: { name: "Acme Guide", tagline: "Setup help", launcherLabel: "Need a hand?" },
14
+ greeting: "Hi! What are you setting up?",
15
+ suggestedPrompts: ["Connect my first repo"],
16
+ theme: { mode: "light" },
17
+ allowAnonymous: true,
18
+ showToolActivity: true,
19
+ };
20
+
21
+ function sseStream(events: FrankieServerEvent[]): ReadableStream<Uint8Array> {
22
+ const encoder = new TextEncoder();
23
+ return new ReadableStream({
24
+ start(controller) {
25
+ for (const event of events) controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
26
+ controller.close();
27
+ },
28
+ });
29
+ }
30
+
31
+ describe("applyEvent", () => {
32
+ it("accumulates text deltas", () => {
33
+ let message = newAssistantMessage("a1");
34
+ message = applyEvent(message, { type: "text_delta", text: "Hel" });
35
+ message = applyEvent(message, { type: "text_delta", text: "lo" });
36
+ expect(message.text).toBe("Hello");
37
+ });
38
+
39
+ it("tracks a tool from start to finish", () => {
40
+ let message = newAssistantMessage("a1");
41
+ message = applyEvent(message, {
42
+ type: "tool_start",
43
+ toolUseId: "t1",
44
+ name: "mcp__acme__create",
45
+ label: "Create · acme",
46
+ input: {},
47
+ });
48
+ expect(message.tools[0]?.status).toBe("running");
49
+ message = applyEvent(message, { type: "tool_end", toolUseId: "t1", ok: true, preview: "created" });
50
+ expect(message.tools[0]).toMatchObject({ status: "done", detail: "created" });
51
+ });
52
+
53
+ it("records a denied tool that never started", () => {
54
+ const message = applyEvent(newAssistantMessage("a1"), {
55
+ type: "tool_denied",
56
+ toolUseId: "t9",
57
+ name: "Bash",
58
+ reason: "not allowed",
59
+ });
60
+ expect(message.tools).toEqual([
61
+ { id: "t9", name: "Bash", label: "Bash", status: "denied", detail: "not allowed" },
62
+ ]);
63
+ });
64
+
65
+ it("keeps an error status through turn_end", () => {
66
+ let message = newAssistantMessage("a1");
67
+ message = applyEvent(message, { type: "error", code: "upstream_error", message: "boom", retryable: true });
68
+ message = applyEvent(message, { type: "turn_end", stopReason: "error" });
69
+ expect(message.status).toBe("error");
70
+ expect(message.error).toBe("boom");
71
+ });
72
+
73
+ it("substitutes text when a turn ends empty", () => {
74
+ const message = applyEvent(newAssistantMessage("a1"), { type: "turn_end", stopReason: "max_turns" });
75
+ expect(message.text).toMatch(/narrow it down/);
76
+ });
77
+ });
78
+
79
+ describe("readEventStream", () => {
80
+ it("yields events split across chunk boundaries", async () => {
81
+ const encoder = new TextEncoder();
82
+ const stream = new ReadableStream<Uint8Array>({
83
+ start(controller) {
84
+ controller.enqueue(encoder.encode('data: {"type":"text_de'));
85
+ controller.enqueue(encoder.encode('lta","text":"hi"}\n\ndata: {"type":"ping"}\n\n'));
86
+ controller.close();
87
+ },
88
+ });
89
+ const events: FrankieServerEvent[] = [];
90
+ for await (const event of readEventStream(stream)) events.push(event);
91
+ expect(events).toEqual([{ type: "text_delta", text: "hi" }, { type: "ping" }]);
92
+ });
93
+ });
94
+
95
+ describe("FrankieClient", () => {
96
+ // The client persists its session to localStorage; isolate tests that reuse
97
+ // the same publishable-key namespace.
98
+ beforeEach(() => {
99
+ try {
100
+ localStorage.clear();
101
+ } catch {
102
+ /* no storage in this environment */
103
+ }
104
+ });
105
+
106
+ it("attaches the publishable key and session bearer token", async () => {
107
+ const calls: Array<{ url: string; init?: RequestInit }> = [];
108
+ const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
109
+ const href = String(url);
110
+ calls.push({ url: href, ...(init ? { init } : {}) });
111
+ if (href.endsWith("/v1/session")) {
112
+ return new Response(
113
+ JSON.stringify({
114
+ sessionToken: "sess.jwt",
115
+ sessionId: "s1",
116
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
117
+ user: { id: "u1", anonymous: true },
118
+ }),
119
+ { status: 200, headers: { "content-type": "application/json" } },
120
+ );
121
+ }
122
+ return new Response(sseStream([{ type: "text_delta", text: "ok" }]), { status: 200 });
123
+ }) as unknown as typeof fetch;
124
+
125
+ const client = new FrankieClient({ baseUrl: "https://agent.acme.com/", publishableKey: "pk_1", fetchImpl });
126
+ const seen: FrankieServerEvent[] = [];
127
+ await client.send({ message: "hi" }, (event) => seen.push(event));
128
+
129
+ expect(seen).toEqual([{ type: "text_delta", text: "ok" }]);
130
+ const chat = calls.find((call) => call.url.endsWith("/v1/chat"));
131
+ const headers = chat?.init?.headers as Record<string, string>;
132
+ expect(headers["authorization"]).toBe("Bearer sess.jwt");
133
+ expect(headers["x-frankie-key"]).toBe("pk_1");
134
+ });
135
+
136
+ it("drops a rejected session so the next send re-authenticates", async () => {
137
+ let sessionCalls = 0;
138
+ const fetchImpl = vi.fn(async (url: string | URL | Request) => {
139
+ const href = String(url);
140
+ if (href.endsWith("/v1/session")) {
141
+ sessionCalls += 1;
142
+ return new Response(
143
+ JSON.stringify({
144
+ sessionToken: "sess.jwt",
145
+ sessionId: "s1",
146
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
147
+ user: { id: "u1", anonymous: true },
148
+ }),
149
+ { status: 200, headers: { "content-type": "application/json" } },
150
+ );
151
+ }
152
+ return new Response(JSON.stringify({ error: { code: "unauthorized", message: "expired" } }), { status: 401 });
153
+ }) as unknown as typeof fetch;
154
+
155
+ const client = new FrankieClient({ baseUrl: "https://agent.acme.com", publishableKey: "pk_1", fetchImpl });
156
+ await expect(client.send({ message: "hi" }, () => {})).rejects.toThrow(/expired/);
157
+ await expect(client.send({ message: "again" }, () => {})).rejects.toThrow(/expired/);
158
+ expect(sessionCalls).toBe(2);
159
+ });
160
+ });
161
+
162
+ describe("Markdown", () => {
163
+ it("renders bold, code and links", () => {
164
+ render(<Markdown text="Go **now** to [docs](https://acme.com/docs) and run `init`" />);
165
+ expect(screen.getByText("now").tagName).toBe("STRONG");
166
+ expect(screen.getByText("init").tagName).toBe("CODE");
167
+ expect(screen.getByRole("link", { name: "docs" })).toHaveProperty("rel", "noopener noreferrer nofollow");
168
+ });
169
+
170
+ it("refuses to linkify a javascript: url", () => {
171
+ render(<Markdown text="[click](javascript:alert(1))" />);
172
+ expect(screen.queryByRole("link")).toBeNull();
173
+ });
174
+
175
+ it("renders bullet lists", () => {
176
+ const { container } = render(<Markdown text={"Steps:\n\n- one\n- two"} />);
177
+ expect(container.querySelectorAll("li")).toHaveLength(2);
178
+ });
179
+ });
180
+
181
+ describe("themeToCssVars", () => {
182
+ it("maps only the keys that are set", () => {
183
+ expect(themeToCssVars({ primary: "#f50", radius: "1" })).toEqual({
184
+ "--frankie-primary": "#f50",
185
+ "--frankie-radius": "1rem",
186
+ });
187
+ });
188
+ });
189
+
190
+ describe("ChatPanel", () => {
191
+ it("shows the greeting and suggested prompts before the first message", async () => {
192
+ render(
193
+ <FrankieProvider baseUrl="https://agent.acme.com" publishableKey="pk_1" config={config}>
194
+ <ChatPanel />
195
+ </FrankieProvider>,
196
+ );
197
+ await waitFor(() => expect(screen.getByText("Hi! What are you setting up?")).toBeDefined());
198
+ expect(screen.getByRole("button", { name: "Connect my first repo" })).toBeDefined();
199
+ expect(screen.getByLabelText("Message")).toBeDefined();
200
+ });
201
+
202
+ it("applies theme overrides as CSS variables", () => {
203
+ const { container } = render(
204
+ <FrankieProvider
205
+ baseUrl="https://agent.acme.com"
206
+ publishableKey="pk_1"
207
+ config={config}
208
+ theme={{ primary: "rgb(255, 0, 128)", mode: "dark" }}
209
+ >
210
+ <ChatPanel />
211
+ </FrankieProvider>,
212
+ );
213
+ const root = container.querySelector(".frankie-root") as HTMLElement;
214
+ expect(root.style.getPropertyValue("--frankie-primary")).toBe("rgb(255, 0, 128)");
215
+ expect(root.classList.contains("dark")).toBe(true);
216
+ });
217
+ });