@fonlan/dsh-quick-commands 0.1.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,580 @@
1
+ /* @fonlan/dsh-quick-commands client styles.
2
+ Uses theme CSS variables (--dsw-alias-*); falls back to neutral literals.
3
+ Constraint: this deployment's theme defines only these text tokens —
4
+ label-primary, label-secondary, label-tertiary, label-dimmed. READABLE
5
+ description copy uses label-secondary / label-tertiary (never label-dimmed,
6
+ which is intentionally near-invisible and reserved for disabled states). */
7
+
8
+ /* ── header button (icon-only, left of Session log pill) ─────────────────── */
9
+ .qc-head-wrap {
10
+ position: relative;
11
+ display: inline-flex;
12
+ align-items: center;
13
+ }
14
+ .qc-head-btn {
15
+ display: inline-flex;
16
+ align-items: center;
17
+ justify-content: center;
18
+ width: 32px;
19
+ height: 32px;
20
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35));
21
+ border-radius: 18px;
22
+ background: transparent;
23
+ color: var(--dsw-alias-label-primary, #f2f2f2);
24
+ cursor: pointer;
25
+ padding: 0;
26
+ }
27
+ .qc-head-btn:hover:not(:disabled) {
28
+ background: color-mix(in srgb, var(--dsw-alias-label-primary, #f2f2f2) 10%, transparent);
29
+ }
30
+ .qc-head-btn:disabled {
31
+ color: var(--dsw-alias-label-dimmed, #666);
32
+ cursor: not-allowed;
33
+ }
34
+ .qc-head-btn svg {
35
+ flex: none;
36
+ }
37
+
38
+ /* ── command menu popover (anchored under the button) ────────────────────── */
39
+ .qc-menu {
40
+ position: absolute;
41
+ top: calc(100% + 6px);
42
+ right: 12px;
43
+ min-width: 280px;
44
+ max-height: 420px;
45
+ overflow: auto;
46
+ background: var(--dsw-alias-bg-overlay, #1b1b1c);
47
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35));
48
+ border-radius: 10px;
49
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
50
+ z-index: 50;
51
+ padding: 6px;
52
+ display: flex;
53
+ flex-direction: column;
54
+ }
55
+ .qc-menu-head {
56
+ display: flex;
57
+ align-items: center;
58
+ gap: 8px;
59
+ padding: 4px 8px 6px;
60
+ border-bottom: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.25));
61
+ }
62
+ .qc-menu-title {
63
+ font-size: 12px;
64
+ font-weight: 600;
65
+ color: var(--dsw-alias-label-primary, #f2f2f2);
66
+ }
67
+ .qc-menu-ws {
68
+ flex: 1;
69
+ font-size: 11px;
70
+ color: var(--dsw-alias-label-tertiary, #adb2b8);
71
+ overflow: hidden;
72
+ text-overflow: ellipsis;
73
+ white-space: nowrap;
74
+ text-align: right;
75
+ margin-right: 2px;
76
+ }
77
+ .qc-menu-remote {
78
+ flex: 0 0 auto;
79
+ font-size: 10px;
80
+ font-weight: 600;
81
+ line-height: 1;
82
+ padding: 2px 5px;
83
+ border-radius: 5px;
84
+ color: var(--dsw-alias-fg-on-accent, #fff);
85
+ background: var(--dsw-alias-accent, #4a8dff);
86
+ letter-spacing: 0.04em;
87
+ }
88
+ .qc-menu-close {
89
+ border: 0;
90
+ background: transparent;
91
+ color: var(--dsw-alias-label-tertiary, #adb2b8);
92
+ cursor: pointer;
93
+ padding: 2px;
94
+ display: inline-flex;
95
+ align-items: center;
96
+ }
97
+ .qc-menu-empty {
98
+ font-size: 12px;
99
+ color: var(--dsw-alias-label-tertiary, #adb2b8);
100
+ padding: 10px 8px;
101
+ margin: 0;
102
+ }
103
+ .qc-menu-item {
104
+ display: flex;
105
+ flex-direction: column;
106
+ align-items: flex-start;
107
+ gap: 2px;
108
+ text-align: left;
109
+ border: 0;
110
+ background: transparent;
111
+ border-radius: 8px;
112
+ padding: 6px 8px;
113
+ cursor: pointer;
114
+ width: 100%;
115
+ }
116
+ .qc-menu-item:hover:not(:disabled) {
117
+ background: color-mix(in srgb, var(--dsw-alias-label-primary, #f2f2f2) 10%, transparent);
118
+ }
119
+ .qc-menu-item:disabled {
120
+ opacity: 0.6;
121
+ cursor: wait;
122
+ }
123
+ .qc-menu-item-name {
124
+ font-size: 13px;
125
+ font-weight: 500;
126
+ color: var(--dsw-alias-label-primary, #f2f2f2);
127
+ }
128
+ .qc-menu-item-cmd {
129
+ font-family: var(--dsw-font-family-mono, monospace);
130
+ font-size: 11px;
131
+ color: var(--dsw-alias-label-secondary, #cfd3d6);
132
+ overflow: hidden;
133
+ text-overflow: ellipsis;
134
+ white-space: nowrap;
135
+ max-width: 100%;
136
+ }
137
+
138
+ /* ── live output popup ───────────────────────────────────────────────────── */
139
+ .qc-popup {
140
+ position: relative; /* anchors the absolutely-positioned resize grip */
141
+ display: flex;
142
+ flex-direction: column;
143
+ background: var(--dsw-alias-bg-overlay, #1b1b1c);
144
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35));
145
+ border-radius: 12px;
146
+ box-shadow: 0 12px 36px rgba(0, 0, 0, 0.45);
147
+ z-index: 60;
148
+ overflow: hidden;
149
+ }
150
+ .qc-popup-corner {
151
+ position: fixed;
152
+ right: 20px;
153
+ bottom: 20px;
154
+ width: 520px;
155
+ max-width: calc(100vw - 40px);
156
+ height: 340px;
157
+ max-height: calc(100vh - 120px);
158
+ }
159
+ .qc-popup-button {
160
+ position: absolute;
161
+ top: calc(100% + 6px);
162
+ right: 12px;
163
+ width: 440px;
164
+ max-width: calc(100vw - 40px);
165
+ height: 300px;
166
+ max-height: calc(100vh - 120px);
167
+ }
168
+ .qc-popup-head {
169
+ display: flex;
170
+ align-items: center;
171
+ gap: 8px;
172
+ padding: 8px 10px;
173
+ border-bottom: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.25));
174
+ }
175
+ .qc-popup-title {
176
+ flex: 1;
177
+ font-size: 12px;
178
+ font-weight: 600;
179
+ color: var(--dsw-alias-label-primary, #f2f2f2);
180
+ overflow: hidden;
181
+ text-overflow: ellipsis;
182
+ white-space: nowrap;
183
+ }
184
+ .qc-popup-cmd {
185
+ font-family: var(--dsw-font-family-mono, monospace);
186
+ font-size: 11px;
187
+ color: var(--dsw-alias-label-secondary, #cfd3d6);
188
+ overflow: hidden;
189
+ text-overflow: ellipsis;
190
+ white-space: nowrap;
191
+ max-width: 40%;
192
+ }
193
+ .qc-popup-remote {
194
+ flex: 0 0 auto;
195
+ font-size: 10px;
196
+ font-weight: 600;
197
+ line-height: 1;
198
+ padding: 3px 6px;
199
+ border-radius: 6px;
200
+ color: var(--dsw-alias-fg-on-accent, #fff);
201
+ background: var(--dsw-alias-accent, #4a8dff);
202
+ letter-spacing: 0.04em;
203
+ }
204
+ .qc-popup-icon {
205
+ border: 0;
206
+ background: transparent;
207
+ color: var(--dsw-alias-label-tertiary, #adb2b8);
208
+ cursor: pointer;
209
+ padding: 4px;
210
+ display: inline-flex;
211
+ align-items: center;
212
+ border-radius: 6px;
213
+ }
214
+ .qc-popup-icon:hover:not(:disabled) {
215
+ background: color-mix(in srgb, var(--dsw-alias-label-primary, #f2f2f2) 10%, transparent);
216
+ }
217
+ .qc-popup-icon:disabled {
218
+ opacity: 0.4;
219
+ cursor: not-allowed;
220
+ }
221
+ .qc-popup-kill {
222
+ color: var(--dsw-alias-state-error-primary, #e5484d);
223
+ }
224
+ .qc-popup-confirm {
225
+ display: flex;
226
+ align-items: center;
227
+ gap: 8px;
228
+ font-size: 12px;
229
+ padding: 8px 10px;
230
+ background: color-mix(in srgb, var(--dsw-alias-state-warn-primary, #f5a623) 12%, transparent);
231
+ color: var(--dsw-alias-label-primary, #f2f2f2);
232
+ border-bottom: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.25));
233
+ }
234
+ .qc-popup-btn {
235
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35));
236
+ border-radius: 8px;
237
+ background: transparent;
238
+ color: var(--dsw-alias-label-primary, #f2f2f2);
239
+ font-size: 12px;
240
+ padding: 4px 10px;
241
+ cursor: pointer;
242
+ }
243
+ .qc-popup-btn:hover {
244
+ background: color-mix(in srgb, var(--dsw-alias-label-primary, #f2f2f2) 10%, transparent);
245
+ }
246
+ .qc-popup-btn-plain {
247
+ color: var(--dsw-alias-label-secondary, #cfd3d6);
248
+ }
249
+ .qc-popup-tabs {
250
+ display: flex;
251
+ gap: 2px;
252
+ padding: 6px 10px 0;
253
+ border-bottom: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.25));
254
+ }
255
+ .qc-popup-tab {
256
+ border: 0;
257
+ background: transparent;
258
+ color: var(--dsw-alias-label-secondary, #cfd3d6);
259
+ font-size: 12px;
260
+ padding: 5px 10px;
261
+ cursor: pointer;
262
+ border-bottom: 2px solid transparent;
263
+ }
264
+ .qc-popup-tab-active {
265
+ color: var(--dsw-alias-label-primary, #f2f2f2);
266
+ border-bottom-color: var(--dsw-alias-brand-primary, #4a9eff);
267
+ }
268
+ .qc-popup-output {
269
+ flex: 1;
270
+ overflow: auto;
271
+ padding: 8px 10px;
272
+ font-family: var(--dsw-font-family-mono, monospace);
273
+ font-size: 12px;
274
+ line-height: 1.5;
275
+ }
276
+ .qc-popup-pre {
277
+ margin: 0;
278
+ white-space: pre-wrap;
279
+ word-break: break-word;
280
+ color: var(--dsw-alias-label-primary, #f2f2f2);
281
+ }
282
+ .qc-popup-empty {
283
+ margin: 0;
284
+ color: var(--dsw-alias-label-tertiary, #adb2b8);
285
+ font-size: 12px;
286
+ }
287
+ .qc-popup-error {
288
+ margin: 0 0 6px;
289
+ color: var(--dsw-alias-state-error-primary, #e5484d);
290
+ font-size: 12px;
291
+ }
292
+ .qc-popup-foot {
293
+ display: flex;
294
+ align-items: center;
295
+ padding: 6px 10px;
296
+ border-top: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.25));
297
+ font-size: 11px;
298
+ }
299
+ .qc-popup-state {
300
+ color: var(--dsw-alias-label-secondary, #cfd3d6);
301
+ }
302
+
303
+ /* Resize handles. The popup pins its RIGHT edge (both anchors); width is
304
+ always dragged from the LEFT border. Height is dragged from the TOP border
305
+ (corner anchor, bottom pinned) or the BOTTOM border (button anchor, top
306
+ pinned below the button). The free corner (top-left / bottom-left) carries
307
+ the diagonal grip. Pointer capture lives in header.tsx; touch-action:none
308
+ lets touch drags resize instead of scrolling. */
309
+ .qc-popup-resize {
310
+ position: absolute;
311
+ z-index: 3;
312
+ touch-action: none;
313
+ user-select: none;
314
+ }
315
+ .qc-popup-resize-left {
316
+ left: 0;
317
+ top: 10px;
318
+ bottom: 10px;
319
+ width: 7px;
320
+ cursor: ew-resize;
321
+ }
322
+ .qc-popup-resize-top {
323
+ top: 0;
324
+ left: 10px;
325
+ right: 10px;
326
+ height: 7px;
327
+ cursor: ns-resize;
328
+ }
329
+ .qc-popup-resize-bottom {
330
+ bottom: 0;
331
+ left: 10px;
332
+ right: 10px;
333
+ height: 7px;
334
+ cursor: ns-resize;
335
+ }
336
+ .qc-popup-resize-left:hover,
337
+ .qc-popup-resize-top:hover,
338
+ .qc-popup-resize-bottom:hover {
339
+ background: color-mix(in srgb, var(--dsw-alias-label-primary, #f2f2f2) 14%, transparent);
340
+ }
341
+ .qc-popup-resize-corner {
342
+ width: 22px;
343
+ height: 22px;
344
+ z-index: 4;
345
+ display: flex;
346
+ padding: 3px;
347
+ color: var(--dsw-alias-label-tertiary, #adb2b8);
348
+ opacity: 0.6;
349
+ transition: opacity .15s ease;
350
+ }
351
+ .qc-popup-resize-corner-tl {
352
+ top: 0;
353
+ left: 0;
354
+ cursor: nwse-resize;
355
+ align-items: flex-start;
356
+ justify-content: flex-start;
357
+ }
358
+ .qc-popup-resize-corner-bl {
359
+ bottom: 0;
360
+ left: 0;
361
+ cursor: nesw-resize;
362
+ align-items: flex-end;
363
+ justify-content: flex-start;
364
+ }
365
+ .qc-popup-resize-corner:hover,
366
+ .qc-popup-resize-corner:active {
367
+ opacity: 1;
368
+ }
369
+
370
+ /* ── settings card (设置 → 插件配置) — mirrors official PluginCard disclosure ── */
371
+ .qc-settings-card {
372
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35));
373
+ background: var(--dsw-alias-bg-layer-2, #1f1f21);
374
+ border-radius: 12px;
375
+ list-style: none;
376
+ transition: border-color .16s, background .16s;
377
+ }
378
+ .qc-settings-card:hover {
379
+ border-color: var(--dsw-alias-label-secondary, #cfd3d6);
380
+ }
381
+ .qc-settings-card-open {
382
+ background: var(--dsw-alias-bg-layer-1, #27272a);
383
+ border-color: var(--dsw-alias-label-secondary, #cfd3d6);
384
+ }
385
+ .qc-settings-header {
386
+ appearance: none;
387
+ width: 100%;
388
+ font: inherit;
389
+ color: inherit;
390
+ text-align: left;
391
+ cursor: pointer;
392
+ background: transparent;
393
+ border: 0;
394
+ border-radius: 12px;
395
+ align-items: center;
396
+ gap: 12px;
397
+ padding: 14px 16px;
398
+ display: flex;
399
+ }
400
+ .qc-settings-header:focus-visible {
401
+ outline: 2px solid var(--dsw-alias-brand-primary, #4a9eff);
402
+ outline-offset: -2px;
403
+ }
404
+ .qc-settings-head-text {
405
+ flex-direction: column;
406
+ flex: 1;
407
+ gap: 4px;
408
+ min-width: 0;
409
+ display: flex;
410
+ }
411
+ .qc-settings-name {
412
+ color: var(--dsw-alias-label-primary, #f2f2f2);
413
+ font-size: 15px;
414
+ font-weight: 600;
415
+ line-height: 1.4;
416
+ }
417
+ .qc-settings-description {
418
+ color: var(--dsw-alias-label-secondary, #cfd3d6);
419
+ font-size: 13px;
420
+ line-height: 1.5;
421
+ }
422
+ .qc-settings-chevron {
423
+ color: var(--dsw-alias-label-secondary, #cfd3d6);
424
+ flex: none;
425
+ transition: transform .16s;
426
+ }
427
+ .qc-settings-chevron-open {
428
+ transform: rotate(180deg);
429
+ }
430
+ .qc-settings-body {
431
+ border-top: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35));
432
+ margin: 0 16px;
433
+ padding-bottom: 12px;
434
+ display: flex;
435
+ flex-direction: column;
436
+ gap: 10px;
437
+ }
438
+ .qc-settings-intro {
439
+ font-size: 12px;
440
+ color: var(--dsw-alias-label-secondary, #cfd3d6);
441
+ margin: 8px 0 0;
442
+ line-height: 1.5;
443
+ }
444
+ .qc-settings-anchor {
445
+ display: flex;
446
+ align-items: center;
447
+ gap: 12px;
448
+ font-size: 12px;
449
+ }
450
+ .qc-settings-anchor-label {
451
+ color: var(--dsw-alias-label-primary, #f2f2f2);
452
+ font-weight: 500;
453
+ }
454
+ .qc-settings-radio {
455
+ display: inline-flex;
456
+ align-items: center;
457
+ gap: 4px;
458
+ color: var(--dsw-alias-label-secondary, #cfd3d6);
459
+ cursor: pointer;
460
+ }
461
+ .qc-settings-error {
462
+ font-size: 12px;
463
+ color: var(--dsw-alias-state-error-primary, #e5484d);
464
+ }
465
+ .qc-settings-empty {
466
+ font-size: 12px;
467
+ color: var(--dsw-alias-label-secondary, #cfd3d6);
468
+ margin: 0;
469
+ }
470
+ .qc-settings-workspace {
471
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35));
472
+ border-radius: 10px;
473
+ padding: 10px;
474
+ display: flex;
475
+ flex-direction: column;
476
+ gap: 8px;
477
+ }
478
+ .qc-settings-workspace-head {
479
+ display: flex;
480
+ align-items: baseline;
481
+ gap: 8px;
482
+ }
483
+ .qc-settings-workspace-title {
484
+ font-size: 13px;
485
+ font-weight: 600;
486
+ color: var(--dsw-alias-label-primary, #f2f2f2);
487
+ }
488
+ .qc-settings-workspace-path {
489
+ font-size: 11px;
490
+ font-family: var(--dsw-font-family-mono, monospace);
491
+ color: var(--dsw-alias-label-tertiary, #adb2b8);
492
+ overflow: hidden;
493
+ text-overflow: ellipsis;
494
+ white-space: nowrap;
495
+ }
496
+ .qc-settings-no-commands {
497
+ font-size: 12px;
498
+ color: var(--dsw-alias-label-tertiary, #adb2b8);
499
+ margin: 0;
500
+ }
501
+ .qc-settings-command {
502
+ display: flex;
503
+ gap: 6px;
504
+ align-items: center;
505
+ }
506
+ .qc-settings-field {
507
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35));
508
+ border-radius: 8px;
509
+ background: var(--dsw-alias-bg-layer-1, #2a2a2d);
510
+ color: var(--dsw-alias-label-primary, #f2f2f2);
511
+ font-size: 12px;
512
+ padding: 6px 8px;
513
+ outline: none;
514
+ }
515
+ .qc-settings-field::placeholder {
516
+ color: var(--dsw-alias-label-tertiary, #adb2b8);
517
+ }
518
+ .qc-settings-field:focus {
519
+ border-color: var(--dsw-alias-brand-primary, #4a9eff);
520
+ }
521
+ .qc-settings-field-name {
522
+ width: 160px;
523
+ flex: none;
524
+ }
525
+ .qc-settings-field-command {
526
+ flex: 1;
527
+ font-family: var(--dsw-font-family-mono, monospace);
528
+ }
529
+ .qc-settings-icon-btn {
530
+ border: 0;
531
+ background: transparent;
532
+ color: var(--dsw-alias-label-tertiary, #adb2b8);
533
+ cursor: pointer;
534
+ padding: 4px;
535
+ display: inline-flex;
536
+ align-items: center;
537
+ border-radius: 6px;
538
+ }
539
+ .qc-settings-icon-btn:hover {
540
+ background: color-mix(in srgb, var(--dsw-alias-label-primary, #f2f2f2) 10%, transparent);
541
+ color: var(--dsw-alias-state-error-primary, #e5484d);
542
+ }
543
+ .qc-settings-add-btn {
544
+ align-self: flex-start;
545
+ display: inline-flex;
546
+ align-items: center;
547
+ gap: 4px;
548
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35));
549
+ border-radius: 8px;
550
+ background: transparent;
551
+ color: var(--dsw-alias-label-primary, #f2f2f2);
552
+ font-size: 12px;
553
+ padding: 4px 10px;
554
+ cursor: pointer;
555
+ }
556
+ .qc-settings-add-btn:hover {
557
+ background: color-mix(in srgb, var(--dsw-alias-label-primary, #f2f2f2) 10%, transparent);
558
+ }
559
+
560
+ /* ── mobile fallback (dsh-mobile-nav) ──────────────────────────────────────
561
+ The dsh-mobile-nav plugin (≤1023px) hides the ENTIRE header utilities seat
562
+ with `[data-mobile-nav="frame"] [data-phase] header > :first-child >
563
+ :last-child { display: none !important; }`, because the seat used to hold
564
+ only the session-log-export capsule (which it relocates to the drawer
565
+ footer). Our quick-run ▶ button rides that same seat, so it vanished on
566
+ phones.
567
+
568
+ Re-show ONLY our button: restore the seat's flex layout (out-ranking the
569
+ host rule via `:has(.qc-head-wrap)` specificity (0,5,1) > (0,4,1)) and keep
570
+ every other seat occupant hidden (the session-log capsule stays relocated
571
+ to the drawer footer). Desktop (no [data-mobile-nav]) is untouched. */
572
+ [data-mobile-nav="frame"] [data-phase] header > :first-child > :last-child:has(.qc-head-wrap) {
573
+ display: flex !important;
574
+ margin-left: 4px !important;
575
+ gap: 0 !important;
576
+ }
577
+ [data-mobile-nav="frame"] [data-phase] header > :first-child > :last-child:has(.qc-head-wrap)
578
+ [data-slot="conversation.session.header.utilities"] > :not(.qc-head-wrap) {
579
+ display: none !important;
580
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * HMR-safe shared run state for the quick-commands header button and output
3
+ * popup. Module-level fields live on one globalThis-backed record keyed with
4
+ * Symbol.for so every client-bundle copy (client-hmr hot swap re-evaluates the
5
+ * bundle without reloading the page) reads/writes the same state cell.
6
+ *
7
+ * The popup handles ONE run at a time (serial per workspace by design); a
8
+ * second workspace can run in parallel with its own popup, so the store keys
9
+ * popups by runId.
10
+ */
11
+ import { useSyncExternalStore } from 'react'
12
+
13
+ const STATE_KEY = Symbol.for('@fonlan/dsh-quick-commands/run-state')
14
+
15
+ /** One popup slot: menu open flag + the live run the popup streams. */
16
+ export interface RunPopoverState {
17
+ /** Whether the header command menu is open. */
18
+ menuOpen: boolean
19
+ /** Active run id (undefined when no run is open). */
20
+ runId: string | null
21
+ /** Workspace id owning the active run (for kill/cwd display). */
22
+ workspaceId: string | null
23
+ /** Command display name. */
24
+ commandName: string | null
25
+ /** SSH host id when the run executes on a remote workspace (else null). */
26
+ remoteHost: string | null
27
+ /** Whether the run popup should stream (false while minimized). */
28
+ streaming: boolean
29
+ }
30
+
31
+ interface RunPopoverStateInternal extends RunPopoverState {
32
+ listeners: Set<() => void>
33
+ }
34
+
35
+ interface QuickCommandsState {
36
+ popover: RunPopoverStateInternal
37
+ /** Cached published snapshot — STABLE reference; rebuilt only after a patch. */
38
+ snapshot: RunPopoverState | null
39
+ }
40
+
41
+ function getState(): QuickCommandsState {
42
+ const g = globalThis as unknown as Record<symbol, QuickCommandsState | undefined>
43
+ let state = g[STATE_KEY]
44
+ if (state === undefined) {
45
+ state = {
46
+ popover: {
47
+ menuOpen: false,
48
+ runId: null,
49
+ workspaceId: null,
50
+ commandName: null,
51
+ remoteHost: null,
52
+ streaming: true,
53
+ listeners: new Set(),
54
+ },
55
+ // Seed the cache with values our equality check will see as unchanged.
56
+ snapshot: {
57
+ menuOpen: false,
58
+ runId: null,
59
+ workspaceId: null,
60
+ commandName: null,
61
+ remoteHost: null,
62
+ streaming: true,
63
+ },
64
+ }
65
+ g[STATE_KEY] = state
66
+ }
67
+ return state
68
+ }
69
+
70
+ function notify(): void {
71
+ // Invalidate the cached snapshot FIRST: useSyncExternalStore reads
72
+ // getSnapshot after notification, so a new stable object must be ready.
73
+ getState().snapshot = null
74
+ for (const listener of getState().popover.listeners) listener()
75
+ }
76
+
77
+ function subscribe(cb: () => void): () => void {
78
+ const listeners = getState().popover.listeners
79
+ listeners.add(cb)
80
+ return () => { listeners.delete(cb) }
81
+ }
82
+
83
+ /**
84
+ * getSnapshot for useSyncExternalStore. MUST return a stable reference while
85
+ * the underlying fields are unchanged (React compares with Object.is) — return
86
+ * the CACHED object; only rebuild it when a patch invalidated it.
87
+ */
88
+ function snapshot(): RunPopoverState {
89
+ const state = getState()
90
+ if (state.snapshot !== null) return state.snapshot
91
+ const p = state.popover
92
+ state.snapshot = {
93
+ menuOpen: p.menuOpen,
94
+ runId: p.runId,
95
+ workspaceId: p.workspaceId,
96
+ commandName: p.commandName,
97
+ remoteHost: p.remoteHost,
98
+ streaming: p.streaming,
99
+ }
100
+ return state.snapshot
101
+ }
102
+
103
+ function patch(partial: Partial<RunPopoverState>): void {
104
+ const state = getState()
105
+ Object.assign(state.popover, partial)
106
+ // Invalidate the cached snapshot so equality flips exactly on real changes.
107
+ state.snapshot = null
108
+ notify()
109
+ }
110
+
111
+ export function useRunPopover(): RunPopoverState {
112
+ return useSyncExternalStore(subscribe, snapshot, snapshot)
113
+ }
114
+
115
+ export function openMenu(): void { patch({ menuOpen: true }) }
116
+ export function closeMenu(): void { patch({ menuOpen: false }) }
117
+ export function toggleMenu(open: boolean): void { patch({ menuOpen: open }) }
118
+
119
+ export function openRun(runId: string, workspaceId: string, commandName: string, remoteHost?: string): void {
120
+ patch({ runId, workspaceId, commandName, remoteHost: remoteHost ?? null, menuOpen: false, streaming: true })
121
+ }
122
+
123
+ export function closeRun(): void {
124
+ patch({ runId: null, workspaceId: null, commandName: null, remoteHost: null, streaming: false })
125
+ }