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.
- package/LICENSE +21 -0
- package/README.md +132 -0
- package/cordis.patch.yml +9 -0
- package/lib/client.js +310 -0
- package/lib/types/client/SurfaceSelectionDock.d.ts +37 -0
- package/lib/types/client/SurfaceSelectionDock.js +125 -0
- package/lib/types/client/index.d.ts +40 -0
- package/lib/types/client/index.js +41 -0
- package/lib/types/client/locales.d.ts +30 -0
- package/lib/types/client/locales.js +36 -0
- package/lib/types/client/service.d.ts +46 -0
- package/lib/types/client/service.js +89 -0
- package/lib/types/client/transport.d.ts +19 -0
- package/lib/types/client/transport.js +38 -0
- package/lib/types/contract.d.ts +329 -0
- package/lib/types/contract.js +38 -0
- package/lib/types/host/narrow.d.ts +25 -0
- package/lib/types/host/narrow.js +193 -0
- package/lib/types/host/render.d.ts +63 -0
- package/lib/types/host/render.js +228 -0
- package/lib/types/host/routes.d.ts +31 -0
- package/lib/types/host/routes.js +108 -0
- package/lib/types/host/service.d.ts +41 -0
- package/lib/types/host/service.js +93 -0
- package/lib/types/host/store.d.ts +85 -0
- package/lib/types/host/store.js +206 -0
- package/lib/types/index.d.ts +93 -0
- package/lib/types/index.js +132 -0
- package/package.json +88 -0
- package/src/client/SurfaceSelectionDock.module.css +186 -0
- package/src/client/SurfaceSelectionDock.tsx +245 -0
- package/src/client/index.ts +65 -0
- package/src/client/locales.ts +42 -0
- package/src/client/service.ts +110 -0
- package/src/client/transport.ts +39 -0
- package/src/contract.ts +351 -0
- package/src/css-modules.d.ts +10 -0
- package/src/host/narrow.ts +180 -0
- package/src/host/render.ts +226 -0
- package/src/host/routes.ts +117 -0
- package/src/host/service.ts +116 -0
- package/src/host/store.ts +236 -0
- 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
|
+
}
|