mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user