dsh-perm-gate 0.2.0 → 0.2.1-beta.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/lib/client.js CHANGED
@@ -19,7 +19,7 @@ window.__ModuleLoader__.load({
19
19
  "card.strategies": "后台审批策略(至少一个生效;可组合)",
20
20
  "card.strategy.trustAutoAllow": "trustAutoAllow — 作用域内安全操作自动放行,危险/未知转 ask(中间档基线)",
21
21
  "card.strategy.alwaysConfirm": "alwaysConfirm — 一律逐次 ask;审批面板「允许控件」含两个扩展按钮:「本会话重复允许该类」(会话语限次 grant)与「允许所有类型」(命令词持久迁入 rulesFile 白名单)",
22
- "card.strategy.llmAssist": "llmAssist — 自定义 LLM 按风险分级裁决:safe 自动放行;deletion/credential/remote/system/bulk 硬风险永远转人工;neutral 进入确认学习;失败/超时回退人工",
22
+ "card.strategy.llmAssist": "llmAssist — 自定义 LLM 按风险分级裁决:safe 自动放行;deletion/credential/remote/system/bulk 硬风险自动拒绝;neutral 进入确认学习;失败/超时回退人工",
23
23
  "card.readonly": "只读",
24
24
  "card.unavailable": "设置命名空间不可用:请确认 dsh-perm-gate 已装配进 profile。",
25
25
  "card.llmReceiver": "llmAssist 接收 LLM(OpenAI 兼容,可用自定义 API)",
@@ -31,12 +31,91 @@ window.__ModuleLoader__.load({
31
31
  "card.llmKeyOverwrite": "已设置保密值,输入新值并失焦即可覆盖。",
32
32
  "card.llmKeyClear": "清除",
33
33
  "card.allowlist": "白名单(allow,rulesFile 同步)",
34
- "card.allowlistHint": "一行一个命令模式,换行即一个元素;保存后写入 rulesFile 的 allow 段并重载(Ctrl/Cmd+Enter 保存)。",
34
+ "card.allowlistHint": "命中 allow 段的命令模式直接放行;逐条添加或删除,实时写入 rulesFile 并重载。",
35
+ "card.allowlistPresetNote": "预置白名单的等价物是 trustAutoAllow 策略(工作区内安全操作自动放行),在上方策略区开启;本列表镜像你的 rulesFile allow 段,初始为空属正常。",
36
+ "card.allowlistEmpty": "无白名单规则",
37
+ "card.allowlistPlaceholder": "新命令模式,如 npm test*",
38
+ "card.allowlistAdd": "添加",
39
+ "card.allowlistRemove": "删除该模式",
40
+ "card.allowlistBulk": "批量编辑",
41
+ "card.allowlistBulkHide": "收起批量编辑",
35
42
  "card.allowlistSave": "保存白名单",
36
43
  "card.riskLearning": "riskLearning — 裁决学习:neutral 风险操作经人工确认并执行后计数,满阈值且操作指纹一致时自动放行",
37
44
  "card.riskLearningHint": "学习状态为插件自有数据($DSH_HOME/perm-gate/learning.json),不写入你的 YAML 规则文件;不同目标永不复用放行。默认关闭。",
38
45
  "card.riskThreshold": "自动放行所需人工确认次数(1–10)",
39
- "notice.label": "权限门禁动态"
46
+ "notice.label": "权限门禁动态",
47
+ "history.label": "审批记录",
48
+ "history.title": "权限审批记录",
49
+ "history.subtitle": "本会话中门禁的每次判定轨迹(最新在上)",
50
+ "history.loading": "加载中…",
51
+ "history.empty": "本会话暂无审批记录",
52
+ "history.error": "加载失败:",
53
+ "history.tag.auto": "自动放行",
54
+ "history.tag.ask": "转人工",
55
+ "history.tag.deny": "拒绝",
56
+ "card.denylistRestore": "恢复预置黑名单",
57
+ "card.denylist": "黑名单(deny 关键词,插件预置)",
58
+ "card.denylistHint": "命中关键词(大小写不敏感子串)的调用直接拒绝,先于白名单/授权/LLM。预置列表继承自 dsh-approval-gate,安装即生效;可增删,清空或删除后可用「恢复预置黑名单」一键还原(空列表等同预置,黑名单常开)。",
59
+ "card.denylistEmpty": "黑名单为空(所有调用都不会被关键词层拒绝)",
60
+ "card.denylistPlaceholder": "新关键词,如 drop database",
61
+ "card.denylistAdd": "添加",
62
+ "card.denylistRemove": "删除该关键词",
63
+ "card.denylistTagPreset": "预置",
64
+ "card.denylistTagCustom": "自定义",
65
+ "card.riskSediment": "riskSediment — 学习沉淀:满阈值的确认样本成为确定性放行规则",
66
+ "card.riskSedimentHint": "开启后,同一「工具|类别」确认满阈值,其样本指纹即沉淀为放行规则——同工具同目标精确命中时直接放行,不再过 LLM(关闭 llmAssist 也继续生效)。",
67
+ "card.sediment": "学习沉淀(确定性放行规则)",
68
+ "card.sedimentHint": "来自人工确认的沉淀规则:样本指纹命中才放行,不同目标永不复用。可终止单个学习或删除单条样本。",
69
+ "card.sedimentEmpty": "暂无沉淀规则(确认满阈值的样本会出现在这里)",
70
+ "card.sedimentCount": "已确认 %n/%t",
71
+ "card.sedimentStop": "终止",
72
+ "card.sedimentStopTitle": "终止该学习(删除计数与样本)",
73
+ "card.sedimentStopConfirm": "终止「%k」的学习?将删除其确认计数与全部样本。",
74
+ "card.sedimentSampleRemove": "删除该沉淀样本",
75
+ "card.llmSource": "接收 LLM 来源",
76
+ "card.llmSourceCustom": "自定义 API(OpenAI 兼容)",
77
+ "card.llmSourceHost": "使用会话当前默认模型(DSH llm 服务)",
78
+ "card.llmSourceHostHint": "默认使用 DSH 会话当前选中的模型组执行审批判定(下方显示当前生效值);Provider / Model 留空即跟随会话,也可从下拉选择或手动输入固定下来——你在 DSH 里配置的自定义组(如 local-35b)会自动出现在下拉中。",
79
+ "card.llmProvider": "Provider(留空 = 跟随会话当前选择)",
80
+ "card.llmProviderPlaceholder": "跟随会话当前选择",
81
+ "card.llmModelHostPlaceholder": "留空 = 跟随会话当前选择",
82
+ "card.llmResolved": "当前生效:%p / %m",
83
+ "card.llmPreset": "预置端点(选择后自动填充,可再编辑)",
84
+ "card.llmPresetChoose": "选择预置…",
85
+ "card.llmPresetPublic": "公共 API 预置",
86
+ "card.llmPresetHost": "DSH 模型组(选择后自动切换为会话模式)",
87
+ "card.healthStale": "宿主路由不可用(404)——node 半区仍是旧版本,请完全重启 dsh(而非仅刷新页面)后重试。",
88
+ "card.healthTest": "健康测试",
89
+ "card.healthRunning": "测试中…",
90
+ "card.healthOk": "正常(%ms ms)",
91
+ "card.healthFail": "失败:",
92
+ "history.tag.learned": "学习沉淀",
93
+ "history.tag.manualApproved": "人工通过",
94
+ "history.tag.manualRejected": "人工拒绝",
95
+ "history.tag.manualCancelled": "人工取消",
96
+ "history.learnedProgress": "学习 %n/%t",
97
+ "history.snapshots": "diff 快照",
98
+ "history.clearSession": "仅清本会话",
99
+ "history.clearAll": "清空全部",
100
+ "history.clearSessionTitle": "仅清除本会话的 diff 快照(不影响其他会话)",
101
+ "history.clearAllTitle": "清空全部会话(含其他会话未查看过的)diff 快照,需二次确认",
102
+ "history.clearSessionConfirm": "确定清除【本会话】的 diff 快照?仅删除本会话审批产生的改动对比数据,不影响审批记录本身。",
103
+ "history.clearAllConfirm": "确定清除【全部会话】的 diff 快照?这可能删除其他会话还没看过的改动对比记录,且不可恢复。",
104
+ "history.diffTitle": "文件改动对比",
105
+ "history.diffClose": "关闭",
106
+ "history.diffLoading": "加载中…",
107
+ "history.diffEmpty": "该文件无内容变化(或文件不可读)",
108
+ "history.diffLoadFail": "加载 diff 失败:",
109
+ "history.diffRevert": "撤销此改动",
110
+ "history.diffReverting": "发送中…",
111
+ "history.diffRevertDone": "已发送撤销指令",
112
+ "history.diffRevertSent": "撤销指令已发送到对话框,AI 将按指令恢复文件",
113
+ "history.diffRevertFail": "发送失败:",
114
+ "history.diffNoSnapshot": "该事件没有此文件的快照",
115
+ "history.diffChipTitle": "查看该文件改动对比",
116
+ "history.diffStats": "+%a / -%r 行变更 · %c 行未变",
117
+ "history.diffGone": "(文件当前已不存在)",
118
+ "history.diffHidden": "%n 行未修改"
40
119
  };
41
120
  /** English dictionary (keys mirror zh). */
42
121
  const en = {
@@ -47,7 +126,7 @@ window.__ModuleLoader__.load({
47
126
  "card.strategies": "Backend approval strategies (at least one effective; combinable)",
48
127
  "card.strategy.trustAutoAllow": "trustAutoAllow — safe in-scope ops auto-allow; dangerous/unknown ask (baseline middle tier)",
49
128
  "card.strategy.alwaysConfirm": "alwaysConfirm — every crossing asks; the approval \"allow controls\" get two extended buttons: \"repeat-allow this type this session\" (bounded session grant) and \"allow every occurrence\" (persist the command word into the rulesFile whitelist)",
50
- "card.strategy.llmAssist": "llmAssist — a custom LLM grades risk: safe auto-allows; deletion/credential/remote/system/bulk hard risks always ask; neutral enters confirm-learning; failure/timeout falls back to the human",
129
+ "card.strategy.llmAssist": "llmAssist — a custom LLM grades risk: safe auto-allows; deletion/credential/remote/system/bulk hard risks auto-deny; neutral enters confirm-learning; failure/timeout falls back to the human",
51
130
  "card.readonly": "Read-only",
52
131
  "card.unavailable": "Settings namespace unavailable: make sure dsh-perm-gate is assembled into this profile.",
53
132
  "card.llmReceiver": "llmAssist receiving LLM (OpenAI-compatible; any custom API)",
@@ -59,14 +138,315 @@ window.__ModuleLoader__.load({
59
138
  "card.llmKeyOverwrite": "A secret is stored; type a new value and blur to overwrite it.",
60
139
  "card.llmKeyClear": "Clear",
61
140
  "card.allowlist": "Whitelist (allow, synced to rulesFile)",
62
- "card.allowlistHint": "One command pattern per line (a newline is one element). Saving writes the rulesFile allow section and reloads (Ctrl/Cmd+Enter to save).",
141
+ "card.allowlistHint": "Patterns in the allow section allow directly; add or remove entries one by one, written to the rulesFile and reloaded live.",
142
+ "card.allowlistPresetNote": "The preset-whitelist equivalent is the trustAutoAllow strategy (safe in-scope ops auto-allow), enabled in the strategies section above; this list mirrors your rulesFile allow section and legitimately starts empty.",
143
+ "card.allowlistEmpty": "No whitelist rules",
144
+ "card.allowlistPlaceholder": "New command pattern, e.g. npm test*",
145
+ "card.allowlistAdd": "Add",
146
+ "card.allowlistRemove": "Remove this pattern",
147
+ "card.allowlistBulk": "Bulk edit",
148
+ "card.allowlistBulkHide": "Hide bulk edit",
63
149
  "card.allowlistSave": "Save whitelist",
64
150
  "card.riskLearning": "riskLearning — verdict learning: neutral-risk asks that the human approves and that execute count up; the same operation auto-allows at threshold with a matching fingerprint",
65
151
  "card.riskLearningHint": "Learning state is plugin-owned ($DSH_HOME/perm-gate/learning.json) and never written into your YAML rules; a different target never reuses authority. Off by default.",
66
152
  "card.riskThreshold": "Human confirmations required before auto-allow (1–10)",
67
- "notice.label": "Permission gate activity"
153
+ "notice.label": "Permission gate activity",
154
+ "history.label": "Approvals",
155
+ "history.title": "Permission approval records",
156
+ "history.subtitle": "Every gate decision in this conversation, newest first",
157
+ "history.loading": "Loading…",
158
+ "history.empty": "No approval records in this session yet",
159
+ "history.error": "Failed to load: ",
160
+ "history.tag.auto": "Auto-allowed",
161
+ "history.tag.ask": "Asked",
162
+ "history.tag.deny": "Denied",
163
+ "card.denylistRestore": "Restore preset blacklist",
164
+ "card.denylist": "Blacklist (deny keywords, plugin preset)",
165
+ "card.denylistHint": "A call whose text contains a keyword (case-insensitive substring) is denied outright, before whitelist / grants / LLM. The preset list is inherited from dsh-approval-gate and active from install; edit freely — empty or cleared falls back to the preset (one-click restore available), so the blacklist never silently turns off.",
166
+ "card.denylistEmpty": "Blacklist empty (no call is vetoed by the keyword layer)",
167
+ "card.denylistPlaceholder": "New keyword, e.g. drop database",
168
+ "card.denylistAdd": "Add",
169
+ "card.denylistRemove": "Remove this keyword",
170
+ "card.denylistTagPreset": "Preset",
171
+ "card.denylistTagCustom": "Custom",
172
+ "card.riskSediment": "riskSediment — learning sedimentation: threshold-reached samples become deterministic auto-allows",
173
+ "card.riskSedimentHint": "When on, once a tool|category key reaches the threshold its sample fingerprints are sedimented into allow rules — an exact same-tool same-target hit allows outright without another LLM call (keeps working with llmAssist off).",
174
+ "card.sediment": "Learning sediment (deterministic allow rules)",
175
+ "card.sedimentHint": "Rules sedimented from human confirmations: only an exact fingerprint hit allows, never a different target. Terminate one learning or remove one sample.",
176
+ "card.sedimentEmpty": "No sedimented rules yet (samples reaching the threshold appear here)",
177
+ "card.sedimentCount": "confirmed %n/%t",
178
+ "card.sedimentStop": "Terminate",
179
+ "card.sedimentStopTitle": "Terminate this learning (drops the count and samples)",
180
+ "card.sedimentStopConfirm": "Terminate learning for '%k'? Its count and samples are dropped.",
181
+ "card.sedimentSampleRemove": "Remove this sedimented sample",
182
+ "card.llmSource": "Receiver source",
183
+ "card.llmSourceCustom": "Custom API (OpenAI-compatible)",
184
+ "card.llmSourceHost": "Use the session's current default model (DSH llm service)",
185
+ "card.llmSourceHostHint": "By default approval grading runs on the model group the DSH session currently uses (the effective value is shown below); leave Provider / Model empty to follow the session, or pick from the dropdown or type to pin one — custom groups you configured in DSH (e.g. local-35b) appear automatically.",
186
+ "card.llmProvider": "Provider (empty = follow the session selection)",
187
+ "card.llmProviderPlaceholder": "Follow the session selection",
188
+ "card.llmModelHostPlaceholder": "Empty = follow the session selection",
189
+ "card.llmResolved": "Effective now: %p / %m",
190
+ "card.llmPreset": "Endpoint presets (fills the fields; still editable)",
191
+ "card.llmPresetChoose": "Pick a preset…",
192
+ "card.llmPresetPublic": "Public API presets",
193
+ "card.llmPresetHost": "DSH model groups (selecting one switches to the session receiver)",
194
+ "card.healthStale": "Host route unavailable (404) — the node half is still an old build; fully restart dsh (not just the browser page) and retry.",
195
+ "card.healthTest": "Health test",
196
+ "card.healthRunning": "Testing…",
197
+ "card.healthOk": "OK (%ms ms)",
198
+ "card.healthFail": "Failed: ",
199
+ "history.tag.learned": "Learned",
200
+ "history.tag.manualApproved": "Approved",
201
+ "history.tag.manualRejected": "Rejected",
202
+ "history.tag.manualCancelled": "Cancelled",
203
+ "history.learnedProgress": "learned %n/%t",
204
+ "history.snapshots": "diff snapshots",
205
+ "history.clearSession": "Clear this session",
206
+ "history.clearAll": "Clear all",
207
+ "history.clearSessionTitle": "Clear only this session’s diff snapshots (other sessions untouched)",
208
+ "history.clearAllTitle": "Clear diff snapshots for every session, including ones never reviewed — asks again",
209
+ "history.clearSessionConfirm": "Clear this session’s diff snapshots? Only the change-comparison data produced by this session’s approvals is removed; the approval records stay.",
210
+ "history.clearAllConfirm": "Clear diff snapshots for ALL sessions? This may delete change comparisons other sessions have not reviewed yet, and cannot be undone.",
211
+ "history.diffTitle": "File change comparison",
212
+ "history.diffClose": "Close",
213
+ "history.diffLoading": "Loading…",
214
+ "history.diffEmpty": "No content change (or the file is unreadable)",
215
+ "history.diffLoadFail": "Failed to load diff: ",
216
+ "history.diffRevert": "Revert this change",
217
+ "history.diffReverting": "Sending…",
218
+ "history.diffRevertDone": "Revert instruction sent",
219
+ "history.diffRevertSent": "Revert instruction sent to the conversation; the AI will restore the file accordingly",
220
+ "history.diffRevertFail": "Send failed: ",
221
+ "history.diffNoSnapshot": "This event has no snapshot for that file",
222
+ "history.diffChipTitle": "View this file’s change comparison",
223
+ "history.diffStats": "+%a / -%r lines changed · %c unchanged",
224
+ "history.diffGone": " (file no longer exists)",
225
+ "history.diffHidden": "%n unmodified lines"
68
226
  };
69
227
  //#endregion
228
+ //#region src/deny-defaults.ts
229
+ /**
230
+ * Preset deny keywords inherited from dsh-approval-gate's `DEFAULT_DENY_KEYWORDS`
231
+ * (the preset blacklist layer of its pipeline). One keyword per entry, matched
232
+ * case-insensitively on word boundaries against the call's command text and its
233
+ * scalar arguments — document bodies are skipped — so a keyword can no longer
234
+ * veto an unrelated identifier or file text that merely contains it.
235
+ *
236
+ * Pure data shared by the node half (deny-keyword pre-layer) and the client
237
+ * bundle (the settings card's preset tags), so it must stay import-free.
238
+ */
239
+ /** The preset blacklist; the gate applies it whenever the namespace carries no override. */
240
+ const DEFAULT_DENY_KEYWORDS = [
241
+ "rm -rf",
242
+ "rm -fr",
243
+ "rm -r -f",
244
+ "rm --recursive --force",
245
+ "push --force",
246
+ "force-push",
247
+ "force push",
248
+ "drop table",
249
+ "drop database",
250
+ "mkfs",
251
+ "mkfs.ext",
252
+ "format",
253
+ "shutdown",
254
+ "reboot",
255
+ "dd of=",
256
+ "delete from",
257
+ "truncate table",
258
+ "truncate ",
259
+ "terraform destroy",
260
+ "revoke",
261
+ "清空数据库",
262
+ "删除数据库",
263
+ "格式化",
264
+ "sudo rm",
265
+ "chmod 777 /",
266
+ "git reset --hard",
267
+ "git clean -fd",
268
+ "docker rm",
269
+ "docker system prune"
270
+ ];
271
+ //#endregion
272
+ //#region src/llm-presets.ts
273
+ const LLM_PRESETS = [
274
+ {
275
+ id: "deepseek",
276
+ label: "DeepSeek",
277
+ endpoint: "https://api.deepseek.com/v1",
278
+ model: "deepseek-chat"
279
+ },
280
+ {
281
+ id: "xiaomi-mimo",
282
+ label: "小米 MiMo",
283
+ endpoint: "https://api.xiaomimimo.com/v1",
284
+ model: "mimo-v2.5"
285
+ },
286
+ {
287
+ id: "openai",
288
+ label: "OpenAI",
289
+ endpoint: "https://api.openai.com/v1",
290
+ model: "gpt-4o-mini"
291
+ }
292
+ ];
293
+ //#endregion
294
+ //#region src/client/sediment.tsx
295
+ /**
296
+ * Learning-sediment management section — the settings-card face of verdict
297
+ * learning's "沉淀" (the ⑤ card pattern demonstrated by dsh-approval-gate):
298
+ * every threshold-reached key's confirmed samples, listed as the deterministic
299
+ * auto-allow rules they now are, with per-key terminate and per-sample remove.
300
+ *
301
+ * Data source: the host's `GET /api/dsh-perm-gate/learning` route (the same
302
+ * learning.json store the gate reads); actions POST back. Best-effort: a
303
+ * missing route renders the empty state, never an error.
304
+ */
305
+ const POLL_MS$2 = 5e3;
306
+ const itemStyle = {
307
+ display: "flex",
308
+ alignItems: "center",
309
+ gap: "8px",
310
+ padding: "4px 8px",
311
+ borderRadius: "6px",
312
+ border: "1px solid var(--dsw-alias-border-l2)",
313
+ background: "var(--dsw-alias-bg-surface, transparent)"
314
+ };
315
+ const chipStyle = {
316
+ fontSize: "11px",
317
+ lineHeight: "16px",
318
+ padding: "0 6px",
319
+ borderRadius: "8px",
320
+ color: "var(--dsw-alias-label-tertiary)",
321
+ border: "1px solid var(--dsw-alias-border-l2)"
322
+ };
323
+ const delButtonStyle = {
324
+ flex: "0 0 auto",
325
+ width: "20px",
326
+ height: "20px",
327
+ border: "none",
328
+ borderRadius: "999px",
329
+ background: "transparent",
330
+ color: "var(--dsw-alias-label-tertiary)",
331
+ cursor: "pointer"
332
+ };
333
+ function SedimentSection({ t }) {
334
+ const [body, setBody] = (0, react.useState)(null);
335
+ (0, react.useEffect)(() => {
336
+ let alive = true;
337
+ const load = () => {
338
+ fetch("/api/dsh-perm-gate/learning", { headers: { "cache-control": "no-cache" } }).then((r) => r.ok ? r.json() : Promise.reject(/* @__PURE__ */ new Error(`HTTP ${r.status}`))).then((data) => {
339
+ if (alive) setBody(data);
340
+ }).catch(() => {
341
+ if (alive) setBody({});
342
+ });
343
+ };
344
+ load();
345
+ const timer = setInterval(load, POLL_MS$2);
346
+ return () => {
347
+ alive = false;
348
+ clearInterval(timer);
349
+ };
350
+ }, []);
351
+ const post = (payload) => {
352
+ fetch("/api/dsh-perm-gate/learning", {
353
+ method: "POST",
354
+ headers: { "content-type": "application/json" },
355
+ body: JSON.stringify(payload)
356
+ }).catch(() => {});
357
+ };
358
+ const threshold = body?.threshold ?? 3;
359
+ const confirmed = body?.confirmed ?? {};
360
+ const samples = body?.samples ?? {};
361
+ const keys = Object.entries(confirmed).filter(([, count]) => count >= threshold);
362
+ if (body === null) return null;
363
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
364
+ style: { marginTop: "6px" },
365
+ children: keys.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
366
+ style: {
367
+ padding: "4px 0",
368
+ fontSize: "12px",
369
+ color: "var(--dsw-alias-label-tertiary)"
370
+ },
371
+ children: t("card.sedimentEmpty")
372
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
373
+ style: {
374
+ display: "flex",
375
+ flexDirection: "column",
376
+ gap: "6px"
377
+ },
378
+ children: keys.map(([key, count]) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
379
+ style: {
380
+ display: "flex",
381
+ flexDirection: "column",
382
+ gap: "4px"
383
+ },
384
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
385
+ style: itemStyle,
386
+ children: [
387
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", {
388
+ style: {
389
+ font: "600 12px/18px var(--ds-font-family-code, monospace)",
390
+ color: "var(--dsw-alias-label-primary)"
391
+ },
392
+ children: key
393
+ }),
394
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
395
+ style: chipStyle,
396
+ children: t("card.sedimentCount").replace("%n", String(count)).replace("%t", String(threshold))
397
+ }),
398
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { style: { flex: "1 1 auto" } }),
399
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
400
+ type: "button",
401
+ title: t("card.sedimentStopTitle"),
402
+ onClick: () => {
403
+ if (window.confirm(t("card.sedimentStopConfirm").replace("%k", key))) post({ key });
404
+ },
405
+ style: {
406
+ ...delButtonStyle,
407
+ width: "auto",
408
+ padding: "0 8px",
409
+ border: "1px solid var(--dsw-alias-border-l2)"
410
+ },
411
+ children: t("card.sedimentStop")
412
+ })
413
+ ]
414
+ }), (samples[key] ?? []).map((s) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
415
+ style: {
416
+ ...itemStyle,
417
+ marginLeft: "16px"
418
+ },
419
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("code", {
420
+ style: {
421
+ flex: "1 1 auto",
422
+ minWidth: 0,
423
+ overflowWrap: "anywhere",
424
+ font: "500 12px/18px var(--ds-font-family-code, monospace)",
425
+ color: "var(--dsw-alias-label-secondary, inherit)"
426
+ },
427
+ children: [s.fp, s.ctx !== "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
428
+ style: { color: "var(--dsw-alias-label-tertiary)" },
429
+ children: ` — ${s.ctx}`
430
+ }) : null]
431
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
432
+ type: "button",
433
+ "aria-label": t("card.sedimentSampleRemove"),
434
+ title: t("card.sedimentSampleRemove"),
435
+ onClick: () => {
436
+ post({
437
+ key,
438
+ fp: s.fp
439
+ });
440
+ },
441
+ style: delButtonStyle,
442
+ children: "✕"
443
+ })]
444
+ }, `${key}:${s.fp}`))]
445
+ }, key))
446
+ })
447
+ });
448
+ }
449
+ //#endregion
70
450
  //#region src/client/card.tsx
71
451
  /**
72
452
  * Permissive settings card — the `settings.plugin.item` face of dsh-perm-gate.
@@ -157,6 +537,50 @@ window.__ModuleLoader__.load({
157
537
  scope.set("allowlist", lines);
158
538
  setAllowDirty(false);
159
539
  };
540
+ const [addDraft, setAddDraft] = (0, react.useState)("");
541
+ const [bulkOpen, setBulkOpen] = (0, react.useState)(false);
542
+ const [healthBusy, setHealthBusy] = (0, react.useState)(false);
543
+ const [healthResult, setHealthResult] = (0, react.useState)(null);
544
+ const receiverSource = (value.classifierSource ?? "custom") === "host" ? "host" : "custom";
545
+ const [receiverInfo, setReceiverInfo] = (0, react.useState)(null);
546
+ const [receiverNonce, setReceiverNonce] = (0, react.useState)(0);
547
+ (0, react.useEffect)(() => {
548
+ let alive = true;
549
+ fetch("/api/dsh-perm-gate/receiver", { headers: { "cache-control": "no-cache" } }).then((r) => r.ok ? r.json() : Promise.reject(/* @__PURE__ */ new Error(`HTTP ${r.status}`))).then((data) => {
550
+ if (alive) setReceiverInfo(data);
551
+ }).catch(() => {
552
+ if (alive) setReceiverInfo(null);
553
+ });
554
+ return () => {
555
+ alive = false;
556
+ };
557
+ }, [receiverSource, receiverNonce]);
558
+ const receiverProviderDraft = value.classifierProvider ?? "";
559
+ const patterns = value.allowlist ?? [];
560
+ const removePattern = (index) => {
561
+ scope.set("allowlist", patterns.filter((_, i) => i !== index));
562
+ };
563
+ const addPattern = () => {
564
+ const next = addDraft.trim();
565
+ if (next === "" || readonly) return;
566
+ scope.set("allowlist", [...patterns, next]);
567
+ setAddDraft("");
568
+ };
569
+ const denyActive = Array.isArray(value.denyKeywords) && value.denyKeywords.length > 0;
570
+ const denylist = denyActive ? value.denyKeywords : [...DEFAULT_DENY_KEYWORDS];
571
+ const [denyDraft, setDenyDraft] = (0, react.useState)("");
572
+ const removeDeny = (index) => {
573
+ scope.set("denyKeywords", denylist.filter((_, i) => i !== index));
574
+ };
575
+ const addDeny = () => {
576
+ const next = denyDraft.trim();
577
+ if (next === "" || readonly) return;
578
+ scope.set("denyKeywords", [...denylist, next]);
579
+ setDenyDraft("");
580
+ };
581
+ const restoreDeny = () => {
582
+ scope.unset("denyKeywords");
583
+ };
160
584
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
161
585
  style: {
162
586
  border: "1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))",
@@ -329,39 +753,153 @@ window.__ModuleLoader__.load({
329
753
  style: fieldStyle,
330
754
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
331
755
  style: fieldLabelStyle,
332
- children: t("card.llmEndpoint")
333
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
334
- type: "text",
335
- value: value.classifierEndpoint ?? "",
756
+ children: t("card.llmSource")
757
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
758
+ value: value.classifierSource ?? "custom",
336
759
  disabled: readonly,
337
- placeholder: "https://api.openai.com/v1",
338
760
  style: controlStyle,
339
761
  onChange: (event) => {
340
- scope.set("classifierEndpoint", event.currentTarget.value);
762
+ scope.set("classifierSource", event.currentTarget.value);
341
763
  },
342
- onBlur: (event) => {
343
- if (event.currentTarget.value.trim() === "") scope.unset("classifierEndpoint");
344
- }
764
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
765
+ value: "custom",
766
+ children: t("card.llmSourceCustom")
767
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
768
+ value: "host",
769
+ children: t("card.llmSourceHost")
770
+ })]
345
771
  })]
346
772
  }),
347
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
773
+ (value.classifierSource ?? "custom") === "custom" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
774
+ style: fieldStyle,
775
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
776
+ style: fieldLabelStyle,
777
+ children: t("card.llmPreset")
778
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
779
+ value: "",
780
+ disabled: readonly,
781
+ style: controlStyle,
782
+ onChange: (event) => {
783
+ const v = event.currentTarget.value;
784
+ if (v.startsWith("host:")) {
785
+ scope.set("classifierSource", "host");
786
+ scope.set("classifierProvider", v.slice(5));
787
+ return;
788
+ }
789
+ const preset = LLM_PRESETS.find((p) => p.id === v);
790
+ if (preset !== void 0) {
791
+ scope.set("classifierEndpoint", preset.endpoint);
792
+ scope.set("classifierModel", preset.model);
793
+ }
794
+ },
795
+ children: [
796
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
797
+ value: "",
798
+ children: t("card.llmPresetChoose")
799
+ }),
800
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("optgroup", {
801
+ label: t("card.llmPresetPublic"),
802
+ children: LLM_PRESETS.map((preset) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
803
+ value: preset.id,
804
+ children: preset.label
805
+ }, preset.id))
806
+ }),
807
+ (receiverInfo?.providers ?? []).length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("optgroup", {
808
+ label: t("card.llmPresetHost"),
809
+ children: (receiverInfo?.providers ?? []).map((p) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
810
+ value: `host:${p.id}`,
811
+ children: `${t("card.llmSourceHost")} · ${p.id}`
812
+ }, `host:${p.id}`))
813
+ }) : null
814
+ ]
815
+ })]
816
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
348
817
  style: fieldStyle,
349
818
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
350
819
  style: fieldLabelStyle,
351
- children: t("card.llmModel")
820
+ children: t("card.llmEndpoint")
352
821
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
353
822
  type: "text",
354
- value: value.classifierModel ?? "",
823
+ value: value.classifierEndpoint ?? "",
355
824
  disabled: readonly,
356
- placeholder: "deepseek-chat",
825
+ placeholder: "https://api.openai.com/v1",
357
826
  style: controlStyle,
358
827
  onChange: (event) => {
359
- scope.set("classifierModel", event.currentTarget.value);
828
+ scope.set("classifierEndpoint", event.currentTarget.value);
360
829
  },
361
830
  onBlur: (event) => {
362
- if (event.currentTarget.value.trim() === "") scope.unset("classifierModel");
831
+ if (event.currentTarget.value.trim() === "") scope.unset("classifierEndpoint");
363
832
  }
364
833
  })]
834
+ })] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
835
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
836
+ style: hintStyle,
837
+ children: t("card.llmSourceHostHint")
838
+ }),
839
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
840
+ style: fieldStyle,
841
+ children: [
842
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
843
+ style: fieldLabelStyle,
844
+ children: t("card.llmProvider")
845
+ }),
846
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
847
+ type: "text",
848
+ list: "perm-gate-provider-options",
849
+ value: value.classifierProvider ?? "",
850
+ disabled: readonly,
851
+ placeholder: t("card.llmProviderPlaceholder"),
852
+ style: controlStyle,
853
+ onChange: (event) => {
854
+ scope.set("classifierProvider", event.currentTarget.value);
855
+ },
856
+ onBlur: (event) => {
857
+ if (event.currentTarget.value.trim() === "") scope.unset("classifierProvider");
858
+ }
859
+ }),
860
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("datalist", {
861
+ id: "perm-gate-provider-options",
862
+ children: (receiverInfo?.providers ?? []).map((p) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("option", {
863
+ value: p.id,
864
+ children: [p.name, p.error !== void 0 ? ` (${p.error})` : ""]
865
+ }, p.id))
866
+ })
867
+ ]
868
+ }),
869
+ receiverInfo?.selection !== null && receiverInfo?.selection !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
870
+ style: hintStyle,
871
+ children: t("card.llmResolved").replace("%p", receiverInfo.selection.provider).replace("%m", receiverInfo.selection.model)
872
+ }) : null
873
+ ] }),
874
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
875
+ style: fieldStyle,
876
+ children: [
877
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
878
+ style: fieldLabelStyle,
879
+ children: t("card.llmModel")
880
+ }),
881
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
882
+ type: "text",
883
+ list: receiverSource === "host" ? "perm-gate-model-options" : void 0,
884
+ value: value.classifierModel ?? "",
885
+ disabled: readonly,
886
+ placeholder: receiverSource === "host" ? t("card.llmModelHostPlaceholder") : "deepseek-chat",
887
+ style: controlStyle,
888
+ onChange: (event) => {
889
+ scope.set("classifierModel", event.currentTarget.value);
890
+ },
891
+ onBlur: (event) => {
892
+ if (event.currentTarget.value.trim() === "") scope.unset("classifierModel");
893
+ }
894
+ }),
895
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("datalist", {
896
+ id: "perm-gate-model-options",
897
+ children: (receiverInfo?.providers ?? []).filter((p) => receiverProviderDraft === "" || p.id === receiverProviderDraft).flatMap((p) => p.models).map((m) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
898
+ value: m.id,
899
+ children: m.name
900
+ }, `${m.id}`))
901
+ })
902
+ ]
365
903
  }),
366
904
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
367
905
  style: fieldStyle,
@@ -405,6 +943,55 @@ window.__ModuleLoader__.load({
405
943
  })
406
944
  ]
407
945
  }),
946
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
947
+ style: {
948
+ display: "flex",
949
+ alignItems: "center",
950
+ gap: "8px",
951
+ marginTop: "4px"
952
+ },
953
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
954
+ type: "button",
955
+ disabled: readonly || healthBusy,
956
+ onClick: () => {
957
+ setHealthBusy(true);
958
+ setHealthResult(null);
959
+ fetch("/api/dsh-perm-gate/health", {
960
+ method: "POST",
961
+ headers: { "content-type": "application/json" },
962
+ body: "{}"
963
+ }).then(async (r) => {
964
+ const text = await r.text();
965
+ if (r.status === 404) throw new Error(t("card.healthStale"));
966
+ try {
967
+ return JSON.parse(text);
968
+ } catch {
969
+ throw new Error(text.trim().slice(0, 120) !== "" ? text.trim().slice(0, 120) : `HTTP ${r.status}`);
970
+ }
971
+ }).then((res) => {
972
+ setHealthResult(res.ok === true && typeof res.ms === "number" ? t("card.healthOk").replace("%ms", String(res.ms)) + (typeof res.detail === "string" ? ` · ${res.detail}` : "") : t("card.healthFail") + (res.detail ?? res.error ?? "unknown"));
973
+ setReceiverNonce((n) => n + 1);
974
+ }).catch((e) => {
975
+ setHealthResult(t("card.healthFail") + String(e?.message ?? e));
976
+ }).finally(() => {
977
+ setHealthBusy(false);
978
+ });
979
+ },
980
+ style: {
981
+ ...controlStyle,
982
+ cursor: readonly || healthBusy ? "default" : "pointer"
983
+ },
984
+ children: healthBusy ? t("card.healthRunning") : t("card.healthTest")
985
+ }), healthResult !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
986
+ style: {
987
+ fontSize: "12px",
988
+ lineHeight: "18px",
989
+ color: "var(--dsw-alias-label-secondary, inherit)",
990
+ overflowWrap: "anywhere"
991
+ },
992
+ children: healthResult
993
+ })]
994
+ }),
408
995
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
409
996
  style: rowStyle,
410
997
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
@@ -446,7 +1033,43 @@ window.__ModuleLoader__.load({
446
1033
  if (Number.isFinite(n) && n >= 1 && n <= 10) scope.set("riskThreshold", n);
447
1034
  }
448
1035
  })]
449
- }) : null
1036
+ }) : null,
1037
+ value.riskLearning === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
1038
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1039
+ style: rowStyle,
1040
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
1041
+ htmlFor: "plugin-config-perm-gate-risk-sediment",
1042
+ style: { fontSize: "12px" },
1043
+ children: t("card.riskSediment")
1044
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1045
+ id: "plugin-config-perm-gate-risk-sediment",
1046
+ type: "checkbox",
1047
+ checked: value.riskSediment ?? true,
1048
+ disabled: readonly || !effective,
1049
+ onChange: (event) => {
1050
+ scope.set("riskSediment", event.currentTarget.checked);
1051
+ }
1052
+ })]
1053
+ }),
1054
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1055
+ style: hintStyle,
1056
+ children: t("card.riskSedimentHint")
1057
+ }),
1058
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1059
+ style: sectionStyle,
1060
+ children: [
1061
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1062
+ style: labelStyle,
1063
+ children: t("card.sediment")
1064
+ }),
1065
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1066
+ style: hintStyle,
1067
+ children: t("card.sedimentHint")
1068
+ }),
1069
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SedimentSection, { t })
1070
+ ]
1071
+ })
1072
+ ] }) : null
450
1073
  ]
451
1074
  }) : null
452
1075
  ]
@@ -462,7 +1085,123 @@ window.__ModuleLoader__.load({
462
1085
  style: hintStyle,
463
1086
  children: t("card.allowlistHint")
464
1087
  }),
465
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
1088
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1089
+ style: hintStyle,
1090
+ children: t("card.allowlistPresetNote")
1091
+ }),
1092
+ patterns.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1093
+ style: {
1094
+ padding: "6px 0",
1095
+ fontSize: "12px",
1096
+ color: "var(--dsw-alias-label-tertiary)"
1097
+ },
1098
+ children: t("card.allowlistEmpty")
1099
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1100
+ style: {
1101
+ display: "flex",
1102
+ flexDirection: "column",
1103
+ gap: "4px",
1104
+ margin: "6px 0"
1105
+ },
1106
+ children: patterns.map((pattern, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1107
+ style: {
1108
+ display: "flex",
1109
+ alignItems: "center",
1110
+ gap: "8px",
1111
+ padding: "4px 8px",
1112
+ borderRadius: "6px",
1113
+ border: "1px solid var(--dsw-alias-border-l2)",
1114
+ background: "var(--dsw-alias-bg-surface, transparent)"
1115
+ },
1116
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", {
1117
+ style: {
1118
+ flex: "1 1 auto",
1119
+ minWidth: 0,
1120
+ overflowWrap: "anywhere",
1121
+ font: "500 12px/18px var(--ds-font-family-code, monospace)",
1122
+ color: "var(--dsw-alias-label-primary)"
1123
+ },
1124
+ children: pattern
1125
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1126
+ type: "button",
1127
+ "aria-label": t("card.allowlistRemove"),
1128
+ title: t("card.allowlistRemove"),
1129
+ disabled: readonly,
1130
+ onClick: () => {
1131
+ removePattern(index);
1132
+ },
1133
+ style: {
1134
+ flex: "0 0 auto",
1135
+ width: "20px",
1136
+ height: "20px",
1137
+ border: "none",
1138
+ borderRadius: "999px",
1139
+ background: "transparent",
1140
+ color: "var(--dsw-alias-label-tertiary)",
1141
+ cursor: readonly ? "default" : "pointer"
1142
+ },
1143
+ children: "✕"
1144
+ })]
1145
+ }, `${index}:${pattern}`))
1146
+ }),
1147
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1148
+ style: {
1149
+ display: "flex",
1150
+ gap: "6px",
1151
+ marginTop: "6px"
1152
+ },
1153
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1154
+ type: "text",
1155
+ value: addDraft,
1156
+ disabled: readonly,
1157
+ placeholder: t("card.allowlistPlaceholder"),
1158
+ spellCheck: false,
1159
+ style: {
1160
+ ...controlStyle,
1161
+ flex: "1 1 auto",
1162
+ minWidth: 0
1163
+ },
1164
+ onChange: (event) => {
1165
+ setAddDraft(event.currentTarget.value);
1166
+ },
1167
+ onKeyDown: (event) => {
1168
+ if (event.key === "Enter") addPattern();
1169
+ }
1170
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1171
+ type: "button",
1172
+ disabled: readonly || addDraft.trim() === "",
1173
+ onClick: addPattern,
1174
+ style: {
1175
+ ...controlStyle,
1176
+ flex: "0 0 auto",
1177
+ cursor: readonly || addDraft.trim() === "" ? "default" : "pointer",
1178
+ opacity: readonly || addDraft.trim() === "" ? .5 : 1
1179
+ },
1180
+ children: t("card.allowlistAdd")
1181
+ })]
1182
+ }),
1183
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1184
+ style: {
1185
+ display: "flex",
1186
+ justifyContent: "flex-end",
1187
+ marginTop: "6px"
1188
+ },
1189
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1190
+ type: "button",
1191
+ onClick: () => {
1192
+ setBulkOpen((current) => !current);
1193
+ },
1194
+ style: {
1195
+ ...controlStyle,
1196
+ cursor: "pointer",
1197
+ border: "none",
1198
+ background: "transparent",
1199
+ color: "var(--dsw-alias-label-tertiary)"
1200
+ },
1201
+ children: bulkOpen ? t("card.allowlistBulkHide") : t("card.allowlistBulk")
1202
+ })
1203
+ }),
1204
+ bulkOpen ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
466
1205
  id: "plugin-config-perm-gate-allowlist",
467
1206
  value: allowlistShown,
468
1207
  disabled: readonly,
@@ -483,8 +1222,7 @@ window.__ModuleLoader__.load({
483
1222
  onKeyDown: (event) => {
484
1223
  if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) commitAllowlist();
485
1224
  }
486
- }),
487
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1225
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
488
1226
  style: {
489
1227
  display: "flex",
490
1228
  justifyContent: "flex-end",
@@ -501,7 +1239,142 @@ window.__ModuleLoader__.load({
501
1239
  },
502
1240
  children: t("card.allowlistSave")
503
1241
  })
504
- })
1242
+ })] }) : null
1243
+ ]
1244
+ }),
1245
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1246
+ style: sectionStyle,
1247
+ children: [
1248
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1249
+ style: labelStyle,
1250
+ children: t("card.denylist")
1251
+ }),
1252
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1253
+ style: hintStyle,
1254
+ children: t("card.denylistHint")
1255
+ }),
1256
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1257
+ style: {
1258
+ display: "flex",
1259
+ flexDirection: "column",
1260
+ gap: "4px",
1261
+ margin: "6px 0"
1262
+ },
1263
+ children: denylist.map((keyword, index) => {
1264
+ const preset = DEFAULT_DENY_KEYWORDS.includes(keyword);
1265
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1266
+ style: {
1267
+ display: "flex",
1268
+ alignItems: "center",
1269
+ gap: "8px",
1270
+ padding: "4px 8px",
1271
+ borderRadius: "6px",
1272
+ border: "1px solid var(--dsw-alias-border-l2)",
1273
+ background: "var(--dsw-alias-bg-surface, transparent)"
1274
+ },
1275
+ children: [
1276
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", {
1277
+ style: {
1278
+ flex: "1 1 auto",
1279
+ minWidth: 0,
1280
+ overflowWrap: "anywhere",
1281
+ font: "500 12px/18px var(--ds-font-family-code, monospace)",
1282
+ color: "var(--dsw-alias-label-primary)"
1283
+ },
1284
+ children: keyword
1285
+ }),
1286
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1287
+ style: {
1288
+ flex: "0 0 auto",
1289
+ fontSize: "11px",
1290
+ lineHeight: "16px",
1291
+ padding: "0 6px",
1292
+ borderRadius: "8px",
1293
+ color: preset ? "var(--dsw-alias-label-tertiary)" : "var(--dsw-alias-label-secondary, inherit)",
1294
+ border: "1px solid var(--dsw-alias-border-l2)"
1295
+ },
1296
+ children: preset ? t("card.denylistTagPreset") : t("card.denylistTagCustom")
1297
+ }),
1298
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1299
+ type: "button",
1300
+ "aria-label": t("card.denylistRemove"),
1301
+ title: t("card.denylistRemove"),
1302
+ disabled: readonly,
1303
+ onClick: () => {
1304
+ removeDeny(index);
1305
+ },
1306
+ style: {
1307
+ flex: "0 0 auto",
1308
+ width: "20px",
1309
+ height: "20px",
1310
+ border: "none",
1311
+ borderRadius: "999px",
1312
+ background: "transparent",
1313
+ color: "var(--dsw-alias-label-tertiary)",
1314
+ cursor: readonly ? "default" : "pointer"
1315
+ },
1316
+ children: "✕"
1317
+ })
1318
+ ]
1319
+ }, `${index}:${keyword}`);
1320
+ })
1321
+ }),
1322
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1323
+ style: {
1324
+ display: "flex",
1325
+ gap: "6px",
1326
+ marginTop: "6px"
1327
+ },
1328
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1329
+ type: "text",
1330
+ value: denyDraft,
1331
+ disabled: readonly,
1332
+ placeholder: t("card.denylistPlaceholder"),
1333
+ spellCheck: false,
1334
+ style: {
1335
+ ...controlStyle,
1336
+ flex: "1 1 auto",
1337
+ minWidth: 0
1338
+ },
1339
+ onChange: (event) => {
1340
+ setDenyDraft(event.currentTarget.value);
1341
+ },
1342
+ onKeyDown: (event) => {
1343
+ if (event.key === "Enter") addDeny();
1344
+ }
1345
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1346
+ type: "button",
1347
+ disabled: readonly || denyDraft.trim() === "",
1348
+ onClick: addDeny,
1349
+ style: {
1350
+ ...controlStyle,
1351
+ flex: "0 0 auto",
1352
+ cursor: readonly || denyDraft.trim() === "" ? "default" : "pointer",
1353
+ opacity: readonly || denyDraft.trim() === "" ? .5 : 1
1354
+ },
1355
+ children: t("card.denylistAdd")
1356
+ })]
1357
+ }),
1358
+ denyActive ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1359
+ style: {
1360
+ display: "flex",
1361
+ justifyContent: "flex-end",
1362
+ marginTop: "6px"
1363
+ },
1364
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1365
+ type: "button",
1366
+ disabled: readonly,
1367
+ onClick: restoreDeny,
1368
+ style: {
1369
+ ...controlStyle,
1370
+ cursor: readonly ? "default" : "pointer",
1371
+ border: "none",
1372
+ background: "transparent",
1373
+ color: "var(--dsw-alias-label-tertiary)"
1374
+ },
1375
+ children: t("card.denylistRestore")
1376
+ })
1377
+ }) : null
505
1378
  ]
506
1379
  }),
507
1380
  !snapshot.writable && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
@@ -626,22 +1499,8 @@ window.__ModuleLoader__.load({
626
1499
  };
627
1500
  }
628
1501
  //#endregion
629
- //#region src/client/notice.tsx
630
- /**
631
- * Gate decision notice strip — the `conversation.input.dock` face of
632
- * dsh-perm-gate (a simplified version of the review-feed pattern demonstrated
633
- * by dsh-approval-gate).
634
- *
635
- * Polls the host's `GET /api/dsh-perm-gate/events?sessionId=&since=` feed every
636
- * 2 s and shows the latest decision above the conversation input: auto-allows
637
- * (green) and denies (red) auto-dismiss after a few seconds; an ask (amber)
638
- * stays until the next event because it needs the human's attention.
639
- *
640
- * Everything is best-effort: no session id, a missing route, or any fetch
641
- * failure simply means "render nothing". No @deepseek-ai value imports.
642
- */
643
- const POLL_MS = 2e3;
644
- const AUTO_HIDE_MS = 4e3;
1502
+ //#region src/client/feed.ts
1503
+ /** Fetch decision events for one session; `since > 0` returns only newer ones. */
645
1504
  async function fetchEvents(sessionId, since) {
646
1505
  const query = `/api/dsh-perm-gate/events?sessionId=${encodeURIComponent(sessionId)}${since > 0 ? `&since=${since}` : ""}`;
647
1506
  const res = await fetch(query, { headers: { "cache-control": "no-cache" } });
@@ -649,19 +1508,22 @@ window.__ModuleLoader__.load({
649
1508
  const body = await res.json();
650
1509
  return Array.isArray(body.events) ? body.events : [];
651
1510
  }
652
- /** Resolve the current session id from the slot props; null when unavailable. */
1511
+ /** Resolve the current session id from slot props (top level, nested, or hook). */
653
1512
  function resolveSessionId(props) {
654
1513
  const direct = props?.sessionId;
655
1514
  if (typeof direct === "string" && direct !== "") return direct;
656
- try {
657
- if (typeof props?.useSessions === "function") {
658
- const state = props.useSessions((s) => s);
1515
+ const nested = props?.slotsProps;
1516
+ if (typeof nested?.sessionId === "string" && nested.sessionId !== "") return nested.sessionId;
1517
+ for (const hook of [props?.useSessions, nested?.useSessions]) {
1518
+ if (typeof hook !== "function") continue;
1519
+ try {
1520
+ const state = hook((s) => s);
659
1521
  if (typeof state?.current === "string" && state.current !== "") return state.current;
660
- }
661
- } catch {}
1522
+ } catch {}
1523
+ }
662
1524
  return null;
663
1525
  }
664
- /** Presentation per event kind: accent color, tag, and whether it auto-dismisses. */
1526
+ /** Presentation per event kind: accent color, background wash, and tag. */
665
1527
  function presentation(kind) {
666
1528
  switch (kind) {
667
1529
  case "deny": return {
@@ -682,6 +1544,24 @@ window.__ModuleLoader__.load({
682
1544
  tag: "LEARNED",
683
1545
  sticky: false
684
1546
  };
1547
+ case "manual-approved": return {
1548
+ color: "var(--dsw-alias-state-warn-label, #b9770e)",
1549
+ bg: "var(--dsw-alias-state-warn-tertiary, rgba(185,119,14,0.08))",
1550
+ tag: "APPROVED",
1551
+ sticky: false
1552
+ };
1553
+ case "manual-rejected": return {
1554
+ color: "var(--dsw-alias-state-error-primary, #c0392b)",
1555
+ bg: "var(--dsw-alias-interactive-bg-hover-danger, rgba(192,57,43,0.08))",
1556
+ tag: "REJECTED",
1557
+ sticky: false
1558
+ };
1559
+ case "manual-cancelled": return {
1560
+ color: "var(--dsw-alias-label-secondary, #666)",
1561
+ bg: "var(--dsw-alias-bg-module-platform, rgba(127,127,127,0.08))",
1562
+ tag: "CANCELLED",
1563
+ sticky: false
1564
+ };
685
1565
  default: return {
686
1566
  color: "var(--dsw-alias-state-success-primary, #1e8449)",
687
1567
  bg: "var(--dsw-alias-state-success-tertiary, rgba(30,132,73,0.08))",
@@ -690,8 +1570,141 @@ window.__ModuleLoader__.load({
690
1570
  };
691
1571
  }
692
1572
  }
693
- function NoticeStrip({ slotsProps }) {
694
- const sessionId = resolveSessionId(slotsProps);
1573
+ /**
1574
+ * Decision-path labels shown beside an event's tool name. Kept alongside
1575
+ * `presentation` (not in the dictionary) because they name host-internal
1576
+ * decision sources; an unknown value falls through to its raw string.
1577
+ */
1578
+ const VERDICT_LABELS = {
1579
+ rule: "规则命中",
1580
+ grant: "会话授权",
1581
+ "hard-deny": "硬拒绝",
1582
+ "deny-keyword": "黑名单关键词",
1583
+ default: "默认策略",
1584
+ ask: "默认策略",
1585
+ permissive: "Permissive 放行",
1586
+ classifier: "LLM 裁决",
1587
+ "llm-safe": "LLM 判定安全",
1588
+ "llm-learned": "LLM 学习放行",
1589
+ "learned-sediment": "沉淀规则放行",
1590
+ "learned-confirm": "学习确认",
1591
+ "human-approved": "人工通过",
1592
+ "human-rejected": "人工拒绝",
1593
+ "human-cancelled": "人工取消",
1594
+ "no-approval-channel": "无审批通道"
1595
+ };
1596
+ /** Compact wall-clock rendering of an ISO timestamp. */
1597
+ function clockTime(iso) {
1598
+ const date = new Date(iso);
1599
+ if (Number.isNaN(date.getTime())) return iso;
1600
+ return date.toLocaleTimeString([], {
1601
+ hour: "2-digit",
1602
+ minute: "2-digit",
1603
+ second: "2-digit"
1604
+ });
1605
+ }
1606
+ /** Human-readable byte size for the snapshot bar. */
1607
+ function fmtBytes(n) {
1608
+ if (!Number.isFinite(n) || n <= 0) return "0 B";
1609
+ if (n < 1024) return `${n} B`;
1610
+ if (n < 1048576) return `${(n / 1024).toFixed(1)} KB`;
1611
+ return `${(n / 1048576).toFixed(2)} MB`;
1612
+ }
1613
+ /** Load the snapshot inventory (optionally session-scoped); null on failure. */
1614
+ async function fetchSnapshotStats(sessionId) {
1615
+ const query = sessionId === null ? "" : `?sessionId=${encodeURIComponent(sessionId)}`;
1616
+ const res = await fetch(`/api/dsh-perm-gate/snapshots-stats${query}`, { headers: { "cache-control": "no-cache" } });
1617
+ if (!res.ok) return null;
1618
+ const body = await res.json();
1619
+ if (body.ok !== true) return null;
1620
+ const files = {};
1621
+ if (typeof body.files === "object" && body.files !== null) {
1622
+ for (const [key, value] of Object.entries(body.files)) if (Array.isArray(value)) files[key] = value.map(String);
1623
+ }
1624
+ return {
1625
+ count: typeof body.count === "number" ? body.count : 0,
1626
+ bytes: typeof body.bytes === "number" ? body.bytes : 0,
1627
+ ids: Array.isArray(body.ids) ? body.ids.map(String) : [],
1628
+ files
1629
+ };
1630
+ }
1631
+ /** Drop snapshots: pass a session id for a session-scoped clear, null for all. */
1632
+ async function clearSnapshots(sessionId) {
1633
+ try {
1634
+ return (await fetch("/api/dsh-perm-gate/snapshots-clear", {
1635
+ method: "POST",
1636
+ headers: { "content-type": "application/json" },
1637
+ body: JSON.stringify(sessionId === null ? {} : { sessionId })
1638
+ })).ok;
1639
+ } catch {
1640
+ return false;
1641
+ }
1642
+ }
1643
+ /** Load the before/after diff for one event's file; throws on failure. */
1644
+ async function fetchDiff(eventId, path) {
1645
+ const res = await fetch(`/api/dsh-perm-gate/diff?eventId=${eventId}&path=${encodeURIComponent(path)}`, { headers: { "cache-control": "no-cache" } });
1646
+ const body = await res.json().catch(() => null);
1647
+ if (body === null || body.ok !== true) throw new Error(typeof body?.error === "string" ? body.error : `HTTP ${res.status}`);
1648
+ return {
1649
+ hunks: Array.isArray(body.hunks) ? body.hunks : [],
1650
+ stats: typeof body.stats === "object" && body.stats !== null ? body.stats : {
1651
+ added: 0,
1652
+ removed: 0,
1653
+ contextLines: 0
1654
+ },
1655
+ afterExists: body.afterExists !== false
1656
+ };
1657
+ }
1658
+ /** Deliver a revert instruction for one event into its conversation. */
1659
+ async function sendRevert(sessionId, eventId) {
1660
+ try {
1661
+ const res = await fetch("/api/dsh-perm-gate/revert", {
1662
+ method: "POST",
1663
+ headers: { "content-type": "application/json" },
1664
+ body: JSON.stringify({
1665
+ sessionId,
1666
+ eventId
1667
+ })
1668
+ });
1669
+ const body = await res.json().catch(() => null);
1670
+ if (body !== null && body.ok === true) return { ok: true };
1671
+ return {
1672
+ ok: false,
1673
+ error: typeof body?.error === "string" ? body.error : `HTTP ${res.status}`
1674
+ };
1675
+ } catch (e) {
1676
+ return {
1677
+ ok: false,
1678
+ error: String(e?.message ?? e)
1679
+ };
1680
+ }
1681
+ }
1682
+ //#endregion
1683
+ //#region src/client/notice.tsx
1684
+ /**
1685
+ * Gate decision notice strip — the `conversation.input.dock` face of
1686
+ * dsh-perm-gate (a simplified version of the review-feed pattern demonstrated
1687
+ * by dsh-approval-gate).
1688
+ *
1689
+ * Polls the host's `GET /api/dsh-perm-gate/events?sessionId=&since=` feed every
1690
+ * 2 s and shows the latest decision above the conversation input: auto-allows
1691
+ * (green) and denies (red) auto-dismiss after a few seconds; an ask (amber)
1692
+ * stays until the next event because it needs the human's attention.
1693
+ *
1694
+ * Everything is best-effort: no session id, a missing route, or any fetch
1695
+ * failure simply means "render nothing". No @deepseek-ai value imports.
1696
+ */
1697
+ const POLL_MS$1 = 2e3;
1698
+ const AUTO_HIDE_MS = 4e3;
1699
+ /** Localized tag for a manual terminal state; other kinds keep their short tag. */
1700
+ function manualTagKey(kind) {
1701
+ if (kind === "manual-approved") return "history.tag.manualApproved";
1702
+ if (kind === "manual-rejected") return "history.tag.manualRejected";
1703
+ if (kind === "manual-cancelled") return "history.tag.manualCancelled";
1704
+ return null;
1705
+ }
1706
+ function NoticeStrip({ t, ...props }) {
1707
+ const sessionId = resolveSessionId(props);
695
1708
  const [notice, setNotice] = (0, react.useState)(null);
696
1709
  const sinceRef = (0, react.useRef)(0);
697
1710
  const shownIdRef = (0, react.useRef)(0);
@@ -718,7 +1731,7 @@ window.__ModuleLoader__.load({
718
1731
  setNotice(null);
719
1732
  }, AUTO_HIDE_MS);
720
1733
  }).catch(() => {});
721
- }, POLL_MS);
1734
+ }, POLL_MS$1);
722
1735
  };
723
1736
  fetchEvents(sessionId, 0).then((events) => {
724
1737
  if (!alive) return;
@@ -736,6 +1749,7 @@ window.__ModuleLoader__.load({
736
1749
  }, [sessionId]);
737
1750
  if (notice === null) return null;
738
1751
  const style = presentation(notice.kind);
1752
+ const manualKey = manualTagKey(notice.kind);
739
1753
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
740
1754
  role: "status",
741
1755
  style: {
@@ -761,7 +1775,7 @@ window.__ModuleLoader__.load({
761
1775
  color: style.color,
762
1776
  fontWeight: 600
763
1777
  },
764
- children: style.tag
1778
+ children: manualKey === null ? style.tag : t(manualKey)
765
1779
  }),
766
1780
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
767
1781
  style: {
@@ -807,6 +1821,715 @@ window.__ModuleLoader__.load({
807
1821
  });
808
1822
  }
809
1823
  //#endregion
1824
+ //#region src/client/history.tsx
1825
+ /**
1826
+ * Approval-history view — the `conversation.view` face of dsh-perm-gate,
1827
+ * following the session-scoped review-page pattern demonstrated by
1828
+ * dsh-approval-gate: a timeline of every gate decision in the current
1829
+ * conversation, newest first, refreshed by polling.
1830
+ *
1831
+ * Beyond the decision timeline this view owns the review data plane: a snapshot
1832
+ * inventory bar (with session/all clearing), per-event file chips, and a diff
1833
+ * overlay that shows the pre-change vs current content of one file and can send
1834
+ * a revert instruction back into the conversation.
1835
+ *
1836
+ * Data source: the host's `GET /api/dsh-perm-gate/events?sessionId=` feed (the
1837
+ * same JSONL the notice strip consumes) plus the diff / revert / snapshot
1838
+ * routes. Everything is best-effort: no session id or any fetch failure renders
1839
+ * the empty / error state. No @deepseek-ai value imports.
1840
+ */
1841
+ const POLL_MS = 5e3;
1842
+ /** Tag text per event kind, keyed into the plugin dictionary. */
1843
+ const TAG_KEYS = {
1844
+ auto: "history.tag.auto",
1845
+ ask: "history.tag.ask",
1846
+ deny: "history.tag.deny",
1847
+ learned: "history.tag.learned",
1848
+ "manual-approved": "history.tag.manualApproved",
1849
+ "manual-rejected": "history.tag.manualRejected",
1850
+ "manual-cancelled": "history.tag.manualCancelled"
1851
+ };
1852
+ /** The file name alone (chips show the basename, like the reference page). */
1853
+ function basename(p) {
1854
+ const seg = p.split(/[\\/]/);
1855
+ return seg[seg.length - 1] ?? p;
1856
+ }
1857
+ function glyphOf(kind) {
1858
+ const style = presentation(kind);
1859
+ switch (kind) {
1860
+ case "deny":
1861
+ case "manual-rejected": return {
1862
+ char: "✕",
1863
+ color: style.color
1864
+ };
1865
+ case "ask": return {
1866
+ char: "◔",
1867
+ color: style.color
1868
+ };
1869
+ case "manual-cancelled": return {
1870
+ char: "—",
1871
+ color: style.color
1872
+ };
1873
+ default: return {
1874
+ char: "✓",
1875
+ color: style.color
1876
+ };
1877
+ }
1878
+ }
1879
+ const barStyle = {
1880
+ display: "flex",
1881
+ alignItems: "center",
1882
+ gap: "8px",
1883
+ flexWrap: "wrap",
1884
+ padding: "6px 10px",
1885
+ borderRadius: "8px",
1886
+ background: "var(--dsw-alias-bg-layer-3, rgba(127,127,127,0.05))",
1887
+ fontSize: "12px",
1888
+ lineHeight: "18px",
1889
+ color: "var(--dsw-alias-label-tertiary)"
1890
+ };
1891
+ const barButtonStyle = {
1892
+ boxSizing: "border-box",
1893
+ height: "26px",
1894
+ color: "var(--dsw-alias-label-primary)",
1895
+ cursor: "pointer",
1896
+ font: "inherit",
1897
+ border: "1px solid var(--dsw-alias-border-l2)",
1898
+ background: "transparent",
1899
+ borderRadius: "13px",
1900
+ padding: "0 12px",
1901
+ fontSize: "12px",
1902
+ lineHeight: "24px"
1903
+ };
1904
+ /** The snapshot inventory bar: usage plus session/all clearing. */
1905
+ function SnapshotBar(props) {
1906
+ const { t, stats, sessionId, onCleared } = props;
1907
+ const busy = stats === null || stats.count === 0;
1908
+ const clear = (mode) => {
1909
+ if (!window.confirm(mode === "all" ? t("history.clearAllConfirm") : t("history.clearSessionConfirm"))) return;
1910
+ clearSnapshots(mode === "session" ? sessionId : null).then(() => {
1911
+ onCleared();
1912
+ });
1913
+ };
1914
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1915
+ style: barStyle,
1916
+ children: [
1917
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [`${t("history.snapshots")} `, /* @__PURE__ */ (0, react_jsx_runtime.jsx)("b", {
1918
+ style: {
1919
+ color: "var(--dsw-alias-label-secondary)",
1920
+ fontWeight: 500
1921
+ },
1922
+ children: stats === null ? "…" : `${fmtBytes(stats.bytes)} · ${stats.count}`
1923
+ })] }),
1924
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { style: { flex: "1 1 auto" } }),
1925
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1926
+ type: "button",
1927
+ title: t("history.clearSessionTitle"),
1928
+ disabled: busy,
1929
+ onClick: () => {
1930
+ clear("session");
1931
+ },
1932
+ style: {
1933
+ ...barButtonStyle,
1934
+ opacity: busy ? .4 : 1
1935
+ },
1936
+ children: t("history.clearSession")
1937
+ }),
1938
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1939
+ type: "button",
1940
+ title: t("history.clearAllTitle"),
1941
+ disabled: busy,
1942
+ onClick: () => {
1943
+ clear("all");
1944
+ },
1945
+ style: {
1946
+ ...barButtonStyle,
1947
+ opacity: busy ? .4 : 1,
1948
+ color: "var(--dsw-alias-state-error-primary)"
1949
+ },
1950
+ children: t("history.clearAll")
1951
+ })
1952
+ ]
1953
+ });
1954
+ }
1955
+ /** One diff line row (marker + line numbers + text). */
1956
+ function DiffRow({ line }) {
1957
+ const isAdd = line.type === "add";
1958
+ const isDel = line.type === "del";
1959
+ const background = isAdd ? "var(--dsw-alias-state-success-tertiary)" : isDel ? "var(--dsw-alias-interactive-bg-hover-danger)" : "transparent";
1960
+ const color = isDel ? "var(--dsw-alias-state-error-primary)" : isAdd ? "var(--dsw-alias-label-primary)" : "var(--dsw-alias-label-secondary)";
1961
+ const aNo = line.aNo === void 0 ? "" : String(line.aNo);
1962
+ const bNo = line.bNo === void 0 ? "" : String(line.bNo);
1963
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1964
+ style: {
1965
+ boxSizing: "border-box",
1966
+ display: "flex",
1967
+ gap: "8px",
1968
+ padding: "1px 8px",
1969
+ whiteSpace: "pre-wrap",
1970
+ wordBreak: "break-all",
1971
+ minWidth: 0,
1972
+ background,
1973
+ color
1974
+ },
1975
+ children: [
1976
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1977
+ "aria-hidden": true,
1978
+ style: {
1979
+ flex: "0 0 auto",
1980
+ width: "16px",
1981
+ userSelect: "none"
1982
+ },
1983
+ children: isAdd ? "+" : isDel ? "-" : " "
1984
+ }),
1985
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1986
+ style: {
1987
+ flex: "0 0 auto",
1988
+ width: "60px",
1989
+ color: "var(--dsw-alias-label-caption)",
1990
+ textAlign: "right",
1991
+ userSelect: "none",
1992
+ fontVariantNumeric: "tabular-nums"
1993
+ },
1994
+ children: [
1995
+ aNo,
1996
+ aNo !== "" && bNo !== "" ? " " : "",
1997
+ bNo
1998
+ ]
1999
+ }),
2000
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2001
+ style: {
2002
+ flex: "1 1 auto",
2003
+ minWidth: 0
2004
+ },
2005
+ children: line.text
2006
+ })
2007
+ ]
2008
+ });
2009
+ }
2010
+ /** The file-change overlay: before/after diff plus the revert action. */
2011
+ function DiffPanel(props) {
2012
+ const { t, sessionId, eventId, path, onClose } = props;
2013
+ const [data, setData] = (0, react.useState)(null);
2014
+ const [error, setError] = (0, react.useState)(null);
2015
+ const [revertMsg, setRevertMsg] = (0, react.useState)(null);
2016
+ const [revertOk, setRevertOk] = (0, react.useState)(false);
2017
+ const [reverting, setReverting] = (0, react.useState)(false);
2018
+ const [revertDone, setRevertDone] = (0, react.useState)(false);
2019
+ (0, react.useEffect)(() => {
2020
+ let alive = true;
2021
+ setData(null);
2022
+ setError(null);
2023
+ setRevertDone(false);
2024
+ setRevertMsg(null);
2025
+ fetchDiff(eventId, path).then((res) => {
2026
+ if (alive) setData(res);
2027
+ }).catch((e) => {
2028
+ if (alive) setError(`${t("history.diffLoadFail")}${String(e?.message ?? e)}`);
2029
+ });
2030
+ return () => {
2031
+ alive = false;
2032
+ };
2033
+ }, [
2034
+ eventId,
2035
+ path,
2036
+ t
2037
+ ]);
2038
+ const doRevert = () => {
2039
+ if (reverting || revertDone || sessionId === null) return;
2040
+ setReverting(true);
2041
+ setRevertMsg(null);
2042
+ sendRevert(sessionId, eventId).then((res) => {
2043
+ if (res.ok) {
2044
+ setRevertMsg(t("history.diffRevertSent"));
2045
+ setRevertOk(true);
2046
+ setRevertDone(true);
2047
+ } else {
2048
+ setRevertMsg(`${t("history.diffRevertFail")}${res.error ?? ""}`);
2049
+ setRevertOk(false);
2050
+ }
2051
+ setReverting(false);
2052
+ });
2053
+ };
2054
+ const stats = data?.stats;
2055
+ const body = error !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2056
+ style: diffEmptyStyle,
2057
+ children: error
2058
+ }) : data === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2059
+ style: diffEmptyStyle,
2060
+ children: t("history.diffLoading")
2061
+ }) : data.hunks.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2062
+ style: diffEmptyStyle,
2063
+ children: t("history.diffEmpty")
2064
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2065
+ style: diffBodyStyle,
2066
+ children: data.hunks.map((hunk, hi) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [hunk.hiddenBefore > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2067
+ style: hunkSepStyle,
2068
+ children: t("history.diffHidden").replace("%n", String(hunk.hiddenBefore))
2069
+ }) : null, hunk.lines.map((line, li) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DiffRow, { line }, `l-${hi}-${li}`))] }, `hunk-${hi}`))
2070
+ });
2071
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2072
+ style: overlayStyle,
2073
+ onClick: (e) => {
2074
+ if (e.target === e.currentTarget) onClose();
2075
+ },
2076
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2077
+ style: panelStyle,
2078
+ children: [
2079
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2080
+ style: panelHeadStyle,
2081
+ children: [
2082
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2083
+ style: {
2084
+ display: "flex",
2085
+ alignItems: "center",
2086
+ gap: "8px"
2087
+ },
2088
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2089
+ style: {
2090
+ flex: "1 1 auto",
2091
+ fontSize: "14px",
2092
+ fontWeight: 500,
2093
+ color: "var(--dsw-alias-label-primary)"
2094
+ },
2095
+ children: t("history.diffTitle")
2096
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2097
+ type: "button",
2098
+ "aria-label": t("history.diffClose"),
2099
+ title: t("history.diffClose"),
2100
+ onClick: onClose,
2101
+ style: closeStyle,
2102
+ children: "✕"
2103
+ })]
2104
+ }),
2105
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2106
+ style: {
2107
+ fontSize: "12px",
2108
+ lineHeight: "18px",
2109
+ fontFamily: "var(--ds-font-family-code, monospace)",
2110
+ color: "var(--dsw-alias-label-tertiary)",
2111
+ wordBreak: "break-all"
2112
+ },
2113
+ children: path
2114
+ }),
2115
+ stats === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2116
+ style: {
2117
+ fontSize: "12px",
2118
+ lineHeight: "18px",
2119
+ color: "var(--dsw-alias-label-secondary)"
2120
+ },
2121
+ children: [t("history.diffStats").replace("%a", String(stats.added)).replace("%r", String(stats.removed)).replace("%c", String(stats.contextLines)), data !== null && !data.afterExists ? t("history.diffGone") : ""]
2122
+ })
2123
+ ]
2124
+ }),
2125
+ body,
2126
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2127
+ style: panelFootStyle,
2128
+ children: [
2129
+ revertMsg === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2130
+ style: {
2131
+ marginRight: "auto",
2132
+ fontSize: "12px",
2133
+ color: revertOk ? "var(--dsw-alias-state-success-primary)" : "var(--dsw-alias-state-error-primary)"
2134
+ },
2135
+ children: revertMsg
2136
+ }),
2137
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2138
+ type: "button",
2139
+ onClick: doRevert,
2140
+ disabled: reverting || revertDone || sessionId === null,
2141
+ style: {
2142
+ ...barButtonStyle,
2143
+ border: "none",
2144
+ background: "var(--dsw-alias-button-primary-fill, var(--dsw-alias-label-primary))",
2145
+ color: "var(--dsw-alias-label-primary-foreground, #fff)",
2146
+ opacity: reverting || revertDone ? .5 : 1
2147
+ },
2148
+ children: reverting ? t("history.diffReverting") : revertDone ? t("history.diffRevertDone") : t("history.diffRevert")
2149
+ }),
2150
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2151
+ type: "button",
2152
+ onClick: onClose,
2153
+ style: barButtonStyle,
2154
+ children: t("history.diffClose")
2155
+ })
2156
+ ]
2157
+ })
2158
+ ]
2159
+ })
2160
+ });
2161
+ }
2162
+ const overlayStyle = {
2163
+ position: "fixed",
2164
+ inset: 0,
2165
+ zIndex: 900,
2166
+ display: "flex",
2167
+ alignItems: "center",
2168
+ justifyContent: "center",
2169
+ background: "var(--dsw-alias-bg-mask-1, rgba(0,0,0,0.35))"
2170
+ };
2171
+ const panelStyle = {
2172
+ boxSizing: "border-box",
2173
+ width: "min(760px, calc(100vw - 48px))",
2174
+ maxHeight: "min(720px, calc(100vh - 48px))",
2175
+ display: "flex",
2176
+ flexDirection: "column",
2177
+ background: "var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-layer-1))",
2178
+ border: "1px solid var(--dsw-alias-border-l1)",
2179
+ borderRadius: "16px",
2180
+ overflow: "hidden"
2181
+ };
2182
+ const panelHeadStyle = {
2183
+ boxSizing: "border-box",
2184
+ flex: "0 0 auto",
2185
+ display: "flex",
2186
+ flexDirection: "column",
2187
+ gap: "4px",
2188
+ padding: "12px 14px",
2189
+ borderBottom: "1px solid var(--dsw-alias-border-l2)"
2190
+ };
2191
+ const panelFootStyle = {
2192
+ boxSizing: "border-box",
2193
+ flex: "0 0 auto",
2194
+ display: "flex",
2195
+ alignItems: "center",
2196
+ gap: "8px",
2197
+ justifyContent: "flex-end",
2198
+ padding: "10px 14px",
2199
+ borderTop: "1px solid var(--dsw-alias-border-l2)"
2200
+ };
2201
+ const diffBodyStyle = {
2202
+ flex: "1 1 auto",
2203
+ minHeight: 0,
2204
+ overflowY: "auto",
2205
+ padding: "8px 10px",
2206
+ display: "flex",
2207
+ flexDirection: "column",
2208
+ fontFamily: "var(--ds-font-family-code, monospace)",
2209
+ fontSize: "12px",
2210
+ lineHeight: "19px"
2211
+ };
2212
+ const diffEmptyStyle = {
2213
+ flex: "1 1 auto",
2214
+ color: "var(--dsw-alias-label-tertiary)",
2215
+ fontSize: "13px",
2216
+ lineHeight: "20px",
2217
+ padding: "16px",
2218
+ textAlign: "center"
2219
+ };
2220
+ const hunkSepStyle = {
2221
+ boxSizing: "border-box",
2222
+ display: "flex",
2223
+ alignItems: "center",
2224
+ justifyContent: "center",
2225
+ color: "var(--dsw-alias-label-tertiary)",
2226
+ fontSize: "11px",
2227
+ lineHeight: "16px",
2228
+ padding: "2px 8px",
2229
+ margin: "2px 0",
2230
+ borderTop: "1px solid var(--dsw-alias-border-l1)",
2231
+ borderBottom: "1px solid var(--dsw-alias-border-l1)",
2232
+ userSelect: "none"
2233
+ };
2234
+ const closeStyle = {
2235
+ flex: "0 0 auto",
2236
+ width: "24px",
2237
+ height: "24px",
2238
+ border: "none",
2239
+ borderRadius: "999px",
2240
+ background: "transparent",
2241
+ color: "var(--dsw-alias-label-tertiary)",
2242
+ cursor: "pointer"
2243
+ };
2244
+ function HistoryView({ t, ...props }) {
2245
+ const sessionId = resolveSessionId(props);
2246
+ const [events, setEvents] = (0, react.useState)(null);
2247
+ const [error, setError] = (0, react.useState)(null);
2248
+ const [stats, setStats] = (0, react.useState)(null);
2249
+ const [diffOpen, setDiffOpen] = (0, react.useState)(null);
2250
+ (0, react.useEffect)(() => {
2251
+ setEvents(null);
2252
+ setError(null);
2253
+ setStats(null);
2254
+ let alive = true;
2255
+ const load = () => {
2256
+ fetchSnapshotStats(sessionId).then((next) => {
2257
+ if (alive) setStats(next);
2258
+ });
2259
+ if (sessionId === null) {
2260
+ setEvents([]);
2261
+ return;
2262
+ }
2263
+ fetchEvents(sessionId, 0).then((loaded) => {
2264
+ if (!alive) return;
2265
+ setEvents([...loaded].sort((a, b) => b.id - a.id));
2266
+ setError(null);
2267
+ }).catch((e) => {
2268
+ if (!alive) return;
2269
+ setError(String(e?.message ?? e));
2270
+ });
2271
+ };
2272
+ load();
2273
+ const timer = setInterval(load, POLL_MS);
2274
+ return () => {
2275
+ alive = false;
2276
+ clearInterval(timer);
2277
+ };
2278
+ }, [sessionId]);
2279
+ /** Whether one event has a snapshot covering this file (chip is clickable). */
2280
+ const hasSnapshot = (ev, file) => {
2281
+ const paths = stats?.files[String(ev.id)];
2282
+ if (paths === void 0 || paths.length === 0) return false;
2283
+ const base = basename(file);
2284
+ return paths.some((p) => p === file || basename(p) === base);
2285
+ };
2286
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2287
+ style: {
2288
+ boxSizing: "border-box",
2289
+ maxWidth: "720px",
2290
+ margin: "0 auto",
2291
+ padding: "8px 4px"
2292
+ },
2293
+ children: [
2294
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2295
+ style: { marginBottom: "10px" },
2296
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2297
+ style: {
2298
+ fontSize: "14px",
2299
+ fontWeight: 600,
2300
+ color: "var(--dsw-alias-label-primary)"
2301
+ },
2302
+ children: t("history.title")
2303
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2304
+ style: {
2305
+ marginTop: "2px",
2306
+ fontSize: "12px",
2307
+ lineHeight: "18px",
2308
+ color: "var(--dsw-alias-label-primary) !important"
2309
+ },
2310
+ children: t("history.subtitle")
2311
+ })]
2312
+ }),
2313
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SnapshotBar, {
2314
+ t,
2315
+ stats,
2316
+ sessionId,
2317
+ onCleared: () => {
2318
+ fetchSnapshotStats(sessionId).then(setStats);
2319
+ }
2320
+ }),
2321
+ events === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2322
+ style: {
2323
+ padding: "16px 0",
2324
+ fontSize: "13px",
2325
+ color: "var(--dsw-alias-label-tertiary)"
2326
+ },
2327
+ children: error === null ? t("history.loading") : `${t("history.error")}${error}`
2328
+ }) : events.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2329
+ style: {
2330
+ padding: "16px 0",
2331
+ fontSize: "13px",
2332
+ color: "var(--dsw-alias-label-tertiary)"
2333
+ },
2334
+ children: error === null ? t("history.empty") : `${t("history.error")}${error}`
2335
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: events.map((ev) => {
2336
+ const style = presentation(ev.kind);
2337
+ const mark = glyphOf(ev.kind);
2338
+ const verdict = ev.verdict === void 0 ? "" : VERDICT_LABELS[ev.verdict] ?? ev.verdict;
2339
+ const files = ev.files ?? [];
2340
+ const chips = [];
2341
+ const seenBase = /* @__PURE__ */ new Set();
2342
+ for (const f of files) {
2343
+ const base = basename(f);
2344
+ if (base === "" || seenBase.has(base)) continue;
2345
+ seenBase.add(base);
2346
+ chips.push(f);
2347
+ }
2348
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2349
+ style: {
2350
+ display: "flex",
2351
+ gap: "10px",
2352
+ padding: "6px 10px",
2353
+ borderRadius: "8px",
2354
+ background: "var(--dsw-alias-bg-layer-3, rgba(127,127,127,0.05))"
2355
+ },
2356
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2357
+ style: {
2358
+ display: "flex",
2359
+ flexDirection: "column",
2360
+ alignItems: "center",
2361
+ flex: "0 0 auto",
2362
+ width: "20px"
2363
+ },
2364
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2365
+ "aria-hidden": true,
2366
+ style: {
2367
+ width: "18px",
2368
+ height: "18px",
2369
+ marginTop: "3px",
2370
+ display: "flex",
2371
+ alignItems: "center",
2372
+ justifyContent: "center",
2373
+ fontSize: "11px",
2374
+ lineHeight: "18px",
2375
+ borderRadius: "999px",
2376
+ border: `1px solid ${mark.color}`,
2377
+ color: mark.color
2378
+ },
2379
+ children: mark.char
2380
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2381
+ "aria-hidden": true,
2382
+ style: {
2383
+ flex: "1 1 auto",
2384
+ width: "1px",
2385
+ minHeight: "10px",
2386
+ background: "var(--dsw-alias-border-l2, rgba(127,127,127,0.3))"
2387
+ }
2388
+ })]
2389
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2390
+ style: {
2391
+ flex: "1 1 auto",
2392
+ minWidth: 0,
2393
+ paddingBottom: "12px"
2394
+ },
2395
+ children: [
2396
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2397
+ style: {
2398
+ display: "flex",
2399
+ alignItems: "center",
2400
+ gap: "8px",
2401
+ flexWrap: "wrap"
2402
+ },
2403
+ children: [
2404
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", {
2405
+ style: {
2406
+ font: "600 12px/18px var(--ds-font-family-code, monospace)",
2407
+ color: "var(--dsw-alias-label-primary)"
2408
+ },
2409
+ children: ev.tool
2410
+ }),
2411
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2412
+ style: {
2413
+ fontSize: "11px",
2414
+ lineHeight: "18px",
2415
+ padding: "0 8px",
2416
+ borderRadius: "9px",
2417
+ color: style.color,
2418
+ background: style.bg,
2419
+ fontWeight: 600
2420
+ },
2421
+ children: t(TAG_KEYS[ev.kind])
2422
+ }),
2423
+ verdict === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2424
+ style: {
2425
+ fontSize: "11px",
2426
+ lineHeight: "18px",
2427
+ padding: "0 6px",
2428
+ borderRadius: "9px",
2429
+ color: "var(--dsw-alias-label-tertiary)",
2430
+ border: "1px solid var(--dsw-alias-border-l2)"
2431
+ },
2432
+ children: verdict
2433
+ }),
2434
+ ev.risk === void 0 || ev.risk === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2435
+ style: {
2436
+ fontSize: "11px",
2437
+ lineHeight: "18px",
2438
+ padding: "0 6px",
2439
+ borderRadius: "9px",
2440
+ color: "var(--dsw-alias-label-tertiary)",
2441
+ border: "1px solid var(--dsw-alias-border-l2)"
2442
+ },
2443
+ children: ev.risk
2444
+ }),
2445
+ ev.kind === "manual-approved" && ev.learningCount !== void 0 && ev.threshold !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2446
+ style: {
2447
+ fontSize: "11px",
2448
+ lineHeight: "18px",
2449
+ padding: "0 6px",
2450
+ borderRadius: "9px",
2451
+ color: "var(--dsw-alias-state-warn-label)",
2452
+ border: "1px solid var(--dsw-alias-border-l2)"
2453
+ },
2454
+ children: t("history.learnedProgress").replace("%n", String(ev.learningCount)).replace("%t", String(ev.threshold))
2455
+ }) : null,
2456
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2457
+ style: {
2458
+ marginLeft: "auto",
2459
+ flex: "0 0 auto",
2460
+ fontSize: "11px",
2461
+ color: "var(--dsw-alias-label-tertiary)"
2462
+ },
2463
+ children: clockTime(ev.ts)
2464
+ })
2465
+ ]
2466
+ }),
2467
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2468
+ style: {
2469
+ marginTop: "2px",
2470
+ fontSize: "12px",
2471
+ lineHeight: "18px",
2472
+ color: "var(--dsw-alias-label-secondary, inherit)",
2473
+ overflowWrap: "anywhere"
2474
+ },
2475
+ children: ev.justification ?? ev.reason
2476
+ }),
2477
+ chips.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2478
+ style: {
2479
+ display: "flex",
2480
+ flexWrap: "wrap",
2481
+ gap: "4px",
2482
+ marginTop: "4px"
2483
+ },
2484
+ children: chips.map((file) => {
2485
+ const clickable = hasSnapshot(ev, file);
2486
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2487
+ title: clickable ? t("history.diffChipTitle") : file,
2488
+ role: clickable ? "button" : void 0,
2489
+ onClick: clickable ? () => {
2490
+ setDiffOpen({
2491
+ eventId: ev.id,
2492
+ path: file
2493
+ });
2494
+ } : void 0,
2495
+ style: {
2496
+ boxSizing: "border-box",
2497
+ maxWidth: "260px",
2498
+ height: "20px",
2499
+ display: "inline-flex",
2500
+ alignItems: "center",
2501
+ padding: "0 7px",
2502
+ borderRadius: "5px",
2503
+ font: "11px/18px var(--ds-font-family-code, monospace)",
2504
+ border: `1px solid ${clickable ? "var(--dsw-alias-state-business-primary)" : "var(--dsw-alias-border-l1)"}`,
2505
+ color: clickable ? "var(--dsw-alias-state-business-primary)" : "var(--dsw-alias-label-secondary)",
2506
+ background: "var(--dsw-alias-bg-layer-1, transparent)",
2507
+ cursor: clickable ? "pointer" : "default",
2508
+ overflow: "hidden",
2509
+ textOverflow: "ellipsis",
2510
+ whiteSpace: "nowrap"
2511
+ },
2512
+ children: basename(file)
2513
+ }, file);
2514
+ })
2515
+ })
2516
+ ]
2517
+ })]
2518
+ }, ev.id);
2519
+ }) }),
2520
+ diffOpen === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DiffPanel, {
2521
+ t,
2522
+ sessionId,
2523
+ eventId: diffOpen.eventId,
2524
+ path: diffOpen.path,
2525
+ onClose: () => {
2526
+ setDiffOpen(null);
2527
+ }
2528
+ })
2529
+ ]
2530
+ });
2531
+ }
2532
+ //#endregion
810
2533
  //#region src/client/index.ts
811
2534
  /** The settings namespace the host half registers (kept in lockstep with src/index.ts). */
812
2535
  const PERMISSIVE_NS = "dsh-perm-gate";
@@ -835,6 +2558,16 @@ window.__ModuleLoader__.load({
835
2558
  locale: NS
836
2559
  }, NoticeStrip);
837
2560
  });
2561
+ ctx.slots.inject("conversation.view", function* () {
2562
+ yield ctx.slots.register({
2563
+ name: "conversation.view",
2564
+ id: "dsh-perm-gate.history",
2565
+ order: 20,
2566
+ label: () => t("history.label"),
2567
+ locale: NS,
2568
+ inject: (sessionId) => ({ sessionId })
2569
+ }, HistoryView);
2570
+ });
838
2571
  const t = ctx.locale.bind(NS);
839
2572
  ctx.slots.inject("settings.plugins.tab", function* () {
840
2573
  yield ctx.slots.register({