Projects: shareable, registry-backed, container-mounted; drop ticket board
Nightly Build / build (push) Successful in 6m27s
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:
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user