Skip to content

feat (side panel): add side panel UI skeleton with toggle command#20

Open
Harsh16gupta wants to merge 1 commit into
masterfrom
feat/ui-panel-skeleton
Open

feat (side panel): add side panel UI skeleton with toggle command#20
Harsh16gupta wants to merge 1 commit into
masterfrom
feat/ui-panel-skeleton

Conversation

@Harsh16gupta

@Harsh16gupta Harsh16gupta commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

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

@Harsh16gupta Harsh16gupta marked this pull request as ready for review June 18, 2026 14:40
@Harsh16gupta Harsh16gupta requested a review from HahaBill June 18, 2026 14:45
@Harsh16gupta Harsh16gupta self-assigned this Jun 18, 2026
@Harsh16gupta

Copy link
Copy Markdown
Collaborator Author

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.

@HahaBill

Copy link
Copy Markdown
Member

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.

@HahaBill

Copy link
Copy Markdown
Member

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

Comment thread src/webview/panel.js Outdated
Comment on lines +1 to +332
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();
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make this React based instead with functional components

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done it.

@Harsh16gupta Harsh16gupta marked this pull request as draft June 20, 2026 04:52
@Harsh16gupta Harsh16gupta force-pushed the feat/ui-panel-skeleton branch from 49be311 to 90011c6 Compare June 20, 2026 17:29
@Harsh16gupta Harsh16gupta marked this pull request as ready for review June 20, 2026 17:44
@Harsh16gupta

Harsh16gupta commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator Author

Hey Bill, the PR is ready now.
Initially, I planned to split this into two PRs, but since the changes were closely related, I ended up combining them into a single PR.
To help with the review, I am adding some comments to the files.

I have also updated the PR description to reflect the latest changes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants