diff --git a/docs/screenshots/live-dream-progress/active.png b/docs/screenshots/live-dream-progress/active.png
new file mode 100644
index 0000000..18f1c26
Binary files /dev/null and b/docs/screenshots/live-dream-progress/active.png differ
diff --git a/docs/screenshots/live-dream-progress/idle.png b/docs/screenshots/live-dream-progress/idle.png
new file mode 100644
index 0000000..6cbb46e
Binary files /dev/null and b/docs/screenshots/live-dream-progress/idle.png differ
diff --git a/docs/screenshots/live-dream-progress/overview.png b/docs/screenshots/live-dream-progress/overview.png
new file mode 100644
index 0000000..74cb148
Binary files /dev/null and b/docs/screenshots/live-dream-progress/overview.png differ
diff --git a/docs/screenshots/live-dream-progress/stalled.png b/docs/screenshots/live-dream-progress/stalled.png
new file mode 100644
index 0000000..3a5020f
Binary files /dev/null and b/docs/screenshots/live-dream-progress/stalled.png differ
diff --git a/packages/web/scripts/screenshot-dream-progress.mjs b/packages/web/scripts/screenshot-dream-progress.mjs
new file mode 100644
index 0000000..c20045c
--- /dev/null
+++ b/packages/web/scripts/screenshot-dream-progress.mjs
@@ -0,0 +1,99 @@
+#!/usr/bin/env node
+/**
+ * Capture documentation screenshots for the Dream Progress panel.
+ *
+ * The dev server must already be running at the URL passed in via PREVIEW_URL
+ * (defaults to http://localhost:5178). The /dream-progress showcase route is
+ * DEV-only and renders three variants of the panel against mock data.
+ *
+ * Usage:
+ * PREVIEW_URL=http://localhost:5178 OUT_DIR=../../docs/screenshots/live-dream-progress \
+ * node scripts/screenshot-dream-progress.mjs
+ */
+import { mkdir } from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { chromium } from "@playwright/test";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const PREVIEW_URL = process.env.PREVIEW_URL ?? "http://localhost:5178";
+const OUT_DIR = path.resolve(
+ __dirname,
+ process.env.OUT_DIR ?? "../../../docs/screenshots/live-dream-progress",
+);
+
+async function main() {
+ await mkdir(OUT_DIR, { recursive: true });
+ const browser = await chromium.launch();
+ const context = await browser.newContext({
+ viewport: { width: 1440, height: 900 },
+ deviceScaleFactor: 2,
+ colorScheme: "dark",
+ });
+ const page = await context.newPage();
+
+ // Seed localStorage with a fake instance so the root redirect doesn't kick
+ // us to the settings page. The showcase doesn't actually make any network
+ // requests — it renders against in-memory mock data.
+ await page.addInitScript(() => {
+ localStorage.setItem(
+ "openconcho:instances",
+ JSON.stringify({
+ instances: [
+ {
+ id: "inst_dev_demo",
+ name: "Demo (mock)",
+ baseUrl: "http://localhost:9999",
+ token: "",
+ },
+ ],
+ activeId: "inst_dev_demo",
+ }),
+ );
+ });
+
+ await page.goto(`${PREVIEW_URL}/dream-progress`, { waitUntil: "networkidle" });
+ await page.waitForSelector('[data-testid="dream-progress-panel"]');
+ // Let framer-motion entrance animations settle.
+ await page.waitForTimeout(600);
+
+ // Full showcase — top-to-bottom view of all three variants.
+ await page.screenshot({
+ path: path.join(OUT_DIR, "overview.png"),
+ fullPage: true,
+ });
+
+ // Variant: idle
+ {
+ const handle = await page.locator("section").nth(0);
+ await handle.scrollIntoViewIfNeeded();
+ await page.waitForTimeout(150);
+ await handle.screenshot({ path: path.join(OUT_DIR, "idle.png") });
+ }
+
+ // Variant: active (with per-session breakdown)
+ {
+ const handle = await page.locator("section").nth(1);
+ await handle.scrollIntoViewIfNeeded();
+ await page.waitForTimeout(150);
+ await handle.screenshot({ path: path.join(OUT_DIR, "active.png") });
+ }
+
+ // Variant: stalled (>30m without forward progress)
+ {
+ const handle = await page.locator("section").nth(2);
+ await handle.scrollIntoViewIfNeeded();
+ await page.waitForTimeout(150);
+ await handle.screenshot({ path: path.join(OUT_DIR, "stalled.png") });
+ }
+
+ await browser.close();
+ console.log(`Saved screenshots to ${OUT_DIR}`);
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
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..562aeb5
--- /dev/null
+++ b/packages/web/src/components/workspaces/DreamProgressPanel.tsx
@@ -0,0 +1,310 @@
+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 in{" "}
+
+ plastic-labs/honcho#724
+ {" "}
+ — once the API exposes them, this panel will surface them.
+
+
+
+
+
+
+ );
+}
+
+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/routeTree.gen.ts b/packages/web/src/routeTree.gen.ts
index 934ef2b..528019a 100644
--- a/packages/web/src/routeTree.gen.ts
+++ b/packages/web/src/routeTree.gen.ts
@@ -14,8 +14,10 @@ import { Route as SettingsRouteImport } from './routes/settings'
import { Route as ExploreRouteImport } from './routes/explore'
import { Route as IndexRouteImport } from './routes/index'
import { Route as WorkspacesWorkspaceIdRouteImport } from './routes/workspaces_.$workspaceId'
+import { Route as DevDreamProgressRouteImport } from './routes/_dev.dream-progress'
import { Route as WorkspacesWorkspaceIdWebhooksRouteImport } from './routes/workspaces_.$workspaceId_.webhooks'
import { Route as WorkspacesWorkspaceIdSessionsRouteImport } from './routes/workspaces_.$workspaceId_.sessions'
+import { Route as WorkspacesWorkspaceIdQueueRouteImport } from './routes/workspaces_.$workspaceId_.queue'
import { Route as WorkspacesWorkspaceIdPeersRouteImport } from './routes/workspaces_.$workspaceId_.peers'
import { Route as WorkspacesWorkspaceIdConclusionsRouteImport } from './routes/workspaces_.$workspaceId_.conclusions'
import { Route as WorkspacesWorkspaceIdSessionsSessionIdRouteImport } from './routes/workspaces_.$workspaceId_.sessions_.$sessionId'
@@ -47,6 +49,11 @@ const WorkspacesWorkspaceIdRoute = WorkspacesWorkspaceIdRouteImport.update({
path: '/workspaces/$workspaceId',
getParentRoute: () => rootRouteImport,
} as any)
+const DevDreamProgressRoute = DevDreamProgressRouteImport.update({
+ id: '/_dev/dream-progress',
+ path: '/dream-progress',
+ getParentRoute: () => rootRouteImport,
+} as any)
const WorkspacesWorkspaceIdWebhooksRoute =
WorkspacesWorkspaceIdWebhooksRouteImport.update({
id: '/workspaces_/$workspaceId_/webhooks',
@@ -59,6 +66,12 @@ const WorkspacesWorkspaceIdSessionsRoute =
path: '/workspaces/$workspaceId/sessions',
getParentRoute: () => rootRouteImport,
} as any)
+const WorkspacesWorkspaceIdQueueRoute =
+ WorkspacesWorkspaceIdQueueRouteImport.update({
+ id: '/workspaces_/$workspaceId_/queue',
+ path: '/workspaces/$workspaceId/queue',
+ getParentRoute: () => rootRouteImport,
+ } as any)
const WorkspacesWorkspaceIdPeersRoute =
WorkspacesWorkspaceIdPeersRouteImport.update({
id: '/workspaces_/$workspaceId_/peers',
@@ -95,9 +108,11 @@ export interface FileRoutesByFullPath {
'/explore': typeof ExploreRoute
'/settings': typeof SettingsRoute
'/workspaces': typeof WorkspacesRoute
+ '/dream-progress': typeof DevDreamProgressRoute
'/workspaces/$workspaceId': typeof WorkspacesWorkspaceIdRoute
'/workspaces/$workspaceId/conclusions': typeof WorkspacesWorkspaceIdConclusionsRoute
'/workspaces/$workspaceId/peers': typeof WorkspacesWorkspaceIdPeersRoute
+ '/workspaces/$workspaceId/queue': typeof WorkspacesWorkspaceIdQueueRoute
'/workspaces/$workspaceId/sessions': typeof WorkspacesWorkspaceIdSessionsRoute
'/workspaces/$workspaceId/webhooks': typeof WorkspacesWorkspaceIdWebhooksRoute
'/workspaces/$workspaceId/peers/$peerId': typeof WorkspacesWorkspaceIdPeersPeerIdRoute
@@ -109,9 +124,11 @@ export interface FileRoutesByTo {
'/explore': typeof ExploreRoute
'/settings': typeof SettingsRoute
'/workspaces': typeof WorkspacesRoute
+ '/dream-progress': typeof DevDreamProgressRoute
'/workspaces/$workspaceId': typeof WorkspacesWorkspaceIdRoute
'/workspaces/$workspaceId/conclusions': typeof WorkspacesWorkspaceIdConclusionsRoute
'/workspaces/$workspaceId/peers': typeof WorkspacesWorkspaceIdPeersRoute
+ '/workspaces/$workspaceId/queue': typeof WorkspacesWorkspaceIdQueueRoute
'/workspaces/$workspaceId/sessions': typeof WorkspacesWorkspaceIdSessionsRoute
'/workspaces/$workspaceId/webhooks': typeof WorkspacesWorkspaceIdWebhooksRoute
'/workspaces/$workspaceId/peers/$peerId': typeof WorkspacesWorkspaceIdPeersPeerIdRoute
@@ -124,9 +141,11 @@ export interface FileRoutesById {
'/explore': typeof ExploreRoute
'/settings': typeof SettingsRoute
'/workspaces': typeof WorkspacesRoute
+ '/_dev/dream-progress': typeof DevDreamProgressRoute
'/workspaces_/$workspaceId': typeof WorkspacesWorkspaceIdRoute
'/workspaces_/$workspaceId_/conclusions': typeof WorkspacesWorkspaceIdConclusionsRoute
'/workspaces_/$workspaceId_/peers': typeof WorkspacesWorkspaceIdPeersRoute
+ '/workspaces_/$workspaceId_/queue': typeof WorkspacesWorkspaceIdQueueRoute
'/workspaces_/$workspaceId_/sessions': typeof WorkspacesWorkspaceIdSessionsRoute
'/workspaces_/$workspaceId_/webhooks': typeof WorkspacesWorkspaceIdWebhooksRoute
'/workspaces_/$workspaceId_/peers_/$peerId': typeof WorkspacesWorkspaceIdPeersPeerIdRoute
@@ -140,9 +159,11 @@ export interface FileRouteTypes {
| '/explore'
| '/settings'
| '/workspaces'
+ | '/dream-progress'
| '/workspaces/$workspaceId'
| '/workspaces/$workspaceId/conclusions'
| '/workspaces/$workspaceId/peers'
+ | '/workspaces/$workspaceId/queue'
| '/workspaces/$workspaceId/sessions'
| '/workspaces/$workspaceId/webhooks'
| '/workspaces/$workspaceId/peers/$peerId'
@@ -154,9 +175,11 @@ export interface FileRouteTypes {
| '/explore'
| '/settings'
| '/workspaces'
+ | '/dream-progress'
| '/workspaces/$workspaceId'
| '/workspaces/$workspaceId/conclusions'
| '/workspaces/$workspaceId/peers'
+ | '/workspaces/$workspaceId/queue'
| '/workspaces/$workspaceId/sessions'
| '/workspaces/$workspaceId/webhooks'
| '/workspaces/$workspaceId/peers/$peerId'
@@ -168,9 +191,11 @@ export interface FileRouteTypes {
| '/explore'
| '/settings'
| '/workspaces'
+ | '/_dev/dream-progress'
| '/workspaces_/$workspaceId'
| '/workspaces_/$workspaceId_/conclusions'
| '/workspaces_/$workspaceId_/peers'
+ | '/workspaces_/$workspaceId_/queue'
| '/workspaces_/$workspaceId_/sessions'
| '/workspaces_/$workspaceId_/webhooks'
| '/workspaces_/$workspaceId_/peers_/$peerId'
@@ -183,9 +208,11 @@ export interface RootRouteChildren {
ExploreRoute: typeof ExploreRoute
SettingsRoute: typeof SettingsRoute
WorkspacesRoute: typeof WorkspacesRoute
+ DevDreamProgressRoute: typeof DevDreamProgressRoute
WorkspacesWorkspaceIdRoute: typeof WorkspacesWorkspaceIdRoute
WorkspacesWorkspaceIdConclusionsRoute: typeof WorkspacesWorkspaceIdConclusionsRoute
WorkspacesWorkspaceIdPeersRoute: typeof WorkspacesWorkspaceIdPeersRoute
+ WorkspacesWorkspaceIdQueueRoute: typeof WorkspacesWorkspaceIdQueueRoute
WorkspacesWorkspaceIdSessionsRoute: typeof WorkspacesWorkspaceIdSessionsRoute
WorkspacesWorkspaceIdWebhooksRoute: typeof WorkspacesWorkspaceIdWebhooksRoute
WorkspacesWorkspaceIdPeersPeerIdRoute: typeof WorkspacesWorkspaceIdPeersPeerIdRoute
@@ -230,6 +257,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof WorkspacesWorkspaceIdRouteImport
parentRoute: typeof rootRouteImport
}
+ '/_dev/dream-progress': {
+ id: '/_dev/dream-progress'
+ path: '/dream-progress'
+ fullPath: '/dream-progress'
+ preLoaderRoute: typeof DevDreamProgressRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/workspaces_/$workspaceId_/webhooks': {
id: '/workspaces_/$workspaceId_/webhooks'
path: '/workspaces/$workspaceId/webhooks'
@@ -244,6 +278,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof WorkspacesWorkspaceIdSessionsRouteImport
parentRoute: typeof rootRouteImport
}
+ '/workspaces_/$workspaceId_/queue': {
+ id: '/workspaces_/$workspaceId_/queue'
+ path: '/workspaces/$workspaceId/queue'
+ fullPath: '/workspaces/$workspaceId/queue'
+ preLoaderRoute: typeof WorkspacesWorkspaceIdQueueRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/workspaces_/$workspaceId_/peers': {
id: '/workspaces_/$workspaceId_/peers'
path: '/workspaces/$workspaceId/peers'
@@ -287,9 +328,11 @@ const rootRouteChildren: RootRouteChildren = {
ExploreRoute: ExploreRoute,
SettingsRoute: SettingsRoute,
WorkspacesRoute: WorkspacesRoute,
+ DevDreamProgressRoute: DevDreamProgressRoute,
WorkspacesWorkspaceIdRoute: WorkspacesWorkspaceIdRoute,
WorkspacesWorkspaceIdConclusionsRoute: WorkspacesWorkspaceIdConclusionsRoute,
WorkspacesWorkspaceIdPeersRoute: WorkspacesWorkspaceIdPeersRoute,
+ WorkspacesWorkspaceIdQueueRoute: WorkspacesWorkspaceIdQueueRoute,
WorkspacesWorkspaceIdSessionsRoute: WorkspacesWorkspaceIdSessionsRoute,
WorkspacesWorkspaceIdWebhooksRoute: WorkspacesWorkspaceIdWebhooksRoute,
WorkspacesWorkspaceIdPeersPeerIdRoute: WorkspacesWorkspaceIdPeersPeerIdRoute,
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..7bc4e05
--- /dev/null
+++ b/packages/web/src/routes/_dev.dream-progress.tsx
@@ -0,0 +1,124 @@
+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
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/web/src/test/dream-progress.test.tsx b/packages/web/src/test/dream-progress.test.tsx
new file mode 100644
index 0000000..91310a8
--- /dev/null
+++ b/packages/web/src/test/dream-progress.test.tsx
@@ -0,0 +1,176 @@
+import { act, renderHook } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ pickQueueRefetchInterval,
+ QUEUE_REFETCH_ACTIVE_MS,
+ QUEUE_REFETCH_IDLE_MS,
+} from "@/api/queries";
+import type { components } from "@/api/schema.d.ts";
+import { STALE_QUEUE_THRESHOLD_MS, useStaleQueueDetection } from "@/hooks/useStaleQueueDetection";
+
+type QueueStatus = components["schemas"]["QueueStatus"];
+
+function buildStatus(partial: Partial): QueueStatus {
+ return {
+ total_work_units: 0,
+ completed_work_units: 0,
+ in_progress_work_units: 0,
+ pending_work_units: 0,
+ sessions: null,
+ ...partial,
+ };
+}
+
+describe("pickQueueRefetchInterval", () => {
+ it("backs off to idle interval when no data has loaded yet", () => {
+ expect(pickQueueRefetchInterval(undefined)).toBe(QUEUE_REFETCH_IDLE_MS);
+ });
+
+ it("uses idle interval when no work is queued or in-flight", () => {
+ const data = buildStatus({
+ total_work_units: 5,
+ completed_work_units: 5,
+ });
+ expect(pickQueueRefetchInterval(data)).toBe(QUEUE_REFETCH_IDLE_MS);
+ });
+
+ it("uses active interval when work is in progress", () => {
+ const data = buildStatus({
+ total_work_units: 5,
+ completed_work_units: 2,
+ in_progress_work_units: 1,
+ });
+ expect(pickQueueRefetchInterval(data)).toBe(QUEUE_REFETCH_ACTIVE_MS);
+ });
+
+ it("uses active interval when work is pending even without in-progress", () => {
+ const data = buildStatus({
+ total_work_units: 5,
+ completed_work_units: 0,
+ pending_work_units: 3,
+ });
+ expect(pickQueueRefetchInterval(data)).toBe(QUEUE_REFETCH_ACTIVE_MS);
+ });
+
+ it("active interval is faster than idle interval", () => {
+ // Sanity check on the constants so a future tweak doesn't invert them.
+ expect(QUEUE_REFETCH_ACTIVE_MS).toBeLessThan(QUEUE_REFETCH_IDLE_MS);
+ });
+});
+
+describe("useStaleQueueDetection", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-05-24T12:00:00Z"));
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("returns no stall when there's no active work", () => {
+ const { result } = renderHook(() =>
+ useStaleQueueDetection(buildStatus({ total_work_units: 3, completed_work_units: 3 })),
+ );
+ expect(result.current.stalledSince).toBeNull();
+ expect(result.current.isStale).toBe(false);
+ });
+
+ it("does not flag a freshly-started run as stale", () => {
+ const { result } = renderHook(() =>
+ useStaleQueueDetection(
+ buildStatus({
+ total_work_units: 5,
+ completed_work_units: 0,
+ in_progress_work_units: 2,
+ pending_work_units: 3,
+ }),
+ ),
+ );
+ expect(result.current.stalledSince).not.toBeNull();
+ expect(result.current.isStale).toBe(false);
+ });
+
+ it("surfaces a warning after 30 minutes of in-progress work without forward progress", () => {
+ const stuck = buildStatus({
+ total_work_units: 5,
+ completed_work_units: 0,
+ in_progress_work_units: 2,
+ pending_work_units: 3,
+ });
+ const { result } = renderHook(({ data }) => useStaleQueueDetection(data), {
+ initialProps: { data: stuck },
+ });
+
+ expect(result.current.isStale).toBe(false);
+
+ // Advance past the threshold; the hook's internal tick interval triggers
+ // a re-render so the new elapsed time is reflected.
+ act(() => {
+ vi.advanceTimersByTime(STALE_QUEUE_THRESHOLD_MS + 30_000);
+ });
+
+ expect(result.current.isStale).toBe(true);
+ expect(result.current.elapsedMs).toBeGreaterThan(STALE_QUEUE_THRESHOLD_MS);
+ });
+
+ it("clears the stall anchor when completed_work_units advances", () => {
+ const stuck = buildStatus({
+ total_work_units: 5,
+ completed_work_units: 0,
+ in_progress_work_units: 2,
+ pending_work_units: 3,
+ });
+ const progressed = buildStatus({
+ total_work_units: 5,
+ completed_work_units: 2,
+ in_progress_work_units: 1,
+ pending_work_units: 2,
+ });
+
+ const { result, rerender } = renderHook(({ data }) => useStaleQueueDetection(data), {
+ initialProps: { data: stuck },
+ });
+
+ act(() => {
+ vi.advanceTimersByTime(STALE_QUEUE_THRESHOLD_MS + 60_000);
+ });
+ expect(result.current.isStale).toBe(true);
+
+ // Forward progress arrives — the stall clock should reset.
+ rerender({ data: progressed });
+ act(() => {
+ vi.advanceTimersByTime(0);
+ });
+ expect(result.current.isStale).toBe(false);
+ });
+
+ it("clears the stall anchor once all work finishes", () => {
+ const stuck = buildStatus({
+ total_work_units: 5,
+ completed_work_units: 0,
+ in_progress_work_units: 2,
+ pending_work_units: 3,
+ });
+ const idle = buildStatus({
+ total_work_units: 5,
+ completed_work_units: 5,
+ });
+
+ const { result, rerender } = renderHook(({ data }) => useStaleQueueDetection(data), {
+ initialProps: { data: stuck },
+ });
+
+ act(() => {
+ vi.advanceTimersByTime(STALE_QUEUE_THRESHOLD_MS + 60_000);
+ });
+ expect(result.current.isStale).toBe(true);
+
+ rerender({ data: idle });
+ act(() => {
+ vi.advanceTimersByTime(0);
+ });
+ expect(result.current.stalledSince).toBeNull();
+ expect(result.current.isStale).toBe(false);
+ });
+});