diff --git a/docs/screenshots/dreams-dark-detail.png b/docs/screenshots/dreams-dark-detail.png new file mode 100644 index 0000000..e7b6664 Binary files /dev/null and b/docs/screenshots/dreams-dark-detail.png differ diff --git a/docs/screenshots/dreams-dark-list.png b/docs/screenshots/dreams-dark-list.png new file mode 100644 index 0000000..337c980 Binary files /dev/null and b/docs/screenshots/dreams-dark-list.png differ diff --git a/docs/screenshots/dreams-light-detail.png b/docs/screenshots/dreams-light-detail.png new file mode 100644 index 0000000..bcb2e0b Binary files /dev/null and b/docs/screenshots/dreams-light-detail.png differ diff --git a/docs/screenshots/dreams-light-list.png b/docs/screenshots/dreams-light-list.png new file mode 100644 index 0000000..d599858 Binary files /dev/null and b/docs/screenshots/dreams-light-list.png differ diff --git a/packages/web/e2e/dreams.spec.ts b/packages/web/e2e/dreams.spec.ts new file mode 100644 index 0000000..d9c7c54 --- /dev/null +++ b/packages/web/e2e/dreams.spec.ts @@ -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(); + }); +}); diff --git a/packages/web/src/api/keys.ts b/packages/web/src/api/keys.ts index 87d3572..89d5606 100644 --- a/packages/web/src/api/keys.ts +++ b/packages/web/src/api/keys.ts @@ -32,5 +32,8 @@ export const QK = { conclusionsQuery: (wsId: string, q: string, filters: Record) => ["conclusions-query", wsId, q, filters] as const, + dreams: (wsId: string, filters: Record, limit: number) => + ["dreams", wsId, filters, limit] as const, + webhooks: (wsId: string) => ["webhooks", wsId] as const, }; diff --git a/packages/web/src/api/queries.ts b/packages/web/src/api/queries.ts index d46185b..442304f 100644 --- a/packages/web/src/api/queries.ts +++ b/packages/web/src/api/queries.ts @@ -690,6 +690,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 = {}, + 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) { diff --git a/packages/web/src/components/dreams/DreamDetail.tsx b/packages/web/src/components/dreams/DreamDetail.tsx new file mode 100644 index 0000000..db267e8 --- /dev/null +++ b/packages/web/src/components/dreams/DreamDetail.tsx @@ -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 = { + explicit: [], + deductive: [], + inductive: [], + }; + for (const c of dream.conclusions) { + buckets[inferConclusionType(c)].push(c); + } + return buckets; + }, [dream]); + + return ( + +
+
+
+ + Dream detail + +
+
+ + {mask(dream.observer_id)} + {dream.observed_id && ( + <> + + {mask(dream.observed_id)} + + )} + + · + + + {counts.total} conclusion{counts.total === 1 ? "" : "s"} + +
+
+ +
+ +
+ {COLUMNS.map((col) => ( + + ))} +
+
+ ); +} + +interface ColumnPanelProps { + type: ConclusionType; + label: string; + description: string; + conclusions: ExtendedConclusion[]; + index: Map; +} + +function ColumnPanel({ type, label, description, conclusions, index }: ColumnPanelProps) { + return ( +
+
+ + + {label} + + + {conclusions.length} + +
+ {description} + + {conclusions.length === 0 ? ( + No {label.toLowerCase()} conclusions in this dream. + ) : ( +
    + {conclusions.map((c) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} + +interface ConclusionCardProps { + conclusion: ExtendedConclusion; + index: Map; + expandable: boolean; +} + +function ConclusionCard({ conclusion, index, expandable }: ConclusionCardProps) { + const { mask } = useDemo(); + const [open, setOpen] = useState(false); + const tree = useMemo( + () => (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 ( +
+ + {mask(conclusion.content)} + +
+ {mask(conclusion.id)} + {expandable && hasPremises && ( + + )} +
+ + + {open && tree && ( + +
+ Reasoning chain + +
+
+ )} +
+
+ ); +} diff --git a/packages/web/src/components/dreams/DreamList.tsx b/packages/web/src/components/dreams/DreamList.tsx new file mode 100644 index 0000000..fe9beff --- /dev/null +++ b/packages/web/src/components/dreams/DreamList.tsx @@ -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(null); + + const dreams = useMemo(() => { + 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 ( +
+ + +
+ + Dreams + {dreams.length > 0 && ( + + {dreams.length} + + )} +
+ + Each run produces explicit, deductive, and inductive conclusions for one peer pair. + +
+ + + + {isLoading && } + + {!isLoading && dreams.length === 0 && !error && ( + + )} + + + {selected && ( + + setSelectedId(null)} /> + + )} + + + {dreams.length > 0 && ( +
    + {dreams.map((d, i) => ( + + setSelectedId(d.id === selectedId ? null : d.id)} + /> + + ))} +
+ )} +
+ ); +} + +interface DreamRowProps { + dream: Dream; + active: boolean; + onSelect: () => void; +} + +function DreamRow({ dream, active, onSelect }: DreamRowProps) { + const { mask } = useDemo(); + const counts = useMemo(() => dreamCounts(dream), [dream]); + + return ( + + ); +} + +type ChipKind = "neutral" | "accent" | "warning"; + +function CountChip({ label, value, kind }: { label: string; value: number; kind: ChipKind }) { + const palette: Record = { + 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 ( + + {value} + {label} + + ); +} + +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 ( + + ); +} diff --git a/packages/web/src/components/dreams/PremiseTree.tsx b/packages/web/src/components/dreams/PremiseTree.tsx new file mode 100644 index 0000000..27de2ca --- /dev/null +++ b/packages/web/src/components/dreams/PremiseTree.tsx @@ -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 ( + + {cfg.label} + + ); +} + +interface PremiseTreeProps { + root: PremiseNode; +} + +export function PremiseTree({ root }: PremiseTreeProps) { + if (root.children.length === 0) { + return No upstream premises recorded for this conclusion.; + } + return ( +
    + {root.children.map((child, i) => ( + + ))} +
+ ); +} + +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 ( +
  • +
    +
    + {hasChildren ? ( + + ) : ( + + )} + +
    +
    + {type && } + {node.cycle && ( + + + cycle + + )} + {mask(node.conclusionId)} +
    + {conclusion ? ( +

    + {mask(conclusion.content)} +

    + ) : ( + + Premise not in current page — fetch more conclusions to expand. + + )} +
    +
    +
    + {expanded && hasChildren && ( +
      + {node.children.map((child, i) => ( + + ))} +
    + )} +
  • + ); +} diff --git a/packages/web/src/components/layout/Breadcrumb.tsx b/packages/web/src/components/layout/Breadcrumb.tsx index cbe84b1..f716b35 100644 --- a/packages/web/src/components/layout/Breadcrumb.tsx +++ b/packages/web/src/components/layout/Breadcrumb.tsx @@ -6,6 +6,7 @@ const SECTION_LABELS: Record = { peers: "Peers", sessions: "Sessions", conclusions: "Conclusions", + dreams: "Dreams", webhooks: "Webhooks", chat: "Chat", }; diff --git a/packages/web/src/components/layout/Sidebar.tsx b/packages/web/src/components/layout/Sidebar.tsx index 4615861..74310b6 100644 --- a/packages/web/src/components/layout/Sidebar.tsx +++ b/packages/web/src/components/layout/Sidebar.tsx @@ -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; diff --git a/packages/web/src/lib/dreams.ts b/packages/web/src/lib/dreams.ts new file mode 100644 index 0000000..43a6d41 --- /dev/null +++ b/packages/web/src/lib/dreams.ts @@ -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): 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(); + 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 { + const index = new Map(); + 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, + maxDepth = 8, +): PremiseNode { + return walk(conclusionId, index, new Set(), 0, maxDepth); +} + +function walk( + conclusionId: string, + index: Map, + visited: Set, + 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 }; +} diff --git a/packages/web/src/routeTree.gen.ts b/packages/web/src/routeTree.gen.ts index 934ef2b..c61fbe0 100644 --- a/packages/web/src/routeTree.gen.ts +++ b/packages/web/src/routeTree.gen.ts @@ -17,6 +17,7 @@ import { Route as WorkspacesWorkspaceIdRouteImport } from './routes/workspaces_. import { Route as WorkspacesWorkspaceIdWebhooksRouteImport } from './routes/workspaces_.$workspaceId_.webhooks' import { Route as WorkspacesWorkspaceIdSessionsRouteImport } from './routes/workspaces_.$workspaceId_.sessions' 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' @@ -65,6 +66,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', @@ -97,6 +104,7 @@ export interface FileRoutesByFullPath { '/workspaces': typeof WorkspacesRoute '/workspaces/$workspaceId': typeof WorkspacesWorkspaceIdRoute '/workspaces/$workspaceId/conclusions': typeof WorkspacesWorkspaceIdConclusionsRoute + '/workspaces/$workspaceId/dreams': typeof WorkspacesWorkspaceIdDreamsRoute '/workspaces/$workspaceId/peers': typeof WorkspacesWorkspaceIdPeersRoute '/workspaces/$workspaceId/sessions': typeof WorkspacesWorkspaceIdSessionsRoute '/workspaces/$workspaceId/webhooks': typeof WorkspacesWorkspaceIdWebhooksRoute @@ -111,6 +119,7 @@ export interface FileRoutesByTo { '/workspaces': typeof WorkspacesRoute '/workspaces/$workspaceId': typeof WorkspacesWorkspaceIdRoute '/workspaces/$workspaceId/conclusions': typeof WorkspacesWorkspaceIdConclusionsRoute + '/workspaces/$workspaceId/dreams': typeof WorkspacesWorkspaceIdDreamsRoute '/workspaces/$workspaceId/peers': typeof WorkspacesWorkspaceIdPeersRoute '/workspaces/$workspaceId/sessions': typeof WorkspacesWorkspaceIdSessionsRoute '/workspaces/$workspaceId/webhooks': typeof WorkspacesWorkspaceIdWebhooksRoute @@ -126,6 +135,7 @@ export interface FileRoutesById { '/workspaces': typeof WorkspacesRoute '/workspaces_/$workspaceId': typeof WorkspacesWorkspaceIdRoute '/workspaces_/$workspaceId_/conclusions': typeof WorkspacesWorkspaceIdConclusionsRoute + '/workspaces_/$workspaceId_/dreams': typeof WorkspacesWorkspaceIdDreamsRoute '/workspaces_/$workspaceId_/peers': typeof WorkspacesWorkspaceIdPeersRoute '/workspaces_/$workspaceId_/sessions': typeof WorkspacesWorkspaceIdSessionsRoute '/workspaces_/$workspaceId_/webhooks': typeof WorkspacesWorkspaceIdWebhooksRoute @@ -142,6 +152,7 @@ export interface FileRouteTypes { | '/workspaces' | '/workspaces/$workspaceId' | '/workspaces/$workspaceId/conclusions' + | '/workspaces/$workspaceId/dreams' | '/workspaces/$workspaceId/peers' | '/workspaces/$workspaceId/sessions' | '/workspaces/$workspaceId/webhooks' @@ -156,6 +167,7 @@ export interface FileRouteTypes { | '/workspaces' | '/workspaces/$workspaceId' | '/workspaces/$workspaceId/conclusions' + | '/workspaces/$workspaceId/dreams' | '/workspaces/$workspaceId/peers' | '/workspaces/$workspaceId/sessions' | '/workspaces/$workspaceId/webhooks' @@ -170,6 +182,7 @@ export interface FileRouteTypes { | '/workspaces' | '/workspaces_/$workspaceId' | '/workspaces_/$workspaceId_/conclusions' + | '/workspaces_/$workspaceId_/dreams' | '/workspaces_/$workspaceId_/peers' | '/workspaces_/$workspaceId_/sessions' | '/workspaces_/$workspaceId_/webhooks' @@ -185,6 +198,7 @@ export interface RootRouteChildren { WorkspacesRoute: typeof WorkspacesRoute WorkspacesWorkspaceIdRoute: typeof WorkspacesWorkspaceIdRoute WorkspacesWorkspaceIdConclusionsRoute: typeof WorkspacesWorkspaceIdConclusionsRoute + WorkspacesWorkspaceIdDreamsRoute: typeof WorkspacesWorkspaceIdDreamsRoute WorkspacesWorkspaceIdPeersRoute: typeof WorkspacesWorkspaceIdPeersRoute WorkspacesWorkspaceIdSessionsRoute: typeof WorkspacesWorkspaceIdSessionsRoute WorkspacesWorkspaceIdWebhooksRoute: typeof WorkspacesWorkspaceIdWebhooksRoute @@ -251,6 +265,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' @@ -289,6 +310,7 @@ const rootRouteChildren: RootRouteChildren = { WorkspacesRoute: WorkspacesRoute, WorkspacesWorkspaceIdRoute: WorkspacesWorkspaceIdRoute, WorkspacesWorkspaceIdConclusionsRoute: WorkspacesWorkspaceIdConclusionsRoute, + WorkspacesWorkspaceIdDreamsRoute: WorkspacesWorkspaceIdDreamsRoute, WorkspacesWorkspaceIdPeersRoute: WorkspacesWorkspaceIdPeersRoute, WorkspacesWorkspaceIdSessionsRoute: WorkspacesWorkspaceIdSessionsRoute, WorkspacesWorkspaceIdWebhooksRoute: WorkspacesWorkspaceIdWebhooksRoute, diff --git a/packages/web/src/routes/workspaces_.$workspaceId_.dreams.tsx b/packages/web/src/routes/workspaces_.$workspaceId_.dreams.tsx new file mode 100644 index 0000000..fb1e141 --- /dev/null +++ b/packages/web/src/routes/workspaces_.$workspaceId_.dreams.tsx @@ -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, +}); diff --git a/packages/web/src/test/dreams.test.ts b/packages/web/src/test/dreams.test.ts new file mode 100644 index 0000000..89061b3 --- /dev/null +++ b/packages/web/src/test/dreams.test.ts @@ -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 { + 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"]); + }); +});