diff --git a/coworker/server/app.py b/coworker/server/app.py index 34ed584d..1123b2aa 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -746,6 +746,23 @@ def create_app(manager: SessionManager) -> FastAPI: def session_board(session_id: str) -> dict[str, Any]: return manager.session_board(session_id) + @app.get("/v1/sessions/{session_id}/board/item") + def session_board_item(session_id: str, id: int) -> dict[str, Any]: + return manager.board_item_detail(session_id, int(id)) + + @app.get("/v1/sessions/{session_id}/board/attachment") + def session_board_attachment(session_id: str, name: str): + from fastapi.responses import Response + + try: + path = manager.attachment_store.path_for(name) + except TeamsBoardError as error: + return JSONResponse({"error": str(error)}, status_code=404) + return Response( + content=path.read_bytes(), + media_type=manager.attachment_store.mime_for(name), + ) + @app.post("/v1/sessions/{session_id}/board/transition") def session_board_transition(session_id: str, body: dict) -> dict[str, Any]: body = body or {} diff --git a/coworker/server/manager.py b/coworker/server/manager.py index 3bad327e..44c738b6 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -1415,6 +1415,49 @@ class SessionManager: def _user_actor(self) -> TeamActor: return TeamActor(id="user", role=TeamRole.USER) + def board_item_detail(self, session_id: str, item_id: int) -> dict[str, Any]: + """One item in full, with its TIMELINE — creations, assignments, + transitions, and comments merged chronologically (the detail pane renders + the item's whole story; the store is an event log, so this is just its + honest projection). Acts as the user.""" + space = self._board_space(session_id) + if space is None: + return {"error": "no board for this session"} + try: + item = self.team_store.get_item(space, int(item_id)) + except TeamsBoardError as error: + return {"error": str(error)} + timeline: list[dict[str, Any]] = [] + for event in self.team_store.events(space, item_id=int(item_id)): + payload = event.get("payload") or {} + row: dict[str, Any] = { + "seq": event["seq"], + "ts": event["ts"], + "actor": event["actor"], + } + if event["kind"] == "item_created": + row["kind"] = "created" + elif event["kind"] == "item_assigned": + row["kind"] = "claimed" if payload.get("claimed") else "assigned" + row["assignee"] = payload.get("assignee") or "" + elif event["kind"] == "item_transitioned": + row["kind"] = "moved" + row["to"] = payload.get("to") or "" + if payload.get("comment"): + row["body"] = payload["comment"] + if payload.get("refs"): + row["refs"] = payload["refs"] + elif event["kind"] == "item_commented": + row["kind"] = "comment" + row["body"] = payload.get("body") or "" + if payload.get("refs"): + row["refs"] = payload["refs"] + else: + continue + timeline.append(row) + item["timeline"] = timeline + return item + 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.""" @@ -1424,6 +1467,20 @@ class SessionManager: items = self.team_store.list_items(space, self._user_actor()) if not items: return {"space": None, "name": "", "items": []} + # Blocked rows carry the blocker as a plain fact ("blocked: need tfvars") — + # the latest blocked-transition comment, resolved here so the list stays + # one round-trip. + for item in items: + if item["state"] != "blocked": + continue + for event in reversed( + self.team_store.events(space, item_id=item["id"]) + ): + payload = event.get("payload") or {} + if event["kind"] == "item_transitioned" and payload.get("to") == "blocked": + if payload.get("comment"): + item["blocker"] = self._clamp(payload["comment"], 120) + break return {"space": space, "name": Path(space).name, "items": items} def board_transition( diff --git a/surfaces/gui/e2e/board.spec.ts b/surfaces/gui/e2e/board.spec.ts index 6f0dfc16..2e605dcc 100644 --- a/surfaces/gui/e2e/board.spec.ts +++ b/surfaces/gui/e2e/board.spec.ts @@ -1,9 +1,10 @@ -// Agent teams (OPE-96): the board in the session UI — rail section (grouped by -// 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. +// Agent teams: the board in the session UI — the rail section (grouped by state, +// blocked on top, active work only) and the expanded overlay: a quiet list over +// the store's RAW states (In progress / Awaiting review / Queued — no computed +// interpretation layer, no row buttons) plus a detail pane with the item's merged +// event timeline. Verdicts flow through the pane: Mark done / Request changes…. +// 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"; @@ -22,7 +23,7 @@ test("plain sessions carry zero board chrome", async ({ page }) => { await expect(page.getByTestId("board-rail")).toHaveCount(0); }); -test("filed items appear grouped in the rail, blocked on top, open items listed", async ({ +test("filed items appear grouped in the rail, blocked on top, queued items listed", async ({ page, }) => { await planTheWork(page); @@ -30,32 +31,40 @@ test("filed items appear grouped in the rail, blocked on top, open items listed" await expect(rail).toBeVisible(); const groups = rail.locator(".board-group"); await expect(groups.first()).toHaveText("Blocked"); - // No gate, no draft: open items are real items, listed like any other state. - await expect(rail).toContainText("Open"); + await expect(rail).toContainText("Queued"); 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; the user verifies review items and removes open ones", async ({ +test("the overlay lists raw-state sections; verdicts flow through the detail pane", async ({ page, }) => { await planTheWork(page); await page.getByTestId("board-expand").click(); const overlay = page.getByTestId("board-overlay"); await expect(overlay).toBeVisible(); - await expect(page.getByTestId("board-col-blocked")).toBeVisible(); + // the owner's sections, nothing computed — and no buttons in the rows + await expect(overlay).toContainText("In progress"); + await expect(overlay).toContainText("Awaiting review"); + await expect(overlay).toContainText("Queued"); + await expect(overlay.getByRole("button", { name: "Mark done" })).toHaveCount(0); + // a blocked row carries the blocker as a plain fact under In progress + await expect(page.getByTestId("board-item-4")).toContainText( + "cloud-posture · blocked: need tfvars for staging", + ); - // review → done (the verification gate stays a human/lead call) - const reviewCol = page.getByTestId("board-col-review"); - await reviewCol.getByRole("button", { name: "Mark done" }).click(); - await expect(page.getByTestId("board-col-done")).toContainText("Report rollup"); + // review verdict from the pane + await page.getByTestId("board-item-5").click(); + const detail = page.getByTestId("board-detail"); + await detail.getByRole("button", { name: "Mark done" }).click(); + await expect(page.getByTestId("overlay-finished-toggle")).toHaveText("1 finished · show"); - // 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(); + // queued items are removed from their pane (maps to canceled underneath) + await page.getByTestId("board-item-1").click(); + await detail.getByRole("button", { name: "Remove" }).click(); + await expect(page.getByTestId("overlay-finished-toggle")).toHaveText("2 finished · show"); await page.keyboard.press("Escape"); await expect(page.getByTestId("board-overlay")).toHaveCount(0); @@ -66,10 +75,8 @@ test("finished items leave the rail; a quiet toggle reveals them", async ({ page const rail = page.getByTestId("board-rail"); await expect(rail.getByText("Report rollup")).toBeVisible(); // review = active await page.getByTestId("board-expand").click(); - await page - .getByTestId("board-col-review") - .getByRole("button", { name: "Mark done" }) - .click(); + await page.getByTestId("board-item-5").click(); + await page.getByTestId("board-detail").getByRole("button", { name: "Mark done" }).click(); await page.keyboard.press("Escape"); // done vanishes from the rail — a fresh session on an old board starts calm await expect(rail.getByText("Report rollup")).toHaveCount(0); @@ -81,6 +88,36 @@ test("finished items leave the rail; a quiet toggle reveals them", async ({ page await expect(rail.getByText("Report rollup")).toHaveCount(0); }); +test("item detail: timeline with attachment, worker link, request changes", async ({ + page, +}) => { + await planTheWork(page); + // a rail row deep-opens the overlay on that item's detail + await page.getByTestId("board-rail").getByText("Report rollup").click(); + const detail = page.getByTestId("board-detail"); + await expect(detail).toBeVisible(); + await expect(detail).toContainText("#5"); + await expect(detail).toContainText("Report rollup"); + await expect(detail).toContainText("In review"); + await expect(detail).toContainText("Done when"); + // the merged timeline tells the item's whole story + await expect(detail).toContainText("security started"); + await expect(detail).toContainText("balances reconcile against the seeded rows"); + await expect(detail).toContainText("moved to in review"); + // the attachment image actually loads (real bytes from the fixture) + await expect(detail.getByTestId("board-attachment")).toBeVisible(); + // the assignee links to that coworker's session + await expect(detail.getByTestId("board-open-worker")).toHaveText("security ↗"); + // Request changes… discloses a comment box; sending returns the item to work + await detail.getByRole("button", { name: "Request changes…" }).click(); + await detail.getByPlaceholder("What needs to change?").fill("totals drift on Tom"); + await detail.getByRole("button", { name: "Send to security" }).click(); + await expect(detail).toContainText("In progress"); + // switching rows switches the pane + await page.getByTestId("board-item-3").click(); + await expect(detail).toContainText("Dependency audit — lockfiles"); +}); + test("journal section lists cases once a board exists", async ({ page }) => { await planTheWork(page); await page.getByRole("button", { name: /Journal/ }).click(); diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index 1b45d5f2..774e72f3 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -582,8 +582,8 @@ export async function mockApi(page: import("@playwright/test").Page) { { 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: [] }, + { id: 4, title: "Cloud posture — infra", description: "", criteria: "trivy config clean or findings triaged", state: "blocked", assignee: "cloud-posture", creator: "lead", refs: [], links: [], blocker: "need tfvars for staging" }, + { id: 5, title: "Report rollup", description: "", criteria: "one report, all sections", state: "review", assignee: "security", creator: "lead", refs: [`attachment://${"a".repeat(64)}.png#rendered-page.png`], links: [] }, ); }; const boardPayload = () => @@ -1062,6 +1062,56 @@ export async function mockApi(page: import("@playwright/test").Page) { }); } if (/\/v1\/sessions\/[^/]+\/artifacts\/reveal$/.test(p)) return json({ ok: true }); + // Item detail (merged event timeline + attachments) for the detail pane. + if (/\/v1\/sessions\/[^/]+\/board\/item$/.test(p)) { + const id = Number(new URL(req.url()).searchParams.get("id")); + const item = boardItems.find((i) => i.id === id); + if (!item) return json({ error: "no such item" }); + const at = new Date().toISOString(); + const timeline = + id === 5 + ? [ + { seq: 30, ts: at, actor: "lead", kind: "created" }, + { seq: 31, ts: at, actor: "lead", kind: "assigned", assignee: "security" }, + { seq: 32, ts: at, actor: "security", kind: "moved", to: "in_progress" }, + { + seq: 41, + ts: at, + actor: "security", + kind: "comment", + body: "Rolled all four sections into report.md — balances reconcile against the seeded rows.", + }, + { + seq: 42, + ts: at, + actor: "security", + kind: "comment", + body: "attached the rendered page", + refs: [`attachment://${"a".repeat(64)}.png#rendered-page.png`], + }, + { + seq: 43, + ts: at, + actor: "security", + kind: "moved", + to: "review", + body: "Ready — balances verified against seeded rows.", + }, + ] + : [{ seq: 30, ts: at, actor: "lead", kind: "created" }]; + return json({ ...item, timeline }); + } + if (/\/v1\/sessions\/[^/]+\/board\/attachment$/.test(p)) { + // A real 1x1 PNG so the actually loads (the spec asserts it renders). + return route.fulfill({ + status: 200, + contentType: "image/png", + body: Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64", + ), + }); + } // Agent teams (OPE-96): board reads + the user-side mutations. if (/\/v1\/sessions\/[^/]+\/board\/transition$/.test(p)) { const b = req.postDataJSON() || {}; diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index ec3f44f4..54f9533b 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -4,6 +4,8 @@ import { createTempWorkspace, finalizeAutomationRun, boardTransition, + fetchBoardAttachment, + getBoardItem, getArtifacts, getBoard, type Board, @@ -277,6 +279,8 @@ export function App() { // Agent teams (OPE-96): board for the current session's workspace space. const [board, setBoard] = useState(null); const [boardOpen, setBoardOpen] = useState(false); + // A rail row click deep-opens the overlay on that item's detail pane. + const [boardDetailId, setBoardDetailId] = useState(null); // # team chat overlay — opened from the team entry's chat row. const [chatTeam, setChatTeam] = useState(null); const [railHidden, setRailHidden] = useState(false); @@ -994,8 +998,8 @@ export function App() { }, [agent, surface, sessionId, browserRefreshKey, running]); const refreshBoard = () => getBoard(sessionId).then(setBoard).catch(() => {}); - const moveBoardItem = async (item: number, to: string) => { - await boardTransition(sessionId, item, to); + const moveBoardItem = async (item: number, to: string, comment = "") => { + await boardTransition(sessionId, item, to, comment); await refreshBoard(); }; @@ -2058,9 +2062,41 @@ export function App() { onOpenIntegrations={() => setSurface("integrations")} board={board} onExpandBoard={() => setBoardOpen(true)} + onOpenBoardItem={(id) => { + setBoardDetailId(id); + setBoardOpen(true); + }} /> {boardOpen && board && board.space && ( - setBoardOpen(false)} onTransition={moveBoardItem} /> + { + setBoardOpen(false); + setBoardDetailId(null); + }} + onTransition={moveBoardItem} + loadItem={(id) => getBoardItem(sessionId, id)} + loadAttachment={(stored) => fetchBoardAttachment(sessionId, stored)} + onOpenWorker={(actor) => { + // The assignee is a team actor whose worker session the sidebar + // already knows — jump straight into its transcript. + const match = + sessions.find( + (s) => + s.team?.role === "worker" && + s.team?.actor === actor && + s.workspace === board.space + ) || + sessions.find( + (s) => s.team?.role === "worker" && s.team?.actor === actor + ); + if (!match) return; + setBoardOpen(false); + setBoardDetailId(null); + void selectSession(match.session_id, match.workspace, match.agent); + }} + initialItem={boardDetailId} + /> )} diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index 5a684aa0..519ce5fe 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -234,6 +234,8 @@ export interface BoardItem { creator: string; refs: string[]; links: { kind: string; item: number }[]; + // Blocked rows only: the latest blocker comment, clamped ("need tfvars…"). + blocker?: string; } export interface Board { @@ -253,6 +255,44 @@ export async function getBoard(sessionId: string): Promise { return res.json(); } +// One event in an item's merged timeline (the detail pane renders the item's +// whole story: filed → assigned/claimed → moves → comments, with attachments). +export interface BoardTimelineEvent { + seq: number; + ts: string; + actor: string; + kind: "created" | "assigned" | "claimed" | "moved" | "comment" | string; + to?: string; + assignee?: string; + body?: string; + refs?: string[]; +} + +export type BoardItemDetail = BoardItem & { timeline?: BoardTimelineEvent[] }; + +export async function getBoardItem( + sessionId: string, + id: number, +): Promise { + const res = await fetch( + `${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/board/item?id=${id}`, + ); + return res.json(); +} + +// Attachment bytes → an object URL for . The module fetch wrapper carries +// the sidecar token, which a bare cannot. +export async function fetchBoardAttachment( + sessionId: string, + stored: string, +): Promise { + const res = await fetch( + `${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/board/attachment?name=${encodeURIComponent(stored)}`, + ); + if (!res.ok) return null; + return URL.createObjectURL(await res.blob()); +} + export async function boardTransition( sessionId: string, item: number, diff --git a/surfaces/gui/src/components/BoardPanel.tsx b/surfaces/gui/src/components/BoardPanel.tsx index cb4174c5..18126812 100644 --- a/surfaces/gui/src/components/BoardPanel.tsx +++ b/surfaces/gui/src/components/BoardPanel.tsx @@ -1,20 +1,23 @@ -// Agent teams (OPE-96): the board in two shapes — +// Agent teams (OPE-96 → detail-view rework, owner-approved mock 2026-08-17): // - BoardSection: the right-rail summary (grouped by state, blocked on top) -// - BoardOverlay: the expanded, Linear-shaped view covering the chat column +// - BoardOverlay: the expanded view — a QUIET LIST grouped by the store's raw +// states (In progress / Awaiting review / Queued; owner ruling: no computed +// interpretation layer, no row buttons, no badges) + a Linear-style detail +// pane with the item's TIMELINE (events + comments merged — the store is an +// event log; the pane is its honest projection). Actions live in the pane +// only: Mark done / Request changes… (review), Remove (queued), Reopen. // 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. +// endpoints and act as the USER. import { useEffect, useState } from "react"; -import type { Board, BoardItem } from "../api"; +import type { Board, BoardItem, BoardItemDetail, BoardTimelineEvent } 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 }[] = [ +// Rail display order: needs-attention first (mock UX-030: "blocked on top"). +const RAIL_GROUPS: { state: string; label: string }[] = [ { state: "blocked", label: "Blocked" }, - { state: "review", label: "Review" }, + { state: "review", label: "Awaiting review" }, { state: "in_progress", label: "In progress" }, - { state: "open", label: "Open" }, + { state: "open", label: "Queued" }, { state: "done", label: "Done" }, { state: "canceled", label: "Canceled" }, ]; @@ -38,7 +41,16 @@ export function boardSummary(board: Board): string { return parts.join(" · "); } -export function BoardSection({ board, onExpand }: { board: Board; onExpand: () => void }) { +export function BoardSection({ + board, + onExpand, + onOpenItem, +}: { + board: Board; + onExpand: () => void; + // Row click deep-opens the overlay on that item's detail (falls back to expand). + onOpenItem?: (id: number) => void; +}) { // The rail shows ACTIVE work only (owner ruling 2026-08-16): a project board // outlives its sessions, so finished history from a past effort would greet // every fresh session as a long stale list. Done/canceled sit behind a quiet @@ -48,8 +60,8 @@ export function BoardSection({ board, onExpand }: { board: Board; onExpand: () = (i) => i.state === "done" || i.state === "canceled" ).length; const shown = showFinished - ? GROUPS - : GROUPS.filter((g) => g.state !== "done" && g.state !== "canceled"); + ? RAIL_GROUPS + : RAIL_GROUPS.filter((g) => g.state !== "done" && g.state !== "canceled"); const groups = shown .map((g) => ({ ...g, @@ -67,7 +79,12 @@ export function BoardSection({ board, onExpand }: { board: Board; onExpand: () =
{group.label}
{group.items.map((item) => ( - + ); return (
@@ -130,67 +203,286 @@ export function BoardOverlay({
-
- {columns.map((column) => ( -
-
- - {column.label} - {column.items.length} +
+
+ {sections.map((section) => ( +
+
{section.label}
+ {section.items.map(row)}
-
- {column.items.map((item) => ( - - ))} - {column.items.length === 0 &&
} -
-
- ))} + ))} + {sections.length === 0 && ( +
No active work
+ )} + {finished.length > 0 && ( + <> + + {showFinished && ( +
+
Finished
+ {finished.map(row)} +
+ )} + + )} +
+ {detail && ( + + )}
); } -function BoardCard({ - item, +const STATE_LABEL: Record = { + open: "Queued", + in_progress: "In progress", + blocked: "Blocked", + review: "In review", + done: "Done", + canceled: "Canceled", +}; + +function ItemDetail({ + detail, onTransition, + loadAttachment, + onOpenWorker, }: { - item: BoardItem; - onTransition?: (item: number, to: string) => void; + detail: BoardItemDetail; + onTransition?: (item: number, to: string, comment?: string) => void; + loadAttachment?: (stored: string) => Promise; + onOpenWorker?: (actor: string) => void; }) { - // The user can always act; offer the obvious next moves for the state. - const moves: { to: string; label: string }[] = - 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: "Remove" }]; + // "Request changes…" discloses a comment box; the verdict rides the transition. + const [changesOpen, setChangesOpen] = useState(false); + const [changesText, setChangesText] = useState(""); + useEffect(() => { + setChangesOpen(false); + setChangesText(""); + }, [detail.id]); return ( -
-
- #{item.id} {item.title} +
+
+ #{detail.id} {detail.title}
- {item.criteria && ( -
- Done when: {item.criteria} +
+ + {STATE_LABEL[detail.state] || detail.state} + + {detail.assignee && ( + <> + {" · "} + {onOpenWorker ? ( + + ) : ( + detail.assignee + )} + + )} + {" · filed by "} + {detail.creator} +
+ {detail.description && ( +
{detail.description}
+ )} + {detail.criteria && ( +
+ Done when — {detail.criteria}
)} -
- {item.assignee ? {item.assignee} : } - {onTransition && moves.length > 0 && ( - - {moves.map((m) => ( - - ))} - - )} +
+ {(detail.timeline || []).map((event) => ( + + ))}
+ {onTransition && ( + + )}
); } +function DetailActions({ + detail, + onTransition, + changesOpen, + setChangesOpen, + changesText, + setChangesText, +}: { + detail: BoardItemDetail; + onTransition: (item: number, to: string, comment?: string) => void; + changesOpen: boolean; + setChangesOpen: (v: boolean) => void; + changesText: string; + setChangesText: (v: string) => void; +}) { + if (detail.state === "review") { + return ( +
+ {changesOpen ? ( +
+