import { queryOptions, useQuery, useQueryClient } from "@tanstack/react-query" import { useRouterState } from "@tanstack/react-router" import { Bot, 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 { useIsMobile } from "@/hooks/useMobile" import { cn } from "@/lib/utils" import { chatStore, useChat } from "./chatStore" import { type ChatEvent, streamTurn } from "./stream" export const aiStatusQueryOptions = () => queryOptions({ queryKey: ["ai", "status"], queryFn: () => AiService.readAiStatus(), staleTime: 60_000, }) /** * What the panel is for. One tab today; the second, talking to a wren node in * a flow, joins here when that node exists, and the strip only draws once * there is a choice to make. */ const TABS = [["build", "Build"]] as const type Item = | { kind: "user" | "assistant" | "error"; text: string } | { kind: "tool" name: string args?: Record ok?: boolean error?: string } /** 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 } /** 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 { open, panels } = useChat() const isMobile = useIsMobile() const queryClient = useQueryClient() const status = useQuery(aiStatusQueryOptions()) 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) useEffect(() => { if (!open || loaded) return AiService.readChat() .then((transcript) => setItems(fromTranscript(transcript))) .finally(() => setLoaded(true)) }, [open, loaded]) // biome-ignore lint/correctness/useExhaustiveDependencies: scroll when the list grows, not on every render. useEffect(() => { bottom.current?.scrollIntoView({ block: "end" }) }, [items.length, busy]) 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 (!status.data?.available) return null return ( 0 && !isMobile ? "bottom" : false} onClose={chatStore.close} header={ <> {TABS.length > 1 ? ( {}} /> ) : ( Assistant )} {status.data.model} } footer={
{ event.preventDefault() void send() }} > setDraft(event.target.value)} /> {busy ? ( ) : ( )}
} >
{items.length === 0 && !busy ? (

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} {items.map((item, index) => ( ))} {busy ? ( ) : 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 "tool": return (
{item.ok === undefined ? "…" : item.ok ? "✓" : "✗"} {item.name}
            {item.error ??
              JSON.stringify(item.args ?? {}, null, 1).slice(0, 2000)}
          
) } }