@skyhook-io/radar-app 1.14.3 → 1.14.5

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 (34) hide show
  1. package/package.json +1 -1
  2. package/src/api/client.ts +2 -0
  3. package/src/api/diagnose.ts +7 -1
  4. package/src/components/diagnose/AIMarkdown.tsx +8 -3
  5. package/src/components/diagnose/ActivityTurn.tsx +1 -1
  6. package/src/components/diagnose/AssessmentCard.tsx +269 -238
  7. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +47 -0
  8. package/src/components/diagnose/InvestigationEvidencePane.tsx +15 -2
  9. package/src/components/diagnose/InvestigationEvidenceSections.tsx +1 -1
  10. package/src/components/diagnose/InvestigationView.tsx +32 -24
  11. package/src/components/diagnose/investigationCase.test.tsx +83 -0
  12. package/src/components/diagnose/investigationCase.ts +3 -2
  13. package/src/components/diagnose/investigationEvidence/adapters/coverage.test.tsx +22 -0
  14. package/src/components/diagnose/investigationEvidence/adapters/diagnose.test.ts +8 -2
  15. package/src/components/diagnose/investigationEvidence/adapters/diagnose.ts +1 -1
  16. package/src/components/diagnose/investigationEvidence/adapters/listings.ts +2 -0
  17. package/src/components/diagnose/investigationEvidence/adapters/logs.test.ts +60 -0
  18. package/src/components/diagnose/investigationEvidence/adapters/logs.ts +7 -3
  19. package/src/components/diagnose/investigationEvidence/bodies/streams.tsx +1 -1
  20. package/src/components/diagnose/investigationEvidence/observations.ts +10 -5
  21. package/src/components/diagnose/investigationEvidence/types.ts +1 -1
  22. package/src/components/diagnose/investigationState.test.ts +2 -2
  23. package/src/components/diagnose/investigationState.ts +2 -2
  24. package/src/components/diagnose/investigationStory.ts +1 -1
  25. package/src/components/diagnose/parts.test.tsx +153 -2
  26. package/src/components/home/mcpToolCatalog.ts +27 -0
  27. package/src/components/rightsizing/RightsizingScanView.tsx +194 -63
  28. package/src/components/rightsizing/empty-state.test.tsx +67 -0
  29. package/src/components/rightsizing/model.test.ts +118 -1
  30. package/src/components/rightsizing/model.ts +36 -13
  31. package/src/components/rightsizing/notices.test.tsx +175 -0
  32. package/src/components/ui/Markdown.tsx +33 -6
  33. package/src/components/ui/Omnibar.tsx +82 -69
  34. package/src/components/workload/WorkloadView.tsx +7 -1
@@ -0,0 +1,67 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { ScanEmptyState } from './RightsizingScanView'
4
+
5
+ const props = {
6
+ counts: { increase: 0, reduction: 0, review: 0, need_data: 0, in_range: 2 },
7
+ classFilter: 'actions' as const,
8
+ hasResultFilters: false,
9
+ onlySystemRowsAreHidden: false,
10
+ hasQueryErrors: false,
11
+ onSelect: () => {},
12
+ onClear: () => {},
13
+ onIncludeSystem: () => {},
14
+ }
15
+
16
+ describe('rightsizing empty journeys', () => {
17
+ it('affirms no actionable changes only for evaluated evidence, with an inspection action', () => {
18
+ const html = renderToStaticMarkup(<ScanEmptyState {...props} />)
19
+ expect(html).toContain('No actionable changes')
20
+ expect(html).toContain('2 evaluated containers')
21
+ expect(html).toContain('outside the scan coverage')
22
+ expect(html).toContain('View evaluated containers')
23
+ expect(html).not.toContain('Show recommended actions')
24
+ })
25
+
26
+ it.each([false, true])(
27
+ 'explains unknown evidence (query errors: %s) without claiming health',
28
+ (hasQueryErrors) => {
29
+ const html = renderToStaticMarkup(
30
+ <ScanEmptyState
31
+ {...props}
32
+ counts={{ ...props.counts, in_range: 0, need_data: 2 }}
33
+ hasQueryErrors={hasQueryErrors}
34
+ />,
35
+ )
36
+ expect(html).toContain('Not enough evidence for recommendations')
37
+ expect(html).toContain('View containers needing evidence')
38
+ expect(html).toContain(
39
+ hasQueryErrors ? 'Some metrics queries failed' : 'not enough usable CPU and memory history',
40
+ )
41
+ expect(html).not.toContain('No actionable changes')
42
+ },
43
+ )
44
+
45
+ it('qualifies a mixture of in-range and unknown containers', () => {
46
+ const html = renderToStaticMarkup(
47
+ <ScanEmptyState {...props} counts={{ ...props.counts, need_data: 1 }} />,
48
+ )
49
+ expect(html).toContain('No actionable changes in available evidence')
50
+ expect(html).toContain('not a complete assessment')
51
+ expect(html).toContain('View containers needing evidence')
52
+ })
53
+
54
+ it('does not blame missing history when a search or category filter hides results', () => {
55
+ for (const override of [{ hasResultFilters: true }, { classFilter: 'increase' as const }]) {
56
+ const html = renderToStaticMarkup(<ScanEmptyState {...props} {...override} />)
57
+ expect(html).toContain('No results match the current filters')
58
+ expect(html).toContain('Clear filters')
59
+ }
60
+ })
61
+
62
+ it('retains the system-workload recovery action', () => {
63
+ const html = renderToStaticMarkup(<ScanEmptyState {...props} onlySystemRowsAreHidden />)
64
+ expect(html).toContain('System workloads are hidden')
65
+ expect(html).toContain('Include system workloads')
66
+ })
67
+ })
@@ -1,6 +1,12 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import type { RightsizingScanResponse, RightsizingRow } from '../../api/client'
3
- import { calculateImpact, classifyRows, flattenScanResults, scanClassCounts } from './model'
3
+ import {
4
+ calculateImpact,
5
+ classifyRows,
6
+ flattenScanResults,
7
+ isActionableClass,
8
+ scanClassCounts,
9
+ } from './model'
4
10
 
5
11
  function metric(overrides: Partial<RightsizingRow> = {}): RightsizingRow {
6
12
  return {
@@ -152,6 +158,77 @@ describe('rightsizing scan model', () => {
152
158
  expect(classifyRows([metric({ queryError: 'query failed' })])).toBe('need_data')
153
159
  })
154
160
 
161
+ describe.each([
162
+ ['failed query', { queryError: 'usage query failed' }],
163
+ ['short history', { fit: 'insufficient_history' as const }],
164
+ ])('with %s on one resource', (_label, missing) => {
165
+ it.each([
166
+ [
167
+ 'increase',
168
+ {
169
+ fit: 'under_requested' as const,
170
+ recommendedRequest: '1Gi',
171
+ recommendedRequestValue: 1024 ** 3,
172
+ },
173
+ ],
174
+ ['review', { currentPodOOM: true }],
175
+ ['review', { hpaManaged: true, recommendationReason: 'hpa_managed' }],
176
+ [
177
+ 'reduction',
178
+ {
179
+ fit: 'oversized' as const,
180
+ currentRequestValue: 1024 ** 3,
181
+ recommendedRequest: '512Mi',
182
+ recommendedRequestValue: 512 * 1024 ** 2,
183
+ },
184
+ ],
185
+ ['need_data', {}],
186
+ ])('retains %s from the other resource', (classification, known) => {
187
+ const cpu = metric(missing)
188
+ const memory = metric({ resource: 'memory', ...known })
189
+ const rows = flattenScanResults(response([cpu, memory]))
190
+ expect(rows).toHaveLength(1)
191
+ expect(rows[0].classification).toBe(classification)
192
+ expect(rows[0].cpu).toBe(cpu)
193
+ expect(rows[0].memory).toBe(memory)
194
+ expect(scanClassCounts(rows)[rows[0].classification]).toBe(1)
195
+ const actions = rows.filter((row) => isActionableClass(row.classification))
196
+ expect(actions).toHaveLength(classification === 'need_data' ? 0 : 1)
197
+ })
198
+ })
199
+
200
+ it('retains a known CPU increase when memory has no evidence', () => {
201
+ const cpu = metric({
202
+ fit: 'under_requested',
203
+ recommendedRequest: '200m',
204
+ recommendedRequestValue: 0.2,
205
+ currentRequestValue: 0.1,
206
+ })
207
+ const memory = metric({ resource: 'memory', fit: 'insufficient_history' })
208
+ const [row] = flattenScanResults(response([cpu, memory], 3))
209
+ expect(row.classification).toBe('increase')
210
+ expect(row.impact.cpuChange).toBeCloseTo(0.3)
211
+ expect(row.impact.memoryChange).toBe(0)
212
+ expect(row.memory).toBe(memory)
213
+ })
214
+
215
+ it('keeps empty and entirely unevidenced containers in need_data', () => {
216
+ expect(classifyRows([])).toBe('need_data')
217
+ expect(
218
+ classifyRows([
219
+ metric({ queryError: 'usage query failed' }),
220
+ metric({ resource: 'memory', fit: 'insufficient_history' }),
221
+ ]),
222
+ ).toBe('need_data')
223
+ expect(
224
+ classifyRows(
225
+ [metric({ fit: 'insufficient_history' }), metric({ resource: 'memory' })],
226
+ 0,
227
+ true,
228
+ ),
229
+ ).toBe('review')
230
+ })
231
+
155
232
  it('keeps workloads with no current replicas in review', () => {
156
233
  const oversized = flattenScanResults(
157
234
  response(
@@ -225,3 +302,43 @@ describe('rightsizing scan model', () => {
225
302
  expect(scanClassCounts(rows).increase).toBe(1)
226
303
  })
227
304
  })
305
+
306
+ it('ranks known safety risks and increases above savings, while routine reviews stay lower', () => {
307
+ const make = (name: string, rows: RightsizingRow[], scaledToZero = false) => ({
308
+ ...response(rows, scaledToZero ? 0 : 1, 'shop', scaledToZero).workloads[0],
309
+ name,
310
+ })
311
+ const cut = metric({
312
+ fit: 'oversized',
313
+ currentRequestValue: 10,
314
+ recommendedRequestValue: 1,
315
+ recommendedRequest: '1',
316
+ })
317
+ const grow = metric({
318
+ fit: 'under_requested',
319
+ currentRequestValue: 0.1,
320
+ recommendedRequestValue: 0.2,
321
+ recommendedRequest: '200m',
322
+ })
323
+ const risk = metric({ resource: 'memory', currentPodOOM: true })
324
+ const workloads = [
325
+ make('cut', [cut]),
326
+ make('grow', [grow]),
327
+ make('hpa', [metric({ hpaManaged: true })]),
328
+ make('oom', [risk]),
329
+ make('limit', [metric({ limitConflict: true })]),
330
+ make('bursty', [{ ...cut, bursty: true }]),
331
+ make('throttled', [{ ...cut, throttleRatio: 0.2 }]),
332
+ make('idle', [risk], true),
333
+ make('failed-risk', [metric({ queryError: 'failed', currentPodOOM: true })]),
334
+ make('partial-grow', [grow, metric({ resource: 'memory', queryError: 'failed' })]),
335
+ make('steady', [metric()]),
336
+ ]
337
+ const rows = flattenScanResults({ ...response([]), workloads })
338
+ const names = rows.map((row) => row.name)
339
+ expect(new Set(names.slice(0, 3))).toEqual(new Set(['oom', 'limit', 'throttled']))
340
+ expect(names.slice(3, 5)).toEqual(['grow', 'partial-grow'])
341
+ expect(names[5]).toBe('cut')
342
+ expect(names.slice(6, 9)).toEqual(['bursty', 'hpa', 'idle'])
343
+ expect(names.slice(9)).toEqual(['failed-risk', 'steady'])
344
+ })
@@ -24,11 +24,11 @@ export interface RightsizingScanRow {
24
24
  }
25
25
 
26
26
  const CLASS_RANK: Record<ScanClass, number> = {
27
- reduction: 0,
28
27
  increase: 1,
29
- review: 2,
30
- need_data: 3,
31
- in_range: 4,
28
+ reduction: 2,
29
+ review: 3,
30
+ need_data: 4,
31
+ in_range: 5,
32
32
  }
33
33
 
34
34
  const MIN_CPU_REDUCTION = 0.05
@@ -39,32 +39,55 @@ export function classifyRows(
39
39
  replicas = 1,
40
40
  scaledToZero = false,
41
41
  ): ScanClass {
42
- if (rows.some((row) => row.queryError || row.fit === 'insufficient_history')) return 'need_data'
42
+ const evidenced = rows.filter((row) => !row.queryError && row.fit !== 'insufficient_history')
43
+ if (evidenced.length === 0) return 'need_data'
43
44
  if (scaledToZero) return 'review'
44
45
  if (
45
- rows.some(
46
+ evidenced.some(
46
47
  (row) =>
47
48
  (row.fit === 'under_requested' || row.fit === 'missing_request') && row.recommendedRequest,
48
49
  )
49
50
  )
50
51
  return 'increase'
51
- if (rows.some(needsManualReview)) return 'review'
52
- const impact = calculateImpact(rows, replicas)
52
+ if (evidenced.some(needsManualReview)) return 'review'
53
+ const impact = calculateImpact(evidenced, replicas)
53
54
  if (-impact.cpuChange >= MIN_CPU_REDUCTION || -impact.memoryChange >= MIN_MEMORY_REDUCTION)
54
55
  return 'reduction'
55
- return 'in_range'
56
+ return evidenced.length === rows.length ? 'in_range' : 'need_data'
56
57
  }
57
58
 
58
59
  function needsManualReview(row: RightsizingRow): boolean {
59
60
  return (
60
61
  row.hpaManaged ||
62
+ needsSafetyReview(row) ||
63
+ (isReduction(row) && row.bursty === true) ||
64
+ row.recommendationReason === 'hpa_evidence_unavailable' ||
65
+ row.recommendationReason === 'oom_evidence_unavailable'
66
+ )
67
+ }
68
+
69
+ function needsSafetyReview(row: RightsizingRow): boolean {
70
+ return (
61
71
  row.currentPodOOM === true ||
62
72
  row.windowOomEvidence === true ||
63
73
  row.limitConflict === true ||
64
- row.recommendationReason === 'hpa_evidence_unavailable' ||
65
- row.recommendationReason === 'oom_evidence_unavailable' ||
66
- (isReduction(row) && (row.bursty || (row.throttleRatio ?? 0) >= 0.1))
74
+ (isReduction(row) && (row.throttleRatio ?? 0) >= 0.1)
75
+ )
76
+ }
77
+
78
+ function scanPriority(row: RightsizingScanRow): number {
79
+ if (
80
+ !row.scaledToZero &&
81
+ [row.cpu, row.memory].some(
82
+ (resource) =>
83
+ resource &&
84
+ !resource.queryError &&
85
+ resource.fit !== 'insufficient_history' &&
86
+ needsSafetyReview(resource),
87
+ )
67
88
  )
89
+ return 0
90
+ return CLASS_RANK[row.classification]
68
91
  }
69
92
 
70
93
  function isReduction(row: RightsizingRow): boolean {
@@ -117,7 +140,7 @@ export function flattenScanResults(scan: RightsizingScanResponse): RightsizingSc
117
140
  }
118
141
  return result.sort(
119
142
  (a, b) =>
120
- CLASS_RANK[a.classification] - CLASS_RANK[b.classification] ||
143
+ scanPriority(a) - scanPriority(b) ||
121
144
  impactScore(b) - impactScore(a) ||
122
145
  a.namespace.localeCompare(b.namespace) ||
123
146
  a.name.localeCompare(b.name) ||
@@ -0,0 +1,175 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+ import type { RightsizingRow, RightsizingScanResponse } from '../../api/client'
4
+ import { FitWhy, ScanNotices, ScanSummary } from './RightsizingScanView'
5
+
6
+ const scan: RightsizingScanResponse = {
7
+ state: 'complete',
8
+ scannedAt: '2026-09-19T06:52:54Z',
9
+ window: '7d',
10
+ source: 'radar',
11
+ coverage: {
12
+ workloadsDiscovered: 120,
13
+ workloadsEvaluated: 120,
14
+ workloadsWithData: 47,
15
+ batches: 3,
16
+ completedBatches: 3,
17
+ },
18
+ workloads: [],
19
+ }
20
+
21
+ function render(result: RightsizingScanResponse) {
22
+ return renderToStaticMarkup(<ScanNotices result={result} />)
23
+ }
24
+
25
+ describe('rightsizing scan notices', () => {
26
+ it('keeps timeout coverage visible with query failures behind one collapsed disclosure', () => {
27
+ const html = render({
28
+ ...scan,
29
+ state: 'partial',
30
+ coverage: {
31
+ ...scan.coverage,
32
+ workloadsEvaluated: 100,
33
+ completedBatches: 0,
34
+ daemonSetsWithoutNodes: 27,
35
+ },
36
+ warnings: ['cpu_query_failed', 'memory_query_failed', 'scan_deadline_exceeded'].map(
37
+ (code) => ({ code, message: 'backend-specific details' }),
38
+ ),
39
+ })
40
+ expect(html.match(/<section/g)).toHaveLength(1)
41
+ expect(html.match(/<button/g)).toHaveLength(1)
42
+ expect(html).toContain('Partial results')
43
+ expect(html).not.toContain('workloads evaluated')
44
+ expect(html).toContain('select namespaces in the top bar')
45
+ expect(html).toContain('Available recommendations are shown')
46
+ expect(html).toContain('aria-expanded="false"')
47
+ expect(html).toContain('inert=""')
48
+ expect(html).toContain('Some CPU data could not be queried')
49
+ expect(html).toContain('Some memory data could not be queried')
50
+ expect(html).toContain('27 DaemonSets')
51
+ expect(html).not.toContain('completed batches')
52
+ expect(html).not.toContain('backend-specific details')
53
+ })
54
+
55
+ it('does not label a complete scan with excluded DaemonSets as a query failure', () => {
56
+ const html = render({
57
+ ...scan,
58
+ coverage: { ...scan.coverage, daemonSetsWithoutNodes: 1 },
59
+ })
60
+ expect(html).toContain('Scan notes')
61
+ expect(html).toContain('1 DaemonSet runs on no node right now and is not listed')
62
+ expect(html).not.toContain('<button')
63
+ expect(html).not.toContain('Partial results')
64
+ expect(html).not.toContain('time limit')
65
+ })
66
+
67
+ it('preserves access and cache limitations without claiming a timeout', () => {
68
+ const html = render({
69
+ ...scan,
70
+ state: 'partial',
71
+ coverage: {
72
+ ...scan.coverage,
73
+ restrictedKinds: ['Deployment'],
74
+ partiallyCachedKinds: ['StatefulSet'],
75
+ unavailableKinds: ['DaemonSet'],
76
+ },
77
+ })
78
+ expect(html).toContain('Kubernetes access')
79
+ expect(html).toContain('available ownership data')
80
+ expect(html).toContain('caching only some namespaces')
81
+ expect(html).not.toContain('time limit')
82
+ })
83
+
84
+ it('does not infer a timeout or short history from a query failure', () => {
85
+ const html = render({
86
+ ...scan,
87
+ state: 'partial',
88
+ warnings: [{ code: 'cpu_query_failed', message: 'unknown error' }],
89
+ })
90
+ expect(html).toContain('Some CPU data could not be queried')
91
+ expect(html).not.toContain('time limit')
92
+ expect(html).not.toContain('not enough recent history')
93
+ })
94
+
95
+ it('shows no notice for a clean scan and no empty Details button for deadline-only partial results', () => {
96
+ expect(render(scan)).toBe('')
97
+ const html = render({
98
+ ...scan,
99
+ state: 'partial',
100
+ warnings: [{ code: 'scan_deadline_exceeded', message: 'deadline' }],
101
+ })
102
+ expect(html).toContain('time limit')
103
+ expect(html).not.toContain('<button')
104
+ })
105
+ })
106
+
107
+ describe('rightsizing evidence explanation', () => {
108
+ const row: RightsizingRow = {
109
+ container: 'app',
110
+ resource: 'cpu',
111
+ fit: 'insufficient_history',
112
+ confidence: 'low',
113
+ coverage: 0,
114
+ sampleCount: 0,
115
+ expectedSamples: 2017,
116
+ hpaManaged: false,
117
+ hpaEvidenceAvailable: true,
118
+ oomEvidenceAvailable: false,
119
+ }
120
+
121
+ it('distinguishes failed queries from successfully measured short history', () => {
122
+ const failed = renderToStaticMarkup(
123
+ <FitWhy label="CPU" row={{ ...row, queryError: 'usage query failed' }} />,
124
+ )
125
+ expect(failed).toContain('Metrics could not be queried')
126
+ expect(failed).not.toContain('0.0 days')
127
+ expect(failed).not.toContain('Wait for more runtime history')
128
+ const short = renderToStaticMarkup(<FitWhy label="CPU" row={row} />)
129
+ expect(short).toContain('0.0 days')
130
+ expect(short).toContain('Wait for more runtime history')
131
+ })
132
+ })
133
+
134
+ describe('rightsizing scope and coverage summary', () => {
135
+ const counts = {
136
+ increase: 1,
137
+ reduction: 4,
138
+ review: 0,
139
+ in_range: 2,
140
+ need_data: 3,
141
+ }
142
+ it('separates attempted workloads from usage evidence and container counts', () => {
143
+ const html = renderToStaticMarkup(
144
+ <ScanSummary
145
+ namespaces={['dev', 'staging']}
146
+ result={{
147
+ ...scan,
148
+ coverage: { ...scan.coverage, workloadsEvaluated: 100 },
149
+ }}
150
+ counts={counts}
151
+ selected="actions"
152
+ onSelect={() => {}}
153
+ />,
154
+ )
155
+ expect(html).toContain('dev, staging')
156
+ expect(html).toContain('Change scan scope in the top bar')
157
+ expect(html).toContain('100 of 120 workloads attempted')
158
+ expect(html).toContain('47 with usage data')
159
+ expect(html).toContain('7d window')
160
+ expect(html).toContain('1 container<')
161
+ expect(html).not.toContain('workloads evaluated')
162
+ })
163
+ it('qualifies the all-namespace scope by visibility', () => {
164
+ const html = renderToStaticMarkup(
165
+ <ScanSummary
166
+ namespaces={[]}
167
+ result={scan}
168
+ counts={counts}
169
+ selected="actions"
170
+ onSelect={() => {}}
171
+ />,
172
+ )
173
+ expect(html).toContain('All visible namespaces')
174
+ })
175
+ })
@@ -13,9 +13,10 @@ interface MarkdownProps {
13
13
  * reference, say) into a control instead of an external link.
14
14
  */
15
15
  linkRenderer?: (href: string | undefined, children: ReactNode) => ReactNode | null
16
+ codeActions?: (code: string) => ReactNode
16
17
  }
17
18
 
18
- export function Markdown({ children, className, linkRenderer }: MarkdownProps) {
19
+ export function Markdown({ children, className, linkRenderer, codeActions }: MarkdownProps) {
19
20
  return (
20
21
  <div className={clsx('markdown-content', className)}>
21
22
  <ReactMarkdown
@@ -62,6 +63,15 @@ export function Markdown({ children, className, linkRenderer }: MarkdownProps) {
62
63
  code: ({ className, children }) => {
63
64
  const isInline = !className
64
65
  if (isInline) {
66
+ const actions = codeActions?.(String(children))
67
+ if (actions) {
68
+ return (
69
+ <span className="inline-flex max-w-full items-start gap-1 align-middle">
70
+ <code className="inline-code min-w-0">{children}</code>
71
+ {actions}
72
+ </span>
73
+ )
74
+ }
65
75
  return (
66
76
  <code className="inline-code">
67
77
  {children}
@@ -72,11 +82,28 @@ export function Markdown({ children, className, linkRenderer }: MarkdownProps) {
72
82
  <code className="block font-mono text-theme-text-secondary">{children}</code>
73
83
  )
74
84
  },
75
- pre: ({ children }) => (
76
- <pre className="my-3 p-3 bg-theme-elevated rounded-lg overflow-x-auto text-xs font-mono text-theme-text-secondary">
77
- {children}
78
- </pre>
79
- ),
85
+ pre: ({ children, node }) => {
86
+ const code = node?.children[0]
87
+ const text = code?.type === 'element'
88
+ ? code.children.filter(child => child.type === 'text').map(child => child.value).join('')
89
+ : ''
90
+ const actions = codeActions?.(text)
91
+ if (actions) {
92
+ // Render the code directly so an unlabelled fence does not also
93
+ // receive the inline-code action. Only the code scrolls, not Copy.
94
+ return (
95
+ <pre className="my-3 flex items-start gap-2 rounded-lg bg-theme-elevated p-3 text-xs font-mono text-theme-text-secondary">
96
+ <code className="min-w-0 flex-1 overflow-x-auto">{text}</code>
97
+ {actions}
98
+ </pre>
99
+ )
100
+ }
101
+ return (
102
+ <pre className="my-3 p-3 bg-theme-elevated rounded-lg overflow-x-auto text-xs font-mono text-theme-text-secondary">
103
+ {children}
104
+ </pre>
105
+ )
106
+ },
80
107
  blockquote: ({ children }) => (
81
108
  <blockquote className="border-l-2 border-theme-border pl-3 my-2 text-theme-text-tertiary italic">
82
109
  {children}