dsh-plugin-desktop-notify 0.6.2 → 0.6.4

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.en.md CHANGED
@@ -44,6 +44,7 @@ Stored in localStorage under `dsh-plugin-desktop-notify.preferences`:
44
44
  | `notifyError` | `true` | Notify when a turn fails |
45
45
  | `notifyViewed` | `false` | Also notify for the session you are looking at |
46
46
  | `badge` | `true` | Show the badge |
47
+ | `orderAttention` | `true` | seat what needs you below your pins (writes the view's order, never the pin state) |
47
48
  | `excludeSubagents` | `true` | Ignore subagent sessions |
48
49
  | `titleComplete` / `titlePending` / `titleError` | empty | Custom titles; empty keeps the built-in copy |
49
50
  | `maxBodyChars` | `140` | Body length limit |
package/README.md CHANGED
@@ -16,6 +16,7 @@
16
16
  - **等待你操作**——工具审批、提问、执行计划确认
17
17
  - **任务失败**——这一轮出错时提示
18
18
  - **说清楚怎么结束的**——正常完成、被拦截、输出到上限、被中止,各自一句话,不把中断说成「已完成」
19
+ - **需要你的排在前面**——运行中、待你审批/回答、出错、未读完成的会话,自动排到**手动置顶的下面、其余会话的上面**,处理完自动放回原位
19
20
  - **小红点**——应用图标上显示「需要你的会话数」,你看过之后才清零
20
21
  - **点击通知**——切到对应会话,而不是只把应用叫到前面
21
22
 
@@ -45,6 +46,7 @@ dsh-plugin-desktop-notify
45
46
  | `notifyViewed` | `false` | 正在看的会话也通知(默认跳过) |
46
47
  | `badge` | `true` | 显示小红点 |
47
48
  | `excludeSubagents` | `true` | 忽略子智能体会话 |
49
+ | `orderAttention` | `true` | 把需要你处理的会话排到手动置顶下面第一条(不改置顶状态;关掉后完全不碰排序) |
48
50
  | `titleComplete` / `titlePending` / `titleError` | 空 | 自定义标题,空 = 用内置文案 |
49
51
  | `maxBodyChars` | `140` | 正文长度上限 |
50
52
 
@@ -60,6 +62,7 @@ dsh-plugin-desktop-notify
60
62
  __DSH_DESKTOP_NOTIFY__.capabilities() // 平台、通知权限、小红点、服务可达性
61
63
  __DSH_DESKTOP_NOTIFY__.state() // 小红点、待处理会话、最近一次通知/点击
62
64
  __DSH_DESKTOP_NOTIFY__.report() // 上面全部 + 窗口焦点,便于排查
65
+ __DSH_DESKTOP_NOTIFY__.orderState() // 当前被排到前面的是哪几条、按什么顺序
63
66
  ```
64
67
 
65
68
 
@@ -72,6 +75,7 @@ __DSH_DESKTOP_NOTIFY__.report() // 上面全部 + 窗口焦点,便于
72
75
  | `POST` | `/desktop-notify/request` | 发一条通知,body:`{"title": "...", "body": "...", "sessionId": "可选"}` |
73
76
  | `GET` | `/desktop-notify/pending` | 渲染层取走待显示的通知(脚本不用调) |
74
77
  | `GET` | `/desktop-notify/turn?sessionId=...` | 渲染层确认这一轮是怎么结束的(脚本不用调) |
78
+ | `GET`/`POST` | `/desktop-notify/pins` | 渲染层读写「插件自己置顶了哪些会话」(脚本不用调) |
75
79
 
76
80
  地址写在 `~/.dsh/desktop-notify.endpoint`,所以调用方不用猜端口:
77
81
 
@@ -107,6 +111,9 @@ urllib.request.urlopen(request, timeout=3).read()
107
111
  ## 已知限制
108
112
 
109
113
  - 重启或刷新窗口时,已经存在的「未读 / 待审批」只恢复小红点,不再补弹横幅
114
+ - 这个排序是插件接管「手动排序」实现的:插件只写「需要你」那几条的顺序,其余会话仍然按最近更新往后排,
115
+ 所以视觉上和「最近更新」一样,只是多了一条需要你的带状区域。代价是侧栏的排序方式会显示成「手动」,
116
+ 而且你把会话拖动排序后,下一次有会话需要你时会被重排覆盖。不想要就 `orderAttention: false`
110
117
 
111
118
  - **Windows**:点通知会切到对应会话,但窗口不一定到前台——`AppUserModelID` 和主进程 focus 通道属于桌面端,渲染层插件做不到
112
119
  - **通知归属**:系统通知中心里显示为 DeepSeek Harness(用的是渲染层原生通知)
package/lib/client.js CHANGED
@@ -30,7 +30,7 @@ window.__ModuleLoader__.load({
30
30
  factory: (require) => {
31
31
  const NS = "dsh-plugin-desktop-notify"
32
32
  /** Reported by capabilities(); keep in step with package.json (cross-machine support). */
33
- const VERSION = "0.6.2"
33
+ const VERSION = "0.6.4"
34
34
  const PREFERENCES_KEY = `${NS}:preferences`
35
35
 
36
36
  /** Defaults; override per field with the localStorage JSON described in README. */
@@ -42,6 +42,8 @@ window.__ModuleLoader__.load({
42
42
  notifyViewed: false,
43
43
  badge: true,
44
44
  excludeSubagents: true,
45
+ // Seat what needs you directly below the manual pins in its group.
46
+ orderAttention: true,
45
47
  // Empty means "use the built-in copy for the active UI language".
46
48
  titleComplete: "",
47
49
  titlePending: "",
@@ -141,6 +143,9 @@ window.__ModuleLoader__.load({
141
143
  */
142
144
  const TURN_KIND_PATH = "/desktop-notify/turn"
143
145
  const TURN_LOOKUP_TIMEOUT_MS = 800
146
+ /** The sidebar's flat-list order key, and how long a tier change waits before it is written. */
147
+ const FLAT_ORDER_KEY = "__flat_session_order__"
148
+ const ORDER_SETTLE_MS = 600
144
149
 
145
150
  /** Ask the Host how one turn ended; undefined when it cannot answer in time. */
146
151
  function lookupTurnKind(sessionId) {
@@ -310,6 +315,95 @@ window.__ModuleLoader__.load({
310
315
  /** sessionId -> ms timestamp when the current turn was first seen running. */
311
316
  const turnStartedAt = new Map()
312
317
  let lastBadge
318
+ /**
319
+ * The sessions that need you, in the order they started needing you.
320
+ * FIFO on purpose: a new arrival joins the bottom of the tier instead of
321
+ * displacing whoever was already in it, so a few running turns can never
322
+ * shuffle the sidebar under the cursor.
323
+ */
324
+ let tier = []
325
+ /** One coalesced write per settled tier, never one per status tick. */
326
+ let tierTimer
327
+ /** Whether an order was ever written, so an empty tier can clear it once and then stay quiet. */
328
+ let hasWrittenOrder = false
329
+ /** The last order written per account key; an unchanged tier touches nothing. */
330
+ const lastOrderByKey = new Map()
331
+
332
+ /** The sidebar's workspace snapshot, or undefined on a build without the service. */
333
+ function workspaceSnapshot() {
334
+ try {
335
+ return ctx.uiWorkspace?.workspaces?.list?.getSnapshot?.()
336
+ } catch {
337
+ return undefined
338
+ }
339
+ }
340
+
341
+ /** True when two id arrays are identical, so a no-op sync never reorders anything. */
342
+ function sameOrder(left, right) {
343
+ if (left.length !== right.length) return false
344
+ for (let index = 0; index < left.length; index += 1) if (left[index] !== right[index]) return false
345
+ return true
346
+ }
347
+
348
+ /** Recompute the tier; only a membership change schedules a write. */
349
+ function updateTier(wanted) {
350
+ const next = tier.filter((sessionId) => wanted.has(sessionId))
351
+ for (const sessionId of wanted) if (!next.includes(sessionId)) next.push(sessionId)
352
+ if (sameOrder(next, tier)) return
353
+ tier = next
354
+ scheduleOrderWrite()
355
+ }
356
+
357
+ /** Coalesce a burst of status ticks into one write. */
358
+ function scheduleOrderWrite() {
359
+ if (tierTimer !== undefined) return
360
+ tierTimer = setTimeout(() => {
361
+ tierTimer = undefined
362
+ writeOrder()
363
+ }, ORDER_SETTLE_MS)
364
+ }
365
+
366
+ /**
367
+ * Seat the tier directly below the manual pins in every group. Only the
368
+ * tier ids are written, so everything else keeps falling back to recency:
369
+ * the result reads like the "most recent" sort with a needs-you band.
370
+ */
371
+ function writeOrder() {
372
+ const view = ctx.uiWorkspace?.view
373
+ if (typeof view?.setSessionOrder !== "function") return
374
+ const snapshot = workspaceSnapshot()
375
+ const list = ctx.sessions.list.getSnapshot()
376
+ if (snapshot === undefined || list?.phase !== "ready") return
377
+ const archived = new Set(Array.isArray(snapshot.archivedSessionIds) ? snapshot.archivedSessionIds : [])
378
+ const live = tier.filter((sessionId) => !archived.has(sessionId))
379
+ const active = preferences.orderAttention && live.length > 0
380
+ if (!active && !hasWrittenOrder) return
381
+ const accounted = new Set()
382
+ const groups = []
383
+ for (const item of Array.isArray(snapshot.items) ? snapshot.items : []) {
384
+ const memberIds = Array.isArray(item?.sessionIds) ? item.sessionIds : []
385
+ for (const sessionId of memberIds) accounted.add(sessionId)
386
+ groups.push([item?.workspaceId ?? "", memberIds])
387
+ }
388
+ const ids = Array.isArray(list.ids) ? list.ids : []
389
+ const known = (sessionId) => list.byId?.[sessionId] !== undefined
390
+ groups.push(["", ids.filter((sessionId) => known(sessionId) && !accounted.has(sessionId))])
391
+ groups.push([FLAT_ORDER_KEY, ids.filter(known)])
392
+ for (const [key, memberIds] of groups) {
393
+ const members = new Set(memberIds)
394
+ const order = active ? live.filter((sessionId) => members.has(sessionId)) : []
395
+ const signature = order.join("\n")
396
+ if (lastOrderByKey.get(key) === signature) continue
397
+ try {
398
+ view.setSessionOrder(key, order, {})
399
+ lastOrderByKey.set(key, signature)
400
+ } catch {
401
+ /* a view store that refuses the write keeps its own order */
402
+ }
403
+ }
404
+ hasWrittenOrder = active
405
+ }
406
+
313
407
  /** The last banner attempt and its outcome — Windows has several ways to swallow it. */
314
408
  let lastNotify
315
409
  /** The last banner click and what it managed to do — Windows/macOS differ here. */
@@ -551,6 +645,8 @@ window.__ModuleLoader__.load({
551
645
  const statuses = ctx.uiSession.sessionStatus.getSnapshot()
552
646
  const summaries = ctx.sessions.list.getSnapshot().byId ?? {}
553
647
  const seen = new Set()
648
+ /** Sessions that should sit at the top of their group. */
649
+ const wanted = new Set()
554
650
 
555
651
  for (const [sessionId, status] of statuses) {
556
652
  const summary = summaries[sessionId]
@@ -592,8 +688,8 @@ window.__ModuleLoader__.load({
592
688
  turnStartedAt.delete(sessionId)
593
689
  const duration = started === undefined ? "" : durationText(Date.now() - started, languageOf())
594
690
  // Watching it finish is only worth a banner when the user opted in.
595
- const wanted = preferences.notifyComplete && (looking ? preferences.notifyViewed : true)
596
- if (wanted) deliverCompletion(sessionId, table, name, duration)
691
+ const shouldNotify = preferences.notifyComplete && (looking ? preferences.notifyViewed : true)
692
+ if (shouldNotify) deliverCompletion(sessionId, table, name, duration)
597
693
  }
598
694
 
599
695
  if (looking) {
@@ -620,6 +716,13 @@ window.__ModuleLoader__.load({
620
716
  })
621
717
  }
622
718
 
719
+ // The tier follows the same facts the banner and badge use, plus a
720
+ // running turn: anything that would make you look belongs on top.
721
+ const current = attention.get(sessionId) ?? {}
722
+ if (!looking && (running || current.completed === true || current.pendingKind !== undefined || current.error !== undefined)) {
723
+ wanted.add(sessionId)
724
+ }
725
+
623
726
  previous.set(sessionId, { running, completed, pendingKind })
624
727
  }
625
728
 
@@ -628,6 +731,7 @@ window.__ModuleLoader__.load({
628
731
  previous.delete(sessionId)
629
732
  turnStartedAt.delete(sessionId)
630
733
  }
734
+ updateTier(wanted)
631
735
  applyBadge()
632
736
  }
633
737
 
@@ -762,10 +866,25 @@ window.__ModuleLoader__.load({
762
866
  badge: lastBadge ?? 0,
763
867
  attention: [...attention.entries()].map(([sessionId, entry]) => ({ sessionId, ...entry })),
764
868
  tracked: [...previous.keys()],
869
+ order: [...tier],
765
870
  lastNotify: lastNotify ?? null,
766
871
  lastClick: lastClick ?? null,
767
872
  }
768
873
  },
874
+ /** Which sessions the plugin seats under the pins, and in what order. */
875
+ orderState() {
876
+ return {
877
+ enabled: preferences.orderAttention,
878
+ tier: [...tier],
879
+ written: Object.fromEntries([...lastOrderByKey].map(([key, value]) => [key, value === "" ? 0 : value.split("\n").length])),
880
+ }
881
+ },
882
+ /** Drop the whole tier; the next settle clears it from the sidebar. */
883
+ clearOrder() {
884
+ tier = []
885
+ scheduleOrderWrite()
886
+ return this.orderState()
887
+ },
769
888
  simulateComplete(sessionId = "desktop-notify-simulation") {
770
889
  const table = copy(languageOf())
771
890
  setAttention(sessionId, { completed: true })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-desktop-notify",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "type": "module",
5
5
  "description": "Desktop notifications and a Dock/taskbar badge for DeepSeek Harness: a finished turn, a pending approval or question, and a failure, with click-through back to the session",
6
6
  "license": "MIT",
@@ -58,5 +58,8 @@
58
58
  },
59
59
  "scripts": {
60
60
  "test": "node test/client.test.mjs && node test/host.test.mjs /tmp/dsh-notify-host-test.log"
61
+ },
62
+ "publishConfig": {
63
+ "access": "public"
61
64
  }
62
65
  }