Board UI: rail section, plan gate, expanded overlay, journal cases (OPE-96)

Board+journal endpoints act as the user; sessions get lead verbs behind OPENWORKER_TEAM_BOARD=1 until the team trait lands.
Rail hides all team chrome until the workspace has items; expand opens a full-width state-column board.
This commit is contained in:
Rohit C Prasad
2026-08-16 07:11:52 -07:00
committed by Rohit P
parent 054f807d4e
commit 4c1b542370
11 changed files with 739 additions and 3 deletions
+24
View File
@@ -734,6 +734,30 @@ def create_app(manager: SessionManager) -> FastAPI:
session_id, str(body.get("path", "")), str(body.get("mode", "reveal"))
)
# Agent teams (OPE-96): the session's board (workspace-keyed space) + journal
# overview. Mutations act as the USER — the human side of the gates.
@app.get("/v1/sessions/{session_id}/board")
def session_board(session_id: str) -> dict[str, Any]:
return manager.session_board(session_id)
@app.post("/v1/sessions/{session_id}/board/transition")
def session_board_transition(session_id: str, body: dict) -> dict[str, Any]:
body = body or {}
return manager.board_transition(
session_id,
int(body.get("item", 0)),
str(body.get("to", "")),
comment=str(body.get("comment", "")),
)
@app.post("/v1/sessions/{session_id}/board/approve")
def session_board_approve(session_id: str) -> dict[str, Any]:
return manager.board_approve(session_id)
@app.get("/v1/teams/journal")
def teams_journal() -> dict[str, Any]:
return {"cases": manager.journal_overview()}
@app.get("/v1/memory")
def memory() -> dict[str, Any]:
return {"memory": manager.list_memory()}
+77 -2
View File
@@ -82,7 +82,10 @@ from ..providers import (
)
from ..secrets import SecretStore, state_dir
from ..sessions import SessionRecord
from ..teams import JournalStore, TeamStore
from ..teams import Actor as TeamActor
from ..teams import BoardError as TeamsBoardError
from ..teams import JournalStore, Role as TeamRole, TeamStore, board_tools, journal_tools
from ..teams.model import space_for_workspace
from ..skills import (
SessionSkillStore,
SkillLoader,
@@ -541,7 +544,11 @@ class SessionManager:
user_rules=lambda: self.memory_settings.user_rules,
on_memory_saved=self._memory_saved_notifier(session_id),
messages=messages,
extra_tools=extra_tools,
extra_tools=[
*(extra_tools or []),
*self._team_board_tools(session_id, agent_name, ws),
]
or None,
secrets=self.secrets,
task_store=self.task_store,
wake_store=self.wakes,
@@ -1371,6 +1378,74 @@ class SessionManager:
def browser_close(self) -> dict[str, Any]:
return browser_close_session()
# ------------------------------------------------------------- agent teams (OPE-96)
def _board_space(self, session_id: str) -> Optional[str]:
record = self.session_store.load(session_id)
workspace = (record.workspace if record else None) or self.default_workspace
return space_for_workspace(workspace) if workspace else None
def _user_actor(self) -> TeamActor:
return TeamActor(id="user", role=TeamRole.USER)
def session_board(self, session_id: str) -> dict[str, Any]:
"""The session's board: items grouped by the workspace-keyed space. Empty
(space=None) when the workspace has no items the rail hides itself."""
space = self._board_space(session_id)
if space is None:
return {"space": None, "name": "", "items": []}
items = self.team_store.list_items(space, self._user_actor())
if not items:
return {"space": None, "name": "", "items": []}
return {"space": space, "name": Path(space).name, "items": items}
def board_transition(
self, session_id: str, item: int, to: str, comment: str = ""
) -> dict[str, Any]:
space = self._board_space(session_id)
if space is None:
return {"error": "this session has no board"}
try:
return self.team_store.transition(
space, self._user_actor(), int(item), to, comment=comment
)
except (TeamsBoardError, ValueError) as error:
return {"error": str(error)}
def board_approve(self, session_id: str) -> dict[str, Any]:
"""The plan gate's action: approve every proposed item on the session's board."""
space = self._board_space(session_id)
if space is None:
return {"error": "this session has no board"}
approved = 0
for entry in self.team_store.list_items(
space, self._user_actor(), state="proposed"
):
self.team_store.transition(space, self._user_actor(), entry["id"], "approved")
approved += 1
return {"approved": approved, **self.session_board(session_id)}
def journal_overview(self) -> list[dict[str, Any]]:
return self.journal_store.overview(self._user_actor())
def _team_board_tools(self, session_id: str, agent_name: str, ws: Optional[str]) -> list[Any]:
"""Phase-1 experimental wiring (flag: OPENWORKER_TEAM_BOARD=1): every
workspace session gets the board+journal verbs as the LEAD of its
workspace's board. Registration moves behind the persona `team:` trait
with the wake plumbing."""
if not ws or os.environ.get("OPENWORKER_TEAM_BOARD") != "1":
return []
actor = TeamActor(
id=f"{agent_name}:{session_id[:8]}",
role=TeamRole.LEAD,
persona=agent_name,
session_id=session_id,
)
space = space_for_workspace(ws)
return board_tools(self.team_store, space=space, actor=actor) + journal_tools(
self.journal_store, actor=actor, space=space
)
def list_artifacts(self, session_id: str) -> list[dict[str, Any]]:
record = self.session_store.load(session_id)
workspace = record.workspace if record else self.default_workspace
+20
View File
@@ -244,6 +244,26 @@ class JournalStore:
break
return out
def overview(self, actor: Actor) -> list[dict[str, Any]]:
"""Case list with entry counts and last activity — the rail's summary view."""
visible = self.cases(actor)
if not visible:
return []
with self._lock:
rows = self._conn.execute(
"SELECT case_id, COUNT(*) AS entries, MAX(ts) AS last_ts"
" FROM journal_entries GROUP BY case_id"
).fetchall()
counts = {row["case_id"]: dict(row) for row in rows}
return [
{
"case": case,
"entries": counts.get(case, {}).get("entries", 0),
"last_ts": counts.get(case, {}).get("last_ts") or "",
}
for case in visible
]
def cases(self, actor: Actor) -> list[str]:
"""Cases visible to this actor (all of them for the user)."""
with self._lock:
+8
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
class ItemState(str, Enum):
@@ -72,6 +73,13 @@ JOURNAL_KINDS = ("finding", "evidence", "decision", "note", "raw")
JOURNAL_BODY_LIMIT = 16_000
def space_for_workspace(workspace: str | Path) -> str:
"""Spaces are keyed to the project/workspace (boards are views over a space).
The resolved path is the one unambiguous local key; a display name is its
basename."""
return str(Path(workspace).expanduser().resolve())
class BoardError(Exception):
"""A verb call the board refuses — illegal transition, missing item, bad input."""
+74
View File
@@ -0,0 +1,74 @@
// Agent teams (OPE-96): the board in the session UI — rail section (grouped by
// state, blocked on top), the plan gate (decomposition approval), and the expanded
// Linear-shaped overlay. The fake agent files items on "plan the work"; approve and
// transition round-trip through the mocked /board endpoints.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function planTheWork(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByPlaceholder(/Ask the coworker/).fill("plan the work");
await page.getByRole("button", { name: "Send" }).click();
await expect(page.getByText(/approve the plan and I'll get started/)).toBeVisible();
}
test("plain sessions carry zero board chrome", async ({ page }) => {
await page.goto("/");
await page.getByPlaceholder(/Ask the coworker/).fill("hello");
await page.getByRole("button", { name: "Send" }).click();
await expect(page.getByText("Echo: hello")).toBeVisible();
await expect(page.getByTestId("board-rail")).toHaveCount(0);
await expect(page.getByTestId("plangate-card")).toHaveCount(0);
});
test("a decomposition turn raises the plan gate; approving moves items to Approved", async ({
page,
}) => {
await planTheWork(page);
const gate = page.getByTestId("plangate-card");
await expect(gate).toBeVisible();
// 3 visible + expander with the true remainder (mock UX-030: expander, true count in header)
await expect(gate).toContainText("Proposed plan — 4 work items");
await expect(gate).toContainText("Done when:");
await expect(gate.getByText("Code security review — api")).toBeVisible();
await expect(gate.getByText("Rate-limit audit — public endpoints")).toHaveCount(0);
await gate.getByRole("button", { name: /1 more item/ }).click();
await expect(gate.getByText("Rate-limit audit — public endpoints")).toBeVisible();
// Blocked renders on top in the rail; proposed items are listed under Proposed.
const rail = page.getByTestId("board-rail");
await expect(rail).toBeVisible();
const groups = rail.locator(".board-group");
await expect(groups.first()).toHaveText("Blocked");
await page.getByTestId("plangate-approve").click();
await expect(page.getByTestId("plangate-card")).toHaveCount(0);
await expect(rail).toContainText("Approved");
});
test("expand opens the overlay board; Esc closes; the user can act on a review item", async ({
page,
}) => {
await planTheWork(page);
await page.getByTestId("board-expand").click();
const overlay = page.getByTestId("board-overlay");
await expect(overlay).toBeVisible();
// Columns render need-attention first; the review item offers the user verbs.
await expect(page.getByTestId("board-col-blocked")).toBeVisible();
const reviewCol = page.getByTestId("board-col-review");
await expect(reviewCol).toContainText("Report rollup");
await reviewCol.getByRole("button", { name: "Mark done" }).click();
await expect(page.getByTestId("board-col-done")).toContainText("Report rollup");
await page.keyboard.press("Escape");
await expect(page.getByTestId("board-overlay")).toHaveCount(0);
});
test("journal section lists cases once a board exists", async ({ page }) => {
await planTheWork(page);
await page.getByRole("button", { name: /Journal/ }).click();
const journal = page.getByTestId("journal-list");
await expect(journal).toBeVisible();
await expect(journal).toContainText("findings");
await expect(journal).toContainText("12 entries");
});
+56
View File
@@ -561,6 +561,26 @@ export async function mockApi(page: import("@playwright/test").Page) {
];
let stagedSkill: any = null;
// Agent teams (OPE-96): the session's board — empty until a test opts in by sending
// "plan the work" (the fake agent then files items into the proposed gate). Mutable
// so approve/transition round-trip through the real endpoints.
const boardItems: any[] = [];
const seedBoard = () => {
if (boardItems.length) return;
boardItems.push(
{ id: 1, title: "Code security review — api", description: "", criteria: "every finding triaged with file:line evidence", state: "proposed", assignee: "", creator: "lead", refs: [], links: [] },
{ id: 2, title: "Secrets — git history, both repos", description: "", criteria: "every hit dismissed-with-reason or rotation-instructed", state: "proposed", assignee: "", creator: "lead", refs: [], links: [] },
{ id: 3, title: "Dependency audit — lockfiles", description: "", criteria: "reachable vs theoretical separated; upgrade branch green", state: "proposed", assignee: "", creator: "lead", refs: [], links: [] },
{ id: 6, title: "Rate-limit audit — public endpoints", description: "", criteria: "every unauthenticated route has a limit or a reason", state: "proposed", assignee: "", creator: "lead", refs: [], links: [] },
{ id: 4, title: "Cloud posture — infra", description: "", criteria: "trivy config clean or findings triaged", state: "blocked", assignee: "cloud-posture", creator: "lead", refs: [], links: [] },
{ id: 5, title: "Report rollup", description: "", criteria: "one report, all sections", state: "review", assignee: "security", creator: "lead", refs: [], links: [] },
);
};
const boardPayload = () =>
boardItems.length
? { space: "/Users/test/OpenWorker/launch-note", name: "launch-note", items: boardItems }
: { space: null, name: "", items: [] };
// Fresh cloud sign-in state per test (module state outlives a page).
Object.assign(CLOUD_STATE, {
signed_in: false,
@@ -610,6 +630,16 @@ export async function mockApi(page: import("@playwright/test").Page) {
});
return; // suspended on the approval
}
// Agent teams (OPE-96): a decomposition turn — the agent files work items and
// the board (rail section + plan gate) appears on the next board fetch.
if (/plan the work/i.test(msg.text)) {
seedBoard();
send("assistant_message", {
text: "Split it into 5 work items — approve the plan and I'll get started.",
});
send("turn_done");
return;
}
// A deliverable turn ending in an artifact chip (§34) — for the chip-open flow.
if (/show the report/i.test(msg.text)) {
send("assistant_message", {
@@ -893,6 +923,32 @@ export async function mockApi(page: import("@playwright/test").Page) {
});
}
if (/\/v1\/sessions\/[^/]+\/artifacts\/reveal$/.test(p)) return json({ ok: true });
// Agent teams (OPE-96): board reads + the user-side mutations.
if (/\/v1\/sessions\/[^/]+\/board\/approve$/.test(p)) {
let approved = 0;
for (const item of boardItems) {
if (item.state === "proposed") {
item.state = "approved";
approved += 1;
}
}
return json({ approved, ...boardPayload() });
}
if (/\/v1\/sessions\/[^/]+\/board\/transition$/.test(p)) {
const b = req.postDataJSON() || {};
const item = boardItems.find((i) => i.id === Number(b.item));
if (!item) return json({ error: "no such item" });
item.state = String(b.to);
return json(item);
}
if (/\/v1\/sessions\/[^/]+\/board$/.test(p)) return json(boardPayload());
if (p.endsWith("/v1/teams/journal")) {
return json({
cases: boardItems.length
? [{ case: "findings", entries: 12, last_ts: new Date().toISOString() }]
: [],
});
}
if (/\/v1\/sessions\/[^/]+\/artifacts$/.test(p)) {
return json({
artifacts: [
+43
View File
@@ -3,7 +3,11 @@ import {
announceInboxUnlock,
createTempWorkspace,
finalizeAutomationRun,
boardApprove,
boardTransition,
getArtifacts,
getBoard,
type Board,
getHealth,
getRecentWorkspaces,
getSessionMessages,
@@ -72,6 +76,7 @@ import { ApprovalCard } from "./components/ApprovalCard";
import { ToolRequestCard } from "./components/ToolRequestCard";
import { DirectoryRequestCard } from "./components/DirectoryRequestCard";
import { PlanCard } from "./components/PlanCard";
import { BoardOverlay, PlanGateCard } from "./components/BoardPanel";
import { WorkspaceTrustPrompt } from "./components/WorkspaceTrustPrompt";
const newId = () =>
@@ -267,6 +272,10 @@ export function App() {
setSurface("persona");
};
const [browserRefreshKey, setBrowserRefreshKey] = useState(0);
// Agent teams (OPE-96): board for the current session's workspace space.
const [board, setBoard] = useState<Board | null>(null);
const [boardOpen, setBoardOpen] = useState(false);
const [planBusy, setPlanBusy] = useState(false);
const [railHidden, setRailHidden] = useState(false);
// Left-nav collapse (⌘B): when collapsed the sidebar leaves the grid so content reclaims the
// width; hovering the left edge peeks it back as a floating overlay. Persisted per-device.
@@ -945,6 +954,32 @@ export function App() {
getArtifacts(sessionId).then((a) => setArtifactCount(a.length)).catch(() => {});
}, [agent, surface, sessionId, browserRefreshKey]);
// Agent teams (OPE-96): the session's board — drives the rail section, the plan
// gate, and the expanded overlay. Refreshes with the same cycle as artifacts
// (session change + turn end) so items the agent just created appear.
useEffect(() => {
if (surface !== "session" || agent === "chat") {
setBoard(null);
return;
}
getBoard(sessionId).then(setBoard).catch(() => setBoard(null));
}, [agent, surface, sessionId, browserRefreshKey, running]);
const refreshBoard = () => getBoard(sessionId).then(setBoard).catch(() => {});
const approvePlan = async () => {
setPlanBusy(true);
try {
await boardApprove(sessionId);
await refreshBoard();
} finally {
setPlanBusy(false);
}
};
const moveBoardItem = async (item: number, to: string) => {
await boardTransition(sessionId, item, to);
await refreshBoard();
};
// Keep the active session's pending Inbox items fresh (answer-in-context card). Loads on session
// change + after each turn, plus a slow poll so an unattended agent's new question surfaces.
useEffect(() => {
@@ -1912,6 +1947,9 @@ export function App() {
) : sessionInbox[0] ? (
// Unattended session blocked on an Inbox item — answer it in context.
<InboxItemCard item={sessionInbox[0]} onResolve={resolveSessionInbox} compact />
) : board && board.items.some((i) => i.state === "proposed") ? (
// Agent teams: the decomposition gate — proposed items awaiting approval.
<PlanGateCard board={board} onApprove={approvePlan} busy={planBusy} />
) : undefined
}
/>
@@ -1932,7 +1970,12 @@ export function App() {
scratchPrimary={agent === "cowork" || tempWorkspace}
openAccessKey={accessKey}
onOpenIntegrations={() => setSurface("integrations")}
board={board}
onExpandBoard={() => setBoardOpen(true)}
/>
{boardOpen && board && board.space && (
<BoardOverlay board={board} onClose={() => setBoardOpen(false)} onTransition={moveBoardItem} />
)}
</div>
</div>
)}
+54
View File
@@ -209,6 +209,60 @@ export async function deleteSession(sessionId: string): Promise<{ ok: boolean; e
return res.json();
}
// Agent teams (OPE-96): the session's board — items on the workspace-keyed space.
export interface BoardItem {
id: number;
title: string;
description: string;
criteria: string;
state: "proposed" | "approved" | "in_progress" | "blocked" | "review" | "done" | "canceled" | string;
assignee: string;
creator: string;
refs: string[];
links: { kind: string; item: number }[];
}
export interface Board {
space: string | null;
name: string;
items: BoardItem[];
}
export interface JournalCase {
case: string;
entries: number;
last_ts: string;
}
export async function getBoard(sessionId: string): Promise<Board> {
const res = await fetch(`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/board`);
return res.json();
}
export async function boardApprove(sessionId: string): Promise<Board & { approved: number }> {
const res = await fetch(`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/board/approve`, { method: "POST" });
return res.json();
}
export async function boardTransition(
sessionId: string,
item: number,
to: string,
comment = "",
): Promise<BoardItem | { error: string }> {
const res = await fetch(`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/board/transition`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ item, to, comment }),
});
return res.json();
}
export async function getJournalCases(): Promise<JournalCase[]> {
const res = await fetch(`${httpBase()}/v1/teams/journal`);
return (await res.json()).cases ?? [];
}
export interface ArtifactInfo {
path: string; // workspace-relative (the display/API identifier)
abs_path?: string; // absolute — what "Copy path" copies
+224
View File
@@ -0,0 +1,224 @@
// Agent teams (OPE-96): the board in three shapes —
// - BoardSection: the right-rail summary (grouped by state, blocked on top)
// - BoardOverlay: the expanded, Linear-shaped view covering the chat column
// - PlanGateCard: the decomposition gate (proposed items awaiting the user)
// All three render the same Board data App owns; mutations go through the
// /board endpoints and act as the USER — the human side of the gates.
import { useEffect, useMemo, useState } from "react";
import type { Board, BoardItem } from "../api";
import { Icon } from "./Icon";
// Display order: needs-attention first (mock UX-030: "grouped by state, blocked on top").
const GROUPS: { state: string; label: string }[] = [
{ state: "blocked", label: "Blocked" },
{ state: "review", label: "Review" },
{ state: "in_progress", label: "In progress" },
{ state: "approved", label: "Approved" },
{ state: "proposed", label: "Proposed" },
{ state: "done", label: "Done" },
{ state: "canceled", label: "Canceled" },
];
function dotClass(state: string): string {
if (state === "blocked") return "board-dot blocked";
if (state === "review") return "board-dot review";
if (state === "in_progress") return "board-dot work";
if (state === "done") return "board-dot done";
return "board-dot idle";
}
export function boardSummary(board: Board): string {
const counts: Record<string, number> = {};
for (const item of board.items) counts[item.state] = (counts[item.state] || 0) + 1;
const parts: string[] = [];
if (counts.blocked) parts.push(`${counts.blocked} blocked`);
if (counts.review) parts.push(`${counts.review} review`);
if (counts.in_progress) parts.push(`${counts.in_progress} in progress`);
if (counts.proposed) parts.push(`${counts.proposed} proposed`);
return parts.join(" · ");
}
export function BoardSection({ board, onExpand }: { board: Board; onExpand: () => void }) {
const groups = GROUPS.map((g) => ({
...g,
items: board.items.filter((i) => i.state === g.state),
})).filter((g) => g.items.length > 0);
return (
<div className="board-rail" data-testid="board-rail">
{groups.map((group) => (
<div key={group.state}>
<div className="board-group">{group.label}</div>
{group.items.map((item) => (
<button className="board-row" key={item.id} onClick={onExpand} title="Open the board">
<span className={dotClass(item.state)} />
<span className="board-row-main">
<span className="board-row-title">
<span className="board-row-id">#{item.id}</span> {item.title}
</span>
{item.assignee && <span className="board-row-who">{item.assignee}</span>}
</span>
</button>
))}
</div>
))}
</div>
);
}
// The expanded board: state columns over the whole session area — the "clean and
// large board like Linear" (owner ask 2026-08-16). Esc, backdrop, or ✕ closes.
export function BoardOverlay({
board,
onClose,
onTransition,
}: {
board: Board;
onClose: () => void;
// (item, to) → performed as the user; App refetches on completion.
onTransition?: (item: number, to: string) => void;
}) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const columns = GROUPS.map((g) => ({
...g,
items: board.items.filter((i) => i.state === g.state),
})).filter((g) => g.items.length > 0 || ["in_progress", "approved", "review"].includes(g.state));
return (
<div className="board-overlay" data-testid="board-overlay" onClick={onClose}>
<div className="board-overlay-panel" onClick={(e) => e.stopPropagation()}>
<div className="board-overlay-head">
<div className="board-overlay-title">
<Icon name="table" size={16} />
<span>Board</span>
<span className="board-overlay-space">{board.name}</span>
</div>
<button className="artifact-icon-btn" onClick={onClose} aria-label="Close board" title="Close">
<Icon name="x" size={16} />
</button>
</div>
<div className="board-columns">
{columns.map((column) => (
<div className="board-col" key={column.state} data-testid={`board-col-${column.state}`}>
<div className="board-col-head">
<span className={dotClass(column.state)} />
{column.label}
<span className="board-col-count">{column.items.length}</span>
</div>
<div className="board-col-body">
{column.items.map((item) => (
<BoardCard key={item.id} item={item} onTransition={onTransition} />
))}
{column.items.length === 0 && <div className="board-col-empty"></div>}
</div>
</div>
))}
</div>
</div>
</div>
);
}
function BoardCard({
item,
onTransition,
}: {
item: BoardItem;
onTransition?: (item: number, to: string) => void;
}) {
// The user can always act; offer the obvious next moves for the state.
const moves: { to: string; label: string }[] =
item.state === "proposed"
? [{ to: "approved", label: "Approve" }, { to: "canceled", label: "Cancel" }]
: item.state === "review"
? [{ to: "done", label: "Mark done" }, { to: "in_progress", label: "Send back" }]
: item.state === "done" || item.state === "canceled"
? []
: [{ to: "canceled", label: "Cancel" }];
return (
<div className="board-card" data-testid={`board-item-${item.id}`}>
<div className="board-card-title">
<span className="board-row-id">#{item.id}</span> {item.title}
</div>
{item.criteria && (
<div className="board-card-criteria">
<span className="board-card-label">Done when:</span> {item.criteria}
</div>
)}
<div className="board-card-foot">
{item.assignee ? <span className="board-card-who">{item.assignee}</span> : <span />}
{onTransition && moves.length > 0 && (
<span className="board-card-actions">
{moves.map((m) => (
<button key={m.to} className="board-card-btn" onClick={() => onTransition(item.id, m.to)}>
{m.label}
</button>
))}
</span>
)}
</div>
</div>
);
}
// The decomposition gate: proposed items awaiting the user's approval, rendered in
// the composer head like the other request cards. Visible layer = the decisions
// (items + criteria); editing happens by replying — no in-card reply surface.
export function PlanGateCard({
board,
onApprove,
busy,
}: {
board: Board;
onApprove: () => void;
busy?: boolean;
}) {
const proposed = useMemo(() => board.items.filter((i) => i.state === "proposed"), [board.items]);
const [expanded, setExpanded] = useState(false);
if (proposed.length === 0) return null;
const visible = expanded ? proposed : proposed.slice(0, 3);
const hidden = proposed.length - visible.length;
return (
<div className="dirreq-card plangate-card" data-testid="plangate-card">
<div className="plangate-head">
<Icon name="table" size={15} />
<span className="plangate-title">
Proposed plan {proposed.length} work item{proposed.length === 1 ? "" : "s"}
</span>
<span className="plangate-board">board: {board.name}</span>
</div>
{visible.map((item) => (
<div className="plangate-item" key={item.id}>
<span className="plangate-num">#{item.id}</span>
<span className="plangate-body">
<span className="plangate-item-title">{item.title}</span>
{item.criteria && (
<span className="plangate-ac">
<b>Done when:</b> {item.criteria}
</span>
)}
</span>
</div>
))}
{hidden > 0 && (
<button className="plangate-more" onClick={() => setExpanded(true)}>
{hidden} more item{hidden === 1 ? "" : "s"}
<Icon name="chevronDown" size={12} />
</button>
)}
<div className="dirreq-actions">
<span className="plangate-note">Reply to edit the plan; nothing runs until you approve.</span>
<span className="spacer" />
<button className="btn primary" data-testid="plangate-approve" disabled={busy} onClick={onApprove}>
Approve plan
</button>
</div>
</div>
);
}
+67 -1
View File
@@ -3,17 +3,21 @@ import { useEffect, useRef, useState, type ReactNode } from "react";
import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
import {
getArtifacts,
getJournalCases,
readArtifact,
revealArtifact,
type ArtifactContent,
type ArtifactInfo,
type Board,
type JournalCase,
} from "../api";
import type { TodoItem } from "../types";
import { AccessSection } from "./AccessSection";
import { BoardSection, boardSummary } from "./BoardPanel";
import { Icon } from "./Icon";
import { Markdown, OPEN_ARTIFACT_EVENT } from "./Markdown";
type Panel = "progress" | "artifacts";
type Panel = "progress" | "artifacts" | "board" | "journal";
// Quiet file-type icons for the artifact list (the colored kind pills read as noisy).
function kindIcon(kind: string): "file" | "fileCode" | "image" | "table" {
@@ -57,6 +61,10 @@ interface Props {
scratchPrimary?: boolean;
openAccessKey?: number;
onOpenIntegrations?: () => void;
// Agent teams (OPE-96): App owns board data (the plan gate needs it too);
// the rail renders the summary section and the expand affordance.
board?: Board | null;
onExpandBoard?: () => void;
}
export function RightRail({
@@ -75,12 +83,17 @@ export function RightRail({
scratchPrimary,
openAccessKey = 0,
onOpenIntegrations,
board,
onExpandBoard,
}: Props) {
const [open, setOpen] = useState<Record<Panel, boolean>>({
progress: true,
artifacts: true,
board: true,
journal: false,
});
const [artifacts, setArtifacts] = useState<ArtifactInfo[]>([]);
const [journal, setJournal] = useState<JournalCase[]>([]);
const [selected, setSelected] = useState<ArtifactInfo | null>(null);
const [content, setContent] = useState<ArtifactContent | null>(null);
@@ -91,6 +104,16 @@ export function RightRail({
if (showArtifacts) refreshArtifacts();
}, [active, sessionId, refreshKey, showArtifacts]);
// Journal cases surface only when a board exists — same visibility rule as the
// Board section, so plain sessions carry zero team chrome.
useEffect(() => {
if (!active || !board?.space) {
setJournal([]);
return;
}
getJournalCases().then(setJournal).catch(() => setJournal([]));
}, [active, sessionId, refreshKey, board?.space]);
// Switching conversations closes any open artifact — it belongs to the previous session's
// workspace, which the new session can't (and shouldn't) read.
useEffect(() => {
@@ -178,6 +201,49 @@ export function RightRail({
<ProgressSummary running={running} toolNames={toolNames} todo={todo} />
</RailSection>
{/* Agent teams (OPE-96): board summary grouped by state, blocked on top.
Hidden entirely until the workspace has items (no chrome for plain sessions). */}
{board?.space && (
<RailSection
title={`Board${boardSummary(board) ? ` · ${boardSummary(board)}` : ""}`}
open={open.board}
onToggle={() => setOpen({ ...open, board: !open.board })}
action={
<button
className="rail-mini-btn"
data-testid="board-expand"
onClick={(e) => {
e.stopPropagation();
onExpandBoard?.();
}}
title="Expand the board"
>
<Icon name="panelOpen" size={13} />
</button>
}
>
<BoardSection board={board} onExpand={() => onExpandBoard?.()} />
</RailSection>
)}
{board?.space && journal.length > 0 && (
<RailSection
title={`Journal (${journal.length})`}
open={open.journal}
onToggle={() => setOpen({ ...open, journal: !open.journal })}
>
<div className="journal-list" data-testid="journal-list">
{journal.map((c) => (
<div className="journal-row" key={c.case}>
<Icon name="file" size={13} />
<span className="journal-case">{c.case}</span>
<span className="journal-count">{c.entries} entr{c.entries === 1 ? "y" : "ies"}</span>
</div>
))}
</div>
</RailSection>
)}
{showArtifacts && (
<RailSection
title={`Artifacts${artifacts.length ? ` (${artifacts.length})` : ""}`}
+92
View File
@@ -1637,3 +1637,95 @@ html[data-platform="linux"] ::-webkit-scrollbar-thumb:hover { background-color:
.artifact-folder-row:hover { background: var(--paper); }
.artifact-folder-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.artifact-folder-size { font-size: 11px; color: var(--faint); }
/* ── Agent teams (OPE-96): board rail, expanded overlay, plan gate ─────────── */
.board-rail { display: flex; flex-direction: column; gap: 2px; }
.board-group {
font-size: 10.5px; letter-spacing: 0.06em; text-transform: uppercase;
color: var(--faint); font-weight: 700; margin: 8px 0 3px;
}
.board-rail > div:first-child .board-group { margin-top: 0; }
.board-row {
display: flex; align-items: flex-start; gap: 8px; width: 100%; text-align: left;
padding: 5px 6px; border: 0; border-radius: 7px; background: transparent;
cursor: pointer; color: var(--muted); font-size: 12.5px;
}
.board-row:hover { background: var(--paper); }
.board-row-main { display: flex; flex-direction: column; min-width: 0; }
.board-row-title { color: var(--ink); overflow: hidden; text-overflow: ellipsis; }
.board-row-id { color: var(--faint); font-weight: 600; font-size: 11.5px; }
.board-row-who { font-size: 11px; color: var(--faint); }
.board-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; margin-top: 5px; background: var(--faint); }
.board-dot.work { background: var(--ok-dot); }
.board-dot.blocked { background: var(--danger); }
.board-dot.review { background: var(--warn-ink); }
.board-dot.done { background: var(--ok); opacity: 0.55; }
.board-dot.idle { background: var(--faint); }
/* Expanded board: covers the session area — the roomy, Linear-shaped view. */
.board-overlay {
position: fixed; inset: 0; z-index: 60;
background: color-mix(in srgb, var(--paper) 55%, transparent);
display: flex; align-items: stretch; justify-content: center; padding: 26px 28px;
}
.board-overlay-panel {
flex: 1; max-width: 1280px; display: flex; flex-direction: column; min-height: 0;
background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.28); overflow: hidden;
}
.board-overlay-head {
display: flex; align-items: center; gap: 10px; padding: 12px 16px;
border-bottom: 1px solid var(--line);
}
.board-overlay-title { display: flex; align-items: center; gap: 8px; font-weight: 600; font-size: 13.5px; color: var(--ink); flex: 1; }
.board-overlay-space { color: var(--faint); font-weight: 400; font-size: 12px; }
.board-columns {
flex: 1; display: flex; gap: 12px; padding: 14px 16px; overflow: auto; min-height: 0;
}
.board-col { flex: 1 1 0; min-width: 210px; max-width: 320px; display: flex; flex-direction: column; min-height: 0; }
.board-col-head {
display: flex; align-items: center; gap: 7px; font-size: 11.5px; font-weight: 700;
letter-spacing: 0.05em; text-transform: uppercase; color: var(--muted); padding: 2px 4px 8px;
}
.board-col-head .board-dot { margin-top: 0; }
.board-col-count { margin-left: auto; color: var(--faint); font-weight: 500; }
.board-col-body { display: flex; flex-direction: column; gap: 8px; overflow: auto; padding-bottom: 8px; }
.board-col-empty { color: var(--faint); font-size: 12px; padding: 6px 4px; }
.board-card {
background: var(--paper); border: 1px solid var(--line); border-radius: 10px;
padding: 9px 11px; display: flex; flex-direction: column; gap: 5px;
}
.board-card-title { font-size: 12.5px; color: var(--ink); }
.board-card-criteria { font-size: 11.5px; color: var(--muted); }
.board-card-label { font-weight: 600; }
.board-card-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.board-card-who { font-size: 11px; color: var(--faint); }
.board-card-actions { display: flex; gap: 6px; }
.board-card-btn {
border: 1px solid var(--line-strong); background: transparent; color: var(--muted);
border-radius: 6px; padding: 2px 8px; font-size: 11px; cursor: pointer;
}
.board-card-btn:hover { color: var(--ink); border-color: var(--muted); }
/* Journal rail section */
.journal-list { display: flex; flex-direction: column; gap: 3px; }
.journal-row { display: flex; align-items: center; gap: 7px; font-size: 12.5px; color: var(--muted); padding: 3px 4px; }
.journal-case { color: var(--ink); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.journal-count { font-size: 11px; color: var(--faint); }
/* Plan gate (decomposition gate) — rides the dirreq-card frame. */
.plangate-card { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.plangate-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.plangate-title { font-weight: 600; font-size: 13px; color: var(--ink); }
.plangate-board { margin-left: auto; font-size: 11.5px; color: var(--faint); }
.plangate-item { display: flex; gap: 9px; padding: 7px 0; border-top: 1px solid var(--line); }
.plangate-num { color: var(--faint); font-size: 12px; padding-top: 1px; }
.plangate-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.plangate-item-title { font-size: 12.5px; color: var(--ink); }
.plangate-ac { font-size: 11.5px; color: var(--muted); }
.plangate-ac b { font-weight: 600; }
.plangate-more {
display: flex; align-items: center; gap: 5px; border: 0; background: transparent;
color: var(--accent); font-size: 12px; cursor: pointer; padding: 6px 0;
}
.plangate-note { font-size: 11.5px; color: var(--faint); }