@frankieai/react 0.1.4 → 0.1.5

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.
@@ -2,14 +2,11 @@
2
2
 
3
3
  import type { CSSProperties } from "react";
4
4
  import type { FrankieProviderProps } from "../../hooks/FrankieProvider.jsx";
5
- import { FrankieProvider } from "../../hooks/FrankieProvider.jsx";
5
+ import { FrankieProvider, useFrankie } from "../../hooks/FrankieProvider.jsx";
6
+ import { useFrankieChat } from "../../hooks/useFrankieChat.js";
6
7
  import { FrankieInline } from "./FrankieInline.js";
7
8
  import type { ChatPanelProps } from "./ChatPanel.js";
8
9
 
9
- /**
10
- * A complete chat route in one component. This is the simplest way to add a
11
- * dedicated /chat page to an app while keeping the existing floating widget.
12
- */
13
10
  export interface FrankieChatPageProps
14
11
  extends Omit<FrankieProviderProps, "children">,
15
12
  Pick<
@@ -20,6 +17,77 @@ export interface FrankieChatPageProps
20
17
  style?: CSSProperties;
21
18
  }
22
19
 
20
+ function PageEmptyState() {
21
+ const { config } = useFrankie();
22
+ const { send, busy } = useFrankieChat();
23
+ const name = config?.branding.name ?? "Frankie";
24
+ const prompts = config?.suggestedPrompts?.slice(0, 3) ?? [];
25
+
26
+ return (
27
+ <section className="frankie-page-empty" aria-label="Start a conversation">
28
+ <span className="frankie-page-avatar" aria-hidden>{name.trim().slice(0, 1).toUpperCase()}</span>
29
+ <div>
30
+ <p className="frankie-page-kicker">{name} assistant</p>
31
+ <h1>What would you like to do?</h1>
32
+ <p>{config?.greeting ?? "Ask a question or choose a task to get started."}</p>
33
+ </div>
34
+ {prompts.length ? (
35
+ <div className="frankie-page-prompts">
36
+ {prompts.map((prompt, index) => (
37
+ <button key={prompt} type="button" disabled={busy} onClick={() => send(prompt)}>
38
+ <span>{String(index + 1).padStart(2, "0")}</span>
39
+ <strong>{prompt}</strong>
40
+ <b aria-hidden>↗</b>
41
+ </button>
42
+ ))}
43
+ </div>
44
+ ) : null}
45
+ </section>
46
+ );
47
+ }
48
+
49
+ function PageChrome({
50
+ header,
51
+ footer,
52
+ emptyState,
53
+ renderMessage,
54
+ renderTool,
55
+ placeholder,
56
+ autoFocus,
57
+ }: Pick<FrankieChatPageProps, "header" | "footer" | "emptyState" | "renderMessage" | "renderTool" | "placeholder" | "autoFocus">) {
58
+ const { config } = useFrankie();
59
+ const { reset } = useFrankieChat();
60
+ const name = config?.branding.name ?? "Frankie";
61
+
62
+ return (
63
+ <>
64
+ <header className="frankie-page-header">
65
+ <div>
66
+ <span className="frankie-page-status" aria-hidden />
67
+ <strong>{name}</strong>
68
+ </div>
69
+ <button type="button" onClick={reset} aria-label="Start a new conversation">New chat</button>
70
+ </header>
71
+ <div className="frankie-page-canvas">
72
+ <FrankieInline
73
+ header={header ?? false}
74
+ footer={footer}
75
+ emptyState={emptyState ?? <PageEmptyState />}
76
+ renderMessage={renderMessage}
77
+ renderTool={renderTool}
78
+ placeholder={placeholder}
79
+ autoFocus={autoFocus}
80
+ />
81
+ </div>
82
+ </>
83
+ );
84
+ }
85
+
86
+ /**
87
+ * A complete, polished chat route matching Frankie's own conversation canvas.
88
+ * The shell, header, empty state and composer are all scoped under
89
+ * `.frankie-root`, so host application styles remain untouched.
90
+ */
23
91
  export function FrankieChatPage({
24
92
  className,
25
93
  style,
@@ -34,11 +102,11 @@ export function FrankieChatPage({
34
102
  }: FrankieChatPageProps) {
35
103
  return (
36
104
  <main
37
- className={className}
105
+ className={["frankie-root", "frankie-chat-page", className].filter(Boolean).join(" ")}
38
106
  style={{ height: "100dvh", minHeight: "100dvh", width: "100%", ...style }}
39
107
  >
40
108
  <FrankieProvider {...providerProps}>
41
- <FrankieInline
109
+ <PageChrome
42
110
  header={header}
43
111
  footer={footer}
44
112
  emptyState={emptyState}
@@ -42,6 +42,9 @@ export function useFrankieChat(): UseFrankieChatResult {
42
42
  const [restored, setRestored] = useState(false);
43
43
  const abortRef = useRef<AbortController | null>(null);
44
44
  const mounted = useRef(true);
45
+ // State updates can be batched while a stream is arriving. Keep the latest
46
+ // transcript synchronously so the final server sync cannot save a stale copy.
47
+ const messagesRef = useRef<ChatMessage[]>([]);
45
48
 
46
49
  useEffect(() => {
47
50
  mounted.current = true;
@@ -63,14 +66,21 @@ export function useFrankieChat(): UseFrankieChatResult {
63
66
  try {
64
67
  const raw = window.localStorage.getItem(storageKey);
65
68
  const stored = raw ? (JSON.parse(raw) as ChatMessage[]) : [];
66
- if (Array.isArray(stored) && stored.length && active) setMessages(stored);
69
+ if (Array.isArray(stored) && stored.length && active) {
70
+ messagesRef.current = stored;
71
+ setMessages(stored);
72
+ }
67
73
  } catch {
68
74
  /* storage unavailable; server history can still restore */
69
75
  }
70
76
  }
71
77
  try {
72
78
  const remote = await client.resumeConversation();
73
- if (active && remote?.messages.length) setMessages(remote.messages as ChatMessage[]);
79
+ if (active && remote?.messages.length) {
80
+ const restoredMessages = remote.messages as ChatMessage[];
81
+ messagesRef.current = restoredMessages;
82
+ setMessages(restoredMessages);
83
+ }
74
84
  } catch {
75
85
  /* A new conversation does not exist remotely until its first message. */
76
86
  } finally {
@@ -96,11 +106,11 @@ export function useFrankieChat(): UseFrankieChatResult {
96
106
  }, [messages, busy, client, storageKey, restored]);
97
107
 
98
108
  const patchAssistant = useCallback((id: string, update: (m: AssistantChatMessage) => AssistantChatMessage) => {
99
- setMessages((current) =>
100
- current.map((message) =>
101
- message.id === id && message.role === "assistant" ? update(message) : message,
102
- ),
109
+ const next = messagesRef.current.map((message) =>
110
+ message.id === id && message.role === "assistant" ? update(message) : message,
103
111
  );
112
+ messagesRef.current = next;
113
+ setMessages(next);
104
114
  }, []);
105
115
 
106
116
  const send = useCallback(
@@ -114,15 +124,17 @@ export function useFrankieChat(): UseFrankieChatResult {
114
124
  const assistantId = nextId("a");
115
125
  setError(undefined);
116
126
  setBusy(true);
117
- setMessages((current) => [
118
- ...current,
127
+ const next = [
128
+ ...messagesRef.current,
119
129
  newUserMessage(
120
130
  nextId("u"),
121
131
  trimmed,
122
132
  attachments.map((file) => ({ name: file.name, mimeType: file.mimeType })),
123
133
  ),
124
134
  newAssistantMessage(assistantId),
125
- ]);
135
+ ];
136
+ messagesRef.current = next;
137
+ setMessages(next);
126
138
 
127
139
  const abort = new AbortController();
128
140
  abortRef.current = abort;
@@ -175,13 +187,18 @@ export function useFrankieChat(): UseFrankieChatResult {
175
187
  patchAssistant(assistantId, (current) => ({ ...current, status: "error", error: message }));
176
188
  }
177
189
  } finally {
178
- if (mounted.current) {
179
- setBusy(false);
180
- // A turn that ended without a `turn_end` event still has to settle.
181
- patchAssistant(assistantId, (message) =>
182
- message.status === "streaming" ? { ...message, status: "done" } : message,
183
- );
190
+ // A turn that ended without a `turn_end` event still has to settle.
191
+ patchAssistant(assistantId, (message) =>
192
+ message.status === "streaming" ? { ...message, status: "done" } : message,
193
+ );
194
+ // Persist before send() resolves. This closes the previous gap where a
195
+ // route change or tab close could leave a server conversation with no
196
+ // rendered messages even though the agent transcript existed.
197
+ const transcript = messagesRef.current;
198
+ if (transcript.length) {
199
+ await client.syncConversation(transcript, conversationTitle(transcript)).catch(() => undefined);
184
200
  }
201
+ if (mounted.current) setBusy(false);
185
202
  abortRef.current = null;
186
203
  }
187
204
  },
@@ -193,6 +210,7 @@ export function useFrankieChat(): UseFrankieChatResult {
193
210
  const reset = useCallback(() => {
194
211
  abortRef.current?.abort();
195
212
  client.resetSession();
213
+ messagesRef.current = [];
196
214
  setMessages([]);
197
215
  setError(undefined);
198
216
  setBusy(false);
@@ -164,3 +164,164 @@
164
164
  padding: 0;
165
165
  }
166
166
  }
167
+
168
+
169
+ @layer components {
170
+ .frankie-chat-page {
171
+ display: flex;
172
+ min-width: 0;
173
+ flex-direction: column;
174
+ overflow: hidden;
175
+ background:
176
+ radial-gradient(60rem 24rem at 50% -12rem, color-mix(in srgb, var(--frankie-primary) 8%, transparent), transparent 65%),
177
+ var(--frankie-background);
178
+ color: var(--frankie-foreground);
179
+ }
180
+
181
+ .frankie-page-header {
182
+ display: flex;
183
+ min-height: 56px;
184
+ flex: 0 0 56px;
185
+ align-items: center;
186
+ justify-content: space-between;
187
+ gap: 16px;
188
+ padding: 0 24px;
189
+ border-bottom: 1px solid var(--frankie-border);
190
+ background: color-mix(in srgb, var(--frankie-background) 92%, transparent);
191
+ backdrop-filter: blur(18px);
192
+ }
193
+
194
+ .frankie-page-header > div {
195
+ display: flex;
196
+ align-items: center;
197
+ gap: 10px;
198
+ }
199
+
200
+ .frankie-page-header strong { font-size: 0.925rem; font-weight: 650; }
201
+
202
+ .frankie-page-status {
203
+ width: 10px;
204
+ height: 10px;
205
+ border-radius: 999px;
206
+ background: var(--frankie-primary);
207
+ box-shadow: 0 0 0 4px color-mix(in srgb, var(--frankie-primary) 16%, transparent);
208
+ }
209
+
210
+ .frankie-page-header button {
211
+ min-height: 36px;
212
+ padding: 0 13px;
213
+ border: 1px solid var(--frankie-border);
214
+ border-radius: calc(var(--frankie-radius) * .9);
215
+ background: var(--frankie-secondary);
216
+ cursor: pointer;
217
+ }
218
+
219
+ .frankie-page-header button:hover { border-color: var(--frankie-primary); }
220
+
221
+ .frankie-page-canvas {
222
+ display: flex;
223
+ min-width: 0;
224
+ min-height: 0;
225
+ flex: 1;
226
+ }
227
+
228
+ .frankie-page-canvas > .frankie-inline {
229
+ width: 100%;
230
+ max-width: 56rem;
231
+ margin: 0 auto;
232
+ }
233
+
234
+ .frankie-page-canvas .frankie-scroll { padding: 2rem clamp(1rem, 4vw, 4rem) 1.25rem; }
235
+ .frankie-page-canvas .frankie-root > .border-t {
236
+ padding: .75rem clamp(1rem, 4vw, 4rem) 1.5rem;
237
+ border-top: 0;
238
+ background: linear-gradient(180deg, transparent, var(--frankie-background) 30%);
239
+ }
240
+
241
+ .frankie-page-empty {
242
+ display: flex;
243
+ width: min(100%, 38rem);
244
+ min-height: 100%;
245
+ margin: auto;
246
+ padding: clamp(2rem, 7vh, 4.5rem) 1rem 2rem;
247
+ flex-direction: column;
248
+ align-items: center;
249
+ justify-content: center;
250
+ gap: 1rem;
251
+ text-align: center;
252
+ }
253
+
254
+ .frankie-page-avatar {
255
+ display: grid;
256
+ width: 48px;
257
+ height: 48px;
258
+ place-items: center;
259
+ border-radius: calc(var(--frankie-radius) * 1.4);
260
+ background: var(--frankie-primary);
261
+ color: var(--frankie-primary-foreground);
262
+ font-weight: 750;
263
+ }
264
+
265
+ .frankie-page-kicker {
266
+ margin: 0 0 .4rem;
267
+ color: var(--frankie-primary);
268
+ font-size: .7rem;
269
+ font-weight: 750;
270
+ letter-spacing: .1em;
271
+ text-transform: uppercase;
272
+ }
273
+
274
+ .frankie-page-empty h1 {
275
+ margin: 0;
276
+ font-size: clamp(1.65rem, 4vw, 2.15rem);
277
+ line-height: 1.08;
278
+ letter-spacing: -.045em;
279
+ }
280
+
281
+ .frankie-page-empty h1 + p {
282
+ margin: .6rem auto 0;
283
+ max-width: 30rem;
284
+ color: var(--frankie-muted-foreground);
285
+ }
286
+
287
+ .frankie-page-prompts {
288
+ display: grid;
289
+ width: 100%;
290
+ gap: .55rem;
291
+ margin-top: .4rem;
292
+ }
293
+
294
+ .frankie-page-prompts button {
295
+ display: grid;
296
+ min-height: 60px;
297
+ grid-template-columns: 2rem minmax(0, 1fr) auto;
298
+ align-items: center;
299
+ gap: .65rem;
300
+ padding: .75rem .9rem;
301
+ border: 1px solid var(--frankie-border);
302
+ border-radius: calc(var(--frankie-radius) * 1.25);
303
+ background: var(--frankie-secondary);
304
+ text-align: left;
305
+ cursor: pointer;
306
+ }
307
+
308
+ .frankie-page-prompts button:hover {
309
+ border-color: color-mix(in srgb, var(--frankie-primary) 58%, var(--frankie-border));
310
+ transform: translateY(-1px);
311
+ }
312
+
313
+ .frankie-page-prompts button > span {
314
+ color: var(--frankie-muted-foreground);
315
+ font-size: .68rem;
316
+ font-weight: 700;
317
+ }
318
+
319
+ .frankie-page-prompts button > strong { font-size: .875rem; }
320
+ .frankie-page-prompts button > b { color: var(--frankie-primary); font-size: 1rem; }
321
+
322
+ @media (max-width: 640px) {
323
+ .frankie-page-header { min-height: 52px; flex-basis: 52px; padding: 0 12px; }
324
+ .frankie-page-canvas .frankie-scroll { padding: 1.25rem .75rem .5rem; }
325
+ .frankie-page-canvas .frankie-root > .border-t { padding: .65rem .75rem calc(.65rem + env(safe-area-inset-bottom)); }
326
+ }
327
+ }