@linxin666/dsh-remote-web-ui 0.3.17 → 0.3.19

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.
@@ -62,6 +62,38 @@ const MODEL_BTN_ID = 'dshRemoteModelPick'
62
62
  const EFFORT_BTN_ID = 'dshRemoteEffortPick'
63
63
  /** Body class while the compact picker buttons are wired. */
64
64
  const COMPACT_CLASS = 'dsh-remote-compact-picker'
65
+ /** Body class while the header actions are seated in the tabs row. */
66
+ const HEADER_SEATED_CLASS = 'dsh-remote-header-seated'
67
+ /** The two composer picker entries the compact buttons drill into. */
68
+ type PickerKind = 'model' | 'effort'
69
+ /** Locale-dependent fast path for the official picker cells (zh/en). */
70
+ const PICKER_CELL_PATTERN: Record<PickerKind, RegExp> = {
71
+ model: /模型|Model/, // i18n-allow: matches official picker cell text, not plugin copy
72
+ effort: /推理等级|Reasoning|Effort/i, // i18n-allow: matches official picker cell text, not plugin copy
73
+ }
74
+ /** Position of each drill cell among the sheet's chevron cells. */
75
+ const DRILL_INDEX: Record<PickerKind, number> = { model: 0, effort: 1 }
76
+ /**
77
+ * The official application frame. The layout column classes are the anchor:
78
+ * `_frame` is shared by unrelated official components (the chat turn rail,
79
+ * message-image thumbnails, the subagent pill, PlanReviewPanel), so a bare
80
+ * suffix match would restyle and hit-block those. `data-dsh-frame` is the
81
+ * aggregate compat stamp, kept as the fast path.
82
+ */
83
+ const APP_FRAME_SELECTOR = '[data-dsh-frame], [class*="_frame"]:has([class*="_centerCol"])'
84
+
85
+ /**
86
+ * The official composer surface. `_composerSeat` is the plugin's historical
87
+ * anchor; the official `data-dsh-surface="composer"` part attribute is the
88
+ * stable alternative if the class ever gains a modifier.
89
+ */
90
+ const COMPOSER_SELECTOR = '[class$="_composerSeat"], [data-dsh-surface="composer"]'
91
+
92
+ /** Resolve the official application frame (never a nested `_frame` surface). */
93
+ function appFrame(): HTMLElement | null {
94
+ const frame = document.querySelector(APP_FRAME_SELECTOR)
95
+ return frame instanceof HTMLElement ? frame : null
96
+ }
65
97
 
66
98
  /**
67
99
  * Whether the current viewport is a portrait touch device small enough to
@@ -84,7 +116,10 @@ function isMobilePortrait(): boolean {
84
116
  const ADAPT_CSS: readonly string[] = [
85
117
  'html,body{height:100%}',
86
118
  // The app frame fills the dynamic viewport (browser chrome collapse).
87
- '[class$="_frame"]{width:100%;height:100dvh}',
119
+ // Scoped to the application frame: a bare [class$="_frame"] also matches the
120
+ // chat turn rail, message thumbnails, the subagent pill and PlanReviewPanel,
121
+ // stretching each to a full-width 100dvh pointer-events:auto block.
122
+ '[class*="_frame"]:has([class*="_centerCol"]){width:100%;height:100dvh}',
88
123
  // Collapsed rail: bigger touch targets.
89
124
  '[class$="_railFish"] button,[class$="_panelIcon"],[class$="_newSession"]{min-width:44px;min-height:44px}',
90
125
  // Message list padding on narrow screens.
@@ -96,8 +131,8 @@ const ADAPT_CSS: readonly string[] = [
96
131
  '[class$="_scrollBody"] [class$="_root"]{font-size:14.5px}',
97
132
  '[class$="_scrollBody"] [class$="_bubble"]{font-size:14.5px}',
98
133
  '[class$="_titleRow"] *{font-size:13px}',
99
- '[class$="_sidebarCol"] [class$="_root"],[class$="_sidebarCol"] [class$="_newSession"],[class$="_sidebarCol"] [class$="_trigger"],[class$="_sidebarCol"] [class$="_title"]{font-size:13px}',
100
- '[class$="_sidebarCol"] [class$="_meta"],[class$="_sidebarCol"] [class$="_time"]{font-size:11.5px}',
134
+ '[class*="_sidebarCol"] [class$="_root"],[class*="_sidebarCol"] [class$="_newSession"],[class*="_sidebarCol"] [class$="_trigger"],[class*="_sidebarCol"] [class$="_title"]{font-size:13px}',
135
+ '[class*="_sidebarCol"] [class$="_meta"],[class*="_sidebarCol"] [class$="_time"]{font-size:11.5px}',
101
136
  // v50: collapsed rail hidden; the whale button is the entry. The frame's
102
137
  // grid is inline-styled (56px rail track when collapsed); pin the first
103
138
  // track to 0 so content uses the full width. On portrait phones the
@@ -111,7 +146,7 @@ const ADAPT_CSS: readonly string[] = [
111
146
  // host overlay mounts). Pin every column to an explicit track so the
112
147
  // center always lands on the 1fr track regardless of the sidebar's
113
148
  // computed position.
114
- '[class$="_frame"][data-sidebar-collapsed] [class$="_sidebarCol"]{grid-column:1/2}',
149
+ '[class$="_frame"][data-sidebar-collapsed] [class*="_sidebarCol"]{grid-column:1/2}',
115
150
  '[class$="_frame"][data-sidebar-collapsed] [class$="_centerCol"]{grid-column:2/3}',
116
151
  '[class$="_frame"][data-sidebar-collapsed] [class$="_detailsCol"]{grid-column:3/4}',
117
152
  // Keep the chat header title clear of the floating whale.
@@ -125,13 +160,13 @@ const ADAPT_CSS: readonly string[] = [
125
160
  // carries two classes, so match by containment); v47's panelIcon touch
126
161
  // rule forces a 44px min on the icon itself — zero it out so the 13px
127
162
  // glyph fits the 18px button.
128
- '[class$="_sidebarCol"] [class$="_logoRow"] [class*="_iconButton"]{width:18px;height:18px}',
129
- '[class$="_sidebarCol"] [class$="_logoRow"] [class*="_iconButton"] svg{width:13px;height:13px;min-width:0;min-height:0}',
163
+ '[class*="_sidebarCol"] [class$="_logoRow"] [class*="_iconButton"]{width:18px;height:18px}',
164
+ '[class*="_sidebarCol"] [class$="_logoRow"] [class*="_iconButton"] svg{width:13px;height:13px;min-width:0;min-height:0}',
130
165
  // v77: workspace rows' right-side actions (menu + new session) are
131
166
  // hover-only on desktop; always show them on mobile touch.
132
- '[class$="_sidebarCol"] [class$="_projectRow"] [class$="_rowActions"]{display:inline-flex}',
167
+ '[class*="_sidebarCol"] [class*="_projectRow"] [class*="_rowActions"]{display:inline-flex}',
133
168
  // v78: mobile long-press on a row must not start native drag.
134
- '[class$="_sidebarCol"] [class$="_sessionRow"],[class$="_sidebarCol"] [class$="_projectRow"]{-webkit-user-drag:none;user-select:none}',
169
+ '[class*="_sidebarCol"] [class*="_sessionRow"],[class*="_sidebarCol"] [class*="_projectRow"]{-webkit-user-drag:none;user-select:none}',
135
170
  // Hide the header Session-log download button (no space on phones).
136
171
  '[class$="_headerUtilities"]{display:none}',
137
172
  // v52 composer: the two lines (permission / model) stay stacked with zero
@@ -211,17 +246,14 @@ const ADAPT_CSS: readonly string[] = [
211
246
  // v67: header actions (agent-preset mode label + background-task badge)
212
247
  // are re-seated from the title row into the tabs row; hidden in the
213
248
  // original spot so React re-renders do not flicker them back.
214
- '[class$="_header"] [class$="_titleCluster"] [class$="_headerActions"]{display:none}',
215
- // The official header keeps a 78px right padding on phones; stretch the
216
- // tabs row so the seated actions sit flush against the right edge
217
- // (verified 360/390/480 on the reference).
218
- '[class$="_header"] [class$="_tabs"]{margin-right:-58px}',
249
+ // Only while the layer has taken the actions over: out of flow (so the
250
+ // title row keeps its own layout and React keeps owning the node) and
251
+ // painted over the tabs row by the transform alignActionsText computes.
252
+ `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}`,
219
253
  // v70: match the tab text size with the seated mode label (12px); NB
220
254
  // [class$="_tab"] misses the active tab (its class ends in "_tabActive")
221
255
  // — containment so BOTH tabs match.
222
256
  '[class$="_header"] [class$="_tabs"] [class*="_tab"]{font-size:12px;white-space:nowrap}',
223
- // Text-bottom alignment is dynamic (alignActionsText below); flex seat.
224
- '[class$="_header"] [class$="_tabs"] [class$="_headerActions"]{margin-left:auto;display:flex;align-items:center;gap:6px;flex:none}',
225
257
  // Mobile scope: hide the plugin surfaces that do not fit a phone — the
226
258
  // right-hand details column and every desktop-oriented tool surface. The
227
259
  // list keys on the L2 semantic roots (data-dsh-plugin, ownership stays
@@ -292,6 +324,11 @@ export function startMobileAdapt(): void {
292
324
  let savedViewportContent: string | null = null
293
325
  let whaleEl: HTMLButtonElement | null = null
294
326
  let whaleObserver: MutationObserver | null = null
327
+ /** Header subtree observer: marks the geometry measurement dirty on re-render. */
328
+ let headerObserver: MutationObserver | null = null
329
+ let observedHeader: Element | null = null
330
+ /** Whether the seated-actions geometry needs re-measuring (see alignActionsText). */
331
+ let headerGeometryDirty = true
295
332
  let whaleTimer: number | null = null
296
333
  let whaleSuppressClick = false
297
334
  let whaleShown = false
@@ -352,6 +389,7 @@ export function startMobileAdapt(): void {
352
389
  if (!active) return
353
390
  active = false
354
391
  unseatHeaderActions()
392
+ restoreRowDrag()
355
393
  removeCompactPicker()
356
394
  document.body.classList.remove(ACTIVE_CLASS)
357
395
  document.body.classList.remove(RAIL_HIDDEN_CLASS)
@@ -364,6 +402,10 @@ export function startMobileAdapt(): void {
364
402
  }
365
403
  if (whaleEl !== null) whaleEl.style.display = 'none'
366
404
  setWhaleTimer(false)
405
+ if (whaleObserver !== null) {
406
+ whaleObserver.disconnect()
407
+ whaleObserver = null
408
+ }
367
409
  }
368
410
 
369
411
  /** Start/stop the 600ms sync tick; a no-op when already in the asked state. */
@@ -391,7 +433,7 @@ export function startMobileAdapt(): void {
391
433
  document.getElementById(EFFORT_BTN_ID)?.remove()
392
434
  }
393
435
 
394
- function drillIntoPicker(cellPattern: RegExp): void {
436
+ function drillIntoPicker(kind: PickerKind): void {
395
437
  const trigger = document.querySelector('[class$="_composerSeat"] [class$="_trailing"] [class$="_trigger"]:has([class$="_triggerEffort"])') as HTMLElement | null
396
438
  if (trigger === null) return
397
439
  trigger.click()
@@ -400,8 +442,13 @@ export function startMobileAdapt(): void {
400
442
  let tries = 0
401
443
  const tapCell = (): void => {
402
444
  tries += 1
403
- const cell = Array.from(document.querySelectorAll('[class$="_composerSeat"] [class$="_menu"] [class$="_cell"]'))
404
- .find((c) => cellPattern.test(c.textContent ?? ''))
445
+ const cells = Array.from(document.querySelectorAll('[class$="_composerSeat"] [class$="_menu"] [class$="_cell"]'))
446
+ // The official cell copy is localized (zh/en/ru), so the label match is
447
+ // only the fast path; the sheet's drill cells (label + value + chevron)
448
+ // are the structural anchor: model first, effort second, on every locale.
449
+ const byLabel = cells.find((c) => PICKER_CELL_PATTERN[kind].test(c.textContent ?? ''))
450
+ const drillable = cells.filter((c) => c.querySelector('[class*="_cellChevron"], [class*="_chevron"]'))
451
+ const cell = byLabel ?? drillable[DRILL_INDEX[kind]]
405
452
  if (cell !== undefined) {
406
453
  ;(cell as HTMLElement).click()
407
454
  return
@@ -411,7 +458,7 @@ export function startMobileAdapt(): void {
411
458
  window.setTimeout(tapCell, 150)
412
459
  }
413
460
 
414
- function makeCompactButton(id: string, title: string, icon: string, cellPattern: RegExp): HTMLButtonElement {
461
+ function makeCompactButton(id: string, title: string, icon: string, kind: PickerKind): HTMLButtonElement {
415
462
  const btn = document.createElement('button')
416
463
  btn.id = id
417
464
  btn.type = 'button'
@@ -419,7 +466,7 @@ export function startMobileAdapt(): void {
419
466
  btn.title = title
420
467
  btn.setAttribute('aria-label', title)
421
468
  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>`
422
- btn.addEventListener('click', () => { drillIntoPicker(cellPattern) })
469
+ btn.addEventListener('click', () => { drillIntoPicker(kind) })
423
470
  return btn
424
471
  }
425
472
 
@@ -434,10 +481,10 @@ export function startMobileAdapt(): void {
434
481
  if (document.getElementById(MODEL_BTN_ID) === null) {
435
482
  // The cell pattern matches the OFFICIAL picker cell text (the official
436
483
  // client's own zh/en copy), not plugin-owned text.
437
- 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
484
+ tools.appendChild(makeCompactButton(MODEL_BTN_ID, surfaceText('mobile.composer.pickModel', 'Pick model'), CUBE_ICON, 'model'))
438
485
  }
439
486
  if (document.getElementById(EFFORT_BTN_ID) === null) {
440
- 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
487
+ tools.appendChild(makeCompactButton(EFFORT_BTN_ID, surfaceText('mobile.composer.pickEffort', 'Pick reasoning effort'), LEVELS_ICON, 'effort'))
441
488
  }
442
489
  // Titles re-read every tick so a locale switch (or the translate seat
443
490
  // arriving after this layer installed) is picked up without a reload.
@@ -454,26 +501,65 @@ export function startMobileAdapt(): void {
454
501
  document.body.classList.add(COMPACT_CLASS)
455
502
  }
456
503
  /**
457
- * Toggle the sidebar through the wired layout face, then verify the flip:
458
- * the official LayoutController can be mounted yet inert (its bound store
459
- * actions race the root entry on this cohort — observed on the running
460
- * local build, where the face call silently did nothing and the whale
461
- * was dead). When the frame state did not change shortly after the call,
462
- * drive the official rail/logo toggle instead: that button owns its own
463
- * store actions and flips the same state on every cohort we support.
504
+ * The official sidebar toggle in the logo row. The row's own `_toggle` class
505
+ * is the precise anchor; the row's last button is the fallback for cohorts
506
+ * that only expose the generic icon-button class (the first button is the
507
+ * brand, which navigates home — never click that one). `_railFish` is kept
508
+ * for older compositions that carried a rail fish button.
509
+ */
510
+ function officialSidebarToggle(): HTMLElement | null {
511
+ const rail = document.querySelector('[class$="_railFish"] button')
512
+ if (rail instanceof HTMLElement) return rail
513
+ const row = document.querySelector('[class$="_logoRow"]')
514
+ if (row === null) return null
515
+ const toggles = row.querySelectorAll('button[class*="_toggle"]')
516
+ const last = toggles.length > 0 ? toggles[toggles.length - 1] : row.querySelectorAll('button')[row.querySelectorAll('button').length - 1]
517
+ return last instanceof HTMLElement ? last : null
518
+ }
519
+ /**
520
+ * Toggle the sidebar through the official rail/logo toggle first, then
521
+ * verify the flip and fall back to the wired layout face.
522
+ *
523
+ * The order is evidence-driven: the official toggle owns its own store
524
+ * actions and flips the state on every cohort we support, while the wired
525
+ * LayoutController is mounted yet inert on the installed cohort — calling it
526
+ * left `data-sidebar-collapsed` untouched for the whole 800ms observation
527
+ * window, so face-first made every whale tap wait out the verification
528
+ * delay before anything moved. The face stays the fallback for compositions
529
+ * that render no logo-row toggle, and it is only called after the toggle
530
+ * demonstrably failed, so the two can never cancel each other out.
464
531
  */
465
532
  function toggleSidebarVerified(): void {
466
- const frame = document.querySelector('[class$="_frame"]')
467
- const collapsedBefore = frame instanceof HTMLElement ? frame.hasAttribute('data-sidebar-collapsed') : null
468
- w.__dshRemoteAdapt?.toggleSidebar?.()
469
- if (collapsedBefore === null) return
470
- window.setTimeout(() => {
533
+ const frame = appFrame()
534
+ const collapsedBefore = frame !== null ? frame.hasAttribute('data-sidebar-collapsed') : null
535
+ const face = w.__dshRemoteAdapt?.toggleSidebar
536
+ const callFace = (): void => {
537
+ if (typeof face !== 'function') return
538
+ try { face() } catch {}
539
+ }
540
+ const toggle = officialSidebarToggle()
541
+ if (collapsedBefore === null || toggle === null) {
542
+ // No frame to verify against, or no official toggle: the face is the
543
+ // only available control.
544
+ if (toggle !== null) toggle.click()
545
+ else callFace()
546
+ return
547
+ }
548
+ toggle.click()
549
+ let tries = 0
550
+ const verify = (): void => {
471
551
  if (!active) return
472
- const frameNow = document.querySelector('[class$="_frame"]')
473
- if (!(frameNow instanceof HTMLElement)) return
552
+ const frameNow = appFrame()
553
+ if (frameNow === null) return
474
554
  if (frameNow.hasAttribute('data-sidebar-collapsed') !== collapsedBefore) return
475
- ;(document.querySelector('[class$="_railFish"] button, [class$="_logoRow"] [class*="_iconButton"]') as HTMLElement | null)?.click()
476
- }, 150)
555
+ tries += 1
556
+ if (tries < 2) {
557
+ window.setTimeout(verify, 120)
558
+ return
559
+ }
560
+ callFace()
561
+ }
562
+ window.setTimeout(verify, 120)
477
563
  }
478
564
  function ensureWhale(): void {
479
565
  if (whaleEl !== null || !document.body) return
@@ -503,12 +589,12 @@ export function startMobileAdapt(): void {
503
589
  // the keyboard (iOS) or keeps it open. Blur at pointerdown (before the
504
590
  // tap completes; click is too late for iOS) and block pending
505
591
  // programmatic refocus.
506
- if (active) {
507
- const ta = document.querySelector('[class$="_composerSeat"] textarea, [class$="_composerSeat"] input')
508
- if (ta !== null && document.activeElement === ta) {
509
- (ta as HTMLElement).blur()
510
- lastComposerTap = 0
511
- }
592
+ if (active && isComposerField(document.activeElement)) {
593
+ // The composer field is a contenteditable div (the official client
594
+ // renders no textarea), so the old textarea/input lookup never matched
595
+ // it while a bare `input` branch could blur an unrelated field.
596
+ document.activeElement.blur()
597
+ lastComposerTap = 0
512
598
  }
513
599
  drag = { x: e.clientX, y: e.clientY, left: whale.offsetLeft, top: whale.offsetTop, moved: false }
514
600
  try { whale.setPointerCapture(e.pointerId) } catch {}
@@ -535,11 +621,31 @@ export function startMobileAdapt(): void {
535
621
  } catch {}
536
622
  }
537
623
  whale.addEventListener('pointerup', endWhaleDrag)
538
- whale.addEventListener('pointercancel', endWhaleDrag)
624
+ // A cancelled pointer (system gesture, incoming call) is not a drag end:
625
+ // keep the click un-suppressed so the next real tap still toggles.
626
+ whale.addEventListener('pointercancel', () => {
627
+ drag = null
628
+ whaleSuppressClick = false
629
+ })
539
630
  whaleEl = whale
540
631
  document.body.appendChild(whale)
541
632
  }
542
633
 
634
+ /**
635
+ * Restore the official draggable state this layer overrode while active.
636
+ * Rows are React-owned and may have been re-created meanwhile, so only the
637
+ * tracked elements are touched; a detached element is skipped.
638
+ */
639
+ function restoreRowDrag(): void {
640
+ if (dragOverridden.size === 0) return
641
+ for (const [row, original] of dragOverridden) {
642
+ if (!row.isConnected) continue
643
+ if (original === null) row.removeAttribute('draggable')
644
+ else row.setAttribute('draggable', original)
645
+ }
646
+ dragOverridden.clear()
647
+ }
648
+
543
649
  /** Restore a dragged position when the whale becomes visible again. */
544
650
  function applyWhalePos(): void {
545
651
  if (whaleEl === null) return
@@ -562,11 +668,16 @@ export function startMobileAdapt(): void {
562
668
  // reordering; a long-press would start native drag instead of our action
563
669
  // menu. Force them non-draggable while the adapt is active (React may
564
670
  // re-create rows, so re-apply on the sync tick).
671
+ /** Rows whose official draggable state this layer overrode (restored on revert). */
672
+ const dragOverridden = new Map<Element, string | null>()
565
673
  function disableRowDrag(): void {
566
674
  if (!active) return
567
- 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"]')
568
676
  for (const row of rows) {
569
- if (row.getAttribute('draggable') !== 'false') row.setAttribute('draggable', 'false')
677
+ if (row.getAttribute('draggable') !== 'false') {
678
+ if (!dragOverridden.has(row)) dragOverridden.set(row, row.getAttribute('draggable'))
679
+ row.setAttribute('draggable', 'false')
680
+ }
570
681
  }
571
682
  }
572
683
 
@@ -579,7 +690,7 @@ export function startMobileAdapt(): void {
579
690
  whaleEl.style.display = 'none'
580
691
  return
581
692
  }
582
- const collapsed = document.querySelector('[class$="_frame"][data-sidebar-collapsed]') !== null
693
+ const collapsed = appFrame()?.hasAttribute('data-sidebar-collapsed') === true
583
694
  const overlayUp = document.querySelector('[class$="_overlay"]') !== null
584
695
  const show = collapsed && !overlayUp
585
696
  // Restore on every hidden-to-shown transition — including the very
@@ -602,32 +713,41 @@ export function startMobileAdapt(): void {
602
713
  // v67: on mobile the header actions (agent-preset mode label + background
603
714
  // task badge) move from the title row into the tabs row. React re-creates
604
715
  // the node in its original spot on re-render, so the interval re-seats it;
605
- // stale seated copies are removed first.
716
+ // stale seated copies are removed first. The original spot is only hidden
717
+ // while a seat actually exists: the tabs row renders only for multi-tab
718
+ // sessions, and hiding the actions without a seat target made them vanish.
606
719
  function seatHeaderActions(): void {
607
720
  if (!active) return
608
- const tabs = document.querySelector('[class$="_header"] [class$="_tabs"]')
609
- const fresh = document.querySelector('[class$="_titleCluster"] [class$="_headerActions"]')
610
- const seated = tabs !== null ? tabs.querySelector(':scope > [class$="_headerActions"]') : null
611
- // fresh === null means the node is either not rendered yet or already
612
- // seated — the seated copy IS the moved node, so never remove it.
613
- if (fresh !== null && tabs !== null) {
614
- seated?.remove()
615
- tabs.appendChild(fresh)
616
- }
617
- }
618
-
619
- // Align the mode/badge text bottom edge with the tab text. The tabs row
620
- // height is not stable (a background task badge grows it), so a static
621
- // transform would be wrong half the time — measure the real text boxes
622
- // each tick and compensate (converges; the previous transform is
623
- // subtracted from the measurement).
721
+ const header = document.querySelector('[class$="_header"]')
722
+ const tabs = header !== null ? header.querySelector('[class$="_tabs"]') : null
723
+ const actions = header !== null ? header.querySelector('[class$="_titleCluster"] [class$="_headerActions"]') : null
724
+ // The actions node is NOT moved: it is a React-owned host node whose badge
725
+ // button carries live handlers, and re-parenting it makes React's later
726
+ // insertBefore/removeChild anchors point at a node that is no longer a
727
+ // child of the recorded parent (a NotFoundError in the commit phase). The
728
+ // injected CSS takes it out of flow and the transform below paints it over
729
+ // the tabs row instead.
730
+ const seated = header !== null && tabs !== null && actions !== null
731
+ document.body.classList.toggle(HEADER_SEATED_CLASS, seated)
732
+ ensureHeaderObserver(seated ? header : null)
733
+ }
734
+
735
+ // Paint the mode/badge over the tabs row: the actions keep their DOM slot in
736
+ // the title cluster (out of flow via CSS) and are moved with a transform, so
737
+ // the tabs row reserves room with padding instead of the node being moved.
738
+ // The tabs row height is not stable (a background task badge grows it) and
739
+ // the official right padding differs per cohort, so both axes are measured
740
+ // and converged each time the header geometry is dirty.
624
741
  function alignActionsText(): void {
625
742
  if (!active) return
626
- const tabs = document.querySelector('[class$="_header"] [class$="_tabs"]')
627
- if (tabs === null) return
628
- const actions = tabs.querySelector(':scope > [class$="_headerActions"]')
743
+ const header = document.querySelector('[class$="_header"]')
744
+ if (header === null) return
745
+ const tabs = header.querySelector('[class$="_tabs"]')
746
+ const actions = header.querySelector('[class$="_titleCluster"] [class$="_headerActions"]')
747
+ if (tabs === null || actions === null || !(tabs instanceof HTMLElement) || !(actions instanceof HTMLElement)) return
748
+ if (!headerGeometryDirty) return
749
+ headerGeometryDirty = false
629
750
  const tabBtn = tabs.querySelector(':scope > [class*="_tab"]')
630
- if (actions === null || tabBtn === null) return
631
751
  const textBottom = (el: Element | null): number | null => {
632
752
  if (el === null) return null
633
753
  const text = Array.from(el.childNodes).find((n) => n.nodeType === 3 && n.textContent !== null && n.textContent.trim() !== '')
@@ -640,31 +760,45 @@ export function startMobileAdapt(): void {
640
760
  return null
641
761
  }
642
762
  }
763
+ const translate = /translate\((-?[\d.]+)px,\s*(-?[\d.]+)px\)/.exec(actions.style.transform ?? '')
764
+ let dx = translate !== null ? parseFloat(translate[1] ?? '0') : 0
765
+ let dy = translate !== null ? parseFloat(translate[2] ?? '0') : 0
766
+ const actionsRect = actions.getBoundingClientRect()
767
+ const tabsRect = tabs.getBoundingClientRect()
768
+ // Horizontal: the actions' right edge lands on the tabs row's right edge.
769
+ const rightDiff = tabsRect.right - actionsRect.right
770
+ if (Math.abs(rightDiff) >= 0.5) dx = Math.round((dx + rightDiff) * 10) / 10
771
+ // Vertical: the actions' text bottom lands on the tab text bottom.
643
772
  const tabBottom = textBottom(tabBtn)
644
- if (tabBottom === null) return
645
- const curMatch = /translateY\((-?[\d.]+)px\)/.exec((actions as HTMLElement).style.transform ?? '')
646
- const cur = curMatch !== null ? parseFloat(curMatch[1] ?? '0') : 0
647
- let maxBottom: number | null = null
648
- for (const sel of ['[class$="_label"]', '[class$="_count"]']) {
649
- const b = textBottom(actions.querySelector(sel))
650
- if (b !== null && (maxBottom === null || b - cur > maxBottom)) maxBottom = b - cur
651
- }
652
- if (maxBottom === null) return
653
- const delta = Math.round((tabBottom - maxBottom) * 10) / 10
654
- if (Math.abs(delta) < 0.5) {
655
- if ((actions as HTMLElement).style.transform !== '') (actions as HTMLElement).style.transform = ''
656
- return
773
+ if (tabBottom !== null) {
774
+ let maxBottom: number | null = null
775
+ for (const sel of ['[class$="_label"]', '[class$="_count"]']) {
776
+ const b = textBottom(actions.querySelector(sel))
777
+ if (b !== null && (maxBottom === null || b > maxBottom)) maxBottom = b
778
+ }
779
+ if (maxBottom !== null) {
780
+ const bottomDiff = tabBottom - maxBottom
781
+ if (Math.abs(bottomDiff) >= 0.5) dy = Math.round((dy + bottomDiff) * 10) / 10
782
+ }
657
783
  }
658
- ;(actions as HTMLElement).style.transform = `translateY(${delta}px)`
784
+ const next = `translate(${dx}px, ${dy}px)`
785
+ if (actions.style.transform !== next) actions.style.transform = next
786
+ // Reserve the painted width so the tab labels never slide under it.
787
+ const reserve = `${Math.ceil(actionsRect.width) + 8}px`
788
+ if (tabs.style.paddingRight !== reserve) tabs.style.paddingRight = reserve
659
789
  }
660
790
 
661
791
  function unseatHeaderActions(): void {
662
- const tabs = document.querySelector('[class$="_header"] [class$="_tabs"]')
663
- const seated = tabs !== null ? tabs.querySelector(':scope > [class$="_headerActions"]') : null
664
- const wrap = document.querySelector('[class$="_titleCluster"] > div')
665
- if (seated !== null) {
666
- if (wrap !== null) wrap.appendChild(seated)
667
- else seated.remove()
792
+ document.body.classList.remove(HEADER_SEATED_CLASS)
793
+ const header = document.querySelector('[class$="_header"]')
794
+ const tabs = header !== null ? header.querySelector('[class$="_tabs"]') : null
795
+ const actions = header !== null ? header.querySelector('[class$="_titleCluster"] [class$="_headerActions"]') : null
796
+ if (actions instanceof HTMLElement) actions.style.transform = ''
797
+ if (tabs instanceof HTMLElement) tabs.style.paddingRight = ''
798
+ if (headerObserver !== null) {
799
+ headerObserver.disconnect()
800
+ headerObserver = null
801
+ observedHeader = null
668
802
  }
669
803
  }
670
804
 
@@ -674,7 +808,29 @@ export function startMobileAdapt(): void {
674
808
  whaleObserver.observe(document.body, { attributes: true, attributeFilter: ['data-sidebar-collapsed'], subtree: true })
675
809
  }
676
810
 
811
+ /**
812
+ * Observe the conversation header subtree and mark its geometry dirty on any
813
+ * re-render (label text, badge count, node replacement). The per-tick
814
+ * alignment then reads layout only when the header actually changed instead
815
+ * of on every tick while a message streams. The observer is swapped when the
816
+ * header node is replaced and disconnected with the layer.
817
+ */
818
+ function ensureHeaderObserver(header: Element | null): void {
819
+ if (typeof MutationObserver === 'undefined') return
820
+ if (observedHeader === header && headerObserver !== null) return
821
+ headerObserver?.disconnect()
822
+ headerObserver = null
823
+ observedHeader = null
824
+ if (header === null) return
825
+ headerObserver = new MutationObserver(() => { headerGeometryDirty = true })
826
+ headerObserver.observe(header, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class'] })
827
+ observedHeader = header
828
+ headerGeometryDirty = true
829
+ }
830
+
677
831
  function evaluate(): void {
832
+ // A viewport change invalidates every measured position.
833
+ headerGeometryDirty = true
678
834
  if (!adaptEnabled) {
679
835
  revert()
680
836
  return
@@ -695,13 +851,16 @@ export function startMobileAdapt(): void {
695
851
 
696
852
  // v51: Enter only inserts a newline on mobile (send goes through the send
697
853
  // button). Captured at document level so the official Enter-to-send
698
- // handler never sees the event.
854
+ // handler never sees the event. The rewrite is scoped to the composer:
855
+ // `_input` is also the class of the settings, plugin and agent-preset text
856
+ // fields, where swallowing Enter and inserting a newline into a single-line
857
+ // input left the key dead.
699
858
  function onKeydownCapture(e: KeyboardEvent): void {
700
859
  if (!active) return
701
860
  if (e.key !== 'Enter' || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return
702
861
  const t = e.target
703
862
  if (!(t instanceof HTMLElement)) return
704
- if (t.closest('[class$="_input"]') === null) return
863
+ if (composerFieldOf(t) === null) return
705
864
  // keyCode 229 is the classic IME-confirm signal (Safari fires
706
865
  // compositionend before the Enter keydown) — never treat it as a
707
866
  // plain newline.
@@ -721,11 +880,11 @@ export function startMobileAdapt(): void {
721
880
  // the whale excluded).
722
881
  function onClickCapture(e: MouseEvent): void {
723
882
  if (!active) return
724
- const frame = document.querySelector('[class$="_frame"]')
883
+ const frame = appFrame()
725
884
  if (frame === null || frame.hasAttribute('data-sidebar-collapsed')) return
726
885
  const t = e.target
727
886
  if (!(t instanceof Element)) return
728
- if (t.closest('[class$="_sidebarCol"]') !== null) {
887
+ if (t.closest('[class*="_sidebarCol"]') !== null) {
729
888
  // v68: opening settings also folds the sidebar so the modal closes
730
889
  // back into a clean conversation view. Clicks on the session
731
890
  // row-actions ellipsis (or its opened menu anchor) must NOT collapse
@@ -734,15 +893,15 @@ export function startMobileAdapt(): void {
734
893
  // button also folds.
735
894
  const topNewSessionClicked = t.closest('[class$="_newSession"], [class$="_brand"]') !== null
736
895
  let projectNewSessionClicked = false
737
- const projectActions = t.closest('[class$="_projectRow"] [class$="_rowActions"]')
896
+ const projectActions = t.closest('[class*="_projectRow"] [class*="_rowActions"]')
738
897
  if (projectActions !== null) {
739
898
  const btn = t.closest('button')
740
899
  const btns = projectActions.querySelectorAll('button')
741
900
  projectNewSessionClicked = btn !== null && btns.length > 0 && btn === btns[btns.length - 1]
742
901
  }
743
902
  let shouldCollapse = false
744
- if (t.closest('[class$="_sessionRow"]') !== null) {
745
- shouldCollapse = t.closest('[class$="_rowActions"]') === null
903
+ if (t.closest('[class*="_sessionRow"]') !== null) {
904
+ shouldCollapse = t.closest('[class*="_rowActions"]') === null
746
905
  } else if (t.closest('[class$="_settingsArea"]') !== null || topNewSessionClicked || projectNewSessionClicked) {
747
906
  shouldCollapse = true
748
907
  }
@@ -769,8 +928,15 @@ export function startMobileAdapt(): void {
769
928
  }
770
929
  document.addEventListener('touchstart', (e) => {
771
930
  if (!active) return
931
+ // Multi-finger touches are never a swipe: a pinch that happens to end with
932
+ // >60px of horizontal finger travel would otherwise toggle the sidebar.
933
+ if (e.touches.length > 1) {
934
+ swipeTouch = null
935
+ return
936
+ }
772
937
  const t = e.target
773
- 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)) {
938
+ const editable = t instanceof HTMLElement && t.isContentEditable
939
+ 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)) {
774
940
  swipeTouch = null
775
941
  return
776
942
  }
@@ -780,6 +946,8 @@ export function startMobileAdapt(): void {
780
946
  }, { capture: true, passive: true })
781
947
  document.addEventListener('touchend', (e) => {
782
948
  if (swipeTouch === null) return
949
+ // Still more than one finger down: a pinch is in progress, not a swipe.
950
+ if (e.touches.length > 0) return
783
951
  const ct = e.changedTouches[0]
784
952
  if (ct === undefined || ct.identifier !== swipeTouch.id) return
785
953
  const dx = ct.clientX - swipeTouch.x
@@ -787,7 +955,7 @@ export function startMobileAdapt(): void {
787
955
  swipeTouch = null
788
956
  if (!active) return
789
957
  if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5) return
790
- const frame = document.querySelector('[class$="_frame"]')
958
+ const frame = appFrame()
791
959
  if (frame === null) return
792
960
  if (document.querySelector('[class$="_overlay"], [class$="_dialog"], [class$="_menu"], [class*="_portal"]') !== null) return
793
961
  const collapsed = frame.hasAttribute('data-sidebar-collapsed')
@@ -816,7 +984,7 @@ export function startMobileAdapt(): void {
816
984
  }
817
985
 
818
986
  function openSessionMenu(row: Element): void {
819
- const actions = row.querySelector('[class$="_rowActions"]')
987
+ const actions = row.querySelector('[class*="_rowActions"]')
820
988
  const btn = actions?.querySelector('button')
821
989
  if (actions === null || btn === null) return
822
990
  // The official row hides actions until hover/menuOpen; force the anchor
@@ -836,8 +1004,8 @@ export function startMobileAdapt(): void {
836
1004
  clearLongPress()
837
1005
  return
838
1006
  }
839
- const row = t.closest('[class$="_sessionRow"]')
840
- if (row === null || t.closest('[class$="_rowActions"]') !== null) {
1007
+ const row = t.closest('[class*="_sessionRow"]')
1008
+ if (row === null || t.closest('[class*="_rowActions"]') !== null) {
841
1009
  clearLongPress()
842
1010
  return
843
1011
  }
@@ -895,7 +1063,7 @@ export function startMobileAdapt(): void {
895
1063
  if (!active) return
896
1064
  const t = e.target
897
1065
  if (!(t instanceof Element)) return
898
- if (t.closest('[class$="_sessionRow"]') !== null && ((longPress !== null && longPress.triggered) || Date.now() <= suppressSessionClickUntil)) {
1066
+ if (t.closest('[class*="_sessionRow"]') !== null && ((longPress !== null && longPress.triggered) || Date.now() <= suppressSessionClickUntil)) {
899
1067
  e.preventDefault()
900
1068
  }
901
1069
  }, true)
@@ -904,26 +1072,39 @@ export function startMobileAdapt(): void {
904
1072
  document.addEventListener('pointerleave', (e) => {
905
1073
  if (!active || Date.now() > longPressMenuGuardUntil) return
906
1074
  const t = e.target
907
- if (t instanceof Element && (t.closest('[class$="_rowActions"]') !== null || t.closest('[class$="_sessionRow"]') !== null || t.closest('[class$="_projectRow"]') !== null)) {
1075
+ if (t instanceof Element && (t.closest('[class*="_rowActions"]') !== null || t.closest('[class*="_sessionRow"]') !== null || t.closest('[class*="_projectRow"]') !== null)) {
908
1076
  e.stopPropagation()
909
1077
  }
910
1078
  }, true)
911
1079
 
912
- // Intercept programmatic .focus() on the composer input: the official
913
- // conversation component focuses it on mount and on session changes;
914
- // user taps focus it through the browser's own pipeline. Only allow
915
- // composer focus that follows a real tap on the input itself.
1080
+ // Intercept programmatic .focus() on the composer field: the official
1081
+ // conversation component focuses it on mount and on session changes; user
1082
+ // taps focus it through the browser's own pipeline. Only allow composer
1083
+ // focus that follows a real tap on the field itself. The composer field is
1084
+ // the official contenteditable input root (`data-composer-input`) on this
1085
+ // cohort — matching only textarea/input missed it entirely, so every session
1086
+ // open popped the phone keyboard. The queue-row editor input is deliberately
1087
+ // NOT a composer field: its autoFocus must keep working.
1088
+ const isComposerField = (el: Element | null): el is HTMLElement => {
1089
+ if (!(el instanceof HTMLElement)) return false
1090
+ if (el.closest(COMPOSER_SELECTOR) === null) return false
1091
+ if (el.matches('[data-composer-input]')) return true
1092
+ if (el.tagName === 'TEXTAREA') return true
1093
+ return el.tagName === 'INPUT' && el.matches('[class*="_input"]')
1094
+ }
1095
+ /** The composer field that owns an event target, if any. */
1096
+ const composerFieldOf = (target: EventTarget | null): HTMLElement | null => {
1097
+ const el = target instanceof Element ? target.closest('[data-composer-input], [class*="_input"], textarea') : null
1098
+ return isComposerField(el) ? el : null
1099
+ }
916
1100
  const lanOrigFocus = HTMLElement.prototype.focus
917
1101
  HTMLElement.prototype.focus = function (this: HTMLElement, options?: FocusOptions): void {
918
- if (active && (this.tagName === 'TEXTAREA' || this.tagName === 'INPUT') && this.closest('[class$="_composerSeat"]') !== null && Date.now() - lastComposerTap >= 800) {
919
- return
920
- }
1102
+ if (active && isComposerField(this) && Date.now() - lastComposerTap >= 800) return
921
1103
  lanOrigFocus.call(this, options)
922
1104
  }
923
1105
  document.addEventListener('pointerdown', (e) => {
924
1106
  if (!active) return
925
- const t = e.target
926
- if (t instanceof Element && (t.tagName === 'TEXTAREA' || t.tagName === 'INPUT') && t.closest('[class$="_composerSeat"]') !== null) lastComposerTap = Date.now()
1107
+ if (composerFieldOf(e.target) !== null) lastComposerTap = Date.now()
927
1108
  }, true)
928
1109
 
929
1110
  // The plugin apply() wires toggleSidebar/closeDetails to ctx.layout once