feat: add ability to upload external plans and remove them from sidebar

This commit is contained in:
Bohdan Triapitsyn
2026-04-19 00:52:14 +03:00
parent f04d732e15
commit 46102ffbf3
2 changed files with 159 additions and 2 deletions
@@ -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<ProjectNotesTodoPanelProps> = ({
[canCreateWorktree, createSession, initializeNewOpenChamberSession, onActionComplete, pendingSendTarget, projectRef, routeToChat, sendMessage, setCurrentSession]
);
const planFileInputRef = React.useRef<HTMLInputElement | null>(null);
const [isImportingPlan, setIsImportingPlan] = React.useState(false);
const [deletingPlanId, setDeletingPlanId] = React.useState<string | null>(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<ProjectNotesTodoPanelProps> = ({
<h3 className="typography-ui-label font-semibold text-foreground">Plans</h3>
<span className="typography-meta text-muted-foreground">{plans.length} file{plans.length === 1 ? '' : 's'}</span>
</div>
<input
ref={planFileInputRef}
type="file"
accept=".md,.markdown,.txt,text/markdown,text/plain"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0] ?? null;
void handleUploadPlanFile(file);
event.currentTarget.value = '';
}}
/>
<button
type="button"
onClick={handleTriggerUploadPlan}
disabled={!projectRef || isImportingPlan}
className="inline-flex h-6 w-6 items-center justify-center rounded-md border border-border/70 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Import plan from file"
title="Import plan from file"
>
<RiAddLine className="h-3.5 w-3.5" />
</button>
</div>
<div className="max-h-56 overflow-y-auto rounded-lg border border-border/60 bg-background/40">
@@ -580,17 +669,27 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
) : (
<ul className="divide-y divide-border/50">
{plans.map((plan) => (
<li key={plan.id} className="px-2.5 py-1.5">
<li key={plan.id} className="flex items-center gap-1.5 px-2.5 py-1.5">
<button
type="button"
onClick={() => handleOpenPlan(plan)}
className="flex w-full min-w-0 items-center justify-between gap-3 rounded-md px-1.5 py-1 text-left hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
className="flex min-w-0 flex-1 items-center justify-between gap-3 rounded-md px-1.5 py-1 text-left hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
>
<span className="min-w-0 truncate typography-ui-label text-foreground">{plan.title}</span>
<span className="flex-shrink-0 typography-micro text-muted-foreground">
{new Date(plan.createdAt).toLocaleDateString()}
</span>
</button>
<button
type="button"
onClick={() => void handleDeletePlan(plan.id)}
disabled={deletingPlanId === plan.id}
className="inline-flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50"
aria-label={`Delete plan "${plan.title}"`}
title="Delete plan"
>
<RiDeleteBinLine className="h-3.5 w-3.5" />
</button>
</li>
))}
</ul>
+58
View File
@@ -749,6 +749,64 @@ export async function readProjectPlanFile(path: string): Promise<OpenChamberProj
};
}
const deleteFile = async (path: string): Promise<boolean> => {
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<boolean> {
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<OpenChamberProjectPlanFileLink | null> {
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 }