Board overlay rework: raw-state list + item detail with event timeline

Sections are the store's states (In progress / Awaiting review / Queued), no row buttons or badges; blocked rows carry the blocker fact.
Detail pane merges the item's events into one timeline (filed/assigned/moves/comments/attachments), links the assignee to its worker session, and hosts the verdicts: Mark done / Request changes… (returns to the worker with the comment).
This commit is contained in:
Rohit C Prasad
2026-08-17 14:55:40 -07:00
committed by Rohit P
parent b2418d5a30
commit b952ed1e1e
10 changed files with 747 additions and 119 deletions
+17
View File
@@ -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 {}
+57
View File
@@ -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(
+60 -23
View File
@@ -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();
+52 -2
View File
@@ -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 <img> 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() || {};
+39 -3
View File
@@ -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<Board | null>(null);
const [boardOpen, setBoardOpen] = useState(false);
// A rail row click deep-opens the overlay on that item's detail pane.
const [boardDetailId, setBoardDetailId] = useState<number | null>(null);
// # team chat overlay — opened from the team entry's chat row.
const [chatTeam, setChatTeam] = useState<string | null>(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 && (
<BoardOverlay board={board} onClose={() => setBoardOpen(false)} onTransition={moveBoardItem} />
<BoardOverlay
board={board}
onClose={() => {
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}
/>
)}
</div>
</div>
+40
View File
@@ -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<Board> {
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<BoardItemDetail | { error: string }> {
const res = await fetch(
`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/board/item?id=${id}`,
);
return res.json();
}
// Attachment bytes → an object URL for <img>. The module fetch wrapper carries
// the sidecar token, which a bare <img src> cannot.
export async function fetchBoardAttachment(
sessionId: string,
stored: string,
): Promise<string | null> {
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,
+359 -67
View File
@@ -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: () =
<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">
<button
className="board-row"
key={item.id}
onClick={() => (onOpenItem ? onOpenItem(item.id) : onExpand())}
title="Open item"
>
<span className={dotClass(item.state)} />
<span className="board-row-main">
<span className="board-row-title">
@@ -92,18 +109,36 @@ export function BoardSection({ board, onExpand }: { board: Board; onExpand: () =
);
}
// 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.
// Overlay list sections — the store's raw states, nothing computed (owner ruling
// 2026-08-17). Blocked rows live under In progress: still that worker's item,
// just stuck — the red dot + blocker fact carry the difference.
const LIST_SECTIONS: { label: string; states: string[] }[] = [
{ label: "In progress", states: ["in_progress", "blocked"] },
{ label: "Awaiting review", states: ["review"] },
{ label: "Queued", states: ["open"] },
];
export function BoardOverlay({
board,
onClose,
onTransition,
loadItem,
loadAttachment,
onOpenWorker,
initialItem,
}: {
board: Board;
onClose: () => void;
// (item, to) → performed as the user; App refetches on completion.
onTransition?: (item: number, to: string) => void;
// (item, to, comment?) → performed as the user; App refetches on completion.
onTransition?: (item: number, to: string, comment?: string) => void;
loadItem?: (id: number) => Promise<BoardItemDetail | { error: string }>;
loadAttachment?: (stored: string) => Promise<string | null>;
// Assignee link → jump into that coworker's session (closes the overlay).
onOpenWorker?: (actor: string) => void;
initialItem?: number | null;
}) {
const [detail, setDetail] = useState<BoardItemDetail | null>(null);
const [showFinished, setShowFinished] = useState(false);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
@@ -112,10 +147,48 @@ export function BoardOverlay({
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", "open", "review"].includes(g.state));
const openItem = async (id: number) => {
if (!loadItem) return;
const loaded = await loadItem(id);
if (!("error" in loaded)) setDetail(loaded);
};
useEffect(() => {
if (initialItem != null) void openItem(initialItem);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialItem]);
const move = async (item: number, to: string, comment?: string) => {
onTransition?.(item, to, comment);
// the pane refreshes on the next tick so the transition's board refetch lands first
if (detail?.id === item) setTimeout(() => void openItem(item), 350);
};
const finished = board.items.filter(
(i) => i.state === "done" || i.state === "canceled"
);
const sections = LIST_SECTIONS.map((s) => ({
...s,
items: board.items.filter((i) => s.states.includes(i.state)),
})).filter((s) => s.items.length > 0);
const row = (item: BoardItem) => (
<button
className={"board-lrow" + (detail?.id === item.id ? " sel" : "")}
key={item.id}
data-testid={`board-item-${item.id}`}
onClick={() => void openItem(item.id)}
>
<span className={dotClass(item.state)} />
<span className="board-lrow-id">#{item.id}</span>
<span className="board-lrow-title">{item.title}</span>
<span className="board-lrow-end">
{item.assignee}
{item.state === "blocked" && (
<> · blocked{item.blocker ? `: ${item.blocker}` : ""}</>
)}
</span>
</button>
);
return (
<div className="board-overlay" data-testid="board-overlay" onClick={onClose}>
@@ -130,67 +203,286 @@ export function BoardOverlay({
<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 className="board-overlay-body">
<div className="board-list">
{sections.map((section) => (
<div key={section.label}>
<div className="board-lsec">{section.label}</div>
{section.items.map(row)}
</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>
))}
))}
{sections.length === 0 && (
<div className="board-rail-quiet">No active work</div>
)}
{finished.length > 0 && (
<>
<button
className="board-finished-toggle"
data-testid="overlay-finished-toggle"
onClick={() => setShowFinished((v) => !v)}
>
{showFinished
? "Hide finished"
: `${finished.length} finished · show`}
</button>
{showFinished && (
<div>
<div className="board-lsec">Finished</div>
{finished.map(row)}
</div>
)}
</>
)}
</div>
{detail && (
<ItemDetail
detail={detail}
onTransition={move}
loadAttachment={loadAttachment}
onOpenWorker={onOpenWorker}
/>
)}
</div>
</div>
</div>
);
}
function BoardCard({
item,
const STATE_LABEL: Record<string, string> = {
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<string | null>;
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 (
<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 className="board-detail" data-testid="board-detail">
<div className="board-detail-title">
<span className="board-detail-id">#{detail.id}</span> {detail.title}
</div>
{item.criteria && (
<div className="board-card-criteria">
<span className="board-card-label">Done when:</span> {item.criteria}
<div className="board-detail-meta">
<span className={"board-detail-st st-" + detail.state}>
{STATE_LABEL[detail.state] || detail.state}
</span>
{detail.assignee && (
<>
{" · "}
{onOpenWorker ? (
<button
className="board-detail-worker"
data-testid="board-open-worker"
onClick={() => onOpenWorker(detail.assignee)}
title="Open this coworker's session"
>
{detail.assignee}
</button>
) : (
detail.assignee
)}
</>
)}
{" · filed by "}
{detail.creator}
</div>
{detail.description && (
<div className="board-detail-desc">{detail.description}</div>
)}
{detail.criteria && (
<div className="board-detail-crit">
<span className="board-detail-label">Done when</span> {detail.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 className="board-tl">
{(detail.timeline || []).map((event) => (
<TimelineRow key={event.seq} event={event} loadAttachment={loadAttachment} />
))}
</div>
{onTransition && (
<DetailActions
detail={detail}
onTransition={onTransition}
changesOpen={changesOpen}
setChangesOpen={setChangesOpen}
changesText={changesText}
setChangesText={setChangesText}
/>
)}
</div>
);
}
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 (
<div className="board-detail-actions">
{changesOpen ? (
<div className="board-changes" data-testid="board-changes">
<textarea
autoFocus
placeholder="What needs to change?"
value={changesText}
onChange={(e) => setChangesText(e.target.value)}
/>
<div className="board-changes-row">
<button
className="board-btn primary"
disabled={!changesText.trim()}
onClick={() =>
onTransition(detail.id, "in_progress", changesText.trim())
}
>
Send to {detail.assignee || "worker"}
</button>
<button className="board-btn ghost" onClick={() => setChangesOpen(false)}>
Cancel
</button>
</div>
</div>
) : (
<>
<button
className="board-btn primary"
onClick={() => onTransition(detail.id, "done")}
>
Mark done
</button>
<button className="board-btn ghost" onClick={() => setChangesOpen(true)}>
Request changes
</button>
</>
)}
</div>
);
}
if (detail.state === "canceled") {
return (
<div className="board-detail-actions">
<button className="board-btn ghost" onClick={() => onTransition(detail.id, "open")}>
Reopen
</button>
</div>
);
}
if (detail.state === "done") return null;
return (
<div className="board-detail-actions">
<button className="board-btn ghost" onClick={() => onTransition(detail.id, "canceled")}>
Remove
</button>
</div>
);
}
function timelineLine(event: BoardTimelineEvent): string {
switch (event.kind) {
case "created":
return "filed this";
case "assigned":
return `assigned to ${event.assignee}`;
case "claimed":
return "claimed this";
case "moved":
return event.to === "in_progress" ? "started" : `moved to ${(STATE_LABEL[event.to || ""] || event.to || "").toLowerCase()}`;
case "comment":
return "commented";
default:
return event.kind;
}
}
function TimelineRow({
event,
loadAttachment,
}: {
event: BoardTimelineEvent;
loadAttachment?: (stored: string) => Promise<string | null>;
}) {
const shots = (event.refs || []).filter((r) => r.startsWith("attachment://"));
const when = new Date(event.ts).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
const tone =
event.kind === "moved" && event.to === "review"
? " review"
: event.kind === "moved" && event.to === "blocked"
? " blocked"
: event.kind === "moved" && event.to === "in_progress"
? " work"
: "";
return (
<div className={"board-tl-ev" + tone}>
<div className="board-tl-line">
<b>{event.actor}</b> {timelineLine(event)} · {when}
</div>
{event.body && <p className="board-tl-body">{event.body}</p>}
{loadAttachment &&
shots.map((ref) => (
<AttachmentThumb key={ref} refString={ref} loadAttachment={loadAttachment} />
))}
</div>
);
}
function AttachmentThumb({
refString,
loadAttachment,
}: {
refString: string;
loadAttachment: (stored: string) => Promise<string | null>;
}) {
const [url, setUrl] = useState<string | null>(null);
const stored = refString.slice("attachment://".length).split("#")[0];
const name = refString.includes("#") ? refString.split("#").pop()! : stored;
useEffect(() => {
let created: string | null = null;
void loadAttachment(stored).then((u) => {
created = u;
setUrl(u);
});
return () => {
if (created) URL.revokeObjectURL(created);
};
}, [stored, loadAttachment]);
if (!url) return null;
return (
<a className="board-shot" href={url} target="_blank" rel="noreferrer" title={name}>
<img src={url} alt={name} data-testid="board-attachment" />
<span className="board-shot-cap">{name}</span>
</a>
);
}
+7 -1
View File
@@ -65,6 +65,7 @@ interface Props {
// the rail renders the summary section and the expand affordance.
board?: Board | null;
onExpandBoard?: () => void;
onOpenBoardItem?: (id: number) => void;
}
export function RightRail({
@@ -85,6 +86,7 @@ export function RightRail({
onOpenIntegrations,
board,
onExpandBoard,
onOpenBoardItem,
}: Props) {
// Progress starts collapsed (owner call 2026-08-16 — rail space goes to the
// board/artifacts); it still auto-opens the first time a live turn has todos.
@@ -231,7 +233,11 @@ export function RightRail({
</button>
}
>
<BoardSection board={board} onExpand={() => onExpandBoard?.()} />
<BoardSection
board={board}
onExpand={() => onExpandBoard?.()}
onOpenItem={onOpenBoardItem}
/>
</RailSection>
)}
+79 -23
View File
@@ -1717,33 +1717,89 @@ html[data-platform="linux"] ::-webkit-scrollbar-thumb:hover { background-color:
}
.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-overlay-body { flex: 1; display: flex; min-height: 0; }
/* -- Overlay list (quiet, Apple-leaning: raw-state sections, no row buttons) -- */
.board-list { flex: 1; min-width: 0; overflow-y: auto; padding: 2px 10px 16px; }
.board-lsec {
padding: 16px 14px 5px; font-size: 11px; font-weight: 600; letter-spacing: 0.05em;
text-transform: uppercase; color: var(--faint);
}
.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-lrow {
display: flex; align-items: center; gap: 11px; width: 100%; text-align: left;
border: none; background: none; cursor: pointer; padding: 8px 14px;
border-radius: 10px; font: inherit; color: var(--ink);
}
.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-lrow:hover { background: var(--paper); }
.board-lrow.sel { background: color-mix(in srgb, var(--accent) 8%, transparent); }
.board-lrow .board-dot { margin-top: 0; flex: none; }
.board-lrow-id { color: var(--faint); font-size: 11.5px; width: 26px; flex: none; }
.board-lrow-title {
flex: 1; min-width: 0; font-size: 13px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.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-lrow-end {
flex: none; max-width: 320px; font-size: 12px; color: var(--faint);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.board-card-btn:hover { color: var(--ink); border-color: var(--muted); }
/* -- Detail pane: airy, de-boxed; the item's timeline is the centerpiece ------ */
.board-detail {
width: 400px; flex: none; border-left: 1px solid var(--line);
padding: 20px 24px; overflow-y: auto; min-height: 0;
}
.board-detail-title { font-size: 15.5px; font-weight: 600; letter-spacing: -0.01em; color: var(--ink); }
.board-detail-id { color: var(--faint); font-weight: 400; font-size: 13px; margin-right: 2px; }
.board-detail-meta { font-size: 12.5px; color: var(--muted); margin-top: 5px; }
.board-detail-st { font-weight: 500; }
.st-review { color: var(--warn, #b45309); }
.st-blocked { color: var(--danger, #dc2626); }
.st-in_progress { color: var(--accent); }
.board-detail-worker {
border: none; background: none; padding: 0; font: inherit; font-size: 12.5px;
color: var(--accent); cursor: pointer;
}
.board-detail-worker:hover { text-decoration: underline; }
.board-detail-desc { margin-top: 12px; font-size: 12.5px; color: var(--muted); white-space: pre-wrap; }
.board-detail-crit {
margin-top: 12px; font-size: 12.5px; color: var(--ink);
max-height: 84px; overflow-y: auto; padding-right: 8px;
}
.board-detail-label { color: var(--faint); font-weight: 600; }
.board-tl { margin-top: 18px; position: relative; padding-left: 18px; display: flex; flex-direction: column; gap: 14px; }
.board-tl::before { content: ""; position: absolute; left: 3px; top: 6px; bottom: 6px; width: 1px; background: var(--line); }
.board-tl-ev { position: relative; }
.board-tl-ev::before {
content: ""; position: absolute; left: -18px; top: 6px; width: 7px; height: 7px;
border-radius: 50%; background: var(--line-strong);
}
.board-tl-ev.review::before { background: var(--warn, #b45309); }
.board-tl-ev.blocked::before { background: var(--danger, #dc2626); }
.board-tl-ev.work::before { background: var(--accent); }
.board-tl-line { font-size: 12px; color: var(--faint); }
.board-tl-line b { color: var(--ink); font-weight: 600; font-size: 12.5px; }
.board-tl-body { font-size: 12.5px; color: var(--muted); margin-top: 3px; white-space: pre-wrap; overflow-wrap: anywhere; }
.board-shot { display: block; width: 170px; margin-top: 8px; border-radius: 10px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,0.14); text-decoration: none; }
.board-shot img { display: block; width: 100%; max-height: 120px; object-fit: cover; background: var(--paper); }
.board-shot-cap { display: block; font-size: 10.5px; color: var(--faint); padding: 4px 8px; background: var(--panel); }
.board-detail-actions { display: flex; gap: 14px; align-items: center; margin-top: 20px; }
.board-btn { font-size: 12.5px; cursor: pointer; font-family: inherit; }
.board-btn.primary {
color: #fff; background: var(--accent); border: none; border-radius: 999px; padding: 6px 16px;
}
.board-btn.primary:disabled { opacity: 0.5; cursor: default; }
.board-btn.ghost { color: var(--muted); background: none; border: none; padding: 6px 0; }
.board-btn.ghost:hover { color: var(--ink); }
.board-changes { display: flex; flex-direction: column; gap: 8px; width: 100%; }
.board-changes textarea {
width: 100%; min-height: 64px; resize: vertical; font: inherit; font-size: 12.5px;
color: var(--ink); background: var(--paper); border: 1px solid var(--line);
border-radius: 10px; padding: 8px 10px; outline: none;
}
.board-changes textarea:focus { border-color: var(--accent); }
.board-changes-row { display: flex; gap: 12px; align-items: center; }
/* Journal rail section */
.journal-list { display: flex; flex-direction: column; gap: 3px; }
+37
View File
@@ -371,6 +371,43 @@ def test_digest_clamps_long_comments_and_carries_structured_rows(manager):
assert moved[0]["note"].endswith("") and len(moved[0]["note"]) < 700
def test_item_detail_timeline_and_blocker_fact(manager):
"""The detail pane renders the item's merged event story; blocked rows carry
the latest blocker comment as a plain fact (owner-approved mock 2026-08-17)."""
space = str(manager.default_workspace)
lead = Actor(id="lead-1", role=Role.LEAD)
worker = Actor(id="nia", role=Role.WORKER)
item = manager.team_store.create_item(space, lead, title="Story", criteria="c")
manager.team_store.assign(space, lead, item["id"], "nia")
manager.team_store.transition(space, worker, item["id"], "in_progress")
manager.team_store.comment(space, worker, item["id"], "halfway there")
manager.team_store.transition(
space, worker, item["id"], "blocked", comment="need the staging tfvars"
)
# session with this workspace → the board space resolves
from coworker.sessions import SessionRecord
manager.session_store.save(
SessionRecord(
session_id="sid", workspace=space, model="m",
mode="interactive", messages=[], agent="cowork",
)
)
detail = manager.board_item_detail("sid", item["id"])
kinds = [(e["kind"], e.get("to")) for e in detail["timeline"]]
assert kinds == [
("created", None),
("assigned", None),
("moved", "in_progress"),
("comment", None),
("moved", "blocked"),
]
assert detail["timeline"][3]["body"] == "halfway there"
board = manager.session_board("sid")
blocked = next(i for i in board["items"] if i["id"] == item["id"])
assert blocked["blocker"] == "need the staging tfvars"
def test_cancel_notice_is_addressed_to_the_assignee(store):
item_id = assigned(store)
store.consume("swe-worker", store.pending_for("swe-worker")[-1]["seq"])