dsh-tiddlywiki 0.28.2 → 0.28.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-tiddlywiki",
3
3
  "description": "把 TiddlyWiki 5 变成你和 Agent 共用的本地文档中心(纯文本 + git):你在 DSH 界面里阅读、写作、记日记,Agent 用 15 个 tiddlywiki_* 工具读写同一批笔记;每条笔记都是你能读、能改、能审计、能随时带走的文件——不是自动记忆插件,不做后台抓取。可选一键发布到微信公众号草稿箱。 Turn TiddlyWiki 5 into a document center you and your agent share, in plain text under git — a knowledge base you can read, edit, audit and take with you, not an auto-memory plugin. Mounts via the official dsh plugin system; no DSH source changes.",
4
- "version": "0.28.2",
4
+ "version": "0.28.3",
5
5
  "type": "module",
6
6
  "keywords": [
7
7
  "dsh",
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Align a `conversation.input.dock` entry with the composer input card.
3
+ *
4
+ * WHY THIS EXISTS (v0.28.2)
5
+ * -------------------------
6
+ * The dock slot renders entries as a full-width flex column that is a SIBLING of
7
+ * the composer, not inside it. The composer's input card is centred and has its
8
+ * own max-width, so an entry's right edge sits far to the right of the input box
9
+ * — visibly misaligned.
10
+ *
11
+ * The fix is to measure the composer card and pad the entry's right side by the
12
+ * difference. That measurement used to live only inside `quick-note-dock.ts`, so
13
+ * the moment a SECOND dock entry was added (the per-session knowledge-base
14
+ * selector) it simply did not align — nobody had published the rule. This module
15
+ * is that rule, written once.
16
+ *
17
+ * HOW IT FINDS THE CARD
18
+ * ---------------------
19
+ * Climb from the entry until an ancestor's subtree contains a wide text field
20
+ * (>300px — the composer input; dock entries' own inputs are small), then take
21
+ * the widest box between that field and its containing column (the visible card).
22
+ *
23
+ * WHEN IT RE-MEASURES
24
+ * -------------------
25
+ * ResizeObserver on the whole ancestor chain up to the conversation root
26
+ * (`[data-phase]`), plus `resize` / `visibilitychange`, plus a short self-healing
27
+ * interval that stops once the layout has measured cleanly three times (or after
28
+ * 20 tries, so a shell we cannot understand never leaks a timer).
29
+ *
30
+ * @module dsh-tiddlywiki/client/dock-align
31
+ */
32
+
33
+ /** The measurement/padding key, exported so the gate can assert on it. */
34
+ export const DOCK_ALIGN_MIN_INPUT_WIDTH = 300
35
+
36
+ /**
37
+ * Start aligning `wrap` with the composer input card. Returns the disposer; call
38
+ * it from the component's effect cleanup.
39
+ *
40
+ * A React ref is intentionally NOT taken: the caller passes its own element, so
41
+ * this works for any dock entry regardless of how it renders.
42
+ */
43
+ export function alignDockEntry(wrap: HTMLElement): () => void {
44
+ /** Find the composer input CARD (see the module doc for the algorithm). */
45
+ const findCard = (): HTMLElement | null => {
46
+ let input: Element | null = null
47
+ let column: HTMLElement | null = null
48
+ let node: HTMLElement | null = wrap
49
+ while (node !== null && node !== document.documentElement) {
50
+ node = node.parentElement
51
+ if (node === null) break
52
+ for (const el of node.querySelectorAll('textarea, [contenteditable]:not([contenteditable="false"]), [role="textbox"]')) {
53
+ if (el.getBoundingClientRect().width > DOCK_ALIGN_MIN_INPUT_WIDTH) { input = el; column = node; break }
54
+ }
55
+ if (column !== null) break
56
+ }
57
+ if (input === null || column === null) return null
58
+ const colW = column.getBoundingClientRect().width
59
+ let cur: HTMLElement | null = input as HTMLElement
60
+ let card: HTMLElement | null = null
61
+ let cardW = 0
62
+ while (cur !== null && cur !== column) {
63
+ const w = cur.getBoundingClientRect().width
64
+ if (w > 0 && w < colW - 8 && w >= cardW) { cardW = w; card = cur }
65
+ cur = cur.parentElement
66
+ }
67
+ return card
68
+ }
69
+
70
+ /** Measure once; returns true when the composer card was found (valid measure). */
71
+ const align = (): boolean => {
72
+ const wrapRect = wrap.getBoundingClientRect()
73
+ const card = findCard()
74
+ const right = card !== null ? card.getBoundingClientRect().right : wrapRect.right
75
+ const pad = Math.max(0, wrapRect.right - right)
76
+ if (wrap.style.paddingRight !== `${pad}px`) wrap.style.paddingRight = `${pad}px`
77
+ return card !== null
78
+ }
79
+
80
+ align()
81
+ // A sidebar toggle changes the conversation column width → the centred,
82
+ // max-width composer card moves → the padding must be re-measured. Observing
83
+ // only the dock container misses that (it may not resize at all), so observe
84
+ // the whole ancestor chain up to the conversation root.
85
+ const ro = new ResizeObserver(() => { align() })
86
+ let node: Element | null = wrap
87
+ while (node !== null && node !== document.documentElement) {
88
+ ro.observe(node)
89
+ if (node instanceof HTMLElement && node.hasAttribute('data-phase')) break
90
+ node = node.parentElement
91
+ }
92
+ window.addEventListener('resize', align)
93
+ document.addEventListener('visibilitychange', align)
94
+
95
+ // Self-healing fallback for layout changes we cannot observe. Each tick is a
96
+ // few getBoundingClientRect reads. It stops after 3 clean measurements, and
97
+ // gives up after 20 tries so an unrecognisable shell cannot leak a timer.
98
+ let guardHits = 0
99
+ let guardTicks = 0
100
+ const guard = window.setInterval(() => {
101
+ guardTicks++
102
+ if (!align()) {
103
+ if (guardTicks >= 20) window.clearInterval(guard)
104
+ return
105
+ }
106
+ guardHits++
107
+ if (guardHits >= 3) window.clearInterval(guard)
108
+ }, 1500)
109
+ // The composer card may mount slightly later; re-measure a couple of times.
110
+ const t1 = window.setTimeout(align, 120)
111
+ const t2 = window.setTimeout(align, 600)
112
+
113
+ return () => {
114
+ ro.disconnect()
115
+ window.removeEventListener('resize', align)
116
+ document.removeEventListener('visibilitychange', align)
117
+ window.clearInterval(guard)
118
+ window.clearTimeout(t1)
119
+ window.clearTimeout(t2)
120
+ }
121
+ }
@@ -835,7 +835,16 @@ export function createNoteWidget(): NoteWidgetHandle {
835
835
  if (disposed) return
836
836
  rosterMode = typeof payload?.mode === 'string' ? payload.mode : 'single'
837
837
  roster = (Array.isArray(payload?.wikis) ? payload.wikis : []).map((item) => ({ id: item.id, label: item.label, running: item.running }))
838
- if (roster.length <= 1) return
838
+ // 单库:连选择器都不该存在。这里**还有一个**兜底 —— 除 `hidden` 外把 select 禁用、
839
+ // 标签也藏掉,因为"能看见一个没有选项的下拉"比"什么都不显示"糟得多(v0.28.1 修)。
840
+ if (roster.length <= 1) {
841
+ wikiField.hidden = true
842
+ wikiField.style.display = 'none'
843
+ wikiSelect.disabled = true
844
+ return
845
+ }
846
+ wikiField.style.display = ''
847
+ wikiSelect.disabled = false
839
848
  const defaultId = typeof payload?.defaultId === 'string' ? payload.defaultId : undefined
840
849
  const followFocus = (): void => {
841
850
  if (!targetPicked) targetWiki = resolveFocusWiki(roster, defaultId)
@@ -17,6 +17,7 @@
17
17
  import * as React from 'react'
18
18
  import { NOTE_STATE_EVENT, type NoteWidgetHandle } from './note-widget.ts'
19
19
  import { fetchUiConfig } from './ui-config.ts'
20
+ import { alignDockEntry } from './dock-align.ts'
20
21
  import { isEditorPopupOpen, isEditorPopupBlank, closeEditorPopup } from './editor-popup.ts'
21
22
 
22
23
  /**
@@ -54,89 +55,13 @@ export function createQuickNoteDock(note: NoteWidgetHandle): () => React.ReactEl
54
55
  window.removeEventListener(NOTE_STATE_EVENT, onState)
55
56
  }
56
57
  }, [])
58
+ // 与 composer 输入框右缘对齐:测量逻辑已抽到 client/dock-align.ts(v0.28.2),
59
+ // 因为第二个 dock 条目(会话级知识库选择器)加进来时它没有对齐 —— 规则只写在
60
+ // 这里,别人看不见。
57
61
  React.useLayoutEffect(() => {
58
62
  const wrap = wrapRef.current
59
63
  if (wrap === null) return
60
- /**
61
- * Find the composer input CARD: climb from the dock entry until an
62
- * ancestor's subtree contains a wide text field (>300px — the composer
63
- * input; dock entries' own inputs are small), then take the widest box
64
- * between that field and its containing column (the visible card).
65
- */
66
- const findCard = (): HTMLElement | null => {
67
- let input: Element | null = null
68
- let column: HTMLElement | null = null
69
- let node: HTMLElement | null = wrap
70
- while (node !== null && node !== document.documentElement) {
71
- node = node.parentElement
72
- if (node === null) break
73
- for (const el of node.querySelectorAll('textarea, [contenteditable]:not([contenteditable="false"]), [role="textbox"]')) {
74
- if (el.getBoundingClientRect().width > 300) { input = el; column = node; break }
75
- }
76
- if (column !== null) break
77
- }
78
- if (input === null || column === null) return null
79
- const colW = column.getBoundingClientRect().width
80
- let cur: HTMLElement | null = input as HTMLElement
81
- let card: HTMLElement | null = null
82
- let cardW = 0
83
- while (cur !== null && cur !== column) {
84
- const w = cur.getBoundingClientRect().width
85
- if (w > 0 && w < colW - 8 && w >= cardW) { cardW = w; card = cur }
86
- cur = cur.parentElement
87
- }
88
- return card
89
- }
90
- /** 测量一次对齐;返回 true = 找到了 composer 输入卡片(测量有效)。 */
91
- const align = (): boolean => {
92
- const wrapRect = wrap.getBoundingClientRect()
93
- const card = findCard()
94
- const right = card !== null ? card.getBoundingClientRect().right : wrapRect.right
95
- const pad = Math.max(0, wrapRect.right - right)
96
- if (wrap.style.paddingRight !== `${pad}px`) wrap.style.paddingRight = `${pad}px`
97
- return card !== null
98
- }
99
- align()
100
- // 侧边栏开/关会改变对话列宽度 → composer 输入卡片(居中、有 max-width)的
101
- // 右缘随之移动。旧实现只观察了 dock 容器,侧边栏变化时它不一定触发 resize,
102
- // 导致按钮停在上次的位置、不再对齐。这里观察「从 dock 条目一直到对话根节点
103
- // (带 data-phase)的整条祖先链」,任一祖先尺寸变化都会重测对齐。
104
- const ro = new ResizeObserver(align)
105
- let node: Element | null = wrap
106
- while (node !== null && node !== document.documentElement) {
107
- ro.observe(node)
108
- if (node instanceof HTMLElement && node.hasAttribute('data-phase')) break
109
- node = node.parentElement
110
- }
111
- window.addEventListener('resize', align)
112
- document.addEventListener('visibilitychange', align)
113
- // 自愈兜底:任何未观测到的布局变化(侧边栏切换、插件重渲染等)也会在
114
- // 1.5s 内被纠正;每帧只是几次 getBoundingClientRect 读取,开销可忽略。
115
- // 但常驻定时器没有必要:连续 3 次测量成功(找到 composer 输入卡片)即认为
116
- // 布局已稳定,清掉 interval;即使一直测不到(异常 shell),也在 20 次后
117
- // 强制收手,之后由 ResizeObserver + resize + visibilitychange 覆盖。
118
- let guardHits = 0
119
- let guardTicks = 0
120
- const guard = window.setInterval(() => {
121
- guardTicks++
122
- if (!align()) {
123
- if (guardTicks >= 20) window.clearInterval(guard)
124
- return
125
- }
126
- guardHits++
127
- if (guardHits >= 3) window.clearInterval(guard)
128
- }, 1500)
129
- // The composer card may mount slightly later; re-measure a couple of times.
130
- const t1 = window.setTimeout(align, 120)
131
- const t2 = window.setTimeout(align, 600)
132
- return () => {
133
- ro.disconnect()
134
- window.removeEventListener('resize', align)
135
- document.removeEventListener('visibilitychange', align)
136
- window.clearInterval(guard)
137
- window.clearTimeout(t1)
138
- window.clearTimeout(t2)
139
- }
64
+ return alignDockEntry(wrap)
140
65
  }, [])
141
66
  const native = mode === 'native'
142
67
  const label = native ? '快速笔记' : (open ? '快速笔记(已打开)' : '快速笔记')
@@ -11,6 +11,8 @@
11
11
  */
12
12
  import type { PanelState } from './state.ts'
13
13
  import { fetchUiConfig, subscribeUiConfig } from './ui-config.ts'
14
+ import { fetchStatus } from './status-cache.ts'
15
+ import { resolveFocusWiki, setFocusWiki, subscribeFocusWiki } from './wiki-focus.ts'
14
16
 
15
17
  /** Stable data attribute identifying this entry row. */
16
18
  export const ENTRY_SELECTOR = '[data-dsh-tw-entry]'
@@ -46,13 +48,24 @@ function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
46
48
  return buttons.find(button => !button.matches(ENTRY_SELECTOR) && /新会话|新建会话|new session/i.test(button.textContent ?? ''))
47
49
  }
48
50
 
49
- /** Build the entry row (a detached button; insert once the shell is up). */
50
- function createEntry(state: PanelState, label: string): { entry: HTMLButtonElement; labelEl: HTMLSpanElement } {
51
+ /**
52
+ * Build one entry row (a detached button; inserted once the shell is up).
53
+ *
54
+ * `wikiId` is optional and only used in multi-wiki mode: clicking the row focuses
55
+ * that knowledge base before toggling the panel, so the row a user clicks is the
56
+ * wiki they get. Single-wiki installs pass nothing and behave exactly as before.
57
+ */
58
+ function createEntry(
59
+ state: PanelState,
60
+ label: string,
61
+ wikiId?: string,
62
+ ): { entry: HTMLButtonElement; labelEl: HTMLSpanElement } {
51
63
  const entry = document.createElement('button')
52
64
  entry.type = 'button'
53
65
  entry.dataset.dshTwEntry = ''
54
66
  entry.className = 'dsh-tw-entry'
55
- entry.setAttribute('aria-label', 'TiddlyWiki 知识库')
67
+ if (wikiId !== undefined) entry.dataset.wiki = wikiId
68
+ entry.setAttribute('aria-label', `TiddlyWiki 知识库:${label}`)
56
69
  const icon = document.createElement('span')
57
70
  icon.className = 'dsh-tw-entry-icon'
58
71
  icon.innerHTML = ICON
@@ -60,7 +73,11 @@ function createEntry(state: PanelState, label: string): { entry: HTMLButtonEleme
60
73
  labelEl.className = 'dsh-tw-entry-label'
61
74
  labelEl.textContent = label
62
75
  entry.append(icon, labelEl)
63
- entry.addEventListener('click', () => { state.toggle() })
76
+ entry.addEventListener('click', () => {
77
+ // 多库:先把这个库设为「焦点库」,面板随后加载它自己的 /tw/<id>/。
78
+ if (wikiId !== undefined) setFocusWiki(wikiId)
79
+ state.toggle()
80
+ })
64
81
  return { entry, labelEl }
65
82
  }
66
83
 
@@ -98,7 +115,10 @@ function placeEntry(root: HTMLElement, entry: HTMLButtonElement): boolean {
98
115
  * @returns disposer removing the entry and its observers.
99
116
  */
100
117
  export function mountSidebarEntry(state: PanelState): () => void {
118
+ /** 多库:每个**在运行**的库一个入口行,用自己的显示名(作者 2026-09-28 要求)。 */
119
+ const rows = new Map<string, { entry: HTMLButtonElement; labelEl: HTMLSpanElement }>()
101
120
  const { entry, labelEl } = createEntry(state, 'TiddlyWiki')
121
+ rows.set('', { entry, labelEl })
102
122
  let disposed = false
103
123
  /**
104
124
  * 自定义显示名:/status 返回 ui.sidebarLabel(设置页「侧边栏入口显示名称」)。
@@ -114,6 +134,60 @@ export function mountSidebarEntry(state: PanelState): () => void {
114
134
  }
115
135
  void applyLabel()
116
136
  const unsubscribeLabel = subscribeUiConfig(() => { void applyLabel() })
137
+
138
+ /**
139
+ * 多库:给**每个在运行**的库挂一个入口行(v0.28.2,作者要求"有多少个 wiki 启动了就按
140
+ * 各自的展示名称显示入口")。
141
+ *
142
+ * 为什么只列「在运行」的:一个入口行点了就该能打开那个库;没在跑的库点开会先卡在启动
143
+ * 上。没在跑的库仍可从右下角「知识库」菜单启动(那里会把它们列全,并标注"未运行")。
144
+ *
145
+ * 单库模式(名册 ≤1):沿用**原来那一个**行与 ui.sidebarLabel —— 行为逐字不变。
146
+ */
147
+ const applyRoster = async (): Promise<void> => {
148
+ const payload = await fetchStatus()
149
+ if (disposed) return
150
+ const list = Array.isArray(payload?.wikis) ? payload.wikis : []
151
+ const running = list.filter((w) => w.running)
152
+ // 单库:不建额外行,标签仍归 ui.sidebarLabel 管。
153
+ if (running.length <= 1) {
154
+ for (const [key, row] of rows) {
155
+ if (key === '') continue
156
+ row.entry.remove()
157
+ rows.delete(key)
158
+ }
159
+ entry.hidden = false
160
+ return
161
+ }
162
+ // 多库:默认那行让位给每个库自己的行(否则会出现一个没有库名的第 4 行)。
163
+ entry.hidden = true
164
+ const wanted = new Set(running.map((w) => w.id))
165
+ // 移除已不在运行/已删除的库
166
+ for (const [key, row] of rows) {
167
+ if (key === '' || wanted.has(key)) continue
168
+ row.entry.remove()
169
+ rows.delete(key)
170
+ }
171
+ // 新增/更新
172
+ for (const w of running) {
173
+ let row = rows.get(w.id)
174
+ if (row === undefined) {
175
+ row = createEntry(state, w.label, w.id)
176
+ rows.set(w.id, row)
177
+ }
178
+ row.labelEl.textContent = w.label
179
+ row.entry.setAttribute('aria-label', `TiddlyWiki 知识库:${w.label}`)
180
+ // 当前焦点库高亮
181
+ const focused = resolveFocusWiki(running, payload?.defaultId) === w.id
182
+ if (focused) row.entry.dataset.focus = 'true'
183
+ else delete row.entry.dataset.focus
184
+ }
185
+ // 让放置逻辑把新行插进去(复用同一个 root)
186
+ if (root !== undefined) { placed = false; tryPlace() }
187
+ }
188
+ void applyRoster()
189
+ const unsubscribeFocus = subscribeFocusWiki(() => { void applyRoster() })
190
+ const rosterTimer = window.setInterval(() => { void applyRoster() }, 10_000)
117
191
  let root: HTMLElement | undefined
118
192
  let placed = false
119
193
  /** 兜底轮询定时器:只在「尚未放置」或「shell 重建了 root」时运行。 */
@@ -135,6 +209,10 @@ export function mountSidebarEntry(state: PanelState): () => void {
135
209
  }, 2_000)
136
210
  }
137
211
 
212
+ /** 当前应该出现在侧边栏里的行(隐藏的默认行不算)。 */
213
+ const activeRows = (): HTMLButtonElement[] =>
214
+ [...rows.values()].map((r) => r.entry).filter((el) => !el.hidden)
215
+
138
216
  const tryPlace = (): void => {
139
217
  if (root !== undefined && !root.isConnected) {
140
218
  rootObserver.disconnect()
@@ -144,7 +222,7 @@ export function mountSidebarEntry(state: PanelState): () => void {
144
222
  startRetry()
145
223
  }
146
224
  if (placed) {
147
- if (document.body.contains(entry)) return
225
+ if (activeRows().every((el) => document.body.contains(el))) return
148
226
  rootObserver.disconnect()
149
227
  root = undefined
150
228
  placed = false
@@ -152,7 +230,12 @@ export function mountSidebarEntry(state: PanelState): () => void {
152
230
  }
153
231
  root ??= sidebarRoot()
154
232
  if (root === undefined) return
155
- placed = placeEntry(root, entry)
233
+ // 逐行放置:多库时每一行都要插进侧边栏(顺序按 rows 的插入序)。
234
+ let any = false
235
+ for (const el of activeRows()) {
236
+ if (placeEntry(root, el)) any = true
237
+ }
238
+ placed = any
156
239
  if (placed) {
157
240
  // 放置成功即停掉兜底轮询:后续自愈由 rootObserver/waitObserver 负责。
158
241
  stopRetry()
@@ -164,7 +247,7 @@ export function mountSidebarEntry(state: PanelState): () => void {
164
247
  const waitObserver = new MutationObserver(() => { tryPlace() })
165
248
  waitObserver.observe(document.body, { childList: true, subtree: true })
166
249
 
167
- // Self-heal: re-insert in the same frame when a re-render displaces the row.
250
+ // Self-heal: re-insert in the same frame when a re-render displaces the rows.
168
251
  const rootObserver = new MutationObserver(() => {
169
252
  if (root === undefined || !root.isConnected) {
170
253
  placed = false
@@ -172,7 +255,9 @@ export function mountSidebarEntry(state: PanelState): () => void {
172
255
  tryPlace()
173
256
  return
174
257
  }
175
- if (!root.contains(entry)) placed = placeEntry(root, entry)
258
+ for (const el of activeRows()) {
259
+ if (!root.contains(el)) placeEntry(root, el)
260
+ }
176
261
  })
177
262
 
178
263
  // Belt-and-braces: a late shell mount with no further mutations still heals.
@@ -180,8 +265,11 @@ export function mountSidebarEntry(state: PanelState): () => void {
180
265
  startRetry()
181
266
 
182
267
  const syncActive = (): void => {
183
- if (state.isOpen()) entry.dataset.active = 'true'
184
- else delete entry.dataset.active
268
+ // 面板是共享的:所有行一起高亮/取消,用户看到的"打开中"状态才一致。
269
+ for (const el of activeRows()) {
270
+ if (state.isOpen()) el.dataset.active = 'true'
271
+ else delete el.dataset.active
272
+ }
185
273
  }
186
274
  const unsubscribe = state.subscribe(syncActive)
187
275
  syncActive()
@@ -190,10 +278,13 @@ export function mountSidebarEntry(state: PanelState): () => void {
190
278
  return () => {
191
279
  disposed = true
192
280
  clearInterval(retry)
281
+ window.clearInterval(rosterTimer)
193
282
  waitObserver.disconnect()
194
283
  rootObserver.disconnect()
195
284
  unsubscribe()
196
285
  unsubscribeLabel()
197
- entry.remove()
286
+ unsubscribeFocus()
287
+ for (const row of rows.values()) row.entry.remove()
288
+ rows.clear()
198
289
  }
199
290
  }
@@ -24,6 +24,9 @@ const CSS_TEXT = `
24
24
  }
25
25
  .dsh-tw-entry:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); color: var(--dsw-alias-label-primary, inherit); }
26
26
  .dsh-tw-entry[data-active="true"] { background: var(--dsw-alias-interactive-bg-active, rgba(128,128,128,.18)); color: var(--dsw-alias-label-primary, inherit); font-weight: 500; }
27
+ /* 多库(v0.28.2):每库一个入口行,当前焦点库加一条左侧标线,和「面板正打开哪个库」区分开
28
+ —— 面板是同一个,但焦点库决定它加载谁。 */
29
+ .dsh-tw-entry[data-focus="true"] { box-shadow: inset 2px 0 0 0 var(--dsw-alias-brand-primary, #3e63dd); }
27
30
  .dsh-tw-entry svg { flex: none; }
28
31
  .dsh-tw-entry .dsh-tw-entry-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
29
32
  /* Collapsed rail: the shell narrows the sidebar to a 56px icon rail (dual
@@ -501,8 +504,13 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
501
504
  /* ── 会话级知识库选择器(v0.28.0,同一个 conversation.input.dock 槽位)──
502
505
  与快速笔记按钮同区、纵向 flex 排列;只在"多于一个可见库"时渲染(组件自己返回
503
506
  null),所以单库安装这一整块都不存在。 */
507
+ /* ── 会话级知识库选择器(v0.28.0,同一个 conversation.input.dock 槽位)──
508
+ 与快速笔记按钮同区、纵向 flex 排列;只在"多于一个可见库"时渲染(组件自己返回
509
+ null),所以单库安装这一整块都不存在。
510
+ justify-content: flex-end 与 JS 测得的 paddingRight 一起把右缘对齐 composer
511
+ 输入框(见 client/dock-align.ts)—— 与 .dsh-tw-dock-note 同一套做法。 */
504
512
  .dsh-tw-scope-dock {
505
- display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
513
+ display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap;
506
514
  padding: 4px 8px 0; box-sizing: border-box; width: 100%; font-size: 12px;
507
515
  }
508
516
  .dsh-tw-scope-label { opacity: .7; }
@@ -515,6 +523,11 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
515
523
  .dsh-tw-scope-note { opacity: .75; }
516
524
  /* 快速笔记卡片的目标库选择(v0.28.0,R7):只在多库时出现。 */
517
525
  .dsh-tw-note-wiki { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; }
526
+ /* ⚠️ hidden 属性在 CSS 里只是 display:none,**任何显式 display 都能盖掉它** ——
527
+ 上面那条 display:inline-flex 就盖掉了,于是单库模式下这个空下拉照样显示出来
528
+ (作者 2026-09-28 报障:"有个写入下拉,但是没有选项")。显式尊重 hidden,
529
+ 并把这一条写在后面(同优先级靠后者生效)。 */
530
+ .dsh-tw-note-wiki[hidden] { display: none; }
518
531
  .dsh-tw-note-wiki-label { opacity: .7; }
519
532
  .dsh-tw-note-wiki-select {
520
533
  border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
@@ -522,6 +535,7 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
522
535
  padding: 2px 6px; border-radius: 999px; max-width: 220px;
523
536
  }
524
537
  .dsh-tw-note-wiki-hint { font-size: 12px; opacity: .75; }
538
+ .dsh-tw-note-wiki-hint[hidden] { display: none; }
525
539
 
526
540
  /* ── input-dock quick-note button(聊天输入框上方,conversation.input.dock)──
527
541
  该槽位渲染为「输入框上方的全宽条目」纵向 flex 排列(todo/cost-meter/goal/
@@ -21,6 +21,7 @@
21
21
  */
22
22
  import * as React from 'react'
23
23
  import { fetchStatus } from './status-cache.ts'
24
+ import { alignDockEntry } from './dock-align.ts'
24
25
  import { SESSION_WIKI_ENDPOINT } from './endpoints.ts'
25
26
 
26
27
  interface WikiOption { id: string; label: string; running: boolean }
@@ -39,6 +40,16 @@ export function createWikiScopeDock(): (props: DockProps) => React.ReactElement
39
40
  const [scope, setScope] = React.useState('')
40
41
  const [note, setNote] = React.useState<string | undefined>(undefined)
41
42
  const [busy, setBusy] = React.useState(false)
43
+ const wrapRef = React.useRef<HTMLDivElement | null>(null)
44
+
45
+ // 与 composer 输入框右缘对齐(v0.28.2):dock 槽位渲染成输入框的**兄弟节点**,
46
+ // 不做这一步这一行就悬在整列最右端(作者报障:"没有和输入框对齐")。测量逻辑
47
+ // 与快速笔记共用一份 —— client/dock-align.ts。
48
+ React.useLayoutEffect(() => {
49
+ const wrap = wrapRef.current
50
+ if (wrap === null) return
51
+ return alignDockEntry(wrap)
52
+ }, [])
42
53
 
43
54
  React.useEffect(() => {
44
55
  if (sessionId === undefined) return
@@ -91,7 +102,7 @@ export function createWikiScopeDock(): (props: DockProps) => React.ReactElement
91
102
 
92
103
  return React.createElement(
93
104
  'div',
94
- { className: 'dsh-tw-scope-dock' },
105
+ { ref: wrapRef, className: 'dsh-tw-scope-dock' },
95
106
  React.createElement('span', { className: 'dsh-tw-scope-label' }, '知识库'),
96
107
  React.createElement(
97
108
  'select',
@@ -64,6 +64,28 @@ const DOC_NOTE_TAIL = `* **TW 编辑器面板**:侧边栏「TiddlyWiki」按
64
64
  * **设置页**:DSH 设置 → 「TiddlyWiki 知识库」管理插件/主题/语言与运行配置(含「知识库」按钮相关显示开关)。
65
65
  * **注入给 Agent 的提示词可配置**(设置页「系统提示词」区块):默认**精简版**只约定同步纪律/标签/链接格式(工具参数由工具 schema 提供,不重复);可切完整版(附参数索引)、追加自定义规范(\`extra\`)、整段替换(\`override\`)或整体停用。保存即生效,无需重启 dsh web;点「查看当前注入文本」可预览下一步实际注入的全文。
66
66
 
67
+ !! 多知识库(v0.28.0 起)
68
+
69
+ 同一个 dsh web 里可以**同时跑多个知识库**,每个库独立配置、独立 git,各自一个 TW 子进程。
70
+
71
+ * **现在有几个库?**右下角「知识库」按钮的菜单会列出全部(● 是当前打开的那个);输入框上方也会有「知识库」下拉,用来选**本会话**让 Agent 用哪个库(单选,选择会被记住)。
72
+ * **加一个库**:DSH 设置 → 「TiddlyWiki 知识库」→ **知识库列表** → 填根目录 + 文件夹名 →「添加知识库」。目录不存在会自动初始化一个全新库;已存在的目录直接接管。
73
+ * **让某个库对 Agent 隐身**:知识库列表里点该行「对 Agent 隐身」。隐身 = Agent 永远碰不到它(不进检索、不进工具、不进会话选择器),**你自己照常用**——适合只读语料、书籍扫描、导入归档。
74
+ * **每个库一套设置**:笔记标签、语言、主题、提示词都在该库自己的配置里。设置页顶部会显示「**配置作用域**:…」,点某一行「配置」即可切换在编辑哪个库。
75
+ * **git 按仓库生效**:多个库可以共用一个仓库(例如两个库是同一仓库下的两个子目录),也可以各自独立仓库。共用仓库时只有一个自动提交者,pull 后只重启内容真的变了的库。
76
+ * **想拆分现有的大库**:见设置页文档或仓库里的 \`docs/wiki-split.md\`——那里有一段可以直接复制给 Agent 的话,它会先只读体检、再问你几个问题、出方案等你确认后才动手。
77
+
78
+ !! 从旧版本升级上来(重要)
79
+
80
+ **升级后不会自动变成多库**,仍然是单库模式(\`mode: single\`),行为与升级前完全一致。
81
+
82
+ * **想继续用单库**:什么都不用做。设置页里「知识库位置(可切换)」只在单库模式出现,用法和以前一样。
83
+ * **想开始用多库**:去设置页「知识库列表」添加第二个库,再把「运行模式」切到**多库**。此后清单里的库都会可用(标了「开局自启」的随 DSH 启动,其余在你打开或选中它时启动)。
84
+ * **控制文件在哪**:\`$DSH_HOME/dsh-tiddlywiki/wikis.json\`(在每本 wiki **之外**,所以它不会随某个库被搬走)。模式、默认库、库清单都在里面;也可以在设置页里改,效果一样。
85
+ * **旧的「知识库位置」指针文件**(\`location.json\`)在单库模式下仍然是权威的——多库模式下它不再参与,改由清单决定。
86
+ * **每个库要有自己的 \`tiddlywiki.info\`**:把一个大库拆成多个目录时,每个目录都要有一个(缺了 TW 认不出它是库)。从旧库整体搬过来的目录通常已经有了。
87
+ * **拆分大库的注意**:书籍/语料这类只读内容建议**独立仓库 + 对 Agent 隐身**;工作与个人笔记可以共用一个仓库、按子目录划分。搬运用 \`git mv\` 保留历史,别用\"复制粘贴再删\"。
88
+
67
89
  !! 知识库纪律(四条)
68
90
 
69
91
  1. 开工先 \`tiddlywiki_git_sync action=pull\`(rebase + autostash,真冲突会自动 abort 并报文件)。