diff --git a/Cargo.lock b/Cargo.lock index 700a230..56aefb5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4172,7 +4172,7 @@ checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "skald" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index c9d599a..f9a96d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ resolver = "2" [package] name = "skald" -version = "0.1.0" +version = "0.1.1" edition = "2024" [features] diff --git a/crates/core-api/src/user_fs.rs b/crates/core-api/src/user_fs.rs index cf72825..d8e8cd4 100644 --- a/crates/core-api/src/user_fs.rs +++ b/crates/core-api/src/user_fs.rs @@ -108,6 +108,32 @@ impl UserFs { .find(|m| m.owner_username == owner_username && m.slug == slug) } + /// Whether the user may **write** at this agent path: their home → always; + /// a shared-folder or project mount → the membership's `can_write` flag; + /// `docs/…` → never (read-only). A `shared/`/`projects/` mount the user is + /// not a member of → false (fail-closed, same as the read side). Purely + /// lexical: memory paths never reach here (classified earlier). + pub fn can_write_to(&self, agent_path: &str) -> bool { + let stripped = strip_home_prefix(agent_path); + let mut parts = stripped.splitn(2, ['/', '\\']); + match parts.next() { + Some("shared") => { + let rest = parts.next().unwrap_or(""); + let name = rest.splitn(2, ['/', '\\']).next().unwrap_or(""); + self.shared_mount(name).map(|m| m.can_write).unwrap_or(false) + } + Some("projects") => { + let rest = parts.next().unwrap_or(""); + let mut seg = rest.splitn(3, ['/', '\\']); + let owner = seg.next().unwrap_or(""); + let slug = seg.next().unwrap_or(""); + self.project_mount(owner, slug).map(|m| m.can_write).unwrap_or(false) + } + Some("docs") => false, + _ => true, + } + } + /// The bind mounts for `docker create`: `(host, container, writable)`, home first. pub fn mounts(&self) -> Vec<(PathBuf, PathBuf, bool)> { let mut out = vec![(self.home_host.clone(), self.container_home.clone(), true)]; diff --git a/src/frontend/api/files.rs b/src/frontend/api/files.rs index ff4d350..79424f4 100644 --- a/src/frontend/api/files.rs +++ b/src/frontend/api/files.rs @@ -2,6 +2,7 @@ use std::path::Path; use axum::{ Extension, Json, + body::Bytes, extract::{Query, State}, http::{HeaderValue, StatusCode, header}, response::{IntoResponse, Response}, @@ -9,6 +10,7 @@ use axum::{ use serde::{Deserialize, Serialize}; use std::sync::Arc; +use core_api::user_fs::UserFs; use skald_core::skald::Skald; use skald_core::latex::CompileError; use skald_core::tools::fs as fs_tools; @@ -16,12 +18,80 @@ use super::ApiError; use super::guard::AuthUser; use super::require_context; +/// Upload body cap for `POST /api/file/upload` (same budget as chat attachments). +pub const MAX_UPLOAD_BYTES: usize = 256 * 1024 * 1024; + #[derive(Serialize)] pub struct FileEntry { pub path: String, pub name: String, } +/// One row of a directory listing: name + agent path (round-trips through +/// `/api/file`) + the metadata the explorer table shows. `size` is files-only; +/// timestamps are RFC-3339 UTC (`None` when the filesystem can't provide them, +/// e.g. no birth-time support) and formatted client-side. +#[derive(Serialize)] +pub struct DirEntry { + pub name: String, + pub path: String, + pub is_dir: bool, + pub size: Option, + pub created_at: Option, + pub modified_at: Option, +} + +fn fmt_ts(t: std::time::SystemTime) -> String { + chrono::DateTime::::from(t).to_rfc3339() +} + +/// Reject a write when the caller's mount for this path is read-only +/// (a shared-folder / project membership without `can_write`, or the docs +/// tree). The container bind mount is the physical gate for in-container +/// writes; the host-side HTTP API needs its own check. +fn require_write(fs: &UserFs, agent: &str) -> Result<(), ApiError> { + if fs.can_write_to(agent) { + Ok(()) + } else { + Err(ApiError::forbidden(format!("read-only: {agent}"))) + } +} + +/// GET /api/files/dir?path=… — the immediate children of a directory (dirs +/// first, then name), resolved and scoped exactly like `GET /api/file`. +pub async fn list_dir( + State(state): State>, + Extension(auth): Extension, + Query(q): Query, +) -> Result>, ApiError> { + let ctx = require_context(&state, &auth.user_id).await?; + let (abs, agent) = fs_tools::resolve_view_path(ctx.fs.load().as_ref(), &q.path) + .map_err(|e| ApiError::bad_request(e.to_string()))?; + if !abs.is_dir() { + return Err(ApiError::bad_request(format!("not a directory: {agent}"))); + } + let mut entries: Vec = Vec::new(); + for entry in std::fs::read_dir(&abs)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().to_string(); + let md = entry.metadata().ok(); + let is_dir = md.as_ref().is_some_and(|m| m.is_dir()); + entries.push(DirEntry { + path: format!("{agent}/{name}"), + name, + is_dir, + size: md.as_ref().filter(|m| m.is_file()).map(|m| m.len()), + created_at: md.as_ref().and_then(|m| m.created().ok()).map(fmt_ts), + modified_at: md.as_ref().and_then(|m| m.modified().ok()).map(fmt_ts), + }); + } + entries.sort_by(|a, b| { + b.is_dir.cmp(&a.is_dir) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + Ok(Json(entries)) +} + pub async fn list_files( State(state): State>, Extension(auth): Extension, @@ -231,6 +301,9 @@ pub struct SavePayload { #[derive(Deserialize)] pub struct CreatePayload { pub path: String, + /// When `true`, create a directory instead of an empty file. + #[serde(default)] + pub dir: bool, } pub async fn create_file( @@ -239,15 +312,45 @@ pub async fn create_file( Json(body): Json, ) -> Result { let ctx = require_context(&state, &auth.user_id).await?; - let (abs, display) = fs_tools::resolve_view_path(ctx.fs.load().as_ref(), &body.path) + let fs = ctx.fs.load(); + let (abs, display) = fs_tools::resolve_view_path(fs.as_ref(), &body.path) .map_err(|e| ApiError::bad_request(e.to_string()))?; + require_write(&fs, &display)?; if abs.exists() { return Err(anyhow::anyhow!("File already exists: {display}").into()); } + if body.dir { + std::fs::create_dir_all(&abs)?; + } else { + if let Some(parent) = abs.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&abs, "")?; + } + Ok(StatusCode::CREATED) +} + +/// POST /api/file/upload?path=… — raw request-body bytes written to `path` +/// (create or replace), for binary uploads from the project explorer. The +/// route caps the body at [`MAX_UPLOAD_BYTES`]; parent dirs are created. +pub async fn upload_file( + State(state): State>, + Extension(auth): Extension, + Query(q): Query, + body: Bytes, +) -> Result { + let ctx = require_context(&state, &auth.user_id).await?; + let fs = ctx.fs.load(); + let (abs, display) = fs_tools::resolve_view_path(fs.as_ref(), &q.path) + .map_err(|e| ApiError::bad_request(e.to_string()))?; + require_write(&fs, &display)?; + if abs.is_dir() { + return Err(ApiError::bad_request(format!("is a directory: {display}"))); + } if let Some(parent) = abs.parent() { std::fs::create_dir_all(parent)?; } - std::fs::write(&abs, "")?; + std::fs::write(&abs, &body)?; Ok(StatusCode::CREATED) } @@ -257,8 +360,10 @@ pub async fn save_file( Json(body): Json, ) -> Result { let ctx = require_context(&state, &auth.user_id).await?; - let (abs, display) = fs_tools::resolve_view_path(ctx.fs.load().as_ref(), &body.path) + let fs = ctx.fs.load(); + let (abs, display) = fs_tools::resolve_view_path(fs.as_ref(), &body.path) .map_err(|e| ApiError::bad_request(e.to_string()))?; + require_write(&fs, &display)?; if !abs.exists() { return Err(anyhow::anyhow!("File not found: {display}").into()); } @@ -283,6 +388,8 @@ pub async fn rename_file( .map_err(|e| ApiError::bad_request(e.to_string()))?; let (new_abs, new_disp) = fs_tools::resolve_view_path(fs.as_ref(), &body.new_path) .map_err(|e| ApiError::bad_request(e.to_string()))?; + require_write(&fs, &old_disp)?; + require_write(&fs, &new_disp)?; if !old_abs.exists() { return Err(anyhow::anyhow!("File not found: {old_disp}").into()); } @@ -302,12 +409,18 @@ pub async fn delete_file( Query(q): Query, ) -> Result { let ctx = require_context(&state, &auth.user_id).await?; - let (abs, display) = fs_tools::resolve_view_path(ctx.fs.load().as_ref(), &q.path) + let fs = ctx.fs.load(); + let (abs, display) = fs_tools::resolve_view_path(fs.as_ref(), &q.path) .map_err(|e| ApiError::bad_request(e.to_string()))?; + require_write(&fs, &display)?; if !abs.exists() { return Err(anyhow::anyhow!("File not found: {display}").into()); } - std::fs::remove_file(&abs)?; + if abs.is_dir() { + std::fs::remove_dir_all(&abs)?; + } else { + std::fs::remove_file(&abs)?; + } Ok(StatusCode::NO_CONTENT) } diff --git a/src/frontend/api/mod.rs b/src/frontend/api/mod.rs index b5b22e9..589dadc 100644 --- a/src/frontend/api/mod.rs +++ b/src/frontend/api/mod.rs @@ -203,8 +203,11 @@ pub fn router() -> Router> { .route("/mcp-media/{file}", get(mcp_media::get_media)) // Files .route("/files", get(files::list_files)) + .route("/files/dir", get(files::list_dir)) .route("/file", get(files::get_file)) .route("/file", post(files::create_file)) + .route("/file/upload", post(files::upload_file) + .layer(DefaultBodyLimit::max(files::MAX_UPLOAD_BYTES))) .route("/file", put(files::save_file)) .route("/file", patch(files::rename_file)) .route("/file", delete(files::delete_file)) diff --git a/src/frontend/api/projects.rs b/src/frontend/api/projects.rs index 66f8667..8227836 100644 --- a/src/frontend/api/projects.rs +++ b/src/frontend/api/projects.rs @@ -57,6 +57,9 @@ pub struct ProjectDetail { pub owner_name: String, pub is_owner: bool, pub can_write: bool, + /// The agent path of the project folder (`projects/{owner_username}/{slug}`) — + /// the explorer's root; round-trips through `/api/file*` endpoints. + pub root_path: String, pub created_at: String, pub updated_at: String, pub members: Vec, @@ -152,15 +155,22 @@ async fn remount(skald: &Skald, user_id: &str) { async fn detail(skald: &Skald, project: Project, caller: &str, can_write: bool) -> Result { let members = project_members::members(skald.db(), project.id).await?; let owner_name = user_label(skald, &project.owner_user_id).await; + // The agent path keys on the owner's *username* (the mount segment), which + // `owner_name` may not be (it's `display_name || username`). + let owner_username = match users::get(skald.db(), &project.owner_user_id).await { + Ok(Some(u)) => u.username, + _ => project.owner_user_id.clone(), + }; Ok(ProjectDetail { is_owner: project.owner_user_id == caller, owner_name, id: project.id, name: project.name, - slug: project.slug, + slug: project.slug.clone(), description: project.description, owner_user_id: project.owner_user_id, can_write, + root_path: format!("projects/{owner_username}/{}", project.slug), created_at: project.created_at, updated_at: project.updated_at, members: members.into_iter().map(Into::into).collect(), diff --git a/src/frontend/api/ws.rs b/src/frontend/api/ws.rs index 75029ff..5d162e2 100644 --- a/src/frontend/api/ws.rs +++ b/src/frontend/api/ws.rs @@ -428,10 +428,20 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc, source: String, match event { Ok(ge) => { // Forward events for this connection's source. - // ApprovalResolved is forwarded regardless of source so the - // copilot can react to approvals resolved from other clients. + // The inbox lifecycle events (approval/clarification/ + // elicitation requested+resolved) are forwarded regardless + // of source: they carry no content — just ids — and let the + // sidebar badge and inbox pages refresh live when any of + // this user's sessions (chat, cron, background) raises or + // settles a pending item. let forward = ge.source.as_deref() == Some(source.as_str()) - || matches!(ge.event, ServerEvent::ApprovalResolved { .. }); + || matches!(ge.event, + ServerEvent::ApprovalRequested { .. } + | ServerEvent::ApprovalResolved { .. } + | ServerEvent::ClarificationRequested { .. } + | ServerEvent::ClarificationResolved { .. } + | ServerEvent::ElicitationRequested { .. } + | ServerEvent::ElicitationResolved { .. }); if !forward { continue; } debug!(event_type = ge.event.type_name(), "sending event to client"); if socket.send(to_msg(&ge.event)).await.is_err() { diff --git a/web/components/agent-inbox.js b/web/components/agent-inbox.js index 62aba20..996a952 100644 --- a/web/components/agent-inbox.js +++ b/web/components/agent-inbox.js @@ -20,6 +20,10 @@ export class AgentInboxPage extends I18nMixin(InboxMixin(LightElement)) { connectedCallback() { super.connectedCallback(); + // Live refresh: the chat WS pushes `inbox-changed` when any of this user's + // sessions raises or settles a pending item — reload immediately if open. + this.__onInboxChanged = () => { if (this._open) this._loadInbox(); }; + window.addEventListener('inbox-changed', this.__onInboxChanged); window.addEventListener('llm-page-change', (e) => { this._open = e.detail.page === 'inbox'; this.style.display = this._open ? 'flex' : 'none'; @@ -35,11 +39,13 @@ export class AgentInboxPage extends I18nMixin(InboxMixin(LightElement)) { disconnectedCallback() { super.disconnectedCallback(); this._stopPolling(); + window.removeEventListener('inbox-changed', this.__onInboxChanged); } _startPolling() { this._stopPolling(); - this._pollTimer = setInterval(() => this._loadInbox(), 8000); + // Fallback only — pushes via `inbox-changed` keep the page fresh. + this._pollTimer = setInterval(() => this._loadInbox(), 60000); } _stopPolling() { diff --git a/web/components/projects/index.js b/web/components/projects/index.js index a4891ab..96fc0cb 100644 --- a/web/components/projects/index.js +++ b/web/components/projects/index.js @@ -8,6 +8,7 @@ export class ProjectsPage extends LightElement { _open: { state: true }, _view: { state: true }, _projectId: { state: true }, + _tab: { state: true }, }; constructor() { @@ -15,6 +16,7 @@ export class ProjectsPage extends LightElement { this._open = false; this._view = 'list'; this._projectId = null; + this._tab = 'files'; } connectedCallback() { @@ -24,9 +26,25 @@ export class ProjectsPage extends LightElement { this._open = open; this.style.display = open ? 'flex' : 'none'; if (open) { - const { view, id } = this._parseHash(); + const { view, id, tab } = this._parseHash(); this._view = view; this._projectId = id; + this._tab = tab; + this._loadCurrent(); + } + }); + window.addEventListener('hashchange', () => { + // Back/forward (or manual edit) between board tabs: same project → just + // switch the tab, no reload; anything else → re-sync from the hash. + if (!this._open || !location.hash.startsWith('#projects')) return; + const { view, id, tab } = this._parseHash(); + if (view === 'board' && this._view === 'board' && id === this._projectId) { + this._tab = tab; + this.querySelector('project-board-section')?.setTab(tab); + } else { + this._view = view; + this._projectId = id; + this._tab = tab; this._loadCurrent(); } }); @@ -40,9 +58,10 @@ export class ProjectsPage extends LightElement { _parseHash() { const parts = location.hash.slice(1).split('/'); if (parts[0] === 'projects' && parts[1] && /^\d+$/.test(parts[1])) { - return { view: 'board', id: parseInt(parts[1], 10) }; + const tab = parts[2] === 'sharing' ? 'sharing' : 'files'; + return { view: 'board', id: parseInt(parts[1], 10), tab }; } - return { view: 'list', id: null }; + return { view: 'list', id: null, tab: 'files' }; } _loadCurrent() { @@ -50,7 +69,7 @@ export class ProjectsPage extends LightElement { if (this._view === 'list') { this.querySelector('project-list-section')?.load(); } else { - this.querySelector('project-board-section')?.load(this._projectId); + this.querySelector('project-board-section')?.load(this._projectId, this._tab); } }); } @@ -58,9 +77,10 @@ export class ProjectsPage extends LightElement { _navigateToBoard(id) { this._view = 'board'; this._projectId = id; + this._tab = 'files'; history.pushState({ page: 'projects', id }, '', `#projects/${id}`); this.updateComplete.then(() => { - this.querySelector('project-board-section')?.load(id); + this.querySelector('project-board-section')?.load(id, 'files'); }); } @@ -73,6 +93,12 @@ export class ProjectsPage extends LightElement { }); } + _onTabChange(tab) { + this._tab = tab; + const hash = tab === 'sharing' ? `#projects/${this._projectId}/sharing` : `#projects/${this._projectId}`; + history.pushState({ page: 'projects', id: this._projectId }, '', hash); + } + render() { if (!this._open) return nothing; return html` @@ -83,6 +109,7 @@ export class ProjectsPage extends LightElement { ` : html` this._navigateToList()} + @project-tab-change=${e => this._onTabChange(e.detail.tab)} > `} `; diff --git a/web/components/projects/project-board.js b/web/components/projects/project-board.js index afe0b32..37846f1 100644 --- a/web/components/projects/project-board.js +++ b/web/components/projects/project-board.js @@ -1,16 +1,18 @@ import { html, nothing } from 'lit'; import { LightElement } from '../../lib/base.js'; import { t } from '../../lib/i18n.js'; +import { ProjectFilesPanel } from './project-files.js'; -/// A project's detail page: header + description, a sharing panel (member picker with -/// read/write, mirroring the shared-folders UI), Open chat, and a Files section (the -/// future primary surface — a file explorer over the project folder). No ticket board. +/// A project's detail page: header + description, then two tabs — **Files** (a +/// live explorer over the project folder, ``) and +/// **Sharing** (member picker with read/write, mirroring the shared-folders UI). export class ProjectBoardSection extends LightElement { static properties = { _project: { state: true }, _users: { state: true }, _add: { state: true }, _error: { state: true }, + _tab: { state: true }, }; constructor() { @@ -20,6 +22,7 @@ export class ProjectBoardSection extends LightElement { this._add = { user_id: '', can_write: false }; this._error = null; this._projectId = null; + this._tab = 'files'; } connectedCallback() { @@ -33,10 +36,11 @@ export class ProjectBoardSection extends LightElement { super.disconnectedCallback(); } - async load(projectId) { + async load(projectId, tab) { this._projectId = projectId; this._project = null; this._error = null; + this._tab = tab === 'sharing' ? 'sharing' : 'files'; try { const [projRes, usersRes] = await Promise.all([ fetch(`/api/projects/${projectId}`), @@ -114,6 +118,19 @@ export class ProjectBoardSection extends LightElement { } } + // Switch the visible tab without reloading (host back/forward sync). + setTab(tab) { + this._tab = tab === 'sharing' ? 'sharing' : 'files'; + } + + _selectTab(tab) { + if (tab === this._tab) return; + this._tab = tab; + this.dispatchEvent(new CustomEvent('project-tab-change', { + detail: { tab }, bubbles: true, composed: true, + })); + } + _back() { this.dispatchEvent(new CustomEvent('project-back', { bubbles: true, composed: true })); } @@ -203,16 +220,19 @@ export class ProjectBoardSection extends LightElement { `; } - _renderFilesPanel() { - // The file explorer is the future primary surface (a directory listing endpoint over - // the project folder is a follow-on). For now, the chat's agent works in the folder. + _renderTabs() { + const tab = (id, icon, label) => html` + + `; return html` -
-
- -

${t('projects.files.placeholder')}

-
-
+ `; } @@ -250,14 +270,20 @@ export class ProjectBoardSection extends LightElement {
${this._error}
` : nothing} + ${this._project.description ? html` +

${this._project.description}

+ ` : nothing} + + ${this._renderTabs()} +
- ${this._project.description - ? html`

${this._project.description}

` - : nothing} - ${this._renderFilesPanel()} - ${this._renderSharePanel()} + + ${this._tab === 'sharing' ? this._renderSharePanel() : nothing}
`; } } + +customElements.define('project-files-panel', ProjectFilesPanel); diff --git a/web/components/projects/project-files.js b/web/components/projects/project-files.js new file mode 100644 index 0000000..d23bf47 --- /dev/null +++ b/web/components/projects/project-files.js @@ -0,0 +1,400 @@ +import { html, nothing } from 'lit'; +import { LightElement } from '../../lib/base.js'; +import { t } from '../../lib/i18n.js'; +import { fileWatcher } from '../../lib/file-watcher.js'; + +/// The Files tab of a project board: a live explorer over the project folder. +/// +/// One directory at a time (`GET /api/files/dir`); clicking a folder navigates +/// into it, clicking a file opens it in the existing viewer (`window.openFile`). +/// The breadcrumb is rooted at the project folder (shown as `/`). The listing +/// reloads in real time: the shared `/api/file/watch` socket (the `fileWatcher` +/// singleton) pushes a `changed` event for the open directory whenever another +/// member — or the agent, from inside its container — creates/modifies/removes +/// a file in it. Write actions (new folder, upload, rename, delete) are offered +/// only to members with `can_write` and are gated server-side too. +export class ProjectFilesPanel extends LightElement { + static properties = { + project: { attribute: false }, + _rel: { state: true }, + _entries: { state: true }, + _loading: { state: true }, + _error: { state: true }, + _busy: { state: true }, + _modal: { state: true }, + _drag: { state: true }, + }; + + constructor() { + super(); + this.project = null; + this._rel = ''; // path relative to the project root ('' = root) + this._entries = null; + this._loading = false; + this._error = null; + this._busy = false; + this._modal = null; // { mode: 'mkdir'|'rename', name, target? } + this._drag = false; + this._unwatch = null; + this._reloadTimer = null; + this._onChanged = () => this._scheduleReload(); + } + + willUpdate(changed) { + // (Re)open the root only when the project itself changes — a refetch of the + // same project (member edits) must not reset the current folder. + if (changed.has('project')) { + const prev = changed.get('project'); + if (this.project?.root_path && this.project.root_path !== prev?.root_path) { + this._open(''); + } + } + } + + disconnectedCallback() { + this._unwatch?.(); + clearTimeout(this._reloadTimer); + super.disconnectedCallback(); + } + + _dirPath() { + const root = this.project?.root_path ?? ''; + return this._rel ? `${root}/${this._rel}` : root; + } + + async _open(rel) { + this._unwatch?.(); + this._unwatch = null; + this._rel = rel; + this._error = null; + await this._load(); + // Live updates for the open directory (best-effort: a dead watcher just + // means manual refresh; auto-reconnect + re-subscribe are handled inside). + try { + this._unwatch = await fileWatcher.watch(this._dirPath(), this._onChanged); + } catch { this._unwatch = null; } + } + + _scheduleReload() { + clearTimeout(this._reloadTimer); + this._reloadTimer = setTimeout(() => this._load(), 300); + } + + async _load() { + if (!this.project?.root_path) return; + this._loading = true; + try { + const res = await fetch(`/api/files/dir?path=${encodeURIComponent(this._dirPath())}`); + if (!res.ok) throw new Error(await res.text()); + this._entries = await res.json(); + this._error = null; + } catch (e) { + this._error = e.message; + } finally { + this._loading = false; + } + } + + // ── Navigation ──────────────────────────────────────────────────────────── + + _enter(entry) { + if (entry.is_dir) { + this._open(this._rel ? `${this._rel}/${entry.name}` : entry.name); + } else { + window.openFile(entry.path); + } + } + + _goTo(index) { + // -1 = project root, otherwise the segment index to land on. + const segs = this._rel ? this._rel.split('/') : []; + this._open(index < 0 ? '' : segs.slice(0, index + 1).join('/')); + } + + // ── Write actions ───────────────────────────────────────────────────────── + + _openModal(mode, target = null) { + this._modal = { mode, name: target?.name ?? '', target }; + this.updateComplete.then(() => this.querySelector('.pf-modal-input')?.focus()); + } + + async _submitModal(e) { + e.preventDefault(); + const name = (this._modal?.name ?? '').trim(); + if (!name || name.includes('/') || name.includes('\\')) { + this._error = t('projects.files.error.name'); + return; + } + this._busy = true; + try { + let res; + if (this._modal.mode === 'mkdir') { + res = await fetch('/api/file', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: `${this._dirPath()}/${name}`, dir: true }), + }); + } else { + res = await fetch('/api/file', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ old_path: this._modal.target.path, new_path: `${this._dirPath()}/${name}` }), + }); + } + if (!res.ok) throw new Error(await res.text()); + this._modal = null; + await this._load(); + } catch (err) { + this._error = err.message; + } finally { + this._busy = false; + } + } + + async _remove(entry) { + const key = entry.is_dir ? 'projects.files.confirm.delete_dir' : 'projects.files.confirm.delete_file'; + if (!confirm(t(key, { name: entry.name }))) return; + this._busy = true; + try { + const res = await fetch(`/api/file?path=${encodeURIComponent(entry.path)}`, { method: 'DELETE' }); + if (!res.ok) throw new Error(await res.text()); + await this._load(); + } catch (e) { + this._error = e.message; + } finally { + this._busy = false; + } + } + + async _uploadFiles(files) { + if (!files?.length) return; + this._busy = true; + this._error = null; + try { + for (const f of files) { + const target = `${this._dirPath()}/${f.name}`; + const res = await fetch(`/api/file/upload?path=${encodeURIComponent(target)}`, { + method: 'POST', + body: f, + }); + if (!res.ok) throw new Error(`${f.name}: ${await res.text()}`); + } + // The watcher will also fire; reload now in case it is down. + await this._load(); + } catch (e) { + this._error = e.message; + } finally { + this._busy = false; + } + } + + _pickFiles() { + this.querySelector('.pf-file-input')?.click(); + } + + // ── Rendering ───────────────────────────────────────────────────────────── + + _renderBreadcrumb() { + const segs = this._rel ? this._rel.split('/') : []; + return html` + + `; + } + + _renderToolbar() { + const canWrite = !!this.project?.can_write; + return html` +
+ ${this._renderBreadcrumb()} +
+ + ${canWrite ? html` + + + { this._uploadFiles([...e.target.files]); e.target.value = ''; }} /> + ` : nothing} +
+
+ `; + } + + _iconFor(entry) { + if (entry.is_dir) return 'bi-folder-fill text-warning'; + const ext = entry.name.includes('.') ? entry.name.split('.').pop().toLowerCase() : ''; + const map = { + png: 'bi-file-image', jpg: 'bi-file-image', jpeg: 'bi-file-image', + gif: 'bi-file-image', webp: 'bi-file-image', svg: 'bi-file-image', + pdf: 'bi-file-pdf', + md: 'bi-file-text', txt: 'bi-file-text', tex: 'bi-file-text', latex: 'bi-file-text', + js: 'bi-file-code', ts: 'bi-file-code', py: 'bi-file-code', rs: 'bi-file-code', + json: 'bi-file-code', html: 'bi-file-code', css: 'bi-file-code', sh: 'bi-file-code', + zip: 'bi-file-zip', gz: 'bi-file-zip', tar: 'bi-file-zip', + mp3: 'bi-file-music', wav: 'bi-file-music', ogg: 'bi-file-music', + mp4: 'bi-file-play', mov: 'bi-file-play', webm: 'bi-file-play', + doc: 'bi-file-word', docx: 'bi-file-word', + xls: 'bi-file-excel', xlsx: 'bi-file-excel', csv: 'bi-file-excel', + }; + return map[ext] ?? 'bi-file-earmark'; + } + + _fmtDate(iso) { + if (!iso) return '—'; + const d = new Date(iso); + return isNaN(d) ? '—' : d.toLocaleString(); + } + + _fmtSize(n) { + if (n == null) return '—'; + if (n < 1024) return `${n} B`; + if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB`; + if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`; + return `${(n / 1024 ** 3).toFixed(2)} GB`; + } + + _renderRow(entry) { + const canWrite = !!this.project?.can_write; + return html` + this._enter(entry)}> + + ${entry.name} + ${this._fmtDate(entry.created_at)} + ${this._fmtDate(entry.modified_at)} + ${entry.is_dir ? '—' : this._fmtSize(entry.size)} + ${canWrite ? html` + e.stopPropagation()}> + + + + ` : nothing} + + `; + } + + _renderTable() { + const canWrite = !!this.project?.can_write; + if (!this._entries) { + return html`
`; + } + if (this._entries.length === 0) { + return html` +
+ +

${t('projects.files.empty')}

+
+ `; + } + return html` + + + + + + + + + ${canWrite ? html`` : nothing} + + + + ${this._entries.map(e => this._renderRow(e))} + +
${t('projects.files.col.name')}${t('projects.files.col.created')}${t('projects.files.col.modified')}${t('projects.files.col.size')}
+ `; + } + + _renderModal() { + if (!this._modal) return nothing; + const isMkdir = this._modal.mode === 'mkdir'; + return html` +
{ if (e.target === e.currentTarget) this._modal = null; }}> +
+
+ + + ${isMkdir ? t('projects.files.modal.mkdir') : t('projects.files.modal.rename', { name: this._modal.target.name })} + + +
+
this._submitModal(e)}> +
+ + this._modal = { ...this._modal, name: e.target.value }} /> +
+
+ + +
+
+
+
+ `; + } + + render() { + if (!this.project?.root_path) return nothing; + const canWrite = !!this.project?.can_write; + return html` +
{ if (canWrite) { e.preventDefault(); this._drag = true; } }} + @dragleave=${() => this._drag = false} + @drop=${e => { e.preventDefault(); this._drag = false; if (canWrite) this._uploadFiles([...e.dataTransfer.files]); }}> +
+ ${this._renderToolbar()} + ${this._error ? html` +
${this._error}
+ ` : nothing} + ${this._drag ? html` +
+ ${t('projects.files.drop')} +
+ ` : this._renderTable()} +
+
+ ${this._renderModal()} + `; + } +} diff --git a/web/components/sidebar.js b/web/components/sidebar.js index 08c9cc7..57f535f 100644 --- a/web/components/sidebar.js +++ b/web/components/sidebar.js @@ -112,9 +112,12 @@ export class AppSidebar extends I18nMixin(LightElement) { if (page === 'tasks') this._tasksSection = this._tasksSectionFromHash(); this._applyPage(page); }, 0); - // Poll inbox count independently of whether the page is open. + // 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(), 10000); + this._pollTimer = setInterval(() => this._pollInbox(), 60000); + window.addEventListener('inbox-changed', () => this._pollInbox()); this._loadCollapsed(); this._loadDebugMode(); this._loadRecentProjects(); diff --git a/web/i18n/en.js b/web/i18n/en.js index fcba7f1..4c19197 100644 --- a/web/i18n/en.js +++ b/web/i18n/en.js @@ -221,7 +221,26 @@ export default { 'projects.share.access.write': 'Write', 'projects.share.access.readonly': 'Read-only', 'projects.share.access.readwrite':'Read & write', - 'projects.files.placeholder': 'The project files live here. Open the chat to work in this folder — a file explorer is coming.', + 'projects.tabs.files': 'Files', + 'projects.tabs.sharing': 'Sharing', + 'projects.files.col.name': 'Name', + 'projects.files.col.created': 'Created', + 'projects.files.col.modified': 'Modified', + 'projects.files.col.size': 'Size', + 'projects.files.empty': 'This folder is empty.', + 'projects.files.refresh': 'Refresh', + 'projects.files.drop': 'Drop files here to upload', + 'projects.files.btn.new_folder': 'New folder', + 'projects.files.btn.upload': 'Upload', + 'projects.files.uploading': 'Uploading…', + 'projects.files.action.rename': 'Rename', + 'projects.files.action.delete': 'Delete', + 'projects.files.confirm.delete_file': 'Delete "{name}"?', + 'projects.files.confirm.delete_dir': 'Delete the folder "{name}" and everything inside it?', + 'projects.files.modal.mkdir': 'New folder', + 'projects.files.modal.rename': 'Rename "{name}"', + 'projects.files.modal.name': 'Name', + 'projects.files.error.name': 'Enter a valid name (no slashes).', // ── Project detail ────────────────────────────────────────────────────────── 'project_board.back': 'Projects', diff --git a/web/i18n/fr.js b/web/i18n/fr.js index e328258..237add5 100644 --- a/web/i18n/fr.js +++ b/web/i18n/fr.js @@ -221,7 +221,26 @@ export default { 'projects.share.access.write': 'Écriture', 'projects.share.access.readonly': 'Lecture seule', 'projects.share.access.readwrite':'Lecture et écriture', - 'projects.files.placeholder': 'Les fichiers du projet vivent ici. Ouvrez la discussion pour travailler dans ce dossier — un explorateur de fichiers arrive bientôt.', + 'projects.tabs.files': 'Fichiers', + 'projects.tabs.sharing': 'Partage', + 'projects.files.col.name': 'Nom', + 'projects.files.col.created': 'Création', + 'projects.files.col.modified': 'Modification', + 'projects.files.col.size': 'Taille', + 'projects.files.empty': 'Ce dossier est vide.', + 'projects.files.refresh': 'Actualiser', + 'projects.files.drop': 'Déposez les fichiers ici pour les envoyer', + 'projects.files.btn.new_folder': 'Nouveau dossier', + 'projects.files.btn.upload': 'Envoyer', + 'projects.files.uploading': 'Envoi…', + 'projects.files.action.rename': 'Renommer', + 'projects.files.action.delete': 'Supprimer', + 'projects.files.confirm.delete_file': 'Supprimer « {name} » ?', + 'projects.files.confirm.delete_dir': 'Supprimer le dossier « {name} » et tout son contenu ?', + 'projects.files.modal.mkdir': 'Nouveau dossier', + 'projects.files.modal.rename': 'Renommer « {name} »', + 'projects.files.modal.name': 'Nom', + 'projects.files.error.name': 'Saisissez un nom valide (sans barres obliques).', // ── Détail du projet ──────────────────────────────────────────────────────── 'project_board.back': 'Projets', diff --git a/web/i18n/it.js b/web/i18n/it.js index 36598d2..2ce1a1a 100644 --- a/web/i18n/it.js +++ b/web/i18n/it.js @@ -245,7 +245,26 @@ export default { 'projects.share.access.write': 'Scrittura', 'projects.share.access.readonly': 'Sola lettura', 'projects.share.access.readwrite':'Lettura e scrittura', - 'projects.files.placeholder': 'Qui vivono i file del progetto. Apri la chat per lavorare in questa cartella — un file explorer è in arrivo.', + 'projects.tabs.files': 'File', + 'projects.tabs.sharing': 'Condivisione', + 'projects.files.col.name': 'Nome', + 'projects.files.col.created': 'Creazione', + 'projects.files.col.modified': 'Ultima modifica', + 'projects.files.col.size': 'Dimensione', + 'projects.files.empty': 'Questa cartella è vuota.', + 'projects.files.refresh': 'Aggiorna', + 'projects.files.drop': 'Trascina qui i file per caricarli', + 'projects.files.btn.new_folder': 'Nuova cartella', + 'projects.files.btn.upload': 'Carica', + 'projects.files.uploading': 'Caricamento…', + 'projects.files.action.rename': 'Rinomina', + 'projects.files.action.delete': 'Elimina', + 'projects.files.confirm.delete_file': 'Eliminare "{name}"?', + 'projects.files.confirm.delete_dir': 'Eliminare la cartella "{name}" e tutto il suo contenuto?', + 'projects.files.modal.mkdir': 'Nuova cartella', + 'projects.files.modal.rename': 'Rinomina "{name}"', + 'projects.files.modal.name': 'Nome', + 'projects.files.error.name': 'Inserisci un nome valido (senza barre).', // ── Dettaglio progetto ────────────────────────────────────────────────────── 'project_board.back': 'Progetti', diff --git a/web/lib/chat-session.js b/web/lib/chat-session.js index d6229b1..6dd5a1a 100644 --- a/web/lib/chat-session.js +++ b/web/lib/chat-session.js @@ -405,6 +405,7 @@ export class ChatSession extends LightElement { case 'approval_resolved': { const { request_id, tool_call_id, approved } = msg; + window.dispatchEvent(new CustomEvent('inbox-changed')); this._updatePendingWrite(request_id, { status: approved ? 'approved' : 'rejected' }); if (tool_call_id != null) { if (approved) { @@ -419,6 +420,17 @@ export class ChatSession extends LightElement { break; } + case 'approval_requested': + case 'clarification_requested': + case 'clarification_resolved': + case 'elicitation_requested': + case 'elicitation_resolved': + // Inbox lifecycle from any of this user's sessions (chat, cron, + // background): nudge listeners (sidebar badge, inbox page) to refresh + // immediately instead of waiting for the next poll. + window.dispatchEvent(new CustomEvent('inbox-changed')); + break; + case 'agent_question': // Link the question form to the tool card by updating status + storing request_id. this._updateTool(msg.tool_call_id, {