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: 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).
* Used for the tmux status bar agent count and active-agent index.
*/
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