@antelopejs/dms-ai 0.5.0 → 0.6.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.
Files changed (53) hide show
  1. package/dist/constants/pages.d.ts +2 -0
  2. package/dist/constants/pages.js +3 -1
  3. package/dist/constants/pages.js.map +1 -1
  4. package/dist/frontend-module.d.ts +2 -0
  5. package/dist/frontend-module.js +18 -0
  6. package/dist/frontend-module.js.map +1 -0
  7. package/dist/index.js +2 -12
  8. package/dist/index.js.map +1 -1
  9. package/dist/pages/activity/page.d.ts +1 -0
  10. package/dist/pages/activity/page.js +13 -0
  11. package/dist/pages/activity/page.js.map +1 -1
  12. package/dist/pages/meta.d.ts +2 -0
  13. package/dist/pages/meta.js +3 -1
  14. package/dist/pages/meta.js.map +1 -1
  15. package/dist/pages/overview/insights.js +0 -2
  16. package/dist/pages/overview/insights.js.map +1 -1
  17. package/dist/pages/overview/page.d.ts +1 -1
  18. package/dist/pages/overview/page.js +12 -11
  19. package/dist/pages/overview/page.js.map +1 -1
  20. package/dist/pages/skills/page.js +7 -0
  21. package/dist/pages/skills/page.js.map +1 -1
  22. package/dist/routes/activity.js +5 -2
  23. package/dist/routes/activity.js.map +1 -1
  24. package/dist/routes/list-query.d.ts +1 -0
  25. package/dist/routes/list-query.js +9 -0
  26. package/dist/routes/list-query.js.map +1 -1
  27. package/dist/routes/metric-shapes.js +7 -4
  28. package/dist/routes/metric-shapes.js.map +1 -1
  29. package/dist/routes/metrics.d.ts +2 -2
  30. package/dist/routes/metrics.js +10 -6
  31. package/dist/routes/metrics.js.map +1 -1
  32. package/dist/types/metrics.d.ts +2 -0
  33. package/frontend-vue/app/chat/components/ConversationDrawer.vue +2 -1
  34. package/frontend-vue/app/chat/components/PanelHeader.vue +3 -1
  35. package/frontend-vue/app/chat/utils/markdown.ts +36 -20
  36. package/frontend-vue/app/components/ActivityDetail.vue +2 -5
  37. package/frontend-vue/app/components/ChatPanel.vue +3 -1
  38. package/frontend-vue/app/components/SkillDetail.vue +72 -26
  39. package/frontend-vue/app/views/RowStepper.vue +1 -1
  40. package/frontend-vue/app/views/types.ts +0 -10
  41. package/frontend-vue/i18n/locales/dms-ai-pages-en-GB.json +16 -3
  42. package/frontend-vue/i18n/locales/dms-ai-pages-fr-FR.json +16 -3
  43. package/frontend-vue/i18n/locales/dms-ai-views-en-GB.json +1 -0
  44. package/frontend-vue/i18n/locales/dms-ai-views-fr-FR.json +1 -0
  45. package/frontend-vue/package.json +1 -1
  46. package/frontend-vue/tests/chat/markdown.test.ts +69 -0
  47. package/frontend-vue/tests/i18n-views.test.ts +63 -0
  48. package/frontend-vue/tests/panel-header-height.test.ts +40 -0
  49. package/package.json +4 -3
  50. package/sidecar/dist/metrics/aggregate.js +29 -7
  51. package/sidecar/dist/metrics/aggregate.js.map +1 -1
  52. package/sidecar/dist/metrics/types.d.ts +2 -0
  53. package/skills/dms-ai/SKILL.md +1 -0
@@ -48,19 +48,27 @@ const LINK_REL = "noopener noreferrer";
48
48
  const NEWLINES = /\r\n?/g;
49
49
  const LINE_BREAK = "\n";
50
50
 
51
- const md = new MarkdownIt({
52
- html: false,
53
- linkify: true,
54
- breaks: true,
55
- });
56
-
57
- md.validateLink = (url) => SAFE_URL.test(url.trim());
58
-
59
- md.renderer.rules.link_open = (tokens, idx, options, _env, self) => {
60
- tokens[idx].attrSet("target", LINK_TARGET);
61
- tokens[idx].attrSet("rel", LINK_REL);
62
- return self.renderToken(tokens, idx, options);
63
- };
51
+ /**
52
+ * The parser shared by every renderer: no raw HTML, safe links opening in a
53
+ * new tab, images shown as links. `breaks` alone differs between them.
54
+ */
55
+ function createParser(breaks: boolean): MarkdownIt {
56
+ const parser = new MarkdownIt({ html: false, linkify: true, breaks });
57
+ parser.validateLink = (url) => SAFE_URL.test(url.trim());
58
+ parser.renderer.rules.link_open = (tokens, idx, options, _env, self) => {
59
+ tokens[idx].attrSet("target", LINK_TARGET);
60
+ tokens[idx].attrSet("rel", LINK_REL);
61
+ return self.renderToken(tokens, idx, options);
62
+ };
63
+ parser.renderer.rules.image = (tokens, idx, options, env, self) => {
64
+ const token = tokens[idx];
65
+ const src = token.attrGet("src") ?? "";
66
+ const alt = self.renderInlineAsText(token.children ?? [], options, env);
67
+ const label = parser.utils.escapeHtml(imageLinkLabel(alt, src));
68
+ return `<a href="${parser.utils.escapeHtml(src)}" target="${LINK_TARGET}" rel="${LINK_REL}">${label}</a>`;
69
+ };
70
+ return parser;
71
+ }
64
72
 
65
73
  function imageDestination(src: string): string {
66
74
  try {
@@ -75,13 +83,11 @@ function imageLinkLabel(alt: string, src: string): string {
75
83
  return alt ? `${alt} (${destination})` : destination;
76
84
  }
77
85
 
78
- md.renderer.rules.image = (tokens, idx, options, env, self) => {
79
- const token = tokens[idx];
80
- const src = token.attrGet("src") ?? "";
81
- const alt = self.renderInlineAsText(token.children ?? [], options, env);
82
- const label = md.utils.escapeHtml(imageLinkLabel(alt, src));
83
- return `<a href="${md.utils.escapeHtml(src)}" target="${LINK_TARGET}" rel="${LINK_REL}">${label}</a>`;
84
- };
86
+ // A chat message keeps every newline the model wrote as a line break.
87
+ const md = createParser(true);
88
+ // A document (a SKILL.md) is hard-wrapped at a fixed column: a single newline
89
+ // inside a paragraph is a CommonMark soft break, rendered as a space.
90
+ const documentMd = createParser(false);
85
91
 
86
92
  let purifier: Purifier | null = null;
87
93
 
@@ -109,6 +115,16 @@ export function renderMarkdown(source: string): string {
109
115
  return sanitize(md.render(normalize(source)));
110
116
  }
111
117
 
118
+ /**
119
+ * A Markdown document (a SKILL.md) as sanitized HTML. Unlike
120
+ * {@link renderMarkdown}, a single newline inside a paragraph is a space, so a
121
+ * hard-wrapped source reflows to the reader's width; a blank line still starts
122
+ * a new paragraph.
123
+ */
124
+ export function renderDocument(source: string): string {
125
+ return sanitize(documentMd.render(normalize(source)));
126
+ }
127
+
112
128
  interface TopLevelBlock {
113
129
  tokens: Token[];
114
130
  startLine: number;
@@ -9,11 +9,8 @@ import { useAiData } from "../views/composables/useAiData";
9
9
  import { useAssistant } from "../views/composables/useAssistant";
10
10
  import { useViewFormat } from "../views/composables/useViewFormat";
11
11
  import { AI_PAGES, AI_ROUTES, CHANGE_SET_QUERY_KEY } from "../views/constants";
12
- import type {
13
- ActivityDetailPayload,
14
- ActivityRow,
15
- RowNavigation,
16
- } from "../views/types";
12
+ import type { RowNavigation } from "#dms-ui/app/types/row-navigation";
13
+ import type { ActivityDetailPayload, ActivityRow } from "../views/types";
17
14
  import {
18
15
  activityOutput,
19
16
  argumentItems,
@@ -173,9 +173,11 @@ onMounted(() => {
173
173
  color: var(--ui-text-muted);
174
174
  }
175
175
 
176
+ /* Sits where the panel header's close button does: centred in a header
177
+ as tall as the DMS navbar (28px is the small square button height). */
176
178
  .dms-ai-panel-close {
177
179
  position: absolute;
178
- top: 11px;
180
+ top: calc((var(--ui-header-height) - 28px) / 2);
179
181
  right: 8px;
180
182
  }
181
183
 
@@ -1,15 +1,25 @@
1
1
  <script setup lang="ts">
2
+ import type { Tone } from "#dms-ui/app/types/tone";
2
3
  import { computed } from "vue";
3
- import { renderMarkdown } from "../chat/utils/markdown";
4
- import RowStepper from "../views/RowStepper.vue";
4
+ import { renderDocument } from "../chat/utils/markdown";
5
5
  import { useAssistant } from "../views/composables/useAssistant";
6
6
  import { useViewFormat } from "../views/composables/useViewFormat";
7
- import type { RowNavigation, SkillRow } from "../views/types";
7
+ import type { SkillRow } from "../views/types";
8
8
  import { skillLook } from "../views/utils/skills";
9
9
 
10
10
  interface Props {
11
11
  rowData?: SkillRow;
12
- navigation?: RowNavigation;
12
+ }
13
+
14
+ /** A fact of the skill: a status pill, an identifier (mono) or plain text. */
15
+ interface SkillFact {
16
+ id: string;
17
+ label: string;
18
+ value: string;
19
+ type?: "status" | "mono";
20
+ tone?: Tone;
21
+ /** Draws a button copying the value. */
22
+ copy?: boolean;
13
23
  }
14
24
 
15
25
  const props = defineProps<Props>();
@@ -20,9 +30,9 @@ const assistant = useAssistant();
20
30
 
21
31
  const skill = computed(() => props.rowData);
22
32
  const look = computed(() => (skill.value ? skillLook(skill.value) : null));
23
- const html = computed(() => renderMarkdown(skill.value?.body ?? ""));
33
+ const html = computed(() => renderDocument(skill.value?.body ?? ""));
24
34
 
25
- const facts = computed(() => {
35
+ const facts = computed<SkillFact[]>(() => {
26
36
  const current = skill.value;
27
37
  if (!current || !look.value) return [];
28
38
  return [
@@ -30,14 +40,21 @@ const facts = computed(() => {
30
40
  id: "source",
31
41
  label: t("dms_ai.views.skills.source_label"),
32
42
  value: t(`dms_ai.views.skills.source.${current.source}`),
33
- type: "status" as const,
43
+ type: "status",
34
44
  tone: look.value.badgeTone,
35
45
  },
46
+ {
47
+ id: "qualified",
48
+ label: t("dms_ai.views.skills.qualified_name_label"),
49
+ value: current.qualifiedName,
50
+ type: "mono",
51
+ copy: true,
52
+ },
36
53
  {
37
54
  id: "origin",
38
55
  label: t("dms_ai.views.skills.origin_label"),
39
56
  value: current.origin,
40
- type: "mono" as const,
57
+ type: "mono",
41
58
  copy: true,
42
59
  },
43
60
  {
@@ -67,22 +84,6 @@ function openLastConversation(): void {
67
84
 
68
85
  <template>
69
86
  <div v-if="skill && look" class="flex min-h-full flex-col gap-5">
70
- <RowStepper v-if="props.navigation" :navigation="props.navigation" />
71
-
72
- <header class="flex items-start gap-3">
73
- <DmsIconWell :icon="look.icon" :tone="look.tone" size="lg" />
74
- <div class="min-w-0 flex-1">
75
- <h3
76
- class="text-highlighted truncate font-mono text-[15px] font-semibold"
77
- >
78
- {{ skill.name }}
79
- </h3>
80
- <p class="text-dimmed mt-0.5 truncate font-mono text-[11.5px]">
81
- {{ skill.qualifiedName }}
82
- </p>
83
- </div>
84
- </header>
85
-
86
87
  <DmsBanner
87
88
  v-if="skill.isShadowed"
88
89
  size="sm"
@@ -95,9 +96,38 @@ function openLastConversation(): void {
95
96
  "
96
97
  />
97
98
 
98
- <DmsKeyValueList :items="facts" />
99
+ <!-- The drawer's header names the skill. The facts read as a record
100
+ drawer's fields: the DMS form row, a label column beside a
101
+ left-aligned value once the drawer is wide enough, the label above
102
+ it on a phone. -->
103
+ <div class="grid gap-3">
104
+ <DmsFieldRow
105
+ v-for="fact in facts"
106
+ :key="fact.id"
107
+ :label="fact.label"
108
+ layout="form"
109
+ spacing="list"
110
+ :inset="false"
111
+ >
112
+ <div class="flex min-w-0 items-center gap-1.5 text-[13px]">
113
+ <DmsStatusPill
114
+ v-if="fact.type === 'status'"
115
+ :tone="fact.tone ?? 'neutral'"
116
+ :label="fact.value"
117
+ />
118
+ <span
119
+ v-else
120
+ class="text-highlighted wrap-anywhere min-w-0"
121
+ :class="fact.type === 'mono' && 'font-mono text-[12.5px]'"
122
+ >
123
+ {{ fact.value }}
124
+ </span>
125
+ <DmsCopyButton v-if="fact.copy" :value="fact.value" class="-my-1" />
126
+ </div>
127
+ </DmsFieldRow>
128
+ </div>
99
129
 
100
- <p class="text-muted text-[13px] leading-relaxed">
130
+ <p class="text-muted max-w-[76ch] text-[13px] leading-relaxed">
101
131
  {{ skill.description }}
102
132
  </p>
103
133
 
@@ -185,4 +215,20 @@ function openLastConversation(): void {
185
215
  color: var(--ui-primary);
186
216
  text-decoration: underline;
187
217
  }
218
+ /* Running text keeps a readable measure in the wide sheet; code and tables
219
+ may use the whole width. */
220
+ .dms-ai-skill-doc :deep(:is(p, ul, ol, blockquote, h1, h2, h3, h4, h5, h6)) {
221
+ max-width: 76ch;
222
+ }
223
+ .dms-ai-skill-doc :deep(table) {
224
+ margin: 0 0 8px;
225
+ border-collapse: collapse;
226
+ display: block;
227
+ overflow-x: auto;
228
+ }
229
+ .dms-ai-skill-doc :deep(:is(th, td)) {
230
+ padding: 4px 8px;
231
+ border: 1px solid var(--ui-border);
232
+ text-align: start;
233
+ }
188
234
  </style>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import type { RowNavigation } from "./types";
2
+ import type { RowNavigation } from "#dms-ui/app/types/row-navigation";
3
3
 
4
4
  interface Props {
5
5
  navigation: RowNavigation;
@@ -170,16 +170,6 @@ export interface SkillRow {
170
170
  icon?: string;
171
171
  }
172
172
 
173
- /** What a DMS row drawer hands its component to step through the rows. */
174
- export interface RowNavigation {
175
- index: number;
176
- total: number;
177
- hasPrev: boolean;
178
- hasNext: boolean;
179
- prev: () => void;
180
- next: () => void;
181
- }
182
-
183
173
  /** One fact of a change set, a row of a DmsKeyValueList. */
184
174
  export interface Fact {
185
175
  id: string;
@@ -28,6 +28,10 @@
28
28
  },
29
29
  "blocks": {
30
30
  "overview": {
31
+ "toolbar": {
32
+ "name": "Toolbar",
33
+ "description": "When the Overview last read its figures, a refresh, and the period"
34
+ },
31
35
  "period": {
32
36
  "name": "Period",
33
37
  "description": "Picks the period the Overview's figures cover"
@@ -80,11 +84,11 @@
80
84
  },
81
85
  "activity_chart": {
82
86
  "name": "Activity chart",
83
- "description": "Actions per day, compared with the previous period",
87
+ "description": "Actions per day, against the chosen comparison",
84
88
  "title": "Activity",
85
89
  "caption": "Actions over the selected period",
86
90
  "primary": "Actions",
87
- "comparison": "Previous period"
91
+ "comparison": "Comparison"
88
92
  },
89
93
  "allowed": {
90
94
  "name": "How actions were allowed",
@@ -133,6 +137,14 @@
133
137
  }
134
138
  },
135
139
  "activity": {
140
+ "toolbar": {
141
+ "name": "Toolbar",
142
+ "description": "When the audit log was last read, a refresh, and the period"
143
+ },
144
+ "period": {
145
+ "name": "Period",
146
+ "description": "Picks the period the audit log covers"
147
+ },
136
148
  "log": {
137
149
  "name": "Audit log",
138
150
  "description": "Every tool call, how it was allowed and what came out"
@@ -561,7 +573,8 @@
561
573
  },
562
574
  "detail": {
563
575
  "open": "Details",
564
- "title": "Skill detail"
576
+ "title": "Skill detail",
577
+ "position": "Skill {index} of {total}"
565
578
  },
566
579
  "empty": {
567
580
  "title": "No skills loaded",
@@ -28,6 +28,10 @@
28
28
  },
29
29
  "blocks": {
30
30
  "overview": {
31
+ "toolbar": {
32
+ "name": "Barre d'outils",
33
+ "description": "Quand la vue d'ensemble a lu ses chiffres, un rafraîchissement et la période"
34
+ },
31
35
  "period": {
32
36
  "name": "Période",
33
37
  "description": "Choisit la période couverte par les chiffres de la vue d'ensemble"
@@ -80,11 +84,11 @@
80
84
  },
81
85
  "activity_chart": {
82
86
  "name": "Graphique d'activité",
83
- "description": "Actions par jour, comparées à la période précédente",
87
+ "description": "Actions par jour, face à la comparaison choisie",
84
88
  "title": "Activité",
85
89
  "caption": "Actions sur la période choisie",
86
90
  "primary": "Actions",
87
- "comparison": "Période précédente"
91
+ "comparison": "Comparaison"
88
92
  },
89
93
  "allowed": {
90
94
  "name": "Comment les actions ont été autorisées",
@@ -133,6 +137,14 @@
133
137
  }
134
138
  },
135
139
  "activity": {
140
+ "toolbar": {
141
+ "name": "Barre d'outils",
142
+ "description": "Quand le journal d'audit a été lu, un rafraîchissement et la période"
143
+ },
144
+ "period": {
145
+ "name": "Période",
146
+ "description": "Choisit la période couverte par le journal d'audit"
147
+ },
136
148
  "log": {
137
149
  "name": "Journal d'audit",
138
150
  "description": "Chaque appel d'outil, comment il a été autorisé et son résultat"
@@ -561,7 +573,8 @@
561
573
  },
562
574
  "detail": {
563
575
  "open": "Détails",
564
- "title": "Détail de la skill"
576
+ "title": "Détail de la skill",
577
+ "position": "Skill {index} sur {total}"
565
578
  },
566
579
  "empty": {
567
580
  "title": "Aucune skill chargée",
@@ -131,6 +131,7 @@
131
131
  "uses": "{count} use | {count} uses",
132
132
  "never_used": "never used",
133
133
  "source_label": "Source",
134
+ "qualified_name_label": "Full name",
134
135
  "origin_label": "Origin",
135
136
  "used_label": "Used",
136
137
  "used_value": "{count}× · 30 days",
@@ -131,6 +131,7 @@
131
131
  "uses": "{count} utilisation | {count} utilisations",
132
132
  "never_used": "jamais utilisée",
133
133
  "source_label": "Source",
134
+ "qualified_name_label": "Nom complet",
134
135
  "origin_label": "Origine",
135
136
  "used_label": "Utilisée",
136
137
  "used_value": "{count}× · 30 jours",
@@ -6,7 +6,7 @@
6
6
  "license": "Apache-2.0",
7
7
  "packageManager": "pnpm@10.6.5",
8
8
  "engines": {
9
- "@antelopejs/dms-frontend": ">=0.6.3 <0.8.0"
9
+ "@antelopejs/dms-frontend": ">=0.8.0 <0.9.0"
10
10
  },
11
11
  "scripts": {
12
12
  "build": "pnpm --dir \"${DMS_FRONTEND_WORKSPACE:?Set DMS_FRONTEND_WORKSPACE to the generated Inertia workspace}\" build",
@@ -2,6 +2,7 @@
2
2
  import { describe, expect, it } from "vitest";
3
3
  import {
4
4
  createIncrementalMarkdown,
5
+ renderDocument,
5
6
  renderMarkdown,
6
7
  } from "../../app/chat/utils/markdown";
7
8
 
@@ -270,3 +271,71 @@ describe("a streaming answer renders incrementally", () => {
270
271
  expect(renderedChars).toBeLessThan(paragraphs.length * 5);
271
272
  });
272
273
  });
274
+
275
+ describe("a document reflows its hard-wrapped lines", () => {
276
+ const SKILL = [
277
+ "# Skill",
278
+ "",
279
+ "The DMS is a **backend-driven admin framework** built",
280
+ "on AntelopeJS. You add an",
281
+ "admin screen by writing TypeScript.",
282
+ "",
283
+ "Second paragraph.",
284
+ "",
285
+ "- one item",
286
+ " wrapped",
287
+ "- two",
288
+ "",
289
+ "```ts",
290
+ "const a = 1;",
291
+ "const b = 2;",
292
+ "```",
293
+ "",
294
+ "| a | b |",
295
+ "|---|---|",
296
+ "| 1 | 2 |",
297
+ ].join("\n");
298
+
299
+ function renderSkill(): HTMLElement {
300
+ const host = document.createElement("div");
301
+ host.innerHTML = renderDocument(SKILL);
302
+ return host;
303
+ }
304
+
305
+ it("joins a paragraph's lines with spaces, not line breaks", () => {
306
+ const host = renderSkill();
307
+ expect(host.querySelector("br")).toBeNull();
308
+ // A soft break stays a newline in the text, which HTML shows as a space.
309
+ const text = host.querySelector("p")?.textContent?.replace(/\s+/g, " ");
310
+ expect(text).toBe(
311
+ "The DMS is a backend-driven admin framework built on AntelopeJS. You add an admin screen by writing TypeScript.",
312
+ );
313
+ });
314
+
315
+ it("keeps paragraphs, lists, code blocks and tables", () => {
316
+ const host = renderSkill();
317
+ expect(host.querySelectorAll("p")).toHaveLength(2);
318
+ expect(host.querySelectorAll("li")).toHaveLength(2);
319
+ expect(host.querySelector("li")?.textContent).toBe("one item\nwrapped");
320
+ expect(host.querySelector("pre code")?.textContent).toBe(
321
+ "const a = 1;\nconst b = 2;\n",
322
+ );
323
+ expect(host.querySelectorAll("td")).toHaveLength(2);
324
+ });
325
+
326
+ it("still honours a hard break written in the source", () => {
327
+ const host = document.createElement("div");
328
+ host.innerHTML = renderDocument("first \nsecond");
329
+ expect(host.querySelector("br")).not.toBeNull();
330
+ });
331
+
332
+ it("leaves chat messages breaking on every newline", () => {
333
+ expect(renderMarkdown("one\ntwo")).toContain("<br>");
334
+ });
335
+
336
+ it("sanitizes like a chat message", () => {
337
+ expectHarmless(
338
+ renderDocument("<img src=x onerror=alert(1)>\n[x](javascript:alert(1))"),
339
+ );
340
+ });
341
+ });
@@ -0,0 +1,63 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+ import en from "../i18n/locales/dms-ai-views-en-GB.json";
5
+ import fr from "../i18n/locales/dms-ai-views-fr-FR.json";
6
+
7
+ type Tree = Record<string, unknown>;
8
+
9
+ const ROOT = resolve(__dirname, "..");
10
+ // The page components (the skill drawer and cards among them) and the view
11
+ // helpers they share; the chat panel has its own test.
12
+ const SOURCES = ["app/components", "app/views"];
13
+ const EXCLUDED = ["ChatPanel.vue"];
14
+ const LITERAL_KEY = /["'`](dms_ai\.views\.[A-Za-z0-9_.]+[A-Za-z0-9_])["'`]/g;
15
+
16
+ /** Keys built at run time from a value, each with every value it can take. */
17
+ const DYNAMIC_FAMILIES: Record<string, readonly string[]> = {
18
+ "dms_ai.views.skills.source": ["module", "local"],
19
+ };
20
+
21
+ function files(path: string): string[] {
22
+ const full = join(ROOT, path);
23
+ if (!statSync(full).isDirectory()) return [full];
24
+ return readdirSync(full)
25
+ .filter((entry) => !EXCLUDED.includes(entry))
26
+ .flatMap((entry) => files(join(path, entry)));
27
+ }
28
+
29
+ function flatten(tree: Tree, prefix = ""): string[] {
30
+ return Object.entries(tree).flatMap(([key, value]) =>
31
+ value !== null && typeof value === "object"
32
+ ? flatten(value as Tree, `${prefix}${key}.`)
33
+ : [`${prefix}${key}`],
34
+ );
35
+ }
36
+
37
+ function usedKeys(): string[] {
38
+ const source = SOURCES.flatMap(files)
39
+ .map((file) => readFileSync(file, "utf8"))
40
+ .join("\n");
41
+ return [
42
+ ...new Set([...source.matchAll(LITERAL_KEY)].map((match) => match[1])),
43
+ ];
44
+ }
45
+
46
+ const dynamicKeys = Object.entries(DYNAMIC_FAMILIES).flatMap(
47
+ ([prefix, values]) => values.map((value) => `${prefix}.${value}`),
48
+ );
49
+
50
+ describe("the views' strings", () => {
51
+ const english = new Set(flatten(en));
52
+ const french = new Set(flatten(fr));
53
+
54
+ it("exist in English and French alike", () => {
55
+ expect([...english].sort()).toEqual([...french].sort());
56
+ });
57
+
58
+ it("cover every key the views use", () => {
59
+ const keys = [...usedKeys(), ...dynamicKeys];
60
+ expect(keys.length).toBeGreaterThan(0);
61
+ expect(keys.filter((key) => !english.has(key))).toEqual([]);
62
+ });
63
+ });
@@ -0,0 +1,40 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ // jsdom does not lay out, so these read the stylesheets: each panel header must
6
+ // take its height from the DMS navbar's variable, or their borders drift apart.
7
+ const HEADER_RULES = [
8
+ { file: "../app/chat/components/PanelHeader.vue", selector: ".cb__head" },
9
+ {
10
+ file: "../app/chat/components/ConversationDrawer.vue",
11
+ selector: ".cb-drawer__head",
12
+ },
13
+ ];
14
+
15
+ function ruleBody(source: string, selector: string): string {
16
+ const start = source.indexOf(`${selector} {`);
17
+ expect(start, `${selector} rule`).toBeGreaterThanOrEqual(0);
18
+ return source.slice(start, source.indexOf("}", start));
19
+ }
20
+
21
+ describe("panel header height", () => {
22
+ it.each(HEADER_RULES)("$selector uses the DMS header height", (rule) => {
23
+ const source = readFileSync(resolve(__dirname, rule.file), "utf8");
24
+
25
+ expect(ruleBody(source, rule.selector)).toMatch(
26
+ /\bheight: var\(--ui-header-height\);/,
27
+ );
28
+ });
29
+
30
+ it("centres the status overlay close button in that height", () => {
31
+ const source = readFileSync(
32
+ resolve(__dirname, "../app/components/ChatPanel.vue"),
33
+ "utf8",
34
+ );
35
+
36
+ expect(ruleBody(source, ".dms-ai-panel-close")).toContain(
37
+ "var(--ui-header-height)",
38
+ );
39
+ });
40
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antelopejs/dms-ai",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "AI development assistant for the AntelopeJS DMS: a chat panel next to every page and a coding agent that edits the project in reviewable, undoable change sets.",
5
5
  "keywords": [
6
6
  "ai",
@@ -68,7 +68,7 @@
68
68
  "dependencies": {
69
69
  "@antelopejs/interface-api": ">=0.0.15 <1.0.0",
70
70
  "@antelopejs/interface-core": ">=0.2.4 <1.0.0",
71
- "@antelopejs/interface-dms": ">=0.7.2 <1.0.0",
71
+ "@antelopejs/interface-dms": ">=0.8.0 <1.0.0",
72
72
  "@anthropic-ai/claude-agent-sdk": "^0.2.131",
73
73
  "@anthropic-ai/claude-code": "^2.1.131",
74
74
  "@modelcontextprotocol/sdk": "1.29.0",
@@ -107,5 +107,6 @@
107
107
  "skills": [
108
108
  "./skills"
109
109
  ]
110
- }
110
+ },
111
+ "dmsFrontend": "dist/frontend-module.js"
111
112
  }