fix(ui): scope agent memory to project context owner

This commit is contained in:
Bohdan Triapitsyn
2026-08-27 20:18:12 +03:00
parent 7aae5a6634
commit 03f4b5e3e0
9 changed files with 214 additions and 54 deletions
@@ -6,46 +6,26 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { formatDirectoryName } from '@/lib/utils'; import { formatDirectoryName } from '@/lib/utils';
import { useSessionUIStore } from '@/sync/session-ui-store'; 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 { useI18n } from '@/lib/i18n';
import { useProjectContextOwner } from '@/hooks/useProjectContextOwner';
export const ProjectContextPanel: React.FC<{ export const ProjectContextPanel: React.FC<{
onActionComplete?: () => void; onActionComplete?: () => void;
onOpenPlan?: (plan: { id: string; title: string }) => void; onOpenPlan?: (plan: { id: string; title: string }) => void;
}> = ({ onActionComplete, onOpenPlan }) => { }> = ({ onActionComplete, onOpenPlan }) => {
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const projects = useProjectsStore((state) => state.projects); const projects = useProjectsStore((state) => state.projects);
const homeDirectory = useDirectoryStore((state) => state.homeDirectory); const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
const { t } = useI18n(); const { t } = useI18n();
const gitDirectories = useGitStore((state) => state.directories); 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 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(() => { const activeProject = React.useMemo(() => {
if (isChatContext) return null; if (isChatContext) return null;
if (activeProjectId) { return projects.find((project) => project.id === projectRef?.id) ?? null;
return projects.find((project) => project.id === activeProjectId) ?? projects[0] ?? null; }, [isChatContext, projectRef?.id, projects]);
}
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]);
const projectLabel = React.useMemo(() => { const projectLabel = React.useMemo(() => {
if (isChatContext) return t('sessions.sidebar.activity.chatsTitle'); if (isChatContext) return t('sessions.sidebar.activity.chatsTitle');
@@ -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 decided server-side by `packages/web/server/lib/session-knowledge`, so it
reaches sessions that have no UI at all and survives compaction. reaches sessions that have no UI at all and survives compaction.
Both sides resolve a worktree to its project before touching the store — the `useProjectContextOwner` is the client authority shared by this panel and the
client through `resolveProjectForSessionDirectory`, the server through memory sync. It resolves managed chat directories to the Chats root and a
`agent-memory/project-resolution`. Keying by the session directory instead filed worktree to its project before either consumer touches a store. The server uses
a worktree's memories under a project nothing reads. `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 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 finished being written. The switch flips the client immediately, which makes the
@@ -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 { 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 { classifyMemory, memoryViewKey, type MemoryBadge } from '@/lib/agentMemoryBadges';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore'; import { selectProjectMemoryForPath, useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
/** /**
@@ -160,7 +160,7 @@ export const MemorySection: React.FC<{
const [expandedId, setExpandedId] = React.useState<string | null>(null); const [expandedId, setExpandedId] = React.useState<string | null>(null);
const globalEntries = useAgentMemoryStore((state) => state.global); 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 globalFailed = useAgentMemoryStore((state) => state.globalFailed);
const projectFailed = useAgentMemoryStore((state) => state.projectFailed); const projectFailed = useAgentMemoryStore((state) => state.projectFailed);
const deleteEntry = useAgentMemoryStore((state) => state.deleteEntry); const deleteEntry = useAgentMemoryStore((state) => state.deleteEntry);
@@ -7,7 +7,7 @@ import { Input } from '@/components/ui/input';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { resolveProjectContextId, type ProjectRef, type ProjectTodoItem } from '@/lib/projectContextApi'; import { resolveProjectContextId, type ProjectRef, type ProjectTodoItem } from '@/lib/projectContextApi';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore'; import { selectProjectMemoryForPath, useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
import { countHighlightedMemories, memoryViewKey } from '@/lib/agentMemoryBadges'; import { countHighlightedMemories, memoryViewKey } from '@/lib/agentMemoryBadges';
import { EMPTY_PROJECT_CONTEXT_ENTRY, useProjectContextStore } from '@/stores/useProjectContextStore'; import { EMPTY_PROJECT_CONTEXT_ENTRY, useProjectContextStore } from '@/stores/useProjectContextStore';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
@@ -133,7 +133,9 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
const memoryDisabledByServer = useAgentMemoryStore((state) => state.disabled); const memoryDisabledByServer = useAgentMemoryStore((state) => state.disabled);
const memoryVisible = memoryEnabled && !memoryDisabledByServer; const memoryVisible = memoryEnabled && !memoryDisabledByServer;
const globalMemory = useAgentMemoryStore((state) => state.global); 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 isMobile = useUIStore((state) => state.isMobile);
const storedTab = useUIStore((state) => state.projectContextTab); const storedTab = useUIStore((state) => state.projectContextTab);
+3 -14
View File
@@ -13,12 +13,10 @@
import React from 'react'; import React from 'react';
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents'; import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore'; import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { useProjectContextOwner } from '@/hooks/useProjectContextOwner';
/** /**
* The directory is a parameter rather than read from `useEffectiveDirectory`, * 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) => ( const enabled = useUIStore((state) => (
state.agentMemoryFeatureAvailable && state.agentMemoryToolEnabled 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 load = useAgentMemoryStore((state) => state.load);
const owner = useProjectContextOwner(directory);
const projectPath = React.useMemo(() => { const projectPath = owner?.path ?? null;
if (!effectiveDirectory) {
return null;
}
const resolved = resolveProjectForSessionDirectory(projects, availableWorktreesByProject, effectiveDirectory);
return resolved?.path ?? null;
}, [availableWorktreesByProject, effectiveDirectory, projects]);
React.useEffect(() => { React.useEffect(() => {
if (!enabled) { if (!enabled) {
@@ -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' });
});
});
@@ -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<string, WorktreeMetadata[]>;
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,
]);
};
@@ -21,6 +21,10 @@ interface MemoryReadResult {
projectFailed: boolean; projectFailed: boolean;
} }
interface PendingMemoryRead {
resolve?: (result: MemoryReadResult) => void;
}
/** /**
* Swappable implementations rather than mock helpers: each test states the one * Swappable implementations rather than mock helpers: each test states the one
* behaviour it needs. * behaviour it needs.
@@ -45,7 +49,7 @@ mock.module('@/lib/agentMemoryApi', () => ({
}, },
})); }));
const { useAgentMemoryStore } = await import('./useAgentMemoryStore'); const { selectProjectMemoryForPath, useAgentMemoryStore } = await import('./useAgentMemoryStore');
beforeEach(() => { beforeEach(() => {
useAgentMemoryStore.getState().reset(); useAgentMemoryStore.getState().reset();
@@ -86,6 +90,38 @@ describe('load', () => {
expect(state.error).toBe('offline'); 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 () => { test('a disabled feature clears the lists rather than reporting an error', async () => {
await useAgentMemoryStore.getState().load('/tmp/project'); await useAgentMemoryStore.getState().load('/tmp/project');
readImpl = async () => { throw new AgentMemoryDisabledError(); }; readImpl = async () => { throw new AgentMemoryDisabledError(); };
+24 -5
View File
@@ -57,6 +57,14 @@ const EMPTY_STATE = {
error: null as string | null, 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 * 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 * fires a load before the setting has finished being written, so an older
@@ -93,13 +101,20 @@ export const useAgentMemoryStore = create<AgentMemoryState>((set, get) => ({
load: async (projectPath) => { load: async (projectPath) => {
const requestId = ++loadSequence; 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 { try {
const snapshot = await fetchAgentMemory(projectPath); const snapshot = await fetchAgentMemory(projectPath);
if (requestId !== loadSequence) return; if (requestId !== loadSequence) return;
const current = get();
set({ set({
global: snapshot.global, global: snapshot.globalFailed ? current.global : snapshot.global,
project: snapshot.project, project: snapshot.projectFailed ? current.project : snapshot.project,
projectPath, projectPath,
globalFailed: snapshot.globalFailed, globalFailed: snapshot.globalFailed,
projectFailed: snapshot.projectFailed, projectFailed: snapshot.projectFailed,
@@ -119,7 +134,12 @@ export const useAgentMemoryStore = create<AgentMemoryState>((set, get) => ({
return; return;
} }
// Whatever was loaded before stays. Only the error is new. // 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<AgentMemoryState>((set, get) => ({
set({ ...EMPTY_STATE }); set({ ...EMPTY_STATE });
}, },
})); }));