@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.
- package/lib/client.js +249 -90
- package/lib/client.js.map +1 -1
- package/lib/index.js +128 -30
- package/lib/types/client/mobile-adapt.d.ts.map +1 -1
- package/lib/types/client/mobile-adapt.js +313 -117
- package/lib/types/routes.d.ts +23 -0
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +94 -29
- package/lib/types/tunnel.d.ts +8 -0
- package/lib/types/tunnel.d.ts.map +1 -1
- package/lib/types/tunnel.js +11 -1
- package/lib/types/update.d.ts.map +1 -1
- package/lib/types/update.js +31 -11
- package/package.json +1 -1
- package/src/client/mobile-adapt.ts +293 -112
- package/src/client/remote.module.css +10 -4
- package/src/routes.ts +92 -25
- package/src/tunnel.ts +12 -1
- package/src/update.ts +30 -11
|
@@ -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
|
-
|
|
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
|
|
100
|
-
'[class
|
|
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
|
|
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
|
|
129
|
-
'[class
|
|
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
|
|
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
|
|
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
|
-
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
|
|
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(
|
|
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
|
|
404
|
-
|
|
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,
|
|
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(
|
|
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,
|
|
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,
|
|
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
|
-
*
|
|
458
|
-
* the
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
|
|
463
|
-
|
|
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 =
|
|
467
|
-
const collapsedBefore = frame
|
|
468
|
-
w.__dshRemoteAdapt?.toggleSidebar
|
|
469
|
-
|
|
470
|
-
|
|
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 =
|
|
473
|
-
if (
|
|
552
|
+
const frameNow = appFrame()
|
|
553
|
+
if (frameNow === null) return
|
|
474
554
|
if (frameNow.hasAttribute('data-sidebar-collapsed') !== collapsedBefore) return
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
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
|
|
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')
|
|
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 =
|
|
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
|
|
609
|
-
const
|
|
610
|
-
const
|
|
611
|
-
//
|
|
612
|
-
//
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
//
|
|
623
|
-
//
|
|
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
|
|
627
|
-
if (
|
|
628
|
-
const
|
|
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
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
if (
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
-
|
|
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
|
-
|
|
663
|
-
const
|
|
664
|
-
const
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
745
|
-
shouldCollapse = t.closest('[class
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
840
|
-
if (row === null || t.closest('[class
|
|
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
|
|
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
|
|
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
|
|
913
|
-
// conversation component focuses it on mount and on session changes;
|
|
914
|
-
//
|
|
915
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|