@frankieai/react 0.2.17 → 0.2.19
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 +280 -87
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +47 -4
- package/dist/index.d.ts +47 -4
- package/dist/index.js +241 -49
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/bottom-bars.test.tsx +67 -0
- package/src/components/chat/ChatEmptyState.tsx +1 -1
- package/src/components/chat/ChatPanel.tsx +2 -2
- package/src/components/chat/FrankieChatPage.tsx +17 -5
- package/src/components/chat/FrankieInline.tsx +4 -4
- package/src/components/chat/FrankieWidget.tsx +70 -7
- package/src/components/chat/MessageBubble.tsx +7 -5
- package/src/generated/css-content.ts +1 -1
- package/src/hooks/FrankieProvider.tsx +17 -3
- package/src/hooks/useFrankieChat.ts +59 -8
- package/src/index.ts +1 -1
- package/src/lib/bottom-bars.ts +43 -0
- package/src/lib/client.ts +21 -3
- package/src/lib/theme.ts +22 -1
- package/src/lib/viewport-fill.ts +39 -0
- package/src/styles/frankie.css +22 -3
- package/src/theme-ready.test.tsx +36 -0
- package/src/turn-follow.test.tsx +111 -0
|
@@ -69,6 +69,34 @@ export function useFrankieChatState(): UseFrankieChatResult {
|
|
|
69
69
|
|
|
70
70
|
const storageKey = `${client.namespace}.messages`;
|
|
71
71
|
|
|
72
|
+
// A reply the server is still writing after this browser lost the stream (a
|
|
73
|
+
// closed tab, a phone that backgrounded the page): show it as working and
|
|
74
|
+
// check back until it lands, then show the server's finished copy.
|
|
75
|
+
const followingRef = useRef(false);
|
|
76
|
+
const followRunningTurn = useCallback(async () => {
|
|
77
|
+
if (followingRef.current || !client.canResume) return;
|
|
78
|
+
followingRef.current = true;
|
|
79
|
+
setBusy(true);
|
|
80
|
+
try {
|
|
81
|
+
const deadline = Date.now() + 3 * 60_000;
|
|
82
|
+
while (mounted.current && Date.now() < deadline) {
|
|
83
|
+
await new Promise((resolve) => setTimeout(resolve, 2500));
|
|
84
|
+
const remote = await client.resumeConversation().catch(() => undefined);
|
|
85
|
+
if (!remote || remote.busy) continue;
|
|
86
|
+
if (remote.messages.length) {
|
|
87
|
+
const finished = remote.messages as ChatMessage[];
|
|
88
|
+
messagesRef.current = finished;
|
|
89
|
+
if (mounted.current) setMessages(finished);
|
|
90
|
+
}
|
|
91
|
+
if (mounted.current) setError(undefined);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
} finally {
|
|
95
|
+
followingRef.current = false;
|
|
96
|
+
if (mounted.current) setBusy(false);
|
|
97
|
+
}
|
|
98
|
+
}, [client]);
|
|
99
|
+
|
|
72
100
|
// Restore local storage immediately, then hydrate the authoritative server
|
|
73
101
|
// transcript. The server path makes the same signed-in user's chats available
|
|
74
102
|
// on another browser or device.
|
|
@@ -90,10 +118,11 @@ export function useFrankieChatState(): UseFrankieChatResult {
|
|
|
90
118
|
try {
|
|
91
119
|
const remote = await client.resumeConversation();
|
|
92
120
|
if (active && remote?.messages.length) {
|
|
93
|
-
const restoredMessages = remote.messages as ChatMessage[];
|
|
121
|
+
const restoredMessages = remote.busy ? stillWorking(remote.messages as ChatMessage[]) : (remote.messages as ChatMessage[]);
|
|
94
122
|
messagesRef.current = restoredMessages;
|
|
95
123
|
setMessages(restoredMessages);
|
|
96
124
|
}
|
|
125
|
+
if (active && remote?.busy) void followRunningTurn();
|
|
97
126
|
} catch (caught) {
|
|
98
127
|
if (active) {
|
|
99
128
|
const message =
|
|
@@ -110,7 +139,7 @@ export function useFrankieChatState(): UseFrankieChatResult {
|
|
|
110
139
|
};
|
|
111
140
|
void restore();
|
|
112
141
|
return () => { active = false; };
|
|
113
|
-
}, [client, storageKey]);
|
|
142
|
+
}, [client, storageKey, followRunningTurn]);
|
|
114
143
|
|
|
115
144
|
// Persist once a turn settles — never mid-stream — so the saved copy is clean.
|
|
116
145
|
// Wait until the initial restore has completed so an empty initial state never
|
|
@@ -205,6 +234,7 @@ export function useFrankieChatState(): UseFrankieChatResult {
|
|
|
205
234
|
patchAssistant(assistantId, (message) => ({ ...message, text: message.text + addText, thinking: message.thinking + addThinking }));
|
|
206
235
|
};
|
|
207
236
|
|
|
237
|
+
let lostStream = false;
|
|
208
238
|
try {
|
|
209
239
|
let receivedText = false;
|
|
210
240
|
let receivedToolActivity = false;
|
|
@@ -254,6 +284,10 @@ export function useFrankieChatState(): UseFrankieChatResult {
|
|
|
254
284
|
status: "done",
|
|
255
285
|
text: message.text || "Stopped.",
|
|
256
286
|
}));
|
|
287
|
+
} else if (!(caught instanceof FrankieApiError) && client.canResume) {
|
|
288
|
+
// The connection dropped, not the turn: the server keeps going and
|
|
289
|
+
// saves the reply. Keep this message working and pick it up below.
|
|
290
|
+
lostStream = true;
|
|
257
291
|
} else {
|
|
258
292
|
const message =
|
|
259
293
|
caught instanceof FrankieApiError
|
|
@@ -268,10 +302,13 @@ export function useFrankieChatState(): UseFrankieChatResult {
|
|
|
268
302
|
// Ensure no frame is left pending and every buffered token is committed
|
|
269
303
|
// before the turn settles and the transcript is persisted.
|
|
270
304
|
flushDeltas();
|
|
271
|
-
// A turn that ended without a `turn_end` event still has to settle
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
305
|
+
// A turn that ended without a `turn_end` event still has to settle,
|
|
306
|
+
// unless the server is still writing it (followed below).
|
|
307
|
+
if (!lostStream) {
|
|
308
|
+
patchAssistant(assistantId, (message) =>
|
|
309
|
+
message.status === "streaming" ? { ...message, status: "done" } : message,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
275
312
|
// Persist before send() resolves. This closes the previous gap where a
|
|
276
313
|
// route change or tab close could leave a server conversation with no
|
|
277
314
|
// rendered messages even though the agent transcript existed.
|
|
@@ -281,12 +318,19 @@ export function useFrankieChatState(): UseFrankieChatResult {
|
|
|
281
318
|
}
|
|
282
319
|
if (mounted.current) setBusy(false);
|
|
283
320
|
abortRef.current = null;
|
|
321
|
+
if (lostStream) void followRunningTurn();
|
|
284
322
|
}
|
|
285
323
|
},
|
|
286
|
-
[busy, client, context, patchAssistant, restored],
|
|
324
|
+
[busy, client, context, patchAssistant, restored, followRunningTurn],
|
|
287
325
|
);
|
|
288
326
|
|
|
289
|
-
|
|
327
|
+
// Closing the stream no longer stops a turn (a closed tab keeps its reply),
|
|
328
|
+
// so Stop tells the server first, then closes it.
|
|
329
|
+
const stop = useCallback(() => {
|
|
330
|
+
if (!abortRef.current) return;
|
|
331
|
+
void client.stopTurn();
|
|
332
|
+
abortRef.current.abort();
|
|
333
|
+
}, [client]);
|
|
290
334
|
|
|
291
335
|
const reset = useCallback(() => {
|
|
292
336
|
abortRef.current?.abort();
|
|
@@ -315,3 +359,10 @@ function conversationTitle(messages: ChatMessage[]): string | undefined {
|
|
|
315
359
|
const title = first.text.trim().replace(/\s+/g, " ");
|
|
316
360
|
return title.length > 42 ? `${title.slice(0, 42)}…` : title;
|
|
317
361
|
}
|
|
362
|
+
|
|
363
|
+
/** Show the last reply as still being written: reopen a broken one, or add an empty one. */
|
|
364
|
+
function stillWorking(messages: ChatMessage[]): ChatMessage[] {
|
|
365
|
+
const last = messages[messages.length - 1];
|
|
366
|
+
if (last?.role === "assistant") return [...messages.slice(0, -1), { ...last, status: "streaming", error: undefined }];
|
|
367
|
+
return [...messages, { id: "pending_" + Date.now(), role: "assistant", text: "", thinking: "", tools: [], status: "streaming", createdAt: Date.now() }];
|
|
368
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -33,7 +33,7 @@ export { applyEvent, newAssistantMessage, newUserMessage } from "./lib/messages.
|
|
|
33
33
|
export type { AssistantChatMessage, ChatMessage, MessageAttachment, ToolActivity, UserChatMessage } from "./lib/messages.js";
|
|
34
34
|
export { extractChoices } from "./lib/choices.js";
|
|
35
35
|
export type { ExtractedChoices } from "./lib/choices.js";
|
|
36
|
-
export { resolveMode, themeToCssVars } from "./lib/theme.js";
|
|
36
|
+
export { resolveMode, themeToCssVars, useResolvedMode } from "./lib/theme.js";
|
|
37
37
|
export { cn } from "./lib/utils.js";
|
|
38
38
|
|
|
39
39
|
export type { PublicWidgetConfig, WidgetTheme, FrankieServerEvent } from "@frankieai/core";
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How far a fixed or sticky bar along the bottom of the viewport (a mobile tab
|
|
3
|
+
* bar, a cookie banner, a sticky "Buy" bar) rises over the widget's corner, in
|
|
4
|
+
* pixels. The launcher sits above it instead of hiding beneath it.
|
|
5
|
+
*
|
|
6
|
+
* It samples the corner where the launcher sits, ignoring the widget itself,
|
|
7
|
+
* and counts only elements anchored to the bottom edge and shorter than 40% of
|
|
8
|
+
* the viewport, so a full-height sidebar or a modal never pushes it away.
|
|
9
|
+
*/
|
|
10
|
+
export function bottomObstruction(root: Element | null, position: "bottom-right" | "bottom-left", offset: number): number {
|
|
11
|
+
if (typeof document === "undefined" || typeof window === "undefined" || typeof document.elementsFromPoint !== "function") return 0;
|
|
12
|
+
const width = window.innerWidth;
|
|
13
|
+
const height = window.innerHeight;
|
|
14
|
+
if (!width || !height) return 0;
|
|
15
|
+
const xs = position === "bottom-right" ? [width - offset - 24, width - 8] : [offset + 24, 8];
|
|
16
|
+
const ys = [height - offset - 24, height - 4];
|
|
17
|
+
let lift = 0;
|
|
18
|
+
const seen = new Set<Element>();
|
|
19
|
+
for (const x of xs) for (const y of ys) {
|
|
20
|
+
for (const hit of document.elementsFromPoint(x, y)) {
|
|
21
|
+
if (root?.contains(hit)) continue;
|
|
22
|
+
const bar = anchoredAncestor(hit);
|
|
23
|
+
if (!bar || seen.has(bar)) continue;
|
|
24
|
+
seen.add(bar);
|
|
25
|
+
const rect = bar.getBoundingClientRect();
|
|
26
|
+
const anchoredToBottom = rect.bottom >= height - 4;
|
|
27
|
+
const short = rect.height > 0 && rect.height < height * 0.4;
|
|
28
|
+
if (anchoredToBottom && short) lift = Math.max(lift, height - rect.top);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return Math.round(lift);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The nearest fixed or sticky element at or above `element` (a few levels up at most). */
|
|
35
|
+
function anchoredAncestor(element: Element): Element | null {
|
|
36
|
+
let node: Element | null = element;
|
|
37
|
+
for (let depth = 0; node && depth < 8 && node !== document.body && node !== document.documentElement; depth += 1) {
|
|
38
|
+
const position = getComputedStyle(node).position;
|
|
39
|
+
if (position === "fixed" || position === "sticky") return node;
|
|
40
|
+
node = node.parentElement;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
package/src/lib/client.ts
CHANGED
|
@@ -207,7 +207,25 @@ export class FrankieClient {
|
|
|
207
207
|
return Array.isArray(body.conversations) ? body.conversations : [];
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
-
|
|
210
|
+
/** Whether a conversation can be reloaded from the server (a signed-in user and a conversation id). */
|
|
211
|
+
get canResume(): boolean {
|
|
212
|
+
return Boolean(this.options.conversationId && this.options.getUserToken);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Ask the server to stop the running turn. Closing the stream no longer
|
|
217
|
+
* stops it (a closed tab shouldn't lose the reply), so Stop says so
|
|
218
|
+
* explicitly. Best effort: the stream is closed right after either way.
|
|
219
|
+
*/
|
|
220
|
+
async stopTurn(): Promise<void> {
|
|
221
|
+
if (!this.session) return;
|
|
222
|
+
await this.doFetch(`${this.baseUrl}/v1/chat/stop`, {
|
|
223
|
+
method: "POST",
|
|
224
|
+
headers: { "x-frankie-key": this.options.publishableKey, authorization: `Bearer ${this.session.sessionToken}` },
|
|
225
|
+
}).catch(() => undefined);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async resumeConversation(id = this.options.conversationId): Promise<{ messages: unknown[]; title: string; busy: boolean } | undefined> {
|
|
211
229
|
if (!id || !this.options.getUserToken) return undefined;
|
|
212
230
|
const userToken = await this.requireUserToken();
|
|
213
231
|
const response = await this.doFetch(`${this.baseUrl}/v1/conversations/${encodeURIComponent(id)}/resume`, {
|
|
@@ -215,11 +233,11 @@ export class FrankieClient {
|
|
|
215
233
|
headers: { "x-frankie-key": this.options.publishableKey, authorization: `Bearer ${userToken}` },
|
|
216
234
|
});
|
|
217
235
|
if (response.status === 404) return undefined;
|
|
218
|
-
const body = (await this.unwrap(response)) as { session: CreateSessionResponse; messages?: unknown[]; title?: string };
|
|
236
|
+
const body = (await this.unwrap(response)) as { session: CreateSessionResponse; messages?: unknown[]; title?: string; busy?: boolean };
|
|
219
237
|
this.session = body.session;
|
|
220
238
|
this.sessionUserKey = sessionIdentity(userToken);
|
|
221
239
|
this.writeStored();
|
|
222
|
-
return { messages: Array.isArray(body.messages) ? body.messages : [], title: body.title ?? "New chat" };
|
|
240
|
+
return { messages: Array.isArray(body.messages) ? body.messages : [], title: body.title ?? "New chat", busy: body.busy === true };
|
|
223
241
|
}
|
|
224
242
|
|
|
225
243
|
async syncConversation(messages: unknown[], title?: string): Promise<void> {
|
package/src/lib/theme.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { WidgetTheme } from "@frankieai/core";
|
|
2
|
-
import type
|
|
2
|
+
import { useSyncExternalStore, type CSSProperties } from "react";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Map a theme to the CSS custom properties the widget's styles read.
|
|
@@ -28,3 +28,24 @@ export function resolveMode(mode: WidgetTheme["mode"]): "light" | "dark" {
|
|
|
28
28
|
if (typeof window === "undefined" || !window.matchMedia) return "light";
|
|
29
29
|
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
30
30
|
}
|
|
31
|
+
|
|
32
|
+
const DARK_QUERY = "(prefers-color-scheme: dark)";
|
|
33
|
+
function subscribeToScheme(onChange: () => void): () => void {
|
|
34
|
+
const media = typeof window !== "undefined" && window.matchMedia ? window.matchMedia(DARK_QUERY) : undefined;
|
|
35
|
+
media?.addEventListener?.("change", onChange);
|
|
36
|
+
return () => media?.removeEventListener?.("change", onChange);
|
|
37
|
+
}
|
|
38
|
+
const prefersDark = () => typeof window !== "undefined" && Boolean(window.matchMedia?.(DARK_QUERY).matches);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* `resolveMode` as a hook. Server-rendered pages can't know the visitor's
|
|
42
|
+
* scheme, and a hydrated attribute that differs from the server's is never
|
|
43
|
+
* patched, so a plain `resolveMode` call could leave "system" stuck on light.
|
|
44
|
+
* This renders light on the server and during hydration, then the visitor's
|
|
45
|
+
* scheme, and follows it when they switch.
|
|
46
|
+
*/
|
|
47
|
+
export function useResolvedMode(mode: WidgetTheme["mode"]): "light" | "dark" {
|
|
48
|
+
const dark = useSyncExternalStore(subscribeToScheme, prefersDark, () => false);
|
|
49
|
+
if (mode === "light" || mode === "dark") return mode;
|
|
50
|
+
return dark ? "dark" : "light";
|
|
51
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useEffect, useState, type RefObject } from "react";
|
|
2
|
+
import { bottomObstruction } from "./bottom-bars.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The height that fills the viewport from where `ref` starts down to the
|
|
6
|
+
* bottom edge, less any fixed bar along the bottom (a mobile tab bar). A
|
|
7
|
+
* full-page chat inside a host layout with no sized parent otherwise stops at
|
|
8
|
+
* its minimum height and leaves a gap above the host's navigation.
|
|
9
|
+
* Re-measured on resize and, throttled, as the page changes.
|
|
10
|
+
*/
|
|
11
|
+
export function useViewportFill(ref: RefObject<HTMLElement | null>, enabled: boolean): number {
|
|
12
|
+
const [fill, setFill] = useState(0);
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (!enabled || typeof window === "undefined") { setFill(0); return; }
|
|
15
|
+
let timer = 0;
|
|
16
|
+
let last = 0;
|
|
17
|
+
const measure = () => {
|
|
18
|
+
timer = 0;
|
|
19
|
+
last = Date.now();
|
|
20
|
+
const node = ref.current;
|
|
21
|
+
if (!node) return;
|
|
22
|
+
const top = node.getBoundingClientRect().top + window.scrollY;
|
|
23
|
+
const lift = bottomObstruction(node, "bottom-right", 0);
|
|
24
|
+
setFill(Math.max(0, Math.round(window.innerHeight - top - lift)));
|
|
25
|
+
};
|
|
26
|
+
const schedule = () => {
|
|
27
|
+
if (timer) return;
|
|
28
|
+
timer = window.setTimeout(() => requestAnimationFrame(measure), Math.max(0, 300 - (Date.now() - last)));
|
|
29
|
+
};
|
|
30
|
+
measure();
|
|
31
|
+
window.addEventListener("resize", schedule);
|
|
32
|
+
const observer = typeof MutationObserver === "function"
|
|
33
|
+
? new MutationObserver((records) => { if (records.some((record) => !ref.current?.contains(record.target))) schedule(); })
|
|
34
|
+
: undefined;
|
|
35
|
+
observer?.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["class", "style"] });
|
|
36
|
+
return () => { window.clearTimeout(timer); window.removeEventListener("resize", schedule); observer?.disconnect(); };
|
|
37
|
+
}, [ref, enabled]);
|
|
38
|
+
return fill;
|
|
39
|
+
}
|
package/src/styles/frankie.css
CHANGED
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
.frankie-chat-settings > div { position:absolute; top:46px; right:0; z-index:5; width:min(280px,80vw); display:grid; gap:12px; padding:16px; border:1px solid var(--frankie-border); border-radius:12px; background:var(--frankie-background); box-shadow:0 10px 30px #0002; font-size:13px; }.frankie-chat-settings p { margin:0; color:var(--frankie-muted-foreground); }
|
|
67
67
|
.frankie-loaded-skills { width:100%; max-width:760px; font-size:12px; color:var(--frankie-muted-foreground); }.frankie-loaded-skills summary { cursor:pointer; padding:6px 0; }.frankie-loaded-skills ul { list-style:none; padding:4px 0; margin:0; }.frankie-loaded-skills li { display:flex; gap:8px; padding:4px 0; }.frankie-loaded-skills small { opacity:.7; }
|
|
68
68
|
.frankie-history-dialog { padding:0; border:0; margin:0; max-height:none; height:100dvh; max-width:none; width:min(320px,86vw); background:var(--frankie-secondary); color:var(--frankie-foreground); }.frankie-history-dialog::backdrop { background:#0008; }.frankie-history-dialog > aside { display:flex; flex-direction:column; gap:16px; height:100%; padding:calc(20px + env(safe-area-inset-top)) 16px calc(20px + env(safe-area-inset-bottom)); overflow:auto; }
|
|
69
|
-
.frankie-mobile-only { display:none!important; }.frankie-widget-panel { max-height:calc(100dvh - 100px); }
|
|
69
|
+
.frankie-mobile-only { display:none!important; }.frankie-widget-panel { max-height:calc(100dvh - 100px - var(--frankie-lift, 0px)); }
|
|
70
70
|
.frankie-root button:focus-visible,.frankie-root summary:focus-visible,.frankie-root input:focus-visible { outline:2px solid var(--frankie-primary); outline-offset:3px; }
|
|
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); } }
|
|
@@ -425,6 +425,22 @@
|
|
|
425
425
|
font-size: .85rem;
|
|
426
426
|
}
|
|
427
427
|
|
|
428
|
+
/* The floating widget is short: a smaller welcome and the starters as
|
|
429
|
+
wrapping pills (icon + title; the description moves to a tooltip) keep
|
|
430
|
+
every suggestion visible without scrolling. */
|
|
431
|
+
.frankie-widget-panel .frankie-page-empty { padding: 1rem .25rem; gap: 1rem; }
|
|
432
|
+
.frankie-widget-panel .frankie-page-welcome h1 { font-size: 1.25rem; }
|
|
433
|
+
.frankie-widget-panel .frankie-page-welcome p { margin-top: .35rem; font-size: .88rem; line-height: 1.45; }
|
|
434
|
+
.frankie-widget-panel .frankie-page-suggest { gap: .55rem; }
|
|
435
|
+
.frankie-widget-panel .frankie-page-suggest-label { font-size: .66rem; }
|
|
436
|
+
.frankie-widget-panel .frankie-page-prompts { display: flex; flex-wrap: wrap; justify-content: center; gap: .4rem; }
|
|
437
|
+
.frankie-widget-panel .frankie-page-prompts button { min-height: 34px; max-width: 100%; align-items: center; gap: .4rem; padding: .3rem .8rem .3rem .45rem; border-radius: 999px; }
|
|
438
|
+
.frankie-widget-panel .frankie-prompt-icon { width: 22px; height: 22px; border-radius: 999px; }
|
|
439
|
+
.frankie-widget-panel .frankie-prompt-icon svg { width: 13px; height: 13px; }
|
|
440
|
+
.frankie-widget-panel .frankie-prompt-text strong { font-size: .82rem; font-weight: 550; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
441
|
+
.frankie-widget-panel .frankie-prompt-text small { display: none; }
|
|
442
|
+
.frankie-widget-panel .frankie-page-suggest-hint { display: none; }
|
|
443
|
+
|
|
428
444
|
@media (max-width: 640px) {
|
|
429
445
|
.frankie-page-header { min-height: 52px; flex-basis: 52px; padding: 0 12px; }
|
|
430
446
|
.frankie-page-canvas .frankie-scroll { padding: 1.25rem .75rem .5rem; }
|
|
@@ -575,11 +591,14 @@
|
|
|
575
591
|
height:12px; border-radius:6px;
|
|
576
592
|
background:linear-gradient(90deg,
|
|
577
593
|
color-mix(in srgb, var(--frankie-muted-foreground) 11%, transparent) 25%,
|
|
578
|
-
color-mix(in srgb, var(--frankie-muted-foreground)
|
|
594
|
+
color-mix(in srgb, var(--frankie-muted-foreground) 19%, transparent) 37%,
|
|
579
595
|
color-mix(in srgb, var(--frankie-muted-foreground) 11%, transparent) 63%);
|
|
580
596
|
background-size:400% 100%;
|
|
581
|
-
animation:frankie-shimmer
|
|
597
|
+
animation:frankie-shimmer 2.2s ease-in-out infinite;
|
|
582
598
|
}
|
|
599
|
+
/* Fades in after a beat, so a fast first word never flashes a skeleton. */
|
|
600
|
+
.frankie-root .frankie-skeleton.is-delayed { animation:frankie-skeleton-in .25s ease .35s both; }
|
|
601
|
+
@keyframes frankie-skeleton-in { from { opacity:0; } to { opacity:1; } }
|
|
583
602
|
@keyframes frankie-shimmer { 0% { background-position:100% 50%; } 100% { background-position:0 50%; } }
|
|
584
603
|
@keyframes frankie-skeleton-pulse { 0%,100% { opacity:.45; } 50% { opacity:.85; } }
|
|
585
604
|
@media (prefers-reduced-motion: reduce) {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { act, render, waitFor } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { FrankieProvider } from "./hooks/FrankieProvider.js";
|
|
4
|
+
import { FrankieWidget } from "./components/chat/FrankieWidget.js";
|
|
5
|
+
import { __resetFrankieStylesForTests } from "./lib/ensure-frankie-styles.js";
|
|
6
|
+
|
|
7
|
+
/** Answer the stylesheet sentinel as a browser with our CSS loaded would. */
|
|
8
|
+
function stylesLoaded() {
|
|
9
|
+
const real = window.getComputedStyle.bind(window);
|
|
10
|
+
vi.spyOn(window, "getComputedStyle").mockImplementation((el: Element, pseudo?: string | null) => {
|
|
11
|
+
const style = real(el, pseudo ?? undefined);
|
|
12
|
+
return { ...style, getPropertyValue: (property: string) => (property === "--frankie-styles-ready" ? "1" : style.getPropertyValue(property)) } as CSSStyleDeclaration;
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
afterEach(() => { vi.restoreAllMocks(); __resetFrankieStylesForTests(); });
|
|
17
|
+
|
|
18
|
+
describe("first paint", () => {
|
|
19
|
+
it("keeps the widget invisible until its theme is known, so OS dark mode never flashes first", async () => {
|
|
20
|
+
stylesLoaded();
|
|
21
|
+
// The visitor's OS prefers dark; the workspace says light.
|
|
22
|
+
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({ matches: query.includes("dark"), media: query, addEventListener() {}, removeEventListener() {} }) as unknown as MediaQueryList);
|
|
23
|
+
let answer: (response: Response) => void = () => {};
|
|
24
|
+
const fetchImpl = vi.fn(() => new Promise<Response>((resolve) => { answer = resolve; }));
|
|
25
|
+
const { container } = render(<FrankieProvider publishableKey="pk_test" baseUrl="https://frankie.test" fetchImpl={fetchImpl as unknown as typeof fetch}><FrankieWidget /></FrankieProvider>);
|
|
26
|
+
const root = container.querySelector(".frankie-widget") as HTMLElement;
|
|
27
|
+
await waitFor(() => expect(root.dataset.frankieStyles).toBe("ready"));
|
|
28
|
+
expect(root.style.opacity).toBe("0");
|
|
29
|
+
|
|
30
|
+
await act(async () => {
|
|
31
|
+
answer(new Response(JSON.stringify({ tenantId: "acme", branding: { name: "Acme" }, theme: { mode: "light" }, auth: { mode: "anonymous" } }), { status: 200, headers: { "content-type": "application/json" } }));
|
|
32
|
+
});
|
|
33
|
+
await waitFor(() => expect(root.style.opacity).toBe(""));
|
|
34
|
+
expect(root.dataset.frankieMode).toBe("light");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { PublicWidgetConfig } from "@frankieai/core";
|
|
4
|
+
import { FrankieProvider } from "./hooks/FrankieProvider.jsx";
|
|
5
|
+
import { useFrankieChat } from "./hooks/useFrankieChat.js";
|
|
6
|
+
|
|
7
|
+
const config: PublicWidgetConfig = {
|
|
8
|
+
tenantId: "acme",
|
|
9
|
+
branding: { name: "Acme Guide", tagline: "Setup help", launcherLabel: "Need a hand?" },
|
|
10
|
+
greeting: "Hi!",
|
|
11
|
+
suggestedPrompts: [],
|
|
12
|
+
theme: { mode: "light" },
|
|
13
|
+
allowAnonymous: false,
|
|
14
|
+
showToolActivity: true,
|
|
15
|
+
features: {
|
|
16
|
+
attachments: { enabled: false, accept: ["image/*"], maxMb: 10, maxCount: 4 },
|
|
17
|
+
modelPicker: { enabled: false, models: [] },
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const session = { sessionId: "s", sessionToken: "fixture", expiresAt: new Date(Date.now() + 600_000).toISOString(), user: { id: "one", anonymous: false } };
|
|
22
|
+
const token = async () => "h.eyJzdWIiOiJvbmUifQ.s";
|
|
23
|
+
const user = { id: "u1", role: "user", text: "Find the backrooms", createdAt: 1 };
|
|
24
|
+
const done = { id: "a1", role: "assistant", text: "Here they are.", thinking: "", tools: [], status: "done", createdAt: 2 };
|
|
25
|
+
|
|
26
|
+
/** Prints the chat state so tests can read it. */
|
|
27
|
+
function Probe() {
|
|
28
|
+
const chat = useFrankieChat();
|
|
29
|
+
const last = chat.messages[chat.messages.length - 1];
|
|
30
|
+
return (
|
|
31
|
+
<>
|
|
32
|
+
<output data-testid="state">{`${chat.busy ? "busy" : "idle"}|${last ? `${last.role}:${last.role === "assistant" ? last.status : ""}:${last.text}` : "empty"}`}</output>
|
|
33
|
+
<button disabled={chat.restoring} onClick={() => void chat.send("hello")}>Send</button>
|
|
34
|
+
<button onClick={() => chat.stop()}>Stop</button>
|
|
35
|
+
</>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const state = () => screen.getByTestId("state").textContent;
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
cleanup();
|
|
43
|
+
vi.useRealTimers();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("a reply the server is still writing", () => {
|
|
47
|
+
it("shows as working after a reload and lands when the server finishes", async () => {
|
|
48
|
+
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
49
|
+
let resumes = 0;
|
|
50
|
+
const fetchImpl = vi.fn(async (url: string | URL | Request) => {
|
|
51
|
+
if (!String(url).endsWith("/resume")) return Response.json({ ok: true });
|
|
52
|
+
resumes += 1;
|
|
53
|
+
// First visit: the tab was closed mid-reply and the turn is still running.
|
|
54
|
+
return resumes === 1
|
|
55
|
+
? Response.json({ session, messages: [user], title: "Backrooms", busy: true })
|
|
56
|
+
: Response.json({ session, messages: [user, done], title: "Backrooms", busy: false });
|
|
57
|
+
});
|
|
58
|
+
render(<FrankieProvider publishableKey="pk_follow" config={config} conversationId="conv-1" fetchImpl={fetchImpl} persist={false} getUserToken={token}><Probe /></FrankieProvider>);
|
|
59
|
+
await waitFor(() => expect(state()).toBe("busy|assistant:streaming:"));
|
|
60
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
61
|
+
await waitFor(() => expect(state()).toBe("idle|assistant:done:Here they are."));
|
|
62
|
+
expect(resumes).toBe(2);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("keeps a reply working when only the connection drops, then picks it up", async () => {
|
|
66
|
+
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
67
|
+
let resumes = 0;
|
|
68
|
+
const fetchImpl = vi.fn(async (url: string | URL | Request) => {
|
|
69
|
+
const href = String(url);
|
|
70
|
+
if (href.endsWith("/resume")) {
|
|
71
|
+
resumes += 1;
|
|
72
|
+
return resumes === 1
|
|
73
|
+
? Response.json({ session, messages: [], title: "New chat", busy: false })
|
|
74
|
+
: Response.json({ session, messages: [{ ...user, text: "hello" }, done], title: "hello", busy: false });
|
|
75
|
+
}
|
|
76
|
+
if (href.endsWith("/v1/chat")) throw new TypeError("network error");
|
|
77
|
+
return Response.json({ ok: true });
|
|
78
|
+
});
|
|
79
|
+
render(<FrankieProvider publishableKey="pk_drop" config={config} conversationId="conv-2" fetchImpl={fetchImpl} persist={false} getUserToken={token}><Probe /></FrankieProvider>);
|
|
80
|
+
await waitFor(() => expect((screen.getByRole("button", { name: "Send" }) as HTMLButtonElement).disabled).toBe(false));
|
|
81
|
+
fireEvent.click(screen.getByRole("button", { name: "Send" }));
|
|
82
|
+
await waitFor(() => expect(state()).toBe("busy|assistant:streaming:"));
|
|
83
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
84
|
+
await waitFor(() => expect(state()).toBe("idle|assistant:done:Here they are."));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("tells the server to stop when the visitor presses Stop", async () => {
|
|
88
|
+
const calls: string[] = [];
|
|
89
|
+
const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
|
|
90
|
+
const href = String(url);
|
|
91
|
+
calls.push(`${init?.method ?? "GET"} ${new URL(href).pathname}`);
|
|
92
|
+
if (href.endsWith("/resume")) return Response.json({ session, messages: [], title: "New chat", busy: false });
|
|
93
|
+
if (href.endsWith("/v1/chat")) {
|
|
94
|
+
// A stream that stays open until the request is aborted.
|
|
95
|
+
return new Response(new ReadableStream({
|
|
96
|
+
start(controller) {
|
|
97
|
+
init?.signal?.addEventListener("abort", () => controller.error(new DOMException("aborted", "AbortError")));
|
|
98
|
+
},
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
return Response.json({ ok: true });
|
|
102
|
+
});
|
|
103
|
+
render(<FrankieProvider publishableKey="pk_stop" config={config} conversationId="conv-3" fetchImpl={fetchImpl} persist={false} getUserToken={token}><Probe /></FrankieProvider>);
|
|
104
|
+
await waitFor(() => expect((screen.getByRole("button", { name: "Send" }) as HTMLButtonElement).disabled).toBe(false));
|
|
105
|
+
fireEvent.click(screen.getByRole("button", { name: "Send" }));
|
|
106
|
+
await waitFor(() => expect(calls).toContain("POST /v1/chat"));
|
|
107
|
+
fireEvent.click(screen.getByRole("button", { name: "Stop" }));
|
|
108
|
+
await waitFor(() => expect(calls).toContain("POST /v1/chat/stop"));
|
|
109
|
+
await waitFor(() => expect(state()).toBe("idle|assistant:done:Stopped."));
|
|
110
|
+
});
|
|
111
|
+
});
|