diff --git a/CHANGELOG.md b/CHANGELOG.md index c32c38b5..5e76cc30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- **Project knowledge:** the Project notes panel is now Project knowledge, with notes, todos, plans and their search in a resizable sidebar. Notes are cards you expand by clicking anywhere on them, plans open and edit in the panel itself instead of a separate tab, and notes and plans you pin travel with every message you send in that project until you unpin them. +- **Work status:** the Context sources section now names each pinned note and plan riding along with your messages, and its pin button unpins them from there. - **Settings:** OpenChamber no longer replaces a full OpenCode config with an empty `$schema`-only stub when the file uses JSON5-style unquoted keys; Settings changes now fail instead of wiping plugins, MCP servers, and providers (thanks to @makeittech). - **Settings/Integrations:** a new Integrations settings page lists Claude Code, Command Code, and Cursor plugins with install, update, setup, and remove actions, plus Discord and Telegram Coming soon placeholders. - **Chat:** an open conversation no longer keeps re-coloring the same code blocks in the background, so browsing files with a chat open stops pinning a CPU core and spinning up the fans (thanks to @makeittech). diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index ad4bc2f4..ff0ffe7c 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -14,6 +14,7 @@ import { useTraySync } from '@/hooks/useTraySync'; import { useRouter } from '@/hooks/useRouter'; import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon'; import { useWebNotificationStream } from '@/hooks/useWebNotificationStream'; +import { useAgentMemorySync } from '@/hooks/useAgentMemorySync'; import { usePwaInstallPrompt } from '@/hooks/usePwaInstallPrompt'; import { useWindowTitle } from '@/hooks/useWindowTitle'; import { useConfigStore } from '@/stores/useConfigStore'; @@ -703,6 +704,10 @@ function App({ apis }: AppProps) { usePushVisibilityBeacon({ enabled: embeddedBackgroundWorkEnabled }); useWebNotificationStream({ enabled: embeddedBackgroundWorkEnabled }); + // Loaded here rather than by the Memory tab: the session index is built from + // this snapshot, so leaving it to the panel meant a user who never opened + // Project notes sent every message with no memory index at all. + useAgentMemorySync(currentDirectory || null); usePwaInstallPrompt(); useWindowTitle(); diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index 42d419c8..b790270c 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -109,7 +109,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc const [workspaceTab, setWorkspaceTab] = React.useState('changes'); // A plan opened from the workspace drawer's Notes tab, shown as a fullscreen // layer on top of it (back returns to the notes). - const [openPlan, setOpenPlan] = React.useState<{ path: string; title: string } | null>(null); + const [openPlan, setOpenPlan] = React.useState<{ id: string; title: string } | null>(null); const [settingsInitialMobileStage, setSettingsInitialMobileStage] = React.useState<'nav' | 'page-content'>('nav'); // When set, the Changes surface opens directly into the per-file diff for this path. const [pendingChangesDiff, setPendingChangesDiff] = React.useState<{ path: string; staged: boolean } | null>(null); @@ -540,7 +540,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc > { closeSurface(); closeWorkspace(); diff --git a/packages/ui/src/apps/MobileWorkspaceDrawer.tsx b/packages/ui/src/apps/MobileWorkspaceDrawer.tsx index b3c94c24..f0847aa1 100644 --- a/packages/ui/src/apps/MobileWorkspaceDrawer.tsx +++ b/packages/ui/src/apps/MobileWorkspaceDrawer.tsx @@ -105,7 +105,7 @@ export const MobileWorkspaceDrawer: React.FC<{ /** When set, the Changes tab opens directly into the per-file diff. */ pendingChangesDiff: { path: string; staged: boolean } | null; /** Notes tab: opens a plan fullscreen (layered above the drawer). */ - onOpenPlan: (plan: { path: string; title: string }) => void; + onOpenPlan: (plan: { id: string; title: string }) => void; /** MCP tab: jump to the MCP settings page pre-seeded with a new server draft. */ onOpenMcpSettings: () => void; variant?: 'drawer' | 'panel'; diff --git a/packages/ui/src/apps/runtimeEndpointReset.ts b/packages/ui/src/apps/runtimeEndpointReset.ts index b430cfc9..39c325fd 100644 --- a/packages/ui/src/apps/runtimeEndpointReset.ts +++ b/packages/ui/src/apps/runtimeEndpointReset.ts @@ -3,6 +3,7 @@ import type { RuntimeEndpointChangedDetail } from '@/lib/runtime-switch'; import { disposeTerminalInputTransport } from '@/lib/terminalApi'; import { useConfigStore } from '@/stores/useConfigStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useProjectContextStore } from '@/stores/useProjectContextStore'; import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; import { useAutoReviewStore } from '@/stores/useAutoReviewStore'; import { useUIStore } from '@/stores/useUIStore'; @@ -52,6 +53,9 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD lastDisconnectReason: null, }); useProjectsStore.getState().resetForRuntimeSwitch(); + // Notes, todos, plans and the pinned-context bookkeeping are keyed by a + // path-derived project id, which two runtimes can collide on. + useProjectContextStore.getState().reset(); // Cross-project session list (mobile sessions sheet & co) belongs to the // previous instance — drop it so stale sessions can't linger after a switch. useGlobalSessionsStore.getState().resetForRuntimeSwitch(); diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index 44e53291..fa0bbfa3 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -41,7 +41,7 @@ import { ToolRevealOnMount } from './parts/ToolRevealOnMount'; import { StaticToolRow } from './parts/ProgressiveGroup'; import { isExpandableTool, isStandaloneTool } from './parts/toolRenderUtils'; import TurnActivity from '../components/TurnActivity'; -import { createProjectPlanFile } from '@/lib/openchamberConfig'; +import { useProjectContextStore } from '@/stores/useProjectContextStore'; import { resolveProjectForSessionDirectory } from '@/lib/projectResolution'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useI18n } from '@/lib/i18n'; @@ -1509,7 +1509,7 @@ const AssistantMessageBody = React.memo(({ setIsSavingPlan(true); try { - const created = await createProjectPlanFile(currentProjectRef, { + const created = await useProjectContextStore.getState().createPlan(currentProjectRef, { title, body: assistantPlanText, }); @@ -1517,9 +1517,6 @@ const AssistantMessageBody = React.memo(({ toast.error(t('chat.messageBody.toast.savePlanFailed')); return; } - window.dispatchEvent(new CustomEvent('openchamber:project-plan-saved', { - detail: { projectId: currentProjectRef.id }, - })); setIsPlanDialogOpen(false); toast.success(t('chat.messageBody.toast.planSaved')); } finally { diff --git a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx index c8ecc9f4..ab1947c1 100644 --- a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx +++ b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx @@ -9,7 +9,8 @@ import { cn } from '@/lib/utils'; import { copyTextToClipboard } from '@/lib/clipboard'; import { toast } from '@/components/ui'; import { Icon } from "@/components/icon/Icon"; -import { OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH, getProjectNotesAndTodos, saveProjectNotesAndTodos } from '@/lib/openchamberConfig'; +import { PROJECT_NOTE_BODY_MAX_LENGTH } from '@/lib/projectContextApi'; +import { useProjectContextStore } from '@/stores/useProjectContextStore'; import { summarizeSelectionForNotes } from '@/lib/smallModel'; import { resolveProjectForSessionDirectory } from '@/lib/projectResolution'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; @@ -34,15 +35,9 @@ interface SelectionPayload { rect: DOMRect; } -const appendDistilledInsightToNotes = (existingNotes: string, insight: string): string => { - const trimmedInsight = insight.trim().replace(/^[-*+]\s+/, '').slice(0, OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH); - if (!trimmedInsight) { - return existingNotes; - } - - const trimmedNotes = existingNotes.trimEnd(); - return trimmedNotes ? `${trimmedNotes}\n${trimmedInsight}` : trimmedInsight; -}; +const normalizeDistilledInsight = (insight: string): string => ( + insight.trim().replace(/^[-*+]\s+/, '').slice(0, PROJECT_NOTE_BODY_MAX_LENGTH) +); const DESKTOP_MENU_SIDE_MARGIN_PX = 8; const DESKTOP_MENU_FALLBACK_WIDTH_PX = 280; @@ -366,19 +361,22 @@ export const TextSelectionMenu: React.FC = ({ containerR // Long selections are distilled into a compact note by the small model; // short ones (and any generation failure) go in verbatim. const noteText = await summarizeSelectionForNotes(selectedTextMarkdown || selectedText, currentSessionId); - const projectData = await getProjectNotesAndTodos(currentProjectRef); - const nextNotes = appendDistilledInsightToNotes(projectData.notes, noteText); - const saved = await saveProjectNotesAndTodos(currentProjectRef, { - notes: nextNotes, - todos: projectData.todos, + const insight = normalizeDistilledInsight(noteText); + if (!insight) { + toast.error(t('chat.textSelection.toast.addToNotesFailed')); + return; + } + // Recorded as its own note with provenance, so the distilled insight can + // later be traced back to the conversation it came from. + const saved = await useProjectContextStore.getState().createNote(currentProjectRef, { + body: insight, + source: 'selection', + ...(currentSessionId ? { origin: { sessionId: currentSessionId } } : {}), }); if (!saved) { toast.error(t('chat.textSelection.toast.addToNotesFailed')); return; } - window.dispatchEvent(new CustomEvent('openchamber:project-notes-updated', { - detail: { projectId: currentProjectRef.id }, - })); toast.success(t('chat.textSelection.toast.addToNotesSuccess')); hideMenu(); window.getSelection()?.removeAllRanges(); diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index 9b627aef..29b5553b 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -1259,6 +1259,7 @@ const ToolExpandedContent: React.FC = React.memo(({ const hasVisualDiffEntry = diffEntries.some((entry) => entry.renderMode === 'diff'); const hideToolInputPreview = part.tool === 'openchamber' || part.tool === 'openchamber_web' + || part.tool === 'openchamber_memory' || part.tool === 'apply_patch' || part.tool === 'edit' || part.tool === 'multiedit'; diff --git a/packages/ui/src/components/chat/message/parts/toolPresentation.tsx b/packages/ui/src/components/chat/message/parts/toolPresentation.tsx index 9219fde1..f4b627ba 100644 --- a/packages/ui/src/components/chat/message/parts/toolPresentation.tsx +++ b/packages/ui/src/components/chat/message/parts/toolPresentation.tsx @@ -59,6 +59,9 @@ export const getToolIcon = (toolName: string) => { if (tool === 'openchamber_web') { return ; } + if (tool === 'openchamber_memory') { + return ; + } if (tool === 'question') { return ; } diff --git a/packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx b/packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx index 254b974a..73671442 100644 --- a/packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx +++ b/packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx @@ -5,6 +5,12 @@ import { useSkillsStore } from '@/stores/useSkillsStore'; import { useMcpStore } from '@/stores/useMcpStore'; import { useSession } from '@/sync/sync-context'; import { getLinkedIssues } from '@/lib/linkedIssues'; +import { fetchSessionKnowledgeSummary, type SessionKnowledgeSummary } from '@/lib/sessionKnowledgeApi'; +import { resolveProjectForSessionDirectory } from '@/lib/projectResolution'; +import { useProjectContextStore } from '@/stores/useProjectContextStore'; +import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; import { WorkStatusCollapsibleSection, WorkStatusRow, WorkStatusValue } from './WorkStatusPrimitives'; import { useReportWorkStatusPresence } from './presenceContext'; @@ -43,6 +49,54 @@ export const WorkStatusContextSection: React.FC = ({ sessionId, directory void loadSkills(); }, [directory, loadSkills]); + /** + * What the project sends along with every message. Read from the server + * rather than from the notes panel's store, because this must be right + * whether or not that panel has ever been opened. + */ + const [knowledge, setKnowledge] = React.useState( + { notes: [], plans: [], memory: { global: 0, project: 0 } }, + ); + + // Re-read whenever the stores that own pins or memory change, not only when + // the directory does. Unpinning is a write those stores make, and a panel + // that keeps listing what was just unpinned tells the user it is still going + // to the agent when it is not. + const contextEntries = useProjectContextStore((state) => state.entries); + const memoryProject = useAgentMemoryStore((state) => state.project); + const memoryGlobal = useAgentMemoryStore((state) => state.global); + + React.useEffect(() => { + let cancelled = false; + void fetchSessionKnowledgeSummary(directory).then((summary) => { + if (!cancelled) setKnowledge(summary); + }); + return () => { cancelled = true; }; + }, [directory, contextEntries, memoryProject, memoryGlobal]); + + const projects = useProjectsStore((state) => state.projects); + const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); + const setNotePinned = useProjectContextStore((state) => state.setNotePinned); + const setPlanPinned = useProjectContextStore((state) => state.setPlanPinned); + + const projectRef = React.useMemo(() => { + const resolved = resolveProjectForSessionDirectory(projects, availableWorktreesByProject, directory ?? ''); + return resolved ? { id: resolved.id, path: resolved.path } : null; + }, [availableWorktreesByProject, directory, projects]); + + // Unpinning from here, like the pinned-messages section: a panel that says + // what is attached should be able to detach it, or the user has to go find + // the surface that can. + const unpinNote = React.useCallback((noteId: string) => { + if (projectRef) void setNotePinned(projectRef, noteId, false); + }, [projectRef, setNotePinned]); + const unpinPlan = React.useCallback((planId: string) => { + if (projectRef) void setPlanPinned(projectRef, planId, false); + }, [projectRef, setPlanPinned]); + + const memoryCount = knowledge.memory.global + knowledge.memory.project; + const pinnedCount = knowledge.notes.length + knowledge.plans.length; + const linked = React.useMemo(() => getLinkedIssues(session), [session]); // Connected servers only. A disabled server contributes nothing to the // context, so counting it here contradicts the MCP section right above, @@ -52,9 +106,14 @@ export const WorkStatusContextSection: React.FC = ({ sessionId, directory [mcpStatus], ); - useReportWorkStatusPresence('context-sources', linked.length > 0 || skills.length > 0 || mcpCount > 0); + useReportWorkStatusPresence( + 'context-sources', + linked.length > 0 || skills.length > 0 || mcpCount > 0 || pinnedCount > 0 || memoryCount > 0, + ); - if (linked.length === 0 && skills.length === 0 && mcpCount === 0) return null; + if (linked.length === 0 && skills.length === 0 && mcpCount === 0 && pinnedCount === 0 && memoryCount === 0) { + return null; + } // The heading names what is distinctive about this session when there is // something — an attached thread — and falls back to the ambient counts @@ -72,6 +131,14 @@ export const WorkStatusContextSection: React.FC = ({ sessionId, directory ? t('chat.workStatus.breakdown.prCountSingle', { count: prCount }) : t('chat.workStatus.breakdown.prCountPlural', { count: prCount })); } + // Pinned knowledge outranks the ambient counts in the summary: it is + // something the user chose for this project, not something that happens to + // be installed. + if (summaryParts.length === 0 && pinnedCount > 0) { + summaryParts.push(pinnedCount === 1 + ? t('chat.workStatus.breakdown.pinnedKnowledgeSingle', { count: pinnedCount }) + : t('chat.workStatus.breakdown.pinnedKnowledgePlural', { count: pinnedCount })); + } if (summaryParts.length === 0) { if (skills.length > 0) { summaryParts.push(skills.length === 1 @@ -115,6 +182,63 @@ export const WorkStatusContextSection: React.FC = ({ sessionId, directory /> ))} + {/* Named individually: a count alone would not tell the user which note + is riding along with every message they send. */} + {/* The pin is the control, exactly as in the pinned-messages section + above: same icon, same placement, same behaviour. Two pins that look + different in one panel would read as two different things. */} + {knowledge.notes.map((note) => ( + { + event.stopPropagation(); + unpinNote(note.id); + }} + className="shrink-0 rounded p-0.5 transition-opacity hover:opacity-70 disabled:opacity-40" + > + + + )} + label={note.body.trim().split('\n')[0] || note.body.trim()} + value={{t('chat.workStatus.breakdown.pinnedNote')}} + /> + ))} + {knowledge.plans.map((plan) => ( + { + event.stopPropagation(); + unpinPlan(plan.id); + }} + className="shrink-0 rounded p-0.5 transition-opacity hover:opacity-70 disabled:opacity-40" + > + + + )} + label={plan.title} + value={{t('chat.workStatus.breakdown.pinnedPlan')}} + /> + ))} + {memoryCount > 0 ? ( + {memoryCount}} + /> + ) : null} + `, "bar-chart-box": ``, "book": ``, + "book-marked": ``, "book-open": ``, "booklet": ``, "braces": ``, "brain": ``, + "brain-4": ``, "brain-ai-3": ``, "briefcase": ``, "bug": ``, diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index efdb91eb..8c24e134 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -940,7 +940,7 @@ export const ContextPanel: React.FC = () => { : activeTab?.mode === 'notes' ? : activeTab?.mode === 'plan' - ? + ? : null; const browserTabs = React.useMemo( diff --git a/packages/ui/src/components/layout/RightSidebarTabs.tsx b/packages/ui/src/components/layout/RightSidebarTabs.tsx index dbfb46f4..cd8b2f1e 100644 --- a/packages/ui/src/components/layout/RightSidebarTabs.tsx +++ b/packages/ui/src/components/layout/RightSidebarTabs.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { ProjectNotesTodoPanel } from '@/components/session/ProjectNotesTodoPanel'; +import { ProjectNotesTodoPanel } from '@/components/session/project-context/ProjectNotesTodoPanel'; import { useGitStore } from '@/stores/useGitStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; @@ -8,7 +8,7 @@ import { formatDirectoryName } from '@/lib/utils'; export const ProjectContextPanel: React.FC<{ onActionComplete?: () => void; - onOpenPlan?: (plan: { path: string; title: string }) => void; + onOpenPlan?: (plan: { id: string; title: string }) => void; }> = ({ onActionComplete, onOpenPlan }) => { const activeProjectId = useProjectsStore((state) => state.activeProjectId); const projects = useProjectsStore((state) => state.projects); @@ -49,7 +49,8 @@ export const ProjectContextPanel: React.FC<{ }, [activeProject, gitDirectories]); return ( -
+ /* The panel scrolls its own tab content; a scroller here would nest. */ +
{ const setAgentControlToolEnabled = useUIStore((state) => state.setAgentControlToolEnabled); const agentWebToolEnabled = useUIStore((state) => state.agentWebToolEnabled); const setAgentWebToolEnabled = useUIStore((state) => state.setAgentWebToolEnabled); + const agentMemoryToolEnabled = useUIStore((state) => state.agentMemoryToolEnabled); + // Absent, not merely off: the feature is finished but unreleased, and a + // visible switch invites turning on something that was never announced. + const agentMemoryAvailable = useUIStore((state) => state.agentMemoryFeatureAvailable); + const setAgentMemoryToolEnabled = useUIStore((state) => state.setAgentMemoryToolEnabled); const handleAgentControlToolChange = React.useCallback((enabled: boolean) => { setAgentControlToolEnabled(enabled); @@ -40,6 +46,24 @@ export const OpenChamberToolsSettings: React.FC = () => { recordDeferredOpenCodeRestart('cli', { id: 'agent-web-tool' }); }, [setAgentWebToolEnabled]); + // Turning memory off removes the whole feature, not just the tool: the panel + // tab goes with it and sessions stop being given the index. Showing the user + // what is stored would be pointless once the agent can no longer manage it. + const handleAgentMemoryToolChange = React.useCallback((enabled: boolean) => { + setAgentMemoryToolEnabled(enabled); + // Re-read after the write lands, not before. The switch flips the client + // immediately, which makes the panel ask the server straight away — and + // while the setting is still being written the server truthfully answers + // "disabled", which used to leave the tab hidden until a restart. + void updateDesktopSettings({ agentMemoryToolEnabled: enabled }) + .finally(() => { + if (enabled) { + void useAgentMemoryStore.getState().refresh(); + } + }); + recordDeferredOpenCodeRestart('cli', { id: 'agent-memory-tool' }); + }, [setAgentMemoryToolEnabled]); + return (
@@ -60,6 +84,17 @@ export const OpenChamberToolsSettings: React.FC = () => { ariaLabel={t('settings.openchamber.tools.field.agentWebToolAria')} info={t('settings.openchamber.tools.field.agentWebToolInfo')} /> + + {agentMemoryAvailable ? ( + + ) : null}
); diff --git a/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx b/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx deleted file mode 100644 index 5f912444..00000000 --- a/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx +++ /dev/null @@ -1,1056 +0,0 @@ -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 { getCurrentIntlLocale, 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; - -// Per-project chain of in-flight saveProjectNotesAndTodos calls. Subsequent -// saves await the previous one so a fast todo toggle or blur that lands -// while the debounced notes save is still on the wire is appended, not -// racing against it. The chain is module-scoped so it survives remounts -// (e.g. when the user switches the right sidebar tab away and back). -const projectSaveChainByProject = new Map>(); - -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; - /** When provided, opening a plan calls this instead of the desktop context - panel tab — hosts without ContextPanel (mobile) render their own viewer. */ - onOpenPlan?: (plan: { path: string; title: string }) => 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 => { - 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['attributes']; - listeners: ReturnType['listeners']; - setActivatorNodeRef: ReturnType['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 ( -
  • - {children({ attributes, listeners, setActivatorNodeRef, isDragging })} -
  • - ); -}; - -export const ProjectNotesTodoPanel: React.FC = ({ - projectRef, - projectLabel, - canCreateWorktree = false, - onActionComplete, - onOpenPlan, - className, -}) => { - const { t } = useI18n(); - const [isLoading, setIsLoading] = React.useState(false); - const [notes, setNotes] = React.useState(''); - const [todos, setTodos] = React.useState([]); - const [newTodoText, setNewTodoText] = React.useState(''); - const [sendingTodoId, setSendingTodoId] = React.useState(null); - const [expandedTodoIds, setExpandedTodoIds] = React.useState>(() => new Set()); - const [plans, setPlans] = React.useState([]); - const [pendingSendTarget, setPendingSendTarget] = React.useState(null); - const [isSendDialogSubmitting, setIsSendDialogSubmitting] = React.useState(false); - const [contextReloadTick, setContextReloadTick] = React.useState(0); - const notesHydratedRef = React.useRef(false); - const lastSavedNotesRef = React.useRef(''); - const notesDebounceTimerRef = React.useRef(null); - 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 key = projectRef.id; - // Serialize concurrent saves per project: a fast toggle/strike while the - // debounce-driven notes save is in flight no longer races the network. - const previous = projectSaveChainByProject.get(key) ?? Promise.resolve(); - const next = previous.catch(() => undefined).then(() => - saveProjectNotesAndTodos(projectRef, { - notes: nextNotes, - todos: nextTodos, - }) - ); - projectSaveChainByProject.set(key, next); - try { - const saved = await next; - if (!saved) { - toast.error(t('rightSidebar.contextNotesTodo.toast.saveNotesFailed')); - } - return saved; - } finally { - if (projectSaveChainByProject.get(key) === next) { - projectSaveChainByProject.delete(key); - } - } - }, - [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 !== targetHeight - && (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 cancelNotesDebounce = React.useCallback(() => { - if (notesDebounceTimerRef.current !== null) { - window.clearTimeout(notesDebounceTimerRef.current); - notesDebounceTimerRef.current = null; - } - }, []); - - const handleNotesBlur = React.useCallback(() => { - cancelNotesDebounce(); - lastSavedNotesRef.current = notes; - void persistProjectData(notes, todos); - }, [cancelNotesDebounce, notes, persistProjectData, todos]); - - React.useEffect(() => { - if (!projectRef || !notesHydratedRef.current) { - return; - } - - if (notes === lastSavedNotesRef.current) { - return; - } - - notesDebounceTimerRef.current = window.setTimeout(() => { - notesDebounceTimerRef.current = null; - lastSavedNotesRef.current = notes; - void persistProjectData(notes, todos); - }, 400); - - return () => { - cancelNotesDebounce(); - }; - }, [cancelNotesDebounce, notes, persistProjectData, projectRef, todos]); - - React.useEffect(() => () => cancelNotesDebounce(), [cancelNotesDebounce]); - - 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(null); - const [isImportingPlan, setIsImportingPlan] = React.useState(false); - const [deletingPlanId, setDeletingPlanId] = React.useState(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', - }); - if (result.outsideFileGrant) { - params.set('outsideFileGrant', result.outsideFileGrant); - } - 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) => { - if (onOpenPlan) { - onOpenPlan({ path: plan.path, title: plan.title }); - return; - } - 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, onOpenPlan, openContextPanelTab, projectRef] - ); - - if (!projectRef) { - return ( -
    -

    - {t('rightSidebar.contextNotesTodo.empty.selectProject')} -

    -
    - ); - } - - return ( -
    -
    -
    -

    - {t('rightSidebar.contextNotesTodo.notes.title', { - project: projectLabel?.trim() || projectRef.path.split('/').filter(Boolean).pop() || projectRef.path, - })} -

    - {notes.length}/{OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH} -
    -