fix(knowledge): scope pins to sessions

This commit is contained in:
Bohdan Triapitsyn
2026-08-19 00:07:57 +03:00
parent a66903c73c
commit f9d1ded81a
16 changed files with 340 additions and 106 deletions
@@ -5,12 +5,9 @@ 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 { fetchSessionKnowledgeSummary, setSessionProjectContextPin, type SessionKnowledgeSummary } from '@/lib/sessionKnowledgeApi';
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';
@@ -50,7 +47,7 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
}, [directory, loadSkills]);
/**
* What the project sends along with every message. Read from the server
* What this session carries. 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.
*/
@@ -58,41 +55,34 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
{ 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.
// Re-read when source content or memory changes, not only when the session does.
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) => {
void fetchSessionKnowledgeSummary(directory, sessionId).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]);
}, [directory, sessionId, session, contextEntries, memoryProject, memoryGlobal]);
// 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]);
if (!directory || !sessionId) return;
void setSessionProjectContextPin(directory, sessionId, 'note', noteId, false).then((pins) => {
if (pins) setKnowledge((current) => ({ ...current, notes: current.notes.filter((note) => note.id !== noteId) }));
});
}, [directory, sessionId]);
const unpinPlan = React.useCallback((planId: string) => {
if (projectRef) void setPlanPinned(projectRef, planId, false);
}, [projectRef, setPlanPinned]);
if (!directory || !sessionId) return;
void setSessionProjectContextPin(directory, sessionId, 'plan', planId, false).then((pins) => {
if (pins) setKnowledge((current) => ({ ...current, plans: current.plans.filter((plan) => plan.id !== planId) }));
});
}, [directory, sessionId]);
const memoryCount = knowledge.memory.global + knowledge.memory.project;
const pinnedCount = knowledge.notes.length + knowledge.plans.length;
@@ -131,9 +121,7 @@ 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.
// Pinned knowledge outranks ambient counts because the user chose it for this session.
if (summaryParts.length === 0 && pinnedCount > 0) {
summaryParts.push(pinnedCount === 1
? t('chat.workStatus.breakdown.pinnedKnowledgeSingle', { count: pinnedCount })
@@ -182,8 +170,7 @@ 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. */}
{/* Named individually: a count alone would not identify this session's context. */}
{/* 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. */}
@@ -194,7 +181,7 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
leading={(
<button
type="button"
disabled={!projectRef}
disabled={!sessionId || !directory}
aria-label={t('chat.workStatus.breakdown.unpin')}
onClick={(event) => {
event.stopPropagation();
@@ -216,7 +203,7 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
leading={(
<button
type="button"
disabled={!projectRef}
disabled={!sessionId || !directory}
aria-label={t('chat.workStatus.breakdown.unpin')}
onClick={(event) => {
event.stopPropagation();
@@ -60,14 +60,13 @@ Leaving the section or the project closes it, so its editor never sits over a
list it no longer matches. Hosts that own a fullscreen plan surface (mobile)
still pass `onOpenPlan` and keep theirs.
## Pins are project state, not a message attachment
## Pins belong to one session
Pinning a note or plan writes to the project, not to the session, so it holds
across every session in that project until it is unpinned. The composer once
carried a chip for it, from when pinned context was a one-shot attachment to the
next message; standing state shown permanently above the input reads as
something being attached to what you are typing, which it is not. What is
attached, and the control to detach it, live in the work status panel instead.
Notes and plans are project data, but attaching one writes its id to the current
session metadata. Other sessions in the project do not inherit it. A pin made
while a new-session draft is open lives on that draft and transfers only to the
session created by its first message. Work status lists and detaches the pins of
the session it describes.
## Memory is not a fifth kind of note
@@ -211,10 +210,8 @@ matched the old project would silently hide everything in the new one.
## Pinned context
The pin toggle on a note or plan marks it as standing context for the agent.
Assembly and delivery live in `packages/ui/src/lib/projectContextPinning.ts`;
this surface only owns the toggle. `ComposerPinnedContextChip` shows the user
what is riding along.
The pin toggle on a note or plan attaches it to the current session or draft.
Assembly and delivery live in `packages/web/server/lib/session-knowledge`.
## Related
@@ -23,12 +23,13 @@ const NOTE_SAVE_DEBOUNCE_MS = 400;
*/
const NoteRow: React.FC<{
note: ProjectNote;
pinned: boolean;
expanded: boolean;
onToggleExpanded: () => void;
onSaveBody: (body: string) => void;
onTogglePinned: () => void;
onDelete: () => void;
}> = ({ note, expanded, onToggleExpanded, onSaveBody, onTogglePinned, onDelete }) => {
}> = ({ note, pinned, expanded, onToggleExpanded, onSaveBody, onTogglePinned, onDelete }) => {
const { t } = useI18n();
const [draft, setDraft] = React.useState(note.body);
const lastSavedRef = React.useRef(note.body);
@@ -107,19 +108,19 @@ const NoteRow: React.FC<{
onClick={onTogglePinned}
className={cn(
'inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
note.pinned ? 'text-primary' : 'text-muted-foreground hover:text-foreground'
pinned ? 'text-primary' : 'text-muted-foreground hover:text-foreground'
)}
aria-pressed={note.pinned}
aria-label={note.pinned
aria-pressed={pinned}
aria-label={pinned
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
title={note.pinned
title={pinned
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
>
{/* Filled means pinned, outline means "pin this" — the same
language the work status panel uses. */}
<Icon name={note.pinned ? 'pushpin-2-fill' : 'pushpin'} className="h-3.5 w-3.5" />
<Icon name={pinned ? 'pushpin-2-fill' : 'pushpin'} className="h-3.5 w-3.5" />
</button>
<button
type="button"
@@ -163,7 +164,9 @@ export const NotesSection: React.FC<{
notes: ProjectNote[];
disabled: boolean;
query: string;
}> = ({ projectRef, notes, disabled, query }) => {
pinnedNoteIds: ReadonlySet<string>;
onTogglePinned: (noteId: string, pinned: boolean) => Promise<boolean>;
}> = ({ projectRef, notes, disabled, query, pinnedNoteIds, onTogglePinned }) => {
const { t } = useI18n();
const [composerText, setComposerText] = React.useState('');
// One at a time on purpose: notes can run to 3000 characters each, and
@@ -173,7 +176,6 @@ export const NotesSection: React.FC<{
const setNotesPanelHeight = useUIStore((state) => state.setNotesPanelHeight);
const createNote = useProjectContextStore((state) => state.createNote);
const saveNoteBody = useProjectContextStore((state) => state.saveNoteBody);
const setNotePinned = useProjectContextStore((state) => state.setNotePinned);
const deleteNote = useProjectContextStore((state) => state.deleteNote);
const visibleNotes = React.useMemo(() => {
@@ -214,12 +216,12 @@ export const NotesSection: React.FC<{
const handleTogglePinned = React.useCallback(
async (noteId: string, pinned: boolean) => {
const ok = await setNotePinned(projectRef, noteId, pinned);
const ok = await onTogglePinned(noteId, pinned);
if (!ok) {
reportFailure(t('rightSidebar.contextNotesTodo.toast.saveNotesFailed'));
}
},
[projectRef, reportFailure, setNotePinned, t]
[onTogglePinned, reportFailure, t]
);
const handleSaveBody = React.useCallback(
@@ -281,10 +283,11 @@ export const NotesSection: React.FC<{
<NoteRow
key={note.id}
note={note}
pinned={pinnedNoteIds.has(note.id)}
expanded={expandedNoteId === note.id}
onToggleExpanded={() => setExpandedNoteId((current) => (current === note.id ? null : note.id))}
onSaveBody={(body) => handleSaveBody(note.id, body)}
onTogglePinned={() => void handleTogglePinned(note.id, !note.pinned)}
onTogglePinned={() => void handleTogglePinned(note.id, !pinnedNoteIds.has(note.id))}
onDelete={() => void handleDelete(note.id)}
/>
))}
@@ -24,14 +24,15 @@ export const PlansSection: React.FC<{
query: string;
/** Hosts without a ContextPanel (mobile) render their own plan viewer. */
onOpenPlan?: (plan: { id: string; title: string }) => void;
}> = ({ projectRef, plans, query, onOpenPlan }) => {
pinnedPlanIds: ReadonlySet<string>;
onTogglePinned: (planId: string, pinned: boolean) => Promise<boolean>;
}> = ({ projectRef, plans, query, onOpenPlan, pinnedPlanIds, onTogglePinned }) => {
const { t } = useI18n();
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const [isImporting, setIsImporting] = React.useState(false);
const [deletingPlanId, setDeletingPlanId] = React.useState<string | null>(null);
const createPlan = useProjectContextStore((state) => state.createPlan);
const removePlan = useProjectContextStore((state) => state.deletePlan);
const setPlanPinned = useProjectContextStore((state) => state.setPlanPinned);
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
@@ -136,13 +137,13 @@ export const PlansSection: React.FC<{
const handleTogglePinned = React.useCallback(
async (planId: string, pinned: boolean) => {
const ok = await setPlanPinned(projectRef, planId, pinned);
const ok = await onTogglePinned(planId, pinned);
if (!ok) {
const detail = useProjectContextStore.getState().getEntry(projectRef).error;
toast.error(t('rightSidebar.contextNotesTodo.toast.updatePlanFailed'), detail ? { description: detail } : undefined);
}
},
[projectRef, setPlanPinned, t]
[onTogglePinned, projectRef, t]
);
const visiblePlans = React.useMemo(() => {
@@ -220,16 +221,16 @@ export const PlansSection: React.FC<{
</button>
<button
type="button"
onClick={() => void handleTogglePinned(plan.id, !plan.pinned)}
onClick={() => void handleTogglePinned(plan.id, !pinnedPlanIds.has(plan.id))}
className={cn(
'inline-flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-md hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
plan.pinned ? 'text-primary' : 'text-muted-foreground hover:text-foreground'
pinnedPlanIds.has(plan.id) ? 'text-primary' : 'text-muted-foreground hover:text-foreground'
)}
aria-pressed={plan.pinned}
aria-label={plan.pinned
aria-pressed={pinnedPlanIds.has(plan.id)}
aria-label={pinnedPlanIds.has(plan.id)
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
title={plan.pinned
title={pinnedPlanIds.has(plan.id)
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
>
@@ -17,6 +17,8 @@ import { NotesSection } from './NotesSection';
import { PlansSection } from './PlansSection';
import { TodosSection } from './TodosSection';
import { useProjectTodoSend } from './useProjectTodoSend';
import { fetchSessionKnowledgeSummary, setSessionProjectContextPin, type SessionProjectContextPins } from '@/lib/sessionKnowledgeApi';
import { useSessionUIStore } from '@/sync/session-ui-store';
/** Lazy: the plan editor is a large view, and most panel visits never open it. */
const PlanView = React.lazy(() => import('@/components/views/PlanView').then((module) => ({ default: module.PlanView })));
@@ -85,6 +87,43 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
);
const loadProjectContext = useProjectContextStore((state) => state.load);
const saveTodos = useProjectContextStore((state) => state.saveTodos);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const currentSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
const newSessionDraft = useSessionUIStore((state) => state.newSessionDraft);
const setDraftProjectContextPin = useSessionUIStore((state) => state.setDraftProjectContextPin);
const [sessionPins, setSessionPins] = React.useState<SessionProjectContextPins>({ notes: [], plans: [] });
React.useEffect(() => {
if (newSessionDraft.open) {
setSessionPins(newSessionDraft.projectContextPins ?? { notes: [], plans: [] });
return;
}
let cancelled = false;
void fetchSessionKnowledgeSummary(currentSessionDirectory, currentSessionId).then((summary) => {
if (!cancelled) {
setSessionPins({
notes: summary.notes.map((note) => note.id),
plans: summary.plans.map((plan) => plan.id),
});
}
});
return () => { cancelled = true; };
}, [currentSessionDirectory, currentSessionId, newSessionDraft.open, newSessionDraft.projectContextPins]);
const toggleSessionPin = React.useCallback(async (kind: 'note' | 'plan', id: string, pinned: boolean) => {
if (newSessionDraft.open) {
setDraftProjectContextPin(kind, id, pinned);
return true;
}
if (!currentSessionId || !currentSessionDirectory) return false;
const next = await setSessionProjectContextPin(currentSessionDirectory, currentSessionId, kind, id, pinned);
if (!next) return false;
setSessionPins(next);
return true;
}, [currentSessionDirectory, currentSessionId, newSessionDraft.open, setDraftProjectContextPin]);
const pinnedNoteIds = React.useMemo(() => new Set(sessionPins.notes), [sessionPins.notes]);
const pinnedPlanIds = React.useMemo(() => new Set(sessionPins.plans), [sessionPins.plans]);
// The whole feature is one switch: with memory off there is nothing for the
// agent to manage, so showing the user what is stored would be pointless.
@@ -387,6 +426,8 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
notes={contextEntry.notes}
disabled={isLoading}
query={query}
pinnedNoteIds={pinnedNoteIds}
onTogglePinned={(noteId, pinned) => toggleSessionPin('note', noteId, pinned)}
/>
) : null}
@@ -413,6 +454,8 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
projectRef={projectRef}
plans={contextEntry.plans}
query={query}
pinnedPlanIds={pinnedPlanIds}
onTogglePinned={(planId, pinned) => toggleSessionPin('plan', planId, pinned)}
// Hosts that own a fullscreen plan surface (mobile) keep it; on the
// desktop panel the plan opens here, in place of the list.
onOpenPlan={onOpenPlan ?? setOpenPlan}