dsh-sidenote 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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +60 -0
  3. package/cordis.patch.yml +16 -0
  4. package/lib/client.js +2143 -0
  5. package/lib/index.js +6 -0
  6. package/lib/types/client/annotate/anchor.d.ts +57 -0
  7. package/lib/types/client/annotate/chip.d.ts +11 -0
  8. package/lib/types/client/annotate/draft.d.ts +29 -0
  9. package/lib/types/client/annotate/format.d.ts +52 -0
  10. package/lib/types/client/annotate/index.d.ts +2 -0
  11. package/lib/types/client/annotate/model.d.ts +59 -0
  12. package/lib/types/client/annotate/overlay.d.ts +27 -0
  13. package/lib/types/client/annotate/selection.d.ts +49 -0
  14. package/lib/types/client/bridge.d.ts +19 -0
  15. package/lib/types/client/index.d.ts +12 -0
  16. package/lib/types/client/locales.d.ts +77 -0
  17. package/lib/types/client/sidechat/SideChatPanel.d.ts +2 -0
  18. package/lib/types/client/sidechat/composer.d.ts +26 -0
  19. package/lib/types/client/sidechat/index.d.ts +2 -0
  20. package/lib/types/client/sidechat/model.d.ts +101 -0
  21. package/lib/types/client/sidechat/open.d.ts +16 -0
  22. package/lib/types/client/sidechat/slash.d.ts +12 -0
  23. package/lib/types/context-types.d.ts +335 -0
  24. package/lib/types/index.d.ts +9 -0
  25. package/package.json +96 -0
  26. package/src/client/annotate/anchor.ts +158 -0
  27. package/src/client/annotate/annotate.module.css +301 -0
  28. package/src/client/annotate/chip.tsx +115 -0
  29. package/src/client/annotate/css.d.ts +9 -0
  30. package/src/client/annotate/draft.ts +67 -0
  31. package/src/client/annotate/format.ts +83 -0
  32. package/src/client/annotate/index.tsx +66 -0
  33. package/src/client/annotate/model.ts +138 -0
  34. package/src/client/annotate/overlay.tsx +531 -0
  35. package/src/client/annotate/selection.ts +203 -0
  36. package/src/client/bridge.ts +19 -0
  37. package/src/client/index.tsx +22 -0
  38. package/src/client/locales.ts +175 -0
  39. package/src/client/sidechat/SideChatPanel.tsx +366 -0
  40. package/src/client/sidechat/composer.ts +129 -0
  41. package/src/client/sidechat/css-modules.d.ts +5 -0
  42. package/src/client/sidechat/index.tsx +56 -0
  43. package/src/client/sidechat/model.ts +331 -0
  44. package/src/client/sidechat/open.ts +66 -0
  45. package/src/client/sidechat/sidechat.module.css +230 -0
  46. package/src/client/sidechat/slash.ts +84 -0
  47. package/src/context-types.ts +369 -0
  48. package/src/index.ts +13 -0
@@ -0,0 +1,301 @@
1
+ /* Workitem 02 — 划选注释 overlay styles.
2
+ z-index 纪律:全部固定 50 — 高于正文与 better-sidebar 面板 (40),低于 DSH
3
+ 浮层栈 (100+)。颜色除品牌蓝角标外一律消费 DSH 令牌(皮肤跟随),缺省回退。 */
4
+
5
+ .toolbar {
6
+ position: fixed;
7
+ transform: translate(-50%, -100%);
8
+ z-index: 50;
9
+ display: flex;
10
+ align-items: stretch;
11
+ background: var(--dsw-alias-bg-layer-1, #ffffff);
12
+ border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, 0.08));
13
+ border-radius: 10px;
14
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
15
+ overflow: hidden;
16
+ user-select: none;
17
+ }
18
+
19
+ .toolbarButton {
20
+ appearance: none;
21
+ border: none;
22
+ background: transparent;
23
+ color: var(--dsw-alias-label-primary, #1f2329);
24
+ font-size: 13px;
25
+ line-height: 20px;
26
+ padding: 7px 12px;
27
+ cursor: pointer;
28
+ white-space: nowrap;
29
+ }
30
+
31
+ .toolbarButton + .toolbarButton {
32
+ border-left: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, 0.08));
33
+ }
34
+
35
+ .toolbarButton:hover {
36
+ background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05));
37
+ }
38
+
39
+ .badge {
40
+ position: fixed;
41
+ transform: translate(0, -50%);
42
+ z-index: 50;
43
+ width: 18px;
44
+ height: 18px;
45
+ border: none;
46
+ border-radius: 999px;
47
+ background: #2f88ff;
48
+ color: #ffffff;
49
+ font-size: 11px;
50
+ font-weight: 600;
51
+ line-height: 18px;
52
+ text-align: center;
53
+ padding: 0;
54
+ cursor: pointer;
55
+ box-shadow: 0 1px 4px rgba(47, 136, 255, 0.45);
56
+ user-select: none;
57
+ }
58
+
59
+ .badge:hover {
60
+ background: #1f78f0;
61
+ }
62
+
63
+ .highlight {
64
+ position: fixed;
65
+ z-index: 49;
66
+ background: rgba(47, 136, 255, 0.22);
67
+ border-radius: 2px;
68
+ pointer-events: none;
69
+ }
70
+
71
+ .editorNew {
72
+ position: fixed;
73
+ z-index: 50;
74
+ display: flex;
75
+ align-items: center;
76
+ gap: 8px;
77
+ padding: 6px 8px 6px 12px;
78
+ background: var(--dsw-alias-bg-layer-1, #ffffff);
79
+ border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, 0.08));
80
+ border-radius: 999px;
81
+ box-shadow: 0 6px 24px rgba(0, 0, 0, 0.14);
82
+ }
83
+
84
+ .editorEdit {
85
+ position: fixed;
86
+ z-index: 50;
87
+ display: flex;
88
+ flex-direction: column;
89
+ background: var(--dsw-alias-bg-layer-1, #ffffff);
90
+ border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, 0.08));
91
+ border-radius: 14px;
92
+ box-shadow: 0 8px 28px rgba(0, 0, 0, 0.16);
93
+ overflow: hidden;
94
+ }
95
+
96
+ .editorInput {
97
+ flex: auto;
98
+ min-width: 0;
99
+ border: none;
100
+ outline: none;
101
+ background: transparent;
102
+ color: var(--dsw-alias-label-primary, #1f2329);
103
+ font-size: 13px;
104
+ line-height: 22px;
105
+ }
106
+
107
+ .editorTextarea {
108
+ border: none;
109
+ outline: none;
110
+ resize: none;
111
+ background: transparent;
112
+ color: var(--dsw-alias-label-primary, #1f2329);
113
+ font-size: 13px;
114
+ line-height: 20px;
115
+ padding: 10px 12px 4px;
116
+ font-family: inherit;
117
+ }
118
+
119
+ .editorInput::placeholder,
120
+ .editorTextarea::placeholder {
121
+ color: var(--dsw-alias-label-caption, #8f959e);
122
+ }
123
+
124
+ .confirmButton {
125
+ flex: none;
126
+ width: 26px;
127
+ height: 26px;
128
+ border: none;
129
+ border-radius: 999px;
130
+ background: #1f2329;
131
+ color: #ffffff;
132
+ cursor: pointer;
133
+ display: grid;
134
+ place-items: center;
135
+ padding: 0;
136
+ }
137
+
138
+ .confirmButton:hover {
139
+ background: #000000;
140
+ }
141
+
142
+ .editorFooter {
143
+ display: flex;
144
+ align-items: center;
145
+ gap: 8px;
146
+ padding: 6px 10px 8px;
147
+ }
148
+
149
+ .editorSpacer {
150
+ flex: auto;
151
+ }
152
+
153
+ .deleteButton {
154
+ width: 26px;
155
+ height: 26px;
156
+ border: none;
157
+ border-radius: 999px;
158
+ background: transparent;
159
+ color: var(--dsw-alias-label-tertiary, #8f959e);
160
+ cursor: pointer;
161
+ display: grid;
162
+ place-items: center;
163
+ padding: 0;
164
+ }
165
+
166
+ .deleteButton:hover {
167
+ background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05));
168
+ color: var(--dsw-alias-state-error-primary, #d53f3f);
169
+ }
170
+
171
+ .cancelButton,
172
+ .saveButton {
173
+ border: none;
174
+ border-radius: 999px;
175
+ font-size: 13px;
176
+ line-height: 20px;
177
+ padding: 4px 14px;
178
+ cursor: pointer;
179
+ }
180
+
181
+ .cancelButton {
182
+ background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05));
183
+ color: var(--dsw-alias-label-primary, #1f2329);
184
+ }
185
+
186
+ .saveButton {
187
+ background: #1f2329;
188
+ color: #ffffff;
189
+ }
190
+
191
+ .saveButton:hover {
192
+ background: #000000;
193
+ }
194
+
195
+ /* ── composer chip (conversation.input.dock) ─────────────────────────────── */
196
+
197
+ .chipWrap {
198
+ display: flex;
199
+ flex-direction: column;
200
+ align-items: flex-start;
201
+ gap: 4px;
202
+ }
203
+
204
+ .chip {
205
+ display: inline-flex;
206
+ align-items: center;
207
+ gap: 5px;
208
+ border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, 0.1));
209
+ border-radius: 999px;
210
+ background: var(--dsw-alias-bg-layer-1, #ffffff);
211
+ color: var(--dsw-alias-label-primary, #1f2329);
212
+ font-size: 12px;
213
+ line-height: 18px;
214
+ padding: 2px 10px;
215
+ cursor: pointer;
216
+ user-select: none;
217
+ }
218
+
219
+ .chip:hover {
220
+ background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05));
221
+ }
222
+
223
+ .chipPanel {
224
+ margin: 0;
225
+ padding: 4px;
226
+ list-style: none;
227
+ min-width: 220px;
228
+ max-width: 420px;
229
+ border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, 0.08));
230
+ border-radius: 12px;
231
+ background: var(--dsw-alias-bg-layer-1, #ffffff);
232
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1);
233
+ }
234
+
235
+ .chipRow {
236
+ display: flex;
237
+ align-items: center;
238
+ gap: 8px;
239
+ padding: 4px 6px;
240
+ border-radius: 8px;
241
+ }
242
+
243
+ .chipRow:hover {
244
+ background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.04));
245
+ }
246
+
247
+ .chipNumber {
248
+ flex: none;
249
+ width: 16px;
250
+ height: 16px;
251
+ border-radius: 999px;
252
+ background: #2f88ff;
253
+ color: #ffffff;
254
+ font-size: 10px;
255
+ font-weight: 600;
256
+ line-height: 16px;
257
+ text-align: center;
258
+ }
259
+
260
+ .chipText {
261
+ flex: auto;
262
+ min-width: 0;
263
+ overflow: hidden;
264
+ text-overflow: ellipsis;
265
+ white-space: nowrap;
266
+ font-size: 12px;
267
+ color: var(--dsw-alias-label-primary, #1f2329);
268
+ }
269
+
270
+ .chipNote {
271
+ color: var(--dsw-alias-label-tertiary, #8f959e);
272
+ }
273
+
274
+ .chipRemove {
275
+ flex: none;
276
+ width: 20px;
277
+ height: 20px;
278
+ border: none;
279
+ border-radius: 999px;
280
+ background: transparent;
281
+ color: var(--dsw-alias-label-tertiary, #8f959e);
282
+ cursor: pointer;
283
+ display: grid;
284
+ place-items: center;
285
+ padding: 0;
286
+ }
287
+
288
+ .chipRemove:hover {
289
+ background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.08));
290
+ color: var(--dsw-alias-state-error-primary, #d53f3f);
291
+ }
292
+
293
+ /* bridge 失败的内联错误(编辑器保持打开,注解不丢)。 */
294
+ .editorError {
295
+ position: absolute;
296
+ top: calc(100% + 4px);
297
+ left: 12px;
298
+ color: #e5484d;
299
+ font-size: 12px;
300
+ white-space: nowrap;
301
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The 「N 条注释」 composer chip (Workitem 02): a `conversation.input.dock`
3
+ * list entry (the official composer-attachment seat; todo dock lives at order
4
+ * 0, the queue strip at 20 — we sit between at 10). The chip counts the
5
+ * session's ACTIVE annotations, expands inline to preview/remove each one,
6
+ * and watches the owner-provided input snapshot for the send edge (draft
7
+ * non-empty → empty) to flip the annotations to 'sent' — the chip then
8
+ * disappears while the badges stay on the message flow.
9
+ */
10
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
11
+ import type { ReactNode } from 'react'
12
+ import { IconCloseOutline16, IconListPenOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
13
+ import type { InputZone } from '../../context-types.ts'
14
+ import { isSendEdge } from './format.ts'
15
+ import type { AnnotationStore } from './model.ts'
16
+ import { t, useLocaleTick } from '../locales.ts'
17
+ import { AnnotateErrorBoundary } from './overlay.tsx'
18
+ import css from './annotate.module.css'
19
+
20
+ /** Props the dock skeleton hands us: the InputZone owner share (point-in-time). */
21
+ interface ChipProps {
22
+ readonly session: InputZone['session']
23
+ readonly input: InputZone['input']
24
+ }
25
+
26
+ /** Create the slot component bound to one store instance. */
27
+ export function createAnnotationChip(store: AnnotationStore) {
28
+ function AnnotationChip(props: ChipProps): ReactNode {
29
+ useLocaleTick()
30
+ const sessionId = props.session.sessionId
31
+ useSyncExternalStore(
32
+ useCallback((cb: () => void) => store.subscribe(cb), [store]),
33
+ () => store.getSnapshot(),
34
+ )
35
+ const [expanded, setExpanded] = useState(false)
36
+
37
+ // 发送沿检测:草稿非空→空 且 伴随机器信号(提交相位/队列增长/running 启
38
+ // 动)——纯「草稿清空」不算发送(用户手动全选删除满足前者,但没有机器信
39
+ // 号,注释不应被误归档为已发送)。
40
+ // owner share 是 point-in-time 快照、由骨架负责重渲染——禁止订阅,只在
41
+ // effect 里比边沿。会话切换不重置:仅当两次渲染属于同一会话时才比较。
42
+ const previous = useRef({
43
+ sessionId,
44
+ draft: props.input.draft,
45
+ phase: props.input.phase,
46
+ queueLen: props.input.queue?.length ?? 0,
47
+ running: props.session.running,
48
+ })
49
+ useEffect(() => {
50
+ const prev = previous.current
51
+ previous.current = {
52
+ sessionId,
53
+ draft: props.input.draft,
54
+ phase: props.input.phase,
55
+ queueLen: props.input.queue?.length ?? 0,
56
+ running: props.session.running,
57
+ }
58
+ if (prev.sessionId !== sessionId) return
59
+ if (store.countActive(sessionId) === 0) return
60
+ if (!isSendEdge(prev.draft, props.input.draft)) return
61
+ const machineSignal = prev.phase !== 'plain'
62
+ || (props.input.queue?.length ?? 0) > prev.queueLen
63
+ || (props.session.running && !prev.running)
64
+ if (!machineSignal) return
65
+ store.markSessionSent(sessionId)
66
+ })
67
+
68
+ const active = store.listActive(sessionId)
69
+ if (active.length === 0) return null
70
+
71
+ return (
72
+ <div className={css.chipWrap}>
73
+ <button
74
+ type="button"
75
+ className={css.chip}
76
+ aria-expanded={expanded}
77
+ onClick={() => { setExpanded(open => !open) }}
78
+ >
79
+ <IconListPenOutline16 size={12} />
80
+ <span>{t(active.length === 1 ? 'chipOne' : 'chipMany', { n: active.length })}</span>
81
+ </button>
82
+ {expanded && (
83
+ <ul className={css.chipPanel}>
84
+ {active.map(annotation => (
85
+ <li key={annotation.id} className={css.chipRow}>
86
+ <span className={css.chipNumber}>{annotation.number}</span>
87
+ <span className={css.chipText} title={annotation.text}>
88
+ {annotation.text}
89
+ {annotation.note !== '' && <span className={css.chipNote}>({annotation.note})</span>}
90
+ </span>
91
+ <button
92
+ type="button"
93
+ className={css.chipRemove}
94
+ title={t('removeTitle')}
95
+ aria-label={t('removeAria', { n: annotation.number })}
96
+ onClick={() => { store.remove(annotation.id) }}
97
+ >
98
+ <IconCloseOutline16 size={12} />
99
+ </button>
100
+ </li>
101
+ ))}
102
+ </ul>
103
+ )}
104
+ </div>
105
+ )
106
+ }
107
+
108
+ return function AnnotationChipEntry(props: ChipProps): ReactNode {
109
+ return (
110
+ <AnnotateErrorBoundary>
111
+ <AnnotationChip {...props} />
112
+ </AnnotateErrorBoundary>
113
+ )
114
+ }
115
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * CSS Modules type declarations (tsdown compiles `*.module.css` to a class-map
3
+ * default export; this mirror keeps `tsc --noEmit` honest). Scoped to the
4
+ * annotate module's needs — one wildcard declaration, no runtime footprint.
5
+ */
6
+ declare module '*.module.css' {
7
+ const classes: Record<string, string>
8
+ export default classes
9
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Managed draft prefix (Workitem 02 发送携带的降级方案): while a session has
3
+ * active annotations, their formatted quote block is maintained at the very
4
+ * top of the composer draft; the user's own text follows it, so sending
5
+ * naturally carries the context. The composer chip clears on the send edge
6
+ * (draft non-empty → empty), the annotations flip to 'sent', and the prefix
7
+ * dissolves with the already-cleared draft.
8
+ *
9
+ * insertReference spike conclusion: the U+FFFC chip path is NOT viable from a
10
+ * plugin — `SessionInput.insertReference` requires a span CAS'd against the
11
+ * live draftRev (a trigger-pipeline concept), and chip serialization routes
12
+ * through the source owner's ReferenceCodec, which is package-internal to
13
+ * ui-input-trigger with no plugin-facing registry. An unowned source would
14
+ * mark the occurrence invalid and fail the whole submit. Hence this file.
15
+ */
16
+ import type { Context, ConversationService, SessionId, SessionInput } from '../../context-types.ts'
17
+ import { buildQuoteBlock, nextManagedDraft } from './format.ts'
18
+ import type { AnnotationStore } from './model.ts'
19
+
20
+ /** The head (quote block + blank line) last written into each session's draft. */
21
+ const managedHeads = new Map<SessionId, string>()
22
+
23
+ /** Resolve the per-session input facade, degrading to undefined (never throws). */
24
+ export function resolveInput(ctx: Context, sessionId: SessionId): SessionInput | undefined {
25
+ try {
26
+ const actx = ctx.sessions.scope(sessionId)
27
+ if (actx === undefined) return undefined
28
+ const conversation = ctx.get('conversation') as ConversationService | undefined
29
+ return conversation?.input.for(actx)
30
+ } catch {
31
+ return undefined
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Rewrite one session's draft so its managed head matches the active
37
+ * annotations. A no-op when nothing would change (the common case while the
38
+ * user types their own message below the head).
39
+ */
40
+ export function syncSessionDraft(ctx: Context, store: AnnotationStore, sessionId: SessionId): void {
41
+ try {
42
+ const input = resolveInput(ctx, sessionId)
43
+ if (input === undefined) return
44
+ const block = buildQuoteBlock(store.listActive(sessionId))
45
+ const lastHead = managedHeads.get(sessionId) ?? ''
46
+ const draft = input.state.getSnapshot().draft
47
+ const next = nextManagedDraft(draft, lastHead, block)
48
+ // block 为空(注释全部 sent/移除)时不覆写 managedHeads:submit 失败
49
+ // rollback 会把含旧前缀的草稿恢复回来,保留 lastHead 才能让下一次 sync
50
+ // 正常剥离它,否则新注释的前缀会叠在旧前缀之上(双重引用块)。
51
+ if (block !== '') managedHeads.set(sessionId, next.head)
52
+ if (next.draft === draft) return
53
+ input.setDraft(next.draft)
54
+ } catch (error) {
55
+ console.warn('[dsh-sidenote] draft sync failed:', error)
56
+ }
57
+ }
58
+
59
+ /** Re-sync every session holding annotations (store-change fan-out). */
60
+ export function syncAllDrafts(ctx: Context, store: AnnotationStore): void {
61
+ for (const sessionId of store.sessions()) syncSessionDraft(ctx, store, sessionId)
62
+ }
63
+
64
+ /** Forget all managed heads (page-level lifecycle; plugin teardown/testing). */
65
+ export function resetManagedHeads(): void {
66
+ managedHeads.clear()
67
+ }
@@ -0,0 +1,83 @@
1
+ import { t } from '../locales.ts'
2
+
3
+ /**
4
+ * Quote-block formatting and the managed-draft math (Workitem 02). Pure and
5
+ * dependency-free for unit testing — the shapes here ARE the contract for
6
+ * 「发送给模型的数据形态」:
7
+ *
8
+ * > 原文片段 1
9
+ * 注解:xxx
10
+ *
11
+ * > 原文片段 2
12
+ * (无注解)
13
+ *
14
+ * 用户消息正文
15
+ */
16
+
17
+ /** Selected-text bound admitted into one annotation (超长截断 + 标记). */
18
+ export const SELECTION_LIMIT = 500
19
+
20
+ /** Marker appended when a selection is truncated (参照 sidebar-qa 的省略号模式). */
21
+ export const TRUNCATION_MARK = '…'
22
+
23
+ /** Bound a quote to {@link SELECTION_LIMIT} characters, marking truncation. */
24
+ export function truncateQuote(text: string, limit: number = SELECTION_LIMIT): string {
25
+ if (text.length <= limit) return text
26
+ return `${text.slice(0, limit)}${TRUNCATION_MARK}`
27
+ }
28
+
29
+ /** Quote one (possibly multi-line) text as `> `-prefixed lines. */
30
+ export function quoteLines(text: string): string {
31
+ return text.split('\n').map(line => (line === '' ? '>' : `> ${line}`)).join('\n')
32
+ }
33
+
34
+ export interface QuoteItem {
35
+ readonly text: string
36
+ readonly note: string
37
+ }
38
+
39
+ /** One annotation's block: quoted original plus the note line. */
40
+ export function formatQuoteItem(item: QuoteItem): string {
41
+ const noteLine = item.note.trim() === '' ? t('noNote') : t('noteLine', { note: item.note })
42
+ return `${quoteLines(item.text)}\n${noteLine}`
43
+ }
44
+
45
+ /** The full context block for every active annotation, in creation order. */
46
+ export function buildQuoteBlock(items: readonly QuoteItem[]): string {
47
+ return items.map(formatQuoteItem).join('\n\n')
48
+ }
49
+
50
+ /** The 「在侧边聊天中提问」 seed: quote + optional note(与主对话注释同构). */
51
+ export function buildSideChatQuote(text: string, note = ''): string {
52
+ return formatQuoteItem({ text, note })
53
+ }
54
+
55
+ /**
56
+ * Recompute a session composer draft from its managed head.
57
+ *
58
+ * The managed head (quote block + one blank line) is maintained at the very
59
+ * top of the draft while annotations are active; the user's own text follows
60
+ * it untouched. `lastHead` is the head we last wrote: when the draft still
61
+ * starts with it we strip exactly that span; otherwise the user meddled with
62
+ * the head region and the whole draft is treated as user text (the fresh head
63
+ * is simply re-prepended — a documented, non-crashing degradation).
64
+ */
65
+ export function nextManagedDraft(
66
+ draft: string,
67
+ lastHead: string,
68
+ block: string,
69
+ ): { head: string; draft: string } {
70
+ const userText = lastHead !== '' && draft.startsWith(lastHead) ? draft.slice(lastHead.length) : draft
71
+ const head = block === '' ? '' : `${block}\n\n`
72
+ return { head, draft: head + userText }
73
+ }
74
+
75
+ /**
76
+ * The send-edge predicate: the composer draft flipping non-empty → empty
77
+ * while annotations were attached is the reliable「发送后」signal (submit
78
+ * clears the draft as a commit; queueing does too). Whitespace-only drafts
79
+ * count as empty.
80
+ */
81
+ export function isSendEdge(previousDraft: string, nextDraft: string): boolean {
82
+ return previousDraft.trim() !== '' && nextDraft.trim() === ''
83
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Workitem 02 — 划选注释:selection listener + numbered badges + annotation
3
+ * editor + the composer「N 条注释」chip. Wiring only: one annotation store,
4
+ * one selection controller, one overlay React root (appended to document.body
5
+ * and marked `data-dsh-sidenote` so the listener excludes our own DOM), and
6
+ * one `conversation.input.dock` slot entry. 全部 additive、页面级生命周期,
7
+ * and every seam degrades to a logged no-op instead of crashing the host page.
8
+ *
9
+ * 整个模块包在一个 ctx.effect 里:HMR/插件禁用时 fiber 撤销即全部清理
10
+ * (store 退订、document 监听器移除、overlay unmount、host div 移除、受管
11
+ * 草稿头清空)——否则重挂后双重挂载(两层工具条、双份监听、新旧 store
12
+ * 分裂)。slot 注册的 disposer 由 cordis 服务代理路由进调用方 fiber,
13
+ * 无需手动回收。
14
+ */
15
+ import { createRoot } from 'react-dom/client'
16
+ import type { Context } from '../../context-types.ts'
17
+ import { createAnnotationStore } from './model.ts'
18
+ import { createSelectionController } from './selection.ts'
19
+ import { AnnotateOverlay } from './overlay.tsx'
20
+ import { createAnnotationChip } from './chip.tsx'
21
+ import { resetManagedHeads, syncAllDrafts } from './draft.ts'
22
+
23
+ export function registerAnnotations(ctx: Context): void {
24
+ ctx.effect(() => {
25
+ try {
26
+ const store = createAnnotationStore()
27
+ const controller = createSelectionController(() => ctx.sessions.list.getSnapshot().current ?? '')
28
+
29
+ // The overlay root: toolbar + badges + highlight + editor.
30
+ const host = document.createElement('div')
31
+ host.dataset.dshSidenote = ''
32
+ document.body.appendChild(host)
33
+ const root = createRoot(host)
34
+ root.render(<AnnotateOverlay ctx={ctx} store={store} controller={controller} />)
35
+
36
+ // 发送携带(受管草稿前缀块): keep each session's composer draft headed by
37
+ // its active annotations' quote block; the chip clears on the send edge.
38
+ const offStore = store.subscribe(() => { syncAllDrafts(ctx, store) })
39
+
40
+ // The「N 条注释」chip: conversation.input.dock is the official composer
41
+ // attachment seat; slots.inject waits for the shell's declaration (the
42
+ // ui-conversation todo/queue docks register the same way)。disposer 由
43
+ // cordis 服务代理级联进本 fiber,随 effect 撤销自动回收。
44
+ ctx.slots.inject('conversation.input.dock', () => {
45
+ return ctx.slots.register({
46
+ name: 'conversation.input.dock',
47
+ id: 'dsh-sidenote-annotations',
48
+ order: 10,
49
+ registrant: 'dsh-sidenote',
50
+ }, createAnnotationChip(store))
51
+ })
52
+
53
+ return () => {
54
+ offStore()
55
+ controller.dispose()
56
+ resetManagedHeads()
57
+ // 同帧 render/unmount 会触发 React 警告——推迟到下一帧。
58
+ setTimeout(() => { root.unmount() })
59
+ host.remove()
60
+ }
61
+ } catch (error) {
62
+ console.error('[dsh-sidenote] annotate setup failed:', error)
63
+ return () => {}
64
+ }
65
+ }, 'dsh-sidenote: annotations')
66
+ }