page.tsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583
  1. "use client";
  2. import { useCallback, useRef, useState } from "react";
  3. import { useChat } from "@ai-sdk/react";
  4. import { DefaultChatTransport, type UIMessage } from "ai";
  5. import {
  6. SPEC_DATA_PART,
  7. SPEC_DATA_PART_TYPE,
  8. type SpecDataPart,
  9. } from "@json-render/core";
  10. import { useJsonRenderMessage } from "@json-render/react";
  11. import {
  12. ArrowUp,
  13. Bug,
  14. ChevronRight,
  15. FilePen,
  16. FilePlus,
  17. FileText,
  18. FolderGit2,
  19. FolderSearch,
  20. FolderTree,
  21. Gauge,
  22. Globe,
  23. Hammer,
  24. ListChecks,
  25. Loader2,
  26. Search,
  27. SquareChevronRight,
  28. Wrench,
  29. type LucideIcon,
  30. } from "lucide-react";
  31. import { Streamdown } from "streamdown";
  32. import { code } from "@streamdown/code";
  33. import { ReportRenderer } from "@/lib/render/renderer";
  34. import {
  35. AGENT_IDS,
  36. AGENTS,
  37. type AgentId,
  38. DEFAULT_AGENT_ID,
  39. } from "@/lib/agents";
  40. type AppDataParts = { [SPEC_DATA_PART]: SpecDataPart };
  41. type AppMessage = UIMessage<unknown, AppDataParts>;
  42. const transport = new DefaultChatTransport({ api: "/api/agent" });
  43. const SUGGESTIONS: Array<{
  44. label: string;
  45. description: string;
  46. icon: LucideIcon;
  47. prompt: string;
  48. }> = [
  49. {
  50. label: "Build & test a library",
  51. description: "Scaffold a TS package with vitest and run the suite.",
  52. icon: Hammer,
  53. prompt:
  54. "Scaffold a tiny TypeScript semver-parsing library with vitest tests, run the tests, and report the results.",
  55. },
  56. {
  57. label: "Fix failing code",
  58. description: "Plant a subtle bug, then debug it end to end.",
  59. icon: Bug,
  60. prompt:
  61. "Create a small JS module with a subtle off-by-one bug and a failing test, then diagnose and fix it like a real debugging session.",
  62. },
  63. {
  64. label: "Benchmark something",
  65. description: "Measure two approaches and chart the numbers.",
  66. icon: Gauge,
  67. prompt:
  68. "Write and run a quick benchmark comparing JSON.parse vs a streaming JSON parser on a 5MB file, and report the numbers as a bar chart.",
  69. },
  70. {
  71. label: "Explore a repo",
  72. description: "Clone a project and map out what each part does.",
  73. icon: FolderGit2,
  74. prompt:
  75. "Clone github.com/vercel-labs/json-render, explore the package structure, and report what each package does.",
  76. },
  77. ];
  78. /** Per-tool icon + readable [running, done] labels (labels used for a11y). */
  79. const TOOL_META: Record<
  80. string,
  81. { icon: LucideIcon; labels: [string, string] }
  82. > = {
  83. bash: {
  84. icon: SquareChevronRight,
  85. labels: ["Running command", "Ran command"],
  86. },
  87. read: { icon: FileText, labels: ["Reading file", "Read file"] },
  88. write: { icon: FilePlus, labels: ["Writing file", "Wrote file"] },
  89. edit: { icon: FilePen, labels: ["Editing file", "Edited file"] },
  90. grep: { icon: Search, labels: ["Searching code", "Searched code"] },
  91. glob: { icon: FolderSearch, labels: ["Listing files", "Listed files"] },
  92. ls: { icon: FolderTree, labels: ["Listing directory", "Listed directory"] },
  93. webSearch: { icon: Globe, labels: ["Searching the web", "Searched the web"] },
  94. WebFetch: { icon: Globe, labels: ["Fetching page", "Fetched page"] },
  95. TodoWrite: { icon: ListChecks, labels: ["Updating plan", "Updated plan"] },
  96. };
  97. /** Pull a one-line human hint out of a tool input (command, path, query). */
  98. function toolInputHint(input: unknown): string | null {
  99. if (input == null || typeof input !== "object") return null;
  100. const record = input as Record<string, unknown>;
  101. const hint =
  102. record.command ?? record.file_path ?? record.pattern ?? record.query;
  103. return typeof hint === "string" ? hint : null;
  104. }
  105. function ToolCallDisplay({
  106. toolName,
  107. state,
  108. input,
  109. output,
  110. }: {
  111. toolName: string;
  112. state: string;
  113. input: unknown;
  114. output: unknown;
  115. }) {
  116. const [expanded, setExpanded] = useState(false);
  117. const isLoading =
  118. state !== "output-available" &&
  119. state !== "output-error" &&
  120. state !== "output-denied";
  121. const meta = TOOL_META[toolName];
  122. const Icon = meta?.icon ?? Wrench;
  123. const label = meta ? meta.labels[isLoading ? 0 : 1] : toolName;
  124. const hint = toolInputHint(input);
  125. return (
  126. <div className="group rounded-lg border bg-card px-3 py-2 text-sm shadow-subtle">
  127. <button
  128. type="button"
  129. title={label}
  130. aria-label={label}
  131. className="flex w-full max-w-full items-center gap-2 text-left"
  132. onClick={() => setExpanded((e) => !e)}
  133. >
  134. <Icon
  135. className={`size-3.5 shrink-0 text-muted-foreground ${
  136. isLoading && !hint ? "animate-pulse" : ""
  137. }`}
  138. strokeWidth={1.75}
  139. />
  140. {hint && (
  141. <code
  142. className={`truncate font-mono text-xs text-muted-foreground/70 ${
  143. isLoading ? "animate-shimmer" : ""
  144. }`}
  145. >
  146. {hint}
  147. </code>
  148. )}
  149. {!isLoading && output != null && (
  150. <ChevronRight
  151. className={`ml-auto h-3.5 w-3.5 shrink-0 text-muted-foreground/40 transition-transform group-hover:text-muted-foreground ${expanded ? "rotate-90" : ""}`}
  152. />
  153. )}
  154. </button>
  155. {expanded && !isLoading && output != null && (
  156. <pre className="mt-2 max-h-64 overflow-auto border-t pt-2 text-xs text-muted-foreground whitespace-pre-wrap break-all">
  157. {typeof output === "string"
  158. ? output
  159. : JSON.stringify(output, null, 2)}
  160. </pre>
  161. )}
  162. </div>
  163. );
  164. }
  165. /**
  166. * Monochrome agent marks. Claude (svgl) and OpenAI (svgl) are single-path
  167. * brand glyphs forced to `currentColor`; Pi uses its namesake π since it has
  168. * no published logo. All inherit the surrounding text color.
  169. */
  170. type MarkProps = { className?: string };
  171. function ClaudeMark({ className }: MarkProps) {
  172. return (
  173. <svg viewBox="0 0 256 257" className={className} aria-hidden="true">
  174. <path
  175. fill="currentColor"
  176. d="m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z"
  177. />
  178. </svg>
  179. );
  180. }
  181. function OpenAIMark({ className }: MarkProps) {
  182. return (
  183. <svg viewBox="0 0 256 260" className={className} aria-hidden="true">
  184. <path
  185. fill="currentColor"
  186. d="M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z"
  187. />
  188. </svg>
  189. );
  190. }
  191. function PiMark({ className }: MarkProps) {
  192. // pi.dev/logo-auto.svg — blocky "Pi" mark. The viewBox is padded a little
  193. // past the mark's bounds so it reads slightly smaller than the other marks.
  194. return (
  195. <svg
  196. viewBox="120 120 560 560"
  197. className={className}
  198. fill="currentColor"
  199. aria-hidden="true"
  200. >
  201. <path
  202. fillRule="evenodd"
  203. d="M165.29 165.29H517.36V400H400V517.36H282.65V634.72H165.29ZM282.65 282.65V400H400V282.65Z"
  204. />
  205. <path d="M517.36 400H634.72V634.72H517.36Z" />
  206. </svg>
  207. );
  208. }
  209. const AGENT_MARKS: Record<AgentId, (props: MarkProps) => React.ReactNode> = {
  210. "claude-code": ClaudeMark,
  211. codex: OpenAIMark,
  212. pi: PiMark,
  213. };
  214. /** Segmented control for picking the coding agent before a chat starts. */
  215. function AgentSelector({
  216. value,
  217. onChange,
  218. }: {
  219. value: AgentId;
  220. onChange: (id: AgentId) => void;
  221. }) {
  222. return (
  223. <div className="inline-flex items-center gap-0.5 rounded-lg border bg-card p-0.5">
  224. {AGENT_IDS.map((id) => {
  225. const Mark = AGENT_MARKS[id];
  226. return (
  227. <button
  228. key={id}
  229. type="button"
  230. onClick={() => onChange(id)}
  231. aria-pressed={value === id}
  232. className={`inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
  233. value === id
  234. ? "bg-foreground text-background"
  235. : "text-muted-foreground hover:text-foreground"
  236. }`}
  237. >
  238. <Mark className="h-3.5 w-3.5" />
  239. {AGENTS[id].label}
  240. </button>
  241. );
  242. })}
  243. </div>
  244. );
  245. }
  246. /** Shimmering status line shown while we wait for the agent to produce output. */
  247. function PendingLine({ label }: { label: string }) {
  248. return (
  249. <div className="text-sm text-muted-foreground animate-shimmer">{label}</div>
  250. );
  251. }
  252. function MessageBubble({
  253. message,
  254. isLast,
  255. isStreaming,
  256. pendingLabel,
  257. }: {
  258. message: AppMessage;
  259. isLast: boolean;
  260. isStreaming: boolean;
  261. pendingLabel: string;
  262. }) {
  263. const { spec, text, hasSpec } = useJsonRenderMessage(message.parts);
  264. if (message.role === "user") {
  265. return (
  266. <div className="flex justify-end">
  267. <div className="max-w-[85%] rounded-2xl rounded-tr-sm bg-foreground px-3.5 py-2 text-sm leading-relaxed whitespace-pre-wrap text-background">
  268. {text}
  269. </div>
  270. </div>
  271. );
  272. }
  273. // Ordered segments: adjacent text merged, adjacent tool calls grouped,
  274. // the spec rendered inline where the agent emitted it.
  275. const segments: Array<
  276. | { kind: "text"; text: string }
  277. | {
  278. kind: "tools";
  279. tools: Array<{
  280. toolCallId: string;
  281. toolName: string;
  282. state: string;
  283. input: unknown;
  284. output: unknown;
  285. }>;
  286. }
  287. | { kind: "spec" }
  288. > = [];
  289. let specInserted = false;
  290. for (const part of message.parts) {
  291. if (part.type === "text") {
  292. if (!part.text.trim()) continue;
  293. const last = segments[segments.length - 1];
  294. if (last?.kind === "text") last.text += part.text;
  295. else segments.push({ kind: "text", text: part.text });
  296. } else if (part.type.startsWith("tool-")) {
  297. const tp = part as {
  298. type: string;
  299. toolCallId: string;
  300. state: string;
  301. input?: unknown;
  302. output?: unknown;
  303. };
  304. const tool = {
  305. toolCallId: tp.toolCallId,
  306. toolName: tp.type.replace(/^tool-/, ""),
  307. state: tp.state,
  308. input: tp.input,
  309. output: tp.output,
  310. };
  311. const last = segments[segments.length - 1];
  312. if (last?.kind === "tools") last.tools.push(tool);
  313. else segments.push({ kind: "tools", tools: [tool] });
  314. } else if (part.type === SPEC_DATA_PART_TYPE && !specInserted) {
  315. segments.push({ kind: "spec" });
  316. specInserted = true;
  317. }
  318. }
  319. const showLoader = isLast && isStreaming && segments.length === 0 && !hasSpec;
  320. return (
  321. <div className="flex w-full flex-col gap-3">
  322. {segments.map((seg, i) => {
  323. if (seg.kind === "text") {
  324. return (
  325. <div key={`text-${i}`} className="markdown text-sm leading-relaxed">
  326. <Streamdown
  327. plugins={{ code }}
  328. animated={isLast && isStreaming && i === segments.length - 1}
  329. >
  330. {seg.text}
  331. </Streamdown>
  332. </div>
  333. );
  334. }
  335. if (seg.kind === "spec") {
  336. if (!hasSpec) return null;
  337. return (
  338. <div key="spec" className="w-full">
  339. <ReportRenderer spec={spec} loading={isLast && isStreaming} />
  340. </div>
  341. );
  342. }
  343. return (
  344. <div key={`tools-${i}`} className="flex flex-col gap-1.5">
  345. {seg.tools.map((t) => (
  346. <ToolCallDisplay
  347. key={t.toolCallId}
  348. toolName={t.toolName}
  349. state={t.state}
  350. input={t.input}
  351. output={t.output}
  352. />
  353. ))}
  354. </div>
  355. );
  356. })}
  357. {showLoader && <PendingLine label={pendingLabel} />}
  358. {hasSpec && !specInserted && (
  359. <div className="w-full">
  360. <ReportRenderer spec={spec} loading={isLast && isStreaming} />
  361. </div>
  362. )}
  363. </div>
  364. );
  365. }
  366. export default function HarnessChatPage() {
  367. const [input, setInput] = useState("");
  368. const [agentId, setAgentId] = useState<AgentId>(DEFAULT_AGENT_ID);
  369. const [chatId, setChatId] = useState(() => crypto.randomUUID());
  370. const [isResetting, setIsResetting] = useState(false);
  371. const inputRef = useRef<HTMLTextAreaElement>(null);
  372. const { messages, sendMessage, setMessages, status, error, id, stop } =
  373. useChat<AppMessage>({ transport, id: chatId });
  374. const isStreaming = status === "streaming" || status === "submitted";
  375. const isBusy = isStreaming || isResetting;
  376. const handleSubmit = useCallback(
  377. async (text?: string) => {
  378. const message = text || input;
  379. if (!message.trim() || isBusy) return;
  380. setInput("");
  381. // The server locks the agent to the chat on the first message; sending
  382. // it every turn is harmless and keeps follow-ups consistent.
  383. await sendMessage({ text: message.trim() }, { body: { agent: agentId } });
  384. },
  385. [input, isBusy, sendMessage, agentId],
  386. );
  387. const handleClear = useCallback(async () => {
  388. if (isResetting) return;
  389. setIsResetting(true);
  390. stop();
  391. // Drop the server-side harness session (and its sandbox) for this chat.
  392. try {
  393. await fetch(`/api/agent?id=${encodeURIComponent(id)}`, {
  394. method: "DELETE",
  395. });
  396. } finally {
  397. setMessages([]);
  398. setChatId(crypto.randomUUID());
  399. setInput("");
  400. setIsResetting(false);
  401. inputRef.current?.focus();
  402. }
  403. }, [id, isResetting, setMessages, stop]);
  404. const isEmpty = messages.length === 0;
  405. return (
  406. <div className="flex h-screen flex-col overflow-hidden">
  407. {/* The header only appears once a chat has started; the first screen is
  408. headerless so the brand title carries it. */}
  409. {!isEmpty && (
  410. <header className="sticky top-0 z-10 flex h-14 shrink-0 items-center justify-between border-b bg-background/80 px-5 backdrop-blur-md">
  411. {/* Left: active agent */}
  412. {(() => {
  413. const Mark = AGENT_MARKS[agentId];
  414. return (
  415. <span className="flex items-center gap-1.5 text-sm text-muted-foreground">
  416. <Mark className="h-3.5 w-3.5" />
  417. {AGENTS[agentId].label}
  418. </span>
  419. );
  420. })()}
  421. {/* Center: brand, absolutely centered so side widths can't shift it */}
  422. <h1 className="pointer-events-none absolute left-1/2 -translate-x-1/2 text-sm font-medium tracking-tight whitespace-nowrap text-muted-foreground">
  423. AI SDK <span className="font-mono">HarnessAgent</span>
  424. <span className="mx-1.5 font-normal">+</span>
  425. <span className="font-mono">json-render</span>
  426. </h1>
  427. {/* Right: reset */}
  428. <button
  429. onClick={handleClear}
  430. disabled={isResetting}
  431. className="-mr-1.5 rounded-md px-2.5 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50"
  432. >
  433. Start over
  434. </button>
  435. </header>
  436. )}
  437. <main className="flex flex-1 flex-col overflow-auto">
  438. {isEmpty ? (
  439. <div className="flex flex-1 flex-col items-center justify-center px-6">
  440. <div className="w-full max-w-3xl">
  441. <div className="space-y-3">
  442. <h2 className="text-3xl font-semibold tracking-tight">
  443. AI SDK <span className="font-mono">HarnessAgent</span>
  444. <span className="mx-1.5 font-normal text-muted-foreground">
  445. +
  446. </span>
  447. <span className="font-mono">json-render</span>
  448. </h2>
  449. <p className="max-w-md text-[15px] leading-relaxed text-muted-foreground">
  450. A coding agent works in a live sandbox, then reports back as
  451. rendered UI — steps, diffs, terminal output, tests, and charts
  452. — instead of a wall of markdown.
  453. </p>
  454. </div>
  455. <div className="mt-8 grid grid-cols-1 gap-px overflow-hidden rounded-xl border bg-border sm:grid-cols-2">
  456. {SUGGESTIONS.map((s) => {
  457. const Icon = s.icon;
  458. return (
  459. <button
  460. key={s.label}
  461. onClick={() => handleSubmit(s.prompt)}
  462. disabled={isBusy}
  463. className="group flex items-start gap-3 bg-card p-4 text-left transition-colors hover:bg-accent"
  464. >
  465. <Icon
  466. className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground"
  467. strokeWidth={1.75}
  468. />
  469. <span className="min-w-0 space-y-0.5">
  470. <span className="block text-sm font-medium tracking-tight">
  471. {s.label}
  472. </span>
  473. <span className="block text-[13px] leading-snug text-muted-foreground">
  474. {s.description}
  475. </span>
  476. </span>
  477. </button>
  478. );
  479. })}
  480. </div>
  481. </div>
  482. </div>
  483. ) : (
  484. <div className="mx-auto w-full max-w-3xl space-y-6 px-6 py-6">
  485. {messages.map((message, index) => (
  486. <MessageBubble
  487. key={message.id}
  488. message={message}
  489. isLast={index === messages.length - 1}
  490. isStreaming={isStreaming}
  491. pendingLabel={index <= 1 ? "Starting sandbox…" : "Working…"}
  492. />
  493. ))}
  494. {isStreaming && messages[messages.length - 1]?.role === "user" && (
  495. <PendingLine
  496. label={messages.length <= 1 ? "Starting sandbox…" : "Working…"}
  497. />
  498. )}
  499. {error && (
  500. <div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
  501. {error.message}
  502. </div>
  503. )}
  504. </div>
  505. )}
  506. </main>
  507. <div className="shrink-0 px-6 pb-5">
  508. {isEmpty && (
  509. <div className="mx-auto mb-2.5 flex max-w-3xl items-center gap-2">
  510. <span className="text-xs text-muted-foreground">Agent</span>
  511. <AgentSelector value={agentId} onChange={setAgentId} />
  512. </div>
  513. )}
  514. <div className="group relative mx-auto max-w-3xl rounded-xl border bg-card shadow-subtle transition-colors focus-within:border-ring">
  515. <textarea
  516. ref={inputRef}
  517. value={input}
  518. onChange={(e) => setInput(e.target.value)}
  519. onKeyDown={(e) => {
  520. if (e.key === "Enter" && !e.shiftKey) {
  521. e.preventDefault();
  522. handleSubmit();
  523. }
  524. }}
  525. placeholder={
  526. isEmpty
  527. ? "Scaffold a TypeScript library and run its tests…"
  528. : "Ask a follow-up…"
  529. }
  530. rows={2}
  531. className="w-full resize-none bg-transparent px-3.5 py-3 pr-12 text-sm leading-relaxed placeholder:text-muted-foreground focus-visible:outline-none"
  532. autoFocus
  533. />
  534. <button
  535. onClick={() => handleSubmit()}
  536. disabled={!input.trim() || isBusy}
  537. className="absolute right-2.5 bottom-2.5 flex h-7 w-7 items-center justify-center rounded-lg bg-foreground text-background transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-25"
  538. >
  539. {isBusy ? (
  540. <Loader2 className="h-3.5 w-3.5 animate-spin" />
  541. ) : (
  542. <ArrowUp className="h-3.5 w-3.5" strokeWidth={2.25} />
  543. )}
  544. </button>
  545. </div>
  546. </div>
  547. </div>
  548. );
  549. }