@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
@@ -3,7 +3,7 @@
3
3
  import { useRef, useState } from "react";
4
4
  import {
5
5
  ArrowUp,
6
- ImagePlus,
6
+ Paperclip,
7
7
  ListChecks,
8
8
  MonitorUp,
9
9
  Square,
@@ -23,7 +23,8 @@ import { ModelPicker } from "../ModelPicker";
23
23
  export function Toolbar({
24
24
  plain,
25
25
  onTogglePlain,
26
- onAddImages,
26
+ onAddFiles,
27
+ accept,
27
28
  plan,
28
29
  onTogglePlan,
29
30
  accessory,
@@ -42,7 +43,9 @@ export function Toolbar({
42
43
  }: {
43
44
  plain: boolean;
44
45
  onTogglePlain: () => void;
45
- onAddImages: (files: Iterable<File>) => void;
46
+ onAddFiles: (files: Iterable<File>) => void;
47
+ // The types the server takes, for the picker.
48
+ accept: string;
46
49
  // Plan mode, when the session can use it.
47
50
  plan?: boolean | null;
48
51
  onTogglePlan?: () => void;
@@ -68,7 +71,7 @@ export function Toolbar({
68
71
  const capture = async () => {
69
72
  try {
70
73
  const file = await captureScreenFrame();
71
- if (file) onAddImages([file]);
74
+ if (file) onAddFiles([file]);
72
75
  } catch (error) {
73
76
  toast.error(
74
77
  `Couldn't capture the screen: ${error instanceof Error ? error.message : String(error)}`
@@ -81,20 +84,20 @@ export function Toolbar({
81
84
  type="button"
82
85
  variant="ghost"
83
86
  size="icon"
84
- aria-label="Attach image"
87
+ aria-label="Attach files"
85
88
  className="h-10 w-10 shrink-0"
86
89
  onClick={() => fileRef.current?.click()}
87
90
  >
88
- <ImagePlus className="h-4 w-4" />
91
+ <Paperclip className="h-4 w-4" />
89
92
  </Button>
90
93
  <input
91
94
  ref={fileRef}
92
95
  type="file"
93
- accept="image/*"
96
+ accept={accept}
94
97
  multiple
95
98
  hidden
96
99
  onChange={(e) => {
97
- if (e.target.files) onAddImages(e.target.files);
100
+ if (e.target.files) onAddFiles(e.target.files);
98
101
  e.target.value = "";
99
102
  }}
100
103
  />
@@ -168,7 +168,7 @@ function paste(
168
168
  return handlePaste(editor.view, event, {
169
169
  plain: false,
170
170
  asText: false,
171
- onImages: () => {},
171
+ onFiles: () => {},
172
172
  onLongPaste: () => {},
173
173
  ...h,
174
174
  });
@@ -8,7 +8,8 @@ export interface PasteHandlers {
8
8
  plain: boolean;
9
9
  // Cmd/Ctrl+Shift+V was pressed just before this paste.
10
10
  asText: boolean;
11
- onImages: (files: File[]) => void;
11
+ // Files pasted: images, documents, anything the composer may attach.
12
+ onFiles: (files: File[]) => void;
12
13
  onLongPaste: (text: string, language?: string) => void;
13
14
  }
14
15
 
@@ -58,9 +59,9 @@ export function handlePaste(
58
59
  ): boolean {
59
60
  const data = event.clipboardData;
60
61
  if (!data) return false;
61
- const images = [...data.files].filter(isImage);
62
- if (images.length) {
63
- h.onImages(images);
62
+ const files = [...data.files];
63
+ if (files.length) {
64
+ h.onFiles(files);
64
65
  return true;
65
66
  }
66
67
  const text = data.getData("text/plain").replace(/\r\n?/g, "\n");
@@ -0,0 +1,142 @@
1
+ "use client";
2
+
3
+ import { useRef, useState } from "react";
4
+ import { useQueryClient } from "@tanstack/react-query";
5
+ import { toast } from "sonner";
6
+ import {
7
+ clientConfigQuery,
8
+ useClientConfig,
9
+ } from "@/data/client-config/queries";
10
+ import {
11
+ acceptAttr,
12
+ tooBigMessage,
13
+ typeFor,
14
+ unsupportedMessage,
15
+ } from "@/lib/attachments/policy";
16
+ import type { ChatFile, ChatImage } from "@/lib/chat/events";
17
+ import { uploadFile } from "@/lib/file-upload";
18
+ import { isImage } from "./paste";
19
+
20
+ export interface Upload {
21
+ key: string;
22
+ file: ChatFile;
23
+ uploading?: boolean;
24
+ }
25
+
26
+ function readImage(file: File): Promise<ChatImage> {
27
+ return new Promise((resolve, reject) => {
28
+ const reader = new FileReader();
29
+ reader.onload = () => {
30
+ const [meta, data] = String(reader.result).split(",");
31
+ resolve({ mediaType: meta.slice(5, meta.indexOf(";")), data });
32
+ };
33
+ reader.onerror = reject;
34
+ reader.readAsDataURL(file);
35
+ });
36
+ }
37
+
38
+ const carriesFiles = (e: React.DragEvent) =>
39
+ [...e.dataTransfer.types].includes("Files");
40
+
41
+ let seq = 0;
42
+
43
+ // Files picked, pasted or dropped onto the composer, and the outline shown
44
+ // while something is dragged over it. Images are read in and sent with the
45
+ // message; any other file is uploaded to the session's attachments folder
46
+ // at once, so a wrong type or a too-big file fails here, not at send. The
47
+ // types and sizes come from the server (/api/client-config).
48
+ export function useAttachFiles({
49
+ scope,
50
+ imageCount,
51
+ onImages,
52
+ }: {
53
+ // The session's id, or "draft" for a chat that hasn't started.
54
+ scope: string;
55
+ imageCount: number;
56
+ onImages: (images: ChatImage[]) => void;
57
+ }) {
58
+ const queryClient = useQueryClient();
59
+ const types = useClientConfig().data?.attachments.types;
60
+ const [dragging, setDragging] = useState(false);
61
+ const [uploads, setUploads] = useState<Upload[]>([]);
62
+ const held = useRef(0);
63
+ held.current = imageCount + uploads.length;
64
+
65
+ const addFiles = async (picked: Iterable<File>) => {
66
+ // Copied first: a file input's live list is emptied right after the pick.
67
+ const list = [...picked];
68
+ let limits;
69
+ try {
70
+ limits = (await queryClient.fetchQuery(clientConfigQuery)).attachments;
71
+ } catch (e) {
72
+ toast.error(e instanceof Error ? e.message : "Couldn't attach");
73
+ return;
74
+ }
75
+ const images: File[] = [];
76
+ const others: File[] = [];
77
+ for (const f of list) {
78
+ let error: string | null = null;
79
+ if (held.current + images.length + others.length >= limits.max)
80
+ error = `You can attach up to ${limits.max} files to one message.`;
81
+ else if (isImage(f)) {
82
+ if (f.size > limits.maxImageBytes)
83
+ error = tooBigMessage(f.name, f.size, limits.maxImageBytes);
84
+ else images.push(f);
85
+ } else if (!typeFor(f.name, limits.types))
86
+ error = unsupportedMessage(f.name);
87
+ else if (f.size > limits.maxBytes)
88
+ error = tooBigMessage(f.name, f.size, limits.maxBytes);
89
+ else others.push(f);
90
+ if (error) toast.error(error);
91
+ }
92
+ if (images.length) onImages(await Promise.all(images.map(readImage)));
93
+ for (const f of others) {
94
+ const key = `up-${++seq}`;
95
+ const file = { path: "", name: f.name, size: f.size, mediaType: f.type };
96
+ setUploads((prev) => [...prev, { key, file, uploading: true }]);
97
+ const done = await uploadFile(f, scope);
98
+ if (done.ok)
99
+ setUploads((prev) =>
100
+ prev.map((u) => (u.key === key ? { key, file: done.file } : u))
101
+ );
102
+ else {
103
+ setUploads((prev) => prev.filter((u) => u.key !== key));
104
+ toast.error(done.error);
105
+ }
106
+ }
107
+ };
108
+
109
+ const dropProps = {
110
+ onDragOver: (e: React.DragEvent) => {
111
+ if (!carriesFiles(e)) return;
112
+ e.preventDefault();
113
+ e.dataTransfer.dropEffect = "copy";
114
+ setDragging(true);
115
+ },
116
+ onDragLeave: (e: React.DragEvent) => {
117
+ if (!e.currentTarget.contains(e.relatedTarget as Node | null))
118
+ setDragging(false);
119
+ },
120
+ onDrop: (e: React.DragEvent) => {
121
+ if (!carriesFiles(e)) return;
122
+ e.preventDefault();
123
+ setDragging(false);
124
+ void addFiles(e.dataTransfer.files);
125
+ },
126
+ };
127
+
128
+ return {
129
+ dragging,
130
+ dropProps,
131
+ addFiles,
132
+ uploads,
133
+ // Until the limits are read, the picker offers images only.
134
+ accept: types ? `image/*,${acceptAttr(types)}` : "image/*",
135
+ uploading: uploads.some((u) => u.uploading),
136
+ remove: (key: string) =>
137
+ setUploads((prev) => prev.filter((u) => u.key !== key)),
138
+ clear: () => setUploads([]),
139
+ restore: (files: ChatFile[]) =>
140
+ setUploads(files.map((file) => ({ key: `up-${++seq}`, file }))),
141
+ };
142
+ }
@@ -13,7 +13,7 @@ import { docToMarkdown } from "@/lib/chat/markdown/serialize";
13
13
  import { plainExtensions, richExtensions } from "./extensions";
14
14
  import { loadMarkdown } from "./load";
15
15
  import { handleKeyDown, type KeyHandlers } from "./keys";
16
- import { handlePaste, isImage, type PasteHandlers } from "./paste";
16
+ import { handlePaste, type PasteHandlers } from "./paste";
17
17
  import { usePlainMode } from "./usePlainMode";
18
18
 
19
19
  export type ComposerHandlers = Omit<
@@ -120,9 +120,9 @@ export function useComposerEditor({
120
120
  asText,
121
121
  });
122
122
  },
123
- // Dropped images are the composer's to take, not the document's.
123
+ // Dropped files are the composer's to take, not the document's.
124
124
  handleDrop: (view, event) =>
125
- [...(event.dataTransfer?.files ?? [])].some(isImage),
125
+ (event.dataTransfer?.files.length ?? 0) > 0,
126
126
  },
127
127
  // Only edits change the text: not loading a draft, not setEditable.
128
128
  onUpdate: ({ editor: ed, transaction }) => {
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useRef } from "react";
4
- import type { ChatImage } from "@/lib/chat/events";
4
+ import type { ChatFile, ChatImage } from "@/lib/chat/events";
5
5
  import type { TextAttachment } from "@/lib/chat/paste";
6
6
 
7
7
  // `text` is the composer's markdown, so a reload restores its formatting.
@@ -9,6 +9,8 @@ interface Draft {
9
9
  text: string;
10
10
  images: ChatImage[];
11
11
  files?: TextAttachment[];
12
+ // Attached files, saved on the server: references only.
13
+ uploads?: ChatFile[];
12
14
  }
13
15
 
14
16
  const keyFor = (id: string) => `agentos:draft:${id}`;
@@ -39,7 +41,12 @@ export function useSaveDraft(id: string | undefined, draft: Draft): void {
39
41
  clearTimeout(timer.current);
40
42
  timer.current = setTimeout(() => {
41
43
  const key = keyFor(id);
42
- if (!draft.text.trim() && !draft.images.length && !draft.files?.length) {
44
+ if (
45
+ !draft.text.trim() &&
46
+ !draft.images.length &&
47
+ !draft.files?.length &&
48
+ !draft.uploads?.length
49
+ ) {
43
50
  localStorage.removeItem(key);
44
51
  return;
45
52
  }
@@ -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) throw new Error("A task takes text only");
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 { uploadFileToTemp } from "@/lib/file-upload";
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 path = await uploadFileToTemp(file);
125
- if (path) {
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."
@@ -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
- (text: string, images?: ChatImage[], compact?: boolean) => {
404
- const p = { id: clientId(), text, images, compact };
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);
@@ -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");
@@ -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
+ }