fix(knowledge): scope pins to sessions
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user