Files
Skald-Circle/web/components/sidebar.js
T
Daniele 488c702517
Nightly Build / build (push) Successful in 5m36s
feat(files): a Files section over the caller's whole space
Until now file browsing existed only inside a project, and the two memory
stores were reachable only by the agent's tools. `#files` is the general
surface: home, both memory stores, the shared folders and projects the
caller belongs to, plus the read-only skills and docs trees.

The root is virtual, and that is the design. Anchoring at `~` is wrong:
the explorer reads host-side, while `shared/`, `projects/`, `skills/` and
`docs/` are bind mounts inside the container — a page rooted at the home
would show less than the user has with no way to reach the rest, and on
native Linux would show Docker's empty mountpoint stubs, a door that
appears to work and leads nowhere. So level 0 is a synthetic list from the
new `GET /api/files/roots`, serialized from the caller's `UserFs` plus the
two virtual memory roots. It sends `kind`, never a label: labels are copy
and get translated.

`GET /api/files/dir` now answers `{ path, can_write, entries }`, and a
memory path is classified before `resolve_view_path` (which refuses one)
and listed from `memory_docs`: one level derived from the flat key space
by the pure `memory_docs::immediate_children`, over a single query whose
unslashed prefix also spots an exact note as "not a directory". Memory is
read-only from the page — every writer routes through `resolve_view_path`,
and `shared-memory/*` is `@fs_write require` for the agent, so a button
that walks past that rule is a decision of its own.

The explorer moves out of projects into `shared/file-explorer.js`, taking
`root` + `rootLabel` and reading `can_write` from the listing rather than
from its host: writability changes per branch and comes from the same
`UserFs::can_write_to` the server rejects writes with, so the buttons
offered and the writes accepted cannot disagree. Deep-linking needed it
steerable without a two-way binding, hence `rel` in and
`explorer-navigate` out — the event fires only for a click, never for a
`rel` the host set, so echoing it back is a no-op.

The URL carries the agent path of the open folder in one parameter, the
same vocabulary the assistant uses, so a link is shareable and pasteable
into a conversation; which root it belongs to is derived, not stored.

docs/: a new files.md, plus two pages this made false — shared-folders.md
claimed in three places that a shared folder has no explorer, and
memory.md never said a user can now read their own notes.
2026-08-22 20:09:56 +01:00

512 lines
22 KiB
JavaScript
Raw 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';
// ── 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() {
const hash = location.hash.slice(1);
if (!hash) return 'home';
// Segment ends at the first `/` (e.g. `#session/123`) or `?` (e.g. `#file_viewer?path=...`).
const match = hash.match(/^([^/?]+)/);
const segment = match ? match[1] : '';
// Plugin pages: `#plugin/<plugin_id>/<page_id>` — the route is accepted by
// shape (deep links must survive the async `/api/plugins/pages` load); the
// host reports an error if the page turns out not to exist for this user.
if (segment === 'plugin') {
const m = hash.match(/^plugin\/([^/?]+)\/([^/?]+)/);
return m ? `plugin/${m[1]}/${m[2]}` : 'home';
}
// `connector` (singular) is the per-connector detail page, `connectors` the list.
// `plugin-catalog` is the pre-merge hash of what is now `#plugins`.
const page = segment === 'plugin-catalog' ? 'plugins' : segment;
return ['inbox', 'dashboard', 'tasks', 'projects', 'files', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'shared-folders', 'connectors', 'connector', 'plugins', 'plugin-detail', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'system-agents', 'file_viewer', 'tool_detail'].includes(page) ? page : 'home';
}
_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>
`;
}
}