// Panneau d'activité d'un job : ouvre un flux SSE vers `/jobs/{id}/events`, // rejoue l'historique renvoyé par le serveur puis affiche le direct. import { endpoints, escapeHtml, subscribe } from "./api.js"; class JobDetail extends HTMLElement { set jobId(value) { const id = value ?? null; if (this._jobId === id) return; this._jobId = id; this.open(); } connectedCallback() { if (!this._jobId) this.renderPlaceholder(); } disconnectedCallback() { this.close(); } close() { if (this._source) { this._source.close(); this._source = null; } } open() { this.close(); this._finished = false; if (!this._jobId) { this.renderPlaceholder(); return; } this.innerHTML = `
`; this._log = this.querySelector("#log"); this._source = subscribe(endpoints.jobEvents(this._jobId), { onProgress: (event) => { this.append(event); if (event.type === "finished") { this._finished = true; this.close(); } }, onGap: (skipped) => this.append({ type: "gap", note: `missed ${skipped} events, refresh to resync` }), // Un `error` survient aussi quand le serveur ferme le flux (job terminé). // On ferme alors la source pour éviter la boucle de reconnexion (le job // n'est plus en cours, la route renverrait 404). onError: () => { if (this._finished) return; this.append({ type: "gap", note: "stream closed" }); this.close(); }, }); } renderPlaceholder() { this.innerHTML = `

Select a job in progress to watch the agent work.

`; } append(event) { if (!this._log) return; this._log.append(this.entry(event)); this._log.scrollTop = this._log.scrollHeight; } entry(event) { return event.type === "tool_result" ? this.resultEntry(event) : this.lineEntry(event); } // Ligne simple : appel d'outil, fin de review, gap. lineEntry(event) { const row = document.createElement("div"); row.className = "log-entry"; row.dataset.kind = event.type; const { tag, body } = this.describe(event); const tagEl = document.createElement("span"); tagEl.className = "log-tag"; tagEl.textContent = tag; const bodyEl = document.createElement("span"); bodyEl.className = "log-body"; bodyEl.innerHTML = body; row.append(tagEl, bodyEl); return row; } // Résultat d'outil : replié par défaut, avec un aperçu sur une ligne. resultEntry(event) { const details = document.createElement("details"); details.className = "log-result"; const summary = document.createElement("summary"); const chev = document.createElement("span"); chev.className = "chev"; chev.setAttribute("aria-hidden", "true"); const tag = document.createElement("span"); tag.className = "log-tag"; tag.textContent = "result"; const preview = document.createElement("span"); preview.className = "log-preview"; preview.textContent = this.preview(event.output); summary.append(chev, tag, preview); const body = document.createElement("div"); body.className = "log-body"; const truncated = event.truncated ? ' (truncated)' : ""; body.innerHTML = `${escapeHtml(event.output)}${truncated}`; details.append(summary, body); return details; } preview(text) { const line = String(text ?? "").replace(/\s+/g, " ").trim(); return line.length > 80 ? `${line.slice(0, 80)}…` : line; } describe(event) { switch (event.type) { case "tool_call": return { tag: "call", body: `${escapeHtml(event.name)} ${escapeHtml(event.arguments)}`, }; case "finished": return { tag: "done", body: event.ok ? "review finished" : "review failed", }; case "gap": return { tag: "gap", body: escapeHtml(event.note ?? "gap") }; default: return { tag: escapeHtml(event.type ?? "event"), body: "" }; } } } customElements.define("steep-job-detail", JobDetail);