-
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 ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+ );
+ }
+ if (detail.state === "canceled") {
+ return (
+
+
+
+ );
+ }
+ if (detail.state === "done") return null;
+ return (
+
+
+
+ );
+}
+
+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
;
+}) {
+ 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 (
+
+
+ {event.actor} {timelineLine(event)} · {when}
+
+ {event.body &&
{event.body}
}
+ {loadAttachment &&
+ shots.map((ref) => (
+
+ ))}
+
+ );
+}
+
+function AttachmentThumb({
+ refString,
+ loadAttachment,
+}: {
+ refString: string;
+ loadAttachment: (stored: string) => Promise;
+}) {
+ const [url, setUrl] = useState(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 (
+
+
+ {name}
+
+ );
+}
diff --git a/surfaces/gui/src/components/RightRail.tsx b/surfaces/gui/src/components/RightRail.tsx
index a0e92ce6..f22759b7 100644
--- a/surfaces/gui/src/components/RightRail.tsx
+++ b/surfaces/gui/src/components/RightRail.tsx
@@ -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({
}
>
- onExpandBoard?.()} />
+ onExpandBoard?.()}
+ onOpenItem={onOpenBoardItem}
+ />
)}
diff --git a/surfaces/gui/src/styles.css b/surfaces/gui/src/styles.css
index f5bc8290..4da6b81c 100644
--- a/surfaces/gui/src/styles.css
+++ b/surfaces/gui/src/styles.css
@@ -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; }
diff --git a/tests/test_team_wake.py b/tests/test_team_wake.py
index 72d2bb1b..6a16c9b7 100644
--- a/tests/test_team_wake.py
+++ b/tests/test_team_wake.py
@@ -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"])