Skip to content

Commit b6b9ad2

Browse files
committed
feat: add coordinator collateral panel and per-node live output
Signed-off-by: Ibrahim Kazimov <74775400+ibrahimkzmv@users.noreply.github.com>
1 parent d480672 commit b6b9ad2

13 files changed

Lines changed: 321 additions & 44 deletions

File tree

‎apps/server/src/runs/event-bridge.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import {
2+
COORDINATOR_AGENT,
3+
COORDINATOR_TASK_ID,
24
deserializeTeamRunResult,
35
type ForgePlanTask,
46
type ForgeWorkflowEvent,
@@ -54,7 +56,16 @@ export function applyForgeWorkflowEvent(
5456
return false
5557
}
5658
case 'plan': {
57-
session.setPlanRecords(planTasksToRecords(event.data.tasks))
59+
const records = planTasksToRecords(event.data.tasks)
60+
session.setPlanRecords(records)
61+
publishTraceLine(session, {
62+
runId: session.id,
63+
at: Date.now(),
64+
level: 'info',
65+
agent: COORDINATOR_AGENT,
66+
taskId: COORDINATOR_TASK_ID,
67+
message: `Plan published: ${records.length} tasks`,
68+
})
5869
publishSnapshot(session)
5970
return false
6071
}

‎apps/server/src/runs/session.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export class RunSession {
6666
}
6767

6868
setPlan(tasks: readonly Task[]): void {
69-
this.tasks = tasks.map(taskToRecord)
69+
this.setPlanRecords(tasks.map(taskToRecord))
7070
}
7171

7272
setPlanRecords(tasks: TaskExecutionRecord[]): void {
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { filterTraceLinesForCoordinator, filterTraceLinesForTask } from '@oma-forge/shared'
3+
4+
describe('filterTraceLinesForCoordinator', () => {
5+
it('keeps coordinator agent and plan messages', () => {
6+
const lines = [
7+
{
8+
runId: 'r1',
9+
at: 1,
10+
level: 'stream' as const,
11+
agent: 'researcher',
12+
message: 'Finding sources…',
13+
},
14+
{
15+
runId: 'r1',
16+
at: 2,
17+
level: 'info' as const,
18+
agent: 'coordinator',
19+
message: 'Plan ready: 2 tasks (approved)',
20+
},
21+
]
22+
expect(filterTraceLinesForCoordinator(lines).map((l) => l.message)).toEqual([
23+
'Plan ready: 2 tasks (approved)',
24+
])
25+
})
26+
})
27+
28+
describe('filterTraceLinesForTask', () => {
29+
it('excludes coordinator traces from worker nodes', () => {
30+
const worker = {
31+
id: 't1',
32+
title: 'Research',
33+
assignee: 'researcher',
34+
status: 'in_progress' as const,
35+
dependsOn: [],
36+
}
37+
const lines = [
38+
{
39+
runId: 'r1',
40+
at: 1,
41+
level: 'info' as const,
42+
agent: 'coordinator',
43+
message: 'Agent started: coordinator',
44+
},
45+
{
46+
runId: 'r1',
47+
at: 2,
48+
level: 'stream' as const,
49+
agent: 'researcher',
50+
taskId: 't1',
51+
message: 'Hello',
52+
},
53+
]
54+
expect(filterTraceLinesForTask(lines, worker).map((l) => l.message)).toEqual(['Hello'])
55+
})
56+
})

‎apps/server/tests/runs-state.test.ts‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,10 @@ describe('RunSession', () => {
4747
])
4848

4949
run.applyProgress({ type: 'task_start', task: 't1' })
50-
expect(run.toSnapshot().tasks[0]?.status).toBe('in_progress')
50+
expect(run.toSnapshot().tasks.find((t) => t.id === 't1')?.status).toBe('in_progress')
5151

5252
run.applyProgress({ type: 'task_complete', task: 't1' })
53-
expect(run.toSnapshot().tasks[0]?.status).toBe('completed')
53+
expect(run.toSnapshot().tasks.find((t) => t.id === 't1')?.status).toBe('completed')
5454
})
5555

5656
it('creates tasks on task_start when no plan was received', () => {
@@ -81,6 +81,27 @@ describe('RunSession', () => {
8181
])
8282
})
8383

84+
it('does not add a coordinator task to the DAG snapshot', () => {
85+
const run = new RunSession('run-1', 'Ship the feature')
86+
run.applyProgress({ type: 'agent_start', agent: 'coordinator' })
87+
expect(run.toSnapshot().tasks).toEqual([])
88+
89+
run.setPlan([
90+
{
91+
id: 't1',
92+
title: 'Research',
93+
status: 'pending',
94+
description: 'Research',
95+
dependsOn: [],
96+
createdAt: new Date(),
97+
updatedAt: new Date(),
98+
},
99+
])
100+
expect(run.toSnapshot().tasks).toEqual([
101+
{ id: 't1', title: 'Research', status: 'pending', dependsOn: [] },
102+
])
103+
})
104+
84105
it('tracks short-circuit runs before the final result arrives', () => {
85106
const run = new RunSession('run-1', 'Quick ask')
86107
run.applyProgress({

‎apps/web/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"license": "Apache-2.0",
66
"type": "module",
77
"scripts": {
8-
"dev": "vite",
8+
"dev": "npm run build -w @oma-forge/shared && vite",
99
"build": "npm run build -w @oma-forge/shared && tsc --noEmit && vite build",
1010
"preview": "vite preview",
1111
"lint": "npm run build -w @oma-forge/shared && tsc --noEmit"
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { useEffect, useMemo, useRef, useState } from 'react'
2+
import {
3+
filterTraceLinesForCoordinator,
4+
type ForgeTraceLine,
5+
type RunStatus,
6+
} from '@oma-forge/shared'
7+
import { LiveOutput } from './LiveOutput.tsx'
8+
9+
type CoordinatorPanelProps = {
10+
readonly traceLines: readonly ForgeTraceLine[]
11+
readonly runStatus?: RunStatus
12+
readonly hasPlanTasks: boolean
13+
}
14+
15+
export function CoordinatorPanel({
16+
traceLines,
17+
runStatus,
18+
hasPlanTasks,
19+
}: CoordinatorPanelProps) {
20+
const [expanded, setExpanded] = useState(false)
21+
const autoExpanded = useRef(false)
22+
const coordinatorLines = useMemo(
23+
() => filterTraceLinesForCoordinator(traceLines),
24+
[traceLines],
25+
)
26+
const hasActivity = coordinatorLines.length > 0
27+
const isRunning = runStatus === 'running'
28+
const planningDone = hasPlanTasks || !isRunning
29+
30+
useEffect(() => {
31+
if (!hasActivity || autoExpanded.current) return
32+
autoExpanded.current = true
33+
setExpanded(true)
34+
}, [hasActivity])
35+
36+
if (!isRunning && !hasActivity) {
37+
return null
38+
}
39+
40+
return (
41+
<section
42+
data-collateral
43+
className={`flex flex-col shrink-0 border-b border-outline-variant/10 ${expanded ? 'min-h-0 max-h-[min(42vh,360px)]' : ''}`}
44+
>
45+
<button
46+
type="button"
47+
className="flex w-full items-center gap-2 px-4 py-3 text-left hover:bg-surface-container transition-colors"
48+
onClick={() => setExpanded((open) => !open)}
49+
aria-expanded={expanded}
50+
>
51+
<span className="material-symbols-outlined text-primary text-lg">hub</span>
52+
<span className="font-headline font-black text-[10px] tracking-widest text-on-surface-variant flex-1">
53+
COORDINATOR
54+
</span>
55+
{hasActivity ? (
56+
<span className="text-[9px] font-mono text-secondary tabular-nums">
57+
{coordinatorLines.length}
58+
</span>
59+
) : null}
60+
{isRunning && !planningDone ? (
61+
<span className="h-1.5 w-1.5 rounded-full bg-primary animate-pulse" aria-hidden />
62+
) : null}
63+
<span className="material-symbols-outlined text-on-surface-variant">
64+
{expanded ? 'expand_less' : 'expand_more'}
65+
</span>
66+
</button>
67+
{expanded ? (
68+
<div className="flex min-h-0 flex-1 flex-col overflow-hidden px-4 pb-4">
69+
<LiveOutput
70+
traceLines={coordinatorLines}
71+
tasks={[]}
72+
variant="coordinator"
73+
planningDone={planningDone}
74+
/>
75+
</div>
76+
) : null}
77+
</section>
78+
)
79+
}

‎apps/web/src/components/dashboard/DetailsPanel.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ export function DetailsPanel({
3131

3232
return (
3333
<aside
34-
className={`${open ? 'flex flex-1' : 'hidden'} w-full lg:w-[400px] lg:flex-none shrink-0 min-h-0 max-h-full overflow-y-auto overscroll-contain bg-surface-container-high p-6 flex-col gap-8 border-l border-outline-variant/10 relative`}
34+
data-node-details
35+
className={`${open ? 'flex flex-1' : 'hidden'} min-h-0 overflow-y-auto overscroll-contain p-6 flex-col gap-8 relative`}
3536
>
3637
<div>
3738
<h2 className="font-headline font-black text-lg tracking-widest mb-6 text-primary flex items-center gap-2">
@@ -122,7 +123,7 @@ export function DetailsPanel({
122123
<h2 className="font-headline font-black text-[10px] tracking-widest mb-4 text-on-surface-variant">
123124
LIVE_AGENT_OUTPUT
124125
</h2>
125-
<LiveOutput tasks={tasks} traceLines={traceLines} />
126+
<LiveOutput tasks={tasks} traceLines={traceLines} scopeTask={selected} />
126127
</div>
127128
</aside>
128129
)

‎apps/web/src/components/dashboard/LiveOutput.tsx‎

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import type { ForgeTraceLine, TaskExecutionRecord, TraceLineLevel } from '@oma-forge/shared'
2+
import { filterTraceLinesForTask } from '@oma-forge/shared'
23

34
type LiveOutputProps = {
45
readonly tasks: readonly TaskExecutionRecord[]
56
readonly traceLines: readonly ForgeTraceLine[]
7+
/** When set, only trace and status lines for this DAG node are shown. */
8+
readonly scopeTask?: TaskExecutionRecord | null
9+
readonly variant?: 'default' | 'coordinator'
10+
readonly planningDone?: boolean
611
}
712

813
const terminalStatuses = new Set(['completed', 'failed', 'skipped', 'blocked'])
@@ -21,17 +26,46 @@ function formatTracePrefix(line: ForgeTraceLine): string {
2126
return parts.length > 0 ? `[${parts.join(':')}] ` : ''
2227
}
2328

24-
export function LiveOutput({ tasks, traceLines }: LiveOutputProps) {
25-
const finished = tasks.every((task) => terminalStatuses.has(task.status))
26-
const visibleTrace = traceLines.slice(-80)
29+
function systemMessage(
30+
variant: 'default' | 'coordinator',
31+
finished: boolean,
32+
planningDone?: boolean,
33+
): string {
34+
if (variant === 'coordinator') {
35+
if (finished || planningDone) {
36+
return '[SYSTEM] Coordinator planning finished.'
37+
}
38+
return '[SYSTEM] Coordinator planning in progress.'
39+
}
40+
return finished
41+
? '[SYSTEM] Task graph execution finished.'
42+
: '[SYSTEM] Task graph execution in progress.'
43+
}
44+
45+
export function LiveOutput({
46+
tasks,
47+
traceLines,
48+
scopeTask,
49+
variant = 'default',
50+
planningDone,
51+
}: LiveOutputProps) {
52+
const scopedTasks = scopeTask ? [scopeTask] : tasks
53+
const finished =
54+
variant === 'coordinator'
55+
? Boolean(planningDone)
56+
: scopedTasks.every((task) => terminalStatuses.has(task.status))
57+
const filteredTrace = scopeTask
58+
? filterTraceLinesForTask(traceLines, scopeTask)
59+
: traceLines
60+
const visibleTrace = filteredTrace.slice(-80)
2761

2862
return (
29-
<div className="bg-surface-container-lowest p-3 font-mono text-[10px] leading-relaxed space-y-1 max-h-64 overflow-y-auto">
30-
<p className="text-tertiary">
31-
{finished
32-
? '[SYSTEM] Task graph execution finished.'
33-
: '[SYSTEM] Task graph execution in progress.'}
34-
</p>
63+
<div
64+
className={`bg-surface-container-lowest p-3 font-mono text-[10px] leading-relaxed space-y-1 overflow-y-auto ${
65+
variant === 'coordinator' ? 'flex-1 min-h-0 max-h-full' : 'max-h-64'
66+
}`}
67+
>
68+
<p className="text-tertiary">{systemMessage(variant, finished, planningDone)}</p>
3569
{visibleTrace.map((line, index) => (
3670
<p
3771
key={`${line.at}-${index}`}
@@ -41,14 +75,15 @@ export function LiveOutput({ tasks, traceLines }: LiveOutputProps) {
4175
{line.message}
4276
</p>
4377
))}
44-
{visibleTrace.length === 0
45-
? tasks.map((task) => (
78+
{visibleTrace.length === 0 && variant === 'coordinator' ? (
79+
<p className="text-on-surface-variant">Waiting for coordinator output…</p>
80+
) : null}
81+
{visibleTrace.length === 0 && variant !== 'coordinator'
82+
? scopedTasks.map((task) => (
4683
<p
4784
key={task.id}
4885
className={
49-
task.status === 'failed'
50-
? 'text-error'
51-
: 'text-on-surface-variant'
86+
task.status === 'failed' ? 'text-error' : 'text-on-surface-variant'
5287
}
5388
>
5489
[{task.assignee?.toUpperCase() ?? 'UNASSIGNED'}] {task.title} {'->'}{' '}

0 commit comments

Comments
 (0)