@linxin666/dsh-remote-web-ui 0.3.6 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +77 -468
  3. package/README.zh.md +83 -113
  4. package/lib/client.js +955 -203
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +1136 -1466
  7. package/lib/types/client/FooterRemoteEntry.d.ts +6 -7
  8. package/lib/types/client/FooterRemoteEntry.d.ts.map +1 -1
  9. package/lib/types/client/FooterRemoteEntry.js +1 -1
  10. package/lib/types/client/RemoteEntry.d.ts +7 -4
  11. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  12. package/lib/types/client/RemoteEntry.js +7 -10
  13. package/lib/types/client/RemotePanel.d.ts +2 -2
  14. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  15. package/lib/types/client/RemotePanel.js +2 -2
  16. package/lib/types/client/RemoteSettingsCard.d.ts +9 -5
  17. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  18. package/lib/types/client/RemoteSettingsCard.js +56 -3
  19. package/lib/types/client/deep-link.d.ts +10 -13
  20. package/lib/types/client/deep-link.d.ts.map +1 -1
  21. package/lib/types/client/deep-link.js +14 -74
  22. package/lib/types/client/index.d.ts +2 -6
  23. package/lib/types/client/index.d.ts.map +1 -1
  24. package/lib/types/client/index.js +73 -39
  25. package/lib/types/client/locales.d.ts +26 -12
  26. package/lib/types/client/locales.d.ts.map +1 -1
  27. package/lib/types/client/locales.js +36 -22
  28. package/lib/types/client/mobile-adapt.d.ts +40 -0
  29. package/lib/types/client/mobile-adapt.d.ts.map +1 -0
  30. package/lib/types/client/mobile-adapt.js +967 -0
  31. package/lib/types/client/pair-api.d.ts +24 -6
  32. package/lib/types/client/pair-api.d.ts.map +1 -1
  33. package/lib/types/client/pair-api.js +9 -14
  34. package/lib/types/client/remote-channel.d.ts +3 -0
  35. package/lib/types/client/remote-channel.d.ts.map +1 -1
  36. package/lib/types/client/remote-channel.js +35 -3
  37. package/lib/types/client/settings-form.d.ts +35 -43
  38. package/lib/types/client/settings-form.d.ts.map +1 -1
  39. package/lib/types/client/settings-form.js +60 -53
  40. package/lib/types/firewall.d.ts +75 -0
  41. package/lib/types/firewall.d.ts.map +1 -0
  42. package/lib/types/firewall.js +184 -0
  43. package/lib/types/gate.d.ts +11 -5
  44. package/lib/types/gate.d.ts.map +1 -1
  45. package/lib/types/gate.js +11 -5
  46. package/lib/types/index.d.ts +39 -10
  47. package/lib/types/index.d.ts.map +1 -1
  48. package/lib/types/index.js +206 -58
  49. package/lib/types/inner-auth.d.ts +32 -0
  50. package/lib/types/inner-auth.d.ts.map +1 -0
  51. package/lib/types/inner-auth.js +72 -0
  52. package/lib/types/lan-bind-plan.d.ts +55 -0
  53. package/lib/types/lan-bind-plan.d.ts.map +1 -0
  54. package/lib/types/lan-bind-plan.js +60 -0
  55. package/lib/types/lan-bind.d.ts +69 -0
  56. package/lib/types/lan-bind.d.ts.map +1 -0
  57. package/lib/types/lan-bind.js +128 -0
  58. package/lib/types/loopback-proxy.d.ts +9 -2
  59. package/lib/types/loopback-proxy.d.ts.map +1 -1
  60. package/lib/types/loopback-proxy.js +24 -2
  61. package/lib/types/pairing.d.ts +15 -3
  62. package/lib/types/pairing.d.ts.map +1 -1
  63. package/lib/types/pairing.js +20 -6
  64. package/lib/types/remote-api.d.ts +30 -13
  65. package/lib/types/remote-api.d.ts.map +1 -1
  66. package/lib/types/remote-api.js +55 -42
  67. package/lib/types/remote-channel-boot.d.ts +21 -2
  68. package/lib/types/remote-channel-boot.d.ts.map +1 -1
  69. package/lib/types/remote-channel-boot.js +65 -3
  70. package/lib/types/remote-channel-rules.d.ts +8 -0
  71. package/lib/types/remote-channel-rules.d.ts.map +1 -1
  72. package/lib/types/remote-channel-rules.js +9 -2
  73. package/lib/types/remote-methods.d.ts +45 -11
  74. package/lib/types/remote-methods.d.ts.map +1 -1
  75. package/lib/types/remote-methods.js +56 -27
  76. package/lib/types/remote-presence-pet.d.ts +51 -0
  77. package/lib/types/remote-presence-pet.d.ts.map +1 -0
  78. package/lib/types/remote-presence-pet.js +101 -0
  79. package/lib/types/routes.d.ts +76 -4
  80. package/lib/types/routes.d.ts.map +1 -1
  81. package/lib/types/routes.js +336 -19
  82. package/lib/types/tunnel.d.ts.map +1 -1
  83. package/lib/types/tunnel.js +7 -1
  84. package/package.json +20 -18
  85. package/src/client/FooterRemoteEntry.tsx +9 -10
  86. package/src/client/RemoteEntry.tsx +13 -14
  87. package/src/client/RemotePanel.tsx +5 -16
  88. package/src/client/RemoteSettingsCard.tsx +72 -13
  89. package/src/client/deep-link.ts +14 -75
  90. package/src/client/index.ts +77 -39
  91. package/src/client/locales.ts +36 -22
  92. package/src/client/mobile-adapt.ts +913 -0
  93. package/src/client/pair-api.ts +26 -15
  94. package/src/client/remote-channel.ts +31 -3
  95. package/src/client/settings-form.ts +78 -92
  96. package/src/dsh-home.ts +1 -1
  97. package/src/firewall.ts +230 -0
  98. package/src/gate.ts +11 -5
  99. package/src/index.ts +235 -65
  100. package/src/inner-auth.ts +85 -0
  101. package/src/lan-bind-plan.ts +81 -0
  102. package/src/lan-bind.ts +145 -0
  103. package/src/loopback-proxy.ts +34 -0
  104. package/src/pairing.persist.test.ts +1 -1
  105. package/src/pairing.ts +20 -6
  106. package/src/remote-api.ts +67 -45
  107. package/src/remote-channel-boot.ts +70 -3
  108. package/src/remote-channel-rules.ts +17 -2
  109. package/src/remote-methods.ts +58 -27
  110. package/src/remote-presence-pet.ts +129 -0
  111. package/src/routes.ts +358 -19
  112. package/src/tunnel.ts +7 -1
  113. package/assets/apple-touch-icon.png +0 -0
  114. package/assets/mobile-icon-192.png +0 -0
  115. package/assets/mobile-icon-512.png +0 -0
  116. package/assets/mobile-service-worker.js +0 -63
  117. package/lib/mobile.js +0 -17658
  118. package/lib/mobile.js.map +0 -1
  119. package/lib/types/mobile/PairRequiredView.d.ts +0 -7
  120. package/lib/types/mobile/PairRequiredView.d.ts.map +0 -1
  121. package/lib/types/mobile/PairRequiredView.js +0 -28
  122. package/lib/types/mobile/PairRequiredView.test.d.ts +0 -2
  123. package/lib/types/mobile/PairRequiredView.test.d.ts.map +0 -1
  124. package/lib/types/mobile/PairRequiredView.test.js +0 -24
  125. package/lib/types/mobile/api.d.ts +0 -145
  126. package/lib/types/mobile/api.d.ts.map +0 -1
  127. package/lib/types/mobile/api.js +0 -103
  128. package/lib/types/mobile/index.d.ts +0 -10
  129. package/lib/types/mobile/index.d.ts.map +0 -1
  130. package/lib/types/mobile/index.js +0 -38
  131. package/lib/types/mobile/markdown.d.ts +0 -34
  132. package/lib/types/mobile/markdown.d.ts.map +0 -1
  133. package/lib/types/mobile/markdown.js +0 -285
  134. package/lib/types/mobile/markdown.test.d.ts +0 -2
  135. package/lib/types/mobile/markdown.test.d.ts.map +0 -1
  136. package/lib/types/mobile/markdown.test.js +0 -79
  137. package/lib/types/mobile/messages.d.ts +0 -149
  138. package/lib/types/mobile/messages.d.ts.map +0 -1
  139. package/lib/types/mobile/messages.js +0 -561
  140. package/lib/types/mobile/messages.test.d.ts +0 -2
  141. package/lib/types/mobile/messages.test.d.ts.map +0 -1
  142. package/lib/types/mobile/messages.test.js +0 -356
  143. package/lib/types/mobile/mobile-styles.d.ts +0 -6
  144. package/lib/types/mobile/mobile-styles.d.ts.map +0 -1
  145. package/lib/types/mobile/mobile-styles.js +0 -1438
  146. package/lib/types/mobile/mobile-theme.d.ts +0 -22
  147. package/lib/types/mobile/mobile-theme.d.ts.map +0 -1
  148. package/lib/types/mobile/mobile-theme.js +0 -70
  149. package/lib/types/mobile/mobile-theme.test.d.ts +0 -2
  150. package/lib/types/mobile/mobile-theme.test.d.ts.map +0 -1
  151. package/lib/types/mobile/mobile-theme.test.js +0 -71
  152. package/lib/types/mobile/mux.d.ts +0 -128
  153. package/lib/types/mobile/mux.d.ts.map +0 -1
  154. package/lib/types/mobile/mux.js +0 -305
  155. package/lib/types/mobile/mux.test.d.ts +0 -2
  156. package/lib/types/mobile/mux.test.d.ts.map +0 -1
  157. package/lib/types/mobile/mux.test.js +0 -267
  158. package/lib/types/mobile/pairing.d.ts +0 -31
  159. package/lib/types/mobile/pairing.d.ts.map +0 -1
  160. package/lib/types/mobile/pairing.js +0 -62
  161. package/lib/types/mobile/rpc.d.ts +0 -38
  162. package/lib/types/mobile/rpc.d.ts.map +0 -1
  163. package/lib/types/mobile/rpc.js +0 -83
  164. package/lib/types/mobile/theme-toggle.d.ts +0 -12
  165. package/lib/types/mobile/theme-toggle.d.ts.map +0 -1
  166. package/lib/types/mobile/theme-toggle.js +0 -26
  167. package/lib/types/mobile/views/App.d.ts +0 -57
  168. package/lib/types/mobile/views/App.d.ts.map +0 -1
  169. package/lib/types/mobile/views/App.js +0 -150
  170. package/lib/types/mobile/views/App.test.d.ts +0 -2
  171. package/lib/types/mobile/views/App.test.d.ts.map +0 -1
  172. package/lib/types/mobile/views/App.test.js +0 -52
  173. package/lib/types/mobile/views/ChatView.d.ts +0 -63
  174. package/lib/types/mobile/views/ChatView.d.ts.map +0 -1
  175. package/lib/types/mobile/views/ChatView.js +0 -821
  176. package/lib/types/mobile/views/ChatView.stream.test.d.ts +0 -2
  177. package/lib/types/mobile/views/ChatView.stream.test.d.ts.map +0 -1
  178. package/lib/types/mobile/views/ChatView.stream.test.js +0 -236
  179. package/lib/types/mobile/views/ChatView.test.d.ts +0 -2
  180. package/lib/types/mobile/views/ChatView.test.d.ts.map +0 -1
  181. package/lib/types/mobile/views/ChatView.test.js +0 -718
  182. package/lib/types/mobile/views/SessionListView.d.ts +0 -27
  183. package/lib/types/mobile/views/SessionListView.d.ts.map +0 -1
  184. package/lib/types/mobile/views/SessionListView.js +0 -139
  185. package/lib/types/mobile/views/SessionListView.test.d.ts +0 -2
  186. package/lib/types/mobile/views/SessionListView.test.d.ts.map +0 -1
  187. package/lib/types/mobile/views/SessionListView.test.js +0 -167
  188. package/lib/types/mobile/views/WorkspaceView.d.ts +0 -21
  189. package/lib/types/mobile/views/WorkspaceView.d.ts.map +0 -1
  190. package/lib/types/mobile/views/WorkspaceView.js +0 -93
  191. package/lib/types/mobile/views/WorkspaceView.test.d.ts +0 -2
  192. package/lib/types/mobile/views/WorkspaceView.test.d.ts.map +0 -1
  193. package/lib/types/mobile/views/WorkspaceView.test.js +0 -183
  194. package/lib/types/mobile-api.d.ts +0 -65
  195. package/lib/types/mobile-api.d.ts.map +0 -1
  196. package/lib/types/mobile-api.js +0 -404
  197. package/lib/types/mobile-pending.d.ts +0 -38
  198. package/lib/types/mobile-pending.d.ts.map +0 -1
  199. package/lib/types/mobile-pending.js +0 -59
  200. package/lib/types/mobile-pwa.d.ts +0 -6
  201. package/lib/types/mobile-pwa.d.ts.map +0 -1
  202. package/lib/types/mobile-pwa.js +0 -17
  203. package/lib/types/mobile-routes.d.ts +0 -13
  204. package/lib/types/mobile-routes.d.ts.map +0 -1
  205. package/lib/types/mobile-routes.js +0 -217
  206. package/lib/types/paired-model-catalog.d.ts +0 -17
  207. package/lib/types/paired-model-catalog.d.ts.map +0 -1
  208. package/lib/types/paired-model-catalog.js +0 -311
  209. package/src/mobile/PairRequiredView.test.tsx +0 -28
  210. package/src/mobile/PairRequiredView.tsx +0 -58
  211. package/src/mobile/api.ts +0 -229
  212. package/src/mobile/index.tsx +0 -41
  213. package/src/mobile/markdown.test.ts +0 -91
  214. package/src/mobile/markdown.ts +0 -291
  215. package/src/mobile/messages.test.ts +0 -402
  216. package/src/mobile/messages.ts +0 -685
  217. package/src/mobile/mobile-styles.ts +0 -1439
  218. package/src/mobile/mobile-theme.test.ts +0 -84
  219. package/src/mobile/mobile-theme.ts +0 -76
  220. package/src/mobile/mux.test.ts +0 -314
  221. package/src/mobile/mux.ts +0 -336
  222. package/src/mobile/pairing.ts +0 -77
  223. package/src/mobile/rpc.ts +0 -90
  224. package/src/mobile/theme-toggle.tsx +0 -47
  225. package/src/mobile/views/App.test.tsx +0 -62
  226. package/src/mobile/views/App.tsx +0 -213
  227. package/src/mobile/views/ChatView.stream.test.tsx +0 -268
  228. package/src/mobile/views/ChatView.test.tsx +0 -816
  229. package/src/mobile/views/ChatView.tsx +0 -1342
  230. package/src/mobile/views/SessionListView.test.tsx +0 -207
  231. package/src/mobile/views/SessionListView.tsx +0 -244
  232. package/src/mobile/views/WorkspaceView.test.tsx +0 -227
  233. package/src/mobile/views/WorkspaceView.tsx +0 -245
  234. package/src/mobile-api.ts +0 -418
  235. package/src/mobile-pending.ts +0 -88
  236. package/src/mobile-pwa.ts +0 -19
  237. package/src/mobile-routes.ts +0 -231
  238. package/src/paired-model-catalog.ts +0 -272
@@ -0,0 +1,967 @@
1
+ /**
2
+ * Portrait-touch adaptation of the OFFICIAL desktop Web GUI (the dsh-LAN
3
+ * approach): while a touch device is in portrait, inject a CSS + gesture
4
+ * layer on top of the running SPA instead of maintaining a second UI. The
5
+ * official layout already auto-collapses the sidebar below 1024px; this
6
+ * layer adds touch-target sizing, 16px inputs (iOS focus zoom), safe-area
7
+ * padding, a floating whale button as the collapsed-sidebar entry (with a
8
+ * verified toggle fallback for cohorts whose layout face mounts inert),
9
+ * swipe gestures, long-press session menus, a bottom-sheet composer picker
10
+ * (model / effort / permission menus), desktop-surface suppression, and
11
+ * composer/header compaction.
12
+ * Landscape, desktop, and wide viewports stay untouched; a manual
13
+ * sessionStorage opt-out (dsh-remote-force-desktop=1) disables the layer.
14
+ *
15
+ * Selector strategy: CSS Modules class names are hash-prefixed with the
16
+ * semantic name as a stable suffix (`pI_x6G_centerCol`), so attribute
17
+ * suffix selectors survive official rebuilds that only change the hash. The
18
+ * semantic names were read off the 0.1.2-alpha.1 official client build and
19
+ * are re-verified on every GUI QA round; rules borrowed from the dsh-LAN
20
+ * reference (MIT, v47-v78) keep their version comments.
21
+ * @module @linxin666/dsh-remote-web-ui/client/mobile-adapt
22
+ */
23
+ /** Storage key for the manual desktop opt-out. */
24
+ const FORCE_DESKTOP_KEY = 'dsh-remote-force-desktop';
25
+ /** Storage key for the dragged whale position. */
26
+ const WHALE_POS_KEY = 'dsh-remote-whale-pos';
27
+ /** Injected stylesheet identity. */
28
+ const ADAPT_CSS_ID = 'dsh-remote-web-ui/mobile-adapt.css';
29
+ /** Body class while the adaptation is active. */
30
+ const ACTIVE_CLASS = 'dsh-remote-portrait';
31
+ /** Body class while the collapsed rail is hidden behind the whale. */
32
+ const RAIL_HIDDEN_CLASS = 'dsh-remote-rail-hidden';
33
+ /** Whale button id. */
34
+ const WHALE_ID = 'dshRemoteWhale';
35
+ /** Compact picker: synthesized model button id. */
36
+ const MODEL_BTN_ID = 'dshRemoteModelPick';
37
+ /** Compact picker: synthesized effort button id. */
38
+ const EFFORT_BTN_ID = 'dshRemoteEffortPick';
39
+ /** Body class while the compact picker buttons are wired. */
40
+ const COMPACT_CLASS = 'dsh-remote-compact-picker';
41
+ /**
42
+ * Whether the current viewport is a portrait touch device small enough to
43
+ * need the adaptation.
44
+ */
45
+ function isMobilePortrait() {
46
+ if (typeof window === 'undefined')
47
+ return false;
48
+ if (typeof window.matchMedia !== 'function')
49
+ return false;
50
+ if (!window.matchMedia('(orientation: portrait)').matches)
51
+ return false;
52
+ if (!window.matchMedia('(pointer: coarse)').matches)
53
+ return false;
54
+ if (window.innerWidth >= 1100)
55
+ return false;
56
+ if (window.sessionStorage.getItem(FORCE_DESKTOP_KEY) === '1')
57
+ return false;
58
+ return true;
59
+ }
60
+ /**
61
+ * The injected rules. Grouped by surface; version comments trace rules back
62
+ * to the dsh-LAN reference they were ported from.
63
+ */
64
+ const ADAPT_CSS = [
65
+ 'html,body{height:100%}',
66
+ // The app frame fills the dynamic viewport (browser chrome collapse).
67
+ '[class$="_frame"]{width:100%;height:100dvh}',
68
+ // Collapsed rail: bigger touch targets.
69
+ '[class$="_railFish"] button,[class$="_panelIcon"],[class$="_newSession"]{min-width:44px;min-height:44px}',
70
+ // Message list padding on narrow screens.
71
+ '[class$="_scroll"]{padding:8px 10px}',
72
+ // 16px inputs prevent iOS focus zoom; keep the send button touchy.
73
+ '[class$="_input"],textarea,input{font-size:16px}',
74
+ '[class$="_composer"]{padding-bottom:calc(4px + env(safe-area-inset-bottom))}',
75
+ // v50: slightly smaller type on portrait phones.
76
+ '[class$="_scrollBody"] [class$="_root"]{font-size:14.5px}',
77
+ '[class$="_scrollBody"] [class$="_bubble"]{font-size:14.5px}',
78
+ '[class$="_titleRow"] *{font-size:13px}',
79
+ '[class$="_sidebarCol"] [class$="_root"],[class$="_sidebarCol"] [class$="_newSession"],[class$="_sidebarCol"] [class$="_trigger"],[class$="_sidebarCol"] [class$="_title"]{font-size:13px}',
80
+ '[class$="_sidebarCol"] [class$="_meta"],[class$="_sidebarCol"] [class$="_time"]{font-size:11.5px}',
81
+ // v50: collapsed rail hidden; the whale button is the entry. The frame's
82
+ // grid is inline-styled (56px rail track when collapsed); pin the first
83
+ // track to 0 so content uses the full width. On portrait phones the
84
+ // details track is always 0 (computeColumns drops it when there is no
85
+ // room), so the three-track override is safe here.
86
+ '[class$="_frame"][data-sidebar-collapsed]{grid-template-columns:0 minmax(0,1fr) 0 !important}',
87
+ // The official collapsed sidebar is an absolutely-positioned overlay rail
88
+ // (it leaves the grid flow), so the in-flow centerCol would auto-place
89
+ // into the now-0px FIRST track and collapse the conversation to zero width
90
+ // on an intermittent basis (the rail flips relative <-> absolute as the
91
+ // host overlay mounts). Pin every column to an explicit track so the
92
+ // center always lands on the 1fr track regardless of the sidebar's
93
+ // computed position.
94
+ '[class$="_frame"][data-sidebar-collapsed] [class$="_sidebarCol"]{grid-column:1/2}',
95
+ '[class$="_frame"][data-sidebar-collapsed] [class$="_centerCol"]{grid-column:2/3}',
96
+ '[class$="_frame"][data-sidebar-collapsed] [class$="_detailsCol"]{grid-column:3/4}',
97
+ // Keep the chat header title clear of the floating whale.
98
+ `body.${RAIL_HIDDEN_CLASS} [class$="_titleRow"]{padding-left:52px}`,
99
+ // The whale button itself.
100
+ `#${WHALE_ID}{position:fixed;top:calc(4px + env(safe-area-inset-top));left:calc(8px + env(safe-area-inset-left));z-index:2147482999;width:34px;height:34px;min-width:34px;padding:0;border-radius:10px;background:var(--dsw-alias-bg-module-platform);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);display:flex;align-items:center;justify-content:center;cursor:pointer;box-shadow:0 1px 6px rgba(0,0,0,.25)}`,
101
+ `#${WHALE_ID} svg{width:20px;height:15px;display:block}`,
102
+ `#${WHALE_ID}:active{opacity:.72}`,
103
+ `#${WHALE_ID}{touch-action:none}`,
104
+ // v51/v52: smaller collapse toggle in the expanded sidebar (the toggle
105
+ // carries two classes, so match by containment); v47's panelIcon touch
106
+ // rule forces a 44px min on the icon itself — zero it out so the 13px
107
+ // glyph fits the 18px button.
108
+ '[class$="_sidebarCol"] [class$="_logoRow"] [class*="_iconButton"]{width:18px;height:18px}',
109
+ '[class$="_sidebarCol"] [class$="_logoRow"] [class*="_iconButton"] svg{width:13px;height:13px;min-width:0;min-height:0}',
110
+ // v77: workspace rows' right-side actions (menu + new session) are
111
+ // hover-only on desktop; always show them on mobile touch.
112
+ '[class$="_sidebarCol"] [class$="_projectRow"] [class$="_rowActions"]{display:inline-flex}',
113
+ // v78: mobile long-press on a row must not start native drag.
114
+ '[class$="_sidebarCol"] [class$="_sessionRow"],[class$="_sidebarCol"] [class$="_projectRow"]{-webkit-user-drag:none;user-select:none}',
115
+ // Hide the header Session-log download button (no space on phones).
116
+ '[class$="_headerUtilities"]{display:none}',
117
+ // v52 composer: the two lines (permission / model) stay stacked with zero
118
+ // row gap; the command, context-meter and send/stop buttons float at the
119
+ // left/right edges vertically centered over both lines.
120
+ '[class$="_composerSeat"] [class$="_card"]{margin-bottom:1px}',
121
+ '[class$="_composerSeat"] [class$="_row"]{flex-wrap:wrap;row-gap:0;padding:2px 8px 1px;position:relative}',
122
+ '[class$="_composerSeat"] [class$="_add"]{position:absolute;left:8px;top:50%;transform:translateY(-50%)}',
123
+ '[class$="_composerSeat"] [class$="_modes"]{min-width:0;padding-left:38px}',
124
+ // Model line left-aligned with the permission line (same command-button
125
+ // clearance), rows stay tightly stacked.
126
+ '[class$="_composerSeat"] [class$="_trailing"]{flex-basis:100%;position:relative;min-height:32px;justify-content:flex-start;padding-left:38px;padding-right:78px}',
127
+ '[class$="_composerSeat"] [class$="_trailing"] *{font-size:12px}',
128
+ // v54: smaller permission/model buttons (font + height). v79: the
129
+ // permission trigger collapses to its shield icon on phones — the label
130
+ // text is the first thing a narrow row drops (the icon color carries the
131
+ // state), matching the dsh-LAN compact composer.
132
+ '[class$="_composerSeat"] [class$="_modes"] [class$="_trigger"]{height:24px;min-height:24px;font-size:12px}',
133
+ '[class$="_composerSeat"] [class$="_trailing"] [class$="_trigger"]{height:24px;min-height:24px;font-size:11px}',
134
+ // Context meter + send/stop float at the right edge, vertically centered
135
+ // over both lines (the meter's root contains the track).
136
+ '[class$="_composerSeat"] [class$="_trailing"] > [class$="_root"]:has([class$="_track"]){position:absolute;right:52px;top:50%;transform:translateY(-50%)}',
137
+ '[class$="_composerSeat"] [class$="_primary"]{position:absolute;right:8px;top:50%;transform:translateY(-50%)}',
138
+ // v79: the model picker menu anchors right:0 to its narrow trigger, so on
139
+ // a phone both the picker menu and the model list fly past the left
140
+ // viewport edge (model names unreadably cut). Turn the picker into a
141
+ // bottom sheet: the composer seat carries an identity transform, which
142
+ // would still become the containing block for fixed children, so free it
143
+ // first; then pin every seat menu to the viewport bottom with real touch
144
+ // targets. Covers the picker, the model list, and the other composer
145
+ // popovers (permission presets, attachments) alike.
146
+ '[class$="_composerSeat"]{transform:none !important}',
147
+ '[class$="_composerSeat"] [class$="_menu"]{position:fixed !important;left:8px !important;right:8px !important;top:auto !important;bottom:calc(8px + env(safe-area-inset-bottom)) !important;width:auto !important;max-width:none !important;max-height:70dvh !important;overflow-y:auto !important;z-index:2147482000}',
148
+ '[class$="_composerSeat"] [class$="_menu"] [class$="_cell"]{height:44px;min-height:44px;font-size:13px}',
149
+ // v79 (compact picker): when the row is too narrow for the desktop text
150
+ // triggers, the phone falls back to icon entries — the context ring stays
151
+ // official, and two synthesized buttons open the picker sheet straight on
152
+ // the model list and the effort list (drill-through, so one tap lands on
153
+ // the same list the user's mock shows). The original text trigger hides
154
+ // only while the wired buttons exist (body class): a failed wiring
155
+ // degrades back to the usable text trigger instead of no picker.
156
+ `body.${COMPACT_CLASS} [class$="_composerSeat"] [class$="_trailing"] [class$="_trigger"]:has([class$="_triggerEffort"]){display:none}`,
157
+ // The icon buttons sit inline in the tools row (parallel to the
158
+ // permission trigger), so the trailing line collapses to zero and the
159
+ // context ring + send re-anchor to the row itself. The ring shifts a
160
+ // few px right: at its desktop offset its hit box kisses the effort
161
+ // button.
162
+ `body.${COMPACT_CLASS} [class$="_composerSeat"] [class$="_trailing"]{flex-basis:auto;position:static;min-height:0;padding:0;width:0}`,
163
+ `body.${COMPACT_CLASS} [class$="_composerSeat"] [class$="_trailing"] > [class$="_root"]:has([class$="_track"]){right:44px}`,
164
+ `#${MODEL_BTN_ID},#${EFFORT_BTN_ID}{width:26px;height:32px;min-width:26px;padding:0;border-radius:9px;background:var(--dsw-alias-bg-module-platform);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);display:flex;align-items:center;justify-content:center;cursor:pointer;flex:none;margin-left:4px}`,
165
+ `#${MODEL_BTN_ID} svg,#${EFFORT_BTN_ID} svg{width:16px;height:16px;display:block}`,
166
+ `#${MODEL_BTN_ID}:active,#${EFFORT_BTN_ID}:active{opacity:.7}`,
167
+ // Bottom stats line (N rounds / M steps) two sizes below the tabs; v55
168
+ // wraps freely but clamps at two lines (the official rule is nowrap +
169
+ // single-line ellipsis); v60 drops the official 32px right padding.
170
+ '[class$="_composerSeat"] [data-slot="conversation.composer.dock"] [class$="_root"]{font-size:10px;white-space:normal;word-break:break-word;overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;text-align:left;line-height:13px;letter-spacing:-0.2px;padding-left:0;padding-right:0}',
171
+ // (user bubbles keep the v50 14.5px via the _bubble rule.)
172
+ '[class$="_scrollBody"] [class$="_root"]{font-size:13px}',
173
+ // v58/v65 port note: the dsh-LAN `_body` gap:6px compaction is deliberately
174
+ // NOT ported. The official message row is a shrinkable flex column whose
175
+ // bottom-anchored body then measures shorter than its text (13px root font
176
+ // on a fixed 24px line box), and the extra gap pushed the first text line
177
+ // above the row box — every assistant message clipped its top half on the
178
+ // phone (verified on the real GUI; the removal restores natural heights).
179
+ // v58/v65: on touch, taps leave :hover/:focus stuck, so official Tooltip
180
+ // bubbles stay visible on mobile. The bubble class hash sits AFTER the
181
+ // name, so match by containment, still scoped by role=tooltip so user
182
+ // message bubbles (no tooltip role) stay visible.
183
+ '[class*="_bubble"][role="tooltip"]{display:none}',
184
+ // v65: PlanReviewPanel shares the _frame/_card suffixes and overflows a
185
+ // narrow phone; pin it to the viewport with border-box and cap its height
186
+ // so the card sits fully on screen.
187
+ '[class$="_composerSeat"] [class$="_frame"]{box-sizing:border-box;width:100%;max-width:100%;padding-left:12px;padding-right:12px;height:auto;max-height:calc(100dvh - 96px);align-items:flex-start;overflow-y:auto}',
188
+ '[class$="_composerSeat"] [class$="_frame"] [class$="_card"]{max-width:none;width:100%}',
189
+ // v67: header actions (agent-preset mode label + background-task badge)
190
+ // are re-seated from the title row into the tabs row; hidden in the
191
+ // original spot so React re-renders do not flicker them back.
192
+ '[class$="_header"] [class$="_titleCluster"] [class$="_headerActions"]{display:none}',
193
+ // The official header keeps a 78px right padding on phones; stretch the
194
+ // tabs row so the seated actions sit flush against the right edge
195
+ // (verified 360/390/480 on the reference).
196
+ '[class$="_header"] [class$="_tabs"]{margin-right:-58px}',
197
+ // v70: match the tab text size with the seated mode label (12px); NB
198
+ // [class$="_tab"] misses the active tab (its class ends in "_tabActive")
199
+ // — containment so BOTH tabs match.
200
+ '[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
+ // Mobile scope: hide the plugin surfaces that do not fit a phone — the
204
+ // right-hand details column and every desktop-oriented tool surface. The
205
+ // list keys on the L2 semantic roots (data-dsh-plugin, ownership stays
206
+ // with the declaring plugin), so official class churn cannot resurrect
207
+ // them. These are render suppressions: the client bundles still load.
208
+ // The pet is part of the hidden set by design: the mobile remote mirror
209
+ // carries the desktop-less phone surface, and a floating pet would only
210
+ // cover the small viewport. The suppression is the requirement, not a bug.
211
+ `body.${ACTIVE_CLASS} [class$=\"_detailsCol\"]{display:none !important}`,
212
+ `body.${ACTIVE_CLASS} [data-dsh-plugin=\"ssh\"],`,
213
+ `body.${ACTIVE_CLASS} [data-dsh-plugin=\"skill-explorer\"],`,
214
+ `body.${ACTIVE_CLASS} [data-dsh-plugin=\"task-board\"],`,
215
+ `body.${ACTIVE_CLASS} [data-dsh-plugin=\"git-graph\"],`,
216
+ `body.${ACTIVE_CLASS} [data-dsh-plugin=\"pet\"],`,
217
+ `body.${ACTIVE_CLASS} [data-dsh-plugin=\"perf\"],`,
218
+ `body.${ACTIVE_CLASS} [data-dsh-plugin=\"usage\"]{display:none !important}`,
219
+ // The official workbench (Files / source control) mounts into a
220
+ // full-viewport portal layer with no data-dsh-plugin root, so the plugin
221
+ // list above cannot key on it; its open state persists across page loads,
222
+ // and on a phone it covers the entire conversation with no visible close
223
+ // control — the remote-opens-to-an-empty-screen report. Hide the
224
+ // workbench panel only: the same portal layer also hosts the settings
225
+ // modal, which must stay reachable (verified in the GUI QA round).
226
+ `body.${ACTIVE_CLASS} [class$=\"_overlayLayer\"] [class$=\"_workbench\"]{display:none !important}`,
227
+ // v68: settings modal on mobile — the official panel is a fixed 800px
228
+ // two-column layout (nav + content); switch to a column layout: the
229
+ // section nav becomes a horizontal scrollable row on top.
230
+ '[class$="_overlay"] [class$="_panel"]{flex-direction:column;max-height:calc(100dvh - 32px)}',
231
+ '[class$="_overlay"] [class$="_panel"] [class$="_nav"]{flex-direction:row;gap:4px;width:100%;padding:12px 12px 0;overflow-x:auto;overflow-y:hidden}',
232
+ '[class$="_overlay"] [class$="_panel"] [class$="_navTitle"]{display:none}',
233
+ '[class$="_overlay"] [class$="_panel"] [class$="_navList"]{flex-direction:row;gap:4px}',
234
+ '[class$="_overlay"] [class$="_panel"] [class$="_navCell"]{height:34px;padding:0 12px;gap:6px;flex:none;border-radius:10px}',
235
+ '[class$="_overlay"] [class$="_panel"] [class$="_navLabel"]{font-size:13px}',
236
+ '[class$="_overlay"] [class$="_panel"] [class$="_content"]{flex:1;min-height:0}',
237
+ ];
238
+ /** Cube glyph for the compact model button (a plain box outline). */
239
+ const CUBE_ICON = '<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>';
240
+ /** Level glyph for the compact effort button (three rising bars). */
241
+ const LEVELS_ICON = '<path d="M4 6h16"/><path d="M7 12h10"/><path d="M10 18h4"/>';
242
+ /** The DeepSeek fish glyph (the official brand mark path). */
243
+ const FISH_PATH = 'M22.9168 1.43018C22.6713 1.31018 22.5658 1.53918 22.4223 1.65519C22.3733 1.69269 22.3318 1.74169 22.2903 1.78669C21.9317 2.1697 21.5127 2.42121 20.9657 2.39121C20.1657 2.34621 19.4827 2.59771 18.8787 3.20973C18.7502 2.45521 18.3236 2.0047 17.6746 1.71569C17.3351 1.56568 16.9916 1.41518 16.7536 1.08867C16.5876 0.856163 16.5421 0.597155 16.4591 0.341647C16.4061 0.187643 16.3536 0.0301382 16.1761 0.00363739C15.9836 -0.0263635 15.9081 0.135141 15.8326 0.270145C15.5306 0.822162 15.4136 1.43018 15.4251 2.0462C15.4516 3.43174 16.0366 4.53527 17.1991 5.3203C17.3311 5.4103 17.3651 5.5003 17.3236 5.63181C17.2441 5.90231 17.1501 6.16482 17.0671 6.43533C17.0141 6.60784 16.9351 6.64584 16.7501 6.57033C16.1121 6.30383 15.5611 5.90931 15.074 5.4328C14.2475 4.63328 13.5 3.75075 12.568 3.05973C12.349 2.89822 12.13 2.74822 11.9034 2.60522C10.9524 1.68169 12.028 0.923165 12.277 0.833162C12.5375 0.739159 12.3675 0.41615 11.5259 0.42015C10.6844 0.42365 9.91439 0.705658 8.93286 1.08117C8.78935 1.13767 8.63835 1.17867 8.48384 1.21267C7.59332 1.04367 6.66829 1.00617 5.70226 1.11517C3.88321 1.31768 2.43016 2.1777 1.36213 3.64575C0.0790928 5.4103 -0.222916 7.41536 0.146595 9.50642C0.535106 11.7105 1.66014 13.535 3.38869 14.9616C5.18125 16.4406 7.24581 17.1657 9.60138 17.0266C11.0319 16.9441 12.6245 16.7526 14.421 15.2321C14.874 15.4576 15.3496 15.5476 16.1381 15.6151C16.7456 15.6716 17.3306 15.5851 17.7836 15.4911C18.4931 15.3411 18.4441 14.6841 18.1876 14.5636C16.1081 13.595 16.5646 13.9891 16.1496 13.67C17.2061 12.42 18.8202 10.1979 19.3182 7.17235C19.3672 6.83834 19.4297 6.36783 19.4222 6.09732C19.4182 5.93231 19.4562 5.86831 19.6447 5.84931C20.1657 5.78931 20.6712 5.64681 21.1357 5.3913C22.4833 4.65528 23.0268 3.44624 23.1548 1.9972C23.1738 1.77569 23.1508 1.54668 22.9168 1.43018Z';
244
+ /**
245
+ * Install the adaptation layer. Runs once per page (idempotent); the
246
+ * evaluate loop re-applies on orientation/resize and reverts off-portrait.
247
+ */
248
+ export function startMobileAdapt() {
249
+ if (typeof window === 'undefined' || typeof document === 'undefined')
250
+ return;
251
+ if (window.__dshRemoteAdaptInstalled === true)
252
+ return;
253
+ window.__dshRemoteAdaptInstalled = true;
254
+ const w = window;
255
+ let active = false;
256
+ let savedViewportContent = null;
257
+ let whaleEl = null;
258
+ let whaleObserver = null;
259
+ let whaleTimer = null;
260
+ let whaleSuppressClick = false;
261
+ let whaleShown = false;
262
+ let drag = null;
263
+ let swipeTouch = null;
264
+ let lastComposerTap = 0;
265
+ // Plugin master switch: the module-scope layer installs before any config
266
+ // is readable, so the plugin apply() flips this through setEnabled() once
267
+ // the settings snapshot settles (disabled plugin = no injected surface).
268
+ let adaptEnabled = true;
269
+ /**
270
+ * Idempotently (re-)install the adaptation stylesheet. The rules live in
271
+ * one <style> tag keyed by data-plugin-css; the sync tick re-runs this so
272
+ * a tag lost to any external DOM cleanup is restored within one tick
273
+ * instead of silently dropping the suppressions (portrait pet hiding,
274
+ * rail compaction) while the body class stays.
275
+ */
276
+ function ensureAdaptStyle() {
277
+ if (document.querySelector(`style[data-plugin-css="${ADAPT_CSS_ID}"]`) !== null)
278
+ return;
279
+ const tag = document.createElement('style');
280
+ tag.dataset.plugin = 'remote-web-ui';
281
+ tag.dataset.pluginCss = ADAPT_CSS_ID;
282
+ tag.textContent = ADAPT_CSS.join('');
283
+ document.head.appendChild(tag);
284
+ }
285
+ function apply() {
286
+ if (active)
287
+ return;
288
+ active = true;
289
+ document.body.classList.add(ACTIVE_CLASS);
290
+ // A details panel opened before the viewport rotated into portrait (or
291
+ // restored across reloads) would sit behind the display:none above;
292
+ // closing it through the official face unmounts the surface entirely.
293
+ // The layout face throws by design when the root entry has not mounted
294
+ // yet (boot-order), which is a tolerated no-op here — the plugin apply
295
+ // replays it through flushCloseDetails once the wiring is live.
296
+ try {
297
+ w.__dshRemoteAdapt?.closeDetails?.();
298
+ }
299
+ catch { }
300
+ ensureAdaptStyle();
301
+ // viewport-fit=cover enables env(safe-area-inset-*); restore on revert.
302
+ const meta = document.querySelector('meta[name=viewport]');
303
+ if (meta instanceof HTMLMetaElement && meta.getAttribute('content') !== null && !(meta.getAttribute('content') ?? '').includes('viewport-fit')) {
304
+ savedViewportContent = meta.getAttribute('content');
305
+ meta.setAttribute('content', `${savedViewportContent}, viewport-fit=cover`);
306
+ }
307
+ ensureWhale();
308
+ syncWhale();
309
+ // React builds nodes with attributes before inserting them, so
310
+ // attribute observers miss the initial collapsed state — a light
311
+ // interval is the reliable sync.
312
+ setWhaleTimer(true);
313
+ seatHeaderActions();
314
+ }
315
+ function revert() {
316
+ if (!active)
317
+ return;
318
+ active = false;
319
+ unseatHeaderActions();
320
+ removeCompactPicker();
321
+ document.body.classList.remove(ACTIVE_CLASS);
322
+ document.body.classList.remove(RAIL_HIDDEN_CLASS);
323
+ const tag = document.querySelector(`style[data-plugin-css="${ADAPT_CSS_ID}"]`);
324
+ tag?.remove();
325
+ const meta = document.querySelector('meta[name=viewport]');
326
+ if (meta instanceof HTMLMetaElement && savedViewportContent !== null) {
327
+ meta.setAttribute('content', savedViewportContent);
328
+ savedViewportContent = null;
329
+ }
330
+ if (whaleEl !== null)
331
+ whaleEl.style.display = 'none';
332
+ setWhaleTimer(false);
333
+ }
334
+ /** Start/stop the 600ms sync tick; a no-op when already in the asked state. */
335
+ function setWhaleTimer(on) {
336
+ if (on && whaleTimer === null && active) {
337
+ whaleTimer = window.setInterval(syncWhale, 600);
338
+ }
339
+ else if (!on && whaleTimer !== null) {
340
+ window.clearInterval(whaleTimer);
341
+ whaleTimer = null;
342
+ }
343
+ }
344
+ /**
345
+ * Compact picker (v79): a phone row cannot fit the desktop text triggers,
346
+ * so the model/effort entries become two icon buttons in the trailing
347
+ * row. Both forward to the official picker trigger (its menu renders as
348
+ * the bottom sheet) and then drill straight into the asked cell — model
349
+ * list or effort list — so one tap lands on the list, matching the
350
+ * cube-model / brain-effort mapping. The official context ring next to
351
+ * the send button keeps its own semantics untouched.
352
+ */
353
+ function removeCompactPicker() {
354
+ document.body.classList.remove(COMPACT_CLASS);
355
+ document.getElementById(MODEL_BTN_ID)?.remove();
356
+ document.getElementById(EFFORT_BTN_ID)?.remove();
357
+ }
358
+ function drillIntoPicker(cellPattern) {
359
+ const trigger = document.querySelector('[class$="_composerSeat"] [class$="_trailing"] [class$="_trigger"]:has([class$="_triggerEffort"])');
360
+ if (trigger === null)
361
+ return;
362
+ trigger.click();
363
+ // The sheet mount takes a beat (observed ~0.2-0.6s on a cold phone
364
+ // mirror); poll until the asked cell exists instead of a fixed delay.
365
+ let tries = 0;
366
+ const tapCell = () => {
367
+ tries += 1;
368
+ const cell = Array.from(document.querySelectorAll('[class$="_composerSeat"] [class$="_menu"] [class$="_cell"]'))
369
+ .find((c) => cellPattern.test(c.textContent ?? ''));
370
+ if (cell !== undefined) {
371
+ ;
372
+ cell.click();
373
+ return;
374
+ }
375
+ if (tries < 8)
376
+ window.setTimeout(tapCell, 150);
377
+ };
378
+ window.setTimeout(tapCell, 150);
379
+ }
380
+ function makeCompactButton(id, title, icon, cellPattern) {
381
+ const btn = document.createElement('button');
382
+ btn.id = id;
383
+ btn.type = 'button';
384
+ btn.dataset.dshPlugin = 'remote-web-ui';
385
+ btn.title = title;
386
+ btn.setAttribute('aria-label', title);
387
+ 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>`;
388
+ btn.addEventListener('click', () => { drillIntoPicker(cellPattern); });
389
+ return btn;
390
+ }
391
+ function syncCompactPicker() {
392
+ if (!active)
393
+ return;
394
+ const tools = document.querySelector('[class$="_composerSeat"] [class$="_tools"]');
395
+ const trigger = tools?.parentElement?.querySelector('[class$="_triggerEffort"]')?.parentElement;
396
+ if (tools === null || trigger === null) {
397
+ removeCompactPicker();
398
+ return;
399
+ }
400
+ const zh = (navigator.language ?? '').toLowerCase().startsWith('zh');
401
+ if (document.getElementById(MODEL_BTN_ID) === null) {
402
+ tools.appendChild(makeCompactButton(MODEL_BTN_ID, zh ? '选择模型' : 'Pick model', CUBE_ICON, /模型|Model/));
403
+ }
404
+ if (document.getElementById(EFFORT_BTN_ID) === null) {
405
+ tools.appendChild(makeCompactButton(EFFORT_BTN_ID, zh ? '选择推理等级' : 'Pick reasoning effort', LEVELS_ICON, /推理等级|Reasoning|Effort/i));
406
+ }
407
+ document.body.classList.add(COMPACT_CLASS);
408
+ }
409
+ /**
410
+ * Toggle the sidebar through the wired layout face, then verify the flip:
411
+ * the official LayoutController can be mounted yet inert (its bound store
412
+ * actions race the root entry on this cohort — observed on the running
413
+ * local build, where the face call silently did nothing and the whale
414
+ * was dead). When the frame state did not change shortly after the call,
415
+ * drive the official rail/logo toggle instead: that button owns its own
416
+ * store actions and flips the same state on every cohort we support.
417
+ */
418
+ function toggleSidebarVerified() {
419
+ const frame = document.querySelector('[class$="_frame"]');
420
+ const collapsedBefore = frame instanceof HTMLElement ? frame.hasAttribute('data-sidebar-collapsed') : null;
421
+ w.__dshRemoteAdapt?.toggleSidebar?.();
422
+ if (collapsedBefore === null)
423
+ return;
424
+ window.setTimeout(() => {
425
+ if (!active)
426
+ return;
427
+ const frameNow = document.querySelector('[class$="_frame"]');
428
+ if (!(frameNow instanceof HTMLElement))
429
+ return;
430
+ if (frameNow.hasAttribute('data-sidebar-collapsed') !== collapsedBefore)
431
+ return;
432
+ document.querySelector('[class$="_railFish"] button, [class$="_logoRow"] [class*="_iconButton"]')?.click();
433
+ }, 150);
434
+ }
435
+ function ensureWhale() {
436
+ if (whaleEl !== null || !document.body)
437
+ return;
438
+ const whale = document.createElement('button');
439
+ whale.id = WHALE_ID;
440
+ whale.type = 'button';
441
+ whale.dataset.dshPlugin = 'remote-web-ui';
442
+ // Outside the slots i18n system (module-scope overlay), so the label
443
+ // picks the browser language directly.
444
+ const whaleLabel = (navigator.language ?? '').toLowerCase().startsWith('zh') ? '打开侧边栏' : 'Open sidebar';
445
+ whale.title = whaleLabel;
446
+ whale.setAttribute('aria-label', whaleLabel);
447
+ whale.innerHTML = `<svg viewBox="0 0 23.16 17.04" fill="none" aria-hidden="true"><path d="${FISH_PATH}" fill="currentColor"/></svg>`;
448
+ whale.addEventListener('click', () => {
449
+ if (whaleSuppressClick) {
450
+ whaleSuppressClick = false;
451
+ return;
452
+ }
453
+ toggleSidebarVerified();
454
+ });
455
+ // v51: the whale floats — pointer-drag repositions it (persisted).
456
+ whale.addEventListener('pointerdown', (e) => {
457
+ if (whaleEl === null)
458
+ return;
459
+ if (e.button !== 0 && e.pointerType === 'mouse')
460
+ return;
461
+ // v61: opening the sidebar must not leave a focused composer input
462
+ // behind — on phones a still-focused input makes the first tap re-pop
463
+ // the keyboard (iOS) or keeps it open. Blur at pointerdown (before the
464
+ // tap completes; click is too late for iOS) and block pending
465
+ // programmatic refocus.
466
+ if (active) {
467
+ const ta = document.querySelector('[class$="_composerSeat"] textarea, [class$="_composerSeat"] input');
468
+ if (ta !== null && document.activeElement === ta) {
469
+ ta.blur();
470
+ lastComposerTap = 0;
471
+ }
472
+ }
473
+ drag = { x: e.clientX, y: e.clientY, left: whale.offsetLeft, top: whale.offsetTop, moved: false };
474
+ try {
475
+ whale.setPointerCapture(e.pointerId);
476
+ }
477
+ catch { }
478
+ e.preventDefault();
479
+ e.stopPropagation();
480
+ });
481
+ whale.addEventListener('pointermove', (e) => {
482
+ if (whaleEl === null || drag === null)
483
+ return;
484
+ const dx = e.clientX - drag.x;
485
+ const dy = e.clientY - drag.y;
486
+ drag.moved = drag.moved || Math.abs(dx) > 3 || Math.abs(dy) > 3;
487
+ whaleEl.style.left = `${Math.min(Math.max(4, drag.left + dx), window.innerWidth - 38)}px`;
488
+ whaleEl.style.top = `${Math.min(Math.max(4, drag.top + dy), window.innerHeight - 38)}px`;
489
+ e.preventDefault();
490
+ e.stopPropagation();
491
+ });
492
+ const endWhaleDrag = () => {
493
+ if (whaleEl === null || drag === null)
494
+ return;
495
+ const moved = drag.moved;
496
+ drag = null;
497
+ whaleSuppressClick = moved;
498
+ try {
499
+ window.localStorage.setItem(WHALE_POS_KEY, JSON.stringify({ x: whaleEl.offsetLeft, y: whaleEl.offsetTop }));
500
+ }
501
+ catch { }
502
+ };
503
+ whale.addEventListener('pointerup', endWhaleDrag);
504
+ whale.addEventListener('pointercancel', endWhaleDrag);
505
+ whaleEl = whale;
506
+ document.body.appendChild(whale);
507
+ }
508
+ /** Restore a dragged position when the whale becomes visible again. */
509
+ function applyWhalePos() {
510
+ if (whaleEl === null)
511
+ return;
512
+ let pos = null;
513
+ try {
514
+ pos = JSON.parse(window.localStorage.getItem(WHALE_POS_KEY) ?? 'null');
515
+ }
516
+ catch { }
517
+ if (pos !== null && typeof pos.x === 'number' && typeof pos.y === 'number') {
518
+ // Clamp to the current viewport: a position saved on a larger screen
519
+ // (resize, split-screen, rotation) must not park the whale — the only
520
+ // portrait sidebar entry — out of reach.
521
+ const x = Math.min(Math.max(4, pos.x), window.innerWidth - 38);
522
+ const y = Math.min(Math.max(4, pos.y), window.innerHeight - 38);
523
+ whaleEl.style.left = `${x}px`;
524
+ whaleEl.style.top = `${y}px`;
525
+ }
526
+ }
527
+ // v78: on touch, official session/project rows are draggable for
528
+ // reordering; a long-press would start native drag instead of our action
529
+ // menu. Force them non-draggable while the adapt is active (React may
530
+ // re-create rows, so re-apply on the sync tick).
531
+ function disableRowDrag() {
532
+ if (!active)
533
+ return;
534
+ const rows = document.querySelectorAll('[class$="_sidebarCol"] [class$="_sessionRow"], [class$="_sidebarCol"] [class$="_projectRow"]');
535
+ for (const row of rows) {
536
+ if (row.getAttribute('draggable') !== 'false')
537
+ row.setAttribute('draggable', 'false');
538
+ }
539
+ }
540
+ function syncWhale() {
541
+ // Keep the stylesheet present while the layer is active (the tick runs
542
+ // every 600ms; see ensureAdaptStyle for why the tag can need a re-assert).
543
+ if (active)
544
+ ensureAdaptStyle();
545
+ if (whaleEl === null)
546
+ return;
547
+ if (!active) {
548
+ whaleEl.style.display = 'none';
549
+ return;
550
+ }
551
+ const collapsed = document.querySelector('[class$="_frame"][data-sidebar-collapsed]') !== null;
552
+ const overlayUp = document.querySelector('[class$="_overlay"]') !== null;
553
+ const show = collapsed && !overlayUp;
554
+ // Restore on every hidden-to-shown transition — including the very
555
+ // first show on a fresh page load, where the inline display is still
556
+ // '' and a CSS-read guard would never fire.
557
+ if (show && !whaleShown)
558
+ applyWhalePos();
559
+ whaleEl.style.display = show ? '' : 'none';
560
+ whaleShown = show;
561
+ document.body.classList.toggle(RAIL_HIDDEN_CLASS, collapsed);
562
+ disableRowDrag();
563
+ seatHeaderActions();
564
+ alignActionsText();
565
+ syncCompactPicker();
566
+ }
567
+ // v67: on mobile the header actions (agent-preset mode label + background
568
+ // task badge) move from the title row into the tabs row. React re-creates
569
+ // the node in its original spot on re-render, so the interval re-seats it;
570
+ // stale seated copies are removed first.
571
+ function seatHeaderActions() {
572
+ if (!active)
573
+ return;
574
+ const tabs = document.querySelector('[class$="_header"] [class$="_tabs"]');
575
+ const fresh = document.querySelector('[class$="_titleCluster"] [class$="_headerActions"]');
576
+ const seated = tabs !== null ? tabs.querySelector(':scope > [class$="_headerActions"]') : null;
577
+ // fresh === null means the node is either not rendered yet or already
578
+ // seated — the seated copy IS the moved node, so never remove it.
579
+ if (fresh !== null && tabs !== null) {
580
+ seated?.remove();
581
+ tabs.appendChild(fresh);
582
+ }
583
+ }
584
+ // Align the mode/badge text bottom edge with the tab text. The tabs row
585
+ // height is not stable (a background task badge grows it), so a static
586
+ // transform would be wrong half the time — measure the real text boxes
587
+ // each tick and compensate (converges; the previous transform is
588
+ // subtracted from the measurement).
589
+ function alignActionsText() {
590
+ if (!active)
591
+ return;
592
+ const tabs = document.querySelector('[class$="_header"] [class$="_tabs"]');
593
+ if (tabs === null)
594
+ return;
595
+ const actions = tabs.querySelector(':scope > [class$="_headerActions"]');
596
+ const tabBtn = tabs.querySelector(':scope > [class*="_tab"]');
597
+ if (actions === null || tabBtn === null)
598
+ return;
599
+ const textBottom = (el) => {
600
+ if (el === null)
601
+ return null;
602
+ const text = Array.from(el.childNodes).find((n) => n.nodeType === 3 && n.textContent !== null && n.textContent.trim() !== '');
603
+ if (text === undefined)
604
+ return null;
605
+ try {
606
+ const range = document.createRange();
607
+ range.selectNode(text);
608
+ return range.getBoundingClientRect().bottom;
609
+ }
610
+ catch {
611
+ return null;
612
+ }
613
+ };
614
+ const tabBottom = textBottom(tabBtn);
615
+ if (tabBottom === null)
616
+ return;
617
+ const curMatch = /translateY\((-?[\d.]+)px\)/.exec(actions.style.transform ?? '');
618
+ const cur = curMatch !== null ? parseFloat(curMatch[1] ?? '0') : 0;
619
+ let maxBottom = null;
620
+ for (const sel of ['[class$="_label"]', '[class$="_count"]']) {
621
+ const b = textBottom(actions.querySelector(sel));
622
+ if (b !== null && (maxBottom === null || b - cur > maxBottom))
623
+ maxBottom = b - cur;
624
+ }
625
+ if (maxBottom === null)
626
+ return;
627
+ const delta = Math.round((tabBottom - maxBottom) * 10) / 10;
628
+ if (Math.abs(delta) < 0.5) {
629
+ if (actions.style.transform !== '')
630
+ actions.style.transform = '';
631
+ return;
632
+ }
633
+ ;
634
+ actions.style.transform = `translateY(${delta}px)`;
635
+ }
636
+ function unseatHeaderActions() {
637
+ const tabs = document.querySelector('[class$="_header"] [class$="_tabs"]');
638
+ const seated = tabs !== null ? tabs.querySelector(':scope > [class$="_headerActions"]') : null;
639
+ const wrap = document.querySelector('[class$="_titleCluster"] > div');
640
+ if (seated !== null) {
641
+ if (wrap !== null)
642
+ wrap.appendChild(seated);
643
+ else
644
+ seated.remove();
645
+ }
646
+ }
647
+ function ensureWhaleObserver() {
648
+ if (whaleObserver !== null || typeof MutationObserver === 'undefined' || !document.body)
649
+ return;
650
+ whaleObserver = new MutationObserver(() => { syncWhale(); });
651
+ whaleObserver.observe(document.body, { attributes: true, attributeFilter: ['data-sidebar-collapsed'], subtree: true });
652
+ }
653
+ function evaluate() {
654
+ if (!adaptEnabled) {
655
+ revert();
656
+ return;
657
+ }
658
+ if (isMobilePortrait()) {
659
+ apply();
660
+ ensureWhaleObserver();
661
+ }
662
+ else {
663
+ revert();
664
+ }
665
+ }
666
+ evaluate();
667
+ window.addEventListener('orientationchange', evaluate);
668
+ window.addEventListener('resize', evaluate);
669
+ // The 600ms tick is a page-lifetime wake source; pause it while the page
670
+ // is hidden (backgrounded phone tab) and resume on return.
671
+ document.addEventListener('visibilitychange', () => { setWhaleTimer(document.visibilityState !== 'hidden'); });
672
+ // v51: Enter only inserts a newline on mobile (send goes through the send
673
+ // button). Captured at document level so the official Enter-to-send
674
+ // handler never sees the event.
675
+ function onKeydownCapture(e) {
676
+ if (!active)
677
+ return;
678
+ if (e.key !== 'Enter' || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey)
679
+ return;
680
+ const t = e.target;
681
+ if (!(t instanceof HTMLElement))
682
+ return;
683
+ if (t.closest('[class$="_input"]') === null)
684
+ return;
685
+ // keyCode 229 is the classic IME-confirm signal (Safari fires
686
+ // compositionend before the Enter keydown) — never treat it as a
687
+ // plain newline.
688
+ if (e.isComposing || e.keyCode === 229)
689
+ return;
690
+ e.preventDefault();
691
+ e.stopPropagation();
692
+ try {
693
+ document.execCommand('insertText', false, '\n');
694
+ }
695
+ catch { }
696
+ }
697
+ document.addEventListener('keydown', onKeydownCapture, true);
698
+ function collapseSidebar() {
699
+ toggleSidebarVerified();
700
+ }
701
+ // Auto-collapse the expanded sidebar on mobile: after clicking a session
702
+ // row, or when tapping anywhere outside the sidebar (overlays/dialogs and
703
+ // the whale excluded).
704
+ function onClickCapture(e) {
705
+ if (!active)
706
+ return;
707
+ const frame = document.querySelector('[class$="_frame"]');
708
+ if (frame === null || frame.hasAttribute('data-sidebar-collapsed'))
709
+ return;
710
+ const t = e.target;
711
+ if (!(t instanceof Element))
712
+ return;
713
+ if (t.closest('[class$="_sidebarCol"]') !== null) {
714
+ // v68: opening settings also folds the sidebar so the modal closes
715
+ // back into a clean conversation view. Clicks on the session
716
+ // row-actions ellipsis (or its opened menu anchor) must NOT collapse
717
+ // the sidebar — they open the action menu. v76: clicking the top-level
718
+ // New Session also folds. v77: clicking a workspace row's new-session
719
+ // button also folds.
720
+ const topNewSessionClicked = t.closest('[class$="_newSession"], [class$="_brand"]') !== null;
721
+ let projectNewSessionClicked = false;
722
+ const projectActions = t.closest('[class$="_projectRow"] [class$="_rowActions"]');
723
+ if (projectActions !== null) {
724
+ const btn = t.closest('button');
725
+ const btns = projectActions.querySelectorAll('button');
726
+ projectNewSessionClicked = btn !== null && btns.length > 0 && btn === btns[btns.length - 1];
727
+ }
728
+ let shouldCollapse = false;
729
+ if (t.closest('[class$="_sessionRow"]') !== null) {
730
+ shouldCollapse = t.closest('[class$="_rowActions"]') === null;
731
+ }
732
+ else if (t.closest('[class$="_settingsArea"]') !== null || topNewSessionClicked || projectNewSessionClicked) {
733
+ shouldCollapse = true;
734
+ }
735
+ if (shouldCollapse)
736
+ collapseSidebar();
737
+ return;
738
+ }
739
+ if (t.closest('[class$="_overlay"], [class$="_dialog"], [class$="_menu"], [class*="_portal"], [id="dshRemoteWhale"], [id="dshLanGate"]') !== null)
740
+ return;
741
+ collapseSidebar();
742
+ }
743
+ document.addEventListener('click', onClickCapture, true);
744
+ // v65/v66: swipe gestures. Swiping LEFT while the sidebar is expanded
745
+ // collapses it; swiping RIGHT in the conversation view opens it. Vertical
746
+ // swipes (list scrolling), swipes starting on inputs, on the whale, or
747
+ // inside horizontally scrollable containers are ignored.
748
+ function insideHScrollable(el) {
749
+ let n = el;
750
+ while (n !== null && n !== document.body) {
751
+ const cs = getComputedStyle(n);
752
+ if ((cs.overflowX === 'auto' || cs.overflowX === 'scroll') && n.scrollWidth > n.clientWidth + 4)
753
+ return true;
754
+ n = n.parentElement;
755
+ }
756
+ return false;
757
+ }
758
+ document.addEventListener('touchstart', (e) => {
759
+ if (!active)
760
+ return;
761
+ const t = e.target;
762
+ 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)) {
763
+ swipeTouch = null;
764
+ return;
765
+ }
766
+ const ct = e.changedTouches[0];
767
+ if (ct === undefined)
768
+ return;
769
+ swipeTouch = { x: ct.clientX, y: ct.clientY, id: ct.identifier };
770
+ }, { capture: true, passive: true });
771
+ document.addEventListener('touchend', (e) => {
772
+ if (swipeTouch === null)
773
+ return;
774
+ const ct = e.changedTouches[0];
775
+ if (ct === undefined || ct.identifier !== swipeTouch.id)
776
+ return;
777
+ const dx = ct.clientX - swipeTouch.x;
778
+ const dy = ct.clientY - swipeTouch.y;
779
+ swipeTouch = null;
780
+ if (!active)
781
+ return;
782
+ if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5)
783
+ return;
784
+ const frame = document.querySelector('[class$="_frame"]');
785
+ if (frame === null)
786
+ return;
787
+ if (document.querySelector('[class$="_overlay"], [class$="_dialog"], [class$="_menu"], [class*="_portal"]') !== null)
788
+ return;
789
+ const collapsed = frame.hasAttribute('data-sidebar-collapsed');
790
+ if (dx < 0 && !collapsed)
791
+ collapseSidebar();
792
+ else if (dx > 0 && collapsed)
793
+ toggleSidebarVerified();
794
+ }, { capture: true, passive: true });
795
+ document.addEventListener('touchcancel', () => {
796
+ swipeTouch = null;
797
+ }, true);
798
+ // Long-press a session row opens the same action menu as the desktop's
799
+ // right-side ellipsis button (rename / fork / archive). On touch the
800
+ // row-actions button is hover-only, so a long press reveals + clicks it.
801
+ const LONG_PRESS_MS = 500;
802
+ const LONG_PRESS_MOVE = 10;
803
+ let longPress = null;
804
+ let suppressSessionClickUntil = 0;
805
+ let suppressSessionClickRow = null;
806
+ let longPressMenuGuardUntil = 0;
807
+ function clearLongPress() {
808
+ if (longPress !== null) {
809
+ if (longPress.timer !== null)
810
+ window.clearTimeout(longPress.timer);
811
+ longPress = null;
812
+ }
813
+ }
814
+ function openSessionMenu(row) {
815
+ const actions = row.querySelector('[class$="_rowActions"]');
816
+ const btn = actions?.querySelector('button');
817
+ if (actions === null || btn === null)
818
+ return;
819
+ // The official row hides actions until hover/menuOpen; force the anchor
820
+ // visible just long enough for .click() to be accepted.
821
+ longPressMenuGuardUntil = Date.now() + 1200;
822
+ actions.style.display = 'inline-flex';
823
+ try {
824
+ btn.click();
825
+ }
826
+ finally {
827
+ window.setTimeout(() => { actions.style.display = ''; }, 50);
828
+ }
829
+ }
830
+ document.addEventListener('touchstart', (e) => {
831
+ if (!active)
832
+ return;
833
+ const t = e.target;
834
+ if (!(t instanceof Element)) {
835
+ clearLongPress();
836
+ return;
837
+ }
838
+ const row = t.closest('[class$="_sessionRow"]');
839
+ if (row === null || t.closest('[class$="_rowActions"]') !== null) {
840
+ clearLongPress();
841
+ return;
842
+ }
843
+ const ct = e.changedTouches[0];
844
+ if (ct === undefined)
845
+ return;
846
+ clearLongPress();
847
+ const lp = { row, x: ct.clientX, y: ct.clientY, timer: null, triggered: false };
848
+ lp.timer = window.setTimeout(() => {
849
+ if (longPress !== lp)
850
+ return;
851
+ lp.triggered = true;
852
+ openSessionMenu(lp.row);
853
+ try {
854
+ navigator.vibrate?.(10);
855
+ }
856
+ catch { }
857
+ }, LONG_PRESS_MS);
858
+ longPress = lp;
859
+ }, { capture: true, passive: true });
860
+ document.addEventListener('touchmove', (e) => {
861
+ if (!active || longPress === null)
862
+ return;
863
+ const ct = e.changedTouches[0];
864
+ if (ct === undefined)
865
+ return;
866
+ if (Math.abs(ct.clientX - longPress.x) > LONG_PRESS_MOVE || Math.abs(ct.clientY - longPress.y) > LONG_PRESS_MOVE) {
867
+ clearLongPress();
868
+ }
869
+ }, { capture: true, passive: true });
870
+ document.addEventListener('touchend', (e) => {
871
+ if (!active || longPress === null)
872
+ return;
873
+ const ct = e.changedTouches[0];
874
+ if (ct === undefined)
875
+ return;
876
+ const lp = longPress;
877
+ clearLongPress();
878
+ if (lp.triggered) {
879
+ // Stop the finger-lift from also opening the session; the menu is
880
+ // already open and the sidebar should stay put.
881
+ e.preventDefault();
882
+ suppressSessionClickUntil = Date.now() + 800;
883
+ suppressSessionClickRow = lp.row;
884
+ }
885
+ }, { capture: true, passive: false });
886
+ document.addEventListener('touchcancel', () => {
887
+ clearLongPress();
888
+ }, true);
889
+ // A long press ends with a synthetic click; swallow that one click so it
890
+ // neither opens the conversation nor collapses the sidebar.
891
+ window.addEventListener('click', (e) => {
892
+ if (!active || Date.now() > suppressSessionClickUntil)
893
+ return;
894
+ const t = e.target;
895
+ if (t instanceof Element && suppressSessionClickRow !== null && (t === suppressSessionClickRow || suppressSessionClickRow.contains(t))) {
896
+ e.stopPropagation();
897
+ e.preventDefault();
898
+ suppressSessionClickUntil = 0;
899
+ suppressSessionClickRow = null;
900
+ }
901
+ }, true);
902
+ // Some Android browsers fire a native context menu after a long press.
903
+ document.addEventListener('contextmenu', (e) => {
904
+ if (!active)
905
+ return;
906
+ const t = e.target;
907
+ if (!(t instanceof Element))
908
+ return;
909
+ if (t.closest('[class$="_sessionRow"]') !== null && ((longPress !== null && longPress.triggered) || Date.now() <= suppressSessionClickUntil)) {
910
+ e.preventDefault();
911
+ }
912
+ }, true);
913
+ // Keep the long-press menu open across the finger-lift: the official
914
+ // Menu closes on pointerleave of its root, which fires on touch end.
915
+ document.addEventListener('pointerleave', (e) => {
916
+ if (!active || Date.now() > longPressMenuGuardUntil)
917
+ return;
918
+ const t = e.target;
919
+ if (t instanceof Element && (t.closest('[class$="_rowActions"]') !== null || t.closest('[class$="_sessionRow"]') !== null || t.closest('[class$="_projectRow"]') !== null)) {
920
+ e.stopPropagation();
921
+ }
922
+ }, true);
923
+ // Intercept programmatic .focus() on the composer input: the official
924
+ // conversation component focuses it on mount and on session changes;
925
+ // user taps focus it through the browser's own pipeline. Only allow
926
+ // composer focus that follows a real tap on the input itself.
927
+ const lanOrigFocus = HTMLElement.prototype.focus;
928
+ HTMLElement.prototype.focus = function (options) {
929
+ if (active && (this.tagName === 'TEXTAREA' || this.tagName === 'INPUT') && this.closest('[class$="_composerSeat"]') !== null && Date.now() - lastComposerTap >= 800) {
930
+ return;
931
+ }
932
+ lanOrigFocus.call(this, options);
933
+ };
934
+ let lastComposerTapRegistered = false;
935
+ if (!lastComposerTapRegistered) {
936
+ lastComposerTapRegistered = true;
937
+ document.addEventListener('pointerdown', (e) => {
938
+ if (!active)
939
+ return;
940
+ const t = e.target;
941
+ if (t instanceof Element && (t.tagName === 'TEXTAREA' || t.tagName === 'INPUT') && t.closest('[class$="_composerSeat"]') !== null)
942
+ lastComposerTap = Date.now();
943
+ }, true);
944
+ }
945
+ // The plugin apply() wires toggleSidebar/closeDetails to ctx.layout once
946
+ // it is live, and flips the master switch when the settings snapshot
947
+ // settles.
948
+ w.__dshRemoteAdapt = {
949
+ evaluate,
950
+ toggleSidebar: null,
951
+ closeDetails: null,
952
+ setEnabled(on) {
953
+ adaptEnabled = on;
954
+ if (on)
955
+ evaluate();
956
+ else
957
+ revert();
958
+ },
959
+ flushCloseDetails() {
960
+ // The first apply() ran before any wiring existed, so its
961
+ // closeDetails call was a no-op; replay it once the layout face is
962
+ // live so a restored details panel cannot sit hidden-uncloseable.
963
+ if (active)
964
+ w.__dshRemoteAdapt?.closeDetails?.();
965
+ },
966
+ };
967
+ }