feat: plugin framework hardening — dynamic UI, persistent state, enriched metadata#106
Conversation
Six user stories covering plugin-aware type dropdown, persistent plugin state, enriched metadata with capability flags, dynamic settings cards, frontend PluginUI registry, and plugin sync contracts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PLUGIN_METADATA now declares has_tab, tab_label, tab_id, has_timer_types, has_counts, has_sync, has_import_export, and has_history_decorators. These flags drive dynamic UI integration instead of hardcoded plugin ID checks in core code. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
togglePlugin() now saves plugin_state_<id> to IndexedDB after toggling. On boot, Phase 2b reads saved states and re-applies them to the server plugin registry, then refreshes extension tab visibility. Also adds Storage.saveSetting(key, value) convenience method. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
updateExtensionTabs() now loops over all extension plugins with
has_tab: true instead of hardcoding a check for 'todos'. Tab/view/card
IDs follow the convention tab-{tab_id}, {tab_id}-view, {tab_id}-settings-card.
Renamed todo-settings-card to todos-settings-card for consistency.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Plugin cards in Settings now show: - Count badges for plugins with has_counts (e.g. "12 items") - Import/export buttons for plugins with has_import_export - Routed through exportPluginCSV/importPluginCSV dispatchers Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
populateTypeDropdowns() now queries /api/plugins for active plugins with has_timer_types and builds a _timerTypeProviders array. renderTypeDropdownTopLevel() and onTypeDropdownChange() loop over providers instead of checking hardcoded _typeDropdownTodosActive/_typeDropdownTodoLists globals. Future plugins (RT tickets, checklists) add themselves as providers — no changes to core dropdown code needed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
populateTypeDropdowns() was only called on settings load, not when toggling plugins. Disabling the Todos plugin left stale todo items in the Type dropdown until page refresh. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pomodoros created via todo linking have type "To-do" which isn't in the standard pomodoro_types list. The edit modal dropdown showed blank because the value had no matching option. Now prepends the type as an option if it's missing from the dropdown. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
History and weekly overview now reload alongside the Type dropdown when a plugin is enabled/disabled. Clears stale todoMap in History and ensures the weekly grid reflects plugin state changes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Edit modal now stashes the original linked_todo_id on the type select's data attribute when opening. On save, preserves it unless the user explicitly selects a different todo. Prevents data loss on edit. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request implements the "Plugin Framework Hardening" specification, introducing persistent plugin states via IndexedDB, dynamic rendering of plugin settings cards, and capability-driven extension tabs. However, the current implementation still contains several hardcoded references to the 'todos' plugin in the frontend. The review feedback focuses on decoupling this logic by introducing a generic frontend PluginUI registry to dynamically manage counts, timer type providers, and CSV import/export handlers, fully aligning the code with the architectural goals of the specification.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| let countBadge = ''; | ||
| if (isActive && plugin.has_counts) { | ||
| try { | ||
| const count = plugin.id === 'todos' ? (await Storage.getTodos()).length : 0; |
There was a problem hiding this comment.
To adhere to the plugin framework design goals (SC-004 and User Story 5), avoid hardcoding plugin-specific logic like plugin.id === 'todos'. Instead, query the count dynamically from the PluginUI registry.
| const count = plugin.id === 'todos' ? (await Storage.getTodos()).length : 0; | |
| const count = await PluginUI.getCount(plugin.id); |
| for (const plugin of timerTypePlugins) { | ||
| if (plugin.id === 'todos') { | ||
| const lists = await Storage.getTodoLists(); | ||
| const todos = (await Storage.getTodos()).filter(t => t.status === 'pending'); | ||
| _timerTypeProviders.push({ plugin, lists, todos }); | ||
| } | ||
| } |
There was a problem hiding this comment.
Instead of hardcoding the 'todos' plugin check, dynamically retrieve the timer type provider from the PluginUI registry. This allows any active plugin with has_timer_types to seamlessly inject its items.
| for (const plugin of timerTypePlugins) { | |
| if (plugin.id === 'todos') { | |
| const lists = await Storage.getTodoLists(); | |
| const todos = (await Storage.getTodos()).filter(t => t.status === 'pending'); | |
| _timerTypeProviders.push({ plugin, lists, todos }); | |
| } | |
| } | |
| for (const plugin of timerTypePlugins) { | |
| const providerFn = PluginUI.getTimerTypeProvider(plugin.id); | |
| if (providerFn) { | |
| const data = await providerFn(); | |
| _timerTypeProviders.push({ plugin, ...data }); | |
| } | |
| } |
| for (const provider of _timerTypeProviders) { | ||
| if (provider.plugin.id === 'todos' && provider.lists.length > 0) { | ||
| html += '<option disabled>──────────</option>'; | ||
| for (const list of provider.lists) { | ||
| const count = provider.todos.filter(t => t.list_id === list.id).length; | ||
| if (count === 0) continue; | ||
| html += `<option value="list:${list.id}">📋 ${escapeHtml(list.name)} (${count})</option>`; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Remove the hardcoded check for 'todos' here as well. Checking for the presence of provider.lists makes the rendering logic generic and reusable for other list-based plugins.
| for (const provider of _timerTypeProviders) { | |
| if (provider.plugin.id === 'todos' && provider.lists.length > 0) { | |
| html += '<option disabled>──────────</option>'; | |
| for (const list of provider.lists) { | |
| const count = provider.todos.filter(t => t.list_id === list.id).length; | |
| if (count === 0) continue; | |
| html += `<option value="list:${list.id}">📋 ${escapeHtml(list.name)} (${count})</option>`; | |
| } | |
| } | |
| } | |
| for (const provider of _timerTypeProviders) { | |
| if (provider.lists && provider.lists.length > 0) { | |
| html += '<option disabled>──────────</option>'; | |
| for (const list of provider.lists) { | |
| const count = provider.todos.filter(t => t.list_id === list.id).length; | |
| if (count === 0) continue; | |
| html += `<option value="list:${list.id}">📋 ${escapeHtml(list.name)} (${count})</option>`; | |
| } | |
| } | |
| } |
| const todosProvider = _timerTypeProviders.find(p => p.plugin.id === 'todos'); | ||
| if (!todosProvider) return; | ||
| const listTodos = todosProvider.todos.filter(t => t.list_id === listId); | ||
| const list = todosProvider.lists.find(l => l.id === listId); | ||
| const label = list ? list.name : 'To-do'; |
There was a problem hiding this comment.
Instead of searching specifically for the 'todos' provider, find the provider that contains the selected list ID. This decouples the cascading dropdown logic from any specific plugin ID.
| const todosProvider = _timerTypeProviders.find(p => p.plugin.id === 'todos'); | |
| if (!todosProvider) return; | |
| const listTodos = todosProvider.todos.filter(t => t.list_id === listId); | |
| const list = todosProvider.lists.find(l => l.id === listId); | |
| const label = list ? list.name : 'To-do'; | |
| const provider = _timerTypeProviders.find(p => p.lists && p.lists.some(l => l.id === listId)); | |
| if (!provider) return; | |
| const listTodos = provider.todos.filter(t => t.list_id === listId); | |
| const list = provider.lists.find(l => l.id === listId); |
| function exportPluginCSV(pluginId) { | ||
| if (pluginId === 'todos') return exportTodoCSV(); | ||
| } | ||
| function importPluginCSV(pluginId, input) { | ||
| if (pluginId === 'todos') return importTodoCSV(input); | ||
| } |
There was a problem hiding this comment.
To fully implement the frontend PluginUI registry specified in User Story 5 and FR-008, define a generic PluginUI registry here and register the built-in todos plugin contributions. This completely removes the hardcoded checks and makes the framework extensible for future plugins.
// ── PluginUI Registry ──────────────────────────────────────
const PluginUI = {
_countProviders: {},
_timerTypeProviders: {},
_csvHandlers: {},
registerCountProvider: function(id, fn) { this._countProviders[id] = fn; },
registerTimerTypeProvider: function(id, fn) { this._timerTypeProviders[id] = fn; },
registerCSVHandlers: function(id, expFn, impFn) { this._csvHandlers[id] = { export: expFn, import: impFn }; },
getCount: async function(id) { return this._countProviders[id] ? await this._countProviders[id]() : 0; },
getTimerTypeProvider: function(id) { return this._timerTypeProviders[id]; },
exportCSV: function(id) { if (this._csvHandlers[id]) return this._csvHandlers[id].export(); },
importCSV: function(id, input) { if (this._csvHandlers[id]) return this._csvHandlers[id].import(input); }
};
// Register built-in todos plugin UI contributions
PluginUI.registerCountProvider('todos', async () => (await Storage.getTodos()).length);
PluginUI.registerTimerTypeProvider('todos', async () => {
const lists = await Storage.getTodoLists();
const todos = (await Storage.getTodos()).filter(t => t.status === 'pending');
return { lists, todos };
});
PluginUI.registerCSVHandlers('todos', exportTodoCSV, importTodoCSV);
function exportPluginCSV(pluginId) {
return PluginUI.exportCSV(pluginId);
}
function importPluginCSV(pluginId, input) {
return PluginUI.importCSV(pluginId, input);
}
Summary
has_tab,has_timer_types,has_counts,has_sync,has_import_export,has_history_decorators'todos'checks)has_timer_typesvia a generic provider loopCloses #101
Test plan
🤖 Generated with Claude Code