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