dsh-zen-remote 1.1.13 → 1.1.14

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 (77) hide show
  1. package/README.md +3 -2
  2. package/README.zh-CN.md +3 -2
  3. package/docs/interface.md +46 -3
  4. package/lib/client.js +3421 -103
  5. package/lib/index.js +13 -0
  6. package/lib/index.js.map +1 -1
  7. package/lib/share-export.js +347 -0
  8. package/lib/share-export.js.map +1 -0
  9. package/lib/types/client/MobileAttachButton.d.ts +8 -1
  10. package/lib/types/client/MobileAttachButton.d.ts.map +1 -1
  11. package/lib/types/client/MobileAttachChips.d.ts.map +1 -1
  12. package/lib/types/client/MobileSessionHeader.d.ts +7 -8
  13. package/lib/types/client/MobileSessionHeader.d.ts.map +1 -1
  14. package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
  15. package/lib/types/client/effects/keyboard-avoid.d.ts.map +1 -1
  16. package/lib/types/client/effects/keyboard-guard.d.ts +16 -11
  17. package/lib/types/client/effects/keyboard-guard.d.ts.map +1 -1
  18. package/lib/types/client/host-attach.d.ts +38 -0
  19. package/lib/types/client/host-attach.d.ts.map +1 -0
  20. package/lib/types/client/index.d.ts.map +1 -1
  21. package/lib/types/client/locales.d.ts +23 -0
  22. package/lib/types/client/locales.d.ts.map +1 -1
  23. package/lib/types/client/share/fetch-share.d.ts +77 -0
  24. package/lib/types/client/share/fetch-share.d.ts.map +1 -0
  25. package/lib/types/client/share/markdown.d.ts +97 -0
  26. package/lib/types/client/share/markdown.d.ts.map +1 -0
  27. package/lib/types/client/share/png-stitch.d.ts +96 -0
  28. package/lib/types/client/share/png-stitch.d.ts.map +1 -0
  29. package/lib/types/client/share/rasterize.d.ts +317 -0
  30. package/lib/types/client/share/rasterize.d.ts.map +1 -0
  31. package/lib/types/client/share/share-card.d.ts +93 -0
  32. package/lib/types/client/share/share-card.d.ts.map +1 -0
  33. package/lib/types/client/share/share-constants.d.ts +27 -0
  34. package/lib/types/client/share/share-constants.d.ts.map +1 -0
  35. package/lib/types/client/share/share-flow.d.ts +76 -0
  36. package/lib/types/client/share/share-flow.d.ts.map +1 -0
  37. package/lib/types/client/share/share-preview.d.ts +11 -0
  38. package/lib/types/client/share/share-preview.d.ts.map +1 -0
  39. package/lib/types/client/share/share-sheet.d.ts +17 -0
  40. package/lib/types/client/share/share-sheet.d.ts.map +1 -0
  41. package/lib/types/client/share/text-layout.d.ts +48 -0
  42. package/lib/types/client/share/text-layout.d.ts.map +1 -0
  43. package/lib/types/client/styles/header.css.d.ts +1 -1
  44. package/lib/types/client/styles/header.css.d.ts.map +1 -1
  45. package/lib/types/client/styles/layout.css.d.ts +1 -1
  46. package/lib/types/client/styles/layout.css.d.ts.map +1 -1
  47. package/lib/types/index.d.ts.map +1 -1
  48. package/lib/types/share-export.d.ts +66 -0
  49. package/lib/types/share-export.d.ts.map +1 -0
  50. package/package.json +7 -2
  51. package/scripts/check-host-shadow.mjs +41 -9
  52. package/scripts/check-keyboard-avoid.mjs +4 -2
  53. package/scripts/check-share-image.mjs +970 -0
  54. package/scripts/check-upload-endpoint.mjs +10 -3
  55. package/src/client/MobileAttachButton.tsx +13 -0
  56. package/src/client/MobileAttachChips.tsx +8 -1
  57. package/src/client/MobileSessionHeader.tsx +90 -30
  58. package/src/client/MobileSessionInfo.tsx +234 -1
  59. package/src/client/effects/keyboard-avoid.ts +14 -7
  60. package/src/client/effects/keyboard-guard.ts +28 -18
  61. package/src/client/host-attach.ts +54 -0
  62. package/src/client/index.tsx +16 -0
  63. package/src/client/locales.ts +46 -0
  64. package/src/client/share/fetch-share.ts +161 -0
  65. package/src/client/share/markdown.ts +609 -0
  66. package/src/client/share/png-stitch.ts +281 -0
  67. package/src/client/share/rasterize.ts +1078 -0
  68. package/src/client/share/share-card.tsx +637 -0
  69. package/src/client/share/share-constants.ts +30 -0
  70. package/src/client/share/share-flow.ts +166 -0
  71. package/src/client/share/share-preview.tsx +337 -0
  72. package/src/client/share/share-sheet.tsx +222 -0
  73. package/src/client/share/text-layout.ts +147 -0
  74. package/src/client/styles/header.css.ts +30 -0
  75. package/src/client/styles/layout.css.ts +5 -2
  76. package/src/index.ts +13 -0
  77. package/src/share-export.ts +431 -0
@@ -1,17 +1,24 @@
1
- // Integration check for the S7 upload route (src/index.ts handleUpload).
1
+ // Integration check for the S7 upload route (lib/index.js handleUpload).
2
2
  //
3
3
  // Drives the real handler over a real node:http socket with a fake sessions
4
4
  // service and a throwaway workspace, so the accept/reject decisions and the
5
5
  // bytes that reach disk are the ones the phone would produce. No harness, no
6
6
  // session, no message: writing a file into a workspace is not a prompt.
7
7
  //
8
- // Run: node scripts/check-upload-endpoint.mjs (needs Node >= 23.6 type stripping)
8
+ // Imports the BUILT lib/index.js rather than src/index.ts: since the host
9
+ // source became multi-file (src/share-export.js import), strip-only type
10
+ // stripping can no longer load the source graph — relative specifiers need
11
+ // their .js extension for the runtime, and nothing rewrites them back to .ts.
12
+ // lib/ is the committed build artifact, the same one check-client-externals
13
+ // reads, so run pnpm build first when editing src/.
14
+ //
15
+ // Run: node scripts/check-upload-endpoint.mjs
9
16
  import assert from 'node:assert/strict'
10
17
  import { createServer } from 'node:http'
11
18
  import { mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'
12
19
  import { tmpdir } from 'node:os'
13
20
  import { join } from 'node:path'
14
- import { UPLOAD_DIR, handleUpload, safeUploadName, sameOriginPost } from '../src/index.ts'
21
+ import { UPLOAD_DIR, handleUpload, safeUploadName, sameOriginPost } from '../lib/index.js'
15
22
 
16
23
  const MAX_BYTES = 4096
17
24
 
@@ -5,6 +5,7 @@ import { IconPaperclipOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
5
5
  import type { SessionId } from './compat/types.ts'
6
6
  import { NS } from './locales.ts'
7
7
  import { draftWithMention, rememberThumbnail, uploadUrl } from './attach-upload.ts'
8
+ import { useOwnAttachUi } from './host-attach.ts'
8
9
 
9
10
  /** Full props for the composer attachment seat. */
10
11
  export type MobileAttachButtonProps =
@@ -40,6 +41,13 @@ async function uploadFile(sessionId: SessionId, file: File): Promise<string> {
40
41
  * the CLIENT's picker — no `accept` attribute on purpose, so iOS offers the
41
42
  * full 相册 / 拍照 / 选取文件 sheet rather than one of them.
42
43
  *
44
+ * DSH 0.1.5 gave the official tool row its own paperclip (the host attachment
45
+ * flow), which made TWO identical controls on a phone. This button now stands
46
+ * down entirely while the host ships its own picker (`host-attach.ts` probes
47
+ * for it) — the official flow uploads into `~/.dsh/attachments` and renders
48
+ * its own rail, so the composer keeps exactly one attach affordance. On a
49
+ * host without one (DSH ≤ 0.1.2) everything below loads exactly as before.
50
+ *
43
51
  * Every picked file takes the SAME path: upload to the node half, then append
44
52
  * `@.dsh-uploads/name` to the draft through `inputActions.setDraft` (the
45
53
  * official write path — no DOM value poking). S7.1 removed the split that used
@@ -55,6 +63,7 @@ export function MobileAttachButton({ t, sessionId, useInput, inputActions }: Mob
55
63
  const [busy, setBusy] = useState(false)
56
64
  const [error, setError] = useState<string | null>(null)
57
65
  const draft = useInput((state) => state.draft)
66
+ const ownUi = useOwnAttachUi()
58
67
  // Uploads are awaited, so the `draft` captured at pick time goes stale — the
59
68
  // user can keep typing while a file is in flight. The ref is refreshed on
60
69
  // every render (useInput re-renders us on each draft change), so the loop
@@ -62,6 +71,10 @@ export function MobileAttachButton({ t, sessionId, useInput, inputActions }: Mob
62
71
  const liveDraft = useRef(draft)
63
72
  liveDraft.current = draft
64
73
 
74
+ // The host's own attach button owns this seat now — render nothing (the
75
+ // hidden picker input goes with it). All hooks stay above this line.
76
+ if (!ownUi) return null
77
+
65
78
  const pick = async (event: ChangeEvent<HTMLInputElement>) => {
66
79
  const files = Array.from(event.target.files ?? [])
67
80
  // Reset first: picking the same file twice in a row fires no change event
@@ -10,6 +10,7 @@ import {
10
10
  releaseThumbnailsExcept,
11
11
  thumbnailFor,
12
12
  } from './attach-upload.ts'
13
+ import { useOwnAttachUi } from './host-attach.ts'
13
14
 
14
15
  /** Full props for the attachment preview row. */
15
16
  export type MobileAttachChipsProps =
@@ -44,12 +45,18 @@ export type MobileAttachChipsProps =
44
45
  */
45
46
  export function MobileAttachChips({ t, useInput, inputActions }: MobileAttachChipsProps) {
46
47
  const draft = useInput((state) => state.draft)
47
- const attachments = mentionsIn(draft)
48
+ const ownUi = useOwnAttachUi()
48
49
 
49
50
  useEffect(() => {
50
51
  releaseThumbnailsExcept(mentionsIn(draft))
51
52
  }, [draft])
52
53
 
54
+ // Same gate as MobileAttachButton (host-attach.ts): where the host ships
55
+ // its own attachment flow, our chips row stands down too — the official
56
+ // rail owns the preview seat. The effect above still runs, so object URLs
57
+ // from OUR flow get revoked even after the switch.
58
+ if (!ownUi) return null
59
+ const attachments = mentionsIn(draft)
53
60
  if (attachments.length === 0) return null
54
61
 
55
62
  return (
@@ -275,16 +275,86 @@ export type MobileHeaderUtilitiesProps =
275
275
  & PropsRuntime<'conversation.session.header.utilities'>
276
276
  & PropsLocale<typeof NS>
277
277
 
278
+ /**
279
+ * Where the header's sidebar button routes. Three ways, resolved at runtime
280
+ * (user decision 2026-09-11, after DSH 0.1.5 grew an official right sidebar):
281
+ *
282
+ * - `'better'` — dsh-better-sidebar is installed: the original design, the
283
+ * button clicks the plugin's own (CSS-hidden) toggle.
284
+ * - `'official'` — no better-sidebar but the host ships the right sidebar:
285
+ * the button drives the official controls instead.
286
+ * - `null` — neither: the button does not render at all.
287
+ *
288
+ * In every case the OTHER sidebar buttons (the official corner ExpandButton,
289
+ * better-sidebar's toggle cluster) stay hidden on the phone — this button is
290
+ * the one visible affordance. Detection is by stable, non-hashed DOM
291
+ * markers, never by locale: better-sidebar's root mount marker, and the
292
+ * official rightbar's always-mounted panel (`data-sidebar-right-panel`; its
293
+ * corner ExpandButton unmounts while expanded, so the PANEL is the probe).
294
+ */
295
+ type SidebarTarget = 'better' | 'official' | null
296
+
297
+ const BETTER_ROOT = '[data-dsh-better-sidebar]'
298
+ const BETTER_TOGGLE = '[data-dsh-better-sidebar] button[class$="_toggleButton"]'
299
+ const OFFICIAL_PANEL = '[data-sidebar-right-panel]'
300
+ const OFFICIAL_EXPAND = '[data-sidebar-right-expand]'
301
+ const OFFICIAL_COLLAPSE = '[data-sidebar-right-toggle]'
302
+
303
+ /** Synchronous read of which sidebar backend this host offers. */
304
+ function readSidebarTarget(): SidebarTarget {
305
+ if (document.querySelector(BETTER_ROOT) !== null) return 'better'
306
+ if (document.querySelector(OFFICIAL_PANEL) !== null) return 'official'
307
+ return null
308
+ }
309
+
310
+ /**
311
+ * Toggle whichever sidebar `target` names, by clicking the official/plugin
312
+ * control through its stable marker. The official pair is open-only in the
313
+ * corner (`ExpandButton` unmounts once shown) and close-only inside the
314
+ * panel (`data-sidebar-right-toggle` rides the panel's own strip), so the
315
+ * open state is read off the panel's `data-sidebar-right-open` attribute and
316
+ * the matching control is clicked. Synthetic `.click()` fires both React
317
+ * handlers through `display: none` (the tablist precedent this plugin
318
+ * already relies on for the view switch).
319
+ */
320
+ function toggleSidebarTarget(target: SidebarTarget): void {
321
+ if (target === 'better') {
322
+ document.querySelector<HTMLButtonElement>(BETTER_TOGGLE)?.click()
323
+ return
324
+ }
325
+ if (target === 'official') {
326
+ const shown = document.querySelector(`${OFFICIAL_PANEL}[data-sidebar-right-open]`) !== null
327
+ const control = shown
328
+ ? document.querySelector<HTMLButtonElement>(OFFICIAL_COLLAPSE)
329
+ : document.querySelector<HTMLButtonElement>(OFFICIAL_EXPAND)
330
+ control?.click()
331
+ }
332
+ }
333
+
334
+ /** Live mirror of {@link readSidebarTarget} — same observer pattern as the
335
+ * old workbench presence gate it replaces (plugins load after us; the right
336
+ * panel mounts with the frame). */
337
+ function useSidebarTarget(): SidebarTarget {
338
+ const [target, setTarget] = useState<SidebarTarget>(readSidebarTarget)
339
+ useEffect(() => {
340
+ const check = (): void => setTarget(readSidebarTarget())
341
+ const observer = new MutationObserver(check)
342
+ observer.observe(document.body, { childList: true, subtree: true })
343
+ check()
344
+ return () => observer.disconnect()
345
+ }, [])
346
+ return target
347
+ }
348
+
278
349
  /**
279
350
  * Session header, right lane: the session-info entry (S4 owns the actual
280
- * sheet — this fires a hook event for it to pick up) and the workbench
281
- * entry, which triggers dsh-better-sidebar's own toggle. There is no public
282
- * API for "open the panel" (BetterSidebarService.openTab only auto-expands
283
- * for a content open, not a bare type-only open), so this clicks the
284
- * plugin's real toggle button through a stable, non-hashed anchor: its root
285
- * mount marker `[data-dsh-better-sidebar]` plus the `_toggleButton` class
286
- * suffix (verified live: 2026-08-17). Safe no-op when the plugin, or any
287
- * other workbench-style plugin sharing that convention, is not installed.
351
+ * sheet — this fires a hook event for it to pick up) and the sidebar entry.
352
+ * The sidebar button routes per {@link SidebarTarget}: better-sidebar's own
353
+ * toggle (no public "open the panel" API — BetterSidebarService.openTab only
354
+ * auto-expands for a content open, not a bare type-only open, so it clicks
355
+ * the plugin's real toggle through its root marker `[data-dsh-better-
356
+ * sidebar]` plus the `_toggleButton` class suffix, verified live 2026-08-17),
357
+ * the official right sidebar's controls, or nothing when neither exists.
288
358
  */
289
359
  export function MobileHeaderUtilities({ t }: MobileHeaderUtilitiesProps) {
290
360
  // Better-sidebar phone close button (S3.1 follow-up, 2026-08-17): the
@@ -338,22 +408,13 @@ export function MobileHeaderUtilities({ t }: MobileHeaderUtilitiesProps) {
338
408
  }
339
409
  }, [t])
340
410
 
341
- // Presence gate (2026-08-17 user question): without dsh-better-sidebar
342
- // installed the workbench button was a dead control — rendered, clickable,
343
- // did nothing. Same live-probe pattern as the home chips: observe until the
344
- // plugin's root marker exists, hide the button while it doesn't. The close
345
- // pill never had this problem (its CSS :has() gate needs the panel node).
346
- const [workbenchPresent, setWorkbenchPresent] = useState(
347
- () => document.querySelector('[data-dsh-better-sidebar]') !== null,
348
- )
349
- useEffect(() => {
350
- const check = () =>
351
- setWorkbenchPresent(document.querySelector('[data-dsh-better-sidebar]') !== null)
352
- const observer = new MutationObserver(check)
353
- observer.observe(document.body, { childList: true, subtree: true })
354
- check()
355
- return () => observer.disconnect()
356
- }, [])
411
+ // Sidebar routing (2026-09-11): better-sidebar installed → its toggle (the
412
+ // original design); else the official right sidebar (DSH 0.1.5+); else the
413
+ // button stays hidden — it used to be a dead control without the plugin
414
+ // (2026-08-17 user question), and the same must hold for a host with no
415
+ // right sidebar either. The close pill above keeps its own better-sidebar
416
+ // CSS :has() gate, unchanged.
417
+ const sidebar = useSidebarTarget()
357
418
 
358
419
  return (
359
420
  <>
@@ -366,14 +427,13 @@ export function MobileHeaderUtilities({ t }: MobileHeaderUtilitiesProps) {
366
427
  >
367
428
  <IconInfoOutline16 size={20} />
368
429
  </button>
369
- {workbenchPresent && <button
430
+ {sidebar !== null && <button
370
431
  type="button"
371
432
  data-mobile-nav="header-workbench"
372
- aria-label={t('workbench')}
373
- title={t('workbench')}
374
- onClick={() => {
375
- document.querySelector<HTMLButtonElement>('[data-dsh-better-sidebar] button[class$="_toggleButton"]')?.click()
376
- }}
433
+ data-sidebar-target={sidebar}
434
+ aria-label={t(sidebar === 'better' ? 'workbench' : 'sidebar')}
435
+ title={t(sidebar === 'better' ? 'workbench' : 'sidebar')}
436
+ onClick={() => { toggleSidebarTarget(sidebar) }}
377
437
  >
378
438
  {/* No IconPanelRightOutline16 in primitives (grepped lib/types/
379
439
  icons/index.d.ts, 2026-08-17) — mirrored via CSS (styles/
@@ -1,4 +1,4 @@
1
- import { useEffect, useState } from 'react'
1
+ import { useEffect, useRef, useState } from 'react'
2
2
  import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
3
3
  import {
4
4
  IconArchiveOutline20,
@@ -6,6 +6,7 @@ import {
6
6
  IconCloseOutline16,
7
7
  IconDownloadOutline16,
8
8
  IconEditOutline16,
9
+ IconShareOutline16,
9
10
  } from '@deepseek-ai/dsh-client-ui-primitives'
10
11
  import { workspaceTitleOf } from './compat/store.ts'
11
12
  import { agentPresetOf } from './compat/types.ts'
@@ -13,9 +14,24 @@ import type { RenameResult, SessionId } from './compat/types.ts'
13
14
  import { NS } from './locales.ts'
14
15
  import { GO_HOME_EVENT, SESSION_INFO_EVENT } from './nav-store.ts'
15
16
  import { hasLayer, popLayer, pushLayer } from './history-nav.ts'
17
+ import { fetchShareExport, ShareFetchError } from './share/fetch-share.ts'
18
+ import type { ShareExportData, ShareRange } from './share/fetch-share.ts'
19
+ import { deliverShareImage, deliverShareImages } from './share/share-flow.ts'
20
+ import { stitchSliceBlobs, supportsPngStitch } from './share/png-stitch.ts'
21
+ import { rasterizeShareCard, ShareRasterizeError } from './share/rasterize.ts'
22
+ import { ShareCard } from './share/share-card.tsx'
23
+ import type { ShareCardCopy } from './share/share-card.tsx'
24
+ import { ShareRangeSheet } from './share/share-sheet.tsx'
25
+ import type { SharePreset } from './share/share-sheet.tsx'
16
26
 
17
27
  /** Layer id for the info sheet, so Android back closes it before leaving the session. */
18
28
  const INFO_LAYER = 'session-info'
29
+ /**
30
+ * Layer id for the share-range sheet. Pushed ON TOP of the info layer's own
31
+ * entry, so one back gesture closes exactly the picker — never both layers
32
+ * at once (the one-direction rule in history-nav.ts).
33
+ */
34
+ const SHARE_LAYER = 'session-info-share'
19
35
  import { useViewTabs } from './MobileSessionHeader.tsx'
20
36
 
21
37
  // Type-only: pulls the 'sessionStats' / 'tokenUsage' SessionProjectionMap
@@ -121,10 +137,19 @@ export function MobileSessionInfo({
121
137
  const [open, setOpen] = useState(false)
122
138
  const [busy, setBusy] = useState(false)
123
139
  const [error, setError] = useState<string | null>(null)
140
+ // Degraded-outcome notice (review 07+08): the multi-file delivery SUCCEEDED,
141
+ // so "操作失败:…" would lie — this row says what the user actually got,
142
+ // without the error template. Rendered in its own row (below), never through
143
+ // setError.
144
+ const [notice, setNotice] = useState<string | null>(null)
145
+ const [shareOpen, setShareOpen] = useState(false)
146
+ const [shareData, setShareData] = useState<ShareExportData | null>(null)
147
+ const shareCardHostRef = useRef<HTMLDivElement>(null)
124
148
 
125
149
  useEffect(() => {
126
150
  const onOpen = (): void => {
127
151
  setError(null)
152
+ setNotice(null)
128
153
  setOpen(true)
129
154
  // Stack a history entry on top of the session's: Android back now
130
155
  // closes this sheet first and only then leaves the session.
@@ -141,6 +166,108 @@ export function MobileSessionInfo({
141
166
  const stats = useProjection('sessionStats')
142
167
  const usage = useProjection('tokenUsage')
143
168
 
169
+ /* ---- share-image flow (ticket 05) --------------------------------------
170
+ * The range sheet renders INSIDE this info layer's DOM — a DIRECT child of
171
+ * the layer, a sibling of the info card, never inside the card itself (a
172
+ * card-internal mount clipped the mask to the card's box, anchored the
173
+ * sheet's bottom to the card's scrollable area, and let outside taps fall
174
+ * through to the info mask, closing both layers at once and stranding
175
+ * SHARE_LAYER as a dead history entry; review 2026-09-10): info.css.ts's
176
+ * ≥768px display:none covers it (this feature adds no page CSS), and the
177
+ * info layer's own z:70 header promotion carries it. Its history entry
178
+ * sits above INFO_LAYER's, so back closes the picker alone. These helpers
179
+ * live before the `if (!open) return null` below because the pipeline
180
+ * effect may still be settling when the sheet unmounts. */
181
+
182
+ // Rewind rather than flip the flag — same one-direction rule as `close`.
183
+ const closeShare = (): void => {
184
+ if (hasLayer(SHARE_LAYER)) popLayer(SHARE_LAYER)
185
+ else setShareOpen(false)
186
+ }
187
+
188
+ // Errors land on the info sheet's existing error row (ticket 05); the
189
+ // share sheet closes first so the row is not hidden behind its mask.
190
+ const shareErrorMessage = (err: unknown): string => {
191
+ if (err instanceof ShareFetchError) {
192
+ switch (err.code) {
193
+ case 'route-missing': return t('shareErrRouteMissing')
194
+ case 'session-not-found': return t('shareErrSessionNotFound')
195
+ case 'bad-request': return t('shareErrBadRequest')
196
+ case 'network': return t('shareErrNetwork')
197
+ case 'server': return t('shareErrServer')
198
+ case 'bad-body': return t('shareErrBody')
199
+ }
200
+ }
201
+ if (err instanceof ShareRasterizeError) {
202
+ if (err.code === 'probe-failed') return t('shareErrProbe')
203
+ if (err.code === 'slice-over-budget') return t('shareErrSliceBudget')
204
+ return t('shareErrRender')
205
+ }
206
+ return err instanceof Error ? err.message : String(err)
207
+ }
208
+
209
+ const shareFailed = (err: unknown): void => {
210
+ setBusy(false)
211
+ setShareData(null)
212
+ closeShare()
213
+ setError(shareErrorMessage(err))
214
+ }
215
+
216
+ // Pipeline stage two. Stage one (onShareConfirm below) fetches and commits
217
+ // the data, which renders the hidden ShareCard; this effect then runs
218
+ // AFTER that commit, when the card element exists. The state → render →
219
+ // effect gap is the React-idiomatic bridge over "DOM ready" without
220
+ // react-dom (not in the client externals whitelist). Ticket 07: when the
221
+ // browser can stitch (CompressionStream), the slices are planned with the
222
+ // unified global width/ratio and concatenated into ONE PNG before delivery;
223
+ // a runtime stitch failure falls back to the legacy per-slice multi-file
224
+ // delivery, and either degraded form lands on the notice row below (never
225
+ // the error row — the delivery itself succeeded, review 07+08).
226
+ useEffect(() => {
227
+ if (shareData === null) return
228
+ const card = shareCardHostRef.current?.querySelector<HTMLElement>('[data-share-card]') ?? null
229
+ if (card === null) {
230
+ shareFailed(new Error(t('shareErrRender')))
231
+ return
232
+ }
233
+ const stitch = supportsPngStitch()
234
+ rasterizeShareCard(card, {
235
+ pixelRatioCap: Math.min(window.devicePixelRatio || 1, 2),
236
+ truncatedNote: (droppedTurns) => t('shareTruncatedNote', { count: droppedTurns }),
237
+ unified: stitch,
238
+ })
239
+ .then(async (result): Promise<string | null> => {
240
+ const title = row?.displayTitle ?? ''
241
+ if (stitch && result.stitchWidth !== undefined) {
242
+ try {
243
+ const png = await stitchSliceBlobs(result.slices, result.stitchWidth)
244
+ await deliverShareImage(png, title)
245
+ return null
246
+ } catch (err) {
247
+ // Runtime stitch failure (a slice decoded at the wrong size, a
248
+ // compressor error, …) — the rasterized slices are finished PNGs
249
+ // either way, so deliver them multi-file instead of failing the
250
+ // whole export. The original error stays on the console for
251
+ // diagnosis; the notice row tells the user what they got (no
252
+ // "操作失败" prefix — the delivery succeeded, review 07+08).
253
+ console.error('[dsh-zen-remote] share-image: stitch failed — delivering the painted slices as multiple images instead', err)
254
+ await deliverShareImages(result.slices, title)
255
+ return t('shareStitchFailed')
256
+ }
257
+ }
258
+ await deliverShareImages(result.slices, title)
259
+ return t('shareStitchUnsupported')
260
+ })
261
+ .then((degraded) => {
262
+ setBusy(false)
263
+ setShareData(null)
264
+ closeShare()
265
+ setNotice(degraded)
266
+ })
267
+ .catch(shareFailed)
268
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- shareData is the trigger; helpers only touch setters and module functions.
269
+ }, [shareData])
270
+
144
271
  if (!open) return null
145
272
 
146
273
  // Rewind rather than flip the flag — see the one-direction rule in
@@ -196,10 +323,51 @@ export function MobileSessionInfo({
196
323
  void downloadSessionLog(sessionId)
197
324
  }
198
325
 
326
+ const onShare = (): void => {
327
+ setError(null)
328
+ setNotice(null)
329
+ setShareData(null)
330
+ setShareOpen(true)
331
+ pushLayer({ id: SHARE_LAYER, close: () => setShareOpen(false) })
332
+ }
333
+
334
+ // Pipeline stage one: fetch the selected range. Stage two (rasterize +
335
+ // deliver) lives in the shareData effect above — the hidden card must be
336
+ // committed to the DOM before the rasterizer can clone it.
337
+ const onShareConfirm = (choice: SharePreset): void => {
338
+ const range: ShareRange = choice === 'all' ? { kind: 'all' } : { kind: 'last', turns: choice }
339
+ setBusy(true)
340
+ setError(null)
341
+ // Session id straight off the useSessions row (its branded
342
+ // session-<uuid> form — a bare uuid 404s, PLAN §2), slot prop as backup.
343
+ fetchShareExport(row?.id ?? sessionId, range)
344
+ .then((data) => {
345
+ if (data.turns.length === 0) throw new Error(t('shareErrEmpty'))
346
+ setShareData(data)
347
+ })
348
+ .catch(shareFailed)
349
+ }
350
+
199
351
  const cacheHit = usage === undefined ? null : cacheHitPercent(usage)
200
352
  const tokensEmpty = usage === undefined || (billedInputTokens(usage) === 0 && usage.outputTokens === 0)
201
353
  const cwd = row?.cwd === undefined || row.cwd === '' ? undefined : workspaceTitleOf(row.cwd)
202
354
  const preset = agentPresetOf(row)
355
+ // Known-empty sessions (stats observed, zero turns) disable the share
356
+ // button outright; unknown-stats sessions stay enabled and let the fetch's
357
+ // empty-turns guard speak. Known 口径 gap, accepted for v1: the projection
358
+ // counts a turn only once one of its steps has CLOSED (the fold increments
359
+ // on step/end — dsh-session-stats projection.js), so a session whose only
360
+ // content is a still-streaming turn, or user messages with no assistant
361
+ // step yet, also reads turns === 0 and is over-disabled here. Wrong
362
+ // direction but fail-safe: the button is merely greyed (no misleading
363
+ // error), and every session that passes through still meets the fetch-side
364
+ // empty-turns guard (shareErrEmpty below), which is exact.
365
+ const shareEmpty = stats !== undefined && stats.turns === 0
366
+ const shareCardCopy: ShareCardCopy = {
367
+ turnsLabel: (count: number) => t('shareCardTurns', { count }),
368
+ generatedBy: t('shareCardGeneratedBy'),
369
+ image: t('shareCardImage'),
370
+ }
203
371
 
204
372
  const cells: Array<{ label: string; value: string; sub: string | undefined }> = [
205
373
  { label: t('infoStatTurns'), value: stats === undefined ? NA : String(stats.turns), sub: undefined },
@@ -304,6 +472,28 @@ export function MobileSessionInfo({
304
472
  </div>
305
473
 
306
474
  {error !== null && <div data-mobile-nav="info-error">{t('infoActionError', { message: error })}</div>}
475
+ {/* Degraded-outcome notice (review 07+08): same container metrics as
476
+ the error row, but informational colors and no prefix template —
477
+ the export itself succeeded, only the single-long-image form did
478
+ not. Inline-styled (this feature adds no page CSS); role="status"
479
+ so assistive tech announces it as a polite status, not an alert. */}
480
+ {notice !== null && (
481
+ <div
482
+ data-mobile-nav="info-notice"
483
+ role="status"
484
+ style={{
485
+ marginBottom: 8,
486
+ padding: '8px 10px',
487
+ borderRadius: 10,
488
+ background: 'var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06))',
489
+ color: 'var(--dsw-alias-label-secondary, rgba(0, 0, 0, .6))',
490
+ fontSize: 12,
491
+ lineHeight: 1.4,
492
+ }}
493
+ >
494
+ {notice}
495
+ </div>
496
+ )}
307
497
 
308
498
  <div data-mobile-nav="info-actions">
309
499
  <button type="button" data-mobile-nav="info-action" disabled={busy} onClick={onExport}>
@@ -328,8 +518,51 @@ export function MobileSessionInfo({
328
518
  <IconArchiveOutline20 size={20} />
329
519
  <span>{t('infoArchive')}</span>
330
520
  </button>
521
+ <button
522
+ type="button"
523
+ data-mobile-nav="info-action"
524
+ disabled={busy || shareEmpty}
525
+ onClick={onShare}
526
+ >
527
+ <IconShareOutline16 size={16} />
528
+ <span>{t('shareAction')}</span>
529
+ </button>
331
530
  </div>
531
+
532
+ {/* Offscreen render host for the share card (ticket 05). The
533
+ rasterizer clones this tree into its own shadow-root sandbox, so
534
+ the card never has to be visible — only attached. position:fixed
535
+ offscreen keeps it laid out but unpainted, and the info layer's
536
+ ≥768px display:none keeps the desktop clean. */}
537
+ {shareData !== null && (
538
+ <div
539
+ ref={shareCardHostRef}
540
+ aria-hidden="true"
541
+ style={{ position: 'fixed', left: '-9999px', top: 0, pointerEvents: 'none' }}
542
+ >
543
+ <ShareCard
544
+ title={row?.displayTitle ?? t('sessionInfo')}
545
+ {...(preset !== undefined ? { subtitle: preset } : {})}
546
+ {...(shareData.createdAt !== undefined ? { createdAt: shareData.createdAt } : {})}
547
+ turns={shareData.turns}
548
+ copy={shareCardCopy}
549
+ />
550
+ </div>
551
+ )}
332
552
  </div>
553
+
554
+ {/* Share-range picker (ticket 05): a DIRECT child of the info layer —
555
+ sibling of the card, never inside it. With the fixed layer as the
556
+ containing block, the picker's own absolute inset:0 overlay is a
557
+ fullscreen mask that sits above the info mask and the card: a tap
558
+ outside the card reaches IT (popLayer(SHARE_LAYER)) and closes only
559
+ the picker, never both layers at once; the sheet bottom anchors to
560
+ the viewport's safe area (not the card's box, no double count) and
561
+ stays put when the card scrolls. Its own history entry means a back
562
+ gesture, likewise, closes it alone. */}
563
+ {shareOpen && (
564
+ <ShareRangeSheet busy={busy} onConfirm={onShareConfirm} onClose={closeShare} t={t} />
565
+ )}
333
566
  </div>
334
567
  )
335
568
  }
@@ -210,11 +210,18 @@ export function installKeyboardAvoid(ctx: ClientContext): void {
210
210
  pad = safetyPad(navigator.userAgent, tuning.safetyPadPx)
211
211
  })
212
212
 
213
- const composerTextarea = (node: unknown): boolean =>
214
- node instanceof HTMLElement && node.tagName === 'TEXTAREA' && node.closest(COMPOSER) !== null
213
+ /** The composer's editing host: the `data-composer-input` contenteditable
214
+ * DSH 0.1.5 binds Lexical to, or the textarea of older hosts (the
215
+ * attribute sat on that textarea too). Same recognition as S9's guard —
216
+ * while this checked TEXTAREA only, the whole focus machinery below was
217
+ * blind to the 0.1.5 contenteditable and the dumb-keyboard estimate
218
+ * never ran. */
219
+ const composerField = (node: unknown): boolean =>
220
+ node instanceof HTMLElement && node.closest(COMPOSER) !== null
221
+ && (node.hasAttribute('data-composer-input') || node.tagName === 'TEXTAREA')
215
222
 
216
223
  const sync = (): void => {
217
- const focused = composerTextarea(document.activeElement)
224
+ const focused = composerField(document.activeElement)
218
225
  if (!focused) baseline = viewport.height
219
226
  const geometric = keyboardLift({
220
227
  innerHeight: window.innerHeight,
@@ -256,7 +263,7 @@ export function installKeyboardAvoid(ctx: ClientContext): void {
256
263
  sync()
257
264
  }
258
265
  const el = document.activeElement
259
- if (composerTextarea(el) && el instanceof HTMLElement) el.blur()
266
+ if (composerField(el) && el instanceof HTMLElement) el.blur()
260
267
  }
261
268
  /** True when a touch outside the composer means "the reader moved on". */
262
269
  const outside = (target: EventTarget | null): boolean =>
@@ -276,7 +283,7 @@ export function installKeyboardAvoid(ctx: ClientContext): void {
276
283
  if (outside(event.target)) retract()
277
284
  }
278
285
  const onFocusIn = (event: FocusEvent): void => {
279
- if (!composerTextarea(event.target)) return
286
+ if (!composerField(event.target)) return
280
287
  // Focus not born from a touch (hardware keyboard, programmatic) raises
281
288
  // no on-screen keyboard — never estimate for it.
282
289
  if (Date.now() - lastTouch > 1000) return
@@ -315,7 +322,7 @@ export function installKeyboardAvoid(ctx: ClientContext): void {
315
322
  // Keyboard is up (touch focus on a phone) yet the viewport never
316
323
  // reacted: the browser cannot see it. Estimate until blur, and
317
324
  // remember the verdict so the next focus skips the wait.
318
- if (!composerTextarea(document.activeElement)) return
325
+ if (!composerField(document.activeElement)) return
319
326
  localStorage.setItem(DUMB_KEY, '1')
320
327
  estimate = estimatedLift(window.innerHeight, tuning)
321
328
  sync()
@@ -323,7 +330,7 @@ export function installKeyboardAvoid(ctx: ClientContext): void {
323
330
  probeTimer = window.setTimeout(step, PROBE_INTERVAL_MS)
324
331
  }
325
332
  const onFocusOut = (event: FocusEvent): void => {
326
- if (!composerTextarea(event.target)) return
333
+ if (!composerField(event.target)) return
327
334
  stopProbe()
328
335
  if (estimate === 0) return
329
336
  estimate = 0