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.
This commit is contained in:
iliya 2026-03-29 00:05:29 +02:00
parent adc61ebcfa
commit 304a2a7f79
22 changed files with 1105 additions and 332 deletions

View file

@ -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<HTMLDivElement>(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 (
<div className="absolute top-2 left-20 right-3 flex items-center justify-between pointer-events-none z-10 gap-3">
{/* Left: team name + status indicator */}
<div className="flex items-center pointer-events-auto">
<>
<div className="absolute left-3 top-3 z-10 flex items-center gap-3 pointer-events-none">
<div
className="flex items-center gap-2 rounded-lg px-3 py-1.5 backdrop-blur-sm"
className="pointer-events-auto flex items-center gap-2 rounded-lg px-3 py-1.5 backdrop-blur-sm"
style={{
background: 'rgba(8, 12, 24, 0.8)',
border: `1px solid ${nameColor}25`,
}}
>
{isAlive && (
<div
className="size-2 rounded-full animate-pulse"
style={{ background: nameColor }}
/>
<div className="size-2 rounded-full animate-pulse" style={{ background: nameColor }} />
)}
<span
className="text-xs font-mono font-semibold"
style={{ color: nameColor }}
>
<span className="text-xs font-mono font-semibold" style={{ color: nameColor }}>
{teamName}
</span>
<span
className="rounded px-1 py-0.5 text-[9px] font-mono"
style={{ background: 'rgba(100, 200, 255, 0.1)', color: '#66ccff90' }}
>
beta
</span>
</div>
</div>
{/* Center: filter toggles */}
<div
className="flex items-center gap-0.5 rounded-lg px-1 py-0.5 pointer-events-auto backdrop-blur-sm"
style={{ background: 'rgba(8, 12, 24, 0.8)', border: '1px solid rgba(100, 200, 255, 0.08)' }}
>
<ToolbarToggle
active={filters.showTasks}
onClick={() => toggle('showTasks')}
icon={<Columns3 size={13} />}
label="Tasks"
/>
<ToolbarToggle
active={filters.showProcesses}
onClick={() => toggle('showProcesses')}
icon={<Server size={13} />}
label="Proc"
/>
<ToolbarToggle
active={filters.showEdges}
onClick={() => toggle('showEdges')}
icon={filters.showEdges ? <Eye size={13} /> : <EyeOff size={13} />}
label="Edges"
/>
<Separator />
<ToolbarButton
onClick={() => toggle('paused')}
icon={filters.paused ? <Play size={13} /> : <Pause size={13} />}
/>
</div>
{/* Right: zoom + actions */}
<div
className="flex items-center gap-0.5 rounded-lg px-1 py-0.5 pointer-events-auto backdrop-blur-sm"
style={{ background: 'rgba(8, 12, 24, 0.8)', border: '1px solid rgba(100, 200, 255, 0.08)' }}
>
<ToolbarButton onClick={onZoomOut} icon={<Minus size={13} />} />
<ToolbarButton onClick={onZoomToFit} icon={<Maximize2 size={13} />} />
<ToolbarButton onClick={onZoomIn} icon={<Plus size={13} />} />
{onRequestPinAsTab && (
<>
<Separator />
<div className="absolute right-3 top-3 z-10 flex items-center gap-2 pointer-events-none">
<div ref={settingsRef} className="relative pointer-events-auto">
<div
className="flex items-center gap-0.5 rounded-lg px-1 py-0.5 backdrop-blur-sm"
style={{
background: 'rgba(8, 12, 24, 0.8)',
border: '1px solid rgba(100, 200, 255, 0.08)',
}}
>
<ToolbarButton
onClick={onRequestPinAsTab}
icon={<Pin size={13} />}
label="Pin"
onClick={() => setIsSettingsOpen((value) => !value)}
icon={<Settings2 size={14} />}
label="View"
active={isSettingsOpen}
/>
</>
)}
{onRequestFullscreen && (
<>
<Separator />
</div>
{isSettingsOpen && (
<div
className="absolute right-0 top-[calc(100%+0.5rem)] w-44 rounded-xl p-1.5 shadow-2xl"
style={{
background: 'rgba(8, 12, 24, 0.96)',
border: '1px solid rgba(100, 200, 255, 0.12)',
}}
>
<ToolbarToggle
active={filters.showTasks}
onClick={() => toggle('showTasks')}
icon={<Columns3 size={13} />}
label="Tasks"
block
/>
<ToolbarToggle
active={filters.showProcesses}
onClick={() => toggle('showProcesses')}
icon={<Server size={13} />}
label="Processes"
block
/>
<ToolbarToggle
active={filters.showEdges}
onClick={() => toggle('showEdges')}
icon={filters.showEdges ? <Eye size={13} /> : <EyeOff size={13} />}
label="Edges"
block
/>
<div className="my-1 h-px bg-[rgba(100,200,255,0.08)]" />
<ToolbarToggle
active={!filters.paused}
onClick={() => toggle('paused')}
icon={filters.paused ? <Play size={13} /> : <Pause size={13} />}
label={filters.paused ? 'Resume' : 'Pause'}
block
/>
</div>
)}
</div>
<div
className="flex items-center gap-2 rounded-lg px-3 py-1.5 backdrop-blur-sm"
style={{
background: 'rgba(8, 12, 24, 0.8)',
border: '1px solid rgba(100, 200, 255, 0.08)',
}}
>
{onRequestPinAsTab && <ToolbarButton onClick={onRequestPinAsTab} icon={<Pin size={13} />} />}
{onRequestFullscreen && (
<ToolbarButton
onClick={onRequestFullscreen}
icon={<Expand size={13} />}
label="Fullscreen"
/>
</>
)}
{onRequestClose && (
<ToolbarButton onClick={onRequestClose} icon={<X size={13} />} />
)}
)}
{onRequestClose && <ToolbarButton onClick={onRequestClose} icon={<X size={13} />} />}
</div>
</div>
</div>
<div className="absolute bottom-3 right-3 z-10 pointer-events-none">
<div
className="pointer-events-auto flex items-center gap-0.5 rounded-lg px-1 py-0.5 backdrop-blur-sm"
style={{
background: 'rgba(8, 12, 24, 0.86)',
border: '1px solid rgba(100, 200, 255, 0.1)',
}}
>
<ToolbarButton onClick={onZoomOut} icon={<ZoomOut size={14} />} />
<ToolbarButton onClick={onZoomToFit} icon={<Maximize2 size={14} />} label="Fit" />
<ToolbarButton onClick={onZoomIn} icon={<ZoomIn size={14} />} />
</div>
</div>
</>
);
}
@ -166,16 +208,21 @@ function ToolbarButton({
onClick,
icon,
label,
active = false,
}: {
onClick?: () => void;
icon: React.ReactNode;
label?: string;
active?: boolean;
}): React.JSX.Element {
return (
<button
onClick={onClick}
className="flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-mono transition-colors
text-[#66ccff90] hover:text-[#aaeeff] hover:bg-[rgba(100,200,255,0.1)] cursor-pointer"
className={`flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-mono transition-colors cursor-pointer ${
active
? 'text-[#aaeeff] bg-[rgba(100,200,255,0.14)]'
: 'text-[#66ccff90] hover:text-[#aaeeff] hover:bg-[rgba(100,200,255,0.1)]'
}`}
>
{icon}
{label && <span>{label}</span>}
@ -188,27 +235,27 @@ function ToolbarToggle({
onClick,
icon,
label,
block = false,
}: {
active: boolean;
onClick: () => void;
icon: React.ReactNode;
label: string;
block?: boolean;
}): React.JSX.Element {
return (
<button
onClick={onClick}
className={`flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-[11px] font-mono transition-all cursor-pointer border
${active
className={`flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-[11px] font-mono transition-all cursor-pointer border ${
block ? 'w-full justify-start' : ''
} ${
active
? 'text-[#aaeeff] bg-[rgba(100,200,255,0.15)] border-[rgba(100,200,255,0.25)]'
: 'text-[#66ccff50] bg-transparent border-transparent hover:text-[#66ccff90] hover:bg-[rgba(100,200,255,0.06)]'
}`}
}`}
>
{icon}
<span>{label}</span>
</button>
);
}
function Separator(): React.JSX.Element {
return <div className="mx-0.5 h-4 w-px bg-[rgba(100,200,255,0.08)]" />;
}

View file

@ -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 (
<div
className="absolute z-20 pointer-events-auto"
className="rounded-lg p-3 min-w-[160px] max-w-[220px] shadow-xl"
style={{
left: `${screenPos.x + 20}px`,
top: `${screenPos.y - 20}px`,
transform: 'translateY(-50%)',
background: 'rgba(10, 15, 30, 0.9)',
border: '1px solid rgba(100, 200, 255, 0.15)',
backdropFilter: 'blur(8px)',
}}
>
<div
className="rounded-lg p-3 min-w-[160px] max-w-[220px] shadow-xl"
style={{
background: 'rgba(10, 15, 30, 0.9)',
border: '1px solid rgba(100, 200, 255, 0.15)',
backdropFilter: 'blur(8px)',
}}
>
<div className="text-xs font-mono font-bold" style={{ color: selectedNode.color ?? '#aaeeff' }}>
{selectedNode.label}
<div className="text-xs font-mono font-bold" style={{ color: selectedNode.color ?? '#aaeeff' }}>
{selectedNode.label}
</div>
{selectedNode.sublabel && (
<div className="mt-0.5 text-[10px] truncate" style={{ color: '#66ccff90' }}>
{selectedNode.sublabel}
</div>
{selectedNode.sublabel && (
<div className="mt-0.5 text-[10px] truncate" style={{ color: '#66ccff90' }}>
{selectedNode.sublabel}
</div>
)}
{selectedNode.role && (
<div className="mt-0.5 text-[10px]" style={{ color: '#66ccff70' }}>
{selectedNode.role}
</div>
)}
<div className="mt-2 flex gap-1">
{(selectedNode.kind === 'member' || selectedNode.kind === 'lead') && (
<FallbackButton
label="Message"
onClick={() => {
const ref = selectedNode.domainRef;
if (ref.kind === 'member') events?.onSendMessage?.(ref.memberName, ref.teamName);
onDeselect();
}}
/>
)}
<FallbackButton label="Close" onClick={onDeselect} />
)}
{selectedNode.role && (
<div className="mt-0.5 text-[10px]" style={{ color: '#66ccff70' }}>
{selectedNode.role}
</div>
)}
<div className="mt-2 flex gap-1">
{(selectedNode.kind === 'member' || selectedNode.kind === 'lead') && (
<FallbackButton
label="Message"
onClick={() => {
const ref = selectedNode.domainRef;
if (ref.kind === 'member') events?.onSendMessage?.(ref.memberName, ref.teamName);
onDeselect();
}}
/>
)}
<FallbackButton label="Close" onClick={onDeselect} />
</div>
</div>
);

View file

@ -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<HTMLDivElement>(null);
const canvasHandle = useRef<GraphCanvasHandle>(null);
const overlayRef = useRef<HTMLDivElement>(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<void> => {
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 (
<div ref={containerRef} className={`relative w-full h-full ${className ?? ''}`}>
@ -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({
<GraphControls
filters={filters}
onFiltersChange={setFilters}
onZoomIn={camera.zoomIn}
onZoomOut={camera.zoomOut}
onZoomIn={() => {
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 ? (
<div
className="absolute z-20 pointer-events-auto"
style={{
left: `${camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0).x + 20}px`,
top: `${camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0).y - 20}px`,
transform: 'translateY(-50%)',
}}
>
{renderOverlay({
node: selectedNode,
screenPos: camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0),
onClose: () => setSelectedNodeId(null),
})}
{selectedNode && (
<div ref={overlayRef} className="fixed z-20 pointer-events-auto">
{renderOverlay ? (
renderOverlay({
node: selectedNode,
screenPos: camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0),
onClose: () => setSelectedNodeId(null),
})
) : (
<GraphOverlay
selectedNode={selectedNode}
events={events}
onDeselect={() => setSelectedNodeId(null)}
/>
)}
</div>
) : (
<GraphOverlay
selectedNode={selectedNode}
worldToScreen={camera.worldToScreen}
events={events}
onDeselect={() => setSelectedNodeId(null)}
/>
)}
</div>
);

View file

@ -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' && <TeamListView />}
{tab.type === 'team' && (
<TabUIProvider tabId={tab.id}>
<TeamDetailView teamName={tab.teamName ?? ''} />
<TeamDetailView teamName={tab.teamName ?? ''} isPaneFocused={isPaneFocused} />
</TabUIProvider>
)}
{tab.type === 'session' && (
@ -68,7 +69,11 @@ export const PaneContent = ({ pane }: PaneContentProps): React.JSX.Element => {
{tab.type === 'schedules' && <SchedulesView />}
{tab.type === 'graph' && (
<TabUIProvider tabId={tab.id}>
<TeamGraphTab teamName={tab.teamName ?? ''} isActive={isActive} />
<TeamGraphTab
teamName={tab.teamName ?? ''}
isActive={isActive}
isPaneFocused={isPaneFocused}
/>
</TabUIProvider>
)}
</div>

View file

@ -49,7 +49,7 @@ export const PaneView = ({ paneId }: PaneViewProps): React.JSX.Element => {
}}
onMouseDown={handleMouseDown}
>
<PaneContent pane={pane} />
<PaneContent pane={pane} isPaneFocused={isFocused} />
{/* Edge split drop zones - visible only during active drag when under MAX_PANES */}
<PaneSplitDropZone paneId={paneId} side="left" isActive={showSplitZones} />

View file

@ -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 (
<div className="flex h-full flex-col overflow-hidden">
{projectSelector}
<div className="flex items-center gap-2 px-4 py-2">
<Calendar className="size-4" style={{ color: 'var(--color-text-muted)' }} />
<div className="flex items-center gap-2 px-2 py-1.5">
<Calendar className="size-3.5" style={{ color: 'var(--color-text-muted)' }} />
<h2
className="text-xs uppercase tracking-wider"
className="text-[11px] uppercase tracking-wider"
style={{ color: 'var(--color-text-muted)' }}
>
{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 */}
<span
ref={countRef}
className="text-xs"
className="text-[11px]"
style={{ color: 'var(--color-text-muted)', opacity: 0.6 }}
onMouseEnter={() => setShowCountTooltip(true)}
onMouseLeave={() => setShowCountTooltip(false)}
@ -898,7 +898,7 @@ export const DateGroupedSessions = (): React.JSX.Element => {
>
{item.type === 'pinned-header' ? (
<div
className="sticky top-0 flex h-full items-center gap-1.5 border-t px-4 py-1.5 text-[11px] font-semibold uppercase tracking-wider backdrop-blur-sm"
className="sticky top-0 flex h-full items-center gap-1.5 border-t px-2 py-1.5 text-[11px] font-semibold uppercase tracking-wider backdrop-blur-sm"
style={{
backgroundColor:
'color-mix(in srgb, var(--color-surface-sidebar) 95%, transparent)',
@ -911,7 +911,7 @@ export const DateGroupedSessions = (): React.JSX.Element => {
</div>
) : item.type === 'header' ? (
<div
className="sticky top-0 flex h-full items-center border-t px-4 py-1.5 text-[11px] font-semibold uppercase tracking-wider backdrop-blur-sm"
className="sticky top-0 flex h-full items-center border-t px-2 py-1.5 text-[11px] font-semibold uppercase tracking-wider backdrop-blur-sm"
style={{
backgroundColor:
'color-mix(in srgb, var(--color-surface-sidebar) 95%, transparent)',

View file

@ -29,7 +29,7 @@ import {
import { useShallow } from 'zustand/react/shallow';
import { AnimatedHeightReveal } from '../team/activity/AnimatedHeightReveal';
import { Combobox, type ComboboxOption } from '../ui/combobox';
import { type ComboboxOption } from '../ui/combobox';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import { SidebarTaskItem } from './SidebarTaskItem';
@ -246,9 +246,6 @@ export const GlobalTaskList = ({
[newTaskIds]
);
// Local project filter (independent from sessions tab)
const [localProjectFilter, setLocalProjectFilter] = useState<string | null>(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={() => {}}
/>
</div>
{/* Project filter */}
<div className="shrink-0 px-2 py-1">
<Combobox
options={projectFilterOptions}
value={localProjectFilter ?? ''}
onValueChange={(v) => setLocalProjectFilter(v)}
placeholder="All Projects"
searchPlaceholder="Search projects..."
emptyMessage="No projects"
className="text-[11px]"
resetLabel="All Projects"
onReset={() => setLocalProjectFilter(null)}
/>
</div>
{/* Pinned tasks section */}
{pinnedTasks.length > 0 && !showArchived && (
<div className="shrink-0 border-b" style={{ borderColor: 'var(--color-border)' }}>
@ -547,14 +530,10 @@ export const GlobalTaskList = ({
</div>
)}
{/* Grouping mode — compact segmented toggle */}
{/* Grouping mode — compact text toggle */}
<div className="flex shrink-0 items-center gap-1.5 px-2 py-1">
<span className="shrink-0 text-[11px] text-text-muted">Group by:</span>
<div
className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5 text-[11px]"
role="group"
aria-label="Group by"
>
<div className="inline-flex gap-1 text-[11px]" role="group" aria-label="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}

View file

@ -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 (
<>
<button
onClick={handleClick}
onContextMenu={handleContextMenu}
className={`h-[58px] w-full overflow-hidden border-b px-3 py-2 text-left transition-colors ${isActive ? '' : 'bg-transparent hover:bg-surface-raised'}`}
className={`flex h-[54px] w-full flex-col justify-center overflow-hidden border-b px-2 py-1.5 text-left transition-colors ${isActive ? '' : 'bg-transparent hover:bg-surface-raised'}`}
style={{
borderColor: 'var(--color-border)',
...(isActive ? { backgroundColor: 'var(--color-surface-raised)' } : {}),

View file

@ -13,6 +13,8 @@ import {
type TaskStatusFilterId,
} from './taskFiltersState';
import type { ComboboxOption } from '../ui/combobox';
const READ_FILTER_OPTIONS: { value: ReadFilter; label: string }[] = [
{ value: 'all', label: 'All' },
{ value: 'unread', label: 'Unread' },
@ -23,6 +25,7 @@ interface TaskFiltersPopoverProps {
open: boolean;
onOpenChange: (open: boolean) => void;
teams: { teamName: string; displayName: string }[];
projectOptions: ComboboxOption[];
filters: TaskFiltersState;
onFiltersChange: (f: TaskFiltersState) => void;
onApply: () => void;
@ -32,6 +35,7 @@ export const TaskFiltersPopover = ({
open,
onOpenChange,
teams,
projectOptions,
filters,
onFiltersChange,
onApply,
@ -138,6 +142,25 @@ export const TaskFiltersPopover = ({
/>
</div>
{projectOptions.length > 0 && (
<div>
<span className="mb-1.5 block text-[11px] font-semibold text-text-secondary">
Project
</span>
<Combobox
options={projectOptions}
value={draft.projectPath ?? ''}
onValueChange={(v) => 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 })}
/>
</div>
)}
<div>
<span className="mb-1.5 block text-[11px] font-semibold text-text-secondary">
Comments

View file

@ -25,6 +25,7 @@ export type ReadFilter = 'all' | 'unread' | 'read';
export interface TaskFiltersState {
statusIds: Set<TaskStatusFilterId>;
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',
});

View file

@ -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 = ({
<CollapsibleTeamSection
sectionId="claude-logs"
title="Claude logs"
icon={
<span className="inline-flex size-5 items-center justify-center rounded-md border border-[var(--color-border)] bg-[var(--color-bg-secondary)] text-[var(--color-text-secondary)] shadow-sm">
<Terminal size={12} />
</span>
}
icon={null}
badge={ctrl.badge}
afterBadge={
ctrl.data.total > 0 ? (

View file

@ -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<string | null>(null);
const [selectedTask, setSelectedTask] = useState<TeamTaskWithKanban | null>(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' && (
<div
className="relative shrink-0 overflow-hidden border-r border-[var(--color-border)]"
style={{ width: messagesPanelWidth }}
<TeamSidebarHost
teamName={teamName}
surface="team"
isActive={isThisTabActive}
isFocused={isPaneFocused}
>
<TeamSidebarPortalSource
teamName={teamName}
isActive={isThisTabActive}
isFocused={isPaneFocused}
>
<div className="flex size-full min-h-0 flex-col overflow-hidden bg-[var(--color-surface)]">
<div className="shrink-0 overflow-hidden px-3">
<ClaudeLogsSection teamName={teamName} position="sidebar" />
</div>
<div className="bg-[var(--color-text-muted)]/35 mx-3 h-px shrink-0" />
<div className="min-h-0 flex-1">
<MessagesPanel
teamName={teamName}
position="sidebar"
onTogglePosition={toggleMessagesPanelMode}
members={activeMembers}
tasks={data.tasks}
messages={data.messages}
isTeamAlive={data.isAlive}
leadActivity={leadActivityByTeam[teamName]}
leadContextUpdatedAt={leadContextUpdatedAt}
timeWindow={timeWindow}
teamSessionIds={teamSessionIds}
currentLeadSessionId={data?.config.leadSessionId}
pendingRepliesByMember={pendingRepliesByMember}
onPendingReplyChange={setPendingRepliesByMember}
onMemberClick={setSelectedMember}
onTaskClick={setSelectedTask}
onCreateTaskFromMessage={handleCreateTaskFromMessage}
onReplyToMessage={handleReplyToMessage}
onRestartTeam={handleRestartTeam}
onTaskIdClick={handleTaskIdClick}
/>
</div>
</div>
{/* Resize handle */}
<div
className={`absolute inset-y-0 right-0 z-20 w-1 cursor-col-resize transition-colors hover:bg-blue-500/30 ${isMessagesPanelResizing ? 'bg-blue-500/40' : ''}`}
onMouseDown={messagesPanelHandleProps.onMouseDown}
<TeamSidebarRail
teamName={teamName}
messagesPanelProps={sharedMessagesPanelProps}
isResizing={isMessagesPanelResizing}
onResizeMouseDown={messagesPanelHandleProps.onMouseDown}
/>
</div>
)}
</TeamSidebarPortalSource>
</TeamSidebarHost>
<div
ref={contentRef}
@ -1759,28 +1764,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
{messagesPanelMode !== 'sidebar' && <ClaudeLogsSection teamName={teamName} />}
{messagesPanelMode === 'inline' && (
<MessagesPanel
teamName={teamName}
position="inline"
onTogglePosition={toggleMessagesPanelMode}
members={activeMembers}
tasks={data.tasks}
messages={data.messages}
isTeamAlive={data.isAlive}
leadActivity={leadActivityByTeam[teamName]}
leadContextUpdatedAt={leadContextUpdatedAt}
timeWindow={timeWindow}
teamSessionIds={teamSessionIds}
currentLeadSessionId={data?.config.leadSessionId}
pendingRepliesByMember={pendingRepliesByMember}
onPendingReplyChange={setPendingRepliesByMember}
onMemberClick={setSelectedMember}
onTaskClick={setSelectedTask}
onCreateTaskFromMessage={handleCreateTaskFromMessage}
onReplyToMessage={handleReplyToMessage}
onRestartTeam={handleRestartTeam}
onTaskIdClick={handleTaskIdClick}
/>
<MessagesPanel position="inline" {...sharedMessagesPanelProps} />
)}
<ReviewDialog

View file

@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
@ -24,6 +24,10 @@ import { ActivityTimeline } from '../activity/ActivityTimeline';
import { getThoughtGroupKey, groupTimelineItems } from '../activity/LeadThoughtsGroup';
import { MessageExpandDialog } from '../activity/MessageExpandDialog';
import { CollapsibleTeamSection } from '../CollapsibleTeamSection';
import {
getTeamMessagesSidebarUiState,
setTeamMessagesSidebarUiState,
} from '../sidebar/teamSidebarUiState';
import { MessageComposer } from './MessageComposer';
import { MessagesFilterPopover } from './MessagesFilterPopover';
@ -110,20 +114,72 @@ export const MessagesPanel = memo(function MessagesPanel({
const openTeamTab = useStore((s) => s.openTeamTab);
const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const sidebarScrollRef = useRef<HTMLDivElement | null>(null);
const handleExpandContent = useCallback(() => {
// no-op: user is reading expanded content, not composing
}, []);
const [messagesSearchQuery, setMessagesSearchQuery] = useState('');
const [messagesFilter, setMessagesFilter] = useState<MessagesFilterState>({
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<string | null>(null);
const initialSidebarStateRef = useRef(getTeamMessagesSidebarUiState(teamName));
const [messagesSearchQuery, setMessagesSearchQuery] = useState(
initialSidebarStateRef.current.messagesSearchQuery
);
const [messagesFilter, setMessagesFilter] = useState<MessagesFilterState>(
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<string | null>(
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({
</div>
)}
{/* Scrollable content */}
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden pb-14 pr-3 pt-2">
<div
ref={sidebarScrollRef}
className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden pb-14 pr-3 pt-2"
onScroll={(e) => setSidebarScrollTop(e.currentTarget.scrollTop)}
>
<div className="pl-3">
<MessageComposer
teamName={teamName}

View file

@ -0,0 +1,73 @@
import { createContext, useContext, useId, useLayoutEffect, useState } from 'react';
import { useStore } from '@renderer/store';
import {
removeTeamSidebarHost,
upsertTeamSidebarHost,
useTeamSidebarPortalSnapshot,
} from './TeamSidebarPortalManager';
import type { TeamSidebarSurface } from './TeamSidebarPortalManager';
const TeamSidebarHostContext = createContext<string | null>(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<HTMLDivElement | null>(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 (
<TeamSidebarHostContext.Provider value={hostId}>
<div
ref={setElement}
data-team-sidebar-host={surface}
data-team-name={teamName}
className={`relative shrink-0 overflow-hidden ${isOwner ? 'border-r border-[var(--color-border)]' : ''}`}
style={{
width: isOwner ? messagesPanelWidth : 0,
minWidth: isOwner ? messagesPanelWidth : 0,
}}
>
{children}
</div>
</TeamSidebarHostContext.Provider>
);
};

View file

@ -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<string, string>;
activeSourceIdByTeam: Record<string, string>;
}
const SURFACE_PRIORITY: Record<TeamSidebarSurface, number> = {
team: 1,
'graph-tab': 2,
'graph-overlay': 3,
};
const hostById = new Map<string, TeamSidebarHostEntry>();
const sourceById = new Map<string, TeamSidebarSourceEntry>();
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<string, string> = {};
const activeSourceIdByTeam: Record<string, string> = {};
const hostsByTeam = new Map<string, TeamSidebarHostEntry[]>();
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<string, TeamSidebarSourceEntry[]>();
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<TeamSidebarHostEntry, 'id' | 'order'>
): 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<TeamSidebarSourceEntry, 'id' | 'order'>
): 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();
}

View file

@ -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);
};

View file

@ -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<ComponentProps<typeof MessagesPanel>, 'position'>;
interface TeamSidebarRailProps {
teamName: string;
messagesPanelProps: SharedMessagesPanelProps;
isResizing: boolean;
onResizeMouseDown: MouseEventHandler<HTMLDivElement>;
}
export const TeamSidebarRail = ({
teamName,
messagesPanelProps,
isResizing,
onResizeMouseDown,
}: TeamSidebarRailProps): React.JSX.Element => {
return (
<div className="flex size-full min-h-0 flex-col overflow-hidden bg-[var(--color-surface)]">
<div className="shrink-0 overflow-hidden px-3">
<ClaudeLogsSection teamName={teamName} position="sidebar" />
</div>
<div className="bg-[var(--color-text-muted)]/35 mx-3 h-px shrink-0" />
<div className="min-h-0 flex-1">
<MessagesPanel position="sidebar" {...messagesPanelProps} />
</div>
<div
className={`absolute inset-y-0 right-0 z-20 w-1 cursor-col-resize transition-colors hover:bg-blue-500/30 ${isResizing ? 'bg-blue-500/40' : ''}`}
onMouseDown={onResizeMouseDown}
/>
</div>
);
};

View file

@ -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<string, TeamMessagesSidebarUiState>();
const claudeLogsStateByTeam = new Map<string, TeamClaudeLogsSidebarUiState>();
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),
});
}

View file

@ -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<string | null>(null);
// ── Search & filter state ─────────────────────────────────────────────
const [searchQuery, setSearchQuery] = useState('');
const [filter, setFilter] = useState<ClaudeLogsFilterState>(() => ({
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<ClaudeLogsFilterState>(
initialSidebarStateRef.current.filter
);
const [filterOpen, setFilterOpen] = useState(initialSidebarStateRef.current.filterOpen);
// ── Viewer state (expansion + viewport) ───────────────────────────────
const [viewerState, setViewerState] = useState<ClaudeLogsViewerState>(createDefaultViewerState);
const [viewerState, setViewerState] = useState<ClaudeLogsViewerState>(
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;

View file

@ -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 (
<div className="fixed inset-0 z-50 flex flex-col" style={{ background: '#050510' }}>
<div className="fixed inset-0 z-50 flex overflow-hidden" style={{ background: '#050510' }}>
<TeamSidebarHost teamName={teamName} surface="graph-overlay" isActive isFocused />
<GraphView
data={graphData}
events={events}
onRequestClose={onClose}
onRequestPinAsTab={onPinAsTab}
className="flex-1"
className="min-w-0 flex-1"
renderOverlay={({ node, onClose: closePopover }) => (
<GraphNodePopover
node={node}

View file

@ -6,6 +6,7 @@
import { useCallback, useState, lazy, Suspense } 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';
@ -19,11 +20,13 @@ const TeamGraphOverlay = lazy(() =>
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 (
<div className="size-full" style={{ background: '#050510' }}>
<GraphView
data={graphData}
events={events}
className="size-full"
suspendAnimation={!isActive}
onRequestFullscreen={() => setFullscreen(true)}
renderOverlay={({ node, onClose }) => (
<GraphNodePopover
node={node}
onClose={onClose}
onSendMessage={dispatchSendMessage}
onOpenTaskDetail={dispatchOpenTask}
onOpenMemberProfile={dispatchOpenProfile}
onCreateTask={dispatchCreateTask}
/>
)}
<div className="flex size-full overflow-hidden" style={{ background: '#050510' }}>
<TeamSidebarHost
teamName={teamName}
surface="graph-tab"
isActive={isActive}
isFocused={isPaneFocused}
/>
<div className="min-w-0 flex-1">
<GraphView
data={graphData}
events={events}
className="size-full"
suspendAnimation={!isActive}
onRequestFullscreen={() => setFullscreen(true)}
renderOverlay={({ node, onClose }) => (
<GraphNodePopover
node={node}
onClose={onClose}
onSendMessage={dispatchSendMessage}
onOpenTaskDetail={dispatchOpenTask}
onOpenMemberProfile={dispatchOpenProfile}
onCreateTask={dispatchCreateTask}
/>
)}
/>
</div>
{fullscreen && (
<Suspense fallback={null}>
<TeamGraphOverlay teamName={teamName} onClose={() => setFullscreen(false)} />

View file

@ -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');
});
});