Files
openchamber/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx
T
Bohdan Triapitsyn 2031e3b4a8 Decouple bundled UI from runtime API and add remote instance tooling (#1228)
Add a packaged-client runtime boundary so the shared UI can talk to local,
desktop, remote, and VS Code runtimes through the right transport instead of
assuming one same-origin web server.

Centralize OpenChamber-owned API access behind RuntimeAPIs, runtimeFetch, and
runtime URL helpers, while keeping official OpenCode traffic on the SDK path.
Support runtime switching, remote host selection, desktop client credentials,
and headless connection links for pairing packaged clients with remote
OpenChamber servers.

Harden the new auth model by moving long-lived client tokens out of browser
URLs, introducing short-lived scoped URL tokens for browser-owned transports,
restricting URL-token access to explicit readable/realtime routes, and making
client-token management session-scoped or self-scoped as appropriate.

Update browser-owned assets and preview proxy flows to work with the split
runtime model, including authenticated project icons, preview token propagation,
CSP-safe preview bridge injection, and preview proxy auth that survives
short-lived URL-token expiry.

Tighten Electron security boundaries for packaged clients by gating privileged
preload state to trusted origins and requiring explicit confirmation before
connect deep-links import or switch remote runtimes.

Also refresh agent guidance and project skills so future runtime/API, auth,
preview, UI, CLI, settings, locale, and drag-to-reorder work follows the new
architecture.
2026-06-02 00:43:05 +03:00

1010 lines
40 KiB
TypeScript

import React from 'react';
import {
DndContext,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from '@dnd-kit/sortable';
import { CSS as DndCSS } from '@dnd-kit/utilities';
import { toast } from '@/components/ui';
import { Checkbox } from '@/components/ui/checkbox';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Icon } from "@/components/icon/Icon";
import {
deleteProjectPlanFile,
getProjectContextData,
importProjectPlanFileFromContent,
OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH,
readProjectPlanFile,
OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH,
saveProjectNotesAndTodos,
type OpenChamberProjectPlanFileLink,
type OpenChamberProjectTodoItem,
type ProjectRef,
} from '@/lib/openchamberConfig';
import { requestFileAccess } from '@/lib/desktop';
import { generateBranchName } from '@/lib/git/branchNameGenerator';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useUIStore } from '@/stores/useUIStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store';
import { useInputStore } from '@/sync/input-store';
import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator';
import { cn } from '@/lib/utils';
import { renderMagicPrompt } from '@/lib/magicPrompts';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { TodoSendDialog, type TodoSendExecution } from './TodoSendDialog';
const TODO_PANEL_MIN_ITEMS = 5;
const TODO_PANEL_MAX_ITEMS = 15;
const getEffectiveItemHeight = (padding: number) => {
const scale = Math.sqrt(padding / 100);
const paddingPx = 12 * scale;
const contentPx = 24 * scale; // h-6 uses --spacing-6 which also scales with --padding-scale
const borderPx = 1;
return Math.ceil(paddingPx + contentPx + borderPx);
};
const getPanelHeightForItems = (itemCount: number, padding: number) => {
const itemHeight = getEffectiveItemHeight(padding);
return Math.max(
itemHeight * TODO_PANEL_MIN_ITEMS,
Math.min(itemHeight * TODO_PANEL_MAX_ITEMS, itemHeight * itemCount)
);
};
interface ProjectNotesTodoPanelProps {
projectRef: ProjectRef | null;
projectLabel?: string | null;
canCreateWorktree?: boolean;
onActionComplete?: () => void;
className?: string;
}
type PendingSendTarget = {
kind: 'session' | 'worktree';
todoId: string;
todoText: string;
};
type ProjectPlanListItem = OpenChamberProjectPlanFileLink & {
title: string;
};
const toPlanListItem = async (
plan: OpenChamberProjectPlanFileLink,
fallbackTitle: string,
): Promise<ProjectPlanListItem> => {
const file = await readProjectPlanFile(plan.path);
return {
...plan,
title: file?.title || plan.path.split('/').pop() || fallbackTitle,
};
};
const createTodoId = (): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `todo_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
};
const sortTodosWithCompletedLast = (items: OpenChamberProjectTodoItem[]): OpenChamberProjectTodoItem[] => [
...items.filter((todo) => !todo.completed),
...items.filter((todo) => todo.completed),
];
const insertTodoBeforeCompleted = (items: OpenChamberProjectTodoItem[], item: OpenChamberProjectTodoItem): OpenChamberProjectTodoItem[] => {
const firstCompletedIndex = items.findIndex((todo) => todo.completed);
if (firstCompletedIndex === -1) {
return [...items, item];
}
return [...items.slice(0, firstCompletedIndex), item, ...items.slice(firstCompletedIndex)];
};
type SortableTodoHandleProps = {
attributes: ReturnType<typeof useSortable>['attributes'];
listeners: ReturnType<typeof useSortable>['listeners'];
setActivatorNodeRef: ReturnType<typeof useSortable>['setActivatorNodeRef'];
isDragging: boolean;
};
const SortableTodoItem: React.FC<{
id: string;
children: (dragHandleProps: SortableTodoHandleProps) => React.ReactNode;
}> = ({ id, children }) => {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
return (
<li
ref={setNodeRef}
style={{
transform: DndCSS.Transform.toString(transform),
transition,
}}
className={cn(isDragging && 'opacity-60')}
>
{children({ attributes, listeners, setActivatorNodeRef, isDragging })}
</li>
);
};
export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
projectRef,
projectLabel,
canCreateWorktree = false,
onActionComplete,
className,
}) => {
const { t } = useI18n();
const [isLoading, setIsLoading] = React.useState(false);
const [notes, setNotes] = React.useState('');
const [todos, setTodos] = React.useState<OpenChamberProjectTodoItem[]>([]);
const [newTodoText, setNewTodoText] = React.useState('');
const [sendingTodoId, setSendingTodoId] = React.useState<string | null>(null);
const [expandedTodoIds, setExpandedTodoIds] = React.useState<Set<string>>(() => new Set());
const [plans, setPlans] = React.useState<ProjectPlanListItem[]>([]);
const [pendingSendTarget, setPendingSendTarget] = React.useState<PendingSendTarget | null>(null);
const [isSendDialogSubmitting, setIsSendDialogSubmitting] = React.useState(false);
const [contextReloadTick, setContextReloadTick] = React.useState(0);
const notesHydratedRef = React.useRef(false);
const lastSavedNotesRef = React.useRef('');
const todoPanelHeight = useUIStore((state) => state.todoPanelHeight);
const setTodoPanelHeight = useUIStore((state) => state.setTodoPanelHeight);
const notesPanelHeight = useUIStore((state) => state.notesPanelHeight);
const setNotesPanelHeight = useUIStore((state) => state.setNotesPanelHeight);
const [isTodoPanelResizing, setIsTodoPanelResizing] = React.useState(false);
const todoPanelStartYRef = React.useRef(0);
const todoPanelStartHeightRef = React.useRef(todoPanelHeight);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const createSession = useSessionUIStore((state) => state.createSession);
const initializeNewOpenChamberSession = useSessionUIStore((state) => state.initializeNewOpenChamberSession);
const sendMessage = useSessionUIStore((state) => state.sendMessage);
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
const setPendingInputText = useInputStore((state) => state.setPendingInputText);
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
const padding = useUIStore((state) => state.padding);
const persistProjectData = React.useCallback(
async (nextNotes: string, nextTodos: OpenChamberProjectTodoItem[]) => {
if (!projectRef) {
return false;
}
const saved = await saveProjectNotesAndTodos(projectRef, {
notes: nextNotes,
todos: nextTodos,
});
if (!saved) {
toast.error(t('rightSidebar.contextNotesTodo.toast.saveNotesFailed'));
}
return saved;
},
[projectRef, t]
);
React.useEffect(() => {
if (!projectRef) {
setNotes('');
setTodos([]);
setPlans([]);
setNewTodoText('');
setExpandedTodoIds(new Set());
return;
}
let cancelled = false;
setIsLoading(true);
(async () => {
try {
const data = await getProjectContextData(projectRef);
const nextPlans = await Promise.all(
data.plans.map((plan) => toPlanListItem(plan, t('rightSidebar.contextNotesTodo.plan.defaultTitle')))
);
if (cancelled) {
return;
}
setNotes(data.notes);
setTodos(sortTodosWithCompletedLast(data.todos));
setPlans(nextPlans);
lastSavedNotesRef.current = data.notes;
notesHydratedRef.current = true;
setNewTodoText('');
setExpandedTodoIds(new Set());
} catch {
if (!cancelled) {
toast.error(t('rightSidebar.contextNotesTodo.toast.loadNotesFailed'));
setNotes('');
setTodos([]);
setPlans([]);
lastSavedNotesRef.current = '';
notesHydratedRef.current = true;
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [contextReloadTick, projectRef, t]);
React.useEffect(() => {
if (!projectRef) {
return;
}
const handleProjectContextRefresh = (event: Event) => {
const detail = (event as CustomEvent<{ projectId?: string }>).detail;
if (detail?.projectId && detail.projectId !== projectRef.id) {
return;
}
setContextReloadTick((previous) => previous + 1);
};
window.addEventListener('openchamber:project-plan-saved', handleProjectContextRefresh);
window.addEventListener('openchamber:project-notes-updated', handleProjectContextRefresh);
return () => {
window.removeEventListener('openchamber:project-plan-saved', handleProjectContextRefresh);
window.removeEventListener('openchamber:project-notes-updated', handleProjectContextRefresh);
};
}, [projectRef]);
React.useEffect(() => {
if (todos.length < 7) {
return;
}
const targetHeight = getPanelHeightForItems(todos.length, padding);
const minHeight = getEffectiveItemHeight(padding) * TODO_PANEL_MIN_ITEMS;
if (todoPanelHeight < minHeight || todoPanelHeight > targetHeight) {
setTodoPanelHeight(targetHeight);
}
}, [todos.length, padding, todoPanelHeight, setTodoPanelHeight]);
React.useEffect(() => {
if (!isTodoPanelResizing) {
return;
}
const handlePointerMove = (event: PointerEvent) => {
const delta = event.clientY - todoPanelStartYRef.current;
const nextHeight = Math.min(
getEffectiveItemHeight(padding) * TODO_PANEL_MAX_ITEMS,
Math.max(getEffectiveItemHeight(padding) * TODO_PANEL_MIN_ITEMS, todoPanelStartHeightRef.current + delta)
);
setTodoPanelHeight(nextHeight);
};
const handlePointerEnd = () => {
setIsTodoPanelResizing(false);
};
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerEnd, { once: true });
window.addEventListener('pointercancel', handlePointerEnd, { once: true });
return () => {
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerEnd);
window.removeEventListener('pointercancel', handlePointerEnd);
};
}, [isTodoPanelResizing, padding, setTodoPanelHeight]);
const handleTodoPanelResizeStart = React.useCallback((event: React.PointerEvent) => {
setIsTodoPanelResizing(true);
todoPanelStartYRef.current = event.clientY;
todoPanelStartHeightRef.current = todoPanelHeight;
event.preventDefault();
}, [todoPanelHeight]);
const handleNotesBlur = React.useCallback(() => {
lastSavedNotesRef.current = notes;
void persistProjectData(notes, todos);
}, [notes, persistProjectData, todos]);
React.useEffect(() => {
if (!projectRef || !notesHydratedRef.current) {
return;
}
if (notes === lastSavedNotesRef.current) {
return;
}
const timer = window.setTimeout(() => {
lastSavedNotesRef.current = notes;
void persistProjectData(notes, todos);
}, 400);
return () => {
window.clearTimeout(timer);
};
}, [notes, persistProjectData, projectRef, todos]);
const handleAddTodo = React.useCallback(() => {
const trimmed = newTodoText.trim();
if (!trimmed) {
return;
}
const nextTodos = insertTodoBeforeCompleted(todos, {
id: createTodoId(),
text: trimmed.slice(0, OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH),
completed: false,
createdAt: Date.now(),
});
setTodos(nextTodos);
setNewTodoText('');
void persistProjectData(notes, nextTodos);
}, [newTodoText, notes, persistProjectData, todos]);
const handleToggleTodoExpanded = React.useCallback((id: string) => {
setExpandedTodoIds((previous) => {
const next = new Set(previous);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
const handleToggleTodo = React.useCallback(
(id: string, completed: boolean) => {
const todo = todos.find((item) => item.id === id);
if (!todo || todo.completed === completed) {
return;
}
const remainingTodos = todos.filter((item) => item.id !== id);
const updatedTodo = { ...todo, completed };
const nextTodos = completed
? [...remainingTodos, updatedTodo]
: insertTodoBeforeCompleted(remainingTodos, updatedTodo);
setTodos(nextTodos);
void persistProjectData(notes, nextTodos);
},
[notes, persistProjectData, todos]
);
const handleDeleteTodo = React.useCallback(
(id: string) => {
const nextTodos = todos.filter((todo) => todo.id !== id);
setTodos(nextTodos);
void persistProjectData(notes, nextTodos);
},
[notes, persistProjectData, todos]
);
const handleClearCompletedTodos = React.useCallback(() => {
const nextTodos = todos.filter((todo) => !todo.completed);
if (nextTodos.length === todos.length) {
return;
}
setTodos(nextTodos);
void persistProjectData(notes, nextTodos);
}, [notes, persistProjectData, todos]);
const handleTodoReorder = React.useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) {
return;
}
const oldIndex = todos.findIndex((todo) => todo.id === active.id);
const newIndex = todos.findIndex((todo) => todo.id === over.id);
if (oldIndex === -1 || newIndex === -1) {
return;
}
const nextTodos = sortTodosWithCompletedLast(arrayMove(todos, oldIndex, newIndex));
setTodos(nextTodos);
void persistProjectData(notes, nextTodos);
},
[notes, persistProjectData, todos]
);
const todoSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } })
);
const todoInputValue = newTodoText.slice(0, OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH);
const completedTodoCount = todos.reduce((count, todo) => count + (todo.completed ? 1 : 0), 0);
const routeToChat = React.useCallback(() => {
setActiveMainTab('chat');
setSessionSwitcherOpen(false);
}, [setActiveMainTab, setSessionSwitcherOpen]);
const handleSendToNewSession = React.useCallback(
(todoId: string, todoText: string) => {
if (!projectRef || sendingTodoId) {
return;
}
setPendingSendTarget({ kind: 'session', todoId, todoText });
},
[projectRef, sendingTodoId]
);
const handleSendToCurrentSession = React.useCallback(
(todoText: string) => {
if (!currentSessionId) {
toast.error(t('rightSidebar.contextNotesTodo.toast.noActiveSession'));
return;
}
routeToChat();
const fenced = `\`\`\`md\n${todoText}\n\`\`\``;
setPendingInputText(fenced, 'append');
toast.success(t('rightSidebar.contextNotesTodo.toast.sentToCurrentSession'));
onActionComplete?.();
},
[currentSessionId, onActionComplete, routeToChat, setPendingInputText, t]
);
const handleSendToNewWorktreeSession = React.useCallback(
(todoId: string, todoText: string) => {
if (!projectRef || sendingTodoId) {
return;
}
if (!canCreateWorktree) {
toast.error(t('rightSidebar.contextNotesTodo.toast.worktreeRequiresGitRepo'));
return;
}
setPendingSendTarget({ kind: 'worktree', todoId, todoText });
},
[canCreateWorktree, projectRef, sendingTodoId, t]
);
const handleConfirmSend = React.useCallback(
async (execution: TodoSendExecution) => {
if (!projectRef || !pendingSendTarget) {
return;
}
const visiblePrompt = await renderMagicPrompt('plan.todo.visible', {
todo_text: pendingSendTarget.todoText,
});
const instructionsText = await renderMagicPrompt('plan.todo.instructions', {
todo_text: pendingSendTarget.todoText,
});
const syntheticParts = [{ synthetic: true as const, text: instructionsText }];
setIsSendDialogSubmitting(true);
setSendingTodoId(pendingSendTarget.todoId);
try {
routeToChat();
let sessionId: string | null = null;
let directoryHint: string | null = projectRef.path;
if (pendingSendTarget.kind === 'worktree') {
if (!canCreateWorktree) {
toast.error(t('rightSidebar.contextNotesTodo.toast.worktreeRequiresGitRepo'));
return;
}
const created = await createWorktreeSessionForNewBranch(projectRef.path, generateBranchName());
if (!created?.id) {
return;
}
sessionId = created.id;
directoryHint = created.path;
} else {
const session = await createSession(undefined, projectRef.path, null);
if (!session?.id) {
toast.error(t('rightSidebar.contextNotesTodo.toast.createSessionFailed'));
return;
}
sessionId = session.id;
directoryHint = session.directory ?? projectRef.path;
initializeNewOpenChamberSession(session.id, useConfigStore.getState().agents ?? []);
}
if (!sessionId) {
return;
}
const selectionState = useSelectionStore.getState();
selectionState.saveSessionModelSelection(sessionId, execution.providerID, execution.modelID);
if (execution.agent.trim()) {
selectionState.saveSessionAgentSelection(sessionId, execution.agent);
selectionState.saveAgentModelForSession(sessionId, execution.agent, execution.providerID, execution.modelID);
selectionState.saveAgentModelVariantForSession(
sessionId,
execution.agent,
execution.providerID,
execution.modelID,
execution.variant || undefined,
);
}
setCurrentSession(sessionId, directoryHint);
await sendMessage(
visiblePrompt,
execution.providerID,
execution.modelID,
execution.agent.trim() || undefined,
undefined,
undefined,
syntheticParts,
execution.variant || undefined,
);
toast.success(
pendingSendTarget.kind === 'worktree'
? t('rightSidebar.contextNotesTodo.toast.sentToNewWorktreeSession')
: t('rightSidebar.contextNotesTodo.toast.sentToNewSession')
);
setPendingSendTarget(null);
onActionComplete?.();
} catch (error) {
const description = error instanceof Error ? error.message : undefined;
toast.error(t('rightSidebar.contextNotesTodo.toast.sendTodoFailed'), description ? { description } : undefined);
} finally {
setIsSendDialogSubmitting(false);
setSendingTodoId(null);
}
},
[canCreateWorktree, createSession, initializeNewOpenChamberSession, onActionComplete, pendingSendTarget, projectRef, routeToChat, sendMessage, setCurrentSession, t]
);
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(t('rightSidebar.contextNotesTodo.toast.deletePlanFailed'));
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, t]
);
const handleTriggerUploadPlan = React.useCallback(async () => {
if (!projectRef || isImportingPlan) {
return;
}
const result = await requestFileAccess({
defaultPath: projectRef.path,
filters: [
{ name: 'Plan files', extensions: ['md', 'markdown', 'txt'] },
{ name: 'All files', extensions: ['*'] },
],
});
if (result.success && result.path) {
setIsImportingPlan(true);
try {
const params = new URLSearchParams({
path: result.path,
allowOutsideWorkspace: 'true',
});
const response = await runtimeFetch(`/api/fs/read?${params.toString()}`, { cache: 'no-store' });
if (!response.ok) {
toast.error(t('rightSidebar.contextNotesTodo.toast.readPlanFileFailed'));
return;
}
const text = await response.text();
if (!text.trim()) {
toast.error(t('rightSidebar.contextNotesTodo.toast.planFileEmpty'));
return;
}
const fallbackTitle = result.path.split('/').pop()?.replace(/\.(md|markdown|txt)$/i, '').trim() || '';
const created = await importProjectPlanFileFromContent(projectRef, text, fallbackTitle);
if (!created) {
toast.error(t('rightSidebar.contextNotesTodo.toast.importPlanFailed'));
return;
}
window.dispatchEvent(new CustomEvent('openchamber:project-plan-saved', {
detail: { projectId: projectRef.id },
}));
toast.success(t('rightSidebar.contextNotesTodo.toast.planImported'));
} catch (error) {
const description = error instanceof Error ? error.message : undefined;
toast.error(t('rightSidebar.contextNotesTodo.toast.readPlanFileFailed'), description ? { description } : undefined);
} finally {
setIsImportingPlan(false);
}
} else if (result.error === 'Native file picker not available') {
// Fall back to HTML file input for web/non-desktop runtimes
planFileInputRef.current?.click();
}
}, [isImportingPlan, projectRef, t]);
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(t('rightSidebar.contextNotesTodo.toast.planFileEmpty'));
return;
}
const fallbackTitle = file.name.replace(/\.(md|markdown|txt)$/i, '').trim();
const created = await importProjectPlanFileFromContent(projectRef, text, fallbackTitle);
if (!created) {
toast.error(t('rightSidebar.contextNotesTodo.toast.importPlanFailed'));
return;
}
window.dispatchEvent(new CustomEvent('openchamber:project-plan-saved', {
detail: { projectId: projectRef.id },
}));
toast.success(t('rightSidebar.contextNotesTodo.toast.planImported'));
} catch (error) {
const description = error instanceof Error ? error.message : undefined;
toast.error(t('rightSidebar.contextNotesTodo.toast.readPlanFileFailed'), description ? { description } : undefined);
} finally {
setIsImportingPlan(false);
}
},
[projectRef, t]
);
const handleOpenPlan = React.useCallback(
(plan: ProjectPlanListItem) => {
const projectPath = projectRef?.path?.trim();
const panelDirectory = currentDirectory?.trim() || projectPath;
if (!panelDirectory) {
return;
}
openContextPanelTab(panelDirectory, {
mode: 'plan',
targetPath: plan.path,
dedupeKey: plan.path,
label: plan.title,
});
},
[currentDirectory, openContextPanelTab, projectRef]
);
if (!projectRef) {
return (
<div className={cn('w-full min-w-0 p-3', className)}>
<p className="typography-meta text-muted-foreground">
{t('rightSidebar.contextNotesTodo.empty.selectProject')}
</p>
</div>
);
}
return (
<div className={cn('w-full min-w-0 space-y-3 p-3', className)}>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<h3 className="min-w-0 truncate typography-ui-label font-semibold text-foreground" title={projectRef.path}>
{t('rightSidebar.contextNotesTodo.notes.title', {
project: projectLabel?.trim() || projectRef.path.split('/').filter(Boolean).pop() || projectRef.path,
})}
</h3>
<span className="typography-meta text-muted-foreground">{notes.length}/{OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH}</span>
</div>
<Textarea
value={notes}
onChange={(event) => setNotes(event.target.value.slice(0, OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH))}
onBlur={handleNotesBlur}
placeholder={t('rightSidebar.contextNotesTodo.notes.placeholder')}
resizedHeight={notesPanelHeight}
onResizeHeightChange={setNotesPanelHeight}
useScrollShadow
scrollShadowSize={56}
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<h3 className="typography-ui-label font-semibold text-foreground">
{t('rightSidebar.contextNotesTodo.todo.title')}
</h3>
<span className="typography-meta text-muted-foreground">
{todos.length === 1
? t('rightSidebar.contextNotesTodo.todo.itemsSingle', { count: todos.length })
: t('rightSidebar.contextNotesTodo.todo.itemsPlural', { count: todos.length })}
</span>
<button
type="button"
onClick={handleClearCompletedTodos}
disabled={isLoading || completedTodoCount === 0}
className="typography-meta rounded-md px-1.5 py-0.5 text-muted-foreground hover:bg-interactive-hover/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50"
>
{t('rightSidebar.contextNotesTodo.todo.clearCompleted')}
</button>
</div>
<span className="typography-meta text-muted-foreground">{todoInputValue.length}/{OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH}</span>
</div>
<div className="flex items-center gap-1.5">
<Input
value={todoInputValue}
onChange={(event) => setNewTodoText(event.target.value.slice(0, OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH))}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
handleAddTodo();
}
}}
placeholder={t('rightSidebar.contextNotesTodo.todo.inputPlaceholder')}
disabled={isLoading}
className="h-8"
/>
<button
type="button"
onClick={handleAddTodo}
disabled={isLoading || todoInputValue.trim().length === 0}
className="inline-flex h-8 w-8 flex-shrink-0 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={t('rightSidebar.contextNotesTodo.todo.addAria')}
title={t('rightSidebar.contextNotesTodo.todo.addAria')}
>
<Icon name="add" className="h-4 w-4" />
</button>
</div>
<div
className={cn(
'overflow-y-auto rounded-lg border border-border/60 bg-background/40',
isTodoPanelResizing && 'transition-none'
)}
style={
todos.length < 7
? undefined
: {
height: `${todoPanelHeight}px`,
maxHeight: `${todoPanelHeight}px`,
}
}
>
{todos.length === 0 ? (
<p className="px-3 py-3 typography-meta text-muted-foreground">
{t('rightSidebar.contextNotesTodo.todo.empty')}
</p>
) : (
<DndContext
sensors={todoSensors}
collisionDetection={closestCenter}
onDragEnd={handleTodoReorder}
>
<SortableContext
items={todos.map((todo) => todo.id)}
strategy={verticalListSortingStrategy}
>
<ul className="divide-y divide-border/50">
{todos.map((todo) => {
const isExpandedTodo = expandedTodoIds.has(todo.id);
return (
<SortableTodoItem key={todo.id} id={todo.id}>
{(dragHandleProps) => (
<div className={cn('flex gap-1.5 px-2.5 py-1.5', isExpandedTodo ? 'items-start' : 'items-center')}>
<button
type="button"
ref={dragHandleProps.setActivatorNodeRef}
{...dragHandleProps.attributes}
{...dragHandleProps.listeners}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
className="flex h-6 w-4 flex-shrink-0 touch-none items-center justify-center text-muted-foreground hover:text-foreground"
aria-label={t('rightSidebar.contextNotesTodo.todo.actions.reorder', { text: todo.text })}
title={t('rightSidebar.contextNotesTodo.todo.actions.reorder', { text: todo.text })}
>
<Icon name="draggable" className="h-3.5 w-3.5" />
</button>
<div className="flex h-6 items-center">
<Checkbox
checked={todo.completed}
onChange={(checked) => handleToggleTodo(todo.id, checked)}
ariaLabel={t('rightSidebar.contextNotesTodo.todo.actions.markComplete', { text: todo.text })}
/>
</div>
<button
type="button"
onClick={() => handleToggleTodoExpanded(todo.id)}
className={cn(
'block min-h-6 min-w-0 flex-1 bg-transparent p-0 text-left typography-ui-label leading-normal text-foreground',
isExpandedTodo ? 'whitespace-normal break-words' : 'overflow-hidden text-ellipsis whitespace-nowrap',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
todo.completed && 'text-muted-foreground line-through'
)}
title={isExpandedTodo ? undefined : todo.text}
aria-label={
isExpandedTodo
? t('rightSidebar.contextNotesTodo.todo.actions.collapse', { text: todo.text })
: t('rightSidebar.contextNotesTodo.todo.actions.expand', { text: todo.text })
}
>
{todo.text}
</button>
<div className="flex h-6 items-center gap-0.5">
<button
type="button"
onClick={() => handleDeleteTodo(todo.id)}
className="inline-flex h-6 w-6 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"
aria-label={t('rightSidebar.contextNotesTodo.todo.actions.delete', { text: todo.text })}
title={t('rightSidebar.contextNotesTodo.todo.actions.delete', { text: todo.text })}
>
<Icon name="delete-bin" className="h-3.5 w-3.5" />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={sendingTodoId === todo.id}
className="inline-flex h-6 w-6 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={t('rightSidebar.contextNotesTodo.todo.actions.send', { text: todo.text })}
title={t('rightSidebar.contextNotesTodo.todo.actions.send', { text: todo.text })}
>
<Icon name="send-plane" className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onClick={() => handleSendToCurrentSession(todo.text)}>
{t('rightSidebar.contextNotesTodo.todo.sendMenu.currentSession')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSendToNewSession(todo.id, todo.text)}>
{t('rightSidebar.contextNotesTodo.todo.sendMenu.newSession')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void handleSendToNewWorktreeSession(todo.id, todo.text)}
disabled={!canCreateWorktree}
>
{t('rightSidebar.contextNotesTodo.todo.sendMenu.newWorktreeSession')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)}
</SortableTodoItem>
);
})}
</ul>
</SortableContext>
</DndContext>
)}
</div>
{todos.length >= 7 && (
<div
className={cn(
'h-[3px] w-full cursor-row-resize hover:bg-[var(--interactive-border)]/80 transition-colors',
isTodoPanelResizing && 'bg-[var(--interactive-border)]'
)}
onPointerDown={handleTodoPanelResizeStart}
role="separator"
aria-orientation="horizontal"
aria-label={t('rightSidebar.contextNotesTodo.todo.resizeAria')}
/>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<h3 className="typography-ui-label font-semibold text-foreground">
{t('rightSidebar.contextNotesTodo.plans.title')}
</h3>
<span className="typography-meta text-muted-foreground">
{plans.length === 1
? t('rightSidebar.contextNotesTodo.plans.filesSingle', { count: plans.length })
: t('rightSidebar.contextNotesTodo.plans.filesPlural', { count: plans.length })}
</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={t('rightSidebar.contextNotesTodo.plans.importFromFile')}
title={t('rightSidebar.contextNotesTodo.plans.importFromFile')}
>
<Icon name="add" 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">
{plans.length === 0 ? (
<p className="px-3 py-3 typography-meta text-muted-foreground">
{t('rightSidebar.contextNotesTodo.plans.empty')}
</p>
) : (
<ul className="divide-y divide-border/50">
{plans.map((plan) => (
<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 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"
title={t('rightSidebar.contextNotesTodo.plans.deletePlan')}
aria-label={t('rightSidebar.contextNotesTodo.plans.deletePlanWithTitle', { title: plan.title })}
>
<Icon name="delete-bin" className="h-3.5 w-3.5" />
</button>
</li>
))}
</ul>
)}
</div>
</div>
<TodoSendDialog
open={pendingSendTarget !== null}
onOpenChange={(open) => {
if (!open && !isSendDialogSubmitting) {
setPendingSendTarget(null);
}
}}
target={pendingSendTarget?.kind ?? 'session'}
projectDirectory={projectRef?.path ?? null}
submitting={isSendDialogSubmitting}
onConfirm={handleConfirmSend}
/>
</div>
);
};