dsh-in-hand 1.7.6 → 1.7.7

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 CHANGED
@@ -252,6 +252,20 @@ PDF / ZIP / MP4)。这是**按内容识别**,不是"按内容放行" ——
252
252
  `scripts/check-mobile.mjs` 会在 390 / 360 两个宽度下跑 `window.twSelfCheck()`、切会话
253
253
  残留断言与左基线断言。
254
254
 
255
+ ### v1.7.7:坏图不再留空框、图标不再依赖字体
256
+
257
+ 图片加载失败(宿主文件路由只服务 `~/.dsh/attachments` / `~/.dsh/outbox`,外面的路径
258
+ 是 403;外域 URL 也可能不可达)时,v1.7.6 会铺一张 63px 的整宽 ERR 卡片,视觉上仍是一块
259
+ 把正文截断的占位框。v1.7.7 换成 36px 单行提示条,并且页面级保证 DOM 里**不留破图 `img`**:
260
+
261
+ | 改前(63px ERR 占位卡) | 改后(36px 提示条,可点开原图) |
262
+ | --- | --- |
263
+ | ![改前](docs/screenshots/v1.7.7-broken-before.png) | ![改后](docs/screenshots/v1.7.7-broken-after.png) |
264
+
265
+ 会话抽屉里「子代理 N」入口的图标改成内联 SVG(不再用字符,真机上曾出现「廿」这种回退字形):
266
+
267
+ ![子代理入口图标](docs/screenshots/v1.7.7-subagents-icon.png)
268
+
255
269
  ### 页面为什么不做整页重建
256
270
 
257
271
  页面每 3 秒轮询一次,但**数据没变就完全不碰 DOM**,只刷新右上角时间戳。首版是
package/lib/page.html CHANGED
@@ -1078,11 +1078,32 @@ html,body{overflow-x:clip}
1078
1078
  手机上「看结果」主要就是看图、看文档:图铺满气泡宽度、点开能放大;
1079
1079
  文档没法内嵌预览,至少把名字 / 大小 / 类型说清楚,再给一个能点开的入口。
1080
1080
  点击目标都不小于 44px —— 这是手机页既定的底线。 */
1081
- .md-imgwrap{display:block;width:100%;max-width:100%;padding:0;margin:var(--sp-2) 0;border:0;
1082
- min-height:44px;background:none;text-align:left;border-radius:var(--dsw-radius-md);overflow:hidden}
1083
- .md-img{display:block;width:100%;height:auto;border:1px solid var(--line);
1081
+ /* v1.7.7:图片盒子的三条硬约束 ——
1082
+ ① 宽度永远不超过气泡(max-width + height:auto,横图竖图都不撑宽);
1083
+ ② min-height:0:没拿到 intrinsic size 之前**不占位撑高**,加载完再长出来,
1084
+ 避免"先铺一块 44px 空气、再跳一下"的布局抖动;
1085
+ ③ 圆角与边框在图片盒子上,加载失败换成提示条时形状/位置不变。 */
1086
+ .md-imgwrap{display:block;width:100%;max-width:100%;min-height:0;padding:0;margin:var(--sp-2) 0;border:0;
1087
+ background:none;text-align:left;border-radius:var(--dsw-radius-md);overflow:hidden}
1088
+ .md-img{display:block;width:100%;max-width:100%;height:auto;min-height:0;border:1px solid var(--line);
1084
1089
  border-radius:var(--dsw-radius-md);background:var(--panel2)}
1085
1090
  .md-imgwrap:active .md-img{opacity:.86}
1091
+ /* v1.7.7:图片加载失败 → 一条 36px 的提示条(≤44px),可点打开原图 / 重试。
1092
+ 绝不再留一个破图空框把正文截成两半;文字单行 ellipsis,不换行、不撑宽。 */
1093
+ .imgerr{display:flex;align-items:center;gap:var(--sp-2);width:100%;max-width:100%;min-width:0;
1094
+ height:36px;max-height:44px;margin:var(--sp-2) 0;padding:0 var(--sp-3);
1095
+ border:1px solid var(--line);border-radius:var(--dsw-radius-md);background:var(--panel2);
1096
+ color:var(--dim);font-size:var(--fs-2);line-height:1;text-align:left;text-decoration:none;overflow:hidden}
1097
+ .imgerr svg{width:16px;height:16px;flex:0 0 auto;fill:none;stroke:currentColor;
1098
+ stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
1099
+ .imgerr .t{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1100
+ .imgerr:active{background:var(--panel);color:var(--fg)}
1101
+ /* 抽屉里的字符图标(+ / ›)现在都是内联 SVG:不再依赖字体里有没有那个字形。 */
1102
+ .pill .pico{width:16px;height:16px;flex:0 0 auto;color:var(--dim);fill:none;stroke:currentColor;
1103
+ stroke-width:1.9;stroke-linecap:round;stroke-linejoin:round}
1104
+ .pill .pchev{width:12px;height:12px;flex:0 0 auto;fill:none;stroke:currentColor;
1105
+ stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
1106
+ .pill .hint{display:flex;align-items:center;gap:4px}
1086
1107
  .filecard{display:flex;align-items:flex-start;gap:var(--sp-3);width:100%;min-width:0;min-height:52px;margin:var(--sp-2) 0;
1087
1108
  padding:var(--sp-2) var(--sp-3);border:1px solid var(--line);border-radius:var(--dsw-radius-md);
1088
1109
  background:var(--panel2);color:var(--fg);text-align:left;text-decoration:none}
@@ -2098,6 +2119,74 @@ function probePath(p){
2098
2119
  } catch (e) { /* 忽略 */ }
2099
2120
  return holder
2100
2121
  }
2122
+ /** 破图提示条里的图标。走 createElementNS —— markdown 渲染段内不拼 HTML 字符串
2123
+ * (契约测试会扫这一段),所以这里也不破例。 */
2124
+ function brokenImgIconNode(){
2125
+ var NS = 'http://www.w3.org/2000/svg'
2126
+ var svg = document.createElementNS(NS, 'svg')
2127
+ svg.setAttribute('viewBox', '0 0 24 24')
2128
+ svg.setAttribute('aria-hidden', 'true')
2129
+ // 相框 + 山形 + 一道斜杠:一眼读作"图裂了",不依赖任何字体字形。
2130
+ var ds = ['M4 5h16v14H4z', 'M4 16.5l4.5-4.5 3.5 3.5 3-3 4.5 4.5', 'M3.5 3.5l17 17']
2131
+ for (var i = 0; i < ds.length; i++) {
2132
+ var p = document.createElementNS(NS, 'path')
2133
+ p.setAttribute('d', ds[i])
2134
+ svg.appendChild(p)
2135
+ }
2136
+ return svg
2137
+ }
2138
+
2139
+ /** 图能不能"打开原图":只有同源路由 / http(s) 才给 <a>;data: / blob: 只能就地重试。 */
2140
+ function canReopenImage(url){
2141
+ var s = String(url || '')
2142
+ return /^https?:\/\//i.test(s) || s.charAt(0) === '/'
2143
+ }
2144
+
2145
+ /** 图片加载失败的兜底 UI(v1.7.7)。
2146
+ *
2147
+ * 以前这里换成一张整宽的 .filecard(63px、两行说明):视觉上仍是一块"占位框",
2148
+ * 把一段话从中间截断。现在统一成一条 **36px 单行提示条**:
2149
+ * · 能打开原地址的给 <a target=_blank>("图片未能加载 · 打开原图");
2150
+ * · data: / blob: 这类重开没意义的给 <button>,点一下就地重新加载。
2151
+ * 高度写死在 CSS(36px / max 44px),文字单行 ellipsis,所以不会再撑高正文。
2152
+ */
2153
+ function brokenImgBar(url, alt){
2154
+ var reopen = canReopenImage(url)
2155
+ var el = document.createElement(reopen ? 'a' : 'button')
2156
+ el.className = 'imgerr'
2157
+ if (reopen) {
2158
+ el.href = url
2159
+ el.target = '_blank'
2160
+ el.rel = 'noopener noreferrer'
2161
+ } else {
2162
+ el.type = 'button'
2163
+ el.addEventListener('click', function(){
2164
+ if (!el.parentNode) return
2165
+ var fresh = imgNode(url, alt)
2166
+ el.parentNode.replaceChild(fresh, el)
2167
+ })
2168
+ }
2169
+ el.setAttribute('aria-label', (alt ? alt + ':' : '') + '图片未能加载,' + (reopen ? '点一下打开原图' : '点一下重试'))
2170
+ el.title = (alt ? alt + ' · ' : '') + (url || '')
2171
+ el.appendChild(brokenImgIconNode())
2172
+ var t = document.createElement('span')
2173
+ t.className = 't'
2174
+ t.textContent = '图片未能加载 · ' + (reopen ? '打开原图' : '点一下重试')
2175
+ el.appendChild(t)
2176
+ return el
2177
+ }
2178
+
2179
+ /** 把失败的 <img>(连同它的 .md-imgwrap)换成提示条。幂等:已被换掉就什么都不做。 */
2180
+ function swapBrokenImg(img, url, alt){
2181
+ if (!img || !img.parentNode) return false
2182
+ var wrap = img.closest ? img.closest('.md-imgwrap') : null
2183
+ if (wrap && !wrap.parentNode) return false // 已经换过一次了,别再动
2184
+ var target = wrap || img
2185
+ if (!target.parentNode) return false
2186
+ target.parentNode.replaceChild(brokenImgBar(url, alt), target)
2187
+ return true
2188
+ }
2189
+
2101
2190
  function imgNode(url, alt){
2102
2191
  var wrap = document.createElement('button')
2103
2192
  wrap.type = 'button'
@@ -2108,13 +2197,17 @@ function imgNode(url, alt){
2108
2197
  img.loading = 'lazy'
2109
2198
  img.decoding = 'async'
2110
2199
  img.alt = alt || ''
2200
+ // 事件必须**先挂再赋 src**:同一条坏 URL 第二次出现时(浏览器已经记住这次失败),
2201
+ // error 可能在赋值那一刻就排队,挂晚了就再也收不到,页面上只留一个破图空框。
2202
+ img.addEventListener('error', function(){ swapBrokenImg(img, url, alt) })
2111
2203
  img.src = url
2112
- img.addEventListener('error', function(){
2113
- if (!wrap.parentNode) return
2114
- wrap.parentNode.replaceChild(brokenFileNode(alt || '图片'), wrap)
2115
- })
2116
2204
  wrap.appendChild(img)
2117
2205
  wrap.addEventListener('click', function(){ openLightbox(url, alt) })
2206
+ // 兜底二:命中"失败缓存"时 complete 会立刻为 true 且 naturalWidth 还是 0,
2207
+ // 而那一次 error 可能已经错过 —— 追加进 DOM 之后补一刀(此时 wrap 已有父节点)。
2208
+ setTimeout(function(){
2209
+ if (img.complete && !img.naturalWidth) swapBrokenImg(img, url, alt)
2210
+ }, 0)
2118
2211
  return wrap
2119
2212
  }
2120
2213
  function fileCard(url, label, kind){
@@ -3009,6 +3102,14 @@ var ICON_CTX = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h12M4
3009
3102
  var ICON_DOWN = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M6 13l6 6 6-6"/></svg>'
3010
3103
  var ICON_UP = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 19V5M6 11l6-6 6 6"/></svg>'
3011
3104
  var ICON_CLOSE = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>'
3105
+ // v1.7.7:抽屉(会话列表)里原来用字符当图标的两处 —— `+ 开一个新会话` 与
3106
+ // `工具轨迹 ›` 这类行尾箭头。真机上曾把某个字符渲染成「廿」这种错误字形
3107
+ // (回退字体里恰好有这个码位),所以连同新加的「子代理」入口图标一起换成 SVG:
3108
+ // 只依赖 path,不依赖设备上装了哪个字体。class 由 CSS 定尺寸(16 / 12px)。
3109
+ var ICON_USERS = '<svg class="pico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="9" cy="8" r="3.2"/>' +
3110
+ '<path d="M3.4 19c.5-3.2 2.8-5 5.6-5s5.1 1.8 5.6 5"/><path d="M16 5.4a3 3 0 0 1 0 5.6M17.2 14.4c2 .5 3.2 2.1 3.5 4.6"/></svg>'
3111
+ var ICON_PLUS = '<svg class="pico" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>'
3112
+ var ICON_GO = '<svg class="pchev" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>'
3012
3113
  function syncSend(){
3013
3114
  var b = $('send')
3014
3115
  // 只读的子代理视图:发送键永久禁用(宿主不接受往子代理会话里写 prompt)。
@@ -3705,6 +3806,12 @@ function renderAttachmentTray(){
3705
3806
  var chip = document.createElement('div'); chip.className = 'attach-chip' + (a.status === 'error' ? ' bad' : '')
3706
3807
  if (a.kind === 'image') {
3707
3808
  var thumb = document.createElement('img'); thumb.className = 'thumb'; thumb.src = a.previewURL; thumb.alt = ''
3809
+ // 预览图(blob:)也可能失败:删掉它,留格式徽标占位,绝不在 32px 的框里显示破图标。
3810
+ thumb.addEventListener('error', function(){
3811
+ if (!thumb.parentNode) return
3812
+ var badge = document.createElement('span'); badge.className = 'kind'; badge.textContent = 'IMG'
3813
+ thumb.parentNode.replaceChild(badge, thumb)
3814
+ })
3708
3815
  chip.appendChild(thumb)
3709
3816
  } else {
3710
3817
  var kind = document.createElement('span'); kind.className = 'kind'; kind.textContent = kindLabel(a.name)
@@ -3877,19 +3984,20 @@ async function openSessions(){
3877
3984
  h += '<button class="pill" data-act="chatonly"><span class="label">只看对话</span><span class="hint" id="chatOnlyHint">' +
3878
3985
  (S.chatOnly ? '已隐藏工具步骤' : '显示工具步骤') + '</span></button>'
3879
3986
  h += '<div id="sessionList"></div><div class="workbench-nav"><h3>工作台</h3>' +
3880
- '<button class="pill navpill" data-act="workspace"><span class="label">工作区</span><span class="hint">新建时选择 ›</span></button>' +
3881
- '<button class="pill navpill" data-act="plugins"><span class="label">插件</span><span class="hint">工具轨迹 ›</span></button>' +
3882
- '<button class="pill navpill" data-act="automation"><span class="label">自动化</span><span class="hint">后台任务 ›</span></button></div><h3>操作</h3>'
3987
+ '<button class="pill navpill" data-act="workspace"><span class="label">工作区</span><span class="hint">新建时选择' + ICON_GO + '</span></button>' +
3988
+ '<button class="pill navpill" data-act="plugins"><span class="label">插件</span><span class="hint">工具轨迹' + ICON_GO + '</span></button>' +
3989
+ '<button class="pill navpill" data-act="automation"><span class="label">自动化</span><span class="hint">后台任务' + ICON_GO + '</span></button></div><h3>操作</h3>'
3883
3990
  // 子代理不混进上面的会话列表:这里给唯一的入口,点开是二级页。
3884
3991
  if (agents.length) {
3885
- h += '<button class="pill" data-act="subagents"><span class="label">子代理 ' + agents.length +
3886
- '</span><span class="hint">单独查看 ›</span></button>'
3992
+ h += '<button class="pill" data-act="subagents">' + ICON_USERS +
3993
+ '<span class="label">子代理 ' + agents.length +
3994
+ '</span><span class="hint">单独查看' + ICON_GO + '</span></button>'
3887
3995
  }
3888
- h += '<button class="pill" data-act="new">+ 开一个新会话</button>'
3889
- h += '<button class="pill" data-act="poker">实验功能:德州扑克</button>'
3996
+ h += '<button class="pill" data-act="new">' + ICON_PLUS + '<span class="label">开一个新会话</span></button>'
3997
+ h += '<button class="pill" data-act="poker"><span class="label">实验功能:德州扑克</span></button>'
3890
3998
  h += '<button class="pill" data-act="theme"><span class="label">' +
3891
3999
  (document.documentElement.getAttribute('data-theme') === 'dark' ? '切换到亮色' : '切换到暗色') +
3892
- '</span><span class="hint">外观 ›</span></button>'
4000
+ '</span><span class="hint">外观' + ICON_GO + '</span></button>'
3893
4001
  openSheet(h)
3894
4002
  bindSheetClose()
3895
4003
  function paintSessions(query){
@@ -5198,6 +5306,12 @@ window.fetch = function (u, o) {
5198
5306
  if (!w) return
5199
5307
  w.classList.remove('loading')
5200
5308
  w.classList.add('done')
5309
+ // v1.7.7 兜底三:任何一条路径(包括以后新加的渲染代码)插进来的图,
5310
+ // 只要 error 之后 naturalWidth 还是 0,就在这里统一换成提示条。
5311
+ // 这样"页面上不留破图 img"是页面级的不变量,而不是某个函数的自觉。
5312
+ if (e.type === 'error' && !im.naturalWidth) {
5313
+ swapBrokenImg(im, im.getAttribute('src') || '', im.alt || '')
5314
+ }
5201
5315
  }
5202
5316
  doc.addEventListener('load', settle, true)
5203
5317
  doc.addEventListener('error', settle, true)
package/lib/sw.js CHANGED
@@ -44,7 +44,9 @@ const SHELL = ['/taskwatch', '/taskwatch/icon-192.png', '/taskwatch/icon-512.png
44
44
  // 那次重载是 cache-first 时代的补丁(旧外壳永远不会自己变新)。现在页面已经是
45
45
  // **网络优先**(见下面 fetch 里的注释),每次导航本来就取新版,这个补丁既没必要、
46
46
  // 又足以把整站打死。别再加回来。
47
- const CACHE = 'taskwatch-v16'
47
+ // v17(v1.7.7):页面换了正文图片的兜底渲染(坏图 → 44px 以内提示条)与抽屉图标
48
+ // (字符字形 → 内联 SVG)。外壳里的 icon-192 也是预缓存的,一起升号清掉旧壳。
49
+ const CACHE = 'taskwatch-v17'
48
50
 
49
51
  const JSON_HEADERS = { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
50
52
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-in-hand",
3
- "version": "1.7.6",
3
+ "version": "1.7.7",
4
4
  "description": "掌上 DSH:DeepSeek Harness 的手机端,不用装 App——会话、实时对话、交付文件、后台任务与待批准。 / DSH in hand: the phone client for DeepSeek Harness with no app to install.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",