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:
parent
adc61ebcfa
commit
304a2a7f79
22 changed files with 1105 additions and 332 deletions
|
|
@ -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)]" />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)' } : {}),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
73
src/renderer/components/team/sidebar/TeamSidebarHost.tsx
Normal file
73
src/renderer/components/team/sidebar/TeamSidebarHost.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
173
src/renderer/components/team/sidebar/TeamSidebarPortalManager.ts
Normal file
173
src/renderer/components/team/sidebar/TeamSidebarPortalManager.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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);
|
||||
};
|
||||
37
src/renderer/components/team/sidebar/TeamSidebarRail.tsx
Normal file
37
src/renderer/components/team/sidebar/TeamSidebarRail.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
122
src/renderer/components/team/sidebar/teamSidebarUiState.ts
Normal file
122
src/renderer/components/team/sidebar/teamSidebarUiState.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)} />
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue