diff --git a/apps/server/src/agents/manager.ts b/apps/server/src/agents/manager.ts index 05f99966..4766f966 100644 --- a/apps/server/src/agents/manager.ts +++ b/apps/server/src/agents/manager.ts @@ -7,6 +7,7 @@ import { readFile, readdir, rename, + rm, stat, unlink, writeFile, @@ -182,6 +183,14 @@ type CreateAgentInput = { cliSessionId?: string; jobRunId?: string; initialPrompt?: string; + initialPins?: AgentPin[]; + initialFiles?: Array<{ + fileName: string; + originalName?: string; + buffer: Buffer; + source: "text" | "user"; + description?: string | null; + }>; }; type WorktreeCleanupMode = "auto" | "keep" | "force"; @@ -543,6 +552,7 @@ export class AgentManager { const tmuxSession = this.toSessionName(id, name); const mediaDir = path.join(this.config.mediaRoot, id); await mkdir(mediaDir, { recursive: true }); + const initialPins = input.initialPins ?? []; const useWorktree = input.useWorktree !== false; const createNewBranch = input.createNewBranch ?? true; @@ -617,8 +627,8 @@ export class AgentManager { const initialSetupPhase: SetupPhase = useWorktree ? "worktree" : "session"; await this.pool.query( ` - INSERT INTO agents (id, name, type, role, status, cwd, tmux_session, media_dir, codex_args, full_access, setup_phase, persona, parent_agent_id, persona_context, review_agent_type, cli_session_id, auto_review, base_branch, updated_at) - VALUES ($1, $2, $3, $4, 'creating', $5, $6, $7, $8::jsonb, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW()) + INSERT INTO agents (id, name, type, role, status, cwd, tmux_session, media_dir, codex_args, full_access, setup_phase, persona, parent_agent_id, persona_context, review_agent_type, cli_session_id, auto_review, base_branch, pins, updated_at) + VALUES ($1, $2, $3, $4, 'creating', $5, $6, $7, $8::jsonb, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18::jsonb, NOW()) `, [ id, @@ -638,9 +648,37 @@ export class AgentManager { cliSessionId, input.autoReview ?? false, normalizedBaseBranch ?? null, + JSON.stringify(initialPins), ] ); + let initialMedia: Array<{ + fileName: string; + displayName: string; + source: string; + description: string | null; + }> = []; + if (input.initialFiles && input.initialFiles.length > 0) { + try { + initialMedia = await this.seedInitialMedia( + id, + mediaDir, + input.initialFiles + ); + } catch (error) { + await this.pool + .query("DELETE FROM agents WHERE id = $1", [id]) + .catch(() => {}); + await rm(mediaDir, { recursive: true, force: true }).catch(() => {}); + throw error; + } + } + const startupPrompt = this.buildStartupPrompt( + input.initialPrompt, + initialPins, + initialMedia + ); + if (this.config.agentRuntime === "inert") { // Inert mode: no tmux, no setup script — do worktree synchronously and go straight to running let effectiveCwd = originalCwd; @@ -719,7 +757,7 @@ export class AgentManager { jobRunId: input.jobRunId, }), !input.persona && !input.jobRunId && (input.autoReview ?? false), - input.initialPrompt + startupPrompt ); const exitFile = `/tmp/dispatch_${tmuxSession}.exit`; @@ -2547,6 +2585,139 @@ export class AgentManager { return `${codexEnvPrefix} ${this.shellEscape(cliBin)} ${codexMcpFlags} ${escaped} ${this.shellEscape(startupPrompt)}`; } + private buildStartupPrompt( + initialPrompt: string | undefined, + initialPins: AgentPin[], + initialMedia: Array<{ + fileName: string; + displayName: string; + source: string; + description: string | null; + }> + ): string | undefined { + const trimmedPrompt = initialPrompt?.trim() || ""; + if (initialPins.length === 0 && initialMedia.length === 0) { + return trimmedPrompt || undefined; + } + + const sections = [ + "Startup context is attached to this session.", + "Inspect the provided pins and shared media before acting. Use Dispatch shared-media tools to access attached files; do not try to locate them by searching the filesystem by name.", + ]; + + if (trimmedPrompt) { + sections.push(`Instructions:\n${trimmedPrompt}`); + } + + if (initialPins.length > 0) { + sections.push( + [ + "Links:", + ...initialPins.map((pin) => { + try { + const hostname = + new URL(pin.value).hostname.replace(/^www\./, "") || "Link"; + const numberedHostPattern = new RegExp( + `^${hostname.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}( \\d+)?$`, + "i" + ); + return numberedHostPattern.test(pin.label) + ? `- ${pin.value}` + : `- ${pin.label}: ${pin.value}`; + } catch { + return `- ${pin.value}`; + } + }), + ].join("\n") + ); + } + + if (initialMedia.length > 0) { + sections.push( + [ + "Attached files:", + ...initialMedia.map((file) => { + const detail = file.description?.trim(); + const suffix = detail ? ` — ${detail}` : ""; + return `- ${file.displayName}${suffix} (available via dispatch shared media)`; + }), + ].join("\n") + ); + } + + return sections.join("\n\n"); + } + + private async seedInitialMedia( + agentId: string, + mediaDir: string, + files: Array<{ + fileName: string; + originalName?: string; + buffer: Buffer; + source: "text" | "user"; + description?: string | null; + }> + ): Promise< + Array<{ + fileName: string; + displayName: string; + source: string; + description: string | null; + }> + > { + const createdAt = new Date(); + const results: Array<{ + fileName: string; + displayName: string; + source: string; + description: string | null; + }> = []; + + for (const [index, file] of files.entries()) { + const timestampedFileName = this.timestampMediaFileName( + file.fileName, + createdAt, + index + ); + await writeFile(path.join(mediaDir, timestampedFileName), file.buffer); + await this.pool.query( + `INSERT INTO media (agent_id, file_name, source, size_bytes, description) + VALUES ($1, $2, $3, $4, $5)`, + [ + agentId, + timestampedFileName, + file.source, + file.buffer.length, + file.description ?? null, + ] + ); + results.push({ + fileName: timestampedFileName, + displayName: file.originalName?.trim() || file.fileName, + source: file.source, + description: file.description ?? null, + }); + } + + return results; + } + + private timestampMediaFileName( + fileName: string, + createdAt: Date, + index: number + ): string { + const timestamp = createdAt + .toISOString() + .replace(/[:.]/g, "-") + .replace("T", "-") + .replace("Z", ""); + const ext = path.extname(fileName); + const base = path.basename(fileName, ext); + return `${base}-${timestamp}-${index + 1}${ext}`; + } + private dispatchMcpUrl(agentId: string, jobRunId?: string): string { const path = jobRunId ? `/api/mcp/jobs/${jobRunId}/${agentId}` diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 65c14c99..7d7d4200 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -161,6 +161,186 @@ jobService.onRunStateChange((run) => { const WEB_NOTIFY_ACK_TIMEOUT_MS = 3_000; const pendingWebNotifications = new Map(); +type CreateAgentBody = { + name?: unknown; + type?: unknown; + cwd?: unknown; + agentArgs?: unknown; + codexArgs?: unknown; + fullAccess?: unknown; + useWorktree?: unknown; + createNewBranch?: unknown; + worktreeBranch?: unknown; + baseBranch?: unknown; + persona?: unknown; + parentAgentId?: unknown; + personaContext?: unknown; + autoReview?: unknown; + initialPrompt?: unknown; + startupLinks?: unknown; +}; + +type StartupFileUpload = { + fileName: string; + originalName: string; + buffer: Buffer; + source: "text" | "user"; + description: string | null; +}; + +const MAX_STARTUP_FILE_COUNT = 10; +const MAX_STARTUP_FILE_NAME_LENGTH = 128; + +function parseOptionalBooleanField( + value: unknown, + fieldName: string, + allowStringCoercion: boolean +): boolean | undefined { + if (value === undefined) return undefined; + if (typeof value === "boolean") return value; + if (allowStringCoercion && value === "true") return true; + if (allowStringCoercion && value === "false") return false; + throw new Error(`${fieldName} must be a boolean when provided.`); +} + +function parseOptionalStringArrayField( + value: unknown, + fieldName: string, + allowStringCoercion: boolean +): string[] | undefined { + if (value === undefined) return undefined; + if (Array.isArray(value) && value.every((item) => typeof item === "string")) { + return value; + } + if (!allowStringCoercion || typeof value !== "string") { + throw new Error(`${fieldName} must be an array of strings.`); + } + try { + const parsed = JSON.parse(value) as unknown; + if ( + Array.isArray(parsed) && + parsed.every((item) => typeof item === "string") + ) { + return parsed; + } + } catch {} + throw new Error(`${fieldName} must be an array of strings.`); +} + +function createStartupPins(urls: string[]): Array<{ + label: string; + value: string; + type: "url"; +}> { + const counts = new Map(); + return urls.map((rawUrl) => { + validatePinValue("url", rawUrl); + const hostname = new URL(rawUrl).hostname.replace(/^www\./, "") || "Link"; + const seen = counts.get(hostname) ?? 0; + counts.set(hostname, seen + 1); + return { + label: seen === 0 ? hostname : `${hostname} ${seen + 1}`, + value: rawUrl, + type: "url", + }; + }); +} + +function sanitizeUploadedFileName(name: string): string { + const ext = path.extname(name).toLowerCase(); + const baseName = path.basename(name, ext).normalize("NFKD"); + const collapsed = baseName + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^A-Za-z0-9._() -]+/g, "-") + .trim() + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^[-.]+|[-.]+$/g, ""); + return `${collapsed || "file"}${ext}`; +} + +function sanitizeStartupDisplayName( + name: string | undefined, + fallback: string +): string { + const normalized = path + .basename(name || "") + .replace(/[\u0000-\u001f\u007f]/g, "") + .trim(); + if (!normalized) { + return fallback; + } + return normalized.slice(0, MAX_STARTUP_FILE_NAME_LENGTH); +} + +async function parseCreateAgentRequest(request: { + body?: unknown; + isMultipart: () => boolean; + parts: () => AsyncIterable; +}): Promise<{ + body: CreateAgentBody; + startupFiles: StartupFileUpload[]; + isMultipart: boolean; +}> { + const multipart = request.isMultipart(); + if (!multipart) { + return { + body: (request.body as CreateAgentBody | undefined) ?? {}, + startupFiles: [], + isMultipart: false, + }; + } + + const body: CreateAgentBody = {}; + const startupFiles: StartupFileUpload[] = []; + + for await (const rawPart of request.parts()) { + const part = rawPart as { + type: "file" | "field"; + fieldname: string; + filename?: string; + value?: unknown; + toBuffer?: () => Promise; + }; + if (part.type === "file") { + if (part.fieldname !== "startupFiles") { + throw new Error("Unexpected file field."); + } + if (startupFiles.length >= MAX_STARTUP_FILE_COUNT) { + throw new Error( + `A maximum of ${MAX_STARTUP_FILE_COUNT} startup files is allowed.` + ); + } + const fileName = sanitizeUploadedFileName( + path.basename(part.filename || "") + ); + if (!fileName) { + throw new Error("Invalid file name."); + } + if (!isMediaFile(fileName)) { + throw new Error( + "Unsupported file type. Use images (png/jpg/gif/webp), video (mp4), documents (pdf), or text files (txt/md/json/yaml/ts/py/etc)." + ); + } + if (!part.toBuffer) { + throw new Error("Invalid file upload."); + } + startupFiles.push({ + fileName, + originalName: sanitizeStartupDisplayName(part.filename, fileName), + buffer: await part.toBuffer(), + source: isTextFile(fileName) ? "text" : "user", + description: null, + }); + continue; + } + + body[part.fieldname as keyof CreateAgentBody] = part.value; + } + + return { body, startupFiles, isMultipart: true }; +} + /** Called by the ack endpoint when a client confirms delivery. */ function ackWebNotification(notificationId: string): boolean { const timer = pendingWebNotifications.get(notificationId); @@ -1301,7 +1481,12 @@ async function registerRoutes() { const cookieSecret = await getOrCreateCookieSecret(pool); await app.register(fastifyCookie, { secret: cookieSecret }); await app.register(fastifyMultipart, { - limits: { fileSize: 20 * 1024 * 1024 }, + limits: { + fileSize: 20 * 1024 * 1024, + files: MAX_STARTUP_FILE_COUNT, + fields: 24, + parts: 32, + }, }); await app.register(fastifyWebsocket); await app.register(fastifyRateLimit, { global: false }); @@ -3620,8 +3805,8 @@ async function registerRoutes() { return reply.code(400).send({ error: "A file field is required." }); } - const fileName = path.basename(data.filename); - if (!/^[A-Za-z0-9._-]+$/.test(fileName)) { + const fileName = sanitizeUploadedFileName(path.basename(data.filename)); + if (!fileName) { return reply.code(400).send({ error: "Invalid file name." }); } if (!isMediaFile(fileName)) { @@ -3830,23 +4015,19 @@ async function registerRoutes() { }); app.post("/api/v1/agents", async (request, reply) => { - const body = request.body as { - name?: unknown; - type?: unknown; - cwd?: unknown; - agentArgs?: unknown; - codexArgs?: unknown; - fullAccess?: unknown; - useWorktree?: unknown; - createNewBranch?: unknown; - worktreeBranch?: unknown; - baseBranch?: unknown; - persona?: unknown; - parentAgentId?: unknown; - personaContext?: unknown; - autoReview?: unknown; - initialPrompt?: unknown; + let parsedRequest: { + body: CreateAgentBody; + startupFiles: StartupFileUpload[]; + isMultipart: boolean; }; + try { + parsedRequest = await parseCreateAgentRequest(request); + } catch (error) { + return reply.code(400).send({ + error: error instanceof Error ? error.message : "Invalid request body.", + }); + } + const { body, startupFiles } = parsedRequest; if (typeof body?.cwd !== "string") { return reply @@ -3854,16 +4035,48 @@ async function registerRoutes() { .send({ error: "Body must include cwd as a string." }); } - const providedAgentArgs = body.agentArgs ?? body.codexArgs; - const agentArgsValid = - providedAgentArgs === undefined || - (Array.isArray(providedAgentArgs) && - providedAgentArgs.every((item) => typeof item === "string")); + let parsedAgentArgs: string[] | undefined; + let startupLinks: string[] | undefined; + let fullAccess: boolean | undefined; + let useWorktree: boolean | undefined; + let createNewBranch: boolean | undefined; + let autoReview: boolean | undefined; - if (!agentArgsValid) { - return reply - .code(400) - .send({ error: "agentArgs must be an array of strings." }); + try { + parsedAgentArgs = parseOptionalStringArrayField( + body.agentArgs ?? body.codexArgs, + "agentArgs", + parsedRequest.isMultipart + ); + startupLinks = parseOptionalStringArrayField( + body.startupLinks, + "startupLinks", + parsedRequest.isMultipart + ); + fullAccess = parseOptionalBooleanField( + body.fullAccess, + "fullAccess", + parsedRequest.isMultipart + ); + useWorktree = parseOptionalBooleanField( + body.useWorktree, + "useWorktree", + parsedRequest.isMultipart + ); + createNewBranch = parseOptionalBooleanField( + body.createNewBranch, + "createNewBranch", + parsedRequest.isMultipart + ); + autoReview = parseOptionalBooleanField( + body.autoReview, + "autoReview", + parsedRequest.isMultipart + ); + } catch (error) { + return reply.code(400).send({ + error: error instanceof Error ? error.message : "Invalid request body.", + }); } if ( @@ -3879,36 +4092,6 @@ async function registerRoutes() { }); } - if (body.fullAccess !== undefined && typeof body.fullAccess !== "boolean") { - return reply - .code(400) - .send({ error: "fullAccess must be a boolean when provided." }); - } - - if ( - body.useWorktree !== undefined && - typeof body.useWorktree !== "boolean" - ) { - return reply - .code(400) - .send({ error: "useWorktree must be a boolean when provided." }); - } - - if ( - body.createNewBranch !== undefined && - typeof body.createNewBranch !== "boolean" - ) { - return reply - .code(400) - .send({ error: "createNewBranch must be a boolean when provided." }); - } - - if (body.autoReview !== undefined && typeof body.autoReview !== "boolean") { - return reply - .code(400) - .send({ error: "autoReview must be a boolean when provided." }); - } - if ( body.worktreeBranch !== undefined && typeof body.worktreeBranch !== "string" @@ -3942,7 +4125,6 @@ async function registerRoutes() { }); } - const agentArgs = providedAgentArgs as string[] | undefined; const agentType = body.type === "claude" ? "claude" @@ -3970,9 +4152,17 @@ async function registerRoutes() { ? CODEX_FULL_ACCESS_ARG : null; const resolvedAgentArgs = - !isTerminalAgent && body.fullAccess === true && fullAccessArg - ? Array.from(new Set([...(agentArgs ?? []), fullAccessArg])) - : agentArgs; + !isTerminalAgent && fullAccess === true && fullAccessArg + ? Array.from(new Set([...(parsedAgentArgs ?? []), fullAccessArg])) + : parsedAgentArgs; + let startupPins: ReturnType; + try { + startupPins = createStartupPins(startupLinks ?? []); + } catch (error) { + return reply.code(400).send({ + error: error instanceof Error ? error.message : "Invalid startupLinks.", + }); + } try { const worktreeLocationRaw = await getSetting(pool, WORKTREE_LOCATION_KEY); @@ -3987,13 +4177,9 @@ async function registerRoutes() { type: agentType, cwd: body.cwd, agentArgs: resolvedAgentArgs, - fullAccess: !isTerminalAgent && body.fullAccess === true, - useWorktree: - typeof body.useWorktree === "boolean" ? body.useWorktree : undefined, - createNewBranch: - typeof body.createNewBranch === "boolean" - ? body.createNewBranch - : undefined, + fullAccess: !isTerminalAgent && fullAccess === true, + useWorktree, + createNewBranch, worktreeBranch: typeof body.worktreeBranch === "string" ? body.worktreeBranch @@ -4010,11 +4196,13 @@ async function registerRoutes() { typeof body.personaContext === "string" ? body.personaContext : undefined, - autoReview: !isTerminalAgent && body.autoReview === true, + autoReview: !isTerminalAgent && autoReview === true, initialPrompt: !isTerminalAgent && typeof body.initialPrompt === "string" ? body.initialPrompt.trim() || undefined : undefined, + initialPins: !isTerminalAgent ? startupPins : [], + initialFiles: !isTerminalAgent ? startupFiles : [], }); queueGitContextRefresh([agent.id]); uiEventBroker.publish({ diff --git a/apps/web/src/components/app/create-agent-dialog.tsx b/apps/web/src/components/app/create-agent-dialog.tsx index dcead1b0..e81191f5 100644 --- a/apps/web/src/components/app/create-agent-dialog.tsx +++ b/apps/web/src/components/app/create-agent-dialog.tsx @@ -1,4 +1,6 @@ import { + type ChangeEvent, + type ClipboardEvent, type FormEvent, useCallback, useEffect, @@ -7,7 +9,16 @@ import { useState, } from "react"; import { useAtom } from "jotai"; -import { Check, ChevronDown, GitBranch, ChevronLeft } from "lucide-react"; +import { + Check, + ChevronDown, + GitBranch, + ChevronLeft, + Link2, + Paperclip, + Plus, + X, +} from "lucide-react"; import { BranchSelect } from "@/components/app/branch-select"; import { PathInput } from "@/components/app/path-input"; @@ -47,6 +58,12 @@ const CWD_HISTORY_MAX = 20; const FULL_ACCESS_PREFIX = "dispatch:fullAccess:"; const AUTO_REVIEW_PREFIX = "dispatch:autoReview:"; const BASE_BRANCH_PREFIX = "dispatch:baseBranch:"; +const STARTUP_FILE_ACCEPT = + ".png,.jpg,.jpeg,.gif,.webp,.mp4,.pdf,.txt,.md,.json,.yaml,.yml,.toml,.csv,.log,.xml,.html,.css,.js,.jsx,.ts,.tsx,.py,.go,.rs,.sh,.sql,.diff,.patch,.env,.ini,.cfg,.conf,.swift,.kt,.java,.c,.cpp,.h,.hpp,.rb,.php,.lua,.zig,.nim,.r,.m,.ex,.exs,.erl,.hs"; + +function startupFileKey(file: File): string { + return `${file.name}:${file.size}:${file.lastModified}`; +} function readStoredString(key: string): string { if (typeof window === "undefined") return ""; @@ -211,8 +228,9 @@ function CreateAgentDialogContent({ resolveDefaultCwd, onCreated, }: Omit): JSX.Element { - const [step, setStep] = useState<"config" | "prompt">("config"); + const [step, setStep] = useState<"config" | "context">("config"); const promptTextareaRef = useRef(null); + const startupFileInputRef = useRef(null); const [createName, setCreateName] = useState(""); const [createType, setCreateType] = useState(() => { const preferred = initialAgentType ?? readLastUsedAgentType(); @@ -231,6 +249,9 @@ function CreateAgentDialogContent({ const [createWorktreeBranch, setCreateWorktreeBranch] = useState(""); const [cwdIsGitRepo, setCwdIsGitRepo] = useState(null); const [initialPrompt, setInitialPrompt] = useState(""); + const [startupFiles, setStartupFiles] = useState([]); + const [startupLinks, setStartupLinks] = useState([]); + const [linkDraft, setLinkDraft] = useState(""); const [creating, setCreating] = useState(false); const [cwdHistory, setCwdHistory] = useState(() => readCwdHistory() @@ -279,7 +300,7 @@ function CreateAgentDialogContent({ }, [createType, enabledAgentTypes]); useEffect(() => { - if (step === "prompt") { + if (step === "context") { requestAnimationFrame(() => promptTextareaRef.current?.focus()); } }, [step]); @@ -301,6 +322,66 @@ function CreateAgentDialogContent({ [] ); + const appendStartupFiles = useCallback((files: File[]) => { + if (files.length === 0) return; + setStartupFiles((current) => { + const next = [...current]; + const seen = new Set(current.map(startupFileKey)); + for (const file of files) { + const key = startupFileKey(file); + if (seen.has(key)) continue; + seen.add(key); + next.push(file); + } + return next; + }); + }, []); + + const handleStartupPaste = useCallback( + (event: ClipboardEvent) => { + const pastedFiles = Array.from(event.clipboardData.items) + .filter((item) => item.kind === "file") + .map((item) => item.getAsFile()) + .filter((file): file is File => file !== null); + if (pastedFiles.length === 0) return; + event.preventDefault(); + appendStartupFiles(pastedFiles); + }, + [appendStartupFiles] + ); + + const handleStartupFileChange = useCallback( + (event: ChangeEvent) => { + const selected = Array.from(event.target.files ?? []); + appendStartupFiles(selected); + event.target.value = ""; + }, + [appendStartupFiles] + ); + + const handleRemoveStartupFile = useCallback((fileToRemove: File) => { + setStartupFiles((current) => + current.filter( + (file) => startupFileKey(file) !== startupFileKey(fileToRemove) + ) + ); + }, []); + + const addStartupLink = useCallback(() => { + const trimmed = linkDraft.trim(); + if (!trimmed) return; + setStartupLinks((current) => + current.includes(trimmed) ? current : [...current, trimmed] + ); + setLinkDraft(""); + }, [linkDraft]); + + const handleRemoveStartupLink = useCallback((linkToRemove: string) => { + setStartupLinks((current) => + current.filter((link) => link !== linkToRemove) + ); + }, []); + const handleSubmit = useCallback( async (event: FormEvent) => { event.preventDefault(); @@ -314,27 +395,56 @@ function CreateAgentDialogContent({ // try to run git in a non-repo directory. const submitUseWorktree = cwdIsGitRepo === false ? false : createUseWorktree; - const payload = await api<{ agent: Agent }>("/api/v1/agents", { - method: "POST", - body: JSON.stringify({ - name: createName.trim(), - cwd, - type: createType, - fullAccess: createFullAccess, - autoReview: createAutoReview, - useWorktree: submitUseWorktree, - createNewBranch: submitUseWorktree ? createNewBranch : undefined, - worktreeBranch: - submitUseWorktree && createNewBranch - ? createWorktreeBranch.trim() || undefined - : undefined, - baseBranch: - submitUseWorktree && createBaseBranch !== "main" - ? createBaseBranch - : undefined, - initialPrompt: initialPrompt.trim() || undefined, - }), - }); + const payloadBase = { + name: createName.trim(), + cwd, + type: createType, + fullAccess: createFullAccess, + autoReview: createAutoReview, + useWorktree: submitUseWorktree, + createNewBranch: submitUseWorktree ? createNewBranch : undefined, + worktreeBranch: + submitUseWorktree && createNewBranch + ? createWorktreeBranch.trim() || undefined + : undefined, + baseBranch: + submitUseWorktree && createBaseBranch !== "main" + ? createBaseBranch + : undefined, + initialPrompt: initialPrompt.trim() || undefined, + }; + const resolvedStartupLinks = + step === "context" && linkDraft.trim() + ? Array.from(new Set([...startupLinks, linkDraft.trim()])) + : startupLinks; + const useStartupContext = + step === "context" && + (payloadBase.initialPrompt || + startupFiles.length > 0 || + resolvedStartupLinks.length > 0); + const payload = useStartupContext + ? await (async () => { + const formData = new FormData(); + for (const [key, value] of Object.entries(payloadBase)) { + if (value === undefined || value === "") continue; + formData.append(key, String(value)); + } + formData.append( + "startupLinks", + JSON.stringify(resolvedStartupLinks) + ); + for (const file of startupFiles) { + formData.append("startupFiles", file); + } + return api<{ agent: Agent }>("/api/v1/agents", { + method: "POST", + body: formData, + }); + })() + : await api<{ agent: Agent }>("/api/v1/agents", { + method: "POST", + body: JSON.stringify(payloadBase), + }); if (typeof window !== "undefined") { window.localStorage.setItem(LAST_USED_CWD_KEY, cwd); @@ -359,6 +469,10 @@ function CreateAgentDialogContent({ cwdIsGitRepo, initialPrompt, onCreated, + linkDraft, + startupFiles, + startupLinks, + step, ] ); @@ -373,7 +487,7 @@ function CreateAgentDialogContent({ if (typeDropdownOpen) { e.preventDefault(); } - if (step === "prompt") { + if (step === "context") { e.preventDefault(); setStep("config"); } @@ -688,10 +802,10 @@ function CreateAgentDialogContent({ variant="default" tabIndex={0} disabled={creating || !createCwd.trim()} - data-testid="create-agent-with-prompt" - onClick={() => setStep("prompt")} + data-testid="create-agent-with-context" + onClick={() => setStep("context")} > - Create with prompt + Create with context ) : null}