AUDIT / src /components /workspace /WorkspaceOutputShelf.tsx
Arypulka98's picture
feat(audit): deploy full backend cluster node (part 2)
cc11e77 verified
Raw
History Blame Contribute Delete
25.3 kB
/**
* WorkspaceOutputShelf.tsx — S612: pannello output sotto l'editor
*
* Desktop: pannello resizable (react-resizable-panels) sotto l'editor.
* Mobile: shelf collassabile da bottom con CSS transition.
*
* Mostra le ultime azioni del task attivo (tool calls, file writes, ecc.)
* e l'ultimo run code snippet — senza duplicare TerminalPanel/xterm.
* "→ Terminale" / "→ Esegui" navigano ai tab dedicati per il full output.
*
* S614 — bug fix + polish:
* - usa selectActiveTask (evita triple subscription con tasks.find)
* - auto-scroll al fondo della activity list quando arrivano nuove azioni
* - MobileOutputShelf si auto-apre quando isStreaming passa da false → true
*/
import { useState, useEffect, useRef, useCallback, memo } from "react";
import { ChevronUp, ChevronDown, Terminal, Play, Zap, X } from "lucide-react";
import { useTaskStore, selectActiveTask } from "@/store/taskStore";
import { useChatStore } from "@/store/chatStore";
import { useWorkspaceStore } from "@/store/workspaceStore";
import { useAgentFileChanged } from "@/hooks/useAgentFileChanged"; // Fix D: watch mode
import { runVfsTsCheck } from "@/lib/vfsTsChecker"; // GAP-3: TS check
import type { VfsTsError } from "@/lib/vfsTsChecker";
// ─── Repair prompt formatter ──────────────────────────────────────────────────
// Produce un prompt conciso che l'agente può leggere e usare per correggere
// gli errori TypeScript. Max 8 errori + 4 warning per non eccedere il context.
function formatRepairPrompt(errors: VfsTsError[]): string {
const errs = errors.filter(e => e.sev === "error");
const warns = errors.filter(e => e.sev !== "error");
const header = errs.length > 0 && warns.length > 0
? `🔧 Correggi ${errs.length} errore/i TypeScript e ${warns.length} warning:`
: errs.length > 0
? `🔧 Correggi ${errs.length} errore/i TypeScript:`
: `🔧 Correggi ${warns.length} warning TypeScript:`;
const errLines = errs.slice(0, 8).map(e => `• ${e.file}:${e.line}:${e.col} TS${e.code}: ${e.message}`);
const warnLines = warns.slice(0, 4).map(e => `⚠ ${e.file}:${e.line}:${e.col} TS${e.code}: ${e.message}`);
const overflow = errs.length > 8 ? [`… e altri ${errs.length - 8} errori non mostrati`] : [];
return [header, "", ...errLines, ...overflow, ...(warns.length > 0 && errs.length < 8 ? warnLines : [])].join("\n");
}
// ─── Action icon map ──────────────────────────────────────────────────────────
const ACTION_ICONS: Record<string, string> = {
file_write: "✏️", file_read: "📖", file_open: "📂", file_delete: "🗑️",
search_web: "🔍", code_run: "▶️", tool_call: "🔧", api_call: "🌐",
thinking: "💭", plan: "📋", memory: "🧠",
};
function actionIcon(type: string): string {
return ACTION_ICONS[type] ?? "⚡";
}
function elapsedLabel(ms: number): string {
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${Math.floor(ms / 60000)}m`;
}
// ─── Tab type ─────────────────────────────────────────────────────────────────
type ShelfTab = "activity" | "code" | "errors";
// ─── Shelf content ────────────────────────────────────────────────────────────
function ShelfContent({
tab, onGoToTerminal, onGoToRun,
// S614: riceve activeTask come prop — un'unica subscription nel parent
activeTask, agentLiveStatus, isStreaming,
tsErrors, tsChecking, onRepairTs,
}: {
tab: ShelfTab;
onGoToTerminal: () => void;
onGoToRun: () => void;
activeTask: ReturnType<typeof selectActiveTask>;
agentLiveStatus: string;
isStreaming: boolean;
tsErrors: VfsTsError[];
tsChecking: boolean;
/** Fix D: callback che inietta il repair prompt nell'input bar via "agente:submit_input" */
onRepairTs?: () => void;
}) {
const runCode = useWorkspaceStore(s => s.runCode);
// S614: auto-scroll al fondo quando arrivano nuove azioni
const bottomRef = useRef<HTMLDivElement | null>(null);
const actions = activeTask?.actions ?? [];
useEffect(() => {
if (tab === "activity" && bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: "smooth", block: "end" });
}
}, [actions.length, tab]);
if (tab === "activity") {
const MAX_VISIBLE = 20;
const hiddenCount = Math.max(0, actions.length - MAX_VISIBLE);
const recentActions = actions.slice(-MAX_VISIBLE); // ultimi 20, ordine cronologico
return (
<div style={{ flex: 1, overflow: "auto", padding: "4px 0" }}>
{/* S621: indicatore azioni precedenti nascosti (truncation hint) */}
{hiddenCount > 0 && (
<div style={{
display: "flex", alignItems: "center", justifyContent: "center",
padding: "3px 10px",
borderBottom: "1px solid rgba(99,102,241,0.08)",
marginBottom: 2,
}}>
<span style={{ fontSize: "0.62rem", color: "#4a4a78", fontStyle: "italic" }}>
⋯ {hiddenCount} azioni precedenti
</span>
</div>
)}
{/* Live status */}
{isStreaming && agentLiveStatus && (
<div style={{
display: "flex", alignItems: "center", gap: 6,
padding: "4px 10px",
borderBottom: "1px solid rgba(99,102,241,0.10)",
marginBottom: 2,
}}>
<span style={{
width: 6, height: 6, borderRadius: "50%",
background: "#22c55e",
animation: "mem-tab-pulse 1s ease-in-out infinite",
flexShrink: 0,
}} />
<span style={{ fontSize: "0.7rem", color: "#86efac", fontStyle: "italic",
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{agentLiveStatus}
</span>
</div>
)}
{/* Actions log */}
{recentActions.length === 0 ? (
<p style={{ fontSize: "0.68rem", color: "#3a3a6a", padding: "10px 12px", lineHeight: 1.5 }}>
Nessuna attività. L'agente inizierà a registrare le azioni qui.
</p>
) : (
recentActions.map(action => (
<div key={action.id} style={{
display: "flex", alignItems: "center", gap: 6,
padding: "2px 10px",
opacity: action.done ? 0.6 : 1,
transition: "opacity 0.2s",
}}>
<span style={{ fontSize: "0.7rem", flexShrink: 0 }}>{actionIcon(action.type)}</span>
<span style={{
flex: 1, fontSize: "0.68rem",
color: action.done ? "#4a5568" : "#a0aec0",
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
}}>
{action.explanation ?? action.label}
</span>
{action.detail && (
<span style={{ fontSize: "0.62rem", color: "#3b82f6",
maxWidth: 100, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
flexShrink: 0 }}>
{action.detail}
</span>
)}
{action.durationMs !== undefined && (
<span style={{ fontSize: "0.6rem", color: "#2d3748", flexShrink: 0 }}>
{elapsedLabel(action.durationMs)}
</span>
)}
</div>
))
)}
{/* S614: sentinella per auto-scroll */}
<div ref={bottomRef} style={{ height: 1 }} />
{/* Full terminal link */}
<button
onClick={onGoToTerminal}
style={{
display: "flex", alignItems: "center", gap: 4,
margin: "6px 10px 4px",
background: "none", border: "none", cursor: "pointer",
color: "#4a4a78", fontSize: "0.62rem",
padding: "3px 0",
transition: "color 0.15s",
}}
onMouseEnter={e => { e.currentTarget.style.color = "#818cf8"; }}
onMouseLeave={e => { e.currentTarget.style.color = "#4a4a78"; }}
>
<Terminal size={10} />
Apri terminale completo →
</button>
</div>
);
}
// ─── Tab ERRORI — Fix D: TS errors live aggiornati dopo ogni file write ──
if (tab === "errors") {
return (
<div style={{ flex: 1, overflow: "auto", padding: "6px 10px" }}>
{tsChecking && (
<div style={{ display: "flex", alignItems: "center", gap: 6, padding: "4px 0", marginBottom: 4 }}>
<span style={{
width: 6, height: 6, borderRadius: "50%",
background: "#f59e0b",
animation: "mem-tab-pulse 1s ease-in-out infinite",
flexShrink: 0,
}} />
<span style={{ fontSize: "0.65rem", color: "#fbbf24", fontStyle: "italic" }}>
Controllo TypeScript…
</span>
</div>
)}
{onRepairTs && tsErrors.length > 0 && (
<button
onClick={onRepairTs}
disabled={isStreaming}
style={{
display: "flex", alignItems: "center", gap: 5,
margin: "6px 0 8px",
padding: "5px 12px",
background: isStreaming
? "rgba(99,102,241,0.06)"
: "rgba(99,102,241,0.14)",
border: "1px solid rgba(99,102,241,0.30)",
borderRadius: 8,
cursor: isStreaming ? "not-allowed" : "pointer",
color: isStreaming ? "#3a3a6a" : "#818cf8",
fontSize: "0.68rem", fontWeight: 600,
transition: "background 0.15s, color 0.15s",
opacity: isStreaming ? 0.5 : 1,
}}
onMouseEnter={e => { if (!isStreaming) e.currentTarget.style.background = "rgba(99,102,241,0.22)"; }}
onMouseLeave={e => { if (!isStreaming) e.currentTarget.style.background = "rgba(99,102,241,0.14)"; }}
title={isStreaming ? "Agente già in esecuzione — attendi il completamento" : `Invia ${tsErrors.length} errore/i all'agente per la correzione automatica`}
>
🔧
{isStreaming ? "Agente in esecuzione…" : `Correggi ${tsErrors.length > 9 ? "9+" : tsErrors.length} errore/i`}
</button>
)}
{!tsChecking && tsErrors.length === 0 ? (
<p style={{ fontSize: "0.68rem", color: "#22c55e", lineHeight: 1.5 }}>
✓ Nessun errore TypeScript rilevato.
</p>
) : (
tsErrors.map((err, i) => (
<div key={i} style={{
display: "flex", flexDirection: "column", gap: 1,
padding: "4px 0",
borderBottom: "1px solid rgba(99,102,241,0.07)",
}}>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{
fontSize: "0.6rem", fontWeight: 700,
color: err.sev === "error" ? "#f87171" : "#fbbf24",
flexShrink: 0,
}}>
{err.sev === "error" ? "ERR" : "WARN"}
</span>
<span style={{
fontSize: "0.6rem", color: "#4a4a78", fontFamily: "monospace",
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
}}>
{err.file.split("/").pop()}:{err.line}:{err.col}
</span>
<span style={{ fontSize: "0.55rem", color: "#3a3a5a", flexShrink: 0 }}>
TS{err.code}
</span>
</div>
<span style={{
fontSize: "0.67rem", color: "#9ca3af",
paddingLeft: 0,
overflow: "hidden", textOverflow: "ellipsis",
display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical",
}}>
{err.message}
</span>
</div>
))
)}
</div>
);
}
// tab === "code"
return (
<div style={{ flex: 1, overflow: "auto", padding: "6px 10px" }}>
{runCode ? (
<>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 4 }}>
<span style={{ fontSize: "0.62rem", color: "#4a4a78", fontFamily: "monospace" }}>
{runCode.filename}
</span>
<button
onClick={onGoToRun}
style={{
display: "flex", alignItems: "center", gap: 3,
background: "rgba(34,197,94,0.12)", border: "1px solid rgba(34,197,94,0.25)",
borderRadius: 8, padding: "2px 8px", cursor: "pointer",
color: "#4ade80", fontSize: "0.65rem", fontWeight: 600,
transition: "background 0.15s",
}}
onMouseEnter={e => { e.currentTarget.style.background = "rgba(34,197,94,0.22)"; }}
onMouseLeave={e => { e.currentTarget.style.background = "rgba(34,197,94,0.12)"; }}
>
<Play size={9} />
Esegui
</button>
</div>
<pre style={{
margin: 0, fontSize: "0.68rem", color: "#9ca3af",
fontFamily: '"JetBrains Mono", ui-monospace, monospace',
lineHeight: 1.5,
whiteSpace: "pre-wrap", wordBreak: "break-all",
maxHeight: 200, overflow: "auto",
}}>
{runCode.code.slice(0, 800)}
{runCode.code.length > 800 && "\n… (troncato)"}
</pre>
</>
) : (
<p style={{ fontSize: "0.68rem", color: "#3a3a6a", lineHeight: 1.5 }}>
Nessun codice in esecuzione. L'agente popolerà questo pannello quando eseguirà codice.
</p>
)}
</div>
);
}
// ─── Desktop shelf (embedded in PanelGroup) ───────────────────────────────────
export const WorkspaceOutputShelf = memo(function WorkspaceOutputShelf({
onGoToTerminal,
onGoToRun,
}: {
onGoToTerminal: () => void;
onGoToRun: () => void;
}) {
const [tab, setTab] = useState<ShelfTab>("activity");
// S614: una sola subscription per il task attivo
const activeTask = useTaskStore(selectActiveTask);
const isStreaming = useChatStore(s => s.isStreaming);
const agentLiveStatus= useChatStore(s => s.agentLiveStatus);
const runCode = useWorkspaceStore(s => s.runCode);
const activityCount = activeTask?.actions.length ?? 0;
const hasCode = !!runCode;
const setInput = useChatStore(s => s.setInput); // Fix D: repair bridge
// ─── Fix D: TS errors live — aggiornati dopo ogni file write (agent:file-changed) ──
const [tsErrors, setTsErrors] = useState<VfsTsError[]>([]);
const [tsChecking, setTsChecking] = useState(false);
const _mountedD = useRef(true);
useEffect(() => { _mountedD.current = true; return () => { _mountedD.current = false; }; }, []);
const _runTsCheck = useCallback(async () => {
setTsChecking(true);
try {
const errs = await runVfsTsCheck();
if (_mountedD.current) setTsErrors(errs);
} catch { /* non-fatal */ }
finally { if (_mountedD.current) setTsChecking(false); }
}, []);
// Debounce 600ms — leggermente più lento del debounce del hook (300ms) per
// aspettare che tutte le scritture batch siano complete prima del TS check.
useAgentFileChanged(useCallback(() => { void _runTsCheck(); }, [_runTsCheck]), 600);
// ─── Fix D: onRepairTs — invia errori TS all'agente via ChatInputBar bridge ──
const _onRepairTs = useCallback(() => {
if (!tsErrors.length) return;
setInput(formatRepairPrompt(tsErrors));
window.dispatchEvent(new CustomEvent("agente:submit_input"));
}, [tsErrors, setInput]);
return (
<div style={{
display: "flex", flexDirection: "column",
background: "rgba(4,5,16,0.97)",
borderTop: "1px solid rgba(99,102,241,0.18)",
overflow: "hidden", height: "100%",
}}>
{/* Header + tabs */}
<div style={{
display: "flex", alignItems: "center", gap: 0,
borderBottom: "1px solid rgba(99,102,241,0.10)",
flexShrink: 0, height: 30,
}}>
{([
{ id: "activity" as ShelfTab, label: "ATTIVITÀ", badge: activityCount, badgeColor: isStreaming ? "#22c55e" : "#3b82f6" },
{ id: "code" as ShelfTab, label: "CODICE", badge: hasCode ? 1 : 0, badgeColor: "#3b82f6" },
{ id: "errors" as ShelfTab, label: "ERRORI", badge: tsErrors.length, badgeColor: tsErrors.some(e=>e.sev==="error") ? "#ef4444" : "#f59e0b" },
] as const).map(t => (
<button key={t.id} onClick={() => setTab(t.id)} style={{
display: "flex", alignItems: "center", gap: 4,
padding: "0 12px", height: "100%",
background: tab === t.id ? "rgba(99,102,241,0.08)" : "none",
border: "none", borderBottom: tab === t.id ? "2px solid #818cf8" : "2px solid transparent",
cursor: "pointer",
color: tab === t.id ? "#818cf8" : "#3a3a6a",
fontSize: "0.6rem", fontWeight: 700, letterSpacing: "0.06em",
transition: "color 0.15s, border-color 0.15s",
}}>
{t.id === "activity" ? <Zap size={10} /> : <Play size={10} />}
{t.label}
{t.badge > 0 && (
<span style={{
background: t.badgeColor,
color: "#fff", fontSize: "0.55rem", fontWeight: 700,
borderRadius: 4, padding: "0 3px", minWidth: 14, textAlign: "center",
}}>
{t.badge > 9 ? "9+" : t.badge}
</span>
)}
</button>
))}
{/* Right spacer + links */}
<div style={{ flex: 1 }} />
<button onClick={onGoToTerminal} title="Vai al terminale"
style={{ background: "none", border: "none", cursor: "pointer",
color: "#3a3a6a", padding: "0 8px", height: "100%", display: "flex",
alignItems: "center", transition: "color 0.15s" }}
onMouseEnter={e => { e.currentTarget.style.color = "#818cf8"; }}
onMouseLeave={e => { e.currentTarget.style.color = "#3a3a6a"; }}
>
<Terminal size={12} />
</button>
<button onClick={onGoToRun} title="Vai a Esegui"
style={{ background: "none", border: "none", cursor: "pointer",
color: "#3a3a6a", padding: "0 8px", height: "100%", display: "flex",
alignItems: "center", transition: "color 0.15s" }}
onMouseEnter={e => { e.currentTarget.style.color = "#22c55e"; }}
onMouseLeave={e => { e.currentTarget.style.color = "#3a3a6a"; }}
>
<Play size={12} />
</button>
</div>
{/* Content — S614: passa activeTask/isStreaming come props, evita triple subscription */}
<ShelfContent
tab={tab}
onGoToTerminal={onGoToTerminal}
onGoToRun={onGoToRun}
activeTask={activeTask}
agentLiveStatus={agentLiveStatus}
isStreaming={isStreaming}
tsErrors={tsErrors}
tsChecking={tsChecking}
onRepairTs={_onRepairTs}
/>
</div>
);
});
// ─── Mobile collapseable shelf ────────────────────────────────────────────────
export const MobileOutputShelf = memo(function MobileOutputShelf({
onGoToTerminal,
onGoToRun,
}: {
onGoToTerminal: () => void;
onGoToRun: () => void;
}) {
const [open, setOpen] = useState(false);
const [tab, setTab] = useState<ShelfTab>("activity");
// S614: una sola subscription
const activeTask = useTaskStore(selectActiveTask);
const isStreaming = useChatStore(s => s.isStreaming);
const agentLiveStatus= useChatStore(s => s.agentLiveStatus);
const actCount = activeTask?.actions.length ?? 0;
const setInputM = useChatStore(s => s.setInput); // Fix D: repair bridge
// ─── Fix D: TS errors live (condivide la stessa logica del desktop) ──────
const [tsErrors, setTsErrorsM] = useState<VfsTsError[]>([]);
const [tsCheckingM, setTsCheckingM] = useState(false);
const _mountedM = useRef(true);
useEffect(() => { _mountedM.current = true; return () => { _mountedM.current = false; }; }, []);
const _runTsCheckM = useCallback(async () => {
setTsCheckingM(true);
try {
const errs = await runVfsTsCheck();
if (_mountedM.current) setTsErrorsM(errs);
} catch { /* non-fatal */ }
finally { if (_mountedM.current) setTsCheckingM(false); }
}, []);
useAgentFileChanged(useCallback(() => { void _runTsCheckM(); }, [_runTsCheckM]), 600);
const _onRepairTsM = useCallback(() => {
if (!tsErrors.length) return;
setInputM(formatRepairPrompt(tsErrors));
window.dispatchEvent(new CustomEvent("agente:submit_input"));
}, [tsErrors, setInputM]);
// S614: auto-apre il pannello quando l'agente inizia a streammare
const prevStreaming = useRef(false);
useEffect(() => {
if (isStreaming && !prevStreaming.current) {
setOpen(true);
}
prevStreaming.current = isStreaming;
}, [isStreaming]);
return (
<div style={{
borderTop: "1px solid rgba(99,102,241,0.18)",
background: "rgba(4,5,16,0.97)",
flexShrink: 0,
transition: "max-height 0.25s cubic-bezier(0.4,0,0.2,1)",
maxHeight: open ? "40vh" : "30px",
overflow: "hidden",
display: "flex", flexDirection: "column",
}}>
{/* Toggle bar */}
<button
onClick={() => setOpen(v => !v)}
style={{
display: "flex", alignItems: "center", gap: 6,
padding: "0 12px", height: 30,
background: "none", border: "none", cursor: "pointer",
color: "#4a4a78", fontSize: "0.62rem", fontWeight: 700,
letterSpacing: "0.06em", width: "100%", textAlign: "left",
flexShrink: 0,
}}
>
<Zap size={10} />
ATTIVITÀ
{actCount > 0 && (
<span style={{
background: isStreaming ? "#22c55e" : "#3b82f6",
color: "#fff", fontSize: "0.55rem", fontWeight: 700,
borderRadius: 4, padding: "0 3px", minWidth: 14, textAlign: "center",
}}>
{actCount > 9 ? "9+" : actCount}
</span>
)}
<span style={{ flex: 1 }} />
{open ? <ChevronDown size={12} /> : <ChevronUp size={12} />}
</button>
{/* Shelf content when open */}
{open && (
<div style={{ display: "flex", flexDirection: "column", flex: 1, overflow: "hidden" }}>
{/* Tabs */}
<div style={{
display: "flex", borderBottom: "1px solid rgba(99,102,241,0.10)",
flexShrink: 0,
}}>
{(["activity", "code", "errors"] as ShelfTab[]).map(t => (
<button key={t} onClick={() => setTab(t)} style={{
padding: "4px 12px", background: "none",
border: "none", borderBottom: tab === t ? "2px solid #818cf8" : "2px solid transparent",
cursor: "pointer", color: tab === t ? "#818cf8" : "#3a3a6a",
fontSize: "0.6rem", fontWeight: 700, letterSpacing: "0.05em",
display: "flex", alignItems: "center", gap: 4,
}}>
{t === "activity" ? "ATTIVITÀ" : t === "code" ? "CODICE" : "ERRORI"}
{t === "errors" && tsErrors.length > 0 && (
<span style={{
background: tsErrors.some(e=>e.sev==="error") ? "#ef4444" : "#f59e0b",
color: "#fff", fontSize: "0.52rem", fontWeight: 700,
borderRadius: 4, padding: "0 3px", minWidth: 14, textAlign: "center",
}}>
{tsErrors.length > 9 ? "9+" : tsErrors.length}
</span>
)}
</button>
))}
<div style={{ flex: 1 }} />
<button onClick={() => setOpen(false)} style={{
background: "none", border: "none", cursor: "pointer",
color: "#3a3a6a", padding: "0 10px",
}}>
<X size={12} />
</button>
</div>
<ShelfContent
tab={tab}
onGoToTerminal={() => { onGoToTerminal(); setOpen(false); }}
onGoToRun={() => { onGoToRun(); setOpen(false); }}
activeTask={activeTask}
agentLiveStatus={agentLiveStatus}
isStreaming={isStreaming}
tsErrors={tsErrors}
tsChecking={tsCheckingM}
onRepairTs={_onRepairTsM}
/>
</div>
)}
</div>
);
});