diff --git a/frontend/src/app/canvas/CanvasPage.tsx b/frontend/src/app/canvas/CanvasPage.tsx index 6e9d753..4722eb2 100644 --- a/frontend/src/app/canvas/CanvasPage.tsx +++ b/frontend/src/app/canvas/CanvasPage.tsx @@ -51,6 +51,11 @@ import { EmptyTitle, } from '@/components/ui/empty' import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogTitle, +} from '@/components/ui/dialog' import { ClipboardPaste, FolderOpen, FileStack, CircleDotDashed } from 'lucide-react' import { DEFAULT_NODE_STYLE, getDefaultDataForType, getNextNodeId } from '@/lib/flowUtils' import { @@ -58,6 +63,7 @@ import { getRegisteredNodeTypesGroupedByClassification, getRegisteredNodeTypeIds, getDefaultStyle, + getNodeType, isConnectionAllowed, } from '@/lib/nodeRegistry' import type { AppNode, AppEdge } from '@/lib/nodeTypes' @@ -131,6 +137,70 @@ function FlowFitViewOnLoad() { return null } +type FullscreenNodeOverlayProps = { + nodeId: string + nodes: AppNode[] + onClose: () => void +} + +function FullscreenNodeOverlay({ nodeId, nodes, onClose }: FullscreenNodeOverlayProps) { + const open = nodeId != null + const node = nodes.find((n) => n.id === nodeId) + if (!nodeId) return null + + return ( + { if (!isOpen) onClose() }}> + + Node: {node?.id ?? nodeId} + {node && node.type && ( + + )} + + + ) +} + +function FullscreenNodeContent({ node }: { node: AppNode }) { + const descriptor = getNodeType(node.type ?? '') + const NodeComponent = descriptor?.component as React.ComponentType<{ + id: string + data: Record + type?: string + selected?: boolean + width?: number + height?: number + }> | undefined + if (!NodeComponent) return null + + const w = typeof window !== 'undefined' ? Math.round(window.innerWidth * 0.94) : 1200 + const h = typeof window !== 'undefined' ? Math.round(window.innerHeight * 0.9) : 800 + + const fullscreenNodeForStore: AppNode = { + ...node, + position: node.position ?? { x: 0, y: 0 }, + style: { ...(node.style as object), width: w, height: h }, + } + + return ( +
+ + ) ?? {}} + type={node.type} + selected={false} + width={w} + height={h} + /> + +
+ ) +} + export type CanvasPageProps = { /** Optional project id for future per-project graph loading */ projectId?: string @@ -193,6 +263,7 @@ export function CanvasPage({ projectId }: CanvasPageProps) { const [isPanning, setIsPanning] = React.useState(false) const [isSelecting, setIsSelecting] = React.useState(false) const [ariaAnnouncement, setAriaAnnouncement] = React.useState(null) + const [fullscreenNodeId, setFullscreenNodeId] = React.useState(null) const nodesRef = useRef(nodes) nodesRef.current = nodes @@ -412,6 +483,8 @@ export function CanvasPage({ projectId }: CanvasPageProps) { setConnectionFrom, isValidConnection, flowActionsRef, + fullscreenNodeId, + setFullscreenNodeId, }), [ nodes, @@ -424,6 +497,8 @@ export function CanvasPage({ projectId }: CanvasPageProps) { setConnectionFrom, isValidConnection, flowActionsRef, + fullscreenNodeId, + setFullscreenNodeId, ] ) @@ -707,6 +782,13 @@ export function CanvasPage({ projectId }: CanvasPageProps) { + {fullscreenNodeId && ( + setFullscreenNodeId(null)} + /> + )} diff --git a/frontend/src/components/base/BaseNode.tsx b/frontend/src/components/base/BaseNode.tsx index 498e1a5..ff070ef 100644 --- a/frontend/src/components/base/BaseNode.tsx +++ b/frontend/src/components/base/BaseNode.tsx @@ -122,19 +122,37 @@ export function BaseNodeHeaderRow({ title, right, className, + onHeaderDoubleClick, }: { icon: ReactNode; title: ReactNode; /** Optional right-side content (e.g. type selector). */ right?: ReactNode; className?: string; + /** When provided, double-click on the header triggers this (e.g. fullscreen). Header gets cursor-pointer and nodrag. */ + onHeaderDoubleClick?: () => void; }) { return (
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onHeaderDoubleClick(); + } + } + : undefined + } className={cn( "shrink-0 mx-0 my-0 flex flex-row items-center justify-between gap-2 px-3 py-2", + onHeaderDoubleClick && "cursor-pointer", className, )} + title={onHeaderDoubleClick ? 'Double-click for full screen' : undefined} > {icon}

{ const onKeyDown = (ev: KeyboardEvent) => { if (ev.key === 'Escape') { + const openDialog = document.querySelector('[role="dialog"]') + if (openDialog && ev.target instanceof Node && openDialog.contains(ev.target)) return setConnectionFrom?.(null) setNodes?.((nds) => nds.map((n) => ({ ...n, selected: false }))) ev.preventDefault() diff --git a/frontend/src/components/nodes/AgentNode.tsx b/frontend/src/components/nodes/AgentNode.tsx index 23abbf3..2da9ebe 100644 --- a/frontend/src/components/nodes/AgentNode.tsx +++ b/frontend/src/components/nodes/AgentNode.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useState } from 'react' +import React, { useCallback, useContext, useMemo, useState } from 'react' import { AbstractNodeProps, createAbstractNodeComponent, @@ -15,6 +15,8 @@ import { NodeMenubar } from '@/components/base/NodeMenubar' import { NodeFooterEdgeIndicators } from '@/components/base/NodeFooterEdgeIndicators' import { NodeHeaderTitle } from '@/components/base/NodeHeaderTitle' import { InputHandle, OutputHandle } from '@/components/base/NodeHandles' +import FlowContext from '@/lib/flowContext' +import { getNodeType } from '@/lib/nodeRegistry' import { Button } from '@/components/ui/button' import { usePlatform } from '@/app/kosmos/KosmosContext' import { Bot, Play, Loader2 } from 'lucide-react' @@ -51,6 +53,9 @@ function serializeNodeForContext(nodes: { id: string; type?: string; data?: unkn } function AgentNodeComponent({ id, data, width, height, selected }: Props) { + const flowContext = useContext(FlowContext) + const setFullscreenNodeId = flowContext?.setFullscreenNodeId + const supportsFullscreen = getNodeType('agent')?.supportsFullscreen const { nodes, sourceIds, updateData } = useAbstractNode(id, data ?? {}) const { aiConnection } = usePlatform() const [running, setRunning] = useState(false) @@ -137,6 +142,7 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) { } title={} + onHeaderDoubleClick={supportsFullscreen && setFullscreenNodeId ? () => setFullscreenNodeId(id) : undefined} />
diff --git a/frontend/src/components/nodes/ConfigNode.tsx b/frontend/src/components/nodes/ConfigNode.tsx index 34224c5..0cb441e 100644 --- a/frontend/src/components/nodes/ConfigNode.tsx +++ b/frontend/src/components/nodes/ConfigNode.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useRef } from 'react' +import React, { useCallback, useContext, useMemo, useRef } from 'react' import { autocompletion } from '@codemirror/autocomplete' import CodeMirror from '@uiw/react-codemirror' import { javascript } from '@codemirror/lang-javascript' @@ -42,12 +42,17 @@ import { InputHandle, OutputHandle } from '../base/NodeHandles' import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators' import { NodeHeaderTitle } from '../base/NodeHeaderTitle' import { NodeMenubar } from '../base/NodeMenubar' +import FlowContext from '../../lib/flowContext' +import { getNodeType } from '../../lib/nodeRegistry' export type ConfigNodeData = { configType?: ConfigTypeId; content?: string; title?: string; /** @deprecated use content */ plantuml?: string } type Props = AbstractNodeProps function ConfigNodeComponent({ id, data, width, height, selected }: Props) { + const flowContext = useContext(FlowContext) + const setFullscreenNodeId = flowContext?.setFullscreenNodeId + const supportsFullscreen = getNodeType('config')?.supportsFullscreen const configTypeId = getConfigTypeId(data ?? {}) const configType = getConfigType(configTypeId) const content = getConfigContent(data ?? {}) @@ -262,6 +267,7 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) { } + onHeaderDoubleClick={supportsFullscreen && setFullscreenNodeId ? () => setFullscreenNodeId(id) : undefined} /> diff --git a/frontend/src/components/nodes/FunctionNode.tsx b/frontend/src/components/nodes/FunctionNode.tsx index f972fff..7633a97 100644 --- a/frontend/src/components/nodes/FunctionNode.tsx +++ b/frontend/src/components/nodes/FunctionNode.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useRef } from 'react' +import React, { useCallback, useContext, useMemo, useRef } from 'react' import CodeMirror from '@uiw/react-codemirror' import { javascript } from '@codemirror/lang-javascript' import { @@ -19,6 +19,8 @@ import { InputHandle, OutputHandle } from '../base/NodeHandles' import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators' import { NodeHeaderTitle } from '../base/NodeHeaderTitle' import { NodeMenubar } from '../base/NodeMenubar' +import FlowContext from '../../lib/flowContext' +import { getNodeType } from '../../lib/nodeRegistry' import { MenubarItem, MenubarShortcut } from '../ui/menubar' import { Kbd } from '../ui/kbd' import { Code2, Variable } from 'lucide-react' @@ -28,6 +30,9 @@ export type FunctionNodeData = { body?: string } type Props = AbstractNodeProps function FunctionNodeComponent({ id, data, width, height, selected }: Props) { + const flowContext = useContext(FlowContext) + const setFullscreenNodeId = flowContext?.setFullscreenNodeId + const supportsFullscreen = getNodeType('function')?.supportsFullscreen const bodyValue = data?.body ?? '' const { theme } = useTheme() const { nodes, sourceIds, updateData } = useAbstractNode(id, data ?? {}) @@ -92,7 +97,11 @@ function FunctionNodeComponent({ id, data, width, height, selected }: Props) { return ( }> - } title={} /> + } + title={} + onHeaderDoubleClick={supportsFullscreen && setFullscreenNodeId ? () => setFullscreenNodeId(id) : undefined} + />
diff --git a/frontend/src/components/nodes/RenderingNode.tsx b/frontend/src/components/nodes/RenderingNode.tsx index 3868247..474ff26 100644 --- a/frontend/src/components/nodes/RenderingNode.tsx +++ b/frontend/src/components/nodes/RenderingNode.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import nunjucks from 'nunjucks' import CodeMirror from '@uiw/react-codemirror' import { javascript } from '@codemirror/lang-javascript' @@ -19,7 +19,8 @@ import { BaseNodeHeaderRow, } from '../base/BaseNode' import { getDefaultDataForType, getNextNodeId } from '../../lib/flowUtils' -import { getDefaultStyle } from '../../lib/nodeRegistry' +import FlowContext from '../../lib/flowContext' +import { getDefaultStyle, getNodeType } from '../../lib/nodeRegistry' import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators' import { NodeHeaderTitle } from '../base/NodeHeaderTitle' import { NodeMenubar } from '../base/NodeMenubar' @@ -47,6 +48,9 @@ type Props = AbstractNodeProps type ViewMode = 'preview' | 'raw' function RenderingNodeComponent({ id, data, width, height, selected }: Props) { + const flowContext = useContext(FlowContext) + const setFullscreenNodeId = flowContext?.setFullscreenNodeId + const supportsFullscreen = getNodeType('render')?.supportsFullscreen const [renderedContent, setRenderedContent] = useState(null) const [resolvedContent, setResolvedContent] = useState(null) const [error, setError] = useState(null) @@ -675,6 +679,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) { } title={} + onHeaderDoubleClick={supportsFullscreen && setFullscreenNodeId ? () => setFullscreenNodeId(id) : undefined} right={ incomingIds.length > 0 ? ( , - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + React.ComponentPropsWithoutRef & { hideCloseButton?: boolean } +>(({ className, children, hideCloseButton, ...props }, ref) => ( {children} - - - Close - + {!hideCloseButton && ( + + + Close + + )} )) diff --git a/frontend/src/lib/flowContext.tsx b/frontend/src/lib/flowContext.tsx index 7afb3c3..5777fbc 100644 --- a/frontend/src/lib/flowContext.tsx +++ b/frontend/src/lib/flowContext.tsx @@ -22,6 +22,9 @@ export type FlowContextValue = { isValidConnection: (connection: Connection) => boolean /** Set by FlowKeyboardShortcuts so Node menubar can trigger paste / fit view. */ flowActionsRef: React.MutableRefObject + /** When set, graph centers on this node and a fullscreen dialog shows the node. Cleared on close. */ + fullscreenNodeId: string | null + setFullscreenNodeId: (id: string | null) => void } const FlowContext = React.createContext(null) diff --git a/frontend/src/lib/nodeRegistry.ts b/frontend/src/lib/nodeRegistry.ts index 0e3343f..ce1569c 100644 --- a/frontend/src/lib/nodeRegistry.ts +++ b/frontend/src/lib/nodeRegistry.ts @@ -45,6 +45,8 @@ export type NodeTypeDescriptor = { getResetData?: (nodeId?: string) => Record /** Optional: label shown on edge when this type is the target (e.g. "render", "add input"). */ connectionLabel?: string + /** When true, double-clicking the node header opens a fullscreen dialog for this node. */ + supportsFullscreen?: boolean } const registry = new Map() diff --git a/frontend/src/lib/registerBuiltinNodes.tsx b/frontend/src/lib/registerBuiltinNodes.tsx index c49933e..a0ed80e 100644 --- a/frontend/src/lib/registerBuiltinNodes.tsx +++ b/frontend/src/lib/registerBuiltinNodes.tsx @@ -42,6 +42,7 @@ export function registerBuiltinNodes(): void { title: nodeId ?? '', }), connectionLabel: 'adding input', + supportsFullscreen: true, }) registerNodeType({ @@ -58,6 +59,7 @@ export function registerBuiltinNodes(): void { menuLabel: 'Renderer', menuIcon: , connectionLabel: 'rendering', + supportsFullscreen: true, }) registerNodeType({ @@ -75,6 +77,7 @@ export function registerBuiltinNodes(): void { menuLabel: 'Agent', menuIcon: , connectionLabel: 'prompt/context', + supportsFullscreen: true, }) registerNodeType({ @@ -128,5 +131,6 @@ export function registerBuiltinNodes(): void { menuIcon: , getResetData: () => ({ body: '' }), connectionLabel: 'adding input', + supportsFullscreen: true, }) }