[beadboard] Add Dispatch-to-Agent button and prompt template

## Context

We want a one-click path from the right-panel task detail into the
claude-agent-service runner, without the user copy-pasting the bead id
into a CLI or another tab. The runner expects a self-contained prompt
that restates the bead id, title, description, acceptance criteria, and
the guard rails the agent must operate under (no push, no file edits,
no terraform/kubectl/helm). The prompt template lives in `src/lib/` so
it can be tested and reused from the server-side dispatch route.

The right-panel button needs to:
- Only appear when the bead is actionable (`open` or `in_progress`).
- Disable itself while the claude-agent-service is already busy (the
  service has a global `asyncio.Lock` — parallel dispatches 409).
- Disable itself when the bead lacks acceptance criteria. An agent that
  doesn't know what "done" looks like burns budget and closes nothing.
- Surface the resulting `job_id` or any 409/error back to the user.

The project has no toast library (no `sonner`, no `react-hot-toast`), so
we render status inline under the button rather than pulling in a new
dependency for this single surface.

## This change

- `src/lib/dispatch-prompt.ts` exports `buildDispatchPrompt(bead)` which
  produces the exact prompt the agent runner expects. Bead id, priority
  (`P<n>`), issue type, description, and acceptance criteria are
  interpolated; `<job_id>` stays a literal placeholder because the agent
  only learns its own id at runtime (env var).
- `src/components/shared/dispatch-button.tsx` is a focused client
  component with three responsibilities:
    1. Poll `GET /api/agent-status` every 5 s while the panel is open
       (plus an initial fetch on mount), mirror `busy` into local state.
    2. On click, `POST /api/agent-dispatch` with `{taskId}`; branch on
       200 / 409 / other.
    3. Render an inline status line under the button (`text-xs`, tone
       driven by `ok | info | error`) — no toast dep required.
  The poll interval self-clears on unmount so closing the panel stops
  network traffic.
- `src/components/shared/thread-drawer.tsx` renders `<DispatchButton>`
  alongside the existing "Edit task" button in the summary section,
  wrapped in a `flex-wrap` so the two controls reflow on narrow panes.
- Registers two new tests in `package.json`'s enumerated test script.

## What is NOT in this change

- The `/api/agent-dispatch` and `/api/agent-status` routes themselves —
  those land in the next commit. The button calls them but the server
  side is intentionally a separate step so each commit can be reviewed
  in isolation.
- No real toast system is introduced; inline status is sufficient.
- No change to how task state transitions on dispatch. The agent itself
  is expected to run `bd update --claim` / `bd close` via the prompt's
  operating rules.

## Test Plan

### Automated

```
$ node --import tsx --test tests/lib/dispatch-prompt.test.ts \
    tests/components/shared/dispatch-button.test.tsx
# tests 7 pass 7 fail 0
```

Covers:
- Bead id appears in opening paragraph and in both `bd note` / `bd close`
  commands.
- Priority rendered as `P<n>`, issue type echoed.
- Description and acceptance criteria quoted verbatim when present.
- `(no description)` / `(no acceptance criteria)` fallbacks when null.
- Guard rails block present (no terraform/kubectl/helm, workspace bd
  path, `bd update … --status blocked` fallback).
- DispatchButton module loads and exports both named and default.

`npm run typecheck` shows only the pre-existing `OrchestratorChatMessage`
type gap in `left-panel.tsx` that reproduces on untouched `main`.

### Manual Verification

1. `npm install`
2. `npm run dev`
3. Open `http://localhost:3000/?task=<some-open-bead-id>`
4. Expected: "Dispatch to Agent" button next to "Edit task" in the
   right-panel summary section.
5. Button disabled on beads with `status in {closed, blocked, deferred}`
   (they don't render the button at all).
6. Button disabled on beads missing acceptance criteria, with tooltip
   "Task is missing acceptance criteria — cannot dispatch.".
7. Click: UI flips to "Dispatching…"; once the next commit is merged,
   the agent-dispatch route will surface a `job_id` (today it returns
   404 which renders as "Dispatch failed (HTTP 404)").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-04-18 14:04:34 +00:00
parent 845e90d7c0
commit 394a771b67
6 changed files with 245 additions and 2 deletions

View file

@ -0,0 +1,126 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Rocket } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { BeadIssue } from '../../lib/types';
interface DispatchButtonProps {
bead: BeadIssue;
}
type StatusMessage = { kind: 'info' | 'ok' | 'error'; text: string };
const DISPATCHABLE_STATUSES = new Set<BeadIssue['status']>(['open', 'in_progress']);
const POLL_INTERVAL_MS = 5000;
export function DispatchButton({ bead }: DispatchButtonProps) {
const [agentBusy, setAgentBusy] = useState<boolean>(false);
const [submitting, setSubmitting] = useState<boolean>(false);
const [status, setStatus] = useState<StatusMessage | null>(null);
const mountedRef = useRef<boolean>(true);
const isDispatchable = DISPATCHABLE_STATUSES.has(bead.status);
const hasAcceptance = Boolean(bead.acceptance_criteria?.trim());
const fetchAgentStatus = useCallback(async () => {
try {
const response = await fetch('/api/agent-status', { cache: 'no-store' });
if (!response.ok) return;
const payload = await response.json() as { busy?: boolean };
if (mountedRef.current) {
setAgentBusy(Boolean(payload.busy));
}
} catch {
// Leave previous state; fail closed is not required for a heartbeat.
}
}, []);
useEffect(() => {
mountedRef.current = true;
if (!isDispatchable) {
return () => {
mountedRef.current = false;
};
}
void fetchAgentStatus();
const handle = window.setInterval(() => { void fetchAgentStatus(); }, POLL_INTERVAL_MS);
return () => {
mountedRef.current = false;
window.clearInterval(handle);
};
}, [isDispatchable, fetchAgentStatus]);
const handleDispatch = useCallback(async () => {
setSubmitting(true);
setStatus({ kind: 'info', text: 'Submitting…' });
try {
const response = await fetch('/api/agent-dispatch', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ taskId: bead.id }),
});
const payload = await response.json().catch(() => ({} as Record<string, unknown>));
if (response.status === 409) {
setStatus({ kind: 'error', text: 'Agent busy — try again shortly' });
setAgentBusy(true);
return;
}
if (!response.ok) {
const message = typeof payload.error === 'string' ? payload.error : `Dispatch failed (HTTP ${response.status})`;
setStatus({ kind: 'error', text: message });
return;
}
const jobId = typeof payload.job_id === 'string' ? payload.job_id : 'unknown';
setStatus({ kind: 'ok', text: `Job \`${jobId}\` submitted` });
setAgentBusy(true);
} catch (error) {
const message = error instanceof Error ? error.message : 'Network error';
setStatus({ kind: 'error', text: message });
} finally {
setSubmitting(false);
}
}, [bead.id]);
if (!isDispatchable) {
return null;
}
const disabled = submitting || agentBusy || !hasAcceptance;
const hint = !hasAcceptance
? 'Task is missing acceptance criteria — cannot dispatch.'
: agentBusy
? 'Agent is currently busy.'
: 'Hand this task to the claude-agent-service runner.';
const statusToneClass = status?.kind === 'ok'
? 'text-[var(--ui-accent-ready)]'
: status?.kind === 'error'
? 'text-[#EAA7A0]'
: 'text-[var(--ui-text-muted)]';
return (
<div className="pt-1" data-testid="dispatch-button-container">
<Button
onClick={() => void handleDispatch()}
disabled={disabled}
title={hint}
className="h-8 rounded-full bg-[var(--ui-accent-info)] px-4 text-[#0b1e30] hover:bg-[color-mix(in_srgb,var(--ui-accent-info)_86%,white)] disabled:opacity-40"
data-testid="dispatch-agent-button"
>
<Rocket className="mr-2 h-3.5 w-3.5" />
{submitting ? 'Dispatching…' : 'Dispatch to Agent'}
</Button>
{status ? (
<p className={`mt-1 text-xs ${statusToneClass}`} data-testid="dispatch-status">
{status.text}
</p>
) : null}
</div>
);
}
export default DispatchButton;

View file

@ -13,6 +13,7 @@ import type { UpdateMutationPayload } from '../../lib/mutations';
import type { BeadIssue } from '../../lib/types';
import { ThreadView, type ThreadItem } from './thread-view';
import { useResponsive } from '../../hooks/use-responsive';
import { DispatchButton } from './dispatch-button';
interface ThreadDrawerProps {
isOpen: boolean;
@ -284,7 +285,7 @@ export function ThreadDrawer({
<Badge className="rounded-full border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] text-[var(--ui-text-muted)]">{issue.issue_type}</Badge>
{issue.assignee ? <Badge className="rounded-full border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] text-[var(--ui-text-muted)]">@{issue.assignee}</Badge> : null}
</div>
<div className="pt-1">
<div className="flex flex-wrap items-start gap-2 pt-1">
<Button
onClick={() => setEditMode(true)}
disabled={!canEdit}
@ -292,6 +293,7 @@ export function ThreadDrawer({
>
<Edit3 className="mr-2 h-3.5 w-3.5" /> Edit task
</Button>
<DispatchButton bead={issue} />
</div>
</div>
) : (