tfrere's picture
tfrere HF Staff
feat(agent): add show_preview tool, auto-focus edited files, robust interrupt on resend
d9f7bbf
Raw
History Blame Contribute Delete
14.1 kB
import type { UIMessage } from "ai";
import { useEffect, useRef } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { deriveAgentStatus, type AgentStatus } from "../utils/agent-status";
function InlineThinking({ status }: { status: AgentStatus }) {
return (
<div className="chat-thinking" role="status" aria-live="polite">
<span className="chat-thinking-dots" aria-hidden="true">
<span />
<span />
<span />
</span>
<span className="chat-thinking-label">{status.label}</span>
{status.detail && (
<span className="chat-thinking-detail mono">· {status.detail}</span>
)}
</div>
);
}
interface ChatPanelProps {
messages: UIMessage[];
isLoading: boolean;
input: string;
setInput: (v: string) => void;
onSend: (text: string) => void;
onStop: () => void;
error: Error | null;
mode: "agent" | "plan";
onModeChange: (mode: "agent" | "plan") => void;
autoFix?: {
attempts: number;
maxAttempts: number;
active: boolean;
stoppedReason?: "max-attempts" | "no-progress" | null;
};
}
const AUTOFIX_PREFIX = "[auto-fix attempt";
const AUTOFIX_ATTEMPT_RE = /^\[auto-fix attempt (\d+)\/(\d+)\]/;
function getAutoFixMeta(
m: UIMessage,
): { attempt: number; max: number } | null {
if (m.role !== "user") return null;
const first = (m.parts ?? []).find(
(p) => (p as { type: string }).type === "text",
) as { text?: string } | undefined;
const text = first?.text ?? "";
if (!text.startsWith(AUTOFIX_PREFIX)) return null;
const match = AUTOFIX_ATTEMPT_RE.exec(text);
if (!match) return { attempt: 0, max: 0 };
return { attempt: Number(match[1]), max: Number(match[2]) };
}
const EXAMPLE_PROMPTS = [
"Build the default template: HF login, buttons to move the head, and the WebRTC video stream.",
"Add a joystick on-screen that drives the head pose continuously while I drag it.",
"Make the antennas react to the robot mic volume using the Web Audio API.",
"Add a small 'dance' button that plays a scripted sequence of head poses for ~5 seconds.",
];
function renderMessagePart(
part: UIMessage["parts"][number],
idx: number,
): React.ReactNode {
const type = part.type;
if (type === "text") {
const text = (part as { text: string }).text;
if (!text.trim()) return null;
return (
<div key={idx} className="bubble-agent-text markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{text}</ReactMarkdown>
</div>
);
}
if (typeof type === "string" && type.startsWith("tool-")) {
const toolPart = part as {
type: string;
state?: string;
input?: Record<string, unknown>;
output?: unknown;
errorText?: string;
};
const toolName = type.slice("tool-".length);
const state = toolPart.state ?? "unknown";
const isDone = state === "output-available" || state === "result";
const isError = state === "output-error" || state === "error";
const isPending = !isDone && !isError;
const statusClass = isDone
? "status-done"
: isError
? "status-error"
: "status-pending";
const isStreaming = state === "input-streaming";
const detail = extractToolDetail(toolName, toolPart.input);
return (
<div key={idx} className={`bubble-tool ${statusClass}`}>
<div className="bubble-tool-header">
{isPending ? (
<span className="tool-spinner" aria-label="in progress" />
) : (
<span className="dot" />
)}
<span>{toolLabel(toolName)}</span>
{detail && (
<span className="mono bubble-tool-detail-inline">
· {detail}
{isStreaming ? "…" : ""}
</span>
)}
</div>
{toolPart.errorText && (
<div className="bubble-tool-detail">{toolPart.errorText}</div>
)}
</div>
);
}
return null;
}
function extractToolDetail(
toolName: string,
input: Record<string, unknown> | undefined,
): string {
if (!input) return "";
if (toolName === "write_file") {
const parts: string[] = [];
if (typeof input.path === "string" && input.path) parts.push(input.path);
if (typeof input.content === "string") {
parts.push(`${input.content.length.toLocaleString()} chars`);
}
return parts.join(" · ");
}
if (toolName === "edit_file") {
const parts: string[] = [];
if (typeof input.path === "string" && input.path) parts.push(input.path);
if (typeof input.new_string === "string") {
parts.push(`${input.new_string.length.toLocaleString()} chars`);
}
return parts.join(" · ");
}
if (
(toolName === "read_file" || toolName === "delete_file") &&
typeof input.path === "string"
) {
return input.path;
}
if (toolName === "read_skill_doc" && typeof input.topic === "string") {
return input.topic;
}
return "";
}
function toolLabel(name: string): string {
const map: Record<string, string> = {
write_file: "Writing file",
edit_file: "Editing file",
delete_file: "Deleting file",
read_file: "Reading file",
list_files: "Listing files",
read_skill_doc: "Loading skill chapter",
read_console_logs: "Reading console logs",
show_preview: "Opening preview",
};
return map[name] ?? name;
}
export function ChatPanel({
messages,
isLoading,
input,
setInput,
onSend,
onStop,
error,
mode,
onModeChange,
autoFix,
}: ChatPanelProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
}, [messages]);
const handleSubmit = (e?: React.FormEvent) => {
e?.preventDefault();
const text = input.trim();
// Having text always wins, even while the agent is still streaming:
// `onSend` interrupts the in-flight turn and queues ours. This is
// the v0/Claude/ChatGPT pattern - users expect their new message
// to supersede whatever the model was doing. No explicit stop
// required.
if (text) {
onSend(text);
return;
}
// Empty input + running agent -> the submit button acts as Stop.
if (isLoading) {
onStop();
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const showEmpty = messages.length === 0;
const agentStatus = deriveAgentStatus(messages, isLoading, autoFix ?? null);
const lastMessage = messages[messages.length - 1];
const planAwaitingApproval =
mode === "plan" &&
!isLoading &&
!!lastMessage &&
lastMessage.role === "assistant";
const handleApprovePlan = () => {
onModeChange("agent");
onSend("Approved - switch to Agent mode and execute the plan above.");
};
return (
<aside className="chat-panel">
<div className="chat-messages" ref={scrollRef}>
{showEmpty && (
<div className="chat-empty">
<p>
Describe the Reachy Mini app you want to build. The agent
produces <strong>tiny static demos</strong> - 3 files
(<code>index.html</code>, <code>main.js</code>,{" "}
<code>README.md</code>), no build step, no server, HF login
only. Pick a starting point below or write your own prompt.
</p>
<div className="chat-empty-examples">
{EXAMPLE_PROMPTS.map((p) => (
<button
key={p}
type="button"
className="chat-empty-example"
disabled={isLoading}
onClick={() => {
if (isLoading) return;
setInput("");
onSend(p);
}}
>
{p}
</button>
))}
</div>
</div>
)}
{(() => {
const seen = new Set<string>();
return messages.map((m, idx) => {
if (seen.has(m.id)) return null;
seen.add(m.id);
const autoFixMeta = getAutoFixMeta(m);
if (autoFixMeta) {
return (
<div key={m.id} className="autofix-divider" role="note">
<span className="autofix-divider-line" aria-hidden="true" />
<span className="autofix-divider-label">
Auto-fix
{autoFixMeta.max > 0 && (
<>
{" "}
<span className="mono">
attempt {autoFixMeta.attempt}/{autoFixMeta.max}
</span>
</>
)}
<span className="autofix-divider-hint">
· preview errors sent back to the agent
</span>
</span>
<span className="autofix-divider-line" aria-hidden="true" />
</div>
);
}
const isLast = idx === messages.length - 1;
const showInlineThinking =
isLoading && agentStatus && isLast && m.role === "assistant";
return m.role === "user" ? (
<div key={m.id} className="bubble-user">
{m.parts
.map((p) => ((p as { type: string }).type === "text" ? (p as { text: string }).text : ""))
.join("")}
</div>
) : (
<div key={m.id} className="bubble-agent">
{m.parts.map((p, i) => renderMessagePart(p, i))}
{showInlineThinking && (
<InlineThinking status={agentStatus} />
)}
</div>
);
});
})()}
{isLoading &&
agentStatus &&
(messages.length === 0 ||
messages[messages.length - 1].role !== "assistant") && (
<div className="bubble-agent bubble-agent-placeholder">
<InlineThinking status={agentStatus} />
</div>
)}
{autoFix && !autoFix.active && autoFix.stoppedReason && (
<div className="autofix-note">
{autoFix.stoppedReason === "max-attempts"
? `Stopped after ${autoFix.maxAttempts} auto-fix attempts. Retry or rephrase.`
: "Auto-fix stopped: no progress between attempts. Retry or rephrase."}
</div>
)}
{error && (
<div className="signin-error" style={{ alignSelf: "stretch" }}>
{error.message}
</div>
)}
</div>
{planAwaitingApproval && (
<div className="plan-approval" role="status">
<div className="plan-approval-text">
<strong>Plan mode.</strong> Review the plan above. When you're ready,
switch to <em>Agent mode</em> to execute it.
</div>
<div className="plan-approval-actions">
<button
type="button"
className="btn btn-primary plan-approval-cta"
onClick={handleApprovePlan}
disabled={isLoading}
>
Switch to Agent &amp; execute
</button>
<button
type="button"
className="btn btn-ghost plan-approval-secondary"
onClick={() => onModeChange("agent")}
disabled={isLoading}
title="Switch to Agent mode without sending a message"
>
Switch only
</button>
</div>
</div>
)}
<form className="chat-composer" onSubmit={handleSubmit}>
<div className="chat-composer-field">
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
mode === "plan"
? "Describe what to design - the agent won't edit files…"
: "Describe what to build or change…"
}
rows={1}
/>
{(() => {
const hasText = input.trim().length > 0;
const showStop = isLoading && !hasText;
return (
<button
type="submit"
className="chat-send"
disabled={!isLoading && !hasText}
title={
showStop
? "Stop"
: isLoading
? "Interrupt and send"
: "Send"
}
>
{showStop ? "■" : "↑"}
</button>
);
})()}
</div>
<div className="chat-composer-toolbar">
<div className="mode-toggle" role="tablist" aria-label="Agent mode">
<button
type="button"
role="tab"
aria-selected={mode === "agent"}
className={`mode-tab ${mode === "agent" ? "active" : ""}`}
onClick={() => onModeChange("agent")}
disabled={isLoading}
title="Agent mode: the agent writes and edits files"
>
Agent
</button>
<button
type="button"
role="tab"
aria-selected={mode === "plan"}
className={`mode-tab ${mode === "plan" ? "active" : ""}`}
onClick={() => onModeChange("plan")}
disabled={isLoading}
title="Plan mode: read-only, the agent drafts a plan in Markdown"
>
Plan
</button>
</div>
<div className="chat-status" aria-live="polite">
<span>Enter to send · Shift+Enter for newline</span>
</div>
</div>
</form>
</aside>
);
}