@frankieai/react 0.2.14 → 0.2.16
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 +140 -25
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +140 -25
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/chat/ChatPanel.tsx +5 -1
- package/src/components/chat/FrankieChatPage.tsx +32 -8
- package/src/components/chat/Markdown.tsx +4 -1
- package/src/generated/css-content.ts +1 -1
- package/src/hooks/FrankieProvider.tsx +35 -11
- package/src/lib/theme-inherit.test.ts +53 -1
- package/src/lib/theme-inherit.ts +131 -6
- package/src/styles/frankie.css +12 -1
- package/src/styles/host-safety.test.ts +10 -0
- package/src/theme-inherit.test.ts +43 -0
- package/src/widget.test.tsx +91 -0
package/src/lib/theme-inherit.ts
CHANGED
|
@@ -39,7 +39,81 @@ function contrastRatio(a: Rgba, b: Rgba): number {
|
|
|
39
39
|
* of guessed; everything after it is a heuristic guess at a "branded"
|
|
40
40
|
* element, weakest last.
|
|
41
41
|
*/
|
|
42
|
-
const ACCENT_SELECTORS = ["
|
|
42
|
+
const ACCENT_SELECTORS = ["button[type=submit]", "a[href]", "button", "[class*=primary]", "[class*=btn]"];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Brand-color custom properties, in the same order the dashboard's
|
|
46
|
+
* server-side preview reads them (`inferTheme` in @frankieai/core), so the
|
|
47
|
+
* live widget and the Appearance preview agree.
|
|
48
|
+
*/
|
|
49
|
+
const PRIMARY_VARS = ["--primary", "--color-primary", "--brand", "--color-brand", "--accent", "--color-accent"];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Frankie's own DOM is never "the host's" color: once the chat is open, its
|
|
53
|
+
* send button (a `button[type=submit]` filled with the fallback accent) would
|
|
54
|
+
* otherwise be the first match and the widget would inherit itself.
|
|
55
|
+
*/
|
|
56
|
+
const OWN_DOM = ".frankie-root, [data-frankie-ignore]";
|
|
57
|
+
|
|
58
|
+
function firstHostElement(selector: string): Element | null {
|
|
59
|
+
for (const element of document.querySelectorAll(selector)) if (!element.closest(OWN_DOM)) return element;
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Resolve any CSS color (hex, hsl, oklch, shadcn's bare "221 83% 53%") to RGBA, or null. */
|
|
64
|
+
function resolveColor(raw: string): Rgba | null {
|
|
65
|
+
const value = raw.trim();
|
|
66
|
+
if (!value) return null;
|
|
67
|
+
const candidate = /^[\d.]+(?:deg)?\s+[\d.]+%\s+[\d.]+%(?:\s*\/\s*[\d.]+%?)?$/.test(value) ? `hsl(${value})` : value;
|
|
68
|
+
const probe = document.createElement("span");
|
|
69
|
+
probe.style.color = candidate;
|
|
70
|
+
if (!probe.style.color) return null; // not a color
|
|
71
|
+
probe.style.display = "none";
|
|
72
|
+
probe.setAttribute("data-frankie-ignore", "");
|
|
73
|
+
document.body.appendChild(probe);
|
|
74
|
+
const computed = getComputedStyle(probe).color;
|
|
75
|
+
probe.remove();
|
|
76
|
+
const rgb = parseRgb(computed);
|
|
77
|
+
if (rgb) return rgb;
|
|
78
|
+
// Newer browsers keep oklch()/lab() as-is in computed styles: rasterize one pixel instead.
|
|
79
|
+
try {
|
|
80
|
+
const context = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
|
|
81
|
+
if (!context) return null;
|
|
82
|
+
context.fillStyle = computed;
|
|
83
|
+
context.fillRect(0, 0, 1, 1);
|
|
84
|
+
const [r, g, b, a] = context.getImageData(0, 0, 1, 1).data;
|
|
85
|
+
return a ? [r!, g!, b!, a / 255] : null;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The page's declared brand color (`--primary` and friends on :root or body), if any. */
|
|
92
|
+
function declaredAccent(background: Rgba): Rgba | null {
|
|
93
|
+
for (const element of [document.documentElement, document.body]) {
|
|
94
|
+
const style = getComputedStyle(element);
|
|
95
|
+
if (typeof style.getPropertyValue !== "function") continue;
|
|
96
|
+
for (const name of PRIMARY_VARS) {
|
|
97
|
+
const color = resolveColor(style.getPropertyValue(name));
|
|
98
|
+
if (color && color[3] > 0.5 && !sameColor(color, background)) return color;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** `a` moved `amount` of the way toward `b`: a surface or hairline in the page's own palette. */
|
|
105
|
+
function mix(a: Rgba, b: Rgba, amount: number): Rgba {
|
|
106
|
+
return [0, 1, 2].map((i) => Math.round(a[i]! + (b[i]! - a[i]!) * amount)).concat(1) as Rgba;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The page's corner radius in rem, from a shadcn-style `--radius` (rem or px), if it declares one. */
|
|
110
|
+
function declaredRadius(): string | undefined {
|
|
111
|
+
const raw = getComputedStyle(document.documentElement).getPropertyValue?.("--radius").trim();
|
|
112
|
+
const match = raw?.match(/^([\d.]+)(rem|px)$/);
|
|
113
|
+
if (!match) return undefined;
|
|
114
|
+
const rem = match[2] === "px" ? Number(match[1]) / 16 : Number(match[1]);
|
|
115
|
+
return rem >= 0 && rem <= 2 ? String(Math.round(rem * 1000) / 1000) : undefined;
|
|
116
|
+
}
|
|
43
117
|
|
|
44
118
|
/**
|
|
45
119
|
* Best-effort read of the host page's own background/text/accent colors, for
|
|
@@ -52,8 +126,8 @@ const ACCENT_SELECTORS = ["[data-frankie-accent]", "button[type=submit]", "a[hre
|
|
|
52
126
|
*
|
|
53
127
|
* Never attempts full brand-color extraction (logos, images, gradients) —
|
|
54
128
|
* only colors real elements are already rendering with, via
|
|
55
|
-
* `getComputedStyle`.
|
|
56
|
-
*
|
|
129
|
+
* `getComputedStyle`. The provider re-runs it when the host switches its own
|
|
130
|
+
* theme (see `watchHostTheme`).
|
|
57
131
|
*/
|
|
58
132
|
export function inferHostTheme(): Partial<WidgetTheme> | null {
|
|
59
133
|
if (typeof document === "undefined" || typeof getComputedStyle !== "function") return null;
|
|
@@ -65,9 +139,18 @@ export function inferHostTheme(): Partial<WidgetTheme> | null {
|
|
|
65
139
|
if (background[3] < 0.5) return null; // a transparent/near-transparent body tells us nothing real
|
|
66
140
|
if (contrastRatio(background, foreground) < 2) return null; // too low-contrast to trust as a real reading
|
|
67
141
|
|
|
142
|
+
// An explicit opt-in wins, then the page's declared brand variable, then
|
|
143
|
+
// a guess from rendered elements (never Frankie's own).
|
|
68
144
|
let accent: Rgba | null = null;
|
|
69
|
-
|
|
70
|
-
|
|
145
|
+
const marked = firstHostElement("[data-frankie-accent]");
|
|
146
|
+
if (marked) {
|
|
147
|
+
const style = getComputedStyle(marked);
|
|
148
|
+
const surface = parseRgb(style.backgroundColor);
|
|
149
|
+
accent = surface && surface[3] > 0.5 && !sameColor(surface, background) ? surface : parseRgb(style.color);
|
|
150
|
+
}
|
|
151
|
+
accent ??= declaredAccent(background);
|
|
152
|
+
for (const selector of accent ? [] : ACCENT_SELECTORS) {
|
|
153
|
+
const element = firstHostElement(selector);
|
|
71
154
|
if (!element) continue;
|
|
72
155
|
const style = getComputedStyle(element);
|
|
73
156
|
const surface = parseRgb(style.backgroundColor);
|
|
@@ -77,13 +160,55 @@ export function inferHostTheme(): Partial<WidgetTheme> | null {
|
|
|
77
160
|
}
|
|
78
161
|
if (!accent) return null; // nothing distinct enough from the page's own surface to call an accent
|
|
79
162
|
|
|
163
|
+
// The page's light/dark decides the widget's, not the visitor's OS: a dark
|
|
164
|
+
// site on a light-mode machine must not get light borders and buttons.
|
|
165
|
+
// Muted surfaces and hairlines come from the page's own two colors so
|
|
166
|
+
// nothing in the widget keeps the default palette.
|
|
167
|
+
const font = bodyStyle.fontFamily?.trim();
|
|
168
|
+
const radius = declaredRadius();
|
|
80
169
|
return {
|
|
170
|
+
mode: luminance(background) < 0.4 ? "dark" : "light",
|
|
81
171
|
background: toCss(background),
|
|
82
172
|
foreground: toCss(foreground),
|
|
83
173
|
primary: toCss(accent),
|
|
84
|
-
|
|
174
|
+
// Whichever of near-black or near-white reads better on the accent.
|
|
175
|
+
primaryForeground: contrastRatio(accent, [10, 10, 10, 1]) > contrastRatio(accent, [250, 250, 250, 1]) ? "#0a0a0a" : "#fafafa",
|
|
176
|
+
muted: toCss(mix(background, foreground, 0.07)),
|
|
177
|
+
border: toCss(mix(background, foreground, 0.14)),
|
|
178
|
+
...(font ? { fontFamily: font } : {}),
|
|
179
|
+
...(radius ? { radius } : {}),
|
|
85
180
|
};
|
|
86
181
|
} catch {
|
|
87
182
|
return null; // any DOM access failure (unusual host environment): never guess
|
|
88
183
|
}
|
|
89
184
|
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Call `onChange` when the host page may have switched its theme: a class,
|
|
188
|
+
* `data-*` or inline style change on <html> or <body> (how sites toggle light
|
|
189
|
+
* and dark), or the OS color scheme flipping. Coalesced to one call per
|
|
190
|
+
* frame. Watches only those two elements' own attributes, never the subtree,
|
|
191
|
+
* so the widget's own rendering can't trigger it. Returns a cleanup.
|
|
192
|
+
*/
|
|
193
|
+
export function watchHostTheme(onChange: () => void): () => void {
|
|
194
|
+
if (typeof document === "undefined" || typeof MutationObserver === "undefined") return () => {};
|
|
195
|
+
let frame = 0;
|
|
196
|
+
const schedule = () => {
|
|
197
|
+
if (frame) return;
|
|
198
|
+
frame = (typeof requestAnimationFrame === "function" ? requestAnimationFrame : (fn: FrameRequestCallback) => setTimeout(fn, 16) as unknown as number)(() => {
|
|
199
|
+
frame = 0;
|
|
200
|
+
onChange();
|
|
201
|
+
});
|
|
202
|
+
};
|
|
203
|
+
const observer = new MutationObserver(schedule);
|
|
204
|
+
for (const element of [document.documentElement, document.body]) {
|
|
205
|
+
if (element) observer.observe(element, { attributes: true, attributeFilter: ["class", "style", "data-theme", "data-mode", "data-color-scheme", "data-color-mode"] });
|
|
206
|
+
}
|
|
207
|
+
const media = typeof window !== "undefined" && window.matchMedia ? window.matchMedia("(prefers-color-scheme: dark)") : undefined;
|
|
208
|
+
media?.addEventListener?.("change", schedule);
|
|
209
|
+
return () => {
|
|
210
|
+
observer.disconnect();
|
|
211
|
+
media?.removeEventListener?.("change", schedule);
|
|
212
|
+
if (frame && typeof cancelAnimationFrame === "function") cancelAnimationFrame(frame);
|
|
213
|
+
};
|
|
214
|
+
}
|
package/src/styles/frankie.css
CHANGED
|
@@ -126,6 +126,14 @@
|
|
|
126
126
|
margin: 0;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
/* A button with no fill of its own (ghost, icon) must not show the
|
|
130
|
+
browser's native gray face and border, which the host's color scheme
|
|
131
|
+
decides. Utilities like bg-primary and border still win. */
|
|
132
|
+
.frankie-root button {
|
|
133
|
+
background-color: transparent;
|
|
134
|
+
border: 0 solid;
|
|
135
|
+
}
|
|
136
|
+
|
|
129
137
|
.frankie-root textarea {
|
|
130
138
|
background-color: var(--frankie-background);
|
|
131
139
|
}
|
|
@@ -312,11 +320,14 @@
|
|
|
312
320
|
display: flex;
|
|
313
321
|
width: min(100%, 44rem);
|
|
314
322
|
min-height: 100%;
|
|
323
|
+
/* Grow with its content instead of shrinking into the small panel, where
|
|
324
|
+
centered content overflowed upward, past the top you can scroll to. */
|
|
325
|
+
flex-shrink: 0;
|
|
315
326
|
margin: auto;
|
|
316
327
|
padding: clamp(2rem, 7vh, 4.5rem) 1rem 2rem;
|
|
317
328
|
flex-direction: column;
|
|
318
329
|
align-items: center;
|
|
319
|
-
justify-content: center;
|
|
330
|
+
justify-content: safe center;
|
|
320
331
|
gap: 2rem;
|
|
321
332
|
text-align: center;
|
|
322
333
|
/* Size the suggestion grid to the surface, not the viewport: one column in
|
|
@@ -81,4 +81,14 @@ describe("widget stylesheet is host-safe", () => {
|
|
|
81
81
|
expect(embedded).not.toMatch(/:(?:root|host)\s*[,{]/);
|
|
82
82
|
expect(embedded).toContain(".frankie-root");
|
|
83
83
|
});
|
|
84
|
+
|
|
85
|
+
it("gives the widget's own buttons no native face, so ghost buttons stay transparent in any host color scheme", () => {
|
|
86
|
+
expect(css).toMatch(/\.frankie-root button\s*\{[^}]*background-color:\s*(?:transparent|#0000)/);
|
|
87
|
+
expect(css).toMatch(/\.frankie-root button\s*\{[^}]*border:\s*0(?:px)? solid/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("never lets the welcome screen overflow above the top of the small panel", () => {
|
|
91
|
+
expect(css).toMatch(/\.frankie-page-empty\s*\{[^}]*flex-shrink:\s*0/);
|
|
92
|
+
expect(css).toMatch(/\.frankie-page-empty\s*\{[^}]*justify-content:\s*safe center/);
|
|
93
|
+
});
|
|
84
94
|
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { inferHostTheme } from "./lib/theme-inherit.js";
|
|
3
|
+
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
document.body.innerHTML = "";
|
|
6
|
+
document.body.removeAttribute("style");
|
|
7
|
+
document.documentElement.removeAttribute("style");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
function page(html: string) {
|
|
11
|
+
document.body.style.backgroundColor = "rgb(255, 255, 255)";
|
|
12
|
+
document.body.style.color = "rgb(17, 17, 17)";
|
|
13
|
+
document.body.innerHTML = html;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("inferHostTheme", () => {
|
|
17
|
+
it("never inherits Frankie's own widget colors", () => {
|
|
18
|
+
page(`
|
|
19
|
+
<div class="frankie-root"><button type="submit" style="background-color: rgb(245, 196, 0)">Send</button></div>
|
|
20
|
+
<a href="/pricing" style="color: rgb(79, 70, 229)">Pricing</a>`);
|
|
21
|
+
expect(inferHostTheme()?.primary).toBe("rgb(79, 70, 229)");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("prefers the page's declared brand variable over a guess from elements", () => {
|
|
25
|
+
page(`<a href="/" style="color: rgb(220, 38, 38)">Home</a>`);
|
|
26
|
+
document.documentElement.style.setProperty("--primary", "#4f46e5");
|
|
27
|
+
expect(inferHostTheme()?.primary).toBe("rgb(79, 70, 229)");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("reads shadcn-style bare HSL variables", () => {
|
|
31
|
+
page("");
|
|
32
|
+
document.documentElement.style.setProperty("--primary", "243 75% 59%");
|
|
33
|
+
const primary = inferHostTheme()?.primary;
|
|
34
|
+
expect(primary).toMatch(/^rgb\(/);
|
|
35
|
+
expect(primary).not.toBe("rgb(255, 255, 255)");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("an explicit data-frankie-accent element wins over everything", () => {
|
|
39
|
+
page(`<span data-frankie-accent style="background-color: rgb(22, 163, 74)"></span>`);
|
|
40
|
+
document.documentElement.style.setProperty("--primary", "#4f46e5");
|
|
41
|
+
expect(inferHostTheme()?.primary).toBe("rgb(22, 163, 74)");
|
|
42
|
+
});
|
|
43
|
+
});
|
package/src/widget.test.tsx
CHANGED
|
@@ -261,6 +261,19 @@ describe("Markdown", () => {
|
|
|
261
261
|
expect(screen.getByRole("link", { name: "docs" })).toHaveProperty("rel", "noopener noreferrer nofollow");
|
|
262
262
|
});
|
|
263
263
|
|
|
264
|
+
it("renders *italic* but leaves a spaced asterisk and bold alone", () => {
|
|
265
|
+
render(<Markdown text="See *Key rotation* — 2 * 3 is **six**" />);
|
|
266
|
+
expect(screen.getByText("Key rotation").tagName).toBe("EM");
|
|
267
|
+
expect(screen.getByText("six").tagName).toBe("STRONG");
|
|
268
|
+
expect(screen.getByText(/2 \* 3 is/)).toBeTruthy();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("keeps runs of asterisks literal instead of eating them as italic", () => {
|
|
272
|
+
const { container } = render(<Markdown text="Divider *** here and *x* too" />);
|
|
273
|
+
expect(container.textContent).toContain("Divider *** here");
|
|
274
|
+
expect(screen.getByText("x").tagName).toBe("EM");
|
|
275
|
+
});
|
|
276
|
+
|
|
264
277
|
it("refuses to linkify a javascript: url", () => {
|
|
265
278
|
render(<Markdown text="[click](javascript:alert(1))" />);
|
|
266
279
|
expect(screen.queryByRole("link")).toBeNull();
|
|
@@ -297,6 +310,19 @@ describe("ChatPanel", () => {
|
|
|
297
310
|
expect(screen.queryByText("1 skill loaded")).toBeNull();
|
|
298
311
|
cleanup();
|
|
299
312
|
});
|
|
313
|
+
it("opens the welcome screen at its top, not scrolled to the bottom", async () => {
|
|
314
|
+
cleanup();
|
|
315
|
+
const scrolls: number[] = [];
|
|
316
|
+
const { container } = render(<FrankieProvider publishableKey="pk_empty_fixture" config={config} fetchImpl={vi.fn()} persist={false}><ChatPanel /></FrankieProvider>);
|
|
317
|
+
const scroller = container.querySelector(".frankie-transcript") as HTMLElement;
|
|
318
|
+
Object.defineProperty(scroller, "scrollHeight", { configurable: true, get: () => 900 });
|
|
319
|
+
Object.defineProperty(scroller, "scrollTop", { configurable: true, get: () => scrolls.at(-1) ?? 0, set: (value: number) => { scrolls.push(value); } });
|
|
320
|
+
// A re-render with still no messages (e.g. config settling) must not jump to the bottom.
|
|
321
|
+
fireEvent.scroll(scroller);
|
|
322
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
323
|
+
expect(scrolls.every((value) => value === 0)).toBe(true);
|
|
324
|
+
cleanup();
|
|
325
|
+
});
|
|
300
326
|
it("keeps following new messages even when one update grows the transcript past the near-bottom threshold", async () => {
|
|
301
327
|
cleanup();
|
|
302
328
|
const fetchImpl = vi.fn(async (url: string | URL | Request) =>
|
|
@@ -429,6 +455,32 @@ describe("ChatPanel", () => {
|
|
|
429
455
|
vi.restoreAllMocks();
|
|
430
456
|
});
|
|
431
457
|
|
|
458
|
+
it("follows the host when it switches between light and dark, mode included", async () => {
|
|
459
|
+
document.body.innerHTML = '<button data-stub="host-accent">Buy</button>';
|
|
460
|
+
let dark = false;
|
|
461
|
+
const real = window.getComputedStyle.bind(window);
|
|
462
|
+
vi.spyOn(window, "getComputedStyle").mockImplementation((el: Element) => {
|
|
463
|
+
if (el === document.body) return (dark ? { backgroundColor: "rgb(15, 15, 14)", color: "rgb(243, 242, 238)" } : { backgroundColor: "rgb(250, 250, 250)", color: "rgb(20, 20, 20)" }) as CSSStyleDeclaration;
|
|
464
|
+
if ((el as HTMLElement).dataset?.stub === "host-accent") return { backgroundColor: "rgb(16, 185, 129)", color: "rgb(255, 255, 255)" } as CSSStyleDeclaration;
|
|
465
|
+
return real(el);
|
|
466
|
+
});
|
|
467
|
+
const { container } = render(
|
|
468
|
+
<FrankieProvider baseUrl="https://agent.acme.com" publishableKey="pk_1" config={{ ...config, theme: { mode: "light", source: "inherit" } }}>
|
|
469
|
+
<ChatPanel />
|
|
470
|
+
</FrankieProvider>,
|
|
471
|
+
);
|
|
472
|
+
const root = () => container.querySelector(".frankie-root") as HTMLElement;
|
|
473
|
+
await waitFor(() => expect(root().style.getPropertyValue("--frankie-background")).toBe("rgb(250, 250, 250)"));
|
|
474
|
+
expect(root().classList.contains("dark")).toBe(false);
|
|
475
|
+
// The site flips its own theme (as Frankie's dashboard does with data-theme).
|
|
476
|
+
dark = true;
|
|
477
|
+
document.documentElement.dataset.theme = "dark";
|
|
478
|
+
await waitFor(() => expect(root().style.getPropertyValue("--frankie-background")).toBe("rgb(15, 15, 14)"));
|
|
479
|
+
expect(root().classList.contains("dark")).toBe(true); // the configured "light" doesn't win over the page
|
|
480
|
+
delete document.documentElement.dataset.theme;
|
|
481
|
+
vi.restoreAllMocks();
|
|
482
|
+
});
|
|
483
|
+
|
|
432
484
|
it("keeps the configured fallback when the host page can't be read reliably", async () => {
|
|
433
485
|
vi.spyOn(window, "getComputedStyle").mockImplementation(() => ({ backgroundColor: "rgba(0, 0, 0, 0)", color: "rgba(0, 0, 0, 0)" }) as CSSStyleDeclaration);
|
|
434
486
|
const { container } = render(
|
|
@@ -467,6 +519,45 @@ describe("FrankieChatPage", () => {
|
|
|
467
519
|
cleanup();
|
|
468
520
|
});
|
|
469
521
|
|
|
522
|
+
it("keeps the conversation list on screen while switching chats, and fetches the config once", async () => {
|
|
523
|
+
cleanup();
|
|
524
|
+
window.localStorage.clear();
|
|
525
|
+
const signedIn = { ...config, allowAnonymous: false };
|
|
526
|
+
const list = [
|
|
527
|
+
{ id: "conv-a", title: "Hi, can you tell me what things I can do", createdAt: 1, updatedAt: Date.now() },
|
|
528
|
+
{ id: "conv-b", title: "Hi", createdAt: 1, updatedAt: Date.now() - 3 * 86400000 },
|
|
529
|
+
];
|
|
530
|
+
const session = (id: string) => ({ sessionId: id, sessionToken: "fixture", expiresAt: new Date(Date.now() + 600_000).toISOString(), user: { id: "one", anonymous: false } });
|
|
531
|
+
const calls: string[] = [];
|
|
532
|
+
const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
|
|
533
|
+
const href = String(url);
|
|
534
|
+
calls.push(`${init?.method ?? "GET"} ${new URL(href).pathname}`);
|
|
535
|
+
if (href.endsWith("/v1/config")) return Response.json(signedIn);
|
|
536
|
+
if (href.endsWith("/v1/session")) return Response.json(session("s"));
|
|
537
|
+
if (href.endsWith("/v1/conversations")) return Response.json({ conversations: list });
|
|
538
|
+
if (/\/v1\/conversations\/[^/]+\/resume$/.test(href)) return Response.json({ session: session("r"), messages: [], title: "Hi" });
|
|
539
|
+
if (/\/v1\/conversations\/[^/]+$/.test(href)) return Response.json({ ok: true });
|
|
540
|
+
return new Response(sseStream([{ type: "turn_end", stopReason: "end_turn" }]));
|
|
541
|
+
});
|
|
542
|
+
const { container } = render(<FrankieChatPage publishableKey="pk_page_switch" conversationId="conv-a" fetchImpl={fetchImpl} persist={false} getUserToken={async () => "h.eyJzdWIiOiJvbmUifQ.s"} />);
|
|
543
|
+
const aside = () => container.querySelector(".frankie-history-desktop") as HTMLElement;
|
|
544
|
+
const items = () => within(aside()).queryAllByRole("button").filter((button) => button.textContent?.startsWith("Hi"));
|
|
545
|
+
await waitFor(() => expect(items()).toHaveLength(2));
|
|
546
|
+
|
|
547
|
+
// Click the other conversation. The list must still be there in the very
|
|
548
|
+
// next render — it used to empty and show "Loading history…" because the
|
|
549
|
+
// sidebar remounted with the conversation — and the page must not fetch
|
|
550
|
+
// the workspace config again.
|
|
551
|
+
const other = () => items().find((button) => button.querySelector("span")?.textContent === "Hi")!;
|
|
552
|
+
fireEvent.click(other());
|
|
553
|
+
expect(items()).toHaveLength(2);
|
|
554
|
+
expect(within(aside()).queryByText("Loading history…")).toBeNull();
|
|
555
|
+
await waitFor(() => expect(other().getAttribute("aria-current")).toBe("page"));
|
|
556
|
+
expect(items()).toHaveLength(2);
|
|
557
|
+
expect(calls.filter((call) => call.endsWith("/v1/config"))).toHaveLength(1);
|
|
558
|
+
cleanup();
|
|
559
|
+
});
|
|
560
|
+
|
|
470
561
|
it("starts collapsed when a prior visit left the sidebar collapsed", async () => {
|
|
471
562
|
cleanup();
|
|
472
563
|
window.localStorage.setItem("frankie.history-collapsed", "1");
|