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.
- package/LICENSE +21 -0
- package/README.md +60 -0
- package/cordis.patch.yml +16 -0
- package/lib/client.js +2143 -0
- package/lib/index.js +6 -0
- package/lib/types/client/annotate/anchor.d.ts +57 -0
- package/lib/types/client/annotate/chip.d.ts +11 -0
- package/lib/types/client/annotate/draft.d.ts +29 -0
- package/lib/types/client/annotate/format.d.ts +52 -0
- package/lib/types/client/annotate/index.d.ts +2 -0
- package/lib/types/client/annotate/model.d.ts +59 -0
- package/lib/types/client/annotate/overlay.d.ts +27 -0
- package/lib/types/client/annotate/selection.d.ts +49 -0
- package/lib/types/client/bridge.d.ts +19 -0
- package/lib/types/client/index.d.ts +12 -0
- package/lib/types/client/locales.d.ts +77 -0
- package/lib/types/client/sidechat/SideChatPanel.d.ts +2 -0
- package/lib/types/client/sidechat/composer.d.ts +26 -0
- package/lib/types/client/sidechat/index.d.ts +2 -0
- package/lib/types/client/sidechat/model.d.ts +101 -0
- package/lib/types/client/sidechat/open.d.ts +16 -0
- package/lib/types/client/sidechat/slash.d.ts +12 -0
- package/lib/types/context-types.d.ts +335 -0
- package/lib/types/index.d.ts +9 -0
- package/package.json +96 -0
- package/src/client/annotate/anchor.ts +158 -0
- package/src/client/annotate/annotate.module.css +301 -0
- package/src/client/annotate/chip.tsx +115 -0
- package/src/client/annotate/css.d.ts +9 -0
- package/src/client/annotate/draft.ts +67 -0
- package/src/client/annotate/format.ts +83 -0
- package/src/client/annotate/index.tsx +66 -0
- package/src/client/annotate/model.ts +138 -0
- package/src/client/annotate/overlay.tsx +531 -0
- package/src/client/annotate/selection.ts +203 -0
- package/src/client/bridge.ts +19 -0
- package/src/client/index.tsx +22 -0
- package/src/client/locales.ts +175 -0
- package/src/client/sidechat/SideChatPanel.tsx +366 -0
- package/src/client/sidechat/composer.ts +129 -0
- package/src/client/sidechat/css-modules.d.ts +5 -0
- package/src/client/sidechat/index.tsx +56 -0
- package/src/client/sidechat/model.ts +331 -0
- package/src/client/sidechat/open.ts +66 -0
- package/src/client/sidechat/sidechat.module.css +230 -0
- package/src/client/sidechat/slash.ts +84 -0
- package/src/context-types.ts +369 -0
- package/src/index.ts +13 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural mirror of the runtime surfaces dsh-sidenote consumes.
|
|
3
|
+
*
|
|
4
|
+
* Third-party plugins resolve outside the DSH monorepo's single cordis
|
|
5
|
+
* instance, so upstream `declare module 'cordis'` augmentations never reach
|
|
6
|
+
* our `Context` type. We mirror the runtime shapes here instead — drift from
|
|
7
|
+
* upstream is contained to this file. Each section names its authority (the
|
|
8
|
+
* upstream .d.ts it mirrors); extend sections as features need more surface,
|
|
9
|
+
* keeping the mirror honest (only declare what actually exists at runtime).
|
|
10
|
+
*
|
|
11
|
+
* Authorities:
|
|
12
|
+
* - betterSidebar: dsh-better-sidebar `src/client/service.ts` (+ docs/external-plugin-guide.md)
|
|
13
|
+
* - sessions/workspaces: `@deepseek-ai/dsh-client-runtime` lib/types/client/contract/{sessions,session,workspaces}.d.ts
|
|
14
|
+
* (local checkout: <dsh install>/node_modules/@deepseek-ai/dsh-client-runtime/lib/types/client/...)
|
|
15
|
+
* - conversation input / slots: `@deepseek-ai/dsh-client-ui-conversation` / `dsh-client-ui-slots` type surfaces
|
|
16
|
+
*/
|
|
17
|
+
import type { Context as CordisContext } from 'cordis';
|
|
18
|
+
import type { ReactNode } from 'react';
|
|
19
|
+
export type SessionId = string;
|
|
20
|
+
/** Minimal observable snapshot shape (identity-stable, useSyncExternalStore-ready). */
|
|
21
|
+
export interface ObservableSnapshot<T> {
|
|
22
|
+
getSnapshot(): T;
|
|
23
|
+
subscribe(listener: () => void): () => void;
|
|
24
|
+
}
|
|
25
|
+
export interface SessionScope {
|
|
26
|
+
sessionId: SessionId;
|
|
27
|
+
cwd?: string;
|
|
28
|
+
}
|
|
29
|
+
/** A live sidebar tab instance. `meta` is plugin-owned JSON persisted with the layout. */
|
|
30
|
+
export interface SidebarTab {
|
|
31
|
+
id: string;
|
|
32
|
+
type: string;
|
|
33
|
+
title: string;
|
|
34
|
+
path?: string;
|
|
35
|
+
meta?: unknown;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* 侧栏状态 = splits/bottomSplits 两棵 split/leaf 树(权威:dsh-better-sidebar
|
|
39
|
+
* src/client/state.ts):leaf 持有 tabs;split 递归分栏。
|
|
40
|
+
*/
|
|
41
|
+
export interface SidebarLeafNode {
|
|
42
|
+
kind: 'leaf';
|
|
43
|
+
id: string;
|
|
44
|
+
tabs: SidebarTab[];
|
|
45
|
+
active: string | null;
|
|
46
|
+
}
|
|
47
|
+
export interface SidebarSplitNode {
|
|
48
|
+
kind: 'split';
|
|
49
|
+
id: string;
|
|
50
|
+
dir: 'row' | 'col';
|
|
51
|
+
sizes: number[];
|
|
52
|
+
children: SidebarTreeNode[];
|
|
53
|
+
}
|
|
54
|
+
export type SidebarTreeNode = SidebarLeafNode | SidebarSplitNode;
|
|
55
|
+
export interface SidebarState {
|
|
56
|
+
/** 右侧面板的 split 树。 */
|
|
57
|
+
splits: SidebarTreeNode;
|
|
58
|
+
/** 底部面板的 split 树。 */
|
|
59
|
+
bottomSplits: SidebarTreeNode;
|
|
60
|
+
}
|
|
61
|
+
export interface SidebarSnapshot {
|
|
62
|
+
sessionId?: SessionId;
|
|
63
|
+
state?: SidebarState;
|
|
64
|
+
prefs?: Record<string, unknown>;
|
|
65
|
+
}
|
|
66
|
+
/** better-sidebar's store handle (external tabs treat it as opaque). */
|
|
67
|
+
export type SidebarStore = unknown;
|
|
68
|
+
export interface TabComponentProps {
|
|
69
|
+
ctx: Context;
|
|
70
|
+
store: SidebarStore;
|
|
71
|
+
scope: SessionScope;
|
|
72
|
+
tab: SidebarTab;
|
|
73
|
+
visible: boolean;
|
|
74
|
+
}
|
|
75
|
+
export interface TabDescriptor {
|
|
76
|
+
id: string;
|
|
77
|
+
title: string | (() => string);
|
|
78
|
+
icon?: ReactNode | ((size: number) => ReactNode);
|
|
79
|
+
order?: number;
|
|
80
|
+
hidden?: boolean;
|
|
81
|
+
available?: (ctx: Context, scope: SessionScope, state: SidebarState) => boolean;
|
|
82
|
+
single?: boolean;
|
|
83
|
+
dedupeKey?: (tab: SidebarTab) => string | undefined;
|
|
84
|
+
createTab?: (state: SidebarState) => {
|
|
85
|
+
tab: SidebarTab;
|
|
86
|
+
patch?: Partial<SidebarState>;
|
|
87
|
+
} | null;
|
|
88
|
+
badge?: (ctx: Context, scope: SessionScope, state: SidebarState) => string | number | null | undefined;
|
|
89
|
+
onOpen?: (tab: SidebarTab, scope: SessionScope) => void;
|
|
90
|
+
onActivate?: (tab: SidebarTab, scope: SessionScope) => void;
|
|
91
|
+
onClose?: (tab: SidebarTab, scope: SessionScope) => void;
|
|
92
|
+
component: (props: TabComponentProps) => ReactNode;
|
|
93
|
+
}
|
|
94
|
+
export interface OpenTabSeed {
|
|
95
|
+
type: string;
|
|
96
|
+
title?: string;
|
|
97
|
+
path?: string;
|
|
98
|
+
id?: string;
|
|
99
|
+
url?: string;
|
|
100
|
+
meta?: unknown;
|
|
101
|
+
}
|
|
102
|
+
export interface BetterSidebarService {
|
|
103
|
+
readonly version: string;
|
|
104
|
+
readonly features: readonly string[];
|
|
105
|
+
registerTab(descriptor: TabDescriptor): () => void;
|
|
106
|
+
openTab(seed: OpenTabSeed, scope?: SessionScope): void;
|
|
107
|
+
closeTab(tabId: string, scope?: SessionScope): void;
|
|
108
|
+
activateTab(tabId: string, scope?: SessionScope): void;
|
|
109
|
+
updateTab(tabId: string, patch: {
|
|
110
|
+
title?: string;
|
|
111
|
+
path?: string;
|
|
112
|
+
meta?: unknown;
|
|
113
|
+
}): void;
|
|
114
|
+
getTab(id: string): TabDescriptor | undefined;
|
|
115
|
+
getTabs(): readonly TabDescriptor[];
|
|
116
|
+
isTabEnabled(id: string): boolean;
|
|
117
|
+
getSnapshot(): SidebarSnapshot;
|
|
118
|
+
subscribeState(listener: () => void): () => void;
|
|
119
|
+
subscribe(listener: () => void): () => void;
|
|
120
|
+
}
|
|
121
|
+
export interface SessionListSnapshot {
|
|
122
|
+
/** Currently selected session id. */
|
|
123
|
+
current?: SessionId;
|
|
124
|
+
[key: string]: unknown;
|
|
125
|
+
}
|
|
126
|
+
export interface ForkOptions {
|
|
127
|
+
sessionId: SessionId;
|
|
128
|
+
atSeq?: number;
|
|
129
|
+
increaseTitle?: boolean;
|
|
130
|
+
}
|
|
131
|
+
/** Text plus browser-owned parts; text covers everything we send today. */
|
|
132
|
+
export type PromptContentPart = {
|
|
133
|
+
type: 'text';
|
|
134
|
+
text: string;
|
|
135
|
+
} | {
|
|
136
|
+
type: string;
|
|
137
|
+
[key: string]: unknown;
|
|
138
|
+
};
|
|
139
|
+
/**
|
|
140
|
+
* Conversation read model (subset). Extend from
|
|
141
|
+
* dsh-client-runtime `lib/types/client/sessions/conversation.d.ts` as needed —
|
|
142
|
+
* the real shape has chat/nodes/partial/queue/running and more.
|
|
143
|
+
*/
|
|
144
|
+
export interface ConversationSnapshot {
|
|
145
|
+
sessionId: SessionId;
|
|
146
|
+
nodes: readonly unknown[];
|
|
147
|
+
running: boolean;
|
|
148
|
+
[key: string]: unknown;
|
|
149
|
+
}
|
|
150
|
+
export interface ISession {
|
|
151
|
+
readonly sessionId: SessionId;
|
|
152
|
+
prompt(content: PromptContentPart[], mode: 'queue' | 'steer'): Promise<unknown>;
|
|
153
|
+
cancel(): Promise<unknown>;
|
|
154
|
+
rename(title: string): Promise<unknown>;
|
|
155
|
+
loadOlder(): Promise<void>;
|
|
156
|
+
command(line: string): Promise<unknown>;
|
|
157
|
+
}
|
|
158
|
+
export type SessionFace = ISession & ObservableSnapshot<ConversationSnapshot>;
|
|
159
|
+
export interface SessionBinding {
|
|
160
|
+
session: SessionFace;
|
|
161
|
+
}
|
|
162
|
+
export interface SessionsService {
|
|
163
|
+
list: ObservableSnapshot<SessionListSnapshot>;
|
|
164
|
+
fork(opts: ForkOptions): Promise<SessionId>;
|
|
165
|
+
binding(id: SessionId): SessionBinding | undefined;
|
|
166
|
+
scope(id: SessionId): Context | undefined;
|
|
167
|
+
open(id: SessionId): void;
|
|
168
|
+
}
|
|
169
|
+
export interface WorkspacesService {
|
|
170
|
+
archiveSession(sessionId: SessionId): Promise<void>;
|
|
171
|
+
}
|
|
172
|
+
export interface SessionInput {
|
|
173
|
+
setDraft(text: string): void;
|
|
174
|
+
submit(mode?: string): void;
|
|
175
|
+
notify(level: string, text: string): void;
|
|
176
|
+
}
|
|
177
|
+
export interface ConversationService {
|
|
178
|
+
input: {
|
|
179
|
+
for(ctx: Context): SessionInput | undefined;
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/** 会话的当前模型选择(`session.models` 的 current 字段)。 */
|
|
183
|
+
export interface ModelSelection {
|
|
184
|
+
provider: string;
|
|
185
|
+
model: string;
|
|
186
|
+
reasoningEffort?: string;
|
|
187
|
+
}
|
|
188
|
+
export interface SessionModelsResult {
|
|
189
|
+
current: ModelSelection;
|
|
190
|
+
routable: boolean;
|
|
191
|
+
groups: readonly unknown[];
|
|
192
|
+
}
|
|
193
|
+
export interface RpcEnvelope<T> {
|
|
194
|
+
result: {
|
|
195
|
+
ok: true;
|
|
196
|
+
value: T;
|
|
197
|
+
} | {
|
|
198
|
+
ok: false;
|
|
199
|
+
error: {
|
|
200
|
+
message?: string;
|
|
201
|
+
};
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
export interface ConnectionSessionsApi {
|
|
205
|
+
models(payload: {
|
|
206
|
+
sessionId: SessionId;
|
|
207
|
+
}): Promise<RpcEnvelope<SessionModelsResult>>;
|
|
208
|
+
selectModel(payload: {
|
|
209
|
+
sessionId: SessionId;
|
|
210
|
+
provider: string;
|
|
211
|
+
model: string;
|
|
212
|
+
reasoningEffort?: string;
|
|
213
|
+
}): Promise<RpcEnvelope<{
|
|
214
|
+
selected: ModelSelection;
|
|
215
|
+
}>>;
|
|
216
|
+
}
|
|
217
|
+
export interface ConnectionService {
|
|
218
|
+
api: {
|
|
219
|
+
sessions: ConnectionSessionsApi;
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
export interface LocaleService {
|
|
223
|
+
getSnapshot(): {
|
|
224
|
+
active: string;
|
|
225
|
+
};
|
|
226
|
+
subscribe(fn: () => void): () => void;
|
|
227
|
+
}
|
|
228
|
+
export interface Context extends CordisContext {
|
|
229
|
+
betterSidebar: BetterSidebarService;
|
|
230
|
+
sessions: SessionsService;
|
|
231
|
+
workspaces: WorkspacesService;
|
|
232
|
+
connection: ConnectionService;
|
|
233
|
+
locale: LocaleService;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* 会话列表快照补全(权威:dsh-client-runtime
|
|
237
|
+
* lib/types/client/sessions/service.d.ts 的 SessionListState):
|
|
238
|
+
* phase 标「首次成功拉取」就绪边;byId 含已归档行(归档过滤在
|
|
239
|
+
* workspace UI 层,store 本身携带全部行)。
|
|
240
|
+
*/
|
|
241
|
+
export interface SessionListSnapshot {
|
|
242
|
+
phase?: 'pending' | 'ready';
|
|
243
|
+
byId?: Record<string, {
|
|
244
|
+
blank?: boolean;
|
|
245
|
+
} | undefined>;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* 会话快照补全(权威:dsh-client-runtime sessions/conversation.d.ts):
|
|
249
|
+
* openState 是历史窗口生命周期(cold = 未开窗口);partial/runningCalls
|
|
250
|
+
* 承载在途流式输出。面板折叠函数对三者全部容错(缺省按空处理)。
|
|
251
|
+
*/
|
|
252
|
+
export interface ConversationSnapshot {
|
|
253
|
+
openState?: 'cold' | 'loading' | 'open' | 'error';
|
|
254
|
+
partial?: unknown;
|
|
255
|
+
runningCalls?: readonly unknown[];
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* input 草稿机的 state store(权威:dsh-client-ui-conversation
|
|
259
|
+
* input/contract.d.ts 的 SessionInput.state)——完整声明见下方 annotate 扩展节
|
|
260
|
+
* (接口合并;draft 字段两侧都依赖)。
|
|
261
|
+
*/
|
|
262
|
+
export interface Context {
|
|
263
|
+
/**
|
|
264
|
+
* 惰性非追踪服务读(运行时事实:context proxy 的 reflect.get)——
|
|
265
|
+
* 读取不在 inject 清单里的服务(conversation / commandUi)的唯一通道。
|
|
266
|
+
* (`ctx.effect` 无需镜像:公开 cordis 包的 fiber.d.ts 已通过接口合并
|
|
267
|
+
* 提供 `effect(execute, label?)`。)
|
|
268
|
+
*/
|
|
269
|
+
get(name: string): unknown;
|
|
270
|
+
}
|
|
271
|
+
/** Registration options subset passed to `ctx.slots.register` (same shape better-sidebar mirrors). */
|
|
272
|
+
export interface SlotRegisterOptions {
|
|
273
|
+
name: string;
|
|
274
|
+
key?: string;
|
|
275
|
+
id?: string;
|
|
276
|
+
order?: number;
|
|
277
|
+
label?: string | (() => string);
|
|
278
|
+
select?: (owner: unknown) => unknown;
|
|
279
|
+
priority?: number;
|
|
280
|
+
locale?: string;
|
|
281
|
+
registrant?: string;
|
|
282
|
+
inject?: (...args: any[]) => Record<string, unknown>;
|
|
283
|
+
children?: Record<string, unknown>;
|
|
284
|
+
}
|
|
285
|
+
/** The client slots service face (register returns the disposer). */
|
|
286
|
+
export interface SlotsService {
|
|
287
|
+
register(options: SlotRegisterOptions, component: unknown): () => void;
|
|
288
|
+
/** Run a callback for each declaration lifetime of a slot (no-op while undeclared). */
|
|
289
|
+
inject(key: string, callback: () => (() => void) | void): () => void;
|
|
290
|
+
}
|
|
291
|
+
/** Published composer input state (subset of the real InputState). */
|
|
292
|
+
export interface InputStateSnapshot {
|
|
293
|
+
readonly draft: string;
|
|
294
|
+
readonly phase: string;
|
|
295
|
+
readonly queue?: readonly unknown[];
|
|
296
|
+
}
|
|
297
|
+
/** Session input snapshot as exposed on `InputZone.input`. */
|
|
298
|
+
export interface ConversationSnapshotLite {
|
|
299
|
+
sessionId: SessionId;
|
|
300
|
+
running: boolean;
|
|
301
|
+
}
|
|
302
|
+
/** Owner share of the `conversation.input.dock` slot (point-in-time snapshots). */
|
|
303
|
+
export interface InputZone {
|
|
304
|
+
readonly session: ConversationSnapshotLite;
|
|
305
|
+
readonly input: InputStateSnapshot;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* U+FFFC reference-chip insert (spike result: NOT used — serialization of the
|
|
309
|
+
* chip routes through the source owner's ReferenceCodec, which is
|
|
310
|
+
* package-internal to ui-input-trigger with no plugin-facing registry; an
|
|
311
|
+
* unowned source would mark the occurrence invalid and fail submit. Mirrored
|
|
312
|
+
* here only to document the probe target).
|
|
313
|
+
*/
|
|
314
|
+
export interface ReferenceInsert {
|
|
315
|
+
readonly source: string;
|
|
316
|
+
readonly ref: string;
|
|
317
|
+
readonly label: string;
|
|
318
|
+
readonly clipboardText: string;
|
|
319
|
+
}
|
|
320
|
+
export interface TokenSpan {
|
|
321
|
+
readonly start: number;
|
|
322
|
+
readonly end: number;
|
|
323
|
+
readonly draftRev: number;
|
|
324
|
+
}
|
|
325
|
+
export interface Context {
|
|
326
|
+
/** The slot registry (provided by dsh-client-runtime, mounted before this plugin). */
|
|
327
|
+
slots: SlotsService;
|
|
328
|
+
}
|
|
329
|
+
/** SessionInput completion for annotate: the live state store + the (unused) chip insert face. */
|
|
330
|
+
export interface SessionInput {
|
|
331
|
+
/** Input state store (draft reads + subscribe for the send-edge watch). */
|
|
332
|
+
readonly state: ObservableSnapshot<InputStateSnapshot>;
|
|
333
|
+
/** Spike-only mirror; see {@link ReferenceInsert}. Not called by annotate. */
|
|
334
|
+
insertReference(ref: ReferenceInsert, span: TokenSpan): boolean;
|
|
335
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-sidenote host half: currently empty by design — every feature is
|
|
3
|
+
* client-side (sessions RPC + the betterSidebar service). A host half would
|
|
4
|
+
* only appear for capabilities the client cannot reach (none today).
|
|
5
|
+
*/
|
|
6
|
+
import type { Context } from './context-types.ts';
|
|
7
|
+
/** Plugin identity for cordis.yml rows. */
|
|
8
|
+
export declare const name = "dsh-sidenote";
|
|
9
|
+
export declare function apply(_ctx: Context): void;
|
package/package.json
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-sidenote",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DSH web plugin: Codex-style side chat (fork the current session into a persistent side panel) + selection annotations with numbered badges and a composer chip. A thin consumer of dsh-better-sidebar. / Codex 风格侧边聊天 + 划选注释的 DSH 插件。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "lib/index.js",
|
|
8
|
+
"types": "lib/types/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./lib/types/index.d.ts",
|
|
12
|
+
"default": "./lib/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./client": {
|
|
15
|
+
"types": "./lib/types/client/index.d.ts",
|
|
16
|
+
"default": "./lib/client.js"
|
|
17
|
+
},
|
|
18
|
+
"./src/*": "./src/*",
|
|
19
|
+
"./package.json": "./package.json"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=20"
|
|
23
|
+
},
|
|
24
|
+
"dsh": {
|
|
25
|
+
"bundle": {
|
|
26
|
+
"patch": "./cordis.patch.yml"
|
|
27
|
+
},
|
|
28
|
+
"client": {
|
|
29
|
+
"inject": [
|
|
30
|
+
"@deepseek-ai/dsh-client-runtime"
|
|
31
|
+
],
|
|
32
|
+
"platform": "web"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"lib/index.js",
|
|
37
|
+
"lib/client.js",
|
|
38
|
+
"lib/types/**/*.d.ts",
|
|
39
|
+
"src",
|
|
40
|
+
"cordis.patch.yml",
|
|
41
|
+
"README.md",
|
|
42
|
+
"LICENSE"
|
|
43
|
+
],
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
|
|
46
|
+
"cordis": "^4.0.0-rc.7",
|
|
47
|
+
"dsh-better-sidebar": "^0.12.0",
|
|
48
|
+
"react": "^18.2.0",
|
|
49
|
+
"react-dom": "^18.2.0"
|
|
50
|
+
},
|
|
51
|
+
"peerDependenciesMeta": {
|
|
52
|
+
"cordis": {
|
|
53
|
+
"optional": true
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
|
|
58
|
+
"@types/node": "^24.0.0",
|
|
59
|
+
"@types/react": "~18.3.1",
|
|
60
|
+
"@types/react-dom": "~18.3.1",
|
|
61
|
+
"cordis": "^4.0.0-rc.7",
|
|
62
|
+
"lightningcss": "^1.32.0",
|
|
63
|
+
"react": "^18.2.0",
|
|
64
|
+
"react-dom": "^18.2.0",
|
|
65
|
+
"tsdown": "^0.22.2",
|
|
66
|
+
"typescript": "^5.6.0",
|
|
67
|
+
"vitest": "^4.1.8",
|
|
68
|
+
"@playwright/test": "^1.50.0"
|
|
69
|
+
},
|
|
70
|
+
"keywords": [
|
|
71
|
+
"dsh-plugin",
|
|
72
|
+
"dsh",
|
|
73
|
+
"deepseek-harness",
|
|
74
|
+
"dsh-better-sidebar",
|
|
75
|
+
"side-chat",
|
|
76
|
+
"annotation",
|
|
77
|
+
"ai-coding",
|
|
78
|
+
"deepseek"
|
|
79
|
+
],
|
|
80
|
+
"homepage": "https://github.com/g-yixuan/dsh-sidenote#readme",
|
|
81
|
+
"repository": {
|
|
82
|
+
"type": "git",
|
|
83
|
+
"url": "https://github.com/g-yixuan/dsh-sidenote.git"
|
|
84
|
+
},
|
|
85
|
+
"bugs": "https://github.com/g-yixuan/dsh-sidenote/issues",
|
|
86
|
+
"author": "gyixuan",
|
|
87
|
+
"scripts": {
|
|
88
|
+
"build": "node -e \"fs.rmSync('lib',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && tsdown",
|
|
89
|
+
"typecheck": "tsc --noEmit",
|
|
90
|
+
"bundle": "tsdown",
|
|
91
|
+
"watch": "tsdown --watch",
|
|
92
|
+
"test": "vitest run",
|
|
93
|
+
"test:mount": "bash scripts/e2e-mount.sh",
|
|
94
|
+
"pretest:mount": "pnpm build && pnpm pack"
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Badge anchoring (Workitem 02): resolve a live DOM Range for an annotation.
|
|
3
|
+
*
|
|
4
|
+
* The range captured at creation time dies when React re-renders the message
|
|
5
|
+
* (session switch, streaming rebuilds, fork/delete). We therefore re-anchor
|
|
6
|
+
* on demand: find the message element by `data-chat-anchor-key`, locate the
|
|
7
|
+
* Nth occurrence of the anchor text in its text nodes, and rebuild the range.
|
|
8
|
+
* Anchor loss is acceptable (页面级临时语义) — the badge simply skips a
|
|
9
|
+
* render; nothing here may throw into the page.
|
|
10
|
+
*/
|
|
11
|
+
import type { Annotation } from './model.ts'
|
|
12
|
+
|
|
13
|
+
/** The badge anchor point: right edge of the selection rects, first-line height. */
|
|
14
|
+
export interface BadgeAnchor {
|
|
15
|
+
/** max(rect.right) across the selection's client rects. */
|
|
16
|
+
readonly right: number
|
|
17
|
+
/** Vertical center of the FIRST line's rect. */
|
|
18
|
+
readonly centerY: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Find the message element for an anchor key (connected DOM only). */
|
|
22
|
+
function findAnchorElement(anchorKey: string): HTMLElement | null {
|
|
23
|
+
try {
|
|
24
|
+
const el = document.querySelector<HTMLElement>(
|
|
25
|
+
`[data-chat-anchor-key="${CSS.escape(anchorKey)}"]`,
|
|
26
|
+
)
|
|
27
|
+
return el !== null && el.isConnected ? el : null
|
|
28
|
+
} catch {
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Rebuild a range covering the `occurrence`-th appearance of `text` inside
|
|
35
|
+
* `root`'s text nodes. Falls back to the LAST occurrence when the ordinal is
|
|
36
|
+
* out of range(消息内容变短时,最后一次出现更可能是原选区).
|
|
37
|
+
*/
|
|
38
|
+
function rangeOfOccurrence(root: HTMLElement, text: string, occurrence: number): Range | null {
|
|
39
|
+
if (text === '') return null
|
|
40
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
|
41
|
+
const haystack = root.textContent ?? ''
|
|
42
|
+
let start = -1
|
|
43
|
+
let at = haystack.indexOf(text)
|
|
44
|
+
let seen = 0
|
|
45
|
+
while (at !== -1) {
|
|
46
|
+
if (seen === occurrence) { start = at; break }
|
|
47
|
+
seen += 1
|
|
48
|
+
at = haystack.indexOf(text, at + text.length)
|
|
49
|
+
}
|
|
50
|
+
if (start === -1 && occurrence > 0) {
|
|
51
|
+
// Ordinal out of range — fall back to the last known occurrence.
|
|
52
|
+
start = haystack.lastIndexOf(text)
|
|
53
|
+
}
|
|
54
|
+
if (start === -1) return null
|
|
55
|
+
const end = start + text.length
|
|
56
|
+
let offset = 0
|
|
57
|
+
let startNode: Text | null = null
|
|
58
|
+
let startOffset = 0
|
|
59
|
+
let endNode: Text | null = null
|
|
60
|
+
let endOffset = 0
|
|
61
|
+
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
|
|
62
|
+
const length = (node.textContent ?? '').length
|
|
63
|
+
const next = offset + length
|
|
64
|
+
if (startNode === null && start < next) {
|
|
65
|
+
startNode = node as Text
|
|
66
|
+
startOffset = start - offset
|
|
67
|
+
}
|
|
68
|
+
if (startNode !== null && end <= next) {
|
|
69
|
+
endNode = node as Text
|
|
70
|
+
endOffset = end - offset
|
|
71
|
+
break
|
|
72
|
+
}
|
|
73
|
+
offset = next
|
|
74
|
+
}
|
|
75
|
+
if (startNode === null || endNode === null) return null
|
|
76
|
+
const range = document.createRange()
|
|
77
|
+
range.setStart(startNode, startOffset)
|
|
78
|
+
range.setEnd(endNode, endOffset)
|
|
79
|
+
return range
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Resolve a live range for an annotation: the cached range while its nodes
|
|
84
|
+
* are still connected, otherwise a re-anchored one (cached on success).
|
|
85
|
+
* Returns null when the anchor is gone — callers skip rendering, never throw.
|
|
86
|
+
*/
|
|
87
|
+
export function resolveRange(annotation: Annotation, cache: Map<number, Range>): Range | null {
|
|
88
|
+
try {
|
|
89
|
+
const cached = cache.get(annotation.id)
|
|
90
|
+
if (cached !== undefined && cached.startContainer.isConnected) return cached
|
|
91
|
+
cache.delete(annotation.id)
|
|
92
|
+
if (annotation.anchorKey === undefined || annotation.anchorText === '') return null
|
|
93
|
+
const anchor = findAnchorElement(annotation.anchorKey)
|
|
94
|
+
if (anchor === null) return null
|
|
95
|
+
const range = rangeOfOccurrence(anchor, annotation.anchorText, annotation.occurrence)
|
|
96
|
+
if (range !== null) cache.set(annotation.id, range)
|
|
97
|
+
return range
|
|
98
|
+
} catch {
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The badge anchor point of a range: 选区矩形右缘 (max right edge across
|
|
105
|
+
* client rects — the message column's right edge for full-line selections,
|
|
106
|
+
* the selection's text end for inline ones) at 选区首行高度 (first rect's
|
|
107
|
+
* vertical center). Null when the range lays out to nothing.
|
|
108
|
+
*/
|
|
109
|
+
export function badgeAnchorOf(range: Range): BadgeAnchor | null {
|
|
110
|
+
try {
|
|
111
|
+
const rects = range.getClientRects()
|
|
112
|
+
if (rects.length === 0) return null
|
|
113
|
+
let right = -Infinity
|
|
114
|
+
for (const rect of rects) right = Math.max(right, rect.right)
|
|
115
|
+
const first = rects[0]
|
|
116
|
+
if (first === undefined || !Number.isFinite(right)) return null
|
|
117
|
+
return { right, centerY: first.top + first.height / 2 }
|
|
118
|
+
} catch {
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Client rects of a range for the active-highlight overlay (empty on failure). */
|
|
124
|
+
export function highlightRectsOf(range: Range): readonly { left: number; top: number; width: number; height: number }[] {
|
|
125
|
+
try {
|
|
126
|
+
const out: { left: number; top: number; width: number; height: number }[] = []
|
|
127
|
+
for (const rect of range.getClientRects()) {
|
|
128
|
+
if (rect.width <= 0 || rect.height <= 0) continue
|
|
129
|
+
out.push({ left: rect.left, top: rect.top, width: rect.width, height: rect.height })
|
|
130
|
+
}
|
|
131
|
+
return out
|
|
132
|
+
} catch {
|
|
133
|
+
return []
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 角标碰撞半径(px):同点位的角标必须错开才可点击。 */
|
|
138
|
+
export const BADGE_COLLISION_RADIUS = 18
|
|
139
|
+
/** 碰撞时向下错开的步长(px,大于角标直径)。 */
|
|
140
|
+
export const BADGE_SPREAD_STEP = 24
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 角标落点错开:与已放置角标(|Δx|、|Δy| 均小于碰撞半径)冲突时按
|
|
144
|
+
* BADGE_SPREAD_STEP 逐级下移——同一选区/相邻行的多个注释角标不再叠罗汉
|
|
145
|
+
* (叠放时上层角标拦截下层角标的点击)。
|
|
146
|
+
*/
|
|
147
|
+
export function spreadBadgePoint(
|
|
148
|
+
point: { x: number; y: number },
|
|
149
|
+
placed: readonly { x: number; y: number }[],
|
|
150
|
+
): { x: number; y: number } {
|
|
151
|
+
let out = point
|
|
152
|
+
for (let guard = 0; guard < 50; guard++) {
|
|
153
|
+
const collides = placed.some(p => Math.abs(p.x - out.x) < BADGE_COLLISION_RADIUS && Math.abs(p.y - out.y) < BADGE_COLLISION_RADIUS)
|
|
154
|
+
if (!collides) return out
|
|
155
|
+
out = { x: out.x, y: out.y + BADGE_SPREAD_STEP }
|
|
156
|
+
}
|
|
157
|
+
return out
|
|
158
|
+
}
|