feat(knowledge): rebuild the project notes panel as Project knowledge (#2973)
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.
This commit is contained in:
committed by
GitHub
parent
7611076436
commit
34e8a24b20
@@ -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 {
|
||||
|
||||
@@ -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<TextSelectionMenuProps> = ({ 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();
|
||||
|
||||
@@ -1259,6 +1259,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = 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';
|
||||
|
||||
@@ -59,6 +59,9 @@ export const getToolIcon = (toolName: string) => {
|
||||
if (tool === 'openchamber_web') {
|
||||
return <Icon name="global" className={iconClass} />;
|
||||
}
|
||||
if (tool === 'openchamber_memory') {
|
||||
return <Icon name="brain-4" className={iconClass} />;
|
||||
}
|
||||
if (tool === 'question') {
|
||||
return <Icon name="survey" className={iconClass} />;
|
||||
}
|
||||
|
||||
@@ -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<Props> = ({ 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<SessionKnowledgeSummary>(
|
||||
{ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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) => (
|
||||
<WorkStatusRow
|
||||
key={note.id}
|
||||
muted
|
||||
leading={(
|
||||
<button
|
||||
type="button"
|
||||
disabled={!projectRef}
|
||||
aria-label={t('chat.workStatus.breakdown.unpin')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
unpinNote(note.id);
|
||||
}}
|
||||
className="shrink-0 rounded p-0.5 transition-opacity hover:opacity-70 disabled:opacity-40"
|
||||
>
|
||||
<Icon name="pushpin-2-fill" className="size-3.5" style={{ color: 'var(--primary)' }} />
|
||||
</button>
|
||||
)}
|
||||
label={note.body.trim().split('\n')[0] || note.body.trim()}
|
||||
value={<WorkStatusValue tone="muted">{t('chat.workStatus.breakdown.pinnedNote')}</WorkStatusValue>}
|
||||
/>
|
||||
))}
|
||||
{knowledge.plans.map((plan) => (
|
||||
<WorkStatusRow
|
||||
key={plan.id}
|
||||
muted
|
||||
leading={(
|
||||
<button
|
||||
type="button"
|
||||
disabled={!projectRef}
|
||||
aria-label={t('chat.workStatus.breakdown.unpin')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
unpinPlan(plan.id);
|
||||
}}
|
||||
className="shrink-0 rounded p-0.5 transition-opacity hover:opacity-70 disabled:opacity-40"
|
||||
>
|
||||
<Icon name="pushpin-2-fill" className="size-3.5" style={{ color: 'var(--primary)' }} />
|
||||
</button>
|
||||
)}
|
||||
label={plan.title}
|
||||
value={<WorkStatusValue tone="muted">{t('chat.workStatus.breakdown.pinnedPlan')}</WorkStatusValue>}
|
||||
/>
|
||||
))}
|
||||
{memoryCount > 0 ? (
|
||||
<WorkStatusRow
|
||||
muted
|
||||
label={t('chat.workStatus.breakdown.memory')}
|
||||
value={<WorkStatusValue>{memoryCount}</WorkStatusValue>}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<WorkStatusRow
|
||||
muted
|
||||
label={t('chat.workStatus.breakdown.skills')}
|
||||
|
||||
Reference in New Issue
Block a user