Skip to content
File

Blob: web/index.html

xml1063 lines
1<!DOCTYPE html>
2<html lang="en">
3<head>
4<meta charset="UTF-8">
5<meta name="viewport" content="width=device-width, initial-scale=1.0">
6<title>collab-bridge</title>
7<style>
8*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
10:root {
11 --bg: #0f0f1a;
12 --surface: #1a1a2e;
13 --surface2: #222240;
14 --border: #2a2a4a;
15 --text: #e0e0e0;
16 --text2: #8888aa;
17 --accent: #6366f1;
18 --accent-hover: #818cf8;
19 --danger: #ef4444;
20 --danger-hover: #f87171;
21 --claude: #6366f1;
22 --codex: #22c55e;
23 --pending-bg: #2a2a1e;
24 --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
25 --mono: "JetBrains Mono", "Fira Code", "Cascadia Code", monospace;
26 --radius: 8px;
27}
28
29html, body { height: 100%; }
30body {
31 font-family: var(--font);
32 background: var(--bg);
33 color: var(--text);
34 display: flex;
35 flex-direction: column;
36 overflow: hidden;
37}
38
39/* ── Header ─────────────────────────────── */
40header {
41 display: flex;
42 align-items: center;
43 padding: 12px 20px;
44 background: var(--surface);
45 border-bottom: 1px solid var(--border);
46 flex-shrink: 0;
47}
48header h1 {
49 font-size: 16px;
50 font-weight: 600;
51 letter-spacing: -0.3px;
52}
53header .dot { color: var(--accent); }
54header .status {
55 margin-left: auto;
56 font-size: 12px;
57 color: var(--text2);
58}
59header .status .live {
60 display: inline-block;
61 width: 6px; height: 6px;
62 background: var(--codex);
63 border-radius: 50%;
64 margin-right: 4px;
65 vertical-align: middle;
66}
67
68/* ── Layout ─────────────────────────────── */
69main {
70 display: flex;
71 flex: 1;
72 overflow: hidden;
73}
74
75/* ── Sidebar ────────────────────────────── */
76aside {
77 width: 280px;
78 min-width: 280px;
79 background: var(--surface);
80 border-right: 1px solid var(--border);
81 display: flex;
82 flex-direction: column;
83 overflow: hidden;
84}
85aside .sidebar-header {
86 padding: 12px;
87 border-bottom: 1px solid var(--border);
88}
89aside .sidebar-header button {
90 width: 100%;
91}
92.session-list {
93 flex: 1;
94 overflow-y: auto;
95 padding: 8px;
96}
97.session-card {
98 padding: 10px 12px;
99 border-radius: var(--radius);
100 cursor: pointer;
101 margin-bottom: 4px;
102 border: 1px solid transparent;
103 transition: background 0.15s;
104}
105.session-card:hover { background: var(--surface2); }
106.session-card.active { background: var(--surface2); border-color: var(--accent); }
107.session-card.closed { opacity: 0.68; }
108.session-card .sid {
109 font-family: var(--mono);
110 font-size: 12px;
111 color: var(--accent);
112}
113.session-card .goal {
114 font-size: 13px;
115 color: var(--text2);
116 margin-top: 2px;
117 white-space: nowrap;
118 overflow: hidden;
119 text-overflow: ellipsis;
120}
121.session-card .meta {
122 font-size: 11px;
123 color: var(--text2);
124 margin-top: 4px;
125 display: flex;
126 gap: 8px;
127}
128.session-status {
129 color: var(--text2);
130}
131.session-status.closed {
132 color: var(--danger-hover);
133}
134
135/* ── Detail panel ───────────────────────── */
136section.detail {
137 flex: 1;
138 display: flex;
139 flex-direction: column;
140 overflow: hidden;
141}
142.detail-empty {
143 flex: 1;
144 display: flex;
145 align-items: center;
146 justify-content: center;
147 color: var(--text2);
148 font-size: 14px;
149}
150
151/* detail header */
152.detail-header {
153 padding: 12px 20px;
154 border-bottom: 1px solid var(--border);
155 display: flex;
156 align-items: center;
157 gap: 16px;
158 flex-shrink: 0;
159}
160.detail-header .sid {
161 font-family: var(--mono);
162 font-size: 14px;
163 font-weight: 600;
164}
165.detail-header .goal-text {
166 font-size: 13px;
167 color: var(--text2);
168 flex: 1;
169 white-space: nowrap;
170 overflow: hidden;
171 text-overflow: ellipsis;
172}
173.detail-header .toggle-row {
174 display: flex;
175 align-items: center;
176 gap: 6px;
177 font-size: 12px;
178 color: var(--text2);
179 white-space: nowrap;
180}
181.closed-badge {
182 font-size: 11px;
183 color: var(--danger-hover);
184 border: 1px solid var(--danger);
185 border-radius: 999px;
186 padding: 2px 8px;
187 white-space: nowrap;
188}
189
190/* toggle switch */
191.switch {
192 position: relative;
193 width: 36px; height: 20px;
194 display: inline-block;
195}
196.switch input { display: none; }
197.switch .slider {
198 position: absolute; inset: 0;
199 background: var(--border);
200 border-radius: 10px;
201 cursor: pointer;
202 transition: background 0.2s;
203}
204.switch .slider::before {
205 content: "";
206 position: absolute;
207 left: 2px; top: 2px;
208 width: 16px; height: 16px;
209 background: var(--text);
210 border-radius: 50%;
211 transition: transform 0.2s;
212}
213.switch input:checked + .slider { background: var(--accent); }
214.switch input:checked + .slider::before { transform: translateX(16px); }
215
216/* tabs */
217.detail-tabs {
218 display: flex;
219 border-bottom: 1px solid var(--border);
220 flex-shrink: 0;
221}
222.detail-tabs button {
223 flex: 1;
224 padding: 8px;
225 background: none;
226 border: none;
227 color: var(--text2);
228 font-size: 13px;
229 cursor: pointer;
230 border-bottom: 2px solid transparent;
231 transition: color 0.15s, border-color 0.15s;
232}
233.detail-tabs button.active {
234 color: var(--text);
235 border-bottom-color: var(--accent);
236}
237
238#tab-content {
239 flex: 1;
240 display: flex;
241 flex-direction: column;
242 overflow: hidden;
243 min-height: 0;
244}
245
246/* prompts tab */
247.prompts-panel {
248 flex: 1;
249 overflow-y: auto;
250 padding: 16px 20px;
251}
252.prompt-block {
253 margin-bottom: 16px;
254}
255.prompt-block h3 {
256 font-size: 13px;
257 font-weight: 600;
258 margin-bottom: 6px;
259 display: flex;
260 align-items: center;
261 gap: 8px;
262}
263.prompt-block h3 .badge {
264 font-size: 10px;
265 padding: 2px 6px;
266 border-radius: 4px;
267 font-weight: 500;
268}
269.badge-claude { background: var(--claude); color: #fff; }
270.badge-codex { background: var(--codex); color: #000; }
271.prompt-block pre {
272 background: var(--bg);
273 border: 1px solid var(--border);
274 border-radius: var(--radius);
275 padding: 12px;
276 font-family: var(--mono);
277 font-size: 12px;
278 line-height: 1.5;
279 white-space: pre-wrap;
280 word-break: break-word;
281 max-height: 300px;
282 overflow-y: auto;
283 color: var(--text2);
284}
285.prompt-block .copy-row {
286 margin-top: 6px;
287 text-align: right;
288}
289
290/* chat tab */
291.chat-panel {
292 flex: 1;
293 overflow-y: auto;
294 padding: 16px 20px;
295 display: flex;
296 flex-direction: column;
297 gap: 8px;
298}
299.chat-empty {
300 flex: 1;
301 display: flex;
302 align-items: center;
303 justify-content: center;
304 color: var(--text2);
305 font-size: 13px;
306}
307.msg {
308 max-width: 80%;
309 padding: 10px 14px;
310 border-radius: 12px;
311 font-size: 13px;
312 line-height: 1.5;
313 position: relative;
314}
315.msg.from-claude {
316 align-self: flex-start;
317 background: #1e1e4a;
318 border: 1px solid #2e2e6a;
319}
320.msg.from-codex {
321 align-self: flex-end;
322 background: #1a2e1a;
323 border: 1px solid #2a4a2a;
324}
325.msg.pending {
326 border-style: dashed;
327 background: var(--pending-bg);
328}
329.msg .msg-header {
330 display: flex;
331 align-items: center;
332 gap: 6px;
333 margin-bottom: 4px;
334 font-size: 11px;
335 color: var(--text2);
336}
337.msg .msg-header .agent {
338 font-weight: 600;
339 text-transform: uppercase;
340 font-size: 10px;
341 letter-spacing: 0.5px;
342}
343.msg .msg-header .agent.claude { color: var(--claude); }
344.msg .msg-header .agent.codex { color: var(--codex); }
345.msg .msg-body {
346 font-family: var(--mono);
347 font-size: 12px;
348 white-space: pre-wrap;
349 word-break: break-word;
350}
351.msg .msg-actions {
352 margin-top: 8px;
353 display: flex;
354 gap: 6px;
355}
356
357/* ── Buttons ────────────────────────────── */
358button {
359 font-family: var(--font);
360 font-size: 12px;
361 padding: 6px 12px;
362 border-radius: 6px;
363 border: none;
364 cursor: pointer;
365 transition: background 0.15s;
366}
367.btn-primary {
368 background: var(--accent);
369 color: #fff;
370}
371.btn-primary:hover { background: var(--accent-hover); }
372.btn-danger {
373 background: var(--danger);
374 color: #fff;
375}
376.btn-danger:hover { background: var(--danger-hover); }
377.btn-ghost {
378 background: transparent;
379 color: var(--text2);
380 border: 1px solid var(--border);
381}
382.btn-ghost:hover { background: var(--surface2); color: var(--text); }
383.btn-sm { padding: 4px 8px; font-size: 11px; }
384
385/* ── New Session Modal ──────────────────── */
386.modal-overlay {
387 position: fixed;
388 inset: 0;
389 background: rgba(0,0,0,0.6);
390 display: flex;
391 align-items: center;
392 justify-content: center;
393 z-index: 100;
394}
395.modal-overlay.hidden { display: none; }
396.modal {
397 background: var(--surface);
398 border: 1px solid var(--border);
399 border-radius: 12px;
400 padding: 24px;
401 width: 640px;
402 max-width: 92vw;
403 max-height: 90vh;
404 overflow-y: auto;
405}
406.modal.edit-modal {
407 width: 820px;
408}
409.modal h2 {
410 font-size: 16px;
411 margin-bottom: 16px;
412}
413.form-group {
414 margin-bottom: 14px;
415}
416.form-group label {
417 display: block;
418 font-size: 12px;
419 color: var(--text2);
420 margin-bottom: 4px;
421}
422.form-group input,
423.form-group textarea,
424.form-group select {
425 width: 100%;
426 padding: 8px 10px;
427 background: var(--bg);
428 border: 1px solid var(--border);
429 border-radius: 6px;
430 color: var(--text);
431 font-family: var(--font);
432 font-size: 13px;
433 outline: none;
434}
435.form-group textarea {
436 resize: vertical;
437 min-height: 160px;
438 line-height: 1.5;
439}
440.form-group input:focus,
441.form-group textarea:focus,
442.form-group select:focus {
443 border-color: var(--accent);
444}
445.modal-actions {
446 display: flex;
447 gap: 8px;
448 justify-content: flex-end;
449 margin-top: 16px;
450}
451
452/* ── Edit modal ─────────────────────────── */
453.edit-modal textarea {
454 width: 100%;
455 min-height: 440px;
456 padding: 12px;
457 background: var(--bg);
458 border: 1px solid var(--border);
459 border-radius: 6px;
460 color: var(--text);
461 font-family: var(--mono);
462 font-size: 13px;
463 line-height: 1.5;
464 resize: vertical;
465 outline: none;
466}
467.edit-modal textarea:focus { border-color: var(--accent); }
468
469/* ── Scrollbar ──────────────────────────── */
470::-webkit-scrollbar { width: 6px; }
471::-webkit-scrollbar-track { background: transparent; }
472::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
473::-webkit-scrollbar-thumb:hover { background: var(--text2); }
474
475/* ── Delete button in header ────────────── */
476.detail-header .btn-del {
477 margin-left: 4px;
478}
479
480/* ── Phase strip ────────────────────────── */
481.phase-strip {
482 display: flex;
483 align-items: center;
484 gap: 10px;
485 padding: 8px 20px;
486 background: var(--surface2);
487 border-bottom: 1px solid var(--border);
488 font-size: 12px;
489 color: var(--text2);
490 flex-shrink: 0;
491}
492.phase-strip .phase-label {
493 flex: 1;
494 color: var(--text);
495 font-weight: 500;
496}
497.phase-strip .phase-label .phase-count {
498 color: var(--text2);
499 font-weight: 400;
500 margin-right: 6px;
501}
502.phase-strip .phase-label .phase-id {
503 color: var(--text2);
504 font-family: var(--mono);
505 font-size: 11px;
506 margin-left: 6px;
507}
508.phase-strip button:disabled {
509 opacity: 0.4;
510 cursor: not-allowed;
511}
512
513/* ── Workflow description under select ──── */
514.workflow-desc {
515 display: block;
516 margin-top: 6px;
517 color: var(--text2);
518 font-size: 11px;
519 line-height: 1.4;
520}
521</style>
522</head>
523<body>
524 
525<header>
526 <h1>collab<span class="dot">-</span>bridge</h1>
527 <div class="status"><span class="live" id="live-dot"></span><span id="status-text">connecting...</span></div>
528</header>
529 
530<main>
531 <aside>
532 <div class="sidebar-header">
533 <button class="btn-primary" onclick="showNewModal()">+ New Session</button>
534 </div>
535 <div class="session-list" id="session-list"></div>
536 </aside>
537 
538 <section class="detail" id="detail">
539 <div class="detail-empty" id="detail-empty">Select or create a session</div>
540 </section>
541</main>
542 
543<!-- New Session Modal -->
544<div class="modal-overlay hidden" id="new-modal">
545 <div class="modal">
546 <h2>New Collaboration Session</h2>
547 <div class="form-group">
548 <label>Workflow template</label>
549 <select id="new-workflow" onchange="onWorkflowChange()">
550 <option value="custom">Custom (no contract)</option>
551 </select>
552 <small class="workflow-desc" id="new-workflow-desc"></small>
553 </div>
554 <div class="form-group">
555 <label>Goal</label>
556 <textarea id="new-goal" placeholder="Describe the shared goal for the two agents..."></textarea>
557 </div>
558 <div class="form-group" id="new-initiator-group">
559 <label>Initiator (sends first message)</label>
560 <select id="new-initiator">
561 <option value="claude">Claude</option>
562 <option value="codex">Codex</option>
563 </select>
564 </div>
565 <div class="modal-actions">
566 <button class="btn-ghost" onclick="hideNewModal()">Cancel</button>
567 <button class="btn-primary" onclick="createSession()">Create</button>
568 </div>
569 </div>
570</div>
571 
572<!-- Edit Message Modal -->
573<div class="modal-overlay hidden" id="edit-modal">
574 <div class="modal edit-modal">
575 <h2>Edit Message</h2>
576 <textarea id="edit-body"></textarea>
577 <input type="hidden" id="edit-session-id">
578 <input type="hidden" id="edit-msg-id">
579 <div class="modal-actions">
580 <button class="btn-ghost" onclick="hideEditModal()">Cancel</button>
581 <button class="btn-primary" onclick="saveEdit()">Save</button>
582 </div>
583 </div>
584</div>
585 
586<script>
587// ── State ──────────────────────────────────────────────────────────
588
589const state = {
590 sessions: [],
591 activeId: null,
592 activeTab: "prompts",
593 prompts: {},
594 workflows: [],
595};
596
597function getWorkflowById(id) {
598 return state.workflows.find(w => w.id === id);
599}
600
601// ── API ────────────────────────────────────────────────────────────
602
603async function api(method, path, body) {
604 const opts = { method, headers: {} };
605 if (body !== undefined) {
606 opts.headers["Content-Type"] = "application/json";
607 opts.body = JSON.stringify(body);
608 }
609 const res = await fetch("/api" + path, opts);
610 if (res.status === 204) return null;
611 const data = await res.json();
612 if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
613 return data;
614}
615
616async function loadSessions() {
617 state.sessions = await api("GET", "/sessions");
618 renderSidebar();
619 if (state.activeId) renderDetail();
620}
621
622async function loadWorkflows() {
623 try {
624 state.workflows = await api("GET", "/workflows");
625 populateWorkflowSelect();
626 } catch (err) {
627 console.error("failed to load workflows", err);
628 }
629}
630
631function populateWorkflowSelect() {
632 const sel = document.getElementById("new-workflow");
633 if (!sel) return;
634 const current = sel.value || "custom";
635 sel.innerHTML = state.workflows.map(w =>
636 `<option value="${esc(w.id)}">${esc(w.name)}</option>`
637 ).join("");
638 sel.value = state.workflows.some(w => w.id === current) ? current : "custom";
639 onWorkflowChange();
640}
641
642function onWorkflowChange() {
643 const sel = document.getElementById("new-workflow");
644 const desc = document.getElementById("new-workflow-desc");
645 const goal = document.getElementById("new-goal");
646 const initiatorGroup = document.getElementById("new-initiator-group");
647 if (!sel || !desc) return;
648 const w = getWorkflowById(sel.value);
649 if (!w) {
650 desc.textContent = "";
651 if (initiatorGroup) initiatorGroup.style.display = "";
652 return;
653 }
654 desc.textContent = w.description || "";
655 if (goal && w.goalPlaceholder) goal.placeholder = w.goalPlaceholder;
656 if (initiatorGroup) {
657 initiatorGroup.style.display = w.forcedInitiator ? "none" : "";
658 }
659}
660
661async function loadPrompts(id) {
662 state.prompts[id] = await api("GET", `/sessions/${id}/prompts`);
663}
664
665// ── SSE ────────────────────────────────────────────────────────────
666
667function connectSSE() {
668 const es = new EventSource("/events");
669 es.onopen = () => {
670 document.getElementById("status-text").textContent = "connected";
671 document.getElementById("live-dot").style.background = "#22c55e";
672 };
673 es.onerror = () => {
674 document.getElementById("status-text").textContent = "reconnecting...";
675 document.getElementById("live-dot").style.background = "#ef4444";
676 };
677 es.onmessage = (e) => {
678 const ev = JSON.parse(e.data);
679 handleEvent(ev);
680 };
681}
682
683function handleEvent(ev) {
684 switch (ev.type) {
685 case "session-created": {
686 const idx = state.sessions.findIndex(s => s.id === ev.session.id);
687 if (idx === -1) state.sessions.unshift(ev.session);
688 else state.sessions[idx] = ev.session;
689 renderSidebar();
690 if (state.activeId === ev.session.id) renderDetail();
691 break;
692 }
693 case "session-updated": {
694 const idx = state.sessions.findIndex(s => s.id === ev.session.id);
695 if (idx !== -1) state.sessions[idx] = ev.session;
696 delete state.prompts[ev.session.id];
697 renderSidebar();
698 if (state.activeId === ev.session.id) renderDetail();
699 break;
700 }
701 case "session-deleted": {
702 state.sessions = state.sessions.filter(s => s.id !== ev.sessionId);
703 if (state.activeId === ev.sessionId) state.activeId = null;
704 renderSidebar();
705 renderDetail();
706 break;
707 }
708 case "message-added":
709 case "message-updated": {
710 const sess = state.sessions.find(s => s.id === ev.sessionId);
711 if (sess) {
712 const idx = sess.messages.findIndex(m => m.id === ev.message.id);
713 if (idx === -1) sess.messages.push(ev.message);
714 else sess.messages[idx] = ev.message;
715 }
716 renderSidebar();
717 if (state.activeId === ev.sessionId) renderDetail();
718 break;
719 }
720 case "message-removed": {
721 const sess = state.sessions.find(s => s.id === ev.sessionId);
722 if (sess) {
723 sess.messages = sess.messages.filter(m => m.id !== ev.messageId);
724 }
725 renderSidebar();
726 if (state.activeId === ev.sessionId) renderDetail();
727 break;
728 }
729 }
730}
731
732// ── Render ─────────────────────────────────────────────────────────
733
734function renderSidebar() {
735 const el = document.getElementById("session-list");
736 if (state.sessions.length === 0) {
737 el.innerHTML = '<div style="padding:20px;text-align:center;color:var(--text2);font-size:13px">No sessions yet</div>';
738 return;
739 }
740 el.innerHTML = state.sessions.map(s => `
741 <div class="session-card ${s.id === state.activeId ? 'active' : ''} ${s.status === 'closed' ? 'closed' : ''}" onclick="selectSession('${s.id}')">
742 <div class="sid">${esc(s.id)}</div>
743 <div class="goal">${esc(s.goal)}</div>
744 <div class="meta">
745 <span>${s.messages.length} msg${s.messages.length !== 1 ? 's' : ''}</span>
746 <span>${s.autoForward ? 'auto' : 'review'}</span>
747 <span class="session-status ${s.status === 'closed' ? 'closed' : ''}">${s.status || 'active'}</span>
748 </div>
749 </div>
750 `).join("");
751}
752
753function renderDetail() {
754 const el = document.getElementById("detail");
755 const sess = state.sessions.find(s => s.id === state.activeId);
756
757 if (!sess) {
758 el.innerHTML = '<div class="detail-empty">Select or create a session</div>';
759 return;
760 }
761
762 const tab = state.activeTab;
763 const workflow = getWorkflowById(sess.workflow);
764 const hasPhases = workflow && workflow.phases && workflow.phases.length > 0;
765 let phaseStripHtml = "";
766 if (hasPhases) {
767 const total = workflow.phases.length;
768 const idx = sess.currentPhaseIndex || 0;
769 const phase = workflow.phases[Math.min(idx, total - 1)];
770 const prevDisabled = idx <= 0 ? "disabled" : "";
771 const nextDisabled = idx >= total - 1 ? "disabled" : "";
772 phaseStripHtml = `
773 <div class="phase-strip">
774 <button class="btn-ghost btn-sm" ${prevDisabled} onclick="advancePhase('${sess.id}', -1)">&lt; Prev</button>
775 <span class="phase-label">
776 <span class="phase-count">Phase ${idx + 1} of ${total}:</span> ${esc(phase.name)} <span class="phase-id">(${esc(phase.id)})</span>
777 </span>
778 <button class="btn-ghost btn-sm" ${nextDisabled} onclick="advancePhase('${sess.id}', 1)">Next &gt;</button>
779 </div>
780 `;
781 }
782
783 el.innerHTML = `
784 <div class="detail-header">
785 <span class="sid">${esc(sess.id)}</span>
786 ${sess.status === 'closed' ? `<span class="closed-badge">Closed${sess.closedBy ? ` by ${esc(sess.closedBy)}` : ''}</span>` : ''}
787 <span class="goal-text">${esc(sess.goal)}</span>
788 <div class="toggle-row">
789 <span>Auto-forward</span>
790 <label class="switch">
791 <input type="checkbox" ${sess.autoForward ? 'checked' : ''} onchange="toggleAutoForward('${sess.id}', this.checked)">
792 <span class="slider"></span>
793 </label>
794 </div>
795 <button class="btn-ghost btn-sm" onclick="saveTranscript('${sess.id}')">Save Transcript</button>
796 ${sess.status === 'closed' ? '' : `<button class="btn-ghost btn-sm" onclick="closeSession('${sess.id}')">Close</button>`}
797 <button class="btn-danger btn-sm btn-del" onclick="deleteSession('${sess.id}')">Delete</button>
798 </div>
799 <div class="detail-tabs">
800 <button class="${tab === 'prompts' ? 'active' : ''}" onclick="switchTab('prompts')">Prompts</button>
801 <button class="${tab === 'chat' ? 'active' : ''}" onclick="switchTab('chat')">Chat (${sess.messages.length})</button>
802 </div>
803 ${phaseStripHtml}
804 <div id="tab-content"></div>
805 `;
806
807 if (tab === "prompts") renderPrompts(sess);
808 else renderChat(sess);
809}
810
811async function renderPrompts(sess) {
812 const container = document.getElementById("tab-content");
813 if (!state.prompts[sess.id]) {
814 container.innerHTML = '<div class="prompts-panel"><div style="color:var(--text2)">Loading prompts...</div></div>';
815 await loadPrompts(sess.id);
816 }
817 const p = state.prompts[sess.id];
818 if (!p) return;
819
820 container.innerHTML = `
821 <div class="prompts-panel">
822 <div class="prompt-block">
823 <h3>Initiator <span class="badge badge-${sess.initiator}">${sess.initiator}</span></h3>
824 <pre>${esc(p.initiator)}</pre>
825 <div class="copy-row">
826 <button class="btn-ghost btn-sm" onclick="copyPrompt(this, '${sess.id}', 'initiator')">Copy</button>
827 </div>
828 </div>
829 <div class="prompt-block">
830 <h3>Responder <span class="badge badge-${sess.responder}">${sess.responder}</span></h3>
831 <pre>${esc(p.responder)}</pre>
832 <div class="copy-row">
833 <button class="btn-ghost btn-sm" onclick="copyPrompt(this, '${sess.id}', 'responder')">Copy</button>
834 </div>
835 </div>
836 </div>
837 `;
838}
839
840function renderChat(sess) {
841 const container = document.getElementById("tab-content");
842 if (sess.messages.length === 0) {
843 container.innerHTML = '<div class="chat-panel"><div class="chat-empty">No messages yet. Paste prompts into your agents to start.</div></div>';
844 return;
845 }
846
847 container.innerHTML = `<div class="chat-panel" id="chat-scroll">
848 ${sess.messages.map(m => {
849 const isPending = m.status === "pending";
850 return `
851 <div class="msg from-${m.from} ${isPending ? 'pending' : ''}">
852 <div class="msg-header">
853 <span class="agent ${m.from}">${m.from}</span>
854 <span>#${m.id}</span>
855 <span>${isPending ? 'pending' : 'delivered'}</span>
856 </div>
857 <div class="msg-body">${esc(m.body)}</div>
858 ${isPending ? `
859 <div class="msg-actions">
860 <button class="btn-primary btn-sm" onclick="deliverMsg('${sess.id}', ${m.id})">Deliver</button>
861 <button class="btn-ghost btn-sm" onclick="editMsg('${sess.id}', ${m.id})">Edit</button>
862 <button class="btn-danger btn-sm" onclick="rejectMsg('${sess.id}', ${m.id})">Reject</button>
863 </div>
864 ` : ''}
865 </div>
866 `;
867 }).join("")}
868 </div>`;
869
870 // Scroll to bottom
871 const sc = document.getElementById("chat-scroll");
872 if (sc) sc.scrollTop = sc.scrollHeight;
873}
874
875// ── Actions ────────────────────────────────────────────────────────
876
877function selectSession(id) {
878 state.activeId = id;
879 renderSidebar();
880 renderDetail();
881}
882
883function switchTab(tab) {
884 state.activeTab = tab;
885 renderDetail();
886}
887
888function showNewModal() {
889 document.getElementById("new-modal").classList.remove("hidden");
890 document.getElementById("new-goal").focus();
891}
892function hideNewModal() {
893 document.getElementById("new-modal").classList.add("hidden");
894}
895
896async function createSession() {
897 const goal = document.getElementById("new-goal").value.trim();
898 if (!goal) return;
899 const workflow = document.getElementById("new-workflow").value;
900 const w = getWorkflowById(workflow);
901 const body = { goal, workflow };
902 if (!w || !w.forcedInitiator) {
903 body.initiator = document.getElementById("new-initiator").value;
904 }
905 try {
906 const sess = await api("POST", "/sessions", body);
907 // Let SSE session-created event update state.sessions
908 state.activeId = sess.id;
909 state.activeTab = "prompts";
910 hideNewModal();
911 document.getElementById("new-goal").value = "";
912 renderSidebar();
913 renderDetail();
914 } catch (err) {
915 alert(err.message);
916 }
917}
918
919async function advancePhase(sessionId, delta) {
920 const sess = state.sessions.find(s => s.id === sessionId);
921 if (!sess) return;
922 const w = getWorkflowById(sess.workflow);
923 if (!w || !w.phases || w.phases.length === 0) return;
924 const next = sess.currentPhaseIndex + delta;
925 if (next < 0 || next >= w.phases.length) return;
926 try {
927 await api("PATCH", `/sessions/${sessionId}`, { currentPhaseIndex: next });
928 // SSE session-updated event will refresh
929 } catch (err) {
930 alert(err.message);
931 }
932}
933
934async function deleteSession(id) {
935 if (!confirm("Delete this session?")) return;
936 try {
937 await api("DELETE", `/sessions/${id}`);
938 // SSE session-deleted event will update state
939 } catch (err) {
940 alert(err.message);
941 }
942}
943
944async function closeSession(id) {
945 if (!confirm("Close this session? Agents will stop waiting and cannot send more messages.")) return;
946 try {
947 await api("POST", `/sessions/${id}/close`, { reason: "Closed from web UI" });
948 } catch (err) {
949 alert(err.message);
950 }
951}
952
953async function toggleAutoForward(id, val) {
954 try {
955 await api("PATCH", `/sessions/${id}`, { autoForward: val });
956 } catch (err) {
957 alert(err.message);
958 }
959}
960
961async function deliverMsg(sessionId, msgId) {
962 try {
963 await api("POST", `/sessions/${sessionId}/messages/${msgId}/deliver`);
964 } catch (err) {
965 alert(err.message);
966 }
967}
968
969async function rejectMsg(sessionId, msgId) {
970 if (!confirm("Reject this message?")) return;
971 try {
972 await api("DELETE", `/sessions/${sessionId}/messages/${msgId}`);
973 } catch (err) {
974 alert(err.message);
975 }
976}
977
978function editMsg(sessionId, msgId) {
979 const sess = state.sessions.find(s => s.id === sessionId);
980 const msg = sess?.messages.find(m => m.id === msgId);
981 if (!msg) return;
982 document.getElementById("edit-session-id").value = sessionId;
983 document.getElementById("edit-msg-id").value = msgId;
984 document.getElementById("edit-body").value = msg.body;
985 document.getElementById("edit-modal").classList.remove("hidden");
986 document.getElementById("edit-body").focus();
987}
988function hideEditModal() {
989 document.getElementById("edit-modal").classList.add("hidden");
990}
991async function saveEdit() {
992 const sessionId = document.getElementById("edit-session-id").value;
993 const msgId = document.getElementById("edit-msg-id").value;
994 const body = document.getElementById("edit-body").value;
995 try {
996 await api("PATCH", `/sessions/${sessionId}/messages/${msgId}`, { body });
997 hideEditModal();
998 } catch (err) {
999 alert(err.message);
1000 }
1001}
1002
1003function saveTranscript(sessionId) {
1004 const sess = state.sessions.find(s => s.id === sessionId);
1005 if (!sess) return;
1006 const lines = [];
1007 lines.push(`Collaboration Transcript`);
1008 lines.push(`Session: ${sess.id}`);
1009 lines.push(`Goal: ${sess.goal}`);
1010 lines.push(`Initiator: ${sess.initiator} | Responder: ${sess.responder}`);
1011 lines.push(`Status: ${sess.status || 'active'}${sess.closedBy ? ` | Closed by: ${sess.closedBy}` : ''}`);
1012 if (sess.closedReason) lines.push(`Closed reason: ${sess.closedReason}`);
1013 lines.push(`Messages: ${sess.messages.length}`);
1014 lines.push("=".repeat(60));
1015 lines.push("");
1016 for (const m of sess.messages) {
1017 const time = new Date(m.createdAt).toISOString();
1018 lines.push(`[#${m.id}] ${m.from.toUpperCase()} (${m.status}) -- ${time}`);
1019 lines.push("-".repeat(40));
1020 lines.push(m.body);
1021 lines.push("");
1022 }
1023 const blob = new Blob([lines.join("\n")], { type: "text/plain" });
1024 const a = document.createElement("a");
1025 a.href = URL.createObjectURL(blob);
1026 a.download = `${sessionId}-transcript.txt`;
1027 a.click();
1028 URL.revokeObjectURL(a.href);
1029}
1030
1031async function copyPrompt(btn, sessionId, role) {
1032 const p = state.prompts[sessionId];
1033 if (!p) return;
1034 await copyText(btn, p[role]);
1035}
1036
1037async function copyText(btn, text) {
1038 try {
1039 await navigator.clipboard.writeText(text);
1040 const orig = btn.textContent;
1041 btn.textContent = "Copied!";
1042 btn.style.color = "var(--codex)";
1043 setTimeout(() => { btn.textContent = orig; btn.style.color = ""; }, 1500);
1044 } catch { /* ignore */ }
1045}
1046
1047// ── Helpers ────────────────────────────────────────────────────────
1048
1049function esc(s) {
1050 const d = document.createElement("div");
1051 d.textContent = s;
1052 return d.innerHTML;
1053}
1054
1055// ── Init ───────────────────────────────────────────────────────────
1056
1057loadWorkflows();
1058loadSessions();
1059connectSSE();
1060</script>
1061</body>
1062</html>