dsh-plugin-taskflow 0.1.1 → 0.1.2

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
@@ -14,10 +14,16 @@
14
14
  # 从 npm 安装
15
15
  dsh plugin --profile web add dsh-plugin-taskflow
16
16
 
17
+ # 若包管理器因「新版本发布冷却期」装到了 0.1.0(无 dsh.bundle 的旧版),显式指定:
18
+ dsh plugin --profile web add dsh-plugin-taskflow@0.1.1
19
+
17
20
  # 或直接从 GitHub 源码安装
18
21
  dsh plugin --profile web add github:wk-xjd/dsh-plugin-taskflow
19
22
  ```
20
23
 
24
+ `dsh plugin add` 会把本包装成一个 **profile bundle**(自动写入 `dsh.profile.bundles` 并加载
25
+ `cordis.patch.yml`),无需手工编辑 patch 文件。
26
+
21
27
  安装后**刷新页面**(桌面客户端:开发者工具里执行 `location.reload()`,或重启 App);
22
28
  宿主半部更新需重启一次 App。
23
29
 
@@ -66,8 +72,14 @@ node install.mjs --profile web # 装到别的 profile
66
72
 
67
73
  ## 安装细节
68
74
 
69
- 本插件是**树外单插件**:不进 `dsh.profile.bundles`,只在目标 profile 的 `cordis.patch.yml` 里插一行
70
- (`dsh plugin add` 会自动完成这件事)。
75
+ 本包同时是一个 **DSH bundle**:`package.json` 里声明 `dsh.bundle.patch = ./cordis.patch.yml`,
76
+ `dsh plugin --profile <p> add dsh-plugin-taskflow` 会把它写进该 profile 的 `dsh.profile.bundles`:
77
+
78
+ ```json
79
+ {"profile": {"bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", "dsh-plugin-taskflow"]}}
80
+ ```
81
+
82
+ bundle 里的 patch 只有一行 insert:
71
83
 
72
84
  ```yaml
73
85
  - insert:
@@ -75,6 +87,8 @@ node install.mjs --profile web # 装到别的 profile
75
87
  name: 'dsh-plugin-taskflow'
76
88
  ```
77
89
 
90
+ 从源码手动安装(不想走 npm 时)也可以用 `install.mjs` 直接改 profile 的 `cordis.patch.yml`。
91
+
78
92
  ### 生效方式
79
93
 
80
94
  | 改了什么 | 怎么生效 |
package/client/client.js CHANGED
@@ -13,7 +13,7 @@ window.__ModuleLoader__.load({
13
13
  const ID = 'dsh-plugin-taskflow'
14
14
  // Bumped whenever the browser half changes: the host reports the live value
15
15
  // in /taskflow/health, which is how a stale page is detected.
16
- const CLIENT_BUILD = 'c95'
16
+ const CLIENT_BUILD = 'c98'
17
17
  /**
18
18
  * true = shadow DSH's own todo panel (id 'todo', priority -1)
19
19
  * false = render beside it (unique id), so both lists are visible
@@ -206,13 +206,13 @@ window.__ModuleLoader__.load({
206
206
  .tf-chevron { transition: transform .18s ease; }
207
207
  .tf-inline-toggle[data-collapsed="true"] .tf-chevron { transform: rotate(-90deg); }
208
208
  .tf-inline-toggle:hover { opacity: .8; }
209
- .tf-inline-list { margin: 4px 0 0; padding: 0 0 0 1px; list-style: none; display: flex; flex-direction: column; gap: 0; max-height: 150px; overflow-y: auto; overflow-x: hidden; scrollbar-width: thin; scrollbar-color: color-mix(in oklab, currentColor 16%, transparent) transparent; }
209
+ .tf-inline-list { margin: 4px 0 0; padding: 0 0 0 1px; list-style: none; display: flex; flex-direction: column; gap: 4px; max-height: 172px; overflow-y: auto; overflow-x: hidden; scrollbar-width: thin; scrollbar-color: color-mix(in oklab, currentColor 16%, transparent) transparent; }
210
210
  .tf-inline-list::-webkit-scrollbar { width: 6px; height: 0; }
211
211
  .tf-inline-list::-webkit-scrollbar-track { background: transparent; }
212
212
  .tf-inline-list::-webkit-scrollbar-thumb { background: color-mix(in oklab, currentColor 14%, transparent); border-radius: 3px; }
213
213
  .tf-inline-list::-webkit-scrollbar-thumb:hover { background: color-mix(in oklab, currentColor 26%, transparent); }
214
214
  .tf-item { list-style: none; }
215
- .tf-item-btn { position: relative; display: flex; align-items: flex-start; gap: 9px; width: 100%; box-sizing: border-box; padding: 5px 6px; border: 0; border-radius: 11px; background: transparent; color: inherit; font: inherit; font-size: 12.5px; line-height: 1.6; text-align: left; cursor: pointer; }
215
+ .tf-item-btn { position: relative; display: flex; align-items: flex-start; gap: 9px; width: 100%; box-sizing: border-box; padding: 6px 7px; border: 0; border-radius: 11px; background: transparent; color: inherit; font: inherit; font-size: 12.5px; line-height: 1.6; text-align: left; cursor: pointer; }
216
216
  .tf-item-btn:hover, .tf-item-btn[data-active="true"] { background: color-mix(in oklab, currentColor 7%, transparent); }
217
217
  .tf-item-btn[data-delegated="true"] { background: color-mix(in oklab, #8b7cff 10%, transparent); box-shadow: inset 0 0 0 1px color-mix(in oklab, #8b7cff 24%, transparent); }
218
218
  .tf-inline-head[data-state="delegated"] .tf-inline-current { color: #a99bff; opacity: .95; }
@@ -404,12 +404,7 @@ window.__ModuleLoader__.load({
404
404
  setActive(sessionId) {
405
405
  if (this.activeSession === sessionId) return
406
406
  this.activeSession = sessionId
407
- if (sessionId) {
408
- void refresh(sessionId)
409
- startStream(sessionId)
410
- } else {
411
- stopStream()
412
- }
407
+ if (sessionId) void refresh(sessionId)
413
408
  this.emit()
414
409
  },
415
410
  put(sessionId, value) {
@@ -439,74 +434,99 @@ window.__ModuleLoader__.load({
439
434
 
440
435
  let hostAvailable = true
441
436
  /** Live SSE subscription for the active session (null when unavailable). */
442
- let stream = null
443
- let streamSession = null
444
- let streamHealthy = false
445
- let streamRetry = null
446
- let streamFailures = 0
447
-
448
- function stopStream() {
449
- if (stream !== null) {
437
+ /** sessionId → { refs, source, healthy, failures, retry } — one push per open conversation. */
438
+ const streams = new Map()
439
+
440
+ function streamHealthy(sessionId) {
441
+ const entry = streams.get(sessionId)
442
+ return entry ? entry.healthy === true : false
443
+ }
444
+
445
+ function closeStream(sessionId) {
446
+ const entry = streams.get(sessionId)
447
+ if (!entry) return
448
+ if (entry.retry !== null) window.clearTimeout(entry.retry)
449
+ if (entry.source) {
450
450
  try {
451
- stream.close()
451
+ entry.source.close()
452
452
  } catch {
453
453
  // already closed
454
454
  }
455
455
  }
456
- stream = null
457
- streamSession = null
458
- streamHealthy = false
456
+ streams.delete(sessionId)
459
457
  }
460
458
 
461
- /** Subscribe to host pushes; on failure fall back to polling and retry later. */
462
- function startStream(sessionId) {
463
- if (typeof EventSource === 'undefined' || !sessionId) return
464
- if (stream !== null && streamSession === sessionId) return
465
- stopStream()
466
- streamSession = sessionId
459
+ function scheduleRetry(sessionId) {
460
+ const entry = streams.get(sessionId)
461
+ if (!entry || entry.failures > 6 || typeof window === 'undefined') return
462
+ if (entry.retry !== null) window.clearTimeout(entry.retry)
463
+ const delay = Math.min(30000, 2000 * 2 ** (entry.failures - 1))
464
+ entry.retry = window.setTimeout(() => {
465
+ if (!streams.has(sessionId)) return
466
+ entry.retry = null
467
+ openStream(sessionId)
468
+ }, delay)
469
+ }
470
+
471
+ /** Open the push connection for one session (idempotent). */
472
+ function openStream(sessionId) {
473
+ const entry = streams.get(sessionId)
474
+ if (!entry || entry.source || typeof EventSource === 'undefined') return
467
475
  let source
468
476
  try {
469
477
  source = new EventSource(`/taskflow/events?session=${encodeURIComponent(sessionId)}`)
470
478
  } catch {
471
- streamSession = null
479
+ entry.failures += 1
480
+ scheduleRetry(sessionId)
472
481
  return
473
482
  }
474
- stream = source
483
+ entry.source = source
475
484
  source.addEventListener('state', (event) => {
476
485
  try {
477
486
  const data = JSON.parse(event.data)
478
487
  if (!data || data.ok !== true) return
479
- streamHealthy = true
480
- streamFailures = 0
488
+ entry.healthy = true
489
+ entry.failures = 0
481
490
  store.put(sessionId, { ...data, loaded: true, fetchedAt: Date.now() })
482
491
  } catch {
483
492
  // ignore malformed frame
484
493
  }
485
494
  })
486
495
  source.addEventListener('error', () => {
487
- streamHealthy = false
496
+ entry.healthy = false
488
497
  try {
489
498
  source.close()
490
499
  } catch {
491
- // ignore
492
- }
493
- if (stream === source) {
494
- stream = null
495
- streamSession = null
500
+ // already closed
496
501
  }
497
- streamFailures += 1
498
- if (streamRetry !== null) window.clearTimeout(streamRetry)
499
- // Give up after six attempts and stay on polling; a later refresh or
500
- // session switch retries from scratch.
501
- if (streamFailures > 6) return
502
- const delay = Math.min(30000, 2000 * 2 ** (streamFailures - 1))
503
- streamRetry = window.setTimeout(() => {
504
- streamRetry = null
505
- const active = store.activeSession
506
- if (active) startStream(active)
507
- }, delay)
502
+ if (entry.source === source) entry.source = null
503
+ entry.failures += 1
504
+ scheduleRetry(sessionId)
508
505
  })
509
506
  }
507
+
508
+ /**
509
+ * Watch one session while a surface is mounted. Every open conversation gets
510
+ * its own push + refresh, so switching sessions never starves the others.
511
+ */
512
+ function watchSession(sessionId) {
513
+ if (typeof window === 'undefined' || !sessionId) return () => {}
514
+ let entry = streams.get(sessionId)
515
+ if (!entry) {
516
+ entry = { refs: 0, source: null, healthy: false, failures: 0, retry: null }
517
+ streams.set(sessionId, entry)
518
+ }
519
+ entry.refs += 1
520
+ void refresh(sessionId)
521
+ openStream(sessionId)
522
+ return () => {
523
+ const current = streams.get(sessionId)
524
+ if (!current) return
525
+ current.refs -= 1
526
+ if (current.refs <= 0) closeStream(sessionId)
527
+ }
528
+ }
529
+
510
530
  /** Opens the sidebar tab that lists every segment summary. */
511
531
  let openSidebar = null
512
532
  /** Which task's detail popover is open; module scope so remounts keep it. */
@@ -568,12 +588,17 @@ window.__ModuleLoader__.load({
568
588
  const tick = () => {
569
589
  if (poller === null) return
570
590
  const hidden = typeof document !== 'undefined' && document.visibilityState === 'hidden'
571
- const sessionId = store.activeSession
572
- if (!hidden && sessionId) void refresh(sessionId)
573
- const snapshot = sessionId ? store.peek(sessionId) : null
574
- const busy = snapshot ? (typeof snapshot.openTurn === 'number' || snapshot.pending === true) : false
591
+ const watched = [...streams.keys()]
592
+ if (!hidden) {
593
+ for (const sessionId of watched) void refresh(sessionId)
594
+ }
595
+ const busy = watched.some((sessionId) => {
596
+ const snapshot = store.peek(sessionId)
597
+ return snapshot ? (typeof snapshot.openTurn === 'number' || snapshot.pending === true) : false
598
+ })
599
+ const allPushed = watched.length > 0 && watched.every((sessionId) => streamHealthy(sessionId))
575
600
  // A healthy push stream makes polling a pure safety net.
576
- const next = streamHealthy ? 8000 : (busy ? POLL_MS_ACTIVE : POLL_MS_IDLE)
601
+ const next = allPushed ? 8000 : (busy ? POLL_MS_ACTIVE : POLL_MS_IDLE)
577
602
  window.setTimeout(tick, next)
578
603
  }
579
604
  window.setTimeout(tick, POLL_MS_ACTIVE)
@@ -801,6 +826,7 @@ window.__ModuleLoader__.load({
801
826
 
802
827
  React.useEffect(() => {
803
828
  store.setActive(sessionId)
829
+ return watchSession(sessionId)
804
830
  }, [sessionId])
805
831
 
806
832
  // The projection changes exactly when the agent writes the list, so pull
@@ -888,7 +914,7 @@ window.__ModuleLoader__.load({
888
914
  }
889
915
  }, [total, collapsed])
890
916
 
891
- useReportGeometry('dock', rootRef, `${collapsed ? 'collapsed' : 'open'} / ${total} tasks / ${segmentCount} segments / sse:${streamHealthy ? 'live' : 'poll'}${layoutNote ? ` / ${layoutNote}` : ''}`)
917
+ useReportGeometry('dock', rootRef, `${collapsed ? 'collapsed' : 'open'} / ${total} tasks / ${segmentCount} segments / sse:${streamHealthy(sessionId) ? 'live' : 'poll'}${layoutNote ? ` / ${layoutNote}` : ''}`)
892
918
 
893
919
 
894
920
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-taskflow",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "TaskFlow for DeepSeek Harness — a liquid-glass task list above the composer that folds finished rounds into the transcript, with a one-line summary per task and per-turn segment digests.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",