diff --git a/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx b/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx index 8db127a0..dc73c7ef 100644 --- a/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx +++ b/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx @@ -11,7 +11,9 @@ import { import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { + deleteProjectPlanFile, getProjectContextData, + importProjectPlanFileFromContent, OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH, readProjectPlanFile, OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH, @@ -405,6 +407,72 @@ export const ProjectNotesTodoPanel: React.FC = ({ [canCreateWorktree, createSession, initializeNewOpenChamberSession, onActionComplete, pendingSendTarget, projectRef, routeToChat, sendMessage, setCurrentSession] ); + const planFileInputRef = React.useRef(null); + const [isImportingPlan, setIsImportingPlan] = React.useState(false); + const [deletingPlanId, setDeletingPlanId] = React.useState(null); + + const handleDeletePlan = React.useCallback( + async (planId: string) => { + if (!projectRef || deletingPlanId) { + return; + } + setDeletingPlanId(planId); + try { + const ok = await deleteProjectPlanFile(projectRef, planId); + if (!ok) { + toast.error('Failed to delete plan'); + return; + } + setPlans((previous) => previous.filter((entry) => entry.id !== planId)); + window.dispatchEvent(new CustomEvent('openchamber:project-plan-saved', { + detail: { projectId: projectRef.id }, + })); + } finally { + setDeletingPlanId(null); + } + }, + [deletingPlanId, projectRef] + ); + + const handleTriggerUploadPlan = React.useCallback(() => { + if (!projectRef || isImportingPlan) { + return; + } + planFileInputRef.current?.click(); + }, [isImportingPlan, projectRef]); + + const handleUploadPlanFile = React.useCallback( + async (file: File | null) => { + if (!projectRef || !file) { + return; + } + setIsImportingPlan(true); + try { + const text = await file.text(); + if (!text.trim()) { + toast.error('Plan file is empty'); + return; + } + const fallbackTitle = file.name.replace(/\.(md|markdown|txt)$/i, '').trim(); + const created = await importProjectPlanFileFromContent(projectRef, text, fallbackTitle); + if (!created) { + toast.error('Failed to import plan'); + return; + } + window.dispatchEvent(new CustomEvent('openchamber:project-plan-saved', { + detail: { projectId: projectRef.id }, + })); + toast.success('Plan imported'); + } catch (error) { + const description = error instanceof Error ? error.message : undefined; + toast.error('Failed to read plan file', description ? { description } : undefined); + } finally { + setIsImportingPlan(false); + } + }, + [projectRef] + ); + const handleOpenPlan = React.useCallback( (plan: ProjectPlanListItem) => { const projectPath = projectRef?.path?.trim(); @@ -572,6 +640,27 @@ export const ProjectNotesTodoPanel: React.FC = ({

Plans

{plans.length} file{plans.length === 1 ? '' : 's'} + { + const file = event.target.files?.[0] ?? null; + void handleUploadPlanFile(file); + event.currentTarget.value = ''; + }} + /> +
@@ -580,17 +669,27 @@ export const ProjectNotesTodoPanel: React.FC = ({ ) : (
    {plans.map((plan) => ( -
  • +
  • +
  • ))}
diff --git a/packages/ui/src/lib/openchamberConfig.ts b/packages/ui/src/lib/openchamberConfig.ts index 727c9174..a1ecbfc4 100644 --- a/packages/ui/src/lib/openchamberConfig.ts +++ b/packages/ui/src/lib/openchamberConfig.ts @@ -749,6 +749,64 @@ export async function readProjectPlanFile(path: string): Promise => { + const runtimeFiles = getRuntimeFilesAPI(); + if (runtimeFiles?.delete) { + try { + const result = await runtimeFiles.delete(path); + if (result?.success !== false) { + return true; + } + } catch { + // fall through + } + } + + const res = await postJson<{ success?: boolean }>(`${getBaseUrl()}/fs/delete`, { path }); + return Boolean(res.ok); +}; + +export async function deleteProjectPlanFile( + project: ProjectRef, + planId: string +): Promise { + const trimmedId = typeof planId === 'string' ? planId.trim() : ''; + if (!trimmedId) { + return false; + } + + const existing = await getProjectPlanFiles(project); + const target = existing.find((entry) => entry.id === trimmedId); + if (!target) { + return false; + } + + const next = existing.filter((entry) => entry.id !== trimmedId); + const saved = await saveProjectPlanFiles(project, next); + if (!saved) { + return false; + } + + // Best-effort: remove underlying markdown file, ignore failure. + await deleteFile(target.path).catch(() => false); + return true; +} + +export async function importProjectPlanFileFromContent( + project: ProjectRef, + content: string, + fallbackTitle?: string +): Promise { + const raw = typeof content === 'string' ? content : ''; + if (!raw.trim()) { + return null; + } + + const parsed = parseProjectPlanMarkdown(raw); + const title = parsed.title || sanitizePlanTitle(fallbackTitle ?? '') || 'Plan'; + return createProjectPlanFile(project, { title, body: parsed.body }); +} + export async function createProjectPlanFile( project: ProjectRef, value: { title: string; body: string }