dsh-notes-plugin 0.1.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/lib/client.js ADDED
@@ -0,0 +1,882 @@
1
+ /* global window, document, fetch, localStorage, performance, PerformanceObserver, console */
2
+ // dsh-notes — Browser 侧 bundle(CJS 工厂,供 dsh web 客户端 ModuleLoader 注入)。
3
+ //
4
+ // 本文件是发布版静态包的 **最终源码**(P3):由 scripts/build-dist.cjs 从开发版 client-impl.js
5
+ // 机械转换而来,转换规则见 task-board-plugin/docs/PACKAGING.md 第 4 节:
6
+ // · React :require('react')(静态包无全局 React)
7
+ // · RPC :fetch('/dsh-notes', POST {method, args}) —— index.mjs 的 webServer exact 路由
8
+ // (动态插件的 host 调用桥在静态包中不存在)
9
+ // · 样式 :fetch notes-css + document.createElement('style') 注入(doc 级,进程单例)
10
+ // (动态插件的 styles 服务在静态包中不存在)
11
+ // · 定时器 :动态插件的 ctx.interval 快捷方式不存在,用 ctx.get('timer') + ctx.effect
12
+ // · inject :只声明硬依赖 slots;timer/sessions/workspaces 全部 ctx.get + 守卫
13
+ //
14
+ // 要改 client 行为:改开发版 client-impl.js,然后 `node scripts/build-dist.cjs` 重新生成。
15
+ window.__ModuleLoader__.load({
16
+ id: 'dsh-notes-plugin',
17
+ factory: (require) => {
18
+ var module = { exports: {} }
19
+ var exports = module.exports
20
+ 'use strict'
21
+ const React = require('react')
22
+
23
+ function apply(ctx) {
24
+ const slots = ctx.get('slots')
25
+ if (!slots) { console.error('[dsh-notes] slots service unavailable'); return }
26
+ const timer = ctx.get('timer')
27
+ if (!timer) { console.error('[dsh-notes] timer service unavailable'); return }
28
+ const sessions = ctx.get('sessions')
29
+ const workspaces = ctx.get('workspaces')
30
+ const disposers = []
31
+ const listeners = new Set()
32
+ const noteRefreshListeners = new Set()
33
+ let panelOpen = false
34
+ let currentSessionId = ''
35
+ let toastEmit = null
36
+ function showToast(msg) { try { if (toastEmit) toastEmit(msg) } catch (e) {} }
37
+ // 入口双模式:'header'(会话头部按钮)| 'fab'(可拖拽悬浮气泡);互斥、可持久化
38
+ let entryMode = 'header'
39
+ let fabPos = { x: 16, y: 80 } // 悬浮气泡默认位置(左上角)
40
+ const entryListeners = new Set()
41
+ function loadEntryState() {
42
+ try {
43
+ const saved = localStorage.getItem('dsh-notes-entry')
44
+ if (saved) {
45
+ const s = JSON.parse(saved)
46
+ if (s.mode === 'header' || s.mode === 'fab') entryMode = s.mode
47
+ if (typeof s.fabX === 'number' && typeof s.fabY === 'number') fabPos = { x: s.fabX, y: s.fabY }
48
+ }
49
+ } catch (err) {}
50
+ }
51
+ function saveEntryState() { try { localStorage.setItem('dsh-notes-entry', JSON.stringify({ mode: entryMode, fabX: fabPos.x, fabY: fabPos.y })) } catch (err) {} }
52
+ function notifyEntry() { entryListeners.forEach(fn => fn({ entryMode })) }
53
+ function setEntryMode(m) { entryMode = m; saveEntryState(); notifyEntry() }
54
+ loadEntryState()
55
+ const PRESET_TOPICS = ['需求', '设计', '开发', '调试', '运维', '调研', '其他']
56
+ const PAGE_SIZE = 50
57
+ const KIND_LABELS = { note: '笔记', decision: '决策', todo: '待办', link: '链接', quote: '引用' }
58
+ const KIND_ICONS = { note: '○', decision: '◆', todo: '☑', link: '↗', quote: '❝' }
59
+ const shortSid = (sid) => sid ? String(sid).replace(/^session-/, '').slice(0, 8) : ''
60
+ const notify = () => listeners.forEach(fn => fn({ panelOpen }))
61
+ const notifyNotesChanged = () => noteRefreshListeners.forEach(fn => fn())
62
+ const e = React.createElement
63
+ // 性能自检计数器:浏览器控制台执行 JSON.stringify(window.__dshNotesPerf) 可取数诊断
64
+ const now = (typeof performance !== 'undefined' && performance.now) ? () => performance.now() : () => Date.now()
65
+ const perf = { selChange: 0, selCollapsedSkip: 0, selChangeMs: 0, selShowEval: 0, selShowMs: 0, mousemoveTracked: 0, hostCall: 0, hostCallMs: 0, panelRender: 0, selRender: 0, hdrRender: 0, longTasks: 0, longTaskMs: 0, worstTaskMs: 0 }
66
+ try { window.__dshNotesPerf = perf } catch (e2) {}
67
+ // client → host RPC:静态包走 webServer exact 路由(PACKAGING.md 第 4 节),
68
+ // 与 index.mjs 的 RPC_PATH = '/dsh-notes' 对应。
69
+ function rpc(method, args) {
70
+ perf.hostCall++
71
+ var t0 = now()
72
+ return fetch('/dsh-notes', {
73
+ method: 'POST',
74
+ headers: { 'Content-Type': 'application/json' },
75
+ body: JSON.stringify({ method: method, args: args || {} })
76
+ }).then(
77
+ function (r) { perf.hostCallMs += now() - t0; return r.json() },
78
+ function (err) { perf.hostCallMs += now() - t0; throw err }
79
+ )
80
+ }
81
+ // 性能计数器在 rpc() helper 内部累加(hostCall/hostCallMs),不再改写全局 host 桥
82
+ // 长任务观察器:主线程阻塞(>50ms)的直接证据
83
+ try {
84
+ if (typeof PerformanceObserver !== 'undefined') {
85
+ const po = new PerformanceObserver((list) => {
86
+ const entries = list.getEntries()
87
+ for (let i = 0; i < entries.length; i++) { const en = entries[i]; perf.longTasks++; perf.longTaskMs += en.duration; if (en.duration > perf.worstTaskMs) perf.worstTaskMs = Math.round(en.duration) }
88
+ })
89
+ po.observe({ type: 'longtask' })
90
+ disposers.push(() => po.disconnect())
91
+ }
92
+ } catch (e2) {}
93
+ // 每 30s 把计数器推给 host,汇总写入 perf-report.json(timer 经 ctx.get + ctx.effect)
94
+ try {
95
+ var pd = typeof timer.interval === 'function' ? timer.interval(function () { try { rpc('notes-perf', { perf: JSON.parse(JSON.stringify(perf)) }) } catch (e2) {} }, 30000) : null
96
+ if (typeof pd === 'function') ctx.effect(function () { return pd })
97
+ } catch (e2) {}
98
+ // 样式从 host 拉取(doc 级 <style> 注入,替代动态插件的 styles 服务)
99
+ // PACKAGING.md 坑5:args 里不能出现值为 undefined 的字段,故 notes-css 不传参
100
+ let cssLoaded = false
101
+ let cssTries = 0
102
+ function loadCss() {
103
+ rpc('notes-css').then(function (res) {
104
+ if (res && res.css) {
105
+ cssLoaded = true
106
+ // 进程单例:样式注入 document.head 一次,卸载时移除
107
+ var tag = document.createElement('style')
108
+ tag.dataset.dshNotes = '1'
109
+ tag.textContent = res.css
110
+ document.head.append(tag)
111
+ disposers.push(function () { try { tag.remove() } catch (e2) {} })
112
+ } else scheduleCssRetry()
113
+ }).catch(scheduleCssRetry)
114
+ }
115
+ function scheduleCssRetry() { if (!cssLoaded && ++cssTries <= 10) { var d = timer.timeout(loadCss, 1200); disposers.push(d) } }
116
+ loadCss()
117
+ // 通用拖拽:move(ev) 在 mousemove 时调用,done() 在 mouseup 时调用
118
+ function drag(move, done) {
119
+ const onMove = (ev) => move(ev)
120
+ const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); if (done) done() }
121
+ document.addEventListener('mousemove', onMove)
122
+ document.addEventListener('mouseup', onUp)
123
+ }
124
+ const d1 = slots.inject('conversation.session.header.actions', () => {
125
+ function HeaderBtn(props) {
126
+ perf.hdrRender++
127
+ const [, force] = React.useState(0)
128
+ if (props && props.sessionId) currentSessionId = props.sessionId
129
+ React.useEffect(() => { const fn = () => force(v => v + 1); entryListeners.add(fn); listeners.add(fn); return () => { entryListeners.delete(fn); listeners.delete(fn) } }, [])
130
+ // 模式互斥:fab 模式时隐藏会话头部按钮
131
+ if (entryMode !== 'header') return null
132
+ return e('button', { className: 'dsh-notes-hdr-btn dsh-nt' + (panelOpen ? ' active' : ''), onClick: () => { panelOpen = !panelOpen; notify() }, 'data-tooltip': '智能笔记' }, e('span', { className: 'dsh-notes-hdr-ic' }, '✎'), e('span', null, '智能笔记'))
133
+ }
134
+ slots.register({ name: 'conversation.session.header.actions', id: 'dsh-notes-btn', order: 40 }, (props) => e(HeaderBtn, props))
135
+ })
136
+ if (typeof d1 === 'function') disposers.push(d1)
137
+ const d2 = slots.inject('shell.overlay', () => {
138
+ // 悬浮气泡入口(可拖拽;点击展开面板;位置持久化;与会话头部按钮互斥)
139
+ function FabEntry() {
140
+ const [, force] = React.useState(0)
141
+ const [pos, setPos] = React.useState({ x: fabPos.x, y: fabPos.y })
142
+ const posRef = React.useRef(pos)
143
+ React.useEffect(() => { posRef.current = pos }, [pos])
144
+ React.useEffect(() => { const fn = () => force(v => v + 1); entryListeners.add(fn); listeners.add(fn); return () => { entryListeners.delete(fn); listeners.delete(fn) } }, [])
145
+ React.useEffect(() => {
146
+ // 窗口尺寸变化时把气泡 clamp 进视口
147
+ function onResize() {
148
+ const p = posRef.current, nx = Math.max(0, Math.min(window.innerWidth - 44, p.x)), ny = Math.max(0, Math.min(window.innerHeight - 44, p.y))
149
+ if (nx !== p.x || ny !== p.y) { posRef.current = { x: nx, y: ny }; setPos({ x: nx, y: ny }) }
150
+ }
151
+ window.addEventListener('resize', onResize)
152
+ return () => window.removeEventListener('resize', onResize)
153
+ }, [])
154
+ // 模式互斥:header 模式时隐藏悬浮气泡
155
+ if (entryMode !== 'fab') return null
156
+ const SIZE = 44
157
+ function onMouseDown(ev) {
158
+ ev.preventDefault()
159
+ const sx = ev.clientX, sy = ev.clientY, px = pos.x, py = pos.y
160
+ let moved = false
161
+ drag(
162
+ (ev2) => {
163
+ const dx = ev2.clientX - sx, dy = ev2.clientY - sy
164
+ if (!moved && (Math.abs(dx) > 5 || Math.abs(dy) > 5)) moved = true
165
+ if (moved) {
166
+ const nx = Math.max(0, Math.min(window.innerWidth - SIZE, px + dx))
167
+ const ny = Math.max(0, Math.min(window.innerHeight - SIZE, py + dy))
168
+ posRef.current = { x: nx, y: ny }
169
+ setPos({ x: nx, y: ny })
170
+ }
171
+ },
172
+ () => {
173
+ // 区分点击与拖拽:未移动视为点击 → 展开面板;移动则持久化最终位置
174
+ if (!moved) { panelOpen = true; notify() }
175
+ else { fabPos = { x: posRef.current.x, y: posRef.current.y }; saveEntryState() }
176
+ }
177
+ )
178
+ }
179
+ return e('button', { className: 'dsh-notes-fab dsh-nt' + (panelOpen ? ' active' : ''), style: { left: pos.x + 'px', top: pos.y + 'px' }, onMouseDown, 'data-tooltip': '笔记' }, e('span', { className: 'dsh-notes-fab-ic' }, '✎'))
180
+ }
181
+ slots.register({ name: 'shell.overlay', id: 'dsh-notes-fab', order: 199 }, (props) => e(FabEntry, props))
182
+ })
183
+ if (typeof d2 === 'function') disposers.push(d2)
184
+ const d3 = slots.inject('shell.overlay', () => {
185
+ function FloatingPanel() {
186
+ perf.panelRender++
187
+ const [open, setOpen] = React.useState(panelOpen)
188
+ const [notes, setNotes] = React.useState([])
189
+ const [selected, setSelected] = React.useState(null)
190
+ const [edTitle, setEdTitle] = React.useState('')
191
+ const [edTopic, setEdTopic] = React.useState('')
192
+ const [edTags, setEdTags] = React.useState('')
193
+ const [edBody, setEdBody] = React.useState('')
194
+ const [edKind, setEdKind] = React.useState('note')
195
+ const [edStatus, setEdStatus] = React.useState('active')
196
+ const [edInject, setEdInject] = React.useState(false)
197
+ const [edScope, setEdScope] = React.useState([])
198
+ const [capText, setCapText] = React.useState('')
199
+ const [capPending, setCapPending] = React.useState(false)
200
+ const [capSaved, setCapSaved] = React.useState(false)
201
+ const [savedTick, setSavedTick] = React.useState(false)
202
+ const [savedAt, setSavedAt] = React.useState(0)
203
+ const [searchText, setSearchText] = React.useState('')
204
+ const [searchIds, setSearchIds] = React.useState(null)
205
+ const [loading, setLoading] = React.useState(false)
206
+ const [error, setError] = React.useState('')
207
+ const [pos, setPos] = React.useState({ x: null, y: null })
208
+ const [size, setSize] = React.useState({ width: 560, height: 620 })
209
+ const [listWidth, setListWidth] = React.useState(230)
210
+ const [showHelp, setShowHelp] = React.useState(false)
211
+ const [topicPickFor, setTopicPickFor] = React.useState(null)
212
+ const [flashId, setFlashId] = React.useState(null)
213
+ const [visibleCount, setVisibleCount] = React.useState(PAGE_SIZE)
214
+ const [focusId, setFocusId] = React.useState(null)
215
+ const [kindFilter, setKindFilter] = React.useState('all')
216
+ const [pinnedOnly, setPinnedOnly] = React.useState(false)
217
+ // T2 顶栏压缩:搜索/记录框默认收起为图标,点击或快捷键内联展开
218
+ const [searchOpen, setSearchOpen] = React.useState(false)
219
+ const [capOpen, setCapOpen] = React.useState(false)
220
+ const [sessList, setSessList] = React.useState([])
221
+ const [scopeOpen, setScopeOpen] = React.useState(false)
222
+ const [dispatchOpen, setDispatchOpen] = React.useState(false)
223
+ const [activeSessions, setActiveSessions] = React.useState([])
224
+ const [dispatching, setDispatching] = React.useState(false)
225
+ const [dispatchMode, setDispatchMode] = React.useState('existing') // existing=派发到活跃会话 / new=新建会话派发
226
+ const [dispatchInstr, setDispatchInstr] = React.useState('')
227
+ const [dispatchWsId, setDispatchWsId] = React.useState('') // new 模式:选中的工作区 id
228
+ const [dispatchSessWs, setDispatchSessWs] = React.useState('') // existing 模式:选中的工作区名
229
+ const [dispatchSessId, setDispatchSessId] = React.useState('') // existing 模式:选中的会话 id
230
+ const [wsList, setWsList] = React.useState([])
231
+ const keepQuickRef = React.useRef(false)
232
+ const capRef = React.useRef(null)
233
+ const timersRef = React.useRef([])
234
+ const selectedRef = React.useRef(null)
235
+ const dragRef = React.useRef(null)
236
+ const dividerRef = React.useRef(null)
237
+ // 键盘导航所需的 ref(keydown 监听挂一次,回调读最新值)
238
+ const focusIdRef = React.useRef(null)
239
+ const openRef = React.useRef(false)
240
+ const pagedIdsRef = React.useRef([])
241
+ const notesRef = React.useRef([])
242
+ const selectNoteRef = React.useRef(null)
243
+ const closeRef = React.useRef(null)
244
+ const searchInputRef = React.useRef(null)
245
+ const moveFocusRef = React.useRef(null)
246
+ // T2 顶栏压缩:展开态镜像到 ref(keydown 闭包挂一次,需读最新值避免过期)
247
+ const searchOpenRef = React.useRef(false)
248
+ const capOpenRef = React.useRef(false)
249
+ // 自动保存:编辑字段的最新值 ref(debounce 回调读 ref 而非闭包 state,避免过期)
250
+ const edTitleRef = React.useRef('')
251
+ const edTopicRef = React.useRef('')
252
+ const edTagsRef = React.useRef('')
253
+ const edBodyRef = React.useRef('')
254
+ const edKindRef = React.useRef('note')
255
+ const edStatusRef = React.useRef('active')
256
+ const edInjectRef = React.useRef(false)
257
+ const edScopeRef = React.useRef([])
258
+ const autoSaveRef = React.useRef(null)
259
+ React.useEffect(() => { selectedRef.current = selected }, [selected])
260
+ function later(fn, ms) { try { const d = timer.timeout(fn, ms); timersRef.current.push(d); return d } catch (err) { return null } }
261
+ React.useEffect(() => {
262
+ const arr = timersRef.current
263
+ timersRef.current = []
264
+ for (const d of arr) { try { d() } catch (err) {} }
265
+ }, [])
266
+ React.useEffect(() => { try { const saved = localStorage.getItem('dsh-notes-panel-state'); if (saved) { const s = JSON.parse(saved); if (s.x !== undefined && s.y !== undefined) setPos({ x: s.x, y: s.y }); if (s.width !== undefined && s.height !== undefined) setSize({ width: s.width, height: s.height }); if (s.listWidth !== undefined) setListWidth(Math.max(170, s.listWidth)) } } catch (err) {} }, [])
267
+ function saveState() { try { localStorage.setItem('dsh-notes-panel-state', JSON.stringify({ x: pos.x, y: pos.y, width: size.width, height: size.height, listWidth })) } catch (err) {} }
268
+ React.useEffect(() => { const fn = (s) => { if (s.panelOpen !== undefined) setOpen(s.panelOpen) }; listeners.add(fn); return () => listeners.delete(fn) }, [])
269
+ // 注入范围下拉的会话列表:面板打开时 + 笔记数变化时刷新(新会话可能出现)
270
+ React.useEffect(() => { if (!open) return; rpc('notes-sessions', {}).then(res => { if (res && res.sessions) setSessList(res.sessions) }).catch(() => {}) }, [open, notes.length])
271
+ // 范围浮层:点击外部关闭
272
+ React.useEffect(() => {
273
+ if (!scopeOpen) return
274
+ const onDown = (ev) => { if (!(ev.target && ev.target.closest && ev.target.closest('.dsh-notes-ed-scope-wrap'))) setScopeOpen(false) }
275
+ document.addEventListener('mousedown', onDown)
276
+ return () => document.removeEventListener('mousedown', onDown)
277
+ }, [scopeOpen])
278
+ // 派发对话框是 modal(自带 mask 点击外部关闭),无需 document 监听
279
+ React.useEffect(() => { const fn = () => loadNotes(true); noteRefreshListeners.add(fn); return () => noteRefreshListeners.delete(fn) }, [])
280
+ React.useEffect(() => { if (open) loadNotes() }, [open])
281
+ React.useEffect(() => { if (open && pos.x === null) { const w = window.innerWidth; const h = window.innerHeight; setPos({ x: Math.max(w - 620, w * 0.45), y: Math.max(56, (h - 620) / 2) }) } }, [open])
282
+ // 搜索两段式:输入即本地过滤(标题/主题/标签/预览),250ms 防抖后 host 全文检索(含正文)补充
283
+ // 用一次性注册的 timer.debounce:每击键调 timer.timeout 等于每击键在 fiber 上注册一次 ctx.effect,是持续簿记开销
284
+ const searchRef = React.useRef('')
285
+ const searchDebRef = React.useRef(null)
286
+ React.useEffect(() => {
287
+ const d = timer.debounce(() => {
288
+ const qq = searchRef.current.trim()
289
+ if (!qq) { setSearchIds(null); return }
290
+ rpc('notes-search', { query: qq }).then(res => setSearchIds((res.notes || []).map(n => n.id))).catch(() => {})
291
+ }, 250)
292
+ searchDebRef.current = d
293
+ return () => { if (d && d.dispose) d.dispose() }
294
+ }, [])
295
+ // 搜索条件变化时重置分页(新结果从头开始)
296
+ React.useEffect(() => { setVisibleCount(PAGE_SIZE) }, [searchText, searchIds])
297
+ // T2 顶栏压缩:展开态同步到 ref(keydown 闭包读 ref 避免过期)
298
+ React.useEffect(() => { searchOpenRef.current = searchOpen }, [searchOpen])
299
+ React.useEffect(() => { capOpenRef.current = capOpen }, [capOpen])
300
+ // T2 顶栏压缩:展开时自动聚焦(Ctrl+K/Ctrl+N 改为 setSearchOpen(true)/setCapOpen(true),由此 effect 完成聚焦)
301
+ React.useEffect(() => { if (searchOpen && searchInputRef.current) searchInputRef.current.focus() }, [searchOpen])
302
+ React.useEffect(() => {
303
+ if (capOpen && capRef.current) {
304
+ capRef.current.focus()
305
+ const t = capRef.current
306
+ t.style.height = 'auto'
307
+ t.style.height = Math.min(110, Math.max(38, t.scrollHeight)) + 'px'
308
+ }
309
+ }, [capOpen])
310
+ // 键盘导航:j/k 或 ↑/↓ 移动高亮,Enter 打开,Esc 关闭,Ctrl+K 聚焦搜索,Ctrl+N 聚焦捕获
311
+ React.useEffect(() => {
312
+ function onKeyDown(ev) {
313
+ if (!openRef.current) return
314
+ const t = ev.target
315
+ const inField = t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)
316
+ const mod = ev.ctrlKey || ev.metaKey
317
+ if (mod && (ev.key === 'k' || ev.key === 'K')) { ev.preventDefault(); setSearchOpen(true); return }
318
+ if (mod && (ev.key === 'n' || ev.key === 'N')) { ev.preventDefault(); setCapOpen(true); return }
319
+ if (ev.key === 'Escape') { ev.preventDefault(); if (capOpenRef.current) { setCapOpen(false); return } if (searchOpenRef.current) { setSearchOpen(false); return } closeRef.current(); return }
320
+ if (inField) return
321
+ const ids = pagedIdsRef.current
322
+ if (!ids.length) return
323
+ if (ev.key === 'j' || ev.key === 'ArrowDown') { ev.preventDefault(); moveFocusRef.current(ids, 1) }
324
+ else if (ev.key === 'k' || ev.key === 'ArrowUp') { ev.preventDefault(); moveFocusRef.current(ids, -1) }
325
+ else if (ev.key === 'Enter') {
326
+ const fid = focusIdRef.current
327
+ if (fid && ids.indexOf(fid) >= 0) { const n = notesRef.current.find(x => x.id === fid); if (n) selectNoteRef.current(n) }
328
+ }
329
+ }
330
+ document.addEventListener('keydown', onKeyDown)
331
+ return () => document.removeEventListener('keydown', onKeyDown)
332
+ }, [])
333
+ function onTitlebarMouseDown(ev) {
334
+ if (ev.target.closest('.dsh-notes-titlebar-btn')) return
335
+ const sx = ev.clientX, sy = ev.clientY, px = pos.x || 0, py = pos.y || 0
336
+ drag((ev2) => setPos({ x: Math.max(0, Math.min(window.innerWidth - 200, px + ev2.clientX - sx)), y: Math.max(0, Math.min(window.innerHeight - 100, py + ev2.clientY - sy)) }), saveState)
337
+ }
338
+ function onResizeMouseDown(direction, ev) {
339
+ ev.stopPropagation(); ev.preventDefault()
340
+ const sx = ev.clientX, sy = ev.clientY, ox = pos.x || 0, oy = pos.y || 0, sw = size.width, sh = size.height
341
+ drag((ev2) => {
342
+ const dx = ev2.clientX - sx, dy = ev2.clientY - sy
343
+ let nw = sw, nh = sh, nx = ox, ny = oy
344
+ if (direction.indexOf('e') >= 0) nw = sw + dx
345
+ if (direction.indexOf('s') >= 0) nh = sh + dy
346
+ if (direction.indexOf('w') >= 0) nw = sw - dx
347
+ if (direction.indexOf('n') >= 0) nh = sh - dy
348
+ nw = Math.max(420, Math.min(window.innerWidth - 40, nw)); nh = Math.max(340, Math.min(window.innerHeight - 40, nh))
349
+ if (direction.indexOf('w') >= 0) nx = ox + sw - nw
350
+ if (direction.indexOf('n') >= 0) ny = oy + sh - nh
351
+ setSize({ width: nw, height: nh }); setPos({ x: nx, y: ny })
352
+ }, saveState)
353
+ }
354
+ function onDividerMouseDown(ev) {
355
+ ev.preventDefault()
356
+ const sx = ev.clientX, sw = listWidth
357
+ drag((ev2) => setListWidth(Math.max(170, Math.min(400, sw + ev2.clientX - sx))), saveState)
358
+ }
359
+ // silent=true 时不显 loading(后台静默刷新,避免闪烁)
360
+ async function loadNotes(silent) { if (!silent) setLoading(true); setError(''); let list = []; try { const res = await rpc('notes-list'); list = res.notes || []; setNotes(list) } catch (err) { setError(String(err.message || err)) } if (!silent) setLoading(false); return list }
361
+ function selectNote(n) {
362
+ setSelected(n.id); setFocusId(n.id); setEdTitle(n.title); setEdTopic(n.topic && n.topic !== '分类中' ? n.topic : '')
363
+ keepQuickRef.current = (n.tags || []).indexOf('quick') >= 0
364
+ setEdTags((n.tags || []).filter(t => t !== 'quick').join(', '))
365
+ setEdKind(n.kind || 'note'); setEdStatus(n.status || 'active'); setEdInject(n.inject === true); setEdScope(n.injectTo || [])
366
+ setEdBody(''); setTopicPickFor(null)
367
+ // 列表是瘦身数据,正文按需加载
368
+ const id = n.id
369
+ rpc('notes-get', { id: id }).then(res => { if (res && res.note && selectedRef.current === id) setEdBody(res.note.body || '') }).catch(() => {})
370
+ }
371
+ function syncTopicLater(id) {
372
+ const check = async () => { const list = await loadNotes(true); const n = list.find(x => x.id === id); if (n && n.topic && n.topic !== '分类中') setEdTopic(prev => (prev === '' && selectedRef.current === id) ? n.topic : prev) }
373
+ later(check, 3800)
374
+ later(check, 8500)
375
+ }
376
+ async function doCapture() {
377
+ const text = capText.trim()
378
+ if (!text || capPending) return
379
+ setCapPending(true); setError('')
380
+ try {
381
+ const res = await rpc('notes-quick', { text: text, sessionId: currentSessionId })
382
+ if (res && res.error) { setError(res.error); return }
383
+ setCapText('')
384
+ if (capRef.current) { capRef.current.style.height = '38px'; capRef.current.focus() }
385
+ setCapSaved(true); later(() => setCapSaved(false), 1600)
386
+ showToast(res && res.merged ? '已合并到本次速记' : '已记录,正在识别主题…')
387
+ if (res && res.id) { setFlashId(res.id); later(() => setFlashId(null), 1800) }
388
+ const list = await loadNotes(true)
389
+ if (res && res.id) { const n = list.find(x => x.id === res.id); if (n) selectNote(n); syncTopicLater(res.id) }
390
+ notifyNotesChanged()
391
+ } catch (err) { setError(String(err.message || err)) } finally { setCapPending(false) }
392
+ }
393
+ async function doSave() {
394
+ const id = selectedRef.current
395
+ if (!id) return
396
+ setError('')
397
+ const tags = (edTagsRef.current || '').split(/[,,;;]/).map(s => s.trim()).filter(Boolean)
398
+ if (keepQuickRef.current && tags.indexOf('quick') < 0) tags.push('quick')
399
+ const upd = { id: id, title: edTitleRef.current, tags: tags, body: edBodyRef.current, kind: edKindRef.current, status: edStatusRef.current, inject: edInjectRef.current, injectTo: edScopeRef.current }
400
+ if ((edTopicRef.current || '').trim()) upd.topic = edTopicRef.current.trim()
401
+ try {
402
+ const res = await rpc('notes-update', upd)
403
+ if (res && res.error) { setError(res.error); return }
404
+ setSavedAt(Date.now())
405
+ await loadNotes(true); notifyNotesChanged()
406
+ } catch (err) { setError(String(err.message || err)) }
407
+ }
408
+ async function doDelete(id) {
409
+ if (!id) return
410
+ setError('')
411
+ try {
412
+ const res = await rpc('notes-delete', { id: id })
413
+ if (res.error) { setError(res.error); return }
414
+ if (selected === id) { setSelected(null); setEdTitle(''); setEdTopic(''); setEdTags(''); setEdBody('') }
415
+ showToast('已删除(可由 Agent 恢复)')
416
+ await loadNotes(true); notifyNotesChanged()
417
+ } catch (err) { setError(String(err.message || err)) }
418
+ }
419
+ async function pickTopic(id, topic) {
420
+ setTopicPickFor(null); setError('')
421
+ try {
422
+ const res = await rpc('notes-update', { id: id, topic: topic })
423
+ if (res && res.error) { setError(res.error); return }
424
+ if (selectedRef.current === id) setEdTopic(topic)
425
+ await loadNotes(true)
426
+ } catch (err) { setError(String(err.message || err)) }
427
+ }
428
+ function close() { panelOpen = false; notify() }
429
+ function jumpToSession(sessionId) { if (sessions && sessionId) { try { sessions.open(sessionId) } catch (err) {} } }
430
+ // 注入开关:独立字段 inject,不碰标签
431
+ function toggleInject() { setEdInject(!edInject); triggerAutoSave() }
432
+ // 范围多选:切换某个目标(global/workspace/会话短id)的选中态
433
+ function toggleScope(key) {
434
+ const cur = edScopeRef.current || []
435
+ let next
436
+ if (key === 'global') {
437
+ // global 是排他的:选了 global 就清空其他
438
+ next = cur.indexOf('global') >= 0 ? [] : ['global']
439
+ } else {
440
+ const withoutGlobal = cur.filter(t => t !== 'global')
441
+ next = withoutGlobal.indexOf(key) >= 0 ? withoutGlobal.filter(t => t !== key) : withoutGlobal.concat([key])
442
+ }
443
+ setEdScope(next)
444
+ triggerAutoSave()
445
+ }
446
+ // 任务派发:加载活跃会话/工作区 + 打开对话框 + 确认派发
447
+ async function loadActiveSessions() {
448
+ try { const res = await rpc('notes-active-sessions', {}); if (res && res.sessions) setActiveSessions(res.sessions) } catch (e) {}
449
+ }
450
+ async function loadWorkspaces() {
451
+ try { const res = await rpc('notes-workspaces', {}); if (res && res.workspaces) setWsList(res.workspaces) } catch (e) {}
452
+ }
453
+ function openDispatch() {
454
+ setDispatchInstr(''); setDispatchSessId(''); setDispatchSessWs(''); setDispatchWsId(''); setDispatchMode('existing'); setError('')
455
+ loadActiveSessions(); loadWorkspaces(); setDispatchOpen(true)
456
+ }
457
+ async function doDispatchConfirm() {
458
+ if (!selected || dispatching) return
459
+ setDispatching(true); setError('')
460
+ try {
461
+ if (dispatchMode === 'new') {
462
+ // 新建会话派发:client connectWorkspace 复用/新建一个 live 会话,再注入上下文+触发工作
463
+ if (!dispatchWsId) { setError('请选择工作区'); setDispatching(false); return }
464
+ if (!workspaces || !workspaces.connectWorkspace) { setError('workspaces 服务不可用'); setDispatching(false); return }
465
+ const ws = wsList.find(w => w.id === dispatchWsId)
466
+ const newSid = await workspaces.connectWorkspace(dispatchWsId)
467
+ const res = await rpc('notes-dispatch', { id: selected, sessionId: newSid, sessionName: (ws ? ws.title : '新会话'), workspace: ws ? ws.title : '', mode: 'new', instruction: dispatchInstr })
468
+ if (res && res.error) { setError(res.error); setDispatching(false); return }
469
+ if (sessions && newSid) { try { sessions.open(newSid) } catch (e) {} }
470
+ showToast('已新建会话,待办已注入并开始处理')
471
+ } else {
472
+ // 已有会话派发
473
+ if (!dispatchSessId) { setError('请选择目标会话'); setDispatching(false); return }
474
+ const sess = activeSessions.find(s => s.id === dispatchSessId)
475
+ // 目标未打开(不 live):先打开激活,等它上线后再注入触发
476
+ if (sess && !sess.live && sessions && sessions.open) {
477
+ try { sessions.open(sess.id) } catch (e) {}
478
+ await timer.timeout(1200)
479
+ }
480
+ const res = await rpc('notes-dispatch', { id: selected, sessionId: dispatchSessId, sessionName: sess ? sess.name : '', workspace: sess ? sess.workspace : '', mode: 'existing', instruction: dispatchInstr })
481
+ if (res && res.error) { setError(res.error); setDispatching(false); return }
482
+ showToast((sess && !sess.live ? '已打开并派发待办到「' : '已派发待办到「') + (sess ? sess.name : '') + '」(开始处理)')
483
+ }
484
+ setDispatchOpen(false); setDispatchInstr('')
485
+ const g = await rpc('notes-get', { id: selected }); if (g && g.note) setEdBody(g.note.body || '')
486
+ await loadNotes(true); notifyNotesChanged()
487
+ } catch (err) { setError(String(err.message || err)) } finally { setDispatching(false) }
488
+ }
489
+ // 标记一条派发待办为完成(停止注入目标会话系统提示)
490
+ async function doDispatchDone(origIndex) {
491
+ try {
492
+ const r = await rpc('notes-dispatch-done', { id: selected, dispatchIndex: origIndex })
493
+ if (r && r.error) { setError(r.error); return }
494
+ showToast('已标记完成'); await loadNotes(true); notifyNotesChanged()
495
+ } catch (err) { setError(String(err.message || err)) }
496
+ }
497
+ async function doArchive() { setError(''); try { const res = await rpc('notes-archive'); if (res.error) { setError(res.error); return } showToast('归档完成:合并 ' + (res.merged || 0) + ' 组'); await loadNotes(true); notifyNotesChanged() } catch (err) { setError(String(err.message || err)) } }
498
+ // 同步键盘导航所需 ref(keydown 监听挂一次,每次渲染刷新最新值)
499
+ openRef.current = open
500
+ focusIdRef.current = focusId
501
+ notesRef.current = notes
502
+ selectNoteRef.current = selectNote
503
+ closeRef.current = close
504
+ moveFocusRef.current = (ids, delta) => {
505
+ setFocusId(prev => {
506
+ const idx = prev ? ids.indexOf(prev) : -1
507
+ const next = idx < 0 ? (delta > 0 ? 0 : ids.length - 1) : Math.max(0, Math.min(ids.length - 1, idx + delta))
508
+ return (ids[next] != null) ? ids[next] : null
509
+ })
510
+ }
511
+ // 同步编辑字段 ref(供自动保存 debounce 读最新值)
512
+ edTitleRef.current = edTitle
513
+ edTopicRef.current = edTopic
514
+ edTagsRef.current = edTags
515
+ edBodyRef.current = edBody
516
+ edKindRef.current = edKind
517
+ edStatusRef.current = edStatus
518
+ edInjectRef.current = edInject
519
+ edScopeRef.current = edScope
520
+ // 自动保存:debounce 只注册一次(null 时赋值),回调读 ref 避免闭包过期
521
+ if (!autoSaveRef.current) autoSaveRef.current = timer.debounce(() => { if (selectedRef.current) doSave() }, 900)
522
+ function triggerAutoSave() { if (autoSaveRef.current) autoSaveRef.current() }
523
+ if (!open) return null
524
+ function groupByTopic(list) { const map = new Map(); for (const n of list) { const t = n.topic || '未分类'; if (!map.has(t)) map.set(t, []); map.get(t).push(n) } return Array.from(map.entries()) }
525
+ function highlight(text, q) { if (!q || !text) return text; const s = String(text); const esc = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const parts = s.split(new RegExp('(' + esc + ')', 'gi')); if (parts.length === 1) return s; return parts.map((p, i) => i % 2 === 1 ? e('mark', { key: i, className: 'dsh-notes-mark' }, p) : p) }
526
+ const q = searchText.trim().toLowerCase()
527
+ const localFiltered = q ? notes.filter(n => { const hay = ((n.title || '') + ' ' + (n.preview || '') + ' ' + (n.topic || '') + ' ' + (n.tags || []).join(' ')).toLowerCase(); return hay.indexOf(q) >= 0 }) : notes
528
+ // 搜索结果取 host 全文 + 本地即时的并集,RPC 失败/延迟时本地结果保底
529
+ let filtered = searchIds ? notes.filter(n => searchIds.indexOf(n.id) >= 0 || localFiltered.indexOf(n) >= 0) : localFiltered
530
+ // kind 筛选 + 置顶筛选
531
+ if (kindFilter !== 'all') filtered = filtered.filter(n => (n.kind || 'note') === kindFilter)
532
+ if (pinnedOnly) filtered = filtered.filter(n => n.status === 'pinned')
533
+ // 懒加载分页:只渲染前 visibleCount 条,滚动到底再加载更多(避免笔记多时全量渲染 + 每条跑 highlight)
534
+ const paged = filtered.slice(0, visibleCount)
535
+ const hasMore = filtered.length > visibleCount
536
+ pagedIdsRef.current = paged.map(n => n.id)
537
+ function onListScroll(ev) {
538
+ const el = ev.target
539
+ if (el.scrollTop + el.clientHeight >= el.scrollHeight - 40) { setVisibleCount(c => c + PAGE_SIZE) }
540
+ }
541
+ // 注入范围文字:工作区 / 全局 / 会话名(查 sessList 拿名字,拿不到回退短 id)
542
+ // 注入范围文字(injectTo 是多选数组):[] = 本工作区;含 global = 全局;否则列出所选会话名
543
+ function injectScopeLabel(injectTo) {
544
+ const arr = injectTo || []
545
+ if (arr.length === 0) return '本工作区'
546
+ if (arr.indexOf('global') >= 0) return '全局'
547
+ const names = arr.map(t => {
548
+ if (t === 'workspace') return '本工作区'
549
+ const s = sessList.find(x => x.short === t)
550
+ return s ? s.name : ('会话 ' + t)
551
+ })
552
+ return names.join('、')
553
+ }
554
+ // Apple Notes 风格列表项:kind 图标 + 标题 + 时间单行;meta 行极简;删除 hover 显示
555
+ function renderNoteItem(n) {
556
+ const statusCls = (n.status === 'pinned' ? ' pinned' : '') + (n.status === 'resolved' ? ' resolved' : '') + (n.status === 'superseded' ? ' superseded' : '')
557
+ const kindIc = KIND_ICONS[n.kind] || KIND_ICONS.note
558
+ const isConv = n.inject === true
559
+ const visTags = (n.tags || []).filter(t => t !== 'quick' && t !== 'convention')
560
+ // meta 行:⚡注入徽章(约定笔记,最前)+ 主题 + 会话 + 其他标签
561
+ const metaEls = []
562
+ if (isConv) metaEls.push(e('span', { className: 'dsh-note-inject dsh-nt', 'data-tooltip': '注入为约定 · 范围:' + injectScopeLabel(n.injectTo) }, '⚡ ' + injectScopeLabel(n.injectTo)))
563
+ if (n.topic && n.topic !== '分类中') metaEls.push(e('span', { className: 'dsh-note-meta-topic' }, n.topic))
564
+ if (n.sessionId) metaEls.push(e('span', { className: 'dsh-note-meta-sess' }, '会话 ' + shortSid(n.sessionId)))
565
+ if (visTags.length) metaEls.push(e('span', { className: 'dsh-note-meta-tags' }, visTags.join(' · ')))
566
+ return e('div', { key: n.id, className: 'dsh-note-item' + (selected === n.id ? ' selected' : '') + (flashId === n.id ? ' flash' : '') + (focusId === n.id ? ' focused' : '') + statusCls, onClick: () => selectNote(n), 'data-tooltip': n.title },
567
+ e('div', { className: 'dsh-note-row' },
568
+ e('span', { className: 'dsh-note-kind-ic dsh-note-kind-' + (n.kind || 'note') }, kindIc),
569
+ e('span', { className: 'dsh-note-title' }, e('span', { className: 'dsh-note-title-text' }, highlight(n.title, q))),
570
+ e('span', { className: 'dsh-note-date' }, n.updatedAt ? n.updatedAt.slice(0, 10) : '')),
571
+ metaEls.length ? e('div', { className: 'dsh-note-meta' }, metaEls) : null,
572
+ e('button', { className: 'dsh-note-delete dsh-nt', onClick: (ev) => { ev.stopPropagation(); doDelete(n.id) }, 'data-tooltip': '删除' }, '×'))
573
+ }
574
+ let listContent
575
+ if (loading && notes.length === 0) listContent = e('div', { className: 'dsh-notes-loading' }, '加载中...')
576
+ else if (filtered.length === 0) listContent = e('div', { className: 'dsh-notes-empty-state' },
577
+ e('div', { className: 'dsh-notes-empty-ic' }, q ? '⌕' : '📝'),
578
+ e('div', { className: 'dsh-notes-empty-t' }, q ? '无匹配结果' : '还没有笔记'),
579
+ e('div', { className: 'dsh-notes-empty-s' }, q ? '换个关键词试试,或清空筛选' : '在上方输入框记点什么,主题会自动识别'),
580
+ !q ? e('button', { className: 'dsh-notes-empty-btn', onClick: () => setCapOpen(true) }, '记第一条') : null)
581
+ else {
582
+ listContent = []
583
+ const pinned = paged.filter(n => n.status === 'pinned')
584
+ const rest = paged.filter(n => n.status !== 'pinned')
585
+ if (pinned.length) {
586
+ listContent.push(e('div', { key: 'grp-pinned', className: 'dsh-notes-topic-header' }, e('span', null, '📌 置顶'), e('span', { className: 'dsh-notes-topic-count' }, pinned.length)))
587
+ pinned.forEach(n => listContent.push(renderNoteItem(n)))
588
+ }
589
+ for (const [topic, topicNotes] of groupByTopic(rest)) {
590
+ listContent.push(e('div', { key: 'topic-' + topic, className: 'dsh-notes-topic-header' }, e('span', null, topic === '分类中' ? '识别中' : topic), e('span', { className: 'dsh-notes-topic-count' }, topicNotes.length)))
591
+ topicNotes.forEach(n => listContent.push(renderNoteItem(n)))
592
+ }
593
+ }
594
+ // 是否注入为约定:由独立的 inject 布尔字段决定(不依赖标签)
595
+ const isConvention = edInject
596
+ // 当前选中笔记(详情区多处用)
597
+ const curNote = notes.find(n => n.id === selected) || null
598
+ const curDispatches = (curNote && curNote.dispatches) || []
599
+ // 范围浮层:会话按工作区分组(两级:工作区 → 会话)
600
+ const scopeByWs = {}
601
+ for (const s of sessList) { const w = s.workspace || '其他'; if (!scopeByWs[w]) scopeByWs[w] = []; scopeByWs[w].push(s) }
602
+ const scopeWsKeys = Object.keys(scopeByWs).sort()
603
+ // 派发对话框:已有会话模式按工作区过滤活跃会话;新建会话模式选工作区
604
+ const dispatchWsKeys = []
605
+ const dispatchSessByWs = {}
606
+ for (const s of activeSessions) { const w = s.workspace || '其他'; if (!dispatchSessByWs[w]) { dispatchSessByWs[w] = []; dispatchWsKeys.push(w) } dispatchSessByWs[w].push(s) }
607
+ return e('div', { className: 'dsh-notes-floating', style: { left: (pos.x || 0) + 'px', top: (pos.y || 0) + 'px', width: size.width + 'px', height: size.height + 'px' } },
608
+ e('div', { className: 'dsh-notes-titlebar dsh-nt', onMouseDown: onTitlebarMouseDown, 'data-tooltip': '拖拽移动窗口' },
609
+ e('span', { className: 'dsh-notes-titlebar-title' }, '笔记'),
610
+ e('span', { className: 'dsh-notes-titlebar-grip' }, '⋮⋮'),
611
+ e('div', { className: 'dsh-notes-titlebar-actions' },
612
+ e('button', { className: 'dsh-notes-titlebar-btn dsh-nt', onClick: () => setEntryMode(entryMode === 'header' ? 'fab' : 'header'), 'data-tooltip': '切换入口模式:会话头部 / 悬浮气泡' }, '⇄'),
613
+ e('button', { className: 'dsh-notes-titlebar-btn dsh-nt', onClick: doArchive, 'data-tooltip': '归档合并:速记按会话、普通笔记按标签' }, '归档'),
614
+ e('button', { className: 'dsh-notes-titlebar-btn dsh-nt', onClick: () => setShowHelp(!showHelp), 'data-tooltip': '使用说明' }, '?'),
615
+ e('button', { className: 'dsh-notes-titlebar-btn dsh-nt', onClick: close, 'data-tooltip': '关闭' }, '×'))),
616
+ showHelp ? e('div', { className: 'dsh-notes-help-bubble' },
617
+ e('button', { className: 'dsh-notes-help-close', onClick: () => setShowHelp(false) }, '×'),
618
+ e('h4', null, '使用说明'),
619
+ e('ul', null,
620
+ e('li', null, '点 ', e('kbd', null, '+'), ' 图标展开输入框,按 ', e('kbd', null, 'Enter'), ' 快速记录,主题自动识别'),
621
+ e('li', null, '点 ', e('kbd', null, '⌕'), ' 图标展开搜索;选中页面文字后用 + 记录'),
622
+ e('li', null, '同一会话 10 分钟内的速记自动合并'),
623
+ e('li', null, '点击笔记的主题标签可快速更换'),
624
+ e('li', null, '点击标题跳转到来源会话'),
625
+ e('li', null, '快捷键:', e('kbd', null, 'Ctrl+K'), ' 搜索、', e('kbd', null, 'Ctrl+N'), ' 新建、', e('kbd', null, 'j/k'), ' 或 ', e('kbd', null, '↑↓'), ' 移动、', e('kbd', null, 'Enter'), ' 打开、', e('kbd', null, 'Esc'), ' 关闭'),
626
+ e('li', null, '「归档」整理:速记按会话、笔记按标签合并'),
627
+ e('li', null, '删除是软删除,可让 Agent 恢复'))) : null,
628
+ e('div', { className: 'dsh-notes-toolbar' },
629
+ e('button', { className: 'dsh-notes-tool-btn dsh-nt' + (searchOpen ? ' on' : ''), onClick: () => setSearchOpen(!searchOpen), 'data-tooltip': '搜索笔记(Ctrl+K)' }, '⌕'),
630
+ e('button', { className: 'dsh-notes-tool-btn dsh-nt' + (capOpen ? ' on' : ''), onClick: () => setCapOpen(!capOpen), 'data-tooltip': '新建笔记(Ctrl+N)' }, '+'),
631
+ e('div', { className: 'dsh-notes-kinds' },
632
+ ['all', 'note', 'decision', 'todo', 'link', 'quote'].map(k => e('button', { key: k, className: 'dsh-notes-kind-chip' + (kindFilter === k ? ' on' : ''), onClick: () => { setKindFilter(k); setVisibleCount(PAGE_SIZE) } }, k === 'all' ? '全部' : KIND_LABELS[k])),
633
+ e('button', { className: 'dsh-notes-pin-toggle' + (pinnedOnly ? ' on' : ''), onClick: () => { setPinnedOnly(!pinnedOnly); setVisibleCount(PAGE_SIZE) }, 'data-tooltip': '只看置顶' }, '📌 置顶'))),
634
+ searchOpen ? e('div', { className: 'dsh-notes-search dsh-notes-expand' },
635
+ e('div', { className: 'dsh-notes-search-box' },
636
+ e('span', { className: 'dsh-notes-search-icon' }, '⌕'),
637
+ e('input', { ref: searchInputRef, className: 'dsh-notes-search-input', placeholder: '搜索笔记、标签、内容…', value: searchText, onChange: (ev) => { searchRef.current = ev.target.value; setSearchText(ev.target.value); setSearchIds(null); setVisibleCount(PAGE_SIZE); if (searchDebRef.current) searchDebRef.current() } })),
638
+ e('button', { className: 'dsh-notes-expand-close dsh-nt', onClick: () => setSearchOpen(false), 'data-tooltip': '收起搜索' }, '×')) : null,
639
+ capOpen ? e('div', { className: 'dsh-notes-capture dsh-notes-expand' },
640
+ e('textarea', { ref: capRef, className: 'dsh-notes-capture-input', placeholder: '记点什么…(Enter 保存,Shift+Enter 换行)', value: capText, rows: 1,
641
+ onChange: (ev) => { setCapText(ev.target.value); const t = ev.target; t.style.height = 'auto'; t.style.height = Math.min(110, Math.max(38, t.scrollHeight)) + 'px' },
642
+ onKeyDown: (ev) => { if (ev.key === 'Enter' && !ev.shiftKey && !ev.ctrlKey && !ev.metaKey) { ev.preventDefault(); doCapture(); setCapOpen(false) } } }),
643
+ e('button', { className: 'dsh-notes-capture-btn dsh-nt' + (capSaved ? ' saved' : ''), onClick: () => { doCapture(); setCapOpen(false) }, disabled: capPending || !capText.trim(), 'data-tooltip': '保存这条记录' }, capPending ? '…' : (capSaved ? '✓' : '记录'))) : null,
644
+ e('div', { className: 'dsh-notes-content' },
645
+ e('div', { className: 'dsh-notes-list', style: { width: listWidth + 'px' } },
646
+ e('div', { className: 'dsh-notes-list-items', onScroll: onListScroll }, listContent, hasMore ? e('div', { className: 'dsh-notes-more' }, '继续滚动加载更多(已显示 ' + paged.length + ' / ' + filtered.length + ')') : null)),
647
+ e('div', { className: 'dsh-notes-divider dsh-nt', onMouseDown: onDividerMouseDown, 'data-tooltip': '拖拽调整宽度' }),
648
+ selected ? e('div', { className: 'dsh-notes-editor' },
649
+ e('div', { className: 'dsh-notes-ed-head' },
650
+ e('input', { className: 'dsh-notes-editor-title', placeholder: '标题', value: edTitle, onChange: (ev) => { setEdTitle(ev.target.value); triggerAutoSave() } }),
651
+ e('div', { className: 'dsh-notes-ed-meta' },
652
+ e('select', { className: 'dsh-notes-ed-select', value: edKind, onChange: (ev) => { setEdKind(ev.target.value); triggerAutoSave() }, 'data-tooltip': '笔记类型' },
653
+ e('option', { value: 'note' }, '笔记'),
654
+ e('option', { value: 'decision' }, '决策'),
655
+ e('option', { value: 'todo' }, '待办'),
656
+ e('option', { value: 'link' }, '链接'),
657
+ e('option', { value: 'quote' }, '引用')),
658
+ e('select', { className: 'dsh-notes-ed-select', value: edStatus, onChange: (ev) => { setEdStatus(ev.target.value); triggerAutoSave() }, 'data-tooltip': '状态' },
659
+ e('option', { value: 'active' }, '进行中'),
660
+ e('option', { value: 'pinned' }, '置顶'),
661
+ e('option', { value: 'resolved' }, '已解决'),
662
+ e('option', { value: 'superseded' }, '已取代')),
663
+ e('input', { className: 'dsh-notes-ed-topic', placeholder: '主题', value: edTopic, onChange: (ev) => { setEdTopic(ev.target.value); triggerAutoSave() }, 'data-tooltip': '主题' }),
664
+ e('input', { className: 'dsh-notes-ed-topic', placeholder: '标签,逗号分隔', value: edTags, onChange: (ev) => { setEdTags(ev.target.value); triggerAutoSave() }, 'data-tooltip': '标签(convention 表示工作区约定)' }),
665
+ e('div', { className: 'dsh-notes-ed-meta-right' },
666
+ e('button', { className: 'dsh-notes-ed-action dsh-nt', onClick: (ev) => { ev.stopPropagation(); openDispatch() }, 'data-tooltip': '派发待办到会话(可补充具体要求)' }, dispatching ? '…' : '▶ 派发'),
667
+ notes.find(n => n.id === selected) && notes.find(n => n.id === selected).sessionId ? e('button', { className: 'dsh-notes-ed-action dsh-nt', onClick: () => jumpToSession(notes.find(n => n.id === selected).sessionId), 'data-tooltip': '跳转到来源会话' }, '↗ 会话') : null,
668
+ e('button', { className: 'dsh-notes-ed-action' + (edStatus === 'pinned' ? ' on' : ''), onClick: () => { setEdStatus(edStatus === 'pinned' ? 'active' : 'pinned'); triggerAutoSave() }, 'data-tooltip': edStatus === 'pinned' ? '取消置顶' : '置顶' }, '📌'),
669
+ e('button', { className: 'dsh-notes-ed-action danger', onClick: () => doDelete(selected), 'data-tooltip': '删除(软删除,可恢复)' }, '🗑'))),
670
+ e('div', { className: 'dsh-notes-ed-injectrow' },
671
+ e('button', { className: 'dsh-notes-ed-inject-btn dsh-nt' + (isConvention ? ' on' : ''), onClick: () => { toggleInject(); if (!isConvention) setScopeOpen(true) }, 'data-tooltip': '作为约定注入到系统提示(Agent 每回合可见)' }, isConvention ? '⚡ 注入中' : '注入为约定'),
672
+ isConvention ? e('div', { className: 'dsh-notes-ed-scope-wrap' },
673
+ e('button', { className: 'dsh-notes-scope-trigger dsh-nt', onClick: (ev) => { ev.stopPropagation(); setScopeOpen(!scopeOpen) }, 'data-tooltip': '选择注入范围(可多选)' },
674
+ injectScopeLabel(edScope), e('span', { className: 'dsh-notes-scope-caret' }, ' ▾')),
675
+ scopeOpen ? e('div', { className: 'dsh-notes-scope-panel' },
676
+ e('label', { className: 'dsh-notes-scope-item' }, e('input', { type: 'checkbox', checked: edScope.length === 0 || edScope.indexOf('workspace') >= 0, onChange: () => toggleScope('workspace') }), ' 本工作区'),
677
+ e('label', { className: 'dsh-notes-scope-item' }, e('input', { type: 'checkbox', checked: edScope.indexOf('global') >= 0, onChange: () => toggleScope('global') }), ' 全局(所有会话)'),
678
+ scopeWsKeys.length ? e('div', { className: 'dsh-notes-scope-sep' }, '指定会话') : null,
679
+ scopeWsKeys.map(ws => e('div', { key: ws, className: 'dsh-notes-scope-group' },
680
+ e('div', { className: 'dsh-notes-scope-ws' }, ws),
681
+ scopeByWs[ws].map(s => e('label', { key: s.id, className: 'dsh-notes-scope-item dsh-notes-scope-sess' },
682
+ e('input', { type: 'checkbox', checked: edScope.indexOf(s.short) >= 0, onChange: () => toggleScope(s.short) }),
683
+ ' ' + s.name)))))
684
+ : null)
685
+ : null)),
686
+ curDispatches.length ? e('div', { className: 'dsh-notes-dispatch-history' },
687
+ e('div', { className: 'dsh-notes-dispatch-history-t' }, '▶ 派发历史(' + curDispatches.length + ')'),
688
+ curDispatches.map((d, origIdx) => ({ d: d, origIdx: origIdx })).reverse().map(({ d, origIdx }) => e('div', { key: origIdx, className: 'dsh-notes-dispatch-rec' + (d.done ? ' done' : '') },
689
+ e('div', { className: 'dsh-notes-dispatch-rec-top' },
690
+ e('span', { className: 'dsh-notes-dispatch-rec-t' }, (d.done ? '✓ ' : '● ') + (d.sessionName || d.sessionId)),
691
+ e('span', { className: 'dsh-notes-dispatch-rec-m' }, (d.done ? '已完成 · ' : '待处理 · ') + (d.mode === 'new' ? '新会话' : (d.workspace || '已有会话')) + (d.at ? ' · ' + String(d.at).slice(5, 16).replace('T', ' ') : ''))),
692
+ d.instruction ? e('div', { className: 'dsh-notes-dispatch-rec-i' }, '要求:' + d.instruction) : null,
693
+ !d.done ? e('button', { className: 'dsh-notes-dispatch-done-btn', onClick: () => doDispatchDone(origIdx) }, '标记完成') : null)))
694
+ : null,
695
+ e('textarea', { className: 'dsh-notes-editor-body', placeholder: '开始记录…(支持 Markdown)', value: edBody, onChange: (ev) => { setEdBody(ev.target.value); triggerAutoSave() } }),
696
+ e('div', { className: 'dsh-notes-ed-foot' },
697
+ e('span', { className: 'dsh-notes-ed-saved' + (savedAt ? ' show' : '') }, savedAt ? '已自动保存 ' + new Date(savedAt).toTimeString().slice(0, 5) : ''),
698
+ e('span', null, (edBody || '').length + ' 字')))
699
+ : e('div', { className: 'dsh-notes-editor-empty' },
700
+ e('div', { className: 'dsh-notes-editor-empty-ic' }, '✎'),
701
+ e('div', { className: 'dsh-notes-editor-empty-t' }, '选择一条笔记查看和编辑'),
702
+ e('div', { className: 'dsh-notes-editor-empty-s' }, '在上方输入框直接记录,主题自动识别'))),
703
+ e('div', { className: 'dsh-notes-resize-handle dsh-nt', style: { position: 'absolute', bottom: 0, right: 0 }, onMouseDown: (ev) => onResizeMouseDown('se', ev), 'data-tooltip': '拖拽调整' }),
704
+ error && !dispatchOpen ? e('div', { className: 'dsh-notes-error' }, error) : null,
705
+ // 派发对话框(modal):todo 上下文预览 + 补充具体要求 + 已有/新建会话(级联下拉)
706
+ (dispatchOpen && curNote) ? e('div', { className: 'dsh-notes-dispatch-mask', onMouseDown: (ev) => { if (ev.target === ev.currentTarget) setDispatchOpen(false) } },
707
+ e('div', { className: 'dsh-notes-dispatch-modal' },
708
+ e('div', { className: 'dsh-notes-dispatch-modal-t' }, '▶ 派发待办', e('span', { style: { fontSize: '10px', color: 'var(--nt3)', fontWeight: 400, marginLeft: '8px' } }, '工作区' + wsList.length + ' / 活跃会话' + activeSessions.length)),
709
+ e('div', { className: 'dsh-notes-dispatch-todo' },
710
+ e('div', { className: 'dsh-notes-dispatch-todo-t' }, curNote.title || 'Untitled'),
711
+ e('div', { className: 'dsh-notes-dispatch-todo-b' }, String(curNote.preview || '').trim() || '(无正文)')),
712
+ e('textarea', { className: 'dsh-notes-dispatch-instr', placeholder: '补充具体要求 / 指令(可选)…', value: dispatchInstr, onChange: (ev) => setDispatchInstr(ev.target.value), rows: 3 }),
713
+ e('div', { className: 'dsh-notes-dispatch-modes' },
714
+ e('button', { className: 'dsh-notes-dispatch-mode' + (dispatchMode === 'existing' ? ' on' : ''), onClick: () => setDispatchMode('existing') }, '已有会话'),
715
+ e('button', { className: 'dsh-notes-dispatch-mode' + (dispatchMode === 'new' ? ' on' : ''), onClick: () => setDispatchMode('new') }, '新建会话')),
716
+ dispatchMode === 'existing' ? e(React.Fragment, null,
717
+ e('select', { className: 'dsh-notes-dispatch-select', value: dispatchSessWs, onChange: (ev) => { setDispatchSessWs(ev.target.value); setDispatchSessId('') } },
718
+ e('option', { value: '' }, '选择工作区…'),
719
+ wsList.map(w => e('option', { key: w.id, value: w.title }, w.title))),
720
+ e('select', { className: 'dsh-notes-dispatch-select', value: dispatchSessId, onChange: (ev) => setDispatchSessId(ev.target.value), disabled: !dispatchSessWs },
721
+ e('option', { value: '' }, dispatchSessWs ? ((dispatchSessByWs[dispatchSessWs] || []).length ? '选择会话…' : '该工作区暂无会话') : '先选工作区'),
722
+ (dispatchSessByWs[dispatchSessWs] || []).map(s => e('option', { key: s.id, value: s.id }, s.name + (s.live ? '' : '(未打开)')))))
723
+ : e('select', { className: 'dsh-notes-dispatch-select', value: dispatchWsId, onChange: (ev) => setDispatchWsId(ev.target.value) },
724
+ e('option', { value: '' }, '选择工作区(在其下新建会话)…'),
725
+ wsList.map(w => e('option', { key: w.id, value: w.id }, w.title))),
726
+ error ? e('div', { className: 'dsh-notes-dispatch-err' }, error) : null,
727
+ e('div', { className: 'dsh-notes-dispatch-actions' },
728
+ e('button', { className: 'dsh-notes-dispatch-cancel', onClick: () => setDispatchOpen(false) }, '取消'),
729
+ e('button', { className: 'dsh-notes-dispatch-ok', onClick: doDispatchConfirm, disabled: dispatching }, dispatching ? '派发中…' : '派发'))))
730
+ : null)
731
+ }
732
+ slots.register({ name: 'shell.overlay', id: 'dsh-notes-panel', order: 200 }, (props) => e(FloatingPanel, props))
733
+ })
734
+ if (typeof d3 === 'function') disposers.push(d3)
735
+ const d4 = slots.inject('shell.overlay', () => {
736
+ function SelectionCapture() {
737
+ perf.selRender++
738
+ const [btn, setBtn] = React.useState(null)
739
+ const [toast, setToast] = React.useState('')
740
+ const [instrText, setInstrText] = React.useState('')
741
+ const selTextRef = React.useRef('')
742
+ const visibleRef = React.useRef(false)
743
+ const instrRef = React.useRef(null)
744
+ React.useEffect(() => { toastEmit = setToast; return () => { if (toastEmit === setToast) toastEmit = null } }, [])
745
+ React.useEffect(() => {
746
+ let mx = -1, my = -1, watchMouse = false, mouseDown = false
747
+ function hide() { if (visibleRef.current) { visibleRef.current = false; setBtn(null) } }
748
+ function onMouseMove(ev) { if (!watchMouse) return; mx = ev.clientX; my = ev.clientY; perf.mousemoveTracked++ }
749
+ function showFromSelection() {
750
+ perf.selShowEval++
751
+ const s0 = now()
752
+ try {
753
+ const sel = window.getSelection()
754
+ const text = sel ? sel.toString().trim() : ''
755
+ // 框已展开时,选区被点击清空(如点输入框)不关闭——只有框未展开且无选区才 hide
756
+ if (!text || text.length < 2) { if (!visibleRef.current) hide(); return }
757
+ let x, y
758
+ if (mx >= 0) {
759
+ x = mx - 160
760
+ y = my + 14
761
+ } else {
762
+ let rect
763
+ try { if (sel.rangeCount > 0) rect = sel.getRangeAt(0).getBoundingClientRect() } catch (err) {}
764
+ if (rect && !(rect.width === 0 && rect.height === 0)) {
765
+ x = rect.left + rect.width / 2 - 160
766
+ y = rect.bottom + 8
767
+ } else { hide(); return }
768
+ }
769
+ x = Math.min(Math.max(8, x), Math.max(60, window.innerWidth - 340))
770
+ y = Math.min(Math.max(8, y), Math.max(60, window.innerHeight - 200))
771
+ selTextRef.current = text
772
+ visibleRef.current = true
773
+ // 位置没有实质变化时不触发重渲染
774
+ setBtn(prev => (prev && Math.abs(prev.x - x) < 2 && Math.abs(prev.y - y) < 2) ? prev : { x, y })
775
+ } catch (err) {}
776
+ finally { perf.selShowMs += now() - s0 }
777
+ }
778
+ // 一次性注册的防抖器:timer.timeout 每次调用都会在 fiber 上注册 ctx.effect,击键频率下是持续簿记开销
779
+ const debouncedShow = timer.debounce(showFromSelection, 140)
780
+ function onSelectionChange() {
781
+ perf.selChange++
782
+ const sc0 = now()
783
+ try {
784
+ // 指令框已展开时保持稳定:避免聚焦输入框导致选区收起而误关(文本已在 selTextRef)
785
+ if (visibleRef.current) return
786
+ // 快速路径:光标态(无选区)直接跳过,不创建任何定时器——聊天输入框每次击键都触发本事件
787
+ let collapsed = true
788
+ let sel = null
789
+ try { sel = window.getSelection(); collapsed = !sel || sel.isCollapsed } catch (err) {}
790
+ if (collapsed) { perf.selCollapsedSkip++; watchMouse = false; hide(); return }
791
+ // 记录当前选区文本(供 mouseup 弹框预览与提交使用,提交不依赖实时选区)
792
+ try { selTextRef.current = sel ? sel.toString().trim() : '' } catch (err) {}
793
+ watchMouse = true
794
+ // 鼠标拖拽中:只记录选区文本与跟踪坐标,等 mouseup 才弹框(避免拖拽中途弹出打断选区)
795
+ if (mouseDown) return
796
+ // 键盘选择(无鼠标按下):正常防抖弹框
797
+ debouncedShow()
798
+ } finally { perf.selChangeMs += now() - sc0 }
799
+ }
800
+ function onMouseDown(ev) {
801
+ if (ev.target.closest && ev.target.closest('.dsh-notes-instruct-box')) return
802
+ // 开始新一次拖拽:置位 mouseDown、停止旧坐标跟踪、隐藏旧指令框
803
+ mouseDown = true; watchMouse = false; hide()
804
+ }
805
+ function onMouseUp(ev) {
806
+ // 拖拽结束:清除 mouseDown;选区非折叠且文本≥2字符时弹框(校验在 showFromSelection 内部)
807
+ mouseDown = false
808
+ // 点击指令框内部(输入框/按钮)的 mouseup 不重新评估选区——否则点输入框清空选区后会误关框
809
+ if (ev && ev.target && ev.target.closest && ev.target.closest('.dsh-notes-instruct-box')) return
810
+ showFromSelection()
811
+ }
812
+ document.addEventListener('selectionchange', onSelectionChange)
813
+ document.addEventListener('mousemove', onMouseMove, { passive: true })
814
+ document.addEventListener('mousedown', onMouseDown)
815
+ document.addEventListener('mouseup', onMouseUp)
816
+ return () => {
817
+ document.removeEventListener('selectionchange', onSelectionChange)
818
+ document.removeEventListener('mousemove', onMouseMove)
819
+ document.removeEventListener('mousedown', onMouseDown)
820
+ document.removeEventListener('mouseup', onMouseUp)
821
+ if (debouncedShow && debouncedShow.dispose) debouncedShow.dispose()
822
+ }
823
+ }, [])
824
+ React.useEffect(() => { if (!toast) return; const d = timer.timeout(() => setToast(''), 2600); return () => d() }, [toast])
825
+ // 弹框后不自动 focus 输入框:focus 会清除页面选区,打断拖拽并使选区丢失。
826
+ // 选区文本已存于 selTextRef,提交不依赖实时选区;用户需备注时手动点击输入框(自然 focus)。
827
+ // 选区预览:截断至 3 行 / 120 字符,避免指令框过高
828
+ function previewText(text) { if (!text) return ''; const lines = String(text).split(/\n/).slice(0, 3).join(' '); return lines.length > 120 ? lines.slice(0, 120) + '…' : lines }
829
+ async function submit() {
830
+ const text = selTextRef.current
831
+ const note = instrText.trim()
832
+ visibleRef.current = false; setBtn(null); setInstrText('')
833
+ if (window.getSelection()) window.getSelection().removeAllRanges()
834
+ if (!text) return
835
+ try {
836
+ if (!note) {
837
+ // 备注为空 → 现有逻辑(行为不变)
838
+ const res = await rpc('notes-quick', { text: text, sessionId: currentSessionId, kind: 'quote' })
839
+ if (res.error) { setToast('记录失败:' + res.error) }
840
+ else { setToast(res.merged ? '已合并到本次速记' : '已记录,正在识别主题…'); notifyNotesChanged() }
841
+ } else {
842
+ // 备注非空 → LLM 提取元数据,按返回结果 toast
843
+ const res = await rpc('notes-quick-instruct', { text: text, note: note, sessionId: currentSessionId })
844
+ if (res.error) { setToast('记录失败:' + res.error) }
845
+ else if (res.ok && res.applied) {
846
+ const a = res.applied
847
+ let msg = '已记录'
848
+ if (a.inject) msg = '已记录并设为约定'
849
+ else if (a.tags && a.tags.length) msg = '已记录并标记 #' + a.tags.join(' #')
850
+ else if (a.kind && a.kind !== 'note' && a.kind !== 'quote') msg = '已记录为' + (KIND_LABELS[a.kind] || a.kind)
851
+ else msg = res.merged ? '已合并到本次速记' : '已记录,正在识别主题…'
852
+ setToast(msg); notifyNotesChanged()
853
+ } else {
854
+ setToast(res.merged ? '已合并到本次速记' : '已记录,正在识别主题…'); notifyNotesChanged()
855
+ }
856
+ }
857
+ } catch (err) { setToast('记录失败:' + String(err.message || err)) }
858
+ }
859
+ function cancel() { visibleRef.current = false; setBtn(null); setInstrText(''); if (window.getSelection()) window.getSelection().removeAllRanges() }
860
+ return e('div', null, btn ? e('div', { className: 'dsh-notes-instruct-box', style: { left: btn.x + 'px', top: btn.y + 'px' } },
861
+ e('div', { className: 'dsh-notes-instruct-preview' }, previewText(selTextRef.current)),
862
+ e('input', { ref: instrRef, className: 'dsh-notes-instruct-input', type: 'text', placeholder: '可补充:打标签/引导标题/定类型/设为约定…直接回车则仅记录', value: instrText, onChange: function (ev) { setInstrText(ev.target.value) }, onKeyDown: function (ev) { if (ev.key === 'Enter') { ev.preventDefault(); submit() } else if (ev.key === 'Escape') { ev.preventDefault(); cancel() } } }),
863
+ e('div', { className: 'dsh-notes-instruct-actions' },
864
+ e('button', { className: 'dsh-notes-instruct-btn primary', onClick: submit }, '记录'),
865
+ e('button', { className: 'dsh-notes-instruct-btn', onClick: cancel }, '取消')
866
+ )
867
+ ) : null, e('div', { className: 'dsh-notes-toast' + (toast ? ' show' : '') }, toast))
868
+ }
869
+ slots.register({ name: 'shell.overlay', id: 'dsh-notes-selection', order: 201 }, () => e(SelectionCapture))
870
+ })
871
+ if (typeof d4 === 'function') disposers.push(d4)
872
+ ctx.effect(() => () => { for (const d of disposers) { try { d() } catch (e2) {} } })
873
+ console.log('notes plugin: client ready')
874
+ }
875
+
876
+ // 动态版 inject 为 ['timer','sessions','workspaces'];静态包按 PACKAGING.md 第 4 节保守处理:
877
+ // 只声明硬依赖 slots(没它就完全没有 UI),其余服务在 apply 内 ctx.get + 守卫,
878
+ // 避免服务未就绪时插件永远不启动。
879
+ module.exports = { name: 'dsh-notes-plugin', inject: ['slots', 'timer', 'sessions', 'workspaces'], apply: apply }
880
+ return module.exports
881
+ }
882
+ })