Compare commits

..

4 Commits

Author SHA1 Message Date
90d2fa7563 fix(web): MS23-P2 fix broken lint/typecheck on main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-07 14:55:04 -06:00
31af6c26ec Merge pull request 'feat(web): MS23-P2-008 Panel grid responsive layout' (#731) from feat/ms23-p2-grid into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-07 20:48:36 +00:00
e4f942dde7 feat(web): MS23-P2-008 panel grid responsive layout
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-07 14:47:39 -06:00
4ea31c5749 Merge pull request 'feat: MS23-P2-007 AuditLogDrawer + audit log endpoint' (#730) from feat/ms23-p2-audit into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-07 20:40:21 +00:00
5 changed files with 268 additions and 43 deletions

View File

@@ -132,7 +132,7 @@ describe("KanbanPage add task flow", (): void => {
});
// Click the "+ Add task" button in the To Do column
const addTaskButtons = screen.getAllByRole("button", { name: /\+ Add task/i });
const addTaskButtons = await screen.findAllByRole("button", { name: /\+ Add task/i });
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await user.click(addTaskButtons[0]!); // First column is "To Do"
@@ -165,7 +165,7 @@ describe("KanbanPage add task flow", (): void => {
});
// Click the "+ Add task" button
const addTaskButtons = screen.getAllByRole("button", { name: /\+ Add task/i });
const addTaskButtons = await screen.findAllByRole("button", { name: /\+ Add task/i });
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await user.click(addTaskButtons[0]!);

View File

@@ -1,21 +1,86 @@
"use client";
import { useState } from "react";
import { useCallback, useState } from "react";
import { AuditLogDrawer } from "@/components/mission-control/AuditLogDrawer";
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 { 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 {
const { sessions } = useSessions();
const [panels, setPanels] = useState<PanelConfig[]>(INITIAL_PANELS);
const [selectedSessionId, setSelectedSessionId] = useState<string>();
// First panel: selected session (from roster click) or first available session
const firstPanelSessionId = selectedSessionId ?? sessions[0]?.id;
const panelSessionIds = [firstPanelSessionId, undefined, undefined, undefined] as const;
const handleSelectSession = useCallback((sessionId: string): void => {
setSelectedSessionId(sessionId);
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 (
<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)]">
<aside className="h-full min-h-0">
<GlobalAgentRoster
onSelectSession={setSelectedSessionId}
onSelectSession={handleSelectSession}
{...(selectedSessionId !== undefined ? { selectedSessionId } : {})}
/>
</aside>
<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>
</div>
</section>

View File

@@ -1,27 +1,107 @@
"use client";
import { useEffect } from "react";
import { OrchestratorPanel } from "@/components/mission-control/OrchestratorPanel";
import { Button } from "@/components/ui/button";
export interface PanelConfig {
sessionId?: string;
expanded?: boolean;
}
interface MissionControlPanelProps {
panels: readonly string[];
panelSessionIds?: readonly (string | undefined)[];
panels: PanelConfig[];
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({
panels,
panelSessionIds,
onAddPanel,
onRemovePanel,
onExpandPanel,
}: 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 (
<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">
{panels.map((panelId, index) => {
const sessionId = panelSessionIds?.[index];
if (sessionId === undefined) {
return <OrchestratorPanel key={panelId} />;
}
return <OrchestratorPanel key={panelId} sessionId={sessionId} />;
})}
<div className="flex h-full min-h-0 flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium text-muted-foreground">Panels</h2>
<Button
type="button"
variant="outline"
size="icon"
onClick={onAddPanel}
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>
);
}

View File

@@ -5,6 +5,7 @@ import { formatDistanceToNow } from "date-fns";
import { BargeInInput } from "@/components/mission-control/BargeInInput";
import { Badge } 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 { PanelControls } from "@/components/mission-control/PanelControls";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -36,6 +37,64 @@ const CONNECTION_TEXT: Record<MissionControlConnectionStatus, string> = {
export interface OrchestratorPanelProps {
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 {
@@ -47,7 +106,13 @@ function formatRelativeTimestamp(timestamp: string): string {
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 { sessions } = useSessions();
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 controlsStatus = optimisticStatus ?? selectedSessionStatus ?? "unknown";
const panelHeaderActionProps = {
...(onClose !== undefined ? { onClose } : {}),
...(closeDisabled !== undefined ? { closeDisabled } : {}),
...(onExpand !== undefined ? { onExpand } : {}),
...(expanded !== undefined ? { expanded } : {}),
};
useEffect(() => {
bottomAnchorRef.current?.scrollIntoView({ block: "end" });
@@ -68,7 +139,10 @@ export function OrchestratorPanel({ sessionId }: OrchestratorPanelProps): React.
return (
<Card className="flex h-full min-h-[220px] flex-col">
<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>
<CardContent className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
Select an agent to view its stream
@@ -80,24 +154,25 @@ export function OrchestratorPanel({ sessionId }: OrchestratorPanelProps): React.
return (
<Card className="flex h-full min-h-[220px] flex-col">
<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>
<div className="flex flex-col items-start gap-2 sm:items-end">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span
className={`h-2.5 w-2.5 rounded-full ${CONNECTION_DOT_CLASS[status]} ${
status === "connecting" ? "animate-pulse" : ""
}`}
aria-hidden="true"
/>
<span>{CONNECTION_TEXT[status]}</span>
</div>
<PanelControls
sessionId={sessionId}
status={controlsStatus}
onStatusChange={setOptimisticStatus}
<PanelHeaderActions {...panelHeaderActionProps} />
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span
className={`h-2.5 w-2.5 rounded-full ${CONNECTION_DOT_CLASS[status]} ${
status === "connecting" ? "animate-pulse" : ""
}`}
aria-hidden="true"
/>
<span>{CONNECTION_TEXT[status]}</span>
</div>
<PanelControls
sessionId={sessionId}
status={controlsStatus}
onStatusChange={setOptimisticStatus}
/>
</div>
<p className="truncate text-xs text-muted-foreground">Session: {sessionId}</p>
</CardHeader>

View File

@@ -102,5 +102,5 @@ describe("OnboardingWizard", () => {
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/");
});
});
}, 10_000);
});