feat: worker orchestration screen#457
Conversation
Top-level /orchestrate route showing active OpenCode workers across all agents as horizontally scrollable columns, each embedding a live OpenCode session via Shadow DOM. Backend: expose project_id, project_name, opencode_session_id, and directory in the workers list API response (LEFT JOIN against projects table, no new migrations needed — columns already exist). Frontend: - Extract OpenCodeEmbed into shared component with ref-counted portal CSS and stable mount lifecycle (no remount on directory discovery) - New Orchestrate page with fan-out data fetching across all agents, project/agent grouping toggle, agent filter, live SSE state merge - Sidebar icon (columns) linking to /orchestrate - AgentWorkers imports shared OpenCodeEmbed instead of inline copy
WalkthroughThis PR introduces an "Orchestrate" page for managing OpenCode worker sessions across agents. It adds a reusable Changes
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
📝 Coding Plan
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 6
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@interface/src/components/OpenCodeEmbed.tsx`:
- Around line 22-35: The loadScript function treats any existing <script
src="..."> as successful which leaves a failed script node in the DOM after
onerror, causing subsequent calls (and embedAssetsPromise handling) to
incorrectly resolve; update loadScript to mark script elements with a loading
state and remove a failed node on error: when creating the script set a
data-loading attribute (or similar), set onload to set data-loaded=true and
resolve, set onerror to remove the script element from document.head and reject,
and when checking for an existing script (querySelector) only treat it as ready
if data-loaded=true (otherwise proceed to create a new script or await its
promise); apply the same fix where similar logic is used around
embedAssetsPromise.
- Around line 42-57: The component currently hardcodes "/opencode-embed/..." and
"/api/opencode/..." when fetching assets and proxying, which breaks deployments
using a non-root BASE_PATH; update all fetch/loadScript calls and any proxy URL
construction in OpenCodeEmbed (e.g., where manifest is fetched,
loadScript(`/opencode-embed/${manifest.js}`),
fetch(`/opencode-embed/${manifest.css}`), and the later proxy usage around lines
97-101) to prepend the app base path by using the existing base helper
(getApiBase() or BASE_PATH) so asset and API URLs become
`${getApiBase()}/opencode-embed/...` and `${getApiBase()}/api/opencode/...`,
preserving the same validAssetPath guard and no change in semantics otherwise.
In `@interface/src/routes/Orchestrate.tsx`:
- Around line 168-176: activeAgents is computed from allWorkers but agentFilter
can refer to an agent that is no longer present, leaving filteredWorkers empty
and the select disappears; add an effect that watches activeAgents (or
allWorkers) and if agentFilter is set to an id not present in activeAgents,
reset agentFilter to null/'' (or a safe default) so the UI can return to an
unfiltered state; update the component to use this effect (referencing
agentFilter, setAgentFilter, activeAgents, filteredWorkers) and ensure the
cleanup covers the other identical logic around lines 212-226 as well.
- Around line 125-163: The grouping logic in the useMemo for groups currently
uses project_name/agent_name as the map key and group.name as the React key,
causing unrelated projects/agents with the same display name to collide; change
the Map keys to use the stable identifiers (use worker.project_id when groupBy
!== "agent" and worker.agent_id when groupBy === "agent"), keep name only for
presentation (from worker.project_name or worker.agent_name), and include the
stable id (projectId/agentId) in each group object so callers can use that id
for React keys (also update the other group render site referenced around lines
247-248 to use the stable id instead of group.name); reference
OrchestrationWorker, project_id, project_name, agent_id, agent_name, groups, and
groupBy when making these updates.
- Around line 60-67: The current effect watches workerEventVersion and
immediately invalidates all ["orchestrate-workers"] queries (via
queryClient.invalidateQueries) whenever workerEventVersion changes, which causes
excessive refetches for transcript-level SSE events; update the logic in the
useEffect that references prevVersion and workerEventVersion so only
lifecycle-related events trigger invalidation—either (a) filter the SSE events
at their source and increment a separate lifecycle-only counter (e.g.,
lifecycleEventVersion) and base this effect on that counter, or (b) add a short
debounce/aggregation step before calling queryClient.invalidateQueries and
ignore event types like tool_started, tool_completed, opencode_part_updated, and
worker_text; ensure you modify the branch that updates prevVersion.current and
the condition that calls invalidateQueries to use the narrower lifecycle-only
signal or debounced change.
- Around line 91-98: The patch currently overwrites the enum state by assigning
liveWorker?.status into the worker.status field (in the result.push block),
which breaks downstream isRunning/isIdle checks; instead keep the original
worker.status enum intact and derive the live state separately from liveWorker
(e.g., use liveWorker.isIdle to decide running/idle state) and store the live
status text in a different property (e.g., live_status or live_tool_calls
already exists) so that UI logic that relies on worker.status continues to work;
update the result.push to preserve worker.status and add/derive a separate
liveState based on liveWorker.isIdle while keeping liveWorker?.status only for
display.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 8719ab46-8f58-4a75-85d9-46f8ca672deb
📒 Files selected for processing (8)
interface/src/api/client.tsinterface/src/components/OpenCodeEmbed.tsxinterface/src/components/Sidebar.tsxinterface/src/router.tsxinterface/src/routes/AgentWorkers.tsxinterface/src/routes/Orchestrate.tsxsrc/api/workers.rssrc/conversation/history.rs
| function loadScript(src: string): Promise<void> { | ||
| return new Promise((resolve, reject) => { | ||
| // Don't add the same script twice | ||
| if (document.querySelector(`script[src="${src}"]`)) { | ||
| resolve(); | ||
| return; | ||
| } | ||
| const script = document.createElement("script"); | ||
| script.type = "module"; | ||
| script.src = src; | ||
| script.onload = () => resolve(); | ||
| script.onerror = () => reject(new Error(`Failed to load script: ${src}`)); | ||
| document.head.appendChild(script); | ||
| }); |
There was a problem hiding this comment.
Make failed embed script loads retryable.
loadScript() treats “a matching <script> exists” as “the module is ready”. After an onerror, the stale node stays in document.head, embedAssetsPromise is cleared, and the next call resolves immediately without re-downloading, so window.__opencode_embed__ never recovers until a full page reload. Remove the failed node on error or track an explicit loaded promise per src.
Also applies to: 71-73
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@interface/src/components/OpenCodeEmbed.tsx` around lines 22 - 35, The
loadScript function treats any existing <script src="..."> as successful which
leaves a failed script node in the DOM after onerror, causing subsequent calls
(and embedAssetsPromise handling) to incorrectly resolve; update loadScript to
mark script elements with a loading state and remove a failed node on error:
when creating the script set a data-loading attribute (or similar), set onload
to set data-loaded=true and resolve, set onerror to remove the script element
from document.head and reject, and when checking for an existing script
(querySelector) only treat it as ready if data-loaded=true (otherwise proceed to
create a new script or await its promise); apply the same fix where similar
logic is used around embedAssetsPromise.
| const manifestRes = await fetch("/opencode-embed/manifest.json"); | ||
| if (!manifestRes.ok) throw new Error("Failed to load opencode-embed manifest"); | ||
| const manifest: { js: string; css: string } = await manifestRes.json(); | ||
|
|
||
| // Guard against path traversal or unexpected values from the manifest | ||
| const validAssetPath = (p: unknown): p is string => | ||
| typeof p === "string" && p.startsWith("assets/") && !p.includes(".."); | ||
| if (!validAssetPath(manifest.js) || !validAssetPath(manifest.css)) { | ||
| throw new Error("Invalid asset paths in opencode-embed manifest"); | ||
| } | ||
|
|
||
| // Load JS via <script> tag (required for /public files in Vite dev) | ||
| // and CSS via fetch (to inject into Shadow DOM) in parallel | ||
| const [, cssRes] = await Promise.all([ | ||
| loadScript(`/opencode-embed/${manifest.js}`), | ||
| fetch(`/opencode-embed/${manifest.css}`), |
There was a problem hiding this comment.
Respect BASE_PATH for embed asset and proxy URLs.
This component hardcodes /opencode-embed/... and /api/opencode/..., while the rest of the client goes through BASE_PATH/getApiBase(). Deployments under a non-root __SPACEBOT_BASE_PATH will 404 here even though the rest of the app still works.
Also applies to: 97-101
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@interface/src/components/OpenCodeEmbed.tsx` around lines 42 - 57, The
component currently hardcodes "/opencode-embed/..." and "/api/opencode/..." when
fetching assets and proxying, which breaks deployments using a non-root
BASE_PATH; update all fetch/loadScript calls and any proxy URL construction in
OpenCodeEmbed (e.g., where manifest is fetched,
loadScript(`/opencode-embed/${manifest.js}`),
fetch(`/opencode-embed/${manifest.css}`), and the later proxy usage around lines
97-101) to prepend the app base path by using the existing base helper
(getApiBase() or BASE_PATH) so asset and API URLs become
`${getApiBase()}/opencode-embed/...` and `${getApiBase()}/api/opencode/...`,
preserving the same validAssetPath guard and no change in semantics otherwise.
| // Invalidate when SSE events fire | ||
| const prevVersion = useRef(workerEventVersion); | ||
| useEffect(() => { | ||
| if (workerEventVersion !== prevVersion.current) { | ||
| prevVersion.current = workerEventVersion; | ||
| queryClient.invalidateQueries({queryKey: ["orchestrate-workers"]}); | ||
| } | ||
| }, [workerEventVersion, queryClient]); |
There was a problem hiding this comment.
Don't refetch every agent on transcript-level SSE events.
workerEventVersion is also bumped for tool_started, tool_completed, opencode_part_updated, and worker_text, so this effect invalidates every ["orchestrate-workers", agent, status] query on live transcript updates as well as lifecycle changes. On a busy board that turns one worker event into 2 × agent_count list requests and will churn the backend hard. Please debounce this or drive it from a narrower lifecycle-only counter.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@interface/src/routes/Orchestrate.tsx` around lines 60 - 67, The current
effect watches workerEventVersion and immediately invalidates all
["orchestrate-workers"] queries (via queryClient.invalidateQueries) whenever
workerEventVersion changes, which causes excessive refetches for
transcript-level SSE events; update the logic in the useEffect that references
prevVersion and workerEventVersion so only lifecycle-related events trigger
invalidation—either (a) filter the SSE events at their source and increment a
separate lifecycle-only counter (e.g., lifecycleEventVersion) and base this
effect on that counter, or (b) add a short debounce/aggregation step before
calling queryClient.invalidateQueries and ignore event types like tool_started,
tool_completed, opencode_part_updated, and worker_text; ensure you modify the
branch that updates prevVersion.current and the condition that calls
invalidateQueries to use the narrower lifecycle-only signal or debounced change.
| const liveWorker = activeWorkers[worker.id]; | ||
| result.push({ | ||
| ...worker, | ||
| agent_id: agent.id, | ||
| agent_name: agentName, | ||
| status: liveWorker?.status ?? worker.status, | ||
| live_tool_calls: liveWorker?.toolCalls, | ||
| }); |
There was a problem hiding this comment.
Keep worker.status as the enum state.
activeWorkers[worker.id].status is the live status text, not the "running" | "idle" | ... state enum. Overwriting worker.status with it breaks the isRunning/isIdle checks below, so active columns can lose their pulse, duration, and cancel affordance as soon as a status message arrives. Mirror interface/src/routes/AgentWorkers.tsx here and derive the state from liveWorker.isIdle, keeping the text separately if you want to display it.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@interface/src/routes/Orchestrate.tsx` around lines 91 - 98, The patch
currently overwrites the enum state by assigning liveWorker?.status into the
worker.status field (in the result.push block), which breaks downstream
isRunning/isIdle checks; instead keep the original worker.status enum intact and
derive the live state separately from liveWorker (e.g., use liveWorker.isIdle to
decide running/idle state) and store the live status text in a different
property (e.g., live_status or live_tool_calls already exists) so that UI logic
that relies on worker.status continues to work; update the result.push to
preserve worker.status and add/derive a separate liveState based on
liveWorker.isIdle while keeping liveWorker?.status only for display.
| const groups: ProjectGroup[] = useMemo(() => { | ||
| if (groupBy === "agent") { | ||
| const byAgent = new Map<string, OrchestrationWorker[]>(); | ||
| for (const worker of filteredWorkers) { | ||
| const key = worker.agent_id; | ||
| const list = byAgent.get(key) ?? []; | ||
| list.push(worker); | ||
| byAgent.set(key, list); | ||
| } | ||
| return Array.from(byAgent.entries()).map(([agentId, workers]) => ({ | ||
| name: workers[0]?.agent_name ?? agentId, | ||
| projectId: null, | ||
| workers, | ||
| })); | ||
| } | ||
|
|
||
| // Group by project | ||
| const byProject = new Map<string, OrchestrationWorker[]>(); | ||
| for (const worker of filteredWorkers) { | ||
| const key = worker.project_name ?? "Ungrouped"; | ||
| const list = byProject.get(key) ?? []; | ||
| list.push(worker); | ||
| byProject.set(key, list); | ||
| } | ||
|
|
||
| // Sort: named projects first (alphabetical), "Ungrouped" last | ||
| const entries = Array.from(byProject.entries()); | ||
| entries.sort(([a], [b]) => { | ||
| if (a === "Ungrouped") return 1; | ||
| if (b === "Ungrouped") return -1; | ||
| return a.localeCompare(b); | ||
| }); | ||
|
|
||
| return entries.map(([name, workers]) => ({ | ||
| name, | ||
| projectId: workers[0]?.project_id ?? null, | ||
| workers, | ||
| })); | ||
| }, [filteredWorkers, groupBy]); |
There was a problem hiding this comment.
Use stable IDs for grouping and React keys.
In project mode, grouping by project_name merges unrelated projects that happen to share a label across agents. In both modes, key={group.name} also collides when two groups render with the same display name. Use project_id/agent_id as the group identity and keep name purely for presentation.
Also applies to: 247-248
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@interface/src/routes/Orchestrate.tsx` around lines 125 - 163, The grouping
logic in the useMemo for groups currently uses project_name/agent_name as the
map key and group.name as the React key, causing unrelated projects/agents with
the same display name to collide; change the Map keys to use the stable
identifiers (use worker.project_id when groupBy !== "agent" and worker.agent_id
when groupBy === "agent"), keep name only for presentation (from
worker.project_name or worker.agent_name), and include the stable id
(projectId/agentId) in each group object so callers can use that id for React
keys (also update the other group render site referenced around lines 247-248 to
use the stable id instead of group.name); reference OrchestrationWorker,
project_id, project_name, agent_id, agent_name, groups, and groupBy when making
these updates.
| const activeAgents = useMemo(() => { | ||
| const seen = new Map<string, string>(); | ||
| for (const worker of allWorkers) { | ||
| if (!seen.has(worker.agent_id)) { | ||
| seen.set(worker.agent_id, worker.agent_name); | ||
| } | ||
| } | ||
| return Array.from(seen.entries()).map(([id, name]) => ({id, name})); | ||
| }, [allWorkers]); |
There was a problem hiding this comment.
Clear stale agentFilter values when that agent stops being active.
If the user filters to agent A and A's last worker finishes while another agent still has workers, filteredWorkers becomes empty and the <select> disappears because activeAgents.length drops to 1. At that point the page is stuck showing “No active workers” with no way to clear the stale filter. Reset invalid selections in an effect, or keep the filter visible while a filter is applied.
Also applies to: 212-226
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@interface/src/routes/Orchestrate.tsx` around lines 168 - 176, activeAgents is
computed from allWorkers but agentFilter can refer to an agent that is no longer
present, leaving filteredWorkers empty and the select disappears; add an effect
that watches activeAgents (or allWorkers) and if agentFilter is set to an id not
present in activeAgents, reset agentFilter to null/'' (or a safe default) so the
UI can return to an unfiltered state; update the component to use this effect
(referencing agentFilter, setAgentFilter, activeAgents, filteredWorkers) and
ensure the cleanup covers the other identical logic around lines 212-226 as
well.
Summary
/orchestrateroute showing all active OpenCode workers across all agents as horizontally scrollable columns, each embedding a live OpenCode sessionproject_id,project_name,opencode_session_id, anddirectory— enabling project-based grouping and direct embed mounting without a detail fetchOpenCodeEmbedinto a shared component with ref-counted portal CSS and a stable mount lifecycle that doesn't remount when the SSE directory probe completesWhat it looks like
Columns of live OpenCode sessions grouped by project (or agent), with status indicators, task names, cancel buttons, and live duration counters. Accessible via a new columns icon in the sidebar rail between Dashboard and Settings.
Changes
Backend (Rust)
src/conversation/history.rs— Extendedlist_worker_runs()SQL to LEFT JOINprojectstable, addedproject_id,project_name,opencode_session_id,directorytoWorkerRunRowsrc/api/workers.rs— Added the same four fields toWorkerListItemAPI responseFrontend (TypeScript/React)
interface/src/components/OpenCodeEmbed.tsx(new) — Extracted from AgentWorkers.tsx. Shared OpenCode embed with ref-counted portal CSS cleanup and mount effect that only depends onserverUrl(route changes go throughnavigate())interface/src/routes/Orchestrate.tsx(new) — Main orchestration page: fan-out fetching across agents, SSE live state merge, project/agent grouping toggle, agent filter, fixed-width columns with embedded OpenCodeinterface/src/router.tsx— Added/orchestraterouteinterface/src/components/Sidebar.tsx— Added orchestration iconinterface/src/routes/AgentWorkers.tsx— Imports sharedOpenCodeEmbed, removed ~330 lines of inline codeinterface/src/api/client.ts— Added new fields toWorkerRunInfoGate results
just preflight— passedjust gate-pr— passed (fmt, check, clippy, 642 lib tests, integration compile)bunx tsc --noEmit— zero errorsbun run build— succeedsNot in scope (yet)