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.
- package/.aws-setup.sh +25 -0
- package/.claude-plugin/plugin.json +22 -0
- package/.mcp.json +12 -0
- package/AGENTS.md +93 -0
- package/CLAUDE.md +99 -0
- package/CLAUDE_PLATFORM_AWS_SETUP.md +105 -0
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/assets/tylor_logo.png +0 -0
- package/assets/tylor_threads_concept.png +0 -0
- package/bin/tylor.js +23 -0
- package/hooks/kill-thread-trigger.sh +7 -0
- package/hooks/post-tool-use-code-index.sh +7 -0
- package/hooks/session-checkpoint.sh +7 -0
- package/hooks/session-start.sh +7 -0
- package/install.py +401 -0
- package/install.sh +260 -0
- package/package.json +24 -0
- package/pytest.ini +2 -0
- package/registry.json +26 -0
- package/server/.env.example +24 -0
- package/server/__init__.py +0 -0
- package/server/config.py +89 -0
- package/server/main.py +93 -0
- package/server/personas/analyst.md +15 -0
- package/server/personas/ceo.md +14 -0
- package/server/personas/code_agent.md +15 -0
- package/server/personas/cto.md +14 -0
- package/server/provision.py +260 -0
- package/server/provision_opensearch.py +154 -0
- package/server/requirements.txt +26 -0
- package/server/storage/__init__.py +0 -0
- package/server/storage/dynamo.py +399 -0
- package/server/storage/json_store.py +359 -0
- package/server/storage/opensearch.py +194 -0
- package/server/storage/s3.py +96 -0
- package/server/storage/tests/__init__.py +0 -0
- package/server/storage/tests/test_dynamo.py +452 -0
- package/server/storage/tests/test_json_store.py +226 -0
- package/server/storage/tests/test_opensearch.py +270 -0
- package/server/storage/tests/test_s3.py +125 -0
- package/server/tests/__init__.py +0 -0
- package/server/tests/test_install.py +606 -0
- package/server/tests/test_isolation.py +90 -0
- package/server/tests/test_ui_server.py +385 -0
- package/server/tests/test_ui_shader_background.py +52 -0
- package/server/tests/test_ui_story_6_3.py +105 -0
- package/server/tools/__init__.py +0 -0
- package/server/tools/_mcp.py +4 -0
- package/server/tools/agents.py +160 -0
- package/server/tools/ecc/__init__.py +1 -0
- package/server/tools/ecc/data.py +35 -0
- package/server/tools/ecc/diagrams.py +23 -0
- package/server/tools/ecc/pipeline.py +24 -0
- package/server/tools/ecc/presentation.py +24 -0
- package/server/tools/ecc/web.py +23 -0
- package/server/tools/executor.py +880 -0
- package/server/tools/harness.py +330 -0
- package/server/tools/help.py +162 -0
- package/server/tools/hooks.py +357 -0
- package/server/tools/personas.py +110 -0
- package/server/tools/registry.py +195 -0
- package/server/tools/router.py +117 -0
- package/server/tools/skill_installer.py +230 -0
- package/server/tools/summarizer.py +168 -0
- package/server/tools/tests/__init__.py +0 -0
- package/server/tools/tests/test_agents.py +246 -0
- package/server/tools/tests/test_code_index.py +108 -0
- package/server/tools/tests/test_ecc_tools.py +51 -0
- package/server/tools/tests/test_executor.py +584 -0
- package/server/tools/tests/test_help_agent101.py +149 -0
- package/server/tools/tests/test_hooks.py +124 -0
- package/server/tools/tests/test_kill_thread.py +125 -0
- package/server/tools/tests/test_new_thread_list_threads.py +293 -0
- package/server/tools/tests/test_personas.py +52 -0
- package/server/tools/tests/test_recall_memory.py +55 -0
- package/server/tools/tests/test_registry_client.py +308 -0
- package/server/tools/tests/test_router.py +263 -0
- package/server/tools/tests/test_skill_installer.py +174 -0
- package/server/tools/tests/test_switch_thread.py +163 -0
- package/server/tools/tests/test_thread_command_skills.py +54 -0
- package/server/tools/tests/test_thread_resolver.py +165 -0
- package/server/tools/tests/test_tier1_schema.py +296 -0
- package/server/tools/thread_resolver.py +75 -0
- package/server/tools/tylor.py +374 -0
- package/server/tools/ui.py +38 -0
- package/server/ui_server.py +292 -0
- package/server/validate.py +237 -0
- package/skills/add-skill/SKILL.md +37 -0
- package/skills/afk-status/SKILL.md +20 -0
- package/skills/bmad/SKILL.md +14 -0
- package/skills/help-agent101/SKILL.md +48 -0
- package/skills/kill-thread/SKILL.md +35 -0
- package/skills/list-threads/SKILL.md +35 -0
- package/skills/new-thread/SKILL.md +35 -0
- package/skills/recall/SKILL.md +39 -0
- package/skills/run/SKILL.md +33 -0
- package/skills/set-sandbox/SKILL.md +38 -0
- package/skills/switch-thread/SKILL.md +38 -0
- package/ui/claude-logo.png +0 -0
- 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,'<')}</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>
|