@@ -89,7 +89,7 @@ export default function App() {
8989 beginAltDragCopy, finishAltDragCopy, undoGraph,
9090 checkServer, reset, newTab, insertTab, switchTab, closeTab, duplicateTab,
9191 openGraphAsTab, openWorkflowAsTab, renameTab, saveActiveWorkflow,
92- diveIntoSubnet, exitSubnet, collapseToSubnet, organizeNodes, cookNode, dismissCookStatus, applyRunReplay,
92+ diveIntoSubnet, exitSubnet, collapseToSubnet, organizeNodes, cookNode, stopCook , dismissCookStatus, applyRunReplay,
9393 handleLearnedNodeEvent,
9494 } = useStore ( )
9595
@@ -102,6 +102,7 @@ export default function App() {
102102 const [ savingWorkflow , setSavingWorkflow ] = useState ( false )
103103 const [ saveOk , setSaveOk ] = useState ( false )
104104 const [ tabMenu , setTabMenu ] = useState < { x : number ; y : number ; tabId : string } | null > ( null )
105+ const [ nodeMenu , setNodeMenu ] = useState < { x : number ; y : number ; nodeId : string } | null > ( null )
105106 const [ notice , setNotice ] = useState < NoticeState | null > ( null )
106107 const [ pendingClose , setPendingClose ] = useState < PendingCloseState | null > ( null )
107108 const [ closeSaving , setCloseSaving ] = useState ( false )
@@ -171,6 +172,20 @@ export default function App() {
171172 }
172173 } , [ tabMenu ] )
173174
175+ useEffect ( ( ) => {
176+ if ( ! nodeMenu ) return
177+ const close = ( ) => setNodeMenu ( null )
178+ const onKeyDown = ( e : KeyboardEvent ) => {
179+ if ( e . key === 'Escape' ) close ( )
180+ }
181+ window . addEventListener ( 'mousedown' , close )
182+ window . addEventListener ( 'keydown' , onKeyDown )
183+ return ( ) => {
184+ window . removeEventListener ( 'mousedown' , close )
185+ window . removeEventListener ( 'keydown' , onKeyDown )
186+ }
187+ } , [ nodeMenu ] )
188+
174189 useEffect ( ( ) => {
175190 if ( ! pendingClose || closeSaving ) return
176191 const onKeyDown = ( e : KeyboardEvent ) => {
@@ -907,11 +922,11 @@ export default function App() {
907922
908923 < button
909924 className = "bn-top-button bn-top-run-button"
910- onClick = { ( ) => void handleRunGraph ( ) }
911- disabled = { ! serverOk || cookActive || nodes . length === 0 }
912- title = " Run current graph"
925+ onClick = { ( ) => ( cookActive ? stopCook ( ) : void handleRunGraph ( ) ) }
926+ disabled = { ! serverOk || ( ! cookActive && nodes . length === 0 ) }
927+ title = { cookActive ? 'Stop the running cook' : ' Run current graph' }
913928 >
914- { cookActive ? 'Running... ' : 'Run' }
929+ { cookActive ? '■ Stop ' : 'Run' }
915930 </ button >
916931
917932 < button
@@ -1136,6 +1151,58 @@ export default function App() {
11361151 </ div >
11371152 ) }
11381153
1154+ { nodeMenu && ( ( ) => {
1155+ const menuNode = nodes . find ( n => n . id === nodeMenu . nodeId )
1156+ if ( ! menuNode ) return null
1157+ const data = menuNode . data
1158+ const hasValue = data . cookResult !== undefined
1159+ const hasError = Boolean ( data . cookError )
1160+ return (
1161+ < div
1162+ onMouseDown = { e => e . stopPropagation ( ) }
1163+ onClick = { e => e . stopPropagation ( ) }
1164+ onContextMenu = { e => e . preventDefault ( ) }
1165+ style = { {
1166+ position : 'fixed' ,
1167+ top : nodeMenu . y ,
1168+ left : nodeMenu . x ,
1169+ zIndex : 40 ,
1170+ minWidth : 168 ,
1171+ background : 'var(--panel)' ,
1172+ border : '1px solid var(--line2)' ,
1173+ borderRadius : 7 ,
1174+ padding : 4 ,
1175+ boxShadow : '0 8px 24px rgba(0,0,0,.28)' ,
1176+ } }
1177+ >
1178+ < button
1179+ className = "bn-menu-item"
1180+ style = { menuItemStyle ( ! hasValue ) }
1181+ disabled = { ! hasValue }
1182+ onClick = { ( ) => { void copyToClipboard ( stringifyValue ( data . cookResult ) ) ; setNodeMenu ( null ) } }
1183+ >
1184+ Copy value
1185+ </ button >
1186+ { hasError && (
1187+ < button
1188+ className = "bn-menu-item"
1189+ style = { menuItemStyle ( false , 'var(--err)' ) }
1190+ onClick = { ( ) => { void copyToClipboard ( String ( data . cookError ) ) ; setNodeMenu ( null ) } }
1191+ >
1192+ Copy error
1193+ </ button >
1194+ ) }
1195+ < button
1196+ className = "bn-menu-item"
1197+ style = { menuItemStyle ( ) }
1198+ onClick = { ( ) => { void copyToClipboard ( menuNode . id ) ; setNodeMenu ( null ) } }
1199+ >
1200+ Copy node ID
1201+ </ button >
1202+ </ div >
1203+ )
1204+ } ) ( ) }
1205+
11391206 { pendingClose && pendingCloseTab && (
11401207 < div
11411208 role = "dialog"
@@ -1369,8 +1436,14 @@ export default function App() {
13691436 selectNode ( null )
13701437 setSearch ( null )
13711438 setTabMenu ( null )
1439+ setNodeMenu ( null )
13721440 } }
13731441 onPaneContextMenu = { onPaneContextMenu }
1442+ onNodeContextMenu = { ( e , node ) => {
1443+ e . preventDefault ( )
1444+ selectNode ( node . id )
1445+ setNodeMenu ( { x : e . clientX , y : e . clientY , nodeId : node . id } )
1446+ } }
13741447 fitView
13751448 fitViewOptions = { { padding : 0.24 , maxZoom : 1 } }
13761449 deleteKeyCode = { [ 'Delete' , 'Backspace' ] }
@@ -1496,9 +1569,11 @@ function CookStatusPanel({
14961569 raised : boolean
14971570 onDismiss : ( ) => void
14981571} ) {
1572+ const [ debug , setDebug ] = useState ( false )
14991573 if ( hidden || entries . length === 0 ) return null
15001574 const latest = entries [ entries . length - 1 ]
15011575 const recent = entries . slice ( - 8 ) . reverse ( )
1576+ const debugRows = entries . slice ( - 300 )
15021577 const statusColor = active ? 'var(--warn)' : latest . kind === 'error' ? 'var(--err)' : 'var(--ok)'
15031578
15041579 return (
@@ -1556,6 +1631,29 @@ function CookStatusPanel({
15561631 } } >
15571632 { active ? 'running' : 'idle' } · { entries . length }
15581633 </ div >
1634+ < button
1635+ type = "button"
1636+ aria-label = "Toggle debug output"
1637+ title = "Show full system output (stdout/stderr, node results, tracebacks)"
1638+ onClick = { ( ) => setDebug ( d => ! d ) }
1639+ style = { {
1640+ height : 20 ,
1641+ padding : '0 8px' ,
1642+ border : `1px solid ${ debug ? 'var(--accent)' : 'var(--line2)' } ` ,
1643+ borderRadius : 6 ,
1644+ background : debug ? 'var(--accent)' : 'transparent' ,
1645+ color : debug ? '#fff' : 'var(--tx3)' ,
1646+ cursor : 'pointer' ,
1647+ fontSize : 10 ,
1648+ fontWeight : 700 ,
1649+ fontFamily : 'var(--font-ui)' ,
1650+ letterSpacing : '0.04em' ,
1651+ textTransform : 'uppercase' ,
1652+ flexShrink : 0 ,
1653+ } }
1654+ >
1655+ Debug
1656+ </ button >
15591657 < button
15601658 type = "button"
15611659 aria-label = "Hide run status"
@@ -1581,49 +1679,126 @@ function CookStatusPanel({
15811679 </ button >
15821680 </ div >
15831681
1584- < div style = { { maxHeight : 168 , overflowY : 'auto' , padding : '4px 0' } } >
1585- { recent . map ( entry => {
1586- const color = entry . kind === 'error'
1587- ? 'var(--err)'
1588- : entry . kind === 'start'
1589- ? 'var(--warn)'
1590- : entry . kind === 'done'
1591- ? 'var(--accent)'
1592- : 'var(--ok)'
1593- return (
1594- < div
1595- key = { entry . id }
1596- style = { {
1597- display : 'grid' ,
1598- gridTemplateColumns : '70px 1fr' ,
1599- gap : 8 ,
1600- padding : '4px 10px' ,
1601- fontSize : 11 ,
1602- lineHeight : 1.35 ,
1603- fontFamily : 'var(--font-ui)' ,
1604- } }
1605- >
1606- < span style = { { color, fontWeight : 700 , textTransform : 'uppercase' } } >
1607- { entry . kind }
1608- </ span >
1609- < span style = { {
1610- color : entry . kind === 'error' ? 'var(--err)' : 'var(--tx2)' ,
1611- fontFamily : 'var(--font-mono)' ,
1612- whiteSpace : 'nowrap' ,
1613- overflow : 'hidden' ,
1614- textOverflow : 'ellipsis' ,
1615- } } >
1616- { entry . message }
1617- </ span >
1618- </ div >
1619- )
1620- } ) }
1621- </ div >
1682+ { debug ? (
1683+ < div style = { { maxHeight : 360 , overflowY : 'auto' , padding : '4px 0' } } >
1684+ { debugRows . map ( entry => {
1685+ const color = cookEntryColor ( entry . kind )
1686+ return (
1687+ < div
1688+ key = { entry . id }
1689+ style = { { padding : '4px 10px' , borderBottom : '1px solid var(--line)' } }
1690+ >
1691+ < div style = { {
1692+ display : 'flex' , gap : 8 , alignItems : 'baseline' ,
1693+ fontSize : 11 , fontFamily : 'var(--font-ui)' ,
1694+ } } >
1695+ < span style = { { color, fontWeight : 700 , textTransform : 'uppercase' , flexShrink : 0 } } >
1696+ { entry . stream ?? entry . kind }
1697+ </ span >
1698+ < span style = { { color : 'var(--tx2)' , fontFamily : 'var(--font-mono)' , wordBreak : 'break-word' } } >
1699+ { entry . message }
1700+ </ span >
1701+ </ div >
1702+ { entry . detail && (
1703+ < pre style = { {
1704+ margin : '4px 0 0' ,
1705+ padding : '6px 8px' ,
1706+ background : 'var(--lift)' ,
1707+ border : `1px solid ${ entry . kind === 'error' ? 'var(--err)' : 'var(--line2)' } ` ,
1708+ borderRadius : 5 ,
1709+ color : entry . kind === 'error' ? 'var(--err)' : entry . stream === 'stderr' ? 'var(--warn)' : 'var(--tx2)' ,
1710+ fontFamily : 'var(--font-mono)' ,
1711+ fontSize : 11 ,
1712+ lineHeight : 1.4 ,
1713+ whiteSpace : 'pre-wrap' ,
1714+ wordBreak : 'break-word' ,
1715+ maxHeight : 180 ,
1716+ overflowY : 'auto' ,
1717+ } } >
1718+ { entry . detail }
1719+ </ pre >
1720+ ) }
1721+ </ div >
1722+ )
1723+ } ) }
1724+ </ div >
1725+ ) : (
1726+ < div style = { { maxHeight : 168 , overflowY : 'auto' , padding : '4px 0' } } >
1727+ { recent . map ( entry => {
1728+ const color = cookEntryColor ( entry . kind )
1729+ return (
1730+ < div
1731+ key = { entry . id }
1732+ style = { {
1733+ display : 'grid' ,
1734+ gridTemplateColumns : '70px 1fr' ,
1735+ gap : 8 ,
1736+ padding : '4px 10px' ,
1737+ fontSize : 11 ,
1738+ lineHeight : 1.35 ,
1739+ fontFamily : 'var(--font-ui)' ,
1740+ } }
1741+ >
1742+ < span style = { { color, fontWeight : 700 , textTransform : 'uppercase' } } >
1743+ { entry . kind }
1744+ </ span >
1745+ < span style = { {
1746+ color : entry . kind === 'error' ? 'var(--err)' : 'var(--tx2)' ,
1747+ fontFamily : 'var(--font-mono)' ,
1748+ whiteSpace : 'nowrap' ,
1749+ overflow : 'hidden' ,
1750+ textOverflow : 'ellipsis' ,
1751+ } } >
1752+ { entry . message }
1753+ </ span >
1754+ </ div >
1755+ )
1756+ } ) }
1757+ </ div >
1758+ ) }
16221759 </ div >
16231760 </ div >
16241761 )
16251762}
16261763
1764+ function stringifyValue ( value : unknown ) : string {
1765+ if ( value === undefined || value === null ) return ''
1766+ if ( typeof value === 'string' ) return value
1767+ try {
1768+ return JSON . stringify ( value , null , 2 )
1769+ } catch {
1770+ return String ( value )
1771+ }
1772+ }
1773+
1774+ async function copyToClipboard ( text : string ) : Promise < void > {
1775+ if ( ! text ) return
1776+ if ( navigator . clipboard ?. writeText ) {
1777+ await navigator . clipboard . writeText ( text )
1778+ return
1779+ }
1780+ const ta = document . createElement ( 'textarea' )
1781+ ta . value = text
1782+ ta . style . position = 'fixed'
1783+ ta . style . left = '-9999px'
1784+ document . body . appendChild ( ta )
1785+ ta . focus ( )
1786+ ta . select ( )
1787+ document . execCommand ( 'copy' )
1788+ document . body . removeChild ( ta )
1789+ }
1790+
1791+ function cookEntryColor ( kind : CookLogEntry [ 'kind' ] ) : string {
1792+ switch ( kind ) {
1793+ case 'error' : return 'var(--err)'
1794+ case 'start' : return 'var(--warn)'
1795+ case 'done' : return 'var(--accent)'
1796+ case 'log' : return 'var(--tx3)'
1797+ case 'info' : return 'var(--tx3)'
1798+ default : return 'var(--ok)'
1799+ }
1800+ }
1801+
16271802function getCompatibleNodeTypes ( draft : ConnectionDraft , nodeDefs : Record < string , BnNodeDef > ) : string [ ] {
16281803 const types = Object . values ( nodeDefs )
16291804 . filter ( def => draft . handleType === 'source'
0 commit comments