From 5352fd99421c748ef9e330dfface80c882f26298 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Fri, 29 May 2026 03:11:50 +0000 Subject: [PATCH] coder(pending): new-file-from-RightRail create endpoint + modal POST /api/sessions/:sessionId/pending/create queues a pending_changes create via queueCreate (WriteGuardError -> 422 with the guard message). RightRail gains a 'New file from pasted text' modal (path + content) wired through api.coder.createPendingFile; sessionId is threaded down from App.tsx. The staged change shows in the CoderPane DiffPanel for explicit apply. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/coder/src/routes/pending.ts | 51 +++++++++++++ apps/web/src/App.tsx | 2 +- apps/web/src/api/client.ts | 17 +++++ apps/web/src/components/RightRail.tsx | 102 +++++++++++++++++++++++++- 4 files changed, 168 insertions(+), 4 deletions(-) diff --git a/apps/coder/src/routes/pending.ts b/apps/coder/src/routes/pending.ts index 392d15f..9467126 100644 --- a/apps/coder/src/routes/pending.ts +++ b/apps/coder/src/routes/pending.ts @@ -1,4 +1,5 @@ import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; import type { Sql } from '../db.js'; import { listPending, @@ -6,7 +7,14 @@ import { applyAll, rejectOne, rewindOne, + queueCreate, } from '../services/pending_changes.js'; +import { WriteGuardError } from '../services/write_guard.js'; + +const CreateBody = z.object({ + file_path: z.string().min(1), + content: z.string(), +}); /** * Resolve project root from a session's project path. @@ -51,6 +59,49 @@ export function registerPendingRoutes(app: FastifyInstance, sql: Sql): void { }, ); + // POST /api/sessions/:sessionId/pending/create — queue a new-file create + // (manual create from the RightRail file browser; no inference involved). + // queueCreate runs resolveWritePath internally, so a path that escapes the + // project root or hits a secret file throws WriteGuardError → 422 with the + // guard message. Mirrors the { error } 404 shape used by the other routes + // and the 422 status used by apply/rewind on failure. + app.post<{ Params: { sessionId: string } }>( + '/api/sessions/:sessionId/pending/create', + async (req, reply) => { + const sessionId = req.params.sessionId; + + const parsed = CreateBody.safeParse(req.body); + if (!parsed.success) { + reply.code(400); + return { error: 'invalid body', details: parsed.error.flatten() }; + } + + const projectRoot = await resolveProjectRoot(sql, sessionId); + if (!projectRoot) { + reply.code(404); + return { error: 'session or project not found' }; + } + + try { + const change = await queueCreate( + sql, + sessionId, + null, + parsed.data.file_path, + parsed.data.content, + projectRoot, + ); + return change; + } catch (err) { + if (err instanceof WriteGuardError) { + reply.code(422); + return { error: err.message }; + } + throw err; + } + }, + ); + // POST /api/sessions/:sessionId/pending/apply — apply all pending changes app.post<{ Params: { sessionId: string } }>( '/api/sessions/:sessionId/pending/apply', diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 71bd4a2..9e15dc8 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -33,7 +33,7 @@ function RightRailForSession({ sessionId }: { sessionId: string }) { // a right-side drawer toggled by the header's FolderTree button (via // useRightRailDrawer). On desktop, it renders inline as before with its // own internal open/close state. - return ; + return ; } function MobileBackdrop() { diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 3df8852..713209f 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -346,6 +346,23 @@ export const api = { user_message: userMessage, }), }), + // Queue a new-file create from the RightRail browser → BooCoder + // pending_changes (operation='create'). Surfaces in the CoderPane DiffPanel + // for explicit apply. A WriteGuardError comes back as a 422 whose { error } + // body ApiError exposes as .message for inline display. + createPendingFile: (sessionId: string, file_path: string, content: string) => + request<{ + id: string; + session_id: string; + task_id: string | null; + file_path: string; + operation: string; + status: string; + created_at: string; + }>(`/api/coder/sessions/${sessionId}/pending/create`, { + method: 'POST', + body: JSON.stringify({ file_path, content }), + }), }, agents: { diff --git a/apps/web/src/components/RightRail.tsx b/apps/web/src/components/RightRail.tsx index 12f9629..59ae508 100644 --- a/apps/web/src/components/RightRail.tsx +++ b/apps/web/src/components/RightRail.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { ChevronRight, ChevronDown, FileText, Folder, PanelRightClose, PanelRightOpen } from 'lucide-react'; -import { api } from '@/api/client'; +import { ChevronRight, ChevronDown, FilePlus, FileText, Folder, PanelRightClose, PanelRightOpen } from 'lucide-react'; +import { api, ApiError } from '@/api/client'; import type { FileEntry } from '@/api/types'; import { inferLanguage } from '@/lib/attachments'; import { sessionEvents } from '@/hooks/sessionEvents'; @@ -8,10 +8,22 @@ import { useRightRailDrawer } from '@/hooks/useRightRailDrawer'; import { useViewport } from '@/hooks/useViewport'; import { FileViewerOverlay } from '@/components/FileViewerOverlay'; import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; import { cn } from '@/lib/utils'; interface Props { projectId: string; + sessionId: string; } const STORAGE_KEY = 'boocode.rightrail'; @@ -27,7 +39,7 @@ function joinPath(parent: string, name: string): string { return `${parent}/${name}`; } -export function RightRail({ projectId }: Props) { +export function RightRail({ projectId, sessionId }: Props) { const { isMobile } = useViewport(); const { open: drawerOpen, setOpen: setDrawerOpen } = useRightRailDrawer(); const [open, setOpen] = useState(() => { @@ -39,6 +51,39 @@ export function RightRail({ projectId }: Props) { const [fullFileList, setFullFileList] = useState(null); const [viewerFile, setViewerFile] = useState<{ path: string; content: string } | null>(null); + // New-file-from-pasted-text modal. Queues a pending_changes create via + // BooCoder; it then shows in the CoderPane DiffPanel for explicit apply. + const [newFileOpen, setNewFileOpen] = useState(false); + const [newFilePath, setNewFilePath] = useState(''); + const [newFileContent, setNewFileContent] = useState(''); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(null); + + const openNewFile = useCallback(() => { + setNewFilePath(''); + setNewFileContent(''); + setCreateError(null); + setNewFileOpen(true); + }, []); + + const submitNewFile = useCallback(async () => { + const path = newFilePath.trim(); + if (!path || creating) return; + setCreating(true); + setCreateError(null); + try { + await api.coder.createPendingFile(sessionId, path, newFileContent); + setNewFileOpen(false); + setNewFilePath(''); + setNewFileContent(''); + } catch (err) { + // 422 WriteGuardError surfaces via ApiError.message (the route's { error }). + setCreateError(err instanceof ApiError ? err.message : err instanceof Error ? err.message : 'Failed to create file'); + } finally { + setCreating(false); + } + }, [sessionId, newFilePath, newFileContent, creating]); + // Combined open state: on mobile use the global drawer state (toggled by // the Session header's FolderTree button); on desktop use the persistent // internal state. @@ -163,6 +208,15 @@ export function RightRail({ projectId }: Props) {