From 34e8a24b202769fd3a40510ddbd8d61301e96433 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 18 Aug 2026 02:59:04 +0300 Subject: [PATCH] feat(knowledge): rebuild the project notes panel as Project knowledge (#2973) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel stored notes, todos and plans inside one shared JSON file that six unrelated domains also wrote to, synchronised itself through window CustomEvents, and could only read plans. It is now Project knowledge: server-owned storage with explicit routes, a store with rollback, a section sidebar, plans that open and edit in place, and search across all of it. Notes and plans the user pins travel with every message sent in that project. Pinning is project state, not an attachment to one message, so it holds until unpinned and the work status panel names what is riding along and can detach it. Agent memory is added alongside, in two scopes: what is true about the user, and what is true about this codebase. The split is not cosmetic — a wrong project fact costs one project and is noticed, while a wrong global fact quietly shapes every session everywhere and the user has no code to check it against. It stays separate from notes so an agent mistake cannot land in what the user wrote. Sessions receive an index of titles only; bodies are read on demand, because an index carrying full text grows until it crowds out the conversation. Deciding what a session must be told, and whether it has been told, now lives on the server. The client owned it before, which meant sessions started without a UI — scheduled tasks, sessions the agent dispatches — received nothing at all, and a tab's record of what it had sent outlived the conversation: after compaction the agent no longer held the block while the tab went on believing it did. What was delivered is recorded in the session's own metadata, and compaction restores it through the runtime that already restores pinned messages, in the same turn. Agent memory ships dark behind OPENCHAMBER_MEMORY_ENABLE: unset, there is no tool, no routes, no session index, no settings row and no panel tab. Absent rather than switched off, so nothing invites turning on a feature that has not been announced. Pinned notes and plans are unaffected and ship as normal. --- CHANGELOG.md | 2 + packages/ui/src/App.tsx | 5 + packages/ui/src/apps/MobileApp.tsx | 4 +- .../ui/src/apps/MobileWorkspaceDrawer.tsx | 2 +- packages/ui/src/apps/runtimeEndpointReset.ts | 4 + .../components/chat/message/MessageBody.tsx | 7 +- .../chat/message/TextSelectionMenu.tsx | 34 +- .../chat/message/parts/ToolPart.tsx | 1 + .../chat/message/parts/toolPresentation.tsx | 3 + .../work-status/WorkStatusContextSection.tsx | 128 +- packages/ui/src/components/icon/sprite.ts | 2 + .../ui/src/components/layout/ContextPanel.tsx | 2 +- .../components/layout/RightSidebarTabs.tsx | 7 +- .../openchamber/OpenChamberToolsSettings.tsx | 35 + .../session/ProjectNotesTodoPanel.tsx | 1056 ----------------- .../session/project-context/DOCUMENTATION.md | 224 ++++ .../session/project-context/KnowledgeCard.tsx | 82 ++ .../session/project-context/MemorySection.tsx | 277 +++++ .../session/project-context/NotesSection.tsx | 296 +++++ .../session/project-context/PlansSection.tsx | 255 ++++ .../project-context/ProjectNotesTodoPanel.tsx | 490 ++++++++ .../session/project-context/TodosSection.tsx | 348 ++++++ .../project-context/useProjectTodoSend.ts | 203 ++++ packages/ui/src/components/ui/textarea.tsx | 35 +- packages/ui/src/components/views/PlanView.tsx | 70 +- packages/ui/src/hooks/useAgentMemorySync.ts | 65 + packages/ui/src/lib/agentMemoryApi.ts | 199 ++++ packages/ui/src/lib/agentMemoryBadges.test.ts | 74 ++ packages/ui/src/lib/agentMemoryBadges.ts | 44 + packages/ui/src/lib/desktop.ts | 2 + .../ui/src/lib/i18n/messages/de.settings.ts | 3 + packages/ui/src/lib/i18n/messages/de.ts | 59 +- .../ui/src/lib/i18n/messages/en.settings.ts | 3 + packages/ui/src/lib/i18n/messages/en.ts | 59 +- .../ui/src/lib/i18n/messages/es.settings.ts | 3 + packages/ui/src/lib/i18n/messages/es.ts | 59 +- .../ui/src/lib/i18n/messages/fr.settings.ts | 3 + packages/ui/src/lib/i18n/messages/fr.ts | 59 +- .../ui/src/lib/i18n/messages/ja.settings.ts | 3 + packages/ui/src/lib/i18n/messages/ja.ts | 59 +- .../ui/src/lib/i18n/messages/ko.settings.ts | 3 + packages/ui/src/lib/i18n/messages/ko.ts | 59 +- .../ui/src/lib/i18n/messages/pl.settings.ts | 3 + packages/ui/src/lib/i18n/messages/pl.ts | 59 +- .../src/lib/i18n/messages/pt-BR.settings.ts | 3 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 59 +- .../ui/src/lib/i18n/messages/uk.settings.ts | 3 + packages/ui/src/lib/i18n/messages/uk.ts | 59 +- .../src/lib/i18n/messages/zh-CN.settings.ts | 3 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 59 +- .../src/lib/i18n/messages/zh-TW.settings.ts | 3 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 59 +- packages/ui/src/lib/openchamberConfig.ts | 384 +----- packages/ui/src/lib/openchamberEvents.ts | 33 +- packages/ui/src/lib/persistence.ts | 17 + packages/ui/src/lib/projectContextApi.ts | 339 ++++++ packages/ui/src/lib/sessionKnowledgeApi.ts | 114 ++ packages/ui/src/lib/settings/search.ts | 11 + packages/ui/src/lib/surfaces/registry.ts | 7 +- packages/ui/src/lib/toolHelpers.ts | 7 + packages/ui/src/stores/DOCUMENTATION.md | 3 + .../ui/src/stores/useAgentMemoryStore.test.ts | 213 ++++ packages/ui/src/stores/useAgentMemoryStore.ts | 159 +++ .../src/stores/useProjectContextStore.test.ts | 458 +++++++ .../ui/src/stores/useProjectContextStore.ts | 450 +++++++ packages/ui/src/stores/useUIStore.ts | 71 +- packages/ui/src/sync/session-ui-store.ts | 56 +- packages/web/server/index.js | 117 +- .../web/server/lib/agent-memory/actions.js | 243 ++++ .../server/lib/agent-memory/actions.test.js | 343 ++++++ .../server/lib/agent-memory/feature-flag.js | 19 + .../lib/agent-memory/feature-flag.test.js | 38 + .../lib/agent-memory/project-resolution.js | 56 + .../agent-memory/project-resolution.test.js | 85 ++ .../lib/agent-memory/routes.http.test.js | 270 +++++ .../web/server/lib/agent-memory/routes.js | 169 +++ .../web/server/lib/agent-memory/runtime.js | 427 +++++++ .../server/lib/agent-memory/runtime.test.js | 344 ++++++ .../lib/agent-memory/threat-patterns.js | 61 + .../lib/agent-memory/threat-patterns.test.js | 61 + .../server/lib/agent-tool/DOCUMENTATION.md | 14 + packages/web/server/lib/agent-tool/runtime.js | 76 +- .../web/server/lib/agent-tool/runtime.test.js | 87 +- .../server/lib/context-obligatory/runtime.js | 31 +- .../lib/context-obligatory/runtime.test.js | 99 ++ .../action-resolution.test.js | 65 + .../server/lib/openchamber-control/actions.js | 78 ++ .../server/lib/openchamber-control/service.js | 7 + .../server/lib/openchamber-sessions/routes.js | 14 + .../lib/opencode/feature-routes-runtime.js | 11 + .../server/lib/opencode/settings-helpers.js | 8 + .../server/lib/opencode/settings-runtime.js | 51 + .../lib/project-context/DOCUMENTATION.md | 157 +++ .../lib/project-context/routes.http.test.js | 264 +++++ .../web/server/lib/project-context/routes.js | 237 ++++ .../web/server/lib/project-context/runtime.js | 667 +++++++++++ .../lib/project-context/runtime.test.js | 498 ++++++++ .../web/server/lib/scheduled-tasks/runtime.js | 23 +- .../lib/session-knowledge/DOCUMENTATION.md | 82 ++ .../server/lib/session-knowledge/routes.js | 87 ++ .../server/lib/session-knowledge/runtime.js | 281 +++++ .../lib/session-knowledge/runtime.test.js | 240 ++++ 102 files changed, 10640 insertions(+), 1630 deletions(-) delete mode 100644 packages/ui/src/components/session/ProjectNotesTodoPanel.tsx create mode 100644 packages/ui/src/components/session/project-context/DOCUMENTATION.md create mode 100644 packages/ui/src/components/session/project-context/KnowledgeCard.tsx create mode 100644 packages/ui/src/components/session/project-context/MemorySection.tsx create mode 100644 packages/ui/src/components/session/project-context/NotesSection.tsx create mode 100644 packages/ui/src/components/session/project-context/PlansSection.tsx create mode 100644 packages/ui/src/components/session/project-context/ProjectNotesTodoPanel.tsx create mode 100644 packages/ui/src/components/session/project-context/TodosSection.tsx create mode 100644 packages/ui/src/components/session/project-context/useProjectTodoSend.ts create mode 100644 packages/ui/src/hooks/useAgentMemorySync.ts create mode 100644 packages/ui/src/lib/agentMemoryApi.ts create mode 100644 packages/ui/src/lib/agentMemoryBadges.test.ts create mode 100644 packages/ui/src/lib/agentMemoryBadges.ts create mode 100644 packages/ui/src/lib/projectContextApi.ts create mode 100644 packages/ui/src/lib/sessionKnowledgeApi.ts create mode 100644 packages/ui/src/stores/useAgentMemoryStore.test.ts create mode 100644 packages/ui/src/stores/useAgentMemoryStore.ts create mode 100644 packages/ui/src/stores/useProjectContextStore.test.ts create mode 100644 packages/ui/src/stores/useProjectContextStore.ts create mode 100644 packages/web/server/lib/agent-memory/actions.js create mode 100644 packages/web/server/lib/agent-memory/actions.test.js create mode 100644 packages/web/server/lib/agent-memory/feature-flag.js create mode 100644 packages/web/server/lib/agent-memory/feature-flag.test.js create mode 100644 packages/web/server/lib/agent-memory/project-resolution.js create mode 100644 packages/web/server/lib/agent-memory/project-resolution.test.js create mode 100644 packages/web/server/lib/agent-memory/routes.http.test.js create mode 100644 packages/web/server/lib/agent-memory/routes.js create mode 100644 packages/web/server/lib/agent-memory/runtime.js create mode 100644 packages/web/server/lib/agent-memory/runtime.test.js create mode 100644 packages/web/server/lib/agent-memory/threat-patterns.js create mode 100644 packages/web/server/lib/agent-memory/threat-patterns.test.js create mode 100644 packages/web/server/lib/openchamber-control/action-resolution.test.js create mode 100644 packages/web/server/lib/project-context/DOCUMENTATION.md create mode 100644 packages/web/server/lib/project-context/routes.http.test.js create mode 100644 packages/web/server/lib/project-context/routes.js create mode 100644 packages/web/server/lib/project-context/runtime.js create mode 100644 packages/web/server/lib/project-context/runtime.test.js create mode 100644 packages/web/server/lib/session-knowledge/DOCUMENTATION.md create mode 100644 packages/web/server/lib/session-knowledge/routes.js create mode 100644 packages/web/server/lib/session-knowledge/runtime.js create mode 100644 packages/web/server/lib/session-knowledge/runtime.test.js 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} -
    -