Download swarm_visual.py from smlflg/ClaudeCodePanel: direct link, hf CLI and curl.
- Browser
- Download file 25.8 kB
-
https://huggingface.co/smlflg/ClaudeCodePanel/resolve/main/swarm_visual.py
- Command line
-
hf download hf://smlflg/ClaudeCodePanel/swarm_visual.py
-
curl -L -o swarm_visual.py https://huggingface.co/smlflg/ClaudeCodePanel/resolve/main/swarm_visual.py
25.8 kB
| #!/usr/bin/env python3 | |
| """Swarm Visual — HTML generator for live agent communication graphs. | |
| Generates a self-contained HTML page with hub-and-spoke layout showing | |
| real team members, their connections via bezier curves, animated particles | |
| with trail effects, and live-updating status. | |
| """ | |
| import json | |
| import math | |
| from datetime import datetime, timezone | |
| from html import escape as _esc | |
| from pathlib import Path | |
| TEAMS_DIR = Path.home() / ".claude" / "teams" | |
| TASKS_DIR = Path.home() / ".claude" / "tasks" | |
| # Color mapping for member.color -> CSS hex | |
| COLOR_MAP = { | |
| "pink": "#f5a0c0", | |
| "green": "#98c379", | |
| "cyan": "#56b6c2", | |
| "blue": "#61afef", | |
| "purple": "#c678dd", | |
| "yellow": "#e5c07b", | |
| "red": "#e06c75", | |
| "orange": "#d19a66", | |
| } | |
| MODEL_BADGE = { | |
| "claude-opus-4-6": ("opus", "#c678dd"), | |
| "opus": ("opus", "#c678dd"), | |
| "claude-sonnet-4-6": ("sonnet", "#61afef"), | |
| "sonnet": ("sonnet", "#61afef"), | |
| "claude-haiku-4-5": ("haiku", "#98c379"), | |
| "haiku": ("haiku", "#98c379"), | |
| } | |
| def _is_recent(ts_str: str, seconds: int = 120) -> bool: | |
| if not ts_str: | |
| return False | |
| try: | |
| ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00")) | |
| return (datetime.now(timezone.utc) - ts).total_seconds() < seconds | |
| except (ValueError, AttributeError): | |
| return False | |
| def _load_inbox(team_dir: Path, member_name: str) -> list[dict]: | |
| inbox = team_dir / "inboxes" / f"{member_name}.json" | |
| if not inbox.exists(): | |
| return [] | |
| try: | |
| data = json.loads(inbox.read_text(encoding="utf-8")) | |
| if not isinstance(data, list): | |
| return [] | |
| return [ | |
| m for m in data | |
| if not any(k in m.get("text", "") for k in | |
| ['"type":"task_assignment"', '"type":"shutdown_request"', | |
| '"type":"idle_notification"']) | |
| ] | |
| except (json.JSONDecodeError, OSError): | |
| return [] | |
| def _load_tasks(team_name: str) -> list[dict]: | |
| tasks = [] | |
| task_dir = TASKS_DIR / team_name | |
| if not task_dir.exists(): | |
| return tasks | |
| for tf in sorted(task_dir.glob("*.json"), key=lambda f: f.stem.zfill(10)): | |
| try: | |
| data = json.loads(tf.read_text(encoding="utf-8")) | |
| if data.get("metadata", {}).get("_internal"): | |
| continue | |
| tasks.append(data) | |
| except (json.JSONDecodeError, OSError): | |
| continue | |
| return tasks | |
| def generate_comm_graph(team_name: str) -> str: | |
| """Generate a complete HTML comm graph for a team.""" | |
| team_dir = TEAMS_DIR / team_name | |
| config_path = team_dir / "config.json" | |
| if not config_path.exists(): | |
| return f"<html><body style='background:#080c10;color:#e6edf3;padding:2em'>Team '{_esc(team_name)}' nicht gefunden.</body></html>" | |
| try: | |
| config = json.loads(config_path.read_text(encoding="utf-8")) | |
| except (json.JSONDecodeError, OSError): | |
| return f"<html><body style='background:#080c10;color:#e6edf3;padding:2em'>Team '{_esc(team_name)}' config fehlerhaft.</body></html>" | |
| members = config.get("members", []) | |
| if not members: | |
| return f"<html><body style='background:#080c10;color:#e6edf3;padding:2em'>Team '{_esc(team_name)}' hat keine Members.</body></html>" | |
| # Separate lead from other members | |
| lead = None | |
| others = [] | |
| for m in members: | |
| if m.get("agentType") == "team-lead" or m.get("name") == "team-lead": | |
| lead = m | |
| else: | |
| others.append(m) | |
| if not lead and members: | |
| lead = members[0] | |
| others = members[1:] | |
| # Collect inbox data per member | |
| member_data = [] | |
| for m in [lead] + others: | |
| name = m.get("name", "?") | |
| msgs = _load_inbox(team_dir, name) | |
| model_key = m.get("model", "") | |
| model_info = MODEL_BADGE.get(model_key, (model_key, "#8b949e")) | |
| color = COLOR_MAP.get(m.get("color", ""), "#8b949e") | |
| last_ts = msgs[-1].get("timestamp", "") if msgs else "" | |
| member_data.append({ | |
| "name": name, | |
| "model": model_info[0], | |
| "model_color": model_info[1], | |
| "color": color, | |
| "agent_type": m.get("agentType", ""), | |
| "msg_count": len(msgs), | |
| "status": "working" if _is_recent(last_ts, 120) else "idle", | |
| "last_msgs": msgs[-3:] if msgs else [], | |
| "is_lead": m == lead, | |
| "has_recent": _is_recent(last_ts, 300), | |
| }) | |
| # Load tasks | |
| tasks = _load_tasks(team_name) | |
| # Collect all messages chronologically | |
| all_msgs = [] | |
| for m in members: | |
| name = m.get("name", "?") | |
| for msg in _load_inbox(team_dir, name): | |
| msg["_to"] = name | |
| all_msgs.append(msg) | |
| all_msgs.sort(key=lambda x: x.get("timestamp", "")) | |
| recent_msgs = all_msgs[-10:] | |
| # Layout: lead in center, others in circle | |
| cx, cy = 400, 270 | |
| radius = 190 | |
| n = len(others) | |
| positions = {} | |
| positions[lead.get("name", "?")] = (cx, cy) | |
| for i, m in enumerate(others): | |
| angle = -math.pi / 2 + (2 * math.pi * i / max(n, 1)) | |
| x = cx + radius * math.cos(angle) | |
| y = cy + radius * math.sin(angle) | |
| positions[m.get("name", "?")] = (x, y) | |
| lead_name = lead.get("name", "?") | |
| # Build SVG bezier connections | |
| svg_paths = [] | |
| for md in member_data: | |
| if md["is_lead"]: | |
| continue | |
| lx, ly = positions[lead_name] | |
| mx, my = positions[md["name"]] | |
| # Control point: offset perpendicular to midpoint | |
| midx, midy = (lx + mx) / 2, (ly + my) / 2 | |
| dx, dy = mx - lx, my - ly | |
| length = math.sqrt(dx * dx + dy * dy) or 1 | |
| # Perpendicular offset (30px) | |
| px, py = -dy / length * 30, dx / length * 30 | |
| cpx, cpy = midx + px, midy + py | |
| active_class = "conn-active" if md["has_recent"] else "conn" | |
| svg_paths.append( | |
| f'<path class="{active_class}" id="path-{md["name"]}" ' | |
| f'data-from="{lead_name}" data-to="{md["name"]}" ' | |
| f'd="M {lx} {ly} Q {cpx:.1f} {cpy:.1f} {mx:.1f} {my:.1f}" ' | |
| f'stroke="{md["color"]}" stroke-width="2" fill="none" stroke-opacity="0.3"/>' | |
| ) | |
| # Build node HTML | |
| node_html_parts = [] | |
| for md in member_data: | |
| x, y = positions[md["name"]] | |
| assigned_task = next((t for t in tasks if t.get("owner") == md["name"]), None) | |
| task_html = "" | |
| if assigned_task: | |
| st = assigned_task.get("status", "pending") | |
| st_color = {"completed": "#98c379", "in_progress": "#e5c07b"}.get(st, "#8b949e") | |
| desc_preview = _esc(assigned_task.get("description", "")[:100].replace('\n', ' ')) | |
| subj = _esc(assigned_task.get("subject", "")[:60]) | |
| task_html = f''' | |
| <div class="node-task"> | |
| <span class="task-status" style="color:{st_color}">{st.upper().replace("_"," ")}</span> | |
| {subj} | |
| <div class="task-desc">{desc_preview}</div> | |
| </div>''' | |
| msg_html = "" | |
| if md["last_msgs"]: | |
| msg_items = "" | |
| for msg in md["last_msgs"]: | |
| sender = _esc(msg.get("from", "?")) | |
| text = _esc(msg.get("text", "")[:80].replace("\n", " ")) | |
| msg_items += f'<div class="detail-msg"><b>{sender}:</b> {text}</div>' | |
| msg_html = f'<div class="node-messages">{msg_items}</div>' | |
| status_class = "status-working" if md["status"] == "working" else "status-idle" | |
| safe_name = _esc(md['name']) | |
| # Use data attribute for JS to avoid quote-breaking in onclick | |
| node_html_parts.append(f''' | |
| <div class="node" id="node-{safe_name}" data-name="{safe_name}" | |
| style="left:{x-70}px;top:{y-40}px;--node-color:{md['color']}" | |
| onclick="toggleDetail(this.dataset.name)" | |
| onmouseenter="highlightConn(this.dataset.name,true)" | |
| onmouseleave="highlightConn(this.dataset.name,false)"> | |
| <div class="node-header"> | |
| <span class="status-dot {status_class}" id="dot-{safe_name}"></span> | |
| <span class="node-name">{safe_name}</span> | |
| <span class="model-badge" style="background:{md['model_color']}20;color:{md['model_color']}">{md['model']}</span> | |
| </div> | |
| <div class="node-meta"> | |
| <span class="agent-type">{md['agent_type']}</span> | |
| <span class="msg-badge" id="badge-{safe_name}">{md['msg_count']}</span> | |
| </div> | |
| <div class="node-detail" id="detail-{safe_name}"> | |
| <div class="detail-inner"> | |
| {task_html} | |
| {msg_html} | |
| </div> | |
| </div> | |
| </div>''') | |
| # Task stream | |
| order = {"in_progress": 0, "pending": 1, "completed": 2} | |
| tasks.sort(key=lambda t: order.get(t.get("status", ""), 9)) | |
| task_stream_items = "" | |
| for t in tasks[:8]: | |
| st = t.get("status", "pending") | |
| st_icon = {"completed": "✓", "in_progress": "●"}.get(st, "○") | |
| st_color = {"completed": "#98c379", "in_progress": "#e5c07b"}.get(st, "#8b949e") | |
| owner = _esc(t.get("owner", "")) | |
| task_stream_items += f''' | |
| <div class="stream-item"> | |
| <span style="color:{st_color}">{st_icon}</span> | |
| <span class="stream-subject">{_esc(t.get("subject","")[:50])}</span> | |
| <span class="stream-owner">{owner}</span> | |
| </div>''' | |
| # Message log | |
| msg_log_items = "" | |
| for msg in reversed(recent_msgs): | |
| sender = _esc(msg.get("from", "?")) | |
| recipient = _esc(msg.get("_to", "?")) | |
| text = _esc(msg.get("text", "")[:90].replace("\n", " ")) | |
| ts = msg.get("timestamp", "") | |
| try: | |
| time_fmt = datetime.fromisoformat(ts.replace("Z", "+00:00")).strftime("%H:%M") | |
| except (ValueError, AttributeError): | |
| time_fmt = "??:??" | |
| msg_log_items += f''' | |
| <div class="log-item"> | |
| <span class="log-time">{time_fmt}</span> | |
| <span class="log-route">{sender} → {recipient}</span> | |
| <span class="log-text">{text}</span> | |
| </div>''' | |
| html = f'''<!DOCTYPE html> | |
| <html lang="de"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <title>Comm Graph — {_esc(team_name)}</title> | |
| <style> | |
| * {{ box-sizing: border-box; margin: 0; padding: 0; }} | |
| :root {{ | |
| --bg: #080c10; | |
| --surface: #0d1117; | |
| --surface-2: #161b22; | |
| --surface-3: #1c2333; | |
| --border: rgba(255,255,255,0.06); | |
| --border-bright: rgba(255,255,255,0.14); | |
| --text: #e6edf3; | |
| --text-dim: #8b949e; | |
| --text-muted: #484f58; | |
| --mono: 'Space Mono', 'SF Mono', 'Fira Code', monospace; | |
| --sans: system-ui, -apple-system, sans-serif; | |
| }} | |
| body {{ font-family: var(--sans); background: var(--bg); color: var(--text); overflow-x: hidden; }} | |
| .header {{ text-align: center; padding: 14px 0 6px; }} | |
| .header h2 {{ font-size: 15px; font-weight: 600; letter-spacing: 0.5px; }} | |
| .header .sub {{ font-size: 11px; color: var(--text-dim); margin-top: 2px; }} | |
| /* ---- GRAPH AREA with dot grid ---- */ | |
| .graph-area {{ | |
| position: relative; width: 800px; height: 540px; margin: 0 auto; | |
| background-image: radial-gradient(circle, rgba(255,255,255,0.025) 1px, transparent 1px); | |
| background-size: 24px 24px; | |
| border-top: 1px solid var(--border); | |
| border-bottom: 1px solid var(--border); | |
| }} | |
| svg.connections {{ | |
| position: absolute; top: 0; left: 0; width: 800px; height: 540px; | |
| pointer-events: none; z-index: 1; | |
| }} | |
| /* ---- CONNECTIONS ---- */ | |
| .conn, .conn-active {{ | |
| transition: stroke-opacity 0.3s, filter 0.3s; | |
| }} | |
| .conn-active {{ | |
| stroke-opacity: 0.5; | |
| stroke-dasharray: 6 4; | |
| animation: dash 1.5s linear infinite; | |
| }} | |
| .conn-highlight {{ | |
| stroke-opacity: 0.85 !important; | |
| stroke-width: 3 !important; | |
| filter: url(#glow); | |
| }} | |
| @keyframes dash {{ | |
| to {{ stroke-dashoffset: -20; }} | |
| }} | |
| /* ---- NODES ---- */ | |
| .node {{ | |
| position: absolute; width: 140px; padding: 10px 12px; | |
| background: var(--surface); border: 1px solid var(--border); | |
| border-radius: 10px; cursor: pointer; z-index: 2; | |
| border-left: 3px solid var(--node-color); | |
| transition: box-shadow 0.25s ease, transform 0.2s ease, border-color 0.3s; | |
| }} | |
| .node:hover {{ | |
| transform: translateY(-4px) scale(1.03); | |
| border-color: var(--node-color); | |
| box-shadow: | |
| 0 8px 25px color-mix(in srgb, var(--node-color) 20%, transparent), | |
| 0 0 0 1px color-mix(in srgb, var(--node-color) 15%, transparent); | |
| }} | |
| .node-header {{ display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }} | |
| .node-name {{ | |
| font-weight: 600; font-size: 12px; flex: 1; | |
| white-space: nowrap; overflow: hidden; text-overflow: ellipsis; | |
| }} | |
| .model-badge {{ | |
| font-size: 9px; padding: 1px 5px; border-radius: 4px; | |
| font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; | |
| }} | |
| .status-dot {{ | |
| width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; | |
| transition: background-color 0.4s, box-shadow 0.4s; | |
| }} | |
| .status-working {{ | |
| background: #98c379; | |
| box-shadow: 0 0 8px #98c37988; | |
| animation: pulse 2s ease-in-out infinite; | |
| }} | |
| .status-idle {{ background: #484f58; }} | |
| @keyframes pulse {{ 0%,100% {{ opacity: 1; }} 50% {{ opacity: 0.4; }} }} | |
| .node-meta {{ display: flex; justify-content: space-between; align-items: center; }} | |
| .agent-type {{ font-size: 10px; color: var(--text-dim); }} | |
| .msg-badge {{ | |
| font-size: 10px; background: var(--surface-2); padding: 1px 6px; | |
| border-radius: 8px; color: var(--text-dim); min-width: 18px; text-align: center; | |
| }} | |
| /* ---- NODE DETAIL (expandable) ---- */ | |
| .node-detail {{ | |
| display: grid; grid-template-rows: 0fr; | |
| transition: grid-template-rows 0.3s ease-out, padding 0.3s; | |
| overflow: hidden; margin-top: 0; padding-top: 0; | |
| border-top: 1px solid transparent; | |
| }} | |
| .node-detail > .detail-inner {{ min-height: 0; }} | |
| .node-detail.open {{ | |
| grid-template-rows: 1fr; | |
| margin-top: 8px; padding-top: 8px; | |
| border-top-color: var(--border); | |
| }} | |
| .node-task {{ font-size: 11px; margin-bottom: 6px; line-height: 1.4; }} | |
| .task-status {{ font-size: 9px; font-weight: 700; margin-right: 4px; }} | |
| .task-desc {{ color: var(--text-dim); font-size: 10px; margin-top: 2px; }} | |
| .node-messages {{ font-size: 10px; }} | |
| .detail-msg {{ margin-bottom: 4px; color: var(--text-dim); line-height: 1.3; }} | |
| .detail-msg b {{ color: var(--text); font-weight: 500; }} | |
| /* ---- BOTTOM PANELS ---- */ | |
| .bottom {{ | |
| display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; | |
| padding: 14px 20px; max-width: 800px; margin: 0 auto; | |
| }} | |
| .section-title {{ | |
| font-size: 10px; font-weight: 600; text-transform: uppercase; | |
| letter-spacing: 1.2px; color: var(--text-muted); margin-bottom: 8px; | |
| }} | |
| .stream-item {{ | |
| display: flex; gap: 8px; align-items: baseline; font-size: 12px; | |
| padding: 4px 0; border-bottom: 1px solid var(--border); | |
| }} | |
| .stream-subject {{ flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }} | |
| .stream-owner {{ color: var(--text-dim); font-size: 11px; font-family: var(--mono); }} | |
| .log-item {{ | |
| display: flex; gap: 8px; font-size: 11px; padding: 3px 0; | |
| border-bottom: 1px solid var(--border); | |
| }} | |
| .log-time {{ color: var(--text-muted); flex-shrink: 0; font-family: var(--mono); font-size: 10px; }} | |
| .log-route {{ | |
| color: var(--text-dim); flex-shrink: 0; width: 140px; | |
| overflow: hidden; text-overflow: ellipsis; white-space: nowrap; | |
| font-family: var(--mono); font-size: 10px; | |
| }} | |
| .log-text {{ flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }} | |
| /* ---- EVENT TICKER ---- */ | |
| .event-item {{ | |
| display: flex; gap: 6px; align-items: baseline; font-size: 11px; | |
| padding: 3px 0; border-bottom: 1px solid var(--border); | |
| font-family: var(--mono); | |
| animation: event-flash 0.6s ease-out; | |
| }} | |
| .event-time {{ color: var(--text-muted); flex-shrink: 0; font-size: 10px; }} | |
| .event-type {{ flex-shrink: 0; font-size: 9px; font-weight: 600; padding: 1px 4px; border-radius: 3px; }} | |
| .event-type-PostToolUse {{ background: #e5c07b20; color: #e5c07b; }} | |
| .event-type-SubagentStart {{ background: #98c37920; color: #98c379; }} | |
| .event-type-SubagentStop {{ background: #e06c7520; color: #e06c75; }} | |
| .event-type-UserPromptSubmit {{ background: #61afef20; color: #61afef; }} | |
| .event-detail {{ flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-dim); }} | |
| @keyframes event-flash {{ | |
| 0% {{ background: rgba(255,255,255,0.06); }} | |
| 100% {{ background: transparent; }} | |
| }} | |
| .activity-flash {{ | |
| animation: glow-flash 0.8s ease-out; | |
| }} | |
| @keyframes glow-flash {{ | |
| 0% {{ box-shadow: 0 0 20px var(--node-color); }} | |
| 100% {{ box-shadow: none; }} | |
| }} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="header"> | |
| <h2>{_esc(team_name)}</h2> | |
| <div class="sub">{len(members)} Agents · {len(tasks)} Tasks · {sum(1 for md in member_data if md["status"]=="working")} active</div> | |
| </div> | |
| <div class="graph-area" id="graph"> | |
| <svg class="connections" id="svg-conn"> | |
| <defs> | |
| <filter id="glow"> | |
| <feGaussianBlur stdDeviation="4" result="blur"/> | |
| <feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge> | |
| </filter> | |
| <filter id="particle-glow"> | |
| <feGaussianBlur stdDeviation="2.5" result="blur"/> | |
| <feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge> | |
| </filter> | |
| </defs> | |
| {"".join(svg_paths)} | |
| </svg> | |
| {"".join(node_html_parts)} | |
| </div> | |
| <div class="bottom"> | |
| <div> | |
| <div class="section-title">Tasks</div> | |
| <div id="task-stream">{task_stream_items}</div> | |
| </div> | |
| <div> | |
| <div class="section-title">Messages</div> | |
| <div id="msg-log">{msg_log_items}</div> | |
| </div> | |
| <div> | |
| <div class="section-title">Live Events</div> | |
| <div id="event-ticker"></div> | |
| </div> | |
| </div> | |
| <script> | |
| function toggleDetail(name) {{ | |
| var el = document.getElementById('detail-' + name); | |
| if (el) el.classList.toggle('open'); | |
| }} | |
| // Connection highlight on node hover — also highlights ALL paths for team-lead (W1 fix) | |
| function highlightConn(name, on) {{ | |
| var path = document.getElementById('path-' + name); | |
| if (path) {{ | |
| if (on) path.classList.add('conn-highlight'); | |
| else path.classList.remove('conn-highlight'); | |
| }} else {{ | |
| // Team-lead has no single path — highlight all connections | |
| document.querySelectorAll('[id^="path-"]').forEach(function(p) {{ | |
| if (on) p.classList.add('conn-highlight'); | |
| else p.classList.remove('conn-highlight'); | |
| }}); | |
| }} | |
| }} | |
| // Escape HTML to prevent XSS in dynamic updates (C1 fix) | |
| function esc(s) {{ var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }} | |
| // Bezier point interpolation | |
| function bezierPoint(t, x0, y0, cpx, cpy, x1, y1) {{ | |
| var u = 1 - t; | |
| return {{ | |
| x: u*u*x0 + 2*u*t*cpx + t*t*x1, | |
| y: u*u*y0 + 2*u*t*cpy + t*t*y1 | |
| }}; | |
| }} | |
| // Parse path "M x0 y0 Q cpx cpy x1 y1" — with null guard (C2 fix) | |
| function parsePath(pathEl) {{ | |
| var d = pathEl.getAttribute('d'); | |
| if (!d) return null; | |
| var nums = d.match(/-?[\\d.]+/g); | |
| if (!nums || nums.length < 6) return null; | |
| nums = nums.map(Number); | |
| return {{ x0: nums[0], y0: nums[1], cpx: nums[2], cpy: nums[3], x1: nums[4], y1: nums[5] }}; | |
| }} | |
| // Particle with trail effect — cleanup on abort (W4 fix) | |
| function animateParticle(memberName, reverse) {{ | |
| var pathEl = document.getElementById('path-' + memberName); | |
| if (!pathEl) return; | |
| var pp = parsePath(pathEl); | |
| if (!pp) return; | |
| var color = pathEl.getAttribute('stroke'); | |
| var svg = document.getElementById('svg-conn'); | |
| if (!svg) return; | |
| var circles = []; | |
| var sizes = [5, 3.5, 2.5, 1.5]; | |
| var opacities = [0.95, 0.6, 0.35, 0.15]; | |
| for (var i = 0; i < 4; i++) {{ | |
| var c = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); | |
| c.setAttribute('r', sizes[i]); | |
| c.setAttribute('fill', color); | |
| c.setAttribute('opacity', '0'); | |
| if (i === 0) c.setAttribute('filter', 'url(#particle-glow)'); | |
| svg.appendChild(c); | |
| circles.push(c); | |
| }} | |
| var start = null, dur = 1400; | |
| var trailOffsets = [0, 0.04, 0.09, 0.15]; | |
| var cancelled = false; | |
| function cleanup() {{ circles.forEach(function(c) {{ if (c.parentNode) c.remove(); }}); }} | |
| function step(ts) {{ | |
| if (cancelled) {{ cleanup(); return; }} | |
| if (!start) start = ts; | |
| var progress = Math.min((ts - start) / dur, 1); | |
| for (var i = 0; i < circles.length; i++) {{ | |
| var t = progress - trailOffsets[i]; | |
| if (t < 0 || t > 1) {{ | |
| circles[i].setAttribute('opacity', '0'); | |
| continue; | |
| }} | |
| var actualT = reverse ? 1 - t : t; | |
| var pt = bezierPoint(actualT, pp.x0, pp.y0, pp.cpx, pp.cpy, pp.x1, pp.y1); | |
| circles[i].setAttribute('cx', pt.x); | |
| circles[i].setAttribute('cy', pt.y); | |
| var fadeOut = 1 - progress * 0.5; | |
| circles[i].setAttribute('opacity', opacities[i] * fadeOut); | |
| }} | |
| if (progress < 1) requestAnimationFrame(step); | |
| else cleanup(); | |
| }} | |
| requestAnimationFrame(step); | |
| // Cancel on page unload to prevent orphaned circles (W4) | |
| window.addEventListener('beforeunload', function() {{ cancelled = true; cleanup(); }}, {{ once: true }}); | |
| }} | |
| // Initial demo particles | |
| setTimeout(function() {{ | |
| var names = {json.dumps([md["name"] for md in member_data if not md["is_lead"]])}; | |
| names.forEach(function(n, i) {{ | |
| setTimeout(function() {{ animateParticle(n, Math.random() > 0.5); }}, i * 600); | |
| }}); | |
| }}, 800); | |
| // Track message counts for particle triggers | |
| var lastMsgCounts = {json.dumps({md["name"]: md["msg_count"] for md in member_data})}; | |
| function updateGraph(data) {{ | |
| if (!data || !data.members) return; | |
| data.members.forEach(function(m) {{ | |
| var dot = document.getElementById('dot-' + m.name); | |
| if (dot) {{ | |
| dot.className = 'status-dot ' + (m.status === 'working' ? 'status-working' : 'status-idle'); | |
| }} | |
| var badge = document.getElementById('badge-' + m.name); | |
| if (badge) badge.textContent = m.msg_count; | |
| // Fire particles for new messages | |
| var prev = lastMsgCounts[m.name] || 0; | |
| if (m.msg_count > prev) {{ | |
| var count = Math.min(m.msg_count - prev, 3); | |
| for (var i = 0; i < count; i++) {{ | |
| (function(idx) {{ | |
| setTimeout(function() {{ animateParticle(m.name, true); }}, idx * 500); | |
| }})(i); | |
| }} | |
| }} | |
| lastMsgCounts[m.name] = m.msg_count; | |
| }}); | |
| if (data.tasks) {{ | |
| var html = ''; | |
| var order = {{"in_progress":0,"pending":1,"completed":2}}; | |
| data.tasks.sort(function(a,b) {{ return (order[a.status]||9) - (order[b.status]||9); }}); | |
| data.tasks.slice(0,8).forEach(function(t) {{ | |
| var subj = (t.subject || '').substring(0,50); | |
| var icon = t.status==='completed'?'✓':t.status==='in_progress'?'●':'○'; | |
| var col = t.status==='completed'?'#98c379':t.status==='in_progress'?'#e5c07b':'#8b949e'; | |
| html += '<div class="stream-item"><span style="color:'+col+'">'+icon+'</span>' | |
| + '<span class="stream-subject">'+esc(subj)+'</span>' | |
| + '<span class="stream-owner">'+esc(t.owner||'')+'</span></div>'; | |
| }}); | |
| document.getElementById('task-stream').innerHTML = html; | |
| }} | |
| if (data.new_messages && data.new_messages.length) {{ | |
| var log = document.getElementById('msg-log'); | |
| data.new_messages.forEach(function(msg) {{ | |
| var div = document.createElement('div'); | |
| div.className = 'log-item'; | |
| div.innerHTML = '<span class="log-time">'+esc(msg.time||'')+'</span>' | |
| + '<span class="log-route">'+esc(msg.sender||'')+' → '+esc(msg.recipient||'')+'</span>' | |
| + '<span class="log-text">'+esc((msg.text||'').substring(0,90))+'</span>'; | |
| log.prepend(div); | |
| }}); | |
| while (log.children.length > 10) log.removeChild(log.lastChild); | |
| }} | |
| }} | |
| // --- LIVE EVENTS --- | |
| var seenEventCount = 0; | |
| function updateEvents(events) {{ | |
| if (!events || !events.length) return; | |
| var ticker = document.getElementById('event-ticker'); | |
| if (!ticker) return; | |
| // Only show new events (compare count) | |
| var newEvents = events.slice(seenEventCount); | |
| seenEventCount = events.length; | |
| newEvents.forEach(function(ev) {{ | |
| var div = document.createElement('div'); | |
| div.className = 'event-item'; | |
| var typeClass = 'event-type-' + (ev.type || '').replace(/[^a-zA-Z]/g, ''); | |
| var detail = ev.tool || ev.agent || ev.session || ''; | |
| div.innerHTML = '<span class="event-time">' + esc(ev.time || '') + '</span>' | |
| + '<span class="event-type ' + typeClass + '">' + esc(ev.type || '') + '</span>' | |
| + '<span class="event-detail">' + esc(detail) + '</span>'; | |
| ticker.prepend(div); | |
| // Trigger particle burst for PostToolUse | |
| if (ev.type === 'PostToolUse') {{ | |
| var names = {json.dumps([md["name"] for md in member_data if not md["is_lead"]])}; | |
| var working = names.filter(function(n) {{ | |
| var dot = document.getElementById('dot-' + n); | |
| return dot && dot.classList.contains('status-working'); | |
| }}); | |
| if (working.length > 0) {{ | |
| var target = working[Math.floor(Math.random() * working.length)]; | |
| animateParticle(target, Math.random() > 0.5); | |
| }} | |
| }} | |
| // Flash node on SubagentStart | |
| if (ev.type === 'SubagentStart' && ev.agent) {{ | |
| var node = document.getElementById('node-' + ev.agent); | |
| if (node) {{ | |
| node.classList.add('activity-flash'); | |
| setTimeout(function() {{ node.classList.remove('activity-flash'); }}, 800); | |
| }} | |
| var dot = document.getElementById('dot-' + ev.agent); | |
| if (dot) {{ | |
| dot.className = 'status-dot status-working'; | |
| }} | |
| }} | |
| // Dim node on SubagentStop | |
| if (ev.type === 'SubagentStop' && ev.agent) {{ | |
| var dot = document.getElementById('dot-' + ev.agent); | |
| if (dot) {{ | |
| dot.className = 'status-dot status-idle'; | |
| }} | |
| }} | |
| }}); | |
| // Keep ticker to max 20 items | |
| while (ticker.children.length > 20) ticker.removeChild(ticker.lastChild); | |
| }} | |
| </script> | |
| </body> | |
| </html>''' | |
| return html | |