import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "@termix/plugin-sdk/frontend"; import { Loader2, Send, Settings2, Sparkles, Square } from "lucide-react"; import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Textarea, } from "@termix/plugin-sdk/ui"; import { getAiConversation, getAiProviders, getAiStatus, setAiOptIn, type AiProposal, type AiProvider, } from "./ai-api"; import { AiMessage } from "./AiMessage"; import { AiProviderSettings } from "./AiProviderSettings"; import { AiToolCall } from "./AiToolCall"; import { ProposalCard } from "./ProposalCard"; import { useAiStream, type ToolActivity } from "./use-ai-stream"; import { activeMentionQuery, useMentions, type MentionItem, } from "./useMentions"; import { mentionLabel } from "./labels"; interface HistoryEntry { role: "user" | "assistant"; content: string; } /** One entry in the rendered conversation, in the order it happened. */ type TimelineItem = | { kind: "message"; key: string; role: "user" | "assistant"; content: string; } | { kind: "tool"; key: string; tool: ToolActivity } | { kind: "proposal"; key: string; proposal: AiProposal }; export function AiPanel({ activeTab }: { activeTab?: string | null }) { const { t } = useTranslation(); const { state, send, stop } = useAiStream(); const [providers, setProviders] = useState([]); const [providerId, setProviderId] = useState(null); const [loading, setLoading] = useState(true); const [showSettings, setShowSettings] = useState(false); const [input, setInput] = useState(""); const [history, setHistory] = useState([]); const [resolvedProposals, setResolvedProposals] = useState< Record >({}); const scrollRef = useRef(null); const textareaRef = useRef(null); const [mention, setMention] = useState<{ query: string; start: number; } | null>(null); const [mentionIndex, setMentionIndex] = useState(0); const { search: searchMentions } = useMentions(Boolean(providerId)); const mentionMatches = mention ? searchMentions(mention.query) : []; const updateMentionQuery = (value: string, caret: number) => { const next = activeMentionQuery(value, caret); setMention(next); setMentionIndex(0); }; /** * Replaces the "@partial" under the caret with the chosen name. The mention * is plain text: the assistant still has to call a read tool to look the * item up, so nothing is attached to the prompt behind the user's back. */ const insertMention = (item: MentionItem) => { if (!mention) return; const caret = textareaRef.current?.selectionStart ?? input.length; const before = input.slice(0, mention.start); const after = input.slice(caret); const inserted = `@${item.label} `; setInput(`${before}${inserted}${after}`); setMention(null); requestAnimationFrame(() => { const position = before.length + inserted.length; textareaRef.current?.focus(); textareaRef.current?.setSelectionRange(position, position); }); }; const loadProviders = useCallback(async (selectId?: number) => { try { const list = await getAiProviders(); setProviders(list); setProviderId((current) => { // A provider that was just added becomes the active one, so there is // no second step between creating it and being able to use it. if (selectId && list.some((entry) => entry.id === selectId)) { return selectId; } // Fall back to the first if the current selection has gone away. if (current && list.some((entry) => entry.id === current)) { return current; } return list[0]?.id ?? null; }); return list; } catch { setProviders([]); return []; } }, []); useEffect(() => { let cancelled = false; (async () => { try { const status = await getAiStatus(); if (cancelled) return; // Opening the panel is consent to use it. Without this every write // 403s through the gate, which surfaces as a bare permission error // rather than anything the user can act on. if (status.globallyEnabled && !status.enabled) { await setAiOptIn(true); } if (!status.globallyEnabled) return; const list = await loadProviders(); // With nothing configured there is nothing to chat with, so go // straight to the form instead of showing a card that just vanishes. if (!cancelled && list.length === 0) setShowSettings(true); } finally { if (!cancelled) setLoading(false); } })(); return () => { cancelled = true; }; }, [loadProviders]); useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth", }); }, [state.assistantText, state.tools.length, history.length]); const reloadHistory = useCallback( async (conversationId: number | null, reply: string) => { // Reload from the server so the transcript is the stored one rather // than a client-side reconstruction. if (conversationId) { try { const loaded = await getAiConversation(conversationId); setHistory( loaded.messages .filter((entry) => entry.role !== "tool") .map((entry) => ({ role: entry.role as "user" | "assistant", content: entry.content, })), ); return; } catch { // Fall through to keeping the streamed copy. } } // No stored copy to fall back on, so the streamed reply is appended // locally. Without this it would vanish when the next turn resets the // stream state. if (reply) { setHistory((prev) => [...prev, { role: "assistant", content: reply }]); } }, [], ); async function handleSend() { const message = input.trim(); if (!message || !providerId || state.streaming) return; setInput(""); setHistory((prev) => [...prev, { role: "user", content: message }]); await send({ message, providerId, conversationId: state.conversationId, activeTab, onComplete: reloadHistory, }); } /** * Records the outcome on the card and tells the assistant what happened. * * Without the follow-up the run just stops: the assistant proposed a * command, the user approved it, and the model never learns it produced * output, so it sits there as if the tool were still running. */ function handleProposalResolved( id: number, status: "applied" | "rejected", resultSummary?: string, ) { setResolvedProposals((prev) => ({ ...prev, [id]: { status, resultSummary }, })); if (!providerId || state.streaming) return; const proposal = state.proposals.find((entry) => entry.id === id); const label = proposal?.summary ?? proposal?.kind ?? `proposal ${id}`; const followUp = status === "applied" ? resultSummary ? `I approved "${label}". Result:\n\n${resultSummary}` : `I approved "${label}". It completed.` : `I rejected "${label}". Do not retry it unless I ask.`; setHistory((prev) => [...prev, { role: "user", content: followUp }]); void send({ message: followUp, providerId, conversationId: state.conversationId, activeTab, onComplete: reloadHistory, }); } const proposals: AiProposal[] = state.proposals.map((proposal) => { const resolved = resolvedProposals[proposal.id]; if (!resolved) return proposal; return { ...proposal, status: resolved.status, resultSummary: resolved.resultSummary ?? proposal.resultSummary, }; }); /** * The reply exists in two places once a turn finishes: the streamed text in * `state`, and the stored copy that `onComplete` reloads into `history`. * Whichever arrives is shown exactly once, and the stored copy wins because * it sits in the right place in the timeline. */ const last = history[history.length - 1]; const historyEndsWithThisReply = state.assistantText.length > 0 && last?.role === "assistant" && last.content === state.assistantText; const streamingReply = historyEndsWithThisReply ? "" : state.assistantText; /** * One ordered list rather than three stacked blocks. * * Tool calls happen before the reply that describes them, but once a turn * ends its reply moves into `history`, which used to render above a separate * tools block. The tool call appeared to jump below the answer it produced. * Splicing the current turn's activity in ahead of its own reply keeps the * order stable across that handover. */ const timeline: TimelineItem[] = []; const trailingReply = historyEndsWithThisReply ? history.length - 1 : -1; history.forEach((entry, index) => { if (index === trailingReply) return; timeline.push({ kind: "message", key: `history-${index}`, role: entry.role, content: entry.content, }); }); for (const tool of state.tools) { timeline.push({ kind: "tool", key: tool.id, tool }); } if (trailingReply >= 0) { timeline.push({ kind: "message", key: `history-${trailingReply}`, role: "assistant", content: history[trailingReply].content, }); } else if (streamingReply) { timeline.push({ kind: "message", key: "streaming", role: "assistant", content: streamingReply, }); } for (const proposal of proposals) { timeline.push({ kind: "proposal", key: `proposal-${proposal.id}`, proposal, }); } if (loading) { return (
); } return (
{/* The panel also lives in the narrow sidebar, so the title and the provider picker get their own rows rather than competing for one line. */}
{t("ai.title")} {t("hosts.docsLink")}
{providers.length > 0 && (
)}
{showSettings && (
setShowSettings(false)} />
)}
{timeline.map((item) => { if (item.kind === "message") { return ( ); } if (item.kind === "tool") { return ( ); } return ( ); })} {state.error && (
{state.error}
)}
{mentionMatches.length > 0 && (
{mentionMatches.map((item, index) => ( ))}
)}