@mp3wizard/figma-console-mcp 1.32.3 → 1.34.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/README.md +25 -17
  2. package/dist/cloudflare/core/cloud-websocket-connector.js +18 -0
  3. package/dist/cloudflare/core/design-system-manifest.js +19 -14
  4. package/dist/cloudflare/core/design-system-tools.js +43 -34
  5. package/dist/cloudflare/core/diagnose-tool.js +4 -0
  6. package/dist/cloudflare/core/enrichment/enrichment-service.js +11 -5
  7. package/dist/cloudflare/core/enrichment/style-resolver.js +38 -18
  8. package/dist/cloudflare/core/figma-api.js +118 -54
  9. package/dist/cloudflare/core/figma-tools.js +179 -63
  10. package/dist/cloudflare/core/port-discovery.js +404 -31
  11. package/dist/cloudflare/core/tokens/alias-resolver.js +75 -5
  12. package/dist/cloudflare/core/tokens/config.js +10 -0
  13. package/dist/cloudflare/core/tokens/dialect.js +232 -0
  14. package/dist/cloudflare/core/tokens/figma-converter.js +144 -16
  15. package/dist/cloudflare/core/tokens/formatters/css-vars.js +21 -12
  16. package/dist/cloudflare/core/tokens/formatters/dtcg.js +106 -30
  17. package/dist/cloudflare/core/tokens/formatters/json.js +28 -10
  18. package/dist/cloudflare/core/tokens/formatters/scss.js +19 -13
  19. package/dist/cloudflare/core/tokens/formatters/style-dictionary-v3.js +15 -9
  20. package/dist/cloudflare/core/tokens/formatters/tailwind-v4.js +14 -9
  21. package/dist/cloudflare/core/tokens/formatters/tokens-studio.js +11 -5
  22. package/dist/cloudflare/core/tokens/index.js +2 -1
  23. package/dist/cloudflare/core/tokens/parsers/dtcg.js +32 -5
  24. package/dist/cloudflare/core/tokens/schemas.js +4 -0
  25. package/dist/cloudflare/core/tokens-tools.js +1017 -88
  26. package/dist/cloudflare/core/version-tools.js +44 -3
  27. package/dist/cloudflare/core/websocket-connector.js +42 -0
  28. package/dist/cloudflare/core/websocket-server.js +99 -8
  29. package/dist/cloudflare/core/write-tools.js +355 -86
  30. package/dist/cloudflare/index.js +7 -7
  31. package/dist/core/design-system-manifest.d.ts +1 -0
  32. package/dist/core/design-system-manifest.d.ts.map +1 -1
  33. package/dist/core/design-system-manifest.js +19 -14
  34. package/dist/core/design-system-manifest.js.map +1 -1
  35. package/dist/core/design-system-tools.d.ts.map +1 -1
  36. package/dist/core/design-system-tools.js +43 -34
  37. package/dist/core/design-system-tools.js.map +1 -1
  38. package/dist/core/diagnose-tool.d.ts +8 -0
  39. package/dist/core/diagnose-tool.d.ts.map +1 -1
  40. package/dist/core/diagnose-tool.js +4 -0
  41. package/dist/core/diagnose-tool.js.map +1 -1
  42. package/dist/core/enrichment/enrichment-service.d.ts.map +1 -1
  43. package/dist/core/enrichment/enrichment-service.js +11 -5
  44. package/dist/core/enrichment/enrichment-service.js.map +1 -1
  45. package/dist/core/enrichment/style-resolver.d.ts +7 -2
  46. package/dist/core/enrichment/style-resolver.d.ts.map +1 -1
  47. package/dist/core/enrichment/style-resolver.js +38 -18
  48. package/dist/core/enrichment/style-resolver.js.map +1 -1
  49. package/dist/core/figma-api.d.ts +18 -9
  50. package/dist/core/figma-api.d.ts.map +1 -1
  51. package/dist/core/figma-api.js +118 -54
  52. package/dist/core/figma-api.js.map +1 -1
  53. package/dist/core/figma-connector.d.ts +12 -0
  54. package/dist/core/figma-connector.d.ts.map +1 -1
  55. package/dist/core/figma-tools.d.ts.map +1 -1
  56. package/dist/core/figma-tools.js +179 -63
  57. package/dist/core/figma-tools.js.map +1 -1
  58. package/dist/core/port-discovery.d.ts +40 -0
  59. package/dist/core/port-discovery.d.ts.map +1 -1
  60. package/dist/core/port-discovery.js +404 -31
  61. package/dist/core/port-discovery.js.map +1 -1
  62. package/dist/core/tokens/alias-resolver.d.ts +45 -3
  63. package/dist/core/tokens/alias-resolver.d.ts.map +1 -1
  64. package/dist/core/tokens/alias-resolver.js +75 -5
  65. package/dist/core/tokens/alias-resolver.js.map +1 -1
  66. package/dist/core/tokens/config.d.ts +28 -0
  67. package/dist/core/tokens/config.d.ts.map +1 -1
  68. package/dist/core/tokens/config.js +10 -0
  69. package/dist/core/tokens/config.js.map +1 -1
  70. package/dist/core/tokens/dialect.d.ts +107 -0
  71. package/dist/core/tokens/dialect.d.ts.map +1 -0
  72. package/dist/core/tokens/dialect.js +233 -0
  73. package/dist/core/tokens/dialect.js.map +1 -0
  74. package/dist/core/tokens/figma-converter.d.ts +23 -2
  75. package/dist/core/tokens/figma-converter.d.ts.map +1 -1
  76. package/dist/core/tokens/figma-converter.js +144 -16
  77. package/dist/core/tokens/figma-converter.js.map +1 -1
  78. package/dist/core/tokens/formatters/css-vars.d.ts.map +1 -1
  79. package/dist/core/tokens/formatters/css-vars.js +21 -12
  80. package/dist/core/tokens/formatters/css-vars.js.map +1 -1
  81. package/dist/core/tokens/formatters/dtcg.d.ts +2 -2
  82. package/dist/core/tokens/formatters/dtcg.d.ts.map +1 -1
  83. package/dist/core/tokens/formatters/dtcg.js +106 -30
  84. package/dist/core/tokens/formatters/dtcg.js.map +1 -1
  85. package/dist/core/tokens/formatters/json.d.ts.map +1 -1
  86. package/dist/core/tokens/formatters/json.js +28 -10
  87. package/dist/core/tokens/formatters/json.js.map +1 -1
  88. package/dist/core/tokens/formatters/scss.d.ts.map +1 -1
  89. package/dist/core/tokens/formatters/scss.js +19 -13
  90. package/dist/core/tokens/formatters/scss.js.map +1 -1
  91. package/dist/core/tokens/formatters/style-dictionary-v3.d.ts.map +1 -1
  92. package/dist/core/tokens/formatters/style-dictionary-v3.js +15 -9
  93. package/dist/core/tokens/formatters/style-dictionary-v3.js.map +1 -1
  94. package/dist/core/tokens/formatters/tailwind-v4.d.ts.map +1 -1
  95. package/dist/core/tokens/formatters/tailwind-v4.js +14 -9
  96. package/dist/core/tokens/formatters/tailwind-v4.js.map +1 -1
  97. package/dist/core/tokens/formatters/tokens-studio.d.ts.map +1 -1
  98. package/dist/core/tokens/formatters/tokens-studio.js +11 -5
  99. package/dist/core/tokens/formatters/tokens-studio.js.map +1 -1
  100. package/dist/core/tokens/index.d.ts +2 -1
  101. package/dist/core/tokens/index.d.ts.map +1 -1
  102. package/dist/core/tokens/index.js +2 -1
  103. package/dist/core/tokens/index.js.map +1 -1
  104. package/dist/core/tokens/parsers/dtcg.js +32 -5
  105. package/dist/core/tokens/parsers/dtcg.js.map +1 -1
  106. package/dist/core/tokens/schemas.d.ts +3 -0
  107. package/dist/core/tokens/schemas.d.ts.map +1 -1
  108. package/dist/core/tokens/schemas.js +4 -0
  109. package/dist/core/tokens/schemas.js.map +1 -1
  110. package/dist/core/tokens/types.d.ts +57 -1
  111. package/dist/core/tokens/types.d.ts.map +1 -1
  112. package/dist/core/tokens/types.js.map +1 -1
  113. package/dist/core/tokens-tools.d.ts +250 -7
  114. package/dist/core/tokens-tools.d.ts.map +1 -1
  115. package/dist/core/tokens-tools.js +1017 -88
  116. package/dist/core/tokens-tools.js.map +1 -1
  117. package/dist/core/version-tools.d.ts.map +1 -1
  118. package/dist/core/version-tools.js +44 -3
  119. package/dist/core/version-tools.js.map +1 -1
  120. package/dist/core/websocket-connector.d.ts +38 -0
  121. package/dist/core/websocket-connector.d.ts.map +1 -1
  122. package/dist/core/websocket-connector.js +42 -0
  123. package/dist/core/websocket-connector.js.map +1 -1
  124. package/dist/core/websocket-server.d.ts +23 -0
  125. package/dist/core/websocket-server.d.ts.map +1 -1
  126. package/dist/core/websocket-server.js +99 -8
  127. package/dist/core/websocket-server.js.map +1 -1
  128. package/dist/core/write-tools.d.ts.map +1 -1
  129. package/dist/core/write-tools.js +355 -86
  130. package/dist/core/write-tools.js.map +1 -1
  131. package/dist/local.d.ts +0 -1
  132. package/dist/local.d.ts.map +1 -1
  133. package/dist/local.js +253 -63
  134. package/dist/local.js.map +1 -1
  135. package/figma-desktop-bridge/code.js +382 -28
  136. package/figma-desktop-bridge/ui.html +578 -292
  137. package/package.json +2 -2
@@ -66,6 +66,10 @@
66
66
  align-items: center;
67
67
  gap: 5px;
68
68
  margin-right: 3px;
69
+ /* Take the free space so long status sentences ellipsize gracefully
70
+ instead of pushing the CTA/icons off the 240px canvas. */
71
+ flex: 1;
72
+ min-width: 0;
69
73
  }
70
74
 
71
75
  .status-indicator {
@@ -90,18 +94,27 @@
90
94
  background: var(--color-error);
91
95
  }
92
96
 
97
+ /* Deliberate user pause — static grey dot, NOT the pulsing loading
98
+ animation, so a pause never reads as "trying to connect". */
99
+ .status-indicator.paused {
100
+ background: var(--color-idle);
101
+ }
102
+
93
103
  @keyframes pulse {
94
104
  0%, 100% { opacity: 0.4; }
95
105
  50% { opacity: 1; }
96
106
  }
97
107
 
98
108
  #status-state {
99
- font-weight: 700;
109
+ font-weight: 600;
100
110
  font-size: 10px;
101
111
  letter-spacing: -0.2px;
102
- text-transform: uppercase;
103
112
  font-stretch: 75%;
104
113
  color: var(--figma-color-text-secondary, rgba(255, 255, 255, 0.6));
114
+ overflow: hidden;
115
+ text-overflow: ellipsis;
116
+ white-space: nowrap;
117
+ min-width: 0;
105
118
  }
106
119
 
107
120
  .status-indicator.active + #status-state {
@@ -134,8 +147,24 @@
134
147
  background: var(--figma-color-bg-secondary, #383838);
135
148
  }
136
149
 
137
- .row-top-spacer {
138
- flex: 1;
150
+ /* ===== Status meta line — connection count + proof of life ===== */
151
+ .status-meta {
152
+ display: none; /* toggled to flex from JS when there's something to show */
153
+ align-items: center;
154
+ gap: 8px;
155
+ padding-left: 17px; /* aligns with the pill text (4px row offset + 8px dot + 5px gap) */
156
+ font-size: 9px;
157
+ color: var(--figma-color-text-secondary, rgba(255, 255, 255, 0.5));
158
+ white-space: nowrap;
159
+ }
160
+
161
+ /* Visible recovery instruction for the ERROR state */
162
+ .status-error-hint {
163
+ display: none; /* shown from JS while in the error state */
164
+ padding-left: 17px;
165
+ font-size: 10px;
166
+ line-height: 1.4;
167
+ color: var(--color-error);
139
168
  }
140
169
 
141
170
  .icon-btn {
@@ -320,43 +349,24 @@
320
349
  height: 11px;
321
350
  }
322
351
 
323
- /* ===== Row: info panel ===== */
324
- .info-panel {
325
- flex-direction: row;
352
+ /* ===== Row: plugin update banner ===== */
353
+ .update-banner {
326
354
  align-items: center;
327
- gap: 8px;
328
- padding: 4px 0;
355
+ gap: 6px;
356
+ padding: 4px 6px;
357
+ background: var(--figma-color-bg-secondary, #383838);
358
+ border: 1px solid var(--figma-color-border, #4a4a4a);
359
+ border-radius: 3px;
329
360
  font-size: 10px;
330
- color: var(--figma-color-text-secondary, rgba(255, 255, 255, 0.7));
361
+ line-height: 1.4;
362
+ color: var(--figma-color-text, rgba(255, 255, 255, 0.9));
331
363
  }
332
364
 
333
- .info-rows {
334
- display: flex;
335
- flex-direction: column;
336
- gap: 2px;
365
+ .update-banner span {
337
366
  flex: 1;
338
367
  min-width: 0;
339
368
  }
340
369
 
341
- .info-row {
342
- display: flex;
343
- gap: 6px;
344
- overflow: hidden;
345
- align-items: center;
346
- }
347
-
348
- .info-row-label {
349
- color: var(--figma-color-text-secondary, rgba(255, 255, 255, 0.5));
350
- flex-shrink: 0;
351
- }
352
-
353
- .info-row-value {
354
- color: var(--figma-color-text, rgba(255, 255, 255, 0.9));
355
- overflow: hidden;
356
- text-overflow: ellipsis;
357
- white-space: nowrap;
358
- }
359
-
360
370
  /* ===== Row: log panel ===== */
361
371
  .log-panel {
362
372
  flex-direction: column;
@@ -367,16 +377,6 @@
367
377
  background: var(--figma-color-bg, #1e1e1e);
368
378
  }
369
379
 
370
- .log-header {
371
- display: flex;
372
- justify-content: flex-end;
373
- padding: 3px 6px;
374
- font-size: 9px;
375
- color: var(--figma-color-text-secondary, rgba(255, 255, 255, 0.5));
376
- background: var(--figma-color-bg-secondary, #383838);
377
- border-bottom: 1px solid var(--figma-color-border, #4a4a4a);
378
- }
379
-
380
380
  .log-entries {
381
381
  max-height: 160px;
382
382
  overflow-y: auto;
@@ -386,10 +386,6 @@
386
386
  line-height: 1.4;
387
387
  }
388
388
 
389
- .log-entries.errors-only .log-entry:not(.error) {
390
- display: none;
391
- }
392
-
393
389
  .log-entry {
394
390
  display: flex;
395
391
  align-items: baseline;
@@ -470,10 +466,10 @@
470
466
  <div class="row-top">
471
467
  <div class="status-pill">
472
468
  <div class="status-indicator loading" id="status-dot" aria-hidden="true"></div>
473
- <span id="status-state" role="status" aria-live="polite">Connecting</span>
469
+ <span id="status-state" role="status" aria-live="polite">Looking for your AI app…</span>
474
470
  </div>
475
- <button class="cta-btn" id="cta-btn" onclick="toggleLocalConnection()">Pause</button>
476
- <div class="row-top-spacer"></div>
471
+ <!-- CTA starts hidden; the first state reconcile shows it with an honest label -->
472
+ <button class="cta-btn" id="cta-btn" onclick="toggleLocalConnection()" style="display:none">Pause</button>
477
473
  <button class="icon-btn" id="cloud-icon" onclick="toggleCloudPair()" title="Cloud pairing" aria-label="Cloud pairing" aria-expanded="false">
478
474
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
479
475
  <path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"/>
@@ -482,6 +478,21 @@
482
478
  <button class="icon-btn" id="expand-btn" onclick="toggleSubToolbar()" title="Show options" aria-label="Show options" aria-expanded="false">+</button>
483
479
  </div>
484
480
 
481
+ <!-- Status meta — connection count + proof of life (shown when connected) -->
482
+ <div class="status-meta" id="status-meta">
483
+ <span id="status-conn-count"></span>
484
+ <span id="status-last-action"></span>
485
+ </div>
486
+
487
+ <!-- Visible recovery instruction, shown only in the error state -->
488
+ <div class="status-error-hint" id="status-error-hint">Close this window and reopen the plugin.</div>
489
+
490
+ <!-- Plugin update banner (shown when the server reports a newer plugin) -->
491
+ <div class="row update-banner" id="update-banner">
492
+ <span>Plugin update available — re-import it in Figma (Plugins → Development → Import from manifest).</span>
493
+ <button class="icon-btn icon-btn--borderless" onclick="dismissUpdateBanner()" title="Dismiss" aria-label="Dismiss update notice">×</button>
494
+ </div>
495
+
485
496
  <!-- Cloud pairing (shown when cloud icon active) -->
486
497
  <div class="row cloud-pair" id="cloud-pair">
487
498
  <button class="icon-btn icon-btn--borderless cloud-info-btn" id="cloud-info-btn" onclick="toggleCloudHelp()" title="About pairing codes" aria-label="About pairing codes" aria-expanded="false" aria-controls="cloud-help">
@@ -502,29 +513,14 @@
502
513
 
503
514
  <!-- Sub-toolbar (shown when [+] active) -->
504
515
  <div class="row sub-toolbar" id="sub-toolbar">
505
- <button class="sub-btn" id="info-toggle" onclick="toggleInfo()" aria-expanded="false" aria-controls="info-panel">
506
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
507
- <circle cx="12" cy="12" r="10"/>
508
- <line x1="12" y1="16" x2="12" y2="12"/>
509
- <line x1="12" y1="8" x2="12.01" y2="8"/>
510
- </svg>
511
- Info
512
- </button>
513
516
  <button class="sub-btn" id="log-toggle" onclick="toggleLog()" aria-expanded="false" aria-controls="log-panel">
514
517
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
515
518
  <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
516
519
  <circle cx="12" cy="12" r="3"/>
517
520
  </svg>
518
- Log
521
+ Activity
519
522
  </button>
520
- <button class="icon-btn" id="errors-toggle" onclick="toggleErrorsOnly()" title="Filter errors only" aria-label="Filter errors only" aria-pressed="false" style="display:none">
521
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
522
- <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
523
- <line x1="12" y1="9" x2="12" y2="13"/>
524
- <line x1="12" y1="17" x2="12.01" y2="17"/>
525
- </svg>
526
- </button>
527
- <button class="icon-btn" id="copy-log-btn" onclick="copyLogToClipboard()" title="Copy log to clipboard" aria-label="Copy log to clipboard" style="display:none">
523
+ <button class="icon-btn" id="copy-log-btn" onclick="copyLogToClipboard()" title="Copy activity to clipboard" aria-label="Copy activity to clipboard" style="display:none">
528
524
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
529
525
  <rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
530
526
  <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
@@ -532,19 +528,8 @@
532
528
  </button>
533
529
  </div>
534
530
 
535
- <!-- Info panel (shown when Info active) -->
536
- <div class="row info-panel" id="info-panel">
537
- <div class="info-rows">
538
- <div class="info-row"><span class="info-row-label">File:</span><span class="info-row-value" id="info-file">—</span></div>
539
- <div class="info-row"><span class="info-row-label">Page:</span><span class="info-row-value" id="info-page">—</span></div>
540
- </div>
541
- </div>
542
-
543
- <!-- Log panel (shown when Show log active) -->
531
+ <!-- Activity panel (shown when Activity active) -->
544
532
  <div class="row log-panel" id="log-panel">
545
- <div class="log-header">
546
- <span id="log-servers">0 server(s)</span>
547
- </div>
548
533
  <div class="log-entries" id="log-entries"></div>
549
534
  </div>
550
535
  </div>
@@ -561,67 +546,98 @@
561
546
 
562
547
  let requestIdCounter = 0;
563
548
 
564
- // Status pill state captured here so we can re-render whenever the
565
- // mode (local port count, cloud paired) changes without losing the
566
- // ready/connecting/error state set by the data layer.
567
- let _currentStatusState = 'connecting';
568
- let _currentStatusActive = false;
569
- let _currentStatusError = false;
570
-
571
- // Compute the mode label shown in place of the static "MCP" prefix.
572
- // Returns "Local", "Cloud", "Local + Cloud", or "" (unknown falls back
573
- // to the original "MCP" label so the pill always says something).
574
- // Lets users see at a glance which transport is carrying their session,
575
- // so a confused user doesn't have to guess whether they're talking to
576
- // the local server, the cloud relay, or both. Port numbers are omitted
577
- // here to keep the pill compact figma_diagnose exposes the port when
578
- // it's actually needed.
579
- //
580
- // NOTE: `activeConnections` is declared inside the connection-pool IIFE
581
- // below (around line ~617), so it is NOT in scope from this top-level
582
- // function. The IIFE exposes `window.__wsGetActiveConnections()` for
583
- // exactly this reason — calling that getter is what makes the pill
584
- // update once the pool reports a live connection.
585
- function renderModeText() {
586
- try {
587
- var getConns = window.__wsGetActiveConnections;
588
- var conns = typeof getConns === 'function' ? (getConns() || []) : [];
589
- var localUp = conns.some(function(c) {
590
- return c && c.ws && c.ws.readyState === 1 && c.port !== 'cloud';
591
- });
592
- var cloudUp = conns.some(function(c) {
593
- return c && c.ws && c.ws.readyState === 1 && c.port === 'cloud';
594
- });
595
- if (localUp && cloudUp) return 'Local + Cloud';
596
- if (localUp) return 'Local';
597
- if (cloudUp) return 'Cloud';
598
- return '';
599
- } catch (e) {
600
- return '';
601
- }
602
- }
603
-
604
- function refreshStatus() {
549
+ // Status inputs owned by the message handler at the bottom of this file.
550
+ // bridgeError flips true on a code.js ERROR message; a later successful
551
+ // VARIABLES_DATA clears it (the plugin recovered).
552
+ var bridgeError = false;
553
+
554
+ // Single source of truth for the status pill. Derives the rendered state
555
+ // from actual facts instead of trusting one-off events:
556
+ // - live WS connections (local + cloud) from the connection pool
557
+ // - userPaused (pool exposes __wsIsPaused)
558
+ // - scanning/discovery activity (__wsIsScanning / __wsIsDiscoveryActive)
559
+ // - bridgeError (ERROR message from code.js)
560
+ // window.__figmaVariablesReady is also an input (set by VARIABLES_DATA);
561
+ // it no longer drives the pill green on its own a Figma-side data event
562
+ // says nothing about whether an AI app is actually connected.
563
+ function deriveAndRenderStatus() {
605
564
  var dot = document.getElementById('status-dot');
606
565
  var stateText = document.getElementById('status-state');
607
- var labelEl = document.querySelector('.status-text .label');
608
566
  if (!dot || !stateText) return;
609
- dot.className = 'status-indicator ' + (_currentStatusError ? 'error' : (_currentStatusActive ? 'active' : 'loading'));
610
- var mode = renderModeText();
611
- // Replace the static "MCP" label with the live mode label (Local / Cloud
612
- // / Local + Cloud). Fall back to "MCP" when nothing is connected yet so
613
- // the pill still has a visible label during initial scan.
614
- if (labelEl) labelEl.textContent = mode || 'MCP';
615
- stateText.textContent = _currentStatusState;
567
+
568
+ var conns = (typeof window.__wsGetActiveConnections === 'function') ? (window.__wsGetActiveConnections() || []) : [];
569
+ var live = 0;
570
+ for (var i = 0; i < conns.length; i++) {
571
+ if (conns[i] && conns[i].ws && conns[i].ws.readyState === 1) live++;
572
+ }
573
+ var paused = (typeof window.__wsIsPaused === 'function') ? window.__wsIsPaused() : false;
574
+ var scanning = (typeof window.__wsIsScanning === 'function' && window.__wsIsScanning()) ||
575
+ (typeof window.__wsIsDiscoveryActive === 'function' && window.__wsIsDiscoveryActive());
576
+
577
+ var dotClass, text;
578
+ if (bridgeError) {
579
+ dotClass = 'error';
580
+ text = 'Something broke';
581
+ } else if (paused) {
582
+ dotClass = 'paused';
583
+ text = 'Paused — AI can\'t make changes';
584
+ } else if (live >= 1) {
585
+ dotClass = 'active';
586
+ text = 'Connected — AI can work in this file';
587
+ } else if (scanning) {
588
+ dotClass = 'loading';
589
+ text = 'Looking for your AI app…';
590
+ } else {
591
+ // Defensive fallback: discovery is always on unless paused, so this
592
+ // only renders if the discovery loop is somehow disabled.
593
+ dotClass = '';
594
+ text = 'Not connected';
595
+ }
596
+ dot.className = 'status-indicator' + (dotClass ? ' ' + dotClass : '');
597
+ stateText.textContent = text;
598
+ stateText.title = text; // full sentence survives ellipsis truncation
599
+
600
+ var hint = document.getElementById('status-error-hint');
601
+ if (hint) hint.style.display = bridgeError ? 'block' : 'none';
602
+
603
+ renderStatusMeta(live);
604
+ }
605
+
606
+ // Small always-visible line under the pill: "Connected to N AI app(s)"
607
+ // plus proof of life ("Last action: Xs ago"). Hidden entirely when there
608
+ // is nothing to show (zero connections).
609
+ function renderStatusMeta(liveCount) {
610
+ var meta = document.getElementById('status-meta');
611
+ var countEl = document.getElementById('status-conn-count');
612
+ var lastEl = document.getElementById('status-last-action');
613
+ if (!meta || !countEl || !lastEl) return;
614
+ var anything = false;
615
+ if (liveCount >= 1) {
616
+ countEl.textContent = 'Connected to ' + liveCount + ' AI app' + (liveCount === 1 ? '' : 's');
617
+ countEl.style.display = '';
618
+ anything = true;
619
+ } else {
620
+ countEl.textContent = '';
621
+ countEl.style.display = 'none';
622
+ }
623
+ if (liveCount >= 1 && _lastActionAt) {
624
+ lastEl.textContent = 'Last action: ' + formatAgo(Date.now() - _lastActionAt);
625
+ lastEl.style.display = '';
626
+ anything = true;
627
+ } else {
628
+ lastEl.textContent = '';
629
+ lastEl.style.display = 'none';
630
+ }
631
+ meta.style.display = anything ? 'flex' : 'none';
616
632
  }
617
633
 
618
- // UI update helper — preserves the latest state and re-renders, picking
619
- // up any mode-text changes (local/cloud connect/disconnect).
620
- function updateStatus(state, isActive, isError) {
621
- _currentStatusState = state;
622
- _currentStatusActive = isActive;
623
- _currentStatusError = isError;
624
- refreshStatus();
634
+ function formatAgo(ms) {
635
+ var s = Math.max(0, Math.floor(ms / 1000));
636
+ if (s < 60) return s + 's ago';
637
+ var m = Math.floor(s / 60);
638
+ if (m < 60) return m + 'm ago';
639
+ var h = Math.floor(m / 60);
640
+ return h + 'h ago';
625
641
  }
626
642
 
627
643
  // ============================================================================
@@ -1004,11 +1020,7 @@
1004
1020
 
1005
1021
  // Multi-connection state: plugin connects to ALL active MCP servers
1006
1022
  // so that every Claude tab/CLI instance gets Figma access.
1007
- var activeConnections = []; // Array of { port, ws }
1008
- var wsReconnectDelay = 500;
1009
- var wsMaxReconnectDelay = 5000;
1010
- var wsReconnectAttempts = 0;
1011
- var wsMaxReconnectAttempts = 50;
1023
+ var activeConnections = []; // Array of { port, ws, serverVersion?, serverInfo? }
1012
1024
  var isScanning = false;
1013
1025
 
1014
1026
  // Backward-compat: ws and wsConnected reflect "at least one connection"
@@ -1031,6 +1043,28 @@
1031
1043
  'CREATE_VARIABLE_COLLECTION': function(params) { return window.createVariableCollection(params.name, params); },
1032
1044
  'GET_LOCAL_COMPONENTS': function() { return window.getLocalComponents(); },
1033
1045
  'INSTANTIATE_COMPONENT': function(params) { return window.instantiateComponent(params.componentKey, params); },
1046
+ // Component set creation — forward params wholesale; result payload rides in msg.data.
1047
+ // Timeout scales with variant count (the plugin builds all variants in one
1048
+ // uncancellable pass; a fixed 30s ceiling made big matrices report failure
1049
+ // while the set still got created). Mirrors componentSetTimeoutMs in
1050
+ // websocket-connector.ts, which adds a 5s buffer on the server hop so this
1051
+ // hop's result always arrives first — keep the base formula in sync.
1052
+ 'CREATE_COMPONENT_SET': function(params) {
1053
+ var variantCount = 1;
1054
+ if (params && params.componentIds && params.componentIds.length) {
1055
+ variantCount = params.componentIds.length;
1056
+ } else if (params && params.properties) {
1057
+ for (var axis in params.properties) {
1058
+ if (params.properties.hasOwnProperty(axis)) {
1059
+ var vals = params.properties[axis];
1060
+ variantCount *= Math.max(1, (vals && vals.length) || 1);
1061
+ }
1062
+ }
1063
+ }
1064
+ var timeout = Math.min(120000, Math.max(30000, variantCount * 1200));
1065
+ return window.sendPluginCommand('CREATE_COMPONENT_SET', params, timeout)
1066
+ .catch(function(err) { return { success: false, error: err.message || String(err) }; });
1067
+ },
1034
1068
  'GET_COMPONENT': function(params) { return window.requestComponentData(params.nodeId); },
1035
1069
  'SET_NODE_DESCRIPTION': function(params) { return window.setNodeDescription(params.nodeId, params.description, params.descriptionMarkdown); },
1036
1070
  'ADD_COMPONENT_PROPERTY': function(params) { return window.addComponentProperty(params.nodeId, params.propertyName, params.propertyType, params.defaultValue, params); },
@@ -1187,6 +1221,9 @@
1187
1221
  function removeConnection(port) {
1188
1222
  activeConnections = activeConnections.filter(function(c) { return c.port !== port; });
1189
1223
  updateCompatState();
1224
+ // Re-pace the health discovery loop: dropping to zero connections
1225
+ // switches it to the fast (3s) cadence immediately.
1226
+ scheduleDiscovery();
1190
1227
  }
1191
1228
 
1192
1229
  /**
@@ -1204,9 +1241,9 @@
1204
1241
  ws = null;
1205
1242
  wsPort = null;
1206
1243
  }
1207
- // refreshStatus is defined in the outer scope (top-level script block).
1208
- // Guarded for the unlikely case it's invoked before that script runs.
1209
- if (typeof refreshStatus === 'function') refreshStatus();
1244
+ // deriveAndRenderStatus is defined in the outer scope (same script tag,
1245
+ // hoisted). Guarded for the unlikely case it's invoked before then.
1246
+ if (typeof deriveAndRenderStatus === 'function') deriveAndRenderStatus();
1210
1247
  }
1211
1248
 
1212
1249
  /**
@@ -1241,14 +1278,9 @@
1241
1278
  // After connecting, disconnect-triggered retries have their own limit.
1242
1279
  var initialScanAttempts = 0;
1243
1280
  var MAX_INITIAL_SCANS = 3;
1244
- // Set true only when the user clicks Pause. Suppresses the background
1245
- // watchdog so a deliberate pause is not undone automatically.
1281
+ // Set true only when the user clicks Pause. Suppresses the health
1282
+ // discovery loop so a deliberate pause is not undone automatically.
1246
1283
  var userPaused = false;
1247
- // Watchdog cadence: how often to re-probe for an MCP server while we have
1248
- // ZERO connections. Runs only during genuine downtime and stops the moment
1249
- // a server connects, so the unavoidable connection-refused console noise is
1250
- // bounded to periods when nothing is connected anyway.
1251
- var BACKGROUND_RESCAN_MS = 12000;
1252
1284
 
1253
1285
  function wsScanAndConnect() {
1254
1286
  if (isScanning) return;
@@ -1285,8 +1317,6 @@
1285
1317
  pending--;
1286
1318
  if (pending <= 0) {
1287
1319
  isScanning = false;
1288
- wsReconnectDelay = 500;
1289
- wsReconnectAttempts = 0;
1290
1320
  }
1291
1321
  };
1292
1322
 
@@ -1302,11 +1332,11 @@
1302
1332
  // Retry with backoff if no servers found, up to MAX_INITIAL_SCANS
1303
1333
  if (!foundAny && activeConnections.length === 0) {
1304
1334
  // Fast burst on load: a couple of quick retries with backoff.
1305
- // Once the burst exhausts we STOP re-arming here — the background
1306
- // watchdog (BACKGROUND_RESCAN_MS) takes over and keeps probing
1307
- // slowly, so a server that starts AFTER the plugin still connects
1308
- // without a restart. The guard also prevents watchdog-triggered
1309
- // scans from incrementing the counter or logging on every cycle.
1335
+ // Once the burst exhausts we STOP re-arming here — the HTTP
1336
+ // /health discovery loop takes over and keeps probing quietly
1337
+ // (fetch to a closed port rejects catchably, no console spam),
1338
+ // so a server that starts AFTER the plugin still connects
1339
+ // without a restart.
1310
1340
  if (initialScanAttempts < MAX_INITIAL_SCANS) {
1311
1341
  initialScanAttempts++;
1312
1342
  if (initialScanAttempts < MAX_INITIAL_SCANS) {
@@ -1314,7 +1344,7 @@
1314
1344
  console.log('[MCP Bridge] No servers found, retry ' + initialScanAttempts + '/' + MAX_INITIAL_SCANS + ' in ' + (delay/1000) + 's');
1315
1345
  setTimeout(wsScanAndConnect, delay);
1316
1346
  } else {
1317
- console.log('[MCP Bridge] No MCP server yet — watchdog will keep probing every ' + (BACKGROUND_RESCAN_MS/1000) + 's until one appears (no restart needed).');
1347
+ console.log('[MCP Bridge] No MCP server yet — health discovery keeps probing every ' + (DISCOVERY_IDLE_MS/1000) + 's until one appears (no restart needed).');
1318
1348
  }
1319
1349
  }
1320
1350
  }
@@ -1328,10 +1358,18 @@
1328
1358
 
1329
1359
  /**
1330
1360
  * Reconnect a specific port that disconnected.
1331
- * Tries the same port first (server may have just restarted),
1332
- * then does a full rescan to pick up any new servers.
1361
+ * Tries the same port once (server may have just restarted); anything
1362
+ * slower is picked up by the /health discovery loop.
1333
1363
  */
1334
1364
  function wsReconnectPort(port) {
1365
+ if (port === 'cloud') {
1366
+ // The cloud connection is a wss:// relay, not a localhost port —
1367
+ // 'ws://localhost:cloud' is an invalid URL. Re-dial through the
1368
+ // cloud code (correct URL + its own bounded retry/give-up).
1369
+ if (typeof window.__cloudReconnect === 'function') window.__cloudReconnect();
1370
+ return;
1371
+ }
1372
+ if (userPaused || isPortConnected(port)) return;
1335
1373
  try {
1336
1374
  var testWs = new WebSocket('ws://localhost:' + port);
1337
1375
  var timeout = setTimeout(function() {
@@ -1351,7 +1389,39 @@
1351
1389
  clearTimeout(timeout);
1352
1390
  };
1353
1391
  } catch (e) {
1354
- // Port gone — no further scanning to avoid console spam
1392
+ // Port gone — the discovery loop will find it if it comes back
1393
+ }
1394
+ }
1395
+
1396
+ /**
1397
+ * Connect directly to a port the /health probe just verified is OUR
1398
+ * server. Same attach path as the scan, plus the server version the
1399
+ * probe reported (stored on the connection for display/diagnostics).
1400
+ */
1401
+ function wsConnectToPort(port, serverVersion) {
1402
+ if (userPaused || isPortConnected(port)) return;
1403
+ try {
1404
+ var testWs = new WebSocket('ws://localhost:' + port);
1405
+ var timeout = setTimeout(function() {
1406
+ if (testWs.readyState !== 1) testWs.close();
1407
+ }, 3000);
1408
+
1409
+ testWs.onopen = function() {
1410
+ clearTimeout(timeout);
1411
+ var conn = { port: port, ws: testWs };
1412
+ if (serverVersion) conn.serverVersion = serverVersion;
1413
+ activeConnections.push(conn);
1414
+ updateCompatState();
1415
+ console.log('[MCP Bridge] WebSocket connected to port ' + port + (serverVersion ? ' (server v' + serverVersion + ')' : '') + ' (' + activeConnections.length + ' server(s) total)');
1416
+ attachWsHandlers(testWs, port);
1417
+ initializeConnection(testWs, port);
1418
+ };
1419
+
1420
+ testWs.onerror = function() {
1421
+ clearTimeout(timeout);
1422
+ };
1423
+ } catch (e) {
1424
+ // ignore — next discovery pass will retry
1355
1425
  }
1356
1426
  }
1357
1427
 
@@ -1367,7 +1437,16 @@
1367
1437
  if (message.type === 'SERVER_HELLO' && message.data) {
1368
1438
  console.log('[MCP Bridge] Connected to server on port ' + message.data.port + ' (PID: ' + message.data.pid + ', v' + message.data.serverVersion + ')');
1369
1439
  var conn = activeConnections.find(function(c) { return c.ws === activeWs; });
1370
- if (conn) conn.serverInfo = message.data;
1440
+ if (conn) {
1441
+ conn.serverInfo = message.data;
1442
+ if (message.data.serverVersion) conn.serverVersion = message.data.serverVersion;
1443
+ }
1444
+ return;
1445
+ }
1446
+
1447
+ // Server says the installed plugin is older than it expects.
1448
+ if (message.type === 'PLUGIN_UPDATE_AVAILABLE') {
1449
+ if (typeof showUpdateBanner === 'function') showUpdateBanner();
1371
1450
  return;
1372
1451
  }
1373
1452
 
@@ -1415,12 +1494,10 @@
1415
1494
  return;
1416
1495
  }
1417
1496
 
1418
- // Retry the specific port with limited attempts (no full rescan)
1419
- wsReconnectAttempts++;
1420
- if (wsReconnectAttempts <= 5) {
1421
- var delay = Math.min(1000 * wsReconnectAttempts, 5000);
1422
- setTimeout(function() { wsReconnectPort(port); }, delay);
1423
- }
1497
+ // One immediate same-port retry covers fast same-port server
1498
+ // restarts; anything slower is picked up by the /health discovery
1499
+ // loop, so the old capped retry-with-backoff dance is gone.
1500
+ setTimeout(function() { wsReconnectPort(port); }, 1000);
1424
1501
  };
1425
1502
 
1426
1503
  activeWs.onerror = function() {
@@ -1513,8 +1590,7 @@
1513
1590
  // Reset scan state so a future Resume gets a clean retry budget.
1514
1591
  isScanning = false;
1515
1592
  initialScanAttempts = 0;
1516
- wsReconnectAttempts = 0;
1517
- // Deliberate user pause — keep the watchdog quiet until Resume/Reconnect.
1593
+ // Deliberate user pause — keep discovery quiet until Resume.
1518
1594
  userPaused = true;
1519
1595
  };
1520
1596
 
@@ -1530,20 +1606,92 @@
1530
1606
 
1531
1607
  window.__wsIsPaused = function() { return userPaused; };
1532
1608
  window.__wsIsScanning = function() { return isScanning; };
1609
+ // The health discovery loop is armed whenever the user hasn't paused —
1610
+ // used by the status pill to show "Looking for your AI app…" honestly.
1611
+ window.__wsIsDiscoveryActive = function() { return !userPaused; };
1612
+
1613
+ // ======================================================================
1614
+ // HTTP /health DISCOVERY LOOP — replaces the old zero-connection-gated
1615
+ // WS watchdog. fetch() to a closed port rejects catchably with NO
1616
+ // console spam (unlike new WebSocket()), so this can run always-on:
1617
+ // - every 3s while zero connections (fast attach after server start)
1618
+ // - every 10s while connected (catches a restarted instance among
1619
+ // live ones — previously a permanent dead end)
1620
+ // A port only gets a WS dial after its /health response matches OUR
1621
+ // server's payload shape ({status:'ok', version, clients, ...}).
1622
+ // ======================================================================
1623
+ var HEALTH_TIMEOUT_MS = 1500;
1624
+ var DISCOVERY_IDLE_MS = 3000; // cadence with zero connections
1625
+ var DISCOVERY_STEADY_MS = 10000; // cadence with >=1 connection
1626
+ var discoveryTimer = null;
1627
+ var discoveryInFlight = false;
1628
+
1629
+ function liveConnectionCount() {
1630
+ var n = 0;
1631
+ for (var i = 0; i < activeConnections.length; i++) {
1632
+ if (activeConnections[i].ws.readyState === 1) n++;
1633
+ }
1634
+ return n;
1635
+ }
1533
1636
 
1534
- // Background watchdog — the fix for "plugin opened before the MCP client
1535
- // started." While we hold zero connections and the user hasn't paused,
1536
- // keep probing at a slow cadence. A late-starting server is picked up
1537
- // automatically; the probing stops the instant a connection succeeds.
1538
- setInterval(function() {
1539
- if (userPaused || isScanning) return;
1540
- if (activeConnections.length > 0) return;
1541
- wsScanAndConnect();
1542
- }, BACKGROUND_RESCAN_MS);
1637
+ function isOurHealthPayload(json) {
1638
+ // Shape served by websocket-server.ts handleHttpRequest()
1639
+ return !!(json && json.status === 'ok' && typeof json.version === 'string' && json.clients !== undefined);
1640
+ }
1543
1641
 
1544
- // Initial scan on load (fast burst). Thereafter the watchdog above keeps
1545
- // probing while disconnected, and disconnect-triggered retries handle drops.
1642
+ function probePortHealth(port) {
1643
+ var opts = {};
1644
+ try {
1645
+ if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
1646
+ opts.signal = AbortSignal.timeout(HEALTH_TIMEOUT_MS);
1647
+ }
1648
+ } catch (e) { /* AbortSignal unavailable — fetch just runs untimed */ }
1649
+ return fetch('http://localhost:' + port + '/health', opts)
1650
+ .then(function(res) {
1651
+ if (!res.ok) return null;
1652
+ return res.json().then(function(json) {
1653
+ return isOurHealthPayload(json) ? { port: port, version: json.version } : null;
1654
+ });
1655
+ })
1656
+ .catch(function() { return null; }); // closed port / timeout / non-JSON — all quiet
1657
+ }
1658
+
1659
+ function scheduleDiscovery() {
1660
+ if (discoveryTimer) clearTimeout(discoveryTimer);
1661
+ var delay = liveConnectionCount() > 0 ? DISCOVERY_STEADY_MS : DISCOVERY_IDLE_MS;
1662
+ discoveryTimer = setTimeout(runDiscoveryPass, delay);
1663
+ }
1664
+
1665
+ function runDiscoveryPass() {
1666
+ if (discoveryInFlight) { scheduleDiscovery(); return; }
1667
+ if (userPaused) { scheduleDiscovery(); return; } // never auto-connect while paused
1668
+ var candidates = [];
1669
+ for (var p = WS_PORT_RANGE_START; p <= WS_PORT_RANGE_END; p++) {
1670
+ if (!isPortConnected(p)) candidates.push(p);
1671
+ }
1672
+ if (candidates.length === 0) { scheduleDiscovery(); return; }
1673
+ discoveryInFlight = true;
1674
+ Promise.all(candidates.map(probePortHealth))
1675
+ .then(function(results) {
1676
+ for (var i = 0; i < results.length; i++) {
1677
+ var hit = results[i];
1678
+ if (hit && !userPaused && !isPortConnected(hit.port)) {
1679
+ wsConnectToPort(hit.port, hit.version);
1680
+ }
1681
+ }
1682
+ })
1683
+ .catch(function() { /* individual probes already swallow errors */ })
1684
+ .then(function() {
1685
+ discoveryInFlight = false;
1686
+ scheduleDiscovery();
1687
+ });
1688
+ }
1689
+
1690
+ // Initial scan on load (fast WS burst for instant attach). The discovery
1691
+ // loop then keeps probing forever: fast while disconnected, slow while
1692
+ // connected. Disconnect-triggered same-port retries handle quick drops.
1546
1693
  wsScanAndConnect();
1694
+ scheduleDiscovery();
1547
1695
  })();
1548
1696
 
1549
1697
  // ============================================================================
@@ -1551,6 +1699,14 @@
1551
1699
  // ============================================================================
1552
1700
  var cloudWs = null;
1553
1701
  var CLOUD_RELAY_HOST = 'wss://figma-console-mcp.southleft.com';
1702
+ var _lastCloudCode = null; // most recent code that successfully paired
1703
+ var _cloudUserDisconnected = false; // user clicked Disconnect — suppress auto re-dial
1704
+ var _cloudWasConnectedBeforePause = false; // restore cloud on Resume after Pause
1705
+ var _cloudRetryCount = 0; // bounded auto-reconnect budget
1706
+ var CLOUD_MAX_RETRIES = 5;
1707
+ var _cloudEverConnected = false; // paired successfully THIS session — separates a real
1708
+ // drop (worth telling the user) from a stale restored
1709
+ // code that never connected (stay quiet)
1554
1710
 
1555
1711
  // ============================================================================
1556
1712
  // 3-STAGE UI TOGGLES
@@ -1645,10 +1801,10 @@
1645
1801
  var opening = !document.getElementById('cloud-pair').classList.contains('visible');
1646
1802
  if (opening) closeSubToolbarIfOpen();
1647
1803
  toggleRow('cloud-pair', 'cloud-icon');
1648
- if (!opening) {
1649
- var s = document.getElementById('cloud-status');
1650
- if (s) { s.textContent = ''; s.className = 'cloud-status'; }
1651
- }
1804
+ // Re-derive on both open and close: opening shows the current cloud
1805
+ // state (previously it could open onto a stale blank), closing hides
1806
+ // routine states while keeping actionable ones ("pairing lost") visible.
1807
+ renderCloudStatus();
1652
1808
  }
1653
1809
 
1654
1810
  function toggleCloudHelp() {
@@ -1661,48 +1817,21 @@
1661
1817
  var nowOpen = toggleRow('sub-toolbar', 'expand-btn');
1662
1818
  document.getElementById('expand-btn').textContent = nowOpen ? '−' : '+';
1663
1819
  if (!nowOpen) {
1664
- // Collapse Info + Log if sub-toolbar closes
1665
- var info = document.getElementById('info-panel');
1820
+ // Collapse the Activity panel if sub-toolbar closes
1666
1821
  var log = document.getElementById('log-panel');
1667
- if (info.classList.contains('visible')) toggleInfo();
1668
1822
  if (log.classList.contains('visible')) toggleLog();
1669
1823
  }
1670
1824
  }
1671
1825
 
1672
- function toggleInfo() {
1673
- var opening = !document.getElementById('info-panel').classList.contains('visible');
1674
- if (opening && document.getElementById('log-panel').classList.contains('visible')) {
1675
- // Close log first so only one panel is open at a time.
1676
- toggleLog();
1677
- }
1678
- toggleRow('info-panel', 'info-toggle');
1679
- }
1680
-
1681
1826
  function toggleLog() {
1682
1827
  var logVisible = document.getElementById('log-panel').classList.contains('visible');
1683
1828
  var opening = !logVisible;
1684
- if (opening && document.getElementById('info-panel').classList.contains('visible')) {
1685
- // Close info first so only one panel is open at a time.
1686
- toggleRow('info-panel', 'info-toggle');
1687
- }
1688
1829
  toggleRow('log-panel', 'log-toggle');
1689
- // Reveal Errors + Copy buttons only when log panel is open
1690
- document.getElementById('errors-toggle').style.display = opening ? '' : 'none';
1830
+ // Reveal the Copy button only when the Activity panel is open
1691
1831
  document.getElementById('copy-log-btn').style.display = opening ? '' : 'none';
1692
1832
  autoResize();
1693
1833
  }
1694
1834
 
1695
- function toggleErrorsOnly() {
1696
- var btn = document.getElementById('errors-toggle');
1697
- var entries = document.getElementById('log-entries');
1698
- var on = !btn.classList.contains('active');
1699
- btn.classList.toggle('active', on);
1700
- btn.setAttribute('aria-pressed', on ? 'true' : 'false');
1701
- entries.classList.toggle('errors-only', on);
1702
- btn.setAttribute('title', on ? 'Remove error filter' : 'Filter errors only');
1703
- btn.setAttribute('aria-label', on ? 'Remove error filter' : 'Filter errors only');
1704
- }
1705
-
1706
1835
  // Follow Figma's theme. With themeColors:true, Figma adds a "figma-light" /
1707
1836
  // "figma-dark" class to <html> and updates it live when the user switches
1708
1837
  // themes. We mirror that onto body[data-theme] so the status/log color
@@ -1735,6 +1864,9 @@
1735
1864
  var logHistory = [];
1736
1865
  var logEntriesEl = null;
1737
1866
  var _ctaBtn = null;
1867
+ // Timestamp of the most recent command sent through the bridge (the same
1868
+ // event that appends to logHistory) — drives "Last action: Xs ago".
1869
+ var _lastActionAt = null;
1738
1870
 
1739
1871
  function ensureLogRefs() {
1740
1872
  if (!logEntriesEl) logEntriesEl = document.getElementById('log-entries');
@@ -1809,7 +1941,7 @@
1809
1941
  try { ok = document.execCommand('copy'); } catch (e) {}
1810
1942
  document.body.removeChild(ta);
1811
1943
  if (ok) {
1812
- logWithHistory('Copied to pasteboard (' + logHistory.length + ' entries)', 'success');
1944
+ logWithHistory('Copied to clipboard (' + logHistory.length + ' entries)', 'success');
1813
1945
  } else {
1814
1946
  logWithHistory('Copy failed - clipboard not available', 'error');
1815
1947
  }
@@ -1901,6 +2033,7 @@
1901
2033
  var durEntry = null;
1902
2034
  var histIdx = -1;
1903
2035
  if (summary) {
2036
+ _lastActionAt = Date.now();
1904
2037
  logWithHistory(summary, 'info');
1905
2038
  histIdx = logHistory.length - 1;
1906
2039
  ensureLogRefs();
@@ -1942,40 +2075,42 @@
1942
2075
  );
1943
2076
  };
1944
2077
 
1945
- // Populate Info panel from GET_FILE_INFO response.
1946
- function updateInfoPanel() {
1947
- _origSendPluginCommand('GET_FILE_INFO', {}).then(function(result) {
1948
- var info = (result && result.fileInfo) || result || {};
1949
- var fileEl = document.getElementById('info-file');
1950
- var pageEl = document.getElementById('info-page');
1951
- if (fileEl && info.fileName) fileEl.textContent = info.fileName;
1952
- if (pageEl && info.currentPage) pageEl.textContent = info.currentPage;
1953
- // Version is TJ's hardcoded PLUGIN_VERSION; not displayed to avoid confusion
1954
- // with the npm package version. Still kept in memory for audit export.
1955
- if (info.pluginVersion) {
1956
- PLUGIN_VERSION = 'v' + info.pluginVersion;
1957
- }
1958
- }).catch(function() { /* ignore */ });
2078
+ // Plugin update banner shown when the server pushes
2079
+ // PLUGIN_UPDATE_AVAILABLE over the WebSocket. Dismiss sticks for the
2080
+ // session so repeated pushes don't nag.
2081
+ var _updateBannerDismissed = false;
2082
+ function showUpdateBanner() {
2083
+ if (_updateBannerDismissed) return;
2084
+ var b = document.getElementById('update-banner');
2085
+ if (b && !b.classList.contains('visible')) {
2086
+ b.classList.add('visible');
2087
+ autoResize();
2088
+ }
1959
2089
  }
1960
-
1961
- // Update "N server(s)" count in log header.
1962
- function updateServerCount() {
1963
- var n = 0;
1964
- if (typeof window.__wsGetActiveConnections === 'function') {
1965
- n = window.__wsGetActiveConnections().length;
2090
+ function dismissUpdateBanner() {
2091
+ _updateBannerDismissed = true;
2092
+ var b = document.getElementById('update-banner');
2093
+ if (b && b.classList.contains('visible')) {
2094
+ b.classList.remove('visible');
2095
+ autoResize();
1966
2096
  }
1967
- var el = document.getElementById('log-servers');
1968
- if (el) el.textContent = n + ' server(s)';
1969
2097
  }
1970
2098
 
1971
- // Real TURN ON / TURN OFF: toggles the local WebSocket bridge.
2099
+ // True while a cloud relay connection is live in the shared pool.
2100
+ function hasLiveCloudConnection() {
2101
+ var conns = (typeof window.__wsGetActiveConnections === 'function') ? (window.__wsGetActiveConnections() || []) : [];
2102
+ return conns.some(function(c) { return c && c.port === 'cloud' && c.ws && c.ws.readyState === 1; });
2103
+ }
2104
+
2105
+ // Real Pause / Resume: toggles the local WebSocket bridge.
1972
2106
  function setCtaState(state) {
1973
2107
  ensureLogRefs();
1974
2108
  if (!_ctaBtn) return;
1975
- if (state === 'on') { _ctaBtn.textContent = 'Pause'; _ctaBtn.disabled = false; }
1976
- else if (state === 'paused') { _ctaBtn.textContent = 'Resume'; _ctaBtn.disabled = false; }
1977
- else if (state === 'reconnect'){ _ctaBtn.textContent = 'Reconnect'; _ctaBtn.disabled = false; }
1978
- else if (state === 'scanning') { _ctaBtn.textContent = 'In progress'; _ctaBtn.disabled = true; }
2109
+ _ctaBtn.style.display = ''; // markup starts it hidden until first honest state
2110
+ if (state === 'on') { _ctaBtn.textContent = 'Pause'; _ctaBtn.disabled = false; }
2111
+ else if (state === 'paused') { _ctaBtn.textContent = 'Resume'; _ctaBtn.disabled = false; }
2112
+ else if (state === 'reconnect'){ _ctaBtn.textContent = 'Try again'; _ctaBtn.disabled = false; }
2113
+ else if (state === 'scanning') { _ctaBtn.textContent = 'Connecting…'; _ctaBtn.disabled = true; }
1979
2114
  }
1980
2115
 
1981
2116
  function toggleLocalConnection() {
@@ -1983,16 +2118,27 @@
1983
2118
  if (!_ctaBtn) return;
1984
2119
  var label = _ctaBtn.textContent;
1985
2120
  if (label === 'Pause') {
2121
+ // Remember a live cloud connection so Resume can restore it.
2122
+ _cloudWasConnectedBeforePause = hasLiveCloudConnection();
1986
2123
  if (typeof window.__wsDisconnectAll === 'function') window.__wsDisconnectAll();
1987
2124
  setCtaState('paused');
1988
- updateStatus('disconnected', false, false);
2125
+ deriveAndRenderStatus();
1989
2126
  logWithHistory('Paused', 'warn');
1990
- } else if (label === 'Resume' || label === 'Reconnect') {
2127
+ } else if (label === 'Resume' || label === 'Try again') {
1991
2128
  setCtaState('scanning');
1992
- updateStatus('connecting', false, false);
1993
- logWithHistory((label === 'Reconnect' ? 'Reconnecting' : 'Resuming') + ', scanning ports 9223-9232', 'info');
2129
+ logWithHistory('Looking for your AI app…', 'info');
1994
2130
  if (typeof window.__wsManualScan === 'function') window.__wsManualScan();
1995
2131
  else if (typeof window.__wsScanAndConnect === 'function') window.__wsScanAndConnect();
2132
+ // Re-establish the cloud connection if one existed before Pause
2133
+ // (the scan above only covers localhost ports).
2134
+ if (_cloudWasConnectedBeforePause) {
2135
+ _cloudWasConnectedBeforePause = false;
2136
+ if (_lastCloudCode && !_cloudUserDisconnected) {
2137
+ _cloudRetryCount = 0;
2138
+ cloudDial(_lastCloudCode, true);
2139
+ }
2140
+ }
2141
+ deriveAndRenderStatus();
1996
2142
  // Watch for connection success or scan timeout.
1997
2143
  var attempts = 0;
1998
2144
  var poller = setInterval(function() {
@@ -2001,26 +2147,25 @@
2001
2147
  if (n > 0) {
2002
2148
  clearInterval(poller);
2003
2149
  setCtaState('on');
2004
- updateStatus('ready', true, false);
2150
+ deriveAndRenderStatus();
2005
2151
  } else if (attempts > 20) { // ~10s max
2006
2152
  clearInterval(poller);
2007
- setCtaState('paused');
2008
- updateStatus('error', false, true);
2009
- logWithHistory('Resume failed - no MCP server found', 'error');
2153
+ setCtaState('reconnect');
2154
+ deriveAndRenderStatus();
2155
+ logWithHistory('Couldn\'t find your AI app. Open Claude (or your AI assistant), then press Try again.', 'error');
2010
2156
  }
2011
2157
  }, 500);
2012
2158
  }
2013
2159
  }
2014
2160
 
2015
- // Keep the CTA button honest about the real connection state. The status
2016
- // dot is driven by Figma-side data (variables loaded); this reconciles the
2017
- // button with the actual number of live MCP server connections so a
2018
- // never-connected or dropped plugin shows a clickable "Reconnect" instead
2019
- // of a misleading "Pause". Skips while a scan is mid-flight (button disabled
2020
- // or __wsIsScanning) to avoid fighting the transient state set on click.
2161
+ // Keep the CTA button honest about the real connection state: a
2162
+ // never-connected or dropped plugin shows a clickable "Try again" instead
2163
+ // of a misleading "Pause". Skips while a scan is mid-flight (button
2164
+ // disabled or __wsIsScanning) to avoid fighting the transient click state.
2021
2165
  function reconcileCta() {
2022
2166
  ensureLogRefs();
2023
- if (!_ctaBtn || _ctaBtn.disabled) return;
2167
+ if (!_ctaBtn) return;
2168
+ if (_ctaBtn.disabled) return;
2024
2169
  if (typeof window.__wsIsScanning === 'function' && window.__wsIsScanning()) return;
2025
2170
  var n = (typeof window.__wsGetActiveConnections === 'function') ? window.__wsGetActiveConnections().length : 0;
2026
2171
  var paused = (typeof window.__wsIsPaused === 'function') ? window.__wsIsPaused() : false;
@@ -2029,32 +2174,76 @@
2029
2174
  } else if (n > 0) {
2030
2175
  if (_ctaBtn.textContent !== 'Pause') setCtaState('on');
2031
2176
  } else {
2032
- if (_ctaBtn.textContent !== 'Reconnect') setCtaState('reconnect');
2177
+ if (_ctaBtn.textContent !== 'Try again') setCtaState('reconnect');
2033
2178
  }
2179
+ // First reconcile also unhides the button with its honest label.
2180
+ if (_ctaBtn.style.display === 'none') _ctaBtn.style.display = '';
2034
2181
  }
2035
2182
 
2036
- // Periodic refresh of server count, info panel, and CTA state (cheap, <1ms).
2183
+ // Periodic reconcile of CTA + status pill/meta (cheap, <1ms). Also
2184
+ // refreshes "Last action: Xs ago" via renderStatusMeta inside
2185
+ // deriveAndRenderStatus.
2037
2186
  setInterval(function() {
2038
- updateServerCount();
2039
2187
  reconcileCta();
2040
- // Only refresh info if the panel is visible (cheap gate).
2041
- var info = document.getElementById('info-panel');
2042
- if (info && info.classList.contains('visible')) updateInfoPanel();
2188
+ deriveAndRenderStatus();
2043
2189
  }, 2000);
2044
2190
 
2045
- // One-shot on first ready.
2046
- setTimeout(function() { updateServerCount(); updateInfoPanel(); }, 500);
2191
+ // One-shot on first ready: render honest initial state and capture the
2192
+ // plugin's own version (used in the Activity export header) without
2193
+ // logging a command line — hence the unwrapped sender. The version is
2194
+ // read from the GET_FILE_INFO_RESULT handler below.
2195
+ setTimeout(function() {
2196
+ reconcileCta();
2197
+ deriveAndRenderStatus();
2198
+ _origSendPluginCommand('GET_FILE_INFO', {}).catch(function() { /* ignore */ });
2199
+ }, 500);
2047
2200
 
2048
2201
  // Size the plugin window to fit content on first paint.
2049
2202
  requestAnimationFrame(autoResize);
2050
2203
 
2051
- function resetCloudUI() {
2204
+ // Single source of truth for the cloud pairing status line — derives the
2205
+ // text from actual state (same philosophy as deriveAndRenderStatus for the
2206
+ // pill) instead of trusting one-off events. Every string is prefixed
2207
+ // "Cloud" so it can never read as the plugin's overall status, and the
2208
+ // line stays empty (hidden via :not(:empty)) unless it has something
2209
+ // useful to say — a background failure like an expired pairing code
2210
+ // restored from a previous session stays silent. Transient messages
2211
+ // ("Cloud: connecting…", bad-code errors) are written directly by
2212
+ // cloudDial/cloudConnect and survive until the next state change.
2213
+ function renderCloudStatus() {
2052
2214
  var statusEl = document.getElementById('cloud-status');
2053
- var btn = document.getElementById('cloud-btn');
2054
- if (statusEl) {
2055
- statusEl.textContent = 'Disconnected';
2215
+ if (!statusEl) return;
2216
+ var cp = document.getElementById('cloud-pair');
2217
+ var panelOpen = !!(cp && cp.classList.contains('visible'));
2218
+ var paused = (typeof window.__wsIsPaused === 'function') ? window.__wsIsPaused() : false;
2219
+
2220
+ if (paused) {
2221
+ // Pause closes the cloud socket deliberately and Resume redials it —
2222
+ // never present that as a lost pairing (retry is suppressed while
2223
+ // paused, so "reconnecting…" would also be untrue).
2224
+ statusEl.textContent = panelOpen ? 'Cloud: paused' : '';
2225
+ statusEl.className = 'cloud-status';
2226
+ } else if (hasLiveCloudConnection()) {
2227
+ statusEl.textContent = panelOpen ? 'Cloud: connected' : '';
2228
+ statusEl.className = 'cloud-status connected';
2229
+ } else if (!_cloudUserDisconnected && _cloudEverConnected && _cloudRetryCount < CLOUD_MAX_RETRIES) {
2230
+ // Paired earlier this session and dropped — actionable, so it shows
2231
+ // even while the panel is collapsed.
2232
+ statusEl.textContent = 'Cloud pairing lost — reconnecting…';
2233
+ statusEl.className = 'cloud-status';
2234
+ } else if (!_cloudUserDisconnected && _cloudEverConnected) {
2235
+ statusEl.textContent = 'Cloud pairing lost. Generate a fresh code in Claude and reconnect.';
2236
+ statusEl.className = 'cloud-status error';
2237
+ } else {
2238
+ statusEl.textContent = panelOpen ? 'Cloud: not connected' : '';
2056
2239
  statusEl.className = 'cloud-status';
2057
2240
  }
2241
+ autoResize();
2242
+ }
2243
+
2244
+ function resetCloudUI() {
2245
+ var btn = document.getElementById('cloud-btn');
2246
+ renderCloudStatus();
2058
2247
  if (btn) {
2059
2248
  btn.disabled = false;
2060
2249
  btn.textContent = 'Connect';
@@ -2063,11 +2252,12 @@
2063
2252
  cloudWs = null;
2064
2253
  }
2065
2254
 
2066
- function cloudConnect() {
2067
- var codeInput = document.getElementById('cloud-code');
2255
+ // Shared dialer used by manual Connect, auto-reconnect after a drop,
2256
+ // restored-config auto-connect, and Resume-after-Pause. `isAuto` softens
2257
+ // the error UX for background attempts and arms the bounded retry chain.
2258
+ function cloudDial(code, isAuto) {
2068
2259
  var btn = document.getElementById('cloud-btn');
2069
2260
  var statusEl = document.getElementById('cloud-status');
2070
- var code = (codeInput.value || '').trim().toUpperCase();
2071
2261
 
2072
2262
  if (!code || code.length < 6) {
2073
2263
  statusEl.textContent = 'Pairing code must be 6 characters';
@@ -2075,24 +2265,35 @@
2075
2265
  return;
2076
2266
  }
2077
2267
 
2078
- btn.disabled = true;
2079
- statusEl.textContent = 'Connecting...';
2080
- statusEl.className = 'cloud-status';
2081
-
2082
- // Close existing cloud connection if any
2268
+ // Close existing cloud connection if any (manual reason suppresses
2269
+ // the pool's same-port retry for the old socket).
2083
2270
  if (cloudWs && cloudWs.readyState <= 1) {
2084
- cloudWs.close();
2271
+ try { cloudWs.close(1000, 'Manual disconnect'); } catch (e) {}
2272
+ }
2273
+
2274
+ if (btn) btn.disabled = true;
2275
+ // Manual attempts show progress; auto re-dials render the derived state
2276
+ // ("Cloud pairing lost — reconnecting…", or silence for a background
2277
+ // restore) so an unlabeled transient never floats under the main pill.
2278
+ if (isAuto) {
2279
+ renderCloudStatus();
2280
+ } else if (statusEl) {
2281
+ statusEl.textContent = 'Cloud: connecting…';
2282
+ statusEl.className = 'cloud-status';
2085
2283
  }
2086
2284
 
2087
2285
  try {
2286
+ var opened = false;
2287
+ var sawError = false;
2088
2288
  cloudWs = new WebSocket(CLOUD_RELAY_HOST + '/ws/pair?code=' + code);
2089
2289
 
2090
2290
  cloudWs.onopen = function() {
2091
- statusEl.textContent = 'Connected to cloud relay';
2092
- statusEl.className = 'cloud-status connected';
2093
- btn.disabled = false;
2094
- btn.textContent = 'Disconnect';
2095
- btn.onclick = cloudDisconnect;
2291
+ opened = true;
2292
+ _lastCloudCode = code;
2293
+ _cloudUserDisconnected = false;
2294
+ _cloudRetryCount = 0;
2295
+ _cloudEverConnected = true;
2296
+ if (btn) { btn.disabled = false; btn.textContent = 'Disconnect'; btn.onclick = cloudDisconnect; }
2096
2297
 
2097
2298
  // Add to the shared connection pool (uses same handlers as localhost).
2098
2299
  // Pass resetCloudUI as disconnect callback — attachWsHandlers overwrites
@@ -2101,6 +2302,9 @@
2101
2302
  window.__wsAddCloudConnection(cloudWs, 'cloud', resetCloudUI);
2102
2303
  }
2103
2304
 
2305
+ // After the pool add, so hasLiveCloudConnection() sees this socket.
2306
+ renderCloudStatus();
2307
+
2104
2308
  // Persist cloud config via code.js clientStorage
2105
2309
  parent.postMessage({ pluginMessage: {
2106
2310
  type: 'STORE_CLOUD_CONFIG',
@@ -2109,27 +2313,76 @@
2109
2313
  };
2110
2314
 
2111
2315
  cloudWs.onerror = function() {
2112
- statusEl.textContent = 'Connection failed — check code';
2113
- statusEl.className = 'cloud-status error';
2114
- btn.disabled = false;
2316
+ sawError = true;
2317
+ if (statusEl && !isAuto) {
2318
+ statusEl.textContent = 'That code didn\'t work. Ask Claude for a fresh pairing code and try again.';
2319
+ statusEl.className = 'cloud-status error';
2320
+ }
2321
+ if (btn) btn.disabled = false;
2115
2322
  };
2116
2323
 
2117
2324
  // Note: onclose here handles pre-connection close (e.g., bad code).
2118
2325
  // After onopen, attachWsHandlers overwrites this — resetCloudUI callback
2119
2326
  // handles post-connection close instead.
2120
2327
  cloudWs.onclose = function(event) {
2121
- resetCloudUI();
2328
+ if (!opened && sawError && !isAuto) {
2329
+ // Keep the visible error message (onclose fires right after
2330
+ // onerror and would otherwise clobber it); restore the button.
2331
+ if (btn) { btn.disabled = false; btn.textContent = 'Connect'; btn.onclick = cloudConnect; }
2332
+ cloudWs = null;
2333
+ } else {
2334
+ resetCloudUI();
2335
+ }
2336
+ // Auto attempt that never opened → keep trying within the budget.
2337
+ if (isAuto && !opened) scheduleCloudRetry();
2122
2338
  };
2123
2339
  } catch (e) {
2124
- statusEl.textContent = 'Failed: ' + e.message;
2340
+ if (statusEl) { statusEl.textContent = 'Failed: ' + e.message; statusEl.className = 'cloud-status error'; }
2341
+ if (btn) btn.disabled = false;
2342
+ }
2343
+ }
2344
+
2345
+ // Bounded auto-reconnect with backoff. Gives up after CLOUD_MAX_RETRIES,
2346
+ // never fires while paused or after a deliberate user Disconnect.
2347
+ function scheduleCloudRetry() {
2348
+ if (_cloudUserDisconnected || !_lastCloudCode) return;
2349
+ if (typeof window.__wsIsPaused === 'function' && window.__wsIsPaused()) return;
2350
+ if (_cloudRetryCount >= CLOUD_MAX_RETRIES) return;
2351
+ _cloudRetryCount++;
2352
+ setTimeout(function() {
2353
+ if (_cloudUserDisconnected || hasLiveCloudConnection()) return;
2354
+ if (typeof window.__wsIsPaused === 'function' && window.__wsIsPaused()) return;
2355
+ cloudDial(_lastCloudCode, true);
2356
+ }, Math.min(2000 * _cloudRetryCount, 10000));
2357
+ }
2358
+
2359
+ // Called by the connection pool when the pooled cloud connection drops —
2360
+ // re-dials the actual relay URL instead of 'ws://localhost:cloud'.
2361
+ window.__cloudReconnect = function() {
2362
+ scheduleCloudRetry();
2363
+ };
2364
+
2365
+ function cloudConnect() {
2366
+ var codeInput = document.getElementById('cloud-code');
2367
+ var statusEl = document.getElementById('cloud-status');
2368
+ var code = (codeInput.value || '').trim().toUpperCase();
2369
+
2370
+ if (!code || code.length < 4) {
2371
+ statusEl.textContent = 'Enter pairing code';
2125
2372
  statusEl.className = 'cloud-status error';
2126
- btn.disabled = false;
2373
+ return;
2127
2374
  }
2375
+
2376
+ _cloudUserDisconnected = false;
2377
+ _cloudRetryCount = 0;
2378
+ cloudDial(code, false);
2128
2379
  }
2129
2380
 
2130
2381
  function cloudDisconnect() {
2382
+ _cloudUserDisconnected = true; // deliberate — suppress auto re-dial
2383
+ _cloudEverConnected = false; // a deliberate disconnect is not a "lost" pairing
2131
2384
  if (cloudWs) {
2132
- cloudWs.close();
2385
+ try { cloudWs.close(1000, 'Manual disconnect'); } catch (e) {}
2133
2386
  }
2134
2387
  // Reset UI immediately — don't rely on onclose (may be overwritten)
2135
2388
  resetCloudUI();
@@ -2154,6 +2407,8 @@
2154
2407
  if (msg.oldName !== undefined) result.oldName = msg.oldName;
2155
2408
  if (msg.instance !== undefined) result.instance = msg.instance;
2156
2409
  if (msg.warnings !== undefined) result.warnings = msg.warnings;
2410
+ if (msg.updatedCount !== undefined) result.updatedCount = msg.updatedCount;
2411
+ if (msg.nodes !== undefined) result.nodes = msg.nodes;
2157
2412
  request.resolve(result);
2158
2413
  } else {
2159
2414
  request.resolve({ success: false, error: msg.error || 'Unknown error' });
@@ -2167,7 +2422,10 @@
2167
2422
  case 'VARIABLES_DATA':
2168
2423
  window.__figmaVariablesData = msg.data;
2169
2424
  window.__figmaVariablesReady = true;
2170
- updateStatus('ready', true, false);
2425
+ // A Figma-side data event says nothing about the WS connection —
2426
+ // record the fact and let the derived state decide the pill.
2427
+ bridgeError = false;
2428
+ deriveAndRenderStatus();
2171
2429
  console.log('[MCP Bridge] Active - ' + (msg.data.variables?.length || 0) + ' vars');
2172
2430
  // Forward to WebSocket client if connected
2173
2431
  if (window.__wsForwardVariables) window.__wsForwardVariables(msg.data);
@@ -2186,7 +2444,8 @@
2186
2444
 
2187
2445
  case 'ERROR':
2188
2446
  window.__figmaVariablesReady = false;
2189
- updateStatus('error', false, true);
2447
+ bridgeError = true; // pill shows "Something broke" + reopen hint
2448
+ deriveAndRenderStatus();
2190
2449
  console.error('[MCP Bridge] Error:', msg.error);
2191
2450
  break;
2192
2451
 
@@ -2230,6 +2489,11 @@
2230
2489
  case 'INSTANTIATE_COMPONENT_RESULT':
2231
2490
  handleResult('INSTANTIATE_COMPONENT', 'instance');
2232
2491
  break;
2492
+ // Component set creation (payload flows via msg.data — already on the
2493
+ // handleResult whitelist, so no new top-level fields to forward)
2494
+ case 'CREATE_COMPONENT_SET_RESULT':
2495
+ handleResult('CREATE_COMPONENT_SET', null);
2496
+ break;
2233
2497
 
2234
2498
  // NEW: Component property operations
2235
2499
  case 'SET_NODE_DESCRIPTION_RESULT':
@@ -2381,9 +2645,31 @@
2381
2645
 
2382
2646
  // File info
2383
2647
  case 'GET_FILE_INFO_RESULT':
2648
+ // Capture the plugin's own version for the Activity export header
2649
+ // (moved here from the deleted Info panel so any GET_FILE_INFO
2650
+ // response keeps it fresh, regardless of who asked).
2651
+ var fileInfo = msg.fileInfo || msg.data;
2652
+ if (fileInfo && fileInfo.pluginVersion) {
2653
+ PLUGIN_VERSION = 'v' + fileInfo.pluginVersion;
2654
+ }
2384
2655
  handleResult('GET_FILE_INFO', 'fileInfo');
2385
2656
  break;
2386
2657
 
2658
+ // Cloud config restored from clientStorage by code.js — auto-fill the
2659
+ // pairing input and reconnect without making the user re-pair.
2660
+ case 'CLOUD_CONFIG_RESTORED':
2661
+ if (msg.config && msg.config.code && !_cloudUserDisconnected) {
2662
+ var restoredCode = String(msg.config.code).trim().toUpperCase();
2663
+ var restoredInput = document.getElementById('cloud-code');
2664
+ if (restoredInput) restoredInput.value = restoredCode;
2665
+ if (restoredCode.length >= 4 && !hasLiveCloudConnection()) {
2666
+ _lastCloudCode = restoredCode;
2667
+ _cloudRetryCount = 0;
2668
+ cloudDial(restoredCode, true);
2669
+ }
2670
+ }
2671
+ break;
2672
+
2387
2673
  // Plugin UI reload
2388
2674
  case 'RELOAD_UI_RESULT':
2389
2675
  handleResult('RELOAD_UI', null);