Files
handler/frontend/components/sections/AgentsSection.tsx
T
Claude 24b8c44451 fix(mise-init): unblock the bootstrap agent + surface live agent output
The mise-init agent wedged on launch and the UI reported it green. Three
distinct problems, fixed together:

1. Onboarding wedge (the proximate bug). A freshly-installed claude opens
   interactive setup — theme picker, then a folder-trust prompt — before
   the REPL. A detached tmux agent has no one to answer it, so it sat on
   the theme picker forever while agents.status said 'working'. New
   control.claude_config.ensure_onboarded() marks onboarding complete and
   trusts the working dir in ~/.claude.json (merge-only, so the login
   flow's oauthAccount survives); spawn() calls it before launching.

2. Config-name gate. control/mise.py only recognized `.mise.toml`, so a
   repo shipping `mise.toml` (no dot) — or config under `.config/mise/` —
   failed the [tasks.test] gate even when healthy. It now accepts the
   filenames mise itself reads and scans them all for the test task.

3. "Done" != done (the design gap). A spawned agent's real state lives in
   its tmux pane, but the socket is control-container-only, so the API
   couldn't see it. The worker now snapshots each working agent's pane
   tail (last ~40 lines) into two new agents columns (last_output,
   output_at, migration 0007) on its existing poll loop; the API
   serializes them and AgentsSection renders a live-output <pre> under
   each running agent. A wedged agent now shows the theme picker instead
   of a misleading green badge.

Tests: home-dir writes are isolated to tmp in conftest; added coverage for
claude_config seeding/merge, the mise filename set, the worker capture
(including dead-session skip), and the API serialization.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BxKY28XKCM6o4ag3nmaVsZ
2026-07-16 19:30:58 +00:00

184 lines
7.1 KiB
TypeScript

/* Agents — spawn a new agent into a repository and manage the ones already running.
* Spawning enqueues a control command that the worker turns into a tmux + claude process. */
"use client";
import { Fragment, useMemo, useState } from "react";
import { useDashboard } from "@/components/store";
import { Badge, Button, Card, Input, Select, StatusBadge, Textarea } from "@/components/ui";
import { fmtFull, timeAgo } from "@/lib/format";
const ROLE_OPTS = [
{ value: "", label: "Role — none" },
{ value: "junior", label: "junior" },
{ value: "senior", label: "senior" },
{ value: "deploy", label: "deploy" },
];
const PLACEMENT_OPTS = [
{ value: "worktree", label: "git worktree on branch" },
{ value: "subdir", label: "subdir under root" },
];
const emptySpawn = {
name: "",
role: "",
placement: "worktree" as "worktree" | "subdir",
worktree: "",
subdir: "",
task: "",
};
export function AgentsSection() {
const s = useDashboard();
const [form, setForm] = useState(emptySpawn);
const projectOpts = useMemo(
() => s.projects.map((p) => ({ value: p.id, label: p.id })),
[s.projects],
);
const agents = useMemo(
() => s.agents.filter((a) => a.project_id === s.selectedProjectId),
[s.agents, s.selectedProjectId],
);
const spawn = async () => {
const ok = await s.spawnAgent(form);
if (ok) setForm(emptySpawn);
};
return (
<>
<div className="section-head">
<div className="section-title">Agents</div>
<div className="section-desc">Spawn agents into a repository and manage running sessions.</div>
</div>
<div className="section-body">
{s.projects.length === 0 ? (
<div className="empty">Register a repository first.</div>
) : (
<>
<div className="row">
<div style={{ width: 260 }}>
<Select
label="Repository"
value={s.selectedProjectId}
onChange={s.selectProject}
options={projectOpts}
/>
</div>
</div>
<Card>
<div className="card-head" style={{ marginBottom: 14 }}>
<span className="card-title" style={{ fontSize: "var(--text-md)", color: "var(--text-heading)" }}>
Spawn an agent
</span>
</div>
<div className="form-grid">
<Input label="Name" value={form.name} onChange={(v) => setForm({ ...form, name: v })} placeholder="junior" />
<Select label="Role" value={form.role} onChange={(v) => setForm({ ...form, role: v })} options={ROLE_OPTS} />
<Select
label="Placement"
value={form.placement}
onChange={(v) => setForm({ ...form, placement: v as "worktree" | "subdir" })}
options={PLACEMENT_OPTS}
/>
{form.placement === "worktree" ? (
<Input label="Branch" value={form.worktree} onChange={(v) => setForm({ ...form, worktree: v })} placeholder="feat/auth" />
) : (
<Input label="Subdir" value={form.subdir} onChange={(v) => setForm({ ...form, subdir: v })} placeholder="api" />
)}
</div>
<div className="mt14">
<Textarea
label="Initial task"
value={form.task}
onChange={(v) => setForm({ ...form, task: v })}
rows={2}
placeholder="initial task / prompt (optional)"
/>
</div>
<div className="hstack mt14">
<Button variant="primary" disabled={s.cmd.busy || !form.name.trim()} onClick={spawn}>
Spawn
</Button>
</div>
</Card>
{agents.length === 0 ? (
<div className="empty">No agents in this repository.</div>
) : (
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th>Name</th>
<th>Role</th>
<th>Status</th>
<th>Working dir</th>
<th>Created</th>
<th />
</tr>
</thead>
<tbody>
{agents.map((a) => (
<Fragment key={a.id}>
<tr>
<td className="mono">{a.name}</td>
<td>{a.role ? <Badge tone="info">{a.role}</Badge> : "—"}</td>
<td>
<StatusBadge status={a.status} />
</td>
<td className="mono faint">{a.working_dir}</td>
<td className="faint nowrap">{fmtFull(a.created_at)}</td>
<td className="nowrap">
<div className="hstack">
<Button size="sm" variant="ghost" onClick={() => s.selectRun(a.project_id, a.name)}>
Open
</Button>
<Button size="sm" variant="secondary" onClick={() => s.killAgent(a.project_id, a.name)}>
Kill
</Button>
<Button size="sm" variant="danger" onClick={() => s.deleteAgent(a.project_id, a.name)}>
Delete
</Button>
</div>
</td>
</tr>
{a.status === "working" && a.last_output?.trim() && (
<tr>
<td colSpan={6} style={{ paddingTop: 0 }}>
<div className="faint" style={{ fontSize: "var(--text-xs)", marginBottom: 4 }}>
live output{a.output_at ? ` · ${timeAgo(a.output_at)}` : ""}
</div>
<pre
className="mono"
style={{
margin: 0,
padding: "8px 10px",
background: "var(--surface-2, rgba(0,0,0,0.25))",
borderRadius: 6,
fontSize: "var(--text-xs)",
lineHeight: 1.4,
maxHeight: 220,
overflow: "auto",
whiteSpace: "pre",
}}
>
{a.last_output}
</pre>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</div>
)}
</>
)}
</div>
</>
);
}