fix: wave 1 quick wins — agent names, scroll bounce-back, chart empty states, model-pin preservation, UUID spawn normalization (#546)

* fix(panel): notifications show agent names, metrics charts get empty states

* fix(panel): stop expand/collapse scroll bounce-back; add floating scroll-jump buttons

* fix(llm): provider mode switches preserve per-agent model pins

* fix(api): normalize agent UUID to slug at the orchestrator route boundary

* fix(panel,docs): align routing-card copy and map docs with preserved-pin mode switches

* fix(panel): drop dead unfiltered scroll hook, re-observe on Suspense swap, name system sender

* docs(map): reflect preserved-pin mode switches, UUID-slug normalization, panel wave-1 deltas

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-18 00:42:13 +02:00
committed by GitHub
co-authored by Renn F
parent 4c8a9fc008
commit 9b4ce6b9c8
24 changed files with 415 additions and 160 deletions
+5
View File
@@ -3,6 +3,7 @@ import { Sidebar } from "@/components/layout/sidebar";
import { Header } from "@/components/layout/header";
import { BottomTabBar } from "@/components/layout/bottom-tab-bar";
import { ScrollRestoration } from "@/components/scroll-restoration";
import { ScrollJumpButtons } from "@/components/scroll-jump-buttons";
import { RateLimitBanner } from "@/components/rate-limit/rate-limit-banner";
import { AutoRefreshDriver } from "@/components/providers/auto-refresh-driver";
@@ -27,6 +28,10 @@ export default function DashboardLayout({
</Suspense>
{children}
</main>
{/* Sibling of <main>, not a child — fixed positioning overlays it
regardless, and staying out keeps ScrollJumpButtons' own DOM node
from being mistaken for the page content root it measures. */}
<ScrollJumpButtons />
</div>
<BottomTabBar />
</div>
@@ -23,6 +23,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { usePageRefresh } from "@/hooks";
import { getAgentDisplayName } from "@/lib/agent-utils";
import {
Bell,
Check,
@@ -163,7 +164,7 @@ function NotificationCard({
<div className="text-xs text-muted-foreground">
From:{" "}
<HelpTip label={notification.from_agent}>
<span>{notification.from_agent.slice(0, 8)}</span>
<span>{getAgentDisplayName(notification.from_agent)}</span>
</HelpTip>{" "}
{formatDistanceToNow(new Date(notification.timestamp))} ago
</div>
+4 -1
View File
@@ -75,7 +75,10 @@ function TasksPageContent() {
}
});
const query = params.toString();
router.push(query ? `/tasks?${query}` : "/tasks");
// scroll: false — a UI-only param write (e.g. row expand/collapse)
// must not reset scroll on its own; ScrollRestoration's route key
// already excludes `expanded`, this is defense in depth.
router.push(query ? `/tasks?${query}` : "/tasks", { scroll: false });
},
[router, searchParams],
);
@@ -0,0 +1,19 @@
import { describe, it, expect } from "vitest";
import { buildRouteKey } from "../scroll-restoration";
describe("buildRouteKey", () => {
it("drops UI-only params so they don't fork the saved scroll position", () => {
const withExpanded = new URLSearchParams("status=open&expanded=abc,def");
const withoutExpanded = new URLSearchParams("status=open");
expect(buildRouteKey("/tasks", withExpanded)).toBe(
buildRouteKey("/tasks", withoutExpanded),
);
});
it("keeps real navigation params", () => {
expect(buildRouteKey("/tasks", new URLSearchParams("status=open"))).toBe(
"/tasks?status=open",
);
});
});
@@ -0,0 +1,24 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { UsageTimeSeriesChart } from "../usage-time-series-chart";
describe("UsageTimeSeriesChart", () => {
it("renders the card title", () => {
render(<UsageTimeSeriesChart data={undefined} isLoading={false} />);
expect(screen.getByText("Token Usage Over Time")).toBeInTheDocument();
});
it("shows an empty state when there is no data", () => {
render(<UsageTimeSeriesChart data={[]} isLoading={false} />);
expect(
screen.getByText("No usage recorded in this window yet."),
).toBeInTheDocument();
});
it("does not show the empty state while loading", () => {
render(<UsageTimeSeriesChart data={[]} isLoading />);
expect(
screen.queryByText("No usage recorded in this window yet."),
).not.toBeInTheDocument();
});
});
@@ -64,6 +64,10 @@ export function AgentUsageChart({ data, isLoading }: AgentUsageChartProps) {
<CardContent>
{isLoading ? (
<Skeleton className="h-52 w-full" />
) : tableRows.length === 0 ? (
<p className="text-sm text-muted-foreground py-16 text-center">
No usage recorded in this window yet.
</p>
) : view === "table" ? (
<div className="max-h-52 overflow-y-auto">
<table className="w-full text-sm">
@@ -47,6 +47,10 @@ export function ModelUsageDonut({ data, isLoading }: ModelUsageDonutProps) {
<CardContent>
{isLoading ? (
<Skeleton className="h-52 w-full" />
) : chartData.length === 0 ? (
<p className="text-sm text-muted-foreground py-16 text-center">
No usage recorded in this window yet.
</p>
) : (
<ResponsiveContainer width="100%" height={208}>
<PieChart>
@@ -61,6 +61,10 @@ export function TeamUsageChart({ data, isLoading }: TeamUsageChartProps) {
<CardContent>
{isLoading ? (
<Skeleton className="h-52 w-full" />
) : tableRows.length === 0 ? (
<p className="text-sm text-muted-foreground py-16 text-center">
No usage recorded in this window yet.
</p>
) : view === "table" ? (
<div className="max-h-52 overflow-y-auto">
<table className="w-full text-sm">
@@ -59,6 +59,10 @@ export function UsageTimeSeriesChart({
<CardContent>
{isLoading ? (
<Skeleton className="h-52 w-full" />
) : chartData.length === 0 ? (
<p className="text-sm text-muted-foreground py-16 text-center">
No usage recorded in this window yet.
</p>
) : (
<ResponsiveContainer width="100%" height={208}>
<AreaChart
@@ -0,0 +1,105 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { ArrowUp, ArrowDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { HelpTip } from "@/components/ui/help-tip";
// Fraction of main's visible height a user must have scrolled (or have left
// to scroll) before the corresponding button engages.
const ENGAGE_RATIO = 0.5;
/**
* Floating back-to-top / jump-to-bottom control for the shared <main>
* scroll container (see layout.tsx) — same querySelector("main") target
* scroll-restoration.tsx uses. Self-contained: no context/store.
*/
export function ScrollJumpButtons() {
const pathname = usePathname();
const [canScrollUp, setCanScrollUp] = useState(false);
const [canScrollDown, setCanScrollDown] = useState(false);
useEffect(() => {
const mainElement = document.querySelector("main");
if (!mainElement) return;
const update = () => {
const { scrollTop, scrollHeight, clientHeight } = mainElement;
const overflows = scrollHeight > clientHeight + 1;
const threshold = clientHeight * ENGAGE_RATIO;
setCanScrollUp(overflows && scrollTop > threshold);
setCanScrollDown(
overflows && scrollHeight - scrollTop - clientHeight > threshold,
);
};
update();
mainElement.addEventListener("scroll", update, { passive: true });
// main's own box is pinned by the flex layout, so overflowing content
// never resizes main itself — watch its children (a page may render a
// multi-root fragment, so all of them, not just the first). main is
// observed too for viewport resizes.
const observer = new ResizeObserver(update);
const observeContent = () => {
observer.disconnect();
observer.observe(mainElement);
Array.from(mainElement.children).forEach((child) =>
observer.observe(child),
);
update();
};
observeContent();
// A Suspense fallback→content swap replaces main's top-level children
// after mount — re-observe on childList changes so the ResizeObserver
// never ends up watching a detached fallback node.
const mutations = new MutationObserver(observeContent);
mutations.observe(mainElement, { childList: true });
return () => {
mainElement.removeEventListener("scroll", update);
mutations.disconnect();
observer.disconnect();
};
}, [pathname]);
if (!canScrollUp && !canScrollDown) return null;
const scrollTo = (top: number) =>
document.querySelector("main")?.scrollTo({ top, behavior: "smooth" });
return (
<div className="fixed right-4 bottom-20 z-30 flex flex-col gap-2 md:right-6 md:bottom-6">
{canScrollUp && (
<HelpTip label="Back to top" side="left">
<Button
variant="secondary"
size="icon"
className="rounded-full shadow-lg"
onClick={() => scrollTo(0)}
>
<ArrowUp className="h-4 w-4" />
<span className="sr-only">Back to top</span>
</Button>
</HelpTip>
)}
{canScrollDown && (
<HelpTip label="Jump to bottom" side="left">
<Button
variant="secondary"
size="icon"
className="rounded-full shadow-lg"
onClick={() =>
scrollTo(document.querySelector("main")?.scrollHeight ?? 0)
}
>
<ArrowDown className="h-4 w-4" />
<span className="sr-only">Jump to bottom</span>
</Button>
</HelpTip>
)}
</div>
);
}
+14 -1
View File
@@ -4,6 +4,19 @@ import { useEffect, useRef } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { useScrollRestorationStore } from "@/lib/stores/scroll-restoration-store";
// Params that reflect UI-only state (e.g. tasks/page.tsx row expand/collapse)
// rather than a distinct "page" a user navigated to — excluded from the
// route key so toggling them doesn't fork/reset the saved scroll position.
const UI_ONLY_PARAMS = ["expanded"];
// Exported for a cheap direct unit test — no need to render the component
// or mock next/navigation/zustand just to check param filtering.
export function buildRouteKey(pathname: string, searchParams: URLSearchParams) {
const filtered = new URLSearchParams(searchParams);
UI_ONLY_PARAMS.forEach((param) => filtered.delete(param));
return `${pathname}?${filtered.toString()}`;
}
/**
* Global scroll restoration component.
* Add this to the layout to automatically save/restore scroll positions.
@@ -17,7 +30,7 @@ export function ScrollRestoration() {
const hasRestored = useRef(false);
const prevRouteKey = useRef<string>("");
const routeKey = `${pathname}?${searchParams.toString()}`;
const routeKey = buildRouteKey(pathname, searchParams);
// Track last visited route per section
useEffect(() => {
@@ -223,11 +223,15 @@ export function AIRoutingCard() {
// --- Mode toggle handlers ---
const flipToAnthropic = async () => {
if (!confirm("Switch every agent to Anthropic? Clears any overrides."))
if (
!confirm(
"Switch every agent to Anthropic? Per-agent pins are kept; role/global assignments are replaced.",
)
)
return;
try {
await applyMode.mutateAsync({ mode: "anthropic" });
toast.success("All agents now on Anthropic");
toast.success("Role/global routing now on Anthropic — per-agent pins kept");
} catch (e) {
toast.error("Switch failed: " + errMsg(e));
}
@@ -238,10 +242,15 @@ export function AIRoutingCard() {
toast.error("Save the Grok (xAI) API key first");
return;
}
if (!confirm("Switch every agent to Grok? Clears any overrides.")) return;
if (
!confirm(
"Switch every agent to Grok? Per-agent pins are kept; role/global assignments are replaced.",
)
)
return;
try {
await applyMode.mutateAsync({ mode: "grok" });
toast.success("All agents now on Grok");
toast.success("Role/global routing now on Grok — per-agent pins kept");
} catch (e) {
toast.error("Switch failed: " + errMsg(e));
}
@@ -252,10 +261,15 @@ export function AIRoutingCard() {
toast.error("Save an Ollama API key first");
return;
}
if (!confirm("Switch every agent to Ollama? Clears any overrides.")) return;
if (
!confirm(
"Switch every agent to Ollama? Per-agent pins are kept; role/global assignments are replaced.",
)
)
return;
try {
await applyMode.mutateAsync({ mode: "ollama" });
toast.success("All agents now on Ollama");
toast.success("Role/global routing now on Ollama — per-agent pins kept");
} catch (e) {
toast.error("Switch failed: " + errMsg(e));
}
@@ -268,7 +282,7 @@ export function AIRoutingCard() {
}
if (
!confirm(
"Switch every agent to the self-hosted LLM? Clears any overrides.",
"Switch every agent to the self-hosted LLM? Per-agent pins are kept; role/global assignments are replaced.",
)
)
return;
@@ -277,7 +291,7 @@ export function AIRoutingCard() {
mode: "self_hosted",
...(selfHostedModel ? { default_model: selfHostedModel } : {}),
});
toast.success("All agents now on Self-Hosted LLM");
toast.success("Role/global routing now on Self-Hosted LLM — per-agent pins kept");
} catch (e) {
toast.error("Switch failed: " + errMsg(e));
}
@@ -480,7 +494,7 @@ export function AIRoutingCard() {
{/* -------- Mode toggle -------- */}
<section className="space-y-3">
<HelpTip label="Anthropic / Grok / Ollama / Self-Hosted route every agent to one provider and clear all per-agent overrides below. Mix keeps whatever's picked in the table.">
<HelpTip label="Anthropic / Grok / Ollama / Self-Hosted replace role/global routing with that provider; per-agent pins in the table below survive the switch. Mix keeps whatever's picked in the table.">
<Label className="text-sm font-medium">Routing mode</Label>
</HelpTip>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-2">
-81
View File
@@ -1,81 +0,0 @@
/**
* Scroll Restoration Hook
*
* Saves and restores scroll position when navigating between pages.
*/
"use client";
import { useEffect, useRef } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { useScrollRestorationStore } from "@/lib/stores/scroll-restoration-store";
export function useScrollRestoration(
scrollContainerRef?: React.RefObject<HTMLElement>,
) {
const pathname = usePathname();
const searchParams = useSearchParams();
const { setScrollPosition, getScrollPosition } = useScrollRestorationStore();
// Create a unique key for current route including search params
const routeKey = `${pathname}?${searchParams.toString()}`;
const hasRestored = useRef(false);
// Save scroll position on scroll
useEffect(() => {
const container = scrollContainerRef?.current ?? window;
const isWindow = container === window;
const handleScroll = () => {
const position = isWindow
? { x: window.scrollX, y: window.scrollY }
: {
x: (container as HTMLElement).scrollLeft,
y: (container as HTMLElement).scrollTop,
};
setScrollPosition(routeKey, position);
};
// Debounce scroll handler
let timeout: NodeJS.Timeout;
const debouncedScroll = () => {
clearTimeout(timeout);
timeout = setTimeout(handleScroll, 100);
};
container.addEventListener("scroll", debouncedScroll, { passive: true });
return () => {
clearTimeout(timeout);
container.removeEventListener("scroll", debouncedScroll);
};
}, [routeKey, scrollContainerRef, setScrollPosition]);
// Restore scroll position on mount
useEffect(() => {
if (hasRestored.current) return;
const savedPosition = getScrollPosition(routeKey);
if (savedPosition) {
const container = scrollContainerRef?.current ?? window;
const isWindow = container === window;
// Delay restoration to ensure content is rendered
requestAnimationFrame(() => {
if (isWindow) {
window.scrollTo(savedPosition.x, savedPosition.y);
} else {
(container as HTMLElement).scrollLeft = savedPosition.x;
(container as HTMLElement).scrollTop = savedPosition.y;
}
hasRestored.current = true;
});
}
}, [routeKey, scrollContainerRef, getScrollPosition]);
// Reset restoration flag when route changes
useEffect(() => {
hasRestored.current = false;
}, [routeKey]);
}
+2
View File
@@ -111,6 +111,8 @@ const AGENT_NAMES: Record<string, string> = {
"intake-1": "Intake",
"secretary-1": "Secretary",
"pr-reviewer-1": "PR Reviewer",
// Backend-authored notifications/events (not an agent)
system: "System",
};
/**