Un-nest the dock panels: crisp in Firefox, and side by side
The console panel lived inside the bar, so its centring translate and its backdrop filter were both nested inside the bar's own — a half-pixel offset under a blurred layer, which is what Gecko was rasterising the text through. Panels and bar are siblings in one column now. That column is also what the issues list needed: it was a popover anchored to its trigger, opaque where the console is frosted and landing on top of it when both were open. It is the same panel now, the same width, in the same row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,15 +13,11 @@ import {
|
|||||||
WifiOff,
|
WifiOff,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { motion } from "motion/react"
|
import { AnimatePresence, motion } from "motion/react"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
import type { ValidationIssue } from "@/client"
|
import type { ValidationIssue } from "@/client"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover"
|
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -30,7 +26,7 @@ import {
|
|||||||
} from "@/components/ui/tooltip"
|
} from "@/components/ui/tooltip"
|
||||||
import { slideUp, transitions } from "@/lib/motion"
|
import { slideUp, transitions } from "@/lib/motion"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { type LogsFilter, LogsPanel } from "./LogsPanel"
|
import { type LogsFilter, LogsPanel, LogsTrigger, PANEL } from "./LogsPanel"
|
||||||
import { useLiveConnection } from "./liveStore"
|
import { useLiveConnection } from "./liveStore"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -110,271 +106,336 @@ export function FlowDock({
|
|||||||
}) {
|
}) {
|
||||||
const { fitView } = useReactFlow()
|
const { fitView } = useReactFlow()
|
||||||
const connected = useLiveConnection()
|
const connected = useLiveConnection()
|
||||||
|
const [issuesOpen, setIssuesOpen] = useState(false)
|
||||||
// The engine marks the issues it does not count against a flow. They are
|
// The engine marks the issues it does not count against a flow. They are
|
||||||
// still worth saying, so they stay in the list — but in the muted tone, and
|
// still worth saying, so they stay in the list — but in the muted tone, and
|
||||||
// without turning the summary red, since nothing here is actually broken.
|
// without turning the summary red, since nothing here is actually broken.
|
||||||
const faults = issues.filter((issue) => !issue.advisory)
|
const faults = issues.filter((issue) => !issue.advisory)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
// The panels and the bar are siblings in one column, so neither nests its
|
||||||
variants={slideUp}
|
// half-pixel centring translate or its backdrop filter inside the other —
|
||||||
initial="hidden"
|
// which is what Firefox was drawing the console's text through. It also
|
||||||
animate="visible"
|
// gives the two panels a row to share, instead of one landing on the other.
|
||||||
exit="exit"
|
<div
|
||||||
transition={transitions.emphasized}
|
|
||||||
// Capped and wrapping: the canvas shell clips, so an uncapped row would
|
|
||||||
// put the buttons at its ends out of reach on a phone rather than merely
|
|
||||||
// look wrong. See DESIGN-GUIDELINES.md → Responsive.
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-auto absolute bottom-4 left-1/2 z-10 flex max-w-[calc(100vw-2rem)] -translate-x-1/2 flex-wrap items-center justify-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md pb-[max(0.25rem,env(safe-area-inset-bottom))]",
|
"pointer-events-none absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 flex-col items-center",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Tooltip>
|
<div className="mb-3 flex max-w-[calc(100vw-2rem)] flex-wrap items-end justify-center gap-3">
|
||||||
<TooltipTrigger asChild>
|
<IssuesPanel
|
||||||
<Button
|
open={issuesOpen}
|
||||||
variant="ghost"
|
issues={issues}
|
||||||
size="icon"
|
onClose={() => setIssuesOpen(false)}
|
||||||
className="size-11 text-muted-foreground md:size-8"
|
onFocusNode={onFocusNode}
|
||||||
onClick={onAddNode}
|
/>
|
||||||
aria-label="Add node"
|
<LogsPanel flow={flow} {...logs} />
|
||||||
data-testid="add-node"
|
</div>
|
||||||
>
|
|
||||||
<Plus />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Add a node (⌘K)</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Separator
|
<motion.div
|
||||||
orientation="vertical"
|
variants={slideUp}
|
||||||
className="mx-0.5 !h-5 hidden md:block"
|
initial="hidden"
|
||||||
/>
|
animate="visible"
|
||||||
|
exit="exit"
|
||||||
|
transition={transitions.emphasized}
|
||||||
|
// Capped and wrapping: the canvas shell clips, so an uncapped row would
|
||||||
|
// put the buttons at its ends out of reach on a phone rather than merely
|
||||||
|
// look wrong. See DESIGN-GUIDELINES.md → Responsive.
|
||||||
|
className="pointer-events-auto flex max-w-[calc(100vw-2rem)] flex-wrap items-center justify-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md pb-[max(0.25rem,env(safe-area-inset-bottom))]"
|
||||||
|
>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-11 text-muted-foreground md:size-8"
|
||||||
|
onClick={onAddNode}
|
||||||
|
aria-label="Add node"
|
||||||
|
data-testid="add-node"
|
||||||
|
>
|
||||||
|
<Plus />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Add a node (⌘K)</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
{/* A phone pinches to zoom and the graph fits itself, so this would only
|
<Separator
|
||||||
|
orientation="vertical"
|
||||||
|
className="mx-0.5 !h-5 hidden md:block"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* A phone pinches to zoom and the graph fits itself, so this would only
|
||||||
be taking room the rest of the bar needs. */}
|
be taking room the rest of the bar needs. */}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="hidden text-muted-foreground md:inline-flex md:size-8"
|
className="hidden text-muted-foreground md:inline-flex md:size-8"
|
||||||
onClick={() => fitView({ ...FIT_VIEW, duration: 300 })}
|
onClick={() => fitView({ ...FIT_VIEW, duration: 300 })}
|
||||||
aria-label="Fit the flow to the screen"
|
aria-label="Fit the flow to the screen"
|
||||||
>
|
>
|
||||||
<Maximize2 />
|
<Maximize2 />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Fit to screen</TooltipContent>
|
<TooltipContent>Fit to screen</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{issues.length > 0 ? (
|
{issues.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<Separator
|
<Separator
|
||||||
orientation="vertical"
|
orientation="vertical"
|
||||||
className="mx-0.5 !h-5 hidden md:block"
|
className="mx-0.5 !h-5 hidden md:block"
|
||||||
/>
|
/>
|
||||||
<Popover>
|
<Button
|
||||||
<PopoverTrigger asChild>
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-11 gap-1.5 md:h-8",
|
||||||
|
faults.length > 0
|
||||||
|
? "text-destructive"
|
||||||
|
: issuesOpen
|
||||||
|
? "text-primary"
|
||||||
|
: "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
onClick={() => setIssuesOpen(!issuesOpen)}
|
||||||
|
aria-pressed={issuesOpen}
|
||||||
|
data-testid="validation-summary"
|
||||||
|
>
|
||||||
|
<AlertCircle className="size-4" />
|
||||||
|
{issues.length}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Separator
|
||||||
|
orientation="vertical"
|
||||||
|
className="mx-0.5 !h-5 hidden md:block"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LogsTrigger open={logs.open} onOpenChange={logs.onOpenChange} />
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"size-11 text-muted-foreground md:size-8",
|
||||||
|
paused && "text-primary",
|
||||||
|
)}
|
||||||
|
onClick={onTogglePause}
|
||||||
|
disabled={!enabled}
|
||||||
|
aria-label={paused ? "Resume flow" : "Pause flow"}
|
||||||
|
data-testid={paused ? "resume-flow" : "pause-flow"}
|
||||||
|
>
|
||||||
|
{paused ? <PlayCircle /> : <Pause />}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{!enabled
|
||||||
|
? "This flow is stopped"
|
||||||
|
: paused
|
||||||
|
? "Let the flow carry on"
|
||||||
|
: "Hold the nodes; values still arrive"}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{paused && enabled ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="icon"
|
||||||
className={cn(
|
className="size-11 text-muted-foreground md:size-8"
|
||||||
"h-11 gap-1.5 md:h-8",
|
onClick={onStep}
|
||||||
faults.length > 0
|
disabled={stepping}
|
||||||
? "text-destructive"
|
aria-label="Step the flow"
|
||||||
: "text-muted-foreground",
|
data-testid="step-flow"
|
||||||
)}
|
|
||||||
data-testid="validation-summary"
|
|
||||||
>
|
>
|
||||||
<AlertCircle className="size-4" />
|
<StepForward />
|
||||||
{issues.length}
|
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</TooltipTrigger>
|
||||||
<PopoverContent align="center" className="w-80 p-2">
|
<TooltipContent>Let one held-back item through</TooltipContent>
|
||||||
<p className="px-2 py-1.5 text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground">
|
</Tooltip>
|
||||||
Needs attention
|
) : null}
|
||||||
</p>
|
|
||||||
<ul className="mt-1 grid gap-0.5">
|
|
||||||
{issues.map((issue) => (
|
|
||||||
<li key={`${issue.code}-${issue.node}-${issue.message_name}`}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"w-full rounded-sm px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent/50 disabled:cursor-default disabled:hover:bg-transparent",
|
|
||||||
issue.advisory && "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
onClick={() => issue.node && onFocusNode(issue.node)}
|
|
||||||
disabled={!issue.node}
|
|
||||||
>
|
|
||||||
{issue.message}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Separator
|
<Tooltip>
|
||||||
orientation="vertical"
|
<TooltipTrigger asChild>
|
||||||
className="mx-0.5 !h-5 hidden md:block"
|
<span>
|
||||||
/>
|
<Button
|
||||||
|
variant="brand"
|
||||||
|
size="sm"
|
||||||
|
className="h-11 gap-1.5 md:h-8"
|
||||||
|
onClick={onRun}
|
||||||
|
disabled={running || !enabled}
|
||||||
|
data-testid="run-flow"
|
||||||
|
>
|
||||||
|
{running ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Play className="size-4" />
|
||||||
|
)}
|
||||||
|
Run
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{enabled ? "Run every node once" : "Start the flow to run it"}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<LogsPanel flow={flow} {...logs} />
|
<Separator
|
||||||
|
orientation="vertical"
|
||||||
|
className="mx-0.5 !h-5 hidden md:block"
|
||||||
|
/>
|
||||||
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className={cn(
|
|
||||||
"size-11 text-muted-foreground md:size-8",
|
|
||||||
paused && "text-primary",
|
|
||||||
)}
|
|
||||||
onClick={onTogglePause}
|
|
||||||
disabled={!enabled}
|
|
||||||
aria-label={paused ? "Resume flow" : "Pause flow"}
|
|
||||||
data-testid={paused ? "resume-flow" : "pause-flow"}
|
|
||||||
>
|
|
||||||
{paused ? <PlayCircle /> : <Pause />}
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{!enabled
|
|
||||||
? "This flow is stopped"
|
|
||||||
: paused
|
|
||||||
? "Let the flow carry on"
|
|
||||||
: "Hold the nodes; values still arrive"}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{paused && enabled ? (
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="size-11 text-muted-foreground md:size-8"
|
className="size-11 text-muted-foreground md:size-8"
|
||||||
onClick={onStep}
|
onClick={onEditFlow}
|
||||||
disabled={stepping}
|
aria-label="Flow settings"
|
||||||
aria-label="Step the flow"
|
data-testid="edit-flow"
|
||||||
data-testid="step-flow"
|
|
||||||
>
|
>
|
||||||
<StepForward />
|
<Pencil />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Let one held-back item through</TooltipContent>
|
<TooltipContent>Flow settings</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Tooltip>
|
{/* Throwing the edit away sits next to putting it live, and only exists
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span>
|
|
||||||
<Button
|
|
||||||
variant="brand"
|
|
||||||
size="sm"
|
|
||||||
className="h-11 gap-1.5 md:h-8"
|
|
||||||
onClick={onRun}
|
|
||||||
disabled={running || !enabled}
|
|
||||||
data-testid="run-flow"
|
|
||||||
>
|
|
||||||
{running ? (
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Play className="size-4" />
|
|
||||||
)}
|
|
||||||
Run
|
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{enabled ? "Run every node once" : "Start the flow to run it"}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Separator
|
|
||||||
orientation="vertical"
|
|
||||||
className="mx-0.5 !h-5 hidden md:block"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="size-11 text-muted-foreground md:size-8"
|
|
||||||
onClick={onEditFlow}
|
|
||||||
aria-label="Flow settings"
|
|
||||||
data-testid="edit-flow"
|
|
||||||
>
|
|
||||||
<Pencil />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Flow settings</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* Throwing the edit away sits next to putting it live, and only exists
|
|
||||||
while there is something to throw away. */}
|
while there is something to throw away. */}
|
||||||
{hasDraft ? (
|
{hasDraft ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-11 text-muted-foreground md:size-8"
|
||||||
|
onClick={onDiscard}
|
||||||
|
aria-label="Discard the unpublished changes"
|
||||||
|
data-testid="discard-draft"
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Discard the unpublished changes</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/*
|
||||||
|
* Saved state and publish are one control: the glyph never moves, it
|
||||||
|
* simply stops being something you can press once there is nothing left
|
||||||
|
* to put live. A button that appears and disappears moved everything
|
||||||
|
* beside it just as the work was finished.
|
||||||
|
*/}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<span>
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="ghost"
|
||||||
className="size-11 text-muted-foreground md:size-8"
|
size="icon"
|
||||||
onClick={onDiscard}
|
className="size-11 text-muted-foreground md:size-8"
|
||||||
aria-label="Discard the unpublished changes"
|
onClick={onPublish}
|
||||||
data-testid="discard-draft"
|
disabled={!hasDraft || publishing || saving || !connected}
|
||||||
>
|
aria-label="Publish this flow"
|
||||||
<X />
|
data-testid="publish-flow"
|
||||||
</Button>
|
>
|
||||||
|
{!connected ? (
|
||||||
|
<WifiOff className="size-3.5" />
|
||||||
|
) : saving || publishing ? (
|
||||||
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check className="size-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Discard the unpublished changes</TooltipContent>
|
<TooltipContent>
|
||||||
|
{!connected
|
||||||
|
? "Reconnecting to the engine"
|
||||||
|
: publishing
|
||||||
|
? "Publishing"
|
||||||
|
: saving
|
||||||
|
? "Saving"
|
||||||
|
: hasDraft
|
||||||
|
? "Saved — publish to put it live"
|
||||||
|
: "All changes saved"}
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What stops this flow running, or is merely worth knowing — the same chrome as
|
||||||
|
* the logs, so a canvas with both open shows one pair rather than a panel and a
|
||||||
|
* popover disagreeing about what a panel looks like.
|
||||||
|
*
|
||||||
|
* Not a popover for the same reason the logs are not: clicking a node is what
|
||||||
|
* you do while reading this, since each line points at one.
|
||||||
|
*/
|
||||||
|
function IssuesPanel({
|
||||||
|
open,
|
||||||
|
issues,
|
||||||
|
onClose,
|
||||||
|
onFocusNode,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
issues: ValidationIssue[]
|
||||||
|
onClose: () => void
|
||||||
|
onFocusNode: (nodeId: string) => void
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") onClose()
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onKeyDown)
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown)
|
||||||
|
}, [open, onClose])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && issues.length > 0 ? (
|
||||||
|
<motion.div
|
||||||
|
variants={slideUp}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
exit="exit"
|
||||||
|
transition={transitions.emphasized}
|
||||||
|
className={PANEL}
|
||||||
|
data-testid="issues-panel"
|
||||||
|
>
|
||||||
|
<div className="border-b border-border px-3 py-2">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground">
|
||||||
|
Needs attention
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ul className="grid gap-0.5 p-2">
|
||||||
|
{issues.map((issue) => (
|
||||||
|
<li key={`${issue.code}-${issue.node}-${issue.message_name}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"w-full rounded-sm px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent/50 disabled:cursor-default disabled:hover:bg-transparent",
|
||||||
|
issue.advisory && "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
onClick={() => issue.node && onFocusNode(issue.node)}
|
||||||
|
disabled={!issue.node}
|
||||||
|
>
|
||||||
|
{issue.message}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</motion.div>
|
||||||
) : null}
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
{/*
|
|
||||||
* Saved state and publish are one control: the glyph never moves, it
|
|
||||||
* simply stops being something you can press once there is nothing left
|
|
||||||
* to put live. A button that appears and disappears moved everything
|
|
||||||
* beside it just as the work was finished.
|
|
||||||
*/}
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="size-11 text-muted-foreground md:size-8"
|
|
||||||
onClick={onPublish}
|
|
||||||
disabled={!hasDraft || publishing || saving || !connected}
|
|
||||||
aria-label="Publish this flow"
|
|
||||||
data-testid="publish-flow"
|
|
||||||
>
|
|
||||||
{!connected ? (
|
|
||||||
<WifiOff className="size-3.5" />
|
|
||||||
) : saving || publishing ? (
|
|
||||||
<Loader2 className="size-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Check className="size-3.5" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{!connected
|
|
||||||
? "Reconnecting to the engine"
|
|
||||||
: publishing
|
|
||||||
? "Publishing"
|
|
||||||
: saving
|
|
||||||
? "Saving"
|
|
||||||
: hasDraft
|
|
||||||
? "Saved — publish to put it live"
|
|
||||||
: "All changes saved"}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</motion.div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ import { slideUp, transitions } from "@/lib/motion"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { liveStore, useLiveLogs } from "./liveStore"
|
import { liveStore, useLiveLogs } from "./liveStore"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The chrome a panel over this canvas wears: frosted, bordered, and the same
|
||||||
|
* width whichever of them is open, so two side by side read as one pair.
|
||||||
|
*/
|
||||||
|
export const PANEL =
|
||||||
|
"pointer-events-auto w-[30rem] max-w-[calc(100vw-2rem)] rounded-lg border border-border bg-card/80 shadow-e2 backdrop-blur-md"
|
||||||
|
|
||||||
function shortTime(ts: number): string {
|
function shortTime(ts: number): string {
|
||||||
return new Date(ts * 1000).toLocaleTimeString(undefined, {
|
return new Date(ts * 1000).toLocaleTimeString(undefined, {
|
||||||
hour12: false,
|
hour12: false,
|
||||||
@@ -36,6 +43,38 @@ export type LogsFilter = {
|
|||||||
onClearNode: () => void
|
onClearNode: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dock button that opens the logs. Separate from the panel because the two
|
||||||
|
* sit in different rows: the trigger belongs in the bar, the panel above it,
|
||||||
|
* beside whatever else is open.
|
||||||
|
*/
|
||||||
|
export function LogsTrigger({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: Pick<LogsFilter, "open" | "onOpenChange">) {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"size-11 text-muted-foreground md:size-8",
|
||||||
|
open && "text-primary",
|
||||||
|
)}
|
||||||
|
onClick={() => onOpenChange(!open)}
|
||||||
|
aria-pressed={open}
|
||||||
|
aria-label="Logs"
|
||||||
|
data-testid="flow-logs"
|
||||||
|
>
|
||||||
|
<Terminal />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>What this flow printed</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What the nodes of this flow printed, and the tracebacks of the ones that
|
* What the nodes of this flow printed, and the tracebacks of the ones that
|
||||||
* failed — the detail the one-line error bubble on a node has no room for.
|
* failed — the detail the one-line error bubble on a node has no room for.
|
||||||
@@ -75,112 +114,92 @@ export function LogsPanel({
|
|||||||
}, [open, onOpenChange])
|
}, [open, onOpenChange])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<AnimatePresence>
|
||||||
<Tooltip>
|
{open ? (
|
||||||
<TooltipTrigger asChild>
|
// Laid out by the dock's panel row rather than positioned against the
|
||||||
<Button
|
// bar, so it can sit beside the issues list instead of over it — and
|
||||||
variant="ghost"
|
// so neither this nor the bar nests a half-pixel translate or a
|
||||||
size="icon"
|
// backdrop filter inside the other, which is what Firefox was
|
||||||
className={cn(
|
// rasterising the text through. Deliberately not a popover: a click on
|
||||||
"size-11 text-muted-foreground md:size-8",
|
// the canvas is what you do *while* reading the logs, so only the
|
||||||
open && "text-primary",
|
// button or Escape puts them away.
|
||||||
)}
|
<motion.div
|
||||||
onClick={() => onOpenChange(!open)}
|
variants={slideUp}
|
||||||
aria-pressed={open}
|
initial="hidden"
|
||||||
aria-label="Logs"
|
animate="visible"
|
||||||
data-testid="flow-logs"
|
exit="exit"
|
||||||
>
|
transition={transitions.emphasized}
|
||||||
<Terminal />
|
className={PANEL}
|
||||||
</Button>
|
data-testid="logs-panel"
|
||||||
</TooltipTrigger>
|
>
|
||||||
<TooltipContent>What this flow printed</TooltipContent>
|
<div className="flex items-center justify-between border-b border-border px-3 py-2">
|
||||||
</Tooltip>
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground">
|
||||||
<AnimatePresence>
|
Logs
|
||||||
{open ? (
|
|
||||||
// A sibling of the button but positioned against the dock, so it
|
|
||||||
// sits centred above the whole bar and clears it by `mb-3` however
|
|
||||||
// many rows the bar wrapped into. Deliberately not a popover: a
|
|
||||||
// click on the canvas is what you do *while* reading the logs, so
|
|
||||||
// only the button or Escape puts them away.
|
|
||||||
<motion.div
|
|
||||||
variants={slideUp}
|
|
||||||
initial="hidden"
|
|
||||||
animate="visible"
|
|
||||||
exit="exit"
|
|
||||||
transition={transitions.emphasized}
|
|
||||||
className="absolute bottom-full left-1/2 mb-3 w-[28rem] max-w-[calc(100vw-2rem)] -translate-x-1/2 rounded-lg border border-border bg-card/80 shadow-e2 backdrop-blur-md"
|
|
||||||
data-testid="logs-panel"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between border-b border-border px-3 py-2">
|
|
||||||
<div className="flex min-w-0 items-center gap-1.5">
|
|
||||||
<p className="text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground">
|
|
||||||
Logs
|
|
||||||
</p>
|
|
||||||
{node ? (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 min-w-0 gap-1 px-2 font-mono text-xs"
|
|
||||||
onClick={onClearNode}
|
|
||||||
data-testid="clear-logs-filter"
|
|
||||||
>
|
|
||||||
<span className="truncate">{node}</span>
|
|
||||||
<X className="size-3 shrink-0" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 px-2 text-xs text-muted-foreground"
|
|
||||||
onClick={() => liveStore.clearLogs()}
|
|
||||||
disabled={lines.length === 0}
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{lines.length === 0 ? (
|
|
||||||
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
|
|
||||||
{node
|
|
||||||
? `Nothing from ${node} yet.`
|
|
||||||
: "Nothing yet. Anything a node prints shows up here."}
|
|
||||||
</p>
|
</p>
|
||||||
) : (
|
{node ? (
|
||||||
<ScrollArea className="h-72">
|
<Button
|
||||||
<ul className="grid gap-1.5 p-3 font-mono text-xs">
|
variant="ghost"
|
||||||
{lines.map((line, index) => (
|
size="sm"
|
||||||
<li
|
className="h-6 min-w-0 gap-1 px-2 font-mono text-xs"
|
||||||
// Lines are append-only and repeat freely, so position is the
|
onClick={onClearNode}
|
||||||
// only stable identity they have.
|
data-testid="clear-logs-filter"
|
||||||
key={`${line.ts}-${line.node}-${index}`}
|
>
|
||||||
className="grid grid-cols-[auto_1fr] gap-2"
|
<span className="truncate">{node}</span>
|
||||||
|
<X className="size-3 shrink-0" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-xs text-muted-foreground"
|
||||||
|
onClick={() => liveStore.clearLogs()}
|
||||||
|
disabled={lines.length === 0}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lines.length === 0 ? (
|
||||||
|
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
{node
|
||||||
|
? `Nothing from ${node} yet.`
|
||||||
|
: "Nothing yet. Anything a node prints shows up here."}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="h-72">
|
||||||
|
<ul className="grid gap-1.5 p-3 font-mono text-xs">
|
||||||
|
{lines.map((line, index) => (
|
||||||
|
<li
|
||||||
|
// Lines are append-only and repeat freely, so position is the
|
||||||
|
// only stable identity they have.
|
||||||
|
key={`${line.ts}-${line.node}-${index}`}
|
||||||
|
className="grid grid-cols-[auto_1fr] gap-2"
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{shortTime(line.ts)}{" "}
|
||||||
|
<span className="text-foreground/70">
|
||||||
|
{nodeLabel(line.node, flow)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"whitespace-pre-wrap break-words",
|
||||||
|
line.level === "error" && "text-destructive",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<span className="text-muted-foreground">
|
{line.text.replace(/\n+$/, "")}
|
||||||
{shortTime(line.ts)}{" "}
|
{line.truncated ? "\n… truncated" : ""}
|
||||||
<span className="text-foreground/70">
|
</span>
|
||||||
{nodeLabel(line.node, flow)}
|
</li>
|
||||||
</span>
|
))}
|
||||||
</span>
|
<li ref={bottom} aria-hidden />
|
||||||
<span
|
</ul>
|
||||||
className={cn(
|
</ScrollArea>
|
||||||
"whitespace-pre-wrap break-words",
|
)}
|
||||||
line.level === "error" && "text-destructive",
|
</motion.div>
|
||||||
)}
|
) : null}
|
||||||
>
|
</AnimatePresence>
|
||||||
{line.text.replace(/\n+$/, "")}
|
|
||||||
{line.truncated ? "\n… truncated" : ""}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
<li ref={bottom} aria-hidden />
|
|
||||||
</ul>
|
|
||||||
</ScrollArea>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
) : null}
|
|
||||||
</AnimatePresence>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user