@clawlabz/clawskin 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.
@@ -0,0 +1,551 @@
1
+ /**
2
+ * ClawSkinApp.js — Main application controller for ClawSkin product mode
3
+ * Supports multiple agents displayed simultaneously in one scene
4
+ */
5
+ class ClawSkinApp {
6
+ constructor(canvasId, options = {}) {
7
+ this.canvas = document.getElementById(canvasId);
8
+ this.ctx = this.canvas.getContext('2d');
9
+ this.width = options.width || 640;
10
+ this.height = options.height || 400;
11
+ this.canvas.width = this.width;
12
+ this.canvas.height = this.height;
13
+
14
+ this.settings = new SettingsManager();
15
+ this.gateway = null;
16
+ this.demoMode = null;
17
+ this.spriteGen = new SpriteGenerator();
18
+ this.scenes = [];
19
+ this.currentScene = null;
20
+ this.running = false;
21
+ this.lastTime = 0;
22
+ this.mode = 'demo'; // 'demo' | 'live'
23
+
24
+ // Multi-agent support
25
+ this.agents = []; // AgentSlot[]
26
+ this.demoCharacter = null; // Single character for demo mode
27
+ this.petManager = null; // Independent pet system
28
+
29
+ // UI elements
30
+ this.statusEl = options.statusEl ? document.getElementById(options.statusEl) : null;
31
+ this.modeEl = options.modeEl ? document.getElementById(options.modeEl) : null;
32
+ this.agentListEl = options.agentListEl ? document.getElementById(options.agentListEl) : null;
33
+ }
34
+
35
+ init() {
36
+ const config = this.settings.load();
37
+ const charConfig = config.character || CharacterEditor.loadSaved() || CharacterSprite.defaultConfig();
38
+
39
+ // Scenes
40
+ this.scenes = [
41
+ new OfficeScene(this.canvas, this.ctx, this.spriteGen),
42
+ new HackerScene(this.canvas, this.ctx, this.spriteGen),
43
+ new CafeScene(this.canvas, this.ctx, this.spriteGen),
44
+ ];
45
+ this.scenes.forEach(s => s.init());
46
+
47
+ const sceneIdx = config.scene || 0;
48
+ this.currentScene = this.scenes[sceneIdx] || this.scenes[0];
49
+
50
+ // Demo character (used when not connected)
51
+ const pos = this.currentScene.getCharacterPosition();
52
+ this.demoCharacter = new CharacterSprite(charConfig, pos.x, pos.y);
53
+ this.demoMode = new DemoMode(this.demoCharacter);
54
+
55
+ // Gateway client
56
+ this.gateway = new GatewayClient({
57
+ autoReconnect: true,
58
+ onConnected: (hello) => this._onGatewayConnected(hello),
59
+ onDisconnected: (info) => this._onGatewayDisconnected(info),
60
+ onEvent: (event) => this._onGatewayEvent(event),
61
+ onStateChange: (state, detail) => this._onConnectionStateChange(state, detail),
62
+ });
63
+
64
+ // Independent pet system
65
+ this.petManager = new PetManager(this.spriteGen);
66
+ this.petManager.setSceneBounds(this.width, this.height);
67
+ this.petManager.initDefaults();
68
+
69
+ // Click-to-customize: click an agent to open their character editor
70
+ this.canvas.addEventListener('click', (e) => this._handleCanvasClick(e));
71
+ this.selectedAgent = null;
72
+
73
+ return this;
74
+ }
75
+
76
+ _handleCanvasClick(e) {
77
+ if (this.mode !== 'live') return;
78
+ const rect = this.canvas.getBoundingClientRect();
79
+ const scaleX = this.width / rect.width;
80
+ const scaleY = this.height / rect.height;
81
+ const clickX = (e.clientX - rect.left) * scaleX;
82
+ const clickY = (e.clientY - rect.top) * scaleY;
83
+
84
+ for (const slot of this.agents) {
85
+ if (slot.hitTest(clickX, clickY)) {
86
+ this.selectedAgent = slot;
87
+ // Update CharacterEditor to edit this agent
88
+ if (window._charEditor) {
89
+ window._charEditor.character = slot.character;
90
+ window._charEditor.onChange = (cfg) => {
91
+ slot.updateConfig(cfg);
92
+ };
93
+ // Show editor and scroll to it
94
+ const editorEl = document.getElementById('char-editor');
95
+ if (editorEl) {
96
+ editorEl.style.display = 'block';
97
+ window._charEditor.visible = true;
98
+ window._charEditor.render();
99
+ editorEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
100
+ }
101
+ // Update header to show which agent is being edited
102
+ const header = document.querySelector('.editor-header span, .editor-header');
103
+ if (header) {
104
+ header.textContent = '🎨 Editing: ' + slot.name;
105
+ }
106
+ }
107
+ // Visual feedback — show a brief highlight
108
+ slot.character.showBubble('✨', 'speech', 1000);
109
+ return;
110
+ }
111
+ }
112
+ }
113
+
114
+ start() {
115
+ this.running = true;
116
+ this.lastTime = performance.now();
117
+
118
+ const config = this.settings.load();
119
+
120
+ if (config.autoConnect && config.gatewayUrl) {
121
+ this.connectToGateway(config.gatewayUrl, config.token);
122
+ } else {
123
+ this._startDemoMode();
124
+ this._tryAutoDetect();
125
+ }
126
+
127
+ this._loop();
128
+ }
129
+
130
+ // ──── Gateway Connection ────
131
+
132
+ connectToGateway(url, token) {
133
+ this.settings.update({
134
+ gatewayUrl: url,
135
+ token: token || '',
136
+ lastConnected: Date.now(),
137
+ });
138
+
139
+ this.gateway.connect(url, token);
140
+ }
141
+
142
+ disconnectGateway() {
143
+ this.gateway.disconnect(true);
144
+ this._clearAgents();
145
+ this._startDemoMode();
146
+ this.settings.update({ autoConnect: false });
147
+ }
148
+
149
+ async _tryAutoDetect() {
150
+ // On remote deployments (not localhost/file), skip local probing entirely.
151
+ // Users on remote hosts should connect via the UI panel.
152
+ const origin = window.location.origin || '';
153
+ const isLocal = !origin || origin.includes('localhost') || origin.includes('127.0.0.1') || origin.startsWith('file:');
154
+
155
+ if (!isLocal) {
156
+ // Remote deployment — show connection panel immediately
157
+ if (window._connPanel) {
158
+ window._connPanel.expanded = true;
159
+ window._connPanel.render();
160
+ }
161
+ return;
162
+ }
163
+
164
+ // Local mode — try auto-detect
165
+
166
+ // Step 1: Try fetching local config from serve.cjs (/api/config)
167
+ try {
168
+ const res = await fetch('/api/config', { signal: AbortSignal.timeout(2000) });
169
+ if (res.ok) {
170
+ const config = await res.json();
171
+ if (config?.gatewayUrl) {
172
+ const saved = this.settings.load();
173
+ this.settings.update({
174
+ gatewayUrl: config.gatewayUrl,
175
+ autoConnect: true,
176
+ });
177
+ if (window._connPanel) {
178
+ window._connPanel.render(this.settings.load());
179
+ }
180
+ // Use previously saved token if available; never read token from /api/config
181
+ this.gateway.connect(config.gatewayUrl, saved.token || '');
182
+ return;
183
+ }
184
+ }
185
+ } catch {}
186
+
187
+ // Step 2: Fallback — probe ws://localhost:18789 directly
188
+ const localUrl = 'ws://localhost:18789';
189
+ try {
190
+ const ws = new WebSocket(localUrl);
191
+ const timeout = setTimeout(() => { ws.close(); }, 3000);
192
+ ws.onopen = () => {
193
+ clearTimeout(timeout);
194
+ ws.close();
195
+ this.settings.update({ gatewayUrl: localUrl });
196
+ this.gateway.connect(localUrl, '');
197
+ };
198
+ ws.onerror = () => {
199
+ clearTimeout(timeout);
200
+ if (window._connPanel) {
201
+ window._connPanel.expanded = true;
202
+ window._connPanel.render();
203
+ }
204
+ };
205
+ } catch {}
206
+ }
207
+
208
+ async _onGatewayConnected(hello) {
209
+ this.mode = 'live';
210
+ this.demoMode.stop();
211
+ this._updateModeDisplay();
212
+
213
+ // Discover all active sessions from Gateway
214
+ await this._discoverAgents();
215
+ this._renderAgentList();
216
+ this._updateStatusDisplay();
217
+ }
218
+
219
+ _onGatewayDisconnected(info) {
220
+ this.mode = 'demo';
221
+ this._clearAgents();
222
+ this._startDemoMode();
223
+ this._updateModeDisplay();
224
+ this._renderAgentList();
225
+ }
226
+
227
+ _onGatewayEvent(event) {
228
+ if (!event || !event.event) return;
229
+
230
+ // Route events to matching agent slots
231
+ const payload = event.payload;
232
+ const sessionKey = payload?.sessionKey;
233
+
234
+ if (sessionKey) {
235
+ // Parse agentId from sessionKey (e.g. "agent:ifig:discord:..." → "ifig")
236
+ const match = sessionKey.match(/^agent:([^:]+):/);
237
+ const agentId = match ? match[1] : 'main';
238
+
239
+ // Route to matching agent
240
+ const slot = this.agents.find(a => a.agentId === agentId);
241
+ if (slot) {
242
+ slot.handleEvent(event);
243
+ }
244
+ } else {
245
+ // Broadcast to all agents
246
+ for (const slot of this.agents) {
247
+ slot.handleEvent(event);
248
+ }
249
+ }
250
+
251
+ this._updateStatusDisplay();
252
+ }
253
+
254
+ _onConnectionStateChange(state, detail) {
255
+ if (window._connPanel) {
256
+ window._connPanel.setState(state, detail);
257
+ if (state === 'error' && detail && (detail.includes('auth') || detail.includes('token') || detail.includes('pairing'))) {
258
+ window._connPanel.expanded = true;
259
+ window._connPanel.render();
260
+ }
261
+ }
262
+ this._updateStatusDisplay();
263
+ }
264
+
265
+ // ──── Multi-Agent Management ────
266
+
267
+ async _discoverAgents() {
268
+ try {
269
+ const result = await this.gateway.getSessionsList({ activeMinutes: 1440 });
270
+ const sessions = result?.sessions || result || [];
271
+
272
+ if (!Array.isArray(sessions) || sessions.length === 0) {
273
+ this._addAgent({ agentId: 'main', label: 'Main Agent', sessionKeys: ['main'] });
274
+ return;
275
+ }
276
+
277
+ // Group sessions by agentId
278
+ // Session keys look like: "agent:main:main", "agent:ifig:discord:channel:123", "agent:xhs:main"
279
+ // The agentId is either session.agentId or extracted from the key pattern "agent:<agentId>:..."
280
+ const agentMap = new Map();
281
+
282
+ for (const session of sessions) {
283
+ const key = session.key || session.sessionKey;
284
+ if (!key) continue;
285
+
286
+ // Determine agentId: prefer session.agentId, else parse from key
287
+ let agentId = session.agentId || null;
288
+ if (!agentId) {
289
+ const match = key.match(/^agent:([^:]+):/);
290
+ agentId = match ? match[1] : 'main';
291
+ }
292
+
293
+ if (!agentMap.has(agentId)) {
294
+ agentMap.set(agentId, {
295
+ agentId,
296
+ label: session.label || agentId,
297
+ sessionKeys: [],
298
+ });
299
+ }
300
+ agentMap.get(agentId).sessionKeys.push(key);
301
+ }
302
+
303
+ // Create one AgentSlot per unique agentId
304
+ for (const [agentId, info] of agentMap) {
305
+ this._addAgent({
306
+ agentId,
307
+ label: info.label,
308
+ sessionKeys: info.sessionKeys,
309
+ });
310
+ }
311
+
312
+ if (this.agents.length === 0) {
313
+ this._addAgent({ agentId: 'main', label: 'Main Agent', sessionKeys: ['main'] });
314
+ }
315
+ } catch (e) {
316
+ console.warn('[ClawSkinApp] session discovery failed:', e);
317
+ this._addAgent({ agentId: 'main', label: 'Main Agent', sessionKeys: ['main'] });
318
+ }
319
+
320
+ // Fetch identity (name) for each agent
321
+ for (const slot of this.agents) {
322
+ this._fetchAgentIdentity(slot);
323
+ }
324
+ }
325
+
326
+ _addAgent(options) {
327
+ // Don't duplicate by agentId
328
+ if (this.agents.find(a => a.agentId === options.agentId)) return;
329
+
330
+ const index = this.agents.length;
331
+
332
+ const slot = new AgentSlot({
333
+ ...options,
334
+ index,
335
+ x: 0,
336
+ y: 0,
337
+ showNameTag: true,
338
+ });
339
+
340
+ this.agents.push(slot);
341
+
342
+ // Give all agents cross-references for social wandering
343
+ for (const a of this.agents) {
344
+ a._otherAgents = this.agents;
345
+ }
346
+
347
+ this._repositionAgents();
348
+ }
349
+
350
+ _clearAgents() {
351
+ for (const slot of this.agents) slot.destroy();
352
+ this.agents = [];
353
+ this._renderAgentList();
354
+ }
355
+
356
+ _repositionAgents() {
357
+ // Positions are now determined by workstations in _render()
358
+ // No manual positioning needed
359
+ }
360
+
361
+ async _fetchAgentIdentity(slot) {
362
+ try {
363
+ const identity = await this.gateway.getAgentIdentity(slot.agentId);
364
+ if (identity?.name) {
365
+ slot.name = identity.name;
366
+ slot.agentId = identity.agentId || slot.agentId;
367
+ this._renderAgentList();
368
+ }
369
+ } catch {}
370
+ }
371
+
372
+ // ──── Demo Mode ────
373
+
374
+ _startDemoMode() {
375
+ this.mode = 'demo';
376
+ const pos = this.currentScene.getCharacterPosition();
377
+ this.demoCharacter.x = pos.x;
378
+ this.demoCharacter.y = pos.y;
379
+ this.demoMode.start();
380
+ this._updateModeDisplay();
381
+ this._updateStatusDisplay();
382
+ }
383
+
384
+ // ──── Scene Management ────
385
+
386
+ setScene(index) {
387
+ if (index < 0 || index >= this.scenes.length) return;
388
+ this.currentScene = this.scenes[index];
389
+ this.currentScene.init();
390
+
391
+ if (this.mode === 'demo') {
392
+ const pos = this.currentScene.getCharacterPosition();
393
+ this.demoCharacter.x = pos.x;
394
+ this.demoCharacter.y = pos.y;
395
+ } else {
396
+ this._repositionAgents();
397
+ }
398
+ this.settings.update({ scene: index });
399
+ }
400
+
401
+ // ──── Render Loop ────
402
+
403
+ _loop() {
404
+ if (!this.running) return;
405
+ const now = performance.now();
406
+ const dt = now - this.lastTime;
407
+ this.lastTime = now;
408
+
409
+ this._update(dt);
410
+ this._render();
411
+ requestAnimationFrame(() => this._loop());
412
+ }
413
+
414
+ _update(dt) {
415
+ if (this.currentScene) this.currentScene.update(dt);
416
+
417
+ if (this.mode === 'demo') {
418
+ this.demoMode.update(dt);
419
+ this.demoCharacter.update(dt);
420
+ } else {
421
+ for (const slot of this.agents) slot.update(dt);
422
+ }
423
+
424
+ // Update independent pets
425
+ if (this.petManager) {
426
+ const obstacles = this.currentScene?.obstacles || [];
427
+ this.petManager.update(dt, this.agents, obstacles);
428
+ }
429
+ }
430
+
431
+ _render() {
432
+ this.ctx.clearRect(0, 0, this.width, this.height);
433
+ this.ctx.imageSmoothingEnabled = false;
434
+
435
+ // Layer 0: Background wall + static decorations
436
+ if (this.currentScene) this.currentScene.render(this.ctx);
437
+
438
+ const scene = this.currentScene;
439
+
440
+ if (this.mode === 'demo') {
441
+ const stations = scene?.getWorkstations?.(1) || [];
442
+ const s = stations[0];
443
+ if (s) {
444
+ this.demoCharacter.x = s.charX;
445
+ this.demoCharacter.y = s.charY;
446
+ if (s.charScale) this.demoCharacter.scale = s.charScale;
447
+
448
+ // Back-to-front: chair → character → desk+monitor
449
+ scene.renderChair(this.ctx, s);
450
+ this.demoCharacter.render(this.ctx);
451
+ scene.renderDesk(this.ctx, s, this.demoCharacter.animManager?.currentState || 'idle');
452
+ } else {
453
+ this.demoCharacter.render(this.ctx);
454
+ }
455
+ } else {
456
+ // Live mode — 3-phase rendering with Y-sort depth ordering
457
+ const stations = scene?.getWorkstations?.(this.agents.length) || [];
458
+
459
+ // Assign stations to agents
460
+ for (let i = 0; i < this.agents.length; i++) {
461
+ const s = stations[i];
462
+ if (!s) continue;
463
+ const slot = this.agents[i];
464
+ slot.station = s;
465
+ slot._scene = scene;
466
+ if (s.charScale) slot.character.scale = s.charScale;
467
+
468
+ // If not wandering and not manually positioned, snap to station
469
+ if (!slot.isWandering && !slot._isReturning) {
470
+ slot.character.x = s.charX;
471
+ if (!slot._manualY) slot.character.y = s.charY;
472
+ }
473
+ }
474
+
475
+ // Phase 1: Render ALL chairs (furniture is always visible)
476
+ for (let i = 0; i < stations.length; i++) {
477
+ scene.renderChair(this.ctx, stations[i]);
478
+ }
479
+
480
+ // Phase 2: Render characters sorted by Y (back-to-front depth sort)
481
+ const sortedAgents = [...this.agents].sort((a, b) => a.character.y - b.character.y);
482
+ for (const slot of sortedAgents) {
483
+ slot.render(this.ctx);
484
+ }
485
+
486
+ // Phase 3: Render ALL desks + laptops + cups (always visible, in front of characters)
487
+ for (let i = 0; i < stations.length; i++) {
488
+ const state = this.agents[i]?.stateMapper.currentState || 'idle';
489
+ scene.renderDesk(this.ctx, stations[i], state);
490
+ }
491
+
492
+ // Phase 4: Render pets (independent of agents)
493
+ if (this.petManager) {
494
+ this.petManager.render(this.ctx);
495
+ }
496
+
497
+ // Phase 5: Render ALL name tags on top of everything
498
+ for (const slot of this.agents) {
499
+ slot.renderNameTag(this.ctx);
500
+ }
501
+ }
502
+ }
503
+
504
+ // ──── UI Updates ────
505
+
506
+ _updateStatusDisplay() {
507
+ if (!this.statusEl) return;
508
+ if (this.mode === 'demo') {
509
+ this.statusEl.textContent = '🎮 Demo Mode';
510
+ return;
511
+ }
512
+ const active = this.agents.filter(a =>
513
+ a.stateMapper.currentState !== 'idle' && a.stateMapper.currentState !== 'sleeping'
514
+ );
515
+ if (active.length > 0) {
516
+ this.statusEl.textContent = `⚡ ${active.length} active / ${this.agents.length} agents`;
517
+ } else {
518
+ this.statusEl.textContent = `💤 ${this.agents.length} agent${this.agents.length > 1 ? 's' : ''} idle`;
519
+ }
520
+ }
521
+
522
+ _updateModeDisplay() {
523
+ if (!this.modeEl) return;
524
+ if (this.mode === 'live') {
525
+ this.modeEl.textContent = '🟢 Live';
526
+ this.modeEl.className = 'mode-badge mode-live';
527
+ } else {
528
+ this.modeEl.textContent = '🎮 Demo';
529
+ this.modeEl.className = 'mode-badge mode-demo';
530
+ }
531
+ }
532
+
533
+ _renderAgentList() {
534
+ if (!this.agentListEl) return;
535
+ if (this.agents.length === 0) {
536
+ this.agentListEl.innerHTML = '';
537
+ return;
538
+ }
539
+ const stateIcons = {
540
+ idle: '💤', thinking: '🤔', typing: '⌨️', executing: '⚙️',
541
+ browsing: '🔍', error: '❌', sleeping: '😴', waving: '👋'
542
+ };
543
+ const esc = ConnectionPanel.esc;
544
+ this.agentListEl.innerHTML = this.agents.map(a => {
545
+ const icon = stateIcons[a.stateMapper.currentState] || '💤';
546
+ return `<span class="agent-badge" title="${esc(a.sessionKey)}">${icon} ${esc(a.name)}</span>`;
547
+ }).join('');
548
+ }
549
+ }
550
+
551
+ if (typeof window !== 'undefined') window.ClawSkinApp = ClawSkinApp;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * ConnectionPanel.js — Gateway connection UI
3
+ * Renders URL/Token input, connect button, status indicator
4
+ */
5
+ class ConnectionPanel {
6
+ /** Escape HTML to prevent XSS from untrusted Gateway data */
7
+ static esc(str) {
8
+ if (!str) return '';
9
+ return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
10
+ }
11
+
12
+ constructor(containerId, options = {}) {
13
+ this.container = document.getElementById(containerId);
14
+ this.onConnect = options.onConnect || null;
15
+ this.onDisconnect = options.onDisconnect || null;
16
+ this.state = 'disconnected'; // disconnected|connecting|connected|error
17
+ this.errorMessage = null;
18
+ this.agentName = null;
19
+ this.agentId = null;
20
+ this.settings = options.settings || {};
21
+ this.expanded = !this.settings.gatewayUrl;
22
+ }
23
+
24
+ render(settings) {
25
+ if (settings) this.settings = settings;
26
+ const s = this.settings;
27
+ const isConnected = this.state === 'connected';
28
+ const isConnecting = this.state === 'connecting';
29
+ const isError = this.state === 'error';
30
+
31
+ // Status dot color
32
+ const dotClass = isConnected ? 'dot-connected' :
33
+ isConnecting ? 'dot-connecting' :
34
+ isError ? 'dot-error' : 'dot-disconnected';
35
+
36
+ const esc = ConnectionPanel.esc;
37
+ const statusText = isConnected ? `Connected${this.agentName ? ' — ' + esc(this.agentName) : ''}` :
38
+ isConnecting ? 'Connecting...' :
39
+ isError ? esc(this.errorMessage || 'Connection failed') :
40
+ 'Not connected';
41
+
42
+ this.container.innerHTML = `
43
+ <div class="conn-panel ${this.expanded ? 'expanded' : 'collapsed'}">
44
+ <div class="conn-status-bar" onclick="window._connPanel.toggleExpand()">
45
+ <div class="conn-status-left">
46
+ <div class="conn-dot ${dotClass}"></div>
47
+ <span class="conn-status-text">${statusText}</span>
48
+ </div>
49
+ <div class="conn-toggle">${this.expanded ? '▲' : '▼'}</div>
50
+ </div>
51
+
52
+ ${this.expanded ? `
53
+ <div class="conn-form">
54
+ <div class="conn-field">
55
+ <label>Gateway URL</label>
56
+ <input type="text" id="conn-url" value="${s.gatewayUrl || ''}"
57
+ placeholder="wss://your-gateway.example.com"
58
+ ${isConnected ? 'disabled' : ''} />
59
+ <div class="conn-hint">
60
+ Local: <code>ws://localhost:18789</code> · Remote: <code>wss://your-machine.ts.net</code>
61
+ </div>
62
+ </div>
63
+ <div class="conn-field">
64
+ <label>Token <span class="conn-optional">(if auth enabled)</span></label>
65
+ <input type="password" id="conn-token" value="${s.token || ''}"
66
+ placeholder="Gateway auth token"
67
+ ${isConnected ? 'disabled' : ''} />
68
+ </div>
69
+ <div class="conn-field">
70
+ <label>Session Key</label>
71
+ <input type="text" id="conn-session" value="${s.sessionKey || 'main'}"
72
+ placeholder="main"
73
+ ${isConnected ? 'disabled' : ''} />
74
+ <div class="conn-hint">Which agent session to visualize</div>
75
+ </div>
76
+ <div class="conn-actions">
77
+ ${isConnected ? `
78
+ <button class="conn-btn conn-btn-disconnect" onclick="window._connPanel._disconnect()">
79
+ Disconnect
80
+ </button>
81
+ ` : `
82
+ <button class="conn-btn conn-btn-connect" onclick="window._connPanel._connect()"
83
+ ${isConnecting ? 'disabled' : ''}>
84
+ ${isConnecting ? '⏳ Connecting...' : '🔌 Connect'}
85
+ </button>
86
+ `}
87
+ <label class="conn-auto-label">
88
+ <input type="checkbox" id="conn-auto" ${s.autoConnect ? 'checked' : ''} />
89
+ Auto-connect on load
90
+ </label>
91
+ </div>
92
+ </div>
93
+ ` : ''}
94
+ </div>
95
+ `;
96
+ }
97
+
98
+ setState(state, detail) {
99
+ this.state = state;
100
+ if (state === 'error') this.errorMessage = detail;
101
+ else if (state === 'connected') this.errorMessage = null;
102
+ this.render();
103
+ }
104
+
105
+ setAgentInfo(name, id) {
106
+ this.agentName = name;
107
+ this.agentId = id;
108
+ if (this.state === 'connected') {
109
+ this.expanded = false;
110
+ }
111
+ this.render();
112
+ }
113
+
114
+ toggleExpand() {
115
+ this.expanded = !this.expanded;
116
+ this.render();
117
+ }
118
+
119
+ _getFormValues() {
120
+ return {
121
+ gatewayUrl: (document.getElementById('conn-url')?.value || '').trim(),
122
+ token: (document.getElementById('conn-token')?.value || '').trim(),
123
+ sessionKey: (document.getElementById('conn-session')?.value || 'main').trim(),
124
+ autoConnect: document.getElementById('conn-auto')?.checked ?? true,
125
+ };
126
+ }
127
+
128
+ _connect() {
129
+ const vals = this._getFormValues();
130
+ if (!vals.gatewayUrl) {
131
+ this.setState('error', 'Please enter a Gateway URL');
132
+ return;
133
+ }
134
+ if (this.onConnect) this.onConnect(vals);
135
+ }
136
+
137
+ _disconnect() {
138
+ if (this.onDisconnect) this.onDisconnect();
139
+ }
140
+ }
141
+
142
+ if (typeof window !== 'undefined') window.ConnectionPanel = ConnectionPanel;