<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Thoughtbox Observatory</title>
<style>
/* ================================================================
CSS Reset
================================================================ */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* ================================================================
GitHub Dark Mode Design Tokens
================================================================ */
:root {
--color-canvas-default: #0d1117;
--color-canvas-subtle: #161b22;
--color-canvas-inset: #010409;
--color-border-default: #30363d;
--color-border-muted: #21262d;
--color-fg-default: #e6edf3;
--color-fg-muted: #8b949e;
--color-fg-subtle: #6e7681;
--color-accent-fg: #58a6ff;
--color-accent-emphasis: #1f6feb;
--color-success-fg: #3fb950;
--color-danger-fg: #f85149;
--color-attention-fg: #d29922;
--color-done-fg: #a371f7;
--color-sponsors-fg: #db61a2;
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
/* Agent profile colors */
--color-manager: #d2a8ff;
--color-architect: #79c0ff;
--color-debugger: #ffa657;
--color-security: #ff7b72;
--color-researcher: #7ee787;
--color-reviewer: #d29922;
}
html, body {
height: 100%;
font-family: var(--font-family);
background: var(--color-canvas-default);
color: var(--color-fg-default);
font-size: 14px;
line-height: 1.5;
overflow: hidden;
}
a { color: var(--color-accent-fg); text-decoration: none; }
a:hover { text-decoration: underline; }
/* ================================================================
Custom Scrollbar
================================================================ */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--color-border-default); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--color-fg-subtle); }
/* ================================================================
Animation
================================================================ */
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes slide-up { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
/* ================================================================
Layout
================================================================ */
#app {
height: 100vh;
display: flex;
flex-direction: column;
}
/* ================================================================
AppHeader — repo name + connection + agent avatars
================================================================ */
.AppHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 24px;
background: var(--color-canvas-subtle);
border-bottom: 1px solid var(--color-border-default);
}
.AppHeader-context {
display: flex;
align-items: center;
gap: 8px;
}
.AppHeader-repoIcon {
color: var(--color-fg-muted);
}
.AppHeader-repoName {
font-size: 16px;
font-weight: 600;
color: var(--color-accent-fg);
}
.AppHeader-separator {
color: var(--color-fg-subtle);
font-size: 20px;
font-weight: 300;
}
.AppHeader-sessionName {
font-size: 14px;
color: var(--color-fg-muted);
font-weight: 400;
}
.AppHeader-right {
display: flex;
align-items: center;
gap: 16px;
}
.AvatarStack {
display: flex;
flex-direction: row-reverse;
}
.Avatar {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid var(--color-canvas-subtle);
margin-left: -8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
color: var(--color-canvas-default);
cursor: default;
position: relative;
}
.Avatar:first-child { margin-left: 0; }
.Avatar[title]:hover::after {
content: attr(title);
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
margin-top: 4px;
padding: 4px 8px;
background: var(--color-canvas-inset);
border: 1px solid var(--color-border-default);
border-radius: 6px;
font-size: 11px;
color: var(--color-fg-default);
white-space: nowrap;
z-index: 100;
pointer-events: none;
}
/* Connection Status */
.ConnectionStatus {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--color-fg-muted);
}
.ConnectionStatus-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-success-fg);
}
.ConnectionStatus-dot.disconnected {
background: var(--color-danger-fg);
}
/* ================================================================
UnderlineNav — tab navigation
================================================================ */
.UnderlineNav {
display: flex;
align-items: center;
padding: 0 24px;
background: var(--color-canvas-subtle);
border-bottom: 1px solid var(--color-border-default);
overflow-x: auto;
}
.UnderlineNav-item {
display: flex;
align-items: center;
gap: 6px;
padding: 12px 16px;
font-size: 14px;
font-weight: 400;
color: var(--color-fg-muted);
border-bottom: 2px solid transparent;
cursor: pointer;
white-space: nowrap;
background: none;
border-top: none;
border-left: none;
border-right: none;
font-family: inherit;
transition: color 0.15s;
}
.UnderlineNav-item:hover {
color: var(--color-fg-default);
}
.UnderlineNav-item.selected {
font-weight: 600;
color: var(--color-fg-default);
border-bottom-color: var(--color-accent-fg);
}
.UnderlineNav-icon {
display: inline-flex;
}
.Counter {
display: inline-block;
padding: 0 6px;
font-size: 12px;
font-weight: 600;
line-height: 18px;
border-radius: 10px;
background: var(--color-border-default);
color: var(--color-fg-muted);
min-width: 20px;
text-align: center;
}
/* ================================================================
Main content area
================================================================ */
.Main {
flex: 1;
overflow: auto;
background: var(--color-canvas-default);
}
.Main-container {
max-width: 1280px;
margin: 0 auto;
padding: 24px;
}
/* ================================================================
Box — bordered container
================================================================ */
.Box {
border: 1px solid var(--color-border-default);
border-radius: 6px;
overflow: hidden;
}
.Box-header {
padding: 16px;
background: var(--color-canvas-subtle);
border-bottom: 1px solid var(--color-border-default);
display: flex;
align-items: center;
justify-content: space-between;
}
.Box-header-title {
font-size: 14px;
font-weight: 600;
}
.Box-row {
padding: 12px 16px;
border-bottom: 1px solid var(--color-border-muted);
display: flex;
align-items: flex-start;
gap: 12px;
animation: fade-in 0.2s ease-out;
}
.Box-row:last-child { border-bottom: none; }
.Box-row:hover {
background: var(--color-canvas-subtle);
}
.Box-row--clickable {
cursor: pointer;
}
/* ================================================================
StateLabel — open/closed/merged badges
================================================================ */
.StateLabel {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 10px;
font-size: 12px;
font-weight: 600;
line-height: 20px;
border-radius: 16px;
white-space: nowrap;
}
.StateLabel--open {
background: rgba(63, 185, 80, 0.15);
color: var(--color-success-fg);
}
.StateLabel--closed {
background: rgba(248, 81, 73, 0.15);
color: var(--color-danger-fg);
}
.StateLabel--merged {
background: rgba(163, 113, 247, 0.15);
color: var(--color-done-fg);
}
.StateLabel--in-progress {
background: rgba(210, 153, 34, 0.15);
color: var(--color-attention-fg);
}
.StateLabel--reviewing {
background: rgba(121, 192, 255, 0.15);
color: var(--color-accent-fg);
}
.StateLabel--rejected {
background: rgba(248, 81, 73, 0.15);
color: var(--color-danger-fg);
}
.StateLabel--resolved {
background: rgba(163, 113, 247, 0.15);
color: var(--color-done-fg);
}
/* ================================================================
AgentPill — author badge
================================================================ */
.AgentPill {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
font-size: 12px;
font-weight: 500;
border-radius: 16px;
background: var(--color-canvas-subtle);
border: 1px solid var(--color-border-default);
color: var(--color-fg-muted);
white-space: nowrap;
}
.AgentPill-dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
/* Profile-specific pill colors */
.AgentPill[data-profile="MANAGER"] .AgentPill-dot { background: var(--color-manager); }
.AgentPill[data-profile="ARCHITECT"] .AgentPill-dot { background: var(--color-architect); }
.AgentPill[data-profile="DEBUGGER"] .AgentPill-dot { background: var(--color-debugger); }
.AgentPill[data-profile="SECURITY"] .AgentPill-dot { background: var(--color-security); }
.AgentPill[data-profile="RESEARCHER"] .AgentPill-dot { background: var(--color-researcher); }
.AgentPill[data-profile="REVIEWER"] .AgentPill-dot { background: var(--color-reviewer); }
/* ================================================================
BranchName — branch label
================================================================ */
.BranchName {
display: inline-flex;
align-items: center;
padding: 2px 8px;
font-size: 12px;
font-family: var(--font-mono);
font-weight: 500;
background: rgba(56, 139, 253, 0.15);
color: var(--color-accent-fg);
border-radius: 6px;
}
/* ================================================================
CommitList — vertical commit log (Code view)
================================================================ */
.CommitList {
list-style: none;
}
.CommitRow {
display: flex;
align-items: flex-start;
gap: 0;
padding: 0;
border-bottom: 1px solid var(--color-border-muted);
transition: background 0.1s;
}
.CommitRow:last-child { border-bottom: none; }
.CommitRow:hover { background: var(--color-canvas-subtle); }
.CommitRow--clickable { cursor: pointer; }
.CommitGraphRail {
width: 80px;
min-height: 48px;
flex-shrink: 0;
position: relative;
}
.CommitRow-content {
flex: 1;
min-width: 0;
padding: 10px 16px 10px 0;
display: flex;
align-items: baseline;
gap: 12px;
}
.CommitRow-message {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
color: var(--color-fg-default);
}
.CommitRow-meta {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.CommitRow-hash {
font-family: var(--font-mono);
font-size: 12px;
color: var(--color-accent-fg);
background: var(--color-canvas-subtle);
padding: 2px 8px;
border-radius: 6px;
border: 1px solid var(--color-border-default);
}
.CommitRow-time {
font-size: 12px;
color: var(--color-fg-subtle);
white-space: nowrap;
}
/* ================================================================
CommitDetail — expanded thought view
================================================================ */
.CommitDetail {
animation: slide-up 0.2s ease-out;
}
.CommitDetail-header {
padding: 20px 24px;
border-bottom: 1px solid var(--color-border-default);
background: var(--color-canvas-subtle);
}
.CommitDetail-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 8px;
}
.CommitDetail-metaRow {
display: flex;
align-items: center;
gap: 16px;
font-size: 13px;
color: var(--color-fg-muted);
}
.CommitDetail-body {
padding: 20px 24px;
}
.CommitDetail-codeBlock {
background: var(--color-canvas-inset);
border: 1px solid var(--color-border-default);
border-radius: 6px;
padding: 16px;
overflow-x: auto;
font-family: var(--font-mono);
font-size: 13px;
color: var(--color-fg-default);
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.CommitDetail-nav {
display: flex;
align-items: center;
gap: 8px;
}
/* ================================================================
IssueRow / PullRow — list rows for Problems and Proposals
================================================================ */
.IssueRow, .PullRow {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--color-border-muted);
cursor: pointer;
transition: background 0.1s;
}
.IssueRow:hover, .PullRow:hover {
background: var(--color-canvas-subtle);
}
.IssueRow:last-child, .PullRow:last-child { border-bottom: none; }
.IssueRow-icon, .PullRow-icon {
flex-shrink: 0;
margin-top: 2px;
}
.IssueRow-main, .PullRow-main {
flex: 1;
min-width: 0;
}
.IssueRow-title, .PullRow-title {
font-size: 14px;
font-weight: 600;
color: var(--color-fg-default);
margin-bottom: 4px;
}
.IssueRow-title:hover, .PullRow-title:hover {
color: var(--color-accent-fg);
}
.IssueRow-subtitle, .PullRow-subtitle {
font-size: 12px;
color: var(--color-fg-subtle);
}
.IssueRow-labels {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin-top: 4px;
}
.Label {
display: inline-block;
padding: 0 7px;
font-size: 12px;
font-weight: 500;
line-height: 18px;
border-radius: 16px;
border: 1px solid transparent;
}
.Label--dependency {
background: rgba(210, 153, 34, 0.15);
color: var(--color-attention-fg);
border-color: rgba(210, 153, 34, 0.3);
}
/* ================================================================
Subnav — filter tabs within a view (Open/Closed)
================================================================ */
.Subnav {
display: flex;
align-items: center;
gap: 0;
padding: 16px 16px 0 16px;
}
.Subnav-item {
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
color: var(--color-fg-muted);
cursor: pointer;
background: none;
border: 1px solid transparent;
border-bottom: none;
border-radius: 6px 6px 0 0;
font-family: inherit;
}
.Subnav-item:hover { color: var(--color-fg-default); }
.Subnav-item.selected {
color: var(--color-fg-default);
background: var(--color-canvas-default);
border-color: var(--color-border-default);
}
/* ================================================================
ActivityFeed — unified timeline
================================================================ */
.ActivityFeed-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--color-border-muted);
animation: fade-in 0.2s ease-out;
}
.ActivityFeed-item:last-child { border-bottom: none; }
.ActivityFeed-icon {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 14px;
background: var(--color-canvas-subtle);
border: 1px solid var(--color-border-default);
}
.ActivityFeed-body {
flex: 1;
min-width: 0;
}
.ActivityFeed-headline {
font-size: 14px;
color: var(--color-fg-default);
}
.ActivityFeed-time {
font-size: 12px;
color: var(--color-fg-subtle);
margin-top: 2px;
}
/* ================================================================
DigestView — workspace overview
================================================================ */
.DigestGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.DigestCard {
border: 1px solid var(--color-border-default);
border-radius: 6px;
overflow: hidden;
}
.DigestCard-header {
padding: 12px 16px;
font-size: 13px;
font-weight: 600;
color: var(--color-fg-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
background: var(--color-canvas-subtle);
border-bottom: 1px solid var(--color-border-default);
}
.DigestCard-body {
padding: 16px;
}
.DigestStat {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 8px;
}
.DigestStat:last-child { margin-bottom: 0; }
.DigestStat-value {
font-size: 24px;
font-weight: 700;
color: var(--color-fg-default);
}
.DigestStat-label {
font-size: 13px;
color: var(--color-fg-muted);
}
/* ================================================================
BackButton
================================================================ */
.BackButton {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
font-size: 14px;
color: var(--color-accent-fg);
background: none;
border: 1px solid var(--color-border-default);
border-radius: 6px;
cursor: pointer;
font-family: inherit;
margin-bottom: 16px;
}
.BackButton:hover {
background: var(--color-canvas-subtle);
}
/* ================================================================
EmptyState
================================================================ */
.EmptyState {
text-align: center;
padding: 48px 24px;
color: var(--color-fg-muted);
}
.EmptyState-heading {
font-size: 18px;
font-weight: 600;
color: var(--color-fg-default);
margin-bottom: 8px;
}
.EmptyState-description {
font-size: 14px;
max-width: 400px;
margin: 0 auto;
}
/* ================================================================
Detail — problem/proposal detail pane
================================================================ */
.Detail-comments {
margin-top: 16px;
}
.Detail-comment {
display: flex;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid var(--color-border-muted);
}
.Detail-comment:last-child { border-bottom: none; }
.Detail-comment-body {
flex: 1;
font-size: 14px;
color: var(--color-fg-default);
white-space: pre-wrap;
word-break: break-word;
}
.Detail-comment-meta {
font-size: 12px;
color: var(--color-fg-subtle);
margin-bottom: 4px;
}
/* Review verdict */
.ReviewBadge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
font-size: 12px;
font-weight: 600;
border-radius: 6px;
}
.ReviewBadge--approve {
background: rgba(63, 185, 80, 0.15);
color: var(--color-success-fg);
}
.ReviewBadge--request-changes {
background: rgba(248, 81, 73, 0.15);
color: var(--color-danger-fg);
}
.ReviewBadge--comment {
background: rgba(139, 148, 158, 0.15);
color: var(--color-fg-muted);
}
/* ================================================================
Session Selector — session picker in Code view header
================================================================ */
.SessionSelect {
display: flex;
align-items: center;
gap: 8px;
}
.SessionSelect-btn {
padding: 4px 12px;
font-size: 13px;
font-weight: 500;
color: var(--color-fg-muted);
background: none;
border: 1px solid var(--color-border-default);
border-radius: 6px;
cursor: pointer;
font-family: inherit;
transition: all 0.15s;
}
.SessionSelect-btn:hover { background: var(--color-canvas-subtle); color: var(--color-fg-default); }
.SessionSelect-btn.active {
background: var(--color-accent-emphasis);
color: #fff;
border-color: var(--color-accent-emphasis);
}
/* ================================================================
Workspace Selector
================================================================ */
.WorkspaceSelect {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.WorkspaceSelect select {
padding: 5px 10px;
font-size: 13px;
color: var(--color-fg-default);
background: var(--color-canvas-subtle);
border: 1px solid var(--color-border-default);
border-radius: 6px;
font-family: inherit;
cursor: pointer;
}
.WorkspaceSelect label {
font-size: 13px;
color: var(--color-fg-muted);
font-weight: 600;
}
/* ================================================================
Responsive
================================================================ */
@media (max-width: 768px) {
.AppHeader { padding: 8px 16px; }
.UnderlineNav { padding: 0 16px; }
.Main-container { padding: 16px; }
.DigestGrid { grid-template-columns: 1fr; }
.CommitRow-meta { display: none; }
.AvatarStack { display: none; }
}
/* hidden utility */
.hidden { display: none !important; }
</style>
</head>
<body>
<div id="app">
<!-- AppHeader -->
<header class="AppHeader">
<div class="AppHeader-context">
<span class="AppHeader-repoIcon">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8ZM5 12.25a.25.25 0 0 1 .25-.25h3.5a.25.25 0 0 1 .25.25v3.25a.25.25 0 0 1-.4.2l-1.45-1.087a.249.249 0 0 0-.3 0L5.4 15.7a.25.25 0 0 1-.4-.2Z"/></svg>
</span>
<span class="AppHeader-repoName" id="repo-name">Thoughtbox Observatory</span>
<span class="AppHeader-separator">/</span>
<span class="AppHeader-sessionName" id="session-name">No session</span>
</div>
<div class="AppHeader-right">
<div id="avatar-stack" class="AvatarStack"></div>
<div id="connection-status" class="ConnectionStatus">
<span class="ConnectionStatus-dot disconnected"></span>
<span>Connecting...</span>
</div>
</div>
</header>
<!-- Tab Navigation -->
<nav class="UnderlineNav" id="tab-nav">
<button class="UnderlineNav-item selected" data-tab="sessions">
<span class="UnderlineNav-icon"><svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M4.72 3.22a.75.75 0 0 1 1.06 1.06L2.06 8l3.72 3.72a.75.75 0 1 1-1.06 1.06L.47 8.53a.75.75 0 0 1 0-1.06Zm6.56 0a.75.75 0 1 0-1.06 1.06L13.94 8l-3.72 3.72a.75.75 0 1 0 1.06 1.06l4.25-4.25a.75.75 0 0 0 0-1.06Z"/></svg></span>
Sessions
<span class="Counter" id="count-sessions">0</span>
</button>
<button class="UnderlineNav-item" data-tab="problems">
<span class="UnderlineNav-icon"><svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"/><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z"/></svg></span>
Problems
<span class="Counter" id="count-problems">0</span>
</button>
<button class="UnderlineNav-item" data-tab="proposals">
<span class="UnderlineNav-icon"><svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z"/></svg></span>
Proposals
<span class="Counter" id="count-proposals">0</span>
</button>
<button class="UnderlineNav-item" data-tab="activity">
<span class="UnderlineNav-icon"><svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M11.93 8.5a4.002 4.002 0 0 1-7.86 0H.75a.75.75 0 0 1 0-1.5h3.32a4.002 4.002 0 0 1 7.86 0h3.32a.75.75 0 0 1 0 1.5Zm-1.43-.75a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"/></svg></span>
Activity
</button>
<button class="UnderlineNav-item" data-tab="digest">
<span class="UnderlineNav-icon"><svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M0 1.75A.75.75 0 0 1 .75 1h4.253c1.227 0 2.317.59 3 1.501A3.743 3.743 0 0 1 11.006 1h4.245a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-4.507a2.25 2.25 0 0 0-1.591.659l-.622.621a.75.75 0 0 1-1.06 0l-.622-.621A2.25 2.25 0 0 0 5.258 13H.75a.75.75 0 0 1-.75-.75Zm7.251 10.324.004-5.073-.002-2.253A2.25 2.25 0 0 0 5.003 2.5H1.5v9h3.757a3.75 3.75 0 0 1 1.994.574ZM8.755 4.75l-.004 7.322a3.752 3.752 0 0 1 1.992-.572H14.5v-9h-3.495a2.25 2.25 0 0 0-2.25 2.25Z"/></svg></span>
Digest
</button>
</nav>
<!-- Main Content Area -->
<main class="Main" id="main-content">
<!-- Views are rendered here by JS -->
</main>
</div>
<script>
// ================================================================
// Observatory Client — WebSocket connection management
// ================================================================
class ObservatoryClient {
constructor() {
this.ws = null;
this.subscriptions = new Set();
this.handlers = new Map();
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 10;
this.reconnectDelay = 1000;
}
connect(url) {
return new Promise((resolve, reject) => {
try {
this.ws = new WebSocket(url);
this.ws.onopen = () => {
this.reconnectAttempts = 0;
this.reconnectDelay = 1000;
this.emit('connected');
resolve();
};
this.ws.onclose = (event) => {
this.emit('disconnected');
this.attemptReconnect(url);
};
this.ws.onerror = (error) => reject(error);
this.ws.onmessage = (event) => {
try {
const [topic, eventName, payload] = JSON.parse(event.data);
this.handleMessage(topic, eventName, payload);
} catch (err) {
console.error('[Observatory] Parse error', err);
}
};
} catch (err) { reject(err); }
});
}
attemptReconnect(url) {
if (this.reconnectAttempts >= this.maxReconnectAttempts) return;
this.reconnectAttempts++;
const delay = Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1), 30000);
setTimeout(() => this.connect(url).catch(() => {}), delay);
}
subscribe(topic) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.subscriptions.add(topic);
this.send(topic, 'subscribe', {});
}
}
unsubscribe(topic) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.subscriptions.delete(topic);
this.send(topic, 'unsubscribe', {});
}
}
send(topic, event, payload) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify([topic, event, payload]));
}
}
on(event, handler) {
if (!this.handlers.has(event)) this.handlers.set(event, []);
this.handlers.get(event).push(handler);
}
emit(event, data) {
if (this.handlers.has(event)) {
this.handlers.get(event).forEach(h => h(data));
}
}
handleMessage(topic, event, payload) {
this.emit(`${topic}:${event}`, payload);
this.emit('message', { topic, event, payload });
}
}
// ================================================================
// Utility functions
// ================================================================
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function relativeTime(isoString) {
if (!isoString) return '';
const diff = Date.now() - new Date(isoString).getTime();
const secs = Math.floor(diff / 1000);
if (secs < 60) return 'just now';
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
}
function shortHash(id) {
return id ? id.slice(0, 8) : '';
}
function profileColor(profile) {
const colors = {
MANAGER: 'var(--color-manager)', ARCHITECT: 'var(--color-architect)',
DEBUGGER: 'var(--color-debugger)', SECURITY: 'var(--color-security)',
RESEARCHER: 'var(--color-researcher)', REVIEWER: 'var(--color-reviewer)',
};
return colors[(profile || '').toUpperCase()] || 'var(--color-fg-subtle)';
}
function statusToStateLabel(status) {
const map = {
'open': 'StateLabel--open', 'in-progress': 'StateLabel--in-progress',
'resolved': 'StateLabel--resolved', 'closed': 'StateLabel--closed',
'reviewing': 'StateLabel--reviewing', 'merged': 'StateLabel--merged',
'rejected': 'StateLabel--rejected',
};
return map[status] || 'StateLabel--open';
}
function problemIcon(status) {
if (status === 'closed' || status === 'resolved') {
return `<svg width="16" height="16" viewBox="0 0 16 16" fill="var(--color-done-fg)"><path d="M11.28 6.78a.75.75 0 0 0-1.06-1.06L7.25 8.69 5.78 7.22a.75.75 0 0 0-1.06 1.06l2 2a.75.75 0 0 0 1.06 0ZM16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0Zm-1.5 0a6.5 6.5 0 1 0-13 0 6.5 6.5 0 0 0 13 0Z"/></svg>`;
}
return `<svg width="16" height="16" viewBox="0 0 16 16" fill="var(--color-success-fg)"><path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"/><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z"/></svg>`;
}
function proposalIcon(status) {
if (status === 'merged') {
return `<svg width="16" height="16" viewBox="0 0 16 16" fill="var(--color-done-fg)"><path d="M5.45 5.154A4.25 4.25 0 0 0 9.25 7.5h1.378a2.251 2.251 0 1 1 0 1.5H9.25A5.734 5.734 0 0 1 5 7.123v3.505a2.25 2.25 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.95-.218ZM4.25 13.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm8-9a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM4.25 4a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z"/></svg>`;
}
if (status === 'rejected') {
return `<svg width="16" height="16" viewBox="0 0 16 16" fill="var(--color-danger-fg)"><path d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z"/></svg>`;
}
return `<svg width="16" height="16" viewBox="0 0 16 16" fill="var(--color-success-fg)"><path d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z"/></svg>`;
}
// ================================================================
// State
// ================================================================
const state = {
connected: false,
currentTab: 'sessions',
// Reasoning state
sessions: [],
activeSessionId: null,
thoughts: new Map(),
branches: new Map(),
arrivalOrder: [],
branchRegistry: new Map(),
selectedThoughtId: null,
// Hub state
activeWorkspaceId: null,
workspaces: [],
problems: [],
proposals: [],
consensusMarkers: [],
agents: [],
activityFeed: [],
// Filters
problemFilter: 'open',
proposalFilter: 'open',
};
// Branch label helpers
function generateBranchLabel(index) {
if (index < 26) return String.fromCharCode(65 + index);
return String.fromCharCode(65 + Math.floor((index - 26) / 26)) +
String.fromCharCode(65 + ((index - 26) % 26));
}
function registerBranch(branchId, originThoughtNumber) {
if (!state.branchRegistry.has(branchId)) {
state.branchRegistry.set(branchId, {
label: generateBranchLabel(state.branchRegistry.size),
originThoughtNumber,
thoughtCount: 1,
});
} else {
state.branchRegistry.get(branchId).thoughtCount++;
}
}
// ================================================================
// Router — hash-based
// ================================================================
function navigate(tab, detailId) {
if (detailId) {
window.location.hash = `#/${tab}/${detailId}`;
} else {
window.location.hash = `#/${tab}`;
}
}
function parseRoute() {
const hash = window.location.hash || '#/sessions';
const parts = hash.replace('#/', '').split('/');
return { tab: parts[0] || 'sessions', detailId: parts[1] || null };
}
function handleRoute() {
const { tab, detailId } = parseRoute();
state.currentTab = tab;
state.selectedThoughtId = (tab === 'sessions' && detailId) ? detailId : null;
// Update tab nav
document.querySelectorAll('.UnderlineNav-item').forEach(btn => {
btn.classList.toggle('selected', btn.dataset.tab === tab);
});
render();
}
// ================================================================
// Data fetching
// ================================================================
async function fetchAllSessions() {
try {
const resp = await fetch('/api/sessions?limit=50');
if (!resp.ok) return;
const data = await resp.json();
const fetched = data.sessions || [];
const existingIds = new Set(state.sessions.map(s => s.id));
for (const s of fetched) {
if (!existingIds.has(s.id)) {
state.sessions.push(s);
}
}
// Sort: active first, then by date desc
state.sessions.sort((a, b) => {
const aActive = a.status === 'active';
const bActive = b.status === 'active';
if (aActive && !bActive) return -1;
if (!aActive && bActive) return 1;
return new Date(b.createdAt || 0) - new Date(a.createdAt || 0);
});
if (!state.activeSessionId && state.sessions.length > 0) {
selectSession(state.sessions[0].id);
}
render();
} catch (e) { /* fetch failed */ }
}
async function fetchSessionDetail(sessionId) {
try {
const resp = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`);
if (!resp.ok) return null;
return await resp.json();
} catch (e) { return null; }
}
async function fetchWorkspaces() {
try {
const res = await fetch('/api/hub/workspaces');
if (!res.ok) return;
const data = await res.json();
state.workspaces = data.workspaces || [];
if (state.workspaces.length > 0 && !state.activeWorkspaceId) {
state.activeWorkspaceId = state.workspaces[0].id;
fetchWorkspaceData();
}
render();
} catch (e) { /* hub not available */ }
}
async function fetchWorkspaceData() {
if (!state.activeWorkspaceId) return;
const wsId = state.activeWorkspaceId;
try {
const [problemsRes, proposalsRes, agentsRes, consensusRes] = await Promise.all([
fetch(`/api/hub/workspaces/${wsId}/problems`),
fetch(`/api/hub/workspaces/${wsId}/proposals`),
fetch(`/api/hub/workspaces/${wsId}/agents`),
fetch(`/api/hub/workspaces/${wsId}/consensus`),
]);
if (problemsRes.ok) state.problems = (await problemsRes.json()).problems || [];
if (proposalsRes.ok) state.proposals = (await proposalsRes.json()).proposals || [];
if (agentsRes.ok) state.agents = (await agentsRes.json()).agents || [];
if (consensusRes.ok) state.consensusMarkers = (await consensusRes.json()).markers || [];
updateCounts();
render();
} catch (e) { /* hub not available */ }
}
function addActivity(type, icon, headline, timestamp) {
state.activityFeed.unshift({ type, icon, headline, timestamp: timestamp || new Date().toISOString() });
if (state.activityFeed.length > 200) state.activityFeed.length = 200;
}
// ================================================================
// Render dispatcher
// ================================================================
function render() {
const main = document.getElementById('main-content');
const { tab, detailId } = parseRoute();
switch (tab) {
case 'sessions':
if (state.selectedThoughtId) {
main.innerHTML = renderCommitDetail(state.selectedThoughtId);
} else {
main.innerHTML = renderSessionsView();
}
break;
case 'problems':
if (detailId) {
main.innerHTML = renderProblemDetail(detailId);
} else {
main.innerHTML = renderProblemsView();
}
break;
case 'proposals':
if (detailId) {
main.innerHTML = renderProposalDetail(detailId);
} else {
main.innerHTML = renderProposalsView();
}
break;
case 'activity':
main.innerHTML = renderActivityView();
break;
case 'digest':
main.innerHTML = renderDigestView();
break;
default:
main.innerHTML = renderSessionsView();
}
bindViewEvents();
}
function updateCounts() {
document.getElementById('count-sessions').textContent = state.sessions.length;
const openProblems = state.problems.filter(p => p.status === 'open' || p.status === 'in-progress');
document.getElementById('count-problems').textContent = openProblems.length;
const openProposals = state.proposals.filter(p => p.status === 'open' || p.status === 'reviewing');
document.getElementById('count-proposals').textContent = openProposals.length;
}
// ================================================================
// Sessions View — commit list with SVG graph rail
// ================================================================
function renderSessionsView() {
// Session selector
const sessionBtns = state.sessions.map(s => {
const label = escapeHtml(s.title || s.id.slice(0, 8));
const cls = s.id === state.activeSessionId ? 'active' : '';
return `<button class="SessionSelect-btn ${cls}" data-session-id="${escapeHtml(s.id)}">${label}</button>`;
}).join('');
// Build ordered thought list (main chain first, then branches)
const mainThoughts = state.arrivalOrder
.map(id => state.thoughts.get(id))
.filter(t => t && !t.branchId);
const branchThoughts = state.arrivalOrder
.map(id => state.thoughts.get(id))
.filter(t => t && t.branchId);
const allThoughts = [...mainThoughts, ...branchThoughts];
if (allThoughts.length === 0 && state.sessions.length === 0) {
return `<div class="Main-container">
<div class="EmptyState">
<div class="EmptyState-heading">No sessions yet</div>
<div class="EmptyState-description">Start a reasoning session to see thoughts appear here.</div>
</div>
</div>`;
}
// Build commit graph SVG data
// Each thought gets a lane (main=0, branches get lane 1+)
const branchLanes = new Map(); // branchId -> lane number
let nextLane = 1;
const graphData = allThoughts.map((t, i) => {
let lane = 0;
if (t.branchId) {
if (!branchLanes.has(t.branchId)) branchLanes.set(t.branchId, nextLane++);
lane = branchLanes.get(t.branchId);
}
return { thought: t, lane, index: i };
});
const rowHeight = 48;
const laneWidth = 20;
const laneOffset = 30;
const totalLanes = nextLane;
const svgWidth = laneOffset + totalLanes * laneWidth;
const svgHeight = allThoughts.length * rowHeight;
// Build SVG paths
let svgPaths = '';
// Vertical lines for each lane
const laneRanges = new Map(); // lane -> {min, max}
graphData.forEach(g => {
const lane = g.lane;
const y = g.index * rowHeight + rowHeight / 2;
if (!laneRanges.has(lane)) laneRanges.set(lane, { min: y, max: y });
const r = laneRanges.get(lane);
r.min = Math.min(r.min, y);
r.max = Math.max(r.max, y);
});
const laneColors = [
'var(--color-success-fg)', 'var(--color-done-fg)', 'var(--color-accent-fg)',
'var(--color-attention-fg)', 'var(--color-sponsors-fg)', 'var(--color-danger-fg)',
];
laneRanges.forEach((range, lane) => {
const x = laneOffset + lane * laneWidth;
const color = laneColors[lane % laneColors.length];
if (range.max > range.min) {
svgPaths += `<line x1="${x}" y1="${range.min}" x2="${x}" y2="${range.max}" stroke="${color}" stroke-width="2" opacity="0.5"/>`;
}
});
// Fork lines (from main to branch start)
graphData.forEach(g => {
if (g.thought.branchId && g.thought.branchFromThought) {
// Find the origin thought in main chain
const originIdx = graphData.findIndex(d =>
!d.thought.branchId && d.thought.thoughtNumber === g.thought.branchFromThought
);
if (originIdx >= 0 && originIdx < g.index) {
const fromX = laneOffset + 0; // main lane
const fromY = originIdx * rowHeight + rowHeight / 2;
const toX = laneOffset + g.lane * laneWidth;
const toY = g.index * rowHeight + rowHeight / 2;
const color = laneColors[g.lane % laneColors.length];
// Only draw for the first thought in a branch
const isFirstInBranch = !graphData.slice(0, g.index).some(
d => d.thought.branchId === g.thought.branchId
);
if (isFirstInBranch) {
svgPaths += `<path d="M${fromX},${fromY} C${fromX + 10},${fromY + 20} ${toX - 10},${toY - 20} ${toX},${toY}" fill="none" stroke="${color}" stroke-width="2" opacity="0.5"/>`;
}
}
}
});
// Dots for each commit
let svgDots = '';
graphData.forEach(g => {
const x = laneOffset + g.lane * laneWidth;
const y = g.index * rowHeight + rowHeight / 2;
const color = laneColors[g.lane % laneColors.length];
svgDots += `<circle cx="${x}" cy="${y}" r="4" fill="${color}"/>`;
});
const commitRows = allThoughts.map(t => {
const msg = escapeHtml((t.thought || '').split('\n')[0].slice(0, 120) || `Thought #${t.thoughtNumber}`);
const hash = shortHash(t.id);
const time = relativeTime(t.timestamp);
const branchLabel = t.branchId ? state.branchRegistry.get(t.branchId) : null;
const branchTag = branchLabel
? `<span class="BranchName">${escapeHtml(branchLabel.label)}</span>`
: '';
return `<li class="CommitRow CommitRow--clickable" data-thought-id="${escapeHtml(t.id)}">
<div class="CommitRow-content">
<span class="CommitRow-message">${branchTag} ${msg}</span>
<span class="CommitRow-meta">
<span class="CommitRow-hash">${hash}</span>
<span class="CommitRow-time">${time}</span>
</span>
</div>
</li>`;
}).join('');
return `<div class="Main-container">
${state.sessions.length > 0 ? `<div class="SessionSelect" style="margin-bottom: 16px;">${sessionBtns}</div>` : ''}
${state.activeWorkspaceId ? `<div class="WorkspaceSelect">
<label>Workspace:</label>
<select id="workspace-select">${state.workspaces.map(w =>
`<option value="${escapeHtml(w.id)}" ${w.id === state.activeWorkspaceId ? 'selected' : ''}>${escapeHtml(w.name)}</option>`
).join('')}</select>
</div>` : ''}
<div class="Box">
<div class="Box-header">
<span class="Box-header-title">${state.thoughts.size} thoughts</span>
${state.branchRegistry.size > 0 ? `<span style="font-size:12px;color:var(--color-fg-subtle)">${state.branchRegistry.size} branch${state.branchRegistry.size !== 1 ? 'es' : ''}</span>` : ''}
</div>
<div style="display:flex;">
<div style="flex-shrink:0;width:${svgWidth}px;">
<svg width="${svgWidth}" height="${svgHeight}" style="display:block;">
${svgPaths}
${svgDots}
</svg>
</div>
<div style="flex:1;min-width:0;">
<ul class="CommitList">${commitRows || '<li class="EmptyState" style="padding:24px"><div class="EmptyState-description">Waiting for thoughts...</div></li>'}</ul>
</div>
</div>
</div>
</div>`;
}
// ================================================================
// Commit Detail
// ================================================================
function renderCommitDetail(thoughtId) {
const thought = state.thoughts.get(thoughtId);
if (!thought) return `<div class="Main-container"><div class="EmptyState"><div class="EmptyState-heading">Thought not found</div></div></div>`;
const content = thought.thought || '';
const mcpData = {
thoughtNumber: thought.thoughtNumber,
totalThoughts: thought.totalThoughts,
nextThoughtNeeded: thought.nextThoughtNeeded,
branchId: thought.branchId || null,
branchFromThought: thought.branchFromThought || null,
};
const branchLabel = thought.branchId ? state.branchRegistry.get(thought.branchId) : null;
return `<div class="Main-container">
<button class="BackButton" data-action="back-to-sessions">← Back to sessions</button>
<div class="CommitDetail">
<div class="CommitDetail-header">
<div class="CommitDetail-title">Thought #${thought.thoughtNumber} ${branchLabel ? `<span class="BranchName">${escapeHtml(branchLabel.label)}</span>` : ''}</div>
<div class="CommitDetail-metaRow">
<span class="CommitRow-hash">${shortHash(thought.id)}</span>
<span>${relativeTime(thought.timestamp)}</span>
<span>Step ${thought.thoughtNumber} of ${thought.totalThoughts || '?'}</span>
</div>
</div>
<div class="CommitDetail-body">
<div class="CommitDetail-codeBlock">${escapeHtml(content)}</div>
<details style="margin-top:16px;">
<summary style="cursor:pointer;color:var(--color-fg-muted);font-size:13px;">MCP metadata</summary>
<div class="CommitDetail-codeBlock" style="margin-top:8px;font-size:12px;">${escapeHtml(JSON.stringify(mcpData, null, 2))}</div>
</details>
</div>
</div>
</div>`;
}
// ================================================================
// Problems View — problem list
// ================================================================
function renderProblemsView() {
const filtered = state.problems.filter(p => {
if (state.problemFilter === 'open') return p.status === 'open' || p.status === 'in-progress';
return p.status === 'closed' || p.status === 'resolved';
});
const openCount = state.problems.filter(p => p.status === 'open' || p.status === 'in-progress').length;
const closedCount = state.problems.filter(p => p.status === 'closed' || p.status === 'resolved').length;
const rows = filtered.map(p => {
const depLabels = (p.dependsOn || []).map(depId => {
const dep = state.problems.find(pp => pp.id === depId);
return dep ? `<span class="Label Label--dependency">depends on: ${escapeHtml(dep.title.slice(0, 30))}</span>` : '';
}).join('');
const assignee = p.assignedTo
? (() => {
const agent = state.agents.find(a => a.agentId === p.assignedTo);
return agent ? `<span class="AgentPill" data-profile="${agent.profile || ''}"><span class="AgentPill-dot" style="background:${profileColor(agent.profile)}"></span>${escapeHtml(agent.name || p.assignedTo.slice(0,8))}</span>` : '';
})()
: '';
const commentsCount = (p.comments || []).length;
return `<div class="IssueRow" data-problem-id="${escapeHtml(p.id)}">
<span class="IssueRow-icon">${problemIcon(p.status)}</span>
<div class="IssueRow-main">
<div class="IssueRow-title">${escapeHtml(p.title)}</div>
<div class="IssueRow-subtitle">
<span class="StateLabel ${statusToStateLabel(p.status)}">${escapeHtml(p.status)}</span>
opened ${relativeTime(p.createdAt)}
${assignee ? ` · assigned to ${assignee}` : ''}
${commentsCount > 0 ? ` · ${commentsCount} comment${commentsCount !== 1 ? 's' : ''}` : ''}
</div>
${depLabels ? `<div class="IssueRow-labels">${depLabels}</div>` : ''}
</div>
</div>`;
}).join('');
return `<div class="Main-container">
<div class="Box">
<div class="Box-header">
<div style="display:flex;gap:16px;">
<button class="Subnav-item ${state.problemFilter === 'open' ? 'selected' : ''}" data-problem-filter="open">${openCount} Open</button>
<button class="Subnav-item ${state.problemFilter === 'closed' ? 'selected' : ''}" data-problem-filter="closed">${closedCount} Closed</button>
</div>
</div>
${rows || `<div class="EmptyState" style="padding:32px"><div class="EmptyState-heading">No problems</div><div class="EmptyState-description">Problems created in Hub workspaces appear here.</div></div>`}
</div>
</div>`;
}
// ================================================================
// Problem Detail
// ================================================================
function renderProblemDetail(problemId) {
const problem = state.problems.find(p => p.id === problemId);
if (!problem) return `<div class="Main-container"><div class="EmptyState"><div class="EmptyState-heading">Problem not found</div></div></div>`;
const comments = (problem.comments || []).map(c => {
const agent = state.agents.find(a => a.agentId === c.agentId);
const name = agent ? agent.name : c.agentId.slice(0, 8);
return `<div class="Detail-comment">
<span class="AgentPill" data-profile="${agent?.profile || ''}"><span class="AgentPill-dot" style="background:${profileColor(agent?.profile)}"></span>${escapeHtml(name)}</span>
<div>
<div class="Detail-comment-meta">${relativeTime(c.createdAt)}</div>
<div class="Detail-comment-body">${escapeHtml(c.content)}</div>
</div>
</div>`;
}).join('');
return `<div class="Main-container">
<button class="BackButton" data-action="back-to-problems">← Back to problems</button>
<div class="Box">
<div class="Box-header">
<div>
<div class="Box-header-title" style="font-size:18px;margin-bottom:4px;">${escapeHtml(problem.title)}</div>
<span class="StateLabel ${statusToStateLabel(problem.status)}">${escapeHtml(problem.status)}</span>
<span style="margin-left:8px;font-size:12px;color:var(--color-fg-subtle)">opened ${relativeTime(problem.createdAt)}</span>
</div>
</div>
<div style="padding:16px;">
<div style="white-space:pre-wrap;word-break:break-word;font-size:14px;color:var(--color-fg-default)">${escapeHtml(problem.description || 'No description.')}</div>
${comments ? `<div class="Detail-comments"><div style="font-weight:600;font-size:13px;color:var(--color-fg-muted);margin-bottom:8px;">Comments</div>${comments}</div>` : ''}
</div>
</div>
</div>`;
}
// ================================================================
// Proposals View — proposal list
// ================================================================
function renderProposalsView() {
const filtered = state.proposals.filter(p => {
if (state.proposalFilter === 'open') return p.status === 'open' || p.status === 'reviewing';
return p.status === 'merged' || p.status === 'rejected';
});
const openCount = state.proposals.filter(p => p.status === 'open' || p.status === 'reviewing').length;
const closedCount = state.proposals.filter(p => p.status === 'merged' || p.status === 'rejected').length;
const rows = filtered.map(p => {
const reviewBadges = (p.reviews || []).map(r => {
return `<span class="ReviewBadge ReviewBadge--${r.verdict}">${escapeHtml(r.verdict)}</span>`;
}).join(' ');
return `<div class="PullRow" data-proposal-id="${escapeHtml(p.id)}">
<span class="PullRow-icon">${proposalIcon(p.status)}</span>
<div class="PullRow-main">
<div class="PullRow-title">${escapeHtml(p.title)}</div>
<div class="PullRow-subtitle">
<span class="StateLabel ${statusToStateLabel(p.status)}">${escapeHtml(p.status)}</span>
${p.sourceBranch ? `<span class="BranchName" style="margin-left:6px">${escapeHtml(p.sourceBranch)}</span>` : ''}
opened ${relativeTime(p.createdAt)}
${reviewBadges ? ` · ${reviewBadges}` : ''}
</div>
</div>
</div>`;
}).join('');
return `<div class="Main-container">
<div class="Box">
<div class="Box-header">
<div style="display:flex;gap:16px;">
<button class="Subnav-item ${state.proposalFilter === 'open' ? 'selected' : ''}" data-proposal-filter="open">${openCount} Open</button>
<button class="Subnav-item ${state.proposalFilter === 'closed' ? 'selected' : ''}" data-proposal-filter="closed">${closedCount} Closed</button>
</div>
</div>
${rows || `<div class="EmptyState" style="padding:32px"><div class="EmptyState-heading">No proposals</div><div class="EmptyState-description">Proposals created in Hub workspaces appear here.</div></div>`}
</div>
</div>`;
}
// ================================================================
// Proposal Detail
// ================================================================
function renderProposalDetail(proposalId) {
const proposal = state.proposals.find(p => p.id === proposalId);
if (!proposal) return `<div class="Main-container"><div class="EmptyState"><div class="EmptyState-heading">Proposal not found</div></div></div>`;
const reviews = (proposal.reviews || []).map(r => {
const agent = state.agents.find(a => a.agentId === r.reviewerId);
const name = agent ? agent.name : r.reviewerId.slice(0, 8);
return `<div class="Detail-comment">
<div>
<span class="AgentPill" data-profile="${agent?.profile || ''}"><span class="AgentPill-dot" style="background:${profileColor(agent?.profile)}"></span>${escapeHtml(name)}</span>
<span class="ReviewBadge ReviewBadge--${r.verdict}" style="margin-left:8px">${escapeHtml(r.verdict)}</span>
</div>
<div>
<div class="Detail-comment-meta">${relativeTime(r.createdAt)}</div>
<div class="Detail-comment-body">${escapeHtml(r.reasoning)}</div>
</div>
</div>`;
}).join('');
return `<div class="Main-container">
<button class="BackButton" data-action="back-to-proposals">← Back to proposals</button>
<div class="Box">
<div class="Box-header">
<div>
<div class="Box-header-title" style="font-size:18px;margin-bottom:4px;">${escapeHtml(proposal.title)}</div>
<span class="StateLabel ${statusToStateLabel(proposal.status)}">${escapeHtml(proposal.status)}</span>
${proposal.sourceBranch ? `<span class="BranchName" style="margin-left:8px">${escapeHtml(proposal.sourceBranch)}</span>` : ''}
<span style="margin-left:8px;font-size:12px;color:var(--color-fg-subtle)">opened ${relativeTime(proposal.createdAt)}</span>
</div>
</div>
<div style="padding:16px;">
<div style="white-space:pre-wrap;word-break:break-word;font-size:14px;color:var(--color-fg-default)">${escapeHtml(proposal.description || 'No description.')}</div>
${reviews ? `<div class="Detail-comments"><div style="font-weight:600;font-size:13px;color:var(--color-fg-muted);margin-bottom:8px;">Reviews</div>${reviews}</div>` : ''}
</div>
</div>
</div>`;
}
// ================================================================
// Activity View — unified event timeline
// ================================================================
function renderActivityView() {
if (state.activityFeed.length === 0) {
return `<div class="Main-container">
<div class="EmptyState">
<div class="EmptyState-heading">No activity yet</div>
<div class="EmptyState-description">Events from reasoning sessions and Hub workspaces appear here in real-time.</div>
</div>
</div>`;
}
const items = state.activityFeed.slice(0, 100).map(item => {
return `<div class="ActivityFeed-item">
<div class="ActivityFeed-icon">${item.icon}</div>
<div class="ActivityFeed-body">
<div class="ActivityFeed-headline">${item.headline}</div>
<div class="ActivityFeed-time">${relativeTime(item.timestamp)}</div>
</div>
</div>`;
}).join('');
return `<div class="Main-container">
<div class="Box">
<div class="Box-header">
<span class="Box-header-title">Activity</span>
<span style="font-size:12px;color:var(--color-fg-subtle)">${state.activityFeed.length} events</span>
</div>
${items}
</div>
</div>`;
}
// ================================================================
// Digest View — workspace overview
// ================================================================
function renderDigestView() {
if (!state.activeWorkspaceId || state.workspaces.length === 0) {
return `<div class="Main-container">
<div class="EmptyState">
<div class="EmptyState-heading">No workspace</div>
<div class="EmptyState-description">Create a Hub workspace to see its digest here.</div>
</div>
</div>`;
}
const ws = state.workspaces.find(w => w.id === state.activeWorkspaceId);
const wsName = ws ? ws.name : state.activeWorkspaceId.slice(0, 8);
const openProblems = state.problems.filter(p => p.status === 'open' || p.status === 'in-progress').length;
const resolvedProblems = state.problems.filter(p => p.status === 'resolved' || p.status === 'closed').length;
const openProposals = state.proposals.filter(p => p.status === 'open' || p.status === 'reviewing').length;
const mergedProposals = state.proposals.filter(p => p.status === 'merged').length;
const agentRoster = state.agents.map(a => {
const profileBg = profileColor(a.profile);
return `<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<div class="Avatar" style="background:${profileBg};width:24px;height:24px;font-size:10px;">${(a.name || '?')[0].toUpperCase()}</div>
<span style="font-size:13px;color:var(--color-fg-default)">${escapeHtml(a.name || a.agentId.slice(0,8))}</span>
${a.profile ? `<span style="font-size:11px;color:var(--color-fg-subtle)">${escapeHtml(a.profile)}</span>` : ''}
<span style="font-size:11px;color:${a.status === 'online' ? 'var(--color-success-fg)' : 'var(--color-fg-subtle)'}">${a.status || 'unknown'}</span>
</div>`;
}).join('') || '<span style="color:var(--color-fg-subtle);font-size:13px">No agents registered</span>';
return `<div class="Main-container">
<div style="margin-bottom:16px;">
<h2 style="font-size:20px;font-weight:600;margin-bottom:4px;">${escapeHtml(wsName)}</h2>
<span style="font-size:13px;color:var(--color-fg-muted)">${ws ? escapeHtml(ws.description || '') : ''}</span>
</div>
<div class="DigestGrid">
<div class="DigestCard">
<div class="DigestCard-header">Problems</div>
<div class="DigestCard-body">
<div class="DigestStat"><span class="DigestStat-value" style="color:var(--color-success-fg)">${openProblems}</span><span class="DigestStat-label">open</span></div>
<div class="DigestStat"><span class="DigestStat-value" style="color:var(--color-done-fg)">${resolvedProblems}</span><span class="DigestStat-label">resolved</span></div>
</div>
</div>
<div class="DigestCard">
<div class="DigestCard-header">Proposals</div>
<div class="DigestCard-body">
<div class="DigestStat"><span class="DigestStat-value" style="color:var(--color-accent-fg)">${openProposals}</span><span class="DigestStat-label">open</span></div>
<div class="DigestStat"><span class="DigestStat-value" style="color:var(--color-done-fg)">${mergedProposals}</span><span class="DigestStat-label">merged</span></div>
</div>
</div>
<div class="DigestCard">
<div class="DigestCard-header">Agents</div>
<div class="DigestCard-body">${agentRoster}</div>
</div>
<div class="DigestCard">
<div class="DigestCard-header">Consensus</div>
<div class="DigestCard-body">
${state.consensusMarkers.length > 0
? state.consensusMarkers.map(m => `<div style="margin-bottom:8px;">
<span style="font-weight:600;color:var(--color-fg-default)">${escapeHtml(m.name)}</span>
<span style="font-size:12px;color:var(--color-fg-subtle);margin-left:8px;">agreed by ${(m.agreedBy || []).length} agent${(m.agreedBy || []).length !== 1 ? 's' : ''}</span>
</div>`).join('')
: '<span style="color:var(--color-fg-subtle);font-size:13px">No consensus markers</span>'}
</div>
</div>
</div>
</div>`;
}
// ================================================================
// Event binding after render
// ================================================================
function bindViewEvents() {
// Session select buttons
document.querySelectorAll('.SessionSelect-btn').forEach(btn => {
btn.addEventListener('click', () => selectSession(btn.dataset.sessionId));
});
// Workspace select
const wsSelect = document.getElementById('workspace-select');
if (wsSelect) {
wsSelect.addEventListener('change', () => {
state.activeWorkspaceId = wsSelect.value;
fetchWorkspaceData();
});
}
// Thought row clicks
document.querySelectorAll('.CommitRow--clickable').forEach(row => {
row.addEventListener('click', () => navigate('sessions', row.dataset.thoughtId));
});
// Problem row clicks
document.querySelectorAll('.IssueRow').forEach(row => {
row.addEventListener('click', () => navigate('problems', row.dataset.problemId));
});
// Proposal row clicks
document.querySelectorAll('.PullRow').forEach(row => {
row.addEventListener('click', () => navigate('proposals', row.dataset.proposalId));
});
// Back buttons
document.querySelectorAll('[data-action="back-to-sessions"]').forEach(btn => {
btn.addEventListener('click', () => navigate('sessions'));
});
document.querySelectorAll('[data-action="back-to-problems"]').forEach(btn => {
btn.addEventListener('click', () => navigate('problems'));
});
document.querySelectorAll('[data-action="back-to-proposals"]').forEach(btn => {
btn.addEventListener('click', () => navigate('proposals'));
});
// Problem filter
document.querySelectorAll('[data-problem-filter]').forEach(btn => {
btn.addEventListener('click', () => {
state.problemFilter = btn.dataset.problemFilter;
render();
});
});
// Proposal filter
document.querySelectorAll('[data-proposal-filter]').forEach(btn => {
btn.addEventListener('click', () => {
state.proposalFilter = btn.dataset.proposalFilter;
render();
});
});
}
// ================================================================
// Connection status & agent avatars
// ================================================================
function updateConnectionStatus(connected) {
state.connected = connected;
const el = document.getElementById('connection-status');
const dot = el.querySelector('.ConnectionStatus-dot');
if (connected) {
dot.classList.remove('disconnected');
el.querySelector('span:last-child').textContent = 'Connected';
} else {
dot.classList.add('disconnected');
el.querySelector('span:last-child').textContent = 'Disconnected';
}
}
function updateAgentAvatars() {
const stack = document.getElementById('avatar-stack');
stack.innerHTML = state.agents.map(a => {
const bg = profileColor(a.profile);
const initial = (a.name || '?')[0].toUpperCase();
return `<div class="Avatar" title="${escapeHtml(a.name || a.agentId.slice(0,8))} (${a.profile || 'unknown'})" style="background:${bg}">${initial}</div>`;
}).join('');
}
// ================================================================
// Session management
// ================================================================
let currentSessionTopic = null;
function selectSession(sessionId) {
if (state.activeSessionId === sessionId) return;
if (currentSessionTopic) {
client.unsubscribe(currentSessionTopic);
currentSessionTopic = null;
}
state.thoughts.clear();
state.branches.clear();
state.arrivalOrder = [];
state.branchRegistry.clear();
state.activeSessionId = sessionId;
const session = state.sessions.find(s => s.id === sessionId);
document.getElementById('session-name').textContent = session?.title || sessionId.slice(0, 8);
// Subscribe to live WebSocket updates
currentSessionTopic = `reasoning:${sessionId}`;
client.subscribe(currentSessionTopic);
// Also load from REST API (covers historical/completed sessions)
fetchSessionDetail(sessionId).then(data => {
if (!data || state.activeSessionId !== sessionId) return;
const thoughts = data.thoughts || [];
thoughts.sort((a, b) => (a.thoughtNumber || 0) - (b.thoughtNumber || 0));
thoughts.forEach(t => processThought(t, t.parentId || null));
if (data.branches) {
for (const [bid, branch] of Object.entries(data.branches)) {
state.branches.set(bid, branch);
if (branch.thoughts) {
branch.thoughts.forEach(t => {
t.branchId = bid;
t.branchFromThought = t.branchFromThought || branch.branchedFromThought;
processThought(t, t.parentId || null);
});
}
}
}
updateCounts();
render();
});
updateCounts();
render();
}
// ================================================================
// Initialize
// ================================================================
const client = new ObservatoryClient();
// Tab navigation
document.querySelectorAll('.UnderlineNav-item').forEach(btn => {
btn.addEventListener('click', () => navigate(btn.dataset.tab));
});
// Hash routing
window.addEventListener('hashchange', handleRoute);
// Connection handlers
client.on('connected', () => {
updateConnectionStatus(true);
client.subscribe('observatory');
client.subscribe('workspace');
fetchWorkspaces();
fetchAllSessions();
});
client.on('disconnected', () => updateConnectionStatus(false));
// Observatory channel events
client.on('observatory:sessions:active', (payload) => {
// Merge active sessions without clobbering historical ones
const activeSessions = payload.sessions || [];
for (const s of activeSessions) {
const existing = state.sessions.find(e => e.id === s.id);
if (existing) Object.assign(existing, s);
else state.sessions.unshift(s);
}
if (!state.activeSessionId && state.sessions.length > 0) {
selectSession(state.sessions[0].id);
}
render();
});
client.on('observatory:session:started', (payload) => {
const session = payload.session;
if (session && !state.sessions.find(s => s.id === session.id)) {
state.sessions.push(session);
const topic = `reasoning:${session.id}`;
client.subscribe(topic);
if (!state.activeSessionId) {
state.activeSessionId = session.id;
currentSessionTopic = topic;
document.getElementById('session-name').textContent = session.title || session.id.slice(0, 8);
}
addActivity('session', '💭', `Session started: <strong>${escapeHtml(session.title || session.id.slice(0,8))}</strong>`, session.createdAt);
render();
}
});
// Thought events (from observatory channel for immediate visibility)
client.on('observatory:thought:added', (payload) => {
if (!payload.thought || !payload.sessionId) return;
if (!state.activeSessionId) {
state.activeSessionId = payload.sessionId;
currentSessionTopic = `reasoning:${payload.sessionId}`;
client.subscribe(currentSessionTopic);
if (!state.sessions.find(s => s.id === payload.sessionId)) {
state.sessions.push({ id: payload.sessionId, title: `Session ${new Date().toISOString()}`, status: 'active' });
}
}
if (payload.sessionId === state.activeSessionId) {
processThought(payload.thought, payload.parentId);
}
});
// Reasoning channel events
client.on('message', ({ topic, event, payload }) => {
if (!topic.startsWith('reasoning:')) return;
switch (event) {
case 'session:snapshot':
state.thoughts.clear();
state.branches.clear();
state.branchRegistry.clear();
state.arrivalOrder = [];
if (payload.thoughts) {
[...payload.thoughts].sort((a, b) => {
if (a.timestamp && b.timestamp) return new Date(a.timestamp) - new Date(b.timestamp);
return a.thoughtNumber - b.thoughtNumber;
}).forEach(t => processThought(t, null));
}
updateCounts();
render();
break;
case 'thought:added':
if (payload.thought) {
payload.thought.parentId = payload.parentId;
processThought(payload.thought, payload.parentId);
addActivity('thought', '💡', `Thought #${payload.thought.thoughtNumber} added`, payload.thought.timestamp);
updateCounts();
if (state.currentTab === 'sessions' && !state.selectedThoughtId) render();
if (state.currentTab === 'activity') render();
}
break;
case 'thought:branched':
if (payload.thought) {
payload.thought.parentId = payload.parentId;
payload.thought.branchId = payload.branchId;
payload.thought.branchFromThought = payload.fromThoughtNumber;
processThought(payload.thought, payload.parentId);
const branchInfo = state.branchRegistry.get(payload.branchId);
addActivity('branch', '🌿', `Branch <strong>${branchInfo ? branchInfo.label : '?'}</strong> created from thought #${payload.fromThoughtNumber}`, payload.thought.timestamp);
updateCounts();
if (state.currentTab === 'sessions' && !state.selectedThoughtId) render();
if (state.currentTab === 'activity') render();
}
break;
}
});
function processThought(thought, parentId) {
if (state.thoughts.has(thought.id)) return;
thought.parentId = parentId;
state.thoughts.set(thought.id, thought);
state.arrivalOrder.push(thought.id);
if (thought.branchId && thought.branchFromThought) {
registerBranch(thought.branchId, thought.branchFromThought);
}
}
// Workspace channel events (real-time hub updates)
client.on('workspace:hub:event', (payload) => {
if (!payload || !payload.type) return;
switch (payload.type) {
case 'problem_created':
addActivity('problem', '🟢', `Problem created: <strong>${escapeHtml((payload.data?.title) || '?')}</strong>`, undefined);
fetchWorkspaceData();
break;
case 'problem_status_changed':
addActivity('problem', '🔄', `Problem status changed to <strong>${escapeHtml((payload.data?.status) || '?')}</strong>`, undefined);
fetchWorkspaceData();
break;
case 'proposal_created':
addActivity('proposal', '📝', `Proposal created: <strong>${escapeHtml((payload.data?.title) || '?')}</strong>`, undefined);
fetchWorkspaceData();
break;
case 'proposal_merged':
addActivity('proposal', '🟣', `Proposal merged`, undefined);
fetchWorkspaceData();
break;
case 'message_posted':
addActivity('message', '💬', `New message in channel`, undefined);
break;
case 'consensus_marked':
addActivity('consensus', '✅', `Consensus: <strong>${escapeHtml((payload.data?.name) || '?')}</strong>`, undefined);
fetchWorkspaceData();
break;
case 'workspace_created':
addActivity('workspace', '📁', `Workspace created: <strong>${escapeHtml((payload.data?.name) || '?')}</strong>`, undefined);
fetchWorkspaces();
break;
}
if (state.currentTab === 'activity') render();
});
// Connect
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${window.location.host}`;
client.connect(wsUrl).catch(err => console.error('[Observatory] Connection failed:', err));
// Initial route
handleRoute();
console.log('[Observatory] GitHub Lite UI initialized');
</script>
</body>
</html>