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,203 @@
1
+ /**
2
+ * Selection capture layer (Workitem 02): document-level listeners validate
3
+ * the live selection and publish the latest valid one to the floating
4
+ * toolbar through a small external-store controller. Mirrors the
5
+ * production-proven dsh-sidebar-qa contract, narrowed to assistant messages:
6
+ *
7
+ * - both selection endpoints must sit in the SAME `[data-chat-flow-kind=
8
+ * "assistant-step"]` message node (跨消息划选不弹);
9
+ * - `[data-streaming]` messages (still streaming) are excluded;
10
+ * - `[data-dsh-better-sidebar]` and our own `[data-dsh-sidenote]` DOM are
11
+ * excluded (与侧边栏 viewer 浮层互斥);
12
+ * - the text must be non-blank; over-length selections are admitted with the
13
+ * quote truncated (见 format.ts), never rejected.
14
+ *
15
+ * These are DSH-internal DOM attributes rather than a public contract — an
16
+ * upstream redesign silently degrades the feature (no popup), never crashes.
17
+ */
18
+ import { truncateQuote } from './format.ts'
19
+
20
+ /** One validated selection, ready for the toolbar. */
21
+ export interface SelectionSnapshot {
22
+ /** Truncated quote (sent to the model / stored on the annotation). */
23
+ readonly text: string
24
+ /** Full selection text (re-anchor needle). */
25
+ readonly anchorText: string
26
+ /** `data-chat-anchor-key` of the owning message, when present. */
27
+ readonly anchorKey: string | undefined
28
+ /** 0-based ordinal of this selection among identical texts in the anchor. */
29
+ readonly occurrence: number
30
+ /** Bounding box for toolbar placement. */
31
+ readonly rect: { readonly left: number; readonly top: number; readonly width: number }
32
+ /** A live clone of the selection range (badge anchor until re-render). */
33
+ readonly range: Range
34
+ readonly sessionId: string
35
+ }
36
+
37
+ export interface SelectionState {
38
+ readonly selection: SelectionSnapshot | null
39
+ }
40
+
41
+ /** The assistant message node kind (v1 实测纠正值 — 勿引旧值 `assistant`). */
42
+ export const ASSISTANT_KIND = 'assistant-step'
43
+
44
+ /** Selector roots our UI must never react to (sidebar viewer + our own root). */
45
+ const EXCLUDED_ROOTS = '[data-dsh-better-sidebar], [data-dsh-sidenote]'
46
+
47
+ /** Pure validation of one candidate selection (unit-tested without DOM). */
48
+ export function isEligibleSelection(input: {
49
+ readonly blank: boolean
50
+ readonly sameMessage: boolean
51
+ readonly kind: string
52
+ readonly streaming: boolean
53
+ readonly excluded: boolean
54
+ readonly hasSession: boolean
55
+ }): boolean {
56
+ return !input.blank
57
+ && input.sameMessage
58
+ && input.kind === ASSISTANT_KIND
59
+ && !input.streaming
60
+ && !input.excluded
61
+ && input.hasSession
62
+ }
63
+
64
+ /** Resolve the assistant message element owning a DOM node (text nodes → parent). */
65
+ function messageOf(node: Node): HTMLElement | null {
66
+ const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : (node as Element | null)
67
+ if (element === null || typeof element.closest !== 'function') return null
68
+ return element.closest<HTMLElement>(`[data-chat-flow-kind="${ASSISTANT_KIND}"]`)
69
+ }
70
+
71
+ /** Whether the message is still streaming (unfinished). */
72
+ function isStreaming(message: HTMLElement): boolean {
73
+ return message.hasAttribute('data-streaming') || message.querySelector('[data-streaming]') !== null
74
+ }
75
+
76
+ /**
77
+ * The 0-based ordinal of the selection's text among its occurrences in the
78
+ * anchor element — the disambiguator for repeated phrases (re-anchor key).
79
+ */
80
+ export function occurrenceOf(anchor: HTMLElement, range: Range, text: string): number {
81
+ try {
82
+ const pre = document.createRange()
83
+ pre.selectNodeContents(anchor)
84
+ pre.setEnd(range.startContainer, range.startOffset)
85
+ const before = pre.toString()
86
+ let occurrence = 0
87
+ let at = before.indexOf(text)
88
+ while (at !== -1) {
89
+ occurrence += 1
90
+ at = before.indexOf(text, at + text.length)
91
+ }
92
+ return occurrence
93
+ } catch {
94
+ return 0
95
+ }
96
+ }
97
+
98
+ /** Capture and validate the current window selection, or null when invalid. */
99
+ export function captureSelection(currentSessionId: string): SelectionSnapshot | null {
100
+ const sel = window.getSelection()
101
+ if (sel === null || sel.isCollapsed || sel.rangeCount === 0) return null
102
+ const anchorText = sel.toString()
103
+ const range = sel.getRangeAt(0)
104
+ const startMessage = messageOf(range.startContainer)
105
+ const endMessage = messageOf(range.endContainer)
106
+ const message = startMessage
107
+ const anchor = message?.closest<HTMLElement>('[data-chat-anchor-key]') ?? null
108
+ const eligible = isEligibleSelection({
109
+ blank: anchorText.trim() === '',
110
+ sameMessage: message !== null && message === endMessage,
111
+ kind: message?.dataset.chatFlowKind ?? '',
112
+ streaming: message !== null && isStreaming(message),
113
+ excluded: anchor !== null
114
+ ? anchor.closest(EXCLUDED_ROOTS) !== null
115
+ : message?.closest(EXCLUDED_ROOTS) !== null,
116
+ hasSession: currentSessionId !== '',
117
+ })
118
+ if (!eligible || message === null) return null
119
+ const rect = range.getBoundingClientRect()
120
+ return {
121
+ text: truncateQuote(anchorText),
122
+ anchorText,
123
+ anchorKey: anchor?.dataset.chatAnchorKey,
124
+ occurrence: anchor === null ? 0 : occurrenceOf(anchor, range, anchorText),
125
+ rect: { left: rect.left, top: rect.top, width: rect.width },
126
+ range: range.cloneRange(),
127
+ sessionId: currentSessionId,
128
+ }
129
+ }
130
+
131
+ export interface SelectionController {
132
+ getSnapshot(): SelectionState
133
+ subscribe(fn: () => void): () => void
134
+ clear(): void
135
+ dispose(): void
136
+ }
137
+
138
+ /** Debounce for selectionchange (ms). */
139
+ const DEBOUNCE_MS = 200
140
+
141
+ /** Create one selection controller (call once per plugin activation). */
142
+ export function createSelectionController(getSessionId: () => string): SelectionController {
143
+ let selection: SelectionSnapshot | null = null
144
+ const listeners = new Set<() => void>()
145
+ let debounceTimer: number | undefined
146
+
147
+ // useSyncExternalStore requires a STABLE snapshot reference between
148
+ // notifications — a fresh object each call would loop React.
149
+ let state: SelectionState = { selection: null }
150
+
151
+ const notify = (): void => {
152
+ state = { selection }
153
+ for (const fn of [...listeners]) fn()
154
+ }
155
+
156
+ const recompute = (): void => {
157
+ let next: SelectionSnapshot | null = null
158
+ try {
159
+ next = captureSelection(getSessionId())
160
+ } catch {
161
+ next = null
162
+ }
163
+ const changed = (selection === null) !== (next === null)
164
+ || (selection !== null && next !== null && (
165
+ selection.anchorText !== next.anchorText
166
+ || selection.anchorKey !== next.anchorKey
167
+ || selection.sessionId !== next.sessionId
168
+ || selection.rect.top !== next.rect.top
169
+ || selection.rect.left !== next.rect.left
170
+ || selection.rect.width !== next.rect.width
171
+ ))
172
+ if (!changed) return
173
+ selection = next
174
+ notify()
175
+ }
176
+
177
+ const onSelectionChange = (): void => {
178
+ if (debounceTimer !== undefined) window.clearTimeout(debounceTimer)
179
+ debounceTimer = window.setTimeout(recompute, DEBOUNCE_MS)
180
+ }
181
+
182
+ document.addEventListener('selectionchange', onSelectionChange)
183
+ document.addEventListener('mouseup', recompute)
184
+ document.addEventListener('keyup', onSelectionChange)
185
+
186
+ return {
187
+ getSnapshot: () => state,
188
+ subscribe(fn: () => void): () => void {
189
+ listeners.add(fn)
190
+ return () => { listeners.delete(fn) }
191
+ },
192
+ clear(): void {
193
+ selection = null
194
+ notify()
195
+ },
196
+ dispose(): void {
197
+ document.removeEventListener('selectionchange', onSelectionChange)
198
+ document.removeEventListener('mouseup', recompute)
199
+ document.removeEventListener('keyup', onSelectionChange)
200
+ if (debounceTimer !== undefined) window.clearTimeout(debounceTimer)
201
+ },
202
+ }
203
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * In-plugin bridge between the annotate module (Workitem 02/03) and the
3
+ * sidechat module (Workitem 01). The sidechat module installs an
4
+ * implementation on activation; annotate's 「在侧边聊天中提问」 button calls
5
+ * it when present. Module-level singleton by design: exactly one sidechat
6
+ * module and one annotate module exist per activation.
7
+ */
8
+ import type { SessionId } from '../context-types.ts'
9
+
10
+ export interface SideChatBridge {
11
+ /**
12
+ * Open (or focus) a side chat forked from `sessionId`, seeding its composer
13
+ * draft with `draftText` (quoted selection + annotation). Returns false
14
+ * when no side chat could be opened (caller keeps its own flow).
15
+ */
16
+ askInSideChat(sessionId: SessionId, draftText: string): boolean
17
+ }
18
+
19
+ export const sideChatBridge: { current: SideChatBridge | null } = { current: null }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Client half of dsh-sidenote: the side-chat tabs (Workitem 01) and the
3
+ * selection annotations (Workitem 02). Thin consumer of dsh-better-sidebar —
4
+ * it builds no panel chrome (portal/resize/collapse/persistence); the panel
5
+ * container is entirely better-sidebar's.
6
+ *
7
+ * Requires the `betterSidebar` service (hard peer dependency): inject keeps
8
+ * the plugin inactive until better-sidebar provides it.
9
+ */
10
+ import type { Context } from '../context-types.ts'
11
+ import { attachLocale, type LocaleServiceLike } from './locales.ts'
12
+ import { registerSideChat } from './sidechat/index.tsx'
13
+ import { registerAnnotations } from './annotate/index.tsx'
14
+
15
+ export const inject = ['betterSidebar', 'sessions', 'workspaces', 'slots', 'connection', 'locale']
16
+
17
+ export function apply(ctx: Context): void {
18
+ // 跟随 DSH 通用设置里的语言(locale.preference,Host-backed,实时切换)。
19
+ attachLocale(ctx.locale as LocaleServiceLike | undefined)
20
+ registerSideChat(ctx)
21
+ registerAnnotations(ctx)
22
+ }
@@ -0,0 +1,175 @@
1
+ import { useSyncExternalStore } from 'react'
2
+
3
+ /**
4
+ * dsh-sidenote 的双语层(zh/en),跟随 DSH 通用设置里的语言(`ctx.locale`,
5
+ * Host-backed locale.preference,实时切换)。模式照 better-sidebar
6
+ * `src/client/locales.ts`:attachLocale 挂服务,t() 读活动语言;
7
+ * 服务缺失(独立组合/测试)时回退浏览器语言。
8
+ */
9
+
10
+ export const LOCALE_NS = 'dsh-sidenote'
11
+
12
+ /** 英文词典(key 的权威清单)。 */
13
+ export const en = {
14
+ menuTitle: 'Side chat',
15
+ tabBaseTitle: 'Side',
16
+
17
+ // SideChatPanel
18
+ codeCopy: 'Copy',
19
+ codeCopied: 'Copied',
20
+ forkErrorTitle: "Couldn't create the side chat",
21
+ forkErrorHint: 'The main session needs at least one completed turn to fork from. Close this tab with the × on the tab.',
22
+ missingTitle: 'Session no longer exists',
23
+ missingDetail: "This side chat's session was removed and can't be restored.",
24
+ missingHint: 'Close this tab with the × on the tab.',
25
+ preparing: 'Preparing the side chat…',
26
+ historyFailed: 'Failed to load the session history. Close and reopen this tab.',
27
+ emptyTitle: 'Side chat',
28
+ emptyText: 'Forked from the current session and evolves independently. Closing the tab removes it.',
29
+ thinking: 'Thinking',
30
+ writing: 'Writing…',
31
+ stopped: 'Stopped',
32
+ toolLabel: 'Tool',
33
+ failed: 'failed',
34
+ running: 'running…',
35
+ inputPlaceholder: 'Message… (Enter to send, Shift+Enter for a newline)',
36
+ modelLabel: 'Model: {name}',
37
+ modelFollowsMain: 'follows main session',
38
+ stopReply: 'Stop',
39
+ stopReplyTitle: 'Stop the current reply',
40
+ send: 'Send',
41
+
42
+ // /side command
43
+ cmdDesc: 'Open a side chat (forked from the current session)',
44
+ cmdNew: 'New side chat',
45
+ cmdNewDetail: 'Forked from the current session; evolves independently',
46
+ cmdFocus: 'Focus "{title}"',
47
+ cmdFocusDetail: 'Existing side chat',
48
+
49
+ // annotate overlay
50
+ addToConversation: 'Add to conversation',
51
+ askInSideChat: 'Ask in side chat',
52
+ toolbarAria: 'Selection annotations',
53
+ notePlaceholder: 'Add a note (optional)…',
54
+ confirmTitle: 'Confirm',
55
+ saveNoteAria: 'Save note',
56
+ sideNotePlaceholder: 'Add a note for the side chat (optional)…',
57
+ sideNoteAria: 'Side chat note',
58
+ confirmAskAria: 'Confirm and ask',
59
+ openSideFailed: "Couldn't open the side chat. Try again.",
60
+ deleteNote: 'Delete annotation',
61
+ cancel: 'Cancel',
62
+ save: 'Save',
63
+
64
+ // chip
65
+ chipOne: '1 annotation',
66
+ chipMany: '{n} annotations',
67
+ removeTitle: 'Remove annotation',
68
+ removeAria: 'Remove annotation {n}',
69
+
70
+ // model-facing quote format
71
+ noteLine: 'Note: {note}',
72
+ noNote: '(no note)',
73
+ }
74
+
75
+ export type CopyKey = keyof typeof en
76
+
77
+ /** 中文词典(与 en 同 key)。 */
78
+ export const zh: Record<CopyKey, string> = {
79
+ menuTitle: '侧边聊天',
80
+ tabBaseTitle: '侧边',
81
+
82
+ codeCopy: '复制',
83
+ codeCopied: '已复制',
84
+ forkErrorTitle: '无法创建侧边聊天',
85
+ forkErrorHint: '主会话需要至少一轮已完成的对话才能 fork。点击标签上的 × 可关闭此标签页。',
86
+ missingTitle: '会话已不存在',
87
+ missingDetail: '此侧边聊天的会话已被移除,无法恢复。',
88
+ missingHint: '点击标签上的 × 关闭此标签页。',
89
+ preparing: '正在准备侧边聊天…',
90
+ historyFailed: '会话历史加载失败,可关闭后重新打开此标签页。',
91
+ emptyTitle: '侧边聊天',
92
+ emptyText: '侧边聊天从当前会话 fork,独立演进;关闭标签页后消失。',
93
+ thinking: '思考过程',
94
+ writing: '正在输出…',
95
+ stopped: '已停止',
96
+ toolLabel: '工具',
97
+ failed: '失败',
98
+ running: '执行中…',
99
+ inputPlaceholder: '输入消息…(Enter 发送,Shift+Enter 换行)',
100
+ modelLabel: '模型:{name}',
101
+ modelFollowsMain: '跟随主会话',
102
+ stopReply: '停止',
103
+ stopReplyTitle: '停止当前回复',
104
+ send: '发送',
105
+
106
+ cmdDesc: '打开侧边聊天(从当前会话 fork)',
107
+ cmdNew: '新建侧边聊天',
108
+ cmdNewDetail: '从当前会话 fork,独立演进',
109
+ cmdFocus: '聚焦「{title}」',
110
+ cmdFocusDetail: '已存在的侧边聊天',
111
+
112
+ addToConversation: '添加到对话',
113
+ askInSideChat: '在侧边聊天中提问',
114
+ toolbarAria: '划选注释',
115
+ notePlaceholder: '这里可以写自己的注解…',
116
+ confirmTitle: '确认',
117
+ saveNoteAria: '确认注解',
118
+ sideNotePlaceholder: '给侧边聊天写个注解(可空)…',
119
+ sideNoteAria: '侧边聊天注解',
120
+ confirmAskAria: '确认并提问',
121
+ openSideFailed: '打开侧边聊天失败,请重试',
122
+ deleteNote: '删除注释',
123
+ cancel: '取消',
124
+ save: '保存',
125
+
126
+ chipOne: '1 条注释',
127
+ chipMany: '{n} 条注释',
128
+ removeTitle: '移除注释',
129
+ removeAria: '移除注释 {n}',
130
+
131
+ noteLine: '注解:{note}',
132
+ noNote: '(无注解)',
133
+ }
134
+
135
+ /** The DSH locale service face we consume (subset of LocaleRuntime). */
136
+ export interface LocaleServiceLike {
137
+ getSnapshot(): { active: string }
138
+ subscribe(fn: () => void): () => void
139
+ }
140
+
141
+ let localeService: LocaleServiceLike | undefined
142
+
143
+ /** Attach the DSH locale service (call once from the client apply). */
144
+ export function attachLocale(service: LocaleServiceLike | undefined): void {
145
+ localeService = service
146
+ }
147
+
148
+ /** Subscribe to locale switches (component re-render driver). */
149
+ export function subscribeLocale(fn: () => void): () => void {
150
+ return localeService?.subscribe(fn) ?? (() => {})
151
+ }
152
+
153
+ /** The active locale id:DSH 语言设置优先,缺失时回退浏览器语言。 */
154
+ function activeLocale(): string {
155
+ return localeService?.getSnapshot().active
156
+ ?? (typeof navigator !== 'undefined' ? navigator.language : '')
157
+ ?? 'en'
158
+ }
159
+
160
+ /** Translate a copy key; `{name}` placeholders interpolate from `params`. */
161
+ export function t(key: CopyKey, params?: Record<string, string | number>): string {
162
+ const dict = activeLocale().toLowerCase().startsWith('zh') ? zh : en
163
+ let text = dict[key]
164
+ if (params !== undefined) {
165
+ for (const [name, value] of Object.entries(params)) {
166
+ text = text.replaceAll(`{${name}}`, String(value))
167
+ }
168
+ }
169
+ return text
170
+ }
171
+
172
+ /** 语言切换时驱动组件重渲(useSyncExternalStore 标准接法)。 */
173
+ export function useLocaleTick(): void {
174
+ useSyncExternalStore(subscribeLocale, () => localeService?.getSnapshot().active ?? 'en')
175
+ }