diff --git a/package-lock.json b/package-lock.json
index 538618a..f775d5c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,11 +12,15 @@
"@huggingface/transformers": "^3.8.1",
"hdbscan-ts": "^1.0.17",
"js-tiktoken": "^1.0.21",
+ "react": "^19.2.7",
+ "react-dom": "^19.2.7",
"umap-js": "^1.4.0",
"vectra": "^0.12.3"
},
"devDependencies": {
"@types/node": "^18.7.13",
+ "@types/react": "^19.2.17",
+ "@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"chalk": "^4.1.0",
@@ -929,6 +933,24 @@
"form-data": "^4.0.4"
}
},
+ "node_modules/@types/react": {
+ "version": "19.2.17",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
+ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
+ "dev": true,
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+ "dev": true,
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
"node_modules/@types/semver": {
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz",
@@ -1918,6 +1940,12 @@
"url": "https://github.com/sponsors/fb55"
}
},
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -4241,6 +4269,25 @@
"safe-buffer": "^5.1.0"
}
},
+ "node_modules/react": {
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
+ "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
+ "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.7"
+ }
+ },
"node_modules/rechoir": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.7.1.tgz",
@@ -4454,6 +4501,11 @@
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="
+ },
"node_modules/schema-utils": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz",
diff --git a/package.json b/package.json
index 76fc5a0..72c705f 100644
--- a/package.json
+++ b/package.json
@@ -7,9 +7,9 @@
"copyAssets": "node tools/copyAssets.js",
"updateVersion": "webpack --env joplin-plugin-config=updateVersion",
"update": "npm install -g generator-joplin && yo joplin --node-package-manager npm --update --force",
- "lint": "eslint \"src/**/*.ts\"",
- "format": "prettier --write \"src/**/*.ts\"",
- "format:check": "prettier --check \"src/**/*.ts\"",
+ "lint": "eslint \"src/**/*.{ts,tsx}\"",
+ "format": "prettier --write \"src/**/*.{ts,tsx}\"",
+ "format:check": "prettier --check \"src/**/*.{ts,tsx}\"",
"ci": "npm run lint && npm run format:check && npm run dist"
},
"license": "MIT",
@@ -21,6 +21,8 @@
],
"devDependencies": {
"@types/node": "^18.7.13",
+ "@types/react": "^19.2.17",
+ "@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"chalk": "^4.1.0",
@@ -39,6 +41,8 @@
"@huggingface/transformers": "^3.8.1",
"hdbscan-ts": "^1.0.17",
"js-tiktoken": "^1.0.21",
+ "react": "^19.2.7",
+ "react-dom": "^19.2.7",
"umap-js": "^1.4.0",
"vectra": "^0.12.3"
}
diff --git a/plugin.config.json b/plugin.config.json
index 0034907..243a65a 100644
--- a/plugin.config.json
+++ b/plugin.config.json
@@ -1,3 +1,3 @@
{
- "extraScripts": ["worker/embedWorker.ts"]
+ "extraScripts": ["worker/embedWorker.ts", "webview/panel.tsx"]
}
\ No newline at end of file
diff --git a/src/index.ts b/src/index.ts
index 98b0277..6fc366d 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,6 +1,8 @@
import joplin from 'api';
-import { MenuItemLocation } from 'api/types';
+import { MenuItemLocation, ToolbarButtonLocation } from 'api/types';
import { runTestEmbed } from './commands/testEmbed';
+import { runPipeline } from './pipeline/runPipeline';
+import { PanelMessage, WebviewMessage } from './types/panel';
import { log } from './utils/logger';
joplin.plugins.register({
@@ -21,6 +23,75 @@ joplin.plugins.register({
MenuItemLocation.Tools,
);
+ // Panel starts hidden; user opens via toolbar button or View menu
+ const panel = await joplin.views.panels.create('aiCategorise.panel');
+ await joplin.views.panels.setHtml(panel, '
');
+ await joplin.views.panels.addScript(panel, './webview/panel.css');
+ await joplin.views.panels.addScript(panel, './webview/panel.js');
+ await joplin.views.panels.show(panel, false);
+
+ // Pipeline state shared between the onMessage handler and pipeline callbacks.
+ // The webview polls this state via { type: 'poll' } messages.
+ let panelState: PanelMessage | { type: 'idle' } = { type: 'idle' };
+
+ await joplin.views.panels.onMessage(panel, async (msg: WebviewMessage) => {
+ switch (msg.type) {
+ case 'run':
+ panelState = { type: 'status', text: 'Starting pipeline...' };
+ log('Panel: starting pipeline');
+
+ // Fire-and-forget — pipeline updates panelState via callbacks
+ runPipeline(installDir, {
+ onStatus: (text) => {
+ panelState = { type: 'status', text };
+ },
+ onProgress: (current, total, cached, skipped) => {
+ panelState = { type: 'progress', current, total, cached, skipped };
+ },
+ onComplete: (strategies, notes) => {
+ panelState = { type: 'results', strategies, notes };
+ },
+ onError: (message) => {
+ panelState = { type: 'error', message };
+ },
+ });
+
+ return panelState;
+
+ case 'poll':
+ return panelState;
+
+ case 'openNote':
+ if (msg.noteId) {
+ await joplin.commands.execute('openNote', msg.noteId);
+ }
+ return;
+ }
+ });
+
+ await joplin.commands.register({
+ name: 'aiCategorise.togglePanel',
+ label: 'AI Categorise: Toggle Panel',
+ iconName: 'fas fa-brain',
+ execute: async () => {
+ const visible = await joplin.views.panels.visible(panel);
+ await joplin.views.panels.show(panel, !visible);
+ },
+ });
+
+ await joplin.views.menuItems.create(
+ 'aiCategorise.togglePanelMenuItem',
+ 'aiCategorise.togglePanel',
+ MenuItemLocation.View,
+ );
+
+ await joplin.views.toolbarButtons.create(
+ 'aiCategorise.togglePanelToolbar',
+ 'aiCategorise.togglePanel',
+ ToolbarButtonLocation.NoteToolbar,
+ );
+
log('Test command registered under Tools menu');
+ log('Panel registered');
},
});
diff --git a/src/pipeline/runPipeline.ts b/src/pipeline/runPipeline.ts
new file mode 100644
index 0000000..0a67fe4
--- /dev/null
+++ b/src/pipeline/runPipeline.ts
@@ -0,0 +1,292 @@
+import { fetchAllNotes } from './noteReader';
+import { benchmark } from './clustering/benchmark';
+import { CategorizationConfig } from '../types/cluster';
+import { averageVectors, blendVectors, computeTitleWeight, cosineSimilarity } from './vectorAggregator';
+import { NoteVector, WorkerMessage } from '../types/embed';
+import { PanelNote } from '../types/panel';
+import { isGenericTitle } from '../utils/titleFilter';
+import { log, logErr } from '../utils/logger';
+import { getEncoding } from 'js-tiktoken';
+import { VectorCache } from './vectorCache';
+
+// See testEmbed.ts for rationale on cl100k_base and the 200-token limit.
+const enc = getEncoding('cl100k_base');
+const MAX_TOKENS = 200;
+
+const DEFAULT_CONFIG: CategorizationConfig = {
+ seed: 42,
+ metric: 'cosine',
+ intermediateDim: 10,
+ intermediateNeighbors: 15,
+ strategies: [
+ { name: 'kmeans-5', algorithm: 'kmeans', K: 5 },
+ { name: 'kmedoids-5', algorithm: 'kmedoids', K: 5 },
+ { name: 'hdbscan-3', algorithm: 'hdbscan', minClusterSize: 3 },
+ { name: 'hdbscan-3-ms2', algorithm: 'hdbscan', minClusterSize: 3, minSamples: 2 },
+ { name: 'hdbscan-5-ms2', algorithm: 'hdbscan', minClusterSize: 5, minSamples: 2 },
+ ],
+};
+
+export interface PipelineCallbacks {
+ onStatus: (text: string) => void;
+ onProgress: (current: number, total: number, cached: number, skipped: number) => void;
+ onComplete: (strategies: import('../types/cluster').BenchmarkResult[], notes: PanelNote[]) => void;
+ onError: (message: string) => void;
+}
+
+/**
+ * Runs the full embedding + clustering pipeline, reporting progress via callbacks.
+ *
+ * This is the same logic as testEmbed.ts, but decoupled from console logging
+ * so the panel (or any other caller) can receive live updates.
+ */
+export const runPipeline = async (installDir: string, callbacks: PipelineCallbacks): Promise => {
+ try {
+ callbacks.onStatus('Fetching notes...');
+ const notes = await fetchAllNotes();
+ log(`Fetched ${notes.length} notes`);
+
+ if (notes.length === 0) {
+ callbacks.onError('No notes found. Create some notes and try again.');
+ return;
+ }
+
+ const cache = await VectorCache.create();
+
+ // Remove notes from cache that are no longer in Joplin
+ const indexedIds = await cache.getIndexedIds();
+ const joplinNoteIds = new Set(notes.map((n) => n.id));
+ const idsToDelete = indexedIds.filter((id) => !joplinNoteIds.has(id));
+
+ if (idsToDelete.length > 0) {
+ log(`Removing ${idsToDelete.length} obsolete notes from cache`);
+ await cache.deleteItems(idsToDelete);
+ }
+
+ await cache.beginUpdate();
+
+ callbacks.onStatus('Loading model...');
+ const worker = new Worker(`${installDir}/worker/embedWorker.js`);
+
+ let currentNoteIndex = 0;
+ let currentChunkIndex = 0;
+ let currentNoteChunks: string[] = [];
+ let currentChunkEmbeddings: number[][] = [];
+ let currentBodyVector: number[] = [];
+ let isEmbeddingTitle = false;
+ let totalInferenceTime = 0;
+ let skippedCount = 0;
+ let cachedCount = 0;
+ let currentNoteHash = '';
+ const batchStartTime = performance.now();
+ const noteVectors: NoteVector[] = [];
+
+ const reportProgress = () => {
+ // current = notes finalized so far (embedded + cached + skipped)
+ const processed = noteVectors.length + skippedCount;
+ callbacks.onProgress(processed, notes.length, cachedCount, skippedCount);
+ };
+
+ const prepareNoteChunks = (text: string): string[] => {
+ const tokens = enc.encode(text);
+ const chunks: string[] = [];
+ if (tokens.length === 0) return [];
+
+ for (let i = 0; i < tokens.length; i += MAX_TOKENS) {
+ const chunkTokens = tokens.slice(i, i + MAX_TOKENS);
+ chunks.push(enc.decode(chunkTokens));
+ }
+ return chunks;
+ };
+
+ const finalizeNote = async (vector: number[], titleWeight: number, hash: string) => {
+ const note = notes[currentNoteIndex];
+ noteVectors.push({ noteId: note.id, title: note.title, vector, titleWeight });
+
+ await cache.upsertItem(note.id, vector, {
+ title: note.title,
+ hash,
+ updatedTime: note.updated_time,
+ titleWeight,
+ });
+
+ reportProgress();
+
+ currentNoteIndex++;
+ await processNextNote();
+ };
+
+ const processNextNote = async () => {
+ currentChunkIndex = 0;
+ currentNoteChunks = [];
+ currentChunkEmbeddings = [];
+ currentBodyVector = [];
+ isEmbeddingTitle = false;
+
+ // Skip notes with empty body and generic title, and bypass cached notes
+ while (currentNoteIndex < notes.length) {
+ const note = notes[currentNoteIndex];
+
+ if (note.body.length === 0 && isGenericTitle(note.title)) {
+ log(
+ `[${currentNoteIndex + 1}/${notes.length}] skipped "${note.title.slice(0, 30)}" (empty body, generic title)`,
+ );
+ skippedCount++;
+ currentNoteIndex++;
+ reportProgress();
+ continue;
+ }
+
+ currentNoteHash = cache.computeHash(note.title, note.body);
+ const cachedItem = await cache.getItem(note.id);
+
+ if (cachedItem && cachedItem.metadata.hash === currentNoteHash) {
+ log(`[${currentNoteIndex + 1}/${notes.length}] cache hit for "${note.title.slice(0, 30)}"`);
+ noteVectors.push({
+ noteId: note.id,
+ title: note.title,
+ vector: cachedItem.vector,
+ titleWeight: cachedItem.metadata.titleWeight ?? 0,
+ });
+ cachedCount++;
+ currentNoteIndex++;
+ reportProgress();
+ continue;
+ }
+
+ break;
+ }
+
+ if (currentNoteIndex >= notes.length) {
+ const totalTime = performance.now() - batchStartTime;
+ log(
+ `Batch complete: ${notes.length} notes, ${noteVectors.length - cachedCount} embedded, ` +
+ `${cachedCount} cached, ${skippedCount} skipped in ${Math.round(totalTime)}ms ` +
+ `(inference: ${Math.round(totalInferenceTime)}ms)`,
+ );
+
+ await cache.endUpdate();
+ worker.terminate();
+
+ callbacks.onStatus('Clustering...');
+
+ if (noteVectors.length < 3) {
+ callbacks.onError('Too few notes for clustering (need at least 3).');
+ return;
+ }
+
+ const vectors = noteVectors.map((nv) => nv.vector);
+ const results = benchmark(vectors, DEFAULT_CONFIG);
+
+ const panelNotes: PanelNote[] = noteVectors.map((nv) => ({
+ noteId: nv.noteId,
+ title: nv.title,
+ }));
+
+ callbacks.onComplete(results, panelNotes);
+ return;
+ }
+
+ const note = notes[currentNoteIndex];
+ callbacks.onStatus(`Embedding "${note.title.slice(0, 40)}"...`);
+
+ if (note.body.length === 0) {
+ isEmbeddingTitle = true;
+ worker.postMessage({ type: 'embed', text: note.title, noteId: note.id });
+ } else {
+ currentNoteChunks = prepareNoteChunks(note.body);
+ if (currentNoteChunks.length === 0) {
+ // Whitespace-only body — treat as title-only note
+ isEmbeddingTitle = true;
+ worker.postMessage({ type: 'embed', text: note.title, noteId: note.id });
+ } else {
+ worker.postMessage({
+ type: 'embed',
+ text: currentNoteChunks[0],
+ noteId: note.id,
+ });
+ }
+ }
+ };
+
+ worker.onerror = (err: ErrorEvent) => {
+ logErr('Worker error:', err.message);
+ cache.cancelUpdate();
+ worker.terminate();
+ callbacks.onError('Embedding worker failed: ' + err.message);
+ };
+
+ worker.onmessage = async (event: MessageEvent) => {
+ const data = event.data;
+
+ if (data.type === 'load-result') {
+ if (data.success) {
+ log(`Model loaded in ${(data.loadTime / 1000).toFixed(1)}s, device: ${data.device}`);
+ callbacks.onStatus('Embedding notes...');
+ await processNextNote();
+ } else {
+ logErr('Model load failed:', data.error);
+ cache.cancelUpdate();
+ worker.terminate();
+ callbacks.onError('Failed to load embedding model: ' + (data.error || 'unknown error'));
+ }
+ return;
+ }
+
+ if (data.type === 'embed-result') {
+ const note = notes[currentNoteIndex];
+
+ if (!data.success) {
+ logErr(`Failed to embed note "${note.title.slice(0, 30)}":`, data.error);
+ currentNoteIndex++;
+ await processNextNote();
+ return;
+ }
+
+ totalInferenceTime += data.inferenceTime;
+
+ if (isEmbeddingTitle) {
+ const titleEmbedding = data.embedding;
+
+ if (currentBodyVector.length > 0) {
+ const sim = cosineSimilarity(currentBodyVector, titleEmbedding);
+ const alpha = computeTitleWeight(sim);
+ const finalVector = blendVectors(currentBodyVector, titleEmbedding, alpha);
+ await finalizeNote(finalVector, alpha, currentNoteHash);
+ } else {
+ await finalizeNote(titleEmbedding, 1.0, currentNoteHash);
+ }
+ } else {
+ currentChunkEmbeddings.push(data.embedding);
+ log(
+ `[${currentNoteIndex + 1}/${notes.length}] embedded chunk ${currentChunkIndex + 1}/${currentNoteChunks.length} of "${note.title.slice(0, 30)}"`,
+ );
+
+ currentChunkIndex++;
+ if (currentChunkIndex < currentNoteChunks.length) {
+ worker.postMessage({
+ type: 'embed',
+ text: currentNoteChunks[currentChunkIndex],
+ noteId: note.id,
+ });
+ } else {
+ currentBodyVector = averageVectors(currentChunkEmbeddings);
+
+ if (!isGenericTitle(note.title)) {
+ isEmbeddingTitle = true;
+ worker.postMessage({ type: 'embed', text: note.title, noteId: note.id });
+ } else {
+ await finalizeNote(currentBodyVector, 0, currentNoteHash);
+ }
+ }
+ }
+ }
+ };
+
+ worker.postMessage({ type: 'load' });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ logErr('Pipeline failed:', message);
+ callbacks.onError('Pipeline failed: ' + message);
+ }
+};
diff --git a/src/types/panel.ts b/src/types/panel.ts
new file mode 100644
index 0000000..4dbf646
--- /dev/null
+++ b/src/types/panel.ts
@@ -0,0 +1,25 @@
+import { BenchmarkResult } from './cluster';
+
+export type { BenchmarkResult };
+
+export interface PanelNote {
+ noteId: string;
+ title: string;
+}
+
+export interface ProgressState {
+ current: number;
+ total: number;
+ cached: number;
+ skipped: number;
+}
+
+// Plugin → Webview
+export type PanelMessage =
+ | { type: 'status'; text: string }
+ | { type: 'progress'; current: number; total: number; cached: number; skipped: number }
+ | { type: 'results'; strategies: BenchmarkResult[]; notes: PanelNote[] }
+ | { type: 'error'; message: string };
+
+// Webview → Plugin
+export type WebviewMessage = { type: 'run' } | { type: 'poll' } | { type: 'openNote'; noteId: string };
diff --git a/src/webview/components/ClusterCard.tsx b/src/webview/components/ClusterCard.tsx
new file mode 100644
index 0000000..729e5db
--- /dev/null
+++ b/src/webview/components/ClusterCard.tsx
@@ -0,0 +1,47 @@
+import * as React from 'react';
+import { PanelNote } from '../../types/panel';
+
+interface ClusterCardProps {
+ title: string;
+ noteIndices: number[];
+ notes: PanelNote[];
+ isNoise?: boolean;
+}
+
+export const ClusterCard: React.FC = ({ title, noteIndices, notes, isNoise }) => {
+ const [isExpanded, setIsExpanded] = React.useState(false);
+
+ const handleHeaderClick = () => {
+ setIsExpanded((prev) => !prev);
+ };
+
+ const handleNoteClick = (noteId: string) => {
+ webviewApi.postMessage({ type: 'openNote', noteId });
+ };
+
+ const count = noteIndices.length;
+ const countLabel = count === 1 ? '1 note' : `${count} notes`;
+
+ return (
+
+
+
+ {title}
+
+
{countLabel}
+
+
+
+ {noteIndices.map((idx) => {
+ const note = notes[idx];
+ if (!note) return null;
+ return (
+
handleNoteClick(note.noteId)}>
+ {note.title || 'Untitled'}
+
+ );
+ })}
+
+
+ );
+};
diff --git a/src/webview/components/EmptyState.tsx b/src/webview/components/EmptyState.tsx
new file mode 100644
index 0000000..e7004fd
--- /dev/null
+++ b/src/webview/components/EmptyState.tsx
@@ -0,0 +1,10 @@
+import * as React from 'react';
+
+export const EmptyState: React.FC = () => {
+ return (
+
+
No categories yet
+
Click Run to categorize your notes using on-device AI.
+
+ );
+};
diff --git a/src/webview/components/Header.tsx b/src/webview/components/Header.tsx
new file mode 100644
index 0000000..de88e6c
--- /dev/null
+++ b/src/webview/components/Header.tsx
@@ -0,0 +1,17 @@
+import * as React from 'react';
+
+interface HeaderProps {
+ isRunning: boolean;
+ onRun: () => void;
+}
+
+export const Header: React.FC = ({ isRunning, onRun }) => {
+ return (
+
+
Note Categorizer
+
+
+ );
+};
diff --git a/src/webview/components/Navigation.tsx b/src/webview/components/Navigation.tsx
new file mode 100644
index 0000000..1df0ddd
--- /dev/null
+++ b/src/webview/components/Navigation.tsx
@@ -0,0 +1,37 @@
+import * as React from 'react';
+import { useAppState, ViewType } from '../context/AppStateContext';
+
+export const Navigation: React.FC = () => {
+ const { activeView, setView, strategies } = useAppState();
+
+ const hasResults = strategies && strategies.length > 0;
+
+ const handleTabClick = (view: ViewType) => {
+ setView(view);
+ };
+
+ return (
+
+
+ {hasResults && (
+
+ )}
+
+
+ );
+};
diff --git a/src/webview/components/ProgressBar.tsx b/src/webview/components/ProgressBar.tsx
new file mode 100644
index 0000000..f117fcf
--- /dev/null
+++ b/src/webview/components/ProgressBar.tsx
@@ -0,0 +1,27 @@
+import * as React from 'react';
+import { ProgressState } from '../../types/panel';
+
+interface ProgressBarProps {
+ statusText: string;
+ progress: ProgressState;
+}
+
+export const ProgressBar: React.FC = ({ statusText, progress }) => {
+ const percent = progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0;
+ const progressLabelParts = [];
+ if (progress.cached > 0) progressLabelParts.push(`${progress.cached} cached`);
+ if (progress.skipped > 0) progressLabelParts.push(`${progress.skipped} skipped`);
+
+ return (
+
+
{statusText}
+
+
+ {progress.total > 0 ? `${progress.current}/${progress.total} notes` : ''}
+ {progressLabelParts.join(' · ')}
+
+
+ );
+};
diff --git a/src/webview/components/StrategySection.tsx b/src/webview/components/StrategySection.tsx
new file mode 100644
index 0000000..c0711c5
--- /dev/null
+++ b/src/webview/components/StrategySection.tsx
@@ -0,0 +1,62 @@
+import * as React from 'react';
+import { BenchmarkResult } from '../../types/panel';
+
+interface StrategySectionProps {
+ strategies: BenchmarkResult[];
+ selectedStrategyIndex: number;
+ onStrategyChange: (index: number) => void;
+}
+
+/** Shortens 'hdbscan-5-ms2' → 'hdbscan-5' for pill labels */
+function abbreviateStrategy(name: string): string {
+ if (!name) return '';
+ const parts = name.split('-');
+ return parts.length > 2 ? parts[0] + '-' + parts[1] : name;
+}
+
+export const StrategySection: React.FC = ({
+ strategies,
+ selectedStrategyIndex,
+ onStrategyChange,
+}) => {
+ const selectedStrategy = strategies[selectedStrategyIndex];
+ if (!selectedStrategy) return null;
+
+ const handleSelectChange = (e: React.ChangeEvent) => {
+ onStrategyChange(parseInt(e.target.value, 10));
+ };
+
+ return (
+
+
+ Strategy:
+
+
+
+
+ Score: {selectedStrategy.silhouetteScore.toFixed(2)} · {selectedStrategy.clusterCount}{' '}
+ clusters
+ {selectedStrategy.outlierCount > 0 ? ` · ${selectedStrategy.outlierCount} noise` : ''}
+
+
+
+ {strategies.map((s, idx) => (
+
+ {abbreviateStrategy(s.strategyName)}: {s.silhouetteScore.toFixed(2)}
+
+ ))}
+
+
+ );
+};
diff --git a/src/webview/context/AppStateContext.tsx b/src/webview/context/AppStateContext.tsx
new file mode 100644
index 0000000..220b326
--- /dev/null
+++ b/src/webview/context/AppStateContext.tsx
@@ -0,0 +1,155 @@
+import * as React from 'react';
+import { PanelNote, BenchmarkResult, ProgressState } from '../../types/panel';
+
+const POLL_INTERVAL_MS = 500;
+
+export type ViewType = 'idle' | 'dashboard' | 'history' | 'settings';
+
+interface AppStateContextType {
+ isRunning: boolean;
+ statusText: string;
+ progress: ProgressState;
+ error: string | null;
+ strategies: BenchmarkResult[];
+ notes: PanelNote[];
+ selectedStrategyIndex: number;
+ activeView: ViewType;
+ runPipeline: () => void;
+ changeStrategy: (index: number) => void;
+ setView: (view: ViewType) => void;
+}
+
+const AppStateContext = React.createContext(undefined);
+
+export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+ const [isRunning, setIsRunning] = React.useState(false);
+ const [statusText, setStatusText] = React.useState('');
+ const [progress, setProgress] = React.useState({
+ current: 0,
+ total: 0,
+ cached: 0,
+ skipped: 0,
+ });
+ const [error, setError] = React.useState(null);
+ const [strategies, setStrategies] = React.useState([]);
+ const [notes, setNotes] = React.useState([]);
+ const [selectedStrategyIndex, setSelectedStrategyIndex] = React.useState(0);
+ const [activeView, setActiveView] = React.useState('idle');
+
+ const pollIntervalRef = React.useRef(null);
+
+ const stopPolling = React.useCallback(() => {
+ if (pollIntervalRef.current) {
+ clearInterval(pollIntervalRef.current);
+ pollIntervalRef.current = null;
+ }
+ }, []);
+
+ const handlePollResponse = React.useCallback(
+ (msg: any) => {
+ if (!msg || !msg.type) return;
+
+ switch (msg.type) {
+ case 'status':
+ setStatusText(msg.text || '');
+ break;
+
+ case 'progress':
+ setProgress({
+ current: msg.current || 0,
+ total: msg.total || 0,
+ cached: msg.cached || 0,
+ skipped: msg.skipped || 0,
+ });
+ break;
+
+ case 'results':
+ stopPolling();
+ setIsRunning(false);
+ setStrategies(msg.strategies || []);
+ setNotes(msg.notes || []);
+ setSelectedStrategyIndex(0);
+ setError(null);
+ setActiveView('dashboard');
+ break;
+
+ case 'error':
+ stopPolling();
+ setIsRunning(false);
+ setError(msg.message || 'An unknown error occurred.');
+ break;
+ }
+ },
+ [stopPolling],
+ );
+
+ const startPolling = React.useCallback(() => {
+ stopPolling();
+ pollIntervalRef.current = setInterval(async () => {
+ const state = await webviewApi.postMessage({ type: 'poll' });
+ if (state) {
+ handlePollResponse(state);
+ }
+ }, POLL_INTERVAL_MS);
+ }, [stopPolling, handlePollResponse]);
+
+ React.useEffect(() => {
+ return () => {
+ stopPolling();
+ };
+ }, [stopPolling]);
+
+ const runPipeline = async () => {
+ setIsRunning(true);
+ setStatusText('Starting pipeline...');
+ setProgress({ current: 0, total: 0, cached: 0, skipped: 0 });
+ setStrategies([]);
+ setNotes([]);
+ setError(null);
+ setActiveView('idle');
+ try {
+ await webviewApi.postMessage({ type: 'run' });
+ } catch (err) {
+ setError('Failed to start pipeline: ' + String(err));
+ setIsRunning(false);
+ return;
+ }
+ startPolling();
+ };
+
+ const changeStrategy = (index: number) => {
+ setSelectedStrategyIndex(index);
+ };
+
+ const setView = (view: ViewType) => {
+ setActiveView(view);
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useAppState = () => {
+ const context = React.useContext(AppStateContext);
+ if (context === undefined) {
+ throw new Error('useAppState must be used within an AppStateProvider');
+ }
+ return context;
+};
diff --git a/src/webview/globals.d.ts b/src/webview/globals.d.ts
new file mode 100644
index 0000000..afc2125
--- /dev/null
+++ b/src/webview/globals.d.ts
@@ -0,0 +1,17 @@
+import type { WebviewMessage, PanelMessage } from '../types/panel';
+
+/**
+ * Joplin injects this global into panel webviews at runtime.
+ * postMessage sends a WebviewMessage to the plugin's onMessage handler
+ * and returns the handler's response (PanelMessage or undefined).
+ */
+interface JoplinWebviewApi {
+ postMessage(message: WebviewMessage): Promise;
+}
+
+declare global {
+ // eslint-disable-next-line no-var
+ var webviewApi: JoplinWebviewApi;
+}
+
+export {};
diff --git a/src/webview/pages/DashboardPage.tsx b/src/webview/pages/DashboardPage.tsx
new file mode 100644
index 0000000..3e84794
--- /dev/null
+++ b/src/webview/pages/DashboardPage.tsx
@@ -0,0 +1,52 @@
+import * as React from 'react';
+import { useAppState } from '../context/AppStateContext';
+import { Header } from '../components/Header';
+import { StrategySection } from '../components/StrategySection';
+import { ClusterCard } from '../components/ClusterCard';
+
+export const DashboardPage: React.FC = () => {
+ const { isRunning, runPipeline, strategies, selectedStrategyIndex, changeStrategy, notes } = useAppState();
+
+ const selectedStrategy = strategies[selectedStrategyIndex];
+
+ const clusters: { [key: number]: number[] } = {};
+ const noise: number[] = [];
+
+ if (selectedStrategy) {
+ selectedStrategy.assignments.forEach((clusterId, noteIndex) => {
+ if (clusterId === -1) {
+ noise.push(noteIndex);
+ } else {
+ if (!clusters[clusterId]) {
+ clusters[clusterId] = [];
+ }
+ clusters[clusterId].push(noteIndex);
+ }
+ });
+ }
+
+ const sortedClusterIds = Object.keys(clusters)
+ .map(Number)
+ .sort((a, b) => clusters[b].length - clusters[a].length);
+
+ return (
+
+
+
+
+
+
+ {sortedClusterIds.map((id, idx) => (
+
+ ))}
+ {noise.length > 0 && (
+
+ )}
+
+
+ );
+};
diff --git a/src/webview/pages/EmptyStatePage.tsx b/src/webview/pages/EmptyStatePage.tsx
new file mode 100644
index 0000000..3e003f7
--- /dev/null
+++ b/src/webview/pages/EmptyStatePage.tsx
@@ -0,0 +1,16 @@
+import * as React from 'react';
+import { useAppState } from '../context/AppStateContext';
+import { Header } from '../components/Header';
+import { ProgressBar } from '../components/ProgressBar';
+import { EmptyState } from '../components/EmptyState';
+
+export const EmptyStatePage: React.FC = () => {
+ const { isRunning, runPipeline, statusText, progress } = useAppState();
+
+ return (
+
+ );
+};
diff --git a/src/webview/pages/HistoryPage.tsx b/src/webview/pages/HistoryPage.tsx
new file mode 100644
index 0000000..849d150
--- /dev/null
+++ b/src/webview/pages/HistoryPage.tsx
@@ -0,0 +1,13 @@
+import * as React from 'react';
+
+export const HistoryPage: React.FC = () => {
+ return (
+
+
Change Log
+
+ A history of categorization runs and note changes will be displayed here once persistence is implemented
+ in a future update.
+
+
+ );
+};
diff --git a/src/webview/pages/SettingsPage.tsx b/src/webview/pages/SettingsPage.tsx
new file mode 100644
index 0000000..4b04715
--- /dev/null
+++ b/src/webview/pages/SettingsPage.tsx
@@ -0,0 +1,27 @@
+import * as React from 'react';
+
+export const SettingsPage: React.FC = () => {
+ return (
+
+
Settings
+
+ Model settings and clustering parameters will be configurable here in a future version.
+
+
+
Default Configuration:
+
+ • Model: ONNX BGE-Micro-v2
+
+
+ • Metric: Cosine Similarity
+
+
+ • Limit: 200 Tokens/Chunk
+
+
+ • Strategies: K-Means, K-Medoids, HDBSCAN
+
+
+
+ );
+};
diff --git a/src/webview/panel.css b/src/webview/panel.css
new file mode 100644
index 0000000..77de1f6
--- /dev/null
+++ b/src/webview/panel.css
@@ -0,0 +1,418 @@
+:root {
+ --accent: #4a90d9;
+}
+
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+html,
+body,
+#root {
+ height: 100vh;
+}
+
+body {
+ font-family: var(--joplin-font-family);
+ font-size: var(--joplin-font-size);
+ color: var(--joplin-color);
+ background-color: var(--joplin-background-color);
+ line-height: 1.5;
+ overflow: hidden;
+}
+
+.panel-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 14px;
+ border-bottom: 1px solid var(--joplin-divider-color);
+ background: var(--joplin-background-color);
+ position: sticky;
+ top: 0;
+ z-index: 10;
+}
+
+.panel-header-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-weight: 600;
+ font-size: 1.05em;
+}
+
+.btn-run {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 6px 14px;
+ border: none;
+ border-radius: 6px;
+ background: var(--accent);
+ color: #fff;
+ font-size: 0.85em;
+ font-weight: 600;
+ font-family: inherit;
+ cursor: pointer;
+ transition: opacity 0.2s, transform 0.15s;
+}
+
+.btn-run:hover {
+ opacity: 0.88;
+ transform: translateY(-1px);
+}
+
+.btn-run:active {
+ transform: translateY(0);
+}
+
+.btn-run:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ transform: none;
+}
+
+.status-bar {
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--joplin-divider-color);
+ display: none;
+}
+
+.status-bar.visible {
+ display: block;
+}
+
+.status-text {
+ font-size: 0.85em;
+ opacity: 0.75;
+ margin-bottom: 6px;
+}
+
+.progress-container {
+ width: 100%;
+ height: 6px;
+ background: var(--joplin-divider-color);
+ border-radius: 3px;
+ overflow: hidden;
+ margin-bottom: 6px;
+}
+
+.progress-fill {
+ height: 100%;
+ background: var(--accent);
+ border-radius: 3px;
+ width: 0%;
+ transition: width 0.35s ease;
+}
+
+.progress-label {
+ font-size: 0.8em;
+ opacity: 0.6;
+ display: flex;
+ justify-content: space-between;
+}
+
+.strategy-section {
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--joplin-divider-color);
+ display: none;
+}
+
+.strategy-section.visible {
+ display: block;
+}
+
+.strategy-selector-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+
+.strategy-selector-label {
+ font-size: 0.82em;
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.strategy-select {
+ flex: 1;
+ padding: 4px 8px;
+ border: 1px solid var(--joplin-divider-color);
+ border-radius: 5px;
+ background: var(--joplin-background-color);
+ color: var(--joplin-color);
+ font-size: 0.82em;
+ font-family: inherit;
+ cursor: pointer;
+ transition: border-color 0.2s;
+}
+
+.strategy-select:hover,
+.strategy-select:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+
+.strategy-score {
+ font-size: 0.82em;
+ opacity: 0.7;
+ margin-bottom: 6px;
+}
+
+.strategy-score strong {
+ color: var(--accent);
+ opacity: 1;
+}
+
+.strategy-pills {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.strategy-pill {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 10px;
+ font-size: 0.75em;
+ background: var(--joplin-divider-color);
+ opacity: 0.8;
+ white-space: nowrap;
+}
+
+.strategy-pill.active {
+ background: var(--accent);
+ color: #fff;
+ opacity: 1;
+ font-weight: 600;
+}
+
+.cluster-list {
+ padding: 6px 0;
+ display: none;
+}
+
+.cluster-list.visible {
+ display: block;
+}
+
+.cluster-card {
+ border-bottom: 1px solid var(--joplin-divider-color);
+}
+
+.cluster-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 14px;
+ cursor: pointer;
+ transition: background-color 0.15s;
+ user-select: none;
+}
+
+.cluster-header:hover {
+ background: var(--joplin-background-color-hover);
+}
+
+.cluster-header-left {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+
+.cluster-title {
+ font-size: 0.9em;
+ font-weight: 600;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.cluster-count {
+ font-size: 0.78em;
+ opacity: 0.55;
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.cluster-chevron {
+ width: 0;
+ height: 0;
+ border-top: 4px solid transparent;
+ border-bottom: 4px solid transparent;
+ border-left: 5px solid currentColor;
+ opacity: 0.5;
+ transition: transform 0.2s ease;
+ flex-shrink: 0;
+ margin-left: 4px;
+}
+
+.cluster-card.expanded .cluster-chevron {
+ transform: rotate(90deg);
+}
+
+.cluster-notes {
+ max-height: 0;
+ overflow: hidden;
+ transition: max-height 0.3s ease;
+}
+
+.cluster-card.expanded .cluster-notes {
+ max-height: 5000px;
+}
+
+.note-item {
+ display: flex;
+ align-items: center;
+ padding: 6px 14px 6px 28px;
+ cursor: pointer;
+ transition: background-color 0.12s;
+ font-size: 0.84em;
+}
+
+.note-item:hover {
+ background: var(--joplin-background-color-hover);
+}
+
+.note-title {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.cluster-card.noise .cluster-title {
+ opacity: 0.7;
+}
+
+.empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 48px 24px;
+ text-align: center;
+}
+
+.empty-title {
+ font-size: 1em;
+ font-weight: 600;
+ margin-bottom: 6px;
+}
+
+.empty-subtitle {
+ font-size: 0.84em;
+ opacity: 0.55;
+ line-height: 1.5;
+ max-width: 280px;
+}
+
+.error-banner {
+ padding: 10px 14px;
+ background: var(--joplin-color-error);
+ color: #fff;
+ font-size: 0.84em;
+ display: none;
+}
+
+.error-banner.visible {
+ display: block;
+}
+
+/* --- Container & Navigation Layout --- */
+
+.panel-container {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+ overflow: hidden;
+}
+
+.panel-main {
+ flex: 1;
+ overflow-y: auto;
+ min-height: 0;
+}
+
+.panel-navigation {
+ display: flex;
+ background: var(--joplin-background-color);
+ border-bottom: 1px solid var(--joplin-divider-color);
+ width: 100%;
+}
+
+.nav-tab {
+ flex: 1;
+ padding: 10px 14px;
+ background: transparent;
+ border: none;
+ border-bottom: 2px solid transparent;
+ color: var(--joplin-color);
+ opacity: 0.65;
+ font-family: inherit;
+ font-size: 0.85em;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ text-align: center;
+}
+
+.nav-tab:hover {
+ opacity: 0.9;
+ background: var(--joplin-background-color-hover);
+}
+
+.nav-tab.active {
+ color: var(--accent);
+ border-bottom-color: var(--accent);
+ font-weight: 600;
+}
+
+/* --- Scrollbar Customization --- */
+
+::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--joplin-divider-color);
+ border-radius: 3px;
+ transition: background-color 0.2s ease;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--accent);
+}
+
+/* --- Config Card (Settings Page) --- */
+
+.config-card {
+ text-align: left;
+ font-size: 0.82em;
+ opacity: 0.7;
+ border: 1px solid var(--joplin-divider-color);
+ border-radius: 6px;
+ padding: 12px;
+ width: 100%;
+ max-width: 300px;
+ background: var(--joplin-background-color-hover);
+}
+
+.config-card-header {
+ font-weight: 600;
+ margin-bottom: 6px;
+ border-bottom: 1px solid var(--joplin-divider-color);
+ padding-bottom: 4px;
+}
+
+.config-card-item {
+ margin: 4px 0;
+}
diff --git a/src/webview/panel.tsx b/src/webview/panel.tsx
new file mode 100644
index 0000000..36f14e5
--- /dev/null
+++ b/src/webview/panel.tsx
@@ -0,0 +1,49 @@
+import * as React from 'react';
+import { createRoot } from 'react-dom/client';
+import { AppStateProvider, useAppState } from './context/AppStateContext';
+import { Navigation } from './components/Navigation';
+import { DashboardPage } from './pages/DashboardPage';
+import { EmptyStatePage } from './pages/EmptyStatePage';
+import { HistoryPage } from './pages/HistoryPage';
+import { SettingsPage } from './pages/SettingsPage';
+
+const AppContent: React.FC = () => {
+ const { activeView, error } = useAppState();
+
+ return (
+
+
+
+ {error &&
Error: {error}
}
+
+
+ {activeView === 'idle' && }
+ {activeView === 'dashboard' && }
+ {activeView === 'history' && }
+ {activeView === 'settings' && }
+
+
+ );
+};
+
+const App: React.FC = () => {
+ return (
+
+
+
+ );
+};
+
+function init() {
+ const container = document.getElementById('root');
+ if (container) {
+ const root = createRoot(container);
+ root.render();
+ }
+}
+
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init);
+} else {
+ init();
+}