feat(graph): default to tab mode + fullscreen button in tab

- Graph button opens dedicated tab (was: overlay)
- Cmd+Shift+G opens tab (was: toggle overlay)
- Tab mode has Fullscreen button → opens overlay on top
- Tab actions (Message, Open) dispatch CustomEvents to TeamDetailView
  which opens corresponding dialogs (SendMessage, TaskDetail)
- Overlay still available via Fullscreen button or TeamGraphOverlay
This commit is contained in:
iliya 2026-03-28 14:17:10 +02:00
parent 8a9121fc3e
commit cb17e2158f
4 changed files with 108 additions and 8 deletions

View file

@ -6,6 +6,7 @@
import { useCallback } from 'react';
import {
Columns3,
Expand,
Eye,
EyeOff,
Maximize2,
@ -33,6 +34,7 @@ export interface GraphControlsProps {
onZoomToFit: () => void;
onRequestClose?: () => void;
onRequestPinAsTab?: () => void;
onRequestFullscreen?: () => void;
teamName: string;
teamColor?: string;
isAlive?: boolean;
@ -46,6 +48,7 @@ export function GraphControls({
onZoomToFit,
onRequestClose,
onRequestPinAsTab,
onRequestFullscreen,
teamName,
teamColor,
isAlive,
@ -133,6 +136,16 @@ export function GraphControls({
/>
</>
)}
{onRequestFullscreen && (
<>
<Separator />
<ToolbarButton
onClick={onRequestFullscreen}
icon={<Expand size={13} />}
label="Fullscreen"
/>
</>
)}
{onRequestClose && (
<ToolbarButton onClick={onRequestClose} icon={<X size={13} />} />
)}

View file

@ -31,6 +31,7 @@ export interface GraphViewProps {
className?: string;
onRequestClose?: () => void;
onRequestPinAsTab?: () => void;
onRequestFullscreen?: () => void;
}
export function GraphView({
@ -40,6 +41,7 @@ export function GraphView({
className,
onRequestClose,
onRequestPinAsTab,
onRequestFullscreen,
}: GraphViewProps): React.JSX.Element {
// ─── React state (user-facing only) ─────────────────────────────────────
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
@ -327,6 +329,7 @@ export function GraphView({
}}
onRequestClose={onRequestClose}
onRequestPinAsTab={onRequestPinAsTab}
onRequestFullscreen={onRequestFullscreen}
teamName={data.teamName}
teamColor={data.teamColor}
isAlive={data.isAlive}

View file

@ -214,18 +214,46 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
}
}, [editorOpen, graphOpen]);
// Listen for Cmd+Shift+G keyboard shortcut
// Listen for Cmd+Shift+G keyboard shortcut — opens graph tab
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (detail?.teamName === teamName) {
setGraphOpen((prev) => !prev);
useStore.getState().openTab({
type: 'graph',
label: `${teamName} Graph`,
teamName,
});
}
};
window.addEventListener('toggle-team-graph', handler);
return () => window.removeEventListener('toggle-team-graph', handler);
}, [teamName]);
// Listen for graph tab actions (open task, send message)
useEffect(() => {
const onOpenTask = (e: Event) => {
const { teamName: tn, taskId } = (e as CustomEvent).detail ?? {};
if (tn !== teamName || !data) return;
const task = data.tasks.find((t: { id: string }) => t.id === taskId);
if (task) setSelectedTask(task);
};
const onSendMsg = (e: Event) => {
const { teamName: tn, memberName } = (e as CustomEvent).detail ?? {};
if (tn !== teamName) return;
setSendDialogRecipient(memberName);
setSendDialogDefaultText(undefined);
setSendDialogDefaultChip(undefined);
setSendDialogOpen(true);
};
window.addEventListener('graph:open-task', onOpenTask);
window.addEventListener('graph:send-message', onSendMsg);
return () => {
window.removeEventListener('graph:open-task', onOpenTask);
window.removeEventListener('graph:send-message', onSendMsg);
};
});
const [sendDialogOpen, setSendDialogOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [stoppingTeam, setStoppingTeam] = useState(false);
@ -1432,7 +1460,11 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
className="h-6 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
onClick={(e) => {
e.stopPropagation();
setGraphOpen(true);
useStore.getState().openTab({
type: 'graph',
label: `${data.config.name} Graph`,
teamName,
});
}}
>
<Network size={12} />

View file

@ -1,8 +1,9 @@
/**
* TeamGraphTab wraps GraphView for use as a dedicated tab.
* Provides Fullscreen button that opens the overlay.
*/
import { useCallback } from 'react';
import { useCallback, useState, lazy, Suspense } from 'react';
import { GraphView } from '@claude-teams/agent-graph';
@ -10,22 +11,73 @@ import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter';
import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph';
const TeamGraphOverlay = lazy(() =>
import('./TeamGraphOverlay').then((m) => ({ default: m.TeamGraphOverlay }))
);
export interface TeamGraphTabProps {
teamName: string;
}
export const TeamGraphTab = ({ teamName }: TeamGraphTabProps): React.JSX.Element => {
const graphData = useTeamGraphAdapter(teamName);
const [fullscreen, setFullscreen] = useState(false);
const events: GraphEventPort = {
onNodeDoubleClick: useCallback((ref: GraphDomainRef) => {
console.log('Double-click in tab:', ref);
}, []),
onNodeDoubleClick: useCallback(
(ref: GraphDomainRef) => {
// Dispatch to TeamDetailView's dialog system via CustomEvent
if (ref.kind === 'task') {
window.dispatchEvent(
new CustomEvent('graph:open-task', { detail: { teamName, taskId: ref.taskId } })
);
} else if (ref.kind === 'member') {
window.dispatchEvent(
new CustomEvent('graph:send-message', {
detail: { teamName, memberName: ref.memberName },
})
);
}
},
[teamName]
),
onSendMessage: useCallback(
(memberName: string) => {
window.dispatchEvent(
new CustomEvent('graph:send-message', { detail: { teamName, memberName } })
);
},
[teamName]
),
onOpenTaskDetail: useCallback(
(taskId: string) => {
window.dispatchEvent(new CustomEvent('graph:open-task', { detail: { teamName, taskId } }));
},
[teamName]
),
onOpenMemberProfile: useCallback(
(memberName: string) => {
window.dispatchEvent(
new CustomEvent('graph:send-message', { detail: { teamName, memberName } })
);
},
[teamName]
),
};
return (
<div className="size-full" style={{ background: '#050510' }}>
<GraphView data={graphData} events={events} className="size-full" />
<GraphView
data={graphData}
events={events}
className="size-full"
onRequestFullscreen={() => setFullscreen(true)}
/>
{fullscreen && (
<Suspense fallback={null}>
<TeamGraphOverlay teamName={teamName} onClose={() => setFullscreen(false)} />
</Suspense>
)}
</div>
);
};