From 673061d9fb57c68f1de0b32dc410832df4469ea6 Mon Sep 17 00:00:00 2001 From: stroblme Date: Mon, 5 Oct 2026 20:59:11 +0200 Subject: [PATCH] Talk to wren nodes from the chat's Control tab, opened from Home Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013GAeg4Jo6xjZzjwy9QDWMQ --- backend/fluksio/api/routes/ai.py | 51 ++++- frontend/src/client/schemas.gen.ts | 29 +++ frontend/src/client/sdk.gen.ts | 18 +- frontend/src/client/types.gen.ts | 13 ++ frontend/src/components/Chat/ChatPanel.tsx | 206 +++++++++++++++++---- frontend/src/components/Chat/chatStore.ts | 14 +- frontend/src/components/Chat/useControl.ts | 102 ++++++++++ frontend/src/components/Flow/FlowNode.tsx | 2 + frontend/src/components/Flow/liveStore.ts | 2 +- frontend/src/routes/_layout.tsx | 13 +- frontend/src/routes/_layout/index.tsx | 9 +- 11 files changed, 417 insertions(+), 42 deletions(-) create mode 100644 frontend/src/components/Chat/useControl.ts diff --git a/backend/fluksio/api/routes/ai.py b/backend/fluksio/api/routes/ai.py index 89d594f..8c93fd7 100644 --- a/backend/fluksio/api/routes/ai.py +++ b/backend/fluksio/api/routes/ai.py @@ -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" diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index 71244eb..d0e4b3c 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -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'], diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index fb20335..5368d5c 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { AiReadAiConfigResponse, AiUpdateAiConfigData, AiUpdateAiConfigResponse, AiDeleteAiConfigResponse, AiListAiModelsData, AiListAiModelsResponse, AiReadAiStatusResponse, AiReadChatResponse, AiChatData, AiChatResponse, AiClearChatResponse, AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsReadWebpushKeyResponse, AlertsAddWebpushSubscriptionData, AlertsAddWebpushSubscriptionResponse, AlertsRemoveWebpushSubscriptionData, AlertsRemoveWebpushSubscriptionResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudAddRemoteUserData, CloudAddRemoteUserResponse, CloudDisconnectResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsGenerateResultsDashboardData, DashboardsGenerateResultsDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlavorsReadFlavorsResponse, FlavorsCreateFlavorData, FlavorsCreateFlavorResponse, FlavorsUpdateFlavorData, FlavorsUpdateFlavorResponse, FlavorsDeleteFlavorData, FlavorsDeleteFlavorResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsAcknowledgeNodeErrorData, FlowsAcknowledgeNodeErrorResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, ModulesRefreshModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PanelsReadPanelsResponse, PanelsSavePanelsData, PanelsSavePanelsResponse, PanelsStartPairingResponse, PanelsPollPairingData, PanelsPollPairingResponse, PanelsPendingDeviceData, PanelsPendingDeviceResponse, PanelsApprovePairingData, PanelsApprovePairingResponse, PanelsUnpairPanelData, PanelsUnpairPanelResponse, PanelsReadPanelData, PanelsReadPanelResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsImportRunsData, RunsImportRunsResponse, RunsReadRunsData, RunsReadRunsResponse, RunsDeleteRunsData, RunsDeleteRunsResponse, RunsReadOverviewResponse, RunsExportMetricsData, RunsExportMetricsResponse, RunsExportRunsData, RunsExportRunsResponse, RunsReadMetricNamesData, RunsReadMetricNamesResponse, RunsReadRunData, RunsReadRunResponse, RunsDeleteRunData, RunsDeleteRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsRetryRunData, RunsRetryRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SearchReadSearchIndexResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersReadResourcesResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersReadRuntimeResponse } from './types.gen'; +import type { AiReadAiConfigResponse, AiUpdateAiConfigData, AiUpdateAiConfigResponse, AiDeleteAiConfigResponse, AiListAiModelsData, AiListAiModelsResponse, AiReadAiStatusResponse, AiReadControlNodesResponse, AiReadChatResponse, AiChatData, AiChatResponse, AiClearChatResponse, AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsReadWebpushKeyResponse, AlertsAddWebpushSubscriptionData, AlertsAddWebpushSubscriptionResponse, AlertsRemoveWebpushSubscriptionData, AlertsRemoveWebpushSubscriptionResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudAddRemoteUserData, CloudAddRemoteUserResponse, CloudDisconnectResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsGenerateResultsDashboardData, DashboardsGenerateResultsDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlavorsReadFlavorsResponse, FlavorsCreateFlavorData, FlavorsCreateFlavorResponse, FlavorsUpdateFlavorData, FlavorsUpdateFlavorResponse, FlavorsDeleteFlavorData, FlavorsDeleteFlavorResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsAcknowledgeNodeErrorData, FlowsAcknowledgeNodeErrorResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, ModulesRefreshModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PanelsReadPanelsResponse, PanelsSavePanelsData, PanelsSavePanelsResponse, PanelsStartPairingResponse, PanelsPollPairingData, PanelsPollPairingResponse, PanelsPendingDeviceData, PanelsPendingDeviceResponse, PanelsApprovePairingData, PanelsApprovePairingResponse, PanelsUnpairPanelData, PanelsUnpairPanelResponse, PanelsReadPanelData, PanelsReadPanelResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsImportRunsData, RunsImportRunsResponse, RunsReadRunsData, RunsReadRunsResponse, RunsDeleteRunsData, RunsDeleteRunsResponse, RunsReadOverviewResponse, RunsExportMetricsData, RunsExportMetricsResponse, RunsExportRunsData, RunsExportRunsResponse, RunsReadMetricNamesData, RunsReadMetricNamesResponse, RunsReadRunData, RunsReadRunResponse, RunsDeleteRunData, RunsDeleteRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsRetryRunData, RunsRetryRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SearchReadSearchIndexResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersReadResourcesResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersReadRuntimeResponse } from './types.gen'; export class AiService { /** @@ -82,6 +82,22 @@ export class AiService { }); } + /** + * Read Control Nodes + * 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'. + * @returns ControlNode Successful Response + * @throws ApiError + */ + public static readControlNodes(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/ai/control' + }); + } + /** * Read Chat * The transcript, with each tool's answer reduced to whether it worked. diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 15c11ed..708f99b 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -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); + export type AiReadChatResponse = (Array); export type AiChatData = { diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index 11d5d1e..1932482 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -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 ( + + ) +} + /** 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() + 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(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) 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 ( - - {TABS.length > 1 ? ( + {controlling ? ( + + ) : ( + + )} + {tabs.length > 1 ? ( {}} + testId="chat-tabs" + onChange={(next) => chatStore.show(next as ChatTab)} /> ) : ( - Assistant + + {controlling ? "Control" : "Assistant"} + + )} + {controlling && nodes.length > 1 ? ( +
+ +
+ ) : ( + + {controlling ? node?.title : status.data?.model} + )} - - {status.data.model} -