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

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