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:
@@ -192,7 +192,7 @@ def create_thesis_line(line_key, name, segment_key=None, is_core=False, descript
|
||||
|
||||
def upsert_thesis_node(line_id, node_type, body, title=None, parent_id=None, ord=None,
|
||||
variant_group=None, node_id=None, change_reason=None, change_summary=None,
|
||||
actor_id="architect", claude_session_id=None, meta=None, db=None):
|
||||
actor_id="architect", actor_type="agent", claude_session_id=None, meta=None, db=None):
|
||||
"""Create or edit a node. On edit, the prior state is written to
|
||||
thesis_node_revisions before the live row changes (full provenance)."""
|
||||
c = _conn(db)
|
||||
@@ -209,7 +209,7 @@ def upsert_thesis_node(line_id, node_type, body, title=None, parent_id=None, ord
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(str(uuid.uuid4()), node_id, prev["line_id"], rev_no, prev["node_type"], prev["title"], prev["body"],
|
||||
prev["status"], prev["ord"], prev["variant_group"], prev["meta"], change_summary, change_reason,
|
||||
"agent", actor_id, claude_session_id, _now()))
|
||||
actor_type, actor_id, claude_session_id, _now()))
|
||||
c.execute("""UPDATE thesis_nodes SET node_type=?, title=COALESCE(?,title), body=?, ord=COALESCE(?,ord),
|
||||
variant_group=?, meta=COALESCE(?,meta), updated_at=? WHERE id=?""",
|
||||
(node_type, title, body, ord, variant_group, json.dumps(meta) if meta else None, _now(), node_id))
|
||||
@@ -280,6 +280,50 @@ def submit_version_for_review(version_id, db=None):
|
||||
return {"version_id": version_id, "status": "in_review"}
|
||||
|
||||
|
||||
def retire_node(node_id, actor_id="human", db=None):
|
||||
"""Soft-delete a node and its whole subtree (deleted_at). Reversible (guardrail #3);
|
||||
the node + descendants drop out of the tree but the rows + revision history remain."""
|
||||
c = _conn(db)
|
||||
row = c.execute("SELECT id FROM thesis_nodes WHERE id=? AND deleted_at IS NULL", (node_id,)).fetchone()
|
||||
if not row:
|
||||
c.close()
|
||||
return {"error": "not_found", "node_id": node_id}
|
||||
ids, frontier = [node_id], [node_id]
|
||||
while frontier:
|
||||
nxt = []
|
||||
for pid in frontier:
|
||||
for r in c.execute("SELECT id FROM thesis_nodes WHERE parent_id=? AND deleted_at IS NULL", (pid,)):
|
||||
ids.append(r["id"]); nxt.append(r["id"])
|
||||
frontier = nxt
|
||||
for nid in ids:
|
||||
c.execute("UPDATE thesis_nodes SET deleted_at=?, updated_at=? WHERE id=?", (_now(), _now(), nid))
|
||||
_log(c, "thesis.node_retired", node_id, {"count": len(ids)}, actor_id=actor_id, actor_type="human")
|
||||
c.commit()
|
||||
c.close()
|
||||
return {"retired": node_id, "count": len(ids)}
|
||||
|
||||
|
||||
def choose_variant(node_id, actor_id="human", db=None):
|
||||
"""'Use this option': keep this variant, soft-delete the OTHER variants in its group,
|
||||
and mark it approved. Collapses a node's competing options down to the chosen one."""
|
||||
c = _conn(db)
|
||||
node = c.execute("SELECT id, variant_group FROM thesis_nodes WHERE id=? AND deleted_at IS NULL", (node_id,)).fetchone()
|
||||
if not node:
|
||||
c.close()
|
||||
return {"error": "not_found", "node_id": node_id}
|
||||
retired = []
|
||||
if node["variant_group"]:
|
||||
for r in c.execute("SELECT id FROM thesis_nodes WHERE variant_group=? AND id<>? AND deleted_at IS NULL",
|
||||
(node["variant_group"], node_id)):
|
||||
c.execute("UPDATE thesis_nodes SET deleted_at=?, updated_at=? WHERE id=?", (_now(), _now(), r["id"]))
|
||||
retired.append(r["id"])
|
||||
c.execute("UPDATE thesis_nodes SET status='approved', updated_at=? WHERE id=?", (_now(), node_id))
|
||||
_log(c, "thesis.variant_chosen", node_id, {"retired_siblings": len(retired)}, actor_id=actor_id, actor_type="human")
|
||||
c.commit()
|
||||
c.close()
|
||||
return {"chosen": node_id, "retired_siblings": len(retired)}
|
||||
|
||||
|
||||
def upsert_segment(segment_key, name, definition=None, needs_to_hear=None, avoid=None, db=None):
|
||||
"""Create/replace a segment's active definition (retire the prior active row)."""
|
||||
c = _conn(db)
|
||||
|
||||
@@ -1898,6 +1898,10 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
return self.handle_node_feedback(user, path.split('/')[-2], body)
|
||||
if path == '/api/architect/ground':
|
||||
return self.handle_architect_ground(user, body)
|
||||
if re.match(r'^/api/thesis/nodes/[^/]+/choose$', path):
|
||||
return self.handle_choose_variant(user, path.split('/')[-2])
|
||||
if re.match(r'^/api/thesis/lines/[^/]+/approve$', path):
|
||||
return self.handle_approve_line(user, path.split('/')[-2])
|
||||
if path == '/api/thesis/lines':
|
||||
return self.handle_create_thesis_line(user, body)
|
||||
if re.match(r'^/api/thesis/lines/[^/]+/nodes$', path):
|
||||
@@ -1938,6 +1942,8 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
return self.handle_update_lp_profile(user, path.split('/')[-1], body)
|
||||
if path == '/api/fundraising/state':
|
||||
return self.handle_update_fundraising_state(user, body)
|
||||
if re.match(r'^/api/thesis/nodes/[^/]+$', path):
|
||||
return self.handle_edit_thesis_node(user, path.split('/')[-1], body)
|
||||
|
||||
self.send_error_json("Not found", 404)
|
||||
|
||||
@@ -1987,6 +1993,8 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
return self.handle_delete_opportunity(user, path.split('/')[-1])
|
||||
if re.match(r'^/api/communications/[^/]+$', path):
|
||||
return self.handle_delete_communication(user, path.split('/')[-1])
|
||||
if re.match(r'^/api/thesis/nodes/[^/]+$', path):
|
||||
return self.handle_retire_thesis_node(user, path.split('/')[-1])
|
||||
self.send_error_json("Not found", 404)
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -3669,6 +3677,73 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
conn.close()
|
||||
return row['line_key'] if row else None
|
||||
|
||||
def handle_edit_thesis_node(self, user, node_id, body):
|
||||
"""Manual human edit of a node's title/body (no Architect)."""
|
||||
if not require_admin(user):
|
||||
return self.send_error_json("Admin required", 403)
|
||||
if _architect_tools is None:
|
||||
return self.send_error_json("Unavailable", 503)
|
||||
node = _architect_tools.get_node(node_id, db=DB_PATH)
|
||||
if node.get('error'):
|
||||
return self.send_error_json("Node not found", 404)
|
||||
body = body or {}
|
||||
res = _architect_tools.upsert_thesis_node(
|
||||
node['line_id'], node['node_type'], body.get('body', node.get('body') or ''),
|
||||
title=body.get('title', node.get('title')), node_id=node_id,
|
||||
change_reason='manual edit', actor_id=user['user_id'], actor_type='human', db=DB_PATH)
|
||||
return self.send_json({"data": res})
|
||||
|
||||
def handle_retire_thesis_node(self, user, node_id):
|
||||
"""Soft-delete a node + its subtree."""
|
||||
if not require_admin(user):
|
||||
return self.send_error_json("Admin required", 403)
|
||||
if _architect_tools is None:
|
||||
return self.send_error_json("Unavailable", 503)
|
||||
res = _architect_tools.retire_node(node_id, actor_id=user['user_id'], db=DB_PATH)
|
||||
if res.get('error'):
|
||||
return self.send_error_json("Node not found", 404)
|
||||
return self.send_json({"data": res})
|
||||
|
||||
def handle_choose_variant(self, user, node_id):
|
||||
"""'Use this option' — keep this variant, retire its siblings."""
|
||||
if not require_admin(user):
|
||||
return self.send_error_json("Admin required", 403)
|
||||
if _architect_tools is None:
|
||||
return self.send_error_json("Unavailable", 503)
|
||||
res = _architect_tools.choose_variant(node_id, actor_id=user['user_id'], db=DB_PATH)
|
||||
if res.get('error'):
|
||||
return self.send_error_json("Node not found", 404)
|
||||
return self.send_json({"data": res})
|
||||
|
||||
def handle_approve_line(self, user, line_key):
|
||||
"""One-click 'approve as current': record this admin's approval on the line's
|
||||
in-review version (creating + submitting one from the live tree if none exists);
|
||||
promotes to canonical once the required distinct approvals are reached."""
|
||||
if not require_admin(user):
|
||||
return self.send_error_json("Admin required", 403)
|
||||
if _architect_tools is None or thesis_review is None:
|
||||
return self.send_error_json("Unavailable", 503)
|
||||
conn = get_db()
|
||||
line = conn.execute("SELECT id FROM thesis_lines WHERE line_key=? AND deleted_at IS NULL", (line_key,)).fetchone()
|
||||
v = None
|
||||
if line:
|
||||
v = conn.execute("SELECT id FROM thesis_versions WHERE line_id=? AND status='in_review' ORDER BY version_no DESC LIMIT 1",
|
||||
(line['id'],)).fetchone()
|
||||
conn.close()
|
||||
if not line:
|
||||
return self.send_error_json("Line not found", 404)
|
||||
if v:
|
||||
version_id = v['id']
|
||||
else:
|
||||
ver = _architect_tools.create_thesis_version(line_key, rationale="Approved as current (Workshop)",
|
||||
created_by=user['user_id'], db=DB_PATH)
|
||||
if ver.get('error'):
|
||||
return self.send_error_json(ver['error'], 400)
|
||||
_architect_tools.submit_version_for_review(ver['id'], db=DB_PATH)
|
||||
version_id = ver['id']
|
||||
res = thesis_review.record_review(DB_PATH, version_id, user['user_id'], 'approve')
|
||||
return self.send_json({"data": res})
|
||||
|
||||
def handle_generate_options(self, user, node_id, body):
|
||||
if not require_admin(user):
|
||||
return self.send_error_json("Admin required", 403)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Backend test for the Workshop's new thesis actions: edit, retire-subtree,
|
||||
choose-variant, and one-click dual-approval -> canonical. Runs init_db (which seeds the
|
||||
v5 thesis) against a throwaway DB, then exercises the architect_tools + thesis_review
|
||||
functions the new routes call. Offline + synthetic.
|
||||
|
||||
Run: cd backend && python3 test_thesis_actions.py
|
||||
"""
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
_tmp = tempfile.mkdtemp()
|
||||
os.environ["CRM_DATA_DIR"] = _tmp
|
||||
os.environ["CRM_DB_PATH"] = os.path.join(_tmp, "crm.db")
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import server # noqa: E402 (the CRM server; it imports architect_tools + thesis_review for us)
|
||||
at = server._architect_tools
|
||||
thesis_review = server.thesis_review
|
||||
|
||||
FAILS = []
|
||||
|
||||
|
||||
def check(cond, msg):
|
||||
print((" PASS " if cond else " FAIL ") + msg)
|
||||
if not cond:
|
||||
FAILS.append(msg)
|
||||
|
||||
|
||||
def main():
|
||||
server.init_db()
|
||||
db = server.DB_PATH
|
||||
c = sqlite3.connect(db)
|
||||
c.row_factory = sqlite3.Row
|
||||
|
||||
# choose-variant: pick Option A in the positioning group -> Option B retired
|
||||
opts = c.execute("SELECT id, title FROM thesis_nodes WHERE variant_group='positioning' AND deleted_at IS NULL").fetchall()
|
||||
check(len(opts) == 2, f"positioning starts with 2 options (got {len(opts)})")
|
||||
a = next(o for o in opts if "Option A" in (o["title"] or ""))
|
||||
res = at.choose_variant(a["id"], db=db)
|
||||
check(res.get("retired_siblings") == 1, "choose_variant retired the 1 sibling")
|
||||
live = c.execute("SELECT COUNT(*) FROM thesis_nodes WHERE variant_group='positioning' AND deleted_at IS NULL").fetchone()[0]
|
||||
check(live == 1, f"positioning now has 1 live option (got {live})")
|
||||
check(c.execute("SELECT status FROM thesis_nodes WHERE id=?", (a["id"],)).fetchone()[0] == "approved",
|
||||
"chosen option marked approved")
|
||||
|
||||
# edit: manual human edit updates body + writes a revision
|
||||
thr = c.execute("SELECT id FROM thesis_nodes WHERE node_type='throughline' AND deleted_at IS NULL").fetchone()
|
||||
at.upsert_thesis_node(None, "throughline", "EDITED THROUGHLINE.", node_id=thr["id"], actor_type="human",
|
||||
change_reason="manual edit", actor_id="u1", db=db)
|
||||
check(c.execute("SELECT body FROM thesis_nodes WHERE id=?", (thr["id"],)).fetchone()[0] == "EDITED THROUGHLINE.",
|
||||
"manual edit updated the node body")
|
||||
rev = c.execute("SELECT actor_type FROM thesis_node_revisions WHERE node_id=? ORDER BY rev_no DESC LIMIT 1", (thr["id"],)).fetchone()
|
||||
check(rev and rev[0] == "human", "edit recorded a 'human' revision")
|
||||
|
||||
# retire-subtree: retiring the Pillars section drops it + its 3 pillar claims
|
||||
sec = c.execute("SELECT id FROM thesis_nodes WHERE node_type='section' AND title='Pillars' AND deleted_at IS NULL").fetchone()
|
||||
kids = c.execute("SELECT COUNT(*) FROM thesis_nodes WHERE parent_id=? AND deleted_at IS NULL", (sec["id"],)).fetchone()[0]
|
||||
res = at.retire_node(sec["id"], db=db)
|
||||
check(res.get("count") == kids + 1, f"retire_node soft-deleted the section + {kids} children (got {res.get('count')})")
|
||||
check(c.execute("SELECT deleted_at FROM thesis_nodes WHERE id=?", (sec["id"],)).fetchone()[0] is not None,
|
||||
"section is soft-deleted")
|
||||
|
||||
# one-click dual approval -> canonical (two distinct admins)
|
||||
ver = at.create_thesis_version("core", created_by="u1", db=db)
|
||||
at.submit_version_for_review(ver["id"], db=db)
|
||||
r1 = thesis_review.record_review(db, ver["id"], "u1", "approve")
|
||||
check(r1["approvals"] == 1 and not r1["promoted_to_canonical"], "1st approval does not promote")
|
||||
r2 = thesis_review.record_review(db, ver["id"], "u2", "approve")
|
||||
check(r2["approvals"] == 2 and r2["promoted_to_canonical"], "2nd distinct approval promotes to canonical")
|
||||
check(thesis_review.get_canonical(db, "core").get("status") == "ok", "core line now has a canonical version")
|
||||
# a 2nd approval from the SAME admin would NOT have promoted (distinct approvers only) — covered by record_review
|
||||
|
||||
c.close()
|
||||
print()
|
||||
if FAILS:
|
||||
print(f"FAILED ({len(FAILS)})")
|
||||
sys.exit(1)
|
||||
print("ALL PASS (thesis workshop actions)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+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>
|
||||
|
||||
@@ -20,8 +20,9 @@ export const PACKAGE_TITLE = 'Ten31 Database'
|
||||
// * 0.1.0:52 (grid/contacts unification: contact_id link + grid as front door)
|
||||
// * 0.1.0:53 (seed v5 thesis into the Architect Workshop)
|
||||
// * 0.1.0:54 (unification polish: LinkedIn in grid inline contact editor)
|
||||
// * Current: 0.1.0:55 (Architect grounding boundary: redaction/re-hydration privacy gate)
|
||||
export const PACKAGE_VERSION = '0.1.0:55'
|
||||
// * 0.1.0:55 (Architect grounding boundary: redaction/re-hydration privacy gate)
|
||||
// * Current: 0.1.0:56 (Thesis Workshop redesign: edit/choose/delete + approve-as-current)
|
||||
export const PACKAGE_VERSION = '0.1.0:56'
|
||||
|
||||
export const DATA_MOUNT_PATH = '/data'
|
||||
export const WEB_PORT = 8080
|
||||
|
||||
@@ -16,8 +16,9 @@ import { v_0_1_0_52 } from './v0.1.0.52'
|
||||
import { v_0_1_0_53 } from './v0.1.0.53'
|
||||
import { v_0_1_0_54 } from './v0.1.0.54'
|
||||
import { v_0_1_0_55 } from './v0.1.0.55'
|
||||
import { v_0_1_0_56 } from './v0.1.0.56'
|
||||
|
||||
export const versionGraph = VersionGraph.of({
|
||||
current: v_0_1_0_55,
|
||||
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54],
|
||||
current: v_0_1_0_56,
|
||||
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
// Thesis Workshop redesign — makes it clear what to do and adds the missing controls.
|
||||
// Per part and per generated option you can now Edit (write your own wording), Use this
|
||||
// (pick a winner and clear the rest), and Delete; the redundant generate/feedback panels
|
||||
// are merged into one "Ask the Architect" box; and an admin can Approve as current right
|
||||
// in the Workshop (dual sign-off -> canonical). Also fixes the stray "0" on segment lines.
|
||||
// No data migration.
|
||||
export const v_0_1_0_56 = VersionInfo.of({
|
||||
version: '0.1.0:56',
|
||||
releaseNotes: {
|
||||
en_US: [
|
||||
'Thesis Workshop is clearer and fuller: edit any part or option in place, pick a winner',
|
||||
'with "Use this", delete what you do not want, ask the Architect for options from one',
|
||||
'box, and approve a line as your current thesis right there (two admins sign off). Also',
|
||||
'fixes a stray "0" shown next to the segment lines.',
|
||||
].join(' '),
|
||||
},
|
||||
migrations: { up: async () => {}, down: async () => {} },
|
||||
})
|
||||
Reference in New Issue
Block a user