Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions crews/backend_crew.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,29 @@ description: >
expert reviews for vulnerabilities before the work ships. Members are
equipped with terminal, git checkpoints, and web search.

tags: [alm, backend, default]

# Intent words that make this the right crew when the composer's crew is "Dynamic".
keywords:
- api
- endpoint
- service
- backend
- server
- database
- schema
- sql
- migration
- orm
- queue
- worker
- go
- python
- fastapi
- grpc
- auth
- handler

lead: senior_developer

members:
Expand Down
22 changes: 22 additions & 0 deletions crews/frontend_crew.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,28 @@ description: >
the result. Members are equipped with terminal, git checkpoints, and web
search so they can build, test, and snapshot their work autonomously.

tags: [alm, frontend]

# Intent words that make this the right crew when the composer's crew is "Dynamic".
keywords:
- ui
- frontend
- react
- next.js
- nextjs
- component
- page
- css
- tailwind
- dashboard
- button
- form
- style
- layout
- tsx
- design
- responsive

lead: senior_developer

members:
Expand Down
21 changes: 21 additions & 0 deletions crews/sre_crew.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,27 @@ description: >
side-effects. Read-only investigation — remediation is handled by the
blueprint's responder stage, not the crew.

tags: [sre]

# Intent words that make this the right crew when the composer's crew is "Dynamic".
keywords:
- incident
- alert
- oncall
- kubernetes
- cluster
- pod
- latency
- slo
- outage
- postmortem
- cost
- reliability
- runbook
- rollout
- error budget
- saturation

lead: root_cause_analyst

members:
Expand Down
154 changes: 135 additions & 19 deletions dashboard/src/app/compose/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { ArrowUpRight, Send, Terminal, Users, Wifi, WifiOff } from "lucide-react";
import { ArrowUpRight, CircleAlert, Send, Terminal, Users, Wifi, WifiOff } from "lucide-react";
import Link from "next/link";
import {
api,
Expand Down Expand Up @@ -36,6 +36,10 @@ import { Select } from "@/components/ui/select";
* dropped connection backfills missed frames, and the terminal/spinner
* are driven off an explicit run-state poll rather than guessing from
* a single "post-report" frame.
* DASH-14 — the composer states plainly what it needs before Run is armed, and
* a dispatch never disappears: a status strip (state · stage · elapsed ·
* open-run link) stays pinned in the composer card and the live area
* scrolls itself into view.
*/

const SSE_REPLAY = 50; // frames the server replays on (re-)connect
Expand Down Expand Up @@ -83,6 +87,12 @@ function ComposeInner() {
const [conn, setConn] = useState<Conn>("idle");
const [error, setError] = useState<string | null>(null);

// Dispatch feedback: when the run started (for the elapsed clock) and where
// the live area is, so a Run always lands somewhere visible.
const [startedAt, setStartedAt] = useState<number | null>(null);
const [nowMs, setNowMs] = useState(() => Date.now());
const liveRef = useRef<HTMLDivElement>(null);

// Last server event id seen on this stream — fed back on reconnect so the
// server can backfill anything emitted while we were disconnected.
const lastEventIdRef = useRef<string>("");
Expand Down Expand Up @@ -229,6 +239,14 @@ function ComposeInner() {
};
}, [taskId, running]);

// Elapsed clock — only ticks while a run is in flight.
useEffect(() => {
if (!running || startedAt === null) return;
setNowMs(Date.now());
const id = window.setInterval(() => setNowMs(Date.now()), 1000);
return () => window.clearInterval(id);
}, [running, startedAt]);

const parseContextRefs = useCallback(
(text: string): ContextRef[] => {
const refs: ContextRef[] = [];
Expand All @@ -250,19 +268,17 @@ function ComposeInner() {
);

async function run() {
if (!intent.trim() || !repo.trim()) {
setError("Intent and repo are required.");
return;
}
if (!blueprint) {
setError("Pick a blueprint to run.");
if (missing.length || !blueprint) {
setError(`Add ${joinList(missing)} to run.`);
return;
}
setError(null);
setRunning(true);
setRunState("queued");
setStartedAt(Date.now());
setEvents([]);
setCheckpoints([]);
setTaskId(null);
lastEventIdRef.current = "";
try {
const result = await api.dispatchCompose({
Expand All @@ -278,10 +294,17 @@ function ComposeInner() {
label: "composer",
});
setTaskId(result.task_id);
// Never leave the user staring at the composer wondering if anything
// happened — bring the live terminal to them.
window.setTimeout(
() => liveRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }),
60,
);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setRunning(false);
setRunState(null);
setStartedAt(null);
}
}

Expand All @@ -292,6 +315,18 @@ function ComposeInner() {

const selectedCrew = crews.find((c) => c.id === crewId);

// What the composer still needs before Run means anything. Shown up front
// rather than only after a failed click.
const missing = [
!intent.trim() ? "what you want done" : null,
!repo.trim() ? "a repo" : null,
!blueprint ? "a blueprint" : null,
].filter((x): x is string => x !== null);

// Plain-language "where the run is right now", from the newest staged frame.
const currentStage = [...events].reverse().find((e) => e.stage)?.stage ?? null;
const dispatched = running || taskId !== null;

return (
<div className="w-full p-6">
<header className="mb-5">
Expand All @@ -316,6 +351,15 @@ function ComposeInner() {
className="rounded-xl border p-4"
style={{ background: "var(--surface)", borderColor: "var(--border-subtle)" }}
>
<div className="mb-2 flex items-baseline gap-2">
<span className="text-xs font-semibold" style={{ color: "var(--ink-strong)" }}>
1 · What should the crew do?
</span>
<span className="text-[11px]" style={{ color: "var(--ink-muted)" }}>
Plain English — e.g. “add a health endpoint and a test for it”.
</span>
</div>

<MentionInput
value={intent}
onChange={setIntent}
Expand All @@ -325,7 +369,7 @@ function ComposeInner() {

<div className="mt-3 flex flex-wrap items-end gap-3">
<label className="flex flex-col text-xs" style={{ color: "var(--ink-muted)" }}>
Repo
2 · Repo
<RepoPicker value={repo} onChange={setRepo} className="mt-1 w-56" />
</label>

Expand Down Expand Up @@ -372,19 +416,74 @@ function ComposeInner() {
</div>
</div>

<button
type="button"
onClick={run}
disabled={running || !blueprint}
className="btn-primary ml-auto !py-2"
>
<Send size={15} />
{running ? "Running…" : "Run"}
</button>
<div className="ml-auto flex flex-col items-end gap-1">
<button
type="button"
onClick={run}
disabled={running || missing.length > 0}
title={missing.length ? `Add ${joinList(missing)} first` : "Dispatch the crew"}
className="btn-primary !py-2"
>
<Send size={15} />
{running ? "Running…" : "Run"}
</button>
{missing.length > 0 && !running && (
<span className="text-[11px]" style={{ color: "var(--ink-muted)" }}>
Add {joinList(missing)} to run.
</span>
)}
</div>
</div>

{/* Dispatch status — stays in the composer card so the run is never
silent, even if the user doesn't scroll to the terminal. */}
{dispatched && (
<div
className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1.5 rounded-lg border px-3 py-2 text-xs"
style={{ borderColor: "var(--border-subtle)", background: "var(--surface-muted)" }}
>
{runState ? (
<RunStateBadge state={runState} />
) : (
<span style={{ color: "var(--ink-muted)" }}>Dispatching…</span>
)}
<span style={{ color: "var(--ink)" }}>
{taskId
? currentStage
? `Working on ${currentStage}`
: running
? "Crew is starting up…"
: "Run finished"
: "Sending the task to the crew…"}
</span>
{startedAt !== null && (
<span className="font-mono" style={{ color: "var(--ink-muted)" }}>
{fmtElapsed((running ? nowMs : Math.max(nowMs, startedAt)) - startedAt)}
</span>
)}
{taskId && (
<>
<span className="font-mono" style={{ color: "var(--ink-muted)" }}>
{taskId.slice(0, 8)}
</span>
<Link
href={`/runs/${taskId}`}
className="ml-auto inline-flex items-center gap-1 font-medium hover:underline"
style={{ color: "var(--accent)" }}
>
Open full run <ArrowUpRight className="w-3 h-3" />
</Link>
</>
)}
</div>
)}

{error && (
<p className="mt-2 text-xs" style={{ color: "var(--error)" }}>
<p
className="mt-2 inline-flex items-center gap-1.5 text-xs"
style={{ color: "var(--error)" }}
>
<CircleAlert className="w-3.5 h-3.5 shrink-0" aria-hidden />
{error}
</p>
)}
Expand All @@ -408,7 +507,7 @@ function ComposeInner() {

{/* Live work area — only mounts once a run is dispatched, so the page
isn't a giant empty terminal void before you hit Run. */}
<div className="mt-5 grid grid-cols-1 gap-4 lg:grid-cols-[2fr_1fr]">
<div ref={liveRef} className="mt-5 scroll-mt-4 grid grid-cols-1 gap-4 lg:grid-cols-[2fr_1fr]">
{/* Left: live terminal while running, friendly placeholder while idle. */}
<div className="flex min-w-0 flex-col">
{taskId ? (
Expand Down Expand Up @@ -520,6 +619,23 @@ function ComposeInner() {
);
}

/** "a repo and a blueprint" — human list joining for the missing-fields hint. */
function joinList(items: string[]): string {
if (items.length <= 1) return items[0] ?? "";
return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
}

/** Elapsed run time as m:ss (or h:mm:ss past an hour). */
function fmtElapsed(ms: number): string {
const total = Math.max(0, Math.floor(ms / 1000));
const s = total % 60;
const m = Math.floor(total / 60) % 60;
const h = Math.floor(total / 3600);
const mm = String(m).padStart(2, "0");
const ss = String(s).padStart(2, "0");
return h > 0 ? `${h}:${mm}:${ss}` : `${m}:${ss}`;
}

/**
* Replay-safe append: the SSE stream replays recent frames on reconnect, so the
* same (task_id, timestamp, stage, phase) frame can arrive twice. Drop the
Expand Down
7 changes: 5 additions & 2 deletions dashboard/src/components/repo-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,10 @@ export function RepoPicker({
// Close on outside click.
useEffect(() => {
function onClick(e: MouseEvent) {
if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false);
if (boxRef.current && !boxRef.current.contains(e.target as Node)) {
setOpen(false);
setQuery(""); // typed-but-unpicked text isn't a selection — don't imply it is
}
}
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
Expand Down Expand Up @@ -136,7 +139,7 @@ export function RepoPicker({
setOpen(false);
}
}}
placeholder={value ? value : placeholder}
placeholder={value ? `${value} — type to change` : placeholder}
className="w-full rounded-md py-1.5 pl-7 pr-2 text-sm outline-none focus:border-[var(--ink-strong)]"
style={inputStyle}
/>
Expand Down
8 changes: 6 additions & 2 deletions dashboard/src/components/run-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -285,8 +285,12 @@ export function RunList({
>
{run.repo}
</span>
<span className="text-xs shrink-0" style={{ color: "var(--ink-muted)" }}>
{completedAgents}/{agentCount || "?"}
<span
className="text-xs shrink-0"
style={{ color: "var(--ink-muted)" }}
title={agentCount ? "Agents finished / agents on this run" : "No agent has reported yet"}
>
{agentCount ? `${completedAgents}/${agentCount} agents` : "no agents yet"}
</span>
</div>
{/* State on its own line; controls on a dedicated wrap-friendly row
Expand Down
3 changes: 3 additions & 0 deletions src/devai/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,9 @@ def _strip_auth_bff_shared_secret(cls, value: object) -> object:

# --- Crews (AI agent teams; seed catalog) ---
crews_dir: str = "crews"
# Crew used when a run picks "Dynamic" and the intent matches no crew's
# keywords. Empty = fall back to a crew tagged `default` in crews/*.yaml.
default_crew: str = "backend_crew"

# --- Teams (human teams that own crews) ---
teams_enabled: bool = True
Expand Down
Loading
Loading