diff --git a/frontend/index.html b/frontend/index.html
index 7f9c8ce..481ae84 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1128,6 +1128,158 @@
margin: 16px 0;
}
+ .thesis-layout {
+ display: grid;
+ grid-template-columns: minmax(300px, 1fr) minmax(360px, 1.2fr);
+ gap: 20px;
+ align-items: start;
+ }
+
+ @media (max-width: 900px) {
+ .thesis-layout {
+ grid-template-columns: 1fr;
+ }
+ }
+
+ .thesis-col {
+ min-width: 0;
+ }
+
+ .thesis-line-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ .thesis-line-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 12px;
+ background-color: #0d1622;
+ border: 1px solid #263548;
+ border-radius: 8px;
+ }
+
+ .thesis-version-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ width: 100%;
+ text-align: left;
+ padding: 12px;
+ background-color: #0d1622;
+ border: 1px solid #263548;
+ border-radius: 8px;
+ color: var(--text-primary);
+ font-weight: 400;
+ }
+
+ .thesis-version-row:hover {
+ background-color: #152233;
+ border-color: #35506a;
+ transform: none;
+ box-shadow: none;
+ }
+
+ .thesis-version-row.active {
+ border-color: #3b82c4;
+ background-color: #152233;
+ box-shadow: inset 0 0 0 1px #3b82c455;
+ }
+
+ .thesis-line-name {
+ font-size: 14px;
+ font-weight: 500;
+ color: #e5edf5;
+ }
+
+ .thesis-line-meta {
+ font-size: 12px;
+ color: #8ea2b7;
+ margin-top: 3px;
+ }
+
+ .approval-pill {
+ 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;
+ }
+
+ .approval-meter {
+ display: flex;
+ align-items: baseline;
+ gap: 6px;
+ }
+
+ .approval-meter-count {
+ font-family: 'IBM Plex Mono', monospace;
+ font-size: 18px;
+ font-weight: 700;
+ color: #3b82c4;
+ }
+
+ .approval-meter-label {
+ font-size: 12px;
+ color: #8ea2b7;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ }
+
+ .thesis-block {
+ margin-bottom: 18px;
+ }
+
+ .thesis-block-label {
+ font-size: 11px;
+ font-weight: 600;
+ color: #8ea2b7;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ margin-bottom: 8px;
+ }
+
+ .thesis-throughline {
+ font-size: 15px;
+ line-height: 1.6;
+ color: #e5edf5;
+ }
+
+ .thesis-list {
+ margin: 0;
+ padding-left: 20px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ color: #c7d3e0;
+ line-height: 1.5;
+ font-size: 13px;
+ }
+
+ .thesis-list-title {
+ color: #e5edf5;
+ font-weight: 500;
+ }
+
+ .thesis-list-detail {
+ margin-top: 3px;
+ font-size: 12px;
+ color: #8ea2b7;
+ }
+
+ .thesis-review-form {
+ margin-top: 8px;
+ border-top: 1px solid #263548;
+ padding-top: 16px;
+ }
+
.confirmation-dialog {
position: fixed;
top: 0;
@@ -8638,6 +8790,465 @@
);
};
+ const THESIS_STATUS_BADGE = {
+ canonical: 'badge-funded',
+ in_review: 'badge-meeting',
+ draft: 'badge-other',
+ superseded: 'badge-low',
+ archived: 'badge-low'
+ };
+
+ const thesisStatusClass = (status) => THESIS_STATUS_BADGE[status] || 'badge-other';
+
+ const formatThesisStatus = (status) => String(status || '').replace(/_/g, ' ');
+
+ const ThesisVersionDetail = ({ token, versionId, user, onShowToast, onReviewed }) => {
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+ const [reviewForm, setReviewForm] = useState({ decision: 'approve', feedback: '' });
+ const [submitting, setSubmitting] = useState(false);
+ const isAdmin = user?.role === 'admin';
+
+ const loadVersion = useCallback(async () => {
+ try {
+ setLoading(true);
+ const result = await api(`/api/thesis/versions/${versionId}`, {}, token);
+ setData(result);
+ setError('');
+ } catch (err) {
+ setError(getErrorMessage(err, 'Failed to load thesis version'));
+ } finally {
+ setLoading(false);
+ }
+ }, [token, versionId]);
+
+ useEffect(() => {
+ loadVersion();
+ }, [loadVersion]);
+
+ const handleSubmitReview = async (e) => {
+ e.preventDefault();
+ if (!isAdmin) return;
+ setSubmitting(true);
+ try {
+ await api(`/api/thesis/versions/${versionId}/review`, {
+ method: 'POST',
+ body: JSON.stringify({
+ decision: reviewForm.decision,
+ feedback: reviewForm.feedback
+ })
+ }, token);
+ setReviewForm({ decision: 'approve', feedback: '' });
+ onShowToast('Review submitted', 'success');
+ await loadVersion();
+ if (onReviewed) onReviewed();
+ } catch (err) {
+ onShowToast(getErrorMessage(err, 'Failed to submit review'), 'error');
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ if (loading) return
;
+ if (error) return {error}
;
+ if (!data) return No content
;
+
+ const body = data.body || {};
+ const pillars = Array.isArray(body.pillars) ? body.pillars : [];
+ const claims = Array.isArray(body.claims) ? body.claims : [];
+ const proofPoints = Array.isArray(body.proof_points) ? body.proof_points : [];
+ const objections = Array.isArray(body.objections) ? body.objections : [];
+ const segmentCuts = Array.isArray(body.segment_cuts) ? body.segment_cuts : [];
+ const reviews = Array.isArray(data.reviews) ? data.reviews : [];
+ const approvals = data.approvals ?? 0;
+ const required = data.required ?? 0;
+
+ const renderTextOrLabel = (item) => {
+ if (item == null) return '-';
+ if (typeof item === 'string') return item;
+ return item.text || item.label || item.claim || item.title || JSON.stringify(item);
+ };
+
+ return (
+
+
+
+ {data.line_key} · v{data.version_no}
+ {formatThesisStatus(data.status)}
+
+
+ {approvals} / {required}
+ approvals
+
+
+
+ {body.throughline && (
+
+
Throughline
+
{body.throughline}
+
+ )}
+
+ {pillars.length > 0 && (
+
+
Pillars
+
+ {pillars.map((p, i) => (
+ -
+ {renderTextOrLabel(p)}
+ {p && typeof p === 'object' && p.detail &&
{p.detail}
}
+
+ ))}
+
+
+ )}
+
+ {claims.length > 0 && (
+
+
Claims
+
+ {claims.map((c, i) => (- {renderTextOrLabel(c)}
))}
+
+
+ )}
+
+ {proofPoints.length > 0 && (
+
+
Proof Points
+
+ {proofPoints.map((p, i) => (- {renderTextOrLabel(p)}
))}
+
+
+ )}
+
+ {objections.length > 0 && (
+
+
Objections
+
+ {objections.map((o, i) => (- {renderTextOrLabel(o)}
))}
+
+
+ )}
+
+ {segmentCuts.length > 0 && (
+
+
Segment Cuts
+
+ {segmentCuts.map((s, i) => (- {renderTextOrLabel(s)}
))}
+
+
+ )}
+
+
+
Reviews & Feedback
+ {reviews.length === 0 ? (
+
No reviews yet.
+ ) : (
+
+ {reviews.map((r, i) => (
+
+
+
+
+ {formatThesisStatus(r.decision)}
+ {r.reviewer_user_id || 'reviewer'}
+
+
{formatDateLong(r.created_at)}
+ {r.feedback &&
{r.feedback}
}
+
+
+ ))}
+
+ )}
+
+
+ {isAdmin ? (
+
+ ) : (
+
Only admins can submit a review. Two distinct approvals promote a version to canonical.
+ )}
+
+ );
+ };
+
+ const ThesisPage = ({ token, user, onShowToast }) => {
+ const [lines, setLines] = useState([]);
+ const [versions, setVersions] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+ const [selectedVersionId, setSelectedVersionId] = useState(null);
+ const [refreshTick, setRefreshTick] = useState(0);
+
+ useEffect(() => {
+ const load = async () => {
+ try {
+ setLoading(true);
+ const [linesResult, versionsResult] = await Promise.all([
+ api('/api/thesis/lines', {}, token),
+ api('/api/thesis/versions', {}, token)
+ ]);
+ const lineList = linesResult.lines || [];
+ const versionList = versionsResult.versions || [];
+ setLines(lineList);
+ setVersions(versionList);
+ setError('');
+ setSelectedVersionId((prev) => {
+ if (prev && versionList.some((v) => v.id === prev)) return prev;
+ return versionList[0]?.id ?? null;
+ });
+ } catch (err) {
+ setError(getErrorMessage(err, 'Failed to load thesis data'));
+ } finally {
+ setLoading(false);
+ }
+ };
+ load();
+ }, [token, refreshTick]);
+
+ if (loading) return
;
+ if (error) return {error}
;
+
+ return (
+
+
Thesis
+
+
+
+
+
Thesis Lines
+ {lines.length === 0 ? (
+
No thesis lines yet.
+ ) : (
+
+ {lines.map((line) => (
+
+
+
+ {line.name}
+ {line.is_core && Core}
+
+
{line.line_key}{line.segment_key ? ` · ${line.segment_key}` : ''}
+
+
{formatThesisStatus(line.status)}
+
+ ))}
+
+ )}
+
+
+
+
In-Review Queue
+ {versions.length === 0 ? (
+
Nothing awaiting review.
+ ) : (
+
+ {versions.map((v) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+
Version Review
+ {selectedVersionId == null ? (
+
+
◷
+ Select a version from the review queue to read it and submit feedback.
+
+ ) : (
+
setRefreshTick((t) => t + 1)}
+ />
+ )}
+
+
+
+
+ );
+ };
+
+ const SystemStatusPage = ({ token }) => {
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+
+ useEffect(() => {
+ const load = async () => {
+ try {
+ setLoading(true);
+ const result = await api('/api/system/status', {}, token);
+ setData(result.data);
+ setError('');
+ } catch (err) {
+ setError(getErrorMessage(err, 'Failed to load system status'));
+ } finally {
+ setLoading(false);
+ }
+ };
+ load();
+ }, [token]);
+
+ if (loading) return
;
+ if (error) return {error}
;
+ if (!data) return No data
;
+
+ const entities = data.canonical_entities || {};
+ const sync = data.last_index_sync;
+ const thesis = data.thesis || {};
+ const activity = Array.isArray(data.recent_activity) ? data.recent_activity : [];
+
+ return (
+
+
System Status
+
+
+
+
LPs
+
{entities.lp ?? 0}
+
+
+
Organizations
+
{entities.organization ?? 0}
+
+
+
People
+
{entities.person ?? 0}
+
+
+
Entity Links
+
{data.entity_links ?? 0}
+
+
+
+
+
Last Index Sync
+ {!sync ? (
+
+
◷
+ The search index has not been built yet.
+
+ ) : (
+
+
+
When
+
{formatDate(sync.ts)}
+
{formatDateLong(sync.ts)}
+
+
+
Mode
+
{sync.mode || '-'}
+
+
+
Qdrant Points
+
{sync.qdrant_points ?? 0}
+
+
+
Rows Embedded
+
{sync.rows_embedded ?? 0}
+
{sync.total_chunks ?? 0} chunks
+
+
+ )}
+
+
+
+
Thesis
+
+
+
Lines
+
{thesis.lines ?? 0}
+
+
+
Canonical Versions
+
{thesis.canonical_versions ?? 0}
+
+
+
In Review
+
{thesis.in_review ?? 0}
+
+
+
+
+
+
Recent Activity
+ {activity.length === 0 ? (
+
No recent activity.
+ ) : (
+
+
+
+ | When |
+ Actor |
+ Action |
+
+
+
+ {activity.slice(0, 30).map((a, i) => (
+
+ | {formatDateLong(a.ts)} |
+ {a.actor_type}{a.actor_id ? ` · ${a.actor_id}` : ''} |
+ {a.action} |
+
+ ))}
+
+
+ )}
+
+
+ );
+ };
+
// ==================== Main App ====================
const App = () => {
const { token, user, logout } = useAuth();
@@ -8813,6 +9424,12 @@
+
+
@@ -8838,6 +9455,8 @@
{page === 'contacts' && 'Contacts'}
{page === 'pipeline' && 'Pipeline'}
{page === 'communications' && 'Communications'}
+ {page === 'thesis' && 'Thesis'}
+ {page === 'system-status' && 'System Status'}
{page === 'feature-requests' && 'Feature Requests'}
{page === 'instructions' && 'Instructions'}
{page === 'settings' && 'Settings'}
@@ -8866,6 +9485,8 @@
{page === 'contacts' && }
{page === 'pipeline' && }
{page === 'communications' && }
+ {page === 'thesis' && }
+ {page === 'system-status' && }
{page === 'feature-requests' && }
{page === 'instructions' && }
{page === 'settings' && (