tylor-mcp 1.0.0

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.
Files changed (101) hide show
  1. package/.aws-setup.sh +25 -0
  2. package/.claude-plugin/plugin.json +22 -0
  3. package/.mcp.json +12 -0
  4. package/AGENTS.md +93 -0
  5. package/CLAUDE.md +99 -0
  6. package/CLAUDE_PLATFORM_AWS_SETUP.md +105 -0
  7. package/LICENSE +21 -0
  8. package/README.md +146 -0
  9. package/assets/tylor_logo.png +0 -0
  10. package/assets/tylor_threads_concept.png +0 -0
  11. package/bin/tylor.js +23 -0
  12. package/hooks/kill-thread-trigger.sh +7 -0
  13. package/hooks/post-tool-use-code-index.sh +7 -0
  14. package/hooks/session-checkpoint.sh +7 -0
  15. package/hooks/session-start.sh +7 -0
  16. package/install.py +401 -0
  17. package/install.sh +260 -0
  18. package/package.json +24 -0
  19. package/pytest.ini +2 -0
  20. package/registry.json +26 -0
  21. package/server/.env.example +24 -0
  22. package/server/__init__.py +0 -0
  23. package/server/config.py +89 -0
  24. package/server/main.py +93 -0
  25. package/server/personas/analyst.md +15 -0
  26. package/server/personas/ceo.md +14 -0
  27. package/server/personas/code_agent.md +15 -0
  28. package/server/personas/cto.md +14 -0
  29. package/server/provision.py +260 -0
  30. package/server/provision_opensearch.py +154 -0
  31. package/server/requirements.txt +26 -0
  32. package/server/storage/__init__.py +0 -0
  33. package/server/storage/dynamo.py +399 -0
  34. package/server/storage/json_store.py +359 -0
  35. package/server/storage/opensearch.py +194 -0
  36. package/server/storage/s3.py +96 -0
  37. package/server/storage/tests/__init__.py +0 -0
  38. package/server/storage/tests/test_dynamo.py +452 -0
  39. package/server/storage/tests/test_json_store.py +226 -0
  40. package/server/storage/tests/test_opensearch.py +270 -0
  41. package/server/storage/tests/test_s3.py +125 -0
  42. package/server/tests/__init__.py +0 -0
  43. package/server/tests/test_install.py +606 -0
  44. package/server/tests/test_isolation.py +90 -0
  45. package/server/tests/test_ui_server.py +385 -0
  46. package/server/tests/test_ui_shader_background.py +52 -0
  47. package/server/tests/test_ui_story_6_3.py +105 -0
  48. package/server/tools/__init__.py +0 -0
  49. package/server/tools/_mcp.py +4 -0
  50. package/server/tools/agents.py +160 -0
  51. package/server/tools/ecc/__init__.py +1 -0
  52. package/server/tools/ecc/data.py +35 -0
  53. package/server/tools/ecc/diagrams.py +23 -0
  54. package/server/tools/ecc/pipeline.py +24 -0
  55. package/server/tools/ecc/presentation.py +24 -0
  56. package/server/tools/ecc/web.py +23 -0
  57. package/server/tools/executor.py +880 -0
  58. package/server/tools/harness.py +330 -0
  59. package/server/tools/help.py +162 -0
  60. package/server/tools/hooks.py +357 -0
  61. package/server/tools/personas.py +110 -0
  62. package/server/tools/registry.py +195 -0
  63. package/server/tools/router.py +117 -0
  64. package/server/tools/skill_installer.py +230 -0
  65. package/server/tools/summarizer.py +168 -0
  66. package/server/tools/tests/__init__.py +0 -0
  67. package/server/tools/tests/test_agents.py +246 -0
  68. package/server/tools/tests/test_code_index.py +108 -0
  69. package/server/tools/tests/test_ecc_tools.py +51 -0
  70. package/server/tools/tests/test_executor.py +584 -0
  71. package/server/tools/tests/test_help_agent101.py +149 -0
  72. package/server/tools/tests/test_hooks.py +124 -0
  73. package/server/tools/tests/test_kill_thread.py +125 -0
  74. package/server/tools/tests/test_new_thread_list_threads.py +293 -0
  75. package/server/tools/tests/test_personas.py +52 -0
  76. package/server/tools/tests/test_recall_memory.py +55 -0
  77. package/server/tools/tests/test_registry_client.py +308 -0
  78. package/server/tools/tests/test_router.py +263 -0
  79. package/server/tools/tests/test_skill_installer.py +174 -0
  80. package/server/tools/tests/test_switch_thread.py +163 -0
  81. package/server/tools/tests/test_thread_command_skills.py +54 -0
  82. package/server/tools/tests/test_thread_resolver.py +165 -0
  83. package/server/tools/tests/test_tier1_schema.py +296 -0
  84. package/server/tools/thread_resolver.py +75 -0
  85. package/server/tools/tylor.py +374 -0
  86. package/server/tools/ui.py +38 -0
  87. package/server/ui_server.py +292 -0
  88. package/server/validate.py +237 -0
  89. package/skills/add-skill/SKILL.md +37 -0
  90. package/skills/afk-status/SKILL.md +20 -0
  91. package/skills/bmad/SKILL.md +14 -0
  92. package/skills/help-agent101/SKILL.md +48 -0
  93. package/skills/kill-thread/SKILL.md +35 -0
  94. package/skills/list-threads/SKILL.md +35 -0
  95. package/skills/new-thread/SKILL.md +35 -0
  96. package/skills/recall/SKILL.md +39 -0
  97. package/skills/run/SKILL.md +33 -0
  98. package/skills/set-sandbox/SKILL.md +38 -0
  99. package/skills/switch-thread/SKILL.md +38 -0
  100. package/ui/claude-logo.png +0 -0
  101. package/ui/index.html +1314 -0
package/ui/index.html ADDED
@@ -0,0 +1,1314 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8"/>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
6
+ <title>TYLOR — Thread Visualizer</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&display=swap" rel="stylesheet">
9
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
10
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js"></script>
11
+ <style>
12
+ :root {
13
+ --bg: #070714;
14
+ --font: 'JetBrains Mono', monospace;
15
+ --c-active: #00ffb3;
16
+ --c-await: #fbbf24;
17
+ --c-run: #a78bfa;
18
+ --c-idle: rgba(200,210,255,0.5);
19
+ --c-killed: rgba(239,80,80,0.7);
20
+ --c-project: rgba(255,255,255,0.9);
21
+ --c-claude: rgba(210,168,84,0.95);
22
+ }
23
+ *{box-sizing:border-box;margin:0;padding:0}
24
+ html,body{width:100vw;height:100vh;overflow:hidden;background:var(--bg);font-family:var(--font)}
25
+
26
+ /* ── Chrome ── */
27
+ #chrome{
28
+ position:fixed;top:0;left:0;right:0;height:54px;z-index:20;
29
+ display:flex;align-items:center;justify-content:space-between;
30
+ padding:0 24px;
31
+ background:rgba(7,7,20,0.8);
32
+ backdrop-filter:blur(16px);
33
+ border-bottom:1px solid rgba(255,255,255,0.06);
34
+ }
35
+ .wm-logo{
36
+ font-size:32px;font-weight:700;
37
+ color:transparent;
38
+ -webkit-text-stroke:1.6px rgba(139,92,246,1);
39
+ filter:drop-shadow(0 0 5px rgba(139,92,246,0.8)) drop-shadow(0 0 14px rgba(139,92,246,0.35));
40
+ letter-spacing:-1px;
41
+ }
42
+ #chrome-right{display:flex;align-items:center;gap:20px}
43
+ #legend{display:flex;gap:14px;align-items:center}
44
+ .leg{display:flex;align-items:center;gap:5px;font-size:9.5px;color:rgba(255,255,255,0.5);letter-spacing:.05em}
45
+ .leg-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
46
+ #tc{font-size:11px;color:rgba(255,255,255,0.35);letter-spacing:.06em}
47
+ #tc b{font-size:22px;font-weight:700;color:rgba(160,120,255,0.9);display:block;line-height:1;
48
+ filter:drop-shadow(0 0 6px rgba(139,92,246,0.6))}
49
+
50
+ /* ── SVG canvas ── */
51
+ #viz{position:fixed;top:54px;left:0;right:0;bottom:36px;z-index:1;background:transparent}
52
+
53
+ /* ── Detail panel ── */
54
+ #panel{
55
+ position:fixed;top:54px;right:0;bottom:36px;width:360px;z-index:15;
56
+ background:rgba(15,15,35,0.82);
57
+ backdrop-filter:blur(18px);-webkit-backdrop-filter:blur(18px);
58
+ border-left:1px solid rgba(139,92,246,0.3);
59
+ display:flex;flex-direction:column;
60
+ transform:translateX(100%);
61
+ transition:transform 250ms ease-out;
62
+ pointer-events:none;
63
+ }
64
+ #panel.open{transform:translateX(0);pointer-events:all}
65
+
66
+ .ph{
67
+ padding:18px 20px 14px;flex-shrink:0;
68
+ border-bottom:1px solid rgba(139,92,246,0.12);
69
+ }
70
+ .ph-top{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;margin-bottom:8px}
71
+ .ph-title{font-size:14px;font-weight:700;color:rgba(255,255,255,0.95);line-height:1.3;flex:1}
72
+ .ph-close{
73
+ background:none;border:none;color:rgba(255,255,255,0.35);
74
+ font-family:var(--font);font-size:18px;cursor:pointer;line-height:1;
75
+ padding:2px 4px;border-radius:4px;transition:color .15s;flex-shrink:0;
76
+ }
77
+ .ph-close:hover{color:rgba(255,255,255,0.9)}
78
+ .ph-meta{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
79
+ .ph-badge{
80
+ display:inline-flex;align-items:center;gap:4px;
81
+ padding:2px 8px;border-radius:10px;font-size:9px;font-weight:700;
82
+ letter-spacing:.1em;text-transform:uppercase;border:1px solid;
83
+ }
84
+ .ph-ts{font-size:10px;color:rgba(255,255,255,0.3);letter-spacing:.03em}
85
+
86
+ /* Load earlier button */
87
+ #load-earlier{
88
+ flex-shrink:0;margin:10px 20px 0;
89
+ background:rgba(139,92,246,0.08);border:1px solid rgba(139,92,246,0.22);
90
+ border-radius:8px;padding:7px 14px;font-size:10px;font-weight:600;
91
+ color:rgba(200,170,255,0.85);font-family:var(--font);
92
+ cursor:pointer;letter-spacing:.06em;transition:background .15s;
93
+ display:none;
94
+ }
95
+ #load-earlier:hover{background:rgba(139,92,246,0.16)}
96
+
97
+ /* Messages list */
98
+ #msgs{
99
+ flex:1;overflow-y:auto;padding:12px 20px 16px;
100
+ display:flex;flex-direction:column;gap:10px;
101
+ scrollbar-width:thin;scrollbar-color:rgba(139,92,246,0.2) transparent;
102
+ }
103
+ #msgs::-webkit-scrollbar{width:3px}
104
+ #msgs::-webkit-scrollbar-thumb{background:rgba(139,92,246,0.22);border-radius:2px}
105
+
106
+ /* Message items */
107
+ .msg{display:flex;flex-direction:column;gap:4px;opacity:1;transition:opacity .2s}
108
+ .msg-head{display:flex;align-items:center;gap:7px}
109
+ .role-chip{
110
+ font-size:8.5px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;
111
+ padding:1px 7px;border-radius:8px;
112
+ }
113
+ .role-user{background:rgba(0,255,179,0.1);color:rgba(0,255,179,0.85);border:1px solid rgba(0,255,179,0.25)}
114
+ .role-assistant{background:rgba(139,92,246,0.1);color:rgba(180,140,255,0.85);border:1px solid rgba(139,92,246,0.25)}
115
+ .role-tool{background:rgba(251,191,36,0.1);color:rgba(251,191,36,0.85);border:1px solid rgba(251,191,36,0.25)}
116
+ .msg-ts{font-size:9.5px;color:rgba(255,255,255,0.25)}
117
+ .msg-body{
118
+ font-size:11.5px;line-height:1.6;color:rgba(255,255,255,0.65);
119
+ padding-left:10px;border-left:1.5px solid rgba(255,255,255,0.07);
120
+ }
121
+ .msg-body.role-user-body{border-color:rgba(0,255,179,0.18)}
122
+ .msg-body.role-assistant-body{border-color:rgba(139,92,246,0.2)}
123
+ .msg-body p{margin:0 0 4px}
124
+ .msg-body code{font-family:var(--font);font-size:10.5px;
125
+ background:rgba(139,92,246,0.1);padding:1px 5px;border-radius:3px}
126
+ .msg-body pre{background:rgba(0,0,0,0.3);border-radius:6px;padding:8px;
127
+ overflow-x:auto;margin:4px 0}
128
+ .msg-sep{height:1px;background:rgba(139,92,246,0.07);flex-shrink:0}
129
+
130
+ /* Content cross-fade */
131
+ #msgs.fading{opacity:0;transition:opacity 100ms}
132
+ #msgs.fading-in{opacity:1;transition:opacity 150ms}
133
+
134
+ /* Responsive: full-width below 900px */
135
+ @media(max-width:900px){
136
+ #panel{width:100%;border-left:none;border-top:1px solid rgba(139,92,246,0.3)}
137
+ }
138
+
139
+ /* ── Node labels ── */
140
+ .node-label{
141
+ font-family:var(--font);
142
+ pointer-events:none;
143
+ dominant-baseline:middle;
144
+ }
145
+
146
+ /* ── Tooltip ── */
147
+ #tip{
148
+ position:fixed;z-index:30;display:none;pointer-events:none;
149
+ background:rgba(8,8,22,0.95);backdrop-filter:blur(20px);
150
+ border:1px solid rgba(139,92,246,0.3);border-radius:8px;
151
+ padding:10px 14px;font-size:11px;font-family:var(--font);
152
+ color:rgba(255,255,255,0.75);max-width:220px;
153
+ }
154
+ #tip strong{color:#fff;font-size:12px;display:block;margin-bottom:4px}
155
+ #tip .tr{display:flex;justify-content:space-between;gap:12px;margin-top:2px}
156
+ #tip .tl{color:rgba(255,255,255,0.35);font-size:9.5px;text-transform:uppercase;letter-spacing:.06em}
157
+
158
+ /* ── Health ── */
159
+ #health{
160
+ position:fixed;bottom:8px;right:16px;z-index:20;
161
+ display:flex;align-items:center;gap:5px;
162
+ font-size:9.5px;font-family:var(--font);color:rgba(255,255,255,0.3);
163
+ }
164
+ .hdot{width:6px;height:6px;border-radius:50%;animation:hb 2.8s ease-in-out infinite}
165
+ @keyframes hb{0%,100%{opacity:.5}50%{opacity:1}}
166
+ @keyframes ring-pulse{0%{transform:translate(-50%,-50%) scale(1);opacity:.45}100%{transform:translate(-50%,-50%) scale(1.8);opacity:0}}
167
+ .hdot.ok{background:var(--c-active);box-shadow:0 0 6px rgba(0,255,179,0.5)}
168
+ .hdot.warn{background:var(--c-await)}
169
+ .hdot.err{background:rgba(239,68,68,0.9);animation:none}
170
+
171
+ /* ── Project switcher ── */
172
+ #switcher{
173
+ position:fixed;bottom:8px;left:50%;transform:translateX(-50%);z-index:20;
174
+ display:flex;gap:6px;
175
+ background:rgba(10,10,28,0.85);backdrop-filter:blur(12px);
176
+ border:1px solid rgba(255,255,255,0.08);border-radius:20px;padding:4px 6px;
177
+ }
178
+ .sp{
179
+ padding:4px 12px;border-radius:14px;font-size:9.5px;font-weight:600;
180
+ letter-spacing:.08em;text-transform:uppercase;cursor:pointer;
181
+ border:1px solid transparent;color:rgba(255,255,255,0.4);background:transparent;
182
+ font-family:var(--font);transition:all .2s;
183
+ }
184
+ .sp:hover{color:rgba(255,255,255,0.7);border-color:rgba(255,255,255,0.15)}
185
+ .sp.on{background:rgba(255,255,255,0.1);border-color:rgba(255,255,255,0.28);color:rgba(255,255,255,0.92)}
186
+ .sp-dot{display:inline-block;width:5px;height:5px;border-radius:50%;margin-right:5px;vertical-align:middle}
187
+ </style>
188
+ </head>
189
+ <body>
190
+
191
+ <!-- Chrome -->
192
+ <div id="chrome">
193
+ <div class="wm-logo">Tylor</div>
194
+ <div id="chrome-right">
195
+ <div id="legend">
196
+ <div class="leg"><div class="leg-dot" style="background:var(--c-active);box-shadow:0 0 5px rgba(0,255,179,0.6)"></div>active</div>
197
+ <div class="leg"><div class="leg-dot" style="background:var(--c-await);box-shadow:0 0 5px rgba(251,191,36,0.6)"></div>awaiting</div>
198
+ <div class="leg"><div class="leg-dot" style="background:var(--c-run)"></div>running</div>
199
+ <div class="leg"><div class="leg-dot" style="background:rgba(200,210,255,0.45)"></div>idle</div>
200
+ <div class="leg"><div class="leg-dot" style="background:var(--c-killed)"></div>killed</div>
201
+ </div>
202
+ <div id="tc"><b id="tc-n">0</b><span id="tc-l">threads</span></div>
203
+ </div>
204
+ </div>
205
+
206
+ <!-- Main SVG -->
207
+ <svg id="viz" style="background:transparent"></svg>
208
+
209
+ <!-- Tooltip -->
210
+ <div id="tip">
211
+ <strong id="tip-title"></strong>
212
+ <div class="tr"><span class="tl">project</span><span id="tip-proj"></span></div>
213
+ <div class="tr"><span class="tl">status</span><span id="tip-status"></span></div>
214
+ <div class="tr"><span class="tl">messages</span><span id="tip-msgs"></span></div>
215
+ </div>
216
+
217
+ <!-- Detail panel -->
218
+ <div id="panel">
219
+ <div class="ph">
220
+ <div class="ph-top">
221
+ <div class="ph-title" id="panel-title">Thread</div>
222
+ <button class="ph-close" id="panel-close" onclick="closePanel()">×</button>
223
+ </div>
224
+ <div class="ph-meta">
225
+ <span class="ph-badge" id="panel-badge"></span>
226
+ <span class="ph-ts" id="panel-ts"></span>
227
+ </div>
228
+ </div>
229
+ <button id="load-earlier" onclick="loadEarlier()">↑ Load earlier messages</button>
230
+ <div id="msgs"></div>
231
+ </div>
232
+
233
+ <!-- Project switcher -->
234
+ <div id="switcher"></div>
235
+
236
+ <!-- Health -->
237
+ <div id="health"><div class="hdot ok" id="hdot"></div><span id="hmsg">connected</span></div>
238
+
239
+ <script>
240
+ // ── Constants ─────────────────────────────────
241
+ const API = 'http://localhost:8765';
242
+ const WS = 'ws://localhost:8765/ws/threads';
243
+
244
+ const STATUS_COLOR = {
245
+ active: '#00ffb3',
246
+ awaiting: '#fbbf24',
247
+ running: '#a78bfa',
248
+ idle: 'rgba(200,210,255,0.55)',
249
+ killed: 'rgba(239,80,80,0.75)',
250
+ };
251
+ const STATUS_GLOW = {
252
+ active: '0 0 14px rgba(0,255,179,0.5), 0 0 28px rgba(0,255,179,0.2)',
253
+ awaiting: '0 0 12px rgba(251,191,36,0.45)',
254
+ running: '0 0 10px rgba(167,139,250,0.35)',
255
+ idle: 'none',
256
+ killed: '0 0 10px rgba(239,80,80,0.3)',
257
+ };
258
+
259
+ // ── Per-thread unique color ────────────────────
260
+ // 12 visually distinct hues spread around the wheel, all vivid enough
261
+ // to read on the dark background.
262
+ const THREAD_HUES = [195,155,270,30,340,90,220,60,310,120,0,240];
263
+ function threadColor(id, alpha=1){
264
+ // Simple djb2-style hash on the thread id string
265
+ let h = 5381;
266
+ for(let i=0;i<id.length;i++) h = ((h<<5)+h) ^ id.charCodeAt(i);
267
+ const hue = THREAD_HUES[Math.abs(h) % THREAD_HUES.length];
268
+ return alpha<1
269
+ ? `hsla(${hue},85%,65%,${alpha})`
270
+ : `hsl(${hue},85%,65%)`;
271
+ }
272
+
273
+ // ── Persistent node positions (survive refresh) ────────────────────
274
+ const POS_KEY = 'tylor_node_positions';
275
+ function loadPositions(){ try{ return JSON.parse(localStorage.getItem(POS_KEY)||'{}'); }catch{ return {}; } }
276
+ function savePosition(id, fx, fy){ const p=loadPositions(); p[id]={fx,fy}; localStorage.setItem(POS_KEY,JSON.stringify(p)); }
277
+ function clearPosition(id){ const p=loadPositions(); delete p[id]; localStorage.setItem(POS_KEY,JSON.stringify(p)); }
278
+
279
+ // ── SVG setup ─────────────────────────────────
280
+ const svgEl = document.getElementById('viz');
281
+ const W = window.innerWidth, H = window.innerHeight - 54 - 36;
282
+ svgEl.setAttribute('width', W);
283
+ svgEl.setAttribute('height', H);
284
+ svgEl.setAttribute('viewBox', `0 0 ${W} ${H}`);
285
+
286
+ const svg = d3.select('#viz');
287
+
288
+ // Defs: gradients, filters
289
+ const defs = svg.append('defs');
290
+
291
+ // Glow filter for links
292
+ const glowF = defs.append('filter').attr('id','link-glow').attr('x','-50%').attr('y','-50%').attr('width','200%').attr('height','200%');
293
+ glowF.append('feGaussianBlur').attr('stdDeviation','2').attr('result','b');
294
+ const fm = glowF.append('feMerge');
295
+ fm.append('feMergeNode').attr('in','b');
296
+ fm.append('feMergeNode').attr('in','SourceGraphic');
297
+
298
+ // Node glow filter
299
+ const nodeF = defs.append('filter').attr('id','node-glow').attr('x','-80%').attr('y','-80%').attr('width','360%').attr('height','360%');
300
+ nodeF.append('feGaussianBlur').attr('stdDeviation','4').attr('result','b');
301
+ const fm2 = nodeF.append('feMerge');
302
+ fm2.append('feMergeNode').attr('in','b');
303
+ fm2.append('feMergeNode').attr('in','SourceGraphic');
304
+
305
+ // Layer order: links first, then nodes
306
+ const linkG = svg.append('g').attr('class','links');
307
+ const nodeG = svg.append('g').attr('class','nodes');
308
+ const labelG = svg.append('g').attr('class','labels');
309
+
310
+ // ── Sparkle background ────────────────────────
311
+ const bgCanvas = document.createElement('canvas');
312
+ bgCanvas.style.cssText = 'position:fixed;top:54px;left:0;z-index:0;pointer-events:none';
313
+ bgCanvas.width = W; bgCanvas.height = H;
314
+ document.body.insertBefore(bgCanvas, document.getElementById('viz'));
315
+ const bgCtx = bgCanvas.getContext('2d');
316
+ const STARS = Array.from({length:90},()=>({
317
+ x:Math.random()*W, y:Math.random()*H,
318
+ vx:(Math.random()-.5)*.18, vy:(Math.random()-.5)*.18,
319
+ r:Math.random()*1.2+.3, ph:Math.random()*Math.PI*2
320
+ }));
321
+ function drawBg(now=0){
322
+ bgCtx.clearRect(0,0,W,H);
323
+ STARS.forEach(s=>{
324
+ s.x+=s.vx; s.y+=s.vy; s.ph+=.008;
325
+ if(s.x<0||s.x>W) s.vx*=-1;
326
+ if(s.y<0||s.y>H) s.vy*=-1;
327
+ });
328
+ for(let i=0;i<STARS.length;i++) for(let j=i+1;j<STARS.length;j++){
329
+ const dx=STARS[i].x-STARS[j].x, dy=STARS[i].y-STARS[j].y, d=Math.sqrt(dx*dx+dy*dy);
330
+ if(d<100){
331
+ bgCtx.beginPath();bgCtx.moveTo(STARS[i].x,STARS[i].y);bgCtx.lineTo(STARS[j].x,STARS[j].y);
332
+ bgCtx.strokeStyle=`rgba(80,50,180,${.12*(1-d/100)})`;bgCtx.lineWidth=.5;bgCtx.stroke();
333
+ }
334
+ }
335
+ STARS.forEach(s=>{
336
+ const tw=(Math.sin(s.ph)+1)*.5;
337
+ bgCtx.beginPath();bgCtx.arc(s.x,s.y,s.r*(0.6+tw*.8),0,Math.PI*2);
338
+ bgCtx.fillStyle=`rgba(140,90,240,${0.28+tw*.45})`;bgCtx.fill();
339
+ });
340
+ requestAnimationFrame(drawBg);
341
+ }
342
+ drawBg();
343
+
344
+ // ── State ─────────────────────────────────────
345
+ let projects = [];
346
+ let activeProjectId = null;
347
+ let currentThreadId = null; // the thread currently active in Claude
348
+ let simulation = null;
349
+ let graphNodes = []; // D3 nodes: [{id, type:'claude'|'project'|'thread', ...}]
350
+ let graphLinks = []; // D3 links: [{source, target, type}]
351
+
352
+ const PROJECT_COLORS = ['#64b4ff','#b4ff8c','#ffb464'];
353
+
354
+ // ── Claude logo node ──────────────────────────
355
+ function drawClaudeNode(cx, cy){
356
+ // Draw via canvas on the bg — just use the SVG center node
357
+ const g = nodeG.append('g').attr('class','claude-node').attr('transform',`translate(${cx},${cy})`);
358
+ // Outer glow rings
359
+ [80,60,44].forEach((r,i)=>{
360
+ g.append('circle').attr('r',r)
361
+ .attr('fill','none')
362
+ .attr('stroke',`rgba(255,255,255,${[0.04,0.07,0.12][i]})`)
363
+ .attr('stroke-width',1)
364
+ .style('animation',`ring-pulse ${3+i*.8}s ease-out infinite ${i*.5}s`);
365
+ });
366
+ // Disc
367
+ g.append('circle').attr('r',38)
368
+ .attr('fill','rgba(10,10,28,0.95)')
369
+ .attr('stroke','rgba(255,255,255,0.35)')
370
+ .attr('stroke-width',1.5)
371
+ .style('filter','drop-shadow(0 0 20px rgba(255,255,255,0.3)) drop-shadow(0 0 40px rgba(255,255,255,0.12))');
372
+ // Logo (canvas-drawn amber)
373
+ const fo = g.append('foreignObject').attr('x',-22).attr('y',-22).attr('width',44).attr('height',44);
374
+ const c = fo.append('xhtml:canvas').attr('width',44).attr('height',44)
375
+ .style('display','block').node();
376
+ const lc = c.getContext('2d');
377
+ const img = new Image();
378
+ img.onload = ()=>{lc.drawImage(img,0,0,44,44);lc.globalCompositeOperation='source-atop';lc.fillStyle='rgba(255,255,255,0.95)';lc.fillRect(0,0,44,44);lc.globalCompositeOperation='source-over'};
379
+ img.onerror = ()=>{lc.font='bold 28px serif';lc.fillStyle='rgba(255,255,255,0.9)';lc.textAlign='center';lc.textBaseline='middle';lc.fillText('✦',22,22)};
380
+ img.src='claude-logo.png';
381
+ return g;
382
+ }
383
+
384
+ // ── Build graph from projects ─────────────────
385
+ // Pre-seeds node positions so simulation starts clean, not from random chaos.
386
+ function buildGraph(){
387
+ graphNodes = [{ id:'__claude__', type:'claude', label:'Claude', fx: W/2, fy: H/2 }];
388
+ graphLinks = [];
389
+
390
+ const np = Math.min(projects.length, 3);
391
+ // Hub angles: evenly spread around Claude (120° apart for 3 projects)
392
+ const hubAngles = np === 1 ? [0]
393
+ : np === 2 ? [-Math.PI/2, Math.PI/2]
394
+ : [-Math.PI/2, Math.PI/6, Math.PI*5/6];
395
+
396
+ projects.forEach((proj, pi)=>{
397
+ const projId = `proj_${proj.id}`;
398
+ const hAngle = hubAngles[pi] || (pi * 2*Math.PI/np);
399
+ const hubDist = Math.min(W, H) * 0.30;
400
+
401
+ // Pre-seed hub position
402
+ const hx = W/2 + hubDist * Math.cos(hAngle);
403
+ const hy = H/2 + hubDist * Math.sin(hAngle);
404
+
405
+ graphNodes.push({ id:projId, type:'project', label:proj.name,
406
+ projectIdx:pi, projectId:proj.id, x:hx, y:hy });
407
+ graphLinks.push({ source:'__claude__', target:projId, type:'hub' });
408
+
409
+ const alive = proj.threads.filter(t=>t.status!=='killed');
410
+ const killed = proj.threads.filter(t=>t.status==='killed');
411
+ const threadDist = Math.min(W, H) * 0.18;
412
+ const totalThreads = alive.length + (killed.length ? 1 : 0);
413
+
414
+ const savedPos = loadPositions();
415
+ alive.forEach((t, ti)=>{
416
+ // Fan threads around the hub, evenly spread in a 200° arc
417
+ const arc = Math.min(Math.PI * 1.1, totalThreads * 0.35);
418
+ const tAngle = hAngle + (arc/2) - (totalThreads > 1 ? (arc * ti/(totalThreads-1)) : 0);
419
+ const nodeId = `thr_${t.id}`;
420
+ const saved = savedPos[nodeId];
421
+ graphNodes.push({
422
+ id: nodeId, type:'thread', label:t.title,
423
+ status:t.status, thread:t, projectIdx:pi, projectId:proj.id,
424
+ x: saved ? saved.fx : hx + threadDist * Math.cos(tAngle),
425
+ y: saved ? saved.fy : hy + threadDist * Math.sin(tAngle),
426
+ fx: saved ? saved.fx : null, // lock to saved position if user placed it
427
+ fy: saved ? saved.fy : null,
428
+ });
429
+ graphLinks.push({ source:projId, target:nodeId, type:'thread' });
430
+ });
431
+
432
+ if(killed.length){
433
+ const kId = `killed_${proj.id}`;
434
+ // Position killed group opposite the hub from Claude
435
+ const kAngle = hAngle + Math.PI * 0.6;
436
+ graphNodes.push({
437
+ id:kId, type:'killed-group', label:`✕ ${killed.length} killed`,
438
+ killed, projectIdx:pi, projectId:proj.id,
439
+ x: hx + threadDist * Math.cos(kAngle),
440
+ y: hy + threadDist * Math.sin(kAngle),
441
+ });
442
+ graphLinks.push({ source:projId, target:kId, type:'killed' });
443
+ }
444
+ });
445
+ }
446
+
447
+ // ── Render ────────────────────────────────────
448
+ function render(){
449
+ buildGraph();
450
+
451
+ const activeIdx = projects.findIndex(p=>p.id===activeProjectId);
452
+
453
+ // ── Simulation ──
454
+ if(simulation) simulation.stop();
455
+
456
+ // Scale link distances based on total node count — more nodes need more room
457
+ const threadCount = graphNodes.filter(n=>n.type==='thread').length;
458
+ const distScale = Math.max(1, Math.min(1.6, 1 + threadCount * 0.025));
459
+
460
+ simulation = d3.forceSimulation(graphNodes)
461
+ .force('link', d3.forceLink(graphLinks).id(d=>d.id)
462
+ .distance(d=>{
463
+ if(d.type==='hub') return 200 * distScale;
464
+ if(d.type==='killed') return 150 * distScale;
465
+ return 140 * distScale;
466
+ })
467
+ .strength(0.75))
468
+ .force('charge', d3.forceManyBody()
469
+ .strength(d=>{
470
+ if(d.type==='claude') return -1200;
471
+ if(d.type==='project') return -500;
472
+ return -280;
473
+ }))
474
+ .force('collide', d3.forceCollide()
475
+ .radius(d=>{
476
+ if(d.type==='claude') return 90; // hard exclusion zone around Claude
477
+ if(d.type==='project') return 80;
478
+ const sf = Math.max(0.7, 1 - threadCount * 0.012);
479
+ return Math.round(62 * sf);
480
+ }).strength(1.0).iterations(4))
481
+ .force('center', d3.forceCenter(W/2, H/2).strength(0.03))
482
+ .force('bounds', ()=>{
483
+ const cx = W/2, cy = H/2;
484
+ graphNodes.forEach(n=>{
485
+ if(n.type==='claude') return;
486
+ const r = n.type==='project' ? 70 : 56;
487
+ // Keep within viewport
488
+ n.x = Math.max(r+8, Math.min(W-r-8, n.x||W/2));
489
+ n.y = Math.max(r+8, Math.min(H-r-8, n.y||H/2));
490
+ // Push away from Claude center — minimum 120px clearance
491
+ const dx = n.x - cx, dy = n.y - cy;
492
+ const dist = Math.sqrt(dx*dx + dy*dy)||1;
493
+ const minDist = 120;
494
+ if(dist < minDist){
495
+ const scale = minDist / dist;
496
+ n.x = cx + dx * scale;
497
+ n.y = cy + dy * scale;
498
+ }
499
+ });
500
+ })
501
+ .alphaDecay(0.04) // settle faster
502
+ .velocityDecay(0.4) // more damping = less jitter after settle
503
+ .on('tick', ticked);
504
+
505
+ // ── Draw silk thread paths (bezier with live wave physics) ──
506
+ linkG.selectAll('*').remove();
507
+
508
+ // Each link gets a unique id for animateMotion mpath references
509
+ graphLinks.forEach((d,i)=>{ d._idx = i; d._phase = Math.random()*Math.PI*2; });
510
+
511
+ const link = linkG.selectAll('path.silk').data(graphLinks).enter()
512
+ .append('path').attr('class','silk')
513
+ .attr('id', d=>`silk-${d._idx}`)
514
+ .attr('fill','none')
515
+ .attr('stroke', d=>{
516
+ if(d.type==='hub'){
517
+ const pi = graphNodes.find(n=>n.id===d.target.id||n.id===d.target)?.projectIdx??0;
518
+ return PROJECT_COLORS[pi%PROJECT_COLORS.length];
519
+ }
520
+ if(d.type==='killed') return 'rgba(239,80,80,0.6)';
521
+ const tn = graphNodes.find(n=>n.id===d.target.id||n.id===d.target);
522
+ return tn ? threadColor(tn.id, 0.5) : 'rgba(200,200,255,0.35)';
523
+ })
524
+ .attr('stroke-width', d=>d.type==='hub' ? 2.2 : 1.4)
525
+ .attr('stroke-dasharray', d=>d.type==='killed' ? '5 3' : null)
526
+ .attr('stroke-linecap','round')
527
+ .attr('opacity', d=>{
528
+ const tId = typeof d.target==='object' ? d.target.id : d.target;
529
+ const tn = graphNodes.find(n=>n.id===tId);
530
+ const isAP = tn?.projectId === activeProjectId;
531
+ if(d.type==='hub') return isAP ? 0.88 : 0.28;
532
+ return isAP ? 0.7 : 0.18;
533
+ })
534
+ .attr('filter', d=>d.type==='hub' ? 'url(#link-glow)' : null);
535
+
536
+ // Traveling dot along each silk thread
537
+ const dots = linkG.selectAll('circle.dot').data(graphLinks.filter(d=>d.type!=='killed')).enter()
538
+ .append('circle').attr('class','dot')
539
+ .attr('r', d=>d.type==='hub' ? 3 : 2)
540
+ .attr('fill', d=>{
541
+ if(d.type==='hub'){
542
+ const pi = graphNodes.find(n=>n.id===d.target.id||n.id===d.target)?.projectIdx??0;
543
+ return PROJECT_COLORS[pi%PROJECT_COLORS.length];
544
+ }
545
+ const tn = graphNodes.find(n=>n.id===d.target.id||n.id===d.target);
546
+ return tn ? threadColor(tn.id, 0.9) : 'rgba(200,200,255,0.5)';
547
+ })
548
+ .attr('opacity', d=>{
549
+ const tId = typeof d.target==='object' ? d.target.id : d.target;
550
+ const tn = graphNodes.find(n=>n.id===tId);
551
+ return tn?.projectId===activeProjectId ? 0.85 : 0.2;
552
+ });
553
+
554
+ // Kick off animateMotion on each dot
555
+ dots.each(function(d){
556
+ const dur = 3 + d._idx * 0.4 % 4;
557
+ d3.select(this).append('animateMotion')
558
+ .attr('dur', `${dur}s`)
559
+ .attr('begin', `${-(d._idx * 0.55)}s`)
560
+ .attr('repeatCount','indefinite')
561
+ .append('mpath')
562
+ .attr('href', `#silk-${d._idx}`);
563
+ });
564
+
565
+ // ── Draw nodes ──
566
+ nodeG.selectAll('*').remove();
567
+ labelG.selectAll('*').remove();
568
+
569
+ // Claude first
570
+ const claudeNode = graphNodes.find(n=>n.type==='claude');
571
+ drawClaudeNode(claudeNode.x||W/2, claudeNode.y||H/2);
572
+
573
+ // Project and thread nodes
574
+ const nonClaude = graphNodes.filter(n=>n.type!=='claude');
575
+ const nodeEl = nodeG.selectAll('g.node').data(nonClaude).enter()
576
+ .append('g').attr('class','node')
577
+ .attr('transform', d=>`translate(${d.x||W/2},${d.y||H/2})`)
578
+ .style('cursor','pointer')
579
+ .on('click', (e,d)=>{
580
+ e.stopPropagation();
581
+ if(d.type==='thread'){
582
+ openPanel(d.thread.id, d.thread);
583
+ } else if(d.type==='killed-group'){
584
+ // Show aggregate info for killed group — no messages, just info
585
+ openPanel(`killed_${d.projectId}`, {
586
+ title:`${d.killed.length} Killed Threads`,
587
+ status:'killed',
588
+ created_at: d.killed[0]?.created_at,
589
+ });
590
+ } else if(d.projectId && d.projectId !== activeProjectId){
591
+ setActiveProject(d.projectId);
592
+ }
593
+ })
594
+ .on('mouseenter', (e,d)=>showTip(e,d))
595
+ .on('mousemove', moveTip)
596
+ .on('mouseleave', hideTip)
597
+ .call(d3.drag()
598
+ .on('start', (e,d)=>{ if(!e.active) simulation.alphaTarget(0.2).restart(); d.fx=d.x; d.fy=d.y; })
599
+ .on('drag', (e,d)=>{ d.fx=e.x; d.fy=e.y; })
600
+ .on('end', (e,d)=>{
601
+ if(!e.active) simulation.alphaTarget(0);
602
+ // Keep node pinned where user dropped it; save to localStorage
603
+ if(d.type==='thread'){
604
+ savePosition(d.id, d.fx, d.fy);
605
+ }
606
+ // Project hubs: keep pinned but don't persist (they reset on reload)
607
+ }))
608
+ .on('dblclick', (e,d)=>{
609
+ // Double-click a thread node to release it back to the simulation
610
+ if(d.type==='thread'){
611
+ d.fx=null; d.fy=null;
612
+ clearPosition(d.id);
613
+ simulation.alphaTarget(0.15).restart();
614
+ }
615
+ e.stopPropagation();
616
+ });
617
+
618
+ nodeEl.each(function(d){
619
+ const g = d3.select(this);
620
+ const isActiveProj = d.projectId === activeProjectId;
621
+ const alpha = isActiveProj ? 1 : 0.45;
622
+ const col = PROJECT_COLORS[d.projectIdx%PROJECT_COLORS.length];
623
+
624
+ if(d.type==='project'){
625
+ // Project hub: circle + pill label below (never bleeds)
626
+ g.append('circle').attr('r',28)
627
+ .attr('fill','rgba(10,10,28,0.92)')
628
+ .attr('stroke',col).attr('stroke-width',2)
629
+ .attr('opacity',alpha)
630
+ .style('filter',isActiveProj ? `drop-shadow(0 0 14px ${col}99) drop-shadow(0 0 28px ${col}44)` : 'none');
631
+ // Center dot
632
+ g.append('circle').attr('r',6).attr('fill',col).attr('opacity',alpha*0.9);
633
+ // Label pill BELOW the circle — fixed width, never bleeds
634
+ const lw = 100, lh = 22;
635
+ g.append('rect').attr('x',-lw/2).attr('y',34).attr('width',lw).attr('height',lh)
636
+ .attr('rx',11).attr('fill','rgba(10,10,28,0.88)')
637
+ .attr('stroke',col).attr('stroke-width',1).attr('opacity',alpha*0.85);
638
+ g.append('text').attr('text-anchor','middle').attr('x',0).attr('y',34+lh/2+1)
639
+ .attr('dominant-baseline','middle')
640
+ .attr('fill',col).attr('font-size','9.5px').attr('font-weight','700')
641
+ .attr('font-family','JetBrains Mono,monospace').attr('letter-spacing','.06em')
642
+ .attr('opacity',alpha)
643
+ .text(d.label.length>13 ? d.label.slice(0,12)+'…' : d.label);
644
+
645
+ } else if(d.type==='thread'){
646
+ const tc = threadColor(d.id); // unique per-thread color
647
+ const sc = STATUS_COLOR[d.status]||'rgba(200,200,255,0.5)'; // status ring color
648
+ const sizeFactor = Math.max(0.7, 1 - threadCount * 0.012);
649
+ const r = Math.round((d.status==='active' ? 22 : 18) * sizeFactor);
650
+ const isPinned = d.fx !== null && d.fx !== undefined;
651
+ const isCurrent = d.thread && d.thread.id === currentThreadId;
652
+
653
+ // Active-thread: outer pulsing halo
654
+ if(isCurrent){
655
+ g.append('circle').attr('r',r+10).attr('class','active-halo')
656
+ .attr('fill','none')
657
+ .attr('stroke','rgba(0,255,179,0.55)')
658
+ .attr('stroke-width',1.5)
659
+ .style('animation','ring-pulse 2s ease-out infinite');
660
+ g.append('circle').attr('r',r+6).attr('class','active-halo2')
661
+ .attr('fill','none')
662
+ .attr('stroke','rgba(0,255,179,0.3)')
663
+ .attr('stroke-width',1)
664
+ .style('animation','ring-pulse 2s ease-out infinite 0.4s');
665
+ }
666
+ // Outer status ring (thin, shows state)
667
+ g.append('circle').attr('r',r+3)
668
+ .attr('fill','none')
669
+ .attr('stroke', isCurrent ? 'rgba(0,255,179,0.9)' : sc)
670
+ .attr('stroke-width', isCurrent ? 2 : 1)
671
+ .attr('opacity', alpha * (isCurrent ? 0.9 : 0.5))
672
+ .attr('stroke-dasharray', d.status==='idle'?'3 3':null);
673
+ // Main node body — colored by thread identity
674
+ g.append('circle').attr('r',r)
675
+ .attr('fill',isCurrent ? 'rgba(0,30,22,0.95)' : 'rgba(10,10,28,0.88)')
676
+ .attr('stroke', isCurrent ? '#00ffb3' : tc)
677
+ .attr('stroke-width', isCurrent ? 2.8 : (d.status==='active' ? 2.2 : 1.4))
678
+ .attr('opacity', alpha)
679
+ .style('filter', isCurrent
680
+ ? 'drop-shadow(0 0 12px rgba(0,255,179,0.7)) drop-shadow(0 0 24px rgba(0,255,179,0.3))'
681
+ : (isActiveProj && STATUS_GLOW[d.status]!=='none' ? `drop-shadow(0 0 8px ${tc}bb)` : 'none'));
682
+ // Center dot — thread color fill
683
+ g.append('circle').attr('r', isCurrent ? 6 : (isPinned ? 5 : 4)).attr('cx',0).attr('cy',0)
684
+ .attr('fill', isCurrent ? '#00ffb3' : tc).attr('opacity', alpha*0.9);
685
+ // Active-thread: "▶" play indicator top-right
686
+ if(isCurrent){
687
+ g.append('text').attr('x',r-2).attr('y',-(r-2))
688
+ .attr('text-anchor','middle').attr('dominant-baseline','middle')
689
+ .attr('font-size','8px').attr('fill','#00ffb3').attr('opacity',0.95)
690
+ .text('▶');
691
+ }
692
+ // Pin indicator for locked nodes
693
+ if(isPinned && !isCurrent){
694
+ g.append('circle').attr('r',2).attr('cx',r-4).attr('cy',-(r-4))
695
+ .attr('fill','rgba(255,255,255,0.6)').attr('opacity',alpha*0.7);
696
+ }
697
+ // Label below — bold + green for active thread
698
+ labelG.append('text')
699
+ .attr('x',(d.x||W/2)).attr('y',(d.y||H/2)+r+12)
700
+ .attr('text-anchor','middle')
701
+ .attr('fill', isCurrent ? '#00ffb3' : 'rgba(255,255,255,0.7)')
702
+ .attr('font-size', isCurrent ? '10.5px' : '9.5px')
703
+ .attr('font-weight', isCurrent ? '700' : '400')
704
+ .attr('font-family','JetBrains Mono,monospace')
705
+ .attr('opacity', isCurrent ? 1 : (isActiveProj ? 0.85 : 0.3))
706
+ .attr('class',`lbl lbl-${d.id}`)
707
+ .text((isCurrent ? '▶ ' : '') + (d.label.length>18 ? d.label.slice(0,17)+'…' : d.label));
708
+
709
+ } else if(d.type==='killed-group'){
710
+ g.append('circle').attr('r',20)
711
+ .attr('fill','rgba(30,6,6,0.9)')
712
+ .attr('stroke','rgba(239,80,80,0.55)')
713
+ .attr('stroke-width',1.2)
714
+ .attr('stroke-dasharray','3 2')
715
+ .attr('opacity',alpha);
716
+ g.append('text').attr('text-anchor','middle').attr('dominant-baseline','middle')
717
+ .attr('fill','rgba(255,140,140,0.85)').attr('font-size','9px').attr('font-weight','700')
718
+ .attr('font-family','JetBrains Mono,monospace').attr('opacity',alpha)
719
+ .text(d.label);
720
+ }
721
+ });
722
+
723
+ let _t = 0; // time counter for wave animation
724
+
725
+ function silkD(d, t){
726
+ const sx = d.source.x, sy = d.source.y;
727
+ const ex = d.target.x, ey = d.target.y;
728
+ const dx = ex-sx, dy = ey-sy;
729
+ const dist = Math.sqrt(dx*dx+dy*dy)||1;
730
+ // Perpendicular unit vector
731
+ const px = -dy/dist, py = dx/dist;
732
+ // Wave amplitude: hub links bigger, thread links smaller
733
+ const baseAmp = d.type==='hub' ? 28 : 16;
734
+ const amp = Math.sin(t*0.9 + (d._phase||0)) * baseAmp;
735
+ const c1x = sx + dx*0.3 + px*amp;
736
+ const c1y = sy + dy*0.3 + py*amp;
737
+ const c2x = sx + dx*0.7 - px*amp*0.7;
738
+ const c2y = sy + dy*0.7 - py*amp*0.7;
739
+ return `M${sx},${sy} C${c1x},${c1y} ${c2x},${c2y} ${ex},${ey}`;
740
+ }
741
+
742
+ function ticked(){
743
+ _t += 0.012;
744
+ // Update Claude node position
745
+ const cn = graphNodes.find(n=>n.type==='claude');
746
+ svg.select('.claude-node').attr('transform',`translate(${cn.x},${cn.y})`);
747
+
748
+ // Update silk thread paths with live wave
749
+ link.attr('d', d => silkD(d, _t));
750
+
751
+ // Update non-claude nodes
752
+ nodeEl.attr('transform',d=>`translate(${d.x},${d.y})`);
753
+
754
+ // Update labels
755
+ graphNodes.filter(n=>n.type==='thread').forEach(d=>{
756
+ labelG.select(`.lbl-${d.id}`)
757
+ .attr('x',d.x).attr('y',d.y + (d.status==='active'?22:18) + 14);
758
+ });
759
+ }
760
+
761
+ // Update switcher
762
+ renderSwitcher();
763
+ updateCounter();
764
+ }
765
+
766
+ // ── Smooth project switch (no full rebuild) ───
767
+ function setActiveProject(id){
768
+ if(id === activeProjectId) return;
769
+ activeProjectId = id;
770
+
771
+ const T = 400; // transition duration ms
772
+
773
+ // Fade links in/out
774
+ linkG.selectAll('path.silk')
775
+ .transition().duration(T).ease(d3.easeCubicInOut)
776
+ .attr('opacity', d=>{
777
+ const tId = typeof d.target==='object' ? d.target.id : d.target;
778
+ const tn = graphNodes.find(n=>n.id===tId);
779
+ const isAP = tn?.projectId === activeProjectId;
780
+ if(d.type==='hub') return isAP ? 0.88 : 0.28;
781
+ return isAP ? 0.7 : 0.18;
782
+ });
783
+
784
+ // Fade dot opacity
785
+ linkG.selectAll('circle.dot')
786
+ .transition().duration(T).ease(d3.easeCubicInOut)
787
+ .attr('opacity', d=>{
788
+ const tId = typeof d.target==='object' ? d.target.id : d.target;
789
+ const tn = graphNodes.find(n=>n.id===tId);
790
+ return tn?.projectId===activeProjectId ? 0.85 : 0.15;
791
+ });
792
+
793
+ // Scale up active project hub, scale down others
794
+ nodeG.selectAll('g.node')
795
+ .transition().duration(T).ease(d3.easeBackOut.overshoot(1.4))
796
+ .attr('opacity', d=> d.projectId===activeProjectId ? 1 : 0.38)
797
+ .attr('transform', d=>{
798
+ const scale = d.projectId===activeProjectId ? 1 : 0.82;
799
+ return `translate(${d.x},${d.y}) scale(${scale})`;
800
+ });
801
+
802
+ // Fade labels
803
+ labelG.selectAll('text')
804
+ .transition().duration(T).ease(d3.easeCubicInOut)
805
+ .attr('opacity', d=>{
806
+ // d here is bound to thread graphNodes
807
+ if(!d) return 0.3;
808
+ return d.projectId===activeProjectId ? 0.85 : 0.2;
809
+ });
810
+
811
+ // Tiny nudge — just enough to jiggle into place without a full re-layout
812
+ if(simulation) simulation.alpha(0.06).restart();
813
+
814
+ renderSwitcher();
815
+ updateCounter();
816
+ }
817
+
818
+ // ── Tooltip ───────────────────────────────────
819
+ const tip = document.getElementById('tip');
820
+ function showTip(e,d){
821
+ if(d.type==='claude') return;
822
+ if(d.type==='project'){
823
+ document.getElementById('tip-title').textContent = d.label;
824
+ document.getElementById('tip-proj').textContent = 'project hub';
825
+ document.getElementById('tip-status').textContent = `${d.killed?.length||0} killed + ${graphNodes.filter(n=>n.projectId===d.projectId&&n.type==='thread').length} active`;
826
+ document.getElementById('tip-msgs').textContent = '—';
827
+ } else if(d.type==='thread'){
828
+ document.getElementById('tip-title').textContent = d.thread.title;
829
+ document.getElementById('tip-proj').textContent = projects.find(p=>p.id===d.projectId)?.name||'';
830
+ document.getElementById('tip-status').textContent = d.status;
831
+ document.getElementById('tip-msgs').textContent = d.thread.message_count;
832
+ } else if(d.type==='killed-group'){
833
+ document.getElementById('tip-title').textContent = `${d.killed.length} killed threads`;
834
+ document.getElementById('tip-proj').textContent = projects.find(p=>p.id===d.projectId)?.name||'';
835
+ document.getElementById('tip-status').textContent = 'killed';
836
+ document.getElementById('tip-msgs').textContent = d.killed.reduce((s,t)=>s+t.message_count,0);
837
+ }
838
+ tip.style.display='block';
839
+ moveTip(e);
840
+ }
841
+ function moveTip(e){
842
+ let x=e.clientX+14, y=e.clientY-10;
843
+ if(x+240>window.innerWidth) x=e.clientX-254;
844
+ if(y+120>window.innerHeight) y=window.innerHeight-130;
845
+ tip.style.left=x+'px'; tip.style.top=y+'px';
846
+ }
847
+ function hideTip(){ tip.style.display='none'; }
848
+
849
+ // ── Project switcher ──────────────────────────
850
+ function renderSwitcher(){
851
+ const el = document.getElementById('switcher');
852
+ el.innerHTML='';
853
+ if(projects.length<=1){el.style.display='none';return;}
854
+ el.style.display='flex';
855
+ projects.forEach((p,i)=>{
856
+ const btn = document.createElement('button');
857
+ btn.className='sp'+(p.id===activeProjectId?' on':'');
858
+ btn.innerHTML=`<span class="sp-dot" style="background:${PROJECT_COLORS[i%PROJECT_COLORS.length]}"></span>${p.name}`;
859
+ btn.title=`/SwProject ${p.id}`;
860
+ btn.addEventListener('click',()=>setActiveProject(p.id));
861
+ el.appendChild(btn);
862
+ });
863
+ }
864
+
865
+ function updateCounter(){
866
+ const ap = projects.find(p=>p.id===activeProjectId);
867
+ const n = ap ? ap.threads.length : 0;
868
+ document.getElementById('tc-n').textContent = n;
869
+ document.getElementById('tc-l').textContent = n===1?'thread':'threads';
870
+ }
871
+
872
+ // ── Detail Panel ─────────────────────────────
873
+ let panelThreadId = null;
874
+ let panelOldestTs = null;
875
+ let panelHasMore = false;
876
+
877
+ const BADGE_STYLE = {
878
+ active: 'color:#00ffb3;border-color:rgba(0,255,179,0.35);background:rgba(0,255,179,0.08)',
879
+ awaiting: 'color:#fbbf24;border-color:rgba(251,191,36,0.35);background:rgba(251,191,36,0.08)',
880
+ running: 'color:#a78bfa;border-color:rgba(167,139,250,0.35);background:rgba(167,139,250,0.08)',
881
+ idle: 'color:rgba(200,210,255,0.7);border-color:rgba(200,210,255,0.2);background:rgba(200,210,255,0.05)',
882
+ killed: 'color:rgba(239,120,120,0.85);border-color:rgba(239,80,80,0.3);background:rgba(239,80,80,0.07)',
883
+ };
884
+
885
+ function roleClass(role){
886
+ if(role==='user') return 'role-user';
887
+ if(role==='assistant') return 'role-assistant';
888
+ return 'role-tool';
889
+ }
890
+
891
+ function fmtTs(ts){
892
+ if(!ts) return '';
893
+ try { return new Date(ts).toLocaleString([], {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}); }
894
+ catch { return ts.slice(0,16).replace('T',' '); }
895
+ }
896
+
897
+ function renderMessages(msgs, prepend=false){
898
+ const container = document.getElementById('msgs');
899
+ const frag = document.createDocumentFragment();
900
+ msgs.forEach((m,i)=>{
901
+ if(i>0){const sep=document.createElement('div');sep.className='msg-sep';frag.appendChild(sep);}
902
+ const div = document.createElement('div');
903
+ div.className='msg';
904
+ const rc = roleClass(m.role||'tool');
905
+ const bodyClass = `msg-body role-${(m.role||'tool')}-body`;
906
+ // Sanitize: strip script/event-handler attributes to prevent stored XSS
907
+ function sanitize(html){
908
+ const t=document.createElement('template');
909
+ t.innerHTML=html;
910
+ t.content.querySelectorAll('script,iframe,object,embed').forEach(el=>el.remove());
911
+ t.content.querySelectorAll('*').forEach(el=>{
912
+ [...el.attributes].forEach(a=>{if(/^on/i.test(a.name)||a.name==='href'&&/^javascript/i.test(a.value))el.removeAttribute(a.name)});
913
+ });
914
+ const d=document.createElement('div');d.appendChild(t.content);return d.innerHTML;
915
+ }
916
+ const raw = (typeof marked!=='undefined' && m.content)
917
+ ? marked.parse(String(m.content), {breaks:true, gfm:true})
918
+ : `<p>${String(m.content||'').replace(/</g,'&lt;')}</p>`;
919
+ const md = sanitize(raw);
920
+ div.innerHTML=`
921
+ <div class="msg-head">
922
+ <span class="role-chip ${rc}">${m.role||'tool'}</span>
923
+ <span class="msg-ts">${fmtTs(m.timestamp)}</span>
924
+ </div>
925
+ <div class="${bodyClass}">${md}</div>`;
926
+ frag.appendChild(div);
927
+ });
928
+ if(prepend && container.firstChild) container.insertBefore(frag, container.firstChild);
929
+ else container.appendChild(frag);
930
+ }
931
+
932
+ async function openPanel(threadId, threadData){
933
+ const isSwitch = panelThreadId && panelThreadId !== threadId;
934
+ panelThreadId = threadId;
935
+ panelOldestTs = null;
936
+ panelHasMore = false;
937
+
938
+ // Populate header
939
+ const title = threadData?.title || threadData?.name || threadId;
940
+ const status = threadData?.status || 'idle';
941
+ document.getElementById('panel-title').textContent = title;
942
+ const badge = document.getElementById('panel-badge');
943
+ badge.textContent = status;
944
+ badge.style.cssText = BADGE_STYLE[status] || BADGE_STYLE.idle;
945
+ document.getElementById('panel-ts').textContent = fmtTs(threadData?.created_at);
946
+
947
+ const msgs = document.getElementById('msgs');
948
+
949
+ if(isSwitch){
950
+ // Cross-fade: fade out, swap, fade in
951
+ msgs.classList.add('fading');
952
+ await new Promise(r=>setTimeout(r,100));
953
+ msgs.innerHTML='';
954
+ msgs.classList.remove('fading');
955
+ } else {
956
+ msgs.innerHTML='';
957
+ }
958
+
959
+ // Skip message fetch for synthetic group IDs (killed groups, etc.)
960
+ if(threadId.startsWith('killed_')){
961
+ msgs.innerHTML='<div style="padding:20px;color:rgba(239,120,120,0.6);font-size:11px">Archived threads — no live messages.</div>';
962
+ document.getElementById('panel').classList.add('open');
963
+ return;
964
+ }
965
+
966
+ // Fetch messages
967
+ try{
968
+ const res = await fetch(`${API}/api/threads/${threadId}/messages`);
969
+ const data = await res.json();
970
+ if(Array.isArray(data) && data.length>0){
971
+ panelOldestTs = data[0].timestamp;
972
+ panelHasMore = data.length >= 50;
973
+ renderMessages(data);
974
+ msgs.scrollTop = msgs.scrollHeight;
975
+ } else {
976
+ msgs.innerHTML='<div style="padding:20px;color:rgba(255,255,255,0.3);font-size:11px">No messages yet.</div>';
977
+ }
978
+ } catch(e){
979
+ msgs.innerHTML='<div style="padding:20px;color:rgba(255,255,255,0.3);font-size:11px">Could not load messages.</div>';
980
+ }
981
+
982
+ document.getElementById('load-earlier').style.display = panelHasMore ? 'block' : 'none';
983
+
984
+ // Slide in
985
+ document.getElementById('panel').classList.add('open');
986
+ msgs.classList.add('fading-in');
987
+ setTimeout(()=>msgs.classList.remove('fading-in'), 200);
988
+ }
989
+
990
+ async function loadEarlier(){
991
+ if(!panelThreadId || !panelOldestTs) return;
992
+ const btn = document.getElementById('load-earlier');
993
+ btn.textContent = '↑ Loading…';
994
+ btn.disabled = true;
995
+ try{
996
+ const url = `${API}/api/threads/${panelThreadId}/messages?before=${encodeURIComponent(panelOldestTs)}`;
997
+ const res = await fetch(url);
998
+ const data = await res.json();
999
+ if(Array.isArray(data) && data.length>0){
1000
+ panelOldestTs = data[0].timestamp;
1001
+ panelHasMore = data.length >= 50;
1002
+ const msgs = document.getElementById('msgs');
1003
+ const prevH = msgs.scrollHeight;
1004
+ renderMessages(data, true); // prepend
1005
+ msgs.scrollTop = msgs.scrollHeight - prevH; // keep scroll position
1006
+ } else {
1007
+ panelHasMore = false;
1008
+ }
1009
+ } catch(e){ /* silent */ }
1010
+ btn.textContent = '↑ Load earlier messages';
1011
+ btn.disabled = false;
1012
+ document.getElementById('load-earlier').style.display = panelHasMore ? 'block' : 'none';
1013
+ }
1014
+
1015
+ function closePanel(){
1016
+ document.getElementById('panel').classList.remove('open');
1017
+ panelThreadId = null;
1018
+ }
1019
+
1020
+ // Close on outside click
1021
+ document.addEventListener('click', e=>{
1022
+ const panel = document.getElementById('panel');
1023
+ if(panel.classList.contains('open') && !panel.contains(e.target)){
1024
+ // Only close if click is not on the SVG nodes (those handle their own click)
1025
+ if(!e.target.closest('#viz')) closePanel();
1026
+ }
1027
+ });
1028
+
1029
+ // ── Data ──────────────────────────────────────
1030
+ // ── Smart diff apply ──────────────────────────
1031
+ // First call: full render. Subsequent calls: diff and update in-place.
1032
+ let _initialRenderDone = false;
1033
+
1034
+ function applyData(data){
1035
+ let incoming;
1036
+ if(data.projects) incoming = data.projects.slice(0,3);
1037
+ else if(Array.isArray(data) && data.length) incoming = [{ id:'default', name:'default', threads:data }];
1038
+ else return;
1039
+
1040
+ // Track active thread — triggers re-render if changed
1041
+ const newCurrentId = data.current_thread_id || null;
1042
+ const currentChanged = newCurrentId !== currentThreadId;
1043
+ currentThreadId = newCurrentId;
1044
+
1045
+ const newProjects = incoming.map(p=>({...p, threads: p.threads||[]}));
1046
+
1047
+ // First load — always full render
1048
+ if(!_initialRenderDone){
1049
+ projects = newProjects;
1050
+ if(!activeProjectId||!projects.find(p=>p.id===activeProjectId))
1051
+ activeProjectId = projects[0]?.id||null;
1052
+ // Auto-switch to the project containing the active thread
1053
+ if(currentThreadId){
1054
+ for(const p of projects){
1055
+ if(p.threads.find(t=>t.id===currentThreadId)){ activeProjectId=p.id; break; }
1056
+ }
1057
+ }
1058
+ render();
1059
+ _initialRenderDone = true;
1060
+ return;
1061
+ }
1062
+
1063
+ // Active thread changed — full rebuild to update highlighting
1064
+ if(currentChanged){ render(); return; }
1065
+
1066
+ // Subsequent WS updates — smart diff
1067
+ let changed = false;
1068
+
1069
+ newProjects.forEach(np=>{
1070
+ const existing = projects.find(p=>p.id===np.id);
1071
+ if(!existing){
1072
+ // New project — full rebuild needed
1073
+ projects = newProjects;
1074
+ activeProjectId = activeProjectId||projects[0]?.id||null;
1075
+ render();
1076
+ changed = false; // render() already handled everything
1077
+ return;
1078
+ }
1079
+
1080
+ // Diff threads
1081
+ np.threads.forEach(nt=>{
1082
+ const et = existing.threads.find(t=>t.id===nt.id);
1083
+ if(!et){
1084
+ // New thread — add node with entrance animation
1085
+ existing.threads.push({...nt, _isNew:true});
1086
+ changed = true;
1087
+ } else if(et.status !== nt.status){
1088
+ // Status changed — update in place
1089
+ et.status = nt.status;
1090
+ et.title = nt.title || et.title;
1091
+ et.message_count = nt.message_count ?? et.message_count;
1092
+ changed = true;
1093
+ updateNodeStatus(et);
1094
+ }
1095
+ });
1096
+
1097
+ // Removed threads — exit animation then remove
1098
+ existing.threads.filter(et=>!np.threads.find(nt=>nt.id===et.id))
1099
+ .forEach(et=>{ removeNodeAnimated(et.id); existing.threads = existing.threads.filter(t=>t.id!==et.id); changed=true; });
1100
+ });
1101
+
1102
+ if(changed) applyDiff();
1103
+ }
1104
+
1105
+ function updateNodeStatus(thread){
1106
+ // Update node visuals in-place — no rebuild
1107
+ const nId = `thr_${thread.id}`;
1108
+ const node = graphNodes.find(n=>n.id===nId);
1109
+ if(!node) return;
1110
+ node.status = thread.status;
1111
+
1112
+ const tc = threadColor(nId);
1113
+ const r = thread.status==='active' ? 22 : 18;
1114
+
1115
+ nodeG.selectAll('g.node').filter(d=>d.id===nId)
1116
+ .selectAll('circle:nth-child(2)') // main body circle (after status ring)
1117
+ .transition().duration(300)
1118
+ .attr('stroke', tc)
1119
+ .attr('r', r);
1120
+
1121
+ // Update silk thread color
1122
+ linkG.selectAll('path.silk').filter(d=>{
1123
+ const tId = typeof d.target==='object' ? d.target.id : d.target;
1124
+ return tId === nId;
1125
+ })
1126
+ .transition().duration(300)
1127
+ .attr('stroke', threadColor(nId, 0.5))
1128
+ .attr('opacity', thread.projectId===activeProjectId ? 0.7 : 0.18);
1129
+ }
1130
+
1131
+ function removeNodeAnimated(threadId){
1132
+ const nId = `thr_${threadId}`;
1133
+ nodeG.selectAll('g.node').filter(d=>d.id===nId)
1134
+ .transition().duration(300).ease(d3.easeCubicIn)
1135
+ .attr('transform', d=>`translate(${d.x},${d.y}) scale(0.1)`)
1136
+ .style('opacity',0)
1137
+ .remove();
1138
+ linkG.selectAll('path.silk').filter(d=>{
1139
+ const tId = typeof d.target==='object' ? d.target.id : d.target;
1140
+ return tId === nId;
1141
+ }).transition().duration(300).attr('opacity',0).remove();
1142
+ labelG.select(`.lbl-${threadId}`).transition().duration(300).attr('opacity',0).remove();
1143
+ graphNodes = graphNodes.filter(n=>n.id!==nId);
1144
+ graphLinks = graphLinks.filter(l=>{
1145
+ const tId = typeof l.target==='object' ? l.target.id : l.target;
1146
+ return tId !== nId;
1147
+ });
1148
+ }
1149
+
1150
+ function applyDiff(){
1151
+ // Add new nodes with entrance animation
1152
+ projects.forEach(proj=>{
1153
+ proj.threads.filter(t=>t._isNew).forEach(t=>{
1154
+ delete t._isNew;
1155
+ // Find parent hub position for initial placement
1156
+ const hub = graphNodes.find(n=>n.id===`proj_${proj.id}`);
1157
+ const angle = Math.random()*Math.PI*2;
1158
+ const newNode = {
1159
+ id:`thr_${t.id}`, type:'thread', label:t.title,
1160
+ status:t.status, thread:t, projectIdx:projects.indexOf(proj), projectId:proj.id,
1161
+ x:(hub?.x||W/2)+80*Math.cos(angle),
1162
+ y:(hub?.y||H/2)+80*Math.sin(angle),
1163
+ };
1164
+ graphNodes.push(newNode);
1165
+ graphLinks.push({ source:`proj_${proj.id}`, target:`thr_${t.id}`, type:'thread' });
1166
+
1167
+ // Add DOM node with entrance animation
1168
+ const isActive = proj.id===activeProjectId;
1169
+ const sc = STATUS_COLOR[t.status]||'rgba(200,200,255,0.5)';
1170
+ const nodeEl = nodeG.append('g').attr('class','node')
1171
+ .attr('transform',`translate(${newNode.x},${newNode.y}) scale(0.5)`)
1172
+ .style('opacity','0')
1173
+ .style('cursor','pointer')
1174
+ .on('click',(e,d)=>{ e.stopPropagation(); openPanel(t.id, t); })
1175
+ .on('mouseenter',(e)=>showTip(e,newNode))
1176
+ .on('mousemove',moveTip).on('mouseleave',hideTip);
1177
+ nodeEl.datum(newNode);
1178
+ nodeEl.append('circle').attr('r',18).attr('fill','rgba(10,10,28,0.88)').attr('stroke',sc).attr('stroke-width',1.2).attr('opacity',isActive?1:0.45);
1179
+ nodeEl.append('circle').attr('r',4).attr('fill',sc).attr('opacity',(isActive?1:0.45)*0.9);
1180
+ labelG.append('text').attr('x',newNode.x).attr('y',newNode.y+30).attr('text-anchor','middle')
1181
+ .attr('fill','rgba(255,255,255,0.7)').attr('font-size','9.5px').attr('font-family','JetBrains Mono,monospace')
1182
+ .attr('opacity',isActive?0.85:0.3).attr('class',`lbl lbl-${t.id}`)
1183
+ .text(t.title.length>18?t.title.slice(0,17)+'…':t.title);
1184
+
1185
+ // Entrance animation
1186
+ nodeEl.transition().duration(400).ease(d3.easeBackOut.overshoot(1.3))
1187
+ .attr('transform',`translate(${newNode.x},${newNode.y}) scale(1)`)
1188
+ .style('opacity','1');
1189
+
1190
+ // Add silk thread with fade-in — use stable id matching animateMotion mpath
1191
+ const silkId = `silk-thr-${t.id}`;
1192
+ linkG.append('path').attr('class','silk')
1193
+ .attr('id', silkId)
1194
+ .attr('fill','none').attr('stroke',sc).attr('stroke-width',1.4)
1195
+ .attr('stroke-linecap','round').attr('opacity',0)
1196
+ .transition().duration(400).attr('opacity', isActive?0.7:0.18);
1197
+ // Traveling dot on new thread
1198
+ const newDot = linkG.append('circle').attr('class','dot')
1199
+ .attr('r',2).attr('fill',sc).attr('opacity',0);
1200
+ newDot.transition().duration(400).attr('opacity', isActive?0.75:0.15);
1201
+ const am = newDot.append('animateMotion').attr('dur','4s').attr('begin','0s').attr('repeatCount','indefinite');
1202
+ const mpath = document.createElementNS('http://www.w3.org/2000/svg','mpath');
1203
+ mpath.setAttributeNS('http://www.w3.org/1999/xlink','href',`#${silkId}`);
1204
+ am.node().appendChild(mpath);
1205
+ });
1206
+ });
1207
+
1208
+ // Re-energise simulation gently
1209
+ if(simulation) simulation.alpha(0.1).restart();
1210
+ renderSwitcher();
1211
+ updateCounter();
1212
+ }
1213
+
1214
+ // ── Mock data ─────────────────────────────────
1215
+ const MOCK = [
1216
+ { id:'default', name:'My Project', threads:[
1217
+ {id:'a1',title:'Thread Visualizer UI', status:'active', message_count:31,created_at:'2026-05-13T20:00:00Z'},
1218
+ {id:'a2',title:'FastMCP Server', status:'awaiting', message_count:44,created_at:'2026-05-13T18:00:00Z'},
1219
+ {id:'a3',title:'DynamoDB Schema', status:'running', message_count:28,created_at:'2026-05-13T16:00:00Z'},
1220
+ {id:'a4',title:'OpenSearch Index', status:'idle', message_count:12,created_at:'2026-05-13T14:00:00Z'},
1221
+ {id:'a5',title:'WebSocket Manager', status:'idle', message_count:9, created_at:'2026-05-13T12:00:00Z'},
1222
+ {id:'a6',title:'Code Index Hook', status:'running', message_count:17,created_at:'2026-05-13T10:00:00Z'},
1223
+ {id:'a7',title:'Skill Registry', status:'idle', message_count:8, created_at:'2026-05-13T08:00:00Z'},
1224
+ {id:'a8',title:'ADK Prototype', status:'killed', message_count:21,created_at:'2026-05-12T20:00:00Z'},
1225
+ {id:'a9',title:'LiteLLM Spike', status:'killed', message_count:7, created_at:'2026-05-12T14:00:00Z'},
1226
+ {id:'aa',title:'Old Auth Flow', status:'killed', message_count:5, created_at:'2026-05-11T10:00:00Z'},
1227
+ ]},
1228
+ { id:'gemma', name:'Gemma Fine-Tune', threads:[
1229
+ {id:'g1',title:'Training Run v3', status:'active', message_count:62,created_at:'2026-05-13T19:00:00Z'},
1230
+ {id:'g2',title:'Dataset Cleaning', status:'running', message_count:38,created_at:'2026-05-13T17:00:00Z'},
1231
+ {id:'g3',title:'Loss Curve Analysis', status:'awaiting', message_count:19,created_at:'2026-05-13T15:00:00Z'},
1232
+ {id:'g4',title:'Hyperparam Sweep', status:'idle', message_count:11,created_at:'2026-05-13T13:00:00Z'},
1233
+ {id:'g5',title:'Tokenizer Config', status:'idle', message_count:6, created_at:'2026-05-13T11:00:00Z'},
1234
+ {id:'g6',title:'Checkpoint Manager', status:'running', message_count:24,created_at:'2026-05-13T09:00:00Z'},
1235
+ {id:'g7',title:'Eval Benchmark', status:'idle', message_count:14,created_at:'2026-05-13T07:00:00Z'},
1236
+ {id:'g8',title:'LoRA Config', status:'idle', message_count:8, created_at:'2026-05-12T22:00:00Z'},
1237
+ {id:'g9',title:'Eval Run v2', status:'killed', message_count:31,created_at:'2026-05-12T18:00:00Z'},
1238
+ {id:'ga',title:'Eval Run v1', status:'killed', message_count:18,created_at:'2026-05-12T12:00:00Z'},
1239
+ {id:'gb',title:'Base Model Spike', status:'killed', message_count:9, created_at:'2026-05-11T16:00:00Z'},
1240
+ {id:'gc',title:'Data Pipeline v1', status:'killed', message_count:4, created_at:'2026-05-10T10:00:00Z'},
1241
+ {id:'gd',title:'Warmup Experiment', status:'killed', message_count:6, created_at:'2026-05-09T14:00:00Z'},
1242
+ ]},
1243
+ { id:'client-a', name:'Client Arcturus', threads:[
1244
+ {id:'c1',title:'System Architecture', status:'active', message_count:47,created_at:'2026-05-13T20:00:00Z'},
1245
+ {id:'c2',title:'API Contract', status:'awaiting', message_count:33,created_at:'2026-05-13T18:00:00Z'},
1246
+ {id:'c3',title:'Cost Estimate', status:'running', message_count:22,created_at:'2026-05-13T16:00:00Z'},
1247
+ {id:'c4',title:'Pitch Deck', status:'active', message_count:18,created_at:'2026-05-13T14:00:00Z'},
1248
+ {id:'c5',title:'DB Schema Design', status:'idle', message_count:14,created_at:'2026-05-13T12:00:00Z'},
1249
+ {id:'c6',title:'Auth Flow', status:'running', message_count:27,created_at:'2026-05-13T10:00:00Z'},
1250
+ {id:'c7',title:'Infra Provisioning', status:'idle', message_count:9, created_at:'2026-05-13T08:00:00Z'},
1251
+ {id:'c8',title:'Security Review', status:'awaiting', message_count:16,created_at:'2026-05-13T06:00:00Z'},
1252
+ {id:'c9',title:'Performance Baseline', status:'idle', message_count:7, created_at:'2026-05-12T22:00:00Z'},
1253
+ {id:'ca',title:'Monitoring Setup', status:'idle', message_count:5, created_at:'2026-05-12T20:00:00Z'},
1254
+ {id:'cb',title:'Scope v1', status:'killed', message_count:19,created_at:'2026-05-12T14:00:00Z'},
1255
+ {id:'cc',title:'Old Arch Proposal', status:'killed', message_count:14,created_at:'2026-05-12T10:00:00Z'},
1256
+ {id:'cd',title:'Vendor Eval', status:'killed', message_count:8, created_at:'2026-05-11T18:00:00Z'},
1257
+ {id:'ce',title:'Monolith Spike', status:'killed', message_count:6, created_at:'2026-05-11T12:00:00Z'},
1258
+ {id:'cf',title:'Kubernetes Spike', status:'killed', message_count:4, created_at:'2026-05-10T10:00:00Z'},
1259
+ ]},
1260
+ ];
1261
+
1262
+ // ── WS ────────────────────────────────────────
1263
+ let wsRetry=1000;
1264
+ function connectWs(){
1265
+ setHealth('warn','connecting...');
1266
+ const ws=new WebSocket(WS);
1267
+ ws.onopen=()=>{wsRetry=1000;setHealth('ok','connected')};
1268
+ ws.onmessage=e=>{
1269
+ const d=JSON.parse(e.data);
1270
+ if(d.projects) applyData(d);
1271
+ else if(d.threads) applyData({projects:[{id:'default',name:'default',threads:d.threads}], current_thread_id: d.current_thread_id||null});
1272
+ };
1273
+ ws.onclose=()=>{setHealth('err','disconnected');setTimeout(connectWs,wsRetry);wsRetry=Math.min(wsRetry*2,16000)};
1274
+ ws.onerror=()=>ws.close();
1275
+ }
1276
+ function setHealth(s,m){
1277
+ document.getElementById('hdot').className='hdot '+s;
1278
+ document.getElementById('hmsg').textContent=m;
1279
+ }
1280
+
1281
+ // ── Init ──────────────────────────────────────
1282
+ // Show mock immediately — frozen, no WebSocket overwrite
1283
+ // Touch support for <900px (AC3)
1284
+ let _touchStartX = 0;
1285
+ document.addEventListener('touchstart', e=>{ _touchStartX = e.touches[0].clientX; }, {passive:true});
1286
+ document.addEventListener('touchend', e=>{
1287
+ const dx = e.changedTouches[0].clientX - _touchStartX;
1288
+ if(dx > 60 && document.getElementById('panel').classList.contains('open')) closePanel();
1289
+ }, {passive:true});
1290
+
1291
+ // Init: try live data first, fall back to mock if server returns empty
1292
+ async function init(){
1293
+ try {
1294
+ const res = await fetch(`${API}/api/threads`);
1295
+ const data = await res.json();
1296
+ const hasProjects = data.projects && data.projects.length > 0;
1297
+ const hasThreads = Array.isArray(data) && data.length > 0;
1298
+ if(hasProjects || hasThreads){
1299
+ applyData(data); // real server has threads — use live data
1300
+ } else {
1301
+ applyData({ projects: MOCK, _isMock: true }); // no real threads yet — show mock
1302
+ setHealth('warn','demo data — use CT <name> to create a thread');
1303
+ }
1304
+ } catch(e) {
1305
+ applyData({ projects: MOCK }); // server unreachable — show mock
1306
+ }
1307
+ connectWs(); // always connect WS for live updates
1308
+ }
1309
+ init();
1310
+
1311
+ window.addEventListener('resize',()=>location.reload());
1312
+ </script>
1313
+ </body>
1314
+ </html>