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.
- package/README.md +3 -2
- package/README.zh-CN.md +3 -2
- package/docs/interface.md +46 -3
- package/lib/client.js +3421 -103
- package/lib/index.js +13 -0
- package/lib/index.js.map +1 -1
- package/lib/share-export.js +347 -0
- package/lib/share-export.js.map +1 -0
- package/lib/types/client/MobileAttachButton.d.ts +8 -1
- package/lib/types/client/MobileAttachButton.d.ts.map +1 -1
- package/lib/types/client/MobileAttachChips.d.ts.map +1 -1
- package/lib/types/client/MobileSessionHeader.d.ts +7 -8
- package/lib/types/client/MobileSessionHeader.d.ts.map +1 -1
- package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
- package/lib/types/client/effects/keyboard-avoid.d.ts.map +1 -1
- package/lib/types/client/effects/keyboard-guard.d.ts +16 -11
- package/lib/types/client/effects/keyboard-guard.d.ts.map +1 -1
- package/lib/types/client/host-attach.d.ts +38 -0
- package/lib/types/client/host-attach.d.ts.map +1 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +23 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/share/fetch-share.d.ts +77 -0
- package/lib/types/client/share/fetch-share.d.ts.map +1 -0
- package/lib/types/client/share/markdown.d.ts +97 -0
- package/lib/types/client/share/markdown.d.ts.map +1 -0
- package/lib/types/client/share/png-stitch.d.ts +96 -0
- package/lib/types/client/share/png-stitch.d.ts.map +1 -0
- package/lib/types/client/share/rasterize.d.ts +317 -0
- package/lib/types/client/share/rasterize.d.ts.map +1 -0
- package/lib/types/client/share/share-card.d.ts +93 -0
- package/lib/types/client/share/share-card.d.ts.map +1 -0
- package/lib/types/client/share/share-constants.d.ts +27 -0
- package/lib/types/client/share/share-constants.d.ts.map +1 -0
- package/lib/types/client/share/share-flow.d.ts +76 -0
- package/lib/types/client/share/share-flow.d.ts.map +1 -0
- package/lib/types/client/share/share-preview.d.ts +11 -0
- package/lib/types/client/share/share-preview.d.ts.map +1 -0
- package/lib/types/client/share/share-sheet.d.ts +17 -0
- package/lib/types/client/share/share-sheet.d.ts.map +1 -0
- package/lib/types/client/share/text-layout.d.ts +48 -0
- package/lib/types/client/share/text-layout.d.ts.map +1 -0
- package/lib/types/client/styles/header.css.d.ts +1 -1
- package/lib/types/client/styles/header.css.d.ts.map +1 -1
- package/lib/types/client/styles/layout.css.d.ts +1 -1
- package/lib/types/client/styles/layout.css.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/share-export.d.ts +66 -0
- package/lib/types/share-export.d.ts.map +1 -0
- package/package.json +7 -2
- package/scripts/check-host-shadow.mjs +41 -9
- package/scripts/check-keyboard-avoid.mjs +4 -2
- package/scripts/check-share-image.mjs +970 -0
- package/scripts/check-upload-endpoint.mjs +10 -3
- package/src/client/MobileAttachButton.tsx +13 -0
- package/src/client/MobileAttachChips.tsx +8 -1
- package/src/client/MobileSessionHeader.tsx +90 -30
- package/src/client/MobileSessionInfo.tsx +234 -1
- package/src/client/effects/keyboard-avoid.ts +14 -7
- package/src/client/effects/keyboard-guard.ts +28 -18
- package/src/client/host-attach.ts +54 -0
- package/src/client/index.tsx +16 -0
- package/src/client/locales.ts +46 -0
- package/src/client/share/fetch-share.ts +161 -0
- package/src/client/share/markdown.ts +609 -0
- package/src/client/share/png-stitch.ts +281 -0
- package/src/client/share/rasterize.ts +1078 -0
- package/src/client/share/share-card.tsx +637 -0
- package/src/client/share/share-constants.ts +30 -0
- package/src/client/share/share-flow.ts +166 -0
- package/src/client/share/share-preview.tsx +337 -0
- package/src/client/share/share-sheet.tsx +222 -0
- package/src/client/share/text-layout.ts +147 -0
- package/src/client/styles/header.css.ts +30 -0
- package/src/client/styles/layout.css.ts +5 -2
- package/src/index.ts +13 -0
- package/src/share-export.ts +431 -0
|
@@ -1,17 +1,24 @@
|
|
|
1
|
-
// Integration check for the S7 upload route (
|
|
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
|
-
//
|
|
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 '../
|
|
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
|
|
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
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
* for a content open, not a bare type-only open
|
|
284
|
-
* plugin's real toggle
|
|
285
|
-
*
|
|
286
|
-
*
|
|
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
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
{
|
|
430
|
+
{sidebar !== null && <button
|
|
370
431
|
type="button"
|
|
371
432
|
data-mobile-nav="header-workbench"
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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 =
|
|
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 (
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
333
|
+
if (!composerField(event.target)) return
|
|
327
334
|
stopProbe()
|
|
328
335
|
if (estimate === 0) return
|
|
329
336
|
estimate = 0
|