@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.
@@ -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
- patchAssistant(assistantId, (message) =>
273
- message.status === "streaming" ? { ...message, status: "done" } : message,
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
- const stop = useCallback(() => abortRef.current?.abort(), []);
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
- async resumeConversation(id = this.options.conversationId): Promise<{ messages: unknown[]; title: string } | undefined> {
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
+ });
@@ -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) 26%, transparent) 37%,
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 1.4s ease infinite;
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; }
@@ -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
+ });