feat(knowledge): rebuild the project notes panel as Project knowledge (#2973)

The panel stored notes, todos and plans inside one shared JSON file that
six unrelated domains also wrote to, synchronised itself through window
CustomEvents, and could only read plans. It is now Project knowledge:
server-owned storage with explicit routes, a store with rollback, a
section sidebar, plans that open and edit in place, and search across
all of it.

Notes and plans the user pins travel with every message sent in that
project. Pinning is project state, not an attachment to one message, so
it holds until unpinned and the work status panel names what is riding
along and can detach it.

Agent memory is added alongside, in two scopes: what is true about the
user, and what is true about this codebase. The split is not cosmetic —
a wrong project fact costs one project and is noticed, while a wrong
global fact quietly shapes every session everywhere and the user has no
code to check it against. It stays separate from notes so an agent
mistake cannot land in what the user wrote. Sessions receive an index of
titles only; bodies are read on demand, because an index carrying full
text grows until it crowds out the conversation.

Deciding what a session must be told, and whether it has been told, now
lives on the server. The client owned it before, which meant sessions
started without a UI — scheduled tasks, sessions the agent dispatches —
received nothing at all, and a tab's record of what it had sent outlived
the conversation: after compaction the agent no longer held the block
while the tab went on believing it did. What was delivered is recorded
in the session's own metadata, and compaction restores it through the
runtime that already restores pinned messages, in the same turn.

Agent memory ships dark behind OPENCHAMBER_MEMORY_ENABLE: unset, there
is no tool, no routes, no session index, no settings row and no panel
tab. Absent rather than switched off, so nothing invites turning on a
feature that has not been announced. Pinned notes and plans are
unaffected and ship as normal.
This commit is contained in:
Bohdan Triapitsyn
2026-08-18 02:59:04 +03:00
committed by GitHub
parent 7611076436
commit 34e8a24b20
102 changed files with 10640 additions and 1630 deletions
@@ -41,7 +41,7 @@ import { ToolRevealOnMount } from './parts/ToolRevealOnMount';
import { StaticToolRow } from './parts/ProgressiveGroup';
import { isExpandableTool, isStandaloneTool } from './parts/toolRenderUtils';
import TurnActivity from '../components/TurnActivity';
import { createProjectPlanFile } from '@/lib/openchamberConfig';
import { useProjectContextStore } from '@/stores/useProjectContextStore';
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useI18n } from '@/lib/i18n';
@@ -1509,7 +1509,7 @@ const AssistantMessageBody = React.memo(({
setIsSavingPlan(true);
try {
const created = await createProjectPlanFile(currentProjectRef, {
const created = await useProjectContextStore.getState().createPlan(currentProjectRef, {
title,
body: assistantPlanText,
});
@@ -1517,9 +1517,6 @@ const AssistantMessageBody = React.memo(({
toast.error(t('chat.messageBody.toast.savePlanFailed'));
return;
}
window.dispatchEvent(new CustomEvent('openchamber:project-plan-saved', {
detail: { projectId: currentProjectRef.id },
}));
setIsPlanDialogOpen(false);
toast.success(t('chat.messageBody.toast.planSaved'));
} finally {
@@ -9,7 +9,8 @@ import { cn } from '@/lib/utils';
import { copyTextToClipboard } from '@/lib/clipboard';
import { toast } from '@/components/ui';
import { Icon } from "@/components/icon/Icon";
import { OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH, getProjectNotesAndTodos, saveProjectNotesAndTodos } from '@/lib/openchamberConfig';
import { PROJECT_NOTE_BODY_MAX_LENGTH } from '@/lib/projectContextApi';
import { useProjectContextStore } from '@/stores/useProjectContextStore';
import { summarizeSelectionForNotes } from '@/lib/smallModel';
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
@@ -34,15 +35,9 @@ interface SelectionPayload {
rect: DOMRect;
}
const appendDistilledInsightToNotes = (existingNotes: string, insight: string): string => {
const trimmedInsight = insight.trim().replace(/^[-*+]\s+/, '').slice(0, OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH);
if (!trimmedInsight) {
return existingNotes;
}
const trimmedNotes = existingNotes.trimEnd();
return trimmedNotes ? `${trimmedNotes}\n${trimmedInsight}` : trimmedInsight;
};
const normalizeDistilledInsight = (insight: string): string => (
insight.trim().replace(/^[-*+]\s+/, '').slice(0, PROJECT_NOTE_BODY_MAX_LENGTH)
);
const DESKTOP_MENU_SIDE_MARGIN_PX = 8;
const DESKTOP_MENU_FALLBACK_WIDTH_PX = 280;
@@ -366,19 +361,22 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
// Long selections are distilled into a compact note by the small model;
// short ones (and any generation failure) go in verbatim.
const noteText = await summarizeSelectionForNotes(selectedTextMarkdown || selectedText, currentSessionId);
const projectData = await getProjectNotesAndTodos(currentProjectRef);
const nextNotes = appendDistilledInsightToNotes(projectData.notes, noteText);
const saved = await saveProjectNotesAndTodos(currentProjectRef, {
notes: nextNotes,
todos: projectData.todos,
const insight = normalizeDistilledInsight(noteText);
if (!insight) {
toast.error(t('chat.textSelection.toast.addToNotesFailed'));
return;
}
// Recorded as its own note with provenance, so the distilled insight can
// later be traced back to the conversation it came from.
const saved = await useProjectContextStore.getState().createNote(currentProjectRef, {
body: insight,
source: 'selection',
...(currentSessionId ? { origin: { sessionId: currentSessionId } } : {}),
});
if (!saved) {
toast.error(t('chat.textSelection.toast.addToNotesFailed'));
return;
}
window.dispatchEvent(new CustomEvent('openchamber:project-notes-updated', {
detail: { projectId: currentProjectRef.id },
}));
toast.success(t('chat.textSelection.toast.addToNotesSuccess'));
hideMenu();
window.getSelection()?.removeAllRanges();
@@ -1259,6 +1259,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
const hasVisualDiffEntry = diffEntries.some((entry) => entry.renderMode === 'diff');
const hideToolInputPreview = part.tool === 'openchamber'
|| part.tool === 'openchamber_web'
|| part.tool === 'openchamber_memory'
|| part.tool === 'apply_patch'
|| part.tool === 'edit'
|| part.tool === 'multiedit';
@@ -59,6 +59,9 @@ export const getToolIcon = (toolName: string) => {
if (tool === 'openchamber_web') {
return <Icon name="global" className={iconClass} />;
}
if (tool === 'openchamber_memory') {
return <Icon name="brain-4" className={iconClass} />;
}
if (tool === 'question') {
return <Icon name="survey" className={iconClass} />;
}
@@ -5,6 +5,12 @@ 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 { 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';
@@ -43,6 +49,54 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
void loadSkills();
}, [directory, loadSkills]);
/**
* What the project sends along with every message. 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.
*/
const [knowledge, setKnowledge] = React.useState<SessionKnowledgeSummary>(
{ 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.
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) => {
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]);
// 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]);
const unpinPlan = React.useCallback((planId: string) => {
if (projectRef) void setPlanPinned(projectRef, planId, false);
}, [projectRef, setPlanPinned]);
const memoryCount = knowledge.memory.global + knowledge.memory.project;
const pinnedCount = knowledge.notes.length + knowledge.plans.length;
const linked = React.useMemo(() => getLinkedIssues(session), [session]);
// Connected servers only. A disabled server contributes nothing to the
// context, so counting it here contradicts the MCP section right above,
@@ -52,9 +106,14 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
[mcpStatus],
);
useReportWorkStatusPresence('context-sources', linked.length > 0 || skills.length > 0 || mcpCount > 0);
useReportWorkStatusPresence(
'context-sources',
linked.length > 0 || skills.length > 0 || mcpCount > 0 || pinnedCount > 0 || memoryCount > 0,
);
if (linked.length === 0 && skills.length === 0 && mcpCount === 0) return null;
if (linked.length === 0 && skills.length === 0 && mcpCount === 0 && pinnedCount === 0 && memoryCount === 0) {
return null;
}
// The heading names what is distinctive about this session when there is
// something — an attached thread — and falls back to the ambient counts
@@ -72,6 +131,14 @@ 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.
if (summaryParts.length === 0 && pinnedCount > 0) {
summaryParts.push(pinnedCount === 1
? t('chat.workStatus.breakdown.pinnedKnowledgeSingle', { count: pinnedCount })
: t('chat.workStatus.breakdown.pinnedKnowledgePlural', { count: pinnedCount }));
}
if (summaryParts.length === 0) {
if (skills.length > 0) {
summaryParts.push(skills.length === 1
@@ -115,6 +182,63 @@ 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. */}
{/* 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) => (
<WorkStatusRow
key={note.id}
muted
leading={(
<button
type="button"
disabled={!projectRef}
aria-label={t('chat.workStatus.breakdown.unpin')}
onClick={(event) => {
event.stopPropagation();
unpinNote(note.id);
}}
className="shrink-0 rounded p-0.5 transition-opacity hover:opacity-70 disabled:opacity-40"
>
<Icon name="pushpin-2-fill" className="size-3.5" style={{ color: 'var(--primary)' }} />
</button>
)}
label={note.body.trim().split('\n')[0] || note.body.trim()}
value={<WorkStatusValue tone="muted">{t('chat.workStatus.breakdown.pinnedNote')}</WorkStatusValue>}
/>
))}
{knowledge.plans.map((plan) => (
<WorkStatusRow
key={plan.id}
muted
leading={(
<button
type="button"
disabled={!projectRef}
aria-label={t('chat.workStatus.breakdown.unpin')}
onClick={(event) => {
event.stopPropagation();
unpinPlan(plan.id);
}}
className="shrink-0 rounded p-0.5 transition-opacity hover:opacity-70 disabled:opacity-40"
>
<Icon name="pushpin-2-fill" className="size-3.5" style={{ color: 'var(--primary)' }} />
</button>
)}
label={plan.title}
value={<WorkStatusValue tone="muted">{t('chat.workStatus.breakdown.pinnedPlan')}</WorkStatusValue>}
/>
))}
{memoryCount > 0 ? (
<WorkStatusRow
muted
label={t('chat.workStatus.breakdown.memory')}
value={<WorkStatusValue>{memoryCount}</WorkStatusValue>}
/>
) : null}
<WorkStatusRow
muted
label={t('chat.workStatus.breakdown.skills')}
@@ -28,10 +28,12 @@ export const iconSpriteData = {
"bar-chart-2": `<path d="M2 13H8V21H2V13ZM16 8H22V21H16V8ZM9 3H15V21H9V3ZM4 15V19H6V15H4ZM11 5V19H13V5H11ZM18 10V19H20V10H18Z" fill="currentColor"/>`,
"bar-chart-box": `<path d="M3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3ZM4 5V19H20V5H4ZM7 13H9V17H7V13ZM11 7H13V17H11V7ZM15 10H17V17H15V10Z" fill="currentColor"/>`,
"book": `<path d="M3 18.5V5C3 3.34315 4.34315 2 6 2H20C20.5523 2 21 2.44772 21 3V21C21 21.5523 20.5523 22 20 22H6.5C4.567 22 3 20.433 3 18.5ZM19 20V17H6.5C5.67157 17 5 17.6716 5 18.5C5 19.3284 5.67157 20 6.5 20H19ZM5 15.3368C5.45463 15.1208 5.9632 15 6.5 15H19V4H6C5.44772 4 5 4.44772 5 5V15.3368Z" fill="currentColor"/>`,
"book-marked": `<path d="M3 18.5V5C3 3.34315 4.34315 2 6 2H20C20.5523 2 21 2.44772 21 3V21C21 21.5523 20.5523 22 20 22H6.5C4.567 22 3 20.433 3 18.5ZM19 20V17H6.5C5.67157 17 5 17.6716 5 18.5C5 19.3284 5.67157 20 6.5 20H19ZM10 4H6C5.44772 4 5 4.44772 5 5V15.3368C5.45463 15.1208 5.9632 15 6.5 15H19V4H17V12L13.5 10L10 12V4Z" fill="currentColor"/>`,
"book-open": `<path d="M13 21V23H11V21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H9C10.1947 3 11.2671 3.52375 12 4.35418C12.7329 3.52375 13.8053 3 15 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H13ZM20 19V5H15C13.8954 5 13 5.89543 13 7V19H20ZM11 19V7C11 5.89543 10.1046 5 9 5H4V19H11Z" fill="currentColor"/>`,
"booklet": `<path d="M20.0049 2C21.1068 2 22 2.89821 22 3.9908V20.0092C22 21.1087 21.1074 22 20.0049 22H4V18H2V16H4V13H2V11H4V8H2V6H4V2H20.0049ZM8 4H6V20H8V4ZM20 4H10V20H20V4Z" fill="currentColor"/>`,
"braces": `<path d="M4 18V14.3C4 13.4716 3.32843 12.8 2.5 12.8H2V11.2H2.5C3.32843 11.2 4 10.5284 4 9.7V6C4 4.34315 5.34315 3 7 3H8V5H7C6.44772 5 6 5.44772 6 6V10.1C6 10.9858 5.42408 11.7372 4.62623 12C5.42408 12.2628 6 13.0142 6 13.9V18C6 18.5523 6.44772 19 7 19H8V21H7C5.34315 21 4 19.6569 4 18ZM20 14.3V18C20 19.6569 18.6569 21 17 21H16V19H17C17.5523 19 18 18.5523 18 18V13.9C18 13.0142 18.5759 12.2628 19.3738 12C18.5759 11.7372 18 10.9858 18 10.1V6C18 5.44772 17.5523 5 17 5H16V3H17C18.6569 3 20 4.34315 20 6V9.7C20 10.5284 20.6716 11.2 21.5 11.2H22V12.8H21.5C20.6716 12.8 20 13.4716 20 14.3Z" fill="currentColor"/>`,
"brain": `<path d="M9 4C10.1046 4 11 4.89543 11 6V12.8271C10.1058 12.1373 8.96602 11.7305 7.6644 11.5136L7.3356 13.4864C8.71622 13.7165 9.59743 14.1528 10.1402 14.7408C10.67 15.3147 11 16.167 11 17.5C11 18.8807 9.88071 20 8.5 20C7.11929 20 6 18.8807 6 17.5V17.1493C6.43007 17.2926 6.87634 17.4099 7.3356 17.4864L7.6644 15.5136C6.92149 15.3898 6.1752 15.1144 5.42909 14.7599C4.58157 14.3573 4 13.499 4 12.5C4 11.6653 4.20761 11.0085 4.55874 10.5257C4.90441 10.0504 5.4419 9.6703 6.24254 9.47014L7 9.28078V6C7 4.89543 7.89543 4 9 4ZM12 3.35418C11.2671 2.52376 10.1947 2 9 2C6.79086 2 5 3.79086 5 6V7.77422C4.14895 8.11644 3.45143 8.64785 2.94126 9.34933C2.29239 10.2415 2 11.3347 2 12.5C2 14.0652 2.79565 15.4367 4 16.2422V17.5C4 19.9853 6.01472 22 8.5 22C9.91363 22 11.175 21.3482 12 20.3287C12.825 21.3482 14.0864 22 15.5 22C17.9853 22 20 19.9853 20 17.5V16.2422C21.2044 15.4367 22 14.0652 22 12.5C22 11.3347 21.7076 10.2415 21.0587 9.34933C20.5486 8.64785 19.8511 8.11644 19 7.77422V6C19 3.79086 17.2091 2 15 2C13.8053 2 12.7329 2.52376 12 3.35418ZM18 17.1493V17.5C18 18.8807 16.8807 20 15.5 20C14.1193 20 13 18.8807 13 17.5C13 16.167 13.33 15.3147 13.8598 14.7408C14.4026 14.1528 15.2838 13.7165 16.6644 13.4864L16.3356 11.5136C15.034 11.7305 13.8942 12.1373 13 12.8271V6C13 4.89543 13.8954 4 15 4C16.1046 4 17 4.89543 17 6V9.28078L17.7575 9.47014C18.5581 9.6703 19.0956 10.0504 19.4413 10.5257C19.7924 11.0085 20 11.6653 20 12.5C20 13.499 19.4184 14.3573 18.5709 14.7599C17.8248 15.1144 17.0785 15.3898 16.3356 15.5136L16.6644 17.4864C17.1237 17.4099 17.5699 17.2926 18 17.1493Z" fill="currentColor"/>`,
"brain-4": `<path d="M19.5 4.7832V7.6709L22 9.11426V14.8867L19.499 16.3311L19.5 19.2178L14.5 22.1045L12 20.6611L9.5 22.1045L4.5 19.2178V16.3311L2 14.8877L2.00098 9.11328L4.5 7.66992V4.78418L9.5 1.89746L11.999 3.34082L14.501 1.89648L19.5 4.7832ZM13 5.07227L12.999 8.42285L15.9639 10.1338L14.9639 11.8662L11 9.57715V5.07324L9.5 4.20703L6.49902 5.93848V8.8252L4 10.2676V13.7334L6.5 15.1768V18.0635L9.5 19.7959L11 18.9287L11.001 15.5771L8.03613 13.8652L9.03613 12.1338L13.001 14.4229V18.9297L14.5 19.7959L17.5 18.0625V15.1768L20 13.7324V10.2695L17.499 8.8252L17.5 5.9375L14.501 4.20605L13 5.07227Z" fill="currentColor"/>`,
"brain-ai-3": `<path d="M19.5 4.7832V7.6709L22 9.11426V14.8867L19.499 16.3311L19.5 19.2178L14.5 22.1045L12 20.6611L9.5 22.1045L4.5 19.2178V16.3311L2 14.8877L2.00098 9.11328L4.5 7.66992V4.78418L9.5 1.89746L11.999 3.34082L14.501 1.89648L19.5 4.7832ZM13 5.07227V7H11V5.07324L9.5 4.20703L6.49902 5.93848V8.8252L4 10.2676V13.7334L6.5 15.1768V18.0635L9.5 19.7959L11 18.9287V17H13V18.9297L14.5 19.7959L17.5 18.0625V15.1768L20 13.7324V10.2695L17.499 8.8252L17.5 5.9375L14.501 4.20605L13 5.07227ZM14.2646 13.1602C14.3529 12.9473 14.6472 12.9473 14.7354 13.1602L14.8623 13.4648C15.0783 13.986 15.4807 14.4027 15.9873 14.6279L16.3457 14.7871C16.5511 14.8784 16.5511 15.1773 16.3457 15.2686L15.9658 15.4375C15.4721 15.6571 15.0761 16.0586 14.8564 16.5625L14.7334 16.8447C14.6432 17.0517 14.3569 17.0517 14.2666 16.8447L14.1436 16.5625C13.9239 16.0586 13.5279 15.6571 13.0342 15.4375L12.6543 15.2686C12.4489 15.1773 12.4489 14.8784 12.6543 14.7871L13.0127 14.6279C13.5193 14.4027 13.9217 13.986 14.1377 13.4648L14.2646 13.1602ZM9.58789 7.7793C9.74239 7.40671 10.2577 7.4067 10.4121 7.7793L10.6338 8.31445C11.0118 9.22695 11.7161 9.95624 12.6025 10.3506L13.2305 10.6289C13.5899 10.7887 13.5897 11.3117 13.2305 11.4717L12.5654 11.7676C11.7013 12.152 11.0086 12.8548 10.624 13.7373L10.4082 14.2324C10.2504 14.5948 9.74973 14.5948 9.5918 14.2324L9.37598 13.7373C8.99143 12.8548 8.29875 12.152 7.43457 11.7676L6.76953 11.4717C6.41033 11.3117 6.41022 10.7887 6.76953 10.6289L7.39746 10.3506C8.2839 9.95624 8.98832 9.22697 9.36621 8.31445L9.58789 7.7793Z" fill="currentColor"/>`,
"briefcase": `<path d="M7 5V2C7 1.44772 7.44772 1 8 1H16C16.5523 1 17 1.44772 17 2V5H21C21.5523 5 22 5.44772 22 6V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V6C2 5.44772 2.44772 5 3 5H7ZM4 16V19H20V16H4ZM4 14H20V7H4V14ZM9 3V5H15V3H9ZM11 11H13V13H11V11Z" fill="currentColor"/>`,
"bug": `<path d="M13 19.9C15.2822 19.4367 17 17.419 17 15V12C17 11.299 16.8564 10.6219 16.5846 10H7.41538C7.14358 10.6219 7 11.299 7 12V15C7 17.419 8.71776 19.4367 11 19.9V14H13V19.9ZM5.5358 17.6907C5.19061 16.8623 5 15.9534 5 15H2V13H5V12C5 11.3573 5.08661 10.7348 5.2488 10.1436L3.0359 8.86602L4.0359 7.13397L6.05636 8.30049C6.11995 8.19854 6.18609 8.09835 6.25469 8H17.7453C17.8139 8.09835 17.88 8.19854 17.9436 8.30049L19.9641 7.13397L20.9641 8.86602L18.7512 10.1436C18.9134 10.7348 19 11.3573 19 12V13H22V15H19C19 15.9534 18.8094 16.8623 18.4642 17.6907L20.9641 19.134L19.9641 20.866L17.4383 19.4077C16.1549 20.9893 14.1955 22 12 22C9.80453 22 7.84512 20.9893 6.56171 19.4077L4.0359 20.866L3.0359 19.134L5.5358 17.6907ZM8 6C8 3.79086 9.79086 2 12 2C14.2091 2 16 3.79086 16 6H8Z" fill="currentColor"/>`,
@@ -940,7 +940,7 @@ export const ContextPanel: React.FC = () => {
: activeTab?.mode === 'notes'
? <ProjectContextPanel />
: activeTab?.mode === 'plan'
? <React.Suspense fallback={null}><PlanView targetPath={activeTab.targetPath} /></React.Suspense>
? <React.Suspense fallback={null}><PlanView targetPath={activeTab.targetPath} projectPlanId={activeTab.projectPlanId} /></React.Suspense>
: null;
const browserTabs = React.useMemo(
@@ -1,6 +1,6 @@
import React from 'react';
import { ProjectNotesTodoPanel } from '@/components/session/ProjectNotesTodoPanel';
import { ProjectNotesTodoPanel } from '@/components/session/project-context/ProjectNotesTodoPanel';
import { useGitStore } from '@/stores/useGitStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
@@ -8,7 +8,7 @@ import { formatDirectoryName } from '@/lib/utils';
export const ProjectContextPanel: React.FC<{
onActionComplete?: () => void;
onOpenPlan?: (plan: { path: string; title: string }) => void;
onOpenPlan?: (plan: { id: string; title: string }) => void;
}> = ({ onActionComplete, onOpenPlan }) => {
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const projects = useProjectsStore((state) => state.projects);
@@ -49,7 +49,8 @@ export const ProjectContextPanel: React.FC<{
}, [activeProject, gitDirectories]);
return (
<div className="h-full min-h-0 overflow-auto bg-background">
/* The panel scrolls its own tab content; a scroller here would nest. */
<div className="h-full min-h-0 overflow-hidden bg-background">
<ProjectNotesTodoPanel
projectRef={projectRef}
projectLabel={projectLabel}
@@ -7,6 +7,7 @@ import {
} from '@/components/sections/shared/SettingsSection';
import { recordDeferredOpenCodeRestart } from '@/lib/opencode/deferredRestart';
import { updateDesktopSettings } from '@/lib/persistence';
import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
import { useUIStore } from '@/stores/useUIStore';
import { useI18n } from '@/lib/i18n';
@@ -27,6 +28,11 @@ export const OpenChamberToolsSettings: React.FC = () => {
const setAgentControlToolEnabled = useUIStore((state) => state.setAgentControlToolEnabled);
const agentWebToolEnabled = useUIStore((state) => state.agentWebToolEnabled);
const setAgentWebToolEnabled = useUIStore((state) => state.setAgentWebToolEnabled);
const agentMemoryToolEnabled = useUIStore((state) => state.agentMemoryToolEnabled);
// Absent, not merely off: the feature is finished but unreleased, and a
// visible switch invites turning on something that was never announced.
const agentMemoryAvailable = useUIStore((state) => state.agentMemoryFeatureAvailable);
const setAgentMemoryToolEnabled = useUIStore((state) => state.setAgentMemoryToolEnabled);
const handleAgentControlToolChange = React.useCallback((enabled: boolean) => {
setAgentControlToolEnabled(enabled);
@@ -40,6 +46,24 @@ export const OpenChamberToolsSettings: React.FC = () => {
recordDeferredOpenCodeRestart('cli', { id: 'agent-web-tool' });
}, [setAgentWebToolEnabled]);
// Turning memory off removes the whole feature, not just the tool: the panel
// tab goes with it and sessions stop being given the index. Showing the user
// what is stored would be pointless once the agent can no longer manage it.
const handleAgentMemoryToolChange = React.useCallback((enabled: boolean) => {
setAgentMemoryToolEnabled(enabled);
// Re-read after the write lands, not before. The switch flips the client
// immediately, which makes the panel ask the server straight away — and
// while the setting is still being written the server truthfully answers
// "disabled", which used to leave the tab hidden until a restart.
void updateDesktopSettings({ agentMemoryToolEnabled: enabled })
.finally(() => {
if (enabled) {
void useAgentMemoryStore.getState().refresh();
}
});
recordDeferredOpenCodeRestart('cli', { id: 'agent-memory-tool' });
}, [setAgentMemoryToolEnabled]);
return (
<SettingsSection title={t('settings.openchamber.tools.title')}>
<div className={SETTINGS_OPTION_STACK_CLASS}>
@@ -60,6 +84,17 @@ export const OpenChamberToolsSettings: React.FC = () => {
ariaLabel={t('settings.openchamber.tools.field.agentWebToolAria')}
info={t('settings.openchamber.tools.field.agentWebToolInfo')}
/>
{agentMemoryAvailable ? (
<SettingsCheckboxRow
settingsItem="sessions.agent-memory-tool"
checked={agentMemoryToolEnabled}
onChange={handleAgentMemoryToolChange}
label={t('settings.openchamber.tools.field.agentMemoryTool')}
ariaLabel={t('settings.openchamber.tools.field.agentMemoryToolAria')}
info={t('settings.openchamber.tools.field.agentMemoryToolInfo')}
/>
) : null}
</div>
</SettingsSection>
);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,224 @@
# Project Context Panel
Notes, todos, saved plans, and agent memory for the active project. Rendered by
the `notes` surface in the desktop context rail and by the mobile workspace
drawer.
## Files
| File | Owns |
|---|---|
| `ProjectNotesTodoPanel.tsx` | container: store subscription, load, failure toast, section sidebar, search query, the todo write |
| `NotesSection.tsx` | note composer, note list, per-note edit/pin/delete |
| `TodosSection.tsx` | todo list, add/toggle/delete/clear, drag reorder, list resize |
| `PlansSection.tsx` | plan list, import, pin, delete, open |
| `MemorySection.tsx` | agent memory list, project/global scope switch, new/changed badges, edit, forget |
| `KnowledgeCard.tsx` | the shared card shell and expand interaction every entry list uses |
| `useProjectTodoSend.ts` | sending a todo to a current/new/worktree session |
## Layout
Content on the left, a section sidebar on the right with a drag-to-resize edge —
the same arrangement the files surface uses, so the two panels do not disagree
about where navigation lives. The sections were a horizontal tab strip until four of them stopped
fitting: a strip has one line of width to divide, and each section added took
width from the rest, while a vertical list grows downwards where there is room.
The surface's default width matches the files surface for the same reason; at a
third of the window the content column is too narrow to read a note in.
Search shares the title row rather than owning one of its own: it filters what
is already on screen, and a full-width field read as the panel's primary control.
It stays above both columns. Sections divide, and search is the one thing
that division would hurt — you do not always remember whether something was
written as a note or lives in a plan — so each sidebar entry carries its own
match count.
## One card, one interaction
Every entry list renders `KnowledgeCard`. Notes and memories had drifted into
two different-looking rows in the same panel — one a bare block of text opened by
clicking the text, the other a bordered card opened by a chevron — which is the
kind of split that makes a panel feel unfinished regardless of how either half
behaves.
A collapsed card opens on a click anywhere on it. An expanded card closes only
through its collapse action, because its body is editable and a stray click in
the text must not throw the editor away.
## Plans open in place
Clicking a plan replaces the list with its editor, and the back control appears
in the panel header beside the project name — PlanView titles the plan itself, so
a title row above it would say the same thing twice. A plan belongs to the project this
panel is about, and sending the reader to another tab to read it made them leave
the surface they were browsing.
The editor is `PlanView`, lazily imported — it is a large view and most panel
visits never open one. It scrolls itself, so the content column stops scrolling
while a plan is open; two scrollbars for one document is what nesting them gives.
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
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.
## Memory is not a fifth kind of note
The first four tabs hold what the user wrote. Memory holds what the **agent**
wrote for itself, in its own store (`packages/web/server/lib/agent-memory`) and
through its own client (`useAgentMemoryStore`). They share the panel and nothing
else — keeping the stores apart is what stops an agent mistake from landing in
the user's notes.
Two consequences shape this tab:
- **Entries are editable.** A memory worded badly enough to mislead should be
fixable where it is read; deleting it and hoping the agent learns it again,
better, is not a repair. The agent rewrites by saving the same memory again,
so `PATCH` exists for the panel alone.
- **Nothing gates the agent, and nothing asks the user to click.** An earlier
version had a confirm button. It was theatre: the agent already had the
memory whether or not the button was pressed, so the click bought the user
nothing. Entries now carry `new` and `changed` badges derived from
`createdAt` / `updatedAt` against a per-scope "last looked" mark, and looking
at the tab is the acknowledgement. Nothing about review is stored server-side.
- **The scopes are a switch, never one merged list.** A claim about the user
reaches every project, so which store an entry sits in is the most important
thing about it and must not be something the reader has to infer. The switch
is a chip group, not a tab strip: it picks which store you are reading, not
which view you are in, and the pressed state reads plainly against the
panel background.
The mark is frozen while the tab is open and advanced on the way out, or every
badge would clear the instant the tab appeared — the one moment the user is
trying to read them. Each project keeps its own mark, so opening one project
cannot silently clear another's badges.
The store is loaded by `useAgentMemorySync` in `App.tsx` and reloads on
`openchamber:agent-memory-changed`, because the agent writes mid-turn through
its own tool. It feeds this panel only — what a session is told about memory is
decided server-side by `packages/web/server/lib/session-knowledge`, so it
reaches sessions that have no UI at all and survives compaction.
Both sides resolve a worktree to its project before touching the store — the
client through `resolveProjectForSessionDirectory`, the server through
`agent-memory/project-resolution`. Keying by the session directory instead filed
a worktree's memories under a project nothing reads.
Turning the switch back on re-reads the store only after the setting has
finished being written. The switch flips the client immediately, which makes the
panel ask the server straight away — and mid-write the server truthfully answers
"disabled", which used to latch the tab hidden until a restart. Loads are also
sequenced, so that stale answer cannot land after the good one.
`agentMemoryToolEnabled` is one switch for the whole feature: it removes the
tool from the agent, this tab from the panel, and the index from new sessions.
The tab also hides when the server reports the surface disabled, so a stale
client cannot keep showing memory that is off. A persisted `memory` tab
selection falls back to `notes` rather than opening a tab that no longer exists.
## Data flow
Storage is server-owned; see
`packages/web/server/lib/project-context/DOCUMENTATION.md`. The panel never
touches `/api/fs/*` and never handles a plan path — plans are addressed by id.
```
useProjectContextStore -> ProjectNotesTodoPanel -> sections
(server cache) (load + shared write)
```
There is deliberately no cross-panel event. An earlier version broadcast
`openchamber:project-notes-updated` / `openchamber:project-plan-saved` on the
window and every mounted panel re-read the whole config in response. Writers now
mutate the store and readers re-render from it.
## Where writes live
Notes, todos, and plans each have their own routes, so each section owns its
writes end to end and no section has to persist a neighbour's state alongside
its own. `NotesSection` and `PlansSection` call the store directly. Todos still
route through the container only because the container already holds the list it
sorts for display.
An earlier version wrote notes and todos together in one request. That forced
the container to own the notes draft, because otherwise a todo toggle would
persist whatever notes were last committed and discard unsaved typing. Splitting
the routes removed the coupling rather than managing it.
## Layout
The three lists are tabs, not one stacked column. Stacking gave each list its
own scroller inside the panel's scroller, and it only got worse as lists grew —
the todo list had to carry a manual resize handle just to stay usable. With
tabs there is exactly one scroller: the panel's. The resize handle and its
persisted `todoPanelHeight` are gone with it, and each section renders its list
at natural height.
The host (`RightSidebarTabs`) therefore sets `overflow-hidden`; putting a
scroller there again would nest one inside the other.
Section headers no longer repeat their own name or count — the tab carries both.
The active tab persists in `useUIStore` so switching surfaces or remounting the
panel returns to where the user was.
## Search
One query in the container filters all three tabs, and the tab bar doubles as
the result summary: each tab shows its match count. Tabs divide, and search is
the one thing division would hurt — you do not always remember whether
something was written as a note or lives in a plan — so search deliberately
stays above the tabs rather than becoming per-tab.
If the active tab has no matches and another does, the panel follows the search
there. Without that, typing a query whose hits live elsewhere shows an empty
list and the user has to guess which tab to try.
Filtering is display-only: every mutation still acts on the full list, so
reordering or clearing completed todos while a filter is active cannot drop
hidden items. The query resets when the project changes, since a query that
matched the old project would silently hide everything in the new one.
## Invariants
- **Each note row keeps a local, debounced draft.** Writing on every keystroke
would put a request behind every character, and re-reading the store each
render would fight the caret.
- **An external note change is adopted only while that row is untouched** since
its last save. "Add to notes" from a chat selection must reach an open panel,
but must never overwrite what the user is typing.
- **Only one note is expanded at a time, and collapsed notes are clamped.**
Notes run to 3000 characters each; with the panel owning the only scroller,
unbounded rows turn the tab into one unbroken wall of text. A collapsed note
shows a three-line preview and expands into its editor on click.
- **A blanked note body is never persisted.** The server rejects it, so the row
restores its last saved text on blur rather than showing a phantom failure.
Deleting is an explicit action.
- **A load failure never blanks the panel.** The store keeps the last good
snapshot; the panel toasts once, and only when nothing had loaded yet.
- **Completed todos sink to the bottom for display only.** Stored order is what
the user dragged.
- **Plan creation is not optimistic.** The id and file name come from the
server, and a row that cannot be opened is worse than a brief wait.
## 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.
## Related
- Store: `packages/ui/src/stores/useProjectContextStore.ts`
- HTTP client: `packages/ui/src/lib/projectContextApi.ts`
- Plan viewer/editor: `packages/ui/src/components/views/PlanView.tsx`
- User docs: `packages/docs/content/docs/notes-todos-plans.mdx`
@@ -0,0 +1,82 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
/**
* One entry in any project knowledge list.
*
* Notes and memories drifted into two different-looking rows in the same panel:
* one a bare block of text opened by clicking the text, the other a bordered
* card opened by a chevron. They hold different content but they are the same
* kind of thing to read, so they share this shell and this interaction.
*
* A collapsed card opens on a click anywhere on it the whole card is the
* target, not a chevron the user has to aim at. An expanded card closes only
* through its collapse action, because its body is editable and a stray click
* in the text must not throw the editor away.
*/
export const KnowledgeCard: React.FC<{
expanded: boolean;
onToggleExpanded: () => void;
/** Shown above the body: a badge, a title, whatever the section needs. */
header?: React.ReactNode;
/** The preview or the editor, depending on `expanded`. */
children: React.ReactNode;
/** Stacked to the right, so the text keeps the full row width. */
actions?: React.ReactNode;
footer?: React.ReactNode;
expandLabel: string;
}> = ({ expanded, onToggleExpanded, header, children, actions, footer, expandLabel }) => {
const { t } = useI18n();
return (
<li
className={cn(
'flex flex-col gap-1 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-2 py-1.5',
!expanded && 'cursor-pointer hover:border-[var(--interactive-border)] hover:bg-interactive-hover/30',
)}
onClick={expanded ? undefined : onToggleExpanded}
onKeyDown={expanded ? undefined : (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onToggleExpanded();
}
}}
role={expanded ? undefined : 'button'}
tabIndex={expanded ? undefined : 0}
aria-label={expanded ? undefined : expandLabel}
>
<div className="flex min-w-0 items-start gap-2">
<div className="min-w-0 flex-1">
{header}
{children}
</div>
{/* Stopped here rather than on each control: every action is a click on
the card too, and without this each one would also toggle it. */}
<div
className="flex flex-shrink-0 flex-col items-center gap-0.5"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
{expanded ? (
<button
type="button"
onClick={onToggleExpanded}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:bg-interactive-hover/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('rightSidebar.contextNotesTodo.notes.actions.collapse')}
title={t('rightSidebar.contextNotesTodo.notes.actions.collapse')}
>
<Icon name="arrow-up-s" className="h-3.5 w-3.5" />
</button>
) : null}
{actions}
</div>
</div>
{footer ? <div className="min-w-0">{footer}</div> : null}
</li>
);
};
@@ -0,0 +1,277 @@
import React from 'react';
import { toast } from '@/components/ui';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { KnowledgeCard } from './KnowledgeCard';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import { AGENT_MEMORY_BODY_MAX_LENGTH, AGENT_MEMORY_TITLE_MAX_LENGTH, type AgentMemoryEntry, type AgentMemoryScope } from '@/lib/agentMemoryApi';
import { classifyMemory, memoryViewKey, type MemoryBadge } from '@/lib/agentMemoryBadges';
import { cn } from '@/lib/utils';
import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
import { useUIStore } from '@/stores/useUIStore';
/**
* One stored memory.
*
* Read-only text on purpose: this is what the agent wrote, and the useful
* action on someone else's claim is to remove it, not to quietly rewrite it
* into something the agent will contradict next session.
*
* There is no confirm button. A badge that the user has to dismiss by hand asks
* them to do work that tells the agent nothing the agent already has the
* memory either way so the badge clears itself once they have looked.
*/
const MemoryRow: React.FC<{
entry: AgentMemoryEntry;
badge: MemoryBadge;
expanded: boolean;
onToggleExpanded: () => void;
onSave: (patch: { title?: string; body?: string }) => void;
onDelete: () => void;
}> = ({ entry, badge, expanded, onToggleExpanded, onSave, onDelete }) => {
const { t } = useI18n();
const [titleDraft, setTitleDraft] = React.useState(entry.title);
const [bodyDraft, setBodyDraft] = React.useState(entry.body);
// Adopt an external rewrite only while this row is not being edited, so the
// agent saving mid-edit cannot swallow what the user is typing.
React.useEffect(() => {
if (expanded) return;
setTitleDraft(entry.title);
setBodyDraft(entry.body);
}, [entry.body, entry.title, expanded]);
const commit = React.useCallback(() => {
const title = titleDraft.trim();
const body = bodyDraft.trim();
// An emptied field is a rejected write, not a delete: restore it rather
// than sending something the server will refuse.
if (!title || !body) {
setTitleDraft(entry.title);
setBodyDraft(entry.body);
return;
}
if (title === entry.title && body === entry.body) {
return;
}
onSave({ title, body });
}, [bodyDraft, entry.body, entry.title, onSave, titleDraft]);
const typeLabel = t(`rightSidebar.contextNotesTodo.memory.type.${entry.type}` as Parameters<typeof t>[0]);
return (
<KnowledgeCard
expanded={expanded}
onToggleExpanded={() => {
if (expanded) commit();
onToggleExpanded();
}}
expandLabel={entry.title}
footer={(
<span className="flex flex-wrap items-center gap-x-2 typography-micro text-muted-foreground">
{typeLabel}
{entry.flagged ? (
// Shown rather than hidden: an entry withheld from the agent is
// exactly the one the user needs to look at.
<span className="flex items-center gap-1 text-[var(--status-error)]">
<Icon name="error-warning" className="h-3 w-3 flex-shrink-0" />
{t('rightSidebar.contextNotesTodo.memory.flagged')}
</span>
) : null}
</span>
)}
header={badge ? (
<span
className={cn(
'mb-0.5 mr-1.5 inline-block rounded-full px-1.5 py-px typography-micro font-medium',
badge === 'new'
? 'bg-[var(--status-success)]/15 text-[var(--status-success)]'
: 'bg-[var(--status-warning)]/15 text-[var(--status-warning)]',
)}
>
{t(badge === 'new'
? 'rightSidebar.contextNotesTodo.memory.badge.new'
: 'rightSidebar.contextNotesTodo.memory.badge.changed')}
</span>
) : null}
actions={(
<button
type="button"
onClick={onDelete}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:bg-interactive-hover/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('rightSidebar.contextNotesTodo.memory.actions.delete')}
title={t('rightSidebar.contextNotesTodo.memory.actions.delete')}
>
<Icon name="delete-bin" className="h-3.5 w-3.5" />
</button>
)}
>
{expanded ? (
// Editable on purpose. A memory worded badly enough to mislead should
// be fixable where it is read; deleting it and hoping the agent learns
// it again, better, is not a repair.
<div className="flex flex-col gap-1">
<Input
value={titleDraft}
onChange={(event) => setTitleDraft(event.target.value.slice(0, AGENT_MEMORY_TITLE_MAX_LENGTH))}
onBlur={commit}
aria-label={t('rightSidebar.contextNotesTodo.memory.actions.editTitle')}
className="h-7 typography-ui-label"
/>
<Textarea
simple
rows={Math.min(20, Math.max(3, bodyDraft.split('\n').length + 1))}
value={bodyDraft}
onChange={(event) => setBodyDraft(event.target.value.slice(0, AGENT_MEMORY_BODY_MAX_LENGTH))}
onBlur={commit}
aria-label={t('rightSidebar.contextNotesTodo.memory.actions.editBody')}
className="min-h-0 w-full resize-none bg-transparent p-0 typography-meta leading-normal text-muted-foreground focus-visible:outline-none focus-visible:ring-0"
/>
</div>
) : (
<>
<span className="block min-w-0 truncate typography-ui-label text-foreground">{entry.title}</span>
<p className="line-clamp-2 whitespace-pre-wrap break-words typography-meta text-muted-foreground">
{entry.body}
</p>
</>
)}
</KnowledgeCard>
);
};
/**
* What the agent has chosen to remember, in the two scopes it writes to.
*
* The scopes are a switch rather than one merged list: a claim about the user
* reaches every project, so which store a memory sits in is the most important
* thing about it and must never be something the reader has to infer.
*/
export const MemorySection: React.FC<{
projectPath: string | null;
query: string;
}> = ({ projectPath, query }) => {
const { t } = useI18n();
const [scope, setScope] = React.useState<AgentMemoryScope>('project');
const [expandedId, setExpandedId] = React.useState<string | null>(null);
const globalEntries = useAgentMemoryStore((state) => state.global);
const projectEntries = useAgentMemoryStore((state) => state.project);
const globalFailed = useAgentMemoryStore((state) => state.globalFailed);
const projectFailed = useAgentMemoryStore((state) => state.projectFailed);
const deleteEntry = useAgentMemoryStore((state) => state.deleteEntry);
const saveEntry = useAgentMemoryStore((state) => state.saveEntry);
const markViewed = useUIStore((state) => state.markAgentMemoryViewed);
const entries = scope === 'global' ? globalEntries : projectEntries;
const scopeFailed = scope === 'global' ? globalFailed : projectFailed;
const viewKey = memoryViewKey(scope, projectPath);
const storedViewedAt = useUIStore((state) => state.agentMemoryViewedAt[viewKey] ?? 0);
/**
* The mark is frozen for the length of the visit and only advanced on the way
* out. Reading the live value would clear every badge the instant the tab
* opened, which is the one moment the user is trying to read them.
*/
const baselineRef = React.useRef(storedViewedAt);
const [baseline, setBaseline] = React.useState(storedViewedAt);
React.useEffect(() => {
baselineRef.current = useUIStore.getState().agentMemoryViewedAt[viewKey] ?? 0;
setBaseline(baselineRef.current);
return () => {
markViewed(viewKey, Date.now());
};
}, [markViewed, viewKey]);
const visibleEntries = React.useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return entries;
return entries.filter((entry) => (
entry.title.toLowerCase().includes(needle) || entry.body.toLowerCase().includes(needle)
));
}, [entries, query]);
const handleDelete = React.useCallback(async (memoryId: string) => {
if (!await deleteEntry(scope, memoryId)) {
const detail = useAgentMemoryStore.getState().error;
toast.error(
t('rightSidebar.contextNotesTodo.memory.toast.deleteFailed'),
detail ? { description: detail } : undefined,
);
}
}, [deleteEntry, scope, t]);
const handleSave = React.useCallback(async (memoryId: string, patch: { title?: string; body?: string }) => {
if (!await saveEntry(scope, memoryId, patch)) {
const detail = useAgentMemoryStore.getState().error;
toast.error(
t('rightSidebar.contextNotesTodo.memory.toast.saveFailed'),
detail ? { description: detail } : undefined,
);
}
}, [saveEntry, scope, t]);
const scopeOptions: Array<{ id: AgentMemoryScope; label: string; count: number }> = [
{ id: 'project', label: t('rightSidebar.contextNotesTodo.memory.scope.project'), count: projectEntries.length },
{ id: 'global', label: t('rightSidebar.contextNotesTodo.memory.scope.global'), count: globalEntries.length },
];
return (
<div className="flex flex-col gap-2">
{/* Chips rather than a tab strip: these pick which store you are reading,
not which view you are in, and the chip's pressed state says which one
is selected far more plainly than a pill sitting on a matching
background did. */}
<div role="group" aria-label={t('rightSidebar.contextNotesTodo.memory.scope.label')} className="flex items-center gap-1">
{scopeOptions.map((option) => (
<Button
key={option.id}
type="button"
variant="chip"
size="xs"
aria-pressed={scope === option.id}
className="!font-normal"
onClick={() => setScope(option.id)}
>
{`${option.label} ${option.count}`}
</Button>
))}
</div>
{scope === 'project' && !projectPath ? (
<p className="typography-meta text-muted-foreground">
{t('rightSidebar.contextNotesTodo.memory.empty.noProject')}
</p>
) : scopeFailed ? (
// Said plainly rather than shown as an empty list: an empty tab would
// read as the agent having forgotten everything it knew.
<p className="typography-meta text-muted-foreground">
{t('rightSidebar.contextNotesTodo.memory.empty.unavailable')}
</p>
) : visibleEntries.length === 0 ? (
<p className="typography-meta text-muted-foreground">
{query.trim()
? t('rightSidebar.contextNotesTodo.memory.empty.noMatches')
: t('rightSidebar.contextNotesTodo.memory.empty.nothing')}
</p>
) : (
<ul className="flex flex-col gap-1.5">
{visibleEntries.map((entry) => (
<MemoryRow
key={entry.id}
entry={entry}
badge={classifyMemory(entry, baseline)}
expanded={expandedId === entry.id}
onToggleExpanded={() => setExpandedId(expandedId === entry.id ? null : entry.id)}
onSave={(patch) => void handleSave(entry.id, patch)}
onDelete={() => void handleDelete(entry.id)}
/>
))}
</ul>
)}
</div>
);
};
@@ -0,0 +1,296 @@
import React from 'react';
import { toast } from '@/components/ui';
import { Icon } from '@/components/icon/Icon';
import { Textarea } from '@/components/ui/textarea';
import { KnowledgeCard } from './KnowledgeCard';
import { useI18n } from '@/lib/i18n';
import { PROJECT_NOTE_BODY_MAX_LENGTH, type ProjectNote, type ProjectRef } from '@/lib/projectContextApi';
import { cn } from '@/lib/utils';
import { useProjectContextStore } from '@/stores/useProjectContextStore';
import { useUIStore } from '@/stores/useUIStore';
const NOTE_SAVE_DEBOUNCE_MS = 400;
/**
* One note, edited in place.
*
* The draft is local and debounced: writing straight through on every keystroke
* would put a request behind every character, and re-reading the store on every
* render would fight the caret. The stored body is adopted only while the
* editor is untouched since its last save, so a concurrent write from another
* surface reaches an idle row without eating an active one.
*/
const NoteRow: React.FC<{
note: ProjectNote;
expanded: boolean;
onToggleExpanded: () => void;
onSaveBody: (body: string) => void;
onTogglePinned: () => void;
onDelete: () => void;
}> = ({ note, expanded, onToggleExpanded, onSaveBody, onTogglePinned, onDelete }) => {
const { t } = useI18n();
const [draft, setDraft] = React.useState(note.body);
const lastSavedRef = React.useRef(note.body);
const debounceRef = React.useRef<number | null>(null);
const cancelDebounce = React.useCallback(() => {
if (debounceRef.current !== null) {
window.clearTimeout(debounceRef.current);
debounceRef.current = null;
}
}, []);
React.useEffect(() => {
if (note.body === lastSavedRef.current) {
return;
}
if (draft !== lastSavedRef.current) {
return;
}
lastSavedRef.current = note.body;
setDraft(note.body);
}, [draft, note.body]);
React.useEffect(() => {
if (draft === lastSavedRef.current) {
return;
}
debounceRef.current = window.setTimeout(() => {
debounceRef.current = null;
// An empty body is a rejected write, not a delete. Leave it unsaved so
// the row stays visible and the user can either restore it or delete it.
if (!draft.trim()) {
return;
}
lastSavedRef.current = draft;
onSaveBody(draft);
}, NOTE_SAVE_DEBOUNCE_MS);
return cancelDebounce;
}, [cancelDebounce, draft, onSaveBody]);
React.useEffect(() => cancelDebounce, [cancelDebounce]);
const handleBlur = React.useCallback(() => {
cancelDebounce();
if (draft === lastSavedRef.current) {
return;
}
if (!draft.trim()) {
// Restore rather than persist a blank: the server rejects it anyway.
setDraft(lastSavedRef.current);
return;
}
lastSavedRef.current = draft;
onSaveBody(draft);
}, [cancelDebounce, draft, onSaveBody]);
const sourceLabel = note.source === 'selection'
? t('rightSidebar.contextNotesTodo.notes.source.selection')
: note.source === 'agent'
? t('rightSidebar.contextNotesTodo.notes.source.agent')
: null;
return (
<KnowledgeCard
expanded={expanded}
onToggleExpanded={onToggleExpanded}
expandLabel={t('rightSidebar.contextNotesTodo.notes.actions.expand')}
footer={sourceLabel ? (
<span className="typography-micro text-muted-foreground">{sourceLabel}</span>
) : null}
actions={(
<>
<button
type="button"
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'
)}
aria-pressed={note.pinned}
aria-label={note.pinned
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
title={note.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" />
</button>
<button
type="button"
onClick={onDelete}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:bg-interactive-hover/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('rightSidebar.contextNotesTodo.notes.actions.delete')}
title={t('rightSidebar.contextNotesTodo.notes.actions.delete')}
>
<Icon name="delete-bin" className="h-3.5 w-3.5" />
</button>
</>
)}
>
{expanded ? (
<Textarea
simple
autoFocus
rows={Math.min(20, Math.max(3, draft.split('\n').length + 1))}
value={draft}
onChange={(event) => setDraft(event.target.value.slice(0, PROJECT_NOTE_BODY_MAX_LENGTH))}
onBlur={handleBlur}
className="min-h-0 w-full resize-none bg-transparent p-0 typography-ui-label leading-normal text-foreground focus-visible:outline-none focus-visible:ring-0"
/>
) : (
<p className="line-clamp-3 whitespace-pre-wrap break-words typography-ui-label leading-normal text-foreground" title={draft}>
{draft}
</p>
)}
</KnowledgeCard>
);
};
/**
* Free-form project notes, one entry per note.
*
* Notes are written through their own routes, so this section owns its writes
* end to end nothing here has to be persisted alongside todos.
*/
export const NotesSection: React.FC<{
projectRef: ProjectRef;
notes: ProjectNote[];
disabled: boolean;
query: string;
}> = ({ projectRef, notes, disabled, query }) => {
const { t } = useI18n();
const [composerText, setComposerText] = React.useState('');
// One at a time on purpose: notes can run to 3000 characters each, and
// letting several stand open turns the tab into one unbroken wall of text.
const [expandedNoteId, setExpandedNoteId] = React.useState<string | null>(null);
const notesPanelHeight = useUIStore((state) => state.notesPanelHeight);
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(() => {
const needle = query.trim().toLowerCase();
if (!needle) return notes;
return notes.filter((note) => note.body.toLowerCase().includes(needle));
}, [notes, query]);
// The store keeps the failure reason; without passing it through, every
// failure looks identical to the user and tells them nothing about the cause.
const reportFailure = React.useCallback((message: string) => {
const detail = useProjectContextStore.getState().getEntry(projectRef).error;
toast.error(message, detail ? { description: detail } : undefined);
}, [projectRef]);
const handleAdd = React.useCallback(async () => {
const body = composerText.trim();
if (!body) {
return;
}
const created = await createNote(projectRef, { body });
if (!created) {
reportFailure(t('rightSidebar.contextNotesTodo.toast.createNoteFailed'));
return;
}
setComposerText('');
}, [composerText, createNote, projectRef, reportFailure, t]);
const handleDelete = React.useCallback(
async (noteId: string) => {
const ok = await deleteNote(projectRef, noteId);
if (!ok) {
reportFailure(t('rightSidebar.contextNotesTodo.toast.deleteNoteFailed'));
}
},
[deleteNote, projectRef, reportFailure, t]
);
const handleTogglePinned = React.useCallback(
async (noteId: string, pinned: boolean) => {
const ok = await setNotePinned(projectRef, noteId, pinned);
if (!ok) {
reportFailure(t('rightSidebar.contextNotesTodo.toast.saveNotesFailed'));
}
},
[projectRef, reportFailure, setNotePinned, t]
);
const handleSaveBody = React.useCallback(
(noteId: string, body: string) => {
void saveNoteBody(projectRef, noteId, body).then((ok: boolean) => {
if (!ok) {
reportFailure(t('rightSidebar.contextNotesTodo.toast.saveNotesFailed'));
}
});
},
[projectRef, reportFailure, saveNoteBody, t]
);
return (
<div className="space-y-2">
{/* Counter and add live in the textarea's own footer slot: beside it they
cost width the panel does not have and leave the button floating
against a tall field. */}
<Textarea
value={composerText}
onChange={(event) => setComposerText(event.target.value.slice(0, PROJECT_NOTE_BODY_MAX_LENGTH))}
placeholder={t('rightSidebar.contextNotesTodo.notes.placeholder')}
resizedHeight={notesPanelHeight}
onResizeHeightChange={setNotesPanelHeight}
useScrollShadow
scrollShadowSize={56}
disabled={disabled}
endSlot={(
<>
<span className="typography-meta text-muted-foreground">
{composerText.length}/{PROJECT_NOTE_BODY_MAX_LENGTH}
</span>
<button
type="button"
onClick={() => void handleAdd()}
disabled={disabled || composerText.trim().length === 0}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-40"
aria-label={t('rightSidebar.contextNotesTodo.notes.addAria')}
title={t('rightSidebar.contextNotesTodo.notes.addAria')}
>
<Icon name="add" className="h-4 w-4" />
</button>
</>
)}
/>
{/* No frame around the list: each note is a bordered card, and an outer
border sitting flush against them read as lines joining the cards. */}
<div>
{visibleNotes.length === 0 ? (
<p className="typography-meta text-muted-foreground">
{query.trim()
? t('rightSidebar.contextNotesTodo.search.noResults', { query: query.trim() })
: t('rightSidebar.contextNotesTodo.notes.empty')}
</p>
) : (
<ul className="flex flex-col gap-1.5">
{visibleNotes.map((note) => (
<NoteRow
key={note.id}
note={note}
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)}
onDelete={() => void handleDelete(note.id)}
/>
))}
</ul>
)}
</div>
</div>
);
};
@@ -0,0 +1,255 @@
import React from 'react';
import { toast } from '@/components/ui';
import { Icon } from '@/components/icon/Icon';
import { requestFileAccess } from '@/lib/desktop';
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
import { parsePlanMarkdown, type ProjectPlanLink, type ProjectRef } from '@/lib/projectContextApi';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { cn } from '@/lib/utils';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectContextStore } from '@/stores/useProjectContextStore';
import { useUIStore } from '@/stores/useUIStore';
/**
* Saved plan markdown for the project.
*
* Plan mutations touch neither notes nor todos, so this section talks to the
* store directly instead of routing writes through the container.
*/
export const PlansSection: React.FC<{
projectRef: ProjectRef;
plans: ProjectPlanLink[];
/** Panel-wide filter, matched against plan titles. */
query: string;
/** Hosts without a ContextPanel (mobile) render their own plan viewer. */
onOpenPlan?: (plan: { id: string; title: string }) => void;
}> = ({ projectRef, plans, query, onOpenPlan }) => {
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);
const handleDeletePlan = React.useCallback(
async (planId: string) => {
if (deletingPlanId) {
return;
}
setDeletingPlanId(planId);
try {
const ok = await removePlan(projectRef, planId);
if (!ok) {
toast.error(t('rightSidebar.contextNotesTodo.toast.deletePlanFailed'));
}
} finally {
setDeletingPlanId(null);
}
},
[deletingPlanId, projectRef, removePlan, t]
);
// Imported files arrive as a whole markdown document; split it the same way
// the server would so the stored plan keeps the author's heading.
const importPlanFromText = React.useCallback(
async (text: string, fallbackTitle: string) => {
if (!text.trim()) {
toast.error(t('rightSidebar.contextNotesTodo.toast.planFileEmpty'));
return;
}
const parsed = parsePlanMarkdown(text, fallbackTitle || t('rightSidebar.contextNotesTodo.plan.defaultTitle'));
const created = await createPlan(projectRef, { title: parsed.title, body: parsed.body });
if (!created) {
toast.error(t('rightSidebar.contextNotesTodo.toast.importPlanFailed'));
return;
}
toast.success(t('rightSidebar.contextNotesTodo.toast.planImported'));
},
[createPlan, projectRef, t]
);
const handleTriggerImport = React.useCallback(async () => {
if (isImporting) {
return;
}
const result = await requestFileAccess({
defaultPath: projectRef.path,
filters: [
{ name: 'Plan files', extensions: ['md', 'markdown', 'txt'] },
{ name: 'All files', extensions: ['*'] },
],
});
if (result.success && result.path) {
setIsImporting(true);
try {
const params = new URLSearchParams({ path: result.path, allowOutsideWorkspace: 'true' });
if (result.outsideFileGrant) {
params.set('outsideFileGrant', result.outsideFileGrant);
}
const response = await runtimeFetch(`/api/fs/read?${params.toString()}`, { cache: 'no-store' });
if (!response.ok) {
toast.error(t('rightSidebar.contextNotesTodo.toast.readPlanFileFailed'));
return;
}
const text = await response.text();
const fallbackTitle = result.path.split('/').pop()?.replace(/\.(md|markdown|txt)$/i, '').trim() || '';
await importPlanFromText(text, fallbackTitle);
} catch (error) {
const description = error instanceof Error ? error.message : undefined;
toast.error(t('rightSidebar.contextNotesTodo.toast.readPlanFileFailed'), description ? { description } : undefined);
} finally {
setIsImporting(false);
}
return;
}
if (result.error === 'Native file picker not available') {
// Fall back to the HTML file input for web/non-desktop runtimes.
fileInputRef.current?.click();
}
}, [importPlanFromText, isImporting, projectRef.path, t]);
const handleUploadFile = React.useCallback(
async (file: File | null) => {
if (!file) {
return;
}
setIsImporting(true);
try {
const text = await file.text();
const fallbackTitle = file.name.replace(/\.(md|markdown|txt)$/i, '').trim();
await importPlanFromText(text, fallbackTitle);
} catch (error) {
const description = error instanceof Error ? error.message : undefined;
toast.error(t('rightSidebar.contextNotesTodo.toast.readPlanFileFailed'), description ? { description } : undefined);
} finally {
setIsImporting(false);
}
},
[importPlanFromText, t]
);
const handleTogglePinned = React.useCallback(
async (planId: string, pinned: boolean) => {
const ok = await setPlanPinned(projectRef, 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]
);
const visiblePlans = React.useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return plans;
return plans.filter((plan) => plan.title.toLowerCase().includes(needle));
}, [plans, query]);
const handleOpenPlan = React.useCallback(
(plan: ProjectPlanLink) => {
if (onOpenPlan) {
onOpenPlan({ id: plan.id, title: plan.title });
return;
}
const panelDirectory = currentDirectory?.trim() || projectRef.path.trim();
if (!panelDirectory) {
return;
}
openContextPanelTab(panelDirectory, {
mode: 'plan',
projectPlanId: plan.id,
dedupeKey: `plan:${plan.id}`,
label: plan.title,
});
},
[currentDirectory, onOpenPlan, openContextPanelTab, projectRef.path]
);
return (
<div className="space-y-2">
<div className="flex items-center justify-end gap-2">
<input
ref={fileInputRef}
type="file"
accept=".md,.markdown,.txt,text/markdown,text/plain"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0] ?? null;
void handleUploadFile(file);
event.currentTarget.value = '';
}}
/>
<button
type="button"
onClick={handleTriggerImport}
disabled={isImporting}
className="inline-flex h-6 w-6 items-center justify-center rounded-md border border-border/70 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50"
aria-label={t('rightSidebar.contextNotesTodo.plans.importFromFile')}
title={t('rightSidebar.contextNotesTodo.plans.importFromFile')}
>
<Icon name="add" className="h-3.5 w-3.5" />
</button>
</div>
<div className="rounded-lg border border-border/60 bg-background/40">
{visiblePlans.length === 0 ? (
<p className="px-3 py-3 typography-meta text-muted-foreground">
{query.trim()
? t('rightSidebar.contextNotesTodo.search.noResults', { query: query.trim() })
: t('rightSidebar.contextNotesTodo.plans.empty')}
</p>
) : (
<ul className="divide-y divide-border/50">
{visiblePlans.map((plan) => (
<li key={plan.id} className="flex items-center gap-1.5 px-2.5 py-1.5">
<button
type="button"
onClick={() => handleOpenPlan(plan)}
className="flex min-w-0 flex-1 items-center justify-between gap-3 rounded-md px-1.5 py-1 text-left hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
>
<span className="min-w-0 truncate typography-ui-label text-foreground">{plan.title}</span>
<span className="flex-shrink-0 typography-micro text-muted-foreground">
{new Date(plan.createdAt).toLocaleDateString(getCurrentIntlLocale())}
</span>
</button>
<button
type="button"
onClick={() => void handleTogglePinned(plan.id, !plan.pinned)}
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'
)}
aria-pressed={plan.pinned}
aria-label={plan.pinned
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
title={plan.pinned
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
>
<Icon name="pushpin" className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => void handleDeletePlan(plan.id)}
disabled={deletingPlanId === plan.id}
className="inline-flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50"
title={t('rightSidebar.contextNotesTodo.plans.deletePlan')}
aria-label={t('rightSidebar.contextNotesTodo.plans.deletePlanWithTitle', { title: plan.title })}
>
<Icon name="delete-bin" className="h-3.5 w-3.5" />
</button>
</li>
))}
</ul>
)}
</div>
</div>
);
};
@@ -0,0 +1,490 @@
import React from 'react';
import { toast } from '@/components/ui';
import { Icon } from '@/components/icon/Icon';
import type { IconName } from '@/components/icon/icons';
import { Input } from '@/components/ui/input';
import { useI18n } from '@/lib/i18n';
import { resolveProjectContextId, type ProjectRef, type ProjectTodoItem } from '@/lib/projectContextApi';
import { cn } from '@/lib/utils';
import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
import { countHighlightedMemories, memoryViewKey } from '@/lib/agentMemoryBadges';
import { EMPTY_PROJECT_CONTEXT_ENTRY, useProjectContextStore } from '@/stores/useProjectContextStore';
import { useUIStore } from '@/stores/useUIStore';
import { TodoSendDialog } from '../TodoSendDialog';
import { MemorySection } from './MemorySection';
import { NotesSection } from './NotesSection';
import { PlansSection } from './PlansSection';
import { TodosSection } from './TodosSection';
import { useProjectTodoSend } from './useProjectTodoSend';
/** 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 })));
interface ProjectNotesTodoPanelProps {
projectRef: ProjectRef | null;
projectLabel?: string | null;
canCreateWorktree?: boolean;
onActionComplete?: () => void;
/** When provided, opening a plan calls this instead of the desktop context
panel tab hosts without ContextPanel (mobile) render their own viewer. */
onOpenPlan?: (plan: { id: string; title: string }) => void;
className?: string;
}
type ProjectContextTab = 'notes' | 'todos' | 'plans' | 'memory';
const TAB_ORDER: ProjectContextTab[] = ['notes', 'todos', 'plans', 'memory'];
/** Wide enough for the longest section label, narrow enough to leave the
content column usable in a half-width panel. */
const SIDEBAR_MIN_WIDTH = 120;
const SIDEBAR_MAX_WIDTH = 320;
const clampSidebarWidth = (width: number): number => (
Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, Math.round(width)))
);
const sortTodosWithCompletedLast = (items: ProjectTodoItem[]): ProjectTodoItem[] => [
...items.filter((todo) => !todo.completed),
...items.filter((todo) => todo.completed),
];
const matches = (haystack: string, needle: string): boolean => (
haystack.toLowerCase().includes(needle)
);
/**
* Notes, todos, and plans for the active project.
*
* The three lists are tabs rather than one stacked column: stacking gave each
* list its own scroller inside the panel's scroller, which only got worse as
* lists grew and forced the todo list to carry a manual resize handle just to
* stay usable.
*
* Search sits above the tabs and stays panel-wide. Tabs divide, and search is
* the one thing that division would hurt you do not always remember whether
* something was written as a note or lives in a plan so the tab bar doubles
* as the result summary by showing per-tab match counts.
*
* Storage is server-owned and reached through `useProjectContextStore`.
*/
export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
projectRef,
projectLabel,
canCreateWorktree = false,
onActionComplete,
onOpenPlan,
className,
}) => {
const { t } = useI18n();
const projectContextId = React.useMemo(() => resolveProjectContextId(projectRef), [projectRef]);
const contextEntry = useProjectContextStore(
(state) => (projectContextId ? state.entries[projectContextId] : undefined) ?? EMPTY_PROJECT_CONTEXT_ENTRY,
);
const loadProjectContext = useProjectContextStore((state) => state.load);
const saveTodos = useProjectContextStore((state) => state.saveTodos);
// 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.
const memoryEnabled = useUIStore((state) => (
state.agentMemoryFeatureAvailable && state.agentMemoryToolEnabled
));
const memoryDisabledByServer = useAgentMemoryStore((state) => state.disabled);
const memoryVisible = memoryEnabled && !memoryDisabledByServer;
const globalMemory = useAgentMemoryStore((state) => state.global);
const projectMemory = useAgentMemoryStore((state) => state.project);
const storedTab = useUIStore((state) => state.projectContextTab);
const setStoredTab = useUIStore((state) => state.setProjectContextTab);
const requestedTab = TAB_ORDER.includes(storedTab as ProjectContextTab)
? storedTab as ProjectContextTab
: 'notes';
// A persisted 'memory' must not survive the feature being turned off, or the
// panel would open on a tab that no longer exists.
const activeTab: ProjectContextTab = requestedTab === 'memory' && !memoryVisible
? 'notes'
: requestedTab;
const [query, setQuery] = React.useState('');
/**
* The plan being read, shown in place of the list. Plans used to open as a
* separate context-panel tab, which pushed the user out of the panel they
* were browsing to read something that belongs to it.
*/
const [openPlan, setOpenPlan] = React.useState<{ id: string; title: string } | null>(null);
const trimmedQuery = query.trim().toLowerCase();
// Completed items sink to the bottom in the list; storage order is untouched.
const todos = React.useMemo(
() => sortTodosWithCompletedLast(contextEntry.todos),
[contextEntry.todos],
);
const isLoading = contextEntry.loading && !contextEntry.loaded;
const memoryEntries = React.useMemo(
() => [...globalMemory, ...projectMemory],
[globalMemory, projectMemory],
);
const counts = React.useMemo(() => {
if (!trimmedQuery) {
return {
notes: contextEntry.notes.length,
todos: todos.length,
plans: contextEntry.plans.length,
memory: memoryEntries.length,
};
}
return {
notes: contextEntry.notes.filter((note) => matches(note.body, trimmedQuery)).length,
todos: todos.filter((todo) => matches(todo.text, trimmedQuery)).length,
plans: contextEntry.plans.filter((plan) => matches(plan.title, trimmedQuery)).length,
memory: memoryEntries.filter((entry) => (
matches(entry.title, trimmedQuery) || matches(entry.body, trimmedQuery)
)).length,
};
}, [contextEntry.notes, contextEntry.plans, memoryEntries, todos, trimmedQuery]);
// Counted across both scopes against their own marks: a new global memory is
// the one the user most needs to see, and it would be invisible behind the
// project scope.
const globalViewedAt = useUIStore((state) => state.agentMemoryViewedAt[memoryViewKey('global', null)] ?? 0);
const projectViewedAt = useUIStore(
(state) => state.agentMemoryViewedAt[memoryViewKey('project', projectRef?.path ?? null)] ?? 0,
);
const highlightedMemoryCount = React.useMemo(
() => countHighlightedMemories(globalMemory, globalViewedAt)
+ countHighlightedMemories(projectMemory, projectViewedAt),
[globalMemory, globalViewedAt, projectMemory, projectViewedAt],
);
const storedSidebarWidth = useUIStore((state) => state.projectContextSidebarWidth);
const setSidebarWidth = useUIStore((state) => state.setProjectContextSidebarWidth);
const [isResizing, setIsResizing] = React.useState(false);
// Held locally while dragging so every pointer move does not write through
// the persisted store, then committed once on release.
const [draggedWidth, setDraggedWidth] = React.useState<number | null>(null);
const sidebarWidth = clampSidebarWidth(draggedWidth ?? storedSidebarWidth);
const handleResizeStart = React.useCallback((event: React.PointerEvent<HTMLDivElement>) => {
event.currentTarget.setPointerCapture(event.pointerId);
setIsResizing(true);
setDraggedWidth(sidebarWidth);
}, [sidebarWidth]);
const handleResizeMove = React.useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) {
return;
}
// The sidebar is on the right, so dragging its left edge leftwards widens
// it: the width is the distance from the pointer to the panel's edge.
const panelRight = event.currentTarget.closest('nav')?.getBoundingClientRect().right ?? 0;
setDraggedWidth(clampSidebarWidth(panelRight - event.clientX));
}, []);
const handleResizeEnd = React.useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
setIsResizing(false);
setDraggedWidth((current) => {
if (current !== null) {
setSidebarWidth(clampSidebarWidth(current));
}
return null;
});
}, [setSidebarWidth]);
const send = useProjectTodoSend({ projectRef, canCreateWorktree, onActionComplete });
React.useEffect(() => {
if (!projectRef) {
return;
}
void loadProjectContext(projectRef);
}, [loadProjectContext, projectRef]);
// Surface a load failure once. The store keeps whatever it already had, so
// the panel never blanks out over an unreachable server.
const reportedErrorRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (!contextEntry.error) {
reportedErrorRef.current = null;
return;
}
if (reportedErrorRef.current === contextEntry.error) {
return;
}
reportedErrorRef.current = contextEntry.error;
if (!contextEntry.loaded) {
toast.error(t('rightSidebar.contextNotesTodo.toast.loadNotesFailed'));
}
}, [contextEntry.error, contextEntry.loaded, t]);
// A plan belongs to its project and to its section; leaving either must not
// leave its editor open over a list it no longer matches.
React.useEffect(() => {
setOpenPlan(null);
}, [projectContextId]);
React.useEffect(() => {
if (activeTab !== 'plans') {
setOpenPlan(null);
}
}, [activeTab]);
// Reset the filter when the project changes: a query that matched the old
// project would silently hide everything in the new one.
React.useEffect(() => {
setQuery('');
}, [projectContextId]);
// Follow the search to where the matches are. Without this, typing a query
// whose hits are all in another tab shows an empty list and the user has to
// guess which tab to try. Only moves off a tab that has nothing.
React.useEffect(() => {
if (!trimmedQuery || counts[activeTab] > 0) {
return;
}
const withMatches = TAB_ORDER.find((tab) => counts[tab] > 0);
if (withMatches) {
setStoredTab(withMatches);
}
}, [activeTab, counts, setStoredTab, trimmedQuery]);
const handlePersistTodos = React.useCallback(
(nextTodos: ProjectTodoItem[]) => {
if (!projectRef) {
return;
}
// The store owns per-project write serialization and rollback; the panel
// only decides what to persist and how to report a failure.
void saveTodos(projectRef, nextTodos).then((saved) => {
if (!saved) {
toast.error(t('rightSidebar.contextNotesTodo.toast.saveNotesFailed'));
}
});
},
[projectRef, saveTodos, t]
);
/**
* The sidebar entries. Icons are worth their width here: a vertical list has
* the room a horizontal strip did not, and they make the sections scannable
* without reading.
*/
const sections: Array<{ id: ProjectContextTab; icon: IconName; label: string; count: string }> = React.useMemo(() => ([
{
id: 'notes',
icon: 'sticky-note',
label: t('rightSidebar.contextNotesTodo.tabs.notes'),
count: String(counts.notes),
},
{
id: 'todos',
icon: 'checkbox-circle',
label: t('rightSidebar.contextNotesTodo.tabs.todos'),
count: String(counts.todos),
},
{
id: 'plans',
icon: 'file-text',
label: t('rightSidebar.contextNotesTodo.tabs.plans'),
count: String(counts.plans),
},
...(memoryVisible ? [{
id: 'memory' as const,
icon: 'brain-4' as IconName,
label: t('rightSidebar.contextNotesTodo.tabs.memory'),
// The new/changed count replaces the total when there is anything the
// user has not seen: what the agent stored without asking is the number
// that deserves the glance.
count: highlightedMemoryCount > 0
? `${highlightedMemoryCount}/${counts.memory}`
: String(counts.memory),
}] : []),
]), [counts, highlightedMemoryCount, memoryVisible, t]);
if (!projectRef) {
return (
<div className={cn('w-full min-w-0 p-3', className)}>
<p className="typography-meta text-muted-foreground">
{t('rightSidebar.contextNotesTodo.empty.selectProject')}
</p>
</div>
);
}
const projectTitle = projectLabel?.trim()
|| projectRef.path.split('/').filter(Boolean).pop()
|| projectRef.path;
return (
<div className={cn('flex h-full min-h-0 w-full min-w-0 flex-col', className)}>
{/* Title and search share a row: search is a filter over what is already
on screen, not a heading, and a full-width field read as the panel's
primary control. */}
<div className="flex flex-shrink-0 items-center gap-2 p-3 pb-2">
{/* Back sits here, beside the project name, rather than above the
editor: PlanView already titles the plan, and a second title row
said the same thing twice. */}
{openPlan ? (
<button
type="button"
onClick={() => setOpenPlan(null)}
className="inline-flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-interactive-hover/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('rightSidebar.contextNotesTodo.plans.actions.back')}
title={t('rightSidebar.contextNotesTodo.plans.actions.back')}
>
<Icon name="arrow-left-s" className="h-4 w-4" />
</button>
) : null}
<h3
className="min-w-0 flex-1 truncate typography-ui-label font-semibold text-foreground"
title={projectRef.path}
>
{projectTitle}
</h3>
<div className="relative w-40 flex-shrink-0">
<Icon
name="search"
className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
/>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t('rightSidebar.contextNotesTodo.search.placeholder')}
className="h-8 pl-7 pr-7"
/>
{query ? (
<button
type="button"
onClick={() => setQuery('')}
className="absolute right-1.5 top-1/2 inline-flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('rightSidebar.contextNotesTodo.search.clear')}
title={t('rightSidebar.contextNotesTodo.search.clear')}
>
<Icon name="close" className="h-3.5 w-3.5" />
</button>
) : null}
</div>
</div>
{/* Content first, sidebar on the right the same order and the same
drag-to-resize edge the files surface uses, so the two panels do not
disagree about where navigation lives. */}
<div className="flex min-h-0 flex-1">
{/* The plan editor scrolls itself; nesting it in this scroller would
give the panel two scrollbars for one document. */}
<div className={cn('min-h-0 min-w-0 flex-1 p-3', openPlan ? 'overflow-hidden' : 'overflow-y-auto')}>
{activeTab === 'notes' ? (
<NotesSection
projectRef={projectRef}
notes={contextEntry.notes}
disabled={isLoading}
query={query}
/>
) : null}
{activeTab === 'todos' ? (
<TodosSection
todos={todos}
query={query}
disabled={isLoading}
canCreateWorktree={canCreateWorktree}
sendingTodoId={send.sendingTodoId}
onPersistTodos={handlePersistTodos}
onSendToCurrentSession={send.sendToCurrentSession}
onSendToNewSession={send.sendToNewSession}
onSendToNewWorktreeSession={send.sendToNewWorktreeSession}
/>
) : null}
{activeTab === 'memory' && memoryVisible ? (
<MemorySection projectPath={projectRef.path} query={query} />
) : null}
{activeTab === 'plans' && !openPlan ? (
<PlansSection
projectRef={projectRef}
plans={contextEntry.plans}
query={query}
// 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}
/>
) : null}
{activeTab === 'plans' && openPlan ? (
<React.Suspense fallback={null}>
<PlanView
projectPlanId={openPlan.id}
onNavigatedToChat={() => setOpenPlan(null)}
/>
</React.Suspense>
) : null}
</div>
<nav
className="relative flex flex-shrink-0 flex-col gap-0.5 overflow-y-auto border-l border-[var(--interactive-border)] p-2"
style={{ width: `${sidebarWidth}px` }}
aria-label={t('rightSidebar.contextNotesTodo.sections.label')}
>
<div
className={cn(
'absolute left-0 top-0 z-20 h-full w-[3px] cursor-col-resize transition-colors hover:bg-[var(--interactive-border)]/80',
isResizing && 'bg-[var(--interactive-border)]',
)}
onPointerDown={handleResizeStart}
onPointerMove={handleResizeMove}
onPointerUp={handleResizeEnd}
onPointerCancel={handleResizeEnd}
role="separator"
aria-orientation="vertical"
aria-label={t('rightSidebar.contextNotesTodo.sections.resize')}
/>
{sections.map((section) => {
const isActive = activeTab === section.id;
return (
<button
key={section.id}
type="button"
onClick={() => setStoredTab(section.id)}
aria-current={isActive ? 'page' : undefined}
className={cn(
'flex min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
isActive
? 'bg-interactive-active text-foreground'
: 'text-muted-foreground hover:bg-interactive-hover/50 hover:text-foreground',
)}
style={{ minHeight: 0 }}
>
<Icon name={section.icon} className="h-3.5 w-3.5 flex-shrink-0" />
<span className="min-w-0 flex-1 truncate typography-meta">{section.label}</span>
<span className="flex-shrink-0 typography-micro text-muted-foreground">{section.count}</span>
</button>
);
})}
</nav>
</div>
<TodoSendDialog
open={send.pendingSendTarget !== null}
onOpenChange={(open) => {
if (!open) {
send.closeDialog();
}
}}
target={send.pendingSendTarget?.kind ?? 'session'}
projectDirectory={projectRef.path}
submitting={send.isSubmitting}
onConfirm={send.confirmSend}
/>
</div>
);
};
@@ -0,0 +1,348 @@
import React from 'react';
import {
DndContext,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from '@dnd-kit/sortable';
import { CSS as DndCSS } from '@dnd-kit/utilities';
import { Checkbox } from '@/components/ui/checkbox';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import { PROJECT_TODO_TEXT_MAX_LENGTH, type ProjectTodoItem } from '@/lib/projectContextApi';
import { cn } from '@/lib/utils';
const createTodoId = (): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `todo_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
};
const sortTodosWithCompletedLast = (items: ProjectTodoItem[]): ProjectTodoItem[] => [
...items.filter((todo) => !todo.completed),
...items.filter((todo) => todo.completed),
];
const insertTodoBeforeCompleted = (items: ProjectTodoItem[], item: ProjectTodoItem): ProjectTodoItem[] => {
const firstCompletedIndex = items.findIndex((todo) => todo.completed);
if (firstCompletedIndex === -1) {
return [...items, item];
}
return [...items.slice(0, firstCompletedIndex), item, ...items.slice(firstCompletedIndex)];
};
type SortableTodoHandleProps = {
attributes: ReturnType<typeof useSortable>['attributes'];
listeners: ReturnType<typeof useSortable>['listeners'];
setActivatorNodeRef: ReturnType<typeof useSortable>['setActivatorNodeRef'];
isDragging: boolean;
};
const SortableTodoItem: React.FC<{
id: string;
children: (dragHandleProps: SortableTodoHandleProps) => React.ReactNode;
}> = ({ id, children }) => {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
return (
<li
ref={setNodeRef}
style={{
transform: DndCSS.Transform.toString(transform),
transition,
}}
className={cn(isDragging && 'opacity-60')}
>
{children({ attributes, listeners, setActivatorNodeRef, isDragging })}
</li>
);
};
export const TodosSection: React.FC<{
todos: ProjectTodoItem[];
/** Panel-wide filter. Mutations still act on the full list. */
query: string;
disabled: boolean;
canCreateWorktree: boolean;
sendingTodoId: string | null;
/** Persists the whole list through the container's store write. */
onPersistTodos: (next: ProjectTodoItem[]) => void;
onSendToCurrentSession: (todoText: string) => void;
onSendToNewSession: (todoId: string, todoText: string) => void;
onSendToNewWorktreeSession: (todoId: string, todoText: string) => void;
}> = ({
todos,
query,
disabled,
canCreateWorktree,
sendingTodoId,
onPersistTodos,
onSendToCurrentSession,
onSendToNewSession,
onSendToNewWorktreeSession,
}) => {
const { t } = useI18n();
const [newTodoText, setNewTodoText] = React.useState('');
const [expandedTodoIds, setExpandedTodoIds] = React.useState<Set<string>>(() => new Set());
const handleAddTodo = React.useCallback(() => {
const trimmed = newTodoText.trim();
if (!trimmed) {
return;
}
onPersistTodos(insertTodoBeforeCompleted(todos, {
id: createTodoId(),
text: trimmed.slice(0, PROJECT_TODO_TEXT_MAX_LENGTH),
completed: false,
createdAt: Date.now(),
}));
setNewTodoText('');
}, [newTodoText, onPersistTodos, todos]);
const handleToggleTodoExpanded = React.useCallback((id: string) => {
setExpandedTodoIds((previous) => {
const next = new Set(previous);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
const handleToggleTodo = React.useCallback(
(id: string, completed: boolean) => {
const todo = todos.find((item) => item.id === id);
if (!todo || todo.completed === completed) {
return;
}
const remaining = todos.filter((item) => item.id !== id);
const updated = { ...todo, completed };
onPersistTodos(completed ? [...remaining, updated] : insertTodoBeforeCompleted(remaining, updated));
},
[onPersistTodos, todos]
);
const handleDeleteTodo = React.useCallback(
(id: string) => {
onPersistTodos(todos.filter((todo) => todo.id !== id));
},
[onPersistTodos, todos]
);
const handleClearCompletedTodos = React.useCallback(() => {
const next = todos.filter((todo) => !todo.completed);
if (next.length === todos.length) {
return;
}
onPersistTodos(next);
}, [onPersistTodos, todos]);
const handleTodoReorder = React.useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) {
return;
}
const oldIndex = todos.findIndex((todo) => todo.id === active.id);
const newIndex = todos.findIndex((todo) => todo.id === over.id);
if (oldIndex === -1 || newIndex === -1) {
return;
}
onPersistTodos(sortTodosWithCompletedLast(arrayMove(todos, oldIndex, newIndex)));
},
[onPersistTodos, todos]
);
const todoSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } })
);
const todoInputValue = newTodoText.slice(0, PROJECT_TODO_TEXT_MAX_LENGTH);
const completedTodoCount = todos.reduce((count, todo) => count + (todo.completed ? 1 : 0), 0);
// Filtering is display-only: every handler above still edits the full list,
// so reordering or clearing while a filter is active cannot drop hidden items.
const visibleTodos = React.useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return todos;
return todos.filter((todo) => todo.text.toLowerCase().includes(needle));
}, [query, todos]);
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleClearCompletedTodos}
disabled={disabled || completedTodoCount === 0}
className="typography-meta rounded-md px-1.5 py-0.5 text-muted-foreground hover:bg-interactive-hover/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50"
>
{t('rightSidebar.contextNotesTodo.todo.clearCompleted')}
</button>
</div>
<span className="typography-meta text-muted-foreground">{todoInputValue.length}/{PROJECT_TODO_TEXT_MAX_LENGTH}</span>
</div>
<div className="flex items-center gap-1.5">
<Input
value={todoInputValue}
onChange={(event) => setNewTodoText(event.target.value.slice(0, PROJECT_TODO_TEXT_MAX_LENGTH))}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
handleAddTodo();
}
}}
placeholder={t('rightSidebar.contextNotesTodo.todo.inputPlaceholder')}
disabled={disabled}
className="h-8"
/>
<button
type="button"
onClick={handleAddTodo}
disabled={disabled || todoInputValue.trim().length === 0}
className="inline-flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md border border-border/70 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50"
aria-label={t('rightSidebar.contextNotesTodo.todo.addAria')}
title={t('rightSidebar.contextNotesTodo.todo.addAria')}
>
<Icon name="add" className="h-4 w-4" />
</button>
</div>
<div className="rounded-lg border border-border/60 bg-background/40">
{visibleTodos.length === 0 ? (
<p className="px-3 py-3 typography-meta text-muted-foreground">
{query.trim()
? t('rightSidebar.contextNotesTodo.search.noResults', { query: query.trim() })
: t('rightSidebar.contextNotesTodo.todo.empty')}
</p>
) : (
<DndContext
sensors={todoSensors}
collisionDetection={closestCenter}
onDragEnd={handleTodoReorder}
>
<SortableContext
items={visibleTodos.map((todo) => todo.id)}
strategy={verticalListSortingStrategy}
>
<ul className="divide-y divide-border/50">
{visibleTodos.map((todo) => {
const isExpandedTodo = expandedTodoIds.has(todo.id);
return (
<SortableTodoItem key={todo.id} id={todo.id}>
{(dragHandleProps) => (
<div className={cn('flex gap-1.5 px-2.5 py-1.5', isExpandedTodo ? 'items-start' : 'items-center')}>
<button
type="button"
ref={dragHandleProps.setActivatorNodeRef}
{...dragHandleProps.attributes}
{...dragHandleProps.listeners}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
className="flex h-6 w-4 flex-shrink-0 touch-none items-center justify-center text-muted-foreground hover:text-foreground"
aria-label={t('rightSidebar.contextNotesTodo.todo.actions.reorder', { text: todo.text })}
title={t('rightSidebar.contextNotesTodo.todo.actions.reorder', { text: todo.text })}
>
<Icon name="draggable" className="h-3.5 w-3.5" />
</button>
<div className="flex h-6 items-center">
<Checkbox
checked={todo.completed}
onChange={(checked) => handleToggleTodo(todo.id, checked)}
ariaLabel={t('rightSidebar.contextNotesTodo.todo.actions.markComplete', { text: todo.text })}
/>
</div>
<button
type="button"
onClick={() => handleToggleTodoExpanded(todo.id)}
className={cn(
'block min-h-6 min-w-0 flex-1 bg-transparent p-0 text-left typography-ui-label leading-normal text-foreground',
isExpandedTodo ? 'whitespace-normal break-words' : 'overflow-hidden text-ellipsis whitespace-nowrap',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
todo.completed && 'text-muted-foreground line-through'
)}
title={isExpandedTodo ? undefined : todo.text}
aria-label={
isExpandedTodo
? t('rightSidebar.contextNotesTodo.todo.actions.collapse', { text: todo.text })
: t('rightSidebar.contextNotesTodo.todo.actions.expand', { text: todo.text })
}
>
{todo.text}
</button>
<div className="flex h-6 items-center gap-0.5">
<button
type="button"
onClick={() => handleDeleteTodo(todo.id)}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('rightSidebar.contextNotesTodo.todo.actions.delete', { text: todo.text })}
title={t('rightSidebar.contextNotesTodo.todo.actions.delete', { text: todo.text })}
>
<Icon name="delete-bin" className="h-3.5 w-3.5" />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={sendingTodoId === todo.id}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50"
aria-label={t('rightSidebar.contextNotesTodo.todo.actions.send', { text: todo.text })}
title={t('rightSidebar.contextNotesTodo.todo.actions.send', { text: todo.text })}
>
<Icon name="send-plane" className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onClick={() => onSendToCurrentSession(todo.text)}>
{t('rightSidebar.contextNotesTodo.todo.sendMenu.currentSession')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onSendToNewSession(todo.id, todo.text)}>
{t('rightSidebar.contextNotesTodo.todo.sendMenu.newSession')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onSendToNewWorktreeSession(todo.id, todo.text)}
disabled={!canCreateWorktree}
>
{t('rightSidebar.contextNotesTodo.todo.sendMenu.newWorktreeSession')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)}
</SortableTodoItem>
);
})}
</ul>
</SortableContext>
</DndContext>
)}
</div>
</div>
);
};
@@ -0,0 +1,203 @@
import React from 'react';
import { toast } from '@/components/ui';
import { generateBranchName } from '@/lib/git/branchNameGenerator';
import { useI18n } from '@/lib/i18n';
import { renderMagicPrompt } from '@/lib/magicPrompts';
import type { ProjectRef } from '@/lib/projectContextApi';
import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { useInputStore } from '@/sync/input-store';
import { useSelectionStore } from '@/sync/selection-store';
import { useSessionUIStore } from '@/sync/session-ui-store';
import type { TodoSendExecution } from '../TodoSendDialog';
type PendingSendTarget = {
kind: 'session' | 'worktree';
todoId: string;
todoText: string;
};
/**
* Sending a todo to an agent.
*
* Creating a session, picking its model/agent, and dispatching the prompt is
* the heaviest thing this surface does and has nothing to do with how todos are
* stored, so it lives apart from the list that triggers it.
*/
export const useProjectTodoSend = (options: {
projectRef: ProjectRef | null;
canCreateWorktree: boolean;
onActionComplete?: () => void;
}) => {
const { projectRef, canCreateWorktree, onActionComplete } = options;
const { t } = useI18n();
const [pendingSendTarget, setPendingSendTarget] = React.useState<PendingSendTarget | null>(null);
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [sendingTodoId, setSendingTodoId] = React.useState<string | null>(null);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const createSession = useSessionUIStore((state) => state.createSession);
const initializeNewOpenChamberSession = useSessionUIStore((state) => state.initializeNewOpenChamberSession);
const sendMessage = useSessionUIStore((state) => state.sendMessage);
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
const setPendingInputText = useInputStore((state) => state.setPendingInputText);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
const routeToChat = React.useCallback(() => {
setActiveMainTab('chat');
setSessionSwitcherOpen(false);
}, [setActiveMainTab, setSessionSwitcherOpen]);
const sendToCurrentSession = React.useCallback(
(todoText: string) => {
if (!currentSessionId) {
toast.error(t('rightSidebar.contextNotesTodo.toast.noActiveSession'));
return;
}
routeToChat();
const fenced = `\`\`\`md\n${todoText}\n\`\`\``;
setPendingInputText(fenced, 'append');
toast.success(t('rightSidebar.contextNotesTodo.toast.sentToCurrentSession'));
onActionComplete?.();
},
[currentSessionId, onActionComplete, routeToChat, setPendingInputText, t]
);
const sendToNewSession = React.useCallback(
(todoId: string, todoText: string) => {
if (!projectRef || sendingTodoId) {
return;
}
setPendingSendTarget({ kind: 'session', todoId, todoText });
},
[projectRef, sendingTodoId]
);
const sendToNewWorktreeSession = React.useCallback(
(todoId: string, todoText: string) => {
if (!projectRef || sendingTodoId) {
return;
}
if (!canCreateWorktree) {
toast.error(t('rightSidebar.contextNotesTodo.toast.worktreeRequiresGitRepo'));
return;
}
setPendingSendTarget({ kind: 'worktree', todoId, todoText });
},
[canCreateWorktree, projectRef, sendingTodoId, t]
);
const confirmSend = React.useCallback(
async (execution: TodoSendExecution) => {
if (!projectRef || !pendingSendTarget) {
return;
}
const visiblePrompt = await renderMagicPrompt('plan.todo.visible', {
todo_text: pendingSendTarget.todoText,
});
const instructionsText = await renderMagicPrompt('plan.todo.instructions', {
todo_text: pendingSendTarget.todoText,
});
const syntheticParts = [{ synthetic: true as const, text: instructionsText }];
setIsSubmitting(true);
setSendingTodoId(pendingSendTarget.todoId);
try {
routeToChat();
let sessionId: string | null = null;
let directoryHint: string | null = projectRef.path;
if (pendingSendTarget.kind === 'worktree') {
if (!canCreateWorktree) {
toast.error(t('rightSidebar.contextNotesTodo.toast.worktreeRequiresGitRepo'));
return;
}
const created = await createWorktreeSessionForNewBranch(projectRef.path, generateBranchName());
if (!created?.id) {
return;
}
sessionId = created.id;
directoryHint = created.path;
} else {
const session = await createSession(undefined, projectRef.path, null);
if (!session?.id) {
toast.error(t('rightSidebar.contextNotesTodo.toast.createSessionFailed'));
return;
}
sessionId = session.id;
directoryHint = session.directory ?? projectRef.path;
initializeNewOpenChamberSession(session.id, useConfigStore.getState().agents ?? []);
}
if (!sessionId) {
return;
}
const selectionState = useSelectionStore.getState();
selectionState.saveSessionModelSelection(sessionId, execution.providerID, execution.modelID);
if (execution.agent.trim()) {
selectionState.saveSessionAgentSelection(sessionId, execution.agent);
selectionState.saveAgentModelForSession(sessionId, execution.agent, execution.providerID, execution.modelID);
selectionState.saveAgentModelVariantForSession(
sessionId,
execution.agent,
execution.providerID,
execution.modelID,
execution.variant || undefined,
);
}
setCurrentSession(sessionId, directoryHint);
await sendMessage(
visiblePrompt,
execution.providerID,
execution.modelID,
execution.agent.trim() || undefined,
undefined,
undefined,
syntheticParts,
execution.variant || undefined,
);
toast.success(
pendingSendTarget.kind === 'worktree'
? t('rightSidebar.contextNotesTodo.toast.sentToNewWorktreeSession')
: t('rightSidebar.contextNotesTodo.toast.sentToNewSession')
);
setPendingSendTarget(null);
onActionComplete?.();
} catch (error) {
const description = error instanceof Error ? error.message : undefined;
toast.error(t('rightSidebar.contextNotesTodo.toast.sendTodoFailed'), description ? { description } : undefined);
} finally {
setIsSubmitting(false);
setSendingTodoId(null);
}
},
[canCreateWorktree, createSession, initializeNewOpenChamberSession, onActionComplete, pendingSendTarget, projectRef, routeToChat, sendMessage, setCurrentSession, t]
);
const closeDialog = React.useCallback(() => {
if (!isSubmitting) {
setPendingSendTarget(null);
}
}, [isSubmitting]);
return {
pendingSendTarget,
isSubmitting,
sendingTodoId,
sendToCurrentSession,
sendToNewSession,
sendToNewWorktreeSession,
confirmSend,
closeDialog,
};
};
+30 -5
View File
@@ -26,6 +26,9 @@ type TextareaProps = React.ComponentProps<"textarea"> & {
endSlot?: React.ReactNode;
};
/** Keep in sync with the textarea's `min-h-[82px]` below. */
const TEXTAREA_MIN_HEIGHT = 82;
function ResizeHandle({
onResizeStart,
ariaLabel,
@@ -81,16 +84,29 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
) => {
const { t } = useI18n();
const wrapperRef = React.useRef<HTMLDivElement>(null);
const dragStateRef = React.useRef<{ startY: number; startHeight: number } | null>(null);
const dragStateRef = React.useRef<{ startY: number; startHeight: number; minHeight: number } | null>(null);
const [resizedHeight, setResizedHeight] = React.useState<number | null>(null);
const effectiveResizedHeight = controlledResizedHeight ?? resizedHeight;
const handleResizeStart = React.useCallback((event: React.PointerEvent<HTMLDivElement>) => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
const startHeight = wrapper.getBoundingClientRect().height;
/**
* The floor is the textarea's own minimum plus everything else the
* wrapper stacks around it the counter row, the gap, the padding.
* Clamping to the textarea minimum alone left no room for that row, so
* dragging the handle far enough pushed the counter out through the
* bottom border instead of stopping.
*/
const innerTextarea = wrapper.querySelector('textarea');
const chromeHeight = innerTextarea
? Math.max(0, startHeight - innerTextarea.getBoundingClientRect().height)
: 0;
dragStateRef.current = {
startY: event.clientY,
startHeight: wrapper.getBoundingClientRect().height,
startHeight,
minHeight: TEXTAREA_MIN_HEIGHT + chromeHeight,
};
const target = event.currentTarget;
target.setPointerCapture(event.pointerId);
@@ -99,7 +115,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
const state = dragStateRef.current;
if (!state) return;
const next = state.startHeight + (moveEvent.clientY - state.startY);
const nextHeight = Math.max(82, next);
const nextHeight = Math.max(state.minHeight, next);
if (onResizeHeightChange) {
onResizeHeightChange(nextHeight);
} else {
@@ -162,12 +178,21 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
<div
ref={wrapperRef}
onPointerDown={focusInnerTextarea}
style={effectiveResizedHeight !== null ? { height: `${effectiveResizedHeight}px` } : undefined}
// minHeight guards a stored height from before the floor was fixed, and
// any future row added to the wrapper: the box never shrinks below what
// it contains, so nothing can spill through the border again.
style={effectiveResizedHeight !== null
? { height: `${effectiveResizedHeight}px`, minHeight: 'fit-content' }
: undefined}
className={cn(
"group/textarea relative flex w-full flex-col rounded-[var(--radius-xl)] bg-[var(--surface-elevated)] pb-2.5",
"ring-1 ring-inset ring-border/60 transition duration-200 ease-out",
"hover:[&:not(:focus-within)]:bg-[var(--surface-subtle)]",
"has-[[disabled]]:pointer-events-none has-[[disabled]]:bg-[var(--surface-subtle)] has-[[disabled]]:ring-transparent",
// Scoped to the textarea, not any disabled descendant: an endSlot
// control that disables itself (an add button with an empty field)
// would otherwise take pointer events away from the whole wrapper,
// leaving the field unclickable and the button permanently disabled.
"has-[textarea:disabled]:pointer-events-none has-[textarea:disabled]:bg-[var(--surface-subtle)] has-[textarea:disabled]:ring-transparent",
!hasError && [
"hover:[&:not(:focus-within)]:ring-transparent",
"focus-within:ring-2 focus-within:ring-[var(--interactive-focus-ring)]",
+61 -9
View File
@@ -38,7 +38,8 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { EditorView } from '@codemirror/view';
import { copyTextToClipboard } from '@/lib/clipboard';
import { generateBranchName } from '@/lib/git/branchNameGenerator';
import { parseProjectPlanMarkdown } from '@/lib/openchamberConfig';
import { fetchProjectPlan, parsePlanMarkdown } from '@/lib/projectContextApi';
import { useProjectContextStore } from '@/stores/useProjectContextStore';
import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator';
import { TodoSendDialog, type TodoSendExecution } from '@/components/session/TodoSendDialog';
import { Icon } from "@/components/icon/Icon";
@@ -48,6 +49,9 @@ import { useI18n } from '@/lib/i18n';
type PlanViewProps = {
targetPath?: string | null;
/** Saved project plan to open. Project plans are server-owned and addressed
by id; they never carry a client-visible filesystem path. */
projectPlanId?: string | null;
/** Called after a send action routes the user to the chat hosts that show
PlanView in an overlay (mobile fullscreen surface) close it here. */
onNavigatedToChat?: () => void;
@@ -150,7 +154,7 @@ type SelectedLineRange = {
end: number;
};
export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigatedToChat }) => {
export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPlanId = null, onNavigatedToChat }) => {
const { t } = useI18n();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const createSession = useSessionUIStore((state) => state.createSession);
@@ -195,6 +199,12 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigat
const [isPlanSendSubmitting, setIsPlanSendSubmitting] = React.useState(false);
const [resolvedPath, setResolvedPath] = React.useState<string | null>(null);
// Set once a saved project plan has actually loaded. Kept separate from
// `resolvedPath` so nothing downstream can mistake a project plan for a file
// the user could open, edit, or be shown a path for.
const [loadedProjectPlanId, setLoadedProjectPlanId] = React.useState<string | null>(null);
const savePlan = useProjectContextStore((state) => state.savePlan);
const hasDocument = Boolean(resolvedPath) || Boolean(loadedProjectPlanId);
const displayPath = React.useMemo(() => {
if (!resolvedPath || !sessionDirectory || !homeDirectory) {
return resolvedPath;
@@ -212,7 +222,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigat
if (!content.trim()) {
return t('planView.title.default');
}
return parseProjectPlanMarkdown(content).title || t('planView.title.default');
return parsePlanMarkdown(content, t('planView.title.default')).title;
}, [content, t]);
const sendPromptTitle = React.useMemo(() => parsedTitle.trim() || t('planView.title.default'), [parsedTitle, t]);
const [loading, setLoading] = React.useState(false);
@@ -374,8 +384,9 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigat
React.useEffect(() => {
// Saved project plans opened via context panel should work even when session plan mode is off.
if (!planModeEnabled && !targetPath) {
if (!planModeEnabled && !targetPath && !projectPlanId) {
setResolvedPath(null);
setLoadedProjectPlanId(null);
setContent('');
setLoading(false);
return;
@@ -407,9 +418,36 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigat
const run = async () => {
setResolvedPath(null);
setLoadedProjectPlanId(null);
setContent('');
setSaveError(null);
if (projectPlanId) {
if (!currentProjectRef) {
return;
}
setLoading(true);
try {
const plan = await fetchProjectPlan(currentProjectRef, projectPlanId);
if (cancelled) return;
if (!plan) {
// The plan or its markdown is gone. Leave the view empty and
// unsaveable rather than presenting an editor that would recreate
// a document the user deleted.
setSaveError(t('planView.error.loadFailed'));
return;
}
setContent(plan.raw);
setLoadedProjectPlanId(projectPlanId);
} catch (error) {
if (cancelled) return;
setSaveError(error instanceof Error ? error.message : t('planView.error.loadFailed'));
} finally {
if (!cancelled) setLoading(false);
}
return;
}
if (targetPath) {
setLoading(true);
try {
@@ -482,17 +520,31 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigat
return () => {
cancelled = true;
};
}, [homeDirectory, planModeEnabled, runtimeApis.files, sessionDirectory, session?.slug, session?.time?.created, targetPath]);
}, [currentProjectRef, homeDirectory, planModeEnabled, projectPlanId, runtimeApis.files, sessionDirectory, session?.slug, session?.time?.created, t, targetPath]);
React.useEffect(() => {
if (!resolvedPath) {
setSaveError(null);
if (!resolvedPath && !loadedProjectPlanId) {
return;
}
const controller = window.setTimeout(async () => {
setSaveError(null);
try {
if (loadedProjectPlanId) {
if (!currentProjectRef) {
throw new Error(t('planView.error.writeFailed'));
}
const saved = await savePlan(currentProjectRef, loadedProjectPlanId, content);
if (!saved) {
throw new Error(t('planView.error.writeFailed'));
}
return;
}
if (!resolvedPath) {
return;
}
if (runtimeApis.files?.writeFile) {
const result = await runtimeApis.files.writeFile(resolvedPath, content);
if (!result?.success) {
@@ -516,7 +568,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigat
return () => {
window.clearTimeout(controller);
};
}, [content, resolvedPath, runtimeApis.files, t]);
}, [content, currentProjectRef, loadedProjectPlanId, resolvedPath, runtimeApis.files, savePlan, t]);
React.useEffect(() => {
return () => {
@@ -672,7 +724,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigat
</div>
) : null}
</div>
{resolvedPath ? (
{hasDocument ? (
<div className="flex items-center gap-1">
<DropdownMenu>
<Tooltip>