projects: file explorer, ws improvements, i18n, and fs routing
Nightly Build / build (push) Successful in 6m47s

Add project-files component with tree navigation. Extend UserFs with
shared-folder resolution. Wire API routes for file browsing. Improve
WS session lifecycle and project-board layout. Add i18n keys for
projects and inbox across all locales.
This commit is contained in:
2026-07-22 18:35:13 +01:00
parent 8407a949fc
commit f34f800e5c
16 changed files with 733 additions and 40 deletions
+7 -1
View File
@@ -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() {
+32 -5
View File
@@ -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`
<project-board-section
@project-back=${() => this._navigateToList()}
@project-tab-change=${e => this._onTabChange(e.detail.tab)}
></project-board-section>
`}
`;
+44 -18
View File
@@ -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, `<project-files-panel>`) 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`
<li class="nav-item">
<button class="nav-link ${this._tab === id ? 'active' : ''}" @click=${() => this._selectTab(id)}>
<i class="bi ${icon} me-1"></i>${label}
</button>
</li>
`;
return html`
<div class="card mb-3">
<div class="card-body text-center text-muted py-4">
<i class="bi bi-folder2-open" style="font-size:1.6rem"></i>
<p class="mb-0 mt-2" style="font-size:0.9rem">${t('projects.files.placeholder')}</p>
</div>
</div>
<ul class="nav nav-tabs px-3">
${tab('files', 'bi-folder2-open', t('projects.tabs.files'))}
${tab('sharing', 'bi-people', t('projects.tabs.sharing'))}
</ul>
`;
}
@@ -250,14 +270,20 @@ export class ProjectBoardSection extends LightElement {
<div class="alert alert-danger py-2 mx-3 mt-3 mb-0" style="font-size:0.85rem">${this._error}</div>
` : nothing}
${this._project.description ? html`
<p class="text-muted px-3 pt-2 mb-1" style="font-size:0.9rem">${this._project.description}</p>
` : nothing}
${this._renderTabs()}
<div class="p-3">
${this._project.description
? html`<p class="text-muted" style="font-size:0.9rem">${this._project.description}</p>`
: nothing}
${this._renderFilesPanel()}
${this._renderSharePanel()}
<project-files-panel .project=${this._project}
style=${this._tab === 'files' ? '' : 'display:none'}></project-files-panel>
${this._tab === 'sharing' ? this._renderSharePanel() : nothing}
</div>
</div>
`;
}
}
customElements.define('project-files-panel', ProjectFilesPanel);
+400
View File
@@ -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`
<nav class="d-flex align-items-center flex-wrap" aria-label="breadcrumb"
style="--bs-breadcrumb-divider: '/';">
<ol class="breadcrumb mb-0" style="font-size:0.9rem">
<li class="breadcrumb-item ${segs.length === 0 ? 'active' : ''}">
${segs.length === 0
? html`<span title=${this.project.root_path}><i class="bi bi-hdd me-1"></i>/</span>`
: html`<a href="#" @click=${e => { e.preventDefault(); this._goTo(-1); }}
title=${this.project.root_path}><i class="bi bi-hdd me-1"></i>/</a>`}
</li>
${segs.map((s, i) => html`
<li class="breadcrumb-item ${i === segs.length - 1 ? 'active' : ''}">
${i === segs.length - 1
? html`<span>${s}</span>`
: html`<a href="#" @click=${e => { e.preventDefault(); this._goTo(i); }}>${s}</a>`}
</li>
`)}
</ol>
</nav>
`;
}
_renderToolbar() {
const canWrite = !!this.project?.can_write;
return html`
<div class="d-flex align-items-center gap-2 mb-2">
${this._renderBreadcrumb()}
<div class="ms-auto d-flex gap-1">
<button class="btn btn-sm btn-outline-secondary" title=${t('projects.files.refresh')}
?disabled=${this._loading} @click=${() => this._load()}>
<i class="bi bi-arrow-clockwise"></i>
</button>
${canWrite ? html`
<button class="btn btn-sm btn-outline-secondary" ?disabled=${this._busy}
@click=${() => this._openModal('mkdir')}>
<i class="bi bi-folder-plus me-1"></i>${t('projects.files.btn.new_folder')}
</button>
<button class="btn btn-sm btn-outline-primary" ?disabled=${this._busy}
@click=${() => this._pickFiles()}>
${this._busy
? html`<span class="spinner-border spinner-border-sm me-1"></span>${t('projects.files.uploading')}`
: html`<i class="bi bi-upload me-1"></i>${t('projects.files.btn.upload')}`}
</button>
<input type="file" class="pf-file-input" multiple hidden
@change=${e => { this._uploadFiles([...e.target.files]); e.target.value = ''; }} />
` : nothing}
</div>
</div>
`;
}
_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`
<tr style="cursor:pointer" @click=${() => this._enter(entry)}>
<td style="width:2rem"><i class="bi ${this._iconFor(entry)}"></i></td>
<td style="word-break:break-all">${entry.name}</td>
<td class="text-muted text-nowrap" style="font-size:0.82rem">${this._fmtDate(entry.created_at)}</td>
<td class="text-muted text-nowrap" style="font-size:0.82rem">${this._fmtDate(entry.modified_at)}</td>
<td class="text-muted text-end text-nowrap" style="font-size:0.82rem">${entry.is_dir ? '—' : this._fmtSize(entry.size)}</td>
${canWrite ? html`
<td class="text-end text-nowrap" @click=${e => e.stopPropagation()}>
<button class="btn btn-sm btn-link text-secondary p-0 me-2" title=${t('projects.files.action.rename')}
@click=${() => this._openModal('rename', entry)}>
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-link text-danger p-0" title=${t('projects.files.action.delete')}
?disabled=${this._busy} @click=${() => this._remove(entry)}>
<i class="bi bi-trash"></i>
</button>
</td>
` : nothing}
</tr>
`;
}
_renderTable() {
const canWrite = !!this.project?.can_write;
if (!this._entries) {
return html`<div class="text-center py-4"><span class="spinner-border spinner-border-sm text-primary"></span></div>`;
}
if (this._entries.length === 0) {
return html`
<div class="text-center text-muted py-4">
<i class="bi bi-folder2-open" style="font-size:1.4rem"></i>
<p class="mb-0 mt-2" style="font-size:0.88rem">${t('projects.files.empty')}</p>
</div>
`;
}
return html`
<table class="table table-sm table-hover align-middle mb-0">
<thead>
<tr>
<th></th>
<th>${t('projects.files.col.name')}</th>
<th style="width:9.5rem">${t('projects.files.col.created')}</th>
<th style="width:9.5rem">${t('projects.files.col.modified')}</th>
<th class="text-end" style="width:5.5rem">${t('projects.files.col.size')}</th>
${canWrite ? html`<th style="width:4.5rem"></th>` : nothing}
</tr>
</thead>
<tbody>
${this._entries.map(e => this._renderRow(e))}
</tbody>
</table>
`;
}
_renderModal() {
if (!this._modal) return nothing;
const isMkdir = this._modal.mode === 'mkdir';
return html`
<div class="agent-dialog-backdrop"
@click=${e => { if (e.target === e.currentTarget) this._modal = null; }}>
<div class="agent-dialog">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:1rem">
<i class="bi ${isMkdir ? 'bi-folder-plus' : 'bi-pencil'}"></i>
<span style="font-weight:600">
${isMkdir ? t('projects.files.modal.mkdir') : t('projects.files.modal.rename', { name: this._modal.target.name })}
</span>
<button type="button" style="margin-left:auto;border:none;background:none;cursor:pointer;font-size:1.1rem"
@click=${() => this._modal = null}>
<i class="bi bi-x"></i>
</button>
</div>
<form @submit=${e => this._submitModal(e)}>
<div class="mb-4">
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('projects.files.modal.name')}</label>
<input type="text" class="form-control form-control-sm pf-modal-input" required
.value=${this._modal.name}
@input=${e => this._modal = { ...this._modal, name: e.target.value }} />
</div>
<div style="display:flex;justify-content:flex-end;gap:0.5rem">
<button type="button" class="btn btn-sm btn-outline-secondary"
@click=${() => this._modal = null}>${t('projects.modal.cancel')}</button>
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._busy}>
<i class="bi bi-check-lg me-1"></i>${isMkdir ? t('projects.modal.create') : t('projects.modal.save')}
</button>
</div>
</form>
</div>
</div>
`;
}
render() {
if (!this.project?.root_path) return nothing;
const canWrite = !!this.project?.can_write;
return html`
<div class="card ${this._drag ? 'border-primary' : ''}"
@dragover=${e => { 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]); }}>
<div class="card-body">
${this._renderToolbar()}
${this._error ? html`
<div class="alert alert-danger py-2 mb-2" style="font-size:0.85rem">${this._error}</div>
` : nothing}
${this._drag ? html`
<div class="text-center text-primary py-3" style="font-size:0.9rem">
<i class="bi bi-cloud-arrow-up me-1"></i>${t('projects.files.drop')}
</div>
` : this._renderTable()}
</div>
</div>
${this._renderModal()}
`;
}
}
+5 -2
View File
@@ -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();
+20 -1
View File
@@ -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',
+20 -1
View File
@@ -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',
+20 -1
View File
@@ -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',
+12
View File
@@ -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, {