import React from 'react'; import { toast } from '@/components/ui'; import { Button } from '@/components/ui/button'; import { Icon } from '@/components/icon/Icon'; import { cn } from '@/lib/utils'; import { useI18n, type I18nKey } from '@/lib/i18n'; import { useSkillsStore } from '@/stores/useSkillsStore'; import { useMcpStore } from '@/stores/useMcpStore'; import { useSession } from '@/sync/sync-context'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { getLinkedIssues, parseLinkedIssueRef, type LinkedIssue } from '@/lib/linkedIssues'; import { linkedEntityLiveInvalidate, useLinkedEntityLive, type LinkedEntityLive } from '@/lib/linkedEntityLive'; import { fetchSessionKnowledgeSummary, setSessionProjectContextPin, type SessionKnowledgeSummary } from '@/lib/sessionKnowledgeApi'; import { resolveProjectForSessionDirectory } from '@/lib/projectResolution'; import { setLinkedIssue } from '@/sync/session-actions'; import { useProjectContextStore } from '@/stores/useProjectContextStore'; import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { resolveProjectContextId } from '@/lib/projectContextApi'; import { WorkStatusCollapsibleSection, WorkStatusPill, WorkStatusRow, WorkStatusValue } from './WorkStatusPrimitives'; import { useReportWorkStatusPresence } from './presenceContext'; import { WorkStatusLinkDialog } from './WorkStatusLinkDialog'; import { resolveDraftPinnedKnowledge } from './draftKnowledge'; type Props = { sessionId: string | null; directory: string | null; }; const STATE_COLOR: Record = { open: 'var(--pr-open)', closed: 'var(--pr-closed)', merged: 'var(--pr-merged)', }; const STATE_LABEL_KEY: Record = { open: 'forge.state.open', closed: 'forge.state.closed', merged: 'forge.state.merged', }; /** * One linked issue/PR as a live card. * * When the entry resolves to a forge entity and the runtime carries the * provider's API, the row fetches current state (open/merged/closed, draft, * freshest title) on mount and on demand — never on an interval. The snapshot * stays the fallback for everything the live fetch has not answered yet: * loading keeps the snapshot row with a spinner, a failed fetch keeps it with * a muted "live unavailable" marker instead of silently looking stale. */ const LinkedIssueRow: React.FC<{ entry: LinkedIssue; sessionId: string | null; directory: string | null | undefined; }> = ({ entry, sessionId, directory }) => { const { t } = useI18n(); const ref = React.useMemo(() => parseLinkedIssueRef(entry), [entry]); const providerKind = entry.provider ?? ref?.provider ?? null; const apis = getRegisteredRuntimeAPIs(); const canLive = Boolean( directory && ref && ((providerKind === 'github' && apis?.github) || (providerKind === 'gitlab' && apis?.gitlab) || (providerKind === 'gitea' && apis?.gitea)), ); const { live, loading, unavailable, refresh } = useLinkedEntityLive(entry, canLive ? directory : null); const [unlinking, setUnlinking] = React.useState(false); const handleUnlink = React.useCallback(async () => { if (!sessionId || !directory || unlinking) return; if (!window.confirm(t('chat.workStatus.linkedIssues.unlinkConfirm'))) return; setUnlinking(true); try { await setLinkedIssue(sessionId, directory, entry, false); linkedEntityLiveInvalidate(entry.id); } catch { toast.error(t('chat.workStatus.linkedIssues.unlinkFailed')); } finally { setUnlinking(false); } }, [directory, entry, sessionId, t, unlinking]); const openInBrowser = React.useCallback(() => { if (typeof window !== 'undefined') { window.open(entry.url, '_blank', 'noopener,noreferrer'); } }, [entry.url]); const stateLabel = live ? t(STATE_LABEL_KEY[live.state]) : null; // The live fetch is the freshest word on the title; the snapshot covers // everything the fetch has not answered yet (initial loading, failure). const title = live?.title ?? entry.title; const leading = entry.authorAvatarUrl ? ( ) : ( ); // A plain row, not WorkStatusRow: the card carries its own controls // (refresh, unlink) next to the number, which a full-row button cannot // contain without nesting buttons. return (
{leading} {live ? ( ) : null} {live?.draft ? {t('chat.workStatus.pr.draft')} : null} {`#${entry.number}`} {canLive ? ( ) : null}
); }; /** * What is loaded into the agent's context: the git-forge threads this session * was pointed at (live state cards plus link/unlink controls), and 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 [linkDialogOpen, setLinkDialogOpen] = React.useState(false); 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]), ); // The session's server-confirmed directory is the authoritative address for // forge lookups; the prop only covers drafts with no session yet. const sessionDirectory = session?.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 })); } } const hasSessionContext = Boolean(sessionId && sessionDirectory); return ( setLinkDialogOpen(true)} aria-label={t('chat.workStatus.linkedIssues.link')} title={t('chat.workStatus.linkedIssues.link')} > {t('chat.workStatus.linkedIssues.link')} )} > {/* Attached threads first: they are specific to this session, while the counts below describe the workspace. */} {linked.map((entry) => ( ))} {linked.length === 0 ? ( ) : null} {/* 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}} /> ); };