mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-11 14:50:14 +00:00
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:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user