Skip to content

feat: worker orchestration screen#457

Merged
jamiepine merged 1 commit into
mainfrom
feat/worker-orchestration-screen
Mar 20, 2026
Merged

feat: worker orchestration screen#457
jamiepine merged 1 commit into
mainfrom
feat/worker-orchestration-screen

Conversation

@jamiepine

Copy link
Copy Markdown
Member

Summary

  • Adds a top-level /orchestrate route showing all active OpenCode workers across all agents as horizontally scrollable columns, each embedding a live OpenCode session
  • Extends the workers list API to include project_id, project_name, opencode_session_id, and directory — enabling project-based grouping and direct embed mounting without a detail fetch
  • Extracts OpenCodeEmbed into a shared component with ref-counted portal CSS and a stable mount lifecycle that doesn't remount when the SSE directory probe completes

What 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 — Extended list_worker_runs() SQL to LEFT JOIN projects table, added project_id, project_name, opencode_session_id, directory to WorkerRunRow
  • src/api/workers.rs — Added the same four fields to WorkerListItem API response

Frontend (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 on serverUrl (route changes go through navigate())
  • 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 OpenCode
  • interface/src/router.tsx — Added /orchestrate route
  • interface/src/components/Sidebar.tsx — Added orchestration icon
  • interface/src/routes/AgentWorkers.tsx — Imports shared OpenCodeEmbed, removed ~330 lines of inline code
  • interface/src/api/client.ts — Added new fields to WorkerRunInfo

Gate results

  • just preflight — passed
  • just gate-pr — passed (fmt, check, clippy, 642 lib tests, integration compile)
  • bunx tsc --noEmit — zero errors
  • bun run build — succeeds

Not in scope (yet)

  • Non-OpenCode worker types in the orchestration view
  • Completed/historical workers (active only)
  • Drag-and-drop column reordering
  • Resizable column widths

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
@coderabbitai

coderabbitai Bot commented Mar 19, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

This PR introduces an "Orchestrate" page for managing OpenCode worker sessions across agents. It adds a reusable OpenCodeEmbed component for embedding OpenCode SPAs in Shadow DOM, extends data models with OpenCode session and project metadata, and refactors existing embedding logic to use the new component.

Changes

Cohort / File(s) Summary
API & Data Models
interface/src/api/client.ts, src/api/workers.rs, src/conversation/history.rs
Extended WorkerRunInfo, WorkerListItem, and WorkerRunRow structs with nullable OpenCode fields (opencode_session_id, directory, project_id, project_name). Updated corresponding queries and API endpoints to populate these fields from database rows.
OpenCode Embedding Component
interface/src/components/OpenCodeEmbed.tsx, interface/src/routes/AgentWorkers.tsx
Extracted embedding logic into reusable OpenCodeEmbed component with Shadow DOM support, manifest loading, SSE directory probing, and navigation handling. Refactored AgentWorkers to delegate embedding via the new component and removed large block of duplicated embed logic.
Routing & Navigation
interface/src/router.tsx, interface/src/components/Sidebar.tsx
Added new /orchestrate route with "Orchestrate" title and subtitle. Added corresponding sidebar navigation link with conditional styling based on active route.
Orchestrate Page
interface/src/routes/Orchestrate.tsx
New page component displaying active OpenCode workers organized by agent/project. Integrates worker list fetching, live SSE state, filtering, grouping, and worker status display with inline OpenCodeEmbed instances and cancellation controls.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: worker orchestration screen' accurately summarizes the main change: adding a new worker orchestration feature with a UI screen.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, clearly explaining the orchestration feature, backend/frontend changes, and the OpenCodeEmbed extraction.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/worker-orchestration-screen
📝 Coding Plan
  • Generate coding plan for human review comments

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between b195e7d and 5dc928d.

📒 Files selected for processing (8)
  • interface/src/api/client.ts
  • interface/src/components/OpenCodeEmbed.tsx
  • interface/src/components/Sidebar.tsx
  • interface/src/router.tsx
  • interface/src/routes/AgentWorkers.tsx
  • interface/src/routes/Orchestrate.tsx
  • src/api/workers.rs
  • src/conversation/history.rs

Comment on lines +22 to +35
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);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +42 to +57
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}`),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +60 to +67
// Invalidate when SSE events fire
const prevVersion = useRef(workerEventVersion);
useEffect(() => {
if (workerEventVersion !== prevVersion.current) {
prevVersion.current = workerEventVersion;
queryClient.invalidateQueries({queryKey: ["orchestrate-workers"]});
}
}, [workerEventVersion, queryClient]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +91 to +98
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,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +125 to +163
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]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +168 to +176
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]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

@jamiepine jamiepine merged commit 806c068 into main Mar 20, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant