@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,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  // src/chat/AgentChat/AgentChat.tsx
4
- import { twMerge as twMerge8 } from "tailwind-merge";
4
+ import { twMerge as twMerge9 } from "tailwind-merge";
5
5
 
6
6
  // src/hooks/useAgentChat.ts
7
7
  import { useReducer, useCallback, useRef } from "react";
@@ -12,7 +12,8 @@ var initialState = {
12
12
  error: null,
13
13
  inputValue: "",
14
14
  streamPhase: "idle",
15
- streamingContent: ""
15
+ streamingContent: "",
16
+ streamingAgent: null
16
17
  };
17
18
  function reducer(state, action) {
18
19
  switch (action.type) {
@@ -26,12 +27,15 @@ function reducer(state, action) {
26
27
  error: null,
27
28
  inputValue: "",
28
29
  streamPhase: "thinking",
29
- streamingContent: ""
30
+ streamingContent: "",
31
+ streamingAgent: null
30
32
  };
31
33
  case "STREAM_PHASE":
32
34
  return { ...state, streamPhase: action.phase };
33
35
  case "STREAM_CONTENT":
34
36
  return { ...state, streamingContent: state.streamingContent + action.content };
37
+ case "STREAM_AGENT":
38
+ return { ...state, streamingAgent: action.agent };
35
39
  case "SEND_SUCCESS":
36
40
  return {
37
41
  ...state,
@@ -47,7 +51,8 @@ function reducer(state, action) {
47
51
  isLoading: false,
48
52
  error: action.error,
49
53
  streamPhase: "idle",
50
- streamingContent: ""
54
+ streamingContent: "",
55
+ streamingAgent: null
51
56
  };
52
57
  case "LOAD_CONVERSATION":
53
58
  return {
@@ -73,107 +78,142 @@ function useAgentChat(config) {
73
78
  const configRef = useRef(config);
74
79
  configRef.current = config;
75
80
  const lastUserMessageRef = useRef(null);
81
+ const lastUserAttachmentsRef = useRef(void 0);
76
82
  const sendMessage = useCallback(
77
- async (content) => {
78
- 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 ?? {};
79
86
  lastUserMessageRef.current = content;
87
+ lastUserAttachmentsRef.current = attachments;
80
88
  const userMessage = {
81
89
  id: generateMessageId(),
82
90
  role: "user",
83
91
  content,
92
+ attachments,
84
93
  timestamp: /* @__PURE__ */ new Date()
85
94
  };
86
95
  dispatch({ type: "SEND_START", message: userMessage });
87
96
  const controller = new AbortController();
88
97
  const timeoutId = setTimeout(() => controller.abort(), timeout);
89
98
  try {
90
- const response = await fetch(`${apiUrl}${streamPath}`, {
91
- method: "POST",
92
- headers: {
93
- "Content-Type": "application/json",
94
- Accept: "text/event-stream",
95
- ...headers
96
- },
97
- body: JSON.stringify({
98
- message: content,
99
- conversation_id: state.conversationId
100
- }),
101
- signal: controller.signal
102
- });
103
- clearTimeout(timeoutId);
104
- if (!response.ok) {
105
- dispatch({
106
- type: "SEND_ERROR",
107
- error: {
108
- code: "API_ERROR",
109
- message: `HTTP ${response.status}: ${response.statusText}`,
110
- retryable: response.status >= 500
111
- }
112
- });
113
- 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
+ }));
114
116
  }
115
- const reader = response.body?.getReader();
116
- if (!reader) {
117
- dispatch({
118
- type: "SEND_ERROR",
119
- 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
120
162
  });
121
- return;
122
- }
123
- const decoder = new TextDecoder();
124
- let buffer = "";
125
- let accumulatedContent = "";
126
- let agentResponse = null;
127
- let capturedAgent = null;
128
- let capturedConversationId = null;
129
- while (true) {
130
- const { done, value } = await reader.read();
131
- if (done) break;
132
- buffer += decoder.decode(value, { stream: true });
133
- const lines = buffer.split("\n");
134
- buffer = lines.pop() ?? "";
135
- for (const line of lines) {
136
- if (!line.startsWith("data: ")) continue;
137
- const data = line.slice(6).trim();
138
- if (data === "[DONE]") continue;
139
- try {
140
- const event = JSON.parse(data);
141
- switch (event.type) {
142
- case "agent":
143
- capturedAgent = event.agent;
144
- break;
145
- case "phase":
146
- dispatch({ type: "STREAM_PHASE", phase: event.phase });
147
- break;
148
- case "delta":
149
- accumulatedContent += event.content;
150
- dispatch({ type: "STREAM_CONTENT", content: event.content });
151
- break;
152
- case "done":
153
- agentResponse = event.response;
154
- capturedConversationId = event.conversation_id ?? null;
155
- break;
156
- case "error":
157
- dispatch({ type: "SEND_ERROR", error: event.error });
158
- 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 {
159
199
  }
160
- } catch {
161
200
  }
162
201
  }
163
202
  }
203
+ if (ctx.hadStreamError) return;
164
204
  const assistantMessage = {
165
205
  id: generateMessageId(),
166
206
  role: "assistant",
167
- content: agentResponse?.message ?? accumulatedContent,
168
- response: agentResponse ?? void 0,
169
- agent: capturedAgent ?? void 0,
207
+ content: ctx.agentResponse?.message ?? ctx.accumulatedContent,
208
+ response: ctx.agentResponse ?? void 0,
209
+ agent: ctx.capturedAgent ?? void 0,
170
210
  timestamp: /* @__PURE__ */ new Date()
171
211
  };
172
212
  dispatch({
173
213
  type: "SEND_SUCCESS",
174
214
  message: assistantMessage,
175
- streamingContent: accumulatedContent,
176
- conversationId: capturedConversationId
215
+ streamingContent: ctx.accumulatedContent,
216
+ conversationId: ctx.capturedConversationId
177
217
  });
178
218
  } catch (err) {
179
219
  clearTimeout(timeoutId);
@@ -204,7 +244,8 @@ function useAgentChat(config) {
204
244
  }, []);
205
245
  const submitFeedback = useCallback(
206
246
  async (messageId, rating, comment) => {
207
- 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 ?? {};
208
249
  await fetch(`${apiUrl}${feedbackPath}`, {
209
250
  method: "POST",
210
251
  headers: { "Content-Type": "application/json", ...headers },
@@ -215,12 +256,13 @@ function useAgentChat(config) {
215
256
  );
216
257
  const retry = useCallback(async () => {
217
258
  if (lastUserMessageRef.current) {
218
- await sendMessage(lastUserMessageRef.current);
259
+ await sendMessage(lastUserMessageRef.current, lastUserAttachmentsRef.current);
219
260
  }
220
261
  }, [sendMessage]);
221
262
  const reset = useCallback(() => {
222
263
  dispatch({ type: "RESET" });
223
264
  lastUserMessageRef.current = null;
265
+ lastUserAttachmentsRef.current = void 0;
224
266
  }, []);
225
267
  const actions = {
226
268
  sendMessage,
@@ -235,7 +277,7 @@ function useAgentChat(config) {
235
277
 
236
278
  // src/chat/MessageThread/MessageThread.tsx
237
279
  import { twMerge as twMerge5 } from "tailwind-merge";
238
- import { useEffect, useRef as useRef2 } from "react";
280
+ import { useCallback as useCallback2, useEffect, useRef as useRef2 } from "react";
239
281
 
240
282
  // src/chat/MessageBubble/MessageBubble.tsx
241
283
  import { twMerge as twMerge4 } from "tailwind-merge";
@@ -244,6 +286,7 @@ import { twMerge as twMerge4 } from "tailwind-merge";
244
286
  import { Badge as Badge2 } from "@surf-kit/core";
245
287
 
246
288
  // src/response/ResponseMessage/ResponseMessage.tsx
289
+ import React from "react";
247
290
  import ReactMarkdown from "react-markdown";
248
291
  import rehypeSanitize from "rehype-sanitize";
249
292
  import { twMerge } from "tailwind-merge";
@@ -268,6 +311,7 @@ function ResponseMessage({ content, className }) {
268
311
  "[&_h3]:text-sm [&_h3]:font-semibold [&_h3]:text-accent [&_h3]:mt-2 [&_h3]:mb-1",
269
312
  "[&_code]:bg-surface-raised [&_code]:text-accent [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_code]:font-mono",
270
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",
271
315
  "[&_blockquote]:border-l-2 [&_blockquote]:border-border-strong [&_blockquote]:pl-4 [&_blockquote]:text-text-secondary",
272
316
  "[&_a]:text-accent [&_a]:underline-offset-2 [&_a]:hover:text-accent/80",
273
317
  className
@@ -283,11 +327,24 @@ function ResponseMessage({ content, className }) {
283
327
  p: ({ children }) => /* @__PURE__ */ jsx("p", { className: "my-2", children }),
284
328
  ul: ({ children }) => /* @__PURE__ */ jsx("ul", { className: "my-2 list-disc pl-6", children }),
285
329
  ol: ({ children }) => /* @__PURE__ */ jsx("ol", { className: "my-2 list-decimal pl-6", children }),
286
- 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
+ },
287
342
  strong: ({ children }) => /* @__PURE__ */ jsx("strong", { className: "font-semibold", children }),
343
+ em: ({ children }) => /* @__PURE__ */ jsx("em", { className: "italic text-text-secondary", children }),
288
344
  h1: ({ children }) => /* @__PURE__ */ jsx("h1", { className: "text-base font-bold mt-4 mb-2", children }),
289
345
  h2: ({ children }) => /* @__PURE__ */ jsx("h2", { className: "text-sm font-bold mt-3 mb-1", children }),
290
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" }),
291
348
  code: ({ children }) => /* @__PURE__ */ jsx("code", { className: "bg-surface-sunken rounded px-1 py-0.5 text-xs font-mono", children })
292
349
  },
293
350
  children: normalizeMarkdownLists(content)
@@ -423,7 +480,14 @@ function renderWarning(data) {
423
480
  }
424
481
  );
425
482
  }
426
- 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;
427
491
  if (!data) return null;
428
492
  let content;
429
493
  switch (uiHint) {
@@ -503,7 +567,36 @@ function SourceCard({ source, variant = "compact", onNavigate, className }) {
503
567
  children: [
504
568
  /* @__PURE__ */ jsxs2("div", { className: "flex items-start justify-between gap-2", children: [
505
569
  /* @__PURE__ */ jsxs2("div", { className: "flex-1 min-w-0", children: [
506
- /* @__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 }),
507
600
  source.section && /* @__PURE__ */ jsx3("p", { className: "text-[11px] font-semibold uppercase tracking-wider text-text-secondary truncate mt-0.5", children: source.section })
508
601
  ] }),
509
602
  /* @__PURE__ */ jsx3(
@@ -637,13 +730,16 @@ function AgentResponse({
637
730
  }) {
638
731
  return /* @__PURE__ */ jsxs4("div", { className: `flex flex-col gap-4 ${className ?? ""}`, "data-testid": "agent-response", children: [
639
732
  /* @__PURE__ */ jsx6(ResponseMessage, { content: response.message }),
640
- response.ui_hint !== "text" && response.structured_data && /* @__PURE__ */ jsx6(
641
- StructuredResponse,
642
- {
643
- uiHint: response.ui_hint,
644
- data: response.structured_data
645
- }
646
- ),
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
+ })(),
647
743
  (showConfidence || showVerification) && /* @__PURE__ */ jsxs4("div", { className: "flex flex-wrap items-center gap-2 mt-1", "data-testid": "response-meta", children: [
648
744
  showConfidence && /* @__PURE__ */ jsxs4(
649
745
  Badge2,
@@ -694,6 +790,31 @@ function AgentResponse({
694
790
 
695
791
  // src/chat/MessageBubble/MessageBubble.tsx
696
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
+ }
697
818
  function MessageBubble({
698
819
  message,
699
820
  showAgent,
@@ -701,23 +822,29 @@ function MessageBubble({
701
822
  showConfidence = true,
702
823
  showVerification = true,
703
824
  animated = true,
825
+ userBubbleClassName,
704
826
  className
705
827
  }) {
706
828
  const isUser = message.role === "user";
829
+ const hasAttachments = message.attachments && message.attachments.length > 0;
707
830
  if (isUser) {
708
831
  return /* @__PURE__ */ jsx7(
709
832
  "div",
710
833
  {
711
834
  "data-message-id": message.id,
712
835
  className: twMerge4("flex w-full justify-end", className),
713
- children: /* @__PURE__ */ jsx7(
836
+ children: /* @__PURE__ */ jsxs5(
714
837
  "div",
715
838
  {
716
839
  className: twMerge4(
717
- "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",
718
- 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
719
843
  ),
720
- 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
+ ]
721
848
  }
722
849
  )
723
850
  }
@@ -729,7 +856,7 @@ function MessageBubble({
729
856
  "data-message-id": message.id,
730
857
  className: twMerge4("flex w-full flex-col items-start gap-1.5", className),
731
858
  children: [
732
- 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("_", " ") }),
733
860
  /* @__PURE__ */ jsx7(
734
861
  "div",
735
862
  {
@@ -755,34 +882,70 @@ function MessageBubble({
755
882
 
756
883
  // src/chat/MessageThread/MessageThread.tsx
757
884
  import { jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
758
- function MessageThread({ messages, streamingSlot, showSources, showConfidence, showVerification, className }) {
759
- 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]);
760
907
  useEffect(() => {
761
- bottomRef.current?.scrollIntoView?.({ behavior: "smooth" });
762
- }, [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]);
763
917
  return /* @__PURE__ */ jsxs6(
764
918
  "div",
765
919
  {
920
+ ref: scrollRef,
766
921
  role: "log",
767
922
  "aria-live": "polite",
768
923
  "aria-label": "Message thread",
924
+ onScroll: handleScroll,
769
925
  className: twMerge5(
770
926
  "flex flex-col gap-4 overflow-y-auto flex-1 px-4 py-6",
771
927
  className
772
928
  ),
773
929
  children: [
774
- messages.map((message) => /* @__PURE__ */ jsx8(
775
- MessageBubble,
776
- {
777
- message,
778
- showSources,
779
- showConfidence,
780
- showVerification
781
- },
782
- message.id
783
- )),
784
- streamingSlot,
785
- /* @__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
786
949
  ]
787
950
  }
788
951
  );
@@ -790,8 +953,96 @@ function MessageThread({ messages, streamingSlot, showSources, showConfidence, s
790
953
 
791
954
  // src/chat/MessageComposer/MessageComposer.tsx
792
955
  import { twMerge as twMerge6 } from "tailwind-merge";
793
- 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";
794
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
+ }
795
1046
  function MessageComposer({
796
1047
  onSend,
797
1048
  isLoading = false,
@@ -799,23 +1050,29 @@ function MessageComposer({
799
1050
  className
800
1051
  }) {
801
1052
  const [value, setValue] = useState2("");
1053
+ const [attachments, setAttachments] = useState2([]);
1054
+ const [dragOver, setDragOver] = useState2(false);
802
1055
  const textareaRef = useRef3(null);
803
- const canSend = value.trim().length > 0 && !isLoading;
804
- const resetHeight = useCallback2(() => {
1056
+ const fileInputRef = useRef3(null);
1057
+ const canSend = (value.trim().length > 0 || attachments.length > 0) && !isLoading;
1058
+ const resetHeight = useCallback3(() => {
805
1059
  const el = textareaRef.current;
806
1060
  if (el) {
807
1061
  el.style.height = "auto";
808
1062
  el.style.overflowY = "hidden";
809
1063
  }
810
1064
  }, []);
811
- const handleSend = useCallback2(() => {
1065
+ const handleSend = useCallback3(() => {
812
1066
  if (!canSend) return;
813
- 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);
814
1070
  setValue("");
1071
+ setAttachments([]);
815
1072
  resetHeight();
816
1073
  textareaRef.current?.focus();
817
- }, [canSend, onSend, value, resetHeight]);
818
- const handleKeyDown = useCallback2(
1074
+ }, [canSend, onSend, value, attachments, resetHeight]);
1075
+ const handleKeyDown = useCallback3(
819
1076
  (e) => {
820
1077
  if (e.key === "Enter" && !e.shiftKey) {
821
1078
  e.preventDefault();
@@ -824,64 +1081,194 @@ function MessageComposer({
824
1081
  },
825
1082
  [handleSend]
826
1083
  );
827
- const handleChange = useCallback2(
1084
+ const handleChange = useCallback3(
828
1085
  (e) => {
829
1086
  setValue(e.target.value);
830
1087
  const el = e.target;
831
1088
  el.style.height = "auto";
832
- const capped = Math.min(el.scrollHeight, 128);
1089
+ const capped = Math.min(el.scrollHeight, 200);
833
1090
  el.style.height = `${capped}px`;
834
- el.style.overflowY = el.scrollHeight > 128 ? "auto" : "hidden";
1091
+ el.style.overflowY = el.scrollHeight > 200 ? "auto" : "hidden";
835
1092
  },
836
1093
  []
837
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
+ );
838
1174
  return /* @__PURE__ */ jsxs7(
839
1175
  "div",
840
1176
  {
841
1177
  className: twMerge6(
842
- "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",
843
1183
  className
844
1184
  ),
1185
+ onDragOver: handleDragOver,
1186
+ onDragLeave: handleDragLeave,
1187
+ onDrop: handleDrop,
845
1188
  children: [
846
1189
  /* @__PURE__ */ jsx9(
847
- "textarea",
1190
+ "input",
848
1191
  {
849
- ref: textareaRef,
850
- value,
851
- onChange: handleChange,
852
- onKeyDown: handleKeyDown,
853
- placeholder,
854
- rows: 1,
855
- disabled: isLoading,
856
- className: twMerge6(
857
- "flex-1 resize-none rounded-xl border border-border bg-surface/80",
858
- "px-4 py-2.5 text-sm text-text-primary placeholder:text-text-muted",
859
- "focus:border-transparent focus:ring-2 focus:ring-accent/40 focus:outline-none",
860
- "disabled:opacity-50 disabled:cursor-not-allowed",
861
- "overflow-hidden",
862
- "transition-all duration-200"
863
- ),
864
- style: { colorScheme: "dark" },
865
- "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"
866
1199
  }
867
1200
  ),
868
- /* @__PURE__ */ jsx9(
869
- "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,
870
1203
  {
871
- type: "button",
872
- onClick: handleSend,
873
- disabled: !value.trim() || isLoading,
874
- "aria-label": "Send message",
875
- className: twMerge6(
876
- "inline-flex items-center justify-center rounded-xl px-5 py-2.5",
877
- "text-sm font-semibold text-white shrink-0",
878
- "transition-all duration-200",
879
- "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent",
880
- 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"
881
- ),
882
- children: "Send"
883
- }
884
- )
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
+ ] })
885
1272
  ]
886
1273
  }
887
1274
  );
@@ -894,6 +1281,7 @@ function WelcomeScreen({
894
1281
  title = "Welcome",
895
1282
  message = "How can I help you today?",
896
1283
  icon,
1284
+ iconClassName,
897
1285
  suggestedQuestions = [],
898
1286
  onQuestionSelect,
899
1287
  className
@@ -906,12 +1294,15 @@ function WelcomeScreen({
906
1294
  className
907
1295
  ),
908
1296
  children: [
909
- /* @__PURE__ */ jsx10(
1297
+ icon ? iconClassName ? /* @__PURE__ */ jsx10("div", { className: iconClassName, "aria-hidden": "true", children: icon }) : icon : /* @__PURE__ */ jsx10(
910
1298
  "div",
911
1299
  {
912
- 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
+ ),
913
1304
  "aria-hidden": "true",
914
- children: icon ?? /* @__PURE__ */ jsx10("span", { className: "text-2xl", children: "\u2726" })
1305
+ children: /* @__PURE__ */ jsx10("span", { className: "text-2xl", children: "\u2726" })
915
1306
  }
916
1307
  ),
917
1308
  /* @__PURE__ */ jsxs8("div", { className: "flex flex-col gap-2", children: [
@@ -921,7 +1312,7 @@ function WelcomeScreen({
921
1312
  suggestedQuestions.length > 0 && /* @__PURE__ */ jsx10(
922
1313
  "div",
923
1314
  {
924
- className: "flex flex-wrap justify-center gap-2 max-w-md",
1315
+ className: "flex flex-wrap justify-center gap-2 max-w-xl",
925
1316
  role: "group",
926
1317
  "aria-label": "Suggested questions",
927
1318
  children: suggestedQuestions.map((question) => /* @__PURE__ */ jsx10(
@@ -930,7 +1321,7 @@ function WelcomeScreen({
930
1321
  type: "button",
931
1322
  onClick: () => onQuestionSelect?.(question),
932
1323
  className: twMerge7(
933
- "px-4 py-2 rounded-full text-sm",
1324
+ "px-3.5 py-1.5 rounded-full text-[12px]",
934
1325
  "border border-border bg-transparent text-text-secondary",
935
1326
  "hover:bg-accent/10 hover:border-interactive hover:text-text-primary",
936
1327
  "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent",
@@ -949,7 +1340,8 @@ function WelcomeScreen({
949
1340
 
950
1341
  // src/streaming/StreamingMessage/StreamingMessage.tsx
951
1342
  import { useEffect as useEffect3, useRef as useRef5 } from "react";
952
- import { Spinner } from "@surf-kit/core";
1343
+ import { twMerge as twMerge8 } from "tailwind-merge";
1344
+ import { WaveLoader } from "@surf-kit/core";
953
1345
 
954
1346
  // src/hooks/useCharacterDrain.ts
955
1347
  import { useState as useState3, useRef as useRef4, useEffect as useEffect2 } from "react";
@@ -978,7 +1370,10 @@ function useCharacterDrain(target, msPerChar = 15) {
978
1370
  const elapsed = now - lastTimeRef.current;
979
1371
  const charsToAdvance = Math.floor(elapsed / msPerCharRef.current);
980
1372
  if (charsToAdvance > 0 && indexRef.current < currentTarget.length) {
981
- 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
+ }
982
1377
  indexRef.current = nextIndex;
983
1378
  lastTimeRef.current = now;
984
1379
  setDisplayed(currentTarget.slice(0, nextIndex));
@@ -1023,14 +1418,35 @@ var phaseLabels = {
1023
1418
  generating: "Writing...",
1024
1419
  verifying: "Verifying..."
1025
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
+ `;
1026
1439
  function StreamingMessage({
1027
1440
  stream,
1028
1441
  onComplete,
1442
+ onDraining,
1029
1443
  showPhases = true,
1030
1444
  className
1031
1445
  }) {
1032
1446
  const onCompleteRef = useRef5(onComplete);
1033
1447
  onCompleteRef.current = onComplete;
1448
+ const onDrainingRef = useRef5(onDraining);
1449
+ onDrainingRef.current = onDraining;
1034
1450
  const wasActiveRef = useRef5(stream.active);
1035
1451
  useEffect3(() => {
1036
1452
  if (wasActiveRef.current && !stream.active) {
@@ -1039,35 +1455,40 @@ function StreamingMessage({
1039
1455
  wasActiveRef.current = stream.active;
1040
1456
  }, [stream.active]);
1041
1457
  const phaseLabel = phaseLabels[stream.phase];
1042
- const { displayed: displayedContent } = useCharacterDrain(stream.content);
1043
- 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: [
1044
1467
  /* @__PURE__ */ jsxs9("div", { "aria-live": "assertive", className: "sr-only", children: [
1045
1468
  stream.active && stream.phase !== "idle" && "Response started",
1046
1469
  !stream.active && stream.content && "Response complete"
1047
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 }),
1048
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: [
1049
- showPhases && stream.active && stream.phase !== "idle" && /* @__PURE__ */ jsxs9(
1474
+ showPhaseIndicator && /* @__PURE__ */ jsxs9(
1050
1475
  "div",
1051
1476
  {
1052
- 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",
1053
1478
  "data-testid": "phase-indicator",
1054
1479
  children: [
1055
- /* @__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" }) }),
1056
1481
  /* @__PURE__ */ jsx11("span", { children: phaseLabel })
1057
1482
  ]
1058
1483
  }
1059
1484
  ),
1060
- /* @__PURE__ */ jsxs9("div", { className: "text-sm leading-relaxed text-text-primary whitespace-pre-wrap", children: [
1061
- displayedContent,
1062
- stream.active && /* @__PURE__ */ jsx11(
1063
- "span",
1064
- {
1065
- className: "inline-block w-0.5 h-4 bg-accent align-text-bottom animate-pulse ml-0.5",
1066
- "aria-hidden": "true",
1067
- "data-testid": "streaming-cursor"
1068
- }
1069
- )
1070
- ] })
1485
+ displayedContent && /* @__PURE__ */ jsx11(
1486
+ ResponseMessage,
1487
+ {
1488
+ content: displayedContent,
1489
+ className: showCursor ? "sk-streaming-cursor" : void 0
1490
+ }
1491
+ )
1071
1492
  ] })
1072
1493
  ] });
1073
1494
  }
@@ -1098,7 +1519,7 @@ function AgentChat({
1098
1519
  return /* @__PURE__ */ jsxs10(
1099
1520
  "div",
1100
1521
  {
1101
- className: twMerge8(
1522
+ className: twMerge9(
1102
1523
  "flex flex-col h-full bg-canvas border border-border rounded-xl overflow-hidden",
1103
1524
  className
1104
1525
  ),
@@ -1141,7 +1562,7 @@ function AgentChat({
1141
1562
  }
1142
1563
 
1143
1564
  // src/chat/ConversationList/ConversationList.tsx
1144
- import { twMerge as twMerge9 } from "tailwind-merge";
1565
+ import { twMerge as twMerge10 } from "tailwind-merge";
1145
1566
  import { jsx as jsx13, jsxs as jsxs11 } from "react/jsx-runtime";
1146
1567
  function ConversationList({
1147
1568
  conversations,
@@ -1155,7 +1576,7 @@ function ConversationList({
1155
1576
  "nav",
1156
1577
  {
1157
1578
  "aria-label": "Conversation list",
1158
- className: twMerge9("flex flex-col h-full bg-canvas", className),
1579
+ className: twMerge10("flex flex-col h-full bg-canvas", className),
1159
1580
  children: [
1160
1581
  onNew && /* @__PURE__ */ jsx13("div", { className: "p-3 border-b border-border", children: /* @__PURE__ */ jsx13(
1161
1582
  "button",
@@ -1172,7 +1593,7 @@ function ConversationList({
1172
1593
  return /* @__PURE__ */ jsxs11(
1173
1594
  "li",
1174
1595
  {
1175
- className: twMerge9(
1596
+ className: twMerge10(
1176
1597
  "flex items-start border-b border-border transition-colors duration-200",
1177
1598
  "hover:bg-surface",
1178
1599
  isActive && "bg-surface-raised border-l-2 border-l-accent"