Drill-round polish: calm rail, digest diet, gate replies, criteria clamp, composer fix

Rail shows active work only (finished behind a count); wake digests clamp hand-offs and ride a collapsed BoardWakeCard.
Typing while a proposal gate is pending resolves it as decline-with-feedback; essay criteria clamp in the gate card.
Composer autogrow now counts padding in its cap (first line no longer clips); lead/worker prompts push tight criteria and hand-offs.
This commit is contained in:
Rohit C Prasad
2026-08-17 08:54:53 -07:00
committed by Rohit P
parent 880c7859bd
commit b2418d5a30
15 changed files with 529 additions and 54 deletions
@@ -17,10 +17,16 @@ NOT implement — you carry no shell or git on purpose. The board is the shared
truth; your context window is disposable, the board is not. truth; your context window is disposable, the board is not.
How you run a piece of work: How you run a piece of work:
1. UNDERSTAND: read enough of the repo (files, search) to decompose honestly. 1. UNDERSTAND: read enough of the repo (files, search) to decompose honestly. The
board is per-PROJECT and outlives sessions — before proposing anything, read it
(list_items) and triage leftovers from earlier efforts: reassign or cancel stale
in-progress items, never stack duplicates of existing open ones.
2. PLAN: split the work into items with crisp acceptance criteria — "Done when:" that a 2. PLAN: split the work into items with crisp acceptance criteria — "Done when:" that a
verifier can actually check. Acceptance criteria are the single biggest quality lever verifier can actually check. Acceptance criteria are the single biggest quality lever
you own; vague criteria produce vague work. Present the decomposition with you own; vague criteria produce vague work. Criteria are 13 SHORT, independently
checkable statements — mechanics (setup commands, file paths, how-to) belong in the
item's description, never in the criteria; a verifier can pass/fail three checks,
it cannot pass/fail an essay. Present the decomposition with
propose_work_items (works in any mode; approval creates the items on the board and propose_work_items (works in any mode; approval creates the items on the board and
returns their ids) and revise until the user approves. Use create_item only for returns their ids) and revise until the user approves. Use create_item only for
one-off additions after the plan is approved. one-off additions after the plan is approved.
@@ -31,8 +31,10 @@ The team contract (this is how you work):
- Discover a bug or follow-up outside your item's scope? File it (create_item) with - Discover a bug or follow-up outside your item's scope? File it (create_item) with
real acceptance criteria and keep moving. The lead triages it. real acceptance criteria and keep moving. The lead triages it.
- Finish = transition to review with a hand-off comment: what you did, how you - Finish = transition to review with a hand-off comment: what you did, how you
verified it, refs (branch, files). You NEVER mark your own work done — done is the verified it, refs (branch, files). Keep the hand-off TIGHT — a short paragraph
verdict after verification. plus refs; full evidence and long output belong in the journal, not the comment
(long comments get clamped in wake digests anyway). You NEVER mark your own work
done — done is the verdict after verification.
- Steering arrives attributed [Lead] or [User]; [User] outranks [Lead]. - Steering arrives attributed [Lead] or [User]; [User] outranks [Lead].
- House rules hold: no silent skips — if you couldn't do part of the work, the - House rules hold: no silent skips — if you couldn't do part of the work, the
hand-off comment says which part and why. hand-off comment says which part and why.
+84 -19
View File
@@ -1955,9 +1955,9 @@ class SessionManager:
if not self.teams.count_wake(team.team_id, cap=self.TEAM_WAKE_CAP_PER_HOUR): if not self.teams.count_wake(team.team_id, cap=self.TEAM_WAKE_CAP_PER_HOUR):
logger.warning("team %s paused for budget this hour", team.team_id) logger.warning("team %s paused for budget this hour", team.team_id)
return 0 return 0
message = self._team_digest(team, directs, subs, chats, is_lead=is_lead) message, rows = self._team_digest(team, directs, subs, chats, is_lead=is_lead)
self._team_inflight.add(session_id) self._team_inflight.add(session_id)
source = self._board_source(team, message) source = self._board_source(team, message, rows=rows)
async def _deliver() -> None: async def _deliver() -> None:
try: try:
@@ -1980,6 +1980,21 @@ class SessionManager:
asyncio.create_task(_deliver()) asyncio.create_task(_deliver())
return 1 return 1
# Long comment/hand-off bodies are already durable on the board — the wake
# message's job is to say what needs DECISIONS, not to re-carry the evidence
# into the recipient's context on every wake (owner ruling 2026-08-16). The
# model text clamps hard; the UI sidecar rows clamp softer (the human gets a
# bigger excerpt on click without re-inflating the lead's prompt).
DIGEST_CLAMP_MODEL = 300
DIGEST_CLAMP_UI = 600
@staticmethod
def _clamp(text: str, limit: int, *, suffix: str = "") -> str:
text = (text or "").strip()
if len(text) <= limit:
return text
return text[:limit].rstrip() + suffix
def _team_digest( def _team_digest(
self, self,
team, team,
@@ -1988,10 +2003,16 @@ class SessionManager:
chats: Optional[list[dict]] = None, chats: Optional[list[dict]] = None,
*, *,
is_lead: bool, is_lead: bool,
) -> str: ) -> tuple[str, list[dict]]:
"""Coalesce one queue batch into one wake message. Deterministic, computed """Coalesce one queue batch into one wake message. Deterministic, computed
by code — the model does judgment, not arithmetic.""" by code — the model does judgment, not arithmetic. Returns (model text,
structured rows) — the rows ride the display sidecar so the GUI renders a
collapsed BoardWakeCard instead of re-parsing prose."""
clamp = lambda text: self._clamp( # noqa: E731 — two-site local shorthand
text, self.DIGEST_CLAMP_MODEL, suffix=" … (full text on the board)"
)
lines: list[str] = [] lines: list[str] = []
rows: list[dict] = []
for event in directs + subs: for event in directs + subs:
item_id = event.get("item_id") item_id = event.get("item_id")
payload = event.get("payload") or {} payload = event.get("payload") or {}
@@ -2002,6 +2023,11 @@ class SessionManager:
except Exception: except Exception:
item = None item = None
title = f"#{item_id} {item['title']}" if item else f"#{item_id}" title = f"#{item_id} {item['title']}" if item else f"#{item_id}"
row = {
"item": item_id,
"title": item["title"] if item else "",
"actor": event.get("actor", ""),
}
if event["kind"] == "item_assigned": if event["kind"] == "item_assigned":
if item is None: if item is None:
continue continue
@@ -2012,15 +2038,18 @@ class SessionManager:
f"{event['actor']} claimed {title} — it's theirs now;" f"{event['actor']} claimed {title} — it's theirs now;"
" reassign or cancel if that's wrong." " reassign or cancel if that's wrong."
) )
rows.append({**row, "kind": "claimed"})
continue continue
lines.append( lines.append(
f"You've been assigned work item {title}.\n" f"You've been assigned work item {title}.\n"
f" Done when: {item['criteria']}" f" Done when: {item['criteria']}"
+ (f"\n Details: {item['description']}" if item["description"] else "") + (f"\n Details: {item['description']}" if item["description"] else "")
) )
rows.append({**row, "kind": "assigned"})
elif event["kind"] == "item_transitioned": elif event["kind"] == "item_transitioned":
to = payload.get("to", "?") to = payload.get("to", "?")
note = f" — “{payload.get('comment')}" if payload.get("comment") else "" comment = clamp(payload.get("comment") or "")
note = f" — “{comment}" if comment else ""
if to == "canceled" and not is_lead: if to == "canceled" and not is_lead:
lines.append( lines.append(
f"{title} was CANCELED by {event['actor']}{note} — stop any" f"{title} was CANCELED by {event['actor']}{note} — stop any"
@@ -2028,32 +2057,63 @@ class SessionManager:
) )
else: else:
lines.append(f"{title} moved to {to} by {event['actor']}{note}") lines.append(f"{title} moved to {to} by {event['actor']}{note}")
rows.append(
{
**row,
"kind": "moved",
"to": to,
"note": self._clamp(
payload.get("comment") or "", self.DIGEST_CLAMP_UI
),
}
)
elif event["kind"] == "item_created": elif event["kind"] == "item_created":
lines.append(f"New item filed by {event['actor']}: {title}") lines.append(f"New item filed by {event['actor']}: {title}")
rows.append({**row, "kind": "filed"})
elif event["kind"] == "item_commented": elif event["kind"] == "item_commented":
lines.append( lines.append(
f"Comment on {title} by {event['actor']}: {payload.get('body', '')}" f"Comment on {title} by {event['actor']}:"
f" {clamp(payload.get('body', ''))}"
)
rows.append(
{
**row,
"kind": "comment",
"note": self._clamp(
payload.get("body") or "", self.DIGEST_CLAMP_UI
),
}
) )
for chat in chats or []: for chat in chats or []:
who = chat["author"] if chat["author_role"] != "user" else "[User]" who = chat["author"] if chat["author_role"] != "user" else "[User]"
lines.append(f"# team chat — {who}: {chat['text']}") lines.append(f"# team chat — {who}: {clamp(chat['text'])}")
rows.append(
{
"kind": "chat",
"actor": who,
"note": self._clamp(chat["text"], self.DIGEST_CLAMP_UI),
}
)
body = "\n".join(f"- {line}" for line in lines) or "- (no detail)" body = "\n".join(f"- {line}" for line in lines) or "- (no detail)"
if is_lead: if is_lead:
return ( message = (
"⏰ Board wake — your team needs decisions:\n" "⏰ Board wake — your team needs decisions:\n"
+ body + body
+ "\n\nVerify review items against their acceptance criteria (then" + "\n\nFull hand-off comments live on the board (get_item)."
" Verify review items against their acceptance criteria (then"
" done, or send back with a comment), unblock or reassign blocked" " done, or send back with a comment), unblock or reassign blocked"
" items, and triage new filings. Steer only where needed." " items, and triage new filings. Steer only where needed."
) )
return ( else:
"[Lead] Board update:\n" message = (
+ body "[Lead] Board update:\n"
+ self._roster_note(team) + body
+ "\n\nMove your item to in_progress when you start; blocked (with a" + self._roster_note(team)
" comment) if stuck; review with a hand-off comment when finished." + "\n\nMove your item to in_progress when you start; blocked (with a"
" Journal evidence as you go." " comment) if stuck; review with a hand-off comment when finished."
) " Journal evidence as you go."
)
return message, rows
@staticmethod @staticmethod
def _roster_note(team) -> str: def _roster_note(team) -> str:
@@ -2074,11 +2134,15 @@ class SessionManager:
return f"\n\nYour team: {mates}; lead (coordinator).{reach}" return f"\n\nYour team: {mates}; lead (coordinator).{reach}"
@staticmethod @staticmethod
def _board_source(team, message: str) -> dict[str, Any]: def _board_source(
team, message: str, *, rows: Optional[list[dict]] = None
) -> dict[str, Any]:
"""Display-only MessageSource sidecar for board deliveries — the same """Display-only MessageSource sidecar for board deliveries — the same
mechanism connector messages use, so the GUI renders a structured card mechanism connector messages use, so the GUI renders a structured card
instead of a fake user bubble (owner ask 2026-08-16). The framed message instead of a fake user bubble (owner ask 2026-08-16). The framed message
stays the model-facing text; this only shapes presentation.""" stays the model-facing text; this only shapes presentation. `rows` are
the digest's structured events — the BoardWakeCard renders those
(collapsed to one line by default) instead of re-parsing the prose."""
return { return {
"connector": "board", "connector": "board",
"kind": "channel", "kind": "channel",
@@ -2088,6 +2152,7 @@ class SessionManager:
"sender_name": "Board", "sender_name": "Board",
"ts": time.time(), "ts": time.time(),
"text": message, "text": message,
"board": {"rows": rows or []},
} }
def team_staleness_digest(self, session_id: str) -> str: def team_staleness_digest(self, session_id: str) -> str:
+20
View File
@@ -61,6 +61,26 @@ test("expand opens the overlay board; the user verifies review items and removes
await expect(page.getByTestId("board-overlay")).toHaveCount(0); await expect(page.getByTestId("board-overlay")).toHaveCount(0);
}); });
test("finished items leave the rail; a quiet toggle reveals them", async ({ page }) => {
await planTheWork(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.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);
const toggle = page.getByTestId("board-finished-toggle");
await expect(toggle).toHaveText("1 finished · show");
await toggle.click();
await expect(rail.getByText("Report rollup")).toBeVisible();
await toggle.click();
await expect(rail.getByText("Report rollup")).toHaveCount(0);
});
test("journal section lists cases once a board exists", async ({ page }) => { test("journal section lists cases once a board exists", async ({ page }) => {
await planTheWork(page); await planTheWork(page);
await page.getByRole("button", { name: /Journal/ }).click(); await page.getByRole("button", { name: /Journal/ }).click();
+33 -1
View File
@@ -642,10 +642,42 @@ export async function mockApi(page: import("@playwright/test").Page) {
} }
// Agent teams: the decomposition gate — the lead proposes work items and // Agent teams: the decomposition gate — the lead proposes work items and
// SUSPENDS until the items_response verdict arrives (approval creates them). // SUSPENDS until the items_response verdict arrives (approval creates them).
// A board wake arriving on this session: the digest rides `source` with
// structured rows — the BoardWakeCard renders collapsed by default.
if (/board wake/i.test(msg.text)) {
send("turn_start", {
source: {
connector: "board",
kind: "channel",
channel_id: "/Users/test/OpenWorker/launch-note",
channel_name: "Team board",
sender_id: "board",
sender_name: "Board",
ts: Date.now() / 1000,
text: "⏰ Board wake — your team needs decisions:\n- #2 moved to review by webb",
board: {
rows: [
{
kind: "moved",
item: 2,
title: "Statements page",
actor: "webb",
to: "review",
note: "Ready for review on feat/customer-statements, commit 029f9f7. Build verified; final verdict stays with the tester.",
},
{ kind: "filed", item: 5, title: "Follow-up: rate limit", actor: "nia" },
],
},
},
});
send("assistant_message", { text: "Reviewing the hand-off now." });
send("turn_done");
return;
}
if (/propose the split/i.test(msg.text)) { if (/propose the split/i.test(msg.text)) {
send("items_proposed", { send("items_proposed", {
items: [ items: [
{ title: "Statement API endpoint", criteria: "returns opening/closing balances; 8 endpoint tests green" }, { title: "Statement API endpoint", criteria: "returns opening/closing balances over the chosen range; 8 endpoint tests green; malformed, missing, and reversed date ranges return 400; draft invoices are excluded from issued totals; inclusive boundaries verified end to end" },
{ title: "Statements dashboard page", criteria: "renders seeded data for Ada / Northgate; empty + error states covered" }, { title: "Statements dashboard page", criteria: "renders seeded data for Ada / Northgate; empty + error states covered" },
{ title: "Statement totals reconcile", criteria: "running balance matches invoices minus payments for the range" }, { title: "Statement totals reconcile", criteria: "running balance matches invoices minus payments for the range" },
{ title: "Verification pass", criteria: "tester confirms page renders with live API data" }, { title: "Verification pass", criteria: "tester confirms page renders with live API data" },
+48
View File
@@ -27,11 +27,59 @@ test("the decomposition gate shows items with criteria; approval lands them on t
await card.getByRole("button", { name: /1 more item/ }).click(); await card.getByRole("button", { name: /1 more item/ }).click();
await expect(card.getByText("Verification pass")).toBeVisible(); await expect(card.getByText("Verification pass")).toBeVisible();
// essay-length criteria clamp behind a per-item expander (owner-hit 2026-08-16)
const acToggle = page.getByTestId("itemsreq-ac-toggle-0");
await expect(acToggle).toHaveText("Show full criteria");
await acToggle.click();
await expect(acToggle).toHaveText("Show less");
// the short-criteria items get no toggle
await expect(page.getByTestId("itemsreq-ac-toggle-1")).toHaveCount(0);
await page.getByTestId("itemsreq-approve").click(); await page.getByTestId("itemsreq-approve").click();
await expect(page.getByText(/Items created on the board/)).toBeVisible(); await expect(page.getByText(/Items created on the board/)).toBeVisible();
await expect(page.getByTestId("board-rail")).toBeVisible(); await expect(page.getByTestId("board-rail")).toBeVisible();
}); });
test("typing while a gate is pending sends the reply as feedback to the lead", async ({
page,
}) => {
await proposeTeam(page);
// the composer re-opens for a typed answer instead of hard-blocking on "running"
const box = page.getByPlaceholder(/Reply to adjust the proposal/);
await box.fill("use openai:gpt-5.6-sol for all the workers");
await page.getByRole("button", { name: "Send" }).click();
// the reply lands as a user message AND resolves the gate as decline-with-feedback
await expect(
page.getByText("use openai:gpt-5.6-sol for all the workers"),
).toBeVisible();
await expect(page.getByText(/tell me how to change the roster/)).toBeVisible();
await expect(page.getByTestId("teamreq-card")).toHaveCount(0);
});
test("a board wake renders collapsed; expanding reveals rows, hand-offs stay one more click away", async ({
page,
}) => {
await page.goto("/");
await page.getByPlaceholder(/Ask the coworker/).fill("board wake");
await page.getByRole("button", { name: "Send" }).click();
const card = page.getByTestId("boardwake-card");
await expect(card).toBeVisible();
await expect(card).toContainText("Board wake");
await expect(card).toContainText("1 review, 1 filing");
// collapsed by default: ambient awareness, not reading assignment
await expect(page.getByTestId("boardwake-body")).toHaveCount(0);
await expect(card).not.toContainText("029f9f7");
await page.getByTestId("boardwake-toggle").click();
const body = page.getByTestId("boardwake-body");
await expect(body).toBeVisible();
await expect(body).toContainText("#2 Statements page → review by webb");
await expect(body).toContainText("nia filed #5 Follow-up: rate limit");
// the hand-off comment sits behind its own per-row toggle
await expect(body).not.toContainText("029f9f7");
await body.getByRole("button", { name: "show hand-off" }).click();
await expect(body).toContainText("029f9f7");
});
test("declining the split returns feedback to the lead", async ({ page }) => { test("declining the split returns feedback to the lead", async ({ page }) => {
await page.goto("/"); await page.goto("/");
await page.getByPlaceholder(/Ask the coworker/).fill("propose the split"); await page.getByPlaceholder(/Ask the coworker/).fill("propose the split");
+19
View File
@@ -1020,6 +1020,24 @@ export function App() {
setSendGate({ text, attachments, skill }); setSendGate({ text, attachments, skill });
return; return;
} }
// A typed message while a proposal gate is pending IS the answer: it resolves
// the gate as decline-with-feedback, so "use gpt-5.6-sol for all workers"
// reaches the lead instead of bouncing off a blocked composer (owner-hit
// 2026-08-16). The card buttons stay the approve/plain-decline paths.
if (!unattended && pendingTeam?.kind === "teamreq" && !pendingTeam.resolved) {
setItems((p) => [...p, { kind: "user", text, ts: Date.now() / 1000 }]);
respondTeam(false, text);
return;
}
if (
!unattended &&
pendingItemsReq?.kind === "itemsreq" &&
!pendingItemsReq.resolved
) {
setItems((p) => [...p, { kind: "user", text, ts: Date.now() / 1000 }]);
respondItemsReq(false, text);
return;
}
// Force-run shows exactly what the user typed: "/name rest". Must match the server's // Force-run shows exactly what the user typed: "/name rest". Must match the server's
// `display` sidecar formula so the turn_start dedupe recognizes the local echo. // `display` sidecar formula so the turn_start dedupe recognizes the local echo.
const shown = skill ? `/${skill}${text ? ` ${text}` : ""}` : text; const shown = skill ? `/${skill}${text ? ` ${text}` : ""}` : text;
@@ -1952,6 +1970,7 @@ export function App() {
models={models} models={models}
modelLabels={modelLabels} modelLabels={modelLabels}
running={running} running={running}
gateOpen={!unattended && (!!pendingTeam || !!pendingItemsReq)}
connected={connected} connected={connected}
modelReady={modelReady} modelReady={modelReady}
onConnectModel={openModelSetup} onConnectModel={openModelSetup}
+14
View File
@@ -162,6 +162,20 @@ export interface MessageSource {
sender_name: string; // resolved; may equal the id sender_name: string; // resolved; may equal the id
ts: number; // epoch seconds ts: number; // epoch seconds
text: string; // the RAW message (what the card shows) text: string; // the RAW message (what the card shows)
// Board wakes only (connector === "board"): the digest as structured rows, so
// the BoardWakeCard renders collapsed summaries instead of re-parsing prose.
board?: { rows: BoardWakeRow[] };
}
// One digest event on a board wake. `note` is a UI-clamped excerpt of a hand-off
// comment (the full text lives on the board).
export interface BoardWakeRow {
kind: "assigned" | "claimed" | "moved" | "filed" | "comment" | "chat" | string;
item?: number | null;
title?: string;
actor?: string;
to?: string;
note?: string;
} }
// A transcript message from GET /v1/sessions/{id}/messages. Kept permissive (open shape) because // A transcript message from GET /v1/sessions/{id}/messages. Kept permissive (open shape) because
+32 -5
View File
@@ -5,7 +5,7 @@
// endpoints and act as the USER. There is NO proposed/draft state: a plan // 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 // proposal lives in the conversation (plan-approval flow); the board only ever
// contains accepted work, and work starts at ASSIGNMENT. // contains accepted work, and work starts at ASSIGNMENT.
import { useEffect } from "react"; import { useEffect, useState } from "react";
import type { Board, BoardItem } from "../api"; import type { Board, BoardItem } from "../api";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
@@ -39,12 +39,30 @@ export function boardSummary(board: Board): string {
} }
export function BoardSection({ board, onExpand }: { board: Board; onExpand: () => void }) { export function BoardSection({ board, onExpand }: { board: Board; onExpand: () => void }) {
const groups = GROUPS.map((g) => ({ // The rail shows ACTIVE work only (owner ruling 2026-08-16): a project board
...g, // outlives its sessions, so finished history from a past effort would greet
items: board.items.filter((i) => i.state === g.state), // every fresh session as a long stale list. Done/canceled sit behind a quiet
})).filter((g) => g.items.length > 0); // count; the expanded overlay keeps the full picture.
const [showFinished, setShowFinished] = useState(false);
const finished = board.items.filter(
(i) => i.state === "done" || i.state === "canceled"
).length;
const shown = showFinished
? GROUPS
: GROUPS.filter((g) => g.state !== "done" && g.state !== "canceled");
const groups = shown
.map((g) => ({
...g,
items: board.items.filter((i) => i.state === g.state),
}))
.filter((g) => g.items.length > 0);
return ( return (
<div className="board-rail" data-testid="board-rail"> <div className="board-rail" data-testid="board-rail">
{groups.length === 0 && (
<div className="board-rail-quiet" data-testid="board-rail-quiet">
No active work
</div>
)}
{groups.map((group) => ( {groups.map((group) => (
<div key={group.state}> <div key={group.state}>
<div className="board-group">{group.label}</div> <div className="board-group">{group.label}</div>
@@ -61,6 +79,15 @@ export function BoardSection({ board, onExpand }: { board: Board; onExpand: () =
))} ))}
</div> </div>
))} ))}
{finished > 0 && (
<button
className="board-finished-toggle"
data-testid="board-finished-toggle"
onClick={() => setShowFinished((v) => !v)}
>
{showFinished ? "Hide finished" : `${finished} finished · show`}
</button>
)}
</div> </div>
); );
} }
@@ -0,0 +1,116 @@
// BoardWakeCard — a board wake in the lead's transcript, collapsed to ONE line by
// default (owner ruling 2026-08-16): most of the time the user just wants the
// feel that something is happening. Click to expand into per-event rows; long
// hand-off comments hide behind a per-row "show hand-off". NOT the connector
// card: a connector message is a foreign message, a board wake is a report —
// different shape, different affordances (they only share the visual family).
import { useState } from "react";
import type { BoardWakeRow, MessageSource } from "../api";
import { Icon } from "./Icon";
function summarize(rows: BoardWakeRow[]): { text: string; attention: boolean } {
const counts: Record<string, number> = {};
const bump = (key: string) => (counts[key] = (counts[key] || 0) + 1);
for (const row of rows) {
if (row.kind === "moved" && row.to === "review") bump("review");
else if (row.kind === "moved" && row.to === "blocked") bump("blocked");
else if (row.kind === "moved" && row.to === "canceled") bump("canceled");
else if (row.kind === "moved") bump("move");
else if (row.kind === "filed") bump("filing");
else if (row.kind === "claimed") bump("claim");
else if (row.kind === "assigned") bump("assignment");
else if (row.kind === "comment") bump("comment");
else if (row.kind === "chat") bump("chat message");
}
const parts = Object.entries(counts).map(
([label, n]) => `${n} ${label}${n === 1 ? "" : "s"}`
);
// reviews/blocked demand a decision — those tint the collapsed line amber
const attention = (counts.review || 0) + (counts.blocked || 0) > 0;
return { text: parts.join(", ") || "update", attention };
}
function rowText(row: BoardWakeRow): string {
const item = row.item != null ? `#${row.item}` : "";
const title = row.title ? ` ${row.title}` : "";
switch (row.kind) {
case "moved":
return `${item}${title}${row.to} by ${row.actor}`;
case "filed":
return `${row.actor} filed ${item}${title}`;
case "claimed":
return `${row.actor} claimed ${item}${title}`;
case "assigned":
return `${item}${title} assigned to you`;
case "comment":
return `${row.actor} commented on ${item}${title}`;
case "chat":
return `# team chat — ${row.actor}`;
default:
return `${item}${title}`;
}
}
function stateDot(row: BoardWakeRow): string {
if (row.kind === "moved" && row.to === "review") return "board-dot review";
if (row.kind === "moved" && row.to === "blocked") return "board-dot blocked";
if (row.kind === "moved" && row.to === "done") return "board-dot done";
if (row.kind === "claimed" || row.kind === "assigned") return "board-dot work";
return "board-dot idle";
}
export function BoardWakeCard({ source }: { source: MessageSource }) {
const [open, setOpen] = useState(false);
const [openNotes, setOpenNotes] = useState<Record<number, boolean>>({});
const rows = source.board?.rows || [];
const { text, attention } = summarize(rows);
return (
<div
className={"boardwake" + (attention ? " attention" : "")}
data-testid="boardwake-card"
>
<button
className="boardwake-head"
data-testid="boardwake-toggle"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
>
<Icon name="table" size={14} />
<span className="boardwake-title">Board wake</span>
<span className="boardwake-summary">{text}</span>
<span className="spacer" />
<span className={"boardwake-chevron" + (open ? " open" : "")}>
<Icon name="chevronDown" size={13} />
</span>
</button>
{open && (
<div className="boardwake-body" data-testid="boardwake-body">
{rows.map((row, i) => (
<div className="boardwake-row" key={i}>
<span className={stateDot(row)} />
<span className="boardwake-row-main">
<span className="boardwake-row-text">{rowText(row)}</span>
{row.note &&
(openNotes[i] ? (
<span className="boardwake-note">{row.note}</span>
) : (
<button
className="boardwake-note-toggle"
onClick={() => setOpenNotes((s) => ({ ...s, [i]: true }))}
>
{row.kind === "chat" || row.kind === "comment"
? "show message"
: "show hand-off"}
</button>
))}
</span>
</div>
))}
{rows.length === 0 && (
<div className="boardwake-note">{source.text}</div>
)}
</div>
)}
</div>
);
}
+20 -5
View File
@@ -53,6 +53,10 @@ interface Props {
// session; after the first turn the fact lives in the topbar subtitle (§22) — no // session; after the first turn the fact lives in the topbar subtitle (§22) — no
// interactive-then-disabled control. // interactive-then-disabled control.
running: boolean; running: boolean;
// A proposal gate (team/items) is awaiting the user: the engine is suspended,
// so `running` is true — but typing must stay possible, because a typed reply
// IS an answer (decline-with-feedback). Unblocks Send while the gate is up.
gateOpen?: boolean;
connected: boolean; connected: boolean;
// False when the default model's provider has no key — the composer shows a "connect a model" // False when the default model's provider has no key — the composer shows a "connect a model"
// banner and routes sends to setup (preserving the draft) instead of dropping them. // banner and routes sends to setup (preserving the draft) instead of dropping them.
@@ -156,7 +160,14 @@ export function Composer(props: Props) {
const el = textareaRef.current; const el = textareaRef.current;
if (!el) return; if (!el) return;
el.style.height = "auto"; el.style.height = "auto";
const max = parseFloat(getComputedStyle(el).lineHeight || "22") * 4; // The cap must include the vertical PADDING: scrollHeight does, so a
// padding-blind cap left the box ~20px short and scrolled the top padding
// (plus the first line) out of the clip while typing (OPE-106). Six lines —
// team briefs outgrew four.
const cs = getComputedStyle(el);
const pad =
(parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0);
const max = (parseFloat(cs.lineHeight) || 22) * 6 + pad;
const next = Math.min(el.scrollHeight, max); const next = Math.min(el.scrollHeight, max);
el.style.height = `${Math.max(next, 24)}px`; el.style.height = `${Math.max(next, 24)}px`;
el.style.overflowY = el.scrollHeight > max ? "auto" : "hidden"; el.style.overflowY = el.scrollHeight > max ? "auto" : "hidden";
@@ -318,7 +329,7 @@ export function Composer(props: Props) {
const t = (skill ? text.slice(skill.length + 1) : text).trim(); const t = (skill ? text.slice(skill.length + 1) : text).trim();
if ( if (
(!t && attachments.length === 0 && !skill) || (!t && attachments.length === 0 && !skill) ||
props.running || (props.running && !props.gateOpen) ||
dictation?.recording || dictation?.recording ||
dictationBusy dictationBusy
) )
@@ -513,7 +524,11 @@ export function Composer(props: Props) {
<textarea <textarea
ref={textareaRef} ref={textareaRef}
className="w-full block px-3.5 pt-3.5 pb-1.5 text-[14.5px]" className="w-full block px-3.5 pt-3.5 pb-1.5 text-[14.5px]"
placeholder={props.placeholder || "Ask the coworker… (drop or paste files)"} placeholder={
props.gateOpen
? "Reply to adjust the proposal — or use the buttons above"
: props.placeholder || "Ask the coworker… (drop or paste files)"
}
value={text} value={text}
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
onKeyDown={onKey} onKeyDown={onKey}
@@ -650,8 +665,8 @@ export function Composer(props: Props) {
</button> </button>
)} )}
{/* send / stop */} {/* send / stop — a pending gate re-opens Send: the reply resolves it */}
{props.running ? ( {props.running && !props.gateOpen ? (
<button className="btn danger" onClick={props.onInterrupt}> <button className="btn danger" onClick={props.onInterrupt}>
Stop Stop
</button> </button>
+8 -1
View File
@@ -3,6 +3,7 @@ import type { ApprovalDecision, Item } from "../types";
import { shortArgs } from "./ApprovalCard"; import { shortArgs } from "./ApprovalCard";
import { humanizeAsk, humanizeTool, type HumanLine } from "../humanize"; import { humanizeAsk, humanizeTool, type HumanLine } from "../humanize";
import { Markdown } from "./Markdown"; import { Markdown } from "./Markdown";
import { BoardWakeCard } from "./BoardWakeCard";
import { ConnectorMessageCard } from "./ConnectorMessageCard"; import { ConnectorMessageCard } from "./ConnectorMessageCard";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
@@ -415,7 +416,13 @@ export function Transcript({ items, running, streamingText, onRetry, onUndoMemor
const { item } = block; const { item } = block;
switch (item.kind) { switch (item.kind) {
case "connector": case "connector":
return <ConnectorMessageCard source={item.source} key={bi} />; // Board wakes get their own collapsed-by-default card — a report,
// not a foreign message (owner ask 2026-08-16).
return item.source.connector === "board" ? (
<BoardWakeCard source={item.source} key={bi} />
) : (
<ConnectorMessageCard source={item.source} key={bi} />
);
case "user": case "user":
return ( return (
<div className="group self-end max-w-[78%] flex flex-col items-end" key={bi}> <div className="group self-end max-w-[78%] flex flex-col items-end" key={bi}>
+39 -17
View File
@@ -6,6 +6,12 @@ import { useState } from "react";
import type { Item } from "../types"; import type { Item } from "../types";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
// Past this length, "Done when" clamps to two lines with a per-item expander —
// a model that writes essay criteria must not occupy two screens of gate card
// (owner-hit 2026-08-16). The full text is one click away; the lead's prompt
// separately pushes criteria back toward 13 crisp checks.
const CRITERIA_CLAMP_CHARS = 160;
export function WorkItemsCard({ export function WorkItemsCard({
item, item,
onRespond, onRespond,
@@ -14,6 +20,7 @@ export function WorkItemsCard({
onRespond: (approved: boolean, feedback?: string) => void; onRespond: (approved: boolean, feedback?: string) => void;
}) { }) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [openCriteria, setOpenCriteria] = useState<Record<number, boolean>>({});
const visible = expanded ? item.items : item.items.slice(0, 3); const visible = expanded ? item.items : item.items.slice(0, 3);
const hidden = item.items.length - visible.length; const hidden = item.items.length - visible.length;
return ( return (
@@ -25,23 +32,38 @@ export function WorkItemsCard({
</span> </span>
</div> </div>
{item.note && <div className="itemsreq-note">{item.note}</div>} {item.note && <div className="itemsreq-note">{item.note}</div>}
{visible.map((entry, i) => ( <div className="itemsreq-list">
<div className="itemsreq-item" key={i}> {visible.map((entry, i) => {
<span className="itemsreq-num">{i + 1}.</span> const long = (entry.criteria || "").length > CRITERIA_CLAMP_CHARS;
<span className="itemsreq-body"> const open = !!openCriteria[i];
<span className="itemsreq-item-title">{entry.title}</span> return (
<span className="itemsreq-ac"> <div className="itemsreq-item" key={i}>
<b>Done when:</b> {entry.criteria} <span className="itemsreq-num">{i + 1}.</span>
</span> <span className="itemsreq-body">
</span> <span className="itemsreq-item-title">{entry.title}</span>
</div> <span className={"itemsreq-ac" + (long && !open ? " clamped" : "")}>
))} <b>Done when:</b> {entry.criteria}
{hidden > 0 && ( </span>
<button className="itemsreq-more" onClick={() => setExpanded(true)}> {long && (
{hidden} more item{hidden === 1 ? "" : "s"} <button
<Icon name="chevronDown" size={12} /> className="itemsreq-ac-toggle"
</button> data-testid={`itemsreq-ac-toggle-${i}`}
)} onClick={() => setOpenCriteria((s) => ({ ...s, [i]: !s[i] }))}
>
{open ? "Show less" : "Show full criteria"}
</button>
)}
</span>
</div>
);
})}
{hidden > 0 && (
<button className="itemsreq-more" onClick={() => setExpanded(true)}>
{hidden} more item{hidden === 1 ? "" : "s"}
<Icon name="chevronDown" size={12} />
</button>
)}
</div>
<div className="dirreq-actions"> <div className="dirreq-actions">
<span className="itemsreq-grant"> <span className="itemsreq-grant">
Reply to edit the split; approval creates these on the board. Reply to edit the split; approval creates these on the board.
+50 -1
View File
@@ -456,7 +456,9 @@ button.btn.danger { color: var(--accent); }
(Composer.tsx); only the textarea reset + drag-ring remain in CSS. */ (Composer.tsx); only the textarea reset + drag-ring remain in CSS. */
.composer textarea { .composer textarea {
width: 100%; border: none; outline: none; resize: none; font: inherit; font-size: 14.5px; line-height: 1.45; width: 100%; border: none; outline: none; resize: none; font: inherit; font-size: 14.5px; line-height: 1.45;
background: transparent; color: var(--ink); min-height: 24px; max-height: 88px; overflow: hidden; background: transparent; color: var(--ink); min-height: 24px; overflow: hidden;
/* Height (incl. the cap) is owned by the autogrow effect in Composer.tsx a
CSS max-height here fought it and re-created the OPE-106 clipping. */
} }
.composer textarea::placeholder { color: var(--faint); } .composer textarea::placeholder { color: var(--faint); }
.voice-wave-line { flex: 1; min-width: 28px; border-top: 1px dashed var(--line-strong); } .voice-wave-line { flex: 1; min-width: 28px; border-top: 1px dashed var(--line-strong); }
@@ -1640,6 +1642,42 @@ html[data-platform="linux"] ::-webkit-scrollbar-thumb:hover { background-color:
/* ── Agent teams (OPE-96): board rail, expanded overlay, plan gate ─────────── */ /* ── Agent teams (OPE-96): board rail, expanded overlay, plan gate ─────────── */
.board-rail { display: flex; flex-direction: column; gap: 2px; } .board-rail { display: flex; flex-direction: column; gap: 2px; }
.board-rail-quiet { font-size: 12px; color: var(--faint); padding: 2px 0 4px; }
.board-finished-toggle {
align-self: flex-start; border: none; background: none; padding: 4px 0 0; cursor: pointer;
font-size: 11.5px; color: var(--faint);
}
.board-finished-toggle:hover { color: var(--muted); text-decoration: underline; }
/* -- BoardWakeCard: a board wake collapsed to one line (click to expand) ------- */
.boardwake {
border: 1px solid var(--line); border-radius: 10px; background: var(--panel);
margin: 2px 0; overflow: hidden;
}
.boardwake.attention { border-color: color-mix(in srgb, var(--warn, #b98a2f) 45%, var(--line)); }
.boardwake-head {
display: flex; align-items: center; gap: 7px; width: 100%; text-align: left;
border: none; background: none; cursor: pointer; padding: 7px 10px;
font-size: 12.5px; color: var(--muted);
}
.boardwake-head:hover { background: var(--paper); }
.boardwake-title { font-weight: 600; color: var(--ink); }
.boardwake-chevron { display: inline-flex; transition: transform 0.15s; }
.boardwake-chevron.open { transform: rotate(180deg); }
.boardwake-summary { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.boardwake-body { border-top: 1px solid var(--line); padding: 6px 10px 8px; max-height: 260px; overflow-y: auto; }
.boardwake-row { display: flex; align-items: baseline; gap: 8px; padding: 4px 0; }
.boardwake-row-main { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.boardwake-row-text { font-size: 12px; color: var(--ink); }
.boardwake-note {
font-size: 11.5px; color: var(--muted); white-space: pre-wrap; overflow-wrap: anywhere;
border-left: 2px solid var(--line); padding-left: 8px;
}
.boardwake-note-toggle {
align-self: flex-start; border: none; background: none; padding: 0; cursor: pointer;
font-size: 11px; color: var(--accent);
}
.boardwake-note-toggle:hover { text-decoration: underline; }
.board-group { .board-group {
font-size: 10.5px; letter-spacing: 0.06em; text-transform: uppercase; font-size: 10.5px; letter-spacing: 0.06em; text-transform: uppercase;
color: var(--faint); font-weight: 700; margin: 8px 0 3px; color: var(--faint); font-weight: 700; margin: 8px 0 3px;
@@ -1746,6 +1784,17 @@ html[data-platform="linux"] ::-webkit-scrollbar-thumb:hover { background-color:
.itemsreq-item-title { font-size: 12.5px; color: var(--ink); } .itemsreq-item-title { font-size: 12.5px; color: var(--ink); }
.itemsreq-ac { font-size: 11.5px; color: var(--muted); } .itemsreq-ac { font-size: 11.5px; color: var(--muted); }
.itemsreq-ac b { font-weight: 600; } .itemsreq-ac b { font-weight: 600; }
/* Essay-length criteria clamp to two lines; the expander reveals the rest. The
list itself caps so a big proposal never occupies two screens of chat. */
.itemsreq-ac.clamped {
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.itemsreq-ac-toggle {
align-self: flex-start; border: none; background: none; padding: 0; cursor: pointer;
font-size: 11px; color: var(--accent);
}
.itemsreq-ac-toggle:hover { text-decoration: underline; }
.itemsreq-list { max-height: 320px; overflow-y: auto; }
.itemsreq-more { display: flex; align-items: center; gap: 5px; border: 0; background: transparent; color: var(--accent); font-size: 12px; cursor: pointer; padding: 6px 0; } .itemsreq-more { display: flex; align-items: center; gap: 5px; border: 0; background: transparent; color: var(--accent); font-size: 12px; cursor: pointer; padding: 6px 0; }
.itemsreq-grant { font-size: 11.5px; color: var(--faint); } .itemsreq-grant { font-size: 11.5px; color: var(--faint); }
+34 -1
View File
@@ -333,11 +333,44 @@ def test_create_team_uses_callnames_and_creates_the_chat_group(manager, monkeypa
group = manager.chat_store.get_group(team.chat_group) group = manager.chat_store.get_group(team.chat_group)
assert {m["name"] for m in group["members"]} == {"nia", "nia-2", "lead"} assert {m["name"] for m in group["members"]} == {"nia", "nia-2", "lead"}
# the worker digest carries the roster + how to reach teammates # the worker digest carries the roster + how to reach teammates
digest = manager._team_digest(team, [], [], is_lead=False) digest, _rows = manager._team_digest(team, [], [], is_lead=False)
assert "Your team: nia (swe-worker — implementation)" in digest assert "Your team: nia (swe-worker — implementation)" in digest
assert "@name in # team chat" in digest assert "@name in # team chat" in digest
def test_digest_clamps_long_comments_and_carries_structured_rows(manager):
"""Hand-off essays live on the board; the wake message carries a head, the
sidecar carries UI rows (owner ruling 2026-08-16 the digest was arriving
as a wall of text)."""
from coworker.teams.registry import Team
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="Big item", criteria="c"
)
manager.team_store.assign(space, lead, item["id"], "nia")
essay = "verified the endpoint thoroughly. " * 40 # ~1300 chars
manager.team_store.transition(
space, worker, item["id"], "in_progress"
)
manager.team_store.transition(
space, worker, item["id"], "review", comment=essay
)
team = Team(team_id="t1", space=space, lead_session="s", lead_actor="lead-1")
subs = manager.team_store.subscribed_events(space, "lead-1")
message, rows = manager._team_digest(team, [], subs, is_lead=True)
# model text: clamped hard, with the pointer back to the board
assert essay not in message
assert "(full text on the board)" in message
assert len(message) < 1200
# sidecar rows: structured, softer clamp for the human on click
moved = [r for r in rows if r["kind"] == "moved" and r["to"] == "review"]
assert moved and moved[0]["item"] == item["id"]
assert moved[0]["note"].endswith("") and len(moved[0]["note"]) < 700
def test_cancel_notice_is_addressed_to_the_assignee(store): def test_cancel_notice_is_addressed_to_the_assignee(store):
item_id = assigned(store) item_id = assigned(store)
store.consume("swe-worker", store.pending_for("swe-worker")[-1]["seq"]) store.consume("swe-worker", store.pending_for("swe-worker")[-1]["seq"])