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) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 03:11:50 +00:00
parent 66df410826
commit 5352fd9942
4 changed files with 168 additions and 4 deletions

View File

@@ -1,4 +1,5 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js'; import type { Sql } from '../db.js';
import { import {
listPending, listPending,
@@ -6,7 +7,14 @@ import {
applyAll, applyAll,
rejectOne, rejectOne,
rewindOne, rewindOne,
queueCreate,
} from '../services/pending_changes.js'; } 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. * 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 // POST /api/sessions/:sessionId/pending/apply — apply all pending changes
app.post<{ Params: { sessionId: string } }>( app.post<{ Params: { sessionId: string } }>(
'/api/sessions/:sessionId/pending/apply', '/api/sessions/:sessionId/pending/apply',

View File

@@ -33,7 +33,7 @@ function RightRailForSession({ sessionId }: { sessionId: string }) {
// a right-side drawer toggled by the header's FolderTree button (via // a right-side drawer toggled by the header's FolderTree button (via
// useRightRailDrawer). On desktop, it renders inline as before with its // useRightRailDrawer). On desktop, it renders inline as before with its
// own internal open/close state. // own internal open/close state.
return <RightRail projectId={projectId} />; return <RightRail projectId={projectId} sessionId={sessionId} />;
} }
function MobileBackdrop() { function MobileBackdrop() {

View File

@@ -346,6 +346,23 @@ export const api = {
user_message: userMessage, 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: { agents: {

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { ChevronRight, ChevronDown, FileText, Folder, PanelRightClose, PanelRightOpen } from 'lucide-react'; import { ChevronRight, ChevronDown, FilePlus, FileText, Folder, PanelRightClose, PanelRightOpen } from 'lucide-react';
import { api } from '@/api/client'; import { api, ApiError } from '@/api/client';
import type { FileEntry } from '@/api/types'; import type { FileEntry } from '@/api/types';
import { inferLanguage } from '@/lib/attachments'; import { inferLanguage } from '@/lib/attachments';
import { sessionEvents } from '@/hooks/sessionEvents'; import { sessionEvents } from '@/hooks/sessionEvents';
@@ -8,10 +8,22 @@ import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
import { useViewport } from '@/hooks/useViewport'; import { useViewport } from '@/hooks/useViewport';
import { FileViewerOverlay } from '@/components/FileViewerOverlay'; import { FileViewerOverlay } from '@/components/FileViewerOverlay';
import { Input } from '@/components/ui/input'; 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'; import { cn } from '@/lib/utils';
interface Props { interface Props {
projectId: string; projectId: string;
sessionId: string;
} }
const STORAGE_KEY = 'boocode.rightrail'; const STORAGE_KEY = 'boocode.rightrail';
@@ -27,7 +39,7 @@ function joinPath(parent: string, name: string): string {
return `${parent}/${name}`; return `${parent}/${name}`;
} }
export function RightRail({ projectId }: Props) { export function RightRail({ projectId, sessionId }: Props) {
const { isMobile } = useViewport(); const { isMobile } = useViewport();
const { open: drawerOpen, setOpen: setDrawerOpen } = useRightRailDrawer(); const { open: drawerOpen, setOpen: setDrawerOpen } = useRightRailDrawer();
const [open, setOpen] = useState(() => { const [open, setOpen] = useState(() => {
@@ -39,6 +51,39 @@ export function RightRail({ projectId }: Props) {
const [fullFileList, setFullFileList] = useState<string[] | null>(null); const [fullFileList, setFullFileList] = useState<string[] | null>(null);
const [viewerFile, setViewerFile] = useState<{ path: string; content: string } | null>(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<string | null>(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 // Combined open state: on mobile use the global drawer state (toggled by
// the Session header's FolderTree button); on desktop use the persistent // the Session header's FolderTree button); on desktop use the persistent
// internal state. // internal state.
@@ -163,6 +208,15 @@ export function RightRail({ projectId }: Props) {
<aside className={asideCls}> <aside className={asideCls}>
<div className="flex items-center gap-2 px-3 py-2 border-b shrink-0"> <div className="flex items-center gap-2 px-3 py-2 border-b shrink-0">
<span className="text-xs font-medium flex-1">Files</span> <span className="text-xs font-medium flex-1">Files</span>
<button
type="button"
onClick={openNewFile}
className="p-1 rounded hover:bg-muted text-muted-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="New file from pasted text"
title="New file"
>
<FilePlus size={14} />
</button>
<button <button
type="button" type="button"
onClick={closeRail} onClick={closeRail}
@@ -225,6 +279,48 @@ export function RightRail({ projectId }: Props) {
onNavigate={(path) => void openFile(path)} onNavigate={(path) => void openFile(path)}
/> />
)} )}
<Dialog open={newFileOpen} onOpenChange={setNewFileOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>New file from pasted text</DialogTitle>
<DialogDescription>
Queues a new file as a pending change. Review and apply it from the Coder pane.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="new-file-path" className="text-xs">Path (relative to project root)</Label>
<Input
id="new-file-path"
value={newFilePath}
onChange={(e) => setNewFilePath(e.target.value)}
placeholder="src/example.ts"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-file-content" className="text-xs">Content</Label>
<Textarea
id="new-file-content"
value={newFileContent}
onChange={(e) => setNewFileContent(e.target.value)}
placeholder="Paste file contents here…"
autoFocus
className="min-h-[180px] font-mono text-xs"
/>
</div>
{createError && <p className="text-xs text-destructive">{createError}</p>}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setNewFileOpen(false)} disabled={creating}>
Cancel
</Button>
<Button onClick={() => void submitNewFile()} disabled={creating || !newFilePath.trim()}>
{creating ? 'Creating…' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</> </>
); );
} }