Thesis Workshop redesign: edit/choose/delete + approve-as-current (v0.1.0:56)

Addresses Grant's feedback that the Workshop was confusing and underbuilt (no delete,
no approve, redundant generate-vs-feedback panels, and a stray "0" on segment lines).

Backend (architect_tools.py + server.py routes/handlers):
- retire_node: soft-delete a node + its subtree (reversible). DELETE /api/thesis/nodes/{id}.
- choose_variant: 'Use this' — keep this option, soft-delete the others in its group,
  mark it approved. POST /api/thesis/nodes/{id}/choose.
- upsert_thesis_node gains actor_type so a manual human edit is recorded as 'human'.
  PUT /api/thesis/nodes/{id} edits a part's text directly.
- handle_approve_line: one-click 'approve as current' — records this admin's approval on
  the line's in-review version (creating + submitting one from the live tree if none),
  promoting to canonical at the required distinct-approval count. POST /api/thesis/lines/{key}/approve.

Frontend (ThesisWorkshop redesign):
- Merged the redundant "Generate options" + "Give feedback" panels into one "Ask the
  Architect for options" box (revise was just generate-with-guidance).
- Per option: Use this / Edit (inline) / Delete. Per part: edit + delete via the same.
- "Approve as current" bar with dual-sign-off state + a "Current ✓" badge, and a one-line
  "how it works" hint. Refreshes the tree after every action.
- Fixed the stray "0": `{line.is_core && <badge>}` rendered 0 for non-core lines (SQLite
  integer 0); now `{!!line.is_core && ...}`.

Verified: backend test_thesis_actions.py (choose/edit/retire-subtree/dual-approval->canonical),
and a live in-browser smoke test (JSX compiles, Workshop renders, options show Use/Edit/Delete,
approve returns 1-of-2, no runtime errors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Keysat
2026-06-05 18:29:47 -05:00
parent 8338c34ac0
commit 6d6f4bcc7e
7 changed files with 389 additions and 110 deletions
+157 -104
View File
@@ -9228,7 +9228,7 @@
<div>
<div className="thesis-line-name">
{line.name}
{line.is_core && <span className="badge badge-investor" style={{ marginLeft: '8px' }}>Core</span>}
{!!line.is_core && <span className="badge badge-investor" style={{ marginLeft: '8px' }}>Core</span>}
</div>
<div className="thesis-line-meta">{line.line_key}{line.segment_key ? ` · ${line.segment_key}` : ''}</div>
</div>
@@ -9318,7 +9318,7 @@
// Renders a single node's competing options (its variant group). The
// number of options is whatever the server returns — could be 0, 1, or
// many — and is rendered as a dynamic list, never a fixed A/B layout.
const ThesisWorkshopOptions = ({ token, node, lineKey, isAdmin, architectReady, onShowToast }) => {
const ThesisWorkshopOptions = ({ token, node, lineKey, isAdmin, architectReady, onShowToast, onTreeChanged }) => {
const nodeId = node.id;
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
@@ -9326,9 +9326,9 @@
const [genN, setGenN] = useState(3);
const [guidance, setGuidance] = useState('');
const [generating, setGenerating] = useState(false);
const [feedbackN, setFeedbackN] = useState(2);
const [feedback, setFeedback] = useState('');
const [sendingFeedback, setSendingFeedback] = useState(false);
const [editingId, setEditingId] = useState(null);
const [editText, setEditText] = useState('');
const [busyId, setBusyId] = useState(null);
const load = useCallback(async () => {
try {
@@ -9356,9 +9356,9 @@
body: JSON.stringify({ n, guidance: guidance.trim() })
}, token);
const count = result?.data?.generated ?? (result?.data?.options || []).length;
onShowToast(`Generated ${count} option${count === 1 ? '' : 's'}`, 'success');
onShowToast(`The Architect drafted ${count} option${count === 1 ? '' : 's'}`, 'success');
setGuidance('');
await load();
await refresh();
} catch (err) {
if (isArchitectMissingKeyError(err)) {
onShowToast(ARCHITECT_CONNECT_HINT, 'error');
@@ -9370,72 +9370,98 @@
}
};
const handleFeedback = async (e) => {
e.preventDefault();
if (!isAdmin || sendingFeedback) return;
if (!feedback.trim()) {
onShowToast('Add some feedback for the Architect to address', 'error');
return;
}
const n = Math.max(1, Math.min(8, parseInt(feedbackN, 10) || 1));
const refresh = async () => { await load(); if (onTreeChanged) onTreeChanged(); };
const chooseOption = async (optId) => {
if (busyId) return;
try {
setSendingFeedback(true);
const result = await api(`/api/thesis/nodes/${nodeId}/feedback`, {
method: 'POST',
body: JSON.stringify({ feedback: feedback.trim(), n })
}, token);
const count = result?.data?.generated ?? (result?.data?.options || []).length;
onShowToast(`Revised — ${count} new option${count === 1 ? '' : 's'}`, 'success');
setFeedback('');
await load();
} catch (err) {
if (isArchitectMissingKeyError(err)) {
onShowToast(ARCHITECT_CONNECT_HINT, 'error');
} else {
onShowToast(getErrorMessage(err, 'Failed to send feedback'), 'error');
}
} finally {
setSendingFeedback(false);
}
setBusyId(optId);
await api(`/api/thesis/nodes/${optId}/choose`, { method: 'POST' }, token);
onShowToast('Set as the chosen wording', 'success');
await refresh();
} catch (err) { onShowToast(getErrorMessage(err, 'Could not choose this option'), 'error'); }
finally { setBusyId(null); }
};
const deleteOption = async (optId) => {
if (busyId) return;
if (!window.confirm('Delete this and anything under it? This can be undone by an admin.')) return;
try {
setBusyId(optId);
await api(`/api/thesis/nodes/${optId}`, { method: 'DELETE' }, token);
onShowToast('Deleted', 'success');
await refresh();
} catch (err) { onShowToast(getErrorMessage(err, 'Could not delete'), 'error'); }
finally { setBusyId(null); }
};
const saveEdit = async (optId) => {
if (busyId) return;
try {
setBusyId(optId);
await api(`/api/thesis/nodes/${optId}`, { method: 'PUT', body: JSON.stringify({ body: editText }) }, token);
setEditingId(null);
onShowToast('Saved', 'success');
await refresh();
} catch (err) { onShowToast(getErrorMessage(err, 'Could not save'), 'error'); }
finally { setBusyId(null); }
};
if (loading) return <div style={{ padding: '4px 0' }}><SkeletonBlock lines={3} /></div>;
if (error) return <div className="toast error" style={{ position: 'static' }}>{error}</div>;
const options = Array.isArray(data?.variants) ? data.variants : [];
const busy = generating || sendingFeedback;
const multi = options.length > 1;
const busy = generating;
return (
<div>
{options.length === 0 ? (
<div className="empty-state" style={{ padding: '18px 0' }}>
No options yet for this part.
{architectReady ? ' Generate some below.' : ' Connect the Architect to generate drafts, or edit the thesis directly.'}
No wording yet for this part.{isAdmin && architectReady ? ' Ask the Architect below, or write your own.' : ''}
</div>
) : (
<div className="thesis-ws-options">
{options.map((opt, i) => {
const meta = opt.meta || {};
const rationale = meta.rationale || meta.reason || meta.notes || '';
const text = opt.body || opt.title || '';
const editing = editingId === opt.id;
const oBusy = busyId === opt.id;
return (
<div key={opt.id ?? i} className="thesis-ws-option">
<div className="thesis-ws-option-head">
<span className="thesis-ws-option-num">Option {i + 1} of {options.length}</span>
<span className="thesis-ws-option-num">{multi ? `Option ${i + 1} of ${options.length}` : 'Current wording'}</span>
{opt.status && (
<span className={`badge ${thesisStatusClass(opt.status)}`}>{formatThesisStatus(opt.status)}</span>
)}
</div>
{opt.title && opt.body && opt.title !== opt.body && (
<div className="thesis-ws-node-title" style={{ marginBottom: '6px' }}>{opt.title}</div>
{editing ? (
<textarea className="text-input" rows="5" value={editText} onChange={(e) => setEditText(e.target.value)} />
) : (
<div className="thesis-ws-option-text">{opt.body || '—'}</div>
)}
<div className="thesis-ws-option-text">{text || '—'}</div>
{rationale && (
{rationale && !editing && (
<div className="thesis-ws-rationale">
<div className="thesis-ws-rationale-label">Architect's rationale</div>
{rationale}
</div>
)}
{isAdmin && (
<div style={{ display: 'flex', gap: '8px', marginTop: '8px', flexWrap: 'wrap' }}>
{editing ? (
<>
<button type="button" disabled={oBusy} onClick={() => saveEdit(opt.id)}>{oBusy ? 'Saving…' : 'Save'}</button>
<button type="button" className="button-secondary" onClick={() => setEditingId(null)}>Cancel</button>
</>
) : (
<>
{multi && <button type="button" disabled={oBusy} onClick={() => chooseOption(opt.id)}>Use this</button>}
<button type="button" className="button-secondary" disabled={oBusy} onClick={() => { setEditingId(opt.id); setEditText(opt.body || ''); }}>Edit</button>
<button type="button" className="button-danger" disabled={oBusy} onClick={() => deleteOption(opt.id)}>Delete</button>
</>
)}
</div>
)}
</div>
);
})}
@@ -9443,69 +9469,38 @@
)}
{isAdmin ? (
<div className="thesis-ws-controls">
<form className="thesis-ws-control-col" onSubmit={handleGenerate}>
<div className="thesis-block-label">Generate options</div>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
<input
type="number"
className="text-input thesis-ws-n-input"
min="1"
max="8"
value={genN}
onChange={(e) => setGenN(e.target.value)}
aria-label="Number of options to generate"
disabled={busy}
/>
<span className="form-help" style={{ marginTop: 0 }}>option(s)</span>
</div>
<textarea
className="text-input"
rows="3"
placeholder="Optional guidance — angle, tone, what to emphasize…"
value={guidance}
onChange={(e) => setGuidance(e.target.value)}
<form className="thesis-ws-control-col" onSubmit={handleGenerate} style={{ marginTop: '14px' }}>
<div className="thesis-block-label">Ask the Architect for options</div>
<textarea
className="text-input"
rows="3"
placeholder="Optional — tell it what to try: sharper, shorter, a different angle, answer a specific objection…"
value={guidance}
onChange={(e) => setGuidance(e.target.value)}
disabled={busy}
/>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center', flexWrap: 'wrap' }}>
<input
type="number"
className="text-input thesis-ws-n-input"
min="1"
max="8"
value={genN}
onChange={(e) => setGenN(e.target.value)}
aria-label="How many options"
disabled={busy}
/>
<div>
<button type="submit" disabled={busy || !architectReady}>
{generating ? 'Architect is drafting…' : 'Generate'}
</button>
</div>
{!architectReady && (
<div className="form-help">Generation needs the Architect connected.</div>
)}
</form>
<form className="thesis-ws-control-col" onSubmit={handleFeedback}>
<div className="thesis-block-label">Give feedback</div>
<textarea
className="text-input"
rows="3"
placeholder="Tell the Architect what to change, then it revises into new option drafts."
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
disabled={busy}
/>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
<input
type="number"
className="text-input thesis-ws-n-input"
min="1"
max="8"
value={feedbackN}
onChange={(e) => setFeedbackN(e.target.value)}
aria-label="Number of revised options"
disabled={busy}
/>
<button type="submit" disabled={busy || !architectReady}>
{sendingFeedback ? 'Architect is revising…' : 'Revise'}
</button>
</div>
</form>
</div>
<span className="form-help" style={{ marginTop: 0 }}>option(s)</span>
<button type="submit" disabled={busy || !architectReady}>
{generating ? 'Architect is drafting…' : 'Generate'}
</button>
</div>
{!architectReady && (
<div className="form-help">Generation needs the Architect connected.</div>
)}
</form>
) : (
<div className="form-help" style={{ marginTop: '14px' }}>Only admins can generate or revise options.</div>
<div className="form-help" style={{ marginTop: '14px' }}>Only admins can edit, choose, or generate options.</div>
)}
</div>
);
@@ -9513,7 +9508,7 @@
// One node in the tree. Renders its header (click to select + reveal its
// options), its options when selected, then recurses into children.
const ThesisWorkshopNode = ({ token, node, lineKey, depth, isAdmin, architectReady, selectedId, onSelect, onShowToast }) => {
const ThesisWorkshopNode = ({ token, node, lineKey, depth, isAdmin, architectReady, selectedId, onSelect, onShowToast, onTreeChanged }) => {
const isSelected = selectedId === node.id;
const children = Array.isArray(node.children) ? node.children : [];
const variantGroup = node.variant_group;
@@ -9545,6 +9540,7 @@
isAdmin={isAdmin}
architectReady={architectReady}
onShowToast={onShowToast}
onTreeChanged={onTreeChanged}
/>
</div>
)}
@@ -9563,6 +9559,7 @@
selectedId={selectedId}
onSelect={onSelect}
onShowToast={onShowToast}
onTreeChanged={onTreeChanged}
/>
))}
</div>
@@ -9585,6 +9582,8 @@
const [treeTick, setTreeTick] = useState(0);
const [architect, setArchitect] = useState(null);
const [canonical, setCanonical] = useState(null);
const [approving, setApproving] = useState(false);
// Seed-a-line form (shown when there are no lines).
const [showNewLine, setShowNewLine] = useState(false);
@@ -9651,6 +9650,42 @@
return () => { cancelled = true; };
}, [token, selectedLineKey, treeTick]);
// Is the selected line already someone's current (canonical) thesis?
useEffect(() => {
if (!selectedLineKey) { setCanonical(null); return undefined; }
let cancelled = false;
(async () => {
try {
const r = await api(`/api/thesis/${selectedLineKey}/canonical`, {}, token);
if (!cancelled) setCanonical(r.data || r);
} catch (err) {
if (!cancelled) setCanonical(null);
}
})();
return () => { cancelled = true; };
}, [token, selectedLineKey, treeTick]);
const handleApproveLine = async () => {
if (!isAdmin || approving || !selectedLineKey) return;
try {
setApproving(true);
const r = await api(`/api/thesis/lines/${selectedLineKey}/approve`, { method: 'POST' }, token);
const d = r.data || r;
if (d.promoted_to_canonical) {
onShowToast('Approved — this is now your current thesis.', 'success');
} else {
const left = Math.max(0, (d.required || 2) - (d.approvals || 0));
onShowToast(`Approved (${d.approvals} of ${d.required}). ${left} more admin approval${left === 1 ? '' : 's'} needed.`, 'success');
}
setTreeTick((t) => t + 1);
await loadLines();
} catch (err) {
onShowToast(getErrorMessage(err, 'Could not approve'), 'error');
} finally {
setApproving(false);
}
};
const handleCreateLine = async (e) => {
e.preventDefault();
if (!isAdmin || creatingLine) return;
@@ -9750,7 +9785,7 @@
<div style={{ minWidth: 0 }}>
<div className="thesis-line-name">
{line.name}
{line.is_core && <span className="badge badge-investor" style={{ marginLeft: '8px' }}>Core</span>}
{!!line.is_core && <span className="badge badge-investor" style={{ marginLeft: '8px' }}>Core</span>}
</div>
<div className="thesis-line-meta">{line.line_key}{line.segment_key ? ` · ${line.segment_key}` : ''}</div>
</div>
@@ -9816,6 +9851,23 @@
<div className="thesis-col">
<div className="section">
<div className="section-title">Workshop</div>
{selectedLineKey && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px', flexWrap: 'wrap', marginBottom: '14px', paddingBottom: '12px', borderBottom: '1px solid #1e2a3a' }}>
<div className="form-help" style={{ marginTop: 0, maxWidth: '60%' }}>
Click any part to draft and compare wordings, pick the one you like, then approve the line as your current thesis.
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
{canonical && canonical.status === 'ok' && (
<span className="badge badge-investor">Current ✓{canonical.version_no ? ` · v${canonical.version_no}` : ''}</span>
)}
{isAdmin && (
<button type="button" disabled={approving} onClick={handleApproveLine}>
{approving ? 'Approving…' : (canonical && canonical.status === 'ok' ? 'Re-approve as current' : 'Approve as current')}
</button>
)}
</div>
</div>
)}
{!selectedLineKey ? (
<div className="empty-state" style={{ padding: '24px 0' }}>
<div className="empty-state-icon"></div>
@@ -9846,6 +9898,7 @@
selectedId={selectedNodeId}
onSelect={setSelectedNodeId}
onShowToast={onShowToast}
onTreeChanged={() => setTreeTick((t) => t + 1)}
/>
))}
</div>