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.
- package/AGENTS.md +51 -0
- package/LICENSE +38 -0
- package/README.md +237 -0
- package/assets/home.png +0 -0
- package/assets/info.png +0 -0
- package/assets/pairing.png +0 -0
- package/assets/session.png +0 -0
- package/assets/sheet.png +0 -0
- package/cordis.patch.yml +17 -0
- package/cordis.patch.yml.example +16 -0
- package/docs/interface.md +200 -0
- package/docs/remote-access.en.md +335 -0
- package/docs/remote-access.md +422 -0
- package/dsh-push.mjs +213 -0
- package/lan-gate.mjs +78 -0
- package/lib/client.js +6615 -0
- package/lib/index.js +310 -0
- package/lib/index.js.map +1 -0
- package/lib/lan-gate-server.cjs +626 -0
- package/lib/types/client/MobileAttachButton.d.ts +25 -0
- package/lib/types/client/MobileAttachButton.d.ts.map +1 -0
- package/lib/types/client/MobileAttachChips.d.ts +32 -0
- package/lib/types/client/MobileAttachChips.d.ts.map +1 -0
- package/lib/types/client/MobileDrawerFooter.d.ts +21 -0
- package/lib/types/client/MobileDrawerFooter.d.ts.map +1 -0
- package/lib/types/client/MobileHome.d.ts +24 -0
- package/lib/types/client/MobileHome.d.ts.map +1 -0
- package/lib/types/client/MobileHomeChips.d.ts +76 -0
- package/lib/types/client/MobileHomeChips.d.ts.map +1 -0
- package/lib/types/client/MobileNavOverlay.d.ts +15 -0
- package/lib/types/client/MobileNavOverlay.d.ts.map +1 -0
- package/lib/types/client/MobileNavToggle.d.ts +18 -0
- package/lib/types/client/MobileNavToggle.d.ts.map +1 -0
- package/lib/types/client/MobileSessionHeader.d.ts +58 -0
- package/lib/types/client/MobileSessionHeader.d.ts.map +1 -0
- package/lib/types/client/MobileSessionInfo.d.ts +41 -0
- package/lib/types/client/MobileSessionInfo.d.ts.map +1 -0
- package/lib/types/client/attach-upload.d.ts +78 -0
- package/lib/types/client/attach-upload.d.ts.map +1 -0
- package/lib/types/client/chips-store.d.ts +13 -0
- package/lib/types/client/chips-store.d.ts.map +1 -0
- package/lib/types/client/debug.d.ts +9 -0
- package/lib/types/client/debug.d.ts.map +1 -0
- package/lib/types/client/effects/aionui-compat.d.ts +4 -0
- package/lib/types/client/effects/aionui-compat.d.ts.map +1 -0
- package/lib/types/client/effects/gestures.d.ts +7 -0
- package/lib/types/client/effects/gestures.d.ts.map +1 -0
- package/lib/types/client/effects/header-status.d.ts +15 -0
- package/lib/types/client/effects/header-status.d.ts.map +1 -0
- package/lib/types/client/effects/phone-chrome.d.ts +106 -0
- package/lib/types/client/effects/phone-chrome.d.ts.map +1 -0
- package/lib/types/client/effects/turn-fold.d.ts +25 -0
- package/lib/types/client/effects/turn-fold.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +18 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +58 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/nav-store.d.ts +73 -0
- package/lib/types/client/nav-store.d.ts.map +1 -0
- package/lib/types/client/session-dot.d.ts +12 -0
- package/lib/types/client/session-dot.d.ts.map +1 -0
- package/lib/types/client/styles/base.css.d.ts +2 -0
- package/lib/types/client/styles/base.css.d.ts.map +1 -0
- package/lib/types/client/styles/chips.css.d.ts +2 -0
- package/lib/types/client/styles/chips.css.d.ts.map +1 -0
- package/lib/types/client/styles/compat.css.d.ts +2 -0
- package/lib/types/client/styles/compat.css.d.ts.map +1 -0
- package/lib/types/client/styles/composer.css.d.ts +2 -0
- package/lib/types/client/styles/composer.css.d.ts.map +1 -0
- package/lib/types/client/styles/header.css.d.ts +2 -0
- package/lib/types/client/styles/header.css.d.ts.map +1 -0
- package/lib/types/client/styles/home.css.d.ts +2 -0
- package/lib/types/client/styles/home.css.d.ts.map +1 -0
- package/lib/types/client/styles/index.d.ts +15 -0
- package/lib/types/client/styles/index.d.ts.map +1 -0
- package/lib/types/client/styles/info.css.d.ts +2 -0
- package/lib/types/client/styles/info.css.d.ts.map +1 -0
- package/lib/types/client/styles/layout.css.d.ts +2 -0
- package/lib/types/client/styles/layout.css.d.ts.map +1 -0
- package/lib/types/client/styles/misc.css.d.ts +2 -0
- package/lib/types/client/styles/misc.css.d.ts.map +1 -0
- package/lib/types/client/styles/turn-fold.css.d.ts +2 -0
- package/lib/types/client/styles/turn-fold.css.d.ts.map +1 -0
- package/lib/types/index.d.ts +83 -0
- package/lib/types/index.d.ts.map +1 -0
- package/llms.txt +20 -0
- package/package.json +128 -0
- package/pwa/app.css +95 -0
- package/pwa/icons/icon-192.png +0 -0
- package/pwa/icons/icon-512.png +0 -0
- package/pwa/icons/icon-maskable-512.png +0 -0
- package/pwa/icons/icon-maskable.svg +1 -0
- package/pwa/icons/icon.svg +1 -0
- package/pwa/inject.js +190 -0
- package/pwa/manifest.json +29 -0
- package/pwa/offline.html +38 -0
- package/pwa/sw.js +176 -0
- package/pwa/touch-gestures.js +94 -0
- package/scripts/build-client.mjs +101 -0
- package/scripts/cdp-probe.mjs +64 -0
- package/scripts/check-attach-upload.mjs +99 -0
- package/scripts/check-sunk-viewport.mjs +33 -0
- package/scripts/check-upload-endpoint.mjs +168 -0
- package/src/client/MobileAttachButton.tsx +125 -0
- package/src/client/MobileAttachChips.tsx +85 -0
- package/src/client/MobileDrawerFooter.tsx +56 -0
- package/src/client/MobileHome.tsx +348 -0
- package/src/client/MobileHomeChips.tsx +426 -0
- package/src/client/MobileNavOverlay.tsx +279 -0
- package/src/client/MobileNavToggle.tsx +52 -0
- package/src/client/MobileSessionHeader.tsx +242 -0
- package/src/client/MobileSessionInfo.tsx +303 -0
- package/src/client/attach-upload.ts +140 -0
- package/src/client/chips-store.ts +41 -0
- package/src/client/debug.ts +253 -0
- package/src/client/effects/aionui-compat.ts +183 -0
- package/src/client/effects/gestures.ts +265 -0
- package/src/client/effects/header-status.ts +30 -0
- package/src/client/effects/phone-chrome.ts +265 -0
- package/src/client/effects/turn-fold.ts +258 -0
- package/src/client/index.tsx +227 -0
- package/src/client/locales.ts +109 -0
- package/src/client/nav-store.ts +78 -0
- package/src/client/session-dot.ts +17 -0
- package/src/client/styles/base.css.ts +167 -0
- package/src/client/styles/chips.css.ts +224 -0
- package/src/client/styles/compat.css.ts +681 -0
- package/src/client/styles/composer.css.ts +504 -0
- package/src/client/styles/header.css.ts +368 -0
- package/src/client/styles/home.css.ts +477 -0
- package/src/client/styles/index.ts +25 -0
- package/src/client/styles/info.css.ts +238 -0
- package/src/client/styles/layout.css.ts +501 -0
- package/src/client/styles/misc.css.ts +128 -0
- package/src/client/styles/turn-fold.css.ts +106 -0
- 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
|
+
}
|