feat (side panel): add side panel UI skeleton with toggle command#20
feat (side panel): add side panel UI skeleton with toggle command#20Harsh16gupta wants to merge 1 commit into
Conversation
|
Hey Bill, this PR is part of a bigger change, I've split it into two PRs to keep things reviewable. This one adds the panel skeleton and UI, and the next one will wire the clustering pipeline to it. |
Hi! From the first look, I recommend using React instead of Vanilla JS because then you can add more frameworks/libraries on top of it. It'll make development better. |
Especially with functional components |
| let elRunBtn; | ||
| let elStatusBar; | ||
| let elStatusText; | ||
| let elProgressFill; | ||
| let elProgressLabelLeft; | ||
| let elProgressLabelRight; | ||
| let elStrategySection; | ||
| let elStrategySelect; | ||
| let elStrategyScore; | ||
| let elStrategyPills; | ||
| let elClusterList; | ||
| let elEmptyState; | ||
| let elErrorBanner; | ||
|
|
||
| let isRunning = false; | ||
|
|
||
| // --- DOM construction --- | ||
|
|
||
| function buildDOM() { | ||
| document.body.innerHTML = ''; | ||
|
|
||
| const header = el('div', 'panel-header'); | ||
| const headerTitle = el('div', 'panel-header-title'); | ||
| headerTitle.textContent = 'Note Categorizer'; | ||
| header.appendChild(headerTitle); | ||
|
|
||
| elRunBtn = el('button', 'btn-run'); | ||
| elRunBtn.id = 'btn-run'; | ||
| elRunBtn.textContent = 'Run'; | ||
| elRunBtn.addEventListener('click', onRunClick); | ||
| header.appendChild(elRunBtn); | ||
| document.body.appendChild(header); | ||
|
|
||
| elStatusBar = el('div', 'status-bar'); | ||
| elStatusText = el('div', 'status-text'); | ||
| elStatusBar.appendChild(elStatusText); | ||
|
|
||
| const progressContainer = el('div', 'progress-container'); | ||
| elProgressFill = el('div', 'progress-fill'); | ||
| progressContainer.appendChild(elProgressFill); | ||
| elStatusBar.appendChild(progressContainer); | ||
|
|
||
| const progressLabel = el('div', 'progress-label'); | ||
| elProgressLabelLeft = el('span'); | ||
| elProgressLabelRight = el('span'); | ||
| progressLabel.appendChild(elProgressLabelLeft); | ||
| progressLabel.appendChild(elProgressLabelRight); | ||
| elStatusBar.appendChild(progressLabel); | ||
| document.body.appendChild(elStatusBar); | ||
|
|
||
| elErrorBanner = el('div', 'error-banner'); | ||
| document.body.appendChild(elErrorBanner); | ||
|
|
||
| elStrategySection = el('div', 'strategy-section'); | ||
|
|
||
| const selectorRow = el('div', 'strategy-selector-row'); | ||
| const selectorLabel = el('span', 'strategy-selector-label'); | ||
| selectorLabel.textContent = 'Strategy:'; | ||
| selectorRow.appendChild(selectorLabel); | ||
|
|
||
| elStrategySelect = el('select', 'strategy-select'); | ||
| elStrategySelect.id = 'strategy-select'; | ||
| elStrategySelect.addEventListener('change', onStrategyChange); | ||
| selectorRow.appendChild(elStrategySelect); | ||
| elStrategySection.appendChild(selectorRow); | ||
|
|
||
| elStrategyScore = el('div', 'strategy-score'); | ||
| elStrategySection.appendChild(elStrategyScore); | ||
|
|
||
| elStrategyPills = el('div', 'strategy-pills'); | ||
| elStrategySection.appendChild(elStrategyPills); | ||
| document.body.appendChild(elStrategySection); | ||
|
|
||
| elClusterList = el('div', 'cluster-list'); | ||
| document.body.appendChild(elClusterList); | ||
|
|
||
| elEmptyState = el('div', 'empty-state'); | ||
| const emptyTitle = el('div', 'empty-title'); | ||
| emptyTitle.textContent = 'No categories yet'; | ||
| elEmptyState.appendChild(emptyTitle); | ||
| const emptySub = el('div', 'empty-subtitle'); | ||
| emptySub.textContent = 'Click Run to categorize your notes using on-device AI.'; | ||
| elEmptyState.appendChild(emptySub); | ||
| document.body.appendChild(elEmptyState); | ||
|
|
||
| showEmptyState(); | ||
| } | ||
|
|
||
| // --- UI state transitions --- | ||
|
|
||
| function showEmptyState() { | ||
| elEmptyState.style.display = 'flex'; | ||
| hide(elStatusBar); | ||
| hide(elStrategySection); | ||
| hide(elClusterList); | ||
| hide(elErrorBanner); | ||
| } | ||
|
|
||
| function showProgress() { | ||
| elEmptyState.style.display = 'none'; | ||
| show(elStatusBar); | ||
| hide(elStrategySection); | ||
| hide(elClusterList); | ||
| hide(elErrorBanner); | ||
| } | ||
|
|
||
| function showResults() { | ||
| elEmptyState.style.display = 'none'; | ||
| hide(elStatusBar); | ||
| show(elStrategySection); | ||
| show(elClusterList); | ||
| hide(elErrorBanner); | ||
| } | ||
|
|
||
| function showError(message) { | ||
| elErrorBanner.textContent = 'Error: ' + message; | ||
| show(elErrorBanner); | ||
| } | ||
|
|
||
| function show(element) { | ||
| element.classList.add('visible'); | ||
| } | ||
|
|
||
| function hide(element) { | ||
| element.classList.remove('visible'); | ||
| } | ||
|
|
||
| // --- Event handlers --- | ||
|
|
||
| function onRunClick() { | ||
| if (isRunning) return; | ||
| isRunning = true; | ||
| elRunBtn.disabled = true; | ||
|
|
||
| showProgress(); | ||
| setStatus('Starting pipeline…'); | ||
| setProgress(0, 0, 0, 0); | ||
|
|
||
| webviewApi.postMessage({ type: 'run' }); | ||
| } | ||
|
|
||
| function onStrategyChange() { | ||
| // PR 2 wires this to switch between benchmark results | ||
| } | ||
|
|
||
| // --- Progress updaters --- | ||
|
|
||
| function setStatus(text) { | ||
| elStatusText.textContent = text; | ||
| } | ||
|
|
||
| function setProgress(current, total, cached, skipped) { | ||
| const pct = total > 0 ? Math.round((current / total) * 100) : 0; | ||
| elProgressFill.style.width = pct + '%'; | ||
| elProgressLabelLeft.textContent = total > 0 ? current + '/' + total + ' notes' : ''; | ||
|
|
||
| const parts = []; | ||
| if (cached > 0) parts.push(cached + ' cached'); | ||
| if (skipped > 0) parts.push(skipped + ' skipped'); | ||
| elProgressLabelRight.textContent = parts.join(' · '); | ||
| } | ||
|
|
||
| // --- Results rendering --- | ||
|
|
||
| /** | ||
| * Populates the strategy selector and cluster cards. | ||
| * Called when the plugin sends a 'results' message. | ||
| */ | ||
| function renderResults(strategies, notes) { | ||
| if (!strategies || strategies.length === 0) { | ||
| showEmptyState(); | ||
| return; | ||
| } | ||
|
|
||
| elStrategySelect.innerHTML = ''; | ||
| strategies.forEach(function (s, i) { | ||
| const opt = document.createElement('option'); | ||
| opt.value = i; | ||
| opt.textContent = s.strategyName + ' (' + s.silhouetteScore.toFixed(2) + ')'; | ||
| elStrategySelect.appendChild(opt); | ||
| }); | ||
|
|
||
| renderStrategy(strategies, notes, 0); | ||
| showResults(); | ||
| } | ||
|
|
||
| function renderStrategy(strategies, notes, selectedIndex) { | ||
| const selected = strategies[selectedIndex]; | ||
|
|
||
| elStrategyScore.innerHTML = | ||
| 'Score: <strong>' + selected.silhouetteScore.toFixed(2) + '</strong>' + | ||
| ' · ' + selected.clusterCount + ' clusters' + | ||
| (selected.outlierCount > 0 ? ' · ' + selected.outlierCount + ' noise' : ''); | ||
|
|
||
| elStrategyPills.innerHTML = ''; | ||
| strategies.forEach(function (s, i) { | ||
| const pill = el('span', 'strategy-pill' + (i === selectedIndex ? ' active' : '')); | ||
| pill.textContent = abbreviateStrategy(s.strategyName) + ': ' + s.silhouetteScore.toFixed(2); | ||
| elStrategyPills.appendChild(pill); | ||
| }); | ||
|
|
||
| renderClusterCards(selected, notes); | ||
| } | ||
|
|
||
| function renderClusterCards(strategy, notes) { | ||
| elClusterList.innerHTML = ''; | ||
|
|
||
| const clusters = {}; | ||
| const noise = []; | ||
| strategy.assignments.forEach(function (clusterId, noteIndex) { | ||
| if (clusterId === -1) { | ||
| noise.push(noteIndex); | ||
| } else { | ||
| if (!clusters[clusterId]) clusters[clusterId] = []; | ||
| clusters[clusterId].push(noteIndex); | ||
| } | ||
| }); | ||
|
|
||
| // Sort clusters by size (largest first) | ||
| const clusterIds = Object.keys(clusters).sort(function (a, b) { | ||
| return clusters[b].length - clusters[a].length; | ||
| }); | ||
|
|
||
| clusterIds.forEach(function (id, i) { | ||
| elClusterList.appendChild( | ||
| createClusterCard('Cluster ' + (i + 1), clusters[id], notes, false), | ||
| ); | ||
| }); | ||
|
|
||
| if (noise.length > 0) { | ||
| elClusterList.appendChild( | ||
| createClusterCard('Uncategorized', noise, notes, true), | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| function createClusterCard(title, noteIndices, notes, isNoise) { | ||
| const card = el('div', 'cluster-card' + (isNoise ? ' noise' : '')); | ||
|
|
||
| const header = el('div', 'cluster-header'); | ||
| const headerLeft = el('div', 'cluster-header-left'); | ||
| headerLeft.appendChild(span('cluster-title', title)); | ||
| header.appendChild(headerLeft); | ||
|
|
||
| const count = noteIndices.length; | ||
| header.appendChild(span('cluster-count', count + (count === 1 ? ' note' : ' notes'))); | ||
| header.appendChild(span('cluster-chevron', '>')); | ||
|
|
||
| header.addEventListener('click', function () { | ||
| card.classList.toggle('expanded'); | ||
| }); | ||
| card.appendChild(header); | ||
|
|
||
| const notesContainer = el('div', 'cluster-notes'); | ||
| noteIndices.forEach(function (idx) { | ||
| const note = notes[idx]; | ||
| if (!note) return; | ||
|
|
||
| const item = el('div', 'note-item'); | ||
| item.appendChild(span('note-title', note.title || 'Untitled')); | ||
| item.addEventListener('click', function () { | ||
| webviewApi.postMessage({ type: 'openNote', noteId: note.noteId }); | ||
| }); | ||
| notesContainer.appendChild(item); | ||
| }); | ||
| card.appendChild(notesContainer); | ||
|
|
||
| return card; | ||
| } | ||
|
|
||
| // --- Message handler (plugin → webview) --- | ||
|
|
||
| function handlePluginMessage(msg) { | ||
| if (!msg || !msg.type) return; | ||
|
|
||
| switch (msg.type) { | ||
| case 'status': | ||
| setStatus(msg.text || ''); | ||
| break; | ||
|
|
||
| case 'progress': | ||
| setProgress(msg.current || 0, msg.total || 0, msg.cached || 0, msg.skipped || 0); | ||
| break; | ||
|
|
||
| case 'results': | ||
| isRunning = false; | ||
| elRunBtn.disabled = false; | ||
| renderResults(msg.strategies || [], msg.notes || []); | ||
| break; | ||
|
|
||
| case 'error': | ||
| isRunning = false; | ||
| elRunBtn.disabled = false; | ||
| showError(msg.message || 'An unknown error occurred.'); | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| // --- DOM helpers --- | ||
|
|
||
| function el(tag, className) { | ||
| const element = document.createElement(tag); | ||
| if (className) element.className = className; | ||
| return element; | ||
| } | ||
|
|
||
| function span(className, text) { | ||
| const s = document.createElement('span'); | ||
| if (className) s.className = className; | ||
| if (text) s.textContent = text; | ||
| return s; | ||
| } | ||
|
|
||
| /** Shortens 'hdbscan-5-ms2' → 'hdbscan-5' for pill labels */ | ||
| function abbreviateStrategy(name) { | ||
| if (!name) return ''; | ||
| const parts = name.split('-'); | ||
| return parts.length > 2 ? parts[0] + '-' + parts[1] : name; | ||
| } | ||
|
|
||
| // Joplin loads panel scripts after the DOM is ready, | ||
| // so DOMContentLoaded has usually already fired. | ||
| function init() { | ||
| buildDOM(); | ||
| webviewApi.onMessage(handlePluginMessage); | ||
| } | ||
|
|
||
| if (document.readyState === 'loading') { | ||
| document.addEventListener('DOMContentLoaded', init); | ||
| } else { | ||
| init(); | ||
| } |
There was a problem hiding this comment.
Make this React based instead with functional components
49be311 to
90011c6
Compare
|
Hey Bill, the PR is ready now. I have also updated the PR description to reflect the latest changes. |
There was a problem hiding this comment.
This file has the same code logic as in the testEmbed.ts file.
Only one improvement compared to testEmbed.ts:
prepareNoteChunks now returns [] instead of [''] for whitespace-only bodies, avoiding a useless embedding call.
This pr adds a Ui panel to the plugin
- A polling-based communication layer between plugin host and webview
- Live progress tracking during the embedding/clustering pipeline
- Results dashboard with strategy comparison and cluster drill-down
- Tab navigation (Dashboard, Change Log, Settings)
- Dark/light theme support via Joplin CSS variables
Screen Recordings:
Dark Mode
https://github.com/user-attachments/assets/871c08ea-f319-45ea-98da-eae7e25d3495
Light Mode
https://github.com/user-attachments/assets/3c50acb6-349f-4b38-b32a-afc2131b6427