From 03f4b5e3e0e8ed4292fc976cd4b9a3f93c9b7808 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 27 Aug 2026 18:23:40 +0300 Subject: [PATCH] fix(ui): scope agent memory to project context owner --- .../components/layout/RightSidebarTabs.tsx | 32 ++------ .../session/project-context/DOCUMENTATION.md | 14 +++- .../session/project-context/MemorySection.tsx | 4 +- .../project-context/ProjectNotesTodoPanel.tsx | 6 +- packages/ui/src/hooks/useAgentMemorySync.ts | 17 +--- .../src/hooks/useProjectContextOwner.test.ts | 48 +++++++++++ .../ui/src/hooks/useProjectContextOwner.ts | 80 +++++++++++++++++++ .../ui/src/stores/useAgentMemoryStore.test.ts | 38 ++++++++- packages/ui/src/stores/useAgentMemoryStore.ts | 29 +++++-- 9 files changed, 214 insertions(+), 54 deletions(-) create mode 100644 packages/ui/src/hooks/useProjectContextOwner.test.ts create mode 100644 packages/ui/src/hooks/useProjectContextOwner.ts diff --git a/packages/ui/src/components/layout/RightSidebarTabs.tsx b/packages/ui/src/components/layout/RightSidebarTabs.tsx index 8138bef1..842e4b88 100644 --- a/packages/ui/src/components/layout/RightSidebarTabs.tsx +++ b/packages/ui/src/components/layout/RightSidebarTabs.tsx @@ -6,46 +6,26 @@ import { useProjectsStore } from '@/stores/useProjectsStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { formatDirectoryName } from '@/lib/utils'; import { useSessionUIStore } from '@/sync/session-ui-store'; -import { CHAT_DRAFT_PROJECT_ID, getChatsRootForHome, getChatsRootFromDirectory, isChatDirectoryPath } from '@/lib/chatDirectories'; +import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories'; import { useI18n } from '@/lib/i18n'; +import { useProjectContextOwner } from '@/hooks/useProjectContextOwner'; export const ProjectContextPanel: React.FC<{ onActionComplete?: () => void; onOpenPlan?: (plan: { id: string; title: string }) => void; }> = ({ onActionComplete, onOpenPlan }) => { - const activeProjectId = useProjectsStore((state) => state.activeProjectId); const projects = useProjectsStore((state) => state.projects); const homeDirectory = useDirectoryStore((state) => state.homeDirectory); const { t } = useI18n(); const gitDirectories = useGitStore((state) => state.directories); - const isChatContext = useSessionUIStore((state) => ( - state.newSessionDraft.open - ? state.newSessionDraft.target === 'chat' - : isChatDirectoryPath(state.currentSessionDirectory) - )); const chatSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory); - const chatsRoot = getChatsRootFromDirectory(chatSessionDirectory) ?? getChatsRootForHome(homeDirectory); + const projectRef = useProjectContextOwner(chatSessionDirectory); + const isChatContext = projectRef?.id === CHAT_DRAFT_PROJECT_ID; const activeProject = React.useMemo(() => { if (isChatContext) return null; - if (activeProjectId) { - return projects.find((project) => project.id === activeProjectId) ?? projects[0] ?? null; - } - return projects[0] ?? null; - }, [activeProjectId, isChatContext, projects]); - - const projectRef = React.useMemo(() => { - if (isChatContext && chatsRoot) { - return { id: CHAT_DRAFT_PROJECT_ID, path: chatsRoot }; - } - if (!activeProject) { - return null; - } - return { - id: activeProject.id, - path: activeProject.path, - }; - }, [activeProject, chatsRoot, isChatContext]); + return projects.find((project) => project.id === projectRef?.id) ?? null; + }, [isChatContext, projectRef?.id, projects]); const projectLabel = React.useMemo(() => { if (isChatContext) return t('sessions.sidebar.activity.chatsTitle'); diff --git a/packages/ui/src/components/session/project-context/DOCUMENTATION.md b/packages/ui/src/components/session/project-context/DOCUMENTATION.md index 29b41d2f..d2e010ee 100644 --- a/packages/ui/src/components/session/project-context/DOCUMENTATION.md +++ b/packages/ui/src/components/session/project-context/DOCUMENTATION.md @@ -106,10 +106,16 @@ its own tool. It feeds this panel only — what a session is told about memory i decided server-side by `packages/web/server/lib/session-knowledge`, so it reaches sessions that have no UI at all and survives compaction. -Both sides resolve a worktree to its project before touching the store — the -client through `resolveProjectForSessionDirectory`, the server through -`agent-memory/project-resolution`. Keying by the session directory instead filed -a worktree's memories under a project nothing reads. +`useProjectContextOwner` is the client authority shared by this panel and the +memory sync. It resolves managed chat directories to the Chats root and a +worktree to its project before either consumer touches a store. The server uses +`agent-memory/project-resolution` for the same worktree rule. Keying by a +worktree session directory would file memories under a project nothing reads. + +Project memory is rendered only when the store's `projectPath` matches the +panel owner. An owner switch hides the previous project's entries before the +new request starts. A failed request marks the new owner unavailable instead of +presenting that hidden list as authoritative empty memory. Turning the switch back on re-reads the store only after the setting has finished being written. The switch flips the client immediately, which makes the diff --git a/packages/ui/src/components/session/project-context/MemorySection.tsx b/packages/ui/src/components/session/project-context/MemorySection.tsx index 61b4ea42..f88350ef 100644 --- a/packages/ui/src/components/session/project-context/MemorySection.tsx +++ b/packages/ui/src/components/session/project-context/MemorySection.tsx @@ -11,7 +11,7 @@ import { useI18n } from '@/lib/i18n'; import { AGENT_MEMORY_BODY_MAX_LENGTH, AGENT_MEMORY_TITLE_MAX_LENGTH, type AgentMemoryEntry, type AgentMemoryScope } from '@/lib/agentMemoryApi'; import { classifyMemory, memoryViewKey, type MemoryBadge } from '@/lib/agentMemoryBadges'; import { cn } from '@/lib/utils'; -import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore'; +import { selectProjectMemoryForPath, useAgentMemoryStore } from '@/stores/useAgentMemoryStore'; import { useUIStore } from '@/stores/useUIStore'; /** @@ -160,7 +160,7 @@ export const MemorySection: React.FC<{ const [expandedId, setExpandedId] = React.useState(null); const globalEntries = useAgentMemoryStore((state) => state.global); - const projectEntries = useAgentMemoryStore((state) => state.project); + const projectEntries = useAgentMemoryStore((state) => selectProjectMemoryForPath(state, projectPath)); const globalFailed = useAgentMemoryStore((state) => state.globalFailed); const projectFailed = useAgentMemoryStore((state) => state.projectFailed); const deleteEntry = useAgentMemoryStore((state) => state.deleteEntry); diff --git a/packages/ui/src/components/session/project-context/ProjectNotesTodoPanel.tsx b/packages/ui/src/components/session/project-context/ProjectNotesTodoPanel.tsx index e26b70ca..72b9fd20 100644 --- a/packages/ui/src/components/session/project-context/ProjectNotesTodoPanel.tsx +++ b/packages/ui/src/components/session/project-context/ProjectNotesTodoPanel.tsx @@ -7,7 +7,7 @@ import { Input } from '@/components/ui/input'; import { useI18n } from '@/lib/i18n'; import { resolveProjectContextId, type ProjectRef, type ProjectTodoItem } from '@/lib/projectContextApi'; import { cn } from '@/lib/utils'; -import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore'; +import { selectProjectMemoryForPath, useAgentMemoryStore } from '@/stores/useAgentMemoryStore'; import { countHighlightedMemories, memoryViewKey } from '@/lib/agentMemoryBadges'; import { EMPTY_PROJECT_CONTEXT_ENTRY, useProjectContextStore } from '@/stores/useProjectContextStore'; import { useUIStore } from '@/stores/useUIStore'; @@ -133,7 +133,9 @@ export const ProjectNotesTodoPanel: React.FC = ({ const memoryDisabledByServer = useAgentMemoryStore((state) => state.disabled); const memoryVisible = memoryEnabled && !memoryDisabledByServer; const globalMemory = useAgentMemoryStore((state) => state.global); - const projectMemory = useAgentMemoryStore((state) => state.project); + const projectMemory = useAgentMemoryStore( + (state) => selectProjectMemoryForPath(state, projectRef?.path ?? null), + ); const isMobile = useUIStore((state) => state.isMobile); const storedTab = useUIStore((state) => state.projectContextTab); diff --git a/packages/ui/src/hooks/useAgentMemorySync.ts b/packages/ui/src/hooks/useAgentMemorySync.ts index 40982d1c..24abdcca 100644 --- a/packages/ui/src/hooks/useAgentMemorySync.ts +++ b/packages/ui/src/hooks/useAgentMemorySync.ts @@ -13,12 +13,10 @@ import React from 'react'; -import { resolveProjectForSessionDirectory } from '@/lib/projectResolution'; import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents'; import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore'; -import { useProjectsStore } from '@/stores/useProjectsStore'; -import { useSessionUIStore } from '@/sync/session-ui-store'; import { useUIStore } from '@/stores/useUIStore'; +import { useProjectContextOwner } from '@/hooks/useProjectContextOwner'; /** * The directory is a parameter rather than read from `useEffectiveDirectory`, @@ -29,18 +27,9 @@ export const useAgentMemorySync = (directory: string | null): void => { const enabled = useUIStore((state) => ( state.agentMemoryFeatureAvailable && state.agentMemoryToolEnabled )); - const projects = useProjectsStore((state) => state.projects); - const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); - const effectiveDirectory = directory ?? ''; const load = useAgentMemoryStore((state) => state.load); - - const projectPath = React.useMemo(() => { - if (!effectiveDirectory) { - return null; - } - const resolved = resolveProjectForSessionDirectory(projects, availableWorktreesByProject, effectiveDirectory); - return resolved?.path ?? null; - }, [availableWorktreesByProject, effectiveDirectory, projects]); + const owner = useProjectContextOwner(directory); + const projectPath = owner?.path ?? null; React.useEffect(() => { if (!enabled) { diff --git a/packages/ui/src/hooks/useProjectContextOwner.test.ts b/packages/ui/src/hooks/useProjectContextOwner.test.ts new file mode 100644 index 00000000..a18ae9e1 --- /dev/null +++ b/packages/ui/src/hooks/useProjectContextOwner.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from 'bun:test'; + +import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories'; +import { resolveProjectContextOwner } from './useProjectContextOwner'; + +const projects = [ + { id: 'openchamber', path: '/workspace/openchamber', label: 'OpenChamber' }, +]; + +describe('resolveProjectContextOwner', () => { + test('resolves a managed chat directory to the Chats root instead of the active project', () => { + const owner = resolveProjectContextOwner({ + projects, + worktreesByProject: new Map(), + directory: '/Users/test/.config/openchamber/chats/2026-08-27/session-a', + activeProjectId: 'openchamber', + chatDraftOpen: false, + chatDraftTarget: 'project', + homeDirectory: '/Users/test', + }); + + expect(owner).toEqual({ + id: CHAT_DRAFT_PROJECT_ID, + path: '/Users/test/.config/openchamber/chats', + }); + }); + + test('resolves a worktree session to its owning project', () => { + const owner = resolveProjectContextOwner({ + projects, + worktreesByProject: new Map([ + ['/workspace/openchamber', [{ + path: '/workspace/openchamber-feature', + projectDirectory: '/workspace/openchamber', + branch: 'feature', + label: 'feature', + }]], + ]), + directory: '/workspace/openchamber-feature', + activeProjectId: null, + chatDraftOpen: false, + chatDraftTarget: 'project', + homeDirectory: '/Users/test', + }); + + expect(owner).toEqual({ id: 'openchamber', path: '/workspace/openchamber' }); + }); +}); diff --git a/packages/ui/src/hooks/useProjectContextOwner.ts b/packages/ui/src/hooks/useProjectContextOwner.ts new file mode 100644 index 00000000..4b9faac1 --- /dev/null +++ b/packages/ui/src/hooks/useProjectContextOwner.ts @@ -0,0 +1,80 @@ +import React from 'react'; + +import { CHAT_DRAFT_PROJECT_ID, getChatsRootForHome, getChatsRootFromDirectory } from '@/lib/chatDirectories'; +import { normalizePath } from '@/lib/pathNormalization'; +import { resolveProjectForSessionDirectory } from '@/lib/projectResolution'; +import type { ProjectRef } from '@/lib/projectContextApi'; +import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import type { WorktreeMetadata } from '@/types/worktree'; +import type { ProjectEntry } from '@/lib/api/types'; + +interface ProjectContextOwnerInput { + projects: ProjectEntry[]; + worktreesByProject: Map; + directory: string | null; + activeProjectId: string | null; + chatDraftOpen: boolean; + chatDraftTarget: 'chat' | 'project'; + homeDirectory: string | null; +} + +export const resolveProjectContextOwner = ({ + projects, + worktreesByProject, + directory, + activeProjectId, + chatDraftOpen, + chatDraftTarget, + homeDirectory, +}: ProjectContextOwnerInput): ProjectRef | null => { + const chatsRoot = getChatsRootFromDirectory(directory) ?? getChatsRootForHome(homeDirectory); + const normalizedDirectory = normalizePath(directory); + const normalizedChatsRoot = normalizePath(chatsRoot); + const ownsChats = chatDraftOpen + ? chatDraftTarget === 'chat' + : Boolean(normalizedDirectory && normalizedChatsRoot && ( + normalizedDirectory === normalizedChatsRoot || normalizedDirectory.startsWith(`${normalizedChatsRoot}/`) + )); + + if (ownsChats && chatsRoot) { + return { id: CHAT_DRAFT_PROJECT_ID, path: chatsRoot }; + } + + const sessionProject = resolveProjectForSessionDirectory(projects, worktreesByProject, directory); + if (sessionProject) { + return { id: sessionProject.id, path: sessionProject.path }; + } + + const activeProject = projects.find((project) => project.id === activeProjectId) ?? projects[0] ?? null; + return activeProject ? { id: activeProject.id, path: activeProject.path } : null; +}; + +/** The single owner used by Project knowledge and agent-memory synchronization. */ +export const useProjectContextOwner = (directory: string | null): ProjectRef | null => { + const projects = useProjectsStore((state) => state.projects); + const activeProjectId = useProjectsStore((state) => state.activeProjectId); + const homeDirectory = useDirectoryStore((state) => state.homeDirectory); + const worktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); + const chatDraftOpen = useSessionUIStore((state) => state.newSessionDraft.open); + const chatDraftTarget = useSessionUIStore((state) => state.newSessionDraft.target); + + return React.useMemo(() => resolveProjectContextOwner({ + projects, + worktreesByProject, + directory, + activeProjectId, + chatDraftOpen, + chatDraftTarget, + homeDirectory, + }), [ + activeProjectId, + chatDraftOpen, + chatDraftTarget, + directory, + homeDirectory, + projects, + worktreesByProject, + ]); +}; diff --git a/packages/ui/src/stores/useAgentMemoryStore.test.ts b/packages/ui/src/stores/useAgentMemoryStore.test.ts index 54cd9e32..a0e7bd60 100644 --- a/packages/ui/src/stores/useAgentMemoryStore.test.ts +++ b/packages/ui/src/stores/useAgentMemoryStore.test.ts @@ -21,6 +21,10 @@ interface MemoryReadResult { projectFailed: boolean; } +interface PendingMemoryRead { + resolve?: (result: MemoryReadResult) => void; +} + /** * Swappable implementations rather than mock helpers: each test states the one * behaviour it needs. @@ -45,7 +49,7 @@ mock.module('@/lib/agentMemoryApi', () => ({ }, })); -const { useAgentMemoryStore } = await import('./useAgentMemoryStore'); +const { selectProjectMemoryForPath, useAgentMemoryStore } = await import('./useAgentMemoryStore'); beforeEach(() => { useAgentMemoryStore.getState().reset(); @@ -86,6 +90,38 @@ describe('load', () => { expect(state.error).toBe('offline'); }); + test("does not expose the previous project's memories under the Chats owner", async () => { + await useAgentMemoryStore.getState().load('/workspace/openchamber'); + + const pending: PendingMemoryRead = {}; + readImpl = () => new Promise((resolve) => { + pending.resolve = resolve; + }); + const chatsPath = '/Users/test/.config/openchamber/chats'; + const loadingChats = useAgentMemoryStore.getState().load(chatsPath); + + const switched = useAgentMemoryStore.getState(); + expect(selectProjectMemoryForPath(switched, chatsPath)).toEqual([]); + expect(switched.projectPath).toBe(chatsPath); + + pending.resolve?.({ global: [entry({ id: 'g1' })], project: [], globalFailed: false, projectFailed: false }); + await loadingChats; + + expect(selectProjectMemoryForPath(useAgentMemoryStore.getState(), chatsPath)).toEqual([]); + }); + + test('a failed load for a new owner stays distinct from an empty project', async () => { + await useAgentMemoryStore.getState().load('/workspace/openchamber'); + readImpl = async () => { throw new Error('offline'); }; + + await useAgentMemoryStore.getState().load('/Users/test/.config/openchamber/chats'); + + const state = useAgentMemoryStore.getState(); + expect(state.project).toEqual([]); + expect(state.projectFailed).toBe(true); + expect(state.error).toBe('offline'); + }); + test('a disabled feature clears the lists rather than reporting an error', async () => { await useAgentMemoryStore.getState().load('/tmp/project'); readImpl = async () => { throw new AgentMemoryDisabledError(); }; diff --git a/packages/ui/src/stores/useAgentMemoryStore.ts b/packages/ui/src/stores/useAgentMemoryStore.ts index 188a3ef8..55bcbf73 100644 --- a/packages/ui/src/stores/useAgentMemoryStore.ts +++ b/packages/ui/src/stores/useAgentMemoryStore.ts @@ -57,6 +57,14 @@ const EMPTY_STATE = { error: null as string | null, }; +const EMPTY_MEMORY: AgentMemoryEntry[] = []; + +/** Never expose one owner's project entries under another owner's heading. */ +export const selectProjectMemoryForPath = ( + state: AgentMemoryState, + projectPath: string | null, +): AgentMemoryEntry[] => state.projectPath === projectPath ? state.project : EMPTY_MEMORY; + /** * Only the newest load may write to the store. Turning the feature back on * fires a load before the setting has finished being written, so an older @@ -93,13 +101,20 @@ export const useAgentMemoryStore = create((set, get) => ({ load: async (projectPath) => { const requestId = ++loadSequence; - set({ loading: true, projectPath }); + const previous = get(); + const ownerChanged = previous.projectPath !== projectPath; + if (ownerChanged) { + set({ loading: true, projectPath, project: [], projectFailed: false }); + } else { + set({ loading: true, projectPath }); + } try { const snapshot = await fetchAgentMemory(projectPath); if (requestId !== loadSequence) return; + const current = get(); set({ - global: snapshot.global, - project: snapshot.project, + global: snapshot.globalFailed ? current.global : snapshot.global, + project: snapshot.projectFailed ? current.project : snapshot.project, projectPath, globalFailed: snapshot.globalFailed, projectFailed: snapshot.projectFailed, @@ -119,7 +134,12 @@ export const useAgentMemoryStore = create((set, get) => ({ return; } // Whatever was loaded before stays. Only the error is new. - set({ loading: false, error: errorMessage(error, 'Failed to load agent memory') }); + set({ + loading: false, + globalFailed: true, + projectFailed: true, + error: errorMessage(error, 'Failed to load agent memory'), + }); } }, @@ -156,4 +176,3 @@ export const useAgentMemoryStore = create((set, get) => ({ set({ ...EMPTY_STATE }); }, })); -