Menu
popagent
publicLatest change 22e859e348543a86a311c69c2d20a2c27168a212 - Fix Mastra lifecycle gaps, human task cards, and rate-limited test models by AkurAI Build
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { ArrowRight, Bot, Hash, Loader2, Menu, Reply, Send, Settings2, Square, Wrench } from "lucide-react";
import type {
AgentListResponse,
AgentWorkspace,
Channel,
ChannelListResponse,
ChannelMessage,
ChannelMessageListResponse,
ChannelPostResponse,
ChannelSettings,
} from "../api-types";
import { executionAgentPeople } from "../company-roster";
import { formatTaskTimestamp } from "./TaskKanban";
import { apiFetch } from "./api";
import { consumeChannelEvents } from "./channel-stream";
import { toast } from "./toast";
export function ChannelPage({ channelId, workspaces, focusSettingsOnMount, onOpenNavigation, onOpenSettings }: {
channelId: string;
workspaces: AgentWorkspace[];
focusSettingsOnMount?: boolean;
onOpenNavigation: () => void;
onOpenSettings: () => void;
}) {
const [channel, setChannel] = useState<Channel>();
const [settings, setSettings] = useState<ChannelSettings>();
const [orchestratorName, setOrchestratorName] = useState("Orchestrator");
const [messages, setMessages] = useState<ChannelMessage[]>([]);
const [workspaceId, setWorkspaceId] = useState(workspaces[0]?.id ?? "default");
const [content, setContent] = useState("");
const [loading, setLoading] = useState(true);
const [failure, setFailure] = useState("");
const [sending, setSending] = useState(false);
const [nextCursor, setNextCursor] = useState<string | null>();
const [connection, setConnection] = useState<"live" | "polling" | "connecting">("connecting");
const endRef = useRef<HTMLDivElement>(null);
const settingsRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!focusSettingsOnMount) return;
const timer = setTimeout(() => settingsRef.current?.focus(), 50);
return () => clearTimeout(timer);
}, [focusSettingsOnMount]);
useEffect(() => {
if (workspaces.some((item) => item.id === workspaceId)) return;
setWorkspaceId(workspaces[0]?.id ?? "default");
}, [workspaceId, workspaces]);
const loadHistory = useCallback(async (quiet = false) => {
if (!quiet) setLoading(true);
const [channelResponse, settingsResponse, historyResponse, agentResponse] = await Promise.all([
apiFetch("/api/channels"),
apiFetch("/api/settings/channels"),
apiFetch(`/api/channels/${encodeURIComponent(channelId)}/messages?limit=100`),
apiFetch("/api/agents"),
]);
if (!channelResponse.ok || !settingsResponse.ok || !historyResponse.ok) throw new Error("Unable to load channel");
const listed = (await channelResponse.json() as ChannelListResponse).channels.find((item) => item.id === channelId);
if (!listed) throw new Error("Channel not found");
const history = await historyResponse.json() as ChannelMessageListResponse;
setChannel(listed);
setSettings(await settingsResponse.json() as ChannelSettings);
if (agentResponse.ok) {
const orchestrator = (await agentResponse.json() as AgentListResponse).agents.find((agent) => agent.id === "orchistrator");
if (orchestrator) setOrchestratorName(orchestrator.name);
}
setMessages(history.messages);
setNextCursor(history.nextCursor);
setFailure("");
setLoading(false);
}, [channelId]);
useEffect(() => {
void loadHistory().catch((error) => { setFailure(error instanceof Error ? error.message : String(error)); setLoading(false); });
}, [loadHistory]);
useEffect(() => {
if (!settings) return;
if (!settings.streaming) {
setConnection("polling");
const timer = setInterval(() => void loadHistory(true).catch(() => undefined), 3_000);
return () => clearInterval(timer);
}
const controller = new AbortController();
let retry: Timer | undefined;
const connect = () => {
setConnection("connecting");
void consumeChannelEvents(channelId, controller.signal, () => {
void loadHistory(true).catch(() => undefined);
}, () => setConnection("live")).then(() => {
if (!controller.signal.aborted) retry = setTimeout(connect, 1_000);
}).catch(() => {
if (!controller.signal.aborted) { setConnection("connecting"); retry = setTimeout(connect, 2_000); }
});
};
connect();
return () => { clearTimeout(retry); controller.abort(); };
}, [channelId, loadHistory, settings?.streaming]);
useEffect(() => {
endRef.current?.scrollIntoView({ block: "end" });
}, [messages.length]);
useEffect(() => {
if (!messages.some((message) => message.task && ["queued", "running", "cancelling"].includes(message.task.status))) return;
const timer = setInterval(() => void loadHistory(true).catch(() => undefined), 2_000);
return () => clearInterval(timer);
}, [loadHistory, messages]);
const send = async () => {
if (!content.trim() || !workspaceId || sending) return;
setSending(true);
const response = await apiFetch(`/api/channels/${encodeURIComponent(channelId)}/messages`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ content: content.trim(), workspaceId }),
});
if (response.ok) {
const result = await response.json() as ChannelPostResponse;
setMessages((current) => mergeMessages(current, [result.message]));
setContent("");
requestAnimationFrame(() => endRef.current?.scrollIntoView({ behavior: "smooth" }));
} else {
const body = await response.json().catch(() => ({})) as { error?: string };
toast(body.error ?? "Unable to post channel message");
}
setSending(false);
};
const loadOlder = async () => {
if (!nextCursor) return;
const response = await apiFetch(`/api/channels/${encodeURIComponent(channelId)}/messages?limit=100&before=${encodeURIComponent(nextCursor)}`);
if (!response.ok) return toast("Unable to load older messages");
const page = await response.json() as ChannelMessageListResponse;
setMessages((current) => mergeMessages(page.messages, current));
setNextCursor(page.nextCursor);
};
const visibleMessages = visibleChannelMessages(messages);
if (loading) return <ChannelState icon={<Loader2 className="size-5 animate-spin" />} title="Loading #general…" />;
if (failure) return <ChannelState title={failure} action="Retry" onAction={() => void loadHistory()} />;
return (
<main className="flex min-w-0 flex-1 flex-col bg-bg">
<header className="page-gutter flex min-h-16 items-center gap-3 border-b border-line bg-panel/90 backdrop-blur">
<button type="button" aria-label="Open sessions" onClick={onOpenNavigation} className="liquid-control flex size-11 items-center justify-center rounded lg:hidden"><Menu className="size-4" /></button>
<div className="brand-orb flex size-9 items-center justify-center rounded"><Hash className="size-4" /></div>
<div className="min-w-0"><h1 className="truncate text-sm font-semibold">{channel?.name ?? settings?.channelName}</h1><p className="text-[11px] text-ink-dim">Shared agent dispatch · <span className={connection === "live" ? "text-emerald-500" : "text-amber-400"}>{connection}</span></p></div>
<button ref={settingsRef} type="button" aria-label="Open channel settings" onClick={onOpenSettings} className="liquid-control ml-auto flex min-h-11 items-center gap-2 rounded px-3 text-xs"><Settings2 className="size-4" />Settings</button>
</header>
<div className="page-content min-h-0 flex-1 overflow-y-auto">
<div className="w-full">
{nextCursor ? <button type="button" onClick={() => void loadOlder()} className="mx-auto mb-5 block min-h-10 rounded border border-line px-4 text-xs text-ink-muted">Load older messages</button> : null}
{!visibleMessages.length ? <EmptyChannel enabled={settings?.enabled ?? false} /> : visibleMessages.map((message) => <ChannelMessageCard key={message.id} message={message} agentName={orchestratorName} mode={settings?.toolDisplay ?? "compact"} onCancel={() => void cancelTask(channelId, message.taskId!, loadHistory)} />)}
<div ref={endRef} />
</div>
</div>
<div aria-live="polite" className="sr-only">{messages.at(-1)?.task?.status ? `Agent task ${messages.at(-1)?.task?.status}` : ""}</div>
<footer className="page-gutter border-t border-line bg-panel/80 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 backdrop-blur">
<form className="liquid-composer w-full rounded border p-2" onSubmit={(event) => { event.preventDefault(); void send(); }}>
<textarea aria-label="Message #general" disabled={!settings?.enabled || !workspaces.length} value={content} onChange={(event) => setContent(event.currentTarget.value)} onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); void send(); } }} rows={2} maxLength={8000} placeholder={settings?.enabled ? "Message the shared agent channel…" : "Channels are disabled in settings"} className="max-h-40 min-h-14 w-full resize-none bg-transparent px-2 py-1 text-sm outline-none disabled:opacity-50" />
<div className="flex items-center gap-2"><select aria-label="Channel workspace" value={workspaceId} onChange={(event) => setWorkspaceId(event.currentTarget.value)} className="min-h-10 min-w-0 max-w-56 rounded border border-line bg-bg px-2 text-xs">{workspaces.map((workspace) => <option key={workspace.id} value={workspace.id}>{workspace.name}</option>)}</select><span className="hidden text-[11px] text-ink-dim sm:block">Enter to send · Shift+Enter for newline</span><button type="submit" aria-label="Send channel message" disabled={sending || !content.trim() || !settings?.enabled || !workspaces.length} className="brand-orb ml-auto flex size-10 items-center justify-center rounded disabled:opacity-40">{sending ? <Loader2 className="size-4 animate-spin" /> : <Send className="size-4" />}</button></div>
</form>
</footer>
</main>
);
}
export function visibleChannelMessages(messages: ChannelMessage[]) {
return messages.filter((message) => !message.authorId.startsWith("task:"));
}
export function ChannelMessageCard({ message, agentName, mode, onCancel }: { message: ChannelMessage; agentName: string; mode: "compact" | "timeline"; onCancel: () => void }) {
const task = message.task;
if (task) {
const active = task.status === "queued" || task.status === "running" || task.status === "cancelling";
return <article className="mb-6 overflow-hidden rounded-lg border border-line bg-panel" aria-label={`${agentName} work run`}>
<div className="flex items-center gap-2 border-b border-line bg-panel-2/60 px-4 py-3">
<span className="brand-orb flex size-8 items-center justify-center rounded"><Bot className="size-4" /></span>
<div className="min-w-0">
<strong className="block text-sm">Repository work</strong>
<time dateTime={new Date(task.createdAt).toISOString()} title={new Date(task.createdAt).toLocaleString()} className="block truncate text-[10px] text-ink-dim">{formatTaskTimestamp(task.createdAt)}</time>
</div>
<TaskStatus status={task.status} />
{active ? <button type="button" onClick={onCancel} disabled={task.status === "cancelling"} className="ml-auto flex min-h-9 items-center gap-1 rounded border border-line px-2 text-[11px] disabled:opacity-50"><Square className="size-3" />Cancel</button> : null}
</div>
<div className="grid gap-4 p-4 sm:grid-cols-[minmax(0,1fr)_auto]">
<div className="min-w-0">
<p className="text-[10px] font-semibold uppercase tracking-[0.14em] text-ink-dim">Objective</p>
<p className="mt-1 line-clamp-3 whitespace-pre-wrap text-sm leading-relaxed">{task.prompt}</p>
</div>
<dl className="grid grid-cols-2 gap-x-5 gap-y-2 text-xs sm:min-w-52">
<div><dt className="text-ink-dim">Agent</dt><dd className="mt-0.5 font-medium">{agentName}</dd></div>
<div><dt className="text-ink-dim">Steps</dt><dd className="mt-0.5 font-medium">{task.stepsCompleted}</dd></div>
<div className="col-span-2"><dt className="text-ink-dim">Model</dt><dd className="mt-0.5 truncate font-mono text-[10px]">{task.model}</dd></div>
</dl>
</div>
<div className={`flex items-center gap-2 border-t px-4 py-3 text-xs ${mode === "timeline" ? "border-accent/40 bg-accent-soft/20" : "border-line"}`}>
<Wrench className="size-3.5 shrink-0 text-accent" />
<span className="min-w-0 flex-1 truncate">{task.progress ?? (active ? "Preparing work…" : "Run finished")}</span>
{task.maxAttempts > 1 ? <span className="shrink-0 text-[10px] text-ink-dim">Attempt {task.attemptCount}/{task.maxAttempts}</span> : null}
</div>
{task.error ? <p role="alert" className="border-t border-line px-4 py-3 text-xs text-red-400">{task.error}</p> : null}
{task.output ? <p className="border-t border-line px-4 py-3 whitespace-pre-wrap text-sm leading-relaxed text-ink-muted">{task.output}</p> : null}
</article>;
}
const communication = message.agentCommunication;
const senderPerson = communication ? executionAgentPeople.get(communication.fromAgentId) : undefined;
const recipientPerson = communication ? executionAgentPeople.get(communication.toAgentId) : undefined;
const label = communication ? `Message from ${message.authorName} to ${communication.toAgentName}` : `Message from ${message.authorName}`;
return <article className="mb-5 rounded-lg border border-line bg-panel/50 p-4" aria-label={label}>
<div className="flex items-start gap-3">
{senderPerson
? <img src={senderPerson.avatarUrl} alt="" className="size-9 shrink-0 rounded-full border border-line object-cover" />
: <div className="flex size-8 shrink-0 items-center justify-center rounded bg-panel-2 text-xs font-semibold">Y</div>}
<div className="min-w-0 flex-1">
{communication
? <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
<strong className="text-sm">{senderPerson?.name ?? message.authorName}</strong>
<span className="text-ink-dim">{message.authorName} · {communication.fromAgentId}</span>
<ArrowRight aria-hidden="true" className="size-3.5 shrink-0 text-ink-dim" />
{recipientPerson ? <img src={recipientPerson.avatarUrl} alt="" className="size-5 shrink-0 rounded-full border border-line object-cover" /> : null}
<strong>{recipientPerson?.name ?? communication.toAgentName}</strong>
<span className="text-ink-dim">{communication.toAgentName} · {communication.toAgentId}</span>
<time className="basis-full text-[10px] text-ink-dim sm:ml-auto sm:basis-auto">{new Date(message.createdAt).toLocaleString()}</time>
</div>
: <div className="flex items-baseline gap-2"><strong className="text-sm">{message.authorName}</strong><time className="text-[10px] text-ink-dim">{new Date(message.createdAt).toLocaleString()}</time></div>}
{communication?.replyToMessageId
? <p aria-label={`Reply to message ${communication.replyToMessageId}`} className="mt-1 flex items-center gap-1 break-all text-[11px] text-ink-dim"><Reply aria-hidden="true" className="size-3 shrink-0" />Reply to message {communication.replyToMessageId}</p>
: null}
<p className="mt-2 whitespace-pre-wrap break-words text-sm leading-relaxed text-ink-muted">{message.content}</p>
</div>
</div>
</article>;
}
function TaskStatus({ status }: { status: NonNullable<ChannelMessage["task"]>["status"] }) {
const tone = status === "completed" ? "text-emerald-500" : status === "failed" ? "text-red-400" : status === "cancelled" ? "text-ink-dim" : "text-amber-400";
return <span className={`rounded bg-bg px-2 py-0.5 uppercase tracking-wide ${tone}`}>{status}</span>;
}
function EmptyChannel({ enabled }: { enabled: boolean }) {
return <div className="flex min-h-80 flex-col items-center justify-center text-center"><div className="brand-orb flex size-12 items-center justify-center rounded"><Hash className="size-5" /></div><h2 className="mt-4 font-medium">No messages yet</h2><p className="mt-1 max-w-sm text-sm text-ink-dim">{enabled ? "Start shared work by posting a message. Popagent will dispatch it in the selected repository." : "Channels are disabled. Enable them in Channels settings."}</p></div>;
}
function ChannelState({ icon, title, action, onAction }: { icon?: ReactNode; title: string; action?: string; onAction?: () => void }) {
return <main className="page-content flex min-w-0 flex-1 items-center justify-center"><div className="text-center text-sm text-ink-dim">{icon ? <div className="mb-3 flex justify-center">{icon}</div> : null}<p role={action ? "alert" : "status"}>{title}</p>{action ? <button type="button" onClick={onAction} className="mt-3 min-h-11 border border-line px-4">{action}</button> : null}</div></main>;
}
function mergeMessages(first: ChannelMessage[], second: ChannelMessage[]) {
return [...new Map([...first, ...second].map((message) => [message.id, message])).values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
}
async function cancelTask(channelId: string, taskId: string, refresh: (quiet?: boolean) => Promise<void>) {
const response = await apiFetch(`/api/channels/${encodeURIComponent(channelId)}/tasks/${encodeURIComponent(taskId)}/cancel`, { method: "POST" });
if (!response.ok) toast("Unable to cancel channel task");
await refresh(true);
}