feat(sidebar): picks priority — drag-link menu, spotlight on add, modal hardening (#76)

* fix(sidebar): merge preset HEAD route into GET handler

PR #68 shipped koolook_routes.py with @routes.get + @routes.head on the
same path. aiohttp's add_get auto-registers HEAD by default, so the
explicit @routes.head collided with the auto-synthesized HEAD and
crashed ComfyUI startup with "Added route will never be executed,
method HEAD is already registered".

Trying allow_head=False on the GET to opt out triggered a second crash:
ComfyUI's server.py mirrors every PromptServer route into a parallel
/api/... table via api_routes.route(method, path)(handler, **kwargs),
and RouteTableDef.route's inner closure rejects unknown kwargs with
TypeError. So no kwarg-based opt-out is viable.

Drop the dedicated HEAD handler. Aiohttp's auto-HEAD-from-GET delivers
HEAD probes to get_preset; we branch on request.method to skip the
file read for HEAD — preserving the cheap-existence optimization the
client's presetExists relies on for multi-MB JSON over Dropbox / iCloud
/ NFS-mounted libraries. CHANGELOG [Unreleased] updated to match.

This bug never reached users — koolook_routes.py was added in #68
(post-v0.2.0) and was never published to Comfy Registry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sidebar): Tools row + starter preset replaces curated_defaults

Two coordinated UX changes for fresh-install distribution + admin
ergonomics — designed together and shipped as one unit.

1. New "Tools" row sits between the Snapshot row and the search field.
   Moves the Export and "Install missing for picks" buttons up out of
   the Nodes row (admin/advanced operations don't belong in the
   everyday + Add toolbar). Adds a new "Drop placeholders onto canvas"
   button — the security_level=normal escape hatch for installing
   missing packs: instantiates one placeholder per missing pack on a
   fresh canvas tab so ComfyUI/Manager's standard "Install Missing
   Custom Nodes" detection takes over via Manager's UI flow (which
   uses different gates than our programmatic /customnode/install/
   git_url call). Nodes row keeps only the everyday + button.

2. curated_defaults.json retired in favor of starter_preset.json.
   Fresh installs no longer get picks seeded directly into localStorage
   on first load. Instead the bundled web/starter_preset.json (full
   snapshot format — picks + workflows + tags + archive) is copied
   into the user's snapshot library directory as starter.json. The
   user opens Snapshot -> Load -> starter to apply in one click.
   Unifies fresh-install distribution with the per-user snapshot
   library (#68); maintainer flow becomes "build state in ComfyUI ->
   click the (down-arrow) Tools-row button -> paste over
   web/starter_preset.json -> commit." Existing users with non-empty
   picks are explicitly skipped by the new seeder, so their state is
   untouched.

Removed: seedDefaultsIfNeeded, exportPicks, web/curated_defaults.json,
SEEDED_KEY, DEFAULTS_URL.

Added: seedStarterPresetIfNeeded, exportStarterPreset (snapshot.js),
STARTER_SEEDED_KEY, STARTER_URL, STARTER_PRESET_FILENAME,
discoverMissingPacks (installer.js), dropPlaceholdersForPacks
(canvas_io.js), web/starter_preset.json.

Docs: CLAUDE.md "Curated Nodes sidebar — distributed defaults" ->
"Kforge Labs sidebar — starter preset distribution".
docs/maintainers/curated-sidebar.md rewritten.
CHANGELOG.md [Unreleased] updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sidebar): picks priority — drag-link menu, spotlight on add, modal hardening

Three coordinated UX features for curated picks plus two underlying bug
fixes uncovered while wiring them.

1. Drag-link-release menu hoists curated picks. When a connection is
   dragged from a socket and released on empty canvas, ComfyUI's compat-
   filtered suggestion list now sorts items in the user's curated picks
   above everything else, with a "── from your picks ──" separator before
   the rest. Detection via the "Add Node" + "Add Reroute" sentinel pair
   in the menu's options (narrow enough that other ContextMenu uses are
   untouched). Purely additive — no compatible nodes are removed, only
   re-ordered. New `web/sidebar/connection_menu.js` exports a
   `patchConnectionMenu()` registered alongside `patchCanvasMenu()`.

2. Spotlight effect on add. Clicking the toolbar `+` (or the canvas
   right-click "Add to Kforge Labs") collapses every Nodes-section pack
   folder, then auto-expands the pack + subcategory the just-saved node
   lives in — pedagogical aid that helps users learn which pack each
   node belongs to. Multi-select adds light up every hit pack
   simultaneously. Duplicate adds get the spotlight too (the educational
   reminder still applies). New helper `findPackPathForType` mirrors the
   gather code's REPO-precedence-then-user-pick-fallback. New exported
   `spotlightAddedPicks(typeNames)` does the collapse + pin sequence.

3. Modal drag-out-of-input no longer dismisses the dialog. Shared modal
   shell `makeModalShell` gained a mousedown / click-intent check: a
   click on the overlay only fires dismiss when the gesture STARTED on
   the overlay too. Drag-selecting text inside an input that releases in
   the overlay's dark area used to dismiss the modal mid-edit. Affects
   every Koolook dialog. Snapshot Settings path field also gained a
   `title` tooltip so the full path is hover-readable.

Bug fixes uncovered + carried in this PR:

- `pinExpanded` paths now expand on the immediate render. buildFolder's
  resolution chain gained a `pinnedPaths` check between `forceExpanded`
  and `pathStates`. Phase 3 of `renderTree` writes pins into pathStates
  AFTER Phase 2 builds the DOM, so without this check the pin had no
  effect on the current render. Improves the existing workflow-save
  pinning (visibly correct now) and is load-bearing for the new
  spotlight feature.

CHANGELOG [Unreleased] updated with all five entries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
malkuthro
2026-05-05 20:33:31 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent a6a31c8086
commit 96b07a2c12
6 changed files with 335 additions and 5 deletions
+50
View File
@@ -7,6 +7,56 @@ The format is inspired by Keep a Changelog and SemVer.
## [Unreleased]
### Added
- **Drag-link-release menu hoists curated picks to the top.** When the user
drags a connection out of a node socket and releases on empty canvas,
ComfyUI's compatibility-filtered suggestion list now sorts items in the
user's curated picks above everything else, with a `── from your picks ──`
separator between hoisted and unhoisted compatible nodes. Implementation
monkey-patches `LiteGraph.ContextMenu` and detects the connection-release
menu via the `Add Node` + `Add Reroute` operation-sentinel pair (narrow
enough that other ContextMenu uses — right-click on a node, on the
canvas — are untouched). Purely additive: no compatible nodes are
removed, only re-ordered. Wired in `web/sidebar/connection_menu.js`,
registered alongside the existing `patchCanvasMenu` in
`web/koolook_sidebar.js`.
- **Spotlight effect on add.** Clicking the toolbar `+` (or the canvas
right-click "Add to Kforge Labs") now collapses every Nodes-section
pack folder, then auto-expands just the pack + subcategory the just-saved
node lives in — a pedagogical aid that helps new users internalize
which pack each node belongs to. Multi-select adds light up every hit
pack simultaneously. Duplicate adds (already in picks) trigger the
same spotlight, since the educational reminder still applies. Helper
`findPackPathForType` mirrors the gather code's REPO-precedence-then-
user-pick-fallback to locate any node ID's sidebar path; new exported
`spotlightAddedPicks(typeNames)` from `web/sidebar/tree.js` does the
collapse + pin sequence and is called from both add paths.
### Fixed
- **`pinExpanded` paths now expand on the immediate render** instead of
being delayed by one. `buildFolder`'s expansion-resolution chain gained
a `pinnedPaths` check between `forceExpanded` and `pathStates`. Phase 3
of `renderTree` writes pins into `pathStates` AFTER Phase 2 has built
the DOM, so without the new check the pin had no effect on the current
render — only on the next one. Side-effect-free correctness improvement
for the workflow-save pinning that already existed; load-bearing for
the new spotlight feature above which depends on pinned paths
expanding immediately.
- **Modal drag-out-of-input no longer dismisses the dialog.** The shared
modal shell (`makeModalShell`) used by every Koolook dialog gained a
mousedown / click-intent check: a click on the overlay now only fires
the dismiss when the gesture *started* on the overlay too. Drag-
selecting text inside an input field that releases in the overlay's
dark area used to trigger `click` with `target=overlay` and dismiss
the modal mid-edit; now it stays open. Affects all dialogs including
Snapshot Save / Load / Settings, Install Missing, Save Workflow, Tags,
Confirm, Input — load-bearing especially for the Snapshot Settings
path field where the long absolute path encourages drag-selection.
- **Snapshot Settings path field shows full path on hover.** Added a
`title` attribute synced to the saved library path on every refresh
and save, so the user can read the full path without scrolling /
selecting inside the narrow input.
### Added (continued)
- **"Install missing for picks" toolbar button** in the Nodes section
(`pi-cloud-download` icon, next to Add and Export). Walks the user's
picks against ComfyUI-Manager's `/customnode/getmappings` mapping,
+2
View File
@@ -20,6 +20,7 @@ import {
} from "./sidebar/workflows_store.js";
import { renderPanel } from "./sidebar/tree.js";
import { patchCanvasMenu } from "./sidebar/canvas_menu.js";
import { patchConnectionMenu } from "./sidebar/connection_menu.js";
app.registerExtension({
name: "koolook.curated_sidebar",
@@ -48,5 +49,6 @@ app.registerExtension({
render: (el) => renderPanel(el),
});
patchCanvasMenu();
patchConnectionMenu();
},
});
+6
View File
@@ -8,6 +8,7 @@
// =============================================================================
import { toast } from "./constants.js";
import { addToMyPicks, notifyPicksChanged } from "./picks_store.js";
import { spotlightAddedPicks } from "./tree.js";
export function patchCanvasMenu() {
const C = (typeof LGraphCanvas !== "undefined") ? LGraphCanvas : null;
@@ -32,9 +33,14 @@ export function patchCanvasMenu() {
const status = addToMyPicks(node.type);
if (status === "added") {
toast(`Added "${node.title || node.type}" to favorites.`);
spotlightAddedPicks([node.type]);
notifyPicksChanged();
} else if (status === "duplicate") {
toast("Already in favorites.");
// Spotlight on duplicate too — the user reached for this
// exact node; remind them where it lives.
spotlightAddedPicks([node.type]);
notifyPicksChanged();
} else {
toast(`Failed to save "${node.title || node.type}". See console.`);
}
+158
View File
@@ -0,0 +1,158 @@
// =============================================================================
// Drag-link-release menu — curated picks priority.
//
// When the user drags a connection out of a node socket and releases on empty
// canvas, LiteGraph shows a context menu of operations + compatible nodes
// (filtered by socket type). Default order is whatever LiteGraph emits,
// which is essentially "all installed nodes that match the type, in
// registration order." On a busy ComfyUI install this menu can be 20+ items
// of nodes the user has never used.
//
// We hoist the user's curated picks (those that ALSO match the dragged
// socket's type) to the top of the suggestion list, immediately below
// LiteGraph's operation entries (Add Node / Add Reroute / Search / Reroute).
// Other compatible nodes still render below — purely additive ordering, no
// information loss.
//
// Strategy: monkey-patch `LiteGraph.ContextMenu` globally and detect the
// connection-release menu by sentinel items in its options array. Detection
// requires BOTH "Add Node" AND "Add Reroute" to be present — narrow enough
// that other ContextMenu uses (right-click on a node, on the canvas, etc.)
// are untouched, while broad enough to survive minor LiteGraph variations.
//
// LiteGraph fork variants used by ComfyUI may shape the suggestion items
// either with `content: <typeId>` or `content: <displayName>` and a parallel
// `value: <typeId>`. We check both, so the picks lookup matches whichever
// shape is in flight.
// =============================================================================
import { loadUserPicks } from "./picks_store.js";
const SEPARATOR_LABEL = "── from your picks ──";
let installed = false;
export function patchConnectionMenu() {
const LG = (typeof LiteGraph !== "undefined") ? LiteGraph : null;
if (!LG || typeof LG.ContextMenu !== "function") {
console.warn("[Koolook] LiteGraph.ContextMenu not available; connection-menu priority skipped.");
return;
}
// Idempotency: this is a global constructor swap. A re-load (HMR /
// multiple registerExtension calls) would otherwise stack wrappers,
// running the priority logic N times per ContextMenu construction —
// performance + duplicate "── from your picks ──" separators.
if (LG.__koolookContextMenuPatched) return;
LG.__koolookContextMenuPatched = true;
const origContextMenu = LG.ContextMenu;
function PatchedContextMenu(options, parentMenuOptions) {
let finalOptions = options;
try {
if (looksLikeConnectionMenu(options)) {
finalOptions = reorderByPicks(options);
}
} catch (e) {
// Never let a reorder bug break the menu — fall back to the
// original options array so the user always gets SOME menu.
console.warn("[Koolook] connection-menu reorder failed, falling back:", e);
finalOptions = options;
}
return new origContextMenu(finalOptions, parentMenuOptions);
}
// Preserve the prototype chain so `instanceof LiteGraph.ContextMenu`
// checks elsewhere in LiteGraph / ComfyUI still pass, and any static
// properties on the original constructor remain reachable through ours.
PatchedContextMenu.prototype = origContextMenu.prototype;
Object.setPrototypeOf(PatchedContextMenu, origContextMenu);
LG.ContextMenu = PatchedContextMenu;
}
// =============================================================================
// Detection + reorder
// =============================================================================
// Detects the drag-link-release menu by looking for the operation sentinels
// LiteGraph emits at the top: BOTH "Add Node" AND "Add Reroute" must be
// present. The right-click canvas menu has "Add Node" but not "Add Reroute",
// so this filter doesn't accidentally re-shape unrelated menus.
function looksLikeConnectionMenu(options) {
if (!Array.isArray(options) || options.length === 0) return false;
let hasAddNode = false, hasAddReroute = false;
for (const opt of options) {
if (!opt || typeof opt.content !== "string") continue;
if (opt.content === "Add Node") hasAddNode = true;
else if (opt.content === "Add Reroute") hasAddReroute = true;
if (hasAddNode && hasAddReroute) return true;
}
return false;
}
// LiteGraph sets `content` to either the node type ID directly or to the
// node's display title (which can include spaces, emoji, decorations).
// Our picks list stores type IDs, so we check `value` first (when present
// it's typically the type ID) and fall back to `content`.
function pickIdOf(opt) {
if (!opt) return null;
if (typeof opt.value === "string") return opt.value;
if (typeof opt.content === "string") return opt.content;
return null;
}
// Operation entries that always sit at the top of LiteGraph's connection
// menu — preserve their position regardless of pick membership.
const OPERATIONS = new Set(["Add Node", "Add Reroute", "Search", "Reroute"]);
function reorderByPicks(options) {
const picks = new Set(loadUserPicks());
if (picks.size === 0) return options;
// Walk the options array linearly. The operation block is typically
// contiguous at the top (Add Node, Add Reroute, Search, plus the two
// Reroute insertion entries); everything after is a node suggestion.
// Stop the operation slice at the first non-operation, non-null item —
// null entries are LiteGraph separators which we want to leave wherever
// LiteGraph put them.
let operationsEnd = 0;
while (operationsEnd < options.length) {
const opt = options[operationsEnd];
if (opt === null) {
operationsEnd += 1;
continue;
}
if (opt && typeof opt.content === "string" && OPERATIONS.has(opt.content)) {
operationsEnd += 1;
continue;
}
break;
}
const operations = options.slice(0, operationsEnd);
const suggestions = options.slice(operationsEnd);
// Stable partition: picks first, others second. Skipping non-pick-able
// entries (separators, weird shapes) puts them in `others` to preserve
// their relative order with the rest of the suggestion block.
const picksHits = [];
const others = [];
for (const opt of suggestions) {
const id = pickIdOf(opt);
if (id != null && picks.has(id)) picksHits.push(opt);
else others.push(opt);
}
// No pick hits → no reordering needed; return the original array reference
// so we don't pay the array-allocation cost in the common no-match path.
if (picksHits.length === 0) return options;
const reordered = [...operations, ...picksHits];
if (others.length > 0) {
// Inert separator label so the user sees where their picks end and
// ComfyUI's default suggestions begin. `disabled: true` prevents
// hover/click activation; LiteGraph still renders it as a row.
reordered.push({ content: SEPARATOR_LABEL, disabled: true });
reordered.push(...others);
}
return reordered;
}
+19 -1
View File
@@ -57,8 +57,20 @@ function makeModalShell({ title, body, actions }) {
};
overlay.appendChild(modal);
// Click-to-dismiss with drag-out-of-input protection. A single click
// fires `mousedown` then `mouseup` then `click` — and `click.target`
// is the deepest common ancestor of mousedown's and mouseup's targets.
// So if you drag-select text inside an input and release in the
// overlay's dark area, click fires with target=overlay and the modal
// would otherwise close mid-edit. Track whether the gesture STARTED
// on the overlay too; only dismiss if both ends did.
let mouseDownOnOverlay = false;
overlay.addEventListener("mousedown", (e) => {
mouseDownOnOverlay = (e.target === overlay);
});
overlay.addEventListener("click", (e) => {
if (e.target === overlay) close();
if (e.target === overlay && mouseDownOnOverlay) close();
mouseDownOnOverlay = false;
});
escHandler = (e) => { if (e.key === "Escape") close(); };
document.addEventListener("keydown", escHandler);
@@ -1233,6 +1245,11 @@ export function showSnapshotSettingsDialog({
return;
}
input.value = settings.savedLibraryPath || "";
// Native tooltip surfaces the full path on hover — the input is only
// ~440px wide so long paths get truncated by horizontal scroll.
// The user can still drag-select to view, but the tooltip avoids
// making them do so.
input.title = settings.savedLibraryPath || "";
input.disabled = false;
let sourceLabel;
if (settings.source === "settings") sourceLabel = "from this Settings panel";
@@ -1252,6 +1269,7 @@ export function showSnapshotSettingsDialog({
hint.textContent =
`Currently in effect: ${result.resolvedPath}\n(source: ${sourceLabel})`;
input.value = result.savedLibraryPath || "";
input.title = result.savedLibraryPath || "";
toast(rawValue
? "Library path saved."
: "Override cleared — using env var / default."
+100 -4
View File
@@ -185,6 +185,51 @@ function pinExpanded(paths) {
for (const p of paths) pinnedPaths.add(p);
}
/**
* Spotlight effect for newly-saved picks: collapse every folder under the
* Nodes section, then pin the just-added node's pack-folder + subcategory-
* folder open. Designed as a pedagogical aid — when the user adds a node
* via the toolbar `+` or the right-click "Add to Kforge Labs", the sidebar
* snaps to focus on exactly where that node lives in the pack hierarchy,
* helping newcomers internalize which pack/category each node belongs to.
*
* Caller is responsible for triggering a re-render afterwards (typically
* via `notifyPicksChanged()`); this function only mutates `pathStates` +
* `pinnedPaths`.
*
* Multi-add (caller passes several types): every pack/subcategory hit by
* the additions is pinned open. So adding 5 nodes from 3 packs lights up
* all 3 pack folders simultaneously rather than just the last.
*
* Idempotent / safe: types not in the LiteGraph registry are silently
* skipped (no-op). Empty input → no-op (no collapse, no pin).
*
* @param {string[]} typeNames
*/
export function spotlightAddedPicks(typeNames) {
if (!Array.isArray(typeNames) || typeNames.length === 0) return;
const pathsToExpand = new Set();
for (const t of typeNames) {
const loc = findPackPathForType(t);
if (!loc) continue;
pathsToExpand.add(`${SECTION_ID_NODES}/${loc.packLabel}`);
pathsToExpand.add(`${SECTION_ID_NODES}/${loc.packLabel}/${loc.sub}`);
}
if (pathsToExpand.size === 0) return;
// Collapse every existing Nodes-section entry (the section root + every
// pack/subcategory). The render-time fallback (`startExpanded`) keeps
// the section header itself open since `addSection` registers it with
// `startExpanded: true`; pack folders default to closed, which is what
// we want for everything except the spotlighted location.
for (const key of [...pathStates.keys()]) {
if (key === SECTION_ID_NODES || key.startsWith(SECTION_ID_NODES + "/")) {
pathStates.delete(key);
}
}
pinExpanded([...pathsToExpand]);
}
/**
* Single render entry point. Idempotent — safe to call from search input,
* picks-changed, workflows-changed, or storage events.
@@ -443,6 +488,40 @@ function subcategoryFor(category, categoryRoot) {
return parts.length > 1 ? parts.slice(1).join("/") : category;
}
// Given a node ID, compute the (packLabel, sub) pair the gather code would
// place it under in the sidebar. Mirrors the precedence in
// `gatherNodesByRepo` + `gatherUserPickPacks`: registered REPOS first
// (`select="all"` matches by category prefix; `select=Array` matches by
// explicit ID), then the user-picks fallback derives the pack label from
// the first segment of the node's category.
//
// Returns `{packLabel, sub}` on success, `null` if the node isn't in the
// LiteGraph registry (caller should skip silently — the node won't render
// in the sidebar either way). Used by `spotlightAddedPicks` to know which
// folder to expand after a save.
function findPackPathForType(typeName) {
const registry = (typeof LiteGraph !== "undefined" && LiteGraph.registered_node_types) || {};
const nc = registry[typeName];
if (!nc) return null;
const cat = (nc && nc.category) || "";
for (const repo of REPOS) {
if (repo.select === "all") {
const root = repo.categoryRoot || "";
if (root && (cat === root || cat.startsWith(root + "/"))) {
return { packLabel: repo.label, sub: subcategoryFor(cat, root) };
}
} else if (Array.isArray(repo.select)) {
if (repo.select.includes(typeName)) {
return { packLabel: repo.label, sub: subcategoryFor(cat, repo.categoryRoot || "") };
}
}
}
const packLabel = cat.split("/")[0] || "(uncategorized)";
return { packLabel, sub: subcategoryFor(cat, packLabel) };
}
function matchesQuery(display, type, q) {
if (!q) return true;
return display.toLowerCase().includes(q) || type.toLowerCase().includes(q);
@@ -804,11 +883,19 @@ function buildFolder({ name, count, iconKind, childrenBuilder, onContextMenu, st
// Resolution order:
// 1. forceExpanded (e.g. search active) — overrides everything
// 2. pathStates (user has previously toggled this folder)
// 3. startExpanded (the natural default for this folder)
// 2. pinnedPaths (a save/spotlight flow has flagged this for force-open
// on the current render) — Phase 3 writes pins into pathStates AFTER
// Phase 2 builds DOM, so without a direct pinnedPaths read here the
// pin would only take effect on the *next* render. The spotlight
// effect for newly-added picks depends on pinned paths expanding
// on the immediate render.
// 3. pathStates (user has previously toggled this folder)
// 4. startExpanded (the natural default for this folder)
let initiallyExpanded;
if (forceExpanded) {
initiallyExpanded = true;
} else if (pinnedPaths.has(path)) {
initiallyExpanded = true;
} else if (pathStates.has(path)) {
initiallyExpanded = pathStates.get(path);
} else {
@@ -1517,21 +1604,30 @@ export function renderPanel(container) {
let added = 0;
let duplicates = 0;
let failed = 0;
const successfulTypes = [];
for (const t of types) {
const status = addToMyPicks(t);
if (status === "added") added += 1;
else if (status === "duplicate") duplicates += 1;
else failed += 1;
// Spotlight even on duplicates — the user clicked + with this node
// selected; reminding them where it lives is the whole feature.
if (status !== "failed") successfulTypes.push(t);
}
spotlightAddedPicks(successfulTypes);
if (added > 0) {
const noun = added === 1 ? "node" : "nodes";
toast(`Added ${added} ${noun} to favorites.`);
notifyPicksChanged();
} else if (duplicates > 0 && failed === 0) {
toast("Already in favorites.");
// Re-render anyway so the spotlight effect lands even on pure-
// duplicate clicks. notifyPicksChanged is the agreed-upon trigger
// for any sidebar refresh, even when picks didn't actually change.
notifyPicksChanged();
}
if (failed > 0) {
toast(`Failed to save ${failed} pick${failed === 1 ? "" : "s"}. See console.`);
} else if (added === 0 && duplicates > 0) {
toast("Already in favorites.");
}
});
nodesRow.appendChild(addBtn);