more qol upgrades

This commit is contained in:
2026-04-01 22:58:46 -07:00
parent bde6abe4ed
commit 878c7b415c
3 changed files with 163 additions and 103 deletions

View File

@@ -898,6 +898,7 @@ function Flow() {
const [helpTabs, setHelpTabs] = useState<{ label: string; type?: string; content: string | null }[]>([]);
const [activeHelpTab, setActiveHelpTab] = useState<string | null>(null);
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string } | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const flowContainerRef = useRef<HTMLDivElement | null>(null);
const panTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -2145,6 +2146,9 @@ function Flow() {
if (defaultWorkflowLoadAttemptedRef.current) return;
defaultWorkflowLoadAttemptedRef.current = true;
// Only auto-load the example workflow on first visit
if (localStorage.getItem('tono_visited')) return;
const graphHasContent = () => {
const currentNodes = (reactFlow.getNodes() as TonoNode[]);
const currentEdges = (reactFlow.getEdges() as TonoEdge[]);
@@ -2167,6 +2171,20 @@ function Flow() {
}
}, [applyMaybePackedWorkflow, reactFlow, scheduleAutoRun]);
const loadExampleWorkflow = useCallback(async () => {
try {
const loaded = await loadDefaultWorkflowAsset();
if (!loaded) {
setStatus({ text: 'No example workflow found.', level: 'error' });
return;
}
await applyMaybePackedWorkflow(loaded.workflow);
setStatus({ text: 'Loaded example workflow.', level: 'info' });
} catch (err: any) {
setStatus({ text: 'Failed to load example workflow: ' + err.message, level: 'error' });
}
}, [applyMaybePackedWorkflow]);
// ── Load node definitions ───────────────────────────────────────────
useEffect(() => {
@@ -2970,6 +2988,19 @@ function Flow() {
// ── Keyboard shortcut ───────────────────────────────────────────────
// Close floating menu on outside click
const floatingMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!menuOpen) return;
const handler = (e: MouseEvent) => {
if (floatingMenuRef.current && !floatingMenuRef.current.contains(e.target as HTMLElement)) {
setMenuOpen(false);
}
};
document.addEventListener('pointerdown', handler);
return () => document.removeEventListener('pointerdown', handler);
}, [menuOpen]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
@@ -3172,58 +3203,62 @@ function Flow() {
return (
<NodeContext.Provider value={contextValue as any}>
<div className="app-container">
{/* Toolbar */}
<div id="toolbar">
<span id="app-title">tono</span>
<div className="toolbar-group">
<button className="btn btn-primary" onClick={runWorkflow} title="Run workflow (Ctrl+Enter)">
Run
</button>
<button className="btn" onClick={clearGraph} title="Clear graph">
Clear
</button>
</div>
<div className="toolbar-group">
<button className="btn" onClick={saveWorkflow} title="Save workflow as PNG">
Save
</button>
<button className="btn" onClick={savePackedWorkflow} title="Save packed workflow (with files)">
Pack
</button>
<button className="btn" onClick={loadWorkflow} title="Load workflow (JSON or PNG)">
Load
</button>
<button className="btn" onClick={copySnapshot} title="Copy workflow screenshot to clipboard">
Snapshot
</button>
{window.pywebview && (
<button className="btn" onClick={uploadPlugin} title="Upload a plugin (.py)">
Plugin
{/* Floating menu */}
<div className="floating-menu" ref={floatingMenuRef}>
<button className="floating-menu-toggle" onClick={() => setMenuOpen((o) => !o)} title="Menu">
<img src="/favicon.svg" alt="tono" className="floating-menu-logo" />
</button>
{menuOpen && (
<div className="floating-menu-dropdown">
<button className="btn btn-primary" onClick={() => { runWorkflow(); setMenuOpen(false); }} title="Run workflow (Ctrl+Enter)">
Run
</button>
)}
</div>
<div className="toolbar-group">
<button className="btn" onClick={openJournalTab} title="Open journal">
Journal
</button>
<button className="btn" onClick={() => openDocByFilename('getting-started.md')} title="Getting started guide">
? Help
</button>
</div>
<div className={`status-bar ${status.level}`}>{status.text}</div>
<button className="btn" onClick={() => { clearGraph(); setMenuOpen(false); }} title="Clear graph">
Clear
</button>
<hr className="floating-menu-divider" />
<button className="btn" onClick={() => { saveWorkflow(); setMenuOpen(false); }} title="Save workflow as PNG">
Save
</button>
<button className="btn" onClick={() => { savePackedWorkflow(); setMenuOpen(false); }} title="Save packed workflow (with files)">
Pack
</button>
<button className="btn" onClick={() => { loadWorkflow(); setMenuOpen(false); }} title="Load workflow (JSON or PNG)">
Load
</button>
<button className="btn" onClick={() => { copySnapshot(); setMenuOpen(false); }} title="Copy workflow screenshot to clipboard">
Snapshot
</button>
{window.pywebview && (
<button className="btn" onClick={() => { uploadPlugin(); setMenuOpen(false); }} title="Upload a plugin (.py)">
Plugin
</button>
)}
<hr className="floating-menu-divider" />
<button className="btn" onClick={() => { loadExampleWorkflow(); setMenuOpen(false); }} title="Load example workflow">
Example
</button>
<button className="btn" onClick={() => { openJournalTab(); setMenuOpen(false); }} title="Open journal">
Journal
</button>
<button className="btn" onClick={() => { openDocByFilename('getting-started.md'); setMenuOpen(false); }} title="Getting started guide">
? Help
</button>
{updateInfo && (
<>
<hr className="floating-menu-divider" />
<a className="btn floating-menu-update" href={updateInfo.url} target="_blank" rel="noopener noreferrer">
Update to {updateInfo.latest}
</a>
</>
)}
</div>
)}
</div>
{updateInfo && (
<div className="update-banner">
tono {updateInfo.latest} is available.
{' '}
<a href={updateInfo.url} target="_blank" rel="noopener noreferrer">Download</a>
<button className="update-banner-dismiss" onClick={() => setUpdateInfo(null)} title="Dismiss"></button>
</div>
{/* Status toast */}
{status.text && (
<div className={`status-toast ${status.level}`}>{status.text}</div>
)}
{/* React Flow canvas */}

View File

@@ -142,33 +142,70 @@ html, body, #root {
flex-direction: column;
}
/* ── Toolbar ───────────────────────────────────────────────────────── */
#toolbar {
height: 44px;
/* ── Floating menu ─────────────────────────────────────────────────── */
.floating-menu {
position: fixed;
top: 12px;
left: 12px;
z-index: 200;
user-select: none;
}
.floating-menu-toggle {
width: 40px;
height: 40px;
border-radius: 10px;
border: 1px solid var(--border-toolbar);
background: var(--bg-toolbar);
border-bottom: 1px solid var(--border-toolbar);
cursor: pointer;
display: flex;
align-items: center;
padding: 0 12px;
gap: 10px;
z-index: 100;
user-select: none;
flex-shrink: 0;
justify-content: center;
padding: 0;
transition: border-color 0.15s, box-shadow 0.15s;
}
.floating-menu-toggle:hover {
border-color: var(--accent);
box-shadow: 0 0 8px rgba(85, 126, 255, 0.3);
}
#app-title {
font-size: 15px;
font-weight: 700;
letter-spacing: 0.5px;
color: var(--text-heading);
margin-right: 8px;
flex-shrink: 0;
.floating-menu-logo {
width: 28px;
height: 28px;
border-radius: 6px;
}
.toolbar-group {
.floating-menu-dropdown {
position: absolute;
top: 48px;
left: 0;
background: var(--bg-toolbar);
border: 1px solid var(--border-toolbar);
border-radius: 8px;
padding: 6px;
display: flex;
gap: 6px;
flex-shrink: 0;
flex-direction: column;
gap: 3px;
min-width: 150px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.floating-menu-dropdown .btn {
width: 100%;
text-align: left;
box-sizing: border-box;
}
.floating-menu-divider {
border: none;
border-top: 1px solid var(--border-toolbar);
margin: 3px 0;
}
.floating-menu-update {
text-decoration: none;
display: block;
text-align: left;
}
/* ── Buttons ───────────────────────────────────────────────────────── */
@@ -200,45 +237,24 @@ html, body, #root {
border-color: var(--danger-hover);
}
/* ── Status bar ────────────────────────────────────────────────────── */
.status-bar {
margin-left: auto;
padding: 4px 10px;
border-radius: 4px;
/* ── Status toast ─────────────────────────────────────────────────── */
.status-toast {
position: fixed;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
padding: 6px 16px;
border-radius: 6px;
font-size: 11px;
z-index: 200;
pointer-events: none;
background: var(--bg-toolbar);
border: 1px solid var(--border-toolbar);
max-width: 60%;
flex-shrink: 1;
}
.status-bar.info { color: var(--accent-light); }
.status-bar.error { color: var(--error-text); background: var(--error-bg); }
.update-banner {
background: var(--accent);
color: #fff;
text-align: center;
padding: 4px 12px;
font-size: 12px;
position: relative;
}
.update-banner a {
color: #fff;
font-weight: 600;
text-decoration: underline;
}
.update-banner-dismiss {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #fff;
cursor: pointer;
font-size: 13px;
padding: 2px 4px;
opacity: 0.7;
}
.update-banner-dismiss:hover { opacity: 1; }
.status-toast.info { color: var(--accent-light); }
.status-toast.error { color: var(--error-text); background: var(--error-bg); }
/* ── React Flow container ──────────────────────────────────────────── */
.flow-container {