The node, flow, widget and dashboard panels explained themselves in paragraphs under every control. They now carry a caption and an info tooltip, so a panel reads as a list of settings. InfoTip, PanelSection and FieldLabel live beside PANEL_SECTION in SidePanel.tsx, which the panels already share. InfoTip holds its own open state because a Radix tooltip ignores a touch pointer, and below md the whole panel is a full-screen sheet — hover-only help would leave a phone with the caption and nothing else. Dashboard/panels.tsx drops from 30 helper paragraphs to 5; the ones left are empty states and status, not explanation. Em-dashes are out of the user-facing strings, except where one separates the halves of a select item. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015YrQnKV3bnQd4K342y8tKj
384 lines
12 KiB
TypeScript
384 lines
12 KiB
TypeScript
import { useMutation } from "@tanstack/react-query"
|
|
import { ArrowUpFromLine, Plus, X } from "lucide-react"
|
|
|
|
import type { DType, FlowDef_Input, FlowInput_Input } from "@/client"
|
|
import { MessagesService } from "@/client"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Checkbox } from "@/components/ui/checkbox"
|
|
import { Input } from "@/components/ui/input"
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select"
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip"
|
|
import { cn } from "@/lib/utils"
|
|
import { qualify } from "./deriveEdges"
|
|
import { useLiveValue } from "./liveStore"
|
|
import { asText, DTYPES } from "./NodePanel"
|
|
import { InfoTip, PANEL_SECTION } from "./SidePanel"
|
|
import { ValuePreview } from "./ValuePreview"
|
|
|
|
/**
|
|
* A typed literal, read back from what was typed.
|
|
*
|
|
* Half-finished input is normal while editing — "-", "1.", a JSON object with
|
|
* one brace — so anything that does not parse yet is kept as text rather than
|
|
* replaced with a zero the person did not type.
|
|
*/
|
|
export function parseByDtype(dtype: DType | undefined, raw: string): unknown {
|
|
if (raw === "") return null
|
|
// A run's output is named rather than typed out: "@run:<id>.<output>", or a
|
|
// digest naming bytes. The engine resolves either into the value itself,
|
|
// whatever its type, so both spellings are kept as text on the way out.
|
|
if (raw.startsWith("@run:") || raw.startsWith("sha256:")) return raw
|
|
if (dtype === "int" || dtype === "float") {
|
|
const parsed = Number(raw)
|
|
return Number.isNaN(parsed) ? raw : parsed
|
|
}
|
|
if (dtype === "bool") return raw === "true"
|
|
if (dtype === "str") return raw
|
|
try {
|
|
return JSON.parse(raw)
|
|
} catch {
|
|
return raw
|
|
}
|
|
}
|
|
|
|
/**
|
|
* One literal, entered the way its type is entered.
|
|
*
|
|
* A flag has two values and gets a choice of them; everything else is typed
|
|
* and read back with {@link parseByDtype}. Deliberately not `type="number"`
|
|
* for the numbers — see that function on why half-typed input must survive.
|
|
*
|
|
* `className` carries no height: the two controls need different ones.
|
|
*/
|
|
export function DtypeValue({
|
|
dtype,
|
|
value,
|
|
label,
|
|
id,
|
|
placeholder,
|
|
className,
|
|
onChange,
|
|
}: {
|
|
dtype: DType | undefined
|
|
value: unknown
|
|
/** What the field is called, for anyone not looking at it. */
|
|
label: string
|
|
id?: string
|
|
placeholder?: string
|
|
className?: string
|
|
onChange: (next: unknown) => void
|
|
}) {
|
|
if (dtype === "bool") {
|
|
return (
|
|
<Select
|
|
value={value === true ? "true" : "false"}
|
|
onValueChange={(next) => onChange(next === "true")}
|
|
>
|
|
<SelectTrigger
|
|
id={id}
|
|
className={cn("!h-8", className)}
|
|
aria-label={label}
|
|
>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="true">true</SelectItem>
|
|
<SelectItem value="false">false</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Input
|
|
id={id}
|
|
value={asText(value)}
|
|
placeholder={placeholder}
|
|
aria-label={label}
|
|
className={cn("h-8", className)}
|
|
onChange={(event) => onChange(parseByDtype(dtype, event.target.value))}
|
|
/>
|
|
)
|
|
}
|
|
|
|
/** Putting a declared value into the running graph, credited to the input. */
|
|
function usePublishInput() {
|
|
return useMutation({
|
|
mutationFn: ({ message, value }: { message: string; value: unknown }) =>
|
|
MessagesService.publishMessage({
|
|
name: message,
|
|
requestBody: {
|
|
value,
|
|
source_kind: "flow",
|
|
// Matches the endpoint id the canvas builds, so that label pulses.
|
|
source_id: `input:${message}`,
|
|
source_label: message,
|
|
source_detail: "input",
|
|
},
|
|
}),
|
|
})
|
|
}
|
|
|
|
function InputRow({
|
|
flow,
|
|
declared,
|
|
live,
|
|
onChange,
|
|
onRemove,
|
|
}: {
|
|
flow: string
|
|
declared: FlowInput_Input
|
|
/** Whether the engine is running this flow, so a value can be put into it. */
|
|
live: boolean
|
|
onChange: (next: FlowInput_Input) => void
|
|
onRemove: () => void
|
|
}) {
|
|
const spec = declared.spec ?? {}
|
|
const name = spec.name ?? ""
|
|
const message = qualify(flow, name)
|
|
const current = useLiveValue(message)
|
|
const publish = usePublishInput()
|
|
|
|
return (
|
|
<div className="grid gap-1.5">
|
|
<div className="flex items-center gap-1.5">
|
|
<Input
|
|
defaultValue={name}
|
|
placeholder="name"
|
|
aria-label="Input name"
|
|
className="h-8 flex-1 text-sm"
|
|
onBlur={(event) =>
|
|
onChange({
|
|
...declared,
|
|
spec: { ...spec, name: event.target.value.trim() },
|
|
})
|
|
}
|
|
/>
|
|
<Select
|
|
value={spec.dtype ?? "float"}
|
|
onValueChange={(next) =>
|
|
onChange({ ...declared, spec: { ...spec, dtype: next as DType } })
|
|
}
|
|
>
|
|
<SelectTrigger className="!h-8 w-[86px] text-sm" aria-label="Type">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{DTYPES.map((option) => (
|
|
<SelectItem key={option} value={option}>
|
|
{option}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<DtypeValue
|
|
dtype={spec.dtype}
|
|
value={declared.initial}
|
|
label="Starting value"
|
|
placeholder="starts at"
|
|
className="flex-1 text-sm"
|
|
onChange={(initial) => onChange({ ...declared, initial })}
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="text-muted-foreground"
|
|
aria-label="Remove input"
|
|
onClick={onRemove}
|
|
>
|
|
<X />
|
|
</Button>
|
|
</div>
|
|
|
|
{live && name ? (
|
|
<div className="flex items-center gap-2 pl-1 text-xs text-muted-foreground">
|
|
<span className="shrink-0">now</span>
|
|
{current === undefined ? (
|
|
<span className="flex-1">—</span>
|
|
) : (
|
|
<span className="min-w-0 flex-1">
|
|
<ValuePreview value={current.value} dtype={spec.dtype} />
|
|
</span>
|
|
)}
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
aria-label="Publish this value now"
|
|
disabled={publish.isPending || declared.initial === null}
|
|
onClick={() =>
|
|
publish.mutate({ message, value: declared.initial })
|
|
}
|
|
>
|
|
<ArrowUpFromLine />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Put this value into the flow now</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* What the flow takes from outside, and what a run of it reports.
|
|
*
|
|
* An input is a message no node here computes — a control writes it, a run
|
|
* passes it in, an agent publishes it — so the flow declares it and says what
|
|
* it starts from. Without that the node reading it waits for something nothing
|
|
* provides, which is what the canvas reports.
|
|
*/
|
|
export function BoundarySections({
|
|
flow,
|
|
definition,
|
|
running,
|
|
onChange,
|
|
}: {
|
|
flow: string
|
|
definition: FlowDef_Input
|
|
/** Whether the engine is running this flow, so it holds values worth showing. */
|
|
running: boolean
|
|
onChange: (next: FlowDef_Input) => void
|
|
}) {
|
|
const inputs = definition.inputs ?? []
|
|
const batch = definition.mode === "batch"
|
|
const outputs = definition.outputs ?? []
|
|
// A run reads its parameters into a namespace of its own, so what the engine
|
|
// holds for a batch flow is not what any run of it saw.
|
|
const live = running && !batch
|
|
|
|
// Everything the flow computes, which is what a result can be made of.
|
|
const provided = [
|
|
...new Set(
|
|
(definition.nodes ?? []).flatMap((node) =>
|
|
(node.provides ?? []).map((spec) => spec.name ?? "").filter(Boolean),
|
|
),
|
|
),
|
|
].sort()
|
|
|
|
const setInputs = (next: FlowInput_Input[]) =>
|
|
onChange({ ...definition, inputs: next })
|
|
|
|
return (
|
|
<>
|
|
<div className="grid gap-3">
|
|
<div className="flex items-center gap-1">
|
|
<span className={PANEL_SECTION}>Mode</span>
|
|
<InfoTip label="Mode">
|
|
A live flow runs continuously, with its subscriptions, schedules and
|
|
webhooks active. A batch flow runs only when a run asks it to, from
|
|
its inputs to its outputs.
|
|
</InfoTip>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<p className="text-sm text-muted-foreground">
|
|
{batch ? "Batch" : "Live"}
|
|
</p>
|
|
<Select
|
|
value={definition.mode ?? "live"}
|
|
onValueChange={(next) =>
|
|
onChange({ ...definition, mode: next as "live" | "batch" })
|
|
}
|
|
>
|
|
<SelectTrigger className="!h-8 w-[92px] text-sm" aria-label="Mode">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="live">live</SelectItem>
|
|
<SelectItem value="batch">batch</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className={PANEL_SECTION}>Inputs</span>
|
|
<InfoTip label="Inputs">
|
|
Messages that arrive from outside, such as a dashboard control, a
|
|
run or the API, and the value the flow starts from.
|
|
</InfoTip>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 text-xs text-muted-foreground"
|
|
data-testid="add-flow-input"
|
|
onClick={() =>
|
|
setInputs([...inputs, { spec: { name: "", dtype: "float" } }])
|
|
}
|
|
>
|
|
<Plus />
|
|
Add
|
|
</Button>
|
|
</div>
|
|
|
|
{inputs.map((declared, index) => (
|
|
// Keyed by position: renaming an input must not remount its row.
|
|
<InputRow
|
|
key={`input-${index}`}
|
|
flow={flow}
|
|
declared={declared}
|
|
live={live}
|
|
onChange={(next) =>
|
|
setInputs(inputs.map((one, at) => (at === index ? next : one)))
|
|
}
|
|
onRemove={() =>
|
|
setInputs(inputs.filter((_one, at) => at !== index))
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{batch ? (
|
|
<div className="grid gap-3">
|
|
<div className="flex items-center gap-1">
|
|
<span className={PANEL_SECTION}>Result</span>
|
|
<InfoTip label="Result">
|
|
What a run reports when it finishes. Everything else it computed
|
|
goes with it.
|
|
</InfoTip>
|
|
</div>
|
|
{provided.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
Nothing is produced here yet, so a run has nothing to report.
|
|
</p>
|
|
) : null}
|
|
{provided.map((name) => (
|
|
<label
|
|
key={name}
|
|
htmlFor={`output-${name}`}
|
|
className="flex items-center gap-2 font-mono text-sm"
|
|
>
|
|
<Checkbox
|
|
id={`output-${name}`}
|
|
checked={outputs.includes(name)}
|
|
onCheckedChange={(checked) =>
|
|
onChange({
|
|
...definition,
|
|
outputs: checked
|
|
? [...outputs, name]
|
|
: outputs.filter((one) => one !== name),
|
|
})
|
|
}
|
|
/>
|
|
{name}
|
|
</label>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</>
|
|
)
|
|
}
|