Skip to content

Commit 51eceb7

Browse files
abossardCopilot
andcommitted
feat: smart JSON rendering in chat + auto-continue button
- Add SmartMessageRenderer that detects JSON blocks in markdown and renders them as structured tables/cards instead of raw <pre><code> - Apply SmartMessageRenderer in ConversationPanel and RunConversationModal - Add 'Continue in Chat' button on AgentRunPage after a run completes - Add Playwright tests for both features (mocked, fast, deterministic) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1222a0b commit 51eceb7

5 files changed

Lines changed: 435 additions & 3 deletions

File tree

frontend/src/features/workbench/AgentRunPage.jsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,9 +152,13 @@ export default function AgentRunPage({ agent }) {
152152

153153
const parsedOutput = parseRunOutput(output)
154154

155+
// Find the latest completed/truncated run for this agent (for auto-continue)
156+
const latestCompletedRun = runs.find(r => r.status === 'completed' || r.status === 'truncated') || null
157+
155158
const handleRun = async () => {
156159
setError('')
157160
setOutput(null)
161+
setChatThread(null)
158162

159163
if (agent.requires_input && !requiredInput.trim()) {
160164
setError(`Required: ${agent.required_input_description || 'input value'}`)
@@ -245,6 +249,20 @@ export default function AgentRunPage({ agent }) {
245249
/>
246250
</div>
247251
)}
252+
253+
{/* Auto-continue: show "Continue in Chat" after a run completes */}
254+
{!chatThread && latestCompletedRun && (
255+
<div style={{ marginTop: tokens.spacingVerticalS, display: 'flex', gap: tokens.spacingHorizontalS }}>
256+
<Button
257+
appearance="primary"
258+
icon={<Chat24Regular />}
259+
onClick={() => handleContinueInChat(latestCompletedRun)}
260+
data-testid="agent-run-continue-chat"
261+
>
262+
Continue in Chat
263+
</Button>
264+
</div>
265+
)}
248266
</div>
249267
</div>
250268

frontend/src/features/workbench/ConversationPanel.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
import { useCallback, useEffect, useRef, useState } from 'react'
2525
import {
2626
MarkdownWidget,
27+
SmartMessageRenderer,
2728
StructuredOutputRenderer,
2829
} from './toolRenders'
2930

@@ -332,7 +333,7 @@ export default function ConversationPanel({
332333
if (msg.role === 'assistant') {
333334
return (
334335
<div key={msg.id} className={styles.assistantMessage} data-testid="chat-message-assistant">
335-
<MarkdownWidget content={msg.content} />
336+
<SmartMessageRenderer content={msg.content} schema={outputSchema} />
336337
</div>
337338
)
338339
}

frontend/src/features/workbench/RunConversationModal.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
3434
import { createThreadFromRun } from '../../services/api'
3535
import { parseRunOutput } from './outputUtils'
3636
import SchemaRenderer from './SchemaRenderer'
37-
import { MarkdownWidget } from './toolRenders'
37+
import { SmartMessageRenderer } from './toolRenders'
3838

3939
const useStyles = makeStyles({
4040
surface: {
@@ -495,7 +495,7 @@ export default function RunConversationModal({
495495
if (msg.role === 'assistant') {
496496
return (
497497
<div key={msg.id} className={styles.assistantMessage} data-testid="chat-message-assistant">
498-
<MarkdownWidget content={msg.content} />
498+
<SmartMessageRenderer content={msg.content} schema={outputSchema} />
499499
</div>
500500
)
501501
}

frontend/src/features/workbench/toolRenders.jsx

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,99 @@ export function StructuredOutputRenderer({ output, schema }) {
281281
)
282282
}
283283

284+
// ---------------------------------------------------------------------------
285+
// Smart message renderer — detects JSON blocks and renders them structurally
286+
// ---------------------------------------------------------------------------
287+
288+
const JSON_FENCE_RE = /```json\s*\n?([\s\S]*?)\n?```/gi
289+
290+
/**
291+
* Split a markdown string into alternating segments of prose and parsed JSON.
292+
* Pure calculation — no side effects.
293+
*
294+
* @param {string} content - Raw assistant message (markdown with optional JSON fences)
295+
* @returns {{ type: 'markdown'|'json', value: string|object }[]}
296+
*/
297+
function splitMarkdownAndJson(content) {
298+
if (!content || typeof content !== 'string') return []
299+
300+
// Try parsing the entire content as JSON first (no fences)
301+
const trimmed = content.trim()
302+
try {
303+
const parsed = JSON.parse(trimmed)
304+
if (typeof parsed === 'object' && parsed !== null) {
305+
return [{ type: 'json', value: parsed }]
306+
}
307+
} catch { /* not pure JSON */ }
308+
309+
const segments = []
310+
let lastIndex = 0
311+
let match
312+
313+
// Reset regex state
314+
JSON_FENCE_RE.lastIndex = 0
315+
316+
while ((match = JSON_FENCE_RE.exec(content)) !== null) {
317+
// Add markdown before this JSON block
318+
const before = content.slice(lastIndex, match.index).trim()
319+
if (before) segments.push({ type: 'markdown', value: before })
320+
321+
// Parse JSON block
322+
try {
323+
const parsed = JSON.parse(match[1].trim())
324+
if (typeof parsed === 'object' && parsed !== null) {
325+
segments.push({ type: 'json', value: parsed })
326+
} else {
327+
// Primitive JSON value — render as markdown
328+
segments.push({ type: 'markdown', value: match[0] })
329+
}
330+
} catch {
331+
// Invalid JSON — keep as markdown code block
332+
segments.push({ type: 'markdown', value: match[0] })
333+
}
334+
335+
lastIndex = match.index + match[0].length
336+
}
337+
338+
// Add remaining markdown after last JSON block
339+
const remaining = content.slice(lastIndex).trim()
340+
if (remaining) segments.push({ type: 'markdown', value: remaining })
341+
342+
return segments
343+
}
344+
345+
/**
346+
* Smart message renderer for assistant chat messages.
347+
* Detects JSON blocks in markdown and renders them with structured widgets
348+
* (tables, stat cards, badge lists) instead of raw <pre><code> blocks.
349+
* Falls back to plain MarkdownWidget when no JSON is detected.
350+
*/
351+
export function SmartMessageRenderer({ content, schema }) {
352+
const styles = useStyles()
353+
const segments = splitMarkdownAndJson(content)
354+
355+
// No JSON found — fast path: plain markdown
356+
if (segments.length === 0) {
357+
return <MarkdownWidget content={content} />
358+
}
359+
if (segments.length === 1 && segments[0].type === 'markdown') {
360+
return <MarkdownWidget content={segments[0].value} />
361+
}
362+
363+
return (
364+
<div className={styles.widgetContainer}>
365+
{segments.map((seg, i) => {
366+
if (seg.type === 'markdown') {
367+
return <MarkdownWidget key={i} content={seg.value} />
368+
}
369+
return (
370+
<StructuredOutputRenderer key={i} output={seg.value} schema={schema} />
371+
)
372+
})}
373+
</div>
374+
)
375+
}
376+
284377
/**
285378
* Build props for a specific widget from value and schema property.
286379
* Pure calculation.

0 commit comments

Comments
 (0)