@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.
- package/app/api/files/upload-temp/route.test.ts +144 -0
- package/app/api/files/upload-temp/route.ts +52 -21
- package/app/api/projects/[id]/route.ts +26 -0
- package/app/api/sessions/[id]/route.ts +2 -0
- package/app/api/sessions/route.ts +6 -2
- 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/Draft/DraftPanel.tsx +7 -4
- package/components/Projects/ProjectSettingsDialog.tsx +7 -0
- package/components/Projects/ProjectTierField.tsx +39 -0
- package/components/Terminal/index.tsx +6 -5
- package/components/Workspaces/ProjectTiersForm.tsx +33 -0
- package/components/Workspaces/WorkspaceSettings.tsx +2 -0
- package/data/chat/useChat.ts +10 -3
- package/data/client-config/queries.ts +18 -0
- package/data/projects/queries.ts +4 -0
- package/data/sessions/queries.ts +2 -1
- 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/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 +7 -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/20261018T2130_project_tier.ts +38 -0
- package/lib/db/migrations/20261019T1200_chat_queue_files.ts +14 -0
- package/lib/db/types.ts +4 -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/armed.test-helpers.ts +4 -0
- package/lib/orchestrator/armed.ts +14 -1
- package/lib/orchestrator/brief.ts +9 -2
- package/lib/orchestrator/fast-lane-state.ts +69 -0
- package/lib/orchestrator/fast-lane.ts +96 -0
- package/lib/orchestrator/gates.ts +8 -2
- package/lib/orchestrator/project-tier-tool.ts +33 -0
- package/lib/orchestrator/review.ts +3 -0
- package/lib/orchestrator/serve.ts +6 -1
- package/lib/orchestrator/signoff.test-helpers.ts +3 -0
- package/lib/orchestrator/signoff.ts +36 -19
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +8 -1
- package/lib/orchestrator/tools.ts +2 -0
- package/lib/orchestrator/watcher.ts +5 -0
- package/lib/project-tier/getProjectTier.ts +18 -0
- package/lib/project-tier/index.ts +3 -0
- package/lib/project-tier/setProjectTier.ts +14 -0
- package/lib/project-tier/types.ts +3 -0
- package/lib/sessions/launch.ts +12 -6
- package/lib/start-options.ts +8 -1
- package/lib/tasks/finish.ts +2 -1
- package/lib/tasks/index.ts +10 -5
- package/lib/tasks/state.ts +7 -1
- package/package.json +1 -1
- 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
|
|
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 (
|
|
11
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
|
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({
|
|
54
|
-
|
|
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(
|
|
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 {
|
|
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>
|