mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-03 04:49:26 +00:00
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:
@@ -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()}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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: [
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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})` : ""}`}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user