diff --git a/CLAUDE.md b/CLAUDE.md index b5eb728..d138470 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,7 +108,7 @@ docker run -p 5200:5200 -v ./config.json:/app/config.json orchestration - **Traceability**: requirements numbered [R1], [R2], mapped to tasks; coverage endpoint shows gaps - **External execution**: MCP server (`backend/mcp/server.py`) for Claude Code integration. Execution modes: auto (engine-only), hybrid (Ollama internal, Claude external), external (all external). Tasks claimed atomically via CAS, results submitted with cost tracking. - **Git integration**: optional per-project (`repo_path` nullable). `GitService` wraps subprocess via `asyncio.to_thread()`. Config in `git.*` section. Phase 1 (foundation) complete; execution wiring (Phase 2+) pending. -- **Tests**: Backend: pytest-asyncio (auto mode), 731 tests. Frontend: vitest + @testing-library/react, 137 tests. Load tests: 7 (excluded from CI via `slow` marker) +- **Tests**: Backend: pytest-asyncio (auto mode), 797 tests. Frontend: vitest + @testing-library/react, 211 tests. Load tests: 7 (excluded from CI via `slow` marker) ## Git Workflow diff --git a/frontend/src/components/PlanTree/DependencyContext.tsx b/frontend/src/components/PlanTree/DependencyContext.tsx new file mode 100644 index 0000000..f611fe8 --- /dev/null +++ b/frontend/src/components/PlanTree/DependencyContext.tsx @@ -0,0 +1,80 @@ +// Orchestration Engine - PlanTree Dependency Context +// +// Shared state for dependency visualization: node DOM refs, +// dependency graph, and hover tracking. +// +// Depends on: (none) +// Used by: PlanTree/index.tsx, PlanTree/PlanTreeNode.tsx, PlanTree/DependencyOverlay.tsx + +import { createContext, useContext, useCallback, useRef, useState, useMemo } from 'react' + +/** Build reverse map: nodeId → list of nodes that depend on it */ +function buildDownstreamMap(depMap: Map): Map { + const downstream = new Map() + depMap.forEach((deps, nodeId) => { + for (const depId of deps) { + let list = downstream.get(depId) + if (!list) { + list = [] + downstream.set(depId, list) + } + list.push(nodeId) + } + }) + return downstream +} + +interface DependencyContextValue { + nodeRefs: React.MutableRefObject> + dependencyMap: Map + downstreamMap: Map + hoveredNodeId: string | null + setHoveredNodeId: (id: string | null) => void +} + +const DependencyCtx = createContext(null) + +export function DependencyProvider({ + dependencyMap, + children, +}: { + dependencyMap: Map + children: React.ReactNode +}) { + const nodeRefs = useRef(new Map()) + const [hoveredNodeId, setHoveredNodeId] = useState(null) + const downstreamMap = useMemo(() => buildDownstreamMap(dependencyMap), [dependencyMap]) + + return ( + + {children} + + ) +} + +export function useDependencyContext(): DependencyContextValue { + const ctx = useContext(DependencyCtx) + if (!ctx) throw new Error('useDependencyContext must be used within DependencyProvider') + return ctx +} + +export function useRegisterNode(id: string) { + const { nodeRefs } = useDependencyContext() + + return useCallback((el: HTMLElement | null) => { + if (el) { + nodeRefs.current.set(id, el) + } else { + nodeRefs.current.delete(id) + } + }, [id, nodeRefs]) +} + +export function useNodeDependencies(id: string) { + const { dependencyMap, downstreamMap } = useDependencyContext() + + const upstream = dependencyMap.get(id) ?? [] + const downstream = downstreamMap.get(id) ?? [] + + return { upstream, downstream } +} diff --git a/frontend/src/components/PlanTree/DependencyOverlay.tsx b/frontend/src/components/PlanTree/DependencyOverlay.tsx new file mode 100644 index 0000000..560485f --- /dev/null +++ b/frontend/src/components/PlanTree/DependencyOverlay.tsx @@ -0,0 +1,153 @@ +// Orchestration Engine - PlanTree Dependency Overlay +// +// SVG overlay that draws bezier curves between dependent task nodes. +// Recalculates on expand/collapse, resize, and scroll. +// +// Depends on: DependencyContext.tsx +// Used by: PlanTree/index.tsx + +import { useEffect, useRef, useState, useCallback } from 'react' +import { useDependencyContext } from './DependencyContext' + +interface PathData { + id: string + from: string + to: string + d: string + color: string +} + +export default function DependencyOverlay({ containerRef }: { containerRef: React.RefObject }) { + const { nodeRefs, dependencyMap, downstreamMap, hoveredNodeId, setHoveredNodeId } = useDependencyContext() + const [paths, setPaths] = useState([]) + const svgRef = useRef(null) + const rafRef = useRef(0) + + const recalculate = useCallback(() => { + cancelAnimationFrame(rafRef.current) + rafRef.current = requestAnimationFrame(() => { + const container = containerRef.current + if (!container) return + + const containerRect = container.getBoundingClientRect() + const newPaths: PathData[] = [] + + dependencyMap.forEach((deps, nodeId) => { + const targetEl = nodeRefs.current.get(nodeId) + if (!targetEl) return + + for (const depId of deps) { + const sourceEl = nodeRefs.current.get(depId) + if (!sourceEl) continue + + const sRect = sourceEl.getBoundingClientRect() + const tRect = targetEl.getBoundingClientRect() + + // Source: right-center of source node + const x1 = sRect.right - containerRect.left + const y1 = sRect.top + sRect.height / 2 - containerRect.top + + // Target: left edge of target node + const x2 = tRect.left - containerRect.left + const y2 = tRect.top + tRect.height / 2 - containerRect.top + + // Cubic bezier control points + const dx = Math.abs(x2 - x1) + const cp = Math.min(50, dx * 0.4) + + const d = `M ${x1} ${y1} C ${x1 + cp} ${y1}, ${x2 - cp} ${y2}, ${x2} ${y2}` + + // Get accent color from source node's computed style + const color = getComputedStyle(sourceEl).borderLeftColor || 'var(--accent)' + + newPaths.push({ + id: `${depId}->${nodeId}`, + from: depId, + to: nodeId, + d, + color, + }) + } + }) + + setPaths(newPaths) + }) + }, [containerRef, dependencyMap, nodeRefs]) + + // Recalculate on mount and when dependencies change + useEffect(() => { + recalculate() + + // ResizeObserver on container + const container = containerRef.current + if (!container) return + + const ro = new ResizeObserver(recalculate) + ro.observe(container) + + // Also recalculate on window scroll/resize + window.addEventListener('resize', recalculate) + window.addEventListener('scroll', recalculate, true) + + return () => { + ro.disconnect() + window.removeEventListener('resize', recalculate) + window.removeEventListener('scroll', recalculate, true) + cancelAnimationFrame(rafRef.current) + } + }, [recalculate]) + + // Hover changes only affect path styling (opacity, stroke-width, dash), + // not path positions — no recalculation needed. + + if (paths.length === 0) return null + + // Determine which paths to highlight + const highlightedFromIds = new Set() + const highlightedToIds = new Set() + if (hoveredNodeId) { + // Upstream: what the hovered node depends on + const upstream = dependencyMap.get(hoveredNodeId) ?? [] + for (const depId of upstream) { + highlightedFromIds.add(`${depId}->${hoveredNodeId}`) + } + // Downstream: what depends on the hovered node (pre-computed) + const downstream = downstreamMap.get(hoveredNodeId) ?? [] + for (const nodeId of downstream) { + highlightedToIds.add(`${hoveredNodeId}->${nodeId}`) + } + } + + const hasHighlight = hoveredNodeId != null + + return ( + + {paths.map(p => { + const isUpstream = highlightedFromIds.has(p.id) + const isDownstream = highlightedToIds.has(p.id) + const isHighlighted = isUpstream || isDownstream + const dimmed = hasHighlight && !isHighlighted + + return ( + setHoveredNodeId(p.to)} + onMouseLeave={() => setHoveredNodeId(null)} + /> + ) + })} + + ) +} diff --git a/frontend/src/components/PlanTree/NodeDetail.tsx b/frontend/src/components/PlanTree/NodeDetail.tsx new file mode 100644 index 0000000..7d3c091 --- /dev/null +++ b/frontend/src/components/PlanTree/NodeDetail.tsx @@ -0,0 +1,86 @@ +// Orchestration Engine - NodeDetail Panel +// +// Slide-in panel showing full details for a selected tree node. +// Uses aria-live to announce content changes to screen readers. +// +// Depends on: types.ts, theme.ts +// Used by: PlanTree/index.tsx + +import { useRef } from 'react' +import type { TreeNode } from './types' +import type { PlanTreeTheme } from './theme' +import { getNodeColors } from './theme' + +interface Props { + node: TreeNode | null + theme: PlanTreeTheme + onClose: () => void +} + +export default function NodeDetail({ node, theme, onClose }: Props) { + const panelRef = useRef(null) + + // Escape within the panel closes it. This is scoped to the panel's + // onKeyDown (not a global listener) to avoid double-firing with + // useTreeKeyboard's Escape handler on the tree container. + + if (!node?.detail) return null + + const colors = getNodeColors(theme, node.type) + + return ( +
{ if (e.key === 'Escape') onClose() }} + > +
+

{node.detail.title}

+ +
+ + {node.badges && node.badges.length > 0 && ( +
+ {node.badges.map((b, i) => { + const bc = getNodeColors(theme, b.colorKey) + return ( + + {b.text} + + ) + })} +
+ )} + +
+ {node.detail.sections.map((s, i) => ( +
+
{s.label}
+ {Array.isArray(s.content) ? ( +
    + {s.content.map((item, j) => ( +
  • {item}
  • + ))} +
+ ) : ( +
{s.content}
+ )} +
+ ))} +
+
+ ) +} diff --git a/frontend/src/components/PlanTree/PlanTree.css b/frontend/src/components/PlanTree/PlanTree.css new file mode 100644 index 0000000..fe42996 --- /dev/null +++ b/frontend/src/components/PlanTree/PlanTree.css @@ -0,0 +1,596 @@ +/* Orchestration Engine - PlanTree Styles + * + * Tree/node hybrid layout with connector lines, color-coded cards, + * slide-in detail panel, and smooth animations. + * + * Depends on: (none — self-contained) + * Used by: PlanTree/index.tsx + */ + +/* ── Container: tree + detail panel side by side ── */ +.pt-container { + display: flex; + gap: 0; + position: relative; + min-height: 200px; +} + +.pt-tree { + flex: 1; + min-width: 0; + transition: flex 0.25s ease; +} + +.pt-has-detail .pt-tree { + flex: 1 1 55%; +} + +/* ── Node wrapper: holds connector + card + children ── */ +.pt-node-wrapper { + position: relative; + padding-left: 24px; + margin-bottom: 2px; +} + +/* Top-level nodes: no left padding */ +.pt-tree > .pt-node-wrapper { + padding-left: 0; +} + +/* ── Connector lines ── */ + +/* Vertical line from parent down through children */ +.pt-children { + position: relative; + margin-left: 12px; + padding-left: 0; + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.25s ease-out; +} + +.pt-children.pt-expanded { + grid-template-rows: 1fr; +} + +.pt-children-inner { + overflow: hidden; +} + +.pt-children::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 12px; + width: 0; + border-left: 2px solid var(--border); +} + +/* Horizontal connector from vertical line to node */ +.pt-connector { + position: absolute; + left: 0; + top: 16px; + width: 20px; + height: 0; + border-top: 2px solid var(--border); +} + +/* Last child: cut the vertical line at the connector */ +.pt-node-wrapper[data-last="true"] > .pt-connector::before { + content: ''; + position: absolute; + left: -2px; + top: 0; + bottom: 0; + width: 4px; + height: 100vh; + background: var(--surface); +} + +/* ── Node card ── */ +.pt-node { + border-left: 3px solid transparent; + border-radius: 0 6px 6px 0; + padding: 8px 12px; + cursor: pointer; + transition: all 0.15s ease; + outline: none; + position: relative; +} + +.pt-node:hover { + filter: brightness(1.15); +} + +.pt-node:focus-visible { + box-shadow: 0 0 0 2px var(--accent); +} + +.pt-node-selected { + box-shadow: inset 0 0 0 1px var(--accent); +} + +/* ── Node header: toggle + label + detail arrow ── */ +.pt-node-header { + display: flex; + align-items: center; + gap: 8px; + min-height: 24px; +} + +/* Toggle button */ +.pt-toggle { + flex-shrink: 0; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + background: none; + border: none; + cursor: pointer; + padding: 0; + border-radius: 4px; + transition: background 0.1s; + line-height: 1; +} + +.pt-toggle:not(.pt-leaf):hover { + background: rgba(255, 255, 255, 0.08); +} + +.pt-leaf { + cursor: default; + opacity: 0.3; +} + +/* Label group */ +.pt-label-group { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; +} + +.pt-label { + font-size: 0.8125rem; + font-weight: 600; + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Badges inline */ +.pt-badges { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +.pt-badge { + display: inline-flex; + align-items: center; + padding: 1px 8px; + border-radius: 10px; + font-size: 0.625rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + border: 1px solid; + white-space: nowrap; +} + +/* Detail arrow */ +.pt-detail-arrow { + flex-shrink: 0; + font-size: 18px; + font-weight: 300; + opacity: 0; + transition: opacity 0.15s, transform 0.15s; + transform: translateX(-4px); +} + +.pt-node:hover .pt-detail-arrow { + opacity: 1; + transform: translateX(0); +} + +.pt-node-selected .pt-detail-arrow { + opacity: 1; + transform: translateX(0); +} + +/* Sublabel */ +.pt-sublabel { + font-size: 0.6875rem; + color: var(--text-dim); + margin-top: 2px; + padding-left: 28px; + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* ── Detail panel ── */ +.pt-detail-panel { + flex: 0 0 40%; + max-width: 400px; + border-left: 3px solid var(--accent); + background: var(--surface); + border-radius: 0 8px 8px 0; + padding: 16px; + overflow-y: auto; + max-height: 500px; + animation: pt-slide-in 0.25s ease; + box-shadow: -4px 0 12px rgba(0, 0, 0, 0.15); +} + +@keyframes pt-slide-in { + from { + opacity: 0; + transform: translateX(40%); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.pt-detail-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 8px; + margin-bottom: 12px; +} + +.pt-detail-header h4 { + font-size: 0.875rem; + line-height: 1.4; +} + +.pt-detail-close { + flex-shrink: 0; + background: none; + border: none; + color: var(--text-dim); + font-size: 20px; + cursor: pointer; + padding: 0 4px; + line-height: 1; + border-radius: 4px; +} + +.pt-detail-close:hover { + color: var(--text); + background: rgba(255, 255, 255, 0.08); +} + +.pt-detail-badges { + display: flex; + gap: 6px; + margin-bottom: 12px; + flex-wrap: wrap; +} + +.pt-detail-sections { + display: flex; + flex-direction: column; + gap: 12px; +} + +.pt-detail-section { + border-top: 1px solid var(--border); + padding-top: 8px; +} + +.pt-detail-section:first-child { + border-top: none; + padding-top: 0; +} + +.pt-detail-label { + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-dim); + margin-bottom: 4px; +} + +.pt-detail-value { + font-size: 0.8125rem; + color: var(--text); + line-height: 1.5; +} + +.pt-detail-list { + margin: 0; + padding-left: 16px; + font-size: 0.8125rem; + color: var(--text); + line-height: 1.6; +} + +.pt-detail-list li { + margin-bottom: 2px; +} + +/* ── Dependency overlay ── */ +.pt-dep-overlay { + z-index: 1; +} + +.pt-dep-line { + transition: opacity 0.2s ease, stroke-width 0.15s ease; +} + +/* ── Toolbar (expand/collapse all) ── */ +.pt-toolbar { + display: flex; + gap: 6px; + margin-bottom: 8px; +} + +.pt-toolbar-btn { + background: none; + border: 1px solid var(--border); + border-radius: 4px; + padding: 3px 10px; + font-size: 0.6875rem; + color: var(--text-dim); + cursor: pointer; + transition: all 0.15s; +} + +.pt-toolbar-btn:hover { + color: var(--text); + border-color: var(--text-dim); + background: rgba(255, 255, 255, 0.04); +} + +/* ── Light theme adjustments ── */ +[data-theme="light"] .pt-toggle:not(.pt-leaf):hover { + background: rgba(0, 0, 0, 0.06); +} + +[data-theme="light"] .pt-detail-close:hover { + background: rgba(0, 0, 0, 0.06); +} + +[data-theme="light"] .pt-node:hover { + filter: brightness(0.97); +} + +[data-theme="light"] .pt-toolbar-btn:hover { + background: rgba(0, 0, 0, 0.04); +} + +[data-theme="light"] .pt-detail-panel { + box-shadow: -4px 0 12px rgba(0, 0, 0, 0.08); +} + +/* ── Theme Configurator ── */ +.pt-theme-btn { + background: none; + border: 1px solid var(--border); + border-radius: 4px; + padding: 3px 8px; + font-size: 0.8125rem; + cursor: pointer; + color: var(--text-dim); + transition: all 0.15s; + margin-left: auto; +} + +.pt-theme-btn:hover { + border-color: var(--text-dim); + color: var(--text); +} + +.pt-theme-popover { + position: absolute; + top: 100%; + right: 0; + z-index: 10; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px; + min-width: 280px; + max-height: 400px; + overflow-y: auto; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); +} + +.pt-theme-group { + margin-bottom: 8px; +} + +.pt-theme-group-label { + font-size: 0.625rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-dim); + margin-bottom: 4px; +} + +.pt-theme-row { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 0; +} + +.pt-theme-name { + font-size: 0.75rem; + min-width: 60px; + color: var(--text); +} + +.pt-theme-swatch { + width: 18px; + height: 18px; + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.15); + cursor: pointer; + transition: transform 0.1s; +} + +.pt-theme-swatch:hover { + transform: scale(1.2); +} + +.pt-theme-swatch.active { + box-shadow: 0 0 0 2px var(--accent); +} + +.pt-theme-sliders { + padding: 6px 0 6px 66px; +} + +.pt-theme-slider-row { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 3px; +} + +.pt-theme-slider-label { + font-size: 0.625rem; + width: 12px; + color: var(--text-dim); +} + +.pt-theme-slider { + flex: 1; + height: 6px; + -webkit-appearance: none; + appearance: none; + border-radius: 3px; + outline: none; +} + +.pt-theme-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 12px; + height: 12px; + border-radius: 50%; + background: white; + border: 1px solid rgba(0, 0, 0, 0.3); + cursor: pointer; +} + +.pt-theme-reset { + background: none; + border: 1px solid var(--border); + border-radius: 4px; + padding: 4px 12px; + font-size: 0.6875rem; + color: var(--text-dim); + cursor: pointer; + width: 100%; + margin-top: 8px; +} + +.pt-theme-reset:hover { + color: var(--text); + border-color: var(--text-dim); +} + +/* ── Search ── */ +.pt-search { + display: flex; + align-items: center; + gap: 6px; + flex: 1; +} + +.pt-search-input { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 4px; + padding: 4px 8px; + font-size: 0.75rem; + color: var(--text); + outline: none; + min-width: 120px; +} + +.pt-search-input:focus { + border-color: var(--accent); +} + +.pt-search-info { + font-size: 0.6875rem; + color: var(--text-dim); + white-space: nowrap; +} + +.pt-search-nav { + background: none; + border: none; + color: var(--text-dim); + cursor: pointer; + padding: 2px 4px; + font-size: 0.75rem; +} + +.pt-search-nav:hover { + color: var(--text); +} + +.pt-search-nav:disabled { + opacity: 0.3; + cursor: default; +} + +mark.pt-highlight { + background: rgba(255, 200, 0, 0.3); + color: inherit; + border-radius: 2px; + padding: 0 1px; +} + +[data-theme="light"] mark.pt-highlight { + background: rgba(255, 200, 0, 0.5); +} + +.pt-node-dimmed { + opacity: 0.5; +} + +/* ── Responsive ── */ +@media (max-width: 767px) { + .pt-container { + flex-direction: column; + } + + .pt-detail-panel { + flex: none; + max-width: none; + border-left: none; + border-top: 3px solid var(--accent); + border-radius: 0 0 8px 8px; + max-height: 300px; + } + + @keyframes pt-slide-in { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } + } +} diff --git a/frontend/src/components/PlanTree/PlanTreeNode.tsx b/frontend/src/components/PlanTree/PlanTreeNode.tsx new file mode 100644 index 0000000..e5885d1 --- /dev/null +++ b/frontend/src/components/PlanTree/PlanTreeNode.tsx @@ -0,0 +1,192 @@ +// Orchestration Engine - PlanTreeNode Component +// +// Recursive tree node with expand/collapse, connector lines, +// color-coded left border, badges, and WAI-ARIA treeview roles. +// Uses centralized expand state and roving tabindex. +// +// Depends on: types.ts, theme.ts, DependencyContext.tsx, highlightText.ts, hooks/useExpandState.ts +// Used by: PlanTree/index.tsx + +import { useRef, useCallback } from 'react' +import type { TreeNode } from './types' +import type { PlanTreeTheme } from './theme' +import { getNodeColors } from './theme' +import { useRegisterNode, useDependencyContext } from './DependencyContext' +import { highlightText } from './highlightText' +import type { ExpandState } from './hooks/useExpandState' + +interface Props { + node: TreeNode + theme: PlanTreeTheme + depth: number + isLast: boolean + selectedId: string | null + focusedId: string | null + onSelect: (node: TreeNode) => void + expandState: ExpandState + siblingCount: number + positionIndex: number + visibleIds?: Set + matchIds?: Set + searchQuery?: string +} + +export default function PlanTreeNode({ + node, theme, depth, isLast, selectedId, focusedId, + onSelect, expandState, siblingCount, positionIndex, + visibleIds, matchIds, searchQuery, +}: Props) { + // All hooks must be called unconditionally (Rules of Hooks) + const nodeRef = useRef(null) + const registerRef = useRegisterNode(node.id) + const { setHoveredNodeId } = useDependencyContext() + const hasChildren = node.children.length > 0 + + const setNodeRef = useCallback((el: HTMLDivElement | null) => { + (nodeRef as React.MutableRefObject).current = el + registerRef(el) + }, [registerRef]) + + const handleMouseEnter = useCallback(() => { + if (node.dependsOn?.length) setHoveredNodeId(node.id) + }, [node.id, node.dependsOn, setHoveredNodeId]) + + const handleMouseLeave = useCallback(() => { + setHoveredNodeId(null) + }, [setHoveredNodeId]) + + const toggle = useCallback(() => { + if (hasChildren) expandState.toggle(node.id) + }, [hasChildren, expandState, node.id]) + + const handleClick = useCallback(() => { + if (node.detail) onSelect(node) + else toggle() + }, [node, onSelect, toggle]) + + // Search filtering: hide nodes not in visibleIds (after all hooks) + if (visibleIds && !visibleIds.has(node.id)) return null + + const isDimmed = matchIds && !matchIds.has(node.id) + const expanded = hasChildren && expandState.isExpanded(node.id) + const isSelected = selectedId === node.id + const isFocused = focusedId === node.id + const colors = getNodeColors(theme, node.type) + + // Chevron icon + const chevron = hasChildren + ? (expanded ? '▾' : '▸') + : '·' + + return ( +
+ {/* Connector lines */} + {depth > 0 && ( +
+ )} + + {/* Node card */} +
+
+ {/* Expand/collapse toggle */} + {hasChildren ? ( + + ) : ( + + )} + + {/* Label */} +
+ {searchQuery ? highlightText(node.label, searchQuery) : node.label} + {node.badges && node.badges.length > 0 && ( + + )} +
+ + {/* Detail indicator */} + {node.detail && ( + + )} +
+ + {/* Sublabel (truncated) */} + {node.sublabel && ( +
{searchQuery ? highlightText(node.sublabel, searchQuery) : node.sublabel}
+ )} +
+ + {/* Children — always rendered, CSS grid animates height */} + {hasChildren && ( +
+
+ {node.children.map((child, i) => ( + + ))} +
+
+ )} +
+ ) +} diff --git a/frontend/src/components/PlanTree/SearchBar.tsx b/frontend/src/components/PlanTree/SearchBar.tsx new file mode 100644 index 0000000..c753ef5 --- /dev/null +++ b/frontend/src/components/PlanTree/SearchBar.tsx @@ -0,0 +1,106 @@ +// Orchestration Engine - PlanTree Search Bar +// +// Compact search input with match count display and prev/next +// navigation. Debounces input before calling onChange. +// +// Depends on: PlanTree.css +// Used by: PlanTree/index.tsx + +import { useState, useRef, useCallback, useEffect } from 'react' + +interface Props { + query: string + onChange: (q: string) => void + matchCount: number + activeMatchIndex: number + onNext: () => void + onPrev: () => void +} + +export default function SearchBar({ + query, + onChange, + matchCount, + activeMatchIndex, + onNext, + onPrev, +}: Props) { + const [localValue, setLocalValue] = useState(query) + const timerRef = useRef | null>(null) + + // Sync local value when external query clears + useEffect(() => { + if (query === '') setLocalValue('') + }, [query]) + + const handleChange = useCallback((e: React.ChangeEvent) => { + const val = e.target.value + setLocalValue(val) + if (timerRef.current) clearTimeout(timerRef.current) + timerRef.current = setTimeout(() => onChange(val), 150) + }, [onChange]) + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + setLocalValue('') + onChange('') + return + } + if (e.key === 'ArrowDown' || (e.key === 'Enter' && !e.shiftKey)) { + e.preventDefault() + onNext() + } + if (e.key === 'ArrowUp' || (e.key === 'Enter' && e.shiftKey)) { + e.preventDefault() + onPrev() + } + }, [onChange, onNext, onPrev]) + + // Cleanup timer on unmount + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current) + } + }, []) + + const hasQuery = query.length > 0 + + return ( +
+ + {hasQuery && ( + <> + + {matchCount > 0 + ? `${activeMatchIndex + 1} of ${matchCount}` + : 'No matches'} + + + + + )} +
+ ) +} diff --git a/frontend/src/components/PlanTree/ThemeConfigurator.tsx b/frontend/src/components/PlanTree/ThemeConfigurator.tsx new file mode 100644 index 0000000..068e381 --- /dev/null +++ b/frontend/src/components/PlanTree/ThemeConfigurator.tsx @@ -0,0 +1,216 @@ +// Orchestration Engine - PlanTree Theme Configurator +// +// Compact popover for live HSL-based color editing of tree node types. +// Grouped by category, each node type shows 3 color swatches (accent, bg, text). +// Clicking a swatch reveals inline H/S/L sliders with spectrum backgrounds. +// +// Depends on: PlanTree/theme.ts, PlanTree/hooks/usePlanTreeTheme.ts +// Used by: PlanTree/index.tsx + +import { useState, useEffect, useCallback, useRef } from 'react' +import type { PlanTreeTheme, NodeType } from './theme' +import { NODE_TYPE_GROUPS, hexToHsl, hslToHex, getNodeColors } from './theme' + +type ColorRole = 'accent' | 'bg' | 'text' +const ROLES: ColorRole[] = ['accent', 'bg', 'text'] + +interface Props { + theme: PlanTreeTheme + setNodeColor: (nodeType: NodeType, role: ColorRole, hex: string) => void + resetNode: (nodeType: NodeType) => void + resetAll: () => void +} + +interface ActiveSwatch { + nodeType: NodeType + role: ColorRole +} + +export default function ThemeConfigurator({ theme, setNodeColor, resetNode, resetAll }: Props) { + const [open, setOpen] = useState(false) + const [active, setActive] = useState(null) + const popoverRef = useRef(null) + const btnRef = useRef(null) + + // Close on click-outside + useEffect(() => { + if (!open) return + const handler = (e: MouseEvent) => { + const target = e.target as Node + if (popoverRef.current?.contains(target)) return + if (btnRef.current?.contains(target)) return + setOpen(false) + setActive(null) + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [open]) + + // Close on Escape + useEffect(() => { + if (!open) return + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { setOpen(false); setActive(null) } + } + document.addEventListener('keydown', handler) + return () => document.removeEventListener('keydown', handler) + }, [open]) + + const togglePopover = useCallback(() => { + setOpen(o => { + if (o) setActive(null) + return !o + }) + }, []) + + const handleSwatchClick = useCallback((nodeType: NodeType, role: ColorRole) => { + setActive(prev => + prev?.nodeType === nodeType && prev.role === role ? null : { nodeType, role } + ) + }, []) + + return ( +
+ + {open && ( +
+ {NODE_TYPE_GROUPS.map(group => ( +
+
{group.label}
+ {group.types.map(nt => ( + + ))} +
+ ))} + +
+ )} +
+ ) +} + +// ── NodeRow: one node type with 3 swatches + optional sliders ── + +interface NodeRowProps { + nodeType: NodeType + theme: PlanTreeTheme + active: ActiveSwatch | null + onSwatchClick: (nodeType: NodeType, role: ColorRole) => void + setNodeColor: (nodeType: NodeType, role: ColorRole, hex: string) => void + resetNode: (nodeType: NodeType) => void +} + +function NodeRow({ nodeType, theme, active, onSwatchClick, setNodeColor, resetNode }: NodeRowProps) { + const colors = getNodeColors(theme, nodeType) + const isActive = active?.nodeType === nodeType + const activeRole = isActive ? active.role : null + const displayName = nodeType.replace('_', ' ') + + return ( +
+
+ {displayName} + {ROLES.map(role => ( +
onSwatchClick(nodeType, role)} + title={`${role}: ${colors[role]}`} + /> + ))} + +
+ {isActive && activeRole && ( + setNodeColor(nodeType, activeRole, hex)} + /> + )} +
+ ) +} + +// ── HSL Sliders ── + +interface HslSlidersProps { + hex: string + onChange: (hex: string) => void +} + +function HslSliders({ hex, onChange }: HslSlidersProps) { + const [h, s, l] = hexToHsl(hex) + + const handleH = (v: number) => onChange(hslToHex(v, s, l)) + const handleS = (v: number) => onChange(hslToHex(h, v, l)) + const handleL = (v: number) => onChange(hslToHex(h, s, v)) + + // Gradient backgrounds for slider tracks + const hueGrad = 'linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)' + const satGrad = `linear-gradient(to right, ${hslToHex(h, 0, l)}, ${hslToHex(h, 100, l)})` + const litGrad = `linear-gradient(to right, ${hslToHex(h, s, 0)}, ${hslToHex(h, s, 50)}, ${hslToHex(h, s, 100)})` + + return ( +
+ + + +
+ ) +} + +interface SliderRowProps { + label: string + value: number + max: number + gradient: string + onChange: (v: number) => void +} + +function SliderRow({ label, value, max, gradient, onChange }: SliderRowProps) { + return ( +
+ {label} + onChange(Number(e.target.value))} + style={{ background: gradient }} + /> + + {value} + +
+ ) +} diff --git a/frontend/src/components/PlanTree/__tests__/theme.test.ts b/frontend/src/components/PlanTree/__tests__/theme.test.ts new file mode 100644 index 0000000..9351828 --- /dev/null +++ b/frontend/src/components/PlanTree/__tests__/theme.test.ts @@ -0,0 +1,179 @@ +// Orchestration Engine - PlanTree Theme Tests +// +// Tests for HSL conversion utilities and usePlanTreeTheme hook. +// +// Depends on: PlanTree/theme.ts, PlanTree/hooks/usePlanTreeTheme.ts +// Used by: (test suite only) + +import { describe, it, expect, beforeEach } from 'vitest' +import { hexToHsl, hslToHex, hslToString, NODE_TYPE_GROUPS } from '../theme' + +describe('hexToHsl', () => { + it('converts pure red', () => { + expect(hexToHsl('#ff0000')).toEqual([0, 100, 50]) + }) + + it('converts pure green', () => { + expect(hexToHsl('#00ff00')).toEqual([120, 100, 50]) + }) + + it('converts pure blue', () => { + expect(hexToHsl('#0000ff')).toEqual([240, 100, 50]) + }) + + it('converts white', () => { + expect(hexToHsl('#ffffff')).toEqual([0, 0, 100]) + }) + + it('converts black', () => { + expect(hexToHsl('#000000')).toEqual([0, 0, 0]) + }) + + it('handles 3-digit hex shorthand', () => { + expect(hexToHsl('#f00')).toEqual([0, 100, 50]) + }) + + it('converts a mid-range color', () => { + // #4080c0 → roughly h=210, s=50, l=50 + const [h, s, l] = hexToHsl('#4080c0') + expect(h).toBe(210) + expect(s).toBe(50) + expect(l).toBe(50) + }) +}) + +describe('hslToHex', () => { + it('converts pure red', () => { + expect(hslToHex(0, 100, 50)).toBe('#ff0000') + }) + + it('converts pure green', () => { + expect(hslToHex(120, 100, 50)).toBe('#00ff00') + }) + + it('converts pure blue', () => { + expect(hslToHex(240, 100, 50)).toBe('#0000ff') + }) + + it('converts white', () => { + expect(hslToHex(0, 0, 100)).toBe('#ffffff') + }) + + it('converts black', () => { + expect(hslToHex(0, 0, 0)).toBe('#000000') + }) +}) + +describe('hexToHsl / hslToHex round-trip', () => { + const testCases = ['#ff0000', '#00ff00', '#0000ff', '#ffffff', '#000000'] + + testCases.forEach(hex => { + it(`round-trips ${hex}`, () => { + const [h, s, l] = hexToHsl(hex) + expect(hslToHex(h, s, l)).toBe(hex) + }) + }) + + it('round-trips arbitrary colors within 1 step tolerance', () => { + // Due to integer rounding in HSL, some colors may be off by 1 + const hex = '#5b9bd5' + const [h, s, l] = hexToHsl(hex) + const result = hslToHex(h, s, l) + // Compare each channel — allow +-2 due to HSL rounding + const parseChannel = (c: string, i: number) => parseInt(c.substring(1 + i * 2, 3 + i * 2), 16) + for (let i = 0; i < 3; i++) { + expect(Math.abs(parseChannel(hex, i) - parseChannel(result, i))).toBeLessThanOrEqual(2) + } + }) +}) + +describe('hslToString', () => { + it('formats correctly', () => { + expect(hslToString(220, 80, 70)).toBe('hsl(220, 80%, 70%)') + }) + + it('formats zero values', () => { + expect(hslToString(0, 0, 0)).toBe('hsl(0, 0%, 0%)') + }) +}) + +describe('NODE_TYPE_GROUPS', () => { + it('has 4 groups', () => { + expect(NODE_TYPE_GROUPS).toHaveLength(4) + }) + + it('covers all expected node types', () => { + const allTypes = NODE_TYPE_GROUPS.flatMap(g => g.types) + expect(allTypes).toContain('plan') + expect(allTypes).toContain('phase') + expect(allTypes).toContain('code') + expect(allTypes).toContain('risk') + expect(allTypes).toContain('simple') + expect(allTypes).toContain('complex') + expect(allTypes).toHaveLength(14) + }) +}) + +describe('usePlanTreeTheme localStorage', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('stores and retrieves overrides from localStorage', async () => { + const { renderHook, act } = await import('@testing-library/react') + const { usePlanTreeTheme } = await import('../hooks/usePlanTreeTheme') + + const { result } = renderHook(() => usePlanTreeTheme()) + + act(() => { + result.current.setNodeColor('plan', 'accent', '#ff0000') + }) + + expect(result.current.theme.plan.accent).toBe('#ff0000') + // Check localStorage has the value + const stored = JSON.parse(localStorage.getItem('plantree-theme-dark') || '{}') + expect(stored.plan.accent).toBe('#ff0000') + }) + + it('resetAll clears all overrides', async () => { + const { renderHook, act } = await import('@testing-library/react') + const { usePlanTreeTheme } = await import('../hooks/usePlanTreeTheme') + + const { result } = renderHook(() => usePlanTreeTheme()) + + act(() => { + result.current.setNodeColor('plan', 'accent', '#ff0000') + result.current.setNodeColor('phase', 'bg', '#00ff00') + }) + + expect(result.current.theme.plan.accent).toBe('#ff0000') + + act(() => { + result.current.resetAll() + }) + + // Should be back to default + expect(result.current.theme.plan.accent).not.toBe('#ff0000') + expect(localStorage.getItem('plantree-theme-dark')).toBeNull() + }) + + it('resetNode clears a single node override', async () => { + const { renderHook, act } = await import('@testing-library/react') + const { usePlanTreeTheme } = await import('../hooks/usePlanTreeTheme') + + const { result } = renderHook(() => usePlanTreeTheme()) + + act(() => { + result.current.setNodeColor('plan', 'accent', '#ff0000') + result.current.setNodeColor('phase', 'bg', '#00ff00') + }) + + act(() => { + result.current.resetNode('plan') + }) + + // plan should be back to default, phase should still be overridden + expect(result.current.theme.plan.accent).not.toBe('#ff0000') + expect(result.current.theme.phase.bg).toBe('#00ff00') + }) +}) diff --git a/frontend/src/components/PlanTree/__tests__/useTreeKeyboard.test.ts b/frontend/src/components/PlanTree/__tests__/useTreeKeyboard.test.ts new file mode 100644 index 0000000..5bff810 --- /dev/null +++ b/frontend/src/components/PlanTree/__tests__/useTreeKeyboard.test.ts @@ -0,0 +1,258 @@ +// Orchestration Engine - useTreeKeyboard Tests +// +// Tests WAI-ARIA Treeview keyboard navigation: arrow keys, +// Home/End, Enter/Space selection, expand/collapse, and +// visibility-based node skipping. +// +// Depends on: ../hooks/useTreeKeyboard.ts, ../hooks/useExpandState.ts, ../types.ts +// Used by: (tests only) + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useTreeKeyboard } from '../hooks/useTreeKeyboard' +import { useExpandState } from '../hooks/useExpandState' +import type { TreeNode } from '../types' + +// ── Test fixtures ── + +function makeNode(id: string, children: TreeNode[] = [], opts: Partial = {}): TreeNode { + return { + id, + type: 'code', + label: `Node ${id}`, + children, + badges: [], + ...opts, + } +} + +// Tree structure: +// A (expanded by default) +// A1 (leaf) +// A2 (has children, explicitly collapsed) +// A2a (leaf) +// B (leaf) +function buildTestTree(): TreeNode[] { + const a2a = makeNode('A2a') + const a2 = makeNode('A2', [a2a], { defaultExpanded: false }) + const a1 = makeNode('A1') + const a = makeNode('A', [a1, a2], { defaultExpanded: true }) + const b = makeNode('B') + return [a, b] +} + +// Helper to create a keyboard event object for the hook +function keyEvent(key: string): React.KeyboardEvent { + return { + key, + preventDefault: vi.fn(), + } as unknown as React.KeyboardEvent +} + +// ── Tests ── + +describe('useTreeKeyboard', () => { + let roots: TreeNode[] + let onSelect: ReturnType + let onDeselect: ReturnType + + beforeEach(() => { + roots = buildTestTree() + onSelect = vi.fn() + onDeselect = vi.fn() + // Mock querySelector for scrollIntoView + vi.spyOn(document, 'querySelector').mockReturnValue(null) + }) + + function renderBoth() { + // Render both hooks together so expandState is shared + return renderHook(() => { + const expandState = useExpandState(roots) + const keyboard = useTreeKeyboard(roots, expandState, onSelect, onDeselect) + return { expandState, keyboard } + }) + } + + it('initializes focusedId to the first root node', () => { + const { result } = renderBoth() + expect(result.current.keyboard.focusedId).toBe('A') + }) + + it('Arrow Down moves focus to the next visible node', () => { + const { result } = renderBoth() + + // A is expanded (depth 0 < 2), so visible order is: A, A1, A2, B + // A2 is collapsed (depth 1 < 2 means expanded, but A2's children at depth 2 are not auto-expanded) + act(() => result.current.keyboard.handleTreeKeyDown(keyEvent('ArrowDown'))) + expect(result.current.keyboard.focusedId).toBe('A1') + + act(() => result.current.keyboard.handleTreeKeyDown(keyEvent('ArrowDown'))) + expect(result.current.keyboard.focusedId).toBe('A2') + + act(() => result.current.keyboard.handleTreeKeyDown(keyEvent('ArrowDown'))) + expect(result.current.keyboard.focusedId).toBe('B') + }) + + it('Arrow Down at the last node is a no-op', () => { + const { result } = renderBoth() + + // Navigate to B (last visible) + act(() => result.current.keyboard.handleTreeKeyDown(keyEvent('End'))) + expect(result.current.keyboard.focusedId).toBe('B') + + act(() => result.current.keyboard.handleTreeKeyDown(keyEvent('ArrowDown'))) + expect(result.current.keyboard.focusedId).toBe('B') + }) + + it('Arrow Up moves focus to the previous visible node', () => { + const { result } = renderBoth() + + // Move to A1 first + act(() => result.current.keyboard.handleTreeKeyDown(keyEvent('ArrowDown'))) + expect(result.current.keyboard.focusedId).toBe('A1') + + act(() => result.current.keyboard.handleTreeKeyDown(keyEvent('ArrowUp'))) + expect(result.current.keyboard.focusedId).toBe('A') + }) + + it('Arrow Up at the first node is a no-op', () => { + const { result } = renderBoth() + act(() => result.current.keyboard.handleTreeKeyDown(keyEvent('ArrowUp'))) + expect(result.current.keyboard.focusedId).toBe('A') + }) + + it('Arrow Right expands a collapsed node with children', () => { + const { result } = renderBoth() + + // Navigate to A2 (collapsed) + act(() => result.current.keyboard.setFocusedId('A2')) + expect(result.current.expandState.isExpanded('A2')).toBe(false) + + act(() => result.current.keyboard.handleTreeKeyDown(keyEvent('ArrowRight'))) + expect(result.current.expandState.isExpanded('A2')).toBe(true) + }) + + it('Arrow Right on expanded node moves to first child', () => { + const { result } = renderBoth() + + // A is expanded, ArrowRight should move to A1 + expect(result.current.expandState.isExpanded('A')).toBe(true) + act(() => result.current.keyboard.handleTreeKeyDown(keyEvent('ArrowRight'))) + expect(result.current.keyboard.focusedId).toBe('A1') + }) + + it('Arrow Right on a leaf node is a no-op', () => { + const { result } = renderBoth() + + act(() => result.current.keyboard.setFocusedId('A1')) + act(() => result.current.keyboard.handleTreeKeyDown(keyEvent('ArrowRight'))) + expect(result.current.keyboard.focusedId).toBe('A1') + }) + + it('Arrow Left collapses an expanded node', () => { + const { result } = renderBoth() + + // A is expanded + expect(result.current.expandState.isExpanded('A')).toBe(true) + act(() => result.current.keyboard.handleTreeKeyDown(keyEvent('ArrowLeft'))) + expect(result.current.expandState.isExpanded('A')).toBe(false) + }) + + it('Arrow Left on collapsed node moves to parent', () => { + const { result } = renderBoth() + + // Navigate to A1 (child of A) + act(() => result.current.keyboard.setFocusedId('A1')) + act(() => result.current.keyboard.handleTreeKeyDown(keyEvent('ArrowLeft'))) + expect(result.current.keyboard.focusedId).toBe('A') + }) + + it('Home jumps to the first visible node', () => { + const { result } = renderBoth() + + act(() => result.current.keyboard.setFocusedId('B')) + act(() => result.current.keyboard.handleTreeKeyDown(keyEvent('Home'))) + expect(result.current.keyboard.focusedId).toBe('A') + }) + + it('End jumps to the last visible node', () => { + const { result } = renderBoth() + act(() => result.current.keyboard.handleTreeKeyDown(keyEvent('End'))) + expect(result.current.keyboard.focusedId).toBe('B') + }) + + it('Enter triggers onSelect with the focused node', () => { + const { result } = renderBoth() + act(() => result.current.keyboard.handleTreeKeyDown(keyEvent('Enter'))) + expect(onSelect).toHaveBeenCalledWith(roots[0]) + }) + + it('Space triggers onSelect with the focused node', () => { + const { result } = renderBoth() + act(() => result.current.keyboard.handleTreeKeyDown(keyEvent(' '))) + expect(onSelect).toHaveBeenCalledWith(roots[0]) + }) + + it('Escape triggers onDeselect', () => { + const { result } = renderBoth() + act(() => result.current.keyboard.handleTreeKeyDown(keyEvent('Escape'))) + expect(onDeselect).toHaveBeenCalled() + }) + + it('nodes with collapsed parents are skipped in navigation', () => { + const { result } = renderBoth() + + // Collapse A so its children are hidden + act(() => result.current.expandState.collapse('A')) + + // Visible order is now: A, B + act(() => result.current.keyboard.handleTreeKeyDown(keyEvent('ArrowDown'))) + expect(result.current.keyboard.focusedId).toBe('B') + }) + + it('* expands all siblings at the current level', () => { + const { result } = renderBoth() + + // Collapse A first + act(() => result.current.expandState.collapse('A')) + expect(result.current.expandState.isExpanded('A')).toBe(false) + + // Press * — A and B are siblings. A has children, B does not. + // A should be expanded. + act(() => result.current.keyboard.handleTreeKeyDown(keyEvent('*'))) + expect(result.current.expandState.isExpanded('A')).toBe(true) + }) + + it('expanding a node reveals its children for navigation', () => { + const { result } = renderBoth() + + // A2 is collapsed by default (depth >= 2). Navigate to it and expand. + act(() => result.current.keyboard.setFocusedId('A2')) + act(() => result.current.keyboard.handleTreeKeyDown(keyEvent('ArrowRight'))) + expect(result.current.expandState.isExpanded('A2')).toBe(true) + + // Now ArrowDown from A2 should go to A2a + act(() => result.current.keyboard.handleTreeKeyDown(keyEvent('ArrowDown'))) + expect(result.current.keyboard.focusedId).toBe('A2a') + }) + + it('visibleNodes list reflects expand state', () => { + const { result } = renderBoth() + + // Default: A expanded, A2 collapsed + const ids = result.current.keyboard.visibleNodes.map(v => v.id) + expect(ids).toEqual(['A', 'A1', 'A2', 'B']) + + // Expand A2 + act(() => result.current.expandState.expand('A2')) + const ids2 = result.current.keyboard.visibleNodes.map(v => v.id) + expect(ids2).toEqual(['A', 'A1', 'A2', 'A2a', 'B']) + }) + + it('preventDefault is called on handled keys', () => { + const { result } = renderBoth() + const event = keyEvent('ArrowDown') + act(() => result.current.keyboard.handleTreeKeyDown(event)) + expect(event.preventDefault).toHaveBeenCalled() + }) +}) diff --git a/frontend/src/components/PlanTree/__tests__/useTreeSearch.test.ts b/frontend/src/components/PlanTree/__tests__/useTreeSearch.test.ts new file mode 100644 index 0000000..718af3e --- /dev/null +++ b/frontend/src/components/PlanTree/__tests__/useTreeSearch.test.ts @@ -0,0 +1,143 @@ +// Orchestration Engine - useTreeSearch Tests +// +// Tests for the tree search hook: visibility filtering, match detection, +// case insensitivity, and multi-field matching. +// +// Depends on: hooks/useTreeSearch.ts, types.ts +// Used by: (test suite) + +import { renderHook, act } from '@testing-library/react' +import { useTreeSearch } from '../hooks/useTreeSearch' +import type { TreeNode } from '../types' + +function makeNode(overrides: Partial & { id: string }): TreeNode { + return { + type: 'code', + label: overrides.label ?? overrides.id, + children: [], + ...overrides, + } +} + +// Tree structure: +// root +// ├── phase1 (label: "Setup Phase") +// │ ├── task1 (label: "Build API", sublabel: "REST endpoints") +// │ └── task2 (label: "Write Tests") +// └── phase2 (label: "Deploy Phase") +// └── task3 (label: "Configure CI", badges: [{text:"complex", colorKey:"complex"}]) +const tree: TreeNode = makeNode({ + id: 'root', + label: 'Project Root', + children: [ + makeNode({ + id: 'phase1', + label: 'Setup Phase', + type: 'phase', + children: [ + makeNode({ id: 'task1', label: 'Build API', sublabel: 'REST endpoints' }), + makeNode({ id: 'task2', label: 'Write Tests' }), + ], + }), + makeNode({ + id: 'phase2', + label: 'Deploy Phase', + type: 'phase', + children: [ + makeNode({ + id: 'task3', + label: 'Configure CI', + badges: [{ text: 'complex', colorKey: 'complex' }], + }), + ], + }), + ], +}) + +describe('useTreeSearch', () => { + it('returns all nodes visible and no matches when query is empty', () => { + const { result } = renderHook(() => useTreeSearch(tree, '')) + expect(result.current.visibleIds.size).toBe(6) // root + phase1 + task1 + task2 + phase2 + task3 + expect(result.current.matchIds.size).toBe(0) + expect(result.current.matchCount).toBe(0) + }) + + it('makes matching leaf node and its ancestors visible', () => { + const { result } = renderHook(() => useTreeSearch(tree, 'Build API')) + expect(result.current.matchIds.has('task1')).toBe(true) + expect(result.current.matchCount).toBe(1) + // Ancestors should be visible but not match + expect(result.current.visibleIds.has('root')).toBe(true) + expect(result.current.visibleIds.has('phase1')).toBe(true) + expect(result.current.visibleIds.has('task1')).toBe(true) + // Unrelated branch should not be visible + expect(result.current.visibleIds.has('phase2')).toBe(false) + expect(result.current.visibleIds.has('task3')).toBe(false) + }) + + it('returns empty match set when nothing matches', () => { + const { result } = renderHook(() => useTreeSearch(tree, 'nonexistent xyz')) + expect(result.current.matchIds.size).toBe(0) + expect(result.current.matchCount).toBe(0) + expect(result.current.visibleIds.size).toBe(0) + }) + + it('performs case-insensitive matching', () => { + const { result } = renderHook(() => useTreeSearch(tree, 'build api')) + expect(result.current.matchIds.has('task1')).toBe(true) + expect(result.current.matchCount).toBe(1) + }) + + it('matches against sublabel text', () => { + const { result } = renderHook(() => useTreeSearch(tree, 'REST')) + expect(result.current.matchIds.has('task1')).toBe(true) + expect(result.current.matchCount).toBe(1) + }) + + it('matches against badge text', () => { + const { result } = renderHook(() => useTreeSearch(tree, 'complex')) + expect(result.current.matchIds.has('task3')).toBe(true) + expect(result.current.matchCount).toBe(1) + }) + + it('matches multiple nodes with a common term', () => { + const { result } = renderHook(() => useTreeSearch(tree, 'Phase')) + // Both "Setup Phase" and "Deploy Phase" match + expect(result.current.matchIds.has('phase1')).toBe(true) + expect(result.current.matchIds.has('phase2')).toBe(true) + expect(result.current.matchCount).toBe(2) + }) + + it('provides match navigation that wraps around', () => { + const { result } = renderHook(() => useTreeSearch(tree, 'Phase')) + expect(result.current.activeMatchIndex).toBe(0) + + act(() => result.current.nextMatch()) + expect(result.current.activeMatchIndex).toBe(1) + + act(() => result.current.nextMatch()) + expect(result.current.activeMatchIndex).toBe(0) // wraps + + act(() => result.current.prevMatch()) + expect(result.current.activeMatchIndex).toBe(1) // wraps backward + }) + + it('matches against detail section content', () => { + const nodeWithDetail: TreeNode = makeNode({ + id: 'root', + label: 'Root', + children: [ + makeNode({ + id: 'detailed', + label: 'Some Task', + detail: { + title: 'Task Detail', + sections: [{ label: 'Notes', content: 'Uses PostgreSQL database' }], + }, + }), + ], + }) + const { result } = renderHook(() => useTreeSearch(nodeWithDetail, 'PostgreSQL')) + expect(result.current.matchIds.has('detailed')).toBe(true) + }) +}) diff --git a/frontend/src/components/PlanTree/buildTree.ts b/frontend/src/components/PlanTree/buildTree.ts new file mode 100644 index 0000000..ae771ac --- /dev/null +++ b/frontend/src/components/PlanTree/buildTree.ts @@ -0,0 +1,239 @@ +// Orchestration Engine - PlanTree Builder +// +// Converts PlanData (from backend API) into a TreeNode hierarchy +// for rendering by PlanTree. +// +// Depends on: types.ts, ../../types/index.ts +// Used by: PlanTree/index.tsx + +import type { PlanData, PlanTask, PlanPhase, PlanOpenQuestion, PlanRisk, PlanTestStrategy } from '../../types' +import type { TreeNode, Badge } from './types' +import type { NodeType } from './theme' + +function taskTypeToNode(tt: string): NodeType { + const map: Record = { + code: 'code', research: 'research', analysis: 'analysis', + asset: 'asset', integration: 'integration', documentation: 'documentation', + } + return map[tt] ?? 'code' +} + +function complexityToNode(c: string): NodeType { + const map: Record = { simple: 'simple', medium: 'medium', complex: 'complex' } + return map[c] ?? 'medium' +} + +function buildTaskNode(task: PlanTask, index: number, phasePrefix: string, allTaskIds: Map): TreeNode { + const badges: Badge[] = [ + { text: task.task_type, colorKey: taskTypeToNode(task.task_type) }, + { text: task.complexity, colorKey: complexityToNode(task.complexity) }, + ] + + const depStr = task.depends_on?.length + ? task.depends_on.map(d => typeof d === 'number' ? `Task ${d}` : d).join(', ') + : 'none' + + const toolStr = task.tools_needed?.length ? task.tools_needed.join(', ') : 'none' + + const nodeId = `${phasePrefix}task-${index}` + + // Resolve dependency indices to node IDs + const dependsOn = task.depends_on + ?.map(d => typeof d === 'number' ? allTaskIds.get(d) : allTaskIds.get(parseInt(String(d), 10))) + .filter((id): id is string => id != null) ?? [] + + return { + id: nodeId, + type: taskTypeToNode(task.task_type), + label: task.title, + sublabel: task.description, + badges, + children: [], + dependsOn, + taskIndex: index, + detail: { + title: task.title, + sections: [ + { label: 'Description', content: task.description }, + { label: 'Type', content: task.task_type }, + { label: 'Complexity', content: task.complexity }, + { label: 'Dependencies', content: depStr }, + { label: 'Tools', content: toolStr }, + ], + }, + } +} + +function buildPhaseNode(phase: PlanPhase, phaseIndex: number, allTaskIds: Map): TreeNode { + const taskNodes = phase.tasks.map((t, i) => buildTaskNode(t, i, `p${phaseIndex}-`, allTaskIds)) + return { + id: `phase-${phaseIndex}`, + type: 'phase', + label: phase.name, + sublabel: phase.description, + badges: [{ text: `${phase.tasks.length} task${phase.tasks.length !== 1 ? 's' : ''}`, colorKey: 'phase' }], + children: taskNodes, + defaultExpanded: true, + } +} + +function buildQuestionNode(q: PlanOpenQuestion, index: number): TreeNode { + return { + id: `question-${index}`, + type: 'question', + label: q.question, + sublabel: `Proposed: ${q.proposed_answer}`, + badges: [], + children: [], + detail: { + title: q.question, + sections: [ + { label: 'Proposed Answer', content: q.proposed_answer }, + { label: 'Impact', content: q.impact }, + ], + }, + } +} + +function buildRiskNode(r: PlanRisk, index: number): TreeNode { + return { + id: `risk-${index}`, + type: 'risk', + label: r.risk, + sublabel: `Mitigation: ${r.mitigation}`, + badges: [ + { text: `likelihood: ${r.likelihood}`, colorKey: complexityToNode(r.likelihood) }, + { text: `impact: ${r.impact}`, colorKey: complexityToNode(r.impact) }, + ], + children: [], + detail: { + title: r.risk, + sections: [ + { label: 'Likelihood', content: r.likelihood }, + { label: 'Impact', content: r.impact }, + { label: 'Mitigation', content: r.mitigation }, + ], + }, + } +} + +function buildTestStrategyNode(ts: PlanTestStrategy): TreeNode { + return { + id: 'test-strategy', + type: 'test_strategy', + label: 'Test Strategy', + sublabel: ts.approach, + badges: [], + children: [], + detail: { + title: 'Test Strategy', + sections: [ + { label: 'Approach', content: ts.approach }, + ...(ts.test_tasks?.length ? [{ label: 'Test Tasks', content: ts.test_tasks }] : []), + ...(ts.coverage_notes ? [{ label: 'Coverage Notes', content: ts.coverage_notes }] : []), + ], + }, + } +} + +export interface BuildResult { + tree: TreeNode + dependencyMap: Map // nodeId → nodeIds it depends on +} + +// Pre-compute a map of task index → node ID across all phases +function buildTaskIdMap(plan: PlanData): Map { + const map = new Map() + if (plan.phases && plan.phases.length > 0) { + let globalIndex = 0 + for (let pi = 0; pi < plan.phases.length; pi++) { + for (let ti = 0; ti < plan.phases[pi].tasks.length; ti++) { + map.set(globalIndex, `p${pi}-task-${ti}`) + globalIndex++ + } + } + } else if (plan.tasks) { + for (let i = 0; i < plan.tasks.length; i++) { + map.set(i, `task-${i}`) + } + } + return map +} + +// Collect all dependency edges from a tree into a flat map +function collectDependencies(node: TreeNode, map: Map): void { + if (node.dependsOn && node.dependsOn.length > 0) { + map.set(node.id, node.dependsOn) + } + for (const child of node.children) { + collectDependencies(child, map) + } +} + +export function buildPlanTree(plan: PlanData): BuildResult { + const children: TreeNode[] = [] + const allTaskIds = buildTaskIdMap(plan) + + // Phases (L2/L3) or flat tasks (L1) + if (plan.phases && plan.phases.length > 0) { + children.push(...plan.phases.map((p, i) => buildPhaseNode(p, i, allTaskIds))) + } else if (plan.tasks && plan.tasks.length > 0) { + const tasksGroup: TreeNode = { + id: 'tasks-group', + type: 'code', + label: 'Tasks', + sublabel: `${plan.tasks.length} task${plan.tasks.length !== 1 ? 's' : ''}`, + badges: [], + children: plan.tasks.map((t, i) => buildTaskNode(t, i, '', allTaskIds)), + defaultExpanded: true, + } + children.push(tasksGroup) + } + + // Questions + if (plan.open_questions && plan.open_questions.length > 0) { + const questionsGroup: TreeNode = { + id: 'questions-group', + type: 'question', + label: 'Open Questions', + sublabel: `${plan.open_questions.length} question${plan.open_questions.length !== 1 ? 's' : ''}`, + badges: [], + children: plan.open_questions.map((q, i) => buildQuestionNode(q, i)), + defaultExpanded: false, + } + children.push(questionsGroup) + } + + // Risks + if (plan.risk_assessment && plan.risk_assessment.length > 0) { + const risksGroup: TreeNode = { + id: 'risks-group', + type: 'risk', + label: 'Risk Assessment', + sublabel: `${plan.risk_assessment.length} risk${plan.risk_assessment.length !== 1 ? 's' : ''}`, + badges: [], + children: plan.risk_assessment.map((r, i) => buildRiskNode(r, i)), + defaultExpanded: false, + } + children.push(risksGroup) + } + + // Test strategy + if (plan.test_strategy) { + children.push(buildTestStrategyNode(plan.test_strategy)) + } + + const tree: TreeNode = { + id: 'plan-root', + type: 'plan', + label: plan.summary, + badges: [], + children, + defaultExpanded: true, + } + + const dependencyMap = new Map() + collectDependencies(tree, dependencyMap) + + return { tree, dependencyMap } +} diff --git a/frontend/src/components/PlanTree/highlightText.ts b/frontend/src/components/PlanTree/highlightText.ts new file mode 100644 index 0000000..eb70757 --- /dev/null +++ b/frontend/src/components/PlanTree/highlightText.ts @@ -0,0 +1,37 @@ +// Orchestration Engine - Text Highlight Utility +// +// Splits text by case-insensitive query match and wraps matching +// segments in elements for search result highlighting. +// +// Depends on: (none) +// Used by: PlanTree/PlanTreeNode.tsx + +import { createElement } from 'react' +import type { ReactNode } from 'react' + +/** Escape special regex characters in a string. */ +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +/** + * Highlight occurrences of `query` in `text` by wrapping them in + * elements. Returns original text + * if query is empty. + */ +export function highlightText(text: string, query: string): ReactNode { + if (!query) return text + + const escaped = escapeRegex(query) + const regex = new RegExp(`(${escaped})`, 'gi') + const parts = text.split(regex) + + if (parts.length === 1) return text + + const testRegex = new RegExp(`^${escaped}$`, 'i') + return parts.map((part, i) => + testRegex.test(part) + ? createElement('mark', { className: 'pt-highlight', key: i }, part) + : part + ) +} diff --git a/frontend/src/components/PlanTree/hooks/useExpandState.ts b/frontend/src/components/PlanTree/hooks/useExpandState.ts new file mode 100644 index 0000000..c14c96e --- /dev/null +++ b/frontend/src/components/PlanTree/hooks/useExpandState.ts @@ -0,0 +1,104 @@ +// Orchestration Engine - PlanTree Expand State Hook +// +// Centralized expand/collapse state for the tree. Provides a Map +// and toggle/expandAll/collapseAll operations. Replaces per-node local state +// so the keyboard hook can compute visible nodes. +// +// Depends on: ../types.ts +// Used by: ../index.tsx, useTreeKeyboard.ts + +import { useState, useCallback, useMemo, useEffect } from 'react' +import type { TreeNode } from '../types' + +/** Walk tree and collect initial expanded state based on defaultExpanded / depth */ +function computeDefaults(node: TreeNode, depth: number, out: Map): void { + const hasChildren = node.children.length > 0 + if (hasChildren) { + out.set(node.id, node.defaultExpanded ?? depth < 2) + } + for (const child of node.children) { + computeDefaults(child, depth + 1, out) + } +} + +export interface ExpandState { + expandedMap: Map + isExpanded: (id: string) => boolean + toggle: (id: string) => void + expand: (id: string) => void + collapse: (id: string) => void + expandAll: () => void + collapseAll: () => void +} + +export function useExpandState(roots: TreeNode[]): ExpandState { + const defaultMap = useMemo(() => { + const m = new Map() + for (const root of roots) { + computeDefaults(root, 0, m) + } + return m + }, [roots]) + + const [expandedMap, setExpandedMap] = useState>(defaultMap) + + // Re-sync when tree changes: add new node defaults, remove stale entries + useEffect(() => { + setExpandedMap(prev => { + const next = new Map() + for (const [id, defaultVal] of defaultMap) { + next.set(id, prev.get(id) ?? defaultVal) + } + return next + }) + }, [defaultMap]) + + const isExpanded = useCallback( + (id: string) => expandedMap.get(id) ?? false, + [expandedMap], + ) + + const toggle = useCallback((id: string) => { + setExpandedMap(prev => { + const next = new Map(prev) + next.set(id, !prev.get(id)) + return next + }) + }, []) + + const expand = useCallback((id: string) => { + setExpandedMap(prev => { + if (prev.get(id) === true) return prev + const next = new Map(prev) + next.set(id, true) + return next + }) + }, []) + + const collapse = useCallback((id: string) => { + setExpandedMap(prev => { + if (prev.get(id) === false) return prev + const next = new Map(prev) + next.set(id, false) + return next + }) + }, []) + + const expandAll = useCallback(() => { + setExpandedMap(prev => { + const next = new Map(prev) + for (const key of next.keys()) next.set(key, true) + return next + }) + }, []) + + const collapseAll = useCallback(() => { + setExpandedMap(prev => { + const next = new Map(prev) + for (const key of next.keys()) next.set(key, false) + return next + }) + }, []) + + return { expandedMap, isExpanded, toggle, expand, collapse, expandAll, collapseAll } +} diff --git a/frontend/src/components/PlanTree/hooks/usePlanTreeTheme.ts b/frontend/src/components/PlanTree/hooks/usePlanTreeTheme.ts new file mode 100644 index 0000000..a7633f7 --- /dev/null +++ b/frontend/src/components/PlanTree/hooks/usePlanTreeTheme.ts @@ -0,0 +1,117 @@ +// Orchestration Engine - PlanTree Theme Hook +// +// Reactive theme management with localStorage persistence, +// MutationObserver for data-theme changes, and deep merge. +// +// Depends on: PlanTree/theme.ts +// Used by: PlanTree/index.tsx + +import { useState, useEffect, useCallback, useRef } from 'react' +import type { PlanTreeTheme, NodeType, NodeColors } from '../theme' +import { defaultTheme, lightTheme } from '../theme' + +type ColorRole = 'accent' | 'bg' | 'text' + +const STORAGE_KEY_DARK = 'plantree-theme-dark' +const STORAGE_KEY_LIGHT = 'plantree-theme-light' + +type NodeOverrides = Partial>> + +function getStorageKey(isDark: boolean): string { + return isDark ? STORAGE_KEY_DARK : STORAGE_KEY_LIGHT +} + +function loadOverrides(isDark: boolean): NodeOverrides { + try { + const raw = localStorage.getItem(getStorageKey(isDark)) + return raw ? JSON.parse(raw) : {} + } catch { + return {} + } +} + +function saveOverrides(isDark: boolean, overrides: NodeOverrides): void { + const key = getStorageKey(isDark) + if (Object.keys(overrides).length === 0) { + localStorage.removeItem(key) + } else { + localStorage.setItem(key, JSON.stringify(overrides)) + } +} + +function detectDark(): boolean { + if (typeof document === 'undefined') return true + return document.documentElement.getAttribute('data-theme') !== 'light' +} + +/** Deep-merge node-level overrides onto a base theme */ +function mergeTheme(base: PlanTreeTheme, overrides: NodeOverrides): PlanTreeTheme { + const result = { ...base } + for (const key of Object.keys(overrides) as NodeType[]) { + const nodeOverride = overrides[key] + if (nodeOverride) { + result[key] = { ...base[key], ...nodeOverride } + } + } + return result +} + +export interface UsePlanTreeThemeResult { + theme: PlanTreeTheme + isDark: boolean + setNodeColor: (nodeType: NodeType, role: ColorRole, hex: string) => void + resetNode: (nodeType: NodeType) => void + resetAll: () => void +} + +export function usePlanTreeTheme(): UsePlanTreeThemeResult { + const [isDark, setIsDark] = useState(detectDark) + const [overrides, setOverrides] = useState(() => loadOverrides(detectDark())) + const isDarkRef = useRef(isDark) + isDarkRef.current = isDark + + // Watch data-theme attribute changes + useEffect(() => { + if (typeof document === 'undefined') return + + const observer = new MutationObserver(() => { + const dark = detectDark() + setIsDark(dark) + setOverrides(loadOverrides(dark)) + }) + + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-theme'], + }) + + return () => observer.disconnect() + }, []) + + const setNodeColor = useCallback((nodeType: NodeType, role: ColorRole, hex: string) => { + setOverrides(prev => { + const next = { ...prev, [nodeType]: { ...prev[nodeType], [role]: hex } } + saveOverrides(isDarkRef.current, next) + return next + }) + }, []) + + const resetNode = useCallback((nodeType: NodeType) => { + setOverrides(prev => { + const next = { ...prev } + delete next[nodeType] + saveOverrides(isDarkRef.current, next) + return next + }) + }, []) + + const resetAll = useCallback(() => { + setOverrides({}) + saveOverrides(isDarkRef.current, {}) + }, []) + + const base = isDark ? defaultTheme : lightTheme + const theme = mergeTheme(base, overrides) + + return { theme, isDark, setNodeColor, resetNode, resetAll } +} diff --git a/frontend/src/components/PlanTree/hooks/useTreeKeyboard.ts b/frontend/src/components/PlanTree/hooks/useTreeKeyboard.ts new file mode 100644 index 0000000..fdc6f30 --- /dev/null +++ b/frontend/src/components/PlanTree/hooks/useTreeKeyboard.ts @@ -0,0 +1,186 @@ +// Orchestration Engine - PlanTree Keyboard Navigation Hook +// +// Implements WAI-ARIA Treeview keyboard navigation pattern with +// roving tabindex. Computes visible nodes from expand state and +// handles Arrow, Home, End, Enter, Space, Escape, and * keys. +// +// Depends on: ../types.ts, useExpandState.ts +// Used by: ../index.tsx + +import { useState, useCallback, useRef, useEffect } from 'react' +import type { TreeNode } from '../types' +import type { ExpandState } from './useExpandState' + +export interface VisibleNode { + id: string + node: TreeNode + depth: number + parentId: string | null + siblings: TreeNode[] + index: number // position among siblings +} + +/** Depth-first traversal that skips children of collapsed nodes */ +function computeVisibleNodes( + nodes: TreeNode[], + expandState: ExpandState, + depth: number, + parentId: string | null, + out: VisibleNode[], +): void { + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i] + out.push({ id: node.id, node, depth, parentId, siblings: nodes, index: i }) + if (node.children.length > 0 && expandState.isExpanded(node.id)) { + computeVisibleNodes(node.children, expandState, depth + 1, node.id, out) + } + } +} + +export interface UseTreeKeyboardResult { + focusedId: string | null + setFocusedId: (id: string | null) => void + handleTreeKeyDown: (e: React.KeyboardEvent) => void + visibleNodes: VisibleNode[] +} + +export function useTreeKeyboard( + roots: TreeNode[], + expandState: ExpandState, + onSelect: (node: TreeNode) => void, + onDeselect: () => void, +): UseTreeKeyboardResult { + const [focusedId, setFocusedId] = useState( + roots.length > 0 ? roots[0].id : null, + ) + + // Keep a ref to avoid stale closures in the keydown handler + const focusedIdRef = useRef(focusedId) + useEffect(() => { focusedIdRef.current = focusedId }, [focusedId]) + + // Compute visible nodes (memoized by identity — recalculated each render) + const visibleNodes: VisibleNode[] = [] + computeVisibleNodes(roots, expandState, 0, null, visibleNodes) + + const visibleRef = useRef(visibleNodes) + visibleRef.current = visibleNodes + + const findIndex = useCallback(() => { + const id = focusedIdRef.current + return visibleRef.current.findIndex(v => v.id === id) + }, []) + + const focusNode = useCallback((id: string) => { + setFocusedId(id) + // Scroll the node into view + const el = document.querySelector(`[data-node-id="${CSS.escape(id)}"]`) as HTMLElement | null + el?.scrollIntoView({ block: 'nearest' }) + }, []) + + const handleTreeKeyDown = useCallback((e: React.KeyboardEvent) => { + const visible = visibleRef.current + if (visible.length === 0) return + + const idx = findIndex() + const current = idx >= 0 ? visible[idx] : null + + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + if (idx < visible.length - 1) focusNode(visible[idx + 1].id) + break + + case 'ArrowUp': + e.preventDefault() + if (idx > 0) focusNode(visible[idx - 1].id) + break + + case 'ArrowRight': + e.preventDefault() + handleArrowRight(current, expandState, visible, idx, focusNode) + break + + case 'ArrowLeft': + e.preventDefault() + handleArrowLeft(current, expandState, focusNode) + break + + case 'Home': + e.preventDefault() + if (visible.length > 0) focusNode(visible[0].id) + break + + case 'End': + e.preventDefault() + if (visible.length > 0) focusNode(visible[visible.length - 1].id) + break + + case 'Enter': + case ' ': + e.preventDefault() + if (current) onSelect(current.node) + break + + case 'Escape': + e.preventDefault() + onDeselect() + break + + case '*': + e.preventDefault() + handleExpandSiblings(current, expandState) + break + } + }, [expandState, onSelect, onDeselect, findIndex, focusNode]) + + return { focusedId, setFocusedId, handleTreeKeyDown, visibleNodes } +} + +/** ArrowRight: expand collapsed, move to first child if expanded, no-op on leaf */ +function handleArrowRight( + current: VisibleNode | null, + expandState: ExpandState, + visible: VisibleNode[], + idx: number, + focusNode: (id: string) => void, +): void { + if (!current) return + const hasChildren = current.node.children.length > 0 + if (!hasChildren) return + + if (!expandState.isExpanded(current.id)) { + expandState.expand(current.id) + } else if (idx < visible.length - 1) { + // Move to first child (next visible node at deeper depth) + focusNode(visible[idx + 1].id) + } +} + +/** ArrowLeft: collapse expanded, move to parent if collapsed/leaf */ +function handleArrowLeft( + current: VisibleNode | null, + expandState: ExpandState, + focusNode: (id: string) => void, +): void { + if (!current) return + const hasChildren = current.node.children.length > 0 + + if (hasChildren && expandState.isExpanded(current.id)) { + expandState.collapse(current.id) + } else if (current.parentId) { + focusNode(current.parentId) + } +} + +/** * key: expand all siblings at the current level */ +function handleExpandSiblings( + current: VisibleNode | null, + expandState: ExpandState, +): void { + if (!current) return + for (const sibling of current.siblings) { + if (sibling.children.length > 0) { + expandState.expand(sibling.id) + } + } +} diff --git a/frontend/src/components/PlanTree/hooks/useTreeSearch.ts b/frontend/src/components/PlanTree/hooks/useTreeSearch.ts new file mode 100644 index 0000000..3a43925 --- /dev/null +++ b/frontend/src/components/PlanTree/hooks/useTreeSearch.ts @@ -0,0 +1,122 @@ +// Orchestration Engine - Tree Search Hook +// +// Filters tree nodes by search query, tracking which nodes match +// and which ancestors should remain visible. Provides match navigation. +// +// Depends on: types.ts +// Used by: PlanTree/index.tsx + +import { useState, useMemo, useCallback } from 'react' +import type { TreeNode } from '../types' + +interface TreeSearchResult { + visibleIds: Set + matchIds: Set + matchCount: number + activeMatchIndex: number + setActiveMatchIndex: (index: number) => void + nextMatch: () => void + prevMatch: () => void +} + +/** Check if a single node's own content matches the query (case-insensitive). */ +function nodeMatchesQuery(node: TreeNode, lowerQuery: string): boolean { + if (node.label.toLowerCase().includes(lowerQuery)) return true + if (node.sublabel?.toLowerCase().includes(lowerQuery)) return true + if (node.badges?.some(b => b.text.toLowerCase().includes(lowerQuery))) return true + if (node.detail) { + for (const section of node.detail.sections) { + const content = section.content + if (typeof content === 'string') { + if (content.toLowerCase().includes(lowerQuery)) return true + } else { + if (content.some(line => line.toLowerCase().includes(lowerQuery))) return true + } + } + } + return false +} + +/** + * Recursively collect matchIds and visibleIds for a subtree. + * Returns true if this node or any descendant matches. + */ +function collectMatches( + node: TreeNode, + lowerQuery: string, + matchIds: Set, + visibleIds: Set, +): boolean { + const selfMatches = nodeMatchesQuery(node, lowerQuery) + let anyChildMatches = false + + for (const child of node.children) { + if (collectMatches(child, lowerQuery, matchIds, visibleIds)) { + anyChildMatches = true + } + } + + if (selfMatches) { + matchIds.add(node.id) + visibleIds.add(node.id) + return true + } + + if (anyChildMatches) { + visibleIds.add(node.id) + return true + } + + return false +} + +/** Collect all node IDs in a tree (for empty-query case). */ +function collectAllIds(node: TreeNode, out: Set): void { + out.add(node.id) + for (const child of node.children) { + collectAllIds(child, out) + } +} + +export function useTreeSearch(tree: TreeNode, query: string): TreeSearchResult { + const [activeMatchIndex, setActiveMatchIndex] = useState(0) + + const { visibleIds, matchIds } = useMemo(() => { + const vis = new Set() + const mat = new Set() + + if (!query.trim()) { + collectAllIds(tree, vis) + return { visibleIds: vis, matchIds: mat } + } + + const lowerQuery = query.toLowerCase() + collectMatches(tree, lowerQuery, mat, vis) + return { visibleIds: vis, matchIds: mat } + }, [tree, query]) + + const matchCount = matchIds.size + + // Clamp activeMatchIndex when matchCount changes + const clampedIndex = matchCount === 0 ? 0 : Math.min(activeMatchIndex, matchCount - 1) + + const nextMatch = useCallback(() => { + if (matchCount === 0) return + setActiveMatchIndex(prev => (prev + 1) % matchCount) + }, [matchCount]) + + const prevMatch = useCallback(() => { + if (matchCount === 0) return + setActiveMatchIndex(prev => (prev - 1 + matchCount) % matchCount) + }, [matchCount]) + + return { + visibleIds, + matchIds, + matchCount, + activeMatchIndex: clampedIndex, + setActiveMatchIndex, + nextMatch, + prevMatch, + } +} diff --git a/frontend/src/components/PlanTree/index.tsx b/frontend/src/components/PlanTree/index.tsx new file mode 100644 index 0000000..3822fe9 --- /dev/null +++ b/frontend/src/components/PlanTree/index.tsx @@ -0,0 +1,135 @@ +// Orchestration Engine - PlanTree Component +// +// Hybrid tree/node visualization for plan data. Renders plan structure +// as a navigable tree with color-coded nodes, connector lines, and +// a slide-in detail panel. Supports configurable themes. +// +// Depends on: types.ts, theme.ts, buildTree.ts, PlanTreeNode.tsx, NodeDetail.tsx, +// SearchBar.tsx, hooks/useTreeSearch.ts, hooks/usePlanTreeTheme.ts, +// ThemeConfigurator.tsx +// Used by: pages/ProjectDetail.tsx + +import { useState, useMemo, useCallback, useRef } from 'react' +import './PlanTree.css' +import type { PlanData } from '../../types' +import { buildPlanTree } from './buildTree' +import type { TreeNode } from './types' +import PlanTreeNode from './PlanTreeNode' +import NodeDetail from './NodeDetail' +import { DependencyProvider } from './DependencyContext' +import DependencyOverlay from './DependencyOverlay' +import { usePlanTreeTheme } from './hooks/usePlanTreeTheme' +import { useExpandState } from './hooks/useExpandState' +import { useTreeKeyboard } from './hooks/useTreeKeyboard' +import { useTreeSearch } from './hooks/useTreeSearch' +import ThemeConfigurator from './ThemeConfigurator' +import SearchBar from './SearchBar' + +interface Props { + plan: PlanData +} + +export default function PlanTree({ plan }: Props) { + const { theme, setNodeColor, resetNode, resetAll } = usePlanTreeTheme() + const { tree, dependencyMap } = useMemo(() => buildPlanTree(plan), [plan]) + const [selectedNode, setSelectedNode] = useState(null) + const [searchQuery, setSearchQuery] = useState('') + const containerRef = useRef(null) + const lastFocusedNodeRef = useRef(null) + + const expandState = useExpandState(tree.children) + + const handleSelect = useCallback((node: TreeNode) => { + setSelectedNode(prev => prev?.id === node.id ? null : node) + }, []) + + const handleClose = useCallback(() => { + setSelectedNode(null) + // Return focus to the node that opened the detail panel + if (lastFocusedNodeRef.current) { + const el = document.querySelector( + `[data-node-id="${CSS.escape(lastFocusedNodeRef.current)}"]`, + ) as HTMLElement | null + el?.focus() + } + }, []) + + const { focusedId, handleTreeKeyDown } = useTreeKeyboard( + tree.children, expandState, handleSelect, handleClose, + ) + + // Track which node had focus before detail panel opens + if (focusedId) lastFocusedNodeRef.current = focusedId + + const { visibleIds, matchIds, matchCount, activeMatchIndex, nextMatch, prevMatch } = + useTreeSearch(tree, searchQuery) + + const hasSearch = searchQuery.length > 0 + + return ( + +
+
+ + + + +
+ +
+ {tree.children.map((child, i) => ( + + ))} +
+ + + + +
+
+ ) +} + +// Re-export theme types for consumers +export type { PlanTreeTheme } from './theme' +export { defaultTheme, lightTheme } from './theme' diff --git a/frontend/src/components/PlanTree/theme.ts b/frontend/src/components/PlanTree/theme.ts new file mode 100644 index 0000000..03dfd02 --- /dev/null +++ b/frontend/src/components/PlanTree/theme.ts @@ -0,0 +1,160 @@ +// Orchestration Engine - PlanTree Theme +// +// Configurable color map keyed to node function. Each node type gets +// a distinct accent color for its left border, icon, and badge. +// Includes HSL utilities for the runtime theme configurator. +// +// Depends on: (none) +// Used by: PlanTree/index.tsx, PlanTree/PlanTreeNode.tsx, +// PlanTree/hooks/usePlanTreeTheme.ts, PlanTree/ThemeConfigurator.tsx + +export interface NodeColors { + accent: string // Left border, icon tint + bg: string // Node background (subtle) + text: string // Label color +} + +export interface PlanTreeTheme { + // Structural + plan: NodeColors + phase: NodeColors + // Task types + code: NodeColors + research: NodeColors + analysis: NodeColors + asset: NodeColors + integration: NodeColors + documentation: NodeColors + // Meta sections + question: NodeColors + risk: NodeColors + test_strategy: NodeColors + // Complexity + simple: NodeColors + medium: NodeColors + complex: NodeColors + // Connector lines + connectorColor: string + connectorWidth: number + // Selection + selectedBorder: string + selectedBg: string +} + +export const defaultTheme: PlanTreeTheme = { + plan: { accent: '#6c8cff', bg: '#1a2040', text: '#e1e4ed' }, + phase: { accent: '#ab47bc', bg: '#2a1b3a', text: '#ce93d8' }, + + code: { accent: '#4fc3f7', bg: '#1b2a3a', text: '#b3e5fc' }, + research: { accent: '#66bb6a', bg: '#1b3a2a', text: '#a5d6a7' }, + analysis: { accent: '#ffa726', bg: '#3a2a1b', text: '#ffcc80' }, + asset: { accent: '#ef5350', bg: '#3a1b1b', text: '#ef9a9a' }, + integration: { accent: '#7e57c2', bg: '#2a1b3a', text: '#b39ddb' }, + documentation: { accent: '#78909c', bg: '#1b2a2a', text: '#b0bec5' }, + + question: { accent: '#ffb74d', bg: '#3a2a1b', text: '#ffe0b2' }, + risk: { accent: '#e57373', bg: '#3a1b1b', text: '#ffcdd2' }, + test_strategy: { accent: '#81c784', bg: '#1b3a1b', text: '#c8e6c9' }, + + simple: { accent: '#66bb6a', bg: '#1b3a2a', text: '#a5d6a7' }, + medium: { accent: '#ffa726', bg: '#3a2a1b', text: '#ffcc80' }, + complex: { accent: '#ef5350', bg: '#3a1b1b', text: '#ef9a9a' }, + + connectorColor: '#2a2d3a', + connectorWidth: 2, + + selectedBorder: '#6c8cff', + selectedBg: '#1a2040', +} + +export const lightTheme: PlanTreeTheme = { + plan: { accent: '#4a6cf7', bg: '#e8edf8', text: '#1a1d27' }, + phase: { accent: '#7b1fa2', bg: '#f3e5f5', text: '#4a148c' }, + + code: { accent: '#0277bd', bg: '#e1f5fe', text: '#01579b' }, + research: { accent: '#2e7d32', bg: '#e8f5e9', text: '#1b5e20' }, + analysis: { accent: '#e65100', bg: '#fff3e0', text: '#bf360c' }, + asset: { accent: '#c62828', bg: '#ffebee', text: '#b71c1c' }, + integration: { accent: '#4527a0', bg: '#ede7f6', text: '#311b92' }, + documentation: { accent: '#546e7a', bg: '#eceff1', text: '#37474f' }, + + question: { accent: '#ef6c00', bg: '#fff3e0', text: '#e65100' }, + risk: { accent: '#c62828', bg: '#ffebee', text: '#b71c1c' }, + test_strategy: { accent: '#2e7d32', bg: '#e8f5e9', text: '#1b5e20' }, + + simple: { accent: '#2e7d32', bg: '#e8f5e9', text: '#1b5e20' }, + medium: { accent: '#e65100', bg: '#fff3e0', text: '#bf360c' }, + complex: { accent: '#c62828', bg: '#ffebee', text: '#b71c1c' }, + + connectorColor: '#dde0e8', + connectorWidth: 2, + + selectedBorder: '#4a6cf7', + selectedBg: '#e8edf8', +} + +export type NodeType = keyof Omit + +/** All node types grouped by category — used by ThemeConfigurator */ +export const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [ + { label: 'Structural', types: ['plan', 'phase'] }, + { label: 'Task Types', types: ['code', 'research', 'analysis', 'asset', 'integration', 'documentation'] }, + { label: 'Meta', types: ['question', 'risk', 'test_strategy'] }, + { label: 'Complexity', types: ['simple', 'medium', 'complex'] }, +] + +export function getNodeColors(theme: PlanTreeTheme, nodeType: NodeType): NodeColors { + return theme[nodeType] ?? theme.plan +} + +// ── HSL Utilities ── + +/** Convert hex color (#rrggbb or #rgb) to [h, s, l] where h: 0-360, s/l: 0-100 */ +export function hexToHsl(hex: string): [number, number, number] { + const cleaned = hex.replace('#', '') + const full = cleaned.length === 3 + ? cleaned[0] + cleaned[0] + cleaned[1] + cleaned[1] + cleaned[2] + cleaned[2] + : cleaned + const r = parseInt(full.substring(0, 2), 16) / 255 + const g = parseInt(full.substring(2, 4), 16) / 255 + const b = parseInt(full.substring(4, 6), 16) / 255 + + const max = Math.max(r, g, b) + const min = Math.min(r, g, b) + const l = (max + min) / 2 + if (max === min) return [0, 0, Math.round(l * 100)] + + const d = max - min + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min) + let h = 0 + if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6 + else if (max === g) h = ((b - r) / d + 2) / 6 + else h = ((r - g) / d + 4) / 6 + + return [Math.round(h * 360), Math.round(s * 100), Math.round(l * 100)] +} + +/** Convert HSL values to hex string (#rrggbb). h: 0-360, s/l: 0-100 */ +export function hslToHex(h: number, s: number, l: number): string { + const sn = s / 100 + const ln = l / 100 + const c = (1 - Math.abs(2 * ln - 1)) * sn + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)) + const m = ln - c / 2 + + let r = 0, g = 0, b = 0 + if (h < 60) { r = c; g = x } + else if (h < 120) { r = x; g = c } + else if (h < 180) { g = c; b = x } + else if (h < 240) { g = x; b = c } + else if (h < 300) { r = x; b = c } + else { r = c; b = x } + + const toHex = (v: number) => Math.round((v + m) * 255).toString(16).padStart(2, '0') + return `#${toHex(r)}${toHex(g)}${toHex(b)}` +} + +/** Format HSL values as a CSS string */ +export function hslToString(h: number, s: number, l: number): string { + return `hsl(${h}, ${s}%, ${l}%)` +} diff --git a/frontend/src/components/PlanTree/types.ts b/frontend/src/components/PlanTree/types.ts new file mode 100644 index 0000000..f3dcb61 --- /dev/null +++ b/frontend/src/components/PlanTree/types.ts @@ -0,0 +1,37 @@ +// Orchestration Engine - PlanTree Node Types +// +// Internal tree model that PlanTree builds from PlanData. +// Each node has a type, label, children, and optional detail payload. +// +// Depends on: theme.ts +// Used by: PlanTree/index.tsx, PlanTree/PlanTreeNode.tsx, PlanTree/NodeDetail.tsx + +import type { NodeType } from './theme' + +export interface TreeNode { + id: string + type: NodeType + label: string + sublabel?: string + badges?: Badge[] + children: TreeNode[] + detail?: NodeDetailData + defaultExpanded?: boolean + dependsOn?: string[] // IDs of nodes this task depends on + taskIndex?: number // Original task index from plan data +} + +export interface Badge { + text: string + colorKey: NodeType +} + +export interface NodeDetailData { + title: string + sections: DetailSection[] +} + +export interface DetailSection { + label: string + content: string | string[] +} diff --git a/frontend/src/pages/ProjectDetail.test.tsx b/frontend/src/pages/ProjectDetail.test.tsx index 4f91229..9a9e271 100644 --- a/frontend/src/pages/ProjectDetail.test.tsx +++ b/frontend/src/pages/ProjectDetail.test.tsx @@ -315,7 +315,7 @@ describe('ProjectDetail', () => { setupDefaultMocks({ status: 'ready' }, [makePlan({ status: 'approved' })]) renderProjectDetail() expect(await screen.findByText('Test plan summary')).toBeInTheDocument() - expect(screen.getByText(/Plan v1/)).toBeInTheDocument() + expect(screen.getByText('v1')).toBeInTheDocument() }) it('shows SSE events section when connected', async () => { diff --git a/frontend/src/pages/ProjectDetail.tsx b/frontend/src/pages/ProjectDetail.tsx index 7602047..bfbfa35 100644 --- a/frontend/src/pages/ProjectDetail.tsx +++ b/frontend/src/pages/ProjectDetail.tsx @@ -10,6 +10,7 @@ import { import { useSSE } from '../hooks/useSSE' import { useFetch } from '../hooks/useFetch' import type { Project, Plan, Task, Checkpoint, CoverageReport, PlanningRigor } from '../types' +import PlanTree from '../components/PlanTree' interface ProjectData { project: Project @@ -92,6 +93,8 @@ export default function ProjectDetail() { await action('rigor', () => updateProject(id!, { planning_rigor: newRigor })) } + const [expandedPlanId, setExpandedPlanId] = useState(null) + if (!id) return
Invalid URL — missing project ID.
if (error && !project) return
Error: {error}
if (!project) return
Loading project...
@@ -100,6 +103,9 @@ export default function ProjectDetail() { const draftPlan = plans.find(p => p.status === 'draft') const unresolvedCheckpoints = checkpoints.filter(c => !c.resolved_at) + // Auto-expand latest plan + const activePlanId = expandedPlanId ?? latestPlan?.id ?? null + return ( <>
@@ -181,85 +187,57 @@ export default function ProjectDetail() { )}
- {/* Plan */} - {latestPlan && ( + {/* Plan History */} + {plans.length > 0 && (
-
-

Plan v{latestPlan.version} {latestPlan.status}

- - {latestPlan.model_used} | {latestPlan.prompt_tokens + latestPlan.completion_tokens} tokens | - ${latestPlan.cost_usd.toFixed(4)} - +
+

Plans ({plans.length} version{plans.length !== 1 ? 's' : ''})

+ {project.status === 'draft' && ( + + )}
-

{latestPlan.plan.summary}

- - {/* Phases */} - {latestPlan.plan.phases && latestPlan.plan.phases.length > 0 && ( -
-

Phases

- {latestPlan.plan.phases.map((phase, i) => ( -
- {phase.name} - — {phase.description} - ({phase.tasks.length} task{phase.tasks.length !== 1 ? 's' : ''}) -
- ))} -
- )} - {/* Open Questions */} - {latestPlan.plan.open_questions && latestPlan.plan.open_questions.length > 0 && ( -
-

Open Questions

- {latestPlan.plan.open_questions.map((q, i) => ( -
-
{q.question}
-
Proposed: {q.proposed_answer}
-
Impact: {q.impact}
-
- ))} -
- )} + {plans.map(plan => { + const isExpanded = activePlanId === plan.id + const isLatest = plan.id === latestPlan.id + const taskCount = plan.plan.phases + ? plan.plan.phases.reduce((sum, p) => sum + p.tasks.length, 0) + : (plan.plan.tasks?.length ?? 0) - {/* Risk Assessment */} - {latestPlan.plan.risk_assessment && latestPlan.plan.risk_assessment.length > 0 && ( -
-

Risk Assessment

- {latestPlan.plan.risk_assessment.map((r, i) => ( -
+ return ( +
+
setExpandedPlanId(isExpanded ? null : plan.id)} + style={{ cursor: 'pointer' }}>
- {r.risk} - - {r.likelihood} - {' '} - {r.impact} - +
+ v{plan.version} + {plan.status} + {isLatest && latest} + + {new Date(plan.created_at * 1000).toLocaleString()} + +
+
+ {taskCount} task{taskCount !== 1 ? 's' : ''} | {plan.model_used} | + ${plan.cost_usd.toFixed(4)} + {isExpanded ? '▾' : '▸'} +
-
Mitigation: {r.mitigation}
+

{plan.plan.summary}

- ))} -
- )} - {/* Test Strategy */} - {latestPlan.plan.test_strategy && ( -
-

Test Strategy

-
{latestPlan.plan.test_strategy.approach}
- {latestPlan.plan.test_strategy.test_tasks && latestPlan.plan.test_strategy.test_tasks.length > 0 && ( -
    - {latestPlan.plan.test_strategy.test_tasks.map((t, i) => ( -
  • {t}
  • - ))} -
- )} - {latestPlan.plan.test_strategy.coverage_notes && ( -
- {latestPlan.plan.test_strategy.coverage_notes} -
- )} -
- )} + {isExpanded && ( +
+ +
+ )} +
+ ) + })}
)} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index e9c958b..955e400 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -254,6 +254,24 @@ pre { background: var(--bg); padding: 0.75rem; border-radius: var(--radius); ove padding: 0.5rem 0; border-bottom: 1px solid var(--border); } +/* Plan version timeline */ +.plan-version { + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.75rem 1rem; + margin-bottom: 0.5rem; + background: var(--surface); + transition: border-color 0.15s; +} +.plan-version:hover { border-color: var(--primary); } +.plan-version-latest { border-left: 3px solid var(--primary); } +.plan-version-header p { margin: 0; } +.plan-version-body { + border-top: 1px solid var(--border); + margin-top: 0.75rem; + padding-top: 0.5rem; +} + /* Risk badges */ .badge.risk-low { background: #1b3a1b; color: var(--success); } .badge.risk-medium { background: #3a2a1b; color: var(--warning); } diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index a9d0dd3..8c5dfaa 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -1 +1,8 @@ import '@testing-library/jest-dom/vitest' + +// Stub ResizeObserver for jsdom (used by DependencyOverlay) +;(globalThis as Record).ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +}