feat(dashboard): compare-mode selection in the History grid

This commit is contained in:
diegosouzapw
2026-09-03 21:44:43 -03:00
parent d6515bd44a
commit fdc7f9bcfe
3 changed files with 202 additions and 5 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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();
});
});
});