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
+1 -2
View File
@@ -5,9 +5,8 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
- **Settings/Integrations:** a new Integrations settings page lists Claude Code, Command Code, and Cursor plugins with install, update, setup, and remove actions, plus Discord and Telegram Coming soon placeholders.
- **Project knowledge:** the Project notes panel is now Project knowledge, with notes, todos, plans and their search in a resizable sidebar. Notes are cards you expand by clicking anywhere on them, plans open and edit in the panel itself instead of a separate tab, and notes and plans can be attached to the current session or its new-session draft without affecting other sessions.
- **Project knowledge:** the Project notes panel is now Project knowledge, with notes, todos, plans and their search in a resizable sidebar. Notes are cards you expand by clicking anywhere on them, plans open and edit in the panel itself instead of a separate tab, and notes and plans can be attached as context.
- **Files:** drag files onto the Files sidebar to upload them into the project or a specific folder; existing files require confirmation before replacement, and open previews refresh after an upload (thanks to @makeittech, @alanzchen).
- Work status: the Context sources section now names each note and plan attached to that session, and its pin button detaches them from there.
- Settings: OpenChamber no longer replaces a full OpenCode config with an empty `$schema`-only stub when the file uses JSON5-style unquoted keys; Settings changes now fail instead of wiping plugins, MCP servers, and providers (thanks to @makeittech).
- Chat: an open conversation no longer keeps re-coloring the same code blocks in the background, so browsing files with a chat open stops pinning a CPU core and spinning up the fans (thanks to @makeittech).
- Stability/Proxy: the local server now reuses its connection to OpenCode instead of opening a new one for every API request. Under sustained traffic the old behavior could use up every outgoing network port on the machine, at which point nothing on the computer could open a new connection until the traffic stopped and the ports were released (thanks to @alohaninja).
@@ -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