dsh-plugin-desktop-notify 0.6.3 → 0.6.5
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 +1 -1
- package/README.md +7 -6
- package/index.js +1 -55
- package/lib/client.js +116 -141
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -44,7 +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
|
-
| `
|
|
47
|
+
| `orderAttention` | `true` | seat what needs you below your pins (writes the view's order, never the pin state) |
|
|
48
48
|
| `excludeSubagents` | `true` | Ignore subagent sessions |
|
|
49
49
|
| `titleComplete` / `titlePending` / `titleError` | empty | Custom titles; empty keeps the built-in copy |
|
|
50
50
|
| `maxBodyChars` | `140` | Body length limit |
|
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
- **等待你操作**——工具审批、提问、执行计划确认
|
|
17
17
|
- **任务失败**——这一轮出错时提示
|
|
18
18
|
- **说清楚怎么结束的**——正常完成、被拦截、输出到上限、被中止,各自一句话,不把中断说成「已完成」
|
|
19
|
-
-
|
|
19
|
+
- **还在进行的排在前面**——正在跑、等你审批/回答、刚出错 的会话排到**手动置顶的下面、其余会话的上面**,处理完自动放回原位;完成未读只进小红点,不搬动列表
|
|
20
20
|
- **小红点**——应用图标上显示「需要你的会话数」,你看过之后才清零
|
|
21
21
|
- **点击通知**——切到对应会话,而不是只把应用叫到前面
|
|
22
22
|
|
|
@@ -46,7 +46,7 @@ dsh-plugin-desktop-notify
|
|
|
46
46
|
| `notifyViewed` | `false` | 正在看的会话也通知(默认跳过) |
|
|
47
47
|
| `badge` | `true` | 显示小红点 |
|
|
48
48
|
| `excludeSubagents` | `true` | 忽略子智能体会话 |
|
|
49
|
-
| `
|
|
49
|
+
| `orderAttention` | `true` | 把仍在进行中的会话排到手动置顶下面(不改置顶状态;关掉后完全不碰排序) |
|
|
50
50
|
| `titleComplete` / `titlePending` / `titleError` | 空 | 自定义标题,空 = 用内置文案 |
|
|
51
51
|
| `maxBodyChars` | `140` | 正文长度上限 |
|
|
52
52
|
|
|
@@ -62,7 +62,7 @@ dsh-plugin-desktop-notify
|
|
|
62
62
|
__DSH_DESKTOP_NOTIFY__.capabilities() // 平台、通知权限、小红点、服务可达性
|
|
63
63
|
__DSH_DESKTOP_NOTIFY__.state() // 小红点、待处理会话、最近一次通知/点击
|
|
64
64
|
__DSH_DESKTOP_NOTIFY__.report() // 上面全部 + 窗口焦点,便于排查
|
|
65
|
-
__DSH_DESKTOP_NOTIFY__.
|
|
65
|
+
__DSH_DESKTOP_NOTIFY__.orderState() // 当前被排到前面的是哪几条、按什么顺序
|
|
66
66
|
```
|
|
67
67
|
|
|
68
68
|
|
|
@@ -111,9 +111,10 @@ urllib.request.urlopen(request, timeout=3).read()
|
|
|
111
111
|
## 已知限制
|
|
112
112
|
|
|
113
113
|
- 重启或刷新窗口时,已经存在的「未读 / 待审批」只恢复小红点,不再补弹横幅
|
|
114
|
-
-
|
|
115
|
-
|
|
116
|
-
|
|
114
|
+
- 这个排序是插件接管「手动排序」实现的:插件只写「需要你」那几条的顺序,其余会话仍然按最近更新往后排,
|
|
115
|
+
所以视觉上和「最近更新」一样,只是多了一条仍在进行中的带状区域。切换窗口焦点不会触发重排。
|
|
116
|
+
代价是侧栏的排序方式会显示成「手动」,
|
|
117
|
+
而且你把会话拖动排序后,下一次有会话需要你时会被重排覆盖。不想要就 `orderAttention: false`
|
|
117
118
|
|
|
118
119
|
- **Windows**:点通知会切到对应会话,但窗口不一定到前台——`AppUserModelID` 和主进程 focus 通道属于桌面端,渲染层插件做不到
|
|
119
120
|
- **通知归属**:系统通知中心里显示为 DeepSeek Harness(用的是渲染层原生通知)
|
package/index.js
CHANGED
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
|
|
17
17
|
import { execFile, spawn } from "node:child_process"
|
|
18
18
|
import { randomBytes } from "node:crypto"
|
|
19
|
-
import { readFileSync } from "node:fs"
|
|
20
19
|
import { appendFile, mkdir, rm, writeFile } from "node:fs/promises"
|
|
21
20
|
import { homedir } from "node:os"
|
|
22
21
|
import { basename, dirname, join } from "node:path"
|
|
@@ -48,8 +47,6 @@ const DEFAULTS = {
|
|
|
48
47
|
api: true,
|
|
49
48
|
/** Where the endpoint URL is written for those tools. */
|
|
50
49
|
endpointPath: "~/.dsh/desktop-notify.endpoint",
|
|
51
|
-
/** Where the sessions this plugin pinned are remembered across window lives. */
|
|
52
|
-
pinsPath: "~/.dsh/desktop-notify-pins.json",
|
|
53
50
|
/** Longest accepted request body. */
|
|
54
51
|
apiBodyLimit: 8192,
|
|
55
52
|
/** How many unread requests the interface keeps. */
|
|
@@ -68,7 +65,6 @@ const TURN_END_BODIES = {
|
|
|
68
65
|
const API_REQUEST_PATH = "/desktop-notify/request"
|
|
69
66
|
const API_PENDING_PATH = "/desktop-notify/pending"
|
|
70
67
|
const API_TURN_PATH = "/desktop-notify/turn"
|
|
71
|
-
const API_PINS_PATH = "/desktop-notify/pins"
|
|
72
68
|
|
|
73
69
|
/** Read one JSON request body, bounded by `limit` bytes. */
|
|
74
70
|
function readBody(request, limit) {
|
|
@@ -241,37 +237,6 @@ function mountInterface(ctx, config, state) {
|
|
|
241
237
|
answer(response, 200, entry === undefined ? {} : { kind: entry.kind, at: entry.at })
|
|
242
238
|
}
|
|
243
239
|
|
|
244
|
-
/**
|
|
245
|
-
* Remember which sessions this plugin pinned. The renderer's origin (and so
|
|
246
|
-
* its localStorage) changes with every launch, while the sidebar pins live
|
|
247
|
-
* on the Host; this file is what lets a restart release the pins it owns
|
|
248
|
-
* instead of mistaking them for the user's own.
|
|
249
|
-
*/
|
|
250
|
-
const pins = (request, response) => {
|
|
251
|
-
if (rejected(request, response, false)) return
|
|
252
|
-
if (request.method === "GET") {
|
|
253
|
-
answer(response, 200, { ids: [...state.pinnedByPlugin] })
|
|
254
|
-
return
|
|
255
|
-
}
|
|
256
|
-
if (request.method !== "POST") {
|
|
257
|
-
answer(response, 405, { ok: false, error: "use GET or POST" })
|
|
258
|
-
return
|
|
259
|
-
}
|
|
260
|
-
void readBody(request, Math.max(1024, config.apiBodyLimit))
|
|
261
|
-
.then((payload) => {
|
|
262
|
-
const ids = Array.isArray(payload?.ids) ? payload.ids.filter((id) => typeof id === "string" && id !== "") : []
|
|
263
|
-
state.pinnedByPlugin = new Set(ids.slice(0, Math.max(1, config.maxTrackedSessions)))
|
|
264
|
-
const path = expandHome(config.pinsPath)
|
|
265
|
-
if (path !== "") {
|
|
266
|
-
void mkdir(dirname(path), { recursive: true })
|
|
267
|
-
.then(() => writeFile(path, `${JSON.stringify({ ids: [...state.pinnedByPlugin] })}\n`, "utf8"))
|
|
268
|
-
.catch(() => {})
|
|
269
|
-
}
|
|
270
|
-
answer(response, 200, { ok: true, tracked: state.pinnedByPlugin.size })
|
|
271
|
-
})
|
|
272
|
-
.catch((error) => answer(response, 400, { ok: false, error: String(error?.message ?? error) }))
|
|
273
|
-
}
|
|
274
|
-
|
|
275
240
|
let disposers
|
|
276
241
|
try {
|
|
277
242
|
disposers = [
|
|
@@ -292,13 +257,6 @@ function mountInterface(ctx, config, state) {
|
|
|
292
257
|
answer(response, 500, {})
|
|
293
258
|
}
|
|
294
259
|
} }),
|
|
295
|
-
server.register({ kind: "exact", path: API_PINS_PATH, handler: (request, response) => {
|
|
296
|
-
try {
|
|
297
|
-
pins(request, response)
|
|
298
|
-
} catch {
|
|
299
|
-
answer(response, 500, { ok: false })
|
|
300
|
-
}
|
|
301
|
-
} }),
|
|
302
260
|
]
|
|
303
261
|
} catch (error) {
|
|
304
262
|
void noteToLog(config.logPath, `interface: cannot register routes: ${String(error)}`)
|
|
@@ -449,19 +407,7 @@ function runDetached(command, args) {
|
|
|
449
407
|
export function apply(ctx, rawConfig) {
|
|
450
408
|
const config = resolveConfig(rawConfig)
|
|
451
409
|
/** The newest turn-end kind per session, read by the renderer over the local interface. */
|
|
452
|
-
const state = { lastTurnEnds: new Map()
|
|
453
|
-
// Seeded synchronously: the renderer asks for it as soon as it applies.
|
|
454
|
-
try {
|
|
455
|
-
const pinsPath = expandHome(config.pinsPath)
|
|
456
|
-
if (pinsPath !== "") {
|
|
457
|
-
const remembered = JSON.parse(readFileSync(pinsPath, "utf8"))
|
|
458
|
-
if (Array.isArray(remembered?.ids)) {
|
|
459
|
-
for (const id of remembered.ids) if (typeof id === "string" && id !== "") state.pinnedByPlugin.add(id)
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
} catch {
|
|
463
|
-
/* no remembered pins yet */
|
|
464
|
-
}
|
|
410
|
+
const state = { lastTurnEnds: new Map() }
|
|
465
411
|
mountInterface(ctx, config, state)
|
|
466
412
|
|
|
467
413
|
const logPath = expandHome(config.logPath)
|
package/lib/client.js
CHANGED
|
@@ -30,10 +30,8 @@ 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.
|
|
33
|
+
const VERSION = "0.6.5"
|
|
34
34
|
const PREFERENCES_KEY = `${NS}:preferences`
|
|
35
|
-
/** Sessions this plugin pinned itself; a reload reads it back so it never drops a user's own pin. */
|
|
36
|
-
const PINNED_KEY = `${NS}:pinned`
|
|
37
35
|
|
|
38
36
|
/** Defaults; override per field with the localStorage JSON described in README. */
|
|
39
37
|
const DEFAULTS = {
|
|
@@ -44,8 +42,8 @@ window.__ModuleLoader__.load({
|
|
|
44
42
|
notifyViewed: false,
|
|
45
43
|
badge: true,
|
|
46
44
|
excludeSubagents: true,
|
|
47
|
-
//
|
|
48
|
-
|
|
45
|
+
// Seat what needs you directly below the manual pins in its group.
|
|
46
|
+
orderAttention: true,
|
|
49
47
|
// Empty means "use the built-in copy for the active UI language".
|
|
50
48
|
titleComplete: "",
|
|
51
49
|
titlePending: "",
|
|
@@ -145,8 +143,9 @@ window.__ModuleLoader__.load({
|
|
|
145
143
|
*/
|
|
146
144
|
const TURN_KIND_PATH = "/desktop-notify/turn"
|
|
147
145
|
const TURN_LOOKUP_TIMEOUT_MS = 800
|
|
148
|
-
/**
|
|
149
|
-
const
|
|
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
|
|
150
149
|
|
|
151
150
|
/** Ask the Host how one turn ended; undefined when it cannot answer in time. */
|
|
152
151
|
function lookupTurnKind(sessionId) {
|
|
@@ -198,18 +197,6 @@ window.__ModuleLoader__.load({
|
|
|
198
197
|
return preferences
|
|
199
198
|
}
|
|
200
199
|
|
|
201
|
-
/** The session ids this plugin pinned in an earlier window life. */
|
|
202
|
-
function readPinnedIds() {
|
|
203
|
-
try {
|
|
204
|
-
const raw = storage()?.getItem(PINNED_KEY)
|
|
205
|
-
if (typeof raw !== "string" || raw === "") return []
|
|
206
|
-
const parsed = JSON.parse(raw)
|
|
207
|
-
return Array.isArray(parsed) ? parsed.filter((id) => typeof id === "string") : []
|
|
208
|
-
} catch {
|
|
209
|
-
return []
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
200
|
/** Collapse whitespace and cut to the body budget. */
|
|
214
201
|
function bodyText(text, maxChars) {
|
|
215
202
|
const collapsed = String(text).replace(/\s+/gu, " ").trim()
|
|
@@ -328,128 +315,101 @@ window.__ModuleLoader__.load({
|
|
|
328
315
|
/** sessionId -> ms timestamp when the current turn was first seen running. */
|
|
329
316
|
const turnStartedAt = new Map()
|
|
330
317
|
let lastBadge
|
|
331
|
-
/** Sessions this plugin pinned itself, so unpinning never touches the user's own pins. */
|
|
332
|
-
const selfPinned = new Set(readPinnedIds())
|
|
333
|
-
/** Whether the Host has answered with its remembered pins; nothing is pinned before that. */
|
|
334
|
-
let pinsReady = false
|
|
335
|
-
/** The most recent wanted set, so hydration can reconcile the moment it lands. */
|
|
336
|
-
let lastWanted = new Set()
|
|
337
|
-
|
|
338
318
|
/**
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
*
|
|
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.
|
|
342
323
|
*/
|
|
343
|
-
|
|
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() {
|
|
344
334
|
try {
|
|
345
|
-
|
|
346
|
-
} catch {
|
|
347
|
-
/* a storage-less renderer keeps the set for this life only */
|
|
348
|
-
}
|
|
349
|
-
if (typeof fetch !== "function") return
|
|
350
|
-
try {
|
|
351
|
-
void fetch(PINS_PATH, {
|
|
352
|
-
method: "POST",
|
|
353
|
-
headers: { "content-type": "application/json" },
|
|
354
|
-
body: JSON.stringify({ ids: [...selfPinned] }),
|
|
355
|
-
}).catch(() => {})
|
|
335
|
+
return ctx.uiWorkspace?.workspaces?.list?.getSnapshot?.()
|
|
356
336
|
} catch {
|
|
357
|
-
|
|
337
|
+
return undefined
|
|
358
338
|
}
|
|
359
339
|
}
|
|
360
340
|
|
|
361
|
-
/**
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
async function hydratePins() {
|
|
367
|
-
if (typeof fetch !== "function") {
|
|
368
|
-
pinsReady = true
|
|
369
|
-
return
|
|
370
|
-
}
|
|
371
|
-
try {
|
|
372
|
-
const response = await fetch(PINS_PATH, { headers: { accept: "application/json" }, cache: "no-store" })
|
|
373
|
-
if (response.ok) {
|
|
374
|
-
const payload = await response.json()
|
|
375
|
-
if (Array.isArray(payload?.ids)) {
|
|
376
|
-
for (const sessionId of payload.ids) if (typeof sessionId === "string" && sessionId !== "") selfPinned.add(sessionId)
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
} catch {
|
|
380
|
-
/* no Host half: this life reconciles from localStorage alone */
|
|
381
|
-
}
|
|
382
|
-
pinsReady = true
|
|
383
|
-
reconcilePins(lastWanted)
|
|
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
|
|
384
346
|
}
|
|
385
347
|
|
|
386
|
-
/**
|
|
387
|
-
function
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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()
|
|
393
355
|
}
|
|
394
356
|
|
|
395
|
-
/**
|
|
396
|
-
function
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
if (pending !== null && typeof pending === "object" && typeof pending.catch === "function") pending.catch(() => {})
|
|
403
|
-
} catch {
|
|
404
|
-
/* the view keeps its order */
|
|
405
|
-
}
|
|
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)
|
|
406
364
|
}
|
|
407
365
|
|
|
408
366
|
/**
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
* the
|
|
412
|
-
* never a session the user pinned by hand, and never one this plugin did
|
|
413
|
-
* not pin in the first place.
|
|
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.
|
|
414
370
|
*/
|
|
415
|
-
function
|
|
416
|
-
|
|
417
|
-
if (
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
+
// The workspace registry hydrates separately; writing before it is ready
|
|
378
|
+
// would file every session under "" and then rewrite them a moment later.
|
|
379
|
+
if (snapshot.phase !== "ready" || snapshot.state === "loading") return
|
|
380
|
+
const archived = new Set(Array.isArray(snapshot.archivedSessionIds) ? snapshot.archivedSessionIds : [])
|
|
381
|
+
const live = tier.filter((sessionId) => !archived.has(sessionId))
|
|
382
|
+
const active = preferences.orderAttention && live.length > 0
|
|
383
|
+
if (!active && !hasWrittenOrder) return
|
|
384
|
+
const accounted = new Set()
|
|
385
|
+
const groups = []
|
|
386
|
+
for (const item of Array.isArray(snapshot.items) ? snapshot.items : []) {
|
|
387
|
+
const memberIds = Array.isArray(item?.sessionIds) ? item.sessionIds : []
|
|
388
|
+
for (const sessionId of memberIds) accounted.add(sessionId)
|
|
389
|
+
groups.push([item?.workspaceId ?? "", memberIds])
|
|
390
|
+
}
|
|
391
|
+
const ids = Array.isArray(list.ids) ? list.ids : []
|
|
392
|
+
const known = (sessionId) => list.byId?.[sessionId] !== undefined
|
|
393
|
+
groups.push(["", ids.filter((sessionId) => known(sessionId) && !accounted.has(sessionId))])
|
|
394
|
+
groups.push([FLAT_ORDER_KEY, ids.filter(known)])
|
|
395
|
+
for (const [key, memberIds] of groups) {
|
|
396
|
+
const members = new Set(memberIds)
|
|
397
|
+
const order = active ? live.filter((sessionId) => members.has(sessionId)) : []
|
|
398
|
+
const signature = order.join("\n")
|
|
399
|
+
// Never touch a group this plugin has not seated before: writing an
|
|
400
|
+
// empty order there would wipe the user's own drag arrangement.
|
|
401
|
+
if (lastOrderByKey.get(key) === signature) continue
|
|
402
|
+
if (signature === "" && !lastOrderByKey.has(key)) continue
|
|
403
|
+
try {
|
|
404
|
+
view.setSessionOrder(key, order, {})
|
|
405
|
+
lastOrderByKey.set(key, signature)
|
|
406
|
+
} catch {
|
|
407
|
+
/* a view store that refuses the write keeps its own order */
|
|
424
408
|
}
|
|
425
|
-
if (changed) persistPins()
|
|
426
|
-
return
|
|
427
409
|
}
|
|
428
|
-
|
|
429
|
-
if (snapshot === undefined) return
|
|
430
|
-
const pinned = new Set(Array.isArray(snapshot.pinnedSessionIds) ? snapshot.pinnedSessionIds : [])
|
|
431
|
-
// An archived session is out of the tree anyway, and pinning it is not
|
|
432
|
-
// this plugin's business.
|
|
433
|
-
const archived = new Set(Array.isArray(snapshot.archivedSessionIds) ? snapshot.archivedSessionIds : [])
|
|
434
|
-
for (const sessionId of wanted) {
|
|
435
|
-
if (archived.has(sessionId)) continue
|
|
436
|
-
if (selfPinned.has(sessionId) || pinned.has(sessionId)) continue
|
|
437
|
-
selfPinned.add(sessionId)
|
|
438
|
-
changed = true
|
|
439
|
-
callPin(sessionId, true)
|
|
440
|
-
}
|
|
441
|
-
for (const sessionId of [...selfPinned]) {
|
|
442
|
-
if (wanted.has(sessionId) && !archived.has(sessionId)) continue
|
|
443
|
-
selfPinned.delete(sessionId)
|
|
444
|
-
changed = true
|
|
445
|
-
callPin(sessionId, false)
|
|
446
|
-
}
|
|
447
|
-
if (changed) persistPins()
|
|
410
|
+
hasWrittenOrder = active
|
|
448
411
|
}
|
|
449
412
|
|
|
450
|
-
// Ask the Host which pins an earlier launch left; nothing is pinned until it answers.
|
|
451
|
-
void hydratePins()
|
|
452
|
-
|
|
453
413
|
/** The last banner attempt and its outcome — Windows has several ways to swallow it. */
|
|
454
414
|
let lastNotify
|
|
455
415
|
/** The last banner click and what it managed to do — Windows/macOS differ here. */
|
|
@@ -762,10 +722,14 @@ window.__ModuleLoader__.load({
|
|
|
762
722
|
})
|
|
763
723
|
}
|
|
764
724
|
|
|
765
|
-
//
|
|
766
|
-
//
|
|
725
|
+
// Only what is still in flight is seated: a running turn, a question
|
|
726
|
+
// waiting on you, or a failure. An unread completion stays in the
|
|
727
|
+
// badge instead — promoting it would move the list every time you
|
|
728
|
+
// read a session, and it is exactly what made the band churn.
|
|
729
|
+
// Focus is deliberately not part of this: a running turn keeps its
|
|
730
|
+
// seat while you tab away, so the band never reshuffles on blur.
|
|
767
731
|
const current = attention.get(sessionId) ?? {}
|
|
768
|
-
if (
|
|
732
|
+
if (running || pendingKind !== undefined || current.error !== undefined) {
|
|
769
733
|
wanted.add(sessionId)
|
|
770
734
|
}
|
|
771
735
|
|
|
@@ -777,7 +741,7 @@ window.__ModuleLoader__.load({
|
|
|
777
741
|
previous.delete(sessionId)
|
|
778
742
|
turnStartedAt.delete(sessionId)
|
|
779
743
|
}
|
|
780
|
-
|
|
744
|
+
updateTier(wanted)
|
|
781
745
|
applyBadge()
|
|
782
746
|
}
|
|
783
747
|
|
|
@@ -869,6 +833,21 @@ window.__ModuleLoader__.load({
|
|
|
869
833
|
}
|
|
870
834
|
}, "dsh-plugin-desktop-notify: badge release")
|
|
871
835
|
|
|
836
|
+
/* Give the sidebar back the order this plugin took when it unloads. */
|
|
837
|
+
ctx.effect(() => () => {
|
|
838
|
+
const view = ctx.uiWorkspace?.view
|
|
839
|
+
if (typeof view?.setSessionOrder !== "function") return
|
|
840
|
+
for (const [key, signature] of lastOrderByKey) {
|
|
841
|
+
if (signature === "") continue
|
|
842
|
+
try {
|
|
843
|
+
view.setSessionOrder(key, [], {})
|
|
844
|
+
} catch {
|
|
845
|
+
/* a torn-down view keeps whatever it had */
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
lastOrderByKey.clear()
|
|
849
|
+
}, "dsh-plugin-desktop-notify: order release")
|
|
850
|
+
|
|
872
851
|
/*
|
|
873
852
|
* Diagnostic surface for DevTools (⌘⌥I):
|
|
874
853
|
* __DSH_DESKTOP_NOTIFY__.capabilities() — what this renderer supports
|
|
@@ -912,28 +891,24 @@ window.__ModuleLoader__.load({
|
|
|
912
891
|
badge: lastBadge ?? 0,
|
|
913
892
|
attention: [...attention.entries()].map(([sessionId, entry]) => ({ sessionId, ...entry })),
|
|
914
893
|
tracked: [...previous.keys()],
|
|
915
|
-
|
|
894
|
+
order: [...tier],
|
|
916
895
|
lastNotify: lastNotify ?? null,
|
|
917
896
|
lastClick: lastClick ?? null,
|
|
918
897
|
}
|
|
919
898
|
},
|
|
920
|
-
/**
|
|
921
|
-
|
|
922
|
-
const snapshot = pinnedSnapshot()
|
|
899
|
+
/** Which sessions the plugin seats under the pins, and in what order. */
|
|
900
|
+
orderState() {
|
|
923
901
|
return {
|
|
924
|
-
enabled: preferences.
|
|
925
|
-
|
|
926
|
-
|
|
902
|
+
enabled: preferences.orderAttention,
|
|
903
|
+
tier: [...tier],
|
|
904
|
+
written: Object.fromEntries([...lastOrderByKey].map(([key, value]) => [key, value === "" ? 0 : value.split("\n").length])),
|
|
927
905
|
}
|
|
928
906
|
},
|
|
929
|
-
/**
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
}
|
|
935
|
-
persistPins()
|
|
936
|
-
return this.pinState()
|
|
907
|
+
/** Drop the whole tier; the next settle clears it from the sidebar. */
|
|
908
|
+
clearOrder() {
|
|
909
|
+
tier = []
|
|
910
|
+
scheduleOrderWrite()
|
|
911
|
+
return this.orderState()
|
|
937
912
|
},
|
|
938
913
|
simulateComplete(sessionId = "desktop-notify-simulation") {
|
|
939
914
|
const table = copy(languageOf())
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-desktop-notify",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5",
|
|
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",
|