@linxin666/dsh-remote-web-ui 0.3.18 → 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
|
@@ -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
|
-
|
|
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
|
|
80
|
-
'[class
|
|
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
|
|
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
|
|
109
|
-
'[class
|
|
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
|
|
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
|
|
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
|
-
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
|
|
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(
|
|
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
|
|
382
|
-
|
|
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,
|
|
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(
|
|
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,
|
|
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,
|
|
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
|
-
*
|
|
438
|
-
* the
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
|
|
443
|
-
|
|
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 =
|
|
447
|
-
const collapsedBefore = frame
|
|
448
|
-
w.__dshRemoteAdapt?.toggleSidebar
|
|
449
|
-
|
|
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
|
-
|
|
531
|
+
}
|
|
532
|
+
toggle.click();
|
|
533
|
+
let tries = 0;
|
|
534
|
+
const verify = () => {
|
|
452
535
|
if (!active)
|
|
453
536
|
return;
|
|
454
|
-
const frameNow =
|
|
455
|
-
if (
|
|
537
|
+
const frameNow = appFrame();
|
|
538
|
+
if (frameNow === null)
|
|
456
539
|
return;
|
|
457
540
|
if (frameNow.hasAttribute('data-sidebar-collapsed') !== collapsedBefore)
|
|
458
541
|
return;
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
608
|
-
const
|
|
609
|
-
const
|
|
610
|
-
//
|
|
611
|
-
//
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
-
//
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
//
|
|
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
|
|
626
|
-
if (
|
|
748
|
+
const header = document.querySelector('[class$="_header"]');
|
|
749
|
+
if (header === null)
|
|
627
750
|
return;
|
|
628
|
-
const
|
|
629
|
-
const
|
|
630
|
-
if (
|
|
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
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
if (
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
|
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
|
-
|
|
671
|
-
const
|
|
672
|
-
const
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
763
|
-
shouldCollapse = t.closest('[class
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
872
|
-
if (row === null || t.closest('[class
|
|
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
|
|
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
|
|
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
|
|
957
|
-
// conversation component focuses it on mount and on session changes;
|
|
958
|
-
//
|
|
959
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|