Talk to wren nodes from the chat's Control tab, opened from Home
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013GAeg4Jo6xjZzjwy9QDWMQ
This commit is contained in:
@@ -21,8 +21,14 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from fluksio.ai import agent, client
|
||||
from fluksio.ai import config as ai_config
|
||||
from fluksio.api.deps import CurrentUser, TokenDep, get_current_active_superuser
|
||||
from fluksio.api.deps import (
|
||||
CurrentUser,
|
||||
FlowControllerDep,
|
||||
TokenDep,
|
||||
get_current_active_superuser,
|
||||
)
|
||||
from fluksio.core.config import settings
|
||||
from fluksio.flow.messages import qualify
|
||||
from fluksio.flow.schemas import NAME_PATTERN
|
||||
from fluksio.mcp import server as tools
|
||||
|
||||
@@ -71,6 +77,17 @@ class ChatItem(BaseModel):
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class ControlNode(BaseModel):
|
||||
"""A wren node the chat can talk to: where it sends and where it reads."""
|
||||
|
||||
#: Qualified, ``flow.node``.
|
||||
id: str
|
||||
flow: str
|
||||
title: str
|
||||
prompt_msg: str
|
||||
actions_msg: str
|
||||
|
||||
|
||||
class TurnBody(BaseModel):
|
||||
message: str = Field(min_length=1, max_length=20_000)
|
||||
#: The flow open in the editor, when the chat is opened from one.
|
||||
@@ -156,6 +173,38 @@ def read_ai_status(_: CurrentUser) -> Any:
|
||||
return AIStatus(available=True, model=config.model)
|
||||
|
||||
|
||||
@router.get("/control", response_model=list[ControlNode])
|
||||
def read_control_nodes(_: CurrentUser, controller: FlowControllerDep) -> Any:
|
||||
"""The wren nodes of the running live flows, for the chat's Control tab.
|
||||
|
||||
Only those with both ends wired: the chat publishes the command on the
|
||||
prompt's message and reads the answer off the actions'.
|
||||
"""
|
||||
found = []
|
||||
for flow in controller.store.read_all():
|
||||
if flow.mode != "live" or flow.name in controller.disabled:
|
||||
continue
|
||||
for node in flow.nodes:
|
||||
if node.type != "wren":
|
||||
continue
|
||||
ends = {
|
||||
spec.port: qualify(flow.name, spec.name)
|
||||
for spec in [*node.requires, *node.provides]
|
||||
if spec.port in ("prompt", "actions") and spec.name
|
||||
}
|
||||
if len(ends) == 2:
|
||||
found.append(
|
||||
ControlNode(
|
||||
id=f"{flow.name}.{node.id}",
|
||||
flow=flow.name,
|
||||
title=node.title or node.id,
|
||||
prompt_msg=ends["prompt"],
|
||||
actions_msg=ends["actions"],
|
||||
)
|
||||
)
|
||||
return found
|
||||
|
||||
|
||||
def _chat_file(user_id: uuid.UUID) -> Any:
|
||||
return settings.CHATS_DIR / f"{user_id}.json"
|
||||
|
||||
|
||||
@@ -565,6 +565,35 @@ export const ChatItemSchema = {
|
||||
title: 'ChatItem'
|
||||
} as const;
|
||||
|
||||
export const ControlNodeSchema = {
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
title: 'Id'
|
||||
},
|
||||
flow: {
|
||||
type: 'string',
|
||||
title: 'Flow'
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
title: 'Title'
|
||||
},
|
||||
prompt_msg: {
|
||||
type: 'string',
|
||||
title: 'Prompt Msg'
|
||||
},
|
||||
actions_msg: {
|
||||
type: 'string',
|
||||
title: 'Actions Msg'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['id', 'flow', 'title', 'prompt_msg', 'actions_msg'],
|
||||
title: 'ControlNode',
|
||||
description: 'A wren node the chat can talk to: where it sends and where it reads.'
|
||||
} as const;
|
||||
|
||||
export const DTypeSchema = {
|
||||
type: 'string',
|
||||
enum: ['float', 'int', 'str', 'bool', 'json', 'series', 'record', 'list', 'artifact', 'image', 'audio', 'video'],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -142,6 +142,17 @@ export type ChatItem = {
|
||||
error?: (string | null);
|
||||
};
|
||||
|
||||
/**
|
||||
* A wren node the chat can talk to: where it sends and where it reads.
|
||||
*/
|
||||
export type ControlNode = {
|
||||
id: string;
|
||||
flow: string;
|
||||
title: string;
|
||||
prompt_msg: string;
|
||||
actions_msg: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A dashboard as stored, and as the API hands it over.
|
||||
*/
|
||||
@@ -1461,6 +1472,8 @@ export type AiListAiModelsResponse = (Array<(string)>);
|
||||
|
||||
export type AiReadAiStatusResponse = (AIStatus);
|
||||
|
||||
export type AiReadControlNodesResponse = (Array<ControlNode>);
|
||||
|
||||
export type AiReadChatResponse = (Array<ChatItem>);
|
||||
|
||||
export type AiChatData = {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"
|
||||
import {
|
||||
createFileRoute,
|
||||
Outlet,
|
||||
redirect,
|
||||
useRouterState,
|
||||
} from "@tanstack/react-router"
|
||||
|
||||
import { ControlTrigger } from "@/components/Chat/ChatPanel"
|
||||
import { ConnectionNotice } from "@/components/Common/ConnectionNotice"
|
||||
import ErrorComponent from "@/components/Common/ErrorComponent"
|
||||
import { Footer } from "@/components/Common/Footer"
|
||||
@@ -31,6 +37,7 @@ function Layout() {
|
||||
// Dashboards live in this shell and read live values, so the socket belongs
|
||||
// here rather than only inside the editor.
|
||||
useFlowSocket()
|
||||
const home = useRouterState({ select: (s) => s.location.pathname === "/" })
|
||||
|
||||
return (
|
||||
<SidebarProvider className="bg-card">
|
||||
@@ -40,9 +47,11 @@ function Layout() {
|
||||
served through a portal: it raises the offline notification. */}
|
||||
<ConnectionNotice />
|
||||
{/* The sidebar carries its own collapse control; a phone has no
|
||||
sidebar on screen to carry it. */}
|
||||
sidebar on screen to carry it. Home's chat sits opposite, where
|
||||
the brain view has it on a wider screen. */}
|
||||
<header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 px-4 md:hidden">
|
||||
<SidebarTrigger className="-ml-1 text-muted-foreground" />
|
||||
{home ? <ControlTrigger className="-mr-1 ml-auto" /> : null}
|
||||
</header>
|
||||
<main className="flex-1 p-4 sm:p-6 md:p-8">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { useState } from "react"
|
||||
|
||||
import { ControlTrigger } from "@/components/Chat/ChatPanel"
|
||||
import { byRecency, DashboardMosaic } from "@/components/Common/DashboardMosaic"
|
||||
import { DEFAULT_RANGE } from "@/components/Common/RangePicker"
|
||||
import { dashboardsQueryOptions } from "@/components/Dashboard/queries"
|
||||
@@ -62,7 +63,13 @@ function Home() {
|
||||
{/* Nothing wired up yet means nothing to draw, and the band would still
|
||||
hold a screenful of empty space above the flows table. Node count
|
||||
rather than flow count: a flow made a minute ago has none. */}
|
||||
{flows.some((flow) => (flow.node_count ?? 0) > 0) ? <BrainView /> : null}
|
||||
{flows.some((flow) => (flow.node_count ?? 0) > 0) ? (
|
||||
<div className="relative">
|
||||
<BrainView />
|
||||
{/* A phone has it in the header instead, opposite the sidebar's. */}
|
||||
<ControlTrigger className="absolute top-0 right-0 hidden md:inline-flex" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* One row across the full width rather than a column beside the flows:
|
||||
a dashboard tile is a picture, and a picture wants to be wide. Past
|
||||
|
||||
Reference in New Issue
Block a user