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.
1010 lines
40 KiB
TypeScript
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>
|
|
);
|
|
};
|