@saadnvd1/agent-os 0.3.671 → 0.3.673

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 (75) hide show
  1. package/app/api/files/upload-temp/route.test.ts +144 -0
  2. package/app/api/files/upload-temp/route.ts +52 -21
  3. package/app/api/projects/[id]/route.ts +26 -0
  4. package/app/api/sessions/[id]/route.ts +2 -0
  5. package/app/api/sessions/route.ts +6 -2
  6. package/components/Chat/ChatPanel.tsx +9 -2
  7. package/components/Chat/FileChip.tsx +67 -0
  8. package/components/Chat/Messages.tsx +8 -0
  9. package/components/Chat/Queue.tsx +6 -0
  10. package/components/Chat/composer/Attachments.tsx +16 -2
  11. package/components/Chat/composer/ComposerBody.tsx +35 -14
  12. package/components/Chat/composer/Toolbar.tsx +11 -8
  13. package/components/Chat/composer/editor.test.ts +1 -1
  14. package/components/Chat/composer/paste.ts +5 -4
  15. package/components/Chat/composer/useAttachFiles.ts +142 -0
  16. package/components/Chat/composer/useComposerEditor.ts +3 -3
  17. package/components/Chat/useDraft.ts +9 -2
  18. package/components/Draft/DraftPanel.tsx +7 -4
  19. package/components/Projects/ProjectSettingsDialog.tsx +7 -0
  20. package/components/Projects/ProjectTierField.tsx +39 -0
  21. package/components/Terminal/index.tsx +6 -5
  22. package/components/Workspaces/ProjectTiersForm.tsx +33 -0
  23. package/components/Workspaces/WorkspaceSettings.tsx +2 -0
  24. package/data/chat/useChat.ts +10 -3
  25. package/data/client-config/queries.ts +18 -0
  26. package/data/projects/queries.ts +4 -0
  27. package/data/sessions/queries.ts +2 -1
  28. package/lib/attachments/check.ts +50 -0
  29. package/lib/attachments/message.ts +49 -0
  30. package/lib/attachments/policy.ts +116 -0
  31. package/lib/attachments/store.ts +92 -0
  32. package/lib/attachments/types.ts +8 -0
  33. package/lib/chat/client-send.ts +6 -2
  34. package/lib/chat/driver.ts +4 -1
  35. package/lib/chat/drivers/claude.ts +2 -0
  36. package/lib/chat/events.ts +13 -1
  37. package/lib/chat/pending.ts +2 -1
  38. package/lib/chat/queued.ts +24 -7
  39. package/lib/chat/runner.ts +11 -2
  40. package/lib/chat/worker/host.ts +7 -3
  41. package/lib/chat/worker/protocol.ts +2 -0
  42. package/lib/config/client-config-types.ts +14 -3
  43. package/lib/config/client-config.ts +1 -1
  44. package/lib/db/migrations/20261018T2130_project_tier.ts +38 -0
  45. package/lib/db/migrations/20261019T1200_chat_queue_files.ts +14 -0
  46. package/lib/db/types.ts +4 -0
  47. package/lib/file-upload.ts +61 -27
  48. package/lib/intake/intake.ts +2 -17
  49. package/lib/intake/sniff.ts +1 -1
  50. package/lib/orchestrator/armed.test-helpers.ts +4 -0
  51. package/lib/orchestrator/armed.ts +14 -1
  52. package/lib/orchestrator/brief.ts +9 -2
  53. package/lib/orchestrator/fast-lane-state.ts +69 -0
  54. package/lib/orchestrator/fast-lane.ts +96 -0
  55. package/lib/orchestrator/gates.ts +8 -2
  56. package/lib/orchestrator/project-tier-tool.ts +33 -0
  57. package/lib/orchestrator/review.ts +3 -0
  58. package/lib/orchestrator/serve.ts +6 -1
  59. package/lib/orchestrator/signoff.test-helpers.ts +3 -0
  60. package/lib/orchestrator/signoff.ts +36 -19
  61. package/lib/orchestrator/tool-names.ts +1 -0
  62. package/lib/orchestrator/tool-schemas.ts +8 -1
  63. package/lib/orchestrator/tools.ts +2 -0
  64. package/lib/orchestrator/watcher.ts +5 -0
  65. package/lib/project-tier/getProjectTier.ts +18 -0
  66. package/lib/project-tier/index.ts +3 -0
  67. package/lib/project-tier/setProjectTier.ts +14 -0
  68. package/lib/project-tier/types.ts +3 -0
  69. package/lib/sessions/launch.ts +12 -6
  70. package/lib/start-options.ts +8 -1
  71. package/lib/tasks/finish.ts +2 -1
  72. package/lib/tasks/index.ts +10 -5
  73. package/lib/tasks/state.ts +7 -1
  74. package/package.json +1 -1
  75. package/components/Chat/composer/useImageDrop.ts +0 -52
@@ -0,0 +1,144 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import { ATTACHMENT_LIMITS } from "@/lib/attachments/policy";
5
+ import { attachmentsRoot } from "@/lib/attachments/store";
6
+ import { POST } from "./route";
7
+
8
+ const PDF = Buffer.from("%PDF-1.4\n1 0 obj\n<<>>\nendobj\n");
9
+ const ZIPPED = Buffer.concat([
10
+ Buffer.from([0x50, 0x4b, 0x03, 0x04]),
11
+ Buffer.alloc(32, 7),
12
+ ]);
13
+ const PNG = Buffer.concat([
14
+ Buffer.from([0x89, 0x50, 0x4e, 0x47]),
15
+ Buffer.alloc(32, 1),
16
+ ]);
17
+
18
+ const SESSION = "test-upload-session";
19
+
20
+ const upload = (
21
+ filename: string,
22
+ data: Buffer | string,
23
+ extra: Record<string, unknown> = {}
24
+ ) =>
25
+ POST(
26
+ new Request("http://localhost/api/files/upload-temp", {
27
+ method: "POST",
28
+ body: JSON.stringify({
29
+ filename,
30
+ base64: Buffer.from(data).toString("base64"),
31
+ ...extra,
32
+ }),
33
+ })
34
+ );
35
+
36
+ afterEach(() => {
37
+ fs.rmSync(path.join(attachmentsRoot(), SESSION), {
38
+ recursive: true,
39
+ force: true,
40
+ });
41
+ });
42
+
43
+ describe("POST /api/files/upload-temp", () => {
44
+ it.each([
45
+ ["report.pdf", PDF, "pdf"],
46
+ ["sheet.xlsx", ZIPPED, "sheet"],
47
+ ["letter.docx", ZIPPED, "doc"],
48
+ ["data.csv", "a,b\n1,2\n", "sheet"],
49
+ ["notes.md", "# hi\n", "text"],
50
+ ["app.ts", "export const a = 1;\n", "text"],
51
+ ["shot.png", PNG, "image"],
52
+ ])("takes %s", async (name, data, kind) => {
53
+ const res = await upload(name, data, { sessionId: SESSION });
54
+ expect(res.status).toBe(200);
55
+ const body = await res.json();
56
+ expect(body.kind).toBe(kind);
57
+ expect(body.path).toBe(path.join(attachmentsRoot(), SESSION, name));
58
+ expect(fs.readFileSync(body.path)).toEqual(Buffer.from(data));
59
+ });
60
+
61
+ it("keeps the old shape: no session means temp storage and a path", async () => {
62
+ const res = await upload("old.csv", "a,b\n");
63
+ const body = await res.json();
64
+ expect(res.status).toBe(200);
65
+ expect(body.path).toContain("agent-os-screenshots");
66
+ expect(body.path).not.toContain(attachmentsRoot());
67
+ fs.rmSync(body.path);
68
+ });
69
+
70
+ it("refuses types it doesn't take, saying which", async () => {
71
+ for (const name of ["a.zip", "run.sh", "tool.exe", "noext"]) {
72
+ const res = await upload(name, "x", { sessionId: SESSION });
73
+ expect(res.status).toBe(415);
74
+ expect((await res.json()).error).toContain(`${name}: this file type`);
75
+ }
76
+ expect(fs.existsSync(path.join(attachmentsRoot(), SESSION))).toBe(false);
77
+ });
78
+
79
+ it("refuses a file that isn't what its name says", async () => {
80
+ for (const [name, data] of [
81
+ ["fake.xlsx", "plain text"],
82
+ ["fake.pdf", "plain text"],
83
+ ["fake.xls", ZIPPED],
84
+ ["fake.csv", Buffer.from([0, 1, 2, 0])],
85
+ ["fake.png", PDF],
86
+ ] as const) {
87
+ const res = await upload(name, data, { sessionId: SESSION });
88
+ expect(res.status, name).toBe(400);
89
+ expect((await res.json()).error).toContain("doesn't look like a real");
90
+ }
91
+ });
92
+
93
+ it("refuses an empty file and a missing body", async () => {
94
+ expect((await upload("a.txt", "", { sessionId: SESSION })).status).toBe(
95
+ 400
96
+ );
97
+ const none = await POST(
98
+ new Request("http://localhost/x", {
99
+ method: "POST",
100
+ body: JSON.stringify({ filename: "a.txt" }),
101
+ })
102
+ );
103
+ expect(none.status).toBe(400);
104
+ });
105
+
106
+ it("refuses a file over the limit, and says what the limit is", async () => {
107
+ const big = Buffer.alloc(ATTACHMENT_LIMITS.maxBytes + 1, 97);
108
+ const res = await upload("big.txt", big, { sessionId: SESSION });
109
+ expect(res.status).toBe(413);
110
+ expect((await res.json()).error).toContain("the limit is 25.0 MB");
111
+ });
112
+
113
+ it("refuses an image over the image limit", async () => {
114
+ const big = Buffer.concat([
115
+ PNG,
116
+ Buffer.alloc(ATTACHMENT_LIMITS.maxImageBytes),
117
+ ]);
118
+ expect((await upload("big.png", big)).status).toBe(413);
119
+ });
120
+
121
+ it("refuses a body over the limit before reading it", async () => {
122
+ const res = await POST(
123
+ new Request("http://localhost/x", {
124
+ method: "POST",
125
+ headers: { "content-length": String(ATTACHMENT_LIMITS.maxBytes * 2) },
126
+ body: "{}",
127
+ })
128
+ );
129
+ expect(res.status).toBe(413);
130
+ });
131
+
132
+ it("keeps a hostile name inside the session's folder", async () => {
133
+ const res = await upload("../../escape.pdf", PDF, { sessionId: SESSION });
134
+ const { path: saved } = await res.json();
135
+ expect(path.dirname(saved)).toBe(path.join(attachmentsRoot(), SESSION));
136
+ });
137
+
138
+ it("never overwrites: a second file with the same name gets its own", async () => {
139
+ const a = await (await upload("x.csv", "1", { sessionId: SESSION })).json();
140
+ const b = await (await upload("x.csv", "2", { sessionId: SESSION })).json();
141
+ expect(a.path).not.toBe(b.path);
142
+ expect(fs.readFileSync(a.path, "utf8")).toBe("1");
143
+ });
144
+ });
@@ -2,38 +2,69 @@ import { NextResponse } from "next/server";
2
2
  import { mkdir, writeFile } from "fs/promises";
3
3
  import path from "path";
4
4
  import os from "os";
5
+ import { checkUpload } from "@/lib/attachments/check";
6
+ import { ATTACHMENT_LIMITS, tooBigMessage } from "@/lib/attachments/policy";
7
+ import { saveAttachment } from "@/lib/attachments/store";
8
+ import { peerChatLink } from "@/lib/chat/peer-relay";
5
9
 
10
+ // Base64 is a third bigger than the bytes, and the JSON around it is small.
11
+ const MAX_BODY = Math.ceil((ATTACHMENT_LIMITS.maxBytes * 4) / 3) + 64 * 1024;
12
+
13
+ const fail = (error: string, status: number) =>
14
+ NextResponse.json({ error }, { status });
15
+
16
+ // POST /api/files/upload-temp { filename, base64, mimeType?, sessionId? }
17
+ // Without a sessionId the file goes to the shared temp folder (the terminal's
18
+ // drop, the file picker). With one - a session's id, or "draft" for a chat
19
+ // not started yet - it is checked against lib/attachments/policy and kept in
20
+ // that session's attachments folder, outside any repo.
6
21
  export async function POST(request: Request) {
7
22
  try {
8
- const { filename, base64, mimeType } = await request.json();
23
+ const length = Number(request.headers.get("content-length"));
24
+ if (length > MAX_BODY)
25
+ return fail(
26
+ tooBigMessage("That file", length, ATTACHMENT_LIMITS.maxBytes),
27
+ 413
28
+ );
29
+ const { filename, base64, sessionId } = await request.json();
30
+
31
+ if (typeof base64 !== "string" || !base64) return fail("No file data", 400);
32
+ const name =
33
+ typeof filename === "string" && filename ? filename : "attachment";
34
+ const buffer = Buffer.from(base64, "base64");
35
+ const checked = checkUpload(name, buffer);
36
+ if (!checked.ok) return fail(checked.error, checked.status);
9
37
 
10
- if (!base64) {
11
- return NextResponse.json({ error: "No image data" }, { status: 400 });
38
+ if (typeof sessionId === "string" && sessionId) {
39
+ if (sessionId !== "draft" && peerChatLink(sessionId))
40
+ return fail(
41
+ "Files can't be attached to a session running on another machine yet. Images still work.",
42
+ 409
43
+ );
44
+ const file = saveAttachment(
45
+ sessionId,
46
+ name,
47
+ buffer,
48
+ checked.type.mediaType
49
+ );
50
+ return NextResponse.json({ ...file, kind: checked.type.kind });
12
51
  }
13
52
 
14
- // Create temp directory for screenshots if it doesn't exist
15
53
  const tempDir = path.join(os.tmpdir(), "agent-os-screenshots");
16
54
  await mkdir(tempDir, { recursive: true });
17
-
18
- // Generate unique filename
19
- const ext = mimeType?.split("/")[1] || "png";
20
- const safeName = filename?.replace(/[^a-zA-Z0-9.-]/g, "_") || "screenshot";
21
- const uniqueName = `${Date.now()}-${safeName}`;
22
- const finalName = uniqueName.endsWith(`.${ext}`)
23
- ? uniqueName
24
- : `${uniqueName}.${ext}`;
25
- const filePath = path.join(tempDir, finalName);
26
-
27
- // Decode base64 and write file
28
- const buffer = Buffer.from(base64, "base64");
55
+ const safeName = name.replace(/[^a-zA-Z0-9.-]/g, "_");
56
+ const filePath = path.join(tempDir, `${Date.now()}-${safeName}`);
29
57
  await writeFile(filePath, buffer);
30
58
 
31
- return NextResponse.json({ path: filePath });
59
+ return NextResponse.json({
60
+ path: filePath,
61
+ name: safeName,
62
+ size: buffer.length,
63
+ mediaType: checked.type.mediaType,
64
+ kind: checked.type.kind,
65
+ });
32
66
  } catch (error) {
33
67
  console.error("Upload error:", error);
34
- return NextResponse.json(
35
- { error: "Failed to save image" },
36
- { status: 500 }
37
- );
68
+ return fail("Failed to save the file", 500);
38
69
  }
39
70
  }
@@ -1,5 +1,7 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
+ import { z } from "zod";
2
3
  import { setProjectWorkspace } from "@/lib/workspaces";
4
+ import { PROJECT_TIERS, setProjectTier } from "@/lib/project-tier";
3
5
  import {
4
6
  getProjectWithDevServers,
5
7
  updateProject,
@@ -7,6 +9,8 @@ import {
7
9
  toggleProjectExpanded,
8
10
  } from "@/lib/projects";
9
11
 
12
+ const tierSchema = z.enum(PROJECT_TIERS);
13
+
10
14
  interface RouteParams {
11
15
  params: Promise<{ id: string }>;
12
16
  }
@@ -46,6 +50,17 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
46
50
  workspaceId,
47
51
  } = body;
48
52
 
53
+ let tier: z.infer<typeof tierSchema> | undefined;
54
+ if (body.tier !== undefined) {
55
+ const parsed = tierSchema.safeParse(body.tier);
56
+ if (!parsed.success)
57
+ return NextResponse.json(
58
+ { error: "tier must be production or fast" },
59
+ { status: 400 }
60
+ );
61
+ tier = parsed.data;
62
+ }
63
+
49
64
  // Handle expanded toggle separately
50
65
  if (typeof expanded === "boolean") {
51
66
  toggleProjectExpanded(id, expanded);
@@ -56,6 +71,17 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
56
71
  setProjectWorkspace(id, workspaceId);
57
72
  }
58
73
 
74
+ if (tier) {
75
+ try {
76
+ setProjectTier(id, tier);
77
+ } catch {
78
+ return NextResponse.json(
79
+ { error: "Project not found or cannot be modified" },
80
+ { status: 404 }
81
+ );
82
+ }
83
+ }
84
+
59
85
  // Update other fields if provided
60
86
  if (
61
87
  name ||
@@ -1,3 +1,4 @@
1
+ import { removeSessionAttachments } from "@/lib/attachments/store";
1
2
  import { NextRequest, NextResponse } from "next/server";
2
3
  import { deletionRefusal } from "@/lib/orchestrator/home";
3
4
  import { getDb, queries, type Session } from "@/lib/db";
@@ -281,6 +282,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
281
282
  );
282
283
  deleteItems(id);
283
284
  clearQueue(id);
285
+ void removeSessionAttachments(id).catch(() => {});
284
286
  queries.deleteSession(db).run(id);
285
287
 
286
288
  // Clean up worktree in background (non-blocking)
@@ -50,8 +50,12 @@ export async function POST(request: NextRequest) {
50
50
  }
51
51
  const str = (v: unknown) => (typeof v === "string" ? v : null);
52
52
  const firstMessage = (b: Record<string, unknown>) => {
53
- const { text, images } = clientSend({ text: b.prompt, images: b.images });
54
- return { prompt: text, images };
53
+ const { text, images, files } = clientSend({
54
+ text: b.prompt,
55
+ images: b.images,
56
+ files: b.files,
57
+ });
58
+ return { prompt: text, images, files };
55
59
  };
56
60
  try {
57
61
  const { session, initialPrompt, repeat } = await launchSession({
@@ -694,6 +694,7 @@ export function ChatPanel({
694
694
  kind: "user",
695
695
  text: p.text,
696
696
  images: p.images,
697
+ files: p.files,
697
698
  createdAt: p.at,
698
699
  }}
699
700
  pending={{
@@ -820,6 +821,7 @@ export function ChatPanel({
820
821
  <LearningCardsHost sessionId={sessionId} />
821
822
  <Composer
822
823
  draftKey={sessionId}
824
+ attachScope={sessionId}
823
825
  suggestion={shownSuggestion}
824
826
  onDismissSuggestion={dismissSuggestion}
825
827
  history={history}
@@ -828,7 +830,7 @@ export function ChatPanel({
828
830
  running={running}
829
831
  disabled={!connected}
830
832
  placeholder={connected ? `Message ${sessionName}` : "Reconnecting…"}
831
- onSend={(text, images) => {
833
+ onSend={(text, images, files) => {
832
834
  if (freshStart && isFreshCommand(text)) {
833
835
  startFresh.mutate(sessionId);
834
836
  return;
@@ -840,7 +842,12 @@ export function ChatPanel({
840
842
  asIs,
841
843
  });
842
844
  setAsIs(false);
843
- send(text, images.length ? images : undefined, compact);
845
+ send(
846
+ text,
847
+ images.length ? images : undefined,
848
+ compact,
849
+ files.length ? files : undefined
850
+ );
844
851
  if (replyTo) {
845
852
  resolveQuestion.mutate(replyTo.id);
846
853
  setReplyTo(null);
@@ -0,0 +1,67 @@
1
+ import {
2
+ FileCode,
3
+ FileSpreadsheet,
4
+ FileText,
5
+ Loader2,
6
+ X,
7
+ type LucideIcon,
8
+ } from "lucide-react";
9
+ import {
10
+ formatBytes,
11
+ typeFor,
12
+ type AttachmentKind,
13
+ } from "@/lib/attachments/policy";
14
+ import { cn } from "@/lib/utils";
15
+
16
+ const ICONS: Record<AttachmentKind, LucideIcon> = {
17
+ image: FileText,
18
+ pdf: FileText,
19
+ sheet: FileSpreadsheet,
20
+ doc: FileText,
21
+ text: FileCode,
22
+ };
23
+
24
+ // A file attached to a message: in the composer (with a way to remove it,
25
+ // and a spinner while it uploads) and in the sent message.
26
+ export function FileChip({
27
+ file,
28
+ uploading,
29
+ onRemove,
30
+ }: {
31
+ file: { name: string; size: number };
32
+ uploading?: boolean;
33
+ onRemove?: () => void;
34
+ }) {
35
+ const Icon = ICONS[typeFor(file.name)?.kind ?? "text"];
36
+ return (
37
+ <div
38
+ className={cn(
39
+ "bg-foreground/[0.05] flex h-14 max-w-56 min-w-0 items-center gap-2 rounded-lg pl-3",
40
+ onRemove ? "pr-1" : "pr-3"
41
+ )}
42
+ title={file.name}
43
+ >
44
+ {uploading ? (
45
+ <Loader2 className="text-muted-foreground h-4 w-4 shrink-0 animate-spin" />
46
+ ) : (
47
+ <Icon className="text-muted-foreground h-4 w-4 shrink-0" />
48
+ )}
49
+ <span className="min-w-0">
50
+ <span className="block truncate text-sm">{file.name}</span>
51
+ <span className="text-muted-foreground block text-xs">
52
+ {uploading ? "Uploading…" : formatBytes(file.size)}
53
+ </span>
54
+ </span>
55
+ {onRemove && (
56
+ <button
57
+ type="button"
58
+ aria-label={`Remove ${file.name}`}
59
+ onClick={onRemove}
60
+ className="text-muted-foreground hover:text-foreground flex h-11 w-11 shrink-0 items-center justify-center"
61
+ >
62
+ <X className="h-4 w-4" />
63
+ </button>
64
+ )}
65
+ </div>
66
+ );
67
+ }
@@ -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>