import React from 'react'; import { useI18n } from '@/lib/i18n'; import { cn } from '@/lib/utils'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { Button } from '@/components/ui/button'; import { useUIStore } from '@/stores/useUIStore'; import { WORK_STATUS_PANEL_WIDTH } from './useWorkStatusVisibility'; import { WorkStatusGoalRow } from './WorkStatusGoalRow'; import { WorkStatusPrimaryGroup } from './WorkStatusPrimaryGroup'; import { WorkStatusUsageSection } from './WorkStatusUsageSection'; import { WorkStatusSubagentsSection } from './WorkStatusSubagentsSection'; import { WorkStatusTasksSection } from './WorkStatusTasksSection'; import { WorkStatusMcpSection } from './WorkStatusMcpSection'; import { WorkStatusPinnedSection } from './WorkStatusPinnedSection'; import { WorkStatusContextSection } from './WorkStatusContextSection'; import { WorkStatusSectionsDialog } from './WorkStatusSectionsDialog'; import { areAllWorkStatusSectionsHidden, getWorkStatusPanelPresentation, isWorkStatusSectionVisible, } from './sections'; import { WorkStatusPresenceProvider } from './presence'; import { Icon } from '@/components/icon/Icon'; type Props = { /** Null on a new-session draft: repository readouts still apply. */ sessionId: string | null; directory: string | null; /** Managed Chats have no project repository, even if another project remains active. */ repositoryEnabled?: boolean; /** Whether the panel should currently occupy space. */ visible: boolean; /** * Floats over the transcript instead of sitting beside it, for when the chat * is too narrow to give it a column of its own. */ overlay?: boolean; }; /** * Matches the context panel's own width animation exactly. * * The two are siblings of the transcript, and opening the context panel hides * this one. With an instant unmount the chat first jumped wider (this panel * gone) and then eased narrower (the context panel expanding) — two opposite * width changes in a row, which reads as a flutter. Collapsing on the same * curve and duration makes the chat's width move once, in one direction. */ const PANEL_TRANSITION_MS = 200; const PANEL_TRANSITION_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)'; /** * Work-status panel: a card inside the chat column reporting the state of the * session, its branch and its subagents. * * Ordering is by durability, not by category. The first sections hold readouts * that stay true for the whole session, then the state of the work in flight, * then episodic material an agent may never produce. Each section renders * nothing when it has nothing, so the panel collapses toward the top instead of * reserving empty space. * * The card clips; the scroller lives inside it, so the same top/bottom scroll * shadows the transcript uses stay within the rounded border instead of * bleeding past it. The scrollbar itself is hidden — at this width it would * eat a visible slice of every row's trailing value, and the shadows already * say there is more to see. */ export const WorkStatusPanel: React.FC = ({ sessionId, directory, visible, repositoryEnabled = true, overlay = false }) => { const { t } = useI18n(); const setScrollTop = useUIStore((state) => state.setWorkStatusScrollTop); const setOverlayOpen = useUIStore((state) => state.setWorkStatusOverlayOpen); const hiddenSections = useUIStore((state) => state.workStatusHiddenSections); const [sectionsDialogOpen, setSectionsDialogOpen] = React.useState(false); // Starts optimistic: sections report after their first commit, and rendering // nothing on the way in would make the card flash out and back on arrival. const [renderedSections, setRenderedSections] = React.useState(1); const sectionVisible = React.useCallback( (sectionId: Parameters[1]) => isWorkStatusSectionVisible(hiddenSections, sectionId), [hiddenSections], ); const frameRef = React.useRef(null); // Restoring the offset has to happen the moment the scroller attaches, and // the panel unmounts whenever the context panel opens. Reading the stored // value through a ref keeps this a mount-time restore rather than a // subscription that would fight the user mid-scroll. // Content is dropped only after the collapse finishes, so the card animates // out with something in it rather than emptying first, and its subscriptions // stop once it is truly gone. const [contentMounted, setContentMounted] = React.useState(visible); // Hidden or mid-collapse: the card is not something the user can act on. // When `visible` but all sections are hidden, the panel stays interactive so // the settings button remains reachable — otherwise there is no way to // re-enable sections. The previous `renderedSections > 0` guard is preserved // for the transient "no data yet" state so the panel doesn't flash a bare // bordered card on first mount. const allSectionsHidden = areAllWorkStatusSectionsHidden(hiddenSections); const { interactive, showEmptyState } = getWorkStatusPanelPresentation({ visible, contentMounted, renderedSections, allSectionsHidden, }); React.useEffect(() => { if (visible) { setContentMounted(true); return undefined; } const timer = window.setTimeout(() => setContentMounted(false), PANEL_TRANSITION_MS); return () => window.clearTimeout(timer); }, [visible]); const restore = React.useCallback((node: HTMLElement | null) => { if (!node) return; const stored = useUIStore.getState().workStatusScrollTop; if (stored > 0) node.scrollTop = stored; }, []); // Coalesced to one write per frame: scroll fires far faster than the store // needs to hear about it. const handleScroll = React.useCallback((event: React.UIEvent) => { const { scrollTop } = event.currentTarget; if (frameRef.current !== null) return; frameRef.current = requestAnimationFrame(() => { frameRef.current = null; setScrollTop(scrollTop); }); }, [setScrollTop]); React.useEffect(() => () => { if (frameRef.current !== null) cancelAnimationFrame(frameRef.current); }, []); // The offset belongs to the panel a session produced, not to the panel in // general: restoring one session's scroll into another's shorter panel lands // somewhere arbitrary. React.useEffect(() => { setScrollTop(0); }, [sessionId, setScrollTop]); // Dismissed like any transient surface: a click elsewhere or Escape. It // covers the transcript, so leaving it up would block the thing it reports on. const overlayRef = React.useRef(null); React.useEffect(() => { // Only while it is actually up: a hidden overlay listening for clicks would // swallow the very press that opens it. if (!overlay || !visible) return undefined; const onPointerDown = (event: PointerEvent) => { const target = event.target as HTMLElement | null; if (overlayRef.current?.contains(target)) return; // The header toggle closes it on its own; letting this fire too would // close and immediately reopen. if (target?.closest('[data-work-status-toggle]')) return; setOverlayOpen(false); }; const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') setOverlayOpen(false); }; document.addEventListener('pointerdown', onPointerDown, true); document.addEventListener('keydown', onKeyDown); return () => { document.removeEventListener('pointerdown', onPointerDown, true); document.removeEventListener('keydown', onKeyDown); }; }, [overlay, setOverlayOpen, visible]); return ( ); };