fix(knowledge): show draft pins in work status

This commit is contained in:
Bohdan Triapitsyn
2026-08-19 00:22:54 +03:00
parent 0843250f07
commit 434e5ea9aa
5 changed files with 102 additions and 12 deletions
@@ -8,8 +8,13 @@ import { getLinkedIssues } from '@/lib/linkedIssues';
import { fetchSessionKnowledgeSummary, setSessionProjectContextPin, type SessionKnowledgeSummary } from '@/lib/sessionKnowledgeApi';
import { useProjectContextStore } from '@/stores/useProjectContextStore';
import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
import { resolveProjectContextId } from '@/lib/projectContextApi';
import { WorkStatusCollapsibleSection, WorkStatusRow, WorkStatusValue } from './WorkStatusPrimitives';
import { useReportWorkStatusPresence } from './presenceContext';
import { resolveDraftPinnedKnowledge } from './draftKnowledge';
type Props = {
sessionId: string | null;
@@ -29,6 +34,11 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
const { t } = useI18n();
const session = useSession(sessionId ?? '', directory ?? undefined);
const newSessionDraft = useSessionUIStore((state) => state.newSessionDraft);
const setDraftProjectContextPin = useSessionUIStore((state) => state.setDraftProjectContextPin);
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
const projects = useProjectsStore((state) => state.projects);
const isDraft = sessionId === null && newSessionDraft.open;
const skills = useSkillsStore((state) => state.skills);
const mcpStatus = useMcpStore(
React.useCallback((state) => state.getStatusForDirectory(directory), [directory]),
@@ -57,9 +67,31 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
// Re-read when source content or memory changes, not only when the session does.
const contextEntries = useProjectContextStore((state) => state.entries);
const loadProjectContext = useProjectContextStore((state) => state.load);
const memoryProject = useAgentMemoryStore((state) => state.project);
const memoryGlobal = useAgentMemoryStore((state) => state.global);
const draftProject = React.useMemo(() => {
if (!isDraft) return null;
const selected = newSessionDraft.selectedProjectId
? projects.find((project) => project.id === newSessionDraft.selectedProjectId) ?? null
: null;
return selected ?? resolveProjectForSessionDirectory(
projects,
availableWorktreesByProject,
newSessionDraft.directoryOverride ?? directory,
);
}, [availableWorktreesByProject, directory, isDraft, newSessionDraft.directoryOverride, newSessionDraft.selectedProjectId, projects]);
const draftContextEntry = draftProject
? contextEntries[resolveProjectContextId({ id: draftProject.id, path: draftProject.path })]
: undefined;
React.useEffect(() => {
if (!isDraft || !draftProject) return;
void loadProjectContext({ id: draftProject.id, path: draftProject.path });
}, [draftProject, isDraft, loadProjectContext]);
React.useEffect(() => {
let cancelled = false;
void fetchSessionKnowledgeSummary(directory, sessionId).then((summary) => {
@@ -68,24 +100,42 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
return () => { cancelled = true; };
}, [directory, sessionId, session, contextEntries, memoryProject, memoryGlobal]);
const visibleKnowledge = React.useMemo<SessionKnowledgeSummary>(() => {
if (!isDraft) return knowledge;
const pinned = resolveDraftPinnedKnowledge(
draftContextEntry?.notes ?? [],
draftContextEntry?.plans ?? [],
newSessionDraft.projectContextPins ?? { notes: [], plans: [] },
);
return { ...knowledge, ...pinned };
}, [draftContextEntry?.notes, draftContextEntry?.plans, isDraft, knowledge, newSessionDraft.projectContextPins]);
// 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 (isDraft) {
setDraftProjectContextPin('note', noteId, false);
return;
}
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]);
}, [directory, isDraft, sessionId, setDraftProjectContextPin]);
const unpinPlan = React.useCallback((planId: string) => {
if (isDraft) {
setDraftProjectContextPin('plan', planId, false);
return;
}
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]);
}, [directory, isDraft, sessionId, setDraftProjectContextPin]);
const memoryCount = knowledge.memory.global + knowledge.memory.project;
const pinnedCount = knowledge.notes.length + knowledge.plans.length;
const memoryCount = visibleKnowledge.memory.global + visibleKnowledge.memory.project;
const pinnedCount = visibleKnowledge.notes.length + visibleKnowledge.plans.length;
const linked = React.useMemo(() => getLinkedIssues(session), [session]);
// Connected servers only. A disabled server contributes nothing to the
@@ -174,14 +224,14 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
{/* 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) => (
{visibleKnowledge.notes.map((note) => (
<WorkStatusRow
key={note.id}
muted
leading={(
<button
type="button"
disabled={!sessionId || !directory}
disabled={!isDraft && (!sessionId || !directory)}
aria-label={t('chat.workStatus.breakdown.unpin')}
onClick={(event) => {
event.stopPropagation();
@@ -196,14 +246,14 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
value={<WorkStatusValue tone="muted">{t('chat.workStatus.breakdown.pinnedNote')}</WorkStatusValue>}
/>
))}
{knowledge.plans.map((plan) => (
{visibleKnowledge.plans.map((plan) => (
<WorkStatusRow
key={plan.id}
muted
leading={(
<button
type="button"
disabled={!sessionId || !directory}
disabled={!isDraft && (!sessionId || !directory)}
aria-label={t('chat.workStatus.breakdown.unpin')}
onClick={(event) => {
event.stopPropagation();
@@ -0,0 +1,24 @@
import { describe, expect, test } from 'bun:test';
import { resolveDraftPinnedKnowledge } from './draftKnowledge';
describe('resolveDraftPinnedKnowledge', () => {
test('shows only notes and plans pinned on this draft', () => {
expect(resolveDraftPinnedKnowledge(
[{ id: 'note-a', body: 'Attached' }, { id: 'note-b', body: 'Not attached' }],
[{ id: 'plan-a', title: 'Attached plan' }, { id: 'plan-b', title: 'Other plan' }],
{ notes: ['note-a'], plans: ['plan-a'] },
)).toEqual({
notes: [{ id: 'note-a', body: 'Attached' }],
plans: [{ id: 'plan-a', title: 'Attached plan' }],
});
});
test('drops stale ids without borrowing project-wide pins', () => {
expect(resolveDraftPinnedKnowledge(
[{ id: 'note-a', body: 'Project note' }],
[{ id: 'plan-a', title: 'Project plan' }],
{ notes: ['missing'], plans: [] },
)).toEqual({ notes: [], plans: [] });
});
});
@@ -0,0 +1,17 @@
import type { SessionKnowledgeSummary, SessionProjectContextPins } from '@/lib/sessionKnowledgeApi';
type NoteSource = { id: string; body: string };
type PlanSource = { id: string; title: string };
export const resolveDraftPinnedKnowledge = (
notes: NoteSource[],
plans: PlanSource[],
pins: SessionProjectContextPins,
): Pick<SessionKnowledgeSummary, 'notes' | 'plans'> => {
const noteIds = new Set(pins.notes);
const planIds = new Set(pins.plans);
return {
notes: notes.filter((note) => noteIds.has(note.id)).map(({ id, body }) => ({ id, body })),
plans: plans.filter((plan) => planIds.has(plan.id)).map(({ id, title }) => ({ id, title })),
};
};
@@ -65,8 +65,8 @@ still pass `onOpenPlan` and keep theirs.
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.
session created by its first message. Work status lists and detaches draft pins
before that first message, then reads them from the created session metadata.
## Memory is not a fifth kind of note