diff --git a/packages/web/src/api/queries.ts b/packages/web/src/api/queries.ts
index d46185b..975e341 100644
--- a/packages/web/src/api/queries.ts
+++ b/packages/web/src/api/queries.ts
@@ -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),
});
}
diff --git a/packages/web/src/components/dashboard/Dashboard.tsx b/packages/web/src/components/dashboard/Dashboard.tsx
index 6caac67..dfafe7d 100644
--- a/packages/web/src/components/dashboard/Dashboard.tsx
+++ b/packages/web/src/components/dashboard/Dashboard.tsx
@@ -209,7 +209,7 @@ export function Dashboard() {
Queue Status
- all workspaces · updates every 10s
+ all workspaces · live polling
diff --git a/packages/web/src/components/workspaces/DreamProgressPanel.tsx b/packages/web/src/components/workspaces/DreamProgressPanel.tsx
new file mode 100644
index 0000000..36712f4
--- /dev/null
+++ b/packages/web/src/components/workspaces/DreamProgressPanel.tsx
@@ -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 (
+
+
+ Could not load queue status
+
+ {error.message}
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+ Dreams in progress
+
+
+ {isActive ? (
+
+
+
+ ) : (
+
+ )}
+
+ {isActive ? `${formatCount(active)} active` : "Idle"}
+
+
+ ·
+
+
+ polling every {pollSeconds}s
+
+
+
+
+ {/* Stale warning */}
+ {stale.isStale && stale.stalledSince !== null && (
+
+
+
+
+
+ Stalled for {formatElapsed(stale.elapsedMs)} without forward progress
+
+
+ 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.
+
+
+
+
+ )}
+
+ {/* Counts */}
+
+
+ {(
+ [
+ { 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 }) => (
+
+ {isLoading ? (
+
+ ) : (
+
+ {formatCount(value)}
+
+ )}
+
+ {label}
+
+
+ ))}
+
+
+ {/* API limitation note */}
+
+
+
+ Honcho's /queue/status exposes aggregate counts
+ only. Per-dream observer/observed pair, specialist phase (deduction vs. induction),
+ and token telemetry are tracked upstream —{" "}
+
+ file an upstream issue
+ {" "}
+ to surface them here.
+
+
+
+
+
+
+ );
+}
+
+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 (
+
+
+
+
No session-scoped work tracked right now.
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {entries.length} session{entries.length !== 1 ? "s" : ""} with active work
+
+
+
+
+
+
+ {["Session", "Total", "Done", "In progress", "Pending"].map((h) => (
+
+ {h}
+
+ ))}
+
+
+
+ {entries.map(([sid, s], i) => (
+ 0 ? "1px solid var(--border)" : undefined }}
+ >
+
+
+ {mask(sid)}
+
+
+
+ {s.total_work_units}
+
+
+ {s.completed_work_units}
+
+
+ {s.in_progress_work_units}
+
+
+ {s.pending_work_units}
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/packages/web/src/components/workspaces/WorkspaceDetail.tsx b/packages/web/src/components/workspaces/WorkspaceDetail.tsx
index a08d0f3..fc50112 100644
--- a/packages/web/src/components/workspaces/WorkspaceDetail.tsx
+++ b/packages/web/src/components/workspaces/WorkspaceDetail.tsx
@@ -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 && (
{/* Nav cards */}
-
+
{NAV_SECTIONS.map((s, i) => {
const Icon = s.icon;
return (
diff --git a/packages/web/src/hooks/useStaleQueueDetection.ts b/packages/web/src/hooks/useStaleQueueDetection.ts
new file mode 100644
index 0000000..67ed31a
--- /dev/null
+++ b/packages/web/src/hooks/useStaleQueueDetection.ts
@@ -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
(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 };
+}
diff --git a/packages/web/src/routes/_dev.dream-progress.tsx b/packages/web/src/routes/_dev.dream-progress.tsx
new file mode 100644
index 0000000..4a8c0e5
--- /dev/null
+++ b/packages/web/src/routes/_dev.dream-progress.tsx
@@ -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 ;
+ }
+ return (
+
+
+ Dream Progress — showcase
+
+ Three states rendered with mock data. DEV-only; used for documentation screenshots.
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/web/src/routes/workspaces_.$workspaceId_.queue.tsx b/packages/web/src/routes/workspaces_.$workspaceId_.queue.tsx
new file mode 100644
index 0000000..721f086
--- /dev/null
+++ b/packages/web/src/routes/workspaces_.$workspaceId_.queue.tsx
@@ -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 (
+
+
+
+
+
+ Live view of in-flight dream, representation, and summary work
+
+
+
+
+
+
+
+ );
+}