dsh-zen-remote 1.1.11 → 1.1.13

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 (85) hide show
  1. package/AGENTS.md +24 -0
  2. package/README.md +7 -3
  3. package/README.zh-CN.md +7 -3
  4. package/docs/interface.md +5 -2
  5. package/dsh-push.mjs +25 -1
  6. package/lan-gate.mjs +16 -1
  7. package/lib/client.js +684 -43
  8. package/lib/lan-gate-server.cjs +107 -4
  9. package/lib/types/client/MobileHome.d.ts +7 -2
  10. package/lib/types/client/MobileHome.d.ts.map +1 -1
  11. package/lib/types/client/MobileSessionInfo.d.ts +2 -2
  12. package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
  13. package/lib/types/client/compat/store.d.ts +62 -0
  14. package/lib/types/client/compat/store.d.ts.map +1 -0
  15. package/lib/types/client/compat/types.d.ts +84 -0
  16. package/lib/types/client/compat/types.d.ts.map +1 -0
  17. package/lib/types/client/debug.d.ts +1 -1
  18. package/lib/types/client/debug.d.ts.map +1 -1
  19. package/lib/types/client/effects/aionui-compat.d.ts +1 -1
  20. package/lib/types/client/effects/aionui-compat.d.ts.map +1 -1
  21. package/lib/types/client/effects/gestures.d.ts +1 -1
  22. package/lib/types/client/effects/gestures.d.ts.map +1 -1
  23. package/lib/types/client/effects/header-status.d.ts +1 -1
  24. package/lib/types/client/effects/header-status.d.ts.map +1 -1
  25. package/lib/types/client/effects/keyboard-avoid.d.ts +1 -1
  26. package/lib/types/client/effects/keyboard-avoid.d.ts.map +1 -1
  27. package/lib/types/client/effects/keyboard-guard.d.ts +1 -1
  28. package/lib/types/client/effects/keyboard-guard.d.ts.map +1 -1
  29. package/lib/types/client/effects/modal-back.d.ts +1 -1
  30. package/lib/types/client/effects/modal-back.d.ts.map +1 -1
  31. package/lib/types/client/effects/model-sheet-extras.d.ts +33 -0
  32. package/lib/types/client/effects/model-sheet-extras.d.ts.map +1 -0
  33. package/lib/types/client/effects/native-trigger-overlay.d.ts +1 -1
  34. package/lib/types/client/effects/native-trigger-overlay.d.ts.map +1 -1
  35. package/lib/types/client/effects/phone-chrome.d.ts +1 -1
  36. package/lib/types/client/effects/phone-chrome.d.ts.map +1 -1
  37. package/lib/types/client/effects/turn-fold.d.ts +1 -1
  38. package/lib/types/client/effects/turn-fold.d.ts.map +1 -1
  39. package/lib/types/client/effects/welcome-notice.d.ts +1 -1
  40. package/lib/types/client/effects/welcome-notice.d.ts.map +1 -1
  41. package/lib/types/client/effects/workbench-ref-close.d.ts +1 -1
  42. package/lib/types/client/effects/workbench-ref-close.d.ts.map +1 -1
  43. package/lib/types/client/index.d.ts +1 -1
  44. package/lib/types/client/index.d.ts.map +1 -1
  45. package/lib/types/client/nav-store.d.ts +2 -2
  46. package/lib/types/client/nav-store.d.ts.map +1 -1
  47. package/lib/types/client/session-dot.d.ts +11 -3
  48. package/lib/types/client/session-dot.d.ts.map +1 -1
  49. package/lib/types/client/styles/compat.css.d.ts +1 -1
  50. package/lib/types/client/styles/compat.css.d.ts.map +1 -1
  51. package/lib/types/client/styles/composer.css.d.ts +1 -1
  52. package/lib/types/client/styles/composer.css.d.ts.map +1 -1
  53. package/lib/types/client/styles/info.css.d.ts +1 -1
  54. package/lib/types/client/styles/info.css.d.ts.map +1 -1
  55. package/package.json +49 -26
  56. package/pwa/app.css +47 -7
  57. package/scripts/check-client-externals.mjs +48 -0
  58. package/scripts/check-compat-store.mjs +113 -0
  59. package/scripts/check-host-shadow.mjs +145 -0
  60. package/scripts/check-info-sheet-stacking.mjs +55 -0
  61. package/src/client/MobileAttachButton.tsx +1 -1
  62. package/src/client/MobileHome.tsx +33 -7
  63. package/src/client/MobileSessionInfo.tsx +6 -4
  64. package/src/client/chips-store.ts +1 -1
  65. package/src/client/compat/store.ts +205 -0
  66. package/src/client/compat/types.ts +115 -0
  67. package/src/client/debug.ts +1 -1
  68. package/src/client/effects/aionui-compat.ts +1 -1
  69. package/src/client/effects/gestures.ts +1 -1
  70. package/src/client/effects/header-status.ts +22 -3
  71. package/src/client/effects/keyboard-avoid.ts +1 -1
  72. package/src/client/effects/keyboard-guard.ts +1 -1
  73. package/src/client/effects/modal-back.ts +1 -1
  74. package/src/client/effects/model-sheet-extras.ts +159 -0
  75. package/src/client/effects/native-trigger-overlay.ts +1 -1
  76. package/src/client/effects/phone-chrome.ts +1 -1
  77. package/src/client/effects/turn-fold.ts +1 -1
  78. package/src/client/effects/welcome-notice.ts +1 -1
  79. package/src/client/effects/workbench-ref-close.ts +1 -1
  80. package/src/client/index.tsx +70 -6
  81. package/src/client/nav-store.ts +2 -2
  82. package/src/client/session-dot.ts +13 -4
  83. package/src/client/styles/compat.css.ts +43 -11
  84. package/src/client/styles/composer.css.ts +184 -9
  85. package/src/client/styles/info.css.ts +21 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-zen-remote",
3
- "version": "1.1.11",
3
+ "version": "1.1.13",
4
4
  "description": "把 DeepSeek Harness 变成手机 App:移动端界面重排(两级页面栈、会话列表主屏、composer 重排、手势、附件上传)+ 配对码认证网关 + PWA 安装 + 真 Web Push。Complete mobile plugin for DeepSeek Harness (DSH): app-shell mobile UI, pairing-code gateway for public-internet access behind your own reverse proxy, installable PWA, offline service worker, and real Web Push agent-done notifications.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -43,10 +43,7 @@
43
43
  "patch": "./cordis.patch.yml"
44
44
  },
45
45
  "client": {
46
- "platform": "web",
47
- "inject": [
48
- "@deepseek-ai/dsh-client-runtime"
49
- ]
46
+ "platform": "web"
50
47
  }
51
48
  },
52
49
  "keywords": [
@@ -82,18 +79,18 @@
82
79
  },
83
80
  "peerDependencies": {
84
81
  "@deepseek-ai/cordis": "^4.0.1",
85
- "@deepseek-ai/dsh-client-locale": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.0 <0.2.0-0",
86
- "@deepseek-ai/dsh-client-runtime": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.0 <0.2.0-0",
87
- "@deepseek-ai/dsh-client-ui-conversation": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.0 <0.2.0-0",
88
- "@deepseek-ai/dsh-client-ui-layout": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.0 <0.2.0-0",
89
- "@deepseek-ai/dsh-client-ui-primitives": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.0 <0.2.0-0",
90
- "@deepseek-ai/dsh-client-ui-sidebar": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.0 <0.2.0-0",
91
- "@deepseek-ai/dsh-client-ui-slots": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.0 <0.2.0-0",
92
- "@deepseek-ai/dsh-host-webserver": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.0 <0.2.0-0",
93
- "@deepseek-ai/dsh-session": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.0 <0.2.0-0",
94
- "@deepseek-ai/dsh-session-log-export": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.0 <0.2.0-0",
82
+ "@deepseek-ai/dsh-client-locale": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.0 <0.2.0-0 || >=0.1.2-rc.0 <0.2.0-0",
83
+ "@deepseek-ai/dsh-client-ui-conversation": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.0 <0.2.0-0 || >=0.1.2-rc.0 <0.2.0-0",
84
+ "@deepseek-ai/dsh-client-ui-layout": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.0 <0.2.0-0 || >=0.1.2-rc.0 <0.2.0-0",
85
+ "@deepseek-ai/dsh-client-ui-primitives": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.0 <0.2.0-0 || >=0.1.2-rc.0 <0.2.0-0",
86
+ "@deepseek-ai/dsh-client-ui-sidebar": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.0 <0.2.0-0 || >=0.1.2-rc.0 <0.2.0-0",
87
+ "@deepseek-ai/dsh-client-ui-slots": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.0 <0.2.0-0 || >=0.1.2-rc.0 <0.2.0-0",
88
+ "@deepseek-ai/dsh-host-webserver": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.0 <0.2.0-0 || >=0.1.2-rc.0 <0.2.0-0",
89
+ "@deepseek-ai/dsh-session": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.0 <0.2.0-0 || >=0.1.2-rc.0 <0.2.0-0",
90
+ "@deepseek-ai/dsh-session-log-export": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.0 <0.2.0-0 || >=0.1.2-rc.0 <0.2.0-0",
95
91
  "react": "^18.2.0",
96
- "@deepseek-ai/dsh-tools": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0"
92
+ "@deepseek-ai/dsh-tools": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0 || >=0.1.2-rc.0 <0.2.0-0",
93
+ "@deepseek-ai/dsh-workspace": ">=0.1.1-rc.0 <0.2.0-0 || >=0.1.2-rc.0 <0.2.0-0"
97
94
  },
98
95
  "peerDependenciesMeta": {
99
96
  "@deepseek-ai/dsh-host-webserver": {
@@ -104,26 +101,52 @@
104
101
  },
105
102
  "@deepseek-ai/dsh-tools": {
106
103
  "optional": true
104
+ },
105
+ "@deepseek-ai/dsh-workspace": {
106
+ "optional": true
107
107
  }
108
108
  },
109
109
  "devDependencies": {
110
- "@deepseek-ai/dsh-api-remotes": "0.1.1-rc.2",
111
- "@deepseek-ai/dsh-client-connection": "0.1.1-rc.2",
112
- "@deepseek-ai/dsh-host-apiproxy": "0.1.1-rc.2",
113
- "@deepseek-ai/dsh-host-webserver": "0.1.1-rc.2",
114
- "@deepseek-ai/dsh-session": "0.1.1-rc.2",
115
- "@deepseek-ai/dsh-session-stats": "0.1.1-rc.2",
116
- "@deepseek-ai/dsh-token-meter": "0.1.1-rc.2",
110
+ "@deepseek-ai/cordis": "4.0.2",
111
+ "@deepseek-ai/cosmokit": "^1.8.3",
112
+ "@deepseek-ai/dsh-api-remotes": "0.1.2-rc.1",
113
+ "@deepseek-ai/dsh-api-session-controller": "0.1.2-rc.1",
114
+ "@deepseek-ai/dsh-api-workspace-controller": "0.1.2-rc.1",
115
+ "@deepseek-ai/dsh-brand": "0.1.2-rc.1",
116
+ "@deepseek-ai/dsh-client-connection": "0.1.2-rc.1",
117
+ "@deepseek-ai/dsh-client-locale": "0.1.2-rc.1",
118
+ "@deepseek-ai/dsh-client-store": "0.1.2-rc.1",
119
+ "@deepseek-ai/dsh-client-ui-conversation": "0.1.2-rc.1",
120
+ "@deepseek-ai/dsh-client-ui-layout": "0.1.2-rc.1",
121
+ "@deepseek-ai/dsh-client-ui-primitives": "0.1.2-rc.1",
122
+ "@deepseek-ai/dsh-client-ui-renderer": "0.1.2-rc.1",
123
+ "@deepseek-ai/dsh-client-ui-session": "0.1.2-rc.1",
124
+ "@deepseek-ai/dsh-client-ui-sidebar": "0.1.2-rc.1",
125
+ "@deepseek-ai/dsh-client-ui-slots": "0.1.2-rc.1",
126
+ "@deepseek-ai/dsh-host-webserver": "0.1.2-rc.1",
127
+ "@deepseek-ai/dsh-llm": "0.1.2-rc.1",
128
+ "@deepseek-ai/dsh-scope": "0.1.2-rc.1",
129
+ "@deepseek-ai/dsh-session": "0.1.2-rc.1",
130
+ "@deepseek-ai/dsh-session-log-export": "0.1.2-rc.1",
131
+ "@deepseek-ai/dsh-session-stats": "0.1.2-rc.1",
132
+ "@deepseek-ai/dsh-subagent": "0.1.2-rc.1",
133
+ "@deepseek-ai/dsh-timeout": "0.1.2-rc.1",
134
+ "@deepseek-ai/dsh-token-meter": "0.1.2-rc.1",
135
+ "@deepseek-ai/dsh-tools": "0.1.2-rc.1",
136
+ "@deepseek-ai/dsh-typert-protocol": "0.1.2-rc.1",
137
+ "@deepseek-ai/dsh-util-crypto": "0.1.2-rc.1",
138
+ "@deepseek-ai/dsh-util-values": "0.1.2-rc.1",
139
+ "@deepseek-ai/dsh-workspace": "0.1.2-rc.1",
140
+ "@deepseek-ai/schemastery": "^3.18.1",
117
141
  "@types/node": "^26.2.0",
118
142
  "@types/react": "~18.3.1",
119
143
  "@types/react-dom": "~18.3.0",
120
- "typescript": "^6.0.3",
121
- "@deepseek-ai/dsh-tools": "0.1.1-rc.2"
144
+ "typescript": "^6.0.3"
122
145
  },
123
146
  "scripts": {
124
147
  "build": "tsc -p tsconfig.json && tsc -p tsconfig.client.json && node scripts/build-client.mjs",
125
148
  "verify": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.client.json --noEmit",
126
- "test": "node --test \"test/*.test.cjs\" && node scripts/check-sunk-viewport.mjs && node scripts/check-keyboard-avoid.mjs && node scripts/check-attach-upload.mjs && node scripts/check-upload-endpoint.mjs && node scripts/sync-doc-version.mjs --check",
149
+ "test": "node --test \"test/*.test.cjs\" && node scripts/check-sunk-viewport.mjs && node scripts/check-keyboard-avoid.mjs && node scripts/check-attach-upload.mjs && node scripts/check-upload-endpoint.mjs && node scripts/check-host-shadow.mjs && node scripts/check-compat-store.mjs && node scripts/check-client-externals.mjs && node scripts/check-info-sheet-stacking.mjs && node scripts/sync-doc-version.mjs --check",
127
150
  "version": "node scripts/sync-doc-version.mjs && git add README.md README.zh-CN.md"
128
151
  }
129
152
  }
package/pwa/app.css CHANGED
@@ -50,13 +50,6 @@ html:not([data-lan-device="desktop"]) body {
50
50
  overscroll-behavior-y: none;
51
51
  }
52
52
 
53
- /* iOS Safari zooms the whole page when a focused input's font-size is below
54
- * 16px. dsh-mobile-nav does not add this workaround, so it stays here. */
55
- html:not([data-lan-device="desktop"]) [data-slot="conversation"] input,
56
- html:not([data-lan-device="desktop"]) [data-slot="conversation"] textarea {
57
- font-size: 16px;
58
- }
59
-
60
53
  /* Apply the pinch-to-resize font scale. touch-gestures.js sets
61
54
  * --dsh-pwa-font-scale via root.style.setProperty() on pinch; this is this
62
55
  * plugin's own gesture feature, not a dsh-mobile-nav concern. */
@@ -67,6 +60,53 @@ html:not([data-lan-device="desktop"]) [data-slot="conversation"] [data-phase] {
67
60
  font-size: calc(1em * var(--dsh-pwa-font-scale));
68
61
  }
69
62
 
63
+ /* iOS Safari zooms the whole page when a focused input's font-size is below
64
+ * 16px, and it does NOT zoom back out when focus leaves — the page just stays
65
+ * magnified until the user pinches it back (reported 2026-09-06: the UI came
66
+ * back enlarged after a trip through a subagent session, and only leaving to
67
+ * the session list and re-entering cleared it).
68
+ *
69
+ * This was scoped to [data-slot="conversation"], which is an exact attribute
70
+ * match: it covers inputs inside the element whose slot is literally
71
+ * "conversation" and nothing in the dozens of conversation.* subtrees around
72
+ * it. Measured on the running UI: one input matched, while a visible 12px
73
+ * text field sat outside the rule — and the composer never triggered the zoom
74
+ * at all because it is contenteditable, not an input, which is why this went
75
+ * unnoticed for so long. So the guard now covers the whole mobile shell.
76
+ *
77
+ * File / checkbox / radio are excluded: they carry no text, the file inputs
78
+ * here are hidden anyway, and forcing 16px on them only disturbs layout.
79
+ *
80
+ * `[contenteditable]` is in the list because the thing users actually focus
81
+ * — the message composer — is one: a contenteditable div with role=textbox,
82
+ * not an input or a textarea. iOS treats it like any other editing host and
83
+ * zooms on it just the same, so covering only inputs left the single most
84
+ * focused element on the page unprotected, which is why widening this rule to
85
+ * every input still did not stop the page coming back magnified (2026-09-06).
86
+ * Measured before changing it: the composer computes 14px, and raising it to
87
+ * 16px moves no geometry at all — its line-height is a fixed 24px rather than
88
+ * a ratio, so the line box, the editor height and the card height are
89
+ * identical either way, and section 5 of composer.css.ts can go on counting
90
+ * lines in 24px units.
91
+ *
92
+ * It has to come AFTER the pinch-scale rule above, and say max() rather than
93
+ * a flat 16px. The composer's editing host carries `data-phase` of its own,
94
+ * so `[data-slot="conversation"] [data-phase]` matches it too — at exactly the
95
+ * same specificity as this rule (0,3,1). Source order was therefore the whole
96
+ * ballgame, and with this block sitting first the pinch rule quietly won and
97
+ * put the composer back to 1em. That is why the composer stayed at 14px even
98
+ * after this selector learned about contenteditable, and why the page went on
99
+ * zooming (measured 2026-09-06: both rules matched, the later one applied).
100
+ * max() keeps the two goals from fighting — a hard 16px floor so iOS never
101
+ * zooms, and everything above it still scaling with the pinch gesture.
102
+ *
103
+ * dsh-mobile-nav does not add this workaround, so it stays here. */
104
+ html:not([data-lan-device="desktop"]) input:not([type="file"]):not([type="checkbox"]):not([type="radio"]),
105
+ html:not([data-lan-device="desktop"]) textarea,
106
+ html:not([data-lan-device="desktop"]) [contenteditable]:not([contenteditable="false"]) {
107
+ font-size: max(16px, calc(1em * var(--dsh-pwa-font-scale, 1)));
108
+ }
109
+
70
110
  /* Home-indicator clearance for the composer USED to live here, as
71
111
  * [data-slot="conversation.composer"], [data-slot="conversation.input.bar"]
72
112
  * { padding-bottom: max(env(safe-area-inset-bottom), 8px) }
@@ -0,0 +1,48 @@
1
+ // Machine evidence for "the client half no longer requires anything that a
2
+ // DSH version could fail to provide" (PLAN §3.1 verification): scan
3
+ // lib/client.js for every non-relative require() specifier and assert the
4
+ // set is within the small allowlist below.
5
+ //
6
+ // Why this matters: the browser resolves these names against the host's
7
+ // module table (window.__ModuleLoader__). A name the table does not know
8
+ // throws when the plugin factory runs, taking the WHOLE plugin down with it.
9
+ // The seed words BOTH DSH versions statically provide are exactly the four
10
+ // allowlisted entries — anything else here means the plugin stopped being
11
+ // version-agnostic (or someone added a require the bundler did not inline).
12
+ //
13
+ // Run: node scripts/check-client-externals.mjs
14
+ import { readFileSync } from 'node:fs'
15
+ import { dirname, join } from 'node:path'
16
+ import { fileURLToPath } from 'node:url'
17
+
18
+ const clientJs = join(dirname(dirname(fileURLToPath(import.meta.url))), 'lib', 'client.js')
19
+
20
+ const ALLOWED = [
21
+ 'react',
22
+ 'react/jsx-runtime',
23
+ '@deepseek-ai/dsh-client-ui-primitives',
24
+ '@deepseek-ai/dsh-client-ui-slots',
25
+ ]
26
+
27
+ const specifiers = new Set()
28
+ const source = readFileSync(clientJs, 'utf8')
29
+ for (const match of source.matchAll(/require\((['"])([^'"]+)\1\)/g)) {
30
+ const specifier = match[2]
31
+ if (specifier.startsWith('.')) continue // relative module — bundled inline
32
+ specifiers.add(specifier)
33
+ }
34
+
35
+ const extra = [...specifiers].filter((specifier) => !ALLOWED.includes(specifier))
36
+ if (extra.length > 0) {
37
+ console.error(`check-client-externals: FAIL — lib/client.js requires packages outside the allowlist:
38
+ ${extra.join('\n ')}
39
+
40
+ These names are fetched from the host browser module table at runtime; a name
41
+ the table does not know throws "missed the module table" and the plugin does
42
+ not load at all. The only names BOTH DSH 0.1.1 and 0.1.2 seed statically are
43
+ the four allowlisted ones. Add new host requires only after confirming the
44
+ name is a seed word in every supported version.`)
45
+ process.exit(1)
46
+ }
47
+
48
+ console.log('check-client-externals: ok')
@@ -0,0 +1,113 @@
1
+ // Behaviour check for the self-written store compat layer
2
+ // (src/client/compat/store.ts): workspaceTitleOf edge cases, snapshot-store
3
+ // update/subscribe semantics, defineStore handle/actions wiring, and
4
+ // whole-value persistence against a fake localStorage.
5
+ //
6
+ // Run: node scripts/check-compat-store.mjs (needs Node >= 23.6 type stripping)
7
+ import assert from 'node:assert/strict'
8
+ import {
9
+ createSnapshotStore,
10
+ defineStore,
11
+ workspaceTitleOf,
12
+ } from '../src/client/compat/store.ts'
13
+
14
+ // ---- 1. workspaceTitleOf ---------------------------------------------------
15
+ assert.equal(workspaceTitleOf('/a/b/c'), 'c')
16
+ assert.equal(workspaceTitleOf('/a/b/c/'), 'c')
17
+ assert.equal(workspaceTitleOf('C:\\x\\y'), 'y')
18
+ assert.equal(workspaceTitleOf('/'), '')
19
+
20
+ // ---- 2. createSnapshotStore basics ------------------------------------------
21
+ const init = { n: 1 }
22
+ const store = createSnapshotStore(init)
23
+ const initialRef = store.getSnapshot()
24
+ assert.deepEqual(initialRef, { n: 1 })
25
+ assert.equal(store.getSnapshot(), initialRef, 'getSnapshot must return the same reference until an update')
26
+
27
+ let calls = 0
28
+ const unsubscribe = store.subscribe(() => { calls += 1 })
29
+ store.update((draft) => { draft.n = 2 })
30
+ assert.equal(store.getSnapshot().n, 2, 'update must apply the mutation')
31
+ assert.notEqual(store.getSnapshot(), initialRef, 'update must swap in a new reference')
32
+ assert.equal(calls, 1, 'subscriber must be called exactly once per update')
33
+ assert.deepEqual(init, { n: 1 }, 'the shallow copy must protect the initial state from the mutator')
34
+
35
+ unsubscribe()
36
+ store.update((draft) => { draft.n = 3 })
37
+ assert.equal(calls, 1, 'unsubscribed callbacks must not be called again')
38
+
39
+ // ---- 3. defineStore basics ---------------------------------------------------
40
+ // Same declaration shape as nav-store.ts: flat { view, workspace } state.
41
+ const decl = {
42
+ init: () => ({ view: 'home', workspace: null }),
43
+ actions: {
44
+ show: (draft, view) => {
45
+ draft.view = view
46
+ },
47
+ },
48
+ }
49
+ const handle = defineStore(decl)
50
+ assert.equal(handle.spec, decl, 'handle carries the declaration as its spec')
51
+ const instance = handle.create()
52
+ assert.equal(typeof instance.getSnapshot, 'function')
53
+ assert.equal(typeof instance.store.getSnapshot, 'function', 'instance must carry the raw snapshot store')
54
+ let viewCalls = 0
55
+ const off = instance.subscribe(() => { viewCalls += 1 })
56
+ instance.actions.show('session')
57
+ assert.equal(instance.getSnapshot().view, 'session', 'baked action must move the view')
58
+ assert.equal(viewCalls, 1, 'baked action must notify exactly once')
59
+ off()
60
+
61
+ // ---- 4. persistence against a fake localStorage -------------------------------
62
+ const memory = new Map()
63
+ let failWrites = false
64
+ globalThis.localStorage = {
65
+ getItem: (key) => (memory.has(key) ? memory.get(key) : null),
66
+ setItem: (key, value) => {
67
+ if (failWrites) throw new Error('quota exceeded')
68
+ memory.set(key, String(value))
69
+ },
70
+ removeItem: (key) => {
71
+ memory.delete(key)
72
+ },
73
+ }
74
+
75
+ const persisted = defineStore({
76
+ persist: 'k',
77
+ init: () => ({ view: 'home', workspace: null }),
78
+ actions: {
79
+ show: (draft, view) => {
80
+ draft.view = view
81
+ },
82
+ },
83
+ })
84
+ const pInstance = persisted.create()
85
+ pInstance.actions.show('session')
86
+ assert.deepEqual(JSON.parse(memory.get('k')), { view: 'session', workspace: null },
87
+ 'the persisted value must be the whole state as JSON, not a spread object')
88
+
89
+ const scoped = persisted.create('sess-1')
90
+ scoped.actions.show('session')
91
+ assert.ok(memory.has('k'), 'the root-scope key must still be stored')
92
+ assert.deepEqual(JSON.parse(memory.get('k.sess-1')), { view: 'session', workspace: null },
93
+ 'a scoped instance must persist under `${decl.persist}.${scopeKey}`')
94
+
95
+ scoped.clearPersisted()
96
+ assert.ok(!memory.has('k.sess-1'), 'clearPersisted must drop exactly the scoped key')
97
+ assert.ok(memory.has('k'), 'clearPersisted must not touch other keys')
98
+
99
+ // A throwing setItem (quota / private mode) must not break the store. The
100
+ // store logs the failure through console.error by design; silence it so the
101
+ // check output stays readable.
102
+ failWrites = true
103
+ const realError = console.error
104
+ console.error = () => {}
105
+ try {
106
+ assert.doesNotThrow(() => { pInstance.actions.show('home') }, 'a failed persistence write must not throw')
107
+ } finally {
108
+ console.error = realError
109
+ }
110
+ assert.equal(pInstance.getSnapshot().view, 'home', 'the in-memory update still applies when persisting fails')
111
+ failWrites = false
112
+
113
+ console.log('check-compat-store: ok')
@@ -0,0 +1,145 @@
1
+ // Shadow check: every @deepseek-ai/* package the host half loads at runtime
2
+ // must come from the DSH profile — except the ones explicitly registered in
3
+ // SHADOW_ALLOWED below.
4
+ //
5
+ // Node resolves imports starting at the importing file's directory, so any
6
+ // @deepseek-ai package physically present in <plugin>/node_modules wins over
7
+ // the copy the DSH profile provides. With a 0.1.1 profile installed that
8
+ // means the host half silently loads whatever version this plugin last
9
+ // installed — "it links and starts" proves nothing.
10
+ //
11
+ // Instead of hand-maintaining a package list, the three files the host half
12
+ // actually loads (dsh-push.mjs, lan-gate.mjs, lib/index.js) are scanned for
13
+ // @deepseek-ai specifiers — static import, dynamic import() and require() —
14
+ // so a new import added there gets watched automatically. src/index.ts is
15
+ // type-only imports (erased at runtime) and the gateway subprocess uses only
16
+ // stdlib + web-push, so this scan is the whole runtime story.
17
+ //
18
+ // Run: node scripts/check-host-shadow.mjs
19
+ import assert from 'node:assert/strict'
20
+ import { existsSync, readFileSync } from 'node:fs'
21
+ import { createRequire } from 'node:module'
22
+ import { dirname, join, sep } from 'node:path'
23
+ import { fileURLToPath } from 'node:url'
24
+
25
+ const pluginNodeModules = join(dirname(dirname(fileURLToPath(import.meta.url))), 'node_modules')
26
+ const require = createRequire(import.meta.url)
27
+
28
+ // Packages that are ALLOWED to sit in this plugin's node_modules and shadow
29
+ // the profile's copy. Every entry must carry its justification here.
30
+ //
31
+ // @deepseek-ai/dsh-tools:
32
+ // - Why allowed at all: test/push-tool.test.cjs and test/push.test.cjs import
33
+ // dsh-push.mjs in-process, and dsh-push.mjs does
34
+ // `import { defineTool } from '@deepseek-ai/dsh-tools'` statically. The
35
+ // host `tools` service only exposes register(definition) — there is no way
36
+ // to obtain defineTool from the host, so this import cannot be removed and
37
+ // the package must be resolvable for the tests to load the module.
38
+ // - Why 0.1.2-rc.1: kept on the same release line as every other devDep.
39
+ // 0.1.1's tools statically imports dsh-session, and dsh-session is pinned
40
+ // at 0.1.2 here — mixing the two lines inside one process is harder to
41
+ // debug than the shadow itself. 0.1.2's tools no longer statically imports
42
+ // dsh-session, so the whole closure stays on the 0.1.2 line.
43
+ // - WHEN TO ACT: this machine's production DSH still runs 0.1.1 and the
44
+ // plugin is installed into its profile via link:, so this shadow is live
45
+ // in production — the host loads 0.1.2's defineTool today. Decide whether
46
+ // that is acceptable BEFORE merging (options: upgrade production to 0.1.2
47
+ // first, or temporarily accept the drift).
48
+ // - npm-published tarballs contain no node_modules, so this shadow only
49
+ // exists on dev machines where the plugin is link-installed.
50
+ const SHADOW_ALLOWED = ['@deepseek-ai/dsh-tools']
51
+
52
+ // Packages that must never exist in this plugin's node_modules at all: DSH
53
+ // 0.1.2 dropped them, and a stale copy would let the type check quietly pass
54
+ // against outdated declarations. NOT exemptible via SHADOW_ALLOWED.
55
+ const BANNED_PACKAGES = ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-host-apiproxy']
56
+
57
+ // The three files the host half actually loads.
58
+ const HOST_ENTRY_FILES = ['dsh-push.mjs', 'lan-gate.mjs', 'lib/index.js']
59
+
60
+ // Matches the specifier of real module statements only — `... from 'pkg'`,
61
+ // `import 'pkg'`, `import('pkg')`, `require('pkg')` — so @deepseek-ai names
62
+ // that merely appear in prose comments don't pollute the set. Scoped
63
+ // subpaths (`@deepseek-ai/x/client`) are trimmed back to the package name.
64
+ const IMPORT_SPECIFIER = /(?:\bfrom\s*|\bimport\s*\(?\s*|\brequire\s*\(\s*)['"](@deepseek-ai\/[^'"\s]+)['"]/g
65
+
66
+ const packageNameOf = (specifier) => specifier.split('/').slice(0, 2).join('/')
67
+
68
+ /** Scan the host entry files for @deepseek-ai import/require specifiers. */
69
+ function collectHostRuntimePackages() {
70
+ const found = new Map() // package name -> files referencing it
71
+ for (const file of HOST_ENTRY_FILES) {
72
+ const path = join(dirname(dirname(fileURLToPath(import.meta.url))), file)
73
+ if (!existsSync(path)) {
74
+ console.error(`check-host-shadow: FAIL — host entry file is missing: ${file}
75
+ The scan cannot guard what it cannot read. Restore the file (lib/ is committed) and re-run.`)
76
+ process.exit(1)
77
+ }
78
+ for (const [, specifier] of readFileSync(path, 'utf8').matchAll(IMPORT_SPECIFIER)) {
79
+ const name = packageNameOf(specifier)
80
+ if (!found.has(name)) found.set(name, [])
81
+ if (!found.get(name).includes(file)) found.get(name).push(file)
82
+ }
83
+ }
84
+ return found
85
+ }
86
+
87
+ const insidePluginNodeModules = (resolved) => resolved.startsWith(pluginNodeModules + sep)
88
+
89
+ /** Version of the copy sitting in the plugin's own node_modules, if any. */
90
+ function pluginCopyVersion(packageName) {
91
+ try {
92
+ return JSON.parse(readFileSync(join(pluginNodeModules, ...packageName.split('/'), 'package.json'), 'utf8')).version
93
+ } catch {
94
+ return undefined
95
+ }
96
+ }
97
+
98
+ let failed = false
99
+ const runtimePackages = collectHostRuntimePackages()
100
+
101
+ for (const packageName of BANNED_PACKAGES) {
102
+ if (!existsSync(join(pluginNodeModules, ...packageName.split('/')))) continue
103
+ failed = true
104
+ console.error(`check-host-shadow: FAIL — ${packageName} is inside this plugin's node_modules.
105
+ DSH 0.1.2 no longer has this package; a stale copy here would let the type
106
+ check quietly pass against outdated declarations. Remove it from
107
+ devDependencies and run pnpm install again. This one is not exemptible via
108
+ SHADOW_ALLOWED.`)
109
+ }
110
+
111
+ for (const [packageName, files] of runtimePackages) {
112
+ let resolved
113
+ try {
114
+ resolved = require.resolve(packageName)
115
+ } catch (error) {
116
+ if (error?.code === 'MODULE_NOT_FOUND') continue // desired: the profile provides it
117
+ // The package exists but its entry could not be located through the
118
+ // "require" condition; the presence probe decides the verdict instead.
119
+ assert.match(error?.code ?? '', /^ERR_(PACKAGE_PATH_NOT_EXPORTED|INVALID_PACKAGE_TARGET)$/,
120
+ `unexpected error resolving ${packageName}: ${error?.message}`)
121
+ if (!existsSync(join(pluginNodeModules, ...packageName.split('/')))) continue
122
+ resolved = join(pluginNodeModules, ...packageName.split('/'))
123
+ }
124
+ if (!insidePluginNodeModules(resolved) && !existsSync(join(pluginNodeModules, ...packageName.split('/')))) continue
125
+
126
+ if (SHADOW_ALLOWED.includes(packageName)) {
127
+ console.log(`note: ${packageName}@${pluginCopyVersion(packageName) ?? 'unknown'} 是已登记的遮蔽(见脚本注释)`)
128
+ continue
129
+ }
130
+
131
+ failed = true
132
+ console.error(`check-host-shadow: FAIL — ${packageName} is inside this plugin's node_modules
133
+ (imported by ${files.join(', ')})
134
+ ${resolved}
135
+
136
+ That shadows the real package in the DSH profile: Node resolves the plugin
137
+ directory before the profile, so with a 0.1.1 profile installed the host
138
+ loads the wrong version of this package. Either remove it from this plugin's
139
+ devDependencies and run pnpm install again — or, if shadowing is truly
140
+ unavoidable for it, add it to SHADOW_ALLOWED in this script with the reason
141
+ written down next to it.`)
142
+ }
143
+
144
+ if (failed) process.exit(1)
145
+ console.log('check-host-shadow: ok')
@@ -0,0 +1,55 @@
1
+ // Self-check for the session-info sheet's stacking promotion (fixed 2026-09-04).
2
+ //
3
+ // The sheet renders INSIDE `[data-phase] header`, which header.css.ts makes a
4
+ // stacking context (position: relative + z-index: 2). A child's z-index is
5
+ // scoped to that context, so the layer's own z:70 competes with the header's
6
+ // 2 — and the host composer seat (.wSkVaW_composerSeat, z:7, owned by
7
+ // dsh-client-ui-conversation) wins, painting the input row through the card.
8
+ // The fix promotes the header itself while the sheet is mounted.
9
+ //
10
+ // This regresses silently: nothing throws, the console stays clean, and the
11
+ // card still opens — it just has the composer sitting on top of it. Hence a
12
+ // check that fails loudly if the rule is dropped or its value drifts back
13
+ // under the host's seat.
14
+ // Run: node scripts/check-info-sheet-stacking.mjs (needs Node >= 23.6 type stripping)
15
+ import assert from 'node:assert/strict'
16
+ import { INFO_CSS } from '../src/client/styles/info.css.ts'
17
+
18
+ /** Host z-index values this sheet has to out-rank (dsh-client-ui-conversation). */
19
+ const HOST_COMPOSER_SEAT = 7
20
+ const HOST_SCROLL_TO_BOTTOM = 8
21
+
22
+ const promotion = /\[data-phase\]\s*header:has\(\[data-mobile-nav="info-layer"\]\)\s*\{([^}]*)\}/.exec(INFO_CSS)
23
+ assert.ok(
24
+ promotion,
25
+ 'the header-promotion rule is gone: the info sheet renders inside [data-phase] header, ' +
26
+ 'so without it the sheet is trapped at the header z-index (2) and the composer seat ' +
27
+ '(host z:7) paints over the card. See the comment in styles/info.css.ts.',
28
+ )
29
+
30
+ const z = Number(/z-index:\s*(\d+)/.exec(promotion[1])?.[1])
31
+ assert.ok(Number.isFinite(z), 'header-promotion rule has no numeric z-index')
32
+ assert.ok(
33
+ z > HOST_SCROLL_TO_BOTTOM && z > HOST_COMPOSER_SEAT,
34
+ `header promotion is z-index:${z}, which does not out-rank the host composer seat ` +
35
+ `(${HOST_COMPOSER_SEAT}) and scroll-to-bottom (${HOST_SCROLL_TO_BOTTOM})`,
36
+ )
37
+
38
+ // Depth-agnostic selector: the utilities slot wrapper is `display: contents`
39
+ // and the host owns the nesting, so a child combinator would silently stop
40
+ // matching on any host reshuffle (AGENTS.md).
41
+ assert.ok(
42
+ !/header:has\(>\s*\[data-mobile-nav="info-layer"\]\)/.test(INFO_CSS),
43
+ 'header promotion must use a descendant :has(), not `:has(> …)` — the host owns the depth',
44
+ )
45
+
46
+ // Desktop must stay pixel-identical: the promotion has to sit inside the
47
+ // phone breakpoint, not at the top level of the sheet.
48
+ const phone = /@media \(max-width: 767px\) \{/.exec(INFO_CSS)
49
+ assert.ok(phone, 'info.css.ts lost its phone media query')
50
+ assert.ok(
51
+ promotion.index > phone.index,
52
+ 'the header promotion escaped the (max-width: 767px) block — it would change the desktop layout',
53
+ )
54
+
55
+ console.log('check-info-sheet-stacking: ok')
@@ -2,7 +2,7 @@ import { useRef, useState } from 'react'
2
2
  import type { ChangeEvent } from 'react'
3
3
  import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
4
4
  import { IconPaperclipOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
5
- import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
5
+ import type { SessionId } from './compat/types.ts'
6
6
  import { NS } from './locales.ts'
7
7
  import { draftWithMention, rememberThumbnail, uploadUrl } from './attach-upload.ts'
8
8
 
@@ -9,7 +9,8 @@ import {
9
9
  StateDot,
10
10
  } from '@deepseek-ai/dsh-client-ui-primitives'
11
11
  import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives'
12
- import type { SessionId, SessionSummary, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
12
+ import { agentPresetOf } from './compat/types.ts'
13
+ import type { MobileSessionRow, SessionId, UsePendingInteractions, WorkspaceId } from './compat/types.ts'
13
14
  import { NS } from './locales.ts'
14
15
  import { GO_HOME_EVENT } from './nav-store.ts'
15
16
  import { hasLayer, popLayer, pushLayer } from './history-nav.ts'
@@ -36,11 +37,29 @@ export type MobileHomeProps =
36
37
  downloadSessionLog: (id: SessionId) => void
37
38
  /** Bound ctx.workspaces.archiveSession(id) — the row swipe action. */
38
39
  archiveSession: (id: SessionId) => Promise<void>
40
+ /**
41
+ * 0.1.2 的待处理交互选择器标准 prop;0.1.1 没有这个 prop,运行时读到
42
+ * undefined(见组件里 EMPTY_PENDING_HOOK 兜底)。
43
+ */
44
+ useSessionPendingInteraction?: UsePendingInteractions
39
45
  }
40
46
 
41
47
  /** Phone breakpoint: below the tablet range, where the app-shell layout applies. */
42
48
  const PHONE_QUERY = '(max-width: 767px)'
43
49
 
50
+ /**
51
+ * Empty pending-interaction map singleton: when the 0.1.2 source is absent
52
+ * the fallback hook returns THIS map, so the selector sees one stable
53
+ * reference across renders — a fresh Map per render would make every
54
+ * selector result a new reference and loop the list into infinite
55
+ * re-renders. Module-level on purpose.
56
+ */
57
+ const EMPTY_PENDING_MAP: ReadonlyMap<SessionId, unknown> = new Map()
58
+
59
+ /** 0.1.1 兜底:恒返回空 Map 的替身 hook(见组件里 useSessionPendingInteraction ?? EMPTY_PENDING_HOOK)。 */
60
+ const EMPTY_PENDING_HOOK: UsePendingInteractions =
61
+ <S,>(sel: (m: ReadonlyMap<SessionId, unknown>) => S) => sel(EMPTY_PENDING_MAP)
62
+
44
63
  /** Swipe geometry: reveal width of the archive action / settle threshold. */
45
64
  const SWIPE_REVEAL = 88
46
65
  const SWIPE_SETTLE = 44
@@ -216,18 +235,19 @@ function relativeTime(at: number): string {
216
235
  /**
217
236
  * Card status subline (real-device round 2 follow-up, 2026-08-17): reuses
218
237
  * `dotState`'s own state read (the same ongoing/warning/done semantics the
219
- * dot already encodes) plus `agentPreset` from the same `SessionSummary`
220
- * row — no new data source. Returns undefined when there is neither a
238
+ * dot already encodes) plus `agentPreset` from the same session row — no
239
+ * new data source. Returns undefined when there is neither a
221
240
  * state nor a preset to show, so the caller can skip the subline entirely
222
241
  * rather than render an empty row.
223
242
  */
224
- function statusLine(row: SessionSummary, dot: StateDotState | undefined, t: (key: MobileNavKey) => string): string | undefined {
243
+ function statusLine(row: MobileSessionRow, dot: StateDotState | undefined, t: (key: MobileNavKey) => string): string | undefined {
244
+ const preset = agentPresetOf(row)
225
245
  const state = dot === 'ongoing' ? t('homeStatusOngoing')
226
246
  : dot === 'warning' ? t('homeStatusWarning')
227
247
  : dot === 'done' ? t('homeStatusDone')
228
248
  : undefined
229
- if (state !== undefined && row.agentPreset !== undefined) return `${state} · ${row.agentPreset}`
230
- return state ?? row.agentPreset
249
+ if (state !== undefined && preset !== undefined) return `${state} · ${preset}`
250
+ return state ?? preset
231
251
  }
232
252
 
233
253
  /**
@@ -242,6 +262,7 @@ function statusLine(row: SessionSummary, dot: StateDotState | undefined, t: (key
242
262
  export function MobileHome({
243
263
  useSessions,
244
264
  useWorkspaces,
265
+ useSessionPendingInteraction,
245
266
  useStore,
246
267
  actions,
247
268
  openSession,
@@ -262,6 +283,11 @@ export function MobileHome({
262
283
  const [sheet, setSheet] = useState<'filter' | 'chips' | null>(null)
263
284
  /** The one row whose archive action is swiped open (null = none). */
264
285
  const [swipeOpen, setSwipeOpen] = useState<SessionId | null>(null)
286
+ // 0.1.2 的待处理交互来源;0.1.1 没有这个标准 prop,读到 undefined 时用一个
287
+ // 恒返回空 Map 的替身,保证 hook 调用次数与顺序在两版之间一致(hooks 规则:
288
+ // 真实 hook 只在 0.1.2 被调用,且每次渲染同一位置无条件调用一次)。
289
+ const pendingHook = useSessionPendingInteraction ?? EMPTY_PENDING_HOOK
290
+ const pending = pendingHook((m) => m)
265
291
 
266
292
  /** Archive with the same confirm gate the session-info card uses. */
267
293
  const onArchiveRow = (id: SessionId): void => {
@@ -417,7 +443,7 @@ export function MobileHome({
417
443
 
418
444
  <ul data-mobile-nav="home-list">
419
445
  {rows.map((row) => {
420
- const dot = dotState(row)
446
+ const dot = dotState(row, pending.has(row.id))
421
447
  const status = statusLine(row, dot, t)
422
448
  const initial = row.displayTitle.trim().charAt(0).toUpperCase()
423
449
  return (