From 39c51102d31126fc822bc832c7a416faf24a874d Mon Sep 17 00:00:00 2001 From: richard-epsilla Date: Fri, 28 Aug 2026 23:41:38 -0700 Subject: [PATCH 1/4] feat(sheets): an agent column can run a base agent, and arrives already pointing at one A new sheet was never runnable. The builder cannot see the person's own agents, so it wrote harness_id "" and said to pick one; the shipped templates ship blank for the same reason. Every route to a sheet therefore ended at a Run button that refused on every column until you opened each menu in turn. The missing piece was that a BASE is a perfectly good answer. Its id IS its base name, which the server already accepts as a harness id, so a column can run one without anybody having configured anything. And unlike a chrn_ id, a base id is stable, so the builder can write one without inventing anything. - The column picker lists base agents beside the person's own, under a heading for each. Bases come from /v1/bases and are filtered on what this deployment reports ready with an available model, never a list written down here: offering one that is not installed would produce a sheet that looks configured and fails on the first row. - The builder now names a base suited to the work. The skill documents the ids and its validator accepts them, so a sheet arrives runnable. - The app fills any blank agent column with a base it has just confirmed this deployment can run, which covers the templates and every sheet written before this. Blanks only: a column pointing at an agent that no longer exists keeps refusing, because quietly re-pointing it would run something other than what the sheet says it runs. The kit's own Harness stays excluded from the list, since a sheet whose column runs the sheet's own agent is recursion with a file-write race in it. The base it happens to sit on is not excluded: that is a different agent, with its own session and no interest in sheet.json. Verified on the public VM: a sheet asked for in one sentence came back with its agent column bound to codex, chosen by the builder itself, and Run enabled without touching a menu. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01X7VoW2QzfDkxEoudktbjVY --- .../app/src/components/HarnessConfig.jsx | 26 ++++++--- kits/sheets/app/src/lib/sh.js | 57 +++++++++++++++++-- kits/sheets/app/src/pages/SheetPage.jsx | 34 +++++++++-- kits/sheets/kit.json | 2 +- kits/sheets/skills/sheet-design/SKILL.md | 25 +++++--- .../skills/sheet-design/validate_sheet.py | 19 +++++-- 6 files changed, 133 insertions(+), 30 deletions(-) diff --git a/kits/sheets/app/src/components/HarnessConfig.jsx b/kits/sheets/app/src/components/HarnessConfig.jsx index 879ad37..ebb72ba 100644 --- a/kits/sheets/app/src/components/HarnessConfig.jsx +++ b/kits/sheets/app/src/components/HarnessConfig.jsx @@ -35,6 +35,22 @@ export function HarnessConfig({ column, columns, applyPatch, close }) { const chosen = agents?.find((a) => a.id === harnessId) || null; + // The person's own agents and the base agents, in one list with a heading over each. The Select + // renders a flat list, so the headings are disabled entries — and they only appear when there is + // more than one group, because a lone heading is noise rather than orientation. + const options = useMemo(() => { + const own = (agents || []).filter((a) => a.kind !== 'base'); + const bases = (agents || []).filter((a) => a.kind === 'base'); + const label = (a) => `${a.name}${a.model ? ` · ${a.model}` : ''}${a.unusable ? ` — ${a.unusable}` : ''}`; + const out = []; + for (const [head, group] of [['Your agents', own], ['Base agents', bases]]) { + if (!group.length) continue; + if (own.length && bases.length) out.push({ value: `__head_${head}`, label: head, disabled: true }); + out.push(...group.map((a) => ({ value: a.id, label: label(a), disabled: !!a.unusable }))); + } + return out; + }, [agents]); + // What this column will actually read, derived from the prompt and the attachments — the same // function the planner uses, so what is shown and what runs cannot drift apart. const reads = derivedDeps({ ...column, type: 'harness', harness: { prompt, attach: [...attach] } }, columns) @@ -82,16 +98,12 @@ export function HarnessConfig({ column, columns, applyPatch, close }) { label="Agent" value={harnessId} onChange={(e) => setHarnessId(e.target.value)} - placeholder={agents === null ? 'Loading your agents…' : 'Choose an agent…'} + placeholder={agents === null ? 'Loading agents…' : 'Choose an agent…'} disabled={agents === null || agents.length === 0} hint={agents && agents.length === 0 - ? (loadErr || 'You have no other agents yet. Create one, then choose it here.') + ? (loadErr || 'No agent on this deployment can run a column yet.') : undefined} - options={(agents || []).map((a) => ({ - value: a.id, - label: `${a.name}${a.model ? ` · ${a.model}` : ''}${a.unusable ? ` — ${a.unusable}` : ''}`, - disabled: !!a.unusable, - }))} + options={options} /> diff --git a/kits/sheets/app/src/lib/sh.js b/kits/sheets/app/src/lib/sh.js index e5c559f..83b2995 100644 --- a/kits/sheets/app/src/lib/sh.js +++ b/kits/sheets/app/src/lib/sh.js @@ -11,7 +11,7 @@ // /kits/sheets, so it is same-origin with the console's API proxy: the browser sends the console // session it already has and the proxy attaches the internal key server-side. import { - configureKit, kitHarness, listHarnesses, listSessions, sessionDetail, patchSession, + configureKit, hr, kitHarness, listHarnesses, listSessions, sessionDetail, patchSession, deleteSession, readJsonFile, writeFile, sessionTurns, containerFileUrl, } from 'reifyui/harness'; @@ -24,21 +24,29 @@ export { containerFileUrl, sessionTurns }; /** The Harness this kit launched, or null when it was never launched. */ export const sheetsHarness = kitHarness; -/** Every Harness an agent COLUMN may run. +/** Every agent an agent COLUMN may run: the person's own agents, and the BASE agents. + * + * A base is a first-class choice, not a fallback. Its id IS its base name ("codex", "opencode"), + * which the server accepts as a harness id directly, so a column can run one without anybody + * having configured a thing first. That is what lets a brand new sheet be runnable the moment it + * is created — before, every agent column arrived blank and the person had to go and make an + * agent before the Run button meant anything. * * Deliberately excludes this kit's own Harness. A sheet whose column runs the sheet's own agent * would have that agent editing sheet.json while the app is driving a run over it — recursion * with a file-write race inside it. Excluded at the source of the list rather than validated at - * run time, so the choice is never offered in the first place. + * run time, so the choice is never offered in the first place. The base it happens to sit on is + * NOT excluded: that is a different agent with its own session and no interest in sheet.json. * * Also excludes harnesses that require request headers: their turns are refused without those * headers, and this app has nowhere to hold them. They are returned marked rather than dropped, * so the picker can say why instead of silently having fewer entries than the console shows. */ export async function runnableHarnesses() { - const [harnesses, mine] = await Promise.all([listHarnesses(), sheetsHarness()]); - return harnesses + const [harnesses, bases, mine] = await Promise.all([listHarnesses(), listBases(), sheetsHarness()]); + const own = harnesses .filter((h) => h.id !== mine?.id) .map((h) => ({ + kind: 'agent', id: h.id, name: h.name, base: h.base, @@ -47,6 +55,45 @@ export async function runnableHarnesses() { ? 'needs request headers this app can’t send' : '', })); + return [...own, ...bases]; +} + +/** The base agents this deployment can actually run. + * + * Filtered on what the server reports, never on a list written down here: which bases are + * installed differs per deployment, and offering one that is not would produce a sheet that looks + * configured and fails on the first row. A base with no available model is dropped for the same + * reason — the choice would dispatch and then fail. */ +async function listBases() { + let bases = []; + try { + ({ bases = [] } = await hr('/bases')); + } catch { + return []; // the person's own agents still list; bases just are not offered + } + return bases + .filter((b) => b.status === 'ready' && (b.models || []).some((m) => m.available)) + .map((b) => ({ + kind: 'base', + id: b.id, // the base id IS the harness id the server accepts + name: b.label || b.id, + base: b.id, + model: b.defaultModel || '', + unusable: '', + })); +} + +/** The agent an unbound column should get, or '' when this deployment can run none. + * + * Prefers the base this kit's own Harness runs on. That one is installed and has working + * credentials by construction — the sheet in front of you was written by it — so it is the + * choice least likely to fail on the first row. */ +export function defaultAgentId(list, mine) { + const bases = (list || []).filter((a) => a.kind === 'base' && !a.unusable); + if (!bases.length) return ''; + const ownBase = String(mine?.base || '').toLowerCase(); + const alias = ownBase === 'claude' ? 'claude-code' : ownBase; + return (bases.find((b) => b.id === alias) || bases[0]).id; } // ── sheets (= sessions) ──────────────────────────────────────────────────── diff --git a/kits/sheets/app/src/pages/SheetPage.jsx b/kits/sheets/app/src/pages/SheetPage.jsx index b1b9def..2fa1ee1 100644 --- a/kits/sheets/app/src/pages/SheetPage.jsx +++ b/kits/sheets/app/src/pages/SheetPage.jsx @@ -16,8 +16,8 @@ import { PaneResizer, useResizablePane, useDialog } from 'reifyui'; import { SheetGrid, FilePreview, fitRowHeights } from 'reifyui'; import { containerFileUrl, getResponse, lastAssistantText, sessionTurns } from 'reifyui/harness'; import { - getSheet, isPending, markViewed, renameSheet, runnableHarnesses, saveSheet, sheetStatus, - sheetsHarness, + defaultAgentId, getSheet, isPending, markViewed, renameSheet, runnableHarnesses, saveSheet, + sheetStatus, sheetsHarness, } from '../lib/sh'; import { GRID_TYPES, cellKey, derivedDeps, isHarnessColumn, validate, @@ -220,12 +220,38 @@ export function SheetPage({ id: routeId, seed }) { Promise.all([runnableHarnesses(), sheetsHarness()]) .then(([list, mine]) => { if (dead) return; - setEnv({ harnesses: new Map(list.map((h) => [h.id, h])), ownId: mine?.id || '' }); + setEnv({ harnesses: new Map(list.map((h) => [h.id, h])), + ownId: mine?.id || '', + ownBase: mine?.base || '' }); }) - .catch(() => { if (!dead) setEnv({ harnesses: new Map(), ownId: '' }); }); + .catch(() => { if (!dead) setEnv({ harnesses: new Map(), ownId: '', ownBase: '' }); }); return () => { dead = true; }; }, []); + // ── an agent column arrives pointing at nothing, so point it somewhere that works ────────── + // The builder cannot see the list of agents, and a template ships blank because which agents + // exist differs per deployment. Left alone that is a finished-looking sheet whose Run button + // refuses on every column until the person opens each menu in turn. So the app fills the blanks + // with an agent it has JUST confirmed this deployment can run. + // + // Blanks only. A column pointing at an agent that no longer exists keeps refusing, because + // quietly re-pointing it would run something other than what the sheet says it runs. + useEffect(() => { + if (!env || !sheet) return; + const cols = sheet.columns || []; + const blank = (c) => isHarnessColumn(c) && !String((c.harness || {}).harness_id || '').trim(); + if (!cols.some(blank)) return; + const pick = defaultAgentId([...env.harnesses.values()], { base: env.ownBase }); + if (!pick) return; // nothing runnable here: leave it honest and refusing + commit({ + ...sheet, + columns: cols.map((c) => (blank(c) + ? { ...c, harness: { ...(c.harness || {}), harness_id: pick, + harness_name: env.harnesses.get(pick)?.name || '' } } + : c)), + }); + }, [env, sheet, commit]); + const adoptSession = useCallback((sid) => { if (!sid || sid === idRef.current) return; const [, query = ''] = (window.location.hash || '').split('?'); diff --git a/kits/sheets/kit.json b/kits/sheets/kit.json index fa35d98..30b816e 100644 --- a/kits/sheets/kit.json +++ b/kits/sheets/kit.json @@ -21,7 +21,7 @@ "model": "claude-opus-5" } ], - "system_prompt": "You build and edit spreadsheets.\n\nTHE FILE: ./sheet.json, in your current working directory. That exact path, always. Do not search for it, do not look elsewhere in the tree, and do not treat its absence as a puzzle — on a new sheet it simply does not exist yet and you create it there. It is the single source of truth and the only file the app reads.\n\nRead it before every change and write it back WHOLE — the person may have edited the grid between turns. Its schema, and the mistakes that make a sheet fail to load, are in the sheet-design skill: read that skill first, on every request, and validate with its validate_sheet.py before you finish.\n\nA column may be an AGENT column (type \"harness\"): it runs one of the person's other agents once per row. You may create and configure such a column, but you NEVER execute one and you never invent an agent id — you cannot see the list. Leave harness_id as \"\" and tell the person to pick the agent in the column menu. The app runs those columns, and it will not let a sheet run itself.\n\nCells in an agent column carry results the app produced — status, session_id, response_id, artifacts. Never write them and never delete them unless you are asked to clear that column; you cannot recompute them.\n\nWork directly. Every command you spend orienting is a command the person waits through.", + "system_prompt": "You build and edit spreadsheets.\n\nTHE FILE: ./sheet.json, in your current working directory. That exact path, always. Do not search for it, do not look elsewhere in the tree, and do not treat its absence as a puzzle — on a new sheet it simply does not exist yet and you create it there. It is the single source of truth and the only file the app reads.\n\nRead it before every change and write it back WHOLE — the person may have edited the grid between turns. Its schema, and the mistakes that make a sheet fail to load, are in the sheet-design skill: read that skill first, on every request, and validate with its validate_sheet.py before you finish.\n\nA column may be an AGENT column (type \"harness\"): it runs an agent once per row. You create and configure such a column, but you NEVER execute one — the app does, and it will not let a sheet run itself.\n\nALWAYS give an agent column a working default so the person can press Run the moment the sheet exists. Set harness_id to a BASE agent id, choosing the one that suits the work: \"codex\", \"claude-code\", \"hermes\", \"pi\", \"dsh\", \"opencode\", \"qwen\". Those ids are stable and you may write them. Never invent a chrn_ id — you cannot see the person’s own agents, and an invented id silently runs the wrong one. If the base you pick is not installed here the app substitutes one that is, so a reasonable choice is always better than leaving it blank. The person can change it in the column menu.\n\nCells in an agent column carry results the app produced — status, session_id, response_id, artifacts. Never write them and never delete them unless you are asked to clear that column; you cannot recompute them.\n\nWork directly. Every command you spend orienting is a command the person waits through.", "skills": [ "sheet-design" ] diff --git a/kits/sheets/skills/sheet-design/SKILL.md b/kits/sheets/skills/sheet-design/SKILL.md index 7a127d1..597beda 100644 --- a/kits/sheets/skills/sheet-design/SKILL.md +++ b/kits/sheets/skills/sheet-design/SKILL.md @@ -22,13 +22,13 @@ empty grid or a column with no editor. Match it exactly. { "id": "col_site", "name": "Site", "type": "url", "width": 240 }, { "id": "col_brief", "name": "Brief", "type": "harness", "width": 380, "harness": { - "harness_id": "", + "harness_id": "codex", "prompt": "Read {{Site}} and write three sentences on {{Company}}: what they sell, who to, and how they price. Put your full notes in notes.md.", "attach": [] } }, { "id": "col_fit", "name": "Overlap", "type": "harness", "width": 300, "harness": { - "harness_id": "", + "harness_id": "codex", "prompt": "Score 1-5 how directly this company competes with us, then one line of why.\n\n{{Brief}}", "attach": ["col_brief"] } } @@ -53,9 +53,13 @@ Non-negotiable, because each of these breaks silently: - **`type` is one of** `text` `number` `select` `tags` `checkbox` `date` `url` `harness`. Anything else loses its editor. - **Only a `harness` column carries a `harness` object**, and only it can be run. -- **`harness_id` is `""`** unless the person gave you a real one. You cannot see - the list of agents, and an invented id silently runs the wrong agent. Write - `""` and tell the person to pick it in the column menu. +- **`harness_id` names a BASE agent**, so the sheet is runnable the moment it + exists. The base ids are stable and you may write them: `codex`, + `claude-code`, `hermes`, `pi`, `dsh`, `opencode`, `qwen`. Pick the one that + suits the work. If it is not installed on this deployment the app substitutes + one that is, so a reasonable guess always beats a blank. Never invent a + `chrn_` id: those are the person's own agents, you cannot see them, and an + invented one silently runs the wrong agent. - **Never write `status`, `run_id`, `response_id`, `session_id` or `artifacts` into a cell, and never invent a `run` block.** Those are results the app produced. Writing them makes the sheet claim a run that never happened; you @@ -68,10 +72,13 @@ have edited the grid between your turns, and a partial write loses their work. ## What you do and do not run -An agent column runs one of the person's OTHER agents, once per row. You create -and configure such columns. **You never execute one.** The app runs them, from -the browser, in dependency order — and it will not let a sheet run itself, so -there is no id you could put there that would work. +An agent column runs an agent once per row. You create and configure such +columns. **You never execute one.** The app runs them, from the browser, in +dependency order — and it will not let a sheet run itself, so there is no id +you could put there that would point back here. + +Leave the sheet ready to go. A person who asked for a sheet with agent columns +wants to press Run, not to open every column menu first. ## Designing the columns diff --git a/kits/sheets/skills/sheet-design/validate_sheet.py b/kits/sheets/skills/sheet-design/validate_sheet.py index 4a3bfc9..318f789 100644 --- a/kits/sheets/skills/sheet-design/validate_sheet.py +++ b/kits/sheets/skills/sheet-design/validate_sheet.py @@ -22,6 +22,12 @@ STATUSES = {"queued", "running", "done", "failed", "skipped"} APP_OWNED = ("status", "run_id", "response_id", "session_id", "artifacts", "started_at", "ended_at") CHRN = re.compile(r"^chrn_[0-9a-f]{32}$") + +# The base agents, whose id IS the base name and is accepted as a harness id directly. Stable +# across deployments, which is exactly why the builder may write one: it cannot see the person's +# own agents, but it can always name a base. Which of these is actually installed varies, and the +# app substitutes an available one, so naming a base is never the thing that breaks a sheet. +BASES = {"codex", "claude-code", "hermes", "pi", "dsh", "opencode", "qwen"} REF = re.compile(r"\{\{\s*([^}]+?)\s*\}\}") errors: list[str] = [] @@ -98,11 +104,16 @@ def check_harness(columns: list, by_name: dict) -> None: if not is_agent: continue - hid = cfg.get("harness_id", "") - if hid != "" and not CHRN.match(str(hid)): + hid = str(cfg.get("harness_id", "") or "") + if hid == "": + warn(f"{at}.harness.harness_id", "is empty", + 'name a base agent so the sheet can be run the moment it exists — one of ' + + ", ".join(sorted(BASES))) + elif hid not in BASES and not CHRN.match(hid): err(f"{at}.harness.harness_id", f"is {json.dumps(hid)}", - 'leave it "" unless you were given a real agent id — you cannot see the list, ' - "and an invented id silently runs the wrong agent") + "use a base agent id (" + ", ".join(sorted(BASES)) + ") or a real chrn_ id you " + "were given — you cannot see the person's own agents, and an invented id " + "silently runs the wrong one") prompt = str(cfg.get("prompt") or "") if not prompt.strip(): From 1f7a131ca3b4eba494b8654b73bc20f9854aec63 Mon Sep 17 00:00:00 2001 From: richard-epsilla Date: Fri, 28 Aug 2026 23:48:45 -0700 Subject: [PATCH 2/4] fix(sheets): the client validator rejected the base ids, and a real answer was thrown away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the base-agent change walked into, both visible on the first run of a sheet built the new way. The banner. The app carries its own copy of the sheet rules in model.js, and only the skill's Python copy learned about base ids — so a sheet the builder had just written correctly was reported broken by the app that asked for it: 'harness_id: is "codex". Leave it "" unless you were given a real agent id.' The empty cells. A cell was filled only when the terminal status read exactly "completed". A turn read a moment before its label catches up comes back terminal with its answer already in the record, and the cell threw that answer away and wrote "the turn ended without an answer" over it. An answer is an answer whatever the turn was labelled, so the content decides now and the status only chooses the wording when there is nothing to show. A Stop stays a Stop: partial output under a green tick would read as a success nobody got. Verified on the public VM: the same three rows that failed came back done, with real sentences, and no banner. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01X7VoW2QzfDkxEoudktbjVY --- kits/sheets/app/src/lib/cell.js | 27 +++++++++++++++++---------- kits/sheets/app/src/lib/model.js | 14 ++++++++++---- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/kits/sheets/app/src/lib/cell.js b/kits/sheets/app/src/lib/cell.js index cd94d4f..47b4c5f 100644 --- a/kits/sheets/app/src/lib/cell.js +++ b/kits/sheets/app/src/lib/cell.js @@ -187,13 +187,16 @@ export function makeCellDispatcher({ sheetId, runId, sheetTitle, columns, onCell const base = { ...partial, ended_at: now(), session_id: res?.metadata?.session_id || sessionId }; - if (st === 'completed') { - // "completed" means the agent exited cleanly, which is not the same as answering. A turn - // that produced neither text nor a file did not fill this cell, and saying it did would - // be a green tick over nothing. - if (!value && !artifacts.length) { - return { ...base, status: 'failed', error: 'The agent finished without answering.' }; - } + // A Stop stays a Stop. The person asked for it, and partial output under a green tick + // would read as a success they did not get. + if (st === 'cancelled') return { ...base, status: 'failed', artifacts, error: 'Stopped.' }; + + // AN ANSWER IS AN ANSWER, whatever the turn was LABELLED. A terminal status that is not + // "completed" and yet carries text or a file is what a finished turn looks like when its + // record is read a moment before the label catches up. Throwing that content away is what + // wrote "the turn ended without an answer" into cells whose answer was sitting in the + // very record being read. + if (value || artifacts.length) { return { ...base, status: 'done', @@ -203,13 +206,17 @@ export function makeCellDispatcher({ sheetId, runId, sheetTitle, columns, onCell error: null, }; } - if (st === 'cancelled') return { ...base, status: 'failed', error: 'Stopped.' }; + + // Nothing to show. "completed" means the agent exited cleanly, which is not the same as + // answering, so it says that rather than borrowing a failure message. return { ...base, status: 'failed', artifacts, - error: res?.error?.message - || (st === 'incomplete' ? 'The turn ended without an answer.' : `The turn ${st}.`), + error: st === 'completed' + ? 'The agent finished without answering.' + : (res?.error?.message + || (st === 'incomplete' ? 'The turn ended without an answer.' : `The turn ${st}.`)), }; } } finally { diff --git a/kits/sheets/app/src/lib/model.js b/kits/sheets/app/src/lib/model.js index 958b2cd..8d782d9 100644 --- a/kits/sheets/app/src/lib/model.js +++ b/kits/sheets/app/src/lib/model.js @@ -185,6 +185,12 @@ export function materialize(template, title) { } const CHRN = /^chrn_[0-9a-f]{32}$/; + +// The base agents, whose id IS the base name and which the server accepts as a harness id +// directly. Stable across deployments, unlike a chrn_ id, which is why an agent column may name +// one and why a sheet can arrive already runnable. The picker filters these against what THIS +// deployment reports; validation only has to know the shape is legitimate. +const BASES = new Set(['codex', 'claude-code', 'hermes', 'pi', 'dsh', 'opencode', 'qwen']); const APP_OWNED = ['status', 'run_id', 'response_id', 'session_id', 'artifacts', 'started_at', 'ended_at']; /** Every way a sheet can be wrong, with the fix for each. @@ -238,10 +244,10 @@ export function validate(sheet) { 'Either set "type": "harness" or delete the harness object.'); } if (isHarnessColumn(c) && c.harness) { - const hid = c.harness.harness_id; - if (hid !== '' && hid !== undefined && !CHRN.test(String(hid))) { - err(`${at}.harness.harness_id`, `is ${JSON.stringify(hid)}`, - 'Leave it "" unless you were given a real agent id — you cannot see the list of agents.'); + const hid = String(c.harness.harness_id ?? ''); + if (hid !== '' && !BASES.has(hid) && !CHRN.test(hid)) { + err(`${at}.harness.harness_id`, `is ${JSON.stringify(c.harness.harness_id)}`, + `Name a base agent (${[...BASES].join(', ')}) or use an agent id you were given.`); } const prompt = String(c.harness.prompt || ''); if (!prompt.trim()) { From eda1667225dd42a3c7bd6631cd93a81a102746b3 Mon Sep 17 00:00:00 2001 From: richard-epsilla Date: Sat, 29 Aug 2026 00:39:02 -0700 Subject: [PATCH 3/4] fix(sheets): let a cell's text fill the row it is sitting in An agent cell keeps its files under its answer, which makes the row tall. Every other cell in that row was showing one ellipsised line above a block of empty row, because the line count was one number shared by the whole row. The measuring now happens per cell, in SheetGrid (reifyui 0.11.2). This side is the half the kit owns: the answer text marks itself with data-shg-clamp-text so the grid can tell it apart from the file cards beside it and work out how much room each actually has. NOTE: needs reifyui 0.11.2 on npm. The fix is in that package; this bump is what picks it up. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01X7VoW2QzfDkxEoudktbjVY --- kits/sheets/app/package.json | 2 +- kits/sheets/app/src/components/HarnessCell.jsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/kits/sheets/app/package.json b/kits/sheets/app/package.json index c3ce394..eeb16ed 100644 --- a/kits/sheets/app/package.json +++ b/kits/sheets/app/package.json @@ -15,7 +15,7 @@ "react-dom": "^18.3.1", "react-file-icon": "^1.6.0", "react-markdown": "^9.0.1", - "reifyui": "^0.8.1", + "reifyui": "^0.11.2", "remark-gfm": "^4.0.0", "xlsx": "^0.18.5" }, diff --git a/kits/sheets/app/src/components/HarnessCell.jsx b/kits/sheets/app/src/components/HarnessCell.jsx index 71a8336..a462677 100644 --- a/kits/sheets/app/src/components/HarnessCell.jsx +++ b/kits/sheets/app/src/components/HarnessCell.jsx @@ -69,7 +69,7 @@ export function HarnessCell({ cell, live, readOnly, onRun, onPreviewFile }) { return ( - {cell.error || 'This cell failed.'} + {cell.error || 'This cell failed.'} {tools} ); @@ -77,14 +77,14 @@ export function HarnessCell({ cell, live, readOnly, onRun, onPreviewFile }) { if (status === 'skipped') { return ( - {cell.error || 'Skipped.'} + {cell.error || 'Skipped.'} {tools} ); } return ( - {cell.value} + {cell.value} {tools} From fd798625e3f6742dc6402d2d39655949a84091e8 Mon Sep 17 00:00:00 2001 From: richard-epsilla Date: Sat, 29 Aug 2026 01:11:06 -0700 Subject: [PATCH 4/4] fix(sheets): fill the cell from the kit, so this works on the reifyui we ship with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit asked for reifyui ^0.11.2, which is where the fix belongs but is not on the registry — so `npm ci` failed with ETARGET and the sheets build went red. Doing it here instead makes the fix work against the version already published, and CI green without waiting on a release. --shg-clamp is set per . A custom property resolves from the nearest ancestor, so this wins over the row-level value SheetGrid sets, and it reaches both this kit's answer text and the grid's own value cell. Three things have to be true at once, and each is a trap on its own: - The basis cannot be the RENDERED height. More lines makes the row taller, which allows more lines: measured live the rows climb 92 → 137 → 182 and never settle. Every clamp goes back to one line before measuring, so the basis is the height the row's OTHER content needs — which the clamp cannot influence. - The basis cannot be the . A table cell is stretched to its row and reports the row height for every column, so they all look equally full. Measured on the text's own box, which sizes to its content. - The count cannot be shared by the row. A cell carrying files under its text has far less room than one carrying nothing; one number for both is the bug restated. Verified on the public VM against reifyui 0.8.1: a 137px row gives its file-card column 4 lines plus the card and every other column 7, a 272px row gives 13 and 16, and a second pass computes the same answer rather than ratcheting the rows upward. The same fix is on ReifyUI main for 0.11.2. When that is published this copy should go, so the measuring lives in the grid that owns the markup. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01X7VoW2QzfDkxEoudktbjVY --- kits/sheets/app/package.json | 2 +- kits/sheets/app/src/pages/SheetPage.jsx | 53 ++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/kits/sheets/app/package.json b/kits/sheets/app/package.json index eeb16ed..c3ce394 100644 --- a/kits/sheets/app/package.json +++ b/kits/sheets/app/package.json @@ -15,7 +15,7 @@ "react-dom": "^18.3.1", "react-file-icon": "^1.6.0", "react-markdown": "^9.0.1", - "reifyui": "^0.11.2", + "reifyui": "^0.8.1", "remark-gfm": "^4.0.0", "xlsx": "^0.18.5" }, diff --git a/kits/sheets/app/src/pages/SheetPage.jsx b/kits/sheets/app/src/pages/SheetPage.jsx index 2fa1ee1..e0276bd 100644 --- a/kits/sheets/app/src/pages/SheetPage.jsx +++ b/kits/sheets/app/src/pages/SheetPage.jsx @@ -8,7 +8,7 @@ // The run happens in this tab. There is no workflow engine and no batch endpoint in this // deployment, so the browser is the orchestrator; the UI says that before you press Run and says // exactly what happened if you leave. -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { Download, HelpCircle, Home } from 'lucide-react'; @@ -35,6 +35,10 @@ const SAVE_DEBOUNCE_MS = 400; const STATUS_POLL_MS = 2000; // while a turn is live: the grid fills in as it is written const STATUS_IDLE_MS = 10000; // while nothing is: still notices a turn started elsewhere const RELOAD_POLL_MS = 4000; +// The line height and vertical padding the grid lays a cell out with (reifyui's sheet.css sets +// line-height: 18px). They turn a measured height into a number of lines for --shg-clamp. +const CLAMP_LINE = 18; +const CLAMP_PAD = 10; const now = () => Math.floor(Date.now() / 1000); const escapeRe = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -401,6 +405,53 @@ export function SheetPage({ id: routeId, seed }) { } }, [env, concurrency, commit, dialog]); + // ── how many lines of text a cell has room for ──────────────────────────── + // The grid derives --shg-clamp from a row's STORED height, falling back to 34px — one line — + // for any row never explicitly sized. But a row is usually tall because of ONE column: an agent + // cell keeping its files under its answer. Every other cell then showed a single ellipsised + // line above a block of empty row, and the taller the neighbour, the more space went to waste. + // + // Three things have to be true at once, and each is a trap on its own: + // + // The basis cannot be the RENDERED height. More lines makes the row taller, which allows more + // lines: measured live the rows climb 92 → 137 → 182 and never settle. So every clamp goes + // back to one line before measuring, making the basis the height the row's OTHER content + // needs — a quantity the clamp cannot influence. + // + // The basis cannot be the . A table cell is stretched to its row and reports the row + // height for every column, which makes them all look equally full. The measurement is taken + // on the text's own box, which sizes to its content. + // + // The count cannot be shared by the row. A cell carrying files under its text has far less + // room than one carrying nothing, and one number for both is the bug restated. + // + // Set on each : a custom property resolves from the nearest ancestor, so this wins over the + // row-level value the grid sets, for both this kit's answer text and the grid's own value cell. + useLayoutEffect(() => { + const trs = gridRef.current?.querySelectorAll('tbody tr[data-row-id]'); + if (!trs?.length) return; + const rows = [...trs]; + for (const tr of rows) for (const td of tr.children) td.style.setProperty('--shg-clamp', '1'); + const plan = rows.map((tr) => ({ + base: tr.offsetHeight, // one reflow, every clamp at its floor + cells: [...tr.children].map((td) => { + const txt = td.querySelector('[data-shg-clamp-text]'); + const box = txt?.parentElement; + return { td, extra: box ? Math.max(0, box.scrollHeight - txt.offsetHeight) : 0 }; + }), + })); + for (const { base, cells } of plan) { + const floorLines = Math.max(1, Math.floor((base - CLAMP_PAD) / CLAMP_LINE)); + // The height the row needs for the cell carrying the most beside its text to still get the + // row's baseline count. Every other cell then fills THAT, which is the whole point. + const need = Math.max(...cells.map((c) => c.extra)) + floorLines * CLAMP_LINE + CLAMP_PAD; + for (const { td, extra } of cells) { + td.style.setProperty('--shg-clamp', String(Math.max(floorLines, + Math.floor((need - extra - CLAMP_PAD) / CLAMP_LINE)))); + } + } + }); + const stopRun = useCallback(() => { runnerRef.current?.stop(); }, []); // Leaving mid-run stops the walk. Say so with the platform's own guard rather than inventing a