Feed model: interest follows the assignment relation; notes are pure appends

Worker delivery is now a read-time feed over its slice (assigned ∪ filed) — send-backs, comment answers, reassignments, cancels, and acceptance all arrive through one relation; per-event recipient addressing retired.
Reassignment delivers before interest ends; a new assignee replays the item's story. Detail pane gains Add a note (never changes state); external pending/consume become space-scoped feed calls.
This commit is contained in:
Rohit C Prasad
2026-08-17 16:05:16 -07:00
committed by Rohit P
parent f26e01c1b6
commit aafb4f4291
15 changed files with 305 additions and 98 deletions
+17
View File
@@ -118,6 +118,23 @@ test("item detail: timeline with attachment, worker link, request changes", asyn
await expect(detail).toContainText("Dependency audit — lockfiles");
});
test("Add a note is a pure append — it lands in the timeline, state untouched", async ({
page,
}) => {
await planTheWork(page);
await page.getByTestId("board-rail").getByText("Report rollup").click();
const detail = page.getByTestId("board-detail");
await expect(detail).toContainText("In review");
await detail.getByTestId("board-note-input").fill("prefer the v2 endpoint for totals");
await detail.getByTestId("board-note-input").press("Enter");
// the note appears as a timeline event…
await expect(detail).toContainText("user commented");
await expect(detail).toContainText("prefer the v2 endpoint for totals");
// …and the state did NOT change (notes never transition)
await expect(detail).toContainText("In review");
await expect(detail.getByRole("button", { name: "Mark done" })).toBeVisible();
});
test("journal section lists cases once a board exists", async ({ page }) => {
await planTheWork(page);
await page.getByRole("button", { name: /Journal/ }).click();
+16 -1
View File
@@ -576,6 +576,9 @@ export async function mockApi(page: import("@playwright/test").Page) {
mentions: ["nia"],
},
];
// Pure notes added from the detail pane (never change state) — appended to
// the item's timeline so the pane reflects them after reload.
const itemNotes: Record<number, any[]> = {};
const seedBoard = () => {
if (boardItems.length) return;
boardItems.push(
@@ -1099,7 +1102,19 @@ export async function mockApi(page: import("@playwright/test").Page) {
},
]
: [{ seq: 30, ts: at, actor: "lead", kind: "created" }];
return json({ ...item, timeline });
return json({ ...item, timeline: timeline.concat(itemNotes[id] || []) });
}
if (/\/v1\/sessions\/[^/]+\/board\/comment$/.test(p) && m === "POST") {
const b = req.postDataJSON() || {};
const id = Number(b.item);
(itemNotes[id] = itemNotes[id] || []).push({
seq: 90 + (itemNotes[id]?.length || 0),
ts: new Date().toISOString(),
actor: "user",
kind: "comment",
body: String(b.body || ""),
});
return json({ ok: true });
}
if (/\/v1\/sessions\/[^/]+\/board\/attachment$/.test(p)) {
// A real 1x1 PNG so the <img> actually loads (the spec asserts it renders).
+2
View File
@@ -3,6 +3,7 @@ import {
announceInboxUnlock,
createTempWorkspace,
finalizeAutomationRun,
boardComment,
boardTransition,
fetchBoardAttachment,
getBoardItem,
@@ -2075,6 +2076,7 @@ export function App() {
setBoardDetailId(null);
}}
onTransition={moveBoardItem}
onComment={(item, body) => boardComment(sessionId, item, body)}
loadItem={(id) => getBoardItem(sessionId, id)}
loadAttachment={(stored) => fetchBoardAttachment(sessionId, stored)}
onOpenWorker={(actor) => {
+17
View File
@@ -293,6 +293,23 @@ export async function fetchBoardAttachment(
return URL.createObjectURL(await res.blob());
}
// A pure note on an item — never changes state; the assignee hears it via its feed.
export async function boardComment(
sessionId: string,
item: number,
body: string,
): Promise<{ ok?: boolean; error?: string }> {
const res = await fetch(
`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/board/comment`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ item, body }),
},
);
return res.json();
}
export async function boardTransition(
sessionId: string,
item: number,
@@ -122,6 +122,7 @@ export function BoardOverlay({
board,
onClose,
onTransition,
onComment,
loadItem,
loadAttachment,
onOpenWorker,
@@ -131,6 +132,8 @@ export function BoardOverlay({
onClose: () => void;
// (item, to, comment?) → performed as the user; App refetches on completion.
onTransition?: (item: number, to: string, comment?: string) => void;
// A pure note — never changes state; the assignee hears it through its feed.
onComment?: (item: number, body: string) => Promise<unknown> | 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).
@@ -162,6 +165,10 @@ export function BoardOverlay({
// 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 addNote = async (item: number, body: string) => {
await onComment?.(item, body);
await openItem(item);
};
const finished = board.items.filter(
(i) => i.state === "done" || i.state === "canceled"
@@ -238,6 +245,7 @@ export function BoardOverlay({
<ItemDetail
detail={detail}
onTransition={move}
onAddNote={onComment ? addNote : undefined}
loadAttachment={loadAttachment}
onOpenWorker={onOpenWorker}
/>
@@ -260,11 +268,13 @@ const STATE_LABEL: Record<string, string> = {
function ItemDetail({
detail,
onTransition,
onAddNote,
loadAttachment,
onOpenWorker,
}: {
detail: BoardItemDetail;
onTransition?: (item: number, to: string, comment?: string) => void;
onAddNote?: (item: number, body: string) => Promise<void>;
loadAttachment?: (stored: string) => Promise<string | null>;
onOpenWorker?: (actor: string) => void;
}) {
@@ -317,6 +327,7 @@ function ItemDetail({
<TimelineRow key={event.seq} event={event} loadAttachment={loadAttachment} />
))}
</div>
{onAddNote && <NoteComposer detail={detail} onAddNote={onAddNote} />}
{onTransition && (
<DetailActions
detail={detail}
@@ -331,6 +342,39 @@ function ItemDetail({
);
}
// A pure note — an append to the item's story that NEVER changes state (owner
// doctrine 2026-08-17). The assignee hears it through its feed, so this is the
// lightweight way to talk to a worker through the board.
function NoteComposer({
detail,
onAddNote,
}: {
detail: BoardItemDetail;
onAddNote: (item: number, body: string) => Promise<void>;
}) {
const [text, setText] = useState("");
useEffect(() => setText(""), [detail.id]);
const submit = async () => {
const body = text.trim();
if (!body) return;
setText("");
await onAddNote(detail.id, body);
};
return (
<input
className="board-note-input"
data-testid="board-note-input"
placeholder="Add a note…"
title="Leaves a note on the item — never changes its state"
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void submit();
}}
/>
);
}
function DetailActions({
detail,
onTransition,
+7
View File
@@ -1784,6 +1784,13 @@ html[data-platform="linux"] ::-webkit-scrollbar-thumb:hover { background-color:
.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-note-input {
width: 100%; margin-top: 14px; font: inherit; font-size: 12.5px; color: var(--ink);
background: var(--paper); border: 1px solid var(--line); border-radius: 999px;
padding: 7px 14px; outline: none;
}
.board-note-input::placeholder { color: var(--faint); }
.board-note-input:focus { border-color: var(--accent); 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 {