Repository navigation
Releases: dingchenhui0618-arch/dsh-in-hand
Release list
dsh-in-hand v1.8.18
dsh-in-hand v1.8.18
为什么改(2026-11-02)
原话:「将左上角三横线点击以后 工作台和操作页面往上提 关闭实验功能德州的内容剔除掉」。
上一版的会话抽屉从上往下是:标题 → 搜索 → 字号 → 会话列表 → 工作台 → 操作 → 主题。
列表占了上半屏,想开个新会话、切入口、看设备,每次都要先滑过整段会话列表 ——
这些入口是"打开抽屉就要做的事",却被放在最难够到的位置。
新的自上而下
- 标题「会话」(带会话数)
- 工作台:工作区 / 插件 / 自动化
- 操作:子代理 / 开一个新会话 / 设备 / 入口 · 当前入口 / 通知 / 并行视图 / 主题
- 搜索框
- 字号三档 + 「只看对话」
- 会话列表(运行中 / 最近)
只挪了字符串拼接顺序:data-act 的含义、事件的委托方式、所有 CSS 类名都没动。
搜索框与字号仍紧贴列表(它们作用于列表)。整块「工作台 / 操作」用一层 3% 前景色的
分组底色 + 上下发丝线和下面的「筛选 + 列表」分开 —— 前置之后动作区有 10 个入口,
给它一个明确的区块边界,读起来是一组入口而不是压住列表的一堵墙;分组标题仍是原来的
16px 上 / 8px 下节奏,明暗两套下都成立(暗色下用同一份 color-mix 规则,不另写一套)。
移除「实验功能:德州扑克」
- 抽屉里那一行
<button data-act="poker">整行删除,[data-act="poker"]在
面板 DOM 里已经不存在(scripts/check-sheet.mjs按选择器数量断言)。 act === 'poker'的分支保留(已不可达),#poker这个牌桌节点和pk*那段
旧 JS 也保留:页面加载时会无条件执行$('poker').innerHTML = …,删掉节点会
在启动时直接抛错。真要清干净,得连牌桌 DOM、样式与pk*一起删,那是另一件事。
验收
npm run check全绿(退出码 0)。新增scripts/check-sheet.mjs(已并入
npm run check):真 Chrome + CDP、DPR2、390/360 两档下断言
「工作台」「操作」两个标题都排在#sessionList之前、面板里不存在
[data-act="poker"]、搜索框仍在列表之前、字号在搜索框之后且列表之前、
点「通知」仍进通知页。lib/sw.js的CACHE升到taskwatch-v33(抽屉结构在外壳里,不升号已装的
PWA 会继续跑旧顺序)。- 截图:
_ui-review/round23/sheet-new.png、sheet-new-dark.png、sheet-390.png
(npm run shots:sheet)。
Install: dsh plugin --profile web add dsh-in-hand
Full changelog: https://github.com/dingchenhui0618-arch/dsh-in-hand/blob/main/CHANGELOG.md
dsh-in-hand v1.8.17
dsh-in-hand v1.8.17
为什么改(2026-10-30)
v1.8.15 起「有事情等你」能推到飞书,v1.8.16 能推到微信,但两条路都要经过别人的服务器:
群里多一条、微信里多一条,还都要一个 webhook 或 SendKey。手机自己就能做的事,没必要绕。
这一版用 Web Push:浏览器把订阅交给它自己的推送服务(Chrome 的 FCM、Safari 的 APNs),
由系统把通知递到锁屏。插件只存订阅、签 VAPID、加密正文 —— 不新增任何第三方账号,
不产生费用,运行时依赖仍然为零(dependencies 依旧缺省)。
加密与签名
lib/webpush.js(新文件):RFC 8291 的aes128gcm正文加密 + RFC 8292 的 VAPID,
只用node:crypto(ECDH P-256 + HKDF-SHA256 + AES-128-GCM)。记录头为
salt(16) | rs(4)=4096 | idlen(1)=65 | keyid(65),正文超过 4079 字节自动拆成多条记录。- 与 RFC 8291 附录 A 的测试向量逐字节一致:
ecdh_secret/PRK_key/IKM/
PRK/CEK/NONCE与整条正文全部比对,所以「能解开」不是自己解自己。 - VAPID 密钥在
~/.dsh/taskwatch-vapid.json(仅当前用户可读,首次需要时才生成,
没开过系统通知的机器上不会出现这个文件)。JWT 用 ES256(dsaEncoding:'ieee-p1363'),
claims 是{aud: endpoint 的 origin, exp: now+12h, sub: 仓库地址};请求头
Authorization: vapid t=<jwt>,k=<公钥>,TTL: 300。 - 订阅存
~/.dsh/taskwatch-push.json(仅当前用户可读):{id, deviceId, endpoint, keys:{p256dh,auth}, origin, uaShort, createdAt, lastOkAt}。deviceId是设备账本里的
12 位摘要(twdcookie 派生),不是 cookie 原值;上限 20 条、单设备 5 条。 - endpoint 只收
https://(本机 / 内网的http://留给自测);推送服务回 404 / 410
就删掉这条订阅,其余失败只留一行push failed(不含地址)。
路由
POST /taskwatch/push/subscribe、POST /taskwatch/push/unsubscribe(带 id 删一条,
不带就删这台设备的全部)、POST /taskwatch/push/test、GET /taskwatch/push/state。
四条都在设备闸门内,响应只出 maskPushEndpoint() 的掩码(域名 + 尾部 6 字符);
完整 endpoint 与 keys 不进任何响应,也不进审计和日志。
通知与设置页
- 标题
N 件事等你,正文沿用既有摘要口径(工具名 + 说明前 30 字,多条列 3 条),
tag固定taskwatch-pending(同一批不叠成一列),点通知回订阅时的origin + #home。 - SW 新增
push→showNotification、notificationclick→ 已有窗口 focus 并导航、
没有才openWindow;CACHE升到taskwatch-v32—— 装到桌面的 PWA 用的是旧 SW,
不升号它根本不会响应 push。 - 设置页新增「系统通知」区块:状态(不支持 / 未开启 / 已开启 N 台设备)、
「开启系统通知」「发一条测试通知」「关闭此设备」。iOS 非主屏幕打开时只给
「分享 → 添加到主屏幕 → 从主屏幕打开」三步,不显示开启按钮(iOS 16.4+ 才支持);
权限被拒时给恢复路径,不再调requestPermission。
测试
scripts/check-webpush.mjs(新,13 组):RFC 向量、记录头结构、独立解密器往返
(空 / 短 / 中文 / 4096 / 4097 字节)、本地假推送服务断言 Authorization / TTL /
Content-Encoding 并解密正文、endpoint 校验、410 删订阅、文件权限与 20 / 5 上限、
三件合并成一条、失败文案不含地址、页面与 SW 静态契约、不新增依赖。npm run check 全绿。
未验证:真实手机(安卓 / iOS)收到通知,以及真实 FCM / APNs 的互通。这两条只能在
真机上点一次「发一条测试通知」才算走通。
Install: dsh plugin --profile web add dsh-in-hand
Full changelog: https://github.com/dingchenhui0618-arch/dsh-in-hand/blob/main/CHANGELOG.md
dsh-in-hand v1.8.16
dsh-in-hand v1.8.16
为什么改(2026-10-30)
v1.8.15 的通知主通道是飞书,而「有事情等你」最常发生在手机上。个人微信没有官方 API,
所以这一版把 Server酱 的公众号消息接成一条正经通道:用户只需要一个 SendKey。飞书
通道原样保留,行为不改(这次只加一种通道类型)。
通道与请求形状
- 新类型
serverchan:用户只粘贴 SendKey(^[A-Za-z0-9_-]{10,64}$,不假设 32 位固定
长度),内部拼https://sctapi.ftqq.com/<SENDKEY>.send,POST
application/x-www-form-urlencoded,只有title与desp两个字段。 title与飞书标题同口径(1 件事等你/3 件事等你,测试消息取首行冒号前那段),
超过 32 字自己截断并补…—— 微信通知栏会截标题,不能交给它。desp仍是摘要:工具名 + 原因前 30 字,多条只列前 3 条 + 「还有 N 件」;不含完整
命令、路径与会话标题以外的内容。- 超时 8 秒、最多 3 次(退避 1s / 3s);失败审计只写一行
notify failed,不含 key。 - 只存 SendKey,不存完整 URL;对外只出
SCT…abcd(首 3 + 末 4)。配置里若出现
type:'serverchan'+url,一律忽略 URL、只认key;只有 URL 没有 key 的条目直接丢弃。
设置页
- 类型从两项变三项:
飞书机器人/Server酱(推到微信)/通用 webhook。 - 选 Server酱 时输入框 label 改「SendKey」,下方一行小字给扫码入口
(在 sct.ftqq.com 微信扫码登录后可复制 SendKey)与「摘要会经过 Server酱」的克制说明,
不弹窗、不用红字。 - 列表徽标「Server酱」、掩码 SendKey、启用 / 关闭 / 测试 / 删除,与飞书一致;
「会收到什么」补一行「Server酱 会在微信里以公众号消息形式出现」。 - 页脚写清:个人微信没有官方 API,这条走 Server酱 的公众号消息;要完全私有可走企业微信
应用消息(一句提及,不展开)。
测试
scripts/check-notify.mjs 追加 5 组:请求路径 /<key>.send + form + 只有 title/desp +
标题 32 字截断;SendKey 校验(abc / SCT123 被拒,SCT + 12 位通过);页面与接口都不
出现完整 SendKey;配置里忽略 url 只认 key;关闭后零请求。npm run check 全绿。
未验证:真实 Server酱 发送(仓库里没有真实 SendKey)。用户自己粘贴 SendKey 后点
「发一条测试消息」才算这条路真正走通。
Install: dsh plugin --profile web add dsh-in-hand
Full changelog: https://github.com/dingchenhui0618-arch/dsh-in-hand/blob/main/CHANGELOG.md
dsh-in-hand v1.8.15
dsh-in-hand v1.8.15
为什么改(2026-10-30)
手机页要你主动看。开会、走路、睡前,你不打开就不知道有东西卡在那儿 —— 而
approval/request 与 user-questions/request 是有超时的,等看到时可能已经退回桌面。
这一版让宿主在有事情等你时往外推一条:飞书群机器人优先,另外收任意 JSON / 表单
webhook(Server 酱这一类)。推送是提醒,不是第二套审批入口:点开还是回到手机页处理。
触发与去重
- 两个闸的
onPending各多一次notifyPoke();onRelease也 poke 一次,用来在
「等待清空」时重新武装。 - 1.2 秒合并窗口(
notify.debounceMs,200–10000 钳制):窗口内多次 poke 只发一条,
同时出现三件就是3 件事等你,不会连发三条。 - 一次等待期间只推一条:
armed=false后不再排定时器,直到待办清空才复位。切换开关 /
删除通道只 poke 不重新武装(避免反复刷屏);新增或启用通道会refresh()重新武装,
让已经在等的事能送到刚配好的通道。 - 没有启用通道时完全静默:不排定时器、不发请求、不写日志、不写审计。
正文口径
- 单条:
1 件事等你:<工具名> · <说明前 30 字>…;提问写成提问 · <标题>。 - 多条:标题
N 件事等你,只列前 3 条,末尾还有 N 件。 - 不含完整命令、对话正文、目录路径、会话 id —— 用一条带
rm -rf的 reason 正面断言
它不会被带出去(check-notify.mjs第一组)。
通道与发送
channels只存在于~/.dsh/taskwatch-notify.json({version:1, channels:[…]}),
最多 8 条;插件配置里的notify.channels会被明确忽略并 warn —— 地址只能由用户
在手机上粘贴,这一条优先于「配置里也能写 channels」的字面要求。- 写盘后立刻收紧权限:POSIX
0600;Windowsicacls <file> /inheritance:r /grant:r <user>:F(best-effort,失败只 warn,不阻断保存)。 - 发送:超时 8 秒,最多 3 次,退避 1s / 3s;只有 2xx 算成功。失败只写一行审计
{kind:'notify', outcome:'failed', error:'notify failed', status},不含地址。 - 载荷:飞书
{"msg_type":"text","content":{"text":…}};通用通道{title, content}
JSON,或format:'form'时的title+desp表单编码。
设置页(#notify)
- 抽屉新增「通知」入口;整屏页依次是:现在会不会推(肯定句横幅,含「当前不会推送」)→
通道列表(掩码地址、开关、发一条测试消息、两步确认删除)→ 会收到什么 → 添加通道。 - 页面没有任何能展开完整地址的入口:列表只画宿主算好的
masked,响应里也没有url
字段;唯一的完整地址出现在添加时的输入框,保存成功即清空。 - 「发一条测试消息」是真发,结果写「成功 · HTTP 200 · 0.4 秒」或失败原因 / HTTP 码;
失败也是一次200的结果汇报,不是服务器错误。 - 地址只收
https://;http://仅127.0.0.1/10./192.168./172.16–31.;
带 userinfo 的地址直接拒。错误文案固定,不回显地址。
测试与截图
- 新增
scripts/check-notify.mjs(挂进npm run check),七组:真实假 webhook 服务器
收一条审批并断言形状与口径;三件合并成一条;全关时零请求;页面与响应的掩码断言;
公网http://被拒;500 时不抛错、审计写notify failed、回执无 URL;通知文件权限
(Windows 断言icacls调用,POSIX 断言0600)。另含正文 / 校验 / 载荷 / 重试 /
静默 / 去重的纯函数用例,以及「日志里不出现地址」的总闸。 scripts/check-bundle.mjs:/taskwatch/notify归入只读、四条写路由归入非对话写,
对话路由仍是 14 条。scripts/make-demo.mjs:演示页带一个已配置的假飞书通道(只给掩码)和一个关闭的通用
通道,format:'form';npm run demo已重生成。lib/sw.js缓存号taskwatch-v29→v30;不升号时已安装的 PWA 会继续跑没有通知
入口的旧外壳。- 新截图
docs/screenshots/current/notify.png(另有添加表单 / 测试成功 / 暗色三张在
本地评审目录)。
兼容
- 未改
lib/client.js、审批 / 提问失败的语义、票据与设备逻辑;DSH_TASKWATCH_ROOTS
不变;没有新增依赖。 notify.enabled:false关掉整块功能;默认true,但没有通道时行为与之前完全一致。package.json版本 1.8.15,新增shots:notify。
未验证
- 真实飞书群机器人的消息外观(包括手机端换行与「还有 N 件」的显示)—— 没有可用凭据,
只用假服务器校验了请求体形状。 - 真实 Server 酱(form)转发到微信的效果;
title/desp字段按公开文档实现。 - Windows 上
icacls收紧后的实际 ACL:只断言了命令参数,没有在另一台机器上复核。
Install: dsh plugin --profile web add dsh-in-hand
Full changelog: https://github.com/dingchenhui0618-arch/dsh-in-hand/blob/main/CHANGELOG.md
dsh-in-hand v1.8.14
dsh-in-hand v1.8.14
为什么改(2026-10-29)
v1.5.2 之后只打了 tag、没有建 GitHub Release:版本页停在 v1.5.2,安装说明与新版本对不上。
补档本身是二十多次 gh release create,手抄 CHANGELOG 段落容易漏、容易贴错版本,半自动脚本
又容易顺手把没核对过的版本发出去。这一版先交只读准备:一条命令列出「谁还没有 Release、
每个 tag 用哪段正文」,核对完再加两个开关补齐。
补档脚本(scripts/publish-releases.mjs)
node scripts/publish-releases.mjs [--from 1.8.0] [--to 1.8.13] [--apply] [--yes]- 默认 dry-run:只读
git tag与gh release list,逐个 tag 打印create/
skip(已存在)/missing(无 CHANGELOG 段落);每行附带正文大小与生成后的标题行,
正文由scripts/release-notes.mjs --out写到临时目录、读完即删,不重复它的取段逻辑。 --from/--to含端点,按语义化版本比较(不是字符串比较);输出从新到旧。- 浅克隆(CI 默认
depth=1)里本地 tag 不全时,用只读的git ls-remote --tags补齐,
不 fetch、不改.git。 --apply才执行gh release create <tag> --title "dsh-in-hand <tag>" --notes-file <临时文件> --verify-tag;
已有 Release 的 tag 跳过,重复跑不会建第二次。- 三道防误发:默认只读;必须显式
--apply;--apply还必须带--yes且 stdin/stdout
都是交互终端,缺一条就打印提示后退出(退出码 3),一个 Release 都不建。 - 任一条
missing:dry-run 退出码 2;--apply直接中止,且不创建任何 Release。 - 新增
scripts/check-publish-releases.mjs并挂进npm run check:五组断言(默认只读、
假 gh 的 apply、缺--yes/非终端拒绝、区间与语义化比较、缺段落中止)。测试里的所有
gh 调用都指向临时目录里的假 gh,只记录参数,不联网、不碰真实 Release。 docs/RELEASING.md增「补发历史 Release」一节,并写明这一步会对外发布、执行前需要用户同意。
兼容
- 未改
lib/、.github/workflows/*、client.js;scripts/本来就不进 npm 包,
files白名单未变。 package.json版本 1.8.14,scripts 增加release:publish。
未验证
- 真实
gh release create的线上效果:按要求没有执行--apply,等用户点头后再补。 - GitHub Release 页面上的实际排版(本地只验证了正文来源、参数与幂等分支)。
Install: dsh plugin --profile web add dsh-in-hand
Full changelog: https://github.com/dingchenhui0618-arch/dsh-in-hand/blob/main/CHANGELOG.md
dsh-in-hand v1.8.13
dsh-in-hand v1.8.13
为什么改(2026-10-29)
每发一版都要手写 GitHub Release 正文:从 CHANGELOG 复制段落、再补安装命令和 changelog
链接,顺手就会漏一段或贴错版本号。这一版把它交给脚本:Release 正文从 CHANGELOG.md
的那一段直接生成,人只负责把 CHANGELOG 写清楚。
生成器(scripts/release-notes.mjs)
node scripts/release-notes.mjs [--version 1.8.13] [--out file.md] [--check];
不带--version时取package.json的 version,npm run release:notes是同一个入口。- 取段:从
## v1.8.13 — 标题到下一个##级别标题或文件末尾;###子标题属于该段,
代码围栏里的##也不算标题,段落不会被截断。正文原样保留 Markdown 结构,只收首尾空行。 - 输出固定三段:
## dsh-in-hand v1.8.13标题、CHANGELOG 该段正文、---之后的
Install:与Full changelog:两行。包名与仓库地址都从package.json读,不写死。 --out写 UTF-8 无 BOM、LF 行尾的文件;不给就打到 stdout。--check校验该版本段落存在,且标题行的版本号等于package.json;任一不满足退出码 1,
报错里同时点名两边版本(版本不存在时还会列出 CHANGELOG 里现有的几个)。- 新增
scripts/check-release-notes.mjs并挂进npm run check:五组断言 —— 当前版本
能生成、段落逐行不丢且至少 3 个该段关键词、--check的两条失败路径、临时文件里的
###样例不截断、输出文件前 3 字节无 BOM 且不含\r\n。 - 新增
docs/RELEASING.md:发版四步,一段一条命令。
兼容
- 未改
lib/下任何文件,未改.github/workflows/*,未动client.js;生成器只读
CHANGELOG.md与package.json,不参与运行时,files白名单也没变(scripts/
本来就不进 npm 包)。 package.json版本 1.8.13,scripts 增加release:notes。
未验证
- GitHub Release 页面上真实粘贴后的排版(本地只验证了 Markdown 源与输出字节)。
Install: dsh plugin --profile web add dsh-in-hand
Full changelog: https://github.com/dingchenhui0618-arch/dsh-in-hand/blob/main/CHANGELOG.md
dsh-in-hand v1.8.12
dsh-in-hand v1.8.12
为什么改(2026-10-29)
多台机器各有各的 /taskwatch。以前换一台就要重新记地址、重新配对,而认证本身
并不是"一个账号":它是每个源各自的 cookie(tw / twd),浏览器只把 cookie
发给发下它的那个源。所以客户端缺的不是令牌同步,只是一份地址清单 ——
切换 = 跳到另一个源的 /taskwatch,落地时那个源自己会认你。
页面(lib/page.html)
- 抽屉「操作」区新增「入口 · 当前名」一行(地球图标),点开是整屏
#entries
(与#devices/#parallel同一种导航深度与返回手势)。 - 每行:名称(可空,默认用 host)、「当前」标记(与
location.origin比对)、
等宽地址一行;操作是「打开 / 编辑 / 删除」。打开是target="_blank"+
rel="noopener noreferrer"+referrerpolicy="no-referrer",不泄露 referrer。 - 名称就地编辑(不弹第二层);编辑态只让改名字,改地址等于换源,应该删了再加。
- 底部「添加入口」:名称(≤24 字)+ 地址 + 校验错误。错误分四种:空地址、
看不懂的地址、非 http(s)、已在列表里;满 8 条时另有上限文案。 - 当前项只做「左侧 2px 细线 + 4% 淡底」,不是实心强调块;390 宽、明暗两套令牌、
按钮 / 输入框 44px、prefers-reduced-motion下去掉 120ms 底色过渡。
存储与规范化(只存地址)
localStorage['tw:entries']=[{"label":"家里","url":"http://192.168.1.10:3099"}],
只有 label 与 url 两个键;不写 token、cookie、配对码、k=之类的任何东西。- 规范化:去尾斜杠、丢路径 /
?/#、协议与 host 小写、默认端口收干净
(统一取URL.origin);没写协议时按http://补。 - 拒绝:
javascript:/data:/file:/ftp:以及其它一切非 http(s)(白名单
只有两项,不是黑名单);主机名缺失同样拒绝。 - 去重按 origin,同一条只留一份;最多 8 条,超出的在加载时截断、添加入口时明确报错。
- 默认项:首次进入且存储里没有这份清单时,用当前
location.origin生成一条
「本机」。已经存在(哪怕用户把它删成[])就不再自动添加,否则删不干净。
兼容与测试
lib/sw.js的CACHE升到taskwatch-v29(新整屏与抽屉入口都在外壳里);
docs/demo.html已重建。scripts/check-entries.mjs在原有宿主路由契约之后新增 P3 客户端验收
(真 Chrome + CDP,DPR2,390 宽,16 项 / 7 组):默认项与当前标记、尾斜杠与
同源去重、三种非 http(s) 拒绝、第 9 条上限、存储只有 label/url 且整份
localStorage 无 token/cookie/k=、删除与改名同步、390 宽无横向溢出与
44px 命中区、长地址只在行内截断。- 新增
scripts/shoot-entries.mjs(npm run shots:entries):entries.png、
entries-add.png、entries-dark.png三张验收图(另留一张entries-drawer.png
证明抽屉里的当前入口名),存_ui-review/round19/;主图同步到
docs/screenshots/current/entries.png供 README 引用。 - 未改
lib/index.js、lib/client.js,未改DSH_TASKWATCH_ROOTS;
package.json版本 1.8.12。
未验证
- 真机在多入口之间来回切换的体感(切过去后手动返回、再切回来的路径)。
- iOS Safari 对
localStorage的持久性(长期不用之后是否被清)。
Install: dsh plugin --profile web add dsh-in-hand
Full changelog: https://github.com/dingchenhui0618-arch/dsh-in-hand/blob/main/CHANGELOG.md
dsh-in-hand v1.8.11
dsh-in-hand v1.8.11
为什么改(2026-10-29)
插件市场上非中文用户点进来只有一份中文 README。这一版加一份英文 README,
但不逐段翻译:只写清「这是什么、怎么装、安全边界、诚实的限制」,细节仍以中文
README 为准。代码零改动。
内容
- 新增
README.en.md:定位一段(DSH 插件,把桌面会话搬到手机浏览器;明确写不是
云服务、不是原生 App、不上传文件到第三方)+ 安装三步(装插件 → 打开页面 →
可选配公网入口)+ 安全说明 + 5 条诚实的限制。 - 两份 README 顶部互相切换:中文
[English](README.en.md) · 简体中文,英文
English · [简体中文](README.md)。 - 截图只用仓库里已存在的四张:
phone-home.png、gui-taskwatch-entry.png、
current/risk-card.png、current/approval-card.png;没有新增或重拍。 - 测试:
scripts/check-bundle.mjs的 README 图片断言改为同时覆盖两份 README,
并新增「两份 README 互链」与「英文版无中文残留(只允许项目名与语言切换行)」
两条断言。 package.json的files加入README.en.md,版本 1.8.11。
未验证
- 英文表达的地道程度(非母语审阅)。
Install: dsh plugin --profile web add dsh-in-hand
Full changelog: https://github.com/dingchenhui0618-arch/dsh-in-hand/blob/main/CHANGELOG.md
dsh-in-hand v1.8.10
dsh-in-hand v1.8.10
为什么改(2026-10-29)
首页「在跑」只放三条,开四五个会话就要一页页翻;更糟的是失败的那条会被埋在
运行中下面。这一版加一个整屏的并行视图,只回答三个问题:谁在跑、谁卡住、
谁失败了。核心不是多一个页面,而是排序 —— 失败的必须永远在运行中之上。
排序(本次交付的实质)
需要你(待批准 / 待回答) > 失败 > 运行中 > 已完成最近;同组按最后活动时间
倒序。实现只有一处:lib/page.html 的 PAR_GROUP 与 parItems()(先按
组号、再按 lastActivity 排),renderParallel() 按这个数组顺序把行
insertBefore 到正确位置。排序是分组而不是视觉强调:把红/黄都拿掉,
失败仍然排在运行中与空闲前面。
页面(lib/page.html)
- 入口:首页「在跑」右上角「全部」+ 抽屉「并行视图」,整屏路由
#parallel
(与#devices同一种导航深度与返回手势)。 - 每行四个字段:标题(2 行截断)、状态徽标(需要你 / 失败 / 运行中 / 空闲)、
一句最新动作(工具名 · 第几步)、已运行 / 结束时长;失败行多一句 ≤60 字
原因 + 44px 的「查看」。字段就这四个,是刻意取舍:再多一行,390 宽首屏
就放不下 3 条,这个视图又会退化成翻页。 - 子代理:
data.subagents(宿主已有投影,不新开接口)按parent建树,
父会话下折一行「子代理 N」,展开只切hidden,不重建;反复展开不重复渲染。 - 实时:复用现有 5 秒轮询 / SSE。
renderParallel()按会话 id 复用 DOM,只在
文本/状态真的变化时写,行需要换组时搬同一个节点而不是重画;页面隐藏时
轮询与每秒时长计时一起停。 - 空态:「现在没有在跑的会话」+「开一个新会话」(复用现有
openWsPicker)。 - 移动端:390 宽首屏 ≥5 行、无横向溢出、标题 2 行、命中区 ≥44px、明暗两套
全走既有令牌;prefers-reduced-motion下去掉运行脉冲与箭头过渡。
宿主(lib/index.js)
只在 /taskwatch/data 的每个会话上补一个 failure 字段:读最后一条
turn/end 的 reason,error / max-tokens 给 {at, kind, reason},其余
(completed / aborted / interrupted / 还在跑)给 null。读事件有独立缓存
(按 seq 失效、最多回看 40 条),不扩大采集面;审批、问答、票据、设备账本、
文件白名单、上传与 DSH_TASKWATCH_ROOTS 均未改。
兼容与测试
lib/sw.js的CACHE升到taskwatch-v28;docs/demo.html已重建
(演示数据:1 需要你 / 1 失败 / 2 运行 / 1 空闲)。- 新增
scripts/check-parallel.mjs(真 Chrome + CDP,14 项 / 6 组):- 注入 5 条会话,DOM 顺序严格为需要你 → 失败 → 运行中 → 空闲;
- 失败原因可见且 ≤60 字,「查看」≥44px;
- 子代理展开 / 收起,反复展开仍只有 2 条;
- 390 宽首屏 ≥3 行、无横向溢出、标题 2 行、命中区 ≥44px;
- 空态文案与「开一个新会话」按钮;
- 状态更新时其他行仍是原 DOM 节点、childList 变更数为 0。
- 新增
scripts/shoot-parallel.mjs(真 Chrome/CDP、DPR2、390),截图落
_ui-review/round18/,并把主图同步到docs/screenshots/current/parallel.png。
Install: dsh plugin --profile web add dsh-in-hand
Full changelog: https://github.com/dingchenhui0618-arch/dsh-in-hand/blob/main/CHANGELOG.md
dsh-in-hand v1.8.9
dsh-in-hand v1.8.9
为什么改(2026-10-29)
手机上最常要发的是「你过来看一眼」——屏幕上的报错码、白板、包装上的标签。打字描述
比拍一张慢一个数量级,而之前的附件流程要先点附件、再进抽屉、再选「上传图片」,
在单手拿着手机对着屏幕的时候是四步。这一版只在输入框旁加了一个相机键,其余全部
复用现有附件通道:没有新增接口、没有新增存储、没有改后端目录。
页面(lib/page.html)
- 相机键
#camera(aria-label="拍照发送")背后是
<input id="cameraInput" type="file" accept="image/*" capture="environment">。
手机上进系统相机,桌面浏览器忽略capture、退化成选图。不用getUserMedia:
那需要权限弹窗、前后摄像头切换和流的状态机,而这三种失败都不该出现在「发张照片」里。 - 客户端压缩(canvas):长边 >1600 缩到 1600、JPEG 质量 0.82;压缩后 >2.5 MB 时
降到 0.7 重试一次,仍超过就显示「图片太大」并不上传。方向用
createImageBitmap(file, { imageOrientation:'from-image' }),EXIF Orientation 参与
解码,竖拍照片不会横躺;createImageBitmap不可用时退回<img>(浏览器同样按
image-orientation:from-image解码)。 - 选中即见:chip 立刻出现(原图预览 + 大小 + ×),状态依次是「压缩中…」→「上传中…」
→「大小 · 已就绪」;压缩期间取消不发任何请求,上传期间取消会 abort 在途请求。
失败保留缩略图,并按失败阶段给「重试」(压缩失败从原图重压,上传失败重发同一份)。 - 上传与失败语义完全复用
uploadAttachment()/runUpload():同一个
/taskwatch/chat/upload、同样的sessionId/kind/name/mime参数与
content-type: application/octet-stream。发送时可只发图、不带文字。 - 附件与相机两个键均 44×44、明暗两套走既有令牌、无新增动效(
prefers-reduced-motion
无需额外覆盖);390 宽实测输入框仍有 210px,页面不横向溢出。 - 附件抽屉里原来的「上传图片」改名为「选择图片」(
aria-label="选择图片"),
说明里补一句「会先压到长边 1600」。 - SW
CACHE升到taskwatch-v27;docs/demo.html已重建。
兼容与测试
lib/index.js、lib/client.js、文件白名单、票据、设备账本、审批 / 问答语义、
DSH_TASKWATCH_ROOTS均未改;lib/sw.js只改版本号与注释。- 新增
scripts/check-camera.mjs(真 Chrome + CDP,36 项):- DOM 有
input[accept="image/*"][capture="environment"]与相机键; - 注入 3000×2000 的图 → 从上传体 JPEG 的 SOF 断言长边 ≤1600、宽高比 3:2 不变;
- 注入 EXIF Orientation=6 的同一张图 → 输出宽 < 高(1067×1600),不横躺;
- 选中即见缩略图/大小/取消,压缩期间点 × → 上传请求数为 0;
- 宿主 500 → 保留缩略图并出现「重试」,点重试恢复;
- 390 宽 → 输入框 >0、无横向溢出、附件/相机两个键 ≥44px;
- 相机与「附件 → 选择图片」的上传请求路径、方法、Content-Type、参数集完全一致
(图片只多一个mime)。
另外断言页面里只有一个fetch('/taskwatch/chat/upload')、无getUserMedia。
- DOM 有
npm run check全绿;新增scripts/shoot-camera.mjs(npm run shots:camera)
出 3 张真渲染截图(亮色已就绪 / 上传中 / 暗色),高清原图在_ui-review/round17/,
仓库内放在docs/screenshots/current/。
未验证
- 真机:iOS Safari 上
capture的入口形态(是否直接进相机、返回时是否重载页面)、
HEIC 照片的输入(Chrome 解不了 HEIC,会落到「图片处理失败:这张图读不出来」)。 - 弱网:2.5 MB 级图片从「上传中…」到可发送的实际等待体感,只在本地假宿主上测过。
- 桌面端
capture被忽略后的行为只在 Chrome 上验证过,Safari 桌面未测。
Install: dsh plugin --profile web add dsh-in-hand
Full changelog: https://github.com/dingchenhui0618-arch/dsh-in-hand/blob/main/CHANGELOG.md