feat(web): add live dream progress panel with adaptive polling

Watching dreams was a 5-15min black box. Now a dedicated
/workspaces/:id/queue route polls /v3/workspaces/:id/queue/status
every 2.5s while work is in-flight (10s when idle) and surfaces a
warning when in-progress work hasn't advanced the completed count
for >30 minutes.

The Honcho API only exposes aggregate counts (no observer/observed
pair, specialist phase, or token telemetry per work-unit), so the
panel links out to an upstream issue for those.

- queries: adaptive refetchInterval via TanStack Query callback form
- hooks/useStaleQueueDetection: client-side stall detection
- routes/workspaces_.\$workspaceId_.queue: dedicated live view
- routes/_dev.dream-progress: DEV-only showcase for screenshots
- WorkspaceDetail: new "Queue & dreams" nav card
This commit is contained in:
Agents
2026-05-24 18:21:25 +01:00
committed by Offending Commit
parent 6960bf4ffe
commit 17f8a5a7bf
7 changed files with 589 additions and 3 deletions

View File

@@ -85,6 +85,22 @@ export function useScheduleDream(workspaceId: string) {
});
}
import type { components } from "./schema.d.ts";
type QueueStatusBody = components["schemas"]["QueueStatus"];
// Poll faster while work is in flight so users can watch dreams/representations
// progress; back off to a slow heartbeat when idle. The TanStack Query callback
// form re-reads the cached value each cycle, so the interval adapts on its own.
export const QUEUE_REFETCH_ACTIVE_MS = 2500;
export const QUEUE_REFETCH_IDLE_MS = 10_000;
export function pickQueueRefetchInterval(data: QueueStatusBody | undefined): number {
if (!data) return QUEUE_REFETCH_IDLE_MS;
const active = (data.in_progress_work_units ?? 0) + (data.pending_work_units ?? 0);
return active > 0 ? QUEUE_REFETCH_ACTIVE_MS : QUEUE_REFETCH_IDLE_MS;
}
export function useQueueStatus(workspaceId: string) {
return useQuery({
queryKey: QK.queueStatus(workspaceId),
@@ -96,7 +112,7 @@ export function useQueueStatus(workspaceId: string) {
return data ?? err(error);
},
enabled: Boolean(workspaceId),
refetchInterval: 10_000,
refetchInterval: (query) => pickQueueRefetchInterval(query.state.data),
});
}

View File

@@ -209,7 +209,7 @@ export function Dashboard() {
<Activity className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
<SectionHeading className="mb-0">Queue Status</SectionHeading>
<span className="text-xs ml-1" style={{ color: "var(--text-4)" }}>
all workspaces · updates every 10s
all workspaces · live polling
</span>
</div>

View File

@@ -0,0 +1,329 @@
import { Link } from "@tanstack/react-router";
import { motion } from "framer-motion";
import { Activity, AlertTriangle, CircleDot, Info, Sparkles, TimerReset } from "lucide-react";
import { QUEUE_REFETCH_ACTIVE_MS, QUEUE_REFETCH_IDLE_MS } from "@/api/queries";
import type { components } from "@/api/schema.d.ts";
import { Skeleton } from "@/components/shared/Skeleton";
import { Body, Caption, Muted, SectionHeading } from "@/components/ui/typography";
import { useDemo } from "@/hooks/useDemo";
import { type StaleQueueState, useStaleQueueDetection } from "@/hooks/useStaleQueueDetection";
import { COLOR } from "@/lib/constants";
import { formatCount } from "@/lib/utils";
type QueueStatus = components["schemas"]["QueueStatus"];
export interface DreamProgressPanelProps {
workspaceId: string;
data: QueueStatus | undefined;
isLoading: boolean;
error: Error | null;
/** Override the stale-detection hook output (used by the dev showcase). */
staleOverride?: StaleQueueState;
}
function formatElapsed(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
const s = totalSeconds % 60;
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
export function DreamProgressPanel({
workspaceId,
data,
isLoading,
error,
staleOverride,
}: DreamProgressPanelProps) {
const { mask } = useDemo();
const detected = useStaleQueueDetection(data);
const stale = staleOverride ?? detected;
const inProgress = data?.in_progress_work_units ?? 0;
const pending = data?.pending_work_units ?? 0;
const completed = data?.completed_work_units ?? 0;
const total = data?.total_work_units ?? 0;
const active = inProgress + pending;
const isActive = active > 0;
const pollSeconds = isActive
? Math.round(QUEUE_REFETCH_ACTIVE_MS / 100) / 10
: Math.round(QUEUE_REFETCH_IDLE_MS / 100) / 10;
if (error) {
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="rounded-xl p-5 theme-card"
style={{
background: COLOR.destructiveDim,
border: `1px solid ${COLOR.destructiveBorder}`,
}}
>
<SectionHeading style={{ color: COLOR.destructive }} className="mb-1">
Could not load queue status
</SectionHeading>
<Caption as="p">{error.message}</Caption>
</motion.div>
);
}
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="rounded-xl theme-card overflow-hidden"
data-testid="dream-progress-panel"
>
{/* Header */}
<div
className="flex items-center justify-between px-5 py-4"
style={{ borderBottom: "1px solid var(--border)" }}
>
<div className="flex items-center gap-2 min-w-0">
<Sparkles
className="w-4 h-4 flex-shrink-0"
style={{ color: "var(--accent)" }}
strokeWidth={1.5}
/>
<SectionHeading className="mb-0">Dreams in progress</SectionHeading>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{isActive ? (
<motion.div
animate={{ opacity: [0.5, 1, 0.5] }}
transition={{ duration: 1.5, repeat: Number.POSITIVE_INFINITY }}
>
<CircleDot className="w-3 h-3" style={{ color: COLOR.warning }} strokeWidth={2} />
</motion.div>
) : (
<CircleDot className="w-3 h-3" style={{ color: COLOR.success }} strokeWidth={2} />
)}
<span
className="text-xs font-medium"
style={{ color: isActive ? COLOR.warning : COLOR.success }}
data-testid="dream-progress-status"
>
{isActive ? `${formatCount(active)} active` : "Idle"}
</span>
<span className="mx-1 text-xs" style={{ color: "var(--text-4)" }}>
·
</span>
<span className="text-xs font-mono" style={{ color: "var(--text-4)" }}>
polling every {pollSeconds}s
</span>
</div>
</div>
{/* Stale warning */}
{stale.isStale && stale.stalledSince !== null && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
className="overflow-hidden"
data-testid="stale-dream-warning"
>
<div
className="flex items-start gap-2 px-5 py-3"
style={{
background: COLOR.warningDim,
borderBottom: `1px solid ${COLOR.warningBorder}`,
}}
>
<AlertTriangle
className="w-4 h-4 flex-shrink-0 mt-0.5"
style={{ color: COLOR.warning }}
strokeWidth={2}
/>
<div className="min-w-0">
<Body className="font-medium" style={{ color: COLOR.warning }}>
Stalled for {formatElapsed(stale.elapsedMs)} without forward progress
</Body>
<Caption as="p" className="mt-0.5">
Work has been in-flight since{" "}
{new Date(stale.stalledSince).toLocaleTimeString()} with no advance in the
completed count. A specialist may be hung check Honcho logs.
</Caption>
</div>
</div>
</motion.div>
)}
{/* Counts */}
<div className="px-5 py-5">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{(
[
{ label: "Total", value: total, color: "var(--text-1)" },
{ label: "Done", value: completed, color: COLOR.success },
{ label: "In progress", value: inProgress, color: COLOR.warning },
{ label: "Pending", value: pending, color: "var(--text-3)" },
] as const
).map(({ label, value, color }) => (
<div key={label}>
{isLoading ? (
<Skeleton accent className="h-8 w-16 rounded" />
) : (
<div
className="text-2xl font-semibold font-mono"
style={{ color }}
data-testid={`count-${label.toLowerCase().replace(/\s+/g, "-")}`}
>
{formatCount(value)}
</div>
)}
<Caption as="div" className="mt-0.5">
{label}
</Caption>
</div>
))}
</div>
{/* API limitation note */}
<div
className="flex items-start gap-2 mt-5 rounded-lg px-3 py-2"
style={{
background: COLOR.accentSubtle,
border: `1px solid ${COLOR.accentBorder}`,
}}
>
<Info
className="w-3.5 h-3.5 flex-shrink-0 mt-0.5"
style={{ color: COLOR.accentText }}
strokeWidth={2}
/>
<Caption as="p">
Honcho's <code className="font-mono">/queue/status</code> exposes aggregate counts
only. Per-dream observer/observed pair, specialist phase (deduction vs. induction),
and token telemetry are tracked upstream {" "}
<a
href="https://github.com/plastic-labs/honcho/issues/new?title=Surface+per-work-unit+detail+in+queue/status&labels=enhancement"
target="_blank"
rel="noreferrer"
style={{ color: COLOR.accentText }}
className="underline"
>
file an upstream issue
</a>{" "}
to surface them here.
</Caption>
</div>
</div>
<SessionsTable workspaceId={workspaceId} sessions={data?.sessions} mask={mask} />
</motion.div>
);
}
function SessionsTable({
workspaceId,
sessions,
mask,
}: {
workspaceId: string;
sessions: QueueStatus["sessions"];
mask: (s: string) => string;
}) {
const entries = sessions ? Object.entries(sessions) : [];
if (entries.length === 0) {
return (
<div className="px-5 pb-5">
<div
className="flex items-center gap-2 rounded-lg px-3 py-3"
style={{ background: "var(--bg-3)", border: "1px solid var(--border)" }}
>
<Activity className="w-3.5 h-3.5" style={{ color: "var(--text-4)" }} strokeWidth={1.5} />
<Muted className="text-xs">No session-scoped work tracked right now.</Muted>
</div>
</div>
);
}
return (
<div className="px-5 pb-5">
<div className="flex items-center gap-1.5 mb-2">
<TimerReset
className="w-3.5 h-3.5"
style={{ color: "var(--text-3)" }}
strokeWidth={1.5}
/>
<Caption>
{entries.length} session{entries.length !== 1 ? "s" : ""} with active work
</Caption>
</div>
<div
className="rounded-lg overflow-hidden"
style={{ border: "1px solid var(--border)" }}
data-testid="sessions-table"
>
<table className="w-full text-xs">
<thead>
<tr
style={{
background: "var(--bg-3)",
borderBottom: "1px solid var(--border)",
}}
>
{["Session", "Total", "Done", "In progress", "Pending"].map((h) => (
<th
key={h}
className={`py-2 px-3 font-medium text-left ${h !== "Session" ? "text-right" : ""}`}
style={{ color: "var(--text-3)" }}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{entries.map(([sid, s], i) => (
<tr
key={sid}
style={{ borderTop: i > 0 ? "1px solid var(--border)" : undefined }}
>
<td className="py-1.5 px-3">
<Link
to={"/workspaces/$workspaceId/sessions/$sessionId" as never}
params={{ workspaceId, sessionId: sid } as never}
className="font-mono truncate block max-w-[220px] hover:underline"
style={{ color: "var(--accent-text)" }}
>
{mask(sid)}
</Link>
</td>
<td
className="py-1.5 px-3 text-right font-mono"
style={{ color: "var(--text-2)" }}
>
{s.total_work_units}
</td>
<td
className="py-1.5 px-3 text-right font-mono"
style={{ color: COLOR.success }}
>
{s.completed_work_units}
</td>
<td
className="py-1.5 px-3 text-right font-mono"
style={{ color: COLOR.warning }}
>
{s.in_progress_work_units}
</td>
<td
className="py-1.5 px-3 text-right font-mono"
style={{ color: "var(--text-3)" }}
>
{s.pending_work_units}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { AnimatePresence, motion } from "framer-motion";
import {
Activity,
Boxes,
ChevronDown,
CircleDot,
@@ -50,6 +51,12 @@ const NAV_SECTIONS = [
to: "webhooks" as const,
description: "Manage event webhooks",
},
{
label: "Queue & dreams",
icon: Activity,
to: "queue" as const,
description: "Live view of in-flight work",
},
] as const;
export function WorkspaceDetail() {
@@ -107,7 +114,7 @@ export function WorkspaceDetail() {
{!isLoading && workspace && (
<div className="space-y-4">
{/* Nav cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-3">
{NAV_SECTIONS.map((s, i) => {
const Icon = s.icon;
return (

View File

@@ -0,0 +1,61 @@
import { useEffect, useState } from "react";
import type { components } from "@/api/schema.d.ts";
type QueueStatus = components["schemas"]["QueueStatus"];
export const STALE_QUEUE_THRESHOLD_MS = 30 * 60 * 1000;
export interface StaleQueueState {
stalledSince: number | null;
elapsedMs: number;
isStale: boolean;
}
/**
* Detects stalled queue work without per-work-unit timestamps from the API.
*
* Anchors when in_progress + pending first goes non-zero and resets the anchor
* whenever completed_work_units advances (forward progress). If the anchor
* lives longer than the threshold, the queue is considered stale.
*
* Note: completed_work_units resets on Honcho's periodic queue cleanup; a drop
* is treated as "no forward progress" rather than regression, so the stall
* clock keeps running until either work finishes or completed advances.
*/
export function useStaleQueueDetection(
data: QueueStatus | undefined,
staleThresholdMs: number = STALE_QUEUE_THRESHOLD_MS,
): StaleQueueState {
const [stalledSince, setStalledSince] = useState<number | null>(null);
const [lastCompleted, setLastCompleted] = useState(0);
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 10_000);
return () => clearInterval(id);
}, []);
useEffect(() => {
if (!data) return;
const active = (data.in_progress_work_units ?? 0) + (data.pending_work_units ?? 0);
const completed = data.completed_work_units ?? 0;
if (active === 0) {
setStalledSince(null);
setLastCompleted(completed);
return;
}
if (completed > lastCompleted) {
setStalledSince(Date.now());
setLastCompleted(completed);
return;
}
if (stalledSince === null) {
setStalledSince(Date.now());
setLastCompleted(completed);
}
}, [data, lastCompleted, stalledSince]);
const elapsedMs = stalledSince !== null ? Math.max(0, now - stalledSince) : 0;
const isStale = stalledSince !== null && elapsedMs > staleThresholdMs;
return { stalledSince, elapsedMs, isStale };
}

View File

@@ -0,0 +1,129 @@
import { createFileRoute, Navigate } from "@tanstack/react-router";
import { motion } from "framer-motion";
import { Body, PageTitle } from "@/components/ui/typography";
import { DreamProgressPanel } from "@/components/workspaces/DreamProgressPanel";
import type { StaleQueueState } from "@/hooks/useStaleQueueDetection";
export const Route = createFileRoute("/_dev/dream-progress")({
component: DreamProgressShowcase,
});
const WORKSPACE_ID = "ws_benchmark_alpha";
const IDLE = {
total_work_units: 142,
completed_work_units: 142,
in_progress_work_units: 0,
pending_work_units: 0,
sessions: null,
};
const ACTIVE = {
total_work_units: 64,
completed_work_units: 38,
in_progress_work_units: 4,
pending_work_units: 22,
sessions: {
sess_2024_q4_eval_run_07: {
total_work_units: 28,
completed_work_units: 17,
in_progress_work_units: 2,
pending_work_units: 9,
},
sess_2024_q4_eval_run_08: {
total_work_units: 24,
completed_work_units: 14,
in_progress_work_units: 1,
pending_work_units: 9,
},
sess_diagnostics_cold_start: {
total_work_units: 12,
completed_work_units: 7,
in_progress_work_units: 1,
pending_work_units: 4,
},
},
};
const STALE = {
total_work_units: 18,
completed_work_units: 11,
in_progress_work_units: 1,
pending_work_units: 6,
sessions: {
sess_induction_specialist_test: {
total_work_units: 18,
completed_work_units: 11,
in_progress_work_units: 1,
pending_work_units: 6,
},
},
};
const STALE_OVERRIDE: StaleQueueState = {
stalledSince: Date.now() - 35 * 60 * 1000,
elapsedMs: 35 * 60 * 1000,
isStale: true,
};
function DreamProgressShowcase() {
if (!import.meta.env.DEV) {
return <Navigate to="/" />;
}
return (
<div className="page-container page-container--wide space-y-8">
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
<PageTitle>Dream Progress showcase</PageTitle>
<Body className="leading-none">
Three states rendered with mock data. DEV-only; used for documentation screenshots.
</Body>
</motion.div>
<section>
<h3
className="text-xs font-mono uppercase tracking-wider mb-3"
style={{ color: "var(--text-3)" }}
>
Idle
</h3>
<DreamProgressPanel
workspaceId={WORKSPACE_ID}
data={IDLE}
isLoading={false}
error={null}
/>
</section>
<section>
<h3
className="text-xs font-mono uppercase tracking-wider mb-3"
style={{ color: "var(--text-3)" }}
>
Active
</h3>
<DreamProgressPanel
workspaceId={WORKSPACE_ID}
data={ACTIVE}
isLoading={false}
error={null}
/>
</section>
<section>
<h3
className="text-xs font-mono uppercase tracking-wider mb-3"
style={{ color: "var(--text-3)" }}
>
Stalled (&gt;30m)
</h3>
<DreamProgressPanel
workspaceId={WORKSPACE_ID}
data={STALE}
isLoading={false}
error={null}
staleOverride={STALE_OVERRIDE}
/>
</section>
</div>
);
}

View File

@@ -0,0 +1,44 @@
import { createFileRoute, useParams } from "@tanstack/react-router";
import { motion } from "framer-motion";
import { Activity } from "lucide-react";
import { useQueueStatus } from "@/api/queries";
import { Breadcrumb } from "@/components/layout/Breadcrumb";
import { Body, PageTitle } from "@/components/ui/typography";
import { DreamProgressPanel } from "@/components/workspaces/DreamProgressPanel";
export const Route = createFileRoute("/workspaces_/$workspaceId_/queue")({
component: WorkspaceQueuePage,
});
function WorkspaceQueuePage() {
const { workspaceId } = useParams({ strict: false }) as { workspaceId: string };
const { data, isLoading, error } = useQueueStatus(workspaceId);
return (
<div className="page-container page-container--wide">
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
<Breadcrumb />
<div className="flex items-center gap-2 mb-1">
<Activity
className="w-5 h-5 flex-shrink-0"
style={{ color: "var(--accent)" }}
strokeWidth={1.5}
/>
<PageTitle>Queue &amp; dreams</PageTitle>
</div>
<Body className="leading-none">
Live view of in-flight dream, representation, and summary work
</Body>
</motion.div>
<div className="mt-8">
<DreamProgressPanel
workspaceId={workspaceId}
data={data}
isLoading={isLoading}
error={error instanceof Error ? error : null}
/>
</div>
</div>
);
}