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.
|
* Positioned below system buttons (top-10) to avoid overlap on macOS.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Columns3,
|
Columns3,
|
||||||
Expand,
|
Expand,
|
||||||
|
Settings2,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Minus,
|
|
||||||
Pause,
|
Pause,
|
||||||
Pin,
|
Pin,
|
||||||
Play,
|
Play,
|
||||||
Plus,
|
|
||||||
Server,
|
Server,
|
||||||
X,
|
X,
|
||||||
|
ZoomIn,
|
||||||
|
ZoomOut,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
export interface GraphFilterState {
|
export interface GraphFilterState {
|
||||||
|
|
@ -53,6 +54,8 @@ export function GraphControls({
|
||||||
teamColor,
|
teamColor,
|
||||||
isAlive,
|
isAlive,
|
||||||
}: GraphControlsProps): React.JSX.Element {
|
}: GraphControlsProps): React.JSX.Element {
|
||||||
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
|
const settingsRef = useRef<HTMLDivElement>(null);
|
||||||
const toggle = useCallback(
|
const toggle = useCallback(
|
||||||
(key: keyof GraphFilterState) => {
|
(key: keyof GraphFilterState) => {
|
||||||
onFiltersChange({ ...filters, [key]: !filters[key] });
|
onFiltersChange({ ...filters, [key]: !filters[key] });
|
||||||
|
|
@ -60,103 +63,142 @@ export function GraphControls({
|
||||||
[filters, onFiltersChange],
|
[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';
|
const nameColor = teamColor ?? '#aaeeff';
|
||||||
|
|
||||||
return (
|
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="absolute left-3 top-3 z-10 flex items-center gap-3 pointer-events-none">
|
||||||
<div className="flex items-center pointer-events-auto">
|
|
||||||
<div
|
<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={{
|
style={{
|
||||||
background: 'rgba(8, 12, 24, 0.8)',
|
background: 'rgba(8, 12, 24, 0.8)',
|
||||||
border: `1px solid ${nameColor}25`,
|
border: `1px solid ${nameColor}25`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isAlive && (
|
{isAlive && (
|
||||||
<div
|
<div className="size-2 rounded-full animate-pulse" style={{ background: nameColor }} />
|
||||||
className="size-2 rounded-full animate-pulse"
|
|
||||||
style={{ background: nameColor }}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<span
|
<span className="text-xs font-mono font-semibold" style={{ color: nameColor }}>
|
||||||
className="text-xs font-mono font-semibold"
|
|
||||||
style={{ color: nameColor }}
|
|
||||||
>
|
|
||||||
{teamName}
|
{teamName}
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Center: filter toggles */}
|
<div className="absolute right-3 top-3 z-10 flex items-center gap-2 pointer-events-none">
|
||||||
<div
|
<div ref={settingsRef} className="relative pointer-events-auto">
|
||||||
className="flex items-center gap-0.5 rounded-lg px-1 py-0.5 pointer-events-auto backdrop-blur-sm"
|
<div
|
||||||
style={{ background: 'rgba(8, 12, 24, 0.8)', border: '1px solid rgba(100, 200, 255, 0.08)' }}
|
className="flex items-center gap-0.5 rounded-lg px-1 py-0.5 backdrop-blur-sm"
|
||||||
>
|
style={{
|
||||||
<ToolbarToggle
|
background: 'rgba(8, 12, 24, 0.8)',
|
||||||
active={filters.showTasks}
|
border: '1px solid rgba(100, 200, 255, 0.08)',
|
||||||
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 />
|
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
onClick={onRequestPinAsTab}
|
onClick={() => setIsSettingsOpen((value) => !value)}
|
||||||
icon={<Pin size={13} />}
|
icon={<Settings2 size={14} />}
|
||||||
label="Pin"
|
label="View"
|
||||||
|
active={isSettingsOpen}
|
||||||
/>
|
/>
|
||||||
</>
|
</div>
|
||||||
)}
|
|
||||||
{onRequestFullscreen && (
|
{isSettingsOpen && (
|
||||||
<>
|
<div
|
||||||
<Separator />
|
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
|
<ToolbarButton
|
||||||
onClick={onRequestFullscreen}
|
onClick={onRequestFullscreen}
|
||||||
icon={<Expand size={13} />}
|
icon={<Expand size={13} />}
|
||||||
label="Fullscreen"
|
label="Fullscreen"
|
||||||
/>
|
/>
|
||||||
</>
|
)}
|
||||||
)}
|
{onRequestClose && <ToolbarButton onClick={onRequestClose} icon={<X size={13} />} />}
|
||||||
{onRequestClose && (
|
</div>
|
||||||
<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,
|
onClick,
|
||||||
icon,
|
icon,
|
||||||
label,
|
label,
|
||||||
|
active = false,
|
||||||
}: {
|
}: {
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
active?: boolean;
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-mono transition-colors
|
className={`flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-mono transition-colors cursor-pointer ${
|
||||||
text-[#66ccff90] hover:text-[#aaeeff] hover:bg-[rgba(100,200,255,0.1)] 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}
|
{icon}
|
||||||
{label && <span>{label}</span>}
|
{label && <span>{label}</span>}
|
||||||
|
|
@ -188,27 +235,27 @@ function ToolbarToggle({
|
||||||
onClick,
|
onClick,
|
||||||
icon,
|
icon,
|
||||||
label,
|
label,
|
||||||
|
block = false,
|
||||||
}: {
|
}: {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
|
block?: boolean;
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
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
|
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
|
block ? 'w-full justify-start' : ''
|
||||||
|
} ${
|
||||||
|
active
|
||||||
? 'text-[#aaeeff] bg-[rgba(100,200,255,0.15)] border-[rgba(100,200,255,0.25)]'
|
? '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)]'
|
: 'text-[#66ccff50] bg-transparent border-transparent hover:text-[#66ccff90] hover:bg-[rgba(100,200,255,0.06)]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
</button>
|
</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 {
|
export interface GraphOverlayProps {
|
||||||
selectedNode: GraphNode | null;
|
selectedNode: GraphNode | null;
|
||||||
worldToScreen: (wx: number, wy: number) => { x: number; y: number };
|
|
||||||
events?: GraphEventPort;
|
events?: GraphEventPort;
|
||||||
onDeselect: () => void;
|
onDeselect: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GraphOverlay({
|
export function GraphOverlay({
|
||||||
selectedNode,
|
selectedNode,
|
||||||
worldToScreen,
|
|
||||||
events,
|
events,
|
||||||
onDeselect,
|
onDeselect,
|
||||||
}: GraphOverlayProps): React.JSX.Element | null {
|
}: GraphOverlayProps): React.JSX.Element | null {
|
||||||
if (!selectedNode) return null;
|
if (!selectedNode) return null;
|
||||||
|
|
||||||
const screenPos = worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute z-20 pointer-events-auto"
|
className="rounded-lg p-3 min-w-[160px] max-w-[220px] shadow-xl"
|
||||||
style={{
|
style={{
|
||||||
left: `${screenPos.x + 20}px`,
|
background: 'rgba(10, 15, 30, 0.9)',
|
||||||
top: `${screenPos.y - 20}px`,
|
border: '1px solid rgba(100, 200, 255, 0.15)',
|
||||||
transform: 'translateY(-50%)',
|
backdropFilter: 'blur(8px)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div className="text-xs font-mono font-bold" style={{ color: selectedNode.color ?? '#aaeeff' }}>
|
||||||
className="rounded-lg p-3 min-w-[160px] max-w-[220px] shadow-xl"
|
{selectedNode.label}
|
||||||
style={{
|
</div>
|
||||||
background: 'rgba(10, 15, 30, 0.9)',
|
{selectedNode.sublabel && (
|
||||||
border: '1px solid rgba(100, 200, 255, 0.15)',
|
<div className="mt-0.5 text-[10px] truncate" style={{ color: '#66ccff90' }}>
|
||||||
backdropFilter: 'blur(8px)',
|
{selectedNode.sublabel}
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="text-xs font-mono font-bold" style={{ color: selectedNode.color ?? '#aaeeff' }}>
|
|
||||||
{selectedNode.label}
|
|
||||||
</div>
|
</div>
|
||||||
{selectedNode.sublabel && (
|
)}
|
||||||
<div className="mt-0.5 text-[10px] truncate" style={{ color: '#66ccff90' }}>
|
{selectedNode.role && (
|
||||||
{selectedNode.sublabel}
|
<div className="mt-0.5 text-[10px]" style={{ color: '#66ccff70' }}>
|
||||||
</div>
|
{selectedNode.role}
|
||||||
)}
|
|
||||||
{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>
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@
|
||||||
* ALL animation state (positions, particles, effects, time) lives in refs.
|
* 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 { GraphDataPort } from '../ports/GraphDataPort';
|
||||||
import type { GraphEventPort } from '../ports/GraphEventPort';
|
import type { GraphEventPort } from '../ports/GraphEventPort';
|
||||||
import type { GraphConfigPort } from '../ports/GraphConfigPort';
|
import type { GraphConfigPort } from '../ports/GraphConfigPort';
|
||||||
|
|
@ -68,9 +69,12 @@ export function GraphView({
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const canvasHandle = useRef<GraphCanvasHandle>(null);
|
const canvasHandle = useRef<GraphCanvasHandle>(null);
|
||||||
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
const rafRef = useRef(0);
|
const rafRef = useRef(0);
|
||||||
const lastTimeRef = useRef(0);
|
const lastTimeRef = useRef(0);
|
||||||
const runningRef = useRef(false);
|
const runningRef = useRef(false);
|
||||||
|
const hasAutoFit = useRef(false);
|
||||||
|
const allowAutoFitRef = useRef(true);
|
||||||
|
|
||||||
// ─── Hooks ──────────────────────────────────────────────────────────────
|
// ─── Hooks ──────────────────────────────────────────────────────────────
|
||||||
const simulation = useGraphSimulation();
|
const simulation = useGraphSimulation();
|
||||||
|
|
@ -172,25 +176,63 @@ export function GraphView({
|
||||||
};
|
};
|
||||||
}, [effectivePaused, animate]);
|
}, [effectivePaused, animate]);
|
||||||
|
|
||||||
// ─── Auto-fit: center graph immediately when data arrives ──────────────
|
const fitGraphToViewport = useCallback(() => {
|
||||||
const hasAutoFit = useRef(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (data.nodes.length > 0 && !hasAutoFit.current) {
|
if (data.nodes.length === 0) {
|
||||||
hasAutoFit.current = true;
|
hasAutoFit.current = false;
|
||||||
// Immediate fit (simulation already settled from 120 pre-ticks)
|
allowAutoFitRef.current = true;
|
||||||
const el = containerRef.current;
|
return;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}, [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) ─
|
// ─── Mouse handlers (Figma-style: drag empty space = pan, drag node = move) ─
|
||||||
const isPanningRef = useRef(false);
|
const isPanningRef = useRef(false);
|
||||||
|
|
@ -208,13 +250,15 @@ export function GraphView({
|
||||||
|
|
||||||
if (interaction.dragNodeId.current) {
|
if (interaction.dragNodeId.current) {
|
||||||
// Hit a node → will drag it
|
// Hit a node → will drag it
|
||||||
|
markUserInteracted();
|
||||||
isPanningRef.current = false;
|
isPanningRef.current = false;
|
||||||
} else {
|
} else {
|
||||||
// Hit empty space → pan
|
// Hit empty space → pan
|
||||||
|
markUserInteracted();
|
||||||
isPanningRef.current = true;
|
isPanningRef.current = true;
|
||||||
camera.handlePanStart(e.clientX, e.clientY);
|
camera.handlePanStart(e.clientX, e.clientY);
|
||||||
}
|
}
|
||||||
}, [camera, interaction, simulation.stateRef]);
|
}, [camera, interaction, markUserInteracted, simulation.stateRef]);
|
||||||
|
|
||||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||||
// Dragging with left button held
|
// Dragging with left button held
|
||||||
|
|
@ -313,6 +357,58 @@ export function GraphView({
|
||||||
? simulation.stateRef.current.nodes.find((n) => n.id === selectedNodeId) ?? null
|
? simulation.stateRef.current.nodes.find((n) => n.id === selectedNodeId) ?? null
|
||||||
: 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 ─────────────────────────────────────────────────────────────
|
// ─── Render ─────────────────────────────────────────────────────────────
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className={`relative w-full h-full ${className ?? ''}`}>
|
<div ref={containerRef} className={`relative w-full h-full ${className ?? ''}`}>
|
||||||
|
|
@ -321,7 +417,7 @@ export function GraphView({
|
||||||
showHexGrid={config?.showHexGrid ?? true}
|
showHexGrid={config?.showHexGrid ?? true}
|
||||||
showStarField={config?.showStarField ?? true}
|
showStarField={config?.showStarField ?? true}
|
||||||
bloomIntensity={config?.bloomIntensity ?? 0.6}
|
bloomIntensity={config?.bloomIntensity ?? 0.6}
|
||||||
onWheel={camera.handleWheel}
|
onWheel={handleWheel}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
onMouseUp={handleMouseUp}
|
onMouseUp={handleMouseUp}
|
||||||
|
|
@ -331,9 +427,16 @@ export function GraphView({
|
||||||
<GraphControls
|
<GraphControls
|
||||||
filters={filters}
|
filters={filters}
|
||||||
onFiltersChange={setFilters}
|
onFiltersChange={setFilters}
|
||||||
onZoomIn={camera.zoomIn}
|
onZoomIn={() => {
|
||||||
onZoomOut={camera.zoomOut}
|
markUserInteracted();
|
||||||
|
camera.zoomIn();
|
||||||
|
}}
|
||||||
|
onZoomOut={() => {
|
||||||
|
markUserInteracted();
|
||||||
|
camera.zoomOut();
|
||||||
|
}}
|
||||||
onZoomToFit={() => {
|
onZoomToFit={() => {
|
||||||
|
markUserInteracted();
|
||||||
const el = containerRef.current;
|
const el = containerRef.current;
|
||||||
if (el) camera.zoomToFit(simulation.stateRef.current.nodes, el.clientWidth, el.clientHeight);
|
if (el) camera.zoomToFit(simulation.stateRef.current.nodes, el.clientWidth, el.clientHeight);
|
||||||
}}
|
}}
|
||||||
|
|
@ -345,28 +448,22 @@ export function GraphView({
|
||||||
isAlive={data.isAlive}
|
isAlive={data.isAlive}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{selectedNode && renderOverlay ? (
|
{selectedNode && (
|
||||||
<div
|
<div ref={overlayRef} className="fixed z-20 pointer-events-auto">
|
||||||
className="absolute z-20 pointer-events-auto"
|
{renderOverlay ? (
|
||||||
style={{
|
renderOverlay({
|
||||||
left: `${camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0).x + 20}px`,
|
node: selectedNode,
|
||||||
top: `${camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0).y - 20}px`,
|
screenPos: camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0),
|
||||||
transform: 'translateY(-50%)',
|
onClose: () => setSelectedNodeId(null),
|
||||||
}}
|
})
|
||||||
>
|
) : (
|
||||||
{renderOverlay({
|
<GraphOverlay
|
||||||
node: selectedNode,
|
selectedNode={selectedNode}
|
||||||
screenPos: camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0),
|
events={events}
|
||||||
onClose: () => setSelectedNodeId(null),
|
onDeselect={() => setSelectedNodeId(null)}
|
||||||
})}
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<GraphOverlay
|
|
||||||
selectedNode={selectedNode}
|
|
||||||
worldToScreen={camera.worldToScreen}
|
|
||||||
events={events}
|
|
||||||
onDeselect={() => setSelectedNodeId(null)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,10 @@ import type { Pane } from '@renderer/types/panes';
|
||||||
|
|
||||||
interface PaneContentProps {
|
interface PaneContentProps {
|
||||||
pane: Pane;
|
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;
|
const activeTabId = pane.activeTabId;
|
||||||
|
|
||||||
// Show default dashboard if no tabs are open in this pane
|
// 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 === 'teams' && <TeamListView />}
|
||||||
{tab.type === 'team' && (
|
{tab.type === 'team' && (
|
||||||
<TabUIProvider tabId={tab.id}>
|
<TabUIProvider tabId={tab.id}>
|
||||||
<TeamDetailView teamName={tab.teamName ?? ''} />
|
<TeamDetailView teamName={tab.teamName ?? ''} isPaneFocused={isPaneFocused} />
|
||||||
</TabUIProvider>
|
</TabUIProvider>
|
||||||
)}
|
)}
|
||||||
{tab.type === 'session' && (
|
{tab.type === 'session' && (
|
||||||
|
|
@ -68,7 +69,11 @@ export const PaneContent = ({ pane }: PaneContentProps): React.JSX.Element => {
|
||||||
{tab.type === 'schedules' && <SchedulesView />}
|
{tab.type === 'schedules' && <SchedulesView />}
|
||||||
{tab.type === 'graph' && (
|
{tab.type === 'graph' && (
|
||||||
<TabUIProvider tabId={tab.id}>
|
<TabUIProvider tabId={tab.id}>
|
||||||
<TeamGraphTab teamName={tab.teamName ?? ''} isActive={isActive} />
|
<TeamGraphTab
|
||||||
|
teamName={tab.teamName ?? ''}
|
||||||
|
isActive={isActive}
|
||||||
|
isPaneFocused={isPaneFocused}
|
||||||
|
/>
|
||||||
</TabUIProvider>
|
</TabUIProvider>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ export const PaneView = ({ paneId }: PaneViewProps): React.JSX.Element => {
|
||||||
}}
|
}}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
>
|
>
|
||||||
<PaneContent pane={pane} />
|
<PaneContent pane={pane} isPaneFocused={isFocused} />
|
||||||
|
|
||||||
{/* Edge split drop zones - visible only during active drag when under MAX_PANES */}
|
{/* Edge split drop zones - visible only during active drag when under MAX_PANES */}
|
||||||
<PaneSplitDropZone paneId={paneId} side="left" isActive={showSplitZones} />
|
<PaneSplitDropZone paneId={paneId} side="left" isActive={showSplitZones} />
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,7 @@ type VirtualItem =
|
||||||
* Mismatch causes items to overlap!
|
* Mismatch causes items to overlap!
|
||||||
*/
|
*/
|
||||||
const HEADER_HEIGHT = 28;
|
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 LOADER_HEIGHT = 36;
|
||||||
const OVERSCAN = 5;
|
const OVERSCAN = 5;
|
||||||
|
|
||||||
|
|
@ -736,10 +736,10 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden">
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
{projectSelector}
|
{projectSelector}
|
||||||
<div className="flex items-center gap-2 px-4 py-2">
|
<div className="flex items-center gap-2 px-2 py-1.5">
|
||||||
<Calendar className="size-4" style={{ color: 'var(--color-text-muted)' }} />
|
<Calendar className="size-3.5" style={{ color: 'var(--color-text-muted)' }} />
|
||||||
<h2
|
<h2
|
||||||
className="text-xs uppercase tracking-wider"
|
className="text-[11px] uppercase tracking-wider"
|
||||||
style={{ color: 'var(--color-text-muted)' }}
|
style={{ color: 'var(--color-text-muted)' }}
|
||||||
>
|
>
|
||||||
{sessionSortMode === 'most-context' ? 'By Context' : 'Sessions'}
|
{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 */}
|
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- tooltip trigger via hover, not interactive */}
|
||||||
<span
|
<span
|
||||||
ref={countRef}
|
ref={countRef}
|
||||||
className="text-xs"
|
className="text-[11px]"
|
||||||
style={{ color: 'var(--color-text-muted)', opacity: 0.6 }}
|
style={{ color: 'var(--color-text-muted)', opacity: 0.6 }}
|
||||||
onMouseEnter={() => setShowCountTooltip(true)}
|
onMouseEnter={() => setShowCountTooltip(true)}
|
||||||
onMouseLeave={() => setShowCountTooltip(false)}
|
onMouseLeave={() => setShowCountTooltip(false)}
|
||||||
|
|
@ -898,7 +898,7 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
||||||
>
|
>
|
||||||
{item.type === 'pinned-header' ? (
|
{item.type === 'pinned-header' ? (
|
||||||
<div
|
<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={{
|
style={{
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
'color-mix(in srgb, var(--color-surface-sidebar) 95%, transparent)',
|
'color-mix(in srgb, var(--color-surface-sidebar) 95%, transparent)',
|
||||||
|
|
@ -911,7 +911,7 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
||||||
</div>
|
</div>
|
||||||
) : item.type === 'header' ? (
|
) : item.type === 'header' ? (
|
||||||
<div
|
<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={{
|
style={{
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
'color-mix(in srgb, var(--color-surface-sidebar) 95%, transparent)',
|
'color-mix(in srgb, var(--color-surface-sidebar) 95%, transparent)',
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ import {
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { AnimatedHeightReveal } from '../team/activity/AnimatedHeightReveal';
|
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 { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||||
|
|
||||||
import { SidebarTaskItem } from './SidebarTaskItem';
|
import { SidebarTaskItem } from './SidebarTaskItem';
|
||||||
|
|
@ -246,9 +246,6 @@ export const GlobalTaskList = ({
|
||||||
[newTaskIds]
|
[newTaskIds]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Local project filter (independent from sessions tab)
|
|
||||||
const [localProjectFilter, setLocalProjectFilter] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const setGroupingMode = (mode: TaskGroupingMode): void => {
|
const setGroupingMode = (mode: TaskGroupingMode): void => {
|
||||||
setGroupingModeState(mode);
|
setGroupingModeState(mode);
|
||||||
saveGroupingMode(mode);
|
saveGroupingMode(mode);
|
||||||
|
|
@ -326,8 +323,8 @@ export const GlobalTaskList = ({
|
||||||
}));
|
}));
|
||||||
}, [viewMode, repositoryGroups, projects]);
|
}, [viewMode, repositoryGroups, projects]);
|
||||||
|
|
||||||
// Resolve local filter to a project path
|
// Resolve project filter from filters state
|
||||||
const selectedProjectPath = localProjectFilter;
|
const selectedProjectPath = filters.projectPath;
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
let result = globalTasks;
|
let result = globalTasks;
|
||||||
|
|
@ -355,7 +352,7 @@ export const GlobalTaskList = ({
|
||||||
return result;
|
return result;
|
||||||
}, [
|
}, [
|
||||||
globalTasks,
|
globalTasks,
|
||||||
selectedProjectPath,
|
filters.projectPath,
|
||||||
filters.statusIds,
|
filters.statusIds,
|
||||||
filters.teamName,
|
filters.teamName,
|
||||||
filters.readFilter,
|
filters.readFilter,
|
||||||
|
|
@ -493,27 +490,13 @@ export const GlobalTaskList = ({
|
||||||
open={filtersPopoverOpen}
|
open={filtersPopoverOpen}
|
||||||
onOpenChange={setFiltersPopoverOpen}
|
onOpenChange={setFiltersPopoverOpen}
|
||||||
teams={teams.map((t) => ({ teamName: t.teamName, displayName: t.displayName }))}
|
teams={teams.map((t) => ({ teamName: t.teamName, displayName: t.displayName }))}
|
||||||
|
projectOptions={projectFilterOptions}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
onFiltersChange={setFilters}
|
onFiltersChange={setFilters}
|
||||||
onApply={() => {}}
|
onApply={() => {}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* Pinned tasks section */}
|
||||||
{pinnedTasks.length > 0 && !showArchived && (
|
{pinnedTasks.length > 0 && !showArchived && (
|
||||||
<div className="shrink-0 border-b" style={{ borderColor: 'var(--color-border)' }}>
|
<div className="shrink-0 border-b" style={{ borderColor: 'var(--color-border)' }}>
|
||||||
|
|
@ -547,14 +530,10 @@ export const GlobalTaskList = ({
|
||||||
</div>
|
</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">
|
<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>
|
<span className="shrink-0 text-[11px] text-text-muted">Group by:</span>
|
||||||
<div
|
<div className="inline-flex gap-1 text-[11px]" role="group" aria-label="Group by">
|
||||||
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"
|
|
||||||
>
|
|
||||||
{(['none', 'project', 'time'] as const).map((mode) => {
|
{(['none', 'project', 'time'] as const).map((mode) => {
|
||||||
const label = mode === 'none' ? 'None' : mode === 'project' ? 'Project' : 'Time';
|
const label = mode === 'none' ? 'None' : mode === 'project' ? 'Project' : 'Time';
|
||||||
return (
|
return (
|
||||||
|
|
@ -563,10 +542,8 @@ export const GlobalTaskList = ({
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setGroupingMode(mode)}
|
onClick={() => setGroupingMode(mode)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded px-2 py-0.5 transition-colors',
|
'rounded px-1.5 py-0.5 transition-colors',
|
||||||
groupingMode === mode
|
groupingMode === mode ? 'text-text' : 'text-text-muted hover:text-text-secondary'
|
||||||
? 'bg-surface-raised text-text-secondary shadow-sm ring-1 ring-[var(--color-border)]'
|
|
||||||
: 'text-text-muted hover:text-text-secondary'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
|
|
|
||||||
|
|
@ -240,13 +240,13 @@ export const SessionItem = ({
|
||||||
}
|
}
|
||||||
}, [activeProjectId, openTab, selectSession, session.id, sessionLabel, splitPane]);
|
}, [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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onContextMenu={handleContextMenu}
|
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={{
|
style={{
|
||||||
borderColor: 'var(--color-border)',
|
borderColor: 'var(--color-border)',
|
||||||
...(isActive ? { backgroundColor: 'var(--color-surface-raised)' } : {}),
|
...(isActive ? { backgroundColor: 'var(--color-surface-raised)' } : {}),
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ import {
|
||||||
type TaskStatusFilterId,
|
type TaskStatusFilterId,
|
||||||
} from './taskFiltersState';
|
} from './taskFiltersState';
|
||||||
|
|
||||||
|
import type { ComboboxOption } from '../ui/combobox';
|
||||||
|
|
||||||
const READ_FILTER_OPTIONS: { value: ReadFilter; label: string }[] = [
|
const READ_FILTER_OPTIONS: { value: ReadFilter; label: string }[] = [
|
||||||
{ value: 'all', label: 'All' },
|
{ value: 'all', label: 'All' },
|
||||||
{ value: 'unread', label: 'Unread' },
|
{ value: 'unread', label: 'Unread' },
|
||||||
|
|
@ -23,6 +25,7 @@ interface TaskFiltersPopoverProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
teams: { teamName: string; displayName: string }[];
|
teams: { teamName: string; displayName: string }[];
|
||||||
|
projectOptions: ComboboxOption[];
|
||||||
filters: TaskFiltersState;
|
filters: TaskFiltersState;
|
||||||
onFiltersChange: (f: TaskFiltersState) => void;
|
onFiltersChange: (f: TaskFiltersState) => void;
|
||||||
onApply: () => void;
|
onApply: () => void;
|
||||||
|
|
@ -32,6 +35,7 @@ export const TaskFiltersPopover = ({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
teams,
|
teams,
|
||||||
|
projectOptions,
|
||||||
filters,
|
filters,
|
||||||
onFiltersChange,
|
onFiltersChange,
|
||||||
onApply,
|
onApply,
|
||||||
|
|
@ -138,6 +142,25 @@ export const TaskFiltersPopover = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<span className="mb-1.5 block text-[11px] font-semibold text-text-secondary">
|
<span className="mb-1.5 block text-[11px] font-semibold text-text-secondary">
|
||||||
Comments
|
Comments
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ export type ReadFilter = 'all' | 'unread' | 'read';
|
||||||
export interface TaskFiltersState {
|
export interface TaskFiltersState {
|
||||||
statusIds: Set<TaskStatusFilterId>;
|
statusIds: Set<TaskStatusFilterId>;
|
||||||
teamName: string | null;
|
teamName: string | null;
|
||||||
|
projectPath: string | null;
|
||||||
/** @deprecated Use readFilter instead */
|
/** @deprecated Use readFilter instead */
|
||||||
unreadOnly: boolean;
|
unreadOnly: boolean;
|
||||||
readFilter: ReadFilter;
|
readFilter: ReadFilter;
|
||||||
|
|
@ -33,6 +34,7 @@ export interface TaskFiltersState {
|
||||||
export const defaultTaskFiltersState = (): TaskFiltersState => ({
|
export const defaultTaskFiltersState = (): TaskFiltersState => ({
|
||||||
statusIds: new Set(STATUS_OPTIONS.map((o) => o.id)),
|
statusIds: new Set(STATUS_OPTIONS.map((o) => o.id)),
|
||||||
teamName: null,
|
teamName: null,
|
||||||
|
projectPath: null,
|
||||||
unreadOnly: false,
|
unreadOnly: false,
|
||||||
readFilter: 'all',
|
readFilter: 'all',
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { useMemo, useState } from 'react';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { cn } from '@renderer/lib/utils';
|
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 { ClaudeLogsDialog } from './ClaudeLogsDialog';
|
||||||
import { ClaudeLogsPanel } from './ClaudeLogsPanel';
|
import { ClaudeLogsPanel } from './ClaudeLogsPanel';
|
||||||
|
|
@ -96,11 +96,7 @@ export const ClaudeLogsSection = ({
|
||||||
<CollapsibleTeamSection
|
<CollapsibleTeamSection
|
||||||
sectionId="claude-logs"
|
sectionId="claude-logs"
|
||||||
title="Claude logs"
|
title="Claude logs"
|
||||||
icon={
|
icon={null}
|
||||||
<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>
|
|
||||||
}
|
|
||||||
badge={ctrl.badge}
|
badge={ctrl.badge}
|
||||||
afterBadge={
|
afterBadge={
|
||||||
ctrl.data.total > 0 ? (
|
ctrl.data.total > 0 ? (
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,9 @@ import { ScheduleSection } from './schedule/ScheduleSection';
|
||||||
import { ClaudeLogsSection } from './ClaudeLogsSection';
|
import { ClaudeLogsSection } from './ClaudeLogsSection';
|
||||||
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
||||||
import { ProcessesSection } from './ProcessesSection';
|
import { ProcessesSection } from './ProcessesSection';
|
||||||
|
import { TeamSidebarHost } from './sidebar/TeamSidebarHost';
|
||||||
|
import { TeamSidebarPortalSource } from './sidebar/TeamSidebarPortalSource';
|
||||||
|
import { TeamSidebarRail } from './sidebar/TeamSidebarRail';
|
||||||
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
|
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
|
||||||
import { TeamSessionsSection } from './TeamSessionsSection';
|
import { TeamSessionsSection } from './TeamSessionsSection';
|
||||||
|
|
||||||
|
|
@ -102,6 +105,7 @@ import type { EditorSelectionAction } from '@shared/types/editor';
|
||||||
|
|
||||||
interface TeamDetailViewProps {
|
interface TeamDetailViewProps {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
isPaneFocused?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreateTaskDialogState {
|
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 { isLight } = useTheme();
|
||||||
const [requestChangesTaskId, setRequestChangesTaskId] = useState<string | null>(null);
|
const [requestChangesTaskId, setRequestChangesTaskId] = useState<string | null>(null);
|
||||||
const [selectedTask, setSelectedTask] = useState<TeamTaskWithKanban | 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
|
const headerColorSet = data.config.color
|
||||||
? getTeamColorSet(data.config.color)
|
? getTeamColorSet(data.config.color)
|
||||||
: nameColorSet(data.config.name);
|
: 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -1155,48 +1183,25 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Messages sidebar (left, after context panel) */}
|
{/* Messages sidebar (left, after context panel) */}
|
||||||
{messagesPanelMode === 'sidebar' && (
|
<TeamSidebarHost
|
||||||
<div
|
teamName={teamName}
|
||||||
className="relative shrink-0 overflow-hidden border-r border-[var(--color-border)]"
|
surface="team"
|
||||||
style={{ width: messagesPanelWidth }}
|
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)]">
|
<TeamSidebarRail
|
||||||
<div className="shrink-0 overflow-hidden px-3">
|
teamName={teamName}
|
||||||
<ClaudeLogsSection teamName={teamName} position="sidebar" />
|
messagesPanelProps={sharedMessagesPanelProps}
|
||||||
</div>
|
isResizing={isMessagesPanelResizing}
|
||||||
<div className="bg-[var(--color-text-muted)]/35 mx-3 h-px shrink-0" />
|
onResizeMouseDown={messagesPanelHandleProps.onMouseDown}
|
||||||
<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}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</TeamSidebarPortalSource>
|
||||||
)}
|
</TeamSidebarHost>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
|
|
@ -1759,28 +1764,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
{messagesPanelMode !== 'sidebar' && <ClaudeLogsSection teamName={teamName} />}
|
{messagesPanelMode !== 'sidebar' && <ClaudeLogsSection teamName={teamName} />}
|
||||||
|
|
||||||
{messagesPanelMode === 'inline' && (
|
{messagesPanelMode === 'inline' && (
|
||||||
<MessagesPanel
|
<MessagesPanel position="inline" {...sharedMessagesPanelProps} />
|
||||||
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}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ReviewDialog
|
<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 { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
|
|
@ -24,6 +24,10 @@ import { ActivityTimeline } from '../activity/ActivityTimeline';
|
||||||
import { getThoughtGroupKey, groupTimelineItems } from '../activity/LeadThoughtsGroup';
|
import { getThoughtGroupKey, groupTimelineItems } from '../activity/LeadThoughtsGroup';
|
||||||
import { MessageExpandDialog } from '../activity/MessageExpandDialog';
|
import { MessageExpandDialog } from '../activity/MessageExpandDialog';
|
||||||
import { CollapsibleTeamSection } from '../CollapsibleTeamSection';
|
import { CollapsibleTeamSection } from '../CollapsibleTeamSection';
|
||||||
|
import {
|
||||||
|
getTeamMessagesSidebarUiState,
|
||||||
|
setTeamMessagesSidebarUiState,
|
||||||
|
} from '../sidebar/teamSidebarUiState';
|
||||||
|
|
||||||
import { MessageComposer } from './MessageComposer';
|
import { MessageComposer } from './MessageComposer';
|
||||||
import { MessagesFilterPopover } from './MessagesFilterPopover';
|
import { MessagesFilterPopover } from './MessagesFilterPopover';
|
||||||
|
|
@ -110,20 +114,72 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
const openTeamTab = useStore((s) => s.openTeamTab);
|
const openTeamTab = useStore((s) => s.openTeamTab);
|
||||||
|
|
||||||
const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
const sidebarScrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
const handleExpandContent = useCallback(() => {
|
const handleExpandContent = useCallback(() => {
|
||||||
// no-op: user is reading expanded content, not composing
|
// no-op: user is reading expanded content, not composing
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [messagesSearchQuery, setMessagesSearchQuery] = useState('');
|
const initialSidebarStateRef = useRef(getTeamMessagesSidebarUiState(teamName));
|
||||||
const [messagesFilter, setMessagesFilter] = useState<MessagesFilterState>({
|
const [messagesSearchQuery, setMessagesSearchQuery] = useState(
|
||||||
from: new Set(),
|
initialSidebarStateRef.current.messagesSearchQuery
|
||||||
to: new Set(),
|
);
|
||||||
showNoise: false,
|
const [messagesFilter, setMessagesFilter] = useState<MessagesFilterState>(
|
||||||
});
|
initialSidebarStateRef.current.messagesFilter
|
||||||
const [messagesFilterOpen, setMessagesFilterOpen] = useState(false);
|
);
|
||||||
const [messagesCollapsed, setMessagesCollapsed] = useState(true);
|
const [messagesFilterOpen, setMessagesFilterOpen] = useState(
|
||||||
const [sidebarSearchVisible, setSidebarSearchVisible] = useState(false);
|
initialSidebarStateRef.current.messagesFilterOpen
|
||||||
const [expandedItemKey, setExpandedItemKey] = useState<string | null>(null);
|
);
|
||||||
|
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(() => {
|
const filteredMessages = useMemo(() => {
|
||||||
return filterTeamMessages(messages, {
|
return filterTeamMessages(messages, {
|
||||||
|
|
@ -479,7 +535,11 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Scrollable content */}
|
{/* 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">
|
<div className="pl-3">
|
||||||
<MessageComposer
|
<MessageComposer
|
||||||
teamName={teamName}
|
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 { useStore } from '@renderer/store';
|
||||||
|
|
||||||
import { DEFAULT_CLAUDE_LOGS_FILTER } from './ClaudeLogsFilterPopover';
|
import { DEFAULT_CLAUDE_LOGS_FILTER } from './ClaudeLogsFilterPopover';
|
||||||
|
import {
|
||||||
|
createDefaultClaudeLogsSidebarUiState,
|
||||||
|
getTeamClaudeLogsSidebarUiState,
|
||||||
|
setTeamClaudeLogsSidebarUiState,
|
||||||
|
} from './sidebar/teamSidebarUiState';
|
||||||
|
|
||||||
import type { ClaudeLogsFilterState } from './ClaudeLogsFilterPopover';
|
import type { ClaudeLogsFilterState } from './ClaudeLogsFilterPopover';
|
||||||
import type { ClaudeLogsViewerState } from './CliLogsRichView';
|
import type { ClaudeLogsViewerState } from './CliLogsRichView';
|
||||||
|
|
@ -364,12 +369,7 @@ function filterStreamJsonText(
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
function createDefaultViewerState(): ClaudeLogsViewerState {
|
function createDefaultViewerState(): ClaudeLogsViewerState {
|
||||||
return {
|
return createDefaultClaudeLogsSidebarUiState().viewerState;
|
||||||
collapsedGroupIds: new Set(),
|
|
||||||
expandedItemIds: new Set(),
|
|
||||||
expandedSubagentIds: new Set(),
|
|
||||||
viewport: { mode: 'edge', edge: 'newest' },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -389,15 +389,17 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// ── Search & filter state ─────────────────────────────────────────────
|
// ── Search & filter state ─────────────────────────────────────────────
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const initialSidebarStateRef = useRef(getTeamClaudeLogsSidebarUiState(teamName));
|
||||||
const [filter, setFilter] = useState<ClaudeLogsFilterState>(() => ({
|
const [searchQuery, setSearchQuery] = useState(initialSidebarStateRef.current.searchQuery);
|
||||||
streams: new Set(DEFAULT_CLAUDE_LOGS_FILTER.streams),
|
const [filter, setFilter] = useState<ClaudeLogsFilterState>(
|
||||||
kinds: new Set(DEFAULT_CLAUDE_LOGS_FILTER.kinds),
|
initialSidebarStateRef.current.filter
|
||||||
}));
|
);
|
||||||
const [filterOpen, setFilterOpen] = useState(false);
|
const [filterOpen, setFilterOpen] = useState(initialSidebarStateRef.current.filterOpen);
|
||||||
|
|
||||||
// ── Viewer state (expansion + viewport) ───────────────────────────────
|
// ── Viewer state (expansion + viewport) ───────────────────────────────
|
||||||
const [viewerState, setViewerState] = useState<ClaudeLogsViewerState>(createDefaultViewerState);
|
const [viewerState, setViewerState] = useState<ClaudeLogsViewerState>(
|
||||||
|
initialSidebarStateRef.current.viewerState
|
||||||
|
);
|
||||||
|
|
||||||
const onViewerStateChange = useCallback((state: ClaudeLogsViewerState) => {
|
const onViewerStateChange = useCallback((state: ClaudeLogsViewerState) => {
|
||||||
setViewerState(state);
|
setViewerState(state);
|
||||||
|
|
@ -415,6 +417,7 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController
|
||||||
|
|
||||||
// ── Reset on team change ──────────────────────────────────────────────
|
// ── Reset on team change ──────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
initialSidebarStateRef.current = getTeamClaudeLogsSidebarUiState(teamName);
|
||||||
setLoadedCount(PAGE_SIZE);
|
setLoadedCount(PAGE_SIZE);
|
||||||
setData({ lines: [], total: 0, hasMore: false });
|
setData({ lines: [], total: 0, hasMore: false });
|
||||||
setPending(null);
|
setPending(null);
|
||||||
|
|
@ -422,14 +425,21 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController
|
||||||
latestRef.current = null;
|
latestRef.current = null;
|
||||||
atTopRef.current = true;
|
atTopRef.current = true;
|
||||||
setError(null);
|
setError(null);
|
||||||
setSearchQuery('');
|
setSearchQuery(initialSidebarStateRef.current.searchQuery);
|
||||||
setFilter({
|
setFilter(initialSidebarStateRef.current.filter);
|
||||||
streams: new Set(DEFAULT_CLAUDE_LOGS_FILTER.streams),
|
setFilterOpen(initialSidebarStateRef.current.filterOpen);
|
||||||
kinds: new Set(DEFAULT_CLAUDE_LOGS_FILTER.kinds),
|
setViewerState(initialSidebarStateRef.current.viewerState);
|
||||||
});
|
|
||||||
setViewerState(createDefaultViewerState());
|
|
||||||
}, [teamName]);
|
}, [teamName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTeamClaudeLogsSidebarUiState(teamName, {
|
||||||
|
searchQuery,
|
||||||
|
filter,
|
||||||
|
filterOpen,
|
||||||
|
viewerState,
|
||||||
|
});
|
||||||
|
}, [teamName, searchQuery, filter, filterOpen, viewerState]);
|
||||||
|
|
||||||
// ── Sync refs ─────────────────────────────────────────────────────────
|
// ── Sync refs ─────────────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
committedRef.current = data;
|
committedRef.current = data;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
import { GraphView } from '@claude-teams/agent-graph';
|
import { GraphView } from '@claude-teams/agent-graph';
|
||||||
|
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
|
||||||
|
|
||||||
import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter';
|
import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter';
|
||||||
import { GraphNodePopover } from './GraphNodePopover';
|
import { GraphNodePopover } from './GraphNodePopover';
|
||||||
|
|
@ -54,13 +55,14 @@ export const TeamGraphOverlay = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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
|
<GraphView
|
||||||
data={graphData}
|
data={graphData}
|
||||||
events={events}
|
events={events}
|
||||||
onRequestClose={onClose}
|
onRequestClose={onClose}
|
||||||
onRequestPinAsTab={onPinAsTab}
|
onRequestPinAsTab={onPinAsTab}
|
||||||
className="flex-1"
|
className="min-w-0 flex-1"
|
||||||
renderOverlay={({ node, onClose: closePopover }) => (
|
renderOverlay={({ node, onClose: closePopover }) => (
|
||||||
<GraphNodePopover
|
<GraphNodePopover
|
||||||
node={node}
|
node={node}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
import { useCallback, useState, lazy, Suspense } from 'react';
|
import { useCallback, useState, lazy, Suspense } from 'react';
|
||||||
|
|
||||||
import { GraphView } from '@claude-teams/agent-graph';
|
import { GraphView } from '@claude-teams/agent-graph';
|
||||||
|
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
|
||||||
|
|
||||||
import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter';
|
import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter';
|
||||||
import { GraphNodePopover } from './GraphNodePopover';
|
import { GraphNodePopover } from './GraphNodePopover';
|
||||||
|
|
@ -19,11 +20,13 @@ const TeamGraphOverlay = lazy(() =>
|
||||||
export interface TeamGraphTabProps {
|
export interface TeamGraphTabProps {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
isPaneFocused?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TeamGraphTab = ({
|
export const TeamGraphTab = ({
|
||||||
teamName,
|
teamName,
|
||||||
isActive = true,
|
isActive = true,
|
||||||
|
isPaneFocused = false,
|
||||||
}: TeamGraphTabProps): React.JSX.Element => {
|
}: TeamGraphTabProps): React.JSX.Element => {
|
||||||
const graphData = useTeamGraphAdapter(teamName);
|
const graphData = useTeamGraphAdapter(teamName);
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
|
|
@ -68,24 +71,32 @@ export const TeamGraphTab = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="size-full" style={{ background: '#050510' }}>
|
<div className="flex size-full overflow-hidden" style={{ background: '#050510' }}>
|
||||||
<GraphView
|
<TeamSidebarHost
|
||||||
data={graphData}
|
teamName={teamName}
|
||||||
events={events}
|
surface="graph-tab"
|
||||||
className="size-full"
|
isActive={isActive}
|
||||||
suspendAnimation={!isActive}
|
isFocused={isPaneFocused}
|
||||||
onRequestFullscreen={() => setFullscreen(true)}
|
|
||||||
renderOverlay={({ node, onClose }) => (
|
|
||||||
<GraphNodePopover
|
|
||||||
node={node}
|
|
||||||
onClose={onClose}
|
|
||||||
onSendMessage={dispatchSendMessage}
|
|
||||||
onOpenTaskDetail={dispatchOpenTask}
|
|
||||||
onOpenMemberProfile={dispatchOpenProfile}
|
|
||||||
onCreateTask={dispatchCreateTask}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
|
<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 && (
|
{fullscreen && (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<TeamGraphOverlay teamName={teamName} onClose={() => setFullscreen(false)} />
|
<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