Files
Daniele 505f2e95c1
Nightly Build / build (push) Successful in 7m51s
feat(chat): view context — tell the assistant what you're looking at
An eye next to the paperclip shares what the user has open with their next
message: the page, the folder being browsed, the file open in the viewer and
any highlighted passage (line numbers where a source view exists), plus which
entity a detail page is about. The bag is client-authored {label, value} pairs
in English — the backend only clamps (chars, never bytes), neutralizes the
harness tag and renders one <system-extra> block per message, deduped
consecutively so it appears exactly when the view changed. On by default,
per-device toggle, hover/tap to preview, a chip on every sent message;
docs/view-context.md for users, an updated harness.md clause for the model.
2026-08-23 20:53:30 +01:00

500 lines
21 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js';
import { t, I18nMixin } from '../lib/i18n.js';
// Shared with the view-context store: one reading of the hash, so the page the
// assistant is told about is always the page the menu highlights.
import { pageFromHash } from '../lib/routes.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 1090 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' },
// Everything this person can reach on disk, plus their two memory stores.
// Distinct from "Shared folders" below, which is the admin's CRUD *over* one
// kind of them — hence the two different words in the copy.
{ id: 'files', group: 'workspace', priority: 45, icon: 'folder2-open', labelKey: 'nav.files' },
{ 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', 'marketplace'] },
{ id: 'agents', group: 'extensions', priority: 30, icon: 'people', labelKey: 'nav.agents' },
// The background agents the instance runs for you. Visible to everyone: the
// run log is the caller's own, so there is nothing here to gate on a role.
{ id: 'system-agents', group: 'extensions', priority: 40, icon: 'robot', labelKey: 'nav.system_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: 'plugins', group: 'config', priority: 70, icon: 'puzzle-fill', labelKey: 'nav.plugins', adminOnly: true, aliases: ['plugin-catalog', 'plugin-detail'] },
{ 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 },
];
// 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';
// Icon-only (minimized) sidebar, persisted per browser: only the menu icons stay
// visible, still clickable; labels, section headers and submenus disappear.
const MINIMIZE_KEY = 'sidebar-minimized';
// The projects NAV entry, surfaced in the simplified interface too (projects are
// membership-gated, not capability-gated, so a simple-mode member can own/share
// them just like anyone else).
const PROJECTS_NAV = NAV.find((i) => i.id === 'projects');
export class AppSidebar extends I18nMixin(LightElement) {
static properties = {
_activePage: { state: true },
_tasksSection: { state: true },
_inboxCount: { state: true },
_debugMode: { state: true },
_recentProjects: { state: true },
_me: { state: true },
_pluginPages: { state: true },
_collapsed: { state: true },
_minimized: { state: true },
};
constructor() {
super();
this._activePage = null;
this._tasksSection = 'running';
this._inboxCount = 0;
this._pollTimer = null;
this._debugMode = false;
this._recentProjects = [];
this._me = null;
this._pluginPages = [];
this._collapsed = { config: true, dev: true };
this._minimized = false;
}
connectedCallback() {
super.connectedCallback();
window.addEventListener('popstate', (e) => {
const page = e.state?.page ?? this._pageFromHash();
if (page === 'tasks') this._tasksSection = this._tasksSectionFromHash();
this._applyPage(page);
});
window.addEventListener('hashchange', () => {
const page = this._pageFromHash();
if (page === 'tasks') this._tasksSection = this._tasksSectionFromHash();
this._applyPage(page);
});
window.addEventListener('inbox-count', (e) => {
this._inboxCount = e.detail.count;
});
window.addEventListener('debug-mode-change', (e) => {
this._debugMode = e.detail.enabled;
});
// On load: home (root) if no hash, otherwise the matching page
setTimeout(() => {
const page = this._pageFromHash();
if (page === 'tasks') this._tasksSection = this._tasksSectionFromHash();
this._applyPage(page);
}, 0);
// Poll inbox count independently of whether the page is open. The 60 s
// interval is only a fallback: `inbox-changed` (pushed over the chat WS
// when any session raises/settles a pending item) refreshes it live.
this._pollInbox();
this._pollTimer = setInterval(() => this._pollInbox(), 60000);
window.addEventListener('inbox-changed', () => this._pollInbox());
this._loadCollapsed();
try { this._minimized = localStorage.getItem(MINIMIZE_KEY) === '1'; } catch { /* keep default */ }
this._loadDebugMode();
this._loadRecentProjects();
this._loadMe();
this._loadPluginPages();
window.addEventListener('plugins-changed', () => this._loadPluginPages());
window.addEventListener('project-updated', () => this._loadRecentProjects());
}
// Only for deciding which links to draw. Hiding a link is not access control —
// every admin route is capability-gated server-side (`require_cap`), so this
// only avoids offering a door that would answer 403.
async _loadMe() {
try {
const res = await fetch('/api/auth/me');
if (res.ok) this._me = await res.json();
} catch { /* ignore */ }
}
disconnectedCallback() {
super.disconnectedCallback();
clearInterval(this._pollTimer);
}
// The minimized state is a class on the host element (`app-sidebar`), since
// the width itself shrinks and the stylesheet keys off it.
updated(changed) {
if (changed.has('_minimized')) {
this.classList.toggle('sidebar-minimized', this._minimized);
try { localStorage.setItem(MINIMIZE_KEY, this._minimized ? '1' : '0'); } catch { /* ignore */ }
}
}
_toggleMinimized() {
this._minimized = !this._minimized;
}
// 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');
if (res.ok) this._debugMode = (await res.json()).enabled;
} catch { /* ignore */ }
}
async _loadRecentProjects() {
try {
const res = await fetch('/api/projects');
if (!res.ok) return;
const projects = await res.json();
this._recentProjects = projects
.slice()
.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at))
.slice(0, 5);
} catch { /* ignore */ }
}
async _openProjectChat(projectId, projectName, e) {
e.preventDefault();
e.stopPropagation();
try {
const res = await fetch(`/api/projects/${projectId}/session`, { method: 'POST' });
if (!res.ok) return;
const { source, session_id } = await res.json();
window.dispatchEvent(new CustomEvent('project-chat-open', {
detail: { source, session_id, label: projectName },
}));
} catch { /* ignore */ }
}
async _pollInbox() {
try {
const res = await fetch('/api/inbox');
if (res.ok) {
const data = await res.json();
this._inboxCount = data.total ?? 0;
}
} catch { /* ignore */ }
}
// Plugin-contributed menu entries (`GET /api/plugins/pages`, per-user).
// Refetched on `plugins-changed` (fired by the plugins admin pages after an
// enable/disable) so entries appear/disappear without a reload.
async _loadPluginPages() {
try {
const res = await fetch('/api/plugins/pages');
if (res.ok) this._pluginPages = await res.json();
} catch { /* ignore */ }
}
_pageFromHash() {
return pageFromHash();
}
_tasksSectionFromHash() {
const parts = location.hash.slice(1).split('/');
if (parts[0] === 'tasks' && parts[1]) {
return ['running', 'cron', 'scheduled', 'history'].includes(parts[1]) ? parts[1] : 'running';
}
return 'running';
}
_applyPage(page) {
this._activePage = page;
window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page } }));
}
_togglePage(page, e) {
e.preventDefault();
if (page === 'home') {
history.pushState({ page: 'home' }, '', location.pathname + location.search);
this._applyPage('home');
return;
}
if (this._activePage === page) {
// In a sub-section (e.g. #models/image) → go back to page root
if (location.hash.slice(1) !== page) {
history.pushState({ page }, '', '#' + page);
this._applyPage(page);
}
return;
}
history.pushState({ page }, '', '#' + page);
this._applyPage(page);
}
_navigateTasksSection(sec, e) {
e.preventDefault();
this._tasksSection = sec;
history.pushState({ page: 'tasks', section: sec }, '', '#tasks/' + sec);
if (this._activePage !== 'tasks') {
this._applyPage('tasks');
} else {
// page already open — tell the TasksPage to switch section
window.dispatchEvent(new CustomEvent('tasks-section-change', { detail: { section: sec } }));
}
}
_openTaskManager(e) {
e.preventDefault();
if (this._activePage === 'tasks') return; // already open, submenu visible
const sec = this._tasksSection || 'cron';
history.pushState({ page: 'tasks', section: sec }, '', '#tasks/' + sec);
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
// Minimized: section labels are hidden, so a collapsible section could never
// be re-opened — render every entry (icons only) regardless of its state.
const collapsed = !this._minimized && group.collapsible && this._collapsed[group.id];
const header = group.collapsible
? html`
<button class="sidebar-section-label is-collapsible"
@click=${() => this._toggleSection(group.id)}>
<span>${t(group.labelKey)}</span>
<i class="bi bi-chevron-${collapsed ? 'down' : 'up'}"></i>
</button>`
: html`<div class="sidebar-section-label"><span>${t(group.labelKey)}</span></div>`;
return html`
<div class="sidebar-section">
${header}
${collapsed ? nothing : entries.map((e) => this._renderEntry(e))}
</div>`;
}
_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`
<a href="#${item.id}" class="sidebar-link ${this._isActive(item) ? 'active' : ''}"
title=${this._minimized ? t(item.labelKey) : nothing}
@click=${(e) => this._togglePage(item.id, e)}>
<i class="bi bi-${item.icon}"></i>
<span class="sidebar-link-name">${t(item.labelKey)}</span>
</a>`;
}
_renderHome() {
return html`
<a href="#" class="sidebar-link ${this._activePage === 'home' ? 'active' : ''}"
title=${this._minimized ? t('nav.chat') : nothing}
@click=${(e) => this._togglePage('home', e)}>
<i class="bi bi-chat-dots"></i>
<span class="sidebar-link-name">${t('nav.chat')}</span>
</a>`;
}
_renderInbox(item) {
const min = this._minimized;
return html`
<a href="#inbox" class="sidebar-link ${this._isActive(item) ? 'active' : ''}"
title=${min ? t('nav.inbox') : nothing}
@click=${(e) => this._togglePage('inbox', e)}>
<i class="bi bi-inbox"></i>
${min && this._inboxCount > 0
? html`<span class="sidebar-icon-badge">${this._inboxCount}</span>`
: nothing}
<span class="sidebar-link-name">
${t('nav.inbox')}
${!min && this._inboxCount > 0
? html`<span class="badge bg-danger ms-1" style="font-size:0.65rem">${this._inboxCount}</span>`
: ''}
</span>
</a>`;
}
_renderPluginEntry(p) {
const route = `plugin/${p.plugin_id}/${p.page_id}`;
return html`
<a href="#${route}"
class="sidebar-link ${this._activePage === route ? 'active' : ''}"
title=${this._minimized ? p.title : nothing}
@click=${(e) => this._togglePage(route, e)}>
<i class="bi bi-${p.icon}"></i>
<span class="sidebar-link-name">${p.title}</span>
</a>`;
}
_renderTasksMenu() {
const active = this._activePage === 'tasks';
const sec = this._tasksSection;
return html`
<a href="#tasks/cron"
class="sidebar-link ${active ? 'active' : ''}"
title=${this._minimized ? t('nav.tasks') : nothing}
@click=${(e) => this._openTaskManager(e)}>
<i class="bi bi-lightning-charge"></i>
<span class="sidebar-link-name">${t('nav.tasks')}</span>
<i class="bi bi-chevron-${active ? 'up' : 'down'} sidebar-link-chevron"></i>
</a>
${active ? html`
<div class="sidebar-submenu">
<a href="#tasks/running"
class="sidebar-sublink ${sec === 'running' ? 'active' : ''}"
@click=${(e) => this._navigateTasksSection('running', e)}>
<i class="bi bi-activity"></i> ${t('nav.tasks.running')}
</a>
<a href="#tasks/cron"
class="sidebar-sublink ${sec === 'cron' ? 'active' : ''}"
@click=${(e) => this._navigateTasksSection('cron', e)}>
<i class="bi bi-repeat"></i> ${t('nav.tasks.cron')}
</a>
<a href="#tasks/scheduled"
class="sidebar-sublink ${sec === 'scheduled' ? 'active' : ''}"
@click=${(e) => this._navigateTasksSection('scheduled', e)}>
<i class="bi bi-clock"></i> ${t('nav.tasks.scheduled')}
</a>
<a href="#tasks/history"
class="sidebar-sublink ${sec === 'history' ? 'active' : ''}"
@click=${(e) => this._navigateTasksSection('history', e)}>
<i class="bi bi-journal-text"></i> ${t('nav.tasks.history')}
</a>
</div>
` : nothing}
`;
}
_renderRecentProjects() {
if (!this._recentProjects.length) return nothing;
return html`
<div class="sidebar-submenu">
${this._recentProjects.map(p => html`
<div class="sidebar-project-link"
@click=${(e) => { e.preventDefault(); history.pushState({ page: 'projects' }, '', '#projects'); this._applyPage('projects'); window.dispatchEvent(new CustomEvent('sidebar-open-project', { detail: { id: p.id } })); }}>
<i class="bi bi-folder2" style="font-size:0.78rem;opacity:0.65;flex-shrink:0"></i>
<span class="sidebar-project-name">${p.name}</span>
<button class="sidebar-project-chat-btn"
title=${t('topbar.open_chat')}
@click=${(e) => this._openProjectChat(p.id, p.name, e)}>
<i class="bi bi-chat-dots"></i>
</button>
</div>
`)}
</div>
`;
}
render() {
// Simplified interface (role attrs `ui_mode: "simple"`): chat, inbox and
// projects (self-service workspaces — membership-gated, not capability-gated),
// ungrouped. Hiding links is not access control — every route stays
// capability-gated server-side; this only shapes the nav for less technical
// members.
const simple = this._me?.ui_mode === 'simple';
return html`
<div class="sidebar-brand">
<img src="/assets/icons/icon-1024.png" alt="" class="sidebar-brand-icon" />
<span>${t('topbar.brand')}</span>
<button class="sidebar-collapse-btn"
title=${this._minimized ? t('nav.expand_sidebar') : t('nav.collapse_sidebar')}
@click=${() => this._toggleMinimized()}>
<i class="bi bi-chevron-double-${this._minimized ? 'right' : 'left'}"></i>
</button>
</div>
<hr class="sidebar-divider" />
<nav class="sidebar-nav">
${simple
? html`
${this._renderHome()}
${this._renderInbox({ id: 'inbox' })}
${PROJECTS_NAV ? this._renderEntry({ kind: 'core', item: PROJECTS_NAV }) : nothing}
`
: GROUPS.map((g) => this._renderGroup(g))}
</nav>
`;
}
}