feat(web): add Dream Output Viewer

Dreams are currently a black box — to benchmark Tier L models on dreamer
induction quality we need to see what each model produced. This adds a
workspace-scoped /dreams route that surfaces every dream run as a
group of conclusions split into explicit / deductive / inductive
columns, with click-to-expand premise trees.

What's here:

- `lib/dreams.ts`: pure helpers. `clusterConclusionsIntoDreams` groups a
  raw conclusions list into per-pair bursts using a configurable time
  window (default 60s). `expandPremiseTree` walks `reasoning_tree` first
  and falls back to a flat `premises` ID list, with cycle detection and
  a depth cap. Defines an `ExtendedConclusion` type that augments the
  generated schema with `conclusion_type`, `premises`, and
  `reasoning_tree` — Honcho's migration f1a2b3c4d5e6 added those
  columns but `schema.d.ts` doesn't expose them yet, so the UI degrades
  gracefully (unknown types fall into "explicit").

- `api/queries.ts`: new `useDreams` hook that paginates the conclusions
  list endpoint up to a configurable cap (default 400) and hands the
  raw list to the UI for clustering. New `dreams` query key.

- `components/dreams/`:
  - `DreamList.tsx` — route entry. Shows recent dreams as rows with
    timestamp, observer→observed pair, and per-type counts. Selecting
    a row expands an inline detail panel above the list.
  - `DreamDetail.tsx` — three-column view (explicit / deductive /
    inductive). Each inductive conclusion has a "Show premises" button
    that expands the reasoning chain.
  - `PremiseTree.tsx` — recursive premise renderer with type badges,
    cycle indicator, and graceful handling of premises that fall
    outside the loaded page.

- Routing: `routes/workspaces_.$workspaceId_.dreams.tsx` registers the
  page; routeTree.gen.ts regenerated.

- Sidebar: new "Dreams" entry between Conclusions and Webhooks
  (MoonStar icon to distinguish from the dark-mode Moon).

- Breadcrumb: "dreams" added to SECTION_LABELS so the trail resolves.

Tests (vitest, 14 new):
- clustering: empty input; same-pair burst within window; gap exceeds
  threshold → split; different pairs don't merge even with overlapping
  timestamps; custom gap window; newest-first ordering; counts default
  unknown types to explicit.
- premise tree: empty children for no premises; flat list → direct
  children; multi-level reasoning_tree recursion; missing premise
  flagged (not in loaded page); cycle detection halts recursion;
  maxDepth cap; reasoning_tree preferred over flat premises.

Verified manually in the preview: route mounts, sidebar/breadcrumb
resolve, error path renders cleanly, typecheck + lint + dream tests
all clean. The unrelated `app.test.tsx` failure is pre-existing on
main.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Agents
2026-05-24 18:29:26 +01:00
committed by Offending Commit
parent 6960bf4ffe
commit f318555c82
11 changed files with 1140 additions and 0 deletions

View File

@@ -0,0 +1,242 @@
import { AnimatePresence, motion } from "framer-motion";
import { ChevronRight, Eye, Lightbulb, X } from "lucide-react";
import { useMemo, useState } from "react";
import { TimestampChip } from "@/components/shared/TimestampChip";
import { Button } from "@/components/ui/button";
import { Body, Caption, MonoCaption, Muted, SectionHeading } from "@/components/ui/typography";
import { useDemo } from "@/hooks/useDemo";
import { COLOR } from "@/lib/constants";
import {
buildPremiseIndex,
type ConclusionType,
type Dream,
dreamCounts,
type ExtendedConclusion,
expandPremiseTree,
inferConclusionType,
type PremiseNode,
} from "@/lib/dreams";
import { ConclusionTypeBadge, PremiseTree } from "./PremiseTree";
const COLUMNS: Array<{ type: ConclusionType; label: string; description: string }> = [
{
type: "explicit",
label: "Explicit",
description: "Surface observations pulled directly from messages",
},
{
type: "deductive",
label: "Deductive",
description: "Logical consequences of explicit observations",
},
{
type: "inductive",
label: "Inductive",
description: "Generalized patterns inferred from deductives",
},
];
interface DreamDetailProps {
dream: Dream;
onClose: () => void;
}
export function DreamDetail({ dream, onClose }: DreamDetailProps) {
const { mask } = useDemo();
const counts = useMemo(() => dreamCounts(dream), [dream]);
const index = useMemo(() => buildPremiseIndex(dream.conclusions), [dream]);
const grouped = useMemo(() => {
const buckets: Record<ConclusionType, ExtendedConclusion[]> = {
explicit: [],
deductive: [],
inductive: [],
};
for (const c of dream.conclusions) {
buckets[inferConclusionType(c)].push(c);
}
return buckets;
}, [dream]);
return (
<motion.section
key={dream.id}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.18 }}
className="rounded-2xl p-5"
style={{
background: "var(--bg-2)",
border: `1px solid ${COLOR.accentBorder}`,
}}
>
<header className="flex items-start gap-3 mb-5">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1">
<Lightbulb className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
<SectionHeading className="mb-0">Dream detail</SectionHeading>
<TimestampChip value={dream.latestIso.replace("T", " ").replace(/\.\d+Z?$/, "")} />
</div>
<div className="flex items-center gap-2 flex-wrap text-xs">
<Eye className="w-3 h-3" style={{ color: "var(--text-4)" }} strokeWidth={1.5} />
<MonoCaption>{mask(dream.observer_id)}</MonoCaption>
{dream.observed_id && (
<>
<ChevronRight
className="w-3 h-3"
style={{ color: "var(--text-4)" }}
strokeWidth={2}
/>
<MonoCaption>{mask(dream.observed_id)}</MonoCaption>
</>
)}
<span className="mx-1.5" style={{ color: "var(--text-4)" }}>
·
</span>
<Caption>
{counts.total} conclusion{counts.total === 1 ? "" : "s"}
</Caption>
</div>
</div>
<Button variant="ghost" size="icon" onClick={onClose} aria-label="Close dream detail">
<X className="w-4 h-4" strokeWidth={1.5} />
</Button>
</header>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{COLUMNS.map((col) => (
<ColumnPanel
key={col.type}
type={col.type}
label={col.label}
description={col.description}
conclusions={grouped[col.type]}
index={index}
/>
))}
</div>
</motion.section>
);
}
interface ColumnPanelProps {
type: ConclusionType;
label: string;
description: string;
conclusions: ExtendedConclusion[];
index: Map<string, ExtendedConclusion>;
}
function ColumnPanel({ type, label, description, conclusions, index }: ColumnPanelProps) {
return (
<div
className="rounded-xl p-4 flex flex-col"
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
minHeight: "8rem",
}}
>
<div className="flex items-center gap-2 mb-1">
<ConclusionTypeBadge type={type} />
<span className="text-sm font-semibold" style={{ color: "var(--text-1)" }}>
{label}
</span>
<span
className="ml-auto text-xs font-mono px-1.5 py-0.5 rounded-full"
style={{
background: "var(--surface)",
color: "var(--text-3)",
border: "1px solid var(--border)",
}}
>
{conclusions.length}
</span>
</div>
<Muted className="text-[11px] mb-3">{description}</Muted>
{conclusions.length === 0 ? (
<Caption className="italic">No {label.toLowerCase()} conclusions in this dream.</Caption>
) : (
<ul className="space-y-2.5">
{conclusions.map((c) => (
<li key={c.id}>
<ConclusionCard conclusion={c} index={index} expandable={type === "inductive"} />
</li>
))}
</ul>
)}
</div>
);
}
interface ConclusionCardProps {
conclusion: ExtendedConclusion;
index: Map<string, ExtendedConclusion>;
expandable: boolean;
}
function ConclusionCard({ conclusion, index, expandable }: ConclusionCardProps) {
const { mask } = useDemo();
const [open, setOpen] = useState(false);
const tree = useMemo<PremiseNode | null>(
() => (open ? expandPremiseTree(conclusion.id, index) : null),
[open, conclusion.id, index],
);
const hasPremises = Boolean(
(conclusion.reasoning_tree?.premises?.length ?? 0) > 0 ||
(conclusion.premises?.length ?? 0) > 0,
);
return (
<div
className="rounded-lg p-3 text-xs"
style={{ background: "var(--bg-2)", border: "1px solid var(--border)" }}
>
<Body className="text-xs whitespace-pre-wrap leading-snug mb-2">
{mask(conclusion.content)}
</Body>
<div className="flex items-center justify-between gap-2">
<MonoCaption className="truncate">{mask(conclusion.id)}</MonoCaption>
{expandable && hasPremises && (
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded transition-colors"
style={{
background: open ? COLOR.accentDim : "transparent",
color: open ? "var(--accent-text)" : "var(--text-3)",
border: `1px solid ${open ? COLOR.accentBorder : "var(--border)"}`,
}}
aria-expanded={open}
>
<ChevronRight
className="w-3 h-3 transition-transform"
style={{ transform: open ? "rotate(90deg)" : undefined }}
strokeWidth={2}
/>
{open ? "Hide" : "Show"} premises
</button>
)}
</div>
<AnimatePresence>
{open && tree && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.18 }}
className="overflow-hidden"
>
<div className="mt-3 pt-3" style={{ borderTop: `1px solid ${COLOR.accentBorder}` }}>
<Caption className="mb-2 block">Reasoning chain</Caption>
<PremiseTree root={tree} />
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,241 @@
import { useParams } from "@tanstack/react-router";
import { AnimatePresence, motion } from "framer-motion";
import { ChevronRight, Eye, Moon } from "lucide-react";
import { useMemo, useState } from "react";
import { useDreams } from "@/api/queries";
import { Breadcrumb } from "@/components/layout/Breadcrumb";
import { EmptyState } from "@/components/shared/EmptyState";
import { ErrorAlert } from "@/components/shared/ErrorAlert";
import { Skeleton } from "@/components/shared/Skeleton";
import { TimestampChip } from "@/components/shared/TimestampChip";
import { Caption, MonoCaption, Muted, PageTitle } from "@/components/ui/typography";
import { useDemo } from "@/hooks/useDemo";
import { COLOR } from "@/lib/constants";
import {
clusterConclusionsIntoDreams,
type Dream,
dreamCounts,
type ExtendedConclusion,
} from "@/lib/dreams";
import { DreamDetail } from "./DreamDetail";
const itemVariants = {
hidden: { opacity: 0, y: 8 },
show: (i: number) => ({
opacity: 1,
y: 0,
transition: { delay: i * 0.03, type: "spring" as const, stiffness: 300, damping: 25 },
}),
};
export function DreamList() {
const { workspaceId } = useParams({ strict: false }) as { workspaceId: string };
const { data, isLoading, error } = useDreams(workspaceId);
const [selectedId, setSelectedId] = useState<string | null>(null);
const dreams = useMemo<Dream[]>(() => {
const conclusions = (data as ExtendedConclusion[] | undefined) ?? [];
return clusterConclusionsIntoDreams(conclusions);
}, [data]);
const selected = useMemo(
() => (selectedId ? (dreams.find((d) => d.id === selectedId) ?? null) : null),
[dreams, selectedId],
);
return (
<div className="page-container">
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} className="mb-8">
<Breadcrumb />
<div className="flex items-center gap-2 mb-1">
<Moon className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
<PageTitle>Dreams</PageTitle>
{dreams.length > 0 && (
<span
className="ml-1 text-xs font-mono px-2 py-0.5 rounded-full"
style={{
background: COLOR.accentSubtle,
color: COLOR.accentText,
border: `1px solid ${COLOR.accentBorder}`,
}}
>
{dreams.length}
</span>
)}
</div>
<Muted className="mt-0.5">
Each run produces explicit, deductive, and inductive conclusions for one peer pair.
</Muted>
</motion.div>
<ErrorAlert error={error instanceof Error ? error : null} />
{isLoading && <DreamsSkeleton />}
{!isLoading && dreams.length === 0 && !error && (
<EmptyState
icon={Moon}
title="No dream runs yet"
description="Trigger a dream from a workspace to see its conclusion stream here."
/>
)}
<AnimatePresence>
{selected && (
<motion.div
key="detail"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.22, ease: "easeInOut" }}
className="overflow-hidden mb-6"
>
<DreamDetail dream={selected} onClose={() => setSelectedId(null)} />
</motion.div>
)}
</AnimatePresence>
{dreams.length > 0 && (
<ul className="space-y-2.5">
{dreams.map((d, i) => (
<motion.li
key={d.id}
custom={i}
variants={itemVariants}
initial="hidden"
animate="show"
>
<DreamRow
dream={d}
active={d.id === selectedId}
onSelect={() => setSelectedId(d.id === selectedId ? null : d.id)}
/>
</motion.li>
))}
</ul>
)}
</div>
);
}
interface DreamRowProps {
dream: Dream;
active: boolean;
onSelect: () => void;
}
function DreamRow({ dream, active, onSelect }: DreamRowProps) {
const { mask } = useDemo();
const counts = useMemo(() => dreamCounts(dream), [dream]);
return (
<button
type="button"
onClick={onSelect}
aria-pressed={active}
className="group w-full text-left rounded-xl p-4 transition-colors"
style={{
background: active ? COLOR.accentDim : "var(--surface)",
border: `1px solid ${active ? COLOR.accentBorder : "var(--border)"}`,
}}
>
<div className="flex items-center gap-3 flex-wrap">
<TimestampChip value={dream.latestIso.replace("T", " ").replace(/\.\d+Z?$/, "")} />
<div className="flex items-center gap-1.5">
<Eye className="w-3 h-3" style={{ color: "var(--text-4)" }} strokeWidth={1.5} />
<MonoCaption>{mask(dream.observer_id)}</MonoCaption>
</div>
{dream.observed_id && (
<div className="flex items-center gap-1">
<ChevronRight className="w-3 h-3" style={{ color: "var(--text-4)" }} strokeWidth={2} />
<MonoCaption>{mask(dream.observed_id)}</MonoCaption>
</div>
)}
<div className="ml-auto flex items-center gap-1.5">
<CountChip label="explicit" value={counts.explicit} kind="neutral" />
<CountChip label="deductive" value={counts.deductive} kind="accent" />
<CountChip label="inductive" value={counts.inductive} kind="warning" />
<ChevronRight
className="w-4 h-4 ml-1 transition-transform"
style={{
color: active ? "var(--accent-text)" : "var(--text-4)",
transform: active ? "rotate(90deg)" : undefined,
}}
strokeWidth={1.5}
/>
</div>
</div>
{dream.earliestIso !== dream.latestIso && (
<Caption className="mt-2 block">
Span: {formatSpan(dream.latestMs - dream.earliestMs)}
</Caption>
)}
</button>
);
}
type ChipKind = "neutral" | "accent" | "warning";
function CountChip({ label, value, kind }: { label: string; value: number; kind: ChipKind }) {
const palette: Record<ChipKind, { bg: string; fg: string; border: string }> = {
neutral: {
bg: "rgba(148,163,184,0.10)",
fg: "var(--text-2)",
border: "rgba(148,163,184,0.25)",
},
accent: { bg: COLOR.accentSubtle, fg: COLOR.accentText, border: COLOR.accentBorder },
warning: { bg: "rgba(245,158,11,0.10)", fg: COLOR.warning, border: COLOR.warningBorder },
};
const cfg = palette[kind];
const dim = value === 0;
return (
<span
title={`${value} ${label}`}
className="inline-flex items-center gap-1 text-[11px] font-mono px-1.5 py-0.5 rounded"
style={{
background: dim ? "transparent" : cfg.bg,
color: dim ? "var(--text-4)" : cfg.fg,
border: `1px solid ${dim ? "var(--border)" : cfg.border}`,
opacity: dim ? 0.6 : 1,
}}
>
<span>{value}</span>
<span className="hidden sm:inline">{label}</span>
</span>
);
}
function formatSpan(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const s = Math.round(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const rem = s % 60;
return rem === 0 ? `${m}m` : `${m}m ${rem}s`;
}
function DreamsSkeleton() {
return (
<div className="space-y-2.5" aria-hidden="true">
{Array.from({ length: 5 }).map((_, index) => (
<div
key={index}
className="rounded-xl p-4"
style={{ background: "var(--surface)", border: "1px solid var(--border)" }}
>
<div className="flex items-center gap-3">
<Skeleton className="h-6 w-28 rounded-full" />
<Skeleton className="h-3 w-20 rounded" />
<Skeleton className="h-3 w-20 rounded" />
<Skeleton className="ml-auto h-5 w-12 rounded" />
<Skeleton className="h-5 w-12 rounded" />
<Skeleton className="h-5 w-12 rounded" />
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,143 @@
import { ChevronRight, CornerDownRight, RefreshCcw } from "lucide-react";
import { useState } from "react";
import { Caption, MonoCaption, Muted } from "@/components/ui/typography";
import { useDemo } from "@/hooks/useDemo";
import { COLOR } from "@/lib/constants";
import { type ConclusionType, inferConclusionType, type PremiseNode } from "@/lib/dreams";
const TYPE_BADGE: Record<
ConclusionType,
{ label: string; bg: string; fg: string; border: string }
> = {
explicit: {
label: "explicit",
bg: "rgba(148,163,184,0.10)",
fg: "var(--text-2)",
border: "rgba(148,163,184,0.25)",
},
deductive: {
label: "deductive",
bg: COLOR.accentSubtle,
fg: "var(--accent-text)",
border: COLOR.accentBorder,
},
inductive: {
label: "inductive",
bg: "rgba(245,158,11,0.10)",
fg: COLOR.warning,
border: COLOR.warningBorder,
},
};
export function ConclusionTypeBadge({ type }: { type: ConclusionType }) {
const cfg = TYPE_BADGE[type];
return (
<span
className="text-[10px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wide"
style={{ background: cfg.bg, color: cfg.fg, border: `1px solid ${cfg.border}` }}
>
{cfg.label}
</span>
);
}
interface PremiseTreeProps {
root: PremiseNode;
}
export function PremiseTree({ root }: PremiseTreeProps) {
if (root.children.length === 0) {
return <Muted className="italic">No upstream premises recorded for this conclusion.</Muted>;
}
return (
<ul className="space-y-1.5" aria-label="Premise tree">
{root.children.map((child, i) => (
<PremiseTreeNode key={`${child.conclusionId}-${i}`} node={child} />
))}
</ul>
);
}
function PremiseTreeNode({ node }: { node: PremiseNode }) {
const { mask } = useDemo();
const [expanded, setExpanded] = useState(false);
const hasChildren = node.children.length > 0;
const conclusion = node.conclusion;
const type: ConclusionType | null = conclusion ? inferConclusionType(conclusion) : null;
const indent = Math.min(node.depth, 4) * 12;
return (
<li style={{ marginLeft: `${indent}px` }}>
<div
className="rounded-lg p-2.5 text-xs"
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
}}
>
<div className="flex items-start gap-2">
{hasChildren ? (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="mt-0.5 p-0.5 rounded transition-colors"
style={{ color: "var(--text-3)" }}
aria-expanded={expanded}
aria-label={expanded ? "Collapse premises" : "Expand premises"}
>
<ChevronRight
className="w-3 h-3 transition-transform"
style={{ transform: expanded ? "rotate(90deg)" : undefined }}
strokeWidth={2}
/>
</button>
) : (
<CornerDownRight
className="w-3 h-3 mt-1 shrink-0"
style={{ color: "var(--text-4)" }}
strokeWidth={1.5}
/>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-1 flex-wrap">
{type && <ConclusionTypeBadge type={type} />}
{node.cycle && (
<span
className="inline-flex items-center gap-1 text-[10px] font-mono px-1.5 py-0.5 rounded"
style={{
background: COLOR.warningDim,
color: COLOR.warning,
border: `1px solid ${COLOR.warningBorder}`,
}}
title="Cycle in reasoning tree — already shown upstream"
>
<RefreshCcw className="w-2.5 h-2.5" strokeWidth={1.5} />
cycle
</span>
)}
<MonoCaption className="truncate">{mask(node.conclusionId)}</MonoCaption>
</div>
{conclusion ? (
<p className="leading-snug" style={{ color: "var(--text-2)" }}>
{mask(conclusion.content)}
</p>
) : (
<Caption className="italic">
Premise not in current page fetch more conclusions to expand.
</Caption>
)}
</div>
</div>
</div>
{expanded && hasChildren && (
<ul className="mt-1.5 space-y-1.5">
{node.children.map((child, i) => (
<PremiseTreeNode key={`${child.conclusionId}-${i}`} node={child} />
))}
</ul>
)}
</li>
);
}

View File

@@ -6,6 +6,7 @@ const SECTION_LABELS: Record<string, string> = {
peers: "Peers",
sessions: "Sessions",
conclusions: "Conclusions",
dreams: "Dreams",
webhooks: "Webhooks",
chat: "Chat",
};

View File

@@ -12,6 +12,7 @@ import {
Lightbulb,
MessageSquare,
Moon,
MoonStar,
Settings,
Sun,
Users,
@@ -36,6 +37,7 @@ const WORKSPACE_SECTIONS = [
{ label: "Peers", icon: Users, section: "peers" },
{ label: "Sessions", icon: MessageSquare, section: "sessions" },
{ label: "Conclusions", icon: Lightbulb, section: "conclusions" },
{ label: "Dreams", icon: MoonStar, section: "dreams" },
{ label: "Webhooks", icon: Webhook, section: "webhooks" },
] as const;