From 304a2a7f796789dfe418166c1adf1df7cedb2bf3 Mon Sep 17 00:00:00 2001 From: iliya Date: Sun, 29 Mar 2026 00:05:29 +0200 Subject: [PATCH] feat(graph): enhance GraphControls with settings toggle and improved zoom functionality - Added a settings toggle to the GraphControls for better user interaction. - Implemented event listeners to close settings when clicking outside or pressing Escape. - Improved zoom controls to mark user interaction, preventing auto-fit adjustments during user actions. - Refactored GraphOverlay to streamline rendering and position updates for selected nodes. - Updated GraphView to utilize new layout effects for better performance and responsiveness. --- packages/agent-graph/src/ui/GraphControls.tsx | 217 +++++++++++------- packages/agent-graph/src/ui/GraphOverlay.tsx | 67 +++--- packages/agent-graph/src/ui/GraphView.tsx | 183 +++++++++++---- .../components/layout/PaneContent.tsx | 11 +- src/renderer/components/layout/PaneView.tsx | 2 +- .../sidebar/DateGroupedSessions.tsx | 14 +- .../components/sidebar/GlobalTaskList.tsx | 41 +--- .../components/sidebar/SessionItem.tsx | 4 +- .../components/sidebar/TaskFiltersPopover.tsx | 23 ++ .../components/sidebar/taskFiltersState.ts | 2 + .../components/team/ClaudeLogsSection.tsx | 8 +- .../components/team/TeamDetailView.tsx | 110 ++++----- .../team/messages/MessagesPanel.tsx | 84 ++++++- .../team/sidebar/TeamSidebarHost.tsx | 73 ++++++ .../team/sidebar/TeamSidebarPortalManager.ts | 173 ++++++++++++++ .../team/sidebar/TeamSidebarPortalSource.tsx | 66 ++++++ .../team/sidebar/TeamSidebarRail.tsx | 37 +++ .../team/sidebar/teamSidebarUiState.ts | 122 ++++++++++ .../team/useClaudeLogsController.ts | 48 ++-- .../agent-graph/ui/TeamGraphOverlay.tsx | 6 +- .../features/agent-graph/ui/TeamGraphTab.tsx | 45 ++-- .../sidebar/TeamSidebarPortalManager.test.ts | 101 ++++++++ 22 files changed, 1105 insertions(+), 332 deletions(-) create mode 100644 src/renderer/components/team/sidebar/TeamSidebarHost.tsx create mode 100644 src/renderer/components/team/sidebar/TeamSidebarPortalManager.ts create mode 100644 src/renderer/components/team/sidebar/TeamSidebarPortalSource.tsx create mode 100644 src/renderer/components/team/sidebar/TeamSidebarRail.tsx create mode 100644 src/renderer/components/team/sidebar/teamSidebarUiState.ts create mode 100644 test/renderer/components/team/sidebar/TeamSidebarPortalManager.test.ts diff --git a/packages/agent-graph/src/ui/GraphControls.tsx b/packages/agent-graph/src/ui/GraphControls.tsx index 54e9c835..b60e7e54 100644 --- a/packages/agent-graph/src/ui/GraphControls.tsx +++ b/packages/agent-graph/src/ui/GraphControls.tsx @@ -3,20 +3,21 @@ * Positioned below system buttons (top-10) to avoid overlap on macOS. */ -import { useCallback } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { Columns3, Expand, + Settings2, Eye, EyeOff, Maximize2, - Minus, Pause, Pin, Play, - Plus, Server, X, + ZoomIn, + ZoomOut, } from 'lucide-react'; export interface GraphFilterState { @@ -53,6 +54,8 @@ export function GraphControls({ teamColor, isAlive, }: GraphControlsProps): React.JSX.Element { + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const settingsRef = useRef(null); const toggle = useCallback( (key: keyof GraphFilterState) => { onFiltersChange({ ...filters, [key]: !filters[key] }); @@ -60,103 +63,142 @@ export function GraphControls({ [filters, onFiltersChange], ); + useEffect(() => { + if (!isSettingsOpen) return; + + const handlePointerDown = (event: MouseEvent): void => { + const target = event.target as Node | null; + if (!target) return; + if (settingsRef.current?.contains(target)) return; + setIsSettingsOpen(false); + }; + + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + setIsSettingsOpen(false); + } + }; + + window.addEventListener('mousedown', handlePointerDown); + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('mousedown', handlePointerDown); + window.removeEventListener('keydown', handleKeyDown); + }; + }, [isSettingsOpen]); + const nameColor = teamColor ?? '#aaeeff'; return ( -
- {/* Left: team name + status indicator */} -
+ <> +
{isAlive && ( -
+
)} - + {teamName} - - beta -
- {/* Center: filter toggles */} -
- toggle('showTasks')} - icon={} - label="Tasks" - /> - toggle('showProcesses')} - icon={} - label="Proc" - /> - toggle('showEdges')} - icon={filters.showEdges ? : } - label="Edges" - /> - - toggle('paused')} - icon={filters.paused ? : } - /> -
- - {/* Right: zoom + actions */} -
- } /> - } /> - } /> - {onRequestPinAsTab && ( - <> - +
+
+
} - label="Pin" + onClick={() => setIsSettingsOpen((value) => !value)} + icon={} + label="View" + active={isSettingsOpen} /> - - )} - {onRequestFullscreen && ( - <> - +
+ + {isSettingsOpen && ( +
+ toggle('showTasks')} + icon={} + label="Tasks" + block + /> + toggle('showProcesses')} + icon={} + label="Processes" + block + /> + toggle('showEdges')} + icon={filters.showEdges ? : } + label="Edges" + block + /> +
+ toggle('paused')} + icon={filters.paused ? : } + label={filters.paused ? 'Resume' : 'Pause'} + block + /> +
+ )} +
+ +
+ {onRequestPinAsTab && } />} + {onRequestFullscreen && ( } label="Fullscreen" /> - - )} - {onRequestClose && ( - } /> - )} + )} + {onRequestClose && } />} +
-
+ +
+
+ } /> + } label="Fit" /> + } /> +
+
+ ); } @@ -166,16 +208,21 @@ function ToolbarButton({ onClick, icon, label, + active = false, }: { onClick?: () => void; icon: React.ReactNode; label?: string; + active?: boolean; }): React.JSX.Element { return ( ); } - -function Separator(): React.JSX.Element { - return
; -} diff --git a/packages/agent-graph/src/ui/GraphOverlay.tsx b/packages/agent-graph/src/ui/GraphOverlay.tsx index 301e6998..0cee384c 100644 --- a/packages/agent-graph/src/ui/GraphOverlay.tsx +++ b/packages/agent-graph/src/ui/GraphOverlay.tsx @@ -9,64 +9,51 @@ import type { GraphEventPort } from '../ports/GraphEventPort'; export interface GraphOverlayProps { selectedNode: GraphNode | null; - worldToScreen: (wx: number, wy: number) => { x: number; y: number }; events?: GraphEventPort; onDeselect: () => void; } export function GraphOverlay({ selectedNode, - worldToScreen, events, onDeselect, }: GraphOverlayProps): React.JSX.Element | null { if (!selectedNode) return null; - const screenPos = worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0); - return (
-
-
- {selectedNode.label} +
+ {selectedNode.label} +
+ {selectedNode.sublabel && ( +
+ {selectedNode.sublabel}
- {selectedNode.sublabel && ( -
- {selectedNode.sublabel} -
- )} - {selectedNode.role && ( -
- {selectedNode.role} -
- )} -
- {(selectedNode.kind === 'member' || selectedNode.kind === 'lead') && ( - { - const ref = selectedNode.domainRef; - if (ref.kind === 'member') events?.onSendMessage?.(ref.memberName, ref.teamName); - onDeselect(); - }} - /> - )} - + )} + {selectedNode.role && ( +
+ {selectedNode.role}
+ )} +
+ {(selectedNode.kind === 'member' || selectedNode.kind === 'lead') && ( + { + const ref = selectedNode.domainRef; + if (ref.kind === 'member') events?.onSendMessage?.(ref.memberName, ref.teamName); + onDeselect(); + }} + /> + )} +
); diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index fe401daa..78bd2200 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -10,7 +10,8 @@ * ALL animation state (positions, particles, effects, time) lives in refs. */ -import { useState, useCallback, useEffect, useRef } from 'react'; +import { useState, useCallback, useEffect, useLayoutEffect, useRef } from 'react'; +import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'; import type { GraphDataPort } from '../ports/GraphDataPort'; import type { GraphEventPort } from '../ports/GraphEventPort'; import type { GraphConfigPort } from '../ports/GraphConfigPort'; @@ -68,9 +69,12 @@ export function GraphView({ const containerRef = useRef(null); const canvasHandle = useRef(null); + const overlayRef = useRef(null); const rafRef = useRef(0); const lastTimeRef = useRef(0); const runningRef = useRef(false); + const hasAutoFit = useRef(false); + const allowAutoFitRef = useRef(true); // ─── Hooks ────────────────────────────────────────────────────────────── const simulation = useGraphSimulation(); @@ -172,25 +176,63 @@ export function GraphView({ }; }, [effectivePaused, animate]); - // ─── Auto-fit: center graph immediately when data arrives ────────────── - const hasAutoFit = useRef(false); + const fitGraphToViewport = useCallback(() => { + const el = containerRef.current; + if (!el || data.nodes.length === 0) return; + camera.zoomToFit(simulation.stateRef.current.nodes, el.clientWidth, el.clientHeight); + }, [camera, data.nodes.length, simulation.stateRef]); + + // ─── Auto-fit: until first user interaction, also react to container resizes ───── useEffect(() => { - if (data.nodes.length > 0 && !hasAutoFit.current) { - hasAutoFit.current = true; - // Immediate fit (simulation already settled from 120 pre-ticks) - const el = containerRef.current; - if (el) { - camera.zoomToFit(simulation.stateRef.current.nodes, el.clientWidth, el.clientHeight); - } - // Second fit after mount stabilizes (ResizeObserver may fire late) - const timer = setTimeout(() => { - if (el) { - camera.zoomToFit(simulation.stateRef.current.nodes, el.clientWidth, el.clientHeight); - } - }, 300); - return () => clearTimeout(timer); + if (data.nodes.length === 0) { + hasAutoFit.current = false; + allowAutoFitRef.current = true; + return; } - }, [data.nodes.length, camera, simulation.stateRef]); + + if (!hasAutoFit.current) { + hasAutoFit.current = true; + fitGraphToViewport(); + + const raf1 = requestAnimationFrame(() => { + fitGraphToViewport(); + requestAnimationFrame(() => { + fitGraphToViewport(); + }); + }); + + return () => cancelAnimationFrame(raf1); + } + }, [data.nodes.length, fitGraphToViewport]); + + useEffect(() => { + const el = containerRef.current; + if (!el || data.nodes.length === 0) return; + + let frame = 0; + const observer = new ResizeObserver(() => { + if (!allowAutoFitRef.current) return; + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + fitGraphToViewport(); + }); + }); + + observer.observe(el); + return () => { + observer.disconnect(); + cancelAnimationFrame(frame); + }; + }, [data.nodes.length, fitGraphToViewport]); + + const markUserInteracted = useCallback(() => { + allowAutoFitRef.current = false; + }, []); + + const handleWheel = useCallback((e: WheelEvent) => { + markUserInteracted(); + camera.handleWheel(e); + }, [camera, markUserInteracted]); // ─── Mouse handlers (Figma-style: drag empty space = pan, drag node = move) ─ const isPanningRef = useRef(false); @@ -208,13 +250,15 @@ export function GraphView({ if (interaction.dragNodeId.current) { // Hit a node → will drag it + markUserInteracted(); isPanningRef.current = false; } else { // Hit empty space → pan + markUserInteracted(); isPanningRef.current = true; camera.handlePanStart(e.clientX, e.clientY); } - }, [camera, interaction, simulation.stateRef]); + }, [camera, interaction, markUserInteracted, simulation.stateRef]); const handleMouseMove = useCallback((e: React.MouseEvent) => { // Dragging with left button held @@ -313,6 +357,58 @@ export function GraphView({ ? simulation.stateRef.current.nodes.find((n) => n.id === selectedNodeId) ?? null : null; + useLayoutEffect(() => { + if (!selectedNode || !containerRef.current || !overlayRef.current) { + return; + } + + const container = containerRef.current; + const floating = overlayRef.current; + + const reference = { + getBoundingClientRect(): DOMRect { + const containerRect = container.getBoundingClientRect(); + const screenPos = camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0); + return DOMRect.fromRect({ + x: containerRect.left + screenPos.x, + y: containerRect.top + screenPos.y, + width: 0, + height: 0, + }); + }, + }; + + const updatePosition = async (): Promise => { + const { x, y } = await computePosition(reference, floating, { + strategy: 'fixed', + placement: 'right-start', + middleware: [ + offset(16), + flip({ + boundary: container, + padding: 12, + fallbackPlacements: ['left-start', 'bottom-start', 'top-start'], + }), + shift({ + boundary: container, + padding: 12, + }), + ], + }); + + floating.style.left = `${x}px`; + floating.style.top = `${y}px`; + }; + + const cleanup = autoUpdate(reference, floating, updatePosition, { + animationFrame: true, + }); + + void updatePosition(); + + return cleanup; + }, [camera, selectedNode]); + // ─── Render ───────────────────────────────────────────────────────────── return (
@@ -321,7 +417,7 @@ export function GraphView({ showHexGrid={config?.showHexGrid ?? true} showStarField={config?.showStarField ?? true} bloomIntensity={config?.bloomIntensity ?? 0.6} - onWheel={camera.handleWheel} + onWheel={handleWheel} onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} @@ -331,9 +427,16 @@ export function GraphView({ { + markUserInteracted(); + camera.zoomIn(); + }} + onZoomOut={() => { + markUserInteracted(); + camera.zoomOut(); + }} onZoomToFit={() => { + markUserInteracted(); const el = containerRef.current; if (el) camera.zoomToFit(simulation.stateRef.current.nodes, el.clientWidth, el.clientHeight); }} @@ -345,28 +448,22 @@ export function GraphView({ isAlive={data.isAlive} /> - {selectedNode && renderOverlay ? ( -
- {renderOverlay({ - node: selectedNode, - screenPos: camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0), - onClose: () => setSelectedNodeId(null), - })} + {selectedNode && ( +
+ {renderOverlay ? ( + renderOverlay({ + node: selectedNode, + screenPos: camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0), + onClose: () => setSelectedNodeId(null), + }) + ) : ( + setSelectedNodeId(null)} + /> + )}
- ) : ( - setSelectedNodeId(null)} - /> )}
); diff --git a/src/renderer/components/layout/PaneContent.tsx b/src/renderer/components/layout/PaneContent.tsx index fb661611..a7449d29 100644 --- a/src/renderer/components/layout/PaneContent.tsx +++ b/src/renderer/components/layout/PaneContent.tsx @@ -21,9 +21,10 @@ import type { Pane } from '@renderer/types/panes'; interface PaneContentProps { pane: Pane; + isPaneFocused: boolean; } -export const PaneContent = ({ pane }: PaneContentProps): React.JSX.Element => { +export const PaneContent = ({ pane, isPaneFocused }: PaneContentProps): React.JSX.Element => { const activeTabId = pane.activeTabId; // Show default dashboard if no tabs are open in this pane @@ -51,7 +52,7 @@ export const PaneContent = ({ pane }: PaneContentProps): React.JSX.Element => { {tab.type === 'teams' && } {tab.type === 'team' && ( - + )} {tab.type === 'session' && ( @@ -68,7 +69,11 @@ export const PaneContent = ({ pane }: PaneContentProps): React.JSX.Element => { {tab.type === 'schedules' && } {tab.type === 'graph' && ( - + )}
diff --git a/src/renderer/components/layout/PaneView.tsx b/src/renderer/components/layout/PaneView.tsx index 29db5e24..51039bf3 100644 --- a/src/renderer/components/layout/PaneView.tsx +++ b/src/renderer/components/layout/PaneView.tsx @@ -49,7 +49,7 @@ export const PaneView = ({ paneId }: PaneViewProps): React.JSX.Element => { }} onMouseDown={handleMouseDown} > - + {/* Edge split drop zones - visible only during active drag when under MAX_PANES */} diff --git a/src/renderer/components/sidebar/DateGroupedSessions.tsx b/src/renderer/components/sidebar/DateGroupedSessions.tsx index dd98b1a3..0cf12c66 100644 --- a/src/renderer/components/sidebar/DateGroupedSessions.tsx +++ b/src/renderer/components/sidebar/DateGroupedSessions.tsx @@ -150,7 +150,7 @@ type VirtualItem = * Mismatch causes items to overlap! */ const HEADER_HEIGHT = 28; -const SESSION_HEIGHT = 58; // Must match h-[58px] in SessionItem.tsx +const SESSION_HEIGHT = 54; // Must match h-[54px] in SessionItem.tsx const LOADER_HEIGHT = 36; const OVERSCAN = 5; @@ -736,10 +736,10 @@ export const DateGroupedSessions = (): React.JSX.Element => { return (
{projectSelector} -
- +
+

{sessionSortMode === 'most-context' ? 'By Context' : 'Sessions'} @@ -747,7 +747,7 @@ export const DateGroupedSessions = (): React.JSX.Element => { {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- tooltip trigger via hover, not interactive */} setShowCountTooltip(true)} onMouseLeave={() => setShowCountTooltip(false)} @@ -898,7 +898,7 @@ export const DateGroupedSessions = (): React.JSX.Element => { > {item.type === 'pinned-header' ? (
{
) : item.type === 'header' ? (
(null); - const setGroupingMode = (mode: TaskGroupingMode): void => { setGroupingModeState(mode); saveGroupingMode(mode); @@ -326,8 +323,8 @@ export const GlobalTaskList = ({ })); }, [viewMode, repositoryGroups, projects]); - // Resolve local filter to a project path - const selectedProjectPath = localProjectFilter; + // Resolve project filter from filters state + const selectedProjectPath = filters.projectPath; const filtered = useMemo(() => { let result = globalTasks; @@ -355,7 +352,7 @@ export const GlobalTaskList = ({ return result; }, [ globalTasks, - selectedProjectPath, + filters.projectPath, filters.statusIds, filters.teamName, filters.readFilter, @@ -493,27 +490,13 @@ export const GlobalTaskList = ({ open={filtersPopoverOpen} onOpenChange={setFiltersPopoverOpen} teams={teams.map((t) => ({ teamName: t.teamName, displayName: t.displayName }))} + projectOptions={projectFilterOptions} filters={filters} onFiltersChange={setFilters} onApply={() => {}} />
- {/* Project filter */} -
- setLocalProjectFilter(v)} - placeholder="All Projects" - searchPlaceholder="Search projects..." - emptyMessage="No projects" - className="text-[11px]" - resetLabel="All Projects" - onReset={() => setLocalProjectFilter(null)} - /> -
- {/* Pinned tasks section */} {pinnedTasks.length > 0 && !showArchived && (
@@ -547,14 +530,10 @@ export const GlobalTaskList = ({
)} - {/* Grouping mode — compact segmented toggle */} + {/* Grouping mode — compact text toggle */}
Group by: -
+
{(['none', 'project', 'time'] as const).map((mode) => { const label = mode === 'none' ? 'None' : mode === 'project' ? 'Project' : 'Time'; return ( @@ -563,10 +542,8 @@ export const GlobalTaskList = ({ type="button" onClick={() => setGroupingMode(mode)} className={cn( - 'rounded px-2 py-0.5 transition-colors', - groupingMode === mode - ? 'bg-surface-raised text-text-secondary shadow-sm ring-1 ring-[var(--color-border)]' - : 'text-text-muted hover:text-text-secondary' + 'rounded px-1.5 py-0.5 transition-colors', + groupingMode === mode ? 'text-text' : 'text-text-muted hover:text-text-secondary' )} > {label} diff --git a/src/renderer/components/sidebar/SessionItem.tsx b/src/renderer/components/sidebar/SessionItem.tsx index 9695f22e..35098c35 100644 --- a/src/renderer/components/sidebar/SessionItem.tsx +++ b/src/renderer/components/sidebar/SessionItem.tsx @@ -240,13 +240,13 @@ export const SessionItem = ({ } }, [activeProjectId, openTab, selectSession, session.id, sessionLabel, splitPane]); - // Height must match SESSION_HEIGHT (58px) in DateGroupedSessions.tsx for virtual scroll + // Height must match SESSION_HEIGHT (54px) in DateGroupedSessions.tsx for virtual scroll return ( <>
+ {projectOptions.length > 0 && ( +
+ + Project + + setDraft({ ...draft, projectPath: v || null })} + placeholder="All Projects" + searchPlaceholder="Search projects..." + emptyMessage="No projects" + className="text-[12px]" + resetLabel="All Projects" + onReset={() => setDraft({ ...draft, projectPath: null })} + /> +
+ )} +
Comments diff --git a/src/renderer/components/sidebar/taskFiltersState.ts b/src/renderer/components/sidebar/taskFiltersState.ts index c7f0cc59..1cc7a86e 100644 --- a/src/renderer/components/sidebar/taskFiltersState.ts +++ b/src/renderer/components/sidebar/taskFiltersState.ts @@ -25,6 +25,7 @@ export type ReadFilter = 'all' | 'unread' | 'read'; export interface TaskFiltersState { statusIds: Set; teamName: string | null; + projectPath: string | null; /** @deprecated Use readFilter instead */ unreadOnly: boolean; readFilter: ReadFilter; @@ -33,6 +34,7 @@ export interface TaskFiltersState { export const defaultTaskFiltersState = (): TaskFiltersState => ({ statusIds: new Set(STATUS_OPTIONS.map((o) => o.id)), teamName: null, + projectPath: null, unreadOnly: false, readFilter: 'all', }); diff --git a/src/renderer/components/team/ClaudeLogsSection.tsx b/src/renderer/components/team/ClaudeLogsSection.tsx index a6ceb73e..1c8b56c5 100644 --- a/src/renderer/components/team/ClaudeLogsSection.tsx +++ b/src/renderer/components/team/ClaudeLogsSection.tsx @@ -3,7 +3,7 @@ import { useMemo, useState } from 'react'; import { Button } from '@renderer/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { cn } from '@renderer/lib/utils'; -import { Brain, Expand, MessageSquare, Terminal, Wrench } from 'lucide-react'; +import { Brain, Expand, MessageSquare, Wrench } from 'lucide-react'; import { ClaudeLogsDialog } from './ClaudeLogsDialog'; import { ClaudeLogsPanel } from './ClaudeLogsPanel'; @@ -96,11 +96,7 @@ export const ClaudeLogsSection = ({ - - - } + icon={null} badge={ctrl.badge} afterBadge={ ctrl.data.total > 0 ? ( diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 0a5104c9..7a015cc1 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -84,6 +84,9 @@ import { ScheduleSection } from './schedule/ScheduleSection'; import { ClaudeLogsSection } from './ClaudeLogsSection'; import { CollapsibleTeamSection } from './CollapsibleTeamSection'; import { ProcessesSection } from './ProcessesSection'; +import { TeamSidebarHost } from './sidebar/TeamSidebarHost'; +import { TeamSidebarPortalSource } from './sidebar/TeamSidebarPortalSource'; +import { TeamSidebarRail } from './sidebar/TeamSidebarRail'; import { TeamProvisioningBanner } from './TeamProvisioningBanner'; import { TeamSessionsSection } from './TeamSessionsSection'; @@ -102,6 +105,7 @@ import type { EditorSelectionAction } from '@shared/types/editor'; interface TeamDetailViewProps { teamName: string; + isPaneFocused?: boolean; } interface CreateTaskDialogState { @@ -178,7 +182,10 @@ function filterKanbanTasks(tasks: TeamTaskWithKanban[], query: string): TeamTask ); } -export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Element => { +export const TeamDetailView = ({ + teamName, + isPaneFocused = false, +}: TeamDetailViewProps): React.JSX.Element => { const { isLight } = useTheme(); const [requestChangesTaskId, setRequestChangesTaskId] = useState(null); const [selectedTask, setSelectedTask] = useState(null); @@ -1101,6 +1108,27 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele const headerColorSet = data.config.color ? getTeamColorSet(data.config.color) : nameColorSet(data.config.name); + const sharedMessagesPanelProps = { + teamName, + onTogglePosition: toggleMessagesPanelMode, + members: activeMembers, + tasks: data.tasks, + messages: data.messages, + isTeamAlive: data.isAlive, + leadActivity: leadActivityByTeam[teamName], + leadContextUpdatedAt, + timeWindow, + teamSessionIds, + currentLeadSessionId: data.config.leadSessionId, + pendingRepliesByMember, + onPendingReplyChange: setPendingRepliesByMember, + onMemberClick: setSelectedMember, + onTaskClick: setSelectedTask, + onCreateTaskFromMessage: handleCreateTaskFromMessage, + onReplyToMessage: handleReplyToMessage, + onRestartTeam: handleRestartTeam, + onTaskIdClick: handleTaskIdClick, + }; return ( <> @@ -1155,48 +1183,25 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele )} {/* Messages sidebar (left, after context panel) */} - {messagesPanelMode === 'sidebar' && ( -
+ -
-
- -
-
-
- -
-
- {/* Resize handle */} -
-
- )} + +
} {messagesPanelMode === 'inline' && ( - + )} s.openTeamTab); const composerTextareaRef = useRef(null); + const sidebarScrollRef = useRef(null); const handleExpandContent = useCallback(() => { // no-op: user is reading expanded content, not composing }, []); - const [messagesSearchQuery, setMessagesSearchQuery] = useState(''); - const [messagesFilter, setMessagesFilter] = useState({ - from: new Set(), - to: new Set(), - showNoise: false, - }); - const [messagesFilterOpen, setMessagesFilterOpen] = useState(false); - const [messagesCollapsed, setMessagesCollapsed] = useState(true); - const [sidebarSearchVisible, setSidebarSearchVisible] = useState(false); - const [expandedItemKey, setExpandedItemKey] = useState(null); + const initialSidebarStateRef = useRef(getTeamMessagesSidebarUiState(teamName)); + const [messagesSearchQuery, setMessagesSearchQuery] = useState( + initialSidebarStateRef.current.messagesSearchQuery + ); + const [messagesFilter, setMessagesFilter] = useState( + initialSidebarStateRef.current.messagesFilter + ); + const [messagesFilterOpen, setMessagesFilterOpen] = useState( + initialSidebarStateRef.current.messagesFilterOpen + ); + const [messagesCollapsed, setMessagesCollapsed] = useState( + initialSidebarStateRef.current.messagesCollapsed + ); + const [sidebarSearchVisible, setSidebarSearchVisible] = useState( + initialSidebarStateRef.current.sidebarSearchVisible + ); + const [expandedItemKey, setExpandedItemKey] = useState( + initialSidebarStateRef.current.expandedItemKey + ); + const [sidebarScrollTop, setSidebarScrollTop] = useState( + initialSidebarStateRef.current.sidebarScrollTop + ); + + useEffect(() => { + initialSidebarStateRef.current = getTeamMessagesSidebarUiState(teamName); + setMessagesSearchQuery(initialSidebarStateRef.current.messagesSearchQuery); + setMessagesFilter(initialSidebarStateRef.current.messagesFilter); + setMessagesFilterOpen(initialSidebarStateRef.current.messagesFilterOpen); + setMessagesCollapsed(initialSidebarStateRef.current.messagesCollapsed); + setSidebarSearchVisible(initialSidebarStateRef.current.sidebarSearchVisible); + setExpandedItemKey(initialSidebarStateRef.current.expandedItemKey); + setSidebarScrollTop(initialSidebarStateRef.current.sidebarScrollTop); + }, [teamName]); + + useEffect(() => { + setTeamMessagesSidebarUiState(teamName, { + messagesSearchQuery, + messagesFilter, + messagesFilterOpen, + messagesCollapsed, + sidebarSearchVisible, + expandedItemKey, + sidebarScrollTop, + }); + }, [ + teamName, + messagesSearchQuery, + messagesFilter, + messagesFilterOpen, + messagesCollapsed, + sidebarSearchVisible, + expandedItemKey, + sidebarScrollTop, + ]); + + useLayoutEffect(() => { + if (position !== 'sidebar') return; + const el = sidebarScrollRef.current; + if (!el) return; + el.scrollTop = sidebarScrollTop; + }, [position, sidebarScrollTop]); const filteredMessages = useMemo(() => { return filterTeamMessages(messages, { @@ -479,7 +535,11 @@ export const MessagesPanel = memo(function MessagesPanel({
)} {/* Scrollable content */} -
+
setSidebarScrollTop(e.currentTarget.scrollTop)} + >
(null); + +interface TeamSidebarHostProps { + teamName: string; + surface: TeamSidebarSurface; + isActive: boolean; + isFocused: boolean; + children?: React.ReactNode; +} + +export function useTeamSidebarHostId(): string | null { + return useContext(TeamSidebarHostContext); +} + +export const TeamSidebarHost = ({ + teamName, + surface, + isActive, + isFocused, + children, +}: TeamSidebarHostProps): React.JSX.Element => { + const hostId = useId(); + const [element, setElement] = useState(null); + const { messagesPanelMode, messagesPanelWidth } = useStore((s) => ({ + messagesPanelMode: s.messagesPanelMode, + messagesPanelWidth: s.messagesPanelWidth, + })); + const snapshot = useTeamSidebarPortalSnapshot(); + const isVisible = messagesPanelMode === 'sidebar'; + const isOwner = isVisible && snapshot.activeHostIdByTeam[teamName] === hostId; + + useLayoutEffect(() => { + upsertTeamSidebarHost(hostId, { + teamName, + surface, + element, + isActive, + isFocused, + }); + return () => { + removeTeamSidebarHost(hostId); + }; + }, [element, hostId, isActive, isFocused, surface, teamName]); + + return ( + +
+ {children} +
+
+ ); +}; diff --git a/src/renderer/components/team/sidebar/TeamSidebarPortalManager.ts b/src/renderer/components/team/sidebar/TeamSidebarPortalManager.ts new file mode 100644 index 00000000..83de088a --- /dev/null +++ b/src/renderer/components/team/sidebar/TeamSidebarPortalManager.ts @@ -0,0 +1,173 @@ +import { useSyncExternalStore } from 'react'; + +export type TeamSidebarSurface = 'team' | 'graph-tab' | 'graph-overlay'; + +interface TeamSidebarHostEntry { + id: string; + teamName: string; + surface: TeamSidebarSurface; + element: HTMLElement | null; + isActive: boolean; + isFocused: boolean; + order: number; +} + +interface TeamSidebarSourceEntry { + id: string; + teamName: string; + isActive: boolean; + isFocused: boolean; + order: number; +} + +interface TeamSidebarSnapshot { + version: number; + activeHostIdByTeam: Record; + activeSourceIdByTeam: Record; +} + +const SURFACE_PRIORITY: Record = { + team: 1, + 'graph-tab': 2, + 'graph-overlay': 3, +}; + +const hostById = new Map(); +const sourceById = new Map(); +const listeners = new Set<() => void>(); +let version = 0; +let nextOrder = 1; + +function emit(): void { + version += 1; + for (const listener of listeners) { + listener(); + } +} + +function sortHosts(a: TeamSidebarHostEntry, b: TeamSidebarHostEntry): number { + const focusedDiff = Number(b.isFocused) - Number(a.isFocused); + if (focusedDiff !== 0) return focusedDiff; + const activeDiff = Number(b.isActive) - Number(a.isActive); + if (activeDiff !== 0) return activeDiff; + const priorityDiff = SURFACE_PRIORITY[b.surface] - SURFACE_PRIORITY[a.surface]; + if (priorityDiff !== 0) return priorityDiff; + return b.order - a.order; +} + +function sortSources(a: TeamSidebarSourceEntry, b: TeamSidebarSourceEntry): number { + const focusedDiff = Number(b.isFocused) - Number(a.isFocused); + if (focusedDiff !== 0) return focusedDiff; + const activeDiff = Number(b.isActive) - Number(a.isActive); + if (activeDiff !== 0) return activeDiff; + return b.order - a.order; +} + +function buildSnapshot(): TeamSidebarSnapshot { + const activeHostIdByTeam: Record = {}; + const activeSourceIdByTeam: Record = {}; + + const hostsByTeam = new Map(); + for (const host of hostById.values()) { + if (!host.element) continue; + const list = hostsByTeam.get(host.teamName) ?? []; + list.push(host); + hostsByTeam.set(host.teamName, list); + } + for (const [teamName, hosts] of hostsByTeam.entries()) { + const winner = [...hosts].sort(sortHosts)[0]; + if (winner) activeHostIdByTeam[teamName] = winner.id; + } + + const sourcesByTeam = new Map(); + for (const source of sourceById.values()) { + const list = sourcesByTeam.get(source.teamName) ?? []; + list.push(source); + sourcesByTeam.set(source.teamName, list); + } + for (const [teamName, sources] of sourcesByTeam.entries()) { + const winner = [...sources].sort(sortSources)[0]; + if (winner) activeSourceIdByTeam[teamName] = winner.id; + } + + return { + version, + activeHostIdByTeam, + activeSourceIdByTeam, + }; +} + +let cachedSnapshot = buildSnapshot(); + +function refreshSnapshot(): void { + cachedSnapshot = buildSnapshot(); + emit(); +} + +export function upsertTeamSidebarHost( + id: string, + entry: Omit +): void { + const existing = hostById.get(id); + hostById.set(id, { + id, + order: existing?.order ?? nextOrder++, + ...entry, + }); + refreshSnapshot(); +} + +export function removeTeamSidebarHost(id: string): void { + if (!hostById.delete(id)) return; + refreshSnapshot(); +} + +export function upsertTeamSidebarSource( + id: string, + entry: Omit +): void { + const existing = sourceById.get(id); + sourceById.set(id, { + id, + order: existing?.order ?? nextOrder++, + ...entry, + }); + refreshSnapshot(); +} + +export function removeTeamSidebarSource(id: string): void { + if (!sourceById.delete(id)) return; + refreshSnapshot(); +} + +export function getTeamSidebarHostElement(hostId: string): HTMLElement | null { + return hostById.get(hostId)?.element ?? null; +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function getSnapshot(): TeamSidebarSnapshot { + return cachedSnapshot; +} + +export function useTeamSidebarPortalSnapshot(): TeamSidebarSnapshot { + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +export function getTeamSidebarPortalSnapshotForTests(): TeamSidebarSnapshot { + return cachedSnapshot; +} + +export function resetTeamSidebarPortalManagerForTests(): void { + hostById.clear(); + sourceById.clear(); + listeners.clear(); + version = 0; + nextOrder = 1; + cachedSnapshot = buildSnapshot(); +} diff --git a/src/renderer/components/team/sidebar/TeamSidebarPortalSource.tsx b/src/renderer/components/team/sidebar/TeamSidebarPortalSource.tsx new file mode 100644 index 00000000..c72eab36 --- /dev/null +++ b/src/renderer/components/team/sidebar/TeamSidebarPortalSource.tsx @@ -0,0 +1,66 @@ +import { useId, useLayoutEffect } from 'react'; +import { createPortal } from 'react-dom'; + +import { useStore } from '@renderer/store'; + +import { + getTeamSidebarHostElement, + removeTeamSidebarSource, + upsertTeamSidebarSource, + useTeamSidebarPortalSnapshot, +} from './TeamSidebarPortalManager'; +import { useTeamSidebarHostId } from './TeamSidebarHost'; + +interface TeamSidebarPortalSourceProps { + teamName: string; + isActive: boolean; + isFocused: boolean; + children: React.ReactNode; +} + +export const TeamSidebarPortalSource = ({ + teamName, + isActive, + isFocused, + children, +}: TeamSidebarPortalSourceProps): React.JSX.Element | null => { + const sourceId = useId(); + const hostId = useTeamSidebarHostId(); + const messagesPanelMode = useStore((s) => s.messagesPanelMode); + const snapshot = useTeamSidebarPortalSnapshot(); + + useLayoutEffect(() => { + upsertTeamSidebarSource(sourceId, { + teamName, + isActive, + isFocused, + }); + return () => { + removeTeamSidebarSource(sourceId); + }; + }, [isActive, isFocused, sourceId, teamName]); + + if (!hostId || messagesPanelMode !== 'sidebar') { + return null; + } + + if (snapshot.activeSourceIdByTeam[teamName] !== sourceId) { + return null; + } + + const activeHostId = snapshot.activeHostIdByTeam[teamName]; + if (!activeHostId) { + return null; + } + + if (activeHostId === hostId) { + return <>{children}; + } + + const target = getTeamSidebarHostElement(activeHostId); + if (!target) { + return null; + } + + return createPortal(children, target); +}; diff --git a/src/renderer/components/team/sidebar/TeamSidebarRail.tsx b/src/renderer/components/team/sidebar/TeamSidebarRail.tsx new file mode 100644 index 00000000..73331d28 --- /dev/null +++ b/src/renderer/components/team/sidebar/TeamSidebarRail.tsx @@ -0,0 +1,37 @@ +import { ClaudeLogsSection } from '../ClaudeLogsSection'; +import { MessagesPanel } from '../messages/MessagesPanel'; + +import type { MouseEventHandler } from 'react'; +import type { ComponentProps } from 'react'; + +type SharedMessagesPanelProps = Omit, 'position'>; + +interface TeamSidebarRailProps { + teamName: string; + messagesPanelProps: SharedMessagesPanelProps; + isResizing: boolean; + onResizeMouseDown: MouseEventHandler; +} + +export const TeamSidebarRail = ({ + teamName, + messagesPanelProps, + isResizing, + onResizeMouseDown, +}: TeamSidebarRailProps): React.JSX.Element => { + return ( +
+
+ +
+
+
+ +
+
+
+ ); +}; diff --git a/src/renderer/components/team/sidebar/teamSidebarUiState.ts b/src/renderer/components/team/sidebar/teamSidebarUiState.ts new file mode 100644 index 00000000..ae7acd82 --- /dev/null +++ b/src/renderer/components/team/sidebar/teamSidebarUiState.ts @@ -0,0 +1,122 @@ +import { DEFAULT_CLAUDE_LOGS_FILTER } from '../ClaudeLogsFilterPopover'; + +import type { ClaudeLogsFilterState } from '../ClaudeLogsFilterPopover'; +import type { ClaudeLogsViewerState } from '../CliLogsRichView'; +import type { MessagesFilterState } from '../messages/MessagesFilterPopover'; + +export interface TeamMessagesSidebarUiState { + messagesSearchQuery: string; + messagesFilter: MessagesFilterState; + messagesFilterOpen: boolean; + messagesCollapsed: boolean; + sidebarSearchVisible: boolean; + expandedItemKey: string | null; + sidebarScrollTop: number; +} + +export interface TeamClaudeLogsSidebarUiState { + searchQuery: string; + filter: ClaudeLogsFilterState; + filterOpen: boolean; + viewerState: ClaudeLogsViewerState; +} + +const messagesStateByTeam = new Map(); +const claudeLogsStateByTeam = new Map(); + +function cloneMessagesFilter(filter: MessagesFilterState): MessagesFilterState { + return { + from: new Set(filter.from), + to: new Set(filter.to), + showNoise: filter.showNoise, + }; +} + +function cloneClaudeLogsFilter(filter: ClaudeLogsFilterState): ClaudeLogsFilterState { + return { + streams: new Set(filter.streams), + kinds: new Set(filter.kinds), + }; +} + +function cloneViewerState(viewerState: ClaudeLogsViewerState): ClaudeLogsViewerState { + return { + collapsedGroupIds: new Set(viewerState.collapsedGroupIds), + expandedItemIds: new Set(viewerState.expandedItemIds), + expandedSubagentIds: new Set(viewerState.expandedSubagentIds), + viewport: { ...viewerState.viewport }, + }; +} + +export function createDefaultMessagesSidebarUiState(): TeamMessagesSidebarUiState { + return { + messagesSearchQuery: '', + messagesFilter: { + from: new Set(), + to: new Set(), + showNoise: false, + }, + messagesFilterOpen: false, + messagesCollapsed: true, + sidebarSearchVisible: false, + expandedItemKey: null, + sidebarScrollTop: 0, + }; +} + +export function createDefaultClaudeLogsSidebarUiState(): TeamClaudeLogsSidebarUiState { + return { + searchQuery: '', + filter: { + streams: new Set(DEFAULT_CLAUDE_LOGS_FILTER.streams), + kinds: new Set(DEFAULT_CLAUDE_LOGS_FILTER.kinds), + }, + filterOpen: false, + viewerState: { + collapsedGroupIds: new Set(), + expandedItemIds: new Set(), + expandedSubagentIds: new Set(), + viewport: { mode: 'edge', edge: 'newest' }, + }, + }; +} + +export function getTeamMessagesSidebarUiState(teamName: string): TeamMessagesSidebarUiState { + const state = messagesStateByTeam.get(teamName) ?? createDefaultMessagesSidebarUiState(); + return { + ...state, + messagesFilter: cloneMessagesFilter(state.messagesFilter), + }; +} + +export function setTeamMessagesSidebarUiState( + teamName: string, + state: TeamMessagesSidebarUiState +): void { + messagesStateByTeam.set(teamName, { + ...state, + messagesFilter: cloneMessagesFilter(state.messagesFilter), + }); +} + +export function getTeamClaudeLogsSidebarUiState(teamName: string): TeamClaudeLogsSidebarUiState { + const state = claudeLogsStateByTeam.get(teamName) ?? createDefaultClaudeLogsSidebarUiState(); + return { + searchQuery: state.searchQuery, + filter: cloneClaudeLogsFilter(state.filter), + filterOpen: state.filterOpen, + viewerState: cloneViewerState(state.viewerState), + }; +} + +export function setTeamClaudeLogsSidebarUiState( + teamName: string, + state: TeamClaudeLogsSidebarUiState +): void { + claudeLogsStateByTeam.set(teamName, { + searchQuery: state.searchQuery, + filter: cloneClaudeLogsFilter(state.filter), + filterOpen: state.filterOpen, + viewerState: cloneViewerState(state.viewerState), + }); +} diff --git a/src/renderer/components/team/useClaudeLogsController.ts b/src/renderer/components/team/useClaudeLogsController.ts index 6f190a88..bee14201 100644 --- a/src/renderer/components/team/useClaudeLogsController.ts +++ b/src/renderer/components/team/useClaudeLogsController.ts @@ -14,6 +14,11 @@ import { api } from '@renderer/api'; import { useStore } from '@renderer/store'; import { DEFAULT_CLAUDE_LOGS_FILTER } from './ClaudeLogsFilterPopover'; +import { + createDefaultClaudeLogsSidebarUiState, + getTeamClaudeLogsSidebarUiState, + setTeamClaudeLogsSidebarUiState, +} from './sidebar/teamSidebarUiState'; import type { ClaudeLogsFilterState } from './ClaudeLogsFilterPopover'; import type { ClaudeLogsViewerState } from './CliLogsRichView'; @@ -364,12 +369,7 @@ function filterStreamJsonText( // ============================================================================= function createDefaultViewerState(): ClaudeLogsViewerState { - return { - collapsedGroupIds: new Set(), - expandedItemIds: new Set(), - expandedSubagentIds: new Set(), - viewport: { mode: 'edge', edge: 'newest' }, - }; + return createDefaultClaudeLogsSidebarUiState().viewerState; } // ============================================================================= @@ -389,15 +389,17 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController const [error, setError] = useState(null); // ── Search & filter state ───────────────────────────────────────────── - const [searchQuery, setSearchQuery] = useState(''); - const [filter, setFilter] = useState(() => ({ - streams: new Set(DEFAULT_CLAUDE_LOGS_FILTER.streams), - kinds: new Set(DEFAULT_CLAUDE_LOGS_FILTER.kinds), - })); - const [filterOpen, setFilterOpen] = useState(false); + const initialSidebarStateRef = useRef(getTeamClaudeLogsSidebarUiState(teamName)); + const [searchQuery, setSearchQuery] = useState(initialSidebarStateRef.current.searchQuery); + const [filter, setFilter] = useState( + initialSidebarStateRef.current.filter + ); + const [filterOpen, setFilterOpen] = useState(initialSidebarStateRef.current.filterOpen); // ── Viewer state (expansion + viewport) ─────────────────────────────── - const [viewerState, setViewerState] = useState(createDefaultViewerState); + const [viewerState, setViewerState] = useState( + initialSidebarStateRef.current.viewerState + ); const onViewerStateChange = useCallback((state: ClaudeLogsViewerState) => { setViewerState(state); @@ -415,6 +417,7 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController // ── Reset on team change ────────────────────────────────────────────── useEffect(() => { + initialSidebarStateRef.current = getTeamClaudeLogsSidebarUiState(teamName); setLoadedCount(PAGE_SIZE); setData({ lines: [], total: 0, hasMore: false }); setPending(null); @@ -422,14 +425,21 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController latestRef.current = null; atTopRef.current = true; setError(null); - setSearchQuery(''); - setFilter({ - streams: new Set(DEFAULT_CLAUDE_LOGS_FILTER.streams), - kinds: new Set(DEFAULT_CLAUDE_LOGS_FILTER.kinds), - }); - setViewerState(createDefaultViewerState()); + setSearchQuery(initialSidebarStateRef.current.searchQuery); + setFilter(initialSidebarStateRef.current.filter); + setFilterOpen(initialSidebarStateRef.current.filterOpen); + setViewerState(initialSidebarStateRef.current.viewerState); }, [teamName]); + useEffect(() => { + setTeamClaudeLogsSidebarUiState(teamName, { + searchQuery, + filter, + filterOpen, + viewerState, + }); + }, [teamName, searchQuery, filter, filterOpen, viewerState]); + // ── Sync refs ───────────────────────────────────────────────────────── useEffect(() => { committedRef.current = data; diff --git a/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx b/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx index ac42d858..91219682 100644 --- a/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx +++ b/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx @@ -6,6 +6,7 @@ import { useCallback } from 'react'; import { GraphView } from '@claude-teams/agent-graph'; +import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost'; import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter'; import { GraphNodePopover } from './GraphNodePopover'; @@ -54,13 +55,14 @@ export const TeamGraphOverlay = ({ }; return ( -
+
+ ( export interface TeamGraphTabProps { teamName: string; isActive?: boolean; + isPaneFocused?: boolean; } export const TeamGraphTab = ({ teamName, isActive = true, + isPaneFocused = false, }: TeamGraphTabProps): React.JSX.Element => { const graphData = useTeamGraphAdapter(teamName); const [fullscreen, setFullscreen] = useState(false); @@ -68,24 +71,32 @@ export const TeamGraphTab = ({ }; return ( -
- setFullscreen(true)} - renderOverlay={({ node, onClose }) => ( - - )} +
+ +
+ setFullscreen(true)} + renderOverlay={({ node, onClose }) => ( + + )} + /> +
{fullscreen && ( setFullscreen(false)} /> diff --git a/test/renderer/components/team/sidebar/TeamSidebarPortalManager.test.ts b/test/renderer/components/team/sidebar/TeamSidebarPortalManager.test.ts new file mode 100644 index 00000000..738e6b84 --- /dev/null +++ b/test/renderer/components/team/sidebar/TeamSidebarPortalManager.test.ts @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + getTeamSidebarPortalSnapshotForTests, + resetTeamSidebarPortalManagerForTests, + upsertTeamSidebarHost, + upsertTeamSidebarSource, +} from '@renderer/components/team/sidebar/TeamSidebarPortalManager'; + +afterEach(() => { + resetTeamSidebarPortalManagerForTests(); +}); + +describe('TeamSidebarPortalManager', () => { + it('prefers overlay host over graph tab and team hosts for the same team', () => { + upsertTeamSidebarHost('team-host', { + teamName: 'alpha', + surface: 'team', + element: document.createElement('div'), + isActive: true, + isFocused: true, + }); + upsertTeamSidebarHost('graph-host', { + teamName: 'alpha', + surface: 'graph-tab', + element: document.createElement('div'), + isActive: true, + isFocused: false, + }); + upsertTeamSidebarHost('overlay-host', { + teamName: 'alpha', + surface: 'graph-overlay', + element: document.createElement('div'), + isActive: true, + isFocused: true, + }); + + const snapshot = getTeamSidebarPortalSnapshotForTests(); + + expect(snapshot.activeHostIdByTeam.alpha).toBe('overlay-host'); + }); + + it('prefers the active team host over an inactive graph host', () => { + upsertTeamSidebarHost('team-host', { + teamName: 'alpha', + surface: 'team', + element: document.createElement('div'), + isActive: true, + isFocused: true, + }); + upsertTeamSidebarHost('graph-host', { + teamName: 'alpha', + surface: 'graph-tab', + element: document.createElement('div'), + isActive: false, + isFocused: false, + }); + + const snapshot = getTeamSidebarPortalSnapshotForTests(); + + expect(snapshot.activeHostIdByTeam.alpha).toBe('team-host'); + }); + + it('prefers focused graph host over unfocused graph host of the same priority', () => { + upsertTeamSidebarHost('graph-a', { + teamName: 'alpha', + surface: 'graph-tab', + element: document.createElement('div'), + isActive: true, + isFocused: false, + }); + upsertTeamSidebarHost('graph-b', { + teamName: 'alpha', + surface: 'graph-tab', + element: document.createElement('div'), + isActive: true, + isFocused: true, + }); + + const snapshot = getTeamSidebarPortalSnapshotForTests(); + + expect(snapshot.activeHostIdByTeam.alpha).toBe('graph-b'); + }); + + it('prefers focused active source over stale mounted source for the same team', () => { + upsertTeamSidebarSource('source-a', { + teamName: 'alpha', + isActive: true, + isFocused: false, + }); + upsertTeamSidebarSource('source-b', { + teamName: 'alpha', + isActive: true, + isFocused: true, + }); + + const snapshot = getTeamSidebarPortalSnapshotForTests(); + + expect(snapshot.activeSourceIdByTeam.alpha).toBe('source-b'); + }); +});