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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
80 changes: 80 additions & 0 deletions frontend/src/components/PlanTree/DependencyContext.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string[]>): Map<string, string[]> {
const downstream = new Map<string, string[]>()
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<Map<string, HTMLElement>>
dependencyMap: Map<string, string[]>
downstreamMap: Map<string, string[]>
hoveredNodeId: string | null
setHoveredNodeId: (id: string | null) => void
}

const DependencyCtx = createContext<DependencyContextValue | null>(null)

export function DependencyProvider({
dependencyMap,
children,
}: {
dependencyMap: Map<string, string[]>
children: React.ReactNode
}) {
const nodeRefs = useRef(new Map<string, HTMLElement>())
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null)
const downstreamMap = useMemo(() => buildDownstreamMap(dependencyMap), [dependencyMap])

return (
<DependencyCtx.Provider value={{ nodeRefs, dependencyMap, downstreamMap, hoveredNodeId, setHoveredNodeId }}>
{children}
</DependencyCtx.Provider>
)
}

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 }
}
153 changes: 153 additions & 0 deletions frontend/src/components/PlanTree/DependencyOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement | null> }) {
const { nodeRefs, dependencyMap, downstreamMap, hoveredNodeId, setHoveredNodeId } = useDependencyContext()
const [paths, setPaths] = useState<PathData[]>([])
const svgRef = useRef<SVGSVGElement>(null)
const rafRef = useRef<number>(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<string>()
const highlightedToIds = new Set<string>()
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 (
<svg
ref={svgRef}
className="pt-dep-overlay"
style={{ position: 'absolute', inset: 0, pointerEvents: 'none', overflow: 'visible' }}
>
{paths.map(p => {
const isUpstream = highlightedFromIds.has(p.id)
const isDownstream = highlightedToIds.has(p.id)
const isHighlighted = isUpstream || isDownstream
const dimmed = hasHighlight && !isHighlighted

return (
<path
key={p.id}
d={p.d}
fill="none"
stroke={p.color}
strokeWidth={isHighlighted ? 2 : 1.5}
strokeDasharray={isDownstream ? '6 3' : 'none'}
opacity={dimmed ? 0.12 : isHighlighted ? 0.9 : 0.3}
className="pt-dep-line"
style={{ pointerEvents: 'auto' }}
onMouseEnter={() => setHoveredNodeId(p.to)}
onMouseLeave={() => setHoveredNodeId(null)}
/>
)
})}
</svg>
)
}
86 changes: 86 additions & 0 deletions frontend/src/components/PlanTree/NodeDetail.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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 (
<div
ref={panelRef}
className="pt-detail-panel"
style={{ borderLeftColor: colors.accent }}
aria-live="polite"
aria-label="Node details"
onKeyDown={e => { if (e.key === 'Escape') onClose() }}
>
<div className="pt-detail-header">
<h4 style={{ color: colors.text, margin: 0 }}>{node.detail.title}</h4>
<button
className="pt-detail-close"
onClick={onClose}
aria-label="Close detail"
>
&times;
</button>
</div>

{node.badges && node.badges.length > 0 && (
<div className="pt-detail-badges">
{node.badges.map((b, i) => {
const bc = getNodeColors(theme, b.colorKey)
return (
<span
key={i}
className="pt-badge"
style={{ background: bc.bg, color: bc.accent, borderColor: bc.accent }}
>
{b.text}
</span>
)
})}
</div>
)}

<div className="pt-detail-sections">
{node.detail.sections.map((s, i) => (
<div key={i} className="pt-detail-section">
<div className="pt-detail-label">{s.label}</div>
{Array.isArray(s.content) ? (
<ul className="pt-detail-list">
{s.content.map((item, j) => (
<li key={j}>{item}</li>
))}
</ul>
) : (
<div className="pt-detail-value">{s.content}</div>
)}
</div>
))}
</div>
</div>
)
}
Loading