dsh-zen-remote 2.2.5 → 2.2.7

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 (103) hide show
  1. package/AGENTS.md +15 -3
  2. package/README.md +10 -2
  3. package/README.zh-CN.md +10 -2
  4. package/docs/interface.md +17 -0
  5. package/docs/remote-access.en.md +9 -2
  6. package/docs/remote-access.md +9 -2
  7. package/lib/adapters/dsh-better-sidebar/index.js +1 -1
  8. package/lib/client-routes.js +3 -0
  9. package/lib/client-routes.js.map +1 -1
  10. package/lib/client.js +1189 -203
  11. package/lib/fetch-route-intercept.js +1 -1
  12. package/lib/index.js +73 -16
  13. package/lib/index.js.map +1 -1
  14. package/lib/intercept.js +13 -0
  15. package/lib/intercept.js.map +1 -1
  16. package/lib/invoke-binary.js +8 -2
  17. package/lib/invoke-binary.js.map +1 -1
  18. package/lib/lan-gate-server.cjs +109 -2
  19. package/lib/owner-peer.js +23 -0
  20. package/lib/owner-peer.js.map +1 -1
  21. package/lib/plugin-adapter-core.js +2 -2
  22. package/lib/relay-access.js +24 -2
  23. package/lib/relay-access.js.map +1 -1
  24. package/lib/relay-client.js +2 -2
  25. package/lib/relay-server.js +103 -100
  26. package/lib/relay-server.js.map +1 -1
  27. package/lib/types/client/MobileAttachButton.d.ts +5 -2
  28. package/lib/types/client/MobileAttachButton.d.ts.map +1 -1
  29. package/lib/types/client/MobileAttachChips.d.ts.map +1 -1
  30. package/lib/types/client/MobileHome.d.ts.map +1 -1
  31. package/lib/types/client/RemoteFileDownload.d.ts +57 -0
  32. package/lib/types/client/RemoteFileDownload.d.ts.map +1 -0
  33. package/lib/types/client/attach-upload.d.ts +32 -0
  34. package/lib/types/client/attach-upload.d.ts.map +1 -1
  35. package/lib/types/client/effects/remote-drop.d.ts +27 -0
  36. package/lib/types/client/effects/remote-drop.d.ts.map +1 -0
  37. package/lib/types/client/index.d.ts.map +1 -1
  38. package/lib/types/client/locales.d.ts +11 -0
  39. package/lib/types/client/locales.d.ts.map +1 -1
  40. package/lib/types/client/remote-file-save.d.ts +112 -0
  41. package/lib/types/client/remote-file-save.d.ts.map +1 -0
  42. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  43. package/lib/types/client/styles/compat.css.d.ts +1 -1
  44. package/lib/types/client/styles/compat.css.d.ts.map +1 -1
  45. package/lib/types/client/styles/header.css.d.ts +1 -1
  46. package/lib/types/client/styles/header.css.d.ts.map +1 -1
  47. package/lib/types/client/styles/home.css.d.ts +1 -1
  48. package/lib/types/client/styles/home.css.d.ts.map +1 -1
  49. package/lib/types/client/styles/layout.css.d.ts +1 -1
  50. package/lib/types/client/styles/layout.css.d.ts.map +1 -1
  51. package/lib/types/client/styles/remote-session.css.d.ts +1 -1
  52. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
  53. package/lib/types/client-data/remote-drop.d.ts +99 -0
  54. package/lib/types/client-data/remote-drop.d.ts.map +1 -0
  55. package/lib/types/client-data/settings-form.d.ts +7 -0
  56. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  57. package/lib/types/client-routes.d.ts +6 -0
  58. package/lib/types/client-routes.d.ts.map +1 -1
  59. package/lib/types/index.d.ts.map +1 -1
  60. package/lib/types/intercept.d.ts.map +1 -1
  61. package/lib/types/invoke-binary.d.ts +4 -1
  62. package/lib/types/invoke-binary.d.ts.map +1 -1
  63. package/lib/types/owner-peer.d.ts +9 -0
  64. package/lib/types/owner-peer.d.ts.map +1 -1
  65. package/lib/types/plugin-adapter-core.d.ts +1 -1
  66. package/lib/types/relay-access.d.ts.map +1 -1
  67. package/lib/types/relay-server.d.ts +10 -7
  68. package/lib/types/relay-server.d.ts.map +1 -1
  69. package/package.json +36 -36
  70. package/pwa/inject.js +20 -9
  71. package/pwa/sw.js +97 -21
  72. package/scripts/check-attach-upload.mjs +37 -0
  73. package/scripts/check-remote-drop.mjs +114 -0
  74. package/scripts/check-settings-form.mjs +3 -2
  75. package/scripts/check-upload-endpoint.mjs +14 -3
  76. package/src/adapters/dsh-better-sidebar/index.ts +1 -1
  77. package/src/client/MobileAttachButton.tsx +14 -9
  78. package/src/client/MobileAttachChips.tsx +4 -0
  79. package/src/client/MobileHome.tsx +79 -12
  80. package/src/client/RemoteFileDownload.tsx +173 -0
  81. package/src/client/attach-upload.ts +47 -0
  82. package/src/client/effects/remote-drop.ts +269 -0
  83. package/src/client/index.tsx +47 -0
  84. package/src/client/locales.ts +32 -0
  85. package/src/client/remote-file-save.ts +232 -0
  86. package/src/client/settings/SettingsSection.tsx +61 -1
  87. package/src/client/styles/compat.css.ts +43 -12
  88. package/src/client/styles/header.css.ts +63 -15
  89. package/src/client/styles/home.css.ts +64 -11
  90. package/src/client/styles/layout.css.ts +24 -7
  91. package/src/client/styles/remote-session.css.ts +38 -0
  92. package/src/client-data/remote-drop.ts +127 -0
  93. package/src/client-data/settings-form.ts +10 -0
  94. package/src/client-routes.ts +8 -0
  95. package/src/fetch-route-intercept.ts +1 -1
  96. package/src/index.ts +71 -18
  97. package/src/intercept.ts +13 -0
  98. package/src/invoke-binary.ts +8 -2
  99. package/src/owner-peer.ts +32 -0
  100. package/src/plugin-adapter-core.ts +2 -2
  101. package/src/relay-access.ts +24 -2
  102. package/src/relay-client.ts +2 -2
  103. package/src/relay-server.ts +114 -90
@@ -0,0 +1,114 @@
1
+ // Behaviour check for the remote-drop decision core
2
+ // (src/client-data/remote-drop.ts): the three-gate truth table, the
3
+ // directory / no-directory / no-entry-API three-state rule,
4
+ // block-directories precedence over redispatch, and the File re-wrap
5
+ // keeping name/type/lastModified.
6
+ //
7
+ // Run: node scripts/check-remote-drop.mjs (needs Node >= 23.6 type stripping)
8
+ import assert from 'node:assert/strict'
9
+ import { readFileSync } from 'node:fs'
10
+ import {
11
+ decideRemoteDrop,
12
+ isRemoteSessionId,
13
+ rewrapFiles,
14
+ } from '../src/client-data/remote-drop.ts'
15
+
16
+ const REMOTE_ID = 'zr~0123abcd~session-1'
17
+ const LOCAL_ID = 'session-6f2e9a10-1111-2222-3333-444455556666'
18
+ const fileItem = { kind: 'file', isDirectory: false }
19
+
20
+ // ---- 1. isRemoteSessionId ---------------------------------------------------
21
+ assert.equal(isRemoteSessionId(REMOTE_ID), true, 'a virtual id is remote')
22
+ assert.equal(isRemoteSessionId(LOCAL_ID), false, 'a plain session id is not remote')
23
+ assert.equal(isRemoteSessionId(undefined), false, 'no signal is not remote')
24
+ assert.equal(isRemoteSessionId(''), false, 'an empty id is not remote')
25
+ assert.equal(isRemoteSessionId('zr~short'), false, 'the full zr~ skeleton is required, not the bare prefix')
26
+
27
+ // ---- 2. the three-gate truth table -------------------------------------------
28
+ // trusted x hasHostPaths x remote over one plain file item: exactly the
29
+ // all-true row intervenes, every failing gate lands on passthrough with its
30
+ // own reason (untrusted first, then no bridge, then local session).
31
+ const truthTable = [
32
+ [{ trusted: true, hasHostPaths: true, remote: true }, 'redispatch', 'remote-files'],
33
+ [{ trusted: true, hasHostPaths: true, remote: false }, 'passthrough', 'not-remote-session'],
34
+ [{ trusted: true, hasHostPaths: false, remote: true }, 'passthrough', 'no-host-paths'],
35
+ [{ trusted: true, hasHostPaths: false, remote: false }, 'passthrough', 'no-host-paths'],
36
+ [{ trusted: false, hasHostPaths: true, remote: true }, 'passthrough', 'untrusted-event'],
37
+ [{ trusted: false, hasHostPaths: true, remote: false }, 'passthrough', 'untrusted-event'],
38
+ [{ trusted: false, hasHostPaths: false, remote: true }, 'passthrough', 'untrusted-event'],
39
+ [{ trusted: false, hasHostPaths: false, remote: false }, 'passthrough', 'untrusted-event'],
40
+ ]
41
+ for (const [gates, action, reason] of truthTable) {
42
+ const decision = decideRemoteDrop({
43
+ trusted: gates.trusted,
44
+ hasHostPaths: gates.hasHostPaths,
45
+ currentSessionId: gates.remote ? REMOTE_ID : LOCAL_ID,
46
+ items: [fileItem],
47
+ })
48
+ assert.deepEqual(decision, { action, reason }, `gates ${JSON.stringify(gates)}`)
49
+ }
50
+
51
+ // ---- 3. directory / no-directory / no-entry-API -------------------------------
52
+ // No entry API: redispatch with the host's own "no directories known" default.
53
+ assert.deepEqual(
54
+ decideRemoteDrop({ trusted: true, hasHostPaths: true, currentSessionId: REMOTE_ID, items: undefined }),
55
+ { action: 'redispatch', reason: 'no-entry-api' },
56
+ )
57
+ // Files only: redispatch.
58
+ assert.deepEqual(
59
+ decideRemoteDrop({ trusted: true, hasHostPaths: true, currentSessionId: REMOTE_ID, items: [fileItem, fileItem] }),
60
+ { action: 'redispatch', reason: 'remote-files' },
61
+ )
62
+ // Any directory anywhere in the list: block, even mixed with plain files.
63
+ assert.deepEqual(
64
+ decideRemoteDrop({ trusted: true, hasHostPaths: true, currentSessionId: REMOTE_ID, items: [fileItem, { kind: 'file', isDirectory: true }] }),
65
+ { action: 'block-directories', reason: 'contains-directory' },
66
+ )
67
+ assert.deepEqual(
68
+ decideRemoteDrop({ trusted: true, hasHostPaths: true, currentSessionId: REMOTE_ID, items: [{ kind: 'file', isDirectory: true }] }),
69
+ { action: 'block-directories', reason: 'contains-directory' },
70
+ )
71
+ // A file item whose entry cannot be read counts as NOT a directory.
72
+ assert.deepEqual(
73
+ decideRemoteDrop({ trusted: true, hasHostPaths: true, currentSessionId: REMOTE_ID, items: [{ kind: 'file', isDirectory: false }] }),
74
+ { action: 'redispatch', reason: 'remote-files' },
75
+ )
76
+ // String-only drags (no file items): the host's Files-in-types gate ignores
77
+ // those anyway — passthrough, zero intervention.
78
+ assert.deepEqual(
79
+ decideRemoteDrop({ trusted: true, hasHostPaths: true, currentSessionId: REMOTE_ID, items: [{ kind: 'other', isDirectory: false }] }),
80
+ { action: 'passthrough', reason: 'no-files' },
81
+ )
82
+
83
+ // ---- 4. block-directories wins over redispatch ---------------------------------
84
+ // Already covered mixed above; pin it once more against the empty-items edge:
85
+ // a directory in an otherwise undecidable list still blocks.
86
+ assert.equal(
87
+ decideRemoteDrop({ trusted: true, hasHostPaths: true, currentSessionId: REMOTE_ID, items: [{ kind: 'other', isDirectory: false }, { kind: 'file', isDirectory: true }] }).action,
88
+ 'block-directories',
89
+ )
90
+
91
+ // ---- 5. rewrapFiles keeps name / type / lastModified ----------------------------
92
+ const original = new File(['hello'], 'report.pdf', { type: 'application/pdf', lastModified: 1700000000000 })
93
+ const [rewrapped] = rewrapFiles([original])
94
+ assert.notEqual(rewrapped, original, 'the rewrap must produce a distinct File')
95
+ assert.equal(rewrapped.name, 'report.pdf')
96
+ assert.equal(rewrapped.type, 'application/pdf')
97
+ assert.equal(rewrapped.lastModified, 1700000000000)
98
+ assert.equal(rewrapped.size, original.size, 'the content must ride along')
99
+ // Multiple files keep their order, and any iterable source works.
100
+ const pair = rewrapFiles([original, new File([new Uint8Array([1, 2])], 'a.txt', { type: 'text/plain', lastModified: 1 })])
101
+ assert.deepEqual(pair.map((f) => f.name), ['report.pdf', 'a.txt'])
102
+
103
+ // ---- 6. the folder-block branch dismisses the host drop overlay ---------------
104
+ // When a directory drop is stopped outright (no synthetic drop is redispatched),
105
+ // the host's bubble drop handler never runs reset(); the guard must fire a
106
+ // synthetic window `dragend` via dismissHostDropOverlay() so the overlay closes.
107
+ {
108
+ const effects = readFileSync(new URL('../src/client/effects/remote-drop.ts', import.meta.url), 'utf8')
109
+ assert.ok(effects.includes('function dismissHostDropOverlay'), 'the guard defines dismissHostDropOverlay')
110
+ const calls = effects.match(/^\s*dismissHostDropOverlay\(\)\s*$/gm)?.length ?? 0
111
+ assert.equal(calls, 1, `the folder-block branch calls dismissHostDropOverlay() as a standalone statement (saw ${calls})`)
112
+ }
113
+
114
+ console.log('check-remote-drop: ok')
@@ -457,8 +457,9 @@ test('the latest-wins gate drops tickets that are no longer newest (T15-fix 4)',
457
457
  test('deriveClientStatusView maps every probe state and tolerates garbage', () => {
458
458
  // T59: the view carries the device's own name ('' when the body had none).
459
459
  // PB-5: the bridge rows ([] — the body carried none) and the rerouting
460
- // note (undefined) ride the same tolerant derivation.
461
- const bare = { state: 'unpaired', serverUrl: '', serverName: '', deviceName: '', nextRetryAt: undefined, lastError: '', intercept: undefined, compat: undefined, pluginBridge: [], pluginRouteIntercept: undefined }
460
+ // note (undefined) ride the same tolerant derivation. RD-6: the
461
+ // upload-redirect probe (undefined — the body carried no serviceProbe).
462
+ const bare = { state: 'unpaired', serverUrl: '', serverName: '', deviceName: '', nextRetryAt: undefined, lastError: '', intercept: undefined, compat: undefined, pluginBridge: [], pluginRouteIntercept: undefined, uploadProbe: undefined }
462
463
  assert.deepEqual(deriveClientStatusView({ state: 'unpaired' }), bare)
463
464
  assert.deepEqual(deriveClientStatusView({ state: 'connected', serverUrl: 'http://192.168.1.10:3088' }), {
464
465
  ...bare,
@@ -89,6 +89,17 @@ try {
89
89
  assert.equal(ok.body.bytes, Buffer.byteLength(payload))
90
90
  assert.equal(await readFile(join(workspace, UPLOAD_DIR, 'notes.txt'), 'utf8'), payload)
91
91
 
92
+ // ---- the upload dir self-ignores in git ---------------------------------
93
+ // Files dropped into a session workspace would otherwise surface as
94
+ // untracked entries — and an agent's `git add -A` would commit the user's
95
+ // uploads. The directory ships a self-ignoring .gitignore created with the
96
+ // directory itself; a pre-existing (user-customized) one is never touched.
97
+ assert.equal(
98
+ await readFile(join(workspace, UPLOAD_DIR, '.gitignore'), 'utf8'),
99
+ '*\n!.gitignore\n',
100
+ 'upload dir gets a self-ignoring .gitignore',
101
+ )
102
+
92
103
  // ---- same name twice does not clobber -----------------------------------
93
104
  const again = await post('?session=session-live&name=notes.txt', 'second')
94
105
  assert.equal(again.status, 201)
@@ -165,7 +176,7 @@ try {
165
176
 
166
177
  // ---- the workspace holds exactly what was uploaded, nothing partial ------
167
178
  assert.deepEqual((await readdir(join(workspace, UPLOAD_DIR))).sort(), [
168
- 'blob.bin', 'evil.txt', 'notes-1.txt', 'notes.txt',
179
+ '.gitignore', 'blob.bin', 'evil.txt', 'notes-1.txt', 'notes.txt',
169
180
  ])
170
181
 
171
182
  // ---- T17: the admission wall the apply() wiring passes in ----------------
@@ -205,8 +216,8 @@ try {
205
216
  assert.equal(body.error.code, status === 401 ? 'unauthorized' : 'forbidden')
206
217
  }
207
218
  assert.deepEqual(await readdir(join(workspace, UPLOAD_DIR)), [
208
- 'blob.bin', 'evil.txt', 'notes-1.txt', 'notes.txt',
209
- ], 'a refused admit writes nothing')
219
+ '.gitignore', 'blob.bin', 'evil.txt', 'notes-1.txt', 'notes.txt',
220
+ ], 'a refused admit writes nothing (the .gitignore predates it, created with the dir)')
210
221
  assert.equal(logged, 7, 'the admission wall short-circuits before the logger, like the 405/403')
211
222
 
212
223
  const allowed = await fetch(`${admitBases[2]}/_dsh/mobile-nav/upload?session=session-live&name=admitted.txt`, {
@@ -38,7 +38,7 @@ import { SIDEBAR_METHODS, sidebarMethodNameOf } from './methods.js'
38
38
  * modules — and pinned to the original by a test in
39
39
  * test/adapter-better-sidebar.test.cjs, which fails the pair apart when
40
40
  * either side moves. */
41
- const RELAY_UPLOAD_CAP_BYTES = 100 * 1024 * 1024
41
+ const RELAY_UPLOAD_CAP_BYTES = 500 * 1024 * 1024
42
42
 
43
43
  /** better-sidebar's own JSON body bound (lib/index.js:863
44
44
  * `MAX_BODY_BYTES = 1 << 20`) — the adapter forwards at most what the
@@ -4,7 +4,7 @@ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots
4
4
  import { IconPaperclipOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
5
5
  import type { SessionId } from './compat/types.ts'
6
6
  import { NS } from './locales.ts'
7
- import { draftWithMention, rememberThumbnail, uploadUrl } from './attach-upload.ts'
7
+ import { appendDraftOf, rememberThumbnail, uploadUrl, writeMention } from './attach-upload.ts'
8
8
  import { useOwnAttachUi } from './host-attach.ts'
9
9
 
10
10
  /** Full props for the composer attachment seat. */
@@ -49,8 +49,11 @@ async function uploadFile(sessionId: SessionId, file: File): Promise<string> {
49
49
  * host without one (DSH ≤ 0.1.2) everything below loads exactly as before.
50
50
  *
51
51
  * Every picked file takes the SAME path: upload to the node half, then append
52
- * `@.dsh-uploads/name` to the draft through `inputActions.setDraft` (the
53
- * official write path — no DOM value poking). S7.1 removed the split that used
52
+ * `@.dsh-uploads/name` to the draft through the official write path — no DOM
53
+ * value poking: `inputActions.appendDraft` where the running DSH exposes it
54
+ * (0.2.1-rc.3+ editor runtime; keeps the user's text and inline references),
55
+ * else `inputActions.setDraft` with the whole next draft (attach-upload.ts
56
+ * `writeMention`). S7.1 removed the split that used
54
57
  * to send inlineable images straight into the session with `session.prompt`:
55
58
  * one behaviour for every attachment, and the user always presses send.
56
59
  * MobileAttachChips renders the preview row off those same draft tokens.
@@ -84,19 +87,21 @@ export function MobileAttachButton({ t, sessionId, useInput, inputActions }: Mob
84
87
 
85
88
  setBusy(true)
86
89
  setError(null)
87
- // setDraft takes the WHOLE next draft, so each mention is appended to the
88
- // value we last wrote — unless the ref shows the user has typed since, in
89
- // which case theirs wins and we append to that instead. (Rebasing on the
90
- // ref unconditionally would lose a mention whenever React had not yet
90
+ // appendDraft (DSH 0.2.1-rc.3+) appends after whatever the composer holds
91
+ // right now, so no rebasing is needed. The setDraft fallback takes the
92
+ // WHOLE next draft, so each mention is appended to the value we last
93
+ // wrote — unless the ref shows the user has typed since, in which case
94
+ // theirs wins and we append to that instead. (Rebasing on the ref
95
+ // unconditionally would lose a mention whenever React had not yet
91
96
  // re-rendered us between two files of the same pick.)
97
+ const appends = appendDraftOf(inputActions) !== undefined
92
98
  let written = liveDraft.current
93
99
  try {
94
100
  for (const file of files) {
95
101
  const relPath = await uploadFile(sessionId, file)
96
102
  // Local preview for the chip row; a no-op for non-images.
97
103
  rememberThumbnail(relPath, file)
98
- written = draftWithMention(liveDraft.current === written ? written : liveDraft.current, relPath)
99
- inputActions.setDraft(written)
104
+ written = writeMention(inputActions, (appends || liveDraft.current === written) ? written : liveDraft.current, relPath)
100
105
  }
101
106
  } catch (cause) {
102
107
  setError(cause instanceof Error ? cause.message : String(cause))
@@ -80,6 +80,10 @@ export function MobileAttachChips({ t, useInput, inputActions }: MobileAttachChi
80
80
  type="button"
81
81
  data-mobile-nav="attach-chip-remove"
82
82
  aria-label={t('attachRemove', { name })}
83
+ // Removal stays on setDraft even where the host has appendDraft
84
+ // (DSH 0.2.1-rc.3+): append can only add text, and the public
85
+ // inputActions face has no span-replace verb, so lifting one
86
+ // token out still means writing the whole next draft.
83
87
  onClick={() => inputActions.setDraft(draftWithoutMention(draft, relPath))}
84
88
  >
85
89
  <IconCloseFillRegular size={14} />
@@ -1,4 +1,4 @@
1
- import { useEffect, useMemo, useRef, useState } from 'react'
1
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
2
2
  import type { ReactNode, TouchEvent as ReactTouchEvent } from 'react'
3
3
  import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
4
4
  import {
@@ -368,6 +368,23 @@ export function MobileHome({
368
368
  return () => window.removeEventListener(GO_HOME_EVENT, onGoHome)
369
369
  }, [actions])
370
370
 
371
+ // Mirror the current phone view onto the AppFrame element itself so CSS inside
372
+ // the main column (session header hide + fade strip gate) can key off a direct
373
+ // ancestor attribute instead of relying solely on cross-subtree body:has(...)
374
+ // invalidation on iOS WebKit (especially across installViewportHeal's display
375
+ // toggle on standalone PWA launch).
376
+ useLayoutEffect(() => {
377
+ const frame = document.querySelector<HTMLElement>('[data-mobile-nav="frame"]')
378
+ ?? document.querySelector('[data-shell-overlay]')?.parentElement
379
+ ?? null
380
+ if (!phone || frame === null) {
381
+ frame?.removeAttribute('data-mobile-view')
382
+ return
383
+ }
384
+ frame.setAttribute('data-mobile-view', view)
385
+ return () => frame.removeAttribute('data-mobile-view')
386
+ }, [phone, view])
387
+
371
388
  const enter = (start: () => void): void => {
372
389
  start()
373
390
  setSheet(null)
@@ -376,19 +393,69 @@ export function MobileHome({
376
393
  }
377
394
 
378
395
  // Opens the OFFICIAL settings modal (dsh-client-ui-settings-general's
379
- // SettingsRoot) by clicking its real trigger button — its "open" state is
396
+ // SettingsRoot) by clicking its real trigger — its "open" state is
380
397
  // component-local React state with no public setter, same reasoning as
381
- // MobileSessionHeader's Chat/Trajectory tab click. The trigger mounts
382
- // inside the sidebar's `sidebar.settings` footer slot
383
- // (`[class$="_settingsArea"]`, the sidebar shell's stable class suffix —
384
- // see dsh-client-ui-sidebar's SidebarRoot), which is display:none on the
385
- // phone breakpoint (home.css.ts): `.click()` still dispatches (bypasses
386
- // hit-testing, S2.1 precedent) and flips `open`, but the resulting
387
- // `aria-modal="true"` dialog would still paint nothing without the portal
388
- // fix in styles/chips.css.ts (`:has([aria-modal="true"])` un-hides exactly
389
- // the ancestor chain down to it, not the whole sidebar).
398
+ // MobileSessionHeader's Chat/Trajectory tab click. The trigger lives in the
399
+ // sidebar's `sidebar.settings` footer slot (`[class$="_settingsArea"]`, the
400
+ // sidebar shell's stable class suffix — see dsh-client-ui-sidebar's
401
+ // SidebarRoot), which is display:none on the phone breakpoint
402
+ // (home.css.ts): `.click()` still dispatches (bypasses hit-testing, S2.1
403
+ // precedent) and flips `open`.
404
+ //
405
+ // DSH 0.2.1-rc.2 moved the entry: that slot now holds only the ACCOUNT
406
+ // launcher (aria-haspopup="menu"), and Settings is an item inside its menu
407
+ // — the `button[aria-haspopup="dialog"]` this used to forward to is gone
408
+ // (and the resulting dialog is a body portal now, so the chips.css.ts
409
+ // ancestor un-hide below no longer has anything to reveal). Hence the
410
+ // menu fallback; both paths keep the label-free, structure-free
411
+ // "dispatch a click on the official control" approach.
390
412
  const openSettings = (): void => {
391
- document.querySelector<HTMLButtonElement>('[class$="_settingsArea"] button[aria-haspopup="dialog"]')?.click()
413
+ const area = document.querySelector<HTMLElement>('[class$="_settingsArea"]')
414
+ // Older DSH: the settings modal's own trigger sits in the settings area.
415
+ const direct = area?.querySelector<HTMLButtonElement>('button[aria-haspopup="dialog"]')
416
+ if (direct) {
417
+ direct.click()
418
+ return
419
+ }
420
+ // DSH 0.2.1-rc.2: the sidebar footer's settings area now holds only the
421
+ // ACCOUNT launcher (aria-haspopup="menu", data-signed-out), and 设置 /
422
+ // Settings is an item inside its menu — the dialog trigger this used to
423
+ // forward to no longer exists there, so the bare
424
+ // querySelector()?.click() silently no-oped (real-phone report
425
+ // 2026-10-21: "手机端右上角设置点了没反应"). Open the menu, then click the
426
+ // settings item.
427
+ //
428
+ // Two constraints from review: (1) menus are body portals and a sibling
429
+ // menu (session actions, model picker) may already be mounted, so the
430
+ // item must come from the menu THIS click opened — snapshot the existing
431
+ // ones and pick the newly mounted menu; (2) the item is located without
432
+ // depending on its label, because the account menu's copy is localized:
433
+ // 设置 / Settings is the FIRST menuitem in both the signed-in and the
434
+ // signed-out variant (AccountMenu items: settings, contact, signin/out),
435
+ // so the first item is the locale-independent anchor, with the label as
436
+ // a refinement if that order ever changes.
437
+ const launcher = area?.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"], button')
438
+ if (!launcher) return
439
+ const before = new Set<Element>(document.querySelectorAll('[role="menu"]'))
440
+ launcher.click()
441
+ const pick = (): boolean => {
442
+ const menu = [...document.querySelectorAll<HTMLElement>('[role="menu"]')].find((el) => !before.has(el))
443
+ if (!menu) return false
444
+ const items = [...menu.querySelectorAll<HTMLButtonElement>('button[role="menuitem"]')]
445
+ const item = items.find((el) => /设置|Settings/i.test(el.textContent || '')) ?? items[0]
446
+ if (!item) return false
447
+ item.click()
448
+ return true
449
+ }
450
+ // React commits the menu in the click's own task; observe for the mount
451
+ // (a deferred sibling launcher cannot leave a stale poll behind) and give
452
+ // up instead of leaking the observer if it never appears.
453
+ if (pick()) return
454
+ const observer = new MutationObserver(() => {
455
+ if (pick()) observer.disconnect()
456
+ })
457
+ observer.observe(document.body, { childList: true, subtree: true })
458
+ setTimeout(() => observer.disconnect(), 1000)
392
459
  }
393
460
 
394
461
  if (!phone) return null
@@ -0,0 +1,173 @@
1
+ /**
2
+ * The "save this file to THIS machine" action for a REMOTE session (2.2.6).
3
+ *
4
+ * DSH's produced-file surfaces only ever OPEN a file: the deliverable card's
5
+ * overlay previews it in the document panel, and open-in-app's split button
6
+ * hands the path to the machine the DSH process runs on. On a paired desktop
7
+ * sub-client both of those are the HOST's machine, which is why the
8
+ * remote-session stylesheet hides the open controls — leaving a produced
9
+ * Word/PDF/zip with no way to reach the client's disk at all. This action
10
+ * fills exactly that gap: it reads the file through the relayed
11
+ * `workspaceFiles/readBytes` channel (src/client/remote-file-save.ts) and
12
+ * hands the bytes to the browser's download machinery, so the file lands on
13
+ * the machine whose screen the user is looking at.
14
+ *
15
+ * Two surfaces, one button:
16
+ *
17
+ * - `deliverables.file.actions` — the file card of a closing turn. The slot
18
+ * deliberately hands its entries only the open-route coordinates
19
+ * (`actionUrl`), NOT the path; the card's own preview button carries the
20
+ * resolved absolute path in its `title` attribute (RT
21
+ * dsh-client-ui-deliverables PresentedFileCard: `title:
22
+ * resolveWorkspacePath(cwd, file.path)`), and the entry mounts INSIDE that
23
+ * card (`[data-presented-file]`), so the button reads the same host-rendered
24
+ * contract the plugin's stylesheets already anchor on. A card without the
25
+ * attribute (a future host) simply renders no path and the button reports
26
+ * the failure instead of guessing.
27
+ * - `sidebar.right.tab.document.actions` — the document preview header, where
28
+ * the owner hands `absolutePath` directly (RT
29
+ * dsh-client-ui-sidebar-documentpreview TextPreview `fileOwner`), so the
30
+ * same file can be saved without hunting for the card again.
31
+ *
32
+ * Both registrations are unconditional; both COMPONENTS return null for a
33
+ * local session (`isVirtual`), so a host-role deployment — and every local
34
+ * session on a client deployment — renders exactly nothing.
35
+ */
36
+ import { useRef, useState } from 'react'
37
+ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
38
+ import type { SessionId } from './compat/types.ts'
39
+ import { isVirtual } from '../virtual-id.js'
40
+ import { NS } from './locales.ts'
41
+ import { downloadPathFromCard } from './remote-file-save.ts'
42
+ import type { SaveRemoteFile } from './remote-file-save.ts'
43
+
44
+ /**
45
+ * Structural mirrors of the two host slot contracts. This package does not
46
+ * depend on dsh-client-ui-deliverables / dsh-client-ui-sidebar-documentpreview
47
+ * (the owners' type-only merges would normally provide these), so the mirror
48
+ * records ONLY what this action reads: both slots are session-scoped lists;
49
+ * the card carries the open-route coordinates, the document header carries
50
+ * the resolved absolute path.
51
+ */
52
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
53
+ interface SlotMap {
54
+ 'deliverables.file.actions': {
55
+ kind: 'list'
56
+ scope: 'session'
57
+ owner: {
58
+ actionUrl: string
59
+ available: boolean
60
+ pending: boolean
61
+ onAction: (action: string, application?: string) => unknown
62
+ }
63
+ }
64
+ 'sidebar.right.tab.document.actions': {
65
+ kind: 'list'
66
+ scope: 'session'
67
+ owner: { absolutePath: string }
68
+ }
69
+ }
70
+ }
71
+
72
+ type DownloadPhase = 'idle' | 'busy' | 'error'
73
+
74
+ /** Translate seat of this plugin's namespace. */
75
+ type Translate = PropsLocale<typeof NS>['t']
76
+
77
+ /** The button's own inputs: the framework session id, the bound save
78
+ * capability, the copy, and — only on the document header — the absolute
79
+ * path; the card surface resolves its path from the card DOM. */
80
+ interface DownloadButtonProps {
81
+ sessionId: SessionId
82
+ t: Translate
83
+ saveFile: SaveRemoteFile
84
+ absolutePath?: string | undefined
85
+ }
86
+
87
+ /**
88
+ * The save control. One download at a time; the label carries the three
89
+ * states (idle / saving / failed) so a failure is visible on the card instead
90
+ * of a silent no-op.
91
+ * @param props - session, copy, save capability and (header only) the path.
92
+ * @returns the button.
93
+ */
94
+ function DownloadButton({ sessionId, t, saveFile, absolutePath }: DownloadButtonProps) {
95
+ const [phase, setPhase] = useState<DownloadPhase>('idle')
96
+ const [failure, setFailure] = useState<string | undefined>(undefined)
97
+ const buttonRef = useRef<HTMLButtonElement | null>(null)
98
+ const onClick = async (): Promise<void> => {
99
+ if (phase === 'busy') return
100
+ const path = absolutePath ?? downloadPathFromCard(buttonRef.current)
101
+ setFailure(undefined)
102
+ if (path === undefined) {
103
+ setFailure('no-path')
104
+ setPhase('error')
105
+ return
106
+ }
107
+ setPhase('busy')
108
+ try {
109
+ await saveFile(sessionId, path)
110
+ setPhase('idle')
111
+ } catch (error) {
112
+ // The reason is rendered INSIDE the button (short) and logged for the
113
+ // console: without it a failed download on a client we cannot debug is
114
+ // a dead end.
115
+ const reason = (error instanceof Error ? error.message : String(error)).trim()
116
+ console.error('[dsh-zen-remote] remote download failed:', path, error)
117
+ setFailure(reason === '' ? 'unknown' : reason.slice(0, 22))
118
+ setPhase('error')
119
+ }
120
+ }
121
+ const label = phase === 'busy' ? t('downloadBusy') : phase === 'error' ? (failure === undefined ? t('downloadFailed') : `${t('downloadFailed')} (${failure})`) : t('downloadToLocal')
122
+ return (
123
+ <button
124
+ ref={buttonRef}
125
+ type="button"
126
+ className="zr-download-action"
127
+ data-phase={phase}
128
+ aria-label={t('downloadToLocal')}
129
+ title={failure === undefined ? t('downloadToLocal') : `${t('downloadToLocal')} — ${failure}`}
130
+ onClick={() => {
131
+ void onClick()
132
+ }}
133
+ >
134
+ {label}
135
+ </button>
136
+ )
137
+ }
138
+
139
+ /** Props the delivered-file card entry receives. */
140
+ export interface DeliverableDownloadProps
141
+ extends PropsRuntime<'deliverables.file.actions'>, PropsLocale<typeof NS> {
142
+ /** Bound by this plugin's registration: the relayed read+save capability. */
143
+ saveFile: SaveRemoteFile
144
+ }
145
+
146
+ /**
147
+ * The deliverable card entry. Renders nothing on a local session.
148
+ * @param props - the card slot's owner props plus the injected save
149
+ * capability and copy.
150
+ * @returns the save button, or null.
151
+ */
152
+ export function DeliverableDownload({ sessionId, t, saveFile }: DeliverableDownloadProps) {
153
+ if (!isVirtual(sessionId)) return null
154
+ return <DownloadButton sessionId={sessionId} t={t} saveFile={saveFile} />
155
+ }
156
+
157
+ /** Props the document-preview header entry receives. */
158
+ export interface DocumentDownloadProps
159
+ extends PropsRuntime<'sidebar.right.tab.document.actions'>, PropsLocale<typeof NS> {
160
+ /** Bound by this plugin's registration: the relayed read+save capability. */
161
+ saveFile: SaveRemoteFile
162
+ }
163
+
164
+ /**
165
+ * The document preview header entry. Renders nothing on a local session.
166
+ * @param props - the document slot's owner props plus the injected save
167
+ * capability and copy.
168
+ * @returns the save button, or null.
169
+ */
170
+ export function DocumentDownload({ sessionId, t, saveFile, absolutePath }: DocumentDownloadProps) {
171
+ if (!isVirtual(sessionId)) return null
172
+ return <DownloadButton sessionId={sessionId} t={t} saveFile={saveFile} absolutePath={absolutePath} />
173
+ }
@@ -63,6 +63,53 @@ export function draftWithMention(draft: string, relPath: string): string {
63
63
  return `${draft} @${relPath} `
64
64
  }
65
65
 
66
+ /**
67
+ * The two draft writers the attachment flow can use. `setDraft` replaces the
68
+ * WHOLE draft with plain text (and, on DSH 0.2.1+, drops any inline reference
69
+ * chips the user had in it); `appendDraft` adds text after the current draft
70
+ * as new paragraphs and keeps its text and references. DSH 0.2.1-rc.3 grew
71
+ * `appendDraft` on the composer editor runtime; it is optional here because
72
+ * older hosts — and, as of rc.3, the public slot `inputActions` face itself —
73
+ * do not carry it, so it is feature-detected at runtime, never assumed.
74
+ */
75
+ export interface DraftWriter {
76
+ setDraft(text: string): void
77
+ appendDraft?: (text: string) => void
78
+ }
79
+
80
+ /**
81
+ * The host's non-destructive append, when the running DSH exposes one.
82
+ * @param actions - the slot's `inputActions` (or any object carrying them).
83
+ * @returns a bound `appendDraft`, or undefined when the host has none.
84
+ */
85
+ export function appendDraftOf(actions: object): ((text: string) => void) | undefined {
86
+ const append = (actions as { appendDraft?: unknown }).appendDraft
87
+ return typeof append === 'function' ? (append as (text: string) => void).bind(actions) : undefined
88
+ }
89
+
90
+ /**
91
+ * Write one `@path` mention into the composer.
92
+ *
93
+ * Prefers `appendDraft` (no whole-draft rewrite, so nothing the user typed or
94
+ * referenced meanwhile can be lost); falls back to `setDraft` with
95
+ * {@link draftWithMention} over `base` on a host without it.
96
+ * @param actions - the draft writers.
97
+ * @param base - the draft to rebase onto for the `setDraft` fallback.
98
+ * @param relPath - workspace-relative path returned by the upload route.
99
+ * @returns the draft text the fallback wrote, or `base` unchanged when the
100
+ * host appended for us (the next render's `useInput` carries the result).
101
+ */
102
+ export function writeMention(actions: DraftWriter, base: string, relPath: string): string {
103
+ const append = appendDraftOf(actions)
104
+ if (append !== undefined) {
105
+ append(`@${relPath} `)
106
+ return base
107
+ }
108
+ const next = draftWithMention(base, relPath)
109
+ actions.setDraft(next)
110
+ return next
111
+ }
112
+
66
113
  /**
67
114
  * Remove one `@path` mention from the draft — what a chip's × does.
68
115
  *