Talk to wren nodes from the chat's Control tab, opened from Home

This commit is contained in:
2026-10-05 20:59:11 +02:00
parent 14d3208a5d
commit afe0c94a24
11 changed files with 417 additions and 42 deletions
+174 -32
View File
@@ -1,6 +1,6 @@
import { queryOptions, useQuery, useQueryClient } from "@tanstack/react-query"
import { useRouterState } from "@tanstack/react-router"
import { Bot, Send, Square, Trash2 } from "lucide-react"
import { Bird, Bot, MessageCircle, Send, Square, Trash2 } from "lucide-react"
import { useEffect, useRef, useState } from "react"
import { AiService, type ChatItem } from "@/client"
@@ -10,10 +10,18 @@ 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 { chatStore, useChat } from "./chatStore"
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({
@@ -23,11 +31,25 @@ export const aiStatusQueryOptions = () =>
})
/**
* 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.
* 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.
*/
const TABS = [["build", "Build"]] as const
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 }
@@ -38,6 +60,7 @@ type Item =
ok?: boolean
error?: string
}
| ControlItem
/** The stored transcript, in the shape the live turn appends. */
function fromTranscript(transcript: ChatItem[]): Item[] {
@@ -76,6 +99,33 @@ function fromTranscript(transcript: ChatItem[]): Item[] {
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 (
<Button
variant="ghost"
size="icon"
className={cn("size-7 text-muted-foreground", className)}
onClick={() =>
open && tab === "control"
? chatStore.close()
: chatStore.open("control")
}
aria-label="Chat"
title="Chat"
data-testid="control-trigger"
>
<MessageCircle />
</Button>
)
}
/** A read leaves nothing to refetch; anything else may have changed a flow. */
const isRead = (name: string) => /^(list|get)_/.test(name)
@@ -87,10 +137,15 @@ const FLOW_PATH = /^\/flows\/([a-z][a-z0-9_]*)/
* the lower half. A phone gets it as a sheet, like every other panel.
*/
export function ChatPanel() {
const { open, panels } = useChat()
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<string>()
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]
@@ -101,17 +156,26 @@ export function ChatPanel() {
const controller = useRef<AbortController | null>(null)
const bottom = useRef<HTMLDivElement>(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) return
if (!open || loaded || !available) return
AiService.readChat()
.then((transcript) => setItems(fromTranscript(transcript)))
.finally(() => setLoaded(true))
}, [open, loaded])
}, [open, loaded, available])
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll when the list grows, not on every render.
useEffect(() => {
bottom.current?.scrollIntoView({ block: "end" })
}, [items.length, busy])
}, [shown.length, waiting])
const send = async () => {
const text = draft.trim()
@@ -167,7 +231,7 @@ export function ChatPanel() {
void AiService.clearChat()
}
if (!status.data?.available) return null
if (!tab) return null
return (
<SidePanel
@@ -180,26 +244,54 @@ export function ChatPanel() {
onClose={chatStore.close}
header={
<>
<Bot className="size-4 shrink-0 text-muted-foreground" />
{TABS.length > 1 ? (
{controlling ? (
<Bird className="size-4 shrink-0 text-muted-foreground" />
) : (
<Bot className="size-4 shrink-0 text-muted-foreground" />
)}
{tabs.length > 1 ? (
<Segmented
value={TABS[0][0]}
options={TABS}
value={tab}
options={tabs}
label="What to talk about"
onChange={() => {}}
testId="chat-tabs"
onChange={(next) => chatStore.show(next as ChatTab)}
/>
) : (
<span className="text-sm font-medium">Assistant</span>
<span className="text-sm font-medium">
{controlling ? "Control" : "Assistant"}
</span>
)}
{controlling && nodes.length > 1 ? (
<div className="min-w-0 flex-1">
<Select value={node?.id} onValueChange={setNodeId}>
<SelectTrigger
size="sm"
className="max-w-full text-xs"
aria-label="Which node to talk to"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{nodes.map((n) => (
<SelectItem key={n.id} value={n.id}>
{n.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{controlling ? node?.title : status.data?.model}
</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}
onClick={controlling ? control.clear : clear}
disabled={shown.length === 0}
aria-label="Clear the conversation"
>
<Trash2 />
@@ -211,15 +303,24 @@ export function ChatPanel() {
className="flex items-center gap-2"
onSubmit={(event) => {
event.preventDefault()
void send()
if (!controlling) {
void send()
return
}
const text = draft.trim()
if (!text || control.busy) return
setDraft("")
void control.send(text)
}}
>
<Input
value={draft}
placeholder={
flow
? `Ask about ${flow}`
: "Ask for a flow, a fix, or an explanation"
controlling
? "Type a command"
: flow
? `Ask about ${flow}`
: "Ask for a flow, a fix, or an explanation"
}
aria-label="Message"
autoComplete="off"
@@ -242,7 +343,7 @@ export function ChatPanel() {
type="submit"
size="icon"
className="shrink-0"
disabled={!draft.trim()}
disabled={!draft.trim() || waiting}
aria-label="Send"
>
<Send />
@@ -252,16 +353,17 @@ export function ChatPanel() {
}
>
<div className="flex flex-col gap-2 p-4">
{items.length === 0 && !busy ? (
{shown.length === 0 && !waiting ? (
<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.
{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."}
</p>
) : null}
{items.map((item, index) => (
{shown.map((item, index) => (
<Message key={index} item={item} />
))}
{busy ? (
{waiting ? (
<FluksioLoader className="size-4 text-muted-foreground" />
) : null}
<div ref={bottom} />
@@ -286,6 +388,8 @@ function Message({ item }: { item: Item }) {
)
case "error":
return <div className="px-1 text-xs text-destructive">{item.text}</div>
case "actions":
return <Actions actions={item.actions} />
case "tool":
return (
<details
@@ -305,3 +409,41 @@ function Message({ item }: { item: Item }) {
)
}
}
/** 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 (
<div className="self-start px-1 text-sm text-muted-foreground">
Nothing to do.
</div>
)
}
return (
<div className="grid min-w-0 gap-1 self-start px-1 font-mono text-xs">
{actions.map((action, index) => (
<div
key={index}
className={cn(
"break-words",
!action.applied && "text-muted-foreground",
)}
>
{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)"}
</div>
))}
<div className="text-muted-foreground">
confidence {actions[0].confidence.toFixed(2)}
</div>
</div>
)
}
+10 -4
View File
@@ -1,16 +1,20 @@
import { useSyncExternalStore } from "react"
/** What the panel is talking to: the assistant, or a wren node in a flow. */
export type ChatTab = "build" | "control"
/**
* Whether the assistant is open, and whether another panel is open beside it.
* Whether the assistant is open, on which tab, and whether another panel is
* open beside it.
*
* Module state rather than context because the chat is mounted once in the
* sidebar and toggled from there, while the editor's own panels report in
* from a different tree: the two only have to agree on who takes which half
* of the lane.
*/
type State = { open: boolean; panels: number }
type State = { open: boolean; tab: ChatTab; panels: number }
let state: State = { open: false, panels: 0 }
let state: State = { open: false, tab: "build", panels: 0 }
const listeners = new Set<() => void>()
function set(next: Partial<State>) {
@@ -19,7 +23,9 @@ function set(next: Partial<State>) {
}
export const chatStore = {
open: () => set({ open: true }),
/** Open it, on a given tab or on whichever was last shown. */
open: (tab?: ChatTab) => set(tab ? { open: true, tab } : { open: true }),
show: (tab: ChatTab) => set({ tab }),
close: () => set({ open: false }),
toggle: () => set({ open: !state.open }),
/** A floating panel is open; returns how to say it closed again. */
+102
View File
@@ -0,0 +1,102 @@
import { useEffect, useState } from "react"
import { ApiError, type ControlNode, MessagesService } from "@/client"
import {
liveStore,
useLiveValue,
useNodeFailure,
} from "@/components/Flow/liveStore"
/** One call the node answered with, as its `actions` output records it. */
export type Action = {
message: string
value: unknown
applied: boolean
delay_s: number | null
call: string
confidence: number
}
export type ControlItem =
| { kind: "user" | "error"; text: string }
| { kind: "actions"; actions: Action[] }
/** How long a command may go unanswered before the chat says so. */
const TIMEOUT_MS = 15_000
/**
* Talking to a wren node: a command goes out on its prompt's message, and the
* answer is the next value of its actions' message — or its next failure.
*
* The answer is told apart by the engine's own timestamps, taken before the
* command leaves, so no clock on this side is compared with the server's. The
* history lives here only, and a reload starts it over.
*/
export function useControl(node: ControlNode | undefined) {
const [items, setItems] = useState<ControlItem[]>([])
// What "newer" means for this command: the last answer and the last failure
// before it was sent.
const [pending, setPending] = useState<{
answer: number
failure: number
} | null>(null)
const answer = useLiveValue(node?.actions_msg)
const failure = useNodeFailure(node?.id ?? "")
useEffect(() => {
if (!pending) return
if ((answer?.ts ?? 0) > pending.answer) {
const actions = Array.isArray(answer?.value)
? (answer.value as Action[])
: []
setItems((prev) => [...prev, { kind: "actions", actions }])
setPending(null)
} else if (failure && failure.ts > pending.failure) {
setItems((prev) => [...prev, { kind: "error", text: failure.error }])
setPending(null)
}
}, [pending, answer, failure])
useEffect(() => {
if (!pending) return
const timer = setTimeout(() => {
setItems((prev) => [
...prev,
{
kind: "error",
text: "No answer yet. Check that its flow is running.",
},
])
setPending(null)
}, TIMEOUT_MS)
return () => clearTimeout(timer)
}, [pending])
const send = async (text: string) => {
if (!node) return
setItems((prev) => [...prev, { kind: "user", text }])
setPending({
answer: liveStore.getValue(node.actions_msg)?.ts ?? 0,
failure: liveStore.getFailure(node.id)?.ts ?? 0,
})
try {
await MessagesService.publishMessage({
name: node.prompt_msg,
requestBody: { value: text, source_kind: "chat", source_label: "Chat" },
})
} catch (error) {
const body = error instanceof ApiError ? error.body : undefined
const detail = (body as { detail?: unknown } | undefined)?.detail
setItems((prev) => [
...prev,
{
kind: "error",
text: typeof detail === "string" ? detail : String(error),
},
])
setPending(null)
}
}
return { items, busy: pending !== null, send, clear: () => setItems([]) }
}
@@ -1,6 +1,7 @@
import { Handle, type NodeProps, Position } from "@xyflow/react"
import {
Bell,
Bird,
Braces,
Bug,
Clock,
@@ -56,6 +57,7 @@ const NODE_ICONS = {
exec: Terminal,
file: FileText,
ntfy: Bell,
wren: Bird,
} as const
// One dot says everything about a node's state. Idle nodes carry no dot at all,
+1 -1
View File
@@ -13,7 +13,7 @@ import { request } from "@/client/core/request"
/** Who caused a value. The canvas needs it to pulse the right edge. */
export type ValueSource = {
kind: "node" | "dashboard" | "flow" | "agent" | "api"
kind: "node" | "dashboard" | "flow" | "agent" | "chat" | "api"
id: string
label: string
detail?: string