feat: add project notes and todos panel with persistence (#393)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user