@bitseek/hermes-webui 0.1.0-beta.0 → 0.1.0-beta.1

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.
Files changed (99) hide show
  1. package/package.json +2 -2
  2. package/vendor/agent-frontend-shell/.bitseek-source.json +2 -2
  3. package/vendor/agent-frontend-shell/CHANGELOG.md +178 -1
  4. package/vendor/agent-frontend-shell/CONTRIBUTORS.md +5 -5
  5. package/vendor/agent-frontend-shell/api/agent_health.py +134 -0
  6. package/vendor/agent-frontend-shell/api/config.py +145 -104
  7. package/vendor/agent-frontend-shell/api/gateway_chat.py +56 -12
  8. package/vendor/agent-frontend-shell/api/helpers.py +4 -2
  9. package/vendor/agent-frontend-shell/api/models.py +202 -20
  10. package/vendor/agent-frontend-shell/api/paths.py +77 -0
  11. package/vendor/agent-frontend-shell/api/plugins.py +185 -0
  12. package/vendor/agent-frontend-shell/api/profiles.py +95 -16
  13. package/vendor/agent-frontend-shell/api/routes.py +831 -30
  14. package/vendor/agent-frontend-shell/api/run_journal.py +1 -0
  15. package/vendor/agent-frontend-shell/api/state_sync.py +5 -4
  16. package/vendor/agent-frontend-shell/api/streaming.py +211 -56
  17. package/vendor/agent-frontend-shell/api/todo_state.py +122 -0
  18. package/vendor/agent-frontend-shell/api/updates.py +30 -3
  19. package/vendor/agent-frontend-shell/api/upload.py +251 -18
  20. package/vendor/agent-frontend-shell/api/workspace.py +323 -65
  21. package/vendor/agent-frontend-shell/bitseek_docs/BitSeek_Claw_Operation_Manual_EN.docx +0 -0
  22. package/vendor/agent-frontend-shell/bitseek_docs/BitSeek_Claw_Operation_Manual_ZH.docx +0 -0
  23. package/vendor/agent-frontend-shell/bitseek_docs/en/00-Installation.md +174 -0
  24. package/vendor/agent-frontend-shell/bitseek_docs/en/01-Overview.md +128 -0
  25. package/vendor/agent-frontend-shell/bitseek_docs/en/02-Page-Operations.md +461 -0
  26. package/vendor/agent-frontend-shell/bitseek_docs/en/README.md +61 -0
  27. package/vendor/agent-frontend-shell/bitseek_docs/en/images/ai-colleagues.png +0 -0
  28. package/vendor/agent-frontend-shell/bitseek_docs/en/images/chat-area.png +0 -0
  29. package/vendor/agent-frontend-shell/bitseek_docs/en/images/kanban.png +0 -0
  30. package/vendor/agent-frontend-shell/bitseek_docs/en/images/main-page.png +0 -0
  31. package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-notes.png +0 -0
  32. package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-overview.png +0 -0
  33. package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-profile.png +0 -0
  34. package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-soul.png +0 -0
  35. package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory.png +0 -0
  36. package/vendor/agent-frontend-shell/bitseek_docs/en/images/navigation-bar.png +0 -0
  37. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-appearance.png +0 -0
  38. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-conversation.png +0 -0
  39. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-overview.png +0 -0
  40. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-plugins.png +0 -0
  41. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-preferences.png +0 -0
  42. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-providers.png +0 -0
  43. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-system.png +0 -0
  44. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings.png +0 -0
  45. package/vendor/agent-frontend-shell/bitseek_docs/en/images/sidebar.png +0 -0
  46. package/vendor/agent-frontend-shell/bitseek_docs/en/images/skills.png +0 -0
  47. package/vendor/agent-frontend-shell/bitseek_docs/en/images/tasks.png +0 -0
  48. package/vendor/agent-frontend-shell/bitseek_docs/en/images/workspace-panel.png +0 -0
  49. package/vendor/agent-frontend-shell/bitseek_docs/md_to_docx.py +351 -0
  50. package/vendor/agent-frontend-shell/bitseek_docs/zh/00-/345/256/211/350/243/205/345/220/257/345/212/250.md +174 -0
  51. package/vendor/agent-frontend-shell/bitseek_docs/zh/01-/346/225/264/344/275/223/346/246/202/350/247/210.md +128 -0
  52. package/vendor/agent-frontend-shell/bitseek_docs/zh/02-/351/241/265/351/235/242/346/223/215/344/275/234.md +463 -0
  53. package/vendor/agent-frontend-shell/bitseek_docs/zh/README.md +61 -0
  54. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/ai-colleagues.png +0 -0
  55. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/chat-area.png +0 -0
  56. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/kanban.png +0 -0
  57. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/main-page.png +0 -0
  58. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-notes.png +0 -0
  59. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-overview.png +0 -0
  60. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-profile.png +0 -0
  61. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-soul.png +0 -0
  62. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory.png +0 -0
  63. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/navigation-bar.png +0 -0
  64. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-appearance.png +0 -0
  65. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-conversation.png +0 -0
  66. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-overview.png +0 -0
  67. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-plugins.png +0 -0
  68. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-preferences.png +0 -0
  69. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-providers.png +0 -0
  70. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-system.png +0 -0
  71. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings.png +0 -0
  72. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/sidebar.png +0 -0
  73. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/skills.png +0 -0
  74. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/tasks.png +0 -0
  75. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/workspace-panel.png +0 -0
  76. package/vendor/agent-frontend-shell/build-release.sh +62 -0
  77. package/vendor/agent-frontend-shell/ctl.sh +1 -0
  78. package/vendor/agent-frontend-shell/docker-compose.local.yml +33 -0
  79. package/vendor/agent-frontend-shell/docker-compose.yml +8 -0
  80. package/vendor/agent-frontend-shell/docker_init.bash +1 -0
  81. package/vendor/agent-frontend-shell/docs/rfcs/hermes-run-adapter-contract.md +74 -15
  82. package/vendor/agent-frontend-shell/extensions/common/index.css +6 -0
  83. package/vendor/agent-frontend-shell/extensions/manifest.json +6 -0
  84. package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.js +60 -14
  85. package/vendor/agent-frontend-shell/readme-simple.md +103 -0
  86. package/vendor/agent-frontend-shell/requirements.txt +5 -0
  87. package/vendor/agent-frontend-shell/server.py +7 -0
  88. package/vendor/agent-frontend-shell/static/boot.js +53 -1
  89. package/vendor/agent-frontend-shell/static/commands.js +20 -10
  90. package/vendor/agent-frontend-shell/static/i18n.js +1142 -1016
  91. package/vendor/agent-frontend-shell/static/index.html +13 -3
  92. package/vendor/agent-frontend-shell/static/messages.js +48 -3
  93. package/vendor/agent-frontend-shell/static/panels.js +199 -30
  94. package/vendor/agent-frontend-shell/static/sessions.js +249 -39
  95. package/vendor/agent-frontend-shell/static/style.css +46 -2
  96. package/vendor/agent-frontend-shell/static/ui.js +323 -79
  97. package/vendor/agent-frontend-shell/static/workspace.js +185 -7
  98. package/vendor/agent-frontend-shell/README-CUSTOM.md +0 -76
  99. package/vendor/agent-frontend-shell/docker-compose.custom.yml +0 -26
@@ -268,12 +268,15 @@ let _messageRenderWindowSize=MESSAGE_RENDER_WINDOW_DEFAULT;
268
268
  // Cached visWithIdx array — invalidated when S.messages.length changes.
269
269
  let _visWithIdxCache=null;
270
270
  let _visWithIdxCacheLen=0;
271
+ function clearVisibleMessageRowCache(){
272
+ _visWithIdxCache=null;
273
+ _visWithIdxCacheLen=0;
274
+ }
271
275
  function _resetMessageRenderWindow(sid){
272
276
  _messageRenderWindowSid=sid||null;
273
277
  _messageRenderWindowSize=MESSAGE_RENDER_WINDOW_DEFAULT;
274
278
  _clearRenderCache();
275
- _visWithIdxCache=null;
276
- _visWithIdxCacheLen=0;
279
+ clearVisibleMessageRowCache();
277
280
  }
278
281
 
279
282
  // ── renderMd / _renderUserFencedBlocks cache ──────────────────────────────
@@ -1060,6 +1063,20 @@ function _findModelInDropdown(modelId, sel, preferredProviderId){
1060
1063
  if(!modelId||!sel) return null;
1061
1064
  const options=Array.from(sel.options);
1062
1065
  const opts=options.map(o=>o.value);
1066
+ // 0. Exact match — highest priority when it doesn't conflict with a
1067
+ // cross-provider preference (#3360, guarded for #1228/#1313).
1068
+ // When all models share the same provider (e.g. a custom proxy),
1069
+ // normalization can collapse distinct multi-slash IDs to the same key
1070
+ // and options.find() returns whichever appears first in the DOM instead
1071
+ // of the exact value. But when the exact option belongs to a *different*
1072
+ // provider than the preferred one, we must fall through to the provider-
1073
+ // aware match so rehydration doesn't snap to the wrong provider row.
1074
+ if(opts.includes(modelId)){
1075
+ const exactOpt=options.find(o=>o.value===modelId);
1076
+ const exactProv=exactOpt?_getOptionProviderId(exactOpt).toLowerCase():'';
1077
+ const pref=String(preferredProviderId||'').toLowerCase();
1078
+ if(!pref || !exactProv || exactProv===pref) return modelId;
1079
+ }
1063
1080
  // 1. Normalize: lowercase, strip namespace prefix, replace hyphens→dots.
1064
1081
  // Also strip @provider: prefix from deduplicated model IDs (#1228, #1313).
1065
1082
  const norm=s=>s.toLowerCase().replace(/^[^/]+\//,'').replace(/^@([^:]+:)+/,'').replace(/-/g,'.');
@@ -1074,8 +1091,7 @@ function _findModelInDropdown(modelId, sel, preferredProviderId){
1074
1091
  const providerMatch=options.find(o=>norm(o.value)===target && _getOptionProviderId(o).toLowerCase()===preferred);
1075
1092
  if(providerMatch) return providerMatch.value;
1076
1093
  }
1077
- // 2. Exact match
1078
- if(opts.includes(modelId)) return modelId;
1094
+ // 2. Normalized match
1079
1095
  const exact=opts.find(o=>norm(o)===target);
1080
1096
  if(exact) return exact;
1081
1097
  // If the request is provider-qualified (either explicit @provider:model or
@@ -1418,7 +1434,26 @@ function _normalizeConfiguredModelKey(modelId){
1418
1434
  // Defensive: trailing-colon / trailing-slash falls back to the original key
1419
1435
  // so malformed configs don't collapse distinct ids to '' (matches backend _norm_model_id).
1420
1436
  if(s.startsWith('@')&&s.includes(':')){const last=s.split(':').pop();s=last||s;}
1421
- if(s.includes('/')){const last=s.split('/').pop();s=last||s;}
1437
+ // Skip slash-based stripping for URI-scheme IDs (e.g. gpt://folder/model)
1438
+ // whose slashes are path separators, not provider delimiters (#3429).
1439
+ const _hasScheme=/^[a-z][a-z0-9+.-]*:\/\//i.test(s);
1440
+ if(!_hasScheme){
1441
+ // Strip provider-qualified prefixes that contain colons before the first
1442
+ // slash (e.g. 'custom:llm-proxy/model' → 'model'). Without this, badge-
1443
+ // key variants like 'custom:llm-proxy/opencode_go/deepseek-v4-pro' and the
1444
+ // bare 'opencode_go/deepseek-v4-pro' produce different normalized keys and
1445
+ // aren't deduped in the configured section (#3360).
1446
+ if(s.includes('/')&&s.indexOf(':')!==-1&&s.indexOf(':')<s.indexOf('/')){
1447
+ s=s.slice(s.indexOf('/')+1)||s;
1448
+ }
1449
+ // Strip only the first slash-segment (provider prefix), preserving any
1450
+ // remaining vendor hierarchy. Using split('/').pop() here previously
1451
+ // discarded ALL segments except the last, collapsing distinct multi-slash
1452
+ // IDs like 'vendor_a/deepseek-v4-pro' and 'vendor_b/deepseek/deepseek-v4-pro'
1453
+ // to the same key, causing badge misattribution and configured-entry
1454
+ // suppression (#3360).
1455
+ if(s.includes('/')) s=s.replace(/^[^/]+\//, '')||s;
1456
+ }
1422
1457
  return s.replace(/-/g,'.');
1423
1458
  }
1424
1459
 
@@ -2197,58 +2232,32 @@ window.addEventListener('resize',function(){
2197
2232
  });
2198
2233
 
2199
2234
  // ── Scroll pinning ──────────────────────────────────────────────────────────
2200
- // When streaming, auto-scroll only if the user hasn't manually scrolled up.
2201
- // Once the user scrolls back to within 250px of the bottom, re-pin.
2202
- // Uses a guard flag to avoid the race where programmatic scrolls (from
2203
- // scrollIfPinned / scrollToBottom) re-set _scrollPinned=true, overriding
2204
- // the user's explicit scroll-up. Fixes #1469 / #1360.
2205
- // Direction-aware unpin (issue #1731): the hysteresis below is correct
2206
- // for re-pinning (entering the near-bottom zone), but applying it to
2207
- // unpinning stranded users who scrolled up by a small amount inside the
2208
- // 250px zone — every upward sample still landed in the near-bottom
2209
- // region, so the counter kept incrementing and _scrollPinned stayed
2210
- // true. The next streaming token snapped them back. We now track
2211
- // scrollTop direction: an explicit upward movement (scrollTop decreased
2212
- // by more than 2px between samples) unpins immediately and resets the
2213
- // counter, while downward / stationary movement falls through the
2214
- // original hysteresis path so the macOS momentum re-pin protection from
2215
- // #1360 is preserved.
2216
- // rAF-debounced scroll listener (issue #1360): on macOS WKWebView, trackpad
2217
- // momentum scrolling fires scroll events that interleave with the
2218
- // _programmaticScroll setTimeout(0) guard. A mid-momentum scroll event can
2219
- // either get swallowed (_programmaticScroll still true) or falsely report
2220
- // the user is at the bottom (momentum hasn't settled). rAF defers the
2221
- // distance check to the next paint frame when the browser's scroll
2222
- // position has settled. A hysteresis counter requires two consecutive
2223
- // near-bottom samples before re-pinning, preventing accidental re-pin
2224
- // during initial deceleration.
2235
+ // When streaming, auto-scroll only while the user is following the live tail.
2236
+ // Any manual scroll up sets a sticky unpinned flag until the user scrolls back
2237
+ // to the bottom (near-bottom hysteresis on downward motion) or clicks ↓.
2238
+ // Programmatic scrolls are ignored via _programmaticScroll. Fixes #1469 / #1360 / #1731.
2225
2239
  let _scrollPinned=true;
2226
2240
  let _programmaticScroll=false;
2227
2241
  let _nearBottomCount=0;
2228
2242
  let _lastScrollTop=null;
2229
- let _lastNonMessageScrollIntentMs=0;
2230
- let _lastMessageUpwardIntentMs=0;
2243
+ // Sticky-unpin model (#3343 supersedes #3330's proximity re-pin): once the user
2244
+ // scrolls up, streaming stops auto-following until they return to the bottom or
2245
+ // click ↓. The upward-intent TIMEOUT mechanism (_lastMessageUpwardIntentMs /
2246
+ // MESSAGE_UPWARD_INTENT_MS) is removed — sticky-unpin makes it unnecessary.
2247
+ // Keep the non-message intent timestamp at -Infinity so load-time isn't read as
2248
+ // intent (the #3330 follow-up fix); 0 would mark the first NON_MESSAGE_SCROLL_INTENT
2249
+ // window after load as suppressed.
2250
+ let _lastNonMessageScrollIntentMs=-Infinity;
2231
2251
  let _messageUserUnpinned=false;
2232
2252
  let _bottomSettleToken=0;
2233
2253
  const NON_MESSAGE_SCROLL_INTENT_SUPPRESS_MS=350;
2234
- const MESSAGE_UPWARD_INTENT_MS=2000;
2235
2254
  function _cancelBottomSettle(){ _bottomSettleToken++; }
2236
2255
  function _recordNonMessageScrollIntent(e){
2237
2256
  const el=document.getElementById('messages');
2238
2257
  const target=e&&e.target;
2239
2258
  if(!el||!target) return;
2240
- // Streaming token renders should keep pinning the chat only while the user is
2241
- // actually interacting with the chat pane. A wheel/touch gesture over the
2242
- // session sidebar (or another independent pane) must not be immediately fought
2243
- // by scrollIfPinned() writing #messages.scrollTop on the next token (#1784).
2244
2259
  if(!el.contains(target)) _lastNonMessageScrollIntentMs=performance.now();
2245
2260
  else if(e.type==='touchmove'||(typeof e.deltaY==='number'&&e.deltaY<0)){
2246
- // User is intentionally moving upward in the transcript. Record the real
2247
- // input event so later scrollTop decreases caused by layout/windowing do
2248
- // not masquerade as user intent and strand live streaming away from bottom.
2249
- _lastMessageUpwardIntentMs=performance.now();
2250
- // User is intentionally moving in the transcript. Cancel any delayed
2251
- // scrollToBottom settling that was scheduled by session-load/layout growth.
2252
2261
  _cancelBottomSettle();
2253
2262
  if(typeof e.deltaY==='number'&&e.deltaY<0){
2254
2263
  _messageUserUnpinned=true;
@@ -2257,9 +2266,6 @@ function _recordNonMessageScrollIntent(e){
2257
2266
  }
2258
2267
  }
2259
2268
  }
2260
- function _recentMessageUpwardIntent(){
2261
- return performance.now()-_lastMessageUpwardIntentMs<MESSAGE_UPWARD_INTENT_MS;
2262
- }
2263
2269
  function _recentNonMessageScrollIntent(){
2264
2270
  return performance.now()-_lastNonMessageScrollIntentMs<NON_MESSAGE_SCROLL_INTENT_SUPPRESS_MS;
2265
2271
  }
@@ -2270,8 +2276,23 @@ if(typeof document!=='undefined'){
2270
2276
  // Reset hook for session-switch — called from sessions.js loadSession() to
2271
2277
  // prevent the new chat's first scroll comparing against the previous chat's
2272
2278
  // scrollTop (Opus stage-302 SHOULD-FIX, #1731 follow-up).
2273
- function _resetScrollDirectionTracker(){ _lastScrollTop=null; }
2274
- if(typeof window!=='undefined') window._resetScrollDirectionTracker=_resetScrollDirectionTracker;
2279
+ function _resetScrollDirectionTracker(){
2280
+ _lastScrollTop=null;
2281
+ _messageUserUnpinned=false;
2282
+ _scrollPinned=true;
2283
+ _nearBottomCount=0;
2284
+ }
2285
+ function _resetStreamScrollFollow(){
2286
+ _messageUserUnpinned=false;
2287
+ _scrollPinned=true;
2288
+ _nearBottomCount=0;
2289
+ _lastScrollTop=null;
2290
+ _cancelBottomSettle();
2291
+ }
2292
+ if(typeof window!=='undefined'){
2293
+ window._resetScrollDirectionTracker=_resetScrollDirectionTracker;
2294
+ window._resetStreamScrollFollow=_resetStreamScrollFollow;
2295
+ }
2275
2296
  /* ── Pull-to-refresh for PWA standalone (Android) ── */
2276
2297
  (function(){
2277
2298
  if(typeof document==='undefined') return;
@@ -2351,17 +2372,32 @@ if(typeof window!=='undefined') window._resetScrollDirectionTracker=_resetScroll
2351
2372
  _scrollRaf=requestAnimationFrame(()=>{
2352
2373
  const top=el.scrollTop;
2353
2374
  const nearBottom=el.scrollHeight-top-el.clientHeight<250;
2354
- // scrollToBottomBtn visibility is updated below after pin state settles.
2355
- const movedUp=_lastScrollTop!==null && top<_lastScrollTop-2 && _recentMessageUpwardIntent();
2375
+ const movedUp=_lastScrollTop!==null&&top<_lastScrollTop-2;
2376
+ const movedDown=_lastScrollTop!==null&&top>_lastScrollTop+2;
2356
2377
  _lastScrollTop=top;
2357
- if(movedUp){ _cancelBottomSettle(); _nearBottomCount=0; _scrollPinned=false; _messageUserUnpinned=true; } // #1731
2358
- else {
2378
+ if(movedUp){
2379
+ _cancelBottomSettle();
2380
+ _nearBottomCount=0;
2381
+ _scrollPinned=false;
2382
+ _messageUserUnpinned=true;
2383
+ }else if(movedDown&&nearBottom){
2384
+ _nearBottomCount=_nearBottomCount+1;
2385
+ if(_nearBottomCount>=2){
2386
+ _scrollPinned=true;
2387
+ _messageUserUnpinned=false;
2388
+ }
2389
+ }else if(!_messageUserUnpinned){
2359
2390
  if(nearBottom){
2360
2391
  _nearBottomCount=_nearBottomCount+1;
2361
2392
  if(_nearBottomCount>=2) _scrollPinned=true;
2362
- } else { _nearBottomCount=0; _scrollPinned=false; }
2363
- if(_scrollPinned) _messageUserUnpinned=false;
2364
- } // #1360
2393
+ }else{
2394
+ _nearBottomCount=0;
2395
+ _scrollPinned=false;
2396
+ }
2397
+ }else if(!nearBottom){
2398
+ _nearBottomCount=0;
2399
+ _scrollPinned=false;
2400
+ }
2365
2401
  const btn=$('scrollToBottomBtn');
2366
2402
  const showBottomButton=!_scrollPinned && el.scrollHeight-top-el.clientHeight>80;
2367
2403
  if(btn) btn.style.display=showBottomButton?'flex':'none';
@@ -2758,13 +2794,34 @@ function _setMessageScrollToBottom(){
2758
2794
  _lastScrollTop=el.scrollTop;
2759
2795
  _nearBottomCount=2;
2760
2796
  _scrollPinned=true;
2761
- requestAnimationFrame(()=>{ setTimeout(()=>{_programmaticScroll=false;},0); });
2797
+ requestAnimationFrame(()=>{
2798
+ // Retry the bottom write on the next layout frame so a DOM rebuild that
2799
+ // grows the transcript after the first write doesn't strand a pinned
2800
+ // conversation mid-scroll (#3319). But by this frame the user may have
2801
+ // scrolled up — under the sticky-unpin model (#3343) _messageUserUnpinned
2802
+ // is the authoritative "user scrolled away" signal, so DON'T snap them back
2803
+ // or re-pin if so; only release the programmatic-scroll latch.
2804
+ if(_messageUserUnpinned || !_scrollPinned || _recentNonMessageScrollIntent()){
2805
+ requestAnimationFrame(()=>{ setTimeout(()=>{_programmaticScroll=false;},0); });
2806
+ return;
2807
+ }
2808
+ el.scrollTop=el.scrollHeight;
2809
+ _lastScrollTop=el.scrollTop;
2810
+ _nearBottomCount=2;
2811
+ _scrollPinned=true;
2812
+ requestAnimationFrame(()=>{ setTimeout(()=>{_programmaticScroll=false;},0); });
2813
+ });
2762
2814
  }
2763
2815
  function _isMessagePaneNearBottom(threshold=250){
2764
2816
  const el=$('messages');
2765
2817
  if(!el) return false;
2766
2818
  return el.scrollHeight-el.scrollTop-el.clientHeight<=threshold;
2767
2819
  }
2820
+ function _messageBottomDistance(){
2821
+ const el=$('messages');
2822
+ if(!el) return 0;
2823
+ return el.scrollHeight-el.scrollTop-el.clientHeight;
2824
+ }
2768
2825
  function _shouldFollowMessagesOnDomReplace(){
2769
2826
  return !_messageUserUnpinned && (_scrollPinned || _isMessagePaneNearBottom(1200));
2770
2827
  }
@@ -2778,21 +2835,23 @@ function _settleMessageScrollToBottom(force){
2778
2835
  const passes=[0,16,80,180];
2779
2836
  passes.forEach(delay=>setTimeout(()=>{
2780
2837
  if(token!==_bottomSettleToken) return;
2781
- if(!force && (!_scrollPinned||_recentNonMessageScrollIntent())) return;
2838
+ if(!force && (!_scrollPinned||_messageUserUnpinned||_recentNonMessageScrollIntent())) return;
2782
2839
  _setMessageScrollToBottom();
2783
2840
  },delay));
2784
2841
  requestAnimationFrame(()=>{
2785
2842
  if(token!==_bottomSettleToken) return;
2786
- if(force || (_scrollPinned&&!_recentNonMessageScrollIntent())) _setMessageScrollToBottom();
2843
+ if(force || (_scrollPinned&&!_messageUserUnpinned&&!_recentNonMessageScrollIntent())) _setMessageScrollToBottom();
2787
2844
  requestAnimationFrame(()=>{
2788
2845
  if(token!==_bottomSettleToken) return;
2789
- if(force || (_scrollPinned&&!_recentNonMessageScrollIntent())) _setMessageScrollToBottom();
2846
+ if(force || (_scrollPinned&&!_messageUserUnpinned&&!_recentNonMessageScrollIntent())) _setMessageScrollToBottom();
2790
2847
  });
2791
2848
  });
2792
2849
  }
2793
2850
  function scrollIfPinned(){
2851
+ if(_messageUserUnpinned) return;
2794
2852
  if(!_scrollPinned) return;
2795
2853
  if(_recentNonMessageScrollIntent()) return;
2854
+ if(_messageBottomDistance()>500) _setMessageScrollToBottom();
2796
2855
  _settleMessageScrollToBottom(false);
2797
2856
  }
2798
2857
  function scrollToBottom(){
@@ -2836,7 +2895,7 @@ function getModelLabel(modelId){
2836
2895
  if(rawId.startsWith('@custom:')){
2837
2896
  const rest=rawId.slice('@custom:'.length);
2838
2897
  if(rest.includes(':')) return rest.slice(rest.lastIndexOf(':')+1)||rawId;
2839
- if(rest.includes('/')) return rest.split('/').pop()||rawId;
2898
+ if(rest.includes('/')) return rest.slice(rest.indexOf('/')+1)||rawId;
2840
2899
  return rest||rawId;
2841
2900
  }
2842
2901
  // Check dynamic labels first, then fall back to splitting the ID
@@ -2844,8 +2903,47 @@ function getModelLabel(modelId){
2844
2903
  // Static fallback for common models
2845
2904
  const STATIC_LABELS={'openai/gpt-5.4-mini':'GPT-5.4 Mini','openai/gpt-4o':'GPT-4o','openai/o3':'o3','openai/o4-mini':'o4-mini','anthropic/claude-sonnet-4.6':'Sonnet 4.6','anthropic/claude-sonnet-4-5':'Sonnet 4.5','anthropic/claude-haiku-3-5':'Haiku 3.5','google/gemini-3.1-pro-preview':'Gemini 3.1 Pro','google/gemini-3-flash-preview':'Gemini 3 Flash','google/gemini-3.1-flash-lite-preview':'Gemini 3.1 Flash Lite','google/gemini-2.5-pro':'Gemini 2.5 Pro','google/gemini-2.5-flash':'Gemini 2.5 Flash','deepseek/deepseek-v4-flash':'DeepSeek V4 Flash','deepseek/deepseek-v4-pro':'DeepSeek V4 Pro','deepseek/deepseek-chat-v3-0324':'DeepSeek V3 (legacy)','meta-llama/llama-4-scout':'Llama 4 Scout'};
2846
2905
  if(STATIC_LABELS[modelId]) return STATIC_LABELS[modelId];
2847
- // Safe Ollama-tag fallback formatter before generic split('/').pop()
2848
- let _last = modelId.split('/').pop() || modelId;
2906
+ // Safe Ollama-tag fallback: strip only the first slash-segment (provider
2907
+ // prefix) so multi-slash IDs preserve their vendor hierarchy (#3360).
2908
+ // URI-scheme ids (e.g. `gpt://${FOLDER}/deepseek-v4-flash/latest`, provider
2909
+ // `yandex:gpt`) must NOT be first-segment-stripped — `indexOf('/')` would
2910
+ // land inside the `://` and leave `/${FOLDER}/...` path junk (#3429). For a
2911
+ // `scheme://authority/path...` id, drop the scheme AND the authority, then
2912
+ // pick the model name from the PATH segments only. A version/channel tail
2913
+ // (`latest`/`stable`/numeric) is skipped only when a real model segment
2914
+ // precedes it — never promoting the authority or a container folder (#3429).
2915
+ let _last;
2916
+ const _uriMatch = /^[a-z][a-z0-9+.-]*:\/\/(.+)$/i.exec(modelId);
2917
+ if (_uriMatch) {
2918
+ const _all = _uriMatch[1].split('/').filter(Boolean);
2919
+ // _all[0] is the authority (folder/host); the model lives in the path tail.
2920
+ const _path = _all.slice(1);
2921
+ // A pure version/channel tail: named channels, or a bare version number
2922
+ // (`v4`, `1.2`, `20231231`) — NOT a mixed model name that merely starts
2923
+ // with a digit (`2026-model`, `4o-mini`), which must be kept as the label.
2924
+ const _isVersionTail = (s) => /^(latest|stable|current|default|v\d[\d.]*|\d[\d.]*)$/i.test(s);
2925
+ const _isPlaceholder = (s) => /\$\{[^}]*\}/.test(s);
2926
+ // Walk path segments right-to-left; the model name is the LAST segment that
2927
+ // is neither a version/channel tail (`latest`, `v4`, `1.2`) nor a `${...}`
2928
+ // env-var placeholder. Fall back to the last non-placeholder segment, then
2929
+ // the literal last segment. Never returns the authority (`_all[0]`).
2930
+ let _pick = '';
2931
+ let _lastUsable = '';
2932
+ for (let _i = _path.length - 1; _i >= 0; _i--) {
2933
+ const _seg = _path[_i];
2934
+ if (_isPlaceholder(_seg)) continue;
2935
+ if (!_lastUsable) _lastUsable = _seg;
2936
+ if (!_isVersionTail(_seg)) { _pick = _seg; break; }
2937
+ }
2938
+ // Fallbacks: the chosen non-version segment, else the last non-placeholder
2939
+ // path segment. NEVER the authority and NEVER a `${...}` placeholder — for
2940
+ // a degenerate id (`gpt://folder123`, `gpt://folder123/${MODEL}`) fall all
2941
+ // the way back to the raw id rather than leak the folder/host or env var.
2942
+ const _lastPath = _path[_path.length - 1] || '';
2943
+ _last = _pick || _lastUsable || (_lastPath && !_isPlaceholder(_lastPath) ? _lastPath : '') || modelId;
2944
+ } else {
2945
+ _last = modelId.includes('/') ? (modelId.slice(modelId.indexOf('/')+1) || modelId) : modelId;
2946
+ }
2849
2947
  // Strip @provider: prefix if present (e.g. @ollama-cloud:kimi-k2.6)
2850
2948
  if (_last.startsWith('@') && _last.includes(':')) _last = _last.split(':').slice(1).join(':');
2851
2949
  const looksLikeOllamaTag = /^[a-z0-9][\w.-]*:[\w.-]+$/i.test(_last);
@@ -3098,9 +3196,10 @@ function renderMd(raw){
3098
3196
  s=s.replace(/\$\$([\s\S]+?)\$\$/g,(_,m)=>{math_stash.push({type:'display',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
3099
3197
  // Match a single literal backslash before the display delimiter (the common LLM form).
3100
3198
  s=s.replace(/\\\[([\s\S]+?)\\\]/g,(_,m)=>{math_stash.push({type:'display',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
3101
- // Inline math: $...$ — require non-space at boundaries to avoid false positives
3102
- // e.g. "costs $5 and $10" should not trigger (space after opening $)
3103
- s=s.replace(/\$([^\s$\n][^$\n]*?[^\s$\n]|\S)\$/g,(_,m)=>{if(m.includes(' | '))return '\$'+m+'\$';math_stash.push({type:'inline',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
3199
+ // Inline math: $...$ — require non-space/non-digit at opening boundary to avoid
3200
+ // false positives on currency like "$1,000 xuống ~$95" or "costs $5 and $10".
3201
+ // Aligns with smd's se() guard which also rejects $ followed by digits.
3202
+ s=s.replace(/\$([^\s$\d\n][^$\n]*?[^\s$\n]|[^\s\d])\$/g,(_,m)=>{if(m.includes(' | '))return '\$'+m+'\$';math_stash.push({type:'inline',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
3104
3203
  // Also stash \(...\) LaTeX delimiters.
3105
3204
  // Match a single literal backslash before the delimiter (the common LLM form).
3106
3205
  s=s.replace(/\\\((.+?)\\\)/g,(_,m)=>{math_stash.push({type:'inline',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
@@ -3479,6 +3578,19 @@ function renderMd(raw){
3479
3578
  : `<audio class="msg-media-player msg-media-audio" src="${safeSrc}" controls preload="metadata" title="${safeName}"></audio>`;
3480
3579
  return `<div class="msg-media-editor msg-media-editor--${kind}" data-media-kind="${kind}">${tag}<div class="msg-media-meta"><span class="msg-media-name">${safeName}</span></div></div>`;
3481
3580
  };
3581
+ const localArtifactCard=(src,name)=>{
3582
+ const safeSrc=esc(src);
3583
+ const safeName=esc(name||'image');
3584
+ const tt=(typeof t==='function')?t:(key=>({media_download:'Download'}[key]||key));
3585
+ // Clean inline image (keeps the existing .msg-media-img lightbox-on-click
3586
+ // behavior) with a hover/focus-revealed Download action overlaid top-right,
3587
+ // matching the ChatGPT/Claude/Gemini pattern. The image stays the hero —
3588
+ // no permanent card chrome. Download is the one affordance the lightbox
3589
+ // (zoom-on-click) doesn't already provide.
3590
+ const dlLabel=esc(tt('media_download'));
3591
+ const dlSvg='<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>';
3592
+ return `<span class="msg-artifact-image"><img class="msg-media-img" src="${safeSrc}" alt="${safeName}" loading="lazy"><a class="msg-artifact-download" href="${safeSrc}" download="${safeName}" title="${dlLabel}" aria-label="${dlLabel}" onclick="event.stopPropagation()">${dlSvg}</a></span>`;
3593
+ };
3482
3594
  if(/^file:\/\//i.test(ref)){
3483
3595
  try{
3484
3596
  const u=new URL(ref);
@@ -3520,7 +3632,7 @@ function renderMd(raw){
3520
3632
  const apiUrl='api/media?path='+encodeURIComponent(ref)+(mediaSessionId?'&session_id='+encodeURIComponent(mediaSessionId):'');
3521
3633
  const localKind=mediaKindForName(ref);
3522
3634
  if(localKind==='image'){
3523
- return `<img class="msg-media-img" src="${esc(apiUrl)}" alt="${esc(ref.split('/').pop())}" loading="lazy">`;
3635
+ return localArtifactCard(apiUrl,ref.split('/').pop()||'image');
3524
3636
  }
3525
3637
  // SVG → inline image (no download, render directly)
3526
3638
  if(_SVG_EXTS.test(ref)){
@@ -3564,6 +3676,10 @@ function renderMd(raw){
3564
3676
  return s;
3565
3677
  }
3566
3678
 
3679
+ function _stripAttachedFilesMarkerForDisplay(text){
3680
+ return String(text||'').replace(/\n\n\[Attached files: [^\]]+\]$/,'').trim();
3681
+ }
3682
+
3567
3683
  function setStatus(t){
3568
3684
  if(!t)return;
3569
3685
  showToast(t, 4000);
@@ -4311,6 +4427,8 @@ function _stripForTTS(text){
4311
4427
  text=text.replace(/\[([^\]]+)\]\([^)]+\)/g,'$1');
4312
4428
  // Replace MEDIA: paths with a simple label
4313
4429
  text=text.replace(/MEDIA:[^\s]+/g,'a file');
4430
+ // Strip emoji and emoticons
4431
+ text=text.replace(/[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F1E0}-\u{1F1FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{FE00}-\u{FE0F}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{200D}]/gu,'');
4314
4432
  // Strip HTML tags that may leak through markdown
4315
4433
  text=text.replace(/<[^>]+>/g,' ');
4316
4434
  // Collapse whitespace
@@ -4320,18 +4438,13 @@ function _stripForTTS(text){
4320
4438
 
4321
4439
  let _ttsSpeaking=false;
4322
4440
  let _ttsCurrentUtterance=null;
4441
+ let _playingEdgeAudio=null;
4323
4442
 
4324
4443
  function speakMessage(btn){
4325
- if(!('speechSynthesis' in window)){
4326
- showToast(t('tts_not_supported')||'Speech synthesis not supported in this browser.');
4327
- return;
4328
- }
4329
- // If already speaking this message, stop
4330
4444
  if(btn&&btn.dataset.speaking==='1'){
4331
4445
  stopTTS();
4332
4446
  return;
4333
4447
  }
4334
- // Stop any current speech
4335
4448
  stopTTS();
4336
4449
 
4337
4450
  const row=btn?btn.closest('[data-raw-text]'):null;
@@ -4341,6 +4454,17 @@ function speakMessage(btn){
4341
4454
  const clean=_stripForTTS(text);
4342
4455
  if(!clean) return;
4343
4456
 
4457
+ const engine=localStorage.getItem('hermes-tts-engine')||'browser';
4458
+ if(engine==='edge'){
4459
+ _playEdgeTts(clean, btn);
4460
+ return;
4461
+ }
4462
+
4463
+ if(!('speechSynthesis' in window)){
4464
+ showToast(t('tts_not_supported')||'Speech synthesis not supported in this browser.');
4465
+ return;
4466
+ }
4467
+
4344
4468
  const utter=new SpeechSynthesisUtterance(clean);
4345
4469
 
4346
4470
  // Apply saved voice preference
@@ -4367,10 +4491,59 @@ function speakMessage(btn){
4367
4491
  speechSynthesis.speak(utter);
4368
4492
  }
4369
4493
 
4494
+ function _playEdgeTts(text, btn){
4495
+ const voice=localStorage.getItem('hermes-tts-voice')||'zh-CN-XiaoxiaoNeural';
4496
+ const savedRate=parseFloat(localStorage.getItem('hermes-tts-rate'));
4497
+ const savedPitch=parseFloat(localStorage.getItem('hermes-tts-pitch'));
4498
+ let rate='', pitch='';
4499
+ if(!isNaN(savedRate)){const pct=Math.round((savedRate-1)*100);const sign=pct>=0?'+':'';rate=sign+pct+'%';}
4500
+ if(!isNaN(savedPitch)){const hz=Math.round((savedPitch-1)*50);const sign=hz>=0?'+':'';pitch=sign+hz+'Hz';}
4501
+ if(btn) btn.dataset.speaking='1';
4502
+ _ttsSpeaking=true;
4503
+ // /api/tts is POST-only (and behind the same-origin CSRF gate); GET via
4504
+ // new Audio(url) would 405 and silently fail, and would also leak the message
4505
+ // text into the query string / access log. POST the JSON body, then play the
4506
+ // returned audio via an object URL — mirrors the working boot.js edge path.
4507
+ const _fail=function(msg){
4508
+ _ttsSpeaking=false;_playingEdgeAudio=null;
4509
+ if(btn)btn.dataset.speaking='0';
4510
+ if(msg&&typeof showToast==='function') showToast(msg,4000,'error');
4511
+ };
4512
+ fetch(new URL('api/tts', document.baseURI || location.href).href, {
4513
+ method:'POST',
4514
+ headers:{'Content-Type':'application/json'},
4515
+ body:JSON.stringify({text:text, voice:voice, rate:rate, pitch:pitch})
4516
+ })
4517
+ .then(function(r){
4518
+ if(!r.ok){
4519
+ // Surface the server error (e.g. 503 "edge-tts not installed", 429 rate limit)
4520
+ return r.json().catch(function(){return {};}).then(function(j){
4521
+ throw new Error((j&&j.error)||('TTS request failed: '+r.status));
4522
+ });
4523
+ }
4524
+ return r.blob();
4525
+ })
4526
+ .then(function(blob){
4527
+ const url=URL.createObjectURL(blob);
4528
+ const audio=new Audio(url);
4529
+ _playingEdgeAudio=audio;
4530
+ const _cleanup=function(){_ttsSpeaking=false;_playingEdgeAudio=null;if(btn)btn.dataset.speaking='0';try{URL.revokeObjectURL(url);}catch(_){}};
4531
+ audio.onended=_cleanup;
4532
+ audio.onerror=function(){_cleanup();};
4533
+ audio.play().catch(function(e){_cleanup();showToast('Edge TTS error: '+(e&&e.message||e));});
4534
+ })
4535
+ .catch(function(e){ _fail((e&&e.message)||'Edge TTS failed'); });
4536
+ }
4537
+
4370
4538
  function stopTTS(){
4371
4539
  if('speechSynthesis' in window){
4372
4540
  speechSynthesis.cancel();
4373
4541
  }
4542
+ // Stop Edge TTS audio
4543
+ if(_playingEdgeAudio){
4544
+ try{ _playingEdgeAudio.pause(); _playingEdgeAudio.currentTime=0; }catch(_){}
4545
+ _playingEdgeAudio=null;
4546
+ }
4374
4547
  _ttsSpeaking=false;
4375
4548
  _ttsCurrentUtterance=null;
4376
4549
  // Reset all speaking buttons
@@ -4378,7 +4551,8 @@ function stopTTS(){
4378
4551
  }
4379
4552
 
4380
4553
  function autoReadLastAssistant(){
4381
- if(!('speechSynthesis' in window)) return;
4554
+ const engine=localStorage.getItem('hermes-tts-engine')||'browser';
4555
+ if(engine==='browser'&&!('speechSynthesis' in window)) return;
4382
4556
  const pref=localStorage.getItem('hermes-tts-auto-read');
4383
4557
  if(pref!=='true') return;
4384
4558
  // Find the last assistant message segment in the DOM
@@ -4389,7 +4563,10 @@ function autoReadLastAssistant(){
4389
4563
  if(!text.trim()) return;
4390
4564
  const clean=_stripForTTS(text);
4391
4565
  if(!clean) return;
4392
-
4566
+ if(engine==='edge'){
4567
+ _playEdgeTts(clean, null);
4568
+ return;
4569
+ }
4393
4570
  const utter=new SpeechSynthesisUtterance(clean);
4394
4571
  const savedVoice=localStorage.getItem('hermes-tts-voice');
4395
4572
  const voices=speechSynthesis.getVoices();
@@ -5390,7 +5567,7 @@ function syncTopbar(){
5390
5567
  // modelSelect already set above
5391
5568
  // Update profile chip label
5392
5569
  const profileLabel=$('profileChipLabel');
5393
- if(profileLabel) profileLabel.textContent=S.activeProfile||'default';
5570
+ if(profileLabel) profileLabel.textContent=(S.session&&S.session.profile)||S.activeProfile||'default';
5394
5571
  }
5395
5572
 
5396
5573
  function msgContent(m){
@@ -5400,6 +5577,29 @@ function msgContent(m){
5400
5577
  return String(c).trim();
5401
5578
  }
5402
5579
 
5580
+ function _isRecoveryControlMessageText(text){
5581
+ const normalized=String(text||'').replace(/\s+/g,' ').trim();
5582
+ if(!normalized) return false;
5583
+ const systemRecovery=/^\[System:/i.test(normalized)
5584
+ && /previous response was cut off by a network error/i.test(normalized)
5585
+ && /continue exactly where you left off/i.test(normalized);
5586
+ const backendRecovery=/^the live worker stopped before this run finished\.?$/i.test(normalized);
5587
+ return !!(systemRecovery || backendRecovery);
5588
+ }
5589
+ function _isRecoveryControlMessage(m){
5590
+ if(!m||m.role==='tool') return false;
5591
+ if(m.recovery_control===true) return true;
5592
+ // Backward-compat ONLY: strict fully-anchored text match for pre-marker
5593
+ // persisted sessions. NOT provider_details_label — a real "Response
5594
+ // interrupted" card carries 'Interruption details' and must stay visible.
5595
+ return _isRecoveryControlMessageText(msgContent(m)||String(m.content||''));
5596
+ }
5597
+ function _assistantMessageHasVisibleContent(m){
5598
+ if(!m||m.role!=='assistant') return false;
5599
+ if(_isRecoveryControlMessage(m)) return false;
5600
+ return !!msgContent(m);
5601
+ }
5602
+
5403
5603
  function _fmtDateSep(d){
5404
5604
  const todayStart=new Date();todayStart.setHours(0,0,0,0);
5405
5605
  const dStart=new Date(d);dStart.setHours(0,0,0,0);
@@ -6106,6 +6306,7 @@ let _sessionHtmlCacheSid=null; // session_id currently rendered in the DOM
6106
6306
  function clearMessageRenderCache(){
6107
6307
  _sessionHtmlCache.clear();
6108
6308
  _sessionHtmlCacheSid=null;
6309
+ clearVisibleMessageRowCache();
6109
6310
  }
6110
6311
 
6111
6312
  function _messageRenderCacheSignature(){
@@ -6275,6 +6476,7 @@ function _restoreMessageScrollSnapshot(snapshot){
6275
6476
  const maxTop=Math.max(0,el.scrollHeight-el.clientHeight);
6276
6477
  _programmaticScroll=true;
6277
6478
  el.scrollTop=Math.max(0,Math.min(Number(snapshot.top)||0,maxTop));
6479
+ // Sync _lastScrollTop after programmatic restore so sticky-unpin does not false-trigger (#1731).
6278
6480
  _lastScrollTop=el.scrollTop;
6279
6481
  requestAnimationFrame(()=>{ setTimeout(()=>{_programmaticScroll=false;},0); });
6280
6482
  }
@@ -6352,10 +6554,12 @@ function renderMessages(options){
6352
6554
  if(!m||!m.role||m.role==='tool')return false;
6353
6555
  if(_isContextCompactionMessage(m)) return false;
6354
6556
  if(_isPreservedCompressionTaskListMessage(m)) return false;
6557
+ if(_isRecoveryControlMessage(m)) return false;
6355
6558
  if(m.role==='assistant'){
6356
6559
  const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
6357
6560
  const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use');
6358
6561
  if(hasTc||hasTu||_messageHasReasoningPayload(m)) return true;
6562
+ if(_assistantMessageHasVisibleContent(m)) return true;
6359
6563
  }
6360
6564
  return m._statusCard||msgContent(m)||m.attachments?.length;
6361
6565
  });
@@ -6382,9 +6586,10 @@ function renderMessages(options){
6382
6586
  for(const m of S.messages){
6383
6587
  if(!m||!m.role||m.role==='tool'){ri++;continue;}
6384
6588
  if(_isPreservedCompressionTaskListMessage(m)){ri++;continue;}
6589
+ if(_isRecoveryControlMessage(m)){ri++;continue;}
6385
6590
  const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
6386
6591
  const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use');
6387
- if(msgContent(m)||m._statusCard||m.attachments?.length||(m.role==='assistant'&&(hasTc||hasTu||_messageHasReasoningPayload(m)))) rebuilt.push({m,rawIdx:ri});
6592
+ if(msgContent(m)||m._statusCard||m.attachments?.length||(m.role==='assistant'&&(hasTc||hasTu||_messageHasReasoningPayload(m)||_assistantMessageHasVisibleContent(m)))) rebuilt.push({m,rawIdx:ri});
6388
6593
  ri++;
6389
6594
  }
6390
6595
  _visWithIdxCache=rebuilt;
@@ -6530,7 +6735,7 @@ function renderMessages(options){
6530
6735
  if(!isUser&&_isMarkerOnlyAssistantCompressionMessage(m)){
6531
6736
  content='**Error:** No response received after context compression. Please retry.';
6532
6737
  }
6533
- const displayContent=isUser?_stripWorkspaceDisplayPrefix(content):content;
6738
+ const displayContent=isUser?_stripAttachedFilesMarkerForDisplay(_stripWorkspaceDisplayPrefix(content)):content;
6534
6739
  if(thinkingText&&!isUser){
6535
6740
  thinkingText=_stripVisibleAssistantEchoFromThinking(thinkingText, displayContent);
6536
6741
  }
@@ -7116,14 +7321,53 @@ function buildToolCard(tc){
7116
7321
  Object.entries(tc.args).map(([k,v])=>`<div><span class="tool-arg-key">${esc(k)}</span> <span class="tool-arg-val">${esc(String(v))}</span></div>`).join('')
7117
7322
  }</div>`:''}
7118
7323
  ${displaySnippet?`<div class="tool-card-result">
7119
- <pre>${esc(displaySnippet)}</pre>
7120
- ${hasMore?`<button class="tool-card-more" data-full="${esc(tc.snippet||'').replace(/"/g,'&quot;')}" data-short="${esc(displaySnippet||'').replace(/"/g,'&quot;')}" data-more-label="${esc(moreLabel)}" data-less-label="${esc(lessLabel)}" onclick="event.stopPropagation();const p=this.previousElementSibling;const full=this.dataset.full;const short=this.dataset.short;p.textContent=p.textContent===short?full:short;this.textContent=p.textContent===short?this.dataset.moreLabel:this.dataset.lessLabel">${esc(moreLabel)}</button>`:''}
7324
+ <pre>${tc.is_diff||_snippetLooksLikeDiff(displaySnippet)?`<code class="diff-block" data-highlighted="1">${_colorDiffLines(displaySnippet)}</code>`:esc(displaySnippet)}</pre>
7325
+ ${hasMore?`<button class="tool-card-more" data-full="${esc(tc.snippet||'').replace(/"/g,'&quot;')}" data-short="${esc(displaySnippet||'').replace(/"/g,'&quot;')}" data-is-diff="${tc.is_diff||_snippetLooksLikeDiff(displaySnippet)?1:0}" data-more-label="${esc(moreLabel)}" data-less-label="${esc(lessLabel)}" onclick="event.stopPropagation();_toggleToolDiff(this)">${esc(moreLabel)}</button>`:''}
7121
7326
  </div>`:''}
7122
7327
  </div>`:''}
7123
7328
  </div>`;
7124
7329
  return row;
7125
7330
  }
7126
7331
 
7332
+ function _colorDiffLines(text){
7333
+ if(typeof text !== 'string') return esc(String(text||''));
7334
+ return esc(text).split('\n').map(line=>{
7335
+ if(line.startsWith('@@')) return `<span class="diff-line diff-hunk">${line}</span>`;
7336
+ if(line.startsWith('+')&&!line.startsWith('+++')) return `<span class="diff-line diff-plus">${line}</span>`;
7337
+ if(line.startsWith('-')&&!line.startsWith('---')) return `<span class="diff-line diff-minus">${line}</span>`;
7338
+ return `<span class="diff-line">${line}</span>`;
7339
+ }).join('\n');
7340
+ }
7341
+
7342
+ // Detect if text looks like a unified diff (has @@ hunk headers and +/- lines).
7343
+ function _snippetLooksLikeDiff(text){
7344
+ if(typeof text!=='string'||text.length<10) return false;
7345
+ if(!/^@@\s/.test(text)) return false;
7346
+ const lines=text.split('\n');
7347
+ let plusMinus=0;
7348
+ for(let i=0;i<lines.length&&i<50;i++){
7349
+ const l=lines[i];
7350
+ if(l.startsWith('+')||l.startsWith('-')) plusMinus++;
7351
+ }
7352
+ return plusMinus>=2;
7353
+ }
7354
+
7355
+ function _toggleToolDiff(btn){
7356
+ const pre=btn.closest('.tool-card-result')?.querySelector('pre');
7357
+ if(!pre) return;
7358
+ const isDiff=btn.dataset.isDiff==='1';
7359
+ const expanded=btn.textContent===btn.dataset.moreLabel;
7360
+ const raw=expanded?btn.dataset.full:btn.dataset.short;
7361
+ if(isDiff){
7362
+ let code=pre.querySelector('code');
7363
+ if(!code){code=document.createElement('code');code.className='diff-block';pre.textContent='';pre.appendChild(code);}
7364
+ code.innerHTML=_colorDiffLines(raw);
7365
+ }else{
7366
+ pre.textContent=raw;
7367
+ }
7368
+ btn.textContent=expanded?btn.dataset.lessLabel:btn.dataset.moreLabel;
7369
+ }
7370
+
7127
7371
  function _syncToolCallGroupSummary(group){
7128
7372
  if(!group) return;
7129
7373
  const cards=Array.from(group.querySelectorAll('.tool-card-row .tool-card'));