Skip to content
Open
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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ The project has two layers:
- Fetches `/api/config` on load; fetches `/v1/agent-info` to populate a settings panel with model info, parameters, tools, and system prompt
- Posts to the local `/v1/chat/completions` proxy with `stream: true`
- Parses SSE responses with streaming display, reasoning/thinking content panel, tool call visualization, and stream metrics (tokens/s, inter-token latency)
- **Subagent delegation rendering** — handles `delta.subagent` events emitted by fipsagents 0.22.0+ agents that use the subagent-as-tool feature. Renders a delegation card per `span_id` that transitions through `invoked` → `completed` / `failed` states. The subagent's content is folded into the parent assistant message via the parent's normal `content` deltas; the card surfaces only the delegation framing (target agent, task, status, token totals on completion). Style mirrors the existing tool-call pill (`.tool-call` ↔ `.subagent-delegation`).
- Maintains conversation history in memory

## Configuration
Expand Down
106 changes: 106 additions & 0 deletions static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,9 @@ function createStreamRenderer(assistantEl) {
// Per-tool state: index -> { pillEl, nameEl, statusEl, argsEl, resultEl, args, name, callId }
const toolCalls = new Map();

// Per-subagent-delegation state: span_id -> { cardEl, statusEl, footerEl }
const subagentCards = new Map();

function ensureThinkingPanel() {
if (thinkingPanel) return;
thinkingPanel = document.createElement("details");
Expand Down Expand Up @@ -620,6 +623,95 @@ function createStreamRenderer(assistantEl) {
scrollToBottom();
}

function startSubagentCard(ev) {
const card = document.createElement("div");
card.className = "subagent-delegation running";

const header = document.createElement("div");
header.className = "subagent-header";

const icon = document.createElement("span");
icon.className = "subagent-icon";
icon.textContent = "\u{1F916}"; // robot face

const nameEl = document.createElement("span");
nameEl.className = "subagent-name";
nameEl.textContent = ev.agent_name;

const statusEl = document.createElement("span");
statusEl.className = "subagent-status";
statusEl.textContent = "delegating…";

header.appendChild(icon);
header.appendChild(nameEl);
header.appendChild(statusEl);
card.appendChild(header);

// Task preview — truncated with a <details> toggle for the full text.
const task = ev.task || "";
const truncated = task.length > 120 ? task.slice(0, 120) + "…" : task;
if (task.length > 120) {
const taskDetails = document.createElement("details");
taskDetails.className = "subagent-task-details";
const taskSummary = document.createElement("summary");
taskSummary.className = "subagent-task";
taskSummary.textContent = truncated;
const taskFull = document.createElement("div");
taskFull.className = "subagent-task-full";
taskFull.textContent = task;
taskDetails.appendChild(taskSummary);
taskDetails.appendChild(taskFull);
card.appendChild(taskDetails);
} else if (task) {
const taskEl = document.createElement("div");
taskEl.className = "subagent-task";
taskEl.textContent = task;
card.appendChild(taskEl);
}

const footerEl = document.createElement("div");
footerEl.className = "subagent-footer";
footerEl.style.display = "none";
card.appendChild(footerEl);

assistantEl.appendChild(card);
subagentCards.set(ev.span_id, { cardEl: card, statusEl, footerEl });
scrollToBottom();
}

function completeSubagentCard(ev) {
const entry = subagentCards.get(ev.span_id);
if (!entry) return;
entry.cardEl.classList.remove("running");
entry.cardEl.classList.add("done");
entry.statusEl.textContent = "done";

const tokensUsed = ev.tokens_used || {};
const total = (tokensUsed.input_tokens || 0) + (tokensUsed.output_tokens || 0);
const parts = [];
if (total > 0) parts.push(total.toLocaleString() + " tokens");
if (ev.cost_usd != null) parts.push("$" + ev.cost_usd.toFixed(4));
if (ev.tool_calls_made != null) parts.push(ev.tool_calls_made + " tool call" + (ev.tool_calls_made === 1 ? "" : "s"));
if (parts.length > 0) {
entry.footerEl.textContent = parts.join(" · ");
entry.footerEl.style.display = "block";
}
scrollToBottom();
}

function failSubagentCard(ev) {
const entry = subagentCards.get(ev.span_id);
if (!entry) return;
entry.cardEl.classList.remove("running");
entry.cardEl.classList.add("error");
entry.statusEl.textContent = "failed";
if (ev.error_message) {
entry.footerEl.textContent = (ev.error_type ? ev.error_type + ": " : "") + ev.error_message;
entry.footerEl.style.display = "block";
}
scrollToBottom();
}

function appendThinking(text) {
ensureThinkingPanel();
thinkingContent.textContent += text;
Expand Down Expand Up @@ -741,6 +833,20 @@ function createStreamRenderer(assistantEl) {
if (delta.role === "tool" && delta.tool_call_id) {
completeToolCall(delta.tool_call_id, delta.content || "", false);
}
// Subagent delegation events (invoked / completed / failed).
// type:"delta" is forward-compat only — log and ignore.
if (delta.subagent) {
const sa = delta.subagent;
if (sa.type === "invoked") {
startSubagentCard(sa);
} else if (sa.type === "completed") {
completeSubagentCard(sa);
} else if (sa.type === "failed") {
failSubagentCard(sa);
} else {
console.debug("[subagent] unhandled type:", sa.type, sa);
}
}
// Assistant content (the user-visible response)
if (delta.content && delta.role !== "tool") {
appendContent(delta.content);
Expand Down
117 changes: 117 additions & 0 deletions static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,123 @@ footer {
color: #b91c1c;
}

/* Subagent delegation cards — rendered inline in the assistant turn when the
agent delegates work to a peer agent. Structurally similar to .tool-call
but slightly more prominent (no <details> collapse, wider padding). */

.subagent-delegation {
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.6);
font-size: 13px;
margin: 6px 0;
}

.subagent-delegation.running {
border-color: var(--color-accent);
background: rgba(59, 130, 246, 0.06);
}

.subagent-delegation.done {
border-color: #10b981;
background: rgba(16, 185, 129, 0.04);
}

.subagent-delegation.error {
border-color: #dc2626;
background: rgba(220, 38, 38, 0.04);
}

.subagent-header {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
}

.subagent-icon {
font-size: 14px;
}

.subagent-name {
font-family: "SF Mono", Menlo, Consolas, monospace;
color: var(--color-text);
}

.subagent-status {
margin-left: auto;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--color-text-dim);
}

.subagent-delegation.running .subagent-status {
color: var(--color-accent);
}

.subagent-delegation.running .subagent-status::after {
content: "";
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-accent);
margin-left: 6px;
animation: pulse 1.2s ease-in-out infinite;
}

.subagent-delegation.done .subagent-status {
color: #047857;
}

.subagent-delegation.error .subagent-status {
color: #b91c1c;
}

.subagent-task {
margin-top: 6px;
color: var(--color-text-dim);
font-size: 12px;
line-height: 1.4;
}

.subagent-task-details {
margin-top: 6px;
}

.subagent-task-details > summary {
color: var(--color-text-dim);
font-size: 12px;
line-height: 1.4;
cursor: pointer;
list-style: none;
}

.subagent-task-details > summary::marker,
.subagent-task-details > summary::-webkit-details-marker {
display: none;
}

.subagent-task-full {
margin-top: 4px;
color: var(--color-text-dim);
font-size: 12px;
line-height: 1.4;
white-space: pre-wrap;
}

.subagent-footer {
margin-top: 6px;
font-size: 11px;
color: var(--color-text-dim);
}

.subagent-delegation.error .subagent-footer {
color: #b91c1c;
}

/* Response content -- the user-visible final answer. The streaming
indicator (cursor) is a separate element that lives next to it. */
.response-content {
Expand Down