diff --git a/.changeset/state-explorer-panel.md b/.changeset/state-explorer-panel.md new file mode 100644 index 0000000000..e80f0e14aa --- /dev/null +++ b/.changeset/state-explorer-panel.md @@ -0,0 +1,5 @@ +--- +"@electric-ax/agents-server": minor +--- + +Add state explorer panel to entity view with real-time StreamDB state visualization, time-travel through events, and jump-to-bottom button on timelines diff --git a/packages/agents-server-ui/package.json b/packages/agents-server-ui/package.json index 2cffe21d6b..4fb34b2320 100644 --- a/packages/agents-server-ui/package.json +++ b/packages/agents-server-ui/package.json @@ -12,8 +12,11 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@durable-streams/client": "npm:@electric-ax/durable-streams-client-beta@^0.3.0", + "@durable-streams/state": "npm:@electric-ax/durable-streams-state-beta@^0.3.0", "@electric-ax/agents-runtime": "workspace:*", "@radix-ui/themes": "^3.3.0", + "@tanstack/react-table": "^8.21.3", "@tanstack/db": "^0.6.4", "@tanstack/electric-db-collection": "^0.3.2", "@tanstack/react-db": "^0.1.82", diff --git a/packages/agents-server-ui/src/components/EntityHeader.tsx b/packages/agents-server-ui/src/components/EntityHeader.tsx index 95fd3e7e8f..a498852dbd 100644 --- a/packages/agents-server-ui/src/components/EntityHeader.tsx +++ b/packages/agents-server-ui/src/components/EntityHeader.tsx @@ -7,7 +7,15 @@ import { Flex, Text, } from '@radix-ui/themes' -import { Copy, Eye, MoreHorizontal, Pin, PinOff, Trash2 } from 'lucide-react' +import { + Copy, + Database, + Eye, + MoreHorizontal, + Pin, + PinOff, + Trash2, +} from 'lucide-react' import { getEntityInstanceName } from '../lib/types' import type { ElectricEntity } from '../lib/ElectricAgentsProvider' @@ -25,12 +33,16 @@ export function EntityHeader({ onTogglePin, onKill, killError, + stateExplorerOpen, + onToggleStateExplorer, }: { entity: ElectricEntity pinned: boolean onTogglePin: () => void onKill: () => void killError?: string | null + stateExplorerOpen?: boolean + onToggleStateExplorer?: () => void }): React.ReactElement { const [showInspect, setShowInspect] = useState(false) const [showKillConfirm, setShowKillConfirm] = useState(false) @@ -66,6 +78,20 @@ export function EntityHeader({ {entity.status} + {onToggleStateExplorer && ( + + )} + @@ -83,6 +109,18 @@ export function EntityHeader({ Inspect + {onToggleStateExplorer && ( + + + + + {stateExplorerOpen + ? `Hide State Explorer` + : `State Explorer`} + + + + )} navigator.clipboard.writeText(entity.url)} > diff --git a/packages/agents-server-ui/src/components/EntityTimeline.tsx b/packages/agents-server-ui/src/components/EntityTimeline.tsx index a9fa21934a..79fa1841cb 100644 --- a/packages/agents-server-ui/src/components/EntityTimeline.tsx +++ b/packages/agents-server-ui/src/components/EntityTimeline.tsx @@ -11,7 +11,8 @@ import { measureElement as defaultMeasureElement, useVirtualizer, } from '@tanstack/react-virtual' -import { Flex, ScrollArea, Text } from '@radix-ui/themes' +import { Flex, IconButton, ScrollArea, Text } from '@radix-ui/themes' +import { ArrowDown } from 'lucide-react' import { loadTimelineRowHeights, persistTimelineRowHeights, @@ -99,6 +100,7 @@ export function EntityTimeline({ const [viewportWidth, setViewportWidth] = useState(0) const [contentWidth, setContentWidth] = useState(0) const isNearBottom = useRef(true) + const [showJumpToBottom, setShowJumpToBottom] = useState(false) const cachedSizeMapRef = useRef(new Map()) const lastMeasureAtRef = useRef(new Map()) const settledKeysRef = useRef(new Set()) @@ -229,7 +231,16 @@ export function EntityTimeline({ if (!viewport) return const updateViewportWidth = () => { - setViewportWidth(Math.round(viewport.clientWidth)) + const w = Math.round(viewport.clientWidth) + setViewportWidth((prev) => { + if (prev !== w && prev > 0) { + // Container resized (e.g. state explorer toggled) — clear settled + // cache so virtualizer re-measures all rows at the new width. + settledKeysRef.current = new Set() + cachedSizeMapRef.current = new Map() + } + return w + }) } updateViewportWidth() @@ -270,9 +281,11 @@ export function EntityTimeline({ if (!viewport) return const handleScroll = () => { - isNearBottom.current = + const nearBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < SCROLL_THRESHOLD + isNearBottom.current = nearBottom + setShowJumpToBottom(!nearBottom) } handleScroll() @@ -300,6 +313,12 @@ export function EntityTimeline({ [] ) + const jumpToBottom = useCallback(() => { + if (rows.length > 0) { + rowVirtualizer.scrollToIndex(rows.length - 1, { align: `end` }) + } + }, [rowVirtualizer, rows.length]) + if (loading) { return ( @@ -321,74 +340,102 @@ export function EntityTimeline({ } return ( - -
+ - - - spawned{spawnTime ? ` · ${formatTime(spawnTime)}` : ``} - - - - {rows.length === 0 ? ( - - - Waiting for events... - - - ) : ( -
- {rowVirtualizer.getVirtualItems().map((virtualRow) => { - const row = rows[virtualRow.index] - - return ( -
- -
- ) - })} -
- )} - - {entityStopped && ( - +
+ - stopped + spawned{spawnTime ? ` · ${formatTime(spawnTime)}` : ``} - )} -
-
+ + {rows.length === 0 ? ( + + + Waiting for events... + + + ) : ( +
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const row = rows[virtualRow.index] + + return ( +
+ +
+ ) + })} +
+ )} + + {entityStopped && ( + + + stopped + + + )} +
+
+ + {showJumpToBottom && ( + + + + )} + ) } diff --git a/packages/agents-server-ui/src/components/stateExplorer/EventSidebar.module.css b/packages/agents-server-ui/src/components/stateExplorer/EventSidebar.module.css new file mode 100644 index 0000000000..e292132673 --- /dev/null +++ b/packages/agents-server-ui/src/components/stateExplorer/EventSidebar.module.css @@ -0,0 +1,71 @@ +.sidebar { + overflow: hidden; +} + +.header { + border-bottom: 1px solid var(--gray-a5); +} + +.headerLabel { + text-transform: uppercase; +} + +.eventListScroll { + flex: 1; + overflow-y: auto; + overflow-x: hidden; +} + +.eventRow { + cursor: pointer; + border-radius: var(--radius-2); + background: transparent; + overflow: hidden; + padding: var(--space-1) var(--space-2); + box-sizing: border-box; +} + +.eventRow[data-selected='true'] { + background: var(--accent-a3); +} + +.eventRow[data-dimmed='true'] { + opacity: 0.4; +} + +.eventRowHeader { + min-width: 0; + width: 100%; + overflow: hidden; +} + +.eventKey { + font-family: var(--code-font-family); + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: block; +} + +.expandButton { + margin-left: auto; + flex-shrink: 0; +} + +.expandButton svg { + transition: transform 0.15s ease; +} + +.expandButton[data-open='true'] svg { + transform: rotate(45deg); +} + +.eventValue { + white-space: pre; + overflow-x: auto; + overflow-y: hidden; + width: 100%; + font-size: var(--font-size-1); +} diff --git a/packages/agents-server-ui/src/components/stateExplorer/EventSidebar.tsx b/packages/agents-server-ui/src/components/stateExplorer/EventSidebar.tsx new file mode 100644 index 0000000000..39c8b9b161 --- /dev/null +++ b/packages/agents-server-ui/src/components/stateExplorer/EventSidebar.tsx @@ -0,0 +1,350 @@ +import { Badge, Code, Flex, IconButton, Text, Tooltip } from '@radix-ui/themes' +import { + Crosshair, + ListCollapse, + ListTree, + Plus, + SkipForward, +} from 'lucide-react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { isControlEvent } from '@durable-streams/state' +import { useVirtualizer } from '@tanstack/react-virtual' +import styles from './EventSidebar.module.css' +import type { ChangeEvent, StateEvent } from '@durable-streams/state' + +type BadgeColor = `green` | `yellow` | `red` | `blue` | `gray` + +function opBadge(op: string): { label: string; color: BadgeColor } { + switch (op) { + case `insert`: + return { label: `INS`, color: `green` } + case `update`: + return { label: `UPD`, color: `yellow` } + case `delete`: + return { label: `DEL`, color: `red` } + case `upsert`: + return { label: `UPS`, color: `blue` } + default: + return { label: op.toUpperCase().slice(0, 3), color: `gray` } + } +} + +function controlBadge(control: string): { label: string; color: BadgeColor } { + switch (control) { + case `snapshot-start`: + return { label: `SNAP▸`, color: `gray` } + case `snapshot-end`: + return { label: `◂SNAP`, color: `gray` } + case `reset`: + return { label: `RESET`, color: `gray` } + default: + return { label: control.toUpperCase().slice(0, 5), color: `gray` } + } +} + +const COLLAPSED_ROW_HEIGHT = 28 +const EXPANDED_ROW_ESTIMATE = 200 +const ICON_SIZE = 14 + +export function EventSidebar({ + events, + cursorIndex, + onSelectEvent, + onNavigateToEvent, + onGoLive, + style, +}: { + events: Array + cursorIndex: number | null + onSelectEvent: (index: number) => void + onNavigateToEvent: (index: number) => void + onGoLive: () => void + style?: React.CSSProperties +}) { + const scrollContainerRef = useRef(null) + const [expandedEvents, setExpandedEvents] = useState>(new Set()) + + const handleToggleEvent = useCallback((index: number) => { + setExpandedEvents((prev) => { + const next = new Set(prev) + if (next.has(index)) { + next.delete(index) + } else { + next.add(index) + } + return next + }) + }, []) + + const handleExpandAll = useCallback(() => { + setExpandedEvents(new Set(events.map((_, i) => i))) + }, [events]) + + const handleCollapseAll = useCallback(() => { + setExpandedEvents(new Set()) + }, []) + + const virtualizer = useVirtualizer({ + count: events.length, + getScrollElement: () => scrollContainerRef.current, + estimateSize: (index) => + expandedEvents.has(index) ? EXPANDED_ROW_ESTIMATE : COLLAPSED_ROW_HEIGHT, + overscan: 15, + }) + + // Scroll to selected event + useEffect(() => { + if (cursorIndex !== null) { + virtualizer.scrollToIndex(cursorIndex, { align: `center` }) + } + }, [cursorIndex, virtualizer]) + + const padWidth = String(events.length).length + + return ( + + {/* Header */} + + + Events + + + {events.length} + + + + + + + + + + + + + + + {/* Virtualized event list */} +
+
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const index = virtualItem.index + const event = events[index] + const isSelected = + cursorIndex !== null ? index === cursorIndex : false + const isDimmed = cursorIndex !== null && index > cursorIndex + const isOpen = expandedEvents.has(index) + + return ( +
onSelectEvent(index)} + > + {isControlEvent(event) ? ( + { + e.stopPropagation() + handleToggleEvent(index) + }} + /> + ) : ( + { + e.stopPropagation() + handleToggleEvent(index) + }} + onNavigate={(e) => { + e.stopPropagation() + onNavigateToEvent(index) + }} + /> + )} +
+ ) + })} +
+
+
+ ) +} + +// ============================================================================ +// Control Event Content +// ============================================================================ +function EventIndex({ index, padWidth }: { index: number; padWidth: number }) { + const padded = String(index + 1).padStart(padWidth, `0`) + return ( + + {padded} + + ) +} + +function ControlEventContent({ + event, + isOpen, + index, + padWidth, + onToggle, +}: { + event: { headers: { control: string } } + isOpen: boolean + index: number + padWidth: number + onToggle: (e: React.MouseEvent) => void +}) { + const { label, color } = controlBadge(event.headers.control) + return ( + <> + + + + {label} + + + control + + + + + + {isOpen && ( + + {JSON.stringify(event, null, 2)} + + )} + + ) +} + +// ============================================================================ +// Change Event Content +// ============================================================================ +function ChangeEventContent({ + event, + isOpen, + index, + padWidth, + onToggle, + onNavigate, +}: { + event: ChangeEvent + isOpen: boolean + index: number + padWidth: number + onToggle: (e: React.MouseEvent) => void + onNavigate: (e: React.MouseEvent) => void +}) { + const { label, color } = opBadge(event.headers.operation) + return ( + <> + + + + {label} + + + {event.type}:{event.key} + + + + + + + + + + + {isOpen && ( + + {JSON.stringify(event, null, 2)} + + )} + + ) +} diff --git a/packages/agents-server-ui/src/components/stateExplorer/StateExplorerPanel.tsx b/packages/agents-server-ui/src/components/stateExplorer/StateExplorerPanel.tsx new file mode 100644 index 0000000000..294f61fc7f --- /dev/null +++ b/packages/agents-server-ui/src/components/stateExplorer/StateExplorerPanel.tsx @@ -0,0 +1,275 @@ +import { Flex, Text } from '@radix-ui/themes' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + MaterializedState, + isChangeEvent, + isControlEvent, +} from '@durable-streams/state' +import { stream as createStream } from '@durable-streams/client' +import { TypeList } from './TypeList' +import { StateTable } from './StateTable' +import { EventSidebar } from './EventSidebar' +import type { ChangeEvent, StateEvent } from '@durable-streams/state' + +/** Runtime guard — checks that the value has a `headers` object before + * delegating to the library's `isChangeEvent`/`isControlEvent` guards. */ +function isStateEvent(value: unknown): value is StateEvent { + return ( + typeof value === `object` && + value !== null && + `headers` in value && + typeof (value as Record).headers === `object` + ) +} + +export function StateExplorerPanel({ + baseUrl, + entityUrl, +}: { + baseUrl: string + entityUrl: string +}) { + const [events, setEvents] = useState>([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const liveTail = true + const [cursorIndex, setCursorIndex] = useState(null) + const [selectedType, setSelectedType] = useState(null) + const [focusedRow, setFocusedRow] = useState<{ + type: string + key: string + } | null>(null) + const cancelRef = useRef<(() => void) | null>(null) + const containerRef = useRef(null) + const [splitRatio, setSplitRatio] = useState(0.6) // top gets 60% + + // Connect to the entity's main stream + useEffect(() => { + let cancelled = false + + const loadContent = async () => { + setIsLoading(true) + setError(null) + setEvents([]) + setCursorIndex(null) + + try { + const streamUrl = `${baseUrl}${entityUrl}/main` + + const res = await createStream({ + url: streamUrl, + offset: `-1`, + live: liveTail, + }) + + cancelRef.current = () => res.cancel() + + res.subscribeJson(async (batch: { items: ReadonlyArray }) => { + if (cancelled) return + const rawItems = batch.items.flatMap((item: unknown) => + Array.isArray(item) ? (item as Array) : [item] + ) + const newEvents = rawItems.filter(isStateEvent) + setEvents((prev) => [...prev, ...newEvents]) + setIsLoading(false) + }) + } catch (err) { + if (!cancelled) { + setError( + err instanceof Error ? err.message : `Failed to load stream content` + ) + setIsLoading(false) + } + } + } + + loadContent() + + return () => { + cancelled = true + if (cancelRef.current) { + cancelRef.current() + cancelRef.current = null + } + } + }, [baseUrl, entityUrl]) + + // Derive materialized state at cursor + const materializedState = useMemo(() => { + const state = new MaterializedState() + const end = cursorIndex === null ? events.length : cursorIndex + 1 + for (let i = 0; i < end; i++) { + const event = events[i] + if (isChangeEvent(event)) { + state.apply(event) + } else if (isControlEvent(event) && event.headers.control === `reset`) { + state.clear() + } + } + return state + }, [events, cursorIndex]) + + // Auto-select first type, or reset if selected type disappears during time-travel + useEffect(() => { + const types = materializedState.types + if (types.length === 0) { + setSelectedType(null) + } else if (selectedType === null || !types.includes(selectedType)) { + setSelectedType(types[0]) + } + }, [materializedState, selectedType]) + + // Track the affected entity from the cursor event (type + key) + const cursorTarget = useMemo(() => { + if (cursorIndex === null) return null + const event = events[cursorIndex] + if (event && isChangeEvent(event)) { + const change = event as ChangeEvent + return { type: change.type, key: change.key } + } + return null + }, [events, cursorIndex]) + + // Highlight from cursor event or from FK navigation + const highlightKey = + focusedRow && focusedRow.type === selectedType + ? focusedRow.key + : cursorTarget && cursorTarget.type === selectedType + ? cursorTarget.key + : null + + // Clear focusedRow when user changes type or cursor + useEffect(() => { + setFocusedRow(null) + }, [cursorIndex, selectedType]) + + const handleSelectEvent = useCallback((index: number) => { + setCursorIndex(index) + }, []) + + const handleNavigateToEvent = useCallback( + (index: number) => { + setCursorIndex(index) + const event = events[index] + if (event && isChangeEvent(event)) { + const change = event as ChangeEvent + setSelectedType(change.type) + } + }, + [events] + ) + + const handleNavigateToRow = useCallback((type: string, key: string) => { + setSelectedType(type) + setFocusedRow({ type, key }) + }, []) + + const handleGoLive = useCallback(() => { + setCursorIndex(null) + }, []) + + if (error) { + return ( + + + {error} + + + ) + } + + if (isLoading && events.length === 0) { + return ( + + + Loading stream… + + + ) + } + + if (events.length === 0) { + return ( + + + No state events in this stream yet + + + ) + } + + return ( + + {/* TypeList + StateTable */} + + + + + + {/* Draggable separator */} +
{ + e.preventDefault() + const container = containerRef.current + if (!container) return + const startY = e.clientY + const startRatio = splitRatio + const rect = container.getBoundingClientRect() + const onMouseMove = (ev: MouseEvent) => { + const dy = ev.clientY - startY + const newRatio = Math.min( + 0.8, + Math.max(0.15, startRatio + dy / rect.height) + ) + setSplitRatio(newRatio) + } + const onMouseUp = () => { + document.removeEventListener(`mousemove`, onMouseMove) + document.removeEventListener(`mouseup`, onMouseUp) + document.body.style.cursor = `` + document.body.style.userSelect = `` + } + document.body.style.cursor = `row-resize` + document.body.style.userSelect = `none` + document.addEventListener(`mousemove`, onMouseMove) + document.addEventListener(`mouseup`, onMouseUp) + }} + /> + + {/* Events section */} + + + ) +} diff --git a/packages/agents-server-ui/src/components/stateExplorer/StateTable.module.css b/packages/agents-server-ui/src/components/stateExplorer/StateTable.module.css new file mode 100644 index 0000000000..7710350429 --- /dev/null +++ b/packages/agents-server-ui/src/components/stateExplorer/StateTable.module.css @@ -0,0 +1,108 @@ +.header { + border-bottom: 1px solid var(--gray-a5); +} + +.scrollContainer { + flex: 1; + overflow: auto; + position: relative; + width: 0; + min-width: 100%; +} + +.gridTable { + font-size: var(--font-size-1); +} + +.gridHead { + position: sticky; + top: 0; + z-index: 1; + background-color: var(--gray-3); +} + +.gridRow { + display: flex; +} + +.gridTh { + position: relative; + border-right: 1px solid var(--gray-a4); + border-bottom: 1px solid var(--gray-a5); + cursor: pointer; + user-select: none; + box-sizing: border-box; +} + +.gridTh:hover { + background-color: var(--gray-a2); +} + +.resizer { + position: absolute; + right: 0; + top: 0; + height: 100%; + width: 4px; + cursor: col-resize; + touch-action: none; + user-select: none; +} + +@media (hover: hover) { + .resizer { + opacity: 0; + } + + *:hover > .resizer { + opacity: 1; + background: var(--gray-a5); + } +} + +.resizer[data-resizing] { + background: var(--accent-9); + opacity: 1; +} + +.gridBody { + position: relative; +} + +.gridBodyRow { + display: flex; + position: absolute; + top: 0; + left: 0; +} + +.gridBodyRow:hover { + background: var(--gray-a2); +} + +.gridCell { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: var(--space-1) var(--space-2); + border-right: 1px solid var(--gray-a3); + border-bottom: 1px solid var(--gray-a3); + display: flex; + align-items: center; + box-sizing: border-box; +} + +.highlightedRow { + background: var(--accent-a3); + box-shadow: inset 2px 0 0 var(--accent-9); + animation: pulse 0.6s ease-out; +} + +@keyframes pulse { + 0% { + background: var(--accent-a5); + } + 100% { + background: var(--accent-a3); + } +} diff --git a/packages/agents-server-ui/src/components/stateExplorer/StateTable.tsx b/packages/agents-server-ui/src/components/stateExplorer/StateTable.tsx new file mode 100644 index 0000000000..0cc57ae562 --- /dev/null +++ b/packages/agents-server-ui/src/components/stateExplorer/StateTable.tsx @@ -0,0 +1,398 @@ +import { + Badge, + Code, + DataList, + Flex, + HoverCard, + Link, + Text, +} from '@radix-ui/themes' +import { useEffect, useMemo, useRef, useState } from 'react' +import { + createColumnHelper, + flexRender, + getCoreRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table' +import { useVirtualizer } from '@tanstack/react-virtual' +import styles from './StateTable.module.css' +import type { ColumnResizeMode, SortingState } from '@tanstack/react-table' +import type { MaterializedState } from '@durable-streams/state' + +// ============================================================================ +// FK column detection +// ============================================================================ + +/** Match `something_id` or `somethingId` → extract `something` */ +function extractFkType(columnName: string): string | null { + const snakeMatch = columnName.match(/^(.+)_id$/) + if (snakeMatch) return snakeMatch[1] + const camelMatch = columnName.match(/^(.+)Id$/) + if (camelMatch) { + const name = camelMatch[1] + return name.charAt(0).toLowerCase() + name.slice(1) + } + return null +} + +function detectFkColumns( + columns: Array, + state: MaterializedState +): Map { + const knownTypes = new Set(state.types) + const fkMap = new Map() + for (const col of columns) { + const refType = extractFkType(col) + if (refType && knownTypes.has(refType)) { + fkMap.set(col, refType) + } + } + return fkMap +} + +// ============================================================================ +// Row type +// ============================================================================ + +type StateRow = { _key: string } & Record + +const ROW_HEIGHT = 32 +const columnHelper = createColumnHelper() + +// ============================================================================ +// StateTable +// ============================================================================ + +export function StateTable({ + state, + selectedType, + highlightKey, + onNavigateToRow, +}: { + state: MaterializedState + selectedType: string | null + highlightKey: string | null + onNavigateToRow: (type: string, key: string) => void +}) { + const scrollContainerRef = useRef(null) + const [sorting, setSorting] = useState([]) + const [columnSizing, setColumnSizing] = useState({}) + const columnResizeMode: ColumnResizeMode = `onChange` + + const typeMap = useMemo(() => { + if (!selectedType) return new Map() + return state.getType(selectedType) + }, [state, selectedType]) + + const columnNames = useMemo(() => { + const keys = new Set() + for (const value of typeMap.values()) { + if (value && typeof value === `object`) { + for (const key of Object.keys(value as object)) { + keys.add(key) + } + } + } + return [`_key`, ...Array.from(keys)] + }, [typeMap]) + + const fkColumns = useMemo( + () => detectFkColumns(columnNames, state), + [columnNames, state] + ) + + const rows = useMemo(() => { + return Array.from(typeMap.entries()).map( + ([key, value]) => + ({ + _key: key, + ...(value as Record), + }) as StateRow + ) + }, [typeMap]) + + const columns = useMemo(() => { + return columnNames.map((col) => { + return columnHelper.accessor(col, { + header: col === `_key` ? `key` : col, + size: col === `_key` ? 120 : 150, + minSize: 60, + cell: (info) => { + const value = info.getValue() + if (col === `_key`) { + return ( + + {String(value)} + + ) + } + if (fkColumns.has(col)) { + return ( + + ) + } + return + }, + }) + }) + }, [columnNames, fkColumns, state, onNavigateToRow]) + + const table = useReactTable({ + data: rows, + columns, + state: { sorting, columnSizing }, + onSortingChange: setSorting, + onColumnSizingChange: setColumnSizing, + columnResizeMode, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }) + + const { rows: tableRows } = table.getRowModel() + + const virtualizer = useVirtualizer({ + count: tableRows.length, + getScrollElement: () => scrollContainerRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 20, + }) + + // Scroll highlighted row into view + useEffect(() => { + if (highlightKey === null) return + const idx = tableRows.findIndex((r) => r.original._key === highlightKey) + if (idx >= 0) { + virtualizer.scrollToIndex(idx, { align: `center` }) + } + }, [highlightKey, tableRows, virtualizer]) + + // Reset sorting when type changes + useEffect(() => { + setSorting([]) + }, [selectedType]) + + if (!selectedType) { + return ( + + + Select a type to view its state + + + ) + } + + const headerGroups = table.getHeaderGroups() + + return ( + + + + Records + + + {rows.length} + + + + {rows.length === 0 ? ( + + + No rows at this point in time + + + ) : ( +
+
+ {/* Header */} +
+ {headerGroups.map((headerGroup) => ( +
+ {headerGroup.headers.map((header) => ( +
+ + + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + + + {{ asc: ` ↑`, desc: ` ↓` }[ + header.column.getIsSorted() as string + ] ?? ``} + + + {header.column.getCanResize() && ( +
+ )} +
+ ))} +
+ ))} +
+ + {/* Body */} +
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const row = tableRows[virtualRow.index] + const isHighlighted = row.original._key === highlightKey + return ( +
+ {row.getVisibleCells().map((cell) => ( +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} +
+ ))} +
+ ) + })} +
+
+
+ )} + + ) +} + +// ============================================================================ +// Foreign Key Cell +// ============================================================================ +function ForeignKeyCell({ + value, + refType, + state, + onNavigate, +}: { + value: unknown + refType: string + state: MaterializedState + onNavigate: (type: string, key: string) => void +}) { + const key = typeof value === `string` ? value : String(value ?? ``) + const refRow = state.get>(refType, key) + + if (!refRow) { + return + } + + return ( + + + { + e.preventDefault() + onNavigate(refType, key) + }} + style={{ fontFamily: `var(--code-font-family)` }} + > + {key} + + + + + + {refType}:{key} + + + {Object.entries(refRow).map(([field, val]) => ( + + + + {field} + + + + + {typeof val === `object` && val !== null + ? JSON.stringify(val) + : String(val ?? `null`)} + + + + ))} + + + + + ) +} + +// ============================================================================ +// Cell Value Renderer +// ============================================================================ +function CellValue({ value }: { value: unknown }) { + if (value === null || value === undefined) { + return ( + + null + + ) + } + if (typeof value === `object`) { + return ( + + {JSON.stringify(value)} + + ) + } + if (typeof value === `boolean`) { + return ( + + {String(value)} + + ) + } + return ( + + {String(value)} + + ) +} diff --git a/packages/agents-server-ui/src/components/stateExplorer/TypeList.tsx b/packages/agents-server-ui/src/components/stateExplorer/TypeList.tsx new file mode 100644 index 0000000000..a3dabaac04 --- /dev/null +++ b/packages/agents-server-ui/src/components/stateExplorer/TypeList.tsx @@ -0,0 +1,89 @@ +import { Badge, Box, Flex, Text } from '@radix-ui/themes' +import type { MaterializedState } from '@durable-streams/state' + +export function TypeList({ + state, + selectedType, + onSelectType, +}: { + state: MaterializedState + selectedType: string | null + onSelectType: (type: string) => void +}) { + const types = state.types + + return ( + + {/* Header — matches Events header */} + + + Types + + + {types.length} + + + + {/* Scrollable list */} + + {types.length === 0 ? ( + + No types yet + + ) : ( + types.map((type) => { + const count = state.getType(type).size + const isSelected = type === selectedType + return ( + onSelectType(type)} + style={{ + padding: `var(--space-1) var(--space-2)`, + borderRadius: `var(--radius-2)`, + cursor: `pointer`, + background: isSelected ? `var(--accent-a3)` : `transparent`, + color: isSelected ? `var(--accent-11)` : `var(--gray-11)`, + }} + > + + + {type} + + + {count} + + + + ) + }) + )} + + + ) +} diff --git a/packages/agents-server-ui/src/lib/entity-connection.ts b/packages/agents-server-ui/src/lib/entity-connection.ts index 8ddd13b231..ca9becce71 100644 --- a/packages/agents-server-ui/src/lib/entity-connection.ts +++ b/packages/agents-server-ui/src/lib/entity-connection.ts @@ -5,11 +5,20 @@ function getMainStreamPath(entityUrl: string): string { return `${entityUrl}/main` } +/** + * Entity-side custom state collections to register on the UI-side + * StreamDB so `db.collections[name]` resolves. Shape matches + * `EntityDefinition['state']` — type + primaryKey are the minimum + * needed; schema defaults to passthrough on the read side. + */ +export type UICustomState = Record + export async function connectEntityStream(opts: { baseUrl: string entityUrl: string + customState?: UICustomState }): Promise<{ db: EntityStreamDBWithActions; close: () => void }> { - const { baseUrl, entityUrl } = opts + const { baseUrl, entityUrl, customState } = opts const res = await fetch(`${baseUrl}${entityUrl}`, { headers: { accept: `application/json` }, @@ -19,7 +28,10 @@ export async function connectEntityStream(opts: { } await res.body?.cancel() const streamUrl = `${baseUrl}${getMainStreamPath(entityUrl)}` - const db = createEntityStreamDB(streamUrl) + const db = createEntityStreamDB( + streamUrl, + customState as unknown as Parameters[1] + ) await db.preload() return { db, close: () => db.close() } diff --git a/packages/agents-server-ui/src/router.tsx b/packages/agents-server-ui/src/router.tsx index baaf2f0846..6947a1a155 100644 --- a/packages/agents-server-ui/src/router.tsx +++ b/packages/agents-server-ui/src/router.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { Outlet, createHashHistory, @@ -19,6 +19,7 @@ import { Sidebar } from './components/Sidebar' import { EntityHeader } from './components/EntityHeader' import { EntityTimeline } from './components/EntityTimeline' import { MessageInput } from './components/MessageInput' +import { StateExplorerPanel } from './components/stateExplorer/StateExplorerPanel' function RootLayout(): React.ReactElement { const { pinnedUrls } = usePinnedEntities() @@ -80,23 +81,10 @@ function EntityPage(): React.ReactElement { const isSpawning = selectedEntity?.status === `spawning` const entityStopped = selectedEntity?.status === `stopped` - // Defer stream connection while the entity is still in its optimistic - // `spawning` state — the server streams don't exist yet. Once Electric - // syncs the real entity (status: 'idle'|'running'|'stopped'), the hook - // re-runs and connects. - const { entries, db, loading, error } = useEntityTimeline( - activeServer?.url ?? null, - isSpawning ? null : entityUrl - ) - + const [stateExplorerOpen, setStateExplorerOpen] = useState(false) + const [statePanelWidth, setStatePanelWidth] = useState(0.5) + const containerRef = useRef(null) const [killError, setKillError] = useState(null) - const navigate = useNavigate() - - useEffect(() => { - if (error && !isSpawning) { - navigate({ to: `/` }) - } - }, [error, navigate, isSpawning]) const handleKill = useCallback(() => { if (!killEntity) return @@ -117,29 +105,128 @@ function EntityPage(): React.ReactElement { ) } + const baseUrl = activeServer?.url ?? `` + // Hide the body while spawning — server streams don't exist yet. + const connectUrl = isSpawning ? null : entityUrl + return ( - + togglePin(entityUrl)} onKill={handleKill} killError={killError} + stateExplorerOpen={stateExplorerOpen} + onToggleStateExplorer={() => setStateExplorerOpen((prev) => !prev)} /> + + + + + {stateExplorerOpen && ( + <> +
{ + e.preventDefault() + const container = containerRef.current + if (!container) return + const startX = e.clientX + const startWidth = statePanelWidth + const rect = container.getBoundingClientRect() + const onMouseMove = (ev: MouseEvent) => { + const dx = startX - ev.clientX + const newWidth = Math.min( + 0.7, + Math.max(0.2, startWidth + dx / rect.width) + ) + setStatePanelWidth(newWidth) + } + const onMouseUp = () => { + document.removeEventListener(`mousemove`, onMouseMove) + document.removeEventListener(`mouseup`, onMouseUp) + document.body.style.cursor = `` + document.body.style.userSelect = `` + } + document.body.style.cursor = `col-resize` + document.body.style.userSelect = `none` + document.addEventListener(`mousemove`, onMouseMove) + document.addEventListener(`mouseup`, onMouseUp) + }} + /> + + + + + )} + + + ) +} + +function GenericEntityBody({ + baseUrl, + entityUrl, + entityStopped, + isSpawning, +}: { + baseUrl: string + entityUrl: string | null + entityStopped: boolean + isSpawning: boolean +}): React.ReactElement { + const { entries, db, loading, error } = useEntityTimeline( + baseUrl || null, + entityUrl + ) + const navigate = useNavigate() + + useEffect(() => { + if (error && !isSpawning) { + navigate({ to: `/` }) + } + }, [error, navigate, isSpawning]) + + return ( + <> - + ) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20b5b72ee5..fa6e433916 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1638,6 +1638,12 @@ importers: packages/agents-server-ui: dependencies: + '@durable-streams/client': + specifier: npm:@electric-ax/durable-streams-client-beta@^0.3.0 + version: '@electric-ax/durable-streams-client-beta@0.3.0' + '@durable-streams/state': + specifier: npm:@electric-ax/durable-streams-state-beta@^0.3.0 + version: '@electric-ax/durable-streams-state-beta@0.3.0(typescript@5.8.3)' '@electric-ax/agents-runtime': specifier: workspace:* version: link:../agents-runtime @@ -1656,6 +1662,9 @@ importers: '@tanstack/react-router': specifier: ^1.167.4 version: 1.168.23(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@tanstack/react-table': + specifier: ^8.21.3 + version: 8.21.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@tanstack/react-virtual': specifier: ^3.13.23 version: 3.13.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -8262,6 +8271,13 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/react-table@8.21.3': + resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + '@tanstack/react-virtual@3.10.8': resolution: {integrity: sha512-VbzbVGSsZlQktyLrP5nxE+vE1ZR+U0NFAWPbJLoG2+DKPwd2D7dVICTVIIaYlJqX1ZCEnYDbaOpmMwbsyhBoIA==} peerDependencies: @@ -8354,6 +8370,10 @@ packages: '@tanstack/store@0.9.3': resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + '@tanstack/virtual-core@3.10.8': resolution: {integrity: sha512-PBu00mtt95jbKFi6Llk9aik8bnR3tR/oQP1o3TSi+iG//+Q2RTIzCEgKkHG8BB86kxMNW6O8wku+Lmi+QFR6jA==} @@ -25491,6 +25511,12 @@ snapshots: react-dom: 19.2.0(react@19.2.0) use-sync-external-store: 1.6.0(react@19.2.0) + '@tanstack/react-table@8.21.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@tanstack/table-core': 8.21.3 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + '@tanstack/react-virtual@3.10.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@tanstack/virtual-core': 3.10.8 @@ -25669,6 +25695,8 @@ snapshots: '@tanstack/store@0.9.3': {} + '@tanstack/table-core@8.21.3': {} + '@tanstack/virtual-core@3.10.8': {} '@tanstack/virtual-core@3.14.0': {}