feat(studio): editable agent queue and a durable run log

The queue was a promise chain, so a waiting run could only be watched. It is
now an explicit list: queued runs can be moved sooner or later, dropped before
they start, and a run already going can be stopped (the tray reveals those
controls on hover).

Every settled run is appended to <project>/.hyperframes/agent-runs.jsonl —
when it ran, which element, the instruction, how long it took and what the
agent reported back. Studio reads that log when a project is first listed, so
the history survives a server restart instead of living only in memory.
This commit is contained in:
Miguel Angel Simon Sierra
2026-08-04 13:37:27 -07:00
parent 28484de469
commit 88a8ef5924
11 changed files with 588 additions and 64 deletions
+197 -18
View File
@@ -1,6 +1,7 @@
import { spawn } from "node:child_process";
import { spawn, type ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto";
import { basename } from "node:path";
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
import { basename, dirname, join } from "node:path";
/** Which harness the command belongs to — drives the icon Studio shows. */
export type AgentKind = "claude" | "codex" | "hermes" | "openclaw" | "custom";
@@ -20,7 +21,7 @@ export interface AgentJob {
/** Element or clip the instruction targets, for the run list. */
target: string;
instruction: string;
status: "queued" | "running" | "done" | "failed";
status: "queued" | "running" | "done" | "failed" | "cancelled";
/** Latest thing the agent did — the tool call or line it is on right now. */
activity: string;
message?: string;
@@ -28,27 +29,182 @@ export interface AgentJob {
endedAt?: number;
}
/** Everything needed to start a queued job once its turn comes. */
interface PendingRun {
job: AgentJob;
agent: AgentCommand;
prompt: string;
cwd: string;
}
/**
* Runs live on the server, not in the tab: a reload re-reads them from here, so
* in-flight work is never lost behind a refresh. Per project the queue is
* serial — two agents rewriting the same composition would clobber each other.
*
* The waiting jobs are an explicit array rather than a promise chain, because
* the queue is editable: entries can be reordered or dropped before they start.
*/
const jobsByProject = new Map<string, AgentJob[]>();
const queueByProject = new Map<string, Promise<void>>();
const pendingByProject = new Map<string, PendingRun[]>();
const runningByProject = new Map<string, ChildProcess>();
const MAX_JOBS_PER_PROJECT = 24;
/** Where a project's run history lives, next to the rest of Studio's state. */
const RUN_LOG_PATH = join(".hyperframes", "agent-runs.jsonl");
const HYDRATED_PROJECTS = new Set<string>();
const AGENT_TIMEOUT_MS = 10 * 60 * 1000;
export function listAgentJobs(projectId: string): AgentJob[] {
/**
* Append a settled run to the project's log. The tray only keeps the recent
* ones in memory; this file is the durable record of what was asked for, on
* which element, and how it went — one JSON object per line, so it is greppable
* by hand and re-readable by Studio.
*/
function appendRunLog(projectDir: string, job: AgentJob): void {
const file = join(projectDir, RUN_LOG_PATH);
try {
mkdirSync(dirname(file), { recursive: true });
appendFileSync(
file,
`${JSON.stringify({
at: new Date(job.startedAt).toISOString(),
target: job.target,
instruction: job.instruction,
status: job.status,
seconds: Math.round(((job.endedAt ?? Date.now()) - job.startedAt) / 1000),
agent: job.label,
result: job.message ?? "",
})}\n`,
"utf-8",
);
} catch {
// A read-only project directory must not take the run down with it.
}
}
function isLoggedRun(value: unknown): value is {
at: string;
target: string;
instruction: string;
status: AgentJob["status"];
seconds: number;
agent: string;
result: string;
} {
return (
typeof value === "object" &&
value !== null &&
typeof (value as { instruction?: unknown }).instruction === "string"
);
}
/** Re-seat past runs after a server restart so the history survives it. */
function hydrateFromRunLog(projectId: string, projectDir: string): void {
if (HYDRATED_PROJECTS.has(projectId)) return;
HYDRATED_PROJECTS.add(projectId);
const file = join(projectDir, RUN_LOG_PATH);
if (!existsSync(file)) return;
try {
const lines = readFileSync(file, "utf-8").trim().split("\n").slice(-MAX_JOBS_PER_PROJECT);
const restored = lines.flatMap((line): AgentJob[] => {
let entry: unknown;
try {
entry = JSON.parse(line);
} catch {
return [];
}
if (!isLoggedRun(entry)) return [];
const startedAt = Date.parse(entry.at);
return [
{
id: randomUUID(),
projectId,
kind: "custom",
label: entry.agent,
target: entry.target,
instruction: entry.instruction,
// Anything that was mid-flight when the server stopped is over now.
status: entry.status === "done" ? "done" : entry.status,
activity: "",
message: entry.result || undefined,
startedAt,
endedAt: startedAt + entry.seconds * 1000,
},
];
});
jobsByProject.set(projectId, [...restored, ...(jobsByProject.get(projectId) ?? [])]);
} catch {
// A corrupt log is history, not state — start the tray empty instead.
}
}
export function listAgentJobs(projectId: string, projectDir?: string): AgentJob[] {
if (projectDir) hydrateFromRunLog(projectId, projectDir);
return [...(jobsByProject.get(projectId) ?? [])].reverse();
}
function isActive(job: AgentJob): boolean {
return job.status === "queued" || job.status === "running";
}
export function clearFinishedAgentJobs(projectId: string): void {
jobsByProject.set(projectId, (jobsByProject.get(projectId) ?? []).filter(isActive));
}
/**
* Drop a run. A queued one never starts; a running one is killed, which is the
* only way to stop an agent that is off doing the wrong thing.
*/
export function cancelAgentJob(projectId: string, jobId: string, dir?: string): AgentJob | null {
const job = (jobsByProject.get(projectId) ?? []).find((candidate) => candidate.id === jobId);
if (!job || !isActive(job)) return null;
if (job.status === "queued") {
pendingByProject.set(
projectId,
(pendingByProject.get(projectId) ?? []).filter((pending) => pending.job.id !== jobId),
);
job.status = "cancelled";
job.message = "Cancelled before it started.";
job.endedAt = Date.now();
if (dir) appendRunLog(dir, job);
return job;
}
// Running: mark first so the close handler doesn't overwrite the reason.
job.status = "cancelled";
job.message = "Stopped mid-run.";
runningByProject.get(projectId)?.kill("SIGTERM");
return job;
}
/**
* Move a queued run to a new slot. Positions index the queue itself (0 is next
* up), so the caller never has to reason about finished or running entries.
*/
export function moveAgentJob(projectId: string, jobId: string, toIndex: number): boolean {
const pending = pendingByProject.get(projectId) ?? [];
const from = pending.findIndex((entry) => entry.job.id === jobId);
if (from === -1) return false;
const to = Math.max(0, Math.min(pending.length - 1, toIndex));
if (to === from) return true;
const [entry] = pending.splice(from, 1);
pending.splice(to, 0, entry!);
pendingByProject.set(projectId, pending);
// The visible list is newest-first; keep it consistent with the run order by
// re-seating the queued jobs in their new sequence.
const jobs = jobsByProject.get(projectId) ?? [];
jobsByProject.set(
projectId,
jobs.filter((job) => job.status === "queued" || job.status === "running"),
);
const queuedSlots = jobs.flatMap((job, index) => (job.status === "queued" ? [index] : []));
const reordered = [...pending].reverse().map((item) => item.job);
queuedSlots.forEach((slot, index) => {
jobs[slot] = reordered[index]!;
});
return true;
}
/** Compact "what is it doing right now" line from one stdout chunk. */
@@ -104,11 +260,16 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function runAgent(job: AgentJob, agent: AgentCommand, prompt: string, cwd: string): Promise<void> {
return new Promise((resolve) => {
function runAgent(projectId: string, { job, agent, prompt, cwd }: PendingRun): Promise<void> {
const settle = (resolve: () => void) => {
appendRunLog(cwd, job);
resolve();
};
return new Promise<void>((resolve) => {
job.status = "running";
job.activity = "Starting…";
const child = spawn(agent.command, agent.args, { cwd, stdio: ["pipe", "pipe", "pipe"] });
runningByProject.set(projectId, child);
let output = "";
let pending = "";
@@ -130,13 +291,21 @@ function runAgent(job: AgentJob, agent: AgentCommand, prompt: string, cwd: strin
child.stderr.on("data", (chunk: Buffer) => (output += chunk.toString()));
child.on("error", (err) => {
clearTimeout(timer);
runningByProject.delete(projectId);
job.status = "failed";
job.message = err.message;
job.endedAt = Date.now();
resolve();
settle(resolve);
});
child.on("close", (code) => {
clearTimeout(timer);
runningByProject.delete(projectId);
if (job.status === "cancelled") {
job.activity = "";
job.endedAt = Date.now();
settle(resolve);
return;
}
const failed = code !== 0;
job.status = failed ? "failed" : "done";
job.message =
@@ -146,7 +315,7 @@ function runAgent(job: AgentJob, agent: AgentCommand, prompt: string, cwd: strin
: (readResultMessage(agent.kind, output) ?? `${agent.label} finished.`));
job.activity = "";
job.endedAt = Date.now();
resolve();
settle(resolve);
});
child.stdin.end(prompt);
@@ -181,11 +350,21 @@ export function enqueueAgentJob(opts: {
jobs.push(job);
jobsByProject.set(opts.projectId, jobs.slice(-MAX_JOBS_PER_PROJECT));
const tail = queueByProject.get(opts.projectId) ?? Promise.resolve();
queueByProject.set(
opts.projectId,
tail.then(() => runAgent(job, opts.agent, opts.prompt, opts.projectDir)),
);
const pending = pendingByProject.get(opts.projectId) ?? [];
pending.push({ job, agent: opts.agent, prompt: opts.prompt, cwd: opts.projectDir });
pendingByProject.set(opts.projectId, pending);
void pump(opts.projectId);
return job;
}
/** Start the next queued run for a project, one at a time. */
async function pump(projectId: string): Promise<void> {
if (runningByProject.has(projectId)) return;
const next = (pendingByProject.get(projectId) ?? []).shift();
if (!next) return;
if (next.job.status !== "queued") return pump(projectId);
await runAgent(projectId, next);
await pump(projectId);
}
+123 -2
View File
@@ -44,6 +44,28 @@ function createFakeAgent(): string {
return script;
}
/** A fake agent that stays alive long enough to reorder or stop the queue. */
function createSlowAgent(): string {
const dir = createProjectDir();
const script = join(dir, "slow-agent.mjs");
writeFileSync(
script,
[
"import { appendFileSync } from 'node:fs';",
"let prompt = '';",
"process.stdin.on('data', (c) => (prompt += c));",
"process.stdin.on('end', () => {",
" setTimeout(() => {",
" appendFileSync('edited.txt', prompt + '\\n');",
" console.log('agent done');",
" }, 400);",
"});",
].join("\n"),
"utf-8",
);
return script;
}
function createAdapter(projectDir: string): StudioApiAdapter {
return {
listProjects: () => [],
@@ -80,10 +102,18 @@ async function readJobs(app: Hono, projectId: string): Promise<AgentJob[]> {
return ((await res.json()) as { jobs: AgentJob[] }).jobs;
}
async function waitFor(check: () => Promise<boolean>): Promise<void> {
for (let attempt = 0; attempt < 100; attempt++) {
if (await check()) return;
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error("condition never met");
}
async function waitForIdle(app: Hono, projectId: string): Promise<AgentJob[]> {
for (let attempt = 0; attempt < 100; attempt++) {
const jobs = await readJobs(app, projectId);
if (jobs.every((job) => job.status === "done" || job.status === "failed")) return jobs;
if (jobs.every((job) => job.status !== "queued" && job.status !== "running")) return jobs;
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error("jobs never settled");
@@ -176,7 +206,8 @@ describe("registerAgentRoutes", () => {
const res = await enqueue(app, "p1", "make the title red");
expect(res.status).toBe(200);
const { job } = (await res.json()) as { job: AgentJob };
expect(job.status).toBe("queued");
// An idle queue starts the run immediately; a busy one leaves it waiting.
expect(["queued", "running"]).toContain(job.status);
expect(job.instruction).toBe("make the title red");
expect(job.target).toBe("#title");
@@ -207,6 +238,96 @@ describe("registerAgentRoutes", () => {
]);
});
it("reorders and removes waiting runs", async () => {
const projectDir = createProjectDir();
process.env.HYPERFRAMES_AGENT_CMD = `${process.execPath} ${createSlowAgent()}`;
const app = createApp(projectDir);
await enqueue(app, "q1", "first");
const second = (await (await enqueue(app, "q1", "second")).json()) as { job: AgentJob };
const third = (await (await enqueue(app, "q1", "third")).json()) as { job: AgentJob };
// "third" jumps the queue ahead of "second", then "second" is dropped.
const moved = await app.request(`/projects/q1/agent/jobs/${third.job.id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ position: 0 }),
});
expect(moved.status).toBe(200);
const removed = await app.request(`/projects/q1/agent/jobs/${second.job.id}`, {
method: "DELETE",
});
expect(removed.status).toBe(200);
expect(
((await removed.json()) as { jobs: AgentJob[] }).jobs.find((j) => j.id === second.job.id)
?.status,
).toBe("cancelled");
const settled = await waitForIdle(app, "q1");
const ran = settled
.filter((job) => job.status === "done")
.map((job) => job.instruction)
.reverse();
expect(ran).toEqual(["first", "third"]);
});
it("stops a run that is already going", async () => {
const projectDir = createProjectDir();
process.env.HYPERFRAMES_AGENT_CMD = `${process.execPath} ${createSlowAgent()}`;
const app = createApp(projectDir);
const started = (await (await enqueue(app, "q2", "long one")).json()) as { job: AgentJob };
await waitFor(async () => (await readJobs(app, "q2"))[0]?.status === "running");
const res = await app.request(`/projects/q2/agent/jobs/${started.job.id}`, {
method: "DELETE",
});
expect(res.status).toBe(200);
const settled = await waitForIdle(app, "q2");
expect(settled[0]?.status).toBe("cancelled");
expect(settled[0]?.message).toBe("Stopped mid-run.");
});
it("refuses to touch a run that already finished", async () => {
const projectDir = createProjectDir();
process.env.HYPERFRAMES_AGENT_CMD = `${process.execPath} ${createFakeAgent()}`;
const app = createApp(projectDir);
const { job } = (await (await enqueue(app, "q3", "quick")).json()) as { job: AgentJob };
await waitForIdle(app, "q3");
expect(
(await app.request(`/projects/q3/agent/jobs/${job.id}`, { method: "DELETE" })).status,
).toBe(409);
});
it("keeps a readable run log per project and reads it back after a restart", async () => {
const projectDir = createProjectDir();
process.env.HYPERFRAMES_AGENT_CMD = `${process.execPath} ${createFakeAgent()}`;
const app = createApp(projectDir);
await enqueue(app, "q4", "log this run");
await waitForIdle(app, "q4");
const logged = readFileSync(join(projectDir, ".hyperframes", "agent-runs.jsonl"), "utf-8")
.trim()
.split("\n")
.map((line) => JSON.parse(line) as Record<string, unknown>);
expect(logged).toHaveLength(1);
expect(logged[0]).toMatchObject({
target: "#title",
instruction: "log this run",
status: "done",
agent: expect.stringContaining("node") as unknown as string,
});
// A fresh project id stands in for a restarted server: same directory, no
// in-memory jobs, history still shows up.
const restored = await readJobs(createApp(projectDir), "q4-after-restart");
expect(restored.map((job) => job.instruction)).toEqual(["log this run"]);
expect(restored[0]?.status).toBe("done");
});
it("clears finished jobs only", async () => {
const projectDir = createProjectDir();
process.env.HYPERFRAMES_AGENT_CMD = `${process.execPath} ${createFakeAgent()}`;
+28 -2
View File
@@ -4,9 +4,11 @@ import { delimiter, extname, join } from "node:path";
import { Readable } from "node:stream";
import type { StudioApiAdapter } from "../types.js";
import {
cancelAgentJob,
clearFinishedAgentJobs,
enqueueAgentJob,
listAgentJobs,
moveAgentJob,
type AgentCommand,
} from "../helpers/agentJobs.js";
@@ -117,7 +119,7 @@ export function registerAgentRoutes(api: Hono, adapter: StudioApiAdapter): void
label: agent?.label ?? null,
kind: agent?.kind ?? null,
iconUrl: resolveAgentIconPath() ? `/api/projects/${project.id}/agent/icon` : null,
jobs: listAgentJobs(project.id),
jobs: listAgentJobs(project.id, project.dir),
});
});
@@ -166,10 +168,34 @@ export function registerAgentRoutes(api: Hono, adapter: StudioApiAdapter): void
return c.json({ job });
});
// Reorder a waiting run. `position` indexes the queue (0 is next up).
api.patch("/projects/:id/agent/jobs/:jobId", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const body = (await c.req.json().catch(() => null)) as { position?: unknown } | null;
const position = typeof body?.position === "number" ? Math.trunc(body.position) : null;
if (position === null) return c.json({ error: "position required" }, 400);
if (!moveAgentJob(project.id, c.req.param("jobId"), position)) {
return c.json({ error: "job is not queued" }, 409);
}
return c.json({ jobs: listAgentJobs(project.id, project.dir) });
});
api.delete("/projects/:id/agent/jobs/:jobId", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
if (!cancelAgentJob(project.id, c.req.param("jobId"), project.dir)) {
return c.json({ error: "job already finished" }, 409);
}
return c.json({ jobs: listAgentJobs(project.id, project.dir) });
});
api.delete("/projects/:id/agent/jobs", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
clearFinishedAgentJobs(project.id);
return c.json({ jobs: listAgentJobs(project.id) });
return c.json({ jobs: listAgentJobs(project.id, project.dir) });
});
}
@@ -30,18 +30,31 @@ function renderTray(
jobs: AgentJob[],
onClearFinished = vi.fn(),
agentIconUrl: string | null = null,
handlers: {
onMoveJob?: (id: string, position: number) => void;
onCancelJob?: (id: string) => void;
} = {},
) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<AgentRunTray jobs={jobs} agentIconUrl={agentIconUrl} onClearFinished={onClearFinished} />,
<AgentRunTray
jobs={jobs}
agentIconUrl={agentIconUrl}
onClearFinished={onClearFinished}
{...handlers}
/>,
);
});
return { host, root, onClearFinished };
}
function buttonLabelled(host: HTMLElement, label: string): HTMLButtonElement[] {
return [...host.querySelectorAll("button")].filter((b) => b.getAttribute("aria-label") === label);
}
describe("AgentRunTray", () => {
it("renders nothing without runs", () => {
const { host, root } = renderTray([]);
@@ -94,6 +107,58 @@ describe("AgentRunTray", () => {
});
});
describe("queue editing", () => {
it("moves a waiting run toward the front of the queue", () => {
const onMoveJob = vi.fn();
// Newest-first rows: "third" is the last in line, "second" ahead of it.
const { host, root } = renderTray(
[
job({ id: "third", status: "queued", instruction: "third" }),
job({ id: "second", status: "queued", instruction: "second" }),
job({ id: "first", status: "running", instruction: "first" }),
],
vi.fn(),
null,
{ onMoveJob },
);
const [thirdSooner, secondSooner] = buttonLabelled(host, "Run sooner");
// "second" is already next up, so it cannot move any sooner.
expect(secondSooner?.disabled).toBe(true);
act(() => thirdSooner?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onMoveJob).toHaveBeenCalledWith("third", 0);
act(() => root.unmount());
});
it("removes a queued run and stops a running one", () => {
const onCancelJob = vi.fn();
const { host, root } = renderTray(
[job({ id: "waiting", status: "queued" }), job({ id: "going", status: "running" })],
vi.fn(),
null,
{ onCancelJob },
);
expect(buttonLabelled(host, "Remove from queue")).toHaveLength(1);
const [stop] = buttonLabelled(host, "Stop this run");
act(() => stop?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onCancelJob).toHaveBeenCalledWith("going");
act(() => root.unmount());
});
it("offers no queue controls on finished runs", () => {
const { host, root } = renderTray(
[job({ status: "done", endedAt: Date.now() })],
vi.fn(),
null,
{ onMoveJob: vi.fn(), onCancelJob: vi.fn() },
);
expect(buttonLabelled(host, "Stop this run")).toHaveLength(0);
expect(buttonLabelled(host, "Run sooner")).toHaveLength(0);
act(() => root.unmount());
});
});
describe("harness marks", () => {
it("draws a distinct glyph per harness and prefers a supplied icon", () => {
const kinds: AgentJob["kind"][] = ["claude", "codex", "hermes", "openclaw", "custom"];
@@ -6,8 +6,34 @@ const STATUS_DOT: Record<AgentJob["status"], string> = {
running: "animate-pulse bg-studio-accent",
done: "bg-studio-accent",
failed: "bg-red-400",
cancelled: "bg-neutral-600",
};
const ROW_BUTTON_CLASS =
"rounded-md p-0.5 text-neutral-600 transition-colors duration-150 ease-out " +
"hover:bg-neutral-800/70 hover:text-neutral-200 active:scale-[0.96] disabled:opacity-25 " +
"disabled:hover:bg-transparent disabled:hover:text-neutral-600";
function RowIcon({ paths }: { paths: string[] }) {
return (
<svg
width="11"
height="11"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{paths.map((d) => (
<path key={d} d={d} />
))}
</svg>
);
}
function elapsedLabel(job: AgentJob): string {
const end = job.endedAt ?? Date.now();
const seconds = Math.max(0, Math.round((end - job.startedAt) / 1000));
@@ -17,6 +43,7 @@ function elapsedLabel(job: AgentJob): string {
/** Live line for a run: the queue position, what the agent is doing, or how it ended. */
function statusLine(job: AgentJob): string {
if (job.status === "queued") return "Queued";
if (job.status === "cancelled") return job.message ?? "Cancelled";
if (job.status === "running") return job.activity || "Working…";
return job.message ?? (job.status === "failed" ? "Failed" : "Done");
}
@@ -31,17 +58,29 @@ export function AgentRunTray({
jobs,
agentIconUrl = null,
onClearFinished,
onMoveJob,
onCancelJob,
}: {
jobs: AgentJob[];
/** A real logo for the active harness, supplied via HYPERFRAMES_AGENT_ICON. */
agentIconUrl?: string | null;
onClearFinished: () => void;
/** Reorder a waiting run; `position` indexes the queue, 0 is next up. */
onMoveJob?: (jobId: string, position: number) => void;
/** Drop a queued run, or stop a running one. */
onCancelJob?: (jobId: string) => void;
}) {
const [collapsed, setCollapsed] = useState(false);
if (jobs.length === 0) return null;
const active = jobs.filter((job) => job.status === "queued" || job.status === "running").length;
const finished = jobs.length - active;
// The list reads newest-first; the queue runs oldest-first. Map between the
// two here so "move up" means "runs sooner" no matter how the rows are shown.
const queueOrder = jobs
.filter((job) => job.status === "queued")
.map((job) => job.id)
.reverse();
return (
<div
@@ -85,38 +124,80 @@ export function AgentRunTray({
{!collapsed && (
<ul className="max-h-56 space-y-0.5 overflow-y-auto">
{jobs.map((job) => (
<li
key={job.id}
className="rounded-[10px] px-1.5 py-1.5 transition-colors duration-150 ease-out hover:bg-neutral-900/70"
>
<div className="flex items-center gap-1.5">
<span
aria-hidden="true"
className={`size-1.5 shrink-0 rounded-full ${STATUS_DOT[job.status]}`}
/>
<AgentGlyph kind={job.kind} size={11} iconUrl={agentIconUrl} />
<span className="min-w-0 flex-1 truncate text-[12px] leading-none text-neutral-200">
{job.instruction}
</span>
<span className="shrink-0 text-[10px] leading-none text-neutral-600 tabular-nums">
{elapsedLabel(job)}
</span>
</div>
<div className="mt-1 flex items-center gap-1.5 pl-[18px]">
<span className="shrink-0 text-[10px] leading-none text-neutral-600">
{job.target}
</span>
<span
className={`min-w-0 truncate text-[10px] leading-none ${
job.status === "failed" ? "text-red-400/90" : "text-neutral-500"
}`}
>
{statusLine(job)}
</span>
</div>
</li>
))}
{jobs.map((job) => {
const queueIndex = queueOrder.indexOf(job.id);
const canReorder = queueIndex !== -1 && queueOrder.length > 1;
return (
<li
key={job.id}
className="group rounded-[10px] px-1.5 py-1.5 transition-colors duration-150 ease-out hover:bg-neutral-900/70"
>
<div className="flex items-center gap-1.5">
<span
aria-hidden="true"
className={`size-1.5 shrink-0 rounded-full ${STATUS_DOT[job.status]}`}
/>
<AgentGlyph kind={job.kind} size={11} iconUrl={agentIconUrl} />
<span className="min-w-0 flex-1 truncate text-[12px] leading-none text-neutral-200">
{job.instruction}
</span>
<span className="shrink-0 text-[10px] leading-none text-neutral-600 tabular-nums group-hover:hidden">
{elapsedLabel(job)}
</span>
{(job.status === "queued" || job.status === "running") && (
<span className="hidden shrink-0 items-center gap-0.5 group-hover:flex">
{canReorder && onMoveJob && (
<>
<button
className={ROW_BUTTON_CLASS}
disabled={queueIndex === 0}
onClick={() => onMoveJob(job.id, queueIndex - 1)}
aria-label="Run sooner"
title="Run sooner"
>
<RowIcon paths={["M8 12.5 V3.5", "M4.5 7 L8 3.5 L11.5 7"]} />
</button>
<button
className={ROW_BUTTON_CLASS}
disabled={queueIndex === queueOrder.length - 1}
onClick={() => onMoveJob(job.id, queueIndex + 1)}
aria-label="Run later"
title="Run later"
>
<RowIcon paths={["M8 3.5 V12.5", "M4.5 9 L8 12.5 L11.5 9"]} />
</button>
</>
)}
{onCancelJob && (
<button
className={`${ROW_BUTTON_CLASS} hover:text-red-400`}
onClick={() => onCancelJob(job.id)}
aria-label={
job.status === "running" ? "Stop this run" : "Remove from queue"
}
title={job.status === "running" ? "Stop this run" : "Remove from queue"}
>
<RowIcon paths={["M4 4 L12 12", "M12 4 L4 12"]} />
</button>
)}
</span>
)}
</div>
<div className="mt-1 flex items-center gap-1.5 pl-[18px]">
<span className="shrink-0 text-[10px] leading-none text-neutral-600">
{job.target}
</span>
<span
className={`min-w-0 truncate text-[10px] leading-none ${
job.status === "failed" ? "text-red-400/90" : "text-neutral-500"
}`}
>
{statusLine(job)}
</span>
</div>
</li>
);
})}
</ul>
)}
</div>
@@ -115,8 +115,9 @@ describe("InlineAgentComposer", () => {
it("names the harness in the header and the element in the field", () => {
const { host, root } = renderComposer({ selectionLabel: "Card" });
const header = host.querySelector<HTMLElement>("[data-inline-agent-composer]")
?.firstElementChild;
const header = host.querySelector<HTMLElement>(
"[data-inline-agent-composer]",
)?.firstElementChild;
expect(header?.textContent).toContain("Claude Code");
expect(header?.textContent).not.toContain("Card");
expect(host.querySelector("textarea")?.placeholder).toBe("Describe a change to Card…");
@@ -125,9 +126,9 @@ describe("InlineAgentComposer", () => {
it("falls back to the element name when no harness is installed", () => {
const { host, root } = renderComposer({ runLabel: null, selectionLabel: "Card" });
expect(host.querySelector("[data-inline-agent-composer]")?.firstElementChild?.textContent).toContain(
"Card",
);
expect(
host.querySelector("[data-inline-agent-composer]")?.firstElementChild?.textContent,
).toContain("Card");
act(() => root.unmount());
});
@@ -277,6 +277,8 @@ export function InlineAgentComposerHost({
jobs={agentJobs}
agentIconUrl={agentIconUrl}
onClearFinished={actions.clearFinishedAgentJobs}
onMoveJob={actions.moveAgentJob}
onCancelJob={actions.cancelAgentJob}
/>
</>
);
@@ -7,7 +7,7 @@ export interface AgentJob {
label: string;
target: string;
instruction: string;
status: "queued" | "running" | "done" | "failed";
status: "queued" | "running" | "done" | "failed" | "cancelled";
activity: string;
message?: string;
startedAt: number;
@@ -31,6 +31,8 @@ export interface DomEditActionsValue extends Pick<
| "handleAgentModalSubmit"
| "handleAgentModalRun"
| "clearFinishedAgentJobs"
| "moveAgentJob"
| "cancelAgentJob"
| "handleBlockedDomMove"
| "handleDomManualDragStart"
| "handleDomEditElementDelete"
@@ -168,6 +170,8 @@ export function DomEditProvider({
handleAgentModalSubmit,
handleAgentModalRun,
clearFinishedAgentJobs,
moveAgentJob,
cancelAgentJob,
handleBlockedDomMove,
handleDomManualDragStart,
handleDomEditElementDelete,
@@ -256,6 +260,8 @@ export function DomEditProvider({
handleAgentModalSubmit,
handleAgentModalRun,
clearFinishedAgentJobs,
moveAgentJob,
cancelAgentJob,
handleBlockedDomMove,
handleDomManualDragStart,
handleDomEditElementDelete,
@@ -326,6 +332,8 @@ export function DomEditProvider({
handleAgentModalSubmit,
handleAgentModalRun,
clearFinishedAgentJobs,
moveAgentJob,
cancelAgentJob,
handleBlockedDomMove,
handleDomManualDragStart,
handleDomEditElementDelete,
+40 -3
View File
@@ -170,6 +170,41 @@ export function useAskAgentModal({
[buildPrompt, projectIdRef, resolveSelection, showToast],
);
const patchAgentJobs = useCallback(
(path: string, init: RequestInit) => {
const pid = projectId ?? projectIdRef.current;
if (!pid) return;
void fetch(`/api/projects/${pid}/agent/jobs${path}`, init)
.then(async (response) => {
const data = (await response.json()) as { error?: string; jobs?: AgentJob[] };
if (!response.ok) {
showToast(data.error ?? "Could not update the queue.", "error");
return;
}
setAgentJobs(data.jobs ?? []);
})
.catch(() => showToast("Could not update the queue.", "error"));
},
[projectId, projectIdRef, showToast],
);
/** Reorder a waiting run; `position` indexes the queue, 0 is next up. */
const moveAgentJob = useCallback(
(jobId: string, position: number) =>
patchAgentJobs(`/${jobId}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ position }),
}),
[patchAgentJobs],
);
/** Drop a queued run, or stop one that is already going. */
const cancelAgentJob = useCallback(
(jobId: string) => patchAgentJobs(`/${jobId}`, { method: "DELETE" }),
[patchAgentJobs],
);
const clearFinishedAgentJobs = useCallback(() => {
const pid = projectIdRef.current;
if (!pid) return;
@@ -207,12 +242,12 @@ export function useAskAgentModal({
void refreshAgentState();
}, [refreshAgentState]);
// Poll only while something is queued or running — an idle tray costs nothing.
// Fast while something is in flight, slow otherwise — a run can also be
// started from another tab or the CLI, and the tray should still notice.
const hasActiveJob = agentJobs.some((job) => job.status === "queued" || job.status === "running");
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (!hasActiveJob) return;
const timer = setInterval(() => void refreshAgentState(), 1200);
const timer = setInterval(() => void refreshAgentState(), hasActiveJob ? 1200 : 8000);
return () => clearInterval(timer);
}, [hasActiveJob, refreshAgentState]);
@@ -253,5 +288,7 @@ export function useAskAgentModal({
handleAgentModalSubmit,
handleAgentModalRun,
clearFinishedAgentJobs,
moveAgentJob,
cancelAgentJob,
};
}
@@ -158,6 +158,8 @@ export function useDomEditSession({
handleAgentModalSubmit,
handleAgentModalRun,
clearFinishedAgentJobs,
moveAgentJob,
cancelAgentJob,
} = useAskAgentModal({
projectId,
activeCompPath,
@@ -507,6 +509,8 @@ export function useDomEditSession({
handleAgentModalSubmit,
handleAgentModalRun,
clearFinishedAgentJobs,
moveAgentJob,
cancelAgentJob,
handleBlockedDomMove,
handleDomManualDragStart,
handleDomEditElementDelete,