AkurAI Build
Menu

popagent

public

Latest change 87892c407de7c8d7b5c93e23f81356985d4f6b97 - fix: replace channel activity spam 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 { 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="flex min-h-16 items-center gap-3 border-b border-line bg-panel/90 px-3 backdrop-blur md:px-5">
        <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="min-h-0 flex-1 overflow-y-auto px-3 py-5 md:px-8">
        <div className="mx-auto max-w-4xl">
          {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="border-t border-line bg-panel/80 px-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 backdrop-blur md:px-8">
        <form className="liquid-composer mx-auto max-w-4xl 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>
          <span className="block truncate font-mono text-[10px] text-ink-dim">{task.id}</span>
        </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="flex min-w-0 flex-1 items-center justify-center p-6"><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);
}