mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-14 02:42:24 +03:00
feat(dashboard): compare-mode selection in the History grid
This commit is contained in:
@@ -202,6 +202,33 @@ function PresetButtons({
|
||||
);
|
||||
}
|
||||
|
||||
/** Compare-mode toggle, rendered next to the preset buttons — presentation only. Its label
|
||||
* flips between `compareMode` ("Compare runs") and `compareExit` ("Exit compare mode") so the
|
||||
* accessible name itself communicates the current state, same idiom as `sourceCollapse` /
|
||||
* `sourceExpand` elsewhere in this tab family. */
|
||||
function CompareToggle({
|
||||
compareMode,
|
||||
t,
|
||||
onToggle,
|
||||
}: {
|
||||
compareMode: boolean;
|
||||
t: ReturnType<typeof useTranslations>;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={compareMode}
|
||||
className={`px-2 py-1 text-xs rounded border ${
|
||||
compareMode ? "border-primary bg-primary/10 font-medium" : "border-border text-muted"
|
||||
}`}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{compareMode ? t("compareExit") : t("compareMode")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Alert rows for history sources that failed to fetch — presentation only. */
|
||||
function FailedSourcesList({
|
||||
failedSources,
|
||||
@@ -222,18 +249,30 @@ function FailedSourcesList({
|
||||
}
|
||||
|
||||
/** The Airflow-grid table itself — header row of bucket labels + one row per identity with
|
||||
* state-colored cell dots. Presentation only; clicking a dot calls `onSelectItem`. Extracted so
|
||||
* `HistoryTab` stays under the max-lines ratchet. */
|
||||
* state-colored cell dots. Presentation only.
|
||||
*
|
||||
* `compareMode === false` (default): clicking a dot calls `onSelectItem`, opening the drawer —
|
||||
* unchanged from before compare mode existed.
|
||||
* `compareMode === true`: clicking a dot calls `onToggleSelect` instead (mark/unmark, never
|
||||
* opens the drawer); `selectedIds` drives the highlight + `aria-pressed` on the dot itself.
|
||||
*
|
||||
* Extracted so `HistoryTab` stays under the max-lines ratchet. */
|
||||
function HistoryGridTable({
|
||||
grid,
|
||||
preset,
|
||||
t,
|
||||
compareMode,
|
||||
selectedIds,
|
||||
onSelectItem,
|
||||
onToggleSelect,
|
||||
}: {
|
||||
grid: HistoryGrid;
|
||||
preset: Preset;
|
||||
t: ReturnType<typeof useTranslations>;
|
||||
compareMode: boolean;
|
||||
selectedIds: ReadonlySet<string>;
|
||||
onSelectItem: (item: HistoryItem) => void;
|
||||
onToggleSelect: (item: HistoryItem) => void;
|
||||
}) {
|
||||
return (
|
||||
// Kept mounted (with the previous range's rows) while `isLoading` is true for a refetch —
|
||||
@@ -272,15 +311,21 @@ function HistoryGridTable({
|
||||
const meta = `${item.label} · ${formatDuration(item.durationMs)} · ${t(
|
||||
STATE_KEY[item.state]
|
||||
)}`;
|
||||
const isSelected = compareMode && selectedIds.has(item.id);
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="w-3 h-3 rounded-sm motion-reduce:transition-none"
|
||||
className={`w-3 h-3 rounded-sm motion-reduce:transition-none${
|
||||
isSelected ? " ring-2 ring-offset-1 ring-primary" : ""
|
||||
}`}
|
||||
style={{ backgroundColor: orchStateColor(item.state) }}
|
||||
title={meta}
|
||||
aria-label={meta}
|
||||
onClick={() => onSelectItem(item)}
|
||||
aria-pressed={compareMode ? isSelected : undefined}
|
||||
onClick={() =>
|
||||
compareMode ? onToggleSelect(item) : onSelectItem(item)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -309,6 +354,11 @@ export function HistoryTab() {
|
||||
// `Date.now()` from a lazy initializer or from inside a nested async/timer callback.
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
const [selected, setSelected] = useState<OrchNode | null>(null);
|
||||
// Compare mode: LOCAL tab state only (not `?node=`/URL, not global state) — Task A3 consumes
|
||||
// both `compareMode` and `compareSelected` to render the comparison panel. A queue of at most
|
||||
// 2 items; the oldest is dropped once a 3rd (different) item is selected.
|
||||
const [compareMode, setCompareMode] = useState(false);
|
||||
const [compareSelected, setCompareSelected] = useState<HistoryItem[]>([]);
|
||||
|
||||
const range = useMemo(() => historyRangeFromPreset(preset, nowMs), [preset, nowMs]);
|
||||
const { items, failedSources, isLoading } = useHistoryData(range);
|
||||
@@ -316,17 +366,37 @@ export function HistoryTab() {
|
||||
() => buildHistoryGrid(items, range, BUCKET_COUNT[preset]),
|
||||
[items, range, preset]
|
||||
);
|
||||
const compareSelectedIds = useMemo(
|
||||
() => new Set(compareSelected.map((item) => item.id)),
|
||||
[compareSelected]
|
||||
);
|
||||
|
||||
const onSelectPreset = (p: Preset) => {
|
||||
setPreset(p);
|
||||
setNowMs(Date.now());
|
||||
};
|
||||
const onSelectItem = (item: HistoryItem) => setSelected(nodeFromHistoryItem(item));
|
||||
// Leaving compare mode clears the selection; entering it starts from an empty queue too, so
|
||||
// resetting unconditionally on every toggle is safe in both directions.
|
||||
const onToggleCompareMode = () => {
|
||||
setCompareMode((prev) => !prev);
|
||||
setCompareSelected([]);
|
||||
};
|
||||
// Click marks/unmarks a cell. A 3rd distinct selection drops the oldest (queue of 2, FIFO).
|
||||
const onToggleCompareSelect = (item: HistoryItem) => {
|
||||
setCompareSelected((prev) => {
|
||||
if (prev.some((i) => i.id === item.id)) return prev.filter((i) => i.id !== item.id);
|
||||
const next = [...prev, item];
|
||||
return next.length > 2 ? next.slice(next.length - 2) : next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<PresetButtons preset={preset} t={t} onSelect={onSelectPreset} />
|
||||
<CompareToggle compareMode={compareMode} t={t} onToggle={onToggleCompareMode} />
|
||||
{compareMode && <span className="text-[10px] text-muted">{t("compareHint")}</span>}
|
||||
<span className="text-[10px] text-muted">{t("historyConductorNote")}</span>
|
||||
</div>
|
||||
|
||||
@@ -343,7 +413,30 @@ export function HistoryTab() {
|
||||
)}
|
||||
|
||||
{grid.rows.length > 0 && (
|
||||
<HistoryGridTable grid={grid} preset={preset} t={t} onSelectItem={onSelectItem} />
|
||||
<HistoryGridTable
|
||||
grid={grid}
|
||||
preset={preset}
|
||||
t={t}
|
||||
compareMode={compareMode}
|
||||
selectedIds={compareSelectedIds}
|
||||
onSelectItem={onSelectItem}
|
||||
onToggleSelect={onToggleCompareSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Placeholder slot for Task A3's comparison panel: this task (A2) only owns the
|
||||
selection queue, not the panel body. Task A3 replaces this div's contents with the
|
||||
actual side-by-side comparison (built from `compareSelected` via
|
||||
`model/compareRuns.ts`'s `normalizeRunSide`/`buildComparison`, Task A1). Kept as a
|
||||
minimal, testable anchor rather than rendering nothing so A3 has a stable mount point
|
||||
and this task's tests can assert the 2-selected condition end-to-end. */}
|
||||
{compareMode && compareSelected.length === 2 && (
|
||||
<div
|
||||
data-testid="orchestration-history-compare-panel"
|
||||
className="text-xs text-muted border border-border rounded p-2"
|
||||
>
|
||||
{compareSelected.map((item) => item.label).join(" ↔ ")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* `onActionDone` must NOT close the drawer: the drawer renders its own success toast
|
||||
|
||||
@@ -13991,6 +13991,9 @@
|
||||
"historyRange1d": "24h",
|
||||
"historyRange7d": "7 days",
|
||||
"historyRange30d": "30 days",
|
||||
"compareMode": "Compare runs",
|
||||
"compareExit": "Exit compare mode",
|
||||
"compareHint": "Select two runs to compare",
|
||||
"showCompleted": "Show completed",
|
||||
"searchPlaceholder": "Search tasks…",
|
||||
"filterStates": "States",
|
||||
|
||||
@@ -300,4 +300,105 @@ describe("HistoryTab", () => {
|
||||
expect(c.textContent).toContain("historyEmpty");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("compare mode", () => {
|
||||
// Three distinct cells (distinct aria-label substrings) so tests can select each
|
||||
// individually: two a2a runs plus one Cloud Agent run.
|
||||
function threeItems() {
|
||||
return {
|
||||
a2aTasks: [
|
||||
a2aTask({ id: "t1", skill: "smart-routing" }),
|
||||
a2aTask({ id: "t3", skill: "eval-suite", createdAt: hoursAgo(2), completedAt: hoursAgo(1.5) }),
|
||||
],
|
||||
cloudAgentTasks: [cloudAgentTask()],
|
||||
};
|
||||
}
|
||||
|
||||
function cellFor(c: HTMLElement, needle: string) {
|
||||
return c.querySelector(`button[aria-label*="${needle}"]`) as HTMLButtonElement;
|
||||
}
|
||||
|
||||
function toggleCompareButton(c: HTMLElement) {
|
||||
return Array.from(c.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "compareMode" || b.textContent === "compareExit"
|
||||
) as HTMLButtonElement;
|
||||
}
|
||||
|
||||
it("compare mode is off by default: the toggle reports aria-pressed=false and a cell click still opens the drawer", async () => {
|
||||
vi.stubGlobal("fetch", mockFetch(threeItems()));
|
||||
const { c, cleanup } = render(<HistoryTab />);
|
||||
await flush();
|
||||
|
||||
const toggle = toggleCompareButton(c);
|
||||
expect(toggle).toBeTruthy();
|
||||
expect(toggle.getAttribute("aria-pressed")).toBe("false");
|
||||
expect(toggle.textContent).toBe("compareMode");
|
||||
|
||||
const cell = cellFor(c, "smart-routing");
|
||||
act(() => cell.click());
|
||||
const last = drawerCalls.at(-1) as { node: { id: string } | null };
|
||||
expect(last.node?.id).toBe("a2a:t1");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("turning compare mode on and clicking two cells marks both and does not open the drawer", async () => {
|
||||
vi.stubGlobal("fetch", mockFetch(threeItems()));
|
||||
const { c, cleanup } = render(<HistoryTab />);
|
||||
await flush();
|
||||
|
||||
act(() => toggleCompareButton(c).click());
|
||||
expect(toggleCompareButton(c).getAttribute("aria-pressed")).toBe("true");
|
||||
expect(toggleCompareButton(c).textContent).toBe("compareExit");
|
||||
|
||||
const cellA = cellFor(c, "smart-routing");
|
||||
const cellB = cellFor(c, "do the thing");
|
||||
act(() => cellA.click());
|
||||
act(() => cellB.click());
|
||||
|
||||
expect(cellFor(c, "smart-routing").getAttribute("aria-pressed")).toBe("true");
|
||||
expect(cellFor(c, "do the thing").getAttribute("aria-pressed")).toBe("true");
|
||||
// The drawer stub keeps re-rendering (parent state changed) but is never given a node —
|
||||
// neither click opened it.
|
||||
const last = drawerCalls.at(-1) as { node: unknown };
|
||||
expect(last.node).toBeNull();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("a third selection in compare mode drops the oldest, keeping the 2nd and 3rd marked", async () => {
|
||||
vi.stubGlobal("fetch", mockFetch(threeItems()));
|
||||
const { c, cleanup } = render(<HistoryTab />);
|
||||
await flush();
|
||||
|
||||
act(() => toggleCompareButton(c).click());
|
||||
act(() => cellFor(c, "smart-routing").click());
|
||||
act(() => cellFor(c, "do the thing").click());
|
||||
act(() => cellFor(c, "eval-suite").click());
|
||||
|
||||
expect(cellFor(c, "smart-routing").getAttribute("aria-pressed")).toBe("false");
|
||||
expect(cellFor(c, "do the thing").getAttribute("aria-pressed")).toBe("true");
|
||||
expect(cellFor(c, "eval-suite").getAttribute("aria-pressed")).toBe("true");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("leaving compare mode clears the selection", async () => {
|
||||
vi.stubGlobal("fetch", mockFetch(threeItems()));
|
||||
const { c, cleanup } = render(<HistoryTab />);
|
||||
await flush();
|
||||
|
||||
act(() => toggleCompareButton(c).click());
|
||||
act(() => cellFor(c, "smart-routing").click());
|
||||
act(() => cellFor(c, "do the thing").click());
|
||||
expect(cellFor(c, "smart-routing").getAttribute("aria-pressed")).toBe("true");
|
||||
|
||||
// Leave compare mode.
|
||||
act(() => toggleCompareButton(c).click());
|
||||
expect(toggleCompareButton(c).getAttribute("aria-pressed")).toBe("false");
|
||||
|
||||
// Re-enter compare mode: nothing should still be marked.
|
||||
act(() => toggleCompareButton(c).click());
|
||||
expect(cellFor(c, "smart-routing").getAttribute("aria-pressed")).toBe("false");
|
||||
expect(cellFor(c, "do the thing").getAttribute("aria-pressed")).toBe("false");
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user