dsh-browser-plus 0.5.0 → 0.5.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 (50) hide show
  1. package/CHANGELOG.md +131 -144
  2. package/README.en.md +27 -8
  3. package/README.md +27 -8
  4. package/client/index.js +185 -0
  5. package/cordis.patch.yml +24 -0
  6. package/docs/README.md +1 -1
  7. package/docs/SOAK-CHECKLIST.md +1 -1
  8. package/docs/tool-reference.md +5 -3
  9. package/docs/user-guide.md +20 -4
  10. package/lib/browser/runtime.d.ts +14 -1
  11. package/lib/browser/runtime.js +28 -0
  12. package/lib/browser/types.d.ts +96 -6
  13. package/lib/browser-electron/chrome-state.d.ts +24 -0
  14. package/lib/browser-electron/entry.d.ts +12 -5
  15. package/lib/browser-electron/entry.js +5 -2
  16. package/lib/browser-electron/host-main.d.ts +2 -1
  17. package/lib/browser-electron/host-main.js +205 -8
  18. package/lib/browser-electron/page-chrome.js +249 -14
  19. package/lib/browser-electron/provider.d.ts +60 -2
  20. package/lib/browser-electron/provider.js +300 -50
  21. package/lib/browser-electron/remote-host.d.ts +3 -1
  22. package/lib/browser-electron/remote-host.js +42 -1
  23. package/lib/client.js +185 -0
  24. package/lib/command-browser/index.d.ts +20 -0
  25. package/lib/command-browser/index.js +35 -0
  26. package/lib/http-browser/index.d.ts +28 -0
  27. package/lib/http-browser/index.js +110 -0
  28. package/lib/index.d.ts +11 -0
  29. package/lib/index.js +11 -0
  30. package/lib/task-todos/index.d.ts +25 -0
  31. package/lib/task-todos/index.js +100 -0
  32. package/lib/tool-browser/index.js +190 -95
  33. package/package.json +27 -2
  34. package/scripts/build-client.mjs +20 -0
  35. package/scripts/smoke-browser-tools.mjs +84 -1
  36. package/scripts/smoke-chrome-world.mjs +6 -4
  37. package/scripts/test-orb-drag.mjs +83 -0
  38. package/src/browser/runtime.ts +36 -0
  39. package/src/browser/types.ts +98 -6
  40. package/src/browser-electron/chrome-state.ts +18 -0
  41. package/src/browser-electron/entry.ts +17 -7
  42. package/src/browser-electron/host-main.ts +207 -11
  43. package/src/browser-electron/page-chrome.ts +249 -14
  44. package/src/browser-electron/provider.ts +328 -50
  45. package/src/browser-electron/remote-host.ts +49 -2
  46. package/src/command-browser/index.ts +61 -0
  47. package/src/http-browser/index.ts +139 -0
  48. package/src/index.ts +13 -0
  49. package/src/task-todos/index.ts +114 -0
  50. package/src/tool-browser/index.ts +193 -96
@@ -89,6 +89,34 @@ export function buildPageChromeScript(bindingToken = '', surface = 'full') {
89
89
  '#taskPanel .task-meta { color:#b8c5d6; }',
90
90
  '#taskPanel .task-latest { color:#acd0ff; }',
91
91
  '#taskPanel .task-error { color:#ffb1b1; }',
92
+ // ── 悬浮球:Agent 现在在做什么 ─────────────────────────────────────────────
93
+ // 宿主整层是 pointer-events:none,交互元素各自开回来 —— 漏了这一句球就只是画在那里:
94
+ // 拖不动、悬停也不出卡片(用户实测报「拖不动」就是它)。
95
+ '#orb { pointer-events:auto; position:fixed; right:18px; bottom:18px; width:44px; height:44px; z-index:2147483000; display:grid; place-items:center; box-sizing:border-box; border:1px solid rgba(255,255,255,.14); border-radius:50%; background:#292a2d; color:#e8eaed; cursor:pointer; box-shadow:0 1px 2px rgba(0,0,0,.5),0 8px 20px rgba(0,0,0,.34); opacity:.62; transition:opacity .18s ease,background .18s ease,border-color .18s ease; }',
96
+ '#orb:hover, #orb:focus-visible, #orb.dragging { opacity:1; background:#35363a; border-color:rgba(255,255,255,.24); }',
97
+ '#orb .orb-mark { width:21px; height:21px; opacity:.88; }',
98
+ '#orb .orb-ring { position:absolute; inset:-3px; border-radius:50%; border:2px solid transparent; border-top-color:#8ab4f8; border-right-color:#8ab4f8; opacity:0; transition:opacity .18s ease; animation:dshOrbSpin .85s linear infinite; }',
99
+ '#orb.working .orb-ring { opacity:1; }',
100
+ '@keyframes dshOrbSpin { to { transform:rotate(360deg); } }',
101
+ '#orb .orb-badge { position:absolute; right:-3px; bottom:-3px; box-sizing:border-box; min-width:17px; height:17px; padding:0 4px; border-radius:9px; background:#5f6368; color:#fff; font:600 10px/17px -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif; text-align:center; box-shadow:0 0 0 2px #202124; }',
102
+ '#orb .orb-badge:empty { display:none; }',
103
+ '#orb.done .orb-badge { background:#34a853; }',
104
+ '#orb.failed .orb-badge { background:#d93025; }',
105
+ '#orb.waiting .orb-badge { background:#f9ab00; color:#202124; }',
106
+ '#orbCard { pointer-events:auto; position:fixed; right:18px; bottom:74px; width:308px; max-height:342px; z-index:2147483000; display:none; flex-direction:column; overflow:hidden; border:1px solid #3c4043; border-radius:10px; background:#292a2d; color:#e8eaed; box-shadow:0 8px 24px rgba(0,0,0,.5); font:12px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif; }',
107
+ '#orbCard.open { display:flex; }',
108
+ '#orbCard .orb-head { flex:none; display:flex; align-items:baseline; justify-content:space-between; gap:8px; padding:9px 12px; border-bottom:1px solid #3c4043; font-weight:500; }',
109
+ '#orbCard .orb-task { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#9aa0a6; font-weight:400; font-size:11px; }',
110
+ '#orbCard .orb-list { overflow:auto; padding:6px 0; }',
111
+ '#orbCard .orb-item { display:grid; grid-template-columns:16px minmax(0,1fr); column-gap:8px; align-items:start; padding:5px 12px; }',
112
+ '#orbCard .orb-mark { display:grid; place-items:center; width:16px; height:16px; color:#9aa0a6; font-size:11px; }',
113
+ '#orbCard .orb-item.in_progress .orb-mark { color:#8ab4f8; }',
114
+ '#orbCard .orb-item.completed .orb-mark { color:#81c995; }',
115
+ '#orbCard .orb-item.completed .orb-text { color:#9aa0a6; }',
116
+ '#orbCard .orb-text { min-width:0; overflow-wrap:anywhere; }',
117
+ '#orbCard .orb-spin { width:10px; height:10px; border:1.5px solid transparent; border-top-color:#8ab4f8; border-right-color:#8ab4f8; border-radius:50%; animation:dshOrbSpin .9s linear infinite; }',
118
+ '#orbCard .orb-foot { flex:none; padding:8px 12px; border-top:1px solid #3c4043; color:#8ab4f8; font-size:11px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }',
119
+ '#orbCard .orb-empty { padding:10px 12px; color:#9aa0a6; }',
92
120
  '#trail #trailList { flex:1; overflow-y:auto; padding:8px 12px; }',
93
121
  '#trail .activity-day { color:#accbff; font:11px/1.4 -apple-system,BlinkMacSystemFont,"SF Pro Display","Segoe UI",system-ui,sans-serif; text-align:center; margin:6px 0 5px; }',
94
122
  '#trail .activity-item { transition:background .12s ease; position:relative; display:grid; grid-template-columns:54px 16px minmax(0,1fr); column-gap:7px; align-items:start; padding:8px 0; }',
@@ -274,14 +302,13 @@ export function buildPageChromeScript(bindingToken = '', surface = 'full') {
274
302
  // and 'page' is the copy that stays in the page (the popups). Popups cannot live
275
303
  // in the frame: it is 84px tall and a menu would be clipped.
276
304
  const chromeCssText = toolbarCss + '\n#toolbarRevealZone,#toolbarHide{display:none!important}'
277
- // #bookmarkBar 挂在 top:84px —— 在 84px 高的 frame 里它就是把文档撑高 34px,浏览器于是画一条
278
- // 滚动条(真机截图里右边那条带箭头的)。书签栏由 page 那份画(它在那边被挪到 top:0)。
279
- + (surface === 'frame' ? '\n#panel, .glass-panel, #mainMenu, #findBar, #toast, #bookmarkBar { display:none !important }' : '')
305
+ // #bookmarkBar 现在由 frame 那份画:帧视图会跟着开关长到 118px(84 + 34),
306
+ // 书签栏正好落在 84..118。页面视图从 118 开始 —— 所以页面**一点都不用让位**,
307
+ // 站点的 position:fixed 头部也就永远不会被压住(这正是这一改动要修的)。
308
+ + (surface === 'frame' ? '\n#panel, .glass-panel, #mainMenu, #findBar, #toast, #orb, #orbCard { display:none !important }' : '')
280
309
  + (surface === 'page'
281
- ? '\n#tabstrip, #bar, #toolbarRevealZone, #toolbarHide { display:none !important }'
282
- // The page's viewport now starts where the frame ends, so the bar sits at the
283
- // very top of it and the popups hang just below the frame.
284
- + '\n#bookmarkBar { top:0 !important }'
310
+ // 书签栏由 frame 那份画,页面这份连标签栏一起藏掉,弹层挂在帧下方。
311
+ ? '\n#tabstrip, #bar, #bookmarkBar, #toolbarRevealZone, #toolbarHide { display:none !important }'
285
312
  + '\n#panel, .glass-panel, #mainMenu, #findBar { top:8px !important }'
286
313
  : '');
287
314
  const toolbarHtml = [
@@ -353,6 +380,11 @@ export function buildPageChromeScript(bindingToken = '', surface = 'full') {
353
380
  '<div class="task-head"><span>浏览器任务</span><button id="taskClose" class="task-close" type="button" aria-label="关闭任务">×</button></div>',
354
381
  '<div id="taskList"></div>',
355
382
  '</div>',
383
+ // 悬浮球(右下角):Agent 正在做什么。它不属于 menus —— 页面上的点击不该关掉它。
384
+ '<button id="orb" type="button" aria-label="Agent 任务"><span class="orb-ring"></span>'
385
+ + '<svg class="orb-mark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3.25" y="4.75" width="17.5" height="14.5" rx="3.25"></rect><path d="M3.25 9.4h17.5"></path><circle cx="6.15" cy="7.05" r=".9" fill="currentColor" stroke="none"></circle><circle cx="8.75" cy="7.05" r=".9" fill="currentColor" stroke="none"></circle></svg>'
386
+ + '<span class="orb-badge"></span></button>',
387
+ '<div id="orbCard" role="menu" aria-label="Agent 任务"><div class="orb-head"><span>Agent 任务</span><span class="orb-task"></span></div><div class="orb-list"></div><div class="orb-foot"></div></div>',
356
388
  ].join('\n');
357
389
  return [
358
390
  '(() => {',
@@ -412,10 +444,9 @@ export function buildPageChromeScript(bindingToken = '', surface = 'full') {
412
444
  " // 根元素高度锁成视口高、padding 算在内部,滚动条就只由真实内容决定。",
413
445
  " // 书签栏开着时顶栏会多 34px,下移量要跟着变(否则页面会被压住一截)。",
414
446
  " let bookmarksVisible = false",
415
- " // page 表面上工具栏画在 frame 视图里,所以页面只需要给书签栏留位置(有就 34px)。",
416
- " const chromeInset = () => CHROME_SURFACE === 'page'",
417
- " ? (bookmarksVisible ? 34 : 0)",
418
- " : 84 + (bookmarksVisible ? 34 : 0)",
447
+ " // page 表面上工具栏和书签栏都画在 frame 视图里,页面视图本身就从它们下面开始,",
448
+ " // 所以页面一点也不用让位(以前要让 34px,position:fixed 的头部于是被压住)。",
449
+ " const chromeInset = () => CHROME_SURFACE === 'page' ? 0 : 84 + (bookmarksVisible ? 34 : 0)",
419
450
  " // 下移量。**过渡只在第二次应用之后才补上**:chrome 每次导航都会重新注入,第一次就带",
420
451
  " // 过渡的话,每个页面加载(书签栏开着时 0 -> 34px)都会看到内容自己往下滑一下。",
421
452
  " let insetApplied = false",
@@ -446,6 +477,200 @@ export function buildPageChromeScript(bindingToken = '', surface = 'full') {
446
477
  " const tasksBtn = root.getElementById('tasksBtn')",
447
478
  " const taskPanel = root.getElementById('taskPanel')",
448
479
  " const taskList = root.getElementById('taskList')",
480
+ " const orb = root.getElementById('orb')",
481
+ " const orbCard = root.getElementById('orbCard')",
482
+ "",
483
+ " // ── 悬浮球 ──────────────────────────────────────────────────────────────",
484
+ " // 两个数据源:任务摘要(status / latestAction)与定向的 task.todos 补丁",
485
+ " // (Agent 的 todo 清单)。清单**不进摘要** —— 摘要会被注入每个访问过的页面的主世界。",
486
+ " const taskTodosCache = new Map()",
487
+ " const orbBadge = orb instanceof HTMLElement ? orb.querySelector('.orb-badge') : null",
488
+ " const orbCardTask = orbCard instanceof HTMLElement ? orbCard.querySelector('.orb-task') : null",
489
+ " const orbCardList = orbCard instanceof HTMLElement ? orbCard.querySelector('.orb-list') : null",
490
+ " const orbCardFoot = orbCard instanceof HTMLElement ? orbCard.querySelector('.orb-foot') : null",
491
+ " let orbCardTimer = null",
492
+ " const orbTask = () => {",
493
+ " const list = Array.isArray(window.__dshTasks) ? window.__dshTasks : []",
494
+ " return list.find(task => task && task.key === chromeSelectedTaskKey) || list.find(task => task && task.active === true) || list[0]",
495
+ " }",
496
+ " let orbRenderSignature = null",
497
+ " const renderOrb = () => {",
498
+ " if (!(orb instanceof HTMLElement)) return",
499
+ " const task = orbTask()",
500
+ " const key = task && typeof task.key === 'string' ? task.key : ''",
501
+ " const todos = key !== '' && Array.isArray(taskTodosCache.get(key)) ? taskTodosCache.get(key) : []",
502
+ " const status = task && typeof task.status === 'string' ? task.status : 'idle'",
503
+ " // 清单整表重建会把人的滚动位置弹回顶部、让转圈动画从头开始 —— 内容没变就整段跳过。",
504
+ " const signature = key + '\u0001' + status + '\u0001' + (task && task.latestAction ? task.latestAction : '') + '\u0001' + todos.map(item => (item && item.status) + ':' + (item && item.content)).join('\u0002')",
505
+ " if (signature === orbRenderSignature) return",
506
+ " orbRenderSignature = signature",
507
+ " const running = todos.some(item => item && item.status === 'in_progress')",
508
+ " // 终态优先:Agent 的清单里通常有一条 in_progress,若让 running 盖过 failed/waiting-user,",
509
+ " // 失败和「等你操作」就永远看不到(球一直转圈)。",
510
+ " const settled = status === 'failed' || status === 'waiting-user'",
511
+ " const working = status === 'running' || (running && !settled)",
512
+ " const completed = todos.filter(item => item && item.status === 'completed').length",
513
+ " // 清单被截断到上限时(与桥接 normalize 的 40 一致),最后那些可能没送进来 ——",
514
+ " // 不能凭前 40 条就宣布「全部完成」。",
515
+ " const capped = todos.length >= 40",
516
+ " const allDone = todos.length > 0 && completed === todos.length && !capped",
517
+ " orb.classList.toggle('working', working)",
518
+ " orb.classList.toggle('done', !working && allDone)",
519
+ " orb.classList.toggle('failed', !working && status === 'failed')",
520
+ " orb.classList.toggle('waiting', !working && status === 'waiting-user')",
521
+ " if (orbBadge) {",
522
+ " // 角标只在有话要说时出现:进度、完成、失败、等你。空闲就是干净的一个球。",
523
+ " if (working) orbBadge.textContent = todos.length > 0 ? String(completed) + '/' + String(todos.length) + (capped ? '+' : '') : ''",
524
+ " else if (status === 'failed') orbBadge.textContent = '!'",
525
+ " else if (status === 'waiting-user') orbBadge.textContent = '‖'",
526
+ " else if (allDone) orbBadge.textContent = '✓'",
527
+ " else orbBadge.textContent = ''",
528
+ " }",
529
+ " // 没有 label 时**不要**退回原始 key(session-6012f8c2-… 是内部键,给人看的东西)。",
530
+ " if (orbCardTask) orbCardTask.textContent = task && typeof task.label === 'string' ? task.label : ''",
531
+ " if (orbCardList) {",
532
+ " orbCardList.textContent = ''",
533
+ " if (todos.length === 0) {",
534
+ " const empty = document.createElement('div')",
535
+ " empty.className = 'orb-empty'",
536
+ " empty.textContent = working ? '正在执行浏览器操作…' : '暂无任务清单'",
537
+ " orbCardList.append(empty)",
538
+ " } else {",
539
+ " for (const item of todos) {",
540
+ " const state = item && item.status === 'completed' ? 'completed' : item && item.status === 'in_progress' ? 'in_progress' : 'pending'",
541
+ " const row = document.createElement('div')",
542
+ " row.className = 'orb-item ' + state",
543
+ " const mark = document.createElement('span')",
544
+ " mark.className = 'orb-mark'",
545
+ " if (state === 'in_progress') { const spin = document.createElement('span'); spin.className = 'orb-spin'; mark.append(spin) }",
546
+ " else mark.textContent = state === 'completed' ? '✓' : '○'",
547
+ " const text = document.createElement('span')",
548
+ " text.className = 'orb-text'",
549
+ " text.textContent = item && typeof item.content === 'string' ? item.content : ''",
550
+ " row.append(mark, text)",
551
+ " orbCardList.append(row)",
552
+ " }",
553
+ " }",
554
+ " }",
555
+ " if (orbCardFoot) {",
556
+ " const latest = task && typeof task.latestAction === 'string' ? task.latestAction : ''",
557
+ " orbCardFoot.textContent = working && latest !== '' ? '现在执行 · ' + latest : allDone ? '全部完成' : status === 'waiting-user' ? '等待你操作' : status === 'failed' ? '上次执行失败' : ''",
558
+ " orbCardFoot.style.display = orbCardFoot.textContent === '' ? 'none' : ''",
559
+ " }",
560
+ " // 列表重建后卡片高度可能变了(缩略图补丁 5Hz 也会走到这里)—— 开着的话重新贴一次球,",
561
+ " // 否则卡片长高会往下压住球(c1f6291 修的那个现象换了个触发路径)。",
562
+ " try { if (orbCard instanceof HTMLElement && orbCard.classList.contains('open')) placeOrbCard() } catch { /* placeOrbCard 还没定义 */ }",
563
+ " }",
564
+ " // 悬浮球可拖动,位置交给宿主保存(chrome-prefs.json)—— 存在页面里会按源丢。",
565
+ " let orbPos = null",
566
+ " let orbDraggedAt = 0",
567
+ " // 悬浮球**不随页面缩放变大变小**:它的大小是本地 px,宿主那层 zoom=1/缩放 正好抵消掉",
568
+ " // (实测 100% 与 130% 下屏幕上都是 44px)。但坐标有两套 —— 事件与 rect 是页面 px,",
569
+ " // style.left/top 是本地 px —— 混用就会拖动跳、夹取偏、卡片错位。",
570
+ " const orbScale = () => { try { return typeof chromeScale === 'function' ? chromeScale() : 1 } catch { return 1 } }",
571
+ " const clampOrbPos = (x, y) => {",
572
+ " const s = orbScale()",
573
+ " const maxX = Math.max(8, window.innerWidth * s - (orb instanceof HTMLElement ? orb.offsetWidth : 44) - 8)",
574
+ " const maxY = Math.max(8, window.innerHeight * s - (orb instanceof HTMLElement ? orb.offsetHeight : 44) - 8)",
575
+ " return { x: Math.min(Math.max(8, x), maxX), y: Math.min(Math.max(8, y), maxY) }",
576
+ " }",
577
+ " const applyOrbPos = () => {",
578
+ " if (!(orb instanceof HTMLElement)) return",
579
+ " if (orbPos === null) { orb.style.left = ''; orb.style.top = ''; orb.style.right = ''; orb.style.bottom = ''; return }",
580
+ " // 存下来的坐标是在**当时那个窗口**里量出来的:窗口变小、换显示器、恢复最大化之后",
581
+ " // 它可能已经在屏幕外 —— 那就再也拖不回来了(而且位置还会一直存在 chrome-prefs.json 里)。",
582
+ " orbPos = clampOrbPos(orbPos.x, orbPos.y)",
583
+ " orb.style.left = Math.round(orbPos.x) + 'px'",
584
+ " orb.style.top = Math.round(orbPos.y) + 'px'",
585
+ " orb.style.right = 'auto'",
586
+ " orb.style.bottom = 'auto'",
587
+ " }",
588
+ " const emitOrbMove = (x, y) => {",
589
+ " if (bindingToken === '') return",
590
+ " try { const binding = window.__dshBrowserTaskAction; if (typeof binding === 'function') binding(JSON.stringify({ type: 'orb-move', x, y, token: bindingToken })) } catch { /* host unavailable */ }",
591
+ " }",
592
+ " let orbDrag = null",
593
+ " if (orb instanceof HTMLElement) {",
594
+ " orb.addEventListener('pointerdown', event => {",
595
+ " if (event.button !== 0) return",
596
+ " // moved 要比「按下时的起点」:拿 orb.offsetLeft 比的话,它是上一帧刚被 applyOrbPos 写进去的值,",
597
+ " // 于是慢速拖动(每帧 <3px)永远不算拖动 —— 位置不落盘,松手还会误触 click。",
598
+ " const s = orbScale()",
599
+ " orbDrag = { startX: event.clientX, startY: event.clientY, localX: event.clientX * s, localY: event.clientY * s, dx: event.clientX * s - orb.offsetLeft, dy: event.clientY * s - orb.offsetTop, moved: false, pressed: false }",
600
+ " orb.classList.add('dragging')",
601
+ " // 不用 setPointerCapture:窗口级的 pointermove/pointerup 已经够了,而捕获会把后续的",
602
+ " // click 重定向到球本身(标签拖动那条同款教训)。",
603
+ " })",
604
+ " window.addEventListener('pointermove', event => {",
605
+ " // 在窗口外松手收不到 pointerup(pointercancel 也不保证)—— 不看 buttons 的话,",
606
+ " // 鼠标一回到页面,球就会在没按任何键的情况下跟着光标跑。",
607
+ " // 但**不能**见 0 就收尾:合成输入(离屏测试、CDP)的 pointermove 常常不带 buttons,",
608
+ " // 那会把正常拖动也打断。所以只在「这次拖动曾经观察到按下」之后才认这个信号。",
609
+ " if (orbDrag === null) return",
610
+ " if (event.buttons !== 0) orbDrag.pressed = true",
611
+ " else if (orbDrag.pressed) { endOrbDrag(); return }",
612
+ " const s = orbScale()",
613
+ " const spot = clampOrbPos(event.clientX * s - orbDrag.dx, event.clientY * s - orbDrag.dy)",
614
+ " const x = spot.x",
615
+ " const y = spot.y",
616
+ " if (Math.abs(event.clientX * s - orbDrag.localX) > 3 || Math.abs(event.clientY * s - orbDrag.localY) > 3) orbDrag.moved = true",
617
+ " orbPos = { x, y }",
618
+ " applyOrbPos()",
619
+ " if (orbDrag.moved && orbCard instanceof HTMLElement) orbCard.classList.remove('open')",
620
+ " })",
621
+ " const endOrbDrag = () => {",
622
+ " if (orbDrag === null) return",
623
+ " const moved = orbDrag.moved",
624
+ " orbDrag = null",
625
+ " orb.classList.remove('dragging')",
626
+ " if (!moved) return",
627
+ " orbDraggedAt = Date.now()",
628
+ " if (orbPos !== null) emitOrbMove(orbPos.x, orbPos.y)",
629
+ " }",
630
+ " window.addEventListener('pointerup', endOrbDrag)",
631
+ " window.addEventListener('pointercancel', endOrbDrag)",
632
+ " // 在别的窗口/桌面上松手时,失焦是最可靠的信号(那时连 pointerup 都收不到)。",
633
+ " window.addEventListener('blur', endOrbDrag)",
634
+ " // 窗口一变形:已存的坐标要重新夹一次;卡片开着的话它也要跟着球重新定位。",
635
+ " window.addEventListener('resize', () => { applyOrbPos(); if (orbCard instanceof HTMLElement && orbCard.classList.contains('open')) placeOrbCard() })",
636
+ " }",
637
+ " const placeOrbCard = () => {",
638
+ " if (!(orb instanceof HTMLElement) || !(orbCard instanceof HTMLElement)) return",
639
+ " const s = orbScale()",
640
+ " const vw = window.innerWidth * s",
641
+ " const vh = window.innerHeight * s",
642
+ " // 视口比卡片还矮/还窄时(小窗口、分屏),先把卡片压到放得下再量 —— 否则它会",
643
+ " // 被夹在 8..vh-8 之间,正好盖住贴底的球(review 报的 #10)。",
644
+ " orbCard.style.maxHeight = Math.max(120, vh - 24) + 'px'",
645
+ " orbCard.style.width = Math.max(160, Math.min(308, vw - 16)) + 'px'",
646
+ " const box = orb.getBoundingClientRect()",
647
+ " const width = orbCard.offsetWidth > 0 ? orbCard.offsetWidth : 308",
648
+ " const height = orbCard.offsetHeight",
649
+ " const left = Math.min(Math.max(8, box.left * s + (box.width * s) / 2 - width / 2), Math.max(8, window.innerWidth * s - width - 8))",
650
+ " // 球拖到上方时,卡片再往上放就会被夹到视口顶、**盖住球自己** —— 上面放不下就翻到下面。",
651
+ " const gap = 8",
652
+ " const above = box.top * s - gap - height",
653
+ " const top = above >= gap ? above : Math.min(box.bottom * s + gap, Math.max(gap, window.innerHeight * s - height - gap))",
654
+ " orbCard.style.left = Math.round(left) + 'px'",
655
+ " orbCard.style.top = Math.round(top) + 'px'",
656
+ " orbCard.style.right = 'auto'",
657
+ " orbCard.style.bottom = 'auto'",
658
+ " }",
659
+ " const openOrbCard = () => {",
660
+ " if (orbCardTimer !== null) { clearTimeout(orbCardTimer); orbCardTimer = null }",
661
+ " if (orbCard instanceof HTMLElement) { orbCard.classList.add('open'); placeOrbCard() }",
662
+ " }",
663
+ " const closeOrbCardSoon = () => {",
664
+ " if (orbCardTimer !== null) clearTimeout(orbCardTimer)",
665
+ " orbCardTimer = setTimeout(() => { if (orbCard instanceof HTMLElement) orbCard.classList.remove('open') }, 150)",
666
+ " }",
667
+ " if (orb instanceof HTMLElement && orbCard instanceof HTMLElement) {",
668
+ " orb.addEventListener('pointerenter', openOrbCard)",
669
+ " orb.addEventListener('pointerleave', closeOrbCardSoon)",
670
+ " orbCard.addEventListener('pointerenter', openOrbCard)",
671
+ " orbCard.addEventListener('pointerleave', closeOrbCardSoon)",
672
+ " orb.addEventListener('click', () => { if (Date.now() - orbDraggedAt < 350) return; renderOrb(); if (orbCard.classList.contains('open')) orbCard.classList.remove('open'); else openOrbCard() })",
673
+ " }",
449
674
  " const taskControl = root.getElementById('taskControl')",
450
675
  " const taskClose = root.getElementById('taskClose')",
451
676
  " const trailList = root.getElementById('trailList')",
@@ -1181,7 +1406,7 @@ export function buildPageChromeScript(bindingToken = '', surface = 'full') {
1181
1406
  " row.append(thumb, title, meta, recent, failure)",
1182
1407
  " // 复用之后闭包里的 key 会过期 —— 点的时候从 dataset 现读(第 46 轮的教训)。",
1183
1408
  " row.addEventListener('click', () => {",
1184
- " const key = row.dataset.dshTaskKey",
1409
+ " const key = row.dataset.dshKey",
1185
1410
  " if (typeof key === 'string' && key !== '') emitTaskAction(key)",
1186
1411
  " })",
1187
1412
  " return row",
@@ -1209,14 +1434,15 @@ export function buildPageChromeScript(bindingToken = '', surface = 'full') {
1209
1434
  " window.__dshTaskRender = renderTasks",
1210
1435
  " const patchTaskRow = task => {",
1211
1436
  " if (!task || typeof task !== 'object' || typeof task.key !== 'string') return false",
1212
- " const row = [...taskList.children].find(candidate => candidate instanceof HTMLElement && candidate.dataset.dshTaskKey === task.key)",
1437
+ " const row = [...taskList.children].find(candidate => candidate instanceof HTMLElement && candidate.dataset.dshKey === task.key)",
1213
1438
  " if (!(row instanceof HTMLButtonElement)) return false",
1214
1439
  " const version = String(Number.isFinite(task.thumbnailVersion) ? task.thumbnailVersion : 0)",
1215
1440
  " if (row.dataset.dshThumbnailVersion !== version) return false",
1216
1441
  " const status = ['idle', 'running', 'waiting-user', 'failed'].includes(task.status) ? task.status : 'idle'",
1217
1442
  " const control = task.control === 'human' ? 'human' : 'agent'",
1218
1443
  " row.className = 'task-row status-' + status + (task.active ? ' active' : '')",
1219
- " row.disabled = false",
1444
+ " // 与整表渲染同一条规则:当前任务的行点不动(点了也只是切到自己)。",
1445
+ " row.disabled = task.active === true",
1220
1446
  " const state = row.querySelector('[data-dsh-task-state]')",
1221
1447
  " if (state instanceof HTMLElement) state.setAttribute('aria-label', statusText(status) + (task.active ? ',当前任务' : ',后台任务'))",
1222
1448
  " const label = row.querySelector('[data-dsh-task-label]')",
@@ -1565,7 +1791,11 @@ export function buildPageChromeScript(bindingToken = '', surface = 'full') {
1565
1791
  " for (const key of [...taskThumbCache.keys()]) if (!liveKeys.has(key)) taskThumbCache.delete(key)",
1566
1792
  " window.__dshTrail = Array.isArray(message.trail) ? message.trail : []",
1567
1793
  " window.__dshWorkspacePanels = message.panels && typeof message.panels === 'object' ? message.panels : { tasks: false, trail: false }",
1794
+ " const saved = message.orbPosition",
1795
+ " orbPos = saved && typeof saved.x === 'number' && typeof saved.y === 'number' ? { x: saved.x, y: saved.y } : null",
1796
+ " applyOrbPos()",
1568
1797
  " renderTasks()",
1798
+ " renderOrb()",
1569
1799
  " renderTabs()",
1570
1800
  " renderTrail()",
1571
1801
  " applyWorkspacePanels()",
@@ -1599,6 +1829,10 @@ export function buildPageChromeScript(bindingToken = '', surface = 'full') {
1599
1829
  " if (typeof operation.dataUrl === 'string') taskThumbCache.set(operation.key, operation.dataUrl)",
1600
1830
  " const task = tasks.find(candidate => candidate && candidate.key === operation.key)",
1601
1831
  " if (task) { task.thumbnailVersion = operation.version; fullTaskRender = true }",
1832
+ " } else if (operation.op === 'task.todos' && typeof operation.key === 'string') {",
1833
+ " // Agent 的任务清单:定向补丁(不进摘要),悬浮球读它。",
1834
+ " taskTodosCache.set(operation.key, Array.isArray(operation.todos) ? operation.todos : [])",
1835
+ " renderOrb()",
1602
1836
  " } else if (operation.op === 'trail.append' && operation.taskKey === chromeSelectedTaskKey && operation.entry) {",
1603
1837
  " const trail = Array.isArray(window.__dshTrail) ? window.__dshTrail : []",
1604
1838
  " window.__dshTrail = [...trail, operation.entry]",
@@ -1644,6 +1878,7 @@ export function buildPageChromeScript(bindingToken = '', surface = 'full') {
1644
1878
  " window.__dshTasks = tasks",
1645
1879
  " if (fullTaskRender) renderTasks()",
1646
1880
  " else if (taskPatches.length > 0) { renderTaskControl(tasks); for (const task of taskPatches) { if (!patchTaskRow(task)) { renderTasks(); break } } }",
1881
+ " renderOrb()",
1647
1882
  " if (trailReplaced) renderTrail()",
1648
1883
  " else for (const entry of trailAppends) appendTrailEntry(entry)",
1649
1884
  " // 换页也要重算星标:宿主给的标签 url 是**规范化过**的(没有结尾斜杠),而收藏里",
@@ -6,7 +6,7 @@
6
6
  * shell that owns the `BrowserWindow`.
7
7
  * @module dsh-browser-plus/browser-electron
8
8
  */
9
- import type { BrowserChallenge, BrowserClearAuthRequest, BrowserClearAuthResult, BrowserContentRequest, BrowserContentResult, BrowserDragRequest, BrowserDragResult, BrowserPointerResult, BrowserPointerTarget, BrowserExecuteRequest, BrowserExecuteResult, BrowserFillRequest, BrowserFillResult, BrowserHandoffState, BrowserHistoryEntry, BrowserOpenOptions, BrowserOpenRequest, BrowserPressKeyRequest, BrowserProvider, BrowserRefRequest, BrowserScrapeRequest, BrowserScrapeStatus, BrowserScrollIntoViewRequest, BrowserScrollRequest, BrowserScrollResult, BrowserSessionId, BrowserSnapshotResult, BrowserSpaceInfo, BrowserTab, BrowserTaskInfo, BrowserTaskUpdate, BrowserUploadFileRequest, BrowserUploadFileResult, BrowserWaitForRequest, BrowserWaitForResult, ExportedCookie } from '../browser/types.ts';
9
+ import type { BrowserChallenge, BrowserClearAuthRequest, BrowserClearAuthResult, BrowserContentRequest, BrowserContentResult, BrowserDragRequest, BrowserDragResult, BrowserPointerResult, BrowserPointerTarget, BrowserExecuteRequest, BrowserExecuteResult, BrowserFillRequest, BrowserFillResult, BrowserHandoffState, BrowserHistoryEntry, BrowserOpenOptions, BrowserOpenRequest, BrowserPressKeyRequest, BrowserProvider, BrowserRefRequest, BrowserScrapeRequest, BrowserScrapeStatus, BrowserScrollIntoViewRequest, BrowserScrollRequest, BrowserScrollResult, BrowserSessionId, BrowserSnapshotResult, BrowserSpaceInfo, BrowserTab, BrowserTaskInfo, BrowserTaskUpdate, BrowserUploadFileRequest, BrowserUploadFileResult, BrowserPdfRequest, BrowserPdfResult, BrowserHighlightRequest, BrowserHighlightResult, BrowserWaitForRequest, BrowserWaitForResult, BrowserTaskTodo, ExportedCookie } from '../browser/types.ts';
10
10
  /** Stable provider id registered with `ctx.browser`. */
11
11
  export declare const ELECTRON_BROWSER_PROVIDER_ID = "electron";
12
12
  /**
@@ -110,6 +110,8 @@ export interface ElectronBrowserViewHost {
110
110
  getTask?(key: string): Promise<BrowserTaskInfo | undefined>;
111
111
  /** Apply a task status/control update to the visible workspace. */
112
112
  updateTask?(key: string, update: BrowserTaskUpdate): Promise<BrowserTaskInfo | undefined>;
113
+ /** Mirror one task's Agent todo list into the visible workspace (floating orb). */
114
+ setTaskTodos?(key: string, todos: readonly BrowserTaskTodo[]): Promise<void>;
113
115
  }
114
116
  /**
115
117
  * A CDP-capable view handle. This is the subset of Electron's
@@ -159,6 +161,24 @@ export interface ElectronViewHandle {
159
161
  * tokenless copy as a fallback.
160
162
  */
161
163
  reinstallChrome?(): Promise<void>;
164
+ /**
165
+ * Print this view to a PDF and return it base64-encoded. Optional: a host
166
+ * with no native print path omits it, and the provider falls back to CDP.
167
+ *
168
+ * Electron's debugger does NOT implement CDP `Page.printToPDF` (it answers
169
+ * "'Page.printToPDF' wasn't found"), so the PDF path has to go through the
170
+ * host's own `webContents.printToPDF`.
171
+ * @param options - Electron PrintToPDFOptions (pageSize in microns).
172
+ */
173
+ printToPdf?(options: Record<string, unknown>): Promise<{
174
+ readonly base64: string;
175
+ }>;
176
+ /**
177
+ * Raise this view's window (show + restore + focus). Optional: a host that
178
+ * owns no window omits it. Distinct from the provider's own show/switch
179
+ * path, which deliberately leaves a human-selected visible task alone.
180
+ */
181
+ focusWindow?(): Promise<void>;
162
182
  }
163
183
  /**
164
184
  * How the host answers a JS dialog (alert/confirm/prompt).
@@ -252,6 +272,12 @@ export interface CdpEvaluateParams {
252
272
  }
253
273
  /** CDP method for a full-page screenshot capture. */
254
274
  export declare const CDP_PAGE_CAPTURE_SCREENSHOT = "Page.captureScreenshot";
275
+ /** CDP method that renders the current document to PDF. */
276
+ export declare const CDP_PAGE_PRINT_TO_PDF = "Page.printToPDF";
277
+ /** CDP methods that draw the DevTools highlight box without touching the DOM. */
278
+ export declare const CDP_OVERLAY_ENABLE = "Overlay.enable";
279
+ export declare const CDP_OVERLAY_HIGHLIGHT_NODE = "Overlay.highlightNode";
280
+ export declare const CDP_OVERLAY_HIDE_HIGHLIGHT = "Overlay.hideHighlight";
255
281
  /** CDP method for runtime evaluation (the execute path). */
256
282
  export declare const CDP_RUNTIME_EVALUATE = "Runtime.evaluate";
257
283
  /**
@@ -291,6 +317,14 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
291
317
  /** Stable task-key index so callers can recover a session after tool-layer state loss. */
292
318
  private readonly sessionsByTask;
293
319
  private readonly taskStates;
320
+ /**
321
+ * The Agent's todo list per task, kept here as well as in the host.
322
+ *
323
+ * The host's copy is in memory and dies with the host process, and the bridge
324
+ * only pushes on a todo change — so without this cache a host restart left the
325
+ * floating orb with no plan until the Agent happened to write its list again.
326
+ */
327
+ private readonly taskTodos;
294
328
  private readonly httpOnly;
295
329
  private readonly snapshotMaxElements;
296
330
  private readonly contentMaxChars;
@@ -438,7 +472,7 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
438
472
  * Bounds the total wait; a timeout surfaces as BROWSER_WAIT_TIMEOUT.
439
473
  */
440
474
  waitForElement(session: BrowserSessionId, request: BrowserWaitForRequest, signal?: AbortSignal): Promise<BrowserWaitForResult>;
441
- /** Poll one tab until the selector matches. */
475
+ /** Poll one tab until the selector and/or text reaches the requested state. */
442
476
  private waitForElementTab;
443
477
  /** Type into the focused element. */
444
478
  type(session: BrowserSessionId, request: {
@@ -536,6 +570,13 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
536
570
  /** Drop a batch's private tabs (and their views) once the batch is over. */
537
571
  private destroyScrapeTabs;
538
572
  /** Capture the current page, optionally full-page. PNG only (CDP JPEG hangs on Electron 43). */
573
+ /**
574
+ * Draw the DevTools highlight box over the first match of a selector, or clear it.
575
+ * Uses CDP Overlay, so the page DOM is never touched.
576
+ */
577
+ highlight(session: BrowserSessionId, request: BrowserHighlightRequest, signal?: AbortSignal): Promise<BrowserHighlightResult>;
578
+ /** Print the active tab to a PDF file (Chrome's "Save as PDF"). */
579
+ pdf(session: BrowserSessionId, request: BrowserPdfRequest, signal?: AbortSignal): Promise<BrowserPdfResult>;
539
580
  screenshot(session: BrowserSessionId, request?: {
540
581
  readonly fullPage?: boolean;
541
582
  readonly savePath?: string;
@@ -619,6 +660,16 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
619
660
  getTask(session: BrowserSessionId): Promise<BrowserTaskInfo>;
620
661
  /** Apply one visible task state update and mirror it to a supporting host. */
621
662
  updateTask(session: BrowserSessionId, update: BrowserTaskUpdate): Promise<BrowserTaskInfo>;
663
+ /**
664
+ * Mirror one task's Agent todo list into the host so the floating orb can
665
+ * render it. The list is the DSH session projection for that task's session;
666
+ * only this side can read it, and only the host can reach the chrome.
667
+ *
668
+ * Unknown task keys are still stored: an Agent usually writes its plan before
669
+ * it opens a page, and the orb should show that plan the moment the task has a
670
+ * view rather than wait for the next write.
671
+ */
672
+ pushTaskTodos(taskKey: string, todos: readonly BrowserTaskTodo[]): Promise<void>;
622
673
  /** Hand control to the user or return it to Agent-driven actions. */
623
674
  setHandoff(session: BrowserSessionId, state: BrowserHandoffState): Promise<BrowserTaskInfo>;
624
675
  /** Append one operation to the session's history. */
@@ -633,6 +684,13 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
633
684
  * @param seq - the recorded entry's sequence number to replay.
634
685
  */
635
686
  replay(session: BrowserSessionId, seq: number): Promise<void>;
687
+ /**
688
+ * Put the shared window on screen and raise it. With no session yet this
689
+ * opens the default one, which spawns the host and creates the first view —
690
+ * that is what makes the window appear. With a session already open the
691
+ * window exists, so it only has to be raised.
692
+ */
693
+ ensureWindowVisible(): Promise<void>;
636
694
  /** Close the session and destroy all its views. Idempotent. */
637
695
  close(session: BrowserSessionId): Promise<void>;
638
696
  /** Recover the live session associated with a stable task key. */