@surf-kit/agent 0.2.2 → 0.4.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 +733 -222
  2. package/dist/chat/index.cjs.map +1 -1
  3. package/dist/chat/index.d.cts +13 -7
  4. package/dist/chat/index.d.ts +13 -7
  5. package/dist/chat/index.js +715 -204
  6. package/dist/chat/index.js.map +1 -1
  7. package/dist/{chat-ChYl2XjV.d.cts → chat-BRY3xGg_.d.cts} +11 -2
  8. package/dist/{chat--OifhIRe.d.ts → chat-CcKc6OAR.d.ts} +11 -2
  9. package/dist/{hooks-BGs8-4GK.d.ts → hooks-BLeiVk-x.d.ts} +22 -5
  10. package/dist/{hooks-DLfF18IU.d.cts → hooks-CSGGLd7j.d.cts} +22 -5
  11. package/dist/hooks.cjs +160 -85
  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 +160 -85
  16. package/dist/hooks.js.map +1 -1
  17. package/dist/index.cjs +794 -283
  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 +758 -247
  22. package/dist/index.js.map +1 -1
  23. package/dist/layouts/index.cjs +754 -243
  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 +733 -222
  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 +100 -15
  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 +99 -14
  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 +213 -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 +183 -73
  48. package/dist/streaming/index.js.map +1 -1
  49. package/dist/{streaming-DfT22A0z.d.cts → streaming-BHPXnwwo.d.cts} +3 -1
  50. package/dist/{streaming-DbQxScpi.d.ts → streaming-C6mbU7My.d.ts} +3 -1
  51. package/package.json +17 -5
@@ -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,17 @@ 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_CONTENT_RESET":
38
+ return { ...state, streamingContent: "" };
39
+ case "STREAM_AGENT":
40
+ return { ...state, streamingAgent: action.agent };
35
41
  case "SEND_SUCCESS":
36
42
  return {
37
43
  ...state,
@@ -47,7 +53,8 @@ function reducer(state, action) {
47
53
  isLoading: false,
48
54
  error: action.error,
49
55
  streamPhase: "idle",
50
- streamingContent: ""
56
+ streamingContent: "",
57
+ streamingAgent: null
51
58
  };
52
59
  case "LOAD_CONVERSATION":
53
60
  return {
@@ -73,115 +80,172 @@ function useAgentChat(config) {
73
80
  const configRef = useRef(config);
74
81
  configRef.current = config;
75
82
  const lastUserMessageRef = useRef(null);
83
+ const lastUserAttachmentsRef = useRef(void 0);
84
+ const abortControllerRef = useRef(null);
76
85
  const sendMessage = useCallback(
77
- async (content) => {
78
- const { apiUrl, streamPath = "/chat/stream", headers = {}, timeout = 3e4 } = configRef.current;
86
+ async (content, attachments) => {
87
+ const { apiUrl, streamPath = "/chat/stream", headers: headersOrFn, timeout = 3e4, bodyExtra } = configRef.current;
88
+ const headers = typeof headersOrFn === "function" ? await headersOrFn() : headersOrFn ?? {};
79
89
  lastUserMessageRef.current = content;
90
+ lastUserAttachmentsRef.current = attachments;
80
91
  const userMessage = {
81
92
  id: generateMessageId(),
82
93
  role: "user",
83
94
  content,
95
+ attachments,
84
96
  timestamp: /* @__PURE__ */ new Date()
85
97
  };
86
98
  dispatch({ type: "SEND_START", message: userMessage });
87
99
  const controller = new AbortController();
100
+ abortControllerRef.current = controller;
88
101
  const timeoutId = setTimeout(() => controller.abort(), timeout);
102
+ const ctx = {
103
+ accumulatedContent: "",
104
+ agentResponse: null,
105
+ capturedAgent: null,
106
+ capturedConversationId: null,
107
+ hadStreamError: false
108
+ };
89
109
  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;
110
+ const url = `${apiUrl}${streamPath}`;
111
+ const mergedHeaders = {
112
+ "Content-Type": "application/json",
113
+ Accept: "text/event-stream",
114
+ ...headers
115
+ };
116
+ const requestBody = {
117
+ message: content,
118
+ conversation_id: state.conversationId,
119
+ ...bodyExtra
120
+ };
121
+ if (attachments && attachments.length > 0) {
122
+ requestBody.attachments = attachments.map((a) => ({
123
+ filename: a.filename,
124
+ content_type: a.content_type,
125
+ data: a.data
126
+ }));
114
127
  }
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 }
128
+ const body = JSON.stringify(requestBody);
129
+ const handleEvent = (event) => {
130
+ switch (event.type) {
131
+ case "agent":
132
+ ctx.capturedAgent = event.agent;
133
+ dispatch({ type: "STREAM_AGENT", agent: ctx.capturedAgent });
134
+ break;
135
+ case "phase":
136
+ dispatch({ type: "STREAM_PHASE", phase: event.phase });
137
+ break;
138
+ case "delta":
139
+ ctx.accumulatedContent += event.content;
140
+ dispatch({ type: "STREAM_CONTENT", content: event.content });
141
+ break;
142
+ case "delta_reset":
143
+ ctx.accumulatedContent = "";
144
+ dispatch({ type: "STREAM_CONTENT_RESET" });
145
+ break;
146
+ case "done":
147
+ ctx.agentResponse = event.response;
148
+ ctx.capturedConversationId = event.conversation_id ?? null;
149
+ break;
150
+ case "error":
151
+ ctx.hadStreamError = true;
152
+ dispatch({ type: "SEND_ERROR", error: event.error });
153
+ break;
154
+ }
155
+ };
156
+ const { streamAdapter } = configRef.current;
157
+ if (streamAdapter) {
158
+ await streamAdapter(
159
+ url,
160
+ { method: "POST", headers: mergedHeaders, body, signal: controller.signal },
161
+ handleEvent
162
+ );
163
+ clearTimeout(timeoutId);
164
+ } else {
165
+ const response = await fetch(url, {
166
+ method: "POST",
167
+ headers: mergedHeaders,
168
+ body,
169
+ signal: controller.signal
120
170
  });
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;
171
+ clearTimeout(timeoutId);
172
+ if (!response.ok) {
173
+ dispatch({
174
+ type: "SEND_ERROR",
175
+ error: {
176
+ code: "API_ERROR",
177
+ message: `HTTP ${response.status}: ${response.statusText}`,
178
+ retryable: response.status >= 500
179
+ }
180
+ });
181
+ return;
182
+ }
183
+ const reader = response.body?.getReader();
184
+ if (!reader) {
185
+ dispatch({
186
+ type: "SEND_ERROR",
187
+ error: { code: "STREAM_ERROR", message: "No response body", retryable: true }
188
+ });
189
+ return;
190
+ }
191
+ const decoder = new TextDecoder();
192
+ let buffer = "";
193
+ while (true) {
194
+ const { done, value } = await reader.read();
195
+ if (done) break;
196
+ buffer += decoder.decode(value, { stream: true });
197
+ const lines = buffer.split("\n");
198
+ buffer = lines.pop() ?? "";
199
+ for (const line of lines) {
200
+ if (!line.startsWith("data: ")) continue;
201
+ const data = line.slice(6).trim();
202
+ if (data === "[DONE]") continue;
203
+ try {
204
+ const event = JSON.parse(data);
205
+ handleEvent(event);
206
+ } catch {
159
207
  }
160
- } catch {
161
208
  }
162
209
  }
163
210
  }
211
+ if (ctx.hadStreamError) return;
164
212
  const assistantMessage = {
165
213
  id: generateMessageId(),
166
214
  role: "assistant",
167
- content: agentResponse?.message ?? accumulatedContent,
168
- response: agentResponse ?? void 0,
169
- agent: capturedAgent ?? void 0,
215
+ content: ctx.agentResponse?.message ?? ctx.accumulatedContent,
216
+ response: ctx.agentResponse ?? void 0,
217
+ agent: ctx.capturedAgent ?? void 0,
170
218
  timestamp: /* @__PURE__ */ new Date()
171
219
  };
172
220
  dispatch({
173
221
  type: "SEND_SUCCESS",
174
222
  message: assistantMessage,
175
- streamingContent: accumulatedContent,
176
- conversationId: capturedConversationId
223
+ streamingContent: ctx.accumulatedContent,
224
+ conversationId: ctx.capturedConversationId
177
225
  });
178
226
  } catch (err) {
179
227
  clearTimeout(timeoutId);
180
228
  if (err.name === "AbortError") {
181
- dispatch({
182
- type: "SEND_ERROR",
183
- error: { code: "TIMEOUT", message: "Request timed out", retryable: true }
184
- });
229
+ if (ctx.accumulatedContent) {
230
+ const partialMessage = {
231
+ id: generateMessageId(),
232
+ role: "assistant",
233
+ content: ctx.accumulatedContent,
234
+ agent: ctx.capturedAgent ?? void 0,
235
+ timestamp: /* @__PURE__ */ new Date()
236
+ };
237
+ dispatch({
238
+ type: "SEND_SUCCESS",
239
+ message: partialMessage,
240
+ streamingContent: ctx.accumulatedContent,
241
+ conversationId: ctx.capturedConversationId
242
+ });
243
+ } else {
244
+ dispatch({
245
+ type: "SEND_ERROR",
246
+ error: { code: "ABORTED", message: "Request stopped", retryable: true }
247
+ });
248
+ }
185
249
  } else {
186
250
  dispatch({
187
251
  type: "SEND_ERROR",
@@ -192,6 +256,8 @@ function useAgentChat(config) {
192
256
  }
193
257
  });
194
258
  }
259
+ } finally {
260
+ abortControllerRef.current = null;
195
261
  }
196
262
  },
197
263
  [state.conversationId]
@@ -204,7 +270,8 @@ function useAgentChat(config) {
204
270
  }, []);
205
271
  const submitFeedback = useCallback(
206
272
  async (messageId, rating, comment) => {
207
- const { apiUrl, feedbackPath = "/feedback", headers = {} } = configRef.current;
273
+ const { apiUrl, feedbackPath = "/feedback", headers: headersOrFn } = configRef.current;
274
+ const headers = typeof headersOrFn === "function" ? await headersOrFn() : headersOrFn ?? {};
208
275
  await fetch(`${apiUrl}${feedbackPath}`, {
209
276
  method: "POST",
210
277
  headers: { "Content-Type": "application/json", ...headers },
@@ -215,12 +282,16 @@ function useAgentChat(config) {
215
282
  );
216
283
  const retry = useCallback(async () => {
217
284
  if (lastUserMessageRef.current) {
218
- await sendMessage(lastUserMessageRef.current);
285
+ await sendMessage(lastUserMessageRef.current, lastUserAttachmentsRef.current);
219
286
  }
220
287
  }, [sendMessage]);
288
+ const stop = useCallback(() => {
289
+ abortControllerRef.current?.abort();
290
+ }, []);
221
291
  const reset = useCallback(() => {
222
292
  dispatch({ type: "RESET" });
223
293
  lastUserMessageRef.current = null;
294
+ lastUserAttachmentsRef.current = void 0;
224
295
  }, []);
225
296
  const actions = {
226
297
  sendMessage,
@@ -228,6 +299,7 @@ function useAgentChat(config) {
228
299
  loadConversation,
229
300
  submitFeedback,
230
301
  retry,
302
+ stop,
231
303
  reset
232
304
  };
233
305
  return { state, actions };
@@ -235,7 +307,7 @@ function useAgentChat(config) {
235
307
 
236
308
  // src/chat/MessageThread/MessageThread.tsx
237
309
  import { twMerge as twMerge5 } from "tailwind-merge";
238
- import { useEffect, useRef as useRef2 } from "react";
310
+ import { useCallback as useCallback2, useEffect, useRef as useRef2 } from "react";
239
311
 
240
312
  // src/chat/MessageBubble/MessageBubble.tsx
241
313
  import { twMerge as twMerge4 } from "tailwind-merge";
@@ -244,8 +316,10 @@ import { twMerge as twMerge4 } from "tailwind-merge";
244
316
  import { Badge as Badge2 } from "@surf-kit/core";
245
317
 
246
318
  // src/response/ResponseMessage/ResponseMessage.tsx
319
+ import React from "react";
247
320
  import ReactMarkdown from "react-markdown";
248
321
  import rehypeSanitize from "rehype-sanitize";
322
+ import remarkGfm from "remark-gfm";
249
323
  import { twMerge } from "tailwind-merge";
250
324
  import { jsx } from "react/jsx-runtime";
251
325
  function normalizeMarkdownLists(content) {
@@ -268,7 +342,12 @@ function ResponseMessage({ content, className }) {
268
342
  "[&_h3]:text-sm [&_h3]:font-semibold [&_h3]:text-accent [&_h3]:mt-2 [&_h3]:mb-1",
269
343
  "[&_code]:bg-surface-raised [&_code]:text-accent [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_code]:font-mono",
270
344
  "[&_pre]:bg-surface-raised [&_pre]:border [&_pre]:border-border [&_pre]:rounded-xl [&_pre]:p-4 [&_pre]:overflow-x-auto",
345
+ "[&_hr]:my-3 [&_hr]:border-border",
271
346
  "[&_blockquote]:border-l-2 [&_blockquote]:border-border-strong [&_blockquote]:pl-4 [&_blockquote]:text-text-secondary",
347
+ "[&_table]:w-full [&_table]:text-sm [&_table]:border-collapse [&_table]:my-2",
348
+ "[&_thead]:border-b [&_thead]:border-border",
349
+ "[&_th]:text-left [&_th]:px-2 [&_th]:py-1.5 [&_th]:font-semibold",
350
+ "[&_td]:px-2 [&_td]:py-1.5 [&_td]:border-t [&_td]:border-border/50",
272
351
  "[&_a]:text-accent [&_a]:underline-offset-2 [&_a]:hover:text-accent/80",
273
352
  className
274
353
  ),
@@ -276,6 +355,7 @@ function ResponseMessage({ content, className }) {
276
355
  children: /* @__PURE__ */ jsx(
277
356
  ReactMarkdown,
278
357
  {
358
+ remarkPlugins: [remarkGfm],
279
359
  rehypePlugins: [rehypeSanitize],
280
360
  components: {
281
361
  script: () => null,
@@ -283,12 +363,29 @@ function ResponseMessage({ content, className }) {
283
363
  p: ({ children }) => /* @__PURE__ */ jsx("p", { className: "my-2", children }),
284
364
  ul: ({ children }) => /* @__PURE__ */ jsx("ul", { className: "my-2 list-disc pl-6", children }),
285
365
  ol: ({ children }) => /* @__PURE__ */ jsx("ol", { className: "my-2 list-decimal pl-6", children }),
286
- li: ({ children }) => /* @__PURE__ */ jsx("li", { className: "my-1", children }),
366
+ li: ({ children, ...props }) => {
367
+ let content2 = children;
368
+ if (props.ordered) {
369
+ content2 = React.Children.map(children, (child, i) => {
370
+ if (i === 0 && typeof child === "string") {
371
+ return child.replace(/^\d+[.)]\s*/, "");
372
+ }
373
+ return child;
374
+ });
375
+ }
376
+ return /* @__PURE__ */ jsx("li", { className: "my-1", children: content2 });
377
+ },
287
378
  strong: ({ children }) => /* @__PURE__ */ jsx("strong", { className: "font-semibold", children }),
379
+ em: ({ children }) => /* @__PURE__ */ jsx("em", { className: "italic text-text-secondary", children }),
288
380
  h1: ({ children }) => /* @__PURE__ */ jsx("h1", { className: "text-base font-bold mt-4 mb-2", children }),
289
381
  h2: ({ children }) => /* @__PURE__ */ jsx("h2", { className: "text-sm font-bold mt-3 mb-1", children }),
290
382
  h3: ({ children }) => /* @__PURE__ */ jsx("h3", { className: "text-sm font-semibold mt-2 mb-1", children }),
291
- code: ({ children }) => /* @__PURE__ */ jsx("code", { className: "bg-surface-sunken rounded px-1 py-0.5 text-xs font-mono", children })
383
+ hr: () => /* @__PURE__ */ jsx("hr", { className: "my-3 border-border" }),
384
+ code: ({ children }) => /* @__PURE__ */ jsx("code", { className: "bg-surface-sunken rounded px-1 py-0.5 text-xs font-mono", children }),
385
+ table: ({ children }) => /* @__PURE__ */ jsx("div", { className: "overflow-x-auto my-2", children: /* @__PURE__ */ jsx("table", { className: "w-full text-sm border-collapse", children }) }),
386
+ thead: ({ children }) => /* @__PURE__ */ jsx("thead", { className: "border-b border-border", children }),
387
+ th: ({ children }) => /* @__PURE__ */ jsx("th", { className: "text-left px-2 py-1.5 font-semibold", children }),
388
+ td: ({ children }) => /* @__PURE__ */ jsx("td", { className: "px-2 py-1.5 border-t border-border/50", children })
292
389
  },
293
390
  children: normalizeMarkdownLists(content)
294
391
  }
@@ -298,7 +395,9 @@ function ResponseMessage({ content, className }) {
298
395
  }
299
396
 
300
397
  // src/response/StructuredResponse/StructuredResponse.tsx
301
- import { jsx as jsx2, jsxs } from "react/jsx-runtime";
398
+ import ReactMarkdown2 from "react-markdown";
399
+ import rehypeSanitize2 from "rehype-sanitize";
400
+ import { Fragment, jsx as jsx2, jsxs } from "react/jsx-runtime";
302
401
  function tryParse(value) {
303
402
  if (value === void 0 || value === null) return null;
304
403
  if (typeof value === "string") {
@@ -310,6 +409,25 @@ function tryParse(value) {
310
409
  }
311
410
  return value;
312
411
  }
412
+ function InlineMarkdown({ text }) {
413
+ return /* @__PURE__ */ jsx2(
414
+ ReactMarkdown2,
415
+ {
416
+ rehypePlugins: [rehypeSanitize2],
417
+ components: {
418
+ // Unwrap block-level <p> so content stays inline within its parent
419
+ p: ({ children }) => /* @__PURE__ */ jsx2(Fragment, { children }),
420
+ strong: ({ children }) => /* @__PURE__ */ jsx2("strong", { className: "font-semibold", children }),
421
+ em: ({ children }) => /* @__PURE__ */ jsx2("em", { className: "italic", children }),
422
+ code: ({ children }) => /* @__PURE__ */ jsx2("code", { className: "bg-surface-sunken rounded px-1 py-0.5 text-xs font-mono", children }),
423
+ // Prevent block elements that would break layout
424
+ script: () => null,
425
+ iframe: () => null
426
+ },
427
+ children: text
428
+ }
429
+ );
430
+ }
313
431
  function renderSteps(data) {
314
432
  const steps = tryParse(data.steps);
315
433
  if (!steps || !Array.isArray(steps)) return null;
@@ -322,7 +440,7 @@ function renderSteps(data) {
322
440
  children: i + 1
323
441
  }
324
442
  ),
325
- /* @__PURE__ */ jsx2("span", { className: "text-sm text-text-primary leading-relaxed", children: step })
443
+ /* @__PURE__ */ jsx2("span", { className: "text-sm text-text-primary leading-relaxed", children: /* @__PURE__ */ jsx2(InlineMarkdown, { text: step }) })
326
444
  ] }, i)) });
327
445
  }
328
446
  function renderTable(data) {
@@ -388,7 +506,7 @@ function renderList(data) {
388
506
  title && /* @__PURE__ */ jsx2("p", { className: "text-xs font-semibold uppercase tracking-wider text-text-secondary mb-1", children: title }),
389
507
  /* @__PURE__ */ jsx2("ul", { className: "flex flex-col gap-1.5", children: items.map((item, i) => /* @__PURE__ */ jsxs("li", { className: "flex items-start gap-2.5", children: [
390
508
  /* @__PURE__ */ jsx2("span", { className: "mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-accent", "aria-hidden": "true" }),
391
- /* @__PURE__ */ jsx2("span", { className: "text-sm text-text-primary leading-relaxed", children: item })
509
+ /* @__PURE__ */ jsx2("span", { className: "text-sm text-text-primary leading-relaxed", children: /* @__PURE__ */ jsx2(InlineMarkdown, { text: item }) })
392
510
  ] }, i)) })
393
511
  ] });
394
512
  }
@@ -423,7 +541,14 @@ function renderWarning(data) {
423
541
  }
424
542
  );
425
543
  }
426
- function StructuredResponse({ uiHint, data, className }) {
544
+ function StructuredResponse({ uiHint, data: rawData, className }) {
545
+ const data = typeof rawData === "string" ? (() => {
546
+ try {
547
+ return JSON.parse(rawData);
548
+ } catch {
549
+ return null;
550
+ }
551
+ })() : rawData;
427
552
  if (!data) return null;
428
553
  let content;
429
554
  switch (uiHint) {
@@ -503,7 +628,36 @@ function SourceCard({ source, variant = "compact", onNavigate, className }) {
503
628
  children: [
504
629
  /* @__PURE__ */ jsxs2("div", { className: "flex items-start justify-between gap-2", children: [
505
630
  /* @__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 }),
631
+ source.url ? /* @__PURE__ */ jsxs2(
632
+ "a",
633
+ {
634
+ href: source.url,
635
+ target: "_blank",
636
+ rel: "noopener noreferrer",
637
+ className: "text-sm font-medium text-accent hover:underline truncate block",
638
+ onClick: (e) => e.stopPropagation(),
639
+ children: [
640
+ source.title,
641
+ /* @__PURE__ */ jsxs2(
642
+ "svg",
643
+ {
644
+ className: "inline-block ml-1 w-3 h-3 opacity-60",
645
+ viewBox: "0 0 24 24",
646
+ fill: "none",
647
+ stroke: "currentColor",
648
+ strokeWidth: "2",
649
+ strokeLinecap: "round",
650
+ strokeLinejoin: "round",
651
+ children: [
652
+ /* @__PURE__ */ jsx3("path", { d: "M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6" }),
653
+ /* @__PURE__ */ jsx3("polyline", { points: "15 3 21 3 21 9" }),
654
+ /* @__PURE__ */ jsx3("line", { x1: "10", y1: "14", x2: "21", y2: "3" })
655
+ ]
656
+ }
657
+ )
658
+ ]
659
+ }
660
+ ) : /* @__PURE__ */ jsx3("p", { className: "text-sm font-medium text-text-primary truncate", children: source.title }),
507
661
  source.section && /* @__PURE__ */ jsx3("p", { className: "text-[11px] font-semibold uppercase tracking-wider text-text-secondary truncate mt-0.5", children: source.section })
508
662
  ] }),
509
663
  /* @__PURE__ */ jsx3(
@@ -637,13 +791,16 @@ function AgentResponse({
637
791
  }) {
638
792
  return /* @__PURE__ */ jsxs4("div", { className: `flex flex-col gap-4 ${className ?? ""}`, "data-testid": "agent-response", children: [
639
793
  /* @__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
- ),
794
+ response.ui_hint !== "text" && response.structured_data && (() => {
795
+ const parsed = typeof response.structured_data === "string" ? (() => {
796
+ try {
797
+ return JSON.parse(response.structured_data);
798
+ } catch {
799
+ return null;
800
+ }
801
+ })() : response.structured_data;
802
+ return parsed ? /* @__PURE__ */ jsx6(StructuredResponse, { uiHint: response.ui_hint, data: parsed }) : null;
803
+ })(),
647
804
  (showConfidence || showVerification) && /* @__PURE__ */ jsxs4("div", { className: "flex flex-wrap items-center gap-2 mt-1", "data-testid": "response-meta", children: [
648
805
  showConfidence && /* @__PURE__ */ jsxs4(
649
806
  Badge2,
@@ -694,6 +851,31 @@ function AgentResponse({
694
851
 
695
852
  // src/chat/MessageBubble/MessageBubble.tsx
696
853
  import { jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
854
+ function DocumentIcon() {
855
+ 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: [
856
+ /* @__PURE__ */ jsx7("path", { d: "M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" }),
857
+ /* @__PURE__ */ jsx7("polyline", { points: "14 2 14 8 20 8" }),
858
+ /* @__PURE__ */ jsx7("line", { x1: "16", y1: "13", x2: "8", y2: "13" }),
859
+ /* @__PURE__ */ jsx7("line", { x1: "16", y1: "17", x2: "8", y2: "17" })
860
+ ] });
861
+ }
862
+ function AttachmentThumbnail({ attachment }) {
863
+ const isImage = attachment.content_type.startsWith("image/");
864
+ if (isImage) {
865
+ return /* @__PURE__ */ jsx7("div", { className: "rounded-lg overflow-hidden border border-black/10 max-w-[240px]", children: /* @__PURE__ */ jsx7(
866
+ "img",
867
+ {
868
+ src: attachment.preview_url ?? `data:${attachment.content_type};base64,${attachment.data}`,
869
+ alt: attachment.filename,
870
+ className: "max-w-full max-h-[200px] object-contain"
871
+ }
872
+ ) });
873
+ }
874
+ return /* @__PURE__ */ jsxs5("div", { className: "flex items-center gap-2 px-3 py-2 rounded-lg border border-black/10 bg-black/5", children: [
875
+ /* @__PURE__ */ jsx7(DocumentIcon, {}),
876
+ /* @__PURE__ */ jsx7("span", { className: "text-xs truncate max-w-[160px]", children: attachment.filename })
877
+ ] });
878
+ }
697
879
  function MessageBubble({
698
880
  message,
699
881
  showAgent,
@@ -701,23 +883,29 @@ function MessageBubble({
701
883
  showConfidence = true,
702
884
  showVerification = true,
703
885
  animated = true,
886
+ userBubbleClassName,
704
887
  className
705
888
  }) {
706
889
  const isUser = message.role === "user";
890
+ const hasAttachments = message.attachments && message.attachments.length > 0;
707
891
  if (isUser) {
708
892
  return /* @__PURE__ */ jsx7(
709
893
  "div",
710
894
  {
711
895
  "data-message-id": message.id,
712
896
  className: twMerge4("flex w-full justify-end", className),
713
- children: /* @__PURE__ */ jsx7(
897
+ children: /* @__PURE__ */ jsxs5(
714
898
  "div",
715
899
  {
716
900
  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"
901
+ "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",
902
+ animated && "motion-safe:animate-slideFromRight",
903
+ userBubbleClassName
719
904
  ),
720
- children: message.content
905
+ children: [
906
+ 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}`)) }),
907
+ message.content
908
+ ]
721
909
  }
722
910
  )
723
911
  }
@@ -729,7 +917,7 @@ function MessageBubble({
729
917
  "data-message-id": message.id,
730
918
  className: twMerge4("flex w-full flex-col items-start gap-1.5", className),
731
919
  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("_", " ") }),
920
+ 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
921
  /* @__PURE__ */ jsx7(
734
922
  "div",
735
923
  {
@@ -755,34 +943,97 @@ function MessageBubble({
755
943
 
756
944
  // src/chat/MessageThread/MessageThread.tsx
757
945
  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);
946
+ function MessageThread({ messages, streamingSlot, showAgent, showSources, showConfidence, showVerification, hideLastAssistant, userBubbleClassName, className }) {
947
+ const scrollRef = useRef2(null);
948
+ const shouldAutoScroll = useRef2(true);
949
+ const hasStreaming = !!streamingSlot;
950
+ const scrollToBottom = useCallback2(() => {
951
+ const el = scrollRef.current;
952
+ if (el && shouldAutoScroll.current) {
953
+ el.scrollTop = el.scrollHeight;
954
+ }
955
+ }, []);
956
+ useEffect(() => {
957
+ const el = scrollRef.current;
958
+ if (!el) return;
959
+ const onWheel = (e) => {
960
+ if (e.deltaY < 0) {
961
+ shouldAutoScroll.current = false;
962
+ }
963
+ };
964
+ const onPointerDown = () => {
965
+ el.dataset.userPointer = "1";
966
+ };
967
+ const onPointerUp = () => {
968
+ delete el.dataset.userPointer;
969
+ };
970
+ el.addEventListener("wheel", onWheel, { passive: true });
971
+ el.addEventListener("pointerdown", onPointerDown);
972
+ window.addEventListener("pointerup", onPointerUp);
973
+ return () => {
974
+ el.removeEventListener("wheel", onWheel);
975
+ el.removeEventListener("pointerdown", onPointerDown);
976
+ window.removeEventListener("pointerup", onPointerUp);
977
+ };
978
+ }, []);
979
+ const handleScroll = useCallback2(() => {
980
+ const el = scrollRef.current;
981
+ if (!el) return;
982
+ const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
983
+ if (nearBottom) {
984
+ shouldAutoScroll.current = true;
985
+ } else if (el.dataset.userPointer) {
986
+ shouldAutoScroll.current = false;
987
+ }
988
+ }, []);
989
+ useEffect(scrollToBottom, [messages.length, scrollToBottom]);
990
+ useEffect(() => {
991
+ if (!hasStreaming) return;
992
+ let raf;
993
+ const tick = () => {
994
+ scrollToBottom();
995
+ raf = requestAnimationFrame(tick);
996
+ };
997
+ raf = requestAnimationFrame(tick);
998
+ return () => cancelAnimationFrame(raf);
999
+ }, [hasStreaming, scrollToBottom]);
760
1000
  useEffect(() => {
761
- bottomRef.current?.scrollIntoView?.({ behavior: "smooth" });
762
- }, [messages.length, streamingSlot]);
1001
+ if (!hasStreaming) {
1002
+ shouldAutoScroll.current = true;
1003
+ }
1004
+ }, [hasStreaming]);
763
1005
  return /* @__PURE__ */ jsxs6(
764
1006
  "div",
765
1007
  {
1008
+ ref: scrollRef,
766
1009
  role: "log",
767
1010
  "aria-live": "polite",
768
1011
  "aria-label": "Message thread",
1012
+ onScroll: handleScroll,
769
1013
  className: twMerge5(
770
1014
  "flex flex-col gap-4 overflow-y-auto flex-1 px-4 py-6",
771
1015
  className
772
1016
  ),
773
1017
  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 })
1018
+ /* @__PURE__ */ jsx8("div", { className: "flex-1 shrink-0" }),
1019
+ messages.map((message, i) => {
1020
+ if (hideLastAssistant && i === messages.length - 1 && message.role === "assistant") {
1021
+ return null;
1022
+ }
1023
+ return /* @__PURE__ */ jsx8(
1024
+ MessageBubble,
1025
+ {
1026
+ message,
1027
+ showAgent,
1028
+ showSources,
1029
+ showConfidence,
1030
+ showVerification,
1031
+ userBubbleClassName
1032
+ },
1033
+ message.id
1034
+ );
1035
+ }),
1036
+ streamingSlot
786
1037
  ]
787
1038
  }
788
1039
  );
@@ -790,32 +1041,127 @@ function MessageThread({ messages, streamingSlot, showSources, showConfidence, s
790
1041
 
791
1042
  // src/chat/MessageComposer/MessageComposer.tsx
792
1043
  import { twMerge as twMerge6 } from "tailwind-merge";
793
- import { useState as useState2, useRef as useRef3, useCallback as useCallback2 } from "react";
1044
+ import { useState as useState2, useRef as useRef3, useCallback as useCallback3 } from "react";
794
1045
  import { jsx as jsx9, jsxs as jsxs7 } from "react/jsx-runtime";
1046
+ var ALLOWED_TYPES = /* @__PURE__ */ new Set([
1047
+ "image/png",
1048
+ "image/jpeg",
1049
+ "image/gif",
1050
+ "image/webp",
1051
+ "application/pdf"
1052
+ ]);
1053
+ var MAX_FILE_SIZE = 10 * 1024 * 1024;
1054
+ var MAX_ATTACHMENTS = 5;
1055
+ function ArrowUpIcon() {
1056
+ 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: [
1057
+ /* @__PURE__ */ jsx9("path", { d: "M10 16V4" }),
1058
+ /* @__PURE__ */ jsx9("path", { d: "M4 10l6-6 6 6" })
1059
+ ] });
1060
+ }
1061
+ function StopIcon() {
1062
+ 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" }) });
1063
+ }
1064
+ function PaperclipIcon() {
1065
+ 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" }) });
1066
+ }
1067
+ function XIcon({ size = 14 }) {
1068
+ 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: [
1069
+ /* @__PURE__ */ jsx9("path", { d: "M18 6L6 18" }),
1070
+ /* @__PURE__ */ jsx9("path", { d: "M6 6l12 12" })
1071
+ ] });
1072
+ }
1073
+ function DocumentIcon2() {
1074
+ 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: [
1075
+ /* @__PURE__ */ jsx9("path", { d: "M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" }),
1076
+ /* @__PURE__ */ jsx9("polyline", { points: "14 2 14 8 20 8" }),
1077
+ /* @__PURE__ */ jsx9("line", { x1: "16", y1: "13", x2: "8", y2: "13" }),
1078
+ /* @__PURE__ */ jsx9("line", { x1: "16", y1: "17", x2: "8", y2: "17" }),
1079
+ /* @__PURE__ */ jsx9("polyline", { points: "10 9 9 9 8 9" })
1080
+ ] });
1081
+ }
1082
+ function fileToBase64(file) {
1083
+ return new Promise((resolve, reject) => {
1084
+ const reader = new FileReader();
1085
+ reader.onload = () => {
1086
+ const result = reader.result;
1087
+ const base64 = result.split(",")[1];
1088
+ resolve(base64);
1089
+ };
1090
+ reader.onerror = reject;
1091
+ reader.readAsDataURL(file);
1092
+ });
1093
+ }
1094
+ function AttachmentPreview({
1095
+ attachment,
1096
+ onRemove
1097
+ }) {
1098
+ const isImage = attachment.content_type.startsWith("image/");
1099
+ return /* @__PURE__ */ jsxs7("div", { className: "relative group flex-shrink-0", children: [
1100
+ isImage ? /* @__PURE__ */ jsx9("div", { className: "w-16 h-16 rounded-lg overflow-hidden border border-border/60 bg-surface-alt", children: /* @__PURE__ */ jsx9(
1101
+ "img",
1102
+ {
1103
+ src: attachment.preview_url ?? `data:${attachment.content_type};base64,${attachment.data}`,
1104
+ alt: attachment.filename,
1105
+ className: "w-full h-full object-cover"
1106
+ }
1107
+ ) }) : /* @__PURE__ */ jsxs7("div", { className: "h-16 px-3 rounded-lg border border-border/60 bg-surface-alt flex items-center gap-2", children: [
1108
+ /* @__PURE__ */ jsx9("div", { className: "text-text-muted", children: /* @__PURE__ */ jsx9(DocumentIcon2, {}) }),
1109
+ /* @__PURE__ */ jsxs7("div", { className: "flex flex-col min-w-0", children: [
1110
+ /* @__PURE__ */ jsx9("span", { className: "text-xs text-text-primary truncate max-w-[120px]", children: attachment.filename }),
1111
+ /* @__PURE__ */ jsx9("span", { className: "text-[10px] text-text-muted", children: "PDF" })
1112
+ ] })
1113
+ ] }),
1114
+ /* @__PURE__ */ jsx9(
1115
+ "button",
1116
+ {
1117
+ type: "button",
1118
+ onClick: onRemove,
1119
+ className: twMerge6(
1120
+ "absolute -top-1.5 -right-1.5",
1121
+ "w-5 h-5 rounded-full",
1122
+ "bg-text-muted/80 text-white",
1123
+ "flex items-center justify-center",
1124
+ "opacity-0 group-hover:opacity-100",
1125
+ "transition-opacity duration-150",
1126
+ "hover:bg-text-primary"
1127
+ ),
1128
+ "aria-label": `Remove ${attachment.filename}`,
1129
+ children: /* @__PURE__ */ jsx9(XIcon, { size: 10 })
1130
+ }
1131
+ )
1132
+ ] });
1133
+ }
795
1134
  function MessageComposer({
796
1135
  onSend,
1136
+ onStop,
797
1137
  isLoading = false,
798
1138
  placeholder = "Type a message...",
799
1139
  className
800
1140
  }) {
801
1141
  const [value, setValue] = useState2("");
1142
+ const [attachments, setAttachments] = useState2([]);
1143
+ const [dragOver, setDragOver] = useState2(false);
802
1144
  const textareaRef = useRef3(null);
803
- const canSend = value.trim().length > 0 && !isLoading;
804
- const resetHeight = useCallback2(() => {
1145
+ const fileInputRef = useRef3(null);
1146
+ const canSend = (value.trim().length > 0 || attachments.length > 0) && !isLoading;
1147
+ const resetHeight = useCallback3(() => {
805
1148
  const el = textareaRef.current;
806
1149
  if (el) {
807
1150
  el.style.height = "auto";
808
1151
  el.style.overflowY = "hidden";
809
1152
  }
810
1153
  }, []);
811
- const handleSend = useCallback2(() => {
1154
+ const handleSend = useCallback3(() => {
812
1155
  if (!canSend) return;
813
- onSend(value.trim());
1156
+ const message = value.trim() || (attachments.length > 0 ? "Please analyse the attached file(s)." : "");
1157
+ if (!message && attachments.length === 0) return;
1158
+ onSend(message, attachments.length > 0 ? attachments : void 0);
814
1159
  setValue("");
1160
+ setAttachments([]);
815
1161
  resetHeight();
816
1162
  textareaRef.current?.focus();
817
- }, [canSend, onSend, value, resetHeight]);
818
- const handleKeyDown = useCallback2(
1163
+ }, [canSend, onSend, value, attachments, resetHeight]);
1164
+ const handleKeyDown = useCallback3(
819
1165
  (e) => {
820
1166
  if (e.key === "Enter" && !e.shiftKey) {
821
1167
  e.preventDefault();
@@ -824,64 +1170,194 @@ function MessageComposer({
824
1170
  },
825
1171
  [handleSend]
826
1172
  );
827
- const handleChange = useCallback2(
1173
+ const handleChange = useCallback3(
828
1174
  (e) => {
829
1175
  setValue(e.target.value);
830
1176
  const el = e.target;
831
1177
  el.style.height = "auto";
832
- const capped = Math.min(el.scrollHeight, 128);
1178
+ const capped = Math.min(el.scrollHeight, 200);
833
1179
  el.style.height = `${capped}px`;
834
- el.style.overflowY = el.scrollHeight > 128 ? "auto" : "hidden";
1180
+ el.style.overflowY = el.scrollHeight > 200 ? "auto" : "hidden";
835
1181
  },
836
1182
  []
837
1183
  );
1184
+ const addFiles = useCallback3(async (files) => {
1185
+ const fileArray = Array.from(files);
1186
+ for (const file of fileArray) {
1187
+ if (attachments.length >= MAX_ATTACHMENTS) break;
1188
+ if (!ALLOWED_TYPES.has(file.type)) continue;
1189
+ if (file.size > MAX_FILE_SIZE) continue;
1190
+ try {
1191
+ const data = await fileToBase64(file);
1192
+ const previewUrl = file.type.startsWith("image/") ? URL.createObjectURL(file) : void 0;
1193
+ const attachment = {
1194
+ filename: file.name,
1195
+ content_type: file.type,
1196
+ data,
1197
+ preview_url: previewUrl
1198
+ };
1199
+ setAttachments((prev) => {
1200
+ if (prev.length >= MAX_ATTACHMENTS) return prev;
1201
+ return [...prev, attachment];
1202
+ });
1203
+ } catch {
1204
+ }
1205
+ }
1206
+ }, [attachments.length]);
1207
+ const handleFileSelect = useCallback3(() => {
1208
+ fileInputRef.current?.click();
1209
+ }, []);
1210
+ const handleFileInputChange = useCallback3(
1211
+ (e) => {
1212
+ if (e.target.files) {
1213
+ void addFiles(e.target.files);
1214
+ e.target.value = "";
1215
+ }
1216
+ },
1217
+ [addFiles]
1218
+ );
1219
+ const removeAttachment = useCallback3((index) => {
1220
+ setAttachments((prev) => {
1221
+ const removed = prev[index];
1222
+ if (removed?.preview_url) URL.revokeObjectURL(removed.preview_url);
1223
+ return prev.filter((_, i) => i !== index);
1224
+ });
1225
+ }, []);
1226
+ const handlePaste = useCallback3(
1227
+ (e) => {
1228
+ const items = e.clipboardData.items;
1229
+ const files = [];
1230
+ for (const item of items) {
1231
+ if (item.kind === "file" && ALLOWED_TYPES.has(item.type)) {
1232
+ const file = item.getAsFile();
1233
+ if (file) files.push(file);
1234
+ }
1235
+ }
1236
+ if (files.length > 0) {
1237
+ void addFiles(files);
1238
+ }
1239
+ },
1240
+ [addFiles]
1241
+ );
1242
+ const handleDragOver = useCallback3((e) => {
1243
+ e.preventDefault();
1244
+ e.stopPropagation();
1245
+ setDragOver(true);
1246
+ }, []);
1247
+ const handleDragLeave = useCallback3((e) => {
1248
+ e.preventDefault();
1249
+ e.stopPropagation();
1250
+ setDragOver(false);
1251
+ }, []);
1252
+ const handleDrop = useCallback3(
1253
+ (e) => {
1254
+ e.preventDefault();
1255
+ e.stopPropagation();
1256
+ setDragOver(false);
1257
+ if (e.dataTransfer.files.length > 0) {
1258
+ void addFiles(e.dataTransfer.files);
1259
+ }
1260
+ },
1261
+ [addFiles]
1262
+ );
838
1263
  return /* @__PURE__ */ jsxs7(
839
1264
  "div",
840
1265
  {
841
1266
  className: twMerge6(
842
- "flex items-end gap-3 shrink-0 border-t border-border px-4 py-3",
1267
+ "relative shrink-0 rounded-3xl border bg-surface",
1268
+ "shadow-lg shadow-black/10",
1269
+ "transition-all duration-200",
1270
+ "focus-within:border-accent/40 focus-within:shadow-accent/5",
1271
+ dragOver ? "border-accent/60 bg-accent/5" : "border-border/60",
843
1272
  className
844
1273
  ),
1274
+ onDragOver: handleDragOver,
1275
+ onDragLeave: handleDragLeave,
1276
+ onDrop: handleDrop,
845
1277
  children: [
846
1278
  /* @__PURE__ */ jsx9(
847
- "textarea",
1279
+ "input",
848
1280
  {
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"
1281
+ ref: fileInputRef,
1282
+ type: "file",
1283
+ multiple: true,
1284
+ accept: "image/png,image/jpeg,image/gif,image/webp,application/pdf",
1285
+ onChange: handleFileInputChange,
1286
+ className: "hidden",
1287
+ "aria-hidden": "true"
866
1288
  }
867
1289
  ),
868
- /* @__PURE__ */ jsx9(
869
- "button",
1290
+ 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(
1291
+ AttachmentPreview,
870
1292
  {
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
- )
1293
+ attachment: att,
1294
+ onRemove: () => removeAttachment(i)
1295
+ },
1296
+ `${att.filename}-${i}`
1297
+ )) }),
1298
+ 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" }) }),
1299
+ /* @__PURE__ */ jsxs7("div", { className: "flex items-end", children: [
1300
+ /* @__PURE__ */ jsx9(
1301
+ "button",
1302
+ {
1303
+ type: "button",
1304
+ onClick: handleFileSelect,
1305
+ disabled: isLoading || attachments.length >= MAX_ATTACHMENTS,
1306
+ "aria-label": "Attach file",
1307
+ className: twMerge6(
1308
+ "flex-shrink-0 ml-2 mb-3",
1309
+ "inline-flex items-center justify-center",
1310
+ "w-9 h-9 rounded-full",
1311
+ "transition-all duration-200",
1312
+ "text-text-muted/60 hover:text-text-secondary hover:bg-text-muted/10",
1313
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent",
1314
+ "disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent"
1315
+ ),
1316
+ children: /* @__PURE__ */ jsx9(PaperclipIcon, {})
1317
+ }
1318
+ ),
1319
+ /* @__PURE__ */ jsx9(
1320
+ "textarea",
1321
+ {
1322
+ ref: textareaRef,
1323
+ value,
1324
+ onChange: handleChange,
1325
+ onKeyDown: handleKeyDown,
1326
+ onPaste: handlePaste,
1327
+ placeholder,
1328
+ rows: 1,
1329
+ disabled: isLoading,
1330
+ className: twMerge6(
1331
+ "flex-1 resize-none bg-transparent",
1332
+ "pl-2 pr-14 pt-4 pb-4 text-[15px] leading-relaxed",
1333
+ "text-text-primary placeholder:text-text-muted/70",
1334
+ "focus:outline-none",
1335
+ "disabled:opacity-50 disabled:cursor-not-allowed",
1336
+ "overflow-hidden"
1337
+ ),
1338
+ style: { colorScheme: "dark" },
1339
+ "aria-label": "Message input"
1340
+ }
1341
+ ),
1342
+ /* @__PURE__ */ jsx9(
1343
+ "button",
1344
+ {
1345
+ type: "button",
1346
+ onClick: isLoading && onStop ? onStop : handleSend,
1347
+ disabled: !canSend && !isLoading,
1348
+ "aria-label": isLoading ? "Stop generating" : "Send message",
1349
+ className: twMerge6(
1350
+ "absolute bottom-3 right-3",
1351
+ "inline-flex items-center justify-center",
1352
+ "w-9 h-9 rounded-full",
1353
+ "transition-all duration-200",
1354
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent",
1355
+ 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 cursor-pointer" : "bg-transparent text-text-muted/40 cursor-default"
1356
+ ),
1357
+ children: isLoading ? /* @__PURE__ */ jsx9(StopIcon, {}) : /* @__PURE__ */ jsx9(ArrowUpIcon, {})
1358
+ }
1359
+ )
1360
+ ] })
885
1361
  ]
886
1362
  }
887
1363
  );
@@ -894,6 +1370,7 @@ function WelcomeScreen({
894
1370
  title = "Welcome",
895
1371
  message = "How can I help you today?",
896
1372
  icon,
1373
+ iconClassName,
897
1374
  suggestedQuestions = [],
898
1375
  onQuestionSelect,
899
1376
  className
@@ -906,12 +1383,15 @@ function WelcomeScreen({
906
1383
  className
907
1384
  ),
908
1385
  children: [
909
- /* @__PURE__ */ jsx10(
1386
+ icon ? iconClassName ? /* @__PURE__ */ jsx10("div", { className: iconClassName, "aria-hidden": "true", children: icon }) : icon : /* @__PURE__ */ jsx10(
910
1387
  "div",
911
1388
  {
912
- className: "w-14 h-14 rounded-2xl bg-accent/10 border border-border flex items-center justify-center pulse-glow",
1389
+ className: twMerge7(
1390
+ "w-14 h-14 rounded-2xl bg-accent/10 border border-border flex items-center justify-center pulse-glow",
1391
+ iconClassName
1392
+ ),
913
1393
  "aria-hidden": "true",
914
- children: icon ?? /* @__PURE__ */ jsx10("span", { className: "text-2xl", children: "\u2726" })
1394
+ children: /* @__PURE__ */ jsx10("span", { className: "text-2xl", children: "\u2726" })
915
1395
  }
916
1396
  ),
917
1397
  /* @__PURE__ */ jsxs8("div", { className: "flex flex-col gap-2", children: [
@@ -921,7 +1401,7 @@ function WelcomeScreen({
921
1401
  suggestedQuestions.length > 0 && /* @__PURE__ */ jsx10(
922
1402
  "div",
923
1403
  {
924
- className: "flex flex-wrap justify-center gap-2 max-w-md",
1404
+ className: "flex flex-wrap justify-center gap-2 max-w-xl",
925
1405
  role: "group",
926
1406
  "aria-label": "Suggested questions",
927
1407
  children: suggestedQuestions.map((question) => /* @__PURE__ */ jsx10(
@@ -930,7 +1410,7 @@ function WelcomeScreen({
930
1410
  type: "button",
931
1411
  onClick: () => onQuestionSelect?.(question),
932
1412
  className: twMerge7(
933
- "px-4 py-2 rounded-full text-sm",
1413
+ "px-3.5 py-1.5 rounded-full text-[12px]",
934
1414
  "border border-border bg-transparent text-text-secondary",
935
1415
  "hover:bg-accent/10 hover:border-interactive hover:text-text-primary",
936
1416
  "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent",
@@ -949,7 +1429,8 @@ function WelcomeScreen({
949
1429
 
950
1430
  // src/streaming/StreamingMessage/StreamingMessage.tsx
951
1431
  import { useEffect as useEffect3, useRef as useRef5 } from "react";
952
- import { Spinner } from "@surf-kit/core";
1432
+ import { twMerge as twMerge8 } from "tailwind-merge";
1433
+ import { WaveLoader } from "@surf-kit/core";
953
1434
 
954
1435
  // src/hooks/useCharacterDrain.ts
955
1436
  import { useState as useState3, useRef as useRef4, useEffect as useEffect2 } from "react";
@@ -978,7 +1459,10 @@ function useCharacterDrain(target, msPerChar = 15) {
978
1459
  const elapsed = now - lastTimeRef.current;
979
1460
  const charsToAdvance = Math.floor(elapsed / msPerCharRef.current);
980
1461
  if (charsToAdvance > 0 && indexRef.current < currentTarget.length) {
981
- const nextIndex = Math.min(indexRef.current + charsToAdvance, currentTarget.length);
1462
+ let nextIndex = Math.min(indexRef.current + charsToAdvance, currentTarget.length);
1463
+ while (nextIndex < currentTarget.length && currentTarget[nextIndex - 1].trim() === "") {
1464
+ nextIndex++;
1465
+ }
982
1466
  indexRef.current = nextIndex;
983
1467
  lastTimeRef.current = now;
984
1468
  setDisplayed(currentTarget.slice(0, nextIndex));
@@ -1023,14 +1507,36 @@ var phaseLabels = {
1023
1507
  generating: "Writing...",
1024
1508
  verifying: "Verifying..."
1025
1509
  };
1510
+ var CURSOR_STYLES = `
1511
+ .sk-streaming-cursor > :not(ul,ol,blockquote,div:has(table)):last-child::after,
1512
+ .sk-streaming-cursor > :is(ul,ol):last-child > li:last-child::after,
1513
+ .sk-streaming-cursor > blockquote:last-child > p:last-child::after,
1514
+ .sk-streaming-cursor > div:has(table):last-child table tbody tr:last-child td:last-child::after {
1515
+ content: "";
1516
+ display: inline-block;
1517
+ width: 2px;
1518
+ height: 1em;
1519
+ background: var(--color-accent, #38bdf8);
1520
+ animation: sk-cursor-blink 0.8s steps(1) infinite;
1521
+ margin-left: 2px;
1522
+ vertical-align: text-bottom;
1523
+ }
1524
+ @keyframes sk-cursor-blink {
1525
+ 0%, 60% { opacity: 1; }
1526
+ 61%, 100% { opacity: 0; }
1527
+ }
1528
+ `;
1026
1529
  function StreamingMessage({
1027
1530
  stream,
1028
1531
  onComplete,
1532
+ onDraining,
1029
1533
  showPhases = true,
1030
1534
  className
1031
1535
  }) {
1032
1536
  const onCompleteRef = useRef5(onComplete);
1033
1537
  onCompleteRef.current = onComplete;
1538
+ const onDrainingRef = useRef5(onDraining);
1539
+ onDrainingRef.current = onDraining;
1034
1540
  const wasActiveRef = useRef5(stream.active);
1035
1541
  useEffect3(() => {
1036
1542
  if (wasActiveRef.current && !stream.active) {
@@ -1039,35 +1545,40 @@ function StreamingMessage({
1039
1545
  wasActiveRef.current = stream.active;
1040
1546
  }, [stream.active]);
1041
1547
  const phaseLabel = phaseLabels[stream.phase];
1042
- const { displayed: displayedContent } = useCharacterDrain(stream.content);
1043
- return /* @__PURE__ */ jsxs9("div", { className, "data-testid": "streaming-message", children: [
1548
+ const { displayed: rawDisplayed, isDraining } = useCharacterDrain(stream.content);
1549
+ const displayedContent = stream.active || isDraining ? rawDisplayed.trimEnd() : rawDisplayed;
1550
+ useEffect3(() => {
1551
+ onDrainingRef.current?.(isDraining);
1552
+ }, [isDraining]);
1553
+ const agentLabel = stream.agent ? stream.agent.replace("_agent", "").replace("_", " ") : null;
1554
+ const showPhaseIndicator = showPhases && stream.active && stream.phase !== "idle" && !displayedContent;
1555
+ const showCursor = (stream.active || isDraining) && !!displayedContent;
1556
+ return /* @__PURE__ */ jsxs9("div", { className: twMerge8("flex w-full flex-col items-start", className), "data-testid": "streaming-message", children: [
1044
1557
  /* @__PURE__ */ jsxs9("div", { "aria-live": "assertive", className: "sr-only", children: [
1045
1558
  stream.active && stream.phase !== "idle" && "Response started",
1046
1559
  !stream.active && stream.content && "Response complete"
1047
1560
  ] }),
1561
+ showCursor && /* @__PURE__ */ jsx11("style", { children: CURSOR_STYLES }),
1562
+ 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
1563
  /* @__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(
1564
+ showPhaseIndicator && /* @__PURE__ */ jsxs9(
1050
1565
  "div",
1051
1566
  {
1052
- className: "flex items-center gap-2 mb-2 text-sm text-text-secondary",
1567
+ className: "flex items-center gap-2 text-sm text-text-secondary",
1053
1568
  "data-testid": "phase-indicator",
1054
1569
  children: [
1055
- /* @__PURE__ */ jsx11("span", { "aria-hidden": "true", children: /* @__PURE__ */ jsx11(Spinner, { size: "sm" }) }),
1570
+ /* @__PURE__ */ jsx11("span", { "aria-hidden": "true", children: /* @__PURE__ */ jsx11(WaveLoader, { size: "sm", color: "#38bdf8" }) }),
1056
1571
  /* @__PURE__ */ jsx11("span", { children: phaseLabel })
1057
1572
  ]
1058
1573
  }
1059
1574
  ),
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
- ] })
1575
+ displayedContent && /* @__PURE__ */ jsx11(
1576
+ ResponseMessage,
1577
+ {
1578
+ content: displayedContent,
1579
+ className: showCursor ? "sk-streaming-cursor" : void 0
1580
+ }
1581
+ )
1071
1582
  ] })
1072
1583
  ] });
1073
1584
  }
@@ -1098,7 +1609,7 @@ function AgentChat({
1098
1609
  return /* @__PURE__ */ jsxs10(
1099
1610
  "div",
1100
1611
  {
1101
- className: twMerge8(
1612
+ className: twMerge9(
1102
1613
  "flex flex-col h-full bg-canvas border border-border rounded-xl overflow-hidden",
1103
1614
  className
1104
1615
  ),
@@ -1134,14 +1645,14 @@ function AgentChat({
1134
1645
  onQuestionSelect: handleQuestionSelect
1135
1646
  }
1136
1647
  ),
1137
- /* @__PURE__ */ jsx12(MessageComposer, { onSend: handleSend, isLoading: state.isLoading })
1648
+ /* @__PURE__ */ jsx12(MessageComposer, { onSend: handleSend, onStop: actions.stop, isLoading: state.isLoading })
1138
1649
  ]
1139
1650
  }
1140
1651
  );
1141
1652
  }
1142
1653
 
1143
1654
  // src/chat/ConversationList/ConversationList.tsx
1144
- import { twMerge as twMerge9 } from "tailwind-merge";
1655
+ import { twMerge as twMerge10 } from "tailwind-merge";
1145
1656
  import { jsx as jsx13, jsxs as jsxs11 } from "react/jsx-runtime";
1146
1657
  function ConversationList({
1147
1658
  conversations,
@@ -1155,14 +1666,14 @@ function ConversationList({
1155
1666
  "nav",
1156
1667
  {
1157
1668
  "aria-label": "Conversation list",
1158
- className: twMerge9("flex flex-col h-full bg-canvas", className),
1669
+ className: twMerge10("flex flex-col flex-1 min-h-0", className),
1159
1670
  children: [
1160
- onNew && /* @__PURE__ */ jsx13("div", { className: "p-3 border-b border-border", children: /* @__PURE__ */ jsx13(
1671
+ onNew && /* @__PURE__ */ jsx13("div", { className: "px-5 pt-1 pb-3 border-b border-border", children: /* @__PURE__ */ jsx13(
1161
1672
  "button",
1162
1673
  {
1163
1674
  type: "button",
1164
1675
  onClick: onNew,
1165
- className: "w-full px-4 py-2.5 rounded-xl text-sm font-semibold bg-accent text-white hover:bg-accent-hover transition-all duration-200",
1676
+ className: "w-full px-4 py-2 rounded-lg text-sm font-medium border border-border text-text-secondary hover:text-text-primary hover:bg-surface hover:border-border-strong transition-colors duration-150",
1166
1677
  children: "New conversation"
1167
1678
  }
1168
1679
  ) }),
@@ -1172,10 +1683,10 @@ function ConversationList({
1172
1683
  return /* @__PURE__ */ jsxs11(
1173
1684
  "li",
1174
1685
  {
1175
- className: twMerge9(
1176
- "flex items-start border-b border-border transition-colors duration-200",
1177
- "hover:bg-surface",
1178
- isActive && "bg-surface-raised border-l-2 border-l-accent"
1686
+ className: twMerge10(
1687
+ "flex items-start transition-colors duration-150",
1688
+ "hover:bg-surface-raised",
1689
+ isActive ? "bg-accent-subtlest border-l-[3px] border-l-accent" : "border-l-[3px] border-l-transparent"
1179
1690
  ),
1180
1691
  children: [
1181
1692
  /* @__PURE__ */ jsxs11(
@@ -1184,10 +1695,10 @@ function ConversationList({
1184
1695
  type: "button",
1185
1696
  onClick: () => onSelect(conversation.id),
1186
1697
  "aria-current": isActive ? "true" : void 0,
1187
- className: "flex-1 min-w-0 text-left px-4 py-3",
1698
+ className: "flex-1 min-w-0 text-left px-5 py-2.5",
1188
1699
  children: [
1189
- /* @__PURE__ */ jsx13("div", { className: "text-sm font-medium text-brand-cream truncate", children: conversation.title }),
1190
- /* @__PURE__ */ jsx13("div", { className: "text-xs text-brand-cream/40 truncate mt-0.5 leading-relaxed", children: conversation.lastMessage })
1700
+ /* @__PURE__ */ jsx13("div", { className: "text-sm font-medium text-text-primary truncate", children: conversation.title }),
1701
+ /* @__PURE__ */ jsx13("div", { className: "text-xs text-text-muted truncate mt-0.5 leading-relaxed", children: conversation.lastMessage })
1191
1702
  ]
1192
1703
  }
1193
1704
  ),
@@ -1197,7 +1708,7 @@ function ConversationList({
1197
1708
  type: "button",
1198
1709
  onClick: () => onDelete(conversation.id),
1199
1710
  "aria-label": `Delete ${conversation.title}`,
1200
- className: "shrink-0 p-1.5 m-2 rounded-lg text-brand-cream/25 hover:text-brand-watermelon hover:bg-brand-watermelon/10 transition-colors duration-200",
1711
+ className: "shrink-0 p-1.5 m-2 rounded-lg text-text-muted hover:text-status-error hover:bg-status-error/10 transition-colors duration-150",
1201
1712
  children: /* @__PURE__ */ jsxs11(
1202
1713
  "svg",
1203
1714
  {
@@ -1224,7 +1735,7 @@ function ConversationList({
1224
1735
  conversation.id
1225
1736
  );
1226
1737
  }),
1227
- conversations.length === 0 && /* @__PURE__ */ jsx13("li", { className: "px-4 py-8 text-center", children: /* @__PURE__ */ jsx13("span", { className: "text-sm text-brand-cream/30 font-body", children: "No conversations yet" }) })
1738
+ conversations.length === 0 && /* @__PURE__ */ jsx13("li", { className: "px-5 py-12 text-center", children: /* @__PURE__ */ jsx13("span", { className: "text-sm text-text-muted font-body", children: "No conversations yet" }) })
1228
1739
  ] })
1229
1740
  ]
1230
1741
  }