@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.
- package/README.md +2 -1
- package/app/api/bus/read/route.ts +34 -0
- package/app/api/files/upload-temp/route.test.ts +144 -0
- package/app/api/files/upload-temp/route.ts +52 -21
- package/app/api/search/conversations/context/route.ts +26 -0
- package/app/api/search/conversations/route.test.ts +139 -0
- package/app/api/search/conversations/route.ts +25 -0
- package/app/api/sessions/[id]/route.ts +2 -0
- package/app/api/sessions/route.ts +6 -2
- package/app/globals.css +25 -0
- package/components/Bookmarks/BookmarksDialog.tsx +2 -8
- package/components/Chat/ChatPanel.tsx +9 -2
- package/components/Chat/FileChip.tsx +67 -0
- package/components/Chat/Messages.tsx +8 -0
- package/components/Chat/Queue.tsx +6 -0
- package/components/Chat/composer/Attachments.tsx +16 -2
- package/components/Chat/composer/ComposerBody.tsx +35 -14
- package/components/Chat/composer/Toolbar.tsx +11 -8
- package/components/Chat/composer/editor.test.ts +1 -1
- package/components/Chat/composer/paste.ts +5 -4
- package/components/Chat/composer/useAttachFiles.ts +142 -0
- package/components/Chat/composer/useComposerEditor.ts +3 -3
- package/components/Chat/useDraft.ts +9 -2
- package/components/Chat/useGoToItem.ts +11 -0
- package/components/CommandPalette/CommandPalette.tsx +23 -156
- package/components/CommandPalette/CommandsBody.tsx +216 -0
- package/components/CommandPalette/conversations/ConversationSearch.tsx +246 -0
- package/components/CommandPalette/conversations/EmptyState.tsx +95 -0
- package/components/CommandPalette/conversations/Preview.tsx +129 -0
- package/components/CommandPalette/conversations/ResultList.tsx +91 -0
- package/components/CommandPalette/conversations/Snippet.tsx +24 -0
- package/components/CommandPalette/conversations/format.ts +30 -0
- package/components/CommandPalette/conversations/recent.test.ts +20 -0
- package/components/CommandPalette/conversations/recent.ts +35 -0
- package/components/CommandPalette/conversations/useOpenHit.ts +30 -0
- package/components/Draft/DraftPanel.tsx +7 -4
- package/components/Terminal/index.tsx +6 -5
- package/data/chat/useChat.ts +10 -3
- package/data/client-config/queries.ts +18 -0
- package/data/search/index.ts +65 -0
- package/data/search/keys.ts +7 -0
- package/data/sessions/queries.ts +2 -1
- package/hooks/useDebouncedValue.ts +11 -0
- package/lib/agents/brief.ts +1 -0
- package/lib/attachments/check.ts +50 -0
- package/lib/attachments/message.ts +49 -0
- package/lib/attachments/policy.ts +116 -0
- package/lib/attachments/store.ts +92 -0
- package/lib/attachments/types.ts +8 -0
- package/lib/bus/condense.ts +242 -0
- package/lib/bus/read-scope.ts +109 -0
- package/lib/bus/read.ts +181 -0
- package/lib/chat/client-send.ts +6 -2
- package/lib/chat/driver.ts +4 -1
- package/lib/chat/drivers/claude.ts +2 -0
- package/lib/chat/events.ts +13 -1
- package/lib/chat/pending.ts +2 -1
- package/lib/chat/queued.ts +24 -7
- package/lib/chat/runner.ts +11 -2
- package/lib/chat/worker/host.ts +9 -3
- package/lib/chat/worker/protocol.ts +2 -0
- package/lib/config/client-config-types.ts +14 -3
- package/lib/config/client-config.ts +1 -1
- package/lib/db/migrations/20261019T1200_chat_queue_files.ts +14 -0
- package/lib/db/migrations/20261019T1500_conversation_search.ts +12 -0
- package/lib/file-upload.ts +61 -27
- package/lib/intake/intake.ts +2 -17
- package/lib/intake/sniff.ts +1 -1
- package/lib/orchestrator/read.ts +18 -4
- package/lib/palette/registry.ts +4 -0
- package/lib/resurrect/index.ts +2 -3
- package/lib/search/conversations/backfill.ts +117 -0
- package/lib/search/conversations/buildMatchQuery.ts +31 -0
- package/lib/search/conversations/index.ts +9 -0
- package/lib/search/conversations/messageContext.ts +45 -0
- package/lib/search/conversations/schema.ts +71 -0
- package/lib/search/conversations/searchConversations.ts +151 -0
- package/lib/search/conversations/types.ts +45 -0
- package/lib/sessions/launch.ts +12 -6
- package/lib/sidebar/row-actions.ts +19 -8
- package/lib/sidebar/row-menu.ts +1 -1
- package/lib/tasks/restorable.ts +16 -0
- package/package.json +1 -1
- package/scripts/bench-conversation-search.ts +73 -0
- package/server.ts +3 -0
- package/stores/chatJump.ts +10 -0
- package/stores/palette.ts +22 -4
- 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 {
|
|
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
|
|
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
|
-
|
|
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 } =
|
|
232
|
-
setImages((prev) => [...prev, ...read])
|
|
233
|
-
);
|
|
248
|
+
const { dragging, dropProps, addFiles } = attach;
|
|
234
249
|
|
|
235
|
-
const empty =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
87
|
+
aria-label="Attach files"
|
|
85
88
|
className="h-10 w-10 shrink-0"
|
|
86
89
|
onClick={() => fileRef.current?.click()}
|
|
87
90
|
>
|
|
88
|
-
<
|
|
91
|
+
<Paperclip className="h-4 w-4" />
|
|
89
92
|
</Button>
|
|
90
93
|
<input
|
|
91
94
|
ref={fileRef}
|
|
92
95
|
type="file"
|
|
93
|
-
accept=
|
|
96
|
+
accept={accept}
|
|
94
97
|
multiple
|
|
95
98
|
hidden
|
|
96
99
|
onChange={(e) => {
|
|
97
|
-
if (e.target.files)
|
|
100
|
+
if (e.target.files) onAddFiles(e.target.files);
|
|
98
101
|
e.target.value = "";
|
|
99
102
|
}}
|
|
100
103
|
/>
|
|
@@ -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
|
-
|
|
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
|
|
62
|
-
if (
|
|
63
|
-
h.
|
|
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,
|
|
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
|
|
123
|
+
// Dropped files are the composer's to take, not the document's.
|
|
124
124
|
handleDrop: (view, event) =>
|
|
125
|
-
|
|
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 (
|
|
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,
|