<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Axocoatl</title>
<link rel="icon" type="image/png" href="/brand/favicon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap">
<style>
:root {
--bg: #000000;
--bg-2: #0A0A0A;
--bg-3: #141414;
--panel: #1C1C1E;
--panel-2: #232325;
--border: #2C2C2E;
--border-strong:#3A3A3C;
--text: #ECECEC;
--muted: #9A9A9A;
--muted-2: #6A6A6A;
--axo-jade: #3E7C5C;
--axo-bronze: #B5904A;
--axo-blue: #3FA9C8;
--axo-jade-glow: #4FCB8E;
--axo-bronze-glow: #E8C275;
--axo-blue-glow: #6FD3EE;
--axo-jade-rgb: 62, 124, 92;
--axo-bronze-rgb: 181, 144, 74;
--axo-blue-rgb: 63, 169, 200;
--axo-jade-glow-rgb: 79, 203, 142;
--axo-bronze-glow-rgb: 232, 194, 117;
--axo-blue-glow-rgb: 111, 211, 238;
--accent: var(--axo-jade);
--accent-2: var(--axo-blue);
--warn: #E8B25A;
--err: #E26A6A;
--ok: #5BCC8A;
--team-eng: var(--axo-jade);
--team-res: var(--axo-blue);
--team-ops: var(--axo-bronze);
--team-cust: var(--ok);
--shadow-sm: 0 1px 2px rgba(0,0,0,.30);
--shadow-md: 0 2px 8px rgba(0,0,0,.40);
--shadow-lg: 0 12px 32px rgba(0,0,0,.50);
--focus-ring: 0 0 0 3px rgba(var(--axo-jade-glow-rgb), .35);
}
[data-theme="light"] {
--bg: #F7F1E3;
--bg-2: #F4ECDA;
--bg-3: #ECE3CB;
--panel: #FFFFFF;
--panel-2: #FBF7EC;
--border: #DCD2B6;
--border-strong:#B5A88A;
--text: #1B2027;
--muted: #6B6555;
--muted-2: #968F7A;
--axo-jade: #2F6248;
--axo-bronze: #8E6E36;
--axo-blue: #2E8BA8;
--axo-jade-glow: #3E9A6F;
--axo-bronze-glow: #B98B45;
--axo-blue-glow: #4FB0CD;
--axo-jade-rgb: 47, 98, 72;
--axo-bronze-rgb: 142, 110, 54;
--axo-blue-rgb: 46, 139, 168;
--axo-jade-glow-rgb: 62, 154, 111;
--axo-bronze-glow-rgb: 185, 139, 69;
--axo-blue-glow-rgb: 79, 176, 205;
--accent: var(--axo-jade);
--accent-2: var(--axo-blue);
--warn: #B57820;
--err: #C04141;
--ok: #2E8A4A;
--team-eng: var(--axo-jade);
--team-res: var(--axo-blue);
--team-ops: var(--axo-bronze);
--team-cust: var(--ok);
--shadow-sm: 0 1px 2px rgba(30,40,60,.08);
--shadow-md: 0 2px 10px rgba(30,40,60,.12);
--shadow-lg: 0 12px 32px rgba(30,40,60,.18);
--focus-ring: 0 0 0 3px rgba(var(--axo-jade-rgb), .30);
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--text); font-family: 'Inter', -apple-system, system-ui, 'Segoe UI', Roboto, sans-serif; font-size: 14px; height: 100vh; overflow: hidden; }
button { font-family: inherit; }
code, pre, .mono { font-family: 'JetBrains Mono', 'SF Mono', Menlo, Consolas, monospace; }
a { color: var(--accent-2); }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 4px; }
::-webkit-scrollbar-track { background: transparent; }
.app { display: grid; grid-template-columns: 40px 1fr; grid-template-rows: 1fr; grid-template-areas: 'strip main'; height: 100vh; min-height: 0; transition: grid-template-columns .18s ease; }
body.strip-expanded .app { grid-template-columns: 180px 1fr; }
body.side-mini { --side-w: 56px; }
body.side-hidden { --side-w: 0; }
body.side-mini .logo-name, body.side-mini .logo-tag, body.side-mini .logo-ver { display: none; }
body.side-mini .nav-section { display: none; }
body.side-mini .nav-item { padding: 9px 6px; justify-content: center; gap: 0; }
body.side-mini .nav-item > span:nth-child(2) { display: none; }
body.side-mini .logo { padding: 0; align-items: center; }
body.side-mini .logo-wordmark, body.side-mini .logo-tag, body.side-mini .logo-ver { display: none; }
body.side-mini .logo::before { content: ""; display: block; width: 32px; height: 32px; background: center/contain no-repeat url('/brand/mark.png'); filter: drop-shadow(0 0 8px rgba(var(--axo-jade-rgb),.35)); }
body.side-mini .nav-counter { display: none; }
body.side-mini .side-foot { display: none; }
body.side-hidden .logo, body.side-hidden .side { display: none; }
.sidebar-toggle { background: transparent; border: 1px solid var(--border); border-radius: 6px; padding: 4px 9px; color: var(--muted); cursor: pointer; font-size: 13px; line-height: 1; }
.sidebar-toggle:hover { background: var(--bg-3); color: var(--text); }
.strip { grid-area: strip; display: flex; flex-direction: column; align-items: flex-start; gap: 2px; padding: 6px 4px 6px; background: var(--bg-2); border-right: 1px solid var(--border); min-width: 0; overflow: hidden; }
.strip-mark { width: 32px; height: 32px; flex-shrink: 0; margin-bottom: 4px; display: flex; align-items: center; cursor: pointer; gap: 10px; }
.strip-mark .strip-icon img { width: 28px; height: 28px; object-fit: contain; filter: drop-shadow(0 0 6px rgba(var(--axo-jade-rgb),.35)); }
.strip-mark-label { display: none; font-family: 'Space Grotesk', sans-serif; font-weight: 600; font-size: 15px; color: var(--fg); letter-spacing: -.01em; white-space: nowrap; }
.strip-spacer { flex: 1; min-height: 4px; }
.strip-item { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 6px; cursor: pointer; color: var(--muted); transition: background .12s, color .12s, width .18s ease; position: relative; user-select: none; font-size: 15px; gap: 10px; }
.strip-item:hover { background: var(--bg-3); color: var(--text); }
.strip-item.active { color: var(--accent); background: rgba(var(--axo-jade-rgb),.13); }
.strip-item.active::before { content: ''; position: absolute; left: -6px; top: 6px; bottom: 6px; width: 2px; background: var(--accent); border-radius: 2px; }
.strip-icon { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; flex-shrink: 0; }
.strip-label { display: none; font-size: 12.5px; font-family: 'Space Grotesk', sans-serif; letter-spacing: .01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
body.strip-expanded .strip { align-items: stretch; padding-left: 8px; padding-right: 8px; }
body.strip-expanded .strip-item { width: 100%; justify-content: flex-start; padding-right: 8px; }
body.strip-expanded .strip-label { display: inline-block; }
body.strip-expanded .strip-item.active::before { left: -8px; }
body.strip-expanded .strip-mark { width: 100%; }
body.strip-expanded .strip-mark-label { display: inline-block; }
body.strip-expanded .strip-theme { flex-direction: row; align-self: flex-start; }
.strip-item .strip-dot { position: absolute; top: 3px; right: 3px; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
.strip-foot { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; padding: 4px 0; }
.strip-theme { display: flex; flex-direction: column; gap: 2px; padding: 2px; background: var(--bg-3); border-radius: 6px; margin-left: 4px; }
.strip-theme button { width: 24px; height: 22px; display: flex; align-items: center; justify-content: center; background: transparent; border: 0; color: var(--muted); cursor: pointer; border-radius: 4px; padding: 0; font-size: 12px; }
.strip-theme button:hover { color: var(--text); }
.strip-theme button.active { background: var(--panel-2); color: var(--accent); }
.status-pearls { display: flex; flex-direction: column; gap: 2px; align-items: stretch; padding: 4px 0 0; min-width: 0; box-sizing: border-box; width: 100%; }
.strip-foot .status-pearls:empty { display: none; }
.strip-foot .pearl {
display: flex;
align-items: center;
justify-content: center;
background: transparent;
backdrop-filter: none;
-webkit-backdrop-filter: none;
border: 0;
padding: 5px 4px;
font-size: 11px;
color: var(--muted);
border-radius: 6px;
gap: 0;
width: 100%;
min-width: 0;
box-sizing: border-box;
line-height: 1.3;
}
.strip-foot .pearl:hover { background: var(--bg-3); }
.strip-foot .pearl .pearl-dot { flex: 0 0 auto; }
.strip-foot .pearl > *:not(.pearl-dot) { display: none; }
body.strip-expanded .strip-foot .pearl {
justify-content: flex-start;
padding: 5px 6px 5px 4px;
gap: 8px;
}
body.strip-expanded .strip-foot .pearl > *:not(.pearl-dot) {
display: block;
min-width: 0;
flex: 1 1 auto;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.pearl { pointer-events: auto; display: inline-flex; align-items: center; gap: 8px; padding: 5px 11px; background: rgba(27, 32, 39, .72); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); border: 1px solid var(--border); border-radius: 999px; font-size: 11.5px; color: var(--muted); transition: opacity .3s ease, transform .3s ease; cursor: default; }
html[data-theme="light"] .pearl { background: rgba(244, 236, 218, .82); }
.pearl.fade-in { opacity: 0; transform: translateY(4px); animation: pearl-in .35s ease forwards; }
@keyframes pearl-in { to { opacity: 1; transform: translateY(0); } }
.pearl-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 6px var(--ok); }
.pearl-dot.amber { background: var(--warn); box-shadow: 0 0 6px var(--warn); }
.pearl-dot.err { background: var(--err); box-shadow: 0 0 6px var(--err); }
.pearl-dot.mute { background: var(--muted); box-shadow: none; }
.pearl.clickable { cursor: pointer; transition: opacity .3s ease, transform .3s ease, color .12s; }
.pearl.clickable:hover { color: var(--text); }
.pearl.warn { color: var(--warn); border-color: var(--warn); background: rgba(255, 180, 84, .15); }
.top { display: none; }
.status-pill { display: inline-flex; align-items: center; gap: 8px; padding: 5px 11px; background: var(--bg-3); border: 1px solid var(--border); border-radius: 999px; font-size: 12px; color: var(--muted); }
.interrupts-pill { display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; background: rgba(255,180,84,.18); border: 1px solid var(--warn); border-radius: 999px; font-size: 12px; color: var(--warn); cursor: pointer; }
.interrupts-pill:hover { background: rgba(255,180,84,.28); }
.interrupts-pop {
position: fixed; top: 56px; right: 16px; z-index: 700;
background: var(--panel-2); border: 1px solid var(--border-strong);
border-radius: 12px; padding: 14px;
width: min(520px, calc(100vw - 32px));
box-shadow: var(--shadow-lg);
display: flex; flex-direction: column; gap: 10px;
max-height: min(78vh, 720px);
transition: width .22s ease, top .22s ease, right .22s ease, max-height .22s ease;
}
.interrupts-pop.fullscreen {
top: 32px; right: 32px; left: 32px; bottom: 32px;
width: auto; max-height: none;
padding: 22px;
border-radius: 16px;
}
.interrupts-head {
display: flex; align-items: center; justify-content: space-between;
font-size: 11px; text-transform: uppercase; letter-spacing: .8px;
color: var(--muted); padding-bottom: 8px; border-bottom: 1px solid var(--border);
gap: 10px;
}
.interrupts-head .head-actions { display: flex; gap: 4px; }
.interrupts-head .head-btn {
background: transparent; border: 0; color: var(--muted);
width: 26px; height: 26px; border-radius: 6px; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center;
font-size: 14px; line-height: 1; padding: 0;
transition: background .15s, color .15s;
}
.interrupts-head .head-btn:hover { background: var(--bg-3); color: var(--text); }
.interrupts-list { overflow-y: auto; display: flex; flex-direction: column; gap: 12px; padding-right: 2px; }
.interrupt-card {
background: var(--panel); border: 1px solid var(--border);
border-radius: 10px; padding: 14px 16px;
display: flex; flex-direction: column; gap: 10px;
box-shadow: var(--shadow-sm);
}
.interrupts-pop.fullscreen .interrupt-card { padding: 20px 24px; gap: 14px; border-radius: 12px; }
.interrupt-card .meta { font-size: 11px; color: var(--muted-2); font-family: ui-monospace, monospace; }
.interrupt-card .msg { font-size: 13.5px; color: var(--text); line-height: 1.55; }
.interrupts-pop.fullscreen .interrupt-card .msg { font-size: 15px; line-height: 1.65; }
.prose { font-size: 13px; line-height: 1.55; color: var(--text); word-wrap: break-word; }
.prose p { margin: 0 0 8px; }
.prose p:last-child { margin-bottom: 0; }
.prose h1, .prose h2, .prose h3, .prose h4 { margin: 12px 0 6px; line-height: 1.3; font-weight: 600; }
.prose h1 { font-size: 16px; }
.prose h2 { font-size: 14.5px; }
.prose h3, .prose h4 { font-size: 13px; }
.prose ul, .prose ol { margin: 6px 0; padding-left: 22px; }
.prose li { margin: 2px 0; }
.prose code { background: var(--bg-3); padding: 1px 5px; border-radius: 3px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11.5px; color: var(--axo-jade); }
.prose pre { background: var(--bg-2); border: 1px solid var(--border); border-radius: 6px;
padding: 9px 11px; overflow-x: auto; margin: 6px 0;
font-family: ui-monospace, monospace; font-size: 11.5px; line-height: 1.45; }
.prose pre code { background: transparent; padding: 0; color: var(--text); font-size: 11.5px; }
.prose blockquote { border-left: 3px solid var(--border-strong); margin: 8px 0;
padding: 2px 12px; color: var(--muted); }
.prose a { color: var(--axo-blue); }
.prose strong { color: var(--text); font-weight: 600; }
.prose em { font-style: italic; color: var(--text); }
.prose table { border-collapse: collapse; margin: 6px 0; font-size: 12px; }
.prose th, .prose td { border: 1px solid var(--border); padding: 4px 8px; text-align: left; }
.prose th { background: var(--bg-3); font-weight: 600; }
.prose hr { border: 0; border-top: 1px solid var(--border); margin: 12px 0; }
.prose-dim { color: var(--muted); }
.interrupt-card .row { display: flex; gap: 8px; align-items: stretch; }
.interrupt-card .row .input {
flex: 1;
padding: 10px 14px;
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 14px;
font-family: inherit;
line-height: 1.5;
min-height: 42px;
transition: border-color .15s ease, box-shadow .15s ease, background .15s ease;
resize: vertical;
}
.interrupt-card .row .input:hover { border-color: var(--border-strong); }
.interrupt-card .row .input:focus {
outline: none;
border-color: var(--axo-jade-glow);
box-shadow: var(--focus-ring);
background: var(--panel);
}
.interrupts-pop.fullscreen .interrupt-card .row .input {
padding: 12px 16px; font-size: 15px; min-height: 88px;
}
.interrupt-card .btn-resume {
background: linear-gradient(180deg, var(--axo-jade-glow), var(--axo-jade));
border: 1px solid var(--axo-jade);
color: #fff;
padding: 10px 16px;
border-radius: 8px;
font-size: 13.5px;
font-weight: 600;
letter-spacing: .01em;
cursor: pointer;
box-shadow: 0 1px 0 rgba(255,255,255,.18) inset,
0 1px 2px rgba(0,0,0,.25),
0 0 0 0 rgba(var(--axo-jade-rgb), .0);
transition: transform .08s ease, box-shadow .15s ease, filter .15s ease;
}
.interrupt-card .btn-resume:hover {
filter: brightness(1.06);
box-shadow: 0 1px 0 rgba(255,255,255,.18) inset,
0 2px 6px rgba(0,0,0,.30),
0 0 18px 2px rgba(var(--axo-jade-glow-rgb), .45);
}
.interrupt-card .btn-resume:active { transform: translateY(1px); }
.interrupt-card .btn-resume:focus-visible {
outline: none;
box-shadow: 0 1px 0 rgba(255,255,255,.18) inset,
0 1px 2px rgba(0,0,0,.25),
var(--focus-ring);
}
.interrupt-card .btn-cancel {
background: transparent;
border: 1px solid var(--border-strong);
color: var(--muted);
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: color .15s, border-color .15s, background .15s;
}
.interrupt-card .btn-cancel:hover { color: var(--text); border-color: var(--text); background: var(--bg-3); }
.interrupts-empty { padding: 18px; text-align: center; color: var(--muted); font-size: 12px; }
.interrupts-foot { padding: 4px 2px; }
.status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted-2); }
.status-pill.ok .status-dot { background: var(--ok); box-shadow: 0 0 8px var(--ok); animation: pulse 2s infinite; }
.status-pill.err .status-dot { background: var(--err); }
.top-stats { display: flex; gap: 10px; margin-left: auto; }
.side { display: none; }
.nav-section { font-size: 10px; text-transform: uppercase; letter-spacing: .1em; color: var(--muted-2); padding: 14px 10px 8px; }
.nav-item { display: flex; align-items: center; gap: 12px; padding: 9px 11px; margin: 1px 0; border-radius: 7px; color: var(--muted); cursor: pointer; transition: background .12s, color .12s; font-size: 13px; user-select: none; }
.nav-item:hover { background: var(--bg-3); color: var(--text); }
.nav-item.active { background: rgba(var(--axo-jade-rgb),.13); color: var(--text); }
.nav-item.active .nav-ico { color: var(--accent); }
.nav-ico { width: 16px; display: inline-block; text-align: center; font-size: 14px; }
.nav-counter { margin-left: auto; font-size: 11px; color: var(--muted-2); }
.side-foot { margin-top: 18px; padding: 12px 10px; font-size: 11px; color: var(--muted-2); line-height: 1.55; border-top: 1px solid var(--border); }
.side-foot kbd { background: var(--bg-3); border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 4px; padding: 1px 5px; font-size: 10.5px; font-family: 'JetBrains Mono', monospace; color: var(--text); }
.main { grid-area: main; overflow: hidden; padding: 0; min-height: 0; min-width: 0; position: relative; display: flex; flex-direction: column; }
.main h1 { margin: 0 0 6px; font-size: 22px; font-weight: 600; }
.main .sub { color: var(--muted); margin-bottom: 22px; font-size: 13px; line-height: 1.55; }
.main .sub strong { color: var(--text); }
.main .sub code { background: var(--bg-3); padding: 1px 6px; border-radius: 4px; font-size: 12px; }
.tab { flex: 1; display: flex; flex-direction: column; min-height: 0; min-width: 0; overflow: auto; }
.tab.tab-pad { padding: 22px 28px; }
.tab.full-bleed { padding: 0; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 16px 18px; }
.card h3 { margin: 0 0 12px; font-size: 13px; font-weight: 600; color: var(--text); }
.card-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 14px; margin-bottom: 18px; }
.stat .stat-label { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted-2); }
.stat .stat-value { font-size: 26px; font-weight: 600; margin-top: 6px; }
.stat .stat-sub { font-size: 11px; color: var(--muted); margin-top: 4px; }
.btn {
background: linear-gradient(180deg, var(--axo-jade-glow), var(--axo-jade));
color: #fff; border: 1px solid var(--axo-jade);
padding: 9px 16px; border-radius: 8px;
font-weight: 600; font-size: 13px;
cursor: pointer;
box-shadow: 0 1px 0 rgba(255,255,255,.16) inset, var(--shadow-sm);
transition: filter .15s ease, box-shadow .15s ease, transform .06s ease;
}
.btn:hover { filter: brightness(1.06); box-shadow: 0 1px 0 rgba(255,255,255,.16) inset, var(--shadow-md); }
.btn:active { transform: translateY(1px); }
.btn:disabled { opacity: .5; cursor: not-allowed; filter: none; }
.btn:focus-visible { outline: none; box-shadow: 0 1px 0 rgba(255,255,255,.16) inset, var(--focus-ring); }
.btn.ghost {
background: transparent; border: 1px solid var(--border-strong); color: var(--text);
box-shadow: none;
}
.btn.ghost:hover { background: var(--bg-3); filter: none; }
.btn.sm { padding: 5px 11px; font-size: 12px; }
.btn.danger { background: var(--err); border-color: var(--err); }
.btn.danger:hover { filter: brightness(1.06); }
.input, .select, textarea.input {
width: 100%;
background: var(--bg-3); border: 1px solid var(--border); color: var(--text);
padding: 9px 12px; border-radius: 8px;
font-family: inherit; font-size: 13px; line-height: 1.5;
outline: none;
transition: border-color .15s ease, box-shadow .15s ease, background .15s ease;
}
.input:hover, .select:hover, textarea.input:hover { border-color: var(--border-strong); }
.input:focus, .select:focus, textarea.input:focus {
border-color: var(--axo-jade-glow);
box-shadow: var(--focus-ring);
background: var(--panel);
}
.input::placeholder, textarea.input::placeholder { color: var(--muted-2); }
textarea.input { resize: vertical; min-height: 60px; font-family: inherit; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--border); }
th { font-weight: 500; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .06em; }
tr:last-child td { border-bottom: 0; }
tr.clickable { cursor: pointer; }
tr.clickable:hover { background: var(--bg-3); }
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 500; line-height: 1.7; }
.badge.idle { background: rgba(74,222,128,.13); color: var(--ok); }
.badge.running { background: rgba(255,180,84,.18); color: var(--warn); animation: pulse 1.4s infinite; }
.badge.failed { background: rgba(255,107,107,.15); color: var(--err); }
.badge.team { font-size: 10.5px; padding: 1px 7px; }
.badge.team.eng { background: rgba(var(--axo-jade-rgb),.15); color: var(--team-eng); }
.badge.team.res { background: rgba(var(--axo-blue-rgb),.15); color: var(--team-res); }
.badge.team.ops { background: rgba(255,180,84,.15); color: var(--team-ops); }
.badge.team.cust { background: rgba(74,222,128,.15); color: var(--team-cust); }
.detail-panel { position: fixed; top: 56px; right: 0; bottom: 0; width: 480px; max-width: 92vw; background: var(--panel-2); border-left: 1px solid var(--border-strong); box-shadow: -16px 0 40px rgba(0,0,0,.4); transform: translateX(110%); transition: transform .25s ease, width .25s ease, left .25s ease; z-index: 60; display: flex; flex-direction: column; }
.detail-panel.open { transform: translateX(0); }
.detail-panel.full { width: 100vw; left: 0; max-width: 100vw; }
.detail-panel.full .detail-body { padding: 24px 8% 20px; max-width: 1100px; margin: 0 auto; width: 100%; }
.detail-panel.full .detail-head { padding-left: 8%; padding-right: 8%; }
.detail-panel.full .detail-footer { padding-left: 8%; padding-right: 8%; }
.detail-head { padding: 16px 20px; border-bottom: 1px solid var(--border); display: flex; gap: 10px; align-items: center; flex-shrink: 0; }
.detail-head .title { font-size: 15px; font-weight: 600; }
.detail-head .pre { font-size: 10px; text-transform: uppercase; letter-spacing: .1em; color: var(--muted-2); }
.detail-actions { margin-left: auto; display: flex; gap: 4px; }
.detail-iconbtn { background: transparent; border: 0; color: var(--muted); cursor: pointer; font-size: 16px; padding: 4px 8px; border-radius: 5px; line-height: 1; }
.detail-iconbtn:hover { color: var(--text); background: var(--bg-3); }
.detail-body { padding: 16px 20px; flex: 1; overflow-y: auto; min-height: 0; }
.detail-section { margin-bottom: 18px; }
.detail-section h4 { margin: 0 0 8px; font-size: 10px; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; font-weight: 600; }
.detail-section .field-row { display: grid; grid-template-columns: 130px 1fr; gap: 12px; align-items: center; padding: 5px 0; }
.detail-section .field-row .lbl { font-size: 12px; color: var(--muted); }
.detail-section .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.detail-footer { padding: 14px 20px; border-top: 1px solid var(--border); background: rgba(0,0,0,.18); display: flex; gap: 10px; flex-wrap: wrap; flex-shrink: 0; }
.editor-dirty-indicator { color: var(--warn); font-size: 11px; font-weight: 500; margin-right: auto; align-self: center; }
.toasts { position: fixed; top: 70px; right: 20px; z-index: 80; display: flex; flex-direction: column; gap: 10px; max-width: 360px; pointer-events: none; }
.toast { background: var(--panel-2); border: 1px solid var(--border-strong); border-left-width: 3px; border-radius: 7px; padding: 10px 14px; font-size: 13px; box-shadow: 0 8px 20px rgba(0,0,0,.35); animation: slide-in .25s ease; pointer-events: auto; }
.toast.ok { border-left-color: var(--ok); }
.toast.err { border-left-color: var(--err); }
.toast.info { border-left-color: var(--accent); }
.toast .t-title { font-weight: 600; margin-bottom: 2px; }
.toast .t-body { color: var(--muted); }
@keyframes slide-in { from { transform: translateX(20px); opacity: 0 } to { transform: translateX(0); opacity: 1 } }
.palette-mask { position: fixed; inset: 0; background: rgba(0,0,0,.55); z-index: 100; display: none; align-items: flex-start; justify-content: center; padding-top: 100px; }
.palette-mask.open { display: flex; }
.palette { width: 560px; max-width: 92vw; background: var(--panel-2); border: 1px solid var(--border-strong); border-radius: 10px; box-shadow: 0 30px 80px rgba(0,0,0,.5); overflow: hidden; }
.palette input { width: 100%; background: transparent; border: 0; border-bottom: 1px solid var(--border); padding: 14px 18px; color: var(--text); font-size: 14px; outline: none; }
.palette-list { max-height: 360px; overflow-y: auto; }
.palette-item { padding: 10px 18px; cursor: pointer; display: flex; gap: 12px; align-items: center; }
.palette-item:hover, .palette-item.sel { background: var(--bg-3); }
.palette-item .kind { font-size: 10px; color: var(--muted-2); text-transform: uppercase; letter-spacing: .07em; min-width: 70px; }
.palette-item .nm { font-weight: 500; }
.palette-item .sub { color: var(--muted); font-size: 12px; margin-left: auto; }
.legend-team { display: flex; align-items: center; gap: 10px; font-size: 12.5px; padding: 4px 0; }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; }
.legend-dot.eng { background: var(--team-eng); }
.legend-dot.res { background: var(--team-res); }
.legend-dot.ops { background: var(--team-ops); }
.legend-dot.cust { background: var(--team-cust); }
.live-pill { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--muted-2); text-transform: uppercase; letter-spacing: .06em; }
.theme-toggle { display: inline-flex; gap: 1px; padding: 2px; background: var(--bg-2); border: 1px solid var(--border); border-radius: 7px; }
.theme-seg { background: transparent; border: 0; padding: 4px 8px; border-radius: 5px; color: var(--muted); cursor: pointer; font-size: 13px; line-height: 1; }
.theme-seg:hover { color: var(--text); background: var(--bg-3); }
.theme-seg.active { color: var(--text); background: var(--panel); box-shadow: 0 1px 2px rgba(0,0,0,.25); }
[data-theme="light"] .theme-seg.active { box-shadow: 0 1px 2px rgba(30,40,60,.15); }
.conn-dot { width: 8px; height: 8px; border-radius: 50%; transition: background .2s, box-shadow .2s; }
.conn-dot.on { background: var(--ok, #4ade80); box-shadow: 0 0 7px var(--ok, #4ade80); }
.conn-dot.off { background: #ff6b6b; box-shadow: 0 0 7px rgba(255,107,107,.6); }
.md p { margin: 0 0 8px; }
.md p:last-child { margin-bottom: 0; }
.md h1, .md h2, .md h3 { margin: 12px 0 6px; font-weight: 600; }
.md h1 { font-size: 16px; }
.md h2 { font-size: 14px; }
.md h3 { font-size: 13px; }
.md ul, .md ol { margin: 6px 0; padding-left: 22px; }
.md li { margin: 3px 0; }
.md code { background: var(--bg); padding: 1px 5px; border-radius: 4px; font-size: 12.5px; }
.md pre { background: var(--bg); border: 1px solid var(--border); border-radius: 7px; padding: 10px 13px; overflow-x: auto; margin: 8px 0; }
.md pre code { background: transparent; padding: 0; font-size: 12.5px; line-height: 1.55; }
.md strong { color: var(--text); font-weight: 600; }
.stream-cursor { display: inline-block; width: 7px; height: 14px; background: var(--accent-2); margin-left: 2px; animation: blink 1s infinite; vertical-align: middle; }
@keyframes blink { 50% { opacity: 0 } }
.tool-chip { background: rgba(255,255,255,.02); border: 1px solid var(--border); border-radius: 6px; padding: 6px 10px 6px 9px; margin: 4px 0; font-size: 11.5px; }
.tool-chip:hover { border-color: var(--border-strong); }
.tool-chip .tc-head { display: flex; gap: 8px; align-items: center; font-family: 'JetBrains Mono', ui-monospace, monospace; }
.tool-chip .tc-head::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: var(--axo-jade-glow); box-shadow: 0 0 6px rgba(var(--axo-jade-glow-rgb),.55); flex-shrink: 0; }
.tool-chip.running .tc-head::before { background: transparent; box-shadow: none; width: 9px; height: 9px; border: 1.5px solid var(--axo-jade-glow); border-top-color: transparent; border-radius: 50%; animation: tc-spin .9s linear infinite; }
.tool-chip.err .tc-head::before { background: var(--err, #e88); box-shadow: 0 0 6px rgba(232,136,136,.55); }
@keyframes tc-spin { to { transform: rotate(360deg); } }
.tool-chip .tc-name { color: var(--fg-soft); font-weight: 600; letter-spacing: .01em; }
.tool-chip .tc-args { color: var(--muted); }
.tool-chip .tc-meta { margin-left: auto; color: var(--muted-2); font-size: 10.5px; font-family: 'JetBrains Mono', ui-monospace, monospace; padding-left: 8px; }
.tool-chip .tc-toggle { margin-left: auto; cursor: pointer; color: var(--muted-2); font-size: 11px; }
.tool-chip .tc-result { display: none; margin-top: 7px; padding: 7px; background: var(--bg); border-radius: 5px; font-family: 'JetBrains Mono', monospace; font-size: 11.5px; white-space: pre-wrap; word-break: break-all; }
.tool-chip.open .tc-result { display: block; }
#studio-lattice {
position: absolute; inset: 0;
--ax-bg: var(--bg-2, #0d1017);
--ax-grid: var(--border, #232a37);
--ax-fg: var(--text, #e8ecf3);
--ax-accent: var(--accent, #3E7C5C);
--ax-accent-2: var(--accent-2, #3FA9C8);
--ax-edge-color: var(--border-strong, #3a4357);
--ax-edge-color-sel: var(--accent, #3E7C5C);
--ax-marquee-fill: rgba(var(--axo-jade-rgb),.10);
}
#studio-lattice ax-node {
--ax-node-bg: var(--bg-3, #161a23);
--ax-node-fg: var(--text, #e8ecf3);
--ax-node-border: var(--border-strong, #3a4357);
--ax-node-border-hover: var(--accent, #3E7C5C);
--ax-node-border-sel: var(--accent, #3E7C5C);
min-width: 158px;
}
#studio-lattice ax-node[data-team="Engineering"] { --ax-node-border: var(--team-eng); }
#studio-lattice ax-node[data-team="Research"] { --ax-node-border: var(--team-res); }
#studio-lattice ax-node[data-team="Ops"] { --ax-node-border: var(--team-ops); }
#studio-lattice ax-node[data-team="Customer"] { --ax-node-border: var(--team-cust); }
#studio-lattice ax-node.studio-skill {
--ax-node-bg: rgba(var(--axo-jade-rgb),.14);
--ax-node-border: var(--accent, #3E7C5C);
}
.sn-title { font-weight: 600; font-size: 13px; }
.sn-sub { color: var(--muted-2); font-size: 10.5px; margin-top: 3px; }
.studio-controls { position: absolute; top: 12px; right: 12px; z-index: 6; }
.studio-minimap { position: absolute; bottom: 12px; right: 12px; z-index: 6; }
.chip { background: var(--bg-3); border: 1px solid var(--border); padding: 2px 9px; border-radius: 999px; font-size: 11px; font-family: 'JetBrains Mono', monospace; }
.chip.emit { color: var(--accent-2); border-color: rgba(var(--axo-blue-rgb),.3); }
.chip.react { color: var(--warn); border-color: rgba(255,180,84,.3); }
.chip.agent { color: var(--accent); border-color: rgba(var(--axo-jade-rgb),.3); }
.row { display: flex; gap: 12px; align-items: center; }
.grow { flex: 1; }
.muted { color: var(--muted); }
.small { font-size: 12px; }
.hide { display: none !important; }
.empty { color: var(--muted); padding: 30px; text-align: center; }
.switch { position: relative; width: 36px; height: 20px; background: var(--bg-3); border: 1px solid var(--border-strong); border-radius: 999px; cursor: pointer; transition: background .15s; }
.switch::after { content: ''; position: absolute; top: 1px; left: 1px; width: 16px; height: 16px; border-radius: 50%; background: var(--muted); transition: left .15s, background .15s; }
.switch.on { background: rgba(74,222,128,.2); border-color: var(--ok); }
.switch.on::after { left: 17px; background: var(--ok); }
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: .55 } }
#tab-sessions > #session-home,
#tab-sessions > #session-cockpit { flex: 1; min-height: 0; display: flex; flex-direction: column; }
#session-cockpit { height: 100%; }
.cockpit-bar { display: flex; align-items: center; gap: 10px; padding: 6px 2px 12px; }
.cockpit-bar #cockpit-title { font-size: 13.5px; letter-spacing: -.005em; }
.cockpit-bar #cockpit-dir { font-size: 11px; }
.cockpit-live-pill { display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px 3px 7px; border-radius: 999px; background: rgba(var(--axo-jade-rgb),.12); border: 1px solid rgba(var(--axo-jade-rgb),.32); color: var(--accent-lift); font-size: 10.5px; text-transform: uppercase; letter-spacing: .8px; font-weight: 600; }
.cockpit-live-pill::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: var(--axo-jade-glow); box-shadow: 0 0 8px rgba(var(--axo-jade-glow-rgb),.7); animation: cockpit-live-pulse 1.6s ease-in-out infinite; }
.cockpit-live-pill.idle { background: rgba(255,255,255,.04); border-color: var(--border); color: var(--muted); }
.cockpit-live-pill.idle::before { background: var(--muted-2); box-shadow: none; animation: none; }
@keyframes cockpit-live-pulse { 0%,100% { opacity: 1; transform: scale(1);} 50% { opacity: .65; transform: scale(.92);} }
.cockpit-shell { position: relative; flex: 1; display: flex; flex-direction: column; min-height: 0; }
.cockpit-grid {
flex: 1; display: grid;
grid-template-columns: 260px 6px 1fr 6px 240px;
gap: 0; min-height: 0;
margin-right: var(--term-rail-w, 40px);
}
.col-resizer { cursor: col-resize; background: transparent; transition: background .15s; user-select: none; touch-action: none; }
.col-resizer:hover, .col-resizer.dragging { background: var(--accent); opacity: .55; }
.cockpit-grid > #cockpit-files { grid-column: 1; }
.cockpit-grid > [data-which="1"] { grid-column: 2; }
.cockpit-grid > #cockpit-stream { grid-column: 3; }
.cockpit-grid > [data-which="2"] { grid-column: 4; }
.cockpit-grid > #cockpit-browser { grid-column: 5; }
body.axo-resizing, body.axo-resizing * { user-select: none !important; cursor: col-resize !important; }
body.axo-vresizing, body.axo-vresizing * { user-select: none !important; cursor: row-resize !important; }
.cockpit-pane { display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); min-height: 0; overflow: hidden; }
.cockpit-terminals-drawer {
position: absolute; top: 0; right: 0; bottom: 0;
width: var(--term-rail-w, 40px);
background: var(--panel);
border-left: 1px solid var(--border);
display: flex; flex-direction: column;
overflow: hidden;
transition: width .22s cubic-bezier(.2,.7,.3,1);
z-index: 12;
}
.cockpit-terminals-drawer.is-open {
width: var(--term-open-w, 460px);
box-shadow: -10px 0 30px rgba(0,0,0,.40), -1px 0 0 var(--border);
}
.term-drawer-outer-resizer {
position: absolute; left: 0; top: 0; bottom: 0;
width: 6px;
cursor: col-resize;
background: transparent;
transition: background .15s;
z-index: 2;
display: none;
}
.cockpit-terminals-drawer.is-open .term-drawer-outer-resizer { display: block; }
.term-drawer-outer-resizer:hover,
.term-drawer-outer-resizer.dragging { background: var(--accent); opacity: .55; }
.term-drawer-rail {
flex: 1; min-height: 0;
display: flex; flex-direction: column; align-items: center;
padding: 6px 0 8px;
gap: 8px;
}
.cockpit-terminals-drawer.is-open .term-drawer-rail { display: none; }
.term-rail-toggle, .term-rail-new {
background: transparent; border: 0; cursor: pointer;
color: var(--accent); font-size: 13px; font-weight: 600;
width: 28px; height: 28px;
display: flex; align-items: center; justify-content: center;
border-radius: 6px;
}
.term-rail-toggle { color: var(--axo-jade-glow); }
.term-rail-toggle:hover, .term-rail-new:hover { background: rgba(var(--axo-jade-rgb), .14); }
.term-rail-label {
writing-mode: vertical-rl;
transform: rotate(180deg);
font-family: 'Space Grotesk', sans-serif;
font-size: 10.5px; font-weight: 600;
text-transform: uppercase;
letter-spacing: 1.2px;
color: var(--muted-2);
margin: 4px 0;
}
.term-rail-list { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 4px; overflow-y: auto; overflow-x: hidden; padding: 4px 0; min-height: 0; width: 100%; }
.term-rail-row {
writing-mode: vertical-rl;
transform: rotate(180deg);
padding: 8px 4px;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 11px;
color: var(--muted);
cursor: pointer;
border-radius: 4px;
display: flex; align-items: center; gap: 6px;
white-space: nowrap;
max-height: 200px;
overflow: hidden;
text-overflow: ellipsis;
}
.term-rail-row:hover { color: var(--fg); background: rgba(255,255,255,.04); }
.term-rail-row.active { color: var(--accent); }
.term-rail-row .term-dot { width: 6px; height: 6px; border-radius: 50%; }
.term-drawer-body { flex: 1; min-height: 0; display: none; flex-direction: column; }
.cockpit-terminals-drawer.is-open .term-drawer-body { display: flex; }
.terminals-inner-resizer {
width: 4px; cursor: col-resize; background: transparent;
transition: background .15s; flex-shrink: 0;
user-select: none; touch-action: none;
}
.terminals-inner-resizer:hover,
.terminals-inner-resizer.dragging { background: var(--accent); opacity: .55; }
.terminals-body { flex: 1; display: flex; min-height: 0; }
.terminals-list { width: 130px; border-right: 1px solid var(--border); overflow-y: auto; padding: 4px; flex-shrink: 0; }
.terminals-empty { padding: 10px; }
.term-row { display: flex; align-items: center; gap: 7px; padding: 5px 8px; border-radius: 4px; cursor: pointer; font-size: 12px; }
.term-row:hover { background: var(--bg-3); }
.term-row.active { background: rgba(var(--axo-jade-rgb),.16); color: var(--fg); }
.term-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.term-dot.running { background: #4caf50; box-shadow: 0 0 5px #4caf50aa; }
.term-dot.exited { background: #888; }
.term-dot.failed { background: #e53935; }
.term-label { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; }
.terminals-output { flex: 1; min-width: 0; background: #0c0e16; display: flex; flex-direction: column; min-height: 0; }
.term-status-bar { padding: 4px 10px; border-bottom: 1px solid #1f2231; color: #8a8fa3; font-size: 11px; display: flex; gap: 10px; align-items: center; flex-shrink: 0; }
.term-status-bar .term-cmd { font-family: ui-monospace, monospace; color: #d4d4d4; }
.term-pre { margin: 0; padding: 10px 12px; flex: 1; overflow: auto; color: #d4d4d4; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; line-height: 1.45; white-space: pre-wrap; word-break: break-word; }
.term-empty-output { padding: 14px; color: #6e7488; font-size: 12px; }
.term-new-pop { position: fixed; background: var(--panel-2); border: 1px solid var(--border-strong); border-radius: 8px; padding: 11px; min-width: 340px; max-width: 420px; z-index: 200; box-shadow: 0 8px 26px rgba(0,0,0,.5); display: flex; flex-direction: column; gap: 9px; }
.term-new-pop .label { font-size: 10.5px; text-transform: uppercase; letter-spacing: .6px; color: var(--muted); }
.term-new-pop .input { width: 100%; box-sizing: border-box; padding: 7px 10px; background: var(--bg-2); border: 1px solid var(--border); border-radius: 5px; color: var(--fg); font-family: ui-monospace, monospace; font-size: 12.5px; }
.term-new-pop .input:focus { outline: none; border-color: var(--accent); }
.term-presets { display: flex; flex-wrap: wrap; gap: 5px; }
.term-preset { padding: 4px 9px; background: var(--bg-3); border: 1px solid var(--border); border-radius: 4px; font-size: 11px; cursor: pointer; font-family: ui-monospace, monospace; color: var(--fg); }
.term-preset:hover { background: var(--bg-2); border-color: var(--accent); }
.term-pop-row { display: flex; justify-content: flex-end; gap: 6px; }
.pane-head { display: flex; align-items: center; gap: 8px; padding: 8px 11px 7px; font-size: 10.5px; text-transform: uppercase; letter-spacing: .75px; color: var(--muted); border-bottom: 1px solid var(--border); flex-shrink: 0; background: linear-gradient(180deg, rgba(255,255,255,.014), rgba(255,255,255,0)); }
.pane-head-title { font-weight: 600; color: var(--muted); }
.pane-head-meta { margin-left: auto; font-family: 'JetBrains Mono', ui-monospace, monospace; text-transform: none; letter-spacing: 0; font-size: 10.5px; color: var(--muted-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 60%; }
.pane-head-meta:empty { display: none; }
.pane-head[draggable="true"] { cursor: grab; user-select: none; }
.pane-head[draggable="true"]:active { cursor: grabbing; }
.pane-head.drag-over { background: rgba(var(--axo-jade-rgb),.10); box-shadow: inset 0 -2px 0 var(--axo-jade-glow); }
.pane-btn { background: transparent; border: none; color: var(--muted); cursor: pointer; padding: 2px 6px; border-radius: 3px; font-size: 11px; line-height: 1; }
.pane-btn:hover { background: var(--bg-3); color: var(--fg); }
.pane-btn.active { color: var(--accent); background: rgba(var(--axo-jade-rgb),.12); }
.file-tabs { display: flex; overflow-x: auto; border-bottom: 1px solid var(--border); flex-shrink: 0; }
.file-tabs:empty { display: none; }
.viewer-modes { display: none; gap: 0; border-bottom: 1px solid var(--border); flex-shrink: 0; align-items: center; }
.vmode-action { padding: 3px 9px; margin: 2px 4px 2px 0; background: var(--bg-3); border: 1px solid var(--border); border-radius: 4px; color: var(--fg); cursor: pointer; font-size: 11px; }
.vmode-action:hover { background: var(--bg-2); border-color: var(--accent); }
.vmode-action:disabled { opacity: .35; cursor: default; }
.monaco-host { width: 100%; height: 100%; }
.monaco-host > .monaco-editor { width: 100% !important; height: 100% !important; }
.monaco-host-empty { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: 12.5px; }
.file-truncated-banner { padding: 8px 12px; background: rgba(255,180,84,.12); border-bottom: 1px solid var(--warn); color: var(--warn); font-size: 12px; }
.chat-refs { display: flex; flex-wrap: wrap; gap: 5px; padding: 5px 10px 0; }
.chat-refs:empty { display: none; }
.chat-ref { display: inline-flex; align-items: center; gap: 6px; padding: 3px 8px 3px 6px; background: var(--bg-3); border: 1px solid var(--border); border-radius: 11px; font-size: 11px; font-family: ui-monospace, monospace; max-width: 100%; }
.chat-ref .chip-icon { font-size: 10px; opacity: .7; }
.chat-ref .chip-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 220px; }
.chat-ref .chip-x { color: var(--muted); cursor: pointer; padding: 0 2px; }
.chat-ref .chip-x:hover { color: var(--accent); }
.sel-bubble { position: fixed; z-index: 80; background: var(--panel-2); border: 1px solid var(--border-strong); border-radius: 6px; padding: 4px 9px; font-size: 11px; cursor: pointer; box-shadow: 0 4px 14px rgba(0,0,0,.4); user-select: none; }
.sel-bubble:hover { border-color: var(--accent); }
.vmode { padding: 4px 11px; background: transparent; border: none; color: var(--muted); cursor: pointer; font-size: 11px; border-right: 1px solid var(--border); }
.vmode:hover { background: var(--bg-3); color: var(--fg); }
.vmode.active { color: var(--accent); background: var(--panel-2); border-bottom: 2px solid var(--accent); }
.vmode-spacer { flex: 1; }
.vmode-hint { padding: 4px 11px; color: var(--muted); font-size: 10.5px; align-self: center; }
.file-preview { width: 100%; height: 100%; border: 0; background: #fff; }
.finder { display: grid; grid-template-columns: 220px 1fr; gap: 0; flex: 1; min-height: 0; height: 100%; border: 1px solid var(--border); overflow: hidden; background: var(--panel); }
.finder-sidebar { background: var(--bg-2); border-right: 1px solid var(--border); display: flex; flex-direction: column; padding: 10px 8px 8px; overflow: hidden; min-width: 0; }
.finder-side-head { font-size: 10.5px; text-transform: uppercase; letter-spacing: .8px; color: var(--muted); padding: 6px 8px 4px; }
.finder-side-head-2 { margin-top: 8px; border-top: 1px solid var(--border); padding-top: 12px; }
.finder-side-list { display: flex; flex-direction: column; gap: 1px; overflow-y: auto; }
.finder-side-row { display: flex; align-items: center; gap: 7px; padding: 5px 8px; border-radius: 6px; font-size: 12.5px; color: var(--fg); cursor: pointer; user-select: none; }
.finder-side-row:hover { background: var(--bg-3); }
.finder-side-row.active { background: rgba(var(--axo-jade-rgb),.22); color: #fff; }
.finder-side-row .ico { width: 14px; text-align: center; opacity: .85; flex-shrink: 0; }
.finder-side-row .lbl { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.finder-side-row .x { color: var(--muted-2); opacity: 0; padding: 0 3px; }
.finder-side-row:hover .x { opacity: 1; }
.finder-side-row .x:hover { color: var(--accent); }
.finder-side-add { margin: 8px 4px 0; padding: 5px 10px; background: transparent; border: 1px dashed var(--border-strong); color: var(--muted); border-radius: 6px; font-size: 11.5px; cursor: pointer; }
.finder-side-add:hover { color: var(--accent); border-color: var(--accent); }
.finder-main { display: flex; flex-direction: column; min-width: 0; min-height: 0; background: var(--panel); }
.finder-toolbar { display: flex; align-items: center; gap: 7px; padding: 8px 11px; border-bottom: 1px solid var(--border); background: var(--panel-2); flex-shrink: 0; }
.finder-nav-btn { background: var(--bg-3); border: 1px solid var(--border); color: var(--fg); width: 26px; height: 24px; border-radius: 5px; font-size: 14px; line-height: 1; cursor: pointer; }
.finder-nav-btn:hover:not(:disabled) { background: var(--bg-2); border-color: var(--accent); color: var(--accent); }
.finder-nav-btn:disabled { opacity: .35; cursor: default; }
.finder-path { flex: 1; min-width: 0; padding: 4px 10px; background: var(--bg-2); border: 1px solid var(--border); border-radius: 5px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.finder-path .crumb { color: var(--fg); cursor: pointer; }
.finder-path .crumb:hover { color: var(--accent); }
.finder-path .sep { color: var(--muted-2); padding: 0 4px; }
.finder-search { width: 180px; padding: 4px 9px; background: var(--bg-2); border: 1px solid var(--border); border-radius: 5px; color: var(--fg); font-size: 11.5px; }
.finder-search:focus { outline: none; border-color: var(--accent); }
.finder-grid { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.finder-col-head { display: grid; grid-template-columns: minmax(220px, 1fr) 110px 90px 130px; gap: 14px; padding: 6px 14px; border-bottom: 1px solid var(--border); background: var(--panel-2); font-size: 10.5px; text-transform: uppercase; letter-spacing: .6px; color: var(--muted); flex-shrink: 0; }
.finder-col-head > * { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.finder-rows { flex: 1; overflow-y: auto; padding: 4px 0; }
.finder-empty { padding: 36px 24px; color: var(--muted); font-size: 13px; text-align: center; }
.finder-row { display: grid; grid-template-columns: minmax(220px, 1fr) 110px 90px 130px; gap: 14px; align-items: center; padding: 6px 14px; cursor: pointer; border: 1px solid transparent; user-select: none; }
.finder-row:hover { background: var(--bg-2); }
.finder-row.active { background: rgba(var(--axo-jade-rgb),.22); color: #fff; border-color: rgba(var(--axo-jade-rgb),.35); }
.finder-row .nm { display: flex; align-items: center; gap: 8px; min-width: 0; }
.finder-row .nm-ico { width: 18px; height: 18px; flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; }
.finder-row .nm-txt { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.finder-row .meta { font-family: ui-monospace, monospace; font-size: 11px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.finder-row.session .nm-txt { color: var(--fg); }
.finder-row.folder .nm-txt { color: var(--fg); }
.finder-row .session-dot { width: 7px; height: 7px; border-radius: 50%; background: #4caf50; box-shadow: 0 0 5px rgba(76,175,80,.5); flex-shrink: 0; }
.finder-row .session-dot.closed { background: #888; box-shadow: none; }
.shell { display: grid; grid-template-columns: 240px 1fr; gap: 0; flex: 1; min-height: 0; height: 100%; border: 1px solid var(--border); overflow: hidden; background: var(--panel); }
.shell-side { background: var(--bg-2); border-right: 1px solid var(--border); display: flex; flex-direction: column; min-width: 0; overflow: hidden; }
.shell-side-search { padding: 9px 9px 6px; flex-shrink: 0; }
.shell-side-search input { width: 100%; padding: 4px 9px; background: var(--panel); border: 1px solid var(--border); border-radius: 5px; color: var(--fg); font-size: 11.5px; box-sizing: border-box; }
.shell-side-search input:focus { outline: none; border-color: var(--accent); }
.shell-side-scroll { flex: 1; overflow-y: auto; padding: 2px 0 8px; }
.shell-side-section { display: flex; flex-direction: column; padding: 4px 0 2px; }
.shell-side-head { display: flex; align-items: center; gap: 6px; padding: 7px 10px 4px; font-size: 10.5px; text-transform: uppercase; letter-spacing: .8px; color: var(--muted); cursor: pointer; user-select: none; }
.shell-side-head .tri { display: inline-block; width: 9px; font-size: 9px; color: var(--muted-2); transition: transform .12s; }
.shell-side-section.collapsed .tri { transform: rotate(-90deg); }
.shell-side-section.collapsed .shell-side-list { display: none; }
.shell-side-list { display: flex; flex-direction: column; gap: 1px; padding: 0 6px; }
.shell-side-row { display: flex; align-items: center; gap: 8px; padding: 5px 9px; border-radius: 6px; font-size: 12.5px; color: var(--fg); cursor: pointer; user-select: none; }
.shell-side-row:hover { background: var(--bg-3); }
.shell-side-row.active { background: rgba(var(--axo-jade-rgb),.22); color: #fff; }
.shell-side-row .ico { width: 14px; text-align: center; opacity: .85; flex-shrink: 0; font-size: 13px; }
.shell-side-row .lbl { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.shell-side-row .ct { font-family: ui-monospace, monospace; font-size: 10.5px; color: var(--muted-2); }
.shell-side-row .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted-2); flex-shrink: 0; }
.shell-side-row .dot.on { background: var(--ok); box-shadow: 0 0 6px var(--ok); }
.shell-side-row .dot.run { background: var(--axo-jade-glow); box-shadow: 0 0 6px var(--axo-jade-glow); animation: pulse 1.6s infinite; }
.shell-side-row .dot.err { background: var(--err); }
.shell-side-empty { padding: 6px 12px; font-size: 11px; color: var(--muted-2); }
.shell-main { display: flex; flex-direction: column; min-width: 0; min-height: 0; background: var(--panel); position: relative; }
.shell-toolbar { display: flex; align-items: center; gap: 8px; padding: 7px 12px; border-bottom: 1px solid var(--border); background: var(--panel-2); flex-shrink: 0; min-height: 38px; }
.shell-toolbar h2 { margin: 0; font-size: 14px; font-weight: 600; color: var(--fg); }
.shell-toolbar .sub { color: var(--muted); font-size: 11.5px; }
.shell-search { width: 200px; padding: 4px 9px; background: var(--bg-2); border: 1px solid var(--border); border-radius: 5px; color: var(--fg); font-size: 11.5px; }
.shell-search:focus { outline: none; border-color: var(--accent); }
.shell-content { flex: 1; min-height: 0; overflow: auto; }
.shell-canvas { flex: 1; min-height: 0; position: relative; overflow: hidden; }
.shell-col-head { display: grid; grid-template-columns: minmax(200px, 1.6fr) 1.4fr 1.4fr 80px; gap: 14px; padding: 7px 14px; border-bottom: 1px solid var(--border); background: var(--panel-2); font-size: 10.5px; text-transform: uppercase; letter-spacing: .6px; color: var(--muted); flex-shrink: 0; position: sticky; top: 0; z-index: 1; }
.shell-rows { padding: 0; }
.shell-row { display: grid; grid-template-columns: minmax(200px, 1.6fr) 1.4fr 1.4fr 80px; gap: 14px; align-items: center; padding: 8px 14px; cursor: pointer; border-bottom: 1px solid var(--border); user-select: none; }
.shell-row:hover { background: var(--bg-2); }
.shell-row.active { background: rgba(var(--axo-jade-rgb),.22); color: #fff; }
.shell-row .nm { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.shell-row .nm-main { font-weight: 600; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.shell-row .nm-sub { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.shell-row .cell { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; min-width: 0; overflow: hidden; }
.shell-row .cell-more { font-size: 10.5px; color: var(--muted-2); font-family: ui-monospace, monospace; }
.shell-row .count { font-family: ui-monospace, monospace; font-size: 12px; color: var(--muted); text-align: right; }
tr.row-flash { animation: row-flash 1.5s ease-out; }
@keyframes row-flash { 0% { background: rgba(var(--axo-jade-rgb),.5); } 100% { background: transparent; } }
.shell-drawer { position: absolute; top: 0; right: 0; bottom: 0; width: 380px; background: var(--panel); border-left: 1px solid var(--border); box-shadow: -8px 0 24px rgba(0,0,0,.25); transform: translateX(100%); transition: transform .18s ease; display: flex; flex-direction: column; z-index: 5; }
.shell-drawer.open { transform: translateX(0); }
.shell-drawer-head { display: flex; align-items: center; gap: 8px; padding: 9px 12px; border-bottom: 1px solid var(--border); background: var(--panel-2); flex-shrink: 0; }
.shell-drawer-head h3 { margin: 0; font-size: 13.5px; font-weight: 600; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.shell-drawer-close { background: transparent; border: 0; color: var(--muted); font-size: 18px; line-height: 1; padding: 0 4px; cursor: pointer; }
.shell-drawer-close:hover { color: var(--fg); }
.shell-drawer-body { flex: 1; overflow-y: auto; padding: 12px 14px; }
.shell-drawer-foot { padding: 10px 12px; border-top: 1px solid var(--border); background: var(--panel-2); display: flex; gap: 8px; justify-content: flex-end; flex-shrink: 0; }
.shell-seg { display: inline-flex; gap: 2px; padding: 2px; background: var(--bg-2); border: 1px solid var(--border); border-radius: 7px; flex-shrink: 0; }
.shell-seg-btn { background: transparent; border: 0; color: var(--muted); padding: 4px 12px; border-radius: 5px; font-size: 12px; cursor: pointer; }
.shell-seg-btn:hover { color: var(--fg); background: var(--bg-3); }
.shell-seg-btn.active { color: var(--fg); background: var(--panel); box-shadow: 0 1px 3px rgba(0,0,0,.3); }
.shell-obs-event { padding: 4px 9px; font-size: 11.5px; line-height: 1.35; border-left: 2px solid transparent; margin: 0 4px 2px; border-radius: 0 4px 4px 0; }
.shell-obs-event .when { color: var(--muted-2); font-family: ui-monospace, monospace; font-size: 10px; }
.shell-obs-event .who { color: var(--axo-jade); font-family: ui-monospace, monospace; font-size: 10.5px; }
.shell-obs-event .what { color: var(--text); }
.folder-skill { color: #d9b56a; font-size: 18px; line-height: 1; }
.studio-mode { display: inline-flex; gap: 2px; padding: 3px; background: var(--bg-2); border: 1px solid var(--border); border-radius: 7px; flex-shrink: 0; }
.studio-mode-btn { background: transparent; border: 0; color: var(--muted); padding: 5px 11px; border-radius: 5px; font-size: 12px; cursor: pointer; }
.studio-mode-btn:hover { color: var(--text); background: var(--bg-3); }
.studio-mode-btn.active { color: var(--text); background: var(--panel); box-shadow: 0 1px 3px rgba(0,0,0,.3); }
.studio-help-inline { font-size: 11px; }
.obs-event { padding: 5px 10px; font-size: 12px; line-height: 1.4; border-left: 2px solid transparent; margin-bottom: 3px; border-radius: 0 4px 4px 0; }
.obs-event .when { color: var(--muted-2); font-family: ui-monospace, monospace; font-size: 10.5px; }
.obs-event .what { color: var(--text); }
.obs-event .who { color: var(--axo-jade); font-family: ui-monospace, monospace; font-size: 11px; }
.obs-event .preview { color: var(--muted); font-size: 11px; padding-left: 8px; margin-top: 2px; border-left: 1px solid var(--border); }
.obs-event.activation { border-left-color: var(--axo-jade); background: rgba(var(--axo-jade-rgb),0.06); }
.obs-event.completion { border-left-color: var(--axo-bronze); }
.obs-event.error { border-left-color: var(--err); }
.obs-event.session { border-left-color: var(--axo-blue); }
@keyframes axoPulse {
0% { box-shadow: 0 0 0 0 rgba(var(--axo-jade-glow-rgb), .75),
0 0 0 0 rgba(var(--axo-jade-glow-rgb), .55); }
60% { box-shadow: 0 0 0 14px rgba(var(--axo-jade-glow-rgb), .15),
0 0 0 28px rgba(var(--axo-jade-glow-rgb), .08); }
100% { box-shadow: 0 0 0 22px rgba(var(--axo-jade-glow-rgb), 0),
0 0 0 44px rgba(var(--axo-jade-glow-rgb), 0); }
}
ax-node.axo-pulse { animation: axoPulse 0.95s ease-out; }
ax-node.axo-active {
outline: 2px solid var(--axo-jade-glow) !important;
outline-offset: 2px;
box-shadow:
0 0 0 1px var(--axo-jade-glow),
0 0 14px 4px rgba(var(--axo-jade-glow-rgb), .55),
0 0 30px 10px rgba(var(--axo-jade-glow-rgb), .22) !important;
animation: axoActiveBreathe 2.2s ease-in-out infinite;
}
ax-node.axo-completed {
outline: 2px solid var(--axo-bronze-glow) !important;
outline-offset: 2px;
box-shadow:
0 0 0 1px var(--axo-bronze-glow),
0 0 12px 3px rgba(var(--axo-bronze-glow-rgb), .50),
0 0 26px 8px rgba(var(--axo-bronze-glow-rgb), .18) !important;
}
ax-node.axo-paused {
outline: 2px dashed var(--warn) !important;
outline-offset: 2px;
box-shadow: 0 0 12px 2px rgba(255,180,84,.40) !important;
}
@keyframes axoActiveBreathe {
0%,100% {
box-shadow:
0 0 0 1px var(--axo-jade-glow),
0 0 14px 4px rgba(var(--axo-jade-glow-rgb), .55),
0 0 30px 10px rgba(var(--axo-jade-glow-rgb), .22);
}
50% {
box-shadow:
0 0 0 1px var(--axo-jade-glow),
0 0 18px 5px rgba(var(--axo-jade-glow-rgb), .70),
0 0 38px 14px rgba(var(--axo-jade-glow-rgb), .30);
}
}
body.studio-watch-mode #studio-lattice ax-node { cursor: pointer; }
body.studio-watch-mode #studio-lattice ax-handle { display: none; }
.auto-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 14px; }
.auto-head-tools { display: flex; gap: 10px; align-items: center; }
.auto-filter { display: inline-flex; gap: 2px; padding: 3px; background: var(--bg-2); border: 1px solid var(--border); border-radius: 7px; }
.auto-pill { background: transparent; border: 0; color: var(--muted); padding: 5px 12px; border-radius: 5px; font-size: 12px; cursor: pointer; }
.auto-pill:hover { color: var(--text); background: var(--bg-3); }
.auto-pill.active { color: var(--text); background: var(--panel); box-shadow: 0 1px 3px rgba(0,0,0,.3); }
.auto-explorer {
display: grid;
grid-template-columns: 260px 1fr;
gap: 0;
flex: 1;
min-height: 0;
height: 100%;
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
background: var(--panel);
}
.auto-tree {
background: var(--bg-2);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.auto-tree-head {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
font-size: 11px;
text-transform: uppercase;
letter-spacing: .06em;
color: var(--muted-2);
}
.auto-tree-list { flex: 1; overflow-y: auto; padding: 6px; }
.auto-tree-row {
display: flex;
gap: 6px;
padding: 6px 9px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
color: var(--text);
align-items: center;
border: 1px solid transparent;
}
.auto-tree-row:hover { background: var(--bg-3); }
.auto-tree-row.active {
background: var(--panel);
border-color: var(--border-strong);
}
.auto-tree-row.drop-target {
border-color: var(--axo-jade-glow);
background: rgba(var(--axo-jade-glow-rgb), .14);
}
.auto-tree-row .twist {
width: 12px;
color: var(--muted-2);
font-family: ui-monospace, monospace;
flex-shrink: 0;
cursor: pointer;
}
.auto-tree-row .ico {
color: var(--muted-2);
flex-shrink: 0;
}
.auto-tree-row.active .ico { color: var(--axo-jade-glow); }
.auto-tree-row .label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.auto-tree-row .count {
font-size: 11px;
color: var(--muted-2);
font-family: ui-monospace, monospace;
}
.auto-tree-section {
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: .06em;
color: var(--muted-2);
padding: 8px 8px 4px;
}
.auto-main {
background: var(--panel);
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.auto-main-head {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
display: flex;
gap: 12px;
align-items: center;
flex-shrink: 0;
}
.auto-main-head .grow { flex: 1; }
.auto-crumbs {
display: flex;
gap: 4px;
align-items: center;
font-size: 13px;
color: var(--muted);
flex-wrap: wrap;
}
.auto-crumb {
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
}
.auto-crumb:hover { background: var(--bg-3); color: var(--text); }
.auto-crumb.current { color: var(--text); font-weight: 600; cursor: default; }
.auto-crumb-sep { color: var(--muted-2); }
.auto-cards {
flex: 1;
overflow-y: auto;
padding: 16px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 12px;
align-content: start;
}
.auto-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px;
cursor: pointer;
transition: border-color .12s, transform .08s, box-shadow .12s;
display: flex;
flex-direction: column;
gap: 10px;
min-height: 132px;
position: relative;
}
.auto-card:hover {
border-color: var(--axo-jade-glow);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.auto-card.disabled { opacity: .55; }
.auto-card.dragging { opacity: .55; transform: scale(.97); }
.auto-card-row-top {
display: flex;
gap: 10px;
align-items: flex-start;
}
.auto-card-row-bottom {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
margin-top: auto;
}
.auto-card-icon { width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 16px; flex-shrink: 0; }
.auto-card-icon.manual { background: rgba(var(--axo-jade-rgb),.18); color: var(--axo-jade); }
.auto-card-icon.schedule { background: rgba(63,169,200,.18); color: var(--axo-blue); }
.auto-card-icon.event { background: rgba(var(--axo-bronze-rgb),.18); color: var(--axo-bronze); }
.auto-card-icon.skill { background: rgba(var(--axo-jade-rgb),.18); color: var(--accent); }
.auto-card-icon.folder { background: rgba(var(--axo-bronze-rgb),.18); color: var(--axo-bronze-glow); font-size: 20px; }
.mcp-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 8px;
transition: border-color .12s, box-shadow .12s, transform .08s;
}
.mcp-card:hover {
border-color: var(--axo-jade-glow);
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.mcp-card-head {
display: flex;
align-items: center;
gap: 10px;
}
.mcp-card-icon {
width: 36px; height: 36px;
border-radius: 8px;
background: var(--bg-3);
display: flex; align-items: center; justify-content: center;
font-size: 16px;
flex-shrink: 0;
}
.mcp-card-name { font-weight: 600; font-size: 14px; color: var(--text); flex: 1; }
.mcp-card-recommended {
font-size: 10px;
text-transform: uppercase;
letter-spacing: .06em;
color: var(--axo-jade-glow);
background: rgba(var(--axo-jade-glow-rgb), .14);
padding: 2px 8px;
border-radius: 4px;
flex-shrink: 0;
}
.mcp-card-desc {
font-size: 12.5px;
color: var(--muted);
line-height: 1.5;
flex: 1;
}
.mcp-card-foot {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
}
.mcp-card-cat {
font-size: 11px;
color: var(--muted-2);
font-family: ui-monospace, monospace;
}
.mcp-card-foot .grow { flex: 1; }
.mcp-card-status-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--ok);
box-shadow: 0 0 8px var(--ok);
}
.mcp-card-status-dot.installed { background: var(--axo-jade-glow); box-shadow: 0 0 8px var(--axo-jade-glow); }
.modal.mcp-approval { width: 640px; }
.mcp-approval-subject {
font-size: 14px;
line-height: 1.6;
color: var(--text);
}
.mcp-approval-subject .mcp-approval-agent {
color: var(--axo-jade-glow);
font-family: ui-monospace, monospace;
font-weight: 600;
}
.mcp-approval-subject .mcp-approval-tool {
color: var(--axo-bronze-glow);
font-family: ui-monospace, monospace;
font-weight: 600;
}
.mcp-approval-subject .mcp-approval-server {
color: var(--axo-blue-glow);
font-family: ui-monospace, monospace;
font-weight: 600;
}
.mcp-approval-args {
background: var(--bg-3);
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px 12px;
margin: 0;
font-family: ui-monospace, monospace;
font-size: 12px;
color: var(--muted);
max-height: 220px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
.mcp-approval-foot {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.mcp-approval-foot .btn { white-space: nowrap; }
.auto-folder-card { border-color: var(--border-strong); }
.auto-folder-card:hover { border-color: var(--axo-bronze-glow); }
.auto-folder-card .auto-card-trigger.folder {
background: rgba(var(--axo-bronze-rgb),.18);
color: var(--axo-bronze);
}
.auto-folder-card.drop-target,
.auto-card.drop-target {
border-color: var(--axo-jade-glow);
background: rgba(var(--axo-jade-glow-rgb), .10);
}
.auto-card-body { min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.auto-card-row1 { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.auto-card-name { font-size: 14px; font-weight: 600; color: var(--text); }
.auto-card-trigger { font-size: 11px; padding: 2px 7px; border-radius: 4px; background: var(--bg-3); color: var(--muted); font-family: ui-monospace, monospace; }
.auto-card-trigger.scheduled { background: rgba(63,169,200,.15); color: var(--axo-blue); }
.auto-card-trigger.manual { background: rgba(var(--axo-jade-rgb),.15); color: var(--axo-jade); }
.auto-card-trigger.event { background: rgba(var(--axo-bronze-rgb),.15); color: var(--axo-bronze); }
.auto-card-agents { font-size: 12px; color: var(--muted); font-family: ui-monospace, monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.auto-card-agents .arrow { color: var(--muted-2); margin: 0 4px; }
.auto-card-meta { font-size: 11px; color: var(--muted-2); margin-top: 1px; }
.auto-card-actions { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
.auto-card .btn.sm { padding: 5px 11px; font-size: 11.5px; }
.auto-empty { padding: 40px 24px; text-align: center; color: var(--muted); border: 1px dashed var(--border); border-radius: 9px; }
.auto-sub { display: block; }
.auto-sub.hide { display: none; }
.auto-editor { display: grid; grid-template-rows: auto 1fr auto; gap: 10px; flex: 1; min-height: 0; height: 100%; }
.auto-editor-head { display: flex; align-items: center; gap: 12px; padding: 10px 14px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 9px; }
.auto-editor-title { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.auto-editor-trigger { font-size: 11.5px; padding: 4px 10px; border-radius: 5px; background: var(--bg-3); color: var(--muted); font-family: ui-monospace, monospace; }
.auto-editor-canvas-wrap { position: relative; background: var(--panel); border: 1px solid var(--border); border-radius: 9px; overflow: hidden; min-height: 0; }
.auto-editor-canvas-wrap ax-lattice { display: block; width: 100%; height: 100%; }
.auto-editor-controls { position: absolute; top: 12px; left: 12px; }
.auto-editor-minimap { position: absolute; bottom: 12px; right: 12px; width: 160px; height: 100px; }
.auto-editor-footer { padding: 6px 4px; }
.auto-editor-inspector { position: absolute; right: 14px; top: 90px; width: 280px; background: var(--panel-2); border: 1px solid var(--border-strong); border-radius: 9px; padding: 12px; box-shadow: 0 8px 24px rgba(0,0,0,.4); z-index: 50; }
.auto-editor-runs { position: absolute; right: 14px; top: 90px; width: 360px; max-height: 70%; background: var(--panel-2); border: 1px solid var(--border-strong); border-radius: 9px; box-shadow: 0 8px 24px rgba(0,0,0,.4); z-index: 50; display: flex; flex-direction: column; overflow: hidden; }
.auto-runs-head { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; font-size: 11px; text-transform: uppercase; letter-spacing: .8px; color: var(--muted); border-bottom: 1px solid var(--border); }
.auto-runs-list { overflow-y: auto; padding: 6px 8px; }
.auto-run { background: var(--panel); border: 1px solid var(--border); border-radius: 7px; padding: 8px 10px; margin-bottom: 6px; }
.auto-run-head { display: flex; align-items: center; justify-content: space-between; cursor: pointer; }
.auto-run-id { font-family: ui-monospace, monospace; font-size: 11px; color: var(--muted); }
.auto-run-status { font-size: 10.5px; padding: 1px 7px; border-radius: 4px; text-transform: uppercase; letter-spacing: .4px; }
.auto-run-status.completed { background: rgba(74,222,128,.18); color: var(--ok); }
.auto-run-status.failed { background: rgba(255,107,107,.18); color: var(--err); }
.auto-run-status.running { background: rgba(var(--axo-jade-rgb),.18); color: var(--axo-jade); }
.auto-run-status.interrupted { background: rgba(255,180,84,.18); color: var(--warn); }
.auto-run-steps { display: none; flex-direction: column; gap: 4px; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); }
.auto-run.open .auto-run-steps { display: flex; }
.auto-run-step { display: flex; align-items: center; gap: 6px; padding: 4px 6px; font-size: 11px; font-family: ui-monospace, monospace; border-radius: 4px; }
.auto-run-step:hover { background: var(--bg-3); }
.auto-run-step .ico { width: 12px; }
.auto-run-step .lbl { flex: 1; color: var(--text); }
.auto-run-step .fork { color: var(--muted); cursor: pointer; padding: 0 6px; }
.auto-run-step .fork:hover { color: var(--accent); }
.add-node-pop { position: fixed; z-index: 700; background: var(--panel-2); border: 1px solid var(--border-strong); border-radius: 9px; padding: 10px; min-width: 360px; max-width: 420px; box-shadow: 0 10px 32px rgba(0,0,0,.5); display: flex; flex-direction: column; gap: 8px; }
.add-node-tabs { display: flex; gap: 4px; background: var(--bg-2); padding: 3px; border-radius: 6px; }
.add-node-tab { display: flex; align-items: center; gap: 6px; padding: 6px 11px; flex: 1; justify-content: center; background: transparent; border: 0; border-radius: 5px; color: var(--muted); cursor: pointer; font-size: 12px; }
.add-node-tab:hover { color: var(--text); }
.add-node-tab.active { color: var(--text); background: var(--panel); box-shadow: 0 1px 3px rgba(0,0,0,.3); }
.add-node-tab-ico { font-size: 14px; }
.add-node-head { display: flex; align-items: center; font-size: 10.5px; text-transform: uppercase; letter-spacing: .8px; color: var(--muted); padding: 0 2px 4px; border-bottom: 1px solid var(--border); }
.add-node-title { font-weight: 600; }
#add-node-search { padding: 6px 10px; background: var(--bg-2); border: 1px solid var(--border); border-radius: 5px; color: var(--fg); font-size: 12.5px; }
#add-node-search:focus { outline: none; border-color: var(--accent); }
.add-node-list { display: flex; flex-direction: column; gap: 2px; max-height: 280px; overflow-y: auto; padding: 2px; }
.add-node-row { display: flex; flex-direction: column; gap: 2px; padding: 7px 10px; border-radius: 5px; cursor: pointer; }
.add-node-row:hover { background: rgba(var(--axo-jade-rgb),.16); }
.add-node-row .nm { font-size: 13px; font-weight: 500; color: var(--text); }
.add-node-row .meta { font-size: 11px; color: var(--muted); font-family: ui-monospace, monospace; }
.add-node-foot { padding-top: 4px; border-top: 1px solid var(--border); }
.add-node-empty { padding: 18px; text-align: center; color: var(--muted); font-size: 12px; }
.run-input-form { padding: 14px 20px; display: flex; flex-direction: column; gap: 14px; max-height: 60vh; overflow-y: auto; }
.run-input-field { display: flex; flex-direction: column; gap: 5px; }
.run-input-field label { font-size: 12px; color: var(--muted); }
.run-input-field .input, .run-input-field textarea.input { padding: 7px 10px; background: var(--bg-2); border: 1px solid var(--border); border-radius: 5px; color: var(--fg); font-size: 13px; }
.run-input-field textarea.input { font-family: ui-monospace, monospace; min-height: 80px; resize: vertical; }
.run-input-empty { padding: 30px 0; text-align: center; color: var(--muted); font-size: 12px; }
.trigger-form { padding: 16px 20px; display: flex; flex-direction: column; gap: 12px; }
.trigger-row { display: flex; flex-direction: column; gap: 5px; }
.trigger-row .input, .trigger-row .select { padding: 6px 10px; background: var(--bg-2); border: 1px solid var(--border); border-radius: 5px; color: var(--fg); font-size: 12.5px; }
.trigger-row textarea.input { font-family: ui-monospace, monospace; font-size: 12px; resize: vertical; }
#auto-editor-lattice ax-node {
background: var(--panel-2);
border: 1px solid var(--border-strong);
border-radius: 8px;
padding: 9px 13px;
min-width: 160px;
color: var(--text);
}
#auto-editor-lattice ax-node .sn-title { font-size: 13px; font-weight: 600; }
#auto-editor-lattice ax-node .sn-sub { font-size: 10.5px; color: var(--muted); margin-top: 2px; font-family: ui-monospace, monospace; }
#auto-editor-lattice ax-node .sn-input { font-size: 10px; color: var(--axo-blue); margin-top: 4px; padding-top: 4px; border-top: 1px solid var(--border); font-family: ui-monospace, monospace; }
#auto-editor-lattice ax-node[data-node-kind="agent"] { border-left: 3px solid var(--axo-jade); }
#auto-editor-lattice ax-node[data-node-kind="tool"] { border-left: 3px solid var(--axo-bronze); }
#auto-editor-lattice ax-node[data-node-kind="conditional"] { border-left: 3px solid var(--accent); }
#auto-editor-lattice ax-node[data-node-kind="map"] { border-left: 3px solid var(--axo-blue); }
#auto-editor-lattice ax-node[data-node-kind="subgraph"] { border-left: 3px solid #b46cff; }
#auto-editor-lattice ax-node[data-node-kind="interrupt"] { border-left: 3px solid var(--warn); }
#auto-editor-lattice ax-node[data-node-kind="text_input"] { border-left: 3px solid var(--accent-2); }
.ctx-menu { position: fixed; z-index: 600; background: var(--panel-2); border: 1px solid var(--border-strong);
border-radius: 7px; box-shadow: 0 8px 26px rgba(0,0,0,.5); padding: 4px; min-width: 180px; }
.ctx-item { display: flex; align-items: center; gap: 8px; padding: 6px 11px; font-size: 12.5px;
border-radius: 4px; cursor: pointer; user-select: none; }
.ctx-item:hover { background: rgba(var(--axo-jade-rgb),.22); color: #fff; }
.ctx-item.danger { color: #e57373; }
.ctx-item.danger:hover { background: rgba(229,115,115,.18); color: #ff8a80; }
.ctx-item.disabled { color: var(--muted-2); cursor: default; }
.ctx-item.disabled:hover { background: transparent; color: var(--muted-2); }
.ctx-sep { height: 1px; background: var(--border); margin: 4px 2px; }
.ctx-kbd { margin-left: auto; font-size: 10.5px; color: var(--muted-2); font-family: ui-monospace, monospace; }
.finder-side-row.dragging { opacity: .5; }
.finder-side-row.drop-above { box-shadow: inset 0 2px 0 0 var(--accent); }
.finder-side-row.drop-below { box-shadow: inset 0 -2px 0 0 var(--accent); }
.finder-rename { background: var(--bg-2); border: 1px solid var(--accent); border-radius: 3px;
color: var(--fg); padding: 1px 6px; font-size: 12.5px; min-width: 0; flex: 1; outline: none; }
.browser-toolbar { display: flex; gap: 4px; padding: 5px 6px; border-bottom: 1px solid var(--border); align-items: center; flex-shrink: 0; }
.bx-btn { background: var(--bg-3); border: 1px solid var(--border); color: var(--fg); padding: 3px 8px; border-radius: 4px; font-size: 11.5px; cursor: pointer; font-family: ui-monospace, monospace; }
.bx-btn:hover:not(:disabled) { background: var(--bg-2); border-color: var(--accent); }
.bx-btn.active { background: rgba(var(--axo-jade-rgb),.18); border-color: var(--accent); color: var(--accent); }
.bx-btn:disabled { opacity: .35; cursor: default; }
.bx-url { flex: 1; min-width: 0; padding: 4px 9px; background: var(--bg-2); border: 1px solid var(--border); border-radius: 4px; color: var(--fg); font-family: ui-monospace, monospace; font-size: 11.5px; }
.bx-url:focus { outline: none; border-color: var(--accent); }
.browser-body { flex: 1; min-height: 0; background: #fff; display: flex; }
.browser-body iframe { width: 100%; height: 100%; border: 0; position: relative; z-index: 0; }
.bx-suggest { display: flex; flex-wrap: wrap; gap: 5px; padding: 5px 7px; border-bottom: 1px solid var(--border); background: var(--panel-2); }
.bx-chip { padding: 2px 7px; background: var(--bg-3); border: 1px solid var(--border); border-radius: 11px; font-size: 10.5px; cursor: pointer; font-family: ui-monospace, monospace; color: var(--fg); }
.bx-chip:hover { background: var(--bg-2); border-color: var(--accent); }
.bx-chip-x { margin-left: 4px; color: var(--muted); cursor: pointer; }
.browser-body { position: relative; }
.dom-hier { position: absolute; top: 8px; right: 8px; width: 300px;
max-height: calc(100% - 16px); background: var(--panel-2);
border: 1px solid var(--border-strong); border-radius: 7px;
box-shadow: 0 6px 22px rgba(0,0,0,.45); z-index: 30;
display: flex; flex-direction: column; font-size: 12px; }
.dom-hier-head { display: flex; align-items: center; justify-content: space-between;
padding: 7px 11px; border-bottom: 1px solid var(--border);
font-size: 11px; text-transform: uppercase; letter-spacing: .6px;
color: var(--muted); }
.dom-hier-close { background: transparent; border: 0; color: var(--muted); font-size: 16px; cursor: pointer; line-height: 1; padding: 0 4px; }
.dom-hier-close:hover { color: var(--accent); }
.dom-hier-list { padding: 5px 4px; max-height: 200px; overflow-y: auto; }
.dom-hier-row { display: flex; align-items: center; gap: 6px; padding: 4px 8px; border-radius: 4px;
cursor: pointer; font-family: ui-monospace, monospace; font-size: 11.5px; white-space: nowrap; overflow: hidden; }
.dom-hier-row:hover { background: var(--bg-3); }
.dom-hier-row.active { background: rgba(var(--axo-jade-rgb),.18); color: var(--accent); }
.dom-hier-row .indent { color: var(--muted-2); flex-shrink: 0; }
.dom-hier-row .label { overflow: hidden; text-overflow: ellipsis; flex: 1; }
.dom-hier-snippet { padding: 7px 11px; border-top: 1px solid var(--border);
font-family: ui-monospace, monospace; font-size: 10.5px;
max-height: 110px; overflow: auto; background: var(--bg-2);
white-space: pre-wrap; word-break: break-all; color: var(--muted); }
.dom-hier-foot { display: flex; justify-content: flex-end; gap: 6px;
padding: 8px 10px; border-top: 1px solid var(--border); }
.cockpit-bar { position: relative; }
.panes-menu { position: fixed; background: var(--panel); border: 1px solid var(--border-strong); border-radius: 8px; padding: 5px; min-width: 220px; z-index: 50; box-shadow: 0 6px 22px rgba(0,0,0,.35); }
.pm-item { display: flex; align-items: center; gap: 9px; padding: 7px 10px; cursor: pointer; border-radius: 5px; font-size: 12.5px; user-select: none; }
.pm-item:hover { background: var(--bg-3); }
.pm-item input[type="checkbox"] { margin: 0; cursor: pointer; }
.pm-item .pm-shortcut { margin-left: auto; font-size: 10.5px; color: var(--muted-2); }
.pm-divider { border-top: 1px solid var(--border); margin: 4px 0; }
.pm-action { color: var(--muted); }
.pm-action:hover { color: var(--text); }
.ftab { display: flex; align-items: center; gap: 5px; padding: 5px 9px; font-size: 11.5px; border-right: 1px solid var(--border); cursor: pointer; white-space: nowrap; max-width: 200px; }
.ftab:hover { background: var(--bg-3); }
.ftab.active { background: var(--panel-2); border-bottom: 2px solid var(--accent); }
.ftab-name { overflow: hidden; text-overflow: ellipsis; }
.ftab-close { opacity: .45; padding: 0 2px; font-size: 13px; }
.ftab-close:hover { opacity: 1; color: var(--err); }
.grow { flex: 1; }
.files-pane-inner { flex: 1; display: flex; flex-direction: row; min-height: 0; min-width: 0; overflow: hidden; }
.files-explorer { width: var(--explorer-w, 140px); min-width: 100px; max-width: 600px; display: flex; flex-direction: column; border-right: 1px solid var(--border); min-height: 0; overflow: hidden; flex-shrink: 1; flex-basis: var(--explorer-w, 140px); }
.files-editor { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; }
.files-inner-resizer { width: 4px; cursor: col-resize; background: transparent; flex-shrink: 0; }
.files-inner-resizer:hover, .files-inner-resizer.dragging { background: var(--accent); }
.file-search { padding: 6px 8px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.file-search-input { flex: 1; min-width: 0; background: var(--bg-3); border: 1px solid var(--border); border-radius: 4px; padding: 4px 8px; color: var(--text); font-size: 12px; outline: none; font-family: inherit; }
.file-search-input:focus { border-color: var(--accent); }
.file-search-clear { color: var(--muted); cursor: pointer; padding: 0 2px; font-size: 16px; line-height: 1; user-select: none; }
.file-search-clear:hover { color: var(--text); }
.file-search-clear.hide { display: none; }
.file-tree { flex: 1; overflow: auto; padding: 5px 4px; font-size: 12.5px; }
.file-tree.no-matches { padding-top: 14px; }
.file-tree-empty { color: var(--muted); font-size: 11.5px; text-align: center; padding: 10px; }
.file-viewer { flex: 1; overflow: auto; padding: 0; }
.file-viewer pre { margin: 0; padding: 10px 12px; font-size: 12px; }
.ftn { display: flex; align-items: center; gap: 6px; padding: 2px 6px; border-radius: 4px; cursor: pointer; white-space: nowrap; }
.ftn:hover { background: var(--bg-3); }
.ftn.sel { background: rgba(var(--axo-jade-rgb),.15); }
.ftn.hide { display: none; }
.ftn-name { overflow: hidden; text-overflow: ellipsis; }
.ftn-match { color: var(--accent); font-weight: 600; }
.quick-open { position: fixed; inset: 0; z-index: 4000; background: rgba(0,0,0,.45); display: flex; align-items: flex-start; justify-content: center; padding-top: 96px; }
.quick-open-card { background: var(--panel-2); border: 1px solid var(--border-strong); border-radius: 8px; width: min(640px, 92vw); box-shadow: 0 12px 48px rgba(0,0,0,.55); overflow: hidden; display: flex; flex-direction: column; max-height: 60vh; }
.quick-open-input { background: var(--bg-3); border: 0; padding: 10px 14px; color: var(--text); font-size: 13.5px; outline: none; font-family: inherit; border-bottom: 1px solid var(--border); }
.quick-open-list { overflow: auto; max-height: calc(60vh - 50px); }
.quick-open-item { display: flex; align-items: center; gap: 8px; padding: 6px 12px; cursor: pointer; font-size: 12.5px; }
.quick-open-item:hover, .quick-open-item.active { background: rgba(var(--axo-jade-rgb),.18); }
.quick-open-name { color: var(--text); }
.quick-open-path { color: var(--muted); font-size: 11.5px; margin-left: auto; overflow: hidden; text-overflow: ellipsis; max-width: 60%; white-space: nowrap; direction: rtl; text-align: left; }
.quick-open-match { color: var(--accent); font-weight: 600; }
.quick-open-empty { padding: 14px; color: var(--muted); text-align: center; font-size: 12px; }
.ftn-chev { width: 12px; text-align: center; opacity: .65; font-size: 11px; flex-shrink: 0; transition: transform .12s ease; }
.ftn-chev.open { transform: rotate(90deg); }
.ftn-ico { width: 16px; height: 16px; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; }
.ftn-ico.codicon { font-size: 14px; line-height: 16px; }
.ftn-ico.tint-jade { color: var(--axo-jade); }
.ftn-ico.tint-bronze { color: var(--axo-bronze); }
.ftn-ico.tint-blue { color: #6cc1d9; }
.ftn-ico.tint-mute { color: var(--muted); }
.ftn-kids { margin-left: 18px; }
.ftn-kids.hide { display: none; }
.session-active { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 12px; border-bottom: 1px solid var(--border); flex-shrink: 0; min-height: 36px; align-items: center; }
.session-active:empty { display: none; }
.session-active .sa-chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; background: rgba(var(--axo-jade-rgb), .10); border: 1px solid rgba(var(--axo-jade-rgb), .28); border-radius: 999px; font-size: 11.5px; color: var(--fg-soft, var(--text)); font-family: 'JetBrains Mono', ui-monospace, monospace; }
.session-active .sa-chip.skill { background: rgba(var(--axo-blue-rgb), .08); border-color: rgba(var(--axo-blue-rgb), .30); }
.session-active .sa-chip .sa-kind { color: var(--muted); text-transform: uppercase; letter-spacing: .6px; font-size: 9.5px; }
.session-active .sa-chip .sa-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--axo-jade-glow); }
.session-active .sa-eyebrow { font-family: 'Space Grotesk', sans-serif; font-size: 10.5px; text-transform: uppercase; letter-spacing: .8px; color: var(--muted-2); margin-right: 4px; font-weight: 600; }
.session-msgs { flex: 1; overflow-y: auto; padding: 14px 14px 12px; display: flex; flex-direction: column; gap: 12px; }
.session-input { display: flex; flex-direction: column; gap: 6px; padding: 10px; border-top: 1px solid var(--border); flex-shrink: 0; background: linear-gradient(180deg, rgba(255,255,255,0), rgba(255,255,255,.012)); }
.session-input-bar { display: flex; align-items: center; gap: 8px; }
.session-input-row { display: flex; gap: 8px; }
.session-input textarea { flex: 1; min-height: 42px; max-height: 140px; }
.session-model-picker, .session-agent-target {
background: var(--bg-3); border: 1px solid var(--border); border-radius: 5px;
color: var(--fg-soft, var(--text)); font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 11px; padding: 3px 8px; outline: none; cursor: pointer;
max-width: 50%;
}
.session-model-picker:focus, .session-agent-target:focus { border-color: var(--accent); }
.session-model-picker.hide, .session-agent-target.hide { display: none; }
.smsg { font-size: 13px; line-height: 1.55; }
.smsg-role { font-size: 10px; text-transform: uppercase; letter-spacing: .8px; color: var(--muted); margin-bottom: 4px; font-family: 'JetBrains Mono', ui-monospace, monospace; }
.smsg.agent .smsg-role { color: var(--accent-lift); }
.smsg.user .smsg-body { background: rgba(var(--axo-jade-rgb),.10); border: 1px solid rgba(var(--axo-jade-rgb),.22); border-radius: 8px; padding: 8px 11px; color: var(--fg); }
.smsg.agent .smsg-body { color: var(--fg-soft); padding: 0 1px; }
.smsg .smsg-body code { background: rgba(255,255,255,.04); border: 1px solid var(--border); padding: 1px 5px; border-radius: 4px; font-size: 11.5px; font-family: 'JetBrains Mono', ui-monospace, monospace; color: var(--accent-2); }
.smsg-typing { display: inline-flex; gap: 4px; padding: 4px 8px; align-items: center; }
.smsg-typing span { width: 5px; height: 5px; border-radius: 50%; background: var(--muted-2); animation: typing-dot 1.2s ease-in-out infinite; }
.smsg-typing span:nth-child(2) { animation-delay: .15s; }
.smsg-typing span:nth-child(3) { animation-delay: .30s; }
@keyframes typing-dot { 0%, 80%, 100% { opacity: .25; transform: scale(.85);} 40% { opacity: 1; transform: scale(1);} }
.toolcard { border: 1px solid var(--border); border-radius: 7px; overflow: hidden; background: var(--bg); }
.toolcard-head { display: flex; align-items: center; gap: 7px; padding: 6px 9px; cursor: pointer; font-size: 12px; }
.toolcard-head:hover { background: var(--bg-3); }
.toolcard-ico { font-size: 11px; }
.toolcard.running .toolcard-ico { animation: pulse 1.1s infinite; }
.toolcard.ok .toolcard-ico { color: var(--ok); }
.toolcard.err { border-color: var(--err); }
.toolcard.err .toolcard-ico { color: var(--err); }
.toolcard-verb { flex: 1; }
.toolcard-body { border-top: 1px solid var(--border); padding: 8px 10px; font-size: 11.5px; max-height: 280px; overflow: auto; }
.toolcard-body pre { margin: 0; white-space: pre-wrap; }
.diff-add { background: rgba(74,222,128,.13); color: var(--ok); }
.diff-del { background: rgba(255,107,107,.13); color: var(--err); }
.chat-grid {
display: grid;
grid-template-columns: 280px 1fr;
gap: 0;
flex: 1;
min-height: 0;
height: 100%;
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
background: var(--panel);
}
.chat-grid.artifacts-open {
grid-template-columns: 280px 1fr minmax(360px, 1.1fr);
}
.chat-artifact {
background: var(--panel);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.chat-artifact.hide { display: none; }
.chat-artifact-head {
padding: 10px 14px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
}
.chat-artifact-title { flex: 1; font-weight: 600; color: var(--text); }
.chat-artifact-body { flex: 1; overflow: auto; padding: 14px; }
.chat-artifact-body iframe {
width: 100%;
height: 100%;
border: 0;
background: #fff;
border-radius: 6px;
}
.chat-artifact-body .prose { font-size: 14px; }
.chat-msgs pre { position: relative; padding-top: 28px !important; }
.chat-msgs pre .code-toolbar {
position: absolute;
top: 5px;
right: 5px;
display: flex;
gap: 4px;
}
.code-toolbar button {
background: var(--bg-3);
border: 1px solid var(--border);
color: var(--muted);
padding: 3px 9px;
border-radius: 5px;
cursor: pointer;
font-size: 11px;
font-family: inherit;
line-height: 1.4;
transition: color .12s, border-color .12s, background .12s;
}
.code-toolbar button:hover {
color: var(--text);
border-color: var(--axo-jade-glow);
background: var(--panel);
}
.code-toolbar button.artifact {
color: var(--axo-jade-glow);
border-color: rgba(var(--axo-jade-glow-rgb), .4);
background: rgba(var(--axo-jade-glow-rgb), .08);
}
.code-toolbar button.artifact:hover {
background: rgba(var(--axo-jade-glow-rgb), .15);
border-color: var(--axo-jade-glow);
}
.chat-artifact-empty {
text-align: center;
color: var(--muted);
font-size: 12.5px;
line-height: 1.55;
padding: 24px 18px;
max-width: 320px;
margin: auto;
}
.chat-side {
background: var(--bg-2);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.chat-side-head {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
display: flex;
gap: 8px;
align-items: center;
}
.chat-search {
flex: 1;
background: var(--bg-3);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 10px;
font-size: 12.5px;
color: var(--text);
}
.chat-search:focus { outline: none; border-color: var(--axo-jade-glow); }
.chat-side-list { flex: 1; overflow-y: auto; padding: 6px; }
.chat-side-group {
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: .06em;
color: var(--muted-2);
padding: 8px 8px 4px;
}
.chat-side-row {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 10px;
border-radius: 7px;
cursor: pointer;
transition: background .15s ease;
border: 1px solid transparent;
}
.chat-side-row:hover { background: var(--bg-3); }
.chat-side-row.active {
background: var(--panel);
border-color: var(--border-strong);
}
.chat-side-row .chat-side-name {
font-size: 13px;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
gap: 6px;
}
.chat-side-row .chat-side-meta {
font-size: 11px;
color: var(--muted-2);
font-family: ui-monospace, monospace;
}
.chat-side-row .chat-side-star {
color: var(--axo-bronze-glow);
flex-shrink: 0;
font-size: 11px;
}
.chat-side-empty {
padding: 24px 12px;
text-align: center;
color: var(--muted);
font-size: 12.5px;
}
.chat-main {
background: var(--panel);
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.chat-main-head {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.chat-main-head .chat-title {
font-size: 14px;
font-weight: 600;
color: var(--text);
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-main-head .chat-agent {
font-size: 11.5px;
color: var(--muted);
font-family: ui-monospace, monospace;
}
.chat-msgs {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.chat-msgs .smsg.user .smsg-body {
background: var(--bg-3);
border-radius: 8px;
padding: 10px 14px;
}
.chat-msgs .smsg {
max-width: 100%;
}
.chat-msgs-empty {
margin: auto;
text-align: center;
color: var(--muted);
font-size: 13px;
max-width: 320px;
line-height: 1.6;
}
.chat-compose {
border-top: 1px solid var(--border);
padding: 12px;
display: flex;
gap: 8px;
flex-shrink: 0;
}
.chat-compose textarea {
flex: 1;
min-height: 44px;
max-height: 200px;
resize: vertical;
}
.chat-attach-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 12px 0;
border-top: 1px solid var(--border);
}
.chat-attach-chips:empty {
display: none;
}
.chat-attach-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--border-strong);
background: var(--bg-3);
font-size: 12px;
color: var(--text);
max-width: 220px;
}
.chat-attach-chip-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-attach-chip-size {
color: var(--muted-2);
font-family: ui-monospace, monospace;
font-size: 10.5px;
}
.chat-attach-chip-rm,
.chat-attach-chip-pin {
background: transparent;
border: 0;
color: var(--muted-2);
cursor: pointer;
padding: 0 2px;
font-size: 13px;
line-height: 1;
}
.chat-attach-chip-rm:hover { color: var(--err); }
.chat-attach-chip-pin:hover { color: var(--axo-jade-glow); }
.chat-attach-chip.pinned {
border-color: var(--axo-jade-glow);
background: rgba(var(--axo-jade-glow-rgb), .12);
}
.chat-attach-chip.broken {
border-color: var(--err);
color: var(--err);
}
.chat-main { position: relative; }
.chat-drop {
position: absolute;
inset: 0;
z-index: 5;
background: rgba(var(--axo-jade-glow-rgb), .14);
border: 2px dashed var(--axo-jade-glow);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
backdrop-filter: blur(2px);
}
.chat-drop.hide { display: none; }
.chat-drop-inner {
background: var(--panel);
border: 1px solid var(--axo-jade-glow);
color: var(--text);
padding: 14px 22px;
border-radius: 12px;
font-size: 14px;
font-weight: 600;
box-shadow: var(--shadow-lg);
}
.files-grid {
display: grid;
grid-template-columns: 320px 1fr;
gap: 0;
flex: 1;
min-height: 0;
height: 100%;
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
background: var(--panel);
}
.files-side {
background: var(--bg-2);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.files-side-head {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
display: flex;
gap: 8px;
align-items: center;
}
.files-list {
flex: 1;
overflow-y: auto;
padding: 6px;
}
.files-row {
display: flex;
gap: 10px;
padding: 8px 10px;
border-radius: 7px;
cursor: pointer;
border: 1px solid transparent;
align-items: center;
}
.files-row:hover { background: var(--bg-3); }
.files-row.active {
background: var(--panel);
border-color: var(--border-strong);
}
.files-row-icon {
width: 36px;
height: 36px;
border-radius: 6px;
background: var(--bg-3);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
overflow: hidden;
}
.files-row-icon img {
width: 100%; height: 100%; object-fit: cover;
}
.files-row-body {
flex: 1;
min-width: 0;
}
.files-row-name {
font-size: 13px;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.files-row-meta {
font-size: 11px;
color: var(--muted-2);
font-family: ui-monospace, monospace;
}
.files-main {
background: var(--panel);
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
position: relative;
}
.files-main-head {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.files-main-head .grow { flex: 1; }
.files-preview {
flex: 1;
overflow: auto;
padding: 16px;
}
.files-preview-image {
max-width: 100%;
max-height: 100%;
display: block;
margin: auto;
}
.files-preview-text {
font-family: ui-monospace, monospace;
font-size: 12.5px;
line-height: 1.5;
white-space: pre-wrap;
word-wrap: break-word;
color: var(--text);
}
.files-preview-icon {
text-align: center;
color: var(--muted);
padding: 40px 18px;
}
.files-preview-icon .big {
font-size: 64px;
display: block;
margin-bottom: 12px;
}
.files-preview-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
}
.files-preview-tag {
background: var(--bg-3);
border: 1px solid var(--border);
border-radius: 999px;
padding: 2px 10px;
font-size: 11px;
color: var(--muted);
}
.smsg-actions {
display: flex;
gap: 6px;
margin-top: 6px;
opacity: .55;
transition: opacity .14s ease;
}
.smsg:hover .smsg-actions { opacity: 1; }
.smsg-act {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
padding: 3px 9px;
border-radius: 5px;
cursor: pointer;
font-size: 11px;
font-family: inherit;
transition: color .12s, border-color .12s, background .12s;
}
.smsg-act:hover {
color: var(--text);
border-color: var(--axo-jade-glow);
background: var(--bg-3);
}
.smsg-tokens {
font-size: 10.5px;
color: var(--muted-2);
margin-top: 4px;
font-family: ui-monospace, monospace;
letter-spacing: .02em;
}
details.chat-reasoning {
border: 1px dashed var(--border);
border-radius: 6px;
padding: 4px 8px;
margin-bottom: 6px;
background: var(--bg-2);
}
details.chat-reasoning > summary {
cursor: pointer;
font-size: 11px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: .06em;
}
details.chat-reasoning[open] > summary { color: var(--text); }
.chat-reasoning-body {
margin: 6px 0 2px;
font-size: 11.5px;
color: var(--muted);
white-space: pre-wrap;
font-family: ui-monospace, monospace;
line-height: 1.45;
}
.chat-main-head .btn.ghost.danger { color: var(--err); border-color: transparent; }
.chat-main-head .btn.ghost.danger:hover {
background: rgba(255,107,107,.13);
border-color: var(--err);
}
.chat-main-head .btn.ghost { padding: 4px 8px; }
.chat-citations {
margin-top: 6px;
padding-top: 6px;
border-top: 1px dashed var(--border);
}
.chat-citations-head {
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: .06em;
color: var(--muted);
margin-bottom: 4px;
}
.chat-citation {
display: flex;
gap: 6px;
font-size: 12px;
padding: 2px 0;
align-items: baseline;
}
.chat-citation-num {
color: var(--axo-bronze-glow);
font-family: ui-monospace, monospace;
flex-shrink: 0;
min-width: 22px;
}
.chat-citation a {
color: var(--axo-blue-glow);
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.chat-citation a:hover { text-decoration: underline; }
.lattice-host { flex: 1; min-height: 0; position: relative; }
.lattice-host ax-lattice { width: 100%; height: 100%; }
.modal-backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,.55);
display: flex; align-items: center; justify-content: center;
z-index: 800;
animation: axo-modal-fade .14s ease-out;
}
@keyframes axo-modal-fade { from { opacity: 0; } to { opacity: 1; } }
.modal {
width: 540px; max-width: 92vw;
background: var(--panel);
border: 1px solid var(--border-strong);
border-radius: 12px;
overflow: hidden;
box-shadow: var(--shadow-lg);
animation: axo-modal-rise .18s ease-out;
}
@keyframes axo-modal-rise {
from { transform: translateY(8px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.modal-head { padding: 14px 18px; font-weight: 600; font-size: 14px; border-bottom: 1px solid var(--border); }
.axo-modal-body { padding: 16px 18px; display: flex; flex-direction: column; gap: 12px; }
.axo-modal-text { font-size: 13.5px; color: var(--text); line-height: 1.55; }
.axo-modal-field { display: flex; flex-direction: column; gap: 6px; }
.axo-modal-label {
font-size: 11px; text-transform: uppercase; letter-spacing: .06em;
color: var(--muted-2);
}
.axo-modal-help { font-size: 11.5px; color: var(--muted-2); }
.axo-modal-foot {
display: flex; gap: 8px;
padding: 12px 18px;
border-top: 1px solid var(--border);
}
.axo-modal-foot > :first-child { flex: 1; }
.axo-modal-file {
padding: 8px;
background: var(--bg-3);
border: 1px dashed var(--border-strong);
border-radius: 8px;
width: 100%;
color: var(--text);
font: inherit;
}
.axo-modal-choices {
display: flex; flex-direction: column; gap: 4px;
max-height: 320px; overflow-y: auto;
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 4px;
}
.axo-modal-choice {
display: flex; align-items: center; gap: 8px;
padding: 10px 12px;
border-radius: 7px;
cursor: pointer;
transition: background .12s ease;
}
.axo-modal-choice:hover { background: var(--bg-3); }
.axo-modal-choice.active {
background: rgba(var(--axo-jade-glow-rgb), .15);
box-shadow: 0 0 0 1px var(--axo-jade-glow) inset;
}
.axo-modal-choice-main { flex: 1; min-width: 0; }
.axo-modal-choice-label { font-size: 13px; color: var(--text); }
.axo-modal-choice-sub { font-size: 11.5px; color: var(--muted-2); font-family: ui-monospace, monospace; }
.fp-breadcrumb { padding: 8px 16px; color: var(--muted); border-bottom: 1px solid var(--border); }
.fp-list { max-height: 320px; overflow: auto; padding: 6px; }
.fp-row { display: flex; align-items: center; gap: 7px; padding: 5px 9px; border-radius: 5px; cursor: pointer; font-size: 13px; }
.fp-row:hover { background: var(--bg-3); }
.modal-foot { display: flex; align-items: center; gap: 9px; padding: 11px 16px; border-top: 1px solid var(--border); }
.fp-skills { padding: 8px 16px; border-top: 1px solid var(--border); max-height: 96px; overflow: auto; }
body.fp-favorite-mode .fp-config-block { display: none; }
body.fp-favorite-mode .fp-config-only { display: none; }
.fp-skills label { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; margin: 3px 10px 3px 0; }
.hljs { background: transparent !important; }
#tab-sessions {
--c-cockpit-bg: var(--bg-2);
--c-pane-bg: var(--panel);
--c-head-bg: var(--panel-2);
--c-border: var(--border);
--c-fg: var(--text);
--c-fg-soft: var(--fg-soft, var(--text));
--c-muted: var(--muted);
--c-muted-2: var(--muted-2);
--c-ink: var(--bg);
}
#tab-sessions > #session-cockpit {
background: var(--c-cockpit-bg);
border-radius: 0;
border: 0;
}
#session-cockpit .cockpit-bar {
display: flex;
align-items: center;
gap: 12px;
height: 40px;
padding: 0 14px;
background: var(--c-head-bg);
border-bottom: 1px solid var(--c-border);
font-size: 12.5px;
color: var(--c-muted);
}
#session-cockpit .cockpit-bar #cockpit-back {
background: transparent;
border: 0;
padding: 0;
height: auto;
line-height: 1;
font-family: 'Space Grotesk', sans-serif;
font-size: 12.5px;
color: var(--c-fg-soft);
cursor: pointer;
border-radius: 0;
}
#session-cockpit .cockpit-bar #cockpit-back:hover { color: var(--c-fg); }
#session-cockpit .cockpit-bar #cockpit-title {
color: var(--c-fg);
font-family: 'Space Grotesk', sans-serif;
font-weight: 600;
font-size: 13px;
letter-spacing: -.005em;
}
#session-cockpit .cockpit-bar #cockpit-dir,
#session-cockpit .cockpit-bar #cockpit-status {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 11.5px;
color: var(--c-muted);
letter-spacing: 0;
}
#session-cockpit .cockpit-bar #cockpit-status:not(:empty)::before {
content: '·';
margin-right: 6px;
color: var(--c-muted-2);
}
#session-cockpit .cockpit-bar .cockpit-live-pill {
margin-left: auto;
height: 22px;
padding: 0 10px 0 8px;
background: transparent;
border: 0;
color: var(--c-fg-soft);
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 11.5px;
text-transform: lowercase;
letter-spacing: 0;
font-weight: 400;
}
#session-cockpit .cockpit-bar .cockpit-live-pill::before {
width: 7px; height: 7px;
background: var(--axo-jade-glow);
box-shadow: 0 0 6px rgba(var(--axo-jade-glow-rgb), .65);
margin-right: 2px;
}
#session-cockpit .cockpit-bar .cockpit-live-pill.idle::before {
background: var(--c-muted-2); box-shadow: none;
}
#session-cockpit .cockpit-bar #panes-menu-btn {
background: transparent;
border: 0;
color: var(--c-muted);
font-size: 11.5px;
padding: 2px 6px;
border-radius: 4px;
cursor: pointer;
}
#session-cockpit .cockpit-bar #panes-menu-btn:hover { color: var(--c-fg); background: rgba(255,255,255,.04); }
#session-cockpit .cockpit-grid { gap: 0; background: var(--c-cockpit-bg); }
#session-cockpit .cockpit-pane {
background: var(--c-pane-bg);
border: 0;
border-radius: 0;
border-right: 1px solid var(--c-border);
}
#session-cockpit .cockpit-pane.is-rightmost { border-right: 0; }
#session-cockpit #cockpit-terminals { height: auto; }
#session-cockpit .pane-head {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
padding: 9px 12px;
background: var(--c-head-bg);
border-bottom: 1px solid var(--c-border);
font-family: 'Space Grotesk', sans-serif;
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: .8px;
color: var(--c-muted-2);
font-weight: 600;
}
#session-cockpit .pane-head-title { color: var(--c-muted-2); }
#session-cockpit .pane-head-meta {
margin-left: auto;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 10.5px;
color: var(--c-muted-2);
text-transform: none;
letter-spacing: 0;
font-weight: 400;
}
#session-cockpit .pane-btn {
background: transparent;
border: 0;
color: var(--c-muted-2);
padding: 2px 5px;
border-radius: 3px;
font-size: 11px;
line-height: 1;
cursor: pointer;
}
#session-cockpit .pane-btn:hover { color: var(--c-fg-soft); background: rgba(255,255,255,.04); }
#session-cockpit .pane-btn.active { color: var(--axo-jade-glow); background: rgba(var(--axo-jade-rgb), .12); }
#session-cockpit .files-pane-inner { background: var(--c-pane-bg); }
#session-cockpit .files-explorer { border-right: 1px solid var(--c-border); }
#session-cockpit .file-search { border-bottom: 1px solid var(--c-border); }
#session-cockpit .file-tree { font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 12px; }
#session-cockpit .file-tabs { background: var(--c-cockpit-bg); border-bottom: 1px solid var(--c-border); }
#session-cockpit .ftab { font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 11.5px; color: var(--c-muted-2); background: transparent; }
#session-cockpit .ftab.active { color: var(--c-fg); background: var(--c-pane-bg); border-bottom: 2px solid var(--axo-jade); }
#session-cockpit .file-viewer { background: var(--c-ink); }
#session-cockpit .session-msgs {
padding: 12px 14px;
gap: 10px;
background: var(--c-pane-bg);
}
#session-cockpit .session-input {
background: var(--c-pane-bg);
border-top: 1px solid var(--c-border);
}
#session-cockpit .smsg { font-size: 12.5px; line-height: 1.55; }
#session-cockpit .smsg-role {
font-family: 'Space Grotesk', sans-serif;
font-size: 11px;
font-weight: 600;
letter-spacing: .04em;
text-transform: uppercase;
color: var(--c-muted-2);
margin-bottom: 4px;
}
#session-cockpit .smsg.user .smsg-role,
#session-cockpit .smsg.agent .smsg-role { color: var(--c-muted-2); }
#session-cockpit .smsg.user .smsg-body,
#session-cockpit .smsg.agent .smsg-body {
background: transparent;
border: 0;
padding: 0;
color: var(--c-fg-soft);
}
#session-cockpit .smsg .smsg-body code {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 11.5px;
background: var(--c-border);
color: var(--c-fg);
padding: 1px 5px;
border-radius: 3px;
border: 0;
}
#session-cockpit .tool-chip {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: var(--c-cockpit-bg);
border: 1px solid var(--c-border);
border-radius: 6px;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 11.5px;
color: var(--c-fg-soft);
margin: 0;
}
#session-cockpit .tool-chip .tc-head { display: contents; }
#session-cockpit .tool-chip .tc-head::before { content: none; }
#session-cockpit .tool-chip::before {
content: '✓';
color: var(--axo-jade-glow);
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 11px;
flex-shrink: 0;
}
#session-cockpit .tool-chip.running::before {
content: '';
width: 9px; height: 9px;
border-radius: 50%;
border: 1.5px solid #2a2f3a;
border-top-color: var(--axo-jade-glow);
animation: tc-spin .85s linear infinite;
}
#session-cockpit .tool-chip.err::before { content: '×'; color: #e88; }
#session-cockpit .tool-chip .tc-name { color: var(--axo-jade-glow); font-weight: 500; }
#session-cockpit .tool-chip .tc-args { color: var(--c-fg-soft); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#session-cockpit .tool-chip .tc-meta { color: var(--c-muted-2); font-size: 10.5px; white-space: nowrap; }
#session-cockpit .tool-chip .tc-toggle { color: var(--c-muted-2); }
#session-cockpit .tool-chip .tc-result { background: var(--c-ink); border-radius: 4px; padding: 7px; margin-top: 7px; font-size: 11px; color: var(--c-muted); }
#session-cockpit .smsg-typing,
#session-cockpit .sessions-typing {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
background: var(--c-cockpit-bg);
border: 1px solid var(--c-border);
border-radius: 999px;
width: max-content;
}
#session-cockpit .smsg-typing span,
#session-cockpit .sessions-typing-dot {
width: 5px; height: 5px; border-radius: 50%;
background: var(--c-muted-2);
animation: type-bounce 1.2s ease-in-out infinite;
}
#session-cockpit .smsg-typing span:nth-child(2),
#session-cockpit .sessions-typing-dot:nth-child(2) { animation-delay: .15s; }
#session-cockpit .smsg-typing span:nth-child(3),
#session-cockpit .sessions-typing-dot:nth-child(3) { animation-delay: .30s; }
@keyframes type-bounce {
0%,80%,100% { transform: translateY(0); background: var(--c-muted-2); }
40% { transform: translateY(-3px); background: var(--axo-jade-glow); }
}
#session-cockpit .lattice-host { background: var(--c-cockpit-bg); }
#session-cockpit .browser-toolbar { background: var(--c-head-bg); border-bottom: 1px solid var(--c-border); }
#session-cockpit .bx-url { background: var(--c-cockpit-bg); border: 1px solid var(--c-border); color: var(--c-fg-soft); font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 11.5px; }
#session-cockpit .browser-body { background: var(--c-pane-bg); }
#session-cockpit .terminals-list { background: var(--c-pane-bg); border-right: 1px solid var(--c-border); }
#session-cockpit .terminals-output { background: var(--c-ink); }
#session-cockpit .term-row { font-family: 'JetBrains Mono', ui-monospace, monospace; }
#session-cockpit .term-status-bar { background: var(--c-pane-bg); border-bottom: 1px solid var(--c-border); color: var(--c-muted); }
#session-cockpit .term-pre {
background: var(--c-ink);
color: var(--c-fg-soft);
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 11.5px;
line-height: 1.65;
}
#session-cockpit .term-pre .term-dim { color: var(--c-muted-2); }
#session-cockpit .term-pre .term-jade { color: var(--axo-jade-glow); }
#session-cockpit .term-pre .term-cursor {
background: var(--axo-jade-glow);
color: var(--axo-jade-glow);
animation: term-blink 1.1s steps(2) infinite;
}
@keyframes term-blink { 50% { opacity: 0; } }
#session-cockpit .col-resizer:hover,
#session-cockpit .col-resizer.dragging {
background: var(--axo-jade);
opacity: .55;
}
#session-cockpit .pane-head[draggable="true"] { cursor: grab; user-select: none; }
#session-cockpit .pane-head[draggable="true"]:active { cursor: grabbing; }
#session-cockpit .pane-head.drag-source { opacity: .55; }
#session-cockpit .pane-head.drag-over {
background: rgba(var(--axo-jade-rgb), .14);
box-shadow: inset 0 -2px 0 var(--axo-jade-glow);
}
</style>
<link rel="stylesheet" href="/vendor/highlight.css">
<link rel="stylesheet" href="/vendor/codicons/codicon.css">
<link rel="stylesheet" href="/vendor/xterm.css">
<script src="/vendor/highlight.min.js"></script>
<script src="/vendor/xterm.js"></script>
<script src="/vendor/xterm-addon-fit.js"></script>
</head>
<body>
<div class="app">
<div class="strip">
<div class="strip-mark" id="strip-mark" title="Toggle labels"><span class="strip-icon"><img src="/brand/mark.png" alt="Axocoatl" /></span><span class="strip-mark-label">Axocoatl</span></div>
<div class="strip-item" data-tab="chat" title="Chat"><span class="strip-icon">✦</span><span class="strip-label">Chat</span></div>
<div class="strip-item" data-tab="files" title="Files"><span class="strip-icon">▤</span><span class="strip-label">Files</span></div>
<div class="strip-item active" data-tab="sessions" title="Sessions"><span class="strip-icon">▣</span><span class="strip-label">Sessions</span></div>
<div class="strip-item" data-tab="automations" title="Automations"><span class="strip-icon">⟳</span><span class="strip-label">Automations</span></div>
<div class="strip-item" data-tab="studio" title="Studio"><span class="strip-icon">◉</span><span class="strip-label">Studio</span></div>
<div class="strip-item" data-tab="agents" title="Agents"><span class="strip-icon">⌬</span><span class="strip-label">Agents</span></div>
<div class="strip-item" data-tab="skills" title="Skills"><span class="strip-icon">◈</span><span class="strip-label">Skills</span></div>
<div class="strip-item" data-tab="mcp" title="MCP servers"><span class="strip-icon">◇</span><span class="strip-label">MCP servers</span></div>
<div class="strip-spacer"></div>
<div class="strip-foot">
<a class="strip-item" href="https://docs.axocoatl.ai" target="_blank" rel="noopener" title="Documentation"><span class="strip-icon">◫</span><span class="strip-label">Docs</span></a>
<div class="status-pearls" id="status-pearls"></div>
<div class="strip-theme" role="group" aria-label="Theme">
<button class="theme-seg" data-theme-pref="light" title="Light" aria-label="Light">☀</button>
<button class="theme-seg" data-theme-pref="dark" title="Dark" aria-label="Dark">☾</button>
<button class="theme-seg" data-theme-pref="system" title="System" aria-label="System">⊙</button>
</div>
</div>
</div>
<div class="top" hidden>
<div id="status-pill" class="status-pill"><span class="status-dot"></span><span id="status-text">connecting…</span></div>
<div class="top-stats" id="top-stats"></div>
<button class="interrupts-pill hide" id="interrupts-pill" title="Pending human-in-the-loop interrupts">
⏸ <span id="interrupts-count">0</span> waiting
</button>
<span id="version" hidden>v0.1.0</span>
<span id="conn-dot" class="conn-dot off" hidden></span>
</div>
<div class="interrupts-pop hide" id="interrupts-pop">
<div class="interrupts-head">
<span>Pending interrupts</span>
<div class="head-actions">
<button class="head-btn" id="interrupts-fullscreen" title="Toggle fullscreen" aria-label="Toggle fullscreen">⛶</button>
<button class="head-btn" id="interrupts-close" title="Close" aria-label="Close">×</button>
</div>
</div>
<div class="interrupts-list" id="interrupts-list">
</div>
<div class="interrupts-foot small muted">
Resume value flows into the node's output per its <span class="mono">resume_strategy</span>.
</div>
</div>
<span hidden id="cnt-chats">—</span><span hidden id="cnt-files">—</span>
<span hidden id="cnt-sessions">—</span><span hidden id="cnt-automations">—</span>
<span hidden id="cnt-agents">—</span><span hidden id="cnt-skills">—</span>
<div class="main" id="main">
<section class="tab hide" id="tab-automations">
<div class="auto-explorer">
<aside class="auto-tree" id="auto-tree">
<div class="auto-tree-head">
<span>Folders</span>
<button class="btn ghost sm" id="auto-new-folder" title="New folder">+ Folder</button>
</div>
<div class="auto-tree-list" id="auto-tree-list">
</div>
</aside>
<section class="auto-main">
<div class="auto-main-head">
<nav class="auto-crumbs" id="auto-crumbs"></nav>
<div class="grow"></div>
<div class="auto-filter" id="auto-filter">
<button class="auto-pill active" data-auto-filter="all">All</button>
<button class="auto-pill" data-auto-filter="manual">▶ Manual</button>
<button class="auto-pill" data-auto-filter="schedule">⏱ Scheduled</button>
<button class="auto-pill" data-auto-filter="event">⊛ Event</button>
</div>
</div>
<div class="auto-cards" id="auto-cards">
</div>
</section>
</div>
<div class="modal-backdrop hide" id="run-input-modal">
<div class="modal" style="max-width:560px;">
<div class="modal-head" id="run-input-head">Run automation</div>
<div class="run-input-form" id="run-input-form">
</div>
<div class="modal-foot">
<span class="grow"></span>
<button class="btn ghost sm" id="run-input-cancel">Cancel</button>
<button class="btn" id="run-input-go">▶ Run</button>
</div>
</div>
</div>
<div class="add-node-pop hide" id="add-node-pop">
<div class="add-node-head">
<span class="add-node-title">Add a node</span>
<button class="ctx-item" id="add-node-close" style="margin-left:auto;padding:2px 8px;">×</button>
</div>
<div class="add-node-tabs">
<button class="add-node-tab active" data-add-tab="agent">
<span class="add-node-tab-ico">🧠</span><span>Agents</span>
</button>
<button class="add-node-tab" data-add-tab="tool">
<span class="add-node-tab-ico">🛠</span><span>Tools</span>
</button>
<button class="add-node-tab" data-add-tab="conditional">
<span class="add-node-tab-ico">⟀</span><span>Router</span>
</button>
<button class="add-node-tab" data-add-tab="flow">
<span class="add-node-tab-ico">⇄</span><span>Flow</span>
</button>
</div>
<input type="text" id="add-node-search" class="input" placeholder="Search…" spellcheck="false">
<div class="add-node-list" id="add-node-list"></div>
<div class="add-node-foot small muted">Click an item to drop it on the canvas. Esc to cancel.</div>
</div>
<div class="modal-backdrop hide" id="trigger-modal">
<div class="modal" style="max-width:520px;">
<div class="modal-head">Configure trigger</div>
<div class="trigger-form">
<div class="trigger-row">
<label class="small muted">Trigger kind</label>
<select id="trigger-kind" class="select">
<option value="manual">▶ Manual — user clicks Run</option>
<option value="schedule">⏱ Schedule — fire on a timer (cron-like)</option>
<option value="on_event">⊛ On event — fire when a lattice event matches</option>
<option value="on_skill">◆ On skill — fire when a skill is published</option>
</select>
</div>
<div class="trigger-row trigger-schedule hide">
<label class="small muted" for="trigger-every">Cadence</label>
<input type="text" id="trigger-every" class="input" placeholder="e.g. 30s · 5m · 2h · 1d">
<span class="small muted" style="font-size:10.5px;">Supports simple intervals; cron syntax coming.</span>
</div>
<div class="trigger-row trigger-event hide">
<label class="small muted" for="trigger-event">Event name</label>
<input type="text" id="trigger-event" class="input" placeholder="e.g. AgentFailed, BugReportFiled">
</div>
<div class="trigger-row trigger-skill hide">
<label class="small muted" for="trigger-skill">Skill</label>
<select id="trigger-skill" class="select"></select>
</div>
<div class="trigger-row trigger-input hide">
<label class="small muted" for="trigger-input">Default input (optional)</label>
<textarea id="trigger-input" class="input" rows="3" placeholder="Sent to nodes whose input source is 'from trigger'."></textarea>
</div>
</div>
<div class="modal-foot">
<span class="grow"></span>
<button class="btn ghost sm" id="trigger-cancel">Cancel</button>
<button class="btn" id="trigger-save">Save</button>
</div>
</div>
</div>
<div class="auto-editor hide" id="auto-editor">
<div class="auto-editor-head">
<button class="btn ghost sm" id="auto-editor-back">← Back</button>
<div class="auto-editor-title">
<h2 id="auto-editor-name" style="margin:0">—</h2>
<span class="small muted" id="auto-editor-source"></span>
</div>
<span class="grow"></span>
<span class="auto-editor-trigger" id="auto-editor-trigger">—</span>
<div class="studio-mode" style="margin: 0 4px;">
<button class="studio-mode-btn active" data-editor-mode="watch">◉ View</button>
<button class="studio-mode-btn" data-editor-mode="edit">✎ Edit</button>
</div>
<button class="btn ghost sm hide" id="auto-editor-add">+ Add node</button>
<button class="btn ghost sm hide" id="auto-editor-trigger-edit">⏱ Trigger…</button>
<button class="btn ghost sm" id="auto-editor-runs">⟲ Runs</button>
<button class="btn sm" id="auto-editor-run">▶ Run</button>
</div>
<div class="auto-editor-canvas-wrap">
<ax-lattice id="auto-editor-lattice" background="dots" snap="20" min-zoom="0.2" max-zoom="3"></ax-lattice>
<ax-controls for="auto-editor-lattice" class="auto-editor-controls"></ax-controls>
<ax-minimap for="auto-editor-lattice" class="auto-editor-minimap"></ax-minimap>
</div>
<aside class="auto-editor-inspector hide" id="auto-editor-inspector"></aside>
<aside class="auto-editor-runs hide" id="auto-editor-runs-panel">
<div class="auto-runs-head">
<span>Run history</span>
<button class="ctx-item" id="auto-runs-close" style="padding:1px 8px;">×</button>
</div>
<div class="auto-runs-list" id="auto-runs-list"></div>
</aside>
<div class="auto-editor-footer small muted">
<span id="auto-editor-status">Edit mode is off · click ✎ Edit to add nodes, wire edges, and configure inputs.</span>
</div>
</div>
</section>
<section class="tab hide" id="tab-studio">
<div class="shell" id="studio-shell">
<aside class="shell-side">
<div class="shell-side-search"><input type="search" id="studio-side-search" placeholder="Filter agents, teams…" spellcheck="false"></div>
<div class="shell-side-scroll">
<div class="shell-side-section" data-section="active">
<div class="shell-side-head"><span class="tri">▾</span><span>Active runs</span></div>
<div class="shell-side-list" id="studio-side-active"></div>
</div>
<div class="shell-side-section" data-section="teams">
<div class="shell-side-head"><span class="tri">▾</span><span>Teams</span></div>
<div class="shell-side-list" id="studio-side-teams"></div>
</div>
<div class="shell-side-section" data-section="agents">
<div class="shell-side-head"><span class="tri">▾</span><span>All agents</span></div>
<div class="shell-side-list" id="studio-side-agents"></div>
</div>
<div class="shell-side-section" data-section="quickfire">
<div class="shell-side-head"><span class="tri">▾</span><span>Quick fire</span></div>
<div class="shell-side-list" id="studio-side-quickfire"></div>
</div>
<div class="shell-side-section" data-section="events">
<div class="shell-side-head">
<span class="tri">▾</span><span>Recent events</span>
<span style="flex:1"></span>
<button class="btn ghost sm" id="studio-clear-feed" title="Clear" style="font-size:10px; padding:1px 6px; margin-right:6px;">Clear</button>
</div>
<div class="shell-side-list" id="studio-feed" style="padding:0 4px;"></div>
</div>
</div>
</aside>
<main class="shell-main">
<div class="shell-toolbar">
<div class="studio-mode">
<button class="studio-mode-btn active" data-studio-mode="watch" title="Read-only · nodes pulse on live events">◉ Watch</button>
<button class="studio-mode-btn" data-studio-mode="edit" title="Drag-to-arrange and drag-dot-to-dot to wire dependencies">✎ Edit</button>
</div>
<span class="grow"></span>
<span class="studio-help-inline small muted" id="studio-help-inline">Click a node to inspect.</span>
<button class="btn ghost sm" id="studio-arrange">Auto-layout</button>
<button class="btn ghost sm" id="studio-fit">Fit</button>
<button class="btn ghost sm" id="studio-reset" title="Reset layout">Reset</button>
</div>
<div class="shell-canvas">
<ax-lattice id="studio-lattice" background="dots" snap="20" min-zoom="0.2" max-zoom="3"></ax-lattice>
<ax-controls for="studio-lattice" class="studio-controls"></ax-controls>
<ax-minimap for="studio-lattice" class="studio-minimap"></ax-minimap>
</div>
<div class="shell-drawer" id="studio-inspector">
<div class="shell-drawer-head">
<h3 id="studio-insp-title">Inspector</h3>
<button class="shell-drawer-close" id="studio-insp-close" title="Close (Esc)">×</button>
</div>
<div class="shell-drawer-body" id="studio-insp-body"></div>
</div>
</main>
</div>
</section>
<section class="tab hide" id="tab-chat">
<div class="chat-grid">
<aside class="chat-side">
<div class="chat-side-head">
<input type="search" class="chat-search" id="chat-search" placeholder="Search chats…">
<button class="btn sm" id="chat-new" title="New chat">+ New</button>
</div>
<div class="chat-side-list" id="chat-list">
<div class="chat-side-empty">Loading…</div>
</div>
</aside>
<section class="chat-main">
<div class="chat-main-head">
<span class="chat-title" id="chat-title">Pick a chat or start a new one</span>
<span class="chat-agent" id="chat-agent-label"></span>
</div>
<div class="chat-msgs" id="chat-msgs">
<div class="chat-msgs-empty">
No chat selected.<br>
Click <b>+ New</b> in the sidebar to start a conversation, or pick one from the list.
</div>
</div>
<div class="chat-drop hide" id="chat-drop">
<div class="chat-drop-inner">📥 Drop files to attach</div>
</div>
<div class="chat-attach-chips" id="chat-attach-chips"></div>
<div class="chat-compose">
<button class="btn ghost sm" id="chat-attach-btn" disabled title="Attach file or image">📎</button>
<textarea class="input" id="chat-text" placeholder="Type a message — Enter to send · Shift+Enter for newline · /help for commands · drop files anywhere" disabled></textarea>
<button class="btn" id="chat-send" disabled>Send</button>
</div>
</section>
<aside class="chat-artifact hide" id="chat-artifact">
<div class="chat-artifact-head">
<span class="chat-artifact-title" id="chat-artifact-title">Artifact</span>
<button class="btn ghost sm" id="chat-artifact-copy" title="Copy contents">Copy</button>
<button class="btn ghost sm" id="chat-artifact-close" title="Close pane">×</button>
</div>
<div class="chat-artifact-body" id="chat-artifact-body"></div>
</aside>
</div>
</section>
<section class="tab hide" id="tab-files">
<div class="files-grid">
<aside class="files-side">
<div class="files-side-head">
<input type="search" class="chat-search" id="files-search" placeholder="Search files…">
<button class="btn sm" id="files-upload">+ Upload</button>
</div>
<div class="files-list" id="files-list">
<div class="chat-side-empty">Loading…</div>
</div>
</aside>
<section class="files-main">
<div class="files-main-head">
<span class="chat-title" id="files-title">Pick a file</span>
<span class="chat-agent" id="files-meta"></span>
<span class="grow"></span>
<button class="btn ghost sm hide" id="files-attach" title="Attach to current chat">+ Attach to chat</button>
<button class="btn ghost sm hide" id="files-rename" title="Rename">✎</button>
<button class="btn ghost sm danger hide" id="files-delete" title="Delete">🗑</button>
</div>
<div class="files-preview" id="files-preview">
<div class="chat-msgs-empty">
No file selected.<br>
Click + Upload, or drop a file onto this pane to add one.
</div>
</div>
</section>
</div>
</section>
<section class="tab hide" id="tab-sessions">
<div id="session-home">
<div class="finder">
<aside class="finder-sidebar" id="finder-sidebar">
<div class="finder-side-head">Favorites</div>
<div class="finder-side-list" id="finder-favorites"></div>
<button class="finder-side-add" id="finder-add-fav" title="Add a folder Axocoatl can work in">+ Add folder</button>
<div class="finder-side-head finder-side-head-2">Recent sessions</div>
<div class="finder-side-list" id="finder-recent"></div>
</aside>
<main class="finder-main">
<div class="finder-toolbar">
<button class="finder-nav-btn" id="finder-back" title="Back" disabled>‹</button>
<button class="finder-nav-btn" id="finder-forward" title="Forward" disabled>›</button>
<button class="finder-nav-btn" id="finder-up" title="Up one level">⤴</button>
<div class="finder-path" id="finder-path"><span class="muted">No folder selected</span></div>
<input class="finder-search" id="finder-search" placeholder="Filter…" spellcheck="false">
<button class="btn sm" id="finder-new-session" title="Start a new session in the current folder">+ New session</button>
</div>
<div class="finder-grid">
<div class="finder-col-head">
<span class="finder-col-name">Name</span>
<span class="finder-col-mode">Mode</span>
<span class="finder-col-agents">Agents</span>
<span class="finder-col-active">Last active</span>
</div>
<div class="finder-rows" id="finder-rows">
<div class="finder-empty">Pick a folder on the left to see its sessions, or click + Add folder to authorize one.</div>
</div>
</div>
</main>
</div>
</div>
<div id="session-cockpit" style="display:none;">
<div class="cockpit-bar">
<button class="btn ghost sm" id="cockpit-back">← Sessions</button>
<strong id="cockpit-title">—</strong>
<span class="mono small muted" id="cockpit-dir"></span>
<span class="grow"></span>
<span class="small muted" id="cockpit-status"></span>
<span class="cockpit-live-pill idle" id="cockpit-live"><span class="cockpit-live-label">idle</span></span>
<button class="btn ghost sm" id="panes-menu-btn" title="Show/hide panes">Panes ▾</button>
</div>
<div id="panes-menu" class="panes-menu hide"></div>
<div class="cockpit-shell" id="cockpit-shell">
<div class="cockpit-grid">
<div class="cockpit-pane" id="cockpit-files">
<div class="pane-head" data-pane="files" draggable="true">
<span class="pane-head-title">Files</span>
<span class="pane-head-meta" id="pane-meta-files"></span>
<button class="pane-btn" data-pane="files" data-act="focus" title="Focus (Ctrl+1)">⛶</button>
<button class="pane-btn" data-pane="files" data-act="collapse" title="Collapse (Ctrl+B)">◀</button>
</div>
<div class="files-pane-inner">
<div class="files-explorer" id="files-explorer">
<div class="file-search">
<i class="codicon codicon-search ftn-ico tint-mute" aria-hidden="true"></i>
<input type="text" class="file-search-input" id="file-search-input"
placeholder="Search files…" spellcheck="false" autocomplete="off" />
<span class="file-search-clear hide" id="file-search-clear" title="Clear">×</span>
</div>
<div class="file-tree" id="file-tree"></div>
</div>
<div class="files-inner-resizer" id="files-inner-resizer" title="Drag to resize"></div>
<div class="files-editor">
<div class="file-tabs" id="file-tabs"></div>
<div class="viewer-modes" id="viewer-modes">
<button class="vmode active" data-mode="edit">Editor</button>
<button class="vmode" data-mode="preview">Preview</button>
<span class="vmode-spacer"></span>
<button class="vmode-action" id="viewer-save" title="Save (Ctrl+S)">Save</button>
<span class="vmode-hint" id="viewer-hint"></span>
</div>
<div class="file-viewer" id="file-viewer"><div class="empty small">Select a file to view</div></div>
</div>
</div>
</div>
<div class="col-resizer" data-which="1"></div>
<div class="cockpit-pane" id="cockpit-stream">
<div class="pane-head" data-pane="stream" draggable="true">
<span class="pane-head-title">Activity</span>
<span class="pane-head-meta" id="pane-meta-stream"></span>
<button class="pane-btn" data-pane="stream" data-act="focus" title="Focus (Ctrl+2)">⛶</button>
</div>
<div class="session-active" id="session-active"></div>
<div class="session-msgs" id="session-msgs"></div>
<div class="chat-refs" id="chat-refs"></div>
<div class="session-input">
<div class="session-input-bar" id="session-input-bar">
<select class="session-model-picker" id="session-model" title="Override the model for this turn"></select>
<select class="session-agent-target hide" id="session-target" title="Send this turn to a specific agent"></select>
</div>
<div class="session-input-row">
<textarea class="input" id="session-text" placeholder="Tell the agents what to build — Enter to send, Shift+Enter for newline"></textarea>
<button class="btn" id="session-send">Send</button>
</div>
</div>
</div>
<div class="col-resizer" data-which="2"></div>
<div class="cockpit-pane cockpit-browser-pane" id="cockpit-browser">
<div class="pane-head" data-pane="browser" draggable="true">
<span class="pane-head-title">Browser</span>
<span class="pane-head-meta" id="pane-meta-browser"></span>
<button class="pane-btn" data-pane="browser" data-act="focus" title="Focus (Ctrl+4)">⛶</button>
<button class="pane-btn" data-pane="browser" data-act="collapse" title="Collapse">▶</button>
</div>
<div class="browser-toolbar">
<button class="bx-btn" id="bx-back" title="Back">←</button>
<button class="bx-btn" id="bx-forward" title="Forward">→</button>
<button class="bx-btn" id="bx-reload" title="Reload">⟳</button>
<input type="text" class="bx-url" id="bx-url" placeholder="http://localhost:8765" spellcheck="false">
<button class="bx-btn" id="bx-pick" title="Pick a DOM element and reference it in chat">🎯 Pick</button>
<button class="bx-btn" id="bx-go" title="Go">Go</button>
<button class="bx-btn" id="bx-open" title="Open in real browser">↗</button>
</div>
<div class="bx-suggest hide" id="bx-suggest"></div>
<div class="browser-body" id="browser-body">
<div class="empty small" id="bx-placeholder" style="padding:14px;">
Enter a URL above, or run a dev server in the Terminals pane — detected URLs appear here as one-click chips.
</div>
<div class="dom-hier hide" id="dom-hier">
<div class="dom-hier-head">
<span>Element hierarchy</span>
<button class="dom-hier-close" id="dom-hier-close" title="Cancel">×</button>
</div>
<div class="dom-hier-list" id="dom-hier-list"></div>
<div class="dom-hier-snippet" id="dom-hier-snippet"></div>
<div class="dom-hier-foot">
<button class="btn ghost sm" id="dom-hier-cancel">Cancel</button>
<button class="btn sm" id="dom-hier-confirm">Add to chat</button>
</div>
</div>
</div>
</div>
</div>
<div class="cockpit-terminals-drawer" id="cockpit-terminals">
<div class="term-drawer-outer-resizer" id="term-drawer-outer-resizer" title="Drag to resize drawer"></div>
<div class="term-drawer-rail" id="term-drawer-rail">
<button class="term-rail-toggle" id="term-rail-toggle" title="Open terminals (Ctrl+5)" aria-label="Open terminals">◀</button>
<div class="term-rail-label">TERMINALS</div>
<div class="term-rail-list" id="term-rail-list"></div>
<button class="pane-btn term-rail-new" id="term-rail-new-btn" title="New terminal">+</button>
</div>
<div class="term-drawer-body" id="term-drawer-body">
<div class="pane-head" data-pane="terminals">
<span class="pane-head-title">Terminals</span>
<span class="pane-head-meta" id="pane-meta-terminals"></span>
<button class="pane-btn" id="term-new-btn" title="New terminal">+</button>
<button class="pane-btn" id="term-drawer-close-btn" title="Close drawer (Ctrl+5)">▶</button>
</div>
<div class="terminals-body" id="terminals-body">
<div class="terminals-list" id="terminals-list">
<div class="terminals-empty small muted">No terminals yet. Click + to start one.</div>
</div>
<div class="terminals-inner-resizer" id="terminals-inner-resizer" title="Drag to resize list/output"></div>
<div class="terminals-output" id="terminals-output">
<div class="term-empty-output">Select a terminal to view its output.</div>
</div>
</div>
</div>
</div>
</div>
<div class="term-new-pop hide" id="term-new-pop">
<div>
<div class="label" style="margin-bottom:4px;">Command</div>
<input type="text" class="input" id="term-new-cmd" placeholder="e.g. python3 serve.py" autocomplete="off" spellcheck="false">
</div>
<div>
<div class="label" style="margin-bottom:4px;">Quick start</div>
<div class="term-presets" id="term-presets"></div>
</div>
<div class="term-pop-row">
<button class="btn ghost sm" id="term-new-cancel">Cancel</button>
<button class="btn sm" id="term-new-start">Start terminal</button>
</div>
</div>
</div>
</section>
<div class="modal-backdrop hide" id="folder-modal">
<div class="modal">
<div class="modal-head" id="fp-head">New session — choose a project folder</div>
<div class="fp-breadcrumb mono small" id="fp-path">—</div>
<div class="fp-list" id="fp-list"></div>
<div class="fp-config-block">
<div class="fp-skills" style="display:flex; align-items:center; gap:10px;">
<label class="small muted" for="fp-copy-from" style="white-space:nowrap;">Copy config from</label>
<select class="select" id="fp-copy-from" style="max-width:280px;">
<option value="">— start from defaults —</option>
</select>
<span class="small muted" style="font-size:10.5px;">Mirrors mode, ports, image, skills</span>
</div>
<div class="fp-skills" id="fp-skills"></div>
<div class="fp-skills" style="display:flex; align-items:center; gap:10px;">
<label class="small muted" for="fp-ports" style="white-space:nowrap;">Exposed ports</label>
<input type="text" class="input" id="fp-ports"
placeholder="3000, 5000, 5173, 8000, 8888"
style="flex:1; padding:5px 9px; background:var(--bg-2);
border:1px solid var(--border); border-radius:5px;
color:var(--fg); font-family:ui-monospace,monospace;
font-size:12px;">
<span class="small muted" style="font-size:10.5px;">Browser pane needs these</span>
</div>
<div class="fp-skills" style="display:flex; align-items:center; gap:10px;">
<label class="small muted" for="fp-image" style="white-space:nowrap;">Base image</label>
<select class="select" id="fp-image-preset" style="max-width:200px;">
<option value="">Default (alpine)</option>
<option value="docker.io/library/alpine:3.20">alpine:3.20 (minimal)</option>
<option value="docker.io/library/debian:bookworm-slim">debian:bookworm-slim</option>
<option value="docker.io/library/ubuntu:24.04">ubuntu:24.04</option>
<option value="docker.io/library/python:3.12-slim">python:3.12-slim</option>
<option value="docker.io/library/node:20-slim">node:20-slim</option>
<option value="docker.io/library/rust:bookworm">rust:bookworm</option>
<option value="__custom__">Custom…</option>
</select>
<input type="text" class="input hide" id="fp-image"
placeholder="docker.io/your/image:tag"
style="flex:1; padding:5px 9px; background:var(--bg-2);
border:1px solid var(--border); border-radius:5px;
color:var(--fg); font-family:ui-monospace,monospace;
font-size:12px;">
<span class="small muted" style="font-size:10.5px;">Per-session runtime</span>
</div>
<div class="fp-skills hide" id="fp-project-probe" style="padding:8px 16px;"></div>
<div class="fp-skills" style="display:flex; align-items:center; gap:10px;">
<label class="small muted">Mode</label>
<select class="select" id="fp-mode" style="max-width:180px;">
<option value="single_agent">Single agent</option>
<option value="lattice">Full lattice</option>
<option value="custom">Custom workflow</option>
</select>
<span class="grow"></span>
<span class="small muted" style="font-size:10.5px;" id="fp-mode-hint">One agent builds in the directory.</span>
</div>
<div class="fp-skills hide" id="fp-custom-row" style="max-height:120px; overflow:auto;"></div>
</div>
<div class="modal-foot">
<label class="small muted fp-config-only" id="fp-agent-label">Agent</label>
<select class="select fp-config-only" id="session-agent" style="max-width:170px;"></select>
<span class="grow"></span>
<button class="btn ghost sm" id="fp-cancel">Cancel</button>
<button class="btn" id="fp-use">Use this folder</button>
</div>
</div>
</div>
<section class="tab hide" id="tab-skills">
<div class="shell" id="skills-shell">
<aside class="shell-side">
<div class="shell-side-search"><input type="search" id="skills-side-search" placeholder="Filter skills…" spellcheck="false"></div>
<div class="shell-side-scroll">
<div class="shell-side-section" data-section="kind">
<div class="shell-side-head"><span class="tri">▾</span><span>By trigger</span></div>
<div class="shell-side-list" id="skills-side-kind"></div>
</div>
<div class="shell-side-section" data-section="agent">
<div class="shell-side-head"><span class="tri">▾</span><span>By agent</span></div>
<div class="shell-side-list" id="skills-side-agent"></div>
</div>
</div>
</aside>
<main class="shell-main">
<div class="shell-toolbar">
<h2>Skills</h2>
<span class="sub" id="skills-count"></span>
<span class="grow"></span>
<input type="search" class="shell-search" id="skills-search" placeholder="Filter…" spellcheck="false">
</div>
<div class="shell-col-head">
<span>Name</span>
<span>◆ Emits</span>
<span>⇡ Reacts to</span>
<span style="text-align:right">Agents</span>
</div>
<div class="shell-content">
<div class="shell-rows" id="skill-rows"></div>
</div>
<div class="shell-drawer" id="skill-drawer">
<div class="shell-drawer-head">
<h3 id="skill-drawer-title">—</h3>
<button class="shell-drawer-close" id="skill-drawer-close" title="Close (Esc / Space)">×</button>
</div>
<div class="shell-drawer-body" id="skill-drawer-body"></div>
<div class="shell-drawer-foot">
<button class="btn" id="skill-drawer-fire">◆ Fire this Skill</button>
</div>
</div>
</main>
</div>
</section>
<section class="tab hide" id="tab-mcp">
<div class="shell" id="mcp-shell">
<aside class="shell-side">
<div class="shell-side-search"><input type="search" id="mcp-side-search" placeholder="Filter servers, tools…" spellcheck="false"></div>
<div class="shell-side-scroll">
<div class="shell-side-section" data-section="servers">
<div class="shell-side-head"><span class="tri">▾</span><span>Servers</span></div>
<div class="shell-side-list" id="mcp-side-servers"></div>
</div>
<div class="shell-side-section" data-section="catalog">
<div class="shell-side-head"><span class="tri">▾</span><span>Catalog</span></div>
<div class="shell-side-list" id="mcp-side-catalog"></div>
</div>
<div class="shell-side-section" data-section="perms">
<div class="shell-side-head"><span class="tri">▾</span><span>Permissions</span></div>
<div class="shell-side-list" id="mcp-side-perms"></div>
</div>
</div>
</aside>
<main class="shell-main">
<div id="mcp-detail" style="display:flex; flex-direction:column; flex:1; min-height:0;"></div>
</main>
</div>
</section>
<section class="tab hide" id="tab-agents">
<div class="shell" id="agents-shell">
<aside class="shell-side">
<div class="shell-side-search"><input type="search" id="agents-side-search" placeholder="Filter teams, agents…" spellcheck="false"></div>
<div class="shell-side-scroll">
<div class="shell-side-section" data-section="teams">
<div class="shell-side-head"><span class="tri">▾</span><span>Teams</span></div>
<div class="shell-side-list" id="agents-side-teams"></div>
</div>
<div class="shell-side-section" data-section="agents">
<div class="shell-side-head"><span class="tri">▾</span><span>All agents</span></div>
<div class="shell-side-list" id="agents-side-list"></div>
</div>
</div>
</aside>
<main class="shell-main">
<div class="shell-toolbar">
<h2>Agents</h2>
<span class="sub" id="agents-count"></span>
<span class="grow"></span>
<input type="search" class="shell-search" id="agents-search" placeholder="Filter…" spellcheck="false">
</div>
<div class="shell-content">
<table style="margin:0;">
<thead><tr><th>ID</th><th>Team</th><th>Provider</th><th>Model</th><th>Status</th><th style="text-align:right">Input</th><th style="text-align:right">Output</th><th></th></tr></thead>
<tbody id="agents-tbl"></tbody>
</table>
</div>
</main>
</div>
</section>
</div>
</div>
<div class="detail-panel" id="detail-panel">
<div class="detail-head">
<div>
<div class="pre" id="detail-pre">—</div>
<div class="title" id="detail-title">—</div>
</div>
<div class="detail-actions">
<button class="detail-iconbtn" id="detail-maximize" title="Toggle full-screen (F)">⛶</button>
<button class="detail-iconbtn" id="detail-close" title="Close (Esc)">×</button>
</div>
</div>
<div class="detail-body" id="detail-body"></div>
<div class="detail-footer" id="detail-footer"></div>
</div>
<div class="toasts" id="toasts"></div>
<div class="palette-mask" id="palette-mask">
<div class="palette">
<input id="palette-input" placeholder="Type a command — fire skill, run workflow, restart agent…" />
<div class="palette-list" id="palette-list"></div>
</div>
</div>
<script>
const THEME_KEY = 'axo:theme-pref';
function getThemePref() { return localStorage.getItem(THEME_KEY) || 'system'; }
function applyTheme(pref) {
const effective = (pref === 'system')
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: pref;
if (effective === 'light') document.documentElement.setAttribute('data-theme', 'light');
else document.documentElement.removeAttribute('data-theme');
document.querySelectorAll('.theme-seg').forEach(b => {
b.classList.toggle('active', b.dataset.themePref === pref);
});
if (window.monaco) {
try { window.monaco.editor.setTheme(effective === 'light' ? 'vs' : 'vs-dark'); } catch {}
}
}
function setThemePref(pref) { localStorage.setItem(THEME_KEY, pref); applyTheme(pref); }
applyTheme(getThemePref());
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (getThemePref() === 'system') applyTheme('system');
});
window.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.theme-seg').forEach(b => {
b.addEventListener('click', () => setThemePref(b.dataset.themePref));
});
applyTheme(getThemePref()); });
const $ = s => document.querySelector(s);
const $$ = s => Array.from(document.querySelectorAll(s));
const el = (tag, cls, txt) => { const n = document.createElement(tag); if (cls) n.className = cls; if (txt != null) n.textContent = txt; return n; };
const fmtNum = n => n != null ? new Intl.NumberFormat().format(n) : '—';
const teamCls = team => ({Engineering:'eng', Research:'res', Ops:'ops', Customer:'cust'}[team] || 'eng');
const escHtml = s => String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
let S = {
agents: [], workflows: [], schedules: [], skills: [], mcpServers: [], mcpTools: [],
sessions: [], activeSession: null,
selectedWorkflow: null,
activeWorkflows: new Set(),
totalEvents: 0,
studioPositions: {},
tokensTotal: 0,
};
function toast(title, body, kind = 'info', ms = 3500) {
const t = el('div', `toast ${kind}`);
t.appendChild(el('div', 't-title', title));
if (body) t.appendChild(el('div', 't-body', body));
$('#toasts').appendChild(t);
setTimeout(() => { t.style.opacity = '0'; t.style.transition = 'opacity .25s'; setTimeout(() => t.remove(), 260); }, ms);
}
function axoModal(spec) {
return new Promise((resolve) => {
const backdrop = el('div', 'modal-backdrop');
const modal = el('div', 'modal axo-modal');
backdrop.appendChild(modal);
if (spec.title) modal.appendChild(el('div', 'modal-head', spec.title));
const body = el('div', 'axo-modal-body');
modal.appendChild(body);
if (spec.body) {
const p = el('div', 'axo-modal-text');
if (typeof spec.body === 'string') p.textContent = spec.body;
else p.appendChild(spec.body);
body.appendChild(p);
}
let chosenChoice = null;
if (spec.choices && spec.choices.length) {
const list = el('div', 'axo-modal-choices');
spec.choices.forEach((c, i) => {
const row = el('div', 'axo-modal-choice' + (i === 0 ? ' active' : ''));
chosenChoice = chosenChoice || c.value;
row.dataset.value = c.value;
const main = el('div', 'axo-modal-choice-main');
main.appendChild(el('div', 'axo-modal-choice-label', c.label));
if (c.sub) main.appendChild(el('div', 'axo-modal-choice-sub', c.sub));
row.appendChild(main);
row.addEventListener('click', () => {
list.querySelectorAll('.axo-modal-choice').forEach(r => r.classList.remove('active'));
row.classList.add('active');
chosenChoice = c.value;
});
row.addEventListener('dblclick', () => commit());
list.appendChild(row);
});
body.appendChild(list);
}
const fieldEls = {};
if (spec.fields && spec.fields.length) {
for (const f of spec.fields) {
const wrap = el('div', 'axo-modal-field');
if (f.label) wrap.appendChild(el('label', 'axo-modal-label', f.label));
let input;
if (f.kind === 'textarea') {
input = document.createElement('textarea');
input.className = 'input';
input.rows = f.rows || 4;
input.value = f.value || '';
} else if (f.kind === 'select') {
input = document.createElement('select');
input.className = 'select';
for (const opt of (f.options || [])) {
const o = document.createElement('option');
o.value = opt.value;
o.textContent = opt.label || opt.value;
if (opt.value === f.value) o.selected = true;
input.appendChild(o);
}
} else if (f.kind === 'file') {
input = document.createElement('input');
input.type = 'file';
input.className = 'axo-modal-file';
if (f.accept) input.accept = f.accept;
if (f.multiple) input.multiple = true;
} else {
input = document.createElement('input');
input.type = f.kind || 'text';
input.className = 'input';
input.value = f.value || '';
}
if (f.placeholder) input.placeholder = f.placeholder;
wrap.appendChild(input);
if (f.help) wrap.appendChild(el('div', 'axo-modal-help', f.help));
body.appendChild(wrap);
fieldEls[f.key] = input;
}
}
const foot = el('div', 'modal-foot axo-modal-foot');
const cancelBtn = el('button', 'btn ghost', spec.cancelLabel || 'Cancel');
cancelBtn.addEventListener('click', () => cleanup(null));
const okBtn = el('button', 'btn' + (spec.okKind === 'danger' ? ' danger' : ''), spec.okLabel || 'OK');
okBtn.addEventListener('click', commit);
foot.appendChild(el('div', '', '')); foot.appendChild(cancelBtn);
foot.appendChild(okBtn);
modal.appendChild(foot);
document.body.appendChild(backdrop);
requestAnimationFrame(() => {
const firstInput = modal.querySelector('input.input, textarea.input, .select, .axo-modal-file');
if (firstInput) firstInput.focus();
else okBtn.focus();
});
function onKey(e) {
if (e.key === 'Escape') { e.preventDefault(); cleanup(null); }
else if (e.key === 'Enter' && !e.shiftKey) {
const t = e.target;
if (t.tagName === 'TEXTAREA') return; e.preventDefault();
commit();
}
}
backdrop.addEventListener('keydown', onKey);
function commit() {
if (spec.fields && spec.fields.length) {
const out = {};
for (const f of spec.fields) {
const el = fieldEls[f.key];
if (f.kind === 'file') out[f.key] = el.files;
else out[f.key] = el.value;
}
cleanup(out);
} else if (spec.choices && spec.choices.length) {
cleanup(chosenChoice);
} else {
cleanup(true);
}
}
function cleanup(value) {
backdrop.removeEventListener('keydown', onKey);
backdrop.remove();
resolve(value);
}
});
}
function axoConfirm({ title, body, okLabel = 'OK', okKind } = {}) {
return axoModal({ title, body, okLabel, okKind });
}
function axoPrompt({ title, label, value = '', placeholder, multiline = false, okLabel = 'Save' } = {}) {
return axoModal({
title,
fields: [{
key: 'value',
label,
value,
placeholder,
kind: multiline ? 'textarea' : 'text',
}],
okLabel,
}).then(out => (out == null ? null : out.value));
}
function mdRender(text) {
if (!text) return '';
const codeBlocks = [];
text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
codeBlocks.push(`<pre><code class="language-${escHtml(lang)}">${escHtml(code)}</code></pre>`);
return ` _CB${codeBlocks.length-1}_ `;
});
text = escHtml(text);
text = text.replace(/`([^`\n]+)`/g, (_, c) => `<code>${c}</code>`);
text = text.replace(/^### (.+)$/gm, '<h3>$1</h3>');
text = text.replace(/^## (.+)$/gm, '<h2>$1</h2>');
text = text.replace(/^# (.+)$/gm, '<h1>$1</h1>');
text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
text = text.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>');
text = text.replace(/^([ \t]*)[-•*] (.+)$/gm, '<li>$2</li>');
text = text.replace(/(<li>(?:.|\n)*?<\/li>(?:\n<li>(?:.|\n)*?<\/li>)*)/g, m => `<ul>${m.replace(/\n/g,'')}</ul>`);
text = text.split(/\n{2,}/).map(p => p.trim() ? (p.startsWith('<') ? p : `<p>${p.replace(/\n/g, '<br>')}</p>`) : '').join('');
text = text.replace(/ _CB(\d+)_ /g, (_, i) => codeBlocks[+i]);
scheduleHighlight();
return text;
}
let _hlScheduled = false;
function scheduleHighlight() {
if (_hlScheduled) return;
_hlScheduled = true;
queueMicrotask(() => {
_hlScheduled = false;
if (window.hljs) {
document.querySelectorAll('pre code:not(.hljs)').forEach(b => {
try { window.hljs.highlightElement(b); } catch {}
});
}
decorateChatCodeBlocks();
});
}
const ARTIFACT_LANGS = new Set([
'js', 'javascript', 'ts', 'typescript', 'jsx', 'tsx',
'css', 'python', 'py', 'rust', 'rs', 'json', 'yaml', 'yml',
'md', 'markdown', 'mermaid'
]);
function isArtifactBlock(code, langClass) {
const lang = (langClass || '').toLowerCase();
if (lang === 'language-html' || lang === 'language-svg') return true;
const text = code.textContent || '';
if (/<!doctype|<html\b/i.test(text)) return true;
if (ARTIFACT_LANGS.has(lang.replace(/^language-/, ''))) {
if (text.split('\n').length >= 15) return true;
}
return false;
}
function decorateChatCodeBlocks() {
document.querySelectorAll('.chat-msgs pre').forEach(pre => {
if (pre.dataset.toolbar) return;
pre.dataset.toolbar = '1';
const code = pre.querySelector('code');
if (!code) return;
const toolbar = el('div', 'code-toolbar');
const copyBtn = el('button', null, '⧉ Copy');
copyBtn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(code.textContent || '');
copyBtn.textContent = '✓ Copied';
setTimeout(() => (copyBtn.textContent = '⧉ Copy'), 1100);
} catch {
copyBtn.textContent = 'Copy failed';
}
});
toolbar.appendChild(copyBtn);
if (isArtifactBlock(code, code.className)) {
const openBtn = el('button', 'artifact', '⛶ Open artifact');
openBtn.addEventListener('click', () => {
const langMatch = (code.className || '').match(/language-(\S+)/);
openArtifact({
lang: langMatch ? langMatch[1] : '',
content: code.textContent || '',
});
});
toolbar.appendChild(openBtn);
}
pre.appendChild(toolbar);
});
}
function openArtifact({ lang, content }) {
const grid = document.querySelector('.chat-grid');
const pane = $('#chat-artifact');
const title = $('#chat-artifact-title');
const body = $('#chat-artifact-body');
if (!grid || !pane || !body) return;
grid.classList.add('artifacts-open');
pane.classList.remove('hide');
title.textContent = lang ? `Artifact · ${lang}` : 'Artifact';
body.innerHTML = '';
const l = (lang || '').toLowerCase();
if (l === 'html' || l === 'svg' || /<!doctype|<html\b/i.test(content)) {
const iframe = document.createElement('iframe');
iframe.setAttribute('sandbox', 'allow-scripts');
iframe.srcdoc = content;
body.appendChild(iframe);
} else if (l === 'md' || l === 'markdown') {
const div = el('div', 'prose');
div.innerHTML = mdRender(content);
body.appendChild(div);
} else {
const pre = document.createElement('pre');
const code = document.createElement('code');
if (lang) code.className = 'language-' + lang;
code.textContent = content;
pre.appendChild(code);
body.appendChild(pre);
if (window.hljs) {
try { window.hljs.highlightElement(code); } catch {}
}
}
pane.dataset.content = content;
}
function closeArtifact() {
const grid = document.querySelector('.chat-grid');
const pane = $('#chat-artifact');
if (grid) grid.classList.remove('artifacts-open');
if (pane) pane.classList.add('hide');
}
window.addEventListener('DOMContentLoaded', () => {
const close = $('#chat-artifact-close');
if (close) close.addEventListener('click', closeArtifact);
const copy = $('#chat-artifact-copy');
if (copy) copy.addEventListener('click', async () => {
const pane = $('#chat-artifact');
try {
await navigator.clipboard.writeText(pane?.dataset.content || '');
copy.textContent = 'Copied ✓';
setTimeout(() => (copy.textContent = 'Copy'), 1100);
} catch {
copy.textContent = 'Copy failed';
}
});
});
function openDetail({ pre, title, body, footer, full }) {
$('#detail-pre').textContent = pre || '';
$('#detail-title').textContent = title || '';
const b = $('#detail-body'); b.innerHTML = '';
if (typeof body === 'string') b.innerHTML = body; else if (body) b.appendChild(body);
const f = $('#detail-footer'); f.innerHTML = '';
(footer || []).forEach(node => f.appendChild(node));
const panel = $('#detail-panel');
panel.classList.add('open');
panel.classList.toggle('full', !!full);
}
function closeDetail() { $('#detail-panel').classList.remove('open'); $('#detail-panel').classList.remove('full'); }
function toggleDetailFull() { $('#detail-panel').classList.toggle('full'); }
$('#detail-close').addEventListener('click', closeDetail);
$('#detail-maximize').addEventListener('click', toggleDetailFull);
$$('.strip-item, .nav-item').forEach(n => {
if (!n.dataset.tab) return;
n.addEventListener('click', () => switchTab(n.dataset.tab));
});
const STRIP_EXPANDED_KEY = 'axo:strip-expanded';
(function initStripToggle() {
if (localStorage.getItem(STRIP_EXPANDED_KEY) === '1') {
document.body.classList.add('strip-expanded');
}
const mark = document.getElementById('strip-mark');
if (!mark) return;
mark.addEventListener('click', () => {
const next = !document.body.classList.contains('strip-expanded');
document.body.classList.toggle('strip-expanded', next);
try { localStorage.setItem(STRIP_EXPANDED_KEY, next ? '1' : '0'); } catch {}
});
})();
function switchTab(tab) {
$$('.strip-item, .nav-item').forEach(x => x.classList.toggle('active', x.dataset.tab === tab));
$$('.tab').forEach(t => t.classList.add('hide'));
const el = $('#tab-' + tab); if (!el) return;
el.classList.remove('hide');
if (tab === 'chat') refreshChats();
if (tab === 'files') refreshFiles();
if (tab === 'sessions') refreshSessions();
if (tab === 'automations') renderAutomations();
if (tab === 'studio') { renderStudio(); renderStudioQuickfire(); refreshStudioFeed(); }
if (tab === 'agents') refreshAgents();
if (tab === 'skills') renderSkills();
if (tab === 'mcp') refreshMcp();
S.activeTabName = tab;
renderStatusPearls();
}
S.autoFilter = 'all';
S.autoFolder = { kind: 'all' };
S.autoFolders = [];
S.autoFoldersCollapsed = new Set();
async function refreshAutomationsData() {
let list = [];
let folders = [];
try { list = await fetch('/api/automations').then(r => r.json()); } catch {}
try { folders = await fetch('/api/automation-folders').then(r => r.json()); } catch {}
if (!Array.isArray(list)) list = [];
if (!Array.isArray(folders)) folders = [];
S.automations = list;
S.autoFolders = folders;
const cnt = $('#cnt-automations');
if (cnt) cnt.textContent = list.length || '—';
}
function buildAutomations() {
return (S.automations || []).map(a => {
const tk = a.trigger ? a.trigger.kind : 'manual';
const kind = tk === 'manual' ? 'manual'
: tk === 'schedule' ? 'schedule'
: tk === 'on_event' ? 'event'
: tk === 'on_skill' ? 'skill'
: 'manual';
const trigger_label =
tk === 'manual' ? 'manual'
: tk === 'schedule' ? 'every ' + (a.trigger.every || '?')
: tk === 'on_event' ? 'on ' + (a.trigger.event || '?')
: tk === 'on_skill' ? 'on skill ' + (a.trigger.skill_id || '?')
: tk;
const agents = (a.nodes || [])
.filter(n => n.kind && n.kind.type === 'agent')
.map(n => n.kind.agent_id);
return {
id: a.id, name: a.name || a.id,
unified_id: a.id, raw_id: a.id,
kind, trigger_label, agents,
enabled: a.enabled !== false,
last_run: null, run_count: 0,
raw: a,
source: tk === 'schedule' ? 'schedule'
: tk === 'on_event' ? 'proactive'
: 'workflow',
runner: async () => {
if (kind === 'manual') {
openRunInputModal(a.raw);
} else {
await fetch('/api/automations/' + encodeURIComponent(a.id) + '/run', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ input: '', inputs: {} }),
});
toast('Automation fired', a.name || a.id, 'ok');
}
},
};
}).sort((x, y) => {
if (x.enabled !== y.enabled) return x.enabled ? -1 : 1;
return (x.name || '').localeCompare(y.name || '');
});
}
async function renderAutomations() {
await refreshAutomationsData();
renderAutoTree();
renderAutoCrumbs();
renderAutoCards();
}
function automationMatchesFolder(autoRaw) {
const f = S.autoFolder;
if (f.kind === 'all') return true;
if (f.kind === 'unfiled') return !autoRaw.folder;
if (f.kind === 'folder') return (autoRaw.folder || '') === f.path;
return true;
}
function immediateSubfolders(parentPath) {
const prefix = parentPath ? parentPath + '/' : '';
const depth = parentPath ? parentPath.split('/').length : 0;
return (S.autoFolders || []).filter(f => {
if (parentPath) {
if (!f.path.startsWith(prefix)) return false;
return f.path.split('/').length === depth + 1;
}
return !f.path.includes('/'); });
}
function renderAutoCards() {
const host = $('#auto-cards'); if (!host) return;
host.innerHTML = '';
const all = buildAutomations();
const inFolder = all.filter(a => automationMatchesFolder(a.raw));
const filtered = inFolder.filter(a => S.autoFilter === 'all' || a.kind === S.autoFilter);
let subfolders = [];
if (S.autoFolder.kind === 'folder') {
subfolders = immediateSubfolders(S.autoFolder.path);
} else if (S.autoFolder.kind === 'unfiled') {
subfolders = immediateSubfolders(null);
}
if (!subfolders.length && !filtered.length) {
const e = el('div', 'auto-empty');
if (!all.length && !S.autoFolders.length) {
e.textContent = 'No automations yet. Click into a folder and create one — or define them in axocoatl.yaml.';
} else if (S.autoFolder.kind === 'folder') {
e.textContent = 'This folder is empty. Drag automations or sub-folders here, or use "Move to…" from any card.';
} else if (S.autoFolder.kind === 'unfiled' && !subfolders.length) {
e.textContent = 'No unfiled automations and no top-level folders.';
} else {
e.textContent = 'No automations match this filter.';
}
host.appendChild(e);
attachFolderDropTarget(host, currentFolderPath());
return;
}
subfolders.forEach(f => host.appendChild(folderCard(f)));
filtered.forEach(a => host.appendChild(autoCard(a)));
attachFolderDropTarget(host, currentFolderPath());
}
function folderCard(f) {
const card = el('div', 'auto-card auto-folder-card');
card.setAttribute('draggable', 'true');
card.dataset.folderPath = f.path;
card.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('application/x-axo-folder', f.path);
e.dataTransfer.effectAllowed = 'move';
card.classList.add('dragging');
});
card.addEventListener('dragend', () => card.classList.remove('dragging'));
const top = el('div', 'auto-card-row-top');
top.appendChild(el('div', 'auto-card-icon folder', '📁'));
const body = el('div', 'auto-card-body');
const row1 = el('div', 'auto-card-row1');
const displayName = f.name && f.name.trim() ? f.name : f.path.split('/').pop();
row1.appendChild(el('span', 'auto-card-name', displayName));
row1.appendChild(el('span', 'auto-card-trigger folder', 'folder'));
body.appendChild(row1);
const subCount = (S.autoFolders || []).filter(o => o.path.startsWith(f.path + '/')).length;
const autoCount = (S.automations || []).filter(a => {
const af = a.folder || '';
return af === f.path || af.startsWith(f.path + '/');
}).length;
const bits = [];
bits.push(autoCount + ' automation' + (autoCount === 1 ? '' : 's'));
if (subCount) bits.push(subCount + ' sub-folder' + (subCount === 1 ? '' : 's'));
const sub = el('div', 'auto-card-agents');
sub.appendChild(el('span', '', bits.join(' · ')));
body.appendChild(sub);
top.appendChild(body);
card.appendChild(top);
const bottom = el('div', 'auto-card-row-bottom');
bottom.appendChild(el('div', 'auto-card-meta', f.path));
const grow = el('span'); grow.style.flex = '1';
bottom.appendChild(grow);
const openBtn = el('button', 'btn ghost sm', 'Open →');
openBtn.addEventListener('click', (e) => {
e.stopPropagation();
S.autoFolder = { kind: 'folder', path: f.path };
renderAutoTree(); renderAutoCrumbs(); renderAutoCards();
});
bottom.appendChild(openBtn);
card.appendChild(bottom);
card.addEventListener('click', () => {
S.autoFolder = { kind: 'folder', path: f.path };
renderAutoTree(); renderAutoCrumbs(); renderAutoCards();
});
card.addEventListener('contextmenu', (e) => {
e.preventDefault();
showFolderContextMenu(f, e.clientX, e.clientY);
});
attachFolderDropTarget(card, f.path);
return card;
}
function currentFolderPath() {
if (S.autoFolder.kind === 'folder') return S.autoFolder.path;
return null;
}
function autoCard(a) {
const card = el('div', 'auto-card' + (a.enabled ? '' : ' disabled'));
card.setAttribute('draggable', 'true');
card.dataset.autoId = a.id;
card.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('application/x-axo-automation', a.id);
e.dataTransfer.effectAllowed = 'move';
card.classList.add('dragging');
});
card.addEventListener('dragend', () => card.classList.remove('dragging'));
const top = el('div', 'auto-card-row-top');
const iconChar = a.kind === 'manual' ? '▶'
: a.kind === 'schedule' ? '⏱'
: a.kind === 'skill' ? '◆'
: '⊛';
top.appendChild(el('div', 'auto-card-icon ' + a.kind, iconChar));
const titleCol = el('div', 'auto-card-body');
const row1 = el('div', 'auto-card-row1');
row1.appendChild(el('span', 'auto-card-name', a.name));
const trigCls = a.kind === 'manual' ? 'manual'
: a.kind === 'schedule' ? 'scheduled'
: 'event';
row1.appendChild(el('span', 'auto-card-trigger ' + trigCls, a.trigger_label));
titleCol.appendChild(row1);
const aRow = el('div', 'auto-card-agents');
if (a.agents.length) {
a.agents.forEach((agent, i) => {
aRow.appendChild(el('span', '', agent));
if (i < a.agents.length - 1) aRow.appendChild(el('span', 'arrow', '→'));
});
} else {
aRow.appendChild(el('span', '', '(no agents)'));
}
titleCol.appendChild(aRow);
top.appendChild(titleCol);
card.appendChild(top);
const bottom = el('div', 'auto-card-row-bottom');
const meta = el('div', 'auto-card-meta');
const bits = [];
if (!a.enabled) bits.push('paused');
if (a.raw.folder) bits.push('📁 ' + a.raw.folder);
if (a.run_count != null && a.run_count > 0) bits.push(a.run_count + ' run' + (a.run_count === 1 ? '' : 's'));
meta.textContent = bits.join(' · ');
bottom.appendChild(meta);
const grow = el('span'); grow.style.flex = '1';
bottom.appendChild(grow);
if (a.runner) {
const run = el('button', 'btn sm', '▶ Run');
run.addEventListener('click', (e) => { e.stopPropagation(); a.runner(); });
bottom.appendChild(run);
}
card.appendChild(bottom);
card.addEventListener('click', () => openAutomationDetail(a));
card.addEventListener('contextmenu', (e) => {
e.preventDefault();
showAutomationContextMenu(a, e.clientX, e.clientY);
});
return card;
}
function renderAutoTree() {
const host = $('#auto-tree-list');
if (!host) return;
host.innerHTML = '';
host.appendChild(autoTreeRow({
label: 'All automations',
icon: '▦',
count: (S.automations || []).length,
active: S.autoFolder.kind === 'all',
onClick: () => { S.autoFolder = { kind: 'all' }; renderAutoTree(); renderAutoCrumbs(); renderAutoCards(); },
}));
const unfiledCount = (S.automations || []).filter(a => !a.folder).length;
host.appendChild(autoTreeRow({
label: 'Unfiled',
icon: '○',
count: unfiledCount,
active: S.autoFolder.kind === 'unfiled',
drop: { path: null, label: 'Unfiled' },
onClick: () => { S.autoFolder = { kind: 'unfiled' }; renderAutoTree(); renderAutoCrumbs(); renderAutoCards(); },
}));
if ((S.autoFolders || []).length) {
host.appendChild(el('div', 'auto-tree-section', 'Folders'));
const sorted = [...S.autoFolders].sort((a, b) => a.path.localeCompare(b.path));
for (const f of sorted) {
const depth = f.path.split('/').length - 1;
const parent = f.path.split('/').slice(0, -1).join('/');
let hidden = false;
let probe = parent;
while (probe) {
if (S.autoFoldersCollapsed.has(probe)) { hidden = true; break; }
probe = probe.split('/').slice(0, -1).join('/');
}
if (hidden) continue;
const isOpen = !S.autoFoldersCollapsed.has(f.path);
const hasChildren = sorted.some(o => o.path.startsWith(f.path + '/'));
const count = (S.automations || []).filter(a => {
const af = a.folder || '';
return af === f.path || af.startsWith(f.path + '/');
}).length;
const displayName = f.name && f.name.trim() ? f.name : f.path.split('/').pop();
host.appendChild(autoTreeRow({
label: displayName,
icon: hasChildren ? (isOpen ? '▾' : '▸') : '─',
twistAction: hasChildren ? () => {
if (S.autoFoldersCollapsed.has(f.path)) S.autoFoldersCollapsed.delete(f.path);
else S.autoFoldersCollapsed.add(f.path);
renderAutoTree();
} : null,
count,
depth,
active: S.autoFolder.kind === 'folder' && S.autoFolder.path === f.path,
drop: { path: f.path, label: displayName },
dragFolder: f.path, onClick: () => { S.autoFolder = { kind: 'folder', path: f.path }; renderAutoTree(); renderAutoCrumbs(); renderAutoCards(); },
onContext: (x, y) => showFolderContextMenu(f, x, y),
}));
}
}
}
function autoTreeRow(o) {
const row = el('div', 'auto-tree-row' + (o.active ? ' active' : ''));
if (o.depth) row.style.paddingLeft = (8 + o.depth * 12) + 'px';
if (o.twistAction) {
const twist = el('span', 'twist', o.icon === '▸' || o.icon === '▾' ? o.icon : '');
twist.addEventListener('click', (e) => { e.stopPropagation(); o.twistAction(); });
row.appendChild(twist);
} else {
row.appendChild(el('span', 'twist', ''));
}
row.appendChild(el('span', 'ico', (o.icon === '▸' || o.icon === '▾') ? '📁' : (o.icon || '📁')));
row.appendChild(el('span', 'label', o.label));
row.appendChild(el('span', 'count', String(o.count ?? '')));
row.addEventListener('click', o.onClick);
if (o.onContext) {
row.addEventListener('contextmenu', (e) => { e.preventDefault(); o.onContext(e.clientX, e.clientY); });
}
if (o.drop !== undefined) attachFolderDropTarget(row, o.drop.path);
if (o.dragFolder) {
row.setAttribute('draggable', 'true');
row.dataset.folderPath = o.dragFolder;
row.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('application/x-axo-folder', o.dragFolder);
e.dataTransfer.effectAllowed = 'move';
row.classList.add('dragging');
});
row.addEventListener('dragend', () => row.classList.remove('dragging'));
}
return row;
}
function attachFolderDropTarget(elNode, folderPath) {
elNode.addEventListener('dragover', (e) => {
const types = e.dataTransfer.types;
const isAuto = types.includes('application/x-axo-automation');
const isFolder = types.includes('application/x-axo-folder');
if (!isAuto && !isFolder) return;
if (isFolder && _autoDragFolderSrc) {
const src = _autoDragFolderSrc;
const dst = folderPath;
if (dst === src) return; if (dst && (dst === src || dst.startsWith(src + '/'))) return; }
e.preventDefault();
elNode.classList.add('drop-target');
});
elNode.addEventListener('dragleave', () => elNode.classList.remove('drop-target'));
elNode.addEventListener('drop', async (e) => {
e.preventDefault();
elNode.classList.remove('drop-target');
const types = e.dataTransfer.types;
if (types.includes('application/x-axo-folder')) {
const src = e.dataTransfer.getData('application/x-axo-folder');
if (!src) return;
await moveFolderTo(src, folderPath);
return;
}
const id = e.dataTransfer.getData('application/x-axo-automation');
if (!id) return;
await moveAutomationToFolder(id, folderPath);
});
}
let _autoDragFolderSrc = null;
document.addEventListener('dragstart', (e) => {
const fp = e.target && e.target.closest && e.target.closest('.auto-tree-row');
_autoDragFolderSrc = (fp && fp.dataset && fp.dataset.folderPath) || null;
});
document.addEventListener('dragend', () => { _autoDragFolderSrc = null; });
async function moveFolderTo(src, dstParent) {
const base = src.split('/').pop();
const newPath = dstParent ? `${dstParent}/${base}` : base;
if (newPath === src) return;
if (dstParent && (dstParent === src || dstParent.startsWith(src + '/'))) {
toast('Cannot move', `"${src}" can't be moved inside itself.`, 'err');
return;
}
try {
const r = await fetch('/api/automation-folders', {
method: 'PATCH', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ old_path: src, new_path: newPath }),
});
if (!r.ok) {
const body = await r.json().catch(() => ({ error: 'HTTP ' + r.status }));
toast('Move failed', body.error || ('HTTP ' + r.status), 'err');
return;
}
if (S.autoFolder.kind === 'folder') {
if (S.autoFolder.path === src) {
S.autoFolder = { kind: 'folder', path: newPath };
} else if (S.autoFolder.path.startsWith(src + '/')) {
const rest = S.autoFolder.path.slice(src.length + 1);
S.autoFolder = { kind: 'folder', path: `${newPath}/${rest}` };
}
}
toast('Moved', `${src} → ${newPath}`, 'ok');
await renderAutomations();
} catch (e) { toast('Move failed', String(e), 'err'); }
}
async function moveAutomationToFolder(id, folderPath) {
try {
const r = await fetch(`/api/automations/${encodeURIComponent(id)}/move`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ folder: folderPath }),
});
if (!r.ok) {
const body = await r.json().catch(() => ({ error: 'HTTP ' + r.status }));
toast('Move failed', body.error || ('HTTP ' + r.status), 'err');
return;
}
toast('Moved', folderPath ? `into ${folderPath}` : 'to Unfiled', 'ok');
await renderAutomations();
} catch (e) { toast('Move failed', String(e), 'err'); }
}
function renderAutoCrumbs() {
const host = $('#auto-crumbs');
if (!host) return;
host.innerHTML = '';
if (S.autoFolder.kind === 'all') {
host.appendChild(el('span', 'auto-crumb current', 'All automations'));
return;
}
if (S.autoFolder.kind === 'unfiled') {
host.appendChild(el('span', 'auto-crumb current', 'Unfiled'));
return;
}
const root = el('span', 'auto-crumb', 'All');
root.addEventListener('click', () => {
S.autoFolder = { kind: 'all' }; renderAutoTree(); renderAutoCrumbs(); renderAutoCards();
});
host.appendChild(root);
const parts = S.autoFolder.path.split('/');
let acc = '';
parts.forEach((p, i) => {
host.appendChild(el('span', 'auto-crumb-sep', '›'));
acc = acc ? `${acc}/${p}` : p;
const path = acc;
const isLast = i === parts.length - 1;
const c = el('span', 'auto-crumb' + (isLast ? ' current' : ''), p);
if (!isLast) c.addEventListener('click', () => {
S.autoFolder = { kind: 'folder', path }; renderAutoTree(); renderAutoCrumbs(); renderAutoCards();
});
host.appendChild(c);
});
}
async function showAutomationContextMenu(a, x, y) {
await refreshAutomationsData(); const items = [];
if (a.runner) items.push({ label: '▶ Run now', onClick: () => a.runner() });
items.push({ label: 'Open editor', onClick: () => openAutomationDetail(a) });
items.push({ sep: true });
items.push({
label: 'Move to…',
onClick: () => moveAutomationPrompt(a.id),
});
items.push({
label: a.enabled ? 'Pause' : 'Enable',
onClick: async () => {
const next = { ...a.raw, enabled: !a.enabled };
await fetch('/api/automations/' + encodeURIComponent(a.id), {
method: 'PUT', headers: { 'content-type': 'application/json' },
body: JSON.stringify(next),
});
await renderAutomations();
},
});
items.push({ sep: true });
items.push({
label: 'Delete…',
danger: true,
onClick: async () => {
const ok = await axoConfirm({
title: 'Delete this automation?',
body: `"${a.name}" will be removed. This can't be undone.`,
okLabel: 'Delete', okKind: 'danger',
});
if (!ok) return;
await fetch('/api/automations/' + encodeURIComponent(a.id), { method: 'DELETE' });
await renderAutomations();
},
});
showContextMenu(x, y, items);
}
async function moveAutomationPrompt(autoId) {
const choices = [{ value: '__root__', label: 'Unfiled (root)', sub: 'no folder' }];
for (const f of S.autoFolders || []) {
choices.push({ value: f.path, label: f.path, sub: f.name && f.name !== f.path.split('/').pop() ? f.name : '' });
}
choices.push({ value: '__new__', label: 'Create new folder…', sub: '' });
const picked = await axoModal({
title: 'Move automation',
body: 'Pick the folder this automation should live in.',
choices,
okLabel: 'Move',
});
if (!picked) return;
if (picked === '__root__') {
await moveAutomationToFolder(autoId, null);
return;
}
if (picked === '__new__') {
const path = await axoPrompt({
title: 'New folder',
label: 'Path (use `/` for nesting)',
placeholder: 'e.g. client/spec-reviews',
});
if (!path || !path.trim()) return;
await moveAutomationToFolder(autoId, path.trim());
return;
}
await moveAutomationToFolder(autoId, picked);
}
function showFolderContextMenu(folder, x, y) {
const items = [
{
label: 'Rename folder…',
onClick: async () => {
const newName = await axoPrompt({
title: 'Rename folder',
label: 'Display name',
value: folder.name || folder.path.split('/').pop(),
});
if (!newName || !newName.trim()) return;
await fetch('/api/automation-folders', {
method: 'PATCH', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ old_path: folder.path, new_path: folder.path, new_name: newName.trim() }),
});
await renderAutomations();
},
},
{
label: 'New sub-folder…',
onClick: async () => {
const name = await axoPrompt({
title: 'New sub-folder',
label: 'Name (no slashes)',
});
if (!name || !name.trim()) return;
const seg = name.trim().replace(/\//g, '-');
await fetch('/api/automation-folders', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ path: `${folder.path}/${seg}` }),
});
await renderAutomations();
},
},
{ sep: true },
{
label: 'Delete folder…',
danger: true,
onClick: () => deleteFolderPrompt(folder),
},
];
showContextMenu(x, y, items);
}
async function deleteFolderPrompt(folder) {
const choice = await axoModal({
title: `Delete "${folder.path}"?`,
body: 'What happens to the automations inside?',
choices: [
{ value: 'keep', label: 'Move contents up to parent', sub: 'Safe — automations are preserved.' },
{ value: 'recursive', label: 'Delete the folder AND everything inside', sub: 'Destructive: automations and sub-folders are removed.' },
],
okLabel: 'Continue',
});
if (!choice) return;
if (choice === 'recursive') {
const ok = await axoConfirm({
title: 'Delete everything inside?',
body: `All automations under "${folder.path}" will be permanently deleted. This can't be undone.`,
okLabel: 'Delete all', okKind: 'danger',
});
if (!ok) return;
}
const keep = choice === 'keep';
await fetch(`/api/automation-folders?path=${encodeURIComponent(folder.path)}&keep_contents=${keep}`, {
method: 'DELETE',
});
if (S.autoFolder.kind === 'folder' && (S.autoFolder.path === folder.path || S.autoFolder.path.startsWith(folder.path + '/'))) {
S.autoFolder = { kind: 'all' };
}
await renderAutomations();
}
async function newAutomationFolder() {
const parent = S.autoFolder.kind === 'folder' ? S.autoFolder.path : null;
const seg = await axoPrompt({
title: parent ? `New folder inside ${parent}` : 'New folder',
label: parent ? 'Folder name' : 'Path (use `/` for nesting)',
placeholder: parent ? 'e.g. spec-reviews' : 'e.g. client/spec-reviews',
});
if (!seg || !seg.trim()) return;
const raw = seg.trim();
const path = parent
? `${parent}/${raw.replace(/^\/+/, '').replace(/\/+$/, '')}`
: raw;
try {
const r = await fetch('/api/automation-folders', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ path }),
});
if (!r.ok) {
const body = await r.json().catch(() => ({ error: 'HTTP ' + r.status }));
toast('Create failed', body.error || ('HTTP ' + r.status), 'err');
return;
}
S.autoFolder = { kind: 'folder', path };
await renderAutomations();
} catch (e) { toast('Create failed', String(e), 'err'); }
}
window.addEventListener('DOMContentLoaded', () => {
$('#auto-new-folder')?.addEventListener('click', newAutomationFolder);
});
S.autoEditor = {
automation: null, mode: 'view', dirty: false, selectedNode: null, };
function openAutomationDetail(a) {
openAutomationEditor(a);
}
async function openAutomationEditor(card) {
$('.auto-explorer')?.classList.add('hide');
const ed = $('#auto-editor');
if (!ed) return;
ed.classList.remove('hide');
let full = null;
try {
full = await fetch('/api/automations/' + encodeURIComponent(card.unified_id || card.raw_id))
.then(r => r.ok ? r.json() : null);
} catch {}
if (!full) { full = projectAutomationFromCard(card); }
S.autoEditor.automation = full;
S.autoEditor.mode = 'view';
S.autoEditor.dirty = false;
S.autoEditor.selectedNode = null;
$('#auto-editor-name').textContent = full.name || full.id;
$('#auto-editor-source').textContent = `id: ${full.id}`;
$('#auto-editor-trigger').textContent = formatTriggerLabel(full.trigger);
applyAutomationEditorMode();
$('#auto-editor-run').onclick = (e) => { e.stopPropagation(); runAutomationFromEditor(); };
await renderAutomationEditor(full);
}
function projectAutomationFromCard(a) {
return {
id: a.unified_id || a.raw_id || a.id, name: a.name,
nodes: (a.agents || []).map((ag, i) => ({
id: ag || ('node' + i),
kind: { type: 'agent', agent_id: ag, input: { kind: 'from_trigger' } },
position: null,
})),
edges: [],
trigger: a.kind === 'manual' ? { kind: 'manual' }
: a.kind === 'schedule' ? { kind: 'schedule', every: (a.trigger_label || '').replace(/^every /, '') }
: a.kind === 'event' ? { kind: 'on_event', event: a.trigger_label }
: { kind: 'manual' },
};
}
function applyAutomationEditorMode() {
const mode = S.autoEditor.mode;
document.body.classList.toggle('studio-watch-mode', mode === 'view');
$$('#auto-editor [data-editor-mode]').forEach(b =>
b.classList.toggle('active', b.dataset.editorMode === mode)
);
$('#auto-editor-add').classList.toggle('hide', mode !== 'edit');
$('#auto-editor-trigger-edit').classList.toggle('hide', mode !== 'edit');
const status = $('#auto-editor-status');
if (status) {
status.textContent = mode === 'edit'
? (S.autoEditor.dirty ? 'Unsaved changes · auto-save on every edit'
: 'Edit mode · drag to arrange, drag dot → dot to wire, click a node to configure input')
: 'View mode · click ✎ Edit to add nodes, wire edges, and configure inputs.';
}
}
$$('#auto-editor [data-editor-mode]').forEach(b => b.addEventListener('click', () => {
S.autoEditor.mode = b.dataset.editorMode;
applyAutomationEditorMode();
}));
async function runAutomationFromEditor() {
const auto = S.autoEditor.automation; if (!auto) return;
openRunInputModal(auto);
}
S.runInput = { automation: null };
function openRunInputModal(automation) {
if (!automation || !automation.id) return;
S.runInput.automation = automation;
const modal = $('#run-input-modal');
$('#run-input-head').textContent = 'Run · ' + (automation.name || automation.id);
const form = $('#run-input-form');
form.innerHTML = '';
const textInputs = (automation.nodes || []).filter(n => n.kind && n.kind.type === 'text_input');
if (textInputs.length === 0) {
const anyFromTrigger = (automation.nodes || []).some(n =>
n.kind && n.kind.input && n.kind.input.kind === 'from_trigger'
);
if (!anyFromTrigger) {
submitRunInput({});
return;
}
const field = el('div', 'run-input-field');
field.appendChild(el('label', '', 'Trigger input'));
field.appendChild(el('div', 'small muted', 'This automation has no Text input nodes; this value feeds every node that reads "From trigger".'));
const ta = el('textarea', 'input');
ta.id = '__legacy_input__';
ta.placeholder = 'Type the prompt for this run…';
field.appendChild(ta);
form.appendChild(field);
} else {
textInputs.forEach(n => {
const field = el('div', 'run-input-field');
field.appendChild(el('label', '', n.kind.label || n.id));
const inp = n.kind.multiline ? el('textarea', 'input') : el('input', 'input');
if (!n.kind.multiline) inp.type = 'text';
inp.dataset.nodeId = n.id;
inp.placeholder = n.kind.placeholder || '';
if (n.kind.default_value) inp.value = n.kind.default_value;
field.appendChild(inp);
form.appendChild(field);
});
}
modal.classList.remove('hide');
setTimeout(() => {
const first = form.querySelector('input, textarea');
if (first) first.focus();
}, 50);
}
function closeRunInputModal() { $('#run-input-modal').classList.add('hide'); S.runInput.automation = null; }
async function submitRunInput(inputs) {
const auto = S.runInput.automation;
if (!auto) return;
const legacy = (inputs && inputs.__legacy__) || '';
const map = { ...inputs };
delete map.__legacy__;
try {
const r = await fetch('/api/automations/' + encodeURIComponent(auto.id) + '/run', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ input: legacy, inputs: map }),
});
if (r.ok) toast('Automation fired', auto.name || auto.id, 'ok');
else toast('Run failed', 'HTTP ' + r.status, 'err');
} catch (e) { toast('Run failed', String(e), 'err'); }
closeRunInputModal();
}
$('#run-input-cancel').addEventListener('click', closeRunInputModal);
$('#run-input-modal').addEventListener('click', e => { if (e.target.id === 'run-input-modal') closeRunInputModal(); });
$('#run-input-go').addEventListener('click', () => {
const form = $('#run-input-form');
const inputs = {};
const legacy = form.querySelector('#__legacy_input__');
if (legacy) inputs.__legacy__ = legacy.value || '';
form.querySelectorAll('[data-node-id]').forEach(el => {
inputs[el.dataset.nodeId] = el.value || '';
});
submitRunInput(inputs);
});
async function saveAutomation() {
const auto = S.autoEditor.automation; if (!auto) return;
try {
const r = await fetch('/api/automations/' + encodeURIComponent(auto.id), {
method: 'PATCH', headers: { 'content-type': 'application/json' },
body: JSON.stringify(auto),
});
if (!r.ok) {
const j = await r.json().catch(() => ({}));
toast('Save failed', j.error || ('HTTP ' + r.status), 'err');
return;
}
S.autoEditor.dirty = false;
applyAutomationEditorMode();
} catch (e) { toast('Save failed', String(e), 'err'); }
}
function markDirty() {
S.autoEditor.dirty = true;
applyAutomationEditorMode();
clearTimeout(S.autoEditor._saveTimer);
S.autoEditor._saveTimer = setTimeout(saveAutomation, 600);
}
function openAddNodePopover() {
const pop = $('#add-node-pop'); if (!pop) return;
pop.classList.remove('hide');
pop.style.top = '-9999px'; pop.style.left = '-9999px';
const btn = $('#auto-editor-add');
requestAnimationFrame(() => {
if (!btn) return;
const r = btn.getBoundingClientRect();
const popR = pop.getBoundingClientRect();
const vh = window.innerHeight, vw = window.innerWidth;
const fitsBelow = (r.bottom + 6 + popR.height) <= vh - 8;
const top = fitsBelow ? r.bottom + 6 : Math.max(8, r.top - 6 - popR.height);
let left = r.left;
if (left + popR.width > vw - 8) left = Math.max(8, vw - 8 - popR.width);
pop.style.top = top + 'px';
pop.style.left = left + 'px';
});
renderAddNodeList('');
const search = $('#add-node-search');
if (search) { search.value = ''; setTimeout(() => search.focus(), 0); }
setTimeout(() => document.addEventListener('mousedown', onAddNodeOutside, true), 0);
document.addEventListener('keydown', onAddNodeKey, true);
}
function closeAddNodePopover() {
$('#add-node-pop').classList.add('hide');
document.removeEventListener('mousedown', onAddNodeOutside, true);
document.removeEventListener('keydown', onAddNodeKey, true);
}
function onAddNodeOutside(e) {
const pop = $('#add-node-pop');
const btn = $('#auto-editor-add');
if (!pop || pop.contains(e.target) || (btn && btn.contains(e.target))) return;
closeAddNodePopover();
}
function onAddNodeKey(e) {
if (e.key === 'Escape') closeAddNodePopover();
}
S.addNodeTab = 'agent';
function renderAddNodeList(filter) {
const host = $('#add-node-list'); if (!host) return;
host.innerHTML = '';
const q = (filter || '').toLowerCase().trim();
if (S.addNodeTab === 'agent') renderAddNodeAgents(host, q);
else if (S.addNodeTab === 'tool') renderAddNodeTools(host, q);
else if (S.addNodeTab === 'conditional') renderAddNodeConditional(host, q);
else if (S.addNodeTab === 'flow') renderAddNodeFlow(host, q);
}
function renderAddNodeFlow(host, q) {
const templates = [
{ label: 'Text input',
desc: 'A typed input slot. For Manual triggers it surfaces as a form field at run time; for Schedule/Proactive it carries a saved default value.',
kind: 'text_input' },
{ label: 'Map (fan-out)',
desc: 'Run a body node once per item in a list. Like LangGraph\'s Send.',
kind: 'map' },
{ label: 'Subgraph',
desc: 'Call another automation as a single node. Recursive composition.',
kind: 'subgraph' },
{ label: 'Interrupt (human approval)',
desc: 'Pause until an operator resumes from the dashboard. Resume value becomes this node\'s output.',
kind: 'interrupt' },
].filter(m => !q || m.label.toLowerCase().includes(q) || m.desc.toLowerCase().includes(q));
if (!templates.length) { host.appendChild(el('div', 'add-node-empty', 'No flow nodes match.')); return; }
templates.forEach(m => {
const row = el('div', 'add-node-row');
row.appendChild(el('div', 'nm', m.label));
row.appendChild(el('div', 'meta', m.desc));
row.addEventListener('click', () => addNode(m.kind, { label: m.kind }));
host.appendChild(row);
});
}
function renderAddNodeAgents(host, q) {
const auto = S.autoEditor.automation;
const used = new Set((auto && auto.nodes || [])
.map(n => n.kind && n.kind.type === 'agent' && n.kind.agent_id)
.filter(Boolean));
const matches = (S.agents || []).filter(a => {
if (!q) return true;
return (a.id || '').toLowerCase().includes(q)
|| (a.name || '').toLowerCase().includes(q)
|| (a.team || '').toLowerCase().includes(q)
|| (a.model || '').toLowerCase().includes(q);
});
if (!matches.length) { host.appendChild(el('div', 'add-node-empty', 'No agents match.')); return; }
matches.forEach(a => {
const row = el('div', 'add-node-row');
const nm = el('div', 'nm', a.name || a.id);
if (used.has(a.id)) {
const tag = el('span', 'small muted', ' · already in this automation');
tag.style.fontWeight = 'normal';
nm.appendChild(tag);
}
row.appendChild(nm);
row.appendChild(el('div', 'meta', [a.id, a.team, a.model].filter(Boolean).join(' · ')));
row.addEventListener('click', () => addNode('agent', { agent_id: a.id, label: a.id }));
host.appendChild(row);
});
}
S.tools = null;
async function ensureToolsLoaded() {
if (S.tools) return S.tools;
try {
const j = await fetch('/api/tools').then(r => r.json());
S.tools = Array.isArray(j) ? j : (j.tools || []);
} catch { S.tools = []; }
return S.tools;
}
function renderAddNodeTools(host, q) {
host.innerHTML = '';
ensureToolsLoaded().then(tools => {
host.innerHTML = '';
const matches = (tools || []).filter(t => {
if (!q) return true;
const hay = (t.name || t.id || '') + ' ' + (t.description || '');
return hay.toLowerCase().includes(q);
});
if (!matches.length) { host.appendChild(el('div', 'add-node-empty', 'No tools match.')); return; }
matches.forEach(t => {
const tid = t.name || t.id;
const row = el('div', 'add-node-row');
row.appendChild(el('div', 'nm', tid));
if (t.description) row.appendChild(el('div', 'meta', t.description.slice(0, 80)));
row.addEventListener('click', () => addNode('tool', { tool_id: tid, label: tid }));
host.appendChild(row);
});
});
}
function renderAddNodeConditional(host, q) {
const matches = [
{
label: 'If/else router',
desc: 'Branches into two paths based on a predicate (contains / equals / regex / not-empty / always).',
kind: 'cond-2',
},
{
label: 'Multi-way switch',
desc: 'Three branches — pick which one to follow per match. Add more in the inspector.',
kind: 'cond-3',
},
].filter(m => !q || m.label.toLowerCase().includes(q));
if (!matches.length) { host.appendChild(el('div', 'add-node-empty', 'No router types match.')); return; }
matches.forEach(m => {
const row = el('div', 'add-node-row');
row.appendChild(el('div', 'nm', m.label));
row.appendChild(el('div', 'meta', m.desc));
row.addEventListener('click', () => addNode('conditional', { kind: m.kind, label: 'router' }));
host.appendChild(row);
});
}
function addNode(kind, opts) {
const auto = S.autoEditor.automation; if (!auto) return;
let nodeId = opts.label || kind;
let n = 2;
while (auto.nodes.some(x => x.id === nodeId)) nodeId = (opts.label || kind) + '-' + (n++);
const pos = computeDropPosition(auto);
let nodeKind;
if (kind === 'agent') {
nodeKind = { type: 'agent', agent_id: opts.agent_id, input: { kind: 'from_trigger' } };
} else if (kind === 'tool') {
nodeKind = { type: 'tool', tool_id: opts.tool_id, input: { kind: 'from_trigger' } };
} else if (kind === 'conditional') {
const branches = (opts.kind === 'cond-3')
? [
{ name: 'a', when: { op: 'contains', value: '' } },
{ name: 'b', when: { op: 'contains', value: '' } },
{ name: 'c', when: { op: 'always' } },
]
: [
{ name: 'true', when: { op: 'not_empty' } },
{ name: 'false', when: { op: 'always' } },
];
nodeKind = {
type: 'conditional',
input: { kind: 'from_trigger' },
branches,
default: null,
};
} else if (kind === 'map') {
nodeKind = {
type: 'map',
input: { kind: 'from_trigger' },
body_node: '', };
} else if (kind === 'subgraph') {
nodeKind = {
type: 'subgraph',
automation_id: '',
input: { kind: 'from_trigger' },
};
} else if (kind === 'interrupt') {
nodeKind = {
type: 'interrupt',
input: { kind: 'literal', value: 'Please review and provide input to continue.' },
resume_strategy: 'replace',
};
} else if (kind === 'text_input') {
nodeKind = {
type: 'text_input',
label: 'Input',
default_value: null,
placeholder: null,
multiline: false,
};
}
auto.nodes.push({ id: nodeId, kind: nodeKind, position: pos });
markDirty();
closeAddNodePopover();
renderAutomationEditor(auto);
toast('Node added', nodeId, 'ok');
}
$$('.add-node-tab').forEach(b => b.addEventListener('click', () => {
S.addNodeTab = b.dataset.addTab;
$$('.add-node-tab').forEach(x => x.classList.toggle('active', x === b));
$('#add-node-search').placeholder =
S.addNodeTab === 'agent' ? 'Search agents by id, team, or model…'
: S.addNodeTab === 'tool' ? 'Search tools…'
: S.addNodeTab === 'flow' ? 'Search flow nodes (map / subgraph / interrupt)…'
: 'Search router templates…';
renderAddNodeList($('#add-node-search').value);
}));
function computeDropPosition(auto) {
const placed = (auto.nodes || []).filter(n => n.position);
if (!placed.length) return { x: 0, y: 0 };
const maxX = Math.max(...placed.map(n => n.position.x));
const avgY = placed.reduce((s, n) => s + n.position.y, 0) / placed.length;
return { x: maxX + 220, y: avgY };
}
$('#auto-editor-add').addEventListener('click', openAddNodePopover);
$('#add-node-close').addEventListener('click', closeAddNodePopover);
$('#add-node-search').addEventListener('input', (e) => renderAddNodeList(e.target.value || ''));
function openTriggerModal() {
const auto = S.autoEditor.automation; if (!auto) return;
const modal = $('#trigger-modal'); if (!modal) return;
const t = auto.trigger || { kind: 'manual' };
$('#trigger-kind').value = t.kind;
$('#trigger-every').value = (t.kind === 'schedule' ? (t.every || '1h') : '1h');
$('#trigger-event').value = (t.kind === 'on_event' ? (t.event || '') : '');
const ginput = (t.kind === 'schedule' || t.kind === 'on_event') ? (t.input || '') : '';
$('#trigger-input').value = ginput;
const gsel = $('#trigger-skill');
gsel.innerHTML = '';
(S.skills || []).forEach(g => {
const o = el('option', '', g.name || g.id); o.value = g.id;
if (t.kind === 'on_skill' && t.skill_id === g.id) o.selected = true;
gsel.appendChild(o);
});
applyTriggerFormVis();
modal.classList.remove('hide');
}
function closeTriggerModal() { $('#trigger-modal').classList.add('hide'); }
function applyTriggerFormVis() {
const k = $('#trigger-kind').value;
$('#trigger-modal .trigger-schedule').classList.toggle('hide', k !== 'schedule');
$('#trigger-modal .trigger-event').classList.toggle('hide', k !== 'on_event');
$('#trigger-modal .trigger-skill').classList.toggle('hide', k !== 'on_skill');
$('#trigger-modal .trigger-input').classList.toggle('hide', !(k === 'schedule' || k === 'on_event'));
}
function saveTriggerForm() {
const auto = S.autoEditor.automation; if (!auto) return;
const k = $('#trigger-kind').value;
if (k === 'manual') { auto.trigger = { kind: 'manual' }; }
else if (k === 'schedule') {
const every = ($('#trigger-every').value || '').trim();
if (!every) { toast('Cadence required', 'e.g. 30s, 5m, 1h, 1d', 'err'); return; }
const input = ($('#trigger-input').value || '').trim();
auto.trigger = { kind: 'schedule', every, input: input || null };
} else if (k === 'on_event') {
const event = ($('#trigger-event').value || '').trim();
if (!event) { toast('Event name required', '', 'err'); return; }
const input = ($('#trigger-input').value || '').trim();
auto.trigger = { kind: 'on_event', event, input: input || null };
} else if (k === 'on_skill') {
const skill_id = $('#trigger-skill').value;
if (!skill_id) { toast('Pick a skill', '', 'err'); return; }
auto.trigger = { kind: 'on_skill', skill_id };
}
$('#auto-editor-trigger').textContent = formatTriggerLabel(auto.trigger);
markDirty();
closeTriggerModal();
}
$('#auto-editor-trigger-edit').addEventListener('click', openTriggerModal);
$('#trigger-cancel').addEventListener('click', closeTriggerModal);
$('#trigger-save').addEventListener('click', saveTriggerForm);
$('#trigger-kind').addEventListener('change', applyTriggerFormVis);
$('#trigger-modal').addEventListener('click', e => { if (e.target.id === 'trigger-modal') closeTriggerModal(); });
function formatTriggerLabel(t) {
if (!t || !t.kind) return 'manual';
if (t.kind === 'manual') return '▶ manual';
if (t.kind === 'schedule') return '⏱ every ' + (t.every || '?') + (t.input ? ' · input: ' + t.input.slice(0, 40) : '');
if (t.kind === 'on_event') return '⊛ on ' + (t.event || '?');
if (t.kind === 'on_skill') return '◆ on skill ' + (t.skill_id || '?');
return t.kind;
}
const AUTO_VIEWPORT_KEY = 'axo.auto.viewport.v1';
function loadAutoViewport(id) {
try {
const all = JSON.parse(localStorage.getItem(AUTO_VIEWPORT_KEY) || '{}');
return all[id] || null;
} catch { return null; }
}
function saveAutoViewport(id, vp) {
try {
const all = JSON.parse(localStorage.getItem(AUTO_VIEWPORT_KEY) || '{}');
all[id] = vp;
localStorage.setItem(AUTO_VIEWPORT_KEY, JSON.stringify(all));
} catch {}
}
let _autoEditorWired = false;
let _autoEditorVpTimer = null;
async function renderAutomationEditor(auto) {
await ensureStudioLattice();
const lat = $('#auto-editor-lattice');
if (!lat) return;
if (!_autoEditorWired) {
_autoEditorWired = true;
lat.addEventListener('selection-change', (ev) => {
const ids = ev.detail.ids || [];
if (ids.length === 1 && ids[0].startsWith('autonode-')) {
const nodeId = ids[0].slice('autonode-'.length);
openNodeInspector(nodeId);
} else {
closeNodeInspector();
}
});
lat.addEventListener('node-moveend', () => syncAutomationPositions());
lat.addEventListener('edge-connect', (ev) => {
const { from, to } = ev.detail || {};
if (!from || !to) return;
const a = (from.match(/^autonode-(.+?):out$/) || [])[1];
const b = (to.match(/^autonode-(.+?):in$/) || [])[1];
if (!a || !b) return;
const auto = S.autoEditor.automation;
if (!auto.edges.find(e => e.from === a && e.to === b)) {
auto.edges.push({ from: a, to: b });
markDirty();
}
});
lat.addEventListener('viewport-change', (ev) => {
const id = S.autoEditor.automation && S.autoEditor.automation.id;
if (!id) return;
clearTimeout(_autoEditorVpTimer);
_autoEditorVpTimer = setTimeout(() => saveAutoViewport(id, ev.detail), 150);
});
}
while (lat.firstChild) lat.removeChild(lat.firstChild);
(auto.nodes || []).forEach(n => {
const node = document.createElement('ax-node');
node.id = 'autonode-' + n.id;
node.setAttribute('data-node-kind', (n.kind && n.kind.type) || 'agent');
if (n.position) {
node.setAttribute('data-x', String(n.position.x));
node.setAttribute('data-y', String(n.position.y));
}
const kind = n.kind || {};
let title = n.id;
let sub = '';
let inputLabel = '';
if (kind.type === 'agent') {
const ac = (S.agents || []).find(a => a.id === kind.agent_id) || {};
sub = '🧠 agent · ' + (kind.agent_id || '?') + (ac.model ? ' · ' + ac.model : '');
inputLabel = formatNodeInputLabel(kind.input);
} else if (kind.type === 'tool') {
sub = '🛠 tool · ' + (kind.tool_id || '?');
inputLabel = formatNodeInputLabel(kind.input);
} else if (kind.type === 'conditional') {
const nb = (kind.branches || []).length;
sub = '⟀ router · ' + nb + ' branch' + (nb === 1 ? '' : 'es');
inputLabel = formatNodeInputLabel(kind.input);
} else if (kind.type === 'map') {
sub = '⇄ map · body: ' + (kind.body_node || '(unset)');
inputLabel = formatNodeInputLabel(kind.input);
} else if (kind.type === 'subgraph') {
sub = '⊞ subgraph · ' + (kind.automation_id || '(unset)');
inputLabel = formatNodeInputLabel(kind.input);
} else if (kind.type === 'interrupt') {
sub = '⏸ interrupt · ' + (kind.resume_strategy || 'replace');
inputLabel = formatNodeInputLabel(kind.input);
} else if (kind.type === 'text_input') {
sub = '✎ input · ' + (kind.label || 'untitled');
inputLabel = kind.default_value
? ('default: "' + (kind.default_value).slice(0, 32) + '"')
: '(no default)';
}
node.append(
mkDiv('sn-title', title),
mkDiv('sn-sub', sub),
mkDiv('sn-input', inputLabel),
mkHandle('target', 'in', 'left'),
mkHandle('source', 'out', 'right'),
);
lat.appendChild(node);
});
(auto.edges || []).forEach(e => {
const edge = document.createElement('ax-edge');
edge.setAttribute('from', 'autonode-' + e.from + ':out');
edge.setAttribute('to', 'autonode-' + e.to + ':in');
if (e.label) edge.setAttribute('label', e.label);
lat.appendChild(edge);
});
requestAnimationFrame(() => {
const hasPositions = (auto.nodes || []).some(n => n.position);
try {
if (!hasPositions) lat.autoLayout({ direction: 'LR' });
const vp = loadAutoViewport(auto.id);
if (vp && typeof vp.k === 'number') {
lat.setViewport({ x: vp.x ?? 0, y: vp.y ?? 0, k: vp.k });
} else {
lat.fitView();
}
lat.clearHistory();
} catch {}
});
}
function syncAutomationPositions() {
const auto = S.autoEditor.automation; if (!auto) return;
const lat = $('#auto-editor-lattice'); if (!lat) return;
let changed = false;
(auto.nodes || []).forEach(n => {
const dom = document.getElementById('autonode-' + n.id);
if (!dom) return;
const x = parseFloat(dom.getAttribute('data-x')) || 0;
const y = parseFloat(dom.getAttribute('data-y')) || 0;
if (!n.position || n.position.x !== x || n.position.y !== y) {
n.position = { x, y };
changed = true;
}
});
if (changed) markDirty();
}
function openNodeInspector(nodeId) {
S.autoEditor.selectedNode = nodeId;
const auto = S.autoEditor.automation; if (!auto) return;
const node = auto.nodes.find(n => n.id === nodeId); if (!node) return;
const kind = node.kind || {};
const panel = $('#auto-editor-inspector');
panel.classList.remove('hide');
panel.innerHTML = '';
const head = el('div');
head.style.cssText = 'display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;';
head.appendChild(el('strong', '', 'Node: ' + node.id));
const close = el('button', 'btn ghost sm', '×');
close.style.cssText = 'padding:1px 8px';
close.addEventListener('click', closeNodeInspector);
head.appendChild(close);
panel.appendChild(head);
panel.appendChild(el('div', 'small muted',
kind.type === 'agent' ? '🧠 Agent node — runs an LLM'
: kind.type === 'tool' ? '🛠 Tool node — calls a builtin'
: kind.type === 'conditional' ? '⟀ Router — branches downstream'
: kind.type === 'map' ? '⇄ Map — fan-out over a list'
: kind.type === 'subgraph' ? '⊞ Subgraph — call another automation'
: kind.type === 'interrupt' ? '⏸ Interrupt — pause for human approval'
: kind.type === 'text_input' ? '✎ Text input — a value supplied at run time (or saved as default)'
: '?'));
const sep = el('div'); sep.style.cssText = 'height:1px;background:var(--border);margin:10px -4px;';
panel.appendChild(sep);
if (kind.type === 'agent') {
panel.appendChild(el('div', 'small muted', 'Agent'));
const sel = el('select', 'select');
sel.style.cssText = 'width:100%; margin-bottom:10px;';
(S.agents || []).forEach(a => {
const o = el('option', '', a.id); o.value = a.id;
if (a.id === kind.agent_id) o.selected = true;
sel.appendChild(o);
});
sel.addEventListener('change', () => {
node.kind.agent_id = sel.value;
markDirty();
renderAutomationEditor(auto);
});
panel.appendChild(sel);
} else if (kind.type === 'tool') {
panel.appendChild(el('div', 'small muted', 'Tool'));
const sel = el('select', 'select');
sel.style.cssText = 'width:100%; margin-bottom:10px;';
ensureToolsLoaded().then(tools => {
(tools || []).forEach(t => {
const tid = t.name || t.id;
const o = el('option', '', tid); o.value = tid;
if (tid === kind.tool_id) o.selected = true;
sel.appendChild(o);
});
});
sel.addEventListener('change', () => {
node.kind.tool_id = sel.value;
markDirty();
renderAutomationEditor(auto);
});
panel.appendChild(sel);
} else if (kind.type === 'map') {
panel.appendChild(el('div', 'small muted', 'Body node — runs once per item'));
const sel = el('select', 'select');
sel.style.cssText = 'width:100%; margin-bottom:10px;';
const none = el('option', '', '(none — Map does nothing)'); none.value = '';
sel.appendChild(none);
(auto.nodes || []).forEach(n => {
if (n.id === node.id) return;
const o = el('option', '', n.id + ' · ' + (n.kind && n.kind.type));
o.value = n.id;
if (n.id === kind.body_node) o.selected = true;
sel.appendChild(o);
});
if (!kind.body_node) sel.value = '';
sel.addEventListener('change', () => {
node.kind.body_node = sel.value;
markDirty();
renderAutomationEditor(auto);
});
panel.appendChild(sel);
const help = el('div', 'small muted');
help.style.cssText = 'font-size:10.5px; margin-bottom:10px;';
help.textContent = 'In the body node\'s input, use "From map item" to read the current item.';
panel.appendChild(help);
} else if (kind.type === 'subgraph') {
panel.appendChild(el('div', 'small muted', 'Automation to call'));
const sel = el('select', 'select');
sel.style.cssText = 'width:100%; margin-bottom:10px;';
const none = el('option', '', '(pick one)'); none.value = '';
sel.appendChild(none);
(S.automations || []).forEach(a => {
if (a.id === auto.id) return; const o = el('option', '', a.name + ' · ' + a.id);
o.value = a.id;
if (a.id === kind.automation_id) o.selected = true;
sel.appendChild(o);
});
if (!kind.automation_id) sel.value = '';
sel.addEventListener('change', () => {
node.kind.automation_id = sel.value;
markDirty();
renderAutomationEditor(auto);
});
panel.appendChild(sel);
} else if (kind.type === 'interrupt') {
panel.appendChild(el('div', 'small muted', 'Resume strategy'));
const sel = el('select', 'select');
sel.style.cssText = 'width:100%; margin-bottom:10px;';
[['replace','Replace — operator value becomes the node\'s output'],
['append','Append — operator value appended after message']].forEach(([v, l]) => {
const o = el('option', '', l); o.value = v;
if (v === (kind.resume_strategy || 'replace')) o.selected = true;
sel.appendChild(o);
});
sel.addEventListener('change', () => {
node.kind.resume_strategy = sel.value;
markDirty();
renderAutomationEditor(auto);
});
panel.appendChild(sel);
} else if (kind.type === 'text_input') {
panel.appendChild(el('div', 'small muted', 'Label'));
const labInp = el('input', 'input');
labInp.type = 'text'; labInp.value = kind.label || '';
labInp.placeholder = 'Field name shown to the operator';
labInp.style.cssText = 'width:100%; margin-bottom:10px;';
labInp.addEventListener('input', () => {
node.kind.label = labInp.value;
markDirty();
refreshNodeLabel(node);
const dom = document.getElementById('autonode-' + node.id);
if (dom) {
const subEl = dom.querySelector('.sn-sub');
if (subEl) subEl.textContent = '✎ input · ' + (labInp.value || 'untitled');
}
});
panel.appendChild(labInp);
panel.appendChild(el('div', 'small muted', 'Default value (used by Schedule / Proactive triggers)'));
const defVal = el('textarea', 'input');
defVal.style.cssText = 'width:100%; min-height:80px; font-family:ui-monospace,monospace; font-size:12px; margin-bottom:10px;';
defVal.value = kind.default_value || '';
defVal.placeholder = 'Empty = the field is required at run time for Manual triggers';
defVal.addEventListener('input', () => {
node.kind.default_value = defVal.value || null;
markDirty();
refreshNodeLabel(node);
});
panel.appendChild(defVal);
panel.appendChild(el('div', 'small muted', 'Placeholder (UI hint)'));
const phInp = el('input', 'input');
phInp.type = 'text'; phInp.value = kind.placeholder || '';
phInp.placeholder = 'e.g. "Bug report text…"';
phInp.style.cssText = 'width:100%; margin-bottom:10px;';
phInp.addEventListener('input', () => {
node.kind.placeholder = phInp.value || null;
markDirty();
});
panel.appendChild(phInp);
const mlRow = el('label', '');
mlRow.style.cssText = 'display:flex; align-items:center; gap:6px; font-size:12px; margin-bottom:10px;';
const ml = document.createElement('input');
ml.type = 'checkbox'; ml.checked = !!kind.multiline;
ml.addEventListener('change', () => {
node.kind.multiline = ml.checked;
markDirty();
});
mlRow.appendChild(ml);
mlRow.appendChild(el('span', '', 'Multi-line (textarea)'));
panel.appendChild(mlRow);
}
if (kind.type === 'text_input') {
const footer = el('div');
footer.style.cssText = 'border-top:1px solid var(--border); margin-top:12px; padding-top:10px;';
const del = el('button', 'btn ghost sm', '🗑 Delete node');
del.style.color = 'var(--err)';
del.addEventListener('click', () => {
auto.nodes = auto.nodes.filter(n => n.id !== node.id);
auto.edges = auto.edges.filter(e => e.from !== node.id && e.to !== node.id);
markDirty();
closeNodeInspector();
renderAutomationEditor(auto);
});
footer.appendChild(del);
panel.appendChild(footer);
return;
}
panel.appendChild(el('div', 'small muted', kind.type === 'tool' ? 'Args input source' : 'Input source'));
const input = kind.input;
const inSel = el('select', 'select');
inSel.style.cssText = 'width:100%; margin-bottom:10px;';
[
['from_trigger', '⇡ From trigger (default)'],
['literal', '« Literal — fixed string' + (kind.type === 'tool' ? ' / JSON' : '')],
['from_upstream', '← From upstream nodes'],
['template', '✎ Template — mix literal + placeholders'],
['from_map_item', '⇄ From map item (only inside a Map body)'],
].forEach(([v, label]) => {
const o = el('option', '', label); o.value = v;
if (input && input.kind === v) o.selected = true;
inSel.appendChild(o);
});
panel.appendChild(inSel);
const body = el('div');
panel.appendChild(body);
function renderInputBody(k) {
body.innerHTML = '';
if (k === 'literal') {
const t = el('textarea', 'input');
t.style.cssText = 'width:100%; min-height:80px; font-family:ui-monospace,monospace; font-size:12px;';
t.value = (input && input.kind === 'literal') ? input.value : '';
t.placeholder = kind.type === 'tool'
? 'Tool args (JSON object) — e.g. {"path": "README.md"}'
: '';
t.addEventListener('input', () => {
node.kind.input = { kind: 'literal', value: t.value };
markDirty();
refreshNodeLabel(node);
});
body.appendChild(t);
} else if (k === 'from_upstream') {
const others = (auto.nodes || []).filter(n => n.id !== node.id);
const help = el('div', 'small muted', 'Outputs from selected nodes are joined with blank lines.');
help.style.marginBottom = '6px';
body.appendChild(help);
others.forEach(o => {
const lab = el('label', '');
lab.style.cssText = 'display:flex; align-items:center; gap:6px; font-size:12px; margin:3px 0;';
const cb = document.createElement('input');
cb.type = 'checkbox';
const current = (input && input.kind === 'from_upstream') ? (input.nodes || []) : [];
cb.checked = current.includes(o.id);
cb.addEventListener('change', () => {
const sel = (node.kind.input && node.kind.input.kind === 'from_upstream' && node.kind.input.nodes) || [];
const set = new Set(sel);
if (cb.checked) set.add(o.id); else set.delete(o.id);
node.kind.input = { kind: 'from_upstream', nodes: Array.from(set) };
markDirty();
refreshNodeLabel(node);
});
lab.appendChild(cb);
lab.appendChild(el('span', 'mono', o.id));
body.appendChild(lab);
});
} else if (k === 'template') {
const ta = el('textarea', 'input');
ta.style.cssText = 'width:100%; min-height:100px; font-family:ui-monospace,monospace; font-size:12px;';
ta.placeholder = 'Trigger input is {{trigger}}.\nUpstream nodes are {{node:planner}} etc.';
ta.value = (input && input.kind === 'template') ? input.template : '';
ta.addEventListener('input', () => {
node.kind.input = { kind: 'template', template: ta.value };
markDirty();
refreshNodeLabel(node);
});
body.appendChild(ta);
} else {
body.appendChild(el('div', 'small muted', 'This node uses the trigger\'s input unchanged.'));
}
}
renderInputBody(input ? input.kind : 'from_trigger');
inSel.addEventListener('change', () => {
const k = inSel.value;
if (k === 'from_trigger') node.kind.input = { kind: 'from_trigger' };
if (k === 'literal') node.kind.input = { kind: 'literal', value: '' };
if (k === 'from_upstream') node.kind.input = { kind: 'from_upstream', nodes: [] };
if (k === 'template') node.kind.input = { kind: 'template', template: '' };
if (k === 'from_map_item') node.kind.input = { kind: 'from_map_item' };
markDirty();
renderInputBody(k);
refreshNodeLabel(node);
});
if (kind.type === 'conditional') {
panel.appendChild(el('div')).style.cssText = 'height:1px;background:var(--border);margin:10px -4px;';
panel.appendChild(el('div', 'small muted', 'Branches'));
const list = el('div');
list.style.cssText = 'display:flex; flex-direction:column; gap:8px; margin-bottom:10px;';
panel.appendChild(list);
function renderBranches() {
list.innerHTML = '';
(kind.branches || []).forEach((b, i) => {
const row = el('div');
row.style.cssText = 'display:flex; gap:6px; align-items:center;';
const nm = el('input', 'input');
nm.type = 'text'; nm.value = b.name;
nm.style.cssText = 'flex:0 0 80px; font-family:ui-monospace,monospace;';
nm.addEventListener('input', () => { b.name = nm.value; markDirty(); refreshNodeLabel(node); });
row.appendChild(nm);
const op = el('select', 'select');
op.style.cssText = 'flex:0 0 110px;';
[
['always','always'], ['equals','equals'], ['contains','contains'],
['matches','regex'], ['not_empty','not empty'],
].forEach(([v, l]) => {
const o = el('option', '', l); o.value = v;
if (b.when && b.when.op === v) o.selected = true;
op.appendChild(o);
});
op.addEventListener('change', () => {
const v = op.value;
if (v === 'always' || v === 'not_empty') b.when = { op: v };
else if (v === 'matches') b.when = { op: 'matches', pattern: (b.when && b.when.pattern) || '' };
else b.when = { op: v, value: (b.when && b.when.value) || '' };
markDirty();
renderBranches();
});
row.appendChild(op);
const needsValue = b.when && (b.when.op === 'equals' || b.when.op === 'contains');
const needsPattern = b.when && b.when.op === 'matches';
if (needsValue || needsPattern) {
const v = el('input', 'input');
v.type = 'text';
v.value = needsPattern ? (b.when.pattern || '') : (b.when.value || '');
v.style.cssText = 'flex:1; font-family:ui-monospace,monospace;';
v.placeholder = needsPattern ? 'regex pattern' : 'value';
v.addEventListener('input', () => {
if (needsPattern) b.when.pattern = v.value;
else b.when.value = v.value;
markDirty();
});
row.appendChild(v);
} else {
row.appendChild(el('span')); }
const rm = el('button', 'btn ghost sm', '×');
rm.style.cssText = 'padding:1px 8px; flex:0 0 auto;';
rm.addEventListener('click', () => {
kind.branches.splice(i, 1);
markDirty();
renderBranches();
refreshNodeLabel(node);
});
row.appendChild(rm);
list.appendChild(row);
});
const add = el('button', 'btn ghost sm', '+ branch');
add.style.cssText = 'align-self:flex-start';
add.addEventListener('click', () => {
kind.branches = kind.branches || [];
kind.branches.push({ name: 'branch' + (kind.branches.length + 1), when: { op: 'always' } });
markDirty();
renderBranches();
refreshNodeLabel(node);
});
list.appendChild(add);
}
renderBranches();
panel.appendChild(el('div', 'small muted', 'Default branch (no match)'));
const defSel = el('select', 'select');
defSel.style.cssText = 'width:100%; margin-bottom:10px;';
const none = el('option', '', '(no default — halt downstream)'); none.value = '';
defSel.appendChild(none);
(kind.branches || []).forEach(b => {
const o = el('option', '', b.name); o.value = b.name;
if (kind.default === b.name) o.selected = true;
defSel.appendChild(o);
});
if (!kind.default) defSel.value = '';
defSel.addEventListener('change', () => {
node.kind.default = defSel.value || null;
markDirty();
});
panel.appendChild(defSel);
const outgoing = (auto.edges || []).filter(e => e.from === node.id);
if (outgoing.length) {
panel.appendChild(el('div', 'small muted', 'Outgoing edges'));
const edgeList = el('div');
edgeList.style.cssText = 'display:flex; flex-direction:column; gap:5px; margin-bottom:10px;';
panel.appendChild(edgeList);
outgoing.forEach(e => {
const row = el('div');
row.style.cssText = 'display:flex; gap:6px; align-items:center; font-size:11.5px;';
row.appendChild(el('span', 'mono small', '→ ' + e.to));
row.appendChild(el('span', 'grow'));
const s = el('select', 'select');
s.style.cssText = 'flex:0 0 110px; font-size:11px; padding:3px 6px;';
const noLab = el('option', '', '(no label)'); noLab.value = '';
s.appendChild(noLab);
(kind.branches || []).forEach(b => {
const o = el('option', '', b.name); o.value = b.name;
if (e.label === b.name) o.selected = true;
s.appendChild(o);
});
if (!e.label) s.value = '';
s.addEventListener('change', () => {
e.label = s.value || null;
markDirty();
});
row.appendChild(s);
edgeList.appendChild(row);
});
}
}
const footer = el('div');
footer.style.cssText = 'border-top:1px solid var(--border); margin-top:12px; padding-top:10px;';
const del = el('button', 'btn ghost sm', '🗑 Delete node');
del.style.color = 'var(--err)';
del.addEventListener('click', () => {
auto.nodes = auto.nodes.filter(n => n.id !== node.id);
auto.edges = auto.edges.filter(e => e.from !== node.id && e.to !== node.id);
markDirty();
closeNodeInspector();
renderAutomationEditor(auto);
});
footer.appendChild(del);
panel.appendChild(footer);
}
function closeNodeInspector() {
S.autoEditor.selectedNode = null;
$('#auto-editor-inspector').classList.add('hide');
}
function refreshNodeLabel(node) {
const dom = document.getElementById('autonode-' + node.id); if (!dom) return;
const cap = dom.querySelector('.sn-input'); if (!cap) return;
const input = node.kind && node.kind.input;
cap.textContent = formatNodeInputLabel(input);
}
function formatNodeInputLabel(input) {
if (!input) return '';
if (input.kind === 'from_trigger') return 'input: ⇡ from trigger';
if (input.kind === 'literal') return 'input: "' + (input.value || '').slice(0, 40) + '"';
if (input.kind === 'from_upstream') return 'input: ← ' + (input.nodes || []).join(', ');
if (input.kind === 'template') return 'input: tpl …';
if (input.kind === 'from_map_item') return 'input: ⇄ map item';
return '';
}
$('#auto-editor-runs').addEventListener('click', toggleRunsPanel);
$('#auto-runs-close').addEventListener('click', () => $('#auto-editor-runs-panel').classList.add('hide'));
async function toggleRunsPanel() {
const panel = $('#auto-editor-runs-panel');
if (panel.classList.contains('hide')) {
panel.classList.remove('hide');
await refreshRunsPanel();
} else {
panel.classList.add('hide');
}
}
async function refreshRunsPanel() {
const auto = S.autoEditor.automation; if (!auto) return;
const host = $('#auto-runs-list'); if (!host) return;
host.innerHTML = '<div class="small muted" style="padding:8px;">Loading…</div>';
let runs = [];
try {
runs = await fetch('/api/automations/' + encodeURIComponent(auto.id) + '/runs').then(r => r.json());
} catch {}
host.innerHTML = '';
if (!Array.isArray(runs) || !runs.length) {
host.appendChild(el('div', 'small muted', 'No runs yet. Click ▶ Run to start one.'));
return;
}
runs.forEach(run => host.appendChild(renderRunCard(run, auto.id)));
}
function renderRunCard(run, automationId) {
const card = el('div', 'auto-run');
const head = el('div', 'auto-run-head');
const left = el('div');
left.style.cssText = 'display:flex; flex-direction:column; gap:2px;';
left.appendChild(el('div', 'auto-run-id', run.run_id.slice(0, 8) + ' · ' + relativeTime(run.started_at_unix)));
if (run.forked_from) {
left.appendChild(el('div', 'small muted',
'forked from ' + run.forked_from.source_run_id.slice(0, 8) + ' @ step ' + run.forked_from.from_step));
}
head.appendChild(left);
head.appendChild(el('span', 'auto-run-status ' + (run.status || 'running'), run.status || 'running'));
card.appendChild(head);
const steps = el('div', 'auto-run-steps');
card.appendChild(steps);
head.addEventListener('click', () => {
card.classList.toggle('open');
if (card.classList.contains('open') && !steps.dataset.built) {
renderRunSteps(steps, run, automationId);
steps.dataset.built = '1';
}
});
return card;
}
function renderRunSteps(host, run, automationId) {
if (!run.checkpoints || !run.checkpoints.length) {
host.appendChild(el('div', 'small muted', 'No checkpoints recorded.'));
return;
}
run.checkpoints.forEach((cp, idx) => {
const row = el('div', 'auto-run-step');
const icoChar =
cp.event === 'node_completed' ? '✓'
: cp.event === 'node_failed' ? '✗'
: cp.event === 'interrupt_parked' ? '⏸'
: cp.event === 'interrupt_resumed' ? '▶'
: '·';
row.appendChild(el('span', 'ico', icoChar));
row.appendChild(el('span', 'lbl', cp.node_id + ' · ' + cp.event.replace(/_/g, ' ')));
const fork = el('span', 'fork', '⑃ fork');
fork.title = 'Spawn a new run from here using the same trigger input';
fork.addEventListener('click', (e) => {
e.stopPropagation();
forkFromRun(automationId, run.run_id, idx);
});
row.appendChild(fork);
host.appendChild(row);
});
}
async function forkFromRun(automationId, runId, fromStep) {
try {
const url = `/api/automations/${encodeURIComponent(automationId)}/runs/${encodeURIComponent(runId)}/fork`;
const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
if (!r.ok) { toast('Fork failed', 'HTTP ' + r.status, 'err'); return; }
toast('Forked', 'Started a new run from ' + runId.slice(0,8) + ' @ step ' + fromStep, 'ok');
setTimeout(refreshRunsPanel, 500);
} catch (e) { toast('Fork failed', String(e), 'err'); }
}
$('#auto-editor-back').addEventListener('click', () => {
$('#auto-editor').classList.add('hide');
$('.auto-explorer')?.classList.remove('hide');
});
$$('.auto-pill').forEach(b => b.addEventListener('click', () => {
S.autoFilter = b.dataset.autoFilter;
$$('.auto-pill').forEach(x => x.classList.toggle('active', x === b));
renderAutoCards();
}));
function updateAutomationsCount() {
const total =
(Array.isArray(S.workflows) ? S.workflows.length : 0) +
(Array.isArray(S.proactive) ? S.proactive.length : 0) +
(Array.isArray(S.schedules) ? S.schedules.length : 0);
const cnt = $('#cnt-automations');
if (cnt) cnt.textContent = total || '—';
}
S.obsFeed = []; const OBS_FEED_MAX = 80;
S.studioMode = localStorage.getItem('axo.studio.mode') || 'watch';
function applyStudioMode() {
document.body.classList.toggle('studio-watch-mode', S.studioMode === 'watch');
$$('.studio-mode-btn').forEach(b => b.classList.toggle('active', b.dataset.studioMode === S.studioMode));
const help = $('#studio-help-inline');
if (help) help.textContent = S.studioMode === 'edit'
? 'Edit mode · drag nodes to arrange, drag dot → dot to wire. Wire persistence is v0.2.'
: 'Watch mode · click any node to inspect. Switch to Edit to rearrange.';
}
$$('.studio-mode-btn').forEach(b => b.addEventListener('click', () => {
S.studioMode = b.dataset.studioMode;
localStorage.setItem('axo.studio.mode', S.studioMode);
applyStudioMode();
}));
function pushObsEvent(ev) {
S.obsFeed.unshift(ev);
if (S.obsFeed.length > OBS_FEED_MAX) S.obsFeed.pop();
if (!$('#tab-studio').classList.contains('hide')) refreshStudioFeed();
}
function refreshStudioFeed() {
const host = $('#studio-feed'); if (!host) return;
host.innerHTML = '';
if (!S.obsFeed.length) {
host.appendChild(el('div', 'shell-side-empty', 'Quiet — nothing on the lattice.'));
return;
}
S.obsFeed.slice(0, 20).forEach(ev => {
const row = el('div', 'shell-obs-event ' + (ev.cls || ''));
row.appendChild(el('span', 'when', ev.when + ' '));
if (ev.who) row.appendChild(el('span', 'who', ev.who + ' '));
row.appendChild(el('span', 'what', ev.what || ''));
host.appendChild(row);
});
}
const obsClear = $('#studio-clear-feed');
if (obsClear) obsClear.addEventListener('click', () => { S.obsFeed = []; refreshStudioFeed(); });
S.studioActive = new Set(); S.studioCompleted = new Set();
function studioPulse(agentId, kind) {
pulseNodeById('agent-' + agentId, kind);
if (kind === 'activated') S.studioActive.add(agentId);
if (kind === 'completed') {
S.studioActive.delete(agentId);
S.studioCompleted.add(agentId);
setTimeout(() => {
S.studioCompleted.delete(agentId);
if (typeof renderStudioSidebar === 'function' && !$('#tab-studio').classList.contains('hide')) renderStudioSidebar();
}, 3000);
}
if (typeof renderStudioSidebar === 'function' && !$('#tab-studio').classList.contains('hide')) renderStudioSidebar();
}
function editorPulse(automationId, nodeId, kind) {
if (!S.autoEditor || !S.autoEditor.automation) return;
if (S.autoEditor.automation.id !== automationId) return;
pulseNodeById('autonode-' + nodeId, kind);
}
function pulseNodeById(domId, kind) {
const node = document.getElementById(domId);
if (!node) return;
node.classList.remove('axo-pulse');
void node.offsetWidth; node.classList.add('axo-pulse');
if (kind === 'activated') {
node.classList.add('axo-active');
node.classList.remove('axo-completed', 'axo-paused');
} else if (kind === 'completed') {
node.classList.remove('axo-active', 'axo-paused');
node.classList.add('axo-completed');
setTimeout(() => node.classList.remove('axo-completed'), 3000);
} else if (kind === 'paused') {
node.classList.remove('axo-active');
node.classList.add('axo-paused');
} else if (kind === 'error') {
node.classList.remove('axo-active');
node.style.outline = '2px solid var(--err)';
setTimeout(() => { node.style.outline = ''; }, 3000);
}
}
function renderStudioQuickfire() {
const host = $('#studio-side-quickfire'); if (!host) return;
host.innerHTML = '';
if (S.workflows && S.workflows.length) {
const all = el('div', 'shell-side-row');
all.innerHTML = '<span class="ico">▶▶</span><span class="lbl">Fire all workflows</span>';
all.addEventListener('click', () => {
S.workflows.forEach(w => fireWorkflowSilent(w.id, defaultInputFor(w.id)));
toast('All workflows fired', '', 'info');
});
host.appendChild(all);
}
(S.workflows || []).forEach(w => {
const row = el('div', 'shell-side-row');
row.innerHTML = '<span class="ico">▶</span><span class="lbl"></span>';
row.querySelector('.lbl').textContent = w.name || w.id;
row.title = 'Fire ' + (w.name || w.id);
row.addEventListener('click', () => {
fireWorkflowSilent(w.id, defaultInputFor(w.id));
toast('Workflow fired', w.name || w.id, 'info');
});
host.appendChild(row);
});
if (!host.children.length) host.appendChild(el('div', 'shell-side-empty', 'No workflows yet.'));
}
function obsFromStreamFrame(frame) {
if (!frame || !frame.kind) return;
const now = new Date();
const when = now.getHours().toString().padStart(2, '0') + ':'
+ now.getMinutes().toString().padStart(2, '0') + ':'
+ now.getSeconds().toString().padStart(2, '0');
switch (frame.kind) {
case 'Event': {
const e = frame.event_type || 'event';
const who = frame.agent || frame.task || frame.workflow || '';
let cls = 'activation', pulseKind = 'activated';
if (e === 'TaskCompleted' || e === 'MapCompleted' || e === 'Resumed')
{ cls = 'completion'; pulseKind = 'completed'; }
else if (e === 'AgentFailed' || e === 'TaskFailed')
{ cls = 'error'; pulseKind = 'error'; }
else if (e === 'Interrupted') { cls = 'session'; pulseKind = 'paused'; }
pushObsEvent({ when, cls, who, what: e, preview: frame.output ? frame.output.slice(0, 200) : '' });
if (frame.agent) studioPulse(frame.agent, pulseKind);
if (frame.task && frame.workflow) editorPulse(frame.workflow, frame.task, pulseKind);
if (e === 'Interrupted' || e === 'Resumed') refreshInterrupts();
break;
}
case 'SessionToken':
case 'SessionDone':
case 'SessionError': {
const map = { SessionToken: 'token', SessionDone: 'done', SessionError: 'error' };
pushObsEvent({ when, cls: 'session', who: frame.session || 'session', what: map[frame.kind] || frame.kind });
break;
}
case 'Token':
case 'Reasoning': break; default:
pushObsEvent({ when, cls: 'activation', who: '', what: frame.kind });
}
}
async function refreshStatus() {
try {
const j = await fetch('/health').then(r => r.json());
S.daemonHealth = 'healthy';
if ($('#status-pill')) {
$('#status-pill').classList.add('ok'); $('#status-pill').classList.remove('err');
$('#status-text').textContent = `daemon healthy · ${j.agents} agents`;
}
} catch {
S.daemonHealth = 'error';
if ($('#status-pill')) {
$('#status-pill').classList.add('err'); $('#status-pill').classList.remove('ok');
$('#status-text').textContent = 'daemon unreachable';
}
}
renderStatusPearls();
}
async function refreshTopStats() {
try {
const [agents, tokens] = await Promise.all([
fetch('/api/agents').then(r => r.json()),
fetch('/api/tokens/report').then(r => r.json()).catch(() => null),
]);
S.agents = agents;
const total = tokens ? (tokens.total ?? (tokens.total_input + tokens.total_output)) : 0;
S.tokensTotal = total;
const stats = $('#top-stats'); stats.innerHTML = '';
stats.appendChild(el('div', 'status-pill', `${agents.length} agents`));
stats.appendChild(el('div', 'status-pill', `${fmtNum(total)} tokens`));
stats.appendChild(el('div', 'status-pill', `${S.workflows.length} workflows`));
$('#cnt-agents').textContent = agents.length;
$('#cnt-workflows') && ($('#cnt-workflows').textContent = S.workflows.length);
$('#cnt-schedules') && ($('#cnt-schedules').textContent = S.schedules.length);
$('#cnt-skills') && ($('#cnt-skills').textContent = S.skills.length);
$('#lat-alive') && ($('#lat-alive').textContent = agents.length);
$('#lat-tokens') && ($('#lat-tokens').textContent = fmtNum(total));
} catch {}
renderStatusPearls();
}
S.daemonHealth = S.daemonHealth || 'unknown'; S.pendingInterrupts = S.pendingInterrupts || 0;
S.activeTabName = S.activeTabName || 'sessions';
function activeSessionPearlText() {
if (!S.session || !S.session.id) return null;
const name = S.session.name || S.session.id.slice(0, 8);
const agentCount = (S.session.agents || []).length;
return agentCount > 0 ? `${name} · ${agentCount} agents` : name;
}
function renderStatusPearls() {
const host = $('#status-pearls'); if (!host) return;
const tab = S.activeTabName || 'sessions';
const pearls = [];
const dotClass = S.daemonHealth === 'healthy' ? '' :
S.daemonHealth === 'starting' ? 'amber' :
S.daemonHealth === 'error' ? 'err' : 'mute';
pearls.push({
key: 'health',
dot: dotClass,
text: S.daemonHealth === 'healthy' ? 'daemon healthy' :
S.daemonHealth === 'starting' ? 'daemon starting' :
S.daemonHealth === 'error' ? 'daemon unreachable' : 'connecting…',
});
if (S.pendingInterrupts > 0) {
pearls.push({
key: 'interrupts', warn: true, clickable: true,
text: `⏸ ${S.pendingInterrupts} waiting`,
onClick: () => { try { openInterruptsPop && openInterruptsPop(); } catch {} },
});
}
if (tab === 'sessions') {
const sess = activeSessionPearlText();
if (sess) pearls.push({ key: 'session', text: sess });
if (S.tokensTotal > 0) pearls.push({ key: 'tokens', text: `${fmtNum(S.tokensTotal)} tok` });
} else if (tab === 'studio' || tab === 'agents') {
const ac = (S.agents || []).length;
if (ac > 0) pearls.push({ key: 'agents', text: `${ac} agents` });
if (S.tokensTotal > 0) pearls.push({ key: 'tokens', text: `${fmtNum(S.tokensTotal)} tok` });
} else if (tab === 'automations') {
const wf = (S.workflows || []).length;
if (wf > 0) pearls.push({ key: 'workflows', text: `${wf} automations` });
} else if (tab === 'chat') {
const c = S.chat && S.chat.current;
if (c) pearls.push({ key: 'chat', text: c.title || c.id.slice(0, 8) });
}
const existing = new Map();
Array.from(host.children).forEach(c => existing.set(c.dataset.key, c));
const seen = new Set();
pearls.forEach(p => {
seen.add(p.key);
const text = p.text;
let row = existing.get(p.key);
if (!row) {
row = document.createElement('div');
row.className = 'pearl fade-in';
row.dataset.key = p.key;
host.appendChild(row);
}
row.classList.toggle('warn', !!p.warn);
row.classList.toggle('clickable', !!p.clickable);
const wantHTML = `<span class="pearl-dot ${p.dot || ''}"></span><span>${escHtml(text)}</span>`
.replace(/<span class="pearl-dot "><\/span>/, p.warn ? '' : '<span class="pearl-dot"></span>');
if (row._lastHtml !== wantHTML) {
row.innerHTML = (p.warn ? '' : `<span class="pearl-dot ${p.dot || ''}"></span>`) +
`<span>${escHtml(text)}</span>`;
row._lastHtml = wantHTML;
}
if (p.onClick) { row.onclick = p.onClick; } else { row.onclick = null; }
});
existing.forEach((node, key) => {
if (!seen.has(key)) node.remove();
});
}
async function loadAll() {
const [wfs, agents, schedules, skills] = await Promise.all([
fetch('/api/workflows').then(r => r.json()),
fetch('/api/agents').then(r => r.json()),
fetch('/api/schedules').then(r => r.json()).catch(() => []),
fetch('/api/skills').then(r => r.json()).catch(() => []),
]);
S.workflows = wfs; S.agents = agents; S.schedules = schedules; S.skills = skills;
if (wfs.length && !S.selectedWorkflow) S.selectedWorkflow = wfs[0];
await refreshTopStats();
}
function defaultInputFor(wfId) {
return ({
'feature-dev': 'Design and implement: a small library that fetches and caches URLs.',
'bug-triage': 'A user reports their dashboard hangs when loading large datasets.',
'research-and-summarize': 'What are the latest developments in multi-agent AI orchestration?',
'release-checklist': 'Run release readiness checks for the current build.',
'daily-briefing': 'Produce a 5-bullet briefing on today\'s likely team priorities.',
})[wfId] || 'Demo input.';
}
async function fireWorkflowSilent(id, input) {
S.activeWorkflows.add(id);
try {
const r = await fetch(`/api/workflows/${id}/execute`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input }),
});
await r.json();
} catch (e) { console.warn(e); }
S.activeWorkflows.delete(id);
refreshTopStats();
}
function detailSection(title, htmlOrNode) {
const s = el('div', 'detail-section');
s.appendChild(el('h4', null, title));
if (typeof htmlOrNode === 'string') { const d = el('div'); d.innerHTML = htmlOrNode; s.appendChild(d); }
else if (htmlOrNode) s.appendChild(htmlOrNode);
return s;
}
function fieldRow(label, inputEl) {
const row = el('div', 'field-row');
row.appendChild(el('div', 'lbl', label));
row.appendChild(inputEl);
return row;
}
function showAgentDetail(a) {
const body = el('div');
const idSection = el('div', 'detail-section');
idSection.appendChild(el('h4', null, 'Identity'));
const nameInput = el('input', 'input'); nameInput.value = a.name || a.id;
const modelInput = el('input', 'input mono'); modelInput.value = a.model || '';
idSection.appendChild(fieldRow('ID', (() => { const x = el('input', 'input mono'); x.value = a.id; x.disabled = true; return x; })()));
idSection.appendChild(fieldRow('Name', nameInput));
idSection.appendChild(fieldRow('Provider', (() => { const x = el('input', 'input'); x.value = a.provider; x.disabled = true; return x; })()));
idSection.appendChild(fieldRow('Model', modelInput));
idSection.appendChild(fieldRow('Team', (() => { const d = el('div'); d.appendChild(el('span', 'badge team ' + teamCls(a.team), a.team || '—')); return d; })()));
body.appendChild(idSection);
const promptSection = el('div', 'detail-section');
promptSection.appendChild(el('h4', null, 'System prompt'));
const promptArea = el('textarea', 'input');
promptArea.value = a.system_prompt || '';
promptArea.rows = 6;
promptArea.style.fontFamily = "'JetBrains Mono', monospace";
promptArea.style.fontSize = '12.5px';
promptArea.style.lineHeight = '1.55';
promptArea.style.minHeight = '120px';
promptSection.appendChild(promptArea);
body.appendChild(promptSection);
const budgetSection = el('div', 'detail-section');
budgetSection.appendChild(el('h4', null, 'Token budget'));
const perCall = el('input', 'input mono'); perCall.type = 'number'; perCall.value = a.per_call_budget ?? '';
const perExec = el('input', 'input mono'); perExec.type = 'number'; perExec.value = a.per_execution_budget ?? '';
const policy = el('select', 'select');
['abort', 'warn', 'summarize'].forEach(p => { const o = el('option'); o.value = p; o.textContent = p; if ((a.overflow_policy || '').toLowerCase() === p) o.selected = true; policy.appendChild(o); });
budgetSection.appendChild(fieldRow('Per call', perCall));
budgetSection.appendChild(fieldRow('Per execution', perExec));
budgetSection.appendChild(fieldRow('Overflow policy', policy));
body.appendChild(budgetSection);
const depsSection = el('div', 'detail-section');
depsSection.appendChild(el('h4', null, 'Depends on'));
const depsBox = el('div'); depsBox.style.display = 'flex'; depsBox.style.flexWrap = 'wrap'; depsBox.style.gap = '6px';
S.agents.filter(x => x.id !== a.id).forEach(other => {
const lbl = el('label'); lbl.style.cssText = 'display:inline-flex;align-items:center;gap:5px;background:var(--bg-3);border:1px solid var(--border);padding:4px 10px;border-radius:999px;cursor:pointer;font-size:12px;';
const cb = el('input'); cb.type = 'checkbox'; cb.value = other.id; cb.style.margin = 0;
if ((a.depends_on || []).includes(other.id)) cb.checked = true;
lbl.appendChild(cb);
lbl.appendChild(el('span', null, other.id));
depsBox.appendChild(lbl);
});
depsSection.appendChild(depsBox);
body.appendChild(depsSection);
const liveSection = el('div', 'detail-section');
liveSection.appendChild(el('h4', null, 'Live'));
const liveBody = el('div'); liveBody.id = 'ag-detail-live';
liveBody.appendChild(el('div', 'muted small', 'loading…'));
liveSection.appendChild(liveBody);
body.appendChild(liveSection);
const dirty = el('div', 'editor-dirty-indicator', '');
const save = el('button', 'btn', '💾 Save & restart');
const restart = el('button', 'btn ghost', '↻ Restart only');
function readForm() {
const checked = Array.from(depsBox.querySelectorAll('input[type=checkbox]:checked')).map(x => x.value);
return {
name: nameInput.value.trim() || null,
model: modelInput.value.trim() || null,
system_prompt: promptArea.value,
depends_on: checked,
per_call_budget: perCall.value ? parseInt(perCall.value, 10) : null,
per_execution_budget: perExec.value ? parseInt(perExec.value, 10) : null,
overflow_policy: policy.value,
restart_now: true,
};
}
function markDirty() { dirty.textContent = '● unsaved'; }
[nameInput, modelInput, promptArea, perCall, perExec, policy].forEach(x => x.addEventListener('input', markDirty));
depsBox.addEventListener('change', markDirty);
save.addEventListener('click', async () => {
save.disabled = true; save.textContent = '… saving';
try {
const r = await fetch(`/api/agents/${a.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(readForm()) });
if (!r.ok) throw new Error(await r.text());
const d = await r.json();
toast('Agent saved', d.message, 'ok');
dirty.textContent = '';
const fresh = await fetch('/api/agents').then(r => r.json());
S.agents = fresh;
const updated = fresh.find(x => x.id === a.id);
if (updated) Object.assign(a, updated);
refreshLive();
} catch (e) { toast('Save failed', e.message || e, 'err'); }
finally { save.disabled = false; save.textContent = '💾 Save & restart'; }
});
restart.addEventListener('click', async () => {
restart.disabled = true; restart.textContent = '…';
try { await fetch(`/api/agents/${a.id}/restart`, { method: 'POST' }); toast('Agent restarted', `${a.id} session restored from checkpoint`, 'ok'); refreshLive(); }
catch (e) { toast('Restart failed', e.message || e, 'err'); }
finally { restart.disabled = false; restart.textContent = '↻ Restart only'; }
});
openDetail({ pre: 'Agent', title: a.id, body, footer: [dirty, restart, save] });
async function refreshLive() {
const target = $('#ag-detail-live'); if (!target) return;
target.innerHTML = '<div class="muted small">loading…</div>';
try {
const [sj, tokens] = await Promise.all([
fetch(`/api/agents/${a.id}/status`).then(r => r.json()),
fetch('/api/tokens/report').then(r => r.json()).catch(() => null),
]);
const tk = tokens?.per_agent?.find(x => x.agent_id === a.id);
const stat = (sj.status || '').toString().replace(/[{}]/g,'').trim() || 'Unknown';
const cls = stat.startsWith('Idle') ? 'idle' : (stat.startsWith('Running') ? 'running' : 'failed');
const html = `
<div class="field-row"><div class="lbl">Status</div><div><span class="badge ${cls}">${escHtml(stat)}</span></div></div>
<div class="field-row"><div class="lbl">Input tokens</div><div class="mono">${fmtNum(tk?.input_tokens || 0)}</div></div>
<div class="field-row"><div class="lbl">Output tokens</div><div class="mono">${fmtNum(tk?.output_tokens || 0)}</div></div>
<div class="field-row"><div class="lbl">Total</div><div class="mono">${fmtNum((tk?.input_tokens || 0) + (tk?.output_tokens || 0))}</div></div>
`;
target.innerHTML = html;
} catch (err) {
console.error('agent live fetch failed:', err);
target.innerHTML = `<div style="color:var(--err)" class="small">failed to load: ${escHtml(err.message || String(err))}</div>`;
}
}
refreshLive();
}
function renderToolChip(tc) {
const c = el('div', 'tool-chip');
const head = el('div', 'tc-head');
head.appendChild(el('span', 'tc-name', tc.name));
const args = JSON.stringify(tc.arguments || {});
head.appendChild(el('span', 'tc-args', args.length > 50 ? args.slice(0, 50) + '…' : args));
const tog = el('span', 'tc-toggle', '⮞ result');
head.appendChild(tog);
c.appendChild(head);
if (tc.result != null) {
const r = el('div', 'tc-result');
r.textContent = typeof tc.result === 'string' ? tc.result : JSON.stringify(tc.result, null, 2);
c.appendChild(r);
}
tog.addEventListener('click', () => c.classList.toggle('open'));
return c;
}
S.chat = { list: [], openId: null, openChat: null, streaming: null };
async function refreshChats(query) {
try {
const q = query && query.trim() ? `?q=${encodeURIComponent(query.trim())}` : '';
S.chat.list = await fetch('/api/chat' + q).then(r => r.json());
} catch (e) {
S.chat.list = [];
}
const cnt = $('#cnt-chats');
if (cnt) cnt.textContent = S.chat.list.length || '—';
renderChatList();
}
function groupChatsByDate(list) {
const now = Date.now() / 1000;
const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0);
const todayStartS = todayStart.getTime() / 1000;
const yesterdayStartS = todayStartS - 86400;
const weekStartS = todayStartS - 7 * 86400;
const starred = [];
const today = [];
const yesterday = [];
const thisWeek = [];
const older = [];
for (const c of list) {
if (c.starred) { starred.push(c); continue; }
if (c.last_active >= todayStartS) today.push(c);
else if (c.last_active >= yesterdayStartS) yesterday.push(c);
else if (c.last_active >= weekStartS) thisWeek.push(c);
else older.push(c);
}
return { starred, today, yesterday, thisWeek, older };
}
function renderChatList() {
const host = $('#chat-list');
if (!host) return;
host.innerHTML = '';
if (!S.chat.list.length) {
host.appendChild(el('div', 'chat-side-empty',
'No chats yet — click + New to start.'));
return;
}
const groups = groupChatsByDate(S.chat.list);
const sections = [
['Starred', groups.starred],
['Today', groups.today],
['Yesterday', groups.yesterday],
['This week', groups.thisWeek],
['Older', groups.older],
];
for (const [label, chats] of sections) {
if (!chats.length) continue;
host.appendChild(el('div', 'chat-side-group', label));
for (const c of chats) {
const row = el('div', 'chat-side-row' + (c.id === S.chat.openId ? ' active' : ''));
const name = el('div', 'chat-side-name');
if (c.starred) name.appendChild(el('span', 'chat-side-star', '★'));
name.appendChild(el('span', null, c.name));
row.appendChild(name);
row.appendChild(el('div', 'chat-side-meta',
c.agent_id + ' · ' + (c.messages?.length || 0) + ' msg'));
row.addEventListener('click', () => openChat(c.id));
host.appendChild(row);
}
}
}
async function openChat(id) {
let c = null;
try {
const r = await fetch('/api/chat/' + encodeURIComponent(id));
if (r.ok) c = await r.json();
} catch {}
if (!c) c = S.chat.list.find(x => x.id === id);
if (!c) return;
S.chat.openId = id;
S.chat.openChat = c;
S.chat.streaming = null;
renderChatHeader(c);
renderChatTranscript(c);
$('#chat-text').disabled = false;
$('#chat-send').disabled = false;
const attBtn = $('#chat-attach-btn'); if (attBtn) attBtn.disabled = false;
renderChatAttachChips();
renderChatList();
}
function renderChatHeader(c) {
$('#chat-title').textContent = c.name;
const label = $('#chat-agent-label');
label.innerHTML = '';
const provInfo = el('span', null, c.agent_id + (c.model_override ? ' · ' + c.model_override : ''));
label.appendChild(provInfo);
const starBtn = el('button', 'btn ghost sm', c.starred ? '★' : '☆');
starBtn.title = c.starred ? 'Unstar' : 'Star';
starBtn.addEventListener('click', () => chatPatch({ starred: !c.starred }));
label.appendChild(starBtn);
const renameBtn = el('button', 'btn ghost sm', '✎');
renameBtn.title = 'Rename';
renameBtn.addEventListener('click', async () => {
const next = await axoPrompt({
title: 'Rename chat',
label: 'Name',
value: c.name,
placeholder: 'Give the chat a useful title…',
});
if (next && next.trim()) chatPatch({ name: next.trim() });
});
label.appendChild(renameBtn);
const sysBtn = el('button', 'btn ghost sm', '⚙');
sysBtn.title = 'Per-chat system prompt';
sysBtn.addEventListener('click', async () => {
const out = await axoModal({
title: 'Per-chat instructions',
body: 'These instructions override the agent\'s default system prompt for this chat only. Leave empty to clear.',
fields: [{ key: 'sys', label: 'System prompt', value: c.system_override || '', kind: 'textarea', placeholder: 'e.g. Respond in haiku.' }],
okLabel: 'Save',
});
if (out == null) return;
chatPatch({ system_override: out.sys.trim() ? out.sys : null });
});
label.appendChild(sysBtn);
const modelBtn = el('button', 'btn ghost sm', '◐');
modelBtn.title = 'Per-chat model override';
modelBtn.addEventListener('click', async () => {
const models = await fetchModelsForAgent(c.agent_id);
if (!models || !models.length) {
toast('No models found', 'Could not discover models for this provider.', 'err');
return;
}
const choices = models.map(m => ({ value: m, label: m, sub: '' }));
choices.unshift({ value: '__clear__', label: '(use agent default)', sub: 'clears any override' });
const picked = await axoModal({
title: 'Pick a model for this chat',
body: 'Switches only the model — the agent\'s provider, credentials, and tools stay the same.',
choices,
okLabel: 'Apply',
});
if (picked == null) return;
chatPatch({ model_override: picked === '__clear__' ? null : picked });
});
label.appendChild(modelBtn);
const exportMd = el('button', 'btn ghost sm', '↓ md');
exportMd.title = 'Export as Markdown';
exportMd.addEventListener('click', () => {
window.location.href = `/api/chat/${encodeURIComponent(c.id)}/export?format=md`;
});
label.appendChild(exportMd);
const exportJson = el('button', 'btn ghost sm', '↓ json');
exportJson.title = 'Export as JSON';
exportJson.addEventListener('click', () => {
window.location.href = `/api/chat/${encodeURIComponent(c.id)}/export?format=json`;
});
label.appendChild(exportJson);
const delBtn = el('button', 'btn ghost sm danger', '🗑');
delBtn.title = 'Delete chat';
delBtn.addEventListener('click', async () => {
const ok = await axoConfirm({
title: 'Delete this chat?',
body: `"${c.name}" and its transcript will be removed. This can't be undone.`,
okLabel: 'Delete',
okKind: 'danger',
});
if (!ok) return;
try {
await fetch('/api/chat/' + encodeURIComponent(c.id), { method: 'DELETE' });
S.chat.openId = null;
S.chat.openChat = null;
$('#chat-title').textContent = 'Pick a chat or start a new one';
$('#chat-agent-label').innerHTML = '';
$('#chat-text').disabled = true;
$('#chat-send').disabled = true;
$('#chat-msgs').innerHTML = '';
$('#chat-msgs').appendChild(el('div', 'chat-msgs-empty', 'Pick a chat from the sidebar, or click + New.'));
await refreshChats();
} catch (e) { toast('Delete failed', String(e), 'err'); }
});
label.appendChild(delBtn);
}
async function fetchModelsForAgent(agentId) {
try {
const r = await fetch('/api/llm/models?agent=' + encodeURIComponent(agentId));
if (!r.ok) throw new Error('HTTP ' + r.status);
return await r.json();
} catch {
const a = (S.agents || []).find(x => x.id === agentId);
return a ? [a.model] : [];
}
}
function renderChatTranscript(c) {
const msgs = $('#chat-msgs');
msgs.innerHTML = '';
if (!c.messages || !c.messages.length) {
msgs.appendChild(el('div', 'chat-msgs-empty',
'No messages yet — type below to start the conversation.'));
return;
}
c.messages.forEach((m, i) => {
msgs.appendChild(chatMessageEl(m.role, m.content, i));
});
msgs.scrollTop = 1e9;
}
function chatMessageEl(role, content, index) {
const wrap = el('div', 'smsg ' + (role === 'user' ? 'user' : 'assistant'));
if (typeof index === 'number') wrap.dataset.idx = String(index);
wrap.appendChild(el('div', 'smsg-role', role));
const body = el('div', 'smsg-body prose');
body.innerHTML = mdRender(content);
wrap.appendChild(body);
const acts = el('div', 'smsg-actions');
const copy = el('button', 'smsg-act', '⧉ Copy');
copy.title = 'Copy message';
copy.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(content);
copy.textContent = '✓ Copied';
setTimeout(() => (copy.textContent = '⧉ Copy'), 1100);
} catch { toast('Copy failed', '', 'err'); }
});
acts.appendChild(copy);
if (role === 'user' && typeof index === 'number') {
const branch = el('button', 'smsg-act', '⑂ Edit & branch');
branch.title = 'Edit this turn and continue in a new chat (parent stays intact)';
branch.addEventListener('click', () => editAndBranch(index, content));
acts.appendChild(branch);
}
wrap.appendChild(acts);
return wrap;
}
async function editAndBranch(idx, currentText) {
const id = S.chat.openId;
if (!id) return;
const out = await axoModal({
title: 'Edit & branch',
body: 'A new chat will fork from here — the parent chat stays intact. The edited turn becomes the first user message in the new chat, then the agent re-answers.',
fields: [{
key: 'content',
label: 'Edited message',
value: currentText,
kind: 'textarea',
}],
okLabel: 'Fork chat',
});
if (out == null || !out.content.trim()) return;
try {
const r = await fetch(`/api/chat/${encodeURIComponent(id)}/fork`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ truncate_at: idx }),
});
if (!r.ok) throw new Error('HTTP ' + r.status);
const newChat = await r.json();
await refreshChats();
await openChat(newChat.id);
const msgs = $('#chat-msgs');
const empty = msgs.querySelector('.chat-msgs-empty');
if (empty) empty.remove();
msgs.appendChild(chatMessageEl('user', out.content, (newChat.messages || []).length));
msgs.scrollTop = 1e9;
wsSend({ cmd: 'chat-turn', chat_id: newChat.id, content: out.content });
} catch (e) {
toast('Fork failed', String(e), 'err');
}
}
async function refreshOpenChat() {
if (!S.chat.openId) return;
try {
const r = await fetch('/api/chat/' + encodeURIComponent(S.chat.openId));
if (r.ok) {
S.chat.openChat = await r.json();
renderChatAttachChips();
}
} catch {}
}
async function renderChatAttachChips() {
const host = $('#chat-attach-chips');
if (!host) return;
host.innerHTML = '';
const c = S.chat.openChat;
if (!c || !c.attachments || !c.attachments.length) return;
for (const ref of c.attachments) {
let meta = null;
try {
const r = await fetch('/api/files/' + encodeURIComponent(ref.file_id));
if (r.ok) meta = await r.json();
} catch {}
if (!meta) {
const chip = el('div', 'chat-attach-chip broken');
chip.appendChild(el('span', null, '⚠'));
chip.appendChild(el('span', 'chat-attach-chip-name', 'missing file'));
chip.appendChild(makeRemoveBtn(c.id, ref.file_id));
host.appendChild(chip);
continue;
}
const chip = el('div', 'chat-attach-chip' + (ref.pinned ? ' pinned' : ''));
chip.appendChild(el('span', null, meta.mime.startsWith('image/') ? '🖼' : (meta.mime === 'application/pdf' ? '📕' : '📎')));
chip.appendChild(el('span', 'chat-attach-chip-name', meta.name));
chip.appendChild(el('span', 'chat-attach-chip-size', humanBytes(meta.size)));
const pinBtn = el('button', 'chat-attach-chip-pin', ref.pinned ? '📌' : '📍');
pinBtn.title = ref.pinned ? 'Unpin (attach for this turn only)' : 'Pin (re-attach to every turn)';
pinBtn.addEventListener('click', async () => {
try {
await fetch(`/api/chat/${encodeURIComponent(c.id)}/attach/${encodeURIComponent(ref.file_id)}`, {
method: 'PATCH', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ pinned: !ref.pinned }),
});
await refreshOpenChat();
} catch (e) { toast('Pin toggle failed', String(e), 'err'); }
});
chip.appendChild(pinBtn);
chip.appendChild(makeRemoveBtn(c.id, ref.file_id));
host.appendChild(chip);
}
}
function makeRemoveBtn(chatId, fileId) {
const rm = el('button', 'chat-attach-chip-rm', '×');
rm.title = 'Remove from this chat (file stays in your library)';
rm.addEventListener('click', async () => {
try {
await fetch(`/api/chat/${encodeURIComponent(chatId)}/attach/${encodeURIComponent(fileId)}`, { method: 'DELETE' });
await refreshOpenChat();
} catch (e) { toast('Could not remove attachment', String(e), 'err'); }
});
return rm;
}
function humanBytes(n) {
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
return (n / 1024 / 1024).toFixed(2) + ' MB';
}
async function uploadChatAttachment(file) {
if (!S.chat.openId) {
toast('Open a chat first', '', 'err');
return;
}
const fd = new FormData();
fd.append('file', file);
try {
const r = await fetch(`/api/chat/${encodeURIComponent(S.chat.openId)}/attach`, {
method: 'POST', body: fd,
});
if (!r.ok) {
const body = await r.json().catch(() => ({ error: 'HTTP ' + r.status }));
toast('Upload failed', body.error || ('HTTP ' + r.status), 'err');
return;
}
await refreshOpenChat();
} catch (e) {
toast('Upload failed', String(e), 'err');
}
}
function installChatDropZone() {
const main = document.querySelector('.chat-main');
const drop = $('#chat-drop');
if (!main || !drop) return;
let counter = 0; main.addEventListener('dragenter', (e) => {
if (!S.chat.openId) return;
e.preventDefault();
counter += 1;
drop.classList.remove('hide');
});
main.addEventListener('dragover', (e) => {
if (!S.chat.openId) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
});
main.addEventListener('dragleave', () => {
counter -= 1;
if (counter <= 0) { counter = 0; drop.classList.add('hide'); }
});
main.addEventListener('drop', async (e) => {
e.preventDefault();
counter = 0; drop.classList.add('hide');
if (!S.chat.openId) return;
const files = Array.from(e.dataTransfer.files || []);
for (const f of files) await uploadChatAttachment(f);
});
}
async function chatPatch(patch) {
const id = S.chat.openId;
if (!id) return;
try {
const r = await fetch('/api/chat/' + encodeURIComponent(id), {
method: 'PATCH', headers: { 'content-type': 'application/json' },
body: JSON.stringify(patch),
});
if (!r.ok) throw new Error('HTTP ' + r.status);
const c = await r.json();
S.chat.openChat = c;
renderChatHeader(c);
await refreshChats($('#chat-search')?.value || '');
} catch (e) {
toast('Update failed', String(e), 'err');
}
}
function chatBeginUserTurn(_d) {
const msgs = $('#chat-msgs');
const empty = msgs.querySelector('.chat-msgs-empty');
if (empty) empty.remove();
const wrap = el('div', 'smsg assistant');
wrap.appendChild(el('div', 'smsg-role', 'assistant'));
const body = el('div', 'smsg-body prose');
wrap.appendChild(body);
msgs.appendChild(wrap);
msgs.scrollTop = 1e9;
S.chat.streaming = { bubble: body, wrap, buffer: '', reasoningEl: null, reasoningBuf: '' };
const sendBtn = $('#chat-send');
sendBtn.textContent = '■ Stop';
sendBtn.dataset.mode = 'stop';
}
function chatAppendToken(delta) {
const s = S.chat.streaming; if (!s) return;
s.buffer += delta;
s.bubble.innerHTML = mdRender(s.buffer) + '<span class="stream-cursor"></span>';
$('#chat-msgs').scrollTop = 1e9;
}
function chatAppendReasoning(delta) {
const s = S.chat.streaming; if (!s) return;
if (!s.reasoningEl) {
const det = document.createElement('details');
det.className = 'chat-reasoning';
const sum = document.createElement('summary');
sum.textContent = 'Thinking…';
det.appendChild(sum);
const body = el('pre', 'chat-reasoning-body');
det.appendChild(body);
s.wrap.insertBefore(det, s.bubble);
s.reasoningEl = body;
}
s.reasoningBuf += delta;
s.reasoningEl.textContent = s.reasoningBuf;
}
function chatAppendToolStart(d) {
const s = S.chat.streaming; if (!s) return;
const chip = renderToolChip({ name: d.name, arguments: d.arguments, result: null });
chip.dataset.callId = d.call_id;
s.wrap.insertBefore(chip, s.bubble);
}
function chatAppendToolResult(d) {
const s = S.chat.streaming; if (!s) return;
const chip = s.wrap.querySelector(`[data-call-id="${d.call_id}"]`);
if (chip) {
const r = el('div', 'tc-result');
r.textContent = typeof d.result === 'string' ? d.result : JSON.stringify(d.result, null, 2);
chip.appendChild(r);
chip.classList.toggle('error', !!d.is_error);
if (d.name === 'web_search' && d.result && typeof d.result === 'object') {
const sources = extractWebSearchUrls(d.result);
if (sources.length) {
const cite = el('div', 'chat-citations');
cite.appendChild(el('div', 'chat-citations-head', 'Sources'));
sources.forEach((src, i) => {
const row = el('div', 'chat-citation');
row.appendChild(el('span', 'chat-citation-num', '[' + (i + 1) + ']'));
const a = document.createElement('a');
a.href = src.url;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.textContent = src.title || src.url;
row.appendChild(a);
cite.appendChild(row);
});
chip.appendChild(cite);
}
}
}
}
function extractWebSearchUrls(result) {
if (Array.isArray(result.results)) {
return result.results.map(r => ({ url: r.url || r.link, title: r.title || r.name })).filter(r => r.url);
}
if (Array.isArray(result.urls)) {
return result.urls.map(u => ({ url: u, title: u }));
}
return [];
}
function chatFinalize({ tokens_in, tokens_out, stopped, error }) {
const s = S.chat.streaming; if (!s) return;
s.bubble.innerHTML = mdRender(s.buffer || (error ? `*Error: ${error}*` : '*(no response)*'));
if (stopped) {
s.wrap.appendChild(el('div', 'smsg-tokens', '⏹ stopped'));
} else if (error) {
s.wrap.appendChild(el('div', 'smsg-tokens', 'error: ' + error));
} else if (tokens_in != null || tokens_out != null) {
s.wrap.appendChild(el('div', 'smsg-tokens', (tokens_in || 0) + ' in / ' + (tokens_out || 0) + ' out'));
}
const sendBtn = $('#chat-send');
sendBtn.textContent = 'Send';
delete sendBtn.dataset.mode;
S.chat.streaming = null;
}
async function sendChatTurn() {
const sendBtn = $('#chat-send');
if (sendBtn.dataset.mode === 'stop') {
if (!S.chat.openId) return;
wsSend({ cmd: 'chat-stop', chat_id: S.chat.openId });
return;
}
const ta = $('#chat-text');
const text = (ta.value || '').trim();
if (!text || !S.chat.openId) return;
if (text.startsWith('/')) {
const handled = await handleChatSlash(text);
if (handled) { ta.value = ''; return; }
}
const msgs = $('#chat-msgs');
const empty = msgs.querySelector('.chat-msgs-empty');
if (empty) empty.remove();
const idx = (S.chat.openChat?.messages || []).length;
msgs.appendChild(chatMessageEl('user', text, idx));
msgs.scrollTop = 1e9;
if (!wsSend({ cmd: 'chat-turn', chat_id: S.chat.openId, content: text })) {
toast('Not connected', 'Live connection is down — reconnecting…', 'err');
return;
}
ta.value = '';
}
async function handleChatSlash(text) {
const [cmd, ...rest] = text.split(' ');
const arg = rest.join(' ').trim();
switch (cmd) {
case '/clear':
$('#chat-msgs').innerHTML = '';
$('#chat-msgs').appendChild(el('div', 'chat-msgs-empty', 'Transcript hidden locally. Reload to bring it back.'));
return true;
case '/system':
await chatPatch({ system_override: arg || null });
toast('System prompt updated', arg ? 'Per-chat override set' : 'Cleared override', 'ok');
return true;
case '/model':
await chatPatch({ model_override: arg || null });
toast('Model override updated', arg ? `Set to ${arg}` : 'Cleared override', 'ok');
return true;
case '/agent':
toast('Agent change', 'Switching agents is not yet supported per-chat. Create a new chat for the new agent.', 'info');
return true;
case '/help':
const help = '**Slash commands**\n\n- `/clear` — hide transcript locally (reload to restore)\n- `/system <prompt>` — set per-chat system prompt\n- `/model <id>` — set per-chat model override\n- `/agent` — info on agent switching';
const msgs = $('#chat-msgs');
msgs.appendChild(chatMessageEl('assistant', help));
msgs.scrollTop = 1e9;
return true;
default:
return false;
}
}
async function newChat() {
if (!S.agents || !S.agents.length) {
try { await loadAll(); } catch {}
}
if (!S.agents || !S.agents.length) {
toast('No agents', 'Configure at least one agent before chatting.', 'err');
return;
}
const choices = S.agents.map(a => ({
value: a.id,
label: a.name || a.id,
sub: `${a.provider}/${a.model} · ${a.team || ''}`,
}));
const agentId = await axoModal({
title: 'New chat',
body: 'Pick which agent should answer in this chat. You can change the model later via the ◐ button in the chat header.',
choices,
okLabel: 'Start chat',
});
if (!agentId) return;
const defaultName = (S.agents.find(a => a.id === agentId)?.name || agentId) + ' · ' + new Date().toLocaleString();
try {
const r = await fetch('/api/chat', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ agent_id: agentId, name: defaultName }),
});
if (!r.ok) throw new Error('HTTP ' + r.status);
const chat = await r.json();
await refreshChats();
openChat(chat.id);
} catch (e) {
toast('Could not create chat', String(e), 'err');
}
}
let _chatSearchTimer = null;
function onChatSearchInput(value) {
if (_chatSearchTimer) clearTimeout(_chatSearchTimer);
_chatSearchTimer = setTimeout(() => refreshChats(value), 220);
}
window.addEventListener('DOMContentLoaded', () => {
const searchEl = $('#chat-search');
if (searchEl) searchEl.addEventListener('input', e => onChatSearchInput(e.target.value));
const newBtn = $('#chat-new');
if (newBtn) newBtn.addEventListener('click', newChat);
const sendBtn = $('#chat-send');
if (sendBtn) sendBtn.addEventListener('click', sendChatTurn);
const ta = $('#chat-text');
if (ta) {
ta.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendChatTurn();
}
});
}
const attBtn = $('#chat-attach-btn');
if (attBtn) attBtn.addEventListener('click', async () => {
if (!S.chat.openId) return;
const out = await axoModal({
title: 'Attach files',
body: 'Add images (up to 10 MB) or text files (up to 1 MB). Images are sent to the model as visual context; text files are inlined into the prompt.',
fields: [{ key: 'files', label: 'Files', kind: 'file', multiple: true }],
okLabel: 'Upload',
});
if (!out || !out.files) return;
for (const f of out.files) await uploadChatAttachment(f);
});
installChatDropZone();
refreshChats();
});
S.files = { list: [], openId: null, openMeta: null };
async function refreshFiles(query) {
try {
const q = query && query.trim() ? `?q=${encodeURIComponent(query.trim())}` : '';
S.files.list = await fetch('/api/files' + q).then(r => r.json());
} catch {
S.files.list = [];
}
const cnt = $('#cnt-files');
if (cnt) cnt.textContent = S.files.list.length || '—';
renderFilesList();
}
function renderFilesList() {
const host = $('#files-list');
if (!host) return;
host.innerHTML = '';
if (!S.files.list.length) {
host.appendChild(el('div', 'chat-side-empty', 'No files yet — click + Upload.'));
return;
}
for (const f of S.files.list) {
const row = el('div', 'files-row' + (S.files.openId === f.id ? ' active' : ''));
const icon = el('div', 'files-row-icon');
if (f.mime.startsWith('image/')) {
const img = document.createElement('img');
img.src = `/api/files/${encodeURIComponent(f.id)}/bytes`;
img.alt = f.name;
icon.appendChild(img);
} else if (f.mime === 'application/pdf') {
icon.textContent = '📕';
} else if (f.mime.includes('spreadsheet') || ['xlsx', 'xls', 'csv', 'ods'].includes(f.ext)) {
icon.textContent = '📊';
} else if (f.mime.startsWith('text/') || ['md', 'txt'].includes(f.ext)) {
icon.textContent = '📝';
} else {
icon.textContent = '📎';
}
row.appendChild(icon);
const body = el('div', 'files-row-body');
body.appendChild(el('div', 'files-row-name', f.name));
body.appendChild(el('div', 'files-row-meta',
`${humanBytes(f.size)} · ${f.mime}`));
row.appendChild(body);
row.addEventListener('click', () => openLibraryFile(f.id));
host.appendChild(row);
}
}
async function openLibraryFile(id) {
const f = S.files.list.find(x => x.id === id) ||
await fetch('/api/files/' + encodeURIComponent(id)).then(r => r.ok ? r.json() : null);
if (!f) return;
S.files.openId = id;
S.files.openMeta = f;
$('#files-title').textContent = f.name;
$('#files-meta').textContent =
`${humanBytes(f.size)} · ${f.mime} · ${new Date(f.uploaded_at * 1000).toLocaleString()}`;
['#files-attach', '#files-rename', '#files-delete'].forEach(s => $(s)?.classList.remove('hide'));
if (!S.chat || !S.chat.openId) $('#files-attach')?.classList.add('hide');
renderFilePreview(f);
renderFilesList();
}
function renderFilePreview(f) {
const host = $('#files-preview');
host.innerHTML = '';
if ((f.tags && f.tags.length) || f.extracted_text || f.ocr_text) {
const tags = el('div', 'files-preview-tags');
if (f.extracted_text) tags.appendChild(el('span', 'files-preview-tag', '🔡 text extracted'));
if (f.ocr_text) tags.appendChild(el('span', 'files-preview-tag', '🔍 ocr available'));
for (const t of (f.tags || [])) tags.appendChild(el('span', 'files-preview-tag', '#' + t));
host.appendChild(tags);
}
if (f.mime.startsWith('image/')) {
const img = document.createElement('img');
img.className = 'files-preview-image';
img.src = `/api/files/${encodeURIComponent(f.id)}/bytes`;
host.appendChild(img);
if (f.ocr_text) {
const h = el('h3', null, 'OCR text');
h.style.marginTop = '16px';
host.appendChild(h);
const pre = el('pre', 'files-preview-text');
pre.textContent = f.ocr_text;
host.appendChild(pre);
}
} else if (f.extracted_text) {
const pre = el('pre', 'files-preview-text');
pre.textContent = f.extracted_text;
host.appendChild(pre);
} else {
const div = el('div', 'files-preview-icon');
div.appendChild(el('span', 'big', '📎'));
div.appendChild(el('div', null, 'No preview available for this file type.'));
const a = document.createElement('a');
a.href = `/api/files/${encodeURIComponent(f.id)}/bytes`;
a.textContent = 'Download original';
a.style.marginTop = '12px';
a.style.color = 'var(--axo-blue-glow)';
a.style.display = 'inline-block';
div.appendChild(a);
host.appendChild(div);
}
}
async function uploadFileToLibrary(file) {
const fd = new FormData();
fd.append('file', file);
try {
const r = await fetch('/api/files', { method: 'POST', body: fd });
if (!r.ok) {
const body = await r.json().catch(() => ({ error: 'HTTP ' + r.status }));
toast('Upload failed', body.error || ('HTTP ' + r.status), 'err');
return null;
}
const entry = await r.json();
await refreshFiles();
openLibraryFile(entry.id);
return entry;
} catch (e) {
toast('Upload failed', String(e), 'err');
return null;
}
}
async function attachOpenFileToChat() {
if (!S.files.openId || !S.chat.openId) return;
try {
await fetch(`/api/chat/${encodeURIComponent(S.chat.openId)}/attach`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ file_id: S.files.openId }),
});
await refreshOpenChat();
toast('Attached to chat', S.files.openMeta?.name || '', 'ok');
} catch (e) {
toast('Attach failed', String(e), 'err');
}
}
async function renameOpenFile() {
if (!S.files.openMeta) return;
const next = await axoPrompt({
title: 'Rename file',
label: 'Name',
value: S.files.openMeta.name,
});
if (!next || !next.trim()) return;
try {
const r = await fetch(`/api/files/${encodeURIComponent(S.files.openId)}`, {
method: 'PATCH', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: next.trim() }),
});
if (!r.ok) throw new Error('HTTP ' + r.status);
const updated = await r.json();
S.files.openMeta = updated;
$('#files-title').textContent = updated.name;
await refreshFiles();
} catch (e) { toast('Rename failed', String(e), 'err'); }
}
async function deleteOpenFile() {
if (!S.files.openMeta) return;
const ok = await axoConfirm({
title: 'Delete this file?',
body: `"${S.files.openMeta.name}" will be removed from the library and detached from every chat that references it. This can't be undone.`,
okLabel: 'Delete',
okKind: 'danger',
});
if (!ok) return;
try {
const r = await fetch(`/api/files/${encodeURIComponent(S.files.openId)}`, { method: 'DELETE' });
if (!r.ok) throw new Error('HTTP ' + r.status);
S.files.openId = null;
S.files.openMeta = null;
$('#files-title').textContent = 'Pick a file';
$('#files-meta').textContent = '';
['#files-attach', '#files-rename', '#files-delete'].forEach(s => $(s)?.classList.add('hide'));
$('#files-preview').innerHTML = '';
$('#files-preview').appendChild(el('div', 'chat-msgs-empty', 'File deleted.'));
await refreshFiles();
if (S.chat && S.chat.openId) await refreshOpenChat();
} catch (e) { toast('Delete failed', String(e), 'err'); }
}
let _filesSearchTimer = null;
function onFilesSearchInput(value) {
if (_filesSearchTimer) clearTimeout(_filesSearchTimer);
_filesSearchTimer = setTimeout(() => refreshFiles(value), 220);
}
function installFilesDropZone() {
const main = document.querySelector('.files-main');
if (!main) return;
main.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
});
main.addEventListener('drop', async (e) => {
e.preventDefault();
const files = Array.from(e.dataTransfer.files || []);
for (const f of files) await uploadFileToLibrary(f);
});
}
window.addEventListener('DOMContentLoaded', () => {
const fsEl = $('#files-search');
if (fsEl) fsEl.addEventListener('input', e => onFilesSearchInput(e.target.value));
const up = $('#files-upload');
if (up) up.addEventListener('click', async () => {
const out = await axoModal({
title: 'Upload to library',
body: 'Files are stored on your machine, dedup\'d by content hash. PDFs / spreadsheets / images get their text extracted automatically for chat use.',
fields: [{ key: 'files', label: 'Files', kind: 'file', multiple: true }],
okLabel: 'Upload',
});
if (!out || !out.files) return;
for (const f of out.files) await uploadFileToLibrary(f);
});
$('#files-attach')?.addEventListener('click', attachOpenFileToChat);
$('#files-rename')?.addEventListener('click', renameOpenFile);
$('#files-delete')?.addEventListener('click', deleteOpenFile);
installFilesDropZone();
});
S.session = { id: null, dir: '', name: '', streamEl: null, streamBuf: '', toolCards: {}, openFiles: [], activeFile: null };
S.fpPath = '';
S.cockpitLayout = {
colWidths: [260, 240],
collapsed: { files: false, browser: false },
focused: null, paneOrder: ['files', 'stream', 'browser'],
termOpen: false, termOpenW: 460, termInnerListW: 140, };
S.sidebarMode = 'full';
const LAYOUT_KEY = 'axo.cockpit.v4';
const SIDEBAR_KEY = 'axo.sidebar.v1';
function persistLayout() {
try { localStorage.setItem(LAYOUT_KEY, JSON.stringify(S.cockpitLayout)); } catch {}
}
function persistSidebar() {
try { localStorage.setItem(SIDEBAR_KEY, S.sidebarMode); } catch {}
}
function loadPersistedLayout() {
try {
const s = JSON.parse(localStorage.getItem(LAYOUT_KEY) || 'null');
if (s && typeof s === 'object') {
if (Array.isArray(s.colWidths) && s.colWidths.length >= 2) {
const NEW = [260, 240];
const MIN = [200, 200];
const padded = s.colWidths.slice(0, 2);
while (padded.length < 2) padded.push(NEW[padded.length]);
for (let i = 0; i < 2; i++) {
if (padded[i] < MIN[i] || padded[i] > 600) padded[i] = NEW[i];
}
S.cockpitLayout.colWidths = padded;
}
if (s.collapsed && typeof s.collapsed === 'object') {
['files','browser'].forEach(k => {
if (typeof s.collapsed[k] === 'boolean') S.cockpitLayout.collapsed[k] = s.collapsed[k];
});
}
if (Array.isArray(s.paneOrder)) {
const want = ['files','stream','browser'].sort().join(',');
const got = [...s.paneOrder].sort().join(',');
if (want === got) S.cockpitLayout.paneOrder = s.paneOrder;
}
if (typeof s.termOpen === 'boolean') S.cockpitLayout.termOpen = s.termOpen;
if (typeof s.termOpenW === 'number' && s.termOpenW > 200) S.cockpitLayout.termOpenW = s.termOpenW;
if (typeof s.termInnerListW === 'number' && s.termInnerListW > 60) S.cockpitLayout.termInnerListW = s.termInnerListW;
}
} catch {}
try {
const m = localStorage.getItem(SIDEBAR_KEY);
if (m === 'mini' || m === 'hidden' || m === 'full') S.sidebarMode = m;
} catch {}
}
const PANE_ELS = {
files: '#cockpit-files',
stream: '#cockpit-stream',
browser: '#cockpit-browser',
};
const PANE_WIDTH_IDX = { files: 0, browser: 1 };
const DEFAULT_PANE_ORDER = ['files', 'stream', 'browser'];
function applyCockpitLayout() {
const grid = document.querySelector('.cockpit-grid');
if (!grid) return;
const L = S.cockpitLayout;
const order = (L.paneOrder && L.paneOrder.length === 3)
? L.paneOrder.slice()
: DEFAULT_PANE_ORDER.slice();
const px = n => n + 'px';
const trackFor = (key) => {
if (L.focused) return L.focused === key ? '1fr' : '0';
if (L.collapsed[key]) return '0';
if (key === 'stream') return '1fr';
return px(L.colWidths[PANE_WIDTH_IDX[key]]);
};
const tracks = [];
for (let i = 0; i < 3; i++) {
tracks.push(trackFor(order[i]));
if (i < 2) {
const leftHidden = (L.focused && L.focused !== order[i]) || L.collapsed[order[i]];
const rightHidden = (L.focused && L.focused !== order[i + 1]) || L.collapsed[order[i + 1]];
tracks.push((leftHidden || rightHidden) ? '0' : '6px');
}
}
grid.style.gridTemplateColumns = tracks.join(' ');
let lastVisibleIdx = -1;
order.forEach((key, i) => {
const hidden = (L.focused && L.focused !== key) || L.collapsed[key];
if (!hidden) lastVisibleIdx = i;
});
order.forEach((key, i) => {
const el = $(PANE_ELS[key]); if (!el) return;
el.style.gridColumn = String(i * 2 + 1);
const hidden = (L.focused && L.focused !== key) || L.collapsed[key];
el.style.display = hidden ? 'none' : 'flex';
el.classList.toggle('is-rightmost', i === lastVisibleIdx);
});
$$('.col-resizer').forEach(h => {
const w = parseInt(h.dataset.which, 10);
if (w > 2) { h.style.display = 'none'; return; }
h.style.gridColumn = String(w * 2);
const leftKey = order[w - 1];
const rightKey = order[w];
const leftHidden = (L.focused && L.focused !== leftKey) || L.collapsed[leftKey];
const rightHidden = (L.focused && L.focused !== rightKey) || L.collapsed[rightKey];
h.style.display = (leftHidden || rightHidden) ? 'none' : '';
});
applyTerminalsDrawer();
$$('.pane-btn[data-act="focus"]').forEach(b => b.classList.toggle('active', L.focused === b.dataset.pane));
$$('.pane-btn[data-act="collapse"]').forEach(b => {
const which = b.dataset.pane;
b.classList.toggle('active', !!L.collapsed[which]);
});
document.body.classList.remove('axo-resizing', 'axo-vresizing');
$$('.col-resizer.dragging')
.forEach(h => h.classList.remove('dragging'));
persistLayout();
requestAnimationFrame(() => relayoutEmbeddedEditors());
}
const TERM_RAIL_W = 40;
const TERM_DRAWER_MIN = 280;
const TERM_DRAWER_MAX = 900;
const TERM_INNER_MIN = 80;
function applyTerminalsDrawer() {
const drawer = document.getElementById('cockpit-terminals');
const shell = document.getElementById('cockpit-shell');
if (!drawer || !shell) return;
const L = S.cockpitLayout;
shell.style.setProperty('--term-rail-w', TERM_RAIL_W + 'px');
shell.style.setProperty('--term-open-w', (L.termOpenW || 460) + 'px');
drawer.classList.toggle('is-open', !!L.termOpen);
const list = document.getElementById('terminals-list');
if (list) list.style.width = (L.termInnerListW || 140) + 'px';
renderTermRailList();
}
function renderTermRailList() {
const rail = document.getElementById('term-rail-list');
if (!rail) return;
rail.innerHTML = '';
const tasks = (S.session && S.session.tasks) || [];
if (tasks.length === 0) return;
const activeId = S.session && S.session.activeTerminalId;
tasks.slice().reverse().forEach(t => {
const row = el('div', 'term-rail-row' + (t.id === activeId ? ' active' : ''));
const dot = el('span', 'term-dot ' + (typeof termStatusClass === 'function' ? termStatusClass(t.status) : (t.status || 'running')));
const label = (typeof termShortLabel === 'function' ? termShortLabel(t.command) : (t.command || 'terminal'));
row.append(dot, document.createTextNode(label));
row.title = (t.command || '') + '\n' + (t.status || '');
row.addEventListener('click', () => {
S.session.activeTerminalId = t.id;
S.cockpitLayout.termOpen = true;
applyCockpitLayout();
if (typeof renderTerminals === 'function') renderTerminals();
});
rail.appendChild(row);
});
}
function toggleTerminalsDrawer(open) {
const L = S.cockpitLayout;
L.termOpen = (typeof open === 'boolean') ? open : !L.termOpen;
applyCockpitLayout();
}
function attachTermDrawerOuterResizer() {
const h = document.getElementById('term-drawer-outer-resizer');
if (!h) return;
h.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
if (!S.cockpitLayout.termOpen) return;
e.preventDefault();
try { h.setPointerCapture(e.pointerId); } catch {}
h.classList.add('dragging');
document.body.classList.add('axo-resizing');
const startX = e.clientX;
const startW = S.cockpitLayout.termOpenW;
const onMove = (ev) => {
const dx = startX - ev.clientX; const w = Math.max(TERM_DRAWER_MIN, Math.min(TERM_DRAWER_MAX, startW + dx));
S.cockpitLayout.termOpenW = w;
applyCockpitLayout();
};
const onEnd = () => {
h.classList.remove('dragging');
document.body.classList.remove('axo-resizing');
try { h.releasePointerCapture(e.pointerId); } catch {}
h.removeEventListener('pointermove', onMove);
h.removeEventListener('pointerup', onEnd);
h.removeEventListener('pointercancel', onEnd);
};
h.addEventListener('pointermove', onMove);
h.addEventListener('pointerup', onEnd);
h.addEventListener('pointercancel', onEnd);
});
}
function attachTermInnerResizer() {
const h = document.getElementById('terminals-inner-resizer');
if (!h) return;
h.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
e.preventDefault();
try { h.setPointerCapture(e.pointerId); } catch {}
h.classList.add('dragging');
document.body.classList.add('axo-resizing');
const startX = e.clientX;
const startW = S.cockpitLayout.termInnerListW;
const max = (S.cockpitLayout.termOpenW || 460) - 80;
const onMove = (ev) => {
const dx = ev.clientX - startX;
S.cockpitLayout.termInnerListW = Math.max(TERM_INNER_MIN, Math.min(max, startW + dx));
applyCockpitLayout();
};
const onEnd = () => {
h.classList.remove('dragging');
document.body.classList.remove('axo-resizing');
try { h.releasePointerCapture(e.pointerId); } catch {}
h.removeEventListener('pointermove', onMove);
h.removeEventListener('pointerup', onEnd);
h.removeEventListener('pointercancel', onEnd);
};
h.addEventListener('pointermove', onMove);
h.addEventListener('pointerup', onEnd);
h.addEventListener('pointercancel', onEnd);
});
}
function attachTermDrawerToggle() {
const t = document.getElementById('term-rail-toggle');
const c = document.getElementById('term-drawer-close-btn');
const nRail = document.getElementById('term-rail-new-btn');
const nHead = document.getElementById('term-new-btn');
if (t) t.addEventListener('click', () => toggleTerminalsDrawer(true));
if (c) c.addEventListener('click', () => toggleTerminalsDrawer(false));
if (nRail) nRail.addEventListener('click', (e) => { e.stopPropagation(); toggleTerminalsDrawer(true); if (typeof openNewTerminalPop === 'function') openNewTerminalPop(); });
}
function relayoutEmbeddedEditors() {
try {
const ed = S.session && S.session.monaco && S.session.monaco.editor;
if (ed && typeof ed.layout === 'function') ed.layout();
} catch {}
try {
if (S.xterms) S.xterms.forEach(entry => {
try {
entry.fit && entry.fit.fit();
if (entry.ws && entry.ws.readyState === WebSocket.OPEN && entry.term) {
entry.ws.send(JSON.stringify({ kind: 'resize', rows: entry.term.rows, cols: entry.term.cols }));
}
} catch {}
});
} catch {}
}
const DEFAULT_LAYOUT = {
colWidths: [260, 240],
collapsed: { files: false, browser: false },
focused: null,
paneOrder: ['files', 'stream', 'browser'],
termOpen: false,
termOpenW: 460,
termInnerListW: 140,
};
function renderPanesMenu() {
const menu = $('#panes-menu'); if (!menu) return;
menu.innerHTML = '';
const L = S.cockpitLayout;
const panes = [
{ key: 'files', label: 'Files', sc: 'Ctrl+1' },
{ key: 'stream', label: 'Activity', sc: 'Ctrl+2', stream: true },
{ key: 'browser', label: 'Browser', sc: 'Ctrl+3' },
];
panes.forEach(p => {
const row = el('div', 'pm-item');
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = p.stream ? true : !L.collapsed[p.key];
if (p.stream) cb.disabled = true;
row.appendChild(cb);
row.appendChild(el('span', '', p.label));
if (p.sc) row.appendChild(el('span', 'pm-shortcut', p.sc));
row.addEventListener('click', (e) => {
if (p.stream) return;
if (e.target !== cb) cb.checked = !cb.checked;
L.focused = null;
L.collapsed[p.key] = !cb.checked;
applyCockpitLayout();
renderPanesMenu();
});
menu.appendChild(row);
});
menu.appendChild(el('div', 'pm-divider'));
const reset = el('div', 'pm-item pm-action');
reset.appendChild(el('span', '', '↺ Reset layout'));
reset.addEventListener('click', () => {
S.cockpitLayout = JSON.parse(JSON.stringify(DEFAULT_LAYOUT));
applyCockpitLayout();
renderPanesMenu();
});
menu.appendChild(reset);
}
function openPanesMenu() {
const menu = $('#panes-menu');
const btn = $('#panes-menu-btn');
if (!menu || !btn) return;
if (!menu.classList.contains('hide')) { menu.classList.add('hide'); return; }
renderPanesMenu();
menu.classList.remove('hide');
menu.style.visibility = 'hidden';
const r = btn.getBoundingClientRect();
const mw = menu.offsetWidth || 240;
let left = r.right - mw;
const margin = 8;
if (left < margin) left = margin;
if (left + mw > window.innerWidth - margin) left = window.innerWidth - mw - margin;
menu.style.top = (r.bottom + 4) + 'px';
menu.style.left = left + 'px';
menu.style.visibility = '';
setTimeout(() => {
document.addEventListener('mousedown', closePanesMenuOutside, { capture: true });
}, 0);
}
function closePanesMenuOutside(e) {
const menu = $('#panes-menu');
const btn = $('#panes-menu-btn');
if (!menu) return;
if (menu.contains(e.target) || (btn && btn.contains(e.target))) return;
menu.classList.add('hide');
document.removeEventListener('mousedown', closePanesMenuOutside, { capture: true });
}
function applySidebar() {
document.body.classList.remove('side-mini', 'side-hidden');
if (S.sidebarMode === 'mini') document.body.classList.add('side-mini');
if (S.sidebarMode === 'hidden') document.body.classList.add('side-hidden');
persistSidebar();
}
function cycleSidebar() {
S.sidebarMode = { full: 'mini', mini: 'hidden', hidden: 'full' }[S.sidebarMode] || 'full';
applySidebar();
}
function resizerTargetPane(which) {
const order = S.cockpitLayout.paneOrder || DEFAULT_PANE_ORDER;
const flexIdx = order.indexOf('stream');
const leftPos = which - 1;
const rightPos = which;
if (rightPos === flexIdx) return { key: order[leftPos], sign: +1 };
if (leftPos === flexIdx) return { key: order[rightPos], sign: -1 };
if (rightPos < flexIdx) return { key: order[leftPos], sign: +1 };
if (leftPos > flexIdx) return { key: order[rightPos], sign: -1 };
return { key: order[leftPos], sign: +1 };
}
const PANE_MINMAX = {
files: { min: 150, max: 700 },
browser: { min: 180, max: 800 },
};
function attachColResizer(handle, which) {
handle.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
e.preventDefault();
try { handle.setPointerCapture(e.pointerId); } catch {}
handle.classList.add('dragging');
document.body.classList.add('axo-resizing');
const startX = e.clientX;
const startW = [...S.cockpitLayout.colWidths];
const tgt = resizerTargetPane(which);
const idx = PANE_WIDTH_IDX[tgt.key];
const mm = PANE_MINMAX[tgt.key] || { min: 150, max: 800 };
const onMove = (ev) => {
const dx = ev.clientX - startX;
S.cockpitLayout.colWidths[idx] = Math.max(mm.min, Math.min(mm.max, startW[idx] + tgt.sign * dx));
applyCockpitLayout();
};
const onEnd = () => {
handle.classList.remove('dragging');
document.body.classList.remove('axo-resizing');
try { handle.releasePointerCapture(e.pointerId); } catch {}
handle.removeEventListener('pointermove', onMove);
handle.removeEventListener('pointerup', onEnd);
handle.removeEventListener('pointercancel', onEnd);
handle.removeEventListener('lostpointercapture', onEnd);
};
handle.addEventListener('pointermove', onMove);
handle.addEventListener('pointerup', onEnd);
handle.addEventListener('pointercancel', onEnd);
handle.addEventListener('lostpointercapture', onEnd);
});
}
let _dragPaneKey = null;
function attachPaneDragReorder() {
const heads = document.querySelectorAll('#session-cockpit .pane-head[draggable="true"]');
heads.forEach(h => {
h.addEventListener('dragstart', (e) => {
_dragPaneKey = h.dataset.pane;
h.classList.add('drag-source');
try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', _dragPaneKey); } catch {}
});
h.addEventListener('dragend', () => {
_dragPaneKey = null;
document.querySelectorAll('#session-cockpit .pane-head.drag-source, #session-cockpit .pane-head.drag-over')
.forEach(x => x.classList.remove('drag-source', 'drag-over'));
});
h.addEventListener('dragover', (e) => {
if (!_dragPaneKey || _dragPaneKey === h.dataset.pane) return;
e.preventDefault();
try { e.dataTransfer.dropEffect = 'move'; } catch {}
h.classList.add('drag-over');
});
h.addEventListener('dragleave', () => h.classList.remove('drag-over'));
h.addEventListener('drop', (e) => {
e.preventDefault();
h.classList.remove('drag-over');
const src = _dragPaneKey;
const dst = h.dataset.pane;
if (!src || !dst || src === dst) return;
const order = S.cockpitLayout.paneOrder.slice();
const si = order.indexOf(src), di = order.indexOf(dst);
if (si < 0 || di < 0) return;
order.splice(si, 1);
order.splice(di, 0, src);
S.cockpitLayout.paneOrder = order;
applyCockpitLayout();
});
});
}
['blur', 'visibilitychange'].forEach(ev => {
window.addEventListener(ev, () => {
document.querySelectorAll('.col-resizer.dragging')
.forEach(h => h.classList.remove('dragging'));
document.body.classList.remove('axo-resizing', 'axo-vresizing');
});
});
function onPaneBtn(e) {
const btn = e.target.closest('.pane-btn'); if (!btn) return;
const act = btn.dataset.act, pane = btn.dataset.pane;
if (act === 'focus') {
S.cockpitLayout.focused = S.cockpitLayout.focused === pane ? null : pane;
} else if (act === 'collapse') {
S.cockpitLayout.collapsed[pane] = !S.cockpitLayout.collapsed[pane];
}
applyCockpitLayout();
}
function onCockpitKey(e) {
if ((e.ctrlKey || e.metaKey) && e.key === '\\') {
e.preventDefault();
cycleSidebar();
return;
}
if ($('#session-cockpit').style.display === 'none' || !S.session.id) return;
const tag = (document.activeElement && document.activeElement.tagName) || '';
const inField = tag === 'INPUT' || tag === 'TEXTAREA';
if (e.key === 'Escape' && S.cockpitLayout.focused) {
S.cockpitLayout.focused = null; applyCockpitLayout(); return;
}
if (inField) return;
if ((e.ctrlKey || e.metaKey) && ['1','2','3','4'].includes(e.key)) {
e.preventDefault();
const map = { '1': 'files', '2': 'stream', '3': 'lattice', '4': 'browser' };
const which = map[e.key];
S.cockpitLayout.focused = S.cockpitLayout.focused === which ? null : which;
applyCockpitLayout();
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'b') {
e.preventDefault();
S.cockpitLayout.collapsed.files = !S.cockpitLayout.collapsed.files;
applyCockpitLayout();
}
}
const FILES_EXPLORER_W_KEY = 'axo:files-explorer-w';
function attachFilesInnerResizer() {
const h = $('#files-inner-resizer');
const ex = $('#files-explorer');
if (!h || !ex) return;
const saved = parseInt(localStorage.getItem(FILES_EXPLORER_W_KEY) || '0', 10);
if (saved > 0) ex.style.setProperty('--explorer-w', saved + 'px');
let dragging = false, startX = 0, startW = 0;
h.addEventListener('mousedown', (e) => {
e.preventDefault();
dragging = true;
startX = e.clientX;
startW = ex.getBoundingClientRect().width;
h.classList.add('dragging');
document.body.style.userSelect = 'none';
});
document.addEventListener('mousemove', (e) => {
if (!dragging) return;
const dx = e.clientX - startX;
let w = Math.max(140, Math.min(600, startW + dx));
ex.style.setProperty('--explorer-w', w + 'px');
});
document.addEventListener('mouseup', () => {
if (!dragging) return;
dragging = false;
h.classList.remove('dragging');
document.body.style.userSelect = '';
const w = parseInt(ex.style.getPropertyValue('--explorer-w'), 10);
if (w > 0) localStorage.setItem(FILES_EXPLORER_W_KEY, String(w));
});
}
let _cockpitInited = false;
function setCockpitLive(state, label) {
const pill = document.getElementById('cockpit-live');
if (!pill) return;
pill.classList.remove('idle');
if (state === 'idle' || state === 'done' || state === 'error') pill.classList.add('idle');
const lbl = pill.querySelector('.cockpit-live-label');
if (lbl) lbl.textContent = label || state;
}
function initCockpitChrome() {
if (_cockpitInited) return; _cockpitInited = true;
document.querySelectorAll('.col-resizer').forEach(h => attachColResizer(h, parseInt(h.dataset.which, 10)));
attachFilesInnerResizer();
attachPaneDragReorder();
attachTermDrawerOuterResizer();
attachTermInnerResizer();
attachTermDrawerToggle();
$('#session-cockpit').addEventListener('click', onPaneBtn);
const modes = $('#viewer-modes');
if (modes) modes.addEventListener('click', onViewerModeClick);
const saveBtn = $('#viewer-save');
if (saveBtn) saveBtn.addEventListener('click', (e) => { e.stopPropagation(); saveActiveFile(); });
document.addEventListener('mousedown', (e) => {
if (_selBubble && !_selBubble.contains(e.target)) hideSelBubble();
}, true);
const viewerCancelBtn = $('#viewer-cancel');
if (viewerCancelBtn) viewerCancelBtn.addEventListener('click', async (e) => {
e.stopPropagation();
const f = (S.session.openFiles || []).find(x => x.path === S.session.activeFile);
if (!f) return;
if (f.dirty) {
const ok = await axoConfirm({
title: 'Discard unsaved changes?',
body: `"${f.path}" has unsaved edits. They will be lost.`,
okLabel: 'Discard',
okKind: 'danger',
});
if (!ok) return;
}
f.draft = null; f.dirty = false; f.viewMode = 'code';
renderActiveFile();
});
const pbtn = $('#panes-menu-btn');
if (pbtn) pbtn.addEventListener('click', (e) => { e.stopPropagation(); openPanesMenu(); });
const newBtn = $('#term-new-btn');
if (newBtn) newBtn.addEventListener('click', (e) => {
e.stopPropagation();
const pop = $('#term-new-pop');
if (pop && !pop.classList.contains('hide')) closeTerminalPop();
else openTerminalPop();
});
const cancelBtn = $('#term-new-cancel');
if (cancelBtn) cancelBtn.addEventListener('click', closeTerminalPop);
const startBtn = $('#term-new-start');
if (startBtn) startBtn.addEventListener('click', submitNewTerminal);
const cmdInp = $('#term-new-cmd');
if (cmdInp) cmdInp.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); submitNewTerminal(); }
else if (e.key === 'Escape') { e.preventDefault(); closeTerminalPop(); }
});
const bBack = $('#bx-back'); if (bBack) bBack.addEventListener('click', browserBack);
const bFwd = $('#bx-forward'); if (bFwd) bFwd.addEventListener('click', browserForward);
const bRel = $('#bx-reload'); if (bRel) bRel.addEventListener('click', browserReload);
const bGo = $('#bx-go'); if (bGo) bGo.addEventListener('click', () => browserGo($('#bx-url').value));
const bOpen = $('#bx-open'); if (bOpen) bOpen.addEventListener('click', browserOpenExternal);
const bPick = $('#bx-pick'); if (bPick) bPick.addEventListener('click', toggleBrowserPick);
const hClose = $('#dom-hier-close'); if (hClose) hClose.addEventListener('click', () => closeDomHier(false));
const hCancel = $('#dom-hier-cancel'); if (hCancel) hCancel.addEventListener('click', () => closeDomHier(false));
const hConfirm = $('#dom-hier-confirm'); if (hConfirm) hConfirm.addEventListener('click', confirmDomHier);
const bUrl = $('#bx-url');
if (bUrl) bUrl.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); browserGo(bUrl.value); }
});
updateBrowserNavButtons();
applyCockpitLayout();
}
(function initAppChrome() {
loadPersistedLayout();
applySidebar();
const tog = document.getElementById('sidebar-toggle');
if (tog) tog.addEventListener('click', cycleSidebar);
document.addEventListener('keydown', onCockpitKey);
})();
function sessionDir(s) {
return typeof s.working_dir === 'string' ? s.working_dir : (s.working_dir || '');
}
const FAV_KEY = 'axo.finder.favorites.v1';
function makeSessionMarkIcon(sizePx) {
const img = document.createElement('img');
img.src = '/brand/mark.png';
img.alt = '';
img.style.cssText = `width:${sizePx}px;height:${sizePx}px;display:block;object-fit:contain;flex-shrink:0;`;
return img;
}
function loadFinderFavorites() {
try {
const raw = localStorage.getItem(FAV_KEY);
if (raw) return JSON.parse(raw);
} catch {}
return [];
}
function saveFinderFavorites(list) {
try { localStorage.setItem(FAV_KEY, JSON.stringify(list)); } catch {}
}
S.finder = {
favorites: loadFinderFavorites(), currentPath: null, selectedSession: null, back: [], forward: [],
searchTerm: '',
contents: { dirs: [], parent: null }, };
async function refreshSessions() {
try { S.sessions = await fetch('/api/sessions').then(r => r.json()); } catch { S.sessions = []; }
const cnt = $('#cnt-sessions'); if (cnt) cnt.textContent = (S.sessions || []).length;
renderFinderSidebar();
if (!S.finder.currentPath) {
const sessions = (S.sessions || []).slice().sort((a, b) => (b.last_active || 0) - (a.last_active || 0));
if (sessions.length) {
S.finder.currentPath = sessionDir(sessions[0]);
} else if (S.finder.favorites.length) {
S.finder.currentPath = S.finder.favorites[0].path;
}
}
if (S.finder.currentPath) {
await finderNavigate(S.finder.currentPath, { pushHistory: false });
} else {
renderFinderMain();
}
}
function renderFinderSidebar() {
const favHost = $('#finder-favorites'); if (!favHost) return;
favHost.innerHTML = '';
if (!S.finder.favorites.length) {
const empty = el('div', 'small muted', 'No folders yet.');
empty.style.padding = '6px 8px';
favHost.appendChild(empty);
} else {
S.finder.favorites.forEach((fav, i) => {
const row = el('div', 'finder-side-row' + (S.finder.currentPath === fav.path ? ' active' : ''));
row.appendChild(el('span', 'ico', '📁'));
row.appendChild(el('span', 'lbl', fav.label || fav.path.split('/').filter(Boolean).pop() || fav.path));
const x = el('span', 'x', '×');
x.title = 'Remove from favorites';
x.addEventListener('click', (e) => {
e.stopPropagation();
removeFavorite(fav.path);
});
row.appendChild(x);
row.title = fav.path;
row.addEventListener('click', () => finderNavigate(fav.path));
row.addEventListener('contextmenu', (e) => {
e.preventDefault();
favoriteContextMenu(fav, e.clientX, e.clientY);
});
attachFavoriteDrag(row, i);
favHost.appendChild(row);
});
}
const recHost = $('#finder-recent'); if (!recHost) return;
recHost.innerHTML = '';
const recent = (S.sessions || []).slice().sort((a, b) => (b.last_active || 0) - (a.last_active || 0)).slice(0, 6);
if (!recent.length) {
const empty = el('div', 'small muted', 'No sessions yet.');
empty.style.padding = '6px 8px';
recHost.appendChild(empty);
} else {
recent.forEach(s => {
const row = el('div', 'finder-side-row');
const ico = el('span', 'ico');
ico.appendChild(makeSessionMarkIcon(14));
row.appendChild(ico);
row.appendChild(el('span', 'lbl', s.name));
row.title = sessionDir(s);
row.addEventListener('click', () => openCockpit(s));
recHost.appendChild(row);
});
}
}
async function finderNavigate(path, opts) {
opts = opts || {};
if (!path) return;
if (S.finder.currentPath && S.finder.currentPath !== path && opts.pushHistory !== false) {
S.finder.back.push(S.finder.currentPath);
S.finder.forward = [];
}
S.finder.currentPath = path;
S.finder.selectedSession = null;
try {
const d = await fetch('/api/fs/list?path=' + encodeURIComponent(path)).then(r => r.json());
if (d && !d.error) {
S.finder.contents = { dirs: d.dirs || [], parent: d.parent || null, path: d.path || path };
S.finder.currentPath = d.path || path;
} else {
S.finder.contents = { dirs: [], parent: null, path };
}
} catch {
S.finder.contents = { dirs: [], parent: null, path };
}
renderFinderSidebar();
renderFinderMain();
}
function finderBack() {
if (!S.finder.back.length) return;
S.finder.forward.push(S.finder.currentPath);
const prev = S.finder.back.pop();
finderNavigate(prev, { pushHistory: false });
}
function finderForward() {
if (!S.finder.forward.length) return;
S.finder.back.push(S.finder.currentPath);
const next = S.finder.forward.pop();
finderNavigate(next, { pushHistory: false });
}
function finderUp() {
const parent = S.finder.contents && S.finder.contents.parent;
if (parent) finderNavigate(parent);
}
function renderFinderMain() {
const back = $('#finder-back'), fwd = $('#finder-forward'), up = $('#finder-up');
if (back) back.disabled = !S.finder.back.length;
if (fwd) fwd.disabled = !S.finder.forward.length;
if (up) up.disabled = !(S.finder.contents && S.finder.contents.parent);
const pathEl = $('#finder-path'); if (pathEl) {
pathEl.innerHTML = '';
const path = S.finder.currentPath || '';
if (!path) {
pathEl.appendChild(el('span', 'muted', 'No folder selected'));
} else {
const parts = path.split('/').filter(Boolean);
let acc = '';
parts.forEach((p, i) => {
acc += '/' + p;
const target = acc;
if (i > 0) pathEl.appendChild(el('span', 'sep', '›'));
const crumb = el('span', 'crumb', p);
crumb.addEventListener('click', () => finderNavigate(target));
pathEl.appendChild(crumb);
});
}
}
const host = $('#finder-rows'); if (!host) return;
host.innerHTML = '';
if (!S.finder.currentPath) {
const empty = el('div', 'finder-empty', 'Pick a folder on the left, or click + Add folder.');
host.appendChild(empty);
return;
}
const term = (S.finder.searchTerm || '').toLowerCase();
const dirs = (S.finder.contents.dirs || []).filter(d => !term || d.name.toLowerCase().includes(term));
const sessionsHere = (S.sessions || []).filter(s => sessionDir(s) === S.finder.currentPath)
.filter(s => !term || s.name.toLowerCase().includes(term));
if (!dirs.length && !sessionsHere.length) {
const empty = el('div', 'finder-empty',
'This folder has no subfolders and no sessions yet. ' +
'Click + New session to start one here.');
host.appendChild(empty);
return;
}
dirs.forEach(d => {
const row = el('div', 'finder-row folder');
const nm = el('div', 'nm');
const ico = el('span', 'nm-ico');
ico.appendChild(el('span', 'folder-skill', '📁'));
nm.appendChild(ico);
nm.appendChild(el('span', 'nm-txt', d.name));
row.appendChild(nm);
row.appendChild(el('span', 'meta', '—'));
row.appendChild(el('span', 'meta', '—'));
row.appendChild(el('span', 'meta', '—'));
row.addEventListener('dblclick', () => finderNavigate(d.path));
row.addEventListener('click', () => finderNavigate(d.path));
row.addEventListener('contextmenu', (e) => {
e.preventDefault();
folderContextMenu(d.path, e.clientX, e.clientY);
});
host.appendChild(row);
});
sessionsHere.forEach(s => {
const row = el('div', 'finder-row session');
const nm = el('div', 'nm');
const ico = el('span', 'nm-ico');
ico.appendChild(makeSessionMarkIcon(16));
nm.appendChild(ico);
nm.appendChild(el('span', 'nm-txt', s.name));
if (s.status === 'active') {
const dot = el('span', 'session-dot');
dot.title = 'Active';
nm.appendChild(dot);
} else if (s.status === 'closed') {
const dot = el('span', 'session-dot closed');
dot.title = 'Closed';
nm.appendChild(dot);
}
row.appendChild(nm);
const mode = (s.mode && s.mode.kind === 'single_agent')
? ('agent · ' + s.mode.agent_id)
: (s.mode && s.mode.kind === 'custom')
? ('custom · ' + ((s.mode.agents && s.mode.agents.length) || 0))
: 'lattice';
row.appendChild(el('span', 'meta', mode));
let agentCount = 1;
if (s.mode && s.mode.kind === 'lattice') agentCount = (S.agents || []).length;
else if (s.mode && s.mode.kind === 'custom') agentCount = (s.mode.agents || []).length;
row.appendChild(el('span', 'meta', String(agentCount)));
row.appendChild(el('span', 'meta', relativeTime(s.last_active)));
row.addEventListener('click', () => {
S.finder.selectedSession = s.id;
renderFinderMain();
});
row.addEventListener('dblclick', () => openCockpit(s));
row.addEventListener('contextmenu', (e) => {
e.preventDefault();
S.finder.selectedSession = s.id;
sessionContextMenu(s, e.clientX, e.clientY, row);
});
if (s.id === S.finder.selectedSession) row.classList.add('active');
host.appendChild(row);
});
}
function relativeTime(sec) {
if (!sec) return '—';
const now = Math.floor(Date.now() / 1000);
const d = now - sec;
if (d < 60) return 'just now';
if (d < 3600) return Math.floor(d / 60) + 'm ago';
if (d < 86400) return Math.floor(d / 3600) + 'h ago';
if (d < 86400 * 7) return Math.floor(d / 86400) + 'd ago';
return new Date(sec * 1000).toLocaleDateString();
}
async function finderAddFavorite() {
S.finder._addingFavorite = true;
document.body.classList.add('fp-favorite-mode');
$('#fp-head').textContent = 'Add a folder Axocoatl can work in';
$('#fp-use').textContent = 'Add this folder';
openFolderPicker();
}
S.interrupts = { items: [], timer: null, _lastKeys: '' };
async function refreshInterrupts() {
let items = [];
try {
items = await fetch('/api/interrupts').then(r => r.json());
} catch {}
S.interrupts.items = Array.isArray(items) ? items : [];
S.pendingInterrupts = S.interrupts.items.length;
const pill = $('#interrupts-pill');
const count = $('#interrupts-count');
if (pill && count) {
count.textContent = S.interrupts.items.length;
pill.classList.toggle('hide', S.interrupts.items.length === 0);
}
renderStatusPearls();
if (!$('#interrupts-pop').classList.contains('hide')) {
const newKeys = S.interrupts.items
.map(it => `${it.automation_id}:${it.run_id}:${it.node_id}`)
.sort()
.join('|');
if (newKeys !== S.interrupts._lastKeys) {
S.interrupts._lastKeys = newKeys;
renderInterruptsList();
}
} else {
S.interrupts._lastKeys = '';
}
}
function renderInterruptsList() {
const host = $('#interrupts-list'); if (!host) return;
host.innerHTML = '';
if (!S.interrupts.items.length) {
host.appendChild(el('div', 'interrupts-empty', 'No pending interrupts.'));
return;
}
S.interrupts.items.forEach(it => {
const card = el('div', 'interrupt-card');
card.appendChild(el('div', 'meta',
it.automation_id + ' · run ' + (it.run_id || '').slice(0, 8) + ' · node ' + it.node_id
));
const msg = el('div', 'msg prose');
msg.innerHTML = mdRender(it.message || '*(no message)*');
card.appendChild(msg);
const row = el('div', 'row');
const inp = document.createElement('textarea');
inp.className = 'input';
inp.rows = 1;
inp.placeholder = 'Your guidance — becomes the node output. ⌘+Enter to submit.';
inp.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
resumeInterrupt(it, inp.value || '');
}
});
row.appendChild(inp);
const ok = el('button', 'btn-resume', '✓ Resume');
ok.addEventListener('click', () => resumeInterrupt(it, inp.value || ''));
row.appendChild(ok);
const skip = el('button', 'btn-cancel', 'Cancel');
skip.addEventListener('click', () => cancelInterrupt(it));
row.appendChild(skip);
card.appendChild(row);
host.appendChild(card);
});
}
async function resumeInterrupt(it, value) {
try {
const url = `/api/automations/${encodeURIComponent(it.automation_id)}/runs/${encodeURIComponent(it.run_id)}/nodes/${encodeURIComponent(it.node_id)}/resume`;
const r = await fetch(url, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ value }),
});
if (!r.ok) { toast('Resume failed', 'HTTP ' + r.status, 'err'); return; }
toast('Resumed', it.node_id, 'ok');
refreshInterrupts();
} catch (e) { toast('Resume failed', String(e), 'err'); }
}
async function cancelInterrupt(it) {
try {
const url = `/api/automations/${encodeURIComponent(it.automation_id)}/runs/${encodeURIComponent(it.run_id)}/nodes/${encodeURIComponent(it.node_id)}/cancel`;
const r = await fetch(url, { method: 'POST' });
if (!r.ok) { toast('Cancel failed', 'HTTP ' + r.status, 'err'); return; }
refreshInterrupts();
} catch (e) { toast('Cancel failed', String(e), 'err'); }
}
function toggleInterruptsPop() {
const pop = $('#interrupts-pop');
if (!pop) return;
if (pop.classList.contains('hide')) {
pop.classList.remove('hide');
renderInterruptsList();
S.interrupts._lastKeys = S.interrupts.items
.map(it => `${it.automation_id}:${it.run_id}:${it.node_id}`)
.sort().join('|');
} else {
pop.classList.add('hide');
}
}
if ($('#interrupts-pill')) $('#interrupts-pill').addEventListener('click', toggleInterruptsPop);
if ($('#interrupts-close')) $('#interrupts-close').addEventListener('click', () => $('#interrupts-pop').classList.add('hide'));
if ($('#interrupts-fullscreen')) {
$('#interrupts-fullscreen').addEventListener('click', () => {
const pop = $('#interrupts-pop');
const btn = $('#interrupts-fullscreen');
const next = !pop.classList.contains('fullscreen');
pop.classList.toggle('fullscreen', next);
btn.textContent = next ? '⤡' : '⛶';
btn.title = next ? 'Exit fullscreen' : 'Toggle fullscreen';
});
}
let _ctxMenuEl = null;
function showContextMenu(x, y, items) {
hideContextMenu();
const menu = document.createElement('div');
menu.className = 'ctx-menu';
items.forEach(item => {
if (item.sep) { menu.appendChild(el('div', 'ctx-sep')); return; }
const row = el('div', 'ctx-item' + (item.danger ? ' danger' : '') + (item.disabled ? ' disabled' : ''));
row.appendChild(el('span', '', item.label));
if (item.kbd) row.appendChild(el('span', 'ctx-kbd', item.kbd));
if (!item.disabled) {
row.addEventListener('click', (e) => {
e.stopPropagation();
hideContextMenu();
try { item.onClick && item.onClick(); } catch (err) { console.error(err); }
});
}
menu.appendChild(row);
});
document.body.appendChild(menu);
_ctxMenuEl = menu;
const r = menu.getBoundingClientRect();
const vw = window.innerWidth, vh = window.innerHeight;
const left = x + r.width > vw - 6 ? Math.max(6, x - r.width) : x;
const top = y + r.height > vh - 6 ? Math.max(6, y - r.height) : y;
menu.style.left = left + 'px';
menu.style.top = top + 'px';
setTimeout(() => {
document.addEventListener('mousedown', _ctxMenuOutside, true);
document.addEventListener('keydown', _ctxMenuKey, true);
window.addEventListener('resize', hideContextMenu);
window.addEventListener('scroll', hideContextMenu, true);
}, 0);
}
function hideContextMenu() {
if (!_ctxMenuEl) return;
_ctxMenuEl.remove(); _ctxMenuEl = null;
document.removeEventListener('mousedown', _ctxMenuOutside, true);
document.removeEventListener('keydown', _ctxMenuKey, true);
window.removeEventListener('resize', hideContextMenu);
window.removeEventListener('scroll', hideContextMenu, true);
}
function _ctxMenuOutside(e) {
if (_ctxMenuEl && !_ctxMenuEl.contains(e.target)) hideContextMenu();
}
function _ctxMenuKey(e) { if (e.key === 'Escape') hideContextMenu(); }
async function renameSessionPrompt(s, rowElForInline) {
const newName = rowElForInline
? await inlineRename(rowElForInline, s.name)
: await axoPrompt({ title: 'Rename session', label: 'Name', value: s.name });
if (!newName || newName === s.name) return;
try {
const r = await fetch('/api/sessions/' + encodeURIComponent(s.id), {
method: 'PATCH', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: newName }),
});
const j = await r.json().catch(() => ({}));
if (!r.ok) { toast('Rename failed', j.error || ('HTTP ' + r.status), 'err'); return; }
toast('Session renamed', newName, 'ok');
await refreshSessions();
} catch (e) { toast('Rename failed', String(e), 'err'); }
}
function inlineRename(rowEl, current) {
return new Promise(resolve => {
const target = rowEl.querySelector('.nm-txt');
if (!target) return resolve(null);
const inp = document.createElement('input');
inp.type = 'text';
inp.className = 'finder-rename';
inp.value = current;
target.replaceWith(inp);
inp.focus();
inp.select();
let done = false;
const commit = (v) => { if (done) return; done = true; resolve(v); };
inp.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); commit(inp.value.trim()); }
else if (e.key === 'Escape') { commit(null); }
});
inp.addEventListener('blur', () => commit(inp.value.trim()));
});
}
async function closeSessionAction(s) {
const ok = await axoConfirm({
title: 'Close this session?',
body: `"${s.name}"'s container will stop. You can reopen it later — its history stays.`,
okLabel: 'Close session',
});
if (!ok) return;
try {
const r = await fetch('/api/sessions/' + encodeURIComponent(s.id), { method: 'DELETE' });
if (!r.ok) {
const j = await r.json().catch(() => ({}));
toast('Close failed', j.error || ('HTTP ' + r.status), 'err');
return;
}
toast('Session closed', s.name, 'ok');
await refreshSessions();
} catch (e) { toast('Close failed', String(e), 'err'); }
}
async function deleteSessionAction(s) {
const ok = await axoConfirm({
title: 'Delete this session permanently?',
body: `"${s.name}"'s config and history will be gone. Files in ${sessionDir(s)} are NOT touched.`,
okLabel: 'Delete session',
okKind: 'danger',
});
if (!ok) return;
try {
const r = await fetch('/api/sessions/' + encodeURIComponent(s.id) + '?force=true', { method: 'DELETE' });
if (!r.ok) {
const j = await r.json().catch(() => ({}));
toast('Delete failed', j.error || ('HTTP ' + r.status), 'err');
return;
}
toast('Session deleted', s.name, 'ok');
await refreshSessions();
} catch (e) { toast('Delete failed', String(e), 'err'); }
}
function sessionContextMenu(s, x, y, rowEl) {
const active = s.status === 'active';
showContextMenu(x, y, [
{ label: 'Open', onClick: () => openCockpit(s) },
{ label: 'Open in new window', onClick: () => window.open('/', '_blank') },
{ sep: true },
{ label: 'Rename…', onClick: () => renameSessionPrompt(s, rowEl) },
{ label: 'Reveal working directory', onClick: () => navigator.clipboard.writeText(sessionDir(s)).then(() => toast('Path copied', sessionDir(s), 'ok')) },
{ sep: true },
{ label: active ? 'Close' : 'Close (already closed)', onClick: () => closeSessionAction(s), disabled: !active },
{ label: 'Delete…', danger: true, onClick: () => deleteSessionAction(s) },
]);
}
function folderContextMenu(dirPath, x, y) {
const fav = (S.finder.favorites || []).find(f => f.path === dirPath);
showContextMenu(x, y, [
{ label: 'Open', onClick: () => finderNavigate(dirPath) },
{ label: 'New session here…', onClick: () => {
S.finder.currentPath = dirPath;
finderNewSessionHere();
} },
{ sep: true },
fav
? { label: 'Remove from favorites', onClick: () => removeFavorite(dirPath) }
: { label: 'Add to favorites', onClick: () => addFavoriteByPath(dirPath) },
]);
}
function favoriteContextMenu(fav, x, y) {
showContextMenu(x, y, [
{ label: 'Open', onClick: () => finderNavigate(fav.path) },
{ label: 'Rename…', onClick: () => renameFavoritePrompt(fav) },
{ sep: true },
{ label: 'Remove from favorites', danger: true, onClick: () => removeFavorite(fav.path) },
]);
}
async function renameFavoritePrompt(fav) {
const next = await axoPrompt({
title: 'Rename favorite',
label: 'Name',
value: fav.label || fav.path,
});
if (!next || !next.trim()) return;
fav.label = next.trim();
saveFinderFavorites(S.finder.favorites);
renderFinderSidebar();
}
function addFavoriteByPath(path) {
if ((S.finder.favorites || []).some(f => f.path === path)) {
toast('Already in favorites', path, 'ok');
return;
}
const label = path.split('/').filter(Boolean).pop() || path;
S.finder.favorites.push({ path, label });
saveFinderFavorites(S.finder.favorites);
renderFinderSidebar();
toast('Added to favorites', label, 'ok');
}
function removeFavorite(path) {
const idx = (S.finder.favorites || []).findIndex(f => f.path === path);
if (idx < 0) return;
S.finder.favorites.splice(idx, 1);
saveFinderFavorites(S.finder.favorites);
renderFinderSidebar();
if (S.finder.currentPath === path && S.finder.contents.parent) {
finderNavigate(S.finder.contents.parent);
}
}
let _dragFavIndex = null;
function attachFavoriteDrag(row, idx) {
row.draggable = true;
row.addEventListener('dragstart', (e) => {
_dragFavIndex = idx;
row.classList.add('dragging');
try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(idx)); } catch {}
});
row.addEventListener('dragend', () => {
row.classList.remove('dragging');
_dragFavIndex = null;
$$('#finder-favorites .finder-side-row').forEach(r => r.classList.remove('drop-above', 'drop-below'));
});
row.addEventListener('dragover', (e) => {
if (_dragFavIndex == null) return;
e.preventDefault();
const r = row.getBoundingClientRect();
const above = (e.clientY - r.top) < r.height / 2;
row.classList.toggle('drop-above', above);
row.classList.toggle('drop-below', !above);
});
row.addEventListener('dragleave', () => {
row.classList.remove('drop-above', 'drop-below');
});
row.addEventListener('drop', (e) => {
e.preventDefault();
const from = _dragFavIndex;
if (from == null || from === idx) return;
const r = row.getBoundingClientRect();
const above = (e.clientY - r.top) < r.height / 2;
let to = idx + (above ? 0 : 1);
if (from < to) to -= 1;
const list = S.finder.favorites;
const [moved] = list.splice(from, 1);
list.splice(to, 0, moved);
saveFinderFavorites(list);
renderFinderSidebar();
});
}
function finderNewSessionHere() {
if (!S.finder.currentPath) {
toast('Pick a folder first', '', 'err');
return;
}
S.finder._addingFavorite = false;
document.body.classList.remove('fp-favorite-mode');
$('#fp-head').textContent = 'New session — in ' + S.finder.currentPath;
$('#fp-use').textContent = 'Create session';
openFolderPicker();
S.fpPath = S.finder.currentPath;
fpNavigate(S.finder.currentPath);
}
function populateAgentSelect() {
const sel = $('#session-agent');
if (sel && !sel.childElementCount && S.agents && S.agents.length) {
S.agents.forEach(a => {
const id = a.id || a;
const o = el('option', '', id); o.value = id; sel.appendChild(o);
});
}
}
async function loadFpSkills() {
const host = $('#fp-skills');
if (!host) return;
let skills = [];
try { skills = await fetch('/api/skills').then(r => r.json()); } catch {}
if (!Array.isArray(skills) || !skills.length) {
host.innerHTML = '<span class="small muted">No skills configured.</span>';
return;
}
host.innerHTML = '<span class="small muted">Skills this session may fire:</span><br>';
skills.forEach(g => {
const lab = el('label');
const cb = el('input'); cb.type = 'checkbox'; cb.value = g.id; cb.className = 'fp-skill-cb';
lab.appendChild(cb);
lab.appendChild(document.createTextNode(g.name || g.id));
host.appendChild(lab);
});
}
function openFolderPicker() {
populateAgentSelect();
loadFpSkills();
renderFpCustomAgents();
populateCopyFromSessions();
applyFpModeUi();
$('#folder-modal').classList.remove('hide');
fpNavigate('');
}
function populateCopyFromSessions() {
const sel = $('#fp-copy-from'); if (!sel) return;
sel.innerHTML = '<option value="">— start from defaults —</option>';
(S.sessions || []).forEach(s => {
const opt = el('option', '', s.name + ' · ' + sessionDir(s));
opt.value = s.id;
sel.appendChild(opt);
});
sel.value = '';
}
function applyCopyFromSession(sessionId) {
const s = (S.sessions || []).find(x => x.id === sessionId);
if (!s) return;
const portsEl = $('#fp-ports');
if (portsEl) portsEl.value = (s.exposed_ports || []).join(', ');
if (s.image) {
const sel = $('#fp-image-preset');
if (sel) {
const known = Array.from(sel.options).find(o => o.value === s.image);
if (known) {
sel.value = s.image;
$('#fp-image').classList.add('hide');
} else {
sel.value = '__custom__';
const inp = $('#fp-image');
if (inp) { inp.value = s.image; inp.classList.remove('hide'); }
}
}
} else {
const sel = $('#fp-image-preset');
if (sel) { sel.value = ''; $('#fp-image').classList.add('hide'); }
}
const modeSel = $('#fp-mode');
if (s.mode && s.mode.kind === 'single_agent') {
if (modeSel) modeSel.value = 'single_agent';
if ($('#session-agent')) $('#session-agent').value = s.mode.agent_id || '';
} else if (s.mode && s.mode.kind === 'lattice') {
if (modeSel) modeSel.value = 'lattice';
} else if (s.mode && s.mode.kind === 'custom') {
if (modeSel) modeSel.value = 'custom';
const picks = new Set(s.mode.agents || []);
$$('.fp-custom-cb').forEach(cb => { cb.checked = picks.has(cb.value); });
}
applyFpModeUi();
const skills = new Set(s.enabled_skills || []);
$$('.fp-skill-cb').forEach(cb => { cb.checked = skills.has(cb.value); });
toast('Copied config', s.name, 'ok');
}
function renderFpCustomAgents() {
const host = $('#fp-custom-row'); if (!host) return;
host.innerHTML = '';
(S.agents || []).forEach(a => {
const lbl = el('label', '');
lbl.style.cssText = 'display:inline-flex; align-items:center; gap:5px; font-size:12px; margin:3px 10px 3px 0;';
const cb = document.createElement('input');
cb.type = 'checkbox'; cb.className = 'fp-custom-cb'; cb.value = a.id;
lbl.appendChild(cb);
lbl.appendChild(el('span', '', a.name || a.id));
if ((a.depends_on || []).length) {
lbl.appendChild(el('span', 'small muted', '← ' + a.depends_on.join(', ')));
}
host.appendChild(lbl);
});
}
function applyFpModeUi() {
const mode = ($('#fp-mode') && $('#fp-mode').value) || 'single_agent';
const hint = $('#fp-mode-hint');
const customRow = $('#fp-custom-row');
const agentLabel = $('#fp-agent-label');
const agentSel = $('#session-agent');
if (mode === 'single_agent') {
if (hint) hint.textContent = 'One agent builds in the directory.';
customRow && customRow.classList.add('hide');
if (agentLabel) agentLabel.style.display = '';
if (agentSel) agentSel.style.display = '';
} else if (mode === 'lattice') {
if (hint) hint.textContent = 'The full multi-agent lattice runs in topological order.';
customRow && customRow.classList.add('hide');
if (agentLabel) agentLabel.style.display = 'none';
if (agentSel) agentSel.style.display = 'none';
} else if (mode === 'custom') {
if (hint) hint.textContent = 'Pick the agents — edges come from their depends_on.';
customRow && customRow.classList.remove('hide');
if (agentLabel) agentLabel.style.display = 'none';
if (agentSel) agentSel.style.display = 'none';
}
}
async function fpNavigate(path) {
let d;
try {
d = await fetch('/api/fs/list' + (path ? '?path=' + encodeURIComponent(path) : '')).then(r => r.json());
} catch { return; }
if (d.error) { toast('Cannot open folder', d.error, 'err'); return; }
S.fpPath = d.path;
$('#fp-path').textContent = d.path;
const list = $('#fp-list'); list.innerHTML = '';
if (d.parent) {
const up = el('div', 'fp-row');
up.appendChild(el('span', 'ftn-ico', '↑'));
up.appendChild(el('span', '', '.. (parent)'));
up.addEventListener('click', () => fpNavigate(d.parent));
list.appendChild(up);
}
(d.dirs || []).forEach(dir => {
const row = el('div', 'fp-row');
row.appendChild(el('span', 'ftn-ico', '▸'));
row.appendChild(el('span', '', dir.name));
row.addEventListener('click', () => fpNavigate(dir.path));
list.appendChild(row);
});
await fpProbeProject(d.path);
}
async function fpProbeProject(dir) {
const banner = $('#fp-project-probe'); if (!banner) return;
let p = null;
try {
p = await fetch('/api/fs/project?path=' + encodeURIComponent(dir)).then(r => r.json());
} catch {}
S.fpProbed = p || {};
const dc = (p && p.devcontainer && !p.devcontainer.error) ? p.devcontainer : null;
const axo = (p && Array.isArray(p.axocoatl_md)) ? p.axocoatl_md : [];
if (!dc && !axo.length) { banner.classList.add('hide'); banner.innerHTML = ''; return; }
banner.classList.remove('hide');
banner.innerHTML = '';
if (dc) {
const row = el('div');
row.style.cssText = 'font-size:12px; margin-bottom:6px;';
const label = el('strong', '', '📦 devcontainer.json');
label.style.cssText = 'color:var(--accent);';
row.appendChild(label);
row.appendChild(el('span', '', ' '));
if (dc.image) row.appendChild(el('span', 'mono small', dc.image));
if (dc.post_create_scripts && dc.post_create_scripts.length) {
row.appendChild(el('div', 'small muted',
'↳ post-create: ' + dc.post_create_scripts.join(' ; ')));
}
if (dc.forwarded_ports && dc.forwarded_ports.length) {
row.appendChild(el('div', 'small muted',
'↳ ports: ' + dc.forwarded_ports.join(', ')));
}
if (dc.ignored_fields && dc.ignored_fields.length) {
const ig = el('div', 'small muted');
ig.style.color = '#c4a060';
ig.textContent = '↳ ignored (v0.1): ' + dc.ignored_fields.join(', ');
row.appendChild(ig);
}
banner.appendChild(row);
if (dc.image && $('#fp-image-preset')) {
const sel = $('#fp-image-preset');
const known = Array.from(sel.options).find(o => o.value === dc.image);
if (known) {
sel.value = dc.image;
} else {
sel.value = '__custom__';
const inp = $('#fp-image');
if (inp) { inp.value = dc.image; inp.classList.remove('hide'); }
}
}
}
if (axo.length) {
const row = el('div');
row.style.cssText = 'font-size:12px;';
const label = el('strong', '', '📜 AXOCOATL.md');
label.style.cssText = 'color:var(--accent);';
row.appendChild(label);
row.appendChild(el('span', 'small muted', ' ' + axo.length + ' file(s) on path'));
axo.forEach(p => {
row.appendChild(el('div', 'small muted mono', '↳ ' + p));
});
banner.appendChild(row);
}
}
async function fpUseFolder() {
const dir = S.fpPath;
if (!dir) return;
if (S.finder && S.finder._addingFavorite) {
const label = dir.split('/').filter(Boolean).pop() || dir;
const exists = (S.finder.favorites || []).some(f => f.path === dir);
if (!exists) {
S.finder.favorites.push({ path: dir, label });
saveFinderFavorites(S.finder.favorites);
}
S.finder._addingFavorite = false;
document.body.classList.remove('fp-favorite-mode');
$('#folder-modal').classList.add('hide');
finderNavigate(dir);
toast('Folder added', dir, 'ok');
return;
}
const modeKind = ($('#fp-mode') && $('#fp-mode').value) || 'single_agent';
let mode;
if (modeKind === 'single_agent') {
mode = { kind: 'single_agent', agent_id: $('#session-agent').value };
} else if (modeKind === 'lattice') {
mode = { kind: 'lattice' };
} else if (modeKind === 'custom') {
const picked = $$('.fp-custom-cb').filter(c => c.checked).map(c => c.value);
if (!picked.length) {
toast('Pick at least one agent', '', 'err');
return;
}
mode = { kind: 'custom', agents: picked };
}
const name = dir.split('/').filter(Boolean).pop() || dir;
const enabled_skills = $$('.fp-skill-cb').filter(c => c.checked).map(c => c.value);
const portsRaw = ($('#fp-ports').value || '').trim();
const exposed_ports = portsRaw
? portsRaw.split(/[,\s]+/).map(s => parseInt(s, 10)).filter(n => Number.isFinite(n) && n > 0 && n < 65536)
: [];
const preset = $('#fp-image-preset') ? $('#fp-image-preset').value : '';
let imageStr = '';
if (preset === '__custom__') {
imageStr = ($('#fp-image').value || '').trim();
} else if (preset) {
imageStr = preset;
}
const image = imageStr || null;
try {
const res = await fetch('/api/sessions', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name, working_dir: dir,
mode,
enabled_skills,
exposed_ports,
image,
}),
});
const j = await res.json();
if (!res.ok) { toast('Could not create session', j.error || '', 'err'); return; }
$('#folder-modal').classList.add('hide');
toast('Session created', j.name, 'ok');
await refreshSessions();
openCockpit(j);
} catch (e) { toast('Could not create session', String(e), 'err'); }
}
function openCockpit(s) {
S.session = {
id: s.id, dir: sessionDir(s), name: s.name,
streamEl: null, streamBuf: '', toolCards: {},
openFiles: [], activeFile: null,
refs: [],
autoTerminalDone: false,
};
renderChatRefs();
$('#session-home').style.display = 'none';
$('#session-cockpit').style.display = 'flex';
initCockpitChrome();
$('#cockpit-title').textContent = s.name;
$('#cockpit-dir').textContent = sessionDir(s);
$('#cockpit-status').textContent = '';
setCockpitLive('idle', 'idle');
$('#session-msgs').innerHTML = '';
$('#file-tabs').innerHTML = '';
$('#file-viewer').innerHTML = '<div class="empty small">Select a file to view</div>';
loadFileTree('', $('#file-tree'));
sessionLatticeBuild(s); renderSessionActive(s);
populateSessionModelPicker(s);
populateSessionAgentTarget(s);
if (S.session.taskTimer) clearInterval(S.session.taskTimer);
S.session.taskTimer = setInterval(refreshSessionTasks, 4000);
refreshSessionTasks().then(maybeAutoStartTerminal);
$('#session-text').focus();
}
async function maybeAutoStartTerminal() {
if (!S.session.id || S.session.autoTerminalDone) return;
S.session.autoTerminalDone = true;
const live = (S.session.tasks || []).filter(
t => t.kind === 'terminal' && String(t.status || '').startsWith('running')
);
if (live.length) {
S.session.activeTerminalId = live[live.length - 1].id;
renderTerminals();
return;
}
S.session.autoTerminalStarting = true;
renderTerminals();
try {
const r = await fetch('/api/sessions/' + encodeURIComponent(S.session.id) + '/tasks', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ command: 'bash', interactive: true, rows: 30, cols: 100 }),
});
const j = await r.json().catch(() => ({}));
if (r.ok && j.id) {
S.session.activeTerminalId = j.id;
} else {
toast('Auto terminal failed', j && j.error ? j.error : ('HTTP ' + r.status), 'err');
}
} catch (e) {
console.warn('auto-start terminal failed:', e);
toast('Auto terminal failed', String(e), 'err');
} finally {
S.session.autoTerminalStarting = false;
await refreshSessionTasks();
}
}
function closeCockpit() {
if (S.session.taskTimer) { clearInterval(S.session.taskTimer); S.session.taskTimer = null; }
teardownXterms();
_lastMountedTerminal = { id: null, kind: null };
S.session.id = null;
S.browser = { url: null, suggestions: [], seen: new Set(), history: [], histIdx: -1 };
$('#session-cockpit').style.display = 'none';
$('#session-home').style.display = '';
refreshSessions();
}
S.browser = { url: null, suggestions: [], seen: new Set(), history: [], histIdx: -1 };
const URL_RE = /\bhttps?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(?::\d{2,5})?(?:\/[^\s'"<>`]*)?/g;
function detectUrlsFromTasks() {
const tasks = S.session.tasks || [];
let added = false;
for (const t of tasks) {
const text = (t.log || '') + ' ' + (t.command || '');
const m = text.match(URL_RE);
if (!m) continue;
for (const raw of m) {
const u = raw.replace('://0.0.0.0', '://localhost').replace('://127.0.0.1', '://localhost');
if (!S.browser.seen.has(u)) {
S.browser.seen.add(u);
S.browser.suggestions.unshift(u);
added = true;
}
}
}
if (added) renderBrowserSuggestions();
}
function renderBrowserSuggestions() {
const bar = $('#bx-suggest'); if (!bar) return;
const sug = S.browser.suggestions.slice(0, 6);
if (!sug.length) { bar.classList.add('hide'); return; }
bar.innerHTML = '';
bar.classList.remove('hide');
sug.forEach(u => {
const chip = el('span', 'bx-chip');
chip.appendChild(el('span', '', u));
const x = el('span', 'bx-chip-x', '×');
chip.appendChild(x);
chip.addEventListener('click', (e) => {
if (e.target === x) {
const i = S.browser.suggestions.indexOf(u);
if (i >= 0) S.browser.suggestions.splice(i, 1);
renderBrowserSuggestions();
return;
}
browserGo(u);
});
bar.appendChild(chip);
});
}
function proxyUrlFor(logical) {
try {
const u = new URL(logical);
const isLocal = u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '0.0.0.0';
if (!isLocal || !S.session.id) return logical;
const port = u.port || (u.protocol === 'https:' ? '443' : '80');
const tail = u.pathname.replace(/^\//, '') + (u.search || '');
return '/api/sessions/' + encodeURIComponent(S.session.id) + '/proxy/' + port + (tail ? '/' + tail : '');
} catch { return logical; }
}
function mountBrowserIframe(srcUrl) {
const body = $('#browser-body'); if (!body) return;
const ph = $('#bx-placeholder'); if (ph) ph.style.display = 'none';
body.querySelectorAll('iframe').forEach(f => f.remove());
const iframe = document.createElement('iframe');
iframe.src = srcUrl;
iframe.setAttribute('sandbox',
'allow-scripts allow-same-origin allow-forms allow-popups allow-modals');
body.appendChild(iframe);
}
function browserGo(rawUrl) {
let url = (rawUrl || '').trim();
if (!url) return;
if (!/^https?:\/\//i.test(url)) url = 'http://' + url;
if (S.browser.histIdx < S.browser.history.length - 1) {
S.browser.history = S.browser.history.slice(0, S.browser.histIdx + 1);
}
if (S.browser.history[S.browser.history.length - 1] !== url) {
S.browser.history.push(url);
S.browser.histIdx = S.browser.history.length - 1;
}
S.browser.url = url;
$('#bx-url').value = url;
mountBrowserIframe(proxyUrlFor(url));
if (S.cockpitLayout && S.cockpitLayout.collapsed && S.cockpitLayout.collapsed.browser) {
S.cockpitLayout.collapsed.browser = false;
applyCockpitLayout();
}
updateBrowserNavButtons();
setBrowserPicking(false);
if (S.browser.hier) closeDomHier(false);
}
function getBrowserIframe() {
const body = $('#browser-body');
return body && body.querySelector('iframe');
}
function setBrowserPicking(on) {
S.browser.picking = !!on;
const btn = $('#bx-pick');
if (btn) btn.classList.toggle('active', !!on);
const f = getBrowserIframe();
if (!f || !f.contentWindow) return;
try {
f.contentWindow.postMessage({ kind: on ? 'axo-tap:start' : 'axo-tap:cancel' }, '*');
} catch {}
}
function toggleBrowserPick() {
if (!S.browser.url) {
toast('Open a page first', 'Enter a URL above, then click 🎯 Pick.', 'err');
return;
}
try {
const u = new URL(S.browser.url);
const isLocal = u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '0.0.0.0';
if (!isLocal) {
toast('Picker only works on local URLs', 'Same-origin restrictions block tapping into external sites.', 'err');
return;
}
} catch {}
setBrowserPicking(!S.browser.picking);
}
S.browser.hier = null;
function postToIframe(msg) {
const f = getBrowserIframe();
if (!f || !f.contentWindow) return;
try { f.contentWindow.postMessage(msg, '*'); } catch {}
}
function openDomHier(chain, selectedIndex, url, selector, html) {
S.browser.hier = { chain, selectedIndex, url, selector, html };
renderDomHier();
$('#dom-hier').classList.remove('hide');
}
function closeDomHier(confirmed) {
S.browser.hier = null;
$('#dom-hier').classList.add('hide');
postToIframe({ kind: confirmed ? 'axo-tap:confirm' : 'axo-tap:cancel' });
setBrowserPicking(false);
}
function renderDomHier() {
const h = S.browser.hier;
const list = $('#dom-hier-list');
const snip = $('#dom-hier-snippet');
if (!h || !list) return;
list.innerHTML = '';
h.chain.forEach((node, i) => {
const row = el('div', 'dom-hier-row' + (i === h.selectedIndex ? ' active' : ''));
const indent = '·'.repeat(i); row.appendChild(el('span', 'indent', indent));
row.appendChild(el('span', 'label', node.label || node.tag || '(node)'));
row.title = node.selector || '';
row.addEventListener('mouseenter', () => postToIframe({ kind: 'axo-tap:hover-level', level: i }));
row.addEventListener('mouseleave', () => postToIframe({ kind: 'axo-tap:unhover' }));
row.addEventListener('click', () => selectHierLevel(i));
list.appendChild(row);
});
if (snip) snip.textContent = (h.html || '').slice(0, 1200) + (h.html && h.html.length > 1200 ? '\n…' : '');
}
function selectHierLevel(i) {
const h = S.browser.hier;
if (!h) return;
h.selectedIndex = i;
postToIframe({ kind: 'axo-tap:select-level', level: i });
renderDomHier();
}
function confirmDomHier() {
const h = S.browser.hier;
if (!h) return;
addRef({
kind: 'dom',
url: h.url || S.browser.url || '',
selector: h.selector || '',
html: h.html || '',
preview: h.selector || '(element)',
});
toast('Element referenced', h.selector || '(no selector)', 'ok');
closeDomHier(true);
}
window.addEventListener('message', (e) => {
const d = e.data || {};
if (d.kind === 'axo-tap:picked') {
openDomHier(d.chain || [], d.selectedIndex || 0, d.url || S.browser.url || '', d.selector || '', d.html || '');
} else if (d.kind === 'axo-tap:level') {
const h = S.browser.hier;
if (h && typeof d.level === 'number') {
h.selector = d.selector || h.selector;
h.html = d.html || h.html;
renderDomHier();
}
} else if (d.kind === 'axo-tap:cancelled') {
closeDomHier(false);
} else if (d.kind === 'axo-tap:ready') {
}
});
function browserBack() {
if (S.browser.histIdx <= 0) return;
S.browser.histIdx -= 1;
const u = S.browser.history[S.browser.histIdx];
S.browser.url = u; $('#bx-url').value = u;
mountBrowserIframe(proxyUrlFor(u));
updateBrowserNavButtons();
setBrowserPicking(false);
if (S.browser.hier) closeDomHier(false);
}
function browserForward() {
if (S.browser.histIdx >= S.browser.history.length - 1) return;
S.browser.histIdx += 1;
const u = S.browser.history[S.browser.histIdx];
S.browser.url = u; $('#bx-url').value = u;
mountBrowserIframe(proxyUrlFor(u));
updateBrowserNavButtons();
setBrowserPicking(false);
if (S.browser.hier) closeDomHier(false);
}
function browserReload() {
const body = $('#browser-body');
const f = body && body.querySelector('iframe');
if (f) { const src = f.src; f.src = 'about:blank'; setTimeout(() => f.src = src, 30); }
}
function browserOpenExternal() {
if (S.browser.url) window.open(S.browser.url, '_blank', 'noopener,noreferrer');
}
function updateBrowserNavButtons() {
const back = $('#bx-back'), fwd = $('#bx-forward');
if (back) back.disabled = S.browser.histIdx <= 0;
if (fwd) fwd.disabled = S.browser.histIdx >= S.browser.history.length - 1;
}
function termStatusClass(status) {
const s = String(status || '');
if (s.startsWith('running')) return 'running';
if (s.startsWith('failed') || s.startsWith('error')) return 'failed';
return 'exited';
}
function termShortLabel(command) {
const c = String(command || '').trim();
const parts = c.split(/\s+/);
if (parts.length === 1) return parts[0] || '(empty)';
return (parts[0] + ' ' + parts[1]).slice(0, 28);
}
async function refreshSessionTasks() {
if (!S.session.id) return;
let tasks = [];
try {
tasks = await fetch('/api/sessions/' + encodeURIComponent(S.session.id) + '/tasks')
.then(r => r.json());
} catch {}
if (!Array.isArray(tasks)) tasks = [];
S.session.tasks = tasks;
renderTerminals();
detectUrlsFromTasks();
}
let _lastMountedTerminal = { id: null, kind: null };
function renderTerminals() {
try { renderTermRailList(); } catch {}
const list = $('#terminals-list');
const out = $('#terminals-output');
if (!list || !out) return;
const tasks = S.session.tasks || [];
if (!tasks.length) {
if (S.session.autoTerminalStarting) {
list.innerHTML = '<div class="terminals-empty small term-jade">starting bash…</div>';
out.innerHTML =
'<pre class="term-pre term-welcome">' +
'<span class="term-dim">user@axocoatl </span><span class="term-jade">$</span> provision-session\n\n' +
'<span class="term-jade">▶</span> <span style="font-weight:600;color:var(--axo-jade-glow);">Starting your terminal…</span>\n' +
'<span class="term-dim"> installing bash, vim, python3, node, git…</span>\n' +
'<span class="term-dim"> first time only — usually 10–20 seconds</span>\n\n' +
'<span class="term-dim">user@axocoatl </span><span class="term-jade">$</span> <span class="term-cursor">▌</span>' +
'</pre>';
} else {
list.innerHTML = '<div class="terminals-empty small muted">no terminals yet — click + to start one</div>';
out.innerHTML =
'<pre class="term-pre term-welcome">' +
'<span class="term-dim">user@axocoatl </span><span class="term-jade">$</span> <span class="term-cursor">▌</span>\n\n' +
'<span class="term-dim">Click + to start a terminal — run a dev server, a build watcher, or anything else.</span>' +
'</pre>';
}
S.session.activeTerminalId = null;
_lastMountedTerminal = { id: null, kind: null };
return;
}
const ordered = tasks.slice().reverse();
if (!S.session.activeTerminalId
|| !ordered.find(t => t.id === S.session.activeTerminalId)) {
S.session.activeTerminalId = ordered[0].id;
}
list.innerHTML = '';
ordered.forEach(t => {
const row = el('div', 'term-row' + (t.id === S.session.activeTerminalId ? ' active' : ''));
row.appendChild(el('span', 'term-dot ' + termStatusClass(t.status)));
row.appendChild(el('span', 'term-label', termShortLabel(t.command)));
row.title = (t.command || '') + '\n' + (t.status || '');
row.addEventListener('click', () => {
S.session.activeTerminalId = t.id;
renderTerminals();
});
list.appendChild(row);
});
const active = ordered.find(t => t.id === S.session.activeTerminalId);
if (!active) { out.innerHTML = ''; _lastMountedTerminal = { id: null, kind: null }; return; }
if (_lastMountedTerminal.id === active.id && _lastMountedTerminal.kind === active.kind) {
const cmdEl = out.querySelector('.term-status-bar .term-cmd');
const statusEl = out.querySelector('.term-status-bar > span:last-child');
const dotEl = out.querySelector('.term-status-bar .term-dot');
if (cmdEl) cmdEl.textContent = active.command || '';
if (statusEl) statusEl.textContent = active.status || '';
if (dotEl) dotEl.className = 'term-dot ' + termStatusClass(active.status);
if (active.kind !== 'terminal') {
const pre = out.querySelector('.term-pre');
if (pre) {
const atBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 8;
pre.textContent = String(active.log || '').slice(-32 * 1024) || '(no output yet)';
if (atBottom) pre.scrollTop = pre.scrollHeight;
}
}
return;
}
out.innerHTML = '';
const bar = el('div', 'term-status-bar');
bar.appendChild(el('span', 'term-dot ' + termStatusClass(active.status)));
bar.appendChild(el('span', 'term-cmd', active.command || ''));
bar.appendChild(el('span', 'grow'));
bar.appendChild(el('span', '', active.status || ''));
out.appendChild(bar);
if (active.kind === 'terminal') {
const entry = getOrCreateXterm(active.id);
if (entry) {
out.appendChild(entry.container);
setTimeout(() => {
try { entry.fit && entry.fit.fit(); } catch {}
try { entry.term.focus(); } catch {}
}, 0);
} else {
out.appendChild(el('div', 'term-empty-output', 'xterm.js failed to load.'));
}
} else {
const pre = el('pre', 'term-pre');
pre.textContent = String(active.log || '').slice(-32 * 1024) || '(no output yet)';
out.appendChild(pre);
queueMicrotask(() => { pre.scrollTop = pre.scrollHeight; });
}
_lastMountedTerminal = { id: active.id, kind: active.kind };
}
const TERMINAL_PRESETS = [
'bash',
'sh',
'python3 serve.py',
'python3 -m http.server 3000',
'npm run dev',
'cargo run',
'vim .',
'nano',
'ls -la',
];
function openTerminalPop() {
const pop = $('#term-new-pop'); if (!pop) return;
if (S.cockpitLayout && S.cockpitLayout.collapsed && S.cockpitLayout.collapsed.terminals) {
S.cockpitLayout.collapsed.terminals = false;
applyCockpitLayout();
}
const presets = $('#term-presets');
if (presets) {
presets.innerHTML = '';
TERMINAL_PRESETS.forEach(cmd => {
const b = el('button', 'term-preset', cmd);
b.type = 'button';
b.addEventListener('click', () => {
const inp = $('#term-new-cmd');
if (inp) { inp.value = cmd; inp.focus(); }
});
presets.appendChild(b);
});
}
const inp = $('#term-new-cmd');
if (inp) {
inp.value = 'bash';
setTimeout(() => { inp.focus(); inp.select(); }, 0);
}
pop.style.top = '-9999px';
pop.style.left = '-9999px';
pop.classList.remove('hide');
requestAnimationFrame(() => {
const btn = $('#term-new-btn');
if (!btn) return;
const r = btn.getBoundingClientRect();
const popRect = pop.getBoundingClientRect();
const vh = window.innerHeight, vw = window.innerWidth;
const fitsBelow = (r.bottom + 6 + popRect.height) <= vh - 8;
const top = fitsBelow ? (r.bottom + 6) : Math.max(8, r.top - 6 - popRect.height);
let left = r.left;
if (left + popRect.width > vw - 8) left = Math.max(8, vw - 8 - popRect.width);
pop.style.top = top + 'px';
pop.style.left = left + 'px';
pop.style.right = 'auto';
});
setTimeout(() => document.addEventListener('mousedown', onTermPopOutside, true), 0);
}
function closeTerminalPop() {
const pop = $('#term-new-pop');
if (pop) pop.classList.add('hide');
document.removeEventListener('mousedown', onTermPopOutside, true);
}
function onTermPopOutside(e) {
const pop = $('#term-new-pop');
const btn = $('#term-new-btn');
if (!pop || pop.contains(e.target) || (btn && btn.contains(e.target))) return;
closeTerminalPop();
}
async function submitNewTerminal() {
const inp = $('#term-new-cmd');
const cmd = (inp && inp.value || '').trim();
if (!cmd || !S.session.id) return;
closeTerminalPop();
try {
const r = await fetch('/api/sessions/' + encodeURIComponent(S.session.id) + '/tasks', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ command: cmd, interactive: true, rows: 30, cols: 100 }),
});
const j = await r.json().catch(() => ({}));
if (r.ok && j && j.id) {
S.session.activeTerminalId = j.id;
toast('Terminal started', cmd, 'ok');
} else {
const msg = j && j.error ? j.error : ('HTTP ' + r.status);
toast('Could not start terminal', msg, 'err');
const out = $('#terminals-output');
if (out) out.innerHTML = '<div class="term-empty-output">Failed to start: ' + escHtml(msg) + '</div>';
}
} catch (e) {
toast('Could not start terminal', String(e), 'err');
const out = $('#terminals-output');
if (out) out.innerHTML = '<div class="term-empty-output">Failed to start: ' + escHtml(String(e)) + '</div>';
}
await refreshSessionTasks();
}
S.xterms = new Map();
function getOrCreateXterm(taskId) {
let entry = S.xterms.get(taskId);
if (entry) return entry;
if (typeof Terminal === 'undefined') return null;
const container = document.createElement('div');
container.style.cssText = 'flex:1; min-height:0; padding:6px;';
const term = new Terminal({
fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace',
fontSize: 12,
lineHeight: 1.35,
convertEol: true,
cursorBlink: true,
cursorStyle: 'block',
theme: {
background: '#000000',
foreground: '#C8CBD1',
cursor: '#4FCB8E',
cursorAccent: '#000000',
selectionBackground:'rgba(79,203,142,0.30)',
black: '#0A0A0A',
red: '#E88A8A',
green: '#4FCB8E', yellow: '#E8C275', blue: '#7FB8D6',
magenta: '#C7A6E0',
cyan: '#3FA9C8', white: '#C8CBD1',
brightBlack: '#636366',
brightRed: '#FFB0B0',
brightGreen: '#7DEBB0', brightYellow: '#FFD89A',
brightBlue: '#A6D2E8',
brightMagenta:'#E0C2F0',
brightCyan: '#7FCEE0',
brightWhite: '#ECECEC',
},
});
const fitAddon = (window.FitAddon && window.FitAddon.FitAddon)
? new window.FitAddon.FitAddon() : null;
if (fitAddon) term.loadAddon(fitAddon);
term.open(container);
setTimeout(() => { try { fitAddon && fitAddon.fit(); } catch {} }, 0);
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = proto + '//' + location.host
+ '/api/sessions/' + encodeURIComponent(S.session.id)
+ '/terminals/' + encodeURIComponent(taskId) + '/ws';
const ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
if (fitAddon) {
try {
fitAddon.fit();
ws.send(JSON.stringify({ kind: 'resize', rows: term.rows, cols: term.cols }));
} catch {}
}
};
ws.onmessage = (ev) => {
if (ev.data instanceof ArrayBuffer) {
term.write(new Uint8Array(ev.data));
} else if (typeof ev.data === 'string') {
try {
const j = JSON.parse(ev.data);
if (j && j.kind === 'error') term.write('\r\n\x1b[31m[' + j.message + ']\x1b[0m\r\n');
else term.write(ev.data);
} catch { term.write(ev.data); }
}
};
ws.onclose = () => { term.write('\r\n\x1b[2m[disconnected]\x1b[0m\r\n'); };
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) ws.send(data);
});
entry = { term, fit: fitAddon, ws, container };
S.xterms.set(taskId, entry);
return entry;
}
function teardownXterms() {
S.xterms.forEach((entry) => {
try { entry.ws.close(); } catch {}
try { entry.term.dispose(); } catch {}
});
S.xterms.clear();
}
const FILE_ICONS = {
rs: ['file-code', 'tint-jade'],
toml: ['settings-gear', 'tint-bronze'], cargo: ['settings-gear', 'tint-bronze'],
js: ['file-code', 'tint-bronze'], mjs: ['file-code', 'tint-bronze'], cjs: ['file-code', 'tint-bronze'],
ts: ['file-code', 'tint-blue'], tsx: ['file-code', 'tint-blue'],
jsx: ['file-code', 'tint-bronze'],
html: ['file-code', 'tint-bronze'], htm: ['file-code', 'tint-bronze'],
css: ['file-code', 'tint-blue'], scss: ['file-code', 'tint-blue'], sass: ['file-code', 'tint-blue'], less: ['file-code', 'tint-blue'],
svg: ['symbol-color', 'tint-jade'],
json: ['json', 'tint-bronze'],
yaml: ['file', 'tint-mute'], yml: ['file', 'tint-mute'],
csv: ['table', 'tint-mute'],
md: ['markdown', 'tint-jade'], markdown: ['markdown', 'tint-jade'],
txt: ['file-text', 'tint-mute'],
py: ['file-code', 'tint-blue'],
go: ['file-code', 'tint-blue'],
java: ['file-code', 'tint-bronze'],
c: ['file-code', 'tint-mute'], h: ['file-code', 'tint-mute'],
cpp: ['file-code', 'tint-mute'], cc: ['file-code', 'tint-mute'], cxx: ['file-code', 'tint-mute'], hpp: ['file-code', 'tint-mute'],
sh: ['terminal', 'tint-mute'], bash: ['terminal', 'tint-mute'],
sql: ['database', 'tint-blue'],
xml: ['file-code', 'tint-mute'],
env: ['settings-gear', 'tint-mute'],
gitignore: ['file', 'tint-mute'], dockerfile: ['file', 'tint-blue'],
lock: ['lock', 'tint-mute'],
png: ['file-media', 'tint-jade'], jpg: ['file-media', 'tint-jade'],
jpeg: ['file-media', 'tint-jade'], gif: ['file-media', 'tint-jade'],
webp: ['file-media', 'tint-jade'], ico: ['file-media', 'tint-jade'],
pdf: ['file-pdf', 'tint-bronze'],
zip: ['file-zip', 'tint-mute'], tar: ['file-zip', 'tint-mute'], gz: ['file-zip', 'tint-mute'],
};
function iconForFile(name) {
const lower = name.toLowerCase();
if (lower === 'dockerfile') return ['file', 'tint-blue'];
if (lower === 'license' || lower === 'license.md') return ['law', 'tint-mute'];
if (lower === 'readme.md' || lower === 'readme') return ['book', 'tint-bronze'];
const dotIdx = lower.lastIndexOf('.');
const ext = dotIdx >= 0 ? lower.slice(dotIdx + 1) : '';
return FILE_ICONS[ext] || ['file', 'tint-mute'];
}
function buildTreeNode(e) {
const node = el('div', 'ftn');
if (e.kind === 'dir') {
const chev = el('i', 'codicon codicon-chevron-right ftn-chev', '');
const ico = el('i', 'codicon codicon-folder ftn-ico tint-bronze', '');
const name = el('span', 'ftn-name', e.name);
node.appendChild(chev); node.appendChild(ico); node.appendChild(name);
} else {
const chevSlot = el('span', 'ftn-chev'); const [iconName, tint] = iconForFile(e.name);
const ico = el('i', 'codicon codicon-' + iconName + ' ftn-ico ' + tint, '');
const name = el('span', 'ftn-name', e.name);
node.appendChild(chevSlot); node.appendChild(ico); node.appendChild(name);
}
return node;
}
async function loadFileTree(relPath, container) {
container.innerHTML = '';
let entries = [];
try {
entries = await fetch('/api/sessions/' + encodeURIComponent(S.session.id) + '/tree'
+ (relPath ? '?path=' + encodeURIComponent(relPath) : '')).then(r => r.json());
} catch {}
if (!Array.isArray(entries)) entries = [];
if (!entries.length && !relPath) {
container.appendChild(el('div', 'file-tree-empty', 'Empty directory'));
return;
}
entries.forEach(e => {
const node = buildTreeNode(e);
if (e.kind === 'dir') {
const kids = el('div', 'ftn-kids hide');
const chev = node.querySelector('.ftn-chev');
const ico = node.querySelector('.ftn-ico');
let loaded = false;
node.addEventListener('click', async () => {
const willOpen = kids.classList.contains('hide');
kids.classList.toggle('hide', !willOpen);
if (chev) chev.classList.toggle('open', willOpen);
if (ico) ico.className = 'codicon codicon-' + (willOpen ? 'folder-opened' : 'folder') + ' ftn-ico tint-bronze';
if (willOpen && !loaded) { loaded = true; await loadFileTree(e.path, kids); }
});
const wrap = el('div');
wrap.appendChild(node); wrap.appendChild(kids);
container.appendChild(wrap);
} else {
node.addEventListener('click', () => {
$$('.ftn.sel').forEach(n => n.classList.remove('sel'));
node.classList.add('sel');
openFile(e.path);
});
container.appendChild(node);
}
});
}
function applyTreeFilter(query) {
const tree = $('#file-tree'); if (!tree) return;
const clearBtn = $('#file-search-clear');
if (clearBtn) clearBtn.classList.toggle('hide', !query);
const q = query.trim().toLowerCase();
if (!q) {
tree.querySelectorAll('.ftn').forEach(n => n.classList.remove('hide'));
tree.querySelectorAll('.ftn-name').forEach(n => { n.innerHTML = escHtml(n.textContent); });
tree.classList.remove('no-matches');
return;
}
let totalMatches = 0;
const wraps = tree.children;
function walk(container) {
let hits = 0;
const rows = Array.from(container.children);
rows.forEach(rowOrWrap => {
const node = rowOrWrap.classList && rowOrWrap.classList.contains('ftn')
? rowOrWrap
: rowOrWrap.querySelector(':scope > .ftn');
if (!node) return;
const kids = rowOrWrap.querySelector(':scope > .ftn-kids');
const nameEl = node.querySelector('.ftn-name');
const raw = nameEl ? nameEl.textContent : '';
const isDir = !!kids;
if (isDir) {
const myHit = raw.toLowerCase().includes(q) ? 1 : 0;
const childHits = kids ? walk(kids) : 0;
const visible = myHit > 0 || childHits > 0;
node.classList.toggle('hide', !visible);
if (kids && childHits > 0 && kids.classList.contains('hide')) {
kids.classList.remove('hide');
const chev = node.querySelector('.ftn-chev');
if (chev) chev.classList.add('open');
}
if (nameEl) nameEl.innerHTML = myHit
? highlightMatch(raw, q) : escHtml(raw);
hits += visible ? 1 : 0;
} else {
const match = raw.toLowerCase().includes(q);
node.classList.toggle('hide', !match);
if (nameEl) nameEl.innerHTML = match ? highlightMatch(raw, q) : escHtml(raw);
if (match) hits += 1;
}
});
return hits;
}
totalMatches = walk(tree);
tree.classList.toggle('no-matches', totalMatches === 0);
}
function highlightMatch(text, q) {
const lower = text.toLowerCase();
const idx = lower.indexOf(q);
if (idx < 0) return escHtml(text);
return escHtml(text.slice(0, idx))
+ '<span class="ftn-match">' + escHtml(text.slice(idx, idx + q.length)) + '</span>'
+ escHtml(text.slice(idx + q.length));
}
(function initFileSearchOnce() {
function attach() {
const input = $('#file-search-input'); if (!input) return;
if (input._wired) return; input._wired = true;
input.addEventListener('input', () => applyTreeFilter(input.value));
input.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { input.value = ''; applyTreeFilter(''); input.blur(); }
});
const clearBtn = $('#file-search-clear');
if (clearBtn) clearBtn.addEventListener('click', () => {
input.value = ''; applyTreeFilter(''); input.focus();
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', attach);
} else attach();
})();
S.quickOpen = { index: null, indexFor: null, building: false };
async function buildQuickOpenIndex() {
if (S.quickOpen.building) return;
if (!S.session || !S.session.id) return;
if (S.quickOpen.indexFor === S.session.id && S.quickOpen.index) return;
S.quickOpen.building = true;
const out = [];
async function crawl(p) {
let entries = [];
try {
entries = await fetch('/api/sessions/' + encodeURIComponent(S.session.id) + '/tree'
+ (p ? '?path=' + encodeURIComponent(p) : '')).then(r => r.json());
} catch {}
if (!Array.isArray(entries)) return;
for (const e of entries) {
if (e.kind === 'dir') { await crawl(e.path); }
else { out.push({ name: e.name, path: e.path }); }
if (out.length >= 5000) return;
}
}
await crawl('');
S.quickOpen.index = out;
S.quickOpen.indexFor = S.session.id;
S.quickOpen.building = false;
}
function quickOpenScore(query, name, path) {
const q = query.toLowerCase();
const n = name.toLowerCase();
const p = path.toLowerCase();
if (n === q) return 10000 - p.length;
if (n.startsWith(q)) return 5000 - p.length;
const nameIdx = n.indexOf(q);
if (nameIdx >= 0) return 2000 - nameIdx * 5 - p.length;
const pathIdx = p.indexOf(q);
if (pathIdx >= 0) return 800 - pathIdx * 2 - p.length;
let qi = 0;
for (let i = 0; i < n.length && qi < q.length; i++) {
if (n[i] === q[qi]) qi++;
}
if (qi === q.length) return 300 - p.length;
return -1;
}
let _quickOpenEl = null;
let _quickOpenActiveIdx = 0;
let _quickOpenHits = [];
async function openQuickOpen() {
if (!S.session || !S.session.id) return;
buildQuickOpenIndex();
if (_quickOpenEl) return;
const overlay = el('div', 'quick-open');
const card = el('div', 'quick-open-card');
const input = document.createElement('input');
input.className = 'quick-open-input';
input.placeholder = 'Search files by name…';
input.spellcheck = false;
input.autocomplete = 'off';
const list = el('div', 'quick-open-list');
card.appendChild(input);
card.appendChild(list);
overlay.appendChild(card);
document.body.appendChild(overlay);
_quickOpenEl = overlay;
_quickOpenActiveIdx = 0;
function refresh() {
const q = input.value.trim();
if (!q) {
const recent = (S.session.openFiles || []).map(f => ({ name: f.path.split('/').pop(), path: f.path }));
_quickOpenHits = recent;
} else {
const idx = S.quickOpen.index || [];
const scored = [];
for (const it of idx) {
const s = quickOpenScore(q, it.name, it.path);
if (s >= 0) scored.push({ ...it, score: s });
}
scored.sort((a, b) => b.score - a.score);
_quickOpenHits = scored.slice(0, 200);
}
_quickOpenActiveIdx = 0;
renderList();
}
function renderList() {
list.innerHTML = '';
if (!_quickOpenHits.length) {
list.appendChild(el('div', 'quick-open-empty',
S.quickOpen.building ? 'Indexing files…' : 'No matches'));
return;
}
_quickOpenHits.forEach((it, i) => {
const row = el('div', 'quick-open-item' + (i === _quickOpenActiveIdx ? ' active' : ''));
const [iconName, tint] = iconForFile(it.name);
const ico = el('i', 'codicon codicon-' + iconName + ' ftn-ico ' + tint, '');
const name = el('span', 'quick-open-name', it.name);
const path = el('span', 'quick-open-path', it.path);
row.appendChild(ico); row.appendChild(name); row.appendChild(path);
row.addEventListener('click', () => choose(i));
list.appendChild(row);
});
const active = list.querySelector('.quick-open-item.active');
if (active) active.scrollIntoView({ block: 'nearest' });
}
function move(delta) {
if (!_quickOpenHits.length) return;
_quickOpenActiveIdx = (_quickOpenActiveIdx + delta + _quickOpenHits.length) % _quickOpenHits.length;
renderList();
}
function choose(idx) {
const hit = _quickOpenHits[idx]; if (!hit) return;
closeQuickOpen();
openFile(hit.path);
}
input.addEventListener('input', refresh);
input.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { e.preventDefault(); closeQuickOpen(); }
else if (e.key === 'ArrowDown') { e.preventDefault(); move(1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); move(-1); }
else if (e.key === 'Enter') { e.preventDefault(); choose(_quickOpenActiveIdx); }
});
overlay.addEventListener('click', (e) => {
if (e.target === overlay) closeQuickOpen();
});
refresh();
const poll = setInterval(() => {
if (!_quickOpenEl) { clearInterval(poll); return; }
if (!S.quickOpen.building) { clearInterval(poll); refresh(); }
}, 250);
input.focus();
}
function closeQuickOpen() {
if (_quickOpenEl) { _quickOpenEl.remove(); _quickOpenEl = null; }
}
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'p' && !e.shiftKey && !e.altKey) {
if (S.session && S.session.id) {
e.preventDefault();
if (_quickOpenEl) closeQuickOpen(); else openQuickOpen();
}
}
});
function ensureMonacoState() {
if (!S.session.monaco) {
S.session.monaco = {
editor: null,
modelsByPath: new Map(),
ready: null,
hostEl: null,
firstLoad: true,
};
}
return S.session.monaco;
}
const MONACO_LANG_ALIASES = {
rs: 'rust', ts: 'typescript', tsx: 'typescript',
js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript',
py: 'python', md: 'markdown', sh: 'shell', bash: 'shell',
yml: 'yaml', tf: 'hcl', htm: 'html', cc: 'cpp', cxx: 'cpp', hpp: 'cpp',
};
function mapMonacoLang(raw) {
const k = (raw || '').toLowerCase();
return MONACO_LANG_ALIASES[k] || k || 'plaintext';
}
function loadMonacoOnce() {
const s = ensureMonacoState();
if (s.ready) return s.ready;
s.ready = new Promise((resolve, reject) => {
const tag = document.createElement('script');
tag.src = '/vendor/monaco/vs/loader.js';
tag.onload = () => {
self.MonacoEnvironment = {
getWorkerUrl: () => '/vendor/monaco/vs/assets/editor.worker-Be8ye1pW.js',
};
window.require.config({ paths: { vs: '/vendor/monaco/vs' } });
window.require(['vs/editor/editor.main'], () => {
try {
const dt = document.documentElement.getAttribute('data-theme');
window.monaco.editor.setTheme(dt === 'light' ? 'vs' : 'vs-dark');
} catch {}
resolve(window.monaco);
}, reject);
};
tag.onerror = () => reject(new Error('Failed to load /vendor/monaco/vs/loader.js'));
document.head.appendChild(tag);
});
return s.ready;
}
async function openFile(relPath) {
if (!S.session.openFiles) S.session.openFiles = [];
const existing = S.session.openFiles.find(f => f.path === relPath);
if (existing) {
S.session.activeFile = relPath;
renderFileTabs(); renderActiveFile();
return;
}
const v = $('#file-viewer');
const s = ensureMonacoState();
v.innerHTML = '<div class="empty small">' +
(s.firstLoad ? 'Loading editor…' : 'Loading…') + '</div>';
let j;
try {
j = await fetch('/api/sessions/' + encodeURIComponent(S.session.id)
+ '/file?path=' + encodeURIComponent(relPath)).then(r => r.json());
} catch (e) { v.innerHTML = '<div class="empty small">' + escHtml(String(e)) + '</div>'; return; }
if (j.error) { v.innerHTML = '<div class="empty small">' + escHtml(j.error) + '</div>'; return; }
const lang = (j.lang || '').toLowerCase();
S.session.openFiles.push({
path: relPath,
content: j.content || '',
lang: j.lang || '',
truncated: !!j.truncated,
viewMode: PREVIEWABLE_LANGS.has(lang) ? 'preview' : 'edit',
});
S.session.activeFile = relPath;
renderFileTabs();
renderActiveFile();
}
function closeFile(relPath) {
if (!S.session.openFiles) return;
const idx = S.session.openFiles.findIndex(f => f.path === relPath);
if (idx === -1) return;
const s = S.session.monaco;
if (s && s.modelsByPath.has(relPath)) {
const entry = s.modelsByPath.get(relPath);
try { entry.disposer && entry.disposer.dispose(); } catch {}
try { entry.model && entry.model.dispose(); } catch {}
s.modelsByPath.delete(relPath);
}
S.session.openFiles.splice(idx, 1);
if (S.session.activeFile === relPath) {
S.session.activeFile = S.session.openFiles.length
? S.session.openFiles[Math.max(0, idx - 1)].path
: null;
}
renderFileTabs(); renderActiveFile();
}
function renderFileTabs() {
const bar = $('#file-tabs'); if (!bar) return;
bar.innerHTML = '';
(S.session.openFiles || []).forEach(f => {
const tab = el('div', 'ftab' + (f.path === S.session.activeFile ? ' active' : ''));
tab.title = f.path;
tab.appendChild(el('span', 'ftab-name', f.path.split('/').pop() || f.path));
const x = el('span', 'ftab-close', '×');
x.addEventListener('click', (e) => { e.stopPropagation(); closeFile(f.path); });
tab.appendChild(x);
tab.addEventListener('click', () => {
S.session.activeFile = f.path; renderFileTabs(); renderActiveFile();
});
bar.appendChild(tab);
});
}
const PREVIEWABLE_LANGS = new Set(['html', 'htm', 'svg', 'md', 'markdown']);
const MARKDOWN_LANGS = new Set(['md', 'markdown']);
function renderMarkdownDoc(text) {
const body = mdRender(text || '');
return `<!doctype html><html><head><meta charset="utf-8"><style>
:root { color-scheme: light; }
body { font-family: -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
max-width: 720px; margin: 28px auto; padding: 0 22px 60px;
line-height: 1.65; color: #1a1a1a; background: #fff; }
h1, h2, h3 { line-height: 1.25; margin: 24px 0 10px; }
h1 { font-size: 30px; letter-spacing: -.01em; }
h2 { font-size: 21px; border-bottom: 1px solid #eee; padding-bottom: 4px; }
h3 { font-size: 17px; }
p, ul, ol { margin: 8px 0; }
code { background: #f3f3f3; padding: 1px 6px; border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; }
pre { background: #f6f6f6; padding: 12px 14px; border-radius: 7px;
overflow-x: auto; border: 1px solid #ececec; }
pre code { background: transparent; padding: 0; font-size: 12.5px; }
a { color: #0a66c2; }
blockquote { border-left: 3px solid #ddd; margin: 12px 0; padding: 2px 14px;
color: #555; background: #fafafa; }
table { border-collapse: collapse; margin: 10px 0; }
th, td { border: 1px solid #e3e3e3; padding: 6px 10px; text-align: left; }
th { background: #f6f6f6; }
hr { border: 0; border-top: 1px solid #e6e6e6; margin: 22px 0; }
img { max-width: 100%; }
</style></head><body>${body}</body></html>`;
}
function renderActiveFile() {
const v = $('#file-viewer'); if (!v) return;
const modes = $('#viewer-modes');
const hint = $('#viewer-hint');
const saveBtn = $('#viewer-save');
const f = (S.session.openFiles || []).find(x => x.path === S.session.activeFile);
if (!f) {
if (modes) modes.style.display = 'none';
v.innerHTML = '<div class="empty small">Select a file to view</div>';
return;
}
const previewable = PREVIEWABLE_LANGS.has((f.lang || '').toLowerCase());
const mode = f.viewMode || 'edit';
if (modes) {
modes.style.display = 'flex';
$$('.vmode').forEach(b => {
const m = b.dataset.mode;
b.classList.toggle('active', m === mode);
b.style.display = (m === 'preview' && !previewable) ? 'none' : '';
});
if (hint) {
hint.textContent =
mode === 'preview' ? 'Live preview (sandboxed)' :
f.truncated ? 'Read-only — file truncated by backend' :
f.dirty ? 'Unsaved changes — Ctrl+S to save' :
'Editing — Ctrl+S to save';
}
if (saveBtn) saveBtn.classList.toggle('hide', mode !== 'edit' || !!f.truncated);
}
if (mode === 'preview') {
const iframe = document.createElement('iframe');
iframe.className = 'file-preview';
iframe.setAttribute('sandbox', 'allow-scripts');
const lang = (f.lang || '').toLowerCase();
const doc = MARKDOWN_LANGS.has(lang) ? renderMarkdownDoc(f.content || '') : (f.content || '');
iframe.setAttribute('srcdoc', doc);
v.innerHTML = '';
v.appendChild(iframe);
return;
}
const s = ensureMonacoState();
v.innerHTML = '';
if (f.truncated) {
const banner = el('div', 'file-truncated-banner',
'⚠ File truncated by backend — saves are disabled. Use the CLI to edit large files.');
v.appendChild(banner);
}
if (!s.hostEl) {
s.hostEl = el('div', 'monaco-host');
s.hostEl.id = 'monaco-host';
} else {
if (s.hostEl.parentNode) s.hostEl.parentNode.removeChild(s.hostEl);
}
v.appendChild(s.hostEl);
loadMonacoOnce().then(monaco => {
s.firstLoad = false;
if (!s.editor) {
s.editor = monaco.editor.create(s.hostEl, {
automaticLayout: true,
minimap: { enabled: true },
fontSize: 13,
scrollBeyondLastLine: false,
wordWrap: 'off',
tabSize: 2,
});
s.editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => saveActiveFile());
s.editor.onDidChangeCursorSelection(() => onMonacoSelectionChange(s.editor, monaco));
s.editor.onDidScrollChange(() => {
if (_selBubble) onMonacoSelectionChange(s.editor, monaco);
});
}
let entry = s.modelsByPath.get(f.path);
if (!entry) {
const uri = monaco.Uri.parse('axo://session/' + encodeURIComponent(f.path));
const existingModel = monaco.editor.getModel(uri);
const model = existingModel || monaco.editor.createModel(
f.content || '', mapMonacoLang(f.lang), uri,
);
entry = { model, viewState: null, disposer: null };
s.modelsByPath.set(f.path, entry);
entry.disposer = entry.model.onDidChangeContent(() => {
f.draft = entry.model.getValue();
f.dirty = f.draft !== (f.content || '');
const h = $('#viewer-hint');
if (h) h.textContent = f.dirty
? 'Unsaved changes — Ctrl+S to save'
: 'Editing — Ctrl+S to save';
});
}
if (s.editor.getModel() && s.editor.getModel() !== entry.model) {
const prevPath = [...s.modelsByPath.entries()]
.find(([_, e]) => e.model === s.editor.getModel());
if (prevPath) prevPath[1].viewState = s.editor.saveViewState();
}
s.editor.setModel(entry.model);
if (entry.viewState) s.editor.restoreViewState(entry.viewState);
s.editor.updateOptions({ readOnly: !!f.truncated });
s.editor.focus();
}).catch(err => {
v.innerHTML = '<div class="empty small">Editor failed to load: ' + escHtml(String(err)) + '</div>';
});
}
async function onViewerModeClick(e) {
const b = e.target.closest('.vmode'); if (!b) return;
const f = (S.session.openFiles || []).find(x => x.path === S.session.activeFile);
if (!f) return;
const next = b.dataset.mode;
if (f.viewMode === 'edit' && next !== 'edit' && f.dirty) {
const ok = await axoConfirm({
title: 'Discard unsaved changes?',
body: `"${f.path}" has unsaved edits. Switching view will lose them.`,
okLabel: 'Discard',
okKind: 'danger',
});
if (!ok) return;
const entry = S.session.monaco && S.session.monaco.modelsByPath.get(f.path);
if (entry && entry.model) entry.model.setValue(f.content || '');
f.draft = null;
f.dirty = false;
}
f.viewMode = next;
renderActiveFile();
}
async function saveActiveFile() {
const f = (S.session.openFiles || []).find(x => x.path === S.session.activeFile);
if (!f || !S.session.id) return;
if (f.truncated) {
toast('Save blocked', 'File was truncated — use the CLI to edit.', 'warn');
return;
}
const entry = S.session.monaco && S.session.monaco.modelsByPath.get(f.path);
const content = entry && entry.model ? entry.model.getValue()
: (f.draft != null ? f.draft : (f.content || ''));
try {
const r = await fetch('/api/sessions/' + encodeURIComponent(S.session.id)
+ '/file?path=' + encodeURIComponent(f.path), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ content }),
});
const j = await r.json().catch(() => ({}));
if (!r.ok) { toast('Save failed', j.error || ('HTTP ' + r.status), 'err'); return; }
f.content = content;
f.draft = null;
f.dirty = false;
toast('Saved', f.path + ' (' + j.bytes + ' bytes)', 'ok');
const h = $('#viewer-hint'); if (h) h.textContent = 'Editing — Ctrl+S to save';
} catch (e) {
toast('Save failed', String(e), 'err');
}
}
function pathMatchesOpen(editedPath, openPath) {
if (!editedPath || !openPath) return false;
const e = editedPath.replace(/^\.\//, '');
const o = openPath.replace(/^\.\//, '');
return e === o || e.endsWith('/' + o);
}
async function reloadOpenFileIfMatch(editedPath) {
const files = S.session.openFiles || [];
if (!files.length || !editedPath) return;
const match = files.find(f => pathMatchesOpen(editedPath, f.path));
if (!match) return;
try {
const j = await fetch('/api/sessions/' + encodeURIComponent(S.session.id)
+ '/file?path=' + encodeURIComponent(match.path)).then(r => r.json());
if (j && !j.error) {
match.content = j.content || '';
match.truncated = !!j.truncated;
if (j.lang) match.lang = j.lang;
if (S.session.activeFile === match.path) renderActiveFile();
}
} catch {}
}
function appendSessionEl(node) {
const msgs = $('#session-msgs');
msgs.appendChild(node);
msgs.scrollTop = msgs.scrollHeight;
}
function toolVerb(name, args) {
args = args || {};
switch (name) {
case 'read_file': return ['◆', 'Read ' + (args.path || '')];
case 'write_file': return ['✎', 'Write ' + (args.path || '')];
case 'edit_file': return ['✎', 'Edit ' + (args.path || '')];
case 'list_dir': return ['▤', 'List ' + (args.path || '.')];
case 'grep': return ['⌕', 'Grep “' + (args.pattern || '') + '”'];
case 'glob': return ['⌕', 'Glob ' + (args.pattern || '')];
case 'bash': return ['▸', 'Bash: ' + (args.command || '')];
case 'web_search': return ['◍', 'Search the web: ' + (args.query || '')];
default:
if (name && name.indexOf('skill_') === 0) return ['◈', 'Fire skill: ' + name.slice(6)];
return ['•', name || 'tool'];
}
}
function renderToolResult(name, result) {
if (result == null) return '';
const r = result;
if (name === 'bash') {
let s = '';
if (r.stdout) s += r.stdout;
if (r.stderr) s += (s ? '\n' : '') + r.stderr;
return '<pre>' + escHtml(s || '(no output)') + '</pre>'
+ (r.exit_code ? '<div class="small muted">exit ' + r.exit_code + '</div>' : '');
}
if (name === 'read_file') return '<pre>' + escHtml((r.content || '').slice(0, 4000)) + '</pre>';
if (name === 'grep') return '<pre>' + escHtml(r.matches || '(no matches)') + '</pre>';
if (name === 'list_dir') return '<pre>' + escHtml(r.listing || '') + '</pre>';
if (name === 'glob') return '<pre>' + escHtml((r.files || []).join('\n')) + '</pre>';
if (name === 'write_file' || name === 'edit_file') {
return '<div class="small">✓ ' + escHtml(r.path || '')
+ (r.replacements != null ? ' · ' + r.replacements + ' replacement(s)' : '') + '</div>';
}
if (name === 'web_search') {
const hits = r.results || [];
if (!hits.length) return '<div class="small muted">No results.</div>';
return hits.map(h =>
'<div style="margin-bottom:7px;"><a href="' + escHtml(h.url || '#')
+ '" target="_blank" rel="noopener">' + escHtml(h.title || h.url || '') + '</a>'
+ '<div class="small muted">' + escHtml((h.snippet || '').slice(0, 220)) + '</div></div>'
).join('');
}
return '<pre>' + escHtml(JSON.stringify(r, null, 2)) + '</pre>';
}
function sessionToolStart(d) {
S.session.streamEl = null;
const [ico, verb] = toolVerb(d.name, d.arguments);
const card = el('div', 'toolcard running');
const head = el('div', 'toolcard-head');
head.appendChild(el('span', 'toolcard-ico', ico));
head.appendChild(el('span', 'toolcard-verb', verb));
const body = el('div', 'toolcard-body hide');
head.addEventListener('click', () => body.classList.toggle('hide'));
card.appendChild(head); card.appendChild(body);
S.session.toolCards[d.call_id] = { card, body };
appendSessionEl(card);
}
function sessionToolResult(d) {
const tc = S.session.toolCards[d.call_id];
if (!tc) return;
tc.card.classList.remove('running');
tc.card.classList.add(d.is_error ? 'err' : 'ok');
tc.body.innerHTML = renderToolResult(d.name, d.result);
if (d.is_error) tc.body.classList.remove('hide');
if (!d.is_error && (d.name === 'write_file' || d.name === 'edit_file' || d.name === 'bash')) {
loadFileTree('', $('#file-tree'));
if (d.name === 'write_file' || d.name === 'edit_file') {
const edited = d.result && d.result.path;
if (edited) reloadOpenFileIfMatch(edited);
}
}
}
function sessionAppendText(delta) {
if (!S.session.streamEl) {
const wrap = el('div', 'smsg');
wrap.appendChild(el('div', 'smsg-role', 'agent'));
const body = el('div', 'smsg-body md');
wrap.appendChild(body);
S.session.streamEl = body;
S.session.streamBuf = '';
appendSessionEl(wrap);
}
S.session.streamBuf += delta;
S.session.streamEl.innerHTML = mdRender(S.session.streamBuf);
const msgs = $('#session-msgs'); msgs.scrollTop = msgs.scrollHeight;
}
function sendSessionMessage() {
if (!S.session.id) return;
const text = ($('#session-text').value || '').trim(); if (!text) return;
$('#session-text').value = '';
const refs = S.session.refs || [];
const fullText = refs.length ? (buildRefsContext(refs) + '\n\n' + text) : text;
if (refs.length) { S.session.refs = []; renderChatRefs(); }
const wrap = el('div', 'smsg user');
wrap.appendChild(el('div', 'smsg-role', 'you'));
wrap.appendChild(el('div', 'smsg-body', text));
appendSessionEl(wrap);
S.session.streamEl = null;
const modelSel = $('#session-model');
const targetSel = $('#session-target');
const model_override = (modelSel && modelSel.value && modelSel.value !== '__default__')
? modelSel.value : undefined;
const target_agent = (targetSel && !targetSel.classList.contains('hide') && targetSel.value && targetSel.value !== '__all__')
? targetSel.value : undefined;
const payload = { cmd: 'session', id: S.session.id, input: fullText };
if (model_override !== undefined) payload.model_override = model_override;
if (target_agent !== undefined) payload.target_agent = target_agent;
if (!wsSend(payload)) {
toast('Not connected', 'Reconnecting to the daemon…', 'err');
}
$('#cockpit-status').textContent = 'running…';
setCockpitLive('live', 'live');
}
function ensureRefs() {
if (!S.session.refs) S.session.refs = [];
return S.session.refs;
}
function addRef(ref) {
ensureRefs().push(ref);
renderChatRefs();
}
function removeRef(i) {
const r = ensureRefs();
r.splice(i, 1);
renderChatRefs();
}
function renderChatRefs() {
const host = $('#chat-refs'); if (!host) return;
host.innerHTML = '';
const refs = ensureRefs();
refs.forEach((r, i) => {
const chip = el('span', 'chat-ref');
chip.title = r.preview || '';
chip.appendChild(el('span', 'chip-icon', r.kind === 'dom' ? '🎯' : '⌖'));
const label =
r.kind === 'code' ? `${r.path}:${r.startLine}${r.endLine !== r.startLine ? '-' + r.endLine : ''}` :
r.kind === 'dom' ? (r.selector || '(element)') :
'(reference)';
chip.appendChild(el('span', 'chip-text', label));
const x = el('span', 'chip-x', '×');
x.addEventListener('click', () => removeRef(i));
chip.appendChild(x);
host.appendChild(chip);
});
}
function buildRefsContext(refs) {
const lines = ['## Context the user attached:'];
refs.forEach(r => {
if (r.kind === 'code') {
const range = r.endLine !== r.startLine ? `${r.startLine}-${r.endLine}` : `${r.startLine}`;
lines.push(`\n### File: \`${r.path}\` (lines ${range})`);
lines.push('```' + (r.lang || ''));
lines.push(r.text);
lines.push('```');
} else if (r.kind === 'dom') {
lines.push(`\n### DOM element on ${r.url || '(unknown)'}`);
if (r.selector) lines.push(`Selector: \`${r.selector}\``);
if (r.html) {
lines.push('```html');
lines.push(r.html);
lines.push('```');
}
}
});
lines.push('');
return lines.join('\n');
}
function addMonacoSelectionAsRef(editor) {
if (!editor) return;
const sel = editor.getSelection();
const model = editor.getModel();
if (!sel || !model || sel.isEmpty()) return;
const text = model.getValueInRange(sel);
if (!text.trim()) return;
const f = (S.session.openFiles || []).find(x => x.path === S.session.activeFile);
if (!f) return;
addRef({
kind: 'code',
path: f.path,
lang: f.lang || '',
startLine: sel.startLineNumber,
endLine: sel.endLineNumber,
text,
preview: text.length > 200 ? text.slice(0, 200) + '…' : text,
});
hideSelBubble();
toast('Reference added', f.path + ':' + sel.startLineNumber, 'ok');
}
let _selBubble = null;
function showSelBubble(x, y, onClick) {
hideSelBubble();
const b = document.createElement('div');
b.className = 'sel-bubble';
b.textContent = '+ Add to chat';
b.style.left = Math.max(8, x) + 'px';
b.style.top = Math.max(8, y) + 'px';
b.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); });
b.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); onClick && onClick(); });
document.body.appendChild(b);
_selBubble = b;
}
function hideSelBubble() {
if (_selBubble) { _selBubble.remove(); _selBubble = null; }
}
function onMonacoSelectionChange(editor, monaco) {
const sel = editor.getSelection();
if (!sel || sel.isEmpty()) { hideSelBubble(); return; }
const anchorPos = sel.getEndPosition();
const dom = editor.getScrolledVisiblePosition(anchorPos);
if (!dom) { hideSelBubble(); return; }
const host = editor.getDomNode();
if (!host) { hideSelBubble(); return; }
const rect = host.getBoundingClientRect();
const x = rect.left + dom.left + 6;
const y = rect.top + dom.top - 30;
showSelBubble(x, y, () => addMonacoSelectionAsRef(editor));
}
function sessionActiveAgentIds(session) {
if (!session || !session.mode) return [];
if (session.mode.kind === 'single_agent') return [session.mode.agent_id];
if (session.mode.kind === 'custom' && Array.isArray(session.mode.agents)) {
return session.mode.agents.slice();
}
if (session.mode.kind === 'lattice') {
const wfId = session.mode.workflow_id;
const wf = (S.workflows || []).find(w => w.id === wfId) || (S.workflows || [])[0];
return wf ? (wf.agents || []).slice() : [];
}
return [];
}
function renderSessionActive(session) {
const host = $('#session-active'); if (!host) return;
host.innerHTML = '';
const ids = sessionActiveAgentIds(session);
if (ids.length === 0) return;
host.appendChild(el('span', 'sa-eyebrow', 'active'));
ids.forEach(aid => {
const ag = (S.agents || []).find(a => a.id === aid) || { id: aid };
const chip = el('div', 'sa-chip');
chip.appendChild(el('span', 'sa-dot'));
chip.appendChild(el('span', 'sa-kind', 'agent'));
chip.appendChild(document.createTextNode(ag.id));
if (ag.model) chip.title = `${ag.id} · ${ag.model}`;
host.appendChild(chip);
(ag.skills || []).forEach(sid => {
const skill = el('div', 'sa-chip skill');
skill.appendChild(el('span', 'sa-kind', 'skill'));
skill.appendChild(document.createTextNode(sid));
host.appendChild(skill);
});
});
}
async function populateSessionModelPicker(session) {
const sel = $('#session-model'); if (!sel) return;
sel.innerHTML = '';
const ids = sessionActiveAgentIds(session);
const agents = ids.map(aid => (S.agents || []).find(a => a.id === aid)).filter(Boolean);
const defaultModel = agents[0] && agents[0].model;
const opt0 = document.createElement('option');
opt0.value = '__default__';
opt0.textContent = defaultModel ? `model · ${defaultModel}` : 'model · agent default';
sel.appendChild(opt0);
try {
const data = await fetch('/api/llm/models').then(r => r.ok ? r.json() : null);
const seen = new Set(defaultModel ? [defaultModel] : []);
const providers = (data && data.providers) || [];
providers.forEach(p => {
(p.models || []).forEach(id => {
if (!id || seen.has(id)) return;
seen.add(id);
const opt = document.createElement('option');
opt.value = id;
opt.textContent = p.id ? `${id} · ${p.id}` : id;
sel.appendChild(opt);
});
});
} catch {}
}
function populateSessionAgentTarget(session) {
const sel = $('#session-target'); if (!sel) return;
const ids = sessionActiveAgentIds(session);
sel.innerHTML = '';
if (ids.length <= 1) { sel.classList.add('hide'); return; }
sel.classList.remove('hide');
const opt0 = document.createElement('option');
opt0.value = '__all__';
opt0.textContent = 'send to · all';
sel.appendChild(opt0);
ids.forEach(aid => {
const opt = document.createElement('option');
opt.value = aid; opt.textContent = 'send to · ' + aid;
sel.appendChild(opt);
});
}
async function sessionLatticeBuild(session) {
const host = $('#session-lattice-host');
if (!host) return;
let agents = [];
if (session.mode && session.mode.kind === 'single_agent') {
agents = [session.mode.agent_id];
} else {
const wfId = session.mode && session.mode.workflow_id;
const wf = (S.workflows || []).find(w => w.id === wfId) || (S.workflows || [])[0];
agents = wf ? (wf.agents || []) : [];
}
S.session.agents = agents;
host.innerHTML = '';
if (!agents.length) {
host.innerHTML = '<div class="empty small">No agents for this session.</div>';
return;
}
try {
await import('/lattice/index.js');
await customElements.whenDefined('ax-lattice');
} catch (e) { host.innerHTML = '<div class="empty small">lattice unavailable</div>'; return; }
const lat = document.createElement('ax-lattice');
lat.setAttribute('background', 'dots');
lat.setAttribute('fit-view-on-init', '');
host.innerHTML = '';
host.appendChild(lat);
agents.forEach((a, i) => {
const node = document.createElement('ax-node');
node.id = 'sl-' + a;
node.setAttribute('data-x', String(60 + (i % 2) * 150));
node.setAttribute('data-y', String(40 + i * 78));
node.setAttribute('status', 'idle');
node.append(mkDiv('sn-title', a));
node.append(mkHandle('target', 'in', 'left'), mkHandle('source', 'out', 'right'));
lat.appendChild(node);
});
S.session.lattice = lat;
setTimeout(() => { try { lat.autoLayout({ direction: 'LR' }); } catch {} }, 60);
}
function sessionLatticeStatus(agent, status) {
const lat = S.session.lattice;
if (lat && typeof lat.setNodeStatus === 'function') {
try { lat.setNodeStatus('sl-' + agent, status); } catch {}
}
}
function sessionLatticeEvent(d) {
if (d.type === 'AgentActivated' && d.agent) sessionLatticeStatus(d.agent, 'running');
else if (d.type === 'TaskCompleted' && d.agent) sessionLatticeStatus(d.agent, 'success');
else if (d.type === 'AgentFailed' && d.agent) sessionLatticeStatus(d.agent, 'error');
}
if ($('#finder-back')) $('#finder-back').addEventListener('click', finderBack);
if ($('#finder-forward')) $('#finder-forward').addEventListener('click', finderForward);
if ($('#finder-up')) $('#finder-up').addEventListener('click', finderUp);
if ($('#finder-add-fav')) $('#finder-add-fav').addEventListener('click', finderAddFavorite);
if ($('#finder-new-session')) $('#finder-new-session').addEventListener('click', finderNewSessionHere);
if ($('#finder-search')) $('#finder-search').addEventListener('input', (e) => {
S.finder.searchTerm = e.target.value || '';
renderFinderMain();
});
if ($('#cockpit-back')) $('#cockpit-back').addEventListener('click', closeCockpit);
if ($('#fp-cancel')) $('#fp-cancel').addEventListener('click', () => {
$('#folder-modal').classList.add('hide');
document.body.classList.remove('fp-favorite-mode');
if (S.finder) S.finder._addingFavorite = false;
});
if ($('#fp-copy-from')) $('#fp-copy-from').addEventListener('change', (e) => {
if (e.target.value) applyCopyFromSession(e.target.value);
});
if ($('#fp-use')) $('#fp-use').addEventListener('click', fpUseFolder);
if ($('#fp-mode')) $('#fp-mode').addEventListener('change', applyFpModeUi);
if ($('#fp-image-preset')) $('#fp-image-preset').addEventListener('change', () => {
const sel = $('#fp-image-preset');
const inp = $('#fp-image');
if (sel.value === '__custom__') {
inp.classList.remove('hide');
setTimeout(() => inp.focus(), 0);
} else {
inp.classList.add('hide');
}
});
if ($('#session-send')) $('#session-send').addEventListener('click', sendSessionMessage);
if ($('#session-text')) $('#session-text').addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendSessionMessage(); }
});
S.skillsFilter = { kind: '', agent: '', q: '' };
S.skillDrawer = null;
function skillKind(g) {
const hasReacts = g.reacts_to && g.reacts_to.length;
const hasEmits = g.emits && g.emits.length;
if (hasReacts && hasEmits) return 'Bridge';
if (hasReacts) return 'Reactive';
if (hasEmits) return 'Emitter';
return 'Manual';
}
function renderSkills() {
renderSkillsSidebar();
renderSkillsRows();
}
function renderSkillsSidebar() {
const q = ($('#skills-side-search')?.value || '').trim().toLowerCase();
const match = (s) => !q || (s || '').toLowerCase().includes(q);
const kindHost = $('#skills-side-kind');
if (kindHost) {
kindHost.innerHTML = '';
const kinds = {};
S.skills.forEach(g => { const k = skillKind(g); kinds[k] = (kinds[k] || 0) + 1; });
const allRow = el('div', 'shell-side-row' + (S.skillsFilter.kind === '' ? ' active' : ''));
allRow.innerHTML = '<span class="ico">⊕</span><span class="lbl">All skills</span><span class="ct"></span>';
allRow.querySelector('.ct').textContent = String(S.skills.length);
allRow.addEventListener('click', () => { S.skillsFilter.kind = ''; renderSkills(); });
kindHost.appendChild(allRow);
['Reactive', 'Emitter', 'Bridge', 'Manual'].filter(k => kinds[k] && match(k)).forEach(k => {
const row = el('div', 'shell-side-row' + (S.skillsFilter.kind === k ? ' active' : ''));
row.innerHTML = `<span class="ico">${k === 'Reactive' ? '⇡' : k === 'Emitter' ? '◆' : k === 'Bridge' ? '⇌' : '⏵'}</span>
<span class="lbl"></span><span class="ct"></span>`;
row.querySelector('.lbl').textContent = k;
row.querySelector('.ct').textContent = String(kinds[k]);
row.addEventListener('click', () => {
S.skillsFilter.kind = S.skillsFilter.kind === k ? '' : k;
renderSkills();
});
kindHost.appendChild(row);
});
}
const agentHost = $('#skills-side-agent');
if (agentHost) {
agentHost.innerHTML = '';
const byAgent = {};
S.skills.forEach(g => (g.agents || []).forEach(a => { byAgent[a] = (byAgent[a] || 0) + 1; }));
const allRow = el('div', 'shell-side-row' + (S.skillsFilter.agent === '' ? ' active' : ''));
allRow.innerHTML = '<span class="ico">⚇</span><span class="lbl">All agents</span>';
allRow.addEventListener('click', () => { S.skillsFilter.agent = ''; renderSkills(); });
agentHost.appendChild(allRow);
Object.keys(byAgent).sort().filter(a => match(a)).forEach(a => {
const row = el('div', 'shell-side-row' + (S.skillsFilter.agent === a ? ' active' : ''));
row.innerHTML = '<span class="ico">·</span><span class="lbl"></span><span class="ct"></span>';
row.querySelector('.lbl').textContent = a;
row.querySelector('.ct').textContent = String(byAgent[a]);
row.addEventListener('click', () => {
S.skillsFilter.agent = S.skillsFilter.agent === a ? '' : a;
renderSkills();
});
agentHost.appendChild(row);
});
}
}
function renderSkillsRows() {
const host = $('#skill-rows'); if (!host) return;
host.innerHTML = '';
const q = ($('#skills-search')?.value || '').trim().toLowerCase();
const visible = S.skills.filter(g => {
if (S.skillsFilter.kind && skillKind(g) !== S.skillsFilter.kind) return false;
if (S.skillsFilter.agent && !(g.agents || []).includes(S.skillsFilter.agent)) return false;
if (!q) return true;
return [g.id, g.name, g.description, ...(g.emits || []), ...(g.reacts_to || []), ...(g.agents || [])]
.some(s => (s || '').toLowerCase().includes(q));
});
$('#skills-count').textContent = `${visible.length} of ${S.skills.length}`;
if (!visible.length) {
host.appendChild(el('div', 'empty', 'No Skills match.'));
return;
}
visible.forEach(g => host.appendChild(skillRow(g)));
}
function skillRow(g) {
const row = el('div', 'shell-row');
row.dataset.skillId = g.id;
const nm = el('div', 'nm');
const main = el('div', 'nm-main'); main.textContent = g.name; nm.appendChild(main);
const sub = el('div', 'nm-sub'); sub.textContent = g.description || ''; nm.appendChild(sub);
row.appendChild(nm);
row.appendChild(chipCell(g.emits, 'emit'));
row.appendChild(chipCell(g.reacts_to, 'react'));
const ct = el('div', 'count'); ct.textContent = String((g.agents || []).length); row.appendChild(ct);
row.addEventListener('click', () => openSkillDrawer(g));
return row;
}
function chipCell(items, kind) {
const cell = el('div', 'cell');
(items || []).slice(0, 3).forEach(e => cell.appendChild(el('span', 'chip ' + kind, e)));
if ((items || []).length > 3) {
const more = el('span', 'cell-more'); more.textContent = `+${items.length - 3}`; cell.appendChild(more);
}
if (!items || !items.length) cell.appendChild(el('span', 'cell-more', '—'));
return cell;
}
function openSkillDrawer(g) {
S.skillDrawer = g;
$('#skill-drawer-title').textContent = g.id;
const body = $('#skill-drawer-body');
body.innerHTML = `<div style="font-size:13px; line-height:1.6;">
<div><strong>${escHtml(g.name)}</strong></div>
<div class="small muted" style="margin-top:4px;">${escHtml(g.description || '')}</div>
<div class="small muted" style="margin-top:14px; text-transform:uppercase; letter-spacing:.5px; font-size:10.5px;">◆ Emits</div>
<div style="margin-top:4px;">${(g.emits || []).map(e => `<span class="chip emit">${escHtml(e)}</span>`).join(' ') || '<span class="muted small">none</span>'}</div>
<div class="small muted" style="margin-top:10px; text-transform:uppercase; letter-spacing:.5px; font-size:10.5px;">⇡ Reacts to</div>
<div style="margin-top:4px;">${(g.reacts_to || []).map(e => `<span class="chip react">${escHtml(e)}</span>`).join(' ') || '<span class="muted small">none</span>'}</div>
<div class="small muted" style="margin-top:10px; text-transform:uppercase; letter-spacing:.5px; font-size:10.5px;">⚇ Held by</div>
<div style="margin-top:4px;">${(g.agents || []).map(a => `<span class="chip agent">${escHtml(a)}</span>`).join(' ') || '<span class="muted small">none</span>'}</div>
</div>`;
$('#skill-drawer').classList.add('open');
$$('#skill-rows .shell-row').forEach(r => r.classList.toggle('active', r.dataset.skillId === g.id));
}
function closeSkillDrawer() {
S.skillDrawer = null;
$('#skill-drawer')?.classList.remove('open');
$$('#skill-rows .shell-row').forEach(r => r.classList.remove('active'));
}
(function wireSkillsShellChrome() {
$('#skill-drawer-close')?.addEventListener('click', closeSkillDrawer);
$('#skill-drawer-fire')?.addEventListener('click', async () => {
const g = S.skillDrawer; if (!g) return;
const btn = $('#skill-drawer-fire'); btn.disabled = true;
try {
const r = await fetch(`/api/skills/${g.id}/fire`, { method: 'POST' });
const d = await r.json();
toast(`Fired '${g.name}'`, `Published ${d.events_published.length} event(s)`, 'ok');
switchTab('studio');
closeSkillDrawer();
} catch (e) { toast('Fire failed', e.message || e, 'err'); }
finally { btn.disabled = false; }
});
document.addEventListener('keydown', (e) => {
if ($('#tab-skills').classList.contains('hide')) return;
if (!$('#skill-drawer')?.classList.contains('open')) return;
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.key === 'Escape' || e.key === ' ') { e.preventDefault(); closeSkillDrawer(); }
});
document.querySelectorAll('#tab-skills .shell-side-head').forEach(h => {
h.addEventListener('click', () => h.parentElement.classList.toggle('collapsed'));
});
$('#skills-side-search')?.addEventListener('input', () => renderSkillsSidebar());
$('#skills-search')?.addEventListener('input', () => renderSkillsRows());
})();
function showSkillDetail(g) { switchTab('skills'); setTimeout(() => openSkillDrawer(g), 50); }
S.mcpApprovals = { queue: [], current: null };
function enqueueMcpApproval(d) {
if (S.mcpApprovals.queue.find(x => x.approval_id === d.approval_id)) return;
if (S.mcpApprovals.current?.approval_id === d.approval_id) return;
S.mcpApprovals.queue.push(d);
if (!S.mcpApprovals.current) showNextMcpApproval();
}
function dismissMcpApproval(approval_id) {
S.mcpApprovals.queue = S.mcpApprovals.queue.filter(x => x.approval_id !== approval_id);
if (S.mcpApprovals.current?.approval_id === approval_id) {
S.mcpApprovals.current = null;
closeMcpApprovalModal();
setTimeout(showNextMcpApproval, 50);
}
}
let _mcpApprovalBackdrop = null;
function closeMcpApprovalModal() {
if (_mcpApprovalBackdrop) {
_mcpApprovalBackdrop.remove();
_mcpApprovalBackdrop = null;
}
}
function showNextMcpApproval() {
if (S.mcpApprovals.current) return;
const next = S.mcpApprovals.queue.shift();
if (!next) return;
S.mcpApprovals.current = next;
const backdrop = el('div', 'modal-backdrop');
const modal = el('div', 'modal axo-modal mcp-approval');
backdrop.appendChild(modal);
modal.appendChild(el('div', 'modal-head', 'Allow MCP tool call?'));
const body = el('div', 'axo-modal-body');
const subj = el('div', 'mcp-approval-subject');
subj.appendChild(el('span', 'mcp-approval-agent', next.agent_id));
subj.appendChild(el('span', 'mcp-approval-sep', ' wants to call '));
subj.appendChild(el('span', 'mcp-approval-tool', next.tool_display));
subj.appendChild(el('span', 'mcp-approval-sep', ' on '));
subj.appendChild(el('span', 'mcp-approval-server', next.server));
body.appendChild(subj);
if (next.arguments_preview) {
const argHdr = el('div', 'axo-modal-label', 'Arguments');
body.appendChild(argHdr);
const pre = el('pre', 'mcp-approval-args');
pre.textContent = next.arguments_preview;
body.appendChild(pre);
}
body.appendChild(el('div', 'axo-modal-help',
`"Allow once" trusts this single call. The "always" options persist your
decision so you won't be prompted again — Deny "always" works the same
way for blocklisting.`));
modal.appendChild(body);
const foot = el('div', 'modal-foot mcp-approval-foot');
const deny = el('button', 'btn ghost danger', 'Deny');
deny.addEventListener('click', () => resolveMcpApproval(next.approval_id, 'deny', 'once'));
foot.appendChild(deny);
const denyAlways = el('button', 'btn ghost danger', 'Deny always');
denyAlways.addEventListener('click', () => resolveMcpApproval(next.approval_id, 'deny', 'agent_server'));
foot.appendChild(denyAlways);
const spacer = el('span'); spacer.style.flex = '1';
foot.appendChild(spacer);
const once = el('button', 'btn ghost', 'Allow once');
once.addEventListener('click', () => resolveMcpApproval(next.approval_id, 'allow', 'once'));
foot.appendChild(once);
const alwaysAgent = el('button', 'btn ghost', 'Allow this agent');
alwaysAgent.addEventListener('click', () => resolveMcpApproval(next.approval_id, 'allow', 'agent_server'));
foot.appendChild(alwaysAgent);
const alwaysAll = el('button', 'btn', 'Allow always');
alwaysAll.addEventListener('click', () => resolveMcpApproval(next.approval_id, 'allow', 'any_agent_server'));
foot.appendChild(alwaysAll);
modal.appendChild(foot);
document.body.appendChild(backdrop);
_mcpApprovalBackdrop = backdrop;
const onKey = (e) => {
if (e.key === 'Escape') {
e.preventDefault();
resolveMcpApproval(next.approval_id, 'deny', 'once');
}
};
backdrop.addEventListener('keydown', onKey);
requestAnimationFrame(() => once.focus());
}
function resolveMcpApproval(approval_id, decision, persist) {
if (!wsSend({ cmd: 'mcp-approve', approval_id, decision, persist })) {
toast('Not connected', 'Live connection is down — try again', 'err');
return;
}
S.mcpApprovals.current = null;
closeMcpApprovalModal();
setTimeout(showNextMcpApproval, 50);
}
S.mcp = { catalog: null, servers: [], tools: [], permissions: [],
section: 'servers', serverTab: 'overview', category: 'All' };
async function refreshMcp() {
if (!S.mcp.catalog) {
try { S.mcp.catalog = await fetch('/api/mcp/catalog').then(r => r.json()); }
catch { S.mcp.catalog = { servers: [] }; }
}
const [srv, tools, perms] = await Promise.all([
fetch('/api/mcp/servers').then(r => r.json()).catch(() => []),
fetch('/api/mcp/tools').then(r => r.json()).catch(() => []),
fetch('/api/mcp/permissions').then(r => r.json()).catch(() => []),
]);
S.mcp.servers = srv;
S.mcp.tools = tools;
S.mcp.permissions = perms;
renderMcpSidebar();
renderMcpDetail();
}
function setMcpSection(section) {
S.mcp.section = section;
if (!section.startsWith('server:')) S.mcp.serverTab = 'overview';
renderMcpSidebar();
renderMcpDetail();
}
function renderMcpSidebar() {
const q = ($('#mcp-side-search')?.value || '').trim().toLowerCase();
const match = (s) => !q || (s || '').toLowerCase().includes(q);
const servers = $('#mcp-side-servers');
if (servers) {
servers.innerHTML = '';
const overview = el('div', 'shell-side-row' + (S.mcp.section === 'servers' ? ' active' : ''));
overview.innerHTML = '<span class="ico">⌥</span><span class="lbl">All servers</span><span class="ct"></span>';
overview.querySelector('.ct').textContent = String(S.mcp.servers.length);
overview.addEventListener('click', () => setMcpSection('servers'));
servers.appendChild(overview);
S.mcp.servers.filter(s => match(s.name)).forEach(s => {
const row = el('div', 'shell-side-row' + (S.mcp.section === 'server:' + s.name ? ' active' : ''));
const dot = el('span', 'dot on'); row.appendChild(dot);
const lbl = el('span', 'lbl'); lbl.textContent = s.name; row.appendChild(lbl);
const ct = el('span', 'ct'); ct.textContent = String(s.tool_count); row.appendChild(ct);
row.addEventListener('click', () => setMcpSection('server:' + s.name));
servers.appendChild(row);
});
if (!S.mcp.servers.length) servers.appendChild(el('div', 'shell-side-empty', 'No servers connected.'));
}
const catalog = $('#mcp-side-catalog');
if (catalog) {
catalog.innerHTML = '';
const cats = Array.from(new Set((S.mcp.catalog?.servers || []).map(e => e.category))).sort();
const browse = el('div', 'shell-side-row' + (S.mcp.section === 'catalog' && S.mcp.category === 'All' ? ' active' : ''));
browse.innerHTML = '<span class="ico">⌬</span><span class="lbl">Browse all</span><span class="ct"></span>';
browse.querySelector('.ct').textContent = String((S.mcp.catalog?.servers || []).length);
browse.addEventListener('click', () => { S.mcp.category = 'All'; setMcpSection('catalog'); });
catalog.appendChild(browse);
cats.filter(c => match(c)).forEach(c => {
const count = (S.mcp.catalog?.servers || []).filter(e => e.category === c).length;
const row = el('div', 'shell-side-row' + (S.mcp.section === 'catalog' && S.mcp.category === c ? ' active' : ''));
const ico = el('span', 'ico'); ico.textContent = mcpIconFor(c); row.appendChild(ico);
const lbl = el('span', 'lbl'); lbl.textContent = c; row.appendChild(lbl);
const ct = el('span', 'ct'); ct.textContent = String(count); row.appendChild(ct);
row.addEventListener('click', () => { S.mcp.category = c; setMcpSection('catalog'); });
catalog.appendChild(row);
});
}
const perms = $('#mcp-side-perms');
if (perms) {
perms.innerHTML = '';
const row = el('div', 'shell-side-row' + (S.mcp.section === 'permissions' ? ' active' : ''));
row.innerHTML = '<span class="ico">⌘</span><span class="lbl">All decisions</span><span class="ct"></span>';
row.querySelector('.ct').textContent = String(S.mcp.permissions.length);
row.addEventListener('click', () => setMcpSection('permissions'));
perms.appendChild(row);
}
}
function renderMcpDetail() {
const host = $('#mcp-detail'); if (!host) return;
host.innerHTML = '';
if (S.mcp.section === 'catalog') return renderMcpCatalogDetail(host);
if (S.mcp.section === 'permissions') return renderMcpPermissionsDetail(host);
if (S.mcp.section.startsWith('server:')) return renderMcpServerDetail(host, S.mcp.section.slice(7));
return renderMcpServersOverview(host);
}
function mcpToolbar(host, title, sub, ...actions) {
const tb = el('div', 'shell-toolbar');
tb.appendChild(el('h2', null, title));
if (sub) tb.appendChild(el('span', 'sub', sub));
const grow = el('span', 'grow'); tb.appendChild(grow);
actions.forEach(a => tb.appendChild(a));
host.appendChild(tb);
return tb;
}
function renderMcpServersOverview(host) {
mcpToolbar(host, 'Servers', `${S.mcp.servers.length} connected`);
const body = el('div'); body.style.flex = '1'; body.style.overflow = 'auto'; body.style.padding = '16px 18px';
if (!S.mcp.servers.length) {
body.appendChild(el('div', 'auto-empty', 'No MCP servers connected yet. Open Catalog in the sidebar to install one.'));
} else {
const grid = el('div'); grid.style.display = 'grid';
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(280px, 1fr))';
grid.style.gap = '12px';
S.mcp.servers.forEach(srv => {
const cat = (S.mcp.catalog?.servers || []).find(c => c.slug === srv.name);
const card = el('div', 'mcp-card');
const head = el('div', 'mcp-card-head');
head.appendChild(el('div', 'mcp-card-icon', mcpIconFor(cat?.category || '')));
head.appendChild(el('div', 'mcp-card-name', srv.name));
head.appendChild(el('span', 'mcp-card-status-dot installed', ''));
card.appendChild(head);
card.appendChild(el('div', 'mcp-card-desc', cat ? cat.description : `Transport: ${srv.transport}`));
const foot = el('div', 'mcp-card-foot');
foot.appendChild(el('span', 'mcp-card-cat', `${srv.tool_count} tool${srv.tool_count === 1 ? '' : 's'}`));
foot.appendChild(el('span', 'grow', ''));
const open = el('button', 'btn ghost sm', 'Open →');
open.addEventListener('click', () => setMcpSection('server:' + srv.name));
foot.appendChild(open);
card.appendChild(foot);
grid.appendChild(card);
});
body.appendChild(grid);
}
host.appendChild(body);
}
function renderMcpServerDetail(host, name) {
const srv = S.mcp.servers.find(s => s.name === name);
if (!srv) { setMcpSection('servers'); return; }
const cat = (S.mcp.catalog?.servers || []).find(c => c.slug === srv.name);
const tb = el('div', 'shell-toolbar');
const back = el('button', 'btn ghost sm', '← Servers');
back.addEventListener('click', () => setMcpSection('servers'));
tb.appendChild(back);
tb.appendChild(el('h2', null, srv.name));
tb.appendChild(el('span', 'sub', `${srv.tool_count} tool${srv.tool_count === 1 ? '' : 's'} · ${srv.transport}`));
const grow = el('span', 'grow'); tb.appendChild(grow);
const seg = el('div', 'shell-seg');
[['overview','Overview'],['tools','Tools'],['permissions','Permissions']].forEach(([k, label]) => {
const b = el('button', 'shell-seg-btn' + (S.mcp.serverTab === k ? ' active' : ''), label);
b.addEventListener('click', () => { S.mcp.serverTab = k; renderMcpDetail(); });
seg.appendChild(b);
});
tb.appendChild(seg);
host.appendChild(tb);
const body = el('div'); body.style.flex = '1'; body.style.overflow = 'auto'; body.style.padding = '16px 18px';
if (S.mcp.serverTab === 'overview') {
const grid = el('div'); grid.style.display = 'grid'; grid.style.gridTemplateColumns = 'auto 1fr';
grid.style.gap = '8px 16px'; grid.style.alignItems = 'baseline'; grid.style.fontSize = '13px';
const row = (k, v) => {
grid.appendChild(el('span', 'small muted', k));
const val = typeof v === 'string' ? el('span', '', v) : v;
grid.appendChild(val);
};
row('Name', srv.name);
row('Transport', srv.transport);
row('Tools', String(srv.tool_count));
if (cat) row('Category', cat.category);
if (cat) row('Description', cat.description);
body.appendChild(grid);
const actions = el('div'); actions.style.marginTop = '20px'; actions.style.display = 'flex'; actions.style.gap = '8px';
const reconnect = el('button', 'btn ghost sm', '↻ Reconnect');
reconnect.addEventListener('click', () => mcpReconnect(srv, reconnect));
actions.appendChild(reconnect);
const remove = el('button', 'btn ghost sm danger', '✕ Remove');
remove.addEventListener('click', () => mcpRemove(srv));
actions.appendChild(remove);
body.appendChild(actions);
} else if (S.mcp.serverTab === 'tools') {
const tools = S.mcp.tools.filter(t => t.server === srv.name);
if (!tools.length) {
body.appendChild(el('div', 'auto-empty', 'No tools discovered for this server.'));
} else {
const card = el('div', 'card'); card.style.padding = '0';
const tbl = el('table');
tbl.innerHTML = '<thead><tr><th>Tool</th><th>Description</th></tr></thead>';
const tbody = el('tbody');
tools.forEach(t => {
const tr = el('tr');
tr.appendChild(el('td', 'mono', t.name));
tr.appendChild(el('td', 'muted', t.description || ''));
tbody.appendChild(tr);
});
tbl.appendChild(tbody);
card.appendChild(tbl);
body.appendChild(card);
}
} else {
const perms = S.mcp.permissions.filter(p => p.server === srv.name);
if (!perms.length) {
body.appendChild(el('div', 'auto-empty', 'No recorded permissions for this server.'));
} else {
body.appendChild(buildPermissionsTable(perms));
}
}
host.appendChild(body);
}
function renderMcpCatalogDetail(host) {
const entries = (S.mcp.catalog?.servers || []);
const filtered = S.mcp.category === 'All' ? entries : entries.filter(e => e.category === S.mcp.category);
mcpToolbar(host, 'Catalog', `${filtered.length} ${S.mcp.category === 'All' ? 'available' : 'in ' + S.mcp.category}`);
const body = el('div'); body.style.flex = '1'; body.style.overflow = 'auto'; body.style.padding = '16px 18px';
if (!entries.length) {
body.appendChild(el('div', 'auto-empty', 'Catalog unavailable.'));
} else {
const installedSlugs = new Set(S.mcp.servers.map(s => s.name));
const sorted = [...filtered].sort((a, b) => {
if (a.recommended !== b.recommended) return a.recommended ? -1 : 1;
return a.name.localeCompare(b.name);
});
const grid = el('div'); grid.style.display = 'grid';
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(280px, 1fr))';
grid.style.gap = '12px';
sorted.forEach(entry => grid.appendChild(mcpCatalogCard(entry, installedSlugs.has(entry.slug))));
body.appendChild(grid);
}
host.appendChild(body);
}
function renderMcpPermissionsDetail(host) {
mcpToolbar(host, 'Permissions', `${S.mcp.permissions.length} recorded`);
const body = el('div'); body.style.flex = '1'; body.style.overflow = 'auto'; body.style.padding = '16px 18px';
if (!S.mcp.permissions.length) {
body.appendChild(el('div', 'auto-empty', 'No recorded permissions yet. Saved "Allow always" / "Deny always" decisions land here.'));
} else {
body.appendChild(buildPermissionsTable(S.mcp.permissions));
}
host.appendChild(body);
}
function buildPermissionsTable(rows) {
const card = el('div', 'card'); card.style.padding = '0';
const tbl = el('table');
tbl.innerHTML = '<thead><tr><th>Agent</th><th>Server</th><th>Tool</th><th>Decision</th><th>Recorded</th><th></th></tr></thead>';
const tbody = el('tbody');
rows.forEach(p => {
const tr = el('tr');
tr.appendChild(el('td', '', p.agent_id || '(any agent)'));
tr.appendChild(el('td', 'mono', p.server));
tr.appendChild(el('td', 'mono', p.tool || '(any tool)'));
const dec = el('td', 'mono');
dec.style.color = `var(--${p.decision === 'allow' ? 'ok' : 'err'})`;
dec.textContent = p.decision;
tr.appendChild(dec);
tr.appendChild(el('td', 'muted', new Date(p.recorded_at * 1000).toLocaleString()));
const ops = el('td');
const rm = el('button', 'btn ghost sm danger', 'Revoke');
rm.addEventListener('click', () => revokeMcpPermission(p));
ops.appendChild(rm);
tr.appendChild(ops);
tbody.appendChild(tr);
});
tbl.appendChild(tbody);
card.appendChild(tbl);
return card;
}
async function mcpReconnect(srv, btn) {
btn.disabled = true;
try {
const r = await fetch(`/api/mcp/servers/${encodeURIComponent(srv.name)}`, { method: 'POST' });
if (!r.ok) {
const body = await r.json().catch(() => ({ error: 'HTTP ' + r.status }));
toast('Reconnect failed', body.error || ('HTTP ' + r.status), 'err');
return;
}
const out = await r.json();
toast('Reconnected', `${srv.name} · ${out.tools} tool${out.tools === 1 ? '' : 's'}`, 'ok');
await refreshMcp();
} finally { btn.disabled = false; }
}
async function mcpRemove(srv) {
const ok = await axoConfirm({
title: `Remove ${srv.name}?`,
body: `Disconnect from this MCP server. Tools become unavailable. The catalog entry stays — you can re-install later.`,
okLabel: 'Remove', okKind: 'danger',
});
if (!ok) return;
try {
await fetch(`/api/mcp/servers/${encodeURIComponent(srv.name)}`, { method: 'DELETE' });
toast('Removed', srv.name, 'ok');
setMcpSection('servers');
await refreshMcp();
} catch (e) { toast('Remove failed', String(e), 'err'); }
}
function mcpCatalogCard(entry, installed) {
const card = el('div', 'mcp-card');
const head = el('div', 'mcp-card-head');
head.appendChild(el('div', 'mcp-card-icon', mcpIconFor(entry.category)));
head.appendChild(el('div', 'mcp-card-name', entry.name));
if (entry.recommended) head.appendChild(el('div', 'mcp-card-recommended', 'recommended'));
card.appendChild(head);
card.appendChild(el('div', 'mcp-card-desc', entry.description));
const foot = el('div', 'mcp-card-foot');
foot.appendChild(el('span', 'mcp-card-cat', entry.category));
const grow = el('span', 'grow', '');
foot.appendChild(grow);
if (installed) {
foot.appendChild(el('span', 'mcp-card-status-dot installed', ''));
foot.appendChild(el('span', 'small muted', 'installed'));
} else {
const btn = el('button', 'btn sm', '+ Install');
btn.addEventListener('click', () => installMcpServer(entry));
foot.appendChild(btn);
}
card.appendChild(foot);
return card;
}
function mcpIconFor(category) {
switch (category) {
case 'Development': return '⌨';
case 'Web': return '⌬';
case 'Data': return '⛁';
case 'Productivity':return '⌧';
case 'Memory': return '◐';
case 'Reasoning': return '✦';
case 'Utilities': return '⌖';
case 'Reference': return '?';
default: return '◇';
}
}
async function installMcpServer(entry) {
let values = {};
if (entry.requires && entry.requires.length) {
const fields = entry.requires.map(r => ({
key: r.key,
label: r.label,
kind: r.kind === 'secret' ? 'text' : (r.kind === 'path' ? 'text' : 'text'),
placeholder: r.placeholder || '',
help: r.help || '',
}));
const out = await axoModal({
title: `Install ${entry.name}`,
body: entry.description,
fields,
okLabel: 'Install',
});
if (!out) return;
for (const r of entry.requires) {
if (!(out[r.key] || '').trim()) {
toast('Missing field', `"${r.label}" is required`, 'err');
return;
}
}
values = out;
} else {
const ok = await axoConfirm({
title: `Install ${entry.name}?`,
body: entry.description,
okLabel: 'Install',
});
if (!ok) return;
}
try {
const r = await fetch('/api/mcp/install', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ slug: entry.slug, values }),
});
if (!r.ok) {
const body = await r.json().catch(() => ({ error: 'HTTP ' + r.status }));
toast('Install failed', body.error || ('HTTP ' + r.status), 'err');
return;
}
const result = await r.json();
toast('Installed', `${result.name} · ${result.tools} tool${result.tools === 1 ? '' : 's'}`, 'ok');
await refreshMcp();
} catch (e) { toast('Install failed', String(e), 'err'); }
}
async function revokeMcpPermission(p) {
const ok = await axoConfirm({
title: 'Revoke this permission?',
body: `Next time ${p.agent_id || 'any agent'} calls ${p.tool || 'any tool'} on ${p.server}, you'll be re-prompted.`,
okLabel: 'Revoke',
okKind: 'danger',
});
if (!ok) return;
const params = new URLSearchParams({ server: p.server });
if (p.agent_id) params.set('agent_id', p.agent_id);
if (p.tool) params.set('tool', p.tool);
try {
const r = await fetch('/api/mcp/permissions?' + params.toString(), { method: 'DELETE' });
if (!r.ok) {
const body = await r.json().catch(() => ({ error: 'HTTP ' + r.status }));
toast('Revoke failed', body.error || ('HTTP ' + r.status), 'err');
return;
}
await refreshMcp();
} catch (e) { toast('Revoke failed', String(e), 'err'); }
}
(function wireMcpShellChrome() {
document.querySelectorAll('#tab-mcp .shell-side-head').forEach(h => {
h.addEventListener('click', () => h.parentElement.classList.toggle('collapsed'));
});
$('#mcp-side-search')?.addEventListener('input', () => renderMcpSidebar());
})();
S.agentsFilter = { team: '', q: '' };
async function refreshAgents() {
const [agents, tokens] = await Promise.all([
fetch('/api/agents').then(r => r.json()),
fetch('/api/tokens/report').then(r => r.json()).catch(() => null),
]);
S.agents = agents;
S.agentsTokens = tokens;
S.agentsStatus = {};
await Promise.all(agents.map(async a => {
try {
const sj = await fetch(`/api/agents/${a.id}/status`).then(r => r.json());
S.agentsStatus[a.id] = (sj.status || '').replace(/[{}]/g,'').trim() || 'Unknown';
} catch { S.agentsStatus[a.id] = '—'; }
}));
renderAgentsSidebar();
renderAgentsTable();
}
function renderAgentsSidebar() {
const q = ($('#agents-side-search')?.value || '').trim().toLowerCase();
const match = (s) => !q || (s || '').toLowerCase().includes(q);
const teamsHost = $('#agents-side-teams');
if (teamsHost) {
teamsHost.innerHTML = '';
const teams = {};
S.agents.forEach(a => { if (a.team) teams[a.team] = (teams[a.team] || 0) + 1; });
const allRow = el('div', 'shell-side-row' + (S.agentsFilter.team === '' ? ' active' : ''));
allRow.innerHTML = '<span class="ico">⊕</span><span class="lbl">All teams</span><span class="ct"></span>';
allRow.querySelector('.ct').textContent = String(S.agents.length);
allRow.addEventListener('click', () => { S.agentsFilter.team = ''; renderAgentsSidebar(); renderAgentsTable(); });
teamsHost.appendChild(allRow);
Object.keys(teams).sort().filter(t => match(t)).forEach(t => {
const row = el('div', 'shell-side-row' + (S.agentsFilter.team === t ? ' active' : ''));
const dot = el('span', 'legend-dot ' + teamCls(t)); dot.style.width = '9px'; dot.style.height = '9px'; row.appendChild(dot);
const lbl = el('span', 'lbl'); lbl.textContent = t; row.appendChild(lbl);
const ct = el('span', 'ct'); ct.textContent = String(teams[t]); row.appendChild(ct);
row.addEventListener('click', () => {
S.agentsFilter.team = S.agentsFilter.team === t ? '' : t;
renderAgentsSidebar(); renderAgentsTable();
});
teamsHost.appendChild(row);
});
}
const list = $('#agents-side-list');
if (list) {
list.innerHTML = '';
const visible = S.agents.filter(a => match(a.id) || match(a.team || ''));
if (!visible.length) {
list.appendChild(el('div', 'shell-side-empty', q ? 'No matches.' : 'No agents.'));
} else {
visible.forEach(a => {
const stat = S.agentsStatus[a.id] || '';
const dotCls = stat.startsWith('Running') ? 'run' : stat.startsWith('Idle') ? 'on' : stat === '—' ? '' : 'err';
const row = el('div', 'shell-side-row');
row.dataset.agentId = a.id;
const dot = el('span', 'dot' + (dotCls ? ' ' + dotCls : '')); row.appendChild(dot);
const lbl = el('span', 'lbl'); lbl.textContent = a.id; row.appendChild(lbl);
row.addEventListener('click', () => jumpToAgentRow(a.id));
list.appendChild(row);
});
}
}
}
function jumpToAgentRow(id) {
const row = document.querySelector(`#agents-tbl tr[data-agent-id="${CSS.escape(id)}"]`);
if (!row) return;
row.scrollIntoView({ block: 'center' });
row.classList.add('row-flash');
setTimeout(() => row.classList.remove('row-flash'), 1500);
}
function renderAgentsTable() {
const tb = $('#agents-tbl'); if (!tb) return;
const q = ($('#agents-search')?.value || '').trim().toLowerCase();
const teamFilter = S.agentsFilter.team;
const tokens = S.agentsTokens;
tb.innerHTML = '';
let count = 0;
for (const a of S.agents) {
if (teamFilter && a.team !== teamFilter) continue;
if (q && !(a.id.toLowerCase().includes(q) || (a.team || '').toLowerCase().includes(q)
|| (a.provider || '').toLowerCase().includes(q) || (a.model || '').toLowerCase().includes(q))) continue;
count++;
const tr = el('tr', 'clickable');
tr.dataset.agentId = a.id;
tr.appendChild(el('td', 'mono', a.id));
const team = el('td'); team.appendChild(el('span', 'badge team ' + teamCls(a.team), a.team)); tr.appendChild(team);
tr.appendChild(el('td', '', a.provider));
tr.appendChild(el('td', 'mono', a.model));
const stat = S.agentsStatus[a.id] || 'Unknown';
const stTd = el('td');
const cls = stat.startsWith('Idle') ? 'idle' : (stat.startsWith('Running') ? 'running' : 'failed');
stTd.appendChild(el('span', 'badge ' + cls, stat));
tr.appendChild(stTd);
const t = tokens?.per_agent?.find(t => t.agent_id === a.id);
const tin = el('td', 'mono', fmtNum(t?.input_tokens || 0)); tin.style.textAlign = 'right';
const tout = el('td', 'mono', fmtNum(t?.output_tokens || 0)); tout.style.textAlign = 'right';
tr.appendChild(tin); tr.appendChild(tout);
const act = el('td');
const b = el('button', 'btn ghost sm', 'Restart');
b.addEventListener('click', async (e) => {
e.stopPropagation();
b.disabled = true; b.textContent = '…';
try { await fetch(`/api/agents/${a.id}/restart`, { method: 'POST' }); toast('Agent restarted', a.id, 'ok'); refreshAgents(); }
catch (e) { toast('Restart failed', e.message || e, 'err'); }
finally { b.disabled = false; b.textContent = 'Restart'; }
});
act.appendChild(b); tr.appendChild(act);
tr.addEventListener('click', () => showAgentDetail(a));
tb.appendChild(tr);
}
const counter = $('#agents-count');
if (counter) counter.textContent = `${count} of ${S.agents.length}`;
}
(function wireAgentsShellChrome() {
document.querySelectorAll('#tab-agents .shell-side-head').forEach(h => {
h.addEventListener('click', (e) => {
if (e.target.closest('button')) return;
h.parentElement.classList.toggle('collapsed');
});
});
$('#agents-side-search')?.addEventListener('input', () => renderAgentsSidebar());
$('#agents-search')?.addEventListener('input', () => renderAgentsTable());
})();
const STUDIO_KEY = 'axocoatl.studio.positions.v2';
let studioLatticeReady = false;
let studioBuilt = false;
function loadStudioPositions() {
try { S.studioPositions = JSON.parse(localStorage.getItem(STUDIO_KEY) || '{}'); }
catch { S.studioPositions = {}; }
}
function saveStudioPositions() {
try { localStorage.setItem(STUDIO_KEY, JSON.stringify(S.studioPositions)); } catch {}
}
function persistStudioPositions(lat) {
const pos = {};
for (const n of lat.nodes) pos[n.id] = { x: n.x, y: n.y };
S.studioPositions = pos;
saveStudioPositions();
}
async function ensureStudioLattice() {
if (studioLatticeReady) return;
await import('/lattice/index.js');
await customElements.whenDefined('ax-lattice');
studioLatticeReady = true;
wireStudioEvents();
}
function wireStudioEvents() {
const lat = $('#studio-lattice');
if (!lat) return;
lat.addEventListener('selection-change', e => {
const ids = e.detail.ids;
if (ids.length !== 1) { closeStudioInspector(); return; }
openStudioInspector(ids[0]);
});
lat.addEventListener('node-moveend', () => persistStudioPositions(lat));
lat.addEventListener('edge-connect', e => {
toast('Wired', `${e.detail.from} → ${e.detail.to}`, 'ok');
});
}
function renderStudioSidebar() {
const q = ($('#studio-side-search')?.value || '').trim().toLowerCase();
const match = (s) => !q || (s || '').toLowerCase().includes(q);
const active = $('#studio-side-active');
if (active) {
active.innerHTML = '';
const ids = Array.from(S.studioActive || []);
const filtered = ids.filter(match);
if (!filtered.length) {
active.appendChild(el('div', 'shell-side-empty', q ? 'No matches.' : 'No active runs.'));
} else {
filtered.forEach(id => active.appendChild(studioSideRow('agent-' + id, id, 'run')));
}
}
const teamsHost = $('#studio-side-teams');
if (teamsHost) {
teamsHost.innerHTML = '';
const teams = {};
(S.agents || []).forEach(a => { if (a.team) teams[a.team] = (teams[a.team] || 0) + 1; });
const teamNames = Object.keys(teams).sort();
const visible = teamNames.filter(t => match(t));
if (!visible.length) {
teamsHost.appendChild(el('div', 'shell-side-empty', q ? 'No matches.' : 'No teams.'));
} else {
visible.forEach(t => {
const row = el('div', 'shell-side-row');
row.dataset.team = t;
const dot = el('span', 'legend-dot ' + teamCls(t));
dot.style.width = '9px'; dot.style.height = '9px';
row.appendChild(dot);
const lbl = el('span', 'lbl'); lbl.textContent = t; row.appendChild(lbl);
row.appendChild(el('span', 'ct', String(teams[t])));
row.addEventListener('click', () => filterStudioByTeam(t));
teamsHost.appendChild(row);
});
}
}
const agentsHost = $('#studio-side-agents');
if (agentsHost) {
agentsHost.innerHTML = '';
const visible = (S.agents || []).filter(a => match(a.id) || match(a.team || ''));
if (!visible.length) {
agentsHost.appendChild(el('div', 'shell-side-empty', q ? 'No matches.' : 'No agents.'));
} else {
visible.forEach(a => {
const cls = S.studioActive?.has(a.id) ? 'run'
: S.studioCompleted?.has(a.id) ? 'on' : '';
agentsHost.appendChild(studioSideRow('agent-' + a.id, a.id, cls, a.team));
});
}
}
}
function studioSideRow(nodeId, label, dotCls, sub) {
const row = el('div', 'shell-side-row');
row.dataset.nodeId = nodeId;
const dot = el('span', 'dot' + (dotCls ? ' ' + dotCls : '')); row.appendChild(dot);
const lbl = el('span', 'lbl'); lbl.textContent = label; row.appendChild(lbl);
if (sub) { const s = el('span', 'ct'); s.textContent = sub; row.appendChild(s); }
row.addEventListener('click', () => selectStudioNode(nodeId));
return row;
}
function selectStudioNode(nodeId) {
const lat = $('#studio-lattice'); if (!lat) return;
try { lat.deselectAll?.(); } catch {}
const node = document.getElementById(nodeId);
if (node) {
try { node.selected = true; } catch {}
node.scrollIntoView?.({ block: 'center', inline: 'center', behavior: 'smooth' });
}
highlightSidebarRow(nodeId);
openStudioInspector(nodeId);
}
function highlightSidebarRow(nodeId) {
$$('#tab-studio .shell-side-row').forEach(r => r.classList.toggle('active', r.dataset.nodeId === nodeId));
const row = document.querySelector('#tab-studio .shell-side-row.active');
if (row) row.scrollIntoView({ block: 'nearest' });
}
function filterStudioByTeam(team) {
const q = $('#studio-side-search');
if (!q) return;
q.value = (q.value === team) ? '' : team;
renderStudioSidebar();
}
function openStudioInspector(nodeId) {
const body = $('#studio-insp-body');
const title = $('#studio-insp-title');
const drawer = $('#studio-inspector');
if (!body || !title || !drawer) return;
body.innerHTML = '';
if (nodeId.startsWith('agent-')) {
const a = S.agents.find(x => 'agent-' + x.id === nodeId);
if (!a) { closeStudioInspector(); return; }
title.textContent = a.id;
const meta = el('div'); meta.style.fontSize = '12px'; meta.style.lineHeight = '1.7';
meta.innerHTML = `<div><span class="small muted">Team</span> <span class="badge team ${teamCls(a.team)}">${escHtml(a.team || '—')}</span></div>
<div><span class="small muted">Provider</span> ${escHtml(a.provider || '—')}</div>
<div><span class="small muted">Model</span> <span class="mono">${escHtml(a.model || '—')}</span></div>`;
body.appendChild(meta);
if (a.depends_on?.length) {
body.appendChild(el('div', 'small muted', 'Depends on'));
const dep = el('div'); dep.style.marginTop = '4px';
a.depends_on.forEach(d => { const c = el('span', 'badge'); c.textContent = d; c.style.marginRight = '4px'; dep.appendChild(c); });
body.appendChild(dep);
}
const more = el('button', 'btn ghost sm', 'Open full detail →');
more.style.marginTop = '12px';
more.addEventListener('click', () => showAgentDetail(a));
body.appendChild(more);
} else if (nodeId.startsWith('skill-')) {
const g = S.skills.find(x => 'skill-' + x.id === nodeId);
if (!g) { closeStudioInspector(); return; }
title.textContent = '◆ ' + g.id;
body.innerHTML = `<div style="font-size:12px; line-height:1.5;">
<div><strong>${escHtml(g.name)}</strong></div>
<div class="small muted" style="margin-top:3px;">${escHtml(g.description || '')}</div>
<div class="small muted" style="margin-top:10px;">Emits</div>
<div>${g.emits.map(e => `<span class="chip emit">${escHtml(e)}</span>`).join(' ') || '<span class="muted small">—</span>'}</div>
<div class="small muted" style="margin-top:8px;">Reacts to</div>
<div>${g.reacts_to.map(e => `<span class="chip react">${escHtml(e)}</span>`).join(' ') || '<span class="muted small">—</span>'}</div>
<div class="small muted" style="margin-top:8px;">Held by</div>
<div>${g.agents.map(a => `<span class="chip agent">${escHtml(a)}</span>`).join(' ') || '<span class="muted small">—</span>'}</div>
</div>`;
const fire = el('button', 'btn', '◆ Fire this Skill');
fire.style.marginTop = '12px';
fire.addEventListener('click', async () => {
try {
const r = await fetch(`/api/skills/${g.id}/fire`, { method: 'POST' });
const d = await r.json();
toast(`Fired '${g.name}'`, `Published ${d.events_published.length} event(s)`, 'ok');
} catch (e) { toast('Fire failed', e.message || e, 'err'); }
});
body.appendChild(fire);
} else {
return;
}
drawer.classList.add('open');
highlightSidebarRow(nodeId);
}
function closeStudioInspector() {
$('#studio-inspector')?.classList.remove('open');
$$('#tab-studio .shell-side-row').forEach(r => r.classList.remove('active'));
}
function mkDiv(cls, text) {
const d = document.createElement('div');
d.className = cls;
d.textContent = text;
return d;
}
function mkHandle(type, id, position) {
const h = document.createElement('ax-handle');
h.setAttribute('type', type);
h.setAttribute('handle-id', id);
h.setAttribute('position', position);
return h;
}
async function renderStudio() {
await ensureStudioLattice();
renderStudioSidebar(); const lat = $('#studio-lattice');
if (!lat || !S.agents.length || studioBuilt) return;
loadStudioPositions();
const havePositions = Object.keys(S.studioPositions).length > 0;
S.agents.forEach(a => {
const node = document.createElement('ax-node');
node.id = 'agent-' + a.id;
if (a.team) node.setAttribute('data-team', a.team);
const p = S.studioPositions[node.id] || { x: 0, y: 0 };
node.setAttribute('data-x', p.x);
node.setAttribute('data-y', p.y);
node.append(
mkDiv('sn-title', a.id),
mkDiv('sn-sub', `${a.team || ''} · ${a.model || ''}`),
mkHandle('target', 'in', 'left'),
mkHandle('source', 'out', 'right'),
);
lat.appendChild(node);
});
S.skills.forEach(g => {
const node = document.createElement('ax-node');
node.id = 'skill-' + g.id;
node.classList.add('studio-skill');
const p = S.studioPositions[node.id] || { x: 0, y: 0 };
node.setAttribute('data-x', p.x);
node.setAttribute('data-y', p.y);
node.append(
mkDiv('sn-title', '◆ ' + g.id),
mkDiv('sn-sub', 'Skill'),
mkHandle('source', 'out', 'right'),
);
lat.appendChild(node);
});
const agentIds = new Set(S.agents.map(a => a.id));
S.agents.forEach(a => {
(a.depends_on || []).forEach(dep => {
if (!agentIds.has(dep)) return;
const e = document.createElement('ax-edge');
e.setAttribute('from', 'agent-' + dep + ':out');
e.setAttribute('to', 'agent-' + a.id + ':in');
lat.appendChild(e);
});
});
S.skills.forEach(g => {
(g.agents || []).forEach(aid => {
if (!agentIds.has(aid)) return;
const e = document.createElement('ax-edge');
e.setAttribute('from', 'skill-' + g.id + ':out');
e.setAttribute('to', 'agent-' + aid + ':in');
e.setAttribute('label', 'skill');
lat.appendChild(e);
});
});
studioBuilt = true;
renderStudioSidebar();
requestAnimationFrame(() => {
if (havePositions) { lat.fitView(); }
else { lat.autoLayout({ direction: 'LR' }); persistStudioPositions(lat); }
lat.clearHistory();
});
}
(function wireStudioShellChrome() {
const close = $('#studio-insp-close');
if (close) close.addEventListener('click', closeStudioInspector);
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
if ($('#tab-studio').classList.contains('hide')) return;
if ($('#studio-inspector')?.classList.contains('open')) closeStudioInspector();
});
document.querySelectorAll('#tab-studio .shell-side-head').forEach(h => {
h.addEventListener('click', (e) => {
if (e.target.closest('button')) return; h.parentElement.classList.toggle('collapsed');
});
});
$('#studio-side-search')?.addEventListener('input', () => renderStudioSidebar());
})();
$('#studio-arrange').addEventListener('click', async () => {
await ensureStudioLattice();
const lat = $('#studio-lattice');
lat.autoLayout({ direction: 'LR' });
persistStudioPositions(lat);
toast('Studio re-arranged', 'Layered auto-layout applied', 'info');
});
$('#studio-fit').addEventListener('click', async () => {
await ensureStudioLattice();
$('#studio-lattice').fitView();
});
$('#studio-reset').addEventListener('click', async () => {
localStorage.removeItem(STUDIO_KEY);
S.studioPositions = {};
await ensureStudioLattice();
const lat = $('#studio-lattice');
lat.autoLayout({ direction: 'LR' });
persistStudioPositions(lat);
toast('Studio reset', 'Layout regenerated', 'info');
});
function openPalette() { $('#palette-mask').classList.add('open'); $('#palette-input').value = ''; renderPalette(''); setTimeout(() => $('#palette-input').focus(), 50); }
function closePalette() { $('#palette-mask').classList.remove('open'); }
$('#palette-mask').addEventListener('click', e => { if (e.target === $('#palette-mask')) closePalette(); });
function renderPalette(query) {
const list = $('#palette-list'); list.innerHTML = '';
const q = query.toLowerCase();
const items = [];
S.workflows.forEach(w => items.push({ kind: 'workflow', label: w.name || w.id, sub: 'Run', action: () => { fireWorkflowSilent(w.id, defaultInputFor(w.id)); switchTab('studio'); toast('Workflow fired', w.name, 'info'); } }));
S.skills.forEach(g => items.push({ kind: 'skill', label: g.name, sub: 'Fire', action: async () => { await fetch(`/api/skills/${g.id}/fire`, { method: 'POST' }); toast(`Fired '${g.name}'`, '', 'ok'); switchTab('studio'); } }));
S.agents.forEach(a => items.push({ kind: 'agent', label: a.id, sub: 'Restart', action: async () => { await fetch(`/api/agents/${a.id}/restart`, { method: 'POST' }); toast('Restarted', a.id, 'ok'); } }));
S.schedules.forEach(s => items.push({ kind: 'schedule', label: s.name, sub: 'Run now', action: async () => { await fetch(`/api/schedules/${s.id}/run`, { method: 'POST' }); toast('Schedule ran', s.name, 'ok'); } }));
items.push({ kind: 'nav', label: 'Open Lattice', sub: '→', action: () => switchTab('studio') });
items.push({ kind: 'nav', label: 'Open Studio', sub: '→', action: () => switchTab('studio') });
items.push({ kind: 'nav', label: 'Open Chat', sub: '→', action: () => switchTab('chat') });
const matches = items.filter(i => {
if (!q) return true;
return i.label.toLowerCase().includes(q)
|| i.kind.toLowerCase().includes(q)
|| (i.sub || '').toLowerCase().includes(q);
});
matches.slice(0, 50).forEach((it, i) => {
const row = el('div', 'palette-item' + (i === 0 ? ' sel' : ''));
row.appendChild(el('span', 'kind', it.kind));
row.appendChild(el('span', 'nm', it.label));
row.appendChild(el('span', 'sub', it.sub));
row.addEventListener('click', () => { it.action(); closePalette(); });
list.appendChild(row);
});
}
$('#palette-input').addEventListener('input', e => renderPalette(e.target.value));
$('#palette-input').addEventListener('keydown', e => {
if (e.key === 'Escape') { closePalette(); return; }
const items = $$('.palette-item'); if (!items.length) return;
let sel = items.findIndex(x => x.classList.contains('sel')); if (sel < 0) sel = 0;
if (e.key === 'ArrowDown') { e.preventDefault(); items[sel].classList.remove('sel'); sel = (sel + 1) % items.length; items[sel].classList.add('sel'); items[sel].scrollIntoView({ block: 'nearest' }); }
else if (e.key === 'ArrowUp') { e.preventDefault(); items[sel].classList.remove('sel'); sel = (sel - 1 + items.length) % items.length; items[sel].classList.add('sel'); items[sel].scrollIntoView({ block: 'nearest' }); }
else if (e.key === 'Enter') { e.preventDefault(); items[sel].click(); }
});
document.addEventListener('keydown', e => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); openPalette(); }
else if (e.key === 'Escape') { if ($('#palette-mask').classList.contains('open')) closePalette(); else if ($('#detail-panel').classList.contains('open')) closeDetail(); }
});
let axoWs = null;
let wsBackoff = 500;
function connectWs() {
const url = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws';
axoWs = new WebSocket(url);
axoWs.addEventListener('open', () => { wsBackoff = 500; setConnDot(true); });
axoWs.addEventListener('message', (e) => {
let d; try { d = JSON.parse(e.data); } catch { return; }
handleWsFrame(d);
});
axoWs.addEventListener('close', () => {
setConnDot(false);
setTimeout(connectWs, wsBackoff);
wsBackoff = Math.min(wsBackoff * 2, 10000); });
axoWs.addEventListener('error', () => { try { axoWs.close(); } catch {} });
}
function wsSend(obj) {
if (axoWs && axoWs.readyState === WebSocket.OPEN) {
axoWs.send(JSON.stringify(obj));
return true;
}
return false;
}
function setConnDot(ok) {
const el = document.getElementById('conn-dot');
if (el) { el.classList.toggle('on', !!ok); el.classList.toggle('off', !ok); }
}
function handleWsFrame(d) {
switch (d.kind) {
case 'ready':
case 'pong':
break;
case 'snapshot': {
const runs = (d.runs || []).filter(r => (r.kind || 'workflow') === 'workflow');
if (runs.length) {
const run = runs[0];
S.activeWorkflows.add(run.workflow);
toast('Reconnected', `Re-attached to the live ${run.workflow} run`, 'info');
}
break;
}
case 'event': {
S.totalEvents += 1;
if (S.session.id && d.workflow === S.session.id) { sessionLatticeEvent(d); break; }
const t = d.type || '';
let pulseKind = null;
if (t === 'AgentActivated' || t === 'MapStarted') pulseKind = 'activated';
else if (t === 'TaskCompleted' || t === 'MapCompleted'
|| t === 'Resumed' || t === 'Branched') pulseKind = 'completed';
else if (t === 'AgentFailed' || t === 'TaskFailed') pulseKind = 'error';
else if (t === 'Interrupted') pulseKind = 'paused';
if (pulseKind) {
if (d.agent) studioPulse(d.agent, pulseKind);
if (d.task && d.workflow) editorPulse(d.workflow, d.task, pulseKind);
}
if (pushObsEvent) {
const now = new Date();
const when = String(now.getHours()).padStart(2,'0') + ':'
+ String(now.getMinutes()).padStart(2,'0') + ':'
+ String(now.getSeconds()).padStart(2,'0');
const cls = pulseKind === 'completed' ? 'completion'
: pulseKind === 'error' ? 'error'
: pulseKind === 'paused' ? 'session'
: 'activation';
pushObsEvent({ when, cls, who: d.agent || d.task || '', what: t,
preview: d.output ? String(d.output).slice(0, 200) : '' });
}
if (t === 'Interrupted' || t === 'Resumed') refreshInterrupts();
break;
}
case 'token':
if (S.session.id && d.workflow === S.session.id) sessionAppendText(d.delta);
break;
case 'reasoning':
break;
case 'tool-call':
if (S.session.id && d.workflow === S.session.id) {
if (d.phase === 'start') sessionToolStart(d);
else sessionToolResult(d);
}
break;
case 'workflow-done':
onWorkflowDone(d);
break;
case 'workflow-error':
onWorkflowError(d);
break;
case 'session-start':
if (S.session.id === d.session && (S.session.agents || []).length === 1) {
sessionLatticeStatus(S.session.agents[0], 'running');
}
break;
case 'session-done':
if (S.session.id === d.session) {
S.session.streamEl = null;
(S.session.agents || []).forEach(a => sessionLatticeStatus(a, 'success'));
$('#cockpit-status').textContent =
'done · ' + (d.input_tokens || 0) + ' in / ' + (d.output_tokens || 0) + ' out';
setCockpitLive('done', 'done');
loadFileTree('', $('#file-tree'));
if (window.hljs) {
$$('#session-msgs pre code').forEach(c => {
try { if (!c.dataset.hl) { window.hljs.highlightElement(c); c.dataset.hl = '1'; } } catch {}
});
}
}
break;
case 'session-error':
if (S.session.id === d.session) {
S.session.streamEl = null;
const e = el('div', 'smsg');
e.appendChild(el('div', 'smsg-role', 'error'));
e.appendChild(el('div', 'smsg-body', d.error || 'session failed'));
appendSessionEl(e);
$('#cockpit-status').textContent = 'error';
setCockpitLive('error', 'error');
}
break;
case 'chat-start':
if (S.chat.openId === d.chat_id) {
chatBeginUserTurn(d);
}
break;
case 'chat-token':
if (S.chat.openId === d.chat_id) chatAppendToken(d.delta);
break;
case 'chat-reasoning':
if (S.chat.openId === d.chat_id) chatAppendReasoning(d.delta);
break;
case 'chat-tool-start':
if (S.chat.openId === d.chat_id) chatAppendToolStart(d);
break;
case 'chat-tool-result':
if (S.chat.openId === d.chat_id) chatAppendToolResult(d);
break;
case 'chat-done':
if (S.chat.openId === d.chat_id) chatFinalize({ tokens_in: d.input_tokens, tokens_out: d.output_tokens });
refreshChats($('#chat-search')?.value || '');
refreshOpenChat();
break;
case 'chat-stopped':
if (S.chat.openId === d.chat_id) chatFinalize({ stopped: true });
refreshChats($('#chat-search')?.value || '');
refreshOpenChat();
break;
case 'chat-error':
if (S.chat.openId === d.chat_id) chatFinalize({ error: d.error });
break;
case 'mcp-approval-required':
enqueueMcpApproval(d);
break;
case 'mcp-approval-resolved':
dismissMcpApproval(d.approval_id);
break;
}
}
function onWorkflowDone(d) {
S.activeWorkflows.delete(d.workflow);
toast('Workflow complete', `${(d.completed || []).length} agents · ${fmtNum(d.tokens || 0)} tokens`, 'ok');
try { refreshTopStats(); } catch {}
}
function onWorkflowError(d) {
S.activeWorkflows.delete(d.workflow);
toast('Workflow failed', d.error || 'unknown error', 'err');
}
async function init() {
await refreshStatus();
await loadAll();
applyStudioMode();
switchTab('sessions');
refreshTopStats();
connectWs();
setInterval(refreshStatus, 5000);
setInterval(refreshTopStats, 8000);
setInterval(refreshInterrupts, 3000); refreshInterrupts();
updateAutomationsCount();
checkLlmHealth();
}
async function checkLlmHealth() {
try {
const r = await fetch('/api/llm-health');
if (!r.ok) return;
const d = await r.json();
if (!d.ollama || !d.ollama.configured) return;
if (d.ollama.reachable && (!d.ollama.missing_models || !d.ollama.missing_models.length)) {
try { localStorage.removeItem('axo.llm-health.warned'); } catch {}
return;
}
const key = 'axo.llm-health.warned:' + JSON.stringify({
r: d.ollama.reachable, m: d.ollama.missing_models || []
});
try { if (localStorage.getItem(key)) return; } catch {}
if (!d.ollama.reachable) {
toast('Ollama is not running',
`Start it with ollama serve — then pull a model: ollama pull llama3.2`,
'err', 9000);
} else if (d.ollama.missing_models.length) {
const cmd = d.ollama.missing_models.map(m => 'ollama pull ' + m).join(' && ');
toast('Missing Ollama model' + (d.ollama.missing_models.length > 1 ? 's' : ''),
`Run: ${cmd}`,
'err', 9000);
}
try { localStorage.setItem(key, '1'); } catch {}
} catch { }
}
init();
</script>
</body>
</html>