feat(editor): per-target compilation console — a section per board/chip

Phase 4 of the run-system work. The compile console now groups output into a
section per run target (board or chip) with a status glyph and label, the way
multiple Arduinos already stream — instead of one flat list.

- CompilationLog gains an optional target { id, label, kind: 'board'|'chip' }.
  message/type are unchanged so the pro overlay (diagnose-with-AI prompt +
  errorCount slot) and the console's length-based clear/auto-error heuristics
  are untouched. parseCompileResult stamps the target on every produced line.
- Producers stamp their lines: compileAllBoards (per-board, dropping the old
  '<label>: ' string prefix the header now carries), prepareCustomChips
  (per-chip, WASM + ROM), handleCompile + handleRun MicroPython (single board) —
  including the Pi / MicroPython / FQBN / error paths so a target's lines never
  fragment across sections.
- CompilationConsole groups filteredLogs into consecutive-run sections at RENDER
  time only (the flat array is unchanged); each target section shows ✓/✕/▸ +
  name + kind tag, with no-target lines ('Compiling all targets', 'Done') as
  plain narration around them.

Reviewed by an adversarial pass; the flagged un-stamped edge paths (Pi /
MicroPython / single-board errors) are now stamped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
David Montero 2026-06-04 07:56:54 +02:00
parent 6ae1ed560d
commit dd322b27e3
3 changed files with 200 additions and 120 deletions

View File

@ -5,7 +5,37 @@
import React, { useRef, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { CompilationLog } from '../../utils/compilationLogger';
import type { CompilationLog, CompileTarget } from '../../utils/compilationLogger';
/** One contiguous run of log lines for the same target (or none). */
interface LogGroup {
target?: CompileTarget;
entries: { log: CompilationLog; index: number }[];
}
/** Group consecutive log lines by target so the console renders a section per
* board/chip. Consecutive-run (not collapse-all) preserves chronological order
* a leading "Compiling all targets" and a trailing "Done" stay where they
* are, around the per-target sections. */
function groupLogs(logs: CompilationLog[]): LogGroup[] {
const groups: LogGroup[] = [];
logs.forEach((log, index) => {
const last = groups[groups.length - 1];
if (last && (last.target?.id ?? null) === (log.target?.id ?? null)) {
last.entries.push({ log, index });
} else {
groups.push({ target: log.target, entries: [{ log, index }] });
}
});
return groups;
}
/** A target section's overall outcome, derived from its lines. */
function groupStatus(entries: LogGroup['entries']): 'error' | 'success' | 'running' {
if (entries.some((e) => e.log.type === 'error')) return 'error';
if (entries.some((e) => e.log.type === 'success')) return 'success';
return 'running';
}
interface CompilationConsoleProps {
isOpen: boolean;
@ -154,33 +184,65 @@ export const CompilationConsole: React.FC<CompilationConsoleProps> = ({
</div>
</div>
{/* Output content */}
{/* Output content — grouped into a section per board/chip target. */}
<div ref={outputRef} style={styles.output}>
{filteredLogs.length === 0 ? (
<div style={styles.emptyState}>{t('editor.console.empty')}</div>
) : (
filteredLogs.map((log, i) => (
<div key={i} style={styles.logLine}>
<span style={styles.timestamp}>
{log.timestamp.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</span>
<span style={{ ...styles.logMessage, color: logColor(log.type) }}>
{log.type === 'core-install' && <span style={styles.coreTag}>CORE </span>}
{log.message}
</span>
</div>
))
groupLogs(filteredLogs).map((group, gi) =>
group.target ? (
<div key={`g-${gi}`} style={styles.targetGroup}>
<div style={styles.targetHeader}>
<span
style={{ ...styles.targetStatus, color: statusColor(groupStatus(group.entries)) }}
>
{statusGlyph(groupStatus(group.entries))}
</span>
<span style={styles.targetLabel}>{group.target.label}</span>
<span style={styles.targetKind}>{group.target.kind}</span>
</div>
<div style={styles.targetBody}>
{group.entries.map(({ log, index }) => (
<LogLine key={index} log={log} />
))}
</div>
</div>
) : (
// No target — plain narration ("Compiling all targets...", "Done").
group.entries.map(({ log, index }) => <LogLine key={index} log={log} />)
),
)
)}
</div>
</div>
);
};
const LogLine: React.FC<{ log: CompilationLog }> = ({ log }) => (
<div style={styles.logLine}>
<span style={styles.timestamp}>
{log.timestamp.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</span>
<span style={{ ...styles.logMessage, color: logColor(log.type) }}>
{log.type === 'core-install' && <span style={styles.coreTag}>CORE </span>}
{log.message}
</span>
</div>
);
function statusColor(status: 'error' | 'success' | 'running'): string {
return status === 'error' ? '#ef5350' : status === 'success' ? '#66bb6a' : '#9aa0a6';
}
function statusGlyph(status: 'error' | 'success' | 'running'): string {
return status === 'error' ? '✕' : status === 'success' ? '✓' : '▸';
}
function logColor(type: CompilationLog['type']): string {
switch (type) {
case 'error':
@ -301,6 +363,40 @@ const styles: Record<string, React.CSSProperties> = {
padding: '12px 0',
fontFamily: 'system-ui, sans-serif',
},
targetGroup: {
marginTop: 6,
borderLeft: '2px solid #3a3a3a',
paddingLeft: 8,
},
targetHeader: {
display: 'flex',
alignItems: 'center',
gap: 7,
padding: '1px 0 2px',
},
targetStatus: {
fontWeight: 700,
flexShrink: 0,
},
targetLabel: {
color: '#e0e0e0',
fontWeight: 700,
fontSize: 11.5,
fontFamily: 'system-ui, sans-serif',
},
targetKind: {
color: '#777',
fontSize: 9,
fontFamily: 'system-ui, sans-serif',
textTransform: 'uppercase' as const,
letterSpacing: '0.5px',
border: '1px solid #3a3a3a',
borderRadius: 3,
padding: '0 4px',
},
targetBody: {
paddingLeft: 4,
},
logLine: {
display: 'flex',
gap: 8,

View File

@ -25,7 +25,7 @@ import { useProjectStore } from '../../store/useProjectStore';
import { LibraryManagerModal } from '../simulator/LibraryManagerModal';
import { InstallLibrariesModal } from '../simulator/InstallLibrariesModal';
import { parseCompileResult } from '../../utils/compilationLogger';
import type { CompilationLog } from '../../utils/compilationLogger';
import type { CompilationLog, CompileTarget } from '../../utils/compilationLogger';
import { exportToWokwiZip } from '../../utils/wokwiZip';
import { importProjectFile, PROJECT_FILE_ACCEPT } from '../../utils/importProject';
import { readFirmwareFile } from '../../utils/firmwareLoader';
@ -287,38 +287,33 @@ export const EditorToolbar = ({
const sourceC = String(props.sourceC ?? '');
const chipJson = String(props.chipJson ?? '{}');
let changed = false;
// Stamp every line for this chip with its target so the console groups
// it under its own section (alongside the boards).
const chipTarget: CompileTarget = { id: chip.id, label: chipLabel, kind: 'chip' };
const clog = (type: CompilationLog['type'], message: string) =>
addLog({ timestamp: new Date(), type, message, target: chipTarget });
// 1. C -> WASM. Only when missing — the chip designer fills this too.
if (!String(props.wasmBase64 ?? '') && sourceC) {
addLog({
timestamp: new Date(),
type: 'info',
message: `Compiling chip "${chipLabel}" to WASM...`,
});
clog('info', `Compiling chip "${chipLabel}" to WASM...`);
try {
const r = await compileChip(sourceC, chipJson);
if (r.success && r.wasm_base64) {
props.wasmBase64 = r.wasm_base64;
changed = true;
addLog({
timestamp: new Date(),
type: 'success',
message: `Chip "${chipLabel}" compiled (${r.byte_size} B WASM).`,
});
clog('success', `Chip "${chipLabel}" compiled (${r.byte_size} B WASM).`);
} else {
addLog({
timestamp: new Date(),
type: 'error',
message: `Chip "${chipLabel}" WASM compile failed: ${r.error || r.stderr || 'unknown error'}`,
});
clog(
'error',
`Chip "${chipLabel}" WASM compile failed: ${r.error || r.stderr || 'unknown error'}`,
);
failed++;
}
} catch (e) {
addLog({
timestamp: new Date(),
type: 'error',
message: `Chip "${chipLabel}" WASM compile error: ${e instanceof Error ? e.message : String(e)}`,
});
clog(
'error',
`Chip "${chipLabel}" WASM compile error: ${e instanceof Error ? e.message : String(e)}`,
);
failed++;
}
}
@ -338,45 +333,34 @@ export const EditorToolbar = ({
chipGroupFiles.find((f) => f.name === programFile) ??
boardFiles.find((f) => f.name === programFile);
if (!file) {
addLog({
timestamp: new Date(),
type: 'error',
message: `Chip "${chipLabel}": program file "${programFile}" not found in the chip's files.`,
});
clog('error', `Chip "${chipLabel}": program file "${programFile}" not found in the chip's files.`);
failed++;
} else {
const target = targetForChip(chipJson);
const fmt = formatForFile(programFile);
addLog({
timestamp: new Date(),
type: 'info',
message: `Assembling "${programFile}" (target=${target}, format=${fmt}) for chip "${chipLabel}"...`,
});
clog(
'info',
`Assembling "${programFile}" (target=${target}, format=${fmt}) for chip "${chipLabel}"...`,
);
try {
const rr = await compileRom(file.content, target, fmt);
if (rr.success && rr.rom_base64) {
props.romBytes = rr.rom_base64;
props.programFile = programFile;
changed = true;
addLog({
timestamp: new Date(),
type: 'success',
message: `ROM ready: ${rr.byte_size} B injected into "${chipLabel}".`,
});
clog('success', `ROM ready: ${rr.byte_size} B injected into "${chipLabel}".`);
} else {
addLog({
timestamp: new Date(),
type: 'error',
message: `ROM compile failed for "${programFile}": ${rr.error || rr.stderr || 'unknown error'}`,
});
clog(
'error',
`ROM compile failed for "${programFile}": ${rr.error || rr.stderr || 'unknown error'}`,
);
failed++;
}
} catch (e) {
addLog({
timestamp: new Date(),
type: 'error',
message: `ROM compile error for "${programFile}": ${e instanceof Error ? e.message : String(e)}`,
});
clog(
'error',
`ROM compile error for "${programFile}": ${e instanceof Error ? e.message : String(e)}`,
);
failed++;
}
}
@ -432,14 +416,18 @@ export const EditorToolbar = ({
// ── End custom-chip preparation ─────────────────────────────────────
const kind = activeBoard?.boardKind;
// The active board's console target, defined up front so EVERY board path
// (Pi, MicroPython, arduino-cli, errors) groups its lines under one section.
const boardLabel = activeBoard ? boardDisplayName(activeBoard) : 'Unknown';
const boardTarget: CompileTarget | undefined = activeBoardId
? { id: activeBoardId, label: boardLabel, kind: 'board' }
: undefined;
const blog = (type: CompilationLog['type'], message: string) =>
addLog({ timestamp: new Date(), type, message, target: boardTarget });
// Raspberry Pi 3B doesn't need arduino-cli compilation
if (isPiBoardKind(kind)) {
addLog({
timestamp: new Date(),
type: 'info',
message: 'Raspberry Pi 3B: no compilation needed — run Python scripts directly.',
});
blog('info', 'Raspberry Pi 3B: no compilation needed — run Python scripts directly.');
setMessage({ type: 'success', text: 'Ready (no compilation needed)' });
setCompiling(false);
return;
@ -447,24 +435,16 @@ export const EditorToolbar = ({
// MicroPython mode — no backend compilation needed
if (activeBoard?.languageMode === 'micropython' && activeBoardId) {
addLog({
timestamp: new Date(),
type: 'info',
message: 'MicroPython: loading firmware and user files...',
});
blog('info', 'MicroPython: loading firmware and user files...');
try {
const groupFiles = useEditorStore.getState().getGroupFiles(activeBoard.activeFileGroupId);
const pyFiles = groupFiles.map((f) => ({ name: f.name, content: f.content }));
await loadMicroPythonProgram(activeBoardId, pyFiles);
addLog({
timestamp: new Date(),
type: 'success',
message: 'MicroPython firmware loaded successfully',
});
blog('success', 'MicroPython firmware loaded successfully');
setMessage({ type: 'success', text: 'MicroPython ready' });
} catch (err) {
const errMsg = err instanceof Error ? err.message : 'Failed to load MicroPython';
addLog({ timestamp: new Date(), type: 'error', message: errMsg });
blog('error', errMsg);
setMessage({ type: 'error', text: errMsg });
} finally {
setCompiling(false);
@ -473,20 +453,15 @@ export const EditorToolbar = ({
}
const fqbn = kind ? BOARD_KIND_FQBN[kind] : null;
const boardLabel = activeBoard ? boardDisplayName(activeBoard) : 'Unknown';
if (!fqbn) {
addLog({ timestamp: new Date(), type: 'error', message: `No FQBN for board kind: ${kind}` });
blog('error', `No FQBN for board kind: ${kind}`);
setMessage({ type: 'error', text: 'Unknown board' });
setCompiling(false);
return;
}
addLog({
timestamp: new Date(),
type: 'info',
message: `Starting compilation for ${boardLabel} (${fqbn})...`,
});
blog('info', `Starting compilation for ${boardLabel} (${fqbn})...`);
try {
const groupFiles = activeBoard?.activeFileGroupId
@ -524,6 +499,7 @@ export const EditorToolbar = ({
timestamp: now,
type: 'info' as const,
message: line,
target: boardTarget,
})),
]);
},
@ -539,7 +515,7 @@ export const EditorToolbar = ({
// the live stream — parseCompileResult highlights FAILED blocks and
// tags compiler errors with type='error', which the console uses for
// colour + the auto-switch-to-errors filter.
const resultLogs = parseCompileResult(result, boardLabel);
const resultLogs = parseCompileResult(result, boardLabel, boardTarget);
setCompileLogs((prev: CompilationLog[]) => [...prev, ...resultLogs]);
if (result.success) {
@ -571,7 +547,7 @@ export const EditorToolbar = ({
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : 'Compile failed';
addLog({ timestamp: new Date(), type: 'error', message: errMsg });
blog('error', errMsg);
setMessage({ type: 'error', text: errMsg });
} finally {
setCompiling(false);
@ -747,23 +723,22 @@ export const EditorToolbar = ({
setCompiling(true);
setMessage(null);
addLog({
timestamp: new Date(),
type: 'info',
message: 'MicroPython: loading firmware and user files...',
});
const mpyTarget: CompileTarget = {
id: activeBoardId,
label: boardDisplayName(board),
kind: 'board',
};
const mlog = (type: CompilationLog['type'], message: string) =>
addLog({ timestamp: new Date(), type, message, target: mpyTarget });
mlog('info', 'MicroPython: loading firmware and user files...');
try {
const groupFiles = useEditorStore.getState().getGroupFiles(board.activeFileGroupId);
const pyFiles = groupFiles.map((f) => ({ name: f.name, content: f.content }));
await loadMicroPythonProgram(activeBoardId, pyFiles);
addLog({
timestamp: new Date(),
type: 'success',
message: 'MicroPython firmware loaded',
});
mlog('success', 'MicroPython firmware loaded');
} catch (err) {
const errMsg = err instanceof Error ? err.message : 'Failed to load MicroPython';
addLog({ timestamp: new Date(), type: 'error', message: errMsg });
mlog('error', errMsg);
setMessage({ type: 'error', text: errMsg });
setCompiling(false);
return;
@ -955,29 +930,25 @@ export const EditorToolbar = ({
for (const board of boardsList) {
const label = boardDisplayName(board);
// Stamp this board's lines so the console groups them under its section.
const boardTarget: CompileTarget = { id: board.id, label, kind: 'board' };
const blog = (type: CompilationLog['type'], message: string) =>
addLog({ timestamp: new Date(), type, message, target: boardTarget });
if (isPiBoardKind(board.boardKind)) {
addLog({
timestamp: new Date(),
type: 'info',
message: `${label}: skipped (no compilation needed)`,
});
blog('info', 'skipped (no compilation needed)');
ok++;
continue;
}
const fqbn = BOARD_KIND_FQBN[board.boardKind];
if (!fqbn) {
addLog({
timestamp: new Date(),
type: 'error',
message: `${label}: no FQBN configured`,
});
blog('error', 'no FQBN configured');
boardFailed++;
continue;
}
addLog({ timestamp: new Date(), type: 'info', message: `${label}: compiling...` });
blog('info', 'compiling...');
try {
const groupFiles = useEditorStore.getState().getGroupFiles(board.activeFileGroupId);
@ -1000,17 +971,19 @@ export const EditorToolbar = ({
const now = new Date();
setCompileLogs((prev: CompilationLog[]) => [
...prev,
// No `${label}: ` prefix — the target section header carries it.
...newLines.map((line) => ({
timestamp: now,
type: 'info' as const,
message: `${label}: ${line}`,
message: line,
target: boardTarget,
})),
]);
},
{ boardOptions: board.boardOptions, spiffsFiles: board.spiffsFiles },
);
const resultLogs = parseCompileResult(result, label);
const resultLogs = parseCompileResult(result, label, boardTarget);
setCompileLogs((prev: CompilationLog[]) => [...prev, ...resultLogs]);
if (result.success) {
@ -1026,11 +999,7 @@ export const EditorToolbar = ({
boardFailed++;
}
} catch (err) {
addLog({
timestamp: new Date(),
type: 'error',
message: `${label}: ${err instanceof Error ? err.message : String(err)}`,
});
blog('error', err instanceof Error ? err.message : String(err));
boardFailed++;
}
}

View File

@ -4,13 +4,28 @@
import type { CompileResult } from '../services/compilation';
/** Which run target a log line belongs to lets the console group output by
* board / chip, the way multiple Arduinos already stream. Optional: lines with
* no target (e.g. "Compiling all targets...", "Done") render as plain
* narration. */
export interface CompileTarget {
id: string;
label: string;
kind: 'board' | 'chip';
}
export interface CompilationLog {
timestamp: Date;
type: 'info' | 'success' | 'error' | 'warning' | 'core-install';
message: string;
target?: CompileTarget;
}
export function parseCompileResult(result: CompileResult, board: string): CompilationLog[] {
export function parseCompileResult(
result: CompileResult,
board: string,
target?: CompileTarget,
): CompilationLog[] {
const logs: CompilationLog[] = [];
const now = new Date();
@ -88,5 +103,5 @@ export function parseCompileResult(result: CompileResult, board: string): Compil
logs.push({ timestamp: now, type: 'error', message: `${errorMsg}` });
}
return logs;
return target ? logs.map((l) => ({ ...l, target })) : logs;
}