dsh-approval-review 0.2.0

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/lib/client.js ADDED
@@ -0,0 +1,1014 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-approval-review",
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
+ /**
10
+ * Reset the nearest scrollable ancestor that actually overflows.
11
+ *
12
+ * "Actually overflows" matters: an ancestor with `overflow-y: auto` but no
13
+ * overflow cannot be scrolled, so resetting it is a no-op that would hide the
14
+ * real scroller further up.
15
+ * @param from - the element whose ancestry to search (usually the view root).
16
+ * @param overflowYOf - computed `overflow-y` accessor for one node.
17
+ * @param maxDepth - stop after this many ancestors.
18
+ * @returns the node that was reset, or undefined when none qualified.
19
+ */
20
+ function resetScrollableAncestorToTop(from, overflowYOf, maxDepth = 12) {
21
+ let node = from?.parentElement ?? null;
22
+ for (let depth = 0; node !== null && depth < maxDepth; depth += 1, node = node.parentElement) {
23
+ const overflowY = overflowYOf(node);
24
+ if (overflowY !== "auto" && overflowY !== "scroll") continue;
25
+ if (node.scrollHeight <= node.clientHeight) continue;
26
+ node.scrollTop = 0;
27
+ return node;
28
+ }
29
+ }
30
+ //#endregion
31
+ //#region src/client/model-choices.ts
32
+ /**
33
+ * Reviewer-route choices for the Approvals tab's model picker.
34
+ *
35
+ * The reviewer runs as a subagent, so the routes a deployment actually offers it
36
+ * are already published as session projections — this module just reads them
37
+ * instead of hardcoding a model list that would go stale:
38
+ *
39
+ * - `subagentModelSelectionPolicy`: the deployment's allowed subagent routes
40
+ * (`subagent-model-selection.allowedModels` in `settings.yaml`);
41
+ * - `modelSelection.lastUsed`: the session's own route, i.e. what "inherit"
42
+ * resolves to.
43
+ *
44
+ * Everything is read structurally and defensively: a projection this host does
45
+ * not publish, or an entry with a non-string half, contributes nothing rather
46
+ * than breaking the picker.
47
+ * @module dsh-approval-review/client/model-choices
48
+ */
49
+ /** One `{provider, model}` route, when both halves are strings. */
50
+ function routeOf(value) {
51
+ if (typeof value !== "object" || value === null) return void 0;
52
+ const entry = value;
53
+ if (typeof entry.provider !== "string" || typeof entry.model !== "string") return void 0;
54
+ if (entry.provider.length === 0 || entry.model.length === 0) return void 0;
55
+ return `${entry.provider}/${entry.model}`;
56
+ }
57
+ /** Every valid route in a projection that carries a list of them. */
58
+ function routesOf(value) {
59
+ if (!Array.isArray(value)) return [];
60
+ const out = [];
61
+ for (const entry of value) {
62
+ const route = routeOf(entry);
63
+ if (route !== void 0) out.push(route);
64
+ }
65
+ return out;
66
+ }
67
+ /**
68
+ * Every route in a client model-directory snapshot (`modelDirectories` service).
69
+ *
70
+ * This is the SAME catalog the composer's model seat and the `/model` picker
71
+ * read, so the reviewer picker offers exactly the models the deployment
72
+ * configures locally — minus anything the catalog failed to load, which it
73
+ * reports separately and which we deliberately do not guess at.
74
+ * @param value - the directory state returned by `directoryFor(session).load()`.
75
+ * @returns distinct `provider/model` labels in catalog order.
76
+ */
77
+ function routesFromDirectory(value) {
78
+ if (typeof value !== "object" || value === null) return [];
79
+ const groups = value.groups;
80
+ if (!Array.isArray(groups)) return [];
81
+ const out = [];
82
+ for (const group of groups) {
83
+ if (typeof group !== "object" || group === null) continue;
84
+ const id = group.id;
85
+ const models = group.models;
86
+ if (typeof id !== "string" || id.length === 0 || !Array.isArray(models)) continue;
87
+ for (const model of models) {
88
+ if (typeof model !== "object" || model === null) continue;
89
+ const modelId = model.id;
90
+ if (typeof modelId !== "string" || modelId.length === 0) continue;
91
+ const route = `${id}/${modelId}`;
92
+ if (!out.includes(route)) out.push(route);
93
+ }
94
+ }
95
+ return out;
96
+ }
97
+ /**
98
+ * Build the picker's option list.
99
+ *
100
+ * Order is deliberate: the session override in force first (so a route chosen
101
+ * outside the deployment's list still shows as the current selection), then the
102
+ * session's own model, then the deployment's allowed subagent routes. Duplicates
103
+ * collapse, so a model that is both the session default and an allowed route
104
+ * appears once.
105
+ * @param input - the projections' raw values plus the override in force.
106
+ * @returns distinct `provider/model` labels, in display order.
107
+ */
108
+ function reviewerRouteChoices(input) {
109
+ const out = [];
110
+ const push = (route) => {
111
+ if (route === void 0 || route.length === 0 || out.includes(route)) return;
112
+ out.push(route);
113
+ };
114
+ push(input.current);
115
+ push(routeOf(typeof input.sessionDefault === "object" && input.sessionDefault !== null ? input.sessionDefault.lastUsed : void 0));
116
+ for (const route of routesOf(input.allowed)) push(route);
117
+ return out;
118
+ }
119
+ /**
120
+ * Filter routes for the picker's list.
121
+ *
122
+ * Matching is a case-insensitive substring over the whole `provider/model`
123
+ * label, so typing `luna` and typing `codex/luna` both narrow to the same row.
124
+ * An empty (or whitespace-only) query keeps the whole list.
125
+ * @param routes - candidate labels.
126
+ * @param query - what the operator typed.
127
+ * @returns the matching labels, in input order.
128
+ */
129
+ function filterRoutes(routes, query) {
130
+ const needle = query.trim().toLowerCase();
131
+ if (needle.length === 0) return routes;
132
+ return routes.filter((route) => route.toLowerCase().includes(needle));
133
+ }
134
+ //#endregion
135
+ //#region src/client/ModelPicker.tsx
136
+ /**
137
+ * The reviewer-model picker.
138
+ *
139
+ * A native `<datalist>` (or `<select>`) popup is drawn by the browser, not the
140
+ * page: its font, weight, and width ignore CSS entirely, which made the list
141
+ * read as a different, much louder control than the tab it sits in. This is the
142
+ * plugin's own listbox instead, styled with the ledger's own type scale, with a
143
+ * free-text field on top so an id the catalog no longer advertises stays
144
+ * reachable (catalog membership is advisory).
145
+ * @module dsh-approval-review/client/ModelPicker
146
+ */
147
+ const TEXT$1 = "var(--dsw-alias-label-primary, #e6edf3)";
148
+ const MUTED$1 = "var(--dsw-alias-label-tertiary, #8b949e)";
149
+ const BORDER$1 = "var(--dsw-alias-border-l2, #30363d)";
150
+ const PANEL$1 = "var(--dsw-alias-bg-layer-2, #161b22)";
151
+ const HOVER = "var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,.08))";
152
+ const CODE$1 = "var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace)";
153
+ /** The picker's field and its plugin-rendered list. */
154
+ function ModelPicker({ choices, runCommand, loadModels, zh, onChoicesLoaded }) {
155
+ const [draft, setDraft] = (0, react.useState)("");
156
+ const [open, setOpen] = (0, react.useState)(false);
157
+ const [highlight, setHighlight] = (0, react.useState)(0);
158
+ const rootRef = (0, react.useRef)(null);
159
+ const requestedRef = (0, react.useRef)(false);
160
+ const matches = (0, react.useMemo)(() => filterRoutes(choices, draft), [choices, draft]);
161
+ /** Fetch the catalog once, on first interaction. */
162
+ const loadOnce = () => {
163
+ if (requestedRef.current || loadModels === void 0) return;
164
+ requestedRef.current = true;
165
+ loadModels().then((routes) => onChoicesLoaded(routes)).catch(() => onChoicesLoaded([]));
166
+ };
167
+ (0, react.useEffect)(() => {
168
+ if (!open) return;
169
+ const onPointerDown = (event) => {
170
+ if (rootRef.current?.contains(event.target) === true) return;
171
+ setOpen(false);
172
+ };
173
+ document.addEventListener("mousedown", onPointerDown);
174
+ return () => {
175
+ document.removeEventListener("mousedown", onPointerDown);
176
+ };
177
+ }, [open]);
178
+ const apply = (route) => {
179
+ const value = route.trim();
180
+ if (value.length === 0) return;
181
+ runCommand(`/approval-review model ${value}`);
182
+ setDraft("");
183
+ setOpen(false);
184
+ };
185
+ const fieldStyle = {
186
+ fontFamily: CODE$1,
187
+ fontSize: 11,
188
+ lineHeight: "16px",
189
+ padding: "2px 6px",
190
+ width: 190,
191
+ borderRadius: 6,
192
+ border: `1px solid ${BORDER$1}`,
193
+ background: "transparent",
194
+ color: TEXT$1,
195
+ outline: "none"
196
+ };
197
+ const itemStyle = {
198
+ display: "block",
199
+ width: "100%",
200
+ textAlign: "left",
201
+ background: "transparent",
202
+ border: "none",
203
+ borderRadius: 4,
204
+ cursor: "pointer",
205
+ fontFamily: CODE$1,
206
+ fontSize: 11,
207
+ fontWeight: 400,
208
+ lineHeight: "16px",
209
+ padding: "3px 8px",
210
+ color: TEXT$1
211
+ };
212
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
213
+ ref: rootRef,
214
+ style: {
215
+ position: "relative",
216
+ display: "inline-flex"
217
+ },
218
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
219
+ value: draft,
220
+ role: "combobox",
221
+ "aria-expanded": open,
222
+ "aria-label": zh ? "复核模型" : "reviewer model",
223
+ placeholder: zh ? "选择或输入模型" : "pick or type a model",
224
+ style: fieldStyle,
225
+ onFocus: () => {
226
+ loadOnce();
227
+ setOpen(true);
228
+ },
229
+ onClick: () => {
230
+ loadOnce();
231
+ setOpen(true);
232
+ },
233
+ onChange: (event) => {
234
+ setDraft(event.target.value);
235
+ setHighlight(0);
236
+ setOpen(true);
237
+ },
238
+ onKeyDown: (event) => {
239
+ if (event.key === "Escape") {
240
+ setOpen(false);
241
+ return;
242
+ }
243
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
244
+ event.preventDefault();
245
+ if (matches.length === 0) return;
246
+ setOpen(true);
247
+ setHighlight((current) => {
248
+ return ((event.key === "ArrowDown" ? current + 1 : current - 1) + matches.length) % matches.length;
249
+ });
250
+ return;
251
+ }
252
+ if (event.key !== "Enter") return;
253
+ const picked = open ? matches[highlight] : void 0;
254
+ apply(picked ?? draft);
255
+ }
256
+ }), open ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
257
+ role: "listbox",
258
+ style: {
259
+ position: "absolute",
260
+ top: "calc(100% + 4px)",
261
+ left: 0,
262
+ zIndex: 60,
263
+ minWidth: "100%",
264
+ maxWidth: 320,
265
+ maxHeight: 220,
266
+ overflowY: "auto",
267
+ background: PANEL$1,
268
+ border: `1px solid ${BORDER$1}`,
269
+ borderRadius: 8,
270
+ padding: 4,
271
+ boxShadow: "0 8px 24px rgba(0,0,0,.45)"
272
+ },
273
+ children: matches.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
274
+ style: {
275
+ ...itemStyle,
276
+ color: MUTED$1,
277
+ cursor: "default"
278
+ },
279
+ children: choices.length === 0 ? zh ? "没有可选模型,直接输入 id 后回车" : "no models to pick from — type an id and press Enter" : zh ? "没有匹配的模型" : "no matching model"
280
+ }) : matches.map((route, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
281
+ type: "button",
282
+ role: "option",
283
+ "aria-selected": index === highlight,
284
+ style: {
285
+ ...itemStyle,
286
+ background: index === highlight ? HOVER : "transparent"
287
+ },
288
+ onMouseEnter: () => setHighlight(index),
289
+ onClick: () => apply(route),
290
+ children: route
291
+ }, route))
292
+ }) : null]
293
+ });
294
+ }
295
+ //#endregion
296
+ //#region src/client/LedgerView.tsx
297
+ /**
298
+ * The approval ledger as a full conversation tab.
299
+ *
300
+ * This is the "look at everything that was reviewed" surface: one row per
301
+ * approval request with the action, the verdict, the reviewer's full rationale,
302
+ * the safer alternative it suggested, the risk grade, which rule routed it, the
303
+ * reviewer route and timing, and the expandable arguments. The header card is the
304
+ * at-a-glance control; this tab is the audit record.
305
+ *
306
+ * It reads the same `approvalReview` projection as the card, so the two can never
307
+ * disagree, and it holds no state of its own.
308
+ * @module dsh-approval-review/client/LedgerView
309
+ */
310
+ const TEXT = "var(--dsw-alias-label-primary, #e6edf3)";
311
+ const MUTED = "var(--dsw-alias-label-tertiary, #8b949e)";
312
+ const BORDER = "var(--dsw-alias-border-l2, #30363d)";
313
+ const PANEL = "var(--dsw-alias-bg-layer-2, #161b22)";
314
+ const ROW = "var(--dsw-alias-bg-layer-1, #0d1117)";
315
+ const ALLOWED = "var(--dsw-alias-state-success-primary, #2ea043)";
316
+ const REFUSED = "var(--dsw-alias-state-error-primary, #f85149)";
317
+ const WARN = "var(--dsw-alias-state-warn-primary, #d29922)";
318
+ const CODE = "var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace)";
319
+ /** Risk tone; unknown risk is neutral rather than reassuring. */
320
+ function riskTone(risk) {
321
+ if (risk === "low") return ALLOWED;
322
+ if (risk === "medium") return WARN;
323
+ if (risk === "high" || risk === "critical") return REFUSED;
324
+ return MUTED;
325
+ }
326
+ /**
327
+ * Whether the plugin was even responsible for one row, from the policy that
328
+ * routed it. A `human` or `never` row sits on the card because the user asked
329
+ * for a record of every approval, NOT because a reviewer judged it.
330
+ */
331
+ function routingTag(record, zh) {
332
+ if (record.policy === "never") return zh ? "硬禁用" : "hard-disabled";
333
+ if (record.policy === "human") return zh ? "交还人工" : "delegated";
334
+ }
335
+ /**
336
+ * The rationale line, told truthfully.
337
+ *
338
+ * A missing rationale has three very different causes and the row must not
339
+ * blame the wrong one: a `never` row never ran a reviewer, a `human` row was
340
+ * handed back to the human answerer, and an `ai` row either never reached the
341
+ * reviewer or completed with the allow rationale left unpersisted
342
+ * (`recordAllowedVerdicts: false`, or a value-projection accept).
343
+ */
344
+ function rationaleText(record, zh) {
345
+ if (record.reason !== void 0) return record.reason;
346
+ if (record.policy === "never") return zh ? "按 never 策略硬禁用,没有经过复核模型。" : "Hard-disabled by the never policy; no reviewer ran.";
347
+ if (record.policy === "human") return zh ? "已交还人工应答者,本插件没有裁决这一次。" : "Delegated to the human answerer; this plugin did not decide it.";
348
+ return record.refused ? zh ? "被否决,但本行没有留下理由记录。" : "Refused, but no rationale was recorded." : zh ? "已放行;本行没有留下理由记录(该请求未走到复核模型,或核可理由未落盘)。" : "Allowed, but no rationale was recorded (the request never reached the reviewer, or its allow rationale was not persisted).";
349
+ }
350
+ /** Short wall-clock stamp. */
351
+ function stamp(epochMs) {
352
+ const d = new Date(epochMs);
353
+ const p = (n) => String(n).padStart(2, "0");
354
+ return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
355
+ }
356
+ /** A labelled field row. */
357
+ function Field({ label, children, mono }) {
358
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
359
+ style: {
360
+ display: "flex",
361
+ gap: 10,
362
+ alignItems: "baseline"
363
+ },
364
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
365
+ style: {
366
+ color: MUTED,
367
+ flex: "0 0 auto",
368
+ width: 76,
369
+ fontSize: 11
370
+ },
371
+ children: label
372
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
373
+ style: {
374
+ color: TEXT,
375
+ fontSize: mono === true ? 11 : 12,
376
+ fontFamily: mono === true ? CODE : void 0,
377
+ whiteSpace: "pre-wrap",
378
+ wordBreak: "break-word",
379
+ flex: "1 1 auto"
380
+ },
381
+ children
382
+ })]
383
+ });
384
+ }
385
+ /** Merge the base list with the loaded catalog, keeping the base order first. */
386
+ function reviewerRoutesMerge(base, loaded) {
387
+ const out = [...base];
388
+ for (const route of loaded) if (!out.includes(route)) out.push(route);
389
+ return out;
390
+ }
391
+ /** One ledger entry, expanded. */
392
+ function Entry({ record, zh, onApprove, deniedIndex }) {
393
+ const [showArgs, setShowArgs] = (0, react.useState)(false);
394
+ const pending = record.outcome === void 0;
395
+ const verdict = pending ? zh ? "进行中" : "pending" : record.refused ? zh ? "否决" : "refused" : record.outcome === "allowed-once" ? zh ? "放行" : "allowed" : zh ? "转人工" : "delegated";
396
+ const tone = pending ? MUTED : record.refused ? REFUSED : ALLOWED;
397
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
398
+ style: {
399
+ border: `1px solid ${BORDER}`,
400
+ borderLeft: `3px solid ${tone}`,
401
+ borderRadius: 8,
402
+ background: ROW,
403
+ padding: "12px 14px",
404
+ display: "flex",
405
+ flexDirection: "column",
406
+ gap: 8
407
+ },
408
+ children: [
409
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
410
+ style: {
411
+ display: "flex",
412
+ gap: 8,
413
+ alignItems: "center",
414
+ flexWrap: "wrap"
415
+ },
416
+ children: [
417
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
418
+ style: {
419
+ fontFamily: CODE,
420
+ fontSize: 13,
421
+ fontWeight: 600,
422
+ color: TEXT
423
+ },
424
+ children: record.toolName
425
+ }),
426
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
427
+ style: {
428
+ fontSize: 11,
429
+ color: tone,
430
+ border: `1px solid ${tone}`,
431
+ borderRadius: 999,
432
+ padding: "1px 7px"
433
+ },
434
+ children: verdict
435
+ }),
436
+ routingTag(record, zh) === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
437
+ style: {
438
+ fontSize: 11,
439
+ color: MUTED,
440
+ border: `1px solid ${BORDER}`,
441
+ borderRadius: 999,
442
+ padding: "1px 7px"
443
+ },
444
+ children: routingTag(record, zh)
445
+ }),
446
+ record.risk === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
447
+ style: {
448
+ fontSize: 11,
449
+ color: riskTone(record.risk)
450
+ },
451
+ children: [
452
+ zh ? "风险" : "risk",
453
+ " ",
454
+ record.risk
455
+ ]
456
+ }),
457
+ record.overridden ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
458
+ style: {
459
+ fontSize: 11,
460
+ color: WARN
461
+ },
462
+ children: zh ? "含人工一次性授权" : "human override"
463
+ }) : null,
464
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
465
+ style: {
466
+ marginLeft: "auto",
467
+ fontSize: 11,
468
+ color: MUTED
469
+ },
470
+ children: [
471
+ stamp(record.startedAt),
472
+ " · T",
473
+ record.turn,
474
+ "/S",
475
+ record.step,
476
+ record.durationMs === void 0 ? "" : ` · ${record.durationMs} ms`
477
+ ]
478
+ })
479
+ ]
480
+ }),
481
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
482
+ label: zh ? "裁决理由" : "rationale",
483
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
484
+ style: { color: record.reason === void 0 ? MUTED : TEXT },
485
+ children: rationaleText(record, zh)
486
+ })
487
+ }),
488
+ record.suggestion === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
489
+ label: zh ? "更安全的做法" : "safer path",
490
+ children: record.suggestion
491
+ }),
492
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Field, {
493
+ label: zh ? "路由策略" : "routing",
494
+ mono: true,
495
+ children: [
496
+ record.policy,
497
+ " · ",
498
+ record.policySource
499
+ ]
500
+ }),
501
+ record.askReason === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
502
+ label: zh ? "申请理由" : "asked why",
503
+ children: record.askReason
504
+ }),
505
+ record.reviewerRoute === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Field, {
506
+ label: zh ? "复核模型" : "reviewer",
507
+ mono: true,
508
+ children: [record.reviewerRoute, record.uncertain ? ` · ${zh ? "不确定" : "uncertain"}` : ""]
509
+ }),
510
+ record.argumentsPreview === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
511
+ type: "button",
512
+ onClick: () => setShowArgs((v) => !v),
513
+ style: {
514
+ background: "none",
515
+ border: "none",
516
+ padding: 0,
517
+ cursor: "pointer",
518
+ color: "var(--dsw-alias-link, #58a6ff)",
519
+ fontSize: 11
520
+ },
521
+ children: showArgs ? zh ? "收起参数" : "hide arguments" : zh ? "查看参数" : "show arguments"
522
+ }), showArgs ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", {
523
+ style: {
524
+ margin: "6px 0 0",
525
+ padding: 10,
526
+ borderRadius: 6,
527
+ background: PANEL,
528
+ fontFamily: CODE,
529
+ fontSize: 11,
530
+ color: TEXT,
531
+ whiteSpace: "pre-wrap",
532
+ wordBreak: "break-word",
533
+ maxHeight: 320,
534
+ overflow: "auto"
535
+ },
536
+ children: record.argumentsPreview
537
+ }) : null] }),
538
+ record.refused && record.outcome === "rejected" && onApprove !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
539
+ type: "button",
540
+ onClick: () => onApprove(record, deniedIndex),
541
+ style: {
542
+ alignSelf: "flex-start",
543
+ cursor: "pointer",
544
+ fontSize: 11,
545
+ padding: "3px 9px",
546
+ borderRadius: 6,
547
+ border: `1px solid ${BORDER}`,
548
+ background: "transparent",
549
+ color: TEXT
550
+ },
551
+ children: zh ? `授权重试第 ${deniedIndex} 条否决` : `approve denial #${deniedIndex} for one retry`
552
+ }) : null
553
+ ]
554
+ });
555
+ }
556
+ /** The full ledger tab. */
557
+ /**
558
+ * Start a freshly opened ledger at its top.
559
+ *
560
+ * The tab renders inside the conversation's resident scrollport, which the
561
+ * transcript keeps pinned to its newest line, so a ledger mounted under it would
562
+ * show its own BOTTOM — while the ledger lists the newest decision FIRST. The
563
+ * walk that finds the box to reset lives in `./scroll.ts`.
564
+ * @param root - the ledger's root element.
565
+ */
566
+ function useStartAtTop(root) {
567
+ (0, react.useEffect)(() => {
568
+ const element = root.current;
569
+ if (element === null) return;
570
+ element.scrollTop = 0;
571
+ if (typeof window === "undefined") return;
572
+ resetScrollableAncestorToTop(element, (node) => window.getComputedStyle(node).overflowY);
573
+ }, [root]);
574
+ }
575
+ function LedgerView({ view, zh, runCommand, modelChoices, loadModels }) {
576
+ const rootRef = (0, react.useRef)(null);
577
+ useStartAtTop(rootRef);
578
+ const [loadedChoices, setLoadedChoices] = (0, react.useState)(void 0);
579
+ const choices = loadedChoices ?? modelChoices ?? [];
580
+ const records = view?.records ?? [];
581
+ const denials = (0, react.useMemo)(() => records.filter((r) => r.refused), [records]);
582
+ const reviewedCount = (0, react.useMemo)(() => records.filter((r) => r.policy === "ai").length, [records]);
583
+ const deniedIndexOf = (record) => denials.findIndex((d) => d.reviewId === record.reviewId) + 1;
584
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
585
+ ref: rootRef,
586
+ style: {
587
+ padding: "14px 16px",
588
+ overflow: "auto",
589
+ height: "100%",
590
+ fontFamily: "inherit"
591
+ },
592
+ children: [
593
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
594
+ style: {
595
+ display: "flex",
596
+ gap: 12,
597
+ alignItems: "center",
598
+ flexWrap: "wrap",
599
+ padding: "0 2px 10px",
600
+ borderBottom: `1px solid ${BORDER}`,
601
+ marginBottom: 12
602
+ },
603
+ children: [
604
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", {
605
+ style: {
606
+ fontSize: 14,
607
+ color: TEXT
608
+ },
609
+ children: zh ? "审批审计" : "Approval audit"
610
+ }),
611
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
612
+ style: {
613
+ fontSize: 12,
614
+ color: view?.enabled === false ? MUTED : ALLOWED
615
+ },
616
+ children: view === void 0 ? zh ? "尚无数据" : "no data" : view.enabled ? zh ? "自动审批已开启" : "auto-approval on" : zh ? "自动审批已关闭" : "auto-approval off"
617
+ }),
618
+ runCommand === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
619
+ type: "button",
620
+ disabled: view?.enabled === true,
621
+ onClick: () => runCommand("/approval-review on"),
622
+ style: {
623
+ fontSize: 11,
624
+ padding: "3px 9px",
625
+ borderRadius: 6,
626
+ cursor: view?.enabled === true ? "default" : "pointer",
627
+ border: `1px solid ${BORDER}`,
628
+ color: view?.enabled === true ? MUTED : TEXT,
629
+ background: "transparent"
630
+ },
631
+ children: zh ? "开启" : "on"
632
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
633
+ type: "button",
634
+ disabled: view?.enabled === false,
635
+ onClick: () => runCommand("/approval-review off"),
636
+ style: {
637
+ fontSize: 11,
638
+ padding: "3px 9px",
639
+ borderRadius: 6,
640
+ cursor: view?.enabled === false ? "default" : "pointer",
641
+ border: `1px solid ${BORDER}`,
642
+ color: view?.enabled === false ? MUTED : TEXT,
643
+ background: "transparent"
644
+ },
645
+ children: zh ? "关闭" : "off"
646
+ })] }),
647
+ view === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
648
+ style: {
649
+ fontSize: 11,
650
+ color: MUTED,
651
+ fontFamily: CODE
652
+ },
653
+ children: [zh ? "复核模型 " : "reviewer ", view.reviewerModel.length > 0 ? `${view.reviewerProvider.length > 0 ? `${view.reviewerProvider}/` : ""}${view.reviewerModel}` : zh ? "继承会话" : "inherit session"]
654
+ }),
655
+ runCommand === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
656
+ style: {
657
+ display: "flex",
658
+ gap: 4,
659
+ alignItems: "center"
660
+ },
661
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelPicker, {
662
+ choices,
663
+ runCommand,
664
+ loadModels,
665
+ zh,
666
+ onChoicesLoaded: (routes) => {
667
+ setLoadedChoices(routes.length === 0 ? modelChoices ?? [] : reviewerRoutesMerge(modelChoices ?? [], routes));
668
+ }
669
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
670
+ type: "button",
671
+ onClick: () => runCommand("/approval-review model default"),
672
+ style: {
673
+ fontSize: 11,
674
+ padding: "3px 9px",
675
+ borderRadius: 6,
676
+ cursor: "pointer",
677
+ border: `1px solid ${BORDER}`,
678
+ color: TEXT,
679
+ background: "transparent"
680
+ },
681
+ children: zh ? "继承" : "inherit"
682
+ })]
683
+ }),
684
+ view === void 0 || view.total === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
685
+ style: {
686
+ fontSize: 11,
687
+ color: MUTED
688
+ },
689
+ children: zh ? `共 ${view.total} 次 · 本插件裁决 ${reviewedCount} · 已否决 ${view.refused} · 本回合复审 ${view.reviewsThisTurn}/${view.maxReviewsPerTurn} · 连续否决 ${view.consecutiveDenials}` : `${view.total} total · ${reviewedCount} routed to the reviewer · ${view.refused} refused · this turn ${view.reviewsThisTurn}/${view.maxReviewsPerTurn} · streak ${view.consecutiveDenials}`
690
+ })
691
+ ]
692
+ }),
693
+ view?.circuitOpen === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
694
+ style: {
695
+ fontSize: 12,
696
+ color: REFUSED,
697
+ marginBottom: 10
698
+ },
699
+ children: zh ? "否决熔断已触发:本回合后续请求转人工审批。" : "Rejection breaker is open: later requests in this turn go to the human chain."
700
+ }) : null,
701
+ records.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
702
+ style: {
703
+ fontSize: 13,
704
+ color: MUTED,
705
+ padding: "24px 4px",
706
+ lineHeight: 1.7
707
+ },
708
+ children: zh ? "本会话还没有审批记录。当某个动作需要越过沙箱边界时,这里会留下完整的裁决理由、风险等级与更安全的替代做法。" : "No approvals recorded in this session yet. When an action needs to cross the sandbox boundary, its full rationale, risk grade, and safer alternative land here."
709
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
710
+ style: {
711
+ display: "flex",
712
+ flexDirection: "column",
713
+ gap: 10
714
+ },
715
+ children: records.map((record) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Entry, {
716
+ record,
717
+ zh,
718
+ deniedIndex: deniedIndexOf(record),
719
+ onApprove: runCommand === void 0 ? void 0 : (_r, index) => runCommand(`/approval-review approve ${index}`)
720
+ }, record.reviewId))
721
+ })
722
+ ]
723
+ });
724
+ }
725
+ //#endregion
726
+ //#region src/client/access-mode-glyph.ts
727
+ /**
728
+ * The "approve for me" access-mode glyph, installed from this plugin.
729
+ *
730
+ * Background: the composer's access-mode menu draws a shield glyph for each
731
+ * permission preset, and that glyph table is a CLOSED design set inside
732
+ * `@deepseek-ai/dsh-client-ui-conversation` — a preset key outside it renders
733
+ * with no icon at all, and the host cannot be asked for one (the `permissions`
734
+ * projection carries value/name/description only). So a plugin that adds a
735
+ * fourth preset gets a fourth menu row with no picture next to it.
736
+ *
737
+ * Two ways out exist. Editing the harness package is the tidy one, but it only
738
+ * takes effect after that package is rebuilt, and it couples the harness to a
739
+ * plugin-specific key. This module is the other one: the plugin decorates the
740
+ * two buttons the menu renders, from the outside, with the same shield+eye mark.
741
+ *
742
+ * Why it decorates by ATTRIBUTE and not by inserting nodes: these buttons belong
743
+ * to React. Inserting a child would put an unknown node where React expects its
744
+ * own child list and would make a later re-render reconcile against DOM it never
745
+ * produced. Setting a `data-*` attribute and drawing the glyph from a
746
+ * plugin-owned stylesheet via `::before` leaves React's tree untouched — React
747
+ * does not remove attributes it never set, and a remount simply loses the mark
748
+ * until the next pass, which the observer re-applies.
749
+ *
750
+ * The shim YIELDS to the built-in glyph: when the harness glyph table already
751
+ * covers this key (i.e. the package was rebuilt with it), the mark is removed
752
+ * and the stylesheet draws nothing, so a rebuild never produces a double icon.
753
+ *
754
+ * Naming coupling: the preset key comes from `Config.reviewerPreset` and the
755
+ * bundle patch's `permission.presets` entry, and the display name from that same
756
+ * entry's `name`. The DOM exposes the NAME (the trigger's `aria-label`, a menu
757
+ * row's text), never the key, so the label list below is what this shim matches.
758
+ * Rename the preset and the glyph simply does not appear; the menu keeps
759
+ * working, which is why this is a progressive enhancement and not a dependency.
760
+ * @module dsh-approval-review/client/access-mode-glyph
761
+ */
762
+ /**
763
+ * Display names the preset may carry, matched against the access-mode trigger's
764
+ * `aria-label` ("访问模式,当前:替我审批" / "Access mode, current: Approve for
765
+ * me") and against a menu row's own text. The bundle patch ships the Chinese
766
+ * name; the English form is accepted so an English deployment still gets its
767
+ * glyph.
768
+ */
769
+ const PRESET_LABELS = ["替我审批", "Approve for me"];
770
+ /** Marks a decorated button; also the selector the stylesheet hangs off. */
771
+ const MARK_ATTRIBUTE = "data-dsh-approval-review-glyph";
772
+ /** The style element's identity, so a re-install replaces its own node. */
773
+ const STYLE_ATTRIBUTE = "data-dsh-approval-review-glyph-style";
774
+ /**
775
+ * The glyph itself: the same shield as the built-in modes — the boundary is
776
+ * unchanged — carrying an eye, because the reviewer looks at the action before
777
+ * it crosses. Rendered as a MASK, so the mark takes `currentColor` from the
778
+ * button exactly like the built-in `currentColor` SVGs do.
779
+ */
780
+ const GLYPH_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none"><path d="M8.20554 0.899994L14.7901 3.36857V7.01026C14.7901 12 11.0466 14.2103 8.20554 15.3C5.36446 14.2103 1.62012 12 1.62012 7.01026V3.36857L8.20554 0.899994Z" stroke="#000" stroke-width="1.31831" stroke-linejoin="round"/><path d="M5.348 6.58C6.224 7.773 7.029 8.375 8.2 8.375C9.371 8.375 10.176 7.773 11.052 6.58" stroke="#000" stroke-width="1.31831" stroke-linecap="round"/><path d="M5.348 6.58C6.224 5.387 7.029 4.785 8.2 4.785C9.371 4.785 10.176 5.387 11.052 6.58" stroke="#000" stroke-width="1.31831" stroke-linecap="round"/><circle cx="8.2" cy="6.58" r="0.95" fill="#000"/></svg>`;
781
+ /**
782
+ * The one selector every pass and every mutation check uses.
783
+ *
784
+ * It is deliberately narrow: the trigger is addressed by SUBSTRING on the
785
+ * `aria-label` (a native attribute test, not a JavaScript scan of every button
786
+ * in the document), menu rows by role, and already-marked buttons — whichever
787
+ * they are — by the mark itself, so a mode switch clears the old one.
788
+ */
789
+ const TARGET_SELECTOR = [
790
+ ...PRESET_LABELS.map((label) => `button[aria-label*="${label}"]`),
791
+ "button[role=\"menuitem\"]",
792
+ `[${MARK_ATTRIBUTE}]`
793
+ ].join(", ");
794
+ /** Build the stylesheet that draws the mark on a decorated button. */
795
+ function stylesheet() {
796
+ const mask = `url("data:image/svg+xml,${encodeURIComponent(GLYPH_SVG)}")`;
797
+ return `
798
+ [${MARK_ATTRIBUTE}]::before{
799
+ content:"";
800
+ display:inline-block;
801
+ flex:none;
802
+ width:16px;
803
+ height:16px;
804
+ background-color:currentColor;
805
+ -webkit-mask-image:${mask};
806
+ mask-image:${mask};
807
+ -webkit-mask-repeat:no-repeat;
808
+ mask-repeat:no-repeat;
809
+ -webkit-mask-position:center;
810
+ mask-position:center;
811
+ -webkit-mask-size:contain;
812
+ mask-size:contain;
813
+ }
814
+ /* The composer trigger sizes its icons at 14px. */
815
+ button[aria-label][${MARK_ATTRIBUTE}]::before{
816
+ width:14px;
817
+ height:14px;
818
+ }
819
+ `;
820
+ }
821
+ /**
822
+ * Whether an element already carries the harness's own glyph.
823
+ *
824
+ * Both surfaces render the built-in icon as a leading `<span>` holding an
825
+ * `<svg>`; the trailing chevron is a later sibling, so only the first child
826
+ * counts. When this is true the shim steps aside.
827
+ */
828
+ function hasBuiltInGlyph(button) {
829
+ const first = button.firstElementChild;
830
+ return first instanceof HTMLElement && first.tagName === "SPAN" && first.querySelector("svg") !== null;
831
+ }
832
+ /**
833
+ * The pure matching rule behind the shim.
834
+ *
835
+ * Extracted from the DOM pass so the rule that can actually go wrong — which
836
+ * button is this plugin's preset, and does it already have a built-in glyph —
837
+ * is testable without a browser. The DOM *plumbing* around it is decoration and
838
+ * degrades to "no glyph", never to a broken menu.
839
+ * @param facts - the element facts the shim matches on.
840
+ * @returns `'mark'` when this plugin should draw the glyph, `'skip'` otherwise.
841
+ */
842
+ function accessModeGlyphDecision(facts) {
843
+ const label = facts.ariaLabel ?? "";
844
+ if (!(PRESET_LABELS.some((candidate) => label.includes(candidate)) || facts.role === "menuitem" && PRESET_LABELS.includes((facts.text ?? "").trim()))) return "skip";
845
+ return facts.hasBuiltInGlyph === true ? "skip" : "mark";
846
+ }
847
+ /**
848
+ * Install the access-mode glyph decoration.
849
+ *
850
+ * The observer is deliberately cheap: it inspects only MUTATED subtrees for the
851
+ * target selector and coalesces every hit into one animation frame, so a
852
+ * streaming conversation (which appends text nodes constantly) never becomes a
853
+ * per-token query over the document.
854
+ * @param root - document to decorate; injectable for tests.
855
+ * @returns the disposer that removes the stylesheet, the marks, and the observer.
856
+ */
857
+ function installAccessModeGlyph(root = document) {
858
+ if (root.querySelector(`style[${STYLE_ATTRIBUTE}]`) === null) {
859
+ const style = root.createElement("style");
860
+ style.setAttribute(STYLE_ATTRIBUTE, "1");
861
+ style.textContent = stylesheet();
862
+ root.head.appendChild(style);
863
+ }
864
+ const decorate = () => {
865
+ for (const button of root.querySelectorAll(TARGET_SELECTOR)) if (accessModeGlyphDecision({
866
+ ariaLabel: button.getAttribute("aria-label"),
867
+ role: button.getAttribute("role"),
868
+ text: button.textContent ?? "",
869
+ hasBuiltInGlyph: hasBuiltInGlyph(button)
870
+ }) === "mark") {
871
+ if (!button.hasAttribute(MARK_ATTRIBUTE)) button.setAttribute(MARK_ATTRIBUTE, "1");
872
+ } else if (button.hasAttribute(MARK_ATTRIBUTE)) button.removeAttribute(MARK_ATTRIBUTE);
873
+ };
874
+ /** True when a mutation could have produced one of the decorated buttons. */
875
+ const mightMatter = (node) => {
876
+ if (!(node instanceof Element)) return false;
877
+ if (node.matches(TARGET_SELECTOR)) return true;
878
+ return node.querySelector(TARGET_SELECTOR) !== null;
879
+ };
880
+ let frame;
881
+ const schedule = () => {
882
+ if (frame !== void 0) return;
883
+ frame = root.defaultView?.requestAnimationFrame(() => {
884
+ frame = void 0;
885
+ decorate();
886
+ });
887
+ };
888
+ const observer = new MutationObserver((records) => {
889
+ for (const record of records) {
890
+ const target = record.target;
891
+ if (target instanceof Element && target.closest(TARGET_SELECTOR) !== null) {
892
+ schedule();
893
+ return;
894
+ }
895
+ for (const node of record.addedNodes) if (mightMatter(node)) {
896
+ schedule();
897
+ return;
898
+ }
899
+ }
900
+ });
901
+ if (root.body !== null) observer.observe(root.body, {
902
+ childList: true,
903
+ subtree: true
904
+ });
905
+ decorate();
906
+ return () => {
907
+ observer.disconnect();
908
+ if (frame !== void 0) root.defaultView?.cancelAnimationFrame(frame);
909
+ for (const button of root.querySelectorAll(`[${MARK_ATTRIBUTE}]`)) button.removeAttribute(MARK_ATTRIBUTE);
910
+ root.querySelector(`style[${STYLE_ATTRIBUTE}]`)?.remove();
911
+ };
912
+ }
913
+ //#endregion
914
+ //#region src/client/index.tsx
915
+ /** Slot entry id; stable so a redeploy replaces its own row. */
916
+ const VIEW_SLOT_ID = "approval-review-ledger";
917
+ /** The conversation tab strip, beside 轨迹 / 上下文 / 费用. */
918
+ const VIEW_SLOT = "conversation.view";
919
+ /**
920
+ * Required client services.
921
+ *
922
+ * `remote.commands` is a named remote SERVICE, not a plain property: the client
923
+ * remote facade throws `cannot get property "remote.commands" without inject`
924
+ * unless the key is declared here. Declaring `remote` alone is not enough, which
925
+ * is exactly the bug this cost once.
926
+ */
927
+ const inject = [
928
+ "slots",
929
+ "remote",
930
+ "remote.commands"
931
+ ];
932
+ /** Whether copy should be Chinese, from the browser language. */
933
+ function preferZh() {
934
+ return typeof navigator !== "undefined" && navigator.language.toLowerCase().startsWith("zh");
935
+ }
936
+ /** Reviewer routes this deployment offers, read from its own projections. */
937
+ function reviewerChoices(props, current) {
938
+ return reviewerRouteChoices({
939
+ current,
940
+ sessionDefault: props.useProjection("modelSelection"),
941
+ allowed: props.useProjection("subagentModelSelectionPolicy")
942
+ });
943
+ }
944
+ /** Render the full ledger tab. */
945
+ function ApprovalReviewLedger(props) {
946
+ const view = props.useProjection("approvalReview");
947
+ const current = view === void 0 || view.reviewerModel.length === 0 ? void 0 : `${view.reviewerProvider.length > 0 ? `${view.reviewerProvider}/` : ""}${view.reviewerModel}`;
948
+ return LedgerView({
949
+ view,
950
+ zh: preferZh(),
951
+ runCommand: props.runCommand,
952
+ modelChoices: reviewerChoices(props, current),
953
+ loadModels: props.loadModels
954
+ });
955
+ }
956
+ /**
957
+ * Register the ledger tab and the access-mode glyph.
958
+ * @param ctx - client Cordis context.
959
+ */
960
+ function apply(ctx) {
961
+ ctx.effect(() => installAccessModeGlyph(), "approval-review: access-mode glyph");
962
+ /**
963
+ * The remote is resolved LAZILY, per call. Capturing `ctx.remote` in the
964
+ * `apply` closure is wrong: `apply` can run before the remote facade finishes
965
+ * mounting, and a captured `undefined` turns every click into a silent no-op
966
+ * on a control that still LOOKS enabled. That was a real bug here.
967
+ */
968
+ const remoteOf = () => ctx.remote?.commands;
969
+ /**
970
+ * The model directory, resolved LAZILY for the same reason the command remote
971
+ * is: this client half mounts before every service it may use is up, and a
972
+ * captured `undefined` would permanently disable the picker.
973
+ */
974
+ const directoriesOf = () => ctx.get?.("modelDirectories");
975
+ /** The per-session business face the tab's seat uses. */
976
+ const inject = (rawSessionId) => ({
977
+ loadModels: async () => {
978
+ const directories = directoriesOf();
979
+ if (directories === void 0) return [];
980
+ try {
981
+ return routesFromDirectory(await directories.directoryFor(rawSessionId).load());
982
+ } catch {
983
+ return [];
984
+ }
985
+ },
986
+ runCommand: async (line) => {
987
+ const commands = remoteOf();
988
+ if (commands === void 0) return "the command remote is not mounted in this client";
989
+ const sessionId = rawSessionId;
990
+ try {
991
+ if (!(await commands.execute(sessionId, line)).ok) return `the host refused "${line}"`;
992
+ return null;
993
+ } catch (error) {
994
+ return `"${line}" failed: ${String(error)}`;
995
+ }
996
+ }
997
+ });
998
+ ctx.slots.inject(VIEW_SLOT, () => ctx.slots.register({
999
+ name: VIEW_SLOT,
1000
+ id: VIEW_SLOT_ID,
1001
+ order: 40,
1002
+ label: () => preferZh() ? "审批" : "Approvals",
1003
+ inject
1004
+ }, ApprovalReviewLedger));
1005
+ }
1006
+ //#endregion
1007
+ exports.VIEW_SLOT_ID = VIEW_SLOT_ID;
1008
+ exports.apply = apply;
1009
+ exports.inject = inject;
1010
+ return module.exports;
1011
+ }
1012
+ });
1013
+
1014
+ //# sourceMappingURL=client.js.map