dsh-workspace-session-filter 1.0.1 → 1.0.3

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 (3) hide show
  1. package/README.md +20 -10
  2. package/client.js +99 -6
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -4,16 +4,17 @@
4
4
 
5
5
  给 DSH 侧边栏加两个入口:
6
6
 
7
- 1. **侧边栏底部的筛选条** —— 设置按钮上方单独一行,`运行中` 与 `待确认` 两个 chip
8
- (各自带命中数)。点一下向上弹出按工作区分组的会话列表,点行直接进会话,是日常主入口;
7
+ 1. **侧边栏底部的筛选条** —— 设置按钮上方单独一行,`运行中` / `待确认` / `未读` 三个
8
+ chip(各自带命中数)。点一下向上弹出按工作区分组的会话列表,点行直接进会话,是日常主入口;
9
9
  2. 侧边栏面板列表里的「会话筛选」图标 —— 打开中间栏的完整视图,顶部是
10
- `全部 / 运行中 / 等待确认 / 更多` 筛选器,`更多` 里是 `最近一天 / 最近三天 /
10
+ `全部 / 运行中 / 等待确认 / 未读 / 更多` 筛选器,`更多` 里是 `最近一天 / 最近三天 /
11
11
  最近一周 / 最近一个月`(外加 `不限时间`)。
12
12
 
13
13
  ## 功能
14
14
 
15
- - **按状态筛选**:底部只放 `运行中`(含子智能体仍在跑)与 `待确认`(审批、计划审阅、
16
- 待回答)两个 chip,各自带当前命中数;完整面板另有 `全部` 用于浏览。
15
+ - **按状态筛选**:底部只放三个 chip —— `运行中`(含子智能体仍在跑)、`待确认`(审批、
16
+ 计划审阅、待回答)、`未读`(跑完但没看,或最近有新内容你没打开过),各自带当前命中数;
17
+ 完整面板另有 `全部` 用于浏览。
17
18
  - **就在手边**:点 chip 向上弹出按工作区分组的列表,点行直接进会话,再点同一个 chip 收起;
18
19
  侧边栏收成窄条时换成带合计角标的圆形按钮,展开后同样能筛。
19
20
  - **单独一行**:筛选条占满侧边栏那一行,不和成本计等其他底部条目挤在一起。
@@ -49,6 +50,9 @@ profile 目录里的 pnpm,装完再读包里的 `dsh.bundle.patch` 自动登
49
50
  `dsh.profile.bundles`,因此不用手改 profile 的 `package.json`):
50
51
 
51
52
  ```bash
53
+ # 从 npm registry 装(1.0.1 起可用,最省事)
54
+ dsh plugin --profile desktop add dsh-workspace-session-filter
55
+
52
56
  # 从 GitHub 装(pnpm 会锁定解析到的 commit)
53
57
  dsh plugin --profile desktop add github:shijiewang/dsh-workspace-session-filter
54
58
 
@@ -75,13 +79,14 @@ dsh plugin --profile desktop remove dsh-workspace-session-filter
75
79
 
76
80
  装好后:
77
81
 
78
- - 侧边栏**底部**(设置按钮上方、单独一行)出现 `运行中 N` / `待确认 N` 筛选条;
82
+ - 侧边栏**底部**(设置按钮上方、单独一行)出现 `运行中 N` / `待确认 N` / `未读 N` 筛选条;
79
83
  - 侧边栏面板列表里出现「会话筛选」图标,指向中间栏的完整视图。
80
84
 
81
85
  卸载:`plugin_manager({ action: "remove_bundle", target: "dsh-workspace-session-filter" })`。
82
86
 
83
- > 包名 `dsh-workspace-session-filter` 与仓库同名,`dsh plugin` 用 git spec、tarball 或
84
- > 目录路径都能装;发布到 npm registry 后,也可以直接把包名交给 `dsh plugin add`。
87
+ > 包名 `dsh-workspace-session-filter` 与仓库同名。截至 1.0.3,npm registry、
88
+ > GitHub、release tarball、本地目录四种来源都能装;registry 上是 `npm publish` 发出的
89
+ > 正式版本,`repository` 字段指回本仓库。
85
90
 
86
91
  ## 结构
87
92
 
@@ -101,14 +106,19 @@ dsh plugin --profile desktop remove dsh-workspace-session-filter
101
106
  上下文之后。
102
107
  - **筛选条落位**:注册在 `sidebar.footer.action`(设置按钮上方的一行槽位),整条占满宽度;
103
108
  弹层用 `position: fixed` 贴在 chip 上方(`bottom: innerHeight - rect.top + 8`),因为侧边栏
104
- 容器 `overflow` 会裁掉绝对定位的浮层。宽版渲染两个 chip,窄条(`wide: false`)渲染一个
105
- 36px 圆按钮。
109
+ 容器 `overflow` 会裁掉绝对定位的浮层。宽版渲染三个 chip,窄条(`wide: false`)渲染一个
110
+ 36px 圆按钮,角标是三个数字之和。
106
111
  - **数据来源**:`main` slot 提供的标准 props — `useSessions`(会话快照)、
107
112
  `useWorkspaces`(工作区快照)、`useSessionStatus`(状态 Map),全部是
108
113
  `SnapshotSelectorHook`,必须传 selector。
109
114
  - **样式**:只用 `--dsw-alias-*` / `--dsw-*` 设计令牌,数值对齐内置列表页
110
115
  (960px 内容宽、`clamp(24px,4vw,48px)` 内边距、28px chip、32px 行高、菜单 4px 内衬
111
116
  + `--dsw-elevation-prominent`),类名前缀 `dwsf-`。
117
+ - **未读的判定**:两个来源叠加 —— ① 状态 Map 里的 `completionUnread`(内置列表画完成点的
118
+ 同一个信号,覆盖"跑完没看");② 本插件自己记的"最后查看时间表",存 `localStorage`
119
+ (key `dsh-workspace-session-filter:seen.v1`),会话 `updatedAt` 比它新就算有新内容。
120
+ 维护方式:当前打开的会话每次渲染都刷新时间戳,点任意行也算读过,首次见到的会话先建基线
121
+ (所以新装不会满屏未读)。`未读` 与 `运行中`/`待确认` 互斥计数,行右侧会多出 `未读` 文案。
112
122
  - **容错**:面板与左下筛选条各包一层错误边界(`PanelBoundary` / `FooterBoundary`),
113
123
  渲染异常时分别退化成空状态 / 什么都不渲染,不会让中间栏变白或拖坏侧边栏;
114
124
  标准 hooks 缺失时先打日志再降级。
package/client.js CHANGED
@@ -30,6 +30,7 @@ window.__ModuleLoader__.load({
30
30
  'filter.running': '运行中',
31
31
  'filter.awaiting': '等待确认',
32
32
  'filter.awaitingShort': '待确认',
33
+ 'filter.unread': '未读',
33
34
  'filter.more': '更多',
34
35
  'filter.reset': '清除筛选',
35
36
  'filter.aria': '会话筛选条件',
@@ -68,6 +69,7 @@ window.__ModuleLoader__.load({
68
69
  'filter.running': 'Running',
69
70
  'filter.awaiting': 'Awaiting confirmation',
70
71
  'filter.awaitingShort': 'Awaiting',
72
+ 'filter.unread': 'Unread',
71
73
  'filter.more': 'More',
72
74
  'filter.reset': 'Clear filters',
73
75
  'filter.aria': 'Session filters',
@@ -208,11 +210,79 @@ window.__ModuleLoader__.load({
208
210
  return parts.length > 0 ? parts[parts.length - 1] : ''
209
211
  }
210
212
 
213
+ /* ------------------------------------------------------------------ *
214
+ * Last-seen stamps
215
+ *
216
+ * The shipped status map only flags a turn that finished while the session
217
+ * was not on screen (`completionUnread`). Everything else that bumps a
218
+ * session — queued work, a subagent settling, an edit made elsewhere —
219
+ * leaves `updatedAt` moving without any flag, so the "unread" dimension
220
+ * remembers when this panel last saw a session and counts newer activity.
221
+ * The map lives in localStorage so it survives reloads; an unknown session
222
+ * is baselined on first sight, which keeps a fresh install quiet.
223
+ * ------------------------------------------------------------------ */
224
+ const SEEN_KEY = 'dsh-workspace-session-filter:seen.v1'
225
+ const seenCache = { loaded: false, map: {}, timer: null }
226
+
227
+ function seenStorage() {
228
+ try {
229
+ return typeof localStorage === 'undefined' ? null : localStorage
230
+ } catch (error) {
231
+ return null
232
+ }
233
+ }
234
+
235
+ function loadSeen() {
236
+ if (seenCache.loaded) return seenCache.map
237
+ seenCache.loaded = true
238
+ const store = seenStorage()
239
+ if (!store) return seenCache.map
240
+ try {
241
+ const parsed = JSON.parse(store.getItem(SEEN_KEY) || 'null')
242
+ if (parsed && typeof parsed === 'object') seenCache.map = parsed
243
+ } catch (error) {
244
+ seenCache.map = {}
245
+ }
246
+ return seenCache.map
247
+ }
248
+
249
+ function flushSeen() {
250
+ const store = seenStorage()
251
+ if (!store) return
252
+ try {
253
+ const ids = Object.keys(seenCache.map)
254
+ if (ids.length > 400) {
255
+ ids.sort((left, right) => (seenCache.map[right] || 0) - (seenCache.map[left] || 0))
256
+ for (const id of ids.slice(400)) delete seenCache.map[id]
257
+ }
258
+ store.setItem(SEEN_KEY, JSON.stringify(seenCache.map))
259
+ } catch (error) {
260
+ /* quota or private mode: the bookkeeping is best effort */
261
+ }
262
+ }
263
+
264
+ function markSeen(sessionId, stamp) {
265
+ if (!(stamp > 0)) return
266
+ const map = loadSeen()
267
+ const previous = map[sessionId]
268
+ if (previous !== undefined && previous >= stamp) return
269
+ map[sessionId] = stamp
270
+ if (seenCache.timer === null && typeof setTimeout === 'function') {
271
+ seenCache.timer = setTimeout(() => { seenCache.timer = null; flushSeen() }, 400)
272
+ }
273
+ }
274
+
211
275
  /** One flat row model, mirroring the shipped sidebar's status precedence. */
212
- function buildRow(summary, status, subagents, t) {
276
+ function buildRow(summary, status, subagents, t, lastSeen) {
213
277
  const kind = pendingKindOf(status)
214
278
  const running = status && status.running !== undefined ? status.running === true : Boolean(summary.running)
215
279
  const completed = Boolean(status && status.completionUnread === true)
280
+ const activityNew = completed !== true
281
+ && kind === undefined
282
+ && running !== true
283
+ && subagents === 0
284
+ && lastSeen !== undefined
285
+ && (Number(summary.updatedAt) || 0) > lastSeen
216
286
  let dot = 'idle'
217
287
  let label = ''
218
288
  if (kind !== undefined) {
@@ -228,6 +298,9 @@ window.__ModuleLoader__.load({
228
298
  label = t('status.subagentsRunning', { n: subagents })
229
299
  } else if (completed) {
230
300
  dot = 'done'
301
+ label = t('filter.unread')
302
+ } else if (activityNew) {
303
+ label = t('filter.unread')
231
304
  }
232
305
  return {
233
306
  id: summary.id,
@@ -238,6 +311,7 @@ window.__ModuleLoader__.load({
238
311
  label,
239
312
  awaiting: kind !== undefined,
240
313
  running: running || subagents > 0,
314
+ unread: completed || activityNew,
241
315
  }
242
316
  }
243
317
 
@@ -266,17 +340,27 @@ window.__ModuleLoader__.load({
266
340
  if (archived.has(summary.id)) continue
267
341
  if (cutoff && !(summary.updatedAt >= cutoff)) continue
268
342
  const status = statuses ? statuses.get(summary.id) : undefined
269
- const row = buildRow(summary, status, runningSubagents(list, summary.id, statuses || new Map()), t)
343
+ const stamp = Number(summary.updatedAt) || 0
344
+ const seen = loadSeen()
345
+ let lastSeen = seen[summary.id]
346
+ if (summary.id === currentId || lastSeen === undefined) {
347
+ markSeen(summary.id, stamp)
348
+ if (lastSeen === undefined) lastSeen = stamp
349
+ }
350
+ const row = buildRow(summary, status, runningSubagents(list, summary.id, statuses || new Map()), t, lastSeen)
270
351
  rows.set(summary.id, row)
271
352
  base.push(row)
272
353
  }
273
354
 
274
355
  const matches = (row) => statusFilter === 'all'
275
- || (statusFilter === 'running' ? row.running : row.awaiting)
356
+ || (statusFilter === 'running' ? row.running
357
+ : statusFilter === 'awaiting' ? row.awaiting
358
+ : row.unread)
276
359
  const counts = {
277
360
  all: base.length,
278
361
  running: base.filter((row) => row.running).length,
279
362
  awaiting: base.filter((row) => row.awaiting).length,
363
+ unread: base.filter((row) => row.unread).length,
280
364
  }
281
365
 
282
366
  const groups = []
@@ -502,7 +586,10 @@ window.__ModuleLoader__.load({
502
586
 
503
587
  const now = Date.now()
504
588
  const view = deriveView(props, statusFilter, range, now)
505
- const onOpen = props.onOpen
589
+ const onOpen = (sessionId) => {
590
+ markSeen(sessionId, Date.now())
591
+ props.onOpen(sessionId)
592
+ }
506
593
  const filtered = statusFilter !== 'all' || range !== null
507
594
 
508
595
  return h('div', { className: 'dwsf-page' },
@@ -526,6 +613,10 @@ window.__ModuleLoader__.load({
526
613
  label: t('filter.awaiting'), count: view.counts.awaiting, active: statusFilter === 'awaiting',
527
614
  onClick: () => setStatusFilter(statusFilter === 'awaiting' ? 'all' : 'awaiting'),
528
615
  }),
616
+ h(FilterTab, {
617
+ label: t('filter.unread'), count: view.counts.unread, active: statusFilter === 'unread',
618
+ onClick: () => setStatusFilter(statusFilter === 'unread' ? 'all' : 'unread'),
619
+ }),
529
620
  filtered
530
621
  ? h('button', {
531
622
  type: 'button',
@@ -653,6 +744,7 @@ window.__ModuleLoader__.load({
653
744
  const now = Date.now()
654
745
  const view = deriveView(props, mode, null, now)
655
746
  const onOpen = (sessionId) => {
747
+ markSeen(sessionId, Date.now())
656
748
  setOpen(false)
657
749
  props.onOpen(sessionId)
658
750
  }
@@ -671,7 +763,8 @@ window.__ModuleLoader__.load({
671
763
 
672
764
  const filters = h('div', { className: 'dwsf-footChips', role: 'group', 'aria-label': t('filter.aria') },
673
765
  chip('running', t('filter.running'), view.counts.running),
674
- chip('awaiting', t('filter.awaitingShort'), view.counts.awaiting)
766
+ chip('awaiting', t('filter.awaitingShort'), view.counts.awaiting),
767
+ chip('unread', t('filter.unread'), view.counts.unread)
675
768
  )
676
769
 
677
770
  const popover = open && anchor !== undefined
@@ -696,7 +789,7 @@ window.__ModuleLoader__.load({
696
789
  : null
697
790
 
698
791
  if (!wide) {
699
- const waiting = view.counts.running + view.counts.awaiting
792
+ const waiting = view.counts.running + view.counts.awaiting + view.counts.unread
700
793
  return h('div', { ref: rootRef, className: 'dwsf-footRail' },
701
794
  popover,
702
795
  h('button', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-workspace-session-filter",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Workspace session filter panel with status and recency filters.",
5
5
  "type": "module",
6
6
  "exports": {