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 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
- 因此本仓库对 `packages/` 与 `plugins/` 的分工是明确的:
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` 的字段),两者互斥;`tests/context-augmentation.test.mjs` 钉住了这条,避免有人顺手加回去。
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. 建一个 `plugins/dsh-data-*` bundle,`dsh.client.inject` 里加上 `dsh-surface-bridge`(Loader 会保证桥先注册)。
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 # lib/types 上的 Node 规格:状态机、渲染、边界校验、打包形状守卫
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.1",
4
+ "version": "0.1.0-alpha.2",
5
5
  "type": "module",
6
6
  "publishConfig": {
7
7
  "access": "public"
@@ -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`. */
@@ -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
- * Width budget for the visible line, in half-width units.
77
+ * Render one document name as a reference the shell knows how to decorate.
83
78
  *
84
- * A user bubble is about 493px of usable width at the shell's 14px content font (max-width is
85
- * `min(748px * .702, 82%)` minus 16px of padding each side), and a CJK glyph there is roughly
86
- * 7px per unit of this measure — so ~70 units fit. The budget is set below that on purpose:
87
- * the estimate is a proxy, and the failure mode it buys insurance against is the line wrapping
88
- * to a second row, which is exactly what the request was about.
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 40-character file name would fill the line on its own and push the count onto a second row
106
- * — and the count is the part the reader is checking. The extension stays visible because it is
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 name, or a shortened form ending in `…<extension>`.
88
+ * @param name - Document name, e.g. `main.excalidraw`.
89
+ * @returns the mention, e.g. `@main.excalidraw` or `@"画布 2.excalidraw"`.
111
90
  */
112
- function shortFileName(name: string): string {
113
- if (widthUnits(name) <= MAX_CHIP_FILE_UNITS) return name
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 one line the person reads in the transcript.
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: the document, how many elements, and — when they fit — the first few names.
100
+ * sent — which document, how many elements — and to be able to get back to it.
136
101
  *
137
- * **One line, by construction.** The line carries the document and the count, then as many
138
- * names as the width budget allows; a name that would push it onto a second row is dropped
139
- * rather than wrapped. A selection of unlabelled shapes (the common case for a drawing someone
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 one line, e.g. `画布选区 · main.excalidraw · 2 个元素:下单、风控`.
107
+ * @returns the suffix, e.g. ` @main.excalidraw · 2 个元素`.
144
108
  */
145
- export function renderSelectionChip(selection: SurfaceSelection): string {
146
- const parts = [`${selection.title}选区`]
147
- if (selection.resource?.name !== undefined) parts.push(shortFileName(selection.resource.name))
148
- parts.push(`${selection.count} 个元素`)
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 visible line is {@link renderSelectionChip}. The two are deliberately different
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.