dsh-surface-bridge 0.1.0-alpha.1

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 (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +132 -0
  3. package/cordis.patch.yml +9 -0
  4. package/lib/client.js +310 -0
  5. package/lib/types/client/SurfaceSelectionDock.d.ts +37 -0
  6. package/lib/types/client/SurfaceSelectionDock.js +125 -0
  7. package/lib/types/client/index.d.ts +40 -0
  8. package/lib/types/client/index.js +41 -0
  9. package/lib/types/client/locales.d.ts +30 -0
  10. package/lib/types/client/locales.js +36 -0
  11. package/lib/types/client/service.d.ts +46 -0
  12. package/lib/types/client/service.js +89 -0
  13. package/lib/types/client/transport.d.ts +19 -0
  14. package/lib/types/client/transport.js +38 -0
  15. package/lib/types/contract.d.ts +329 -0
  16. package/lib/types/contract.js +38 -0
  17. package/lib/types/host/narrow.d.ts +25 -0
  18. package/lib/types/host/narrow.js +193 -0
  19. package/lib/types/host/render.d.ts +63 -0
  20. package/lib/types/host/render.js +228 -0
  21. package/lib/types/host/routes.d.ts +31 -0
  22. package/lib/types/host/routes.js +108 -0
  23. package/lib/types/host/service.d.ts +41 -0
  24. package/lib/types/host/service.js +93 -0
  25. package/lib/types/host/store.d.ts +85 -0
  26. package/lib/types/host/store.js +206 -0
  27. package/lib/types/index.d.ts +93 -0
  28. package/lib/types/index.js +132 -0
  29. package/package.json +88 -0
  30. package/src/client/SurfaceSelectionDock.module.css +186 -0
  31. package/src/client/SurfaceSelectionDock.tsx +245 -0
  32. package/src/client/index.ts +65 -0
  33. package/src/client/locales.ts +42 -0
  34. package/src/client/service.ts +110 -0
  35. package/src/client/transport.ts +39 -0
  36. package/src/contract.ts +351 -0
  37. package/src/css-modules.d.ts +10 -0
  38. package/src/host/narrow.ts +180 -0
  39. package/src/host/render.ts +226 -0
  40. package/src/host/routes.ts +117 -0
  41. package/src/host/service.ts +116 -0
  42. package/src/host/store.ts +236 -0
  43. package/src/index.ts +194 -0
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Node half of dsh-surface-bridge.
3
+ *
4
+ * The bridge exists so a right-Sidebar business surface can hand a selection to
5
+ * the composer once, and have it arrive in the model step as real context. This
6
+ * half owns the two things that must be unique per process: the Session-keyed
7
+ * selection/operation state, and the `agent/pre-step` listener that turns a read
8
+ * selection into **two** durable messages — one visible row the person can see (a
9
+ * file chip for the drawing, plus a one-line summary) and one hidden row carrying
10
+ * the element table the model works from.
11
+ *
12
+ * Why a mounted bundle rather than a shared library: a library would be inlined
13
+ * into each consuming plugin's own bundle, giving every consumer its own service
14
+ * instance and its own composer chip. The uniqueness the seam needs — one chip,
15
+ * one injection point — can only be guaranteed by the Loader mounting one bundle.
16
+ *
17
+ * @module dsh-surface-bridge
18
+ */
19
+ import { createUserMessage } from '@deepseek-ai/dsh-llm';
20
+ import { renderSelectionChip, renderSelectionText } from "./host/render.js";
21
+ import { registerSurfaceBridgeRoutes } from "./host/routes.js";
22
+ import { SurfaceBridgeHost } from "./host/service.js";
23
+ import { SurfaceBridgeStore } from "./host/store.js";
24
+ /** Cordis plugin name used by loader diagnostics. */
25
+ export const name = 'dsh-surface-bridge';
26
+ /** Route registration and image admission are the only Host capabilities this half needs. */
27
+ export const inject = ['connection', 'attachments'];
28
+ /** Decode a raster into the bytes the attachment service admits. */
29
+ function decodeRaster(data) {
30
+ return new Uint8Array(Buffer.from(data, 'base64'));
31
+ }
32
+ /**
33
+ * Build the two durable messages one selection travels in.
34
+ *
35
+ * **Two messages, one visible.** The person who sent a selection must be able to see what
36
+ * went out; the model needs the whole element table. Those are different appetites, and one
37
+ * message cannot satisfy both:
38
+ *
39
+ * · the **visible** row is a plain user message carrying one line — `画布选区 · main.excalidraw ·
40
+ * 3 个元素` — because the Chat view renders no row at all for a producer-tagged context node
41
+ * (`isVisibleChatNode` excludes ordinary Context, keeping only tool changes). Before this,
42
+ * the selection reached the model and appeared nowhere in the transcript.
43
+ * · the **detail** row is producer-tagged, and therefore hidden: it carries the element ids,
44
+ * coordinates and sizes the model edits from, plus the file path the write-back must name.
45
+ *
46
+ * Images ride in the visible row, so a person sees the pictures they sent. Bytes never enter
47
+ * the text, and a surface with no image element pays for no image.
48
+ *
49
+ * @param ctx - Plugin context carrying the attachment service.
50
+ * @param selection - Selection to render.
51
+ * @returns the messages to append to the step, visible row first.
52
+ */
53
+ export async function buildSelectionMessages(ctx, selection) {
54
+ const reference = `${selection.source}#${selection.revision}`;
55
+ const detail = createUserMessage({
56
+ content: [{ type: 'text', text: renderSelectionText(selection, reference) }],
57
+ // A producer kind, on purpose: this is the row the transcript hides.
58
+ source: { kind: 'surface-selection' },
59
+ });
60
+ // One line, and nothing above it. An earlier version put a `file` block first, which the
61
+ // shell renders as a 240x64 card — two stacked blocks for one selection, and the card was a
62
+ // *snapshot* of the drawing that the model had to be told not to edit. The line alone says
63
+ // which drawing and how many elements, which is what the reader needs.
64
+ const visible = [];
65
+ const images = selection.images ?? [];
66
+ if (images.length > 0) {
67
+ const refs = await ctx.attachments.saveImages(images.map((image, index) => ({
68
+ data: decodeRaster(image.data),
69
+ mediaType: image.mediaType,
70
+ name: image.name ?? `${selection.source}-${image.elementId ?? `image-${String(index + 1)}`}.png`,
71
+ })));
72
+ for (const ref of refs)
73
+ visible.push({ type: 'image', attachment: ref });
74
+ }
75
+ visible.push({ type: 'text', text: renderSelectionChip(selection) });
76
+ const shown = createUserMessage({
77
+ content: visible,
78
+ // `user`, so the transcript shows it. Any other kind makes the row a `context` node,
79
+ // and the Chat view renders no row for those.
80
+ source: { kind: 'user' },
81
+ });
82
+ return [shown, detail];
83
+ }
84
+ /**
85
+ * Plugin body: provide the Host face, serve the bridge routes, and arm the
86
+ * pre-step injection.
87
+ *
88
+ * @param ctx - Registrant context; every registration is disposed with it.
89
+ */
90
+ /**
91
+ * Last turn that read each Session's surface.
92
+ *
93
+ * A turn has several steps (a model call, then the tool results, then another). The
94
+ * selection is a property of the message that opened the turn, so it is read once
95
+ * per turn; without this the context would be re-injected on every step and the
96
+ * model would see the same drawing three times.
97
+ */
98
+ const lastReadTurn = new Map();
99
+ export function apply(ctx) {
100
+ const store = new SurfaceBridgeStore();
101
+ const host = new SurfaceBridgeHost(store);
102
+ ctx.effect(() => ctx.reflect.provide('surfaceBridgeHost', host), 'dsh-surface-bridge: host face');
103
+ registerSurfaceBridgeRoutes(ctx, store);
104
+ ctx.on('agent/pre-step', async ({ agent, signal, turn, step }, next) => {
105
+ const decision = await next();
106
+ if (decision.kind === 'reject' || signal.aborted)
107
+ return decision;
108
+ // A selection belongs to the message that opened the turn, and that message is
109
+ // admitted at step 1. Gating on `step` rather than on the admitted message batch
110
+ // is deliberate: an empty batch at step 1 is a real case (a turn opened by
111
+ // something other than a typed prompt), and the batch is not a reliable signal
112
+ // for "the user just sent this". The whole point is also that the selection is
113
+ // read *here* — when the message is going out — not pushed while the user drew.
114
+ if (step !== 1)
115
+ return decision;
116
+ const sessionId = String(agent.id);
117
+ if (lastReadTurn.get(sessionId) === turn)
118
+ return decision;
119
+ // Consuming: this read exists to carry the selection into the message, so the
120
+ // surface drops it as it answers and the chip leaves the composer. A peek would
121
+ // leave it on screen and let the same drawing ride the next message too.
122
+ const selections = await host.consumeSelections(sessionId, signal);
123
+ if (selections === undefined || selections.length === 0)
124
+ return decision;
125
+ lastReadTurn.set(sessionId, turn);
126
+ const messages = [...decision.messages];
127
+ for (const selection of selections) {
128
+ messages.push(...await buildSelectionMessages(ctx, selection));
129
+ }
130
+ return { ...decision, messages };
131
+ }, { prepend: true });
132
+ }
package/package.json ADDED
@@ -0,0 +1,88 @@
1
+ {
2
+ "name": "dsh-surface-bridge",
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",
5
+ "type": "module",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "main": "lib/types/index.js",
10
+ "types": "lib/types/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./lib/types/index.d.ts",
14
+ "default": "./lib/types/index.js"
15
+ },
16
+ "./contract": {
17
+ "types": "./lib/types/contract.d.ts",
18
+ "default": "./lib/types/contract.js"
19
+ },
20
+ "./transport": {
21
+ "types": "./lib/types/client/transport.d.ts",
22
+ "default": "./lib/types/client/transport.js"
23
+ },
24
+ "./client": "./lib/client.js",
25
+ "./cordis.patch.yml": "./cordis.patch.yml",
26
+ "./package.json": "./package.json"
27
+ },
28
+ "files": [
29
+ "lib",
30
+ "src",
31
+ "cordis.patch.yml"
32
+ ],
33
+ "dsh": {
34
+ "bundle": {
35
+ "patch": "./cordis.patch.yml"
36
+ },
37
+ "client": {
38
+ "platform": "web",
39
+ "inject": [
40
+ "@deepseek-ai/dsh-client-locale",
41
+ "@deepseek-ai/dsh-client-ui-conversation",
42
+ "@deepseek-ai/dsh-client-ui-primitives",
43
+ "@deepseek-ai/dsh-client-ui-renderer",
44
+ "@deepseek-ai/dsh-client-ui-session",
45
+ "@deepseek-ai/dsh-client-ui-slots"
46
+ ]
47
+ }
48
+ },
49
+ "peerDependencies": {
50
+ "@deepseek-ai/cordis": "~4.0.4",
51
+ "@deepseek-ai/dsh-agent": "^0.1.7-rc.1",
52
+ "@deepseek-ai/dsh-attachment": "^0.1.7-rc.1",
53
+ "@deepseek-ai/dsh-client-connection": "^0.1.7-rc.1",
54
+ "@deepseek-ai/dsh-client-locale": "^0.1.7-rc.1",
55
+ "@deepseek-ai/dsh-client-ui-conversation": "^0.1.7-rc.1",
56
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.7-rc.1",
57
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.1.7-rc.1",
58
+ "@deepseek-ai/dsh-client-ui-session": "^0.1.7-rc.1",
59
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.7-rc.1",
60
+ "@deepseek-ai/dsh-llm": "^0.1.7-rc.1"
61
+ },
62
+ "devDependencies": {
63
+ "@deepseek-ai/cordis": "~4.0.4",
64
+ "@deepseek-ai/dsh-agent": "0.1.7-rc.1",
65
+ "@deepseek-ai/dsh-attachment": "0.1.7-rc.1",
66
+ "@deepseek-ai/dsh-client-connection": "0.1.7-rc.1",
67
+ "@deepseek-ai/dsh-client-locale": "0.1.7-rc.1",
68
+ "@deepseek-ai/dsh-client-ui-conversation": "0.1.7-rc.1",
69
+ "@deepseek-ai/dsh-client-ui-primitives": "0.1.7-rc.1",
70
+ "@deepseek-ai/dsh-client-ui-renderer": "0.1.7-rc.1",
71
+ "@deepseek-ai/dsh-client-ui-session": "0.1.7-rc.1",
72
+ "@deepseek-ai/dsh-client-ui-slots": "0.1.7-rc.1",
73
+ "@deepseek-ai/dsh-llm": "0.1.7-rc.1",
74
+ "@types/node": "^26.0.0",
75
+ "@types/react": "~18.3.1",
76
+ "esbuild": "^0.25.0",
77
+ "lightningcss": "^1.28.0",
78
+ "react": "^18.2.0",
79
+ "react-dom": "^18.2.0",
80
+ "typescript": "^5.9.0"
81
+ },
82
+ "license": "MIT",
83
+ "scripts": {
84
+ "build": "rm -rf lib && tsc -p tsconfig.json && node build-client.mjs",
85
+ "test": "pnpm build && node --test tests/*.test.mjs",
86
+ "typecheck": "tsc -p tsconfig.json --noEmit"
87
+ }
88
+ }
@@ -0,0 +1,186 @@
1
+ /* The selection chip: a capsule on the composer's input line.
2
+ *
3
+ * The shell's own metrics decide every number here, all read from its InputBar CSS
4
+ * module:
5
+ *
6
+ * .card padding-top:8px, border-radius:22px, column flex, gap:12px
7
+ * .input padding:4px 8px 0 14px, min-height:36px, line-height:24px
8
+ * .overlayAnchor height:0; position:absolute; inset:0 0 auto
9
+ * [data-input-scroll] wraps the editable
10
+ * [data-composer-placeholder] inset:4px 8px auto 14px
11
+ *
12
+ * So the first line box is 12px..36px inside the card and the text starts at 14px.
13
+ * The capsule is 22px tall, which fits inside that 24px line — which is the whole
14
+ * point: it sits *on* the line rather than in a band reserved above it, so the
15
+ * composer's height does not change when a selection appears or disappears.
16
+ *
17
+ * Two consequences worth stating, because both are deliberate:
18
+ *
19
+ * 1. The text has to start after the capsule, and the only element that can move it
20
+ * is the shell's own scroll container. `--surface-bridge-indent` is set on the
21
+ * card from the capsule's measured width (see the component) — a measured value
22
+ * rather than a constant, because the label and the count are both localized.
23
+ * 2. Nothing is pushed down and nothing is pushed sideways except that one indent:
24
+ * the tool row below the editor is untouched, so the input box keeps its shape.
25
+ * Pressing Enter starts a new block, whose first line indents too — the one case
26
+ * where a hanging indent cannot imitate an inline token, and a fair trade for not
27
+ * having to put the capsule inside the editor's own document.
28
+ */
29
+
30
+ /* Push the line's first line right, out from under the capsule — `text-indent`, not
31
+ * `padding-left`. The difference is the whole point: padding would move every wrapped
32
+ * line, so the paragraph would look like a narrow column beside the chip. An indent
33
+ * applies to a block's FIRST line only, so the text starts after the capsule and then
34
+ * wraps back to the card's left margin, exactly like an inline token in the editor
35
+ * (DSH's own file mentions behave this way).
36
+ *
37
+ * `text-indent` is inherited, so one declaration on this container reaches both the
38
+ * editable and the placeholder, which the shell positions inside the same box. */
39
+ [data-composer-card]:has([data-surface-chip]) [data-input-scroll] {
40
+ text-indent: var(--surface-bridge-indent, 0px);
41
+ }
42
+
43
+ .anchor {
44
+ position: absolute;
45
+ /* The editor is a later sibling and would otherwise paint over the capsule, so clicks on the
46
+ * clear button landed in the text. The chip is chrome over the input line, not under it. */
47
+ z-index: 2;
48
+ /* Line 1 spans 12px..36px; centre the 22px capsule in it. */
49
+ top: 13px;
50
+ left: 14px;
51
+ display: flex;
52
+ align-items: center;
53
+ gap: 6px;
54
+ width: max-content;
55
+ max-width: calc(100% - 26px);
56
+ /* The anchor overlays the editor's first line, so only the capsule itself may take
57
+ * input; otherwise it would swallow clicks and caret placement in the text. */
58
+ pointer-events: none;
59
+ }
60
+
61
+ .chip {
62
+ display: inline-flex;
63
+ align-items: center;
64
+ gap: 6px;
65
+ max-width: 100%;
66
+ min-width: 0;
67
+ height: 22px;
68
+ padding: 0 8px 0 7px;
69
+ border: 0.5px solid var(--dsw-alias-border-l2);
70
+ border-radius: 999px;
71
+ background: var(--dsw-alias-interactive-bg-hover);
72
+ color: var(--dsw-alias-label-secondary);
73
+ font-family: inherit;
74
+ font-size: 12px;
75
+ line-height: 16px;
76
+ white-space: nowrap;
77
+ cursor: pointer;
78
+ pointer-events: auto;
79
+ }
80
+
81
+ .chip:hover {
82
+ border-color: var(--dsw-alias-border-l4);
83
+ color: var(--dsw-alias-label-primary);
84
+ }
85
+
86
+ .chip:focus-visible {
87
+ outline: 2px solid var(--dsw-alias-state-business-primary);
88
+ outline-offset: 1px;
89
+ }
90
+
91
+ .glyph {
92
+ display: inline-flex;
93
+ flex: none;
94
+ color: currentcolor;
95
+ opacity: 0.75;
96
+ }
97
+
98
+ .label {
99
+ flex: none;
100
+ font-weight: 500;
101
+ }
102
+
103
+ .dot {
104
+ flex: none;
105
+ color: var(--dsw-alias-label-caption);
106
+ }
107
+
108
+ .count {
109
+ min-width: 0;
110
+ overflow: hidden;
111
+ color: var(--dsw-alias-state-business-primary);
112
+ font-variant-numeric: tabular-nums;
113
+ text-overflow: ellipsis;
114
+ }
115
+
116
+ .chevron {
117
+ display: inline-flex;
118
+ flex: none;
119
+ color: var(--dsw-alias-label-caption);
120
+ }
121
+
122
+ .clear {
123
+ display: inline-flex;
124
+ flex: none;
125
+ align-items: center;
126
+ justify-content: center;
127
+ width: 18px;
128
+ height: 18px;
129
+ margin-left: -3px;
130
+ padding: 0;
131
+ border: 0;
132
+ border-radius: 999px;
133
+ background: none;
134
+ color: var(--dsw-alias-label-caption);
135
+ cursor: pointer;
136
+ pointer-events: auto;
137
+ }
138
+
139
+ .clear:hover {
140
+ background: var(--dsw-alias-interactive-bg-hover);
141
+ color: var(--dsw-alias-label-primary);
142
+ }
143
+
144
+ .clear:focus-visible {
145
+ outline: 2px solid var(--dsw-alias-state-business-primary);
146
+ outline-offset: 1px;
147
+ }
148
+
149
+ /* Summary rows inside the anchored menu. The shell's menu card draws the frame;
150
+ * these rules add only what an element row needs on top of it. */
151
+ .row {
152
+ display: flex;
153
+ align-items: baseline;
154
+ gap: 8px;
155
+ min-width: 0;
156
+ }
157
+
158
+ .rowType {
159
+ flex: none;
160
+ color: var(--dsw-alias-label-caption);
161
+ font-size: 11px;
162
+ line-height: 16px;
163
+ }
164
+
165
+ .rowLabel {
166
+ min-width: 0;
167
+ overflow: hidden;
168
+ color: var(--dsw-alias-label-primary);
169
+ text-overflow: ellipsis;
170
+ white-space: nowrap;
171
+ }
172
+
173
+ .rowGeom {
174
+ flex: none;
175
+ margin-left: auto;
176
+ color: var(--dsw-alias-label-caption);
177
+ font-family: var(--font-tita-mono);
178
+ font-size: 11px;
179
+ line-height: 16px;
180
+ }
181
+
182
+ .more {
183
+ color: var(--dsw-alias-label-caption);
184
+ font-size: 11px;
185
+ line-height: 16px;
186
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * The one composer chip for every surface selection.
3
+ *
4
+ * Exactly one entry is registered into `conversation.input.overlay`, and it renders
5
+ * one capsule per source that currently has a selection. That is the whole reason the
6
+ * bridge is a mounted bundle: with the registry inlined per consumer, two surfaces
7
+ * would each register their own entry and the composer would grow a second chip with
8
+ * a different shape.
9
+ *
10
+ * Why the overlay seat and not the dock seat it used to occupy: the dock renders
11
+ * *above* the composer card, in the same stack as the to-do dock — so anything there
12
+ * shares the task list's form and reflows the whole input every time it appears. The
13
+ * overlay seat is inside the card, on the shell's own top-edge anchor, which is where
14
+ * the stylesheet puts the capsule on the input line itself.
15
+ *
16
+ * The chip is a pure view of local state. Nothing here talks to the Host: the
17
+ * selection is read from this registry when a model step asks for it, which is why
18
+ * the chip has no syncing, pending, or failed states to twitch between.
19
+ *
20
+ * @module dsh-surface-bridge/client/SurfaceSelectionDock
21
+ */
22
+
23
+ import type { ReactNode } from 'react'
24
+ import { useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react'
25
+ import {
26
+ IconChevronDownOutlineRegular,
27
+ IconCloseOutlineRegular,
28
+ IconEditOutlineRegular,
29
+ IconFullscreenOutlineRegular,
30
+ Menu,
31
+ type MenuEntry,
32
+ } from '@deepseek-ai/dsh-client-ui-primitives'
33
+ import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
34
+ import type { SurfaceSelection } from '../contract.ts'
35
+ import type { SurfaceBridgeService, SurfaceSourceDescriptor } from './service.ts'
36
+ import { NS } from './locales.ts'
37
+ import styles from './SurfaceSelectionDock.module.css'
38
+
39
+ /** Injected face of the chip entry. */
40
+ export interface SurfaceDockInjected {
41
+ readonly bridge: SurfaceBridgeService
42
+ }
43
+
44
+ /**
45
+ * The translate seat this entry is registered with.
46
+ *
47
+ * Taken from the registration's own locale declaration rather than re-typed, so a key
48
+ * that does not exist in the dictionary is a compile error here.
49
+ */
50
+ type ChipTranslate = PropsLocale<typeof NS>['t']
51
+
52
+ /** Maximum element rows the summary menu lists. */
53
+ const MAX_SUMMARY_ROWS = 24
54
+
55
+ /** Menu row id prefixes, so one `onSelect` can tell element rows from actions. */
56
+ const ELEMENT_PREFIX = 'element:'
57
+ const ACTION_REVEAL = 'action:reveal'
58
+ const ACTION_CLEAR = 'action:clear'
59
+
60
+ /** One element's row label: kind, name, and size in a single scannable line. */
61
+ function elementRowLabel(selection: SurfaceSelection, index: number): ReactNode {
62
+ const element = selection.elements[index]
63
+ if (element === undefined) return null
64
+ return (
65
+ <span className={styles.row}>
66
+ <span className={styles.rowType}>{element.type}</span>
67
+ <span className={styles.rowLabel}>{element.label.length > 0 ? element.label : element.id}</span>
68
+ <span className={styles.rowGeom}>{`${Math.round(element.width)}×${Math.round(element.height)}`}</span>
69
+ </span>
70
+ )
71
+ }
72
+
73
+ /** One capsule: a single source's selection and its summary menu. */
74
+ function SurfaceChip({
75
+ bridge,
76
+ descriptor,
77
+ selection,
78
+ t,
79
+ }: {
80
+ bridge: SurfaceBridgeService
81
+ descriptor: SurfaceSourceDescriptor
82
+ selection: SurfaceSelection
83
+ t: ChipTranslate
84
+ }): ReactNode {
85
+ const [open, setOpen] = useState(false)
86
+ const Glyph = descriptor.icon ?? IconEditOutlineRegular
87
+
88
+ const shown = selection.elements.slice(0, MAX_SUMMARY_ROWS)
89
+ const rows: MenuEntry[] = [
90
+ { id: 'note:title', label: <span className={styles.more}>{String(t('menu.title', { count: selection.count }))}</span>, disabled: true },
91
+ ]
92
+ for (const [index] of shown.entries()) {
93
+ const element = selection.elements[index]
94
+ if (element === undefined) continue
95
+ rows.push({ id: `${ELEMENT_PREFIX}${element.id}`, label: elementRowLabel(selection, index) })
96
+ }
97
+ if (shown.length === 0) {
98
+ rows.push({ id: 'element:none', label: <span className={styles.more}>{String(t('menu.empty'))}</span>, disabled: true })
99
+ } else if (selection.elements.length > shown.length) {
100
+ rows.push({
101
+ id: 'element:more',
102
+ label: <span className={styles.more}>{String(t('menu.more', { count: selection.count - shown.length }))}</span>,
103
+ disabled: true,
104
+ })
105
+ }
106
+
107
+ const footer: MenuEntry[] = []
108
+ if (descriptor.reveal !== undefined) {
109
+ footer.push({ id: ACTION_REVEAL, label: String(t('action.reveal')), icon: <IconFullscreenOutlineRegular size={14} /> })
110
+ }
111
+ footer.push({ id: ACTION_CLEAR, label: String(t('action.clear')), danger: true })
112
+
113
+ const clear = (): void => {
114
+ // Local only: the surface keeps its own canvas selection, and the next change to
115
+ // it republishes. Clearing here means "do not carry this into the message".
116
+ bridge.publish(descriptor.id, null)
117
+ setOpen(false)
118
+ }
119
+
120
+ const onSelect = (id: string): void => {
121
+ if (id === ACTION_REVEAL) {
122
+ descriptor.reveal?.()
123
+ setOpen(false)
124
+ return
125
+ }
126
+ if (id === ACTION_CLEAR) {
127
+ clear()
128
+ return
129
+ }
130
+ if (!id.startsWith(ELEMENT_PREFIX)) return
131
+ const elementId = id.slice(ELEMENT_PREFIX.length)
132
+ if (elementId === 'more' || elementId === 'none') return
133
+ descriptor.reveal?.()
134
+ descriptor.focusElement?.(elementId)
135
+ setOpen(false)
136
+ }
137
+
138
+ return (
139
+ <>
140
+ <Menu
141
+ open={open}
142
+ anchor={(
143
+ <button
144
+ type="button"
145
+ className={styles.chip}
146
+ data-surface-chip=""
147
+ aria-label={String(t('chip.aria', { count: selection.count }))}
148
+ aria-expanded={open}
149
+ onClick={() => { setOpen(value => !value) }}
150
+ >
151
+ <span className={styles.glyph} aria-hidden="true"><Glyph size={12} /></span>
152
+ <span className={styles.label}>{descriptor.label}</span>
153
+ <span className={styles.dot} aria-hidden="true">·</span>
154
+ <span className={styles.count}>{String(t('chip.count', { count: selection.count }))}</span>
155
+ <span className={styles.chevron} aria-hidden="true"><IconChevronDownOutlineRegular size={12} /></span>
156
+ </button>
157
+ )}
158
+ items={rows}
159
+ footer={footer}
160
+ onSelect={onSelect}
161
+ onClose={() => { setOpen(false) }}
162
+ side="bottom"
163
+ align="start"
164
+ portal
165
+ />
166
+ <button
167
+ type="button"
168
+ className={styles.clear}
169
+ aria-label={String(t('action.clear'))}
170
+ onClick={clear}
171
+ >
172
+ <IconCloseOutlineRegular size={12} />
173
+ </button>
174
+ </>
175
+ )
176
+ }
177
+
178
+ /**
179
+ * The chip entry: one capsule per surface that has a selection and is on screen.
180
+ *
181
+ * Hiding is not done here — a source that goes off screen (a tab switch, a Session
182
+ * switch, a collapsed column) drops out of the registry's `active`, which both hides
183
+ * the capsule and removes it from the answer a read gets.
184
+ */
185
+ export function SurfaceSelectionChip({
186
+ bridge,
187
+ t,
188
+ }: PropsRuntime<'conversation.input.overlay'> & InjectFace<SurfaceDockInjected> & PropsLocale<typeof NS>): ReactNode {
189
+ useSyncExternalStore(
190
+ listener => bridge.subscribe(listener),
191
+ () => bridge.version(),
192
+ () => bridge.version(),
193
+ )
194
+ const active = bridge.active()
195
+ const anchorRef = useRef<HTMLDivElement | null>(null)
196
+
197
+ /*
198
+ * Tell the card how far to move its input line, so the text starts after the
199
+ * capsule instead of under it.
200
+ *
201
+ * This is the one place the bridge writes to the shell's DOM, and it is deliberate:
202
+ * the composer's scroll container is the only element that can move its own text,
203
+ * and the distance depends on the capsule's rendered width — which depends on the
204
+ * locale and the digit count, so a constant in the stylesheet would be wrong in at
205
+ * least one language. One scoped custom property, set while a chip is mounted and
206
+ * removed when it is not.
207
+ */
208
+ useLayoutEffect(() => {
209
+ const anchor = anchorRef.current
210
+ if (anchor === null) return
211
+ // `closest` is not generic over attribute selectors, so the cast is the element
212
+ // the shell documents with that attribute.
213
+ const card = anchor.closest('[data-composer-card]') as HTMLElement | null
214
+ if (card === null) return
215
+ const apply = (): void => {
216
+ const width = anchor.getBoundingClientRect().width
217
+ if (width <= 0) return
218
+ // 8px of air between the capsule and the first character.
219
+ card.style.setProperty('--surface-bridge-indent', `${Math.round(width) + 8}px`)
220
+ }
221
+ apply()
222
+ if (typeof ResizeObserver === 'undefined') return () => { card.style.removeProperty('--surface-bridge-indent') }
223
+ const observer = new ResizeObserver(apply)
224
+ observer.observe(anchor)
225
+ return () => {
226
+ observer.disconnect()
227
+ card.style.removeProperty('--surface-bridge-indent')
228
+ }
229
+ }, [active.length])
230
+
231
+ if (active.length === 0) return null
232
+ return (
233
+ <div className={styles.anchor} data-surface-bridge="" ref={anchorRef}>
234
+ {active.map(({ descriptor, selection }) => (
235
+ <SurfaceChip
236
+ key={descriptor.id}
237
+ bridge={bridge}
238
+ descriptor={descriptor}
239
+ selection={selection}
240
+ t={t}
241
+ />
242
+ ))}
243
+ </div>
244
+ )
245
+ }