diff --git a/docs/superpowers/specs/2026-04-03-section-reorder-design.md b/docs/superpowers/specs/2026-04-03-section-reorder-design.md new file mode 100644 index 0000000..2719987 --- /dev/null +++ b/docs/superpowers/specs/2026-04-03-section-reorder-design.md @@ -0,0 +1,324 @@ +# Spec: Section Reorder via Drag-and-Drop + +**Date:** 2026-04-03 +**Status:** Approved + +## Overview + +Allow users to reorder the three GUI sections (Active Models, Providers, Recent) by dragging section headers. Order persists across sessions via localStorage. + +--- + +## Design Decisions + +| Decision | Choice | +|----------|--------| +| Drag handle | Grip icon (⠿) on section header, visible on hover | +| Pattern | Notion/Linear style — handle appears left of title on hover | +| Persistence | localStorage key `sectionOrder` — array of section IDs | +| Reset | Right-click context menu → "Reset order" | + +--- + +## Layout + +Three `
` siblings in `#app` flex column: + +``` +#app (flex-direction: column) + ├──
+ ├──
+ └──
+``` + +Drag reorder applies CSS `order` property via inline `style` attribute or class toggle. + +--- + +## HTML Changes + +### Section titles — add grip handle + IDs + +```html + +
+

Active Models

+ ... +
+ + +
+

+ + Active Models +

+ ... +
+``` + +IDs added to all three sections: +- Active Models → `id="models-section"` +- Providers → `id="providers-section"` +- Recent → `id="recent-section"` + +### Reset menu item (hidden by default) + +```html + +``` + +--- + +## CSS Changes + +### Drag handle (gui/frontend/styles.css) + +```css +/* Hidden by default, visible on section hover */ +.drag-handle { + opacity: 0; + cursor: grab; + font-size: 14px; + color: var(--text-dim); + transition: opacity 0.15s ease; + user-select: none; + flex-shrink: 0; +} + +.section:hover .drag-handle { + opacity: 1; +} + +.drag-handle:active { + cursor: grabbing; +} + +/* Dragging state — dim the dragged section */ +.section.dragging { + opacity: 0.4; + outline: 2px dashed var(--border-active, #4a4a6a); + outline-offset: 2px; +} + +/* Drop indicator — horizontal line between sections */ +.section.drag-over-top::before { + content: ''; + display: block; + height: 2px; + background: #6366f1; + margin-bottom: -1px; + border-radius: 1px; +} + +.section.drag-over-bottom::after { + content: ''; + display: block; + height: 2px; + background: #6366f1; + margin-top: -1px; + border-radius: 1px; +} +``` + +### Context menu + +```css +.context-menu { + position: fixed; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + padding: 4px; + z-index: 1000; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + min-width: 160px; +} + +.context-menu button { + display: block; + width: 100%; + text-align: left; + padding: 6px 10px; + font-size: 12px; + background: none; + border: none; + color: var(--text); + border-radius: 4px; + cursor: pointer; +} + +.context-menu button:hover { + background: var(--border); +} +``` + +--- + +## JavaScript Changes (gui/frontend/app.js) + +### 1. Default order constant + +```javascript +const DEFAULT_SECTION_ORDER = ['models-section', 'providers-section', 'recent-section']; +const STORAGE_KEY = 'sectionOrder'; +``` + +### 2. Load and apply order on init + +```javascript +function initSectionOrder() { + let order = DEFAULT_SECTION_ORDER; + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) order = JSON.parse(saved); + } catch (e) {} + + applySectionOrder(order); +} +``` + +### 3. applySectionOrder — set CSS order on each section + +```javascript +function applySectionOrder(order) { + const app = document.getElementById('app'); + order.forEach((sectionId, index) => { + const section = document.getElementById(sectionId); + if (section) { + section.style.order = index; + } + }); +} +``` + +### 4. Drag-and-drop handlers + +```javascript +let draggedSection = null; +let draggedIndex = -1; + +function initSectionDragDrop() { + document.querySelectorAll('.section[draggable="true"]').forEach(section => { + section.addEventListener('dragstart', onDragStart); + section.addEventListener('dragend', onDragEnd); + section.addEventListener('dragover', onDragOver); + section.addEventListener('dragleave', onDragLeave); + section.addEventListener('drop', onDrop); + section.addEventListener('contextmenu', onSectionContextMenu); + }); +} + +function onDragStart(e) { + draggedSection = this; + draggedIndex = [...this.parentNode.children].indexOf(this); + this.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', this.id); +} + +function onDragEnd(e) { + this.classList.remove('dragging'); + document.querySelectorAll('.section').forEach(s => { + s.classList.remove('drag-over-top', 'drag-over-bottom'); + }); + draggedSection = null; +} + +function onDragOver(e) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + const rect = this.getBoundingClientRect(); + const midY = rect.top + rect.height / 2; + this.classList.remove('drag-over-top', 'drag-over-bottom'); + this.classList.add(e.clientY < midY ? 'drag-over-top' : 'drag-over-bottom'); +} + +function onDragLeave(e) { + this.classList.remove('drag-over-top', 'drag-over-bottom'); +} + +function onDrop(e) { + e.preventDefault(); + if (this === draggedSection) return; + + const rect = this.getBoundingClientRect(); + const insertBefore = e.clientY < rect.top + rect.height / 2; + + // Reorder DOM + const parent = this.parentNode; + const thisIndex = [...parent.children].indexOf(this); + const targetIndex = insertBefore ? thisIndex : thisIndex + 1; + const finalIndex = draggedIndex < targetIndex ? targetIndex - 1 : targetIndex; + + // Get current order from DOM (since CSS order is applied) + const currentOrder = getCurrentSectionOrder(); + const [moved] = currentOrder.splice(draggedIndex, 1); + currentOrder.splice(finalIndex > draggedIndex ? finalIndex - 1 : finalIndex, 0, moved); + + applySectionOrder(currentOrder); + saveSectionOrder(currentOrder); +} +``` + +### 5. Persistence + +```javascript +function saveSectionOrder(order) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(order)); + } catch (e) {} +} +``` + +### 6. Context menu for reset + +```javascript +function onSectionContextMenu(e) { + e.preventDefault(); + const menu = document.getElementById('section-context-menu'); + menu.style.left = e.clientX + 'px'; + menu.style.top = e.clientY + 'px'; + menu.classList.remove('hidden'); + + const closeMenu = (ev) => { + if (!menu.contains(ev.target)) { + menu.classList.add('hidden'); + document.removeEventListener('click', closeMenu); + } + }; + setTimeout(() => document.addEventListener('click', closeMenu), 0); +} + +document.getElementById('reset-section-order').addEventListener('click', () => { + applySectionOrder(DEFAULT_SECTION_ORDER); + saveSectionOrder(DEFAULT_SECTION_ORDER); + document.getElementById('section-context-menu').classList.add('hidden'); +}); +``` + +### 7. Call init on DOMContentLoaded + +Add `initSectionDragDrop()` call after DOM is ready. + +--- + +## File Changes Summary + +| File | Changes | +|------|---------| +| `gui/frontend/index.html` | Add IDs to sections, `draggable="true"`, grip icon in titles, context menu HTML | +| `gui/frontend/styles.css` | `.drag-handle` styles, `.dragging` state, `.drag-over-top/bottom` indicators, context menu styles | +| `gui/frontend/app.js` | Drag-and-drop logic, order persistence, init calls | + +--- + +## Testing Checklist + +- [ ] Dragging a section visually reorders it +- [ ] Order persists after page reload +- [ ] Reset order restores default (Active Models → Providers → Recent) +- [ ] Right-click context menu appears only on section header area +- [ ] Compact mode still works (hides Providers + Recent) +- [ ] No console errors during drag operations diff --git a/gui/frontend/app.js b/gui/frontend/app.js index a96e9bd..bff3a78 100644 --- a/gui/frontend/app.js +++ b/gui/frontend/app.js @@ -998,6 +998,144 @@ let reconnectTimer = null; const WS_MAX_BACKOFF = 30000; const WS_CONNECT_TIMEOUT = 5000; +// --- Section Reorder (mousedown/mousemove/mouseup for WKWebView compat) --- +const DEFAULT_SECTION_ORDER = ['models-section', 'providers-section', 'recent-section']; +const SECTION_ORDER_KEY = 'sectionOrder'; + +function getSectionAnchor() { + return document.querySelector('.app-credit'); +} + +function getCurrentSectionOrder() { + const anchor = getSectionAnchor(); + const app = document.getElementById('app'); + const order = []; + let node = app.firstElementChild; + while (node && node !== anchor) { + if (node.classList && node.classList.contains('section') && node.id) order.push(node.id); + node = node.nextElementSibling; + } + return order; +} + +function applySectionOrder(order) { + const anchor = getSectionAnchor(); + const app = document.getElementById('app'); + order.forEach(sectionId => { + const section = document.getElementById(sectionId); + if (section && section.parentElement === app) { + app.insertBefore(section, anchor); + } + }); +} + +function saveSectionOrder(order) { + try { + localStorage.setItem(SECTION_ORDER_KEY, JSON.stringify(order)); + } catch (e) {} +} + +function isDefaultOrder() { + const current = getCurrentSectionOrder(); + return current.length === DEFAULT_SECTION_ORDER.length && + current.every((id, i) => id === DEFAULT_SECTION_ORDER[i]); +} + +function updateResetIcon() { + const icon = document.getElementById('reset-section-order'); + if (!icon) return; + icon.style.display = isDefaultOrder() ? 'none' : ''; +} + +(function initSectionDragDrop() { + let draggedEl = null; // the .section being dragged + + // Find which section the cursor is over, and whether top or bottom half + function getDropTarget(y) { + const sections = document.querySelectorAll('#app > .section'); + for (const sec of sections) { + if (sec === draggedEl) continue; + const rect = sec.getBoundingClientRect(); + if (y >= rect.top && y <= rect.bottom) { + return { section: sec, before: y < rect.top + rect.height / 2 }; + } + } + return null; + } + + function clearIndicators() { + document.querySelectorAll('.section').forEach(s => { + s.classList.remove('drag-over-top', 'drag-over-bottom'); + }); + } + + document.addEventListener('mousedown', (e) => { + const handle = e.target.closest('.drag-handle'); + if (!handle) return; + e.preventDefault(); + + draggedEl = handle.closest('.section'); + if (!draggedEl) return; + + startY = e.clientY; + draggedEl.classList.add('dragging'); + }); + + document.addEventListener('mousemove', (e) => { + if (!draggedEl) return; + e.preventDefault(); + + const target = getDropTarget(e.clientY); + clearIndicators(); + + if (target) { + if (target.before) { + target.section.classList.add('drag-over-top'); + } else { + target.section.classList.add('drag-over-bottom'); + } + } + }); + + document.addEventListener('mouseup', (e) => { + if (!draggedEl) return; + + const target = getDropTarget(e.clientY); + if (target) { + const parent = draggedEl.parentNode; + if (target.before) { + parent.insertBefore(draggedEl, target.section); + } else { + parent.insertBefore(draggedEl, target.section.nextSibling); + } + saveSectionOrder(getCurrentSectionOrder()); + updateResetIcon(); + } + + clearIndicators(); + draggedEl.classList.remove('dragging'); + draggedEl = null; + }); + + // Reset icon + document.getElementById('reset-section-order').addEventListener('click', () => { + applySectionOrder(DEFAULT_SECTION_ORDER); + saveSectionOrder(DEFAULT_SECTION_ORDER); + updateResetIcon(); + }); +})(); + +// Apply saved section order on load +(function() { + let order = DEFAULT_SECTION_ORDER; + try { + const saved = localStorage.getItem(SECTION_ORDER_KEY); + if (saved) order = JSON.parse(saved); + } catch (e) {} + applySectionOrder(order); + updateResetIcon(); +})(); + // Set custom titlebar version from the native window title (set by Rust backend) if (window.__TAURI__) { const titleEl = document.querySelector('.titlebar .title'); diff --git a/gui/frontend/index.html b/gui/frontend/index.html index e6e400c..dcfbce0 100644 --- a/gui/frontend/index.html +++ b/gui/frontend/index.html @@ -58,8 +58,11 @@ -
-

Active Models

+
+

+ + Active Models +

No requests yet
@@ -67,7 +70,10 @@

Active Models

-

Providers %

+

+ + Providers % +

No requests yet
@@ -75,15 +81,20 @@

Providers -

Recent

+

+ + Recent +

No requests yet

+
by kianwoon +
diff --git a/gui/frontend/styles.css b/gui/frontend/styles.css index 158bc5e..987e042 100644 --- a/gui/frontend/styles.css +++ b/gui/frontend/styles.css @@ -345,6 +345,54 @@ body { text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; + display: flex; + align-items: center; + gap: 6px; +} + +/* Drag handle — hidden by default, visible on section hover */ +.drag-handle { + opacity: 0; + cursor: grab; + font-size: 14px; + color: var(--text-dim); + transition: opacity 0.15s ease; + user-select: none; + flex-shrink: 0; +} + +.section:hover .drag-handle { + opacity: 1; +} + +.drag-handle:active { + cursor: grabbing; +} + +/* Dragging state — dim the dragged section */ +.section.dragging { + opacity: 0.4; + outline: 2px dashed var(--border, #4a4a6a); + outline-offset: 2px; +} + +/* Drop indicator — horizontal line between sections */ +.section.drag-over-top::before { + content: ''; + display: block; + height: 2px; + background: #6366f1; + margin-bottom: -1px; + border-radius: 1px; +} + +.section.drag-over-bottom::after { + content: ''; + display: block; + height: 2px; + background: #6366f1; + margin-top: -1px; + border-radius: 1px; } .section-content { @@ -631,9 +679,8 @@ body { border-radius: 2px; } -/* Compact Mode */ -#app.compact-mode #providers-section, -#app.compact-mode #recent-section { +/* Compact Mode — hide all sections, show only the first in DOM order */ +#app.compact-mode > .section { max-height: 0; opacity: 0; padding-top: 0; @@ -645,8 +692,15 @@ body { transition: max-height 0.3s ease, opacity 0.2s ease, padding 0.3s ease; } -#providers-section, -#recent-section { +#app.compact-mode > .section:first-of-type { + max-height: none; + opacity: 1; + padding: 8px 12px; + overflow: visible; + pointer-events: auto; +} + +#app > .section { transition: max-height 0.3s ease, opacity 0.2s ease, padding 0.3s ease; } @@ -692,3 +746,21 @@ body.glow-active #app { body.glow-active { animation: none; box-shadow: none; border-color: var(--border); } body.glow-active #app { animation: none; box-shadow: none; } } + +/* === Section Reorder Context Menu === */ +.reset-order-btn { + background: none; + border: 1px solid var(--border); + color: var(--text-dim); + font-size: 12px; + padding: 1px 5px; + border-radius: 4px; + cursor: pointer; + margin-left: 6px; + transition: color 0.15s, border-color 0.15s; +} + +.reset-order-btn:hover { + color: var(--text); + border-color: var(--text-dim); +}