@cat-factory/app 0.87.2 → 0.87.4

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.
@@ -21,6 +21,7 @@ import type {
21
21
 
22
22
  const board = useBoardStore()
23
23
  const clarity = useClarityStore()
24
+ const models = useModelsStore()
24
25
  const toast = useToast()
25
26
  const { t } = useI18n()
26
27
 
@@ -493,7 +494,9 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
493
494
  </div>
494
495
  <div v-if="review.model" class="flex items-center justify-between">
495
496
  <span>{{ t('clarity.rail.model') }}</span>
496
- <span class="truncate ps-2 text-slate-500">{{ review.model }}</span>
497
+ <span class="truncate ps-2 text-slate-500">{{
498
+ models.labelForRef(review.model) ?? review.model
499
+ }}</span>
497
500
  </div>
498
501
  </div>
499
502
 
@@ -0,0 +1,109 @@
1
+ <script setup lang="ts">
2
+ // Shared inline markdown renderer for agent prose (a rationale, a synthesis, a summary).
3
+ // Routes text through the secure `renderMarkdown` reader (markdown-it, `html: false`, links
4
+ // decorated to open safely in a new tab) instead of the `whitespace-pre-wrap` plain-text
5
+ // dumps several result views used to show (UX-43) — so `**bold**`, lists, code, and links in
6
+ // an agent's output read as formatted prose, consistently with `AgentStepDetail`'s reader.
7
+ import { computed } from 'vue'
8
+ import { renderMarkdown } from '~/utils/agentOutput'
9
+
10
+ const props = defineProps<{
11
+ /** The agent's raw markdown text. */
12
+ text: string | null | undefined
13
+ }>()
14
+
15
+ const html = computed(() => renderMarkdown(props.text))
16
+ </script>
17
+
18
+ <template>
19
+ <!-- eslint-disable-next-line vue/no-v-html (sanitized by renderMarkdown, html: false) -->
20
+ <div class="cf-prose" v-html="html" />
21
+ </template>
22
+
23
+ <style scoped>
24
+ /* Prose styling for the sanitized markdown injected via v-html (out of scoped reach
25
+ without :deep), mirroring the inspector reader's prose styling. */
26
+ .cf-prose :deep(p) {
27
+ margin: 0.5rem 0;
28
+ }
29
+ .cf-prose :deep(p:first-child) {
30
+ margin-top: 0;
31
+ }
32
+ .cf-prose :deep(p:last-child) {
33
+ margin-bottom: 0;
34
+ }
35
+ .cf-prose :deep(ul),
36
+ .cf-prose :deep(ol) {
37
+ margin: 0.5rem 0;
38
+ padding-left: 1.25rem;
39
+ }
40
+ .cf-prose :deep(ul) {
41
+ list-style: disc;
42
+ }
43
+ .cf-prose :deep(ol) {
44
+ list-style: decimal;
45
+ }
46
+ .cf-prose :deep(li) {
47
+ margin: 0.2rem 0;
48
+ }
49
+ .cf-prose :deep(strong) {
50
+ font-weight: 600;
51
+ color: rgb(226 232 240);
52
+ }
53
+ .cf-prose :deep(em) {
54
+ font-style: italic;
55
+ }
56
+ .cf-prose :deep(code) {
57
+ border-radius: 0.25rem;
58
+ background: rgb(30 41 59 / 0.8);
59
+ padding: 0.1rem 0.3rem;
60
+ font-family: ui-monospace, monospace;
61
+ font-size: 0.85em;
62
+ color: rgb(199 210 254);
63
+ }
64
+ .cf-prose :deep(pre) {
65
+ margin: 0.6rem 0;
66
+ overflow: auto;
67
+ border-radius: 0.5rem;
68
+ background: rgb(2 6 23 / 0.6);
69
+ padding: 0.75rem 0.9rem;
70
+ }
71
+ .cf-prose :deep(pre code) {
72
+ background: transparent;
73
+ padding: 0;
74
+ color: rgb(203 213 225);
75
+ }
76
+ .cf-prose :deep(blockquote) {
77
+ margin: 0.6rem 0;
78
+ border-left: 3px solid rgb(99 102 241 / 0.5);
79
+ padding-left: 0.75rem;
80
+ color: rgb(148 163 184);
81
+ }
82
+ .cf-prose :deep(h1),
83
+ .cf-prose :deep(h2),
84
+ .cf-prose :deep(h3),
85
+ .cf-prose :deep(h4) {
86
+ margin: 0.7rem 0 0.4rem;
87
+ font-weight: 600;
88
+ color: rgb(226 232 240);
89
+ }
90
+ .cf-prose :deep(table) {
91
+ margin: 0.6rem 0;
92
+ border-collapse: collapse;
93
+ font-size: 0.95em;
94
+ }
95
+ .cf-prose :deep(th),
96
+ .cf-prose :deep(td) {
97
+ border: 1px solid rgb(51 65 85);
98
+ padding: 0.3rem 0.6rem;
99
+ }
100
+ .cf-prose :deep(th) {
101
+ background: rgb(30 41 59 / 0.6);
102
+ font-weight: 600;
103
+ }
104
+ .cf-prose :deep(hr) {
105
+ margin: 1rem 0;
106
+ border: none;
107
+ border-top: 1px solid rgb(51 65 85);
108
+ }
109
+ </style>
@@ -9,11 +9,14 @@
9
9
  import { computed } from 'vue'
10
10
  import type { ConsensusContribution, ConsensusSession } from '~/types/consensus'
11
11
  import CopyButton from '~/components/common/CopyButton.vue'
12
+ import MarkdownProse from '~/components/common/MarkdownProse.vue'
13
+ import { agentKindMeta } from '~/utils/catalog'
12
14
 
13
15
  const { t, n } = useI18n()
14
16
 
15
17
  const board = useBoardStore()
16
18
  const consensus = useConsensusStore()
19
+ const models = useModelsStore()
17
20
 
18
21
  const { open, blockId, close } = useResultView('consensus-session', {
19
22
  onOpen: (id) => {
@@ -114,7 +117,7 @@ function topScore(c: ConsensusContribution): { label: string; value: number } |
114
117
  <span v-if="block" class="font-normal text-slate-400">— {{ block.title }}</span>
115
118
  </h2>
116
119
  <p v-if="session" class="text-xs text-slate-500">
117
- {{ session.agentKind }} ·
120
+ {{ agentKindMeta(session.agentKind).label }} ·
118
121
  {{
119
122
  t(
120
123
  'consensus.participantCount',
@@ -169,11 +172,12 @@ function topScore(c: ConsensusContribution): { label: string; value: number } |
169
172
  class="rounded bg-emerald-500/15 px-1.5 py-0.5 text-xs text-emerald-300"
170
173
  >{{ t('consensus.confidence', { pct: pct(session.confidence) }) }}</span
171
174
  >
175
+ <CopyButton :text="session.synthesis" class="ms-auto -my-1" />
172
176
  </div>
173
- <pre
174
- class="whitespace-pre-wrap rounded-lg border border-slate-800 bg-slate-950/60 px-4 py-3 text-sm text-slate-200"
175
- >{{ session.synthesis }}</pre
176
- >
177
+ <MarkdownProse
178
+ :text="session.synthesis"
179
+ class="rounded-lg border border-slate-800 bg-slate-950/60 px-4 py-3 text-sm text-slate-200"
180
+ />
177
181
  <ul v-if="session.dissent?.length" class="mt-2 space-y-1">
178
182
  <li
179
183
  v-for="(d, i) in session.dissent"
@@ -201,7 +205,9 @@ function topScore(c: ConsensusContribution): { label: string; value: number } |
201
205
  t('consensus.expert', { letter: String.fromCharCode(65 + i) })
202
206
  }}</span>
203
207
  <span class="text-slate-400"> · {{ p.role }}</span>
204
- <span v-if="p.modelId" class="ms-1 text-slate-500">({{ p.modelId }})</span>
208
+ <span v-if="p.modelId" class="ms-1 text-slate-500"
209
+ >({{ models.labelForRef(p.modelId) ?? p.modelId }})</span
210
+ >
205
211
  </div>
206
212
  </div>
207
213
  </section>
@@ -233,8 +239,9 @@ function topScore(c: ConsensusContribution): { label: string; value: number } |
233
239
  })
234
240
  }}</span
235
241
  >
242
+ <CopyButton :text="c.text" :class="topScore(c) ? '-my-1' : 'ms-auto -my-1'" />
236
243
  </div>
237
- <pre class="whitespace-pre-wrap text-sm text-slate-300">{{ c.text }}</pre>
244
+ <MarkdownProse :text="c.text" class="text-sm text-slate-300" />
238
245
  <div v-if="c.scores?.length" class="mt-2 flex flex-wrap gap-1.5">
239
246
  <span
240
247
  v-for="s in c.scores"
@@ -9,6 +9,8 @@
9
9
  import { computed } from 'vue'
10
10
  import StepRunMeta from '~/components/panels/StepRunMeta.vue'
11
11
  import StepRestartControl from '~/components/panels/StepRestartControl.vue'
12
+ import MarkdownProse from '~/components/common/MarkdownProse.vue'
13
+ import CopyButton from '~/components/common/CopyButton.vue'
12
14
 
13
15
  const board = useBoardStore()
14
16
  const execution = useExecutionStore()
@@ -94,17 +96,19 @@ const customJson = computed<string | null>(() => {
94
96
  <div class="flex min-h-0 flex-1">
95
97
  <!-- Main: prose summary + structured JSON -->
96
98
  <div class="min-w-0 flex-1 overflow-y-auto px-5 py-4">
97
- <p
99
+ <MarkdownProse
98
100
  v-if="step?.output"
99
- class="mb-4 whitespace-pre-wrap text-[13px] leading-relaxed text-slate-300"
100
- >
101
- {{ step.output }}
102
- </p>
101
+ :text="step.output"
102
+ class="mb-4 text-[13px] leading-relaxed text-slate-300"
103
+ />
103
104
 
104
105
  <template v-if="customJson">
105
- <h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
106
- {{ t('panels.structuredResult.structuredOutput') }}
107
- </h3>
106
+ <div class="mb-2 flex items-center gap-2">
107
+ <h3 class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
108
+ {{ t('panels.structuredResult.structuredOutput') }}
109
+ </h3>
110
+ <CopyButton :text="customJson" class="-my-1" />
111
+ </div>
108
112
  <pre
109
113
  class="overflow-x-auto rounded-lg border border-slate-800 bg-slate-950/60 p-3 text-[12px] leading-relaxed text-slate-200"
110
114
  ><code>{{ customJson }}</code></pre>
@@ -10,6 +10,7 @@ import { computed } from 'vue'
10
10
  import type { MergeAxis, MergeDecision } from '@cat-factory/contracts'
11
11
  import StepRunMeta from '~/components/panels/StepRunMeta.vue'
12
12
  import StepRestartControl from '~/components/panels/StepRestartControl.vue'
13
+ import MarkdownProse from '~/components/common/MarkdownProse.vue'
13
14
 
14
15
  const board = useBoardStore()
15
16
  const execution = useExecutionStore()
@@ -221,9 +222,10 @@ const reasonText = computed(() => {
221
222
  >
222
223
  {{ t('panels.mergerResult.rationale') }}
223
224
  </h3>
224
- <p class="whitespace-pre-wrap text-[13px] leading-relaxed text-slate-300">
225
- {{ decision.assessment.rationale }}
226
- </p>
225
+ <MarkdownProse
226
+ :text="decision.assessment.rationale"
227
+ class="text-[13px] leading-relaxed text-slate-300"
228
+ />
227
229
  </template>
228
230
  <p v-else class="text-[13px] italic leading-relaxed text-slate-500">
229
231
  {{ t('panels.mergerResult.noAssessment') }}
@@ -231,12 +233,11 @@ const reasonText = computed(() => {
231
233
  </template>
232
234
 
233
235
  <!-- Pre-structured runs kept only the raw prose output. -->
234
- <p
236
+ <MarkdownProse
235
237
  v-else-if="step?.output"
236
- class="whitespace-pre-wrap text-[13px] leading-relaxed text-slate-300"
237
- >
238
- {{ step.output }}
239
- </p>
238
+ :text="step.output"
239
+ class="text-[13px] leading-relaxed text-slate-300"
240
+ />
240
241
  <div
241
242
  v-else
242
243
  class="flex h-full flex-col items-center justify-center gap-2 text-center text-slate-400"
@@ -21,6 +21,7 @@ import type {
21
21
 
22
22
  const board = useBoardStore()
23
23
  const requirements = useRequirementsStore()
24
+ const models = useModelsStore()
24
25
  const toast = useToast()
25
26
  const { t } = useI18n()
26
27
 
@@ -881,7 +882,9 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
881
882
  </template>
882
883
  <div v-if="review.model" class="flex items-center justify-between">
883
884
  <span>{{ t('requirements.stats.model') }}</span>
884
- <span class="truncate ps-2 text-slate-500">{{ review.model }}</span>
885
+ <span class="truncate ps-2 text-slate-500">{{
886
+ models.labelForRef(review.model) ?? review.model
887
+ }}</span>
885
888
  </div>
886
889
  </div>
887
890
 
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { parseOutputOutline, sliceSource } from '~/utils/agentOutput'
2
+ import { parseOutputOutline, renderMarkdown, sliceSource } from '~/utils/agentOutput'
3
3
 
4
4
  describe('parseOutputOutline', () => {
5
5
  it('splits on headings and builds a ToC', () => {
@@ -75,6 +75,49 @@ describe('parseOutputOutline', () => {
75
75
  })
76
76
  })
77
77
 
78
+ describe('renderMarkdown', () => {
79
+ it('renders inline marks and preserves single newlines as breaks', () => {
80
+ const html = renderMarkdown('see **bold** and _em_\nnext line')
81
+ expect(html).toContain('<strong>bold</strong>')
82
+ expect(html).toContain('<em>em</em>')
83
+ expect(html).toContain('<br')
84
+ })
85
+
86
+ it('renders lists and fenced code', () => {
87
+ const html = renderMarkdown(['- a', '- b', '', '```', 'const x = 1', '```'].join('\n'))
88
+ expect(html).toContain('<ul')
89
+ expect(html).toContain('<li>a</li>')
90
+ expect(html).toContain('<pre>')
91
+ expect(html).toContain('const x = 1')
92
+ })
93
+
94
+ it('escapes raw HTML rather than injecting it (html: false)', () => {
95
+ const html = renderMarkdown('<img src=x onerror=alert(1)>')
96
+ expect(html).not.toContain('<img')
97
+ expect(html).toContain('&lt;img')
98
+ })
99
+
100
+ it('decorates links to open safely in a new tab', () => {
101
+ const html = renderMarkdown('[site](https://example.com)')
102
+ expect(html).toContain('href="https://example.com"')
103
+ expect(html).toContain('target="_blank"')
104
+ expect(html).toContain('rel="noopener noreferrer"')
105
+ })
106
+
107
+ it('does not create a link for javascript: URLs', () => {
108
+ const html = renderMarkdown('[x](javascript:alert(1))')
109
+ expect(html).not.toContain('<a ')
110
+ expect(html).not.toContain('href')
111
+ })
112
+
113
+ it('renders empty string for empty / nullish input', () => {
114
+ expect(renderMarkdown('')).toBe('')
115
+ expect(renderMarkdown(' ')).toBe('')
116
+ expect(renderMarkdown(null)).toBe('')
117
+ expect(renderMarkdown(undefined)).toBe('')
118
+ })
119
+ })
120
+
78
121
  describe('sliceSource', () => {
79
122
  it('returns the verbatim line range (0-based, end-exclusive)', () => {
80
123
  const text = ['line0', 'line1', 'line2', 'line3'].join('\n')
@@ -78,6 +78,43 @@ const md = new MarkdownIt({
78
78
  const HEADINGS = new Set(['H1', 'H2', 'H3', 'H4', 'H5', 'H6'])
79
79
  const LINK_CLASS = 'text-indigo-300 underline decoration-indigo-500/40 hover:text-indigo-200'
80
80
 
81
+ // A second markdown-it instance for INLINE prose rendering (a rationale, a synthesis,
82
+ // a summary) — the same secure config as `md` (html: false, so raw HTML is escaped and
83
+ // dangerous link schemes are blocked by `validateLink`), but without the segmentation
84
+ // plugin: these surfaces render one continuous document, not a ToC-navigable outline.
85
+ // Link attributes are set through a renderer rule (not DOM post-processing) so the
86
+ // function returns ready-to-inject HTML without depending on `document`.
87
+ const proseMd = new MarkdownIt({
88
+ html: false, // secure by default: escape raw HTML rather than render it
89
+ linkify: true, // turn bare URLs into links
90
+ breaks: true, // single newlines → <br>, matching how agents lay out prose
91
+ typographer: true,
92
+ })
93
+
94
+ // Make every rendered link open safely in a new tab and pick up the prose link style,
95
+ // mirroring `decorateLinks` but at the token level so no DOM is required.
96
+ const defaultLinkOpen =
97
+ proseMd.renderer.rules.link_open ??
98
+ ((tokens, idx, options, _env, self) => self.renderToken(tokens, idx, options))
99
+ proseMd.renderer.rules.link_open = (tokens, idx, options, env, self) => {
100
+ const token = tokens[idx]!
101
+ token.attrSet('target', '_blank')
102
+ token.attrSet('rel', 'noopener noreferrer')
103
+ token.attrSet('class', LINK_CLASS)
104
+ return defaultLinkOpen(tokens, idx, options, env, self)
105
+ }
106
+
107
+ /**
108
+ * Render an agent's prose (markdown) to safe HTML for inline display. Escapes raw HTML,
109
+ * blocks dangerous link schemes, and decorates links to open safely in a new tab — the
110
+ * same guarantees as the reader overlay, minus the heading segmentation. Empty/nullish
111
+ * input renders to an empty string.
112
+ */
113
+ export function renderMarkdown(text: string | null | undefined): string {
114
+ const source = text ?? ''
115
+ return source.trim() ? proseMd.render(source) : ''
116
+ }
117
+
81
118
  function slugify(title: string, used: Set<string>): string {
82
119
  const base =
83
120
  title
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.87.2",
3
+ "version": "0.87.4",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",