@surf-kit/agent 0.2.2 → 0.3.0

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.
Files changed (51) hide show
  1. package/dist/chat/index.cjs +625 -204
  2. package/dist/chat/index.cjs.map +1 -1
  3. package/dist/chat/index.d.cts +11 -6
  4. package/dist/chat/index.d.ts +11 -6
  5. package/dist/chat/index.js +606 -185
  6. package/dist/chat/index.js.map +1 -1
  7. package/dist/{chat--OifhIRe.d.ts → chat-BIIDOGrD.d.ts} +10 -1
  8. package/dist/{chat-ChYl2XjV.d.cts → chat-CGamM7Mz.d.cts} +10 -1
  9. package/dist/{hooks-DLfF18IU.d.cts → hooks-B1NYoLLs.d.cts} +21 -5
  10. package/dist/{hooks-BGs8-4GK.d.ts → hooks-CTeEqnBQ.d.ts} +21 -5
  11. package/dist/hooks.cjs +126 -81
  12. package/dist/hooks.cjs.map +1 -1
  13. package/dist/hooks.d.cts +3 -3
  14. package/dist/hooks.d.ts +3 -3
  15. package/dist/hooks.js +126 -81
  16. package/dist/hooks.js.map +1 -1
  17. package/dist/index.cjs +686 -265
  18. package/dist/index.cjs.map +1 -1
  19. package/dist/index.d.cts +3 -3
  20. package/dist/index.d.ts +3 -3
  21. package/dist/index.js +645 -224
  22. package/dist/index.js.map +1 -1
  23. package/dist/layouts/index.cjs +646 -225
  24. package/dist/layouts/index.cjs.map +1 -1
  25. package/dist/layouts/index.d.cts +1 -1
  26. package/dist/layouts/index.d.ts +1 -1
  27. package/dist/layouts/index.js +622 -201
  28. package/dist/layouts/index.js.map +1 -1
  29. package/dist/mcp/index.cjs +1 -1
  30. package/dist/mcp/index.cjs.map +1 -1
  31. package/dist/mcp/index.js +2 -2
  32. package/dist/mcp/index.js.map +1 -1
  33. package/dist/response/index.cjs +66 -12
  34. package/dist/response/index.cjs.map +1 -1
  35. package/dist/response/index.d.cts +2 -2
  36. package/dist/response/index.d.ts +2 -2
  37. package/dist/response/index.js +64 -10
  38. package/dist/response/index.js.map +1 -1
  39. package/dist/sources/index.cjs +30 -1
  40. package/dist/sources/index.cjs.map +1 -1
  41. package/dist/sources/index.js +30 -1
  42. package/dist/sources/index.js.map +1 -1
  43. package/dist/streaming/index.cjs +202 -93
  44. package/dist/streaming/index.cjs.map +1 -1
  45. package/dist/streaming/index.d.cts +4 -3
  46. package/dist/streaming/index.d.ts +4 -3
  47. package/dist/streaming/index.js +172 -73
  48. package/dist/streaming/index.js.map +1 -1
  49. package/dist/{streaming-DbQxScpi.d.ts → streaming-Bx-ff2tt.d.ts} +1 -1
  50. package/dist/{streaming-DfT22A0z.d.cts → streaming-x7umFHoP.d.cts} +1 -1
  51. package/package.json +15 -4
@@ -1,11 +1,11 @@
1
1
  'use client';
2
2
 
3
3
  // src/layouts/AgentFullPage/AgentFullPage.tsx
4
- import { twMerge as twMerge10 } from "tailwind-merge";
5
- import { useState as useState4, useCallback as useCallback3 } from "react";
4
+ import { twMerge as twMerge11 } from "tailwind-merge";
5
+ import { useState as useState4, useCallback as useCallback4 } from "react";
6
6
 
7
7
  // src/chat/AgentChat/AgentChat.tsx
8
- import { twMerge as twMerge8 } from "tailwind-merge";
8
+ import { twMerge as twMerge9 } from "tailwind-merge";
9
9
 
10
10
  // src/hooks/useAgentChat.ts
11
11
  import { useReducer, useCallback, useRef } from "react";
@@ -16,7 +16,8 @@ var initialState = {
16
16
  error: null,
17
17
  inputValue: "",
18
18
  streamPhase: "idle",
19
- streamingContent: ""
19
+ streamingContent: "",
20
+ streamingAgent: null
20
21
  };
21
22
  function reducer(state, action) {
22
23
  switch (action.type) {
@@ -30,12 +31,15 @@ function reducer(state, action) {
30
31
  error: null,
31
32
  inputValue: "",
32
33
  streamPhase: "thinking",
33
- streamingContent: ""
34
+ streamingContent: "",
35
+ streamingAgent: null
34
36
  };
35
37
  case "STREAM_PHASE":
36
38
  return { ...state, streamPhase: action.phase };
37
39
  case "STREAM_CONTENT":
38
40
  return { ...state, streamingContent: state.streamingContent + action.content };
41
+ case "STREAM_AGENT":
42
+ return { ...state, streamingAgent: action.agent };
39
43
  case "SEND_SUCCESS":
40
44
  return {
41
45
  ...state,
@@ -51,7 +55,8 @@ function reducer(state, action) {
51
55
  isLoading: false,
52
56
  error: action.error,
53
57
  streamPhase: "idle",
54
- streamingContent: ""
58
+ streamingContent: "",
59
+ streamingAgent: null
55
60
  };
56
61
  case "LOAD_CONVERSATION":
57
62
  return {
@@ -77,107 +82,142 @@ function useAgentChat(config) {
77
82
  const configRef = useRef(config);
78
83
  configRef.current = config;
79
84
  const lastUserMessageRef = useRef(null);
85
+ const lastUserAttachmentsRef = useRef(void 0);
80
86
  const sendMessage = useCallback(
81
- async (content) => {
82
- const { apiUrl, streamPath = "/chat/stream", headers = {}, timeout = 3e4 } = configRef.current;
87
+ async (content, attachments) => {
88
+ const { apiUrl, streamPath = "/chat/stream", headers: headersOrFn, timeout = 3e4, bodyExtra } = configRef.current;
89
+ const headers = typeof headersOrFn === "function" ? await headersOrFn() : headersOrFn ?? {};
83
90
  lastUserMessageRef.current = content;
91
+ lastUserAttachmentsRef.current = attachments;
84
92
  const userMessage = {
85
93
  id: generateMessageId(),
86
94
  role: "user",
87
95
  content,
96
+ attachments,
88
97
  timestamp: /* @__PURE__ */ new Date()
89
98
  };
90
99
  dispatch({ type: "SEND_START", message: userMessage });
91
100
  const controller = new AbortController();
92
101
  const timeoutId = setTimeout(() => controller.abort(), timeout);
93
102
  try {
94
- const response = await fetch(`${apiUrl}${streamPath}`, {
95
- method: "POST",
96
- headers: {
97
- "Content-Type": "application/json",
98
- Accept: "text/event-stream",
99
- ...headers
100
- },
101
- body: JSON.stringify({
102
- message: content,
103
- conversation_id: state.conversationId
104
- }),
105
- signal: controller.signal
106
- });
107
- clearTimeout(timeoutId);
108
- if (!response.ok) {
109
- dispatch({
110
- type: "SEND_ERROR",
111
- error: {
112
- code: "API_ERROR",
113
- message: `HTTP ${response.status}: ${response.statusText}`,
114
- retryable: response.status >= 500
115
- }
116
- });
117
- return;
103
+ const url = `${apiUrl}${streamPath}`;
104
+ const mergedHeaders = {
105
+ "Content-Type": "application/json",
106
+ Accept: "text/event-stream",
107
+ ...headers
108
+ };
109
+ const requestBody = {
110
+ message: content,
111
+ conversation_id: state.conversationId,
112
+ ...bodyExtra
113
+ };
114
+ if (attachments && attachments.length > 0) {
115
+ requestBody.attachments = attachments.map((a) => ({
116
+ filename: a.filename,
117
+ content_type: a.content_type,
118
+ data: a.data
119
+ }));
118
120
  }
119
- const reader = response.body?.getReader();
120
- if (!reader) {
121
- dispatch({
122
- type: "SEND_ERROR",
123
- error: { code: "STREAM_ERROR", message: "No response body", retryable: true }
121
+ const body = JSON.stringify(requestBody);
122
+ const ctx = {
123
+ accumulatedContent: "",
124
+ agentResponse: null,
125
+ capturedAgent: null,
126
+ capturedConversationId: null,
127
+ hadStreamError: false
128
+ };
129
+ const handleEvent = (event) => {
130
+ switch (event.type) {
131
+ case "agent":
132
+ ctx.capturedAgent = event.agent;
133
+ dispatch({ type: "STREAM_AGENT", agent: ctx.capturedAgent });
134
+ break;
135
+ case "phase":
136
+ dispatch({ type: "STREAM_PHASE", phase: event.phase });
137
+ break;
138
+ case "delta":
139
+ ctx.accumulatedContent += event.content;
140
+ dispatch({ type: "STREAM_CONTENT", content: event.content });
141
+ break;
142
+ case "done":
143
+ ctx.agentResponse = event.response;
144
+ ctx.capturedConversationId = event.conversation_id ?? null;
145
+ break;
146
+ case "error":
147
+ ctx.hadStreamError = true;
148
+ dispatch({ type: "SEND_ERROR", error: event.error });
149
+ break;
150
+ }
151
+ };
152
+ const { streamAdapter } = configRef.current;
153
+ if (streamAdapter) {
154
+ await streamAdapter(
155
+ url,
156
+ { method: "POST", headers: mergedHeaders, body, signal: controller.signal },
157
+ handleEvent
158
+ );
159
+ clearTimeout(timeoutId);
160
+ } else {
161
+ const response = await fetch(url, {
162
+ method: "POST",
163
+ headers: mergedHeaders,
164
+ body,
165
+ signal: controller.signal
124
166
  });
125
- return;
126
- }
127
- const decoder = new TextDecoder();
128
- let buffer = "";
129
- let accumulatedContent = "";
130
- let agentResponse = null;
131
- let capturedAgent = null;
132
- let capturedConversationId = null;
133
- while (true) {
134
- const { done, value } = await reader.read();
135
- if (done) break;
136
- buffer += decoder.decode(value, { stream: true });
137
- const lines = buffer.split("\n");
138
- buffer = lines.pop() ?? "";
139
- for (const line of lines) {
140
- if (!line.startsWith("data: ")) continue;
141
- const data = line.slice(6).trim();
142
- if (data === "[DONE]") continue;
143
- try {
144
- const event = JSON.parse(data);
145
- switch (event.type) {
146
- case "agent":
147
- capturedAgent = event.agent;
148
- break;
149
- case "phase":
150
- dispatch({ type: "STREAM_PHASE", phase: event.phase });
151
- break;
152
- case "delta":
153
- accumulatedContent += event.content;
154
- dispatch({ type: "STREAM_CONTENT", content: event.content });
155
- break;
156
- case "done":
157
- agentResponse = event.response;
158
- capturedConversationId = event.conversation_id ?? null;
159
- break;
160
- case "error":
161
- dispatch({ type: "SEND_ERROR", error: event.error });
162
- return;
167
+ clearTimeout(timeoutId);
168
+ if (!response.ok) {
169
+ dispatch({
170
+ type: "SEND_ERROR",
171
+ error: {
172
+ code: "API_ERROR",
173
+ message: `HTTP ${response.status}: ${response.statusText}`,
174
+ retryable: response.status >= 500
175
+ }
176
+ });
177
+ return;
178
+ }
179
+ const reader = response.body?.getReader();
180
+ if (!reader) {
181
+ dispatch({
182
+ type: "SEND_ERROR",
183
+ error: { code: "STREAM_ERROR", message: "No response body", retryable: true }
184
+ });
185
+ return;
186
+ }
187
+ const decoder = new TextDecoder();
188
+ let buffer = "";
189
+ while (true) {
190
+ const { done, value } = await reader.read();
191
+ if (done) break;
192
+ buffer += decoder.decode(value, { stream: true });
193
+ const lines = buffer.split("\n");
194
+ buffer = lines.pop() ?? "";
195
+ for (const line of lines) {
196
+ if (!line.startsWith("data: ")) continue;
197
+ const data = line.slice(6).trim();
198
+ if (data === "[DONE]") continue;
199
+ try {
200
+ const event = JSON.parse(data);
201
+ handleEvent(event);
202
+ } catch {
163
203
  }
164
- } catch {
165
204
  }
166
205
  }
167
206
  }
207
+ if (ctx.hadStreamError) return;
168
208
  const assistantMessage = {
169
209
  id: generateMessageId(),
170
210
  role: "assistant",
171
- content: agentResponse?.message ?? accumulatedContent,
172
- response: agentResponse ?? void 0,
173
- agent: capturedAgent ?? void 0,
211
+ content: ctx.agentResponse?.message ?? ctx.accumulatedContent,
212
+ response: ctx.agentResponse ?? void 0,
213
+ agent: ctx.capturedAgent ?? void 0,
174
214
  timestamp: /* @__PURE__ */ new Date()
175
215
  };
176
216
  dispatch({
177
217
  type: "SEND_SUCCESS",
178
218
  message: assistantMessage,
179
- streamingContent: accumulatedContent,
180
- conversationId: capturedConversationId
219
+ streamingContent: ctx.accumulatedContent,
220
+ conversationId: ctx.capturedConversationId
181
221
  });
182
222
  } catch (err) {
183
223
  clearTimeout(timeoutId);
@@ -208,7 +248,8 @@ function useAgentChat(config) {
208
248
  }, []);
209
249
  const submitFeedback = useCallback(
210
250
  async (messageId, rating, comment) => {
211
- const { apiUrl, feedbackPath = "/feedback", headers = {} } = configRef.current;
251
+ const { apiUrl, feedbackPath = "/feedback", headers: headersOrFn } = configRef.current;
252
+ const headers = typeof headersOrFn === "function" ? await headersOrFn() : headersOrFn ?? {};
212
253
  await fetch(`${apiUrl}${feedbackPath}`, {
213
254
  method: "POST",
214
255
  headers: { "Content-Type": "application/json", ...headers },
@@ -219,12 +260,13 @@ function useAgentChat(config) {
219
260
  );
220
261
  const retry = useCallback(async () => {
221
262
  if (lastUserMessageRef.current) {
222
- await sendMessage(lastUserMessageRef.current);
263
+ await sendMessage(lastUserMessageRef.current, lastUserAttachmentsRef.current);
223
264
  }
224
265
  }, [sendMessage]);
225
266
  const reset = useCallback(() => {
226
267
  dispatch({ type: "RESET" });
227
268
  lastUserMessageRef.current = null;
269
+ lastUserAttachmentsRef.current = void 0;
228
270
  }, []);
229
271
  const actions = {
230
272
  sendMessage,
@@ -239,7 +281,7 @@ function useAgentChat(config) {
239
281
 
240
282
  // src/chat/MessageThread/MessageThread.tsx
241
283
  import { twMerge as twMerge5 } from "tailwind-merge";
242
- import { useEffect, useRef as useRef2 } from "react";
284
+ import { useCallback as useCallback2, useEffect, useRef as useRef2 } from "react";
243
285
 
244
286
  // src/chat/MessageBubble/MessageBubble.tsx
245
287
  import { twMerge as twMerge4 } from "tailwind-merge";
@@ -248,6 +290,7 @@ import { twMerge as twMerge4 } from "tailwind-merge";
248
290
  import { Badge as Badge2 } from "@surf-kit/core";
249
291
 
250
292
  // src/response/ResponseMessage/ResponseMessage.tsx
293
+ import React from "react";
251
294
  import ReactMarkdown from "react-markdown";
252
295
  import rehypeSanitize from "rehype-sanitize";
253
296
  import { twMerge } from "tailwind-merge";
@@ -272,6 +315,7 @@ function ResponseMessage({ content, className }) {
272
315
  "[&_h3]:text-sm [&_h3]:font-semibold [&_h3]:text-accent [&_h3]:mt-2 [&_h3]:mb-1",
273
316
  "[&_code]:bg-surface-raised [&_code]:text-accent [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_code]:font-mono",
274
317
  "[&_pre]:bg-surface-raised [&_pre]:border [&_pre]:border-border [&_pre]:rounded-xl [&_pre]:p-4 [&_pre]:overflow-x-auto",
318
+ "[&_hr]:my-3 [&_hr]:border-border",
275
319
  "[&_blockquote]:border-l-2 [&_blockquote]:border-border-strong [&_blockquote]:pl-4 [&_blockquote]:text-text-secondary",
276
320
  "[&_a]:text-accent [&_a]:underline-offset-2 [&_a]:hover:text-accent/80",
277
321
  className
@@ -287,11 +331,24 @@ function ResponseMessage({ content, className }) {
287
331
  p: ({ children }) => /* @__PURE__ */ jsx("p", { className: "my-2", children }),
288
332
  ul: ({ children }) => /* @__PURE__ */ jsx("ul", { className: "my-2 list-disc pl-6", children }),
289
333
  ol: ({ children }) => /* @__PURE__ */ jsx("ol", { className: "my-2 list-decimal pl-6", children }),
290
- li: ({ children }) => /* @__PURE__ */ jsx("li", { className: "my-1", children }),
334
+ li: ({ children, ...props }) => {
335
+ let content2 = children;
336
+ if (props.ordered) {
337
+ content2 = React.Children.map(children, (child, i) => {
338
+ if (i === 0 && typeof child === "string") {
339
+ return child.replace(/^\d+[.)]\s*/, "");
340
+ }
341
+ return child;
342
+ });
343
+ }
344
+ return /* @__PURE__ */ jsx("li", { className: "my-1", children: content2 });
345
+ },
291
346
  strong: ({ children }) => /* @__PURE__ */ jsx("strong", { className: "font-semibold", children }),
347
+ em: ({ children }) => /* @__PURE__ */ jsx("em", { className: "italic text-text-secondary", children }),
292
348
  h1: ({ children }) => /* @__PURE__ */ jsx("h1", { className: "text-base font-bold mt-4 mb-2", children }),
293
349
  h2: ({ children }) => /* @__PURE__ */ jsx("h2", { className: "text-sm font-bold mt-3 mb-1", children }),
294
350
  h3: ({ children }) => /* @__PURE__ */ jsx("h3", { className: "text-sm font-semibold mt-2 mb-1", children }),
351
+ hr: () => /* @__PURE__ */ jsx("hr", { className: "my-3 border-border" }),
295
352
  code: ({ children }) => /* @__PURE__ */ jsx("code", { className: "bg-surface-sunken rounded px-1 py-0.5 text-xs font-mono", children })
296
353
  },
297
354
  children: normalizeMarkdownLists(content)
@@ -427,7 +484,14 @@ function renderWarning(data) {
427
484
  }
428
485
  );
429
486
  }
430
- function StructuredResponse({ uiHint, data, className }) {
487
+ function StructuredResponse({ uiHint, data: rawData, className }) {
488
+ const data = typeof rawData === "string" ? (() => {
489
+ try {
490
+ return JSON.parse(rawData);
491
+ } catch {
492
+ return null;
493
+ }
494
+ })() : rawData;
431
495
  if (!data) return null;
432
496
  let content;
433
497
  switch (uiHint) {
@@ -507,7 +571,36 @@ function SourceCard({ source, variant = "compact", onNavigate, className }) {
507
571
  children: [
508
572
  /* @__PURE__ */ jsxs2("div", { className: "flex items-start justify-between gap-2", children: [
509
573
  /* @__PURE__ */ jsxs2("div", { className: "flex-1 min-w-0", children: [
510
- /* @__PURE__ */ jsx3("p", { className: "text-sm font-medium text-text-primary truncate", children: source.title }),
574
+ source.url ? /* @__PURE__ */ jsxs2(
575
+ "a",
576
+ {
577
+ href: source.url,
578
+ target: "_blank",
579
+ rel: "noopener noreferrer",
580
+ className: "text-sm font-medium text-accent hover:underline truncate block",
581
+ onClick: (e) => e.stopPropagation(),
582
+ children: [
583
+ source.title,
584
+ /* @__PURE__ */ jsxs2(
585
+ "svg",
586
+ {
587
+ className: "inline-block ml-1 w-3 h-3 opacity-60",
588
+ viewBox: "0 0 24 24",
589
+ fill: "none",
590
+ stroke: "currentColor",
591
+ strokeWidth: "2",
592
+ strokeLinecap: "round",
593
+ strokeLinejoin: "round",
594
+ children: [
595
+ /* @__PURE__ */ jsx3("path", { d: "M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6" }),
596
+ /* @__PURE__ */ jsx3("polyline", { points: "15 3 21 3 21 9" }),
597
+ /* @__PURE__ */ jsx3("line", { x1: "10", y1: "14", x2: "21", y2: "3" })
598
+ ]
599
+ }
600
+ )
601
+ ]
602
+ }
603
+ ) : /* @__PURE__ */ jsx3("p", { className: "text-sm font-medium text-text-primary truncate", children: source.title }),
511
604
  source.section && /* @__PURE__ */ jsx3("p", { className: "text-[11px] font-semibold uppercase tracking-wider text-text-secondary truncate mt-0.5", children: source.section })
512
605
  ] }),
513
606
  /* @__PURE__ */ jsx3(
@@ -641,13 +734,16 @@ function AgentResponse({
641
734
  }) {
642
735
  return /* @__PURE__ */ jsxs4("div", { className: `flex flex-col gap-4 ${className ?? ""}`, "data-testid": "agent-response", children: [
643
736
  /* @__PURE__ */ jsx6(ResponseMessage, { content: response.message }),
644
- response.ui_hint !== "text" && response.structured_data && /* @__PURE__ */ jsx6(
645
- StructuredResponse,
646
- {
647
- uiHint: response.ui_hint,
648
- data: response.structured_data
649
- }
650
- ),
737
+ response.ui_hint !== "text" && response.structured_data && (() => {
738
+ const parsed = typeof response.structured_data === "string" ? (() => {
739
+ try {
740
+ return JSON.parse(response.structured_data);
741
+ } catch {
742
+ return null;
743
+ }
744
+ })() : response.structured_data;
745
+ return parsed ? /* @__PURE__ */ jsx6(StructuredResponse, { uiHint: response.ui_hint, data: parsed }) : null;
746
+ })(),
651
747
  (showConfidence || showVerification) && /* @__PURE__ */ jsxs4("div", { className: "flex flex-wrap items-center gap-2 mt-1", "data-testid": "response-meta", children: [
652
748
  showConfidence && /* @__PURE__ */ jsxs4(
653
749
  Badge2,
@@ -698,6 +794,31 @@ function AgentResponse({
698
794
 
699
795
  // src/chat/MessageBubble/MessageBubble.tsx
700
796
  import { jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
797
+ function DocumentIcon() {
798
+ return /* @__PURE__ */ jsxs5("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", children: [
799
+ /* @__PURE__ */ jsx7("path", { d: "M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" }),
800
+ /* @__PURE__ */ jsx7("polyline", { points: "14 2 14 8 20 8" }),
801
+ /* @__PURE__ */ jsx7("line", { x1: "16", y1: "13", x2: "8", y2: "13" }),
802
+ /* @__PURE__ */ jsx7("line", { x1: "16", y1: "17", x2: "8", y2: "17" })
803
+ ] });
804
+ }
805
+ function AttachmentThumbnail({ attachment }) {
806
+ const isImage = attachment.content_type.startsWith("image/");
807
+ if (isImage) {
808
+ return /* @__PURE__ */ jsx7("div", { className: "rounded-lg overflow-hidden border border-black/10 max-w-[240px]", children: /* @__PURE__ */ jsx7(
809
+ "img",
810
+ {
811
+ src: attachment.preview_url ?? `data:${attachment.content_type};base64,${attachment.data}`,
812
+ alt: attachment.filename,
813
+ className: "max-w-full max-h-[200px] object-contain"
814
+ }
815
+ ) });
816
+ }
817
+ return /* @__PURE__ */ jsxs5("div", { className: "flex items-center gap-2 px-3 py-2 rounded-lg border border-black/10 bg-black/5", children: [
818
+ /* @__PURE__ */ jsx7(DocumentIcon, {}),
819
+ /* @__PURE__ */ jsx7("span", { className: "text-xs truncate max-w-[160px]", children: attachment.filename })
820
+ ] });
821
+ }
701
822
  function MessageBubble({
702
823
  message,
703
824
  showAgent,
@@ -705,23 +826,29 @@ function MessageBubble({
705
826
  showConfidence = true,
706
827
  showVerification = true,
707
828
  animated = true,
829
+ userBubbleClassName,
708
830
  className
709
831
  }) {
710
832
  const isUser = message.role === "user";
833
+ const hasAttachments = message.attachments && message.attachments.length > 0;
711
834
  if (isUser) {
712
835
  return /* @__PURE__ */ jsx7(
713
836
  "div",
714
837
  {
715
838
  "data-message-id": message.id,
716
839
  className: twMerge4("flex w-full justify-end", className),
717
- children: /* @__PURE__ */ jsx7(
840
+ children: /* @__PURE__ */ jsxs5(
718
841
  "div",
719
842
  {
720
843
  className: twMerge4(
721
- "max-w-[70%] rounded-[18px] rounded-br-[4px] px-4 py-2.5 bg-accent text-brand-cream break-words whitespace-pre-wrap text-sm leading-relaxed",
722
- animated && "motion-safe:animate-slideFromRight"
844
+ "max-w-[70%] rounded-[18px] rounded-br-[4px] px-4 py-2.5 bg-[#e8e8e8] text-[#1a1a1a] break-words whitespace-pre-wrap text-sm leading-relaxed",
845
+ animated && "motion-safe:animate-slideFromRight",
846
+ userBubbleClassName
723
847
  ),
724
- children: message.content
848
+ children: [
849
+ hasAttachments && /* @__PURE__ */ jsx7("div", { className: "flex flex-wrap gap-2 mb-2", children: message.attachments.map((att, i) => /* @__PURE__ */ jsx7(AttachmentThumbnail, { attachment: att }, `${att.filename}-${i}`)) }),
850
+ message.content
851
+ ]
725
852
  }
726
853
  )
727
854
  }
@@ -733,7 +860,7 @@ function MessageBubble({
733
860
  "data-message-id": message.id,
734
861
  className: twMerge4("flex w-full flex-col items-start gap-1.5", className),
735
862
  children: [
736
- showAgent && message.agent && /* @__PURE__ */ jsx7("div", { className: "text-[11px] font-semibold uppercase tracking-[0.08em] text-text-muted px-1", children: message.agent.replace("_agent", "").replace("_", " ") }),
863
+ showAgent && message.agent && /* @__PURE__ */ jsx7("div", { className: "text-[11px] font-display font-semibold uppercase tracking-[0.08em] text-text-muted px-1", children: message.agent.replace("_agent", "").replace("_", " ") }),
737
864
  /* @__PURE__ */ jsx7(
738
865
  "div",
739
866
  {
@@ -759,34 +886,70 @@ function MessageBubble({
759
886
 
760
887
  // src/chat/MessageThread/MessageThread.tsx
761
888
  import { jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
762
- function MessageThread({ messages, streamingSlot, showSources, showConfidence, showVerification, className }) {
763
- const bottomRef = useRef2(null);
889
+ function MessageThread({ messages, streamingSlot, showAgent, showSources, showConfidence, showVerification, hideLastAssistant, userBubbleClassName, className }) {
890
+ const scrollRef = useRef2(null);
891
+ const isNearBottom = useRef2(true);
892
+ const isProgrammaticScroll = useRef2(false);
893
+ const hasStreaming = !!streamingSlot;
894
+ const scrollToBottom = useCallback2(() => {
895
+ const el = scrollRef.current;
896
+ if (el && isNearBottom.current) {
897
+ isProgrammaticScroll.current = true;
898
+ el.scrollTop = el.scrollHeight;
899
+ }
900
+ }, []);
901
+ const handleScroll = useCallback2(() => {
902
+ if (isProgrammaticScroll.current) {
903
+ isProgrammaticScroll.current = false;
904
+ return;
905
+ }
906
+ const el = scrollRef.current;
907
+ if (!el) return;
908
+ isNearBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
909
+ }, []);
910
+ useEffect(scrollToBottom, [messages.length, scrollToBottom]);
764
911
  useEffect(() => {
765
- bottomRef.current?.scrollIntoView?.({ behavior: "smooth" });
766
- }, [messages.length, streamingSlot]);
912
+ if (!hasStreaming) return;
913
+ let raf;
914
+ const tick = () => {
915
+ scrollToBottom();
916
+ raf = requestAnimationFrame(tick);
917
+ };
918
+ raf = requestAnimationFrame(tick);
919
+ return () => cancelAnimationFrame(raf);
920
+ }, [hasStreaming, scrollToBottom]);
767
921
  return /* @__PURE__ */ jsxs6(
768
922
  "div",
769
923
  {
924
+ ref: scrollRef,
770
925
  role: "log",
771
926
  "aria-live": "polite",
772
927
  "aria-label": "Message thread",
928
+ onScroll: handleScroll,
773
929
  className: twMerge5(
774
930
  "flex flex-col gap-4 overflow-y-auto flex-1 px-4 py-6",
775
931
  className
776
932
  ),
777
933
  children: [
778
- messages.map((message) => /* @__PURE__ */ jsx8(
779
- MessageBubble,
780
- {
781
- message,
782
- showSources,
783
- showConfidence,
784
- showVerification
785
- },
786
- message.id
787
- )),
788
- streamingSlot,
789
- /* @__PURE__ */ jsx8("div", { ref: bottomRef })
934
+ /* @__PURE__ */ jsx8("div", { className: "flex-1 shrink-0" }),
935
+ messages.map((message, i) => {
936
+ if (hideLastAssistant && i === messages.length - 1 && message.role === "assistant") {
937
+ return null;
938
+ }
939
+ return /* @__PURE__ */ jsx8(
940
+ MessageBubble,
941
+ {
942
+ message,
943
+ showAgent,
944
+ showSources,
945
+ showConfidence,
946
+ showVerification,
947
+ userBubbleClassName
948
+ },
949
+ message.id
950
+ );
951
+ }),
952
+ streamingSlot
790
953
  ]
791
954
  }
792
955
  );
@@ -794,8 +957,96 @@ function MessageThread({ messages, streamingSlot, showSources, showConfidence, s
794
957
 
795
958
  // src/chat/MessageComposer/MessageComposer.tsx
796
959
  import { twMerge as twMerge6 } from "tailwind-merge";
797
- import { useState as useState2, useRef as useRef3, useCallback as useCallback2 } from "react";
960
+ import { useState as useState2, useRef as useRef3, useCallback as useCallback3 } from "react";
798
961
  import { jsx as jsx9, jsxs as jsxs7 } from "react/jsx-runtime";
962
+ var ALLOWED_TYPES = /* @__PURE__ */ new Set([
963
+ "image/png",
964
+ "image/jpeg",
965
+ "image/gif",
966
+ "image/webp",
967
+ "application/pdf"
968
+ ]);
969
+ var MAX_FILE_SIZE = 10 * 1024 * 1024;
970
+ var MAX_ATTACHMENTS = 5;
971
+ function ArrowUpIcon() {
972
+ return /* @__PURE__ */ jsxs7("svg", { width: "20", height: "20", viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round", children: [
973
+ /* @__PURE__ */ jsx9("path", { d: "M10 16V4" }),
974
+ /* @__PURE__ */ jsx9("path", { d: "M4 10l6-6 6 6" })
975
+ ] });
976
+ }
977
+ function StopIcon() {
978
+ return /* @__PURE__ */ jsx9("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: /* @__PURE__ */ jsx9("rect", { x: "3", y: "3", width: "10", height: "10", rx: "2" }) });
979
+ }
980
+ function PaperclipIcon() {
981
+ return /* @__PURE__ */ jsx9("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: /* @__PURE__ */ jsx9("path", { d: "M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48" }) });
982
+ }
983
+ function XIcon({ size = 14 }) {
984
+ return /* @__PURE__ */ jsxs7("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round", children: [
985
+ /* @__PURE__ */ jsx9("path", { d: "M18 6L6 18" }),
986
+ /* @__PURE__ */ jsx9("path", { d: "M6 6l12 12" })
987
+ ] });
988
+ }
989
+ function DocumentIcon2() {
990
+ return /* @__PURE__ */ jsxs7("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", children: [
991
+ /* @__PURE__ */ jsx9("path", { d: "M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" }),
992
+ /* @__PURE__ */ jsx9("polyline", { points: "14 2 14 8 20 8" }),
993
+ /* @__PURE__ */ jsx9("line", { x1: "16", y1: "13", x2: "8", y2: "13" }),
994
+ /* @__PURE__ */ jsx9("line", { x1: "16", y1: "17", x2: "8", y2: "17" }),
995
+ /* @__PURE__ */ jsx9("polyline", { points: "10 9 9 9 8 9" })
996
+ ] });
997
+ }
998
+ function fileToBase64(file) {
999
+ return new Promise((resolve, reject) => {
1000
+ const reader = new FileReader();
1001
+ reader.onload = () => {
1002
+ const result = reader.result;
1003
+ const base64 = result.split(",")[1];
1004
+ resolve(base64);
1005
+ };
1006
+ reader.onerror = reject;
1007
+ reader.readAsDataURL(file);
1008
+ });
1009
+ }
1010
+ function AttachmentPreview({
1011
+ attachment,
1012
+ onRemove
1013
+ }) {
1014
+ const isImage = attachment.content_type.startsWith("image/");
1015
+ return /* @__PURE__ */ jsxs7("div", { className: "relative group flex-shrink-0", children: [
1016
+ isImage ? /* @__PURE__ */ jsx9("div", { className: "w-16 h-16 rounded-lg overflow-hidden border border-border/60 bg-surface-alt", children: /* @__PURE__ */ jsx9(
1017
+ "img",
1018
+ {
1019
+ src: attachment.preview_url ?? `data:${attachment.content_type};base64,${attachment.data}`,
1020
+ alt: attachment.filename,
1021
+ className: "w-full h-full object-cover"
1022
+ }
1023
+ ) }) : /* @__PURE__ */ jsxs7("div", { className: "h-16 px-3 rounded-lg border border-border/60 bg-surface-alt flex items-center gap-2", children: [
1024
+ /* @__PURE__ */ jsx9("div", { className: "text-text-muted", children: /* @__PURE__ */ jsx9(DocumentIcon2, {}) }),
1025
+ /* @__PURE__ */ jsxs7("div", { className: "flex flex-col min-w-0", children: [
1026
+ /* @__PURE__ */ jsx9("span", { className: "text-xs text-text-primary truncate max-w-[120px]", children: attachment.filename }),
1027
+ /* @__PURE__ */ jsx9("span", { className: "text-[10px] text-text-muted", children: "PDF" })
1028
+ ] })
1029
+ ] }),
1030
+ /* @__PURE__ */ jsx9(
1031
+ "button",
1032
+ {
1033
+ type: "button",
1034
+ onClick: onRemove,
1035
+ className: twMerge6(
1036
+ "absolute -top-1.5 -right-1.5",
1037
+ "w-5 h-5 rounded-full",
1038
+ "bg-text-muted/80 text-white",
1039
+ "flex items-center justify-center",
1040
+ "opacity-0 group-hover:opacity-100",
1041
+ "transition-opacity duration-150",
1042
+ "hover:bg-text-primary"
1043
+ ),
1044
+ "aria-label": `Remove ${attachment.filename}`,
1045
+ children: /* @__PURE__ */ jsx9(XIcon, { size: 10 })
1046
+ }
1047
+ )
1048
+ ] });
1049
+ }
799
1050
  function MessageComposer({
800
1051
  onSend,
801
1052
  isLoading = false,
@@ -803,23 +1054,29 @@ function MessageComposer({
803
1054
  className
804
1055
  }) {
805
1056
  const [value, setValue] = useState2("");
1057
+ const [attachments, setAttachments] = useState2([]);
1058
+ const [dragOver, setDragOver] = useState2(false);
806
1059
  const textareaRef = useRef3(null);
807
- const canSend = value.trim().length > 0 && !isLoading;
808
- const resetHeight = useCallback2(() => {
1060
+ const fileInputRef = useRef3(null);
1061
+ const canSend = (value.trim().length > 0 || attachments.length > 0) && !isLoading;
1062
+ const resetHeight = useCallback3(() => {
809
1063
  const el = textareaRef.current;
810
1064
  if (el) {
811
1065
  el.style.height = "auto";
812
1066
  el.style.overflowY = "hidden";
813
1067
  }
814
1068
  }, []);
815
- const handleSend = useCallback2(() => {
1069
+ const handleSend = useCallback3(() => {
816
1070
  if (!canSend) return;
817
- onSend(value.trim());
1071
+ const message = value.trim() || (attachments.length > 0 ? "Please analyse the attached file(s)." : "");
1072
+ if (!message && attachments.length === 0) return;
1073
+ onSend(message, attachments.length > 0 ? attachments : void 0);
818
1074
  setValue("");
1075
+ setAttachments([]);
819
1076
  resetHeight();
820
1077
  textareaRef.current?.focus();
821
- }, [canSend, onSend, value, resetHeight]);
822
- const handleKeyDown = useCallback2(
1078
+ }, [canSend, onSend, value, attachments, resetHeight]);
1079
+ const handleKeyDown = useCallback3(
823
1080
  (e) => {
824
1081
  if (e.key === "Enter" && !e.shiftKey) {
825
1082
  e.preventDefault();
@@ -828,64 +1085,194 @@ function MessageComposer({
828
1085
  },
829
1086
  [handleSend]
830
1087
  );
831
- const handleChange = useCallback2(
1088
+ const handleChange = useCallback3(
832
1089
  (e) => {
833
1090
  setValue(e.target.value);
834
1091
  const el = e.target;
835
1092
  el.style.height = "auto";
836
- const capped = Math.min(el.scrollHeight, 128);
1093
+ const capped = Math.min(el.scrollHeight, 200);
837
1094
  el.style.height = `${capped}px`;
838
- el.style.overflowY = el.scrollHeight > 128 ? "auto" : "hidden";
1095
+ el.style.overflowY = el.scrollHeight > 200 ? "auto" : "hidden";
839
1096
  },
840
1097
  []
841
1098
  );
1099
+ const addFiles = useCallback3(async (files) => {
1100
+ const fileArray = Array.from(files);
1101
+ for (const file of fileArray) {
1102
+ if (attachments.length >= MAX_ATTACHMENTS) break;
1103
+ if (!ALLOWED_TYPES.has(file.type)) continue;
1104
+ if (file.size > MAX_FILE_SIZE) continue;
1105
+ try {
1106
+ const data = await fileToBase64(file);
1107
+ const previewUrl = file.type.startsWith("image/") ? URL.createObjectURL(file) : void 0;
1108
+ const attachment = {
1109
+ filename: file.name,
1110
+ content_type: file.type,
1111
+ data,
1112
+ preview_url: previewUrl
1113
+ };
1114
+ setAttachments((prev) => {
1115
+ if (prev.length >= MAX_ATTACHMENTS) return prev;
1116
+ return [...prev, attachment];
1117
+ });
1118
+ } catch {
1119
+ }
1120
+ }
1121
+ }, [attachments.length]);
1122
+ const handleFileSelect = useCallback3(() => {
1123
+ fileInputRef.current?.click();
1124
+ }, []);
1125
+ const handleFileInputChange = useCallback3(
1126
+ (e) => {
1127
+ if (e.target.files) {
1128
+ void addFiles(e.target.files);
1129
+ e.target.value = "";
1130
+ }
1131
+ },
1132
+ [addFiles]
1133
+ );
1134
+ const removeAttachment = useCallback3((index) => {
1135
+ setAttachments((prev) => {
1136
+ const removed = prev[index];
1137
+ if (removed?.preview_url) URL.revokeObjectURL(removed.preview_url);
1138
+ return prev.filter((_, i) => i !== index);
1139
+ });
1140
+ }, []);
1141
+ const handlePaste = useCallback3(
1142
+ (e) => {
1143
+ const items = e.clipboardData.items;
1144
+ const files = [];
1145
+ for (const item of items) {
1146
+ if (item.kind === "file" && ALLOWED_TYPES.has(item.type)) {
1147
+ const file = item.getAsFile();
1148
+ if (file) files.push(file);
1149
+ }
1150
+ }
1151
+ if (files.length > 0) {
1152
+ void addFiles(files);
1153
+ }
1154
+ },
1155
+ [addFiles]
1156
+ );
1157
+ const handleDragOver = useCallback3((e) => {
1158
+ e.preventDefault();
1159
+ e.stopPropagation();
1160
+ setDragOver(true);
1161
+ }, []);
1162
+ const handleDragLeave = useCallback3((e) => {
1163
+ e.preventDefault();
1164
+ e.stopPropagation();
1165
+ setDragOver(false);
1166
+ }, []);
1167
+ const handleDrop = useCallback3(
1168
+ (e) => {
1169
+ e.preventDefault();
1170
+ e.stopPropagation();
1171
+ setDragOver(false);
1172
+ if (e.dataTransfer.files.length > 0) {
1173
+ void addFiles(e.dataTransfer.files);
1174
+ }
1175
+ },
1176
+ [addFiles]
1177
+ );
842
1178
  return /* @__PURE__ */ jsxs7(
843
1179
  "div",
844
1180
  {
845
1181
  className: twMerge6(
846
- "flex items-end gap-3 shrink-0 border-t border-border px-4 py-3",
1182
+ "relative shrink-0 rounded-3xl border bg-surface",
1183
+ "shadow-lg shadow-black/10",
1184
+ "transition-all duration-200",
1185
+ "focus-within:border-accent/40 focus-within:shadow-accent/5",
1186
+ dragOver ? "border-accent/60 bg-accent/5" : "border-border/60",
847
1187
  className
848
1188
  ),
1189
+ onDragOver: handleDragOver,
1190
+ onDragLeave: handleDragLeave,
1191
+ onDrop: handleDrop,
849
1192
  children: [
850
1193
  /* @__PURE__ */ jsx9(
851
- "textarea",
1194
+ "input",
852
1195
  {
853
- ref: textareaRef,
854
- value,
855
- onChange: handleChange,
856
- onKeyDown: handleKeyDown,
857
- placeholder,
858
- rows: 1,
859
- disabled: isLoading,
860
- className: twMerge6(
861
- "flex-1 resize-none rounded-xl border border-border bg-surface/80",
862
- "px-4 py-2.5 text-sm text-text-primary placeholder:text-text-muted",
863
- "focus:border-transparent focus:ring-2 focus:ring-accent/40 focus:outline-none",
864
- "disabled:opacity-50 disabled:cursor-not-allowed",
865
- "overflow-hidden",
866
- "transition-all duration-200"
867
- ),
868
- style: { colorScheme: "dark" },
869
- "aria-label": "Message input"
1196
+ ref: fileInputRef,
1197
+ type: "file",
1198
+ multiple: true,
1199
+ accept: "image/png,image/jpeg,image/gif,image/webp,application/pdf",
1200
+ onChange: handleFileInputChange,
1201
+ className: "hidden",
1202
+ "aria-hidden": "true"
870
1203
  }
871
1204
  ),
872
- /* @__PURE__ */ jsx9(
873
- "button",
1205
+ attachments.length > 0 && /* @__PURE__ */ jsx9("div", { className: "flex gap-2 px-4 pt-3 pb-1 overflow-x-auto", children: attachments.map((att, i) => /* @__PURE__ */ jsx9(
1206
+ AttachmentPreview,
874
1207
  {
875
- type: "button",
876
- onClick: handleSend,
877
- disabled: !value.trim() || isLoading,
878
- "aria-label": "Send message",
879
- className: twMerge6(
880
- "inline-flex items-center justify-center rounded-xl px-5 py-2.5",
881
- "text-sm font-semibold text-white shrink-0",
882
- "transition-all duration-200",
883
- "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent",
884
- value.trim() && !isLoading ? "bg-accent hover:bg-accent-hover hover:scale-[1.02] hover:shadow-glow-cyan active:scale-[0.98]" : "bg-accent/30 text-text-muted cursor-not-allowed"
885
- ),
886
- children: "Send"
887
- }
888
- )
1208
+ attachment: att,
1209
+ onRemove: () => removeAttachment(i)
1210
+ },
1211
+ `${att.filename}-${i}`
1212
+ )) }),
1213
+ dragOver && /* @__PURE__ */ jsx9("div", { className: "absolute inset-0 rounded-3xl flex items-center justify-center bg-accent/10 border-2 border-dashed border-accent/40 z-10 pointer-events-none", children: /* @__PURE__ */ jsx9("span", { className: "text-sm font-display font-semibold text-accent", children: "Drop files here" }) }),
1214
+ /* @__PURE__ */ jsxs7("div", { className: "flex items-end", children: [
1215
+ /* @__PURE__ */ jsx9(
1216
+ "button",
1217
+ {
1218
+ type: "button",
1219
+ onClick: handleFileSelect,
1220
+ disabled: isLoading || attachments.length >= MAX_ATTACHMENTS,
1221
+ "aria-label": "Attach file",
1222
+ className: twMerge6(
1223
+ "flex-shrink-0 ml-2 mb-3",
1224
+ "inline-flex items-center justify-center",
1225
+ "w-9 h-9 rounded-full",
1226
+ "transition-all duration-200",
1227
+ "text-text-muted/60 hover:text-text-secondary hover:bg-text-muted/10",
1228
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent",
1229
+ "disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent"
1230
+ ),
1231
+ children: /* @__PURE__ */ jsx9(PaperclipIcon, {})
1232
+ }
1233
+ ),
1234
+ /* @__PURE__ */ jsx9(
1235
+ "textarea",
1236
+ {
1237
+ ref: textareaRef,
1238
+ value,
1239
+ onChange: handleChange,
1240
+ onKeyDown: handleKeyDown,
1241
+ onPaste: handlePaste,
1242
+ placeholder,
1243
+ rows: 1,
1244
+ disabled: isLoading,
1245
+ className: twMerge6(
1246
+ "flex-1 resize-none bg-transparent",
1247
+ "pl-2 pr-14 pt-4 pb-4 text-[15px] leading-relaxed",
1248
+ "text-text-primary placeholder:text-text-muted/70",
1249
+ "focus:outline-none",
1250
+ "disabled:opacity-50 disabled:cursor-not-allowed",
1251
+ "overflow-hidden"
1252
+ ),
1253
+ style: { colorScheme: "dark" },
1254
+ "aria-label": "Message input"
1255
+ }
1256
+ ),
1257
+ /* @__PURE__ */ jsx9(
1258
+ "button",
1259
+ {
1260
+ type: "button",
1261
+ onClick: handleSend,
1262
+ disabled: !canSend,
1263
+ "aria-label": "Send message",
1264
+ className: twMerge6(
1265
+ "absolute bottom-3 right-3",
1266
+ "inline-flex items-center justify-center",
1267
+ "w-9 h-9 rounded-full",
1268
+ "transition-all duration-200",
1269
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent",
1270
+ canSend ? "bg-accent text-white hover:bg-accent-hover active:scale-90 shadow-md shadow-accent/25" : isLoading ? "bg-text-muted/20 text-text-secondary hover:bg-text-muted/30" : "bg-transparent text-text-muted/40 cursor-default"
1271
+ ),
1272
+ children: isLoading ? /* @__PURE__ */ jsx9(StopIcon, {}) : /* @__PURE__ */ jsx9(ArrowUpIcon, {})
1273
+ }
1274
+ )
1275
+ ] })
889
1276
  ]
890
1277
  }
891
1278
  );
@@ -898,6 +1285,7 @@ function WelcomeScreen({
898
1285
  title = "Welcome",
899
1286
  message = "How can I help you today?",
900
1287
  icon,
1288
+ iconClassName,
901
1289
  suggestedQuestions = [],
902
1290
  onQuestionSelect,
903
1291
  className
@@ -910,12 +1298,15 @@ function WelcomeScreen({
910
1298
  className
911
1299
  ),
912
1300
  children: [
913
- /* @__PURE__ */ jsx10(
1301
+ icon ? iconClassName ? /* @__PURE__ */ jsx10("div", { className: iconClassName, "aria-hidden": "true", children: icon }) : icon : /* @__PURE__ */ jsx10(
914
1302
  "div",
915
1303
  {
916
- className: "w-14 h-14 rounded-2xl bg-accent/10 border border-border flex items-center justify-center pulse-glow",
1304
+ className: twMerge7(
1305
+ "w-14 h-14 rounded-2xl bg-accent/10 border border-border flex items-center justify-center pulse-glow",
1306
+ iconClassName
1307
+ ),
917
1308
  "aria-hidden": "true",
918
- children: icon ?? /* @__PURE__ */ jsx10("span", { className: "text-2xl", children: "\u2726" })
1309
+ children: /* @__PURE__ */ jsx10("span", { className: "text-2xl", children: "\u2726" })
919
1310
  }
920
1311
  ),
921
1312
  /* @__PURE__ */ jsxs8("div", { className: "flex flex-col gap-2", children: [
@@ -925,7 +1316,7 @@ function WelcomeScreen({
925
1316
  suggestedQuestions.length > 0 && /* @__PURE__ */ jsx10(
926
1317
  "div",
927
1318
  {
928
- className: "flex flex-wrap justify-center gap-2 max-w-md",
1319
+ className: "flex flex-wrap justify-center gap-2 max-w-xl",
929
1320
  role: "group",
930
1321
  "aria-label": "Suggested questions",
931
1322
  children: suggestedQuestions.map((question) => /* @__PURE__ */ jsx10(
@@ -934,7 +1325,7 @@ function WelcomeScreen({
934
1325
  type: "button",
935
1326
  onClick: () => onQuestionSelect?.(question),
936
1327
  className: twMerge7(
937
- "px-4 py-2 rounded-full text-sm",
1328
+ "px-3.5 py-1.5 rounded-full text-[12px]",
938
1329
  "border border-border bg-transparent text-text-secondary",
939
1330
  "hover:bg-accent/10 hover:border-interactive hover:text-text-primary",
940
1331
  "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent",
@@ -953,7 +1344,8 @@ function WelcomeScreen({
953
1344
 
954
1345
  // src/streaming/StreamingMessage/StreamingMessage.tsx
955
1346
  import { useEffect as useEffect3, useRef as useRef5 } from "react";
956
- import { Spinner } from "@surf-kit/core";
1347
+ import { twMerge as twMerge8 } from "tailwind-merge";
1348
+ import { WaveLoader } from "@surf-kit/core";
957
1349
 
958
1350
  // src/hooks/useCharacterDrain.ts
959
1351
  import { useState as useState3, useRef as useRef4, useEffect as useEffect2 } from "react";
@@ -982,7 +1374,10 @@ function useCharacterDrain(target, msPerChar = 15) {
982
1374
  const elapsed = now - lastTimeRef.current;
983
1375
  const charsToAdvance = Math.floor(elapsed / msPerCharRef.current);
984
1376
  if (charsToAdvance > 0 && indexRef.current < currentTarget.length) {
985
- const nextIndex = Math.min(indexRef.current + charsToAdvance, currentTarget.length);
1377
+ let nextIndex = Math.min(indexRef.current + charsToAdvance, currentTarget.length);
1378
+ while (nextIndex < currentTarget.length && currentTarget[nextIndex - 1].trim() === "") {
1379
+ nextIndex++;
1380
+ }
986
1381
  indexRef.current = nextIndex;
987
1382
  lastTimeRef.current = now;
988
1383
  setDisplayed(currentTarget.slice(0, nextIndex));
@@ -1027,14 +1422,35 @@ var phaseLabels = {
1027
1422
  generating: "Writing...",
1028
1423
  verifying: "Verifying..."
1029
1424
  };
1425
+ var CURSOR_STYLES = `
1426
+ .sk-streaming-cursor > :not(ul,ol,blockquote):last-child::after,
1427
+ .sk-streaming-cursor > :is(ul,ol):last-child > li:last-child::after,
1428
+ .sk-streaming-cursor > blockquote:last-child > p:last-child::after {
1429
+ content: "";
1430
+ display: inline-block;
1431
+ width: 2px;
1432
+ height: 1em;
1433
+ background: var(--color-accent, #38bdf8);
1434
+ animation: sk-cursor-blink 0.8s steps(1) infinite;
1435
+ margin-left: 2px;
1436
+ vertical-align: text-bottom;
1437
+ }
1438
+ @keyframes sk-cursor-blink {
1439
+ 0%, 60% { opacity: 1; }
1440
+ 61%, 100% { opacity: 0; }
1441
+ }
1442
+ `;
1030
1443
  function StreamingMessage({
1031
1444
  stream,
1032
1445
  onComplete,
1446
+ onDraining,
1033
1447
  showPhases = true,
1034
1448
  className
1035
1449
  }) {
1036
1450
  const onCompleteRef = useRef5(onComplete);
1037
1451
  onCompleteRef.current = onComplete;
1452
+ const onDrainingRef = useRef5(onDraining);
1453
+ onDrainingRef.current = onDraining;
1038
1454
  const wasActiveRef = useRef5(stream.active);
1039
1455
  useEffect3(() => {
1040
1456
  if (wasActiveRef.current && !stream.active) {
@@ -1043,35 +1459,40 @@ function StreamingMessage({
1043
1459
  wasActiveRef.current = stream.active;
1044
1460
  }, [stream.active]);
1045
1461
  const phaseLabel = phaseLabels[stream.phase];
1046
- const { displayed: displayedContent } = useCharacterDrain(stream.content);
1047
- return /* @__PURE__ */ jsxs9("div", { className, "data-testid": "streaming-message", children: [
1462
+ const { displayed: rawDisplayed, isDraining } = useCharacterDrain(stream.content);
1463
+ const displayedContent = stream.active || isDraining ? rawDisplayed.trimEnd() : rawDisplayed;
1464
+ useEffect3(() => {
1465
+ onDrainingRef.current?.(isDraining);
1466
+ }, [isDraining]);
1467
+ const agentLabel = stream.agent ? stream.agent.replace("_agent", "").replace("_", " ") : null;
1468
+ const showPhaseIndicator = showPhases && stream.active && stream.phase !== "idle" && !displayedContent;
1469
+ const showCursor = (stream.active || isDraining) && !!displayedContent;
1470
+ return /* @__PURE__ */ jsxs9("div", { className: twMerge8("flex w-full flex-col items-start", className), "data-testid": "streaming-message", children: [
1048
1471
  /* @__PURE__ */ jsxs9("div", { "aria-live": "assertive", className: "sr-only", children: [
1049
1472
  stream.active && stream.phase !== "idle" && "Response started",
1050
1473
  !stream.active && stream.content && "Response complete"
1051
1474
  ] }),
1475
+ showCursor && /* @__PURE__ */ jsx11("style", { children: CURSOR_STYLES }),
1476
+ agentLabel && /* @__PURE__ */ jsx11("div", { className: "text-[11px] font-display font-semibold uppercase tracking-[0.08em] text-text-muted px-1 mb-1.5", children: agentLabel }),
1052
1477
  /* @__PURE__ */ jsxs9("div", { className: "max-w-[88%] px-4 py-3 rounded-[18px] rounded-tl-[4px] bg-surface border border-border motion-safe:animate-springFromLeft", children: [
1053
- showPhases && stream.active && stream.phase !== "idle" && /* @__PURE__ */ jsxs9(
1478
+ showPhaseIndicator && /* @__PURE__ */ jsxs9(
1054
1479
  "div",
1055
1480
  {
1056
- className: "flex items-center gap-2 mb-2 text-sm text-text-secondary",
1481
+ className: "flex items-center gap-2 text-sm text-text-secondary",
1057
1482
  "data-testid": "phase-indicator",
1058
1483
  children: [
1059
- /* @__PURE__ */ jsx11("span", { "aria-hidden": "true", children: /* @__PURE__ */ jsx11(Spinner, { size: "sm" }) }),
1484
+ /* @__PURE__ */ jsx11("span", { "aria-hidden": "true", children: /* @__PURE__ */ jsx11(WaveLoader, { size: "sm", color: "#38bdf8" }) }),
1060
1485
  /* @__PURE__ */ jsx11("span", { children: phaseLabel })
1061
1486
  ]
1062
1487
  }
1063
1488
  ),
1064
- /* @__PURE__ */ jsxs9("div", { className: "text-sm leading-relaxed text-text-primary whitespace-pre-wrap", children: [
1065
- displayedContent,
1066
- stream.active && /* @__PURE__ */ jsx11(
1067
- "span",
1068
- {
1069
- className: "inline-block w-0.5 h-4 bg-accent align-text-bottom animate-pulse ml-0.5",
1070
- "aria-hidden": "true",
1071
- "data-testid": "streaming-cursor"
1072
- }
1073
- )
1074
- ] })
1489
+ displayedContent && /* @__PURE__ */ jsx11(
1490
+ ResponseMessage,
1491
+ {
1492
+ content: displayedContent,
1493
+ className: showCursor ? "sk-streaming-cursor" : void 0
1494
+ }
1495
+ )
1075
1496
  ] })
1076
1497
  ] });
1077
1498
  }
@@ -1102,7 +1523,7 @@ function AgentChat({
1102
1523
  return /* @__PURE__ */ jsxs10(
1103
1524
  "div",
1104
1525
  {
1105
- className: twMerge8(
1526
+ className: twMerge9(
1106
1527
  "flex flex-col h-full bg-canvas border border-border rounded-xl overflow-hidden",
1107
1528
  className
1108
1529
  ),
@@ -1145,7 +1566,7 @@ function AgentChat({
1145
1566
  }
1146
1567
 
1147
1568
  // src/chat/ConversationList/ConversationList.tsx
1148
- import { twMerge as twMerge9 } from "tailwind-merge";
1569
+ import { twMerge as twMerge10 } from "tailwind-merge";
1149
1570
  import { jsx as jsx13, jsxs as jsxs11 } from "react/jsx-runtime";
1150
1571
  function ConversationList({
1151
1572
  conversations,
@@ -1159,7 +1580,7 @@ function ConversationList({
1159
1580
  "nav",
1160
1581
  {
1161
1582
  "aria-label": "Conversation list",
1162
- className: twMerge9("flex flex-col h-full bg-canvas", className),
1583
+ className: twMerge10("flex flex-col h-full bg-canvas", className),
1163
1584
  children: [
1164
1585
  onNew && /* @__PURE__ */ jsx13("div", { className: "p-3 border-b border-border", children: /* @__PURE__ */ jsx13(
1165
1586
  "button",
@@ -1176,7 +1597,7 @@ function ConversationList({
1176
1597
  return /* @__PURE__ */ jsxs11(
1177
1598
  "li",
1178
1599
  {
1179
- className: twMerge9(
1600
+ className: twMerge10(
1180
1601
  "flex items-start border-b border-border transition-colors duration-200",
1181
1602
  "hover:bg-surface",
1182
1603
  isActive && "bg-surface-raised border-l-2 border-l-accent"
@@ -1249,7 +1670,7 @@ function AgentFullPage({
1249
1670
  className
1250
1671
  }) {
1251
1672
  const [sidebarOpen, setSidebarOpen] = useState4(false);
1252
- const handleSelect = useCallback3(
1673
+ const handleSelect = useCallback4(
1253
1674
  (id) => {
1254
1675
  onConversationSelect?.(id);
1255
1676
  setSidebarOpen(false);
@@ -1259,7 +1680,7 @@ function AgentFullPage({
1259
1680
  return /* @__PURE__ */ jsxs12(
1260
1681
  "div",
1261
1682
  {
1262
- className: twMerge10("flex h-screen w-full overflow-hidden bg-brand-dark", className),
1683
+ className: twMerge11("flex h-screen w-full overflow-hidden bg-brand-dark", className),
1263
1684
  "data-testid": "agent-full-page",
1264
1685
  children: [
1265
1686
  showConversationList && /* @__PURE__ */ jsxs12(Fragment, { children: [
@@ -1274,7 +1695,7 @@ function AgentFullPage({
1274
1695
  /* @__PURE__ */ jsx14(
1275
1696
  "aside",
1276
1697
  {
1277
- className: twMerge10(
1698
+ className: twMerge11(
1278
1699
  "bg-brand-dark border-r border-brand-gold/15 w-72 shrink-0 flex-col z-40",
1279
1700
  // Desktop: always visible
1280
1701
  "hidden md:flex",
@@ -1340,7 +1761,7 @@ function AgentFullPage({
1340
1761
  }
1341
1762
 
1342
1763
  // src/layouts/AgentPanel/AgentPanel.tsx
1343
- import { twMerge as twMerge11 } from "tailwind-merge";
1764
+ import { twMerge as twMerge12 } from "tailwind-merge";
1344
1765
  import { useRef as useRef6, useEffect as useEffect4 } from "react";
1345
1766
  import { jsx as jsx15, jsxs as jsxs13 } from "react/jsx-runtime";
1346
1767
  function AgentPanel({
@@ -1365,13 +1786,13 @@ function AgentPanel({
1365
1786
  return /* @__PURE__ */ jsxs13(
1366
1787
  "div",
1367
1788
  {
1368
- className: twMerge11("fixed inset-0 z-50", !isOpen && "pointer-events-none"),
1789
+ className: twMerge12("fixed inset-0 z-50", !isOpen && "pointer-events-none"),
1369
1790
  "aria-hidden": !isOpen,
1370
1791
  children: [
1371
1792
  /* @__PURE__ */ jsx15(
1372
1793
  "div",
1373
1794
  {
1374
- className: twMerge11(
1795
+ className: twMerge12(
1375
1796
  "fixed inset-0 transition-opacity duration-300",
1376
1797
  isOpen ? "opacity-100 bg-brand-dark/70 backdrop-blur-sm pointer-events-auto" : "opacity-0 pointer-events-none"
1377
1798
  ),
@@ -1387,7 +1808,7 @@ function AgentPanel({
1387
1808
  "aria-label": title,
1388
1809
  "aria-modal": isOpen ? "true" : void 0,
1389
1810
  style: { width: widthStyle, maxWidth: "100vw" },
1390
- className: twMerge11(
1811
+ className: twMerge12(
1391
1812
  "fixed top-0 h-full flex flex-col z-50 bg-brand-dark shadow-card",
1392
1813
  "transition-transform duration-300 ease-in-out",
1393
1814
  side === "left" ? `left-0 border-r border-brand-gold/15 ${isOpen ? "translate-x-0" : "-translate-x-full"}` : `right-0 border-l border-brand-gold/15 ${isOpen ? "translate-x-0" : "translate-x-full"}`,
@@ -1429,8 +1850,8 @@ function AgentPanel({
1429
1850
  }
1430
1851
 
1431
1852
  // src/layouts/AgentWidget/AgentWidget.tsx
1432
- import { twMerge as twMerge12 } from "tailwind-merge";
1433
- import { useState as useState5, useCallback as useCallback4 } from "react";
1853
+ import { twMerge as twMerge13 } from "tailwind-merge";
1854
+ import { useState as useState5, useCallback as useCallback5 } from "react";
1434
1855
  import { jsx as jsx16, jsxs as jsxs14 } from "react/jsx-runtime";
1435
1856
  function AgentWidget({
1436
1857
  endpoint,
@@ -1440,7 +1861,7 @@ function AgentWidget({
1440
1861
  className
1441
1862
  }) {
1442
1863
  const [isOpen, setIsOpen] = useState5(false);
1443
- const toggle = useCallback4(() => {
1864
+ const toggle = useCallback5(() => {
1444
1865
  setIsOpen((prev) => !prev);
1445
1866
  }, []);
1446
1867
  const positionClasses = position === "bottom-left" ? "left-4 bottom-4" : "right-4 bottom-4";
@@ -1453,7 +1874,7 @@ function AgentWidget({
1453
1874
  role: "dialog",
1454
1875
  "aria-label": title,
1455
1876
  "aria-hidden": !isOpen,
1456
- className: twMerge12(
1877
+ className: twMerge13(
1457
1878
  "fixed z-50 flex flex-col",
1458
1879
  "w-[min(400px,calc(100vw-2rem))] h-[min(600px,calc(100vh-6rem))]",
1459
1880
  "rounded-2xl overflow-hidden border border-brand-gold/15",
@@ -1500,7 +1921,7 @@ function AgentWidget({
1500
1921
  onClick: toggle,
1501
1922
  "aria-label": isOpen ? "Close chat" : triggerLabel,
1502
1923
  "aria-expanded": isOpen,
1503
- className: twMerge12(
1924
+ className: twMerge13(
1504
1925
  "fixed z-50 flex items-center justify-center w-14 h-14 rounded-full",
1505
1926
  "bg-brand-blue text-brand-cream shadow-glow-cyan",
1506
1927
  "hover:bg-brand-cyan hover:shadow-glow-cyan hover:scale-105",
@@ -1518,7 +1939,7 @@ function AgentWidget({
1518
1939
  }
1519
1940
 
1520
1941
  // src/layouts/AgentEmbed/AgentEmbed.tsx
1521
- import { twMerge as twMerge13 } from "tailwind-merge";
1942
+ import { twMerge as twMerge14 } from "tailwind-merge";
1522
1943
  import { jsx as jsx17 } from "react/jsx-runtime";
1523
1944
  function AgentEmbed({
1524
1945
  endpoint,
@@ -1528,7 +1949,7 @@ function AgentEmbed({
1528
1949
  return /* @__PURE__ */ jsx17(
1529
1950
  "div",
1530
1951
  {
1531
- className: twMerge13("w-full h-full min-h-0", className),
1952
+ className: twMerge14("w-full h-full min-h-0", className),
1532
1953
  "data-testid": "agent-embed",
1533
1954
  children: /* @__PURE__ */ jsx17(
1534
1955
  AgentChat,