@cat-factory/app 0.87.3 → 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.
@@ -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,6 +9,7 @@
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'
12
13
  import { agentKindMeta } from '~/utils/catalog'
13
14
 
14
15
  const { t, n } = useI18n()
@@ -171,11 +172,12 @@ function topScore(c: ConsensusContribution): { label: string; value: number } |
171
172
  class="rounded bg-emerald-500/15 px-1.5 py-0.5 text-xs text-emerald-300"
172
173
  >{{ t('consensus.confidence', { pct: pct(session.confidence) }) }}</span
173
174
  >
175
+ <CopyButton :text="session.synthesis" class="ms-auto -my-1" />
174
176
  </div>
175
- <pre
176
- class="whitespace-pre-wrap rounded-lg border border-slate-800 bg-slate-950/60 px-4 py-3 text-sm text-slate-200"
177
- >{{ session.synthesis }}</pre
178
- >
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
+ />
179
181
  <ul v-if="session.dissent?.length" class="mt-2 space-y-1">
180
182
  <li
181
183
  v-for="(d, i) in session.dissent"
@@ -237,8 +239,9 @@ function topScore(c: ConsensusContribution): { label: string; value: number } |
237
239
  })
238
240
  }}</span
239
241
  >
242
+ <CopyButton :text="c.text" :class="topScore(c) ? '-my-1' : 'ms-auto -my-1'" />
240
243
  </div>
241
- <pre class="whitespace-pre-wrap text-sm text-slate-300">{{ c.text }}</pre>
244
+ <MarkdownProse :text="c.text" class="text-sm text-slate-300" />
242
245
  <div v-if="c.scores?.length" class="mt-2 flex flex-wrap gap-1.5">
243
246
  <span
244
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"
@@ -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.3",
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",