import { html, nothing } from 'lit'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { LightElement, renderMarkdown } from '../../lib/base.js'; import { formatDate } from '../tasks/utils.js'; 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 }, }; 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'; } disconnectedCallback() { 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) { this._projectId = projectId; this._project = null; this._error = null; try { const [projRes, tickRes] = await Promise.all([ fetch(`/api/projects/${projectId}`), fetch(`/api/projects/${projectId}/tickets`), ]); 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(); } catch (e) { this._error = e.message; } } async _loadTickets() { if (!this._projectId) return; 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 */ } } _hasActiveTickets() { return this._tickets.some(t => t.status === 'pending' || t.status === 'in_progress'); } _updatePolling() { if (this._hasActiveTickets()) { this._startPolling(); } else { this._stopPolling(); } } _startPolling() { if (this._pollTimer) return; this._pollTimer = setInterval(() => this._loadTickets(), 5000); } _stopPolling() { if (this._pollTimer) { clearInterval(this._pollTimer); this._pollTimer = null; } } _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() { 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(`Delete ticket "${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`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); 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; } } _back() { this._stopPolling(); this.dispatchEvent(new CustomEvent('project-back', { bubbles: true, composed: true })); } async _openChat() { try { const res = await fetch(`/api/projects/${this._projectId}/session`, { method: 'POST' }); if (!res.ok) throw new Error(await res.text()); const { source } = await res.json(); window.dispatchEvent(new CustomEvent('project-chat-open', { detail: { source, label: this._project?.name ?? `Project ${this._projectId}` }, })); } catch (e) { this._error = e.message; } } _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'; return html`
${ticket.error ?? '(no error message)'}`}