diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts
index 954da1b9..60276393 100644
--- a/app/api/chat/route.ts
+++ b/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/components/project/project-icon-rail.tsx b/components/project/project-icon-rail.tsx
index 64f79aa1..0f8a608d 100644
--- a/components/project/project-icon-rail.tsx
+++ b/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/components/vibn-chat/chat-panel.tsx b/components/vibn-chat/chat-panel.tsx
index 07b7c22b..e0b1a765 100644
--- a/components/vibn-chat/chat-panel.tsx
+++ b/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}
+
+ );
+ })()}
))}