fix(ui): scope agent memory to project context owner
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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(); };
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user