Files
Skald-Circle/web/app.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

126 lines
6.6 KiB
JavaScript

import { AppTopbar } from './components/topbar.js';
import { AppSidebar } from './components/sidebar.js';
import { AppCopilot } from './components/copilot.js';
import { LlmProvidersPage } from './components/llm-providers.js';
import { ModelsHubPage } from './components/models-hub.js';
import { ModelsLlmSection } from './components/models-llm.js';
import { ModelsTranscribeSection } from './components/models-transcribe.js';
import { ModelsImageSection } from './components/models-image.js';
import { ModelsTtsSection } from './components/models-tts.js';
import { TasksPage } from './components/tasks/index.js';
import { AgentsPage } from './components/agents.js';
import { UsersPage } from './components/users-page.js';
import { RolesPage } from './components/roles-page.js';
import { SharedFoldersPage } from './components/shared-folders.js';
import { ConnectorsPage } from './components/connectors.js';
import { ConnectorDetailPage } from './components/connector-detail.js';
import { PluginPageHost } from './components/plugin-page-host.js';
import { PluginCatalogPage } from './components/plugin-catalog.js';
import { PluginDetailPage } from './components/plugin-detail.js';
import { MarketplacePage } from './components/marketplace.js';
import { ProfilePage } from './components/profile-page.js';
import { ApprovalGroupsPage } from './components/approval-groups.js';
import { ApprovalRulesPage } from './components/approval-rules.js';
import { ConfigPage } from './components/config-page.js';
import { DashboardPage } from './components/dashboard-page.js';
import { AgentInboxPage } from './components/agent-inbox.js';
import { LlmRequestsPage } from './components/llm-requests.js';
import { LlmRequestDetail } from './components/llm-request-detail.js';
import { SessionDetailPage } from './components/session-detail.js';
import { SystemAgentsPage } from './components/system-agents.js';
import { ProjectsPage } from './components/projects/index.js';
import { FilesPage } from './components/files-page.js';
import { FileViewerPage } from './components/file-viewer-page.js';
import { ToolDetailPage } from './components/tool-detail-page.js';
import { SetupPage } from './components/setup-page.js';
import { LoginPage } from './components/login-page.js';
// Register the global `openFile(path)` / `openToolDetail(id)` helpers.
import './lib/open-file.js';
import './lib/open-tool.js';
import { initI18n } from './lib/i18n.js';
import { installSessionExpiryWatch } from './lib/session-expiry.js';
import { installSessionRelogin } from './components/session-relogin.js';
// Installed before anything can call the API, so no component's first fetch
// escapes the 401 watch. A session that dies under an open tab is renewed in a
// modal over the page, leaving the app's state — an unsent message included —
// exactly where it was.
installSessionExpiryWatch();
installSessionRelogin();
customElements.define('app-topbar', AppTopbar);
customElements.define('app-sidebar', AppSidebar);
customElements.define('app-copilot', AppCopilot);
customElements.define('llm-providers-page', LlmProvidersPage);
customElements.define('models-hub-page', ModelsHubPage);
customElements.define('models-llm-section', ModelsLlmSection);
customElements.define('models-transcribe-section', ModelsTranscribeSection);
customElements.define('models-image-section', ModelsImageSection);
customElements.define('models-tts-section', ModelsTtsSection);
customElements.define('tasks-page', TasksPage);
customElements.define('agents-page', AgentsPage);
customElements.define('users-page', UsersPage);
customElements.define('roles-page', RolesPage);
customElements.define('shared-folders-page', SharedFoldersPage);
customElements.define('connectors-page', ConnectorsPage);
customElements.define('connector-detail-page', ConnectorDetailPage);
customElements.define('plugin-page-host', PluginPageHost);
customElements.define('plugin-catalog-page', PluginCatalogPage);
customElements.define('plugin-detail-page', PluginDetailPage);
customElements.define('marketplace-page', MarketplacePage);
customElements.define('profile-page', ProfilePage);
customElements.define('approval-groups-page', ApprovalGroupsPage);
customElements.define('approval-rules-page', ApprovalRulesPage);
customElements.define('config-page', ConfigPage);
customElements.define('dashboard-page', DashboardPage);
customElements.define('agent-inbox-page', AgentInboxPage);
customElements.define('llm-requests-page', LlmRequestsPage);
customElements.define('llm-request-detail', LlmRequestDetail);
customElements.define('session-detail-page', SessionDetailPage);
customElements.define('system-agents-page', SystemAgentsPage);
customElements.define('projects-page', ProjectsPage);
customElements.define('files-page', FilesPage);
customElements.define('file-viewer-page', FileViewerPage);
customElements.define('tool-detail-page', ToolDetailPage);
customElements.define('setup-page', SetupPage);
customElements.define('login-page', LoginPage);
// Toggle the workspace placeholder when an LLM page opens/closes.
const workspace = document.getElementById('app-workspace');
window.addEventListener('llm-page-change', (e) => {
workspace.style.display = e.detail.page ? 'none' : 'flex';
});
// ── First-run check ─────────────────────────────────────────────────────────
// If no user exists yet, show the setup screen. Otherwise, check if we have
// a valid session: if not, show the login screen. If logged in, show the app.
(async () => {
const app = document.getElementById('app');
const setup = document.querySelector('setup-page');
const login = document.querySelector('login-page');
try {
const setupRes = await fetch('/api/setup/status');
if (setupRes.ok) {
const { needs_setup } = await setupRes.json();
if (needs_setup) {
if (app) app.style.display = 'none';
if (setup) setup.style.display = '';
return;
}
}
} catch { /* fall through to auth check */ }
try {
const meRes = await fetch('/api/auth/me');
if (meRes.status === 401) {
if (app) app.style.display = 'none';
if (login) login.style.display = '';
return;
}
// Logged in: resolve the effective locale (user pref → instance default).
initI18n();
} catch { /* show app by default */ }
})();