From efcef68426b96c0329262080e3332304edf072d3 Mon Sep 17 00:00:00 2001 From: iammojogo-sudo Date: Thu, 3 Sep 2026 11:31:57 -0400 Subject: [PATCH 1/7] feat: notify user on generation errors and fix launch - Add showErrorNotification mirroring showCompletionNotification - Fire error notification from appStore.updateCurrentJob when status becomes error - Fire error toasts even when window has focus (was gated by hasFocus) - Disable backgroundThrottling so renderer polling + notifications fire when minimized --- electron/main/index.ts | 3 ++- package-lock.json | 12 ++++++------ package.json | 2 +- src/shared/stores/appStore.ts | 4 ++++ src/shared/utils/notification.ts | 8 ++++++++ 5 files changed, 21 insertions(+), 8 deletions(-) diff --git a/electron/main/index.ts b/electron/main/index.ts index 0cbd5e5a..2f3f5b83 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -33,7 +33,8 @@ function createWindow(): void { preload: join(__dirname, '../preload/index.js'), sandbox: false, contextIsolation: true, - nodeIntegration: false + nodeIntegration: false, + backgroundThrottling: false } }) diff --git a/package-lock.json b/package-lock.json index 5f7152ca..7c354c2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "modly", - "version": "0.4.1", + "version": "0.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "modly", - "version": "0.4.1", + "version": "0.4.2", "dependencies": { "@electron-toolkit/utils": "^4.0.0", "@mkkellogg/gaussian-splats-3d": "^0.4.7", @@ -34,7 +34,7 @@ "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", "cross-env": "^10.1.0", - "electron": "^42.4.1", + "electron": "^42.11.1", "electron-builder": "^26.15.3", "electron-vite": "^5.0.0", "eslint": "^9.17.0", @@ -4166,9 +4166,9 @@ } }, "node_modules/electron": { - "version": "42.9.3", - "resolved": "https://registry.npmjs.org/electron/-/electron-42.9.3.tgz", - "integrity": "sha512-REQUgPrCWOP0FajNcKCwwjkssirN+MVbemyd6bT+x51OVq58y6IqjtpA4vmm/KEzKXj2MIOebx/gF497CkRAPg==", + "version": "42.11.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-42.11.1.tgz", + "integrity": "sha512-mRYYjGDRWCyU+h4FU/0ruqxL/rXc+h2VCLhTb19KHu18HCUFWQAeFS/mFwnEKT/7GQCwlIHOhHie5ICLPXW6aw==", "license": "MIT", "dependencies": { "@electron-internal/extract-zip": "^1.0.1", diff --git a/package.json b/package.json index df135498..48dd6a29 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", "cross-env": "^10.1.0", - "electron": "^42.4.1", + "electron": "^42.11.1", "electron-builder": "^26.15.3", "electron-vite": "^5.0.0", "eslint": "^9.17.0", diff --git a/src/shared/stores/appStore.ts b/src/shared/stores/appStore.ts index d06a5211..6e0cb792 100644 --- a/src/shared/stores/appStore.ts +++ b/src/shared/stores/appStore.ts @@ -1,5 +1,6 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' +import { showErrorNotification } from '@shared/utils/notification' export type UiScale = 'small' | 'medium' | 'large' | 'very-large' export type BackendStatus = 'not_started' | 'starting' | 'ready' | 'error' @@ -290,6 +291,9 @@ export const useAppStore = create()( const current = get().currentJob if (!current) return set({ currentJob: { ...current, ...patch } }) + if (patch.status === 'error') { + void showErrorNotification('Generation failed', `Workspace generation failed: ${patch.error ?? 'Unknown error'}`) + } }, setGenerationOptions: (patch) => { diff --git a/src/shared/utils/notification.ts b/src/shared/utils/notification.ts index 864cd161..789ed827 100644 --- a/src/shared/utils/notification.ts +++ b/src/shared/utils/notification.ts @@ -15,3 +15,11 @@ export async function showCompletionNotification(body: string, title = 'Modly'): // Notifications not available (e.g. unsupported platform) } } + +export async function showErrorNotification(body: string, title = 'Modly'): Promise { + try { + await window.electron.notifications.show(title, body) + } catch { + // Notifications not available (e.g. unsupported platform) + } +} From e75f19bfeb2412b265cd45ce8247976d1ee6e090 Mon Sep 17 00:00:00 2001 From: mojo Date: Thu, 1 Oct 2026 21:51:06 -0400 Subject: [PATCH 2/7] Add files via upload --- WorkflowPanel.tsx | 798 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 798 insertions(+) create mode 100644 WorkflowPanel.tsx diff --git a/WorkflowPanel.tsx b/WorkflowPanel.tsx new file mode 100644 index 00000000..e5906ae5 --- /dev/null +++ b/WorkflowPanel.tsx @@ -0,0 +1,798 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + ReactFlowProvider, + useNodesState, useEdgesState, useReactFlow, + type Node as FlowNode, type Edge as FlowEdge, +} from '@xyflow/react' +// ReactFlowProvider wraps EmbeddedCanvas so useReactFlow() works in param rows +import { useWorkflowsStore } from '@shared/stores/workflowsStore' +import { useAppStore } from '@shared/stores/appStore' +import { useExtensionsStore } from '@shared/stores/extensionsStore' +import { useNavStore } from '@shared/stores/navStore' +import { useWorkflowRunStore } from '@areas/workflows/workflowRunStore' +import { useWaitButton } from '@areas/workflows/useWaitButton' +import { buildAllWorkflowExtensions, getWorkflowExtension } from '@areas/workflows/mockExtensions' +import { validateWorkflowPreflight } from '@areas/workflows/preflight' +import { mimeFromPath } from '@areas/workflows/nodes/imageUtils' +import type { WorkflowExtension } from '@areas/workflows/mockExtensions' +import type { Workflow, WFNode, WFEdge, ParamSchema } from '@shared/types/electron.d' +import { PICKER_LABELS, openParamPicker, resolvePickerIntent } from '@shared/utils/paramPicker' +import { PickerIcon } from '@shared/components/ui' +import ChatPanel from './ChatPanel' + +type PanelMode = 'basic' | 'chat' + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const TYPE_COLOR: Record = { + image: '#38bdf8', + mesh: '#a78bfa', + text: '#fbbf24', +} + +const SUPPORTED_IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']) + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function topoSortNodes(nodes: Workflow['nodes'], edges: Workflow['edges']): WFNode[] { + const nodeMap = new Map(nodes.map((n) => [n.id, n])) + const inDegree = new Map(nodes.map((n) => [n.id, 0])) + const adj = new Map(nodes.map((n) => [n.id, [] as string[]])) + for (const e of edges) { + if (!nodeMap.has(e.source) || !nodeMap.has(e.target)) continue + adj.get(e.source)!.push(e.target) + inDegree.set(e.target, (inDegree.get(e.target) ?? 0) + 1) + } + const queue = nodes.filter((n) => (inDegree.get(n.id) ?? 0) === 0) + const result: WFNode[] = [] + while (queue.length > 0) { + const node = queue.shift()! + result.push(node) + for (const neighbor of adj.get(node.id) ?? []) { + const deg = (inDegree.get(neighbor) ?? 0) - 1 + inDegree.set(neighbor, deg) + if (deg === 0) queue.push(nodeMap.get(neighbor)!) + } + } + return result +} + +// ─── Param field ────────────────────────────────────────────────────────────── + +const inputCls = 'w-full bg-zinc-800 border border-zinc-700/80 rounded-md px-2 py-1 text-[11px] text-zinc-200 focus:outline-none focus:border-accent/60' + +function IntInput({ value, onChange, className }: { value: number; onChange: (v: number) => void; className: string }) { + const [text, setText] = useState(String(value)) + const prevValue = useRef(value) + if (prevValue.current !== value && parseInt(text, 10) !== value) { + prevValue.current = value + setText(String(value)) + } + return ( + { + const raw = e.target.value + if (raw !== '' && raw !== '-' && !/^-?\d+$/.test(raw)) return + setText(raw) + const n = parseInt(raw, 10) + if (!isNaN(n)) { prevValue.current = n; onChange(n) } + }} + className={className} + /> + ) +} + +function FloatInput({ value, onChange, className, min, max, step, label, defaultValue }: { + value: number + onChange: (v: number) => void + className: string + min?: number + max?: number + step?: number + label: string + defaultValue: number +}) { + const [text, setText] = useState(String(value)) + const prevValue = useRef(value) + if (prevValue.current !== value && parseFloat(text.replace(',', '.')) !== value) { + prevValue.current = value + setText(String(value)) + } + const sliderMin = typeof min === 'number' ? min : 0 + const sliderMax = typeof max === 'number' ? max : 0 + const hasSlider = typeof min === 'number' && typeof max === 'number' && sliderMax > sliderMin + const sliderStep = typeof step === 'number' && step > 0 + ? step + : hasSlider ? (sliderMax - sliderMin) / 100 : undefined + const parsedValue = typeof value === 'number' ? value : Number.parseFloat(String(value)) + const sliderValue = Number.isFinite(parsedValue) ? parsedValue : defaultValue + const numberInput = ( + { + const raw = e.target.value.replace(',', '.') + if (raw !== '' && raw !== '-' && raw !== '.' && !/^-?\d*\.?\d*$/.test(raw)) return + setText(e.target.value) + const num = parseFloat(raw) + if (!isNaN(num)) { prevValue.current = num; onChange(num) } + }} + className={hasSlider ? `${className.replace('w-full', 'w-16 shrink-0 text-center')} nodrag` : className} + /> + ) + if (!hasSlider) return numberInput + + return ( +
+ { + const num = e.currentTarget.valueAsNumber + if (Number.isFinite(num)) { setText(String(num)); prevValue.current = num; onChange(num) } + }} + aria-label={`${label} slider`} + style={{ accentColor: '#38bdf8', cursor: 'pointer' }} + className="nodrag min-w-0 flex-1" + /> + {numberInput} +
+ ) +} + +function ParamField({ param, value, onChange }: { + param: ParamSchema + value: number | string + onChange: (v: number | string) => void +}) { + if (param.type === 'select') { + return ( + + ) + } + if (param.type === 'string') { + const intent = resolvePickerIntent(param) + return ( +
+ onChange(e.target.value)} className={`${inputCls} flex-1`} /> + +
+ ) + } + if (param.type === 'float') { + return onChange(v)} className={inputCls} + min={param.min} max={param.max} step={param.step} label={param.label} + defaultValue={typeof param.default === 'number' ? param.default : 0} /> + } + // int + return onChange(v)} className={inputCls} /> +} + +// ─── Workflow dropdown ──────────────────────────────────────────────────────── + +function WorkflowDropdown({ workflows, value, onChange }: { + workflows: Workflow[] + value: string | null + onChange: (id: string) => void +}) { + const [open, setOpen] = useState(false) + const selected = workflows.find((w) => w.id === value) + + if (workflows.length === 0) { + return ( +
+ No workflows yet +
+ ) + } + + return ( +
+ + + {open && ( +
+ {workflows.map((wf, i) => ( + + ))} +
+ )} +
+ ) +} + +// ─── Node param rows ────────────────────────────────────────────────────────── +// These components receive nodes + onPatch directly from EmbeddedCanvas +// to avoid relying on the React Flow store (which requires a mounted ). + +type PatchFn = (nodeId: string, patch: Record) => void + +function ImageParamRow({ nodeId, nodes, onPatch }: { nodeId: string; nodes: FlowNode[]; onPatch: PatchFn }) { + const node = nodes.find((n) => n.id === nodeId) + const data = node?.data as { params: Record } | undefined + const preview = data?.params.preview as string | undefined + const showToast = useAppStore((state) => state.showToast) + const loadRequest = useRef(0) + + const applyImagePath = useCallback(async (path: string | null) => { + const request = ++loadRequest.current + if (!path) return + try { + const base64 = await window.electron.fs.readFileBase64(path) + if (request !== loadRequest.current) return + const src = `data:${mimeFromPath(path)};base64,${base64}` + onPatch(nodeId, { params: { ...(data?.params ?? {}), filePath: path, preview: src } }) + } catch { + if (request === loadRequest.current) showToast('Unable to load the selected image') + } + }, [nodeId, data?.params, onPatch, showToast]) + + const browse = useCallback(async () => { + await applyImagePath(await window.electron.fs.selectImage()) + }, [applyImagePath]) + + return ( +
{ + event.preventDefault() + event.dataTransfer.dropEffect = 'copy' + }} + onDrop={(event) => { + event.preventDefault() + const file = event.dataTransfer.files[0] + if (!file || !SUPPORTED_IMAGE_TYPES.has(file.type)) return + void applyImagePath(window.electron.fs.getPathForFile(file)) + }} + > +
+ + + + + Image +
+ {preview ? ( + + ) : ( + + )} +
+ ) +} + +function MeshParamRow({ nodeId, nodes, onPatch }: { nodeId: string; nodes: FlowNode[]; onPatch: PatchFn }) { + const node = nodes.find((n) => n.id === nodeId) + const data = node?.data as { params: Record } | undefined + const source = (data?.params.source as 'file' | 'current' | undefined) ?? 'file' + const fileName = data?.params.fileName as string | undefined + + const browse = useCallback(async () => { + const p = await window.electron.fs.selectMeshFile() + if (!p) return + const name = p.split(/[\\/]/).pop() ?? p + onPatch(nodeId, { params: { ...(data?.params ?? {}), filePath: p, fileName: name, source: 'file' } }) + }, [nodeId, data?.params, onPatch]) + + const toggleSource = useCallback(() => { + const next = source === 'file' ? 'current' : 'file' + onPatch(nodeId, { params: { ...(data?.params ?? {}), source: next } }) + }, [nodeId, data?.params, source, onPatch]) + + return ( +
+
+ + + + Load 3D Mesh +
+ + {/* Toggle: use current model */} + + + {source === 'file' ? ( + fileName ? ( + + ) : ( + + ) + ) : ( +
+ Uses the model currently loaded in the 3D viewer +
+ )} +
+ ) +} + +function TextParamRow({ nodeId, nodes, onPatch }: { nodeId: string; nodes: FlowNode[]; onPatch: PatchFn }) { + const node = nodes.find((n) => n.id === nodeId) + const data = node?.data as { params: Record } | undefined + const text = (data?.params.text as string | undefined) ?? '' + + return ( +
+
+ + + + Text +
+