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:
Keysat
2026-06-05 13:32:43 -05:00
parent dd25bbc08d
commit 77e619d097
6 changed files with 917 additions and 4 deletions
+793
View File
@@ -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 />}