Drop the proposed state: boards hold only accepted work

Plan proposals live in the conversation (plan-approval flow); items are created open/unassigned and work starts at assignment — the granted, revocable authority.
Also closes a verify gap: tail truncation is now caught against the stored head hash.
This commit is contained in:
Rohit C Prasad
2026-08-16 08:07:10 -07:00
committed by Rohit P
parent d8e4fc73c0
commit fc24b667ed
15 changed files with 106 additions and 247 deletions
+21 -29
View File
@@ -1,7 +1,9 @@
// 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.
// state, blocked on top) and the expanded Linear-shaped overlay. There is no
// draft/proposed state: plan approval is a conversation-layer moment (the existing
// plan-approval flow); the board only ever holds accepted work. The fake agent
// files items on "plan the work"; transitions round-trip through the mocked
// /board endpoints as the user.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
@@ -9,7 +11,7 @@ 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();
await expect(page.getByText(/filed 5 work items/)).toBeVisible();
}
test("plain sessions carry zero board chrome", async ({ page }) => {
@@ -18,53 +20,43 @@ test("plain sessions carry zero board chrome", async ({ page }) => {
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 ({
test("filed items appear grouped in the rail, blocked on top, open items listed", 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 collapse to ONE line —
// the gate card is the only place the plan renders in full (no double listing).
const rail = page.getByTestId("board-rail");
await expect(rail).toBeVisible();
const groups = rail.locator(".board-group");
await expect(groups.first()).toHaveText("Blocked");
await expect(page.getByTestId("board-proposed-note")).toHaveText("4 items awaiting your approval.");
await expect(rail.getByText("Dependency audit — lockfiles")).toHaveCount(0);
await page.getByTestId("plangate-approve").click();
await expect(page.getByTestId("plangate-card")).toHaveCount(0);
await expect(page.getByTestId("board-proposed-note")).toHaveCount(0);
await expect(rail).toContainText("Approved");
await expect(rail.getByText("Dependency audit — lockfiles")).toBeVisible();
// No gate, no draft: open items are real items, listed like any other state.
await expect(rail).toContainText("Open");
await expect(rail.getByText("Secrets — git history, both repos")).toBeVisible();
await expect(
page.getByRole("button", { name: /Board · 1 blocked · 1 review · 1 in progress · 2 open/ }),
).toBeVisible();
});
test("expand opens the overlay board; Esc closes; the user can act on a review item", async ({
test("expand opens the overlay board; the user verifies review items and removes open ones", 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();
// review → done (the verification gate stays a human/lead call)
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");
// open → removed (lead/user triage of filed items; maps to canceled underneath)
const openCol = page.getByTestId("board-col-open");
await openCol.getByRole("button", { name: "Remove" }).first().click();
await expect(page.getByTestId("board-col-canceled")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("board-overlay")).toHaveCount(0);
});
+9 -19
View File
@@ -562,16 +562,15 @@ 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.
// "plan the work" (the fake agent then files items; no draft state — the board only
// holds accepted work). Mutable so transitions 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: 1, title: "Code security review — api", description: "", criteria: "every finding triaged with file:line evidence", state: "open", assignee: "", creator: "lead", refs: [], links: [] },
{ id: 2, title: "Secrets — git history, both repos", description: "", criteria: "every hit dismissed-with-reason or rotation-instructed", state: "open", assignee: "", creator: "lead", refs: [], links: [] },
{ id: 3, title: "Dependency audit — lockfiles", description: "", criteria: "reachable vs theoretical separated; upgrade branch green", state: "in_progress", assignee: "dep-audit", 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: [] },
);
@@ -630,12 +629,13 @@ 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.
// Agent teams (OPE-96): a decomposition turn — the plan was approved in
// conversation (plan-approval flow); the agent files the items and the
// board rail 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.",
text: "Plan approved — filed 5 work items on the board.",
});
send("turn_done");
return;
@@ -924,16 +924,6 @@ 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));
+1 -15
View File
@@ -3,7 +3,6 @@ import {
announceInboxUnlock,
createTempWorkspace,
finalizeAutomationRun,
boardApprove,
boardTransition,
getArtifacts,
getBoard,
@@ -76,7 +75,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 { BoardOverlay } from "./components/BoardPanel";
import { WorkspaceTrustPrompt } from "./components/WorkspaceTrustPrompt";
const newId = () =>
@@ -275,7 +274,6 @@ export function App() {
// 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.
@@ -966,15 +964,6 @@ export function App() {
}, [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();
@@ -1947,9 +1936,6 @@ 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
}
/>
+1 -6
View File
@@ -215,7 +215,7 @@ export interface BoardItem {
title: string;
description: string;
criteria: string;
state: "proposed" | "approved" | "in_progress" | "blocked" | "review" | "done" | "canceled" | string;
state: "open" | "in_progress" | "blocked" | "review" | "done" | "canceled" | string;
assignee: string;
creator: string;
refs: string[];
@@ -239,11 +239,6 @@ export async function getBoard(sessionId: string): Promise<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,
+19 -85
View File
@@ -1,10 +1,11 @@
// Agent teams (OPE-96): the board in three shapes —
// Agent teams (OPE-96): the board in two 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";
// Both render the same Board data App owns; mutations go through the /board
// endpoints and act as the USER. There is NO proposed/draft state: a plan
// proposal lives in the conversation (plan-approval flow); the board only ever
// contains accepted work, and work starts at ASSIGNMENT.
import { useEffect } from "react";
import type { Board, BoardItem } from "../api";
import { Icon } from "./Icon";
@@ -13,8 +14,7 @@ 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: "open", label: "Open" },
{ state: "done", label: "Done" },
{ state: "canceled", label: "Canceled" },
];
@@ -34,21 +34,15 @@ export function boardSummary(board: Board): 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`);
if (counts.open) parts.push(`${counts.open} open`);
return parts.join(" · ");
}
export function BoardSection({ board, onExpand }: { board: Board; onExpand: () => void }) {
// Proposed items are the plan gate's content — the rail collapses them to one
// line (mock UX-030 state 1: "4 items awaiting your approval") instead of
// listing the same items twice on screen.
const proposed = board.items.filter((i) => i.state === "proposed");
const groups = GROUPS.filter((g) => g.state !== "proposed")
.map((g) => ({
...g,
items: board.items.filter((i) => i.state === g.state),
}))
.filter((g) => g.items.length > 0);
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) => (
@@ -67,11 +61,6 @@ export function BoardSection({ board, onExpand }: { board: Board; onExpand: () =
))}
</div>
))}
{proposed.length > 0 && (
<div className="board-proposed-note" data-testid="board-proposed-note">
{proposed.length} item{proposed.length === 1 ? "" : "s"} awaiting your approval.
</div>
)}
</div>
);
}
@@ -99,7 +88,7 @@ export function BoardOverlay({
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));
})).filter((g) => g.items.length > 0 || ["in_progress", "open", "review"].includes(g.state));
return (
<div className="board-overlay" data-testid="board-overlay" onClick={onClose}>
@@ -145,13 +134,13 @@ function BoardCard({
}) {
// 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"
item.state === "review"
? [{ to: "done", label: "Mark done" }, { to: "in_progress", label: "Send back" }]
: item.state === "canceled"
? [{ to: "open", label: "Reopen" }]
: item.state === "done"
? []
: [{ to: "canceled", label: "Cancel" }];
: [{ to: "canceled", label: "Remove" }];
return (
<div className="board-card" data-testid={`board-item-${item.id}`}>
<div className="board-card-title">
@@ -178,58 +167,3 @@ function BoardCard({
);
}
// 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>
);
}
+1 -17
View File
@@ -1713,20 +1713,4 @@ html[data-platform="linux"] ::-webkit-scrollbar-thumb:hover { background-color:
.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); }
.board-proposed-note { color: var(--faint); font-size: 12px; padding: 8px 6px 2px; }