@surf-kit/agent 0.2.1 → 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 (65) hide show
  1. package/LICENSE +184 -12
  2. package/README.md +1 -1
  3. package/dist/agent-identity/index.cjs +1 -0
  4. package/dist/agent-identity/index.cjs.map +1 -1
  5. package/dist/agent-identity/index.js +2 -0
  6. package/dist/agent-identity/index.js.map +1 -1
  7. package/dist/chat/index.cjs +626 -204
  8. package/dist/chat/index.cjs.map +1 -1
  9. package/dist/chat/index.d.cts +11 -6
  10. package/dist/chat/index.d.ts +11 -6
  11. package/dist/chat/index.js +608 -185
  12. package/dist/chat/index.js.map +1 -1
  13. package/dist/{chat--OifhIRe.d.ts → chat-BIIDOGrD.d.ts} +10 -1
  14. package/dist/{chat-ChYl2XjV.d.cts → chat-CGamM7Mz.d.cts} +10 -1
  15. package/dist/confidence/index.cjs +1 -0
  16. package/dist/confidence/index.cjs.map +1 -1
  17. package/dist/confidence/index.js +2 -0
  18. package/dist/confidence/index.js.map +1 -1
  19. package/dist/feedback/index.cjs +1 -0
  20. package/dist/feedback/index.cjs.map +1 -1
  21. package/dist/feedback/index.js +2 -0
  22. package/dist/feedback/index.js.map +1 -1
  23. package/dist/{hooks-DLfF18IU.d.cts → hooks-B1NYoLLs.d.cts} +21 -5
  24. package/dist/{hooks-BGs8-4GK.d.ts → hooks-CTeEqnBQ.d.ts} +21 -5
  25. package/dist/hooks.cjs +127 -81
  26. package/dist/hooks.cjs.map +1 -1
  27. package/dist/hooks.d.cts +3 -3
  28. package/dist/hooks.d.ts +3 -3
  29. package/dist/hooks.js +128 -81
  30. package/dist/hooks.js.map +1 -1
  31. package/dist/index.cjs +687 -265
  32. package/dist/index.cjs.map +1 -1
  33. package/dist/index.d.cts +3 -3
  34. package/dist/index.d.ts +3 -3
  35. package/dist/index.js +647 -224
  36. package/dist/index.js.map +1 -1
  37. package/dist/layouts/index.cjs +647 -225
  38. package/dist/layouts/index.cjs.map +1 -1
  39. package/dist/layouts/index.d.cts +1 -1
  40. package/dist/layouts/index.d.ts +1 -1
  41. package/dist/layouts/index.js +624 -201
  42. package/dist/layouts/index.js.map +1 -1
  43. package/dist/mcp/index.cjs +2 -1
  44. package/dist/mcp/index.cjs.map +1 -1
  45. package/dist/mcp/index.js +4 -2
  46. package/dist/mcp/index.js.map +1 -1
  47. package/dist/response/index.cjs +67 -12
  48. package/dist/response/index.cjs.map +1 -1
  49. package/dist/response/index.d.cts +2 -2
  50. package/dist/response/index.d.ts +2 -2
  51. package/dist/response/index.js +66 -10
  52. package/dist/response/index.js.map +1 -1
  53. package/dist/sources/index.cjs +31 -1
  54. package/dist/sources/index.cjs.map +1 -1
  55. package/dist/sources/index.js +32 -1
  56. package/dist/sources/index.js.map +1 -1
  57. package/dist/streaming/index.cjs +203 -93
  58. package/dist/streaming/index.cjs.map +1 -1
  59. package/dist/streaming/index.d.cts +4 -3
  60. package/dist/streaming/index.d.ts +4 -3
  61. package/dist/streaming/index.js +174 -73
  62. package/dist/streaming/index.js.map +1 -1
  63. package/dist/{streaming-DbQxScpi.d.ts → streaming-Bx-ff2tt.d.ts} +1 -1
  64. package/dist/{streaming-DfT22A0z.d.cts → streaming-x7umFHoP.d.cts} +1 -1
  65. package/package.json +17 -6
@@ -1,5 +1,7 @@
1
+ 'use client';
2
+
1
3
  // src/chat/AgentChat/AgentChat.tsx
2
- import { twMerge as twMerge8 } from "tailwind-merge";
4
+ import { twMerge as twMerge9 } from "tailwind-merge";
3
5
 
4
6
  // src/hooks/useAgentChat.ts
5
7
  import { useReducer, useCallback, useRef } from "react";
@@ -10,7 +12,8 @@ var initialState = {
10
12
  error: null,
11
13
  inputValue: "",
12
14
  streamPhase: "idle",
13
- streamingContent: ""
15
+ streamingContent: "",
16
+ streamingAgent: null
14
17
  };
15
18
  function reducer(state, action) {
16
19
  switch (action.type) {
@@ -24,12 +27,15 @@ function reducer(state, action) {
24
27
  error: null,
25
28
  inputValue: "",
26
29
  streamPhase: "thinking",
27
- streamingContent: ""
30
+ streamingContent: "",
31
+ streamingAgent: null
28
32
  };
29
33
  case "STREAM_PHASE":
30
34
  return { ...state, streamPhase: action.phase };
31
35
  case "STREAM_CONTENT":
32
36
  return { ...state, streamingContent: state.streamingContent + action.content };
37
+ case "STREAM_AGENT":
38
+ return { ...state, streamingAgent: action.agent };
33
39
  case "SEND_SUCCESS":
34
40
  return {
35
41
  ...state,
@@ -45,7 +51,8 @@ function reducer(state, action) {
45
51
  isLoading: false,
46
52
  error: action.error,
47
53
  streamPhase: "idle",
48
- streamingContent: ""
54
+ streamingContent: "",
55
+ streamingAgent: null
49
56
  };
50
57
  case "LOAD_CONVERSATION":
51
58
  return {
@@ -71,107 +78,142 @@ function useAgentChat(config) {
71
78
  const configRef = useRef(config);
72
79
  configRef.current = config;
73
80
  const lastUserMessageRef = useRef(null);
81
+ const lastUserAttachmentsRef = useRef(void 0);
74
82
  const sendMessage = useCallback(
75
- async (content) => {
76
- const { apiUrl, streamPath = "/chat/stream", headers = {}, timeout = 3e4 } = configRef.current;
83
+ async (content, attachments) => {
84
+ const { apiUrl, streamPath = "/chat/stream", headers: headersOrFn, timeout = 3e4, bodyExtra } = configRef.current;
85
+ const headers = typeof headersOrFn === "function" ? await headersOrFn() : headersOrFn ?? {};
77
86
  lastUserMessageRef.current = content;
87
+ lastUserAttachmentsRef.current = attachments;
78
88
  const userMessage = {
79
89
  id: generateMessageId(),
80
90
  role: "user",
81
91
  content,
92
+ attachments,
82
93
  timestamp: /* @__PURE__ */ new Date()
83
94
  };
84
95
  dispatch({ type: "SEND_START", message: userMessage });
85
96
  const controller = new AbortController();
86
97
  const timeoutId = setTimeout(() => controller.abort(), timeout);
87
98
  try {
88
- const response = await fetch(`${apiUrl}${streamPath}`, {
89
- method: "POST",
90
- headers: {
91
- "Content-Type": "application/json",
92
- Accept: "text/event-stream",
93
- ...headers
94
- },
95
- body: JSON.stringify({
96
- message: content,
97
- conversation_id: state.conversationId
98
- }),
99
- signal: controller.signal
100
- });
101
- clearTimeout(timeoutId);
102
- if (!response.ok) {
103
- dispatch({
104
- type: "SEND_ERROR",
105
- error: {
106
- code: "API_ERROR",
107
- message: `HTTP ${response.status}: ${response.statusText}`,
108
- retryable: response.status >= 500
109
- }
110
- });
111
- return;
99
+ const url = `${apiUrl}${streamPath}`;
100
+ const mergedHeaders = {
101
+ "Content-Type": "application/json",
102
+ Accept: "text/event-stream",
103
+ ...headers
104
+ };
105
+ const requestBody = {
106
+ message: content,
107
+ conversation_id: state.conversationId,
108
+ ...bodyExtra
109
+ };
110
+ if (attachments && attachments.length > 0) {
111
+ requestBody.attachments = attachments.map((a) => ({
112
+ filename: a.filename,
113
+ content_type: a.content_type,
114
+ data: a.data
115
+ }));
112
116
  }
113
- const reader = response.body?.getReader();
114
- if (!reader) {
115
- dispatch({
116
- type: "SEND_ERROR",
117
- error: { code: "STREAM_ERROR", message: "No response body", retryable: true }
117
+ const body = JSON.stringify(requestBody);
118
+ const ctx = {
119
+ accumulatedContent: "",
120
+ agentResponse: null,
121
+ capturedAgent: null,
122
+ capturedConversationId: null,
123
+ hadStreamError: false
124
+ };
125
+ const handleEvent = (event) => {
126
+ switch (event.type) {
127
+ case "agent":
128
+ ctx.capturedAgent = event.agent;
129
+ dispatch({ type: "STREAM_AGENT", agent: ctx.capturedAgent });
130
+ break;
131
+ case "phase":
132
+ dispatch({ type: "STREAM_PHASE", phase: event.phase });
133
+ break;
134
+ case "delta":
135
+ ctx.accumulatedContent += event.content;
136
+ dispatch({ type: "STREAM_CONTENT", content: event.content });
137
+ break;
138
+ case "done":
139
+ ctx.agentResponse = event.response;
140
+ ctx.capturedConversationId = event.conversation_id ?? null;
141
+ break;
142
+ case "error":
143
+ ctx.hadStreamError = true;
144
+ dispatch({ type: "SEND_ERROR", error: event.error });
145
+ break;
146
+ }
147
+ };
148
+ const { streamAdapter } = configRef.current;
149
+ if (streamAdapter) {
150
+ await streamAdapter(
151
+ url,
152
+ { method: "POST", headers: mergedHeaders, body, signal: controller.signal },
153
+ handleEvent
154
+ );
155
+ clearTimeout(timeoutId);
156
+ } else {
157
+ const response = await fetch(url, {
158
+ method: "POST",
159
+ headers: mergedHeaders,
160
+ body,
161
+ signal: controller.signal
118
162
  });
119
- return;
120
- }
121
- const decoder = new TextDecoder();
122
- let buffer = "";
123
- let accumulatedContent = "";
124
- let agentResponse = null;
125
- let capturedAgent = null;
126
- let capturedConversationId = null;
127
- while (true) {
128
- const { done, value } = await reader.read();
129
- if (done) break;
130
- buffer += decoder.decode(value, { stream: true });
131
- const lines = buffer.split("\n");
132
- buffer = lines.pop() ?? "";
133
- for (const line of lines) {
134
- if (!line.startsWith("data: ")) continue;
135
- const data = line.slice(6).trim();
136
- if (data === "[DONE]") continue;
137
- try {
138
- const event = JSON.parse(data);
139
- switch (event.type) {
140
- case "agent":
141
- capturedAgent = event.agent;
142
- break;
143
- case "phase":
144
- dispatch({ type: "STREAM_PHASE", phase: event.phase });
145
- break;
146
- case "delta":
147
- accumulatedContent += event.content;
148
- dispatch({ type: "STREAM_CONTENT", content: event.content });
149
- break;
150
- case "done":
151
- agentResponse = event.response;
152
- capturedConversationId = event.conversation_id ?? null;
153
- break;
154
- case "error":
155
- dispatch({ type: "SEND_ERROR", error: event.error });
156
- return;
163
+ clearTimeout(timeoutId);
164
+ if (!response.ok) {
165
+ dispatch({
166
+ type: "SEND_ERROR",
167
+ error: {
168
+ code: "API_ERROR",
169
+ message: `HTTP ${response.status}: ${response.statusText}`,
170
+ retryable: response.status >= 500
171
+ }
172
+ });
173
+ return;
174
+ }
175
+ const reader = response.body?.getReader();
176
+ if (!reader) {
177
+ dispatch({
178
+ type: "SEND_ERROR",
179
+ error: { code: "STREAM_ERROR", message: "No response body", retryable: true }
180
+ });
181
+ return;
182
+ }
183
+ const decoder = new TextDecoder();
184
+ let buffer = "";
185
+ while (true) {
186
+ const { done, value } = await reader.read();
187
+ if (done) break;
188
+ buffer += decoder.decode(value, { stream: true });
189
+ const lines = buffer.split("\n");
190
+ buffer = lines.pop() ?? "";
191
+ for (const line of lines) {
192
+ if (!line.startsWith("data: ")) continue;
193
+ const data = line.slice(6).trim();
194
+ if (data === "[DONE]") continue;
195
+ try {
196
+ const event = JSON.parse(data);
197
+ handleEvent(event);
198
+ } catch {
157
199
  }
158
- } catch {
159
200
  }
160
201
  }
161
202
  }
203
+ if (ctx.hadStreamError) return;
162
204
  const assistantMessage = {
163
205
  id: generateMessageId(),
164
206
  role: "assistant",
165
- content: agentResponse?.message ?? accumulatedContent,
166
- response: agentResponse ?? void 0,
167
- agent: capturedAgent ?? void 0,
207
+ content: ctx.agentResponse?.message ?? ctx.accumulatedContent,
208
+ response: ctx.agentResponse ?? void 0,
209
+ agent: ctx.capturedAgent ?? void 0,
168
210
  timestamp: /* @__PURE__ */ new Date()
169
211
  };
170
212
  dispatch({
171
213
  type: "SEND_SUCCESS",
172
214
  message: assistantMessage,
173
- streamingContent: accumulatedContent,
174
- conversationId: capturedConversationId
215
+ streamingContent: ctx.accumulatedContent,
216
+ conversationId: ctx.capturedConversationId
175
217
  });
176
218
  } catch (err) {
177
219
  clearTimeout(timeoutId);
@@ -202,7 +244,8 @@ function useAgentChat(config) {
202
244
  }, []);
203
245
  const submitFeedback = useCallback(
204
246
  async (messageId, rating, comment) => {
205
- const { apiUrl, feedbackPath = "/feedback", headers = {} } = configRef.current;
247
+ const { apiUrl, feedbackPath = "/feedback", headers: headersOrFn } = configRef.current;
248
+ const headers = typeof headersOrFn === "function" ? await headersOrFn() : headersOrFn ?? {};
206
249
  await fetch(`${apiUrl}${feedbackPath}`, {
207
250
  method: "POST",
208
251
  headers: { "Content-Type": "application/json", ...headers },
@@ -213,12 +256,13 @@ function useAgentChat(config) {
213
256
  );
214
257
  const retry = useCallback(async () => {
215
258
  if (lastUserMessageRef.current) {
216
- await sendMessage(lastUserMessageRef.current);
259
+ await sendMessage(lastUserMessageRef.current, lastUserAttachmentsRef.current);
217
260
  }
218
261
  }, [sendMessage]);
219
262
  const reset = useCallback(() => {
220
263
  dispatch({ type: "RESET" });
221
264
  lastUserMessageRef.current = null;
265
+ lastUserAttachmentsRef.current = void 0;
222
266
  }, []);
223
267
  const actions = {
224
268
  sendMessage,
@@ -233,7 +277,7 @@ function useAgentChat(config) {
233
277
 
234
278
  // src/chat/MessageThread/MessageThread.tsx
235
279
  import { twMerge as twMerge5 } from "tailwind-merge";
236
- import { useEffect, useRef as useRef2 } from "react";
280
+ import { useCallback as useCallback2, useEffect, useRef as useRef2 } from "react";
237
281
 
238
282
  // src/chat/MessageBubble/MessageBubble.tsx
239
283
  import { twMerge as twMerge4 } from "tailwind-merge";
@@ -242,6 +286,7 @@ import { twMerge as twMerge4 } from "tailwind-merge";
242
286
  import { Badge as Badge2 } from "@surf-kit/core";
243
287
 
244
288
  // src/response/ResponseMessage/ResponseMessage.tsx
289
+ import React from "react";
245
290
  import ReactMarkdown from "react-markdown";
246
291
  import rehypeSanitize from "rehype-sanitize";
247
292
  import { twMerge } from "tailwind-merge";
@@ -266,6 +311,7 @@ function ResponseMessage({ content, className }) {
266
311
  "[&_h3]:text-sm [&_h3]:font-semibold [&_h3]:text-accent [&_h3]:mt-2 [&_h3]:mb-1",
267
312
  "[&_code]:bg-surface-raised [&_code]:text-accent [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_code]:font-mono",
268
313
  "[&_pre]:bg-surface-raised [&_pre]:border [&_pre]:border-border [&_pre]:rounded-xl [&_pre]:p-4 [&_pre]:overflow-x-auto",
314
+ "[&_hr]:my-3 [&_hr]:border-border",
269
315
  "[&_blockquote]:border-l-2 [&_blockquote]:border-border-strong [&_blockquote]:pl-4 [&_blockquote]:text-text-secondary",
270
316
  "[&_a]:text-accent [&_a]:underline-offset-2 [&_a]:hover:text-accent/80",
271
317
  className
@@ -281,11 +327,24 @@ function ResponseMessage({ content, className }) {
281
327
  p: ({ children }) => /* @__PURE__ */ jsx("p", { className: "my-2", children }),
282
328
  ul: ({ children }) => /* @__PURE__ */ jsx("ul", { className: "my-2 list-disc pl-6", children }),
283
329
  ol: ({ children }) => /* @__PURE__ */ jsx("ol", { className: "my-2 list-decimal pl-6", children }),
284
- li: ({ children }) => /* @__PURE__ */ jsx("li", { className: "my-1", children }),
330
+ li: ({ children, ...props }) => {
331
+ let content2 = children;
332
+ if (props.ordered) {
333
+ content2 = React.Children.map(children, (child, i) => {
334
+ if (i === 0 && typeof child === "string") {
335
+ return child.replace(/^\d+[.)]\s*/, "");
336
+ }
337
+ return child;
338
+ });
339
+ }
340
+ return /* @__PURE__ */ jsx("li", { className: "my-1", children: content2 });
341
+ },
285
342
  strong: ({ children }) => /* @__PURE__ */ jsx("strong", { className: "font-semibold", children }),
343
+ em: ({ children }) => /* @__PURE__ */ jsx("em", { className: "italic text-text-secondary", children }),
286
344
  h1: ({ children }) => /* @__PURE__ */ jsx("h1", { className: "text-base font-bold mt-4 mb-2", children }),
287
345
  h2: ({ children }) => /* @__PURE__ */ jsx("h2", { className: "text-sm font-bold mt-3 mb-1", children }),
288
346
  h3: ({ children }) => /* @__PURE__ */ jsx("h3", { className: "text-sm font-semibold mt-2 mb-1", children }),
347
+ hr: () => /* @__PURE__ */ jsx("hr", { className: "my-3 border-border" }),
289
348
  code: ({ children }) => /* @__PURE__ */ jsx("code", { className: "bg-surface-sunken rounded px-1 py-0.5 text-xs font-mono", children })
290
349
  },
291
350
  children: normalizeMarkdownLists(content)
@@ -421,7 +480,14 @@ function renderWarning(data) {
421
480
  }
422
481
  );
423
482
  }
424
- function StructuredResponse({ uiHint, data, className }) {
483
+ function StructuredResponse({ uiHint, data: rawData, className }) {
484
+ const data = typeof rawData === "string" ? (() => {
485
+ try {
486
+ return JSON.parse(rawData);
487
+ } catch {
488
+ return null;
489
+ }
490
+ })() : rawData;
425
491
  if (!data) return null;
426
492
  let content;
427
493
  switch (uiHint) {
@@ -501,7 +567,36 @@ function SourceCard({ source, variant = "compact", onNavigate, className }) {
501
567
  children: [
502
568
  /* @__PURE__ */ jsxs2("div", { className: "flex items-start justify-between gap-2", children: [
503
569
  /* @__PURE__ */ jsxs2("div", { className: "flex-1 min-w-0", children: [
504
- /* @__PURE__ */ jsx3("p", { className: "text-sm font-medium text-text-primary truncate", children: source.title }),
570
+ source.url ? /* @__PURE__ */ jsxs2(
571
+ "a",
572
+ {
573
+ href: source.url,
574
+ target: "_blank",
575
+ rel: "noopener noreferrer",
576
+ className: "text-sm font-medium text-accent hover:underline truncate block",
577
+ onClick: (e) => e.stopPropagation(),
578
+ children: [
579
+ source.title,
580
+ /* @__PURE__ */ jsxs2(
581
+ "svg",
582
+ {
583
+ className: "inline-block ml-1 w-3 h-3 opacity-60",
584
+ viewBox: "0 0 24 24",
585
+ fill: "none",
586
+ stroke: "currentColor",
587
+ strokeWidth: "2",
588
+ strokeLinecap: "round",
589
+ strokeLinejoin: "round",
590
+ children: [
591
+ /* @__PURE__ */ jsx3("path", { d: "M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6" }),
592
+ /* @__PURE__ */ jsx3("polyline", { points: "15 3 21 3 21 9" }),
593
+ /* @__PURE__ */ jsx3("line", { x1: "10", y1: "14", x2: "21", y2: "3" })
594
+ ]
595
+ }
596
+ )
597
+ ]
598
+ }
599
+ ) : /* @__PURE__ */ jsx3("p", { className: "text-sm font-medium text-text-primary truncate", children: source.title }),
505
600
  source.section && /* @__PURE__ */ jsx3("p", { className: "text-[11px] font-semibold uppercase tracking-wider text-text-secondary truncate mt-0.5", children: source.section })
506
601
  ] }),
507
602
  /* @__PURE__ */ jsx3(
@@ -635,13 +730,16 @@ function AgentResponse({
635
730
  }) {
636
731
  return /* @__PURE__ */ jsxs4("div", { className: `flex flex-col gap-4 ${className ?? ""}`, "data-testid": "agent-response", children: [
637
732
  /* @__PURE__ */ jsx6(ResponseMessage, { content: response.message }),
638
- response.ui_hint !== "text" && response.structured_data && /* @__PURE__ */ jsx6(
639
- StructuredResponse,
640
- {
641
- uiHint: response.ui_hint,
642
- data: response.structured_data
643
- }
644
- ),
733
+ response.ui_hint !== "text" && response.structured_data && (() => {
734
+ const parsed = typeof response.structured_data === "string" ? (() => {
735
+ try {
736
+ return JSON.parse(response.structured_data);
737
+ } catch {
738
+ return null;
739
+ }
740
+ })() : response.structured_data;
741
+ return parsed ? /* @__PURE__ */ jsx6(StructuredResponse, { uiHint: response.ui_hint, data: parsed }) : null;
742
+ })(),
645
743
  (showConfidence || showVerification) && /* @__PURE__ */ jsxs4("div", { className: "flex flex-wrap items-center gap-2 mt-1", "data-testid": "response-meta", children: [
646
744
  showConfidence && /* @__PURE__ */ jsxs4(
647
745
  Badge2,
@@ -692,6 +790,31 @@ function AgentResponse({
692
790
 
693
791
  // src/chat/MessageBubble/MessageBubble.tsx
694
792
  import { jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
793
+ function DocumentIcon() {
794
+ 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: [
795
+ /* @__PURE__ */ jsx7("path", { d: "M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" }),
796
+ /* @__PURE__ */ jsx7("polyline", { points: "14 2 14 8 20 8" }),
797
+ /* @__PURE__ */ jsx7("line", { x1: "16", y1: "13", x2: "8", y2: "13" }),
798
+ /* @__PURE__ */ jsx7("line", { x1: "16", y1: "17", x2: "8", y2: "17" })
799
+ ] });
800
+ }
801
+ function AttachmentThumbnail({ attachment }) {
802
+ const isImage = attachment.content_type.startsWith("image/");
803
+ if (isImage) {
804
+ return /* @__PURE__ */ jsx7("div", { className: "rounded-lg overflow-hidden border border-black/10 max-w-[240px]", children: /* @__PURE__ */ jsx7(
805
+ "img",
806
+ {
807
+ src: attachment.preview_url ?? `data:${attachment.content_type};base64,${attachment.data}`,
808
+ alt: attachment.filename,
809
+ className: "max-w-full max-h-[200px] object-contain"
810
+ }
811
+ ) });
812
+ }
813
+ return /* @__PURE__ */ jsxs5("div", { className: "flex items-center gap-2 px-3 py-2 rounded-lg border border-black/10 bg-black/5", children: [
814
+ /* @__PURE__ */ jsx7(DocumentIcon, {}),
815
+ /* @__PURE__ */ jsx7("span", { className: "text-xs truncate max-w-[160px]", children: attachment.filename })
816
+ ] });
817
+ }
695
818
  function MessageBubble({
696
819
  message,
697
820
  showAgent,
@@ -699,23 +822,29 @@ function MessageBubble({
699
822
  showConfidence = true,
700
823
  showVerification = true,
701
824
  animated = true,
825
+ userBubbleClassName,
702
826
  className
703
827
  }) {
704
828
  const isUser = message.role === "user";
829
+ const hasAttachments = message.attachments && message.attachments.length > 0;
705
830
  if (isUser) {
706
831
  return /* @__PURE__ */ jsx7(
707
832
  "div",
708
833
  {
709
834
  "data-message-id": message.id,
710
835
  className: twMerge4("flex w-full justify-end", className),
711
- children: /* @__PURE__ */ jsx7(
836
+ children: /* @__PURE__ */ jsxs5(
712
837
  "div",
713
838
  {
714
839
  className: twMerge4(
715
- "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",
716
- animated && "motion-safe:animate-slideFromRight"
840
+ "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",
841
+ animated && "motion-safe:animate-slideFromRight",
842
+ userBubbleClassName
717
843
  ),
718
- children: message.content
844
+ children: [
845
+ 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}`)) }),
846
+ message.content
847
+ ]
719
848
  }
720
849
  )
721
850
  }
@@ -727,7 +856,7 @@ function MessageBubble({
727
856
  "data-message-id": message.id,
728
857
  className: twMerge4("flex w-full flex-col items-start gap-1.5", className),
729
858
  children: [
730
- 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("_", " ") }),
859
+ 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("_", " ") }),
731
860
  /* @__PURE__ */ jsx7(
732
861
  "div",
733
862
  {
@@ -753,34 +882,70 @@ function MessageBubble({
753
882
 
754
883
  // src/chat/MessageThread/MessageThread.tsx
755
884
  import { jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
756
- function MessageThread({ messages, streamingSlot, showSources, showConfidence, showVerification, className }) {
757
- const bottomRef = useRef2(null);
885
+ function MessageThread({ messages, streamingSlot, showAgent, showSources, showConfidence, showVerification, hideLastAssistant, userBubbleClassName, className }) {
886
+ const scrollRef = useRef2(null);
887
+ const isNearBottom = useRef2(true);
888
+ const isProgrammaticScroll = useRef2(false);
889
+ const hasStreaming = !!streamingSlot;
890
+ const scrollToBottom = useCallback2(() => {
891
+ const el = scrollRef.current;
892
+ if (el && isNearBottom.current) {
893
+ isProgrammaticScroll.current = true;
894
+ el.scrollTop = el.scrollHeight;
895
+ }
896
+ }, []);
897
+ const handleScroll = useCallback2(() => {
898
+ if (isProgrammaticScroll.current) {
899
+ isProgrammaticScroll.current = false;
900
+ return;
901
+ }
902
+ const el = scrollRef.current;
903
+ if (!el) return;
904
+ isNearBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
905
+ }, []);
906
+ useEffect(scrollToBottom, [messages.length, scrollToBottom]);
758
907
  useEffect(() => {
759
- bottomRef.current?.scrollIntoView?.({ behavior: "smooth" });
760
- }, [messages.length, streamingSlot]);
908
+ if (!hasStreaming) return;
909
+ let raf;
910
+ const tick = () => {
911
+ scrollToBottom();
912
+ raf = requestAnimationFrame(tick);
913
+ };
914
+ raf = requestAnimationFrame(tick);
915
+ return () => cancelAnimationFrame(raf);
916
+ }, [hasStreaming, scrollToBottom]);
761
917
  return /* @__PURE__ */ jsxs6(
762
918
  "div",
763
919
  {
920
+ ref: scrollRef,
764
921
  role: "log",
765
922
  "aria-live": "polite",
766
923
  "aria-label": "Message thread",
924
+ onScroll: handleScroll,
767
925
  className: twMerge5(
768
926
  "flex flex-col gap-4 overflow-y-auto flex-1 px-4 py-6",
769
927
  className
770
928
  ),
771
929
  children: [
772
- messages.map((message) => /* @__PURE__ */ jsx8(
773
- MessageBubble,
774
- {
775
- message,
776
- showSources,
777
- showConfidence,
778
- showVerification
779
- },
780
- message.id
781
- )),
782
- streamingSlot,
783
- /* @__PURE__ */ jsx8("div", { ref: bottomRef })
930
+ /* @__PURE__ */ jsx8("div", { className: "flex-1 shrink-0" }),
931
+ messages.map((message, i) => {
932
+ if (hideLastAssistant && i === messages.length - 1 && message.role === "assistant") {
933
+ return null;
934
+ }
935
+ return /* @__PURE__ */ jsx8(
936
+ MessageBubble,
937
+ {
938
+ message,
939
+ showAgent,
940
+ showSources,
941
+ showConfidence,
942
+ showVerification,
943
+ userBubbleClassName
944
+ },
945
+ message.id
946
+ );
947
+ }),
948
+ streamingSlot
784
949
  ]
785
950
  }
786
951
  );
@@ -788,8 +953,96 @@ function MessageThread({ messages, streamingSlot, showSources, showConfidence, s
788
953
 
789
954
  // src/chat/MessageComposer/MessageComposer.tsx
790
955
  import { twMerge as twMerge6 } from "tailwind-merge";
791
- import { useState as useState2, useRef as useRef3, useCallback as useCallback2 } from "react";
956
+ import { useState as useState2, useRef as useRef3, useCallback as useCallback3 } from "react";
792
957
  import { jsx as jsx9, jsxs as jsxs7 } from "react/jsx-runtime";
958
+ var ALLOWED_TYPES = /* @__PURE__ */ new Set([
959
+ "image/png",
960
+ "image/jpeg",
961
+ "image/gif",
962
+ "image/webp",
963
+ "application/pdf"
964
+ ]);
965
+ var MAX_FILE_SIZE = 10 * 1024 * 1024;
966
+ var MAX_ATTACHMENTS = 5;
967
+ function ArrowUpIcon() {
968
+ 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: [
969
+ /* @__PURE__ */ jsx9("path", { d: "M10 16V4" }),
970
+ /* @__PURE__ */ jsx9("path", { d: "M4 10l6-6 6 6" })
971
+ ] });
972
+ }
973
+ function StopIcon() {
974
+ 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" }) });
975
+ }
976
+ function PaperclipIcon() {
977
+ 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" }) });
978
+ }
979
+ function XIcon({ size = 14 }) {
980
+ 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: [
981
+ /* @__PURE__ */ jsx9("path", { d: "M18 6L6 18" }),
982
+ /* @__PURE__ */ jsx9("path", { d: "M6 6l12 12" })
983
+ ] });
984
+ }
985
+ function DocumentIcon2() {
986
+ 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: [
987
+ /* @__PURE__ */ jsx9("path", { d: "M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" }),
988
+ /* @__PURE__ */ jsx9("polyline", { points: "14 2 14 8 20 8" }),
989
+ /* @__PURE__ */ jsx9("line", { x1: "16", y1: "13", x2: "8", y2: "13" }),
990
+ /* @__PURE__ */ jsx9("line", { x1: "16", y1: "17", x2: "8", y2: "17" }),
991
+ /* @__PURE__ */ jsx9("polyline", { points: "10 9 9 9 8 9" })
992
+ ] });
993
+ }
994
+ function fileToBase64(file) {
995
+ return new Promise((resolve, reject) => {
996
+ const reader = new FileReader();
997
+ reader.onload = () => {
998
+ const result = reader.result;
999
+ const base64 = result.split(",")[1];
1000
+ resolve(base64);
1001
+ };
1002
+ reader.onerror = reject;
1003
+ reader.readAsDataURL(file);
1004
+ });
1005
+ }
1006
+ function AttachmentPreview({
1007
+ attachment,
1008
+ onRemove
1009
+ }) {
1010
+ const isImage = attachment.content_type.startsWith("image/");
1011
+ return /* @__PURE__ */ jsxs7("div", { className: "relative group flex-shrink-0", children: [
1012
+ isImage ? /* @__PURE__ */ jsx9("div", { className: "w-16 h-16 rounded-lg overflow-hidden border border-border/60 bg-surface-alt", children: /* @__PURE__ */ jsx9(
1013
+ "img",
1014
+ {
1015
+ src: attachment.preview_url ?? `data:${attachment.content_type};base64,${attachment.data}`,
1016
+ alt: attachment.filename,
1017
+ className: "w-full h-full object-cover"
1018
+ }
1019
+ ) }) : /* @__PURE__ */ jsxs7("div", { className: "h-16 px-3 rounded-lg border border-border/60 bg-surface-alt flex items-center gap-2", children: [
1020
+ /* @__PURE__ */ jsx9("div", { className: "text-text-muted", children: /* @__PURE__ */ jsx9(DocumentIcon2, {}) }),
1021
+ /* @__PURE__ */ jsxs7("div", { className: "flex flex-col min-w-0", children: [
1022
+ /* @__PURE__ */ jsx9("span", { className: "text-xs text-text-primary truncate max-w-[120px]", children: attachment.filename }),
1023
+ /* @__PURE__ */ jsx9("span", { className: "text-[10px] text-text-muted", children: "PDF" })
1024
+ ] })
1025
+ ] }),
1026
+ /* @__PURE__ */ jsx9(
1027
+ "button",
1028
+ {
1029
+ type: "button",
1030
+ onClick: onRemove,
1031
+ className: twMerge6(
1032
+ "absolute -top-1.5 -right-1.5",
1033
+ "w-5 h-5 rounded-full",
1034
+ "bg-text-muted/80 text-white",
1035
+ "flex items-center justify-center",
1036
+ "opacity-0 group-hover:opacity-100",
1037
+ "transition-opacity duration-150",
1038
+ "hover:bg-text-primary"
1039
+ ),
1040
+ "aria-label": `Remove ${attachment.filename}`,
1041
+ children: /* @__PURE__ */ jsx9(XIcon, { size: 10 })
1042
+ }
1043
+ )
1044
+ ] });
1045
+ }
793
1046
  function MessageComposer({
794
1047
  onSend,
795
1048
  isLoading = false,
@@ -797,23 +1050,29 @@ function MessageComposer({
797
1050
  className
798
1051
  }) {
799
1052
  const [value, setValue] = useState2("");
1053
+ const [attachments, setAttachments] = useState2([]);
1054
+ const [dragOver, setDragOver] = useState2(false);
800
1055
  const textareaRef = useRef3(null);
801
- const canSend = value.trim().length > 0 && !isLoading;
802
- const resetHeight = useCallback2(() => {
1056
+ const fileInputRef = useRef3(null);
1057
+ const canSend = (value.trim().length > 0 || attachments.length > 0) && !isLoading;
1058
+ const resetHeight = useCallback3(() => {
803
1059
  const el = textareaRef.current;
804
1060
  if (el) {
805
1061
  el.style.height = "auto";
806
1062
  el.style.overflowY = "hidden";
807
1063
  }
808
1064
  }, []);
809
- const handleSend = useCallback2(() => {
1065
+ const handleSend = useCallback3(() => {
810
1066
  if (!canSend) return;
811
- onSend(value.trim());
1067
+ const message = value.trim() || (attachments.length > 0 ? "Please analyse the attached file(s)." : "");
1068
+ if (!message && attachments.length === 0) return;
1069
+ onSend(message, attachments.length > 0 ? attachments : void 0);
812
1070
  setValue("");
1071
+ setAttachments([]);
813
1072
  resetHeight();
814
1073
  textareaRef.current?.focus();
815
- }, [canSend, onSend, value, resetHeight]);
816
- const handleKeyDown = useCallback2(
1074
+ }, [canSend, onSend, value, attachments, resetHeight]);
1075
+ const handleKeyDown = useCallback3(
817
1076
  (e) => {
818
1077
  if (e.key === "Enter" && !e.shiftKey) {
819
1078
  e.preventDefault();
@@ -822,64 +1081,194 @@ function MessageComposer({
822
1081
  },
823
1082
  [handleSend]
824
1083
  );
825
- const handleChange = useCallback2(
1084
+ const handleChange = useCallback3(
826
1085
  (e) => {
827
1086
  setValue(e.target.value);
828
1087
  const el = e.target;
829
1088
  el.style.height = "auto";
830
- const capped = Math.min(el.scrollHeight, 128);
1089
+ const capped = Math.min(el.scrollHeight, 200);
831
1090
  el.style.height = `${capped}px`;
832
- el.style.overflowY = el.scrollHeight > 128 ? "auto" : "hidden";
1091
+ el.style.overflowY = el.scrollHeight > 200 ? "auto" : "hidden";
833
1092
  },
834
1093
  []
835
1094
  );
1095
+ const addFiles = useCallback3(async (files) => {
1096
+ const fileArray = Array.from(files);
1097
+ for (const file of fileArray) {
1098
+ if (attachments.length >= MAX_ATTACHMENTS) break;
1099
+ if (!ALLOWED_TYPES.has(file.type)) continue;
1100
+ if (file.size > MAX_FILE_SIZE) continue;
1101
+ try {
1102
+ const data = await fileToBase64(file);
1103
+ const previewUrl = file.type.startsWith("image/") ? URL.createObjectURL(file) : void 0;
1104
+ const attachment = {
1105
+ filename: file.name,
1106
+ content_type: file.type,
1107
+ data,
1108
+ preview_url: previewUrl
1109
+ };
1110
+ setAttachments((prev) => {
1111
+ if (prev.length >= MAX_ATTACHMENTS) return prev;
1112
+ return [...prev, attachment];
1113
+ });
1114
+ } catch {
1115
+ }
1116
+ }
1117
+ }, [attachments.length]);
1118
+ const handleFileSelect = useCallback3(() => {
1119
+ fileInputRef.current?.click();
1120
+ }, []);
1121
+ const handleFileInputChange = useCallback3(
1122
+ (e) => {
1123
+ if (e.target.files) {
1124
+ void addFiles(e.target.files);
1125
+ e.target.value = "";
1126
+ }
1127
+ },
1128
+ [addFiles]
1129
+ );
1130
+ const removeAttachment = useCallback3((index) => {
1131
+ setAttachments((prev) => {
1132
+ const removed = prev[index];
1133
+ if (removed?.preview_url) URL.revokeObjectURL(removed.preview_url);
1134
+ return prev.filter((_, i) => i !== index);
1135
+ });
1136
+ }, []);
1137
+ const handlePaste = useCallback3(
1138
+ (e) => {
1139
+ const items = e.clipboardData.items;
1140
+ const files = [];
1141
+ for (const item of items) {
1142
+ if (item.kind === "file" && ALLOWED_TYPES.has(item.type)) {
1143
+ const file = item.getAsFile();
1144
+ if (file) files.push(file);
1145
+ }
1146
+ }
1147
+ if (files.length > 0) {
1148
+ void addFiles(files);
1149
+ }
1150
+ },
1151
+ [addFiles]
1152
+ );
1153
+ const handleDragOver = useCallback3((e) => {
1154
+ e.preventDefault();
1155
+ e.stopPropagation();
1156
+ setDragOver(true);
1157
+ }, []);
1158
+ const handleDragLeave = useCallback3((e) => {
1159
+ e.preventDefault();
1160
+ e.stopPropagation();
1161
+ setDragOver(false);
1162
+ }, []);
1163
+ const handleDrop = useCallback3(
1164
+ (e) => {
1165
+ e.preventDefault();
1166
+ e.stopPropagation();
1167
+ setDragOver(false);
1168
+ if (e.dataTransfer.files.length > 0) {
1169
+ void addFiles(e.dataTransfer.files);
1170
+ }
1171
+ },
1172
+ [addFiles]
1173
+ );
836
1174
  return /* @__PURE__ */ jsxs7(
837
1175
  "div",
838
1176
  {
839
1177
  className: twMerge6(
840
- "flex items-end gap-3 shrink-0 border-t border-border px-4 py-3",
1178
+ "relative shrink-0 rounded-3xl border bg-surface",
1179
+ "shadow-lg shadow-black/10",
1180
+ "transition-all duration-200",
1181
+ "focus-within:border-accent/40 focus-within:shadow-accent/5",
1182
+ dragOver ? "border-accent/60 bg-accent/5" : "border-border/60",
841
1183
  className
842
1184
  ),
1185
+ onDragOver: handleDragOver,
1186
+ onDragLeave: handleDragLeave,
1187
+ onDrop: handleDrop,
843
1188
  children: [
844
1189
  /* @__PURE__ */ jsx9(
845
- "textarea",
1190
+ "input",
846
1191
  {
847
- ref: textareaRef,
848
- value,
849
- onChange: handleChange,
850
- onKeyDown: handleKeyDown,
851
- placeholder,
852
- rows: 1,
853
- disabled: isLoading,
854
- className: twMerge6(
855
- "flex-1 resize-none rounded-xl border border-border bg-surface/80",
856
- "px-4 py-2.5 text-sm text-text-primary placeholder:text-text-muted",
857
- "focus:border-transparent focus:ring-2 focus:ring-accent/40 focus:outline-none",
858
- "disabled:opacity-50 disabled:cursor-not-allowed",
859
- "overflow-hidden",
860
- "transition-all duration-200"
861
- ),
862
- style: { colorScheme: "dark" },
863
- "aria-label": "Message input"
1192
+ ref: fileInputRef,
1193
+ type: "file",
1194
+ multiple: true,
1195
+ accept: "image/png,image/jpeg,image/gif,image/webp,application/pdf",
1196
+ onChange: handleFileInputChange,
1197
+ className: "hidden",
1198
+ "aria-hidden": "true"
864
1199
  }
865
1200
  ),
866
- /* @__PURE__ */ jsx9(
867
- "button",
1201
+ 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(
1202
+ AttachmentPreview,
868
1203
  {
869
- type: "button",
870
- onClick: handleSend,
871
- disabled: !value.trim() || isLoading,
872
- "aria-label": "Send message",
873
- className: twMerge6(
874
- "inline-flex items-center justify-center rounded-xl px-5 py-2.5",
875
- "text-sm font-semibold text-white shrink-0",
876
- "transition-all duration-200",
877
- "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent",
878
- 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"
879
- ),
880
- children: "Send"
881
- }
882
- )
1204
+ attachment: att,
1205
+ onRemove: () => removeAttachment(i)
1206
+ },
1207
+ `${att.filename}-${i}`
1208
+ )) }),
1209
+ 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" }) }),
1210
+ /* @__PURE__ */ jsxs7("div", { className: "flex items-end", children: [
1211
+ /* @__PURE__ */ jsx9(
1212
+ "button",
1213
+ {
1214
+ type: "button",
1215
+ onClick: handleFileSelect,
1216
+ disabled: isLoading || attachments.length >= MAX_ATTACHMENTS,
1217
+ "aria-label": "Attach file",
1218
+ className: twMerge6(
1219
+ "flex-shrink-0 ml-2 mb-3",
1220
+ "inline-flex items-center justify-center",
1221
+ "w-9 h-9 rounded-full",
1222
+ "transition-all duration-200",
1223
+ "text-text-muted/60 hover:text-text-secondary hover:bg-text-muted/10",
1224
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent",
1225
+ "disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent"
1226
+ ),
1227
+ children: /* @__PURE__ */ jsx9(PaperclipIcon, {})
1228
+ }
1229
+ ),
1230
+ /* @__PURE__ */ jsx9(
1231
+ "textarea",
1232
+ {
1233
+ ref: textareaRef,
1234
+ value,
1235
+ onChange: handleChange,
1236
+ onKeyDown: handleKeyDown,
1237
+ onPaste: handlePaste,
1238
+ placeholder,
1239
+ rows: 1,
1240
+ disabled: isLoading,
1241
+ className: twMerge6(
1242
+ "flex-1 resize-none bg-transparent",
1243
+ "pl-2 pr-14 pt-4 pb-4 text-[15px] leading-relaxed",
1244
+ "text-text-primary placeholder:text-text-muted/70",
1245
+ "focus:outline-none",
1246
+ "disabled:opacity-50 disabled:cursor-not-allowed",
1247
+ "overflow-hidden"
1248
+ ),
1249
+ style: { colorScheme: "dark" },
1250
+ "aria-label": "Message input"
1251
+ }
1252
+ ),
1253
+ /* @__PURE__ */ jsx9(
1254
+ "button",
1255
+ {
1256
+ type: "button",
1257
+ onClick: handleSend,
1258
+ disabled: !canSend,
1259
+ "aria-label": "Send message",
1260
+ className: twMerge6(
1261
+ "absolute bottom-3 right-3",
1262
+ "inline-flex items-center justify-center",
1263
+ "w-9 h-9 rounded-full",
1264
+ "transition-all duration-200",
1265
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent",
1266
+ 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"
1267
+ ),
1268
+ children: isLoading ? /* @__PURE__ */ jsx9(StopIcon, {}) : /* @__PURE__ */ jsx9(ArrowUpIcon, {})
1269
+ }
1270
+ )
1271
+ ] })
883
1272
  ]
884
1273
  }
885
1274
  );
@@ -892,6 +1281,7 @@ function WelcomeScreen({
892
1281
  title = "Welcome",
893
1282
  message = "How can I help you today?",
894
1283
  icon,
1284
+ iconClassName,
895
1285
  suggestedQuestions = [],
896
1286
  onQuestionSelect,
897
1287
  className
@@ -904,12 +1294,15 @@ function WelcomeScreen({
904
1294
  className
905
1295
  ),
906
1296
  children: [
907
- /* @__PURE__ */ jsx10(
1297
+ icon ? iconClassName ? /* @__PURE__ */ jsx10("div", { className: iconClassName, "aria-hidden": "true", children: icon }) : icon : /* @__PURE__ */ jsx10(
908
1298
  "div",
909
1299
  {
910
- className: "w-14 h-14 rounded-2xl bg-accent/10 border border-border flex items-center justify-center pulse-glow",
1300
+ className: twMerge7(
1301
+ "w-14 h-14 rounded-2xl bg-accent/10 border border-border flex items-center justify-center pulse-glow",
1302
+ iconClassName
1303
+ ),
911
1304
  "aria-hidden": "true",
912
- children: icon ?? /* @__PURE__ */ jsx10("span", { className: "text-2xl", children: "\u2726" })
1305
+ children: /* @__PURE__ */ jsx10("span", { className: "text-2xl", children: "\u2726" })
913
1306
  }
914
1307
  ),
915
1308
  /* @__PURE__ */ jsxs8("div", { className: "flex flex-col gap-2", children: [
@@ -919,7 +1312,7 @@ function WelcomeScreen({
919
1312
  suggestedQuestions.length > 0 && /* @__PURE__ */ jsx10(
920
1313
  "div",
921
1314
  {
922
- className: "flex flex-wrap justify-center gap-2 max-w-md",
1315
+ className: "flex flex-wrap justify-center gap-2 max-w-xl",
923
1316
  role: "group",
924
1317
  "aria-label": "Suggested questions",
925
1318
  children: suggestedQuestions.map((question) => /* @__PURE__ */ jsx10(
@@ -928,7 +1321,7 @@ function WelcomeScreen({
928
1321
  type: "button",
929
1322
  onClick: () => onQuestionSelect?.(question),
930
1323
  className: twMerge7(
931
- "px-4 py-2 rounded-full text-sm",
1324
+ "px-3.5 py-1.5 rounded-full text-[12px]",
932
1325
  "border border-border bg-transparent text-text-secondary",
933
1326
  "hover:bg-accent/10 hover:border-interactive hover:text-text-primary",
934
1327
  "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent",
@@ -947,7 +1340,8 @@ function WelcomeScreen({
947
1340
 
948
1341
  // src/streaming/StreamingMessage/StreamingMessage.tsx
949
1342
  import { useEffect as useEffect3, useRef as useRef5 } from "react";
950
- import { Spinner } from "@surf-kit/core";
1343
+ import { twMerge as twMerge8 } from "tailwind-merge";
1344
+ import { WaveLoader } from "@surf-kit/core";
951
1345
 
952
1346
  // src/hooks/useCharacterDrain.ts
953
1347
  import { useState as useState3, useRef as useRef4, useEffect as useEffect2 } from "react";
@@ -976,7 +1370,10 @@ function useCharacterDrain(target, msPerChar = 15) {
976
1370
  const elapsed = now - lastTimeRef.current;
977
1371
  const charsToAdvance = Math.floor(elapsed / msPerCharRef.current);
978
1372
  if (charsToAdvance > 0 && indexRef.current < currentTarget.length) {
979
- const nextIndex = Math.min(indexRef.current + charsToAdvance, currentTarget.length);
1373
+ let nextIndex = Math.min(indexRef.current + charsToAdvance, currentTarget.length);
1374
+ while (nextIndex < currentTarget.length && currentTarget[nextIndex - 1].trim() === "") {
1375
+ nextIndex++;
1376
+ }
980
1377
  indexRef.current = nextIndex;
981
1378
  lastTimeRef.current = now;
982
1379
  setDisplayed(currentTarget.slice(0, nextIndex));
@@ -1021,14 +1418,35 @@ var phaseLabels = {
1021
1418
  generating: "Writing...",
1022
1419
  verifying: "Verifying..."
1023
1420
  };
1421
+ var CURSOR_STYLES = `
1422
+ .sk-streaming-cursor > :not(ul,ol,blockquote):last-child::after,
1423
+ .sk-streaming-cursor > :is(ul,ol):last-child > li:last-child::after,
1424
+ .sk-streaming-cursor > blockquote:last-child > p:last-child::after {
1425
+ content: "";
1426
+ display: inline-block;
1427
+ width: 2px;
1428
+ height: 1em;
1429
+ background: var(--color-accent, #38bdf8);
1430
+ animation: sk-cursor-blink 0.8s steps(1) infinite;
1431
+ margin-left: 2px;
1432
+ vertical-align: text-bottom;
1433
+ }
1434
+ @keyframes sk-cursor-blink {
1435
+ 0%, 60% { opacity: 1; }
1436
+ 61%, 100% { opacity: 0; }
1437
+ }
1438
+ `;
1024
1439
  function StreamingMessage({
1025
1440
  stream,
1026
1441
  onComplete,
1442
+ onDraining,
1027
1443
  showPhases = true,
1028
1444
  className
1029
1445
  }) {
1030
1446
  const onCompleteRef = useRef5(onComplete);
1031
1447
  onCompleteRef.current = onComplete;
1448
+ const onDrainingRef = useRef5(onDraining);
1449
+ onDrainingRef.current = onDraining;
1032
1450
  const wasActiveRef = useRef5(stream.active);
1033
1451
  useEffect3(() => {
1034
1452
  if (wasActiveRef.current && !stream.active) {
@@ -1037,35 +1455,40 @@ function StreamingMessage({
1037
1455
  wasActiveRef.current = stream.active;
1038
1456
  }, [stream.active]);
1039
1457
  const phaseLabel = phaseLabels[stream.phase];
1040
- const { displayed: displayedContent } = useCharacterDrain(stream.content);
1041
- return /* @__PURE__ */ jsxs9("div", { className, "data-testid": "streaming-message", children: [
1458
+ const { displayed: rawDisplayed, isDraining } = useCharacterDrain(stream.content);
1459
+ const displayedContent = stream.active || isDraining ? rawDisplayed.trimEnd() : rawDisplayed;
1460
+ useEffect3(() => {
1461
+ onDrainingRef.current?.(isDraining);
1462
+ }, [isDraining]);
1463
+ const agentLabel = stream.agent ? stream.agent.replace("_agent", "").replace("_", " ") : null;
1464
+ const showPhaseIndicator = showPhases && stream.active && stream.phase !== "idle" && !displayedContent;
1465
+ const showCursor = (stream.active || isDraining) && !!displayedContent;
1466
+ return /* @__PURE__ */ jsxs9("div", { className: twMerge8("flex w-full flex-col items-start", className), "data-testid": "streaming-message", children: [
1042
1467
  /* @__PURE__ */ jsxs9("div", { "aria-live": "assertive", className: "sr-only", children: [
1043
1468
  stream.active && stream.phase !== "idle" && "Response started",
1044
1469
  !stream.active && stream.content && "Response complete"
1045
1470
  ] }),
1471
+ showCursor && /* @__PURE__ */ jsx11("style", { children: CURSOR_STYLES }),
1472
+ 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 }),
1046
1473
  /* @__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: [
1047
- showPhases && stream.active && stream.phase !== "idle" && /* @__PURE__ */ jsxs9(
1474
+ showPhaseIndicator && /* @__PURE__ */ jsxs9(
1048
1475
  "div",
1049
1476
  {
1050
- className: "flex items-center gap-2 mb-2 text-sm text-text-secondary",
1477
+ className: "flex items-center gap-2 text-sm text-text-secondary",
1051
1478
  "data-testid": "phase-indicator",
1052
1479
  children: [
1053
- /* @__PURE__ */ jsx11("span", { "aria-hidden": "true", children: /* @__PURE__ */ jsx11(Spinner, { size: "sm" }) }),
1480
+ /* @__PURE__ */ jsx11("span", { "aria-hidden": "true", children: /* @__PURE__ */ jsx11(WaveLoader, { size: "sm", color: "#38bdf8" }) }),
1054
1481
  /* @__PURE__ */ jsx11("span", { children: phaseLabel })
1055
1482
  ]
1056
1483
  }
1057
1484
  ),
1058
- /* @__PURE__ */ jsxs9("div", { className: "text-sm leading-relaxed text-text-primary whitespace-pre-wrap", children: [
1059
- displayedContent,
1060
- stream.active && /* @__PURE__ */ jsx11(
1061
- "span",
1062
- {
1063
- className: "inline-block w-0.5 h-4 bg-accent align-text-bottom animate-pulse ml-0.5",
1064
- "aria-hidden": "true",
1065
- "data-testid": "streaming-cursor"
1066
- }
1067
- )
1068
- ] })
1485
+ displayedContent && /* @__PURE__ */ jsx11(
1486
+ ResponseMessage,
1487
+ {
1488
+ content: displayedContent,
1489
+ className: showCursor ? "sk-streaming-cursor" : void 0
1490
+ }
1491
+ )
1069
1492
  ] })
1070
1493
  ] });
1071
1494
  }
@@ -1096,7 +1519,7 @@ function AgentChat({
1096
1519
  return /* @__PURE__ */ jsxs10(
1097
1520
  "div",
1098
1521
  {
1099
- className: twMerge8(
1522
+ className: twMerge9(
1100
1523
  "flex flex-col h-full bg-canvas border border-border rounded-xl overflow-hidden",
1101
1524
  className
1102
1525
  ),
@@ -1139,7 +1562,7 @@ function AgentChat({
1139
1562
  }
1140
1563
 
1141
1564
  // src/chat/ConversationList/ConversationList.tsx
1142
- import { twMerge as twMerge9 } from "tailwind-merge";
1565
+ import { twMerge as twMerge10 } from "tailwind-merge";
1143
1566
  import { jsx as jsx13, jsxs as jsxs11 } from "react/jsx-runtime";
1144
1567
  function ConversationList({
1145
1568
  conversations,
@@ -1153,7 +1576,7 @@ function ConversationList({
1153
1576
  "nav",
1154
1577
  {
1155
1578
  "aria-label": "Conversation list",
1156
- className: twMerge9("flex flex-col h-full bg-canvas", className),
1579
+ className: twMerge10("flex flex-col h-full bg-canvas", className),
1157
1580
  children: [
1158
1581
  onNew && /* @__PURE__ */ jsx13("div", { className: "p-3 border-b border-border", children: /* @__PURE__ */ jsx13(
1159
1582
  "button",
@@ -1170,7 +1593,7 @@ function ConversationList({
1170
1593
  return /* @__PURE__ */ jsxs11(
1171
1594
  "li",
1172
1595
  {
1173
- className: twMerge9(
1596
+ className: twMerge10(
1174
1597
  "flex items-start border-b border-border transition-colors duration-200",
1175
1598
  "hover:bg-surface",
1176
1599
  isActive && "bg-surface-raised border-l-2 border-l-accent"