{
+ 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': {}