@lmzhen/dsh-evolution-skill-history 0.12.0 → 0.13.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @lmzhen/dsh-evolution-skill-history
2
2
 
3
- The web surface for skill content history: a left-sidebar panel row (**Skill history**) that lists the versions every skill write records, and the four loopback host routes it reads.
3
+ The web surface for skill content history: a left-sidebar panel row (**Skill history**) that lists the versions every skill write records, and the five loopback host routes it reads.
4
4
 
5
5
  The **facts** belong to `@lmzhen/dsh-evolution-core` (which artifact a version holds, how the body chain and the support-file chain split) and the **only write** is `curator.undo`. This package maps a route to that seam and draws it; it owns no state of its own.
6
6
 
@@ -10,7 +10,7 @@ Two registrations, one id (`skill-history`), because that is the sidebar's contr
10
10
 
11
11
  ctx.slots.inject('sidebar.panellist', function* () {
12
12
  yield ctx.slots.register({ name: 'sidebar.panellist', id: PANEL_ID, order: 35, label: () => t('entry.label') }, PanelIcon)
13
- yield ctx.slots.register({ name: 'main', key: PANEL_ID, inject: () => ({ t, loadSkills, loadVersions, undo }) }, SkillHistoryPanel)
13
+ yield ctx.slots.register({ name: 'main', key: PANEL_ID, inject: () => ({ t, format, loadSkills, loadVersions, loadDiff, undo }) }, SkillHistoryPanel)
14
14
  })
15
15
 
16
16
  The label is a **thunk**: the sidebar re-reads it on every projection, so a language switch follows without re-registering the row. The body receives plain callbacks through its inject face — no service handle, no subscription, and the component never sees the context.
@@ -22,6 +22,7 @@ The label is a **thunk**: the sidebar re-reads it on every projection, so a lang
22
22
  | GET | `/api/dsh-evolution/skill-history/skills` | the skills that recorded at least one version, with their counts |
23
23
  | GET | `/api/dsh-evolution/skill-history/versions?name=` | the body chain and the support-file chain, split, each body row carrying its own `undoable` verdict |
24
24
  | POST | `/api/dsh-evolution/skill-history/undo` | `curator.undo(name, v?)` — the only write |
25
+ | GET | `/api/dsh-evolution/skill-history/versions/diff?name=&v=` | one body version against its recorded predecessor, as line counts plus the windowed changed region |
25
26
  | GET | `/api/dsh-evolution/skill-history/health` | liveness probe for the client |
26
27
 
27
28
  Every route sits behind the platform's own /api fence, kept local because the canonical implementation is not part of that package's published surface: the **socket** must be loopback (authoritative; `X-Forwarded-For` is never trusted), the **Host** header must name a loopback authority, an explicit `sec-fetch-site: cross-site` marker is refused, and an attached **Origin** must be exactly this authority.
@@ -32,7 +33,13 @@ The row is **inert without a web server** (`ctx.get('webServer')`, not a declare
32
33
 
33
34
  ## What the panel shows
34
35
 
35
- The selected skill's versions as two groups: **Body versions** (restorable) and **File versions** (history only, because a restore rewrites `SKILL.md`). A body row offers **Restore this version** unless it already IS the live content (the host marks those), and the button turns into an inline confirmation first. After a restore the rows are read again — the restore itself is a new version — while the curator's result sentence stays on screen. **Refresh** re-reads the skill list and the open skill; before it, seeing what another writer recorded in the meantime meant opening the panel again for the list, or clicking the open skill again for its rows (the aside counts are read with the list, not per row).
36
+ **The left column** lists the skills that recorded versions, one row per skill: its name, how many versions it has, its own one-line description (the listing already carried it) and the management state the marker probe reported. A **search box** filters that list by name or description — client state only, no route behind it.
37
+
38
+ **The right pane** shows the selected skill's versions as two groups: **Body versions** (restorable) and **File versions** (history only, because a restore rewrites `SKILL.md`). A body row reads like a sentence rather than a dump — `v3 targeted edit 2026-09-27 18:20 · 3 hours ago (+1363 chars)` — with a *current* capsule on the live content, **Restore this version** (an inline two-click confirmation) on the others, and **Diff** expanding that version against its predecessor (lines added and removed, plus the changed region, marked truncated when it is long). After a restore the rows are read again — the restore itself is a new version — while the curator's result sentence stays on screen. **Refresh** re-reads the skill list and the open skill.
39
+
40
+ **The panel owns no rule and no arithmetic.** The host reports every row with its whole verdict — `undoable` (evolution-core's artifact classification against the live bytes), `actionKind` (a closed vocabulary key), `age` (a bucket plus a count), `charsDelta`, `summary` — and this half substitutes words from its locale dictionary and renders. The time, the action words and the sentences are therefore localized; the facts are computed once, host-side, so the panel and the slash commands cannot drift.
41
+
42
+ **Appearance follows the platform's tokens, not its modules.** The panel carries its own stylesheet (`src/client/styles.ts`, injected once behind `data-plugin-css`) and copies its metrics from the platform's own components — the command card's `<pre>` for the diff block, the sidebar row's hover and focus ring for the controls — while every size is a `--dsh-content-font-size*` token or a `calc()` over one, so the panel follows the deployment's content size the way the platform's panels do. Importing the platform's control package is NOT possible from here without splitting this package's compiler faces: the specifier is path-mapped to source, and `tsc -b` then refuses the foreign project (host/client face split, `react` devDependencies and a CI graph change would all be needed). The roll call of what the panel deliberately does not do is in `## Known limitations and deferred work`.
36
43
 
37
44
  ## Build
38
45
 
@@ -46,6 +53,8 @@ This package adds **no model-visible content**: no prompt section, no tool schem
46
53
 
47
54
  - **No rendered spec.** The family's specs are Node-level and this package's client half cannot be rendered there, so the panel is covered by `tsc`, the client bundle build, the pure `tests/client-api.spec.ts` and a live pass on the installed artifact. A rendered spec (jsdom plus a driven fixture runtime) is the follow-up that would catch wiring in CI — 0.11.3 is the cost of not having one: the first installed pass restored the content correctly and re-read the rows correctly while the curator's result sentence never appeared, because the reload that follows a restore cleared the note state it had just set. No Node-level spec and no `tsc` can see the order of two state updates; only clicking the panel showed it.
48
55
  - **The list shows skills with recorded versions only.** A skill that never wrote through the library has no history to browse, so it does not appear; the tree-wide listing stays with the skill catalog.
49
- - **No diff view.** The routes return entries, not bodies; a two-version diff would need a second read route and a client-side differ.
56
+ - **No quality and no last-use in the list.** Both are real and both are wanted, but they live in the curator's health view and the usage store; joining them into this listing would make the route a second home for facts it does not own. The row shows what the listing already holds (description, managed/protected markers, version count).
57
+ - **No rendered component spec yet.** The panel's state machine (result sentence, two-click confirmation, the read ticket that discards a late reply, the lazy diff) is pinned by `tsc`, the bundle build and live passes; a jsdom lane is the follow-up, and it needs `react` in this package's devDependencies plus one `pnpm install` in the tree the specs run in.
58
+
50
59
  - **Support-file bytes are history only.** They share the index with the body, so they are listed and labelled, but `undo` refuses them by name — the bytes stay in the blob store for a hand copy.
51
60
  - **No occupancy row.** `.history` growth is documented in the evolution-core README; showing the size in the panel would promise a cleanup path the family has not designed (a whole-library reference count belongs to the curator).
package/lib/client.js CHANGED
@@ -18,6 +18,7 @@ window.__ModuleLoader__.load({
18
18
  const HOST_ROUTES = {
19
19
  skills: "/api/dsh-evolution/skill-history/skills",
20
20
  versions: "/api/dsh-evolution/skill-history/versions",
21
+ diff: "/api/dsh-evolution/skill-history/versions/diff",
21
22
  undo: "/api/dsh-evolution/skill-history/undo"
22
23
  };
23
24
  /** A refusal the host reported, carrying its own sentence (the curator's, not ours). */
@@ -38,6 +39,7 @@ window.__ModuleLoader__.load({
38
39
  return {
39
40
  skills: async () => await request(HOST_ROUTES.skills),
40
41
  versions: async (name) => await request(HOST_ROUTES.versions + "?name=" + encodeURIComponent(name)),
42
+ diff: async (name, v) => await request(HOST_ROUTES.diff + "?name=" + encodeURIComponent(name) + "&v=" + String(v)),
41
43
  undo: async (name, v) => {
42
44
  return (await request(HOST_ROUTES.undo, {
43
45
  method: "POST",
@@ -53,10 +55,11 @@ window.__ModuleLoader__.load({
53
55
  //#endregion
54
56
  //#region src/client/messages.ts
55
57
  /**
56
- * Panel copy, in a zh/en dictionary registered through the platform's locale seat.
58
+ * Panel copy, in a zh/en dictionary registered through the platform’s locale seat.
57
59
  *
58
- * Client copy is locale-owned: the entry label, the group headings, the actions and the status lines
59
- * all resolve here, and the panel carries no literal string of its own.
60
+ * Client copy is locale-owned: the entry label, the group headings, the actions, the version facts
61
+ * (action words, relative time, deltas) and the status lines all resolve here, and the panel carries
62
+ * no literal string of its own. Facts come from the host; only the WORDS live here.
60
63
  * @module @lmzhen/dsh-evolution-skill-history/client
61
64
  */
62
65
  /** The locale namespace this bundle registers. */
@@ -66,6 +69,9 @@ window.__ModuleLoader__.load({
66
69
  "entry.label": "技能历史",
67
70
  title: "技能历史",
68
71
  hint: "每次技能写入留下的内容版本。回退只改 SKILL.md 正文,标记、使用计数与策展状态不变。",
72
+ "hint.current": "内容相同的版本会同时标「当前」。",
73
+ search: "按名字或描述筛选",
74
+ "search.none": "没有匹配的技能。",
69
75
  "group.content": "正文版本",
70
76
  "group.support": "文件版本(不可回退)",
71
77
  "group.support.note": "附带文件的字节与正文共用一份索引;回退不恢复它们。",
@@ -74,18 +80,60 @@ window.__ModuleLoader__.load({
74
80
  loading: "读取中…",
75
81
  refresh: "刷新",
76
82
  undo: "回退到此版",
77
- "undo.confirm": "确认回退到此版?",
78
- "undo.yes": "回退",
83
+ "undo.confirm": "确认回退",
79
84
  "undo.no": "取消",
80
85
  current: "当前",
86
+ "versions.count": "个版本",
87
+ "row.first": "初始版本",
88
+ "row.summary.missing": "(未生成摘要)",
89
+ "row.delta.up": "(+{n} 字符)",
90
+ "row.delta.down": "(-{n} 字符)",
91
+ "diff.show": "差异",
92
+ "diff.hide": "收起差异",
93
+ "diff.loading": "读取差异…",
94
+ "diff.against": "与 v{n} 比较",
95
+ "diff.first": "这是第一版,整篇都是新增",
96
+ "diff.truncated": "差异过长,只显示前一部分",
97
+ "diff.added": "新增 {n} 行",
98
+ "diff.removed": "删除 {n} 行",
99
+ "state.managed": "家族管理",
100
+ "state.foreign": "非家族管理",
101
+ "state.protected": "受保护",
102
+ "state.unknown": "标记不可读",
103
+ "action.baseline": "初始快照",
104
+ "action.create": "新建",
105
+ "action.patch": "定点修改",
106
+ "action.update": "整篇更新",
107
+ "action.restore": "回退",
108
+ "action.delete": "删除(存的是被删的正文)",
109
+ "action.archive": "归档(存的是被归档的正文)",
110
+ "action.consolidate": "合并",
111
+ "action.restructure": "结构调整",
112
+ "action.support-write": "写入附带文件",
113
+ "action.support-remove": "删除附带文件",
114
+ "action.other": "其他写入",
115
+ "time.now": "刚刚",
116
+ "time.minutes": "{n} 分钟前",
117
+ "time.minutes.one": "1 分钟前",
118
+ "time.hours": "{n} 小时前",
119
+ "time.hours.one": "1 小时前",
120
+ "time.days": "{n} 天前",
121
+ "time.days.one": "1 天前",
122
+ "time.months": "{n} 个月前",
123
+ "time.months.one": "1 个月前",
124
+ "time.years": "{n} 年前",
125
+ "time.years.one": "1 年前",
81
126
  error: "读取失败",
82
- versions: "个版本"
127
+ "error.hint": "面板读不到宿主路由:通常是插件版本与宿主不匹配,重启 dsh web 后重试。"
83
128
  };
84
129
  /** English dictionary: the same keys, or the panel falls back to the key itself. */
85
130
  const en = {
86
131
  "entry.label": "Skill history",
87
132
  title: "Skill history",
88
133
  hint: "The content versions every skill write leaves behind. Restoring changes the SKILL.md body only: markers, usage counts and curation state stay as they are.",
134
+ "hint.current": "Versions holding the same content are all marked “current”.",
135
+ search: "Filter by name or description",
136
+ "search.none": "No skill matches.",
89
137
  "group.content": "Body versions",
90
138
  "group.support": "File versions (not restorable)",
91
139
  "group.support.note": "Support-file bytes share this index with the body; restoring the body does not bring them back.",
@@ -94,147 +142,258 @@ window.__ModuleLoader__.load({
94
142
  loading: "Loading…",
95
143
  refresh: "Refresh",
96
144
  undo: "Restore this version",
97
- "undo.confirm": "Restore this version?",
98
- "undo.yes": "Restore",
145
+ "undo.confirm": "Confirm restore",
99
146
  "undo.no": "Cancel",
100
147
  current: "Current",
148
+ "versions.count": "versions",
149
+ "row.first": "First version",
150
+ "row.summary.missing": "(no summary generated)",
151
+ "row.delta.up": "(+{n} chars)",
152
+ "row.delta.down": "(-{n} chars)",
153
+ "diff.show": "Diff",
154
+ "diff.hide": "Hide diff",
155
+ "diff.loading": "Loading the diff…",
156
+ "diff.against": "compared with v{n}",
157
+ "diff.first": "first version: everything is new",
158
+ "diff.truncated": "the change is long; only its beginning is shown",
159
+ "diff.added": "{n} lines added",
160
+ "diff.removed": "{n} lines removed",
161
+ "state.managed": "family-managed",
162
+ "state.foreign": "not family-managed",
163
+ "state.protected": "protected",
164
+ "state.unknown": "marker unreadable",
165
+ "action.baseline": "initial snapshot",
166
+ "action.create": "created",
167
+ "action.patch": "targeted edit",
168
+ "action.update": "whole-body update",
169
+ "action.restore": "restored",
170
+ "action.delete": "deleted (holds the removed body)",
171
+ "action.archive": "archived (holds the archived body)",
172
+ "action.consolidate": "merged",
173
+ "action.restructure": "restructured",
174
+ "action.support-write": "support file written",
175
+ "action.support-remove": "support file removed",
176
+ "action.other": "other write",
177
+ "time.now": "just now",
178
+ "time.minutes": "{n} minutes ago",
179
+ "time.minutes.one": "1 minute ago",
180
+ "time.hours": "{n} hours ago",
181
+ "time.hours.one": "1 hour ago",
182
+ "time.days": "{n} days ago",
183
+ "time.days.one": "1 day ago",
184
+ "time.months": "{n} months ago",
185
+ "time.months.one": "1 month ago",
186
+ "time.years": "{n} years ago",
187
+ "time.years.one": "1 year ago",
101
188
  error: "Could not load",
102
- versions: "versions"
189
+ "error.hint": "The panel could not reach the host routes — usually a plugin/host version mismatch; restart dsh web and retry."
103
190
  };
191
+ /**
192
+ * Fill one TEMPLATE’s `{n}` slots. The seat resolves the key, this fills it, so the panel needs
193
+ * only the seat plus this rule and never learns which language it is rendering.
194
+ * @param template - the copy, with `{slot}` placeholders.
195
+ * @param values - the substitutions, keyed by the slot name inside the braces.
196
+ * @returns the filled copy.
197
+ */
198
+ function fill(template, values) {
199
+ return template.replace(/\{(\w+)\}/g, (match, slot) => {
200
+ const value = values[slot];
201
+ return value === void 0 ? match : String(value);
202
+ });
203
+ }
204
+ //#endregion
205
+ //#region src/client/styles.ts
206
+ /**
207
+ * The panel’s stylesheet, injected once behind a `<style data-plugin-css=…>` tag.
208
+ *
209
+ * The bundle is built outside the platform’s CSS-Modules pipeline, so it carries its own string and
210
+ * injects it the same way the family’s settings section does. Every rule reads the platform’s design
211
+ * tokens, and the metrics are COPIED from the platform’s own components rather than invented:
212
+ *
213
+ * - the code block (`--dsw-font-markdown-code-block-small`, `.5px` border, `12px`, radius `12px`,
214
+ * `max-height: 260px`) is the platform command card’s `<pre>` (client/ui-chat/…/GenericCommandCard.module.css);
215
+ * - hover / focus (`--dsw-alias-interactive-bg-hover`, a 2px inset focus ring) follow the platform
216
+ * sidebar row (client/ui-sidebar/…/SidebarRoot.module.css);
217
+ * - the panel’s type scale is the platform TOKEN rather than a magic number, so it follows the
218
+ * deployment’s content size the way the platform’s own panels do;
219
+ * - the capsule and the field metrics follow the family’s settings card, which copied them from the
220
+ * platform’s settings fields in the first place.
221
+ *
222
+ * Geometry (widths, paddings, radii, the 260px scroll cap) is a plain number here because the platform's
223
+ * own components write geometry the same way; what must never be a literal is TYPE and COLOUR, and
224
+ * every size and colour below is a token or a `calc()` over one.
225
+ * @module @lmzhen/dsh-evolution-skill-history/client
226
+ */
227
+ /** Tag id that makes the injection idempotent. */
228
+ const CSS_TAG_ID = "@lmzhen/dsh-evolution-skill-history/panel.css";
229
+ /** The stylesheet the panel injects once. */
230
+ const CSS = [
231
+ ".evo-hist-root{display:flex;height:100%;min-height:0;min-width:0;font-size:var(--dsh-content-font-size-secondary,13px);color:var(--dsw-alias-label-primary)}",
232
+ ".evo-hist-aside{display:flex;flex-direction:column;flex:0 0 264px;width:264px;min-height:0;border-right:.5px solid var(--dsw-alias-border-l2)}",
233
+ ".evo-hist-search{padding:10px 12px 4px}",
234
+ ".evo-hist-search input{width:100%;box-sizing:border-box;padding:6px 10px;border:.5px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font:inherit}",
235
+ ".evo-hist-search input:focus-visible{outline:2px solid var(--dsw-alias-label-primary);outline-offset:-2px}",
236
+ ".evo-hist-list{flex:1 1 auto;min-height:0;overflow-y:auto;padding:4px 6px 10px}",
237
+ ".evo-hist-empty{padding:8px 12px;color:var(--dsw-alias-label-tertiary)}",
238
+ ".evo-hist-skill{display:block;width:100%;box-sizing:border-box;padding:7px 8px;border:0;border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;text-align:left;cursor:pointer}",
239
+ ".evo-hist-skill:hover{background:var(--dsw-alias-interactive-bg-hover)}",
240
+ ".evo-hist-skill:focus-visible{outline:2px solid var(--dsw-alias-label-primary);outline-offset:-2px}",
241
+ ".evo-hist-skill[aria-current=\"true\"]{background:var(--dsw-alias-interactive-bg-active);color:var(--dsw-alias-label-primary);font-weight:500}",
242
+ ".evo-hist-skill-line{display:flex;align-items:baseline;gap:6px}",
243
+ ".evo-hist-skill-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
244
+ ".evo-hist-skill-count{flex:none;margin-left:auto;color:var(--dsw-alias-label-tertiary)}",
245
+ ".evo-hist-skill-desc{margin-top:2px;color:var(--dsw-alias-label-tertiary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
246
+ ".evo-hist-main{flex:1 1 auto;min-width:0;min-height:0;overflow-y:auto;padding:12px 14px}",
247
+ ".evo-hist-head{display:flex;align-items:center;gap:10px;margin:0 0 4px}",
248
+ ".evo-hist-title{flex:1 1 auto;margin:0;font-size:inherit;font-weight:600}",
249
+ ".evo-hist-hint{margin:0 0 10px;color:var(--dsw-alias-label-secondary);line-height:1.5}",
250
+ ".evo-hist-note{margin:0 0 10px;color:var(--dsw-alias-label-secondary);line-height:1.5;white-space:pre-wrap}",
251
+ ".evo-hist-note[data-error=\"true\"]{color:var(--dsw-alias-state-error-primary)}",
252
+ ".evo-hist-group{margin:10px 0 2px;font-size:inherit;font-weight:600}",
253
+ ".evo-hist-row{display:flex;align-items:flex-start;gap:8px;padding:8px 0;border-top:.5px solid var(--dsw-alias-border-l3)}",
254
+ ".evo-hist-row-body{flex:1 1 auto;min-width:0}",
255
+ ".evo-hist-row-title{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}",
256
+ ".evo-hist-row-meta{color:var(--dsw-alias-label-tertiary)}",
257
+ ".evo-hist-row-summary{margin-top:2px;color:var(--dsw-alias-label-secondary);line-height:1.5}",
258
+ ".evo-hist-actions{display:flex;flex:none;align-items:center;gap:6px}",
259
+ ".evo-hist-capsule{flex:none;padding:1px 6px;border:.5px solid var(--dsw-alias-border-l2);border-radius:999px;color:var(--dsw-alias-label-secondary)}",
260
+ ".evo-hist-button{flex:none;font:inherit;padding:3px 10px;border:.5px solid var(--dsw-alias-border-l2);border-radius:8px;background:transparent;color:inherit;cursor:pointer}",
261
+ ".evo-hist-button:hover{background:var(--dsw-alias-interactive-bg-hover)}",
262
+ ".evo-hist-button:focus-visible{outline:2px solid var(--dsw-alias-label-primary);outline-offset:-2px}",
263
+ ".evo-hist-button[data-tone=\"primary\"]{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary)}",
264
+ ".evo-hist-pre{margin:6px 0 2px;padding:12px 16px;max-height:260px;overflow:auto;border:.5px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-alias-markdown-code-block);color:var(--dsw-alias-label-primary);font:var(--dsw-font-markdown-code-block-small);white-space:pre-wrap}",
265
+ ".evo-hist-pre-add{color:var(--dsw-alias-brand-primary)}",
266
+ ".evo-hist-pre-del{color:var(--dsw-alias-state-error-primary)}"
267
+ ].join("\n");
104
268
  //#endregion
105
269
  //#region src/client/Panel.ts
106
270
  /**
107
271
  * The skill-history panel: the skills that recorded versions, the two chains of the selected skill,
108
272
  * and one action per body version (restore, behind an inline confirmation).
109
273
  *
110
- * The panel owns no rule: the host reports each body row with a verdict (`undoable`) computed from
111
- * the version facts in evolution-core, and the curator owns the words a refusal uses. This file is
112
- * presentation plus local state — it never subscribes to anything and it never sees the context.
274
+ * The panel owns no rule and no arithmetic: the host reports each row with its whole verdict
275
+ * (`undoable`, the vocabulary key `actionKind`, the `age` bucket, `charsDelta`, `summary`) and this
276
+ * file substitutes WORDS through the locale dictionary and renders. It never subscribes to anything
277
+ * and never sees the context.
113
278
  *
114
- * Two state rules, both pinned by a live pass rather than by a test (a browser half has no unit test
115
- * seat in this family): a restore's result sentence stays on screen while the rows are read again, and
116
- * nothing is read on a poll — the skill list is read on mount and by the refresh button, the rows when
117
- * a skill opens, when the refresh button is pressed and after a restore. The width cap keeps a
118
- * version's action within reach of the version it acts on.
279
+ * State rules, each pinned by a live pass or a component spec: a restore's result sentence stays on
280
+ * screen while the rows are read again; nothing is read on a poll (mount, opening a skill, the
281
+ * refresh button, after a restore); a late reply for a skill the operator has already left is
282
+ * discarded by its ticket; and the diff is fetched only when a row is expanded.
119
283
  * @module @lmzhen/dsh-evolution-skill-history/client
120
284
  */
121
- const root = {
122
- display: "flex",
123
- height: "100%",
124
- minHeight: "0",
125
- maxWidth: "960px",
126
- fontSize: "13px",
127
- color: "var(--dsw-alias-label-primary)"
128
- };
129
- const aside = {
130
- width: "200px",
131
- flex: "0 0 200px",
132
- borderRight: "1px solid var(--dsw-alias-border-l2)",
133
- overflowY: "auto",
134
- padding: "8px 0"
135
- };
136
- const main = {
137
- flex: "1 1 auto",
138
- minWidth: "0",
139
- overflowY: "auto",
140
- padding: "12px 14px"
141
- };
142
- const header = {
143
- display: "flex",
144
- alignItems: "center",
145
- gap: "10px",
146
- margin: "0 0 4px"
147
- };
148
- const skillButton = {
149
- display: "block",
150
- width: "100%",
151
- textAlign: "left",
152
- padding: "6px 12px",
153
- border: "none",
154
- background: "transparent",
155
- color: "inherit",
156
- cursor: "pointer",
157
- fontSize: "13px"
158
- };
159
- const skillButtonActive = {
160
- ...skillButton,
161
- background: "var(--dsw-alias-bg-l2)",
162
- fontWeight: 600
163
- };
164
- const heading = {
165
- margin: "0",
166
- fontSize: "13px",
167
- fontWeight: 600,
168
- flex: "1 1 auto"
169
- };
170
- const hint = {
171
- margin: "0 0 10px",
172
- fontSize: "12px",
173
- color: "var(--dsw-alias-label-secondary)",
174
- lineHeight: "1.5"
175
- };
176
- const row = {
177
- display: "flex",
178
- alignItems: "center",
179
- gap: "8px",
180
- padding: "4px 0",
181
- borderTop: "1px solid var(--dsw-alias-border-l3)"
182
- };
183
- const rowMeta = {
184
- flex: "1 1 auto",
185
- minWidth: "0",
186
- fontFamily: "var(--dsw-font-mono, monospace)",
187
- fontSize: "12px",
188
- whiteSpace: "nowrap",
189
- overflow: "hidden",
190
- textOverflow: "ellipsis"
191
- };
192
- const chip = {
193
- flex: "0 0 auto",
194
- fontSize: "11px",
195
- padding: "1px 6px",
196
- borderRadius: "999px",
197
- border: "1px solid var(--dsw-alias-border-l2)",
198
- color: "var(--dsw-alias-label-secondary)"
199
- };
200
- const action = {
201
- flex: "0 0 auto",
202
- fontSize: "12px",
203
- padding: "2px 8px",
204
- borderRadius: "6px",
205
- border: "1px solid var(--dsw-alias-border-l2)",
206
- background: "transparent",
207
- color: "inherit",
208
- cursor: "pointer"
209
- };
210
- const note = {
211
- margin: "0 0 10px",
212
- fontSize: "12px",
213
- color: "var(--dsw-alias-label-secondary)",
214
- whiteSpace: "pre-wrap"
215
- };
216
- /** One version row: the facts, the current/plain chip, and the restore action when the row allows it. */
217
- function versionEntry(face, entry) {
218
- const confirming = face.confirming === entry.v;
219
- return (0, react.createElement)("div", {
220
- key: String(entry.v),
221
- style: row
222
- }, (0, react.createElement)("span", { style: rowMeta }, "v" + String(entry.v) + " " + entry.at + " " + entry.action + " " + String(entry.chars) + " chars " + entry.hash.slice(0, 12)), entry.undoable === false ? (0, react.createElement)("span", { style: chip }, face.t("current")) : null, entry.undoable === true ? (0, react.createElement)("button", {
285
+ /** Cut a description to one line's worth of characters (the CSS ellipsises the rest). */
286
+ function oneLine(text) {
287
+ const firstLine = text.split("\n")[0] ?? "";
288
+ return firstLine.length > 120 ? firstLine.slice(0, 119) + "…" : firstLine;
289
+ }
290
+ /** The local time an ISO timestamp names, as the operator's own clock shows it. */
291
+ function localTime(at) {
292
+ const parsed = Date.parse(at);
293
+ if (Number.isNaN(parsed)) return at;
294
+ const date = new Date(parsed);
295
+ const pad = (value) => String(value).padStart(2, "0");
296
+ return String(date.getFullYear()) + "-" + pad(date.getMonth() + 1) + "-" + pad(date.getDate()) + " " + pad(date.getHours()) + ":" + pad(date.getMinutes());
297
+ }
298
+ /** The state word one skill row carries, from the facts the listing reported. */
299
+ function stateKey(skill) {
300
+ if (skill.protectionUnknown) return "state.unknown";
301
+ if (skill.protectedBy !== null) return "state.protected";
302
+ return skill.managed ? "state.managed" : "state.foreign";
303
+ }
304
+ /** The relative-time sentence for one row's bucket. */
305
+ function ageText(face, age) {
306
+ if (age.unit === "now") return face.t("time.now");
307
+ return age.n === 1 ? face.t("time." + age.unit + ".one") : face.format("time." + age.unit, { n: age.n });
308
+ }
309
+ /** The character delta of one row, or null when it is the first version. */
310
+ function deltaText(face, row) {
311
+ if (row.charsDelta === void 0 || row.charsDelta === 0) return null;
312
+ return row.charsDelta > 0 ? face.format("row.delta.up", { n: row.charsDelta }) : face.format("row.delta.down", { n: -row.charsDelta });
313
+ }
314
+ /** One `<pre>` rendering of a diff: added lines marked `+`, removed ones `-`. */
315
+ function diffBlock(diff) {
316
+ const rows = [];
317
+ for (const hunk of diff.hunks) {
318
+ for (const [index, line] of hunk.oldText.split("\n").entries()) {
319
+ if (hunk.oldText === "" && line === "") continue;
320
+ rows.push((0, react.createElement)("div", {
321
+ key: "old-" + String(index),
322
+ className: "evo-hist-pre-del"
323
+ }, "- " + line));
324
+ }
325
+ for (const [index, line] of hunk.newText.split("\n").entries()) {
326
+ if (hunk.newText === "" && line === "") continue;
327
+ rows.push((0, react.createElement)("div", {
328
+ key: "new-" + String(index),
329
+ className: "evo-hist-pre-add"
330
+ }, "+ " + line));
331
+ }
332
+ }
333
+ return (0, react.createElement)("pre", { className: "evo-hist-pre" }, rows.length === 0 ? "±" : rows);
334
+ }
335
+ /** One version row: what happened, when, how big, and what it is against. */
336
+ function versionRow(face, row, confirming, openDiff, diff, restore, toggleDiff) {
337
+ const parts = [
338
+ (0, react.createElement)("span", {
339
+ key: "v",
340
+ className: "evo-hist-row-meta"
341
+ }, "v" + String(row.v)),
342
+ (0, react.createElement)("span", { key: "action" }, face.t("action." + row.actionKind)),
343
+ (0, react.createElement)("span", {
344
+ key: "time",
345
+ className: "evo-hist-row-meta"
346
+ }, localTime(row.at) + " · " + ageText(face, row.age))
347
+ ];
348
+ const delta = deltaText(face, row);
349
+ if (delta !== null) parts.push((0, react.createElement)("span", {
350
+ key: "delta",
351
+ className: "evo-hist-row-meta"
352
+ }, delta));
353
+ const actions = [];
354
+ if (row.undoable === false) actions.push((0, react.createElement)("span", {
355
+ key: "current",
356
+ className: "evo-hist-capsule"
357
+ }, face.t("current")));
358
+ if (row.undoable === true) actions.push((0, react.createElement)("button", {
359
+ key: "undo",
360
+ type: "button",
361
+ className: "evo-hist-button",
362
+ "data-tone": confirming === row.v ? "primary" : void 0,
363
+ onClick: () => {
364
+ restore(row.v);
365
+ }
366
+ }, confirming === row.v ? face.t("undo.confirm") : face.t("undo")));
367
+ if (row.undoable === true || row.charsDelta !== void 0) actions.push((0, react.createElement)("button", {
368
+ key: "diff",
223
369
  type: "button",
224
- style: action,
370
+ className: "evo-hist-button",
225
371
  onClick: () => {
226
- face.restore(entry.v);
372
+ toggleDiff(row.v);
227
373
  }
228
- }, confirming ? face.t("undo.confirm") : face.t("undo")) : null);
374
+ }, openDiff === row.v ? face.t("diff.hide") : face.t("diff.show")));
375
+ return (0, react.createElement)("div", {
376
+ key: String(row.v),
377
+ className: "evo-hist-row"
378
+ }, (0, react.createElement)("div", { className: "evo-hist-row-body" }, (0, react.createElement)("div", { className: "evo-hist-row-title" }, parts), row.summary === void 0 || row.summary === "" ? null : (0, react.createElement)("div", { className: "evo-hist-row-summary" }, row.summary), openDiff === row.v && diff !== void 0 ? diffBody(face, diff) : null), (0, react.createElement)("div", { className: "evo-hist-actions" }, actions));
379
+ }
380
+ /** The expanded body of one row: the diff, a loading line, or the refusal. */
381
+ function diffBody(face, diff) {
382
+ if (diff.kind === "loading") return (0, react.createElement)("p", { className: "evo-hist-note" }, face.t("diff.loading"));
383
+ if (diff.kind === "failed") return (0, react.createElement)("p", {
384
+ className: "evo-hist-note",
385
+ "data-error": "true"
386
+ }, diff.message);
387
+ return (0, react.createElement)("div", null, (0, react.createElement)("p", { className: "evo-hist-note" }, (diff.diff.against === null ? face.t("diff.first") : face.format("diff.against", { n: diff.diff.against })) + " · " + face.format("diff.added", { n: diff.diff.linesAdded }) + " · " + face.format("diff.removed", { n: diff.diff.linesRemoved }) + (diff.diff.truncated ? " · " + face.t("diff.truncated") : "")), diffBlock(diff.diff));
229
388
  }
230
389
  /** One group: a heading, an optional note, and its rows. */
231
- function group(face, title, entries, noteText) {
390
+ function group(face, title, entries, noteText, state) {
232
391
  if (entries.length === 0) return null;
233
- return (0, react.createElement)("section", { key: title }, (0, react.createElement)("h3", { style: heading }, title), noteText === void 0 ? null : (0, react.createElement)("p", { style: hint }, noteText), ...entries.map((entry) => versionEntry(face, entry)));
392
+ return (0, react.createElement)("section", { key: title }, (0, react.createElement)("h3", { className: "evo-hist-group" }, title), noteText === void 0 ? null : (0, react.createElement)("p", { className: "evo-hist-hint" }, noteText), ...entries.map((entry) => versionRow(face, entry, state.confirming, state.openDiff, state.diffs.get(entry.v), state.restore, state.toggleDiff)));
234
393
  }
235
394
  /**
236
395
  * The panel body.
237
- * @param face - copy, the data callbacks and the in-flight confirmation state.
396
+ * @param face - copy plus the data callbacks.
238
397
  * @returns the panel element.
239
398
  */
240
399
  function SkillHistoryPanel(face) {
@@ -242,11 +401,19 @@ window.__ModuleLoader__.load({
242
401
  const [selected, setSelected] = (0, react.useState)(void 0);
243
402
  const [payload, setPayload] = (0, react.useState)(void 0);
244
403
  const [noteText, setNoteText] = (0, react.useState)(void 0);
404
+ const [noteError, setNoteError] = (0, react.useState)(false);
245
405
  const [pending, setPending] = (0, react.useState)(void 0);
406
+ const [query, setQuery] = (0, react.useState)("");
407
+ const [openDiff, setOpenDiff] = (0, react.useState)(void 0);
408
+ const [diffs, setDiffs] = (0, react.useState)(/* @__PURE__ */ new Map());
246
409
  const readTicket = (0, react.useRef)(0);
247
- const failureText = (error) => face.t("error") + ": " + (error instanceof Error ? error.message : String(error));
248
410
  const failed = (error) => {
249
- setNoteText(failureText(error));
411
+ setNoteError(true);
412
+ setNoteText(face.t("error.hint") + "\n" + face.t("error") + ": " + (error instanceof Error ? error.message : String(error)));
413
+ };
414
+ const succeeded = (message) => {
415
+ setNoteError(false);
416
+ setNoteText(message);
250
417
  };
251
418
  /** Read the skills again, keeping the sentence: a re-read is not an answer to anything. */
252
419
  const reloadSkills = () => {
@@ -256,7 +423,7 @@ window.__ModuleLoader__.load({
256
423
  * Show one skill's chains, keeping the sentence. A re-read that FAILS reports through the caller's
257
424
  * own handler, so the failure of a read can never erase the write it was reading after.
258
425
  * @param name - the skill to open.
259
- * @param onFailure - what to do with a failed read; the panel shows it as its own sentence.
426
+ * @param onFailure - what to do with a failed read.
260
427
  */
261
428
  const showVersions = (name, onFailure = failed) => {
262
429
  const ticket = readTicket.current + 1;
@@ -264,6 +431,8 @@ window.__ModuleLoader__.load({
264
431
  setSelected(name);
265
432
  setPayload(void 0);
266
433
  setPending(void 0);
434
+ setOpenDiff(void 0);
435
+ setDiffs(/* @__PURE__ */ new Map());
267
436
  face.loadVersions(name).then((next) => {
268
437
  if (readTicket.current === ticket) setPayload(next);
269
438
  }).catch((error) => {
@@ -272,7 +441,10 @@ window.__ModuleLoader__.load({
272
441
  };
273
442
  /** Open a skill from the list: another skill's sentence described another skill, so it goes away. */
274
443
  const open = (name) => {
275
- if (name !== selected) setNoteText(void 0);
444
+ if (name !== selected) {
445
+ setNoteText(void 0);
446
+ setNoteError(false);
447
+ }
276
448
  showVersions(name);
277
449
  };
278
450
  /** What the refresh button does: both reads again, and the sentence stays. */
@@ -290,33 +462,74 @@ window.__ModuleLoader__.load({
290
462
  const name = selected;
291
463
  face.undo(name, v).then((message) => {
292
464
  showVersions(name, (error) => {
293
- setNoteText(message + "\n" + failureText(error));
465
+ setNoteError(true);
466
+ setNoteText(message + "\n" + face.t("error") + ": " + (error instanceof Error ? error.message : String(error)));
294
467
  });
295
- setNoteText(message);
468
+ succeeded(message);
296
469
  }).catch(failed);
297
470
  };
471
+ const toggleDiff = (v) => {
472
+ if (selected === void 0) return;
473
+ if (openDiff === v) {
474
+ setOpenDiff(void 0);
475
+ return;
476
+ }
477
+ setOpenDiff(v);
478
+ if (diffs.has(v)) return;
479
+ const name = selected;
480
+ const ticket = readTicket.current + 1;
481
+ readTicket.current = ticket;
482
+ setDiffs((current) => new Map(current).set(v, { kind: "loading" }));
483
+ face.loadDiff(name, v).then((diff) => {
484
+ if (readTicket.current !== ticket) return;
485
+ setDiffs((current) => new Map(current).set(v, {
486
+ kind: "ready",
487
+ diff
488
+ }));
489
+ }).catch((error) => {
490
+ if (readTicket.current !== ticket) return;
491
+ const message = face.t("error") + ": " + (error instanceof Error ? error.message : String(error));
492
+ setDiffs((current) => new Map(current).set(v, {
493
+ kind: "failed",
494
+ message
495
+ }));
496
+ });
497
+ };
298
498
  (0, react.useEffect)(reloadSkills, []);
499
+ const needle = query.trim().toLowerCase();
500
+ const visible = (0, react.useMemo)(() => needle === "" ? skills : skills.filter((skill) => skill.name.toLowerCase().includes(needle) || skill.description.toLowerCase().includes(needle)), [skills, needle]);
299
501
  const loaded = payload !== void 0;
300
- return (0, react.createElement)("div", { style: root }, (0, react.createElement)("aside", { style: aside }, skills.length === 0 ? (0, react.createElement)("p", { style: hint }, face.t("empty.skills")) : skills.map((skill) => (0, react.createElement)("button", {
502
+ const rowState = {
503
+ confirming: pending,
504
+ openDiff,
505
+ diffs,
506
+ restore,
507
+ toggleDiff
508
+ };
509
+ return (0, react.createElement)("div", { className: "evo-hist-root" }, (0, react.createElement)("aside", { className: "evo-hist-aside" }, (0, react.createElement)("div", { className: "evo-hist-search" }, (0, react.createElement)("input", {
510
+ type: "search",
511
+ value: query,
512
+ placeholder: face.t("search"),
513
+ "aria-label": face.t("search"),
514
+ onChange: (event) => {
515
+ setQuery(event.target.value);
516
+ }
517
+ })), (0, react.createElement)("div", { className: "evo-hist-list" }, skills.length === 0 ? (0, react.createElement)("p", { className: "evo-hist-empty" }, face.t("empty.skills")) : visible.length === 0 ? (0, react.createElement)("p", { className: "evo-hist-empty" }, face.t("search.none")) : visible.map((skill) => (0, react.createElement)("button", {
301
518
  key: skill.name,
302
519
  type: "button",
303
- style: skill.name === selected ? skillButtonActive : skillButton,
520
+ className: "evo-hist-skill",
521
+ "aria-current": skill.name === selected ? "true" : void 0,
304
522
  onClick: () => {
305
523
  open(skill.name);
306
524
  }
307
- }, skill.name + " (" + String(skill.versions) + ")"))), (0, react.createElement)("div", { style: main }, (0, react.createElement)("div", { style: header }, (0, react.createElement)("h2", { style: heading }, face.t("title")), (0, react.createElement)("button", {
525
+ }, (0, react.createElement)("span", { className: "evo-hist-skill-line" }, (0, react.createElement)("span", { className: "evo-hist-skill-name" }, skill.name), (0, react.createElement)("span", { className: "evo-hist-skill-count" }, String(skill.versions) + " " + face.t("versions.count"))), (0, react.createElement)("span", { className: "evo-hist-skill-desc" }, oneLine(skill.description) + " · " + face.t(stateKey(skill))))))), (0, react.createElement)("div", { className: "evo-hist-main" }, (0, react.createElement)("div", { className: "evo-hist-head" }, (0, react.createElement)("h2", { className: "evo-hist-title" }, face.t("title")), (0, react.createElement)("button", {
308
526
  type: "button",
309
- style: action,
527
+ className: "evo-hist-button",
310
528
  onClick: refresh
311
- }, face.t("refresh"))), (0, react.createElement)("p", { style: hint }, face.t("hint")), noteText === void 0 ? null : (0, react.createElement)("p", { style: note }, noteText), selected === void 0 ? null : loaded ? null : (0, react.createElement)("p", { style: hint }, face.t("loading")), loaded ? (0, react.createElement)("div", null, group({
312
- ...face,
313
- restore,
314
- confirming: pending
315
- }, face.t("group.content"), payload.content), group({
316
- ...face,
317
- restore,
318
- confirming: pending
319
- }, face.t("group.support"), payload.support, face.t("group.support.note")), payload.content.length === 0 && payload.support.length === 0 ? (0, react.createElement)("p", { style: hint }, face.t("empty.versions")) : null) : null));
529
+ }, face.t("refresh"))), (0, react.createElement)("p", { className: "evo-hist-hint" }, face.t("hint") + " " + face.t("hint.current")), noteText === void 0 ? null : (0, react.createElement)("p", {
530
+ className: "evo-hist-note",
531
+ "data-error": noteError ? "true" : void 0
532
+ }, noteText), selected === void 0 ? null : loaded ? null : (0, react.createElement)("p", { className: "evo-hist-hint" }, face.t("loading")), loaded ? (0, react.createElement)("div", null, group(face, face.t("group.content"), payload.content, void 0, rowState), group(face, face.t("group.support"), payload.support, face.t("group.support.note"), rowState), payload.content.length === 0 && payload.support.length === 0 ? (0, react.createElement)("p", { className: "evo-hist-hint" }, face.t("empty.versions")) : null) : null));
320
533
  }
321
534
  //#endregion
322
535
  //#region src/client/seam.ts
@@ -337,9 +550,30 @@ window.__ModuleLoader__.load({
337
550
  const inject = ["slots", "locale"];
338
551
  /** Row order inside the global panel list (beside the platform's own rows). */
339
552
  const PANEL_ORDER = 35;
340
- /** The row's icon: the label carries the accessible name, so the glyph is decoration only. */
553
+ /**
554
+ * The row's icon: a platform-style line glyph rather than an emoji.
555
+ *
556
+ * An emoji cannot follow `currentColor`, cannot match the 16/18px the sidebar hands its glyph slot,
557
+ * and renders differently on every platform font — the sidebar's other rows are 1.5px-stroke line
558
+ * icons, and this one now is too. The label carries the accessible name, so the glyph stays
559
+ * decoration.
560
+ */
341
561
  function PanelIcon() {
342
- return (0, react.createElement)("span", { "aria-hidden": "true" }, "🕘");
562
+ return (0, react.createElement)("svg", {
563
+ "aria-hidden": "true",
564
+ width: "100%",
565
+ height: "100%",
566
+ viewBox: "0 0 16 16",
567
+ fill: "none",
568
+ stroke: "currentColor",
569
+ strokeWidth: "1.5",
570
+ strokeLinecap: "round",
571
+ strokeLinejoin: "round"
572
+ }, (0, react.createElement)("circle", {
573
+ cx: "8",
574
+ cy: "8",
575
+ r: "5.75"
576
+ }), (0, react.createElement)("path", { d: "M8 4.75V8l2.25 1.5" }));
343
577
  }
344
578
  /**
345
579
  * Register the locale namespace, the panel row and the panel body.
@@ -355,6 +589,18 @@ window.__ModuleLoader__.load({
355
589
  });
356
590
  }, "evolution-skill-history: locale");
357
591
  const t = seam.locale.bind(NS);
592
+ ctx.effect(() => {
593
+ if (document.querySelector("style[data-plugin-css=\"@lmzhen/dsh-evolution-skill-history/panel.css\"]") === null) {
594
+ const tag = document.createElement("style");
595
+ tag.setAttribute("data-plugin-css", CSS_TAG_ID);
596
+ tag.textContent = CSS;
597
+ document.head.append(tag);
598
+ return () => {
599
+ tag.remove();
600
+ };
601
+ }
602
+ return () => {};
603
+ }, "evolution-skill-history: styles");
358
604
  seam.slots.inject("sidebar.panellist", function* () {
359
605
  yield seam.slots.register({
360
606
  name: "sidebar.panellist",
@@ -367,8 +613,10 @@ window.__ModuleLoader__.load({
367
613
  key: PANEL_ID,
368
614
  inject: () => ({
369
615
  t,
616
+ format: (key, values) => fill(t(key), values),
370
617
  loadSkills: api.skills,
371
618
  loadVersions: api.versions,
619
+ loadDiff: api.diff,
372
620
  undo: api.undo
373
621
  })
374
622
  }, SkillHistoryPanel);
package/lib/index.js CHANGED
@@ -1,7 +1,7 @@
1
- import { contentHash, errorText, partitionVersions } from "@lmzhen/dsh-evolution-core";
1
+ import { contentHash, elapsedSince, errorText, partitionVersions, textDiffFacts, versionActionKind } from "@lmzhen/dsh-evolution-core";
2
2
  //#region lib/types/routes.js
3
3
  /**
4
- * The skill-history HTTP surface: four loopback routes over the curator's read/write seam.
4
+ * The skill-history HTTP surface: five loopback routes over the curator's read/write seam (four reads plus the one write).
5
5
  *
6
6
  * Layering: this module maps a route to a USE CASE and does nothing else. "Which artifact does this
7
7
  * version hold" and "how do the two chains split" live in evolution-core; the only write is
@@ -12,6 +12,7 @@ import { contentHash, errorText, partitionVersions } from "@lmzhen/dsh-evolution
12
12
  const SKILL_HISTORY_ROUTES = {
13
13
  skills: "/api/dsh-evolution/skill-history/skills",
14
14
  versions: "/api/dsh-evolution/skill-history/versions",
15
+ diff: "/api/dsh-evolution/skill-history/versions/diff",
15
16
  undo: "/api/dsh-evolution/skill-history/undo",
16
17
  health: "/api/dsh-evolution/skill-history/health"
17
18
  };
@@ -140,9 +141,14 @@ function makeSkillHistoryRoutes(services) {
140
141
  const withHistory = [];
141
142
  for (const skill of listed) {
142
143
  const versions = await curator.skills.listVersions(skill.name);
143
- if (versions.length > 0) withHistory.push({
144
+ if (versions.length === 0) continue;
145
+ withHistory.push({
144
146
  name: skill.name,
145
- versions: versions.length
147
+ versions: versions.length,
148
+ description: skill.description,
149
+ managed: skill.managed,
150
+ protectedBy: skill.protectedBy,
151
+ protectionUnknown: skill.protectionUnknown
146
152
  });
147
153
  }
148
154
  writeJson(res, 200, {
@@ -170,20 +176,82 @@ function makeSkillHistoryRoutes(services) {
170
176
  const groups = partitionVersions(await curator.history(name));
171
177
  const live = await curator.skills.read(name);
172
178
  const liveHash = live === null ? null : contentHash(live);
179
+ const now = Date.now();
180
+ const decorate = (entry, index, chain, withDelta) => {
181
+ const previous = index === 0 ? void 0 : chain[index - 1];
182
+ return {
183
+ ...entry,
184
+ actionKind: versionActionKind(entry.action),
185
+ age: elapsedSince(entry.at, now),
186
+ ...!withDelta || previous === void 0 ? {} : { charsDelta: entry.chars - previous.chars }
187
+ };
188
+ };
173
189
  writeJson(res, 200, {
174
190
  ok: true,
175
191
  data: {
176
- content: groups.content.map((entry) => ({
177
- ...entry,
192
+ content: groups.content.map((entry, index) => ({
193
+ ...decorate(entry, index, groups.content, true),
178
194
  undoable: entry.hash !== liveHash
179
195
  })),
180
- support: groups.support,
196
+ support: groups.support.map((entry, index) => decorate(entry, index, groups.support, false)),
181
197
  liveHash
182
198
  }
183
199
  });
184
200
  });
185
201
  }
186
202
  },
203
+ {
204
+ kind: "exact",
205
+ path: SKILL_HISTORY_ROUTES.diff,
206
+ handler: async (req, res) => {
207
+ if (!guard(req, res, "GET")) return;
208
+ const params = new URL(req.url ?? "/", "http://loopback").searchParams;
209
+ const name = params.get("name")?.trim() ?? "";
210
+ const rawV = params.get("v")?.trim() ?? "";
211
+ const v = Number.parseInt(rawV, 10);
212
+ if (name === "" || !/^\d+$/.test(rawV) || !Number.isInteger(v) || v < 1) {
213
+ writeJson(res, 400, {
214
+ ok: false,
215
+ code: "bad-request",
216
+ message: "name and a positive integer v are required"
217
+ });
218
+ return;
219
+ }
220
+ await withCurator(res, async (curator) => {
221
+ const groups = partitionVersions(await curator.history(name));
222
+ const index = groups.content.findIndex((entry) => entry.v === v);
223
+ if (index === -1) {
224
+ writeJson(res, 404, {
225
+ ok: false,
226
+ code: "not-found",
227
+ message: "that version is not in the recorded history of \"" + name + "\""
228
+ });
229
+ return;
230
+ }
231
+ const entry = groups.content[index];
232
+ const previous = (entry === void 0 || entry.beforeHash === void 0 ? void 0 : groups.content.find((candidate) => candidate.hash === entry.beforeHash)) ?? (index === 0 ? void 0 : groups.content[index - 1]);
233
+ const after = entry === void 0 ? null : await curator.skills.readVersion(name, v);
234
+ const before = previous === void 0 ? "" : await curator.skills.readVersion(name, previous.v);
235
+ if (after === null || before === null) {
236
+ writeJson(res, 404, {
237
+ ok: false,
238
+ code: "not-found",
239
+ message: "the stored content of that version could not be read"
240
+ });
241
+ return;
242
+ }
243
+ const facts = textDiffFacts(before, after, "SKILL.md");
244
+ writeJson(res, 200, {
245
+ ok: true,
246
+ data: {
247
+ v,
248
+ against: previous?.v ?? null,
249
+ ...facts
250
+ }
251
+ });
252
+ });
253
+ }
254
+ },
187
255
  {
188
256
  kind: "exact",
189
257
  path: SKILL_HISTORY_ROUTES.undo,
@@ -2,29 +2,32 @@
2
2
  * The skill-history panel: the skills that recorded versions, the two chains of the selected skill,
3
3
  * and one action per body version (restore, behind an inline confirmation).
4
4
  *
5
- * The panel owns no rule: the host reports each body row with a verdict (`undoable`) computed from
6
- * the version facts in evolution-core, and the curator owns the words a refusal uses. This file is
7
- * presentation plus local state — it never subscribes to anything and it never sees the context.
5
+ * The panel owns no rule and no arithmetic: the host reports each row with its whole verdict
6
+ * (`undoable`, the vocabulary key `actionKind`, the `age` bucket, `charsDelta`, `summary`) and this
7
+ * file substitutes WORDS through the locale dictionary and renders. It never subscribes to anything
8
+ * and never sees the context.
8
9
  *
9
- * Two state rules, both pinned by a live pass rather than by a test (a browser half has no unit test
10
- * seat in this family): a restore's result sentence stays on screen while the rows are read again, and
11
- * nothing is read on a poll — the skill list is read on mount and by the refresh button, the rows when
12
- * a skill opens, when the refresh button is pressed and after a restore. The width cap keeps a
13
- * version's action within reach of the version it acts on.
10
+ * State rules, each pinned by a live pass or a component spec: a restore's result sentence stays on
11
+ * screen while the rows are read again; nothing is read on a poll (mount, opening a skill, the
12
+ * refresh button, after a restore); a late reply for a skill the operator has already left is
13
+ * discarded by its ticket; and the diff is fetched only when a row is expanded.
14
14
  * @module @lmzhen/dsh-evolution-skill-history/client
15
15
  */
16
16
  import { type ReactNode } from 'react';
17
- import type { SkillRow, VersionsPayload } from './api.ts';
18
- /** The face the plugin hands the component: copy plus three callbacks, never a service handle. */
17
+ import type { SkillRow, VersionDiffRow, VersionsPayload } from './api.ts';
18
+ /** The face the plugin hands the component: copy plus callbacks, never a service handle. */
19
19
  export interface PanelFace {
20
20
  readonly t: (key: string) => string;
21
+ /** Fill one copy key's `{n}` slots (the dictionaries own the sentences). */
22
+ readonly format: (key: string, values: Record<string, string | number>) => string;
21
23
  readonly loadSkills: () => Promise<readonly SkillRow[]>;
22
24
  readonly loadVersions: (name: string) => Promise<VersionsPayload>;
25
+ readonly loadDiff: (name: string, v: number) => Promise<VersionDiffRow>;
23
26
  readonly undo: (name: string, v?: number) => Promise<string>;
24
27
  }
25
28
  /**
26
29
  * The panel body.
27
- * @param face - copy, the data callbacks and the in-flight confirmation state.
30
+ * @param face - copy plus the data callbacks.
28
31
  * @returns the panel element.
29
32
  */
30
33
  export declare function SkillHistoryPanel(face: PanelFace): ReactNode;
@@ -10,8 +10,25 @@
10
10
  export declare const HOST_ROUTES: {
11
11
  readonly skills: "/api/dsh-evolution/skill-history/skills";
12
12
  readonly versions: "/api/dsh-evolution/skill-history/versions";
13
+ readonly diff: "/api/dsh-evolution/skill-history/versions/diff";
13
14
  readonly undo: "/api/dsh-evolution/skill-history/undo";
14
15
  };
16
+ /** One changed region, as the host reports it (the platform diff card's own shape). */
17
+ export interface DiffHunkRow {
18
+ readonly path: string;
19
+ readonly oldText: string;
20
+ readonly newText: string;
21
+ }
22
+ /** One version's diff, as the host computes it. */
23
+ export interface VersionDiffRow {
24
+ readonly v: number;
25
+ /** The version this one replaced, or null for the first recorded version. */
26
+ readonly against: number | null;
27
+ readonly linesAdded: number;
28
+ readonly linesRemoved: number;
29
+ readonly hunks: readonly DiffHunkRow[];
30
+ readonly truncated: boolean;
31
+ }
15
32
  /** One recorded version, as the host reports it. */
16
33
  export interface VersionRow {
17
34
  readonly v: number;
@@ -21,11 +38,26 @@ export interface VersionRow {
21
38
  readonly chars: number;
22
39
  /** Present on body rows: false when this version already IS the live content. */
23
40
  readonly undoable?: boolean;
41
+ /** Characters this version added to (or removed from) the one before it; absent on the first. */
42
+ readonly charsDelta?: number;
43
+ /** Which kind of write produced it (the vocabulary key the panel turns into words). */
44
+ readonly actionKind: string;
45
+ /** How long ago the host recorded it, as a bucket plus a count — never a sentence. */
46
+ readonly age: {
47
+ readonly unit: string;
48
+ readonly n: number;
49
+ };
50
+ /** The one line the optional summarizer wrote, when the deployment turned it on. */
51
+ readonly summary?: string;
24
52
  }
25
- /** One skill that has recorded versions. */
53
+ /** One skill that has recorded versions, with what the listing already knows about it. */
26
54
  export interface SkillRow {
27
55
  readonly name: string;
28
56
  readonly versions: number;
57
+ readonly description: string;
58
+ readonly managed: boolean;
59
+ readonly protectedBy: string | null;
60
+ readonly protectionUnknown: boolean;
29
61
  }
30
62
  /** The two chains, plus the live body hash the panel marks as "current". */
31
63
  export interface VersionsPayload {
@@ -38,6 +70,7 @@ export interface SkillHistoryApi {
38
70
  readonly skills: () => Promise<readonly SkillRow[]>;
39
71
  readonly versions: (name: string) => Promise<VersionsPayload>;
40
72
  readonly undo: (name: string, v?: number) => Promise<string>;
73
+ readonly diff: (name: string, v: number) => Promise<VersionDiffRow>;
41
74
  }
42
75
  /** A refusal the host reported, carrying its own sentence (the curator's, not ours). */
43
76
  export declare class SkillHistoryRefusal extends Error {
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Panel copy, in a zh/en dictionary registered through the platform's locale seat.
2
+ * Panel copy, in a zh/en dictionary registered through the platform’s locale seat.
3
3
  *
4
- * Client copy is locale-owned: the entry label, the group headings, the actions and the status lines
5
- * all resolve here, and the panel carries no literal string of its own.
4
+ * Client copy is locale-owned: the entry label, the group headings, the actions, the version facts
5
+ * (action words, relative time, deltas) and the status lines all resolve here, and the panel carries
6
+ * no literal string of its own. Facts come from the host; only the WORDS live here.
6
7
  * @module @lmzhen/dsh-evolution-skill-history/client
7
8
  */
8
9
  /** The locale namespace this bundle registers. */
@@ -18,4 +19,12 @@ export declare const en: Record<string, string>;
18
19
  * @returns the copy, or the key itself when neither dictionary carries it.
19
20
  */
20
21
  export declare function message(language: 'zh' | 'en', key: string): string;
22
+ /**
23
+ * Fill one TEMPLATE’s `{n}` slots. The seat resolves the key, this fills it, so the panel needs
24
+ * only the seat plus this rule and never learns which language it is rendering.
25
+ * @param template - the copy, with `{slot}` placeholders.
26
+ * @param values - the substitutions, keyed by the slot name inside the braces.
27
+ * @returns the filled copy.
28
+ */
29
+ export declare function fill(template: string, values: Record<string, string | number>): string;
21
30
  //# sourceMappingURL=messages.d.ts.map
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The panel’s stylesheet, injected once behind a `<style data-plugin-css=…>` tag.
3
+ *
4
+ * The bundle is built outside the platform’s CSS-Modules pipeline, so it carries its own string and
5
+ * injects it the same way the family’s settings section does. Every rule reads the platform’s design
6
+ * tokens, and the metrics are COPIED from the platform’s own components rather than invented:
7
+ *
8
+ * - the code block (`--dsw-font-markdown-code-block-small`, `.5px` border, `12px`, radius `12px`,
9
+ * `max-height: 260px`) is the platform command card’s `<pre>` (client/ui-chat/…/GenericCommandCard.module.css);
10
+ * - hover / focus (`--dsw-alias-interactive-bg-hover`, a 2px inset focus ring) follow the platform
11
+ * sidebar row (client/ui-sidebar/…/SidebarRoot.module.css);
12
+ * - the panel’s type scale is the platform TOKEN rather than a magic number, so it follows the
13
+ * deployment’s content size the way the platform’s own panels do;
14
+ * - the capsule and the field metrics follow the family’s settings card, which copied them from the
15
+ * platform’s settings fields in the first place.
16
+ *
17
+ * Geometry (widths, paddings, radii, the 260px scroll cap) is a plain number here because the platform's
18
+ * own components write geometry the same way; what must never be a literal is TYPE and COLOUR, and
19
+ * every size and colour below is a token or a `calc()` over one.
20
+ * @module @lmzhen/dsh-evolution-skill-history/client
21
+ */
22
+ /** Tag id that makes the injection idempotent. */
23
+ export declare const CSS_TAG_ID = "@lmzhen/dsh-evolution-skill-history/panel.css";
24
+ /** The stylesheet the panel injects once. */
25
+ export declare const CSS: string;
26
+ //# sourceMappingURL=styles.d.ts.map
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The skill-history HTTP surface: four loopback routes over the curator's read/write seam.
2
+ * The skill-history HTTP surface: five loopback routes over the curator's read/write seam (four reads plus the one write).
3
3
  *
4
4
  * Layering: this module maps a route to a USE CASE and does nothing else. "Which artifact does this
5
5
  * version hold" and "how do the two chains split" live in evolution-core; the only write is
@@ -13,6 +13,7 @@ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver';
13
13
  export declare const SKILL_HISTORY_ROUTES: {
14
14
  readonly skills: "/api/dsh-evolution/skill-history/skills";
15
15
  readonly versions: "/api/dsh-evolution/skill-history/versions";
16
+ readonly diff: "/api/dsh-evolution/skill-history/versions/diff";
16
17
  readonly undo: "/api/dsh-evolution/skill-history/undo";
17
18
  readonly health: "/api/dsh-evolution/skill-history/health";
18
19
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lmzhen/dsh-evolution-skill-history",
3
3
  "description": "Web surface for skill content history: loopback host routes plus the sidebar panel (community build)",
4
- "version": "0.12.0",
4
+ "version": "0.13.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -30,21 +30,21 @@
30
30
  ],
31
31
  "license": "MIT",
32
32
  "dependencies": {
33
- "@lmzhen/dsh-evolution-core": "^0.12.0"
33
+ "@lmzhen/dsh-evolution-core": "^0.13.0"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "@deepseek-ai/cordis": "^4.0.1",
37
37
  "@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.2",
38
38
  "react": "^18.2.0",
39
- "@lmzhen/dsh-evolution-curator": "^0.12.0"
39
+ "@lmzhen/dsh-evolution-curator": "^0.13.0"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@deepseek-ai/cordis": "^4.0.1",
43
43
  "@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.2",
44
- "@lmzhen/dsh-evolution-core": "^0.12.0",
45
- "@lmzhen/dsh-evolution-curator": "^0.12.0",
46
- "@lmzhen/dsh-evolution-io": "^0.12.0",
47
- "@lmzhen/dsh-evolution-io-node": "^0.12.0"
44
+ "@lmzhen/dsh-evolution-core": "^0.13.0",
45
+ "@lmzhen/dsh-evolution-curator": "^0.13.0",
46
+ "@lmzhen/dsh-evolution-io": "^0.13.0",
47
+ "@lmzhen/dsh-evolution-io-node": "^0.13.0"
48
48
  },
49
49
  "dsh": {
50
50
  "client": {