feat(web): MS23-P2-008 panel grid responsive layout
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
This commit is contained in:
@@ -1,21 +1,86 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { AuditLogDrawer } from "@/components/mission-control/AuditLogDrawer";
|
import { AuditLogDrawer } from "@/components/mission-control/AuditLogDrawer";
|
||||||
import { GlobalAgentRoster } from "@/components/mission-control/GlobalAgentRoster";
|
import { GlobalAgentRoster } from "@/components/mission-control/GlobalAgentRoster";
|
||||||
import { MissionControlPanel } from "@/components/mission-control/MissionControlPanel";
|
import {
|
||||||
|
MAX_PANEL_COUNT,
|
||||||
|
MIN_PANEL_COUNT,
|
||||||
|
MissionControlPanel,
|
||||||
|
type PanelConfig,
|
||||||
|
} from "@/components/mission-control/MissionControlPanel";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useSessions } from "@/hooks/useMissionControl";
|
|
||||||
|
|
||||||
const DEFAULT_PANEL_SLOTS = ["panel-1", "panel-2", "panel-3", "panel-4"] as const;
|
const INITIAL_PANELS: PanelConfig[] = [{}];
|
||||||
|
|
||||||
export function MissionControlLayout(): React.JSX.Element {
|
export function MissionControlLayout(): React.JSX.Element {
|
||||||
const { sessions } = useSessions();
|
const [panels, setPanels] = useState<PanelConfig[]>(INITIAL_PANELS);
|
||||||
const [selectedSessionId, setSelectedSessionId] = useState<string>();
|
const [selectedSessionId, setSelectedSessionId] = useState<string>();
|
||||||
|
|
||||||
// First panel: selected session (from roster click) or first available session
|
const handleSelectSession = useCallback((sessionId: string): void => {
|
||||||
const firstPanelSessionId = selectedSessionId ?? sessions[0]?.id;
|
setSelectedSessionId(sessionId);
|
||||||
const panelSessionIds = [firstPanelSessionId, undefined, undefined, undefined] as const;
|
|
||||||
|
setPanels((currentPanels) => {
|
||||||
|
if (currentPanels.some((panel) => panel.sessionId === sessionId)) {
|
||||||
|
return currentPanels;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstEmptyPanelIndex = currentPanels.findIndex(
|
||||||
|
(panel) => panel.sessionId === undefined
|
||||||
|
);
|
||||||
|
if (firstEmptyPanelIndex >= 0) {
|
||||||
|
return currentPanels.map((panel, index) =>
|
||||||
|
index === firstEmptyPanelIndex ? { ...panel, sessionId } : panel
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPanels.length >= MAX_PANEL_COUNT) {
|
||||||
|
return currentPanels;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...currentPanels, { sessionId }];
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddPanel = useCallback((): void => {
|
||||||
|
setPanels((currentPanels) => {
|
||||||
|
if (currentPanels.length >= MAX_PANEL_COUNT) {
|
||||||
|
return currentPanels;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...currentPanels, {}];
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRemovePanel = useCallback((panelIndex: number): void => {
|
||||||
|
setPanels((currentPanels) => {
|
||||||
|
if (panelIndex < 0 || panelIndex >= currentPanels.length) {
|
||||||
|
return currentPanels;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPanels.length <= MIN_PANEL_COUNT) {
|
||||||
|
return currentPanels;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPanels = currentPanels.filter((_, index) => index !== panelIndex);
|
||||||
|
return nextPanels.length === 0 ? INITIAL_PANELS : nextPanels;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleExpandPanel = useCallback((panelIndex: number): void => {
|
||||||
|
setPanels((currentPanels) => {
|
||||||
|
if (panelIndex < 0 || panelIndex >= currentPanels.length) {
|
||||||
|
return currentPanels;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldExpand = !currentPanels[panelIndex]?.expanded;
|
||||||
|
|
||||||
|
return currentPanels.map((panel, index) => ({
|
||||||
|
...panel,
|
||||||
|
expanded: shouldExpand && index === panelIndex,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="flex h-full min-h-0 flex-col overflow-hidden" aria-label="Mission Control">
|
<section className="flex h-full min-h-0 flex-col overflow-hidden" aria-label="Mission Control">
|
||||||
@@ -32,12 +97,17 @@ export function MissionControlLayout(): React.JSX.Element {
|
|||||||
<div className="grid min-h-0 flex-1 gap-4 xl:grid-cols-[280px_minmax(0,1fr)]">
|
<div className="grid min-h-0 flex-1 gap-4 xl:grid-cols-[280px_minmax(0,1fr)]">
|
||||||
<aside className="h-full min-h-0">
|
<aside className="h-full min-h-0">
|
||||||
<GlobalAgentRoster
|
<GlobalAgentRoster
|
||||||
onSelectSession={setSelectedSessionId}
|
onSelectSession={handleSelectSession}
|
||||||
{...(selectedSessionId !== undefined ? { selectedSessionId } : {})}
|
{...(selectedSessionId !== undefined ? { selectedSessionId } : {})}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
<main className="h-full min-h-0 overflow-hidden">
|
<main className="h-full min-h-0 overflow-hidden">
|
||||||
<MissionControlPanel panels={DEFAULT_PANEL_SLOTS} panelSessionIds={panelSessionIds} />
|
<MissionControlPanel
|
||||||
|
panels={panels}
|
||||||
|
onAddPanel={handleAddPanel}
|
||||||
|
onRemovePanel={handleRemovePanel}
|
||||||
|
onExpandPanel={handleExpandPanel}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,27 +1,107 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
import { OrchestratorPanel } from "@/components/mission-control/OrchestratorPanel";
|
import { OrchestratorPanel } from "@/components/mission-control/OrchestratorPanel";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export interface PanelConfig {
|
||||||
|
sessionId?: string;
|
||||||
|
expanded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface MissionControlPanelProps {
|
interface MissionControlPanelProps {
|
||||||
panels: readonly string[];
|
panels: PanelConfig[];
|
||||||
panelSessionIds?: readonly (string | undefined)[];
|
onAddPanel: () => void;
|
||||||
|
onRemovePanel: (index: number) => void;
|
||||||
|
onExpandPanel: (index: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const MIN_PANEL_COUNT = 1;
|
||||||
|
export const MAX_PANEL_COUNT = 6;
|
||||||
|
|
||||||
export function MissionControlPanel({
|
export function MissionControlPanel({
|
||||||
panels,
|
panels,
|
||||||
panelSessionIds,
|
onAddPanel,
|
||||||
|
onRemovePanel,
|
||||||
|
onExpandPanel,
|
||||||
}: MissionControlPanelProps): React.JSX.Element {
|
}: MissionControlPanelProps): React.JSX.Element {
|
||||||
|
const expandedPanelIndex = panels.findIndex((panel) => panel.expanded);
|
||||||
|
const expandedPanel = expandedPanelIndex >= 0 ? panels[expandedPanelIndex] : undefined;
|
||||||
|
const canAddPanel = panels.length < MAX_PANEL_COUNT;
|
||||||
|
const canRemovePanel = panels.length > MIN_PANEL_COUNT;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (expandedPanelIndex < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
onExpandPanel(expandedPanelIndex);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [expandedPanelIndex, onExpandPanel]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid h-full min-h-0 auto-rows-fr grid-cols-1 gap-4 overflow-y-auto pr-1 md:grid-cols-2">
|
<div className="flex h-full min-h-0 flex-col gap-3">
|
||||||
{panels.map((panelId, index) => {
|
<div className="flex items-center justify-between">
|
||||||
const sessionId = panelSessionIds?.[index];
|
<h2 className="text-sm font-medium text-muted-foreground">Panels</h2>
|
||||||
|
<Button
|
||||||
if (sessionId === undefined) {
|
type="button"
|
||||||
return <OrchestratorPanel key={panelId} />;
|
variant="outline"
|
||||||
}
|
size="icon"
|
||||||
|
onClick={onAddPanel}
|
||||||
return <OrchestratorPanel key={panelId} sessionId={sessionId} />;
|
disabled={!canAddPanel}
|
||||||
})}
|
aria-label="Add panel"
|
||||||
|
title={canAddPanel ? "Add panel" : "Maximum of 6 panels"}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" className="text-lg leading-none">
|
||||||
|
+
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
|
{expandedPanelIndex >= 0 && expandedPanel ? (
|
||||||
|
<div className="h-full min-h-0">
|
||||||
|
<OrchestratorPanel
|
||||||
|
{...(expandedPanel.sessionId !== undefined
|
||||||
|
? { sessionId: expandedPanel.sessionId }
|
||||||
|
: {})}
|
||||||
|
onClose={() => {
|
||||||
|
onRemovePanel(expandedPanelIndex);
|
||||||
|
}}
|
||||||
|
closeDisabled={!canRemovePanel}
|
||||||
|
onExpand={() => {
|
||||||
|
onExpandPanel(expandedPanelIndex);
|
||||||
|
}}
|
||||||
|
expanded
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid h-full min-h-0 auto-rows-fr grid-cols-1 gap-4 overflow-y-auto pr-1 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{panels.map((panel, index) => (
|
||||||
|
<OrchestratorPanel
|
||||||
|
key={`panel-${String(index)}`}
|
||||||
|
{...(panel.sessionId !== undefined ? { sessionId: panel.sessionId } : {})}
|
||||||
|
onClose={() => {
|
||||||
|
onRemovePanel(index);
|
||||||
|
}}
|
||||||
|
closeDisabled={!canRemovePanel}
|
||||||
|
onExpand={() => {
|
||||||
|
onExpandPanel(index);
|
||||||
|
}}
|
||||||
|
expanded={panel.expanded ?? false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { formatDistanceToNow } from "date-fns";
|
|||||||
import { BargeInInput } from "@/components/mission-control/BargeInInput";
|
import { BargeInInput } from "@/components/mission-control/BargeInInput";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import type { BadgeVariant } from "@/components/ui/badge";
|
import type { BadgeVariant } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { PanelControls } from "@/components/mission-control/PanelControls";
|
import { PanelControls } from "@/components/mission-control/PanelControls";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
@@ -36,6 +37,64 @@ const CONNECTION_TEXT: Record<MissionControlConnectionStatus, string> = {
|
|||||||
|
|
||||||
export interface OrchestratorPanelProps {
|
export interface OrchestratorPanelProps {
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
|
onClose?: () => void;
|
||||||
|
closeDisabled?: boolean;
|
||||||
|
onExpand?: () => void;
|
||||||
|
expanded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PanelHeaderActionsProps {
|
||||||
|
onClose?: () => void;
|
||||||
|
closeDisabled?: boolean;
|
||||||
|
onExpand?: () => void;
|
||||||
|
expanded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanelHeaderActions({
|
||||||
|
onClose,
|
||||||
|
closeDisabled = false,
|
||||||
|
onExpand,
|
||||||
|
expanded = false,
|
||||||
|
}: PanelHeaderActionsProps): React.JSX.Element | null {
|
||||||
|
if (!onClose && !onExpand) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{onExpand ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={onExpand}
|
||||||
|
aria-label={expanded ? "Collapse panel" : "Expand panel"}
|
||||||
|
title={expanded ? "Collapse panel" : "Expand panel"}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" className="text-base leading-none">
|
||||||
|
{expanded ? "↙" : "↗"}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{onClose ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={closeDisabled}
|
||||||
|
aria-label="Remove panel"
|
||||||
|
title="Remove panel"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" className="text-base leading-none">
|
||||||
|
×
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRelativeTimestamp(timestamp: string): string {
|
function formatRelativeTimestamp(timestamp: string): string {
|
||||||
@@ -47,7 +106,13 @@ function formatRelativeTimestamp(timestamp: string): string {
|
|||||||
return formatDistanceToNow(parsedDate, { addSuffix: true });
|
return formatDistanceToNow(parsedDate, { addSuffix: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OrchestratorPanel({ sessionId }: OrchestratorPanelProps): React.JSX.Element {
|
export function OrchestratorPanel({
|
||||||
|
sessionId,
|
||||||
|
onClose,
|
||||||
|
closeDisabled,
|
||||||
|
onExpand,
|
||||||
|
expanded,
|
||||||
|
}: OrchestratorPanelProps): React.JSX.Element {
|
||||||
const { messages, status, error } = useSessionStream(sessionId ?? "");
|
const { messages, status, error } = useSessionStream(sessionId ?? "");
|
||||||
const { sessions } = useSessions();
|
const { sessions } = useSessions();
|
||||||
const bottomAnchorRef = useRef<HTMLDivElement | null>(null);
|
const bottomAnchorRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -55,6 +120,12 @@ export function OrchestratorPanel({ sessionId }: OrchestratorPanelProps): React.
|
|||||||
|
|
||||||
const selectedSessionStatus = sessions.find((session) => session.id === sessionId)?.status;
|
const selectedSessionStatus = sessions.find((session) => session.id === sessionId)?.status;
|
||||||
const controlsStatus = optimisticStatus ?? selectedSessionStatus ?? "unknown";
|
const controlsStatus = optimisticStatus ?? selectedSessionStatus ?? "unknown";
|
||||||
|
const panelHeaderActionProps = {
|
||||||
|
...(onClose !== undefined ? { onClose } : {}),
|
||||||
|
...(closeDisabled !== undefined ? { closeDisabled } : {}),
|
||||||
|
...(onExpand !== undefined ? { onExpand } : {}),
|
||||||
|
...(expanded !== undefined ? { expanded } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
bottomAnchorRef.current?.scrollIntoView({ block: "end" });
|
bottomAnchorRef.current?.scrollIntoView({ block: "end" });
|
||||||
@@ -68,7 +139,10 @@ export function OrchestratorPanel({ sessionId }: OrchestratorPanelProps): React.
|
|||||||
return (
|
return (
|
||||||
<Card className="flex h-full min-h-[220px] flex-col">
|
<Card className="flex h-full min-h-[220px] flex-col">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Orchestrator Panel</CardTitle>
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<CardTitle className="text-base">Orchestrator Panel</CardTitle>
|
||||||
|
<PanelHeaderActions {...panelHeaderActionProps} />
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
<CardContent className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||||
Select an agent to view its stream
|
Select an agent to view its stream
|
||||||
@@ -80,24 +154,25 @@ export function OrchestratorPanel({ sessionId }: OrchestratorPanelProps): React.
|
|||||||
return (
|
return (
|
||||||
<Card className="flex h-full min-h-[220px] flex-col">
|
<Card className="flex h-full min-h-[220px] flex-col">
|
||||||
<CardHeader className="space-y-2">
|
<CardHeader className="space-y-2">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<CardTitle className="text-base">Orchestrator Panel</CardTitle>
|
<CardTitle className="text-base">Orchestrator Panel</CardTitle>
|
||||||
<div className="flex flex-col items-start gap-2 sm:items-end">
|
<PanelHeaderActions {...panelHeaderActionProps} />
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
</div>
|
||||||
<span
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||||
className={`h-2.5 w-2.5 rounded-full ${CONNECTION_DOT_CLASS[status]} ${
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
status === "connecting" ? "animate-pulse" : ""
|
<span
|
||||||
}`}
|
className={`h-2.5 w-2.5 rounded-full ${CONNECTION_DOT_CLASS[status]} ${
|
||||||
aria-hidden="true"
|
status === "connecting" ? "animate-pulse" : ""
|
||||||
/>
|
}`}
|
||||||
<span>{CONNECTION_TEXT[status]}</span>
|
aria-hidden="true"
|
||||||
</div>
|
|
||||||
<PanelControls
|
|
||||||
sessionId={sessionId}
|
|
||||||
status={controlsStatus}
|
|
||||||
onStatusChange={setOptimisticStatus}
|
|
||||||
/>
|
/>
|
||||||
|
<span>{CONNECTION_TEXT[status]}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<PanelControls
|
||||||
|
sessionId={sessionId}
|
||||||
|
status={controlsStatus}
|
||||||
|
onStatusChange={setOptimisticStatus}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="truncate text-xs text-muted-foreground">Session: {sessionId}</p>
|
<p className="truncate text-xs text-muted-foreground">Session: {sessionId}</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
Reference in New Issue
Block a user