dsh-surface-bridge 0.1.0-alpha.1 → 0.1.0-alpha.2
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/README.md +8 -10
- package/package.json +1 -1
- package/src/client/service.ts +25 -1
- package/src/contract.ts +35 -0
- package/src/host/render.ts +23 -81
package/README.md
CHANGED
|
@@ -26,10 +26,8 @@ DSH 右侧栏的**业务面**(画布、表格、Notebook)与 **DSH 输入栏
|
|
|
26
26
|
| 联动逻辑做成纯库,各插件 import | 看起来更轻,但库会被**分别打进每个插件自己的 client bundle**。于是 `ctx` 上出现 N 份服务实例,各自注册一个 `conversation.input.dock` 条目 —— chip 依然是 N 个。**纯库无法保证"全局唯一"** |
|
|
27
27
|
| 联动逻辑做成独立 bundle(本包) | 唯一实例由 DSH 的 Loader 保证;业务插件只 `inject` 服务 + `registerSource(...)` |
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
- `packages/`:**无状态、可被任意打包**的纯代码(`ui`、`data-contracts`、`convert-core`)。
|
|
32
|
-
- `plugins/`:需要在 `ctx` 上**提供唯一服务**、参与 profile 组合的**运行单元**。
|
|
29
|
+
因此本包是一个**独立 bundle**,而不是一个给各插件 `import` 的库:唯一实例由 DSH 的
|
|
30
|
+
Loader 保证,业务插件只 `inject` 服务 + `registerSource(...)`。
|
|
33
31
|
|
|
34
32
|
## 给业务插件用的 API
|
|
35
33
|
|
|
@@ -87,7 +85,7 @@ Host 半在 `agent/pre-step` 里做三件事,而且只在**带着新用户输
|
|
|
87
85
|
|
|
88
86
|
这不是自造 UI:卡片由 shell 自己渲染(shadcn/lucide 那套),本包只提供一行 summary。要注意的取舍是:
|
|
89
87
|
|
|
90
|
-
- `notice` **不允许再带 `sections`**(那是 `snapshot`
|
|
88
|
+
- `notice` **不允许再带 `sections`**(那是 `snapshot` 的字段),两者互斥。
|
|
91
89
|
- summary 是**持久化在消息里的显示文本**,因此和注入正文一样是中文、不随 locale 变。模型读到的东西完全没变 —— 换的只是这一行怎么显示。
|
|
92
90
|
|
|
93
91
|
## 路由
|
|
@@ -107,7 +105,7 @@ Host 半在 `agent/pre-step` 里做三件事,而且只在**带着新用户输
|
|
|
107
105
|
|
|
108
106
|
## 扩展一个新业务面
|
|
109
107
|
|
|
110
|
-
1.
|
|
108
|
+
1. 建一个带 `dsh.client` 的 bundle,`dsh.client.inject` 里加上 `dsh-surface-bridge`(Loader 会保证桥先注册)。
|
|
111
109
|
2. `inject: ['surfaceBridge', ...]`,`registerSource` 一个描述符,并在可见性变化时 `setVisible`。
|
|
112
110
|
3. 用户选择变化时把投影好的 `SurfaceSelection` `publish` 到注册表(本地)。
|
|
113
111
|
4. 自己的操作循环里把 `READ_SELECTION_OP` 答成 `[selection]` 或 `[]`。
|
|
@@ -122,11 +120,11 @@ chip、输入行占位、`agent/pre-step` 注入、图片附件化、长轮询
|
|
|
122
120
|
- **每个面各自轮询。** 多个面同时挂在一个会话上时,读取请求会被先轮询到的那个取走。当前只有一个面,此路径正确;接第二个面时应把轮询收归桥的客户端半边(按 `operation.source` 分派)。
|
|
123
121
|
- **回写不做持久队列。** 操作队列是同一个进程里两半之间的交接通道,画布关掉即失去意义;因此工具失败要响,而不是排队等。
|
|
124
122
|
|
|
125
|
-
##
|
|
123
|
+
## 源码
|
|
124
|
+
|
|
125
|
+
发布的包里带 `src/`。构建是两个 half 一起出:
|
|
126
126
|
|
|
127
127
|
```bash
|
|
128
128
|
pnpm build # tsc(Host 半 + 类型)+ esbuild(唯一 client.js)
|
|
129
|
-
pnpm test
|
|
129
|
+
pnpm test
|
|
130
130
|
```
|
|
131
|
-
|
|
132
|
-
`tests/context-augmentation.test.mjs` 里有一条**必须保留的守卫**:cordis 的 `Context` augmentation 只能写在包入口模块。放进 `contract.ts` 之类的共享类型模块时,同一段 `declare module` 会退化成*环境声明*并**替换**掉 cordis 的 `Context`,导致整张图里的 `ctx.effect` / `ctx.on` 全部不再类型检查。该用例会在它被"顺手整理"走时失败。
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-surface-bridge",
|
|
3
3
|
"description": "Shared seam between a right-Sidebar business surface and the DSH composer: one selection chip, one Host-context injection point, one write-back channel",
|
|
4
|
-
"version": "0.1.0-alpha.
|
|
4
|
+
"version": "0.1.0-alpha.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
package/src/client/service.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import type {
|
|
20
20
|
SurfaceBridgeService,
|
|
21
|
+
SurfaceFocusTarget,
|
|
21
22
|
SurfaceSelection,
|
|
22
23
|
SurfaceSourceDescriptor,
|
|
23
24
|
} from '../contract.ts'
|
|
@@ -30,6 +31,8 @@ interface SourceEntry {
|
|
|
30
31
|
selection: SurfaceSelection | null
|
|
31
32
|
/** Whether the surface is on screen for the current Session; see {@link SurfaceBridgeService.setVisible}. */
|
|
32
33
|
visible: boolean
|
|
34
|
+
/** A selection the transcript asked to see again, until the surface's view takes it. */
|
|
35
|
+
focus: SurfaceFocusTarget | null
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
/** Default implementation of the bridge's browser-side registry. */
|
|
@@ -40,7 +43,7 @@ export class SurfaceBridgeRegistry implements SurfaceBridgeService {
|
|
|
40
43
|
|
|
41
44
|
/** @inheritdoc */
|
|
42
45
|
registerSource(descriptor: SurfaceSourceDescriptor): () => void {
|
|
43
|
-
this.entries.set(descriptor.id, { descriptor, selection: null, visible: false })
|
|
46
|
+
this.entries.set(descriptor.id, { descriptor, selection: null, visible: false, focus: null })
|
|
44
47
|
this.bump()
|
|
45
48
|
return () => {
|
|
46
49
|
this.entries.delete(descriptor.id)
|
|
@@ -91,6 +94,27 @@ export class SurfaceBridgeRegistry implements SurfaceBridgeService {
|
|
|
91
94
|
})
|
|
92
95
|
}
|
|
93
96
|
|
|
97
|
+
/** @inheritdoc */
|
|
98
|
+
requestFocus(source: string, target: SurfaceFocusTarget): void {
|
|
99
|
+
const entry = this.entries.get(source)
|
|
100
|
+
if (entry === undefined) return
|
|
101
|
+
entry.focus = target
|
|
102
|
+
this.bump()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** @inheritdoc */
|
|
106
|
+
takeFocus(source: string, document?: string): SurfaceFocusTarget | null {
|
|
107
|
+
const entry = this.entries.get(source)
|
|
108
|
+
const focus = entry?.focus ?? null
|
|
109
|
+
if (entry === undefined || focus === null) return null
|
|
110
|
+
// A request for another document stays put: the panel that can honour it is the one
|
|
111
|
+
// that is a view of that document, and taking it here would lose it silently.
|
|
112
|
+
if (document !== undefined && focus.document !== undefined && focus.document !== document) return null
|
|
113
|
+
entry.focus = null
|
|
114
|
+
this.bump()
|
|
115
|
+
return focus
|
|
116
|
+
}
|
|
117
|
+
|
|
94
118
|
/** @inheritdoc */
|
|
95
119
|
subscribe(listener: () => void): () => void {
|
|
96
120
|
this.listeners.add(listener)
|
package/src/contract.ts
CHANGED
|
@@ -252,6 +252,22 @@ export interface SurfaceSourceDescriptor {
|
|
|
252
252
|
readonly focusElement?: (elementId: string) => void
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
/**
|
|
256
|
+
* One selection a person asked to see again.
|
|
257
|
+
*
|
|
258
|
+
* A message keeps the selection that rode with it, so the line in the transcript stays
|
|
259
|
+
* clickable long after the composer chip is gone. The transcript cannot select anything
|
|
260
|
+
* itself — that row belongs to the shell, and the one action its renderer offers is
|
|
261
|
+
* "open this file" — so the click lands here and the bridge hands the request to the
|
|
262
|
+
* surface's own view, which is the only part that knows how to select and scroll.
|
|
263
|
+
*/
|
|
264
|
+
export interface SurfaceFocusTarget {
|
|
265
|
+
/** Document the selection came from, when the surface is a view of a file. */
|
|
266
|
+
readonly document?: string
|
|
267
|
+
/** Elements to select and bring into view, in selection order. */
|
|
268
|
+
readonly elementIds: readonly string[]
|
|
269
|
+
}
|
|
270
|
+
|
|
255
271
|
/**
|
|
256
272
|
* The `ctx.surfaceBridge` face a business surface publishes into.
|
|
257
273
|
*
|
|
@@ -279,6 +295,25 @@ export interface SurfaceBridgeService {
|
|
|
279
295
|
read(source: string): SurfaceSelection | null
|
|
280
296
|
/** Read every source that currently has a selection, in chip order. */
|
|
281
297
|
active(): readonly { descriptor: SurfaceSourceDescriptor; selection: SurfaceSelection }[]
|
|
298
|
+
/**
|
|
299
|
+
* Ask a surface's own view to show a selection again.
|
|
300
|
+
*
|
|
301
|
+
* Held until the view takes it: the request arrives from a click in the transcript,
|
|
302
|
+
* and the panel that answers it may not be mounted yet (the click itself is what
|
|
303
|
+
* opens it) or may still be loading the document. One pending request per source —
|
|
304
|
+
* a second click replaces the first, which is what a person means by clicking again.
|
|
305
|
+
*/
|
|
306
|
+
requestFocus(source: string, target: SurfaceFocusTarget): void
|
|
307
|
+
/**
|
|
308
|
+
* Take the pending request for one source, clearing it.
|
|
309
|
+
*
|
|
310
|
+
* @param source - Surface whose request is taken.
|
|
311
|
+
* @param document - Document the caller is a view of. A request naming another
|
|
312
|
+
* document is left in place for the view that can honour it, so two open panels
|
|
313
|
+
* do not steal each other's request.
|
|
314
|
+
* @returns the request, or `null` when there is none for this caller.
|
|
315
|
+
*/
|
|
316
|
+
takeFocus(source: string, document?: string): SurfaceFocusTarget | null
|
|
282
317
|
/** Subscribe to any change (selection, sync state, roster). */
|
|
283
318
|
subscribe(listener: () => void): () => void
|
|
284
319
|
/** Snapshot version, for `useSyncExternalStore`. */
|
package/src/host/render.ts
CHANGED
|
@@ -73,108 +73,50 @@ export function renderElementRow(element: SurfaceElement): string {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
/** How many element names the visible summary line carries before it degrades to `等`. */
|
|
76
|
-
export const MAX_CHIP_NAMES = 3
|
|
77
|
-
|
|
78
|
-
/** How long one element name may be on the visible summary line. */
|
|
79
|
-
const MAX_CHIP_NAME_CHARS = 18
|
|
80
|
-
|
|
81
76
|
/**
|
|
82
|
-
*
|
|
77
|
+
* Render one document name as a reference the shell knows how to decorate.
|
|
83
78
|
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* the
|
|
88
|
-
*
|
|
89
|
-
*/
|
|
90
|
-
export const MAX_CHIP_LINE_UNITS = 56
|
|
91
|
-
|
|
92
|
-
/** Approximate width of one string in half-width units: CJK counts double. */
|
|
93
|
-
function widthUnits(text: string): number {
|
|
94
|
-
let total = 0
|
|
95
|
-
for (const char of text) total += (char.codePointAt(0) ?? 0) > 0x2e80 ? 2 : 1
|
|
96
|
-
return total
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** Width budget for the document name on the visible line, in half-width units. */
|
|
100
|
-
export const MAX_CHIP_FILE_UNITS = 24
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Shorten a document name for the visible line, keeping its extension.
|
|
79
|
+
* The transcript's own renderer projects a sent user message and turns an `@name` token into
|
|
80
|
+
* a blue, clickable reference chip — the only inline, clickable thing it draws inside a
|
|
81
|
+
* person's own bubble, and it comes with an icon and an "open this file" action for free.
|
|
82
|
+
* Writing the document that way is therefore what makes the selection part of the message
|
|
83
|
+
* the person actually sent, on its own line, instead of a second row under it.
|
|
104
84
|
*
|
|
105
|
-
* A
|
|
106
|
-
*
|
|
107
|
-
* the part that says what kind of thing this is; the full name is one row down, in the detail.
|
|
85
|
+
* A name with whitespace has to be quoted: the shell's token rule ends an unquoted mention
|
|
86
|
+
* at the first space, and scene names really do contain spaces (`画布 2.excalidraw`).
|
|
108
87
|
*
|
|
109
|
-
* @param name - Document name.
|
|
110
|
-
* @returns the
|
|
88
|
+
* @param name - Document name, e.g. `main.excalidraw`.
|
|
89
|
+
* @returns the mention, e.g. `@main.excalidraw` or `@"画布 2.excalidraw"`.
|
|
111
90
|
*/
|
|
112
|
-
function
|
|
113
|
-
|
|
114
|
-
const dot = name.lastIndexOf('.')
|
|
115
|
-
const extension = dot > 0 ? name.slice(dot) : ''
|
|
116
|
-
const stem = dot > 0 ? name.slice(0, dot) : name
|
|
117
|
-
const room = MAX_CHIP_FILE_UNITS - widthUnits(extension) - 1
|
|
118
|
-
if (room <= 0) return `${name.slice(0, MAX_CHIP_FILE_UNITS - 1)}…`
|
|
119
|
-
let kept = ''
|
|
120
|
-
for (const char of stem) {
|
|
121
|
-
if (widthUnits(kept + char) > room) break
|
|
122
|
-
kept += char
|
|
123
|
-
}
|
|
124
|
-
// A separator left dangling before the ellipsis reads as a typo: `a-very-long-…` rather than
|
|
125
|
-
// `a-very-long…`.
|
|
126
|
-
kept = kept.replace(/[-_ .]+$/, '')
|
|
127
|
-
return `${kept}…${extension}`
|
|
91
|
+
export function mentionOf(name: string): string {
|
|
92
|
+
return /[\s"]/.test(name) ? `@"${name}"` : `@${name}`
|
|
128
93
|
}
|
|
129
94
|
|
|
130
95
|
/**
|
|
131
|
-
* Render the
|
|
96
|
+
* Render the inline summary appended to the person's own message.
|
|
132
97
|
*
|
|
133
98
|
* The element table stays in the hidden detail row, because a bubble of coordinates is not
|
|
134
99
|
* something a person wants in their conversation. What they do want is to recognise what they
|
|
135
|
-
* sent
|
|
100
|
+
* sent — which document, how many elements — and to be able to get back to it.
|
|
136
101
|
*
|
|
137
|
-
* **
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
* just made) therefore reads exactly `画布选区 · main.excalidraw · 1 个元素`.
|
|
102
|
+
* **No line of its own, by construction.** The text is a suffix, and its leading space is part
|
|
103
|
+
* of the form: the shell's mention rule requires whitespace (or the start of the text) before
|
|
104
|
+
* `@`, so a suffix glued to the end of a sentence would render as plain text.
|
|
141
105
|
*
|
|
142
106
|
* @param selection - Selection to summarise.
|
|
143
|
-
* @returns
|
|
107
|
+
* @returns the suffix, e.g. ` @main.excalidraw · 2 个元素`.
|
|
144
108
|
*/
|
|
145
|
-
export function
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const head = parts.join(' · ')
|
|
150
|
-
const names = (selection.elements ?? [])
|
|
151
|
-
.map(element => element.text ?? element.label)
|
|
152
|
-
.filter((name): name is string => typeof name === 'string' && name.length > 0)
|
|
153
|
-
.slice(0, MAX_CHIP_NAMES)
|
|
154
|
-
.map(name => (name.length > MAX_CHIP_NAME_CHARS ? `${name.slice(0, MAX_CHIP_NAME_CHARS)}…` : name))
|
|
155
|
-
if (names.length === 0) return head
|
|
156
|
-
|
|
157
|
-
// Greedy fit: add names while the whole line stays inside the budget. `等` is charged up
|
|
158
|
-
// front when there are more names than the list holds, so the suffix never overflows either.
|
|
159
|
-
const room = MAX_CHIP_LINE_UNITS - widthUnits(head) - 2
|
|
160
|
-
const fits: string[] = []
|
|
161
|
-
let used = 0
|
|
162
|
-
for (const name of names) {
|
|
163
|
-
const cost = widthUnits(name) + (fits.length === 0 ? 0 : 2)
|
|
164
|
-
if (used + cost > room) break
|
|
165
|
-
fits.push(name)
|
|
166
|
-
used += cost
|
|
167
|
-
}
|
|
168
|
-
if (fits.length === 0) return head
|
|
169
|
-
const suffix = selection.count > fits.length ? ' 等' : ''
|
|
170
|
-
return `${head}:${fits.join('、')}${suffix}`
|
|
109
|
+
export function renderSelectionInline(selection: SurfaceSelection): string {
|
|
110
|
+
const name = selection.resource?.name
|
|
111
|
+
const head = name === undefined || name.length === 0 ? `${selection.title}选区` : mentionOf(name)
|
|
112
|
+
return ` ${head} · ${selection.count} 个元素`
|
|
171
113
|
}
|
|
172
114
|
|
|
173
115
|
/**
|
|
174
116
|
* Render one selection as the text that enters the model step.
|
|
175
117
|
*
|
|
176
118
|
* This is the hidden detail row: every actionable fact, and nothing written for a person to
|
|
177
|
-
* read — the
|
|
119
|
+
* read — the person's line is {@link renderSelectionInline}. The two are deliberately different
|
|
178
120
|
* texts rather than one text shown twice.
|
|
179
121
|
*
|
|
180
122
|
* @param selection - Selection to render.
|