Files
app/frontend/src/components/Chat/ChatPanel.tsx
T
stroblmeandClaude Fable 5.1 cc4091166e
Docs / docs (push) Successful in 42s
Playwright Tests / test-playwright (1, 2) (push) Failing after 2m42s
Playwright Tests / test-playwright (2, 2) (push) Failing after 10s
pre-commit / pre-commit (push) Failing after 2m33s
Test Backend / test-backend (push) Failing after 2m48s
Compose Smoke Test / test-compose (push) Failing after 9s
Playwright Tests / merge-reports (push) Failing after 2m17s
Add the assistant: a chat that drives the MCP tools in-process
A provider set under Settings (OpenAI-compatible; key encrypted in
flow-data/ai.json) turns on an Assistant panel in the sidebar. Each turn runs
the FastMCP tools in-process as the person chatting, streams the rounds over
SSE and keeps one transcript per user. The panel floats on any screen and
shares the editor's lane: a settings panel keeps the top half, the chat the
bottom. The MCP client is now built once, for the endpoint and the chat.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 23:28:23 +02:00

308 lines
9.6 KiB
TypeScript

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<string, unknown>
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<string, unknown> | undefined
try {
args = JSON.parse(fn.arguments ?? "{}") as Record<string, unknown>
} 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<Item[]>([])
const [loaded, setLoaded] = useState(false)
const [draft, setDraft] = useState("")
const [busy, setBusy] = useState(false)
const controller = useRef<AbortController | null>(null)
const bottom = useRef<HTMLDivElement>(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 (
<SidePanel
open={open}
label="Assistant"
testId="chat-panel"
bodyKey="chat"
fixed
stacked={panels > 0 && !isMobile ? "bottom" : false}
onClose={chatStore.close}
header={
<>
<Bot className="size-4 shrink-0 text-muted-foreground" />
{TABS.length > 1 ? (
<Segmented
value={TABS[0][0]}
options={TABS}
label="What to talk about"
onChange={() => {}}
/>
) : (
<span className="text-sm font-medium">Assistant</span>
)}
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{status.data.model}
</span>
<Button
variant="ghost"
size="icon-sm"
className="shrink-0 text-muted-foreground"
onClick={clear}
disabled={items.length === 0}
aria-label="Clear the conversation"
>
<Trash2 />
</Button>
</>
}
footer={
<form
className="flex items-center gap-2"
onSubmit={(event) => {
event.preventDefault()
void send()
}}
>
<Input
value={draft}
placeholder={
flow
? `Ask about ${flow}`
: "Ask for a flow, a fix, or an explanation"
}
aria-label="Message"
autoComplete="off"
data-testid="chat-input"
onChange={(event) => setDraft(event.target.value)}
/>
{busy ? (
<Button
type="button"
variant="outline"
size="icon"
className="shrink-0"
onClick={() => controller.current?.abort()}
aria-label="Stop"
>
<Square />
</Button>
) : (
<Button
type="submit"
size="icon"
className="shrink-0"
disabled={!draft.trim()}
aria-label="Send"
>
<Send />
</Button>
)}
</form>
}
>
<div className="flex flex-col gap-2 p-4">
{items.length === 0 && !busy ? (
<p className="py-8 text-center text-sm text-muted-foreground">
Describe a flow to build, a change to make, or ask what a failure
means. Drafts land in the editor for you to publish.
</p>
) : null}
{items.map((item, index) => (
<Message key={index} item={item} />
))}
{busy ? (
<FluksioLoader className="size-4 text-muted-foreground" />
) : null}
<div ref={bottom} />
</div>
</SidePanel>
)
}
function Message({ item }: { item: Item }) {
switch (item.kind) {
case "user":
return (
<div className="max-w-[85%] self-end whitespace-pre-wrap break-words rounded-lg bg-primary px-3 py-1.5 text-sm text-primary-foreground">
{item.text}
</div>
)
case "assistant":
return (
<div className="self-start whitespace-pre-wrap break-words px-1 text-sm">
{item.text}
</div>
)
case "error":
return <div className="px-1 text-xs text-destructive">{item.text}</div>
case "tool":
return (
<details
className={cn(
"self-start px-1 font-mono text-xs",
item.ok === false ? "text-destructive" : "text-muted-foreground",
)}
>
<summary className="cursor-pointer">
{item.ok === undefined ? "…" : item.ok ? "✓" : "✗"} {item.name}
</summary>
<pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap break-all rounded-md bg-muted p-2">
{item.error ??
JSON.stringify(item.args ?? {}, null, 1).slice(0, 2000)}
</pre>
</details>
)
}
}