trellis
Version:
Agentic State Engine — event-sourced causal graph with branching, decision traces, and realtime sync for AI-native applications
1,501 lines (1,305 loc) • 40.3 kB
HTML
<html lang="en" data-trellis-band="L3">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Trellis — Agent Lanes</title>
<link rel="stylesheet" href="/theme/runtime-theme.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 24px;
}
header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
flex-wrap: wrap;
}
h1 {
font-size: 18px;
font-weight: 700;
letter-spacing: -0.3px;
}
h1 span {
color: var(--text3);
font-weight: 400;
font-size: 13px;
margin-left: 8px;
}
.live {
display: flex;
align-items: center;
gap: 6px;
font: 500 11px var(--mono);
color: var(--text3);
}
.live-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--green);
box-shadow: 0 0 8px var(--green);
animation: pulse 2s infinite;
}
.live.off .live-dot {
background: var(--red);
box-shadow: 0 0 8px var(--red);
animation: none;
}
@keyframes pulse {
0%,
100% {
opacity: 1
}
50% {
opacity: .45
}
}
.meta {
margin-left: auto;
font: 400 11px var(--mono);
color: var(--text3);
text-align: right;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.stat {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 16px;
}
.stat-label {
font-size: 11px;
color: var(--text3);
text-transform: uppercase;
letter-spacing: .4px;
}
.stat-value {
font: 600 22px var(--font);
margin-top: 4px;
}
.stat-value small {
font-size: 12px;
color: var(--text3);
font-weight: 400;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 14px;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
transition: border-color .15s, box-shadow .15s;
}
.card.active {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent-glow), 0 8px 32px rgba(0, 0, 0, .35);
}
.card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
margin-bottom: 12px;
}
.lane-id {
font: 600 13px var(--mono);
word-break: break-all;
}
.badge {
font: 600 10px var(--mono);
text-transform: uppercase;
letter-spacing: .3px;
padding: 3px 8px;
border-radius: 999px;
white-space: nowrap;
}
.badge.active {
background: var(--tml-badge-success-bg-strong);
color: var(--green);
}
.badge.promoting {
background: var(--tml-badge-warning-bg);
color: var(--yellow);
}
.badge.promoted {
background: var(--tml-badge-info-bg);
color: var(--blue);
}
.badge.dropped {
background: var(--tml-badge-neutral-bg);
color: var(--text3);
}
.row {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 12px;
padding: 4px 0;
border-bottom: 1px solid rgba(255, 255, 255, .03);
}
.row:last-child {
border-bottom: none;
}
.row .k {
color: var(--text3);
}
.row .v {
color: var(--text2);
font-family: var(--mono);
font-size: 11px;
text-align: right;
max-width: 65%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.issue-block {
margin-top: 10px;
padding: 10px 12px;
background: var(--surface2);
border-radius: 8px;
font-size: 12px;
}
.issue-block .issue-id {
font: 600 11px var(--mono);
color: var(--accent);
}
.issue-block .issue-title {
color: var(--text2);
margin-top: 4px;
line-height: 1.35;
}
.empty {
grid-column: 1 / -1;
text-align: center;
padding: 48px;
color: var(--text3);
font-size: 14px;
}
.lock-banner {
background: var(--tml-badge-warning-bg-subtle);
border: 1px solid var(--tml-badge-warning-border);
color: var(--yellow);
border-radius: 10px;
padding: 12px 16px;
margin-bottom: 16px;
font: 500 12px var(--mono);
}
section h2 {
font-size: 12px;
text-transform: uppercase;
letter-spacing: .5px;
color: var(--text3);
margin: 28px 0 12px;
}
#issues-section {
margin-bottom: 24px;
}
.orphan-section {
margin-bottom: 20px;
}
.orphan-section h2 {
font-size: 12px;
text-transform: uppercase;
letter-spacing: .5px;
color: var(--text3);
margin-bottom: 10px;
}
.orphan-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 180px;
overflow: hidden;
position: relative;
}
.orphan-list.expanded {
max-height: none;
}
.orphan-list:not(.expanded)::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 40px;
background: linear-gradient(transparent, var(--bg));
pointer-events: none;
}
.orphan-toggle {
font: 500 11px var(--mono);
color: var(--accent);
background: none;
border: none;
cursor: pointer;
padding: 4px 0;
margin-top: 4px;
}
.orphan-toggle:hover {
text-decoration: underline;
}
.orphan-row {
display: flex;
align-items: center;
gap: 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 12px;
font-size: 12px;
}
.orphan-row .orphan-id {
font: 600 11px var(--mono);
color: var(--green);
min-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.orphan-row .orphan-agent {
color: var(--text2);
flex: 1;
}
.orphan-row .orphan-ops {
font: 400 11px var(--mono);
color: var(--text3);
}
.orphan-row .orphan-updated {
font: 400 11px var(--mono);
color: var(--text3);
}
.toolbar {
--toolbar-control-h: 34px;
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.search {
position: relative;
display: flex;
align-items: center;
flex: 1;
min-width: 0;
height: var(--toolbar-control-h);
}
.search .search-icon {
position: absolute;
left: 12px;
width: 14px;
height: 14px;
color: var(--text3);
pointer-events: none;
}
.search input {
box-sizing: border-box;
height: var(--toolbar-control-h);
font: 400 12px var(--mono);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
padding: 0 30px 0 34px;
width: 100%;
outline: none;
transition: border-color .15s, box-shadow .15s;
}
.search input::placeholder {
color: var(--text3);
}
.search input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent-glow);
}
.search .search-clear {
position: absolute;
right: 8px;
background: none;
border: none;
color: var(--text3);
font-size: 15px;
cursor: pointer;
line-height: 1;
padding: 0 2px;
display: none;
}
.search .search-clear:hover {
color: var(--text);
}
.search.has-value .search-clear {
display: block;
}
.view-toggle {
box-sizing: border-box;
display: flex;
align-items: stretch;
height: var(--toolbar-control-h);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
flex: none;
}
.view-toggle button {
box-sizing: border-box;
height: 100%;
font: 500 12px var(--mono);
padding: 0 12px;
border: none;
background: transparent;
color: var(--text3);
cursor: pointer;
transition: background .15s, color .15s;
}
.view-toggle button.active {
background: var(--accent);
color: #fff;
}
.view-toggle button:hover:not(.active) {
background: var(--surface2);
color: var(--text2);
}
.kanban {
display: flex;
gap: 16px;
overflow-x: auto;
padding-bottom: 16px;
min-height: 200px;
}
.kanban-col {
min-width: 300px;
max-width: 340px;
flex: 1;
display: flex;
flex-direction: column;
}
.kanban-col-head {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px 10px 0 0;
border-bottom: none;
}
.kanban-col-head .col-title {
font: 600 12px var(--font);
text-transform: uppercase;
letter-spacing: .4px;
}
.kanban-col-head .col-count {
font: 600 10px var(--mono);
background: var(--surface2);
padding: 2px 7px;
border-radius: 999px;
color: var(--text3);
}
.kanban-col-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
background: var(--tml-kanban-body-inset);
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 10px 10px;
min-height: 80px;
}
.kanban-col-body .card {
flex: none;
}
.kanban-empty {
color: var(--text3);
font-size: 12px;
text-align: center;
padding: 24px 8px;
}
.table-wrap {
overflow-x: auto;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface);
}
table.lanes-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
table.lanes-table th {
text-align: left;
font: 600 10px var(--mono);
text-transform: uppercase;
letter-spacing: .4px;
color: var(--text3);
padding: 11px 14px;
border-bottom: 1px solid var(--border);
cursor: pointer;
user-select: none;
white-space: nowrap;
position: sticky;
top: 0;
background: var(--surface);
}
table.lanes-table th:hover {
color: var(--text2);
}
table.lanes-table th .sort-ind {
margin-left: 5px;
color: var(--accent);
}
table.lanes-table td {
padding: 10px 14px;
border-bottom: 1px solid rgba(255, 255, 255, .03);
color: var(--text2);
vertical-align: middle;
}
table.lanes-table tbody tr:hover td {
background: var(--surface2);
}
table.lanes-table tbody tr:last-child td {
border-bottom: none;
}
table.lanes-table td.l-id {
font-family: var(--mono);
color: var(--text);
}
table.lanes-table td.l-issue {
font-family: var(--mono);
color: var(--accent);
}
table.lanes-table .badge {
font: 600 9px var(--mono);
text-transform: uppercase;
letter-spacing: .3px;
padding: 3px 7px;
border-radius: 999px;
white-space: nowrap;
}
.table-empty {
padding: 48px;
text-align: center;
color: var(--text3);
font-size: 14px;
}
.issue-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px;
cursor: pointer;
transition: border-color .15s, box-shadow .15s;
}
.issue-card:hover {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent-glow);
}
.issue-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.issue-card .issue-id {
font: 600 12px var(--mono);
color: var(--accent);
}
.issue-card .issue-title {
font-size: 13px;
color: var(--text);
margin-bottom: 8px;
line-height: 1.4;
}
.issue-card .issue-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.issue-card .lane-badge {
font: 500 10px var(--mono);
background: var(--tml-badge-success-bg);
border: 1px solid var(--tml-badge-success-border);
padding: 2px 8px;
border-radius: 999px;
color: var(--green);
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
vertical-align: middle;
}
.issue-card .lane-badge.none {
background: var(--surface2);
border-color: var(--border);
color: var(--text3);
}
.issue-card .agent-msg {
font: 400 10px var(--mono);
color: var(--text3);
margin-left: 4px;
vertical-align: middle;
}
.issue-card .priority-badge {
font: 600 9px var(--mono);
text-transform: uppercase;
letter-spacing: .3px;
padding: 2px 6px;
border-radius: 4px;
}
.priority-badge.critical {
background: var(--tml-badge-critical-bg);
color: var(--red);
}
.priority-badge.high {
background: var(--tml-badge-critical-bg);
color: var(--red);
}
.priority-badge.medium {
background: var(--tml-badge-warning-bg);
color: var(--yellow);
}
.priority-badge.low {
background: var(--tml-badge-neutral-bg);
color: var(--text3);
}
.dialog-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, .6);
z-index: 900;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity .2s;
}
.dialog-backdrop.open {
opacity: 1;
pointer-events: auto;
}
.dialog {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 14px;
width: 90%;
max-width: 560px;
max-height: 80vh;
overflow-y: auto;
padding: 24px;
transform: translateY(12px);
transition: transform .2s;
}
.dialog-backdrop.open .dialog {
transform: translateY(0);
}
.dialog-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.dialog-head .dialog-title {
font: 600 15px var(--font);
}
.dialog-close {
background: none;
border: none;
color: var(--text3);
font-size: 18px;
cursor: pointer;
padding: 2px 6px;
border-radius: 6px;
}
.dialog-close:hover {
background: var(--surface);
color: var(--text);
}
.dialog-section {
margin-top: 16px;
}
.dialog-section h3 {
font-size: 11px;
text-transform: uppercase;
letter-spacing: .5px;
color: var(--text3);
margin-bottom: 8px;
}
.dialog-lane-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
}
.dialog-lane-card .lane-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.dialog-lane-card .lane-id {
font: 600 12px var(--mono);
}
.dialog-lane-card .lane-detail {
font-size: 11px;
color: var(--text2);
display: flex;
flex-direction: column;
gap: 4px;
}
.dialog-lane-card .lane-detail span {
display: flex;
justify-content: space-between;
}
.dialog-lane-card .lane-detail .k {
color: var(--text3);
}
.dialog-lane-card .lane-detail .v {
font-family: var(--mono);
color: var(--text2);
}
.dialog-empty {
color: var(--text3);
font-size: 12px;
text-align: center;
padding: 16px;
}
/* Op log — real ops off the stream (TRL-108) */
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: 16px;
align-items: start;
}
.main {
min-width: 0;
}
.sidebar {
position: sticky;
top: 24px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
display: flex;
flex-direction: column;
max-height: calc(100vh - 48px);
}
.sidebar-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 13px 16px;
border-bottom: 1px solid var(--border);
font: 600 11px var(--mono);
text-transform: uppercase;
letter-spacing: .5px;
color: var(--text3);
}
.sidebar-head .sidebar-count {
color: var(--text2);
}
.sidebar .log-list {
overflow-y: auto;
padding: 6px;
}
.log-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.log-row {
display: grid;
grid-template-columns: 80px 190px 1fr 72px;
gap: 12px;
align-items: baseline;
padding: 7px 12px;
border-radius: 6px;
font: 400 11px var(--mono);
border: 1px solid transparent;
}
.log-row:nth-child(odd) {
background: rgba(255, 255, 255, .02);
}
.log-row:hover {
border-color: var(--border);
background: var(--surface);
}
.log-time {
color: var(--text3);
}
.log-kind {
color: var(--accent);
font-weight: 500;
}
.log-detail {
color: var(--text2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.log-hash {
color: var(--text3);
text-align: right;
opacity: .65;
}
.log-empty {
color: var(--text3);
font-size: 13px;
text-align: center;
padding: 40px;
}
/* Toasts */
.toast-wrap {
position: fixed;
bottom: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 800;
pointer-events: none;
}
.toast {
background: var(--surface);
border: 1px solid var(--border);
border-left: 2px solid var(--accent);
border-radius: 8px;
padding: 9px 14px;
font: 400 11px var(--mono);
box-shadow: 0 8px 24px rgba(0, 0, 0, .4);
animation: toast-in .18s ease-out;
max-width: 380px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.toast.out {
animation: toast-out .3s ease-in forwards;
}
.toast .op-kind {
color: var(--accent);
font-weight: 500;
}
.toast .op-file {
color: var(--text2);
margin-left: 6px;
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateX(12px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes toast-out {
to {
opacity: 0;
transform: translateX(12px);
}
}
/* Flash the card an op touched */
.flash {
animation: flash .7s ease-out;
}
@keyframes flash {
0% {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent-glow);
}
100% {
border-color: var(--border);
box-shadow: none;
}
}
@media (max-width: 900px) {
body {
padding: 16px;
}
.layout {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
max-height: 420px;
}
.toolbar {
flex-wrap: wrap;
}
.search {
order: 2;
flex-basis: 100%;
}
.view-toggle {
flex: 1;
}
}
</style>
</head>
<body>
<header>
<h1>Trellis <span>Agent Lanes</span></h1>
<div class="live" id="live"><span class="live-dot"></span><span id="live-label">connecting…</span></div>
<div class="meta">
<div id="repo">—</div>
<div id="updated">—</div>
</div>
</header>
<div id="lock" hidden class="lock-banner"></div>
<div class="stats" id="stats"></div>
<div class="toolbar">
<div class="view-toggle">
<button id="btn-grid" class="active" onclick="setView('grid')">Grid</button>
<button id="btn-kanban" onclick="setView('kanban')">Kanban</button>
<button id="btn-table" onclick="setView('table')">Table</button>
</div>
<div class="search" id="search">
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="7"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input id="search-input" type="text" placeholder="Search lanes, issues, ops…" oninput="onSearch(this.value)" />
<button class="search-clear" id="search-clear" onclick="clearSearch()">×</button>
</div>
</div>
<section id="orphan-section" class="orphan-section" hidden>
<h2>Unassigned lanes</h2>
<div class="orphan-list" id="orphan-list"></div>
<button class="orphan-toggle" id="orphan-toggle" onclick="toggleOrphans()">show more</button>
</section>
<div class="layout">
<div class="main">
<div class="grid" id="lanes"></div>
</div>
<aside class="sidebar">
<div class="sidebar-head">
<span>Op log</span>
<span class="sidebar-count" id="log-count">0</span>
</div>
<div class="log-list" id="log-list"></div>
</aside>
</div>
<div class="toast-wrap" id="toasts"></div>
<div class="dialog-backdrop" id="dialog-backdrop" onclick="if(event.target===this)closeDialog()">
<div class="dialog" id="dialog">
<div class="dialog-head">
<div class="dialog-title" id="dialog-title">—</div>
<button class="dialog-close" onclick="closeDialog()">×</button>
</div>
<div id="dialog-body"></div>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const ISSUE_COLUMNS = [
{ key: 'backlog', label: 'Backlog', statuses: ['backlog', 'queue'] },
{ key: 'in_progress', label: 'In Progress', statuses: ['in_progress', 'paused'] },
{ key: 'done', label: 'Done', statuses: ['closed'] },
];
let currentView = localStorage.getItem('trellis-lanes-view') || 'grid';
if (currentView === 'logs') currentView = 'grid';
let lastSnap = null;
let opLog = [];
let lastOpHash = null; // resume point for the op stream
let searchQuery = '';
const MAX_LOG = 200;
function onSearch(q) {
searchQuery = q.trim().toLowerCase();
$('search').classList.toggle('has-value', searchQuery.length > 0);
if (lastSnap) render(lastSnap);
}
function clearSearch() {
$('search-input').value = '';
onSearch('');
}
function laneMatches(lane, q) {
return (
lane.id.toLowerCase().includes(q) ||
(lane.agentId || '').toLowerCase().includes(q) ||
(lane.sessionId || '').toLowerCase().includes(q) ||
(lane.targetBranch || '').toLowerCase().includes(q) ||
(lane.worktreePath || '').toLowerCase().includes(q) ||
(lane.issueId || '').toLowerCase().includes(q) ||
(lane.issueTitle || '').toLowerCase().includes(q) ||
(lane.status || '').toLowerCase().includes(q)
);
}
function issueMatches(issue, q) {
return (
issue.id.toLowerCase().includes(q) ||
(issue.title || '').toLowerCase().includes(q) ||
(issue.priority || '').toLowerCase().includes(q) ||
(issue.status || '').toLowerCase().includes(q) ||
(issue.laneIds || []).join(' ').toLowerCase().includes(q)
);
}
function opMatches(op, q) {
const v = op.vcs || {};
return (
op.kind.toLowerCase().includes(q) ||
(op.hash || '').toLowerCase().includes(q) ||
(op.agentId || '').toLowerCase().includes(q) ||
(v.filePath || v.issueId || v.laneId || v.branchName || v.message || v.criterionId || v.decisionId || '').toLowerCase().includes(q)
);
}
function fmtTime(iso) {
if (!iso) return '—';
const d = new Date(iso);
const now = new Date();
const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
if (d.toDateString() === now.toDateString()) return time;
const date = d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
return date + ' ' + time;
}
function shortPath(p) {
if (!p) return '—';
const parts = p.split('/');
return parts.length > 3 ? '…/' + parts.slice(-3).join('/') : p;
}
function toast(html) {
const wrap = $('toasts');
const el = document.createElement('div');
el.className = 'toast';
el.innerHTML = html;
wrap.appendChild(el);
setTimeout(() => { el.classList.add('out'); }, 3500);
setTimeout(() => { el.remove(); }, 3800);
if (wrap.children.length > 5) wrap.firstChild.remove();
}
function flashEl(el) {
if (!el) return;
el.classList.remove('flash');
void el.offsetWidth;
el.classList.add('flash');
}
function setView(view) {
currentView = view;
localStorage.setItem('trellis-lanes-view', view);
$('btn-grid').classList.toggle('active', view === 'grid');
$('btn-kanban').classList.toggle('active', view === 'kanban');
$('btn-table').classList.toggle('active', view === 'table');
if (lastSnap) render(lastSnap);
}
function laneCard(lane, idAttr) {
return `
<article class="card${lane.isActive ? ' active' : ''}" ${idAttr ? `data-id="${idAttr}"` : ''}>
<div class="card-head">
<div class="lane-id">${lane.id}</div>
<span class="badge ${lane.status}">${lane.status}${lane.isActive ? ' · current' : ''}</span>
</div>
<div class="row"><span class="k">Agent</span><span class="v" title="${lane.agentId}">${lane.agentId}</span></div>
<div class="row"><span class="k">Session</span><span class="v" title="${lane.sessionId || ''}">${lane.sessionId || '—'}</span></div>
<div class="row"><span class="k">Ops / files</span><span class="v">${lane.opCount} / ${lane.fileCount}</span></div>
<div class="row"><span class="k">Branch</span><span class="v" title="${lane.targetBranch}">${lane.targetBranch}</span></div>
<div class="row"><span class="k">Worktree</span><span class="v" title="${lane.worktreePath || ''}">${shortPath(lane.worktreePath)}</span></div>
<div class="row"><span class="k">Updated</span><span class="v" title="${lane.updatedAt}">${fmtTime(lane.updatedAt)}</span></div>
${lane.issueId ? `
<div class="issue-block">
<div class="issue-id">${lane.issueId.replace(/^issue:/, '')}</div>
<div class="issue-title">${lane.issueTitle || ''}</div>
</div>` : ''}
</article>`;
}
function renderGrid(lanesEl, lanes) {
const filtered = searchQuery ? lanes.filter(l => laneMatches(l, searchQuery)) : lanes;
if (!filtered.length) {
lanesEl.innerHTML = searchQuery
? `<div class="empty">No lanes match “${searchQuery}”</div>`
: '<div class="empty">No lanes yet — open an Agent tab or run <code>trellis lane ensure --session …</code></div>';
} else {
lanesEl.innerHTML = filtered.map(l => laneCard(l, l.id)).join('');
}
}
function fmtAgentName(sessionId) {
if (!sessionId) return '';
if (sessionId.startsWith('opencode:')) return 'opencode';
return 'claude';
}
function issueCard(issue) {
const laneBadges = issue.laneIds.map(id => {
const lane = lastSnap?.lanes.find(l => l.id === id);
const agent = lane?.sessionId ? fmtAgentName(lane.sessionId) : '';
const msg = agent ? `<span class="agent-msg">${agent} is working on this</span>` : '';
return `<span class="lane-badge">${id}</span>${msg}`;
}).join('');
const priorityBadge = issue.priority ? `<span class="priority-badge ${issue.priority}">${issue.priority}</span>` : '';
return `
<div class="issue-card" data-id="${issue.id}" onclick="openIssueDialog('${issue.id}')">
<div class="issue-card-head">
<span class="issue-id">${issue.id}</span>
${priorityBadge}
</div>
<div class="issue-title">${issue.title || ''}</div>
<div class="issue-meta">
${laneBadges || '<span class="lane-badge none">no lane</span>'}
</div>
</div>`;
}
function renderKanban(lanesEl, issues) {
const filtered = searchQuery ? issues.filter(i => issueMatches(i, searchQuery)) : issues;
if (!filtered.length) {
lanesEl.innerHTML = searchQuery
? `<div class="empty">No issues match “${searchQuery}”</div>`
: '<div class="empty">No issues yet — create one with <code>trellis issue create -t "title"</code></div>';
return;
}
const grouped = {};
for (const col of ISSUE_COLUMNS) grouped[col.key] = [];
for (const issue of filtered) {
const col = ISSUE_COLUMNS.find(c => c.statuses.includes(issue.status));
if (col) grouped[col.key].push(issue);
else grouped.backlog.push(issue);
}
lanesEl.className = 'kanban';
lanesEl.innerHTML = ISSUE_COLUMNS.map(col => {
const items = grouped[col.key] || [];
return `
<div class="kanban-col">
<div class="kanban-col-head">
<span class="col-title">${col.label}</span>
<span class="col-count">${items.length}</span>
</div>
<div class="kanban-col-body">
${items.length ? items.map(issueCard).join('') : '<div class="kanban-empty">No issues</div>'}
</div>
</div>`;
}).join('');
}
function openIssueDialog(issueId) {
const issue = lastSnap?.issues.find(i => i.id === issueId);
if (!issue) return;
$('dialog-title').textContent = issue.id;
const lanes = (issue.laneIds || []).map(id => lastSnap?.lanes.find(l => l.id === id)).filter(Boolean);
let html = `<div style="font-size:13px;color:var(--text);margin-bottom:4px">${issue.title || ''}</div>`;
if (issue.priority) html += `<span class="priority-badge ${issue.priority}" style="display:inline-block;margin-bottom:12px">${issue.priority}</span>`;
if (lanes.length) {
html += '<div class="dialog-section"><h3>Lanes</h3>';
for (const l of lanes) {
html += `
<div class="dialog-lane-card">
<div class="lane-head">
<span class="lane-id">${l.id}</span>
<span class="badge ${l.status}">${l.status}${l.isActive ? ' · current' : ''}</span>
</div>
<div class="lane-detail">
<span><span class="k">Agent</span><span class="v">${l.agentId}</span></span>
<span><span class="k">Ops / files</span><span class="v">${l.opCount} / ${l.fileCount}</span></span>
<span><span class="k">Branch</span><span class="v">${l.targetBranch}</span></span>
<span><span class="k">Updated</span><span class="v">${fmtTime(l.updatedAt)}</span></span>
</div>
</div>`;
}
html += '</div>';
} else {
html += '<div class="dialog-empty">No lanes attached</div>';
}
$('dialog-body').innerHTML = html;
$('dialog-backdrop').classList.add('open');
}
function closeDialog() {
$('dialog-backdrop').classList.remove('open');
}
function renderLogs() {
const list = $('log-list');
$('log-count').textContent = opLog.length;
if (!opLog.length) {
list.innerHTML = '<div class="log-empty">No ops yet — waiting on the stream</div>';
return;
}
// Real ops now: real kinds, real content-addressed hashes.
const rows = searchQuery ? opLog.filter(o => opMatches(o, searchQuery)) : opLog;
if (!rows.length && searchQuery) {
list.innerHTML = `<div class="log-empty">No ops match “${searchQuery}”</div>`;
return;
}
list.innerHTML = rows.slice(-100).reverse().map(op => `
<div class="log-row" data-op="${op.hash}" title="${op.hash}">
<span class="log-time">${fmtTime(op.timestamp)}</span>
<span class="log-kind">${op.kind}</span>
<span class="log-detail">${opDetail(op)}</span>
<span class="log-hash">${op.hash.replace(/^trellis:op:/, '').slice(0, 8)}</span>
</div>
`).join('');
}
// TRL-108: ops arrive as ops. This used to reconstruct them by diffing opCount
// between snapshots and synthesising hashes (`snap.at + l.id`) — an op log
// reverse-engineered from counter deltas, in a system built on an op log.
function onOp(op) {
opLog.push(op);
if (opLog.length > MAX_LOG) opLog = opLog.slice(-MAX_LOG);
lastOpHash = op.hash;
toast(`<span class="op-kind">${op.kind}</span> <span class="op-file">${opDetail(op)}</span>`);
const target = op.laneId || op.vcs?.issueId;
if (target) flashEl(document.querySelector(`[data-id="${target}"]`));
renderLogs();
}
/** Pull the one human-meaningful field out of an op's payload. */
function opDetail(op) {
const v = op.vcs || {};
return (
v.filePath || v.issueId || v.laneId || v.branchName || v.message ||
v.criterionId || v.decisionId || op.agentId || ''
);
}
const TABLE_COLS = [
{ key: 'id', label: 'Lane', get: l => l.id },
{ key: 'status', label: 'Status', get: l => l.status },
{ key: 'agentId', label: 'Agent', get: l => l.agentId || '' },
{ key: 'sessionId', label: 'Session', get: l => l.sessionId || '' },
{ key: 'opCount', label: 'Ops', get: l => l.opCount, numeric: true },
{ key: 'fileCount', label: 'Files', get: l => l.fileCount, numeric: true },
{ key: 'targetBranch', label: 'Branch', get: l => l.targetBranch || '' },
{ key: 'issueId', label: 'Issue', get: l => l.issueId || '' },
{ key: 'updatedAt', label: 'Updated', get: l => l.updatedAt || '' },
];
let tableSort = { key: 'id', dir: 1 };
function sortLanes(lanes) {
const col = TABLE_COLS.find(c => c.key === tableSort.key) || TABLE_COLS[0];
return [...lanes].sort((a, b) => {
let av = col.get(a);
let bv = col.get(b);
if (col.numeric) {
return ((av || 0) - (bv || 0)) * tableSort.dir;
}
av = String(av).toLowerCase();
bv = String(bv).toLowerCase();
return av.localeCompare(bv) * tableSort.dir;
});
}
function renderTable(lanesEl, lanes) {
const filtered = searchQuery ? lanes.filter(l => laneMatches(l, searchQuery)) : lanes;
if (!filtered.length) {
lanesEl.className = 'grid';
lanesEl.innerHTML = searchQuery
? `<div class="empty">No lanes match “${searchQuery}”</div>`
: '<div class="empty">No lanes yet — open an Agent tab or run <code>trellis lane ensure --session …</code></div>';
return;
}
const sorted = sortLanes(filtered);
lanesEl.className = 'grid';
lanesEl.innerHTML = `
<div class="table-wrap" style="grid-column:1/-1">
<table class="lanes-table">
<thead><tr>${TABLE_COLS.map(c => {
const ind = tableSort.key === c.key ? `<span class="sort-ind">${tableSort.dir > 0 ? '▲' : '▼'}</span>` : '';
return `<th onclick="setSort('${c.key}')">${c.label}${ind}</th>`;
}).join('')}</tr></thead>
<tbody>
${sorted.map(l => `
<tr data-id="${l.id}">
<td class="l-id">${l.id}</td>
<td><span class="badge ${l.status}">${l.status}${l.isActive ? ' · current' : ''}</span></td>
<td>${l.agentId || '—'}</td>
<td>${l.sessionId || '—'}</td>
<td>${l.opCount}</td>
<td>${l.fileCount}</td>
<td title="${l.targetBranch}">${shortPath(l.targetBranch)}</td>
<td class="l-issue">${(l.issueId || '').replace(/^issue:/, '') || '—'}</td>
<td title="${l.updatedAt}">${fmtTime(l.updatedAt)}</td>
</tr>`).join('')}
</tbody>
</table>
</div>`;
}
function setSort(key) {
if (tableSort.key === key) tableSort.dir *= -1;
else tableSort = { key, dir: 1 };
if (lastSnap) render(lastSnap);
}
function render(snap) {
lastSnap = snap;
$('repo').textContent = shortPath(snap.rootPath);
$('updated').textContent = 'updated ' + fmtTime(snap.at);
const active = snap.lanes.filter(l => l.status === 'active').length;
const totalIssues = (snap.issues || []).length;
$('stats').innerHTML = `
<div class="stat"><div class="stat-label">Active lanes</div><div class="stat-value">${active}</div></div>
<div class="stat"><div class="stat-label">Total lanes</div><div class="stat-value">${snap.lanes.length}</div></div>
<div class="stat"><div class="stat-label">Integration</div><div class="stat-value"><small>${snap.integrationBranch}</small></div></div>
<div class="stat"><div class="stat-label">Issues</div><div class="stat-value">${totalIssues}</div></div>
`;
const lock = snap.promoteLock;
const lockEl = $('lock');
if (lock && lock.locked) {
lockEl.hidden = false;
lockEl.textContent = `Promote lock: ${lock.laneId} · pid ${lock.pid} · since ${fmtTime(lock.acquiredAt)}`;
} else if (lock && lock.stale) {
lockEl.hidden = false;
lockEl.textContent = `Stale promote lock: ${lock.laneId} — clear with trellis lane promote --force-lock`;
} else {
lockEl.hidden = true;
}
const lanesEl = $('lanes');
if (currentView === 'kanban') {
renderKanban(lanesEl, snap.issues || []);
} else if (currentView === 'table') {
renderTable(lanesEl, snap.lanes);
} else {
lanesEl.className = 'grid';
renderGrid(lanesEl, snap.lanes);
}
renderLogs();
}
$('btn-grid').classList.toggle('active', currentView === 'grid');
$('btn-kanban').classList.toggle('active', currentView === 'kanban');
$('btn-table').classList.toggle('active', currentView === 'table');
function connect() {
// Resume from the last op we saw, so a reconnect replays only what we missed
// rather than re-sending the world.
const qs = lastOpHash ? `?since=${encodeURIComponent(lastOpHash)}` : '';
const es = new EventSource('/api/lanes/stream' + qs);
es.onopen = () => {
$('live').classList.remove('off');
$('live-label').textContent = 'live';
};
// Projections (lanes/issues) are still server-derived; ops are the events.
es.addEventListener('snapshot', (ev) => {
try { render(JSON.parse(ev.data)); } catch (e) { console.error(e); }
});
es.addEventListener('op', (ev) => {
try { onOp(JSON.parse(ev.data)); } catch (e) { console.error(e); }
});
es.addEventListener('error', (ev) => {
if (ev.data) { try { console.error('stream:', JSON.parse(ev.data)); } catch { } }
});
es.onerror = () => {
$('live').classList.add('off');
$('live-label').textContent = 'reconnecting…';
es.close();
setTimeout(connect, 2000);
};
}
connect();
</script>
</body>
</html>