import { queryOptions, useQuery, useQueryClient } from "@tanstack/react-query" import { useRouterState } from "@tanstack/react-router" import { Bird, Bot, MessageCircle, Send, Square, Trash2 } from "lucide-react" import { useEffect, useRef, useState } from "react" import { AiService, type ChatItem } from "@/client" import { flowKeys } from "@/components/Flow/queries" import { SidePanel } from "@/components/Flow/SidePanel" import { Button } from "@/components/ui/button" import { FluksioLoader } from "@/components/ui/fluksio-loader" import { Input } from "@/components/ui/input" import { Segmented } from "@/components/ui/segmented" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select" import { useIsMobile } from "@/hooks/useMobile" import { cn } from "@/lib/utils" import { type ChatTab, chatStore, useChat } from "./chatStore" import { type ChatEvent, streamTurn } from "./stream" import { type Action, type ControlItem, useControl } from "./useControl" export const aiStatusQueryOptions = () => queryOptions({ queryKey: ["ai", "status"], queryFn: () => AiService.readAiStatus(), staleTime: 60_000, }) /** * The wren nodes the Control tab can talk to. Under the flows prefix: the * socket invalidates it on every rebuild, so a node published anywhere shows * up without a reload. */ export const controlNodesQueryOptions = () => queryOptions({ queryKey: [...flowKeys.all, "control-nodes"], queryFn: () => AiService.readControlNodes(), }) /** * What the panel is for: building flows with the assistant, or commanding a * wren node in one. A tab is there when there is something behind it, and the * strip only draws once there is a choice to make. */ const TABS: readonly (readonly [ChatTab, string])[] = [ ["build", "Build"], ["control", "Control"], ] type Item = | { kind: "user" | "assistant" | "error"; text: string } | { kind: "tool" name: string args?: Record ok?: boolean error?: string } | ControlItem /** The stored transcript, in the shape the live turn appends. */ function fromTranscript(transcript: ChatItem[]): Item[] { const items: Item[] = [] // A tool's answer follows its call; the earliest unanswered call is its. const pending: number[] = [] for (const message of transcript) { if (message.role === "user" && message.content) { items.push({ kind: "user", text: message.content }) } else if (message.role === "assistant") { if (message.content) items.push({ kind: "assistant", text: message.content }) for (const call of message.tool_calls ?? []) { const fn = (call.function ?? {}) as { name?: string arguments?: string } let args: Record | undefined try { args = JSON.parse(fn.arguments ?? "{}") as Record } catch { args = undefined } pending.push(items.length) items.push({ kind: "tool", name: fn.name ?? "tool", args }) } } else if (message.role === "tool") { const index = pending.shift() const call = index === undefined ? undefined : items[index] if (call?.kind === "tool") { call.ok = message.ok ?? true call.error = message.error ?? undefined } } } return items } /** * The way into the Control tab from Home, there only while a wren node is. * Shaped like the sidebar trigger, which it mirrors on a phone. */ export function ControlTrigger({ className }: { className?: string }) { const { open, tab } = useChat() const nodes = useQuery(controlNodesQueryOptions()).data ?? [] if (nodes.length === 0) return null return ( ) } /** A read leaves nothing to refetch; anything else may have changed a flow. */ const isRead = (name: string) => /^(list|get)_/.test(name) const FLOW_PATH = /^\/flows\/([a-z][a-z0-9_]*)/ /** * The assistant, floating over whichever screen it was opened on and sharing * the editor's lane with its panels: when one of those is open the chat takes * the lower half. A phone gets it as a sheet, like every other panel. */ export function ChatPanel() { const chat = useChat() const { open, panels } = chat const isMobile = useIsMobile() const queryClient = useQueryClient() const status = useQuery(aiStatusQueryOptions()) const nodes = useQuery(controlNodesQueryOptions()).data ?? [] const [nodeId, setNodeId] = useState() const node = nodes.find((n) => n.id === nodeId) ?? nodes[0] const control = useControl(node) const pathname = useRouterState({ select: (s) => s.location.pathname }) const flow = FLOW_PATH.exec(pathname)?.[1] const [items, setItems] = useState([]) const [loaded, setLoaded] = useState(false) const [draft, setDraft] = useState("") const [busy, setBusy] = useState(false) const controller = useRef(null) const bottom = useRef(null) const available = Boolean(status.data?.available) const tabs = TABS.filter(([key]) => key === "build" ? available : nodes.length > 0, ) const tab = tabs.some(([key]) => key === chat.tab) ? chat.tab : tabs[0]?.[0] const controlling = tab === "control" const shown: Item[] = controlling ? control.items : items const waiting = controlling ? control.busy : busy useEffect(() => { if (!open || loaded || !available) return AiService.readChat() .then((transcript) => setItems(fromTranscript(transcript))) .finally(() => setLoaded(true)) }, [open, loaded, available]) // biome-ignore lint/correctness/useExhaustiveDependencies: scroll when the list grows, not on every render. useEffect(() => { bottom.current?.scrollIntoView({ block: "end" }) }, [shown.length, waiting]) const send = async () => { const text = draft.trim() if (!text || busy) return setDraft("") setBusy(true) setItems((prev) => [...prev, { kind: "user", text }]) const abort = new AbortController() controller.current = abort const onEvent = (event: ChatEvent) => { switch (event.type) { case "message": setItems((prev) => [ ...prev, { kind: "assistant", text: event.content }, ]) break case "tool_call": setItems((prev) => [ ...prev, { kind: "tool", name: event.name, args: event.arguments }, ]) break case "tool_result": setItems((prev) => { const next = [...prev] for (let i = next.length - 1; i >= 0; i--) { const item = next[i] if (item.kind === "tool" && item.ok === undefined) { next[i] = { ...item, ok: event.ok, error: event.error } break } } return next }) if (!isRead(event.name)) { queryClient.invalidateQueries({ queryKey: flowKeys.all }) } break case "error": setItems((prev) => [...prev, { kind: "error", text: event.detail }]) break } } await streamTurn({ message: text, flow, signal: abort.signal, onEvent }) controller.current = null setBusy(false) } const clear = () => { controller.current?.abort() setItems([]) void AiService.clearChat() } if (!tab) return null return ( 0 && !isMobile ? "bottom" : false} onClose={chatStore.close} header={ <> {controlling ? ( ) : ( )} {tabs.length > 1 ? ( chatStore.show(next as ChatTab)} /> ) : ( {controlling ? "Control" : "Assistant"} )} {controlling && nodes.length > 1 ? (
) : ( {controlling ? node?.title : status.data?.model} )} } footer={
{ event.preventDefault() if (!controlling) { void send() return } const text = draft.trim() if (!text || control.busy) return setDraft("") void control.send(text) }} > setDraft(event.target.value)} /> {busy ? ( ) : ( )}
} >
{shown.length === 0 && !waiting ? (

{controlling ? "Type a command, like turn on the lights in the kitchen. What it did comes back here." : "Describe a flow to build, a change to make, or ask what a failure means. Drafts land in the editor for you to publish."}

) : null} {shown.map((item, index) => ( ))} {waiting ? ( ) : null}
) } function Message({ item }: { item: Item }) { switch (item.kind) { case "user": return (
{item.text}
) case "assistant": return (
{item.text}
) case "error": return
{item.text}
case "actions": return case "tool": return (
{item.ok === undefined ? "…" : item.ok ? "✓" : "✗"} {item.name}
            {item.error ??
              JSON.stringify(item.args ?? {}, null, 1).slice(0, 2000)}
          
) } } /** A wren node's answer: each call, where it went, and whether it was applied. */ function Actions({ actions }: { actions: Action[] }) { if (actions.length === 0) { return (
Nothing to do.
) } return (
{actions.map((action, index) => (
{action.applied ? "✓" : "–"} {action.call} {action.message ? ` → ${action.message} = ${JSON.stringify(action.value)}` : ""} {action.delay_s ? ` in ${Math.round(action.delay_s / 60)} min` : ""} {action.applied ? "" : action.message ? " (not applied)" : " (nothing wired for it)"}
))}
confidence {actions[0].confidence.toFixed(2)}
) }