import React from 'react'; import { useI18n } from '@/lib/i18n'; import { Icon } from '@/components/icon/Icon'; import { useSkillsStore } from '@/stores/useSkillsStore'; import { useMcpStore } from '@/stores/useMcpStore'; import { useSession } from '@/sync/sync-context'; import { getLinkedIssues } from '@/lib/linkedIssues'; import { fetchSessionKnowledgeSummary, setSessionProjectContextPin, type SessionKnowledgeSummary } from '@/lib/sessionKnowledgeApi'; import { useProjectContextStore } from '@/stores/useProjectContextStore'; import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { resolveProjectForSessionDirectory } from '@/lib/projectResolution'; import { resolveProjectContextId } from '@/lib/projectContextApi'; import { WorkStatusCollapsibleSection, WorkStatusRow, WorkStatusValue } from './WorkStatusPrimitives'; import { useReportWorkStatusPresence } from './presenceContext'; import { resolveDraftPinnedKnowledge } from './draftKnowledge'; type Props = { sessionId: string | null; directory: string | null; }; /** * What is loaded into the agent's context: the GitHub threads this session was * pointed at, plus how much ambient material is available. * * Agents are deliberately absent — an agent is who does the work, not material * the work is done with. Tools are absent for want of an honest source: * `Agent.tools` is a per-agent override map, not a registry, so its size would * report something other than "tools available". */ export const WorkStatusContextSection: React.FC = ({ sessionId, directory }) => { const { t } = useI18n(); const session = useSession(sessionId ?? '', directory ?? undefined); const newSessionDraft = useSessionUIStore((state) => state.newSessionDraft); const setDraftProjectContextPin = useSessionUIStore((state) => state.setDraftProjectContextPin); const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); const projects = useProjectsStore((state) => state.projects); const isDraft = sessionId === null && newSessionDraft.open; const skills = useSkillsStore((state) => state.skills); const mcpStatus = useMcpStore( React.useCallback((state) => state.getStatusForDirectory(directory), [directory]), ); // Skills were previously fetched only when the composer's slash autocomplete // opened, so this row reported whatever count happened to be cached — often // none — until the user typed "/". The panel states a count, so it is the // panel's business to have one. Re-run per directory because skills are // discovered relative to the active project. No background-network wrap // here: `loadSkills` already gates its own fetch, and wrapping it again // would hold a second slot idle for the length of the first. const loadSkills = useSkillsStore((state) => state.loadSkills); React.useEffect(() => { void loadSkills(); }, [directory, loadSkills]); /** * What this session carries. 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( { notes: [], plans: [], memory: { global: 0, project: 0 } }, ); // Re-read when source content or memory changes, not only when the session does. const contextEntries = useProjectContextStore((state) => state.entries); const loadProjectContext = useProjectContextStore((state) => state.load); const memoryProject = useAgentMemoryStore((state) => state.project); const memoryGlobal = useAgentMemoryStore((state) => state.global); const draftProject = React.useMemo(() => { if (!isDraft) return null; const selected = newSessionDraft.selectedProjectId ? projects.find((project) => project.id === newSessionDraft.selectedProjectId) ?? null : null; return selected ?? resolveProjectForSessionDirectory( projects, availableWorktreesByProject, newSessionDraft.directoryOverride ?? directory, ); }, [availableWorktreesByProject, directory, isDraft, newSessionDraft.directoryOverride, newSessionDraft.selectedProjectId, projects]); const draftContextEntry = draftProject ? contextEntries[resolveProjectContextId({ id: draftProject.id, path: draftProject.path })] : undefined; React.useEffect(() => { if (!isDraft || !draftProject) return; void loadProjectContext({ id: draftProject.id, path: draftProject.path }); }, [draftProject, isDraft, loadProjectContext]); React.useEffect(() => { let cancelled = false; void fetchSessionKnowledgeSummary(directory, sessionId).then((summary) => { if (!cancelled) setKnowledge(summary); }); return () => { cancelled = true; }; }, [directory, sessionId, session, contextEntries, memoryProject, memoryGlobal]); const visibleKnowledge = React.useMemo(() => { if (!isDraft) return knowledge; const pinned = resolveDraftPinnedKnowledge( draftContextEntry?.notes ?? [], draftContextEntry?.plans ?? [], newSessionDraft.projectContextPins ?? { notes: [], plans: [] }, ); return { ...knowledge, ...pinned }; }, [draftContextEntry?.notes, draftContextEntry?.plans, isDraft, knowledge, newSessionDraft.projectContextPins]); // Unpinning from here, like the pinned-messages section: a panel that says // what is attached should be able to detach it, or the user has to go find // the surface that can. const unpinNote = React.useCallback((noteId: string) => { if (isDraft) { setDraftProjectContextPin('note', noteId, false); return; } if (!directory || !sessionId) return; void setSessionProjectContextPin(directory, sessionId, 'note', noteId, false).then((pins) => { if (pins) setKnowledge((current) => ({ ...current, notes: current.notes.filter((note) => note.id !== noteId) })); }); }, [directory, isDraft, sessionId, setDraftProjectContextPin]); const unpinPlan = React.useCallback((planId: string) => { if (isDraft) { setDraftProjectContextPin('plan', planId, false); return; } if (!directory || !sessionId) return; void setSessionProjectContextPin(directory, sessionId, 'plan', planId, false).then((pins) => { if (pins) setKnowledge((current) => ({ ...current, plans: current.plans.filter((plan) => plan.id !== planId) })); }); }, [directory, isDraft, sessionId, setDraftProjectContextPin]); const memoryCount = visibleKnowledge.memory.global + visibleKnowledge.memory.project; const pinnedCount = visibleKnowledge.notes.length + visibleKnowledge.plans.length; const linked = React.useMemo(() => getLinkedIssues(session), [session]); // Connected servers only. A disabled server contributes nothing to the // context, so counting it here contradicts the MCP section right above, // which shows the same servers switched off. const mcpCount = React.useMemo( () => Object.values(mcpStatus ?? {}).filter((entry) => entry?.status === 'connected').length, [mcpStatus], ); useReportWorkStatusPresence( 'context-sources', linked.length > 0 || skills.length > 0 || mcpCount > 0 || pinnedCount > 0 || memoryCount > 0, ); 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 // when there is not. `1 · 33 · 2` said nothing without opening the section. const issueCount = linked.filter((entry) => entry.kind === 'issue').length; const prCount = linked.length - issueCount; const summaryParts: string[] = []; if (issueCount > 0) { summaryParts.push(issueCount === 1 ? t('chat.workStatus.breakdown.issueCountSingle', { count: issueCount }) : t('chat.workStatus.breakdown.issueCountPlural', { count: issueCount })); } if (prCount > 0) { summaryParts.push(prCount === 1 ? t('chat.workStatus.breakdown.prCountSingle', { count: prCount }) : t('chat.workStatus.breakdown.prCountPlural', { count: prCount })); } // Pinned knowledge outranks ambient counts because the user chose it for this session. 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 ? t('chat.workStatus.breakdown.skillCountSingle', { count: skills.length }) : t('chat.workStatus.breakdown.skillCountPlural', { count: skills.length })); } if (mcpCount > 0) { summaryParts.push(mcpCount === 1 ? t('chat.workStatus.breakdown.mcpCountSingle', { count: mcpCount }) : t('chat.workStatus.breakdown.mcpCountPlural', { count: mcpCount })); } } return ( {/* Attached threads first: they are specific to this session, while the counts below describe the workspace. */} {linked.map((entry) => ( ) : ( )} label={entry.title} muted // The stored snapshot is enough to render; the live thread only ever // exists on github.com. onClick={() => window.open(entry.url, '_blank', 'noopener,noreferrer')} ariaLabel={t('chat.workStatus.linkedIssues.open', { number: entry.number })} value={{`#${entry.number}`}} /> ))} {/* Named individually: a count alone would not identify this session's context. */} {/* 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. */} {visibleKnowledge.notes.map((note) => ( { event.stopPropagation(); unpinNote(note.id); }} className="shrink-0 rounded p-0.5 transition-opacity hover:opacity-70 disabled:opacity-40" > )} label={note.body.trim().split('\n')[0] || note.body.trim()} value={{t('chat.workStatus.breakdown.pinnedNote')}} /> ))} {visibleKnowledge.plans.map((plan) => ( { event.stopPropagation(); unpinPlan(plan.id); }} className="shrink-0 rounded p-0.5 transition-opacity hover:opacity-70 disabled:opacity-40" > )} label={plan.title} value={{t('chat.workStatus.breakdown.pinnedPlan')}} /> ))} {memoryCount > 0 ? ( {memoryCount}} /> ) : null} {skills.length}} /> {mcpCount}} /> ); };