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) => ( + + ))} + + + + {entries.map(([sid, s], i) => ( + 0 ? "1px solid var(--border)" : undefined }}> + + + + + + + ))} + +
+ {h} +
+ + {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. + + + +
+

+ Idle +

+ +
+ +
+

+ Active +

+ +
+ +
+

+ Stalled (>30m) +

+ +
+
+ ); +} 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 ( +
+ + +
+ + Queue & dreams +
+ + 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); + }); +});