@hachej/boring-agent 0.1.5 → 0.1.7

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.
@@ -4,12 +4,24 @@ import { ReactNode, ComponentType, HTMLAttributes, ComponentProps, FormEvent, El
4
4
  import * as ai from 'ai';
5
5
  import { UIMessage, FileUIPart, ChatStatus } from 'ai';
6
6
  import * as _ai_sdk_react from '@ai-sdk/react';
7
- import { S as SendMessageInput, d as SessionSummary } from '../harness-CMiJ4kok.js';
7
+ import { S as SendMessageInput, d as SessionSummary } from '../harness-DpMCKbzh.js';
8
8
  import { Button, Collapsible, CollapsibleContent, CollapsibleTrigger, InputGroupButton, TooltipContent, InputGroupAddon, InputGroupTextarea } from '@hachej/boring-ui-kit';
9
9
  import { Streamdown } from 'streamdown';
10
10
  import { StickToBottom } from 'use-stick-to-bottom';
11
11
  import { ClassValue } from 'clsx';
12
12
 
13
+ interface UploadFileOptions {
14
+ apiBaseUrl?: string;
15
+ workspaceRequestId?: string | null;
16
+ directory?: string;
17
+ sourcePath?: string;
18
+ }
19
+ interface UploadFileResult {
20
+ url: string;
21
+ path: string;
22
+ }
23
+ declare function uploadFile(file: File, opts?: UploadFileOptions): Promise<UploadFileResult>;
24
+
13
25
  type SlashCommandHandler = (args: string, ctx: SlashCommandContext) => string | void;
14
26
  interface SlashCommand {
15
27
  name: string;
@@ -200,6 +212,12 @@ interface ChatPanelProps {
200
212
  */
201
213
  debug?: boolean;
202
214
  className?: string;
215
+ /** When provided, files are uploaded immediately on attach and sent as stable
216
+ * server URLs rather than base64 data URLs. Supply via useFileUpload() from
217
+ * @hachej/boring-workspace's DataProvider context. */
218
+ onUploadFile?: (file: File) => Promise<{
219
+ url: string;
220
+ }>;
203
221
  }
204
222
  declare function ChatPanel(props: ChatPanelProps): react_jsx_runtime.JSX.Element;
205
223
 
@@ -359,8 +377,13 @@ type PromptInputProps = Omit<HTMLAttributes<HTMLFormElement>, "onSubmit" | "onEr
359
377
  message: string;
360
378
  }) => void;
361
379
  onSubmit: (message: PromptInputMessage, event: FormEvent<HTMLFormElement>) => void | Promise<void>;
380
+ /** When provided, files are uploaded to the server immediately on add and the
381
+ * attachment URL is replaced with the stable server path before submit. */
382
+ onUploadFile?: (file: File) => Promise<{
383
+ url: string;
384
+ }>;
362
385
  };
363
- declare const PromptInput: ({ className, accept, multiple, globalDrop, syncHiddenInput, maxFiles, maxFileSize, onError, onSubmit, children, ...props }: PromptInputProps) => react_jsx_runtime.JSX.Element;
386
+ declare const PromptInput: ({ className, accept, multiple, globalDrop, syncHiddenInput, maxFiles, maxFileSize, onError, onSubmit, onUploadFile, children, ...props }: PromptInputProps) => react_jsx_runtime.JSX.Element;
364
387
  type PromptInputTextareaProps = ComponentProps<typeof InputGroupTextarea>;
365
388
  declare const PromptInputTextarea: ({ onChange, onKeyDown, className, placeholder, ...props }: PromptInputTextareaProps) => react_jsx_runtime.JSX.Element;
366
389
  type PromptInputFooterProps = Omit<ComponentProps<typeof InputGroupAddon>, "align">;
@@ -391,4 +414,4 @@ declare const Shimmer: react.MemoExoticComponent<({ children, as: Component, cla
391
414
 
392
415
  declare function cn(...inputs: ClassValue[]): string;
393
416
 
394
- export { type AgentCommandContribution, type AgentCommandOptions, ArtifactOpenProvider, ChatEmptyState, type ChatEmptyStateProps, ChatPanel, type ChatPanelProps, type ChatSuggestion, CodeBlock, CodeBlockContainer, CodeBlockContent, CodeBlockCopyButton, CodeBlockHeader, type CommandRegistry, Conversation, ConversationContent, ConversationEmptyState, ConversationScrollButton, DebugDrawer, DiffView, type GroupedToolEntry, Message, MessageAction, MessageActions, MessageContent, MessageResponse, MessageToolbar, type OpenArtifactHandler, type ParsedCommand, PromptInput, PromptInputButton, PromptInputFooter, PromptInputSubmit, PromptInputTextarea, Reasoning, ReasoningContent, ReasoningTrigger, Shimmer, type SlashCommand, type SlashCommandContext, ToolCallGroup, type ToolPart, type ToolRenderer, type ToolRendererOverrides, type UseAgentChatOptions, type UseSessionsOptions, type UseSessionsResult, builtinCommands, cn, createCommandRegistry, defaultChatSuggestions, defaultToolRenderers, getAgentCommands, mergeShadcnToolRenderers, mergeToolRenderers, parseSlashCommand, resolveToolRenderer, useAgentChat, useOpenArtifact, useSessions };
417
+ export { type AgentCommandContribution, type AgentCommandOptions, ArtifactOpenProvider, ChatEmptyState, type ChatEmptyStateProps, ChatPanel, type ChatPanelProps, type ChatSuggestion, CodeBlock, CodeBlockContainer, CodeBlockContent, CodeBlockCopyButton, CodeBlockHeader, type CommandRegistry, Conversation, ConversationContent, ConversationEmptyState, ConversationScrollButton, DebugDrawer, DiffView, type GroupedToolEntry, Message, MessageAction, MessageActions, MessageContent, MessageResponse, MessageToolbar, type OpenArtifactHandler, type ParsedCommand, PromptInput, PromptInputButton, PromptInputFooter, PromptInputSubmit, PromptInputTextarea, Reasoning, ReasoningContent, ReasoningTrigger, Shimmer, type SlashCommand, type SlashCommandContext, ToolCallGroup, type ToolPart, type ToolRenderer, type ToolRendererOverrides, type UploadFileOptions, type UploadFileResult, type UseAgentChatOptions, type UseSessionsOptions, type UseSessionsResult, builtinCommands, cn, createCommandRegistry, defaultChatSuggestions, defaultToolRenderers, getAgentCommands, mergeShadcnToolRenderers, mergeToolRenderers, parseSlashCommand, resolveToolRenderer, uploadFile, useAgentChat, useOpenArtifact, useSessions };
@@ -1,3 +1,39 @@
1
+ // src/front/upload/uploadFile.ts
2
+ function readAsDataUrl(file) {
3
+ return new Promise((resolve, reject) => {
4
+ const reader = new FileReader();
5
+ reader.onload = () => resolve(reader.result);
6
+ reader.onerror = () => reject(reader.error ?? new Error("Read failed"));
7
+ reader.readAsDataURL(file);
8
+ });
9
+ }
10
+ async function uploadFile(file, opts = {}) {
11
+ const { apiBaseUrl = "", workspaceRequestId, directory, sourcePath } = opts;
12
+ const dataUrl = await readAsDataUrl(file);
13
+ const comma = dataUrl.indexOf(",");
14
+ const contentBase64 = comma >= 0 ? dataUrl.slice(comma + 1) : "";
15
+ const headers = { "Content-Type": "application/json" };
16
+ if (workspaceRequestId) headers["x-boring-workspace-id"] = workspaceRequestId;
17
+ const base = apiBaseUrl.replace(/\/$/, "");
18
+ const res = await fetch(`${base}/api/v1/files/upload`, {
19
+ method: "POST",
20
+ headers,
21
+ credentials: "include",
22
+ body: JSON.stringify({
23
+ filename: file.name,
24
+ contentType: file.type,
25
+ contentBase64,
26
+ ...directory ? { directory } : {},
27
+ ...sourcePath ? { sourcePath } : {}
28
+ })
29
+ });
30
+ if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
31
+ const body = await res.json();
32
+ const url = body.markdownUrl ?? body.path;
33
+ if (!url) throw new Error("Upload response missing url");
34
+ return { url, path: body.path ?? url };
35
+ }
36
+
1
37
  // src/front/ChatPanel.tsx
2
38
  import { isToolUIPart as isToolUIPart2 } from "ai";
3
39
  import { motion as motion2 } from "motion/react";
@@ -235,6 +271,7 @@ function useAgentChat(opts) {
235
271
  };
236
272
  }, [sessionId, cacheKey, setMessages]);
237
273
  const messages = chat.messages;
274
+ const status = chat.status;
238
275
  useEffect4(() => {
239
276
  if (!hydrated || !cacheKey || messages.length === 0) return;
240
277
  try {
@@ -242,7 +279,38 @@ function useAgentChat(opts) {
242
279
  } catch {
243
280
  }
244
281
  }, [hydrated, cacheKey, messages]);
245
- const status = chat.status;
282
+ const SETTLED_STATES = /* @__PURE__ */ new Set([
283
+ "output-available",
284
+ "output-error",
285
+ "output-denied",
286
+ "approval-responded"
287
+ ]);
288
+ const prevSettleRef = useRef4(status);
289
+ useEffect4(() => {
290
+ const prev = prevSettleRef.current;
291
+ prevSettleRef.current = status;
292
+ if (status !== "ready") return;
293
+ if (prev !== "streaming" && prev !== "submitted") return;
294
+ const hasUnsettled = messages.some(
295
+ (msg) => msg.parts?.some((p) => {
296
+ const part = p;
297
+ return typeof part.type === "string" && part.type.startsWith("tool-") && !SETTLED_STATES.has(part.state);
298
+ })
299
+ );
300
+ if (!hasUnsettled) return;
301
+ setMessages(
302
+ (prev2) => prev2.map((msg) => ({
303
+ ...msg,
304
+ parts: msg.parts?.map((p) => {
305
+ const part = p;
306
+ if (typeof part.type === "string" && part.type.startsWith("tool-") && !SETTLED_STATES.has(part.state)) {
307
+ return { ...part, state: "output-error" };
308
+ }
309
+ return p;
310
+ })
311
+ }))
312
+ );
313
+ }, [status, setMessages]);
246
314
  const prevStatusRef = useRef4(status);
247
315
  useEffect4(() => {
248
316
  const prev = prevStatusRef.current;
@@ -251,19 +319,58 @@ function useAgentChat(opts) {
251
319
  if (prev !== "streaming" && prev !== "submitted") return;
252
320
  if (!sessionId || messages.length === 0) return;
253
321
  const url = `/api/v1/agent/chat/${encodeURIComponent(sessionId)}/messages`;
322
+ const stripped = messages.map((msg) => ({
323
+ ...msg,
324
+ parts: msg.parts?.map((part) => {
325
+ const p = part;
326
+ if (p.type === "file" && typeof p.url === "string" && p.url.startsWith("data:")) {
327
+ return { ...p, url: "" };
328
+ }
329
+ return part;
330
+ })
331
+ }));
254
332
  fetch(url, {
255
333
  method: "PUT",
256
334
  headers: {
257
335
  "Content-Type": "application/json",
258
336
  ...optsRef.current.requestHeaders
259
337
  },
260
- body: JSON.stringify({ messages })
338
+ body: JSON.stringify({ messages: stripped })
261
339
  }).catch(() => {
262
340
  });
263
341
  }, [sessionId, status, messages]);
264
342
  return chat;
265
343
  }
266
344
 
345
+ // src/front/splitFollowUp.ts
346
+ function splitFollowUp(msgs, consumed, genId) {
347
+ const targetIdx = msgs.findIndex(
348
+ (m) => m.role === "assistant" && m.parts?.some((p) => {
349
+ const part = p;
350
+ return part.type === "data-followup-consumed" || part.id?.startsWith("turn-");
351
+ })
352
+ );
353
+ const userMsg = {
354
+ id: genId(),
355
+ role: "user",
356
+ parts: [...consumed.files, { type: "text", text: consumed.text }]
357
+ };
358
+ if (targetIdx < 0) {
359
+ return [...msgs, userMsg];
360
+ }
361
+ const target = msgs[targetIdx];
362
+ const markerIdx = target.parts?.findIndex((p) => {
363
+ const part = p;
364
+ return part.type === "data-followup-consumed" || part.id?.startsWith("turn-");
365
+ }) ?? -1;
366
+ const markerIsPart = markerIdx >= 0 && (target.parts?.[markerIdx]).type === "data-followup-consumed";
367
+ const turn1Parts = markerIdx >= 0 ? target.parts?.slice(0, markerIdx) ?? [] : target.parts ?? [];
368
+ const turn2Parts = markerIdx >= 0 ? target.parts?.slice(markerIsPart ? markerIdx + 1 : markerIdx) ?? [] : [];
369
+ const asst1 = { ...target, parts: turn1Parts };
370
+ const asst2 = { ...target, id: genId(), parts: turn2Parts };
371
+ return [...msgs.slice(0, targetIdx), asst1, userMsg, asst2, ...msgs.slice(targetIdx + 1)];
372
+ }
373
+
267
374
  // src/front/DebugDrawer.tsx
268
375
  import { useCallback as useCallback3, useEffect as useEffect5, useRef as useRef5, useState as useState4 } from "react";
269
376
  import { Button, IconButton, Tabs, TabsContent, TabsList, TabsTrigger } from "@hachej/boring-ui-kit";
@@ -2731,6 +2838,7 @@ var PromptInput = ({
2731
2838
  maxFileSize,
2732
2839
  onError,
2733
2840
  onSubmit,
2841
+ onUploadFile,
2734
2842
  children,
2735
2843
  ...props
2736
2844
  }) => {
@@ -2739,6 +2847,13 @@ var PromptInput = ({
2739
2847
  const inputRef = useRef9(null);
2740
2848
  const formRef = useRef9(null);
2741
2849
  const [items, setItems] = useState12([]);
2850
+ const setFileUrlLocal = useCallback10((id, url, status) => {
2851
+ setItems((prev) => prev.map((f) => {
2852
+ if (f.id !== id) return f;
2853
+ if (f.url !== url && f.url.startsWith("blob:")) URL.revokeObjectURL(f.url);
2854
+ return { ...f, url, status };
2855
+ }));
2856
+ }, []);
2742
2857
  const files = usingProvider ? controller.attachments.files : items;
2743
2858
  const [referencedSources, setReferencedSources] = useState12([]);
2744
2859
  const filesRef = useRef9(files);
@@ -2784,34 +2899,37 @@ var PromptInput = ({
2784
2899
  });
2785
2900
  return;
2786
2901
  }
2787
- setItems((prev) => {
2788
- const capacity = typeof maxFiles === "number" ? Math.max(0, maxFiles - prev.length) : void 0;
2789
- const capped = typeof capacity === "number" ? sized.slice(0, capacity) : sized;
2790
- if (typeof capacity === "number" && sized.length > capacity) {
2791
- onError?.({
2792
- code: "max_files",
2793
- message: "Too many files. Some were not added."
2794
- });
2795
- }
2796
- const next = [];
2797
- for (const file of capped) {
2798
- next.push({
2799
- filename: file.name,
2800
- id: nanoid(),
2801
- mediaType: file.type,
2802
- type: "file",
2803
- url: URL.createObjectURL(file)
2804
- });
2902
+ const capacity = typeof maxFiles === "number" ? Math.max(0, maxFiles - items.length) : void 0;
2903
+ const capped = typeof capacity === "number" ? sized.slice(0, capacity) : sized;
2904
+ if (typeof capacity === "number" && sized.length > capacity) {
2905
+ onError?.({
2906
+ code: "max_files",
2907
+ message: "Too many files. Some were not added."
2908
+ });
2909
+ }
2910
+ const entries = capped.map((file) => ({
2911
+ filename: file.name,
2912
+ id: nanoid(),
2913
+ mediaType: file.type,
2914
+ type: "file",
2915
+ url: URL.createObjectURL(file),
2916
+ status: onUploadFile ? "uploading" : "ready"
2917
+ }));
2918
+ setItems((prev) => [...prev, ...entries]);
2919
+ if (onUploadFile) {
2920
+ for (let i = 0; i < entries.length; i++) {
2921
+ const entry = entries[i];
2922
+ const file = capped[i];
2923
+ onUploadFile(file).then(({ url }) => setFileUrlLocal(entry.id, url, "ready")).catch(() => setFileUrlLocal(entry.id, entry.url, "error"));
2805
2924
  }
2806
- return [...prev, ...next];
2807
- });
2925
+ }
2808
2926
  },
2809
- [matchesAccept, maxFiles, maxFileSize, onError]
2927
+ [matchesAccept, maxFiles, maxFileSize, onError, items.length, onUploadFile, setFileUrlLocal]
2810
2928
  );
2811
2929
  const removeLocal = useCallback10(
2812
2930
  (id) => setItems((prev) => {
2813
2931
  const found = prev.find((file) => file.id === id);
2814
- if (found?.url) {
2932
+ if (found?.url.startsWith("blob:")) {
2815
2933
  URL.revokeObjectURL(found.url);
2816
2934
  }
2817
2935
  return prev.filter((file) => file.id !== id);
@@ -2856,7 +2974,7 @@ var PromptInput = ({
2856
2974
  const clearAttachments = useCallback10(
2857
2975
  () => usingProvider ? controller?.attachments.clear() : setItems((prev) => {
2858
2976
  for (const file of prev) {
2859
- if (file.url) {
2977
+ if (file.url.startsWith("blob:")) {
2860
2978
  URL.revokeObjectURL(file.url);
2861
2979
  }
2862
2980
  }
@@ -2959,6 +3077,7 @@ var PromptInput = ({
2959
3077
  },
2960
3078
  [add]
2961
3079
  );
3080
+ const setFileUrl = usingProvider ? controller.attachments.setFileUrl : setFileUrlLocal;
2962
3081
  const attachmentsCtx = useMemo8(
2963
3082
  () => ({
2964
3083
  add,
@@ -2966,9 +3085,10 @@ var PromptInput = ({
2966
3085
  fileInputRef: inputRef,
2967
3086
  files: files.map((item) => ({ ...item, id: item.id })),
2968
3087
  openFileDialog,
2969
- remove
3088
+ remove,
3089
+ setFileUrl
2970
3090
  }),
2971
- [files, add, remove, clearAttachments, openFileDialog]
3091
+ [files, add, remove, clearAttachments, openFileDialog, setFileUrl]
2972
3092
  );
2973
3093
  const refsCtx = useMemo8(
2974
3094
  () => ({
@@ -2995,18 +3115,16 @@ var PromptInput = ({
2995
3115
  const formData = new FormData(form);
2996
3116
  return formData.get("message") || "";
2997
3117
  })();
3118
+ if (files.some((f) => f.status === "uploading")) return;
2998
3119
  if (!usingProvider) {
2999
3120
  form.reset();
3000
3121
  }
3001
3122
  try {
3002
3123
  const convertedFiles = await Promise.all(
3003
- files.map(async ({ id: _id, ...item }) => {
3124
+ files.map(async ({ id: _id, status: _status, ...item }) => {
3004
3125
  if (item.url?.startsWith("blob:")) {
3005
3126
  const dataUrl = await convertBlobUrlToDataUrl(item.url);
3006
- return {
3007
- ...item,
3008
- url: dataUrl ?? item.url
3009
- };
3127
+ return { ...item, url: dataUrl ?? item.url };
3010
3128
  }
3011
3129
  return item;
3012
3130
  })
@@ -3472,7 +3590,7 @@ var AttachmentRemove = ({
3472
3590
  };
3473
3591
 
3474
3592
  // src/front/ChatPanel.tsx
3475
- import { PaperclipIcon as PaperclipIcon2, CopyIcon as CopyIcon4, CheckIcon as CheckIcon3, RefreshCwIcon as RefreshCwIcon2, BrainIcon as BrainIcon2, EyeIcon, EyeOffIcon, BotIcon } from "lucide-react";
3593
+ import { PaperclipIcon as PaperclipIcon2, CopyIcon as CopyIcon4, CheckIcon as CheckIcon3, RefreshCwIcon as RefreshCwIcon2, BrainIcon as BrainIcon2, EyeIcon, EyeOffIcon, BotIcon, Loader2, AlertCircleIcon } from "lucide-react";
3476
3594
  import {
3477
3595
  Button as Button11,
3478
3596
  IconButton as IconButton2,
@@ -3492,6 +3610,14 @@ import {
3492
3610
  } from "@hachej/boring-ui-kit";
3493
3611
  import { Fragment as Fragment8, jsx as jsx23, jsxs as jsxs21 } from "react/jsx-runtime";
3494
3612
  var INLINE_TEXT_MIME_PREFIXES = ["text/", "application/json", "application/xml", "application/yaml"];
3613
+ async function resolveAttachmentUrls(files) {
3614
+ if (!files) return [];
3615
+ return Promise.all(files.map(async (file) => ({
3616
+ filename: file.filename,
3617
+ mediaType: file.mediaType,
3618
+ url: file.url.startsWith("blob:") ? await convertBlobUrlToDataUrl(file.url) ?? file.url : file.url
3619
+ })));
3620
+ }
3495
3621
  async function readFileAsText(file) {
3496
3622
  const looksText = INLINE_TEXT_MIME_PREFIXES.some((p) => file.mediaType?.startsWith(p)) || /\.(md|txt|csv|json|yaml|yml|ts|tsx|js|jsx|py|rb|rs|go|sh|bash|css|html|sql|log)$/i.test(file.filename ?? "");
3497
3623
  if (!looksText) return null;
@@ -3646,9 +3772,14 @@ function ChatPanel(props) {
3646
3772
  onData,
3647
3773
  requestHeaders,
3648
3774
  onOpenArtifact,
3775
+ onUploadFile,
3649
3776
  debug = false
3650
3777
  } = props;
3651
3778
  const [debugWidth, setDebugWidth] = useState13(440);
3779
+ const [pendingMessage, setPendingMessage] = useState13(null);
3780
+ const pendingMessageRef = useRef10(null);
3781
+ const consumedFollowUpRef = useRef10(null);
3782
+ const followUpSplitIdsRef = useRef10(null);
3652
3783
  const {
3653
3784
  messages,
3654
3785
  sendMessage,
@@ -3657,7 +3788,22 @@ function ChatPanel(props) {
3657
3788
  error,
3658
3789
  stop,
3659
3790
  clearError
3660
- } = useAgentChat({ sessionId, onData, requestHeaders });
3791
+ } = useAgentChat({
3792
+ sessionId,
3793
+ onData: (part) => {
3794
+ if (part?.type === "data-followup-consumed") {
3795
+ const pending = pendingMessageRef.current;
3796
+ consumedFollowUpRef.current = pending ? { text: pending.text, files: pending.files } : null;
3797
+ followUpSplitIdsRef.current = {
3798
+ userId: globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-followup-user`,
3799
+ assistantId: globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-followup-assistant`
3800
+ };
3801
+ setPendingMessage(null);
3802
+ }
3803
+ onData?.(part);
3804
+ },
3805
+ requestHeaders
3806
+ });
3661
3807
  const mergedToolRenderers = mergeShadcnToolRenderers(toolRenderers);
3662
3808
  const registry = useMemo10(
3663
3809
  () => createCommandRegistry([...builtinCommands, ...extraCommands ?? []]),
@@ -3777,6 +3923,70 @@ function ChatPanel(props) {
3777
3923
  return () => globalThis.removeEventListener?.("boring:model-change", onChange);
3778
3924
  }, []);
3779
3925
  const isStreaming = status === "submitted" || status === "streaming";
3926
+ const displayMessages = useMemo10(() => {
3927
+ const consumed = consumedFollowUpRef.current ?? pendingMessageRef.current;
3928
+ const ids = followUpSplitIdsRef.current;
3929
+ if (!consumed || !ids) return messages;
3930
+ const hasFollowUpPart = messages.some(
3931
+ (m) => m.role === "assistant" && m.parts?.some((p) => p.id?.startsWith("turn-"))
3932
+ );
3933
+ if (!hasFollowUpPart) return messages;
3934
+ let n = 0;
3935
+ return splitFollowUp(messages, consumed, () => n++ === 0 ? ids.userId : ids.assistantId);
3936
+ }, [messages]);
3937
+ const prevStatusForQueue = useRef10(status);
3938
+ useEffect11(() => {
3939
+ const prev = prevStatusForQueue.current;
3940
+ prevStatusForQueue.current = status;
3941
+ if (status !== "ready") return;
3942
+ if (prev !== "streaming" && prev !== "submitted") return;
3943
+ const consumed = consumedFollowUpRef.current ?? pendingMessageRef.current;
3944
+ if (!consumed) return;
3945
+ const ids = followUpSplitIdsRef.current;
3946
+ consumedFollowUpRef.current = null;
3947
+ followUpSplitIdsRef.current = null;
3948
+ pendingMessageRef.current = null;
3949
+ setPendingMessage(null);
3950
+ let n = 0;
3951
+ const genId = () => ids ? n++ === 0 ? ids.userId : ids.assistantId : globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`;
3952
+ const nextMessages = splitFollowUp(messages, consumed, genId);
3953
+ setMessages(nextMessages);
3954
+ fetch(`/api/v1/agent/chat/${encodeURIComponent(sessionId)}/messages`, {
3955
+ method: "PUT",
3956
+ headers: {
3957
+ "Content-Type": "application/json",
3958
+ ...requestHeaders
3959
+ },
3960
+ body: JSON.stringify({ messages: nextMessages })
3961
+ }).catch(() => {
3962
+ });
3963
+ }, [status, messages, sessionId, requestHeaders, setMessages]);
3964
+ const handleStop = useCallback12(() => {
3965
+ stop();
3966
+ consumedFollowUpRef.current = null;
3967
+ followUpSplitIdsRef.current = null;
3968
+ pendingMessageRef.current = null;
3969
+ setPendingMessage(null);
3970
+ fetch(`/api/v1/agent/chat/${encodeURIComponent(sessionId)}/followup`, {
3971
+ method: "DELETE",
3972
+ headers: requestHeaders
3973
+ }).catch(() => {
3974
+ });
3975
+ }, [stop, sessionId, requestHeaders]);
3976
+ const handleInterrupt = useCallback12(() => {
3977
+ stop();
3978
+ }, [stop]);
3979
+ useEffect11(() => {
3980
+ const onKeyDown = (e) => {
3981
+ if (e.key !== "Escape" || !isStreaming) return;
3982
+ const tag = e.target?.tagName;
3983
+ if (tag === "INPUT" || tag === "TEXTAREA") return;
3984
+ e.preventDefault();
3985
+ handleInterrupt();
3986
+ };
3987
+ window.addEventListener("keydown", onKeyDown);
3988
+ return () => window.removeEventListener("keydown", onKeyDown);
3989
+ }, [isStreaming, handleInterrupt]);
3780
3990
  const userHistory = useMemo10(
3781
3991
  () => messages.filter((m) => m.role === "user").map((m) => m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n").trim()).filter(Boolean),
3782
3992
  [messages]
@@ -3935,6 +4145,29 @@ ${content}
3935
4145
  ...mentionNote ? [mentionNote] : []
3936
4146
  ].filter(Boolean).join("\n\n") || text;
3937
4147
  setMentionedFiles([]);
4148
+ const resolvedAttachments = await resolveAttachmentUrls(files);
4149
+ if (isStreaming) {
4150
+ if (!pendingMessage) {
4151
+ const nextPending = {
4152
+ text,
4153
+ files: files ?? [],
4154
+ serverMessage,
4155
+ attachments: resolvedAttachments
4156
+ };
4157
+ pendingMessageRef.current = nextPending;
4158
+ setPendingMessage(nextPending);
4159
+ fetch(`/api/v1/agent/chat/${encodeURIComponent(sessionId)}/followup`, {
4160
+ method: "POST",
4161
+ headers: {
4162
+ "Content-Type": "application/json",
4163
+ ...requestHeaders
4164
+ },
4165
+ body: JSON.stringify({ message: serverMessage, attachments: resolvedAttachments })
4166
+ }).catch(() => {
4167
+ });
4168
+ }
4169
+ return;
4170
+ }
3938
4171
  void sendMessage(
3939
4172
  {
3940
4173
  // Rendered bubble: unchanged user text + file chips via files parts.
@@ -3951,13 +4184,7 @@ ${content}
3951
4184
  // schema treats it as optional; omitting it keeps the existing
3952
4185
  // 'off' default behaviour for hosts that don't expose the toggle.
3953
4186
  ...thinkingControl ? { thinkingLevel } : {},
3954
- attachments: files?.map((f) => ({
3955
- filename: f.filename,
3956
- mediaType: f.mediaType,
3957
- // Keep the data URL so the server could later forward images
3958
- // to multimodal-capable providers.
3959
- url: f.url
3960
- })) ?? []
4187
+ attachments: resolvedAttachments
3961
4188
  }
3962
4189
  }
3963
4190
  );
@@ -4007,7 +4234,7 @@ ${content}
4007
4234
  "mx-auto flex w-full flex-col gap-6",
4008
4235
  chrome ? "max-w-3xl px-6 py-8" : "max-w-[680px] px-4 py-4"
4009
4236
  ), children: [
4010
- messages.length === 0 && /* @__PURE__ */ jsx23(
4237
+ displayMessages.length === 0 && /* @__PURE__ */ jsx23(
4011
4238
  ChatEmptyState,
4012
4239
  {
4013
4240
  eyebrow: emptyEyebrow,
@@ -4031,7 +4258,7 @@ ${content}
4031
4258
  }
4032
4259
  }
4033
4260
  ),
4034
- messages.map((message, messageIndex) => {
4261
+ displayMessages.map((message, messageIndex) => {
4035
4262
  const role = message.role === "user" || message.role === "assistant" ? message.role : "assistant";
4036
4263
  const textParts = message.parts.filter(isTextPart);
4037
4264
  const fileParts = message.parts.filter(isFilePart);
@@ -4063,6 +4290,7 @@ ${reasoningPart.text}`;
4063
4290
  } else {
4064
4291
  acc.push({ kind: "tool-group", tools: [{ part: item.part, key: item.key }], key: item.key });
4065
4292
  }
4293
+ } else if (item.kind === "part" && !isTextPart(item.part)) {
4066
4294
  } else {
4067
4295
  acc.push(item);
4068
4296
  }
@@ -4196,6 +4424,10 @@ ${reasoningPart.text}`;
4196
4424
  message.id
4197
4425
  );
4198
4426
  }),
4427
+ pendingMessage && /* @__PURE__ */ jsx23(Message, { from: "user", children: /* @__PURE__ */ jsxs21(MessageContent, { className: "opacity-50", children: [
4428
+ /* @__PURE__ */ jsx23("div", { className: "text-[11px] font-medium text-muted-foreground mb-1", children: "Follow-up" }),
4429
+ /* @__PURE__ */ jsx23("div", { className: "text-sm whitespace-pre-wrap", children: pendingMessage.text })
4430
+ ] }) }),
4199
4431
  (() => {
4200
4432
  if (!error) return null;
4201
4433
  const friendly = friendlyError(error);
@@ -4328,6 +4560,7 @@ ${reasoningPart.text}`;
4328
4560
  {
4329
4561
  "data-boring-state": status,
4330
4562
  onSubmit: handleSubmit,
4563
+ onUploadFile,
4331
4564
  multiple: true,
4332
4565
  maxFiles: 20,
4333
4566
  maxFileSize: 5 * 1024 * 1024,
@@ -4403,7 +4636,7 @@ ${reasoningPart.text}`;
4403
4636
  PromptInputSubmit,
4404
4637
  {
4405
4638
  status,
4406
- onStop: stop,
4639
+ onStop: handleStop,
4407
4640
  className: cn(
4408
4641
  // Primary action. Uses the warm accent (not `primary`,
4409
4642
  // which is a neutral foreground tone) — this is the one
@@ -4684,15 +4917,20 @@ function AttachmentsList() {
4684
4917
  // Compact pill — medium border, muted fill, room for a
4685
4918
  // thumbnail + name + remove action.
4686
4919
  "!h-9 !gap-2 !rounded-full !border-input/80 !bg-muted/40 !pl-1 !pr-2",
4687
- "transition-colors hover:!bg-muted/70 hover:!text-foreground"
4920
+ "transition-colors hover:!bg-muted/70 hover:!text-foreground",
4921
+ file.status === "error" && "!border-destructive/50 !bg-destructive/10"
4688
4922
  ),
4689
4923
  children: [
4690
- /* @__PURE__ */ jsx23(
4691
- AttachmentPreview,
4692
- {
4693
- className: "!size-7 shrink-0 overflow-hidden !rounded-full bg-background/60"
4694
- }
4695
- ),
4924
+ /* @__PURE__ */ jsxs21("div", { className: "relative shrink-0", children: [
4925
+ /* @__PURE__ */ jsx23(
4926
+ AttachmentPreview,
4927
+ {
4928
+ className: "!size-7 overflow-hidden !rounded-full bg-background/60"
4929
+ }
4930
+ ),
4931
+ file.status === "uploading" && /* @__PURE__ */ jsx23("div", { className: "absolute inset-0 flex items-center justify-center rounded-full bg-background/70", children: /* @__PURE__ */ jsx23(Loader2, { className: "size-3.5 animate-spin text-muted-foreground" }) }),
4932
+ file.status === "error" && /* @__PURE__ */ jsx23("div", { className: "absolute inset-0 flex items-center justify-center rounded-full bg-destructive/20", children: /* @__PURE__ */ jsx23(AlertCircleIcon, { className: "size-3.5 text-destructive" }) })
4933
+ ] }),
4696
4934
  /* @__PURE__ */ jsx23(
4697
4935
  AttachmentInfo,
4698
4936
  {
@@ -5020,6 +5258,7 @@ export {
5020
5258
  mergeToolRenderers,
5021
5259
  parseSlashCommand,
5022
5260
  resolveToolRenderer,
5261
+ uploadFile,
5023
5262
  useAgentChat,
5024
5263
  useOpenArtifact,
5025
5264
  useSessions
@@ -978,6 +978,12 @@
978
978
  --tw-border-style: none;
979
979
  border-style: none;
980
980
  }
981
+ .\!border-destructive\/50 {
982
+ border-color: var(--destructive) !important;
983
+ @supports (color: color-mix(in lab, red, red)) {
984
+ border-color: color-mix(in oklab, var(--destructive) 50%, transparent) !important;
985
+ }
986
+ }
981
987
  .\!border-input\/80 {
982
988
  border-color: var(--input) !important;
983
989
  @supports (color: color-mix(in lab, red, red)) {
@@ -1089,6 +1095,12 @@
1089
1095
  .border-l-transparent {
1090
1096
  border-left-color: transparent;
1091
1097
  }
1098
+ .\!bg-destructive\/10 {
1099
+ background-color: var(--destructive) !important;
1100
+ @supports (color: color-mix(in lab, red, red)) {
1101
+ background-color: color-mix(in oklab, var(--destructive) 10%, transparent) !important;
1102
+ }
1103
+ }
1092
1104
  .\!bg-muted\/40 {
1093
1105
  background-color: var(--muted) !important;
1094
1106
  @supports (color: color-mix(in lab, red, red)) {
@@ -1206,6 +1218,12 @@
1206
1218
  background-color: color-mix(in oklab, var(--background) 60%, transparent);
1207
1219
  }
1208
1220
  }
1221
+ .bg-background\/70 {
1222
+ background-color: var(--background);
1223
+ @supports (color: color-mix(in lab, red, red)) {
1224
+ background-color: color-mix(in oklab, var(--background) 70%, transparent);
1225
+ }
1226
+ }
1209
1227
  .bg-background\/80 {
1210
1228
  background-color: var(--background);
1211
1229
  @supports (color: color-mix(in lab, red, red)) {
@@ -1278,6 +1296,12 @@
1278
1296
  background-color: color-mix(in oklab, var(--destructive) 10%, transparent);
1279
1297
  }
1280
1298
  }
1299
+ .bg-destructive\/20 {
1300
+ background-color: var(--destructive);
1301
+ @supports (color: color-mix(in lab, red, red)) {
1302
+ background-color: color-mix(in oklab, var(--destructive) 20%, transparent);
1303
+ }
1304
+ }
1281
1305
  .bg-foreground {
1282
1306
  background-color: var(--foreground);
1283
1307
  }