ux(chat): clean tool-pill results (no raw JSON, Failed/exit verbs); structured build-health status instead of 'didn't reach a clean stopping point'; label active toolbar mode

This commit is contained in:
2026-06-10 17:44:19 -07:00
parent 6fe774719a
commit a87faa2353
3 changed files with 191 additions and 20 deletions

View File

@@ -210,12 +210,22 @@ function RailLink({
aria-current={active ? "page" : undefined}
style={{
...linkBase,
// Active item shows its label so the user always knows which mode
// they're in; inactive items stay icon-only (with tooltip) to save
// space. This makes the editor's modes explicit instead of a row of
// unlabeled glyphs.
width: active ? "auto" : 36,
padding: active ? "0 12px" : 0,
gap: active ? 6 : 0,
fontSize: "0.8rem",
fontWeight: 600,
background: active ? "#f6f2ec" : "transparent",
color: active ? "#1a1a1a" : "#6b665e",
borderColor: active ? "#d9d2c5" : "transparent",
}}
>
<Icon size={16} />
{active && <span>{label}</span>}
</Link>
);
}

View File

@@ -230,6 +230,73 @@ function friendlyToolName(name: string): string {
return map[dotted] || dotted;
}
/**
* Turn a raw tool result string (often a JSON blob like
* `{ "code": 1, "stdout": "...", "stderr": "..." }`) into a short,
* human-readable status. We NEVER show raw JSON in the chat — only a
* clean verb + outcome (e.g. "Failed — exit 1: connection refused").
*/
function summarizeToolResult(result?: string): {
ok: boolean;
label: string;
} | null {
if (!result) return null;
const raw = String(result).trim();
if (!raw) return null;
let parsed: Record<string, unknown> | null = null;
try {
const p = JSON.parse(raw);
if (p && typeof p === "object") parsed = p as Record<string, unknown>;
} catch {
// not JSON — fall through to text heuristics
}
const firstLine = (s: string) => s.replace(/\s+/g, " ").trim().slice(0, 80);
if (parsed) {
const code = parsed.code;
const stderr = typeof parsed.stderr === "string" ? parsed.stderr : "";
const stdout = typeof parsed.stdout === "string" ? parsed.stdout : "";
const errMsg = typeof parsed.error === "string" ? parsed.error : "";
// Explicit non-zero exit code → failure
if (typeof code === "number" && code !== 0) {
const detail = firstLine(stderr || errMsg || stdout);
return {
ok: false,
label: detail
? `Failed (exit ${code}) — ${detail}`
: `Failed (exit ${code})`,
};
}
if (errMsg && !/^null$/i.test(errMsg)) {
return { ok: false, label: `Failed — ${firstLine(errMsg)}` };
}
if (parsed.ok === false) {
return {
ok: false,
label: `Failed${stderr ? " — " + firstLine(stderr) : ""}`,
};
}
// Success-ish: surface a tiny hint when available, else just "Done"
if (typeof code === "number" && code === 0)
return { ok: true, label: "Done" };
return { ok: true, label: "Done" };
}
// Plain-text heuristics
const lower = raw.toLowerCase();
if (
/(econnrefused|enoent|error|failed|traceback|exception|not found|permission denied|cannot)/.test(
lower,
)
) {
return { ok: false, label: `Failed — ${firstLine(raw)}` };
}
return { ok: true, label: "Done" };
}
// ── Markdown-lite renderer ────────────────────────────────────────────────────
function escapeHtmlAttr(s: string): string {
@@ -850,20 +917,26 @@ function TimelineToolGroup({
{!e.result && e.status === "running" && (
<span className="animate-pulse">...</span>
)}
{e.result && (
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
opacity: 0.7,
maxWidth: 150,
}}
title={e.result}
>
{e.result}
</span>
)}
{(() => {
// Render a clean, human summary of the outcome — never raw JSON.
const summary = summarizeToolResult(e.result);
if (!summary) return null;
return (
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: 220,
color: summary.ok ? "#6b8e6b" : "#c2554d",
opacity: 0.9,
}}
title={summary.label}
>
{summary.label}
</span>
);
})()}
</div>
))}
</div>