Merge pull request #29 from offendingcommit/feat/live-dream-progress

feat(web): add live dream progress panel with adaptive polling
This commit is contained in:
Offending Commit
2026-05-28 14:23:55 -05:00
committed by GitHub
14 changed files with 883 additions and 3 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

View File

@@ -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);
});

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,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 (
<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 in{" "}
<a
href="https://github.com/plastic-labs/honcho/issues/724"
target="_blank"
rel="noreferrer"
style={{ color: COLOR.accentText }}
className="underline"
>
plastic-labs/honcho#724
</a>{" "}
once the API exposes them, this panel will surface them.
</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

@@ -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,

View File

@@ -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 <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>
);
}

View File

@@ -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>): 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);
});
});