fix(knowledge): show draft pins in work status
This commit is contained in:
+1
-2
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user