diff --git a/crates/plugin-honcho/src/lib.rs b/crates/plugin-honcho/src/lib.rs index 42af13c..8944218 100644 --- a/crates/plugin-honcho/src/lib.rs +++ b/crates/plugin-honcho/src/lib.rs @@ -863,7 +863,9 @@ impl core_api::plugin::Plugin for HonchoPlugin { icon: "gear", entry: "web/config.js".into(), admin_only: true, - priority: 10, + // Sidebar priority: core "Your space" items live in 10–90, so + // plugin pages use ≥100 to land after them (see sidebar.js NAV). + priority: 120, }, PluginPage { page_id: "memory", @@ -871,7 +873,7 @@ impl core_api::plugin::Plugin for HonchoPlugin { icon: "stars", entry: "web/memory.js".into(), admin_only: false, - priority: 10, + priority: 130, }, ] } diff --git a/crates/plugin-mobile-connector/src/lib.rs b/crates/plugin-mobile-connector/src/lib.rs index ab7a1d1..80daaf0 100644 --- a/crates/plugin-mobile-connector/src/lib.rs +++ b/crates/plugin-mobile-connector/src/lib.rs @@ -321,7 +321,9 @@ impl Plugin for MobileConnectorPlugin { icon: "qr-code", entry: "web/pairing.js".into(), admin_only: true, - priority: 10, + // Sidebar priority: core "Your space" items live in 10–90, so + // plugin pages use ≥100 to land after them (see sidebar.js NAV). + priority: 100, }, PluginPage { page_id: "devices", @@ -329,7 +331,7 @@ impl Plugin for MobileConnectorPlugin { icon: "phone", entry: "web/devices.js".into(), admin_only: true, - priority: 20, + priority: 110, }, ] } diff --git a/web/components/sidebar.js b/web/components/sidebar.js index 4a026a9..0209f04 100644 --- a/web/components/sidebar.js +++ b/web/components/sidebar.js @@ -3,6 +3,66 @@ import { LightElement } from '../lib/base.js'; import { t, I18nMixin } from '../lib/i18n.js'; +// ── Navigation model ────────────────────────────────────────────────────────── +// The nav is data, not markup: every entry declares its `group` and a numeric +// `priority` (lower = higher up), and each group is rendered by sorting its +// entries on that key. Retuning the order is editing a number here, never moving +// JSX around. Plugin-contributed pages (`GET /api/plugins/pages`) carry their own +// `priority` (see `PluginPage`) and merge into the `workspace` group on the *same* +// number line — the convention is core items live in 10–90 and plugin pages ≥100, +// so a page lands after the daily items by default yet stays freely placeable. +// +// The split axis is **function**, not permission: `adminOnly`/`debugOnly` gate +// individual entries, and a whole section disappears when it has no visible entry +// for this user (so "Configuration" — all admin entries — vanishes for non-admins +// without a section-level role check). `aliases` lists extra `_activePage` values +// that should light the entry (e.g. the detail route paired with its list). +const NAV = [ + // Il tuo spazio — daily productivity. Visible to everyone (full mode). + { id: 'home', group: 'workspace', priority: 10, icon: 'chat-dots', labelKey: 'nav.chat' }, + { id: 'inbox', group: 'workspace', priority: 20, icon: 'inbox', labelKey: 'nav.inbox' }, + { id: 'dashboard', group: 'workspace', priority: 30, icon: 'speedometer2', labelKey: 'nav.dashboard' }, + { id: 'projects', group: 'workspace', priority: 40, icon: 'kanban', labelKey: 'nav.projects' }, + { id: 'tasks', group: 'workspace', priority: 50, icon: 'lightning-charge',labelKey: 'nav.tasks' }, + // Shared folders is admin-managed but *content*, so it lives with the daily + // items, not buried in Configuration — the link stays admin-gated per-entry. + { id: 'shared-folders', group: 'workspace', priority: 60, icon: 'folder-symlink', labelKey: 'nav.shared_folders', adminOnly: true }, + + // Estensioni — what the assistant is made of / can use. Visible to everyone; + // Agents is read-only for non-admins (editable only by the admin server-side). + { id: 'connectors', group: 'extensions', priority: 10, icon: 'plug', labelKey: 'nav.connectors', aliases: ['connector'] }, + { id: 'plugins', group: 'extensions', priority: 20, icon: 'puzzle', labelKey: 'nav.plugins' }, + { id: 'agents', group: 'extensions', priority: 30, icon: 'people', labelKey: 'nav.agents' }, + + // Configurazione — rarely-touched setup. Every entry is admin-only today, so + // the section is admin-only in effect via the empty-section rule. + { id: 'users', group: 'config', priority: 10, icon: 'person-badge', labelKey: 'nav.users', adminOnly: true }, + { id: 'roles', group: 'config', priority: 20, icon: 'tags', labelKey: 'nav.roles', adminOnly: true }, + { id: 'models', group: 'config', priority: 30, icon: 'cpu', labelKey: 'nav.models', adminOnly: true }, + { id: 'providers', group: 'config', priority: 40, icon: 'plug', labelKey: 'nav.providers', adminOnly: true }, + { id: 'approval', group: 'config', priority: 50, icon: 'shield-check', labelKey: 'nav.security', adminOnly: true }, + { id: 'plugin-catalog', group: 'config', priority: 70, icon: 'puzzle-fill', labelKey: 'nav.plugin_catalog', adminOnly: true, aliases: ['plugin-detail'] }, + { id: 'catalog', group: 'config', priority: 80, icon: 'journal-text', labelKey: 'nav.catalog', adminOnly: true, aliases: ['marketplace'] }, + { id: 'config', group: 'config', priority: 90, icon: 'gear', labelKey: 'nav.config', adminOnly: true }, + + // Sviluppo — debug surface, only with the debug flag on. + { id: 'llm-requests', group: 'dev', priority: 10, icon: 'journal-code', labelKey: 'nav.llm_requests', debugOnly: true }, + { id: 'tic', group: 'dev', priority: 20, icon: 'bell', labelKey: 'nav.tic', debugOnly: true }, +]; + +// Section order + which sections collapse. Configuration and Development are +// collapsible and closed by default (rarely touched); the two productivity +// sections are always open. +const GROUPS = [ + { id: 'workspace', labelKey: 'nav.section.workspace', collapsible: false }, + { id: 'extensions', labelKey: 'nav.section.extensions', collapsible: false }, + { id: 'config', labelKey: 'nav.section.config', collapsible: true }, + { id: 'dev', labelKey: 'nav.section.dev', collapsible: true }, +]; + +const COLLAPSE_KEY = 'sidebar-collapsed'; + + export class AppSidebar extends I18nMixin(LightElement) { static properties = { _activePage: { state: true }, @@ -12,6 +72,7 @@ export class AppSidebar extends I18nMixin(LightElement) { _recentProjects: { state: true }, _me: { state: true }, _pluginPages: { state: true }, + _collapsed: { state: true }, }; constructor() { @@ -24,6 +85,7 @@ export class AppSidebar extends I18nMixin(LightElement) { this._recentProjects = []; this._me = null; this._pluginPages = []; + this._collapsed = { config: true, dev: true }; } connectedCallback() { @@ -53,6 +115,7 @@ export class AppSidebar extends I18nMixin(LightElement) { // Poll inbox count independently of whether the page is open. this._pollInbox(); this._pollTimer = setInterval(() => this._pollInbox(), 10000); + this._loadCollapsed(); this._loadDebugMode(); this._loadRecentProjects(); this._loadMe(); @@ -76,6 +139,20 @@ export class AppSidebar extends I18nMixin(LightElement) { clearInterval(this._pollTimer); } + // Persisted per-section collapse state. Defaults (config + dev closed) apply + // until the user toggles a section, then their choice is remembered. + _loadCollapsed() { + try { + const saved = JSON.parse(localStorage.getItem(COLLAPSE_KEY) || '{}'); + this._collapsed = { config: true, dev: true, ...saved }; + } catch { /* keep defaults */ } + } + + _toggleSection(id) { + this._collapsed = { ...this._collapsed, [id]: !this._collapsed[id] }; + try { localStorage.setItem(COLLAPSE_KEY, JSON.stringify(this._collapsed)); } catch { /* ignore */ } + } + async _loadDebugMode() { try { const res = await fetch('/api/dev/debug_mode'); @@ -197,6 +274,107 @@ export class AppSidebar extends I18nMixin(LightElement) { this._applyPage('tasks'); } + // ── Entry-level helpers ───────────────────────────────────────────────────── + + _itemVisible(item) { + if (item.adminOnly && this._me?.role_id !== 'admin') return false; + if (item.debugOnly && !this._debugMode) return false; + return true; + } + + _isActive(item) { + if (this._activePage === item.id) return true; + return (item.aliases || []).includes(this._activePage); + } + + // Core entries of a group plus (workspace only) the plugin pages, merged on the + // shared `priority` line and sorted ascending (lower = higher up). + _entriesForGroup(groupId) { + const core = NAV + .filter((i) => i.group === groupId && this._itemVisible(i)) + .map((i) => ({ kind: 'core', priority: i.priority, item: i })); + const plugins = groupId === 'workspace' + ? this._pluginPages.map((p) => ({ kind: 'plugin', priority: p.priority ?? 100, page: p })) + : []; + return [...core, ...plugins].sort((a, b) => a.priority - b.priority); + } + + // ── Render helpers ────────────────────────────────────────────────────────── + + _renderGroup(group) { + const entries = this._entriesForGroup(group.id); + if (!entries.length) return nothing; // empty section → hidden + const collapsed = group.collapsible && this._collapsed[group.id]; + const header = group.collapsible + ? html` + ` + : html`
`; + return html` + `; + } + + _renderEntry(entry) { + if (entry.kind === 'plugin') return this._renderPluginEntry(entry.page); + const item = entry.item; + switch (item.id) { + case 'home': return this._renderHome(); + case 'inbox': return this._renderInbox(item); + case 'tasks': return this._renderTasksMenu(); + case 'projects': return html`${this._renderStdLink(item)}${this._renderRecentProjects()}`; + default: return this._renderStdLink(item); + } + } + + _renderStdLink(item) { + return html` + this._togglePage(item.id, e)}> + + + `; + } + + _renderHome() { + return html` + this._togglePage('home', e)}> + + + `; + } + + _renderInbox(item) { + return html` + this._togglePage('inbox', e)}> + + + `; + } + + _renderPluginEntry(p) { + const route = `plugin/${p.plugin_id}/${p.page_id}`; + return html` + this._togglePage(route, e)}> + + + `; + } + _renderTasksMenu() { const active = this._activePage === 'tasks'; const sec = this._tasksSection; @@ -235,23 +413,6 @@ export class AppSidebar extends I18nMixin(LightElement) { `; } - _renderPluginPages() { - if (!this._pluginPages.length) return nothing; - return html` -