Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/components/layout/app-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AddModal } from "@/components/modals/add-modal"
import { BudgetModal } from "@/components/modals/budget-modal"
import { EditNodeModal } from "@/components/modals/edit-node-modal"
import { MediaPlayer } from "@/components/player/media-player"
import { EpisodeBoardOverlay } from "@/components/playable/episode-board-overlay"
import { useDefaultLayout } from "react-resizable-panels"
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable"
import { useSchemaStore } from "@/stores/schema-store"
Expand Down Expand Up @@ -60,6 +61,7 @@ export function AppLayout() {
<EditNodeModal />
<BudgetModal />
<MediaPlayer />
<EpisodeBoardOverlay />
</>
)
}
13 changes: 12 additions & 1 deletion src/components/layout/node-preview-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client"

import { useState, useEffect, useRef, useMemo } from "react"
import { ArrowLeft, Link, Zap, Loader2, Play, Film, ExternalLink, Heart, Repeat2, ChevronDown, ChevronUp, MessageCircle, Quote, Eye, BadgeCheck, AtSign, HeartOff, X, Pencil, FlaskConical, GitMerge, MoreHorizontal, Search } from "lucide-react"
import { ArrowLeft, Link, Zap, Loader2, Play, Film, ExternalLink, Heart, Repeat2, ChevronDown, ChevronUp, MessageCircle, Quote, Eye, BadgeCheck, AtSign, HeartOff, X, Pencil, FlaskConical, GitMerge, MoreHorizontal, Search, LayoutGrid } from "lucide-react"

import { Badge } from "@/components/ui/badge"
import { BoostButton } from "@/components/boost/boost-button"
Expand Down Expand Up @@ -34,6 +34,7 @@ import type { AgentChatContext } from "../agent/transcript-chat"
import { AttachableEmbeds } from "./attachable-embeds"
import { formatDateAbsolute, formatDateRelative } from "@/lib/date-format"
import { useGraphStore } from "@/stores/graph-store"
import { useBoardStore } from "@/stores/board-store"

const DEEP_RESEARCH_NODE_TYPES = ["Topic"]
const ENRICH_NODE_TYPES = ["Person", "Organization", "Product", "Location", "Topic"] as const
Expand Down Expand Up @@ -1184,6 +1185,16 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp
>
{displayNodeType(nodeType)}
</Badge>
{nodeType === "Episode" && (
<button
onClick={() => useBoardStore.getState().openBoard(currentNode.ref_id)}
title="Open the 2D episode board"
className="flex items-center gap-1 h-5 rounded border border-primary/40 bg-primary/10 px-1.5 font-mono text-[9px] uppercase tracking-wider text-primary hover:bg-primary/20 transition-colors"
>
<LayoutGrid className="h-3 w-3" />
2D board
</button>
)}
<div className="ml-auto flex items-center gap-1.5">
{/* Hidden BoostButton — clicked programmatically from the dropdown */}
{ownerReference && !hideBoost && (
Expand Down
22 changes: 22 additions & 0 deletions src/components/playable/__tests__/fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import rawData from "../../../../private/playable-data"
import claimsData from "../../../../private/playable-claims.json"
import crossEdgesData from "../../../../private/playable-cross-edges.json"
import { setBoardData, type BoardEdge, type BoardNode } from "@/lib/board-dataset"

/**
* Board test fixture: one real episode's graph slice, assembled from DB pulls
* kept under `private/`:
* - `playable-data.ts` — base export (episode, chapters, clips, entities)
* - `playable-claims.json` — Claim nodes + SOURCE/SUPPORTS/CONTRADICTS/
* MADE_CLAIM edges
* - `playable-cross-edges.json` — Chapter→Entity MENTIONS edges
*/
export function loadFixture() {
const data = rawData as { nodes: BoardNode[]; edges: BoardEdge[] }
const claims = claimsData as { nodes: BoardNode[]; edges: BoardEdge[] }
const cross = crossEdgesData as unknown as { edges: BoardEdge[] }
setBoardData(
[...data.nodes, ...claims.nodes],
[...data.edges, ...claims.edges, ...cross.edges]
)
}
90 changes: 90 additions & 0 deletions src/components/playable/__tests__/playable-explorer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, it, expect, beforeEach } from "vitest"
import { render, screen, fireEvent } from "@testing-library/react"
import { PlayableExplorer } from "../playable-explorer"
import {
getChapters,
getEntitiesByType,
getClaimRelations,
getChapterMentions,
getTypeCounts,
setBoardData,
} from "@/lib/board-dataset"
import { computeBoardLayout } from "../board-layout"
import { loadFixture } from "./fixture"

beforeEach(() => {
loadFixture()
})

describe("PlayableExplorer (board view)", () => {
it("lays out the full board: episode, chapters, clips and proximity entities", () => {
const layout = computeBoardLayout()
expect(layout.episode).not.toBeNull()
expect(layout.chapters).toHaveLength(getChapters().length)
expect(layout.clips.length).toBeGreaterThan(0)

// every entity got a chip anchored inside the chapter strip's x-span
const entityCount = [...getEntitiesByType().values()].flat().length
expect(layout.entities).toHaveLength(entityCount)
const first = layout.chapters[0].card
const last = layout.chapters[layout.chapters.length - 1].card
for (const e of layout.entities) {
expect(e.card.x).toBeGreaterThanOrEqual(first.x - 1)
expect(e.card.x + e.card.w).toBeLessThanOrEqual(last.x + last.w + 1)
// chips sit in the band above the chapter strip
expect(e.card.y + e.card.h).toBeLessThanOrEqual(layout.chapterBusY)
}

// claims stack under a valid chapter card
expect(layout.claims.length).toBe(21)
const chapterIds = new Set(layout.chapters.map((c) => c.card.id))
for (const c of layout.claims) expect(chapterIds.has(c.chapterId)).toBe(true)

// claim relations pulled from the DB stay within the pulled claim set
const rel = getClaimRelations()
expect(rel.supports.length).toBe(10)
expect(rel.contradicts.length).toBe(2)
expect(rel.madeBy.length).toBe(21)

// chapter→entity mention edges link chapters to nodes already on the board
const mentions = getChapterMentions()
expect(mentions.length).toBe(40)
for (const e of mentions) {
expect(chapterIds.has(e.source)).toBe(true)
}
})

it("renders the type legend and chapter cards", () => {
render(<PlayableExplorer />)
expect(screen.getByText("Topic")).toBeInTheDocument()
expect(screen.getByText("Person")).toBeInTheDocument()
expect(screen.getByText(/chapters/)).toBeInTheDocument()
})

it("opens the detail panel on node click and closes on Escape", () => {
render(<PlayableExplorer />)
// click a chapter card (index badge "00" belongs to first chapter)
const chapterBadge = screen.getByText("00")
fireEvent.click(chapterBadge.closest("button")!)
// detail panel exposes the chapter's source link
expect(screen.getByText("Source")).toBeInTheDocument()
fireEvent.keyDown(window, { key: "Escape" })
expect(screen.queryByText("Source")).not.toBeInTheDocument()
})

it("swaps the active dataset and can restore the fixture", () => {
const fixtureChapterCount = getChapters().length
try {
setBoardData(
[{ ref_id: "ep-x", node_type: "Episode", properties: { episode_title: "X" } }],
[],
"ep-x"
)
expect(getChapters()).toHaveLength(0)
expect(getTypeCounts()).toEqual([["Episode", 1]])
} finally {
loadFixture()
}
expect(getChapters()).toHaveLength(fixtureChapterCount)
})
})
Loading
Loading