dsh-zen-remote 1.0.0

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 (136) hide show
  1. package/AGENTS.md +51 -0
  2. package/LICENSE +38 -0
  3. package/README.md +237 -0
  4. package/assets/home.png +0 -0
  5. package/assets/info.png +0 -0
  6. package/assets/pairing.png +0 -0
  7. package/assets/session.png +0 -0
  8. package/assets/sheet.png +0 -0
  9. package/cordis.patch.yml +17 -0
  10. package/cordis.patch.yml.example +16 -0
  11. package/docs/interface.md +200 -0
  12. package/docs/remote-access.en.md +335 -0
  13. package/docs/remote-access.md +422 -0
  14. package/dsh-push.mjs +213 -0
  15. package/lan-gate.mjs +78 -0
  16. package/lib/client.js +6615 -0
  17. package/lib/index.js +310 -0
  18. package/lib/index.js.map +1 -0
  19. package/lib/lan-gate-server.cjs +626 -0
  20. package/lib/types/client/MobileAttachButton.d.ts +25 -0
  21. package/lib/types/client/MobileAttachButton.d.ts.map +1 -0
  22. package/lib/types/client/MobileAttachChips.d.ts +32 -0
  23. package/lib/types/client/MobileAttachChips.d.ts.map +1 -0
  24. package/lib/types/client/MobileDrawerFooter.d.ts +21 -0
  25. package/lib/types/client/MobileDrawerFooter.d.ts.map +1 -0
  26. package/lib/types/client/MobileHome.d.ts +24 -0
  27. package/lib/types/client/MobileHome.d.ts.map +1 -0
  28. package/lib/types/client/MobileHomeChips.d.ts +76 -0
  29. package/lib/types/client/MobileHomeChips.d.ts.map +1 -0
  30. package/lib/types/client/MobileNavOverlay.d.ts +15 -0
  31. package/lib/types/client/MobileNavOverlay.d.ts.map +1 -0
  32. package/lib/types/client/MobileNavToggle.d.ts +18 -0
  33. package/lib/types/client/MobileNavToggle.d.ts.map +1 -0
  34. package/lib/types/client/MobileSessionHeader.d.ts +58 -0
  35. package/lib/types/client/MobileSessionHeader.d.ts.map +1 -0
  36. package/lib/types/client/MobileSessionInfo.d.ts +41 -0
  37. package/lib/types/client/MobileSessionInfo.d.ts.map +1 -0
  38. package/lib/types/client/attach-upload.d.ts +78 -0
  39. package/lib/types/client/attach-upload.d.ts.map +1 -0
  40. package/lib/types/client/chips-store.d.ts +13 -0
  41. package/lib/types/client/chips-store.d.ts.map +1 -0
  42. package/lib/types/client/debug.d.ts +9 -0
  43. package/lib/types/client/debug.d.ts.map +1 -0
  44. package/lib/types/client/effects/aionui-compat.d.ts +4 -0
  45. package/lib/types/client/effects/aionui-compat.d.ts.map +1 -0
  46. package/lib/types/client/effects/gestures.d.ts +7 -0
  47. package/lib/types/client/effects/gestures.d.ts.map +1 -0
  48. package/lib/types/client/effects/header-status.d.ts +15 -0
  49. package/lib/types/client/effects/header-status.d.ts.map +1 -0
  50. package/lib/types/client/effects/phone-chrome.d.ts +106 -0
  51. package/lib/types/client/effects/phone-chrome.d.ts.map +1 -0
  52. package/lib/types/client/effects/turn-fold.d.ts +25 -0
  53. package/lib/types/client/effects/turn-fold.d.ts.map +1 -0
  54. package/lib/types/client/index.d.ts +18 -0
  55. package/lib/types/client/index.d.ts.map +1 -0
  56. package/lib/types/client/locales.d.ts +58 -0
  57. package/lib/types/client/locales.d.ts.map +1 -0
  58. package/lib/types/client/nav-store.d.ts +73 -0
  59. package/lib/types/client/nav-store.d.ts.map +1 -0
  60. package/lib/types/client/session-dot.d.ts +12 -0
  61. package/lib/types/client/session-dot.d.ts.map +1 -0
  62. package/lib/types/client/styles/base.css.d.ts +2 -0
  63. package/lib/types/client/styles/base.css.d.ts.map +1 -0
  64. package/lib/types/client/styles/chips.css.d.ts +2 -0
  65. package/lib/types/client/styles/chips.css.d.ts.map +1 -0
  66. package/lib/types/client/styles/compat.css.d.ts +2 -0
  67. package/lib/types/client/styles/compat.css.d.ts.map +1 -0
  68. package/lib/types/client/styles/composer.css.d.ts +2 -0
  69. package/lib/types/client/styles/composer.css.d.ts.map +1 -0
  70. package/lib/types/client/styles/header.css.d.ts +2 -0
  71. package/lib/types/client/styles/header.css.d.ts.map +1 -0
  72. package/lib/types/client/styles/home.css.d.ts +2 -0
  73. package/lib/types/client/styles/home.css.d.ts.map +1 -0
  74. package/lib/types/client/styles/index.d.ts +15 -0
  75. package/lib/types/client/styles/index.d.ts.map +1 -0
  76. package/lib/types/client/styles/info.css.d.ts +2 -0
  77. package/lib/types/client/styles/info.css.d.ts.map +1 -0
  78. package/lib/types/client/styles/layout.css.d.ts +2 -0
  79. package/lib/types/client/styles/layout.css.d.ts.map +1 -0
  80. package/lib/types/client/styles/misc.css.d.ts +2 -0
  81. package/lib/types/client/styles/misc.css.d.ts.map +1 -0
  82. package/lib/types/client/styles/turn-fold.css.d.ts +2 -0
  83. package/lib/types/client/styles/turn-fold.css.d.ts.map +1 -0
  84. package/lib/types/index.d.ts +83 -0
  85. package/lib/types/index.d.ts.map +1 -0
  86. package/llms.txt +20 -0
  87. package/package.json +128 -0
  88. package/pwa/app.css +95 -0
  89. package/pwa/icons/icon-192.png +0 -0
  90. package/pwa/icons/icon-512.png +0 -0
  91. package/pwa/icons/icon-maskable-512.png +0 -0
  92. package/pwa/icons/icon-maskable.svg +1 -0
  93. package/pwa/icons/icon.svg +1 -0
  94. package/pwa/inject.js +190 -0
  95. package/pwa/manifest.json +29 -0
  96. package/pwa/offline.html +38 -0
  97. package/pwa/sw.js +176 -0
  98. package/pwa/touch-gestures.js +94 -0
  99. package/scripts/build-client.mjs +101 -0
  100. package/scripts/cdp-probe.mjs +64 -0
  101. package/scripts/check-attach-upload.mjs +99 -0
  102. package/scripts/check-sunk-viewport.mjs +33 -0
  103. package/scripts/check-upload-endpoint.mjs +168 -0
  104. package/src/client/MobileAttachButton.tsx +125 -0
  105. package/src/client/MobileAttachChips.tsx +85 -0
  106. package/src/client/MobileDrawerFooter.tsx +56 -0
  107. package/src/client/MobileHome.tsx +348 -0
  108. package/src/client/MobileHomeChips.tsx +426 -0
  109. package/src/client/MobileNavOverlay.tsx +279 -0
  110. package/src/client/MobileNavToggle.tsx +52 -0
  111. package/src/client/MobileSessionHeader.tsx +242 -0
  112. package/src/client/MobileSessionInfo.tsx +303 -0
  113. package/src/client/attach-upload.ts +140 -0
  114. package/src/client/chips-store.ts +41 -0
  115. package/src/client/debug.ts +253 -0
  116. package/src/client/effects/aionui-compat.ts +183 -0
  117. package/src/client/effects/gestures.ts +265 -0
  118. package/src/client/effects/header-status.ts +30 -0
  119. package/src/client/effects/phone-chrome.ts +265 -0
  120. package/src/client/effects/turn-fold.ts +258 -0
  121. package/src/client/index.tsx +227 -0
  122. package/src/client/locales.ts +109 -0
  123. package/src/client/nav-store.ts +78 -0
  124. package/src/client/session-dot.ts +17 -0
  125. package/src/client/styles/base.css.ts +167 -0
  126. package/src/client/styles/chips.css.ts +224 -0
  127. package/src/client/styles/compat.css.ts +681 -0
  128. package/src/client/styles/composer.css.ts +504 -0
  129. package/src/client/styles/header.css.ts +368 -0
  130. package/src/client/styles/home.css.ts +477 -0
  131. package/src/client/styles/index.ts +25 -0
  132. package/src/client/styles/info.css.ts +238 -0
  133. package/src/client/styles/layout.css.ts +501 -0
  134. package/src/client/styles/misc.css.ts +128 -0
  135. package/src/client/styles/turn-fold.css.ts +106 -0
  136. package/src/index.ts +343 -0
@@ -0,0 +1,303 @@
1
+ import { useEffect, useState } from 'react'
2
+ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
3
+ import {
4
+ IconArchiveOutline20,
5
+ IconBranchOutline16,
6
+ IconCloseOutline16,
7
+ IconDownloadOutline16,
8
+ IconEditOutline16,
9
+ } from '@deepseek-ai/dsh-client-ui-primitives'
10
+ import { workspaceTitleOf } from '@deepseek-ai/dsh-client-runtime/client'
11
+ import type { SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
12
+ import { NS } from './locales.ts'
13
+ import { GO_HOME_EVENT, SESSION_INFO_EVENT } from './nav-store.ts'
14
+ import { useViewTabs } from './MobileSessionHeader.tsx'
15
+
16
+ // Type-only: pulls the 'sessionStats' / 'tokenUsage' SessionProjectionMap
17
+ // merges into the program so useProjection('sessionStats' | 'tokenUsage')
18
+ // below type-checks (see appendix D: the official StatsLine, the only other
19
+ // consumer, lives in dsh-client-ui-conversation but never re-exports these
20
+ // merges from a file this package's type graph reaches — devDependencies +
21
+ // tsconfig.client.json path entries added for exactly this).
22
+ import type {} from '@deepseek-ai/dsh-session-stats/client'
23
+ import type {} from '@deepseek-ai/dsh-token-meter/client'
24
+
25
+ /** Full props for the session-info sheet (header.utilities, second entry). */
26
+ export type MobileSessionInfoProps =
27
+ & PropsRuntime<'conversation.session.header.utilities'>
28
+ & PropsLocale<typeof NS>
29
+ & {
30
+ /** Bound ctx.sessions.fork({sessionId}). */
31
+ forkSession: (sessionId: SessionId) => Promise<SessionId>
32
+ /** Bound ctx.sessions.open(id) — lands on the freshly forked session. */
33
+ openSession: (id: SessionId) => void
34
+ /** Bound ctx.sessions.binding(id)?.session.rename(title); undefined when the binding is gone. */
35
+ renameSession: (sessionId: SessionId, title: string) => ReturnType<SessionFace['rename']> | undefined
36
+ /** Bound ctx.workspaces.archiveSession(sessionId). */
37
+ archiveSession: (sessionId: SessionId) => Promise<void>
38
+ /** Bound ctx.sessionLogDownload.download(sessionId) — owns its own progress/result modal. */
39
+ downloadSessionLog: (sessionId: SessionId) => Promise<void>
40
+ }
41
+
42
+ /* ---- StatsLine-identical formatting -------------------------------------
43
+ * Ported (not imported — the source functions are module-private to
44
+ * StatsLine.tsx) from dsh-client-ui-conversation lib/client.js:2755-2787
45
+ * (verified 2026-08-17). The "口径对齐官方" requirement is digit-for-digit,
46
+ * not just look-alike, so the algorithm is copied exactly. */
47
+
48
+ /** Compact token count: 517 / 12.2K / 517K / 1.2M (one decimal under three digits). */
49
+ function formatTokens(n: number): string {
50
+ const scaled = (v: number) => (v >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10))
51
+ if (n < 1e3) return String(n)
52
+ if (n < 1e6) return `${scaled(n / 1e3)}K`
53
+ return `${scaled(n / 1e6)}M`
54
+ }
55
+
56
+ /** Compact duration: 45.2s under a minute, 2m42s from there on. */
57
+ function formatDuration(ms: number): string {
58
+ const s = ms / 1e3
59
+ if (s < 60) return `${Math.round(s * 10) / 10}s`
60
+ const whole = Math.round(s)
61
+ return `${Math.floor(whole / 60)}m${whole % 60}s`
62
+ }
63
+
64
+ /** Sum of the three disjoint prompt-side billing buckets. */
65
+ function billedInputTokens(usage: { uncachedInputTokens: number; cacheReadTokens: number; cacheWriteTokens: number }): number {
66
+ return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
67
+ }
68
+
69
+ /** Cache-hit share of prompt-side input over the whole durable log; null when nothing was billed. */
70
+ function cacheHitPercent(usage: { cacheReadTokens: number; uncachedInputTokens: number; cacheWriteTokens: number }): number | null {
71
+ const denominator = billedInputTokens(usage)
72
+ return denominator === 0 ? null : Math.round((usage.cacheReadTokens / denominator) * 100)
73
+ }
74
+
75
+ /** Data-missing / not-yet-observed placeholder for a stat cell. */
76
+ const NA = '—'
77
+
78
+ /**
79
+ * Session-info sheet: the bottom sheet that gathers everything S3 pulled off
80
+ * the composer (the official stats strip) and everything S2 left out of the
81
+ * header (Chat/Trajectory as a real control, badges, session actions).
82
+ *
83
+ * Registered as a SECOND entry on `conversation.session.header.utilities` —
84
+ * session scope, sibling to the ⓘ button that opens it
85
+ * (MobileSessionHeader.tsx dispatches {@link SESSION_INFO_EVENT}).
86
+ *
87
+ * Mount-point choice (the plan's own tradeoff to weigh): this needs
88
+ * `useProjection`/`sessionId` for the stats grid, and those are
89
+ * session-scope-only standard props — `header.utilities` has them,
90
+ * `shell.overlay` (S1's other option) does not. `shell.overlay` would have
91
+ * gained nothing in exchange (GlobalStandardProps — `useSessions` for the
92
+ * badges/subagent-count — is unconditional on every slot per
93
+ * `PropsRuntime`, so this component gets it here for free too) while
94
+ * running into a real problem: shell.overlay content renders inside the
95
+ * `pI_x6G_overlayLayer`, a z-index:20 stacking context (AGENTS.md), and the
96
+ * composer's own permission/model bottom sheets sit at z:60 — a
97
+ * shell.overlay-hosted info sheet would render BEHIND an open composer
98
+ * menu. Mounting inside the header's own DOM (outside that capped layer)
99
+ * lets this sheet's z-index clear every other phone-shell float.
100
+ */
101
+ export function MobileSessionInfo({
102
+ sessionId,
103
+ useSessions,
104
+ useProjection,
105
+ forkSession,
106
+ openSession,
107
+ renameSession,
108
+ archiveSession,
109
+ downloadSessionLog,
110
+ t,
111
+ }: MobileSessionInfoProps) {
112
+ const [open, setOpen] = useState(false)
113
+ const [busy, setBusy] = useState(false)
114
+ const [error, setError] = useState<string | null>(null)
115
+
116
+ useEffect(() => {
117
+ const onOpen = (): void => {
118
+ setError(null)
119
+ setOpen(true)
120
+ }
121
+ window.addEventListener(SESSION_INFO_EVENT, onOpen)
122
+ return () => window.removeEventListener(SESSION_INFO_EVENT, onOpen)
123
+ }, [])
124
+
125
+ const tabs = useViewTabs()
126
+ const row = useSessions((s) => s.byId[sessionId])
127
+ const subagentCount = useSessions((s) => s.subagentsByParent[sessionId]?.entries.length ?? 0)
128
+ const stats = useProjection('sessionStats')
129
+ const usage = useProjection('tokenUsage')
130
+
131
+ if (!open) return null
132
+
133
+ const close = (): void => setOpen(false)
134
+
135
+ const run = async (action: () => Promise<void>): Promise<void> => {
136
+ setBusy(true)
137
+ setError(null)
138
+ try {
139
+ await action()
140
+ } catch (err) {
141
+ setError(err instanceof Error ? err.message : String(err))
142
+ } finally {
143
+ setBusy(false)
144
+ }
145
+ }
146
+
147
+ const onRename = (): void => {
148
+ const next = window.prompt(t('infoRenamePrompt'), row?.displayTitle ?? '')
149
+ if (next === null) return
150
+ const title = next.trim()
151
+ if (title === '') return
152
+ void run(async () => {
153
+ const result = await renameSession(sessionId, title)
154
+ if (result === undefined) throw new Error(t('infoRename'))
155
+ if (!result.ok) throw new Error(result.error.message)
156
+ })
157
+ }
158
+
159
+ const onFork = (): void => {
160
+ void run(async () => {
161
+ const forkedId = await forkSession(sessionId)
162
+ openSession(forkedId)
163
+ close()
164
+ })
165
+ }
166
+
167
+ const onArchive = (): void => {
168
+ if (!window.confirm(t('infoArchiveConfirm'))) return
169
+ void run(async () => {
170
+ await archiveSession(sessionId)
171
+ window.dispatchEvent(new CustomEvent(GO_HOME_EVENT))
172
+ close()
173
+ })
174
+ }
175
+
176
+ const onExport = (): void => {
177
+ void downloadSessionLog(sessionId)
178
+ }
179
+
180
+ const cacheHit = usage === undefined ? null : cacheHitPercent(usage)
181
+ const tokensEmpty = usage === undefined || (billedInputTokens(usage) === 0 && usage.outputTokens === 0)
182
+ const cwd = row?.cwd === undefined || row.cwd === '' ? undefined : workspaceTitleOf(row.cwd)
183
+
184
+ const cells: Array<{ label: string; value: string; sub: string | undefined }> = [
185
+ { label: t('infoStatTurns'), value: stats === undefined ? NA : String(stats.turns), sub: undefined },
186
+ { label: t('infoStatSteps'), value: stats === undefined ? NA : String(stats.steps), sub: undefined },
187
+ {
188
+ label: t('infoStatTtft'),
189
+ value: stats === undefined || stats.ttftSteps === 0 ? NA : formatDuration(stats.ttftMs / stats.ttftSteps),
190
+ sub: undefined,
191
+ },
192
+ {
193
+ label: t('infoStatLlm'),
194
+ value: stats === undefined || stats.llmMs === 0 ? NA : formatDuration(stats.llmMs),
195
+ sub: undefined,
196
+ },
197
+ {
198
+ label: t('infoStatTool'),
199
+ value: stats === undefined || stats.toolMs === 0 ? NA : formatDuration(stats.toolMs),
200
+ sub: undefined,
201
+ },
202
+ {
203
+ label: t('infoStatTokens'),
204
+ value: tokensEmpty || usage === undefined
205
+ ? NA
206
+ : `${formatTokens(billedInputTokens(usage))}→${formatTokens(usage.outputTokens)}`,
207
+ sub: cacheHit === null ? undefined : t('infoCacheHit', { percent: cacheHit }),
208
+ },
209
+ ]
210
+
211
+ return (
212
+ <div data-mobile-nav="info-layer">
213
+ <div
214
+ data-mobile-nav="info-mask"
215
+ role="button"
216
+ tabIndex={-1}
217
+ aria-label={t('infoClose')}
218
+ onClick={close}
219
+ />
220
+ <div data-mobile-nav="info-sheet" role="dialog" aria-modal="true">
221
+ <div data-mobile-nav="info-head">
222
+ {tabs.length > 1 && (
223
+ // Deliberately NOT role="tablist"/role="tab": readViewTabs()
224
+ // (MobileSessionHeader.tsx) locates the OFFICIAL tablist with
225
+ // `header [role="tablist"]`, and this control also renders
226
+ // inside <header> (the header.utilities slot) — reusing that
227
+ // role here made the query match this control instead of the
228
+ // real one once the sheet was open, so a tap "switched" this
229
+ // already-open segmented control rather than the official tabs
230
+ // underneath (found via HTMLElement.prototype.click patching:
231
+ // tab.el.click() was recursing into its own button). A plain
232
+ // button group with aria-pressed avoids the collision entirely.
233
+ <div data-mobile-nav="info-tabs" role="group" aria-label={t('switchView')}>
234
+ {tabs.map((tab) => (
235
+ <button
236
+ key={tab.label}
237
+ type="button"
238
+ aria-pressed={tab.active}
239
+ data-mobile-nav="info-tab"
240
+ data-selected={tab.active ? '' : undefined}
241
+ onClick={() => {
242
+ if (!tab.active) tab.el.click()
243
+ close()
244
+ }}
245
+ >
246
+ {tab.label}
247
+ </button>
248
+ ))}
249
+ </div>
250
+ )}
251
+ <button type="button" data-mobile-nav="info-close" aria-label={t('infoClose')} onClick={close}>
252
+ <IconCloseOutline16 size={16} />
253
+ </button>
254
+ </div>
255
+
256
+ <div data-mobile-nav="info-badges">
257
+ {row?.agentPreset !== undefined && <span data-mobile-nav="info-badge">{row.agentPreset}</span>}
258
+ {subagentCount > 0 && (
259
+ <span data-mobile-nav="info-badge">{t('infoSubagents', { count: subagentCount })}</span>
260
+ )}
261
+ <span data-mobile-nav="info-badge-cwd">{cwd ?? t('infoCwdFallback')}</span>
262
+ </div>
263
+
264
+ <div data-mobile-nav="info-stats">
265
+ {cells.map((cell) => (
266
+ <div key={cell.label} data-mobile-nav="info-stat">
267
+ <span data-mobile-nav="info-stat-value">{cell.value}</span>
268
+ <span data-mobile-nav="info-stat-label">{cell.label}</span>
269
+ {cell.sub !== undefined && <span data-mobile-nav="info-stat-sub">{cell.sub}</span>}
270
+ </div>
271
+ ))}
272
+ </div>
273
+
274
+ {error !== null && <div data-mobile-nav="info-error">{t('infoActionError', { message: error })}</div>}
275
+
276
+ <div data-mobile-nav="info-actions">
277
+ <button type="button" data-mobile-nav="info-action" disabled={busy} onClick={onExport}>
278
+ <IconDownloadOutline16 size={16} />
279
+ <span>{t('infoExport')}</span>
280
+ </button>
281
+ <button type="button" data-mobile-nav="info-action" disabled={busy} onClick={onRename}>
282
+ <IconEditOutline16 size={16} />
283
+ <span>{t('infoRename')}</span>
284
+ </button>
285
+ <button type="button" data-mobile-nav="info-action" disabled={busy} onClick={onFork}>
286
+ <IconBranchOutline16 size={16} />
287
+ <span>{t('infoFork')}</span>
288
+ </button>
289
+ <button
290
+ type="button"
291
+ data-mobile-nav="info-action"
292
+ data-mobile-nav-danger=""
293
+ disabled={busy}
294
+ onClick={onArchive}
295
+ >
296
+ <IconArchiveOutline20 size={20} />
297
+ <span>{t('infoArchive')}</span>
298
+ </button>
299
+ </div>
300
+ </div>
301
+ </div>
302
+ )
303
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * S7 attachment plumbing: the string math plus the thumbnail registry.
3
+ *
4
+ * Kept free of React so `scripts/check-attach-upload.mjs` can import it
5
+ * directly under Node's type stripping.
6
+ *
7
+ * The one idea worth stating up front: **the composer draft is the only state
8
+ * this feature has.** Every attachment is an `@.dsh-uploads/name` token in the
9
+ * draft text, and the chip row is a pure function of that text. Nothing to
10
+ * keep in sync — the user deleting the token by hand, or the official send
11
+ * clearing the draft, makes the chips disappear on their own.
12
+ */
13
+
14
+ /** Exact host route registered by the node half (src/index.ts). */
15
+ export const UPLOAD_ROUTE = '/_dsh/mobile-nav/upload'
16
+
17
+ /** Workspace-relative directory uploads land in; also the `@` mention prefix. */
18
+ export const UPLOAD_DIR = '.dsh-uploads'
19
+
20
+ /**
21
+ * One attachment mention in the draft.
22
+ *
23
+ * `\S+` is safe as the terminator because {@link safeUploadName} (host side)
24
+ * folds every whitespace run in a filename into `_` — a path with a space in
25
+ * it would break the `@` mention for the agent too, not just for this regex.
26
+ */
27
+ const MENTION = new RegExp(`@(${UPLOAD_DIR.replace('.', '\\.')}/\\S+)`, 'g')
28
+
29
+ /**
30
+ * The upload URL for one file. Root-relative on purpose: through the
31
+ * gateway half the pairing cookie only rides along same-origin.
32
+ * @param sessionId - target session (its workspace receives the file).
33
+ * @param name - the browser filename; the host sanitizes it again.
34
+ * @returns a root-relative URL.
35
+ */
36
+ export function uploadUrl(sessionId: string, name: string): string {
37
+ const query = new URLSearchParams({ session: sessionId, name })
38
+ return `${UPLOAD_ROUTE}?${query.toString()}`
39
+ }
40
+
41
+ /**
42
+ * Every attachment mentioned in a draft, in reading order, without repeats.
43
+ * @param draft - the composer draft text.
44
+ * @returns workspace-relative paths (no leading `@`).
45
+ */
46
+ export function mentionsIn(draft: string): string[] {
47
+ return [...new Set([...draft.matchAll(MENTION)].map((match) => match[1] as string))]
48
+ }
49
+
50
+ /**
51
+ * Append one `@path` mention to the draft.
52
+ *
53
+ * Separated from whatever came before by a space (or a newline when the draft
54
+ * already ends in one), and trailed by a space so the next mention does not
55
+ * fuse onto this one — and so {@link draftWithoutMention} can lift it back out
56
+ * without leaving a double space behind.
57
+ * @param draft - the current draft text.
58
+ * @param relPath - workspace-relative path returned by the upload route.
59
+ * @returns the next draft.
60
+ */
61
+ export function draftWithMention(draft: string, relPath: string): string {
62
+ if (draft === '' || draft.endsWith('\n') || draft.endsWith(' ')) return `${draft}@${relPath} `
63
+ return `${draft} @${relPath} `
64
+ }
65
+
66
+ /**
67
+ * Remove one `@path` mention from the draft — what a chip's × does.
68
+ *
69
+ * The file itself stays on disk: it is a few KB in a directory the user can
70
+ * clear whenever, and deleting it would need a second host route whose only
71
+ * job is destruction.
72
+ * @param draft - the current draft text.
73
+ * @param relPath - the attachment to drop.
74
+ * @returns the next draft; whitespace-only collapses to empty.
75
+ */
76
+ export function draftWithoutMention(draft: string, relPath: string): string {
77
+ const token = `@${relPath}`
78
+ const next = draft.split(`${token} `).join('').split(token).join('')
79
+ return next.trim() === '' ? '' : next
80
+ }
81
+
82
+ /** The filename part of a workspace-relative path. */
83
+ export function leafOf(relPath: string): string {
84
+ return relPath.slice(relPath.lastIndexOf('/') + 1)
85
+ }
86
+
87
+ /**
88
+ * Shorten a filename from the MIDDLE, so both the stem and the extension stay
89
+ * readable (CSS `text-overflow` can only cut one end).
90
+ * @param text - the filename.
91
+ * @param max - budget in characters, including the ellipsis.
92
+ * @returns the original when it fits, else head + `…` + tail.
93
+ */
94
+ export function middleEllipsis(text: string, max = 18): string {
95
+ if (text.length <= max) return text
96
+ const head = Math.ceil((max - 1) / 2)
97
+ return `${text.slice(0, head)}…${text.slice(text.length - (max - 1 - head))}`
98
+ }
99
+
100
+ /**
101
+ * Preview URLs by attachment path.
102
+ *
103
+ * Module-level because the button that creates them and the chip row that
104
+ * renders them are two different slot entries with no shared React ancestor.
105
+ * Nothing persists it: after a reload the draft still carries the mentions
106
+ * (it is persisted) but this map is empty, so those chips render as file
107
+ * chips. That degradation is correct — the browser-owned blob is gone.
108
+ */
109
+ const thumbnails = new Map<string, string>()
110
+
111
+ /**
112
+ * Register a preview for an uploaded image.
113
+ * @param relPath - workspace-relative path the file landed on.
114
+ * @param blob - the browser-owned file.
115
+ */
116
+ export function rememberThumbnail(relPath: string, blob: Blob): void {
117
+ if (!blob.type.startsWith('image/')) return
118
+ const previous = thumbnails.get(relPath)
119
+ if (previous !== undefined) URL.revokeObjectURL(previous)
120
+ thumbnails.set(relPath, URL.createObjectURL(blob))
121
+ }
122
+
123
+ /** The preview URL for one attachment, or undefined when there is none. */
124
+ export function thumbnailFor(relPath: string): string | undefined {
125
+ return thumbnails.get(relPath)
126
+ }
127
+
128
+ /**
129
+ * Revoke and forget every preview whose attachment is no longer in the draft.
130
+ * Called whenever the chip row re-renders, so a removed chip frees its blob.
131
+ * @param keep - the attachments still mentioned.
132
+ */
133
+ export function releaseThumbnailsExcept(keep: readonly string[]): void {
134
+ const live = new Set(keep)
135
+ for (const [relPath, url] of thumbnails) {
136
+ if (live.has(relPath)) continue
137
+ URL.revokeObjectURL(url)
138
+ thumbnails.delete(relPath)
139
+ }
140
+ }
@@ -0,0 +1,41 @@
1
+ import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
2
+ import { useSyncExternalStore } from 'react'
3
+
4
+ /**
5
+ * Home-screen chip visibility, keyed by {@link ChipDef.id} (MobileHomeChips.tsx).
6
+ * A missing key means "shown" — new chip ids need no migration step, they
7
+ * just default enabled the first time they exist.
8
+ */
9
+ export type ChipPrefs = Record<string, boolean>
10
+
11
+ /**
12
+ * Plain `createSnapshotStore` (runtime contract/store.d.ts), not a
13
+ * `defineStore` slot handle: this preference is not tied to any slot's
14
+ * mount lifecycle (unlike nav-store.ts's page stack, which a session-scope
15
+ * registration also needs a handle for), so it needs none of that
16
+ * machinery — a bare persisted store is the whole requirement (design doc
17
+ * Appendix G lists both as valid `defineStore`/`createSnapshotStore`
18
+ * options for this exact case). A module-level constant is safe here
19
+ * (unlike a `defineStore` handle) because this store is not resolved
20
+ * per-scope by the slot runtime; it is just a persisted value with its own
21
+ * localStorage-backed identity, so re-importing the module always reaches
22
+ * the same store.
23
+ */
24
+ const store = createSnapshotStore<ChipPrefs>({}, { persist: { name: 'dsh-mobile-nav.chips' } })
25
+
26
+ /** @param prefs - current snapshot. @param id - chip id. @returns whether the chip should render (default true). */
27
+ export function isChipEnabled(prefs: ChipPrefs, id: string): boolean {
28
+ return prefs[id] !== false
29
+ }
30
+
31
+ /** Flip one chip's visibility and persist it. @param id - chip id. */
32
+ export function toggleChip(id: string): void {
33
+ store.update((draft) => {
34
+ draft[id] = !isChipEnabled(draft, id)
35
+ })
36
+ }
37
+
38
+ /** Live chip-prefs mirror (React 18 tearing-safe external store read). */
39
+ export function useChipsPrefs(): ChipPrefs {
40
+ return useSyncExternalStore(store.subscribe, store.getSnapshot)
41
+ }