@frankieai/react 0.2.18 → 0.2.20
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 +232 -39
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +37 -3
- package/dist/index.d.ts +37 -3
- package/dist/index.js +227 -34
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/bottom-bars.test.tsx +67 -0
- package/src/components/chat/FrankieChatPage.tsx +12 -2
- package/src/components/chat/FrankieWidget.tsx +90 -4
- package/src/components/chat/MessageBubble.tsx +7 -5
- package/src/generated/css-content.ts +1 -1
- package/src/hooks/FrankieProvider.tsx +19 -2
- package/src/hooks/useFrankieChat.ts +59 -8
- package/src/lib/bottom-bars.ts +43 -0
- package/src/lib/client.ts +21 -3
- package/src/lib/viewport-fill.ts +39 -0
- package/src/outside-click.test.tsx +42 -0
- package/src/styles/frankie.css +13 -3
- package/src/theme-ready.test.tsx +19 -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
|
+
}
|
|
@@ -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> {
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { act, cleanup, fireEvent, render, screen } 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 { FrankieWidget } from "./components/chat/FrankieWidget.js";
|
|
6
|
+
|
|
7
|
+
const config: PublicWidgetConfig = {
|
|
8
|
+
tenantId: "acme", branding: { name: "Acme", tagline: "", launcherLabel: "Need a hand?" }, greeting: "Hi", suggestedPrompts: [],
|
|
9
|
+
theme: { mode: "light" }, allowAnonymous: true, showToolActivity: true,
|
|
10
|
+
features: { attachments: { enabled: false, accept: [], maxMb: 10, maxCount: 4 }, modelPicker: { enabled: false, models: [] } },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
afterEach(cleanup);
|
|
14
|
+
const panel = () => document.querySelector(".frankie-widget-panel");
|
|
15
|
+
const renderWidget = (props: { closeOnOutsideClick?: boolean } = {}) => render(
|
|
16
|
+
<div>
|
|
17
|
+
<button type="button">Elsewhere</button>
|
|
18
|
+
<button type="button" data-frankie-open>Ask</button>
|
|
19
|
+
<FrankieProvider publishableKey="pk_outside" config={config} fetchImpl={vi.fn()} persist={false}><FrankieWidget defaultOpen {...props} /></FrankieProvider>
|
|
20
|
+
</div>,
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
describe("clicking outside the widget", () => {
|
|
24
|
+
it("closes the panel", () => {
|
|
25
|
+
renderWidget();
|
|
26
|
+
expect(panel()).toBeTruthy();
|
|
27
|
+
act(() => { fireEvent.pointerDown(screen.getByRole("button", { name: "Elsewhere" })); });
|
|
28
|
+
expect(panel()).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
it("keeps it open for clicks inside it and on the host's own Ask button", () => {
|
|
31
|
+
renderWidget();
|
|
32
|
+
act(() => { fireEvent.pointerDown(panel()!); });
|
|
33
|
+
expect(panel()).toBeTruthy();
|
|
34
|
+
act(() => { fireEvent.pointerDown(screen.getByRole("button", { name: "Ask" })); });
|
|
35
|
+
expect(panel()).toBeTruthy();
|
|
36
|
+
});
|
|
37
|
+
it("can be turned off", () => {
|
|
38
|
+
renderWidget({ closeOnOutsideClick: false });
|
|
39
|
+
act(() => { fireEvent.pointerDown(screen.getByRole("button", { name: "Elsewhere" })); });
|
|
40
|
+
expect(panel()).toBeTruthy();
|
|
41
|
+
});
|
|
42
|
+
});
|
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); } }
|
|
@@ -591,11 +591,14 @@
|
|
|
591
591
|
height:12px; border-radius:6px;
|
|
592
592
|
background:linear-gradient(90deg,
|
|
593
593
|
color-mix(in srgb, var(--frankie-muted-foreground) 11%, transparent) 25%,
|
|
594
|
-
color-mix(in srgb, var(--frankie-muted-foreground)
|
|
594
|
+
color-mix(in srgb, var(--frankie-muted-foreground) 19%, transparent) 37%,
|
|
595
595
|
color-mix(in srgb, var(--frankie-muted-foreground) 11%, transparent) 63%);
|
|
596
596
|
background-size:400% 100%;
|
|
597
|
-
animation:frankie-shimmer
|
|
597
|
+
animation:frankie-shimmer 2.2s ease-in-out infinite;
|
|
598
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; } }
|
|
599
602
|
@keyframes frankie-shimmer { 0% { background-position:100% 50%; } 100% { background-position:0 50%; } }
|
|
600
603
|
@keyframes frankie-skeleton-pulse { 0%,100% { opacity:.45; } 50% { opacity:.85; } }
|
|
601
604
|
@media (prefers-reduced-motion: reduce) {
|
|
@@ -604,3 +607,10 @@
|
|
|
604
607
|
animation:frankie-skeleton-pulse 1.6s ease-in-out infinite;
|
|
605
608
|
}
|
|
606
609
|
}
|
|
610
|
+
/* The compact composer (widget, inline) with an attach button or model picker:
|
|
611
|
+
the message box gets its own row and the tools + send sit below it, so the
|
|
612
|
+
row can't run past a narrow panel's edge. */
|
|
613
|
+
.frankie-root:not(.frankie-chat-panel-fill) .frankie-composer-controls:has(.frankie-composer-actions > :not(input[hidden])) { flex-wrap:wrap; align-items:center; row-gap:8px; }
|
|
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
|
+
.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
|
+
.frankie-root:not(.frankie-chat-panel-fill) .frankie-composer-controls:has(.frankie-composer-actions > :not(input[hidden])) > button { margin-left:auto; }
|
package/src/theme-ready.test.tsx
CHANGED
|
@@ -33,4 +33,23 @@ describe("first paint", () => {
|
|
|
33
33
|
await waitFor(() => expect(root.style.opacity).toBe(""));
|
|
34
34
|
expect(root.dataset.frankieMode).toBe("light");
|
|
35
35
|
});
|
|
36
|
+
|
|
37
|
+
it("shows the widget anyway when the config never answers, instead of hiding it for as long as the browser waits", async () => {
|
|
38
|
+
stylesLoaded();
|
|
39
|
+
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
40
|
+
try {
|
|
41
|
+
// A hung request: no response, no error, and the client sets no timeout of its own.
|
|
42
|
+
const fetchImpl = vi.fn(() => new Promise<Response>(() => {}));
|
|
43
|
+
const { container } = render(<FrankieProvider publishableKey="pk_test" baseUrl="https://frankie.test" fetchImpl={fetchImpl as unknown as typeof fetch}><FrankieWidget /></FrankieProvider>);
|
|
44
|
+
const root = container.querySelector(".frankie-widget") as HTMLElement;
|
|
45
|
+
await waitFor(() => expect(root.dataset.frankieStyles).toBe("ready"));
|
|
46
|
+
expect(root.style.opacity).toBe("0");
|
|
47
|
+
await act(async () => { await vi.advanceTimersByTimeAsync(4_000); });
|
|
48
|
+
expect(root.style.opacity).toBe("0");
|
|
49
|
+
await act(async () => { await vi.advanceTimersByTimeAsync(1_500); });
|
|
50
|
+
expect(root.style.opacity).toBe("");
|
|
51
|
+
} finally {
|
|
52
|
+
vi.useRealTimers();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
36
55
|
});
|
|
@@ -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
|
+
});
|