From a87faa2353e88285947dfb2ad46c3f272361def1 Mon Sep 17 00:00:00 2001 From: mawkone Date: Wed, 10 Jun 2026 17:44:19 -0700 Subject: [PATCH] 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 --- vibn-frontend/app/api/chat/route.ts | 100 +++++++++++++++-- .../components/project/project-icon-rail.tsx | 10 ++ .../components/vibn-chat/chat-panel.tsx | 101 +++++++++++++++--- 3 files changed, 191 insertions(+), 20 deletions(-) diff --git a/vibn-frontend/app/api/chat/route.ts b/vibn-frontend/app/api/chat/route.ts index 954da1b9..60276393 100644 --- a/vibn-frontend/app/api/chat/route.ts +++ b/vibn-frontend/app/api/chat/route.ts @@ -579,6 +579,83 @@ function lastToolResultsHadFailure(messages: ChatMessage[], lookback = 3) { return false; } +// Pull a short, human-readable error out of the most recent failing tool +// result so the build-health status can say WHAT broke (not just "didn't +// reach a clean stopping point"). Secrets are already redacted upstream. +function extractLastToolFailure( + messages: ChatMessage[], + lookback = 4, +): string | null { + const toolMsgs = messages.filter((m) => m.role === "tool").slice(-lookback); + const clean = (s: string) => s.replace(/\s+/g, " ").trim().slice(0, 160); + for (let i = toolMsgs.length - 1; i >= 0; i--) { + const raw = + typeof toolMsgs[i].content === "string" + ? (toolMsgs[i].content as string) + : ""; + if (!raw) continue; + try { + const p = JSON.parse(raw); + if (typeof p.error === "string" && p.error.trim()) return clean(p.error); + if (typeof p.exitCode === "number" && p.exitCode !== 0) + return clean( + `${p.stderr || p.stdout || "command failed"} (exit ${p.exitCode})`, + ); + if (typeof p.code === "number" && p.code !== 0) + return clean( + `${p.stderr || p.stdout || "command failed"} (exit ${p.code})`, + ); + if (p.healthCheck?.status && p.healthCheck.status >= 400) + return clean(`health check returned ${p.healthCheck.status}`); + if (p.ok === false && typeof p.message === "string") + return clean(p.message); + } catch { + if (/(econnrefused|enoent|error|failed|exception)/i.test(raw)) + return clean(raw); + } + } + return null; +} + +// Deterministic, STRUCTURED build-health status used when the model's own +// wrap-up comes back empty. Replaces the old vague "didn't reach a clean +// stopping point" line with: what happened + the specific blocker + a clear +// next action. +function buildHealthStatus(opts: { + loopBreakReason?: string | null; + hitRoundCap: boolean; + lastError: string | null; + toolCount: number; +}): string { + const { loopBreakReason, hitRoundCap, lastError, toolCount } = opts; + + if (lastError) { + return ( + `I ran ${toolCount} step${toolCount === 1 ? "" : "s"} but hit a blocker: ` + + `**${lastError}**. I didn't want to claim success on top of that. ` + + `Want me to fix that specific issue and retry?` + ); + } + if (loopBreakReason) { + return ( + `I kept hitting the same wall while working on this (${loopBreakReason}), ` + + `so I stopped rather than spin. Want me to try a different approach, ` + + `or take a look together?` + ); + } + if (hitRoundCap) { + return ( + `I made progress across ${toolCount} step${toolCount === 1 ? "" : "s"} but ran out ` + + `of room this turn before finishing. Say "continue" and I'll pick up ` + + `exactly where I left off.` + ); + } + return ( + `I worked through ${toolCount} step${toolCount === 1 ? "" : "s"} but didn't land a ` + + `clean result. Want me to keep going, or take a different angle?` + ); +} + export async function POST(request: Request) { await ensureChatTables(); @@ -1374,11 +1451,17 @@ export async function POST(request: Request) { assistantTextSegments.push(summary.text); emit({ type: "text", text: summary.text }); } else { - // Gemini returned empty — fall back to a deterministic - // status so the user never sees silent ✓ pills. - const fallback = loopBreakReason - ? `I hit a loop while working on this — ${loopBreakReason}. Want me to try a different approach, or do you want to take a look?` - : `I ran a chain of ${assistantToolCalls.length} tool calls but didn't reach a clean stopping point. Want me to keep going, or take a different angle?`; + // Gemini returned empty — fall back to a deterministic but + // STRUCTURED build-health status (never a vague "didn't reach a + // clean stopping point"). It states what happened, what broke, + // and the next action, using the same signals as the telemetry + // stop_reason. + const fallback = buildHealthStatus({ + loopBreakReason, + hitRoundCap: maxToolRounds > 0 && round >= maxToolRounds, + lastError: extractLastToolFailure(messages), + toolCount: assistantToolCalls.length, + }); assistantText += (assistantText ? "\n\n" : "") + fallback; assistantTextSegments.push(fallback); emit({ type: "text", text: fallback }); @@ -1387,7 +1470,12 @@ export async function POST(request: Request) { emit({ type: "thinking", text: summary.thoughts }); } } catch { - const fallback = `I ran ${assistantToolCalls.length} tool calls but the wrap-up failed. Want me to retry, or try a different approach?`; + const fallback = buildHealthStatus({ + loopBreakReason, + hitRoundCap: maxToolRounds > 0 && round >= maxToolRounds, + lastError: extractLastToolFailure(messages), + toolCount: assistantToolCalls.length, + }); assistantText += (assistantText ? "\n\n" : "") + fallback; assistantTextSegments.push(fallback); emit({ type: "text", text: fallback }); diff --git a/vibn-frontend/components/project/project-icon-rail.tsx b/vibn-frontend/components/project/project-icon-rail.tsx index 64f79aa1..0f8a608d 100644 --- a/vibn-frontend/components/project/project-icon-rail.tsx +++ b/vibn-frontend/components/project/project-icon-rail.tsx @@ -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", }} > + {active && {label}} ); } diff --git a/vibn-frontend/components/vibn-chat/chat-panel.tsx b/vibn-frontend/components/vibn-chat/chat-panel.tsx index 07b7c22b..e0b1a765 100644 --- a/vibn-frontend/components/vibn-chat/chat-panel.tsx +++ b/vibn-frontend/components/vibn-chat/chat-panel.tsx @@ -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 | null = null; + try { + const p = JSON.parse(raw); + if (p && typeof p === "object") parsed = p as Record; + } 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" && ( ... )} - {e.result && ( - - — {e.result} - - )} + {(() => { + // Render a clean, human summary of the outcome — never raw JSON. + const summary = summarizeToolResult(e.result); + if (!summary) return null; + return ( + + — {summary.label} + + ); + })()} ))}