dsh-transfer-hud 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xiuerhuahuazi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,182 @@
1
- # Temporary Holding Version
1
+ # dsh-transfer-hud
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ English | [中文](#中文)
4
+
5
+ A thin strip below the message composer that shows what DeepSeek Harness is downloading
6
+ right now. Zero runtime dependencies; a Host half (`index.js`) plus a Web Client half
7
+ (`client.js`). Also stops (background) downloads from the strip.
8
+
9
+ > **Version:** this npm release is **1.0.0** and packages the workspace HUD revision
10
+ > **v1.1.1** (refined download classifier). The first public release renumbers the
11
+ > internal revision to 1.0.0.
12
+
13
+ ## English
14
+
15
+ ### What it shows
16
+
17
+ | Source | Row | Progress |
18
+ |---|---|---|
19
+ | **Background jobs** (`ctx.jobs`: `pwsh`/`bash` jobs started with `run_in_background`, or promoted after a timeout) | real job row: label, status, **Stop** button | percent / bytes / rate / ETA when the output ring carries a meter (curl, wget, aria2, tqdm / `hf download`, huggingface, modelscope, `git` counters, …), parsed from **stdout and stderr** — most downloaders print their meter to stderr |
20
+ | **Foreground shell commands** (`pwsh`, `bash`, `ops_bash`, `terminal_send`, …) | indeterminate row (`foreground: true`, no Stop button) | none — the Host only sees the call, not its live output; the row proves a download is running and disappears shortly after the tool result |
21
+
22
+ When a foreground call starts a background job (`run_in_background: true`, or a
23
+ foreground command promoted on timeout), the job row replaces the foreground row: the
24
+ Host reads `job/registered`'s `toolCall.callId` and dedupes.
25
+
26
+ ### What counts as a download
27
+
28
+ The classifier is deliberately narrow so ordinary API traffic never shows a row:
29
+
30
+ - **Unambiguous downloaders** in command position (optional `sudo`/`nohup`/`time`/
31
+ `timeout`/`VAR=` wrappers): `aria2c`, `yt-dlp`, `youtube-dl`, `scp`, `sftp`, `rsync`,
32
+ `axel`, `hf` / `huggingface-cli` / `snapshot_download` / `modelscope`,
33
+ `git clone` / `git lfs`, `pip download` / `pip install`, PowerShell
34
+ `Start-BitsTransfer` / `bitsadmin` / `DownloadFile` / `DownloadString` / `DownloadData`,
35
+ `[System.Net.WebClient]` / `[System.Net.HttpClient]`.
36
+ - **Dual-use `curl` / `wget` / `Invoke-WebRequest` / `iwr`** count only with real download
37
+ intent: an explicit output flag (`-o`, `-O`, `--output`, `--remote-name`,
38
+ `--remote-header-name`, `-J`, or a short-flag cluster like `-sSLo`; `-OutFile` for
39
+ `iwr`), **or** a non-loopback URL whose path ends in a file-like extension
40
+ (`.bin .zip .tar .tgz .gz .bz2 .xz .zst .7z .rar .whl .exe .msi .iso .dmg .pkg .deb .rpm
41
+ .apk .safetensors .gguf .ckpt .onnx .pt .pth .model .mp4 .mkv .webm .npy .npz .parquet
42
+ .arrow .h5 .hdf5 .tflite .mlmodel`).
43
+ - **Ignored on purpose:** loopback / `localhost` / `127.*` / `::1` / `0.0.0.0` URLs without
44
+ an output flag (API polls), and JSON / JSONL / CSV responses — those are API payloads,
45
+ not downloads.
46
+ - The downloader must sit in command position, so `grep -r curl src/` or `cat download.md`
47
+ are ignored. An opaque call such as `python fetch.py` is recognised only when the call's
48
+ description says "download/fetch/…" or its file/URL matches a known artifact pattern.
49
+
50
+ ### Install
51
+
52
+ Requires DeepSeek Harness `>=0.2.1-rc.2 <0.3.0-0` and a Web or Desktop profile
53
+ (the plugin has a Host half and a browser Client half).
54
+
55
+ 1. Open **Settings → Plugins**, install bundle `dsh-transfer-hud@1.0.0`.
56
+ 2. Or ask the agent: `plugin_manager` → `action: "install_bundle"`,
57
+ `target: "dsh-transfer-hud@1.0.0"`.
58
+
59
+ **Restart the app after installing or upgrading.** HMR applies a new bundle, but the
60
+ profile's Host watch roots are empty (`root: []`) and a replaced package needs a fresh
61
+ JavaScript module generation, so the new Host code is loaded on restart; the Client half
62
+ then follows with the usual client HMR / page refresh. Verification: after the restart,
63
+ run a foreground download (e.g. `curl -o file.bin <url>`) or a background job
64
+ (`run_in_background: true`) and the strip appears below the composer. Remove the bundle to
65
+ uninstall.
66
+
67
+ ### Configuration
68
+
69
+ None. The row takes no config; the strip is automatic and only reads activity that
70
+ already belongs to this Harness.
71
+
72
+ ### Limits
73
+
74
+ - **Foreground downloads are indeterminate.** A foreground tool call exposes no live
75
+ output to the Host, so there is no percent/rate/ETA — only "a download is running".
76
+ - **Session-scoped.** Rows belong to the session the strip is mounted in. A download
77
+ started by a subagent/teammate session is not shown in the parent conversation.
78
+ - **Opaque scripts need a hint.** See "What counts as a download" above.
79
+ - **No progress output, no meter.** `Invoke-WebRequest` with
80
+ `$ProgressPreference = 'SilentlyContinue'` prints nothing to parse; the row still
81
+ appears from the label hint but stays indeterminate.
82
+ - **Stop only stops background jobs.** A foreground command cannot be killed from the
83
+ HUD; it follows its own approval/timeout/cancellation path.
84
+ - UI rendering (strip/panel layout) can only be verified in a browser.
85
+
86
+ ### Development
87
+
88
+ Host and Client test harnesses live in `test/` in the source repository and are excluded
89
+ from the published tarball:
90
+
91
+ ```sh
92
+ node plugins/dsh-transfer-hud/test/host.test.mjs
93
+ node plugins/dsh-transfer-hud/test/client.test.mjs
94
+ ```
95
+
96
+ ### License
97
+
98
+ MIT.
99
+
100
+ ## 中文
101
+
102
+ 消息输入框下方的一条细长下载进度条,显示本 Harness 当前正在下载的内容,并可从进度条上
103
+ 停止(后台)任务。零运行时依赖,由 Host 半(`index.js`)与 Web Client 半(`client.js`)组成。
104
+
105
+ > **版本**:本次 npm 发布为 **1.0.0**,打包的是工作区 HUD **v1.1.1**(细化后的下载分类器);
106
+ > 首次公开发布将内部修订号重编为 1.0.0。
107
+
108
+ ### 显示内容
109
+
110
+ | 来源 | 行 | 进度 |
111
+ |---|---|---|
112
+ | **后台任务**(`ctx.jobs`:用 `run_in_background` 启动、或超时转后台的 `pwsh`/`bash` 任务) | 真实任务行:标签、状态、**停止**按钮 | 输出环携带进度时显示百分比/字节/速度/剩余时间(curl、wget、aria2、tqdm / `hf download`、huggingface、modelscope、`git` 计数……),同时解析 **stdout 与 stderr**——多数下载器把进度打印到 stderr |
113
+ | **前台 shell 命令**(`pwsh`、`bash`、`ops_bash`、`terminal_send`……) | 不确定行(`foreground: true`,无停止按钮) | 无——Host 只能看到调用,看不到其运行中的输出;该行用于表明正在下载,并在工具结果返回后不久消失 |
114
+
115
+ 当前台调用启动后台任务(`run_in_background: true`,或超时转后台)时,任务行会替换前台行:
116
+ Host 读取 `job/registered` 的 `toolCall.callId` 去重。
117
+
118
+ ### 什么算作下载
119
+
120
+ 分类器刻意收窄,普通 API 流量不会出现行:
121
+
122
+ - **无歧义下载器**(位于命令位置,可带 `sudo`/`nohup`/`time`/`timeout`/`VAR=` 前缀):
123
+ `aria2c`、`yt-dlp`、`youtube-dl`、`scp`、`sftp`、`rsync`、`axel`、`hf` /
124
+ `huggingface-cli` / `snapshot_download` / `modelscope`、`git clone` / `git lfs`、
125
+ `pip download` / `pip install`、PowerShell `Start-BitsTransfer` / `bitsadmin` /
126
+ `DownloadFile` / `DownloadString` / `DownloadData`、`[System.Net.WebClient]` /
127
+ `[System.Net.HttpClient]`。
128
+ - **两用的 `curl` / `wget` / `Invoke-WebRequest` / `iwr`** 只有具备真实下载意图才计入:
129
+ 显式输出参数(`-o`、`-O`、`--output`、`--remote-name`、`--remote-header-name`、`-J`,
130
+ 或 `-sSLo` 这类短参数组合;`iwr` 的 `-OutFile`),**或**非回环 URL 的路径以文件类扩展名
131
+ 结尾(`.bin .zip .tar .tgz .gz .bz2 .xz .zst .7z .rar .whl .exe .msi .iso .dmg .pkg
132
+ .deb .rpm .apk .safetensors .gguf .ckpt .onnx .pt .pth .model .mp4 .mkv .webm .npy .npz
133
+ .parquet .arrow .h5 .hdf5 .tflite .mlmodel`)。
134
+ - **有意忽略**:无输出参数时访问回环/`localhost`/`127.*`/`::1`/`0.0.0.0` 的 URL(API 轮询),
135
+ 以及 JSON / JSONL / CSV 响应——那是 API 载荷而不是下载。
136
+ - 下载器必须位于命令位置,因此 `grep -r curl src/`、`cat download.md` 不会被误判。
137
+ `python fetch.py` 这类不透明调用只有描述包含 download/fetch 等词、或文件/URL 命中已知
138
+ 产物模式时才会被识别。
139
+
140
+ ### 安装
141
+
142
+ 需要 DeepSeek Harness `>=0.2.1-rc.2 <0.3.0-0`,以及 Web 或 Desktop profile
143
+ (插件同时包含 Host 半与浏览器 Client 半)。
144
+
145
+ 1. 打开 **设置 → 插件**,安装 bundle `dsh-transfer-hud@1.0.0`。
146
+ 2. 或让 agent 调用:`plugin_manager` → `action: "install_bundle"`,
147
+ `target: "dsh-transfer-hud@1.0.0"`。
148
+
149
+ **安装或升级后请重启应用。** HMR 会应用新的 bundle,但该 profile 的 Host watch root 为空
150
+ (`root: []`),且替换已安装的包需要新的 JavaScript 模块代号,因此新的 Host 代码要重启后
151
+ 才加载;Client 半随后走常规的 client HMR / 页面刷新。验证方式:重启后运行一个前台下载
152
+ (如 `curl -o file.bin <url>`)或后台任务(`run_in_background: true`),输入框下方会出现
153
+ 进度条。移除该 bundle 即卸载。
154
+
155
+ ### 配置
156
+
157
+ 无。该行不需要配置,进度条自动出现,且只读取本 Harness 自身已有的活动。
158
+
159
+ ### 限制
160
+
161
+ - **前台下载是不确定的**:前台工具调用不向 Host 暴露运行中输出,所以没有百分比/速度/
162
+ 剩余时间,只表示“正在下载”。
163
+ - **按会话隔离**:行属于进度条挂载的会话;子代理/队友会话发起的下载不会显示在父会话中。
164
+ - **不透明脚本需要提示**:见上文“什么算作下载”。
165
+ - **没有进度输出就没有进度**:`Invoke-WebRequest` 在
166
+ `$ProgressPreference = 'SilentlyContinue'` 下不打印可解析内容;行仍会按标签提示出现,
167
+ 但保持不确定状态。
168
+ - **停止只作用于后台任务**:前台命令无法从进度条取消,它遵循自身的审批/超时/取消机制。
169
+ - 界面渲染(进度条/浮层布局)只能在浏览器中验证。
170
+
171
+ ### 开发
172
+
173
+ Host 与 Client 的测试位于源码仓库的 `test/`,不包含在发布的 tarball 中:
174
+
175
+ ```sh
176
+ node plugins/dsh-transfer-hud/test/host.test.mjs
177
+ node plugins/dsh-transfer-hud/test/client.test.mjs
178
+ ```
179
+
180
+ ### 许可证
181
+
182
+ MIT。
package/client.js ADDED
@@ -0,0 +1,445 @@
1
+ /**
2
+ * Download HUD — Client half.
3
+ *
4
+ * A thin, space-free strip below the message composer that appears only while a
5
+ * background job looks like a download. Clicking it opens a details popover with
6
+ * per-job progress, throughput, remaining time and a stop action.
7
+ *
8
+ * Data comes from the Host half's same-origin route; this module renders only.
9
+ */
10
+
11
+ window.__ModuleLoader__.load({
12
+ id: 'dsh-transfer-hud',
13
+ factory(require) {
14
+ const React = require('react')
15
+ const h = React.createElement
16
+ const { useCallback, useEffect, useMemo, useRef, useState } = React
17
+
18
+ const STATE_URL = '/dsh-download-hud/state'
19
+ const KILL_URL = '/dsh-download-hud/kill'
20
+ const POLL_MS = 1000
21
+ const LINGER_MS = 15000
22
+ const PANEL_MAX_WIDTH = 520
23
+
24
+ const TEXT = {
25
+ en: {
26
+ live: 'Downloading',
27
+ jobs: 'downloads',
28
+ stopped: 'Stopped',
29
+ done: 'Done',
30
+ failed: 'Failed',
31
+ eta: 'left',
32
+ stop: 'Stop',
33
+ stopping: 'Stopping…',
34
+ noProgress: 'running',
35
+ details: 'Downloads',
36
+ elapsed: 'elapsed',
37
+ command: 'command',
38
+ },
39
+ zh: {
40
+ live: '下载中',
41
+ jobs: '个下载',
42
+ stopped: '已停止',
43
+ done: '已完成',
44
+ failed: '失败',
45
+ eta: '剩余',
46
+ stop: '取消',
47
+ stopping: '正在停止…',
48
+ noProgress: '运行中',
49
+ details: '下载任务',
50
+ elapsed: '已用时',
51
+ command: '命令',
52
+ },
53
+ }
54
+
55
+ const CSS = `
56
+ .ddh-strip{position:relative;display:flex;align-items:center;gap:6px;box-sizing:border-box;flex:1 1 auto;min-width:220px;max-width:680px;padding:3px 2px 3px;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;cursor:pointer;background:transparent;border:0;border-radius:5px;text-align:left;font-family:inherit}
57
+ .ddh-strip:hover{color:var(--dsw-alias-label-primary)}
58
+ .ddh-line{display:flex;align-items:center;gap:6px;min-width:0;flex:1}
59
+ .ddh-icon{flex:none;display:inline-flex;color:var(--dsw-alias-brand-primary)}
60
+ .ddh-label{flex:none;font-weight:500}
61
+ .ddh-meta{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-secondary)}
62
+ .ddh-chevron{flex:none;display:inline-flex;transition:transform .12s ease}
63
+ .ddh-chevron-open{transform:rotate(180deg)}
64
+ .ddh-track{position:absolute;left:2px;bottom:1px;height:2px;border-radius:1px;max-width:calc(100% - 4px);background:var(--dsw-alias-brand-primary);transition:width .25s ease}
65
+ .ddh-track-done{background:var(--dsw-alias-state-success-primary)}
66
+ .ddh-track-failed{background:var(--dsw-alias-state-error-primary)}
67
+ .ddh-fill{height:100%;border-radius:2px;background:var(--dsw-alias-brand-primary);transition:width .25s ease}
68
+ .ddh-fill-done{background:var(--dsw-alias-state-success-primary)}
69
+ .ddh-fill-failed{background:var(--dsw-alias-state-error-primary)}
70
+ .ddh-panel{position:fixed;z-index:120;display:flex;flex-direction:column;box-sizing:border-box;background:var(--dsw-alias-bg-layer-1);border:0.5px solid var(--dsw-alias-border-l2);border-radius:10px;box-shadow:0 10px 32px rgba(0,0,0,.28);overflow:hidden;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}
71
+ .ddh-panel-head{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 12px 6px;color:var(--dsw-alias-label-tertiary);font-size:11px;border-bottom:0.5px solid var(--dsw-alias-border-l1)}
72
+ .ddh-rows{display:flex;flex-direction:column;max-height:min(420px,60vh);overflow:auto;padding:2px 0}
73
+ .ddh-row{display:flex;flex-direction:column;gap:2px;padding:7px 12px;min-width:0}
74
+ .ddh-row + .ddh-row{border-top:0.5px solid var(--dsw-alias-border-l1)}
75
+ .ddh-row-top{display:flex;align-items:center;gap:8px;min-width:0}
76
+ .ddh-dot{flex:none;width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-brand-primary)}
77
+ .ddh-dot-done{background:var(--dsw-alias-state-success-primary)}
78
+ .ddh-dot-failed{background:var(--dsw-alias-state-error-primary)}
79
+ .ddh-dot-idle{background:var(--dsw-alias-state-idle-primary)}
80
+ .ddh-row-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-primary)}
81
+ .ddh-row-pct{flex:none;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary)}
82
+ .ddh-stop{flex:none;border:0;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;font:inherit;padding:1px 6px;border-radius:5px}
83
+ .ddh-stop:hover{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-error-primary)}
84
+ .ddh-stop:disabled{opacity:.5;cursor:default}
85
+ .ddh-row-sub{display:flex;gap:10px;min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:var(--dsw-alias-label-tertiary);font-size:11px;font-variant-numeric:tabular-nums}
86
+ .ddh-row-bar{height:2px;border-radius:1px;background:var(--dsw-alias-border-l1);overflow:hidden;margin-top:2px}
87
+ .ddh-note{padding:8px 12px;color:var(--dsw-alias-state-error-primary);font-size:11px}
88
+ `
89
+
90
+ function pickLocale(ctx) {
91
+ const language = (() => {
92
+ try {
93
+ const snapshot = ctx.locale.getLocale()
94
+ const id = snapshot && (snapshot.id || snapshot.locale || snapshot.current)
95
+ if (typeof id === 'string' && id) return id
96
+ } catch {}
97
+ try {
98
+ return navigator.language || 'en'
99
+ } catch {
100
+ return 'en'
101
+ }
102
+ })()
103
+ return String(language).toLowerCase().startsWith('zh') ? 'zh' : 'en'
104
+ }
105
+
106
+ function formatBytes(value) {
107
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return ''
108
+ const units = ['B', 'KB', 'MB', 'GB', 'TB']
109
+ let amount = value
110
+ let index = 0
111
+ while (amount >= 1024 && index < units.length - 1) {
112
+ amount /= 1024
113
+ index += 1
114
+ }
115
+ const digits = index === 0 ? 0 : amount >= 100 ? 0 : 1
116
+ return `${amount.toFixed(digits)} ${units[index]}`
117
+ }
118
+
119
+ function formatDuration(seconds) {
120
+ if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds < 0) return ''
121
+ const total = Math.round(seconds)
122
+ if (total < 60) return `${total}s`
123
+ const minutes = Math.floor(total / 60)
124
+ if (minutes < 60) return `${minutes}m ${String(total % 60).padStart(2, '0')}s`
125
+ const hours = Math.floor(minutes / 60)
126
+ return `${hours}h ${String(minutes % 60).padStart(2, '0')}m`
127
+ }
128
+
129
+ function isLive(job) {
130
+ return job.status === 'running' || job.status === 'stopping'
131
+ }
132
+
133
+ function statusText(job, text) {
134
+ if (job.status === 'completed') return text.done
135
+ if (job.status === 'failed') return text.failed
136
+ if (job.status === 'killed') return text.stopped
137
+ if (job.status === 'stopping') return text.stopping
138
+ return text.live
139
+ }
140
+
141
+ function visibleJobs(state, now) {
142
+ const jobs = state && Array.isArray(state.jobs) ? state.jobs : []
143
+ return jobs.filter((job) => {
144
+ if (!job || job.download !== true) return false
145
+ if (isLive(job)) return true
146
+ const finishedAt = typeof job.finishedAt === 'number' ? job.finishedAt : 0
147
+ return finishedAt > 0 && now - finishedAt < LINGER_MS
148
+ })
149
+ }
150
+
151
+ function aggregate(jobs) {
152
+ const live = jobs.filter(isLive)
153
+ let done = 0
154
+ let total = 0
155
+ let percentSum = 0
156
+ let percentCount = 0
157
+ let rate = 0
158
+ let eta = 0
159
+ for (const job of live) {
160
+ if (typeof job.done === 'number' && typeof job.total === 'number' && job.total > 0) {
161
+ done += job.done
162
+ total += job.total
163
+ } else if (typeof job.percent === 'number') {
164
+ percentSum += job.percent
165
+ percentCount += 1
166
+ }
167
+ if (typeof job.rateBps === 'number') rate += job.rateBps
168
+ if (typeof job.etaSeconds === 'number' && job.etaSeconds > eta) eta = job.etaSeconds
169
+ }
170
+ let percent = null
171
+ if (total > 0) percent = Math.round((done / total) * 1000) / 10
172
+ else if (percentCount > 0) percent = Math.round((percentSum / percentCount) * 10) / 10
173
+ return { live, done, total, percent, rate, eta }
174
+ }
175
+
176
+ function createDownloadHud(ctx, text) {
177
+ return function DownloadHud(props) {
178
+ const sessionId = useMemo(() => {
179
+ const candidates = [props && props.sessionId, props && props.session && props.session.id]
180
+ for (const candidate of candidates) {
181
+ if (typeof candidate === 'string' && candidate) return candidate
182
+ }
183
+ return ''
184
+ }, [props && props.sessionId])
185
+
186
+ const [state, setState] = useState(null)
187
+ const [open, setOpen] = useState(false)
188
+ const [busyId, setBusyId] = useState('')
189
+ const [nowTick, setNowTick] = useState(() => Date.now())
190
+ const stripRef = useRef(null)
191
+ const panelRef = useRef(null)
192
+ const [anchor, setAnchor] = useState(null)
193
+
194
+ const url = useMemo(() => {
195
+ return sessionId ? `${STATE_URL}?sessionId=${encodeURIComponent(sessionId)}` : STATE_URL
196
+ }, [sessionId])
197
+
198
+ useEffect(() => {
199
+ let cancelled = false
200
+ let timer = 0
201
+ const tick = () => {
202
+ if (typeof document !== 'undefined' && document.hidden) return
203
+ fetch(url, { headers: { accept: 'application/json' }, cache: 'no-store' })
204
+ .then((response) => (response.ok ? response.json() : null))
205
+ .then((data) => {
206
+ if (cancelled || !data) return
207
+ setState(data)
208
+ setNowTick(Date.now())
209
+ })
210
+ .catch(() => {})
211
+ }
212
+ tick()
213
+ timer = setInterval(tick, POLL_MS)
214
+ // A hidden tab pauses polling; catch up as soon as it is visible so a
215
+ // download that started while the window was in the background shows.
216
+ const onVisible = () => {
217
+ if (typeof document === 'undefined' || document.hidden) return
218
+ tick()
219
+ }
220
+ if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisible)
221
+ return () => {
222
+ cancelled = true
223
+ clearInterval(timer)
224
+ if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisible)
225
+ }
226
+ }, [url])
227
+
228
+ const jobs = visibleJobs(state, nowTick)
229
+ const totals = aggregate(jobs)
230
+
231
+ const measure = useCallback(() => {
232
+ const node = stripRef.current
233
+ if (!node) return
234
+ const rect = node.getBoundingClientRect()
235
+ const width = Math.min(PANEL_MAX_WIDTH, Math.max(260, rect.width))
236
+ const left = Math.min(
237
+ Math.max(8, rect.left),
238
+ Math.max(8, (typeof window === 'undefined' ? rect.left + width : window.innerWidth) - width - 8),
239
+ )
240
+ setAnchor({
241
+ left,
242
+ width,
243
+ bottom: typeof window === 'undefined' ? 0 : window.innerHeight - rect.top + 6,
244
+ })
245
+ }, [])
246
+
247
+ useEffect(() => {
248
+ if (!open) return undefined
249
+ measure()
250
+ const onResize = () => measure()
251
+ window.addEventListener('resize', onResize)
252
+ window.addEventListener('scroll', onResize, true)
253
+ const onDown = (event) => {
254
+ const node = event.target
255
+ if (stripRef.current && stripRef.current.contains(node)) return
256
+ if (panelRef.current && panelRef.current.contains(node)) return
257
+ setOpen(false)
258
+ }
259
+ const onKey = (event) => {
260
+ if (event.key === 'Escape') setOpen(false)
261
+ }
262
+ document.addEventListener('mousedown', onDown)
263
+ document.addEventListener('keydown', onKey)
264
+ return () => {
265
+ window.removeEventListener('resize', onResize)
266
+ window.removeEventListener('scroll', onResize, true)
267
+ document.removeEventListener('mousedown', onDown)
268
+ document.removeEventListener('keydown', onKey)
269
+ }
270
+ }, [open, measure])
271
+
272
+ const stop = useCallback((job) => {
273
+ if (!job || !job.id || busyId) return
274
+ setBusyId(job.id)
275
+ fetch(KILL_URL, {
276
+ method: 'POST',
277
+ headers: { 'content-type': 'application/json' },
278
+ body: JSON.stringify({ id: job.id, sessionId: job.owner || sessionId || undefined }),
279
+ })
280
+ .catch(() => {})
281
+ .finally(() => setBusyId(''))
282
+ }, [busyId, sessionId])
283
+
284
+ if (jobs.length === 0) return null
285
+
286
+ const single = totals.live.length === 1 ? totals.live[0] : null
287
+ const percent = totals.percent === null ? (single && single.percent !== null ? single.percent : null) : totals.percent
288
+ const parts = []
289
+ if (jobs.length === 1) {
290
+ parts.push(`${totals.live.length === 0 ? statusText(jobs[0], text) : text.live} · ${jobs[0].label}`)
291
+ } else {
292
+ parts.push(`${text.live} ${totals.live.length || jobs.length} ${text.jobs}`)
293
+ }
294
+ if (typeof percent === 'number') parts.push(`${percent}%`)
295
+ if (totals.total > 0) parts.push(`${formatBytes(totals.done)} / ${formatBytes(totals.total)}`)
296
+ if (totals.rate > 0 && totals.live.length > 0) parts.push(`${formatBytes(totals.rate)}/s`)
297
+ if (totals.eta > 0 && totals.live.length > 0) parts.push(`${text.eta} ${formatDuration(totals.eta)}`)
298
+
299
+ const worst = jobs.some((job) => job.status === 'failed')
300
+ ? 'failed'
301
+ : jobs.every((job) => job.status === 'completed')
302
+ ? 'done'
303
+ : 'live'
304
+ const barWidth = typeof percent === 'number' ? Math.max(1.5, Math.min(100, percent)) : 12
305
+ const barClass = worst === 'failed' ? 'ddh-fill ddh-fill-failed' : worst === 'done' ? 'ddh-fill ddh-fill-done' : 'ddh-fill'
306
+ const trackClass = worst === 'failed' ? 'ddh-track ddh-track-failed' : worst === 'done' ? 'ddh-track ddh-track-done' : 'ddh-track'
307
+ const chevronClass = open ? 'ddh-chevron ddh-chevron-open' : 'ddh-chevron'
308
+
309
+ const panel = open && anchor
310
+ ? h('div', {
311
+ ref: panelRef,
312
+ className: 'ddh-panel',
313
+ role: 'dialog',
314
+ 'aria-label': text.details,
315
+ style: { left: `${anchor.left}px`, bottom: `${anchor.bottom}px`, width: `${anchor.width}px` },
316
+ }, [
317
+ h('div', { key: 'head', className: 'ddh-panel-head' }, [
318
+ h('span', { key: 'title' }, text.details),
319
+ h('span', { key: 'count' }, `${jobs.length}`),
320
+ ]),
321
+ h('div', { key: 'rows', className: 'ddh-rows' }, jobs.map((job) => {
322
+ const dotSuffix = job.status === 'completed' ? ' ddh-dot-done'
323
+ : job.status === 'failed' ? ' ddh-dot-failed'
324
+ : isLive(job) ? '' : ' ddh-dot-idle'
325
+ const rowPercent = typeof job.percent === 'number'
326
+ ? `${Math.round(job.percent * 10) / 10}%`
327
+ : (isLive(job) ? text.noProgress : '')
328
+ const sub = []
329
+ if (job.foreground) sub.push(text.command)
330
+ if (typeof job.done === 'number' && typeof job.total === 'number' && job.total > 0) {
331
+ sub.push(`${formatBytes(job.done)} / ${formatBytes(job.total)}`)
332
+ }
333
+ if (typeof job.rateBps === 'number' && job.rateBps > 0) sub.push(`${formatBytes(job.rateBps)}/s`)
334
+ if (typeof job.etaSeconds === 'number' && job.etaSeconds > 0 && isLive(job)) {
335
+ sub.push(`${text.eta} ${formatDuration(job.etaSeconds)}`)
336
+ }
337
+ if (!isLive(job)) sub.push(statusText(job, text))
338
+ else sub.push(`${text.elapsed} ${formatDuration((job.elapsedMs || 0) / 1000)}`)
339
+ const rowPercentValue = typeof job.percent === 'number' ? Math.max(1.5, Math.min(100, job.percent)) : 0
340
+ return h('div', { key: job.id, className: 'ddh-row' }, [
341
+ h('div', { key: 'top', className: 'ddh-row-top' }, [
342
+ h('span', { key: 'dot', className: `ddh-dot${dotSuffix}` }),
343
+ h('span', { key: 'name', className: 'ddh-row-name', title: job.fullLabel || job.label }, job.label),
344
+ rowPercent ? h('span', { key: 'pct', className: 'ddh-row-pct' }, rowPercent) : null,
345
+ isLive(job) && job.killable !== false
346
+ ? h('button', {
347
+ key: 'stop',
348
+ type: 'button',
349
+ className: 'ddh-stop',
350
+ disabled: busyId === job.id,
351
+ onClick: (event) => {
352
+ event.stopPropagation()
353
+ stop(job)
354
+ },
355
+ }, text.stop)
356
+ : null,
357
+ ]),
358
+ h('div', { key: 'sub', className: 'ddh-row-sub' }, sub.join(' · ')),
359
+ rowPercentValue > 0
360
+ ? h('div', { key: 'bar', className: 'ddh-row-bar' }, [
361
+ h('div', {
362
+ className: job.status === 'failed' ? 'ddh-fill ddh-fill-failed' : 'ddh-fill',
363
+ style: { width: `${rowPercentValue}%` },
364
+ }),
365
+ ])
366
+ : null,
367
+ ])
368
+ })),
369
+ state && Array.isArray(state.warnings) && state.warnings.length > 0
370
+ ? h('div', { key: 'note', className: 'ddh-note' }, String(state.warnings[state.warnings.length - 1]))
371
+ : null,
372
+ ])
373
+ : null
374
+
375
+ return h(React.Fragment, null, [
376
+ h('button', {
377
+ key: 'strip',
378
+ ref: stripRef,
379
+ type: 'button',
380
+ className: 'ddh-strip',
381
+ 'aria-expanded': open ? 'true' : 'false',
382
+ onClick: () => setOpen((previous) => !previous),
383
+ }, [
384
+ h('span', { key: 'line', className: 'ddh-line' }, [
385
+ h('span', { key: 'icon', className: 'ddh-icon', 'aria-hidden': 'true' }, [
386
+ h('svg', { key: 'svg', width: 14, height: 14, viewBox: '0 0 16 16', fill: 'none' }, [
387
+ h('path', {
388
+ d: 'M8 2v7', stroke: 'currentColor', strokeWidth: '1.6', strokeLinecap: 'round',
389
+ }),
390
+ h('path', {
391
+ d: 'M4.8 6.4 8 9.6l3.2-3.2', stroke: 'currentColor', strokeWidth: '1.6',
392
+ strokeLinecap: 'round', strokeLinejoin: 'round',
393
+ }),
394
+ h('path', {
395
+ d: 'M3 12.5h10', stroke: 'currentColor', strokeWidth: '1.6', strokeLinecap: 'round',
396
+ }),
397
+ ]),
398
+ ]),
399
+ h('span', { key: 'meta', className: 'ddh-meta' }, parts.join(' · ')),
400
+ h('span', { key: 'chevron', className: chevronClass, 'aria-hidden': 'true' }, [
401
+ h('svg', { key: 'svg', width: 12, height: 12, viewBox: '0 0 16 16', fill: 'none' }, [
402
+ h('path', {
403
+ d: 'M4 6.5 8 10.5l4-4', stroke: 'currentColor', strokeWidth: '1.5',
404
+ strokeLinecap: 'round', strokeLinejoin: 'round',
405
+ }),
406
+ ]),
407
+ ]),
408
+ ]),
409
+ typeof percent === 'number'
410
+ ? h('span', {
411
+ key: 'track',
412
+ className: trackClass,
413
+ style: { width: `${barWidth}%` },
414
+ })
415
+ : null,
416
+ ]),
417
+ panel,
418
+ ])
419
+ }
420
+ }
421
+
422
+ return {
423
+ inject: ['slots', 'locale'],
424
+ apply(ctx) {
425
+ const text = TEXT[pickLocale(ctx)]
426
+ const Component = createDownloadHud(ctx, text)
427
+ ctx.effect(() => {
428
+ const tag = document.createElement('style')
429
+ tag.dataset.plugin = 'dsh-transfer-hud'
430
+ tag.dataset.pluginCss = 'dsh-transfer-hud/hud.css'
431
+ tag.textContent = CSS
432
+ document.head.appendChild(tag)
433
+ return () => {
434
+ if (tag.parentNode) tag.parentNode.removeChild(tag)
435
+ }
436
+ })
437
+ ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({
438
+ name: 'conversation.composer.dock',
439
+ id: 'dsh-download-hud',
440
+ order: 20,
441
+ }, Component))
442
+ },
443
+ }
444
+ },
445
+ })
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: dsh-download-hud
3
+ name: 'dsh-transfer-hud'
package/icon.svg ADDED
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
2
+ <rect x="5" y="5" width="54" height="54" rx="13" fill="#1f6feb" opacity="0.12"/>
3
+ <path d="M32 15.5V36" stroke="#1f6feb" stroke-width="5.5" stroke-linecap="round"/>
4
+ <path d="M21.5 27.5 32 38l10.5-10.5" fill="none" stroke="#1f6feb" stroke-width="5.5" stroke-linecap="round" stroke-linejoin="round"/>
5
+ <path d="M18 46.5h28" stroke="#1f6feb" stroke-width="5.5" stroke-linecap="round"/>
6
+ </svg>