copilot-cli-trace-deck 0.1.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,2305 @@
1
+ from __future__ import annotations
2
+
3
+ import html
4
+ import json
5
+
6
+ from ..models import SessionFlowNode, SessionLogEntry, SessionLogSection, SessionPreview, SessionSummary
7
+
8
+
9
+ PAGE_TITLE = "Copilot CLI Trace Deck"
10
+ REPOSITORY_URL = "https://github.com/lanbaoshen/copilot-cli-trace-deck"
11
+ AUTHOR_URL = "https://github.com/lanbaoshen"
12
+
13
+
14
+ def render_document(page_title: str, body: str) -> str:
15
+ return f"""<!DOCTYPE html>
16
+ <html lang="en">
17
+ <head>
18
+ <meta charset="utf-8">
19
+ <meta name="viewport" content="width=device-width, initial-scale=1">
20
+ <title>{html.escape(page_title)}</title>
21
+ <style>
22
+ :root {{
23
+ --bg: #090d14;
24
+ --panel: rgba(255, 255, 255, 0.03);
25
+ --panel-strong: rgba(255, 255, 255, 0.045);
26
+ --line: rgba(255, 255, 255, 0.09);
27
+ --text: #eef3fa;
28
+ --muted: #8f98a8;
29
+ --blue-strong: #1f66e2;
30
+ }}
31
+
32
+ * {{
33
+ box-sizing: border-box;
34
+ }}
35
+
36
+ html, body {{
37
+ margin: 0;
38
+ min-height: 100%;
39
+ }}
40
+
41
+ body {{
42
+ font-family: "Avenir Next", "Segoe UI", sans-serif;
43
+ background:
44
+ radial-gradient(circle at top, rgba(82, 125, 214, 0.07), transparent 36%),
45
+ radial-gradient(circle at 20% 15%, rgba(255, 255, 255, 0.035), transparent 18%),
46
+ linear-gradient(180deg, #0a0f17 0%, var(--bg) 28%, #080c12 100%);
47
+ color: var(--text);
48
+ overflow-x: hidden;
49
+ }}
50
+
51
+ body::before {{
52
+ content: "";
53
+ position: fixed;
54
+ inset: 0;
55
+ pointer-events: none;
56
+ opacity: 0.18;
57
+ background-image:
58
+ linear-gradient(rgba(255, 255, 255, 0.015) 1px, transparent 1px),
59
+ linear-gradient(90deg, rgba(255, 255, 255, 0.012) 1px, transparent 1px);
60
+ background-size: 100% 32px, 32px 100%;
61
+ mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.9), transparent 82%);
62
+ }}
63
+
64
+ a {{
65
+ color: inherit;
66
+ }}
67
+
68
+ .page {{
69
+ position: relative;
70
+ min-height: 100vh;
71
+ }}
72
+
73
+ .app-shell {{
74
+ display: grid;
75
+ gap: 16px;
76
+ }}
77
+
78
+ .shell {{
79
+ width: min(1180px, calc(100% - 48px));
80
+ margin: 0 auto;
81
+ }}
82
+
83
+ .site-footer {{
84
+ display: flex;
85
+ align-items: center;
86
+ justify-content: space-between;
87
+ gap: 12px;
88
+ margin-top: auto;
89
+ padding: 16px 4px 0;
90
+ border-top: 1px solid rgba(255, 255, 255, 0.09);
91
+ color: #7f8796;
92
+ font-size: 0.88rem;
93
+ line-height: 1.4;
94
+ }}
95
+
96
+ .site-footer-copy {{
97
+ min-width: 0;
98
+ }}
99
+
100
+ .site-footer a {{
101
+ color: #b9d0ff;
102
+ text-decoration: none;
103
+ }}
104
+
105
+ .site-footer a:hover {{
106
+ text-decoration: underline;
107
+ }}
108
+
109
+ .index-page {{
110
+ height: 100vh;
111
+ padding: 18px 0 20px;
112
+ overflow: hidden;
113
+ }}
114
+
115
+ .index-shell {{
116
+ height: 100%;
117
+ grid-template-rows: auto minmax(0, 1fr) auto;
118
+ min-height: 0;
119
+ }}
120
+
121
+ .index-content {{
122
+ min-height: 0;
123
+ display: flex;
124
+ flex-direction: column;
125
+ }}
126
+
127
+ .hero {{
128
+ flex: none;
129
+ text-align: center;
130
+ animation: rise 520ms ease-out both;
131
+ }}
132
+
133
+ .index-title {{
134
+ margin: 0;
135
+ font-size: clamp(2.15rem, 3.2vw, 3.2rem);
136
+ line-height: 1.02;
137
+ font-weight: 700;
138
+ letter-spacing: -0.05em;
139
+ }}
140
+
141
+ .index-subtitle {{
142
+ margin: 12px 0 0;
143
+ font-size: clamp(0.98rem, 1.35vw, 1.28rem);
144
+ line-height: 1.28;
145
+ color: var(--muted);
146
+ letter-spacing: -0.03em;
147
+ }}
148
+
149
+ .session-list {{
150
+ list-style: none;
151
+ padding: 0 8px 0 0;
152
+ margin: 38px auto 0;
153
+ width: min(940px, 100%);
154
+ flex: 1;
155
+ min-height: 0;
156
+ overflow-y: auto;
157
+ overflow-x: hidden;
158
+ scrollbar-width: thin;
159
+ scrollbar-color: rgba(143, 152, 168, 0.45) transparent;
160
+ }}
161
+
162
+ .session-list::-webkit-scrollbar {{
163
+ width: 10px;
164
+ }}
165
+
166
+ .session-list::-webkit-scrollbar-thumb {{
167
+ background: rgba(143, 152, 168, 0.35);
168
+ border: 3px solid transparent;
169
+ border-radius: 999px;
170
+ background-clip: padding-box;
171
+ }}
172
+
173
+ .session-list::-webkit-scrollbar-track {{
174
+ background: transparent;
175
+ }}
176
+
177
+ .session-link {{
178
+ display: flex;
179
+ align-items: center;
180
+ justify-content: space-between;
181
+ gap: 18px;
182
+ width: 100%;
183
+ padding: 14px 14px;
184
+ border-radius: 18px;
185
+ text-decoration: none;
186
+ transition: background-color 160ms ease, transform 160ms ease;
187
+ animation: rise 520ms ease-out both;
188
+ }}
189
+
190
+ .session-link:hover {{
191
+ background: var(--panel);
192
+ transform: translateY(-1px);
193
+ }}
194
+
195
+ .session-main {{
196
+ display: flex;
197
+ align-items: center;
198
+ gap: 14px;
199
+ min-width: 0;
200
+ }}
201
+
202
+ .session-title {{
203
+ margin: 0;
204
+ font-size: clamp(0.96rem, 1.2vw, 1.28rem);
205
+ line-height: 1.18;
206
+ font-weight: 600;
207
+ letter-spacing: -0.035em;
208
+ color: #f0f4fb;
209
+ white-space: nowrap;
210
+ overflow: hidden;
211
+ text-overflow: ellipsis;
212
+ }}
213
+
214
+ .icon {{
215
+ flex: none;
216
+ width: 24px;
217
+ height: 24px;
218
+ color: #8d97a6;
219
+ }}
220
+
221
+ .badge {{
222
+ flex: none;
223
+ min-width: 76px;
224
+ padding: 8px 14px;
225
+ border-radius: 999px;
226
+ background: linear-gradient(180deg, #3184ff 0%, var(--blue-strong) 100%);
227
+ color: #f8fbff;
228
+ font-size: 0.86rem;
229
+ line-height: 1;
230
+ font-weight: 700;
231
+ letter-spacing: -0.02em;
232
+ text-align: center;
233
+ box-shadow: 0 10px 30px rgba(45, 126, 247, 0.28);
234
+ }}
235
+
236
+ .detail-page {{
237
+ padding: 18px 0 22px;
238
+ }}
239
+
240
+ .detail-shell {{
241
+ min-height: calc(100vh - 40px);
242
+ grid-template-rows: auto minmax(0, 1fr) auto;
243
+ }}
244
+
245
+ .detail-header {{
246
+ display: flex;
247
+ align-items: flex-start;
248
+ justify-content: space-between;
249
+ gap: 16px;
250
+ margin-bottom: 34px;
251
+ animation: rise 420ms ease-out both;
252
+ }}
253
+
254
+ .detail-title-row {{
255
+ display: flex;
256
+ align-items: center;
257
+ gap: 12px;
258
+ min-width: 0;
259
+ }}
260
+
261
+ .detail-title {{
262
+ margin: 0;
263
+ font-size: clamp(1.5rem, 2vw, 2rem);
264
+ line-height: 1.12;
265
+ font-weight: 700;
266
+ letter-spacing: -0.04em;
267
+ }}
268
+
269
+ .detail-meta {{
270
+ margin-top: 10px;
271
+ color: var(--muted);
272
+ font-size: 0.95rem;
273
+ line-height: 1.35;
274
+ }}
275
+
276
+ .ghost-link {{
277
+ display: inline-flex;
278
+ align-items: center;
279
+ gap: 8px;
280
+ padding: 9px 12px;
281
+ border: 1px solid var(--line);
282
+ border-radius: 12px;
283
+ background: rgba(255, 255, 255, 0.02);
284
+ color: var(--muted);
285
+ text-decoration: none;
286
+ font-size: 0.92rem;
287
+ line-height: 1;
288
+ transition: border-color 160ms ease, color 160ms ease, background-color 160ms ease;
289
+ }}
290
+
291
+ .ghost-link:hover {{
292
+ color: var(--text);
293
+ border-color: rgba(255, 255, 255, 0.18);
294
+ background: rgba(255, 255, 255, 0.04);
295
+ }}
296
+
297
+ .section-title {{
298
+ margin: 0 0 14px;
299
+ font-size: 1.05rem;
300
+ line-height: 1.2;
301
+ font-weight: 700;
302
+ letter-spacing: -0.02em;
303
+ }}
304
+
305
+ .detail-grid {{
306
+ display: grid;
307
+ grid-template-columns: minmax(250px, 410px) 1fr;
308
+ gap: 34px 40px;
309
+ align-items: start;
310
+ }}
311
+
312
+ .facts {{
313
+ display: grid;
314
+ grid-template-columns: 130px minmax(0, 1fr);
315
+ gap: 10px 18px;
316
+ margin: 0;
317
+ }}
318
+
319
+ .fact-label {{
320
+ color: var(--muted);
321
+ font-size: 1rem;
322
+ line-height: 1.35;
323
+ font-weight: 600;
324
+ }}
325
+
326
+ .fact-value {{
327
+ margin: 0;
328
+ color: var(--text);
329
+ font-size: 1rem;
330
+ line-height: 1.35;
331
+ font-weight: 600;
332
+ }}
333
+
334
+ .summary-block,
335
+ .explore-block {{
336
+ grid-column: 1 / -1;
337
+ }}
338
+
339
+ .card-grid {{
340
+ display: grid;
341
+ grid-template-columns: repeat(6, minmax(180px, 1fr));
342
+ gap: 18px;
343
+ }}
344
+
345
+ .stat-card {{
346
+ min-height: 102px;
347
+ padding: 20px 24px 18px;
348
+ border: 1px solid var(--line);
349
+ border-radius: 12px;
350
+ background: rgba(9, 13, 20, 0.68);
351
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.015);
352
+ }}
353
+
354
+ .stat-label {{
355
+ display: block;
356
+ color: var(--muted);
357
+ font-size: 0.9rem;
358
+ line-height: 1.25;
359
+ font-weight: 600;
360
+ white-space: nowrap;
361
+ overflow: hidden;
362
+ text-overflow: ellipsis;
363
+ }}
364
+
365
+ .stat-value {{
366
+ display: block;
367
+ margin-top: 10px;
368
+ font-size: clamp(1.45rem, 1.9vw, 2.1rem);
369
+ line-height: 1;
370
+ letter-spacing: -0.045em;
371
+ font-weight: 400;
372
+ font-variant-numeric: tabular-nums;
373
+ color: var(--text);
374
+ }}
375
+
376
+ .action-row {{
377
+ display: flex;
378
+ flex-wrap: wrap;
379
+ gap: 14px;
380
+ }}
381
+
382
+ .action-chip {{
383
+ display: inline-flex;
384
+ align-items: center;
385
+ gap: 10px;
386
+ padding: 12px 16px;
387
+ border-radius: 10px;
388
+ border: 1px solid var(--line);
389
+ background: rgba(255, 255, 255, 0.03);
390
+ color: #dbe2ee;
391
+ text-decoration: none;
392
+ font-size: 0.96rem;
393
+ line-height: 1;
394
+ font-weight: 600;
395
+ }}
396
+
397
+ .empty-state {{
398
+ padding: 48px 24px;
399
+ text-align: center;
400
+ color: var(--muted);
401
+ }}
402
+
403
+ .empty-title {{
404
+ margin: 0;
405
+ color: var(--text);
406
+ font-size: 1.4rem;
407
+ line-height: 1.15;
408
+ }}
409
+
410
+ .empty-copy {{
411
+ margin: 12px auto 0;
412
+ max-width: 460px;
413
+ font-size: 1rem;
414
+ line-height: 1.45;
415
+ }}
416
+
417
+ @keyframes rise {{
418
+ from {{
419
+ opacity: 0;
420
+ transform: translateY(14px);
421
+ }}
422
+
423
+ to {{
424
+ opacity: 1;
425
+ transform: translateY(0);
426
+ }}
427
+ }}
428
+
429
+ @media (max-width: 1280px) {{
430
+ .card-grid {{
431
+ grid-template-columns: repeat(3, minmax(180px, 1fr));
432
+ }}
433
+ }}
434
+
435
+ @media (max-width: 920px) {{
436
+ .detail-grid {{
437
+ grid-template-columns: 1fr;
438
+ }}
439
+
440
+ .card-grid {{
441
+ grid-template-columns: repeat(2, minmax(180px, 1fr));
442
+ }}
443
+ }}
444
+
445
+ @media (max-width: 820px) {{
446
+ .index-page {{
447
+ padding-top: 28px;
448
+ }}
449
+
450
+ .session-list {{
451
+ margin-top: 28px;
452
+ }}
453
+
454
+ .session-link {{
455
+ padding-inline: 8px;
456
+ }}
457
+
458
+ .badge {{
459
+ min-width: 84px;
460
+ padding-inline: 16px;
461
+ }}
462
+ }}
463
+
464
+ @media (max-width: 640px) {{
465
+ .shell {{
466
+ width: min(100%, calc(100% - 28px));
467
+ }}
468
+
469
+ .site-footer,
470
+ .site-footer {{
471
+ flex-direction: column;
472
+ align-items: stretch;
473
+ }}
474
+
475
+ .detail-header {{
476
+ flex-direction: column;
477
+ align-items: stretch;
478
+ }}
479
+
480
+ .facts {{
481
+ grid-template-columns: 1fr;
482
+ gap: 4px 0;
483
+ }}
484
+
485
+ .session-link {{
486
+ align-items: flex-start;
487
+ gap: 16px;
488
+ }}
489
+
490
+ .session-title {{
491
+ white-space: normal;
492
+ }}
493
+
494
+ .card-grid {{
495
+ grid-template-columns: 1fr;
496
+ }}
497
+ }}
498
+ </style>
499
+ </head>
500
+ <body>{body}</body>
501
+ </html>
502
+ """
503
+
504
+
505
+ def build_index_page(session_previews: list[SessionPreview]) -> str:
506
+ items = render_session_list_markup(session_previews)
507
+ body = f"""
508
+ <main class="page index-page">
509
+ <div class="shell app-shell index-shell">
510
+ <section class="index-content">
511
+ <header class="hero">
512
+ <h1 class="index-title">{PAGE_TITLE}</h1>
513
+ <p class="index-subtitle">Select a chat session to debug</p>
514
+ </header>
515
+ <ul class="session-list" id="session-list">{items}
516
+ </ul>
517
+ </section>
518
+ {render_page_footer()}
519
+ </div>
520
+
521
+ <script>
522
+ (() => {{
523
+ const sessionList = document.getElementById('session-list');
524
+ let eventSource = null;
525
+ let isPolling = false;
526
+ let lastMarkup = sessionList ? sessionList.innerHTML.trim() : '';
527
+
528
+ async function refreshIndex() {{
529
+ if (isPolling || document.hidden || !sessionList) {{
530
+ return;
531
+ }}
532
+
533
+ isPolling = true;
534
+ try {{
535
+ const response = await fetch('/index.json', {{
536
+ headers: {{ 'Accept': 'application/json' }},
537
+ }});
538
+ if (!response.ok) {{
539
+ return;
540
+ }}
541
+
542
+ const payload = await response.json();
543
+ const itemsHtml = typeof payload.itemsHtml === 'string' ? payload.itemsHtml.trim() : '';
544
+ if (itemsHtml === lastMarkup) {{
545
+ return;
546
+ }}
547
+
548
+ sessionList.innerHTML = itemsHtml;
549
+ lastMarkup = itemsHtml;
550
+ }} catch (_error) {{
551
+ return;
552
+ }} finally {{
553
+ isPolling = false;
554
+ }}
555
+ }}
556
+
557
+ function closeIndexStream() {{
558
+ if (!eventSource) {{
559
+ return;
560
+ }}
561
+
562
+ eventSource.close();
563
+ eventSource = null;
564
+ }}
565
+
566
+ function openIndexStream() {{
567
+ if (eventSource || !sessionList || document.hidden || typeof EventSource !== 'function') {{
568
+ return;
569
+ }}
570
+
571
+ eventSource = new EventSource('/index.events');
572
+ eventSource.onmessage = () => {{
573
+ refreshIndex();
574
+ }};
575
+ eventSource.onerror = () => {{
576
+ return;
577
+ }};
578
+ }}
579
+
580
+ openIndexStream();
581
+ document.addEventListener('visibilitychange', () => {{
582
+ if (document.hidden) {{
583
+ closeIndexStream();
584
+ }} else {{
585
+ refreshIndex();
586
+ openIndexStream();
587
+ }}
588
+ }});
589
+ window.addEventListener('beforeunload', () => {{
590
+ closeIndexStream();
591
+ }});
592
+ }})();
593
+ </script>
594
+ </main>
595
+ """
596
+ return render_document(PAGE_TITLE, body)
597
+
598
+
599
+ def build_session_page(summary: SessionSummary) -> str:
600
+ title = html.escape(summary.title)
601
+ detail_meta = build_session_detail_meta(summary)
602
+ body = f"""
603
+ <main class="page detail-page">
604
+ <div class="shell app-shell detail-shell">
605
+ <header class="detail-header">
606
+ <div>
607
+ <div class="detail-title-row">
608
+ {chat_icon()}
609
+ <h1 class="detail-title">{title}</h1>
610
+ </div>
611
+ <div class="detail-meta" id="session-detail-meta">{detail_meta}</div>
612
+ </div>
613
+ <a class="ghost-link" href="/">Back To Sessions</a>
614
+ </header>
615
+
616
+ <section class="detail-grid">
617
+ <div>
618
+ <h2 class="section-title">Session Details</h2>
619
+ <dl class="facts">
620
+ <dt class="fact-label">Session Type</dt>
621
+ <dd class="fact-value" id="session-type-value">{html.escape(summary.session_type)}</dd>
622
+ <dt class="fact-label">Location</dt>
623
+ <dd class="fact-value" id="session-location-value">{html.escape(summary.location)}</dd>
624
+ <dt class="fact-label">Status</dt>
625
+ <dd class="fact-value" id="session-status-value">{html.escape(summary.status)}</dd>
626
+ <dt class="fact-label">Created</dt>
627
+ <dd class="fact-value" id="session-created-value">{html.escape(summary.created_label)}</dd>
628
+ <dt class="fact-label">Last Activity</dt>
629
+ <dd class="fact-value" id="session-updated-value">{html.escape(summary.updated_label)}</dd>
630
+ </dl>
631
+ </div>
632
+
633
+ <div class="summary-block">
634
+ <h2 class="section-title">Summary</h2>
635
+ <div class="card-grid">
636
+ {render_stat_card('Model Turns', summary.model_turns, value_id='session-model-turns-value')}
637
+ {render_stat_card('Tool Calls', summary.tool_calls, value_id='session-tool-calls-value')}
638
+ {render_stat_card('Total Input Tokens', summary.total_input_tokens, value_id='session-total-input-value')}
639
+ {render_stat_card('Total Output Tokens', summary.total_output_tokens, value_id='session-total-output-value')}
640
+ {render_stat_card('Total Cached Input Tokens', summary.total_cached_input_tokens, value_id='session-total-cached-input-value')}
641
+ {render_stat_card('Total Tokens', summary.total_tokens, value_id='session-total-tokens-value')}
642
+ {render_stat_card('Errors', summary.error_count, value_id='session-error-count-value')}
643
+ </div>
644
+ </div>
645
+
646
+ <div class="explore-block">
647
+ <h2 class="section-title">Explore Trace Data</h2>
648
+ <div class="action-row">
649
+ {render_action_chip('View Logs', f'/sessions/{summary.session_id}/logs')}
650
+ {render_action_chip('Agent Flow Chart', f'/sessions/{summary.session_id}/flow')}
651
+ </div>
652
+ </div>
653
+ </section>
654
+ {render_page_footer()}
655
+ </div>
656
+
657
+ <script>
658
+ (() => {{
659
+ const sessionId = {json.dumps(summary.session_id)};
660
+ let eventSource = null;
661
+ let isPolling = false;
662
+
663
+ function setText(id, value) {{
664
+ const node = document.getElementById(id);
665
+ if (node) {{
666
+ node.textContent = value;
667
+ }}
668
+ }}
669
+
670
+ async function refreshSummary() {{
671
+ if (isPolling || document.hidden) {{
672
+ return;
673
+ }}
674
+
675
+ isPolling = true;
676
+ try {{
677
+ const response = await fetch('/sessions/' + encodeURIComponent(sessionId) + '/summary.json', {{
678
+ headers: {{ 'Accept': 'application/json' }},
679
+ }});
680
+ if (!response.ok) {{
681
+ return;
682
+ }}
683
+
684
+ const summary = await response.json();
685
+ setText('session-detail-meta', summary.detailMeta || '');
686
+ setText('session-type-value', summary.sessionType || '');
687
+ setText('session-location-value', summary.location || '');
688
+ setText('session-status-value', summary.status || '');
689
+ setText('session-created-value', summary.createdLabel || '');
690
+ setText('session-updated-value', summary.updatedLabel || '');
691
+ setText('session-model-turns-value', summary.modelTurns || '0');
692
+ setText('session-tool-calls-value', summary.toolCalls || '0');
693
+ setText('session-total-input-value', summary.totalInputTokens || '0');
694
+ setText('session-total-output-value', summary.totalOutputTokens || '0');
695
+ setText('session-total-cached-input-value', summary.totalCachedInputTokens || '0');
696
+ setText('session-total-tokens-value', summary.totalTokens || '0');
697
+ setText('session-error-count-value', summary.errorCount || '0');
698
+ }} catch (_error) {{
699
+ return;
700
+ }} finally {{
701
+ isPolling = false;
702
+ }}
703
+ }}
704
+
705
+ function closeSummaryStream() {{
706
+ if (!eventSource) {{
707
+ return;
708
+ }}
709
+
710
+ eventSource.close();
711
+ eventSource = null;
712
+ }}
713
+
714
+ function openSummaryStream() {{
715
+ if (eventSource || document.hidden || typeof EventSource !== 'function') {{
716
+ return;
717
+ }}
718
+
719
+ eventSource = new EventSource('/sessions/' + encodeURIComponent(sessionId) + '/summary.events');
720
+ eventSource.onmessage = () => {{
721
+ refreshSummary();
722
+ }};
723
+ eventSource.onerror = () => {{
724
+ return;
725
+ }};
726
+ }}
727
+
728
+ openSummaryStream();
729
+ document.addEventListener('visibilitychange', () => {{
730
+ if (document.hidden) {{
731
+ closeSummaryStream();
732
+ }} else {{
733
+ refreshSummary();
734
+ openSummaryStream();
735
+ }}
736
+ }});
737
+ window.addEventListener('beforeunload', () => {{
738
+ closeSummaryStream();
739
+ }});
740
+ }})();
741
+ </script>
742
+ </main>
743
+ """
744
+ return render_document(f"{summary.title} - {PAGE_TITLE}", body)
745
+
746
+
747
+ def build_flow_page(summary: SessionSummary, flow_nodes: list[SessionFlowNode]) -> str:
748
+ nodes_markup = "\n".join(render_flow_node(summary.session_id, node) for node in flow_nodes) or render_empty_flow_state()
749
+ body = f"""
750
+ <style>
751
+ .flow-page {{
752
+ min-height: 100vh;
753
+ padding: 18px 0 20px;
754
+ }}
755
+
756
+ .flow-shell {{
757
+ min-height: calc(100vh - 38px);
758
+ grid-template-rows: auto minmax(0, 1fr) auto;
759
+ }}
760
+
761
+ .flow-content {{
762
+ min-height: 0;
763
+ display: grid;
764
+ grid-template-rows: auto auto minmax(0, 1fr);
765
+ gap: 18px;
766
+ }}
767
+
768
+ .flow-header {{
769
+ display: flex;
770
+ align-items: flex-start;
771
+ justify-content: space-between;
772
+ gap: 18px;
773
+ }}
774
+
775
+ .flow-kicker {{
776
+ color: #7f8796;
777
+ font-size: 0.82rem;
778
+ letter-spacing: 0.16em;
779
+ text-transform: uppercase;
780
+ }}
781
+
782
+ .flow-title {{
783
+ margin: 8px 0 0;
784
+ font-size: clamp(2rem, 4vw, 3rem);
785
+ line-height: 0.96;
786
+ letter-spacing: -0.06em;
787
+ }}
788
+
789
+ .flow-subtitle {{
790
+ max-width: 760px;
791
+ margin: 10px 0 0;
792
+ color: #9ea6b6;
793
+ font-size: 0.98rem;
794
+ line-height: 1.6;
795
+ }}
796
+
797
+ .flow-summary-bar {{
798
+ display: flex;
799
+ flex-wrap: wrap;
800
+ gap: 10px;
801
+ }}
802
+
803
+ .flow-summary-pill {{
804
+ display: inline-flex;
805
+ align-items: center;
806
+ gap: 8px;
807
+ padding: 8px 12px;
808
+ border-radius: 999px;
809
+ border: 1px solid rgba(255, 255, 255, 0.09);
810
+ background: rgba(255, 255, 255, 0.03);
811
+ color: #a8b0bf;
812
+ font-size: 0.84rem;
813
+ }}
814
+
815
+ .flow-summary-pill strong {{
816
+ color: #eef3fa;
817
+ font-weight: 600;
818
+ }}
819
+
820
+ .flow-board {{
821
+ position: relative;
822
+ min-height: 0;
823
+ padding: 18px 0 24px;
824
+ }}
825
+
826
+ .flow-board::before {{
827
+ content: "";
828
+ position: absolute;
829
+ top: 0;
830
+ bottom: 0;
831
+ left: 50%;
832
+ width: 1px;
833
+ transform: translateX(-50%);
834
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(163, 175, 197, 0.28), rgba(255, 255, 255, 0.04));
835
+ }}
836
+
837
+ .flow-rail {{
838
+ position: relative;
839
+ z-index: 1;
840
+ display: flex;
841
+ flex-direction: column;
842
+ align-items: center;
843
+ gap: 18px;
844
+ }}
845
+
846
+ .flow-step {{
847
+ position: relative;
848
+ display: flex;
849
+ justify-content: center;
850
+ width: 100%;
851
+ }}
852
+
853
+ .flow-step::before,
854
+ .flow-step::after {{
855
+ content: "";
856
+ position: absolute;
857
+ left: 50%;
858
+ transform: translateX(-50%);
859
+ }}
860
+
861
+ .flow-step::before {{
862
+ top: -18px;
863
+ width: 1px;
864
+ height: 18px;
865
+ background: rgba(176, 188, 207, 0.34);
866
+ }}
867
+
868
+ .flow-step::after {{
869
+ top: -6px;
870
+ width: 10px;
871
+ height: 10px;
872
+ border-right: 1.5px solid rgba(176, 188, 207, 0.66);
873
+ border-bottom: 1.5px solid rgba(176, 188, 207, 0.66);
874
+ transform: translateX(-50%) rotate(45deg);
875
+ background: var(--bg);
876
+ }}
877
+
878
+ .flow-step:first-child::before,
879
+ .flow-step:first-child::after {{
880
+ display: none;
881
+ }}
882
+
883
+ .flow-card {{
884
+ position: relative;
885
+ width: min(100%, 560px);
886
+ padding: 14px 16px;
887
+ border-radius: 16px;
888
+ border: 1px solid rgba(255, 255, 255, 0.12);
889
+ background: rgba(16, 22, 33, 0.88);
890
+ box-shadow: 0 18px 36px rgba(0, 0, 0, 0.26), inset 0 1px 0 rgba(255, 255, 255, 0.04);
891
+ }}
892
+
893
+ .flow-link {{
894
+ display: block;
895
+ width: min(100%, 560px);
896
+ color: inherit;
897
+ text-decoration: none;
898
+ }}
899
+
900
+ .flow-link .flow-card {{
901
+ width: 100%;
902
+ }}
903
+
904
+ .flow-link:hover .flow-card,
905
+ .flow-link:focus-visible .flow-card {{
906
+ transform: translateY(-1px);
907
+ border-color: rgba(255, 255, 255, 0.18);
908
+ box-shadow: 0 22px 42px rgba(0, 0, 0, 0.34), inset 0 0 0 1px rgba(255, 255, 255, 0.04);
909
+ }}
910
+
911
+ .flow-link:focus-visible {{
912
+ outline: none;
913
+ }}
914
+
915
+ .flow-card::before,
916
+ .flow-card::after {{
917
+ content: "";
918
+ position: absolute;
919
+ inset: 0;
920
+ border-radius: inherit;
921
+ pointer-events: none;
922
+ }}
923
+
924
+ .flow-card::before {{
925
+ border: 1px solid rgba(255, 255, 255, 0.04);
926
+ }}
927
+
928
+ .flow-card.is-group {{
929
+ width: min(100%, 420px);
930
+ background: rgba(20, 25, 37, 0.94);
931
+ }}
932
+
933
+ .flow-card.is-group::after {{
934
+ inset: auto 10px -7px 10px;
935
+ height: 14px;
936
+ border-radius: 0 0 12px 12px;
937
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.02));
938
+ opacity: 0.7;
939
+ }}
940
+
941
+ .flow-card.is-user {{
942
+ width: min(100%, 700px);
943
+ border-color: rgba(73, 127, 255, 0.65);
944
+ background: linear-gradient(180deg, rgba(11, 30, 67, 0.95), rgba(9, 19, 41, 0.95));
945
+ box-shadow: 0 18px 36px rgba(7, 18, 38, 0.42), inset 0 0 0 1px rgba(107, 157, 255, 0.14);
946
+ }}
947
+
948
+ .flow-card.is-model {{
949
+ width: min(100%, 540px);
950
+ border-color: rgba(73, 127, 255, 0.54);
951
+ background: linear-gradient(180deg, rgba(11, 30, 67, 0.88), rgba(10, 19, 41, 0.92));
952
+ }}
953
+
954
+ .flow-card.is-response {{
955
+ width: min(100%, 640px);
956
+ background: rgba(18, 22, 30, 0.94);
957
+ }}
958
+
959
+ .flow-card.is-tool,
960
+ .flow-card.is-skill {{
961
+ width: min(100%, 370px);
962
+ border-color: rgba(108, 221, 164, 0.62);
963
+ background: linear-gradient(180deg, rgba(10, 43, 31, 0.96), rgba(11, 31, 25, 0.94));
964
+ }}
965
+
966
+ .flow-card.is-error {{
967
+ border-color: rgba(239, 97, 97, 0.58);
968
+ background: linear-gradient(180deg, rgba(67, 18, 18, 0.94), rgba(38, 12, 12, 0.96));
969
+ }}
970
+
971
+ .flow-card.is-agent,
972
+ .flow-card.is-state {{
973
+ width: min(100%, 420px);
974
+ background: rgba(24, 28, 37, 0.94);
975
+ }}
976
+
977
+ .flow-card-top {{
978
+ display: flex;
979
+ align-items: flex-start;
980
+ justify-content: space-between;
981
+ gap: 14px;
982
+ }}
983
+
984
+ .flow-card-title-wrap {{
985
+ min-width: 0;
986
+ }}
987
+
988
+ .flow-card-title {{
989
+ margin: 0;
990
+ color: #f1f5fb;
991
+ font-size: 1.06rem;
992
+ line-height: 1.15;
993
+ font-weight: 700;
994
+ letter-spacing: -0.03em;
995
+ }}
996
+
997
+ .flow-card-subtitle {{
998
+ margin-top: 4px;
999
+ color: #a6afbe;
1000
+ font-size: 0.82rem;
1001
+ line-height: 1.4;
1002
+ }}
1003
+
1004
+ .flow-card-pill {{
1005
+ flex: none;
1006
+ display: inline-flex;
1007
+ align-items: center;
1008
+ padding: 5px 8px;
1009
+ border-radius: 999px;
1010
+ background: rgba(255, 255, 255, 0.05);
1011
+ color: #d2d9e4;
1012
+ font-size: 0.76rem;
1013
+ line-height: 1;
1014
+ white-space: nowrap;
1015
+ }}
1016
+
1017
+ .flow-card-body {{
1018
+ margin-top: 10px;
1019
+ color: #edf2fa;
1020
+ font-size: 0.98rem;
1021
+ line-height: 1.55;
1022
+ white-space: pre-wrap;
1023
+ word-break: break-word;
1024
+ }}
1025
+
1026
+ .flow-card-meta {{
1027
+ margin-top: 10px;
1028
+ color: #8790a0;
1029
+ font-size: 0.78rem;
1030
+ line-height: 1.4;
1031
+ font-family: "SFMono-Regular", "Menlo", monospace;
1032
+ }}
1033
+
1034
+ .flow-card.is-user .flow-card-subtitle,
1035
+ .flow-card.is-model .flow-card-subtitle {{
1036
+ color: #bfd1ff;
1037
+ }}
1038
+
1039
+ .flow-card.is-tool .flow-card-subtitle,
1040
+ .flow-card.is-skill .flow-card-subtitle {{
1041
+ color: #b8eed0;
1042
+ }}
1043
+
1044
+ .flow-card.is-error .flow-card-subtitle {{
1045
+ color: #ffb9b9;
1046
+ }}
1047
+
1048
+ .flow-empty {{
1049
+ width: min(100%, 560px);
1050
+ padding: 28px 30px;
1051
+ border-radius: 18px;
1052
+ border: 1px solid rgba(255, 255, 255, 0.08);
1053
+ background: rgba(255, 255, 255, 0.03);
1054
+ text-align: center;
1055
+ color: #a2abbb;
1056
+ }}
1057
+
1058
+ .flow-empty strong {{
1059
+ display: block;
1060
+ margin-bottom: 8px;
1061
+ color: #eef3fa;
1062
+ font-size: 1rem;
1063
+ }}
1064
+
1065
+ @media (max-width: 780px) {{
1066
+ .shell.flow-shell {{
1067
+ width: min(100%, calc(100% - 24px));
1068
+ }}
1069
+
1070
+ .flow-header {{
1071
+ flex-direction: column;
1072
+ }}
1073
+
1074
+ .flow-board::before {{
1075
+ left: 24px;
1076
+ transform: none;
1077
+ }}
1078
+
1079
+ .flow-rail {{
1080
+ align-items: stretch;
1081
+ }}
1082
+
1083
+ .flow-step {{
1084
+ justify-content: flex-start;
1085
+ padding-left: 48px;
1086
+ }}
1087
+
1088
+ .flow-step::before,
1089
+ .flow-step::after {{
1090
+ left: 24px;
1091
+ transform: none;
1092
+ }}
1093
+
1094
+ .flow-step::after {{
1095
+ transform: rotate(45deg);
1096
+ }}
1097
+
1098
+ .flow-card {{
1099
+ width: 100%;
1100
+ }}
1101
+ }}
1102
+ </style>
1103
+ <main class="page flow-page">
1104
+ <div class="shell app-shell flow-shell">
1105
+ <div class="flow-content">
1106
+ <header class="flow-header">
1107
+ <div>
1108
+ <div class="flow-kicker">Trace View</div>
1109
+ <h1 class="flow-title">Agent Flow Chart</h1>
1110
+ <p class="flow-subtitle">A reconstructed vertical execution graph for {html.escape(summary.title)}. The chart compresses raw session events into user prompts, model turns, tool calls, skill loads, and state transitions.</p>
1111
+ </div>
1112
+ <a class="ghost-link" href="/sessions/{summary.session_id}">Back To Summary</a>
1113
+ </header>
1114
+
1115
+ <div class="flow-summary-bar">
1116
+ <span class="flow-summary-pill"><strong id="flow-node-count-value">{len(flow_nodes)}</strong> flow nodes</span>
1117
+ <span class="flow-summary-pill"><strong id="flow-model-name-value">{html.escape(summary.model_name)}</strong> active model</span>
1118
+ <span class="flow-summary-pill"><strong id="flow-tool-calls-value">{summary.tool_calls}</strong> tool calls</span>
1119
+ <span class="flow-summary-pill"><strong id="flow-error-count-value">{summary.error_count}</strong> errors</span>
1120
+ </div>
1121
+
1122
+ <section class="flow-board">
1123
+ <div class="flow-rail" id="flow-rail">{nodes_markup}
1124
+ </div>
1125
+ </section>
1126
+ </div>
1127
+ {render_page_footer()}
1128
+ </div>
1129
+
1130
+ <script>
1131
+ (() => {{
1132
+ const sessionId = {json.dumps(summary.session_id)};
1133
+ const flowRail = document.getElementById('flow-rail');
1134
+ let eventSource = null;
1135
+ let isPolling = false;
1136
+
1137
+ function setText(id, value) {{
1138
+ const node = document.getElementById(id);
1139
+ if (node) {{
1140
+ node.textContent = value;
1141
+ }}
1142
+ }}
1143
+
1144
+ function createNodeFromMarkup(markup) {{
1145
+ const template = document.createElement('template');
1146
+ template.innerHTML = markup.trim();
1147
+ return template.content.firstElementChild;
1148
+ }}
1149
+
1150
+ function currentMaxFlowIndex() {{
1151
+ const nodes = Array.from(document.querySelectorAll('[data-flow-index]'));
1152
+ return nodes.reduce((maxIndex, node) => Math.max(maxIndex, Number(node.dataset.flowIndex || '-1')), -1);
1153
+ }}
1154
+
1155
+ function shouldFollowTail() {{
1156
+ const remaining = document.documentElement.scrollHeight - window.innerHeight - window.scrollY;
1157
+ return remaining < 160;
1158
+ }}
1159
+
1160
+ async function refreshFlow() {{
1161
+ if (isPolling || document.hidden || !flowRail) {{
1162
+ return;
1163
+ }}
1164
+
1165
+ isPolling = true;
1166
+ try {{
1167
+ const response = await fetch('/sessions/' + encodeURIComponent(sessionId) + '/flow.json?after=' + String(currentMaxFlowIndex()), {{
1168
+ headers: {{ 'Accept': 'application/json' }},
1169
+ }});
1170
+ if (!response.ok) {{
1171
+ return;
1172
+ }}
1173
+
1174
+ const payload = await response.json();
1175
+ const nodes = Array.isArray(payload.nodes) ? payload.nodes : [];
1176
+ setText('flow-node-count-value', String(payload.nodeCount || 0));
1177
+ setText('flow-model-name-value', payload.modelName || 'Unknown');
1178
+ setText('flow-tool-calls-value', String(payload.toolCalls || 0));
1179
+ setText('flow-error-count-value', String(payload.errorCount || 0));
1180
+
1181
+ if (!nodes.length) {{
1182
+ return;
1183
+ }}
1184
+
1185
+ const followTail = shouldFollowTail();
1186
+ const emptyState = flowRail.querySelector('.flow-empty');
1187
+ if (emptyState) {{
1188
+ emptyState.remove();
1189
+ }}
1190
+
1191
+ let newestNode = null;
1192
+ nodes.forEach((node) => {{
1193
+ const flowNode = createNodeFromMarkup(node.html || '');
1194
+ if (!flowNode) {{
1195
+ return;
1196
+ }}
1197
+ flowRail.appendChild(flowNode);
1198
+ newestNode = flowNode;
1199
+ }});
1200
+
1201
+ if (followTail && newestNode) {{
1202
+ newestNode.scrollIntoView({{ block: 'end' }});
1203
+ }}
1204
+ }} catch (_error) {{
1205
+ return;
1206
+ }} finally {{
1207
+ isPolling = false;
1208
+ }}
1209
+ }}
1210
+
1211
+ function closeFlowStream() {{
1212
+ if (!eventSource) {{
1213
+ return;
1214
+ }}
1215
+
1216
+ eventSource.close();
1217
+ eventSource = null;
1218
+ }}
1219
+
1220
+ function openFlowStream() {{
1221
+ if (eventSource || !flowRail || document.hidden || typeof EventSource !== 'function') {{
1222
+ return;
1223
+ }}
1224
+
1225
+ eventSource = new EventSource('/sessions/' + encodeURIComponent(sessionId) + '/flow.events');
1226
+ eventSource.onmessage = () => {{
1227
+ refreshFlow();
1228
+ }};
1229
+ eventSource.onerror = () => {{
1230
+ return;
1231
+ }};
1232
+ }}
1233
+
1234
+ openFlowStream();
1235
+ document.addEventListener('visibilitychange', () => {{
1236
+ if (document.hidden) {{
1237
+ closeFlowStream();
1238
+ }} else {{
1239
+ refreshFlow();
1240
+ openFlowStream();
1241
+ }}
1242
+ }});
1243
+ window.addEventListener('beforeunload', () => {{
1244
+ closeFlowStream();
1245
+ }});
1246
+ }})();
1247
+ </script>
1248
+ </main>
1249
+ """
1250
+ return render_document(f"{summary.title} Flow - {PAGE_TITLE}", body)
1251
+
1252
+
1253
+ def build_logs_page(summary: SessionSummary, log_entries: list[SessionLogEntry]) -> str:
1254
+ rows = "\n".join(render_log_row(entry, selected=index == 0) for index, entry in enumerate(log_entries))
1255
+ templates = "\n".join(render_log_detail_template(entry) for entry in log_entries)
1256
+ initial_detail = render_log_detail(log_entries[0]) if log_entries else render_empty_log_detail()
1257
+ body = f"""
1258
+ <style>
1259
+ .logs-page {{
1260
+ height: 100vh;
1261
+ padding: 18px 0 0;
1262
+ overflow: hidden;
1263
+ }}
1264
+
1265
+ .logs-shell {{
1266
+ height: 100%;
1267
+ display: grid;
1268
+ grid-template-rows: minmax(0, 1fr) auto;
1269
+ gap: 14px;
1270
+ min-height: 0;
1271
+ }}
1272
+
1273
+ .logs-content {{
1274
+ display: grid;
1275
+ grid-template-rows: auto auto minmax(0, 1fr);
1276
+ gap: 14px;
1277
+ min-height: 0;
1278
+ overflow: hidden;
1279
+ }}
1280
+
1281
+ .logs-header {{
1282
+ display: flex;
1283
+ align-items: center;
1284
+ justify-content: space-between;
1285
+ gap: 18px;
1286
+ min-height: 0;
1287
+ }}
1288
+
1289
+ .logs-toolbar {{
1290
+ display: flex;
1291
+ align-items: center;
1292
+ gap: 12px;
1293
+ min-height: 0;
1294
+ }}
1295
+
1296
+ .logs-filter {{
1297
+ position: relative;
1298
+ flex: 1;
1299
+ max-width: 640px;
1300
+ }}
1301
+
1302
+ .logs-filter-meta {{
1303
+ display: inline-flex;
1304
+ align-items: center;
1305
+ min-width: 104px;
1306
+ padding: 0 12px;
1307
+ height: 42px;
1308
+ border-radius: 10px;
1309
+ border: 1px solid rgba(255, 255, 255, 0.08);
1310
+ background: rgba(255, 255, 255, 0.03);
1311
+ color: #96a0af;
1312
+ font-size: 0.88rem;
1313
+ line-height: 1;
1314
+ letter-spacing: -0.01em;
1315
+ }}
1316
+
1317
+ .logs-filter input {{
1318
+ width: 100%;
1319
+ height: 42px;
1320
+ border-radius: 10px;
1321
+ border: 1px solid rgba(255, 255, 255, 0.12);
1322
+ background: rgba(7, 10, 16, 0.74);
1323
+ color: #e9edf4;
1324
+ padding: 0 44px 0 14px;
1325
+ outline: none;
1326
+ font: inherit;
1327
+ }}
1328
+
1329
+ .logs-filter input::placeholder {{
1330
+ color: #7f8796;
1331
+ }}
1332
+
1333
+ .logs-filter svg {{
1334
+ position: absolute;
1335
+ right: 14px;
1336
+ top: 50%;
1337
+ width: 16px;
1338
+ height: 16px;
1339
+ transform: translateY(-50%);
1340
+ color: #7f8796;
1341
+ }}
1342
+
1343
+ .logs-main {{
1344
+ display: grid;
1345
+ grid-template-columns: minmax(0, 1.6fr) minmax(320px, 0.9fr);
1346
+ gap: 16px;
1347
+ min-height: 0;
1348
+ overflow: hidden;
1349
+ }}
1350
+
1351
+ .logs-list-panel,
1352
+ .logs-detail-panel {{
1353
+ min-height: 0;
1354
+ border: 1px solid rgba(255, 255, 255, 0.08);
1355
+ border-radius: 16px;
1356
+ background: rgba(7, 10, 16, 0.86);
1357
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02);
1358
+ overflow: hidden;
1359
+ }}
1360
+
1361
+ .logs-table {{
1362
+ height: 100%;
1363
+ display: grid;
1364
+ grid-template-rows: auto auto minmax(0, 1fr);
1365
+ min-height: 0;
1366
+ }}
1367
+
1368
+ .logs-table-head,
1369
+ .log-row {{
1370
+ display: grid;
1371
+ grid-template-columns: 174px 224px minmax(0, 1fr);
1372
+ gap: 18px;
1373
+ align-items: center;
1374
+ }}
1375
+
1376
+ .logs-table-head {{
1377
+ padding: 14px 20px 12px;
1378
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
1379
+ color: #99a1af;
1380
+ font-size: 0.82rem;
1381
+ text-transform: uppercase;
1382
+ letter-spacing: 0.08em;
1383
+ }}
1384
+
1385
+ .logs-table-body {{
1386
+ min-height: 0;
1387
+ overflow: auto;
1388
+ scrollbar-width: thin;
1389
+ scrollbar-color: rgba(153, 161, 175, 0.3) transparent;
1390
+ }}
1391
+
1392
+ .logs-table-body::-webkit-scrollbar,
1393
+ .logs-detail-body::-webkit-scrollbar {{
1394
+ width: 10px;
1395
+ }}
1396
+
1397
+ .logs-table-body::-webkit-scrollbar-thumb,
1398
+ .logs-detail-body::-webkit-scrollbar-thumb {{
1399
+ background: rgba(153, 161, 175, 0.28);
1400
+ border: 3px solid transparent;
1401
+ border-radius: 999px;
1402
+ background-clip: padding-box;
1403
+ }}
1404
+
1405
+ .log-row {{
1406
+ width: 100%;
1407
+ border: 0;
1408
+ padding: 12px 20px;
1409
+ text-align: left;
1410
+ background: transparent;
1411
+ color: inherit;
1412
+ cursor: pointer;
1413
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
1414
+ transition: background-color 140ms ease, border-color 140ms ease;
1415
+ }}
1416
+
1417
+ .log-row:hover {{
1418
+ background: rgba(255, 255, 255, 0.035);
1419
+ }}
1420
+
1421
+ .log-row:focus-visible {{
1422
+ outline: none;
1423
+ background: rgba(255, 255, 255, 0.05);
1424
+ box-shadow: inset 2px 0 0 #4d88f7, 0 0 0 1px rgba(77, 136, 247, 0.26);
1425
+ }}
1426
+
1427
+ .log-row.is-active {{
1428
+ background: linear-gradient(90deg, rgba(50, 95, 182, 0.22), rgba(50, 95, 182, 0.06));
1429
+ box-shadow: inset 2px 0 0 #4d88f7;
1430
+ }}
1431
+
1432
+ .log-row.is-error:not(.is-active) {{
1433
+ background: linear-gradient(90deg, rgba(198, 70, 70, 0.14), rgba(198, 70, 70, 0.03));
1434
+ box-shadow: inset 2px 0 0 rgba(239, 97, 97, 0.78);
1435
+ }}
1436
+
1437
+ .log-row.is-error.is-active {{
1438
+ background: linear-gradient(90deg, rgba(198, 70, 70, 0.22), rgba(198, 70, 70, 0.06));
1439
+ box-shadow: inset 2px 0 0 #ef6161;
1440
+ }}
1441
+
1442
+ .log-row.is-error .log-name {{
1443
+ color: #ffb4b4;
1444
+ }}
1445
+
1446
+ .log-row.is-error .log-details {{
1447
+ color: #e2a1a1;
1448
+ }}
1449
+
1450
+ .log-created {{
1451
+ color: #9aa3b2;
1452
+ font-size: 0.95rem;
1453
+ white-space: nowrap;
1454
+ }}
1455
+
1456
+ .log-name {{
1457
+ color: #edf2fa;
1458
+ font-size: 1rem;
1459
+ font-weight: 600;
1460
+ white-space: nowrap;
1461
+ overflow: hidden;
1462
+ text-overflow: ellipsis;
1463
+ }}
1464
+
1465
+ .log-details {{
1466
+ color: #c7ceda;
1467
+ font-size: 0.96rem;
1468
+ white-space: nowrap;
1469
+ overflow: hidden;
1470
+ text-overflow: ellipsis;
1471
+ }}
1472
+
1473
+ .logs-detail-body {{
1474
+ height: 100%;
1475
+ overflow: auto;
1476
+ padding: 18px 18px 22px;
1477
+ }}
1478
+
1479
+ .log-detail-top {{
1480
+ padding-bottom: 16px;
1481
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
1482
+ }}
1483
+
1484
+ .log-detail-kicker {{
1485
+ color: #8c95a5;
1486
+ font-size: 0.78rem;
1487
+ text-transform: uppercase;
1488
+ letter-spacing: 0.12em;
1489
+ }}
1490
+
1491
+ .log-detail-title {{
1492
+ margin: 10px 0 0;
1493
+ font-size: 1.36rem;
1494
+ line-height: 1.1;
1495
+ font-weight: 700;
1496
+ letter-spacing: -0.035em;
1497
+ }}
1498
+
1499
+ .log-detail-meta {{
1500
+ display: flex;
1501
+ flex-wrap: wrap;
1502
+ gap: 10px;
1503
+ margin-top: 12px;
1504
+ color: #9da6b5;
1505
+ font-size: 0.9rem;
1506
+ }}
1507
+
1508
+ .log-detail-badge {{
1509
+ display: inline-flex;
1510
+ align-items: center;
1511
+ padding: 6px 10px;
1512
+ border-radius: 999px;
1513
+ background: rgba(77, 136, 247, 0.14);
1514
+ color: #b7d0ff;
1515
+ }}
1516
+
1517
+ .log-detail-badge.is-error {{
1518
+ background: rgba(198, 70, 70, 0.16);
1519
+ color: #ffb8b8;
1520
+ box-shadow: inset 0 0 0 1px rgba(239, 97, 97, 0.24);
1521
+ }}
1522
+
1523
+ .log-detail-summary {{
1524
+ margin: 12px 0 0;
1525
+ color: #dbe2ee;
1526
+ font-size: 0.96rem;
1527
+ line-height: 1.45;
1528
+ }}
1529
+
1530
+ .log-section {{
1531
+ margin-top: 16px;
1532
+ }}
1533
+
1534
+ .log-section-header {{
1535
+ display: flex;
1536
+ align-items: center;
1537
+ justify-content: space-between;
1538
+ gap: 12px;
1539
+ margin-bottom: 8px;
1540
+ }}
1541
+
1542
+ .log-section-title {{
1543
+ margin: 0;
1544
+ color: #f1f5fb;
1545
+ font-size: 0.95rem;
1546
+ font-weight: 600;
1547
+ }}
1548
+
1549
+ .log-section-copy {{
1550
+ display: inline-flex;
1551
+ align-items: center;
1552
+ gap: 8px;
1553
+ padding: 6px 10px;
1554
+ border: 1px solid rgba(255, 255, 255, 0.08);
1555
+ border-radius: 999px;
1556
+ background: rgba(255, 255, 255, 0.03);
1557
+ color: #aeb7c7;
1558
+ font-size: 0.8rem;
1559
+ line-height: 1;
1560
+ cursor: pointer;
1561
+ transition: border-color 160ms ease, background-color 160ms ease, color 160ms ease;
1562
+ }}
1563
+
1564
+ .log-section-copy:hover {{
1565
+ color: #f1f5fb;
1566
+ border-color: rgba(255, 255, 255, 0.16);
1567
+ background: rgba(255, 255, 255, 0.06);
1568
+ }}
1569
+
1570
+ .log-section-copy.is-copied {{
1571
+ color: #d8f7d6;
1572
+ border-color: rgba(118, 204, 110, 0.28);
1573
+ background: rgba(118, 204, 110, 0.12);
1574
+ }}
1575
+
1576
+ .log-section-body {{
1577
+ margin: 0;
1578
+ padding: 14px;
1579
+ border-radius: 12px;
1580
+ border: 1px solid rgba(255, 255, 255, 0.07);
1581
+ background: rgba(255, 255, 255, 0.025);
1582
+ color: #cdd5e1;
1583
+ font-size: 0.88rem;
1584
+ line-height: 1.55;
1585
+ white-space: pre-wrap;
1586
+ word-break: break-word;
1587
+ overflow-wrap: anywhere;
1588
+ }}
1589
+
1590
+ .logs-empty {{
1591
+ padding: 28px 24px;
1592
+ color: #8f98a8;
1593
+ }}
1594
+
1595
+ .logs-empty strong {{
1596
+ display: block;
1597
+ margin-bottom: 8px;
1598
+ color: #edf2fa;
1599
+ font-size: 1rem;
1600
+ }}
1601
+
1602
+ .logs-list-empty {{
1603
+ display: none;
1604
+ padding: 28px 24px;
1605
+ color: #8f98a8;
1606
+ }}
1607
+
1608
+ .logs-list-empty.is-visible {{
1609
+ display: block;
1610
+ }}
1611
+
1612
+ mark.log-highlight {{
1613
+ padding: 0 0.16em;
1614
+ border-radius: 0.28em;
1615
+ background: rgba(255, 214, 102, 0.2);
1616
+ color: #fff4cc;
1617
+ box-shadow: inset 0 0 0 1px rgba(255, 214, 102, 0.18);
1618
+ }}
1619
+
1620
+ .logs-templates {{
1621
+ display: none;
1622
+ }}
1623
+
1624
+ @media (max-width: 1180px) {{
1625
+ .logs-table-head,
1626
+ .log-row {{
1627
+ grid-template-columns: 154px 190px minmax(0, 1fr);
1628
+ }}
1629
+ }}
1630
+
1631
+ @media (max-width: 820px) {{
1632
+ .logs-toolbar {{
1633
+ flex-wrap: wrap;
1634
+ }}
1635
+ }}
1636
+ </style>
1637
+ <main class="page logs-page">
1638
+ <div class="shell app-shell logs-shell">
1639
+ <div class="logs-content">
1640
+ <header class="logs-header">
1641
+ <div class="detail-title-row">
1642
+ {chat_icon()}
1643
+ <div>
1644
+ <h1 class="detail-title">Agent Debug Logs</h1>
1645
+ <div class="detail-meta">{html.escape(summary.title)} &nbsp;&middot;&nbsp; Real-time trace from Copilot CLI session events</div>
1646
+ </div>
1647
+ </div>
1648
+ <a class="ghost-link" href="/sessions/{summary.session_id}">Back To Summary</a>
1649
+ </header>
1650
+
1651
+ <div class="logs-toolbar">
1652
+ <label class="logs-filter" aria-label="Filter logs">
1653
+ <input id="log-filter" type="text" placeholder="Filter (e.g. text, tool name, event type)">
1654
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
1655
+ <path d="M2 3.25h12l-4.7 5.28v3.54L6.7 13V8.53L2 3.25Z" />
1656
+ </svg>
1657
+ </label>
1658
+ <div class="logs-filter-meta" id="log-filter-meta">{len(log_entries)} events</div>
1659
+ </div>
1660
+
1661
+ <section class="logs-main">
1662
+ <section class="logs-list-panel">
1663
+ <div class="logs-table">
1664
+ <div class="logs-table-head">
1665
+ <span>Created</span>
1666
+ <span>Name</span>
1667
+ <span>Details</span>
1668
+ </div>
1669
+ <div class="logs-list-empty" id="log-list-empty">
1670
+ <strong>No matching events</strong>
1671
+ Try another keyword to inspect this session trace.
1672
+ </div>
1673
+ <div class="logs-table-body" id="log-list">{rows or render_empty_log_list()}
1674
+ </div>
1675
+ </div>
1676
+ </section>
1677
+
1678
+ <aside class="logs-detail-panel">
1679
+ <div class="logs-detail-body" id="log-detail">{initial_detail}
1680
+ </div>
1681
+ </aside>
1682
+ </section>
1683
+ </div>
1684
+ {render_page_footer()}
1685
+ </div>
1686
+
1687
+ <div class="logs-templates" aria-hidden="true">
1688
+ <template id="log-empty-template">{render_empty_log_detail()}</template>
1689
+ {templates}
1690
+ </div>
1691
+
1692
+ <script>
1693
+ (() => {{
1694
+ const rows = Array.from(document.querySelectorAll('.log-row'));
1695
+ const sessionId = {json.dumps(summary.session_id)};
1696
+ const detailRoot = document.getElementById('log-detail');
1697
+ const logList = document.getElementById('log-list');
1698
+ const filterInput = document.getElementById('log-filter');
1699
+ const filterMeta = document.getElementById('log-filter-meta');
1700
+ const listEmptyState = document.getElementById('log-list-empty');
1701
+ const emptyTemplate = document.getElementById('log-empty-template');
1702
+ const templatesRoot = document.querySelector('.logs-templates');
1703
+ let eventSource = null;
1704
+ let isPolling = false;
1705
+
1706
+ function escapeHtml(value) {{
1707
+ return value
1708
+ .replace(/&/g, '&amp;')
1709
+ .replace(/</g, '&lt;')
1710
+ .replace(/>/g, '&gt;')
1711
+ .replace(/"/g, '&quot;')
1712
+ .replace(/'/g, '&#39;');
1713
+ }}
1714
+
1715
+ function escapeRegExp(value) {{
1716
+ return value.replace(/[][\\^$.*+?(){{}}|]/g, '\\$&');
1717
+ }}
1718
+
1719
+ function highlightText(value, query) {{
1720
+ const escapedValue = escapeHtml(value);
1721
+ if (!query) {{
1722
+ return escapedValue;
1723
+ }}
1724
+
1725
+ const pattern = new RegExp('(' + escapeRegExp(query) + ')', 'ig');
1726
+ return escapedValue.replace(pattern, '<mark class="log-highlight">$1</mark>');
1727
+ }}
1728
+
1729
+ function applyRowHighlight(row, query) {{
1730
+ const created = row.querySelector('.log-created');
1731
+ const name = row.querySelector('.log-name');
1732
+ const details = row.querySelector('.log-details');
1733
+
1734
+ if (created) {{
1735
+ created.innerHTML = highlightText(row.dataset.created || '', query);
1736
+ }}
1737
+ if (name) {{
1738
+ name.innerHTML = highlightText(row.dataset.name || '', query);
1739
+ }}
1740
+ if (details) {{
1741
+ details.innerHTML = highlightText(row.dataset.details || '', query);
1742
+ }}
1743
+ }}
1744
+
1745
+ function restoreDetailForRow(row) {{
1746
+ if (!row) {{
1747
+ detailRoot.innerHTML = emptyTemplate ? emptyTemplate.innerHTML : '';
1748
+ return;
1749
+ }}
1750
+
1751
+ const template = document.getElementById('log-detail-template-' + row.dataset.logIndex);
1752
+ detailRoot.innerHTML = template ? template.innerHTML : '';
1753
+ }}
1754
+
1755
+ function applyDetailHighlight(query) {{
1756
+ const activeRow = rows.find((row) => row.classList.contains('is-active') && !row.hidden) || null;
1757
+ restoreDetailForRow(activeRow);
1758
+ if (!query || !activeRow) {{
1759
+ return;
1760
+ }}
1761
+
1762
+ const selectors = [
1763
+ '.log-detail-title',
1764
+ '.log-detail-summary',
1765
+ '.log-section-body',
1766
+ '.log-detail-badge',
1767
+ '.log-section-title',
1768
+ ];
1769
+
1770
+ selectors.forEach((selector) => {{
1771
+ detailRoot.querySelectorAll(selector).forEach((node) => {{
1772
+ node.innerHTML = highlightText(node.textContent || '', query);
1773
+ }});
1774
+ }});
1775
+ }}
1776
+
1777
+ function visibleRows() {{
1778
+ return rows.filter((row) => !row.hidden);
1779
+ }}
1780
+
1781
+ function firstVisibleRow() {{
1782
+ return visibleRows()[0] || null;
1783
+ }}
1784
+
1785
+ function updateListEmptyState() {{
1786
+ if (!listEmptyState) {{
1787
+ return;
1788
+ }}
1789
+
1790
+ listEmptyState.classList.toggle('is-visible', visibleRows().length === 0);
1791
+ }}
1792
+
1793
+ function updateFilterMeta() {{
1794
+ if (!filterMeta) {{
1795
+ return;
1796
+ }}
1797
+
1798
+ const currentVisibleRows = visibleRows();
1799
+ const activeIndex = currentVisibleRows.findIndex((row) => row.classList.contains('is-active'));
1800
+ const current = activeIndex >= 0 ? activeIndex + 1 : 0;
1801
+ filterMeta.textContent = String(current) + ' / ' + String(currentVisibleRows.length) + ' matches';
1802
+ }}
1803
+
1804
+ function selectRow(row) {{
1805
+ rows.forEach((item) => item.classList.toggle('is-active', item === row));
1806
+ if (!row) {{
1807
+ restoreDetailForRow(null);
1808
+ updateListEmptyState();
1809
+ updateFilterMeta();
1810
+ return;
1811
+ }}
1812
+
1813
+ restoreDetailForRow(row);
1814
+ applyDetailHighlight(filterInput ? filterInput.value.trim() : '');
1815
+ updateListEmptyState();
1816
+ updateFilterMeta();
1817
+ window.location.hash = 'log-' + row.dataset.logIndex;
1818
+ }}
1819
+
1820
+ function moveSelection(offset) {{
1821
+ const currentVisibleRows = visibleRows();
1822
+ if (!currentVisibleRows.length) {{
1823
+ return;
1824
+ }}
1825
+
1826
+ const activeIndex = currentVisibleRows.findIndex((row) => row.classList.contains('is-active'));
1827
+ const nextIndex = activeIndex >= 0
1828
+ ? (activeIndex + offset + currentVisibleRows.length) % currentVisibleRows.length
1829
+ : 0;
1830
+ const nextRow = currentVisibleRows[nextIndex];
1831
+ selectRow(nextRow);
1832
+ nextRow.focus();
1833
+ nextRow.scrollIntoView({{ block: 'nearest' }});
1834
+ }}
1835
+
1836
+ function revealRow(row) {{
1837
+ if (!row) {{
1838
+ return;
1839
+ }}
1840
+
1841
+ row.focus();
1842
+ row.scrollIntoView({{ block: 'nearest' }});
1843
+ }}
1844
+
1845
+ function bindRow(row) {{
1846
+ row.addEventListener('click', () => selectRow(row));
1847
+ row.addEventListener('keydown', (event) => {{
1848
+ if (event.key === 'ArrowDown') {{
1849
+ event.preventDefault();
1850
+ moveSelection(1);
1851
+ }}
1852
+ if (event.key === 'ArrowUp') {{
1853
+ event.preventDefault();
1854
+ moveSelection(-1);
1855
+ }}
1856
+ }});
1857
+ }}
1858
+
1859
+ function createNodeFromMarkup(markup) {{
1860
+ const template = document.createElement('template');
1861
+ template.innerHTML = markup.trim();
1862
+ return template.content.firstElementChild;
1863
+ }}
1864
+
1865
+ function currentMaxLogIndex() {{
1866
+ return rows.reduce((maxIndex, row) => Math.max(maxIndex, Number(row.dataset.logIndex || '-1')), -1);
1867
+ }}
1868
+
1869
+ function shouldFollowTail() {{
1870
+ const query = filterInput ? filterInput.value.trim() : '';
1871
+ if (query) {{
1872
+ return false;
1873
+ }}
1874
+
1875
+ const activeRow = rows.find((row) => row.classList.contains('is-active')) || null;
1876
+ if (!activeRow) {{
1877
+ return true;
1878
+ }}
1879
+
1880
+ return rows.length > 0 && rows[rows.length - 1] === activeRow;
1881
+ }}
1882
+
1883
+ function appendLogEntry(entry) {{
1884
+ if (!logList || !templatesRoot) {{
1885
+ return null;
1886
+ }}
1887
+
1888
+ const rowNode = createNodeFromMarkup(entry.rowHtml || '');
1889
+ const templateNode = createNodeFromMarkup(entry.detailTemplateHtml || '');
1890
+ if (!rowNode || !templateNode) {{
1891
+ return null;
1892
+ }}
1893
+
1894
+ logList.appendChild(rowNode);
1895
+ templatesRoot.appendChild(templateNode);
1896
+ rows.push(rowNode);
1897
+ bindRow(rowNode);
1898
+ return rowNode;
1899
+ }}
1900
+
1901
+ async function refreshLogs() {{
1902
+ if (isPolling || document.hidden) {{
1903
+ return;
1904
+ }}
1905
+
1906
+ isPolling = true;
1907
+ try {{
1908
+ const response = await fetch('/sessions/' + encodeURIComponent(sessionId) + '/logs.json?after=' + String(currentMaxLogIndex()), {{
1909
+ headers: {{
1910
+ 'Accept': 'application/json',
1911
+ }},
1912
+ }});
1913
+
1914
+ if (!response.ok) {{
1915
+ return;
1916
+ }}
1917
+
1918
+ const payload = await response.json();
1919
+ const entries = Array.isArray(payload.entries) ? payload.entries : [];
1920
+ if (!entries.length) {{
1921
+ return;
1922
+ }}
1923
+
1924
+ const followTail = shouldFollowTail();
1925
+ const query = filterInput ? filterInput.value.trim() : '';
1926
+ let newestRow = null;
1927
+ entries.forEach((entry) => {{
1928
+ const appendedRow = appendLogEntry(entry);
1929
+ if (appendedRow) {{
1930
+ newestRow = appendedRow;
1931
+ }}
1932
+ }});
1933
+
1934
+ rows.forEach((row) => {{
1935
+ row.hidden = !row.dataset.search.includes(query.toLowerCase());
1936
+ applyRowHighlight(row, query);
1937
+ }});
1938
+
1939
+ if (followTail && newestRow && !newestRow.hidden) {{
1940
+ selectRow(newestRow);
1941
+ revealRow(newestRow);
1942
+ return;
1943
+ }}
1944
+
1945
+ applyDetailHighlight(query);
1946
+ updateListEmptyState();
1947
+ updateFilterMeta();
1948
+ }} catch (_error) {{
1949
+ return;
1950
+ }} finally {{
1951
+ isPolling = false;
1952
+ }}
1953
+ }}
1954
+
1955
+ rows.forEach((row) => bindRow(row));
1956
+
1957
+ detailRoot.addEventListener('click', async (event) => {{
1958
+ const button = event.target.closest('.log-section-copy');
1959
+ if (!button) {{
1960
+ return;
1961
+ }}
1962
+
1963
+ const section = button.closest('.log-section');
1964
+ const contentNode = section ? section.querySelector('.log-section-body') : null;
1965
+ if (!contentNode) {{
1966
+ return;
1967
+ }}
1968
+
1969
+ const originalLabel = button.dataset.label || 'Copy';
1970
+ try {{
1971
+ await navigator.clipboard.writeText(contentNode.textContent || '');
1972
+ button.textContent = 'Copied';
1973
+ button.classList.add('is-copied');
1974
+ }} catch (_error) {{
1975
+ button.textContent = 'Failed';
1976
+ }}
1977
+
1978
+ window.setTimeout(() => {{
1979
+ button.textContent = originalLabel;
1980
+ button.classList.remove('is-copied');
1981
+ }}, 1200);
1982
+ }});
1983
+
1984
+ if (filterInput) {{
1985
+ filterInput.addEventListener('input', () => {{
1986
+ const query = filterInput.value.trim();
1987
+ const normalizedQuery = query.toLowerCase();
1988
+
1989
+ rows.forEach((row) => {{
1990
+ row.hidden = !row.dataset.search.includes(normalizedQuery);
1991
+ applyRowHighlight(row, query);
1992
+ }});
1993
+
1994
+ updateListEmptyState();
1995
+
1996
+ const activeVisible = rows.find((row) => row.classList.contains('is-active') && !row.hidden);
1997
+ if (!activeVisible) {{
1998
+ selectRow(firstVisibleRow());
1999
+ return;
2000
+ }}
2001
+
2002
+ applyDetailHighlight(query);
2003
+ updateFilterMeta();
2004
+ }});
2005
+
2006
+ filterInput.addEventListener('keydown', (event) => {{
2007
+ if (event.key === 'ArrowDown') {{
2008
+ event.preventDefault();
2009
+ moveSelection(1);
2010
+ }}
2011
+ if (event.key === 'ArrowUp') {{
2012
+ event.preventDefault();
2013
+ moveSelection(-1);
2014
+ }}
2015
+ }});
2016
+ }}
2017
+
2018
+ const hashMatch = window.location.hash.match(/^#log-([0-9]+)$/);
2019
+ if (hashMatch) {{
2020
+ const rowFromHash = document.querySelector('.log-row[data-log-index="' + hashMatch[1] + '"]');
2021
+ if (rowFromHash) {{
2022
+ selectRow(rowFromHash);
2023
+ revealRow(rowFromHash);
2024
+ return;
2025
+ }}
2026
+ }}
2027
+
2028
+ selectRow(document.querySelector('.log-row.is-active') || firstVisibleRow());
2029
+ rows.forEach((row) => applyRowHighlight(row, ''));
2030
+ updateListEmptyState();
2031
+ updateFilterMeta();
2032
+
2033
+ function closeLogsStream() {{
2034
+ if (!eventSource) {{
2035
+ return;
2036
+ }}
2037
+
2038
+ eventSource.close();
2039
+ eventSource = null;
2040
+ }}
2041
+
2042
+ function openLogsStream() {{
2043
+ if (eventSource || document.hidden || typeof EventSource !== 'function') {{
2044
+ return;
2045
+ }}
2046
+
2047
+ eventSource = new EventSource('/sessions/' + encodeURIComponent(sessionId) + '/logs.events');
2048
+ eventSource.onmessage = () => {{
2049
+ refreshLogs();
2050
+ }};
2051
+ eventSource.onerror = () => {{
2052
+ return;
2053
+ }};
2054
+ }}
2055
+
2056
+ openLogsStream();
2057
+ document.addEventListener('visibilitychange', () => {{
2058
+ if (document.hidden) {{
2059
+ closeLogsStream();
2060
+ }} else {{
2061
+ refreshLogs();
2062
+ openLogsStream();
2063
+ }}
2064
+ }});
2065
+ window.addEventListener('beforeunload', () => {{
2066
+ closeLogsStream();
2067
+ }});
2068
+ }})();
2069
+ </script>
2070
+ </main>
2071
+ """
2072
+ return render_document(f"{summary.title} Logs - {PAGE_TITLE}", body)
2073
+
2074
+
2075
+ def build_logs_feed_payload(log_entries: list[SessionLogEntry], after_index: int = -1) -> dict[str, object]:
2076
+ entries = [entry for entry in log_entries if entry.index > after_index]
2077
+ return {
2078
+ "entries": [
2079
+ {
2080
+ "index": entry.index,
2081
+ "rowHtml": render_log_row(entry),
2082
+ "detailTemplateHtml": render_log_detail_template(entry),
2083
+ }
2084
+ for entry in entries
2085
+ ]
2086
+ }
2087
+
2088
+
2089
+ def build_missing_session_page(session_id: str) -> str:
2090
+ body = f"""
2091
+ <main class="page detail-page">
2092
+ <div class="shell app-shell detail-shell">
2093
+ <div class="empty-state">
2094
+ <h1 class="empty-title">Session not found</h1>
2095
+ <p class="empty-copy">No readable Copilot CLI session metadata was found for <strong>{html.escape(session_id)}</strong>.</p>
2096
+ <p class="empty-copy"><a class="ghost-link" href="/">Back To Sessions</a></p>
2097
+ </div>
2098
+ {render_page_footer()}
2099
+ </div>
2100
+ </main>
2101
+ """
2102
+ return render_document(PAGE_TITLE, body)
2103
+
2104
+
2105
+ def render_page_footer() -> str:
2106
+ return f"""
2107
+ <footer class="site-footer">
2108
+ <div class="site-footer-copy">
2109
+ Powered by <a href="{html.escape(REPOSITORY_URL, quote=True)}" target="_blank" rel="noreferrer">Copilot CLI Trace Deck</a><br>
2110
+ by <a href="{html.escape(AUTHOR_URL, quote=True)}" target="_blank" rel="noreferrer">Lanbao Shen</a>
2111
+ </div>
2112
+ </footer>"""
2113
+
2114
+
2115
+ def build_index_feed_payload(session_previews: list[SessionPreview]) -> dict[str, object]:
2116
+ return {
2117
+ "count": len(session_previews),
2118
+ "itemsHtml": render_session_list_markup(session_previews),
2119
+ }
2120
+
2121
+
2122
+ def render_session_list_markup(session_previews: list[SessionPreview]) -> str:
2123
+ return "\n".join(render_session_item(session) for session in session_previews)
2124
+
2125
+
2126
+ def render_session_item(session: SessionPreview) -> str:
2127
+ title = html.escape(session.title)
2128
+ badge = '<span class="badge">Active</span>' if session.is_active else ""
2129
+ return f"""
2130
+ <li>
2131
+ <a class="session-link" href="/sessions/{session.session_id}" aria-label="Open {title}">
2132
+ <div class="session-main">
2133
+ {chat_icon()}
2134
+ <p class="session-title">{title}</p>
2135
+ </div>
2136
+ {badge}
2137
+ </a>
2138
+ </li>"""
2139
+
2140
+
2141
+ def render_stat_card(label: str, value: int, value_id: str | None = None) -> str:
2142
+ return render_live_stat_card(label, value, value_id=value_id)
2143
+
2144
+
2145
+ def render_live_stat_card(label: str, value: int, value_id: str | None = None) -> str:
2146
+ value_id_attr = f' id="{html.escape(value_id, quote=True)}"' if value_id else ''
2147
+ return f"""
2148
+ <article class="stat-card">
2149
+ <span class="stat-label">{html.escape(label)}</span>
2150
+ <span class="stat-value"{value_id_attr}>{format_number(value)}</span>
2151
+ </article>"""
2152
+
2153
+
2154
+ def render_action_chip(label: str, href: str = "#") -> str:
2155
+ return f'<a class="action-chip" href="{html.escape(href, quote=True)}">{html.escape(label)}</a>'
2156
+
2157
+
2158
+ def render_flow_node(session_id: str, node: SessionFlowNode) -> str:
2159
+ kind_class = f"is-{node.kind}"
2160
+ status_class = "is-error" if node.status == "error" else ""
2161
+ pill = f'+{node.count - 1} more' if node.kind == 'group' and node.count > 1 else ''
2162
+ pill_markup = f'<span class="flow-card-pill">{html.escape(pill)}</span>' if pill else ''
2163
+ meta_markup = f'<div class="flow-card-meta">{html.escape(node.meta)}</div>' if node.meta else ''
2164
+ card_markup = f"""
2165
+ <div class="flow-card {kind_class} {status_class}">
2166
+ <div class="flow-card-top">
2167
+ <div class="flow-card-title-wrap">
2168
+ <h2 class="flow-card-title">{html.escape(node.title)}</h2>
2169
+ <div class="flow-card-subtitle">{html.escape(node.subtitle)}</div>
2170
+ </div>
2171
+ {pill_markup}
2172
+ </div>
2173
+ <div class="flow-card-body">{html.escape(node.detail)}</div>
2174
+ {meta_markup}
2175
+ </div>"""
2176
+ if node.log_index is not None:
2177
+ return f"""
2178
+ <article class="flow-step" data-flow-index="{node.index}">
2179
+ <a class="flow-link" href="/sessions/{html.escape(session_id, quote=True)}/logs#log-{node.log_index}" aria-label="Open {html.escape(node.title)} in logs">
2180
+ {card_markup}
2181
+ </a>
2182
+ </article>"""
2183
+ return f"""
2184
+ <article class="flow-step" data-flow-index="{node.index}">
2185
+ {card_markup}
2186
+ </article>"""
2187
+
2188
+
2189
+ def render_empty_flow_state() -> str:
2190
+ return """
2191
+ <div class="flow-empty">
2192
+ <strong>No flow nodes available</strong>
2193
+ This session does not have enough structured events to reconstruct an agent flow chart yet.
2194
+ </div>"""
2195
+
2196
+
2197
+ def build_session_snapshot_payload(summary: SessionSummary) -> dict[str, str]:
2198
+ return {
2199
+ "detailMeta": build_session_detail_meta(summary),
2200
+ "sessionType": summary.session_type,
2201
+ "location": summary.location,
2202
+ "status": summary.status,
2203
+ "createdLabel": summary.created_label,
2204
+ "updatedLabel": summary.updated_label,
2205
+ "modelTurns": format_number(summary.model_turns),
2206
+ "toolCalls": format_number(summary.tool_calls),
2207
+ "totalInputTokens": format_number(summary.total_input_tokens),
2208
+ "totalOutputTokens": format_number(summary.total_output_tokens),
2209
+ "totalCachedInputTokens": format_number(summary.total_cached_input_tokens),
2210
+ "totalTokens": format_number(summary.total_tokens),
2211
+ "errorCount": format_number(summary.error_count),
2212
+ }
2213
+
2214
+
2215
+ def build_flow_feed_payload(session_id: str, summary: SessionSummary, flow_nodes: list[SessionFlowNode], after_index: int = -1) -> dict[str, object]:
2216
+ nodes = [node for node in flow_nodes if node.index > after_index]
2217
+ return {
2218
+ "nodeCount": len(flow_nodes),
2219
+ "modelName": summary.model_name,
2220
+ "toolCalls": summary.tool_calls,
2221
+ "errorCount": summary.error_count,
2222
+ "nodes": [
2223
+ {
2224
+ "index": node.index,
2225
+ "html": render_flow_node(session_id, node),
2226
+ }
2227
+ for node in nodes
2228
+ ],
2229
+ }
2230
+
2231
+
2232
+ def render_log_row(entry: SessionLogEntry, selected: bool = False) -> str:
2233
+ classes = "log-row is-error" if entry.is_error else "log-row"
2234
+ if selected:
2235
+ classes += " is-active"
2236
+ section_search = " ".join(f"{section.title} {section.content}" for section in entry.sections)
2237
+ search_value = " ".join([entry.created_label, entry.name, entry.event_type, entry.details, section_search]).lower()
2238
+ return f"""
2239
+ <button class="{classes}" type="button" data-log-index="{entry.index}" data-error="{'true' if entry.is_error else 'false'}" data-event-type="{html.escape(entry.event_type, quote=True)}" data-search="{html.escape(search_value, quote=True)}" data-created="{html.escape(entry.created_label, quote=True)}" data-name="{html.escape(entry.name, quote=True)}" data-details="{html.escape(entry.details, quote=True)}">
2240
+ <span class="log-created">{html.escape(entry.created_label)}</span>
2241
+ <span class="log-name">{html.escape(entry.name)}</span>
2242
+ <span class="log-details">{html.escape(entry.details)}</span>
2243
+ </button>"""
2244
+
2245
+
2246
+ def render_log_detail_template(entry: SessionLogEntry) -> str:
2247
+ return f'<template id="log-detail-template-{entry.index}">{render_log_detail(entry)}</template>'
2248
+
2249
+
2250
+ def render_log_detail(entry: SessionLogEntry) -> str:
2251
+ sections = "\n".join(render_log_section(section) for section in entry.sections)
2252
+ badge_class = "log-detail-badge is-error" if entry.is_error else "log-detail-badge"
2253
+ return f"""
2254
+ <div class="log-detail-top">
2255
+ <div class="log-detail-kicker">Event Inspector</div>
2256
+ <h2 class="log-detail-title">{html.escape(entry.name)}</h2>
2257
+ <div class="log-detail-meta">
2258
+ <span class="{badge_class}">{html.escape(entry.event_type)}</span>
2259
+ <span>{html.escape(entry.created_label)}</span>
2260
+ </div>
2261
+ <p class="log-detail-summary">{html.escape(entry.details)}</p>
2262
+ </div>
2263
+ {sections}
2264
+ """
2265
+
2266
+
2267
+ def render_log_section(section: SessionLogSection) -> str:
2268
+ return f"""
2269
+ <section class="log-section">
2270
+ <div class="log-section-header">
2271
+ <h3 class="log-section-title">{html.escape(section.title)}</h3>
2272
+ <button class="log-section-copy" type="button" data-label="Copy">Copy</button>
2273
+ </div>
2274
+ <pre class="log-section-body">{html.escape(section.content)}</pre>
2275
+ </section>"""
2276
+
2277
+
2278
+ def render_empty_log_detail() -> str:
2279
+ return """
2280
+ <div class="logs-empty">
2281
+ <strong>No log selected</strong>
2282
+ Refine the filter or choose another event from the list.
2283
+ </div>"""
2284
+
2285
+
2286
+ def render_empty_log_list() -> str:
2287
+ return """
2288
+ <div class="logs-empty">
2289
+ <strong>No events found</strong>
2290
+ This session does not have readable debug log events yet.
2291
+ </div>"""
2292
+
2293
+
2294
+ def build_session_detail_meta(summary: SessionSummary) -> str:
2295
+ return f"Model: {summary.model_name or 'Unknown'} · Repository: {summary.repository or '-'} · Branch: {summary.branch or '-'}"
2296
+
2297
+
2298
+ def chat_icon() -> str:
2299
+ return """<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.55" stroke-linecap="round" stroke-linejoin="round">
2300
+ <path d="M6.25 5.25h11.5A2.25 2.25 0 0 1 20 7.5v7A2.25 2.25 0 0 1 17.75 16.75H11l-3.9 3.23c-.72.6-1.85.08-1.85-.86v-2.37A2.25 2.25 0 0 1 3 14.5v-7a2.25 2.25 0 0 1 2.25-2.25Z" />
2301
+ </svg>"""
2302
+
2303
+
2304
+ def format_number(value: int) -> str:
2305
+ return f"{value:,}"