Add Architect Thesis Workshop UI (v0.1.0:49)
Frontend: ThesisWorkshopPage / ThesisWorkshopNode / ThesisWorkshopOptions — the collaborative iteration screen where partners generate a variable number of competing thesis options (1, 2, 3, A1/A2/A3 ...) for any node, give feedback, and regenerate. Reuses the shared api() helper; flexible option count is the core UX constraint. Backend Architect agent (architect_agent.py) + routes shipped in dd25bbc; this completes the user-facing surface and bumps the StartOS package to 0.1.0:49 (anthropic dep already in the image, key loaded from /data/secrets/anthropic-api-key — self-disabling until present). Also lands thesis seed iterations v3 and v5 (voice/messaging corrections). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1280,6 +1280,190 @@
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
/* Thesis Workshop ----------------------------------------------------- */
|
||||
.thesis-ws-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 18px;
|
||||
background-color: #1a2233;
|
||||
border: 1px solid #35506a;
|
||||
border-radius: 8px;
|
||||
color: #c7d3e0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.thesis-ws-banner.ready {
|
||||
background-color: #10b9810f;
|
||||
border-color: #1f6f54;
|
||||
}
|
||||
|
||||
.thesis-ws-banner-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 16px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.thesis-ws-node {
|
||||
border: 1px solid #263548;
|
||||
border-radius: 8px;
|
||||
background-color: #0d1622;
|
||||
margin-bottom: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.thesis-ws-node.selected {
|
||||
border-color: #3b82c4;
|
||||
box-shadow: inset 0 0 0 1px #3b82c455;
|
||||
}
|
||||
|
||||
.thesis-ws-node-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 12px 14px;
|
||||
background-color: transparent;
|
||||
color: var(--text-primary);
|
||||
font-weight: 400;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.thesis-ws-node-head:hover {
|
||||
background-color: #152233;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.thesis-ws-node-type {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #8ea2b7;
|
||||
}
|
||||
|
||||
.thesis-ws-node-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #e5edf5;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.thesis-ws-children {
|
||||
padding-left: 18px;
|
||||
border-left: 1px solid #1d2a3a;
|
||||
margin: 4px 0 4px 18px;
|
||||
}
|
||||
|
||||
.thesis-ws-body {
|
||||
padding: 4px 14px 16px;
|
||||
}
|
||||
|
||||
.thesis-ws-count {
|
||||
flex-shrink: 0;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #93c5fd;
|
||||
background-color: #3b82c422;
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
.thesis-ws-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.thesis-ws-option {
|
||||
border: 1px solid #263548;
|
||||
border-radius: 8px;
|
||||
background-color: #111a27;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.thesis-ws-option-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.thesis-ws-option-num {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #8ea2b7;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.thesis-ws-option-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #e5edf5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.thesis-ws-rationale {
|
||||
margin-top: 10px;
|
||||
padding: 8px 10px;
|
||||
background-color: #0d1622;
|
||||
border-left: 2px solid #3b82c4;
|
||||
border-radius: 0 6px 6px 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #8ea2b7;
|
||||
}
|
||||
|
||||
.thesis-ws-rationale-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #93c5fd;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.thesis-ws-controls {
|
||||
margin-top: 16px;
|
||||
border-top: 1px solid #263548;
|
||||
padding-top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (min-width: 760px) {
|
||||
.thesis-ws-controls {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.thesis-ws-control-col {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.thesis-ws-control-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.thesis-ws-n-input {
|
||||
min-width: 0;
|
||||
width: 80px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.index-action-status {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
@@ -9264,6 +9448,610 @@
|
||||
);
|
||||
};
|
||||
|
||||
// Friendly label for the various node types in a thesis tree.
|
||||
const THESIS_NODE_TYPE_LABEL = {
|
||||
thesis_root: 'Thesis',
|
||||
throughline: 'Throughline',
|
||||
section: 'Section',
|
||||
claim: 'Claim',
|
||||
proof_point: 'Proof Point',
|
||||
objection: 'Objection',
|
||||
segment_cut: 'Segment Cut'
|
||||
};
|
||||
|
||||
const formatNodeType = (t) => THESIS_NODE_TYPE_LABEL[t] || formatThesisStatus(t);
|
||||
|
||||
// Detect the server's "Architect not connected" signal so we can show a
|
||||
// calm connect-the-Architect message instead of a raw 502.
|
||||
const isArchitectMissingKeyError = (err) => {
|
||||
const status = err?.status;
|
||||
const payload = err?.payload || {};
|
||||
const haystack = `${err?.message || ''} ${payload.error || ''} ${payload.reason || ''} ${payload.detail || ''}`.toLowerCase();
|
||||
return status === 502 || haystack.includes('anthropic_api_key') || haystack.includes('api key') || haystack.includes('not configured');
|
||||
};
|
||||
|
||||
const ARCHITECT_CONNECT_HINT = "The Architect isn't connected yet — add your Anthropic API key on the server to enable generation. You can still view and edit the thesis.";
|
||||
|
||||
// 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 nodeId = node.id;
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
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 load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await api(`/api/thesis/nodes/${nodeId}/variants`, {}, token);
|
||||
setData(result);
|
||||
setError('');
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, 'Failed to load options'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token, nodeId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleGenerate = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!isAdmin || generating) return;
|
||||
const n = Math.max(1, Math.min(8, parseInt(genN, 10) || 1));
|
||||
try {
|
||||
setGenerating(true);
|
||||
const result = await api(`/api/thesis/nodes/${nodeId}/generate`, {
|
||||
method: 'POST',
|
||||
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');
|
||||
setGuidance('');
|
||||
await load();
|
||||
} catch (err) {
|
||||
if (isArchitectMissingKeyError(err)) {
|
||||
onShowToast(ARCHITECT_CONNECT_HINT, 'error');
|
||||
} else {
|
||||
onShowToast(getErrorMessage(err, 'Failed to generate options'), 'error');
|
||||
}
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
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));
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
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.'}
|
||||
</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 || '';
|
||||
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>
|
||||
{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>
|
||||
)}
|
||||
<div className="thesis-ws-option-text">{text || '—'}</div>
|
||||
{rationale && (
|
||||
<div className="thesis-ws-rationale">
|
||||
<div className="thesis-ws-rationale-label">Architect's rationale</div>
|
||||
{rationale}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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)}
|
||||
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>
|
||||
) : (
|
||||
<div className="form-help" style={{ marginTop: '14px' }}>Only admins can generate or revise options.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 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 isSelected = selectedId === node.id;
|
||||
const children = Array.isArray(node.children) ? node.children : [];
|
||||
const variantGroup = node.variant_group;
|
||||
|
||||
return (
|
||||
<div className={`thesis-ws-node ${isSelected ? 'selected' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="thesis-ws-node-head"
|
||||
onClick={() => onSelect(isSelected ? null : node.id)}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="thesis-ws-node-type">{formatNodeType(node.node_type)}</div>
|
||||
<div className="thesis-ws-node-title">{node.title || node.body || formatNodeType(node.node_type)}</div>
|
||||
{node.body && node.title && node.body !== node.title && (
|
||||
<div className="thesis-line-meta">{node.body}</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="thesis-ws-count">{isSelected ? 'Hide options' : 'Options'}</span>
|
||||
</button>
|
||||
|
||||
{isSelected && (
|
||||
<div className="thesis-ws-body">
|
||||
<ThesisWorkshopOptions
|
||||
key={variantGroup || node.id}
|
||||
token={token}
|
||||
node={node}
|
||||
lineKey={lineKey}
|
||||
isAdmin={isAdmin}
|
||||
architectReady={architectReady}
|
||||
onShowToast={onShowToast}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children.length > 0 && (
|
||||
<div className="thesis-ws-children">
|
||||
{children.map((child) => (
|
||||
<ThesisWorkshopNode
|
||||
key={child.id}
|
||||
token={token}
|
||||
node={child}
|
||||
lineKey={lineKey}
|
||||
depth={(depth || 0) + 1}
|
||||
isAdmin={isAdmin}
|
||||
architectReady={architectReady}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
onShowToast={onShowToast}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ThesisWorkshopPage = ({ token, user, onShowToast }) => {
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const [lines, setLines] = useState([]);
|
||||
const [linesLoading, setLinesLoading] = useState(true);
|
||||
const [linesError, setLinesError] = useState('');
|
||||
const [selectedLineKey, setSelectedLineKey] = useState(null);
|
||||
|
||||
const [tree, setTree] = useState(null);
|
||||
const [treeLoading, setTreeLoading] = useState(false);
|
||||
const [treeError, setTreeError] = useState('');
|
||||
const [selectedNodeId, setSelectedNodeId] = useState(null);
|
||||
const [treeTick, setTreeTick] = useState(0);
|
||||
|
||||
const [architect, setArchitect] = useState(null);
|
||||
|
||||
// Seed-a-line form (shown when there are no lines).
|
||||
const [showNewLine, setShowNewLine] = useState(false);
|
||||
const [newLine, setNewLine] = useState({ line_key: '', name: '', is_core: false, description: '' });
|
||||
const [creatingLine, setCreatingLine] = useState(false);
|
||||
|
||||
// Seed-a-node controls (shown when a line has an empty tree).
|
||||
const [seedTitle, setSeedTitle] = useState('');
|
||||
const [seedType, setSeedType] = useState('throughline');
|
||||
const [seeding, setSeeding] = useState(false);
|
||||
|
||||
// Architect status — non-blocking; failure just means "unknown",
|
||||
// we still let the partner view/edit the thesis.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const result = await api('/api/architect/status', {}, token);
|
||||
if (!cancelled) setArchitect(result.data || result);
|
||||
} catch (err) {
|
||||
if (!cancelled) setArchitect({ ready: false, reason: getErrorMessage(err, 'Status unavailable') });
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [token]);
|
||||
|
||||
const loadLines = useCallback(async () => {
|
||||
try {
|
||||
setLinesLoading(true);
|
||||
const result = await api('/api/thesis/lines', {}, token);
|
||||
const lineList = result.lines || [];
|
||||
setLines(lineList);
|
||||
setLinesError('');
|
||||
setSelectedLineKey((prev) => {
|
||||
if (prev && lineList.some((l) => l.line_key === prev)) return prev;
|
||||
return lineList[0]?.line_key ?? null;
|
||||
});
|
||||
} catch (err) {
|
||||
setLinesError(getErrorMessage(err, 'Failed to load thesis lines'));
|
||||
} finally {
|
||||
setLinesLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => { loadLines(); }, [loadLines]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedLineKey) { setTree(null); return undefined; }
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
setTreeLoading(true);
|
||||
const result = await api(`/api/thesis/${selectedLineKey}/tree`, {}, token);
|
||||
if (cancelled) return;
|
||||
setTree(result);
|
||||
setTreeError('');
|
||||
setSelectedNodeId(null);
|
||||
} catch (err) {
|
||||
if (!cancelled) setTreeError(getErrorMessage(err, 'Failed to load thesis tree'));
|
||||
} finally {
|
||||
if (!cancelled) setTreeLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [token, selectedLineKey, treeTick]);
|
||||
|
||||
const handleCreateLine = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!isAdmin || creatingLine) return;
|
||||
if (!newLine.line_key.trim() || !newLine.name.trim()) {
|
||||
onShowToast('A key and name are required', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setCreatingLine(true);
|
||||
await api('/api/thesis/lines', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
line_key: newLine.line_key.trim(),
|
||||
name: newLine.name.trim(),
|
||||
is_core: !!newLine.is_core,
|
||||
description: newLine.description.trim()
|
||||
})
|
||||
}, token);
|
||||
onShowToast('Thesis line created', 'success');
|
||||
const createdKey = newLine.line_key.trim();
|
||||
setNewLine({ line_key: '', name: '', is_core: false, description: '' });
|
||||
setShowNewLine(false);
|
||||
await loadLines();
|
||||
setSelectedLineKey(createdKey);
|
||||
} catch (err) {
|
||||
onShowToast(getErrorMessage(err, 'Failed to create thesis line'), 'error');
|
||||
} finally {
|
||||
setCreatingLine(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeedNode = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!isAdmin || seeding || !selectedLineKey) return;
|
||||
if (!seedTitle.trim()) {
|
||||
onShowToast('Give the new part a title', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSeeding(true);
|
||||
await api(`/api/thesis/lines/${selectedLineKey}/nodes`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ node_type: seedType, title: seedTitle.trim(), body: '' })
|
||||
}, token);
|
||||
onShowToast('Added to the thesis', 'success');
|
||||
setSeedTitle('');
|
||||
setTreeTick((t) => t + 1);
|
||||
} catch (err) {
|
||||
onShowToast(getErrorMessage(err, 'Failed to add part'), 'error');
|
||||
} finally {
|
||||
setSeeding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const architectReady = !!architect?.ready;
|
||||
const nodes = Array.isArray(tree?.tree) ? tree.tree : [];
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<h2 className="section-title" style={{ marginBottom: '20px' }}>Thesis Workshop</h2>
|
||||
|
||||
{architect && (
|
||||
<div className={`thesis-ws-banner ${architectReady ? 'ready' : ''}`}>
|
||||
<span className="thesis-ws-banner-icon">{architectReady ? '◆' : 'ⓘ'}</span>
|
||||
<div>
|
||||
{architectReady ? (
|
||||
<>The Architect is connected{architect.model ? ` (${architect.model})` : ''} and ready to draft options with you.</>
|
||||
) : (
|
||||
<>{ARCHITECT_CONNECT_HINT}{architect.reason ? ` (${architect.reason})` : ''}</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="thesis-layout">
|
||||
<div className="thesis-col">
|
||||
<div className="section">
|
||||
<div className="section-title">Thesis Lines</div>
|
||||
{linesLoading ? (
|
||||
<SkeletonBlock lines={4} />
|
||||
) : linesError ? (
|
||||
<div className="toast error" style={{ position: 'static' }}>{linesError}</div>
|
||||
) : lines.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '20px 0' }}>
|
||||
<div className="empty-state-icon">§</div>
|
||||
No thesis lines yet.
|
||||
{isAdmin ? ' Seed one to start working with the Architect.' : ' An admin needs to create one.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="thesis-line-list">
|
||||
{lines.map((line) => (
|
||||
<button
|
||||
key={line.id}
|
||||
className={`thesis-version-row ${selectedLineKey === line.line_key ? 'active' : ''}`}
|
||||
onClick={() => setSelectedLineKey(line.line_key)}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="thesis-line-name">
|
||||
{line.name}
|
||||
{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>
|
||||
<span className={`badge ${thesisStatusClass(line.status)}`}>{formatThesisStatus(line.status)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdmin && !linesLoading && (
|
||||
<div style={{ marginTop: '14px' }}>
|
||||
{showNewLine ? (
|
||||
<form onSubmit={handleCreateLine} className="thesis-review-form" style={{ marginTop: 0 }}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Key</label>
|
||||
<input
|
||||
className="text-input"
|
||||
placeholder="e.g. bitcoin_energy"
|
||||
value={newLine.line_key}
|
||||
onChange={(e) => setNewLine((f) => ({ ...f, line_key: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Name</label>
|
||||
<input
|
||||
className="text-input"
|
||||
placeholder="Human-friendly name"
|
||||
value={newLine.name}
|
||||
onChange={(e) => setNewLine((f) => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Description</label>
|
||||
<textarea
|
||||
className="text-input"
|
||||
rows="2"
|
||||
placeholder="Optional one-liner"
|
||||
value={newLine.description}
|
||||
onChange={(e) => setNewLine((f) => ({ ...f, description: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<label className="form-help" style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newLine.is_core}
|
||||
onChange={(e) => setNewLine((f) => ({ ...f, is_core: e.target.checked }))}
|
||||
/>
|
||||
Mark as a core thesis line
|
||||
</label>
|
||||
<div className="form-actions">
|
||||
<button type="button" className="button-secondary" onClick={() => setShowNewLine(false)} disabled={creatingLine}>Cancel</button>
|
||||
<button type="submit" disabled={creatingLine}>{creatingLine ? 'Creating…' : 'Create line'}</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<button className="button-secondary" style={{ width: '100%' }} onClick={() => setShowNewLine(true)}>+ New thesis line</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="thesis-col">
|
||||
<div className="section">
|
||||
<div className="section-title">Workshop</div>
|
||||
{!selectedLineKey ? (
|
||||
<div className="empty-state" style={{ padding: '24px 0' }}>
|
||||
<div className="empty-state-icon">◷</div>
|
||||
Pick a thesis line on the left to start iterating on it.
|
||||
</div>
|
||||
) : treeLoading ? (
|
||||
<SkeletonBlock lines={6} />
|
||||
) : treeError ? (
|
||||
<div className="toast error" style={{ position: 'static' }}>{treeError}</div>
|
||||
) : (
|
||||
<div>
|
||||
{nodes.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '20px 0' }}>
|
||||
This line has no content yet.
|
||||
{isAdmin ? ' Add a throughline or a pillar to seed it.' : ' An admin needs to seed it.'}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{nodes.map((node) => (
|
||||
<ThesisWorkshopNode
|
||||
key={node.id}
|
||||
token={token}
|
||||
node={node}
|
||||
lineKey={selectedLineKey}
|
||||
depth={0}
|
||||
isAdmin={isAdmin}
|
||||
architectReady={architectReady}
|
||||
selectedId={selectedNodeId}
|
||||
onSelect={setSelectedNodeId}
|
||||
onShowToast={onShowToast}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<form onSubmit={handleSeedNode} className="thesis-review-form">
|
||||
<div className="thesis-block-label">Add a part</div>
|
||||
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||
<div className="form-group" style={{ marginBottom: 0, flex: '0 0 auto' }}>
|
||||
<label className="form-label">Type</label>
|
||||
<select
|
||||
className="select-input"
|
||||
value={seedType}
|
||||
onChange={(e) => setSeedType(e.target.value)}
|
||||
>
|
||||
<option value="throughline">Throughline</option>
|
||||
<option value="section">Section / Pillar</option>
|
||||
<option value="claim">Claim</option>
|
||||
<option value="proof_point">Proof Point</option>
|
||||
<option value="objection">Objection</option>
|
||||
<option value="segment_cut">Segment Cut</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: 0, flex: '1 1 200px' }}>
|
||||
<label className="form-label">Title</label>
|
||||
<input
|
||||
className="text-input"
|
||||
placeholder="Short name for this part"
|
||||
value={seedTitle}
|
||||
onChange={(e) => setSeedTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={seeding}>{seeding ? 'Adding…' : 'Add'}</button>
|
||||
</div>
|
||||
<div className="form-help" style={{ marginTop: '8px' }}>
|
||||
Each part holds its own set of competing options — one or many — that you grow with the Architect.
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SystemStatusPage = ({ token, user, onShowToast }) => {
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const [data, setData] = useState(null);
|
||||
@@ -9770,6 +10558,9 @@
|
||||
<button className={`nav-item ${page === 'thesis' ? 'active' : ''}`} onClick={() => setPage('thesis')}>
|
||||
<span className="nav-item-icon">§</span> Thesis
|
||||
</button>
|
||||
<button className={`nav-item ${page === 'thesis-workshop' ? 'active' : ''}`} onClick={() => setPage('thesis-workshop')}>
|
||||
<span className="nav-item-icon">◆</span> Thesis Workshop
|
||||
</button>
|
||||
<button className={`nav-item ${page === 'system-status' ? 'active' : ''}`} onClick={() => setPage('system-status')}>
|
||||
<span className="nav-item-icon">◉</span> System Status
|
||||
</button>
|
||||
@@ -9799,6 +10590,7 @@
|
||||
{page === 'pipeline' && 'Pipeline'}
|
||||
{page === 'communications' && 'Communications'}
|
||||
{page === 'thesis' && 'Thesis'}
|
||||
{page === 'thesis-workshop' && 'Thesis Workshop'}
|
||||
{page === 'system-status' && 'System Status'}
|
||||
{page === 'feature-requests' && 'Feature Requests'}
|
||||
{page === 'instructions' && 'Instructions'}
|
||||
@@ -9829,6 +10621,7 @@
|
||||
{page === 'pipeline' && <PipelinePage token={token} onShowToast={showToast} />}
|
||||
{page === 'communications' && <CommunicationsPage token={token} onShowToast={showToast} />}
|
||||
{page === 'thesis' && <ThesisPage token={token} user={user} onShowToast={showToast} />}
|
||||
{page === 'thesis-workshop' && <ThesisWorkshopPage token={token} user={user} onShowToast={showToast} />}
|
||||
{page === 'system-status' && <SystemStatusPage token={token} user={user} onShowToast={showToast} />}
|
||||
{page === 'feature-requests' && <FeatureRequestsPage token={token} onShowToast={showToast} user={user} />}
|
||||
{page === 'instructions' && <InstructionsPage />}
|
||||
|
||||
Reference in New Issue
Block a user