import { type ReactNode, useEffect, useRef, useState, useSyncExternalStore } from "react"; import { aceAvatar, type Attachment, Button, ChatComposer, type ChatComposerHandle, serialize, toast, useMedia, } from "@ace/ui"; import type { Effort, ModelOption, ModelRef } from "@ace/channel/protocol"; import type { Listing } from "@ace/host/protocol"; import { host } from "./host"; import { images } from "./images"; const ACE_MODE = { id: "ace", name: "Ace", placeholder: "Ask the agent", mention: "ace" }; const MODES = [ { id: "chat", name: "Chat", placeholder: "Mention @ace to ask the agent" }, ACE_MODE, ]; // Teammates keep the Ace mode and their drafts while the owner has turned invocation off. const BLOCKED_MODES = [{ id: "chat", name: "Chat", placeholder: "Message the channel" }, ACE_MODE]; const MENTIONS = [{ name: "ace", avatar: aceAvatar }]; const EFFORTS: Record = { off: "Off", minimal: "Minimal", low: "Low", medium: "Medium", high: "High", xhigh: "Extra high", max: "Max", }; const ACE = /(^|\s)@ace\b/i; // Channel views remount on navigation; a deliberate mode choice outlives them for this session. const chosen = new Map(); const key = (model: ModelRef) => `${model.provider}/${model.modelId}`; type Props = { channel: Listing; chat: number; user: string; current?: ModelRef; currentEffort?: Effort; busy: boolean; /** Whether teammates may invoke agents; absent while unknown. */ shared?: boolean; ready: boolean; draft?: string; onDraftLoaded: () => void; onSettings?: () => void; accessory?: ReactNode; }; /** The channel refuses these too; holding them here keeps the draft instead of failing a send. */ function useInvocation(shared: boolean | undefined, owner: boolean, mode: string) { const [mentioned, setMentioned] = useState(false); const blocked = shared === false && !owner; return { blocked, refused: blocked && (mode === "ace" || mentioned), onText: (text: string) => setMentioned(ACE.test(text)), }; } /** Below the composer: its attachment tray rises over the space above it and takes clicks there. */ function Notice({ children, action }: { children: ReactNode; action?: ReactNode }) { return (

{children}

{action}
); } /** Why a teammate's invoking draft is held while the owner has turned invocation off. */ function Refusal({ mode, onChat }: { mode: string; onChat: () => void }) { if (mode !== "ace") { return ( The owner has turned off teammate agent invocation. Remove @ace to send this as a message. ); } return ( Switch to Chat}> The owner has turned off teammate agent invocation. Switch to Chat to send this as a message. ); } /** A failed admission keeps the draft and attachments available for retry. */ export function Composer( { channel, chat: id, user, current, currentEffort, busy, shared, ready, draft, onDraftLoaded, onSettings, accessory, }: Props, ) { const composer = useRef(null); const sending = useRef(false); const [pending, setPending] = useState(false); const [attached, setAttached] = useState([]); const [mode, setMode] = useState(chosen.get(channel.id) || "ace"); const [models, setModels] = useState(); const [modelError, setModelError] = useState(); const status = useSyncExternalStore(host.subscribe, () => host.status); const settingsVersion = useSyncExternalStore(host.subscribe, () => host.settingsVersion); useEffect(() => { if (!draft) return; composer.current?.set(ACE.test(draft) ? draft : `@ace ${draft}`); onDraftLoaded(); }, [draft, onDraftLoaded]); useEffect(() => { if (status !== "open" || mode !== "ace") return; let active = true; // Ask the channel itself: a hosted channel uses the service's credentials, not its workspace's. host.channel(channel.id, { op: "models" }).then( (models) => { if (!active) return; setModels(models); setModelError(undefined); }, (error: Error) => { if (!active) return; setModels([]); setModelError(error.message); }, ); return () => { active = false; }; }, [channel.id, mode, status, settingsVersion]); const [picked, setPicked] = useState(); const model = picked || (current ? key(current) : "auto"); const [pickedEffort, setPickedEffort] = useState(); const levels = models?.find((value) => key(value) === model)?.efforts; const requestedEffort = pickedEffort || (current && key(current) === model ? currentEffort : undefined) || "off"; const effort = requestedEffort === "off" || levels?.includes(requestedEffort) ? requestedEffort : "off"; const efforts = levels && levels.some((level) => level !== "off") ? (levels.includes("off") ? levels : ["off" as const, ...levels]).map((id) => ({ id, name: id === "off" && !levels.includes("off") ? "Default" : EFFORTS[id], })) : undefined; const choices = (models || []).map((value) => ({ id: key(value), name: value.modelId, vendor: value.provider, })); if (model === "auto") choices.unshift({ id: "auto", name: "Automatic", vendor: "" }); else if (!choices.some((value) => value.id === model)) { choices.unshift({ id: model, name: model.split("/").slice(1).join("/"), vendor: "" }); } const { blocked, refused, onText } = useInvocation(shared, channel.owner === user, mode); const changeMode = (value: string) => { chosen.set(channel.id, value); setMode(value); }; // Formatting tools crowd a phone's composer; they stay one tap away. const phone = useMedia("(width < 40rem)"); async function send(text: string, mode: string, attached: Attachment[]) { if (sending.current) return; const invoke = mode === "ace" || ACE.test(text); const body = text.trim(); if (!body && !attached.length) return; if (invoke && blocked) { return void toast.error("Could not invoke agent", { description: "The owner has turned off teammate agent invocation.", }); } const doc = composer.current?.get(); sending.current = true; setPending(true); try { const encoded = attached.length ? { images: await images(attached) } : {}; if (invoke) { const [provider, ...rest] = model.split("/"); await host.channel(channel.id, { op: "ask", ...(levels ? { effort } : {}), chat: id, author: user, text: body, ...encoded, ...(model === "auto" ? {} : { model: { provider: provider!, modelId: rest.join("/") } }), }); } else { await host.channel(channel.id, { op: "say", chat: id, author: user, text: body, ...encoded, }); } if (composer.current?.get() === doc) composer.current?.clear(); const sent = new Set(attached); setAttached((current) => current.filter((value) => !sent.has(value))); } catch (error) { toast.error(invoke ? "Could not invoke agent" : "Could not send message", { description: (error as Error).message, ...(invoke && onSettings ? { action: { label: "Settings", onClick: onSettings } } : {}), }); } finally { sending.current = false; setPending(false); } } return ( <> { setPicked(value === "auto" ? undefined : value); setPickedEffort(undefined); }} efforts={efforts} effort={effort} onEffortChange={(value) => setPickedEffort(value as Effort)} attachments={attached} onAttachmentsChange={setAttached} busy={busy} submitBusy={pending} canStop={busy} onStop={() => void host.channel(channel.id, { op: "stop", chat: id })} canSend={ready && status === "open" && !pending && !refused} clearOnSend={false} onSend={({ doc, mode, attachments }) => void send(serialize(doc), mode, attachments)} /> {refused ? changeMode("chat")} /> : mode === "ace" && models?.length === 0 && ( Provider settings )} > {modelError || "Add a provider to use Ace. You can still send messages in Chat mode."} )} ); }