Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions kits/sheets/app/src/components/HarnessCell.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,22 +69,22 @@ export function HarnessCell({ cell, live, readOnly, onRun, onPreviewFile }) {
return (
<span className="hcell failed" title={cell.error || ''}>
<span className="hcell-dot failed" />
<span className="hcell-txt">{cell.error || 'This cell failed.'}</span>
<span className="hcell-txt" data-shg-clamp-text>{cell.error || 'This cell failed.'}</span>
{tools}
</span>
);
}
if (status === 'skipped') {
return (
<span className="hcell skipped" title={cell.error || ''}>
<span className="hcell-txt">{cell.error || 'Skipped.'}</span>
<span className="hcell-txt" data-shg-clamp-text>{cell.error || 'Skipped.'}</span>
{tools}
</span>
);
}
return (
<span className="hcell">
<span className="hcell-txt">{cell.value}</span>
<span className="hcell-txt" data-shg-clamp-text>{cell.value}</span>
<Artifacts artifacts={cell.artifacts} onPreview={onPreviewFile} />
{tools}
</span>
Expand Down
26 changes: 19 additions & 7 deletions kits/sheets/app/src/components/HarnessConfig.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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}
/>


Expand Down
27 changes: 17 additions & 10 deletions kits/sheets/app/src/lib/cell.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 {
Expand Down
14 changes: 10 additions & 4 deletions kits/sheets/app/src/lib/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()) {
Expand Down
57 changes: 52 additions & 5 deletions kits/sheets/app/src/lib/sh.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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,
Expand All @@ -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) ────────────────────────────────────────────────────
Expand Down
87 changes: 82 additions & 5 deletions kits/sheets/app/src/pages/SheetPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,16 @@
// 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';
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,
Expand All @@ -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, '\\$&');
Expand Down Expand Up @@ -220,12 +224,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('?');
Expand Down Expand Up @@ -375,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 <td>. 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 <td>: 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
Expand Down
Loading
Loading