From 4c1b5423709b079c965622c8d74766821572a24a Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Sun, 16 Aug 2026 07:11:52 -0700 Subject: [PATCH] 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. --- coworker/server/app.py | 24 +++ coworker/server/manager.py | 79 +++++++- coworker/teams/journal.py | 20 ++ coworker/teams/model.py | 8 + surfaces/gui/e2e/board.spec.ts | 74 +++++++ surfaces/gui/e2e/fixtures.ts | 56 ++++++ surfaces/gui/src/App.tsx | 43 ++++ surfaces/gui/src/api.ts | 54 +++++ surfaces/gui/src/components/BoardPanel.tsx | 224 +++++++++++++++++++++ surfaces/gui/src/components/RightRail.tsx | 68 ++++++- surfaces/gui/src/styles.css | 92 +++++++++ 11 files changed, 739 insertions(+), 3 deletions(-) create mode 100644 surfaces/gui/e2e/board.spec.ts create mode 100644 surfaces/gui/src/components/BoardPanel.tsx diff --git a/coworker/server/app.py b/coworker/server/app.py index 375037a7..aeacc4ef 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -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()} diff --git a/coworker/server/manager.py b/coworker/server/manager.py index 3e923eea..9a9cedb3 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -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 diff --git a/coworker/teams/journal.py b/coworker/teams/journal.py index be2ecdec..648f62f5 100644 --- a/coworker/teams/journal.py +++ b/coworker/teams/journal.py @@ -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: diff --git a/coworker/teams/model.py b/coworker/teams/model.py index 5de6ba0a..3ed6c630 100644 --- a/coworker/teams/model.py +++ b/coworker/teams/model.py @@ -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.""" diff --git a/surfaces/gui/e2e/board.spec.ts b/surfaces/gui/e2e/board.spec.ts new file mode 100644 index 00000000..70a7dbce --- /dev/null +++ b/surfaces/gui/e2e/board.spec.ts @@ -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"); +}); diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index 2350b2aa..eac71e4e 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -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: [ diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index dd70e707..1308948d 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -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(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. + ) : board && board.items.some((i) => i.state === "proposed") ? ( + // Agent teams: the decomposition gate — proposed items awaiting approval. + ) : 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 && ( + setBoardOpen(false)} onTransition={moveBoardItem} /> + )} )} diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index cceee941..84aaf210 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -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 { + const res = await fetch(`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/board`); + return res.json(); +} + +export async function boardApprove(sessionId: string): Promise { + 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 { + 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 { + 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 diff --git a/surfaces/gui/src/components/BoardPanel.tsx b/surfaces/gui/src/components/BoardPanel.tsx new file mode 100644 index 00000000..bafdb72d --- /dev/null +++ b/surfaces/gui/src/components/BoardPanel.tsx @@ -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 = {}; + 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 ( +
+ {groups.map((group) => ( +
+
{group.label}
+ {group.items.map((item) => ( + + ))} +
+ ))} +
+ ); +} + +// 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 ( +
+
e.stopPropagation()}> +
+
+ + Board + {board.name} +
+ +
+
+ {columns.map((column) => ( +
+
+ + {column.label} + {column.items.length} +
+
+ {column.items.map((item) => ( + + ))} + {column.items.length === 0 &&
} +
+
+ ))} +
+
+
+ ); +} + +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 ( +
+
+ #{item.id} {item.title} +
+ {item.criteria && ( +
+ Done when: {item.criteria} +
+ )} +
+ {item.assignee ? {item.assignee} : } + {onTransition && moves.length > 0 && ( + + {moves.map((m) => ( + + ))} + + )} +
+
+ ); +} + +// 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 ( +
+
+ + + Proposed plan — {proposed.length} work item{proposed.length === 1 ? "" : "s"} + + board: {board.name} +
+ {visible.map((item) => ( +
+ #{item.id} + + {item.title} + {item.criteria && ( + + Done when: {item.criteria} + + )} + +
+ ))} + {hidden > 0 && ( + + )} +
+ Reply to edit the plan; nothing runs until you approve. + + +
+
+ ); +} diff --git a/surfaces/gui/src/components/RightRail.tsx b/surfaces/gui/src/components/RightRail.tsx index 651ee917..99b42d9a 100644 --- a/surfaces/gui/src/components/RightRail.tsx +++ b/surfaces/gui/src/components/RightRail.tsx @@ -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>({ progress: true, artifacts: true, + board: true, + journal: false, }); const [artifacts, setArtifacts] = useState([]); + const [journal, setJournal] = useState([]); const [selected, setSelected] = useState(null); const [content, setContent] = useState(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({ + {/* 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 && ( + setOpen({ ...open, board: !open.board })} + action={ + + } + > + onExpandBoard?.()} /> + + )} + + {board?.space && journal.length > 0 && ( + setOpen({ ...open, journal: !open.journal })} + > +
+ {journal.map((c) => ( +
+ + {c.case} + {c.entries} entr{c.entries === 1 ? "y" : "ies"} +
+ ))} +
+
+ )} + {showArtifacts && ( 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); }