@frankieai/react 0.2.0 → 0.2.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frankieai/react",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Embeddable, themeable chat widget for the Frankie onboarding agent — shadcn/ui components, works with or without Tailwind in the host app.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,7 +31,7 @@
31
31
  "clsx": "^2.1.1",
32
32
  "lucide-react": "^0.460.0",
33
33
  "tailwind-merge": "^2.5.0",
34
- "@frankieai/core": "0.2.0"
34
+ "@frankieai/core": "0.2.1"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@tailwindcss/cli": "^4.0.0",
@@ -0,0 +1,22 @@
1
+ "use client";
2
+ import { ArrowUpRight, Sparkles } from "lucide-react";
3
+ import { useFrankie } from "../../hooks/FrankieProvider.jsx";
4
+ import { useFrankieChat } from "../../hooks/useFrankieChat.js";
5
+
6
+ /** Reports server-confirmed skills for the current turn; never implies drafts are active. */
7
+ export function AgentContext() {
8
+ const { loading, configError } = useFrankie();
9
+ const { loadedSkills, busy, restoring, error } = useFrankieChat();
10
+ const status = configError || error ? "Unavailable" : loading || restoring ? "Connecting" : busy && loadedSkills === null ? "Checking" : loadedSkills?.length ? "Active" : loadedSkills ? "Tools only" : "Ready";
11
+ return <div className="frankie-agent-context-wrap">
12
+ <details className="frankie-agent-context">
13
+ <summary aria-label="Agent context"><Sparkles size={17} aria-hidden="true" /><span>Agent context</span><span className="frankie-context-count">{loadedSkills === null ? "Not checked" : `${loadedSkills.length} ${loadedSkills.length === 1 ? "skill" : "skills"}`}</span><span className="frankie-context-status" data-active={status === "Active"}><i aria-hidden="true" />{status}</span><ArrowUpRight size={15} aria-hidden="true" /></summary>
14
+ <div className="frankie-context-detail">
15
+ <strong>{loadedSkills === null ? "Skills are checked when you send a message" : loadedSkills.length ? `${loadedSkills.length} ${loadedSkills.length === 1 ? "skill" : "skills"} loaded` : "No skills loaded for this turn"}</strong>
16
+ {loadedSkills?.length ? <ul>{loadedSkills.map(skill => <li key={skill.id}><span>{skill.name}</span><small>v{skill.revision}</small></li>)}</ul> : <p>Frankie can use the tools approved for this workspace. Drafts and skills without available tools are not loaded.</p>}
17
+ <p>Published skills and updates are checked on every message. Only approved, available tools can run.</p>
18
+ </div>
19
+ </details>
20
+ <span className="frankie-sr-only" role="status">{loadedSkills === null ? status : `${loadedSkills.length} ${loadedSkills.length === 1 ? "skill" : "skills"} loaded. ${status}.`}</span>
21
+ </div>;
22
+ }
@@ -11,6 +11,7 @@ import { Avatar } from "../ui/avatar.js";
11
11
  import { Button } from "../ui/button.js";
12
12
  import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "../ui/card.js";
13
13
  import { ScrollArea } from "../ui/scroll-area.js";
14
+ import { AgentContext } from "./AgentContext.js";
14
15
  import { Composer } from "./Composer.js";
15
16
  import { MessageBubble } from "./MessageBubble.js";
16
17
  import { SuggestedPrompts } from "./SuggestedPrompts.js";
@@ -49,7 +50,7 @@ export function ChatPanel({
49
50
  }: ChatPanelProps) {
50
51
  const { config, configError, loading } = useFrankie();
51
52
  const theme = useFrankieTheme();
52
- const { messages, busy, restoring, loadedSkills, error, send, stop, reset } = useFrankieChat();
53
+ const { messages, busy, restoring, error, send, stop, reset } = useFrankieChat();
53
54
  const scrollRef = useRef<HTMLDivElement>(null);
54
55
 
55
56
  const features = config?.features;
@@ -70,7 +71,8 @@ export function ChatPanel({
70
71
  return (
71
72
  <Card
72
73
  className={cn(
73
- "frankie-root",
74
+ "frankie-root frankie-chat-panel",
75
+ fill && "frankie-chat-panel-fill",
74
76
  fill ? "h-full w-full rounded-none border-0 shadow-none" : "h-[32rem] w-[22rem] max-w-[calc(100vw-2rem)]",
75
77
  mode === "dark" && "dark",
76
78
  className,
@@ -98,8 +100,9 @@ export function ChatPanel({
98
100
  </CardHeader>
99
101
  )}
100
102
 
101
- <CardContent>
102
- <ScrollArea ref={scrollRef} className="flex h-full flex-col gap-4 p-4" aria-live="polite">
103
+ <AgentContext />
104
+ <CardContent className="frankie-conversation-content">
105
+ <ScrollArea ref={scrollRef} className="frankie-transcript flex h-full flex-col gap-4 p-4" aria-live="polite">
103
106
  {configError ? (
104
107
  <div className="frankie-config-error" role="alert">
105
108
  <strong>Frankie couldn’t start</strong>
@@ -116,7 +119,7 @@ export function ChatPanel({
116
119
  </div>
117
120
  ))
118
121
  ) : (
119
- <div className="flex flex-col gap-4">
122
+ <div className="frankie-message-list flex flex-col gap-4">
120
123
  {messages.map((message) =>
121
124
  renderMessage ? (
122
125
  <div key={message.id}>{renderMessage(message)}</div>
@@ -136,8 +139,7 @@ export function ChatPanel({
136
139
  </ScrollArea>
137
140
  </CardContent>
138
141
 
139
- <CardFooter className="flex flex-col gap-2">
140
- {loadedSkills !== null ? <details className="frankie-loaded-skills"><summary role="status">{loadedSkills.length ? `${loadedSkills.length} ${loadedSkills.length === 1 ? "skill" : "skills"} loaded` : "No skills loaded for this turn"}</summary>{loadedSkills.length ? <ul>{loadedSkills.map(skill => <li key={skill.id}>{skill.name}<small>v{skill.revision}</small></li>)}</ul> : <p>Your assistant can still answer using its available tools.</p>}</details> : null}
142
+ <CardFooter className="frankie-composer-footer flex flex-col gap-2">
141
143
  {error ? <p className="w-full text-xs text-red-600 dark:text-red-400">{error}</p> : null}
142
144
  <div className="w-full">
143
145
  <Composer
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { ArrowUp, Paperclip, Square, X } from "lucide-react";
3
+ import { ArrowUp, Paperclip, Plus, Square, X } from "lucide-react";
4
4
  import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from "react";
5
5
  import type { ChatAttachment } from "@frankieai/core";
6
6
  import { Button } from "../ui/button.js";
@@ -107,7 +107,7 @@ export function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocu
107
107
  }, [busy, disabled, files, modelPicker?.value, onSend, value]);
108
108
 
109
109
  const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
110
- if (event.key === "Enter" && !event.shiftKey) {
110
+ if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
111
111
  event.preventDefault();
112
112
  submit();
113
113
  }
@@ -117,7 +117,7 @@ export function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocu
117
117
 
118
118
  return (
119
119
  <form
120
- className="flex flex-col gap-2"
120
+ className="frankie-composer flex flex-col gap-2"
121
121
  onSubmit={(event) => {
122
122
  event.preventDefault();
123
123
  submit();
@@ -147,7 +147,7 @@ export function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocu
147
147
 
148
148
  {fileError ? <p className="text-xs text-red-600 dark:text-red-400">{fileError}</p> : null}
149
149
 
150
- <div className="flex items-end gap-2">
150
+ <div className="frankie-composer-controls flex items-end gap-2">
151
151
  {canAttach ? (
152
152
  <>
153
153
  <input
@@ -170,7 +170,7 @@ export function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocu
170
170
  aria-label="Attach files"
171
171
  title="Attach files"
172
172
  >
173
- <Paperclip />
173
+ <Plus />
174
174
  </Button>
175
175
  </>
176
176
  ) : null}
@@ -236,3 +236,4 @@ function readAsBase64(file: File): Promise<string> {
236
236
  reader.readAsDataURL(file);
237
237
  });
238
238
  }
239
+
@@ -33,7 +33,7 @@ function PageEmptyState() {
33
33
 
34
34
  function PageChrome({ showHistory, navigation, currentId, onSelect, onNew, ...panel }: Pick<FrankieChatPageProps, "header" | "footer" | "emptyState" | "renderMessage" | "renderTool" | "placeholder" | "autoFocus" | "navigation"> & { showHistory: boolean; currentId: string; onSelect: (id: string) => void; onNew: () => void }) {
35
35
  const { client, config } = useFrankie();
36
- const { busy } = useFrankieChat();
36
+ const { busy, messages } = useFrankieChat();
37
37
  const theme = useFrankieTheme();
38
38
  const [conversations, setConversations] = useState<ChatConversation[]>([]);
39
39
  const [historyError, setHistoryError] = useState("");
@@ -43,7 +43,6 @@ function PageChrome({ showHistory, navigation, currentId, onSelect, onNew, ...pa
43
43
  const dialog = useRef<HTMLDialogElement>(null);
44
44
  const menu = useRef<HTMLButtonElement>(null);
45
45
  const requestVersion = useRef(0);
46
- const name = config?.branding.name ?? "Frankie";
47
46
  const refresh = useCallback(async () => {
48
47
  if (!showHistory || !config || config.allowAnonymous) { setHistoryError(""); return; }
49
48
  const version = ++requestVersion.current;
@@ -71,7 +70,7 @@ function PageChrome({ showHistory, navigation, currentId, onSelect, onNew, ...pa
71
70
  return <div className={"frankie-page-shell" + (resolveMode(theme?.mode) === "dark" ? " dark" : "")} style={themeToCssVars(theme)}>
72
71
  {showHistory ? <aside className="frankie-history-desktop">{history}</aside> : null}
73
72
  <div className="frankie-page-main">
74
- <header className="frankie-page-header"><div>{showHistory ? <button type="button" className="frankie-mobile-only" ref={menu} aria-label="Open conversations" aria-haspopup="dialog" aria-expanded={drawerOpen} onClick={() => setDrawerOpen(true)}><Menu size={20} /></button> : null}<strong>{name}</strong><span className="frankie-header-caption">Product assistant</span></div><div>
73
+ <header className="frankie-page-header"><div>{showHistory ? <button type="button" className="frankie-mobile-only" ref={menu} aria-label="Open conversations" aria-haspopup="dialog" aria-expanded={drawerOpen} onClick={() => setDrawerOpen(true)}><Menu size={20} /></button> : null}<strong className="frankie-thread-title">{conversations.find(item => item.id === currentId)?.title ?? messages.find(message => message.role === "user")?.text ?? "New chat"}</strong></div><div>
75
74
  <button type="button" disabled={busy} onClick={onNew} aria-label="Start a new conversation"><Plus size={16} /><span>New chat</span></button>
76
75
  <details className="frankie-chat-settings"><summary aria-label="Chat settings"><Settings size={19} /></summary><div><strong>Chat settings</strong><p>History syncs automatically for signed-in users.</p><button type="button" onClick={() => void refresh()} disabled={historyLoading || !showHistory}><History size={16} />Refresh history</button><small>Skills are checked at the start of each message.</small></div></details>
77
76
  </div></header>
@@ -26,7 +26,7 @@ export function MessageBubble({
26
26
  }: MessageBubbleProps) {
27
27
  if (message.role === "user") {
28
28
  return (
29
- <div className="flex flex-col items-end gap-1">
29
+ <div className="frankie-user-message flex flex-col items-end gap-1">
30
30
  {message.attachments?.length ? (
31
31
  <div className="flex max-w-[85%] flex-wrap justify-end gap-1">
32
32
  {message.attachments.map((file, index) => (
@@ -42,7 +42,7 @@ export function MessageBubble({
42
42
  ) : null}
43
43
  {message.text ? (
44
44
  <div className="flex max-w-[85%] items-end gap-1">
45
- <div className="rounded-[var(--frankie-radius)] bg-primary px-3 py-2 text-sm text-primary-foreground">{message.text}</div>
45
+ <div className="frankie-user-bubble rounded-[var(--frankie-radius)] bg-primary px-3 py-2 text-sm text-primary-foreground">{message.text}</div>
46
46
  <MessageCopyButton text={message.text} />
47
47
  </div>
48
48
  ) : null}
@@ -53,12 +53,12 @@ export function MessageBubble({
53
53
  const empty = message.text.length === 0 && message.tools.length === 0;
54
54
 
55
55
  return (
56
- <div className="flex gap-2">
56
+ <div className="frankie-assistant-message flex gap-2">
57
57
  <div className="frankie-assistant-actions mt-0.5 flex shrink-0 flex-col items-center gap-1">
58
58
  <Avatar {...(avatarUrl ? { src: avatarUrl } : {})} name={assistantName} />
59
59
  {message.text ? <MessageCopyButton text={message.text} /> : null}
60
60
  </div>
61
- <div className="min-w-0 max-w-[85%]">
61
+ <div className="frankie-assistant-content min-w-0 max-w-[85%]">
62
62
  {message.status === "error" ? (
63
63
  <p className="rounded-[var(--frankie-radius)] bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-400">
64
64
  {message.error}
@@ -90,7 +90,8 @@ function MessageCopyButton({ text }: { text: string }) {
90
90
 
91
91
  export function TypingDots() {
92
92
  return (
93
- <span className="inline-flex items-center gap-1 py-2" role="status" aria-label="Thinking">
93
+ <span className="frankie-thinking inline-flex items-center gap-1 py-2" role="status" aria-label="Thinking">
94
+ <span className="frankie-thinking-label">Thinking</span>
94
95
  {[0, 1, 2].map((index) => (
95
96
  <span
96
97
  key={index}
@@ -101,3 +102,4 @@ export function TypingDots() {
101
102
  </span>
102
103
  );
103
104
  }
105
+
package/src/lib/client.ts CHANGED
@@ -12,7 +12,7 @@ export type PageContext = Record<string, string | number | boolean | null>;
12
12
  * The hosted Frankie data plane. Embedding needs only a publishable key, so
13
13
  * `baseUrl` defaults here — override it only when self-hosting the server.
14
14
  */
15
- export const FRANKIE_CLOUD_URL = "https://frankie-data-gq5o5syq2q-uc.a.run.app";
15
+ export const FRANKIE_CLOUD_URL = "https://frankie-data-665333762719.us-central1.run.app";
16
16
 
17
17
  export interface FrankieClientOptions {
18
18
  /** Where the Frankie server is mounted. Defaults to the hosted cloud. */
@@ -346,3 +346,64 @@
346
346
  .frankie-page-canvas .frankie-root > .border-t { padding: .65rem .75rem calc(.65rem + env(safe-area-inset-bottom)); }
347
347
  }
348
348
  }
349
+
350
+ /* Shared conversation surface: SDK pages, embedded panels and Frankie's app. */
351
+ .frankie-root.frankie-chat-panel { min-height:0; position:relative; }
352
+ .frankie-root .frankie-agent-context-wrap { flex:none; display:flex; justify-content:center; padding:12px 10px; }
353
+ .frankie-root .frankie-agent-context { position:relative; max-width:100%; color:var(--frankie-foreground); font-size:13px; }
354
+ .frankie-root .frankie-agent-context > summary { display:flex; align-items:center; justify-content:center; gap:10px; min-height:44px; padding:7px 13px; border:1px solid var(--frankie-border); border-radius:999px; background:var(--frankie-background); box-shadow:0 1px 3px #0000000d; cursor:pointer; list-style:none; white-space:nowrap; }
355
+ .frankie-root .frankie-agent-context > summary::-webkit-details-marker { display:none; }
356
+ .frankie-root .frankie-agent-context > summary > svg { flex:none; color:var(--frankie-muted-foreground); }
357
+ .frankie-root .frankie-context-count { padding:2px 10px; border-radius:999px; background:var(--frankie-muted); font-size:12px; color:var(--frankie-muted-foreground); }
358
+ .frankie-root .frankie-context-status { display:flex; align-items:center; gap:6px; font-size:12px; color:var(--frankie-muted-foreground); }
359
+ .frankie-root .frankie-context-status i { width:7px; height:7px; border-radius:50%; background:currentColor; }
360
+ .frankie-root .frankie-context-status[data-active="true"] { color:#00976b; }
361
+ .frankie-root .frankie-context-detail { position:absolute; z-index:10; top:calc(100% + 10px); left:50%; transform:translateX(-50%); width:340px; max-width:calc(100vw - 32px); padding:18px; border:1px solid var(--frankie-border); border-radius:16px; background:var(--frankie-background); box-shadow:0 12px 32px #00000016; }
362
+ .frankie-root .frankie-context-detail p { margin:10px 0 0; font-size:12px; line-height:1.6; color:var(--frankie-muted-foreground); }
363
+ .frankie-root .frankie-context-detail ul { list-style:none; margin:12px 0; padding:0; max-height:220px; overflow:auto; }
364
+ .frankie-root .frankie-context-detail li { display:flex; justify-content:space-between; gap:12px; padding:8px 0; border-bottom:1px solid var(--frankie-border); overflow-wrap:anywhere; }
365
+ .frankie-root .frankie-context-detail small { flex:none; color:var(--frankie-muted-foreground); }
366
+ .frankie-root .frankie-sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
367
+ .frankie-root.frankie-chat-panel-fill { --frankie-canvas:color-mix(in srgb,var(--frankie-background) 60%,var(--frankie-muted)); background:var(--frankie-canvas); }
368
+ .frankie-root.frankie-chat-panel-fill .frankie-agent-context-wrap { padding:44px 16px 26px; }
369
+ .frankie-root.frankie-chat-panel-fill .frankie-transcript { padding:14px max(24px,calc((100% - 960px)/2)) 24px; }
370
+ .frankie-root.frankie-chat-panel-fill .frankie-message-list { gap:38px; }
371
+ .frankie-root.frankie-chat-panel-fill .frankie-user-bubble { border-radius:24px; padding:12px 20px; background:var(--frankie-muted); color:var(--frankie-foreground); font-size:15px; white-space:pre-wrap; overflow-wrap:anywhere; }
372
+ .frankie-root.frankie-chat-panel-fill .frankie-assistant-content { max-width:100%; flex:1; font-size:15px; line-height:1.7; }
373
+ .frankie-root.frankie-chat-panel-fill .frankie-assistant-message { gap:14px; }
374
+ .frankie-root .frankie-thinking { color:var(--frankie-muted-foreground); gap:5px; }
375
+ .frankie-root .frankie-thinking-label { margin-right:6px; font-size:14px; }
376
+ .frankie-root.frankie-chat-panel-fill .frankie-composer-footer { border:0; padding:16px max(24px,calc((100% - 1000px)/2)) 24px; background:transparent; flex:none; }
377
+ .frankie-root.frankie-chat-panel-fill .frankie-composer { padding:14px; border:1px solid var(--frankie-border); border-radius:28px; background:var(--frankie-background); box-shadow:0 2px 4px #0000000a; }
378
+ .frankie-root.frankie-chat-panel-fill .frankie-composer:focus-within { border-color:color-mix(in srgb,var(--frankie-foreground) 32%,var(--frankie-border)); box-shadow:0 0 0 2px color-mix(in srgb,var(--frankie-primary) 8%,transparent); }
379
+ .frankie-root.frankie-chat-panel-fill .frankie-composer-controls { display:flex; flex-wrap:wrap; align-items:center; gap:8px; }
380
+ .frankie-root.frankie-chat-panel-fill .frankie-composer textarea { order:-1; flex:0 0 100%; width:100%; min-height:32px; max-height:140px; margin-bottom:4px; padding:2px 6px; border:0; border-radius:0; resize:none; background:transparent; color:var(--frankie-foreground); font-size:16px; box-shadow:none; outline:none; }
381
+ .frankie-root.frankie-chat-panel-fill .frankie-composer button[aria-label="Send"],.frankie-root.frankie-chat-panel-fill .frankie-composer button[aria-label="Stop"] { margin-left:auto; width:42px; height:42px; border-radius:50%; background:var(--frankie-primary); color:var(--frankie-primary-foreground); border:0; }
382
+ .frankie-root.frankie-chat-panel-fill .frankie-composer button[aria-label="Attach files"] { width:42px; height:42px; border-radius:50%; }
383
+ .frankie-root .frankie-history-desktop { width:290px; background:var(--frankie-background); padding:20px 12px; }
384
+ .frankie-root .frankie-new-chat { justify-content:flex-start; border:0; background:transparent; padding:8px 12px; font-size:15px; }
385
+ .frankie-root .frankie-history-heading { font-size:12px; font-weight:400; color:var(--frankie-muted-foreground); padding:0 12px; }
386
+ .frankie-root .frankie-history-list button { min-height:44px; padding:10px 12px; }
387
+ .frankie-root .frankie-history-list button span { font-size:14px; }
388
+ .frankie-root .frankie-history-list button[aria-current],.frankie-root .frankie-history-list button:hover { border-color:transparent; background:var(--frankie-muted); }
389
+ .frankie-root .frankie-history-list small { display:none; }
390
+ .frankie-root .frankie-page-header { min-height:56px; height:56px; background:var(--frankie-background); }
391
+ .frankie-root .frankie-page-header > div { min-width:0; }
392
+ .frankie-root .frankie-thread-title { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:55vw; font-weight:500; }
393
+ .frankie-root .frankie-page-header button { border-color:transparent; background:transparent; }
394
+ .frankie-root .frankie-page-canvas > .frankie-inline { max-width:none; }
395
+ @media(max-width:760px) {
396
+ .frankie-root.frankie-chat-panel-fill .frankie-agent-context-wrap { padding:20px 8px 14px; }
397
+ .frankie-root.frankie-chat-panel-fill .frankie-transcript { padding:12px 16px 16px; }
398
+ .frankie-root.frankie-chat-panel-fill .frankie-composer-footer { padding:10px 12px calc(12px + env(safe-area-inset-bottom)); }
399
+ .frankie-root .frankie-agent-context > summary { gap:6px; padding:7px 9px; font-size:12px; }
400
+ .frankie-root .frankie-context-count { padding:2px 7px; font-size:11px; }
401
+ .frankie-root .frankie-context-status { font-size:11px; gap:4px; }
402
+ .frankie-root .frankie-agent-context > summary > svg { width:14px; }
403
+ .frankie-root .frankie-thread-title { max-width:42vw; }
404
+ }
405
+ .frankie-root .frankie-agent-context > summary > svg:first-child { box-sizing:content-box; padding:4px; border-radius:50%; background:var(--frankie-muted); }
406
+ .frankie-root.frankie-chat-panel-fill .frankie-agent-context > summary { font-size:14px; }
407
+
408
+ .frankie-root .frankie-agent-context > summary > span:first-of-type { border-right:1px solid var(--frankie-border); padding-right:10px; }
409
+ @media(max-width:760px) { .frankie-root .frankie-page-header button > span { display:none; } }
@@ -226,6 +226,35 @@ describe("ChatPanel", () => {
226
226
  expect(screen.queryByText("1 skill loaded")).toBeNull();
227
227
  cleanup();
228
228
  });
229
+ it("refreshes the context count and revisions from each server turn", async () => {
230
+ cleanup();
231
+ let turn = 0;
232
+ const fetchImpl = vi.fn(async (url: string | URL | Request) => {
233
+ if (String(url).endsWith("/v1/session")) return Response.json({ sessionId: "context", sessionToken: "fixture", expiresAt: new Date(Date.now() + 600_000).toISOString(), user: { id: "one", anonymous: true } });
234
+ turn++;
235
+ const skills = turn === 1 ? [{ id: "one", name: "Reservations", revision: 2 }, { id: "two", name: "Locations", revision: 1 }, { id: "three", name: "Service", revision: 3 }] : [];
236
+ return new Response(sseStream([{ type: "skills_loaded", skills }, { type: "text_delta", text: `Response ${turn}` }, { type: "turn_end", stopReason: "end_turn" }]));
237
+ });
238
+ render(<FrankieProvider publishableKey="pk_context_fixture" config={config} fetchImpl={fetchImpl} persist={false}><ChatPanel fill /></FrankieProvider>);
239
+ expect(screen.getByText("Not checked")).toBeTruthy();
240
+ await waitFor(() => expect((screen.getByLabelText("Message") as HTMLTextAreaElement).disabled).toBe(false));
241
+ fireEvent.change(screen.getByLabelText("Message"), { target: { value: "Review service" } });
242
+ fireEvent.click(screen.getByRole("button", { name: "Send" }));
243
+ await screen.findByText("Response 1");
244
+ expect(screen.getByText("3 skills")).toBeTruthy();
245
+ expect(screen.getByText("Active")).toBeTruthy();
246
+ fireEvent.click(screen.getByLabelText("Agent context"));
247
+ expect(screen.getByText("Reservations")).toBeTruthy();
248
+ expect(screen.getByText("v3")).toBeTruthy();
249
+ await waitFor(() => expect(screen.getByRole("button", { name: "Send" })).toBeTruthy());
250
+ fireEvent.change(screen.getByLabelText("Message"), { target: { value: "Try again" } });
251
+ fireEvent.click(screen.getByRole("button", { name: "Send" }));
252
+ await screen.findByText("Response 2");
253
+ expect(screen.getByText("0 skills")).toBeTruthy();
254
+ expect(screen.queryByText("Active")).toBeNull();
255
+ expect(screen.queryByText("Reservations")).toBeNull();
256
+ cleanup();
257
+ });
229
258
  it("shows the greeting and suggested prompts before the first message", async () => {
230
259
  render(
231
260
  <FrankieProvider baseUrl="https://agent.acme.com" publishableKey="pk_1" config={config}>