Projects: shareable, registry-backed, container-mounted; drop ticket board
Nightly Build / build (push) Successful in 6m27s

Rework projects from single-user leftovers into shareable endeavours.

- DB: move `projects` from the owner bucket to the registry (system.db,
  not encrypted); add `owner_user_id` + `slug` (drop free `path`); new
  `project_members(project_id, user_id, can_write)` mirroring
  `shared_folder_members`. Drop `project_tickets` entirely. Only user↔agent
  conversations stay encrypted (per-user DB) — each member keeps a private
  project chat. Registry home dissolves the cross-DB-FK problem.
- Filesystem/container: on disk `{WD}/projects/{owner_userid}/{slug}`,
  agent/container path `projects/{owner_username}/{slug}`. Two-segment routing
  in UserFs (ProjectMount + host_base_and_tail arm) and a second loop in
  build_user_fs; read-only members get a :ro mount. Reuse the shared-folder
  remount machinery (refresh_user_shared_folders -> refresh_user_mounts).
- Remove the ticket system: ProjectTicketManager, UserContext.tickets, its
  wiring, and the project_tickets references in scheduled_jobs/cron.
- API: repoint handlers to the registry pool + membership scoping. Sharing is
  self-service — owner or any write-member may add/remove members and set
  read/write; only the owner deletes; the owner cannot be removed. New
  POST/DELETE /api/projects/{id}/members[/{user_id}]. Seed `@fs_any allow
  projects/*`; build_runtime_run_context sets working_directory to the agent
  path and drops the host-path allow_fs_writes.
- Frontend: create form without the free path field, owner/read-write badges;
  the detail page becomes header + description + sharing panel + Open chat + a
  file-explorer placeholder (the future primary surface). i18n en/it/fr.
This commit is contained in:
2026-07-20 22:21:10 +01:00
parent bd88f02226
commit 2ec394c17c
26 changed files with 963 additions and 1245 deletions
+143 -360
View File
@@ -1,39 +1,25 @@
import { html, nothing } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement, renderMarkdown } from '../../lib/base.js';
import { LightElement } from '../../lib/base.js';
import { t } from '../../lib/i18n.js';
import { formatDate } from '../tasks/utils.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.
export class ProjectBoardSection extends LightElement {
static properties = {
_project: { state: true },
_tickets: { state: true },
_modal: { state: true },
_form: { state: true },
_saving: { state: true },
_error: { state: true },
_expanded: { state: true },
_expandedDesc: { state: true },
_agents: { state: true },
_groups: { state: true },
_activeTab: { state: true },
_project: { state: true },
_users: { state: true },
_add: { state: true },
_error: { state: true },
};
constructor() {
super();
this._project = null;
this._tickets = [];
this._modal = null;
this._form = this._emptyForm();
this._saving = false;
this._error = null;
this._expanded = null;
this._expandedDesc = {};
this._pollTimer = null;
this._projectId = null;
this._agents = [];
this._groups = [];
this._activeTab = 'tickets';
this._project = null;
this._users = [];
this._add = { user_id: '', can_write: false };
this._error = null;
this._projectId = null;
}
connectedCallback() {
@@ -45,12 +31,6 @@ export class ProjectBoardSection extends LightElement {
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
this._stopPolling();
}
_emptyForm() {
// No default agent — a ticket runs a `task` agent, picked once the list loads.
return { title: '', description: '', agent_id: '', security_group: '' };
}
async load(projectId) {
@@ -58,160 +38,83 @@ export class ProjectBoardSection extends LightElement {
this._project = null;
this._error = null;
try {
const [projRes, tickRes] = await Promise.all([
const [projRes, usersRes] = await Promise.all([
fetch(`/api/projects/${projectId}`),
fetch(`/api/projects/${projectId}/tickets`),
fetch('/api/users'),
]);
if (!projRes.ok) throw new Error(`HTTP ${projRes.status}`);
if (!tickRes.ok) throw new Error(`HTTP ${tickRes.status}`);
this._project = await projRes.json();
this._tickets = await tickRes.json();
this._updatePolling();
if (usersRes.ok) this._users = await usersRes.json();
} catch (e) {
this._error = e.message;
}
}
async _loadTickets() {
if (!this._projectId) return;
async _reload() {
try {
const res = await fetch(`/api/projects/${this._projectId}/tickets`);
if (res.ok) {
this._tickets = await res.json();
this._updatePolling();
}
} catch { /* ignore transient errors during poll */ }
const res = await fetch(`/api/projects/${this._projectId}`);
if (res.ok) this._project = await res.json();
} catch { /* transient */ }
}
_hasActiveTickets() {
return this._tickets.some(t => t.status === 'pending' || t.status === 'in_progress');
_canManage() {
return !!this._project && (this._project.is_owner || this._project.can_write);
}
_updatePolling() {
if (this._hasActiveTickets()) {
this._startPolling();
} else {
this._stopPolling();
}
_userLabel(id) {
const u = this._users.find(u => u.id === id);
return u ? (u.display_name || u.username) : id;
}
_startPolling() {
if (this._pollTimer) return;
this._pollTimer = setInterval(() => this._loadTickets(), 5000);
_candidates() {
const taken = new Set((this._project?.members ?? []).map(m => m.user_id));
return this._users.filter(u => u.active !== false && !taken.has(u.id));
}
_stopPolling() {
if (this._pollTimer) {
clearInterval(this._pollTimer);
this._pollTimer = null;
}
}
// ── Membership actions ─────────────────────────────────────────────────────────
_groupTickets() {
const running = [];
const todo = [];
const completed = [];
for (const t of this._tickets) {
if (t.status === 'pending' || t.status === 'in_progress') {
running.push(t);
} else if (t.status === 'todo') {
todo.push(t);
} else {
completed.push(t);
}
}
todo.sort((a, b) => (b.created_at ?? '').localeCompare(a.created_at ?? ''));
completed.sort((a, b) => (b.completed_at ?? '').localeCompare(a.completed_at ?? ''));
return { running, todo, completed };
}
async _loadModalData() {
async _addMember() {
if (!this._add.user_id) return;
try {
const [agentsRes, groupsRes] = await Promise.all([
fetch('/api/agents'),
fetch('/api/tool-permission-groups'),
]);
if (agentsRes.ok) this._agents = await agentsRes.json();
if (groupsRes.ok) this._groups = await groupsRes.json();
// Tickets run task agents only; pre-select the first one so a valid value is sent.
if (!this._form.agent_id) {
const first = this._agents.find(a => a.type === 'task');
if (first) this._form = { ...this._form, agent_id: first.id };
}
} catch { /* non-critical */ }
}
// ── Actions ──────────────────────────────────────────────────────────────────
async _startTicket(ticket) {
try {
const res = await fetch(
`/api/projects/${ticket.project_id}/tickets/${ticket.id}/start`,
{ method: 'POST' },
);
if (!res.ok) throw new Error(await res.text());
await this._loadTickets();
} catch (e) {
this._error = e.message;
}
}
async _resetTicket(ticket) {
try {
const res = await fetch(
`/api/projects/${ticket.project_id}/tickets/${ticket.id}/reset`,
{ method: 'POST' },
);
if (!res.ok) throw new Error(await res.text());
if (this._expanded === ticket.id) this._expanded = null;
await this._loadTickets();
} catch (e) {
this._error = e.message;
}
}
async _deleteTicket(ticket) {
if (!confirm(t('project_board.confirm.delete', { title: ticket.title }))) return;
try {
const res = await fetch(
`/api/projects/${ticket.project_id}/tickets/${ticket.id}`,
{ method: 'DELETE' },
);
if (!res.ok) throw new Error(await res.text());
await this._loadTickets();
} catch (e) {
this._error = e.message;
}
}
async _createTicket(e) {
e.preventDefault();
if (this._saving) return;
this._saving = true;
this._error = null;
try {
const payload = { ...this._form };
if (!payload.security_group) delete payload.security_group;
const res = await fetch(`/api/projects/${this._projectId}/tickets`, {
const res = await fetch(`/api/projects/${this._projectId}/members`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
body: JSON.stringify({ user_id: this._add.user_id, can_write: this._add.can_write }),
});
if (!res.ok) throw new Error(await res.text());
this._modal = null;
await this._loadTickets();
} catch (err) {
this._error = err.message;
} finally {
this._saving = false;
this._project = { ...this._project, members: await res.json() };
this._add = { user_id: '', can_write: false };
} catch (e) {
this._error = e.message;
}
}
async _setAccess(userId, canWrite) {
try {
const res = await fetch(`/api/projects/${this._projectId}/members`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId, can_write: canWrite }),
});
if (!res.ok) throw new Error(await res.text());
this._project = { ...this._project, members: await res.json() };
} catch (e) {
this._error = e.message;
}
}
async _removeMember(userId) {
try {
const res = await fetch(`/api/projects/${this._projectId}/members/${encodeURIComponent(userId)}`,
{ method: 'DELETE' });
if (!res.ok) throw new Error(await res.text());
this._project = { ...this._project, members: await res.json() };
} catch (e) {
this._error = e.message;
}
}
_back() {
this._stopPolling();
this.dispatchEvent(new CustomEvent('project-back', { bubbles: true, composed: true }));
}
@@ -228,207 +131,86 @@ export class ProjectBoardSection extends LightElement {
}
}
_toggleExpand(id) {
this._expanded = this._expanded === id ? null : id;
}
_toggleDesc(id) {
this._expandedDesc = { ...this._expandedDesc, [id]: !this._expandedDesc[id] };
}
// ── Rendering ─────────────────────────────────────────────────────────────────
_renderTicketCard(ticket) {
const isRunning = ticket.status === 'pending' || ticket.status === 'in_progress';
const isDone = ticket.status === 'done';
const isFailed = ticket.status === 'failed';
const isCompleted = isDone || isFailed;
const isExpanded = this._expanded === ticket.id;
const cardClass = isRunning ? 'ticket-card ticket-card--running'
: isDone ? 'ticket-card ticket-card--done'
: isFailed ? 'ticket-card ticket-card--failed'
: 'ticket-card';
_renderMember(m) {
const isOwner = m.user_id === this._project.owner_user_id;
const manage = this._canManage();
return html`
<div class="${cardClass}">
<div class="ticket-card-header">
<span class="ticket-card-title">${ticket.title}</span>
${isRunning ? html`
<span class="spinner-border spinner-border-sm text-primary"
style="width:0.7rem;height:0.7rem;flex-shrink:0"></span>
` : nothing}
</div>
${ticket.description
? html`<div class="ticket-card-desc ${this._expandedDesc[ticket.id] ? 'ticket-card-desc--expanded' : ''}"
@click=${() => { if (!window.getSelection().toString()) this._toggleDesc(ticket.id); }}>${ticket.description}</div>`
: nothing}
<div class="ticket-card-meta">
<span><i class="bi bi-person me-1"></i>${ticket.agent_id}</span>
${ticket.started_at ? html`
<span><i class="bi bi-clock me-1"></i>${formatDate(ticket.started_at)}</span>
` : html`
<span><i class="bi bi-calendar me-1"></i>${formatDate(ticket.created_at)}</span>
`}
${isCompleted && ticket.completed_at ? html`
<span><i class="bi bi-check2 me-1"></i>${formatDate(ticket.completed_at)}</span>
` : nothing}
</div>
<div class="ticket-card-actions">
${ticket.status === 'todo' ? html`
<button class="btn btn-sm btn-outline-primary ticket-card-btn"
@click=${() => this._startTicket(ticket)}>
<i class="bi bi-play-fill me-1"></i>${t('project_board.ticket.start')}
<div class="d-flex align-items-center gap-2 py-1">
<span style="min-width:10rem">${this._userLabel(m.user_id)}
${isOwner ? html`<span class="badge text-bg-light ms-1">${t('projects.share.owner')}</span>` : nothing}
</span>
${isOwner ? html`
<span class="text-muted" style="font-size:0.8rem">${t('projects.share.access.readwrite')}</span>
` : manage ? html`
<div class="btn-group btn-group-sm" role="group">
<button class="btn ${!m.can_write ? 'btn-secondary' : 'btn-outline-secondary'}"
@click=${() => m.can_write && this._setAccess(m.user_id, false)}>
<i class="bi bi-eye me-1"></i>${t('projects.share.access.read')}
</button>
<button class="btn btn-sm btn-outline-danger ticket-card-btn"
@click=${() => this._deleteTicket(ticket)}>
<i class="bi bi-trash"></i>
</button>
` : nothing}
${isRunning ? html`
<span class="ticket-card-running-label">${t('project_board.ticket.running')}</span>
${ticket.session_id != null ? html`
<a href="#session/${ticket.session_id}" class="ticket-card-session-link">
<i class="bi bi-chat-text me-1"></i>#${ticket.session_id}
</a>
` : nothing}
` : nothing}
${isCompleted ? html`
<button class="btn btn-sm btn-outline-secondary ticket-card-btn"
@click=${() => this._resetTicket(ticket)}>
<i class="bi bi-arrow-counterclockwise me-1"></i>${t('project_board.ticket.reset')}
</button>
<button class="btn btn-sm ticket-card-btn ${isDone ? 'btn-outline-success' : 'btn-outline-danger'}"
@click=${() => this._toggleExpand(ticket.id)}>
<i class="bi bi-${isExpanded ? 'chevron-up' : 'chevron-down'} me-1"></i>
${isDone ? t('project_board.ticket.result') : t('project_board.ticket.error')}
</button>
${ticket.session_id != null ? html`
<a href="#session/${ticket.session_id}"
class="btn btn-sm btn-outline-secondary ticket-card-btn ticket-card-session-btn">
<i class="bi bi-chat-text me-1"></i>#${ticket.session_id}
</a>
` : nothing}
` : nothing}
</div>
${isCompleted && isExpanded ? html`
<div class="ticket-card-result ticket-card-result--${isDone ? 'success' : 'error'}">
${isDone
? html`<div class="ticket-result-markdown copilot-markdown">
${unsafeHTML(renderMarkdown(ticket.result ?? t('project_board.ticket.no_output')))}
</div>`
: html`<pre class="ticket-result-error">${ticket.error ?? t('project_board.ticket.no_error')}</pre>`}
</div>
` : nothing}
</div>
`;
}
_renderSection(label, icon, colorClass, tickets, emptyLabel) {
return html`
<div class="ticket-section">
<div class="ticket-section-header ${colorClass}">
<span><i class="bi bi-${icon} me-1"></i>${label}</span>
<span class="badge bg-secondary ms-2">${tickets.length}</span>
</div>
${tickets.length === 0
? html`<div class="ticket-section-empty">${emptyLabel}</div>`
: tickets.map(t => this._renderTicketCard(t))}
</div>
`;
}
_renderTabBar() {
return html`
<div class="project-tab-bar">
<button
class="project-tab ${this._activeTab === 'tickets' ? 'project-tab--active' : ''}"
@click=${() => { this._activeTab = 'tickets'; }}>
<i class="bi bi-card-list me-1"></i>${t('project_board.tab.tickets')}
</button>
</div>
`;
}
_renderTicketsTab() {
const { running, todo, completed } = this._groupTickets();
return html`
<div class="ticket-list">
${this._renderSection(t('project_board.section.running'), 'activity', 'ticket-section-header--running', running, t('project_board.section.running_empty'))}
${this._renderSection(t('project_board.section.todo'), 'circle', '', todo, t('project_board.section.todo_empty'))}
${this._renderSection(t('project_board.section.completed'), 'check-circle', 'ticket-section-header--completed', completed, t('project_board.section.completed_empty'))}
</div>
`;
}
_renderModal() {
return html`
<div class="agent-dialog-backdrop">
<div class="agent-dialog agent-dialog--ticket">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:1rem">
<i class="bi bi-card-text"></i>
<span style="font-weight:600">${t('project_board.modal.title')}</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 class="btn ${m.can_write ? 'btn-secondary' : 'btn-outline-secondary'}"
@click=${() => !m.can_write && this._setAccess(m.user_id, true)}>
<i class="bi bi-pencil me-1"></i>${t('projects.share.access.write')}
</button>
</div>
<button class="btn btn-sm btn-outline-danger" title=${t('projects.share.remove')}
@click=${() => this._removeMember(m.user_id)}>
<i class="bi bi-x-lg"></i>
</button>
` : html`
<span class="text-muted" style="font-size:0.8rem">
${m.can_write ? t('projects.share.access.readwrite') : t('projects.share.access.readonly')}
</span>
`}
</div>
`;
}
${this._error ? html`
<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>
_renderSharePanel() {
const candidates = this._candidates();
const manage = this._canManage();
return html`
<div class="card mb-3">
<div class="card-body">
<h6 class="fw-semibold mb-3"><i class="bi bi-people me-1"></i>${t('projects.share.title')}</h6>
${(this._project.members ?? []).map(m => this._renderMember(m))}
${manage ? html`
<hr class="my-3" />
${candidates.length > 0 ? html`
<div class="d-flex gap-2 align-items-center flex-wrap">
<select class="form-select form-select-sm" style="max-width:16rem"
@change=${e => this._add = { ...this._add, user_id: e.target.value }}>
<option value="" ?selected=${!this._add.user_id}>${t('projects.share.choose_user')}</option>
${candidates.map(u => html`
<option value=${u.id} ?selected=${this._add.user_id === u.id}>${u.display_name || u.username}</option>`)}
</select>
<select class="form-select form-select-sm" style="max-width:11rem"
@change=${e => this._add = { ...this._add, can_write: e.target.value === 'write' }}>
<option value="read" ?selected=${!this._add.can_write}>${t('projects.share.access.readonly')}</option>
<option value="write" ?selected=${this._add.can_write}>${t('projects.share.access.readwrite')}</option>
</select>
<button class="btn btn-sm btn-primary" ?disabled=${!this._add.user_id}
@click=${() => this._addMember()}>
<i class="bi bi-plus-lg me-1"></i>${t('projects.share.add')}
</button>
</div>
` : html`<div class="text-muted" style="font-size:0.85rem">${t('projects.share.all_added')}</div>`}
` : nothing}
</div>
</div>
`;
}
<form @submit=${e => this._createTicket(e)}>
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('project_board.modal.title_label')}</label>
<input type="text" class="form-control form-control-sm" required
placeholder=${t('project_board.modal.title_ph')}
.value=${this._form.title}
@input=${e => this._form = { ...this._form, title: e.target.value }} />
</div>
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('project_board.modal.desc_label')}</label>
<textarea class="form-control form-control-sm" rows="4"
placeholder=${t('project_board.modal.desc_ph')}
.value=${this._form.description}
@input=${e => this._form = { ...this._form, description: e.target.value }}></textarea>
</div>
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('project_board.modal.agent')}</label>
<select class="form-select form-select-sm"
.value=${this._form.agent_id}
@change=${e => this._form = { ...this._form, agent_id: e.target.value }}>
${this._agents.filter(a => a.type === 'task').map(a => html`
<option value=${a.id} ?selected=${this._form.agent_id === a.id}>${a.name || a.id}</option>
`)}
</select>
</div>
<div class="mb-4">
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('project_board.modal.security_group')}</label>
<select class="form-select form-select-sm"
.value=${this._form.security_group}
@change=${e => this._form = { ...this._form, security_group: e.target.value }}>
<option value="">${t('project_board.modal.inherit')}</option>
${this._groups.map(g => html`
<option value=${g.id} ?selected=${this._form.security_group === g.id}>${g.name}</option>
`)}
</select>
</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('project_board.modal.cancel')}</button>
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}>
${this._saving
? html`<span class="spinner-border spinner-border-sm me-1"></span>${t('project_board.modal.saving')}`
: html`<i class="bi bi-check-lg me-1"></i>${t('project_board.modal.create')}`}
</button>
</div>
</form>
_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.
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>
`;
@@ -453,27 +235,28 @@ export class ProjectBoardSection extends LightElement {
<h2 class="project-page-title">
<i class="bi bi-folder2"></i>${this._project.name}
</h2>
${this._project.is_owner
? html`<span class="badge text-bg-light"><i class="bi bi-person me-1"></i>${t('projects.badge.owned')}</span>`
: html`<span class="badge text-bg-light"><i class="bi bi-people me-1"></i>${t('projects.badge.shared_by', { name: this._project.owner_name })}</span>`}
</div>
<div style="display:flex;gap:0.5rem">
<button class="btn btn-sm btn-outline-primary" @click=${() => this._openChat()}>
<i class="bi bi-chat-dots me-1"></i>${t('project_board.open_chat')}
</button>
<button class="btn btn-sm btn-primary"
@click=${() => { this._form = this._emptyForm(); this._error = null; this._modal = { mode: 'add' }; this._loadModalData(); }}>
<i class="bi bi-plus-lg me-1"></i>${t('project_board.new_ticket')}
</button>
</div>
</div>
${this._renderTabBar()}
${this._error ? html`
<div class="alert alert-danger py-2 mx-3 mt-3 mb-0" style="font-size:0.85rem">${this._error}</div>
` : nothing}
${this._activeTab === 'tickets' ? this._renderTicketsTab() : nothing}
${this._modal ? this._renderModal() : nothing}
<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()}
</div>
</div>
`;
}
+20 -18
View File
@@ -33,7 +33,7 @@ export class ProjectListSection extends LightElement {
}
_emptyForm() {
return { name: '', path: '', description: '' };
return { name: '', description: '' };
}
async load() {
@@ -54,7 +54,7 @@ export class ProjectListSection extends LightElement {
}
_openEdit(project) {
this._form = { name: project.name, path: project.path, description: project.description ?? '' };
this._form = { name: project.name, description: project.description ?? '' };
this._error = null;
this._modal = { mode: 'edit', project };
}
@@ -135,13 +135,6 @@ export class ProjectListSection extends LightElement {
.value=${this._form.name}
@input=${e => this._setField('name', e.target.value)} />
</div>
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('projects.modal.path')}</label>
<input type="text" class="form-control form-control-sm" required
placeholder=${t('projects.modal.path_ph')}
.value=${this._form.path}
@input=${e => this._setField('path', e.target.value)} />
</div>
<div class="mb-4">
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('projects.modal.desc')}</label>
<textarea class="form-control form-control-sm" rows="2"
@@ -170,17 +163,26 @@ export class ProjectListSection extends LightElement {
<div class="project-card-header">
<div class="project-card-title">${project.name}</div>
<div class="project-card-actions" @click=${e => e.stopPropagation()}>
<button class="project-card-icon-btn" title=${t('projects.action.edit')}
@click=${() => this._openEdit(project)}>
<i class="bi bi-pencil"></i>
</button>
<button class="project-card-icon-btn project-card-icon-btn--danger" title=${t('projects.action.delete')}
@click=${() => this._delete(project)}>
<i class="bi bi-trash"></i>
</button>
${project.can_write ? html`
<button class="project-card-icon-btn" title=${t('projects.action.edit')}
@click=${() => this._openEdit(project)}>
<i class="bi bi-pencil"></i>
</button>` : nothing}
${project.is_owner ? html`
<button class="project-card-icon-btn project-card-icon-btn--danger" title=${t('projects.action.delete')}
@click=${() => this._delete(project)}>
<i class="bi bi-trash"></i>
</button>` : nothing}
</div>
</div>
<div class="project-card-path"><i class="bi bi-folder2 me-1"></i>${project.path}</div>
<div class="project-card-path">
${project.is_owner
? html`<span class="badge text-bg-light"><i class="bi bi-person me-1"></i>${t('projects.badge.owned')}</span>`
: html`<span class="badge text-bg-light"><i class="bi bi-people me-1"></i>${t('projects.badge.shared_by', { name: project.owner_name })}</span>`}
${!project.can_write
? html`<span class="badge text-bg-light ms-1" title=${t('projects.badge.readonly')}><i class="bi bi-eye"></i></span>`
: nothing}
</div>
${project.description
? html`<div class="project-card-desc">${project.description}</div>`
: nothing}
+18 -31
View File
@@ -184,14 +184,15 @@ export default {
'projects.action.edit': 'Edit',
'projects.action.delete': 'Delete',
'projects.card.updated': 'Updated',
'projects.confirm.delete': 'Delete project "{name}"?\nAll tickets will also be deleted.',
'projects.confirm.delete': 'Delete project "{name}"? This removes it for everyone it is shared with.',
'projects.badge.owned': 'Owned',
'projects.badge.shared_by': 'Shared by {name}',
'projects.badge.readonly': 'Read-only',
'projects.modal.title_edit': 'Edit Project',
'projects.modal.title_new': 'New Project',
'projects.modal.name': 'Name',
'projects.modal.name_ph': 'My Project',
'projects.modal.path': 'Path',
'projects.modal.path_ph': '/path/to/project',
'projects.modal.desc': 'Description',
'projects.modal.desc_ph': 'What this project is about',
'projects.modal.cancel': 'Cancel',
@@ -199,36 +200,22 @@ export default {
'projects.modal.save': 'Save',
'projects.modal.create': 'Create',
// ── Project board ───────────────────────────────────────────────────────────
// ── Project sharing ─────────────────────────────────────────────────────────
'projects.share.title': 'Sharing',
'projects.share.owner': 'owner',
'projects.share.choose_user': 'Choose a person…',
'projects.share.add': 'Add',
'projects.share.all_added': 'Everyone already has access.',
'projects.share.remove': 'Remove',
'projects.share.access.read': 'Read',
'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.',
// ── Project detail ──────────────────────────────────────────────────────────
'project_board.back': 'Projects',
'project_board.open_chat': 'Open Chat',
'project_board.new_ticket': 'New Ticket',
'project_board.tab.tickets': 'Tickets',
'project_board.section.running': 'Running',
'project_board.section.running_empty': 'No tickets running',
'project_board.section.todo': 'Todo',
'project_board.section.todo_empty': 'No tickets to do',
'project_board.section.completed': 'Completed',
'project_board.section.completed_empty': 'No completed tickets',
'project_board.ticket.start': 'Start',
'project_board.ticket.running': 'Running…',
'project_board.ticket.reset': 'Reset',
'project_board.ticket.result': 'Result',
'project_board.ticket.error': 'Error',
'project_board.ticket.no_output': '(no output)',
'project_board.ticket.no_error': '(no error message)',
'project_board.modal.title': 'New Ticket',
'project_board.modal.title_label': 'Title',
'project_board.modal.title_ph': 'What needs to be done',
'project_board.modal.desc_label': 'Description / Prompt',
'project_board.modal.desc_ph': 'Detailed instructions for the agent…',
'project_board.modal.agent': 'Agent',
'project_board.modal.security_group':'Security Group',
'project_board.modal.inherit': '— inherit from project —',
'project_board.modal.cancel': 'Cancel',
'project_board.modal.saving': 'Saving…',
'project_board.modal.create': 'Create',
'project_board.confirm.delete': 'Delete ticket "{title}"?',
// ── Session detail ──────────────────────────────────────────────────────────
'session.back': 'Back',
+18 -31
View File
@@ -184,14 +184,15 @@ export default {
'projects.action.edit': 'Modifier',
'projects.action.delete': 'Supprimer',
'projects.card.updated': 'Mis à jour',
'projects.confirm.delete': 'Supprimer le projet "{name}" ?\nTous les tickets seront également supprimés.',
'projects.confirm.delete': 'Supprimer le projet "{name}" ? Il sera retiré pour toutes les personnes avec qui il est partagé.',
'projects.badge.owned': 'Le vôtre',
'projects.badge.shared_by': 'Partagé par {name}',
'projects.badge.readonly': 'Lecture seule',
'projects.modal.title_edit': 'Modifier le projet',
'projects.modal.title_new': 'Nouveau projet',
'projects.modal.name': 'Nom',
'projects.modal.name_ph': 'Mon projet',
'projects.modal.path': 'Chemin',
'projects.modal.path_ph': '/chemin/vers/le/projet',
'projects.modal.desc': 'Description',
'projects.modal.desc_ph': 'À propos de ce projet',
'projects.modal.cancel': 'Annuler',
@@ -199,36 +200,22 @@ export default {
'projects.modal.save': 'Enregistrer',
'projects.modal.create': 'Créer',
// ── Project board ───────────────────────────────────────────────────────────
// ── Partage de projet ───────────────────────────────────────────────────────
'projects.share.title': 'Partage',
'projects.share.owner': 'propriétaire',
'projects.share.choose_user': 'Choisir une personne…',
'projects.share.add': 'Ajouter',
'projects.share.all_added': 'Tout le monde a déjà accès.',
'projects.share.remove': 'Retirer',
'projects.share.access.read': 'Lecture',
'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.',
// ── Détail du projet ────────────────────────────────────────────────────────
'project_board.back': 'Projets',
'project_board.open_chat': 'Ouvrir la discussion',
'project_board.new_ticket': 'Nouveau ticket',
'project_board.tab.tickets': 'Tickets',
'project_board.section.running': 'En cours',
'project_board.section.running_empty': 'Aucun ticket en cours',
'project_board.section.todo': 'À faire',
'project_board.section.todo_empty': 'Aucun ticket à faire',
'project_board.section.completed': 'Terminé',
'project_board.section.completed_empty': 'Aucun ticket terminé',
'project_board.ticket.start': 'Démarrer',
'project_board.ticket.running': 'En cours…',
'project_board.ticket.reset': 'Réinitialiser',
'project_board.ticket.result': 'Résultat',
'project_board.ticket.error': 'Erreur',
'project_board.ticket.no_output': '(aucune sortie)',
'project_board.ticket.no_error': '(aucun message d\'erreur)',
'project_board.modal.title': 'Nouveau ticket',
'project_board.modal.title_label': 'Titre',
'project_board.modal.title_ph': 'Ce qui doit être fait',
'project_board.modal.desc_label': 'Description / Prompt',
'project_board.modal.desc_ph': 'Instructions détaillées pour l\'agent…',
'project_board.modal.agent': 'Agent',
'project_board.modal.security_group':'Groupe de sécurité',
'project_board.modal.inherit': '— hériter du projet —',
'project_board.modal.cancel': 'Annuler',
'project_board.modal.saving': 'Enregistrement…',
'project_board.modal.create': 'Créer',
'project_board.confirm.delete': 'Supprimer le ticket "{title}" ?',
// ── Session detail ──────────────────────────────────────────────────────────
'session.back': 'Retour',
+18 -31
View File
@@ -208,14 +208,15 @@ export default {
'projects.action.edit': 'Modifica',
'projects.action.delete': 'Elimina',
'projects.card.updated': 'Aggiornato',
'projects.confirm.delete': 'Eliminare il progetto "{name}"?\nTutti i ticket verranno eliminati.',
'projects.confirm.delete': 'Eliminare il progetto "{name}"? Verrà rimosso per tutti coloro con cui è condiviso.',
'projects.badge.owned': 'Tuo',
'projects.badge.shared_by': 'Condiviso da {name}',
'projects.badge.readonly': 'Sola lettura',
'projects.modal.title_edit': 'Modifica progetto',
'projects.modal.title_new': 'Nuovo progetto',
'projects.modal.name': 'Nome',
'projects.modal.name_ph': 'Mio progetto',
'projects.modal.path': 'Percorso',
'projects.modal.path_ph': '/percorso/del/progetto',
'projects.modal.desc': 'Descrizione',
'projects.modal.desc_ph': 'Di cosa tratta questo progetto',
'projects.modal.cancel': 'Annulla',
@@ -223,36 +224,22 @@ export default {
'projects.modal.save': 'Salva',
'projects.modal.create': 'Crea',
// ── Project board ───────────────────────────────────────────────────────────
// ── Condivisione progetto ───────────────────────────────────────────────────
'projects.share.title': 'Condivisione',
'projects.share.owner': 'proprietario',
'projects.share.choose_user': 'Scegli una persona…',
'projects.share.add': 'Aggiungi',
'projects.share.all_added': 'Tutti hanno già accesso.',
'projects.share.remove': 'Rimuovi',
'projects.share.access.read': 'Lettura',
'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.',
// ── Dettaglio progetto ──────────────────────────────────────────────────────
'project_board.back': 'Progetti',
'project_board.open_chat': 'Apri chat',
'project_board.new_ticket': 'Nuovo ticket',
'project_board.tab.tickets': 'Ticket',
'project_board.section.running': 'In esecuzione',
'project_board.section.running_empty': 'Nessun ticket in esecuzione',
'project_board.section.todo': 'Da fare',
'project_board.section.todo_empty': 'Nessun ticket da fare',
'project_board.section.completed': 'Completati',
'project_board.section.completed_empty': 'Nessun ticket completato',
'project_board.ticket.start': 'Avvia',
'project_board.ticket.running': 'In esecuzione…',
'project_board.ticket.reset': 'Reimposta',
'project_board.ticket.result': 'Risultato',
'project_board.ticket.error': 'Errore',
'project_board.ticket.no_output': '(nessun output)',
'project_board.ticket.no_error': '(nessun errore)',
'project_board.modal.title': 'Nuovo ticket',
'project_board.modal.title_label': 'Titolo',
'project_board.modal.title_ph': 'Cosa bisogna fare',
'project_board.modal.desc_label': 'Descrizione / Prompt',
'project_board.modal.desc_ph': 'Istruzioni dettagliate per l\'agente…',
'project_board.modal.agent': 'Agente',
'project_board.modal.security_group':'Gruppo di sicurezza',
'project_board.modal.inherit': '— eredita dal progetto —',
'project_board.modal.cancel': 'Annulla',
'project_board.modal.saving': 'Salvataggio…',
'project_board.modal.create': 'Crea',
'project_board.confirm.delete': 'Eliminare il ticket "{title}"?',
// ── Richieste ──────────────────────────────────────────────────────────────
'inbox.empty': 'Nessuna richiesta in attesa',