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.
- package/AGENTS.md +24 -0
- package/README.md +7 -3
- package/README.zh-CN.md +7 -3
- package/docs/interface.md +5 -2
- package/dsh-push.mjs +25 -1
- package/lan-gate.mjs +16 -1
- package/lib/client.js +684 -43
- package/lib/lan-gate-server.cjs +107 -4
- package/lib/types/client/MobileHome.d.ts +7 -2
- package/lib/types/client/MobileHome.d.ts.map +1 -1
- package/lib/types/client/MobileSessionInfo.d.ts +2 -2
- package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
- package/lib/types/client/compat/store.d.ts +62 -0
- package/lib/types/client/compat/store.d.ts.map +1 -0
- package/lib/types/client/compat/types.d.ts +84 -0
- package/lib/types/client/compat/types.d.ts.map +1 -0
- package/lib/types/client/debug.d.ts +1 -1
- package/lib/types/client/debug.d.ts.map +1 -1
- package/lib/types/client/effects/aionui-compat.d.ts +1 -1
- package/lib/types/client/effects/aionui-compat.d.ts.map +1 -1
- package/lib/types/client/effects/gestures.d.ts +1 -1
- package/lib/types/client/effects/gestures.d.ts.map +1 -1
- package/lib/types/client/effects/header-status.d.ts +1 -1
- package/lib/types/client/effects/header-status.d.ts.map +1 -1
- package/lib/types/client/effects/keyboard-avoid.d.ts +1 -1
- package/lib/types/client/effects/keyboard-avoid.d.ts.map +1 -1
- package/lib/types/client/effects/keyboard-guard.d.ts +1 -1
- package/lib/types/client/effects/keyboard-guard.d.ts.map +1 -1
- package/lib/types/client/effects/modal-back.d.ts +1 -1
- package/lib/types/client/effects/modal-back.d.ts.map +1 -1
- package/lib/types/client/effects/model-sheet-extras.d.ts +33 -0
- package/lib/types/client/effects/model-sheet-extras.d.ts.map +1 -0
- package/lib/types/client/effects/native-trigger-overlay.d.ts +1 -1
- package/lib/types/client/effects/native-trigger-overlay.d.ts.map +1 -1
- package/lib/types/client/effects/phone-chrome.d.ts +1 -1
- package/lib/types/client/effects/phone-chrome.d.ts.map +1 -1
- package/lib/types/client/effects/turn-fold.d.ts +1 -1
- package/lib/types/client/effects/turn-fold.d.ts.map +1 -1
- package/lib/types/client/effects/welcome-notice.d.ts +1 -1
- package/lib/types/client/effects/welcome-notice.d.ts.map +1 -1
- package/lib/types/client/effects/workbench-ref-close.d.ts +1 -1
- package/lib/types/client/effects/workbench-ref-close.d.ts.map +1 -1
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/nav-store.d.ts +2 -2
- package/lib/types/client/nav-store.d.ts.map +1 -1
- package/lib/types/client/session-dot.d.ts +11 -3
- package/lib/types/client/session-dot.d.ts.map +1 -1
- package/lib/types/client/styles/compat.css.d.ts +1 -1
- package/lib/types/client/styles/compat.css.d.ts.map +1 -1
- package/lib/types/client/styles/composer.css.d.ts +1 -1
- package/lib/types/client/styles/composer.css.d.ts.map +1 -1
- package/lib/types/client/styles/info.css.d.ts +1 -1
- package/lib/types/client/styles/info.css.d.ts.map +1 -1
- package/package.json +49 -26
- package/pwa/app.css +47 -7
- package/scripts/check-client-externals.mjs +48 -0
- package/scripts/check-compat-store.mjs +113 -0
- package/scripts/check-host-shadow.mjs +145 -0
- package/scripts/check-info-sheet-stacking.mjs +55 -0
- package/src/client/MobileAttachButton.tsx +1 -1
- package/src/client/MobileHome.tsx +33 -7
- package/src/client/MobileSessionInfo.tsx +6 -4
- package/src/client/chips-store.ts +1 -1
- package/src/client/compat/store.ts +205 -0
- package/src/client/compat/types.ts +115 -0
- package/src/client/debug.ts +1 -1
- package/src/client/effects/aionui-compat.ts +1 -1
- package/src/client/effects/gestures.ts +1 -1
- package/src/client/effects/header-status.ts +22 -3
- package/src/client/effects/keyboard-avoid.ts +1 -1
- package/src/client/effects/keyboard-guard.ts +1 -1
- package/src/client/effects/modal-back.ts +1 -1
- package/src/client/effects/model-sheet-extras.ts +159 -0
- package/src/client/effects/native-trigger-overlay.ts +1 -1
- package/src/client/effects/phone-chrome.ts +1 -1
- package/src/client/effects/turn-fold.ts +1 -1
- package/src/client/effects/welcome-notice.ts +1 -1
- package/src/client/effects/workbench-ref-close.ts +1 -1
- package/src/client/index.tsx +70 -6
- package/src/client/nav-store.ts +2 -2
- package/src/client/session-dot.ts +13 -4
- package/src/client/styles/compat.css.ts +43 -11
- package/src/client/styles/composer.css.ts +184 -9
- 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.
|
|
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-
|
|
87
|
-
"@deepseek-ai/dsh-client-ui-
|
|
88
|
-
"@deepseek-ai/dsh-client-ui-
|
|
89
|
-
"@deepseek-ai/dsh-client-ui-
|
|
90
|
-
"@deepseek-ai/dsh-client-ui-
|
|
91
|
-
"@deepseek-ai/dsh-
|
|
92
|
-
"@deepseek-ai/dsh-
|
|
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/
|
|
111
|
-
"@deepseek-ai/
|
|
112
|
-
"@deepseek-ai/dsh-
|
|
113
|
-
"@deepseek-ai/dsh-
|
|
114
|
-
"@deepseek-ai/dsh-
|
|
115
|
-
"@deepseek-ai/dsh-
|
|
116
|
-
"@deepseek-ai/dsh-
|
|
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 '
|
|
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
|
|
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
|
|
220
|
-
*
|
|
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:
|
|
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 &&
|
|
230
|
-
return state ??
|
|
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 (
|