Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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: 2 additions & 0 deletions bunfig.toml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ coveragePathIgnorePatterns = [
"src/sdk/runtime/theme.ts",
"src/sdk/components/error-boundary.tsx",
"src/sdk/components/orchestrator-panel.tsx",
"src/sdk/components/compact-switcher.tsx",
"src/sdk/components/session-graph-panel.tsx",
# Workflow runtime I/O (subprocess/SDK/filesystem dependent)
"src/services/workflows/session.ts",
"src/services/workflows/graph/nodes.ts",
Expand Down
682 changes: 682 additions & 0 deletions research/designs/option3-refined-prototype.html

Large diffs are not rendered by default.

73 changes: 73 additions & 0 deletions src/sdk/components/compact-switcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/** @jsxImportSource @opentui/react */
/**
* CompactSwitcher — a lightweight popup that lists all agents for quick
* direct-jump navigation. Opened with "/" from any view mode.
*/

import { useStore, useGraphTheme, useStoreVersion } from "./orchestrator-panel-contexts.ts";
import { statusIcon, statusColor, fmtDuration } from "./status-helpers.ts";
import { lerpColor } from "./color-utils.ts";

export interface CompactSwitcherProps {
selectedIndex: number;
}

export function CompactSwitcher({ selectedIndex }: CompactSwitcherProps) {
const store = useStore();
const theme = useGraphTheme();
useStoreVersion(store);

const agents = store.sessions;
const headerHint = "\u2191\u2193 select \u00B7 \u21B5 jump \u00B7 Esc close";

return (
<box
position="absolute"
bottom={1}
left={0}
width={44}
border
borderStyle="rounded"
borderColor={theme.borderActive}
backgroundColor={theme.backgroundElement}
flexDirection="column"
>
{/* Header */}
<box height={1} flexDirection="row" paddingLeft={1} paddingRight={1}>
<text fg={theme.textDim}>agents</text>
<box flexGrow={1} />
<text fg={theme.textDim}>{headerHint}</text>
</box>

{/* Agent list */}
{agents.map((agent, i) => {
const isSelected = i === selectedIndex;
const icon = statusIcon(agent.status);
const iconColor = statusColor(agent.status, theme);
const duration =
agent.startedAt !== null
? fmtDuration((agent.endedAt ?? Date.now()) - agent.startedAt)
: "\u2014";

return (
<box
key={agent.name}
height={1}
flexDirection="row"
paddingLeft={1}
paddingRight={1}
backgroundColor={isSelected ? lerpColor(theme.backgroundElement, theme.primary, 0.12) : theme.backgroundElement}
>
<text>
<span fg={theme.textDim}>{String(i + 1).padStart(2)} </span>
<span fg={iconColor}>{icon} </span>
<span fg={isSelected ? theme.text : theme.textMuted}>{agent.name}</span>
</text>
<box flexGrow={1} />
<text fg={theme.textDim}>{duration}</text>
</box>
);
})}
</box>
);
}
124 changes: 124 additions & 0 deletions src/sdk/components/orchestrator-panel-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -714,4 +714,128 @@ describe("PanelStore", () => {
expect(store.sessions.find((s) => s.name === "s3")!.parents).toEqual(["s2"]);
});
});

// ── setViewMode ────────────────────────────────────────────────────────────

describe("setViewMode", () => {
test("defaults to graph mode with empty active agent", () => {
expect(store.viewMode).toBe("graph");
expect(store.activeAgentId).toBe("");
});

test("switches to attached mode with agent ID", () => {
store.setViewMode("attached", "worker-1");
expect(store.viewMode).toBe("attached");
expect(store.activeAgentId).toBe("worker-1");
});

test("switches back to graph mode and clears active agent", () => {
store.setViewMode("attached", "worker-1");
store.setViewMode("graph");
expect(store.viewMode).toBe("graph");
expect(store.activeAgentId).toBe("");
});

test("increments version by exactly 1", () => {
const before = store.version;
store.setViewMode("attached", "worker-1");
expect(store.version).toBe(before + 1);
});

test("notifies subscribers", () => {
const listener = mock(() => {});
store.subscribe(listener);
store.setViewMode("attached", "worker-1");
expect(listener).toHaveBeenCalledTimes(1);
});

test("attached without agent ID clears active agent", () => {
store.setViewMode("attached");
expect(store.viewMode).toBe("attached");
expect(store.activeAgentId).toBe("");
});
});

// ── getSubagents ───────────────────────────────────────────────────────────

describe("getSubagents", () => {
beforeEach(() => {
store.setWorkflowInfo("wf", "claude", [
{ name: "planner", parents: [] },
{ name: "writer", parents: ["planner"] },
{ name: "reviewer", parents: ["writer"] },
], "prompt");
});

test("returns empty when all non-orchestrator sessions are pending", () => {
expect(store.getSubagents()).toEqual([]);
});

test("excludes orchestrator from subagent list", () => {
store.startSession("planner");
const subs = store.getSubagents();
expect(subs.every((s) => s.name !== "orchestrator")).toBe(true);
});

test("includes running and completed sessions", () => {
store.startSession("planner");
store.completeSession("planner");
store.startSession("writer");
const subs = store.getSubagents();
expect(subs.map((s) => s.name)).toEqual(["planner", "writer"]);
});

test("includes errored sessions", () => {
store.startSession("planner");
store.failSession("planner", "timeout");
const subs = store.getSubagents();
expect(subs.map((s) => s.name)).toEqual(["planner"]);
});

test("excludes pending sessions", () => {
store.startSession("planner");
const subs = store.getSubagents();
expect(subs.map((s) => s.name)).toEqual(["planner"]);
expect(subs.some((s) => s.name === "writer")).toBe(false);
expect(subs.some((s) => s.name === "reviewer")).toBe(false);
});
});

// ── getActiveAgentIndex ────────────────────────────────────────────────────

describe("getActiveAgentIndex", () => {
beforeEach(() => {
store.setWorkflowInfo("wf", "claude", [
{ name: "planner", parents: [] },
{ name: "writer", parents: ["planner"] },
{ name: "reviewer", parents: ["writer"] },
], "prompt");
store.startSession("planner");
store.startSession("writer");
});

test("returns -1 when no agent is active", () => {
expect(store.getActiveAgentIndex()).toBe(-1);
});

test("returns correct index for first subagent", () => {
store.setViewMode("attached", "planner");
expect(store.getActiveAgentIndex()).toBe(0);
});

test("returns correct index for second subagent", () => {
store.setViewMode("attached", "writer");
expect(store.getActiveAgentIndex()).toBe(1);
});

test("returns -1 for orchestrator (not a subagent)", () => {
store.setViewMode("attached", "orchestrator");
expect(store.getActiveAgentIndex()).toBe(-1);
});

test("returns -1 for non-existent agent", () => {
store.setViewMode("attached", "nonexistent");
expect(store.getActiveAgentIndex()).toBe(-1);
});
});
});
37 changes: 36 additions & 1 deletion src/sdk/components/orchestrator-panel-store.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// ─── State Store ──────────────────────────────────
// Bridges the imperative OrchestratorPanel API with the React component tree.

import type { SessionData, SessionStatus, PanelSession } from "./orchestrator-panel-types.ts";
import type { SessionData, SessionStatus, PanelSession, ViewMode } from "./orchestrator-panel-types.ts";

type Listener = () => void;

Expand All @@ -17,6 +17,11 @@ export class PanelStore {
exitResolve: (() => void) | null = null;
abortResolve: (() => void) | null = null;

/** Current view mode — graph overview or attached to a specific agent. */
viewMode: ViewMode = "graph";
/** ID of the agent currently attached to (only meaningful when viewMode === "attached"). */
activeAgentId = "";

private listeners = new Set<Listener>();

subscribe = (fn: Listener): (() => void) => {
Expand Down Expand Up @@ -108,6 +113,36 @@ export class PanelStore {
this.emit();
}

/**
* Switch between graph and attached view modes.
* When switching to "attached", provide the agent ID to attach to.
* Switching to "graph" clears the active agent.
*/
setViewMode(mode: ViewMode, agentId?: string): void {
this.viewMode = mode;
this.activeAgentId = mode === "attached" && agentId ? agentId : "";
this.emit();
}

/**
* Return non-orchestrator agents that have started (not pending).
* These are the agents Tab/Shift+Tab cycles through.
*/
getSubagents(): SessionData[] {
return this.sessions.filter(
(s) => s.name !== "orchestrator" && s.status !== "pending",
);
Comment on lines +127 to +134

Copilot AI Apr 12, 2026

Copy link

Choose a reason for hiding this comment

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

The doc comment says these are the agents that "Tab/Shift+Tab cycles through", but the updated keyboard handling in SessionGraphPanel no longer cycles with Tab (it attaches to the first subagent) and tmux-level navigation is bound to Ctrl+\. Please update the comment to match the actual navigation behavior so it doesn’t mislead future changes/tests.

Copilot uses AI. Check for mistakes.
}

/**
* Return the 0-based index of the active agent within the subagent list,
* or -1 if not found.
*/
getActiveAgentIndex(): number {
const subs = this.getSubagents();
return subs.findIndex((s) => s.name === this.activeAgentId);
}

/** Safely invoke exitResolve at most once, guarding against rapid repeated calls. */
resolveExit(): void {
if (this.exitResolve) {
Expand Down
2 changes: 2 additions & 0 deletions src/sdk/components/orchestrator-panel-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

export type SessionStatus = "pending" | "running" | "complete" | "error";

export type ViewMode = "graph" | "attached";

export interface PanelSession {
name: string;
parents: string[];
Expand Down
Loading
Loading