@linxin666/dsh-remote-web-ui 0.3.18 → 0.3.20

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.
@@ -38,6 +38,34 @@ const MODEL_BTN_ID = 'dshRemoteModelPick';
38
38
  const EFFORT_BTN_ID = 'dshRemoteEffortPick';
39
39
  /** Body class while the compact picker buttons are wired. */
40
40
  const COMPACT_CLASS = 'dsh-remote-compact-picker';
41
+ /** Body class while the header actions are seated in the tabs row. */
42
+ const HEADER_SEATED_CLASS = 'dsh-remote-header-seated';
43
+ /** Locale-dependent fast path for the official picker cells (zh/en). */
44
+ const PICKER_CELL_PATTERN = {
45
+ model: /模型|Model/, // i18n-allow: matches official picker cell text, not plugin copy
46
+ effort: /推理等级|Reasoning|Effort/i, // i18n-allow: matches official picker cell text, not plugin copy
47
+ };
48
+ /** Position of each drill cell among the sheet's chevron cells. */
49
+ const DRILL_INDEX = { model: 0, effort: 1 };
50
+ /**
51
+ * The official application frame. The layout column classes are the anchor:
52
+ * `_frame` is shared by unrelated official components (the chat turn rail,
53
+ * message-image thumbnails, the subagent pill, PlanReviewPanel), so a bare
54
+ * suffix match would restyle and hit-block those. `data-dsh-frame` is the
55
+ * aggregate compat stamp, kept as the fast path.
56
+ */
57
+ const APP_FRAME_SELECTOR = '[data-dsh-frame], [class*="_frame"]:has([class*="_centerCol"])';
58
+ /**
59
+ * The official composer surface. `_composerSeat` is the plugin's historical
60
+ * anchor; the official `data-dsh-surface="composer"` part attribute is the
61
+ * stable alternative if the class ever gains a modifier.
62
+ */
63
+ const COMPOSER_SELECTOR = '[class$="_composerSeat"], [data-dsh-surface="composer"]';
64
+ /** Resolve the official application frame (never a nested `_frame` surface). */
65
+ function appFrame() {
66
+ const frame = document.querySelector(APP_FRAME_SELECTOR);
67
+ return frame instanceof HTMLElement ? frame : null;
68
+ }
41
69
  /**
42
70
  * Whether the current viewport is a portrait touch device small enough to
43
71
  * need the adaptation.
@@ -64,7 +92,10 @@ function isMobilePortrait() {
64
92
  const ADAPT_CSS = [
65
93
  'html,body{height:100%}',
66
94
  // The app frame fills the dynamic viewport (browser chrome collapse).
67
- '[class$="_frame"]{width:100%;height:100dvh}',
95
+ // Scoped to the application frame: a bare [class$="_frame"] also matches the
96
+ // chat turn rail, message thumbnails, the subagent pill and PlanReviewPanel,
97
+ // stretching each to a full-width 100dvh pointer-events:auto block.
98
+ '[class*="_frame"]:has([class*="_centerCol"]){width:100%;height:100dvh}',
68
99
  // Collapsed rail: bigger touch targets.
69
100
  '[class$="_railFish"] button,[class$="_panelIcon"],[class$="_newSession"]{min-width:44px;min-height:44px}',
70
101
  // Message list padding on narrow screens.
@@ -76,8 +107,8 @@ const ADAPT_CSS = [
76
107
  '[class$="_scrollBody"] [class$="_root"]{font-size:14.5px}',
77
108
  '[class$="_scrollBody"] [class$="_bubble"]{font-size:14.5px}',
78
109
  '[class$="_titleRow"] *{font-size:13px}',
79
- '[class$="_sidebarCol"] [class$="_root"],[class$="_sidebarCol"] [class$="_newSession"],[class$="_sidebarCol"] [class$="_trigger"],[class$="_sidebarCol"] [class$="_title"]{font-size:13px}',
80
- '[class$="_sidebarCol"] [class$="_meta"],[class$="_sidebarCol"] [class$="_time"]{font-size:11.5px}',
110
+ '[class*="_sidebarCol"] [class$="_root"],[class*="_sidebarCol"] [class$="_newSession"],[class*="_sidebarCol"] [class$="_trigger"],[class*="_sidebarCol"] [class$="_title"]{font-size:13px}',
111
+ '[class*="_sidebarCol"] [class$="_meta"],[class*="_sidebarCol"] [class$="_time"]{font-size:11.5px}',
81
112
  // v50: collapsed rail hidden; the whale button is the entry. The frame's
82
113
  // grid is inline-styled (56px rail track when collapsed); pin the first
83
114
  // track to 0 so content uses the full width. On portrait phones the
@@ -91,7 +122,7 @@ const ADAPT_CSS = [
91
122
  // host overlay mounts). Pin every column to an explicit track so the
92
123
  // center always lands on the 1fr track regardless of the sidebar's
93
124
  // computed position.
94
- '[class$="_frame"][data-sidebar-collapsed] [class$="_sidebarCol"]{grid-column:1/2}',
125
+ '[class$="_frame"][data-sidebar-collapsed] [class*="_sidebarCol"]{grid-column:1/2}',
95
126
  '[class$="_frame"][data-sidebar-collapsed] [class$="_centerCol"]{grid-column:2/3}',
96
127
  '[class$="_frame"][data-sidebar-collapsed] [class$="_detailsCol"]{grid-column:3/4}',
97
128
  // Keep the chat header title clear of the floating whale.
@@ -105,13 +136,13 @@ const ADAPT_CSS = [
105
136
  // carries two classes, so match by containment); v47's panelIcon touch
106
137
  // rule forces a 44px min on the icon itself — zero it out so the 13px
107
138
  // glyph fits the 18px button.
108
- '[class$="_sidebarCol"] [class$="_logoRow"] [class*="_iconButton"]{width:18px;height:18px}',
109
- '[class$="_sidebarCol"] [class$="_logoRow"] [class*="_iconButton"] svg{width:13px;height:13px;min-width:0;min-height:0}',
139
+ '[class*="_sidebarCol"] [class$="_logoRow"] [class*="_iconButton"]{width:18px;height:18px}',
140
+ '[class*="_sidebarCol"] [class$="_logoRow"] [class*="_iconButton"] svg{width:13px;height:13px;min-width:0;min-height:0}',
110
141
  // v77: workspace rows' right-side actions (menu + new session) are
111
142
  // hover-only on desktop; always show them on mobile touch.
112
- '[class$="_sidebarCol"] [class$="_projectRow"] [class$="_rowActions"]{display:inline-flex}',
143
+ '[class*="_sidebarCol"] [class*="_projectRow"] [class*="_rowActions"]{display:inline-flex}',
113
144
  // v78: mobile long-press on a row must not start native drag.
114
- '[class$="_sidebarCol"] [class$="_sessionRow"],[class$="_sidebarCol"] [class$="_projectRow"]{-webkit-user-drag:none;user-select:none}',
145
+ '[class*="_sidebarCol"] [class*="_sessionRow"],[class*="_sidebarCol"] [class*="_projectRow"]{-webkit-user-drag:none;user-select:none}',
115
146
  // Hide the header Session-log download button (no space on phones).
116
147
  '[class$="_headerUtilities"]{display:none}',
117
148
  // v52 composer: the two lines (permission / model) stay stacked with zero
@@ -189,17 +220,14 @@ const ADAPT_CSS = [
189
220
  // v67: header actions (agent-preset mode label + background-task badge)
190
221
  // are re-seated from the title row into the tabs row; hidden in the
191
222
  // original spot so React re-renders do not flicker them back.
192
- '[class$="_header"] [class$="_titleCluster"] [class$="_headerActions"]{display:none}',
193
- // The official header keeps a 78px right padding on phones; stretch the
194
- // tabs row so the seated actions sit flush against the right edge
195
- // (verified 360/390/480 on the reference).
196
- '[class$="_header"] [class$="_tabs"]{margin-right:-58px}',
223
+ // Only while the layer has taken the actions over: out of flow (so the
224
+ // title row keeps its own layout and React keeps owning the node) and
225
+ // painted over the tabs row by the transform alignActionsText computes.
226
+ `body.${HEADER_SEATED_CLASS} [class$="_header"] [class$="_titleCluster"] [class$="_headerActions"]{position:absolute;left:0;top:0;margin:0;display:flex;align-items:center;gap:6px;flex:none;z-index:2}`,
197
227
  // v70: match the tab text size with the seated mode label (12px); NB
198
228
  // [class$="_tab"] misses the active tab (its class ends in "_tabActive")
199
229
  // — containment so BOTH tabs match.
200
230
  '[class$="_header"] [class$="_tabs"] [class*="_tab"]{font-size:12px;white-space:nowrap}',
201
- // Text-bottom alignment is dynamic (alignActionsText below); flex seat.
202
- '[class$="_header"] [class$="_tabs"] [class$="_headerActions"]{margin-left:auto;display:flex;align-items:center;gap:6px;flex:none}',
203
231
  // Mobile scope: hide the plugin surfaces that do not fit a phone — the
204
232
  // right-hand details column and every desktop-oriented tool surface. The
205
233
  // list keys on the L2 semantic roots (data-dsh-plugin, ownership stays
@@ -269,6 +297,11 @@ export function startMobileAdapt() {
269
297
  let savedViewportContent = null;
270
298
  let whaleEl = null;
271
299
  let whaleObserver = null;
300
+ /** Header subtree observer: marks the geometry measurement dirty on re-render. */
301
+ let headerObserver = null;
302
+ let observedHeader = null;
303
+ /** Whether the seated-actions geometry needs re-measuring (see alignActionsText). */
304
+ let headerGeometryDirty = true;
272
305
  let whaleTimer = null;
273
306
  let whaleSuppressClick = false;
274
307
  let whaleShown = false;
@@ -330,6 +363,7 @@ export function startMobileAdapt() {
330
363
  return;
331
364
  active = false;
332
365
  unseatHeaderActions();
366
+ restoreRowDrag();
333
367
  removeCompactPicker();
334
368
  document.body.classList.remove(ACTIVE_CLASS);
335
369
  document.body.classList.remove(RAIL_HIDDEN_CLASS);
@@ -343,6 +377,10 @@ export function startMobileAdapt() {
343
377
  if (whaleEl !== null)
344
378
  whaleEl.style.display = 'none';
345
379
  setWhaleTimer(false);
380
+ if (whaleObserver !== null) {
381
+ whaleObserver.disconnect();
382
+ whaleObserver = null;
383
+ }
346
384
  }
347
385
  /** Start/stop the 600ms sync tick; a no-op when already in the asked state. */
348
386
  function setWhaleTimer(on) {
@@ -368,7 +406,7 @@ export function startMobileAdapt() {
368
406
  document.getElementById(MODEL_BTN_ID)?.remove();
369
407
  document.getElementById(EFFORT_BTN_ID)?.remove();
370
408
  }
371
- function drillIntoPicker(cellPattern) {
409
+ function drillIntoPicker(kind) {
372
410
  const trigger = document.querySelector('[class$="_composerSeat"] [class$="_trailing"] [class$="_trigger"]:has([class$="_triggerEffort"])');
373
411
  if (trigger === null)
374
412
  return;
@@ -378,8 +416,13 @@ export function startMobileAdapt() {
378
416
  let tries = 0;
379
417
  const tapCell = () => {
380
418
  tries += 1;
381
- const cell = Array.from(document.querySelectorAll('[class$="_composerSeat"] [class$="_menu"] [class$="_cell"]'))
382
- .find((c) => cellPattern.test(c.textContent ?? ''));
419
+ const cells = Array.from(document.querySelectorAll('[class$="_composerSeat"] [class$="_menu"] [class$="_cell"]'));
420
+ // The official cell copy is localized (zh/en/ru), so the label match is
421
+ // only the fast path; the sheet's drill cells (label + value + chevron)
422
+ // are the structural anchor: model first, effort second, on every locale.
423
+ const byLabel = cells.find((c) => PICKER_CELL_PATTERN[kind].test(c.textContent ?? ''));
424
+ const drillable = cells.filter((c) => c.querySelector('[class*="_cellChevron"], [class*="_chevron"]'));
425
+ const cell = byLabel ?? drillable[DRILL_INDEX[kind]];
383
426
  if (cell !== undefined) {
384
427
  ;
385
428
  cell.click();
@@ -390,7 +433,7 @@ export function startMobileAdapt() {
390
433
  };
391
434
  window.setTimeout(tapCell, 150);
392
435
  }
393
- function makeCompactButton(id, title, icon, cellPattern) {
436
+ function makeCompactButton(id, title, icon, kind) {
394
437
  const btn = document.createElement('button');
395
438
  btn.id = id;
396
439
  btn.type = 'button';
@@ -398,7 +441,7 @@ export function startMobileAdapt() {
398
441
  btn.title = title;
399
442
  btn.setAttribute('aria-label', title);
400
443
  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>`;
401
- btn.addEventListener('click', () => { drillIntoPicker(cellPattern); });
444
+ btn.addEventListener('click', () => { drillIntoPicker(kind); });
402
445
  return btn;
403
446
  }
404
447
  function syncCompactPicker() {
@@ -413,10 +456,10 @@ export function startMobileAdapt() {
413
456
  if (document.getElementById(MODEL_BTN_ID) === null) {
414
457
  // The cell pattern matches the OFFICIAL picker cell text (the official
415
458
  // client's own zh/en copy), not plugin-owned text.
416
- tools.appendChild(makeCompactButton(MODEL_BTN_ID, surfaceText('mobile.composer.pickModel', 'Pick model'), CUBE_ICON, /模型|Model/)); // i18n-allow: matches official picker cell text, not plugin copy
459
+ tools.appendChild(makeCompactButton(MODEL_BTN_ID, surfaceText('mobile.composer.pickModel', 'Pick model'), CUBE_ICON, 'model'));
417
460
  }
418
461
  if (document.getElementById(EFFORT_BTN_ID) === null) {
419
- tools.appendChild(makeCompactButton(EFFORT_BTN_ID, surfaceText('mobile.composer.pickEffort', 'Pick reasoning effort'), LEVELS_ICON, /推理等级|Reasoning|Effort/i)); // i18n-allow: matches official picker cell text, not plugin copy
462
+ tools.appendChild(makeCompactButton(EFFORT_BTN_ID, surfaceText('mobile.composer.pickEffort', 'Pick reasoning effort'), LEVELS_ICON, 'effort'));
420
463
  }
421
464
  // Titles re-read every tick so a locale switch (or the translate seat
422
465
  // arriving after this layer installed) is picked up without a reload.
@@ -434,30 +477,76 @@ export function startMobileAdapt() {
434
477
  document.body.classList.add(COMPACT_CLASS);
435
478
  }
436
479
  /**
437
- * Toggle the sidebar through the wired layout face, then verify the flip:
438
- * the official LayoutController can be mounted yet inert (its bound store
439
- * actions race the root entry on this cohort — observed on the running
440
- * local build, where the face call silently did nothing and the whale
441
- * was dead). When the frame state did not change shortly after the call,
442
- * drive the official rail/logo toggle instead: that button owns its own
443
- * store actions and flips the same state on every cohort we support.
480
+ * The official sidebar toggle in the logo row. The row's own `_toggle` class
481
+ * is the precise anchor; the row's last button is the fallback for cohorts
482
+ * that only expose the generic icon-button class (the first button is the
483
+ * brand, which navigates home — never click that one). `_railFish` is kept
484
+ * for older compositions that carried a rail fish button.
485
+ */
486
+ function officialSidebarToggle() {
487
+ const rail = document.querySelector('[class$="_railFish"] button');
488
+ if (rail instanceof HTMLElement)
489
+ return rail;
490
+ const row = document.querySelector('[class$="_logoRow"]');
491
+ if (row === null)
492
+ return null;
493
+ const toggles = row.querySelectorAll('button[class*="_toggle"]');
494
+ const last = toggles.length > 0 ? toggles[toggles.length - 1] : row.querySelectorAll('button')[row.querySelectorAll('button').length - 1];
495
+ return last instanceof HTMLElement ? last : null;
496
+ }
497
+ /**
498
+ * Toggle the sidebar through the official rail/logo toggle first, then
499
+ * verify the flip and fall back to the wired layout face.
500
+ *
501
+ * The order is evidence-driven: the official toggle owns its own store
502
+ * actions and flips the state on every cohort we support, while the wired
503
+ * LayoutController is mounted yet inert on the installed cohort — calling it
504
+ * left `data-sidebar-collapsed` untouched for the whole 800ms observation
505
+ * window, so face-first made every whale tap wait out the verification
506
+ * delay before anything moved. The face stays the fallback for compositions
507
+ * that render no logo-row toggle, and it is only called after the toggle
508
+ * demonstrably failed, so the two can never cancel each other out.
444
509
  */
445
510
  function toggleSidebarVerified() {
446
- const frame = document.querySelector('[class$="_frame"]');
447
- const collapsedBefore = frame instanceof HTMLElement ? frame.hasAttribute('data-sidebar-collapsed') : null;
448
- w.__dshRemoteAdapt?.toggleSidebar?.();
449
- if (collapsedBefore === null)
511
+ const frame = appFrame();
512
+ const collapsedBefore = frame !== null ? frame.hasAttribute('data-sidebar-collapsed') : null;
513
+ const face = w.__dshRemoteAdapt?.toggleSidebar;
514
+ const callFace = () => {
515
+ if (typeof face !== 'function')
516
+ return;
517
+ try {
518
+ face();
519
+ }
520
+ catch { }
521
+ };
522
+ const toggle = officialSidebarToggle();
523
+ if (collapsedBefore === null || toggle === null) {
524
+ // No frame to verify against, or no official toggle: the face is the
525
+ // only available control.
526
+ if (toggle !== null)
527
+ toggle.click();
528
+ else
529
+ callFace();
450
530
  return;
451
- window.setTimeout(() => {
531
+ }
532
+ toggle.click();
533
+ let tries = 0;
534
+ const verify = () => {
452
535
  if (!active)
453
536
  return;
454
- const frameNow = document.querySelector('[class$="_frame"]');
455
- if (!(frameNow instanceof HTMLElement))
537
+ const frameNow = appFrame();
538
+ if (frameNow === null)
456
539
  return;
457
540
  if (frameNow.hasAttribute('data-sidebar-collapsed') !== collapsedBefore)
458
541
  return;
459
- document.querySelector('[class$="_railFish"] button, [class$="_logoRow"] [class*="_iconButton"]')?.click();
460
- }, 150);
542
+ tries += 1;
543
+ if (tries < 2) {
544
+ window.setTimeout(verify, 120);
545
+ return;
546
+ }
547
+ callFace();
548
+ };
549
+ window.setTimeout(verify, 120);
461
550
  }
462
551
  function ensureWhale() {
463
552
  if (whaleEl !== null || !document.body)
@@ -490,12 +579,12 @@ export function startMobileAdapt() {
490
579
  // the keyboard (iOS) or keeps it open. Blur at pointerdown (before the
491
580
  // tap completes; click is too late for iOS) and block pending
492
581
  // programmatic refocus.
493
- if (active) {
494
- const ta = document.querySelector('[class$="_composerSeat"] textarea, [class$="_composerSeat"] input');
495
- if (ta !== null && document.activeElement === ta) {
496
- ta.blur();
497
- lastComposerTap = 0;
498
- }
582
+ if (active && isComposerField(document.activeElement)) {
583
+ // The composer field is a contenteditable div (the official client
584
+ // renders no textarea), so the old textarea/input lookup never matched
585
+ // it while a bare `input` branch could blur an unrelated field.
586
+ document.activeElement.blur();
587
+ lastComposerTap = 0;
499
588
  }
500
589
  drag = { x: e.clientX, y: e.clientY, left: whale.offsetLeft, top: whale.offsetTop, moved: false };
501
590
  try {
@@ -528,10 +617,33 @@ export function startMobileAdapt() {
528
617
  catch { }
529
618
  };
530
619
  whale.addEventListener('pointerup', endWhaleDrag);
531
- whale.addEventListener('pointercancel', endWhaleDrag);
620
+ // A cancelled pointer (system gesture, incoming call) is not a drag end:
621
+ // keep the click un-suppressed so the next real tap still toggles.
622
+ whale.addEventListener('pointercancel', () => {
623
+ drag = null;
624
+ whaleSuppressClick = false;
625
+ });
532
626
  whaleEl = whale;
533
627
  document.body.appendChild(whale);
534
628
  }
629
+ /**
630
+ * Restore the official draggable state this layer overrode while active.
631
+ * Rows are React-owned and may have been re-created meanwhile, so only the
632
+ * tracked elements are touched; a detached element is skipped.
633
+ */
634
+ function restoreRowDrag() {
635
+ if (dragOverridden.size === 0)
636
+ return;
637
+ for (const [row, original] of dragOverridden) {
638
+ if (!row.isConnected)
639
+ continue;
640
+ if (original === null)
641
+ row.removeAttribute('draggable');
642
+ else
643
+ row.setAttribute('draggable', original);
644
+ }
645
+ dragOverridden.clear();
646
+ }
535
647
  /** Restore a dragged position when the whale becomes visible again. */
536
648
  function applyWhalePos() {
537
649
  if (whaleEl === null)
@@ -555,13 +667,18 @@ export function startMobileAdapt() {
555
667
  // reordering; a long-press would start native drag instead of our action
556
668
  // menu. Force them non-draggable while the adapt is active (React may
557
669
  // re-create rows, so re-apply on the sync tick).
670
+ /** Rows whose official draggable state this layer overrode (restored on revert). */
671
+ const dragOverridden = new Map();
558
672
  function disableRowDrag() {
559
673
  if (!active)
560
674
  return;
561
- const rows = document.querySelectorAll('[class$="_sidebarCol"] [class$="_sessionRow"], [class$="_sidebarCol"] [class$="_projectRow"]');
675
+ const rows = document.querySelectorAll('[class*="_sidebarCol"] [class*="_sessionRow"], [class*="_sidebarCol"] [class*="_projectRow"]');
562
676
  for (const row of rows) {
563
- if (row.getAttribute('draggable') !== 'false')
677
+ if (row.getAttribute('draggable') !== 'false') {
678
+ if (!dragOverridden.has(row))
679
+ dragOverridden.set(row, row.getAttribute('draggable'));
564
680
  row.setAttribute('draggable', 'false');
681
+ }
565
682
  }
566
683
  }
567
684
  function syncWhale() {
@@ -575,7 +692,7 @@ export function startMobileAdapt() {
575
692
  whaleEl.style.display = 'none';
576
693
  return;
577
694
  }
578
- const collapsed = document.querySelector('[class$="_frame"][data-sidebar-collapsed]') !== null;
695
+ const collapsed = appFrame()?.hasAttribute('data-sidebar-collapsed') === true;
579
696
  const overlayUp = document.querySelector('[class$="_overlay"]') !== null;
580
697
  const show = collapsed && !overlayUp;
581
698
  // Restore on every hidden-to-shown transition — including the very
@@ -600,35 +717,45 @@ export function startMobileAdapt() {
600
717
  // v67: on mobile the header actions (agent-preset mode label + background
601
718
  // task badge) move from the title row into the tabs row. React re-creates
602
719
  // the node in its original spot on re-render, so the interval re-seats it;
603
- // stale seated copies are removed first.
720
+ // stale seated copies are removed first. The original spot is only hidden
721
+ // while a seat actually exists: the tabs row renders only for multi-tab
722
+ // sessions, and hiding the actions without a seat target made them vanish.
604
723
  function seatHeaderActions() {
605
724
  if (!active)
606
725
  return;
607
- const tabs = document.querySelector('[class$="_header"] [class$="_tabs"]');
608
- const fresh = document.querySelector('[class$="_titleCluster"] [class$="_headerActions"]');
609
- const seated = tabs !== null ? tabs.querySelector(':scope > [class$="_headerActions"]') : null;
610
- // fresh === null means the node is either not rendered yet or already
611
- // seated — the seated copy IS the moved node, so never remove it.
612
- if (fresh !== null && tabs !== null) {
613
- seated?.remove();
614
- tabs.appendChild(fresh);
615
- }
726
+ const header = document.querySelector('[class$="_header"]');
727
+ const tabs = header !== null ? header.querySelector('[class$="_tabs"]') : null;
728
+ const actions = header !== null ? header.querySelector('[class$="_titleCluster"] [class$="_headerActions"]') : null;
729
+ // The actions node is NOT moved: it is a React-owned host node whose badge
730
+ // button carries live handlers, and re-parenting it makes React's later
731
+ // insertBefore/removeChild anchors point at a node that is no longer a
732
+ // child of the recorded parent (a NotFoundError in the commit phase). The
733
+ // injected CSS takes it out of flow and the transform below paints it over
734
+ // the tabs row instead.
735
+ const seated = header !== null && tabs !== null && actions !== null;
736
+ document.body.classList.toggle(HEADER_SEATED_CLASS, seated);
737
+ ensureHeaderObserver(seated ? header : null);
616
738
  }
617
- // Align the mode/badge text bottom edge with the tab text. The tabs row
618
- // height is not stable (a background task badge grows it), so a static
619
- // transform would be wrong half the time — measure the real text boxes
620
- // each tick and compensate (converges; the previous transform is
621
- // subtracted from the measurement).
739
+ // Paint the mode/badge over the tabs row: the actions keep their DOM slot in
740
+ // the title cluster (out of flow via CSS) and are moved with a transform, so
741
+ // the tabs row reserves room with padding instead of the node being moved.
742
+ // The tabs row height is not stable (a background task badge grows it) and
743
+ // the official right padding differs per cohort, so both axes are measured
744
+ // and converged each time the header geometry is dirty.
622
745
  function alignActionsText() {
623
746
  if (!active)
624
747
  return;
625
- const tabs = document.querySelector('[class$="_header"] [class$="_tabs"]');
626
- if (tabs === null)
748
+ const header = document.querySelector('[class$="_header"]');
749
+ if (header === null)
627
750
  return;
628
- const actions = tabs.querySelector(':scope > [class$="_headerActions"]');
629
- const tabBtn = tabs.querySelector(':scope > [class*="_tab"]');
630
- if (actions === null || tabBtn === null)
751
+ const tabs = header.querySelector('[class$="_tabs"]');
752
+ const actions = header.querySelector('[class$="_titleCluster"] [class$="_headerActions"]');
753
+ if (tabs === null || actions === null || !(tabs instanceof HTMLElement) || !(actions instanceof HTMLElement))
754
+ return;
755
+ if (!headerGeometryDirty)
631
756
  return;
757
+ headerGeometryDirty = false;
758
+ const tabBtn = tabs.querySelector(':scope > [class*="_tab"]');
632
759
  const textBottom = (el) => {
633
760
  if (el === null)
634
761
  return null;
@@ -644,37 +771,51 @@ export function startMobileAdapt() {
644
771
  return null;
645
772
  }
646
773
  };
774
+ const translate = /translate\((-?[\d.]+)px,\s*(-?[\d.]+)px\)/.exec(actions.style.transform ?? '');
775
+ let dx = translate !== null ? parseFloat(translate[1] ?? '0') : 0;
776
+ let dy = translate !== null ? parseFloat(translate[2] ?? '0') : 0;
777
+ const actionsRect = actions.getBoundingClientRect();
778
+ const tabsRect = tabs.getBoundingClientRect();
779
+ // Horizontal: the actions' right edge lands on the tabs row's right edge.
780
+ const rightDiff = tabsRect.right - actionsRect.right;
781
+ if (Math.abs(rightDiff) >= 0.5)
782
+ dx = Math.round((dx + rightDiff) * 10) / 10;
783
+ // Vertical: the actions' text bottom lands on the tab text bottom.
647
784
  const tabBottom = textBottom(tabBtn);
648
- if (tabBottom === null)
649
- return;
650
- const curMatch = /translateY\((-?[\d.]+)px\)/.exec(actions.style.transform ?? '');
651
- const cur = curMatch !== null ? parseFloat(curMatch[1] ?? '0') : 0;
652
- let maxBottom = null;
653
- for (const sel of ['[class$="_label"]', '[class$="_count"]']) {
654
- const b = textBottom(actions.querySelector(sel));
655
- if (b !== null && (maxBottom === null || b - cur > maxBottom))
656
- maxBottom = b - cur;
657
- }
658
- if (maxBottom === null)
659
- return;
660
- const delta = Math.round((tabBottom - maxBottom) * 10) / 10;
661
- if (Math.abs(delta) < 0.5) {
662
- if (actions.style.transform !== '')
663
- actions.style.transform = '';
664
- return;
785
+ if (tabBottom !== null) {
786
+ let maxBottom = null;
787
+ for (const sel of ['[class$="_label"]', '[class$="_count"]']) {
788
+ const b = textBottom(actions.querySelector(sel));
789
+ if (b !== null && (maxBottom === null || b > maxBottom))
790
+ maxBottom = b;
791
+ }
792
+ if (maxBottom !== null) {
793
+ const bottomDiff = tabBottom - maxBottom;
794
+ if (Math.abs(bottomDiff) >= 0.5)
795
+ dy = Math.round((dy + bottomDiff) * 10) / 10;
796
+ }
665
797
  }
666
- ;
667
- actions.style.transform = `translateY(${delta}px)`;
798
+ const next = `translate(${dx}px, ${dy}px)`;
799
+ if (actions.style.transform !== next)
800
+ actions.style.transform = next;
801
+ // Reserve the painted width so the tab labels never slide under it.
802
+ const reserve = `${Math.ceil(actionsRect.width) + 8}px`;
803
+ if (tabs.style.paddingRight !== reserve)
804
+ tabs.style.paddingRight = reserve;
668
805
  }
669
806
  function unseatHeaderActions() {
670
- const tabs = document.querySelector('[class$="_header"] [class$="_tabs"]');
671
- const seated = tabs !== null ? tabs.querySelector(':scope > [class$="_headerActions"]') : null;
672
- const wrap = document.querySelector('[class$="_titleCluster"] > div');
673
- if (seated !== null) {
674
- if (wrap !== null)
675
- wrap.appendChild(seated);
676
- else
677
- seated.remove();
807
+ document.body.classList.remove(HEADER_SEATED_CLASS);
808
+ const header = document.querySelector('[class$="_header"]');
809
+ const tabs = header !== null ? header.querySelector('[class$="_tabs"]') : null;
810
+ const actions = header !== null ? header.querySelector('[class$="_titleCluster"] [class$="_headerActions"]') : null;
811
+ if (actions instanceof HTMLElement)
812
+ actions.style.transform = '';
813
+ if (tabs instanceof HTMLElement)
814
+ tabs.style.paddingRight = '';
815
+ if (headerObserver !== null) {
816
+ headerObserver.disconnect();
817
+ headerObserver = null;
818
+ observedHeader = null;
678
819
  }
679
820
  }
680
821
  function ensureWhaleObserver() {
@@ -683,7 +824,31 @@ export function startMobileAdapt() {
683
824
  whaleObserver = new MutationObserver(() => { syncWhale(); });
684
825
  whaleObserver.observe(document.body, { attributes: true, attributeFilter: ['data-sidebar-collapsed'], subtree: true });
685
826
  }
827
+ /**
828
+ * Observe the conversation header subtree and mark its geometry dirty on any
829
+ * re-render (label text, badge count, node replacement). The per-tick
830
+ * alignment then reads layout only when the header actually changed instead
831
+ * of on every tick while a message streams. The observer is swapped when the
832
+ * header node is replaced and disconnected with the layer.
833
+ */
834
+ function ensureHeaderObserver(header) {
835
+ if (typeof MutationObserver === 'undefined')
836
+ return;
837
+ if (observedHeader === header && headerObserver !== null)
838
+ return;
839
+ headerObserver?.disconnect();
840
+ headerObserver = null;
841
+ observedHeader = null;
842
+ if (header === null)
843
+ return;
844
+ headerObserver = new MutationObserver(() => { headerGeometryDirty = true; });
845
+ headerObserver.observe(header, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class'] });
846
+ observedHeader = header;
847
+ headerGeometryDirty = true;
848
+ }
686
849
  function evaluate() {
850
+ // A viewport change invalidates every measured position.
851
+ headerGeometryDirty = true;
687
852
  if (!adaptEnabled) {
688
853
  revert();
689
854
  return;
@@ -704,7 +869,10 @@ export function startMobileAdapt() {
704
869
  document.addEventListener('visibilitychange', () => { setWhaleTimer(document.visibilityState !== 'hidden'); });
705
870
  // v51: Enter only inserts a newline on mobile (send goes through the send
706
871
  // button). Captured at document level so the official Enter-to-send
707
- // handler never sees the event.
872
+ // handler never sees the event. The rewrite is scoped to the composer:
873
+ // `_input` is also the class of the settings, plugin and agent-preset text
874
+ // fields, where swallowing Enter and inserting a newline into a single-line
875
+ // input left the key dead.
708
876
  function onKeydownCapture(e) {
709
877
  if (!active)
710
878
  return;
@@ -713,7 +881,7 @@ export function startMobileAdapt() {
713
881
  const t = e.target;
714
882
  if (!(t instanceof HTMLElement))
715
883
  return;
716
- if (t.closest('[class$="_input"]') === null)
884
+ if (composerFieldOf(t) === null)
717
885
  return;
718
886
  // keyCode 229 is the classic IME-confirm signal (Safari fires
719
887
  // compositionend before the Enter keydown) — never treat it as a
@@ -737,13 +905,13 @@ export function startMobileAdapt() {
737
905
  function onClickCapture(e) {
738
906
  if (!active)
739
907
  return;
740
- const frame = document.querySelector('[class$="_frame"]');
908
+ const frame = appFrame();
741
909
  if (frame === null || frame.hasAttribute('data-sidebar-collapsed'))
742
910
  return;
743
911
  const t = e.target;
744
912
  if (!(t instanceof Element))
745
913
  return;
746
- if (t.closest('[class$="_sidebarCol"]') !== null) {
914
+ if (t.closest('[class*="_sidebarCol"]') !== null) {
747
915
  // v68: opening settings also folds the sidebar so the modal closes
748
916
  // back into a clean conversation view. Clicks on the session
749
917
  // row-actions ellipsis (or its opened menu anchor) must NOT collapse
@@ -752,15 +920,15 @@ export function startMobileAdapt() {
752
920
  // button also folds.
753
921
  const topNewSessionClicked = t.closest('[class$="_newSession"], [class$="_brand"]') !== null;
754
922
  let projectNewSessionClicked = false;
755
- const projectActions = t.closest('[class$="_projectRow"] [class$="_rowActions"]');
923
+ const projectActions = t.closest('[class*="_projectRow"] [class*="_rowActions"]');
756
924
  if (projectActions !== null) {
757
925
  const btn = t.closest('button');
758
926
  const btns = projectActions.querySelectorAll('button');
759
927
  projectNewSessionClicked = btn !== null && btns.length > 0 && btn === btns[btns.length - 1];
760
928
  }
761
929
  let shouldCollapse = false;
762
- if (t.closest('[class$="_sessionRow"]') !== null) {
763
- shouldCollapse = t.closest('[class$="_rowActions"]') === null;
930
+ if (t.closest('[class*="_sessionRow"]') !== null) {
931
+ shouldCollapse = t.closest('[class*="_rowActions"]') === null;
764
932
  }
765
933
  else if (t.closest('[class$="_settingsArea"]') !== null || topNewSessionClicked || projectNewSessionClicked) {
766
934
  shouldCollapse = true;
@@ -791,8 +959,15 @@ export function startMobileAdapt() {
791
959
  document.addEventListener('touchstart', (e) => {
792
960
  if (!active)
793
961
  return;
962
+ // Multi-finger touches are never a swipe: a pinch that happens to end with
963
+ // >60px of horizontal finger travel would otherwise toggle the sidebar.
964
+ if (e.touches.length > 1) {
965
+ swipeTouch = null;
966
+ return;
967
+ }
794
968
  const t = e.target;
795
- if (t instanceof Element && (t.tagName === 'TEXTAREA' || t.tagName === 'INPUT' || t.closest(`#${WHALE_ID}`) !== null || insideHScrollable(t) || t.closest('table, [class$="_table"], [class$="_tablePane"]') !== null)) {
969
+ const editable = t instanceof HTMLElement && t.isContentEditable;
970
+ if (t instanceof Element && (t.tagName === 'TEXTAREA' || t.tagName === 'INPUT' || editable || t.closest(`#${WHALE_ID}`) !== null || insideHScrollable(t) || t.closest('table, [class$="_table"], [class$="_tablePane"]') !== null)) {
796
971
  swipeTouch = null;
797
972
  return;
798
973
  }
@@ -804,6 +979,9 @@ export function startMobileAdapt() {
804
979
  document.addEventListener('touchend', (e) => {
805
980
  if (swipeTouch === null)
806
981
  return;
982
+ // Still more than one finger down: a pinch is in progress, not a swipe.
983
+ if (e.touches.length > 0)
984
+ return;
807
985
  const ct = e.changedTouches[0];
808
986
  if (ct === undefined || ct.identifier !== swipeTouch.id)
809
987
  return;
@@ -814,7 +992,7 @@ export function startMobileAdapt() {
814
992
  return;
815
993
  if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5)
816
994
  return;
817
- const frame = document.querySelector('[class$="_frame"]');
995
+ const frame = appFrame();
818
996
  if (frame === null)
819
997
  return;
820
998
  if (document.querySelector('[class$="_overlay"], [class$="_dialog"], [class$="_menu"], [class*="_portal"]') !== null)
@@ -845,7 +1023,7 @@ export function startMobileAdapt() {
845
1023
  }
846
1024
  }
847
1025
  function openSessionMenu(row) {
848
- const actions = row.querySelector('[class$="_rowActions"]');
1026
+ const actions = row.querySelector('[class*="_rowActions"]');
849
1027
  const btn = actions?.querySelector('button');
850
1028
  if (actions === null || btn === null)
851
1029
  return;
@@ -868,8 +1046,8 @@ export function startMobileAdapt() {
868
1046
  clearLongPress();
869
1047
  return;
870
1048
  }
871
- const row = t.closest('[class$="_sessionRow"]');
872
- if (row === null || t.closest('[class$="_rowActions"]') !== null) {
1049
+ const row = t.closest('[class*="_sessionRow"]');
1050
+ if (row === null || t.closest('[class*="_rowActions"]') !== null) {
873
1051
  clearLongPress();
874
1052
  return;
875
1053
  }
@@ -939,7 +1117,7 @@ export function startMobileAdapt() {
939
1117
  const t = e.target;
940
1118
  if (!(t instanceof Element))
941
1119
  return;
942
- if (t.closest('[class$="_sessionRow"]') !== null && ((longPress !== null && longPress.triggered) || Date.now() <= suppressSessionClickUntil)) {
1120
+ if (t.closest('[class*="_sessionRow"]') !== null && ((longPress !== null && longPress.triggered) || Date.now() <= suppressSessionClickUntil)) {
943
1121
  e.preventDefault();
944
1122
  }
945
1123
  }, true);
@@ -949,26 +1127,44 @@ export function startMobileAdapt() {
949
1127
  if (!active || Date.now() > longPressMenuGuardUntil)
950
1128
  return;
951
1129
  const t = e.target;
952
- if (t instanceof Element && (t.closest('[class$="_rowActions"]') !== null || t.closest('[class$="_sessionRow"]') !== null || t.closest('[class$="_projectRow"]') !== null)) {
1130
+ if (t instanceof Element && (t.closest('[class*="_rowActions"]') !== null || t.closest('[class*="_sessionRow"]') !== null || t.closest('[class*="_projectRow"]') !== null)) {
953
1131
  e.stopPropagation();
954
1132
  }
955
1133
  }, true);
956
- // Intercept programmatic .focus() on the composer input: the official
957
- // conversation component focuses it on mount and on session changes;
958
- // user taps focus it through the browser's own pipeline. Only allow
959
- // composer focus that follows a real tap on the input itself.
1134
+ // Intercept programmatic .focus() on the composer field: the official
1135
+ // conversation component focuses it on mount and on session changes; user
1136
+ // taps focus it through the browser's own pipeline. Only allow composer
1137
+ // focus that follows a real tap on the field itself. The composer field is
1138
+ // the official contenteditable input root (`data-composer-input`) on this
1139
+ // cohort — matching only textarea/input missed it entirely, so every session
1140
+ // open popped the phone keyboard. The queue-row editor input is deliberately
1141
+ // NOT a composer field: its autoFocus must keep working.
1142
+ const isComposerField = (el) => {
1143
+ if (!(el instanceof HTMLElement))
1144
+ return false;
1145
+ if (el.closest(COMPOSER_SELECTOR) === null)
1146
+ return false;
1147
+ if (el.matches('[data-composer-input]'))
1148
+ return true;
1149
+ if (el.tagName === 'TEXTAREA')
1150
+ return true;
1151
+ return el.tagName === 'INPUT' && el.matches('[class*="_input"]');
1152
+ };
1153
+ /** The composer field that owns an event target, if any. */
1154
+ const composerFieldOf = (target) => {
1155
+ const el = target instanceof Element ? target.closest('[data-composer-input], [class*="_input"], textarea') : null;
1156
+ return isComposerField(el) ? el : null;
1157
+ };
960
1158
  const lanOrigFocus = HTMLElement.prototype.focus;
961
1159
  HTMLElement.prototype.focus = function (options) {
962
- if (active && (this.tagName === 'TEXTAREA' || this.tagName === 'INPUT') && this.closest('[class$="_composerSeat"]') !== null && Date.now() - lastComposerTap >= 800) {
1160
+ if (active && isComposerField(this) && Date.now() - lastComposerTap >= 800)
963
1161
  return;
964
- }
965
1162
  lanOrigFocus.call(this, options);
966
1163
  };
967
1164
  document.addEventListener('pointerdown', (e) => {
968
1165
  if (!active)
969
1166
  return;
970
- const t = e.target;
971
- if (t instanceof Element && (t.tagName === 'TEXTAREA' || t.tagName === 'INPUT') && t.closest('[class$="_composerSeat"]') !== null)
1167
+ if (composerFieldOf(e.target) !== null)
972
1168
  lastComposerTap = Date.now();
973
1169
  }, true);
974
1170
  // The plugin apply() wires toggleSidebar/closeDetails to ctx.layout once