diff --git a/coworker/personas/builtin/swe-lead/manifest.md b/coworker/personas/builtin/swe-lead/manifest.md index 40ba0554..b1ce0616 100644 --- a/coworker/personas/builtin/swe-lead/manifest.md +++ b/coworker/personas/builtin/swe-lead/manifest.md @@ -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. 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 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 1–3 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 returns their ids) and revise until the user approves. Use create_item only for one-off additions after the plan is approved. diff --git a/coworker/personas/builtin/swe-worker/manifest.md b/coworker/personas/builtin/swe-worker/manifest.md index f3860ff0..da65a8df 100644 --- a/coworker/personas/builtin/swe-worker/manifest.md +++ b/coworker/personas/builtin/swe-worker/manifest.md @@ -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 real acceptance criteria and keep moving. The lead triages it. - 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 - verdict after verification. + verified it, refs (branch, files). Keep the hand-off TIGHT — a short paragraph + 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]. - House rules hold: no silent skips — if you couldn't do part of the work, the hand-off comment says which part and why. diff --git a/coworker/server/manager.py b/coworker/server/manager.py index 7c6ba258..3bad327e 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -1955,9 +1955,9 @@ class SessionManager: 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) 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) - source = self._board_source(team, message) + source = self._board_source(team, message, rows=rows) async def _deliver() -> None: try: @@ -1980,6 +1980,21 @@ class SessionManager: asyncio.create_task(_deliver()) 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( self, team, @@ -1988,10 +2003,16 @@ class SessionManager: chats: Optional[list[dict]] = None, *, is_lead: bool, - ) -> str: + ) -> tuple[str, list[dict]]: """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] = [] + rows: list[dict] = [] for event in directs + subs: item_id = event.get("item_id") payload = event.get("payload") or {} @@ -2002,6 +2023,11 @@ class SessionManager: except Exception: item = None 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 item is None: continue @@ -2012,15 +2038,18 @@ class SessionManager: f"{event['actor']} claimed {title} — it's theirs now;" " reassign or cancel if that's wrong." ) + rows.append({**row, "kind": "claimed"}) continue lines.append( f"You've been assigned work item {title}.\n" f" Done when: {item['criteria']}" + (f"\n Details: {item['description']}" if item["description"] else "") ) + rows.append({**row, "kind": "assigned"}) elif event["kind"] == "item_transitioned": 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: lines.append( f"{title} was CANCELED by {event['actor']}{note} — stop any" @@ -2028,32 +2057,63 @@ class SessionManager: ) else: 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": lines.append(f"New item filed by {event['actor']}: {title}") + rows.append({**row, "kind": "filed"}) elif event["kind"] == "item_commented": 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 []: 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)" if is_lead: - return ( + message = ( "⏰ Board wake — your team needs decisions:\n" + 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" " items, and triage new filings. Steer only where needed." ) - return ( - "[Lead] Board update:\n" - + body - + self._roster_note(team) - + "\n\nMove your item to in_progress when you start; blocked (with a" - " comment) if stuck; review with a hand-off comment when finished." - " Journal evidence as you go." - ) + else: + message = ( + "[Lead] Board update:\n" + + body + + self._roster_note(team) + + "\n\nMove your item to in_progress when you start; blocked (with a" + " comment) if stuck; review with a hand-off comment when finished." + " Journal evidence as you go." + ) + return message, rows @staticmethod def _roster_note(team) -> str: @@ -2074,11 +2134,15 @@ class SessionManager: return f"\n\nYour team: {mates}; lead (coordinator).{reach}" @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 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 - 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 { "connector": "board", "kind": "channel", @@ -2088,6 +2152,7 @@ class SessionManager: "sender_name": "Board", "ts": time.time(), "text": message, + "board": {"rows": rows or []}, } def team_staleness_digest(self, session_id: str) -> str: diff --git a/surfaces/gui/e2e/board.spec.ts b/surfaces/gui/e2e/board.spec.ts index 1ac8f1e7..6f0dfc16 100644 --- a/surfaces/gui/e2e/board.spec.ts +++ b/surfaces/gui/e2e/board.spec.ts @@ -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(); diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index 1811ca31..1b45d5f2 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -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" }, diff --git a/surfaces/gui/e2e/team.spec.ts b/surfaces/gui/e2e/team.spec.ts index b3d311d0..18efa244 100644 --- a/surfaces/gui/e2e/team.spec.ts +++ b/surfaces/gui/e2e/team.spec.ts @@ -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"); diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index f7df71d0..ec3f44f4 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -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} diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index 83baa7bb..5a684aa0 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -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 diff --git a/surfaces/gui/src/components/BoardPanel.tsx b/surfaces/gui/src/components/BoardPanel.tsx index c8048366..cb4174c5 100644 --- a/surfaces/gui/src/components/BoardPanel.tsx +++ b/surfaces/gui/src/components/BoardPanel.tsx @@ -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 (
+ {groups.length === 0 && ( +
+ No active work +
+ )} {groups.map((group) => (
{group.label}
@@ -61,6 +79,15 @@ export function BoardSection({ board, onExpand }: { board: Board; onExpand: () = ))}
))} + {finished > 0 && ( + + )}
); } diff --git a/surfaces/gui/src/components/BoardWakeCard.tsx b/surfaces/gui/src/components/BoardWakeCard.tsx new file mode 100644 index 00000000..688545f6 --- /dev/null +++ b/surfaces/gui/src/components/BoardWakeCard.tsx @@ -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 = {}; + 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>({}); + const rows = source.board?.rows || []; + const { text, attention } = summarize(rows); + return ( +
+ + {open && ( +
+ {rows.map((row, i) => ( +
+ + + {rowText(row)} + {row.note && + (openNotes[i] ? ( + {row.note} + ) : ( + + ))} + +
+ ))} + {rows.length === 0 && ( +
{source.text}
+ )} +
+ )} +
+ ); +} diff --git a/surfaces/gui/src/components/Composer.tsx b/surfaces/gui/src/components/Composer.tsx index 532c437d..4c557cbb 100644 --- a/surfaces/gui/src/components/Composer.tsx +++ b/surfaces/gui/src/components/Composer.tsx @@ -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) {