Flow canvas draws panel I/O ports as panel:<id>:<port> endpoints that open their panel in the Panels dialog

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx
This commit is contained in:
2026-10-15 16:31:33 +02:00
co-authored by Claude Opus 5.5
parent d825acb768
commit af796f7a02
12 changed files with 132 additions and 31 deletions
+5 -4
View File
@@ -952,10 +952,11 @@ export const EndpointSchema = {
title: 'Endpoint',
description: `Something wired into this flow that is not a node in it.
A dashboard control setting one of its messages, a tile showing one, or a
node in another flow on the far side of a dotted name. The canvas draws
these so a value never appears to come from nowhere — or worse, appears to
come from whichever node happens to be drawn as a producer.`
A dashboard control setting one of its messages, a tile showing one, a
port on a panel's own hardware, or a node in another flow on the far side
of a dotted name. The canvas draws these so a value never appears to come
from nowhere — or worse, appears to come from whichever node happens to be
drawn as a producer.`
} as const;
export const EnrollBodySchema = {
+5 -4
View File
@@ -257,10 +257,11 @@ export type DType = 'float' | 'int' | 'str' | 'bool' | 'json' | 'series' | 'reco
/**
* Something wired into this flow that is not a node in it.
*
* A dashboard control setting one of its messages, a tile showing one, or a
* node in another flow on the far side of a dotted name. The canvas draws
* these so a value never appears to come from nowhere — or worse, appears to
* come from whichever node happens to be drawn as a producer.
* A dashboard control setting one of its messages, a tile showing one, a
* port on a panel's own hardware, or a node in another flow on the far side
* of a dotted name. The canvas draws these so a value never appears to come
* from nowhere — or worse, appears to come from whichever node happens to be
* drawn as a producer.
*/
export type Endpoint = {
kind: string;
@@ -182,7 +182,10 @@ export function GlobalSearch({
// Panels are managed in a dialog on the dashboards screen, which opens
// itself when the address says so.
case "panel":
return navigate({ to: "/dashboards", search: { panels: true } })
return navigate({
to: "/dashboards",
search: { panels: true, panel: entry.name },
})
case "secret":
return navigate({ to: "/secrets" })
case "module":
@@ -1,6 +1,6 @@
import { useMutation, useQuery } from "@tanstack/react-query"
import { Plus, Trash2, X } from "lucide-react"
import { useState } from "react"
import { useEffect, useState } from "react"
import {
type ApiError,
@@ -72,7 +72,7 @@ const objectOnly = (value: unknown) =>
* publish, so it lives in a dialog over the dashboards list instead of a page
* of its own. Every change saves as it is made — there is no form to submit.
*/
export function PanelsDialog() {
export function PanelsDialog({ focus }: { focus?: string } = {}) {
const { data: config } = useQuery(panelsQueryOptions())
const { data: dashboards } = useQuery(dashboardsQueryOptions())
const { user } = useAuth()
@@ -125,6 +125,15 @@ export function PanelsDialog() {
const newId = slugify(name)
const taken = panels.some((panel) => panel.id === newId)
// Arriving about one panel, from its port on a flow canvas, lands on it.
const focusLoaded = Boolean(config) && Boolean(focus)
useEffect(() => {
if (!focusLoaded) return
document
.querySelector(`[data-testid="panel-${focus}"]`)
?.scrollIntoView({ block: "start" })
}, [focusLoaded, focus])
return (
<DialogContent className="max-h-[80vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
@@ -146,6 +155,7 @@ export function PanelsDialog() {
<PanelRow
key={panel.id}
panel={panel}
focused={panel.id === focus}
host={host}
remoteHost={remoteHost}
canEdit={canEdit}
@@ -211,6 +221,7 @@ export function PanelsDialog() {
function PanelRow({
panel,
focused,
host,
remoteHost,
canEdit,
@@ -219,6 +230,8 @@ function PanelRow({
onRemove,
}: {
panel: PanelDef
/** The one a link arrived about, marked so it is found at a glance. */
focused: boolean
/** Where this instance answers, as it knows itself. */
host: string
/** Where the portal serves this instance, when it is enrolled. */
@@ -288,7 +301,14 @@ function PanelRow({
: `Remove ${panel.id}`
return (
<div className="grid gap-3" data-testid={`panel-${panel.id}`}>
<div
className={cn(
"grid scroll-mt-2 gap-3",
// An outline rather than a border, so the row does not shift.
focused && "rounded-md outline-2 outline-offset-4 outline-primary/40",
)}
data-testid={`panel-${panel.id}`}
>
<div className="flex items-center gap-2">
<Input
value={panel.title}
@@ -1,5 +1,11 @@
import { Handle, type NodeProps, Position } from "@xyflow/react"
import { LayoutDashboard, LogIn, LogOut, Workflow } from "lucide-react"
import {
LayoutDashboard,
LogIn,
LogOut,
MonitorSmartphone,
Workflow,
} from "lucide-react"
import { memo, useEffect, useMemo, useRef, useState } from "react"
import { useIsMobile } from "@/hooks/useMobile"
@@ -10,6 +16,8 @@ import { useLiveValues } from "./liveStore"
const KIND_ICONS = {
dashboard: LayoutDashboard,
// The same glyph the dashboards screen opens its panels with.
panel: MonitorSmartphone,
flow: Workflow,
input: LogIn,
output: LogOut,
@@ -487,6 +487,12 @@ function FlowEditorInner({
to: "/dashboards/$name",
params: { name: (rest ?? "").split(":")[0] },
})
} else if (kind === "panel") {
// A panel has no page of its own, only its row in the Panels dialog.
navigate({
to: "/dashboards",
search: { panels: true, panel: (rest ?? "").split(":")[0] },
})
} else if (kind === "flow") {
navigate({
to: "/flows/$flowName",
+3 -3
View File
@@ -7,8 +7,8 @@ import { displayName, portOf, qualify } from "./deriveEdges"
/**
* Canvas elements for things wired into a flow that are not nodes in it.
*
* A dashboard control setting one of its messages, a tile showing one, or a
* node in another flow across a dotted name. Without these the canvas shows a
* A dashboard control setting one of its messages, a tile showing one, a port
* on a panel's own hardware, or a node in another flow across a dotted name. Without these the canvas shows a
* value changing with nothing to account for it — and pulses whichever node
* happens to be drawn as a producer, which did nothing.
*
@@ -21,7 +21,7 @@ import { displayName, portOf, qualify } from "./deriveEdges"
export const ENDPOINT_TYPE = "endpoint"
/** The id prefixes an endpoint may carry, none of which is a node id. */
const PREFIXES = ["dashboard:", "flow:", "input:", "output:"]
const PREFIXES = ["dashboard:", "panel:", "flow:", "input:", "output:"]
export type EndpointNodeData = {
label: string
@@ -35,7 +35,7 @@ import {
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
type Search = { panels?: boolean }
type Search = { panels?: boolean; panel?: string }
export const Route = createFileRoute("/_layout/dashboards/")({
component: Dashboards,
@@ -43,6 +43,8 @@ export const Route = createFileRoute("/_layout/dashboards/")({
// else — the global search among them — arrives at it.
validateSearch: (search: Record<string, unknown>): Search => ({
panels: search.panels === true || search.panels === "true" || undefined,
// Which panel to scroll to, for a link that is about one of them.
panel: typeof search.panel === "string" ? search.panel : undefined,
}),
})
@@ -57,7 +59,7 @@ function Dashboards() {
const [deleteOpen, setDeleteOpen] = useState(false)
// Which screens exist is a question with an address, so anything can link to
// it — the global search lands a panel here.
const { panels: panelsOpen } = Route.useSearch()
const { panels: panelsOpen, panel: panelFocus } = Route.useSearch()
const setPanelsOpen = (open: boolean) =>
navigate({ to: "/dashboards", search: open ? { panels: true } : {} })
@@ -122,7 +124,7 @@ function Dashboards() {
{/* Its own root rather than a nested one: which screens exist is a
different question from which dashboards do. */}
<Dialog open={panelsOpen ?? false} onOpenChange={setPanelsOpen}>
<PanelsDialog />
<PanelsDialog focus={panelFocus} />
</Dialog>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>