@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
|
@@ -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
|
}
|
|
@@ -5,7 +5,7 @@ import { useSnapshot } from "valtio";
|
|
|
5
5
|
import { toast } from "sonner";
|
|
6
6
|
import { Composer } from "@/components/Chat/Composer";
|
|
7
7
|
import { Button } from "@/components/ui/button";
|
|
8
|
-
import type { ChatImage } from "@/lib/chat/events";
|
|
8
|
+
import type { ChatFile, ChatImage } from "@/lib/chat/events";
|
|
9
9
|
import type { Project } from "@/lib/db";
|
|
10
10
|
import { draftComposerKey, type Draft } from "@/lib/drafts";
|
|
11
11
|
import { getModelOptions } from "@/lib/model-catalog";
|
|
@@ -57,11 +57,12 @@ export function DraftPanel({
|
|
|
57
57
|
</div>
|
|
58
58
|
);
|
|
59
59
|
|
|
60
|
-
const send = async (text: string, images: ChatImage[]) => {
|
|
60
|
+
const send = async (text: string, images: ChatImage[], files: ChatFile[]) => {
|
|
61
61
|
try {
|
|
62
62
|
if (draft.openPr) {
|
|
63
63
|
if (!project) throw new Error("Pick a project for a task");
|
|
64
|
-
if (images.length
|
|
64
|
+
if (images.length || files.length)
|
|
65
|
+
throw new Error("A task takes text only");
|
|
65
66
|
const { session, queued } = await createTask.mutateAsync({
|
|
66
67
|
projectId: project.id,
|
|
67
68
|
prompt: text,
|
|
@@ -96,6 +97,7 @@ export function DraftPanel({
|
|
|
96
97
|
baseBranch: draft.baseBranch,
|
|
97
98
|
prompt: text,
|
|
98
99
|
images: images.length ? images : undefined,
|
|
100
|
+
files: files.length ? files : undefined,
|
|
99
101
|
});
|
|
100
102
|
// A resend whose first answer was lost: the terminal still needs its
|
|
101
103
|
// first prompt, and this is the text that was sent.
|
|
@@ -134,6 +136,7 @@ export function DraftPanel({
|
|
|
134
136
|
/>
|
|
135
137
|
<Composer
|
|
136
138
|
draftKey={draftComposerKey(draft.id)}
|
|
139
|
+
attachScope="draft"
|
|
137
140
|
running={false}
|
|
138
141
|
disabled={pending}
|
|
139
142
|
placeholder={
|
|
@@ -143,7 +146,7 @@ export function DraftPanel({
|
|
|
143
146
|
? "Describe the task"
|
|
144
147
|
: `Message ${project?.name ?? "a new chat"}`
|
|
145
148
|
}
|
|
146
|
-
onSend={(text, images) => void send(text, images)}
|
|
149
|
+
onSend={(text, images, files) => void send(text, images, files)}
|
|
147
150
|
onStop={() => {}}
|
|
148
151
|
models={getModelOptions(draft.agentType)}
|
|
149
152
|
model={draft.model}
|
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
import { Button } from "@/components/ui/button";
|
|
13
13
|
import { Input } from "@/components/ui/input";
|
|
14
14
|
import { Textarea } from "@/components/ui/textarea";
|
|
15
|
+
import { ProjectTierField } from "./ProjectTierField";
|
|
16
|
+
import type { ProjectTier } from "@/lib/project-tier/types";
|
|
15
17
|
import {
|
|
16
18
|
Select,
|
|
17
19
|
SelectContent,
|
|
@@ -95,6 +97,7 @@ export function ProjectSettingsDialog({
|
|
|
95
97
|
getDefaultModelForAgent("claude")
|
|
96
98
|
);
|
|
97
99
|
const [initialPrompt, setInitialPrompt] = useState("");
|
|
100
|
+
const [tier, setTier] = useState<ProjectTier>("fast");
|
|
98
101
|
const [devServers, setDevServers] = useState<DevServerConfig[]>([]);
|
|
99
102
|
const [repositories, setRepositories] = useState<RepositoryConfig[]>([]);
|
|
100
103
|
const [isLoading, setIsLoading] = useState(false);
|
|
@@ -118,6 +121,7 @@ export function ProjectSettingsDialog({
|
|
|
118
121
|
setName(project.name);
|
|
119
122
|
setWorkingDirectory(project.working_directory);
|
|
120
123
|
setAgentType(project.agent_type);
|
|
124
|
+
setTier(project.tier);
|
|
121
125
|
setDefaultModel(
|
|
122
126
|
isSupportedModelForAgent(project.agent_type, project.default_model)
|
|
123
127
|
? project.default_model
|
|
@@ -336,6 +340,7 @@ export function ProjectSettingsDialog({
|
|
|
336
340
|
agentType,
|
|
337
341
|
defaultModel,
|
|
338
342
|
initialPrompt: initialPrompt.trim() || null,
|
|
343
|
+
tier,
|
|
339
344
|
});
|
|
340
345
|
|
|
341
346
|
// Handle dev server changes
|
|
@@ -526,6 +531,8 @@ export function ProjectSettingsDialog({
|
|
|
526
531
|
</Select>
|
|
527
532
|
</div>
|
|
528
533
|
|
|
534
|
+
<ProjectTierField value={tier} onChange={setTier} />
|
|
535
|
+
|
|
529
536
|
{/* Initial Prompt */}
|
|
530
537
|
<div className="space-y-2">
|
|
531
538
|
<label className="text-sm font-medium">Initial Prompt</label>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Select,
|
|
5
|
+
SelectContent,
|
|
6
|
+
SelectItem,
|
|
7
|
+
SelectTrigger,
|
|
8
|
+
SelectValue,
|
|
9
|
+
} from "@/components/ui/select";
|
|
10
|
+
import type { ProjectTier } from "@/lib/project-tier/types";
|
|
11
|
+
|
|
12
|
+
const DETAIL: Record<ProjectTier, string> = {
|
|
13
|
+
production: "CI, the independent review and scope must pass before a merge.",
|
|
14
|
+
fast: "A task's PR merges as soon as it opens: no CI wait, no review.",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function ProjectTierField({
|
|
18
|
+
value,
|
|
19
|
+
onChange,
|
|
20
|
+
}: {
|
|
21
|
+
value: ProjectTier;
|
|
22
|
+
onChange: (tier: ProjectTier) => void;
|
|
23
|
+
}) {
|
|
24
|
+
return (
|
|
25
|
+
<div className="space-y-2">
|
|
26
|
+
<label className="text-sm font-medium">Tier</label>
|
|
27
|
+
<Select value={value} onValueChange={(v) => onChange(v as ProjectTier)}>
|
|
28
|
+
<SelectTrigger>
|
|
29
|
+
<SelectValue />
|
|
30
|
+
</SelectTrigger>
|
|
31
|
+
<SelectContent>
|
|
32
|
+
<SelectItem value="fast">Fast</SelectItem>
|
|
33
|
+
<SelectItem value="production">Production</SelectItem>
|
|
34
|
+
</SelectContent>
|
|
35
|
+
</Select>
|
|
36
|
+
<p className="text-muted-foreground text-xs">{DETAIL[value]}</p>
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -19,7 +19,8 @@ import { useTerminalConnection, useTerminalSearch } from "./hooks";
|
|
|
19
19
|
import type { TerminalScrollState } from "./hooks";
|
|
20
20
|
import { useViewport } from "@/hooks/useViewport";
|
|
21
21
|
import { useFileDrop } from "@/hooks/useFileDrop";
|
|
22
|
-
import {
|
|
22
|
+
import { toast } from "sonner";
|
|
23
|
+
import { uploadFile } from "@/lib/file-upload";
|
|
23
24
|
import { FilePicker } from "@/components/FilePicker";
|
|
24
25
|
import type { AttachSpec } from "@/lib/hosts/attach";
|
|
25
26
|
|
|
@@ -121,11 +122,11 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(
|
|
|
121
122
|
async (file: File) => {
|
|
122
123
|
setIsUploading(true);
|
|
123
124
|
try {
|
|
124
|
-
const
|
|
125
|
-
if (
|
|
126
|
-
sendInput(path);
|
|
125
|
+
const done = await uploadFile(file);
|
|
126
|
+
if (done.ok) {
|
|
127
|
+
sendInput(done.file.path);
|
|
127
128
|
focus();
|
|
128
|
-
}
|
|
129
|
+
} else toast.error(done.error);
|
|
129
130
|
} catch (err) {
|
|
130
131
|
console.error("Failed to upload file:", err);
|
|
131
132
|
} finally {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { SettingToggle } from "@/components/Settings/SettingToggle";
|
|
4
|
+
import { useProjectsQuery, useUpdateProject } from "@/data/projects";
|
|
5
|
+
|
|
6
|
+
// A workspace's projects, each switched between production (every merge gate)
|
|
7
|
+
// and fast (a task's PR merges as soon as it opens).
|
|
8
|
+
export function ProjectTiersForm({ workspaceId }: { workspaceId: string }) {
|
|
9
|
+
const { data: projects } = useProjectsQuery();
|
|
10
|
+
const update = useUpdateProject();
|
|
11
|
+
const mine = (projects ?? []).filter(
|
|
12
|
+
(p) => p.workspace_id === workspaceId && !p.is_uncategorized
|
|
13
|
+
);
|
|
14
|
+
if (!mine.length) return null;
|
|
15
|
+
return (
|
|
16
|
+
<div className="space-y-2">
|
|
17
|
+
{mine.map((p) => (
|
|
18
|
+
<SettingToggle
|
|
19
|
+
key={p.id}
|
|
20
|
+
title={`${p.name}: production`}
|
|
21
|
+
detail="On: CI, the independent review and scope gate its merges. Off (fast): a task's PR merges as soon as it opens."
|
|
22
|
+
checked={p.tier === "production"}
|
|
23
|
+
onChange={(on) =>
|
|
24
|
+
update.mutate({
|
|
25
|
+
projectId: p.id,
|
|
26
|
+
tier: on ? "production" : "fast",
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
/>
|
|
30
|
+
))}
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -7,6 +7,7 @@ import { RulesForm } from "./RulesForm";
|
|
|
7
7
|
import { FreshStartForm } from "./FreshStartForm";
|
|
8
8
|
import { McpServersForm } from "./McpServersForm";
|
|
9
9
|
import { HqFolderForm } from "./HqFolderForm";
|
|
10
|
+
import { ProjectTiersForm } from "./ProjectTiersForm";
|
|
10
11
|
import { StallForm } from "./StallForm";
|
|
11
12
|
import { WorkspaceModelsForm } from "./WorkspaceModelsForm";
|
|
12
13
|
import { AwayWindowForm } from "@/components/Away/AwayWindowForm";
|
|
@@ -49,6 +50,7 @@ export function WorkspaceSettings() {
|
|
|
49
50
|
<WorkspaceModelsForm workspaceId={w.id} />
|
|
50
51
|
<McpServersForm workspace={w} />
|
|
51
52
|
<HqFolderForm workspaceId={w.id} />
|
|
53
|
+
<ProjectTiersForm workspaceId={w.id} />
|
|
52
54
|
<SettingToggle
|
|
53
55
|
title="Wake tasks on CI failures and reviews"
|
|
54
56
|
detail="A task's agent is messaged once when a check fails on its PR's head, with the log's tail, and when someone reviews or comments on it."
|
package/data/chat/useChat.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
ChatAccess,
|
|
10
10
|
ChatCommand,
|
|
11
11
|
ChatContext,
|
|
12
|
+
ChatFile,
|
|
12
13
|
ChatImage,
|
|
13
14
|
ChatItem,
|
|
14
15
|
ChatModel,
|
|
@@ -34,12 +35,13 @@ import { usePending } from "./usePending";
|
|
|
34
35
|
|
|
35
36
|
// A pending send as the socket's `send`, under its own id.
|
|
36
37
|
const sendFrame = (
|
|
37
|
-
p: Pick<PendingSend, "id" | "text" | "images" | "compact">
|
|
38
|
+
p: Pick<PendingSend, "id" | "text" | "images" | "files" | "compact">
|
|
38
39
|
) =>
|
|
39
40
|
JSON.stringify({
|
|
40
41
|
type: "send",
|
|
41
42
|
text: p.text,
|
|
42
43
|
images: p.images,
|
|
44
|
+
files: p.files,
|
|
43
45
|
clientId: p.id,
|
|
44
46
|
...(p.compact && { compact: true }),
|
|
45
47
|
});
|
|
@@ -400,8 +402,13 @@ export function useChat(
|
|
|
400
402
|
const send = useCallback(
|
|
401
403
|
// `compact`: the reader let a stale chat compact first (lib/chat/stale).
|
|
402
404
|
// It shows as a pending bubble at once, until it's in the chat.
|
|
403
|
-
(
|
|
404
|
-
|
|
405
|
+
(
|
|
406
|
+
text: string,
|
|
407
|
+
images?: ChatImage[],
|
|
408
|
+
compact?: boolean,
|
|
409
|
+
files?: ChatFile[]
|
|
410
|
+
) => {
|
|
411
|
+
const p = { id: clientId(), text, images, files, compact };
|
|
405
412
|
pend({ type: "add", send: p, now: Date.now() });
|
|
406
413
|
const ws = wsRef.current;
|
|
407
414
|
if (ws?.readyState !== WebSocket.OPEN) {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { queryOptions, useQuery } from "@tanstack/react-query";
|
|
2
|
+
import type { ClientConfig } from "@/lib/config/client-config-types";
|
|
3
|
+
|
|
4
|
+
export const clientConfigKeys = { all: ["client-config"] as const };
|
|
5
|
+
|
|
6
|
+
// What the server says clients render with and are limited by; it only
|
|
7
|
+
// changes with a server release, so one read per page load.
|
|
8
|
+
export const clientConfigQuery = queryOptions({
|
|
9
|
+
queryKey: clientConfigKeys.all,
|
|
10
|
+
queryFn: async (): Promise<ClientConfig> => {
|
|
11
|
+
const res = await fetch("/api/client-config");
|
|
12
|
+
if (!res.ok) throw new Error("Couldn't read the server's limits");
|
|
13
|
+
return res.json();
|
|
14
|
+
},
|
|
15
|
+
staleTime: Infinity,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const useClientConfig = () => useQuery(clientConfigQuery);
|
package/data/projects/queries.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
ProjectWithRepositories,
|
|
4
4
|
DetectedDevServer,
|
|
5
5
|
} from "@/lib/projects";
|
|
6
|
+
import type { ProjectTier } from "@/lib/db/types";
|
|
6
7
|
import { projectKeys } from "./keys";
|
|
7
8
|
import { sessionKeys } from "../sessions/keys";
|
|
8
9
|
|
|
@@ -131,6 +132,7 @@ export function useUpdateProject() {
|
|
|
131
132
|
agentType,
|
|
132
133
|
defaultModel,
|
|
133
134
|
initialPrompt,
|
|
135
|
+
tier,
|
|
134
136
|
}: {
|
|
135
137
|
projectId: string;
|
|
136
138
|
name?: string;
|
|
@@ -138,6 +140,7 @@ export function useUpdateProject() {
|
|
|
138
140
|
agentType?: string;
|
|
139
141
|
defaultModel?: string;
|
|
140
142
|
initialPrompt?: string | null;
|
|
143
|
+
tier?: ProjectTier;
|
|
141
144
|
}) => {
|
|
142
145
|
const res = await fetch(`/api/projects/${projectId}`, {
|
|
143
146
|
method: "PATCH",
|
|
@@ -148,6 +151,7 @@ export function useUpdateProject() {
|
|
|
148
151
|
agentType,
|
|
149
152
|
defaultModel,
|
|
150
153
|
initialPrompt,
|
|
154
|
+
tier,
|
|
151
155
|
}),
|
|
152
156
|
});
|
|
153
157
|
if (!res.ok) throw new Error("Failed to update project");
|
package/data/sessions/queries.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
2
2
|
import type { Session, Group } from "@/lib/db";
|
|
3
3
|
import type { AgentType } from "@/lib/providers";
|
|
4
|
-
import type { ChatAccess, ChatImage } from "@/lib/chat/events";
|
|
4
|
+
import type { ChatAccess, ChatFile, ChatImage } from "@/lib/chat/events";
|
|
5
5
|
import { sessionKeys } from "./keys";
|
|
6
6
|
import { usePollWhenOffline, usePushConnected } from "../push/connection";
|
|
7
7
|
|
|
@@ -192,6 +192,7 @@ export interface LaunchSessionInput {
|
|
|
192
192
|
baseBranch: string | null;
|
|
193
193
|
prompt: string;
|
|
194
194
|
images?: ChatImage[];
|
|
195
|
+
files?: ChatFile[];
|
|
195
196
|
}
|
|
196
197
|
|
|
197
198
|
interface LaunchSessionResponse {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { isText, sniff } from "../intake/sniff";
|
|
2
|
+
import {
|
|
3
|
+
ATTACHMENT_LIMITS,
|
|
4
|
+
tooBigMessage,
|
|
5
|
+
typeFor,
|
|
6
|
+
unsupportedMessage,
|
|
7
|
+
type AttachmentType,
|
|
8
|
+
} from "./policy";
|
|
9
|
+
|
|
10
|
+
export type UploadCheck =
|
|
11
|
+
| { ok: true; type: AttachmentType }
|
|
12
|
+
| { ok: false; status: 400 | 413 | 415; error: string };
|
|
13
|
+
|
|
14
|
+
const ZIP = [0x50, 0x4b, 0x03, 0x04];
|
|
15
|
+
const OLE = [0xd0, 0xcf, 0x11, 0xe0];
|
|
16
|
+
const OOXML_ZIP = new Set(["xlsx", "docx", "pptx", "ods", "odt"]);
|
|
17
|
+
const OLE_EXTS = new Set(["xls", "doc"]);
|
|
18
|
+
|
|
19
|
+
const starts = (b: Buffer, sig: number[]) => sig.every((v, i) => b[i] === v);
|
|
20
|
+
|
|
21
|
+
const bad = (name: string): UploadCheck => ({
|
|
22
|
+
ok: false,
|
|
23
|
+
status: 400,
|
|
24
|
+
error: `${name} doesn't look like a real .${name.split(".").pop()?.toLowerCase()} file.`,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/** Whether a file's name, size and bytes are something a chat accepts. */
|
|
28
|
+
export function checkUpload(name: string, data: Buffer): UploadCheck {
|
|
29
|
+
const type = typeFor(name);
|
|
30
|
+
if (!type) return { ok: false, status: 415, error: unsupportedMessage(name) };
|
|
31
|
+
const limit =
|
|
32
|
+
type.kind === "image"
|
|
33
|
+
? ATTACHMENT_LIMITS.maxImageBytes
|
|
34
|
+
: ATTACHMENT_LIMITS.maxBytes;
|
|
35
|
+
if (data.length > limit)
|
|
36
|
+
return {
|
|
37
|
+
ok: false,
|
|
38
|
+
status: 413,
|
|
39
|
+
error: tooBigMessage(name, data.length, limit),
|
|
40
|
+
};
|
|
41
|
+
if (!data.length)
|
|
42
|
+
return { ok: false, status: 400, error: `${name} is empty.` };
|
|
43
|
+
let real: boolean;
|
|
44
|
+
if (type.kind === "image" || type.kind === "pdf")
|
|
45
|
+
real = sniff(data, name)?.mediaType === type.mediaType;
|
|
46
|
+
else if (OOXML_ZIP.has(type.ext)) real = starts(data, ZIP);
|
|
47
|
+
else if (OLE_EXTS.has(type.ext)) real = starts(data, OLE);
|
|
48
|
+
else real = isText(data);
|
|
49
|
+
return real ? { ok: true, type } : bad(name);
|
|
50
|
+
}
|