mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-11 14:50:14 +00:00
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:
@@ -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);
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
await planTheWork(page);
|
||||
await page.getByRole("button", { name: /Journal/ }).click();
|
||||
|
||||
@@ -642,10 +642,42 @@ export async function mockApi(page: import("@playwright/test").Page) {
|
||||
}
|
||||
// Agent teams: the decomposition gate — the lead proposes work items and
|
||||
// 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)) {
|
||||
send("items_proposed", {
|
||||
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: "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" },
|
||||
|
||||
@@ -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 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 expect(page.getByText(/Items created on the board/)).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 }) => {
|
||||
await page.goto("/");
|
||||
await page.getByPlaceholder(/Ask the coworker/).fill("propose the split");
|
||||
|
||||
@@ -1020,6 +1020,24 @@ export function App() {
|
||||
setSendGate({ text, attachments, skill });
|
||||
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
|
||||
// `display` sidecar formula so the turn_start dedupe recognizes the local echo.
|
||||
const shown = skill ? `/${skill}${text ? ` ${text}` : ""}` : text;
|
||||
@@ -1952,6 +1970,7 @@ export function App() {
|
||||
models={models}
|
||||
modelLabels={modelLabels}
|
||||
running={running}
|
||||
gateOpen={!unattended && (!!pendingTeam || !!pendingItemsReq)}
|
||||
connected={connected}
|
||||
modelReady={modelReady}
|
||||
onConnectModel={openModelSetup}
|
||||
|
||||
@@ -162,6 +162,20 @@ export interface MessageSource {
|
||||
sender_name: string; // resolved; may equal the id
|
||||
ts: number; // epoch seconds
|
||||
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
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// 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.
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Board, BoardItem } from "../api";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
@@ -39,12 +39,30 @@ export function boardSummary(board: Board): string {
|
||||
}
|
||||
|
||||
export function BoardSection({ board, onExpand }: { board: Board; onExpand: () => void }) {
|
||||
const groups = GROUPS.map((g) => ({
|
||||
...g,
|
||||
items: board.items.filter((i) => i.state === g.state),
|
||||
})).filter((g) => g.items.length > 0);
|
||||
// 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
|
||||
// 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 (
|
||||
<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) => (
|
||||
<div key={group.state}>
|
||||
<div className="board-group">{group.label}</div>
|
||||
@@ -61,6 +79,15 @@ export function BoardSection({ board, onExpand }: { board: Board; onExpand: () =
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -53,6 +53,10 @@ interface Props {
|
||||
// session; after the first turn the fact lives in the topbar subtitle (§22) — no
|
||||
// interactive-then-disabled control.
|
||||
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;
|
||||
// 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.
|
||||
@@ -156,7 +160,14 @@ export function Composer(props: Props) {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
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);
|
||||
el.style.height = `${Math.max(next, 24)}px`;
|
||||
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();
|
||||
if (
|
||||
(!t && attachments.length === 0 && !skill) ||
|
||||
props.running ||
|
||||
(props.running && !props.gateOpen) ||
|
||||
dictation?.recording ||
|
||||
dictationBusy
|
||||
)
|
||||
@@ -513,7 +524,11 @@ export function Composer(props: Props) {
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
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}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={onKey}
|
||||
@@ -650,8 +665,8 @@ export function Composer(props: Props) {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* send / stop */}
|
||||
{props.running ? (
|
||||
{/* send / stop — a pending gate re-opens Send: the reply resolves it */}
|
||||
{props.running && !props.gateOpen ? (
|
||||
<button className="btn danger" onClick={props.onInterrupt}>
|
||||
⏹ Stop
|
||||
</button>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ApprovalDecision, Item } from "../types";
|
||||
import { shortArgs } from "./ApprovalCard";
|
||||
import { humanizeAsk, humanizeTool, type HumanLine } from "../humanize";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { BoardWakeCard } from "./BoardWakeCard";
|
||||
import { ConnectorMessageCard } from "./ConnectorMessageCard";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
@@ -415,7 +416,13 @@ export function Transcript({ items, running, streamingText, onRetry, onUndoMemor
|
||||
const { item } = block;
|
||||
switch (item.kind) {
|
||||
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":
|
||||
return (
|
||||
<div className="group self-end max-w-[78%] flex flex-col items-end" key={bi}>
|
||||
|
||||
@@ -6,6 +6,12 @@ import { useState } from "react";
|
||||
import type { Item } from "../types";
|
||||
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 1–3 crisp checks.
|
||||
const CRITERIA_CLAMP_CHARS = 160;
|
||||
|
||||
export function WorkItemsCard({
|
||||
item,
|
||||
onRespond,
|
||||
@@ -14,6 +20,7 @@ export function WorkItemsCard({
|
||||
onRespond: (approved: boolean, feedback?: string) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [openCriteria, setOpenCriteria] = useState<Record<number, boolean>>({});
|
||||
const visible = expanded ? item.items : item.items.slice(0, 3);
|
||||
const hidden = item.items.length - visible.length;
|
||||
return (
|
||||
@@ -25,23 +32,38 @@ export function WorkItemsCard({
|
||||
</span>
|
||||
</div>
|
||||
{item.note && <div className="itemsreq-note">{item.note}</div>}
|
||||
{visible.map((entry, i) => (
|
||||
<div className="itemsreq-item" key={i}>
|
||||
<span className="itemsreq-num">{i + 1}.</span>
|
||||
<span className="itemsreq-body">
|
||||
<span className="itemsreq-item-title">{entry.title}</span>
|
||||
<span className="itemsreq-ac">
|
||||
<b>Done when:</b> {entry.criteria}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<button className="itemsreq-more" onClick={() => setExpanded(true)}>
|
||||
+ {hidden} more item{hidden === 1 ? "" : "s"}
|
||||
<Icon name="chevronDown" size={12} />
|
||||
</button>
|
||||
)}
|
||||
<div className="itemsreq-list">
|
||||
{visible.map((entry, i) => {
|
||||
const long = (entry.criteria || "").length > CRITERIA_CLAMP_CHARS;
|
||||
const open = !!openCriteria[i];
|
||||
return (
|
||||
<div className="itemsreq-item" key={i}>
|
||||
<span className="itemsreq-num">{i + 1}.</span>
|
||||
<span className="itemsreq-body">
|
||||
<span className="itemsreq-item-title">{entry.title}</span>
|
||||
<span className={"itemsreq-ac" + (long && !open ? " clamped" : "")}>
|
||||
<b>Done when:</b> {entry.criteria}
|
||||
</span>
|
||||
{long && (
|
||||
<button
|
||||
className="itemsreq-ac-toggle"
|
||||
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">
|
||||
<span className="itemsreq-grant">
|
||||
Reply to edit the split; approval creates these on the board.
|
||||
|
||||
@@ -456,7 +456,9 @@ button.btn.danger { color: var(--accent); }
|
||||
(Composer.tsx); only the textarea reset + drag-ring remain in CSS. */
|
||||
.composer textarea {
|
||||
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); }
|
||||
.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 ─────────── */
|
||||
.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 {
|
||||
font-size: 10.5px; letter-spacing: 0.06em; text-transform: uppercase;
|
||||
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-ac { font-size: 11.5px; color: var(--muted); }
|
||||
.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-grant { font-size: 11.5px; color: var(--faint); }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user