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:
+157
-104
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user