import { Button } from "@cloudflare/kumo"; import { ArrowUUpLeftIcon, CaretRightIcon, CheckCircleIcon, DownloadSimpleIcon, GraphIcon, ImagesIcon, PlayIcon, PlusIcon, RowsIcon, ShareNetworkIcon, SlidersHorizontalIcon, SparkleIcon, SquaresFourIcon, WarningCircleIcon, XIcon } from "@phosphor-icons/react"; import { AnimatePresence, motion } from "motion/react"; import { useEffect, useMemo, useRef, useState } from "react"; import { getDef } from "../lib/registry"; import type { CanvasNode, FileRef, OutputValue } from "../lib/types"; import { OutputView } from "./OutputView"; import { useCanvasStore } from "../store/useCanvasStore"; import { useCopilotStore } from "../store/useCopilotStore"; export type MobilePanel = "library" | "inspector" | "copilot" | "results" | null; /** Which panel is showing on a phone. Only one fits, so the last one opened wins. */ export function useMobilePanel(active: boolean): MobilePanel { const leftOpen = useCanvasStore((s) => s.leftOpen); const rightOpen = useCanvasStore((s) => s.rightOpen); const copilotOpen = useCopilotStore((s) => s.open); const resultsOpen = useCanvasStore((s) => s.resultsOpen); const setLeftOpen = useCanvasStore((s) => s.setLeftOpen); const setRightOpen = useCanvasStore((s) => s.setRightOpen); const setResultsOpen = useCanvasStore((s) => s.setResultsOpen); const setCopilotOpen = useCopilotStore((s) => s.setOpen); const prev = useRef({ leftOpen, rightOpen, copilotOpen, resultsOpen }); useEffect(() => { if (!active) return; const p = prev.current; prev.current = { leftOpen, rightOpen, copilotOpen, resultsOpen }; const opened = (resultsOpen && !p.resultsOpen && "results") || (leftOpen && !p.leftOpen && "library") || (rightOpen && !p.rightOpen && "inspector") || (copilotOpen && !p.copilotOpen && "copilot") || null; if (!opened) return; if (opened !== "library" && leftOpen) setLeftOpen(false); if (opened !== "inspector" && rightOpen) setRightOpen(false); if (opened !== "copilot" && copilotOpen) setCopilotOpen(false); if (opened !== "results" && resultsOpen) setResultsOpen(false); }, [active, leftOpen, rightOpen, copilotOpen, resultsOpen, setLeftOpen, setRightOpen, setCopilotOpen, setResultsOpen]); if (!active) return null; return resultsOpen ? "results" : copilotOpen ? "copilot" : rightOpen ? "inspector" : leftOpen ? "library" : null; } /** Full-height sheet over the work area; the header carries the close button. */ export function MobileSheet({ panel, title, children }: { panel: MobilePanel; title: string; children: React.ReactNode }) { const setLeftOpen = useCanvasStore((s) => s.setLeftOpen); const setRightOpen = useCanvasStore((s) => s.setRightOpen); const setCopilotOpen = useCopilotStore((s) => s.setOpen); const setResultsOpen = useCanvasStore((s) => s.setResultsOpen); const close = () => { setLeftOpen(false); setRightOpen(false); setCopilotOpen(false); setResultsOpen(false); }; return ( {panel && ( {title} {children} )} ); } /** Bottom tab bar: what the side toggles and view switch do on a desktop. */ export function MobileBar({ panel }: { panel: MobilePanel }) { const view = useCanvasStore((s) => s.view); const setView = useCanvasStore((s) => s.setView); const setLeftOpen = useCanvasStore((s) => s.setLeftOpen); const setRightOpen = useCanvasStore((s) => s.setRightOpen); const setInspectorTab = useCanvasStore((s) => s.setInspectorTab); const openPalette = useCanvasStore((s) => s.openPalette); const selectedCount = useCanvasStore((s) => s.nodes.filter((n) => n.selected).length); const running = useCanvasStore((s) => s.run.running); const queued = useCanvasStore((s) => s.queue.length); const setCopilotOpen = useCopilotStore((s) => s.setOpen); const setResultsOpen = useCanvasStore((s) => s.setResultsOpen); const closeAll = () => { setLeftOpen(false); setRightOpen(false); setCopilotOpen(false); setResultsOpen(false); }; const toggle = (which: Exclude) => { if (panel === which) return closeAll(); if (which === "library") setLeftOpen(true); else if (which === "results") setResultsOpen(true); else if (which === "inspector") { setInspectorTab("properties"); setRightOpen(true); } else setCopilotOpen(true); }; const ViewIcon = view === "canvas" ? GraphIcon : RowsIcon; return ( toggle("library")}> Library { if (panel) closeAll(); else setView(view === "canvas" ? "pipeline" : "canvas"); }} title={panel ? "Back to the workflow" : `Switch to ${view === "canvas" ? "pipeline" : "canvas"} view`} > {view === "canvas" ? "Canvas" : "Pipeline"} openPalette(null)} aria-label="Add a node"> Add toggle("inspector")}> {(selectedCount > 0 || running || queued > 0) && } {selectedCount > 0 ? "Node" : "Inspect"} toggle("copilot")}> Copilot ); } /* Run strip -------------------------------------------------------------- */ const fmtSecs = (ms: number) => (ms < 60_000 ? `${Math.round(ms / 1000)}s` : `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`); /** Under the top bar while a run is active: the current step, its ETA, and a tap to jump to it. */ export function RunStrip() { const run = useCanvasStore((s) => s.run); const current = useCanvasStore((s) => (s.run.currentNodeId ? s.nodes.find((n) => n.id === s.run.currentNodeId) : undefined)); const runningNode = useCanvasStore((s) => s.nodes.find((n) => n.data.status === "running")); const failedNode = useCanvasStore((s) => s.nodes.find((n) => n.data.status === "error")); const queued = useCanvasStore((s) => s.queue.length); const selectNodes = useCanvasStore((s) => s.selectNodes); const setResultsOpen = useCanvasStore((s) => s.setResultsOpen); const [, tick] = useState(0); // The "Done" state hides itself after a few seconds. const finished = !run.running && run.finishedAt !== null && Date.now() - run.finishedAt < 6000; useEffect(() => { if (run.running) { const t = setInterval(() => tick((x) => x + 1), 1000); return () => clearInterval(t); } if (run.finishedAt === null) return; const t = setTimeout(() => tick((x) => x + 1), 6100); return () => clearTimeout(t); }, [run.running, run.finishedAt]); const show = run.running || finished; const node = runningNode ?? current; const title = node ? node.data.title ?? getDef(node.data.defType).title : null; const frac = run.total ? Math.min(1, (run.completed + (runningNode?.data.progress ?? 0)) / run.total) : 0; const jump = (id: string) => { selectNodes([id]); window.dispatchEvent(new CustomEvent("gr-canvas:focus-node", { detail: id })); }; return ( {show && ( { if (run.running) { if (node) jump(node.id); } else if (run.ok) setResultsOpen(true); else if (failedNode) jump(failedNode.id); }} > {run.running ? ( <> {title ?? "Starting…"} {runningNode?.data.statusText ?? (run.total ? `${Math.min(run.completed + 1, run.total)}/${run.total}` : fmtSecs(Date.now() - run.startedAt))} {queued > 0 && ` · +${queued} queued`} > ) : run.ok ? ( <> Done in {fmtSecs(Date.now() - run.startedAt)} Results > ) : ( <> {failedNode ? `${failedNode.data.title ?? getDef(failedNode.data.defType).title} failed` : "Run failed"} Show > )} )} ); } /* Results sheet ----------------------------------------------------------- */ const isRef = (v: unknown): v is FileRef => !!v && typeof v === "object" && typeof (v as FileRef).url === "string"; const filesOf = (v: OutputValue | undefined): FileRef[] => (isRef(v) ? [v] : Array.isArray(v) ? (v.filter(isRef) as FileRef[]) : []); const hasValue = (v: OutputValue | undefined) => v !== undefined && v !== null && v !== ""; async function fetchFile(ref: FileRef): Promise { try { const r = await fetch(ref.url); if (!r.ok) return null; const blob = await r.blob(); const name = ref.name ?? ref.url.split("/").pop()?.split("?")[0] ?? "output"; return new File([blob], name, { type: ref.mime || blob.type }); } catch { return null; } } async function shareRef(ref: FileRef, title: string, setToast: (t: { text: string } | null) => void) { const nav = navigator as Navigator & { canShare?: (d: ShareData) => boolean }; if (typeof nav.share !== "function") { try { await navigator.clipboard.writeText(ref.url); setToast({ text: "Link copied" }); } catch { window.open(ref.url, "_blank", "noopener"); } return; } const file = await fetchFile(ref); try { if (file && nav.canShare?.({ files: [file] })) await nav.share({ files: [file], title }); else await nav.share({ title, url: ref.url }); } catch { /* user dismissed */ } } async function saveRef(ref: FileRef) { const file = await fetchFile(ref); if (!file) return window.open(ref.url, "_blank", "noopener"); const url = URL.createObjectURL(file); const a = document.createElement("a"); a.href = url; a.download = file.name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 10_000); } function ResultCard({ node }: { node: CanvasNode }) { const def = getDef(node.data.defType); const setToast = useCanvasStore((s) => s.setToast); const title = node.data.title ?? def.title; const ports = def.outputs.filter((p) => hasValue(node.data.outputs?.[p.id])); return ( {title} {ports.map((p) => { const value = node.data.outputs?.[p.id]; const files = filesOf(value); return ( {ports.length > 1 && {p.label || p.id}} {files.length > 0 && ( {files.slice(0, 4).map((f, i) => ( void shareRef(f, title, setToast)}> Share{files.length > 1 ? ` ${i + 1}` : ""} void saveRef(f)}> Save ))} )} {files.length === 0 && typeof value === "string" && ( { if (typeof navigator.share === "function") void navigator.share({ title, text: value }).catch(() => undefined); else void navigator.clipboard.writeText(value).then(() => setToast({ text: "Copied" })); }} > Share )} ); })} ); } /** Every output of the last run, full width; final outputs first. */ export function ResultsView() { const nodes = useCanvasStore((s) => s.nodes); const edges = useCanvasStore((s) => s.edges); const order = useCanvasStore((s) => s.order); const running = useCanvasStore((s) => s.run.running); const enqueue = useCanvasStore((s) => s.enqueue); const setResultsOpen = useCanvasStore((s) => s.setResultsOpen); const items = useMemo(() => { const feeds = new Set(edges.map((e) => e.source)); const rank = new Map(order.map((id, i) => [id, i])); return nodes .filter((n) => { const def = getDef(n.data.defType); return !def.emits && def.outputs.some((p) => hasValue(n.data.outputs?.[p.id])); }) .sort((a, b) => Number(feeds.has(a.id)) - Number(feeds.has(b.id)) || (rank.get(b.id) ?? 0) - (rank.get(a.id) ?? 0)); }, [nodes, edges, order]); if (items.length === 0) return ( {running ? "Running… results land here." : "No results yet."} {!running && nodes.length > 0 && ( enqueue()}> Run workflow )} {!running && nodes.length === 0 && ( { setResultsOpen(false); useCanvasStore.getState().openPalette(null); }}> Add a node )} ); return ( {items.map((n) => ( ))} ); } /* Toast -------------------------------------------------------------------- */ /** Bottom toast with an optional action ("Removed X · Undo"). */ export function ToastHost() { const toast = useCanvasStore((s) => s.toast); const setToast = useCanvasStore((s) => s.setToast); return ( {toast && ( {toast.text} {toast.action && ( {toast.action.label === "Undo" && } {toast.action.label} )} setToast(null)}> )} ); }
{running ? "Running… results land here." : "No results yet."}