Skip to content

Commit f318555

Browse files
Agentsclaude
authored andcommitted
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>
1 parent 6960bf4 commit f318555

11 files changed

Lines changed: 1140 additions & 0 deletions

File tree

packages/web/src/api/keys.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,8 @@ export const QK = {
3232
conclusionsQuery: (wsId: string, q: string, filters: Record<string, unknown>) =>
3333
["conclusions-query", wsId, q, filters] as const,
3434

35+
dreams: (wsId: string, filters: Record<string, unknown>, limit: number) =>
36+
["dreams", wsId, filters, limit] as const,
37+
3538
webhooks: (wsId: string) => ["webhooks", wsId] as const,
3639
};

packages/web/src/api/queries.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -690,6 +690,50 @@ export function useDeleteConclusion(workspaceId: string) {
690690
});
691691
}
692692

693+
// ─── Dreams ───────────────────────────────────────────────────────────────────
694+
//
695+
// Dreams are synthetic groupings of conclusions: bursts produced by a single
696+
// dream run for one (observer, observed) pair. We fetch a generous batch of
697+
// conclusions and let the UI cluster them via `clusterConclusionsIntoDreams`.
698+
699+
const DREAM_FETCH_PAGE_SIZE = 100;
700+
const DREAM_MAX_PAGES = 4;
701+
702+
export function useDreams(
703+
workspaceId: string,
704+
filters: Record<string, unknown> = {},
705+
limit = DREAM_FETCH_PAGE_SIZE * DREAM_MAX_PAGES,
706+
) {
707+
return useQuery({
708+
queryKey: QK.dreams(workspaceId, filters, limit),
709+
queryFn: async () => {
710+
const collected: unknown[] = [];
711+
const pageSize = Math.min(DREAM_FETCH_PAGE_SIZE, limit);
712+
let page = 1;
713+
while (collected.length < limit) {
714+
const { data, error } = await client.current.POST(
715+
"/v3/workspaces/{workspace_id}/conclusions/list",
716+
{
717+
params: {
718+
path: { workspace_id: workspaceId },
719+
query: { page, page_size: pageSize, reverse: false },
720+
},
721+
body: filters,
722+
},
723+
);
724+
if (error) err(error);
725+
const items = (data as { items?: unknown[] } | undefined)?.items ?? [];
726+
const totalPages = (data as { pages?: number } | undefined)?.pages ?? 1;
727+
collected.push(...items);
728+
if (items.length === 0 || page >= totalPages || page >= DREAM_MAX_PAGES) break;
729+
page++;
730+
}
731+
return collected.slice(0, limit);
732+
},
733+
enabled: Boolean(workspaceId),
734+
});
735+
}
736+
693737
// ─── Webhooks ─────────────────────────────────────────────────────────────────
694738

695739
export function useWebhooks(workspaceId: string) {
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import { AnimatePresence, motion } from "framer-motion";
2+
import { ChevronRight, Eye, Lightbulb, X } from "lucide-react";
3+
import { useMemo, useState } from "react";
4+
import { TimestampChip } from "@/components/shared/TimestampChip";
5+
import { Button } from "@/components/ui/button";
6+
import { Body, Caption, MonoCaption, Muted, SectionHeading } from "@/components/ui/typography";
7+
import { useDemo } from "@/hooks/useDemo";
8+
import { COLOR } from "@/lib/constants";
9+
import {
10+
buildPremiseIndex,
11+
type ConclusionType,
12+
type Dream,
13+
dreamCounts,
14+
type ExtendedConclusion,
15+
expandPremiseTree,
16+
inferConclusionType,
17+
type PremiseNode,
18+
} from "@/lib/dreams";
19+
import { ConclusionTypeBadge, PremiseTree } from "./PremiseTree";
20+
21+
const COLUMNS: Array<{ type: ConclusionType; label: string; description: string }> = [
22+
{
23+
type: "explicit",
24+
label: "Explicit",
25+
description: "Surface observations pulled directly from messages",
26+
},
27+
{
28+
type: "deductive",
29+
label: "Deductive",
30+
description: "Logical consequences of explicit observations",
31+
},
32+
{
33+
type: "inductive",
34+
label: "Inductive",
35+
description: "Generalized patterns inferred from deductives",
36+
},
37+
];
38+
39+
interface DreamDetailProps {
40+
dream: Dream;
41+
onClose: () => void;
42+
}
43+
44+
export function DreamDetail({ dream, onClose }: DreamDetailProps) {
45+
const { mask } = useDemo();
46+
const counts = useMemo(() => dreamCounts(dream), [dream]);
47+
const index = useMemo(() => buildPremiseIndex(dream.conclusions), [dream]);
48+
49+
const grouped = useMemo(() => {
50+
const buckets: Record<ConclusionType, ExtendedConclusion[]> = {
51+
explicit: [],
52+
deductive: [],
53+
inductive: [],
54+
};
55+
for (const c of dream.conclusions) {
56+
buckets[inferConclusionType(c)].push(c);
57+
}
58+
return buckets;
59+
}, [dream]);
60+
61+
return (
62+
<motion.section
63+
key={dream.id}
64+
initial={{ opacity: 0, y: 12 }}
65+
animate={{ opacity: 1, y: 0 }}
66+
exit={{ opacity: 0, y: -8 }}
67+
transition={{ duration: 0.18 }}
68+
className="rounded-2xl p-5"
69+
style={{
70+
background: "var(--bg-2)",
71+
border: `1px solid ${COLOR.accentBorder}`,
72+
}}
73+
>
74+
<header className="flex items-start gap-3 mb-5">
75+
<div className="flex-1 min-w-0">
76+
<div className="flex items-center gap-2 flex-wrap mb-1">
77+
<Lightbulb className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
78+
<SectionHeading className="mb-0">Dream detail</SectionHeading>
79+
<TimestampChip value={dream.latestIso.replace("T", " ").replace(/\.\d+Z?$/, "")} />
80+
</div>
81+
<div className="flex items-center gap-2 flex-wrap text-xs">
82+
<Eye className="w-3 h-3" style={{ color: "var(--text-4)" }} strokeWidth={1.5} />
83+
<MonoCaption>{mask(dream.observer_id)}</MonoCaption>
84+
{dream.observed_id && (
85+
<>
86+
<ChevronRight
87+
className="w-3 h-3"
88+
style={{ color: "var(--text-4)" }}
89+
strokeWidth={2}
90+
/>
91+
<MonoCaption>{mask(dream.observed_id)}</MonoCaption>
92+
</>
93+
)}
94+
<span className="mx-1.5" style={{ color: "var(--text-4)" }}>
95+
·
96+
</span>
97+
<Caption>
98+
{counts.total} conclusion{counts.total === 1 ? "" : "s"}
99+
</Caption>
100+
</div>
101+
</div>
102+
<Button variant="ghost" size="icon" onClick={onClose} aria-label="Close dream detail">
103+
<X className="w-4 h-4" strokeWidth={1.5} />
104+
</Button>
105+
</header>
106+
107+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
108+
{COLUMNS.map((col) => (
109+
<ColumnPanel
110+
key={col.type}
111+
type={col.type}
112+
label={col.label}
113+
description={col.description}
114+
conclusions={grouped[col.type]}
115+
index={index}
116+
/>
117+
))}
118+
</div>
119+
</motion.section>
120+
);
121+
}
122+
123+
interface ColumnPanelProps {
124+
type: ConclusionType;
125+
label: string;
126+
description: string;
127+
conclusions: ExtendedConclusion[];
128+
index: Map<string, ExtendedConclusion>;
129+
}
130+
131+
function ColumnPanel({ type, label, description, conclusions, index }: ColumnPanelProps) {
132+
return (
133+
<div
134+
className="rounded-xl p-4 flex flex-col"
135+
style={{
136+
background: "var(--surface)",
137+
border: "1px solid var(--border)",
138+
minHeight: "8rem",
139+
}}
140+
>
141+
<div className="flex items-center gap-2 mb-1">
142+
<ConclusionTypeBadge type={type} />
143+
<span className="text-sm font-semibold" style={{ color: "var(--text-1)" }}>
144+
{label}
145+
</span>
146+
<span
147+
className="ml-auto text-xs font-mono px-1.5 py-0.5 rounded-full"
148+
style={{
149+
background: "var(--surface)",
150+
color: "var(--text-3)",
151+
border: "1px solid var(--border)",
152+
}}
153+
>
154+
{conclusions.length}
155+
</span>
156+
</div>
157+
<Muted className="text-[11px] mb-3">{description}</Muted>
158+
159+
{conclusions.length === 0 ? (
160+
<Caption className="italic">No {label.toLowerCase()} conclusions in this dream.</Caption>
161+
) : (
162+
<ul className="space-y-2.5">
163+
{conclusions.map((c) => (
164+
<li key={c.id}>
165+
<ConclusionCard conclusion={c} index={index} expandable={type === "inductive"} />
166+
</li>
167+
))}
168+
</ul>
169+
)}
170+
</div>
171+
);
172+
}
173+
174+
interface ConclusionCardProps {
175+
conclusion: ExtendedConclusion;
176+
index: Map<string, ExtendedConclusion>;
177+
expandable: boolean;
178+
}
179+
180+
function ConclusionCard({ conclusion, index, expandable }: ConclusionCardProps) {
181+
const { mask } = useDemo();
182+
const [open, setOpen] = useState(false);
183+
const tree = useMemo<PremiseNode | null>(
184+
() => (open ? expandPremiseTree(conclusion.id, index) : null),
185+
[open, conclusion.id, index],
186+
);
187+
const hasPremises = Boolean(
188+
(conclusion.reasoning_tree?.premises?.length ?? 0) > 0 ||
189+
(conclusion.premises?.length ?? 0) > 0,
190+
);
191+
192+
return (
193+
<div
194+
className="rounded-lg p-3 text-xs"
195+
style={{ background: "var(--bg-2)", border: "1px solid var(--border)" }}
196+
>
197+
<Body className="text-xs whitespace-pre-wrap leading-snug mb-2">
198+
{mask(conclusion.content)}
199+
</Body>
200+
<div className="flex items-center justify-between gap-2">
201+
<MonoCaption className="truncate">{mask(conclusion.id)}</MonoCaption>
202+
{expandable && hasPremises && (
203+
<button
204+
type="button"
205+
onClick={() => setOpen((v) => !v)}
206+
className="flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded transition-colors"
207+
style={{
208+
background: open ? COLOR.accentDim : "transparent",
209+
color: open ? "var(--accent-text)" : "var(--text-3)",
210+
border: `1px solid ${open ? COLOR.accentBorder : "var(--border)"}`,
211+
}}
212+
aria-expanded={open}
213+
>
214+
<ChevronRight
215+
className="w-3 h-3 transition-transform"
216+
style={{ transform: open ? "rotate(90deg)" : undefined }}
217+
strokeWidth={2}
218+
/>
219+
{open ? "Hide" : "Show"} premises
220+
</button>
221+
)}
222+
</div>
223+
224+
<AnimatePresence>
225+
{open && tree && (
226+
<motion.div
227+
initial={{ opacity: 0, height: 0 }}
228+
animate={{ opacity: 1, height: "auto" }}
229+
exit={{ opacity: 0, height: 0 }}
230+
transition={{ duration: 0.18 }}
231+
className="overflow-hidden"
232+
>
233+
<div className="mt-3 pt-3" style={{ borderTop: `1px solid ${COLOR.accentBorder}` }}>
234+
<Caption className="mb-2 block">Reasoning chain</Caption>
235+
<PremiseTree root={tree} />
236+
</div>
237+
</motion.div>
238+
)}
239+
</AnimatePresence>
240+
</div>
241+
);
242+
}

0 commit comments

Comments
 (0)