@linxin666/dsh-remote-web-ui 0.4.3 → 0.4.5

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 (47) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +23 -8
  3. package/README.zh.md +22 -7
  4. package/lib/client.js +194 -140
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +231 -23
  7. package/lib/types/client/PluginSettingsCard.d.ts +7 -0
  8. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  9. package/lib/types/client/PluginSettingsCard.js +2 -2
  10. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  11. package/lib/types/client/RemoteSettingsCard.js +2 -1
  12. package/lib/types/client/index.js +3 -3
  13. package/lib/types/client/locales.d.ts +0 -4
  14. package/lib/types/client/locales.d.ts.map +1 -1
  15. package/lib/types/client/locales.js +5 -9
  16. package/lib/types/client/mobile-adapt.d.ts +2 -2
  17. package/lib/types/client/mobile-adapt.d.ts.map +1 -1
  18. package/lib/types/client/mobile-adapt.js +163 -136
  19. package/lib/types/client/settings-form.d.ts +16 -0
  20. package/lib/types/client/settings-form.d.ts.map +1 -1
  21. package/lib/types/client/settings-form.js +27 -1
  22. package/lib/types/detached-work.d.ts +45 -0
  23. package/lib/types/detached-work.d.ts.map +1 -0
  24. package/lib/types/detached-work.js +54 -0
  25. package/lib/types/index.d.ts.map +1 -1
  26. package/lib/types/index.js +67 -12
  27. package/lib/types/lan-bind.d.ts +21 -4
  28. package/lib/types/lan-bind.d.ts.map +1 -1
  29. package/lib/types/lan-bind.js +103 -17
  30. package/lib/types/pairing.d.ts.map +1 -1
  31. package/lib/types/pairing.js +43 -0
  32. package/lib/types/remote-api.d.ts +13 -1
  33. package/lib/types/remote-api.d.ts.map +1 -1
  34. package/lib/types/remote-api.js +14 -1
  35. package/package.json +19 -19
  36. package/src/client/PluginSettingsCard.tsx +9 -0
  37. package/src/client/RemoteSettingsCard.tsx +2 -0
  38. package/src/client/index.ts +3 -3
  39. package/src/client/locales.ts +5 -9
  40. package/src/client/mobile-adapt.ts +167 -138
  41. package/src/client/settings-card.module.css +31 -5
  42. package/src/client/settings-form.ts +27 -1
  43. package/src/detached-work.ts +57 -0
  44. package/src/index.ts +67 -12
  45. package/src/lan-bind.ts +111 -17
  46. package/src/pairing.ts +44 -1
  47. package/src/remote-api.ts +14 -1
@@ -32,8 +32,8 @@ export interface RemoteAdaptGlobal {
32
32
  closeDetails: (() => void) | null
33
33
  /**
34
34
  * Wired by the plugin apply once the `remote` locale namespace is bound:
35
- * the translate seat the injected surfaces (whale, compact picker) read
36
- * their labels from. Null until then; label reads fall back to English
35
+ * the translate seat the injected surface (the whale) reads its label
36
+ * from. Null until then; label reads fall back to English
37
37
  * (the SDK's universal fallback) and the 600ms sync tick re-renders the
38
38
  * labels once the seat is wired.
39
39
  */
@@ -54,14 +54,19 @@ const ADAPT_CSS_ID = 'dsh-remote-web-ui/mobile-adapt.css'
54
54
  const ACTIVE_CLASS = 'dsh-remote-portrait'
55
55
  /** Body class while the collapsed rail is hidden behind the whale. */
56
56
  const RAIL_HIDDEN_CLASS = 'dsh-remote-rail-hidden'
57
+ /**
58
+ * The official sidebar column root. Also the scope of the row sweep: every row
59
+ * the drag suppression covers is a descendant of it, so the document-wide scan
60
+ * it replaces only ever returned these.
61
+ */
62
+ const SIDEBAR_SELECTOR = '[class*="_sidebarCol"]'
63
+ /** The official draggable session/project rows (see disableRowDrag). */
64
+ const ROW_SELECTOR = '[class*="_sessionRow"], [class*="_projectRow"]'
65
+ /** The injected stylesheet, addressed through the same cached lookup. */
66
+ const STYLE_SELECTOR = `style[data-plugin-css="${ADAPT_CSS_ID}"]`
67
+
57
68
  /** Whale button id. */
58
69
  const WHALE_ID = 'dshRemoteWhale'
59
- /** Compact picker: synthesized model button id. */
60
- const MODEL_BTN_ID = 'dshRemoteModelPick'
61
- /** Compact picker: synthesized effort button id. */
62
- const EFFORT_BTN_ID = 'dshRemoteEffortPick'
63
- /** Body class while the compact picker buttons are wired. */
64
- const COMPACT_CLASS = 'dsh-remote-compact-picker'
65
70
  /** Body class while the header actions are seated in the tabs row. */
66
71
  const HEADER_SEATED_CLASS = 'dsh-remote-header-seated'
67
72
  /**
@@ -70,15 +75,6 @@ const HEADER_SEATED_CLASS = 'dsh-remote-header-seated'
70
75
  * reserving the space with padding.
71
76
  */
72
77
  const HEADER_RESERVE_VAR = '--dsh-remote-header-actions-reserve'
73
- /** The two composer picker entries the compact buttons drill into. */
74
- type PickerKind = 'model' | 'effort'
75
- /** Locale-dependent fast path for the official picker cells (zh/en). */
76
- const PICKER_CELL_PATTERN: Record<PickerKind, RegExp> = {
77
- model: /模型|Model/, // i18n-allow: matches official picker cell text, not plugin copy
78
- effort: /推理等级|Reasoning|Effort/i, // i18n-allow: matches official picker cell text, not plugin copy
79
- }
80
- /** Position of each drill cell among the sheet's chevron cells. */
81
- const DRILL_INDEX: Record<PickerKind, number> = { model: 0, effort: 1 }
82
78
  /**
83
79
  * The official application frame. The layout column classes are the anchor:
84
80
  * `_frame` is shared by unrelated official components (the chat turn rail,
@@ -207,26 +203,12 @@ const ADAPT_CSS: readonly string[] = [
207
203
  '[class$="_composerSeat"]{transform:none !important}',
208
204
  '[class$="_composerSeat"] [class$="_menu"]{position:fixed !important;left:8px !important;right:8px !important;top:auto !important;bottom:calc(8px + env(safe-area-inset-bottom)) !important;width:auto !important;max-width:none !important;max-height:70dvh !important;overflow-y:auto !important;z-index:2147482000}',
209
205
  '[class$="_composerSeat"] [class$="_menu"] [class$="_cell"]{height:44px;min-height:44px;font-size:13px}',
210
- // v79 (compact picker): when the row is too narrow for the desktop text
211
- // triggers, the phone falls back to icon entries — the context ring stays
212
- // official, and two synthesized buttons open the picker sheet straight on
213
- // the model list and the effort list (drill-through, so one tap lands on
214
- // the same list the user's mock shows). The original text trigger hides
215
- // only while the wired buttons exist (body class): a failed wiring
216
- // degrades back to the usable text trigger instead of no picker.
217
- `body.${COMPACT_CLASS} [class$="_composerSeat"] [class$="_trailing"] [class$="_trigger"]:has([class$="_triggerEffort"]){display:none}`,
218
- // The icon buttons sit inline in the tools row (parallel to the
219
- // permission trigger), so the trailing line collapses to zero and the
220
- // context ring + send re-anchor to the row itself. The ring shifts a
221
- // few px right: at its desktop offset its hit box kisses the effort
222
- // button.
223
- `body.${COMPACT_CLASS} [class$="_composerSeat"] [class$="_trailing"]{flex-basis:auto;position:static;min-height:0;padding:0;width:0}`,
224
- `body.${COMPACT_CLASS} [class$="_composerSeat"] [class$="_trailing"] > [class$="_root"]:has([class$="_track"]){right:44px}`,
225
- `#${MODEL_BTN_ID},#${EFFORT_BTN_ID}{width:26px;height:32px;min-width:26px;padding:0;border-radius:9px;background:var(--dsw-alias-bg-module-platform);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);display:flex;align-items:center;justify-content:center;cursor:pointer;flex:none;margin-left:4px}`,
226
-
227
-
228
- `#${MODEL_BTN_ID} svg,#${EFFORT_BTN_ID} svg{width:16px;height:16px;display:block}`,
229
- `#${MODEL_BTN_ID}:active,#${EFFORT_BTN_ID}:active{opacity:.7}`,
206
+ // v79's compact picker (two synthesized model/effort icon buttons in the
207
+ // tools row, the trailing text trigger hidden behind a body class, and the
208
+ // trailing line collapsed to zero width) was removed at the user's request.
209
+ // The portrait composer keeps the official text trigger with the trailing
210
+ // line rules above; the bottom-sheet rules immediately above still apply to
211
+ // the official menu.
230
212
  // Bottom stats line (N rounds / M steps) two sizes below the tabs; v55
231
213
  // wraps freely but clamps at two lines (the official rule is nowrap +
232
214
  // single-line ellipsis); v60 drops the official 32px right padding.
@@ -300,20 +282,23 @@ const ADAPT_CSS: readonly string[] = [
300
282
  // v68: settings modal on mobile — the official panel is a fixed 800px
301
283
  // two-column layout (nav + content); switch to a column layout: the
302
284
  // section nav becomes a horizontal scrollable row on top.
303
- '[class$="_overlay"] [class$="_panel"]{flex-direction:column;max-height:calc(100dvh - 32px)}',
304
- '[class$="_overlay"] [class$="_panel"] [class$="_nav"]{flex-direction:row;gap:4px;width:100%;padding:12px 12px 0;overflow-x:auto;overflow-y:hidden}',
305
- '[class$="_overlay"] [class$="_panel"] [class$="_navTitle"]{display:none}',
306
- '[class$="_overlay"] [class$="_panel"] [class$="_navList"]{flex-direction:row;gap:4px}',
307
- '[class$="_overlay"] [class$="_panel"] [class$="_navCell"]{height:34px;padding:0 12px;gap:6px;flex:none;border-radius:10px}',
308
- '[class$="_overlay"] [class$="_panel"] [class$="_navLabel"]{font-size:13px}',
309
- '[class$="_overlay"] [class$="_panel"] [class$="_content"]{flex:1;min-height:0}',
285
+ // Scoped to the portrait body class like the suppressions above: these
286
+ // are the only rules in this array that reshape an official two-column
287
+ // surface, and the overlay portal is shared with every other dialog.
288
+ // Unscoped, the tag — which lives in <head> for as long as the layer
289
+ // is active and is restored by the sync tick after a revert —
290
+ // collapsed the DESKTOP settings panel into a stacked column (measured
291
+ // on a real 1440px host: panel flex-direction row -> column, nav 800px
292
+ // tall -> 236px, content 612px -> 800px wide and pushed below the nav).
293
+ `body.${ACTIVE_CLASS} [class$="_overlay"] [class$="_panel"]{flex-direction:column;max-height:calc(100dvh - 32px)}`,
294
+ `body.${ACTIVE_CLASS} [class$="_overlay"] [class$="_panel"] [class$="_nav"]{flex-direction:row;gap:4px;width:100%;padding:12px 12px 0;overflow-x:auto;overflow-y:hidden}`,
295
+ `body.${ACTIVE_CLASS} [class$="_overlay"] [class$="_panel"] [class$="_navTitle"]{display:none}`,
296
+ `body.${ACTIVE_CLASS} [class$="_overlay"] [class$="_panel"] [class$="_navList"]{flex-direction:row;gap:4px}`,
297
+ `body.${ACTIVE_CLASS} [class$="_overlay"] [class$="_panel"] [class$="_navCell"]{height:34px;padding:0 12px;gap:6px;flex:none;border-radius:10px}`,
298
+ `body.${ACTIVE_CLASS} [class$="_overlay"] [class$="_panel"] [class$="_navLabel"]{font-size:13px}`,
299
+ `body.${ACTIVE_CLASS} [class$="_overlay"] [class$="_panel"] [class$="_content"]{flex:1;min-height:0}`,
310
300
  ]
311
301
 
312
- /** Cube glyph for the compact model button (a plain box outline). */
313
- const CUBE_ICON = '<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>'
314
- /** Level glyph for the compact effort button (three rising bars). */
315
- const LEVELS_ICON = '<path d="M4 6h16"/><path d="M7 12h10"/><path d="M10 18h4"/>'
316
-
317
302
  /** The DeepSeek fish glyph (the official brand mark path). */
318
303
  const FISH_PATH = 'M22.9168 1.43018C22.6713 1.31018 22.5658 1.53918 22.4223 1.65519C22.3733 1.69269 22.3318 1.74169 22.2903 1.78669C21.9317 2.1697 21.5127 2.42121 20.9657 2.39121C20.1657 2.34621 19.4827 2.59771 18.8787 3.20973C18.7502 2.45521 18.3236 2.0047 17.6746 1.71569C17.3351 1.56568 16.9916 1.41518 16.7536 1.08867C16.5876 0.856163 16.5421 0.597155 16.4591 0.341647C16.4061 0.187643 16.3536 0.0301382 16.1761 0.00363739C15.9836 -0.0263635 15.9081 0.135141 15.8326 0.270145C15.5306 0.822162 15.4136 1.43018 15.4251 2.0462C15.4516 3.43174 16.0366 4.53527 17.1991 5.3203C17.3311 5.4103 17.3651 5.5003 17.3236 5.63181C17.2441 5.90231 17.1501 6.16482 17.0671 6.43533C17.0141 6.60784 16.9351 6.64584 16.7501 6.57033C16.1121 6.30383 15.5611 5.90931 15.074 5.4328C14.2475 4.63328 13.5 3.75075 12.568 3.05973C12.349 2.89822 12.13 2.74822 11.9034 2.60522C10.9524 1.68169 12.028 0.923165 12.277 0.833162C12.5375 0.739159 12.3675 0.41615 11.5259 0.42015C10.6844 0.42365 9.91439 0.705658 8.93286 1.08117C8.78935 1.13767 8.63835 1.17867 8.48384 1.21267C7.59332 1.04367 6.66829 1.00617 5.70226 1.11517C3.88321 1.31768 2.43016 2.1777 1.36213 3.64575C0.0790928 5.4103 -0.222916 7.41536 0.146595 9.50642C0.535106 11.7105 1.66014 13.535 3.38869 14.9616C5.18125 16.4406 7.24581 17.1657 9.60138 17.0266C11.0319 16.9441 12.6245 16.7526 14.421 15.2321C14.874 15.4576 15.3496 15.5476 16.1381 15.6151C16.7456 15.6716 17.3306 15.5851 17.7836 15.4911C18.4931 15.3411 18.4441 14.6841 18.1876 14.5636C16.1081 13.595 16.5646 13.9891 16.1496 13.67C17.2061 12.42 18.8202 10.1979 19.3182 7.17235C19.3672 6.83834 19.4297 6.36783 19.4222 6.09732C19.4182 5.93231 19.4562 5.86831 19.6447 5.84931C20.1657 5.78931 20.6712 5.64681 21.1357 5.3913C22.4833 4.65528 23.0268 3.44624 23.1548 1.9972C23.1738 1.77569 23.1508 1.54668 22.9168 1.43018Z'
319
304
 
@@ -346,6 +331,8 @@ export function startMobileAdapt(): void {
346
331
  let whaleObserver: MutationObserver | null = null
347
332
  /** Header subtree observer: marks the geometry measurement dirty on re-render. */
348
333
  let headerObserver: MutationObserver | null = null
334
+ /** Document child-list observer: invalidates the cached-absent selector set. */
335
+ let domObserver: MutationObserver | null = null
349
336
  let observedHeader: Element | null = null
350
337
  /** Whether the seated-actions geometry needs re-measuring (see alignActionsText). */
351
338
  let headerGeometryDirty = true
@@ -368,17 +355,28 @@ export function startMobileAdapt(): void {
368
355
  * rail compaction) while the body class stays.
369
356
  */
370
357
  function ensureAdaptStyle(): void {
371
- if (nodeOf(`style[data-plugin-css="${ADAPT_CSS_ID}"]`) !== null) return
358
+ if (nodeOf(STYLE_SELECTOR) !== null) return
372
359
  const tag = document.createElement('style')
373
360
  tag.dataset.plugin = 'remote-web-ui'
374
361
  tag.dataset.pluginCss = ADAPT_CSS_ID
375
362
  tag.textContent = ADAPT_CSS.join('')
376
363
  document.head.appendChild(tag)
364
+ // Seed the lookup cache: the tag is created here, outside nodeOf, so the
365
+ // negative cache could otherwise report it missing for one more tick and
366
+ // stack a duplicate stylesheet.
367
+ nodeCache.set(STYLE_SELECTOR, tag)
368
+ nodeMissCache.delete(STYLE_SELECTOR)
377
369
  }
378
370
 
379
371
  function apply(): void {
380
372
  if (active) return
381
373
  active = true
374
+ // First statement of the activation, before any lookup: nothing observed the
375
+ // document while the layer was reverted (the observer is created below), so
376
+ // no "absent" verdict recorded earlier may be served now. Clearing here
377
+ // rather than beside the observer also covers the lookups this path runs
378
+ // before that point.
379
+ forgetAbsentSelectors()
382
380
  document.body.classList.add(ACTIVE_CLASS)
383
381
  // A details panel opened before the viewport rotated into portrait (or
384
382
  // restored across reloads) would sit behind the display:none above;
@@ -397,6 +395,7 @@ export function startMobileAdapt(): void {
397
395
  meta.setAttribute('content', `${savedViewportContent}, viewport-fit=cover`)
398
396
  }
399
397
  ensureWhale()
398
+ ensureDomObserver()
400
399
  syncWhale()
401
400
  // React builds nodes with attributes before inserting them, so
402
401
  // attribute observers miss the initial collapsed state — a light
@@ -410,7 +409,6 @@ export function startMobileAdapt(): void {
410
409
  active = false
411
410
  unseatHeaderActions()
412
411
  restoreRowDrag()
413
- removeCompactPicker()
414
412
  document.body.classList.remove(ACTIVE_CLASS)
415
413
  document.body.classList.remove(RAIL_HIDDEN_CLASS)
416
414
  const tag = document.querySelector(`style[data-plugin-css="${ADAPT_CSS_ID}"]`)
@@ -429,6 +427,45 @@ export function startMobileAdapt(): void {
429
427
  whaleObserver.disconnect()
430
428
  whaleObserver = null
431
429
  }
430
+ if (domObserver !== null) {
431
+ domObserver.disconnect()
432
+ domObserver = null
433
+ }
434
+ // Nothing observes the document from here on; a verdict recorded now could
435
+ // not be trusted after the next apply().
436
+ forgetAbsentSelectors()
437
+ }
438
+
439
+ /**
440
+ * Observe the document for the two kinds of change that can make a cached-absent
441
+ * target discoverable again (see nodeOf): node insertion/removal, and the
442
+ * class / compat-stamp attributes the cached selectors match on.
443
+ *
444
+ * Body class writes are excluded by target: the layer toggles three body
445
+ * classes every tick, and counting those would invalidate the negative cache
446
+ * on every tick and restore the very scan this cache removes. They cannot
447
+ * create a target either — every cached selector matches an element other
448
+ * than <body>.
449
+ *
450
+ * The record queue is drained on a microtask, so this stays off the layout
451
+ * path; a chat turn's insertions are a handful per second, not per element.
452
+ */
453
+ function ensureDomObserver(): void {
454
+ if (domObserver !== null || typeof MutationObserver === 'undefined' || !document.body) return
455
+ domObserver = new MutationObserver((records) => {
456
+ for (const record of records) {
457
+ if (record.type === 'childList' || record.target !== document.body) {
458
+ noteDomChange()
459
+ return
460
+ }
461
+ }
462
+ })
463
+ domObserver.observe(document.body, {
464
+ childList: true,
465
+ subtree: true,
466
+ attributes: true,
467
+ attributeFilter: ['class', 'data-dsh-frame'],
468
+ })
432
469
  }
433
470
 
434
471
  /** Start/stop the 600ms sync tick; a no-op when already in the asked state. */
@@ -441,88 +478,6 @@ export function startMobileAdapt(): void {
441
478
  }
442
479
  }
443
480
 
444
- /**
445
- * Compact picker (v79): a phone row cannot fit the desktop text triggers,
446
- * so the model/effort entries become two icon buttons in the trailing
447
- * row. Both forward to the official picker trigger (its menu renders as
448
- * the bottom sheet) and then drill straight into the asked cell — model
449
- * list or effort list — so one tap lands on the list, matching the
450
- * cube-model / brain-effort mapping. The official context ring next to
451
- * the send button keeps its own semantics untouched.
452
- */
453
- function removeCompactPicker(): void {
454
- document.body.classList.remove(COMPACT_CLASS)
455
- document.getElementById(MODEL_BTN_ID)?.remove()
456
- document.getElementById(EFFORT_BTN_ID)?.remove()
457
- }
458
-
459
- function drillIntoPicker(kind: PickerKind): void {
460
- const trigger = document.querySelector('[class$="_composerSeat"] [class$="_trailing"] [class$="_trigger"]:has([class$="_triggerEffort"])') as HTMLElement | null
461
- if (trigger === null) return
462
- trigger.click()
463
- // The sheet mount takes a beat (observed ~0.2-0.6s on a cold phone
464
- // mirror); poll until the asked cell exists instead of a fixed delay.
465
- let tries = 0
466
- const tapCell = (): void => {
467
- tries += 1
468
- const cells = Array.from(document.querySelectorAll('[class$="_composerSeat"] [class$="_menu"] [class$="_cell"]'))
469
- // The official cell copy is localized (zh/en/ru), so the label match is
470
- // only the fast path; the sheet's drill cells (label + value + chevron)
471
- // are the structural anchor: model first, effort second, on every locale.
472
- const byLabel = cells.find((c) => PICKER_CELL_PATTERN[kind].test(c.textContent ?? ''))
473
- const drillable = cells.filter((c) => c.querySelector('[class*="_cellChevron"], [class*="_chevron"]'))
474
- const cell = byLabel ?? drillable[DRILL_INDEX[kind]]
475
- if (cell !== undefined) {
476
- ;(cell as HTMLElement).click()
477
- return
478
- }
479
- if (tries < 8) window.setTimeout(tapCell, 150)
480
- }
481
- window.setTimeout(tapCell, 150)
482
- }
483
-
484
- function makeCompactButton(id: string, title: string, icon: string, kind: PickerKind): HTMLButtonElement {
485
- const btn = document.createElement('button')
486
- btn.id = id
487
- btn.type = 'button'
488
- btn.dataset.dshPlugin = 'remote-web-ui'
489
- btn.title = title
490
- btn.setAttribute('aria-label', title)
491
- btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icon}</svg>`
492
- btn.addEventListener('click', () => { drillIntoPicker(kind) })
493
- return btn
494
- }
495
-
496
- function syncCompactPicker(): void {
497
- if (!active) return
498
- const tools = nodeOf('[class$="_composerSeat"] [class$="_tools"]')
499
- const trigger = tools?.parentElement?.querySelector('[class$="_triggerEffort"]')?.parentElement
500
- if (tools === null || trigger === null) {
501
- removeCompactPicker()
502
- return
503
- }
504
- if (document.getElementById(MODEL_BTN_ID) === null) {
505
- // The cell pattern matches the OFFICIAL picker cell text (the official
506
- // client's own zh/en copy), not plugin-owned text.
507
- tools.appendChild(makeCompactButton(MODEL_BTN_ID, surfaceText('mobile.composer.pickModel', 'Pick model'), CUBE_ICON, 'model'))
508
- }
509
- if (document.getElementById(EFFORT_BTN_ID) === null) {
510
- tools.appendChild(makeCompactButton(EFFORT_BTN_ID, surfaceText('mobile.composer.pickEffort', 'Pick reasoning effort'), LEVELS_ICON, 'effort'))
511
- }
512
- // Titles re-read every tick so a locale switch (or the translate seat
513
- // arriving after this layer installed) is picked up without a reload.
514
- for (const [id, key, fallback] of [
515
- [MODEL_BTN_ID, 'mobile.composer.pickModel', 'Pick model'],
516
- [EFFORT_BTN_ID, 'mobile.composer.pickEffort', 'Pick reasoning effort'],
517
- ] as const) {
518
- const btn = document.getElementById(id)
519
- if (btn === null) continue
520
- const label = surfaceText(key, fallback)
521
- btn.title = label
522
- btn.setAttribute('aria-label', label)
523
- }
524
- document.body.classList.add(COMPACT_CLASS)
525
- }
526
481
  /**
527
482
  * The official sidebar toggle in the logo row. The row's own `_toggle` class
528
483
  * is the precise anchor; the row's last button is the fallback for cohorts
@@ -702,7 +657,7 @@ export function startMobileAdapt(): void {
702
657
  const dragOverridden = new Map<Element, string | null>()
703
658
  function disableRowDrag(): void {
704
659
  if (!active) return
705
- const rows = document.querySelectorAll('[class*="_sidebarCol"] [class*="_sessionRow"], [class*="_sidebarCol"] [class*="_projectRow"]')
660
+ const rows = sidebarRows()
706
661
  for (const row of rows) {
707
662
  if (row.getAttribute('draggable') !== 'false') {
708
663
  if (!dragOverridden.has(row)) dragOverridden.set(row, row.getAttribute('draggable'))
@@ -731,15 +686,90 @@ export function startMobileAdapt(): void {
731
686
  * node on a major re-render, which the isConnected guard detects.
732
687
  */
733
688
  const nodeCache = new Map<string, Element>()
689
+ /**
690
+ * Selectors known to be absent as of {@link domGeneration}. A miss is the
691
+ * common case, not the exception: the layer holds no overlay of its own, and
692
+ * several official surfaces it looks for (tabs row, tools row, sidebar rows)
693
+ * legitimately do not exist at once. Re-walking a conversation-sized document
694
+ * for the same absent selector on every 600ms tick was waste that almost
695
+ * always resolved to null: five document-wide lookups per tick on the measured
696
+ * fixture, zero after this cache. That is a lookup count — the wall-clock
697
+ * saving is real while the mounted content is quiet and shrinks to the
698
+ * row-scope saving when a turn streams (see the Agent Note for the harness and
699
+ * the before/after numbers).
700
+ *
701
+ * A miss is trusted only while the observed content has not changed, so a
702
+ * surface that appears is still found on the very next tick — the same
703
+ * discovery latency the unconditional probe had. The cache is never aged by a
704
+ * clock: it is cleared by {@link noteDomChange} while the observer is live and
705
+ * by {@link forgetAbsentSelectors} across the windows where it is not (apply
706
+ * and revert). One blind spot is deliberate and load-bearing: the observer
707
+ * watches <body>, so a head-resident target is not covered by that signal —
708
+ * the one such target is seeded into the cache where it is created (see
709
+ * ensureAdaptStyle).
710
+ */
711
+ const nodeMissCache = new Set<string>()
712
+ /**
713
+ * Bumped whenever a cached target may have appeared or disappeared. A surface
714
+ * qualifies on a class token or on the aggregate compat stamp
715
+ * (`data-dsh-frame`), so both insertions/removals AND those two attributes
716
+ * can change the answer. Nothing the layer writes per tick lands here: its own
717
+ * class writes are all on <body>, and its row/transform/label writes touch
718
+ * other attributes entirely (see ensureDomObserver).
719
+ */
720
+ let domGeneration = 0
721
+ /** The generation the negative cache was recorded against. */
722
+ let missGeneration = -1
723
+
724
+ function noteDomChange(): void {
725
+ domGeneration += 1
726
+ }
727
+
728
+ /**
729
+ * Drop every cached "absent" verdict. Called on apply and revert: while the
730
+ * observer is disconnected the layer is blind to insertions, so a verdict
731
+ * recorded before the gap could otherwise outlive the change that falsified
732
+ * it and be served without a probe. The positive cache needs no equivalent —
733
+ * its `isConnected` guard already re-resolves a replaced node.
734
+ */
735
+ function forgetAbsentSelectors(): void {
736
+ nodeMissCache.clear()
737
+ domGeneration += 1
738
+ }
739
+
734
740
  function nodeOf(selector: string): Element | null {
735
741
  const cached = nodeCache.get(selector)
736
- if (cached !== undefined && cached.isConnected) return cached
742
+ if (cached !== undefined) {
743
+ // A node React replaced is no longer connected; drop it and fall through
744
+ // so the lookup below can find its replacement.
745
+ if (cached.isConnected) return cached
746
+ nodeCache.delete(selector)
747
+ }
748
+ if (missGeneration !== domGeneration) {
749
+ nodeMissCache.clear()
750
+ missGeneration = domGeneration
751
+ }
752
+ if (nodeMissCache.has(selector)) return null
737
753
  const found = document.querySelector(selector)
738
- if (found === null) nodeCache.delete(selector)
739
- else nodeCache.set(selector, found)
754
+ if (found === null) {
755
+ nodeMissCache.add(selector)
756
+ return null
757
+ }
758
+ nodeCache.set(selector, found)
740
759
  return found
741
760
  }
742
761
 
762
+ /**
763
+ * The session/project rows the drag suppression covers, scoped to the cached
764
+ * sidebar root. Each row carries the sidebar column class as an ancestor, so
765
+ * the document-wide scan this replaces only ever returned rows from here.
766
+ */
767
+ function sidebarRows(): Element[] {
768
+ const sidebar = nodeOf(SIDEBAR_SELECTOR)
769
+ if (sidebar === null) return []
770
+ return Array.from(sidebar.querySelectorAll(ROW_SELECTOR))
771
+ }
772
+
743
773
  /** The official application frame, through the same cached lookup. */
744
774
  function frameEl(): HTMLElement | null {
745
775
  const frame = nodeOf(APP_FRAME_SELECTOR)
@@ -772,7 +802,6 @@ export function startMobileAdapt(): void {
772
802
  disableRowDrag()
773
803
  seatHeaderActions()
774
804
  alignActionsText()
775
- syncCompactPicker()
776
805
  }
777
806
 
778
807
  // v67: on mobile the header actions (agent-preset mode label + background
@@ -36,8 +36,8 @@
36
36
  border: 0;
37
37
  border-radius: 12px;
38
38
  align-items: center;
39
- gap: 12px;
40
- padding: 14px 16px;
39
+ gap: 13px;
40
+ padding: 16px;
41
41
  display: flex;
42
42
  }
43
43
 
@@ -51,8 +51,8 @@
51
51
  box-sizing: border-box;
52
52
  border-radius: 12px;
53
53
  align-items: center;
54
- gap: 12px;
55
- padding: 14px 16px;
54
+ gap: 13px;
55
+ padding: 16px;
56
56
  display: flex;
57
57
  }
58
58
 
@@ -64,6 +64,25 @@
64
64
  display: flex;
65
65
  }
66
66
 
67
+ /* Optional leading plugin mark: a quiet tile that takes the ink color on
68
+ hover and while the card is open, so the whole row reads as one control. */
69
+ .mark {
70
+ flex: none;
71
+ display: grid;
72
+ place-items: center;
73
+ width: 34px;
74
+ height: 34px;
75
+ border-radius: 10px;
76
+ background: var(--dsw-alias-bg-module-platform);
77
+ color: var(--dsw-alias-label-secondary);
78
+ transition: color 0.16s;
79
+ }
80
+
81
+ .header:hover .mark,
82
+ .cardOpen .mark {
83
+ color: var(--dsw-alias-label-primary);
84
+ }
85
+
67
86
  .name {
68
87
  color: var(--dsw-alias-label-primary);
69
88
  font-size: 15px;
@@ -95,7 +114,13 @@
95
114
  .chevron {
96
115
  color: var(--dsw-alias-label-tertiary);
97
116
  flex: none;
98
- transition: transform 0.16s;
117
+ width: 16px;
118
+ height: 16px;
119
+ transition: transform 0.16s, color 0.16s;
120
+ }
121
+
122
+ .header:hover .chevron {
123
+ color: var(--dsw-alias-label-secondary);
99
124
  }
100
125
 
101
126
  .chevronOpen {
@@ -406,6 +431,7 @@
406
431
  @media (prefers-reduced-motion: reduce) {
407
432
  .card,
408
433
  .header,
434
+ .mark,
409
435
  .chevron,
410
436
  .chevronOpen,
411
437
  .discard,
@@ -215,6 +215,8 @@ export class CardForm<T> {
215
215
  private readonly disposeForm: () => void
216
216
  private disposed = false
217
217
  private saving = false
218
+ /** A save asked for while one was in flight; it runs once the first settles (#1754). */
219
+ private saveQueued = false
218
220
  private failed = false
219
221
  private failedReason: string | undefined
220
222
 
@@ -283,7 +285,7 @@ export class CardForm<T> {
283
285
  resetField: (field) => {
284
286
  this.stage(field, { text: this.specOf(field).format(this.baseValue(field)), clear: true })
285
287
  },
286
- save: () => { void this.save() },
288
+ save: () => { void this.requestSave() },
287
289
  discard: () => {
288
290
  if (this.staged.size === 0 && !this.failed) return
289
291
  this.staged.clear()
@@ -310,6 +312,30 @@ export class CardForm<T> {
310
312
  * the user can correct them instead of retyping.
311
313
  * @returns settlement after the mutation and the read-back.
312
314
  */
315
+ /**
316
+ * Run one save, and re-run it once if another was asked for while this one
317
+ * was still in flight.
318
+ *
319
+ * A save is a Host round trip that also drives the profile reconcile, and
320
+ * the Host runs that write inside one exclusive transaction. Answering a
321
+ * press that arrives mid-flight by returning immediately dropped the edit
322
+ * with no explanation, which is the "the save button stops working after a
323
+ * few rounds" report (#1754). Serializing instead means a save pressed while
324
+ * another is still settling runs against the settled state - which is what
325
+ * the operator meant by pressing it again.
326
+ * @returns settlement after the mutation and the read-back.
327
+ */
328
+ async requestSave(): Promise<void> {
329
+ if (this.saving) {
330
+ this.saveQueued = true
331
+ return
332
+ }
333
+ await this.save()
334
+ if (!this.saveQueued) return
335
+ this.saveQueued = false
336
+ await this.requestSave()
337
+ }
338
+
313
339
  async save(): Promise<void> {
314
340
  const plan = this.plan()
315
341
  const valid = plan.filter((item): item is PlannedWrite & { judge: () => boolean } => item.judge !== undefined)
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Run one piece of work outside the caller's async context.
3
+ *
4
+ * The Host performs a settings mutation inside `hmr.runExclusive`, which marks
5
+ * the running async context with an AsyncLocalStorage store. Side effects that
6
+ * live on that path - writing `cordis.patch.yml`, the very file the HMR config
7
+ * watcher refreshes from - must not run under that mark, or the watcher's
8
+ * refresh can re-enter the exclusive transaction and be refused with "HMR
9
+ * transactions cannot be nested" (#1751, #1754).
10
+ *
11
+ * Deferring is NOT enough, and the earlier belt that this one replaces was
12
+ * built on a disproven premise: Node propagates an AsyncLocalStorage store into
13
+ * `setImmediate`, into `node:timers`, into promise continuations, and into any
14
+ * AsyncResource scoped to the current async id. Measured on Node 24:
15
+ *
16
+ * ```
17
+ * als.run(true, () => setImmediate(() => als.getStore())) // -> true
18
+ * ```
19
+ *
20
+ * What DOES start clean is an AsyncResource whose `triggerAsyncId` is not the
21
+ * transaction. A resource created once at module scope - that is, before any
22
+ * transaction existed - carries no store, and everything it schedules inherits
23
+ * that emptiness rather than the caller's mark:
24
+ *
25
+ * ```
26
+ * const detached = new AsyncResource('...') // module scope
27
+ * als.run(true, () => detached.runInAsyncScope(() => setImmediate(() => als.getStore())))
28
+ * // -> undefined
29
+ * ```
30
+ *
31
+ * @module
32
+ */
33
+
34
+ import { AsyncResource } from 'node:async_hooks'
35
+
36
+ /**
37
+ * The escape hatch, created at module scope on purpose: a resource built while
38
+ * a transaction is running would capture that transaction, so this single
39
+ * instance is the only one the module ever needs. It carries no context of its
40
+ * own, so every job run through it starts from the process's root context.
41
+ */
42
+ const DETACHED = new AsyncResource('dsh-remote-web-ui:detached-work', { triggerAsyncId: 0 })
43
+
44
+ /**
45
+ * Run `work` outside the caller's AsyncLocalStorage context.
46
+ *
47
+ * The call itself is synchronous - this schedules, it does not defer. Work that
48
+ * must not block the caller should schedule its own `setImmediate` inside
49
+ * `work`, which then also starts clean because it inherits this resource's
50
+ * empty context rather than the caller's.
51
+ *
52
+ * @param work - the side effect to detach from the caller's async context.
53
+ * @returns whatever `work` returns.
54
+ */
55
+ export function runDetached<T>(work: () => T): T {
56
+ return DETACHED.runInAsyncScope(work)
57
+ }