dsh-quick-actions 0.1.0-rc.3
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.en.md +199 -0
- package/README.md +199 -0
- package/cordis.patch.yml +29 -0
- package/lib/client.js +3901 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +737 -0
- package/lib/types/client/controller.d.ts +207 -0
- package/lib/types/client/dsh.d.ts +171 -0
- package/lib/types/client/index.d.ts +43 -0
- package/lib/types/client/manager/ActionForm.d.ts +20 -0
- package/lib/types/client/manager/ActionPanel.d.ts +15 -0
- package/lib/types/client/manager/ManagedRow.d.ts +40 -0
- package/lib/types/client/manager/ManagerPanel.d.ts +9 -0
- package/lib/types/client/manager/press.d.ts +38 -0
- package/lib/types/client/manager/search.d.ts +56 -0
- package/lib/types/client/manager/status.d.ts +23 -0
- package/lib/types/client/modal.d.ts +55 -0
- package/lib/types/client/session/ConfirmPanel.d.ts +23 -0
- package/lib/types/client/session/availability.d.ts +19 -0
- package/lib/types/client/session/execution.d.ts +86 -0
- package/lib/types/client/session/guards.d.ts +59 -0
- package/lib/types/client/surfaces/ActionFace.d.ts +21 -0
- package/lib/types/client/surfaces/ErrorBoundary.d.ts +31 -0
- package/lib/types/client/surfaces/QuickActionsSurface.d.ts +17 -0
- package/lib/types/client/surfaces/entries.d.ts +27 -0
- package/lib/types/client/surfaces/layout.d.ts +49 -0
- package/lib/types/client/surfaces/residency.d.ts +65 -0
- package/lib/types/host/config.d.ts +18 -0
- package/lib/types/host/index.d.ts +38 -0
- package/lib/types/host/presets.d.ts +26 -0
- package/lib/types/host/settings.d.ts +113 -0
- package/lib/types/index.d.ts +35 -0
- package/lib/types/locales/index.d.ts +34 -0
- package/lib/types/model/catalog.d.ts +63 -0
- package/lib/types/model/index.d.ts +13 -0
- package/lib/types/model/json.d.ts +11 -0
- package/lib/types/model/mutations.d.ts +98 -0
- package/lib/types/model/normalize.d.ts +15 -0
- package/lib/types/model/projection.d.ts +49 -0
- package/lib/types/model/settings.d.ts +48 -0
- package/lib/types/model/text.d.ts +28 -0
- package/lib/types/model/types.d.ts +89 -0
- package/lib/types/model/validation.d.ts +40 -0
- package/lib/types/styles/index.d.ts +39 -0
- package/lib/types/types.d.ts +11 -0
- package/lib/types.js +1 -0
- package/package.json +83 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,3901 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-quick-actions",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
let react = require("react");
|
|
8
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
9
|
+
let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
|
|
10
|
+
let react_dom = require("react-dom");
|
|
11
|
+
//#region src/model/types.ts
|
|
12
|
+
/** Every layout value, in the order the management panel offers them. */
|
|
13
|
+
const QUICK_ACTION_LAYOUTS = [
|
|
14
|
+
"ribbon",
|
|
15
|
+
"bar",
|
|
16
|
+
"launcher"
|
|
17
|
+
];
|
|
18
|
+
/**
|
|
19
|
+
* The identity of a reference as one comparable string. Source and id together
|
|
20
|
+
* are the identity: the same id under a different source is a different action.
|
|
21
|
+
*/
|
|
22
|
+
function quickActionRefKey(ref) {
|
|
23
|
+
return `${ref.source}:${ref.id}`;
|
|
24
|
+
}
|
|
25
|
+
//#endregion
|
|
26
|
+
//#region src/model/json.ts
|
|
27
|
+
/**
|
|
28
|
+
* JSON structural comparison shared by the mutation planner and the Host's
|
|
29
|
+
* canonical rewrite.
|
|
30
|
+
*
|
|
31
|
+
* Structural, not canonical-JSON: object key order carries no meaning here.
|
|
32
|
+
* A stored section round-tripped through YAML, and a tombstone written by a
|
|
33
|
+
* higher version, both come back with whatever key order their writer chose —
|
|
34
|
+
* comparing serialized text would report a difference that is not one, and the
|
|
35
|
+
* rewrite would then write on every start instead of being idempotent.
|
|
36
|
+
*/
|
|
37
|
+
function deepEqualJson(left, right) {
|
|
38
|
+
if (left === right) return true;
|
|
39
|
+
if (typeof left !== "object" || typeof right !== "object" || left === null || right === null) return false;
|
|
40
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
41
|
+
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
|
|
42
|
+
return left.every((item, index) => deepEqualJson(item, right[index]));
|
|
43
|
+
}
|
|
44
|
+
const leftKeys = Object.keys(left);
|
|
45
|
+
const rightKeys = Object.keys(right);
|
|
46
|
+
if (leftKeys.length !== rightKeys.length) return false;
|
|
47
|
+
return leftKeys.every((key) => Object.hasOwn(right, key) && deepEqualJson(left[key], right[key]));
|
|
48
|
+
}
|
|
49
|
+
//#endregion
|
|
50
|
+
//#region src/model/text.ts
|
|
51
|
+
/**
|
|
52
|
+
* Unicode primitives shared by every Quick Action entry point (spec 4.3).
|
|
53
|
+
* Config loading, the management form, migration and Settings mutations must all
|
|
54
|
+
* measure and trim text through these functions so one rule cannot drift from another.
|
|
55
|
+
*/
|
|
56
|
+
/**
|
|
57
|
+
* Reference placeholder code points DSH reserves and strips inside `setDraft`:
|
|
58
|
+
* the reference marker block plus the legacy object replacement character.
|
|
59
|
+
* Static text carrying one of them would submit something other than what the
|
|
60
|
+
* user configured, so the model rejects it as a field error (spec 4.3).
|
|
61
|
+
*
|
|
62
|
+
* Source: `REFERENCE_PLACEHOLDER_RE` in `@deepseek-ai/dsh-client-ui-conversation`
|
|
63
|
+
* (`lib/client.js`, input machine), applied by `setDraft` before it rebuilds the
|
|
64
|
+
* draft. Verified identical in 0.1.1-rc.2, 0.1.2-rc.1 and 0.1.5-rc.1 — the release
|
|
65
|
+
* that renamed `imageIds` left this range untouched (ticket 29). Its tail is
|
|
66
|
+
* published as a named constant, which 0.1.5-rc.1 moved and renamed:
|
|
67
|
+
* `PLACEHOLDER = "\uFFFC"` in `lib/types/client/input/machine.d.ts` became
|
|
68
|
+
* `ATOMIC_CHAR = "\uFFFC"` in `lib/types/client/input/editor/projection.d.ts`.
|
|
69
|
+
* Re-check this range when the supported DSH range moves.
|
|
70
|
+
*/
|
|
71
|
+
const RESERVED_REFERENCE_PLACEHOLDER = /[\u{E100}-\u{E11D}\u{FFFC}]/u;
|
|
72
|
+
/** Unicode code point length — the counting unit for every length limit (spec 4.3). */
|
|
73
|
+
function countCodePoints(value) {
|
|
74
|
+
let count = 0;
|
|
75
|
+
for (const _ of value) count += 1;
|
|
76
|
+
return count;
|
|
77
|
+
}
|
|
78
|
+
/** Whether a text holds no character outside ECMAScript `trim()` whitespace (spec 4.3). */
|
|
79
|
+
function isBlankQuickActionText(text) {
|
|
80
|
+
return text.trim().length === 0;
|
|
81
|
+
}
|
|
82
|
+
/** Whether a text carries a DSH-reserved reference placeholder code point (spec 4.3). */
|
|
83
|
+
function containsReservedReferencePlaceholder(text) {
|
|
84
|
+
return RESERVED_REFERENCE_PLACEHOLDER.test(text);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Whether a static text is a Command Send Action: its first non-whitespace
|
|
88
|
+
* character is `/` (spec 4.3). Whitespace is ECMAScript `trim()` whitespace.
|
|
89
|
+
*/
|
|
90
|
+
function isCommandSendActionText(text) {
|
|
91
|
+
return text.trimStart().startsWith("/");
|
|
92
|
+
}
|
|
93
|
+
/** Code points allowed to appear inside an emoji grapheme cluster. */
|
|
94
|
+
const EMOJI_CLUSTER_MEMBER = /^[\p{Extended_Pictographic}\p{Emoji_Component}\p{Emoji_Modifier}\u{FE0E}\u{FE0F}\u{200D}]+$/u;
|
|
95
|
+
/** A keycap sequence carries no pictographic code point of its own. */
|
|
96
|
+
const KEYCAP_CLUSTER = /^[0-9#*]\u{FE0F}?\u{20E3}$/u;
|
|
97
|
+
/** A flag is a pair of regional indicators, also without a pictographic code point. */
|
|
98
|
+
const FLAG_CLUSTER = /^\p{Regional_Indicator}{2}$/u;
|
|
99
|
+
const graphemeSegmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
|
|
100
|
+
function isEmojiCluster(cluster) {
|
|
101
|
+
if (!EMOJI_CLUSTER_MEMBER.test(cluster)) return false;
|
|
102
|
+
return /\p{Extended_Pictographic}/u.test(cluster) || KEYCAP_CLUSTER.test(cluster) || FLAG_CLUSTER.test(cluster);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Segment an icon candidate into grapheme clusters and report whether each one
|
|
106
|
+
* is an emoji cluster, so validation can tell `too-long` from `not-emoji`.
|
|
107
|
+
*/
|
|
108
|
+
function scanEmojiClusters(icon) {
|
|
109
|
+
let clusters = 0;
|
|
110
|
+
let emojiOnly = true;
|
|
111
|
+
for (const { segment } of graphemeSegmenter.segment(icon)) {
|
|
112
|
+
clusters += 1;
|
|
113
|
+
if (!isEmojiCluster(segment)) emojiOnly = false;
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
clusters,
|
|
117
|
+
emojiOnly
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/** Static text limit, in Unicode code points, measured without trimming (spec 4.3). */
|
|
121
|
+
const QUICK_ACTION_TEXT_MAX_CODE_POINTS = 4e3;
|
|
122
|
+
/** Trimmed label, or the reason it cannot be stored. */
|
|
123
|
+
function labelIssue(label) {
|
|
124
|
+
const trimmed = label.trim();
|
|
125
|
+
if (trimmed.length === 0) return {
|
|
126
|
+
field: "label",
|
|
127
|
+
reason: "blank"
|
|
128
|
+
};
|
|
129
|
+
if (countCodePoints(trimmed) > 40) return {
|
|
130
|
+
field: "label",
|
|
131
|
+
reason: "too-long"
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/** The reason a static text cannot be stored, if any. */
|
|
135
|
+
function textIssue(text) {
|
|
136
|
+
if (isBlankQuickActionText(text)) return {
|
|
137
|
+
field: "text",
|
|
138
|
+
reason: "blank"
|
|
139
|
+
};
|
|
140
|
+
if (countCodePoints(text) > 4e3) return {
|
|
141
|
+
field: "text",
|
|
142
|
+
reason: "too-long"
|
|
143
|
+
};
|
|
144
|
+
if (containsReservedReferencePlaceholder(text)) return {
|
|
145
|
+
field: "text",
|
|
146
|
+
reason: "reserved-placeholder"
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/** The reason a non-empty icon cannot be stored, if any. */
|
|
150
|
+
function iconIssue(icon) {
|
|
151
|
+
const { clusters, emojiOnly } = scanEmojiClusters(icon);
|
|
152
|
+
if (!emojiOnly) return {
|
|
153
|
+
field: "icon",
|
|
154
|
+
reason: "not-emoji"
|
|
155
|
+
};
|
|
156
|
+
if (clusters > 4) return {
|
|
157
|
+
field: "icon",
|
|
158
|
+
reason: "too-long"
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Validate one edited or created action. Issues come back in field declaration
|
|
163
|
+
* order — label, text, icon — so the form can render them deterministically.
|
|
164
|
+
*/
|
|
165
|
+
function validateQuickActionDraft(draft) {
|
|
166
|
+
const issues = [
|
|
167
|
+
labelIssue(draft.label),
|
|
168
|
+
textIssue(draft.text),
|
|
169
|
+
draft.icon === "" ? void 0 : iconIssue(draft.icon)
|
|
170
|
+
].filter((issue) => issue !== void 0);
|
|
171
|
+
if (issues.length > 0) return {
|
|
172
|
+
ok: false,
|
|
173
|
+
issues
|
|
174
|
+
};
|
|
175
|
+
return {
|
|
176
|
+
ok: true,
|
|
177
|
+
value: {
|
|
178
|
+
label: draft.label.trim(),
|
|
179
|
+
text: draft.text,
|
|
180
|
+
icon: draft.icon === "" ? void 0 : draft.icon,
|
|
181
|
+
confirm: draft.confirm
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function fieldIssue(field, reason) {
|
|
186
|
+
return {
|
|
187
|
+
field,
|
|
188
|
+
reason
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
function readId(entry) {
|
|
192
|
+
const { id } = entry;
|
|
193
|
+
if (id === void 0) return { issue: fieldIssue("id", "missing") };
|
|
194
|
+
if (typeof id !== "string") return { issue: fieldIssue("id", "invalid-type") };
|
|
195
|
+
if (id.trim().length === 0) return { issue: fieldIssue("id", "blank") };
|
|
196
|
+
return { id };
|
|
197
|
+
}
|
|
198
|
+
function readPreset(entry, id) {
|
|
199
|
+
if (entry.kind !== void 0 && entry.kind !== "send") return { issue: fieldIssue("kind", "unsupported") };
|
|
200
|
+
const { label, text, icon, confirm } = entry;
|
|
201
|
+
if (typeof label !== "string") return { issue: fieldIssue("label", label === void 0 ? "missing" : "invalid-type") };
|
|
202
|
+
const labelProblem = labelIssue(label);
|
|
203
|
+
if (labelProblem !== void 0) return { issue: labelProblem };
|
|
204
|
+
if (typeof text !== "string") return { issue: fieldIssue("text", text === void 0 ? "missing" : "invalid-type") };
|
|
205
|
+
const textProblem = textIssue(text);
|
|
206
|
+
if (textProblem !== void 0) return { issue: textProblem };
|
|
207
|
+
if (icon !== void 0 && typeof icon !== "string") return { issue: fieldIssue("icon", "invalid-type") };
|
|
208
|
+
if (icon === "") return { issue: fieldIssue("icon", "blank") };
|
|
209
|
+
if (icon !== void 0) {
|
|
210
|
+
const iconProblem = iconIssue(icon);
|
|
211
|
+
if (iconProblem !== void 0) return { issue: iconProblem };
|
|
212
|
+
}
|
|
213
|
+
if (confirm !== void 0 && typeof confirm !== "boolean") return { issue: fieldIssue("confirm", "invalid-type") };
|
|
214
|
+
return { preset: {
|
|
215
|
+
id,
|
|
216
|
+
kind: "send",
|
|
217
|
+
label: label.trim(),
|
|
218
|
+
text,
|
|
219
|
+
...icon === void 0 ? {} : { icon },
|
|
220
|
+
confirm: confirm ?? true
|
|
221
|
+
} };
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Read one published catalog snapshot back (spec 17.2). The Host is the catalog's
|
|
225
|
+
* single validation authority, so this is a defensive decode of an already
|
|
226
|
+
* confirmed snapshot rather than a second authority — but it is a decode, not a
|
|
227
|
+
* cast: a snapshot this release cannot read in full yields no catalog at all.
|
|
228
|
+
*
|
|
229
|
+
* All-or-nothing on purpose. Dropping the entries it cannot read would show the
|
|
230
|
+
* user a silently truncated action list, which is exactly what spec 5.1 forbids
|
|
231
|
+
* the Host to do; the consumer reports a catalog error instead (spec 10).
|
|
232
|
+
*
|
|
233
|
+
* The Host's `revision` is carried through rather than recomputed. It is the
|
|
234
|
+
* catalog's published identity, and a Client that recomputed it would be
|
|
235
|
+
* asserting an authority it does not have.
|
|
236
|
+
*/
|
|
237
|
+
function decodeCatalogSnapshot(raw) {
|
|
238
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return void 0;
|
|
239
|
+
const { schemaVersion, revision, presets } = raw;
|
|
240
|
+
if (schemaVersion !== 1) return void 0;
|
|
241
|
+
if (typeof revision !== "string" || revision === "") return void 0;
|
|
242
|
+
if (!Array.isArray(presets)) return void 0;
|
|
243
|
+
const read = readPublishedPresets(presets);
|
|
244
|
+
if (read === void 0) return void 0;
|
|
245
|
+
return {
|
|
246
|
+
schemaVersion: 1,
|
|
247
|
+
revision,
|
|
248
|
+
presets: read
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Validate an already-published preset list against the same field rules the
|
|
253
|
+
* Host applied. Nothing is reported field by field: the Host is the authority
|
|
254
|
+
* that names an author's mistake, and a Client can only refuse the snapshot.
|
|
255
|
+
*/
|
|
256
|
+
function readPublishedPresets(entries) {
|
|
257
|
+
if (entries.length > 50) return void 0;
|
|
258
|
+
const presets = [];
|
|
259
|
+
const seen = /* @__PURE__ */ new Set();
|
|
260
|
+
for (const entry of entries) {
|
|
261
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return void 0;
|
|
262
|
+
const record = entry;
|
|
263
|
+
const identity = readId(record);
|
|
264
|
+
if ("issue" in identity || seen.has(identity.id)) return void 0;
|
|
265
|
+
const read = readPreset(record, identity.id);
|
|
266
|
+
if ("issue" in read) return void 0;
|
|
267
|
+
seen.add(identity.id);
|
|
268
|
+
presets.push(read.preset);
|
|
269
|
+
}
|
|
270
|
+
return presets;
|
|
271
|
+
}
|
|
272
|
+
//#endregion
|
|
273
|
+
//#region src/model/settings.ts
|
|
274
|
+
/**
|
|
275
|
+
* Settings V1 decoding (spec 4.2, 6.3). Decoding is defensive and lossless: it
|
|
276
|
+
* coerces shapes it recognises, falls back to documented defaults, and preserves
|
|
277
|
+
* anything it cannot render as a tombstone rather than repairing or dropping it.
|
|
278
|
+
* Catalog-aware canonicalization lives in `normalize.ts`.
|
|
279
|
+
*/
|
|
280
|
+
/**
|
|
281
|
+
* The one namespace holding user data (spec 4.2); renaming it orphans every
|
|
282
|
+
* stored section. It lives in the shared model because both faces address it:
|
|
283
|
+
* the Host registers it, the Client binds the same name.
|
|
284
|
+
*/
|
|
285
|
+
const QUICK_ACTIONS_SETTINGS_NAMESPACE = "composer-quick-actions";
|
|
286
|
+
/**
|
|
287
|
+
* The read-only namespace carrying the Preset Catalog to Clients (spec 17.2).
|
|
288
|
+
* The plugin never writes its user layer, so it holds no persisted section —
|
|
289
|
+
* `composer-quick-actions` remains the only persisted namespace (spec 4.2).
|
|
290
|
+
*/
|
|
291
|
+
const QUICK_ACTIONS_CATALOG_NAMESPACE = "composer-quick-actions-catalog";
|
|
292
|
+
/** The state a fresh install starts from (spec 4.2). */
|
|
293
|
+
const DEFAULT_QUICK_ACTION_SETTINGS = Object.freeze({
|
|
294
|
+
schemaVersion: 1,
|
|
295
|
+
layout: "ribbon",
|
|
296
|
+
userActionsById: Object.freeze({}),
|
|
297
|
+
actionOrder: Object.freeze([]),
|
|
298
|
+
presetStateById: Object.freeze({})
|
|
299
|
+
});
|
|
300
|
+
function asRecord(value) {
|
|
301
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
302
|
+
return value;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Whether a stored entry is a live Send Action this release can render.
|
|
306
|
+
* Everything else is a tombstone: a higher version's `kind`, or a value whose
|
|
307
|
+
* label/text are not readable strings.
|
|
308
|
+
*/
|
|
309
|
+
function isLiveQuickAction(value) {
|
|
310
|
+
return value.kind === "send" && typeof value.label === "string" && typeof value.text === "string";
|
|
311
|
+
}
|
|
312
|
+
/** Whether a stored entry must be preserved untouched and kept out of every projection (spec 5.3). */
|
|
313
|
+
function isQuickActionTombstone(value) {
|
|
314
|
+
return !isLiveQuickAction(value);
|
|
315
|
+
}
|
|
316
|
+
function decodeLayout(value) {
|
|
317
|
+
return QUICK_ACTION_LAYOUTS.find((layout) => layout === value) ?? DEFAULT_QUICK_ACTION_SETTINGS.layout;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Read one stored custom action into canonical form. A missing `kind` reads as
|
|
321
|
+
* `'send'` because V1 always writes the tag; anything else marks a tombstone and
|
|
322
|
+
* is handed straight back by identity, so a value written by a higher version
|
|
323
|
+
* survives the round trip intact.
|
|
324
|
+
*
|
|
325
|
+
* A live result always carries `kind`, `confirm` and `enabled` explicitly, which
|
|
326
|
+
* is what spec 4.3 requires of every normalized action. `confirm` is only
|
|
327
|
+
* defaulted when it is absent — never derived from the text.
|
|
328
|
+
*/
|
|
329
|
+
function decodeStoredQuickAction(value) {
|
|
330
|
+
const entry = asRecord(value);
|
|
331
|
+
if (entry === void 0) return void 0;
|
|
332
|
+
if ((entry.kind ?? "send") !== "send" || typeof entry.label !== "string" || typeof entry.text !== "string") return entry;
|
|
333
|
+
const { icon, confirm, enabled, clonedFromPresetId } = entry;
|
|
334
|
+
return {
|
|
335
|
+
kind: "send",
|
|
336
|
+
label: entry.label,
|
|
337
|
+
text: entry.text,
|
|
338
|
+
...typeof icon === "string" && icon !== "" ? { icon } : {},
|
|
339
|
+
confirm: typeof confirm === "boolean" ? confirm : true,
|
|
340
|
+
enabled: typeof enabled === "boolean" ? enabled : true,
|
|
341
|
+
...typeof clonedFromPresetId === "string" ? { clonedFromPresetId } : {}
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
function decodeUserActions(value) {
|
|
345
|
+
const entries = asRecord(value);
|
|
346
|
+
if (entries === void 0) return {};
|
|
347
|
+
const decoded = {};
|
|
348
|
+
for (const [id, raw] of Object.entries(entries)) {
|
|
349
|
+
const action = decodeStoredQuickAction(raw);
|
|
350
|
+
if (action !== void 0) decoded[id] = action;
|
|
351
|
+
}
|
|
352
|
+
return decoded;
|
|
353
|
+
}
|
|
354
|
+
function decodeRef(value) {
|
|
355
|
+
const entry = asRecord(value);
|
|
356
|
+
if (entry === void 0) return void 0;
|
|
357
|
+
if (typeof entry.id !== "string" || entry.id === "") return void 0;
|
|
358
|
+
if (entry.source === "preset") return {
|
|
359
|
+
source: "preset",
|
|
360
|
+
id: entry.id
|
|
361
|
+
};
|
|
362
|
+
if (entry.source === "custom") return {
|
|
363
|
+
source: "custom",
|
|
364
|
+
id: entry.id
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
function decodeOrder(value) {
|
|
368
|
+
if (!Array.isArray(value)) return [];
|
|
369
|
+
return value.map(decodeRef).filter((ref) => ref !== void 0);
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Canonicalize one preset state: `hidden` becomes an explicit boolean or
|
|
373
|
+
* disappears, and every other field is carried through so a higher version's own
|
|
374
|
+
* preference survives a downgrade (spec 5.3).
|
|
375
|
+
*/
|
|
376
|
+
function canonicalPresetState(state) {
|
|
377
|
+
const rest = { ...state };
|
|
378
|
+
delete rest.hidden;
|
|
379
|
+
return state.hidden === true ? {
|
|
380
|
+
...rest,
|
|
381
|
+
hidden: true
|
|
382
|
+
} : rest;
|
|
383
|
+
}
|
|
384
|
+
/** Whether a canonical preset state carries no user preference at all. */
|
|
385
|
+
function isEmptyPresetState(state) {
|
|
386
|
+
return Object.keys(state).length === 0;
|
|
387
|
+
}
|
|
388
|
+
function decodePresetState(value) {
|
|
389
|
+
const entries = asRecord(value);
|
|
390
|
+
if (entries === void 0) return {};
|
|
391
|
+
const decoded = {};
|
|
392
|
+
for (const [id, raw] of Object.entries(entries)) {
|
|
393
|
+
const state = asRecord(raw);
|
|
394
|
+
if (state === void 0) continue;
|
|
395
|
+
decoded[id] = canonicalPresetState(state);
|
|
396
|
+
}
|
|
397
|
+
return decoded;
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Decode any published snapshot into V1. The stored `schemaVersion` is not a
|
|
401
|
+
* gate: a snapshot written by a higher version still yields every field this
|
|
402
|
+
* release understands, which is what makes a downgrade round trip lossless.
|
|
403
|
+
*/
|
|
404
|
+
function decodeQuickActionSettings(raw) {
|
|
405
|
+
const stored = asRecord(raw);
|
|
406
|
+
if (stored === void 0) return DEFAULT_QUICK_ACTION_SETTINGS;
|
|
407
|
+
return {
|
|
408
|
+
schemaVersion: 1,
|
|
409
|
+
layout: decodeLayout(stored.layout),
|
|
410
|
+
userActionsById: decodeUserActions(stored.userActionsById),
|
|
411
|
+
actionOrder: decodeOrder(stored.actionOrder),
|
|
412
|
+
presetStateById: decodePresetState(stored.presetStateById)
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
//#endregion
|
|
416
|
+
//#region src/model/normalize.ts
|
|
417
|
+
/**
|
|
418
|
+
* Deterministic, idempotent canonicalization of decoded Settings against the
|
|
419
|
+
* current Preset Catalog (spec 5.3). The same input and catalog always produce
|
|
420
|
+
* the same result, and normalizing twice changes nothing — which is what lets the
|
|
421
|
+
* Host rewrite behind a revision fence without fighting its own writes.
|
|
422
|
+
*
|
|
423
|
+
* Normalization repairs references and re-states the discriminant, never content.
|
|
424
|
+
* In particular it never derives `confirm` from the text: that default is
|
|
425
|
+
* initialized when an action is created or cloned, so rewriting it here would undo
|
|
426
|
+
* the user's choice on every load and break idempotence.
|
|
427
|
+
*/
|
|
428
|
+
/**
|
|
429
|
+
* Canonicalize one decoded snapshot against a catalog.
|
|
430
|
+
*
|
|
431
|
+
* Order: existing references keep their positions, minus repeats and minus
|
|
432
|
+
* references to custom actions that are gone; references to presets this catalog
|
|
433
|
+
* no longer carries stay put, because the same Preset Action ID coming back must
|
|
434
|
+
* restore the user's preference. Known actions with no reference are appended —
|
|
435
|
+
* presets in catalog order, then custom actions in stored order. Tombstones are
|
|
436
|
+
* never given a new reference, so a downgrade round trip returns their exact order.
|
|
437
|
+
*/
|
|
438
|
+
function normalizeQuickActionSettings(settings, catalog) {
|
|
439
|
+
const knownPresetIds = new Set(catalog.presets.map((preset) => preset.id));
|
|
440
|
+
const placed = /* @__PURE__ */ new Set();
|
|
441
|
+
const actionOrder = [];
|
|
442
|
+
const place = (ref) => {
|
|
443
|
+
const key = quickActionRefKey(ref);
|
|
444
|
+
if (placed.has(key)) return;
|
|
445
|
+
placed.add(key);
|
|
446
|
+
actionOrder.push(ref);
|
|
447
|
+
};
|
|
448
|
+
const userActionsById = {};
|
|
449
|
+
for (const [id, stored] of Object.entries(settings.userActionsById)) {
|
|
450
|
+
const value = decodeStoredQuickAction(stored);
|
|
451
|
+
if (value !== void 0) userActionsById[id] = value;
|
|
452
|
+
}
|
|
453
|
+
for (const ref of settings.actionOrder) {
|
|
454
|
+
if (ref.source === "custom" && userActionsById[ref.id] === void 0) continue;
|
|
455
|
+
place(ref);
|
|
456
|
+
}
|
|
457
|
+
for (const preset of catalog.presets) place({
|
|
458
|
+
source: "preset",
|
|
459
|
+
id: preset.id
|
|
460
|
+
});
|
|
461
|
+
for (const [id, value] of Object.entries(userActionsById)) if (isLiveQuickAction(value)) place({
|
|
462
|
+
source: "custom",
|
|
463
|
+
id
|
|
464
|
+
});
|
|
465
|
+
const presetStateById = {};
|
|
466
|
+
for (const [id, stored] of Object.entries(settings.presetStateById)) {
|
|
467
|
+
const state = canonicalPresetState(stored);
|
|
468
|
+
if (!knownPresetIds.has(id)) presetStateById[id] = state;
|
|
469
|
+
else if (!isEmptyPresetState(state)) presetStateById[id] = state;
|
|
470
|
+
}
|
|
471
|
+
return {
|
|
472
|
+
schemaVersion: 1,
|
|
473
|
+
layout: settings.layout,
|
|
474
|
+
userActionsById,
|
|
475
|
+
actionOrder,
|
|
476
|
+
presetStateById
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Project one snapshot against a catalog. The snapshot is canonicalized first, so
|
|
481
|
+
* the counts are the real totals even when the caller hands over state the catalog
|
|
482
|
+
* has moved on from — a known preset or a live action missing from the order must
|
|
483
|
+
* never slip past the action ceiling.
|
|
484
|
+
*/
|
|
485
|
+
function projectQuickActions(raw, catalog) {
|
|
486
|
+
const settings = normalizeQuickActionSettings(raw, catalog);
|
|
487
|
+
const presets = new Map(catalog.presets.map((preset) => [preset.id, preset]));
|
|
488
|
+
const managed = [];
|
|
489
|
+
const referenced = /* @__PURE__ */ new Set();
|
|
490
|
+
let preserved = 0;
|
|
491
|
+
for (const ref of settings.actionOrder) {
|
|
492
|
+
if (ref.source === "preset") {
|
|
493
|
+
const preset = presets.get(ref.id);
|
|
494
|
+
if (preset === void 0) {
|
|
495
|
+
preserved += 1;
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
managed.push({
|
|
499
|
+
ref,
|
|
500
|
+
label: preset.label,
|
|
501
|
+
text: preset.text,
|
|
502
|
+
icon: preset.icon,
|
|
503
|
+
confirm: preset.confirm,
|
|
504
|
+
command: isCommandSendActionText(preset.text),
|
|
505
|
+
editable: false,
|
|
506
|
+
hidden: settings.presetStateById[ref.id]?.hidden === true,
|
|
507
|
+
clonedFromPresetId: void 0
|
|
508
|
+
});
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
referenced.add(ref.id);
|
|
512
|
+
const value = settings.userActionsById[ref.id];
|
|
513
|
+
if (value === void 0 || !isLiveQuickAction(value)) {
|
|
514
|
+
preserved += 1;
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
managed.push({
|
|
518
|
+
ref,
|
|
519
|
+
label: value.label,
|
|
520
|
+
text: value.text,
|
|
521
|
+
icon: value.icon,
|
|
522
|
+
confirm: value.confirm,
|
|
523
|
+
command: isCommandSendActionText(value.text),
|
|
524
|
+
editable: true,
|
|
525
|
+
hidden: !value.enabled,
|
|
526
|
+
clonedFromPresetId: value.clonedFromPresetId
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
for (const [id, value] of Object.entries(settings.userActionsById)) if (!referenced.has(id) && isQuickActionTombstone(value)) preserved += 1;
|
|
530
|
+
const composer = managed.filter((action) => !action.hidden);
|
|
531
|
+
const total = managed.length;
|
|
532
|
+
return {
|
|
533
|
+
layout: settings.layout,
|
|
534
|
+
managed,
|
|
535
|
+
composer,
|
|
536
|
+
counts: {
|
|
537
|
+
total,
|
|
538
|
+
limit: 50,
|
|
539
|
+
overflow: total > 50,
|
|
540
|
+
canAdd: total < 50,
|
|
541
|
+
visible: composer.length,
|
|
542
|
+
hidden: total - composer.length,
|
|
543
|
+
preserved
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
//#endregion
|
|
548
|
+
//#region src/model/mutations.ts
|
|
549
|
+
/**
|
|
550
|
+
* Revision-fenced Settings mutation planning (spec 5.2, 5.4, 6.3, 10).
|
|
551
|
+
*
|
|
552
|
+
* A planner is pure: it takes the last Host-confirmed snapshot and returns the
|
|
553
|
+
* canonical snapshot to persist together with the revision the write must be
|
|
554
|
+
* fenced to. It performs no I/O and mints no identity — the caller supplies each
|
|
555
|
+
* new Custom Action ID, so a UUID collision comes back as a refusal to retry
|
|
556
|
+
* rather than as a silent overwrite.
|
|
557
|
+
*/
|
|
558
|
+
/** The draft the management form opens on; the only place the confirm default is applied. */
|
|
559
|
+
function newQuickActionDraft() {
|
|
560
|
+
return {
|
|
561
|
+
label: "",
|
|
562
|
+
text: "",
|
|
563
|
+
icon: "",
|
|
564
|
+
confirm: true
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
function refuse(reason, issues = []) {
|
|
568
|
+
return {
|
|
569
|
+
ok: false,
|
|
570
|
+
rejection: {
|
|
571
|
+
reason,
|
|
572
|
+
issues
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
function unknownAction() {
|
|
577
|
+
return refuse("unknown-action", [{
|
|
578
|
+
field: "id",
|
|
579
|
+
reason: "missing"
|
|
580
|
+
}]);
|
|
581
|
+
}
|
|
582
|
+
function planFrom(context, current, next) {
|
|
583
|
+
const canonical = normalizeQuickActionSettings(next, context.catalog);
|
|
584
|
+
return {
|
|
585
|
+
ok: true,
|
|
586
|
+
plan: {
|
|
587
|
+
expectedRevision: context.revision,
|
|
588
|
+
next: canonical,
|
|
589
|
+
changed: !deepEqualJson(current, canonical)
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
/** Every planner starts from the canonical reading of the confirmed snapshot. */
|
|
594
|
+
function currentOf(context) {
|
|
595
|
+
return normalizeQuickActionSettings(context.settings, context.catalog);
|
|
596
|
+
}
|
|
597
|
+
function withUserActions(settings, userActionsById) {
|
|
598
|
+
return {
|
|
599
|
+
...settings,
|
|
600
|
+
userActionsById
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
function liveActionOf(settings, id) {
|
|
604
|
+
const value = settings.userActionsById[id];
|
|
605
|
+
if (value === void 0 || !isLiveQuickAction(value)) return void 0;
|
|
606
|
+
return value;
|
|
607
|
+
}
|
|
608
|
+
/** Build one stored Send Action with every field spec 4.3 requires stated explicitly. */
|
|
609
|
+
function sendActionValue(input) {
|
|
610
|
+
return {
|
|
611
|
+
kind: "send",
|
|
612
|
+
label: input.label,
|
|
613
|
+
text: input.text,
|
|
614
|
+
...input.icon === void 0 ? {} : { icon: input.icon },
|
|
615
|
+
confirm: input.confirm,
|
|
616
|
+
enabled: input.enabled,
|
|
617
|
+
...input.clonedFromPresetId === void 0 ? {} : { clonedFromPresetId: input.clonedFromPresetId }
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Check a caller-minted Custom Action ID before it is persisted. A taken id comes
|
|
622
|
+
* back as `id-in-use` so the caller mints another UUID and retries; normalization
|
|
623
|
+
* must never renumber an id that is already stored (spec 4.1).
|
|
624
|
+
*/
|
|
625
|
+
function identityRefusal(settings, id) {
|
|
626
|
+
if (id === "") return refuse("invalid-fields", [{
|
|
627
|
+
field: "id",
|
|
628
|
+
reason: "blank"
|
|
629
|
+
}]);
|
|
630
|
+
if (settings.userActionsById[id] !== void 0) return refuse("id-in-use", [{
|
|
631
|
+
field: "id",
|
|
632
|
+
reason: "duplicate"
|
|
633
|
+
}]);
|
|
634
|
+
}
|
|
635
|
+
function planWithAction(context, current, id, value) {
|
|
636
|
+
return planFrom(context, current, withUserActions(current, {
|
|
637
|
+
...current.userActionsById,
|
|
638
|
+
[id]: value
|
|
639
|
+
}));
|
|
640
|
+
}
|
|
641
|
+
/** Create a Custom Quick Action under a caller-minted identity (spec 5.2, 5.4). */
|
|
642
|
+
function planCreateCustomQuickAction(context, input) {
|
|
643
|
+
const current = currentOf(context);
|
|
644
|
+
if (!projectQuickActions(current, context.catalog).counts.canAdd) return refuse("limit-reached");
|
|
645
|
+
const refusal = identityRefusal(current, input.id);
|
|
646
|
+
if (refusal !== void 0) return refusal;
|
|
647
|
+
const validated = validateQuickActionDraft(input.draft);
|
|
648
|
+
if (!validated.ok) return refuse("invalid-fields", validated.issues);
|
|
649
|
+
return planWithAction(context, current, input.id, sendActionValue({
|
|
650
|
+
...validated.value,
|
|
651
|
+
enabled: true,
|
|
652
|
+
clonedFromPresetId: void 0
|
|
653
|
+
}));
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Clone a Preset Quick Action into a new Custom Quick Action (spec 5.2).
|
|
657
|
+
* The confirmation policy is copied as it stands — including a Command Send
|
|
658
|
+
* Action the author left unconfirmed — never re-defaulted.
|
|
659
|
+
*/
|
|
660
|
+
function planClonePresetQuickAction(context, input) {
|
|
661
|
+
const current = currentOf(context);
|
|
662
|
+
if (!projectQuickActions(current, context.catalog).counts.canAdd) return refuse("limit-reached");
|
|
663
|
+
const preset = context.catalog.presets.find((candidate) => candidate.id === input.presetId);
|
|
664
|
+
if (preset === void 0) return unknownAction();
|
|
665
|
+
const refusal = identityRefusal(current, input.id);
|
|
666
|
+
if (refusal !== void 0) return refusal;
|
|
667
|
+
return planWithAction(context, current, input.id, sendActionValue({
|
|
668
|
+
label: preset.label,
|
|
669
|
+
text: preset.text,
|
|
670
|
+
icon: preset.icon,
|
|
671
|
+
confirm: preset.confirm,
|
|
672
|
+
enabled: true,
|
|
673
|
+
clonedFromPresetId: preset.id
|
|
674
|
+
}));
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Save edited content onto an existing Custom Quick Action. `enabled` and the
|
|
678
|
+
* Clone Provenance survive the edit; `confirm` comes from the form, which is the
|
|
679
|
+
* only place the user can change it.
|
|
680
|
+
*/
|
|
681
|
+
function planUpdateCustomQuickAction(context, input) {
|
|
682
|
+
const current = currentOf(context);
|
|
683
|
+
const existing = liveActionOf(current, input.id);
|
|
684
|
+
if (existing === void 0) return unknownAction();
|
|
685
|
+
const validated = validateQuickActionDraft(input.draft);
|
|
686
|
+
if (!validated.ok) return refuse("invalid-fields", validated.issues);
|
|
687
|
+
return planWithAction(context, current, input.id, sendActionValue({
|
|
688
|
+
...validated.value,
|
|
689
|
+
enabled: existing.enabled,
|
|
690
|
+
clonedFromPresetId: existing.clonedFromPresetId
|
|
691
|
+
}));
|
|
692
|
+
}
|
|
693
|
+
/** Disable or re-enable a Custom Quick Action; disabled actions stay in the management panel (spec 3). */
|
|
694
|
+
function planSetCustomQuickActionEnabled(context, input) {
|
|
695
|
+
const current = currentOf(context);
|
|
696
|
+
const existing = liveActionOf(current, input.id);
|
|
697
|
+
if (existing === void 0) return unknownAction();
|
|
698
|
+
return planWithAction(context, current, input.id, {
|
|
699
|
+
...existing,
|
|
700
|
+
enabled: input.enabled
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
/** Delete a Custom Quick Action; normalization drops its order reference with it. */
|
|
704
|
+
function planDeleteCustomQuickAction(context, input) {
|
|
705
|
+
const current = currentOf(context);
|
|
706
|
+
if (liveActionOf(current, input.id) === void 0) return unknownAction();
|
|
707
|
+
const userActionsById = { ...current.userActionsById };
|
|
708
|
+
delete userActionsById[input.id];
|
|
709
|
+
return planFrom(context, current, withUserActions(current, userActionsById));
|
|
710
|
+
}
|
|
711
|
+
/** Hide or restore a Preset Quick Action; the author's definition is never touched (spec 5.1). */
|
|
712
|
+
function planSetPresetQuickActionHidden(context, input) {
|
|
713
|
+
const current = currentOf(context);
|
|
714
|
+
if (!context.catalog.presets.some((preset) => preset.id === input.presetId)) return unknownAction();
|
|
715
|
+
const presetStateById = { ...current.presetStateById };
|
|
716
|
+
if (input.hidden) presetStateById[input.presetId] = { hidden: true };
|
|
717
|
+
else delete presetStateById[input.presetId];
|
|
718
|
+
return planFrom(context, current, {
|
|
719
|
+
...current,
|
|
720
|
+
presetStateById
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Apply a new order to the actions the management panel lists. Preserved
|
|
725
|
+
* references — unknown presets and tombstones — keep their exact positions, so a
|
|
726
|
+
* reorder here never disturbs data a higher version owns.
|
|
727
|
+
*/
|
|
728
|
+
function planReorderQuickActions(context, input) {
|
|
729
|
+
const current = currentOf(context);
|
|
730
|
+
const managed = projectQuickActions(current, context.catalog).managed.map((action) => action.ref);
|
|
731
|
+
const managedKeys = new Set(managed.map(quickActionRefKey));
|
|
732
|
+
const wanted = input.order.map(quickActionRefKey);
|
|
733
|
+
if (wanted.length !== managedKeys.size || new Set(wanted).size !== wanted.length) return refuse("invalid-order");
|
|
734
|
+
if (!wanted.every((key) => managedKeys.has(key))) return refuse("invalid-order");
|
|
735
|
+
const queue = [...input.order];
|
|
736
|
+
const actionOrder = current.actionOrder.map((ref) => managedKeys.has(quickActionRefKey(ref)) ? queue.shift() ?? ref : ref);
|
|
737
|
+
return planFrom(context, current, {
|
|
738
|
+
...current,
|
|
739
|
+
actionOrder
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
/** Move one managed action to another position in the management list. */
|
|
743
|
+
function planMoveQuickAction(context, input) {
|
|
744
|
+
const managed = projectQuickActions(currentOf(context), context.catalog).managed.map((action) => action.ref);
|
|
745
|
+
const from = managed.findIndex((candidate) => quickActionRefKey(candidate) === quickActionRefKey(input.ref));
|
|
746
|
+
if (from === -1) return unknownAction();
|
|
747
|
+
if (!Number.isInteger(input.toIndex) || input.toIndex < 0 || input.toIndex >= managed.length) return refuse("invalid-order");
|
|
748
|
+
const order = [...managed];
|
|
749
|
+
const [moved] = order.splice(from, 1);
|
|
750
|
+
if (moved === void 0) return refuse("invalid-order");
|
|
751
|
+
order.splice(input.toIndex, 0, moved);
|
|
752
|
+
return planReorderQuickActions(context, { order });
|
|
753
|
+
}
|
|
754
|
+
/** Switch the global Quick Action Layout (spec 8.1). */
|
|
755
|
+
function planSetQuickActionLayout(context, input) {
|
|
756
|
+
const current = currentOf(context);
|
|
757
|
+
return planFrom(context, current, {
|
|
758
|
+
...current,
|
|
759
|
+
layout: input.layout
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
//#endregion
|
|
763
|
+
//#region src/client/controller.ts
|
|
764
|
+
/**
|
|
765
|
+
* The Client's single owner of authoritative Quick Action state (spec 7.1).
|
|
766
|
+
*
|
|
767
|
+
* It binds the two Settings namespaces the Host registers — the read-only
|
|
768
|
+
* catalog namespace, read off its composition `base` layer, and the user-state
|
|
769
|
+
* namespace — derives the projection every surface renders, and serializes
|
|
770
|
+
* revision-fenced writes back through the same scope.
|
|
771
|
+
*
|
|
772
|
+
* What it deliberately does not own:
|
|
773
|
+
*
|
|
774
|
+
* - **Validation and migration authority.** The Host owns both (spec 6.3). Every
|
|
775
|
+
* rule applied here comes from the shared model, and a snapshot is decoded
|
|
776
|
+
* defensively rather than repaired: this face never rewrites before validating,
|
|
777
|
+
* never writes a file, and never treats browser storage as a source of truth.
|
|
778
|
+
* - **Anything session-scoped.** No Session, InputState, Slot props or
|
|
779
|
+
* `InputActions` object reaches this module — long-lived global state must not
|
|
780
|
+
* pin a session's runtime objects (spec 7.1). Draft occupancy, confirmation and
|
|
781
|
+
* send single-flight belong to the per-session execution layer (spec 7.2).
|
|
782
|
+
* - **Its own copy of the settings document.** Both bindings derive from the one
|
|
783
|
+
* browser-side describe mirror, so the catalog costs no read of its own and
|
|
784
|
+
* refreshes with that mirror after a reconnect (spec 17.3).
|
|
785
|
+
*/
|
|
786
|
+
/**
|
|
787
|
+
* How many Custom Action IDs a create or clone tries before giving up.
|
|
788
|
+
* Normalization must never renumber a stored id (spec 4.1), so the planner
|
|
789
|
+
* refuses a taken id and the controller mints another rather than overwriting
|
|
790
|
+
* anything; exhausting the attempts surfaces the refusal instead of hiding it.
|
|
791
|
+
*/
|
|
792
|
+
const ID_MINT_ATTEMPTS = 4;
|
|
793
|
+
/** The five top-level fields of the persisted section (spec 4.2). */
|
|
794
|
+
function sectionOps(next) {
|
|
795
|
+
return [
|
|
796
|
+
{
|
|
797
|
+
op: "set",
|
|
798
|
+
path: ["schemaVersion"],
|
|
799
|
+
value: next.schemaVersion
|
|
800
|
+
},
|
|
801
|
+
{
|
|
802
|
+
op: "set",
|
|
803
|
+
path: ["layout"],
|
|
804
|
+
value: next.layout
|
|
805
|
+
},
|
|
806
|
+
{
|
|
807
|
+
op: "set",
|
|
808
|
+
path: ["userActionsById"],
|
|
809
|
+
value: next.userActionsById
|
|
810
|
+
},
|
|
811
|
+
{
|
|
812
|
+
op: "set",
|
|
813
|
+
path: ["actionOrder"],
|
|
814
|
+
value: next.actionOrder
|
|
815
|
+
},
|
|
816
|
+
{
|
|
817
|
+
op: "set",
|
|
818
|
+
path: ["presetStateById"],
|
|
819
|
+
value: next.presetStateById
|
|
820
|
+
}
|
|
821
|
+
];
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Read the catalog off a bound namespace.
|
|
825
|
+
*
|
|
826
|
+
* `base` is read, never `value`: `base` is the author's own layer, so a user who
|
|
827
|
+
* hand-writes a section of that name into the settings document still sees the
|
|
828
|
+
* Host's catalog (spec 17.2).
|
|
829
|
+
*/
|
|
830
|
+
function readCatalog(snapshot, document) {
|
|
831
|
+
if (snapshot.status === "loading") {
|
|
832
|
+
if (document.view !== void 0) return {
|
|
833
|
+
status: "error",
|
|
834
|
+
reason: "unavailable"
|
|
835
|
+
};
|
|
836
|
+
return document.error === null ? { status: "loading" } : {
|
|
837
|
+
status: "error",
|
|
838
|
+
reason: "unreadable"
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
if (snapshot.status === "unavailable" || snapshot.base === void 0) return {
|
|
842
|
+
status: "error",
|
|
843
|
+
reason: "unavailable"
|
|
844
|
+
};
|
|
845
|
+
const catalog = decodeCatalogSnapshot(snapshot.base);
|
|
846
|
+
if (catalog === void 0) return {
|
|
847
|
+
status: "error",
|
|
848
|
+
reason: "undecodable"
|
|
849
|
+
};
|
|
850
|
+
return {
|
|
851
|
+
status: "ready",
|
|
852
|
+
catalog
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Read the confirmed user state off a bound namespace.
|
|
857
|
+
*
|
|
858
|
+
* Decoded, not canonicalized: canonicalization is catalog-relative, and whether
|
|
859
|
+
* a namespace is readable has nothing to do with whether the catalog is. Every
|
|
860
|
+
* consumer that needs the canonical form — the projection, each planner, the
|
|
861
|
+
* post-write comparison — derives it against the catalog it already holds.
|
|
862
|
+
*/
|
|
863
|
+
function readSettings(snapshot) {
|
|
864
|
+
if (snapshot.status === "loading") return { status: "loading" };
|
|
865
|
+
if (snapshot.status === "unavailable" || snapshot.revision === void 0) return { status: "unavailable" };
|
|
866
|
+
return {
|
|
867
|
+
status: "ready",
|
|
868
|
+
settings: decodeQuickActionSettings(snapshot.value),
|
|
869
|
+
revision: snapshot.revision,
|
|
870
|
+
writable: snapshot.writable
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
function writeGate(catalog, settings, stale) {
|
|
874
|
+
if (catalog.status !== "ready" || settings.status !== "ready") return {
|
|
875
|
+
ok: false,
|
|
876
|
+
failure: { kind: "not-ready" }
|
|
877
|
+
};
|
|
878
|
+
if (!settings.writable || stale) return {
|
|
879
|
+
ok: false,
|
|
880
|
+
failure: { kind: "read-only" }
|
|
881
|
+
};
|
|
882
|
+
return {
|
|
883
|
+
ok: true,
|
|
884
|
+
context: {
|
|
885
|
+
settings: settings.settings,
|
|
886
|
+
catalog: catalog.catalog,
|
|
887
|
+
revision: String(settings.revision)
|
|
888
|
+
},
|
|
889
|
+
catalog: catalog.catalog,
|
|
890
|
+
revision: settings.revision
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* Create the controller. Both bindings are made on the calling fiber, so the
|
|
895
|
+
* scope disposers the binder registers are withdrawn with it; `dispose()`
|
|
896
|
+
* releases what this module owns on top of that.
|
|
897
|
+
*/
|
|
898
|
+
function createQuickActionsController(options) {
|
|
899
|
+
const mintId = options.mintCustomActionId ?? (() => crypto.randomUUID());
|
|
900
|
+
const catalogScope = options.settingsScope.bind({
|
|
901
|
+
namespace: QUICK_ACTIONS_CATALOG_NAMESPACE,
|
|
902
|
+
decode: (value) => value ?? null
|
|
903
|
+
});
|
|
904
|
+
const settingsScope = options.settingsScope.bind({
|
|
905
|
+
namespace: QUICK_ACTIONS_SETTINGS_NAMESPACE,
|
|
906
|
+
decode: (value) => decodeQuickActionSettings(value)
|
|
907
|
+
});
|
|
908
|
+
const mirror = options.settingsScope.describe();
|
|
909
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
910
|
+
let disposed = false;
|
|
911
|
+
let manager = { open: false };
|
|
912
|
+
let writing = 0;
|
|
913
|
+
let failure;
|
|
914
|
+
let connectionState = options.connection.state.getSnapshot();
|
|
915
|
+
let state = derive();
|
|
916
|
+
/** Serializes this controller's own writes so each plan reads the previous one back. */
|
|
917
|
+
let tail = Promise.resolve();
|
|
918
|
+
function derive() {
|
|
919
|
+
const catalog = readCatalog(catalogScope.getSnapshot(), mirror.getSnapshot());
|
|
920
|
+
const settings = readSettings(settingsScope.getSnapshot());
|
|
921
|
+
const projection = catalog.status === "ready" ? projectQuickActions(settings.status === "ready" ? settings.settings : DEFAULT_QUICK_ACTION_SETTINGS, catalog.catalog) : void 0;
|
|
922
|
+
const stale = connectionState !== void 0 && connectionState !== "connected";
|
|
923
|
+
return {
|
|
924
|
+
catalog,
|
|
925
|
+
settings,
|
|
926
|
+
projection,
|
|
927
|
+
readOnly: !writeGate(catalog, settings, stale).ok,
|
|
928
|
+
stale,
|
|
929
|
+
manager,
|
|
930
|
+
writing: writing > 0,
|
|
931
|
+
...failure === void 0 ? {} : { failure }
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
/** Republish only on a real change, so a subscriber never re-renders for nothing. */
|
|
935
|
+
function publish() {
|
|
936
|
+
if (disposed) return;
|
|
937
|
+
const next = derive();
|
|
938
|
+
if (deepEqualJson(state, next)) return;
|
|
939
|
+
state = next;
|
|
940
|
+
for (const listener of Array.from(listeners)) listener();
|
|
941
|
+
}
|
|
942
|
+
const stopSources = [
|
|
943
|
+
mirror.subscribe(publish),
|
|
944
|
+
catalogScope.subscribe(publish),
|
|
945
|
+
settingsScope.subscribe(publish)
|
|
946
|
+
];
|
|
947
|
+
const stopConnection = options.connection.state.subscribe(() => {
|
|
948
|
+
connectionState = options.connection.state.getSnapshot();
|
|
949
|
+
publish();
|
|
950
|
+
});
|
|
951
|
+
/**
|
|
952
|
+
* Run one planner, re-minting on a taken Custom Action ID. Only a planner that
|
|
953
|
+
* mints can answer `id-in-use`, so this loop costs nothing for the others.
|
|
954
|
+
*/
|
|
955
|
+
function plan(planner, context) {
|
|
956
|
+
let outcome = planner(context);
|
|
957
|
+
for (let attempt = 1; attempt < ID_MINT_ATTEMPTS; attempt += 1) {
|
|
958
|
+
if (outcome.ok || outcome.rejection.reason !== "id-in-use") return outcome;
|
|
959
|
+
outcome = planner(context);
|
|
960
|
+
}
|
|
961
|
+
return outcome;
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* Classify one settled write against the snapshot the scope published for it.
|
|
965
|
+
*
|
|
966
|
+
* The stored state reading back as the plan is the only positive evidence of a
|
|
967
|
+
* commit. Failing that, a namespace revision that moved elsewhere is a lost
|
|
968
|
+
* fence and anything else is a Host refusal — with one bounded blind spot: if
|
|
969
|
+
* the recovery read that follows a refusal also fails, a lost fence is
|
|
970
|
+
* indistinguishable from a refusal and is reported as the latter. In that
|
|
971
|
+
* window the connection is down, so the surfaces are already read-only, and
|
|
972
|
+
* retrying against the stale fence is refused again rather than overwriting
|
|
973
|
+
* the writer that won.
|
|
974
|
+
*/
|
|
975
|
+
function classify(catalog, next, fence) {
|
|
976
|
+
const snapshot = settingsScope.getSnapshot();
|
|
977
|
+
const settings = readSettings(snapshot);
|
|
978
|
+
if (settings.status === "ready" && deepEqualJson(normalizeQuickActionSettings(settings.settings, catalog), next)) return {
|
|
979
|
+
ok: true,
|
|
980
|
+
changed: true
|
|
981
|
+
};
|
|
982
|
+
if (snapshot.revision !== fence) return {
|
|
983
|
+
ok: false,
|
|
984
|
+
failure: { kind: "conflict" }
|
|
985
|
+
};
|
|
986
|
+
return {
|
|
987
|
+
ok: false,
|
|
988
|
+
failure: { kind: "refused" }
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
function settle(outcome) {
|
|
992
|
+
failure = outcome.ok ? void 0 : outcome.failure;
|
|
993
|
+
return outcome;
|
|
994
|
+
}
|
|
995
|
+
function submit(planner) {
|
|
996
|
+
if (disposed) return Promise.resolve({
|
|
997
|
+
ok: false,
|
|
998
|
+
failure: { kind: "not-ready" }
|
|
999
|
+
});
|
|
1000
|
+
writing += 1;
|
|
1001
|
+
publish();
|
|
1002
|
+
const task = tail.then(async () => {
|
|
1003
|
+
if (disposed) return {
|
|
1004
|
+
ok: false,
|
|
1005
|
+
failure: { kind: "not-ready" }
|
|
1006
|
+
};
|
|
1007
|
+
const gate = writeGate(state.catalog, state.settings, state.stale);
|
|
1008
|
+
if (!gate.ok) return settle(gate);
|
|
1009
|
+
try {
|
|
1010
|
+
const outcome = plan(planner, gate.context);
|
|
1011
|
+
if (!outcome.ok) return settle({
|
|
1012
|
+
ok: false,
|
|
1013
|
+
failure: {
|
|
1014
|
+
kind: "rejected",
|
|
1015
|
+
rejection: outcome.rejection
|
|
1016
|
+
}
|
|
1017
|
+
});
|
|
1018
|
+
if (!outcome.plan.changed) return settle({
|
|
1019
|
+
ok: true,
|
|
1020
|
+
changed: false
|
|
1021
|
+
});
|
|
1022
|
+
await settingsScope.mutate(sectionOps(outcome.plan.next), gate.revision);
|
|
1023
|
+
return settle(classify(gate.catalog, outcome.plan.next, gate.revision));
|
|
1024
|
+
} catch (error) {
|
|
1025
|
+
await mirror.load().catch(() => void 0);
|
|
1026
|
+
return settle({
|
|
1027
|
+
ok: false,
|
|
1028
|
+
failure: {
|
|
1029
|
+
kind: "failed",
|
|
1030
|
+
message: messageOf$1(error)
|
|
1031
|
+
}
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
});
|
|
1035
|
+
tail = task.catch(() => void 0);
|
|
1036
|
+
return task.finally(() => {
|
|
1037
|
+
writing -= 1;
|
|
1038
|
+
publish();
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
return {
|
|
1042
|
+
getSnapshot: () => state,
|
|
1043
|
+
subscribe(listener) {
|
|
1044
|
+
listeners.add(listener);
|
|
1045
|
+
return () => {
|
|
1046
|
+
listeners.delete(listener);
|
|
1047
|
+
};
|
|
1048
|
+
},
|
|
1049
|
+
refresh: async () => {
|
|
1050
|
+
if (disposed) return;
|
|
1051
|
+
await mirror.load();
|
|
1052
|
+
},
|
|
1053
|
+
openManager() {
|
|
1054
|
+
manager = { open: true };
|
|
1055
|
+
publish();
|
|
1056
|
+
},
|
|
1057
|
+
closeManager() {
|
|
1058
|
+
manager = { open: false };
|
|
1059
|
+
publish();
|
|
1060
|
+
},
|
|
1061
|
+
dismissFailure() {
|
|
1062
|
+
failure = void 0;
|
|
1063
|
+
publish();
|
|
1064
|
+
},
|
|
1065
|
+
createCustomAction: (draft) => submit((context) => planCreateCustomQuickAction(context, {
|
|
1066
|
+
id: mintId(),
|
|
1067
|
+
draft
|
|
1068
|
+
})),
|
|
1069
|
+
clonePreset: (presetId) => submit((context) => planClonePresetQuickAction(context, {
|
|
1070
|
+
presetId,
|
|
1071
|
+
id: mintId()
|
|
1072
|
+
})),
|
|
1073
|
+
updateCustomAction: (id, draft) => submit((context) => planUpdateCustomQuickAction(context, {
|
|
1074
|
+
id,
|
|
1075
|
+
draft
|
|
1076
|
+
})),
|
|
1077
|
+
setCustomActionEnabled: (id, enabled) => submit((context) => planSetCustomQuickActionEnabled(context, {
|
|
1078
|
+
id,
|
|
1079
|
+
enabled
|
|
1080
|
+
})),
|
|
1081
|
+
deleteCustomAction: (id) => submit((context) => planDeleteCustomQuickAction(context, { id })),
|
|
1082
|
+
setPresetHidden: (presetId, hidden) => submit((context) => planSetPresetQuickActionHidden(context, {
|
|
1083
|
+
presetId,
|
|
1084
|
+
hidden
|
|
1085
|
+
})),
|
|
1086
|
+
reorderActions: (order) => submit((context) => planReorderQuickActions(context, { order })),
|
|
1087
|
+
moveAction: (ref, toIndex) => submit((context) => planMoveQuickAction(context, {
|
|
1088
|
+
ref,
|
|
1089
|
+
toIndex
|
|
1090
|
+
})),
|
|
1091
|
+
setLayout: (layout) => submit((context) => planSetQuickActionLayout(context, { layout })),
|
|
1092
|
+
/**
|
|
1093
|
+
* Release what this module owns. The two scope bindings are withdrawn by the
|
|
1094
|
+
* binder's own disposer on the fiber this controller was created on, so the
|
|
1095
|
+
* fiber and this call together leave nothing behind.
|
|
1096
|
+
*/
|
|
1097
|
+
dispose() {
|
|
1098
|
+
disposed = true;
|
|
1099
|
+
for (const stop of stopSources) stop();
|
|
1100
|
+
stopConnection();
|
|
1101
|
+
listeners.clear();
|
|
1102
|
+
}
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
function messageOf$1(error) {
|
|
1106
|
+
return error instanceof Error ? error.message : String(error);
|
|
1107
|
+
}
|
|
1108
|
+
//#endregion
|
|
1109
|
+
//#region src/client/session/guards.ts
|
|
1110
|
+
/**
|
|
1111
|
+
* Whether one published list field carries content, read totally.
|
|
1112
|
+
*
|
|
1113
|
+
* A field that is absent or not an array counts as content. The snapshot is the
|
|
1114
|
+
* only evidence this guard has, and DSH has renamed a field of it out from
|
|
1115
|
+
* under this plugin once already (`imageIds` → `attachmentIds` in 0.1.5-rc.1,
|
|
1116
|
+
* ticket 29). If that happens again, a Quick Action must go inert rather than
|
|
1117
|
+
* load a draft whose remaining content this guard can no longer see — an action
|
|
1118
|
+
* that fired blind would submit its own text *plus* whatever the user had
|
|
1119
|
+
* attached.
|
|
1120
|
+
*
|
|
1121
|
+
* This is not the compatibility fallback spec 21.3 rules out: no superseded
|
|
1122
|
+
* field name is ever read, and nothing degrades to a working state. The only
|
|
1123
|
+
* thing it buys is that an unreadable snapshot reports `occupied-draft` instead
|
|
1124
|
+
* of throwing through `derive → publish → observe` and taking the whole surface
|
|
1125
|
+
* down with it.
|
|
1126
|
+
*/
|
|
1127
|
+
function holdsContent(field) {
|
|
1128
|
+
if (!Array.isArray(field)) return true;
|
|
1129
|
+
return field.length > 0;
|
|
1130
|
+
}
|
|
1131
|
+
/**
|
|
1132
|
+
* Whether the draft is occupied (spec 9.2). Occupancy is any text — pure
|
|
1133
|
+
* whitespace included, so the raw string is compared against `''` and never
|
|
1134
|
+
* trimmed — any attachment, and any rich reference.
|
|
1135
|
+
*
|
|
1136
|
+
* The public snapshot exposes exactly `{ draft, attachmentIds, draftRev, phase,
|
|
1137
|
+
* claim?, occurrences, queue }`. Occupancy reads three of them:
|
|
1138
|
+
*
|
|
1139
|
+
* - `draft` — the clipboard-text projection of the whole document. Compared
|
|
1140
|
+
* against `''`, so a snapshot without it is occupied for the same reason
|
|
1141
|
+
* {@link holdsContent} gives;
|
|
1142
|
+
* - `attachmentIds` — the ordered draft attachments, and the only public
|
|
1143
|
+
* attachment field there is. Since 0.1.5-rc.1 it admits any attachment kind,
|
|
1144
|
+
* not just images;
|
|
1145
|
+
* - `occurrences` — the reference chips. They are already expanded inside
|
|
1146
|
+
* `draft`, so this test is redundant today; it is kept because a future chip
|
|
1147
|
+
* whose clipboard projection is empty must still count as content the send
|
|
1148
|
+
* action would carry away.
|
|
1149
|
+
*
|
|
1150
|
+
* The remaining fields are not occupancy: `draftRev` is a revision counter,
|
|
1151
|
+
* `phase`/`claim` are the submit plane (handled by {@link composerGate}), and
|
|
1152
|
+
* `queue` is the Session's transient inbox, which spec 9.2 explicitly allows a
|
|
1153
|
+
* send to join.
|
|
1154
|
+
*/
|
|
1155
|
+
function isOccupiedDraft(input) {
|
|
1156
|
+
return input.draft !== "" || holdsContent(input.attachmentIds) || holdsContent(input.occurrences);
|
|
1157
|
+
}
|
|
1158
|
+
/**
|
|
1159
|
+
* The composer-level guard, reproducing the shipped send button's own
|
|
1160
|
+
* conditions from public state alone (spec 9.2).
|
|
1161
|
+
*
|
|
1162
|
+
* `disabled` in spec 9.2's list is the shipped bar's inert state, which is
|
|
1163
|
+
* reached only without a Session or on the blank-session hero. Neither can
|
|
1164
|
+
* occur here: both dock Slots are session-scoped, and the Quick Action surfaces
|
|
1165
|
+
* render only where the Resident Composer predicate holds.
|
|
1166
|
+
*/
|
|
1167
|
+
function composerGate(input, session, block) {
|
|
1168
|
+
if (session.removed) return "session-removed";
|
|
1169
|
+
if (block !== void 0) return "composer-blocked";
|
|
1170
|
+
if (session.subagent?.address.mode === "continuable" && session.subagent.parentAvailable !== true) return "parent-offline";
|
|
1171
|
+
if (input.phase !== "plain") return "composer-busy";
|
|
1172
|
+
if (isOccupiedDraft(input)) return "occupied-draft";
|
|
1173
|
+
}
|
|
1174
|
+
//#endregion
|
|
1175
|
+
//#region src/client/session/execution.ts
|
|
1176
|
+
/**
|
|
1177
|
+
* The per-Session Quick Action execution layer (spec 7.2, 9.2–9.5).
|
|
1178
|
+
*
|
|
1179
|
+
* One engine per Session owns the current `InputActions`, the confirmation in
|
|
1180
|
+
* flight and the send single-flight. Nothing global holds it: the registry below
|
|
1181
|
+
* hands the same engine to whichever Slot entry is currently rendering the
|
|
1182
|
+
* layout, so switching layouts re-mounts a component without ever minting a
|
|
1183
|
+
* second lock for the same Session (spec 7.2).
|
|
1184
|
+
*
|
|
1185
|
+
* ## The single-flight window
|
|
1186
|
+
*
|
|
1187
|
+
* Spec 9.5 asks for a window that runs from the first activation until the
|
|
1188
|
+
* official submission stage ends, judged only from the published Input snapshot
|
|
1189
|
+
* `{ draft, attachmentIds, draftRev, phase, claim?, occurrences, queue }`, with the
|
|
1190
|
+
* hard acceptance criterion that it never produces a duplicate send.
|
|
1191
|
+
*
|
|
1192
|
+
* The mutex itself is this module's own: {@link QuickActionSessionEngine.activate}
|
|
1193
|
+
* claims it synchronously, before any await and before the draft is touched. It
|
|
1194
|
+
* is deliberately not derived from draft occupancy — the official default sink
|
|
1195
|
+
* clears optimistically, so a second activation one frame later would find an
|
|
1196
|
+
* empty draft and pass every precondition. That is the duplicate-send trap, and
|
|
1197
|
+
* the regression test for it pins two activations in the same tick.
|
|
1198
|
+
*
|
|
1199
|
+
* What the public snapshot is used for is deciding *when the window may close*,
|
|
1200
|
+
* and the answer differs by the path the official machine took. Both readings
|
|
1201
|
+
* come from one place: `SessionInputShell.submit()` feeds an `enter` event to a
|
|
1202
|
+
* pure submit machine and executes the resulting effects synchronously, then
|
|
1203
|
+
* publishes. So the first snapshot observed after `submit()` returns already
|
|
1204
|
+
* carries the machine's verdict.
|
|
1205
|
+
*
|
|
1206
|
+
* - **The machine kept the frozen slot** (`phase` is `adjudicating` or
|
|
1207
|
+
* `submitting`). Our attempt owns that slot exclusively — the machine refuses
|
|
1208
|
+
* another `enter` while it is held — so the phase is attributable to this
|
|
1209
|
+
* submission. The window stays open until the phase leaves those states; that
|
|
1210
|
+
* is the latest reliable public boundary of the official stage, and past it
|
|
1211
|
+
* every outcome belongs to DSH's own feedback (spec 9.5).
|
|
1212
|
+
* - **The machine committed an ordinary send** (`phase` back to `plain` and
|
|
1213
|
+
* `draft` empty). The optimistic commit is the official stage's last publicly
|
|
1214
|
+
* observable step: the default sink runs detached, and nothing about it
|
|
1215
|
+
* reaches the public snapshot. So the commit is the latest boundary
|
|
1216
|
+
* attributable to this submission, and the window closes there.
|
|
1217
|
+
* - **The machine refused the submission** (`phase` still `plain` and `draft`
|
|
1218
|
+
* still holding content). Nothing was sent; the text stays exactly as loaded
|
|
1219
|
+
* and the window closes with "not sent, text retained".
|
|
1220
|
+
*
|
|
1221
|
+
* The ordinary-send test is emptiness, not equality with the text that was
|
|
1222
|
+
* loaded: `draft` is the editor's clipboard-text projection rather than the
|
|
1223
|
+
* string handed to `setDraft`, so comparing the two would make the verdict
|
|
1224
|
+
* depend on that round trip. Emptiness needs no such assumption — the load only
|
|
1225
|
+
* ever runs against a verified-unoccupied draft, so anything in `draft` after it
|
|
1226
|
+
* is this feature's own text, and only the official commit clears it.
|
|
1227
|
+
*
|
|
1228
|
+
* The window also never closes inside the activation that opened it: settlement
|
|
1229
|
+
* is read from {@link QuickActionSessionEngine.observe}, which the owning entry
|
|
1230
|
+
* calls once per commit, so two activations in one tick can only ever produce
|
|
1231
|
+
* one send however fast the official sink empties the draft.
|
|
1232
|
+
*
|
|
1233
|
+
* Nothing here reads the DOM, Lexical, a private event or a private state, and
|
|
1234
|
+
* no DSH object is retained beyond the Session it belongs to.
|
|
1235
|
+
*/
|
|
1236
|
+
/**
|
|
1237
|
+
* The executable identity of an action: everything a re-verification must find
|
|
1238
|
+
* unchanged before the draft is touched (spec 9.3). `command` is included even
|
|
1239
|
+
* though it is derived from `text`, so a rewrite that moves the action across
|
|
1240
|
+
* the Command Send Action boundary is rejected under its own name.
|
|
1241
|
+
*/
|
|
1242
|
+
function identityOf(action) {
|
|
1243
|
+
return JSON.stringify([
|
|
1244
|
+
quickActionRefKey(action.ref),
|
|
1245
|
+
action.label,
|
|
1246
|
+
action.text,
|
|
1247
|
+
action.confirm,
|
|
1248
|
+
action.command,
|
|
1249
|
+
action.hidden
|
|
1250
|
+
]);
|
|
1251
|
+
}
|
|
1252
|
+
function messageOf(error) {
|
|
1253
|
+
return error instanceof Error ? error.message : String(error);
|
|
1254
|
+
}
|
|
1255
|
+
/** Create the execution engine for one Session. */
|
|
1256
|
+
function createQuickActionSessionEngine(sessionId) {
|
|
1257
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
1258
|
+
let disposed = false;
|
|
1259
|
+
let observed;
|
|
1260
|
+
let actions;
|
|
1261
|
+
let composer = [];
|
|
1262
|
+
let flight;
|
|
1263
|
+
let confirming;
|
|
1264
|
+
let identity;
|
|
1265
|
+
let feedback;
|
|
1266
|
+
/** True while `run` is on the stack, so no settlement can close its own flight. */
|
|
1267
|
+
let running = false;
|
|
1268
|
+
let state = derive();
|
|
1269
|
+
function derive() {
|
|
1270
|
+
return {
|
|
1271
|
+
unavailable: flight !== void 0 ? "sending" : observed === void 0 ? "composer-busy" : composerGate(observed.input, observed.session, observed.block),
|
|
1272
|
+
confirming,
|
|
1273
|
+
sending: flight !== void 0,
|
|
1274
|
+
activeRef: flight?.ref,
|
|
1275
|
+
feedback
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
function publish() {
|
|
1279
|
+
if (disposed) return;
|
|
1280
|
+
const next = derive();
|
|
1281
|
+
if (next.unavailable === state.unavailable && next.confirming === state.confirming && next.sending === state.sending && next.activeRef === state.activeRef && next.feedback === state.feedback) return;
|
|
1282
|
+
state = next;
|
|
1283
|
+
for (const listener of Array.from(listeners)) listener();
|
|
1284
|
+
}
|
|
1285
|
+
/** End the flight, optionally reporting why. */
|
|
1286
|
+
function settle(next) {
|
|
1287
|
+
flight = void 0;
|
|
1288
|
+
confirming = void 0;
|
|
1289
|
+
identity = void 0;
|
|
1290
|
+
feedback = next;
|
|
1291
|
+
publish();
|
|
1292
|
+
}
|
|
1293
|
+
/** The action as the current Composer projection carries it, if it still does. */
|
|
1294
|
+
function shownAction(ref) {
|
|
1295
|
+
const key = quickActionRefKey(ref);
|
|
1296
|
+
const found = composer.find((action) => quickActionRefKey(action.ref) === key);
|
|
1297
|
+
return found === void 0 || found.hidden ? void 0 : found;
|
|
1298
|
+
}
|
|
1299
|
+
/** Re-read everything spec 9.3 lists, immediately before the draft is touched. */
|
|
1300
|
+
function reverify(ref) {
|
|
1301
|
+
const found = shownAction(ref);
|
|
1302
|
+
if (found === void 0) return void 0;
|
|
1303
|
+
if (identity !== void 0 && identityOf(found) !== identity) return void 0;
|
|
1304
|
+
if (observed === void 0 || observed.session.sessionId !== sessionId) return void 0;
|
|
1305
|
+
if (composerGate(observed.input, observed.session, observed.block) !== void 0) return void 0;
|
|
1306
|
+
return found;
|
|
1307
|
+
}
|
|
1308
|
+
/**
|
|
1309
|
+
* Load the static text and hand it to the official submit path (spec 9.4).
|
|
1310
|
+
* The two calls are the whole write side of this feature; everything before
|
|
1311
|
+
* them is verification and everything after is observation.
|
|
1312
|
+
*/
|
|
1313
|
+
function run(action) {
|
|
1314
|
+
const face = actions;
|
|
1315
|
+
if (face === void 0 || observed === void 0) {
|
|
1316
|
+
settle({ kind: "state-changed" });
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
running = true;
|
|
1320
|
+
try {
|
|
1321
|
+
try {
|
|
1322
|
+
face.setDraft(action.text);
|
|
1323
|
+
} catch (error) {
|
|
1324
|
+
settle({
|
|
1325
|
+
kind: "failed",
|
|
1326
|
+
message: messageOf(error)
|
|
1327
|
+
});
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
flight = {
|
|
1331
|
+
stage: "submitted",
|
|
1332
|
+
ref: action.ref
|
|
1333
|
+
};
|
|
1334
|
+
confirming = void 0;
|
|
1335
|
+
try {
|
|
1336
|
+
face.submit();
|
|
1337
|
+
} catch (error) {
|
|
1338
|
+
settle({ kind: "retained" });
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
} finally {
|
|
1342
|
+
running = false;
|
|
1343
|
+
}
|
|
1344
|
+
publish();
|
|
1345
|
+
}
|
|
1346
|
+
return {
|
|
1347
|
+
getSnapshot: () => state,
|
|
1348
|
+
subscribe(listener) {
|
|
1349
|
+
listeners.add(listener);
|
|
1350
|
+
return () => {
|
|
1351
|
+
listeners.delete(listener);
|
|
1352
|
+
};
|
|
1353
|
+
},
|
|
1354
|
+
observe(input, session, block) {
|
|
1355
|
+
if (disposed) return;
|
|
1356
|
+
observed = {
|
|
1357
|
+
input,
|
|
1358
|
+
session,
|
|
1359
|
+
block
|
|
1360
|
+
};
|
|
1361
|
+
if (running) return;
|
|
1362
|
+
if (flight?.stage === "submitted") {
|
|
1363
|
+
if (input.phase === "adjudicating" || input.phase === "submitting") {
|
|
1364
|
+
flight = {
|
|
1365
|
+
stage: "official",
|
|
1366
|
+
ref: flight.ref
|
|
1367
|
+
};
|
|
1368
|
+
confirming = void 0;
|
|
1369
|
+
feedback = void 0;
|
|
1370
|
+
} else if (input.draft === "") settle(void 0);
|
|
1371
|
+
else settle({ kind: "retained" });
|
|
1372
|
+
} else if (flight?.stage === "official" && input.phase !== "adjudicating" && input.phase !== "submitting") settle(void 0);
|
|
1373
|
+
publish();
|
|
1374
|
+
},
|
|
1375
|
+
bind(face, projection) {
|
|
1376
|
+
if (disposed) return;
|
|
1377
|
+
actions = face;
|
|
1378
|
+
composer = projection;
|
|
1379
|
+
if (confirming !== void 0 && shownAction(confirming.ref) === void 0) settle(void 0);
|
|
1380
|
+
publish();
|
|
1381
|
+
},
|
|
1382
|
+
activate(action) {
|
|
1383
|
+
if (disposed) return;
|
|
1384
|
+
if (flight !== void 0) return;
|
|
1385
|
+
if (observed === void 0 || observed.session.sessionId !== sessionId) {
|
|
1386
|
+
settle({ kind: "state-changed" });
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
if (shownAction(action.ref) === void 0 || composerGate(observed.input, observed.session, observed.block) !== void 0) {
|
|
1390
|
+
settle({ kind: "state-changed" });
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
identity = identityOf(action);
|
|
1394
|
+
feedback = void 0;
|
|
1395
|
+
flight = {
|
|
1396
|
+
stage: "pending",
|
|
1397
|
+
ref: action.ref
|
|
1398
|
+
};
|
|
1399
|
+
if (action.confirm) {
|
|
1400
|
+
confirming = {
|
|
1401
|
+
ref: action.ref,
|
|
1402
|
+
label: action.label,
|
|
1403
|
+
text: action.text,
|
|
1404
|
+
command: action.command
|
|
1405
|
+
};
|
|
1406
|
+
publish();
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
const fresh = reverify(action.ref);
|
|
1410
|
+
if (fresh === void 0) {
|
|
1411
|
+
settle({ kind: "state-changed" });
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
run(fresh);
|
|
1415
|
+
},
|
|
1416
|
+
confirm() {
|
|
1417
|
+
if (disposed || flight?.stage !== "pending" || confirming === void 0) return;
|
|
1418
|
+
const fresh = reverify(flight.ref);
|
|
1419
|
+
if (fresh === void 0) {
|
|
1420
|
+
settle({ kind: "state-changed" });
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
run(fresh);
|
|
1424
|
+
},
|
|
1425
|
+
cancel() {
|
|
1426
|
+
if (disposed || flight?.stage !== "pending" || confirming === void 0) return;
|
|
1427
|
+
settle(void 0);
|
|
1428
|
+
},
|
|
1429
|
+
dismissFeedback() {
|
|
1430
|
+
if (disposed || feedback === void 0) return;
|
|
1431
|
+
feedback = void 0;
|
|
1432
|
+
publish();
|
|
1433
|
+
},
|
|
1434
|
+
cancelPending() {
|
|
1435
|
+
if (disposed || flight === void 0) return;
|
|
1436
|
+
if (flight.stage !== "pending") return;
|
|
1437
|
+
settle(void 0);
|
|
1438
|
+
},
|
|
1439
|
+
dispose() {
|
|
1440
|
+
disposed = true;
|
|
1441
|
+
listeners.clear();
|
|
1442
|
+
actions = void 0;
|
|
1443
|
+
observed = void 0;
|
|
1444
|
+
composer = [];
|
|
1445
|
+
}
|
|
1446
|
+
};
|
|
1447
|
+
}
|
|
1448
|
+
function createQuickActionSessionRegistry() {
|
|
1449
|
+
const engines = /* @__PURE__ */ new Map();
|
|
1450
|
+
return {
|
|
1451
|
+
engineFor(sessionId) {
|
|
1452
|
+
let engine = engines.get(sessionId);
|
|
1453
|
+
if (engine === void 0) {
|
|
1454
|
+
engine = createQuickActionSessionEngine(sessionId);
|
|
1455
|
+
engines.set(sessionId, engine);
|
|
1456
|
+
}
|
|
1457
|
+
return engine;
|
|
1458
|
+
},
|
|
1459
|
+
dispose() {
|
|
1460
|
+
for (const engine of engines.values()) {
|
|
1461
|
+
engine.cancelPending();
|
|
1462
|
+
engine.dispose();
|
|
1463
|
+
}
|
|
1464
|
+
engines.clear();
|
|
1465
|
+
}
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1468
|
+
//#endregion
|
|
1469
|
+
//#region src/client/surfaces/residency.ts
|
|
1470
|
+
function createResidentComposerRegistry() {
|
|
1471
|
+
const marks = /* @__PURE__ */ new Map();
|
|
1472
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
1473
|
+
function publish() {
|
|
1474
|
+
for (const listener of Array.from(listeners)) listener();
|
|
1475
|
+
}
|
|
1476
|
+
return {
|
|
1477
|
+
mark(sessionId) {
|
|
1478
|
+
marks.set(sessionId, (marks.get(sessionId) ?? 0) + 1);
|
|
1479
|
+
publish();
|
|
1480
|
+
let withdrawn = false;
|
|
1481
|
+
return () => {
|
|
1482
|
+
if (withdrawn) return;
|
|
1483
|
+
withdrawn = true;
|
|
1484
|
+
const held = (marks.get(sessionId) ?? 1) - 1;
|
|
1485
|
+
if (held > 0) marks.set(sessionId, held);
|
|
1486
|
+
else marks.delete(sessionId);
|
|
1487
|
+
publish();
|
|
1488
|
+
};
|
|
1489
|
+
},
|
|
1490
|
+
isResident: (sessionId) => marks.has(sessionId),
|
|
1491
|
+
primarySessionId: () => marks.keys().next().value,
|
|
1492
|
+
subscribe(listener) {
|
|
1493
|
+
listeners.add(listener);
|
|
1494
|
+
return () => {
|
|
1495
|
+
listeners.delete(listener);
|
|
1496
|
+
};
|
|
1497
|
+
}
|
|
1498
|
+
};
|
|
1499
|
+
}
|
|
1500
|
+
//#endregion
|
|
1501
|
+
//#region src/client/surfaces/ErrorBoundary.tsx
|
|
1502
|
+
/**
|
|
1503
|
+
* One Slot entry's local error boundary (spec 7.3).
|
|
1504
|
+
*
|
|
1505
|
+
* A render error inside the Quick Action surfaces replaces the Quick Action
|
|
1506
|
+
* area and nothing else: the Composer, its draft and its submit button keep
|
|
1507
|
+
* working, and the user can ask for the surface back. Retrying remounts the
|
|
1508
|
+
* subtree by changing its key, so a transient failure clears without a reload.
|
|
1509
|
+
*
|
|
1510
|
+
* DSH's own submission errors never reach here — they are reported by the
|
|
1511
|
+
* Composer, and this feature must not repeat them (spec 9.5).
|
|
1512
|
+
*/
|
|
1513
|
+
var SurfaceErrorBoundary = class extends react.Component {
|
|
1514
|
+
state = {
|
|
1515
|
+
failed: false,
|
|
1516
|
+
attempt: 0
|
|
1517
|
+
};
|
|
1518
|
+
static getDerivedStateFromError() {
|
|
1519
|
+
return { failed: true };
|
|
1520
|
+
}
|
|
1521
|
+
componentDidCatch(error, info) {
|
|
1522
|
+
console.error("[composer-quick-actions] surface failed", error, info.componentStack);
|
|
1523
|
+
}
|
|
1524
|
+
retry = () => {
|
|
1525
|
+
this.setState((current) => ({
|
|
1526
|
+
failed: false,
|
|
1527
|
+
attempt: current.attempt + 1
|
|
1528
|
+
}));
|
|
1529
|
+
};
|
|
1530
|
+
render() {
|
|
1531
|
+
const { t, children } = this.props;
|
|
1532
|
+
if (!this.state.failed) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react.Fragment, { children }, this.state.attempt);
|
|
1533
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1534
|
+
className: "dsh-cqa-note",
|
|
1535
|
+
role: "status",
|
|
1536
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1537
|
+
className: "dsh-cqa-note-text",
|
|
1538
|
+
children: t("crash.title")
|
|
1539
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1540
|
+
type: "button",
|
|
1541
|
+
className: "dsh-cqa-link",
|
|
1542
|
+
onClick: this.retry,
|
|
1543
|
+
children: t("crash.retry")
|
|
1544
|
+
})]
|
|
1545
|
+
});
|
|
1546
|
+
}
|
|
1547
|
+
};
|
|
1548
|
+
//#endregion
|
|
1549
|
+
//#region src/client/surfaces/ActionFace.tsx
|
|
1550
|
+
function ActionFace({ action, t }) {
|
|
1551
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
1552
|
+
action.icon === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1553
|
+
className: "dsh-cqa-icon",
|
|
1554
|
+
"aria-hidden": "true",
|
|
1555
|
+
children: action.icon
|
|
1556
|
+
}),
|
|
1557
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1558
|
+
className: "dsh-cqa-label",
|
|
1559
|
+
children: action.label
|
|
1560
|
+
}),
|
|
1561
|
+
action.command ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Pill, {
|
|
1562
|
+
className: "dsh-cqa-badge",
|
|
1563
|
+
children: t("command.badge")
|
|
1564
|
+
}) : null
|
|
1565
|
+
] });
|
|
1566
|
+
}
|
|
1567
|
+
function densityFor(width) {
|
|
1568
|
+
return width > 0 && width < 480 ? "narrow" : "wide";
|
|
1569
|
+
}
|
|
1570
|
+
/**
|
|
1571
|
+
* How many leading actions the `bar` layout shows (spec 8.1). The rest fold into
|
|
1572
|
+
* the shared action panel behind "more", and the management entry stays visible
|
|
1573
|
+
* whatever the answer is.
|
|
1574
|
+
*
|
|
1575
|
+
* Before the first measurement — a fresh mount, or a headless environment with
|
|
1576
|
+
* no layout — every width reads as zero. That answers "all of them": showing the
|
|
1577
|
+
* full row and letting the browser's own overflow handle it for one frame is
|
|
1578
|
+
* better than flashing a "more" button that the next frame withdraws.
|
|
1579
|
+
*/
|
|
1580
|
+
function fitActionCount(input) {
|
|
1581
|
+
const { available, widths, reserved, gap } = input;
|
|
1582
|
+
if (widths.length === 0) return 0;
|
|
1583
|
+
if (available <= 0 || widths.every((width) => width <= 0)) return widths.length;
|
|
1584
|
+
let used = reserved;
|
|
1585
|
+
let shown = 0;
|
|
1586
|
+
for (const width of widths) {
|
|
1587
|
+
const next = used + width + (shown === 0 && reserved === 0 ? 0 : gap);
|
|
1588
|
+
if (next > available) break;
|
|
1589
|
+
used = next;
|
|
1590
|
+
shown += 1;
|
|
1591
|
+
}
|
|
1592
|
+
return shown;
|
|
1593
|
+
}
|
|
1594
|
+
//#endregion
|
|
1595
|
+
//#region src/client/manager/search.ts
|
|
1596
|
+
/** The fields a query is compared against. */
|
|
1597
|
+
const SEARCH_FIELDS = ["label", "text"];
|
|
1598
|
+
/**
|
|
1599
|
+
* The one normalization both sides of a comparison go through. Applying it to
|
|
1600
|
+
* the query and to the haystack through the same function is what keeps the two
|
|
1601
|
+
* from drifting apart.
|
|
1602
|
+
*/
|
|
1603
|
+
function normalizeQuickActionSearchText(value) {
|
|
1604
|
+
return value.normalize("NFKC").toLowerCase().replace(/\s+/gu, " ").trim();
|
|
1605
|
+
}
|
|
1606
|
+
/**
|
|
1607
|
+
* Whether a query asks for anything at all. A whitespace-only box is an empty
|
|
1608
|
+
* one, and telling the two apart is what lets a panel say "nothing matched"
|
|
1609
|
+
* rather than "nothing to run".
|
|
1610
|
+
*/
|
|
1611
|
+
function hasQuickActionQuery(query) {
|
|
1612
|
+
return normalizeQuickActionSearchText(query) !== "";
|
|
1613
|
+
}
|
|
1614
|
+
/**
|
|
1615
|
+
* The actions matching `query`, in their original relative order.
|
|
1616
|
+
*
|
|
1617
|
+
* An empty or whitespace-only query hands the same array back by identity: an
|
|
1618
|
+
* unsearched panel is not a filtered panel, and returning a copy would remount
|
|
1619
|
+
* every row for nothing.
|
|
1620
|
+
*/
|
|
1621
|
+
function filterQuickActions(actions, query) {
|
|
1622
|
+
const needle = normalizeQuickActionSearchText(query);
|
|
1623
|
+
if (needle === "") return actions;
|
|
1624
|
+
return actions.filter((action) => SEARCH_FIELDS.some((field) => normalizeQuickActionSearchText(action[field]).includes(needle)));
|
|
1625
|
+
}
|
|
1626
|
+
//#endregion
|
|
1627
|
+
//#region src/client/modal.ts
|
|
1628
|
+
/**
|
|
1629
|
+
* The modal semantics every Quick Action panel shares (spec 8.4).
|
|
1630
|
+
*
|
|
1631
|
+
* Spec 8.4 makes Escape cancellation, a clear focus ring, keyboard traversal and
|
|
1632
|
+
* focus return after a panel closes hard gates. A Slot entry renders in place,
|
|
1633
|
+
* inside the composer stack, so without a Tab boundary a keyboard user would tab
|
|
1634
|
+
* straight out of an open panel into the draft behind it, and every route back
|
|
1635
|
+
* into the panel would take the Escape handler with it.
|
|
1636
|
+
*
|
|
1637
|
+
* That holds for the management overlay too, even though ticket 26 moved it onto
|
|
1638
|
+
* `document.body` through a portal: a portal relocates the DOM node, not the
|
|
1639
|
+
* React tree, so its events still bubble to the dock and its Tab order still
|
|
1640
|
+
* follows document order — which is now the end of the body, further from the
|
|
1641
|
+
* draft rather than nearer it. Both hooks below are unaffected either way,
|
|
1642
|
+
* because both work off the panel element they are handed.
|
|
1643
|
+
*
|
|
1644
|
+
* This module is a leaf beside `dsh.ts`, owned by no directory of spec 14's
|
|
1645
|
+
* source map: the confirmation panel (`session/`), the action panel and the
|
|
1646
|
+
* management panel (`manager/`) all need it, and putting it inside any one of
|
|
1647
|
+
* them would make another depend on that one's directory for something that is
|
|
1648
|
+
* neither an action nor an execution concern.
|
|
1649
|
+
*
|
|
1650
|
+
* Both hooks are deliberately tiny and DOM-only: they read `document.activeElement`
|
|
1651
|
+
* and call `focus()`, which is the browser's own focus contract, not DSH's. No
|
|
1652
|
+
* Composer state, private event or Lexical path is touched (spec 9.1).
|
|
1653
|
+
*/
|
|
1654
|
+
/**
|
|
1655
|
+
* What Tab may reach inside a panel. Disabled controls are excluded, which is
|
|
1656
|
+
* what keeps the boundary correct while a write is in flight and half the
|
|
1657
|
+
* panel's buttons are inert.
|
|
1658
|
+
*/
|
|
1659
|
+
const FOCUSABLE_SELECTOR = [
|
|
1660
|
+
"a[href]",
|
|
1661
|
+
"button:not([disabled])",
|
|
1662
|
+
"input:not([disabled])",
|
|
1663
|
+
"select:not([disabled])",
|
|
1664
|
+
"textarea:not([disabled])",
|
|
1665
|
+
"[tabindex]:not([tabindex=\"-1\"])"
|
|
1666
|
+
].join(",");
|
|
1667
|
+
/**
|
|
1668
|
+
* Escape cancels, and Tab stays inside.
|
|
1669
|
+
*
|
|
1670
|
+
* The focusable set is recomputed on every keystroke rather than cached: a
|
|
1671
|
+
* management panel grows a form, a form's Save button goes inert while a write
|
|
1672
|
+
* is in flight, and a stale boundary would trap focus on a control that is no
|
|
1673
|
+
* longer there.
|
|
1674
|
+
*/
|
|
1675
|
+
function useModalKeys(onCancel) {
|
|
1676
|
+
const panelRef = (0, react.useRef)(null);
|
|
1677
|
+
return {
|
|
1678
|
+
panelRef,
|
|
1679
|
+
onKeyDown: (0, react.useCallback)((event) => {
|
|
1680
|
+
if (event.key === "Escape") {
|
|
1681
|
+
event.stopPropagation();
|
|
1682
|
+
onCancel();
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
if (event.key !== "Tab") return;
|
|
1686
|
+
const focusable = Array.from(panelRef.current?.querySelectorAll(FOCUSABLE_SELECTOR) ?? []);
|
|
1687
|
+
const first = focusable[0];
|
|
1688
|
+
const last = focusable[focusable.length - 1];
|
|
1689
|
+
if (first === void 0 || last === void 0) return;
|
|
1690
|
+
const edge = event.shiftKey ? first : last;
|
|
1691
|
+
if (event.target !== edge) return;
|
|
1692
|
+
event.preventDefault();
|
|
1693
|
+
(event.shiftKey ? last : first).focus();
|
|
1694
|
+
}, [onCancel])
|
|
1695
|
+
};
|
|
1696
|
+
}
|
|
1697
|
+
/**
|
|
1698
|
+
* Return focus to whatever opened this panel, when it closes (spec 8.4).
|
|
1699
|
+
*
|
|
1700
|
+
* The opener is captured in a layout effect, so it must be declared *before* any
|
|
1701
|
+
* effect that moves focus into the panel: effects run in declaration order
|
|
1702
|
+
* within a component, and layout effects run before passive ones, so the capture
|
|
1703
|
+
* always sees the control the user actually activated.
|
|
1704
|
+
*
|
|
1705
|
+
* An opener that has since been unmounted — an action picked from a list that
|
|
1706
|
+
* closed with it — is skipped rather than focused, and the caller is free to
|
|
1707
|
+
* offer a fallback of its own.
|
|
1708
|
+
*
|
|
1709
|
+
* @param scope - the closing context's own element, for an editing context
|
|
1710
|
+
* nested inside a panel (the management form inside the management panel).
|
|
1711
|
+
* When the outer panel closes, React runs the outer cleanup first, the inner
|
|
1712
|
+
* cleanup next, and only then removes the outer DOM: by the time the nested
|
|
1713
|
+
* context returns focus, the panel has already handed it to *its* opener, and
|
|
1714
|
+
* the nested opener is a control about to leave the document. So a nested
|
|
1715
|
+
* context returns focus only while focus is still inside it (or fell to the
|
|
1716
|
+
* body); focus that has already left belongs to whoever moved it.
|
|
1717
|
+
*/
|
|
1718
|
+
function useFocusReturn(scope) {
|
|
1719
|
+
const opener = (0, react.useRef)(null);
|
|
1720
|
+
(0, react.useLayoutEffect)(() => {
|
|
1721
|
+
const active = typeof document === "undefined" ? null : document.activeElement;
|
|
1722
|
+
opener.current = active === document.body ? null : active;
|
|
1723
|
+
return () => {
|
|
1724
|
+
const element = opener.current;
|
|
1725
|
+
opener.current = null;
|
|
1726
|
+
if (element === null || !element.isConnected) return;
|
|
1727
|
+
const current = document.activeElement;
|
|
1728
|
+
if (scope?.current != null && current !== null && current !== document.body && !scope.current.contains(current)) return;
|
|
1729
|
+
element.focus();
|
|
1730
|
+
};
|
|
1731
|
+
}, []);
|
|
1732
|
+
}
|
|
1733
|
+
/**
|
|
1734
|
+
* Move focus into a panel once, on open, addressing the target through the
|
|
1735
|
+
* panel rather than through a ref (spec 8.4).
|
|
1736
|
+
*
|
|
1737
|
+
* `@deepseek-ai/dsh-client-ui-primitives` publishes no `forwardRef` at all, so
|
|
1738
|
+
* an official `Button` or `Input` cannot carry one. Addressing the opening
|
|
1739
|
+
* target by marker attribute — the way the Tab boundary above already addresses
|
|
1740
|
+
* the focusable set — keeps that limitation in this one module instead of
|
|
1741
|
+
* pushing every panel back onto native controls.
|
|
1742
|
+
*
|
|
1743
|
+
* @param container - the panel whose subtree holds the target.
|
|
1744
|
+
* @param selector - CSS selector for the control that should open focused.
|
|
1745
|
+
*/
|
|
1746
|
+
function useInitialFocusIn(container, selector) {
|
|
1747
|
+
(0, react.useEffect)(() => {
|
|
1748
|
+
container.current?.querySelector(selector)?.focus();
|
|
1749
|
+
}, [container, selector]);
|
|
1750
|
+
}
|
|
1751
|
+
//#endregion
|
|
1752
|
+
//#region src/client/session/availability.ts
|
|
1753
|
+
/**
|
|
1754
|
+
* The reason this action cannot run right now, or `undefined` when it can.
|
|
1755
|
+
*
|
|
1756
|
+
* While a send holds the Session's single flight, only the action holding it
|
|
1757
|
+
* explains itself as "sending"; the rest are simply unavailable while the
|
|
1758
|
+
* Composer is busy with it (spec 9.5).
|
|
1759
|
+
*/
|
|
1760
|
+
function unavailableReasonFor(action, session) {
|
|
1761
|
+
if (session.unavailable === "sending") return session.activeRef !== void 0 && quickActionRefKey(session.activeRef) === quickActionRefKey(action.ref) ? "sending" : "composer-busy";
|
|
1762
|
+
return session.unavailable;
|
|
1763
|
+
}
|
|
1764
|
+
//#endregion
|
|
1765
|
+
//#region src/client/manager/ActionPanel.tsx
|
|
1766
|
+
/**
|
|
1767
|
+
* The shared searchable action panel (spec 8.1).
|
|
1768
|
+
*
|
|
1769
|
+
* One panel serves both entries that need a list rather than a row: the `bar`
|
|
1770
|
+
* layout's "more" overflow and the `launcher` layout's single entry. Sharing it
|
|
1771
|
+
* is a spec requirement, not a convenience — the two entries must not drift into
|
|
1772
|
+
* offering different search behaviour or different keyboard handling.
|
|
1773
|
+
*
|
|
1774
|
+
* The search itself is `./search.js`: it really filters, it keeps the matches in
|
|
1775
|
+
* their `actionOrder` relative order, and its field, case and Unicode policy is
|
|
1776
|
+
* stated and pinned there. The panel adds only the UI around it — a search field
|
|
1777
|
+
* with a visible text label, the same disabled-with-a-reason projection the
|
|
1778
|
+
* layouts use (spec 3), and the modal semantics of spec 8.4.
|
|
1779
|
+
*
|
|
1780
|
+
* Nothing here executes anything: activation is handed straight to the caller's
|
|
1781
|
+
* per-Session engine, which owns the single flight and the final re-verification
|
|
1782
|
+
* (spec 9.3, 9.5). The panel never mints a second lock.
|
|
1783
|
+
*/
|
|
1784
|
+
function ActionPanel(props) {
|
|
1785
|
+
const { actions, session, t, labelledBy, onActivate, onClose } = props;
|
|
1786
|
+
const [query, setQuery] = (0, react.useState)("");
|
|
1787
|
+
const { panelRef, onKeyDown } = useModalKeys(onClose);
|
|
1788
|
+
useInitialFocusIn(panelRef, "[data-quick-actions-search]");
|
|
1789
|
+
const matches = filterQuickActions(actions, query);
|
|
1790
|
+
const emptyKey = hasQuickActionQuery(query) ? "panel.search.empty" : "empty";
|
|
1791
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1792
|
+
className: "dsh-cqa-backdrop",
|
|
1793
|
+
"data-quick-actions-backdrop": "",
|
|
1794
|
+
"aria-hidden": "true",
|
|
1795
|
+
onClick: onClose
|
|
1796
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1797
|
+
className: "dsh-cqa-panel",
|
|
1798
|
+
ref: panelRef,
|
|
1799
|
+
role: "dialog",
|
|
1800
|
+
"aria-modal": "true",
|
|
1801
|
+
"aria-labelledby": labelledBy,
|
|
1802
|
+
"data-quick-actions-panel": "",
|
|
1803
|
+
onKeyDown,
|
|
1804
|
+
children: [
|
|
1805
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1806
|
+
className: "dsh-cqa-panel-head",
|
|
1807
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1808
|
+
className: "dsh-cqa-panel-title",
|
|
1809
|
+
id: labelledBy,
|
|
1810
|
+
children: t("panel.title")
|
|
1811
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1812
|
+
type: "button",
|
|
1813
|
+
className: "dsh-cqa-link",
|
|
1814
|
+
"data-quick-actions-panel-close": "",
|
|
1815
|
+
onClick: onClose,
|
|
1816
|
+
children: t("panel.close")
|
|
1817
|
+
})]
|
|
1818
|
+
}),
|
|
1819
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
1820
|
+
className: "dsh-cqa-field",
|
|
1821
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1822
|
+
className: "dsh-cqa-field-label",
|
|
1823
|
+
children: t("panel.search")
|
|
1824
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Input, {
|
|
1825
|
+
icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconSearchOutline16, {}),
|
|
1826
|
+
type: "search",
|
|
1827
|
+
value: query,
|
|
1828
|
+
"data-quick-actions-search": "",
|
|
1829
|
+
onChange: (event) => {
|
|
1830
|
+
setQuery(event.target.value);
|
|
1831
|
+
}
|
|
1832
|
+
})]
|
|
1833
|
+
}),
|
|
1834
|
+
matches.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1835
|
+
className: "dsh-cqa-panel-title",
|
|
1836
|
+
children: t(emptyKey)
|
|
1837
|
+
}) : null,
|
|
1838
|
+
matches.map((action) => {
|
|
1839
|
+
const reason = unavailableReasonFor(action, session);
|
|
1840
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1841
|
+
type: "button",
|
|
1842
|
+
className: "dsh-cqa-panel-item",
|
|
1843
|
+
"data-quick-action": quickActionRefKey(action.ref),
|
|
1844
|
+
disabled: reason !== void 0,
|
|
1845
|
+
title: reason === void 0 ? action.text : t(`unavailable.${reason}`),
|
|
1846
|
+
onClick: () => {
|
|
1847
|
+
onActivate(action);
|
|
1848
|
+
onClose();
|
|
1849
|
+
},
|
|
1850
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ActionFace, {
|
|
1851
|
+
action,
|
|
1852
|
+
t
|
|
1853
|
+
})
|
|
1854
|
+
}, quickActionRefKey(action.ref));
|
|
1855
|
+
})
|
|
1856
|
+
]
|
|
1857
|
+
})] });
|
|
1858
|
+
}
|
|
1859
|
+
//#endregion
|
|
1860
|
+
//#region src/client/session/ConfirmPanel.tsx
|
|
1861
|
+
function ConfirmPanel({ pending, t, onConfirm, onCancel }) {
|
|
1862
|
+
const { panelRef, onKeyDown } = useModalKeys(onCancel);
|
|
1863
|
+
useInitialFocusIn(panelRef, "[data-quick-actions-confirm-send]");
|
|
1864
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1865
|
+
className: "dsh-cqa-backdrop",
|
|
1866
|
+
"data-quick-actions-backdrop": "",
|
|
1867
|
+
"aria-hidden": "true",
|
|
1868
|
+
onClick: onCancel
|
|
1869
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1870
|
+
className: "dsh-cqa-panel",
|
|
1871
|
+
ref: panelRef,
|
|
1872
|
+
role: "dialog",
|
|
1873
|
+
"aria-modal": "true",
|
|
1874
|
+
"aria-label": t("confirm.title"),
|
|
1875
|
+
"data-quick-actions-confirm": "",
|
|
1876
|
+
onKeyDown,
|
|
1877
|
+
children: [
|
|
1878
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1879
|
+
className: "dsh-cqa-panel-title",
|
|
1880
|
+
children: pending.label
|
|
1881
|
+
}),
|
|
1882
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1883
|
+
className: "dsh-cqa-confirm-text",
|
|
1884
|
+
children: pending.text
|
|
1885
|
+
}),
|
|
1886
|
+
pending.command ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1887
|
+
className: "dsh-cqa-note",
|
|
1888
|
+
"data-quick-actions-command-notice": "",
|
|
1889
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1890
|
+
className: "dsh-cqa-note-text",
|
|
1891
|
+
children: t("confirm.command")
|
|
1892
|
+
})
|
|
1893
|
+
}) : null,
|
|
1894
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1895
|
+
className: "dsh-cqa-confirm-actions",
|
|
1896
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
1897
|
+
variant: "toolbar",
|
|
1898
|
+
size: "sm",
|
|
1899
|
+
className: "dsh-cqa-entry",
|
|
1900
|
+
onClick: onCancel,
|
|
1901
|
+
children: t("confirm.cancel")
|
|
1902
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
1903
|
+
variant: "toolbar",
|
|
1904
|
+
size: "sm",
|
|
1905
|
+
className: "dsh-cqa-entry",
|
|
1906
|
+
"data-quick-actions-confirm-send": "",
|
|
1907
|
+
onClick: onConfirm,
|
|
1908
|
+
children: t("confirm.send")
|
|
1909
|
+
})]
|
|
1910
|
+
})
|
|
1911
|
+
]
|
|
1912
|
+
})] });
|
|
1913
|
+
}
|
|
1914
|
+
//#endregion
|
|
1915
|
+
//#region src/client/surfaces/QuickActionsSurface.tsx
|
|
1916
|
+
/**
|
|
1917
|
+
* The three Quick Action Layouts and the control they share (spec 8.1, 8.2).
|
|
1918
|
+
*
|
|
1919
|
+
* | layout | Slot | body |
|
|
1920
|
+
* |------------|-----------------------------|-------------------------------------------------------------|
|
|
1921
|
+
* | `ribbon` | `conversation.input.dock` | title, the actions in shared order, and "manage" on one line |
|
|
1922
|
+
* | `bar` | `conversation.composer.dock`| the leading actions that fit, then "more", then "manage" |
|
|
1923
|
+
* | `launcher` | `conversation.input.dock` | one compact entry carrying the Composer projection's count |
|
|
1924
|
+
*
|
|
1925
|
+
* Three rules hold across all of them:
|
|
1926
|
+
*
|
|
1927
|
+
* - the management entry is always rendered, so hiding or disabling every action
|
|
1928
|
+
* still leaves a compact, operable entry rather than an empty strip;
|
|
1929
|
+
* - controls keep a visible text label at every width — a narrow surface drops
|
|
1930
|
+
* the section title and tightens spacing, and nothing else (spec 8.2, 8.4);
|
|
1931
|
+
* - an action that cannot run right now is shown disabled with its reason, never
|
|
1932
|
+
* removed. Removal is the Hidden Quick Action projection, and that is the
|
|
1933
|
+
* management panel's business (spec 3).
|
|
1934
|
+
*/
|
|
1935
|
+
/** Gap between adjacent controls; mirrors `--gap` in the stylesheet. */
|
|
1936
|
+
const CONTROL_GAP = 8;
|
|
1937
|
+
function ActionControl(props) {
|
|
1938
|
+
const { action, reason, t, onActivate } = props;
|
|
1939
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
1940
|
+
variant: "toolbar",
|
|
1941
|
+
size: "sm",
|
|
1942
|
+
className: "dsh-cqa-action",
|
|
1943
|
+
"data-quick-action": quickActionRefKey(action.ref),
|
|
1944
|
+
disabled: reason !== void 0,
|
|
1945
|
+
title: reason === void 0 ? action.text : t(`unavailable.${reason}`),
|
|
1946
|
+
onClick: () => {
|
|
1947
|
+
onActivate(action);
|
|
1948
|
+
},
|
|
1949
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ActionFace, {
|
|
1950
|
+
action,
|
|
1951
|
+
t
|
|
1952
|
+
})
|
|
1953
|
+
});
|
|
1954
|
+
}
|
|
1955
|
+
/**
|
|
1956
|
+
* Measure one element's inner width, republishing on every resize. Used twice:
|
|
1957
|
+
* on the whole row for the density rule, and on the action region alone for the
|
|
1958
|
+
* bar's overflow split — the region is `flex: 1 1 auto`, so its width is already
|
|
1959
|
+
* what is left after the controls that must stay visible.
|
|
1960
|
+
*/
|
|
1961
|
+
function useElementWidth() {
|
|
1962
|
+
const ref = (0, react.useRef)(null);
|
|
1963
|
+
const [width, setWidth] = (0, react.useState)(0);
|
|
1964
|
+
(0, react.useEffect)(() => {
|
|
1965
|
+
const element = ref.current;
|
|
1966
|
+
if (element === null || typeof ResizeObserver === "undefined") return;
|
|
1967
|
+
const observer = new ResizeObserver(() => {
|
|
1968
|
+
setWidth(element.clientWidth);
|
|
1969
|
+
});
|
|
1970
|
+
observer.observe(element);
|
|
1971
|
+
setWidth(element.clientWidth);
|
|
1972
|
+
return () => {
|
|
1973
|
+
observer.disconnect();
|
|
1974
|
+
};
|
|
1975
|
+
}, []);
|
|
1976
|
+
return [ref, width];
|
|
1977
|
+
}
|
|
1978
|
+
function QuickActionsSurface(props) {
|
|
1979
|
+
const { layout, actions, session, t, onActivate, onConfirm, onCancelConfirm, onDismissFeedback, onManage } = props;
|
|
1980
|
+
const [rowRef, width] = useElementWidth();
|
|
1981
|
+
const [fitRef, fitWidth] = useElementWidth();
|
|
1982
|
+
const density = densityFor(width);
|
|
1983
|
+
const [panelOpen, setPanelOpen] = (0, react.useState)(false);
|
|
1984
|
+
const panelTitleId = (0, react.useId)();
|
|
1985
|
+
const widths = (0, react.useRef)(/* @__PURE__ */ new Map());
|
|
1986
|
+
const [measured, setMeasured] = (0, react.useState)(0);
|
|
1987
|
+
(0, react.useLayoutEffect)(() => {
|
|
1988
|
+
if (layout !== "bar") return;
|
|
1989
|
+
let changed = false;
|
|
1990
|
+
for (const node of Array.from(fitRef.current?.querySelectorAll("[data-quick-action]") ?? [])) {
|
|
1991
|
+
const key = node.dataset["quickAction"];
|
|
1992
|
+
const value = node.offsetWidth;
|
|
1993
|
+
if (key === void 0 || value <= 0 || widths.current.get(key) === value) continue;
|
|
1994
|
+
widths.current.set(key, value);
|
|
1995
|
+
changed = true;
|
|
1996
|
+
}
|
|
1997
|
+
if (changed) setMeasured((seen) => seen + 1);
|
|
1998
|
+
});
|
|
1999
|
+
const shown = (0, react.useMemo)(() => {
|
|
2000
|
+
if (layout !== "bar") return actions.length;
|
|
2001
|
+
const known = actions.map((action) => quickActionRefKey(action.ref)).map((key) => widths.current.get(key) ?? 0);
|
|
2002
|
+
if (known.some((value) => value <= 0)) return actions.length;
|
|
2003
|
+
return fitActionCount({
|
|
2004
|
+
available: fitWidth,
|
|
2005
|
+
widths: known,
|
|
2006
|
+
reserved: 0,
|
|
2007
|
+
gap: CONTROL_GAP
|
|
2008
|
+
});
|
|
2009
|
+
}, [
|
|
2010
|
+
layout,
|
|
2011
|
+
actions,
|
|
2012
|
+
fitWidth,
|
|
2013
|
+
measured
|
|
2014
|
+
]);
|
|
2015
|
+
const overflow = layout === "bar" ? actions.slice(shown) : actions;
|
|
2016
|
+
/**
|
|
2017
|
+
* Resolve one of this surface's own controls by its data marker.
|
|
2018
|
+
*
|
|
2019
|
+
* `Button` takes no ref, so the focus contract of spec 8.4 addresses controls
|
|
2020
|
+
* the way the stylesheet does: through a marker attribute, queried from the
|
|
2021
|
+
* row this surface already measures. The selector and the attribute that
|
|
2022
|
+
* answers it live in this one file, so the coupling stays local.
|
|
2023
|
+
*/
|
|
2024
|
+
const control = (0, react.useCallback)((marker) => {
|
|
2025
|
+
return rowRef.current?.querySelector(`[${marker}]`) ?? null;
|
|
2026
|
+
}, [rowRef]);
|
|
2027
|
+
const closePanel = (0, react.useCallback)(() => {
|
|
2028
|
+
setPanelOpen(false);
|
|
2029
|
+
control("data-quick-actions-entry")?.focus();
|
|
2030
|
+
}, [control]);
|
|
2031
|
+
const confirmOpener = (0, react.useRef)(null);
|
|
2032
|
+
const confirming = session.confirming;
|
|
2033
|
+
const activate = (0, react.useCallback)((action) => {
|
|
2034
|
+
confirmOpener.current = document.activeElement;
|
|
2035
|
+
onActivate(action);
|
|
2036
|
+
}, [onActivate]);
|
|
2037
|
+
(0, react.useEffect)(() => {
|
|
2038
|
+
if (confirming !== void 0) return;
|
|
2039
|
+
const opener = confirmOpener.current;
|
|
2040
|
+
confirmOpener.current = null;
|
|
2041
|
+
if (opener === null) return;
|
|
2042
|
+
(opener.isConnected && !opener.disabled ? opener : control("data-quick-actions-manage"))?.focus();
|
|
2043
|
+
}, [confirming, control]);
|
|
2044
|
+
(0, react.useEffect)(() => {
|
|
2045
|
+
if (overflow.length === 0) setPanelOpen(false);
|
|
2046
|
+
}, [overflow.length]);
|
|
2047
|
+
const manage = /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
2048
|
+
variant: "toolbar",
|
|
2049
|
+
size: "sm",
|
|
2050
|
+
className: "dsh-cqa-entry",
|
|
2051
|
+
"data-quick-actions-manage": "",
|
|
2052
|
+
title: t("manage.tooltip"),
|
|
2053
|
+
onClick: onManage,
|
|
2054
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2055
|
+
className: "dsh-cqa-label",
|
|
2056
|
+
children: t("manage")
|
|
2057
|
+
})
|
|
2058
|
+
});
|
|
2059
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2060
|
+
className: layout === "bar" ? "dsh-cqa-bar" : layout === "launcher" ? "dsh-cqa-launcher" : "dsh-cqa-ribbon",
|
|
2061
|
+
"data-quick-actions-layout": layout,
|
|
2062
|
+
"data-quick-actions-density": density,
|
|
2063
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2064
|
+
className: "dsh-cqa-row",
|
|
2065
|
+
ref: rowRef,
|
|
2066
|
+
"data-density": density,
|
|
2067
|
+
children: [
|
|
2068
|
+
layout === "ribbon" && density === "wide" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2069
|
+
className: "dsh-cqa-title",
|
|
2070
|
+
children: t("title")
|
|
2071
|
+
}) : null,
|
|
2072
|
+
layout === "ribbon" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2073
|
+
className: "dsh-cqa-scroll",
|
|
2074
|
+
children: [actions.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2075
|
+
className: "dsh-cqa-title",
|
|
2076
|
+
children: t("empty")
|
|
2077
|
+
}) : null, actions.map((action) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ActionControl, {
|
|
2078
|
+
action,
|
|
2079
|
+
reason: unavailableReasonFor(action, session),
|
|
2080
|
+
t,
|
|
2081
|
+
onActivate: activate
|
|
2082
|
+
}, quickActionRefKey(action.ref)))]
|
|
2083
|
+
}) : null,
|
|
2084
|
+
layout === "bar" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2085
|
+
className: "dsh-cqa-fit",
|
|
2086
|
+
ref: fitRef,
|
|
2087
|
+
children: [actions.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2088
|
+
className: "dsh-cqa-title",
|
|
2089
|
+
children: t("empty")
|
|
2090
|
+
}) : null, actions.slice(0, shown).map((action) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ActionControl, {
|
|
2091
|
+
action,
|
|
2092
|
+
reason: unavailableReasonFor(action, session),
|
|
2093
|
+
t,
|
|
2094
|
+
onActivate: activate
|
|
2095
|
+
}, quickActionRefKey(action.ref)))]
|
|
2096
|
+
}) : null,
|
|
2097
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2098
|
+
className: "dsh-cqa-trailing",
|
|
2099
|
+
children: [layout === "launcher" || layout === "bar" && overflow.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
2100
|
+
className: "dsh-cqa-anchor",
|
|
2101
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
2102
|
+
variant: "toolbar",
|
|
2103
|
+
size: "sm",
|
|
2104
|
+
className: "dsh-cqa-entry",
|
|
2105
|
+
"data-quick-actions-entry": layout,
|
|
2106
|
+
"aria-expanded": panelOpen,
|
|
2107
|
+
"aria-haspopup": "dialog",
|
|
2108
|
+
disabled: layout === "launcher" && actions.length === 0,
|
|
2109
|
+
onClick: () => {
|
|
2110
|
+
setPanelOpen((open) => !open);
|
|
2111
|
+
},
|
|
2112
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2113
|
+
className: "dsh-cqa-label",
|
|
2114
|
+
children: layout === "launcher" ? t("launcher", { count: actions.length }) : t("more", { count: overflow.length })
|
|
2115
|
+
})
|
|
2116
|
+
}), panelOpen ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ActionPanel, {
|
|
2117
|
+
actions: overflow,
|
|
2118
|
+
session,
|
|
2119
|
+
t,
|
|
2120
|
+
labelledBy: panelTitleId,
|
|
2121
|
+
onActivate: activate,
|
|
2122
|
+
onClose: closePanel
|
|
2123
|
+
}) : null]
|
|
2124
|
+
}) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
2125
|
+
className: "dsh-cqa-anchor",
|
|
2126
|
+
children: [manage, session.confirming === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ConfirmPanel, {
|
|
2127
|
+
pending: session.confirming,
|
|
2128
|
+
t,
|
|
2129
|
+
onConfirm,
|
|
2130
|
+
onCancel: onCancelConfirm
|
|
2131
|
+
})]
|
|
2132
|
+
})]
|
|
2133
|
+
})
|
|
2134
|
+
]
|
|
2135
|
+
}), session.feedback === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2136
|
+
className: "dsh-cqa-note",
|
|
2137
|
+
role: "status",
|
|
2138
|
+
"data-quick-actions-feedback": session.feedback.kind,
|
|
2139
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2140
|
+
className: "dsh-cqa-note-text",
|
|
2141
|
+
children: session.feedback.kind === "failed" ? t("feedback.failed", { message: session.feedback.message }) : t(`feedback.${session.feedback.kind}`)
|
|
2142
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2143
|
+
type: "button",
|
|
2144
|
+
className: "dsh-cqa-link",
|
|
2145
|
+
onClick: onDismissFeedback,
|
|
2146
|
+
children: t("feedback.dismiss")
|
|
2147
|
+
})]
|
|
2148
|
+
})]
|
|
2149
|
+
});
|
|
2150
|
+
}
|
|
2151
|
+
//#endregion
|
|
2152
|
+
//#region src/client/manager/press.ts
|
|
2153
|
+
/**
|
|
2154
|
+
* Gate one control. `blocked` is its own extra condition — an end of the list,
|
|
2155
|
+
* the ceiling, the choice already made; the gate's own `busy` is folded in here
|
|
2156
|
+
* so no caller has to remember it.
|
|
2157
|
+
*/
|
|
2158
|
+
function pressProps(gate, blocked, onPress) {
|
|
2159
|
+
const refused = gate.readOnly || gate.busy || blocked;
|
|
2160
|
+
return {
|
|
2161
|
+
disabled: gate.readOnly,
|
|
2162
|
+
"aria-disabled": gate.busy || blocked,
|
|
2163
|
+
onClick: () => {
|
|
2164
|
+
if (refused) return;
|
|
2165
|
+
onPress();
|
|
2166
|
+
}
|
|
2167
|
+
};
|
|
2168
|
+
}
|
|
2169
|
+
//#endregion
|
|
2170
|
+
//#region src/locales/index.ts
|
|
2171
|
+
/** The locale namespace this package registers and every Slot entry binds. */
|
|
2172
|
+
const QUICK_ACTIONS_LOCALE_NAMESPACE = "composer-quick-actions";
|
|
2173
|
+
const zh = {
|
|
2174
|
+
"title": "快捷动作",
|
|
2175
|
+
"manage": "管理",
|
|
2176
|
+
"manage.tooltip": "管理快捷动作",
|
|
2177
|
+
"more": "更多 {count}",
|
|
2178
|
+
"launcher": "快捷动作 {count}",
|
|
2179
|
+
"empty": "暂无可用快捷动作",
|
|
2180
|
+
"command.badge": "命令",
|
|
2181
|
+
"unavailable.occupied-draft": "草稿中已有内容;发送动作不会覆盖或携带它",
|
|
2182
|
+
"unavailable.composer-busy": "消息编辑器正在提交,请稍候",
|
|
2183
|
+
"unavailable.composer-blocked": "当前会话的消息编辑器已被占用",
|
|
2184
|
+
"unavailable.session-removed": "会话已被移除",
|
|
2185
|
+
"unavailable.parent-offline": "父会话不可用",
|
|
2186
|
+
"unavailable.sending": "正在发送",
|
|
2187
|
+
"feedback.state-changed": "状态已变化,请重试",
|
|
2188
|
+
"feedback.retained": "未发送,文本已保留",
|
|
2189
|
+
"feedback.failed": "未能执行:{message}",
|
|
2190
|
+
"feedback.dismiss": "知道了",
|
|
2191
|
+
"catalog.loading": "正在读取快捷动作…",
|
|
2192
|
+
"catalog.unreadable": "无法读取设置,快捷动作暂不可用",
|
|
2193
|
+
"catalog.unavailable": "未找到预置目录",
|
|
2194
|
+
"catalog.undecodable": "预置目录版本过新,无法读取",
|
|
2195
|
+
"catalog.retry": "重试",
|
|
2196
|
+
"crash.title": "快捷动作出错了",
|
|
2197
|
+
"crash.retry": "重新加载",
|
|
2198
|
+
"confirm.title": "确认发送",
|
|
2199
|
+
"confirm.send": "发送",
|
|
2200
|
+
"confirm.cancel": "取消",
|
|
2201
|
+
"confirm.command": "这条文本会按命令进入 DSH 官方裁决路径。此处不会出现输入 / 时的原生候选菜单,你看到的就是最终提交内容。",
|
|
2202
|
+
"panel.title": "选择快捷动作",
|
|
2203
|
+
"panel.close": "关闭",
|
|
2204
|
+
"panel.search": "搜索快捷动作",
|
|
2205
|
+
"panel.search.empty": "没有匹配的快捷动作",
|
|
2206
|
+
"manager.title": "管理快捷动作",
|
|
2207
|
+
"manager.close": "关闭",
|
|
2208
|
+
"manager.layout": "布局",
|
|
2209
|
+
"manager.layout.ribbon": "上方动作带",
|
|
2210
|
+
"manager.layout.bar": "下方操作栏",
|
|
2211
|
+
"manager.layout.launcher": "单入口面板",
|
|
2212
|
+
"manager.actions": "快捷动作",
|
|
2213
|
+
"manager.count": "共 {total} / {limit} 项",
|
|
2214
|
+
"manager.new": "新建快捷动作",
|
|
2215
|
+
"manager.empty": "还没有任何快捷动作",
|
|
2216
|
+
"manager.overflow": "动作总数为 {total} 项,已超过 {limit} 项上限。现有动作全部保留,但在恢复到上限以内之前无法新增或克隆。",
|
|
2217
|
+
"manager.limit": "已达 {limit} 项上限,无法新增或克隆。",
|
|
2218
|
+
"manager.command.notice": "标有「命令」的动作以 / 开头,会按命令进入 DSH 官方裁决路径。确认面板只展示最终提交的文本,不会出现原生 / 候选菜单;关闭确认后,命令将一键提交且没有任何预览。",
|
|
2219
|
+
"manager.preset": "预置",
|
|
2220
|
+
"manager.custom": "自定义",
|
|
2221
|
+
"manager.clonedFrom": "克隆自预置",
|
|
2222
|
+
"manager.hidden": "已隐藏",
|
|
2223
|
+
"manager.disabled": "已停用",
|
|
2224
|
+
"manager.hide": "隐藏",
|
|
2225
|
+
"manager.restore": "恢复",
|
|
2226
|
+
"manager.clone": "克隆",
|
|
2227
|
+
"manager.edit": "编辑",
|
|
2228
|
+
"manager.enable": "启用",
|
|
2229
|
+
"manager.disable": "停用",
|
|
2230
|
+
"manager.delete": "删除",
|
|
2231
|
+
"manager.delete.confirm": "确认删除",
|
|
2232
|
+
"manager.delete.cancel": "不删除",
|
|
2233
|
+
"manager.moveUp": "上移",
|
|
2234
|
+
"manager.moveDown": "下移",
|
|
2235
|
+
"manager.readonly.storage": "设置存储当前不可用,管理界面为只读。",
|
|
2236
|
+
"manager.readonly.offline": "连接已断开,管理界面为只读;连接恢复后可继续修改。",
|
|
2237
|
+
"form.title.new": "新建快捷动作",
|
|
2238
|
+
"form.title.edit": "编辑快捷动作",
|
|
2239
|
+
"form.label": "标签",
|
|
2240
|
+
"form.label.hint": "按钮上显示的名称,最长 {max} 个字符",
|
|
2241
|
+
"form.text": "发送文本",
|
|
2242
|
+
"form.text.hint": "原样提交的静态文本,保留换行,最长 {max} 个字符",
|
|
2243
|
+
"form.icon": "图标(可选)",
|
|
2244
|
+
"form.icon.hint": "1–{max} 个 emoji,仅作装饰",
|
|
2245
|
+
"form.confirm": "发送前确认",
|
|
2246
|
+
"form.save": "保存",
|
|
2247
|
+
"form.cancel": "取消",
|
|
2248
|
+
"form.command.warning": "这条文本以 / 开头,是命令发送动作:它会按命令进入 DSH 官方裁决路径;确认面板不会展示原生 / 候选菜单;关闭确认后,该命令将一键提交且没有任何预览。确认开关仍由你自行设置。",
|
|
2249
|
+
"issue.invalid": "这个字段不符合要求",
|
|
2250
|
+
"issue.label.blank": "请填写标签",
|
|
2251
|
+
"issue.label.too-long": "标签超出长度上限",
|
|
2252
|
+
"issue.text.blank": "发送文本至少要有一个非空白字符",
|
|
2253
|
+
"issue.text.too-long": "发送文本超出长度上限",
|
|
2254
|
+
"issue.text.reserved-placeholder": "发送文本包含 DSH 保留的引用占位符,无法作为静态文本提交",
|
|
2255
|
+
"issue.icon.not-emoji": "图标只能由 emoji 组成",
|
|
2256
|
+
"issue.icon.too-long": "图标的 emoji 数量超出上限",
|
|
2257
|
+
"write.dismiss": "知道了",
|
|
2258
|
+
"write.retry": "重试",
|
|
2259
|
+
"write.not-ready": "设置尚未就绪,改动没有保存。",
|
|
2260
|
+
"write.read-only": "当前无法写入设置,改动没有保存。",
|
|
2261
|
+
"write.refused": "保存被拒绝,没有写入任何内容;请重试。",
|
|
2262
|
+
"write.conflict": "设置已在别处被修改,已刷新到最新状态;请核对后重新确认这次修改。",
|
|
2263
|
+
"write.failed": "保存失败:{message};请重试。",
|
|
2264
|
+
"write.invalid-fields": "有字段不符合要求,改动没有保存。",
|
|
2265
|
+
"write.limit-reached": "已达动作数量上限,无法新增或克隆。",
|
|
2266
|
+
"write.unknown-action": "这个动作已经不存在了,请核对当前列表后重试。",
|
|
2267
|
+
"write.id-in-use": "生成的标识发生重复,请重试。",
|
|
2268
|
+
"write.invalid-order": "顺序已经变化,请核对当前列表后重试。"
|
|
2269
|
+
};
|
|
2270
|
+
/** The dictionaries as `ctx.locale.register` takes them. */
|
|
2271
|
+
const quickActionsDictionaries = {
|
|
2272
|
+
zh,
|
|
2273
|
+
en: {
|
|
2274
|
+
"title": "Quick Actions",
|
|
2275
|
+
"manage": "Manage",
|
|
2276
|
+
"manage.tooltip": "Manage Quick Actions",
|
|
2277
|
+
"more": "More {count}",
|
|
2278
|
+
"launcher": "Quick Actions {count}",
|
|
2279
|
+
"empty": "No Quick Actions available",
|
|
2280
|
+
"command.badge": "Command",
|
|
2281
|
+
"unavailable.occupied-draft": "The draft already has content; a send action never overwrites or carries it",
|
|
2282
|
+
"unavailable.composer-busy": "The composer is submitting; try again in a moment",
|
|
2283
|
+
"unavailable.composer-blocked": "Another feature owns this session’s composer",
|
|
2284
|
+
"unavailable.session-removed": "This session was removed",
|
|
2285
|
+
"unavailable.parent-offline": "The parent session is unavailable",
|
|
2286
|
+
"unavailable.sending": "Sending",
|
|
2287
|
+
"feedback.state-changed": "Something changed — try again",
|
|
2288
|
+
"feedback.retained": "Not sent; the text was kept in the draft",
|
|
2289
|
+
"feedback.failed": "Could not run: {message}",
|
|
2290
|
+
"feedback.dismiss": "Dismiss",
|
|
2291
|
+
"catalog.loading": "Loading Quick Actions…",
|
|
2292
|
+
"catalog.unreadable": "Settings could not be read, so Quick Actions are unavailable",
|
|
2293
|
+
"catalog.unavailable": "No Preset Catalog was published",
|
|
2294
|
+
"catalog.undecodable": "The Preset Catalog is newer than this release can read",
|
|
2295
|
+
"catalog.retry": "Retry",
|
|
2296
|
+
"crash.title": "Quick Actions hit an error",
|
|
2297
|
+
"crash.retry": "Reload",
|
|
2298
|
+
"confirm.title": "Confirm send",
|
|
2299
|
+
"confirm.send": "Send",
|
|
2300
|
+
"confirm.cancel": "Cancel",
|
|
2301
|
+
"confirm.command": "This text enters DSH’s own command adjudication path. The native “/” suggestion menu does not appear here, so what you see is exactly what is submitted.",
|
|
2302
|
+
"panel.title": "Pick a Quick Action",
|
|
2303
|
+
"panel.close": "Close",
|
|
2304
|
+
"panel.search": "Search Quick Actions",
|
|
2305
|
+
"panel.search.empty": "No Quick Action matches that",
|
|
2306
|
+
"manager.title": "Manage Quick Actions",
|
|
2307
|
+
"manager.close": "Close",
|
|
2308
|
+
"manager.layout": "Layout",
|
|
2309
|
+
"manager.layout.ribbon": "Action ribbon",
|
|
2310
|
+
"manager.layout.bar": "Action bar",
|
|
2311
|
+
"manager.layout.launcher": "Single launcher",
|
|
2312
|
+
"manager.actions": "Quick Actions",
|
|
2313
|
+
"manager.count": "{total} of {limit}",
|
|
2314
|
+
"manager.new": "New Quick Action",
|
|
2315
|
+
"manager.empty": "No Quick Actions yet",
|
|
2316
|
+
"manager.overflow": "There are {total} actions, over the limit of {limit}. Everything you have is kept, but creating and cloning stay disabled until the total is back within the limit.",
|
|
2317
|
+
"manager.limit": "The limit of {limit} actions is reached, so creating and cloning are disabled.",
|
|
2318
|
+
"manager.command.notice": "Actions marked “Command” start with “/” and enter DSH’s own command adjudication path. The confirmation panel shows only the text that will be submitted — the native “/” suggestion menu does not appear — and with confirmation off the command is submitted in one click with no preview.",
|
|
2319
|
+
"manager.preset": "Preset",
|
|
2320
|
+
"manager.custom": "Custom",
|
|
2321
|
+
"manager.clonedFrom": "Cloned from a preset",
|
|
2322
|
+
"manager.hidden": "Hidden",
|
|
2323
|
+
"manager.disabled": "Disabled",
|
|
2324
|
+
"manager.hide": "Hide",
|
|
2325
|
+
"manager.restore": "Restore",
|
|
2326
|
+
"manager.clone": "Clone",
|
|
2327
|
+
"manager.edit": "Edit",
|
|
2328
|
+
"manager.enable": "Enable",
|
|
2329
|
+
"manager.disable": "Disable",
|
|
2330
|
+
"manager.delete": "Delete",
|
|
2331
|
+
"manager.delete.confirm": "Confirm delete",
|
|
2332
|
+
"manager.delete.cancel": "Keep it",
|
|
2333
|
+
"manager.moveUp": "Move up",
|
|
2334
|
+
"manager.moveDown": "Move down",
|
|
2335
|
+
"manager.readonly.storage": "Settings storage is unavailable right now, so management is read-only.",
|
|
2336
|
+
"manager.readonly.offline": "The connection is down, so management is read-only until it is back.",
|
|
2337
|
+
"form.title.new": "New Quick Action",
|
|
2338
|
+
"form.title.edit": "Edit Quick Action",
|
|
2339
|
+
"form.label": "Label",
|
|
2340
|
+
"form.label.hint": "The name on the button, up to {max} characters",
|
|
2341
|
+
"form.text": "Text to send",
|
|
2342
|
+
"form.text.hint": "Static text submitted as-is, line breaks kept, up to {max} characters",
|
|
2343
|
+
"form.icon": "Icon (optional)",
|
|
2344
|
+
"form.icon.hint": "1–{max} emoji, decoration only",
|
|
2345
|
+
"form.confirm": "Confirm before sending",
|
|
2346
|
+
"form.save": "Save",
|
|
2347
|
+
"form.cancel": "Cancel",
|
|
2348
|
+
"form.command.warning": "This text starts with “/”, which makes it a Command Send Action: it enters DSH’s own command adjudication path, the confirmation panel shows no native “/” suggestion menu, and with confirmation off the command is submitted in one click with no preview at all. The confirmation switch stays yours to set.",
|
|
2349
|
+
"issue.invalid": "This field is not acceptable",
|
|
2350
|
+
"issue.label.blank": "Enter a label",
|
|
2351
|
+
"issue.label.too-long": "The label is over the length limit",
|
|
2352
|
+
"issue.text.blank": "The text needs at least one non-whitespace character",
|
|
2353
|
+
"issue.text.too-long": "The text is over the length limit",
|
|
2354
|
+
"issue.text.reserved-placeholder": "The text holds a DSH-reserved reference placeholder and cannot be submitted as static text",
|
|
2355
|
+
"issue.icon.not-emoji": "An icon may only be made of emoji",
|
|
2356
|
+
"issue.icon.too-long": "The icon has too many emoji",
|
|
2357
|
+
"write.dismiss": "Dismiss",
|
|
2358
|
+
"write.retry": "Retry",
|
|
2359
|
+
"write.not-ready": "Settings are not ready yet, so nothing was saved.",
|
|
2360
|
+
"write.read-only": "Settings cannot be written right now, so nothing was saved.",
|
|
2361
|
+
"write.refused": "The write was refused and nothing was saved; try again.",
|
|
2362
|
+
"write.conflict": "Settings changed elsewhere and have been refreshed; check what is on screen and confirm your change again.",
|
|
2363
|
+
"write.failed": "The write failed: {message}. Try again.",
|
|
2364
|
+
"write.invalid-fields": "Some fields are not acceptable, so nothing was saved.",
|
|
2365
|
+
"write.limit-reached": "The action limit is reached, so creating and cloning are disabled.",
|
|
2366
|
+
"write.unknown-action": "That action no longer exists; check the current list and try again.",
|
|
2367
|
+
"write.id-in-use": "The generated id collided; try again.",
|
|
2368
|
+
"write.invalid-order": "The order has changed; check the current list and try again."
|
|
2369
|
+
}
|
|
2370
|
+
};
|
|
2371
|
+
/**
|
|
2372
|
+
* Every key this package ships, taken from the dictionary rather than restated,
|
|
2373
|
+
* so a composed key is checked against what actually exists.
|
|
2374
|
+
*/
|
|
2375
|
+
const SHIPPED_KEYS = new Set(Object.keys(zh));
|
|
2376
|
+
/**
|
|
2377
|
+
* Narrow a composed key to one this package ships, falling back when it does
|
|
2378
|
+
* not. Composed keys come from names the model and the controller own — a field
|
|
2379
|
+
* issue, a mutation refusal, a write failure — so a case added upstream shows a
|
|
2380
|
+
* general sentence instead of leaking `write.something-new` onto a surface.
|
|
2381
|
+
*/
|
|
2382
|
+
function quickActionsLocaleKey(candidate, fallback) {
|
|
2383
|
+
return SHIPPED_KEYS.has(candidate) ? candidate : fallback;
|
|
2384
|
+
}
|
|
2385
|
+
/** The dictionary entry naming one field issue (spec 4.3). */
|
|
2386
|
+
function quickActionIssueKey(issue) {
|
|
2387
|
+
return quickActionsLocaleKey(`issue.${issue.field}.${issue.reason}`, "issue.invalid");
|
|
2388
|
+
}
|
|
2389
|
+
//#endregion
|
|
2390
|
+
//#region src/client/manager/ActionForm.tsx
|
|
2391
|
+
/**
|
|
2392
|
+
* The Custom Quick Action form (spec 8.3).
|
|
2393
|
+
*
|
|
2394
|
+
* It edits exactly four things — label, static text, optional emoji and the send
|
|
2395
|
+
* confirmation — and validates them through the shared model, so a draft this
|
|
2396
|
+
* form accepts is a draft the Host will accept (spec 4.3).
|
|
2397
|
+
*
|
|
2398
|
+
* ## What this form deliberately does not have
|
|
2399
|
+
*
|
|
2400
|
+
* - **An action-type selector.** `kind` is not a configuration field: the first
|
|
2401
|
+
* release is always `'send'`, and no user path may change it (spec 4.1, 16.1).
|
|
2402
|
+
* - **Any control the Command Send Action warning locks.** When the text becomes
|
|
2403
|
+
* a command the form warns and nothing else (spec 8.3, 16.1): the warning
|
|
2404
|
+
* states that the text enters DSH's own adjudication path, that the
|
|
2405
|
+
* confirmation panel shows no native `/` suggestion menu, and that with
|
|
2406
|
+
* confirmation off the command is submitted in one click with no preview. The
|
|
2407
|
+
* confirmation switch stays editable throughout.
|
|
2408
|
+
* - **Any rewrite of `confirm` in response to the text.** The default is applied
|
|
2409
|
+
* once, when a draft is created or cloned (`newQuickActionDraft`, the clone
|
|
2410
|
+
* planner). Deriving it from the text here would silently undo the user's own
|
|
2411
|
+
* choice every time they edited the text — the same reason spec 4.3 forbids
|
|
2412
|
+
* normalization from touching it.
|
|
2413
|
+
*
|
|
2414
|
+
* The draft lives in the caller's state, which is what makes "a failed write
|
|
2415
|
+
* keeps the form content" (spec 10) structural rather than incidental.
|
|
2416
|
+
*
|
|
2417
|
+
* ## The form as a nested editing context
|
|
2418
|
+
*
|
|
2419
|
+
* The management panel's Escape handler relies on this form stopping Escape
|
|
2420
|
+
* before it arrives — but a handler on the form only sees keys typed *inside*
|
|
2421
|
+
* the form. Until the form took focus of its own, the first Escape after "new"
|
|
2422
|
+
* or "edit" was still aimed at the button that opened it, outside the form, and
|
|
2423
|
+
* closed the whole panel (ticket 25). So the form has the two focus rules every
|
|
2424
|
+
* other panel of spec 8.4 has: it opens with the caret in its first field, and
|
|
2425
|
+
* it hands focus back to the control that opened it when it closes — which is
|
|
2426
|
+
* what keeps a second Escape meaning "close the panel".
|
|
2427
|
+
*/
|
|
2428
|
+
/**
|
|
2429
|
+
* The issues to render for one draft.
|
|
2430
|
+
*
|
|
2431
|
+
* Every rule comes from the shared model; the only presentation decision is that
|
|
2432
|
+
* a blank field stays quiet until the user has tried to save it.
|
|
2433
|
+
*/
|
|
2434
|
+
function visibleQuickActionIssues(draft, attempted) {
|
|
2435
|
+
const validated = validateQuickActionDraft(draft);
|
|
2436
|
+
if (validated.ok) return [];
|
|
2437
|
+
return attempted ? validated.issues : validated.issues.filter((issue) => issue.reason !== "blank");
|
|
2438
|
+
}
|
|
2439
|
+
/** One labelled field, with its hint and — when it has one — its issue. */
|
|
2440
|
+
function Field(props) {
|
|
2441
|
+
const { field, label, hint, issue, t, children } = props;
|
|
2442
|
+
const id = (0, react.useId)();
|
|
2443
|
+
const hintId = `${id}-hint`;
|
|
2444
|
+
const errorId = `${id}-error`;
|
|
2445
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2446
|
+
className: "dsh-cqa-field",
|
|
2447
|
+
children: [
|
|
2448
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
|
|
2449
|
+
className: "dsh-cqa-field-label",
|
|
2450
|
+
htmlFor: id,
|
|
2451
|
+
children: label
|
|
2452
|
+
}),
|
|
2453
|
+
children({
|
|
2454
|
+
id,
|
|
2455
|
+
describedBy: issue === void 0 ? hintId : `${hintId} ${errorId}`,
|
|
2456
|
+
invalid: issue !== void 0
|
|
2457
|
+
}),
|
|
2458
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2459
|
+
className: "dsh-cqa-field-hint",
|
|
2460
|
+
id: hintId,
|
|
2461
|
+
children: hint
|
|
2462
|
+
}),
|
|
2463
|
+
issue === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2464
|
+
className: "dsh-cqa-field-error",
|
|
2465
|
+
id: errorId,
|
|
2466
|
+
"data-quick-actions-issue": field,
|
|
2467
|
+
children: t(quickActionIssueKey(issue))
|
|
2468
|
+
})
|
|
2469
|
+
]
|
|
2470
|
+
});
|
|
2471
|
+
}
|
|
2472
|
+
function ActionForm(props) {
|
|
2473
|
+
const { mode, draft, attempted, gate, t, onChange, onSave, onCancel } = props;
|
|
2474
|
+
const issues = visibleQuickActionIssues(draft, attempted);
|
|
2475
|
+
const issueFor = (field) => issues.find((issue) => issue.field === field);
|
|
2476
|
+
const command = isCommandSendActionText(draft.text);
|
|
2477
|
+
const titleId = (0, react.useId)();
|
|
2478
|
+
const formRef = (0, react.useRef)(null);
|
|
2479
|
+
useFocusReturn(formRef);
|
|
2480
|
+
useInitialFocusIn(formRef, "[data-quick-actions-form-label]");
|
|
2481
|
+
/**
|
|
2482
|
+
* Escape leaves the form, not the panel behind it.
|
|
2483
|
+
*
|
|
2484
|
+
* Without stopping it here, Escape typed in a field would reach the management
|
|
2485
|
+
* panel's own handler and close the whole panel — a far larger action than the
|
|
2486
|
+
* user asked for. The innermost editing context is what Escape means.
|
|
2487
|
+
*/
|
|
2488
|
+
const onKeyDown = (event) => {
|
|
2489
|
+
if (event.key !== "Escape") return;
|
|
2490
|
+
event.stopPropagation();
|
|
2491
|
+
onCancel();
|
|
2492
|
+
};
|
|
2493
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2494
|
+
className: "dsh-cqa-form",
|
|
2495
|
+
ref: formRef,
|
|
2496
|
+
role: "group",
|
|
2497
|
+
"aria-labelledby": titleId,
|
|
2498
|
+
"data-quick-actions-form": mode,
|
|
2499
|
+
onKeyDown,
|
|
2500
|
+
children: [
|
|
2501
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2502
|
+
className: "dsh-cqa-section-title",
|
|
2503
|
+
id: titleId,
|
|
2504
|
+
children: t(mode === "new" ? "form.title.new" : "form.title.edit")
|
|
2505
|
+
}),
|
|
2506
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
|
|
2507
|
+
field: "label",
|
|
2508
|
+
label: t("form.label"),
|
|
2509
|
+
hint: t("form.label.hint", { max: 40 }),
|
|
2510
|
+
issue: issueFor("label"),
|
|
2511
|
+
t,
|
|
2512
|
+
children: ({ id, describedBy, invalid }) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
2513
|
+
className: "dsh-cqa-input",
|
|
2514
|
+
id,
|
|
2515
|
+
"aria-describedby": describedBy,
|
|
2516
|
+
"aria-invalid": invalid,
|
|
2517
|
+
disabled: gate.readOnly,
|
|
2518
|
+
"data-quick-actions-form-label": "",
|
|
2519
|
+
value: draft.label,
|
|
2520
|
+
onChange: (event) => {
|
|
2521
|
+
onChange({
|
|
2522
|
+
...draft,
|
|
2523
|
+
label: event.target.value
|
|
2524
|
+
});
|
|
2525
|
+
}
|
|
2526
|
+
})
|
|
2527
|
+
}),
|
|
2528
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
|
|
2529
|
+
field: "text",
|
|
2530
|
+
label: t("form.text"),
|
|
2531
|
+
hint: t("form.text.hint", { max: QUICK_ACTION_TEXT_MAX_CODE_POINTS }),
|
|
2532
|
+
issue: issueFor("text"),
|
|
2533
|
+
t,
|
|
2534
|
+
children: ({ id, describedBy, invalid }) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
|
|
2535
|
+
className: "dsh-cqa-textarea",
|
|
2536
|
+
id,
|
|
2537
|
+
rows: 4,
|
|
2538
|
+
"aria-describedby": describedBy,
|
|
2539
|
+
"aria-invalid": invalid,
|
|
2540
|
+
disabled: gate.readOnly,
|
|
2541
|
+
value: draft.text,
|
|
2542
|
+
onChange: (event) => {
|
|
2543
|
+
onChange({
|
|
2544
|
+
...draft,
|
|
2545
|
+
text: event.target.value
|
|
2546
|
+
});
|
|
2547
|
+
}
|
|
2548
|
+
})
|
|
2549
|
+
}),
|
|
2550
|
+
command ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
2551
|
+
className: "dsh-cqa-note",
|
|
2552
|
+
role: "status",
|
|
2553
|
+
"data-quick-actions-command-warning": "",
|
|
2554
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2555
|
+
className: "dsh-cqa-note-text",
|
|
2556
|
+
children: t("form.command.warning")
|
|
2557
|
+
})
|
|
2558
|
+
}) : null,
|
|
2559
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
|
|
2560
|
+
field: "icon",
|
|
2561
|
+
label: t("form.icon"),
|
|
2562
|
+
hint: t("form.icon.hint", { max: 4 }),
|
|
2563
|
+
issue: issueFor("icon"),
|
|
2564
|
+
t,
|
|
2565
|
+
children: ({ id, describedBy, invalid }) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
2566
|
+
className: "dsh-cqa-input",
|
|
2567
|
+
id,
|
|
2568
|
+
"aria-describedby": describedBy,
|
|
2569
|
+
"aria-invalid": invalid,
|
|
2570
|
+
disabled: gate.readOnly,
|
|
2571
|
+
value: draft.icon,
|
|
2572
|
+
onChange: (event) => {
|
|
2573
|
+
onChange({
|
|
2574
|
+
...draft,
|
|
2575
|
+
icon: event.target.value
|
|
2576
|
+
});
|
|
2577
|
+
}
|
|
2578
|
+
})
|
|
2579
|
+
}),
|
|
2580
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
2581
|
+
className: "dsh-cqa-switch",
|
|
2582
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
2583
|
+
type: "checkbox",
|
|
2584
|
+
disabled: gate.readOnly,
|
|
2585
|
+
checked: draft.confirm,
|
|
2586
|
+
"data-quick-actions-confirm-switch": "",
|
|
2587
|
+
onChange: (event) => {
|
|
2588
|
+
onChange({
|
|
2589
|
+
...draft,
|
|
2590
|
+
confirm: event.target.checked
|
|
2591
|
+
});
|
|
2592
|
+
}
|
|
2593
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2594
|
+
className: "dsh-cqa-field-label",
|
|
2595
|
+
children: t("form.confirm")
|
|
2596
|
+
})]
|
|
2597
|
+
}),
|
|
2598
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2599
|
+
className: "dsh-cqa-form-actions",
|
|
2600
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
2601
|
+
variant: "toolbar",
|
|
2602
|
+
size: "sm",
|
|
2603
|
+
className: "dsh-cqa-entry",
|
|
2604
|
+
onClick: onCancel,
|
|
2605
|
+
children: t("form.cancel")
|
|
2606
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
2607
|
+
variant: "toolbar",
|
|
2608
|
+
size: "sm",
|
|
2609
|
+
className: "dsh-cqa-entry",
|
|
2610
|
+
...pressProps(gate, false, onSave),
|
|
2611
|
+
children: t("form.save")
|
|
2612
|
+
})]
|
|
2613
|
+
})
|
|
2614
|
+
]
|
|
2615
|
+
});
|
|
2616
|
+
}
|
|
2617
|
+
//#endregion
|
|
2618
|
+
//#region src/client/manager/ManagedRow.tsx
|
|
2619
|
+
function ManagedRow({ action, index, total, gate, deleting, t, on }) {
|
|
2620
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
|
|
2621
|
+
className: "dsh-cqa-list-item",
|
|
2622
|
+
"data-quick-action": quickActionRefKey(action.ref),
|
|
2623
|
+
"data-quick-action-hidden": action.hidden ? "" : void 0,
|
|
2624
|
+
children: [
|
|
2625
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
2626
|
+
className: "dsh-cqa-list-head",
|
|
2627
|
+
children: [
|
|
2628
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(ActionFace, {
|
|
2629
|
+
action,
|
|
2630
|
+
t
|
|
2631
|
+
}),
|
|
2632
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Pill, {
|
|
2633
|
+
className: "dsh-cqa-tag",
|
|
2634
|
+
children: t(action.editable ? "manager.custom" : "manager.preset")
|
|
2635
|
+
}),
|
|
2636
|
+
action.hidden ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Pill, {
|
|
2637
|
+
active: true,
|
|
2638
|
+
className: "dsh-cqa-tag",
|
|
2639
|
+
children: t(action.editable ? "manager.disabled" : "manager.hidden")
|
|
2640
|
+
}) : null,
|
|
2641
|
+
action.clonedFromPresetId === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Pill, {
|
|
2642
|
+
className: "dsh-cqa-tag",
|
|
2643
|
+
children: t("manager.clonedFrom")
|
|
2644
|
+
})
|
|
2645
|
+
]
|
|
2646
|
+
}),
|
|
2647
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2648
|
+
className: "dsh-cqa-list-text",
|
|
2649
|
+
children: action.text
|
|
2650
|
+
}),
|
|
2651
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
2652
|
+
className: "dsh-cqa-list-controls",
|
|
2653
|
+
children: [
|
|
2654
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
2655
|
+
variant: "toolbar",
|
|
2656
|
+
size: "sm",
|
|
2657
|
+
className: "dsh-cqa-entry",
|
|
2658
|
+
"data-quick-actions-move": "up",
|
|
2659
|
+
...pressProps(gate, index === 0, () => {
|
|
2660
|
+
on.onMove(action.ref, index - 1);
|
|
2661
|
+
}),
|
|
2662
|
+
children: t("manager.moveUp")
|
|
2663
|
+
}),
|
|
2664
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
2665
|
+
variant: "toolbar",
|
|
2666
|
+
size: "sm",
|
|
2667
|
+
className: "dsh-cqa-entry",
|
|
2668
|
+
"data-quick-actions-move": "down",
|
|
2669
|
+
...pressProps(gate, index === total - 1, () => {
|
|
2670
|
+
on.onMove(action.ref, index + 1);
|
|
2671
|
+
}),
|
|
2672
|
+
children: t("manager.moveDown")
|
|
2673
|
+
}),
|
|
2674
|
+
action.editable ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
2675
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
2676
|
+
variant: "toolbar",
|
|
2677
|
+
size: "sm",
|
|
2678
|
+
className: "dsh-cqa-entry",
|
|
2679
|
+
...pressProps(gate, false, on.onEdit),
|
|
2680
|
+
children: t("manager.edit")
|
|
2681
|
+
}),
|
|
2682
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
2683
|
+
variant: "toolbar",
|
|
2684
|
+
size: "sm",
|
|
2685
|
+
className: "dsh-cqa-entry",
|
|
2686
|
+
...pressProps(gate, false, on.onToggleEnabled),
|
|
2687
|
+
children: t(action.hidden ? "manager.enable" : "manager.disable")
|
|
2688
|
+
}),
|
|
2689
|
+
deleting ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
2690
|
+
variant: "toolbar",
|
|
2691
|
+
size: "sm",
|
|
2692
|
+
className: "dsh-cqa-entry",
|
|
2693
|
+
"data-quick-actions-delete": "confirm",
|
|
2694
|
+
...pressProps(gate, false, on.onConfirmDelete),
|
|
2695
|
+
children: t("manager.delete.confirm")
|
|
2696
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
2697
|
+
variant: "toolbar",
|
|
2698
|
+
size: "sm",
|
|
2699
|
+
className: "dsh-cqa-entry",
|
|
2700
|
+
onClick: on.onCancelDelete,
|
|
2701
|
+
children: t("manager.delete.cancel")
|
|
2702
|
+
})] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
2703
|
+
variant: "toolbar",
|
|
2704
|
+
size: "sm",
|
|
2705
|
+
className: "dsh-cqa-entry",
|
|
2706
|
+
"data-quick-actions-delete": "ask",
|
|
2707
|
+
...pressProps(gate, false, on.onAskDelete),
|
|
2708
|
+
children: t("manager.delete")
|
|
2709
|
+
})
|
|
2710
|
+
] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
2711
|
+
variant: "toolbar",
|
|
2712
|
+
size: "sm",
|
|
2713
|
+
className: "dsh-cqa-entry",
|
|
2714
|
+
...pressProps(gate, false, on.onToggleHidden),
|
|
2715
|
+
children: t(action.hidden ? "manager.restore" : "manager.hide")
|
|
2716
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
2717
|
+
variant: "toolbar",
|
|
2718
|
+
size: "sm",
|
|
2719
|
+
className: "dsh-cqa-entry",
|
|
2720
|
+
"data-quick-actions-clone": "",
|
|
2721
|
+
...pressProps(gate, !gate.canAdd, on.onClone),
|
|
2722
|
+
children: t("manager.clone")
|
|
2723
|
+
})] })
|
|
2724
|
+
]
|
|
2725
|
+
})
|
|
2726
|
+
]
|
|
2727
|
+
});
|
|
2728
|
+
}
|
|
2729
|
+
//#endregion
|
|
2730
|
+
//#region src/client/manager/status.ts
|
|
2731
|
+
/**
|
|
2732
|
+
* How the management panel names the state it is in (spec 10).
|
|
2733
|
+
*
|
|
2734
|
+
* Both readings are pure functions over the snapshot the controller publishes,
|
|
2735
|
+
* kept beside the panel rather than inside it — the same split the surfaces use
|
|
2736
|
+
* for `layout.ts` and the action panel for `search.ts`.
|
|
2737
|
+
*/
|
|
2738
|
+
/**
|
|
2739
|
+
* Report read-only state, and only read-only state.
|
|
2740
|
+
*
|
|
2741
|
+
* Spec 10 keeps two first-read failures apart: a catalog that could not be read
|
|
2742
|
+
* shows a retryable catalog error, while a *readable* catalog whose user
|
|
2743
|
+
* namespace failed shows "storage unavailable". Without the projection guard
|
|
2744
|
+
* below, a loading or failed catalog also reads as read-only — the write gate
|
|
2745
|
+
* refuses on `not-ready` — and the panel would print both notices at once,
|
|
2746
|
+
* telling the user their storage is broken when it is the catalog that is
|
|
2747
|
+
* missing. With no catalog there is nothing to be read-only *about*: the catalog
|
|
2748
|
+
* notice is the whole story.
|
|
2749
|
+
*/
|
|
2750
|
+
function managerReadOnlyReason(client) {
|
|
2751
|
+
if (client.projection === void 0) return void 0;
|
|
2752
|
+
if (!client.readOnly) return void 0;
|
|
2753
|
+
return client.stale ? "offline" : "storage";
|
|
2754
|
+
}
|
|
2755
|
+
/** One write failure as a sentence, with the recovery it implies (spec 10). */
|
|
2756
|
+
function managerFailureMessage(failure, t) {
|
|
2757
|
+
if (failure.kind === "failed") return t("write.failed", { message: failure.message });
|
|
2758
|
+
return t(quickActionsLocaleKey(failure.kind === "rejected" ? `write.${failure.rejection.reason}` : `write.${failure.kind}`, "write.refused"));
|
|
2759
|
+
}
|
|
2760
|
+
//#endregion
|
|
2761
|
+
//#region src/client/manager/ManagerPanel.tsx
|
|
2762
|
+
/**
|
|
2763
|
+
* The centralized management panel (spec 8.3).
|
|
2764
|
+
*
|
|
2765
|
+
* Everything a user can change about Quick Actions lives here and nowhere else:
|
|
2766
|
+
* the shared order, a preset's hidden state and its clone, a custom action's
|
|
2767
|
+
* whole life cycle, and the global layout. The Composer surfaces only read the
|
|
2768
|
+
* projection and execute.
|
|
2769
|
+
*
|
|
2770
|
+
* ## Where the truth is
|
|
2771
|
+
*
|
|
2772
|
+
* The panel renders the authoritative snapshot the controller publishes and
|
|
2773
|
+
* never a local copy of it. So a new UI state appears only once the Host has
|
|
2774
|
+
* persisted it (spec 10) — including the order, which is why a failed reorder
|
|
2775
|
+
* needs no "restore the old order" path: the old order was never left.
|
|
2776
|
+
*
|
|
2777
|
+
* ## Failure, and the one retry
|
|
2778
|
+
*
|
|
2779
|
+
* Every write goes through one funnel, which also remembers how to run it again.
|
|
2780
|
+
* That is spec 10's 「提供明确重试」: the failure banner offers the retry, and
|
|
2781
|
+
* pressing it *re-plans* rather than replaying — the controller builds each plan
|
|
2782
|
+
* from the snapshot it holds at that moment, and a create mints a fresh Custom
|
|
2783
|
+
* Action ID per attempt. Nothing ever retries on its own; a silent replay could
|
|
2784
|
+
* submit a plan the user has already moved past.
|
|
2785
|
+
*
|
|
2786
|
+
* Each failure is named, because spec 10 asks for different handling per kind: a
|
|
2787
|
+
* refusal or a transport failure keeps the form content and the panel open,
|
|
2788
|
+
* while a revision conflict means the controller has already re-read the
|
|
2789
|
+
* authoritative state, so the notice asks the user to confirm the change again
|
|
2790
|
+
* against what is now on screen — this feature keeps no second offline truth.
|
|
2791
|
+
*
|
|
2792
|
+
* Nothing here reports success: the list is the feedback, and spec 9.5 forbids an
|
|
2793
|
+
* extra success toast.
|
|
2794
|
+
*/
|
|
2795
|
+
/** The form's starting draft for an existing Custom Quick Action. */
|
|
2796
|
+
function draftOf(action) {
|
|
2797
|
+
return {
|
|
2798
|
+
label: action.label,
|
|
2799
|
+
text: action.text,
|
|
2800
|
+
icon: action.icon ?? "",
|
|
2801
|
+
confirm: action.confirm
|
|
2802
|
+
};
|
|
2803
|
+
}
|
|
2804
|
+
function ManagerPanel({ client, controller, t }) {
|
|
2805
|
+
const close = (0, react.useCallback)(() => {
|
|
2806
|
+
controller.closeManager();
|
|
2807
|
+
}, [controller]);
|
|
2808
|
+
useFocusReturn();
|
|
2809
|
+
const { panelRef, onKeyDown } = useModalKeys(close);
|
|
2810
|
+
useInitialFocusIn(panelRef, "[data-quick-actions-manager-close]");
|
|
2811
|
+
const titleId = (0, react.useId)();
|
|
2812
|
+
const layoutTitleId = (0, react.useId)();
|
|
2813
|
+
const listTitleId = (0, react.useId)();
|
|
2814
|
+
const [form, setForm] = (0, react.useState)(void 0);
|
|
2815
|
+
const [pendingDelete, setPendingDelete] = (0, react.useState)(void 0);
|
|
2816
|
+
const [pending, setPending] = (0, react.useState)(void 0);
|
|
2817
|
+
const projection = client.projection;
|
|
2818
|
+
const managed = projection?.managed ?? [];
|
|
2819
|
+
const counts = projection?.counts;
|
|
2820
|
+
const readOnlyReason = managerReadOnlyReason(client);
|
|
2821
|
+
const gate = {
|
|
2822
|
+
readOnly: readOnlyReason !== void 0,
|
|
2823
|
+
busy: client.writing,
|
|
2824
|
+
canAdd: counts?.canAdd === true
|
|
2825
|
+
};
|
|
2826
|
+
const editingId = form?.target.kind === "edit" ? form.target.id : void 0;
|
|
2827
|
+
const editingLives = editingId === void 0 || managed.some((action) => action.editable && action.ref.source === "custom" && action.ref.id === editingId);
|
|
2828
|
+
(0, react.useEffect)(() => {
|
|
2829
|
+
if (!editingLives) setForm(void 0);
|
|
2830
|
+
}, [editingLives]);
|
|
2831
|
+
/**
|
|
2832
|
+
* The one funnel every write goes through: remember it, run it, and answer
|
|
2833
|
+
* whether it landed.
|
|
2834
|
+
*
|
|
2835
|
+
* The `catch` is the belt. A controller write answers with an outcome rather
|
|
2836
|
+
* than rejecting, so this arm should be unreachable — but an unhandled
|
|
2837
|
+
* rejection must never reach the page from a click handler, and the failure
|
|
2838
|
+
* the user sees comes from the snapshot the controller publishes either way.
|
|
2839
|
+
*/
|
|
2840
|
+
const write = (0, react.useCallback)(async (remember, run) => {
|
|
2841
|
+
setPending(remember);
|
|
2842
|
+
try {
|
|
2843
|
+
const outcome = await run();
|
|
2844
|
+
if (outcome.ok) setPending(void 0);
|
|
2845
|
+
return outcome.ok;
|
|
2846
|
+
} catch {
|
|
2847
|
+
return false;
|
|
2848
|
+
}
|
|
2849
|
+
}, []);
|
|
2850
|
+
/** Start a write from a click handler, with no outcome to react to. */
|
|
2851
|
+
const fire = (0, react.useCallback)((run) => {
|
|
2852
|
+
write({
|
|
2853
|
+
kind: "write",
|
|
2854
|
+
run
|
|
2855
|
+
}, run);
|
|
2856
|
+
}, [write]);
|
|
2857
|
+
const save = (0, react.useCallback)(async () => {
|
|
2858
|
+
if (form === void 0) return;
|
|
2859
|
+
if (!validateQuickActionDraft(form.draft).ok) {
|
|
2860
|
+
setForm({
|
|
2861
|
+
...form,
|
|
2862
|
+
attempted: true
|
|
2863
|
+
});
|
|
2864
|
+
return;
|
|
2865
|
+
}
|
|
2866
|
+
const { target, draft } = form;
|
|
2867
|
+
if (await write({ kind: "form" }, () => target.kind === "new" ? controller.createCustomAction(draft) : controller.updateCustomAction(target.id, draft))) setForm(void 0);
|
|
2868
|
+
else setForm({
|
|
2869
|
+
...form,
|
|
2870
|
+
attempted: true
|
|
2871
|
+
});
|
|
2872
|
+
}, [
|
|
2873
|
+
controller,
|
|
2874
|
+
form,
|
|
2875
|
+
write
|
|
2876
|
+
]);
|
|
2877
|
+
const retry = (0, react.useCallback)(() => {
|
|
2878
|
+
if (pending === void 0) return;
|
|
2879
|
+
if (pending.kind === "form") {
|
|
2880
|
+
save();
|
|
2881
|
+
return;
|
|
2882
|
+
}
|
|
2883
|
+
write(pending, pending.run);
|
|
2884
|
+
}, [
|
|
2885
|
+
pending,
|
|
2886
|
+
save,
|
|
2887
|
+
write
|
|
2888
|
+
]);
|
|
2889
|
+
const remove = (0, react.useCallback)((id) => {
|
|
2890
|
+
const run = () => controller.deleteCustomAction(id);
|
|
2891
|
+
write({
|
|
2892
|
+
kind: "write",
|
|
2893
|
+
run
|
|
2894
|
+
}, run).then((landed) => {
|
|
2895
|
+
if (landed) setPendingDelete(void 0);
|
|
2896
|
+
});
|
|
2897
|
+
}, [controller, write]);
|
|
2898
|
+
return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2899
|
+
className: "dsh-cqa-backdrop dsh-cqa-manager-backdrop",
|
|
2900
|
+
"data-quick-actions-backdrop": "",
|
|
2901
|
+
"aria-hidden": "true",
|
|
2902
|
+
onClick: close
|
|
2903
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2904
|
+
className: "dsh-cqa-manager",
|
|
2905
|
+
ref: panelRef,
|
|
2906
|
+
role: "dialog",
|
|
2907
|
+
"aria-modal": "true",
|
|
2908
|
+
"aria-labelledby": titleId,
|
|
2909
|
+
"data-quick-actions-manager": "",
|
|
2910
|
+
onKeyDown,
|
|
2911
|
+
children: [
|
|
2912
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2913
|
+
className: "dsh-cqa-manager-head",
|
|
2914
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
|
|
2915
|
+
className: "dsh-cqa-manager-title",
|
|
2916
|
+
id: titleId,
|
|
2917
|
+
children: t("manager.title")
|
|
2918
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
2919
|
+
variant: "toolbar",
|
|
2920
|
+
size: "sm",
|
|
2921
|
+
className: "dsh-cqa-entry",
|
|
2922
|
+
"data-quick-actions-manager-close": "",
|
|
2923
|
+
onClick: close,
|
|
2924
|
+
children: t("manager.close")
|
|
2925
|
+
})]
|
|
2926
|
+
}),
|
|
2927
|
+
projection === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2928
|
+
className: "dsh-cqa-note",
|
|
2929
|
+
role: "status",
|
|
2930
|
+
"data-quick-actions-catalog-error": client.catalog.status === "error" ? client.catalog.reason : "loading",
|
|
2931
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2932
|
+
className: "dsh-cqa-note-text",
|
|
2933
|
+
children: t(client.catalog.status === "error" ? `catalog.${client.catalog.reason}` : "catalog.loading")
|
|
2934
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2935
|
+
type: "button",
|
|
2936
|
+
className: "dsh-cqa-link",
|
|
2937
|
+
onClick: () => {
|
|
2938
|
+
controller.refresh().catch(() => void 0);
|
|
2939
|
+
},
|
|
2940
|
+
children: t("catalog.retry")
|
|
2941
|
+
})]
|
|
2942
|
+
}) : null,
|
|
2943
|
+
readOnlyReason === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2944
|
+
className: "dsh-cqa-note",
|
|
2945
|
+
role: "status",
|
|
2946
|
+
"data-quick-actions-readonly": readOnlyReason,
|
|
2947
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2948
|
+
className: "dsh-cqa-note-text",
|
|
2949
|
+
children: t(`manager.readonly.${readOnlyReason}`)
|
|
2950
|
+
})
|
|
2951
|
+
}),
|
|
2952
|
+
client.failure === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2953
|
+
className: "dsh-cqa-note",
|
|
2954
|
+
role: "alert",
|
|
2955
|
+
"data-quick-actions-write-failure": client.failure.kind,
|
|
2956
|
+
children: [
|
|
2957
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2958
|
+
className: "dsh-cqa-note-text",
|
|
2959
|
+
children: managerFailureMessage(client.failure, t)
|
|
2960
|
+
}),
|
|
2961
|
+
pending === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2962
|
+
type: "button",
|
|
2963
|
+
className: "dsh-cqa-link",
|
|
2964
|
+
"data-quick-actions-write-retry": "",
|
|
2965
|
+
onClick: retry,
|
|
2966
|
+
children: t("write.retry")
|
|
2967
|
+
}),
|
|
2968
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2969
|
+
type: "button",
|
|
2970
|
+
className: "dsh-cqa-link",
|
|
2971
|
+
onClick: () => {
|
|
2972
|
+
controller.dismissFailure();
|
|
2973
|
+
},
|
|
2974
|
+
children: t("write.dismiss")
|
|
2975
|
+
})
|
|
2976
|
+
]
|
|
2977
|
+
}),
|
|
2978
|
+
counts === void 0 || !counts.overflow && counts.canAdd ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2979
|
+
className: "dsh-cqa-note",
|
|
2980
|
+
role: "status",
|
|
2981
|
+
"data-quick-actions-limit": counts.overflow ? "overflow" : "reached",
|
|
2982
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2983
|
+
className: "dsh-cqa-note-text",
|
|
2984
|
+
children: t(counts.overflow ? "manager.overflow" : "manager.limit", {
|
|
2985
|
+
limit: counts.limit,
|
|
2986
|
+
total: counts.total
|
|
2987
|
+
})
|
|
2988
|
+
})
|
|
2989
|
+
}),
|
|
2990
|
+
projection === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
2991
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2992
|
+
className: "dsh-cqa-section",
|
|
2993
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2994
|
+
className: "dsh-cqa-section-title",
|
|
2995
|
+
id: layoutTitleId,
|
|
2996
|
+
children: t("manager.layout")
|
|
2997
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2998
|
+
className: "dsh-cqa-group",
|
|
2999
|
+
role: "group",
|
|
3000
|
+
"aria-labelledby": layoutTitleId,
|
|
3001
|
+
children: QUICK_ACTION_LAYOUTS.map((layout) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
3002
|
+
variant: "toolbar",
|
|
3003
|
+
size: "sm",
|
|
3004
|
+
className: "dsh-cqa-entry",
|
|
3005
|
+
"data-quick-actions-layout-choice": layout,
|
|
3006
|
+
"aria-pressed": projection.layout === layout,
|
|
3007
|
+
...pressProps(gate, projection.layout === layout, () => {
|
|
3008
|
+
fire(() => controller.setLayout(layout));
|
|
3009
|
+
}),
|
|
3010
|
+
children: t(`manager.layout.${layout}`)
|
|
3011
|
+
}, layout))
|
|
3012
|
+
})]
|
|
3013
|
+
}),
|
|
3014
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
3015
|
+
className: "dsh-cqa-section",
|
|
3016
|
+
children: [
|
|
3017
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
3018
|
+
className: "dsh-cqa-section-head",
|
|
3019
|
+
children: [
|
|
3020
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
3021
|
+
className: "dsh-cqa-section-title",
|
|
3022
|
+
id: listTitleId,
|
|
3023
|
+
children: t("manager.actions")
|
|
3024
|
+
}),
|
|
3025
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
3026
|
+
className: "dsh-cqa-field-hint",
|
|
3027
|
+
"data-quick-actions-count": "",
|
|
3028
|
+
children: t("manager.count", {
|
|
3029
|
+
total: counts?.total ?? 0,
|
|
3030
|
+
limit: counts?.limit ?? 0
|
|
3031
|
+
})
|
|
3032
|
+
}),
|
|
3033
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
3034
|
+
variant: "toolbar",
|
|
3035
|
+
size: "sm",
|
|
3036
|
+
className: "dsh-cqa-entry",
|
|
3037
|
+
"data-quick-actions-new": "",
|
|
3038
|
+
...pressProps(gate, !gate.canAdd || form?.target.kind === "new", () => {
|
|
3039
|
+
setForm({
|
|
3040
|
+
target: { kind: "new" },
|
|
3041
|
+
draft: newQuickActionDraft(),
|
|
3042
|
+
attempted: false
|
|
3043
|
+
});
|
|
3044
|
+
}),
|
|
3045
|
+
children: t("manager.new")
|
|
3046
|
+
})
|
|
3047
|
+
]
|
|
3048
|
+
}),
|
|
3049
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
3050
|
+
className: "dsh-cqa-note",
|
|
3051
|
+
"data-quick-actions-command-notice": "",
|
|
3052
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
3053
|
+
className: "dsh-cqa-note-text",
|
|
3054
|
+
children: t("manager.command.notice")
|
|
3055
|
+
})
|
|
3056
|
+
}),
|
|
3057
|
+
managed.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
3058
|
+
className: "dsh-cqa-field-hint",
|
|
3059
|
+
children: t("manager.empty")
|
|
3060
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
|
|
3061
|
+
className: "dsh-cqa-list",
|
|
3062
|
+
"aria-labelledby": listTitleId,
|
|
3063
|
+
children: managed.map((action, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ManagedRow, {
|
|
3064
|
+
action,
|
|
3065
|
+
index,
|
|
3066
|
+
total: managed.length,
|
|
3067
|
+
gate,
|
|
3068
|
+
deleting: action.ref.source === "custom" && pendingDelete === action.ref.id,
|
|
3069
|
+
t,
|
|
3070
|
+
on: {
|
|
3071
|
+
onMove: (ref, toIndex) => {
|
|
3072
|
+
fire(() => controller.moveAction(ref, toIndex));
|
|
3073
|
+
},
|
|
3074
|
+
onEdit: () => {
|
|
3075
|
+
setForm({
|
|
3076
|
+
target: {
|
|
3077
|
+
kind: "edit",
|
|
3078
|
+
id: action.ref.id
|
|
3079
|
+
},
|
|
3080
|
+
draft: draftOf(action),
|
|
3081
|
+
attempted: false
|
|
3082
|
+
});
|
|
3083
|
+
},
|
|
3084
|
+
onClone: () => {
|
|
3085
|
+
fire(() => controller.clonePreset(action.ref.id));
|
|
3086
|
+
},
|
|
3087
|
+
onToggleHidden: () => {
|
|
3088
|
+
fire(() => controller.setPresetHidden(action.ref.id, !action.hidden));
|
|
3089
|
+
},
|
|
3090
|
+
onToggleEnabled: () => {
|
|
3091
|
+
fire(() => controller.setCustomActionEnabled(action.ref.id, action.hidden));
|
|
3092
|
+
},
|
|
3093
|
+
onAskDelete: () => {
|
|
3094
|
+
setPendingDelete(action.ref.id);
|
|
3095
|
+
},
|
|
3096
|
+
onCancelDelete: () => {
|
|
3097
|
+
setPendingDelete(void 0);
|
|
3098
|
+
},
|
|
3099
|
+
onConfirmDelete: () => {
|
|
3100
|
+
remove(action.ref.id);
|
|
3101
|
+
}
|
|
3102
|
+
}
|
|
3103
|
+
}, quickActionRefKey(action.ref)))
|
|
3104
|
+
})
|
|
3105
|
+
]
|
|
3106
|
+
}),
|
|
3107
|
+
form === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ActionForm, {
|
|
3108
|
+
mode: form.target.kind,
|
|
3109
|
+
draft: form.draft,
|
|
3110
|
+
attempted: form.attempted,
|
|
3111
|
+
gate,
|
|
3112
|
+
t,
|
|
3113
|
+
onChange: (draft) => {
|
|
3114
|
+
setForm({
|
|
3115
|
+
...form,
|
|
3116
|
+
draft
|
|
3117
|
+
});
|
|
3118
|
+
},
|
|
3119
|
+
onSave: () => {
|
|
3120
|
+
save();
|
|
3121
|
+
},
|
|
3122
|
+
onCancel: () => {
|
|
3123
|
+
setForm(void 0);
|
|
3124
|
+
}
|
|
3125
|
+
}, editingId === void 0 ? "new" : `edit:${editingId}`)
|
|
3126
|
+
] })
|
|
3127
|
+
]
|
|
3128
|
+
})] }), document.body);
|
|
3129
|
+
}
|
|
3130
|
+
//#endregion
|
|
3131
|
+
//#region src/client/surfaces/entries.tsx
|
|
3132
|
+
/**
|
|
3133
|
+
* The two dock Slot entries (spec 7.3, 8.1).
|
|
3134
|
+
*
|
|
3135
|
+
* Both are always registered; which one renders the layout is decided at render
|
|
3136
|
+
* time from the authoritative Settings snapshot, and both render nothing at all
|
|
3137
|
+
* unless the Resident Composer predicate holds for their Session:
|
|
3138
|
+
*
|
|
3139
|
+
* - `conversation.composer.dock` is the residency beacon. Its mount is DSH's own
|
|
3140
|
+
* statement that this Session's composer is in its resident variant, so it
|
|
3141
|
+
* marks the Session while mounted and additionally renders the `bar` layout.
|
|
3142
|
+
* - `conversation.input.dock` renders `ribbon` or `launcher`, but only while the
|
|
3143
|
+
* beacon's mark stands — that Slot also mounts on the blank-session hero, and
|
|
3144
|
+
* the first release must never appear there.
|
|
3145
|
+
*
|
|
3146
|
+
* A third entry rides the same input dock: the centralized management overlay,
|
|
3147
|
+
* which spec 8.1 requires to be registered independently of the layout entries.
|
|
3148
|
+
* It is a separate Slot cell with its own id, order and error boundary, so a
|
|
3149
|
+
* failure in the management panel cannot take a Composer layout down with it and
|
|
3150
|
+
* vice versa (spec 7.3). Because the overlay is global state rendered from a
|
|
3151
|
+
* session-scoped Slot, only the Session the residency registry elects primary
|
|
3152
|
+
* draws it — two Resident Composers on screen must not stack two overlays.
|
|
3153
|
+
*
|
|
3154
|
+
* Each entry carries its own error boundary, so a failure replaces the Quick
|
|
3155
|
+
* Action area alone.
|
|
3156
|
+
*/
|
|
3157
|
+
function useControllerState(controller) {
|
|
3158
|
+
return (0, react.useSyncExternalStore)((0, react.useCallback)((listener) => controller.subscribe(listener), [controller]), () => controller.getSnapshot());
|
|
3159
|
+
}
|
|
3160
|
+
function useResident(residency, sessionId) {
|
|
3161
|
+
return (0, react.useSyncExternalStore)((0, react.useCallback)((listener) => residency.subscribe(listener), [residency]), () => residency.isResident(sessionId));
|
|
3162
|
+
}
|
|
3163
|
+
/** Whether this Session is the one that draws anything global (see the registry). */
|
|
3164
|
+
function usePrimaryResident(residency, sessionId) {
|
|
3165
|
+
return (0, react.useSyncExternalStore)((0, react.useCallback)((listener) => residency.subscribe(listener), [residency]), () => residency.primarySessionId() === sessionId);
|
|
3166
|
+
}
|
|
3167
|
+
/**
|
|
3168
|
+
* The Composer block for one Session, resolved on every read rather than cached.
|
|
3169
|
+
*
|
|
3170
|
+
* `conversation` is reached through `ctx.get` rather than `inject`, so nothing
|
|
3171
|
+
* orders it before this component's first render. Caching the resolution would
|
|
3172
|
+
* turn "the service was not there yet" into "this Session can never be blocked",
|
|
3173
|
+
* and a Quick Action would then send into a Composer another feature has made
|
|
3174
|
+
* inert. `storeFor` answers an identity-stable store, so re-resolving costs a
|
|
3175
|
+
* map lookup and keeps `getSnapshot` referentially stable.
|
|
3176
|
+
*/
|
|
3177
|
+
function useComposerBlock(blocks, sessionId) {
|
|
3178
|
+
return (0, react.useSyncExternalStore)((0, react.useCallback)((listener) => blocks()?.storeFor(sessionId)?.subscribe(listener) ?? (() => {}), [blocks, sessionId]), () => blocks()?.storeFor(sessionId)?.getSnapshot());
|
|
3179
|
+
}
|
|
3180
|
+
/**
|
|
3181
|
+
* The body both entries render once residency and the layout agree.
|
|
3182
|
+
*
|
|
3183
|
+
* It owns the Session's execution engine for as long as it is mounted: every
|
|
3184
|
+
* commit republishes the live `InputActions` and the live Composer projection
|
|
3185
|
+
* into it, and unmounting cancels whatever has not reached the official state
|
|
3186
|
+
* machine (spec 7.2).
|
|
3187
|
+
*/
|
|
3188
|
+
function SessionSurface(props) {
|
|
3189
|
+
const { deps, layout, actions, sessionId, useInput, useSession, inputActions, t } = props;
|
|
3190
|
+
const engine = (0, react.useMemo)(() => deps.sessions.engineFor(sessionId), [deps.sessions, sessionId]);
|
|
3191
|
+
const input = useInput((state) => state);
|
|
3192
|
+
const session = useSession((state) => state);
|
|
3193
|
+
const block = useComposerBlock(deps.blocks, sessionId);
|
|
3194
|
+
(0, react.useLayoutEffect)(() => {
|
|
3195
|
+
engine.bind(inputActions, actions);
|
|
3196
|
+
engine.observe(input, session, block);
|
|
3197
|
+
});
|
|
3198
|
+
(0, react.useEffect)(() => () => {
|
|
3199
|
+
engine.cancelPending();
|
|
3200
|
+
}, [engine]);
|
|
3201
|
+
const state = (0, react.useSyncExternalStore)((0, react.useCallback)((listener) => engine.subscribe(listener), [engine]), () => engine.getSnapshot());
|
|
3202
|
+
const onActivate = (0, react.useCallback)((action) => {
|
|
3203
|
+
engine.activate(action);
|
|
3204
|
+
}, [engine]);
|
|
3205
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(QuickActionsSurface, {
|
|
3206
|
+
layout,
|
|
3207
|
+
actions,
|
|
3208
|
+
session: state,
|
|
3209
|
+
t,
|
|
3210
|
+
onActivate,
|
|
3211
|
+
onConfirm: () => {
|
|
3212
|
+
engine.confirm();
|
|
3213
|
+
},
|
|
3214
|
+
onCancelConfirm: () => {
|
|
3215
|
+
engine.cancel();
|
|
3216
|
+
},
|
|
3217
|
+
onDismissFeedback: () => {
|
|
3218
|
+
engine.dismissFeedback();
|
|
3219
|
+
},
|
|
3220
|
+
onManage: () => {
|
|
3221
|
+
deps.controller.openManager();
|
|
3222
|
+
}
|
|
3223
|
+
});
|
|
3224
|
+
}
|
|
3225
|
+
/** The catalog error and loading states of spec 10, rendered where the layout would be. */
|
|
3226
|
+
function CatalogNotice(props) {
|
|
3227
|
+
const { client, t, onRetry } = props;
|
|
3228
|
+
if (client.catalog.status === "loading") return null;
|
|
3229
|
+
if (client.catalog.status === "ready") return null;
|
|
3230
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
3231
|
+
className: "dsh-cqa-note",
|
|
3232
|
+
role: "status",
|
|
3233
|
+
"data-quick-actions-catalog-error": client.catalog.reason,
|
|
3234
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
3235
|
+
className: "dsh-cqa-note-text",
|
|
3236
|
+
children: t(`catalog.${client.catalog.reason}`)
|
|
3237
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
3238
|
+
type: "button",
|
|
3239
|
+
className: "dsh-cqa-link",
|
|
3240
|
+
onClick: onRetry,
|
|
3241
|
+
children: t("catalog.retry")
|
|
3242
|
+
})]
|
|
3243
|
+
});
|
|
3244
|
+
}
|
|
3245
|
+
/** Build both Slot entry components over one set of fiber-owned dependencies. */
|
|
3246
|
+
function createQuickActionDockEntries(deps) {
|
|
3247
|
+
function Body(props) {
|
|
3248
|
+
const { owns, ...slot } = props;
|
|
3249
|
+
const client = useControllerState(deps.controller);
|
|
3250
|
+
const projection = client.projection;
|
|
3251
|
+
const onRetry = (0, react.useCallback)(() => {
|
|
3252
|
+
deps.controller.refresh().catch(() => void 0);
|
|
3253
|
+
}, []);
|
|
3254
|
+
if (projection === void 0) return owns.includes("ribbon") ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CatalogNotice, {
|
|
3255
|
+
client,
|
|
3256
|
+
t: slot.t,
|
|
3257
|
+
onRetry
|
|
3258
|
+
}) : null;
|
|
3259
|
+
if (!owns.includes(projection.layout)) return null;
|
|
3260
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SessionSurface, {
|
|
3261
|
+
...slot,
|
|
3262
|
+
deps,
|
|
3263
|
+
layout: projection.layout,
|
|
3264
|
+
actions: projection.composer
|
|
3265
|
+
});
|
|
3266
|
+
}
|
|
3267
|
+
function InputDock(props) {
|
|
3268
|
+
const resident = useResident(deps.residency, props.sessionId);
|
|
3269
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SurfaceErrorBoundary, {
|
|
3270
|
+
t: props.t,
|
|
3271
|
+
children: resident ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Body, {
|
|
3272
|
+
...props,
|
|
3273
|
+
owns: INPUT_DOCK_LAYOUTS
|
|
3274
|
+
}) : null
|
|
3275
|
+
});
|
|
3276
|
+
}
|
|
3277
|
+
function ComposerDock(props) {
|
|
3278
|
+
const { sessionId } = props;
|
|
3279
|
+
(0, react.useLayoutEffect)(() => deps.residency.mark(sessionId), [sessionId]);
|
|
3280
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SurfaceErrorBoundary, {
|
|
3281
|
+
t: props.t,
|
|
3282
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Body, {
|
|
3283
|
+
...props,
|
|
3284
|
+
owns: COMPOSER_DOCK_LAYOUTS
|
|
3285
|
+
})
|
|
3286
|
+
});
|
|
3287
|
+
}
|
|
3288
|
+
/**
|
|
3289
|
+
* The centralized management overlay (spec 8.1, 8.3).
|
|
3290
|
+
*
|
|
3291
|
+
* It renders only where a Resident Composer's own "manage" entry could have
|
|
3292
|
+
* opened it, and only for the primary Session, so the global panel stays
|
|
3293
|
+
* single however many composers are on screen.
|
|
3294
|
+
*/
|
|
3295
|
+
function ManagerDock(props) {
|
|
3296
|
+
const primary = usePrimaryResident(deps.residency, props.sessionId);
|
|
3297
|
+
const client = useControllerState(deps.controller);
|
|
3298
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SurfaceErrorBoundary, {
|
|
3299
|
+
t: props.t,
|
|
3300
|
+
children: primary && client.manager.open ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ManagerPanel, {
|
|
3301
|
+
client,
|
|
3302
|
+
controller: deps.controller,
|
|
3303
|
+
t: props.t
|
|
3304
|
+
}) : null
|
|
3305
|
+
});
|
|
3306
|
+
}
|
|
3307
|
+
return {
|
|
3308
|
+
InputDock,
|
|
3309
|
+
ComposerDock,
|
|
3310
|
+
ManagerDock
|
|
3311
|
+
};
|
|
3312
|
+
}
|
|
3313
|
+
/** `ribbon` and `launcher` render above the composer card (spec 8.1). */
|
|
3314
|
+
const INPUT_DOCK_LAYOUTS = ["ribbon", "launcher"];
|
|
3315
|
+
/** `bar` renders below it. */
|
|
3316
|
+
const COMPOSER_DOCK_LAYOUTS = ["bar"];
|
|
3317
|
+
//#endregion
|
|
3318
|
+
//#region src/styles/index.ts
|
|
3319
|
+
/**
|
|
3320
|
+
* The Composer Quick Action surfaces' stylesheet (spec 8.2, 8.4).
|
|
3321
|
+
*
|
|
3322
|
+
* It is shipped as one string installed into a `<style data-plugin-css>` tag,
|
|
3323
|
+
* the same shape and idempotence the first-party DSH client bundles use, rather
|
|
3324
|
+
* than as CSS Modules: the Client build adapter produces one browser CJS file
|
|
3325
|
+
* with no CSS pipeline (spec 11.2), and adding one would change a contract the
|
|
3326
|
+
* bundle's own tests pin. Class names carry the `dsh-cqa-` prefix instead of a
|
|
3327
|
+
* generated hash, which gives the same collision safety by convention.
|
|
3328
|
+
*
|
|
3329
|
+
* Colours are DSH alias theme tokens only — nothing here defines a palette, and
|
|
3330
|
+
* nothing overrides a global theme (spec 8.4).
|
|
3331
|
+
*
|
|
3332
|
+
* ## The width rule (spec 8.2)
|
|
3333
|
+
*
|
|
3334
|
+
* The two layouts sit in different boxes, so they reach the same outer edges by
|
|
3335
|
+
* different arithmetic:
|
|
3336
|
+
*
|
|
3337
|
+
* - `.dsh-cqa-ribbon` renders in the composer stack, outside the InputBar, which
|
|
3338
|
+
* is the element that owns `--dsh-composer-side-clearance` as padding. It
|
|
3339
|
+
* therefore subtracts that clearance from both sides itself.
|
|
3340
|
+
* - `.dsh-cqa-bar` renders as the InputBar's last child, inside that padding, so
|
|
3341
|
+
* it is simply `100%` wide.
|
|
3342
|
+
*
|
|
3343
|
+
* Both are then capped at `--dsh-composer-card-max-width` — the same cap the
|
|
3344
|
+
* composer card uses — and centred, so their left and right edges coincide with
|
|
3345
|
+
* the input box exactly rather than approximately.
|
|
3346
|
+
*/
|
|
3347
|
+
const TAG_ID = "dsh-quick-actions/surfaces.css";
|
|
3348
|
+
const QUICK_ACTIONS_CSS = `
|
|
3349
|
+
.dsh-cqa-ribbon,
|
|
3350
|
+
.dsh-cqa-launcher {
|
|
3351
|
+
box-sizing: border-box;
|
|
3352
|
+
width: calc(100% - var(--dsh-composer-side-clearance) - var(--dsh-composer-side-clearance));
|
|
3353
|
+
max-width: var(--dsh-composer-card-max-width);
|
|
3354
|
+
margin: 0 auto;
|
|
3355
|
+
}
|
|
3356
|
+
.dsh-cqa-bar {
|
|
3357
|
+
box-sizing: border-box;
|
|
3358
|
+
width: 100%;
|
|
3359
|
+
max-width: var(--dsh-composer-card-max-width);
|
|
3360
|
+
margin: 0 auto;
|
|
3361
|
+
}
|
|
3362
|
+
|
|
3363
|
+
.dsh-cqa-row {
|
|
3364
|
+
display: flex;
|
|
3365
|
+
align-items: center;
|
|
3366
|
+
gap: 8px;
|
|
3367
|
+
min-width: 0;
|
|
3368
|
+
padding: 2px 0;
|
|
3369
|
+
}
|
|
3370
|
+
.dsh-cqa-row[data-density='narrow'] {
|
|
3371
|
+
gap: 6px;
|
|
3372
|
+
}
|
|
3373
|
+
|
|
3374
|
+
.dsh-cqa-title {
|
|
3375
|
+
flex: none;
|
|
3376
|
+
color: var(--dsw-alias-label-tertiary);
|
|
3377
|
+
font-size: 12px;
|
|
3378
|
+
font-weight: 500;
|
|
3379
|
+
line-height: 18px;
|
|
3380
|
+
white-space: nowrap;
|
|
3381
|
+
}
|
|
3382
|
+
|
|
3383
|
+
/* The ribbon overflows horizontally; the management entry never scrolls away. */
|
|
3384
|
+
.dsh-cqa-scroll {
|
|
3385
|
+
display: flex;
|
|
3386
|
+
align-items: center;
|
|
3387
|
+
gap: 8px;
|
|
3388
|
+
flex: 1 1 auto;
|
|
3389
|
+
min-width: 0;
|
|
3390
|
+
overflow-x: auto;
|
|
3391
|
+
scrollbar-width: thin;
|
|
3392
|
+
}
|
|
3393
|
+
.dsh-cqa-fit {
|
|
3394
|
+
display: flex;
|
|
3395
|
+
align-items: center;
|
|
3396
|
+
gap: 8px;
|
|
3397
|
+
flex: 1 1 auto;
|
|
3398
|
+
min-width: 0;
|
|
3399
|
+
overflow: hidden;
|
|
3400
|
+
}
|
|
3401
|
+
.dsh-cqa-trailing {
|
|
3402
|
+
display: flex;
|
|
3403
|
+
align-items: center;
|
|
3404
|
+
gap: 8px;
|
|
3405
|
+
flex: none;
|
|
3406
|
+
margin-left: auto;
|
|
3407
|
+
}
|
|
3408
|
+
|
|
3409
|
+
/*
|
|
3410
|
+
* The capsule itself — height, radius, padding, colours, hover and ":disabled"
|
|
3411
|
+
* — belongs to the official "Button". What is left here is what a shared
|
|
3412
|
+
* control cannot know (how it behaves inside these rows) plus what the
|
|
3413
|
+
* primitive does not supply. Nothing below may restate a Button property,
|
|
3414
|
+
* because the plugin class is applied after the primitive's and would win on
|
|
3415
|
+
* equal specificity.
|
|
3416
|
+
*
|
|
3417
|
+
* The focus ring is one of the things it does not supply: "Button.module.css"
|
|
3418
|
+
* and "Pill.module.css" carry no ":focus" rule at all — the library leaves the
|
|
3419
|
+
* ring to each component that wants one ("Input.module.css" has
|
|
3420
|
+
* ":focus-within", "HoverCard" and "JsonTree" their own). Spec 8.4 makes a
|
|
3421
|
+
* clear focus ring a hard gate, so it stays here for every control this plugin
|
|
3422
|
+
* renders.
|
|
3423
|
+
*/
|
|
3424
|
+
.dsh-cqa-action,
|
|
3425
|
+
.dsh-cqa-entry {
|
|
3426
|
+
/* No global border-box reset exists in this stylesheet, and Button pads
|
|
3427
|
+
itself, so the cap has to count that padding to mean 240px on screen. */
|
|
3428
|
+
box-sizing: border-box;
|
|
3429
|
+
flex: none;
|
|
3430
|
+
/* Long labels truncate rather than pushing the row's other controls out. */
|
|
3431
|
+
max-width: 240px;
|
|
3432
|
+
}
|
|
3433
|
+
.dsh-cqa-action:focus-visible,
|
|
3434
|
+
.dsh-cqa-entry:focus-visible {
|
|
3435
|
+
outline: 2px solid var(--dsw-alias-state-business-primary);
|
|
3436
|
+
outline-offset: 1px;
|
|
3437
|
+
}
|
|
3438
|
+
/* "aria-disabled" is not ":disabled": a control kept focusable while a write is
|
|
3439
|
+
in flight still has to read as inert, and no primitive expresses that. */
|
|
3440
|
+
.dsh-cqa-entry[aria-disabled='true'] {
|
|
3441
|
+
opacity: 0.5;
|
|
3442
|
+
cursor: default;
|
|
3443
|
+
}
|
|
3444
|
+
/* The searchable panel's rows stay native — a full-width, left-aligned list row
|
|
3445
|
+
is not a capsule — so they still need a focus ring of their own. */
|
|
3446
|
+
.dsh-cqa-panel-item:focus-visible,
|
|
3447
|
+
.dsh-cqa-link:focus-visible {
|
|
3448
|
+
outline: 2px solid var(--dsw-alias-state-business-primary);
|
|
3449
|
+
outline-offset: 1px;
|
|
3450
|
+
}
|
|
3451
|
+
.dsh-cqa-label {
|
|
3452
|
+
min-width: 0;
|
|
3453
|
+
overflow: hidden;
|
|
3454
|
+
text-overflow: ellipsis;
|
|
3455
|
+
white-space: nowrap;
|
|
3456
|
+
}
|
|
3457
|
+
.dsh-cqa-icon {
|
|
3458
|
+
flex: none;
|
|
3459
|
+
}
|
|
3460
|
+
/* The chip look is "Pill"'s; only its behaviour in a flex row is ours. */
|
|
3461
|
+
.dsh-cqa-badge,
|
|
3462
|
+
.dsh-cqa-tag {
|
|
3463
|
+
flex: none;
|
|
3464
|
+
}
|
|
3465
|
+
|
|
3466
|
+
.dsh-cqa-note {
|
|
3467
|
+
box-sizing: border-box;
|
|
3468
|
+
width: 100%;
|
|
3469
|
+
margin: 4px 0 0;
|
|
3470
|
+
padding: 4px 8px;
|
|
3471
|
+
border-radius: 8px;
|
|
3472
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
3473
|
+
color: var(--dsw-alias-label-secondary);
|
|
3474
|
+
font-size: 12px;
|
|
3475
|
+
line-height: 18px;
|
|
3476
|
+
display: flex;
|
|
3477
|
+
align-items: center;
|
|
3478
|
+
gap: 8px;
|
|
3479
|
+
}
|
|
3480
|
+
.dsh-cqa-note-text {
|
|
3481
|
+
flex: 1 1 auto;
|
|
3482
|
+
min-width: 0;
|
|
3483
|
+
}
|
|
3484
|
+
.dsh-cqa-link {
|
|
3485
|
+
flex: none;
|
|
3486
|
+
border: 0;
|
|
3487
|
+
background: none;
|
|
3488
|
+
padding: 0;
|
|
3489
|
+
color: var(--dsw-alias-state-business-primary);
|
|
3490
|
+
font: inherit;
|
|
3491
|
+
font-size: 12px;
|
|
3492
|
+
cursor: pointer;
|
|
3493
|
+
text-decoration: underline;
|
|
3494
|
+
}
|
|
3495
|
+
|
|
3496
|
+
/* Anchored surfaces: the popover list and the confirmation panel. */
|
|
3497
|
+
.dsh-cqa-anchor {
|
|
3498
|
+
position: relative;
|
|
3499
|
+
}
|
|
3500
|
+
.dsh-cqa-backdrop {
|
|
3501
|
+
position: fixed;
|
|
3502
|
+
z-index: 19;
|
|
3503
|
+
inset: 0;
|
|
3504
|
+
}
|
|
3505
|
+
.dsh-cqa-panel {
|
|
3506
|
+
position: absolute;
|
|
3507
|
+
z-index: 20;
|
|
3508
|
+
bottom: calc(100% + 6px);
|
|
3509
|
+
left: 0;
|
|
3510
|
+
box-sizing: border-box;
|
|
3511
|
+
max-width: min(100%, 360px);
|
|
3512
|
+
min-width: 220px;
|
|
3513
|
+
max-height: 260px;
|
|
3514
|
+
overflow-y: auto;
|
|
3515
|
+
padding: 6px;
|
|
3516
|
+
border: 0.5px solid var(--dsw-alias-border-l1);
|
|
3517
|
+
border-radius: 12px;
|
|
3518
|
+
background: var(--dsw-specific-tip);
|
|
3519
|
+
box-shadow: var(--dsw-elevation-soft);
|
|
3520
|
+
}
|
|
3521
|
+
.dsh-cqa-panel-head {
|
|
3522
|
+
display: flex;
|
|
3523
|
+
align-items: center;
|
|
3524
|
+
gap: 8px;
|
|
3525
|
+
}
|
|
3526
|
+
.dsh-cqa-panel-head .dsh-cqa-panel-title {
|
|
3527
|
+
flex: 1 1 auto;
|
|
3528
|
+
min-width: 0;
|
|
3529
|
+
}
|
|
3530
|
+
.dsh-cqa-panel-title {
|
|
3531
|
+
padding: 4px 8px;
|
|
3532
|
+
color: var(--dsw-alias-label-tertiary);
|
|
3533
|
+
font-size: 12px;
|
|
3534
|
+
line-height: 18px;
|
|
3535
|
+
}
|
|
3536
|
+
.dsh-cqa-panel-item {
|
|
3537
|
+
display: flex;
|
|
3538
|
+
align-items: center;
|
|
3539
|
+
gap: 6px;
|
|
3540
|
+
width: 100%;
|
|
3541
|
+
padding: 6px 8px;
|
|
3542
|
+
border: 0;
|
|
3543
|
+
border-radius: 8px;
|
|
3544
|
+
background: none;
|
|
3545
|
+
color: var(--dsw-alias-label-primary);
|
|
3546
|
+
font: inherit;
|
|
3547
|
+
font-size: 13px;
|
|
3548
|
+
line-height: 20px;
|
|
3549
|
+
text-align: left;
|
|
3550
|
+
cursor: pointer;
|
|
3551
|
+
}
|
|
3552
|
+
.dsh-cqa-panel-item:hover:not(:disabled) {
|
|
3553
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
3554
|
+
}
|
|
3555
|
+
.dsh-cqa-panel-item:disabled {
|
|
3556
|
+
opacity: 0.5;
|
|
3557
|
+
cursor: default;
|
|
3558
|
+
}
|
|
3559
|
+
|
|
3560
|
+
.dsh-cqa-confirm-text {
|
|
3561
|
+
margin: 6px 0;
|
|
3562
|
+
padding: 8px;
|
|
3563
|
+
border-radius: 8px;
|
|
3564
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
3565
|
+
color: var(--dsw-alias-label-primary);
|
|
3566
|
+
font-family: var(--ds-font-family-code);
|
|
3567
|
+
font-size: 12px;
|
|
3568
|
+
line-height: 18px;
|
|
3569
|
+
white-space: pre-wrap;
|
|
3570
|
+
word-break: break-word;
|
|
3571
|
+
max-height: 160px;
|
|
3572
|
+
overflow-y: auto;
|
|
3573
|
+
}
|
|
3574
|
+
.dsh-cqa-confirm-actions {
|
|
3575
|
+
display: flex;
|
|
3576
|
+
justify-content: flex-end;
|
|
3577
|
+
gap: 8px;
|
|
3578
|
+
}
|
|
3579
|
+
|
|
3580
|
+
/* ------------------------------------------------------------------------- */
|
|
3581
|
+
/* Fields, shared by the search box and the Custom Quick Action form */
|
|
3582
|
+
/* ------------------------------------------------------------------------- */
|
|
3583
|
+
|
|
3584
|
+
.dsh-cqa-field {
|
|
3585
|
+
display: flex;
|
|
3586
|
+
flex-direction: column;
|
|
3587
|
+
gap: 2px;
|
|
3588
|
+
padding: 4px 0;
|
|
3589
|
+
}
|
|
3590
|
+
.dsh-cqa-field-label {
|
|
3591
|
+
color: var(--dsw-alias-label-secondary);
|
|
3592
|
+
font-size: 12px;
|
|
3593
|
+
line-height: 18px;
|
|
3594
|
+
}
|
|
3595
|
+
.dsh-cqa-field-hint {
|
|
3596
|
+
color: var(--dsw-alias-label-tertiary);
|
|
3597
|
+
font-size: 11px;
|
|
3598
|
+
line-height: 16px;
|
|
3599
|
+
}
|
|
3600
|
+
.dsh-cqa-field-error {
|
|
3601
|
+
color: var(--dsw-alias-state-danger-primary, var(--dsw-alias-label-primary));
|
|
3602
|
+
font-size: 11px;
|
|
3603
|
+
line-height: 16px;
|
|
3604
|
+
font-weight: 500;
|
|
3605
|
+
}
|
|
3606
|
+
|
|
3607
|
+
.dsh-cqa-input,
|
|
3608
|
+
.dsh-cqa-textarea {
|
|
3609
|
+
box-sizing: border-box;
|
|
3610
|
+
width: 100%;
|
|
3611
|
+
padding: 4px 8px;
|
|
3612
|
+
border: 0.5px solid var(--dsw-alias-border-l1);
|
|
3613
|
+
border-radius: 8px;
|
|
3614
|
+
background: transparent;
|
|
3615
|
+
color: var(--dsw-alias-label-primary);
|
|
3616
|
+
font: inherit;
|
|
3617
|
+
font-size: 13px;
|
|
3618
|
+
line-height: 20px;
|
|
3619
|
+
}
|
|
3620
|
+
.dsh-cqa-textarea {
|
|
3621
|
+
resize: vertical;
|
|
3622
|
+
min-height: 72px;
|
|
3623
|
+
font-family: var(--ds-font-family-code);
|
|
3624
|
+
white-space: pre-wrap;
|
|
3625
|
+
}
|
|
3626
|
+
.dsh-cqa-input:focus-visible,
|
|
3627
|
+
.dsh-cqa-textarea:focus-visible {
|
|
3628
|
+
outline: 2px solid var(--dsw-alias-state-business-primary);
|
|
3629
|
+
outline-offset: 1px;
|
|
3630
|
+
}
|
|
3631
|
+
.dsh-cqa-input:disabled,
|
|
3632
|
+
.dsh-cqa-textarea:disabled {
|
|
3633
|
+
opacity: 0.5;
|
|
3634
|
+
}
|
|
3635
|
+
|
|
3636
|
+
.dsh-cqa-switch {
|
|
3637
|
+
display: flex;
|
|
3638
|
+
align-items: center;
|
|
3639
|
+
gap: 6px;
|
|
3640
|
+
padding: 4px 0;
|
|
3641
|
+
cursor: pointer;
|
|
3642
|
+
}
|
|
3643
|
+
|
|
3644
|
+
/* ------------------------------------------------------------------------- */
|
|
3645
|
+
/* The centralized management overlay */
|
|
3646
|
+
/* ------------------------------------------------------------------------- */
|
|
3647
|
+
|
|
3648
|
+
/*
|
|
3649
|
+
* The overlay and its click-catching backdrop. Neither paints: only DSH alias
|
|
3650
|
+
* theme tokens may be used here, and this release has no token to spend on a
|
|
3651
|
+
* modal scrim (spec 8.4).
|
|
3652
|
+
*
|
|
3653
|
+
* These two numbers rank the overlay against its own backdrop, and nothing
|
|
3654
|
+
* else. Since ticket 26 the overlay is portaled to the document body, so it and
|
|
3655
|
+
* the anchored popovers (19 and 20, still rendered in the input dock) sit in
|
|
3656
|
+
* different stacking contexts: which of them paints on top follows from where
|
|
3657
|
+
* the dock's own ancestors land in the root context, not from comparing 31
|
|
3658
|
+
* against 20. Raising these would not change that.
|
|
3659
|
+
*/
|
|
3660
|
+
.dsh-cqa-manager-backdrop {
|
|
3661
|
+
z-index: 30;
|
|
3662
|
+
}
|
|
3663
|
+
.dsh-cqa-manager {
|
|
3664
|
+
position: fixed;
|
|
3665
|
+
z-index: 31;
|
|
3666
|
+
top: 50%;
|
|
3667
|
+
left: 50%;
|
|
3668
|
+
transform: translate(-50%, -50%);
|
|
3669
|
+
box-sizing: border-box;
|
|
3670
|
+
display: flex;
|
|
3671
|
+
flex-direction: column;
|
|
3672
|
+
gap: 6px;
|
|
3673
|
+
width: min(560px, calc(100vw - 24px));
|
|
3674
|
+
max-height: min(80vh, 680px);
|
|
3675
|
+
overflow-y: auto;
|
|
3676
|
+
padding: 12px;
|
|
3677
|
+
border: 0.5px solid var(--dsw-alias-border-l1);
|
|
3678
|
+
border-radius: 12px;
|
|
3679
|
+
background: var(--dsw-specific-tip);
|
|
3680
|
+
color: var(--dsw-alias-label-primary);
|
|
3681
|
+
box-shadow: var(--dsw-elevation-soft);
|
|
3682
|
+
}
|
|
3683
|
+
|
|
3684
|
+
.dsh-cqa-manager-head {
|
|
3685
|
+
display: flex;
|
|
3686
|
+
align-items: center;
|
|
3687
|
+
gap: 8px;
|
|
3688
|
+
}
|
|
3689
|
+
.dsh-cqa-manager-title {
|
|
3690
|
+
flex: 1 1 auto;
|
|
3691
|
+
min-width: 0;
|
|
3692
|
+
margin: 0;
|
|
3693
|
+
color: var(--dsw-alias-label-primary);
|
|
3694
|
+
font-size: 14px;
|
|
3695
|
+
font-weight: 600;
|
|
3696
|
+
line-height: 20px;
|
|
3697
|
+
}
|
|
3698
|
+
|
|
3699
|
+
.dsh-cqa-section {
|
|
3700
|
+
display: flex;
|
|
3701
|
+
flex-direction: column;
|
|
3702
|
+
gap: 6px;
|
|
3703
|
+
padding: 6px 0;
|
|
3704
|
+
border-top: 0.5px solid var(--dsw-alias-border-l1);
|
|
3705
|
+
}
|
|
3706
|
+
.dsh-cqa-section-head {
|
|
3707
|
+
display: flex;
|
|
3708
|
+
align-items: center;
|
|
3709
|
+
gap: 8px;
|
|
3710
|
+
flex-wrap: wrap;
|
|
3711
|
+
}
|
|
3712
|
+
.dsh-cqa-section-head .dsh-cqa-section-title {
|
|
3713
|
+
flex: 1 1 auto;
|
|
3714
|
+
}
|
|
3715
|
+
.dsh-cqa-section-title {
|
|
3716
|
+
color: var(--dsw-alias-label-secondary);
|
|
3717
|
+
font-size: 12px;
|
|
3718
|
+
font-weight: 500;
|
|
3719
|
+
line-height: 18px;
|
|
3720
|
+
}
|
|
3721
|
+
.dsh-cqa-group {
|
|
3722
|
+
display: flex;
|
|
3723
|
+
align-items: center;
|
|
3724
|
+
gap: 6px;
|
|
3725
|
+
flex-wrap: wrap;
|
|
3726
|
+
}
|
|
3727
|
+
.dsh-cqa-entry[aria-pressed='true'] {
|
|
3728
|
+
background: var(--dsw-alias-state-business-tertiary);
|
|
3729
|
+
color: var(--dsw-alias-label-primary-bluish);
|
|
3730
|
+
opacity: 1;
|
|
3731
|
+
}
|
|
3732
|
+
|
|
3733
|
+
.dsh-cqa-list {
|
|
3734
|
+
display: flex;
|
|
3735
|
+
flex-direction: column;
|
|
3736
|
+
gap: 6px;
|
|
3737
|
+
margin: 0;
|
|
3738
|
+
padding: 0;
|
|
3739
|
+
list-style: none;
|
|
3740
|
+
}
|
|
3741
|
+
.dsh-cqa-list-item {
|
|
3742
|
+
display: flex;
|
|
3743
|
+
flex-direction: column;
|
|
3744
|
+
gap: 4px;
|
|
3745
|
+
padding: 6px 8px;
|
|
3746
|
+
border-radius: 8px;
|
|
3747
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
3748
|
+
}
|
|
3749
|
+
/* A hidden or disabled action stays legible: it is managed here, not removed. */
|
|
3750
|
+
.dsh-cqa-list-item[data-quick-action-hidden] .dsh-cqa-list-head,
|
|
3751
|
+
.dsh-cqa-list-item[data-quick-action-hidden] .dsh-cqa-list-text {
|
|
3752
|
+
opacity: 0.6;
|
|
3753
|
+
}
|
|
3754
|
+
.dsh-cqa-list-head {
|
|
3755
|
+
display: flex;
|
|
3756
|
+
align-items: center;
|
|
3757
|
+
gap: 6px;
|
|
3758
|
+
flex-wrap: wrap;
|
|
3759
|
+
min-width: 0;
|
|
3760
|
+
font-size: 13px;
|
|
3761
|
+
line-height: 20px;
|
|
3762
|
+
}
|
|
3763
|
+
.dsh-cqa-list-text {
|
|
3764
|
+
min-width: 0;
|
|
3765
|
+
overflow: hidden;
|
|
3766
|
+
color: var(--dsw-alias-label-tertiary);
|
|
3767
|
+
font-family: var(--ds-font-family-code);
|
|
3768
|
+
font-size: 11px;
|
|
3769
|
+
line-height: 16px;
|
|
3770
|
+
white-space: nowrap;
|
|
3771
|
+
text-overflow: ellipsis;
|
|
3772
|
+
}
|
|
3773
|
+
.dsh-cqa-list-controls {
|
|
3774
|
+
display: flex;
|
|
3775
|
+
align-items: center;
|
|
3776
|
+
gap: 4px;
|
|
3777
|
+
flex-wrap: wrap;
|
|
3778
|
+
}
|
|
3779
|
+
|
|
3780
|
+
.dsh-cqa-form {
|
|
3781
|
+
display: flex;
|
|
3782
|
+
flex-direction: column;
|
|
3783
|
+
gap: 2px;
|
|
3784
|
+
padding: 6px 0 0;
|
|
3785
|
+
border-top: 0.5px solid var(--dsw-alias-border-l1);
|
|
3786
|
+
}
|
|
3787
|
+
.dsh-cqa-form-actions {
|
|
3788
|
+
display: flex;
|
|
3789
|
+
justify-content: flex-end;
|
|
3790
|
+
gap: 8px;
|
|
3791
|
+
padding-top: 6px;
|
|
3792
|
+
}
|
|
3793
|
+
`;
|
|
3794
|
+
/**
|
|
3795
|
+
* Install the stylesheet once per document and return its disposer, so the
|
|
3796
|
+
* Client fiber owns it like every other registration (spec 7.3).
|
|
3797
|
+
*
|
|
3798
|
+
* The holder count lives on the tag rather than in this module, because a hot
|
|
3799
|
+
* reload runs two bundle instances at once: the new fiber installs before the
|
|
3800
|
+
* old one unloads, and each has its own module scope. Counting in the DOM is
|
|
3801
|
+
* what keeps the surviving instance's styles from being removed underneath it.
|
|
3802
|
+
*/
|
|
3803
|
+
function installQuickActionStyles() {
|
|
3804
|
+
if (typeof document === "undefined") return () => {};
|
|
3805
|
+
const selector = `style[data-plugin-css=${JSON.stringify(TAG_ID)}]`;
|
|
3806
|
+
const existing = document.querySelector(selector);
|
|
3807
|
+
const tag = existing ?? document.createElement("style");
|
|
3808
|
+
if (existing === null) {
|
|
3809
|
+
tag.dataset["plugin"] = "dsh-quick-actions";
|
|
3810
|
+
tag.dataset["pluginCss"] = TAG_ID;
|
|
3811
|
+
tag.textContent = QUICK_ACTIONS_CSS;
|
|
3812
|
+
document.head.appendChild(tag);
|
|
3813
|
+
}
|
|
3814
|
+
tag.dataset["pluginCssHolders"] = String(holdersOf(tag) + 1);
|
|
3815
|
+
let released = false;
|
|
3816
|
+
return () => {
|
|
3817
|
+
if (released) return;
|
|
3818
|
+
released = true;
|
|
3819
|
+
const left = holdersOf(tag) - 1;
|
|
3820
|
+
if (left > 0) {
|
|
3821
|
+
tag.dataset["pluginCssHolders"] = String(left);
|
|
3822
|
+
return;
|
|
3823
|
+
}
|
|
3824
|
+
tag.remove();
|
|
3825
|
+
};
|
|
3826
|
+
}
|
|
3827
|
+
function holdersOf(tag) {
|
|
3828
|
+
const held = Number(tag.dataset["pluginCssHolders"]);
|
|
3829
|
+
return Number.isFinite(held) && held > 0 ? held : 0;
|
|
3830
|
+
}
|
|
3831
|
+
//#endregion
|
|
3832
|
+
//#region src/client/index.tsx
|
|
3833
|
+
const name = "composer-quick-actions";
|
|
3834
|
+
/**
|
|
3835
|
+
* The services this Client requires (spec 7.3). `conversation` is deliberately
|
|
3836
|
+
* absent: its Composer-block registry is read through `ctx.get`, the documented
|
|
3837
|
+
* read "without the inject requirement", because the service is guaranteed
|
|
3838
|
+
* present wherever the two dock Slots it declares are rendered.
|
|
3839
|
+
*/
|
|
3840
|
+
const inject = [
|
|
3841
|
+
"slots",
|
|
3842
|
+
"settingsScope",
|
|
3843
|
+
"connection",
|
|
3844
|
+
"locale"
|
|
3845
|
+
];
|
|
3846
|
+
/** Ascending position among the shipped dock entries; late enough to sit last. */
|
|
3847
|
+
const DOCK_ORDER = 100;
|
|
3848
|
+
/** The management overlay's own cell, right after the layout's (spec 8.1). */
|
|
3849
|
+
const MANAGER_ORDER = 101;
|
|
3850
|
+
/**
|
|
3851
|
+
* Start the feature and tie every piece of it to this fiber.
|
|
3852
|
+
*
|
|
3853
|
+
* Nothing is returned and no internal type is re-exported: the controller, the
|
|
3854
|
+
* execution engine and the surfaces are internal to this package (spec 14).
|
|
3855
|
+
*/
|
|
3856
|
+
function apply(ctx) {
|
|
3857
|
+
const controller = createQuickActionsController({
|
|
3858
|
+
settingsScope: ctx.settingsScope,
|
|
3859
|
+
connection: ctx.connection
|
|
3860
|
+
});
|
|
3861
|
+
const sessions = createQuickActionSessionRegistry();
|
|
3862
|
+
const residency = createResidentComposerRegistry();
|
|
3863
|
+
ctx.effect(() => () => {
|
|
3864
|
+
sessions.dispose();
|
|
3865
|
+
controller.dispose();
|
|
3866
|
+
}, "composer-quick-actions: client controller");
|
|
3867
|
+
ctx.effect(() => ctx.locale.register(QUICK_ACTIONS_LOCALE_NAMESPACE, quickActionsDictionaries), "composer-quick-actions: dictionaries");
|
|
3868
|
+
ctx.effect(installQuickActionStyles, "composer-quick-actions: surface styles");
|
|
3869
|
+
const { InputDock, ComposerDock, ManagerDock } = createQuickActionDockEntries({
|
|
3870
|
+
controller,
|
|
3871
|
+
sessions,
|
|
3872
|
+
residency,
|
|
3873
|
+
blocks: () => ctx.get("conversation")?.blocks
|
|
3874
|
+
});
|
|
3875
|
+
ctx.slots.inject("conversation.input.dock", () => [ctx.slots.register({
|
|
3876
|
+
name: "conversation.input.dock",
|
|
3877
|
+
id: "composer-quick-actions",
|
|
3878
|
+
order: DOCK_ORDER,
|
|
3879
|
+
locale: QUICK_ACTIONS_LOCALE_NAMESPACE
|
|
3880
|
+
}, InputDock), ctx.slots.register({
|
|
3881
|
+
name: "conversation.input.dock",
|
|
3882
|
+
id: "composer-quick-actions-manager",
|
|
3883
|
+
order: MANAGER_ORDER,
|
|
3884
|
+
locale: QUICK_ACTIONS_LOCALE_NAMESPACE
|
|
3885
|
+
}, ManagerDock)]);
|
|
3886
|
+
ctx.slots.inject("conversation.composer.dock", () => ctx.slots.register({
|
|
3887
|
+
name: "conversation.composer.dock",
|
|
3888
|
+
id: "composer-quick-actions",
|
|
3889
|
+
order: DOCK_ORDER,
|
|
3890
|
+
locale: QUICK_ACTIONS_LOCALE_NAMESPACE
|
|
3891
|
+
}, ComposerDock));
|
|
3892
|
+
}
|
|
3893
|
+
//#endregion
|
|
3894
|
+
exports.apply = apply;
|
|
3895
|
+
exports.inject = inject;
|
|
3896
|
+
exports.name = name;
|
|
3897
|
+
return module.exports;
|
|
3898
|
+
}
|
|
3899
|
+
});
|
|
3900
|
+
|
|
3901
|
+
//# sourceMappingURL=client.js.map
|