feat: accumulate and display assistant output during provisioning

- Added `provisioningOutputParts` to `ProvisioningRun` for accumulating assistant text during the provisioning phase.
- Updated `updateProgress` and `emitLogsProgress` functions to include the new `assistantOutput` in the progress state.
- Enhanced `ProvisioningProgressBlock` to display live assistant output using `MarkdownViewer`.
- Modified `TeamProvisioningBanner` to pass the new `assistantOutput` prop for rendering in the UI.
This commit is contained in:
iliya 2026-02-24 14:32:51 +02:00 committed by Илия
parent 1aa1410b38
commit 34ef846a5b
4 changed files with 55 additions and 42 deletions

View file

@ -128,6 +128,8 @@ interface ProvisioningRun {
* Flushed to liveLeadProcessMessages on result.success.
*/
directReplyParts: string[];
/** Accumulates assistant text during provisioning phase for live UI preview. */
provisioningOutputParts: string[];
}
type ProvisioningAuthSource =
@ -446,6 +448,10 @@ function updateProgress(
message: string,
extras?: Pick<TeamProvisioningProgress, 'pid' | 'error' | 'warnings' | 'cliLogsTail'>
): TeamProvisioningProgress {
const assistantOutput =
run.provisioningOutputParts.length > 0
? run.provisioningOutputParts.join('')
: run.progress.assistantOutput;
run.progress = {
...run.progress,
state,
@ -455,6 +461,7 @@ function updateProgress(
error: extras?.error,
warnings: extras?.warnings,
cliLogsTail: extras?.cliLogsTail ?? run.progress.cliLogsTail,
assistantOutput,
};
return run.progress;
}
@ -485,13 +492,17 @@ function extractLogsTail(stdoutBuffer: string, stderrBuffer: string): string | u
function emitLogsProgress(run: ProvisioningRun): void {
const logsTail = extractLogsTail(run.stdoutBuffer, run.stderrBuffer);
if (!logsTail) {
const assistantOutput =
run.provisioningOutputParts.length > 0 ? run.provisioningOutputParts.join('') : undefined;
if (!logsTail && !assistantOutput) {
return;
}
run.progress = {
...run.progress,
updatedAt: nowIso(),
cliLogsTail: logsTail,
...(logsTail !== undefined && { cliLogsTail: logsTail }),
...(assistantOutput !== undefined && { assistantOutput }),
};
run.onProgress(run.progress);
}
@ -702,6 +713,7 @@ export class TeamProvisioningService {
fsPhase: 'waiting_config',
leadRelayCapture: null,
directReplyParts: [],
provisioningOutputParts: [],
progress: {
runId,
teamName: request.teamName,
@ -972,6 +984,7 @@ export class TeamProvisioningService {
fsPhase: 'waiting_members',
leadRelayCapture: null,
directReplyParts: [],
provisioningOutputParts: [],
progress: {
runId,
teamName: request.teamName,
@ -1560,6 +1573,12 @@ export class TeamProvisioningService {
if (textParts.length > 0) {
const text = textParts.join('');
logger.debug(`[${run.teamName}] assistant: ${text.slice(0, 200)}`);
// During provisioning (before provisioningComplete), accumulate for live UI preview.
// Emission is handled by the throttled emitLogsProgress() in the stdout data handler.
if (!run.provisioningComplete) {
run.provisioningOutputParts.push(text);
}
if (run.leadRelayCapture) {
const capture = run.leadRelayCapture;
if (!capture.settled) {

View file

@ -1,11 +1,12 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { cn } from '@renderer/lib/utils';
import hljs from 'highlight.js';
import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react';
import { MarkdownViewer } from '../chat/viewers/MarkdownViewer';
import { STEP_LABELS, STEP_ORDER } from './provisioningSteps';
import type { ProvisioningStep } from './provisioningSteps';
@ -27,6 +28,8 @@ export interface ProvisioningProgressBlockProps {
pid?: number;
/** Tail of CLI logs */
cliLogsTail?: string;
/** Accumulated assistant text output for live preview */
assistantOutput?: string;
className?: string;
}
@ -59,32 +62,6 @@ function useElapsedTimer(startedAt?: string): string | null {
return elapsed;
}
function highlightLogsHtml(text: string): string {
return text
.split('\n')
.map((line) => {
const trimmed = line.trimStart();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
const parsed: unknown = JSON.parse(trimmed);
const pretty = JSON.stringify(parsed, null, 2);
return hljs.highlight(pretty, { language: 'json' }).value;
} catch {
return escapeHtml(line);
}
}
if (line === '[stdout]' || line === '[stderr]') {
return `<span class="hljs-comment">${escapeHtml(line)}</span>`;
}
return escapeHtml(line);
})
.join('\n');
}
function escapeHtml(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
export const ProvisioningProgressBlock = ({
title,
message,
@ -94,22 +71,28 @@ export const ProvisioningProgressBlock = ({
startedAt,
pid,
cliLogsTail,
assistantOutput,
className,
}: ProvisioningProgressBlockProps): React.JSX.Element => {
const elapsed = useElapsedTimer(startedAt);
const [logsOpen, setLogsOpen] = useState(false);
const logsRef = useRef<HTMLPreElement>(null);
const highlightedHtml = useMemo(
() => (cliLogsTail ? highlightLogsHtml(cliLogsTail) : ''),
[cliLogsTail]
);
const outputScrollRef = useRef<HTMLDivElement>(null);
// Auto-scroll CLI logs
useEffect(() => {
if (logsOpen && logsRef.current) {
logsRef.current.scrollTop = logsRef.current.scrollHeight;
}
}, [logsOpen, cliLogsTail]);
// Auto-scroll assistant output
useEffect(() => {
if (outputScrollRef.current) {
outputScrollRef.current.scrollTop = outputScrollRef.current.scrollHeight;
}
}, [assistantOutput]);
return (
<div
className={cn(
@ -174,6 +157,17 @@ export const ProvisioningProgressBlock = ({
);
})}
</div>
{assistantOutput ? (
<div className="mt-2">
<p className="mb-1 text-[11px] font-medium text-[var(--color-text-muted)]">Live output</p>
<div
ref={outputScrollRef}
className="max-h-[400px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2"
>
<MarkdownViewer content={assistantOutput} bare maxHeight="max-h-none" />
</div>
</div>
) : null}
{cliLogsTail ? (
<div className="mt-2">
<button
@ -182,18 +176,15 @@ export const ProvisioningProgressBlock = ({
onClick={() => setLogsOpen((v) => !v)}
>
{logsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
CLI output
CLI logs
</button>
{logsOpen ? (
<pre
ref={logsRef}
className="hljs mt-1 max-h-[400px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2 font-mono text-[11px] leading-relaxed text-[var(--color-text-secondary)]"
// Safe: highlightedHtml is built from hljs.highlight() which only produces
// hljs-* <span> tags, combined with escapeHtml() for non-JSON lines.
// Input is CLI stdout/stderr from a local process, not user-supplied web content.
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
/>
className="mt-1 max-h-[400px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2 font-mono text-[11px] leading-relaxed text-[var(--color-text-secondary)]"
>
{cliLogsTail}
</pre>
) : null}
</div>
) : null}

View file

@ -145,6 +145,7 @@ export const TeamProvisioningBanner = ({
startedAt={progress.startedAt}
pid={progress.pid}
cliLogsTail={progress.cliLogsTail}
assistantOutput={progress.assistantOutput}
onCancel={
canCancel
? () => {

View file

@ -262,6 +262,8 @@ export interface TeamProvisioningProgress {
error?: string;
warnings?: string[];
cliLogsTail?: string;
/** Accumulated assistant text output during provisioning (for live preview). */
assistantOutput?: string;
}
export interface GlobalTask extends TeamTaskWithKanban {