Merge pull request #27 from offendingcommit/feat/dream-viewer
feat(web): add dream output viewer
This commit is contained in:
BIN
docs/screenshots/dreams-dark-detail.png
Normal file
BIN
docs/screenshots/dreams-dark-detail.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 352 KiB |
BIN
docs/screenshots/dreams-dark-list.png
Normal file
BIN
docs/screenshots/dreams-dark-list.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 217 KiB |
BIN
docs/screenshots/dreams-light-detail.png
Normal file
BIN
docs/screenshots/dreams-light-detail.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 338 KiB |
BIN
docs/screenshots/dreams-light-list.png
Normal file
BIN
docs/screenshots/dreams-light-list.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 205 KiB |
148
packages/web/e2e/dreams.spec.ts
Normal file
148
packages/web/e2e/dreams.spec.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const STORE_KEY = "openconcho:instances";
|
||||
const STORE_VALUE = JSON.stringify({
|
||||
instances: [{ id: "i1", name: "Local", baseUrl: "http://localhost:9999", token: "" }],
|
||||
activeId: "i1",
|
||||
});
|
||||
|
||||
test.describe("Dreams route", () => {
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await context.addInitScript(
|
||||
([key, value]) => {
|
||||
window.localStorage.setItem(key, value);
|
||||
},
|
||||
[STORE_KEY, STORE_VALUE],
|
||||
);
|
||||
// Stub the conclusions/list endpoint so the route can render real dreams.
|
||||
// :9999 is unreachable; this intercept replaces the network call entirely.
|
||||
// Use a function matcher so the trailing query string (?page=&page_size=) doesn't
|
||||
// break a glob.
|
||||
await context.route(
|
||||
(url) => url.pathname.endsWith("/conclusions/list"),
|
||||
async (route) => {
|
||||
const now = Date.now();
|
||||
const iso = (offsetMs: number) => new Date(now - offsetMs).toISOString();
|
||||
const items = [
|
||||
// Dream A — burst
|
||||
{
|
||||
id: "ind-1",
|
||||
content: "Alice prefers asynchronous communication",
|
||||
observer_id: "alice",
|
||||
observed_id: "bob",
|
||||
session_id: "sess-1",
|
||||
created_at: iso(1000),
|
||||
conclusion_type: "inductive",
|
||||
reasoning_tree: {
|
||||
conclusion_id: "ind-1",
|
||||
premises: [{ conclusion_id: "ded-1" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "ded-1",
|
||||
content: "Alice mentioned email twice and declined two meetings",
|
||||
observer_id: "alice",
|
||||
observed_id: "bob",
|
||||
session_id: "sess-1",
|
||||
created_at: iso(2000),
|
||||
conclusion_type: "deductive",
|
||||
reasoning_tree: {
|
||||
conclusion_id: "ded-1",
|
||||
premises: [{ conclusion_id: "exp-1" }, { conclusion_id: "exp-2" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "exp-1",
|
||||
content: "Alice said 'just email me'",
|
||||
observer_id: "alice",
|
||||
observed_id: "bob",
|
||||
session_id: "sess-1",
|
||||
created_at: iso(3000),
|
||||
conclusion_type: "explicit",
|
||||
},
|
||||
{
|
||||
id: "exp-2",
|
||||
content: "Alice declined the Tuesday standup",
|
||||
observer_id: "alice",
|
||||
observed_id: "bob",
|
||||
session_id: "sess-1",
|
||||
created_at: iso(4000),
|
||||
conclusion_type: "explicit",
|
||||
},
|
||||
// Dream B — 30 minutes ago, different pair → clusters separately
|
||||
{
|
||||
id: "ded-2",
|
||||
content: "Carol responds in the evenings",
|
||||
observer_id: "carol",
|
||||
observed_id: "dan",
|
||||
session_id: "sess-2",
|
||||
created_at: iso(30 * 60_000),
|
||||
conclusion_type: "deductive",
|
||||
},
|
||||
];
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
items,
|
||||
total: items.length,
|
||||
pages: 1,
|
||||
page: 1,
|
||||
size: items.length,
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("shows a Dreams entry in the workspace sub-nav", async ({ page }) => {
|
||||
await page.goto("/workspaces/ws-test/dreams");
|
||||
// Sidebar link with the Dreams label
|
||||
const dreamsLink = page.getByRole("link", { name: /^Dreams$/ });
|
||||
await expect(dreamsLink.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("renders heading and breadcrumb on the dreams route", async ({ page }) => {
|
||||
await page.goto("/workspaces/ws-test/dreams");
|
||||
await expect(page.getByRole("heading", { name: /^Dreams$/ })).toBeVisible();
|
||||
// Breadcrumb specifically — the sidebar has a "Workspaces" link too, so scope.
|
||||
await expect(
|
||||
page.getByLabel("Breadcrumb").getByRole("link", { name: "Workspaces" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("clusters mocked conclusions into dreams and opens detail on click", async ({ page }) => {
|
||||
await page.goto("/workspaces/ws-test/dreams");
|
||||
|
||||
// Two dreams: alice→bob burst, and the older carol→dan
|
||||
const rows = page.locator('button[aria-pressed]');
|
||||
await expect(rows).toHaveCount(2);
|
||||
|
||||
// Alice→bob row should show count chips
|
||||
await expect(rows.first()).toContainText("alice");
|
||||
await expect(rows.first()).toContainText("bob");
|
||||
await expect(rows.first()).toContainText("2 explicit");
|
||||
await expect(rows.first()).toContainText("1 deductive");
|
||||
await expect(rows.first()).toContainText("1 inductive");
|
||||
|
||||
// Click → detail panel renders three columns
|
||||
await rows.first().click();
|
||||
await expect(page.getByText("Dream detail")).toBeVisible();
|
||||
await expect(page.getByText("Explicit", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Deductive", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Inductive", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("expands premise tree for an inductive conclusion", async ({ page }) => {
|
||||
await page.goto("/workspaces/ws-test/dreams");
|
||||
await page.locator('button[aria-pressed]').first().click();
|
||||
|
||||
const showPremises = page.getByRole("button", { name: /^Show premises$/i });
|
||||
await expect(showPremises).toBeVisible();
|
||||
await showPremises.click();
|
||||
|
||||
// The reasoning chain renders with the deductive premise (ded-1)
|
||||
await expect(page.getByText("Reasoning chain")).toBeVisible();
|
||||
await expect(page.getByLabel("Premise tree")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -32,5 +32,8 @@ export const QK = {
|
||||
conclusionsQuery: (wsId: string, q: string, filters: Record<string, unknown>) =>
|
||||
["conclusions-query", wsId, q, filters] as const,
|
||||
|
||||
dreams: (wsId: string, filters: Record<string, unknown>, limit: number) =>
|
||||
["dreams", wsId, filters, limit] as const,
|
||||
|
||||
webhooks: (wsId: string) => ["webhooks", wsId] as const,
|
||||
};
|
||||
|
||||
@@ -706,6 +706,50 @@ export function useDeleteConclusion(workspaceId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Dreams ───────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Dreams are synthetic groupings of conclusions: bursts produced by a single
|
||||
// dream run for one (observer, observed) pair. We fetch a generous batch of
|
||||
// conclusions and let the UI cluster them via `clusterConclusionsIntoDreams`.
|
||||
|
||||
const DREAM_FETCH_PAGE_SIZE = 100;
|
||||
const DREAM_MAX_PAGES = 4;
|
||||
|
||||
export function useDreams(
|
||||
workspaceId: string,
|
||||
filters: Record<string, unknown> = {},
|
||||
limit = DREAM_FETCH_PAGE_SIZE * DREAM_MAX_PAGES,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: QK.dreams(workspaceId, filters, limit),
|
||||
queryFn: async () => {
|
||||
const collected: unknown[] = [];
|
||||
const pageSize = Math.min(DREAM_FETCH_PAGE_SIZE, limit);
|
||||
let page = 1;
|
||||
while (collected.length < limit) {
|
||||
const { data, error } = await client.current.POST(
|
||||
"/v3/workspaces/{workspace_id}/conclusions/list",
|
||||
{
|
||||
params: {
|
||||
path: { workspace_id: workspaceId },
|
||||
query: { page, page_size: pageSize, reverse: false },
|
||||
},
|
||||
body: filters,
|
||||
},
|
||||
);
|
||||
if (error) err(error);
|
||||
const items = (data as { items?: unknown[] } | undefined)?.items ?? [];
|
||||
const totalPages = (data as { pages?: number } | undefined)?.pages ?? 1;
|
||||
collected.push(...items);
|
||||
if (items.length === 0 || page >= totalPages || page >= DREAM_MAX_PAGES) break;
|
||||
page++;
|
||||
}
|
||||
return collected.slice(0, limit);
|
||||
},
|
||||
enabled: Boolean(workspaceId),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Webhooks ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useWebhooks(workspaceId: string) {
|
||||
|
||||
242
packages/web/src/components/dreams/DreamDetail.tsx
Normal file
242
packages/web/src/components/dreams/DreamDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
241
packages/web/src/components/dreams/DreamList.tsx
Normal file
241
packages/web/src/components/dreams/DreamList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
143
packages/web/src/components/dreams/PremiseTree.tsx
Normal file
143
packages/web/src/components/dreams/PremiseTree.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ const SECTION_LABELS: Record<string, string> = {
|
||||
peers: "Peers",
|
||||
sessions: "Sessions",
|
||||
conclusions: "Conclusions",
|
||||
dreams: "Dreams",
|
||||
webhooks: "Webhooks",
|
||||
chat: "Chat",
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Lightbulb,
|
||||
MessageSquare,
|
||||
Moon,
|
||||
MoonStar,
|
||||
Settings,
|
||||
Sun,
|
||||
Users,
|
||||
@@ -39,6 +40,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;
|
||||
|
||||
|
||||
196
packages/web/src/lib/dreams.ts
Normal file
196
packages/web/src/lib/dreams.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import type { components } from "@/api/schema.d.ts";
|
||||
|
||||
type ApiConclusion = components["schemas"]["Conclusion"];
|
||||
|
||||
export type ConclusionType = "explicit" | "deductive" | "inductive";
|
||||
|
||||
export const CONCLUSION_TYPES: readonly ConclusionType[] = [
|
||||
"explicit",
|
||||
"deductive",
|
||||
"inductive",
|
||||
] as const;
|
||||
|
||||
// The generated OpenAPI schema does not yet expose `conclusion_type`, `premises`, or
|
||||
// `reasoning_tree` (Honcho migration f1a2b3c4d5e6 added the columns but the response
|
||||
// schema hasn't been regenerated client-side). We declare them as optional here so
|
||||
// the UI consumes them when present and degrades gracefully when absent.
|
||||
export type ExtendedConclusion = ApiConclusion & {
|
||||
conclusion_type?: ConclusionType | null;
|
||||
premises?: string[] | null;
|
||||
reasoning_tree?: ReasoningTreeNode | null;
|
||||
};
|
||||
|
||||
export interface ReasoningTreeNode {
|
||||
conclusion_id: string;
|
||||
premises?: ReasoningTreeNode[];
|
||||
}
|
||||
|
||||
export interface Dream {
|
||||
id: string;
|
||||
observer_id: string;
|
||||
observed_id: string | null;
|
||||
session_id: string | null;
|
||||
earliestMs: number;
|
||||
latestMs: number;
|
||||
earliestIso: string;
|
||||
latestIso: string;
|
||||
conclusions: ExtendedConclusion[];
|
||||
}
|
||||
|
||||
export interface DreamCounts {
|
||||
explicit: number;
|
||||
deductive: number;
|
||||
inductive: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ClusterOptions {
|
||||
/** Max gap (ms) between adjacent conclusions in the same dream. Defaults to 60s. */
|
||||
gapMs?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_GAP_MS = 60_000;
|
||||
|
||||
export function inferConclusionType(c: ExtendedConclusion): ConclusionType {
|
||||
return c.conclusion_type ?? "explicit";
|
||||
}
|
||||
|
||||
export function dreamCounts(dream: Pick<Dream, "conclusions">): DreamCounts {
|
||||
const counts: DreamCounts = { explicit: 0, deductive: 0, inductive: 0, total: 0 };
|
||||
for (const c of dream.conclusions) {
|
||||
counts[inferConclusionType(c)]++;
|
||||
counts.total++;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function parseMs(iso: string): number {
|
||||
const t = Date.parse(iso);
|
||||
return Number.isFinite(t) ? t : 0;
|
||||
}
|
||||
|
||||
function dreamIdFor(observer: string, observed: string | null, earliestIso: string): string {
|
||||
return `${observer}|${observed ?? ""}|${earliestIso}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group conclusions into "dreams" — bursts of conclusions for the same
|
||||
* (observer, observed) pair within a short time window.
|
||||
*
|
||||
* Algorithm: walk conclusions newest-first; for each peer pair, keep an "open"
|
||||
* dream open as long as the next conclusion is within `gapMs` of the oldest
|
||||
* timestamp in the dream. When the gap exceeds `gapMs`, close the dream and
|
||||
* start a new one for that pair.
|
||||
*/
|
||||
export function clusterConclusionsIntoDreams(
|
||||
conclusions: ExtendedConclusion[],
|
||||
options: ClusterOptions = {},
|
||||
): Dream[] {
|
||||
const gapMs = options.gapMs ?? DEFAULT_GAP_MS;
|
||||
if (conclusions.length === 0) return [];
|
||||
|
||||
const sorted = [...conclusions].sort((a, b) => parseMs(b.created_at) - parseMs(a.created_at));
|
||||
const openByPair = new Map<string, Dream>();
|
||||
const result: Dream[] = [];
|
||||
|
||||
for (const c of sorted) {
|
||||
const observed = c.observed_id ?? null;
|
||||
const pairKey = `${c.observer_id}::${observed ?? ""}`;
|
||||
const t = parseMs(c.created_at);
|
||||
const open = openByPair.get(pairKey);
|
||||
|
||||
if (open && open.earliestMs - t <= gapMs) {
|
||||
open.conclusions.push(c);
|
||||
if (t < open.earliestMs) {
|
||||
open.earliestMs = t;
|
||||
open.earliestIso = c.created_at;
|
||||
open.id = dreamIdFor(c.observer_id, observed, c.created_at);
|
||||
}
|
||||
if (t > open.latestMs) {
|
||||
open.latestMs = t;
|
||||
open.latestIso = c.created_at;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const dream: Dream = {
|
||||
id: dreamIdFor(c.observer_id, observed, c.created_at),
|
||||
observer_id: c.observer_id,
|
||||
observed_id: observed,
|
||||
session_id: c.session_id ?? null,
|
||||
earliestMs: t,
|
||||
latestMs: t,
|
||||
earliestIso: c.created_at,
|
||||
latestIso: c.created_at,
|
||||
conclusions: [c],
|
||||
};
|
||||
openByPair.set(pairKey, dream);
|
||||
result.push(dream);
|
||||
}
|
||||
|
||||
return result.sort((a, b) => b.latestMs - a.latestMs);
|
||||
}
|
||||
|
||||
export function buildPremiseIndex(
|
||||
conclusions: ExtendedConclusion[],
|
||||
): Map<string, ExtendedConclusion> {
|
||||
const index = new Map<string, ExtendedConclusion>();
|
||||
for (const c of conclusions) index.set(c.id, c);
|
||||
return index;
|
||||
}
|
||||
|
||||
export interface PremiseNode {
|
||||
conclusion: ExtendedConclusion | null;
|
||||
conclusionId: string;
|
||||
depth: number;
|
||||
children: PremiseNode[];
|
||||
cycle: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand the premise tree for a conclusion. Walks `reasoning_tree` if present,
|
||||
* otherwise falls back to the flat `premises` ID list. Cycle-safe via a visited
|
||||
* set; `maxDepth` defaults to 8.
|
||||
*/
|
||||
export function expandPremiseTree(
|
||||
conclusionId: string,
|
||||
index: Map<string, ExtendedConclusion>,
|
||||
maxDepth = 8,
|
||||
): PremiseNode {
|
||||
return walk(conclusionId, index, new Set<string>(), 0, maxDepth);
|
||||
}
|
||||
|
||||
function walk(
|
||||
conclusionId: string,
|
||||
index: Map<string, ExtendedConclusion>,
|
||||
visited: Set<string>,
|
||||
depth: number,
|
||||
maxDepth: number,
|
||||
): PremiseNode {
|
||||
if (visited.has(conclusionId)) {
|
||||
return {
|
||||
conclusion: index.get(conclusionId) ?? null,
|
||||
conclusionId,
|
||||
depth,
|
||||
children: [],
|
||||
cycle: true,
|
||||
};
|
||||
}
|
||||
const conclusion = index.get(conclusionId) ?? null;
|
||||
if (!conclusion || depth >= maxDepth) {
|
||||
return { conclusion, conclusionId, depth, children: [], cycle: false };
|
||||
}
|
||||
|
||||
const nextVisited = new Set(visited);
|
||||
nextVisited.add(conclusionId);
|
||||
|
||||
let children: PremiseNode[] = [];
|
||||
if (conclusion.reasoning_tree?.premises?.length) {
|
||||
children = conclusion.reasoning_tree.premises.map((node) =>
|
||||
walk(node.conclusion_id, index, nextVisited, depth + 1, maxDepth),
|
||||
);
|
||||
} else if (conclusion.premises?.length) {
|
||||
children = conclusion.premises.map((id) => walk(id, index, nextVisited, depth + 1, maxDepth));
|
||||
}
|
||||
return { conclusion, conclusionId, depth, children, cycle: false };
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { Route as WorkspacesWorkspaceIdWebhooksRouteImport } from './routes/work
|
||||
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 WorkspacesWorkspaceIdDreamsRouteImport } from './routes/workspaces_.$workspaceId_.dreams'
|
||||
import { Route as WorkspacesWorkspaceIdConclusionsRouteImport } from './routes/workspaces_.$workspaceId_.conclusions'
|
||||
import { Route as WorkspacesWorkspaceIdSessionsSessionIdRouteImport } from './routes/workspaces_.$workspaceId_.sessions_.$sessionId'
|
||||
import { Route as WorkspacesWorkspaceIdPeersPeerIdRouteImport } from './routes/workspaces_.$workspaceId_.peers_.$peerId'
|
||||
@@ -84,6 +85,12 @@ const WorkspacesWorkspaceIdPeersRoute =
|
||||
path: '/workspaces/$workspaceId/peers',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const WorkspacesWorkspaceIdDreamsRoute =
|
||||
WorkspacesWorkspaceIdDreamsRouteImport.update({
|
||||
id: '/workspaces_/$workspaceId_/dreams',
|
||||
path: '/workspaces/$workspaceId/dreams',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const WorkspacesWorkspaceIdConclusionsRoute =
|
||||
WorkspacesWorkspaceIdConclusionsRouteImport.update({
|
||||
id: '/workspaces_/$workspaceId_/conclusions',
|
||||
@@ -118,6 +125,7 @@ export interface FileRoutesByFullPath {
|
||||
'/dream-progress': typeof DevDreamProgressRoute
|
||||
'/workspaces/$workspaceId': typeof WorkspacesWorkspaceIdRoute
|
||||
'/workspaces/$workspaceId/conclusions': typeof WorkspacesWorkspaceIdConclusionsRoute
|
||||
'/workspaces/$workspaceId/dreams': typeof WorkspacesWorkspaceIdDreamsRoute
|
||||
'/workspaces/$workspaceId/peers': typeof WorkspacesWorkspaceIdPeersRoute
|
||||
'/workspaces/$workspaceId/queue': typeof WorkspacesWorkspaceIdQueueRoute
|
||||
'/workspaces/$workspaceId/sessions': typeof WorkspacesWorkspaceIdSessionsRoute
|
||||
@@ -135,6 +143,7 @@ export interface FileRoutesByTo {
|
||||
'/dream-progress': typeof DevDreamProgressRoute
|
||||
'/workspaces/$workspaceId': typeof WorkspacesWorkspaceIdRoute
|
||||
'/workspaces/$workspaceId/conclusions': typeof WorkspacesWorkspaceIdConclusionsRoute
|
||||
'/workspaces/$workspaceId/dreams': typeof WorkspacesWorkspaceIdDreamsRoute
|
||||
'/workspaces/$workspaceId/peers': typeof WorkspacesWorkspaceIdPeersRoute
|
||||
'/workspaces/$workspaceId/queue': typeof WorkspacesWorkspaceIdQueueRoute
|
||||
'/workspaces/$workspaceId/sessions': typeof WorkspacesWorkspaceIdSessionsRoute
|
||||
@@ -153,6 +162,7 @@ export interface FileRoutesById {
|
||||
'/_dev/dream-progress': typeof DevDreamProgressRoute
|
||||
'/workspaces_/$workspaceId': typeof WorkspacesWorkspaceIdRoute
|
||||
'/workspaces_/$workspaceId_/conclusions': typeof WorkspacesWorkspaceIdConclusionsRoute
|
||||
'/workspaces_/$workspaceId_/dreams': typeof WorkspacesWorkspaceIdDreamsRoute
|
||||
'/workspaces_/$workspaceId_/peers': typeof WorkspacesWorkspaceIdPeersRoute
|
||||
'/workspaces_/$workspaceId_/queue': typeof WorkspacesWorkspaceIdQueueRoute
|
||||
'/workspaces_/$workspaceId_/sessions': typeof WorkspacesWorkspaceIdSessionsRoute
|
||||
@@ -172,6 +182,7 @@ export interface FileRouteTypes {
|
||||
| '/dream-progress'
|
||||
| '/workspaces/$workspaceId'
|
||||
| '/workspaces/$workspaceId/conclusions'
|
||||
| '/workspaces/$workspaceId/dreams'
|
||||
| '/workspaces/$workspaceId/peers'
|
||||
| '/workspaces/$workspaceId/queue'
|
||||
| '/workspaces/$workspaceId/sessions'
|
||||
@@ -189,6 +200,7 @@ export interface FileRouteTypes {
|
||||
| '/dream-progress'
|
||||
| '/workspaces/$workspaceId'
|
||||
| '/workspaces/$workspaceId/conclusions'
|
||||
| '/workspaces/$workspaceId/dreams'
|
||||
| '/workspaces/$workspaceId/peers'
|
||||
| '/workspaces/$workspaceId/queue'
|
||||
| '/workspaces/$workspaceId/sessions'
|
||||
@@ -206,6 +218,7 @@ export interface FileRouteTypes {
|
||||
| '/_dev/dream-progress'
|
||||
| '/workspaces_/$workspaceId'
|
||||
| '/workspaces_/$workspaceId_/conclusions'
|
||||
| '/workspaces_/$workspaceId_/dreams'
|
||||
| '/workspaces_/$workspaceId_/peers'
|
||||
| '/workspaces_/$workspaceId_/queue'
|
||||
| '/workspaces_/$workspaceId_/sessions'
|
||||
@@ -224,6 +237,7 @@ export interface RootRouteChildren {
|
||||
DevDreamProgressRoute: typeof DevDreamProgressRoute
|
||||
WorkspacesWorkspaceIdRoute: typeof WorkspacesWorkspaceIdRoute
|
||||
WorkspacesWorkspaceIdConclusionsRoute: typeof WorkspacesWorkspaceIdConclusionsRoute
|
||||
WorkspacesWorkspaceIdDreamsRoute: typeof WorkspacesWorkspaceIdDreamsRoute
|
||||
WorkspacesWorkspaceIdPeersRoute: typeof WorkspacesWorkspaceIdPeersRoute
|
||||
WorkspacesWorkspaceIdQueueRoute: typeof WorkspacesWorkspaceIdQueueRoute
|
||||
WorkspacesWorkspaceIdSessionsRoute: typeof WorkspacesWorkspaceIdSessionsRoute
|
||||
@@ -312,6 +326,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof WorkspacesWorkspaceIdPeersRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/workspaces_/$workspaceId_/dreams': {
|
||||
id: '/workspaces_/$workspaceId_/dreams'
|
||||
path: '/workspaces/$workspaceId/dreams'
|
||||
fullPath: '/workspaces/$workspaceId/dreams'
|
||||
preLoaderRoute: typeof WorkspacesWorkspaceIdDreamsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/workspaces_/$workspaceId_/conclusions': {
|
||||
id: '/workspaces_/$workspaceId_/conclusions'
|
||||
path: '/workspaces/$workspaceId/conclusions'
|
||||
@@ -352,6 +373,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
DevDreamProgressRoute: DevDreamProgressRoute,
|
||||
WorkspacesWorkspaceIdRoute: WorkspacesWorkspaceIdRoute,
|
||||
WorkspacesWorkspaceIdConclusionsRoute: WorkspacesWorkspaceIdConclusionsRoute,
|
||||
WorkspacesWorkspaceIdDreamsRoute: WorkspacesWorkspaceIdDreamsRoute,
|
||||
WorkspacesWorkspaceIdPeersRoute: WorkspacesWorkspaceIdPeersRoute,
|
||||
WorkspacesWorkspaceIdQueueRoute: WorkspacesWorkspaceIdQueueRoute,
|
||||
WorkspacesWorkspaceIdSessionsRoute: WorkspacesWorkspaceIdSessionsRoute,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { DreamList } from "@/components/dreams/DreamList";
|
||||
|
||||
export const Route = createFileRoute("/workspaces_/$workspaceId_/dreams")({
|
||||
component: DreamList,
|
||||
});
|
||||
240
packages/web/src/test/dreams.test.ts
Normal file
240
packages/web/src/test/dreams.test.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildPremiseIndex,
|
||||
clusterConclusionsIntoDreams,
|
||||
dreamCounts,
|
||||
type ExtendedConclusion,
|
||||
expandPremiseTree,
|
||||
} from "@/lib/dreams";
|
||||
|
||||
// Helpers ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function mkConclusion(
|
||||
id: string,
|
||||
createdAt: string,
|
||||
overrides: Partial<ExtendedConclusion> = {},
|
||||
): ExtendedConclusion {
|
||||
return {
|
||||
id,
|
||||
content: `conclusion ${id}`,
|
||||
observer_id: "observer-a",
|
||||
observed_id: "observed-b",
|
||||
session_id: null,
|
||||
created_at: createdAt,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function iso(secondsFromZero: number): string {
|
||||
const base = Date.UTC(2026, 0, 1, 0, 0, 0); // 2026-01-01T00:00:00Z
|
||||
return new Date(base + secondsFromZero * 1000).toISOString();
|
||||
}
|
||||
|
||||
// Clustering ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("clusterConclusionsIntoDreams", () => {
|
||||
it("returns no dreams for empty input", () => {
|
||||
expect(clusterConclusionsIntoDreams([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("groups conclusions within the default 60s window into a single dream", () => {
|
||||
const burst = [
|
||||
mkConclusion("c1", iso(0)),
|
||||
mkConclusion("c2", iso(5)),
|
||||
mkConclusion("c3", iso(15)),
|
||||
mkConclusion("c4", iso(55)),
|
||||
];
|
||||
|
||||
const dreams = clusterConclusionsIntoDreams(burst);
|
||||
|
||||
expect(dreams).toHaveLength(1);
|
||||
expect(dreams[0].conclusions).toHaveLength(4);
|
||||
expect(dreams[0].observer_id).toBe("observer-a");
|
||||
expect(dreams[0].observed_id).toBe("observed-b");
|
||||
expect(dreams[0].earliestIso).toBe(iso(0));
|
||||
expect(dreams[0].latestIso).toBe(iso(55));
|
||||
});
|
||||
|
||||
it("starts a new dream when the gap exceeds the threshold", () => {
|
||||
const conclusions = [
|
||||
mkConclusion("a", iso(0)),
|
||||
mkConclusion("b", iso(20)),
|
||||
// 5 minutes later → new dream
|
||||
mkConclusion("c", iso(20 + 5 * 60)),
|
||||
mkConclusion("d", iso(20 + 5 * 60 + 10)),
|
||||
];
|
||||
|
||||
const dreams = clusterConclusionsIntoDreams(conclusions);
|
||||
|
||||
expect(dreams).toHaveLength(2);
|
||||
// Sorted by latest descending — the newer dream comes first.
|
||||
expect(dreams[0].conclusions.map((c) => c.id).sort()).toEqual(["c", "d"]);
|
||||
expect(dreams[1].conclusions.map((c) => c.id).sort()).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("separates dreams by (observer, observed) pair even when timestamps overlap", () => {
|
||||
const conclusions = [
|
||||
mkConclusion("a1", iso(0), { observer_id: "alice", observed_id: "bob" }),
|
||||
mkConclusion("a2", iso(5), { observer_id: "alice", observed_id: "bob" }),
|
||||
mkConclusion("c1", iso(2), { observer_id: "carol", observed_id: "dan" }),
|
||||
mkConclusion("c2", iso(7), { observer_id: "carol", observed_id: "dan" }),
|
||||
];
|
||||
|
||||
const dreams = clusterConclusionsIntoDreams(conclusions);
|
||||
|
||||
expect(dreams).toHaveLength(2);
|
||||
const aliceDream = dreams.find((d) => d.observer_id === "alice");
|
||||
const carolDream = dreams.find((d) => d.observer_id === "carol");
|
||||
expect(aliceDream?.conclusions).toHaveLength(2);
|
||||
expect(carolDream?.conclusions).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("respects a custom gap window", () => {
|
||||
const conclusions = [
|
||||
mkConclusion("a", iso(0)),
|
||||
mkConclusion("b", iso(120)), // 2 minutes apart
|
||||
];
|
||||
|
||||
const tight = clusterConclusionsIntoDreams(conclusions, { gapMs: 60_000 });
|
||||
expect(tight).toHaveLength(2);
|
||||
|
||||
const loose = clusterConclusionsIntoDreams(conclusions, { gapMs: 5 * 60_000 });
|
||||
expect(loose).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("sorts dreams newest-first by latest timestamp", () => {
|
||||
const olderTs = iso(0);
|
||||
const newerTs = iso(10 * 60); // 10 minutes later
|
||||
const dreams = clusterConclusionsIntoDreams([
|
||||
mkConclusion("old", olderTs),
|
||||
mkConclusion("new", newerTs),
|
||||
]);
|
||||
expect(dreams).toHaveLength(2);
|
||||
expect(dreams[0].latestIso).toBe(newerTs);
|
||||
expect(dreams[1].latestIso).toBe(olderTs);
|
||||
expect(dreams[0].latestMs).toBeGreaterThan(dreams[1].latestMs);
|
||||
});
|
||||
|
||||
it("computes counts by inferred conclusion_type, defaulting unknown to explicit", () => {
|
||||
const conclusions = [
|
||||
mkConclusion("c1", iso(0)),
|
||||
mkConclusion("c2", iso(2), { conclusion_type: "deductive" }),
|
||||
mkConclusion("c3", iso(4), { conclusion_type: "deductive" }),
|
||||
mkConclusion("c4", iso(6), { conclusion_type: "inductive" }),
|
||||
];
|
||||
const [dream] = clusterConclusionsIntoDreams(conclusions);
|
||||
expect(dreamCounts(dream)).toEqual({
|
||||
explicit: 1, // c1 has no type → defaults to explicit
|
||||
deductive: 2,
|
||||
inductive: 1,
|
||||
total: 4,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Premise tree ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("expandPremiseTree", () => {
|
||||
it("returns an empty tree when the conclusion has no premises", () => {
|
||||
const c = mkConclusion("solo", iso(0));
|
||||
const index = buildPremiseIndex([c]);
|
||||
const tree = expandPremiseTree("solo", index);
|
||||
expect(tree.children).toEqual([]);
|
||||
expect(tree.conclusion?.id).toBe("solo");
|
||||
});
|
||||
|
||||
it("expands a flat premises list to direct children", () => {
|
||||
const p1 = mkConclusion("p1", iso(0), { conclusion_type: "explicit" });
|
||||
const p2 = mkConclusion("p2", iso(1), { conclusion_type: "explicit" });
|
||||
const top = mkConclusion("top", iso(5), {
|
||||
conclusion_type: "inductive",
|
||||
premises: ["p1", "p2"],
|
||||
});
|
||||
const index = buildPremiseIndex([p1, p2, top]);
|
||||
|
||||
const tree = expandPremiseTree("top", index);
|
||||
expect(tree.children.map((n) => n.conclusionId)).toEqual(["p1", "p2"]);
|
||||
expect(tree.children.every((n) => n.conclusion !== null)).toBe(true);
|
||||
});
|
||||
|
||||
it("walks a multi-level reasoning_tree recursively", () => {
|
||||
const e1 = mkConclusion("e1", iso(0), { conclusion_type: "explicit" });
|
||||
const e2 = mkConclusion("e2", iso(1), { conclusion_type: "explicit" });
|
||||
const d1 = mkConclusion("d1", iso(2), {
|
||||
conclusion_type: "deductive",
|
||||
reasoning_tree: {
|
||||
conclusion_id: "d1",
|
||||
premises: [{ conclusion_id: "e1" }, { conclusion_id: "e2" }],
|
||||
},
|
||||
});
|
||||
const ind = mkConclusion("ind", iso(3), {
|
||||
conclusion_type: "inductive",
|
||||
reasoning_tree: {
|
||||
conclusion_id: "ind",
|
||||
premises: [{ conclusion_id: "d1" }],
|
||||
},
|
||||
});
|
||||
const index = buildPremiseIndex([e1, e2, d1, ind]);
|
||||
|
||||
const tree = expandPremiseTree("ind", index);
|
||||
expect(tree.children).toHaveLength(1);
|
||||
const deductive = tree.children[0];
|
||||
expect(deductive.conclusionId).toBe("d1");
|
||||
expect(deductive.children.map((n) => n.conclusionId).sort()).toEqual(["e1", "e2"]);
|
||||
});
|
||||
|
||||
it("flags missing premises (e.g., outside the loaded page) without throwing", () => {
|
||||
const top = mkConclusion("top", iso(5), { premises: ["missing"] });
|
||||
const index = buildPremiseIndex([top]);
|
||||
const tree = expandPremiseTree("top", index);
|
||||
expect(tree.children).toHaveLength(1);
|
||||
expect(tree.children[0].conclusion).toBeNull();
|
||||
expect(tree.children[0].conclusionId).toBe("missing");
|
||||
});
|
||||
|
||||
it("detects cycles and stops recursion", () => {
|
||||
const a = mkConclusion("a", iso(0), { premises: ["b"] });
|
||||
const b = mkConclusion("b", iso(1), { premises: ["a"] });
|
||||
const index = buildPremiseIndex([a, b]);
|
||||
const tree = expandPremiseTree("a", index);
|
||||
|
||||
// a → b → a(cycle)
|
||||
expect(tree.children).toHaveLength(1);
|
||||
const bNode = tree.children[0];
|
||||
expect(bNode.conclusionId).toBe("b");
|
||||
expect(bNode.children).toHaveLength(1);
|
||||
const cycleNode = bNode.children[0];
|
||||
expect(cycleNode.conclusionId).toBe("a");
|
||||
expect(cycleNode.cycle).toBe(true);
|
||||
expect(cycleNode.children).toEqual([]);
|
||||
});
|
||||
|
||||
it("stops recursion at maxDepth", () => {
|
||||
// a → b → c → d, expand with maxDepth=2: tree depth should not exceed 2
|
||||
const d = mkConclusion("d", iso(0));
|
||||
const c = mkConclusion("c", iso(1), { premises: ["d"] });
|
||||
const b = mkConclusion("b", iso(2), { premises: ["c"] });
|
||||
const a = mkConclusion("a", iso(3), { premises: ["b"] });
|
||||
const index = buildPremiseIndex([a, b, c, d]);
|
||||
|
||||
const tree = expandPremiseTree("a", index, 2);
|
||||
// depth 0: a, depth 1: b, depth 2: c (no further children)
|
||||
expect(tree.conclusionId).toBe("a");
|
||||
expect(tree.children[0].conclusionId).toBe("b");
|
||||
expect(tree.children[0].children[0].conclusionId).toBe("c");
|
||||
expect(tree.children[0].children[0].children).toEqual([]);
|
||||
});
|
||||
|
||||
it("prefers reasoning_tree over flat premises when both are present", () => {
|
||||
const e1 = mkConclusion("e1", iso(0));
|
||||
const e2 = mkConclusion("e2", iso(0));
|
||||
const top = mkConclusion("top", iso(5), {
|
||||
premises: ["e1"], // flat says one
|
||||
reasoning_tree: { conclusion_id: "top", premises: [{ conclusion_id: "e2" }] }, // tree says another
|
||||
});
|
||||
const index = buildPremiseIndex([e1, e2, top]);
|
||||
|
||||
const tree = expandPremiseTree("top", index);
|
||||
expect(tree.children.map((n) => n.conclusionId)).toEqual(["e2"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user