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