@mzzsfy/dsh-turn-notify 0.10.0 → 0.11.0

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
@@ -97,6 +97,10 @@ turn-notify:
97
97
 
98
98
  通知实际送达时,侧边栏会话列表中对应会话行以背景脉冲闪烁强调,六类各一色(完成绿/出错红/提问蓝/中断橙/审批黄/上限紫),点击该会话行即停止。开关在面板"偏好"页首项(独立开关,本机生效,各浏览器独立),默认开启。并行多会话时用于快速定位状态刚更新的会话:运行中的会话不闪烁(与 dsh 原生状态点分工——状态点表达运行中,闪烁表达已更新待查看),会话重新进入运行状态时闪烁自动让位;聚焦时正在查看的会话不闪烁,失焦期间本会话的通知照常闪烁,返回窗口即自动消失(回来即已读,焦点与页面可见双通道)。行定位按会话标题文本匹配,标题默认与侧边栏行同源(session-title 投影);该服务缺失或不可用(如伪会话无事件日志)时回落会话事件内存储的标题,可能与侧边栏不同步。会话行未渲染(列表折叠/懒加载未滚动到)时该次高亮静默跳过。重开页面的冷启动静默对齐同样挂高亮(见"多窗口只响一次"),重开即见哪些会话有动静。
99
99
 
100
+ ## 工作区文件夹运行标记
101
+
102
+ 侧边栏会话列表按工作区分组(官方 WorkspaceBrowser 分组模式)时,有运行中会话的工作区文件夹组头显示脉冲运行点并给文件夹图标着色,游离(未归属任何工作区)的可见运行中会话使末位「未分组」组头同样标记——折叠与展开同样生效。归属与运行态一律取服务快照投影,DOM 仅按下标对位官方组容器;组容器数漂移(单列表模式/搜索态/结构变更)即清除标记不误标。图标着色让位官方 folderActive(展开且含当前会话)态。开关在面板"偏好"页"会话列表"卡(存宿主配置,各浏览器共用),默认开启,变更刷新页面生效。
103
+
100
104
  ## 多窗口只响一次
101
105
 
102
106
  同一地址(同源)打开的多个窗口共享一次呈现:各窗口以长轮询挂起在 host 的通知队列上(事件产生或配置变更即时返回,空闲期单次挂起至多数十秒,失败按指数退避重连),经 localStorage 协调,仅一个窗口抢先呈现——发声并弹出页内提示、系统弹窗、高亮会话行,其余窗口全部跳过,不止声音。通知在产生后的下一次唤醒即送达,通常亚秒级;不同浏览器或不同地址打开的窗口各自发声互不感知。窗口关闭/刷新期间错过的通知不做重开回放:启动首轮静默对齐游标与完成标记,不逐条补发提示与声音(等待用户动作的审批与提问例外,照常呈现);未读会话行高亮不在静默范围,照常挂上。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-turn-notify",
3
3
  "description": "消息通知:六类回合事件送达声音、系统弹窗、页内提示、webhook、IM 与宿主桌面通知;内置八种合成音色,自定义音效,按事件分类指定;多窗口只响一次",
4
- "version": "0.10.0",
4
+ "version": "0.11.0",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -864,6 +864,55 @@ window.__ModuleLoader__.load({
864
864
  row.className = row.className.split(' ').filter((name) => name !== SESSION_HL_CLASS && name.indexOf(SESSION_HL_CLASS + '--') !== 0).join(' ')
865
865
  }
866
866
 
867
+ // ---- 工作区文件夹运行标记 ----
868
+
869
+ // 官方组容器 CSS module 类名后缀(哈希前缀随构建漂移,后缀稳定)与自家标记属性;
870
+ // 扫描合并窗口覆盖聊天流式输出的高频 DOM mutation
871
+ const FOLD_SECTION_SELECTOR = 'div[class$="_groupSection"]'
872
+ const FOLD_RUNNING_FLAG = 'data-tn-folder-running'
873
+ const FOLD_SCAN_DELAY_MS = 50
874
+
875
+ // 运行标记状态:官方 dsh-client-ui-workspace 分组语义的投影——
876
+ // 归属走 workspace 账本 sessionIds 一级映射(官方 owningGroupKey 同构,无 cwd 回退),
877
+ // 游离会话入末位未分组桶;可见性镜像官方 sessionVisible(subagent / 已归档 /
878
+ // 空白且非当前排除),组内任一可见会话 running 即该工作区运行中。
879
+ // ids 保工作区登记序(官方组渲染序 = 快照 items 序),供 DOM 组容器按下标对位
880
+ function folderRunningState(sessionsState, workspacesState) {
881
+ const byId = (sessionsState && sessionsState.byId) || {}
882
+ const ids = (sessionsState && sessionsState.ids) || []
883
+ const current = sessionsState ? sessionsState.current : undefined
884
+ const workspaces = workspacesState && Array.isArray(workspacesState.items) ? workspacesState.items : []
885
+ const archived = new Set((workspacesState && workspacesState.archivedSessionIds) || [])
886
+ const visible = (row) => Boolean(row)
887
+ && row.origin !== 'subagent'
888
+ && !archived.has(row.id)
889
+ && (!row.blank || row.id === current)
890
+ const running = new Set()
891
+ const accounted = new Set()
892
+ const workspaceIds = []
893
+ for (const workspace of workspaces) {
894
+ // 主键不做防御过滤,与官方 groupByWorkspace 同构:items 长度即组容器对位基准,
895
+ // 过滤会让 ids 短于容器数,恰差一时错位成「末位未分组桶」对位
896
+ workspaceIds.push(workspace.workspaceId)
897
+ const sessionIds = Array.isArray(workspace.sessionIds) ? workspace.sessionIds : []
898
+ for (const sessionId of sessionIds) {
899
+ const row = byId[sessionId]
900
+ if (!row) continue
901
+ accounted.add(sessionId)
902
+ if (row.running && visible(row)) running.add(workspace.workspaceId)
903
+ }
904
+ }
905
+ let ungrouped = false
906
+ for (const sessionId of ids) {
907
+ if (accounted.has(sessionId)) continue
908
+ const row = byId[sessionId]
909
+ if (row && row.running && visible(row)) ungrouped = true
910
+ }
911
+ return { ids: workspaceIds, running, ungrouped }
912
+ }
913
+
914
+ const FOLD_SWITCH_TITLE = '侧边栏会话列表按工作区分组时,有运行中会话的文件夹组头显示运行点并给文件夹图标着色,文件夹折叠与展开同样生效;游离会话所在的「未分组」组同样标记;单列表模式下无此标记。停用即全部隐藏,刷新页面生效。'
915
+
867
916
  // ---- 点击直达会话:页内卡片与系统弹窗共用 ----
868
917
 
869
918
  // 按通知单元激活会话:聚焦窗口并模拟点击侧边栏会话行(官方行点击即切换会话);
@@ -1019,6 +1068,7 @@ window.__ModuleLoader__.load({
1019
1068
  suppressSubagentWake: true,
1020
1069
  hostNotify: false,
1021
1070
  hostNotifyFallback: false,
1071
+ folderRunningEnabled: true,
1022
1072
  enabled: Object.fromEntries(CATEGORIES.map((key) => [key, true])),
1023
1073
  imTargets: [],
1024
1074
  }
@@ -1136,6 +1186,18 @@ window.__ModuleLoader__.load({
1136
1186
  '@media (prefers-reduced-motion: reduce) {',
1137
1187
  ' .tn-sess-hl { animation:none; background-color:color-mix(in srgb, var(--tn-sess-color, #4c8dff) 50%, var(--dsw-alias-bg-base, #202020) 50%); }',
1138
1188
  '}',
1189
+ // 工作区文件夹运行标记:官方组容器自带 position:relative,运行点绝对定位于
1190
+ // 组容器左缘、垂直对齐组头行中线(行高 34px 之半即 top 17px,落于组头行左内边距
1191
+ // 区),零布局位移;图标着色让位官方 folderActive(展开含当前)态;色值取官方
1192
+ // ongoing 运行点的实际色值(--dsh-state-ongoing 在官方仅定义于其状态点组件局部,
1193
+ // 组容器上下文读不到,直接引用其回退源)
1194
+ 'div[class$="_groupSection"][data-tn-folder-running] { --tn-fold-running-color: var(--dsw-static-deepseek-450); }',
1195
+ 'div[class$="_groupSection"][data-tn-folder-running]::before { content:""; position:absolute; left:0; top:17px; width:5px; height:5px;',
1196
+ ' margin-top:-2.5px; border-radius:999px; background:var(--tn-fold-running-color);',
1197
+ ' animation:tn-fold-running-pulse 1.2s ease-in-out infinite; }',
1198
+ 'div[class$="_groupSection"][data-tn-folder-running] span[class$="_folder"]:not([class$="_folderActive"]) { color:var(--tn-fold-running-color); }',
1199
+ '@keyframes tn-fold-running-pulse { 0%, 100% { opacity:1; } 50% { opacity:.35; } }',
1200
+ '@media (prefers-reduced-motion: reduce) { div[class$="_groupSection"][data-tn-folder-running]::before { animation:none; } }',
1139
1201
  ].join('\n')
1140
1202
 
1141
1203
  function h(type, props) {
@@ -1408,6 +1470,21 @@ window.__ModuleLoader__.load({
1408
1470
  })
1409
1471
  }
1410
1472
 
1473
+ // 文件夹运行标记开关:乐观回填,服务端失败回滚;变更刷新页面生效(标记
1474
+ // 注入在插件激活时按配置装配)
1475
+ async function toggleFolderRunning(checked) {
1476
+ const previous = config.folderRunningEnabled
1477
+ setConfig({ ...config, folderRunningEnabled: checked })
1478
+ try {
1479
+ const res = await api('/api/turn-notify/config', { method: 'POST', body: JSON.stringify({ folderRunningEnabled: checked }) })
1480
+ setConfig({ ...DEFAULT_CONFIG, ...res })
1481
+ patch('工作区文件夹运行标记已' + (res.folderRunningEnabled !== false ? '启用' : '停用') + ',刷新页面后生效')
1482
+ } catch (error) {
1483
+ setConfig((prev) => ({ ...prev, folderRunningEnabled: previous }))
1484
+ patch('切换失败:' + (error && error.message ? error.message : String(error)), 'error')
1485
+ }
1486
+ }
1487
+
1411
1488
  async function requestPermission() {
1412
1489
  try {
1413
1490
  const next = await Notification.requestPermission()
@@ -1794,6 +1871,20 @@ window.__ModuleLoader__.load({
1794
1871
  ' 弹窗不可用时以标题闪烁替代'),
1795
1872
  ]),
1796
1873
  ) : null,
1874
+ activeTab === '偏好' ? h('div', { className: 'tn-card' },
1875
+ h('div', { className: 'tn-card__head' },
1876
+ h('span', { className: 'tn-card__title' }, '会话列表'),
1877
+ h('span', { className: 'tn-card__sub' }, '存宿主配置,各浏览器共用'),
1878
+ ),
1879
+ field('运行标记', [
1880
+ h('label', { className: 'tn-meta tn-switch', title: FOLD_SWITCH_TITLE },
1881
+ ...switchToggle({
1882
+ checked: config.folderRunningEnabled !== false,
1883
+ onChange: (e) => void toggleFolderRunning(e.target.checked),
1884
+ }),
1885
+ ' 有运行中会话的文件夹显示运行点'),
1886
+ ], '侧边栏按工作区分组时,运行中的文件夹组头显示脉冲运行点并给图标着色,折叠与展开同样生效;变更刷新页面生效。'),
1887
+ ) : null,
1797
1888
  activeTab === '音效' ? [
1798
1889
  h('div', { className: 'tn-card' },
1799
1890
  h('div', { className: 'tn-card__head' },
@@ -2001,17 +2092,95 @@ window.__ModuleLoader__.load({
2001
2092
  }
2002
2093
 
2003
2094
  return {
2004
- inject: ['slots'],
2095
+ inject: ['slots', 'sessions', 'workspaces'],
2005
2096
  apply(ctx) {
2097
+ const sessions = ctx.get('sessions')
2098
+ const workspaces = ctx.get('workspaces')
2006
2099
  // 样式挂载宿主文档级:通知栈在面板未打开时也要有完整样式
2007
2100
  ctx.effect(() => {
2008
2101
  const style = document.createElement('style')
2102
+ // 自带 data-plugin:缺失时宿主 claimStyles 会把它归属给后续材质化插件,其 HMR 重建即误删
2103
+ style.setAttribute('data-plugin', '@mzzsfy/dsh-turn-notify')
2009
2104
  style.textContent = CSS
2010
2105
  document.head.appendChild(style)
2011
2106
  return () => style.remove()
2012
2107
  }, 'turn-notify styles')
2013
2108
  // 激活即轮询:通知链路不依赖设置面板是否打开过
2014
2109
  start()
2110
+ // 工作区文件夹运行标记:服务快照投影 + DOM 组容器下标对位,状态变化经
2111
+ // 订阅与 DOM 变更(合并窗口)双通道驱动;开关读宿主配置,变更刷新页面生效
2112
+ let foldEnabled = null
2113
+ ctx.effect(() => {
2114
+ if (typeof MutationObserver === 'undefined' || typeof document.querySelectorAll !== 'function') return
2115
+ let disposed = false
2116
+ let timer = null
2117
+ function clearAll() {
2118
+ document.querySelectorAll('[' + FOLD_RUNNING_FLAG + ']').forEach((section) => {
2119
+ section.removeAttribute(FOLD_RUNNING_FLAG)
2120
+ })
2121
+ }
2122
+ function scan() {
2123
+ const sections = document.querySelectorAll(FOLD_SECTION_SELECTOR)
2124
+ if (sections.length === 0) {
2125
+ clearAll()
2126
+ return
2127
+ }
2128
+ // 开关未决时仅清除:终态正确且停用用户刷新页面不闪现标记
2129
+ if (foldEnabled === null) {
2130
+ clearAll()
2131
+ return
2132
+ }
2133
+ const state = foldEnabled
2134
+ ? folderRunningState(sessions.list.getSnapshot(), workspaces.list.getSnapshot())
2135
+ : { ids: [], running: new Set(), ungrouped: false }
2136
+ const expected = state.ids.length
2137
+ // 数量不变量:组容器数 = 工作区数 或 工作区数+1(末位未分组桶);
2138
+ // 漂移(单列表模式 / 搜索态 / 官方结构变更)即清除标记,不误标
2139
+ const ungroupedLast = sections.length === expected + 1
2140
+ if (sections.length !== expected && !ungroupedLast) {
2141
+ clearAll()
2142
+ return
2143
+ }
2144
+ sections.forEach((section, index) => {
2145
+ const running = index < expected ? state.running.has(state.ids[index]) : ungroupedLast && state.ungrouped
2146
+ // 同值跳过:流式输出期间扫描高频,重复写属性触发无谓的 CSS 重匹配
2147
+ if (running === section.hasAttribute(FOLD_RUNNING_FLAG)) return
2148
+ if (running) section.setAttribute(FOLD_RUNNING_FLAG, '')
2149
+ else section.removeAttribute(FOLD_RUNNING_FLAG)
2150
+ })
2151
+ }
2152
+ let scheduled = false
2153
+ const schedule = () => {
2154
+ if (disposed || scheduled) return
2155
+ scheduled = true
2156
+ timer = setTimeout(() => {
2157
+ scheduled = false
2158
+ timer = null
2159
+ if (!disposed) scan()
2160
+ }, FOLD_SCAN_DELAY_MS)
2161
+ }
2162
+ api('/api/turn-notify/config')
2163
+ .then((payload) => {
2164
+ foldEnabled = payload ? payload.folderRunningEnabled !== false : true
2165
+ })
2166
+ .catch(() => {
2167
+ foldEnabled = true
2168
+ })
2169
+ .finally(schedule)
2170
+ const unsubscribeSessions = sessions.list.subscribe(schedule)
2171
+ const unsubscribeWorkspaces = workspaces.list.subscribe(schedule)
2172
+ const observer = new MutationObserver(schedule)
2173
+ observer.observe(document.documentElement, { childList: true, subtree: true })
2174
+ schedule()
2175
+ return () => {
2176
+ disposed = true
2177
+ if (timer !== null) clearTimeout(timer)
2178
+ observer.disconnect()
2179
+ unsubscribeSessions()
2180
+ unsubscribeWorkspaces()
2181
+ clearAll()
2182
+ }
2183
+ }, 'turn-notify folder running mark')
2015
2184
  ctx.slots.inject('settings.section', () =>
2016
2185
  ctx.slots.register(
2017
2186
  { name: 'settings.section', id: 'turn-notify', order: 41, label: '消息通知' },
package/src/core.mjs CHANGED
@@ -641,7 +641,7 @@ export function imBoundBotIds(list) {
641
641
  // 返回归一化后的补丁。
642
642
  export function validateConfigPatch(patch) {
643
643
  if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) return { ok: false, reason: '补丁须为对象' }
644
- const known = ['webhookUrl', 'minTurnDurationMs', 'rootsOnly', 'suppressSubagentWake', 'enabled', 'imTargets', 'kindRoutes', 'hostNotify', 'hostNotifyFallback']
644
+ const known = ['webhookUrl', 'minTurnDurationMs', 'rootsOnly', 'suppressSubagentWake', 'enabled', 'imTargets', 'kindRoutes', 'hostNotify', 'hostNotifyFallback', 'folderRunningEnabled']
645
645
  for (const key of Object.keys(patch)) {
646
646
  if (known.indexOf(key) < 0) return { ok: false, reason: '未知配置项: ' + key }
647
647
  }
@@ -702,6 +702,10 @@ export function validateConfigPatch(patch) {
702
702
  if (typeof patch.hostNotifyFallback !== 'boolean') return { ok: false, reason: 'hostNotifyFallback 须为布尔' }
703
703
  next.hostNotifyFallback = patch.hostNotifyFallback
704
704
  }
705
+ if ('folderRunningEnabled' in patch) {
706
+ if (typeof patch.folderRunningEnabled !== 'boolean') return { ok: false, reason: 'folderRunningEnabled 须为布尔' }
707
+ next.folderRunningEnabled = patch.folderRunningEnabled
708
+ }
705
709
  if ('enabled' in patch) {
706
710
  const enabled = patch.enabled
707
711
  if (enabled === null || typeof enabled !== 'object' || Array.isArray(enabled)) return { ok: false, reason: 'enabled 须为对象' }
@@ -767,6 +771,7 @@ export function resolvedConfig(settings) {
767
771
  suppressSubagentWake: source.suppressSubagentWake !== false,
768
772
  hostNotify: source.hostNotify === true,
769
773
  hostNotifyFallback: source.hostNotifyFallback === true,
774
+ folderRunningEnabled: source.folderRunningEnabled !== false,
770
775
  enabled: Object.fromEntries(CATEGORIES.map((key) => [key, enabled[key] !== false])),
771
776
  soundMapping,
772
777
  imTargets: normalizeImTargets(source.imTargets),
@@ -783,6 +788,7 @@ export function publicConfig(settings) {
783
788
  suppressSubagentWake: resolved.suppressSubagentWake,
784
789
  hostNotify: resolved.hostNotify,
785
790
  hostNotifyFallback: resolved.hostNotifyFallback,
791
+ folderRunningEnabled: resolved.folderRunningEnabled,
786
792
  enabled: resolved.enabled,
787
793
  soundMapping: resolved.soundMapping,
788
794
  imTargets: resolved.imTargets,
package/src/index.js CHANGED
@@ -94,6 +94,7 @@ const SETTINGS_SCHEMA = z.object({
94
94
  suppressSubagentWake: z.boolean().default(true).description('子代理相关回合不通知(仅任务完成类):后台委托未收尾的回合与收尾唤醒的回合'),
95
95
  hostNotify: z.boolean().default(false).description('宿主机桌面通知:通知触发时由宿主进程弹系统级通知(osascript/notify-send/PowerShell toast);浏览器优先,本机浏览器在场(2 秒内有在途长轮询)时由浏览器呈现宿主不重复弹,离场时宿主补位'),
96
96
  hostNotifyFallback: z.boolean().default(false).description('宿主通知回退:本机浏览器在场时由浏览器呈现,离场(2 秒内无在途长轮询)时宿主补位;与总开关判定一致,任一开启即生效'),
97
+ folderRunningEnabled: z.boolean().default(true).description('工作区文件夹运行标记:侧边栏分组模式下,有运行中会话的文件夹组头显示运行点并给图标着色;关闭后标记整体不渲染,变更刷新页面生效'),
97
98
  enabled: z.object(Object.fromEntries(CATEGORIES.map((key) => [key, z.boolean().default(true)]))).description('六类事件独立开关:完成/出错/被中断/等待审批/AI 提问/达到上限'),
98
99
  soundMapping: z.object(Object.fromEntries(CATEGORIES.map((key) => [key, z.string().default('')]))).description('每类事件的声音映射,空为内置默认,非空为内置音名或上传音效 id'),
99
100
  imTargets: z.array(z.object({ botId: z.string().default(''), targetId: z.string().default('') })).default([]).description('dsh-im 推送目标列表,空数组禁用 IM 通道'),
@@ -0,0 +1,405 @@
1
+ // 工作区文件夹运行标记:纯函数投影切片 + effect 扫描接线测试。
2
+ // 扫描逻辑闭包在 apply 的 effect 内,经捕获 effect 函数 + DOM 桩驱动,
3
+ // 锁定数量不变量守卫、下标对位、开关兜底与卸载清理。
4
+ import test from 'node:test'
5
+ import assert from 'node:assert/strict'
6
+ import { readFileSync } from 'node:fs'
7
+ import { fileURLToPath } from 'node:url'
8
+ import { dirname, join } from 'node:path'
9
+ import { mock } from 'node:test'
10
+
11
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
12
+ const CLIENT_SRC = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
13
+
14
+ // ── 纯函数切片:常量与 folderRunningState(无外部依赖) ──
15
+
16
+ const PURE_START = CLIENT_SRC.indexOf('const FOLD_SECTION_SELECTOR')
17
+ const PURE_END = CLIENT_SRC.indexOf('// ---- 点击直达会话')
18
+ assert.ok(PURE_START >= 0 && PURE_END > PURE_START, 'client.js 文件夹运行标记纯函数切片定位失败')
19
+ const pure = new Function(
20
+ CLIENT_SRC.slice(PURE_START, PURE_END)
21
+ + '; return { folderRunningState: folderRunningState, sectionSelector: FOLD_SECTION_SELECTOR, runningFlag: FOLD_RUNNING_FLAG, scanDelayMs: FOLD_SCAN_DELAY_MS }',
22
+ )()
23
+
24
+ const WS = (id, sessionIds) => ({ workspaceId: id, sessionIds })
25
+ const ROW = (id, extra) => ({ id, ...extra })
26
+
27
+ test('folderRunningState 主路径:运行中会话所在工作区入集合,非运行中不入', () => {
28
+ const sessions = {
29
+ ids: ['a', 'b'],
30
+ byId: { a: ROW('a', { running: true }), b: ROW('b', { running: false }) },
31
+ }
32
+ const workspaces = { items: [WS('w1', ['a']), WS('w2', ['b'])], archivedSessionIds: [] }
33
+ const state = pure.folderRunningState(sessions, workspaces)
34
+ assert.deepEqual(state.ids, ['w1', 'w2'])
35
+ assert.deepEqual([...state.running], ['w1'])
36
+ assert.equal(state.ungrouped, false)
37
+ })
38
+
39
+ test('folderRunningState 未分组:游离运行中会话使末位未分组桶标记', () => {
40
+ const sessions = {
41
+ ids: ['a', 'stray'],
42
+ byId: { a: ROW('a', { running: false }), stray: ROW('stray', { running: true }) },
43
+ }
44
+ const workspaces = { items: [WS('w1', ['a'])], archivedSessionIds: [] }
45
+ const state = pure.folderRunningState(sessions, workspaces)
46
+ assert.equal(state.running.has('w1'), false)
47
+ assert.equal(state.ungrouped, true)
48
+ })
49
+
50
+ test('folderRunningState 排除项:subagent、已归档、空白非当前不标记,空白当前例外', () => {
51
+ const sessions = {
52
+ ids: ['sub', 'arch', 'blank', 'cur'],
53
+ byId: {
54
+ sub: ROW('sub', { running: true, origin: 'subagent' }),
55
+ arch: ROW('arch', { running: true }),
56
+ blank: ROW('blank', { running: true, blank: true }),
57
+ cur: ROW('cur', { running: true, blank: true }),
58
+ },
59
+ current: 'cur',
60
+ }
61
+ const workspaces = {
62
+ items: [WS('w1', ['sub']), WS('w2', ['arch']), WS('w3', ['blank']), WS('w4', ['cur'])],
63
+ archivedSessionIds: ['arch'],
64
+ }
65
+ const state = pure.folderRunningState(sessions, workspaces)
66
+ assert.equal(state.running.has('w1'), false, 'subagent 运行不标记')
67
+ assert.equal(state.running.has('w2'), false, '已归档运行不标记')
68
+ assert.equal(state.running.has('w3'), false, '空白非当前运行不标记')
69
+ assert.equal(state.running.has('w4'), true, '空白且为当前会话标记')
70
+ })
71
+
72
+ test('folderRunningState 缺省守卫:快照缺失或账本悬空不抛错', () => {
73
+ assert.deepEqual(pure.folderRunningState(undefined, undefined), { ids: [], running: new Set(), ungrouped: false })
74
+ const state = pure.folderRunningState({ ids: ['gone'], byId: {} }, { items: [WS('w1', ['gone'])] })
75
+ assert.deepEqual([...state.running], [])
76
+ assert.equal(state.ungrouped, false)
77
+ })
78
+
79
+ // ── effect 扫描接线:DOM 桩 + 捕获 effect 函数驱动 ──
80
+
81
+ const SCAN_SETTLE_MS = pure.scanDelayMs * 4
82
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
83
+
84
+ // 组容器桩:属性集合语义与 Element 同构
85
+ function sectionStub() {
86
+ const attrs = new Set()
87
+ return {
88
+ attrs,
89
+ setAttribute: (name) => attrs.add(name),
90
+ removeAttribute: (name) => attrs.delete(name),
91
+ hasAttribute: (name) => attrs.has(name),
92
+ }
93
+ }
94
+
95
+ // 加载 client.js 并执行 apply(mock 最小服务面),effect 函数捕获不执行;
96
+ // MutationObserver 经工厂形参注入(源码自由变量),document 桩承载组容器查询;
97
+ // 通知长轮询经挂起 fetch 静默(测试只驱动 fold effect,不消费投影)
98
+ function loadClient() {
99
+ const modules = []
100
+ const effects = []
101
+ const windowStub = {
102
+ __ModuleLoader__: { load: (module) => modules.push(module) },
103
+ addEventListener: () => {},
104
+ removeEventListener: () => {},
105
+ }
106
+ const reactStub = {
107
+ useState: (value) => [value, () => {}],
108
+ useEffect: () => {},
109
+ useSyncExternalStore: () => [],
110
+ useRef: (value) => ({ current: value }),
111
+ createElement: () => null,
112
+ }
113
+ const requireStub = (name) => {
114
+ if (name === '@mzzsfy/dsh-toast/client') return { show: () => 0 }
115
+ return reactStub
116
+ }
117
+ const sections = []
118
+ const documentStub = {
119
+ documentElement: {},
120
+ createElement: () => ({ style: {}, remove() {} }),
121
+ head: { appendChild: () => {} },
122
+ body: { appendChild: () => {} },
123
+ addEventListener: () => {},
124
+ removeEventListener: () => {},
125
+ querySelectorAll: (selector) => {
126
+ if (selector === pure.sectionSelector) return [...sections]
127
+ if (selector === '[' + pure.runningFlag + ']') return sections.filter((section) => section.hasAttribute(pure.runningFlag))
128
+ return []
129
+ },
130
+ }
131
+ class MutationObserverStub {
132
+ observe() {}
133
+ disconnect() {}
134
+ }
135
+ // 不传 fetch 工厂参数:源码内裸 fetch 解析到 globalThis.fetch,由 withFetch 显式 mock;
136
+ // 传参数会把 fetch 绑定进沙箱,mock 永远打不到
137
+ const factory = new Function('window', 'require', 'document', 'MutationObserver', CLIENT_SRC + '\n;return null')
138
+ factory(windowStub, requireStub, documentStub, MutationObserverStub)
139
+ assert.equal(modules.length, 1, 'client.js 模块未被捕获')
140
+ const mod = modules[0].factory(requireStub)
141
+
142
+ // 快照源桩:监听器捕获 + emit,供测试模拟服务变更触发的重扫(生产由订阅驱动)
143
+ let sessionsState = { ids: [], byId: {} }
144
+ let workspacesState = undefined
145
+ function sourceStub(getSnapshot) {
146
+ const listeners = new Set()
147
+ return {
148
+ list: {
149
+ subscribe: (cb) => {
150
+ listeners.add(cb)
151
+ return () => listeners.delete(cb)
152
+ },
153
+ getSnapshot,
154
+ },
155
+ emit: () => {
156
+ for (const cb of [...listeners]) cb()
157
+ },
158
+ }
159
+ }
160
+ const sessions = { binding: () => undefined, ...sourceStub(() => sessionsState) }
161
+ const workspaces = sourceStub(() => workspacesState)
162
+ const ctx = {
163
+ get: (name) => (name === 'sessions' ? sessions : name === 'workspaces' ? workspaces : {}),
164
+ effect: (fn, tag) => effects.push({ fn, tag }),
165
+ slots: { inject: () => {}, register: () => {} },
166
+ }
167
+ mod.apply(ctx)
168
+ const entry = effects.find((item) => item.tag === 'turn-notify folder running mark')
169
+ assert.ok(entry, 'apply 未挂文件夹运行标记 effect')
170
+ return {
171
+ sections,
172
+ runEffect: entry.fn,
173
+ emitSessions: sessions.emit,
174
+ setSessions: (next) => { sessionsState = next },
175
+ setWorkspaces: (next) => { workspacesState = next },
176
+ }
177
+ }
178
+
179
+ // 两工作区一运行一的基准快照
180
+ function baseSnapshots(client) {
181
+ client.setSessions({
182
+ ids: ['a', 'b'],
183
+ byId: { a: ROW('a', { running: true }), b: ROW('b', { running: false }) },
184
+ })
185
+ client.setWorkspaces({ items: [WS('w1', ['a']), WS('w2', ['b'])], archivedSessionIds: [] })
186
+ }
187
+
188
+ async function withFetch(implOrPayload, run) {
189
+ // mock.method 返回被 mock 的函数,还原须经 .mock.restore(),不可直接调用返回值;
190
+ // 传函数即显式 fetch 实现(构造拉取失败),传对象即固定响应,undefined 即固定
191
+ // 成功响应 {folderRunningEnabled:true}——默认路径显式 mock,不依赖运行环境对相对
192
+ // URL 的 fetch 行为(隐式环境耦合会随 Node 升级漂移);
193
+ // 通知长轮询路径恒返回挂起 Promise,测试期间不产生投影循环
194
+ const impl = typeof implOrPayload === 'function'
195
+ ? async (path) => (String(path).indexOf('/api/turn-notify/projection') === 0 ? new Promise(() => {}) : implOrPayload(path))
196
+ : async (path) => {
197
+ if (String(path).indexOf('/api/turn-notify/projection') === 0) return new Promise(() => {})
198
+ return { ok: true, json: async () => (implOrPayload === undefined ? { folderRunningEnabled: true } : implOrPayload) }
199
+ }
200
+ const mocked = mock.method(globalThis, 'fetch', impl)
201
+ try {
202
+ await run()
203
+ } finally {
204
+ mocked.mock.restore()
205
+ }
206
+ }
207
+
208
+ test('Given 两工作区组容器与快照同序, When 扫描, Then 运行中工作区组容器带标记', async () => {
209
+ await withFetch(undefined, async () => {
210
+ const client = loadClient()
211
+ baseSnapshots(client)
212
+ const [s1, s2] = [sectionStub(), sectionStub()]
213
+ client.sections.push(s1, s2)
214
+ client.runEffect()
215
+ await sleep(SCAN_SETTLE_MS)
216
+ assert.ok(s1.hasAttribute(pure.runningFlag), '运行中工作区组容器应带标记')
217
+ assert.equal(s2.hasAttribute(pure.runningFlag), false, '空闲工作区组容器不带标记')
218
+ })
219
+ })
220
+
221
+ test('Given 末位未分组桶存在, When 扫描, Then 组容器数 = 工作区数+1 且游离运行中落末位', async () => {
222
+ await withFetch(undefined, async () => {
223
+ const client = loadClient()
224
+ baseSnapshots(client)
225
+ client.setSessions({
226
+ ids: ['a', 'b', 'stray'],
227
+ byId: { a: ROW('a', { running: true }), b: ROW('b', { running: false }), stray: ROW('stray', { running: true }) },
228
+ })
229
+ const [s1, s2, s3] = [sectionStub(), sectionStub(), sectionStub()]
230
+ client.sections.push(s1, s2, s3)
231
+ client.runEffect()
232
+ await sleep(SCAN_SETTLE_MS)
233
+ assert.ok(s1.hasAttribute(pure.runningFlag), '首工作区(运行中)带标记')
234
+ assert.equal(s2.hasAttribute(pure.runningFlag), false, '次工作区(空闲)不带标记')
235
+ assert.ok(s3.hasAttribute(pure.runningFlag), '末位未分组桶(游离运行中)带标记')
236
+ })
237
+ })
238
+
239
+ test('Given 组容器数与不变量漂移(单列表/搜索态/结构变更), When 扫描, Then 清除全部标记不误标', async () => {
240
+ await withFetch(undefined, async () => {
241
+ const client = loadClient()
242
+ baseSnapshots(client)
243
+ const [s1, s2] = [sectionStub(), sectionStub()]
244
+ client.sections.push(s1, s2)
245
+ client.runEffect()
246
+ await sleep(SCAN_SETTLE_MS)
247
+ assert.ok(s1.hasAttribute(pure.runningFlag), '前置:正常对位时首组带标记')
248
+ // 漂移:两工作区只剩一个组容器(或多余两个)均不匹配不变量
249
+ client.sections.length = 0
250
+ client.sections.push(s1)
251
+ client.emitSessions()
252
+ await sleep(SCAN_SETTLE_MS)
253
+ assert.equal(s1.hasAttribute(pure.runningFlag), false, '数量漂移后标记被清除')
254
+ })
255
+ })
256
+
257
+ test('Given 开关关闭(拉取返回 folderRunningEnabled:false), When 扫描, Then 不标记且既有标记清除', async () => {
258
+ await withFetch({ folderRunningEnabled: false }, async () => {
259
+ const client = loadClient()
260
+ baseSnapshots(client)
261
+ const s1 = sectionStub()
262
+ client.sections.push(s1)
263
+ client.runEffect()
264
+ await sleep(SCAN_SETTLE_MS)
265
+ assert.equal(s1.hasAttribute(pure.runningFlag), false, '停用后不新增标记')
266
+ })
267
+ })
268
+
269
+ test('Given 开关拉取失败, When 扫描, Then 按启用兜底照常标记', async () => {
270
+ await withFetch(async (path) => {
271
+ if (String(path).indexOf('/api/turn-notify/projection') === 0) return new Promise(() => {})
272
+ throw new Error('network down')
273
+ }, async () => {
274
+ const client = loadClient()
275
+ baseSnapshots(client)
276
+ const [s1, s2] = [sectionStub(), sectionStub()]
277
+ client.sections.push(s1, s2)
278
+ client.runEffect()
279
+ await sleep(SCAN_SETTLE_MS)
280
+ assert.ok(s1.hasAttribute(pure.runningFlag), '拉取失败按启用兜底(默认启用)')
281
+ assert.equal(s2.hasAttribute(pure.runningFlag), false, '兜底不改变运行态判定')
282
+ })
283
+ })
284
+
285
+ test('Given 运行态变化经服务订阅到达(折叠组无 DOM 变更), When 快照更新, Then 标记跟随', async () => {
286
+ await withFetch(undefined, async () => {
287
+ const client = loadClient()
288
+ baseSnapshots(client)
289
+ const [s1, s2] = [sectionStub(), sectionStub()]
290
+ client.sections.push(s1, s2)
291
+ client.runEffect()
292
+ await sleep(SCAN_SETTLE_MS)
293
+ assert.ok(s1.hasAttribute(pure.runningFlag), '前置:首工作区运行中')
294
+ // 唯一运行会话转入另一工作区(模拟会话被归档/挂到他处后的快照差)
295
+ client.setSessions({
296
+ ids: ['a', 'b'],
297
+ byId: { a: ROW('a', { running: false }), b: ROW('b', { running: true }) },
298
+ })
299
+ client.emitSessions()
300
+ await sleep(SCAN_SETTLE_MS)
301
+ assert.equal(s1.hasAttribute(pure.runningFlag), false, '原运行工作区标记消除')
302
+ assert.ok(s2.hasAttribute(pure.runningFlag), '新运行工作区获得标记')
303
+ })
304
+ })
305
+
306
+ test('Given effect 卸载, When 清理执行, Then 标记移除且资源退订', async () => {
307
+ await withFetch(undefined, async () => {
308
+ const client = loadClient()
309
+ baseSnapshots(client)
310
+ const [s1, s2] = [sectionStub(), sectionStub()]
311
+ client.sections.push(s1, s2)
312
+ const cleanup = client.runEffect()
313
+ await sleep(SCAN_SETTLE_MS)
314
+ assert.ok(s1.hasAttribute(pure.runningFlag), '前置:标记已应用')
315
+ cleanup()
316
+ assert.equal(s1.hasAttribute(pure.runningFlag), false, '卸载清除运行中工作区的标记')
317
+ assert.equal(s2.hasAttribute(pure.runningFlag), false, '卸载不残留其他标记')
318
+ })
319
+ })
320
+
321
+ test('Given 无工作区且游离会话运行, When 扫描, Then 末位未分组桶正确标记', async () => {
322
+ await withFetch(undefined, async () => {
323
+ const client = loadClient()
324
+ client.setSessions({ ids: ['stray'], byId: { stray: ROW('stray', { running: true }) } })
325
+ client.setWorkspaces({ items: [], archivedSessionIds: [] })
326
+ const s1 = sectionStub()
327
+ client.sections.push(s1)
328
+ client.runEffect()
329
+ await sleep(SCAN_SETTLE_MS)
330
+ assert.ok(s1.hasAttribute(pure.runningFlag), '零工作区时组容器数 1 = 工作区数 0 + 1,末位按未分组桶标记')
331
+ })
332
+ })
333
+
334
+ test('Given 组容器数多于不变量上界, When 扫描, Then 清除全部标记不误标', async () => {
335
+ await withFetch(undefined, async () => {
336
+ const client = loadClient()
337
+ baseSnapshots(client)
338
+ // 数量漂移「变多」:两工作区出现四个组容器(> 工作区数+1)
339
+ const sections = [sectionStub(), sectionStub(), sectionStub(), sectionStub()]
340
+ client.sections.push(...sections)
341
+ client.runEffect()
342
+ await sleep(SCAN_SETTLE_MS)
343
+ for (const [index, section] of sections.entries()) {
344
+ assert.equal(section.hasAttribute(pure.runningFlag), false, '容器 ' + index + ' 数量漂移时不带标记')
345
+ }
346
+ })
347
+ })
348
+
349
+ test('Given 拉取未完成即卸载, When 后续拉取完成并触发调度, Then 标记不复活', async () => {
350
+ // 拉取挂起(永不resolve),标记先经拉取完成前的唯一路径产生:
351
+ // 未决期 scan 仅清除,故先注入「已启用 + 已标记」状态再卸载
352
+ let release
353
+ await withFetch((path) => {
354
+ if (String(path).indexOf('/api/turn-notify/projection') === 0) return new Promise(() => {})
355
+ return new Promise((resolve) => { release = resolve })
356
+ }, async () => {
357
+ const client = loadClient()
358
+ baseSnapshots(client)
359
+ const s1 = sectionStub()
360
+ client.sections.push(s1)
361
+ const cleanup = client.runEffect()
362
+ await sleep(SCAN_SETTLE_MS)
363
+ assert.equal(s1.hasAttribute(pure.runningFlag), false, '拉取未决期间不产生标记')
364
+ // 模拟标记已在(如卸载前最后一次 scan 的残留):卸载后拉取完成不得复活
365
+ s1.setAttribute(pure.runningFlag, '')
366
+ cleanup()
367
+ release({ ok: true, json: async () => ({ folderRunningEnabled: true }) })
368
+ await sleep(SCAN_SETTLE_MS)
369
+ assert.equal(s1.hasAttribute(pure.runningFlag), false, '卸载后拉取完成不复活标记')
370
+ })
371
+ })
372
+
373
+ test('Given 开关停用, When 拉取完成前的首次扫描, Then 不产生标记(未决期仅清除)', async () => {
374
+ // 拉取延迟释放(3 个扫描窗口后返回停用),未决期内的扫描不得标记
375
+ await withFetch((path) => new Promise((resolve) => {
376
+ if (String(path).indexOf('/api/turn-notify/projection') === 0) return
377
+ setTimeout(() => resolve({ ok: true, json: async () => ({ folderRunningEnabled: false }) }), pure.scanDelayMs * 3)
378
+ }), async () => {
379
+ const client = loadClient()
380
+ baseSnapshots(client)
381
+ const s1 = sectionStub()
382
+ client.sections.push(s1)
383
+ client.runEffect()
384
+ await sleep(SCAN_SETTLE_MS)
385
+ assert.equal(s1.hasAttribute(pure.runningFlag), false, '拉取未决期首次扫描不标记')
386
+ await sleep(SCAN_SETTLE_MS * 2)
387
+ assert.equal(s1.hasAttribute(pure.runningFlag), false, '拉取返回停用后维持无标记')
388
+ })
389
+ })
390
+
391
+ // ── 源码契约:CSS 规则、面板开关挂载、选择器与标记属性 ──
392
+
393
+ test('源码契约:运行点与图标着色规则、官方组容器锚点、面板开关挂载齐备', () => {
394
+ assert.ok(CLIENT_SRC.includes('div[class$="_groupSection"][data-tn-folder-running]'), '缺组容器标记 CSS 锚点')
395
+ assert.ok(CLIENT_SRC.includes('var(--dsw-static-deepseek-450)'), '运行点色源应为官方 ongoing 实际色值(--dsh-state-ongoing 仅存在于官方状态点组件局部,组容器读不到)')
396
+ assert.ok(!CLIENT_SRC.includes('--tn-fold-running-color: var(--dsh-state-ongoing'), '死变量引用:组容器上下文读不到 --dsh-state-ongoing,恒走回退源,不得引入永不生效的引用')
397
+ assert.ok(CLIENT_SRC.includes('[class$="_folderActive"]'), '图标着色应让位官方 folderActive 态')
398
+ assert.ok(CLIENT_SRC.includes('tn-fold-running-pulse'), '缺运行点脉冲动画')
399
+ assert.ok(CLIENT_SRC.includes('prefers-reduced-motion'), '动画应尊重减少动态偏好')
400
+ assert.ok(CLIENT_SRC.includes('toggleFolderRunning'), '面板缺运行标记开关处理函数')
401
+ assert.ok(CLIENT_SRC.includes('title: FOLD_SWITCH_TITLE'), '开关行缺悬停说明挂载')
402
+ assert.ok(CLIENT_SRC.includes('const FOLD_SWITCH_TITLE'), '缺悬停说明常量')
403
+ // 开关经既有 config 路由读写:不新增路由,host 与 client 字面量一致
404
+ assert.ok(!CLIENT_SRC.includes("'/api/turn-notify/folder-running"), '运行标记开关不得另开路由(经 config 路由读写)')
405
+ })
@@ -661,6 +661,7 @@ test('配置解析:enabled 缺省键按开补全,字段类型回退默认', () =
661
661
  suppressSubagentWake: true,
662
662
  hostNotify: false,
663
663
  hostNotifyFallback: false,
664
+ folderRunningEnabled: true,
664
665
  enabled: { completed: false, error: true, interrupted: true, approval: true, ask: true, 'max-tokens': true },
665
666
  soundMapping: { completed: 'snd-1' },
666
667
  imTargets: [],
@@ -674,6 +675,7 @@ test('配置解析:enabled 缺省键按开补全,字段类型回退默认', () =
674
675
  suppressSubagentWake: true,
675
676
  hostNotify: false,
676
677
  hostNotifyFallback: false,
678
+ folderRunningEnabled: true,
677
679
  enabled: Object.fromEntries(CATEGORIES.map((name) => [name, true])),
678
680
  soundMapping: {},
679
681
  imTargets: [],
@@ -692,6 +694,7 @@ test('面板可见配置:webhookUrl 原文回显', () => {
692
694
  suppressSubagentWake: true,
693
695
  hostNotify: false,
694
696
  hostNotifyFallback: false,
697
+ folderRunningEnabled: true,
695
698
  enabled: { completed: false, error: true, interrupted: true, approval: true, ask: true, 'max-tokens': true },
696
699
  soundMapping: { completed: 'snd-1' },
697
700
  imTargets: [],
@@ -45,14 +45,15 @@ function loadClient({ storage, payload, onFetch, fetchImpl, document: documentOv
45
45
  listeners: windowListeners,
46
46
  localStorage: storage,
47
47
  }
48
+ const styleTags = []
48
49
  const documentStub = {
49
50
  hasFocus: () => true,
50
51
  // 默认桩:聚焦窗口恒可见(浏览器不变式);聚焦静默判定为可见+焦点双条件
51
52
  hidden: false,
52
53
  title: 'dsh',
53
- createElement: () => ({ style: {}, remove() {} }),
54
+ createElement: () => ({ style: {}, attrs: {}, setAttribute(name, value) { this.attrs[name] = value }, remove() {} }),
54
55
  body: { appendChild: () => {} },
55
- head: { appendChild: () => {} },
56
+ head: { appendChild: (node) => { styleTags.push(node) } },
56
57
  ...documentOverride,
57
58
  }
58
59
  const reactStub = { useState: (value) => [value, () => {}], useEffect: () => {}, useSyncExternalStore: () => [] }
@@ -85,7 +86,7 @@ function loadClient({ storage, payload, onFetch, fetchImpl, document: documentOv
85
86
  )
86
87
  assert.equal(modules.length, 1, 'client.js 模块未被捕获')
87
88
  // load({id, factory}) 结构:再调 factory(require) 得到真正的模块对象
88
- return { mod: modules[0].factory(requireStub), shown, window: windowStub, document: documentStub }
89
+ return { mod: modules[0].factory(requireStub), shown, window: windowStub, document: documentStub, styleTags }
89
90
  }
90
91
 
91
92
  const units = [
@@ -718,18 +719,23 @@ test('Given 页内映射存储损坏或死链 When 解析 Then 容错回落且
718
719
 
719
720
  test('激活即启动轮询:apply 注册设置分区且 start 已执行', async () => {
720
721
  const fetched = []
721
- const { mod } = loadClient({
722
+ const { mod, styleTags } = loadClient({
722
723
  storage: new FakeStorage(),
723
724
  payload: { units: [], soundMapping: {}, version: 1 },
724
725
  onFetch: (path) => { fetched.push(path) },
725
726
  })
726
727
  const injected = []
727
728
  const effects = []
729
+ // sessions/workspaces 服务桩:文件夹运行标记的注入面(订阅桩不做任何事;
730
+ // 本测试 document 桩无 querySelectorAll,fold effect 装配即干净退出)
731
+ const serviceStub = { list: { subscribe: () => () => {}, getSnapshot: () => ({}) } }
728
732
  mod.apply({
733
+ get: (name) => (name === 'sessions' || name === 'workspaces' ? serviceStub : undefined),
729
734
  slots: { inject: (name, fn) => { injected.push([name, fn]) } },
730
735
  effect: (fn) => { effects.push(fn()) },
731
736
  })
732
- assert.equal(effects.length, 1, '文档级样式未挂载')
737
+ assert.equal(effects.length, 2, '文档级样式与文件夹运行标记两个 effect 均已挂载')
738
+ assert.equal(styleTags[0].attrs['data-plugin'], '@mzzsfy/dsh-turn-notify', '样式自带 data-plugin,防宿主 claimStyles 误归属后随他插件 HMR 整批误删')
733
739
  assert.deepEqual(injected.map(([name]) => name), ['settings.section'], '页内通知展示已移交 dsh-toast,不再注入 shell.overlay')
734
740
  // start 已执行:音效清单被首拉;轮询定时器 unref,不阻止测试进程退出
735
741
  await new Promise((resolve) => { setTimeout(resolve, 0) })
@@ -799,7 +805,11 @@ test('分类通知开关提交失败:报错并以权威配置纠偏', async () =
799
805
  })
800
806
 
801
807
  function activate(mod) {
808
+ // sessions/workspaces 桩:apply 的注入面声明;fold effect 在无 querySelectorAll
809
+ // 的 document 桩上装配即退出,不触碰订阅
810
+ const serviceStub = { list: { subscribe: () => () => {}, getSnapshot: () => ({}) } }
802
811
  mod.apply({
812
+ get: (name) => (name === 'sessions' || name === 'workspaces' ? serviceStub : undefined),
803
813
  slots: { inject: () => {} },
804
814
  effect: (fn) => { fn() },
805
815
  })