diff --git a/app/package.json b/app/package.json index 8f62689c1..a69263aa7 100644 --- a/app/package.json +++ b/app/package.json @@ -38,6 +38,7 @@ "@coasys/flux-poll-view": "0.11.0", "@coasys/flux-post-view": "0.11.0", "@coasys/flux-synergy-demo-view": "0.11.0", + "@coasys/flux-soa-tree-view": "0.11.0", "@coasys/flux-table-view": "0.11.0", "@coasys/flux-types": "0.11.0", "@coasys/flux-ui": "0.11.0", diff --git a/app/src/constants/index.ts b/app/src/constants/index.ts index 40bda70de..4feb6a4ea 100644 --- a/app/src/constants/index.ts +++ b/app/src/constants/index.ts @@ -33,6 +33,14 @@ export const viewOptions = [ pkg: '@coasys/flux-webrtc-view', component: 'webrtc-view', }, + { + title: 'SoA Tree', + description: 'View State of Affairs nodes as a collapsible tree', + icon: 'diagram-3', + type: ChannelView.SoATree, + pkg: '@coasys/flux-soa-tree-view', + component: 'soa-tree-view', + }, { title: 'Debug', description: 'WebRTC debugger', diff --git a/app/src/modules.ts b/app/src/modules.ts index a874e5065..c78fa8f1f 100644 --- a/app/src/modules.ts +++ b/app/src/modules.ts @@ -8,3 +8,4 @@ declare module '@coasys/flux-kanban-view'; declare module '@coasys/flux-table-view'; declare module '@coasys/flux-synergy-demo-view'; declare module '@coasys/flux-poll-view'; +declare module '@coasys/flux-soa-tree-view'; diff --git a/app/src/utils/fetchFluxApp.ts b/app/src/utils/fetchFluxApp.ts index 25de34fb6..19e3bd46b 100644 --- a/app/src/utils/fetchFluxApp.ts +++ b/app/src/utils/fetchFluxApp.ts @@ -11,6 +11,7 @@ const fetchFluxApp = async function (packageName: string) { '@coasys/nillion-file-store', '@coasys/flux-synergy-demo-view', '@coasys/flux-poll-view', + '@coasys/flux-soa-tree-view', ]; const isOfficialApp = officialPackages.includes(packageName); @@ -50,6 +51,9 @@ const fetchFluxApp = async function (packageName: string) { if (packageName === '@coasys/flux-poll-view') { module = await import('@coasys/flux-poll-view'); } + if (packageName === '@coasys/flux-soa-tree-view') { + module = await import('@coasys/flux-soa-tree-view'); + } } else { module = await import( /* @vite-ignore */ diff --git a/packages/api/src/npmApi.ts b/packages/api/src/npmApi.ts index 258dbc68a..b0fb752d7 100644 --- a/packages/api/src/npmApi.ts +++ b/packages/api/src/npmApi.ts @@ -42,7 +42,7 @@ export async function getAllFluxApps(): Promise { } export function getOfflineFluxApps(): FluxApp[] { - const packages = ['chat-view', 'post-view', 'graph-view', 'webrtc-view', 'table-view', 'kanban-board']; + const packages = ['chat-view', 'post-view', 'graph-view', 'webrtc-view', 'table-view', 'kanban-board', 'flux-soa-tree-view']; const fluxApps = packages.map((name) => ({ created: '', diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 75a504fe4..fde2b7f4a 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -104,6 +104,7 @@ export enum ChannelView { Graph = 'flux://has_graph_view', Voice = 'flux://has_voice_view', Debug = 'flux://has_debug_view', + SoATree = 'flux://has_soa_tree_view', } export enum EntryType { diff --git a/views/soa-tree-view/package.json b/views/soa-tree-view/package.json new file mode 100644 index 000000000..7f186aa0a --- /dev/null +++ b/views/soa-tree-view/package.json @@ -0,0 +1,51 @@ +{ + "name": "@coasys/flux-soa-tree-view", + "version": "0.11.0", + "description": "SoA Tree View for Flux", + "author": "Coasys ", + "license": "ISC", + "type": "module", + "scripts": { + "start": "vite", + "dev": "vite", + "build": "vite build", + "test": "echo \"Tests to be implemented in follow-up PR\" && exit 0" + }, + "main": "./dist/main.umd.cjs", + "module": "./dist/main.js", + "exports": { + ".": { + "import": "./dist/main.js", + "require": "./dist/main.umd.cjs" + } + }, + "files": [ + "dist" + ], + "keywords": [ + "flux-plugin", + "ad4m-view" + ], + "dependencies": { + "@coasys/ad4m": "0.11.1", + "@coasys/ad4m-react-hooks": "0.11.1", + "@coasys/flux-react-web": "0.11.0", + "@coasys/flux-api": "0.11.0", + "preact": "^10.13.1" + }, + "devDependencies": { + "@babel/plugin-proposal-class-properties": "^7.18.6", + "@babel/plugin-proposal-decorators": "^7.21.0", + "@preact/preset-vite": "^2.5.0", + "vite": "^4.3.5", + "vite-plugin-css-injected-by-js": "^3.1.0" + }, + "publishConfig": { + "access": "public" + }, + "fluxapp": { + "name": "SoA Tree", + "description": "View State of Affairs nodes as a collapsible tree", + "icon": "diagram-3" + } +} diff --git a/views/soa-tree-view/src/App.module.css b/views/soa-tree-view/src/App.module.css new file mode 100644 index 000000000..96e350cab --- /dev/null +++ b/views/soa-tree-view/src/App.module.css @@ -0,0 +1,11 @@ +.appContainer { + margin: 0 auto; + width: 100%; + height: 100%; +} + +.error { + padding: 2rem; + text-align: center; + color: var(--j-color-danger-500, #e53e3e); +} diff --git a/views/soa-tree-view/src/App.tsx b/views/soa-tree-view/src/App.tsx new file mode 100644 index 000000000..f5134e57a --- /dev/null +++ b/views/soa-tree-view/src/App.tsx @@ -0,0 +1,26 @@ +import styles from './App.module.css'; +import { PerspectiveProxy } from '@coasys/ad4m'; +import SoATreeView from './components/SoATreeView'; +import '@coasys/flux-ui/dist/main.css'; + +type Props = { + perspective: PerspectiveProxy; + source: string; +}; + +export default function App({ perspective, source }: Props) { + if (!perspective?.uuid || !source) { + return ( +
+
+ No perspective or source available +
+
+ ); + } + return ( +
+ +
+ ); +} diff --git a/views/soa-tree-view/src/components/SoANode.module.css b/views/soa-tree-view/src/components/SoANode.module.css new file mode 100644 index 000000000..ed05308cc --- /dev/null +++ b/views/soa-tree-view/src/components/SoANode.module.css @@ -0,0 +1,149 @@ +.nodeWrapper { + display: flex; + flex-direction: column; +} + +.nodeHeader { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-radius: 4px; + cursor: pointer; + user-select: none; + transition: background-color 0.15s; +} + +.nodeHeader:hover { + background-color: var(--j-color-ui-100, #f1f5f9); +} + +.nodeHeader.expanded { + background-color: var(--j-color-ui-50, #f8fafc); +} + +.toggle { + font-size: 12px; + width: 14px; + flex-shrink: 0; + color: var(--j-color-ui-400, #94a3b8); +} + +.icon { + font-size: 16px; + flex-shrink: 0; +} + +.title { + font-size: 14px; + font-weight: 500; + color: var(--j-color-ui-800, #1e293b); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.statusBadge { + font-size: 11px; + padding: 1px 6px; + border-radius: 10px; + color: #fff; + font-weight: 500; + flex-shrink: 0; +} + +.priorityBadge { + font-size: 11px; + padding: 1px 6px; + border-radius: 10px; + background-color: var(--j-color-ui-200, #e2e8f0); + color: var(--j-color-ui-600, #475569); + font-weight: 500; + flex-shrink: 0; +} + +.relCount { + font-size: 11px; + color: var(--j-color-ui-400, #94a3b8); + flex-shrink: 0; +} + +.details { + display: flex; + flex-direction: column; + gap: 6px; + padding: 4px 0 8px; +} + +.property { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; +} + +.propLabel { + font-size: 12px; + color: var(--j-color-ui-400, #94a3b8); + font-weight: 500; + flex-shrink: 0; + min-width: 70px; +} + +.confidenceBar { + width: 80px; + height: 6px; + background-color: var(--j-color-ui-200, #e2e8f0); + border-radius: 3px; + overflow: hidden; + flex-shrink: 0; +} + +.confidenceFill { + height: 100%; + background-color: var(--j-color-primary-500, #3b82f6); + border-radius: 3px; + transition: width 0.2s; +} + +.confidenceValue { + font-size: 12px; + color: var(--j-color-ui-500, #64748b); +} + +.tagList { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.tag { + font-size: 11px; + padding: 1px 8px; + border-radius: 10px; + background-color: var(--j-color-ui-100, #f1f5f9); + color: var(--j-color-ui-600, #475569); +} + +.relationships { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; +} + +.relBadge { + font-size: 11px; + padding: 2px 8px; + border-radius: 10px; + background-color: var(--j-color-ui-100, #f1f5f9); + color: var(--j-color-ui-600, #475569); + border: 1px solid var(--j-color-ui-200, #e2e8f0); +} + +.children { + display: flex; + flex-direction: column; + gap: 2px; +} diff --git a/views/soa-tree-view/src/components/SoANode.tsx b/views/soa-tree-view/src/components/SoANode.tsx new file mode 100644 index 000000000..a395a6a02 --- /dev/null +++ b/views/soa-tree-view/src/components/SoANode.tsx @@ -0,0 +1,145 @@ +import { useState } from 'preact/hooks'; +import type { SoANodeData } from './SoATreeView'; +import styles from './SoANode.module.css'; + +const MODALITY_ICONS: Record = { + observation: '\uD83D\uDD2D', + belief: '\uD83D\uDCAD', + intention: '\uD83C\uDFAF', + vision: '\uD83C\uDF1F', + plan: '\uD83D\uDCCB', + skill: '\uD83D\uDEE0\uFE0F', +}; + +const STATUS_COLORS: Record = { + active: '#22c55e', + completed: '#3b82f6', + abandoned: '#6b7280', + blocked: '#ef4444', + held: '#f59e0b', + revised: '#8b5cf6', + retracted: '#6b7280', + current: '#22c55e', + outdated: '#9ca3af', +}; + +const REL_LABELS: Record = { + supports: 'Supports', + contradicts: 'Contradicts', + similar: 'Similar', + same: 'Same as', + requires: 'Requires', + enables: 'Enables', + refines: 'Refines', + blocks: 'Blocks', +}; + +type Props = { + node: SoANodeData; + depth: number; +}; + +export default function SoANode({ node, depth }: Props) { + const [expanded, setExpanded] = useState(depth === 0); + const hasChildren = node.children.length > 0; + const hasDetails = + node.description || + node.confidence != null || + node.status || + node.tags || + node.priority != null || + node.source || + node.relationships.length > 0; + + const icon = MODALITY_ICONS[node.modality] || '\uD83D\uDD35'; + + return ( +
+
setExpanded(!expanded)} + > + + {hasChildren || hasDetails ? (expanded ? '\u25BE' : '\u25B8') : '\u00A0\u00A0'} + + {icon} + {node.title || '(untitled)'} + {node.status && ( + + {node.status} + + )} + {node.priority != null && ( + + P{node.priority} + + )} + {node.relationships.length > 0 && ( + + {node.relationships.length} rel + + )} +
+ + {expanded && hasDetails && ( +
+ {node.description && ( +
+ {node.description} +
+ )} + {node.confidence != null && ( +
+ Confidence +
+
+
+ {Math.round(node.confidence * 100)}% +
+ )} + {node.tags && ( +
+ Tags +
+ {node.tags.split(',').map((tag) => tag.trim()).filter(Boolean).map((tag) => ( + {tag} + ))} +
+
+ )} + {node.source && ( +
+ Source + {node.source} +
+ )} + {node.relationships.length > 0 && ( +
+ Relationships + {node.relationships.map((rel, i) => ( + + {REL_LABELS[rel.predicate] || rel.predicate} + {rel.targetTitle ? `: ${rel.targetTitle}` : ''} + + ))} +
+ )} +
+ )} + + {expanded && hasChildren && ( +
+ {node.children.map((child) => ( + + ))} +
+ )} +
+ ); +} diff --git a/views/soa-tree-view/src/components/SoATreeView.module.css b/views/soa-tree-view/src/components/SoATreeView.module.css new file mode 100644 index 000000000..58d6df60b --- /dev/null +++ b/views/soa-tree-view/src/components/SoATreeView.module.css @@ -0,0 +1,20 @@ +.container { + padding: 16px; + height: 100%; + overflow-y: auto; +} + +.header { + display: flex; + align-items: baseline; + gap: 12px; + margin-bottom: 16px; + padding-bottom: 8px; + border-bottom: 1px solid var(--j-color-ui-200, #e2e8f0); +} + +.tree { + display: flex; + flex-direction: column; + gap: 2px; +} diff --git a/views/soa-tree-view/src/components/SoATreeView.tsx b/views/soa-tree-view/src/components/SoATreeView.tsx new file mode 100644 index 000000000..767f96690 --- /dev/null +++ b/views/soa-tree-view/src/components/SoATreeView.tsx @@ -0,0 +1,204 @@ +import { useState, useEffect } from 'preact/hooks'; +import { PerspectiveProxy, LinkExpression } from '@coasys/ad4m'; +import SoANode from './SoANode'; +import styles from './SoATreeView.module.css'; + +type SoANodeData = { + base: string; + title: string; + modality: string; + description?: string; + confidence?: number; + status?: string; + tags?: string; + priority?: number; + source?: string; + children: SoANodeData[]; + relationships: { predicate: string; target: string; targetTitle?: string }[]; +}; + +type Props = { + perspective: PerspectiveProxy; + source: string; +}; + +const RELATIONSHIP_PREDICATES = [ + 'soa://rel_supports', + 'soa://rel_contradicts', + 'soa://rel_similar', + 'soa://rel_same', + 'soa://rel_requires', + 'soa://rel_enables', + 'soa://rel_refines', + 'soa://rel_blocks', +]; + +function parseLiteralString(literal: string): string { + const match = literal.match(/^literal:\/\/string:(.*)$/); + return match ? decodeURIComponent(match[1]) : literal; +} + +function buildTree(soaLinks: LinkExpression[]): SoANodeData[] { + const nodeMap = new Map(); + const parentChildLinks: { parent: string; child: string }[] = []; + + // First pass: find all nodes (anything with a soa://title link) + for (const link of soaLinks) { + const pred = link.data.predicate; + const base = link.data.source; + + if (pred === 'soa://title') { + if (!nodeMap.has(base)) { + nodeMap.set(base, { + base, + title: '', + modality: 'observation', + children: [], + relationships: [], + }); + } + nodeMap.get(base)!.title = parseLiteralString(link.data.target); + } + } + + // Second pass: collect properties and relationships + for (const link of soaLinks) { + const pred = link.data.predicate; + const base = link.data.source; + const target = link.data.target; + const node = nodeMap.get(base); + + if (!node) continue; + + if (pred === 'soa://modality') { + node.modality = parseLiteralString(target); + } else if (pred === 'soa://description') { + node.description = parseLiteralString(target); + } else if (pred === 'soa://confidence') { + const conf = parseFloat(parseLiteralString(target)); + node.confidence = Number.isFinite(conf) ? Math.max(0, Math.min(1, conf)) : undefined; + } else if (pred === 'soa://status') { + node.status = parseLiteralString(target); + } else if (pred === 'soa://tags') { + node.tags = parseLiteralString(target); + } else if (pred === 'soa://priority') { + const prio = parseInt(parseLiteralString(target), 10); + node.priority = Number.isFinite(prio) ? prio : undefined; + } else if (pred === 'soa://source') { + node.source = parseLiteralString(target); + } else if (pred === 'soa://rel_parent') { + // source is parent of target + parentChildLinks.push({ parent: base, child: target }); + } else if (RELATIONSHIP_PREDICATES.includes(pred)) { + const targetNode = nodeMap.get(target); + node.relationships.push({ + predicate: pred.replace('soa://rel_', ''), + target, + targetTitle: targetNode?.title, + }); + } + } + + // Build tree from parent-child links + const childSet = new Set(); + for (const { parent, child } of parentChildLinks) { + const parentNode = nodeMap.get(parent); + const childNode = nodeMap.get(child); + if (parentNode && childNode) { + parentNode.children.push(childNode); + childSet.add(child); + } + } + + // Root nodes are those that are never a child + const roots: SoANodeData[] = []; + for (const [base, node] of nodeMap) { + if (!childSet.has(base)) { + roots.push(node); + } + } + + return roots; +} + +export default function SoATreeView({ perspective, source }: Props) { + const [roots, setRoots] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function loadTree() { + try { + setLoading(true); + // TODO: Optimize with server-side filtering by querying each soa:// predicate individually + // instead of fetching all links and filtering client-side + const allLinks = await perspective.queryLinks({}); + const soaLinks = allLinks.filter( + (l: LinkExpression) => l.data.predicate?.startsWith('soa://') + ); + + if (!cancelled) { + const tree = buildTree(soaLinks); + setRoots(tree); + setError(null); + } + } catch (e: any) { + if (!cancelled) { + setError(e.message || 'Failed to load SoA data'); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + loadTree(); + + return () => { + cancelled = true; + }; + }, [perspective?.uuid, source]); + + if (loading) { + return ( +
+ Loading SoA tree... +
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (roots.length === 0) { + return ( +
+ No SoA nodes found in this perspective. +
+ ); + } + + return ( +
+
+ State of Affairs + {roots.length} root node{roots.length !== 1 ? 's' : ''} +
+
+ {roots.map((node) => ( + + ))} +
+
+ ); +} + +export type { SoANodeData }; diff --git a/views/soa-tree-view/src/main.ts b/views/soa-tree-view/src/main.ts new file mode 100644 index 000000000..ed0ded458 --- /dev/null +++ b/views/soa-tree-view/src/main.ts @@ -0,0 +1,7 @@ +import 'preact/debug'; +import { toCustomElement } from '@coasys/flux-react-web'; +import MyComponent from './App'; + +const CustomElement = toCustomElement(MyComponent, ['perspective', 'agent', 'source'], { shadow: false }); + +export default CustomElement; diff --git a/views/soa-tree-view/vite.config.ts b/views/soa-tree-view/vite.config.ts new file mode 100644 index 000000000..b8a4b4f59 --- /dev/null +++ b/views/soa-tree-view/vite.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vite'; +import { resolve } from 'path'; +import preact from '@preact/preset-vite'; +import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'; + +export default defineConfig({ + plugins: [ + preact({ + babel: { + plugins: [['@babel/plugin-proposal-decorators', { legacy: true }], ['@babel/plugin-proposal-class-properties']], + }, + }), + cssInjectedByJsPlugin(), + ], + build: { + lib: { + entry: resolve(__dirname, './src/main.ts'), + name: 'Main', + fileName: 'main', + }, + }, +});