@linxin666/dsh-remote-web-ui 0.4.2 → 0.4.4

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 (79) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +12 -10
  3. package/README.zh.md +12 -10
  4. package/lib/client.js +278 -599
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +396 -913
  7. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  8. package/lib/types/client/RemoteEntry.js +8 -9
  9. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  10. package/lib/types/client/RemoteSettingsCard.js +1 -1
  11. package/lib/types/client/index.d.ts +32 -3
  12. package/lib/types/client/index.d.ts.map +1 -1
  13. package/lib/types/client/index.js +51 -8
  14. package/lib/types/client/locales.d.ts +0 -84
  15. package/lib/types/client/locales.d.ts.map +1 -1
  16. package/lib/types/client/locales.js +0 -84
  17. package/lib/types/client/mobile-adapt.d.ts.map +1 -1
  18. package/lib/types/client/mobile-adapt.js +142 -8
  19. package/lib/types/client/settings-entry-form.d.ts +50 -0
  20. package/lib/types/client/settings-entry-form.d.ts.map +1 -0
  21. package/lib/types/client/settings-entry-form.js +98 -0
  22. package/lib/types/index.d.ts +99 -7
  23. package/lib/types/index.d.ts.map +1 -1
  24. package/lib/types/index.js +150 -149
  25. package/lib/types/lan-bind-plan.d.ts +14 -0
  26. package/lib/types/lan-bind-plan.d.ts.map +1 -1
  27. package/lib/types/lan-bind-plan.js +16 -0
  28. package/lib/types/lan-bind.d.ts +1 -1
  29. package/lib/types/lan-bind.js +1 -1
  30. package/lib/types/mount-once.d.ts +38 -7
  31. package/lib/types/mount-once.d.ts.map +1 -1
  32. package/lib/types/mount-once.js +96 -12
  33. package/lib/types/pairing.d.ts.map +1 -1
  34. package/lib/types/pairing.js +43 -0
  35. package/lib/types/remote-api.d.ts +13 -1
  36. package/lib/types/remote-api.d.ts.map +1 -1
  37. package/lib/types/remote-api.js +14 -1
  38. package/lib/types/tunnel.d.ts +60 -3
  39. package/lib/types/tunnel.d.ts.map +1 -1
  40. package/lib/types/tunnel.js +136 -3
  41. package/package.json +20 -20
  42. package/src/client/RemoteEntry.tsx +7 -9
  43. package/src/client/RemoteSettingsCard.tsx +0 -2
  44. package/src/client/index.ts +53 -9
  45. package/src/client/locales.ts +0 -84
  46. package/src/client/mobile-adapt.ts +144 -5
  47. package/src/client/remote.module.css +50 -200
  48. package/src/client/settings-entry-form.ts +142 -0
  49. package/src/index.ts +202 -164
  50. package/src/lan-bind-plan.ts +22 -0
  51. package/src/lan-bind.ts +1 -1
  52. package/src/mount-once.ts +107 -13
  53. package/src/pairing.ts +44 -1
  54. package/src/remote-api.ts +14 -1
  55. package/src/tunnel.ts +142 -3
  56. package/lib/types/client/UpdateEntry.d.ts +0 -14
  57. package/lib/types/client/UpdateEntry.d.ts.map +0 -1
  58. package/lib/types/client/UpdateEntry.js +0 -112
  59. package/lib/types/client/UpdatePanel.d.ts +0 -42
  60. package/lib/types/client/UpdatePanel.d.ts.map +0 -1
  61. package/lib/types/client/UpdatePanel.js +0 -101
  62. package/lib/types/client/update-api.d.ts +0 -29
  63. package/lib/types/client/update-api.d.ts.map +0 -1
  64. package/lib/types/client/update-api.js +0 -47
  65. package/lib/types/console-output.d.ts +0 -41
  66. package/lib/types/console-output.d.ts.map +0 -1
  67. package/lib/types/console-output.js +0 -89
  68. package/lib/types/update-routes.d.ts +0 -30
  69. package/lib/types/update-routes.d.ts.map +0 -1
  70. package/lib/types/update-routes.js +0 -47
  71. package/lib/types/update.d.ts +0 -282
  72. package/lib/types/update.d.ts.map +0 -1
  73. package/lib/types/update.js +0 -773
  74. package/src/client/UpdateEntry.tsx +0 -144
  75. package/src/client/UpdatePanel.tsx +0 -256
  76. package/src/client/update-api.ts +0 -49
  77. package/src/console-output.ts +0 -97
  78. package/src/update-routes.ts +0 -65
  79. package/src/update.ts +0 -915
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-remote-web-ui",
3
3
  "description": "Scan-to-pair remote access for the dsh web GUI that shares one official interface: a QR beside the settings button pairs phones and PCs into the same Web GUI (a portrait-touch adaptation layer for phones, full desktop on PCs) through one-time tokens and revocable device sessions, with a LAN bind toggle, optional Cloudflare tunnel, and one-click family self-update",
4
- "version": "0.4.2",
4
+ "version": "0.4.4",
5
5
  "icon": "icon.svg",
6
6
  "type": "module",
7
7
  "engines": {
@@ -38,7 +38,7 @@
38
38
  "license": "Apache-2.0",
39
39
  "dsh": {
40
40
  "engines": {
41
- "dsh": ">=0.1.7-rc.2"
41
+ "dsh": ">=0.2.0-rc.1"
42
42
  },
43
43
  "bundle": {
44
44
  "patch": "./cordis.patch.yml"
@@ -53,36 +53,36 @@
53
53
  }
54
54
  },
55
55
  "dependencies": {
56
+ "@deepseek-ai/schemastery": "^3.18.4",
56
57
  "cloudflared": "^0.7.3",
57
58
  "clsx": "^2.1.1",
58
59
  "qrcode.react": "^4.2.0",
59
- "schemastery": "^3.18.0",
60
60
  "yaml": "^2.8.2",
61
61
  "zod": "^4.4.3"
62
62
  },
63
63
  "peerDependencies": {
64
- "@deepseek-ai/dsh": ">=0.1.7-rc.2",
64
+ "@deepseek-ai/dsh": ">=0.2.0-rc.1",
65
65
  "react": "^18.2.0",
66
66
  "react-dom": "^18.2.0"
67
67
  },
68
68
  "devDependencies": {
69
69
  "@deepseek-ai/cordis": "^4.0.4",
70
- "@deepseek-ai/dsh-agent": "^0.1.7-rc.2",
71
- "@deepseek-ai/dsh-api-remotes": "^0.1.7-rc.2",
72
- "@deepseek-ai/dsh-api-session-controller": "^0.1.7-rc.2",
73
- "@deepseek-ai/dsh-api-workspace-controller": "^0.1.7-rc.2",
74
- "@deepseek-ai/dsh-client-connection": "^0.1.7-rc.2",
75
- "@deepseek-ai/dsh-client-locale": "^0.1.7-rc.2",
76
- "@deepseek-ai/dsh-client-store": "^0.1.7-rc.2",
77
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.7-rc.2",
78
- "@deepseek-ai/dsh-client-ui-renderer": "^0.1.7-rc.2",
79
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.7-rc.2",
80
- "@deepseek-ai/dsh-client-ui-sidebar": "^0.1.7-rc.2",
81
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.7-rc.2",
82
- "@deepseek-ai/dsh-commands": "^0.1.7-rc.2",
83
- "@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.2",
84
- "@deepseek-ai/dsh-invariants": "^0.1.7-rc.2",
85
- "@deepseek-ai/dsh-settings": "^0.1.7-rc.2",
70
+ "@deepseek-ai/dsh-agent": "^0.2.0-rc.1",
71
+ "@deepseek-ai/dsh-api-remotes": "^0.2.0-rc.1",
72
+ "@deepseek-ai/dsh-api-session-controller": "^0.2.0-rc.1",
73
+ "@deepseek-ai/dsh-api-workspace-controller": "^0.2.0-rc.1",
74
+ "@deepseek-ai/dsh-client-connection": "^0.2.0-rc.1",
75
+ "@deepseek-ai/dsh-client-locale": "^0.2.0-rc.1",
76
+ "@deepseek-ai/dsh-client-store": "^0.2.0-rc.1",
77
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.2.0-rc.1",
78
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.2.0-rc.1",
79
+ "@deepseek-ai/dsh-client-ui-settings": "^0.2.0-rc.1",
80
+ "@deepseek-ai/dsh-client-ui-sidebar": "^0.2.0-rc.1",
81
+ "@deepseek-ai/dsh-client-ui-slots": "^0.2.0-rc.1",
82
+ "@deepseek-ai/dsh-commands": "^0.2.0-rc.1",
83
+ "@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.1",
84
+ "@deepseek-ai/dsh-invariants": "^0.2.0-rc.1",
85
+ "@deepseek-ai/dsh-settings": "^0.2.0-rc.1",
86
86
  "@testing-library/dom": "^10.4.1",
87
87
  "@testing-library/react": "^16.3.2",
88
88
  "@types/node": "^22.20.0",
@@ -1,11 +1,11 @@
1
1
  /**
2
- * The sidebar remote-control seat: the update trigger plus the phone-icon
3
- * trigger beside the settings button, and the pairing panel modal. Owns the
4
- * panel behavior — token minting on open, the status SSE subscription,
5
- * stop/refresh/copy — and renders the pure {@link RemotePanel} body. The
6
- * update seat (the dsh-web self-update flow) rides the same footer row,
7
- * rendered by {@link UpdateEntry}. Component-local state per the client
8
- * stack rules: nothing here survives remounts or crosses entries.
2
+ * The sidebar remote-control seat: the phone-icon trigger beside the
3
+ * settings button, and the pairing panel modal. Owns the panel behavior —
4
+ * token minting on open, the status SSE subscription, stop/refresh/copy — and
5
+ * renders the pure {@link RemotePanel} body. The family self-update seat is
6
+ * its own plugin (dsh-update) and no longer rides this row. Component-local
7
+ * state per the client stack rules: nothing here survives remounts or crosses
8
+ * entries.
9
9
  */
10
10
  import { useCallback, useEffect, useRef, useState } from 'react'
11
11
  import { createPortal } from 'react-dom'
@@ -14,7 +14,6 @@ import type { PairingPhase } from '../pairing.ts'
14
14
  import { RemotePanel, type PanelState } from './RemotePanel.tsx'
15
15
  import { copyText, issuePair, revokePair, stopPair, type DeviceFrame, type IssueResponse, type PairStateFrame, type RelayStatusFrame, type TunnelStatusFrame } from './pair-api.ts'
16
16
  import { PhoneIcon } from './PhoneIcon.tsx'
17
- import { UpdateEntry } from './UpdateEntry.tsx'
18
17
  import css from './remote.module.css'
19
18
 
20
19
  /** Entry props: the sidebar column state and the standard locale seat. */
@@ -233,7 +232,6 @@ export function RemoteEntry({ wide, t }: RemoteEntryProps) {
233
232
  return (
234
233
  <>
235
234
  <div className={css.entryRow} data-rail={wide ? undefined : 'rail'}>
236
- <UpdateEntry wide={wide} t={t} />
237
235
  <TooltipAnchor wide={wide} label={t('entry.label')} onClick={openPanel} expanded={open} />
238
236
  </div>
239
237
  {open && createPortal((
@@ -175,8 +175,6 @@ export function RemoteSettingsCard(props: RemoteSettingsCardProps) {
175
175
  descriptionKey="settings.description"
176
176
  defaultOpen={false}
177
177
  state={state}
178
- renderChildrenWhenNotExposed
179
- hideNotExposedNotice
180
178
  onSave={props.save}
181
179
  onDiscard={props.discard}
182
180
  >
@@ -41,6 +41,7 @@ import { FenceNotice } from './FenceNotice.tsx'
41
41
  import { reportDailyHeartbeat } from './telemetry.ts'
42
42
  import { startMobileAdapt, type RemoteAdaptGlobal } from './mobile-adapt.ts'
43
43
  import { installPluginCard } from './plugin-card-seat.ts'
44
+ import { createServedEntryForm } from './settings-entry-form.ts'
44
45
 
45
46
  // Portrait-touch adaptation of the official UI: installed under the plugin
46
47
  // lifecycle (apply) so disabling the plugin in cordis patch (disabled: true)
@@ -51,8 +52,6 @@ export type { PanelState, RemotePanelProps } from './RemotePanel.tsx'
51
52
  export type { PairFailedNoticeProps } from './PairFailedNotice.tsx'
52
53
  export type { RemoteKey } from './locales.ts'
53
54
  export type { RemoteSettingsCardFace, RemoteSettingsCardState } from './RemoteSettingsCard.tsx'
54
- export type { UpdateEntryProps } from './UpdateEntry.tsx'
55
- export type { UpdatePanelProps, UpdateView } from './UpdatePanel.tsx'
56
55
 
57
56
  declare module '@deepseek-ai/dsh-client-ui-slots' {
58
57
  interface LocaleNamespaceMap {
@@ -110,22 +109,70 @@ declare module '@deepseek-ai/cordis' {
110
109
  /** Dictionary namespace owned by this plugin. */
111
110
  const NS = 'remote'
112
111
 
113
- /** Settings namespace the remote-control card edits (the Host plugin registers it). */
112
+ /**
113
+ * Settings namespace the remote-control card edits: the family identity of
114
+ * this plugin's own settings form, and the row id a standalone bundle install
115
+ * carries.
116
+ */
114
117
  const REMOTE_WEB_UI_NS = 'remote-web-ui'
118
+
119
+ /** Profile entry id the family aggregate's generated row carries. */
115
120
  const AGGREGATE_ENTRY_ID = 'web-ui-remote-web-ui'
121
+
122
+ /** Profile entry ids this package's patch rows carry, most specific first. */
116
123
  const REMOTE_WEB_UI_ENTRY_IDS: readonly string[] = [AGGREGATE_ENTRY_ID, 'ui-remote-web-ui', REMOTE_WEB_UI_NS]
117
124
 
118
- function servedEntryId(forms: ConfigForms): string {
125
+ /**
126
+ * The profile entry id this package's own row carries, for a page that serves
127
+ * no family binder.
128
+ *
129
+ * The shared describe mirror answers asynchronously, so at plugin activation it
130
+ * usually holds nothing yet — and an unanswered or empty mirror is not evidence
131
+ * of absence. Binding the bare namespace there left the card bound to an entry
132
+ * the Host does not serve, and the form is bound once per session, so the card
133
+ * never recovered (every save answered `No configurable plugin entry`). The
134
+ * aggregate row id matches nearly every deployment; only a mirror that answers
135
+ * with OTHER plugins' rows falls back to the namespace itself (the pre-0.1.7
136
+ * keying shape), because that answer is the one that actually proves this
137
+ * package's own row is not served.
138
+ * @param forms - the shared configuration forms service.
139
+ * @returns the entry id to bind.
140
+ */
141
+ export function servedEntryId(forms: ConfigForms): string {
119
142
  let served: readonly string[] | undefined
120
143
  try {
121
144
  served = forms.describe().getSnapshot().view?.namespaces.map(view => view.ns)
122
145
  } catch {
146
+ // A mirror that refuses the read is unanswered too, not absent.
123
147
  served = undefined
124
148
  }
125
- if (!served || served.length === 0) return REMOTE_WEB_UI_NS
149
+ if (served === undefined || served.length === 0) return AGGREGATE_ENTRY_ID
126
150
  return REMOTE_WEB_UI_ENTRY_IDS.find(id => served.includes(id)) ?? REMOTE_WEB_UI_NS
127
151
  }
128
152
 
153
+ /**
154
+ * Bind the settings form the remote-control card reads and writes.
155
+ *
156
+ * The family binder comes first: it resolves this package's family namespace
157
+ * onto the profile entry id the Host serves the form under, and keeps the
158
+ * loopback bridge as its own fallback. A page without that group (or one where
159
+ * its client half has not applied yet) binds through the shared forms service,
160
+ * on the entry id the describe mirror justifies and rebound as soon as the
161
+ * mirror answers — see {@link createServedEntryForm}.
162
+ * @param ctx - client context carrying the family binder and/or the shared forms service.
163
+ * @returns the form the card stages and saves through.
164
+ */
165
+ export function bindSettingsForm(ctx: ClientContext): ConfigForm<RemoteSettings> {
166
+ const family = ctx.get('webUiSettings')
167
+ if (family !== undefined && typeof family.bind === 'function') {
168
+ return family.bind<RemoteSettings>({ namespace: REMOTE_WEB_UI_NS })
169
+ }
170
+ return createServedEntryForm<RemoteSettings>({
171
+ forms: ctx.configForms,
172
+ entryIds: REMOTE_WEB_UI_ENTRY_IDS,
173
+ })
174
+ }
175
+
129
176
  /** Heartbeat cadence from a paired phone (presence + revocation liveness). */
130
177
  const HEARTBEAT_INTERVAL_MS = 10_000
131
178
 
@@ -204,10 +251,7 @@ export function apply(ctx: ClientContext): void {
204
251
  // shared form (natively, or over its loopback bridge); without that group the
205
252
  // namespace IS the owning entry id, so the official service is addressed
206
253
  // directly.
207
- const family = ctx.get('webUiSettings')
208
- const settingsForm: ConfigForm<RemoteSettings> = family !== undefined && typeof family.bind === 'function'
209
- ? family.bind<RemoteSettings>({ namespace: REMOTE_WEB_UI_NS })
210
- : ctx.configForms.get<RemoteSettings>(servedEntryId(ctx.configForms))
254
+ const settingsForm: ConfigForm<RemoteSettings> = bindSettingsForm(ctx)
211
255
  const enabled = (): boolean => {
212
256
  const snapshot = settingsForm.getSnapshot()
213
257
  return snapshot.status === 'ready'
@@ -123,48 +123,6 @@ export const zh = {
123
123
  'settings.saveFailed': '部署未接受这些值,已保留供你修改。',
124
124
  'settings.invalidNumber': '请输入数字,留空则使用默认值。',
125
125
  // 更新面板(侧边栏更新按钮)。
126
- 'update.label': '检查更新',
127
- 'update.badge': '有更新',
128
- 'update.availableLabel': '发现新版本,检查更新',
129
- 'update.title': '检查更新',
130
- 'update.checking': '正在检查远程仓库中的新版本…',
131
- 'update.upToDate': '当前已是最新版本',
132
- 'update.upToDateDetail': '{name} 已是最新版本 v{version}。',
133
- 'update.found': '发现新版本',
134
- 'update.foundDetail': '{name} 有新版本 v{version}。确认后开始更新。',
135
- 'update.start': '开始更新',
136
- 'update.updating': '正在更新 {name} 至 v{version},可能需要 1-2 分钟…',
137
- 'update.cooldownNotice': '本次更新由你主动触发,将跳过 pnpm 的 24 小时发布冷却期(供应链保护)直接安装所选版本。',
138
- 'update.updatingTitle': '正在更新…',
139
- 'update.done': '更新完成',
140
- 'update.doneDetail': '所有组件已更新到最新版本。',
141
- 'update.restartHint': '请重启 dsh web 使新版本生效:在终端按 Ctrl+C 停止后重新运行 dsh web。',
142
- 'update.linkMode': '当前为本地开发模式',
143
- 'update.linkModeDetail': 'dsh-web 或其组件通过本地链接安装(开发模式),无法自动更新。npm 上最新版本:{version}。请同步本地仓库,或移除与全家桶重复的独立链接。',
144
- 'update.missing': '未找到 dsh-web 安装',
145
- 'update.missingDetail': '无法定位已安装的 dsh-web 聚合包。请先运行 dsh plugin --profile <profile> add @linxin666/dsh-web-all。',
146
- 'update.offline': '无法连接更新源',
147
- 'update.offlineDetail': '网络不可用或更新服务异常,请稍后重试。',
148
- 'update.unmounted': '更新服务未加载',
149
- 'update.unmountedDetail': '当前 dsh web 进程运行的插件版本不含更新服务,请重启 dsh web(Ctrl+C 后重新运行)后再试。',
150
- 'update.error': '更新失败',
151
- 'update.packages': '组件版本',
152
- 'update.releaseNotes': 'v{version} 更新说明',
153
- 'update.releaseFeatures': '新增功能',
154
- 'update.releaseFixes': '修复',
155
- 'update.releaseOther': '其他变动',
156
- 'update.releaseUnavailable': '本次发布说明暂不可用,可前往 GitHub Release 查看。',
157
- 'update.componentVersions': '查看组件版本',
158
- 'update.retry': '重新检查',
159
- 'update.close': '关闭更新面板',
160
- 'update.error.pnpmMissing': '未找到 pnpm,已尝试 pnpm / corepack / npx 仍不可用。请安装 pnpm 后重启应用(Windows: npm install -g pnpm 或 corepack enable;macOS: brew install pnpm 或 corepack enable)。',
161
- 'update.error.timeout': '更新超时,已终止安装进程,请重试。',
162
- 'update.error.notFound': '未找到 dsh-web 聚合包安装,无法更新。',
163
- 'update.error.link': 'dsh-web 或其组件使用本地链接,无法自动更新;请同步本地仓库,或移除与全家桶重复的独立链接。',
164
- 'update.error.pnpmFailed': '更新执行失败(pnpm 退出码 {code}),详见下方输出。',
165
- 'update.error.stale': '更新命令已执行,但安装版本未变化。常见原因:pnpm 11 的 minimumReleaseAge 门禁会静默跳过发布不足 24 小时的新版本。请在 profile 目录(~/.dsh/profiles/<profile>)的 pnpm-workspace.yaml 中配置 minimumReleaseAgeExclude(例如 "@linxin666/*")或 minimumReleaseAge: 0 后重新点击更新。',
166
- 'update.error.verifyFailed': '更新命令已执行,但无法核对安装后的版本(可能是 registry 探测失败或安装路径变化)。请查看下方输出,或稍后重试。',
167
- 'update.error.unknown': '更新失败,请重试。',
168
126
  } satisfies Record<string, string>
169
127
 
170
128
  /** The remote namespace key union. */
@@ -296,46 +254,4 @@ export const en = {
296
254
  'settings.saveFailed': 'The deployment did not accept these values; they were left for you to correct.',
297
255
  'settings.invalidNumber': 'Enter a number, or leave blank to use the default.',
298
256
  // Update panel (the sidebar update trigger).
299
- 'update.label': 'Check for updates',
300
- 'update.badge': 'Update available',
301
- 'update.availableLabel': 'New version available. Check for updates',
302
- 'update.title': 'Check for updates',
303
- 'update.checking': 'Checking the remote repository for newer releases…',
304
- 'update.upToDate': 'Everything is up to date',
305
- 'update.upToDateDetail': '{name} is at the latest version v{version}.',
306
- 'update.found': 'A new version is available',
307
- 'update.foundDetail': '{name} has a newer release v{version}. Confirm to start the update.',
308
- 'update.start': 'Update now',
309
- 'update.updating': 'Updating {name} to v{version}; this may take 1-2 minutes…',
310
- 'update.cooldownNotice': 'This user-initiated update bypasses pnpm\'s 24-hour release cooldown (supply-chain protection) and installs the selected version immediately.',
311
- 'update.updatingTitle': 'Updating…',
312
- 'update.done': 'Update complete',
313
- 'update.doneDetail': 'All components are up to date.',
314
- 'update.restartHint': 'Restart dsh web for the new version to take effect: stop it with Ctrl+C in the terminal, then run dsh web again.',
315
- 'update.linkMode': 'Local development mode',
316
- 'update.linkModeDetail': 'dsh-web or one of its components is installed via a local link and cannot be updated remotely. Latest npm release: {version}. Sync the local checkout, or remove a standalone link duplicated by the aggregate.',
317
- 'update.missing': 'dsh-web not found',
318
- 'update.missingDetail': 'Could not locate the installed dsh-web aggregate package. Install it with: dsh plugin --profile <profile> add @linxin666/dsh-web-all',
319
- 'update.offline': 'Cannot reach the update source',
320
- 'update.offlineDetail': 'The network is unavailable or the update service is failing; try again later.',
321
- 'update.unmounted': 'The update service is not loaded',
322
- 'update.unmountedDetail': 'The running dsh web process loads a plugin build without the update service; restart dsh web (Ctrl+C, then run dsh web again) and retry.',
323
- 'update.error': 'Update failed',
324
- 'update.packages': 'Component versions',
325
- 'update.releaseNotes': 'v{version} release notes',
326
- 'update.releaseFeatures': 'New Features',
327
- 'update.releaseFixes': 'Bug Fixes',
328
- 'update.releaseOther': 'Other Changes',
329
- 'update.releaseUnavailable': 'Release notes are unavailable for this version; see the GitHub release instead.',
330
- 'update.componentVersions': 'View component versions',
331
- 'update.retry': 'Check again',
332
- 'update.close': 'Close update panel',
333
- 'update.error.pnpmMissing': 'pnpm was not found (tried pnpm / corepack / npx). Install pnpm and restart the app (Windows: npm install -g pnpm or corepack enable; macOS: brew install pnpm or corepack enable).',
334
- 'update.error.timeout': 'The update timed out and the install process was killed; try again.',
335
- 'update.error.notFound': 'The dsh-web aggregate package is not installed; nothing to update.',
336
- 'update.error.link': 'dsh-web or one of its components uses a local link. Sync the checkout, or remove a standalone link duplicated by the aggregate.',
337
- 'update.error.pnpmFailed': 'The update failed (pnpm exited with code {code}); see the output below.',
338
- 'update.error.stale': 'The update command ran but the installed versions did not change. Likely cause: the pnpm 11 minimumReleaseAge gate silently skips releases published less than 24 hours ago. Add minimumReleaseAgeExclude (e.g. "@linxin666/*") or set minimumReleaseAge: 0 in pnpm-workspace.yaml under the profile directory (~/.dsh/profiles/<profile>), then run the update again.',
339
- 'update.error.verifyFailed': 'The update command ran but the installed versions could not be verified afterwards (registry probe failed or the install path changed). Inspect the output below, or retry later.',
340
- 'update.error.unknown': 'Update failed; try again.',
341
257
  } satisfies Record<RemoteKey, string>
@@ -54,6 +54,17 @@ const ADAPT_CSS_ID = 'dsh-remote-web-ui/mobile-adapt.css'
54
54
  const ACTIVE_CLASS = 'dsh-remote-portrait'
55
55
  /** Body class while the collapsed rail is hidden behind the whale. */
56
56
  const RAIL_HIDDEN_CLASS = 'dsh-remote-rail-hidden'
57
+ /**
58
+ * The official sidebar column root. Also the scope of the row sweep: every row
59
+ * the drag suppression covers is a descendant of it, so the document-wide scan
60
+ * it replaces only ever returned these.
61
+ */
62
+ const SIDEBAR_SELECTOR = '[class*="_sidebarCol"]'
63
+ /** The official draggable session/project rows (see disableRowDrag). */
64
+ const ROW_SELECTOR = '[class*="_sessionRow"], [class*="_projectRow"]'
65
+ /** The injected stylesheet, addressed through the same cached lookup. */
66
+ const STYLE_SELECTOR = `style[data-plugin-css="${ADAPT_CSS_ID}"]`
67
+
57
68
  /** Whale button id. */
58
69
  const WHALE_ID = 'dshRemoteWhale'
59
70
  /** Compact picker: synthesized model button id. */
@@ -346,6 +357,8 @@ export function startMobileAdapt(): void {
346
357
  let whaleObserver: MutationObserver | null = null
347
358
  /** Header subtree observer: marks the geometry measurement dirty on re-render. */
348
359
  let headerObserver: MutationObserver | null = null
360
+ /** Document child-list observer: invalidates the cached-absent selector set. */
361
+ let domObserver: MutationObserver | null = null
349
362
  let observedHeader: Element | null = null
350
363
  /** Whether the seated-actions geometry needs re-measuring (see alignActionsText). */
351
364
  let headerGeometryDirty = true
@@ -368,17 +381,28 @@ export function startMobileAdapt(): void {
368
381
  * rail compaction) while the body class stays.
369
382
  */
370
383
  function ensureAdaptStyle(): void {
371
- if (nodeOf(`style[data-plugin-css="${ADAPT_CSS_ID}"]`) !== null) return
384
+ if (nodeOf(STYLE_SELECTOR) !== null) return
372
385
  const tag = document.createElement('style')
373
386
  tag.dataset.plugin = 'remote-web-ui'
374
387
  tag.dataset.pluginCss = ADAPT_CSS_ID
375
388
  tag.textContent = ADAPT_CSS.join('')
376
389
  document.head.appendChild(tag)
390
+ // Seed the lookup cache: the tag is created here, outside nodeOf, so the
391
+ // negative cache could otherwise report it missing for one more tick and
392
+ // stack a duplicate stylesheet.
393
+ nodeCache.set(STYLE_SELECTOR, tag)
394
+ nodeMissCache.delete(STYLE_SELECTOR)
377
395
  }
378
396
 
379
397
  function apply(): void {
380
398
  if (active) return
381
399
  active = true
400
+ // First statement of the activation, before any lookup: nothing observed the
401
+ // document while the layer was reverted (the observer is created below), so
402
+ // no "absent" verdict recorded earlier may be served now. Clearing here
403
+ // rather than beside the observer also covers the lookups this path runs
404
+ // before that point.
405
+ forgetAbsentSelectors()
382
406
  document.body.classList.add(ACTIVE_CLASS)
383
407
  // A details panel opened before the viewport rotated into portrait (or
384
408
  // restored across reloads) would sit behind the display:none above;
@@ -397,6 +421,7 @@ export function startMobileAdapt(): void {
397
421
  meta.setAttribute('content', `${savedViewportContent}, viewport-fit=cover`)
398
422
  }
399
423
  ensureWhale()
424
+ ensureDomObserver()
400
425
  syncWhale()
401
426
  // React builds nodes with attributes before inserting them, so
402
427
  // attribute observers miss the initial collapsed state — a light
@@ -429,6 +454,45 @@ export function startMobileAdapt(): void {
429
454
  whaleObserver.disconnect()
430
455
  whaleObserver = null
431
456
  }
457
+ if (domObserver !== null) {
458
+ domObserver.disconnect()
459
+ domObserver = null
460
+ }
461
+ // Nothing observes the document from here on; a verdict recorded now could
462
+ // not be trusted after the next apply().
463
+ forgetAbsentSelectors()
464
+ }
465
+
466
+ /**
467
+ * Observe the document for the two kinds of change that can make a cached-absent
468
+ * target discoverable again (see nodeOf): node insertion/removal, and the
469
+ * class / compat-stamp attributes the cached selectors match on.
470
+ *
471
+ * Body class writes are excluded by target: the layer toggles three body
472
+ * classes every tick, and counting those would invalidate the negative cache
473
+ * on every tick and restore the very scan this cache removes. They cannot
474
+ * create a target either — every cached selector matches an element other
475
+ * than <body>.
476
+ *
477
+ * The record queue is drained on a microtask, so this stays off the layout
478
+ * path; a chat turn's insertions are a handful per second, not per element.
479
+ */
480
+ function ensureDomObserver(): void {
481
+ if (domObserver !== null || typeof MutationObserver === 'undefined' || !document.body) return
482
+ domObserver = new MutationObserver((records) => {
483
+ for (const record of records) {
484
+ if (record.type === 'childList' || record.target !== document.body) {
485
+ noteDomChange()
486
+ return
487
+ }
488
+ }
489
+ })
490
+ domObserver.observe(document.body, {
491
+ childList: true,
492
+ subtree: true,
493
+ attributes: true,
494
+ attributeFilter: ['class', 'data-dsh-frame'],
495
+ })
432
496
  }
433
497
 
434
498
  /** Start/stop the 600ms sync tick; a no-op when already in the asked state. */
@@ -702,7 +766,7 @@ export function startMobileAdapt(): void {
702
766
  const dragOverridden = new Map<Element, string | null>()
703
767
  function disableRowDrag(): void {
704
768
  if (!active) return
705
- const rows = document.querySelectorAll('[class*="_sidebarCol"] [class*="_sessionRow"], [class*="_sidebarCol"] [class*="_projectRow"]')
769
+ const rows = sidebarRows()
706
770
  for (const row of rows) {
707
771
  if (row.getAttribute('draggable') !== 'false') {
708
772
  if (!dragOverridden.has(row)) dragOverridden.set(row, row.getAttribute('draggable'))
@@ -731,15 +795,90 @@ export function startMobileAdapt(): void {
731
795
  * node on a major re-render, which the isConnected guard detects.
732
796
  */
733
797
  const nodeCache = new Map<string, Element>()
798
+ /**
799
+ * Selectors known to be absent as of {@link domGeneration}. A miss is the
800
+ * common case, not the exception: the layer holds no overlay of its own, and
801
+ * several official surfaces it looks for (tabs row, tools row, sidebar rows)
802
+ * legitimately do not exist at once. Re-walking a conversation-sized document
803
+ * for the same absent selector on every 600ms tick was waste that almost
804
+ * always resolved to null: five document-wide lookups per tick on the measured
805
+ * fixture, zero after this cache. That is a lookup count — the wall-clock
806
+ * saving is real while the mounted content is quiet and shrinks to the
807
+ * row-scope saving when a turn streams (see the Agent Note for the harness and
808
+ * the before/after numbers).
809
+ *
810
+ * A miss is trusted only while the observed content has not changed, so a
811
+ * surface that appears is still found on the very next tick — the same
812
+ * discovery latency the unconditional probe had. The cache is never aged by a
813
+ * clock: it is cleared by {@link noteDomChange} while the observer is live and
814
+ * by {@link forgetAbsentSelectors} across the windows where it is not (apply
815
+ * and revert). One blind spot is deliberate and load-bearing: the observer
816
+ * watches <body>, so a head-resident target is not covered by that signal —
817
+ * the one such target is seeded into the cache where it is created (see
818
+ * ensureAdaptStyle).
819
+ */
820
+ const nodeMissCache = new Set<string>()
821
+ /**
822
+ * Bumped whenever a cached target may have appeared or disappeared. A surface
823
+ * qualifies on a class token or on the aggregate compat stamp
824
+ * (`data-dsh-frame`), so both insertions/removals AND those two attributes
825
+ * can change the answer. Nothing the layer writes per tick lands here: its own
826
+ * class writes are all on <body>, and its row/transform/label writes touch
827
+ * other attributes entirely (see ensureDomObserver).
828
+ */
829
+ let domGeneration = 0
830
+ /** The generation the negative cache was recorded against. */
831
+ let missGeneration = -1
832
+
833
+ function noteDomChange(): void {
834
+ domGeneration += 1
835
+ }
836
+
837
+ /**
838
+ * Drop every cached "absent" verdict. Called on apply and revert: while the
839
+ * observer is disconnected the layer is blind to insertions, so a verdict
840
+ * recorded before the gap could otherwise outlive the change that falsified
841
+ * it and be served without a probe. The positive cache needs no equivalent —
842
+ * its `isConnected` guard already re-resolves a replaced node.
843
+ */
844
+ function forgetAbsentSelectors(): void {
845
+ nodeMissCache.clear()
846
+ domGeneration += 1
847
+ }
848
+
734
849
  function nodeOf(selector: string): Element | null {
735
850
  const cached = nodeCache.get(selector)
736
- if (cached !== undefined && cached.isConnected) return cached
851
+ if (cached !== undefined) {
852
+ // A node React replaced is no longer connected; drop it and fall through
853
+ // so the lookup below can find its replacement.
854
+ if (cached.isConnected) return cached
855
+ nodeCache.delete(selector)
856
+ }
857
+ if (missGeneration !== domGeneration) {
858
+ nodeMissCache.clear()
859
+ missGeneration = domGeneration
860
+ }
861
+ if (nodeMissCache.has(selector)) return null
737
862
  const found = document.querySelector(selector)
738
- if (found === null) nodeCache.delete(selector)
739
- else nodeCache.set(selector, found)
863
+ if (found === null) {
864
+ nodeMissCache.add(selector)
865
+ return null
866
+ }
867
+ nodeCache.set(selector, found)
740
868
  return found
741
869
  }
742
870
 
871
+ /**
872
+ * The session/project rows the drag suppression covers, scoped to the cached
873
+ * sidebar root. Each row carries the sidebar column class as an ancestor, so
874
+ * the document-wide scan this replaces only ever returned rows from here.
875
+ */
876
+ function sidebarRows(): Element[] {
877
+ const sidebar = nodeOf(SIDEBAR_SELECTOR)
878
+ if (sidebar === null) return []
879
+ return Array.from(sidebar.querySelectorAll(ROW_SELECTOR))
880
+ }
881
+
743
882
  /** The official application frame, through the same cached lookup. */
744
883
  function frameEl(): HTMLElement | null {
745
884
  const frame = nodeOf(APP_FRAME_SELECTOR)