@saadnvd1/agent-os 0.3.672 → 0.3.678

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 (88) hide show
  1. package/README.md +2 -1
  2. package/app/api/bus/read/route.ts +34 -0
  3. package/app/api/files/upload-temp/route.test.ts +144 -0
  4. package/app/api/files/upload-temp/route.ts +52 -21
  5. package/app/api/search/conversations/context/route.ts +26 -0
  6. package/app/api/search/conversations/route.test.ts +139 -0
  7. package/app/api/search/conversations/route.ts +25 -0
  8. package/app/api/sessions/[id]/route.ts +2 -0
  9. package/app/api/sessions/route.ts +6 -2
  10. package/app/globals.css +25 -0
  11. package/components/Bookmarks/BookmarksDialog.tsx +2 -8
  12. package/components/Chat/ChatPanel.tsx +9 -2
  13. package/components/Chat/FileChip.tsx +67 -0
  14. package/components/Chat/Messages.tsx +8 -0
  15. package/components/Chat/Queue.tsx +6 -0
  16. package/components/Chat/composer/Attachments.tsx +16 -2
  17. package/components/Chat/composer/ComposerBody.tsx +35 -14
  18. package/components/Chat/composer/Toolbar.tsx +11 -8
  19. package/components/Chat/composer/editor.test.ts +1 -1
  20. package/components/Chat/composer/paste.ts +5 -4
  21. package/components/Chat/composer/useAttachFiles.ts +142 -0
  22. package/components/Chat/composer/useComposerEditor.ts +3 -3
  23. package/components/Chat/useDraft.ts +9 -2
  24. package/components/Chat/useGoToItem.ts +11 -0
  25. package/components/CommandPalette/CommandPalette.tsx +23 -156
  26. package/components/CommandPalette/CommandsBody.tsx +216 -0
  27. package/components/CommandPalette/conversations/ConversationSearch.tsx +246 -0
  28. package/components/CommandPalette/conversations/EmptyState.tsx +95 -0
  29. package/components/CommandPalette/conversations/Preview.tsx +129 -0
  30. package/components/CommandPalette/conversations/ResultList.tsx +91 -0
  31. package/components/CommandPalette/conversations/Snippet.tsx +24 -0
  32. package/components/CommandPalette/conversations/format.ts +30 -0
  33. package/components/CommandPalette/conversations/recent.test.ts +20 -0
  34. package/components/CommandPalette/conversations/recent.ts +35 -0
  35. package/components/CommandPalette/conversations/useOpenHit.ts +30 -0
  36. package/components/Draft/DraftPanel.tsx +7 -4
  37. package/components/Terminal/index.tsx +6 -5
  38. package/data/chat/useChat.ts +10 -3
  39. package/data/client-config/queries.ts +18 -0
  40. package/data/search/index.ts +65 -0
  41. package/data/search/keys.ts +7 -0
  42. package/data/sessions/queries.ts +2 -1
  43. package/hooks/useDebouncedValue.ts +11 -0
  44. package/lib/agents/brief.ts +1 -0
  45. package/lib/attachments/check.ts +50 -0
  46. package/lib/attachments/message.ts +49 -0
  47. package/lib/attachments/policy.ts +116 -0
  48. package/lib/attachments/store.ts +92 -0
  49. package/lib/attachments/types.ts +8 -0
  50. package/lib/bus/condense.ts +242 -0
  51. package/lib/bus/read-scope.ts +109 -0
  52. package/lib/bus/read.ts +181 -0
  53. package/lib/chat/client-send.ts +6 -2
  54. package/lib/chat/driver.ts +4 -1
  55. package/lib/chat/drivers/claude.ts +2 -0
  56. package/lib/chat/events.ts +13 -1
  57. package/lib/chat/pending.ts +2 -1
  58. package/lib/chat/queued.ts +24 -7
  59. package/lib/chat/runner.ts +11 -2
  60. package/lib/chat/worker/host.ts +9 -3
  61. package/lib/chat/worker/protocol.ts +2 -0
  62. package/lib/config/client-config-types.ts +14 -3
  63. package/lib/config/client-config.ts +1 -1
  64. package/lib/db/migrations/20261019T1200_chat_queue_files.ts +14 -0
  65. package/lib/db/migrations/20261019T1500_conversation_search.ts +12 -0
  66. package/lib/file-upload.ts +61 -27
  67. package/lib/intake/intake.ts +2 -17
  68. package/lib/intake/sniff.ts +1 -1
  69. package/lib/orchestrator/read.ts +18 -4
  70. package/lib/palette/registry.ts +4 -0
  71. package/lib/resurrect/index.ts +2 -3
  72. package/lib/search/conversations/backfill.ts +117 -0
  73. package/lib/search/conversations/buildMatchQuery.ts +31 -0
  74. package/lib/search/conversations/index.ts +9 -0
  75. package/lib/search/conversations/messageContext.ts +45 -0
  76. package/lib/search/conversations/schema.ts +71 -0
  77. package/lib/search/conversations/searchConversations.ts +151 -0
  78. package/lib/search/conversations/types.ts +45 -0
  79. package/lib/sessions/launch.ts +12 -6
  80. package/lib/sidebar/row-actions.ts +19 -8
  81. package/lib/sidebar/row-menu.ts +1 -1
  82. package/lib/tasks/restorable.ts +16 -0
  83. package/package.json +1 -1
  84. package/scripts/bench-conversation-search.ts +73 -0
  85. package/server.ts +3 -0
  86. package/stores/chatJump.ts +10 -0
  87. package/stores/palette.ts +22 -4
  88. package/components/Chat/composer/useImageDrop.ts +0 -52
@@ -18,6 +18,7 @@ import { splitProse, splitUserText } from "@/lib/chat/user-text";
18
18
  import { inAppSessionId } from "@/lib/session-url";
19
19
  import { CodeBlock } from "./Code";
20
20
  import { CopyButton } from "./CopyButton";
21
+ import { FileChip } from "./FileChip";
21
22
  import { ImageThumb } from "./ImageThumb";
22
23
  import { Markdown } from "./Markdown";
23
24
  import { openSessionLink } from "./session-link";
@@ -178,6 +179,13 @@ export function UserMessage({
178
179
  ))}
179
180
  </div>
180
181
  ) : null}
182
+ {item.files?.length ? (
183
+ <div data-copy-skip className="flex flex-wrap justify-end gap-1.5">
184
+ {item.files.map((f) => (
185
+ <FileChip key={f.path} file={f} />
186
+ ))}
187
+ </div>
188
+ ) : null}
181
189
  {item.text && (
182
190
  <div
183
191
  className={cn(
@@ -90,6 +90,12 @@ export function Queue({
90
90
  · {m.imageCount} image{m.imageCount > 1 ? "s" : ""}
91
91
  </span>
92
92
  ) : null}
93
+ {m.fileCount ? (
94
+ <span className="text-muted-foreground text-xs">
95
+ {" "}
96
+ · {m.fileCount} file{m.fileCount > 1 ? "s" : ""}
97
+ </span>
98
+ ) : null}
93
99
  </button>
94
100
  )}
95
101
  <div className="flex shrink-0 items-center">
@@ -2,6 +2,8 @@
2
2
 
3
3
  import { FileText, X } from "lucide-react";
4
4
  import type { ChatImage } from "@/lib/chat/events";
5
+ import { FileChip } from "../FileChip";
6
+ import type { Upload } from "./useAttachFiles";
5
7
  import { byteLength, type TextAttachment } from "@/lib/chat/paste";
6
8
 
7
9
  const size = (text: string) => {
@@ -24,19 +26,23 @@ function Remove({ label, onClick }: { label: string; onClick: () => void }) {
24
26
  );
25
27
  }
26
28
 
27
- // Images and long pastes waiting to go with the message.
29
+ // Images, files and long pastes waiting to go with the message.
28
30
  export function Attachments({
29
31
  images,
30
32
  files,
33
+ uploads,
31
34
  onRemoveImage,
32
35
  onRemoveFile,
36
+ onRemoveUpload,
33
37
  }: {
34
38
  images: ChatImage[];
35
39
  files: TextAttachment[];
40
+ uploads: Upload[];
36
41
  onRemoveImage: (index: number) => void;
37
42
  onRemoveFile: (index: number) => void;
43
+ onRemoveUpload: (key: string) => void;
38
44
  }) {
39
- if (!images.length && !files.length) return null;
45
+ if (!images.length && !files.length && !uploads.length) return null;
40
46
  return (
41
47
  <div className="flex flex-wrap gap-2 px-1 pb-2">
42
48
  {images.map((img, i) => (
@@ -50,6 +56,14 @@ export function Attachments({
50
56
  <Remove label="Remove image" onClick={() => onRemoveImage(i)} />
51
57
  </div>
52
58
  ))}
59
+ {uploads.map((u) => (
60
+ <FileChip
61
+ key={u.key}
62
+ file={u.file}
63
+ uploading={u.uploading}
64
+ onRemove={() => onRemoveUpload(u.key)}
65
+ />
66
+ ))}
53
67
  {files.map((file, i) => (
54
68
  <div
55
69
  key={`file-${i}`}
@@ -6,6 +6,7 @@ import { CornerDownRight, File, Folder } from "lucide-react";
6
6
  import type {
7
7
  ChatAccess,
8
8
  ChatCommand,
9
+ ChatFile,
9
10
  ChatImage,
10
11
  ChatModel,
11
12
  FileSuggestion,
@@ -28,7 +29,7 @@ import { useCoarsePointer } from "./useCoarsePointer";
28
29
  import { useMentions } from "./useMentions";
29
30
  import { Attachments } from "./Attachments";
30
31
  import { Toolbar } from "./Toolbar";
31
- import { useImageDrop } from "./useImageDrop";
32
+ import { useAttachFiles } from "./useAttachFiles";
32
33
  import type { MenuKey } from "./keys";
33
34
  import { useComposerEditor, type ComposerHandlers } from "./useComposerEditor";
34
35
  import { useFocusOnShow } from "./autofocus";
@@ -44,7 +45,9 @@ export interface ComposerProps {
44
45
  running: boolean;
45
46
  disabled?: boolean;
46
47
  placeholder: string;
47
- onSend: (text: string, images: ChatImage[]) => void;
48
+ onSend: (text: string, images: ChatImage[], files: ChatFile[]) => void;
49
+ // Where attached files are kept: the session's id, or "draft" before one exists.
50
+ attachScope: string;
48
51
  onStop: () => void;
49
52
  commands?: ChatCommand[];
50
53
  // Reloads the commands past the cache (a skill just added), from the menu.
@@ -99,6 +102,7 @@ export function ComposerBody({
99
102
  accessory,
100
103
  prefill,
101
104
  draftKey,
105
+ attachScope,
102
106
  suggestion = null,
103
107
  onDismissSuggestion,
104
108
  history = [],
@@ -132,7 +136,19 @@ export function ComposerBody({
132
136
  if (text && suggestion) onDismissSuggestion?.(suggestion);
133
137
  }, [text, suggestion, onDismissSuggestion]);
134
138
 
135
- const draft = useMemo(() => ({ text, images, files }), [text, images, files]);
139
+ const attach = useAttachFiles({
140
+ scope: attachScope,
141
+ imageCount: images.length,
142
+ onImages: (read) => setImages((prev) => [...prev, ...read]),
143
+ });
144
+ const attached = useMemo(
145
+ () => attach.uploads.filter((u) => !u.uploading).map((u) => u.file),
146
+ [attach.uploads]
147
+ );
148
+ const draft = useMemo(
149
+ () => ({ text, images, files, uploads: attached }),
150
+ [text, images, files, attached]
151
+ );
136
152
  useSaveDraft(draftKey, draft);
137
153
 
138
154
  // Restored once the editor exists (the server render has no access to this
@@ -145,7 +161,8 @@ export function ComposerBody({
145
161
  setText(saved?.text ?? "");
146
162
  setImages(saved?.images ?? []);
147
163
  setFiles(saved?.files ?? []);
148
- }, [draftKey, editor, setText]);
164
+ attach.restore(saved?.uploads ?? []);
165
+ }, [draftKey, editor, setText]); // eslint-disable-line react-hooks/exhaustive-deps
149
166
  useFocusOnShow(editor, focusKey, phone, disabled);
150
167
 
151
168
  const prefilled = useRef<number | null>(null);
@@ -228,16 +245,16 @@ export function ComposerBody({
228
245
  if (offered) setText(offered, true);
229
246
  };
230
247
 
231
- const { dragging, dropProps, addFiles } = useImageDrop((read) =>
232
- setImages((prev) => [...prev, ...read])
233
- );
248
+ const { dragging, dropProps, addFiles } = attach;
234
249
 
235
- const empty = !text.trim() && !images.length && !files.length;
250
+ const empty =
251
+ !text.trim() && !images.length && !files.length && !attach.uploads.length;
236
252
  const submit = () => {
237
- if (empty || disabled) return;
238
- onSend(composeMessage(text, files), images);
253
+ if (empty || disabled || attach.uploading) return;
254
+ onSend(composeMessage(text, files), images, attached);
239
255
  setImages([]);
240
256
  setFiles([]);
257
+ attach.clear();
241
258
  setText("");
242
259
  setHistoryIndex(-1);
243
260
  };
@@ -261,7 +278,8 @@ export function ComposerBody({
261
278
  suggestion: ghost !== null,
262
279
  onAcceptSuggestion: acceptSuggestion,
263
280
  onHistory: (dir, at) => {
264
- if (images.length || files.length) return false;
281
+ if (images.length || files.length || attach.uploads.length)
282
+ return false;
265
283
  const step = historyStep(
266
284
  { history, index: historyIndex, text, ...at },
267
285
  dir
@@ -271,7 +289,7 @@ export function ComposerBody({
271
289
  setText(step.text, true);
272
290
  return true;
273
291
  },
274
- onImages: (picked) => void addFiles(picked),
292
+ onFiles: (picked) => void addFiles(picked),
275
293
  onLongPaste: (pasted, language) => {
276
294
  const next = nextAttachment(files, lastPaste.current);
277
295
  lastPaste.current = next.number;
@@ -320,6 +338,8 @@ export function ComposerBody({
320
338
  <Attachments
321
339
  images={images}
322
340
  files={files}
341
+ uploads={attach.uploads}
342
+ onRemoveUpload={attach.remove}
323
343
  onRemoveImage={(i) => setImages(images.filter((_, j) => j !== i))}
324
344
  onRemoveFile={(i) => setFiles(files.filter((_, j) => j !== i))}
325
345
  />
@@ -329,7 +349,8 @@ export function ComposerBody({
329
349
  <Toolbar
330
350
  plain={plain}
331
351
  onTogglePlain={() => setPlain(!plain)}
332
- onAddImages={(picked) => void addFiles(picked)}
352
+ onAddFiles={(picked) => void addFiles(picked)}
353
+ accept={attach.accept}
333
354
  plan={plan}
334
355
  onTogglePlan={onTogglePlan}
335
356
  accessory={accessory}
@@ -343,7 +364,7 @@ export function ComposerBody({
343
364
  onModelOpenChange={setModelOpen}
344
365
  stop={running && empty}
345
366
  onStop={onStop}
346
- canSend={!disabled && !empty}
367
+ canSend={!disabled && !empty && !attach.uploading}
347
368
  onSend={submit}
348
369
  />
349
370
  </div>
@@ -3,7 +3,7 @@
3
3
  import { useRef, useState } from "react";
4
4
  import {
5
5
  ArrowUp,
6
- ImagePlus,
6
+ Paperclip,
7
7
  ListChecks,
8
8
  MonitorUp,
9
9
  Square,
@@ -23,7 +23,8 @@ import { ModelPicker } from "../ModelPicker";
23
23
  export function Toolbar({
24
24
  plain,
25
25
  onTogglePlain,
26
- onAddImages,
26
+ onAddFiles,
27
+ accept,
27
28
  plan,
28
29
  onTogglePlan,
29
30
  accessory,
@@ -42,7 +43,9 @@ export function Toolbar({
42
43
  }: {
43
44
  plain: boolean;
44
45
  onTogglePlain: () => void;
45
- onAddImages: (files: Iterable<File>) => void;
46
+ onAddFiles: (files: Iterable<File>) => void;
47
+ // The types the server takes, for the picker.
48
+ accept: string;
46
49
  // Plan mode, when the session can use it.
47
50
  plan?: boolean | null;
48
51
  onTogglePlan?: () => void;
@@ -68,7 +71,7 @@ export function Toolbar({
68
71
  const capture = async () => {
69
72
  try {
70
73
  const file = await captureScreenFrame();
71
- if (file) onAddImages([file]);
74
+ if (file) onAddFiles([file]);
72
75
  } catch (error) {
73
76
  toast.error(
74
77
  `Couldn't capture the screen: ${error instanceof Error ? error.message : String(error)}`
@@ -81,20 +84,20 @@ export function Toolbar({
81
84
  type="button"
82
85
  variant="ghost"
83
86
  size="icon"
84
- aria-label="Attach image"
87
+ aria-label="Attach files"
85
88
  className="h-10 w-10 shrink-0"
86
89
  onClick={() => fileRef.current?.click()}
87
90
  >
88
- <ImagePlus className="h-4 w-4" />
91
+ <Paperclip className="h-4 w-4" />
89
92
  </Button>
90
93
  <input
91
94
  ref={fileRef}
92
95
  type="file"
93
- accept="image/*"
96
+ accept={accept}
94
97
  multiple
95
98
  hidden
96
99
  onChange={(e) => {
97
- if (e.target.files) onAddImages(e.target.files);
100
+ if (e.target.files) onAddFiles(e.target.files);
98
101
  e.target.value = "";
99
102
  }}
100
103
  />
@@ -168,7 +168,7 @@ function paste(
168
168
  return handlePaste(editor.view, event, {
169
169
  plain: false,
170
170
  asText: false,
171
- onImages: () => {},
171
+ onFiles: () => {},
172
172
  onLongPaste: () => {},
173
173
  ...h,
174
174
  });
@@ -8,7 +8,8 @@ export interface PasteHandlers {
8
8
  plain: boolean;
9
9
  // Cmd/Ctrl+Shift+V was pressed just before this paste.
10
10
  asText: boolean;
11
- onImages: (files: File[]) => void;
11
+ // Files pasted: images, documents, anything the composer may attach.
12
+ onFiles: (files: File[]) => void;
12
13
  onLongPaste: (text: string, language?: string) => void;
13
14
  }
14
15
 
@@ -58,9 +59,9 @@ export function handlePaste(
58
59
  ): boolean {
59
60
  const data = event.clipboardData;
60
61
  if (!data) return false;
61
- const images = [...data.files].filter(isImage);
62
- if (images.length) {
63
- h.onImages(images);
62
+ const files = [...data.files];
63
+ if (files.length) {
64
+ h.onFiles(files);
64
65
  return true;
65
66
  }
66
67
  const text = data.getData("text/plain").replace(/\r\n?/g, "\n");
@@ -0,0 +1,142 @@
1
+ "use client";
2
+
3
+ import { useRef, useState } from "react";
4
+ import { useQueryClient } from "@tanstack/react-query";
5
+ import { toast } from "sonner";
6
+ import {
7
+ clientConfigQuery,
8
+ useClientConfig,
9
+ } from "@/data/client-config/queries";
10
+ import {
11
+ acceptAttr,
12
+ tooBigMessage,
13
+ typeFor,
14
+ unsupportedMessage,
15
+ } from "@/lib/attachments/policy";
16
+ import type { ChatFile, ChatImage } from "@/lib/chat/events";
17
+ import { uploadFile } from "@/lib/file-upload";
18
+ import { isImage } from "./paste";
19
+
20
+ export interface Upload {
21
+ key: string;
22
+ file: ChatFile;
23
+ uploading?: boolean;
24
+ }
25
+
26
+ function readImage(file: File): Promise<ChatImage> {
27
+ return new Promise((resolve, reject) => {
28
+ const reader = new FileReader();
29
+ reader.onload = () => {
30
+ const [meta, data] = String(reader.result).split(",");
31
+ resolve({ mediaType: meta.slice(5, meta.indexOf(";")), data });
32
+ };
33
+ reader.onerror = reject;
34
+ reader.readAsDataURL(file);
35
+ });
36
+ }
37
+
38
+ const carriesFiles = (e: React.DragEvent) =>
39
+ [...e.dataTransfer.types].includes("Files");
40
+
41
+ let seq = 0;
42
+
43
+ // Files picked, pasted or dropped onto the composer, and the outline shown
44
+ // while something is dragged over it. Images are read in and sent with the
45
+ // message; any other file is uploaded to the session's attachments folder
46
+ // at once, so a wrong type or a too-big file fails here, not at send. The
47
+ // types and sizes come from the server (/api/client-config).
48
+ export function useAttachFiles({
49
+ scope,
50
+ imageCount,
51
+ onImages,
52
+ }: {
53
+ // The session's id, or "draft" for a chat that hasn't started.
54
+ scope: string;
55
+ imageCount: number;
56
+ onImages: (images: ChatImage[]) => void;
57
+ }) {
58
+ const queryClient = useQueryClient();
59
+ const types = useClientConfig().data?.attachments.types;
60
+ const [dragging, setDragging] = useState(false);
61
+ const [uploads, setUploads] = useState<Upload[]>([]);
62
+ const held = useRef(0);
63
+ held.current = imageCount + uploads.length;
64
+
65
+ const addFiles = async (picked: Iterable<File>) => {
66
+ // Copied first: a file input's live list is emptied right after the pick.
67
+ const list = [...picked];
68
+ let limits;
69
+ try {
70
+ limits = (await queryClient.fetchQuery(clientConfigQuery)).attachments;
71
+ } catch (e) {
72
+ toast.error(e instanceof Error ? e.message : "Couldn't attach");
73
+ return;
74
+ }
75
+ const images: File[] = [];
76
+ const others: File[] = [];
77
+ for (const f of list) {
78
+ let error: string | null = null;
79
+ if (held.current + images.length + others.length >= limits.max)
80
+ error = `You can attach up to ${limits.max} files to one message.`;
81
+ else if (isImage(f)) {
82
+ if (f.size > limits.maxImageBytes)
83
+ error = tooBigMessage(f.name, f.size, limits.maxImageBytes);
84
+ else images.push(f);
85
+ } else if (!typeFor(f.name, limits.types))
86
+ error = unsupportedMessage(f.name);
87
+ else if (f.size > limits.maxBytes)
88
+ error = tooBigMessage(f.name, f.size, limits.maxBytes);
89
+ else others.push(f);
90
+ if (error) toast.error(error);
91
+ }
92
+ if (images.length) onImages(await Promise.all(images.map(readImage)));
93
+ for (const f of others) {
94
+ const key = `up-${++seq}`;
95
+ const file = { path: "", name: f.name, size: f.size, mediaType: f.type };
96
+ setUploads((prev) => [...prev, { key, file, uploading: true }]);
97
+ const done = await uploadFile(f, scope);
98
+ if (done.ok)
99
+ setUploads((prev) =>
100
+ prev.map((u) => (u.key === key ? { key, file: done.file } : u))
101
+ );
102
+ else {
103
+ setUploads((prev) => prev.filter((u) => u.key !== key));
104
+ toast.error(done.error);
105
+ }
106
+ }
107
+ };
108
+
109
+ const dropProps = {
110
+ onDragOver: (e: React.DragEvent) => {
111
+ if (!carriesFiles(e)) return;
112
+ e.preventDefault();
113
+ e.dataTransfer.dropEffect = "copy";
114
+ setDragging(true);
115
+ },
116
+ onDragLeave: (e: React.DragEvent) => {
117
+ if (!e.currentTarget.contains(e.relatedTarget as Node | null))
118
+ setDragging(false);
119
+ },
120
+ onDrop: (e: React.DragEvent) => {
121
+ if (!carriesFiles(e)) return;
122
+ e.preventDefault();
123
+ setDragging(false);
124
+ void addFiles(e.dataTransfer.files);
125
+ },
126
+ };
127
+
128
+ return {
129
+ dragging,
130
+ dropProps,
131
+ addFiles,
132
+ uploads,
133
+ // Until the limits are read, the picker offers images only.
134
+ accept: types ? `image/*,${acceptAttr(types)}` : "image/*",
135
+ uploading: uploads.some((u) => u.uploading),
136
+ remove: (key: string) =>
137
+ setUploads((prev) => prev.filter((u) => u.key !== key)),
138
+ clear: () => setUploads([]),
139
+ restore: (files: ChatFile[]) =>
140
+ setUploads(files.map((file) => ({ key: `up-${++seq}`, file }))),
141
+ };
142
+ }
@@ -13,7 +13,7 @@ import { docToMarkdown } from "@/lib/chat/markdown/serialize";
13
13
  import { plainExtensions, richExtensions } from "./extensions";
14
14
  import { loadMarkdown } from "./load";
15
15
  import { handleKeyDown, type KeyHandlers } from "./keys";
16
- import { handlePaste, isImage, type PasteHandlers } from "./paste";
16
+ import { handlePaste, type PasteHandlers } from "./paste";
17
17
  import { usePlainMode } from "./usePlainMode";
18
18
 
19
19
  export type ComposerHandlers = Omit<
@@ -120,9 +120,9 @@ export function useComposerEditor({
120
120
  asText,
121
121
  });
122
122
  },
123
- // Dropped images are the composer's to take, not the document's.
123
+ // Dropped files are the composer's to take, not the document's.
124
124
  handleDrop: (view, event) =>
125
- [...(event.dataTransfer?.files ?? [])].some(isImage),
125
+ (event.dataTransfer?.files.length ?? 0) > 0,
126
126
  },
127
127
  // Only edits change the text: not loading a draft, not setEditable.
128
128
  onUpdate: ({ editor: ed, transaction }) => {
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useRef } from "react";
4
- import type { ChatImage } from "@/lib/chat/events";
4
+ import type { ChatFile, ChatImage } from "@/lib/chat/events";
5
5
  import type { TextAttachment } from "@/lib/chat/paste";
6
6
 
7
7
  // `text` is the composer's markdown, so a reload restores its formatting.
@@ -9,6 +9,8 @@ interface Draft {
9
9
  text: string;
10
10
  images: ChatImage[];
11
11
  files?: TextAttachment[];
12
+ // Attached files, saved on the server: references only.
13
+ uploads?: ChatFile[];
12
14
  }
13
15
 
14
16
  const keyFor = (id: string) => `agentos:draft:${id}`;
@@ -39,7 +41,12 @@ export function useSaveDraft(id: string | undefined, draft: Draft): void {
39
41
  clearTimeout(timer.current);
40
42
  timer.current = setTimeout(() => {
41
43
  const key = keyFor(id);
42
- if (!draft.text.trim() && !draft.images.length && !draft.files?.length) {
44
+ if (
45
+ !draft.text.trim() &&
46
+ !draft.images.length &&
47
+ !draft.files?.length &&
48
+ !draft.uploads?.length
49
+ ) {
43
50
  localStorage.removeItem(key);
44
51
  return;
45
52
  }
@@ -17,6 +17,16 @@ interface Deps {
17
17
  foldEvents: boolean;
18
18
  }
19
19
 
20
+ // The message arrived at is marked for a moment (app/globals.css fades it).
21
+ function flash(block: string) {
22
+ const el = document.querySelector(`[data-block="${CSS.escape(block)}"]`);
23
+ if (!el) return;
24
+ el.removeAttribute("data-flash");
25
+ void (el as HTMLElement).offsetWidth;
26
+ el.setAttribute("data-flash", "");
27
+ setTimeout(() => el.removeAttribute("data-flash"), 2000);
28
+ }
29
+
20
30
  // Takes the reader to a message by a rule over the loaded items (the strip's
21
31
  // "the message where the question was asked"): older pages load until it is
22
32
  // found, then its folded rows open and it scrolls into view.
@@ -54,6 +64,7 @@ export function useGoToItem(d: Deps) {
54
64
  )
55
65
  ?.scrollIntoView({ block: "center" })
56
66
  )
67
+ .then(() => flash(where.block))
57
68
  .catch(() => {});
58
69
  }, [
59
70
  find,