@linxin666/dsh-remote-web-ui 0.3.2 → 0.3.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 (78) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +22 -8
  3. package/README.zh.md +12 -7
  4. package/assets/mobile-service-worker.js +1 -2
  5. package/lib/client.js +177 -19
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +222 -15
  8. package/lib/mobile.js +64 -13
  9. package/lib/mobile.js.map +1 -1
  10. package/lib/types/client/PluginSettingsCard.d.ts +2 -0
  11. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  12. package/lib/types/client/PluginSettingsCard.js +2 -2
  13. package/lib/types/client/RemoteEntry.js +1 -1
  14. package/lib/types/client/UpdatePanel.d.ts.map +1 -1
  15. package/lib/types/client/UpdatePanel.js +20 -3
  16. package/lib/types/client/index.d.ts +1 -1
  17. package/lib/types/client/index.d.ts.map +1 -1
  18. package/lib/types/client/index.js +4 -0
  19. package/lib/types/client/locales.d.ts +12 -0
  20. package/lib/types/client/locales.d.ts.map +1 -1
  21. package/lib/types/client/locales.js +22 -10
  22. package/lib/types/client/settings-form.d.ts +1 -1
  23. package/lib/types/client/settings-form.js +1 -1
  24. package/lib/types/client/telemetry.d.ts +27 -0
  25. package/lib/types/client/telemetry.d.ts.map +1 -0
  26. package/lib/types/client/telemetry.js +97 -0
  27. package/lib/types/dsh-home.d.ts.map +1 -1
  28. package/lib/types/dsh-home.js +9 -3
  29. package/lib/types/http.d.ts +1 -1
  30. package/lib/types/http.js +1 -1
  31. package/lib/types/index.d.ts.map +1 -1
  32. package/lib/types/index.js +34 -5
  33. package/lib/types/loopback.d.ts +1 -1
  34. package/lib/types/loopback.js +1 -1
  35. package/lib/types/mobile/api.d.ts +7 -0
  36. package/lib/types/mobile/api.d.ts.map +1 -1
  37. package/lib/types/mobile/api.js +9 -3
  38. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  39. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  40. package/lib/types/mobile/mobile-styles.js +10 -0
  41. package/lib/types/mobile/views/ChatView.d.ts +1 -0
  42. package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
  43. package/lib/types/mobile/views/ChatView.js +32 -5
  44. package/lib/types/mobile/views/ChatView.stream.test.js +28 -2
  45. package/lib/types/mobile/views/ChatView.test.js +152 -2
  46. package/lib/types/mobile-api.d.ts +14 -0
  47. package/lib/types/mobile-api.d.ts.map +1 -1
  48. package/lib/types/mobile-api.js +56 -1
  49. package/lib/types/mobile-routes.d.ts.map +1 -1
  50. package/lib/types/mobile-routes.js +25 -2
  51. package/lib/types/mount-once.d.ts +1 -1
  52. package/lib/types/mount-once.js +2 -2
  53. package/lib/types/update.d.ts +35 -4
  54. package/lib/types/update.d.ts.map +1 -1
  55. package/lib/types/update.js +98 -4
  56. package/package.json +4 -2
  57. package/src/client/PluginSettingsCard.tsx +4 -2
  58. package/src/client/RemoteEntry.tsx +1 -1
  59. package/src/client/UpdatePanel.tsx +43 -3
  60. package/src/client/index.ts +6 -1
  61. package/src/client/locales.ts +22 -10
  62. package/src/client/remote.module.css +56 -3
  63. package/src/client/settings-card.module.css +5 -0
  64. package/src/client/settings-form.ts +1 -1
  65. package/src/client/telemetry.ts +105 -0
  66. package/src/dsh-home.ts +9 -3
  67. package/src/http.ts +1 -1
  68. package/src/index.ts +36 -4
  69. package/src/loopback.ts +1 -1
  70. package/src/mobile/api.ts +10 -3
  71. package/src/mobile/mobile-styles.ts +10 -0
  72. package/src/mobile/views/ChatView.stream.test.tsx +34 -2
  73. package/src/mobile/views/ChatView.test.tsx +177 -2
  74. package/src/mobile/views/ChatView.tsx +44 -6
  75. package/src/mobile-api.ts +61 -0
  76. package/src/mobile-routes.ts +24 -2
  77. package/src/mount-once.ts +2 -2
  78. package/src/update.ts +117 -4
@@ -114,21 +114,27 @@ export const zh = {
114
114
  'update.doneDetail': '所有组件已更新到最新版本。',
115
115
  'update.restartHint': '请重启 dsh web 使新版本生效:在终端按 Ctrl+C 停止后重新运行 dsh web。',
116
116
  'update.linkMode': '当前为本地开发模式',
117
- 'update.linkModeDetail': 'dsh-web-ui 或其组件通过本地链接安装(开发模式),无法自动更新。npm 上最新版本:{version}。请同步本地仓库,或移除与全家桶重复的独立链接。',
118
- 'update.missing': '未找到 dsh-web-ui 安装',
119
- 'update.missingDetail': '无法定位已安装的 dsh-web-ui 聚合包。请先运行 dsh plugin --profile <profile> add @linxin666/dsh-web-ui-all。',
117
+ 'update.linkModeDetail': 'dsh-web 或其组件通过本地链接安装(开发模式),无法自动更新。npm 上最新版本:{version}。请同步本地仓库,或移除与全家桶重复的独立链接。',
118
+ 'update.missing': '未找到 dsh-web 安装',
119
+ 'update.missingDetail': '无法定位已安装的 dsh-web 聚合包。请先运行 dsh plugin --profile <profile> add @linxin666/dsh-web-all。',
120
120
  'update.offline': '无法连接更新源',
121
121
  'update.offlineDetail': '网络不可用或更新服务异常,请稍后重试。',
122
122
  'update.unmounted': '更新服务未加载',
123
123
  'update.unmountedDetail': '当前 dsh web 进程运行的插件版本不含更新服务,请重启 dsh web(Ctrl+C 后重新运行)后再试。',
124
124
  'update.error': '更新失败',
125
125
  'update.packages': '组件版本',
126
+ 'update.releaseNotes': 'v{version} 更新说明',
127
+ 'update.releaseFeatures': '新增功能',
128
+ 'update.releaseFixes': '修复',
129
+ 'update.releaseOther': '其他变动',
130
+ 'update.releaseUnavailable': '本次发布说明暂不可用,可前往 GitHub Release 查看。',
131
+ 'update.componentVersions': '查看组件版本',
126
132
  'update.retry': '重新检查',
127
133
  'update.close': '关闭更新面板',
128
134
  'update.error.pnpmMissing': '未找到 pnpm,已尝试 pnpm / corepack / npx 仍不可用。请安装 pnpm 后重启应用(Windows: npm install -g pnpm 或 corepack enable;macOS: brew install pnpm 或 corepack enable)。',
129
135
  'update.error.timeout': '更新超时,已终止安装进程,请重试。',
130
- 'update.error.notFound': '未找到 dsh-web-ui 聚合包安装,无法更新。',
131
- 'update.error.link': 'dsh-web-ui 或其组件使用本地链接,无法自动更新;请同步本地仓库,或移除与全家桶重复的独立链接。',
136
+ 'update.error.notFound': '未找到 dsh-web 聚合包安装,无法更新。',
137
+ 'update.error.link': 'dsh-web 或其组件使用本地链接,无法自动更新;请同步本地仓库,或移除与全家桶重复的独立链接。',
132
138
  'update.error.pnpmFailed': '更新执行失败(pnpm 退出码 {code}),详见下方输出。',
133
139
  'update.error.stale': '更新命令已执行,但安装版本未变化。常见原因:pnpm 11 的 minimumReleaseAge 门禁会静默跳过发布不足 24 小时的新版本。请在 profile 目录(~/.dsh/profiles/<profile>)的 pnpm-workspace.yaml 中配置 minimumReleaseAgeExclude(例如 "@linxin666/*")或 minimumReleaseAge: 0 后重新点击更新。',
134
140
  'update.error.verifyFailed': '更新命令已执行,但无法核对安装后的版本(可能是 registry 探测失败或安装路径变化)。请查看下方输出,或稍后重试。',
@@ -255,21 +261,27 @@ export const en = {
255
261
  'update.doneDetail': 'All components are up to date.',
256
262
  '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.',
257
263
  'update.linkMode': 'Local development mode',
258
- 'update.linkModeDetail': 'dsh-web-ui 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.',
259
- 'update.missing': 'dsh-web-ui not found',
260
- 'update.missingDetail': 'Could not locate the installed dsh-web-ui aggregate package. Install it with: dsh plugin --profile <profile> add @linxin666/dsh-web-ui-all',
264
+ '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.',
265
+ 'update.missing': 'dsh-web not found',
266
+ 'update.missingDetail': 'Could not locate the installed dsh-web aggregate package. Install it with: dsh plugin --profile <profile> add @linxin666/dsh-web-all',
261
267
  'update.offline': 'Cannot reach the update source',
262
268
  'update.offlineDetail': 'The network is unavailable or the update service is failing; try again later.',
263
269
  'update.unmounted': 'The update service is not loaded',
264
270
  '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.',
265
271
  'update.error': 'Update failed',
266
272
  'update.packages': 'Component versions',
273
+ 'update.releaseNotes': 'v{version} release notes',
274
+ 'update.releaseFeatures': 'New Features',
275
+ 'update.releaseFixes': 'Bug Fixes',
276
+ 'update.releaseOther': 'Other Changes',
277
+ 'update.releaseUnavailable': 'Release notes are unavailable for this version; see the GitHub release instead.',
278
+ 'update.componentVersions': 'View component versions',
267
279
  'update.retry': 'Check again',
268
280
  'update.close': 'Close update panel',
269
281
  '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).',
270
282
  'update.error.timeout': 'The update timed out and the install process was killed; try again.',
271
- 'update.error.notFound': 'The dsh-web-ui aggregate package is not installed; nothing to update.',
272
- 'update.error.link': 'dsh-web-ui or one of its components uses a local link. Sync the checkout, or remove a standalone link duplicated by the aggregate.',
283
+ 'update.error.notFound': 'The dsh-web aggregate package is not installed; nothing to update.',
284
+ '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.',
273
285
  'update.error.pnpmFailed': 'The update failed (pnpm exited with code {code}); see the output below.',
274
286
  '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.',
275
287
  '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.',
@@ -18,8 +18,10 @@
18
18
  backdrop-filter: var(--dsw-mask-blur);
19
19
  }
20
20
 
21
- /* Trigger: settings row height beside the trigger row (wide), or the 36px
22
- rail circle; the seat's owner (ui-sidebar) lays out the geometry. */
21
+ /* Trigger: the 36px rail circle, or a fully rounded (pill) full-row button
22
+ in wide mode — both maximally rounded so the footer action pair shares one
23
+ shape family (#1035); the seat's owner (ui-sidebar) lays out the
24
+ geometry. */
23
25
  .trigger {
24
26
  position: relative;
25
27
  flex: none;
@@ -57,7 +59,9 @@
57
59
  justify-content: flex-start;
58
60
  width: auto;
59
61
  min-width: 0;
60
- border-radius: 8px;
62
+ /* Pill: maximal rounding, same shape family as the 36px rail circle next
63
+ to it (the update trigger) — one consistent footer-action look (#1035). */
64
+ border-radius: 999px;
61
65
  gap: 8px;
62
66
  padding: 0 10px;
63
67
  }
@@ -750,6 +754,55 @@
750
754
  font-variant-numeric: tabular-nums;
751
755
  }
752
756
 
757
+ .updateNotes {
758
+ margin-top: 14px;
759
+ }
760
+
761
+ .updateNotesTitle {
762
+ margin: 0 0 8px;
763
+ font-size: 14px;
764
+ font-weight: 600;
765
+ }
766
+
767
+ .updateNoteSection {
768
+ display: flex;
769
+ flex-direction: column;
770
+ gap: 4px;
771
+ margin-top: 10px;
772
+ }
773
+
774
+ .updateNoteHeading {
775
+ margin: 0;
776
+ font-size: 13px;
777
+ font-weight: 600;
778
+ color: var(--dsw-alias-label-secondary);
779
+ }
780
+
781
+ .updateNoteList {
782
+ margin: 0;
783
+ padding-left: 18px;
784
+ display: flex;
785
+ flex-direction: column;
786
+ gap: 4px;
787
+ font-size: 13px;
788
+ }
789
+
790
+ .updateNoteItem {
791
+ color: var(--dsw-alias-label-primary);
792
+ overflow-wrap: anywhere;
793
+ }
794
+
795
+ .updateVersions {
796
+ margin-top: 12px;
797
+ }
798
+
799
+ .updateVersionsSummary {
800
+ cursor: pointer;
801
+ color: var(--dsw-alias-label-secondary);
802
+ font-size: 12px;
803
+ user-select: none;
804
+ }
805
+
753
806
  .updateActions {
754
807
  display: flex;
755
808
  justify-content: flex-end;
@@ -24,6 +24,10 @@
24
24
  .header {
25
25
  appearance: none;
26
26
  width: 100%;
27
+ /* The suite does not force a global border-box, so width:100% + padding
28
+ would stretch the row 32px past the card and push the trailing pending
29
+ badge (last flex item) outside the card border. */
30
+ box-sizing: border-box;
27
31
  font: inherit;
28
32
  color: inherit;
29
33
  text-align: left;
@@ -44,6 +48,7 @@
44
48
 
45
49
  .headerStatic {
46
50
  width: 100%;
51
+ box-sizing: border-box;
47
52
  border-radius: 12px;
48
53
  align-items: center;
49
54
  gap: 12px;
@@ -320,7 +320,7 @@ export class CardForm<T> {
320
320
  /**
321
321
  * Write every staged edit, then re-seed from what the Host accepted.
322
322
  *
323
- * When the scope carries the optional batch surface (the dsh-web-ui
323
+ * When the scope carries the optional batch surface (the dsh-web
324
324
  * bridge scope), every planned write rides one mutation so cross-field
325
325
  * validate hooks (baseURL+model) judge the batch as a unit instead of
326
326
  * deadlocking on per-field writes. Otherwise the per-field loop runs.
@@ -0,0 +1,105 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/client/telemetry.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Anonymous install telemetry shared by the DSH Web UI family plugins.
4
+ *
5
+ * Once per UTC day per browser, each wired plugin sends one heartbeat to
6
+ * the dsh-market edge API listing its package name (and version when known).
7
+ * The payload carries no conversation data and no identifiers beyond a random
8
+ * UUID generated in localStorage; the server hashes it with a deployment salt
9
+ * before storage and never persists IP addresses. Sends are fire-and-forget:
10
+ * failures stay silent and simply retry on a later mount or day. The system
11
+ * is documented in docs/telemetry.md.
12
+ */
13
+
14
+ export type TelemetryChannel = 'market' | 'npm' | 'unknown'
15
+
16
+ export interface TelemetryItem {
17
+ /** npm package name or asset id, e.g. "@linxin666/dsh-pet" or "skin:harbor". */
18
+ name: string
19
+ /** Installed version when known; omitted otherwise. */
20
+ version?: string
21
+ /** Install channel when determinable (market = Workshop install). */
22
+ channel?: TelemetryChannel
23
+ }
24
+
25
+ const VISITOR_KEY = 'dsh-web-ui-telemetry-visitor'
26
+ const DAY_KEY_PREFIX = 'dsh-web-ui-telemetry-day:'
27
+ const ENDPOINT = 'https://dsh-market.com/api/telemetry/event'
28
+
29
+ // Baked into each client bundle by shared/tsdown.client.ts (the package's own
30
+ // package.json version at build time). Undefined in dev/test builds.
31
+ declare const __DSH_PKG_VERSION__: string | undefined
32
+
33
+ /** The building package's version, when the bundle carries it. */
34
+ function bakedVersion(): string | undefined {
35
+ try {
36
+ return typeof __DSH_PKG_VERSION__ === 'string' && __DSH_PKG_VERSION__ !== ''
37
+ ? __DSH_PKG_VERSION__
38
+ : undefined
39
+ } catch {
40
+ return undefined
41
+ }
42
+ }
43
+
44
+ /** Read or lazily create the anonymous visitor id; null when storage is unavailable. */
45
+ function visitorId(): string | null {
46
+ try {
47
+ const existing = localStorage.getItem(VISITOR_KEY)
48
+ if (existing && /^[A-Za-z0-9_-]{16,64}$/.test(existing)) return existing
49
+ const fresh = crypto.randomUUID().replaceAll('-', '')
50
+ localStorage.setItem(VISITOR_KEY, fresh)
51
+ return fresh
52
+ } catch {
53
+ // Storage unavailable (privacy mode, sandboxed frame): report nothing.
54
+ return null
55
+ }
56
+ }
57
+
58
+ /** Drop stale per-day dedup keys so localStorage does not grow forever. */
59
+ function pruneDayKeys(today: string): void {
60
+ try {
61
+ for (let index = localStorage.length - 1; index >= 0; index -= 1) {
62
+ const key = localStorage.key(index)
63
+ if (key !== null && key.startsWith(DAY_KEY_PREFIX) && key !== DAY_KEY_PREFIX + today) {
64
+ localStorage.removeItem(key)
65
+ }
66
+ }
67
+ } catch { /* best effort */ }
68
+ }
69
+
70
+ /**
71
+ * Fire the daily heartbeat for the given items at most once per UTC day per
72
+ * browser. Never throws and never blocks the caller. Items without an explicit
73
+ * version inherit the bundle's baked build version.
74
+ */
75
+ export function reportDailyHeartbeat(items: readonly TelemetryItem[]): void {
76
+ try {
77
+ if (items.length === 0) return
78
+ const today = new Date().toISOString().slice(0, 10)
79
+ // Automation contexts (CDP-driven QA browsers, fresh isolated profiles)
80
+ // mint a new visitor id per run and would inflate instance counts.
81
+ if (navigator.webdriver) return
82
+ if (localStorage.getItem(DAY_KEY_PREFIX + today) !== null) return
83
+ const visitor = visitorId()
84
+ if (visitor === null) return
85
+ pruneDayKeys(today)
86
+ const payloadItems = items.map((item) => {
87
+ const out: Record<string, string> = { name: item.name }
88
+ const version = item.version ?? bakedVersion()
89
+ if (version !== undefined) out.version = version
90
+ if (item.channel !== undefined) out.channel = item.channel
91
+ return out
92
+ })
93
+ const body = JSON.stringify({ kind: 'heartbeat', visitor, items: payloadItems })
94
+ void fetch(ENDPOINT, {
95
+ method: 'POST',
96
+ headers: { 'content-type': 'application/json' },
97
+ body,
98
+ keepalive: true,
99
+ }).then((response) => {
100
+ // Mark the day only after an accepted send, so offline browsers retry
101
+ // on a later mount instead of going dark until tomorrow.
102
+ if (response.ok) localStorage.setItem(DAY_KEY_PREFIX + today, '1')
103
+ }).catch(() => { /* unreachable endpoint: stay silent */ })
104
+ } catch { /* never break the host UI over telemetry */ }
105
+ }
package/src/dsh-home.ts CHANGED
@@ -7,11 +7,14 @@
7
7
 
8
8
  import { homedir } from 'node:os'
9
9
  import { isAbsolute, join } from 'node:path'
10
+ import { isAbsolute as posixIsAbsolute, join as posixJoin } from 'node:path/posix'
10
11
 
11
12
  /** Expand a leading ~ (or ~user) in a path, platform-style. */
12
13
  export function expandHome(path: string, home: string = homedir()): string {
14
+ const isPosix = home.startsWith('/')
15
+ const j = isPosix ? posixJoin : join
13
16
  if (path === '~') return home
14
- if (path.startsWith('~/') || path.startsWith('~\\')) return join(home, path.slice(2))
17
+ if (path.startsWith('~/') || path.startsWith('~\\')) return j(home, path.slice(2))
15
18
  return path
16
19
  }
17
20
 
@@ -22,12 +25,15 @@ export function expandHome(path: string, home: string = homedir()): string {
22
25
  * @returns the absolute DSH home path.
23
26
  */
24
27
  export function resolveDshHome(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string {
28
+ const isPosix = home.startsWith('/')
29
+ const j = isPosix ? posixJoin : join
30
+ const isAbs = isPosix ? posixIsAbsolute : isAbsolute
25
31
  const raw = env.DSH_HOME
26
32
  if (raw !== undefined && raw.trim() !== '') {
27
33
  const expanded = expandHome(raw.trim(), home)
28
- return isAbsolute(expanded) ? expanded : join(process.cwd(), expanded)
34
+ return isAbs(expanded) ? expanded : j(process.cwd(), expanded)
29
35
  }
30
- return join(home, '.dsh')
36
+ return j(home, '.dsh')
31
37
  }
32
38
 
33
39
  /** Resolve the DSH home directory from the live environment. */
package/src/http.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * edit this shared source and re-run the sync instead of editing a copy.
12
12
  * Consumer code is migrated onto it in follow-up waves; no call site changes
13
13
  * belong in the same change as its introduction.
14
- * @module dsh-web-ui-shared/host/http
14
+ * @module dsh-web-shared/host/http
15
15
  */
16
16
 
17
17
  import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'node:http'
package/src/index.ts CHANGED
@@ -16,6 +16,8 @@ import type { Context } from '@deepseek-ai/cordis'
16
16
  import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
17
17
  import z from 'schemastery'
18
18
  import type {} from '@deepseek-ai/dsh-host-webserver'
19
+ import type {} from '@deepseek-ai/dsh-agent'
20
+ import type {} from '@deepseek-ai/dsh-commands'
19
21
  import { DEFAULT_IDLE_EXPIRE_MS, PairingService, type PairingConfig } from './pairing.ts'
20
22
  import { dshHome } from './dsh-home.ts'
21
23
  import { isPairedDeviceRequest, makeGateListener } from './gate.ts'
@@ -31,10 +33,13 @@ import { lanIPv4Addresses } from './lan.ts'
31
33
  import { TunnelManager, type TunnelInfo } from './tunnel.ts'
32
34
  import {
33
35
  checkUpdates,
36
+ fetchGitHubReleaseNotes,
34
37
  fetchLatestVersion,
38
+ RELEASE_NOTES_CACHE_TTL_MS,
35
39
  resolveAnchorManifest,
36
40
  resolveUpdateTarget,
37
41
  runUpdateVerified,
42
+ type UpdateReleaseNotes,
38
43
  type UpdateRunResult,
39
44
  } from './update.ts'
40
45
  import { makeUpdateRoutes } from './update-routes.ts'
@@ -63,7 +68,7 @@ declare module '@deepseek-ai/cordis' {
63
68
  export const name = 'remote-web-ui'
64
69
 
65
70
  /** Services required before the pairing surfaces can mount. */
66
- export const inject = ['webServer', 'apiProxy']
71
+ export const inject = ['webServer', 'apiProxy', 'commands', 'agents']
67
72
 
68
73
  /**
69
74
  * Settings namespace of the remote-control capability — the section the web
@@ -294,7 +299,7 @@ function applyImpl(ctx: Context, config?: Config): void {
294
299
  console.warn('remote-web-ui: apiProxy service unavailable — the mobile data channel is disabled')
295
300
  }
296
301
  // ── remote update ────────────────────────────────────────────────────────
297
- // The dsh-web-ui self-update surface: probe the npm registry for family
302
+ // The dsh-web self-update surface: probe the npm registry for family
298
303
  // releases and run `pnpm update --latest` in the owning profile. Resolutions
299
304
  // anchor on the host process's own module graph, so the update always
300
305
  // targets the profile the running web GUI was booted from. The anchor path
@@ -309,6 +314,15 @@ function applyImpl(ctx: Context, config?: Config): void {
309
314
  return undefined
310
315
  }
311
316
  })
317
+
318
+ const releaseNotesCache = new Map<string, { at: number; notes?: UpdateReleaseNotes }>()
319
+ const fetchReleaseNotesCached = async (version: string): Promise<UpdateReleaseNotes | undefined> => {
320
+ const cached = releaseNotesCache.get(version)
321
+ if (cached !== undefined && Date.now() - cached.at < RELEASE_NOTES_CACHE_TTL_MS) return cached.notes
322
+ const notes = await fetchGitHubReleaseNotes(version, fetch)
323
+ releaseNotesCache.set(version, { at: Date.now(), notes })
324
+ return notes
325
+ }
312
326
  const updateRoutes = makeUpdateRoutes({
313
327
  // Control endpoints are host-surface only: a LAN/phone origin must never
314
328
  // trigger a real install on this machine.
@@ -323,6 +337,7 @@ function applyImpl(ctx: Context, config?: Config): void {
323
337
  }
324
338
  },
325
339
  fetchLatest: name => fetchLatestVersion(name, fetch),
340
+ fetchReleaseNotes: fetchReleaseNotesCached,
326
341
  }),
327
342
  run: async (): Promise<UpdateRunResult> => {
328
343
  const target = resolveUpdateTarget({ anchorManifestPath: resolveAnchorPath() })
@@ -332,7 +347,7 @@ function applyImpl(ctx: Context, config?: Config): void {
332
347
  ok: false,
333
348
  exitCode: null,
334
349
  output: '',
335
- error: code === 'not-found' ? 'dsh-web-ui aggregate not installed' : 'local link install — update unavailable',
350
+ error: code === 'not-found' ? 'dsh-web aggregate not installed' : 'local link install — update unavailable',
336
351
  errorCode: code,
337
352
  }
338
353
  }
@@ -352,6 +367,7 @@ function applyImpl(ctx: Context, config?: Config): void {
352
367
  }
353
368
  },
354
369
  fetchLatest: name => fetchLatestVersion(name, fetch),
370
+ fetchReleaseNotes: fetchReleaseNotesCached,
355
371
  },
356
372
  })
357
373
  },
@@ -360,7 +376,23 @@ function applyImpl(ctx: Context, config?: Config): void {
360
376
  ...makeRoutes({ service, lanAddresses, requirePairingForLan: () => resolve().requirePairingForLan }),
361
377
  ...makeMobileRoutes(),
362
378
  ...(apiProxy !== undefined
363
- ? makeMobileApiRoutes({ service, apiProxy, pendingTracker: new PendingTracker(), mobileEnterToSend: () => resolve().mobileEnterToSend })
379
+ ? makeMobileApiRoutes({
380
+ service,
381
+ apiProxy,
382
+ pendingTracker: new PendingTracker(),
383
+ mobileEnterToSend: () => resolve().mobileEnterToSend,
384
+ commandDispatcher: ctx.commands !== undefined && ctx.agents !== undefined
385
+ ? {
386
+ async execute(sessionId, line, signal) {
387
+ const agent = ctx.agents.get(sessionId as never)
388
+ if (agent === undefined) return { error: 'session-not-found' as const }
389
+ const execution = await ctx.commands.execute(agent, line, [], signal)
390
+ if (execution === undefined) return { error: 'unknown-command' as const }
391
+ return { ok: true as const, result: execution.result }
392
+ },
393
+ }
394
+ : undefined,
395
+ })
364
396
  : []),
365
397
  ...(apiProxy !== undefined ? makePairedModelCatalogRoutes({ service, apiProxy, lanAddresses }) : []),
366
398
  // The remote desktop channel: policy-gated `/remote` prefix that
package/src/loopback.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * Semantics: RFC 5735 IPv4 127/8, ::1, IPv4-mapped ::ffff:127/8 (matching the
9
9
  * remote-web-ui gate), localhost hostnames, plus the browser same-origin
10
10
  * markers (sec-fetch-site and Origin) for the request-level fence.
11
- * @module dsh-web-ui-shared/host/loopback
11
+ * @module dsh-web-shared/host/loopback
12
12
  */
13
13
 
14
14
  import type { IncomingMessage } from 'node:http'
package/src/mobile/api.ts CHANGED
@@ -105,13 +105,20 @@ export async function prompt(sessionId: string, text: string): Promise<void> {
105
105
 
106
106
  /** Send one slash command line (e.g. `/permission workspace-write`). */
107
107
  export async function sendCommand(sessionId: string, line: string): Promise<unknown> {
108
- return await callUnary<unknown>('session.prompt', {
108
+ return await callUnary<unknown>('mobile.command', {
109
109
  sessionId,
110
- mode: 'queue',
111
- content: [{ type: 'text', text: line }],
110
+ line,
112
111
  })
113
112
  }
114
113
 
114
+ /**
115
+ * Stop the session's active turn (the mobile stop button). Pending queued
116
+ * work is preserved and resumes in FIFO order once cancellation settles.
117
+ */
118
+ export async function cancelSession(sessionId: string): Promise<{ accepted: true }> {
119
+ return await callUnary<{ accepted: true }>('session.cancel', { sessionId })
120
+ }
121
+
115
122
  /** Fresh advisory model directory for one session (current + groups + failures). */
116
123
  export async function models(sessionId: string): Promise<SessionModels> {
117
124
  return await callUnary<SessionModels>('session.models', { sessionId })
@@ -1170,6 +1170,16 @@ body {
1170
1170
  opacity: 0.5;
1171
1171
  }
1172
1172
 
1173
+ /* Running state: the primary button carries only the square stop icon, so
1174
+ it centers the glyph instead of sizing to text. */
1175
+ .chat-send-stop {
1176
+ display: inline-flex;
1177
+ align-items: center;
1178
+ justify-content: center;
1179
+ min-width: 44px;
1180
+ padding: 0 12px;
1181
+ }
1182
+
1173
1183
  .chat-load-older {
1174
1184
  align-self: center;
1175
1185
  margin: 4px 0;
@@ -7,8 +7,8 @@
7
7
  * to the plain renderMarkdown output) the moment the turn closes.
8
8
  */
9
9
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
10
- import { act, cleanup, render } from '@testing-library/react'
11
- import { ChatView, STREAM_RENDER_INTERVAL_MS } from './ChatView.tsx'
10
+ import { act, cleanup, render, screen } from '@testing-library/react'
11
+ import { ChatView, STREAM_RENDER_INTERVAL_MS, LONG_TEXT_LIMIT } from './ChatView.tsx'
12
12
  import { type SessionView } from './App.tsx'
13
13
  import { renderMarkdown } from '../markdown.ts'
14
14
  import type { SessionModels } from '@deepseek-ai/dsh-host-apiproxy/api/sessions'
@@ -22,6 +22,8 @@ vi.mock('../api.ts', () => ({
22
22
  models: vi.fn(),
23
23
  selectModel: vi.fn(),
24
24
  sendCommand: vi.fn(),
25
+ cancelSession: vi.fn(),
26
+ fetchPending: vi.fn(),
25
27
  }))
26
28
  vi.mock('./App.tsx', async importOriginal => {
27
29
  const actual = await importOriginal<typeof import('./App.tsx')>()
@@ -228,4 +230,34 @@ describe('ChatView streaming markdown throttle', () => {
228
230
  expect(renderMarkdownMock.mock.calls.length - beforeFinal).toBe(0)
229
231
  expect(normalize(body().innerHTML)).toBe(normalize(renderMarkdown(full)))
230
232
  })
233
+
234
+ it('does not collapse long text while streaming (pending) and collapses once turn finishes', async () => {
235
+ const mux = new FakeMux()
236
+ let container: HTMLElement
237
+ await act(async () => {
238
+ const rendered = render(<ChatView session={session} mux={mux as never} onBack={() => {}} />)
239
+ container = rendered.container
240
+ })
241
+
242
+ const hugeChunk = 'C'.repeat(LONG_TEXT_LIMIT + 200)
243
+
244
+ // Stream a chunk exceeding LONG_TEXT_LIMIT
245
+ await act(async () => {
246
+ chunk(mux, hugeChunk, 6)
247
+ vi.advanceTimersByTime(STREAM_RENDER_INTERVAL_MS + 5)
248
+ })
249
+
250
+ // During streaming (pending), message is not collapsed
251
+ expect(container!.querySelector('.chat-md-collapsed')).toBeNull()
252
+ expect(screen.queryByRole('button', { name: /展开全文/ })).toBeNull()
253
+
254
+ // Turn closes
255
+ await act(async () => {
256
+ finalMessage(mux, hugeChunk, 7)
257
+ })
258
+
259
+ // Once turn ends (not pending), message is collapsed and has expand button
260
+ expect(container!.querySelector('.chat-md-collapsed')).not.toBeNull()
261
+ expect(screen.getByRole('button', { name: new RegExp(`展开全文(${LONG_TEXT_LIMIT + 200} 字)`) })).toBeTruthy()
262
+ })
231
263
  })