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 { 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');
@@ -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
@@ -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<string | null>(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);
@@ -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<ProjectNotesTodoPanelProps> = ({
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);
+3 -14
View File
@@ -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) {
@@ -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;
}
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(); };
+24 -5
View File
@@ -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<AgentMemoryState>((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<AgentMemoryState>((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<AgentMemoryState>((set, get) => ({
set({ ...EMPTY_STATE });
},
}));