mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-11 14:50:14 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user