feat: add project notes and todos panel with persistence (#393)

This commit is contained in:
gsxdsm
2026-02-12 09:53:37 +02:00
committed by GitHub
parent 628237d5f5
commit d28d548bbc
3 changed files with 549 additions and 6 deletions
@@ -0,0 +1,360 @@
import React from 'react';
import { RiAddLine, RiDeleteBinLine, RiSendPlaneLine } from '@remixicon/react';
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 {
getProjectNotesAndTodos,
OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH,
OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH,
saveProjectNotesAndTodos,
type OpenChamberProjectTodoItem,
type ProjectRef,
} from '@/lib/openchamberConfig';
import { useUIStore } from '@/stores/useUIStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { createWorktreeOnly } from '@/lib/worktreeSessionCreator';
import { cn } from '@/lib/utils';
interface ProjectNotesTodoPanelProps {
projectRef: ProjectRef | null;
canCreateWorktree?: boolean;
onActionComplete?: () => void;
className?: string;
}
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)}`;
};
export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
projectRef,
canCreateWorktree = false,
onActionComplete,
className,
}) => {
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 currentSessionId = useSessionStore((state) => state.currentSessionId);
const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft);
const setPendingInputText = useSessionStore((state) => state.setPendingInputText);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
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('Failed to save project notes');
}
return saved;
},
[projectRef]
);
React.useEffect(() => {
if (!projectRef) {
setNotes('');
setTodos([]);
setNewTodoText('');
return;
}
let cancelled = false;
setIsLoading(true);
(async () => {
try {
const data = await getProjectNotesAndTodos(projectRef);
if (cancelled) {
return;
}
setNotes(data.notes);
setTodos(data.todos);
setNewTodoText('');
} catch {
if (!cancelled) {
toast.error('Failed to load project notes');
setNotes('');
setTodos([]);
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [projectRef]);
const handleNotesBlur = React.useCallback(() => {
void persistProjectData(notes, todos);
}, [notes, persistProjectData, todos]);
const handleAddTodo = React.useCallback(() => {
const trimmed = newTodoText.trim();
if (!trimmed) {
return;
}
const nextTodos = [
...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 handleToggleTodo = React.useCallback(
(id: string, completed: boolean) => {
const nextTodos = todos.map((todo) => (todo.id === id ? { ...todo, completed } : todo));
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 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(
(todoText: string) => {
if (!projectRef) {
return;
}
routeToChat();
openNewSessionDraft({
directoryOverride: projectRef.path,
initialPrompt: todoText,
});
toast.success('Todo sent to new session');
onActionComplete?.();
},
[onActionComplete, openNewSessionDraft, projectRef, routeToChat]
);
const handleSendToCurrentSession = React.useCallback(
(todoText: string) => {
if (!currentSessionId) {
toast.error('No active session selected');
return;
}
routeToChat();
setPendingInputText(todoText, 'append');
toast.success('Todo sent to current session');
onActionComplete?.();
},
[currentSessionId, onActionComplete, routeToChat, setPendingInputText]
);
const handleSendToNewWorktreeSession = React.useCallback(
async (todoId: string, todoText: string) => {
if (!projectRef) {
return;
}
if (!canCreateWorktree) {
toast.error('Worktree actions are only available for Git repositories');
return;
}
setSendingTodoId(todoId);
try {
const newWorktreePath = await createWorktreeOnly();
if (!newWorktreePath) {
return;
}
routeToChat();
openNewSessionDraft({
directoryOverride: newWorktreePath,
initialPrompt: todoText,
});
toast.success('Todo sent to new worktree session');
onActionComplete?.();
} finally {
setSendingTodoId(null);
}
},
[canCreateWorktree, onActionComplete, openNewSessionDraft, projectRef, routeToChat]
);
if (!projectRef) {
return (
<div className={cn('w-full min-w-0 p-3', className)}>
<p className="typography-meta text-muted-foreground">Select a project to add notes and todos.</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="typography-ui-label font-semibold text-foreground">Quick notes</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="Capture context, reminders, or links"
className="min-h-24 resize-none"
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<h3 className="typography-ui-label font-semibold text-foreground">Todo</h3>
<div className="flex items-center gap-2">
<span className="typography-meta text-muted-foreground">{todos.length} item{todos.length === 1 ? '' : 's'}</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"
>
Clear completed
</button>
</div>
</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="Add a todo"
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="Add todo"
>
<RiAddLine className="h-4 w-4" />
</button>
</div>
<div className="max-h-56 overflow-y-auto rounded-lg border border-border/60 bg-background/40">
{todos.length === 0 ? (
<p className="px-3 py-3 typography-meta text-muted-foreground">No todos yet. Add a small checklist for this project.</p>
) : (
<ul className="divide-y divide-border/50">
{todos.map((todo) => (
<li key={todo.id} className="flex items-center gap-1.5 px-2.5 py-1.5">
<Checkbox
checked={todo.completed}
onChange={(checked) => handleToggleTodo(todo.id, checked)}
ariaLabel={`Mark "${todo.text}" complete`}
/>
<span
className={cn(
'min-w-0 flex-1 typography-ui-label text-foreground',
todo.completed && 'text-muted-foreground line-through'
)}
title={todo.text}
>
{todo.text}
</span>
<button
type="button"
onClick={() => handleDeleteTodo(todo.id)}
className="inline-flex h-7 w-7 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={`Delete "${todo.text}"`}
>
<RiDeleteBinLine className="h-3.5 w-3.5" />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={sendingTodoId === todo.id}
className="inline-flex h-7 w-7 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={`Send "${todo.text}"`}
>
<RiSendPlaneLine className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onClick={() => handleSendToCurrentSession(todo.text)}>
Send to current session
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSendToNewSession(todo.text)}>
Send to new session
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void handleSendToNewWorktreeSession(todo.id, todo.text)}
disabled={!canCreateWorktree}
>
Send to new worktree session
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</li>
))}
</ul>
)}
</div>
</div>
</div>
);
};
@@ -30,6 +30,7 @@ import {
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import { GridLoader } from '@/components/ui/grid-loader';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import {
RiAddLine,
RiArrowDownSLine,
@@ -42,6 +43,7 @@ import {
RiFolderAddLine,
RiGitBranchLine,
RiGitPullRequestLine,
RiStickyNoteLine,
RiLinkUnlinkM,
RiGithubLine,
@@ -66,10 +68,12 @@ import { getSafeStorage } from '@/stores/utils/safeStorage';
import { createWorktreeOnly, createWorktreeSession } from '@/lib/worktreeSessionCreator';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
import { useGitStore } from '@/stores/useGitStore';
import { useDeviceInfo } from '@/lib/device';
import { isVSCodeRuntime } from '@/lib/desktop';
import { updateDesktopSettings } from '@/lib/persistence';
import { GitHubIssuePickerDialog } from './GitHubIssuePickerDialog';
import { GitHubPullRequestPickerDialog } from './GitHubPullRequestPickerDialog';
import { ProjectNotesTodoPanel } from './ProjectNotesTodoPanel';
const ATTENTION_DIAMOND_INDICES = new Set([1, 3, 4, 5, 7]);
@@ -550,6 +554,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const [hoveredProjectId, setHoveredProjectId] = React.useState<string | null>(null);
const [issuePickerOpen, setIssuePickerOpen] = React.useState(false);
const [pullRequestPickerOpen, setPullRequestPickerOpen] = React.useState(false);
const [projectNotesPanelOpen, setProjectNotesPanelOpen] = React.useState(false);
const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState<Set<string>>(new Set());
const [openMenuSessionId, setOpenMenuSessionId] = React.useState<string | null>(null);
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(() => {
@@ -623,6 +628,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const renameProject = useProjectsStore((state) => state.renameProject);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const deviceInfo = useDeviceInfo();
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
const openMultiRunLauncher = useUIStore((state) => state.openMultiRunLauncher);
const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree);
@@ -1414,16 +1420,28 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
() => normalizedProjects.find((project) => project.id === activeProjectId) ?? normalizedProjects[0] ?? null,
[normalizedProjects, activeProjectId],
);
const activeProjectRefForHeader = React.useMemo(
() => (activeProjectForHeader
? {
id: activeProjectForHeader.id,
path: activeProjectForHeader.normalizedPath,
}
: null),
[activeProjectForHeader],
);
const activeProjectIsRepo = React.useMemo(
() => (activeProjectForHeader ? Boolean(projectRepoStatus.get(activeProjectForHeader.id)) : false),
[activeProjectForHeader, projectRepoStatus],
);
const activeProjectRepoState = React.useMemo(
() => (activeProjectForHeader ? projectRepoStatus.get(activeProjectForHeader.id) : undefined),
[activeProjectForHeader, projectRepoStatus],
);
const reserveHeaderActionsSpace = activeProjectRepoState !== false;
const reserveHeaderActionsSpace = Boolean(activeProjectForHeader);
const useMobileNotesPanel = mobileVariant || deviceInfo.isMobile;
React.useEffect(() => {
if (!activeProjectForHeader) {
setProjectNotesPanelOpen(false);
}
}, [activeProjectForHeader]);
const projectSessionMeta = React.useMemo(() => {
const metaByProject = new Map<string, Map<string, { directory: string | null }>>();
@@ -2387,8 +2405,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
</div>
{reserveHeaderActionsSpace ? (
<div className="mt-1 h-8 pl-1">
{activeProjectIsRepo ? (
{activeProjectForHeader ? (
<div className="inline-flex h-8 items-center gap-1.5 rounded-md pl-0 pr-1">
{activeProjectIsRepo ? (
<>
<Tooltip>
<TooltipTrigger asChild>
<button
@@ -2457,6 +2477,47 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>New multi-run</p></TooltipContent>
</Tooltip>
</>
) : null}
{useMobileNotesPanel ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setProjectNotesPanelOpen(true)}
className={headerActionButtonClass}
aria-label="Project notes and todos"
>
<RiStickyNoteLine className="h-4.5 w-4.5" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>Project notes</p></TooltipContent>
</Tooltip>
) : (
<DropdownMenu open={projectNotesPanelOpen} onOpenChange={setProjectNotesPanelOpen} modal={false}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
className={headerActionButtonClass}
aria-label="Project notes and todos"
>
<RiStickyNoteLine className="h-4.5 w-4.5" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>Project notes</p></TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" className="w-[340px] p-0">
<ProjectNotesTodoPanel
projectRef={activeProjectRefForHeader}
canCreateWorktree={activeProjectIsRepo}
onActionComplete={() => setProjectNotesPanelOpen(false)}
/>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
) : null}
</div>
@@ -2679,6 +2740,21 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}
}}
/>
{useMobileNotesPanel ? (
<MobileOverlayPanel
open={projectNotesPanelOpen}
onClose={() => setProjectNotesPanelOpen(false)}
title="Project notes"
>
<ProjectNotesTodoPanel
projectRef={activeProjectRefForHeader}
canCreateWorktree={activeProjectIsRepo}
onActionComplete={() => setProjectNotesPanelOpen(false)}
className="p-0"
/>
</MobileOverlayPanel>
) : null}
</div>
);
};
+107
View File
@@ -57,8 +57,25 @@ function getRuntimeFilesAPI(): FilesAPI | null {
export interface OpenChamberConfig {
'setup-worktree'?: string[];
projectNotes?: string;
projectTodos?: OpenChamberProjectTodoItem[];
}
export interface OpenChamberProjectTodoItem {
id: string;
text: string;
completed: boolean;
createdAt: number;
}
export interface OpenChamberProjectNotesTodos {
notes: string;
todos: OpenChamberProjectTodoItem[];
}
export const OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH = 1000;
export const OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH = 120;
const normalize = (value: string): string => {
if (!value) return '';
const replaced = value.replace(/\\/g, '/');
@@ -270,6 +287,73 @@ const getUserConfigPath = async (project: ProjectRef): Promise<string | null> =>
return joinPath(base, `${safeId}.json`);
};
const trimToMaxLength = (value: string, maxLength: number): string => {
if (value.length <= maxLength) {
return value;
}
return value.slice(0, maxLength);
};
const sanitizeProjectNotes = (value: unknown): string => {
if (typeof value !== 'string') {
return '';
}
return trimToMaxLength(value, OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH);
};
const sanitizeProjectTodoItems = (value: unknown): OpenChamberProjectTodoItem[] => {
if (!Array.isArray(value)) {
return [];
}
const sanitized: OpenChamberProjectTodoItem[] = [];
for (const entry of value) {
if (!entry || typeof entry !== 'object') {
continue;
}
const record = entry as {
id?: unknown;
text?: unknown;
completed?: unknown;
createdAt?: unknown;
};
const id = typeof record.id === 'string' ? record.id.trim() : '';
const textRaw = typeof record.text === 'string' ? record.text : '';
const text = trimToMaxLength(textRaw.trim(), OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH);
if (!id || !text) {
continue;
}
const completed = Boolean(record.completed);
const createdAt =
typeof record.createdAt === 'number' && Number.isFinite(record.createdAt) && record.createdAt >= 0
? record.createdAt
: Date.now();
sanitized.push({
id,
text,
completed,
createdAt,
});
}
return sanitized;
};
const sanitizeProjectNotesAndTodos = (value: {
notes?: unknown;
todos?: unknown;
} | null | undefined): OpenChamberProjectNotesTodos => {
return {
notes: sanitizeProjectNotes(value?.notes),
todos: sanitizeProjectTodoItems(value?.todos),
};
};
/**
* Read the config for a project.
* Returns null if file doesn't exist or is invalid.
@@ -397,6 +481,29 @@ export async function saveWorktreeSetupCommands(project: ProjectRef, commands: s
return updateOpenChamberConfig(project, { 'setup-worktree': filtered });
}
export async function getProjectNotesAndTodos(project: ProjectRef): Promise<OpenChamberProjectNotesTodos> {
const config = await readOpenChamberConfig(project);
return sanitizeProjectNotesAndTodos({
notes: config?.projectNotes,
todos: config?.projectTodos,
});
}
export async function saveProjectNotesAndTodos(
project: ProjectRef,
value: OpenChamberProjectNotesTodos
): Promise<boolean> {
const sanitized = sanitizeProjectNotesAndTodos({
notes: value.notes,
todos: value.todos,
});
return updateOpenChamberConfig(project, {
projectNotes: sanitized.notes,
projectTodos: sanitized.todos,
});
}
/**
* Substitute variables in a command string.
* Supported variables: