diff --git a/web/css/linker-shell.css b/web/css/linker-shell.css
index 625a19a..e311f2e 100644
--- a/web/css/linker-shell.css
+++ b/web/css/linker-shell.css
@@ -145,6 +145,14 @@
padding: 0;
}
+#model-linker-modal .ml-window-btn--dock {
+ width: 46px;
+ min-width: 46px;
+ height: 100%;
+ min-height: 40px;
+ padding: 0;
+}
+
#model-linker-modal .ml-window-btn--close {
width: 46px;
min-width: 46px;
@@ -448,6 +456,63 @@
transform: scale(1.05);
}
+.comfyui-model-linker-tab-button .mdi,
+.comfyui-model-linker-tab-button .mdi-link-variant {
+ font-size: 18px;
+ line-height: 1;
+}
+
+.ml-sidebar-dock-panel {
+ box-sizing: border-box;
+ width: 100%;
+ height: 100%;
+ min-width: 0;
+ min-height: 0;
+ overflow: hidden;
+ color: var(--ml-text);
+ background: var(--ml-bg);
+}
+
+#model-linker-modal.ml-is-docked {
+ position: relative;
+ inset: auto;
+ top: auto;
+ left: auto;
+ transform: none;
+ width: 100%;
+ height: 100%;
+ max-width: none;
+ max-height: none;
+ min-width: 0;
+ min-height: 0;
+ border: 0;
+ border-radius: 0;
+ box-shadow: none;
+ resize: none;
+ z-index: auto;
+}
+
+#model-linker-modal .ml-window-btn--undock {
+ display: none;
+ width: 46px;
+ min-width: 46px;
+ height: 100%;
+ min-height: 40px;
+ padding: 0;
+}
+
+#model-linker-modal.ml-is-docked .ml-window-btn--undock {
+ display: flex;
+}
+
+#model-linker-modal.ml-is-docked .ml-window-btn--dock {
+ display: none;
+}
+
+#model-linker-modal.ml-is-docked .ml-window-btn--fullscreen {
+ display: none;
+}
+
@keyframes ml-slide-down {
from {
opacity: 0;
diff --git a/web/linker.js b/web/linker.js
index fa8c069..6af28dd 100644
--- a/web/linker.js
+++ b/web/linker.js
@@ -33,6 +33,14 @@ class LinkerManagerDialog extends ComfyDialog {
this.activeTabStorageKey = 'model_linker_active_tab';
this.activeTab = this.restoreActiveTab(); // Default tab
this.fullscreen = false;
+ this.docked = false;
+ this.dockContainer = null;
+ this.lastDockContainer = null;
+ this.pendingDockToSidebar = false;
+ this.sidebarTabId = "comfyui-model-linker";
+ this.dockButton = null;
+ this.undockButton = null;
+ this._floatingRectBeforeDock = null;
this._dragging = false;
this._dragStart = null;
this._analysisProgressToken = null;
@@ -2044,6 +2052,24 @@ class LinkerManagerDialog extends ComfyDialog {
onclick: () => this.toggleFullScreen()
});
this.setTooltip(fullscreenButton, "Toggle full screen");
+
+ this.dockButton = $el("button", {
+ id: "model-linker-dock-toggle",
+ className: "ml-window-btn ml-window-btn--dock",
+ innerHTML: getSvgIcon('internalLink', 'currentColor', 'ml-window-btn-icon'),
+ ariaLabel: "Dock Model Linker to sidebar",
+ onclick: () => this.dockToSidebar()
+ });
+ this.setTooltip(this.dockButton, "Dock to sidebar");
+
+ this.undockButton = $el("button", {
+ id: "model-linker-undock-toggle",
+ className: "ml-window-btn ml-window-btn--undock",
+ innerHTML: getSvgIcon('externalLink', 'currentColor', 'ml-window-btn-icon'),
+ ariaLabel: "Undock Model Linker",
+ onclick: () => this.undockToFloating()
+ });
+ this.setTooltip(this.undockButton, "Undock to floating window");
return $el("div.ml-dialog-shell", {}, [
$el("div.ml-dialog-topbar", {}, [
@@ -2056,6 +2082,8 @@ class LinkerManagerDialog extends ComfyDialog {
])
]),
$el("div.ml-dialog-controls", {}, [
+ this.dockButton,
+ this.undockButton,
fullscreenButton,
$el("button", {
className: "ml-window-btn ml-window-btn--close",
@@ -2067,6 +2095,294 @@ class LinkerManagerDialog extends ComfyDialog {
])
]);
}
+
+ captureFloatingRect() {
+ if (!this.element || this.docked || getComputedStyle(this.element).display === 'none') return;
+
+ const rect = this.element.getBoundingClientRect();
+ if (!rect.width || !rect.height) return;
+
+ this._floatingRectBeforeDock = {
+ top: Math.round(rect.top),
+ left: Math.round(rect.left),
+ width: Math.round(rect.width),
+ height: Math.round(rect.height)
+ };
+ }
+
+ clearModalPlacementStyles() {
+ if (!this.element) return;
+
+ [
+ 'position',
+ 'top',
+ 'left',
+ 'right',
+ 'bottom',
+ 'inset',
+ 'transform',
+ 'width',
+ 'height',
+ 'maxWidth',
+ 'maxHeight',
+ 'minWidth',
+ 'minHeight',
+ 'borderRadius',
+ 'resize'
+ ].forEach((property) => {
+ this.element.style[property] = '';
+ });
+ }
+
+ restoreFloatingGeometry() {
+ const el = this.element;
+ if (!el) return;
+
+ el.style.width = '1100px';
+ el.style.height = '700px';
+ el.style.maxWidth = '100vw';
+ el.style.maxHeight = '100vh';
+ el.style.minWidth = '640px';
+ el.style.minHeight = '420px';
+ el.style.resize = 'both';
+ el.style.borderRadius = '7px';
+
+ const rect = this._floatingRectBeforeDock;
+ if (rect?.width && rect?.height) {
+ el.style.width = `${rect.width}px`;
+ el.style.height = `${rect.height}px`;
+ el.style.top = `${rect.top}px`;
+ el.style.left = `${rect.left}px`;
+ el.style.transform = 'none';
+ return;
+ }
+
+ try {
+ const wh = JSON.parse(localStorage.getItem('model_linker_modal_size_before_fs') || 'null');
+ if (wh?.w && wh?.h) {
+ el.style.width = `${wh.w}px`;
+ el.style.height = `${wh.h}px`;
+ }
+
+ const pos = JSON.parse(localStorage.getItem('model_linker_modal_pos') || 'null');
+ if (pos && Number.isFinite(pos.top) && Number.isFinite(pos.left)) {
+ el.style.top = `${pos.top}px`;
+ el.style.left = `${pos.left}px`;
+ el.style.transform = 'none';
+ return;
+ }
+ } catch (e) {
+ // Fall back to centered floating geometry below.
+ }
+
+ el.style.top = '50%';
+ el.style.left = '50%';
+ el.style.transform = 'translate(-50%, -50%)';
+ }
+
+ dockTo(container) {
+ if (!container || !this.element) return;
+
+ if (!this.docked) {
+ this.captureFloatingRect();
+ }
+
+ if (this.fullscreen) {
+ this.setFullScreen(false);
+ }
+
+ this.docked = true;
+ this.dockContainer = container;
+ this.lastDockContainer = container;
+ this.pendingDockToSidebar = false;
+ this.backdrop.style.display = 'none';
+ container.classList.add('ml-sidebar-dock-panel');
+ this.element.classList.add('ml-is-docked');
+ this.clearModalPlacementStyles();
+ this.element.style.display = 'flex';
+
+ if (this.element.parentNode !== container) {
+ container.appendChild(this.element);
+ }
+ }
+
+ dockToSidebar() {
+ if (this.docked) return;
+
+ this.pendingDockToSidebar = true;
+
+ if (this.isUsableDockContainer(this.lastDockContainer)) {
+ this.dockTo(this.lastDockContainer);
+ return;
+ }
+
+ this.tryOpenComfySidebarState();
+
+ requestAnimationFrame(() => {
+ if (!this.pendingDockToSidebar || this.docked) return;
+
+ const button = document.querySelector(`.${this.sidebarTabId}-tab-button`);
+ if (button instanceof HTMLElement) {
+ const isActive = button.matches([
+ '[aria-pressed="true"]',
+ '[aria-selected="true"]',
+ '[data-active="true"]',
+ '[data-selected="true"]',
+ '.active',
+ '.is-active',
+ '.selected',
+ '.p-highlight'
+ ].join(','));
+
+ if (!isActive) {
+ button.click();
+ }
+ }
+ });
+ }
+
+ isUsableDockContainer(container) {
+ if (!(container instanceof HTMLElement) || !container.isConnected) return false;
+
+ const style = getComputedStyle(container);
+ if (style.display === 'none' || style.visibility === 'hidden') return false;
+
+ const rect = container.getBoundingClientRect();
+ return rect.width > 0 && rect.height > 0;
+ }
+
+ tryOpenComfySidebarState() {
+ const extensionManager = app.extensionManager;
+ let opened = false;
+ const candidates = [
+ extensionManager?.sidebarTab,
+ extensionManager?.sidebarTabs,
+ extensionManager?.sidebar,
+ extensionManager
+ ].filter(Boolean);
+
+ for (const candidate of candidates) {
+ try {
+ if (typeof candidate.openSidebar === 'function') {
+ candidate.openSidebar(this.sidebarTabId);
+ opened = true;
+ }
+ if (typeof candidate.openSidebarTab === 'function') {
+ candidate.openSidebarTab(this.sidebarTabId);
+ opened = true;
+ }
+ if (typeof candidate.setActiveSidebarTab === 'function') {
+ candidate.setActiveSidebarTab(this.sidebarTabId);
+ opened = true;
+ }
+ if (typeof candidate.setActiveSidebarTabId === 'function') {
+ candidate.setActiveSidebarTabId(this.sidebarTabId);
+ opened = true;
+ }
+ if ('activeSidebarTabId' in candidate) {
+ candidate.activeSidebarTabId = this.sidebarTabId;
+ opened = true;
+ }
+ } catch (error) {
+ console.debug('Model Linker: Sidebar state open attempt failed.', error);
+ }
+ }
+
+ return opened;
+ }
+
+ undockToFloating({ persist = true, closeSidebar = true } = {}) {
+ if (!this.element) return;
+
+ const wasDocked = this.docked;
+ const dockContainer = this.dockContainer;
+ this.docked = false;
+ this.dockContainer = null;
+ this.element.classList.remove('ml-is-docked');
+ this.clearModalPlacementStyles();
+
+ if (this.element.parentNode !== document.body) {
+ document.body.appendChild(this.element);
+ }
+
+ this.element.style.display = 'flex';
+ this.restoreFloatingGeometry();
+ this.ensureModalHandleInViewport({ persist });
+
+ if (wasDocked && closeSidebar) {
+ this.closeComfySidebar(dockContainer);
+ }
+ }
+
+ closeComfySidebar(dockContainer = null) {
+ const closedByState = this.tryCloseComfySidebarState();
+
+ requestAnimationFrame(() => {
+ if (dockContainer && !dockContainer.isConnected) return;
+
+ const button = document.querySelector(`.${this.sidebarTabId}-tab-button`);
+ if (!(button instanceof HTMLElement)) return;
+
+ const isActive = button.matches([
+ '[aria-pressed="true"]',
+ '[aria-selected="true"]',
+ '[data-active="true"]',
+ '[data-selected="true"]',
+ '.active',
+ '.is-active',
+ '.selected',
+ '.p-highlight'
+ ].join(','));
+
+ if (!closedByState || isActive) {
+ button.click();
+ }
+ });
+ }
+
+ tryCloseComfySidebarState() {
+ const extensionManager = app.extensionManager;
+ let closed = false;
+ const candidates = [
+ extensionManager?.sidebarTab,
+ extensionManager?.sidebarTabs,
+ extensionManager?.sidebar,
+ extensionManager
+ ].filter(Boolean);
+
+ for (const candidate of candidates) {
+ try {
+ if (typeof candidate.closeSidebar === 'function') {
+ candidate.closeSidebar();
+ closed = true;
+ }
+ if (typeof candidate.closeSidebarTab === 'function') {
+ candidate.closeSidebarTab(this.sidebarTabId);
+ closed = true;
+ }
+ if (typeof candidate.setActiveSidebarTab === 'function') {
+ candidate.setActiveSidebarTab(null);
+ closed = true;
+ }
+ if (typeof candidate.setActiveSidebarTabId === 'function') {
+ candidate.setActiveSidebarTabId(null);
+ closed = true;
+ }
+ if ('activeSidebarTabId' in candidate) {
+ candidate.activeSidebarTabId = null;
+ closed = true;
+ }
+ if ('activeSidebarTab' in candidate && typeof candidate.activeSidebarTab !== 'function') {
+ candidate.activeSidebarTab = null;
+ closed = true;
+ }
+ } catch (error) {
+ console.debug('Model Linker: Sidebar state close attempt failed.', error);
+ }
+ }
+
+ return closed;
+ }
// Toggle full screen mode for the dialog
toggleFullScreen() {
@@ -2074,6 +2390,10 @@ class LinkerManagerDialog extends ComfyDialog {
}
setFullScreen(enable) {
+ if (enable && this.docked) {
+ this.undockToFloating({ persist: false });
+ }
+
this.fullscreen = !!enable;
const el = this.element;
if (!el) return;
@@ -2176,6 +2496,8 @@ class LinkerManagerDialog extends ComfyDialog {
}
saveModalPosition() {
+ if (this.docked) return;
+
try {
const el = this.element;
if (!el) return;
@@ -2185,6 +2507,7 @@ class LinkerManagerDialog extends ComfyDialog {
}
ensureModalHandleInViewport({ persist = false } = {}) {
+ if (this.docked) return;
if (this.fullscreen) return;
const el = this.element;
if (!el || getComputedStyle(el).display === 'none') return;
@@ -2216,6 +2539,8 @@ class LinkerManagerDialog extends ComfyDialog {
// Begin window drag
startDrag(e) {
+ if (this.docked) return;
+
try {
const el = this.element;
if (!el) return;
@@ -3840,9 +4165,21 @@ class LinkerManagerDialog extends ComfyDialog {
}
async show(workflow = null) {
+ this.undockToFloating({ persist: false });
+ await this.showContent(workflow, { restoreFullscreen: true });
+ }
+
+ async showDocked(container, workflow = null) {
+ this.dockTo(container);
+ await this.showContent(workflow, { restoreFullscreen: false });
+ }
+
+ async showContent(workflow = null, { restoreFullscreen = true } = {}) {
this.backdrop.style.display = "none";
this.element.style.display = "flex";
- this.scheduleModalViewportClamp();
+ if (!this.docked) {
+ this.scheduleModalViewportClamp();
+ }
// Update button state in case there are active downloads
this.updateDownloadAllButtonState();
@@ -3855,7 +4192,7 @@ class LinkerManagerDialog extends ComfyDialog {
// Restore fullscreen state if enabled
try {
const fs = localStorage.getItem('model_linker_modal_fullscreen');
- if (fs === '1') this.setFullScreen(true);
+ if (!this.docked && restoreFullscreen && fs === '1') this.setFullScreen(true);
} catch (e) { }
// Attach drag handle event listener (only once)
@@ -3881,7 +4218,7 @@ class LinkerManagerDialog extends ComfyDialog {
if (!handle || !topbar) return;
const onMouseDown = (e) => {
- if (this.fullscreen) return; // no drag in fullscreen
+ if (this.fullscreen || this.docked) return; // no drag in fullscreen or docked mode
if (e.button !== 0 || this.isTopbarDragExcluded(e.target)) return;
topbar.classList.add('ml-is-dragging');
this.startDrag(e);
@@ -6225,6 +6562,9 @@ class ModelLinker {
this.linkerButton = null;
this.buttonGroup = null;
this.buttonId = "model-linker-button";
+ this.sidebarTabId = "comfyui-model-linker";
+ this.sidebarRegistered = false;
+ this.openTooltip = "Open Model Linker to find or download missing workflow models. Shortcut: Ctrl+Shift+L.";
this.dialog = null;
this.isCheckingMissing = false; // Prevent multiple simultaneous checks
this.lastCheckedWorkflow = null; // Track to avoid duplicate checks
@@ -6253,6 +6593,50 @@ class ModelLinker {
// Listen for workflow load events to auto-check for missing models
this.setupAutoOpenOnMissingModels();
+ const sidebarRegistered = this.registerSidebarButton();
+ if (!sidebarRegistered) {
+ await this.registerTopbarButton();
+ }
+ }
+
+ registerSidebarButton() {
+ const registerSidebarTab = app.extensionManager?.registerSidebarTab;
+ if (typeof registerSidebarTab !== "function") {
+ return false;
+ }
+
+ if (this.sidebarRegistered || window.__modelLinkerSidebarRegistered) {
+ this.sidebarRegistered = true;
+ return true;
+ }
+
+ try {
+ registerSidebarTab.call(app.extensionManager, {
+ id: this.sidebarTabId,
+ icon: "mdi mdi-link-variant",
+ title: "Model Linker",
+ tooltip: this.openTooltip,
+ type: "custom",
+ render: (element) => this.renderSidebarPanel(element),
+ });
+ this.sidebarRegistered = true;
+ window.__modelLinkerSidebarRegistered = true;
+ return true;
+ } catch (error) {
+ console.warn("Model Linker: Sidebar tab registration failed, falling back to top menu button.", error);
+ return false;
+ }
+ }
+
+ renderSidebarPanel(element) {
+ element.replaceChildren();
+ element.style.height = "100%";
+ element.classList.add("ml-sidebar-dock-panel");
+
+ this.openLinkerManager({ dockContainer: element });
+ }
+
+ async registerTopbarButton() {
// Try to use new ComfyUI button system (like ComfyUI Manager does)
try {
// Dynamic imports for ComfyUI's button components
@@ -6260,14 +6644,14 @@ class ModelLinker {
const { ComfyButton } = await import("../../../scripts/ui/components/button.js");
// Create button group with Model Linker button
- const modelLinkerTooltip = "Open Model Linker to find or download missing workflow models. Shortcut: Ctrl+Shift+L.";
const modelLinkerButton = new ComfyButton({
icon: "link-variant",
action: () => this.openLinkerManager(),
content: "Model Linker",
classList: "comfyui-button comfyui-menu-mobile-collapse"
}).element;
- this.dialog.setTooltip(modelLinkerButton, modelLinkerTooltip);
+ modelLinkerButton.id = this.buttonId;
+ this.dialog.setTooltip(modelLinkerButton, this.openTooltip);
this.buttonGroup = new ComfyButtonGroup(
modelLinkerButton
);
@@ -6658,6 +7042,13 @@ class ModelLinker {
existingButton.remove();
}
+ document.querySelectorAll('.comfyui-button-group button.comfyui-button').forEach((button) => {
+ const label = button.querySelector('span')?.textContent?.trim() || button.textContent?.trim();
+ if (label === 'Model Linker') {
+ button.closest('.comfyui-button-group')?.remove();
+ }
+ });
+
// Remove button group if it exists
if (this.buttonGroup?.element?.parentNode) {
this.buttonGroup.element.remove();
@@ -6686,13 +7077,18 @@ class ModelLinker {
this.dialog?.setTooltip(this.linkerButton, "Open Model Linker to find or download missing workflow models. Shortcut: Ctrl+Shift+L.");
}
- openLinkerManager() {
+ async openLinkerManager(options = {}) {
try {
if (!this.dialog) {
this.dialog = new LinkerManagerDialog();
window.modelLinkerDialog = this.dialog;
}
- this.dialog.show();
+ if (options.dockContainer) {
+ await this.dialog.showDocked(options.dockContainer, options.workflow || null);
+ return;
+ }
+
+ await this.dialog.show(options.workflow || null);
} catch (error) {
console.error("🔗 Model Linker: Error creating/showing dialog:", error);
alert("Error opening Model Linker: " + error.message);
diff --git a/web/utils/icon_utils.js b/web/utils/icon_utils.js
index 947cf85..b14a60c 100644
--- a/web/utils/icon_utils.js
+++ b/web/utils/icon_utils.js
@@ -7,6 +7,7 @@ const SVG_ICONS = {
locate: ``,
download: ``,
externalLink: ``,
+ internalLink: ``,
database: ``,
archive: ``,
loraManager: ``,