');
+ expect(html).toContain("kpi kpi-danger");
+ expect(html).toContain('5');
+ expect(html).not.toContain("");
+ });
+
+ it("renders page headers without description paragraphs", () => {
+ const html = renderToStaticMarkup();
+ expect(html).toContain('Overview
');
+ expect(html).not.toContain("");
+ });
+});
diff --git a/apps/web/src/components/console-ui.tsx b/apps/web/src/components/console-ui.tsx
new file mode 100644
index 000000000..fbbae3b3d
--- /dev/null
+++ b/apps/web/src/components/console-ui.tsx
@@ -0,0 +1,315 @@
+import { CircleHelp, RefreshCw, type LucideIcon } from "lucide-react";
+import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
+import * as m from "motion/react-m";
+import { createPortal } from "react-dom";
+import { useTranslation } from "react-i18next";
+
+/**
+ * Shared page grammar for the administrator console. Every page uses the same
+ * header, body padding, section heading, metric strip, status and meter
+ * vocabulary so that screens read as one product.
+ */
+
+const TIP_WIDTH = 288;
+
+/**
+ * Explanations live behind a small circled question mark instead of lines of
+ * small print. The tip opens on hover, keyboard focus or click and renders in a
+ * portal so framed containers cannot clip it.
+ */
+export function HelpTip({ children, label, id: fixedId }: { children: ReactNode; label?: string; id?: string }) {
+ const { t } = useTranslation();
+ const buttonRef = useRef(null);
+ const [position, setPosition] = useState<{ top: number; left: number; above: boolean } | null>(null);
+ const [pinned, setPinned] = useState(false);
+ const generatedId = useId();
+ // A fixed id lets a form control keep pointing at the explanation with aria-describedby.
+ const id = fixedId ?? generatedId;
+
+ const show = useCallback(() => {
+ const rect = buttonRef.current?.getBoundingClientRect();
+ if (!rect) return;
+ const left = Math.min(Math.max(8, rect.left - 12), window.innerWidth - TIP_WIDTH - 8);
+ const above = rect.bottom + 160 > window.innerHeight && rect.top > 180;
+ setPosition({ top: above ? rect.top - 8 : rect.bottom + 8, left, above });
+ }, []);
+ const hide = useCallback(() => {
+ setPosition(null);
+ setPinned(false);
+ }, []);
+
+ useEffect(() => {
+ if (!position) return;
+ const close = () => hide();
+ window.addEventListener("scroll", close, true);
+ window.addEventListener("resize", close);
+ return () => {
+ window.removeEventListener("scroll", close, true);
+ window.removeEventListener("resize", close);
+ };
+ }, [hide, position]);
+
+ return (
+ <>
+
+ {/* The explanation stays in the document for assistive technology; the popover is its visual copy. */}
+ {children}
+ {position && typeof document !== "undefined" ? createPortal(
+
+ {children}
+
,
+ document.body,
+ ) : null}
+ >
+ );
+}
+
+export function PageHeader({
+ title,
+ help,
+ actions,
+ headingId,
+}: {
+ title: ReactNode;
+ help?: ReactNode;
+ actions?: ReactNode;
+ headingId?: string;
+}) {
+ return (
+
+ );
+}
+
+export function RefreshButton({
+ onClick,
+ refreshing = false,
+ disabled = false,
+ label,
+ updatedAt,
+}: {
+ onClick: () => void;
+ refreshing?: boolean;
+ disabled?: boolean;
+ label?: string;
+ /** Shown as the button's tooltip instead of a visible timestamp. */
+ updatedAt?: string | null;
+}) {
+ const { t } = useTranslation();
+ const name = refreshing ? t("actions.refreshing") : label ?? t("actions.refresh");
+ return (
+
+ );
+}
+
+export function PageBody({ children, className }: { children: ReactNode; className?: string }) {
+ return {children}
;
+}
+
+export function Section({
+ title,
+ help,
+ actions,
+ headingId,
+ children,
+ className,
+}: {
+ title: ReactNode;
+ help?: ReactNode;
+ actions?: ReactNode;
+ headingId: string;
+ children: ReactNode;
+ className?: string;
+}) {
+ return (
+
+ );
+}
+
+export type Tone = "ok" | "warning" | "danger" | "neutral" | "pending";
+
+export function StatusDot({ tone, label }: { tone: Tone; label: ReactNode }) {
+ return (
+
+
+ {label}
+
+ );
+}
+
+export function KpiStrip({ children, label }: { children: ReactNode; label: string }) {
+ return {children}
;
+}
+
+export function Kpi({
+ label,
+ value,
+ help,
+ tone,
+}: {
+ label: ReactNode;
+ value: ReactNode;
+ help?: ReactNode;
+ tone?: Tone;
+}) {
+ return (
+
+
-
+ {label}
+ {help ? {help} : null}
+
+ -
+ {tone ? : null}
+ {value}
+
+
+ );
+}
+
+/** A ratio against a limit. The fill carries severity; the track stays in the same ramp. */
+export function Meter({
+ value,
+ limit,
+ label,
+ warnAt = 0.9,
+ dangerAt = 1,
+ color,
+}: {
+ value: number | null;
+ limit: number | null;
+ label: string;
+ warnAt?: number;
+ dangerAt?: number;
+ /** Fixed identity colour for a share (for example a status); disables the threshold tones. */
+ color?: string;
+}) {
+ const ratio = value !== null && limit !== null && limit > 0 ? Math.min(1, Math.max(0, value / limit)) : null;
+ const tone = ratio === null ? "unknown" : color ? "share" : ratio >= dangerAt ? "danger" : ratio >= warnAt ? "warning" : "ok";
+ return (
+
+ 0 ? 3 : 0)}%`, background: color }} />
+
+ );
+}
+
+export function EmptyState({
+ icon: Icon,
+ title,
+ description,
+ action,
+}: {
+ icon?: LucideIcon;
+ title: ReactNode;
+ description?: ReactNode;
+ action?: ReactNode;
+}) {
+ return (
+
+ {Icon ?
: null}
+
{title}
+ {description ?
{description}
: null}
+ {action ?
{action}
: null}
+
+ );
+}
+
+export function SegmentedControl({
+ label,
+ value,
+ options,
+ onChange,
+}: {
+ label: string;
+ value: T;
+ options: ReadonlyArray<{ value: T; label: string; count?: string | number }>;
+ onChange: (value: T) => void;
+}) {
+ const groupRef = useRef(null);
+ const thumbId = useId();
+ const move = (delta: number) => {
+ const index = options.findIndex((option) => option.value === value);
+ const next = options[(index + delta + options.length) % options.length];
+ if (!next) return;
+ onChange(next.value);
+ window.requestAnimationFrame(() => groupRef.current?.querySelector('[aria-checked="true"]')?.focus());
+ };
+ return (
+ {
+ if (event.key === "ArrowRight" || event.key === "ArrowDown") { event.preventDefault(); move(1); }
+ if (event.key === "ArrowLeft" || event.key === "ArrowUp") { event.preventDefault(); move(-1); }
+ }}
+ >
+ {options.map((option) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/components/list-ui.tsx b/apps/web/src/components/list-ui.tsx
new file mode 100644
index 000000000..684c79c46
--- /dev/null
+++ b/apps/web/src/components/list-ui.tsx
@@ -0,0 +1,144 @@
+import { Check, Copy, Search } from "lucide-react";
+import { useEffect, useState, type ButtonHTMLAttributes, type MouseEvent, type ReactNode } from "react";
+import type { TFunction } from "i18next";
+import { useTranslation } from "react-i18next";
+
+import { shortId } from "../lib/format";
+
+/**
+ * One list grammar for every resource page: a toolbar (search first, then
+ * filters, the count on the right), a framed table whose first column carries
+ * the name with its ID underneath, and row actions aligned at the end.
+ */
+
+export function ListToolbar({ label, children, summary }: { label: string; children?: ReactNode; summary?: ReactNode }) {
+ return (
+
+
{children}
+ {summary ?
{summary}
: null}
+
+ );
+}
+
+export function SearchField({
+ value,
+ onChange,
+ placeholder,
+ label,
+}: {
+ value: string;
+ onChange: (value: string) => void;
+ placeholder: string;
+ label?: string;
+}) {
+ const { t } = useTranslation();
+ return (
+
+ );
+}
+
+/** "12 total", "3 of 12" while filtering, or "40 loaded" when more pages exist. */
+export function listSummary(
+ t: TFunction<"common">,
+ shown: number,
+ total: number,
+ options: { hasMore?: boolean; locale?: string } = {},
+): string {
+ const format = (value: number) => value.toLocaleString(options.locale);
+ // With more pages on Core the filter covers loaded rows only, and the count says so.
+ if (shown !== total) return t(options.hasMore ? "list.filteredLoaded" : "list.filtered", { shown: format(shown), total: format(total) });
+ if (options.hasMore) return t("list.loadedMore", { n: format(total) });
+ return t("list.count", { n: format(total) });
+}
+
+/** A one-click copy button for an ID. */
+export function CopyIdButton({ id, label }: { id: string; label?: string }) {
+ const { t } = useTranslation();
+ const [copied, setCopied] = useState(false);
+ useEffect(() => {
+ if (!copied) return;
+ const timer = window.setTimeout(() => setCopied(false), 1500);
+ return () => window.clearTimeout(timer);
+ }, [copied]);
+ const copy = (event: MouseEvent) => {
+ event.stopPropagation();
+ const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard;
+ if (!clipboard) return;
+ void clipboard.writeText(id).then(() => setCopied(true), () => undefined);
+ };
+ const name = copied ? t("actions.copied") : label ?? t("actions.copyId");
+ return (
+
+ );
+}
+
+/** A resource ID with its copy button; compact IDs keep the full value in the tooltip. */
+export function CopyableId({ id, compact = false, label }: { id: string; compact?: boolean; label?: string }) {
+ return (
+
+ {compact ? shortId(id) : id}
+
+
+ );
+}
+
+/**
+ * The first column of every resource table: the name (or a muted fallback)
+ * with the compact ID and its copy button underneath. When `onOpen` is given
+ * the name opens the resource.
+ */
+export function NameCell({
+ name,
+ id,
+ fallback,
+ onOpen,
+ openLabel,
+ openProps,
+ idLabel,
+ children,
+}: {
+ name: string | null | undefined;
+ id: string;
+ fallback?: string;
+ onOpen?: () => void;
+ openLabel?: string;
+ /** Extra attributes for the open button (data hooks, disabled). */
+ openProps?: ButtonHTMLAttributes & Record<`data-${string}`, string>;
+ /** Accessible name of the copy button when a resource-specific one reads better ("Copy File ID"). */
+ idLabel?: string;
+ /** Optional inline marker after the name, for example a status. */
+ children?: ReactNode;
+}) {
+ const label = name?.trim() ? name : null;
+ const title = label ?? fallback ?? id;
+ return (
+
+
+ {onOpen ? (
+
+ ) : (
+ {title}
+ )}
+ {children}
+
+
+
+ );
+}
+
+export function RowActions({ children }: { children: ReactNode }) {
+ return {children};
+}
diff --git a/apps/web/src/components/live-number.tsx b/apps/web/src/components/live-number.tsx
new file mode 100644
index 000000000..75980b53b
--- /dev/null
+++ b/apps/web/src/components/live-number.tsx
@@ -0,0 +1,28 @@
+import NumberFlow, { type Format } from "@number-flow/react";
+import { useTranslation } from "react-i18next";
+
+import { MISSING } from "../lib/format";
+
+export type LiveNumberFormat = "integer" | "compact" | "percent";
+
+/** Intl options matching formatInteger, formatCompact and formatPercent in lib/format. */
+function formatOptions(value: number, format: LiveNumberFormat, locale: string | undefined): Format {
+ if (format === "percent") return { style: "percent", maximumFractionDigits: value > 0 && value < 0.1 ? 1 : 0 };
+ if (format === "integer" || Math.abs(value) < 10_000) return { maximumFractionDigits: 0 };
+ const integerDigits = new Intl.NumberFormat(locale, { notation: "compact", maximumFractionDigits: 1 })
+ .formatToParts(value).filter((part) => part.type === "integer").map((part) => part.value).join("").length;
+ return { notation: "compact", maximumFractionDigits: integerDigits === 1 ? 2 : 1 };
+}
+
+/**
+ * A live figure. When a refresh changes it, its digits roll to the new value
+ * (NumberFlow, https://number-flow.barvian.me) instead of swapping, so a
+ * change is noticed without a highlight; the first render does not animate.
+ * Missing figures stay an em dash.
+ */
+export function LiveNumber({ value, format = "integer" }: { value: number | null | undefined; format?: LiveNumberFormat }) {
+ const { i18n } = useTranslation();
+ const locale = i18n.resolvedLanguage;
+ if (value === null || value === undefined || !Number.isFinite(value)) return <>{MISSING}>;
+ return ;
+}
diff --git a/apps/web/src/components/magicui/README.md b/apps/web/src/components/magicui/README.md
new file mode 100644
index 000000000..325aaa17c
--- /dev/null
+++ b/apps/web/src/components/magicui/README.md
@@ -0,0 +1,14 @@
+# Magic UI components
+
+Copied from the Magic UI registry (, MIT License,
+Copyright (c) Magic UI) and used by the onboarding stage. Their animation
+keyframes live in `src/styles/magicui-theme.css`.
+
+Local changes:
+
+- `motion.*` components are Motion's lazy `m.*` components, as the console
+ renders inside a strict `LazyMotion`.
+- `flickering-grid.tsx` draws one still frame with reduced motion, redrawn on
+ resize, instead of flickering.
+- `orbiting-circles.tsx` starts each orbit at its wall-clock phase, so a
+ remounted orbit continues instead of jumping, and ignores the unused `delay`.
diff --git a/apps/web/src/components/magicui/animated-shiny-text.tsx b/apps/web/src/components/magicui/animated-shiny-text.tsx
new file mode 100644
index 000000000..02b24c193
--- /dev/null
+++ b/apps/web/src/components/magicui/animated-shiny-text.tsx
@@ -0,0 +1,42 @@
+import {
+ type ComponentPropsWithoutRef,
+ type CSSProperties,
+ type FC,
+} from "react"
+
+import { cn } from "@/lib/utils"
+
+export interface AnimatedShinyTextProps extends ComponentPropsWithoutRef<"span"> {
+ shimmerWidth?: number
+}
+
+export const AnimatedShinyText: FC = ({
+ children,
+ className,
+ shimmerWidth = 100,
+ ...props
+}) => {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/apps/web/src/components/magicui/blur-fade.tsx b/apps/web/src/components/magicui/blur-fade.tsx
new file mode 100644
index 000000000..a8aa58630
--- /dev/null
+++ b/apps/web/src/components/magicui/blur-fade.tsx
@@ -0,0 +1,94 @@
+"use client"
+
+import { useRef } from "react"
+import * as m from "motion/react-m"
+import {
+ AnimatePresence,
+ useInView,
+ type MotionProps,
+ type UseInViewOptions,
+ type Variants,
+} from "motion/react"
+
+type MarginType = UseInViewOptions["margin"]
+
+interface BlurFadeProps extends MotionProps {
+ children: React.ReactNode
+ className?: string
+ variant?: {
+ hidden: { y: number }
+ visible: { y: number }
+ }
+ duration?: number
+ delay?: number
+ offset?: number
+ direction?: "up" | "down" | "left" | "right"
+ inView?: boolean
+ inViewMargin?: MarginType
+ blur?: string
+}
+
+const getFilter = (v: Variants[string]) =>
+ typeof v === "function" ? undefined : v.filter
+
+export function BlurFade({
+ children,
+ className,
+ variant,
+ duration = 0.4,
+ delay = 0,
+ offset = 6,
+ direction = "down",
+ inView = false,
+ inViewMargin = "-50px",
+ blur = "6px",
+ ...props
+}: BlurFadeProps) {
+ const ref = useRef(null)
+ const inViewResult = useInView(ref, { once: true, margin: inViewMargin })
+ const isInView = !inView || inViewResult
+ const defaultVariants: Variants = {
+ hidden: {
+ [direction === "left" || direction === "right" ? "x" : "y"]:
+ direction === "right" || direction === "down" ? -offset : offset,
+ opacity: 0,
+ filter: `blur(${blur})`,
+ },
+ visible: {
+ [direction === "left" || direction === "right" ? "x" : "y"]: 0,
+ opacity: 1,
+ filter: `blur(0px)`,
+ },
+ }
+ const combinedVariants = variant ?? defaultVariants
+
+ const hiddenFilter = getFilter(combinedVariants.hidden)
+ const visibleFilter = getFilter(combinedVariants.visible)
+
+ const shouldTransitionFilter =
+ hiddenFilter != null &&
+ visibleFilter != null &&
+ hiddenFilter !== visibleFilter
+
+ return (
+
+
+ {children}
+
+
+ )
+}
diff --git a/apps/web/src/components/magicui/border-beam.tsx b/apps/web/src/components/magicui/border-beam.tsx
new file mode 100644
index 000000000..2e34104ca
--- /dev/null
+++ b/apps/web/src/components/magicui/border-beam.tsx
@@ -0,0 +1,111 @@
+"use client"
+
+import * as m from "motion/react-m"
+import {
+ type MotionStyle,
+ type Transition,
+} from "motion/react"
+
+import { cn } from "@/lib/utils"
+
+interface BorderBeamProps {
+ /**
+ * The size of the border beam.
+ */
+ size?: number
+ /**
+ * The duration of the border beam.
+ */
+ duration?: number
+ /**
+ * The delay of the border beam.
+ */
+ delay?: number
+ /**
+ * The color of the border beam from.
+ */
+ colorFrom?: string
+ /**
+ * The color of the border beam to.
+ */
+ colorTo?: string
+ /**
+ * The motion transition of the border beam.
+ */
+ transition?: Transition
+ /**
+ * The class name of the border beam.
+ */
+ className?: string
+ /**
+ * The style of the border beam.
+ */
+ style?: React.CSSProperties
+ /**
+ * Whether to reverse the animation direction.
+ */
+ reverse?: boolean
+ /**
+ * The initial offset position (0-100).
+ */
+ initialOffset?: number
+ /**
+ * The border width of the beam.
+ */
+ borderWidth?: number
+}
+
+export const BorderBeam = ({
+ className,
+ size = 50,
+ delay = 0,
+ duration = 6,
+ colorFrom = "#ffaa40",
+ colorTo = "#9c40ff",
+ transition,
+ style,
+ reverse = false,
+ initialOffset = 0,
+ borderWidth = 1,
+}: BorderBeamProps) => {
+ return (
+
+
+
+ )
+}
diff --git a/apps/web/src/components/magicui/flickering-grid.tsx b/apps/web/src/components/magicui/flickering-grid.tsx
new file mode 100644
index 000000000..5ce387ede
--- /dev/null
+++ b/apps/web/src/components/magicui/flickering-grid.tsx
@@ -0,0 +1,203 @@
+"use client"
+
+import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
+
+import { cn } from "@/lib/utils"
+
+interface FlickeringGridProps extends React.HTMLAttributes {
+ squareSize?: number
+ gridGap?: number
+ flickerChance?: number
+ color?: string
+ width?: number
+ height?: number
+ className?: string
+ maxOpacity?: number
+}
+
+export const FlickeringGrid: React.FC = ({
+ squareSize = 4,
+ gridGap = 6,
+ flickerChance = 0.3,
+ color = "rgb(0, 0, 0)",
+ width,
+ height,
+ className,
+ maxOpacity = 0.3,
+ ...props
+}) => {
+ const canvasRef = useRef(null)
+ const containerRef = useRef(null)
+ const [isInView, setIsInView] = useState(false)
+ const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 })
+
+ const memoizedColor = useMemo(() => {
+ const toRGBA = (color: string) => {
+ if (typeof window === "undefined") {
+ return `rgba(0, 0, 0,`
+ }
+ const canvas = document.createElement("canvas")
+ canvas.width = canvas.height = 1
+ const ctx = canvas.getContext("2d")
+ if (!ctx) return "rgba(255, 0, 0,"
+ ctx.fillStyle = color
+ ctx.fillRect(0, 0, 1, 1)
+ const [r, g, b] = Array.from(ctx.getImageData(0, 0, 1, 1).data)
+ return `rgba(${r}, ${g}, ${b},`
+ }
+ return toRGBA(color)
+ }, [color])
+
+ const setupCanvas = useCallback(
+ (canvas: HTMLCanvasElement, width: number, height: number) => {
+ const dpr = window.devicePixelRatio || 1
+ canvas.width = width * dpr
+ canvas.height = height * dpr
+ canvas.style.width = `${width}px`
+ canvas.style.height = `${height}px`
+ const cols = Math.ceil(width / (squareSize + gridGap))
+ const rows = Math.ceil(height / (squareSize + gridGap))
+
+ const squares = new Float32Array(cols * rows)
+ for (let i = 0; i < squares.length; i++) {
+ squares[i] = Math.random() * maxOpacity
+ }
+
+ return { cols, rows, squares, dpr }
+ },
+ [squareSize, gridGap, maxOpacity]
+ )
+
+ const updateSquares = useCallback(
+ (squares: Float32Array, deltaTime: number) => {
+ for (let i = 0; i < squares.length; i++) {
+ if (Math.random() < flickerChance * deltaTime) {
+ squares[i] = Math.random() * maxOpacity
+ }
+ }
+ },
+ [flickerChance, maxOpacity]
+ )
+
+ const drawGrid = useCallback(
+ (
+ ctx: CanvasRenderingContext2D,
+ width: number,
+ height: number,
+ cols: number,
+ rows: number,
+ squares: Float32Array,
+ dpr: number
+ ) => {
+ ctx.clearRect(0, 0, width, height)
+ ctx.fillStyle = "transparent"
+ ctx.fillRect(0, 0, width, height)
+
+ for (let i = 0; i < cols; i++) {
+ for (let j = 0; j < rows; j++) {
+ const opacity = squares[i * rows + j]
+ ctx.fillStyle = `${memoizedColor}${opacity})`
+ ctx.fillRect(
+ i * (squareSize + gridGap) * dpr,
+ j * (squareSize + gridGap) * dpr,
+ squareSize * dpr,
+ squareSize * dpr
+ )
+ }
+ }
+ },
+ [memoizedColor, squareSize, gridGap]
+ )
+
+ useEffect(() => {
+ const canvas = canvasRef.current
+ const container = containerRef.current
+ const ctx = canvas?.getContext("2d") ?? null
+ let animationFrameId: number | null = null
+ let resizeObserver: ResizeObserver | null = null
+ let intersectionObserver: IntersectionObserver | null = null
+ let gridParams: ReturnType | null = null
+
+ if (canvas && container && ctx) {
+ // Local change: with reduced motion the grid is one still frame, redrawn on resize.
+ const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ const updateCanvasSize = () => {
+ const newWidth = width || container.clientWidth
+ const newHeight = height || container.clientHeight
+ setCanvasSize({ width: newWidth, height: newHeight })
+ gridParams = setupCanvas(canvas, newWidth, newHeight)
+ if (reduced) {
+ drawGrid(ctx, canvas.width, canvas.height, gridParams.cols, gridParams.rows, gridParams.squares, gridParams.dpr)
+ }
+ }
+
+ updateCanvasSize()
+
+ let lastTime = 0
+ const animate = (time: number) => {
+ if (!isInView || !gridParams) return
+
+ const deltaTime = (time - lastTime) / 1000
+ lastTime = time
+
+ updateSquares(gridParams.squares, deltaTime)
+ drawGrid(
+ ctx,
+ canvas.width,
+ canvas.height,
+ gridParams.cols,
+ gridParams.rows,
+ gridParams.squares,
+ gridParams.dpr
+ )
+ animationFrameId = requestAnimationFrame(animate)
+ }
+
+ resizeObserver = new ResizeObserver(() => {
+ updateCanvasSize()
+ })
+ resizeObserver.observe(container)
+
+ intersectionObserver = new IntersectionObserver(
+ ([entry]) => {
+ setIsInView(entry?.isIntersecting ?? false)
+ },
+ { threshold: 0 }
+ )
+ intersectionObserver.observe(canvas)
+
+ if (!reduced && isInView) {
+ animationFrameId = requestAnimationFrame(animate)
+ }
+ }
+
+ return () => {
+ if (animationFrameId !== null) {
+ cancelAnimationFrame(animationFrameId)
+ }
+ if (resizeObserver) {
+ resizeObserver.disconnect()
+ }
+ if (intersectionObserver) {
+ intersectionObserver.disconnect()
+ }
+ }
+ }, [setupCanvas, updateSquares, drawGrid, width, height, isInView])
+
+ return (
+
+
+
+ )
+}
diff --git a/apps/web/src/components/magicui/light-rays.tsx b/apps/web/src/components/magicui/light-rays.tsx
new file mode 100644
index 000000000..a70c4cd10
--- /dev/null
+++ b/apps/web/src/components/magicui/light-rays.tsx
@@ -0,0 +1,149 @@
+"use client"
+
+import { useEffect, useState, type CSSProperties } from "react"
+import * as m from "motion/react-m"
+
+import { cn } from "@/lib/utils"
+
+interface LightRaysProps extends React.HTMLAttributes {
+ ref?: React.Ref
+ count?: number
+ color?: string
+ blur?: number
+ speed?: number
+ length?: string
+}
+
+type LightRay = {
+ id: string
+ left: number
+ rotate: number
+ width: number
+ swing: number
+ delay: number
+ duration: number
+ intensity: number
+}
+
+const createRays = (count: number, cycle: number): LightRay[] => {
+ if (count <= 0) return []
+
+ return Array.from({ length: count }, (_, index) => {
+ const left = 8 + Math.random() * 84
+ const rotate = -28 + Math.random() * 56
+ const width = 160 + Math.random() * 160
+ const swing = 0.8 + Math.random() * 1.8
+ const delay = Math.random() * cycle
+ const duration = cycle * (0.75 + Math.random() * 0.5)
+ const intensity = 0.6 + Math.random() * 0.5
+
+ return {
+ id: `${index}-${Math.round(left * 10)}`,
+ left,
+ rotate,
+ width,
+ swing,
+ delay,
+ duration,
+ intensity,
+ }
+ })
+}
+
+const Ray = ({
+ left,
+ rotate,
+ width,
+ swing,
+ delay,
+ duration,
+ intensity,
+}: LightRay) => {
+ return (
+
+ )
+}
+
+export function LightRays({
+ className,
+ style,
+ count = 7,
+ color = "rgba(160, 210, 255, 0.2)",
+ blur = 36,
+ speed = 14,
+ length = "70vh",
+ ref,
+ ...props
+}: LightRaysProps) {
+ const [rays, setRays] = useState([])
+ const cycleDuration = Math.max(speed, 0.1)
+
+ useEffect(() => {
+ setRays(createRays(count, cycleDuration))
+ }, [count, cycleDuration])
+
+ return (
+
+
+
+
+ {rays.map((ray) => (
+
+ ))}
+
+
+ )
+}
diff --git a/apps/web/src/components/magicui/orbiting-circles.tsx b/apps/web/src/components/magicui/orbiting-circles.tsx
new file mode 100644
index 000000000..6a03907e5
--- /dev/null
+++ b/apps/web/src/components/magicui/orbiting-circles.tsx
@@ -0,0 +1,76 @@
+import React from "react"
+
+import { cn } from "@/lib/utils"
+
+export interface OrbitingCirclesProps extends React.HTMLAttributes {
+ className?: string
+ children?: React.ReactNode
+ reverse?: boolean
+ duration?: number
+ delay?: number
+ radius?: number
+ path?: boolean
+ iconSize?: number
+ speed?: number
+}
+
+export function OrbitingCircles({
+ className,
+ children,
+ reverse,
+ duration = 20,
+ radius = 160,
+ path = true,
+ iconSize = 30,
+ speed = 1,
+ delay: _delay,
+ ...props
+}: OrbitingCirclesProps) {
+ const calculatedDuration = duration / speed
+ // Local change: start each orbit at the wall-clock phase of its cycle, so a
+ // remounted orbit continues where the previous one was instead of jumping.
+ const phase = (Date.now() / 1000) % calculatedDuration
+ return (
+ <>
+ {path && (
+
+ )}
+ {React.Children.map(children, (child, index) => {
+ const angle = (360 / React.Children.count(children)) * index
+ return (
+
+ {child}
+
+ )
+ })}
+ >
+ )
+}
diff --git a/apps/web/src/components/magicui/terminal.tsx b/apps/web/src/components/magicui/terminal.tsx
new file mode 100644
index 000000000..df2f3f20d
--- /dev/null
+++ b/apps/web/src/components/magicui/terminal.tsx
@@ -0,0 +1,300 @@
+"use client"
+
+import {
+ Children,
+ createContext,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ComponentType,
+ type RefAttributes,
+} from "react"
+import * as m from "motion/react-m"
+import {
+ useInView,
+ type DOMMotionComponents,
+ type HTMLMotionProps,
+ type MotionProps,
+} from "motion/react"
+
+import { cn } from "@/lib/utils"
+
+interface SequenceContextValue {
+ completeItem: (index: number) => void
+ activeIndex: number
+ sequenceStarted: boolean
+}
+
+const SequenceContext = createContext(null)
+
+const useSequence = () => useContext(SequenceContext)
+
+const ItemIndexContext = createContext(null)
+const useItemIndex = () => useContext(ItemIndexContext)
+
+const motionElements = {
+ article: m.article,
+ div: m.div,
+ h1: m.h1,
+ h2: m.h2,
+ h3: m.h3,
+ h4: m.h4,
+ h5: m.h5,
+ h6: m.h6,
+ li: m.li,
+ p: m.p,
+ section: m.section,
+ span: m.span,
+} as const
+
+type MotionElementType = Extract<
+ keyof DOMMotionComponents,
+ keyof typeof motionElements
+>
+type TerminalTypingMotionComponent = ComponentType<
+ Omit, "ref"> & RefAttributes
+>
+
+interface AnimatedSpanProps extends MotionProps {
+ children: React.ReactNode
+ delay?: number
+ className?: string
+ startOnView?: boolean
+}
+
+export const AnimatedSpan = ({
+ children,
+ delay = 0,
+ className,
+ startOnView = false,
+ ...props
+}: AnimatedSpanProps) => {
+ const elementRef = useRef(null)
+ const isInView = useInView(elementRef as React.RefObject, {
+ amount: 0.3,
+ once: true,
+ })
+
+ const sequence = useSequence()
+ const itemIndex = useItemIndex()
+ const [hasStarted, setHasStarted] = useState(false)
+ useEffect(() => {
+ if (!sequence || itemIndex === null) return
+ if (!sequence.sequenceStarted) return
+ if (hasStarted) return
+ if (sequence.activeIndex === itemIndex) {
+ setHasStarted(true)
+ }
+ }, [sequence, hasStarted, itemIndex])
+
+ const shouldAnimate = sequence ? hasStarted : startOnView ? isInView : true
+
+ return (
+ {
+ if (!sequence) return
+ if (itemIndex === null) return
+ sequence.completeItem(itemIndex)
+ }}
+ {...props}
+ >
+ {children}
+
+ )
+}
+
+interface TypingAnimationProps extends Omit {
+ children: string
+ className?: string
+ duration?: number
+ delay?: number
+ as?: MotionElementType
+ startOnView?: boolean
+}
+
+export const TypingAnimation = ({
+ children,
+ className,
+ duration = 60,
+ delay = 0,
+ as: Component = "span",
+ startOnView = true,
+ ...props
+}: TypingAnimationProps) => {
+ if (typeof children !== "string") {
+ throw new Error("TypingAnimation: children must be a string. Received:")
+ }
+
+ const MotionComponent = motionElements[
+ Component
+ ] as TerminalTypingMotionComponent
+
+ const [displayedText, setDisplayedText] = useState("")
+ const [started, setStarted] = useState(false)
+ const elementRef = useRef(null)
+ const isInView = useInView(elementRef as React.RefObject, {
+ amount: 0.3,
+ once: true,
+ })
+
+ const sequence = useSequence()
+ const itemIndex = useItemIndex()
+ const hasSequence = sequence !== null
+ const sequenceStarted = sequence?.sequenceStarted ?? false
+ const sequenceActiveIndex = sequence?.activeIndex ?? null
+ const sequenceCompleteItemRef = useRef<
+ SequenceContextValue["completeItem"] | null
+ >(null)
+ const sequenceItemIndexRef = useRef(null)
+
+ useEffect(() => {
+ sequenceCompleteItemRef.current = sequence?.completeItem ?? null
+ sequenceItemIndexRef.current = itemIndex
+ }, [sequence?.completeItem, itemIndex])
+
+ useEffect(() => {
+ let startTimeout: ReturnType | null = null
+
+ if (hasSequence && itemIndex !== null) {
+ if (sequenceStarted && !started && sequenceActiveIndex === itemIndex) {
+ setStarted(true)
+ }
+ } else if (!startOnView || isInView) {
+ startTimeout = setTimeout(() => setStarted(true), delay)
+ }
+
+ return () => {
+ if (startTimeout !== null) {
+ clearTimeout(startTimeout)
+ }
+ }
+ }, [
+ delay,
+ startOnView,
+ isInView,
+ started,
+ hasSequence,
+ sequenceActiveIndex,
+ sequenceStarted,
+ itemIndex,
+ ])
+
+ useEffect(() => {
+ let typingEffect: ReturnType | null = null
+
+ if (started) {
+ let i = 0
+ typingEffect = setInterval(() => {
+ if (i < children.length) {
+ setDisplayedText(children.substring(0, i + 1))
+ i++
+ } else {
+ if (typingEffect !== null) {
+ clearInterval(typingEffect)
+ }
+ const completeItem = sequenceCompleteItemRef.current
+ const currentItemIndex = sequenceItemIndexRef.current
+ if (completeItem && currentItemIndex !== null) {
+ completeItem(currentItemIndex)
+ }
+ }
+ }, duration)
+ }
+
+ return () => {
+ if (typingEffect !== null) {
+ clearInterval(typingEffect)
+ }
+ }
+ }, [children, duration, started])
+
+ return (
+
+ {displayedText}
+
+ )
+}
+
+interface TerminalProps {
+ children: React.ReactNode
+ className?: string
+ sequence?: boolean
+ startOnView?: boolean
+}
+
+export const Terminal = ({
+ children,
+ className,
+ sequence = true,
+ startOnView = true,
+}: TerminalProps) => {
+ const containerRef = useRef(null)
+ const isInView = useInView(containerRef as React.RefObject, {
+ amount: 0.3,
+ once: true,
+ })
+
+ const [activeIndex, setActiveIndex] = useState(0)
+ const sequenceHasStarted = sequence ? !startOnView || isInView : false
+
+ const contextValue = useMemo(() => {
+ if (!sequence) return null
+ return {
+ completeItem: (index: number) => {
+ setActiveIndex((current) => (index === current ? current + 1 : current))
+ },
+ activeIndex,
+ sequenceStarted: sequenceHasStarted,
+ }
+ }, [sequence, activeIndex, sequenceHasStarted])
+
+ const wrappedChildren = useMemo(() => {
+ if (!sequence) return children
+ const array = Children.toArray(children)
+ return array.map((child, index) => (
+
+ {child as React.ReactNode}
+
+ ))
+ }, [children, sequence])
+
+ const content = (
+
+
+
+ {wrappedChildren}
+
+
+ )
+
+ if (!sequence) return content
+
+ return (
+
+ {content}
+
+ )
+}
diff --git a/apps/web/src/components/motion-features.ts b/apps/web/src/components/motion-features.ts
new file mode 100644
index 000000000..a216d00fb
--- /dev/null
+++ b/apps/web/src/components/motion-features.ts
@@ -0,0 +1,4 @@
+// Motion's DOM animation and shared-layout features, split from the main bundle.
+import { domMax } from "motion/react";
+
+export default domMax;
diff --git a/apps/web/src/components/motion.tsx b/apps/web/src/components/motion.tsx
new file mode 100644
index 000000000..e928b69a1
--- /dev/null
+++ b/apps/web/src/components/motion.tsx
@@ -0,0 +1,21 @@
+import { LazyMotion, MotionConfig } from "motion/react";
+import type { ReactNode } from "react";
+
+/** Motion's DOM features, including shared layout, loaded after first paint. */
+const features = () => import("./motion-features").then((module) => module.default);
+
+/**
+ * The console's motion defaults. Motion (motion.dev) carries the shared-layout
+ * moves — the active navigation chip and segmented thumbs — with one quick,
+ * unbouncy spring; everything else is CSS. Reduced motion follows the
+ * operating system: layout moves become instant, opacity changes remain.
+ */
+export const LAYOUT_SPRING = { type: "spring", duration: 0.32, bounce: 0 } as const;
+
+export function ConsoleMotion({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx
new file mode 100644
index 000000000..69d3e1b76
--- /dev/null
+++ b/apps/web/src/components/ui/popover.tsx
@@ -0,0 +1,118 @@
+"use client";
+
+import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
+import type React from "react";
+import { cn } from "@/lib/utils";
+
+export const PopoverCreateHandle: typeof PopoverPrimitive.createHandle =
+ PopoverPrimitive.createHandle;
+
+export const Popover: typeof PopoverPrimitive.Root = PopoverPrimitive.Root;
+
+export function PopoverTrigger({
+ className,
+ children,
+ ...props
+}: PopoverPrimitive.Trigger.Props): React.ReactElement {
+ return (
+
+ {children}
+
+ );
+}
+
+export function PopoverPopup({
+ children,
+ className,
+ side = "bottom",
+ align = "center",
+ sideOffset = 4,
+ alignOffset = 0,
+ tooltipStyle = false,
+ anchor,
+ portalProps,
+ ...props
+}: PopoverPrimitive.Popup.Props & {
+ portalProps?: PopoverPrimitive.Portal.Props;
+ side?: PopoverPrimitive.Positioner.Props["side"];
+ align?: PopoverPrimitive.Positioner.Props["align"];
+ sideOffset?: PopoverPrimitive.Positioner.Props["sideOffset"];
+ alignOffset?: PopoverPrimitive.Positioner.Props["alignOffset"];
+ tooltipStyle?: boolean;
+ anchor?: PopoverPrimitive.Positioner.Props["anchor"];
+}): React.ReactElement {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
+
+export function PopoverClose({
+ ...props
+}: PopoverPrimitive.Close.Props): React.ReactElement {
+ return ;
+}
+
+export function PopoverTitle({
+ className,
+ ...props
+}: PopoverPrimitive.Title.Props): React.ReactElement {
+ return (
+
+ );
+}
+
+export function PopoverDescription({
+ className,
+ ...props
+}: PopoverPrimitive.Description.Props): React.ReactElement {
+ return (
+
+ );
+}
+
+export { PopoverPrimitive, PopoverPopup as PopoverContent };
diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx
new file mode 100644
index 000000000..02280b4fc
--- /dev/null
+++ b/apps/web/src/components/ui/select.tsx
@@ -0,0 +1,262 @@
+"use client";
+
+// Installed from https://coss.com/ui/r/select.json (coss ui, MIT). Local edits:
+// highlighted items use the neutral `hover-2` fill instead of `accent` (the
+// console's brand colour), and the popup border names its colour explicitly
+// because the console runs Tailwind without preflight.
+
+import { mergeProps } from "@base-ui/react/merge-props";
+import { Select as SelectPrimitive } from "@base-ui/react/select";
+import { useRender } from "@base-ui/react/use-render";
+import { cva, type VariantProps } from "class-variance-authority";
+import {
+ ChevronDownIcon,
+ ChevronsUpDownIcon,
+ ChevronUpIcon,
+} from "lucide-react";
+import type * as React from "react";
+import { cn } from "@/lib/utils";
+
+export const Select: typeof SelectPrimitive.Root = SelectPrimitive.Root;
+
+export const selectTriggerVariants = cva(
+ "relative inline-flex min-h-9 w-full min-w-36 select-none items-center justify-between gap-2 rounded-lg border border-input bg-background not-dark:bg-clip-padding px-[calc(--spacing(3)-1px)] text-left text-base text-foreground shadow-xs/5 outline-none ring-ring/24 transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-data-disabled:not-focus-visible:not-aria-invalid:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 focus-visible:border-ring focus-visible:ring-[3px] aria-invalid:border-destructive/36 focus-visible:aria-invalid:border-destructive/64 focus-visible:aria-invalid:ring-destructive/16 data-disabled:pointer-events-none data-disabled:opacity-64 sm:min-h-8 sm:text-sm dark:bg-input/32 dark:aria-invalid:ring-destructive/24 dark:not-data-disabled:not-focus-visible:not-aria-invalid:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0 [[data-disabled],:focus-visible,[aria-invalid],[data-pressed]]:shadow-none",
+ {
+ defaultVariants: {
+ size: "default",
+ },
+ variants: {
+ size: {
+ default: "",
+ lg: "min-h-10 sm:min-h-9",
+ sm: "min-h-8 gap-1.5 px-[calc(--spacing(2.5)-1px)] sm:min-h-7",
+ },
+ },
+ },
+);
+
+export const selectTriggerIconClassName = "-me-1 size-4.5 opacity-80 sm:size-4";
+
+export interface SelectButtonProps extends useRender.ComponentProps<"button"> {
+ size?: VariantProps["size"];
+}
+
+export function SelectButton({
+ className,
+ size,
+ render,
+ children,
+ ...props
+}: SelectButtonProps): React.ReactElement {
+ const typeValue: React.ButtonHTMLAttributes["type"] =
+ render ? undefined : "button";
+
+ const defaultProps = {
+ children: (
+ <>
+
+ {children}
+
+
+ >
+ ),
+ className: cn(selectTriggerVariants({ size }), "min-w-0", className),
+ "data-slot": "select-button",
+ type: typeValue,
+ };
+
+ return useRender({
+ defaultTagName: "button",
+ props: mergeProps<"button">(defaultProps, props),
+ render,
+ });
+}
+
+export function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: SelectPrimitive.Trigger.Props &
+ VariantProps): React.ReactElement {
+ return (
+
+ {children}
+
+
+
+
+ );
+}
+
+export function SelectValue({
+ className,
+ ...props
+}: SelectPrimitive.Value.Props): React.ReactElement {
+ return (
+
+ );
+}
+
+export function SelectPopup({
+ className,
+ children,
+ side = "bottom",
+ sideOffset = 4,
+ align = "start",
+ alignOffset = 0,
+ alignItemWithTrigger = true,
+ anchor,
+ portalProps,
+ ...props
+}: SelectPrimitive.Popup.Props & {
+ portalProps?: SelectPrimitive.Portal.Props;
+ side?: SelectPrimitive.Positioner.Props["side"];
+ sideOffset?: SelectPrimitive.Positioner.Props["sideOffset"];
+ align?: SelectPrimitive.Positioner.Props["align"];
+ alignOffset?: SelectPrimitive.Positioner.Props["alignOffset"];
+ alignItemWithTrigger?: SelectPrimitive.Positioner.Props["alignItemWithTrigger"];
+ anchor?: SelectPrimitive.Positioner.Props["anchor"];
+}): React.ReactElement {
+ return (
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+
+
+ );
+}
+
+export function SelectItem({
+ className,
+ children,
+ ...props
+}: SelectPrimitive.Item.Props): React.ReactElement {
+ return (
+
+
+
+
+
+ {children}
+
+
+ );
+}
+
+export function SelectSeparator({
+ className,
+ ...props
+}: SelectPrimitive.Separator.Props): React.ReactElement {
+ return (
+
+ );
+}
+
+export function SelectGroup(
+ props: SelectPrimitive.Group.Props,
+): React.ReactElement {
+ return ;
+}
+
+export function SelectLabel({
+ className,
+ ...props
+}: SelectPrimitive.Label.Props): React.ReactElement {
+ return (
+
+ );
+}
+
+export function SelectGroupLabel(
+ props: SelectPrimitive.GroupLabel.Props,
+): React.ReactElement {
+ return (
+
+ );
+}
+
+export { SelectPrimitive, SelectPopup as SelectContent };
diff --git a/apps/web/src/features/CoreCollectionStates.test.tsx b/apps/web/src/features/CoreCollectionStates.test.tsx
deleted file mode 100644
index 1c1997c33..000000000
--- a/apps/web/src/features/CoreCollectionStates.test.tsx
+++ /dev/null
@@ -1,490 +0,0 @@
-import { renderToStaticMarkup } from "react-dom/server";
-import { describe, expect, it } from "vitest";
-
-import type { AgentSession, SavedAgent, SessionItem } from "@agents-core-web/agents-client";
-
-import { AgentsView } from "./agents/AgentsView";
-import { restoreDraftAfterFailedSend, SessionsView } from "./sessions/SessionsView";
-
-const agentsCallbacks = {
- onCreate: async () => undefined,
- onRefresh: () => undefined,
- onStartSession: async () => undefined,
-};
-
-const sessionsCallbacks = {
- onCancel: async () => undefined,
- onCreateSession: async () => undefined,
- onDeleteSession: async () => true,
- onCancelAndDeleteSession: async () => true,
- onFunctionResult: async () => undefined,
- onRefresh: () => undefined,
- onRetrySession: () => undefined,
- onRetryStream: () => undefined,
- onRetrieveSession: async () => undefined,
- onSelect: () => undefined,
- onSend: async () => undefined,
- onUpdateSession: async () => undefined,
-};
-
-const selectedSession: AgentSession = {
- id: "session_1",
- object: "agent.session",
- agent: {
- id: "agent_1",
- model: "gpt-5",
- name: "Researcher",
- instructions: null,
- multi_agent: { enabled: false, max_concurrent_subagents: null },
- reasoning: {},
- service_tier: "auto",
- text: { format: { type: "text" }, verbosity: "medium" },
- tools: [],
- },
- environment: { type: "none" },
- status: "idle",
- error: null,
- metadata: {},
- required_actions: [],
- vault_ids: [],
- usage: null,
- created_at: 1,
- last_active_at: 1,
-};
-
-const durableItems: SessionItem[] = [{
- id: "item_1",
- turn_id: "turn_1",
- type: "message",
- status: "completed",
- role: "user",
- content: [{ type: "input_text", text: "Existing durable item" }],
-}];
-
-const savedAgent: SavedAgent = {
- ...selectedSession.agent,
- object: "agent",
- metadata: {},
- created_at: 1,
- updated_at: 1,
-};
-
-describe("Agent Core collection states", () => {
- it("restores a failed message only when it cannot overwrite a newer draft", () => {
- expect(restoreDraftAfterFailedSend("", "failed message")).toBe("failed message");
- expect(restoreDraftAfterFailedSend("new draft", "failed message")).toBe("new draft");
- });
-
- it("keeps an Agents load failure distinct from a ready empty collection", () => {
- const failed = renderToStaticMarkup(
- ,
- );
- const empty = renderToStaticMarkup(
- ,
- );
-
- expect(failed).toContain("Couldn’t load Agents");
- expect(failed).toContain("connection refused");
- expect(failed).not.toContain("No saved Agents");
- expect(empty).toContain("No saved Agents");
- });
-
- it("keeps a stale Agent ledger visible when its collection refresh fails", () => {
- const failed = renderToStaticMarkup(
- ,
- );
-
- expect(failed).toContain("Couldn’t refresh Agents");
- expect(failed).toContain("agents refresh failed");
- expect(failed).toContain('aria-label="Agents"');
- expect(failed).toContain("Researcher");
- });
-
- it("shows composer activity only for a Core-reported in-progress Session", () => {
- const renderStatus = (status: AgentSession["status"], busy = false) => renderToStaticMarkup(
- ,
- );
-
- const active = renderStatus("in_progress");
- expect(active).toContain('class="conversation-activity"');
- expect(active).toContain('role="status" aria-live="polite" aria-atomic="true"');
- expect(active).toContain("Researcher is working…");
- expect(renderStatus("idle", true)).not.toContain("conversation-activity");
- expect(renderStatus("requires_action")).not.toContain("conversation-activity");
- expect(renderStatus("failed")).not.toContain("conversation-activity");
- });
-
- it("keeps Sessions loading and failure distinct from ready empty copy", () => {
- const common = {
- agents: [],
- sessions: [],
- selected: null,
- items: [],
- busy: false,
- detailError: null,
- detailState: "idle" as const,
- streamError: null,
- streamState: "idle" as const,
- ...sessionsCallbacks,
- };
- const loading = renderToStaticMarkup(
- ,
- );
- const failed = renderToStaticMarkup(
- ,
- );
- const empty = renderToStaticMarkup(
- ,
- );
-
- expect(loading).toContain("Loading Sessions");
- expect(loading).not.toContain("No Sessions yet");
- expect(failed).toContain("Couldn’t load Sessions");
- expect(failed).not.toContain("Select or create a Session");
- expect(empty).toContain("No Sessions yet");
- expect(empty).toContain("Select or create a Session");
- });
-
- it("keeps stale Sessions and a healthy selected workspace usable after collection refresh fails", () => {
- const failed = renderToStaticMarkup(
- ,
- );
- const composer = failed.match(/