agentic-dev-loop 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1148 @@
1
+ """Live graph visualization: StreamPart translation, a thread-safe event
2
+ bus, a config-driven topology renderer, and a stdlib-only HTTP/SSE server
3
+ serving an embedded Mermaid page.
4
+
5
+ The graph itself is untouched — every live update is derived from LangGraph's
6
+ ``tasks``/``updates`` stream modes. The viz surface is served by default
7
+ (suppressed with ``--no-viz``); the
8
+ server is stdlib-only and the page loads Mermaid.js v11 from a CDN. The
9
+ topology is rendered by LangChain's own ``graph_mermaid`` renderer (a core
10
+ dependency already pulled in by LangGraph).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import errno
16
+ import json
17
+ import os
18
+ import queue
19
+ import sys
20
+ import threading
21
+ import time
22
+ from collections import deque
23
+ from collections.abc import Iterable
24
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
25
+
26
+ from langchain_core.runnables.graph import Edge
27
+ from langchain_core.runnables.graph_mermaid import draw_mermaid
28
+
29
+ from .config import PipelineConfig, Route, route_target
30
+
31
+ STATE_WHITELIST = ("attempt", "step_verdicts", "outcome", "failed")
32
+
33
+
34
+ def topology_mermaid(cfg: PipelineConfig) -> tuple[str, list[str]]:
35
+ """Build a single vertical flowchart for the configured steps.
36
+
37
+ Returns ``(mermaid_source, node_ids)``. The happy path is one solid
38
+ ``-->`` chain following each step's ``on_pass``; retries render as dotted
39
+ ``-. label .->`` loop-backs, and a non-retry ``on_fail`` as an unlabeled
40
+ dotted edge. LangChain's renderer is reused with ``nodes={}`` so nodes
41
+ auto-render as plain rectangles (no stadium declarations, no terminals).
42
+ """
43
+ chain: list[Edge] = []
44
+ back_edges: list[Edge] = []
45
+ referenced: set[str] = set()
46
+ for step in cfg.steps:
47
+ target = route_target(step.on_pass)
48
+ if target is not None:
49
+ chain.append(Edge(step.name, target))
50
+ referenced.add(target)
51
+ if isinstance(step.on_fail, Route) and step.on_fail.retry:
52
+ if step.verdicts is not None:
53
+ label = step.verdicts.fail
54
+ elif step.command is not None:
55
+ label = "fail"
56
+ else:
57
+ label = "retry"
58
+ back_edges.append(Edge(step.name, step.on_fail.goto, data=label,
59
+ conditional=True))
60
+ referenced.add(step.on_fail.goto)
61
+ else:
62
+ target = route_target(step.on_fail)
63
+ if target is not None:
64
+ back_edges.append(Edge(step.name, target, conditional=True))
65
+ referenced.add(target)
66
+ node_ids = [step.name for step in cfg.steps]
67
+ for terminal in ("success", "failure"):
68
+ if terminal in referenced:
69
+ node_ids.append(terminal)
70
+ source = draw_mermaid(nodes={}, edges=chain + back_edges, first_node=None,
71
+ last_node=None, with_styles=False)
72
+ return source, node_ids
73
+
74
+
75
+ def translate(part: dict) -> list[dict]:
76
+ """Translate one v2 StreamPart into zero or more viz event dicts.
77
+
78
+ ``tasks`` parts pair up by id: a part whose data carries ``triggers`` is a
79
+ node start; one carrying ``error`` is the matching finish (``error`` is
80
+ ``None`` on success). ``updates`` parts carry one LoopState delta per node
81
+ and collapse to a single ``state`` event holding only whitelisted keys —
82
+ ``docs``/``context``/``notes`` are large or internal and never shipped.
83
+ """
84
+ ptype = part.get("type")
85
+ data = part.get("data")
86
+ if not isinstance(data, dict):
87
+ return []
88
+ if ptype == "tasks":
89
+ if "triggers" in data and "name" in data:
90
+ return [{"type": "node_started", "node": data["name"]}]
91
+ if "error" in data and "name" in data:
92
+ return [{"type": "node_finished", "node": data["name"],
93
+ "ok": data["error"] is None}]
94
+ return []
95
+ if ptype == "updates":
96
+ state: dict = {}
97
+ for delta in data.values():
98
+ if not isinstance(delta, dict):
99
+ continue
100
+ for key in STATE_WHITELIST:
101
+ if key in delta:
102
+ state[key] = delta[key]
103
+ return [{"type": "state", **state}]
104
+ return []
105
+
106
+
107
+ class VizBus:
108
+ """Thread-safe pub/sub with bounded backlogs.
109
+
110
+ Nodes publish from the pipeline thread; each SSE connection consumes from
111
+ its own thread. New subscribers first receive a replay of retained
112
+ (lifecycle) and console events merged in publish order, so a browser tab
113
+ opened mid-run catches up without console volume evicting lifecycle
114
+ events. Heartbeats are delivered live but never replayed.
115
+ """
116
+
117
+ def __init__(self, maxlen: int = 500, console_maxlen: int = 500) -> None:
118
+ self._subscribers: set[queue.Queue] = set()
119
+ self._lock = threading.Lock()
120
+ self._backlog: deque = deque(maxlen=maxlen)
121
+ self._console_backlog: deque = deque(maxlen=console_maxlen)
122
+ self._seq = 0
123
+
124
+ def publish(self, event: dict) -> None:
125
+ with self._lock:
126
+ self._seq += 1
127
+ seq = self._seq
128
+ etype = event.get("type")
129
+ if etype == "console":
130
+ self._console_backlog.append((seq, event))
131
+ elif etype != "heartbeat":
132
+ self._backlog.append((seq, event))
133
+ subscribers = list(self._subscribers)
134
+ for sub in subscribers:
135
+ sub.put(event)
136
+
137
+ def subscribe(self) -> queue.Queue:
138
+ sub: queue.Queue = queue.Queue()
139
+ with self._lock:
140
+ merged = sorted(
141
+ list(self._backlog) + list(self._console_backlog),
142
+ key=lambda item: item[0])
143
+ for _seq, event in merged:
144
+ sub.put(event)
145
+ self._subscribers.add(sub)
146
+ return sub
147
+
148
+ def unsubscribe(self, sub: queue.Queue) -> None:
149
+ with self._lock:
150
+ self._subscribers.discard(sub)
151
+
152
+
153
+ MAX_LINE = 8000 # cap per captured console line — SDK state dumps are huge
154
+
155
+
156
+ class _Tee:
157
+ """Write-through proxy for one stream. Forwards every write to ``target``
158
+ unchanged, buffers to line boundaries, and publishes each complete line
159
+ (plus any trailing partial line on detach) as a console event attributed
160
+ to the node NodeWatch currently reports. ANSI escapes pass through
161
+ verbatim — colour parsing is the frontend's job."""
162
+
163
+ def __init__(self, capture: "ConsoleCapture", stream: str, target) -> None:
164
+ self._capture = capture
165
+ self._stream = stream
166
+ self._target = target
167
+ self._buffer = ""
168
+ self._lock = threading.Lock()
169
+
170
+ def write(self, s) -> int:
171
+ if isinstance(s, bytes):
172
+ s = s.decode("utf-8", "replace")
173
+ with self._lock:
174
+ self._target.write(s)
175
+ self._buffer += s
176
+ self._flush_lines()
177
+ return len(s)
178
+
179
+ def _flush_lines(self) -> None:
180
+ while "\n" in self._buffer:
181
+ line, self._buffer = self._buffer.split("\n", 1)
182
+ self._capture._publish(self._stream, line.rstrip("\r"))
183
+
184
+ def _flush_partial(self) -> None:
185
+ with self._lock:
186
+ if self._buffer:
187
+ self._capture._publish(self._stream, self._buffer.rstrip("\r"))
188
+ self._buffer = ""
189
+
190
+ def flush(self) -> None:
191
+ self._target.flush()
192
+
193
+ def isatty(self) -> bool:
194
+ return self._target.isatty()
195
+
196
+ def __getattr__(self, name):
197
+ return getattr(self._target, name)
198
+
199
+
200
+ class ConsoleCapture:
201
+ """Tees sys.stdout/sys.stderr into the viz bus, line by line, attributed
202
+ to the node NodeWatch currently reports (None when idle).
203
+
204
+ While attached, ``COLUMNS`` is forced to ``MAX_LINE`` so that rich-based
205
+ writers (e.g. the SDK's conversation visualizer) do not hard-wrap output
206
+ at a terminal-width fallback: under the capture, stdout is a pipe, so
207
+ rich would otherwise bake 80-column newlines into the console stream that
208
+ the browser's ``pre-wrap`` cannot rejoin. The previous value is restored
209
+ on detach.
210
+ """
211
+
212
+ def __init__(self, bus: VizBus, watch: NodeWatch) -> None:
213
+ self._bus = bus
214
+ self._watch = watch
215
+ self._tees: dict[str, _Tee] | None = None
216
+ self._old_columns: str | None = None
217
+
218
+ def attach(self) -> None:
219
+ if self._tees is not None:
220
+ raise RuntimeError("ConsoleCapture is already attached")
221
+ self._old_columns = os.environ.get("COLUMNS")
222
+ os.environ["COLUMNS"] = str(MAX_LINE)
223
+ self._tees = {
224
+ "stdout": _Tee(self, "stdout", sys.stdout),
225
+ "stderr": _Tee(self, "stderr", sys.stderr),
226
+ }
227
+ sys.stdout = self._tees["stdout"]
228
+ sys.stderr = self._tees["stderr"]
229
+
230
+ def detach(self) -> None:
231
+ if self._tees is None:
232
+ return
233
+ for stream, tee in self._tees.items():
234
+ tee._flush_partial()
235
+ tee.flush()
236
+ setattr(sys, stream, tee._target)
237
+ self._tees = None
238
+ if self._old_columns is None:
239
+ os.environ.pop("COLUMNS", None)
240
+ else:
241
+ os.environ["COLUMNS"] = self._old_columns
242
+
243
+ def _publish(self, stream: str, line: str) -> None:
244
+ if len(line) > MAX_LINE:
245
+ line = line[:MAX_LINE] + "…"
246
+ self._bus.publish({
247
+ "type": "console",
248
+ "node": self._watch.current(),
249
+ "stream": stream,
250
+ "text": line,
251
+ })
252
+
253
+
254
+ class NodeWatch:
255
+ """Current running node + start time.
256
+
257
+ Written by the stream-consumer thread as it publishes ``node_started`` /
258
+ ``node_finished`` events; read by the heartbeat thread. ``start`` and
259
+ ``finish`` are lock-protected; ``finish`` clears only its own node so a
260
+ stale finish can never clobber a newer running node.
261
+ """
262
+
263
+ def __init__(self) -> None:
264
+ self._lock = threading.Lock()
265
+ self._node: str | None = None
266
+ self._started: float | None = None
267
+
268
+ def start(self, node: str) -> None:
269
+ with self._lock:
270
+ self._node = node
271
+ self._started = time.monotonic()
272
+
273
+ def finish(self, node: str) -> None:
274
+ with self._lock:
275
+ if self._node == node:
276
+ self._node = None
277
+ self._started = None
278
+
279
+ def current(self) -> str | None:
280
+ with self._lock:
281
+ return self._node
282
+
283
+ def elapsed_seconds(self) -> int | None:
284
+ with self._lock:
285
+ if self._started is None:
286
+ return None
287
+ return int(time.monotonic() - self._started)
288
+
289
+
290
+ class HeartbeatThread(threading.Thread):
291
+ """Daemon thread publishing a liveness heartbeat while a node runs."""
292
+
293
+ def __init__(self, watch: NodeWatch, bus: VizBus,
294
+ interval: float = 2.0) -> None:
295
+ super().__init__(daemon=True)
296
+ self._watch = watch
297
+ self._bus = bus
298
+ self._interval = interval
299
+ self._stop_event = threading.Event()
300
+
301
+ def stop(self) -> None:
302
+ self._stop_event.set()
303
+
304
+ def run(self) -> None:
305
+ while not self._stop_event.is_set():
306
+ node = self._watch.current()
307
+ if node is not None:
308
+ self._bus.publish({
309
+ "type": "heartbeat",
310
+ "node": node,
311
+ "elapsed_seconds": self._watch.elapsed_seconds(),
312
+ })
313
+ self._stop_event.wait(self._interval)
314
+
315
+
316
+ def _handler_class(bus: VizBus, topology_mermaid: str,
317
+ subject: str, nodes: Iterable[str]) -> type[BaseHTTPRequestHandler]:
318
+ class Handler(BaseHTTPRequestHandler):
319
+ protocol_version = "HTTP/1.1"
320
+ viz_bus = bus
321
+ viz_topology = topology_mermaid
322
+ viz_subject = subject
323
+ viz_nodes = list(nodes)
324
+
325
+ def log_message(self, *_args) -> None: # keep test output quiet
326
+ pass
327
+
328
+ def handle_one_request(self) -> None:
329
+ try:
330
+ super().handle_one_request()
331
+ except (ConnectionResetError, BrokenPipeError):
332
+ return
333
+
334
+ def do_GET(self) -> None:
335
+ if self.path == "/":
336
+ self._send(200, "text/html; charset=utf-8", PAGE)
337
+ elif self.path == "/topology":
338
+ body = json.dumps({"mermaid": self.viz_topology,
339
+ "subject": self.viz_subject,
340
+ "nodes": self.viz_nodes})
341
+ self._send(200, "application/json", body)
342
+ elif self.path == "/events":
343
+ self._events()
344
+ else:
345
+ self.send_error(404)
346
+
347
+ def _send(self, status: int, content_type: str, body: str) -> None:
348
+ data = body.encode("utf-8")
349
+ self.send_response(status)
350
+ self.send_header("Content-Type", content_type)
351
+ self.send_header("Content-Length", str(len(data)))
352
+ self.end_headers()
353
+ self.wfile.write(data)
354
+
355
+ def _events(self) -> None:
356
+ self.send_response(200)
357
+ self.send_header("Content-Type", "text/event-stream")
358
+ self.send_header("Cache-Control", "no-cache")
359
+ self.send_header("Connection", "keep-alive")
360
+ self.end_headers()
361
+ sub = self.viz_bus.subscribe()
362
+ try:
363
+ while True:
364
+ try:
365
+ event = sub.get(timeout=15)
366
+ except queue.Empty:
367
+ self.wfile.write(b":hb\n\n")
368
+ self.wfile.flush()
369
+ continue
370
+ frame = f"data: {json.dumps(event)}\n\n".encode("utf-8")
371
+ self.wfile.write(frame)
372
+ self.wfile.flush()
373
+ except (BrokenPipeError, ConnectionResetError, OSError):
374
+ pass
375
+ finally:
376
+ self.viz_bus.unsubscribe(sub)
377
+
378
+ return Handler
379
+
380
+
381
+ def serve_viz(bus: VizBus, topology_mermaid: str, subject: str,
382
+ port: int, nodes: Iterable[str] = ()) -> tuple[ThreadingHTTPServer, threading.Thread]:
383
+ """Bind a loopback HTTP server on ``port`` and serve it on a daemon
384
+ thread. Returns ``(httpd, thread)`` — the caller reads the real bound
385
+ port from ``httpd.server_address[1]``.
386
+
387
+ ``port`` is preferred, not fixed: when it is already in use
388
+ (``errno.EADDRINUSE``) the server rebinds port 0 and lets the OS pick a
389
+ free port. Any other bind error (e.g. ``EACCES`` on a privileged port)
390
+ still fails loud, naming ``--viz-port``."""
391
+ handler = _handler_class(bus, topology_mermaid, subject, nodes)
392
+ try:
393
+ httpd = ThreadingHTTPServer(("127.0.0.1", port), handler)
394
+ except OSError as exc:
395
+ if exc.errno == errno.EADDRINUSE and port != 0:
396
+ httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
397
+ else:
398
+ raise OSError(
399
+ f"Could not bind the viz server to 127.0.0.1:{port} — the "
400
+ f"--viz-port may already be in use. ({exc})") from exc
401
+ thread = threading.Thread(target=httpd.serve_forever, daemon=True)
402
+ thread.start()
403
+ return httpd, thread
404
+
405
+
406
+ PAGE = """<!doctype html>
407
+ <html lang="en">
408
+ <head>
409
+ <meta charset="utf-8">
410
+ <meta name="viewport" content="width=device-width, initial-scale=1">
411
+ <title>agentic-dev-loop viz</title>
412
+ <style>
413
+ :root { color-scheme: light; }
414
+ * { box-sizing: border-box; }
415
+ body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
416
+ Roboto, Helvetica, Arial, sans-serif; display: flex; height: 100vh; }
417
+ #graph-panel { flex: 1 1 auto; padding: 16px; overflow: auto; display: flex;
418
+ flex-direction: column; }
419
+ #mermaid-container { flex: 1 1 auto; }
420
+ #mermaid-container svg { max-width: 100%; height: auto; }
421
+ .running > * { animation: pulse 1.2s ease-in-out infinite; }
422
+ @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
423
+ #run-banner { font-size: 18px; font-weight: 700; padding: 10px 14px;
424
+ border-radius: 6px; margin-top: 10px; display: none; }
425
+ .banner-success { background: #e8f5e9; color: #2e7d32; }
426
+ .banner-failure { background: #ffebee; color: #c62828; }
427
+ #divider { flex: 0 0 6px; cursor: col-resize; background: #e0e0e0; }
428
+ #divider:hover, #divider.dragging { background: #9e9e9e; }
429
+ #sidebar { flex: 0 0 320px; border-left: 1px solid #e0e0e0; padding: 16px;
430
+ display: flex; flex-direction: column; gap: 12px; overflow: hidden; }
431
+ #subject { margin: 0; font-size: 16px; word-break: break-word; }
432
+ #attempt { font-size: 14px; color: #555; }
433
+ #thread { font-size: 14px; color: #555; }
434
+ #thread code { font: 12px ui-monospace, SFMono-Regular, Menlo, monospace;
435
+ background: #f5f5f5; padding: 2px 6px; border-radius: 4px;
436
+ cursor: pointer; user-select: all; }
437
+ #elapsed { font-size: 14px; color: #1976d2; display: none; }
438
+ #verdicts { display: flex; flex-wrap: wrap; gap: 6px; }
439
+ .badge { font-size: 12px; padding: 3px 8px; border-radius: 10px;
440
+ background: #f0f0f0; color: #333; }
441
+ .badge-pass { background: #e8f5e9; color: #2e7d32; }
442
+ .badge-fail { background: #ffebee; color: #c62828; }
443
+ .badge-unclear { background: #fff8e1; color: #f57f17; }
444
+ #stages { flex: 1 1 auto; min-height: 0; overflow-y: auto; display: flex;
445
+ flex-direction: column; gap: 8px; }
446
+ .stage { border: 1px solid #e0e0e0; border-radius: 6px; overflow: hidden; }
447
+ .stage-header { display: block; width: 100%; text-align: left; cursor: pointer;
448
+ font: 13px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI",
449
+ Roboto, Helvetica, Arial, sans-serif; padding: 6px 10px;
450
+ border: 0; background: #f7f7f7; }
451
+ .stage-glyph { display: inline-block; width: 1.1em; }
452
+ .stage-status { margin-left: 0.4em; }
453
+ .status-pending { color: #555; }
454
+ .status-running { color: #1976d2; }
455
+ .status-pass { color: #2e7d32; }
456
+ .status-fail { color: #b26a00; }
457
+ .status-fatal { color: #c62828; }
458
+ .console-wrap { max-height: 240px; overflow-y: auto; background: #1e1e1e; }
459
+ .stage-console { margin: 0; padding: 8px 10px; background: #1e1e1e;
460
+ color: #e0e0e0; font: 12px/1.5 ui-monospace, SFMono-Regular,
461
+ Menlo, monospace; white-space: pre-wrap; word-break: break-all; }
462
+ .stage-console .cline { display: block; min-height: 1em; }
463
+ .stage-console .omitted { color: #888; font-style: italic; }
464
+ .stage-console .stage-placeholder { color: #888; font-style: italic; }
465
+ /* float puts the button in the wrap's content box, which the scrollbar
466
+ sits outside of — the scrollbar can never cover it; sticky keeps it
467
+ pinned to the top-right corner while the console scrolls */
468
+ .copy-btn { position: sticky; top: 6px; float: right; display: flex;
469
+ align-items: center; justify-content: center; width: 26px;
470
+ height: 26px; margin: 6px 6px 0 0; padding: 0; border: 0;
471
+ border-radius: 4px; cursor: pointer; background: #333333;
472
+ color: #f2f2f2; }
473
+ .copy-btn:hover { background: #4d4d4d; color: #ffffff; }
474
+ .copy-btn:focus-visible { outline: 2px solid #61d6d6; outline-offset: 1px; }
475
+ .copy-btn svg { display: block; }
476
+ .stage.collapsed .console-wrap { display: none; }
477
+ #run-log { list-style: none; margin: 0; padding: 8px 0 0; flex: 0 0 auto;
478
+ max-height: 25%; overflow-y: auto; border-top: 1px solid #e0e0e0;
479
+ font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
480
+ #run-log li { padding: 2px 0; border-bottom: 1px solid #f3f3f3; }
481
+ @media (max-width: 768px) {
482
+ body { flex-direction: column; height: auto; }
483
+ #divider { display: none; }
484
+ #sidebar { flex: 0 0 auto; border-left: 0; border-top: 1px solid #e0e0e0; }
485
+ }
486
+ </style>
487
+ <script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"
488
+ onerror="window.__mermaidFailed=true"></script>
489
+ </head>
490
+ <body>
491
+ <div id="graph-panel">
492
+ <div id="mermaid-container"><p>Loading graph…</p></div>
493
+ <div id="run-banner"></div>
494
+ </div>
495
+ <div id="divider"></div>
496
+ <aside id="sidebar">
497
+ <h2 id="subject">agentic-dev-loop pipeline</h2>
498
+ <div id="attempt">attempt ?/?</div>
499
+ <div id="thread">thread id: <code id="thread-id" title="click to select">\u2014</code></div>
500
+ <div id="elapsed"></div>
501
+ <div id="verdicts"></div>
502
+ <div id="stages"></div>
503
+ <ul id="run-log"></ul>
504
+ </aside>
505
+ <script>
506
+ (function () {
507
+ var nodeStatus = {};
508
+ var stageWord = {};
509
+ var verdictSteps = new Set();
510
+ var nodeIds = new Set();
511
+ var currentVerdicts = {};
512
+ var verdictFail = new Set();
513
+ var verdictPass = new Set();
514
+ var fatalNodes = new Set();
515
+ var maxAttempts = null;
516
+ var attempt = null;
517
+ var mermaidSource = null;
518
+ var renderCount = 0;
519
+ var mermaidFailed = !!window.__mermaidFailed;
520
+ var pendingCompletion = null;
521
+ var topologyReady = false;
522
+ var pendingEvents = [];
523
+ var MAX_CONSOLE = 500;
524
+ var PALETTE = ["#0c0c0c", "#c50f1f", "#13a10e", "#c19c00", "#0037da",
525
+ "#881798", "#3a96dd", "#cccccc"];
526
+ var BRIGHT = ["#767676", "#e74856", "#16c60c", "#f9f1a5", "#3b78ff",
527
+ "#b4009e", "#61d6d6", "#f2f2f2"];
528
+ var COPY_ICON = '<svg xmlns="http://www.w3.org/2000/svg" width="14"'
529
+ + ' height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"'
530
+ + ' stroke-width="2" stroke-linecap="round" stroke-linejoin="round"'
531
+ + ' aria-hidden="true"><rect x="9" y="9" width="13" height="13"'
532
+ + ' rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1'
533
+ + ' 2-2h9a2 2 0 0 1 2 2v1"></path></svg>';
534
+ var CHECK_ICON = '<svg xmlns="http://www.w3.org/2000/svg" width="14"'
535
+ + ' height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"'
536
+ + ' stroke-width="2" stroke-linecap="round" stroke-linejoin="round"'
537
+ + ' aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>';
538
+
539
+ function el(id) { return document.getElementById(id); }
540
+
541
+ function logLine(text) {
542
+ var li = document.createElement("li");
543
+ li.textContent = text;
544
+ var log = el("run-log");
545
+ log.appendChild(li);
546
+ log.scrollTop = log.scrollHeight;
547
+ }
548
+
549
+ function logConsole(html) {
550
+ var li = document.createElement("li");
551
+ li.innerHTML = html;
552
+ var log = el("run-log");
553
+ log.appendChild(li);
554
+ log.scrollTop = log.scrollHeight;
555
+ }
556
+
557
+ function updateAttempt() {
558
+ el("attempt").textContent = attempt === null
559
+ ? "attempt ?/" + (maxAttempts === null ? "?" : maxAttempts)
560
+ : "attempt " + attempt + "/" + (maxAttempts === null ? "?" : maxAttempts);
561
+ }
562
+
563
+ function mmss(totalSeconds) {
564
+ var s = totalSeconds % 60;
565
+ var m = Math.floor(totalSeconds / 60);
566
+ return m + ":" + (s < 10 ? "0" : "") + s;
567
+ }
568
+
569
+ function clearElapsed() {
570
+ var line = el("elapsed");
571
+ line.textContent = "";
572
+ line.style.display = "none";
573
+ }
574
+
575
+ function statusOf(n) {
576
+ if (nodeStatus[n] === "running") { return "running"; }
577
+ if (fatalNodes.has(n)) { return "fatal"; }
578
+ if (nodeStatus[n] === "fatal") { return "fatal"; }
579
+ if (verdictFail.has(n)) { return "fail"; }
580
+ if (verdictPass.has(n)) { return "pass"; }
581
+ return nodeStatus[n] || "pending";
582
+ }
583
+
584
+ function updateBadges() {
585
+ var box = el("verdicts");
586
+ box.innerHTML = "";
587
+ Array.from(verdictSteps).forEach(function (step) {
588
+ var b = document.createElement("span");
589
+ var v = currentVerdicts[step];
590
+ b.className = "badge";
591
+ if (v === "pass") { b.className += " badge-pass"; }
592
+ else if (v === "fail") { b.className += " badge-fail"; }
593
+ else { b.className += " badge-unclear"; }
594
+ b.textContent = step + ": " + (v === null || v === undefined ? "no verdict" : v);
595
+ box.appendChild(b);
596
+ });
597
+ }
598
+
599
+ function resetVerdicts() {
600
+ verdictSteps.clear();
601
+ verdictPass.clear();
602
+ verdictFail.clear();
603
+ currentVerdicts = {};
604
+ nodeStatus = {};
605
+ stageWord = {};
606
+ pendingCompletion = null;
607
+ updateBadges();
608
+ Array.from(nodeIds).forEach(function (n) { renderStageHeader(n); });
609
+ renderGraph();
610
+ }
611
+
612
+ function renderGraph() {
613
+ var container = el("mermaid-container");
614
+ if (mermaidFailed || typeof mermaid === "undefined" || !mermaidSource) {
615
+ container.innerHTML = "<p>Graph unavailable (mermaid CDN unreachable) "
616
+ + "\u2014 the event log below is still live.</p>";
617
+ return;
618
+ }
619
+ var classDefs = [
620
+ "classDef running fill:#1976d2,stroke:#1976d2,color:#fff",
621
+ "classDef pass fill:#2e7d32,stroke:#2e7d32,color:#fff",
622
+ "classDef fail fill:#ffb300,stroke:#ffb300,color:#fff",
623
+ "classDef fatal fill:#c62828,stroke:#c62828,color:#fff",
624
+ "classDef pending fill:#f2f0ff,stroke:#555,color:#000"
625
+ ];
626
+ var classLines = Object.keys(nodeStatus).filter(function (n) {
627
+ return nodeIds.has(n);
628
+ }).map(function (n) {
629
+ return "class " + n + " " + statusOf(n) + ";";
630
+ });
631
+ var src = mermaidSource + "\\n" + classDefs.join("\\n") + "\\n" + classLines.join("\\n");
632
+ mermaid.render("graph-" + (renderCount++), src).then(function (res) {
633
+ container.innerHTML = res.svg;
634
+ if (res.bindFunctions) { res.bindFunctions(container); }
635
+ }).catch(function (err) {
636
+ container.innerHTML = "<p>Graph render failed.</p>";
637
+ logLine("graph render failed: " + err);
638
+ });
639
+ }
640
+
641
+ function addPlaceholder(pre) {
642
+ var ph = document.createElement("div");
643
+ ph.className = "stage-placeholder";
644
+ ph.textContent = "No output";
645
+ pre.appendChild(ph);
646
+ }
647
+
648
+ function ensureSection(node) {
649
+ var existing = document.getElementById("stage-" + node);
650
+ if (existing) { return existing; }
651
+ var stages = el("stages");
652
+ var section = document.createElement("section");
653
+ section.className = "stage collapsed";
654
+ section.id = "stage-" + node;
655
+
656
+ var header = document.createElement("button");
657
+ header.className = "stage-header";
658
+ header.type = "button";
659
+ header.addEventListener("click", function () {
660
+ var willCollapse = !section.classList.contains("collapsed");
661
+ section.classList.toggle("collapsed");
662
+ renderStageHeader(node);
663
+ if (!willCollapse) {
664
+ requestAnimationFrame(function () { tailSection(section); });
665
+ }
666
+ });
667
+
668
+ var glyph = document.createElement("span");
669
+ glyph.className = "stage-glyph";
670
+ var name = document.createElement("span");
671
+ name.className = "stage-name";
672
+ var status = document.createElement("span");
673
+ status.className = "stage-status";
674
+
675
+ header.appendChild(glyph);
676
+ header.appendChild(name);
677
+ header.appendChild(status);
678
+
679
+ var console = document.createElement("pre");
680
+ console.className = "stage-console";
681
+ addPlaceholder(console);
682
+
683
+ var wrap = document.createElement("div");
684
+ wrap.className = "console-wrap";
685
+ var copyBtn = document.createElement("button");
686
+ copyBtn.className = "copy-btn";
687
+ copyBtn.type = "button";
688
+ copyBtn.setAttribute("title", "Copy output");
689
+ copyBtn.setAttribute("aria-label", "Copy " + node + " console output");
690
+ copyBtn.innerHTML = COPY_ICON;
691
+ copyBtn.addEventListener("click", function () {
692
+ copyConsole(console, copyBtn);
693
+ });
694
+ wrap.appendChild(copyBtn);
695
+ wrap.appendChild(console);
696
+
697
+ section.appendChild(header);
698
+ section.appendChild(wrap);
699
+ stages.appendChild(section);
700
+ renderStageHeader(node);
701
+ return section;
702
+ }
703
+
704
+ function renderStageHeader(node) {
705
+ var section = ensureSection(node);
706
+ var header = section.querySelector(".stage-header");
707
+ var glyph = header.querySelector(".stage-glyph");
708
+ var name = header.querySelector(".stage-name");
709
+ var status = header.querySelector(".stage-status");
710
+ var collapsed = section.classList.contains("collapsed");
711
+ glyph.textContent = collapsed ? "\u25B8" : "\u25BE";
712
+ name.textContent = node;
713
+ var word = stageWord[node] || "";
714
+ var suffix = "";
715
+ if (word === "completed") {
716
+ var v = currentVerdicts[node];
717
+ if (v === "pass") { suffix = " (verdict: PASS)"; }
718
+ else if (v === "fail") { suffix = " (verdict: FAIL)"; }
719
+ }
720
+ status.textContent = (word ? " " + word : "") + suffix;
721
+ header.className = "stage-header status-" + statusOf(node);
722
+ }
723
+
724
+ function setStageStatus(node, word) {
725
+ stageWord[node] = word;
726
+ renderStageHeader(node);
727
+ }
728
+
729
+ function flushPending() {
730
+ if (pendingCompletion === null) { return; }
731
+ var p = pendingCompletion;
732
+ pendingCompletion = null;
733
+ setStageStatus(p.node, p.word);
734
+ }
735
+
736
+ function expandStage(node) {
737
+ var section = ensureSection(node);
738
+ section.classList.remove("collapsed");
739
+ renderStageHeader(node);
740
+ section.scrollIntoView({ block: "nearest" });
741
+ requestAnimationFrame(function () { tailSection(section); });
742
+ }
743
+
744
+ function collapseStage(node) {
745
+ ensureSection(node).classList.add("collapsed");
746
+ renderStageHeader(node);
747
+ }
748
+
749
+ function collapseAll() {
750
+ Array.from(nodeIds).forEach(function (n) {
751
+ ensureSection(n).classList.add("collapsed");
752
+ renderStageHeader(n);
753
+ });
754
+ }
755
+
756
+ function clearConsoles() {
757
+ stageWord = {};
758
+ Array.from(nodeIds).forEach(function (n) {
759
+ var section = ensureSection(n);
760
+ var pre = consoleOf(section);
761
+ pre.innerHTML = "";
762
+ addPlaceholder(pre);
763
+ section.classList.add("collapsed");
764
+ renderStageHeader(n);
765
+ });
766
+ el("run-log").innerHTML = "";
767
+ }
768
+
769
+ function consoleOf(section) {
770
+ return section.querySelector(".console-wrap")
771
+ .querySelector(".stage-console");
772
+ }
773
+
774
+ function scrollPortOf(section) {
775
+ return section.querySelector(".console-wrap");
776
+ }
777
+
778
+ function copyConsole(pre, btn) {
779
+ var lines = Array.prototype.map.call(pre.querySelectorAll(".cline"),
780
+ function (c) { return c.textContent; });
781
+ if (lines.length === 0) {
782
+ btn.setAttribute("title", "Nothing to copy");
783
+ return;
784
+ }
785
+ var text = lines.join("\\n");
786
+ function done(ok) {
787
+ btn.innerHTML = ok ? CHECK_ICON : COPY_ICON;
788
+ btn.setAttribute("title", ok ? "Copied!" : "Copy failed");
789
+ if (ok) {
790
+ setTimeout(function () {
791
+ btn.innerHTML = COPY_ICON;
792
+ btn.setAttribute("title", "Copy output");
793
+ }, 1500);
794
+ }
795
+ }
796
+ if (navigator.clipboard && navigator.clipboard.writeText) {
797
+ navigator.clipboard.writeText(text).then(function () { done(true); },
798
+ function () { done(false); });
799
+ } else {
800
+ // clipboard API unavailable (e.g. plain-http on a non-localhost host)
801
+ var ta = document.createElement("textarea");
802
+ ta.value = text;
803
+ ta.style.position = "fixed";
804
+ ta.style.top = "-1000px";
805
+ document.body.appendChild(ta);
806
+ ta.select();
807
+ var ok = false;
808
+ try { ok = document.execCommand("copy"); } catch (err) { ok = false; }
809
+ document.body.removeChild(ta);
810
+ done(ok);
811
+ }
812
+ }
813
+
814
+ function nearBottom(pre) {
815
+ return pre.scrollHeight - pre.scrollTop - pre.clientHeight < 24;
816
+ }
817
+
818
+ function tailSection(section) {
819
+ if (section.classList.contains("collapsed")) { return; }
820
+ var sp = scrollPortOf(section);
821
+ sp.scrollTop = sp.scrollHeight;
822
+ }
823
+
824
+ function appendConsole(section, html) {
825
+ var pre = consoleOf(section);
826
+ var sp = scrollPortOf(section);
827
+ var stick = !section.classList.contains("collapsed") && nearBottom(sp);
828
+ var ph = pre.querySelector(".stage-placeholder");
829
+ if (ph) { pre.removeChild(ph); }
830
+ if (pre.querySelectorAll(".cline").length >= MAX_CONSOLE) {
831
+ var first = pre.querySelector(".cline");
832
+ if (first) { pre.removeChild(first); }
833
+ var omitted = pre.querySelector(".omitted");
834
+ if (!omitted) {
835
+ omitted = document.createElement("div");
836
+ omitted.className = "omitted";
837
+ pre.insertBefore(omitted, pre.firstChild);
838
+ }
839
+ var count = parseInt(omitted.getAttribute("data-n") || "0", 10) + 1;
840
+ omitted.setAttribute("data-n", String(count));
841
+ omitted.textContent = "\u2026" + count + " line"
842
+ + (count === 1 ? "" : "s") + " omitted";
843
+ }
844
+ var line = document.createElement("span");
845
+ line.className = "cline";
846
+ line.innerHTML = html;
847
+ pre.appendChild(line);
848
+ if (stick) { sp.scrollTop = sp.scrollHeight; }
849
+ }
850
+
851
+ function handleConsole(ev) {
852
+ var html = ansiToHtml(ev.text);
853
+ if (ev.node && nodeIds.has(ev.node)) {
854
+ appendConsole(ensureSection(ev.node), html);
855
+ } else {
856
+ logConsole(html);
857
+ }
858
+ }
859
+
860
+ function ansiToHtml(text) {
861
+ var out = "";
862
+ var i = 0;
863
+ var ESC = "\\x1b";
864
+ var cur = { fg: null, bg: null, bold: false, italic: false, underline: false };
865
+ var open = false;
866
+
867
+ function currentStyle() {
868
+ var parts = [];
869
+ if (cur.bold) { parts.push("font-weight:bold"); }
870
+ if (cur.italic) { parts.push("font-style:italic"); }
871
+ if (cur.underline) { parts.push("text-decoration:underline"); }
872
+ if (cur.fg !== null) { parts.push("color:" + cur.fg); }
873
+ if (cur.bg !== null) { parts.push("background-color:" + cur.bg); }
874
+ return parts.join(";");
875
+ }
876
+
877
+ function closeSpan() {
878
+ if (open) { out += "</span>"; open = false; }
879
+ }
880
+
881
+ function pushSpan() {
882
+ closeSpan();
883
+ var s = currentStyle();
884
+ if (s) { out += '<span style="' + s + '">'; open = true; }
885
+ }
886
+
887
+ var html = text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
888
+ var n = html.length;
889
+ while (i < n) {
890
+ var idx = html.indexOf(ESC, i);
891
+ if (idx === -1) {
892
+ out += html.slice(i);
893
+ break;
894
+ }
895
+ out += html.slice(i, idx);
896
+ var j = idx + 1;
897
+ if (j >= n) { i = idx + 1; continue; }
898
+ var ch = html.charAt(j);
899
+ if (ch === "[") {
900
+ var k = j + 1;
901
+ while (k < n) {
902
+ var cc = html.charCodeAt(k);
903
+ if (cc >= 0x40 && cc <= 0x7e) { break; }
904
+ k++;
905
+ }
906
+ if (k >= n) { i = n; break; }
907
+ var final = html.charAt(k);
908
+ var params = html.slice(j + 1, k);
909
+ if (final === "m") {
910
+ var parts = params.split(";");
911
+ var codes = [];
912
+ for (var p = 0; p < parts.length; p++) {
913
+ codes.push(parts[p] === "" ? 0 : parseInt(parts[p], 10));
914
+ }
915
+ var changed = false;
916
+ for (var c = 0; c < codes.length; c++) {
917
+ var code = codes[c];
918
+ if (code === 0) {
919
+ cur = { fg: null, bg: null, bold: false, italic: false, underline: false };
920
+ changed = true;
921
+ } else if (code === 1) { cur.bold = true; changed = true; }
922
+ else if (code === 22) { cur.bold = false; changed = true; }
923
+ else if (code === 3) { cur.italic = true; changed = true; }
924
+ else if (code === 23) { cur.italic = false; changed = true; }
925
+ else if (code === 4) { cur.underline = true; changed = true; }
926
+ else if (code === 24) { cur.underline = false; changed = true; }
927
+ else if (code >= 30 && code <= 37) { cur.fg = PALETTE[code - 30]; changed = true; }
928
+ else if (code >= 90 && code <= 97) { cur.fg = BRIGHT[code - 90]; changed = true; }
929
+ else if (code >= 40 && code <= 47) { cur.bg = PALETTE[code - 40]; changed = true; }
930
+ else if (code >= 100 && code <= 107) { cur.bg = BRIGHT[code - 100]; changed = true; }
931
+ else if (code === 39) { cur.fg = null; changed = true; }
932
+ else if (code === 49) { cur.bg = null; changed = true; }
933
+ else if (code === 38 || code === 48) {
934
+ // extended colour: 38;5;n (256-colour) or 38;2;r;g;b (truecolor)
935
+ if (c + 2 < codes.length && codes[c + 1] === 5) {
936
+ var val = codes[c + 2];
937
+ var col;
938
+ if (val < 8) { col = PALETTE[val]; }
939
+ else if (val < 16) { col = BRIGHT[val - 8]; }
940
+ else if (val < 232) {
941
+ var cube = val - 16;
942
+ var levels = [0, 95, 135, 175, 215, 255];
943
+ col = "rgb(" + levels[Math.floor(cube / 36)] + ","
944
+ + levels[Math.floor((cube % 36) / 6)] + ","
945
+ + levels[cube % 6] + ")";
946
+ } else {
947
+ var g = 8 + 10 * (val - 232);
948
+ col = "rgb(" + g + "," + g + "," + g + ")";
949
+ }
950
+ if (code === 38) { cur.fg = col; } else { cur.bg = col; }
951
+ c += 2;
952
+ changed = true;
953
+ } else if (c + 4 < codes.length && codes[c + 1] === 2) {
954
+ var rgb = "rgb(" + codes[c + 2] + "," + codes[c + 3] + "," + codes[c + 4] + ")";
955
+ if (code === 38) { cur.fg = rgb; } else { cur.bg = rgb; }
956
+ c += 4;
957
+ changed = true;
958
+ }
959
+ }
960
+ }
961
+ if (changed) { pushSpan(); }
962
+ }
963
+ i = k + 1;
964
+ continue;
965
+ }
966
+ if (ch === "]") {
967
+ var bel = html.indexOf(String.fromCharCode(7), j);
968
+ var st = html.indexOf(ESC + String.fromCharCode(92), j);
969
+ var end = -1;
970
+ if (bel !== -1) { end = bel + 1; }
971
+ if (st !== -1 && (end === -1 || st + 2 < end)) { end = st + 2; }
972
+ if (end === -1) { i = n; break; }
973
+ i = end;
974
+ continue;
975
+ }
976
+ i = idx + 1;
977
+ }
978
+ closeSpan();
979
+ return out;
980
+ }
981
+
982
+ function handle(ev) {
983
+ switch (ev.type) {
984
+ case "run_started":
985
+ flushPending();
986
+ clearConsoles();
987
+ resetVerdicts();
988
+ if (ev.attempt !== undefined) {
989
+ attempt = ev.attempt;
990
+ }
991
+ maxAttempts = ev.max_attempts;
992
+ el("subject").textContent = ev.subject;
993
+ if (ev.thread_id !== undefined) {
994
+ el("thread-id").textContent = ev.thread_id;
995
+ }
996
+ logLine("run started: " + ev.subject + " (thread " + ev.thread_id + ")");
997
+ updateAttempt();
998
+ break;
999
+ case "node_started":
1000
+ flushPending();
1001
+ if (!nodeIds.has(ev.node)) { break; }
1002
+ nodeStatus[ev.node] = "running";
1003
+ stageWord[ev.node] = "running";
1004
+ Array.from(nodeIds).forEach(function (n) {
1005
+ if (n === ev.node) { expandStage(n); } else { collapseStage(n); }
1006
+ });
1007
+ renderGraph();
1008
+ break;
1009
+ case "node_finished":
1010
+ clearElapsed();
1011
+ if (!nodeIds.has(ev.node)) { break; }
1012
+ if (!fatalNodes.has(ev.node)) {
1013
+ nodeStatus[ev.node] = ev.ok ? "pass" : "fail";
1014
+ }
1015
+ pendingCompletion = {
1016
+ node: ev.node,
1017
+ word: ev.ok ? "completed" : "errored"
1018
+ };
1019
+ setStageStatus(ev.node, ev.ok ? "completed" : "errored");
1020
+ collapseStage(ev.node);
1021
+ renderGraph();
1022
+ break;
1023
+ case "heartbeat":
1024
+ if (nodeStatus[ev.node] === "running") {
1025
+ el("elapsed").textContent = ev.node + " running \u2014 " + mmss(ev.elapsed_seconds) + " elapsed";
1026
+ el("elapsed").style.display = "block";
1027
+ }
1028
+ break;
1029
+ case "state":
1030
+ if (ev.attempt !== undefined) { attempt = ev.attempt; }
1031
+ if (ev.step_verdicts !== undefined) {
1032
+ Object.keys(ev.step_verdicts).forEach(function (k) {
1033
+ verdictSteps.add(k);
1034
+ var v = ev.step_verdicts[k];
1035
+ if (v === "pass") { verdictPass.add(k); verdictFail.delete(k); }
1036
+ else if (v === "fail") { verdictFail.add(k); verdictPass.delete(k); }
1037
+ else { verdictPass.delete(k); verdictFail.delete(k); }
1038
+ currentVerdicts[k] = v;
1039
+ });
1040
+ updateBadges();
1041
+ if (pendingCompletion !== null
1042
+ && (pendingCompletion.node in ev.step_verdicts)) {
1043
+ flushPending();
1044
+ }
1045
+ renderGraph();
1046
+ }
1047
+ if (ev.failed) {
1048
+ Object.keys(nodeStatus).forEach(function (n) {
1049
+ if (nodeStatus[n] === "running") { fatalNodes.add(n); }
1050
+ });
1051
+ logLine("state: failed");
1052
+ renderGraph();
1053
+ }
1054
+ updateAttempt();
1055
+ break;
1056
+ case "run_finished":
1057
+ clearElapsed();
1058
+ flushPending();
1059
+ if (!ev.success) {
1060
+ Object.keys(nodeStatus).forEach(function (n) {
1061
+ var s = statusOf(n);
1062
+ if (s === "fail" || s === "running") { nodeStatus[n] = "fatal"; }
1063
+ });
1064
+ renderGraph();
1065
+ }
1066
+ collapseAll();
1067
+ logLine("run finished: " + (ev.success ? "success" : "failure"));
1068
+ var banner = el("run-banner");
1069
+ banner.textContent = ev.success ? "\u2713 SUCCESS" : "\u2718 FAILURE";
1070
+ banner.className = ev.success ? "banner-success" : "banner-failure";
1071
+ banner.style.display = "block";
1072
+ break;
1073
+ case "console":
1074
+ handleConsole(ev);
1075
+ break;
1076
+ }
1077
+ }
1078
+
1079
+ fetch("/topology").then(function (r) { return r.json(); }).then(function (d) {
1080
+ el("subject").textContent = d.subject;
1081
+ mermaidSource = d.mermaid;
1082
+ (d.nodes || []).forEach(function (n) {
1083
+ nodeIds.add(n);
1084
+ ensureSection(n);
1085
+ });
1086
+ if (!mermaidFailed && typeof mermaid !== "undefined") {
1087
+ mermaid.initialize({ startOnLoad: false, securityLevel: "loose" });
1088
+ }
1089
+ topologyReady = true;
1090
+ pendingEvents.forEach(handle);
1091
+ pendingEvents = [];
1092
+ renderGraph();
1093
+ }).catch(function () {
1094
+ logLine("failed to load topology");
1095
+ topologyReady = true;
1096
+ pendingEvents.forEach(handle);
1097
+ pendingEvents = [];
1098
+ });
1099
+
1100
+ setTimeout(function () {
1101
+ if (!topologyReady) {
1102
+ topologyReady = true;
1103
+ logLine("topology load timed out \u2014 event log only");
1104
+ pendingEvents.forEach(handle);
1105
+ pendingEvents = [];
1106
+ }
1107
+ }, 5000);
1108
+
1109
+ var es = new EventSource("/events");
1110
+ es.onmessage = function (e) {
1111
+ var ev;
1112
+ try { ev = JSON.parse(e.data); }
1113
+ catch (err) { logLine("bad event: " + e.data); return; }
1114
+ if (!topologyReady) { pendingEvents.push(ev); return; }
1115
+ try { handle(ev); }
1116
+ catch (err) { logLine("bad event: " + e.data); }
1117
+ };
1118
+ es.onerror = function () { logLine("event stream interrupted"); };
1119
+
1120
+ // Grab-handle between the graph and the sidebar: drag to resize the
1121
+ // sidebar (and with it the console panels, which stretch with its width).
1122
+ (function initDivider() {
1123
+ var divider = el("divider");
1124
+ var sidebar = el("sidebar");
1125
+ var dragging = false;
1126
+ divider.addEventListener("mousedown", function (e) {
1127
+ dragging = true;
1128
+ divider.classList.add("dragging");
1129
+ document.body.style.userSelect = "none";
1130
+ e.preventDefault();
1131
+ });
1132
+ window.addEventListener("mousemove", function (e) {
1133
+ if (!dragging) { return; }
1134
+ var width = window.innerWidth - e.clientX - divider.offsetWidth / 2;
1135
+ width = Math.max(240, Math.min(width, window.innerWidth * 0.8));
1136
+ sidebar.style.flex = "0 0 " + width + "px";
1137
+ });
1138
+ window.addEventListener("mouseup", function () {
1139
+ dragging = false;
1140
+ divider.classList.remove("dragging");
1141
+ document.body.style.userSelect = "";
1142
+ });
1143
+ })();
1144
+ })();
1145
+ </script>
1146
+ </body>
1147
+ </html>
1148
+ """