s.replace(/<\\\/script/gi, ' { const m = /]*>([\s\S]*?)<\/title>/i.exec(html); return m ? m[1].trim() : ''; };
function factory() {
if (typeof document === 'undefined') return [];
return Array.from(document.querySelectorAll('script[type="text/plain"][data-layout]')).map((n) => {
const html = unescape(n.textContent.replace(/^\n/, ''));
return { id: n.dataset.layout, name: titleOf(html) || n.dataset.layout, html, factory: true, hidden: n.hasAttribute('data-hidden') };
});
}
// Vistas propias de la app (Now, List): cubren cosas que el contrato no da (todos los proyectos), así que se dibujan
// en la página. Las registra Shell con registerNative(id, name, render).
const natives = [];
function registerNative(id, name, render) { natives.push({ id, name, render, factory: true, native: true }); }
// Los que se ven en el selector: de fábrica visibles, las vistas propias y los subidos que cumplen el contrato 2.
function list() {
return factory().filter((l) => !l.hidden).concat(natives)
.concat(Store.get().layouts.filter((l) => validate(l.html).ok).map((l) => ({ id: l.id, name: l.name || titleOf(l.html) || l.id, html: l.html, factory: false })));
}
const find = (id) => list().find((l) => l.id === id) || null;
const example = () => factory().find((l) => l.id === 'minimo') || null;
// Un layout subido necesita y un con texto.
function validate(html) {
if (typeof html !== 'string' || !html.trim()) return { ok: false, missing: ['content (the file is empty)'] };
const metas = html.match(/]*>/gi) || [];
const meta = metas.find((m) => /\bname\s*=\s*["']?tasky-layout["'\s/>]/i.test(m));
const version = meta ? ((/\bcontent\s*=\s*["']?(\d+)/i.exec(meta) || [])[1] || '') : '';
const name = titleOf(html).replace(/\s+/g, ' ');
if (version === '1') return { ok: false, old: true, missing: ['contract version 2: this layout was made for contract 1 (scores, no workflows)'] };
const missing = [];
if (version !== '2') missing.push('');
if (!name) missing.push('a with the layout name');
return missing.length ? { ok: false, missing } : { ok: true, name };
}
// Datos para el layout, con la forma exacta de docs/spec/contrato-layout.md (versión 2).
function data() {
const p = Store.current();
if (!p) return { type: 'tasky:data', version: 2, project: null, workflows: [], tasks: [], selected: null, filter: null };
// Línea de tiempo: con past, las tareas y los estados son los de ese momento (sin agentes) y no se edita nada.
const past = getPast();
// Universos (A7): la vista muestra solo los workflows del universo elegido; el avance del proyecto suma todos.
const uni = getUniverse(), inUni = (wid) => !uni || Store.universeOf(p.id, wid) === uni;
const tasks = Store.tasksOf(p.id).filter((t) => (!past || past.statuses.get(t.id) != null) && inUni(t.workflow)).map((t) => {
const a = past ? null : Store.agentOn(p.id, t.id);
const status = past ? past.statuses.get(t.id) : t.status;
// Archivos (modo Team): cuántas versiones tiene y cuántos entregables pedidos faltan.
const fl = past ? [] : Store.get().files.filter((x) => x.projectId === p.id && x.taskId === t.id && !x.deleted);
const delivered = new Set(fl.filter((x) => x.kind === 'delivery').map((x) => x.name));
const pending = typeof Team !== 'undefined' && Team.on() && !past ? (t.deliverables || []).filter((n) => !delivered.has(n)).length : 0;
return { id: t.id, title: t.title, mission: t.mission || '', workflow: t.workflow, status, needs: status === 'needs-you' && !past ? (t.needs || '') : '', ...(typeof Team !== 'undefined' && Team.on() ? { files: fl.length, pending, unread: !past && Team.unreadIn(p.id, t.id) > 0 } : {}), agent: a ? { id: a.id, name: a.name, icon: a.icon, color: safeColor(a.color, '#5B9DFF'), path: Store.agentPath(a.icon), state: typeof Agents !== 'undefined' ? Agents.liveLine(a).state : 'working' } : null };
});
const count = (wid) => { const ts = tasks.filter((t) => wid == null || t.workflow === wid); const done = ts.filter((t) => t.status === 'done').length; return { done, total: ts.length, left: ts.length - done }; };
const c = past ? count(null) : Store.counts(p.id);
const workflows = Store.workflowsOf(p.id).filter((w) => inUni(w.id) && (!past || tasks.some((t) => t.workflow === w.id))).map((w) => ({ id: w.id, name: w.name, mission: w.mission || '', color: Store.colorOf(p.id, w.id), ...pick(count(w.id)) }));
const out = { type: 'tasky:data', version: 2, project: { id: p.id, name: p.name, status: p.status, left: c.left, done: c.done, total: c.total }, workflows, tasks, selected: getSelected(), filter: getFilter(), past: past ? { at: past.at, label: past.label } : null, ...(Store.universes().length > 1 ? { universe: uni } : {}) };
// Modo Team: las personas conectadas que miran este proyecto, y qué miran (sus naves).
if (typeof Team !== 'undefined' && Team.on()) out.people = Team.people().filter((x) => x.userId !== Team.myId() && x.focus && x.focus.project === p.id && (!uni || (x.focus.universe || 'Main') === uni))
.map((x) => ({ id: x.userId, name: String(x.name || '').slice(0, 60), color: safeColor(x.color, '#FFD23F'), shape: Ships.SHAPES.includes(x.shape) ? x.shape : 'arrow', path: Ships.path(x.shape), kind: x.focus.kind, target: x.focus.id }));
return out;
}
const pick = (c) => ({ done: c.done, total: c.total });
// Colores que viajan a los layouts: solo #RRGGBB. En modo Team los escribe otra persona (agentes, naves).
const safeColor = (c, fallback) => (/^#[0-9A-Fa-f]{6}$/.test(String(c || '')) ? c : fallback);
// La app le pasa Fredoka a cada layout: el mismo @font-face, insertado al principio del del srcdoc.
function withFont(html) {
const css = typeof document !== 'undefined' && document.getElementById('tasky-font') ? document.getElementById('tasky-font').textContent : '';
if (!css) return html;
const tag = '';
return /]*>/i.test(html) ? html.replace(/]*>/i, (m) => m + tag) : tag + html;
}
function post(msg) { if (frame && frame.contentWindow) frame.contentWindow.postMessage(msg, '*'); }
function push() {
if (current && current.native) { current.render(root.querySelector('.native-view')); return; }
if (ready) post(data());
}
function stalled() {
if (ready || !root) return;
const box = document.createElement('div');
box.className = 'layout-stalled';
box.setAttribute('role', 'alert');
box.append(Help.TEXT.stalled(current ? current.name : '?'));
for (const l of list()) {
if (current && l.id === current.id) continue;
const b = document.createElement('button');
b.textContent = l.name;
b.setAttribute('aria-label', 'Use the ' + l.name + ' view');
b.dataset.detail = 'view:' + l.id;
b.onclick = () => Actions.chooseLayout(l.id);
box.append(b);
}
root.querySelectorAll('.layout-stalled').forEach((n) => n.remove());
root.prepend(box);
}
function use(layoutId) {
if (!root) return null;
const l = find(layoutId) || list()[0];
if (!l) return null;
clearTimeout(timer);
ready = false;
current = l;
root.textContent = '';
if (l.native) {
frame = null;
const box = document.createElement('div');
box.className = 'native-view native-' + l.id;
root.append(box);
l.render(box);
return l.id;
}
frame = document.createElement('iframe');
frame.setAttribute('sandbox', 'allow-scripts');
frame.setAttribute('title', l.name + ' view');
frame.className = 'layout-frame';
frame.srcdoc = withFont(l.html);
root.append(frame);
timer = setTimeout(stalled, READY_MS);
return l.id;
}
const str = (v) => typeof v === 'string' && v.length > 0 && v.length < 200;
function onMessage(e) {
if (!frame || e.source !== frame.contentWindow) return;
const m = e.data;
if (!m || typeof m !== 'object' || typeof m.type !== 'string') return;
switch (m.type) {
case 'tasky:ready':
ready = true; clearTimeout(timer);
root.querySelectorAll('.layout-stalled').forEach((n) => n.remove());
push();
if (pending) { focus(pending); pending = null; }
break;
case 'tasky:task':
if (str(m.id)) Shell.taskClicked(m.id);
break;
case 'tasky:select':
if (m.kind === 'workflow' && str(m.id)) Shell.select({ kind: 'workflow', id: m.id });
else if (m.kind === 'task' && str(m.id)) Shell.select({ kind: 'task', id: m.id }); // selecciona, no cambia el estado
break;
case 'tasky:hover':
if (m.kind === 'none') Shell.peek(null);
else if (m.kind === 'project') Shell.peek({ kind: 'project' });
else if ((m.kind === 'task' || m.kind === 'workflow') && str(m.id)) Shell.peek({ kind: m.kind, id: m.id });
break;
case 'tasky:interact':
Shell.interacted();
break;
case 'tasky:height': {
const v = Number(m.value);
if (Number.isFinite(v) && v > 0) frame.style.height = Math.min(Math.max(Math.ceil(v), 120), 20000) + 'px';
break;
}
}
}
function mount(element, selected, filter, past, universe) {
root = element;
if (universe) getUniverse = universe;
if (selected) getSelected = selected;
if (filter) getFilter = filter;
if (past) getPast = past;
addEventListener('message', onMessage);
Store.subscribe((s) => {
if (!current || (s.settings.layoutId !== current.id && find(s.settings.layoutId))) use(s.settings.layoutId);
else if (!find(current.id)) use(s.settings.layoutId);
else push();
});
return use(Store.get().settings.layoutId);
}
// La app lleva la cámara (o la vista) a un workflow o a una tarea: tasky:focus { workflow } | { task }.
let pending = null; // un foco pedido antes de que la vista esté lista se aplica al llegar tasky:ready
function focus(target) {
if (!target) return;
if (!ready) { pending = target; return; }
if (target.task) post({ type: 'tasky:focus', task: String(target.task) });
else if (target.workflow) post({ type: 'tasky:focus', workflow: String(target.workflow) });
}
return { mount, use, list, data, push, focus, registerNative, validate, example, current: () => current, isReady: () => ready, isNative: () => !!(current && current.native) };
})();
/* ==================== Shell ==================== */
const Shell = (() => {
const $ = (sel) => document.querySelector(sel);
const T = () => Help.TEXT;
const el = (tag, props = {}, ...kids) => {
const n = document.createElement(tag);
for (const [k, v] of Object.entries(props)) {
if (/^aria[A-Z]/.test(k)) n.setAttribute('aria-' + k.slice(4).toLowerCase(), v);
else if (k === 'data') Object.assign(n.dataset, v);
else if (k === 'role') n.setAttribute('role', v);
else n[k] = v;
}
n.append(...kids.filter((x) => x != null));
return n;
};
const btn = (text, onclick, props = {}) => el('button', { type: 'button', textContent: text, onclick, ...props });
const svg = (paths, cls = '') => { const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('aria-hidden', 'true'); if (cls) s.setAttribute('class', cls); s.innerHTML = paths; return s; };
const ico = (status, color) => { const i = el('i', { className: 'ico ' + status, ariaHidden: 'true' }); if (color) i.style.setProperty('--c', color); return i; };
// Lo que muestra la tarjeta de detalle: lo señalado (peek) o, si no, lo elegido (focus).
let focus = null;
const prevPct = new Map();
const bumpUntil = new Map(); // el pulso de una fila dura aunque se redibuje
function notify(text) {
if (/not saved|could not|failed|did not work/i.test(String(text))) noteError(text);
const box = $('#notice');
box.textContent = '';
box.append(el('button', { className: 'close', textContent: '✕', ariaLabel: 'Close message', onclick: () => { box.hidden = true; } }), text);
box.hidden = false;
}
// ---- Versión nueva: la app publicada (Team: version.json) o la que sirve el puente (/version) cambió ----
// No recarga sola: avisa "New version available · Reload" y espera; si hay texto sin enviar, pregunta antes.
let buildSeen = null, updateShown = false;
const myBuild = () => { const m = typeof document !== 'undefined' && document.querySelector('meta[name="tasky-build"]'); return m ? m.content : null; };
async function checkVersion() {
let url = null;
if (Team.on() && /^https?:$/.test(location.protocol)) url = 'version.json';
else if (typeof Bridge !== 'undefined' && Bridge.servedByBridge()) url = location.origin + '/version';
if (!url) return null;
let build = null;
try { const r = await fetch(url + '?t=' + Date.now(), { cache: 'no-store' }); if (r.ok) build = (await r.json()).build || null; } catch {}
if (!build) return null;
const mine = myBuild() || buildSeen;
if (!mine) { buildSeen = build; return false; }
if (build !== mine) { showUpdate(); return true; }
return false;
}
const UNSENT_SKIP = ['d-needs', 'search', 'acc-name'];
const unsentText = () => [...document.querySelectorAll('textarea, input')].some((n) => ['text', 'textarea', 'email', 'password', 'search', ''].includes(n.type === 'textarea' ? 'textarea' : n.type || '') && n.value && n.value.trim() && !n.readOnly && n.offsetParent !== null && !UNSENT_SKIP.includes(n.dataset.key) && !n.classList.contains('tname') && !n.classList.contains('ainput'));
function showUpdate() {
const bar = $('#update');
if (!bar || updateShown) return;
updateShown = true;
bar.hidden = false; bar.textContent = '';
const reload = btn('Reload', () => {
if (unsentText()) {
if (bar.querySelector('.uconfirm')) return;
bar.append(el('span', { className: 'uconfirm' }, el('span', { textContent: T().update.unsent }),
btn('Reload anyway', () => location.reload(), { className: 'abtn', data: { key: 'update-anyway' } }),
btn('Not now', () => { const c = bar.querySelector('.uconfirm'); if (c) c.remove(); }, { className: 'abtn', data: { key: 'update-later' } })));
return;
}
location.reload();
}, { className: 'abtn', data: { key: 'update-reload' } });
bar.append(el('strong', { textContent: T().update.title }), el('span', { textContent: '· ' + T().update.text }), reload);
}
function watchVersion() {
checkVersion();
setInterval(checkVersion, 5 * 60 * 1000);
document.addEventListener('visibilitychange', () => { if (!document.hidden) checkVersion(); });
}
// ---- "Report a problem": un archivo de diagnóstico SIN contenido de tareas, mensajes ni archivos ----
const errorsSeen = [];
function noteError(msg) {
const m = String((msg && (msg.message || msg)) || '').split('\n')[0].slice(0, 300);
if (!m) return;
errorsSeen.push({ at: new Date().toISOString(), message: m });
if (errorsSeen.length > 30) errorsSeen.shift();
}
function bindErrors() {
addEventListener('error', (e) => noteError(e.message || e.error));
addEventListener('unhandledrejection', (e) => noteError(e.reason));
const orig = console.error.bind(console);
console.error = (...a) => { try { noteError(a.map((x) => (x && x.message) || String(x)).join(' ')); } catch {} orig(...a); };
}
// Todo texto escrito por personas o agentes (tareas, mensajes, archivos, nombres) se tapa en los errores anotados.
function scrubber() {
const s = Store.get(), secrets = new Set();
const add = (v) => { v = String(v == null ? '' : v).trim(); if (v.length >= 3) secrets.add(v); };
for (const x of s.projects) { add(x.name); add(x.doneWhen); add(x.notes); }
for (const w of s.workflows) { add(w.name); add(w.mission); add(w.universe); }
for (const t of s.tasks) { add(t.title); add(t.mission); add(t.needs); add(t.reviewNote); add(t.backComment); for (const f of [].concat(t.deliverables || [], t.files || [], t.touched || [])) add(f && f.path || f); }
for (const m of s.messages) add(m.body);
for (const f of s.files) { add(f.name); add(f.note); add(f.path); }
for (const n of s.notifications) add(n.body);
for (const a of s.agents) { add(a.name); add(a.cwd); add(a.transcript); }
for (const e of s.trash || []) add(e.name);
for (const e of s.history.slice(-300)) { add(e.title); add(e.note); add(e.comment); add(e.name); }
if (Team.on()) for (const m of Team.members()) { add(m.name); add(m.email); }
const list = [...secrets].sort((a, b) => b.length - a.length);
// Un error puede traer un trozo de un texto (por ejemplo una palabra de un título): también se tapa cada palabra
// que venga de lo escrito, salvo las palabras de la propia app (así el error se sigue entendiendo).
const words = new Set();
for (const v of list) for (const w of v.toLowerCase().split(/[^\p{L}\p{N}_-]+/u)) if (w.length >= 4) words.add(w);
const appWords = new Set();
const walk = (x) => { if (typeof x === 'string' || typeof x === 'function') for (const w of String(x).toLowerCase().split(/[^\p{L}\p{N}_-]+/u)) appWords.add(w); else if (x && typeof x === 'object') for (const v of Object.values(x)) walk(v); };
walk(Help.TEXT);
return (text) => {
let out = String(text);
for (const v of list) out = out.split(v).join('[hidden]');
out = out.replace(/[\p{L}\p{N}_-]{4,}/gu, (w) => (words.has(w.toLowerCase()) && !appWords.has(w.toLowerCase()) ? '[hidden]' : w));
return out.replace(/[\w.+-]+@[\w-]+\.[\w.-]+/g, '[email]').replace(/eyJ[\w-]+\.[\w-]+\.[\w-]+/g, '[token]').replace(/\b[0-9a-f]{24,}\b/gi, '[id]').replace(/(sb_(?:publishable|secret)_)[\w-]+/g, '$1[key]').replace(/“[^”]*”|"[^"]*"/g, '"[text]"');
};
}
function diagnostic() {
const s = Store.get(), scrub = scrubber();
const size = (() => { try { return (localStorage.getItem(Team.on() ? 'tasky:team:cache' : Store.KEY) || '').length * 2; } catch { return null; } })();
const data = {
kind: 'tasky-diagnostic',
note: 'Made by "Report a problem". It has no task, message or file content: only the version, the browser, the mode, the bridge and the last errors (with any names or text hidden).',
createdAt: new Date().toISOString(),
version: myBuild() || buildSeen || 'unknown (opened from the disk)',
mode: Team.on() ? 'team' : 'personal',
openedFrom: location.protocol === 'file:' ? 'disk' : typeof Bridge !== 'undefined' && Bridge.servedByBridge() ? 'bridge' : 'web',
browser: navigator.userAgent, language: navigator.language,
timeZone: (() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { return ''; } })(),
window: innerWidth + 'x' + innerHeight, view: s.settings.layoutId,
bridge: typeof Bridge !== 'undefined' ? { on: Bridge.isOn(), port: Number(s.settings.bridgePort) || (Team.on() && Team.config().bridgePort) || 4317 } : null,
team: Team.on() ? { signedIn: Team.isEntered(), online: Team.isOnline(), role: Team.role(), people: Team.members().length, missingTables: [...Team.missing()] } : null,
counts: { projects: s.projects.length, workflows: s.workflows.length, tasks: s.tasks.length, history: s.history.length, agents: s.agents.length, files: s.files.length, messages: s.messages.length, notifications: s.notifications.length, trash: (s.trash || []).length, views: s.layouts.length },
savedBytes: size, storageProblem: Store.problem(),
errors: errorsSeen.map((e) => ({ at: e.at, message: scrub(e.message) })),
};
return JSON.stringify(data, null, 2);
}
function reportProblem() {
const f = 'tasky-diagnostic-' + Store.today() + '.json';
download(f, diagnostic(), 'application/json');
notify(T().report.done(f));
return f;
}
// El dueño ve un aviso si los archivos se acercan al límite (una vez por sesión).
let storageReminded = false;
function storageReminder() {
if (storageReminded || !Team.on() || Team.role() !== 'owner') return;
const u = Team.storageUsage();
if (!u.near) return;
storageReminded = true;
notify(T().team.storage(fileSize(u.used), fileSize(u.limit), Math.round(100 * u.pct)) + ' ' + T().team.storageNear);
}
function storageNotice() {
const p = Store.problem();
if (p && T().storage[p]) notify(T().storage[p]);
}
// ---- Importar: soltar, elegir o pegar ----
function summarize(name, r) {
const head = name ? name + ': ' : '';
if (!r.ok) return head + r.error + (r.warnings.length ? '\n · ' + r.warnings.join('\n · ') : '');
let s = head + r.name + ' ' + (r.created ? 'created' : 'updated') + ', ' + r.tasks + (r.tasks === 1 ? ' task' : ' tasks') + ' in ' + r.workflows + (r.workflows === 1 ? ' workflow' : ' workflows');
if (!r.created && r.added) s += ' (' + r.added + ' new)';
if (r.total > r.tasks) s += ', ' + r.total + ' in total';
s += '.';
if (r.warnings.length) s += '\n ' + r.warnings.length + (r.warnings.length === 1 ? ' warning' : ' warnings') + ':\n · ' + r.warnings.join('\n · ');
return s;
}
function importTexts(items) {
const lines = items.map(({ name, text }) => summarize(name, Actions.importHandoff(text)));
if (lines.length) { focus = null; notify(lines.join('\n')); render(); }
return lines;
}
const isLayoutFile = (name) => /\.html?$/i.test(String(name || ''));
async function importFiles(files) {
const all = Array.from(files || []);
const layouts = all.filter((f) => isLayoutFile(f.name));
if (layouts.length) await uploadLayouts(layouts);
const items = [];
for (const f of all.filter((x) => !isLayoutFile(x.name))) {
try { items.push({ name: f.name, text: await f.text() }); } catch { items.push({ name: f.name, text: null }); }
}
return importTexts(items);
}
let picker = null;
function pickFile() {
if (!picker) {
picker = el('input', { type: 'file', multiple: true, accept: '.md,.txt,text/markdown,text/plain', hidden: true });
picker.addEventListener('change', () => { importFiles(picker.files).then(() => { picker.value = ''; }); });
document.body.append(picker);
}
picker.click();
}
function bindDrop() {
let depth = 0;
const hasFiles = (e) => e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files');
addEventListener('dragenter', (e) => { if (!hasFiles(e)) return; e.preventDefault(); depth++; document.body.classList.add('dragging'); });
addEventListener('dragover', (e) => { if (!hasFiles(e)) return; e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; });
addEventListener('dragleave', () => { if (--depth <= 0) { depth = 0; document.body.classList.remove('dragging'); } });
addEventListener('drop', (e) => {
if (!e.dataTransfer) return;
e.preventDefault(); depth = 0; document.body.classList.remove('dragging');
if (e.dataTransfer.files && e.dataTransfer.files.length) importFiles(e.dataTransfer.files);
});
}
// Pegar un handoff en cualquier parte (fuera de campos de texto) también lo importa.
function bindPaste() {
addEventListener('paste', (e) => {
const t = e.target;
if (t && (t.tagName === 'TEXTAREA' || t.tagName === 'INPUT' || t.isContentEditable)) return;
const text = e.clipboardData ? e.clipboardData.getData('text') : '';
if (/^\s*---/.test(text) && /##\s*tasks/i.test(text)) { e.preventDefault(); importTexts([{ name: '', text }]); }
});
}
// ---- Descargas y portapapeles ----
const PERSONAL_LIMIT = 5 * 1048576;
function downloadBlob(filename, blob) {
const url = URL.createObjectURL(blob);
const a = el('a', { href: url, download: filename, hidden: true });
document.body.append(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 60000);
}
function download(filename, text, type = 'text/markdown;charset=utf-8') {
const url = URL.createObjectURL(new Blob([text], { type }));
const a = el('a', { href: url, download: filename, hidden: true });
document.body.append(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 4000);
}
async function copyText(text) {
try { await navigator.clipboard.writeText(text); return true; } catch {}
const ta = el('textarea', { value: text });
ta.style.cssText = 'position:fixed;left:-9999px;top:0';
document.body.append(ta); ta.select();
let ok = false;
try { ok = document.execCommand('copy'); } catch {}
ta.remove();
return ok;
}
const copied = (ok, what) => notify(ok ? what + ' copied.' : 'Could not copy; select the text and press Ctrl+C.');
function fileName(name, used = new Set()) {
const base = String(name).normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'project';
let f = base + '.tasky.md';
for (let n = 2; used.has(f); n++) f = base + '-' + n + '.tasky.md';
used.add(f);
return f;
}
function downloadProject(projectId, used) {
const p = Store.project(projectId);
if (!p) return null;
const f = fileName(p.name, used);
download(f, Handoff.serialize(Store.bundle(projectId)));
return f;
}
async function downloadProjects(ids) {
const used = new Set(), names = [];
for (const id of ids) { const f = downloadProject(id, used); if (f) names.push(f); await new Promise((r) => setTimeout(r, 300)); }
if (names.length) notify('Downloaded: ' + names.join(', ') + '.');
return names;
}
// ---- Panel lateral (diálogo) ----
let panel = null;
function focusables(root) {
return Array.from(root.querySelectorAll('button, input, textarea, select, [tabindex]:not([tabindex="-1"])')).filter((n) => !n.disabled && !n.closest('[hidden]') && n.offsetParent !== null);
}
function closePanel() {
if (!panel) return;
const { opener, root, scrim } = panel;
panel = null;
root.remove(); scrim.remove();
const again = opener && !opener.isConnected && opener.dataset && opener.dataset.key ? Array.from(document.querySelectorAll('[data-key]')).find((n) => n.dataset.key === opener.dataset.key) : null;
const target = again || (opener && opener.isConnected ? opener : document.querySelector('.tool'));
if (target && typeof target.focus === 'function') target.focus();
}
function openPanel(kind, title) {
closePanel();
const opener = document.activeElement;
const scrim = el('div', { className: 'scrim', onclick: closePanel });
const root = el('aside', { id: 'panel', className: 'panel-' + kind, ariaModal: 'true', ariaLabelledby: 'panel-title' });
root.setAttribute('role', 'dialog');
root.append(el('div', { className: 'panel-head' }, el('h2', { id: 'panel-title', textContent: title }), btn('✕', closePanel, { className: 'panel-close', ariaLabel: 'Close panel' })));
root.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
const f = focusables(root);
if (!f.length) return;
if (e.shiftKey && document.activeElement === f[0]) { e.preventDefault(); f[f.length - 1].focus(); }
else if (!e.shiftKey && document.activeElement === f[f.length - 1]) { e.preventDefault(); f[0].focus(); }
});
document.body.append(scrim, root);
panel = { kind, opener, root, scrim };
return root;
}
function exportAll() {
const live = Store.get().projects.filter((p) => !p.archived);
if (!live.length) { notify(T().noProjectsToExport); return; }
if (live.length === 1) { notify('Downloaded: ' + downloadProject(live[0].id) + '.'); return; }
const root = openPanel('export', 'Export');
const list = el('ul', { className: 'pn-list' });
const boxes = live.map((p) => {
const cb = el('input', { type: 'checkbox', checked: true, value: p.id, ariaLabel: 'Export ' + p.name });
list.append(el('li', {}, el('label', { className: 'pn-item' }, cb, el('span', { textContent: p.name }))));
return cb;
});
const go = btn('', () => { const ids = boxes.filter((b) => b.checked).map((b) => b.value); if (ids.length) { closePanel(); downloadProjects(ids); } }, { className: 'primary' });
const label = () => { const n = boxes.filter((b) => b.checked).length; go.textContent = n ? 'Download ' + n + (n === 1 ? ' file' : ' files') : 'Pick at least one'; go.disabled = !n; };
boxes.forEach((b) => b.addEventListener('change', label));
label();
root.append(el('p', { textContent: T().exportHint }), list, el('div', { className: 'pn-actions' }, go));
boxes[0].focus();
}
function openTemplate() {
const root = openPanel('template', 'Template for your agent');
const copyPhrase = btn('Copy instruction + template', async () => copied(await copyText(T().agentPhrase + '\n\n' + Handoff.TEMPLATE), 'Instruction and template'));
const dl = btn('Download tasky-template.tasky.md', () => { download('tasky-template.tasky.md', Handoff.TEMPLATE); notify('Downloaded: tasky-template.tasky.md.'); }, { className: 'primary' });
const cp = btn('Copy template', async () => copied(await copyText(Handoff.TEMPLATE), 'Template'));
root.append(
el('h3', { textContent: 'For your agent' }), el('p', { className: 'phrase', textContent: T().agentPhrase }), el('div', { className: 'pn-actions' }, copyPhrase),
el('h3', { textContent: 'tasky-template.tasky.md' }), el('pre', { className: 'pn-text', tabIndex: 0, textContent: Handoff.TEMPLATE, ariaLabel: 'Template text' }),
el('div', { className: 'pn-actions' }, dl, cp),
);
dl.focus();
}
function openBackup() {
const root = openPanel('backup', 'Backup');
const dl = btn('Download backup', () => { const f = 'tasky-backup-' + Store.today() + '.json'; download(f, JSON.stringify(Store.get(), null, 2), 'application/json'); notify('Downloaded: ' + f + '.'); }, { className: 'primary' });
const file = el('input', { type: 'file', accept: '.json,application/json', hidden: true });
let pending = null;
const yes = btn('Yes, replace everything', () => {
if (!pending) return;
const r = Actions.restoreBackup(pending);
if (r.ok) { closePanel(); focus = null; notify('Backup restored: ' + r.projects + (r.projects === 1 ? ' project.' : ' projects.')); }
else notify('That backup could not be restored.');
pending = null;
}, { className: 'danger' });
const no = btn('No', () => { pending = null; confirm.hidden = true; restore.focus(); });
const confirmText = el('span');
const confirm = el('div', { className: 'pn-confirm', hidden: true }, confirmText, yes, no);
file.addEventListener('change', async () => {
const f = file.files && file.files[0];
file.value = '';
if (!f) return;
let data = null;
try { data = JSON.parse(await f.text()); } catch {}
if (!Actions.isBackup(data)) { notify(f.name + ': this is not a Tasky backup.'); return; }
pending = data;
confirmText.textContent = 'Replace everything in this browser with ' + f.name + ' (' + data.projects.length + ' projects)? ';
confirm.hidden = false;
yes.focus();
});
const restore = btn('Restore backup', () => file.click());
if (Team.on()) restore.hidden = true; // en Team, el respaldo del equipo va abajo (solo el dueño)
root.append(el('p', { textContent: Team.on() ? T().team.backupMine : T().backupHint }), el('div', { className: 'pn-actions' }, dl, restore), confirm, file);
if (!Team.on()) {
// Cuánto ocupa lo guardado en este navegador (los navegadores permiten unos 5 MB por sitio).
const used = (() => { try { return (localStorage.getItem(Store.KEY) || '').length * 2; } catch { return 0; } })();
const near = used >= 0.8 * PERSONAL_LIMIT;
root.append(el('p', { className: 'store-use' + (near ? ' near' : ''), data: { key: 'storage' }, textContent: T().personalStorage(fileSize(used), fileSize(PERSONAL_LIMIT)) + (near ? ' ' + T().personalStorageNear : '') }));
} else if (Team.can('restore')) {
const zipIn = el('input', { type: 'file', accept: '.zip,application/zip', hidden: true });
const status = el('p', { className: 'fnote', role: 'status' });
const tdl = btn('Download the team backup (.zip)', async () => {
tdl.disabled = true;
const r = await Team.backupTeam((i, n) => { status.textContent = T().team.backupProgress(i, n); });
tdl.disabled = false;
if (!r) return;
const f = 'tasky-team-backup-' + Store.today() + '.zip';
downloadBlob(f, r.blob);
status.textContent = T().team.backupDone(f, r.counts);
}, { className: 'primary', data: { key: 'team-backup' } });
const trs = btn('Restore a team backup…', () => zipIn.click(), { disabled: !Team.isEmptyTeam(), title: T().team.restoreTitle, data: { key: 'team-restore' } });
zipIn.addEventListener('change', async () => {
const f = zipIn.files && zipIn.files[0]; zipIn.value = '';
if (!f) return;
trs.disabled = true;
const r = await Team.restoreTeam(await f.arrayBuffer(), (i, n) => { status.textContent = T().team.restoreProgress(i, n); });
trs.disabled = !Team.isEmptyTeam();
if (r.error) { status.textContent = r.error; notify(r.error); return; }
status.textContent = T().team.restored(r);
const links = (r.invited || []).map((x) => x.email + ' (' + x.role + '): ' + Team.inviteLink(x)).join('\n');
notify(T().team.restored(r) + (links ? '\n' + T().team.restoredInvites + '\n' + links : ''));
});
root.append(el('h3', { textContent: 'Team backup' }), el('p', { textContent: T().team.backupHint }), el('div', { className: 'pn-actions' }, tdl, trs), status, zipIn);
}
dl.focus();
}
function viewHandoff(projectId) {
const p = Store.project(projectId);
if (!p) return;
const root = openPanel('handoff', p.name + ' · handoff');
const text = Handoff.serialize(Store.bundle(projectId));
const dl = btn('Download', () => { notify('Downloaded: ' + downloadProject(projectId) + '.'); }, { className: 'primary' });
const cp = btn('Copy', async () => copied(await copyText(Handoff.serialize(Store.bundle(projectId))), 'Handoff'));
root.append(el('p', { textContent: T().handoffHint }), el('pre', { className: 'pn-text', tabIndex: 0, textContent: text, ariaLabel: 'Handoff text' }), el('div', { className: 'pn-actions' }, dl, cp));
dl.focus();
}
function openArchive() {
const root = openPanel('archive', 'Archived projects');
const list = el('ul', { className: 'pn-list' });
const fill = () => {
list.textContent = '';
const arch = Store.get().projects.filter((p) => p.archived);
if (!arch.length) { list.append(el('li', { className: 'pn-item', textContent: 'No archived projects.' })); return; }
for (const p of arch) list.append(el('li', { className: 'pn-item' }, el('span', { textContent: p.name }),
btn('Restore', () => { Actions.archiveProject(p.id, false); notify(p.name + ' is back.'); fill(); }, { className: 'row-btn', ariaLabel: 'Restore ' + p.name, data: { key: 'restore|' + p.id } })));
};
fill();
root.append(el('p', { textContent: T().archiveHint }), list);
const f = focusables(root); (f[1] || f[0]).focus();
}
// ---- Papelera: restaurar, o borrar para siempre pasados 30 días (con confirmación) ----
function openTrash() {
const root = openPanel('trash', 'Trash');
const list = el('ul', { className: 'pn-list' });
let confirmId = null;
const fill = () => {
list.textContent = '';
const items = (Store.get().trash || []).slice().sort((a, b) => b.deletedAt - a.deletedAt);
if (!items.length) { list.append(el('li', { className: 'pn-item', textContent: T().trash.empty })); return; }
for (const e of items) {
const left = Actions.TRASH_DAYS - Math.floor((Date.now() - (Number(e.deletedAt) || 0)) / 864e5);
const li = el('li', { className: 'pn-item trow', data: { key: 'trash|' + e.id } },
el('span', {}, el('b', { textContent: (e.kind === 'project' ? 'Project · ' : 'Task · ') + e.name }), el('small', { className: 'tmeta', textContent: ' ' + T().trash.when(ago(e.deletedAt), e.deletedByName, left) })),
btn('Restore', () => { const r = Actions.restoreFromTrash(e.id); if (r && r.ok) { notify(T().trash.restored(e.name)); fill(); } else if (r && r.why) notify(T().trash.cannot[r.why]); }, { className: 'row-btn', ariaLabel: 'Restore ' + e.name, data: { key: 'trash-restore|' + e.id } }));
if (Actions.canPurge(e)) {
if (confirmId === e.id) li.append(el('span', { className: 'pn-confirm' }, el('span', { textContent: T().trash.purgeAsk(e.name) }),
btn('Yes, delete forever', async () => { confirmId = null; const ok = await Actions.purgeTrash(e.id); notify(ok ? T().trash.purged(e.name) : T().trash.purgeFailed); fill(); }, { className: 'danger', data: { key: 'trash-purge-yes|' + e.id } }),
btn('No', () => { confirmId = null; fill(); })));
else li.append(btn('Delete forever', () => { confirmId = e.id; fill(); }, { className: 'row-btn', ariaLabel: 'Delete ' + e.name + ' forever', data: { key: 'trash-purge|' + e.id } }));
}
list.append(li);
}
};
fill();
root.append(el('p', { textContent: T().trash.hint(Actions.TRASH_DAYS) }), list);
if (Team.on()) Team.setPanelRefresh(() => { if (document.querySelector('#panel.panel-trash')) fill(); });
const f = focusables(root); (f[1] || f[0]).focus();
}
// Una vez por sesión: si algo lleva más de 30 días en la papelera, se avisa (nada se borra solo).
let trashReminded = false;
function trashReminder() {
if (trashReminded || (Team.on() && !Team.can('editProjects'))) return;
const n = (Store.get().trash || []).filter((e) => Actions.canPurge(e)).length;
if (!n) return;
trashReminded = true;
notify(T().trash.expired(n));
}
let replaceInput = null;
function replaceHandoff(projectId) {
if (!replaceInput) {
replaceInput = el('input', { type: 'file', accept: '.md,.txt,text/markdown,text/plain', hidden: true });
document.body.append(replaceInput);
}
replaceInput.onchange = async () => {
const f = replaceInput.files && replaceInput.files[0];
replaceInput.value = '';
if (!f) return;
let t = null;
try { t = await f.text(); } catch {}
notify(summarize(f.name, Actions.importHandoff(t, { projectId })));
};
replaceInput.click();
}
// ---- Layouts propios ----
let layoutPicker = null;
function addLayout() {
if (!layoutPicker) {
layoutPicker = el('input', { type: 'file', multiple: true, accept: '.html,.htm,text/html', hidden: true });
layoutPicker.addEventListener('change', () => { uploadLayouts(layoutPicker.files).then(() => { layoutPicker.value = ''; }); });
document.body.append(layoutPicker);
}
layoutPicker.click();
}
async function uploadLayouts(files) {
const lines = [];
for (const f of Array.from(files || [])) {
let html = null;
try { html = await f.text(); } catch {}
const v = LayoutHost.validate(html);
if (!v.ok) { lines.push(f.name + (v.old ? ': ' + T().oldLayout : ': this is not a Tasky layout. It needs ' + v.missing.join(' and ') + '.')); continue; }
const name = LayoutHost.list().some((l) => l.factory && l.name.toLowerCase() === v.name.toLowerCase()) ? v.name + ' (own)' : v.name;
const same = Store.get().layouts.find((l) => String(l.name).trim().toLowerCase() === name.toLowerCase());
if (same) { if (lines.length) notify(lines.join('\n')); askReplaceLayout(same, name, html); return; }
const id = Actions.saveLayout(name, html);
Actions.chooseLayout(id);
lines.push('View “' + name + '” added and selected.');
}
if (lines.length) notify(lines.join('\n'));
}
function askReplaceLayout(old, name, html) {
const root = openPanel('replaceLayout', 'Repeated view');
const yes = btn('Yes, replace', () => { Actions.saveLayout(name, html, old.id); Actions.chooseLayout(old.id); closePanel(); notify('View “' + name + '” replaced.'); }, { className: 'primary' });
const no = btn('No, keep the current one', () => { closePanel(); notify('Kept the view “' + old.name + '”.'); });
root.append(el('p', { textContent: 'You already have a view called “' + old.name + '”. Replace it with the one you just uploaded?' }), el('div', { className: 'pn-actions' }, yes, no));
yes.focus();
}
function removeLayout(id) {
const l = Store.get().layouts.find((x) => x.id === id);
if (!l) return;
const wasCurrent = Store.get().settings.layoutId === id;
Actions.deleteLayout(id);
const plus = document.querySelector('[data-key="view|+"]');
if (plus) plus.focus();
notify('View “' + l.name + '” deleted.' + (wasCurrent ? ' Back to Universe.' : ''));
}
function downloadExampleLayout() {
const l = LayoutHost.example();
if (l) { download('minimal-example.tasky-layout.html', l.html, 'text/html;charset=utf-8'); notify('Downloaded: minimal-example.tasky-layout.html.'); }
}
// Aviso breve abajo a la derecha que se cierra solo (" is back", "See you later, "). Un clic lo cierra.
function brief(text, kind = '', ms = 4500) {
const zone = $('#toasts');
if (!zone) return null;
const t = el('div', { className: 'toast brief ' + kind, role: 'status', textContent: text, title: 'Click to close' });
t.addEventListener('click', () => t.remove());
zone.append(t);
setTimeout(() => t.remove(), ms);
return t;
}
// ---- Aviso flotante: un agente nuevo, ¿en qué trabaja? ----
function askAgentTask(agentId) {
const a = Store.agent(agentId);
if (!a) return;
const zone = $('#toasts');
const toast = el('section', { className: 'toast', role: 'dialog', ariaLabel: T().toast.title, data: { agent: agentId } });
const close = () => toast.remove();
const name = el('input', { type: 'text', className: 'tname', value: a.name, ariaLabel: 'Agent name', autocomplete: 'off' });
name.addEventListener('input', () => { if (name.value.trim()) Actions.updateAgent(agentId, { name: name.value }); });
// Solo proyectos con tareas abiertas. Si el actual no tiene, se ofrecen los otros; si ninguno tiene, solo el nombre.
const openOf = (pid) => Store.tasksOf(pid).filter((t) => t.status !== 'done');
const withOpen = Store.get().projects.filter((p) => !p.archived && openOf(p.id).length);
const cur = Store.current();
const curHasOpen = !!cur && withOpen.some((p) => p.id === cur.id);
const tasks = el('div', { className: 'ttasks' });
const fill = (pid) => {
tasks.textContent = '';
for (const t of openOf(pid)) {
const b = btn(t.id + ' · ' + t.title, () => {
Actions.assignAgent(agentId, pid, t.id);
close();
if (!Store.current() || Store.current().id !== pid) Actions.selectProject(pid);
select({ kind: 'task', id: t.id });
}, { className: 'ttask', ariaLabel: 'Working on ' + t.id + ' ' + t.title });
b.style.setProperty('--c', Store.colorOf(pid, t.workflow));
tasks.append(b);
}
};
const parts = [];
if (withOpen.length) {
parts.push(el('p', { className: 'tq', textContent: T().toast.question }));
if (!curHasOpen) parts.push(el('p', { className: 'tnote', textContent: cur ? T().toast.otherProjects(cur.name) : T().toast.pickProject }));
if (withOpen.length > 1 || !curHasOpen) {
const pick = el('select', { className: 'tproj', ariaLabel: 'Project' });
for (const p of withOpen) pick.append(el('option', { value: p.id, textContent: p.name + ' (' + openOf(p.id).length + ' open)' }));
pick.value = curHasOpen ? cur.id : withOpen[0].id;
pick.addEventListener('change', () => fill(pick.value));
parts.push(pick);
}
parts.push(tasks);
fill(curHasOpen ? cur.id : withOpen[0].id);
} else parts.push(el('p', { className: 'tnote', textContent: T().toast.noOpenAnywhere }));
const icon = el('span', { className: 'aicon big' }, Agents.iconSvg(a, 24));
// A3: la primera vez que aparece un agente en una carpeta, también a qué proyecto pertenece (y se recuerda).
let linkRow = null;
if (a.cwd) {
const linked = Agents.linkOf(a.cwd);
if (linked) linkRow = el('p', { className: 'tnote', textContent: T().toast.linked(Store.project(linked.projectId).name) });
else {
const lsel = el('select', { className: 'tlinksel', ariaLabel: 'Project of this folder', data: { key: 'toast-link' } }, el('option', { value: '', textContent: T().toast.linkPick }), ...Store.get().projects.filter((p) => !p.archived).map((p) => el('option', { value: p.id, textContent: p.name })));
lsel.addEventListener('change', () => { if (lsel.value) { Actions.linkFolder(a.cwd, lsel.value); notify(T().toast.linkedNow(Store.project(lsel.value).name)); } });
linkRow = el('label', { className: 'tlink' }, el('span', { textContent: T().toast.linkQuestion }), lsel);
}
}
toast.append(
el('div', { className: 'thead' }, icon, el('div', {}, el('strong', { textContent: T().toast.title }), el('small', { textContent: T().toast.started }))),
el('p', { className: 'tfolder', title: a.cwd || '' }, el('b', { textContent: 'Folder: ' }), a.cwd || T().toast.noFolder),
...(linkRow ? [linkRow] : []), name, ...parts,
btn(withOpen.length ? T().toast.later : T().toast.ok, close, { className: withOpen.length ? 'linkish tlater' : 'ttask', ariaLabel: withOpen.length ? T().toast.later : T().toast.ok }),
);
zone.append(toast);
return toast;
}
function closeAgentToast(agentId) { document.querySelectorAll('#toasts .toast[data-agent="' + agentId + '"]').forEach((n) => n.remove()); }
// Hace cuánto: "just now", "5 min", "3 h", "2 d".
function ago(ms) {
const m = Math.max(0, Math.round((Date.now() - Number(ms)) / 60000));
return m < 1 ? 'just now' : m < 60 ? m + ' min' : m < 60 * 24 ? Math.round(m / 60) + ' h' : Math.round(m / 1440) + ' d';
}
// Ir a una tarea o a un workflow de cualquier proyecto: cambia de proyecto, abre su tarjeta y vuela allí.
function goTo(projectId, item) {
if (!Store.project(projectId)) return;
// Lo que abarca todos los universos (bandeja, Now, Live, búsqueda, avisos, reportes): un clic salta al correcto.
if (multiUni() && item) {
const wid = item.kind === 'task' ? (Store.task(projectId, item.id) || {}).workflow : item.id;
const u = wid ? Store.universeOf(projectId, wid) : null;
if (u && u !== currentUniverse() || (u && Store.get().settings.universe !== u)) { const changed = u !== currentUniverse(); Actions.chooseUniverse(u); if (changed) jump(); }
}
focus = item;
if (LayoutHost.isNative()) Actions.chooseLayout('universe'); // desde Now o List, se va a la tarea en Universe
if (!Store.current() || Store.current().id !== projectId) Actions.selectProject(projectId);
select(item);
LayoutHost.focus(item.kind === 'task' ? { task: item.id } : { workflow: item.id });
}
// Lo que espera a quien mira: en Team, al dueño también las entregas en revisión.
const waitOn = () => Store.waitingOnYou({ reviews: Team.on() && Team.role() === 'owner' });
// Agentes propios que esperan permiso o una respuesta de su dueño (A2): entran en su bandeja y salen solos.
const agentWaits = () => Store.visibleAgents().filter((a) => Actions.ownAgent(a) && a.activity && a.activity.waiting).sort((x, y) => (x.activity.since || 0) - (y.activity.since || 0));
function agentWaitRow(a, cls) {
const L = T().live, w = a.taskId && Store.project(a.projectId) && Store.task(a.projectId, a.taskId);
const what = a.activity.waiting === 'permission' ? L.needsPermission(a.name) : L.hasQuestion(a.name);
const b = btn('', () => { if (w) goTo(a.projectId, { kind: 'task', id: a.taskId }); else { const n = document.querySelector('[data-key="agent|' + a.id + '"]'); if (n) { n.scrollIntoView({ block: 'center' }); n.focus(); } } },
{ className: cls, data: { key: 'await|' + a.id }, ariaLabel: what + (w ? '. ' + w.id + ' ' + w.title : '') + '. Go to it' });
b.append(el('span', { className: 'ineed', textContent: what }), el('span', { className: 'imeta', textContent: w ? Store.project(a.projectId).name + ' · ' + w.id + ' ' + w.title : L.noTask }),
el('span', { className: 'itags' }, el('span', { className: 'itag why', textContent: L.agentTag }), el('span', { className: 'itag', textContent: a.activity.since ? 'waiting ' + ago(a.activity.since) : 'waiting' })));
return b;
}
// ---- Live (A2): lo que pasa ahora en todo el equipo, en orden de llegada ----
const live = [];
const lastLive = new Map();
function recordLive() {
for (const a of Store.visibleAgents()) {
if (!a.activity && a.source !== 'bridge') continue;
const l = Agents.liveLine(a);
const key = l.state + '|' + l.text.replace(/ · \d+ min/, '');
if (lastLive.get(a.id) === key) continue;
lastLive.set(a.id, key);
const owner = Team.on() ? Team.memberOf(a.owner) : null;
live.push({ at: Date.now(), agentId: a.id, name: a.name, owner: Team.on() ? (a.owner === Team.myId() ? 'You' : (owner ? owner.name : '')) : '', color: Team.on() ? ((owner && /^#[0-9A-Fa-f]{6}$/.test(owner.ship_color || '') && owner.ship_color) || '#FFD23F') : (/^#[0-9A-Fa-f]{6}$/.test(a.color) ? a.color : '#5B9DFF'), state: l.state, text: l.text, projectId: a.projectId, taskId: a.taskId });
if (live.length > 200) live.shift();
}
for (const id of [...lastLive.keys()]) if (!Store.visibleAgents().some((a) => a.id === id)) lastLive.delete(id);
if (document.querySelector('#panel.panel-live')) fillLive();
}
let liveList = null;
function fillLive() {
if (!liveList) return;
liveList.textContent = '';
if (!live.length) { liveList.append(el('li', { className: 'pempty', textContent: T().live.empty })); return; }
for (const e of live) {
const t = e.taskId && Store.project(e.projectId) ? Store.task(e.projectId, e.taskId) : null;
const dot = el('i', { className: 'ldot', ariaHidden: 'true' }); dot.style.setProperty('--c', e.color);
const li = el('li', { className: 'lvrow ' + e.state, data: { key: 'live|' + e.agentId + '|' + e.at } }, el('span', { className: 'htime', textContent: clock(e.at) }), dot,
el('span', { className: 'lvtext' }, el('b', { textContent: (e.owner ? e.owner + ' · ' : '') + e.name }), el('span', { textContent: ' ' + e.text })),
t ? btn(t.id + ' ' + t.title + uniTag(Store.universeOf(e.projectId, t.workflow)), () => { closePanel(); goTo(e.projectId, { kind: 'task', id: t.id }); }, { className: 'linkish lvtask' }) : el('small', { className: 'lvtask', textContent: T().live.noTask }));
liveList.append(li);
}
liveList.scrollTop = liveList.scrollHeight;
}
function openLive() {
if (!live.length) recordLive();
const root = openPanel('live', 'Live');
liveList = el('ul', { className: 'pn-list lvlist', role: 'log', ariaLive: 'polite' });
root.append(el('p', { textContent: T().live.hint }), liveList);
fillLive();
}
// ---- Bandeja "Waiting on you" (arriba en la barra izquierda) ----
function renderInbox() {
const box = $('#inbox');
if (!box) return;
const rows = waitOn(), waits = agentWaits();
const open = Store.get().settings.inboxOpen !== false;
box.textContent = '';
const n = rows.length + waits.length;
const head = btn('', () => Actions.setPreference('inboxOpen', !open), { className: 'ibox-head' + (n ? ' has' : ''), ariaExpanded: String(open),
ariaLabel: 'Waiting on you: ' + n + (n === 1 ? ' item' : ' items') + (open ? '. Click to fold' : '. Click to unfold'), title: T().inbox.title, data: { key: 'inbox' } });
head.append(el('span', { textContent: T().inbox.name }), el('span', { className: 'ibox-count', textContent: String(n) }), el('span', { className: 'chev', ariaHidden: 'true', textContent: open ? '▾' : '▸' }));
box.append(head);
if (!open) return;
if (!n) { box.append(el('p', { className: 'iempty', textContent: T().inbox.empty })); return; }
const list = el('ul', { className: 'ibox-list' });
for (const a of waits) list.append(el('li', {}, agentWaitRow(a, 'irow agentwait')));
for (const r of rows) {
const why = r.reason === 'review' ? T().inbox.review(Team.nameOf(r.task.assignee) || '') : r.reason === 'agent' ? T().inbox.agent(r.agent.name) : r.reason === 'open' ? T().inbox.open(r.open, r.workflowName) : T().inbox.age;
const b = btn('', () => goTo(r.project.id, { kind: 'task', id: r.task.id }), { className: 'irow', data: { key: 'inbox|' + r.project.id + '|' + r.task.id },
ariaLabel: (r.task.needs || T().inbox.noText) + '. ' + r.project.name + ', ' + r.task.id + ' ' + r.task.title + '. Waiting ' + (r.since ? ago(r.since) : '') + '. ' + why });
b.append(el('span', { className: 'ineed', textContent: r.review ? T().inbox.reviewTitle(r.task.title) : r.task.needs || T().inbox.noText }),
el('span', { className: 'imeta', textContent: r.project.name + uniTag(Store.universeOf(r.project.id, r.task.workflow)) + ' · ' + r.task.id + ' ' + r.task.title }),
el('span', { className: 'itags' }, el('span', { className: 'itag why', textContent: why }), el('span', { className: 'itag', textContent: r.since ? 'waiting ' + ago(r.since) : 'waiting' })));
list.append(el('li', {}, b));
}
box.append(list);
}
// ---- Historia ----
// Una línea en inglés por entrada; quién la hizo va aparte.
const whoName = (w, e) => {
if (e && Team.on() && e.agentAct && w && w !== 'you') return String(w) + ' · ' + (e.actor === Team.myId() ? 'you' : e.actorName || Team.nameOf(e.actor) || 'someone');
if (e && Team.on() && e.actor && e.actor === Team.myId() && !e.agentAct && w !== 'handoff') return 'You';
return w === 'you' ? 'You' : w === 'handoff' ? 'Handoff' : String(w || 'You');
};
function describe(e) {
const S = T().status, P = T().projectStatus;
const task = (e.taskId ? e.taskId + (e.title ? ' ' + e.title : '') : '');
switch (e.kind) {
case 'status': return task + ': ' + (S[e.from] || e.from) + ' → ' + (S[e.to] || e.to) + (e.review === 'approved' ? ' (approved)' : e.review === 'sent back' ? ' (sent back: “' + e.comment + '”)' : e.to === 'review' && e.note ? ' (“' + e.note + '”)' : '');
case 'import': return e.created ? 'Imported ' + (e.projectName || 'a project') + ' (new, ' + e.total + (e.total === 1 ? ' task)' : ' tasks)') : 'Imported a handoff for ' + (e.projectName || 'a project') + ': ' + (e.changes || []).length + ((e.changes || []).length === 1 ? ' task changed' : ' tasks changed');
case 'rename': return 'Renamed the project “' + e.from + '” → “' + e.to + '”';
case 'projectStatus': return (e.projectName || 'Project') + ': ' + (P[e.from] || e.from) + ' → ' + (P[e.to] || e.to);
case 'archive': return e.to ? 'Archived ' + (e.projectName || 'a project') : 'Brought ' + (e.projectName || 'a project') + ' back from the archive';
case 'delete': return 'Deleted ' + (e.projectName || 'a project') + ' (' + e.tasks + (e.tasks === 1 ? ' task)' : ' tasks)');
case 'duplicate': return 'Duplicated ' + e.from + ' as ' + (e.projectName || 'a copy');
case 'assign': return e.removed ? e.agentName + ' left ' + task : e.agentName + ' → ' + task;
case 'agentIn': return e.isNew ? e.agentName + ' appeared (a new Claude Code conversation)' : e.agentName + ' is back';
case 'agentOut': return e.agentName + ' left (its conversation closed)';
case 'agentDelete': return 'Deleted the agent ' + e.agentName;
case 'note': return task + ': note: “' + (e.note || '') + '”';
case 'agentSession': return e.agentName + ' finished a session' + (task ? ' on ' + task : '') + ': ' + (e.minutes || 0) + ' min' + (e.tools && Object.keys(e.tools).length ? ' · ' + T().live.toolCounts(e.tools) : '') + (e.files && e.files.length ? ' · ' + e.files.length + (e.files.length === 1 ? ' file: ' : ' files: ') + e.files.slice(0, 8).join(', ') + (e.files.length > 8 ? '…' : '') : '');
case 'person': return task + ': ' + (e.toName ? 'assigned to ' + e.toName : 'nobody has it now') + (e.fromName ? ' (was ' + e.fromName + ')' : '');
case 'task': return (e.what === 'create' ? 'Created ' : e.what === 'delete' ? 'Deleted the task ' : 'Edited ') + task;
case 'universe': return e.what === 'rename' ? 'Renamed the universe ' + e.from + ' → ' + e.to : 'Moved the workflow ' + (e.name || e.workflow) + ' from ' + e.from + ' to ' + e.to;
case 'restore': return 'Brought ' + (e.what === 'task' ? 'the task ' : '') + (e.name || 'something') + ' back from the Trash';
case 'purge': return 'Deleted ' + (e.what === 'task' ? 'the task ' : '') + (e.name || 'something') + ' forever (from the Trash)';
case 'welcome': return 'Added the welcome galaxy for ' + (e.person || 'someone') + ' (' + e.total + (e.total === 1 ? ' task)' : ' tasks)');
case 'file': return e.what === 'upload' ? (e.fileKind === 'starting' ? 'Added the starting file ' : 'Uploaded ') + e.name + ' v' + e.version + (e.taskId ? ' to ' + task : '') : e.what === 'download' ? 'Downloaded ' + e.name + ' v' + e.version + (e.taskId ? ' from ' + task : '') : 'Deleted ' + e.name + ' v' + e.version + (e.taskId ? ' of ' + task : '');
case 'team': return e.what === 'invite' ? 'Invited ' + e.email + ' as ' + e.role : e.what === 'revoke' ? 'Withdrew the invitation for ' + e.email : e.what === 'role' ? e.person + ' is now ' + e.to : e.what === 'remove' ? 'Removed ' + e.person + ' from the team' + (e.reassigned ? ' (' + e.reassigned + (e.reassigned === 1 ? ' task' : ' tasks') + ' reassigned)' : '') : e.what === 'purge' ? 'Deleted ' + (e.name || 'something') + ' forever (from the Trash)' : e.what === 'importPersonal' ? 'Brought ' + e.projects + (e.projects === 1 ? ' project' : ' projects') + ' from Personal mode (' + e.tasks + ' tasks, ' + e.events + ' history entries)' : e.what;
case 'undo': { const o = Store.get().history.find((x) => x.id === e.undoOf); return 'Undo: ' + (o ? describe(o) : e.undoKind); }
default: return e.kind;
}
}
const dayKey = (ms) => { const d = new Date(ms); return d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate(); };
function dayLabel(ms) {
const k = dayKey(ms);
if (k === dayKey(Date.now())) return 'Today';
if (k === dayKey(Date.now() - 864e5)) return 'Yesterday';
return new Date(ms).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
}
const clock = (ms) => { const d = new Date(ms); return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0'); };
// Clic en una entrada: vuela a la tarea, al workflow o al proyecto (si sigue a la vista).
function goToEntry(e) {
const p = Store.project(e.projectId);
if (!p) { notify(T().history.gone); return; }
if (p.archived) { notify(T().history.archived(p.name)); return; }
closePanel();
if (e.taskId && Store.task(p.id, e.taskId)) goTo(p.id, { kind: 'task', id: e.taskId });
else if (e.workflow && Store.workflow(p.id, e.workflow)) goTo(p.id, { kind: 'workflow', id: e.workflow });
else { focus = null; Actions.selectProject(p.id); }
}
let historyFilter = { project: '', workflow: '', limit: 200 };
function openHistory() {
const root = openPanel('history', 'History');
const fpro = el('select', { className: 'hsel', ariaLabel: 'Filter by project' });
const fwf = el('select', { className: 'hsel', ariaLabel: 'Filter by workflow' });
const list = el('div', { className: 'hlist' });
const fill = () => {
const all = Store.get().history;
// Proyectos: los que hay y los que aparecen en la historia (con su último nombre).
const names = new Map();
for (const e of all) if (e.projectId) names.set(e.projectId, e.projectName || e.projectId);
for (const p of Store.get().projects) names.set(p.id, p.name);
fpro.textContent = '';
fpro.append(el('option', { value: '', textContent: 'All projects' }));
for (const [id, n] of names) fpro.append(el('option', { value: id, textContent: n }));
fpro.value = names.has(historyFilter.project) ? historyFilter.project : '';
fwf.textContent = '';
fwf.append(el('option', { value: '', textContent: 'All workflows' }));
const wfs = new Map();
if (fpro.value) {
for (const w of Store.workflowsOf(fpro.value)) wfs.set(w.id, w.name);
for (const e of all) if (e.projectId === fpro.value && e.workflow && !wfs.has(e.workflow)) wfs.set(e.workflow, e.workflow);
}
for (const [id, n] of wfs) fwf.append(el('option', { value: id, textContent: n }));
fwf.value = wfs.has(historyFilter.workflow) ? historyFilter.workflow : '';
fwf.disabled = !fpro.value;
const rows = all.filter((e) => (!fpro.value || e.projectId === fpro.value) && (!fwf.value || e.workflow === fwf.value || (e.kind === 'import' && (e.changes || []).some((c) => c.workflow === fwf.value)))).slice().reverse();
list.textContent = '';
if (!rows.length) { list.append(el('p', { className: 'pempty', textContent: T().history.empty })); return; }
let day = null, ul = null;
for (const e of rows.slice(0, historyFilter.limit)) {
if (dayKey(e.at) !== day) { day = dayKey(e.at); list.append(el('h3', { className: 'hday', textContent: dayLabel(e.at) })); ul = el('ul', { className: 'pn-list' }); list.append(ul); }
const go = btn('', () => goToEntry(e), { className: 'hrow' + (e.undone ? ' undone' : ''), data: { key: 'h|' + e.id }, ariaLabel: clock(e.at) + ', ' + whoName(e.who, e) + ': ' + describe(e) + (e.undone ? ' (undone)' : '') + '. Go there' });
go.append(el('span', { className: 'htime', textContent: clock(e.at) }), el('span', { className: 'hwho' + (e.who === 'you' ? ' you' : e.who === 'handoff' ? ' handoff' : ' agent'), textContent: whoName(e.who, e) }),
el('span', { className: 'htext', textContent: describe(e) + (e.projectName && fpro.value === '' && !/project|Imported|Archived|Brought|Deleted|Duplicated/.test(describe(e)) ? ' · ' + e.projectName : '') }));
const li = el('li', { className: 'hitem' }, go);
if (Actions.canUndo(e)) li.append(btn('Undo', () => { Actions.undo(e.id); fill(); }, { className: 'row-btn hundo', ariaLabel: 'Undo: ' + describe(e), data: { key: 'hu|' + e.id } }));
ul.append(li);
}
if (rows.length > historyFilter.limit) list.append(btn('Show older (' + (rows.length - historyFilter.limit) + ')', () => { historyFilter.limit += 200; fill(); }, { className: 'linkish' }));
};
fpro.addEventListener('change', () => { historyFilter = { project: fpro.value, workflow: '', limit: 200 }; fill(); });
fwf.addEventListener('change', () => { historyFilter.workflow = fwf.value; fill(); });
fill();
root.append(el('p', { textContent: T().history.hint }), el('div', { className: 'pn-actions hfilters' }, fpro, fwf), list);
fpro.focus();
}
// Tras un cambio hecho con un clic: aviso de 5 s con "Undo".
let undoToast = null;
function offerUndo(e) {
if (e.who !== 'you' || e.kind === 'undo' || !Actions.canUndo(e)) return;
if (undoToast) undoToast.remove();
const zone = $('#toasts');
const t = el('div', { className: 'toast brief undo', role: 'status' }, el('span', { textContent: describe(e) }),
btn('Undo', () => { Actions.undo(e.id); t.remove(); }, { className: 'ubtn', ariaLabel: 'Undo: ' + describe(e), data: { key: 'undo-toast' } }));
zone.append(t);
undoToast = t;
setTimeout(() => { t.remove(); if (undoToast === t) undoToast = null; }, 5000);
}
// ---- Filtros de un clic sobre el mapa: lo que no coincide se atenúa ----
const FILTERS = [
{ id: 'doing', test: (p, t) => t.status === 'doing' },
{ id: 'needs', test: (p, t) => t.status === 'needs-you' },
{ id: 'agents', test: (p, t) => !!Store.agentOn(p.id, t.id) },
{ id: 'notdone', test: (p, t) => t.status !== 'done' },
];
let filterId = null;
function activeFilter() {
const p = Store.current(), F = FILTERS.find((x) => x.id === filterId);
if (!p || !F) return null;
return { id: F.id, label: T().filters[F.id], match: Store.tasksOf(p.id).filter((t) => F.test(p, t)).map((t) => t.id) };
}
function setFilter(id) { filterId = filterId === id ? null : id; renderBar(); LayoutHost.push(); }
// ---- Buscar: tareas, workflows y proyectos por id, título o misión ----
function searchItems(q) {
q = String(q || '').trim().toLowerCase();
if (!q) return [];
const out = [];
const score = (id, title, ...rest) => {
id = String(id || '').toLowerCase(); title = String(title || '').toLowerCase();
if (id === q) return 0;
if (id.startsWith(q)) return 1;
if (title.startsWith(q)) return 2;
if (title.includes(q)) return 3;
if (rest.some((x) => String(x || '').toLowerCase().includes(q))) return 4;
return -1;
};
for (const p of Store.get().projects.filter((x) => !x.archived)) {
const sp = score('', p.name);
if (sp >= 0) out.push({ kind: 'project', p, s: sp, label: p.name, sub: T().search.project });
for (const w of Store.workflowsOf(p.id)) { const sw = score(w.id, w.name, w.mission); if (sw >= 0) out.push({ kind: 'workflow', p, w, s: sw, label: w.name, sub: 'Workflow · ' + p.name + uniTag(w.universe || 'Main') }); }
for (const t of Store.tasksOf(p.id)) {
const st = score(t.id, t.title, t.mission, t.needs);
if (st >= 0) out.push({ kind: 'task', p, t, s: st, label: t.id + ' ' + t.title, sub: T().status[t.status] + ' · ' + p.name + ' · ' + ((Store.workflow(p.id, t.workflow) || {}).name || t.workflow) + uniTag(Store.universeOf(p.id, t.workflow)) });
}
}
return out.sort((a, b) => a.s - b.s).slice(0, 12);
}
function goToResult(r) {
if (r.kind === 'task') goTo(r.p.id, { kind: 'task', id: r.t.id });
else if (r.kind === 'workflow') goTo(r.p.id, { kind: 'workflow', id: r.w.id });
else { focus = null; Actions.selectProject(r.p.id); }
}
let searchBox = null;
function buildSearch() {
const input = el('input', { type: 'search', id: 'search', className: 'search', placeholder: T().search.placeholder, autocomplete: 'off', spellcheck: false, ariaLabel: T().search.label, ariaExpanded: 'false', ariaControls: 'search-results', data: { key: 'search' } });
input.setAttribute('role', 'combobox');
const list = el('ul', { id: 'search-results', className: 'sresults', hidden: true });
list.setAttribute('role', 'listbox');
let results = [], active = 0;
const close = () => { list.hidden = true; input.setAttribute('aria-expanded', 'false'); };
const show = () => {
results = searchItems(input.value);
list.textContent = '';
if (!input.value.trim()) { close(); return; }
if (!results.length) list.append(el('li', { className: 'snone', textContent: T().search.none }));
results.forEach((r, i) => {
const b = btn('', () => { pick(r); }, { className: 'sitem' + (i === active ? ' active' : ''), data: { key: 'sr|' + i } });
b.setAttribute('role', 'option'); b.setAttribute('aria-selected', String(i === active)); b.tabIndex = -1;
if (r.kind === 'task') b.append(ico(r.t.status, Store.colorOf(r.p.id, r.t.workflow)));
else b.append(el('span', { className: 'skind', textContent: r.kind === 'workflow' ? 'W' : 'P' }));
b.append(el('span', { className: 'stext' }, el('b', { textContent: r.label }), el('small', { textContent: r.sub })));
b.addEventListener('mousedown', (e) => e.preventDefault()); // que el clic no cierre la lista antes de tiempo
list.append(el('li', {}, b));
});
list.hidden = false; input.setAttribute('aria-expanded', 'true');
};
const pick = (r) => { close(); input.value = ''; input.blur(); goToResult(r); };
input.addEventListener('input', () => { active = 0; show(); });
input.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); if (results.length) { active = (active + (e.key === 'ArrowDown' ? 1 : results.length - 1)) % results.length; show(); } }
else if (e.key === 'Enter') { e.preventDefault(); if (results[active]) pick(results[active]); }
else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); input.value = ''; close(); input.blur(); }
});
input.addEventListener('blur', () => setTimeout(close, 150));
input.addEventListener('focus', () => { if (input.value.trim()) show(); });
searchBox = el('div', { className: 'sbox' }, svg('', 'sicon'), input, el('kbd', { textContent: '/', ariaHidden: 'true' }), list);
return searchBox;
}
// "/" o Ctrl+K desde cualquier parte (salvo mientras se escribe) lleva a la búsqueda.
function bindSearchKeys() {
addEventListener('keydown', (e) => {
const tgt = e.target, typing = tgt && (tgt.tagName === 'INPUT' || tgt.tagName === 'TEXTAREA' || tgt.tagName === 'SELECT' || tgt.isContentEditable);
if ((e.key === '/' && !typing && !e.ctrlKey && !e.metaKey) || ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K'))) {
const n = document.getElementById('search');
if (!n || panel) return;
e.preventDefault(); n.focus(); n.select();
}
});
}
// La barra: búsqueda y filtros. Se arma una vez; los filtros se actualizan sin rehacer la búsqueda.
let chipsBox = null;
function renderBar() {
const bar = $('#bar');
if (!bar) return;
bar.hidden = !Store.current();
if (!searchBox) { chipsBox = el('div', { className: 'chips', role: 'group', ariaLabel: T().filters.label }); bar.append(buildSearch(), chipsBox); }
chipsBox.textContent = '';
const f = activeFilter();
for (const F of FILTERS) {
const on = filterId === F.id;
const b = btn(T().filters[F.id] + (on ? ' ✕' : ''), () => setFilter(F.id), { className: 'chip' + (on ? ' on' : ''), title: on ? T().filters.clearTitle : T().filters.title, data: { key: 'filter|' + F.id } });
b.setAttribute('aria-pressed', String(on));
chipsBox.append(b);
}
if (LayoutHost.current() && LayoutHost.current().id === 'universe') chipsBox.append(btn('▶ Tour', startTour, { className: 'chip tour', title: T().tour.title, ariaLabel: 'Start the tour', data: { key: 'tour' } }));
if (f) {
const total = Store.counts(Store.current().id).total;
chipsBox.append(el('span', { className: 'fsum', role: 'status', textContent: T().filters.showing(f.label, f.match.length, total) }),
btn(T().filters.clear, () => setFilter(filterId), { className: 'linkish fclear', data: { key: 'filter-clear' } }));
}
}
// ---- Línea de tiempo: el universo como estaba en un momento pasado, reconstruido desde la historia ----
// Momentos: cada cambio de estado o importación del proyecto. Hacia atrás, cada entrada posterior al momento se
// deshace sobre los estados de hoy (status: vuelve a from; import: from, o la tarea todavía no existía).
let pastAt = null;
const isPast = () => pastAt != null;
const changesStatus = (e) => e.kind === 'status' || e.kind === 'import' || (e.kind === 'undo' && e.undoKind === 'status');
function moments(pid) { return [...new Set(Store.get().history.filter((e) => e.projectId === pid && changesStatus(e)).map((e) => e.at))].sort((a, b) => a - b); }
function statusesAt(pid, at) {
const map = new Map(Store.tasksOf(pid).map((t) => [t.id, t.status]));
for (const e of Store.get().history.filter((x) => x.projectId === pid && x.at > at && changesStatus(x)).sort((a, b) => b.at - a.at)) {
if (e.kind === 'import') { for (const c of e.changes || []) if (map.has(c.taskId)) map.set(c.taskId, c.from); }
else if (map.has(e.taskId)) map.set(e.taskId, e.from);
}
return map;
}
const whenLabel = (ms) => new Date(ms).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ', ' + clock(ms);
function pastState() {
const p = Store.current();
if (!isPast() || !p) return null;
return { at: pastAt, label: whenLabel(pastAt), statuses: statusesAt(p.id, pastAt) };
}
function setPast(at) {
pastAt = at;
renderPast(); LayoutHost.push(); renderDetail();
}
let timelineKey = '';
function renderTimeline() {
const box = $('#timeline');
const p = Store.current();
const ms = p ? moments(p.id) : [];
const show = !!p && !LayoutHost.isNative() && ms.length > 0;
if (!show && isPast()) { pastAt = null; LayoutHost.push(); }
box.hidden = !show;
if (!show) { timelineKey = ''; renderPast(); return; }
const key = p.id + '|' + ms.length + '|' + ms[ms.length - 1];
if (key === timelineKey) { renderPast(); return; }
timelineKey = key;
if (isPast() && !ms.includes(pastAt)) pastAt = null;
box.textContent = '';
const range = el('input', { type: 'range', id: 'timeline-range', min: 0, max: ms.length, step: 1, value: isPast() ? ms.indexOf(pastAt) : ms.length, ariaLabel: T().timeline.label, data: { key: 'timeline' } });
const out = el('span', { className: 'tlabel', id: 'timeline-label' });
range.addEventListener('input', () => { const i = Number(range.value); setPast(i >= ms.length ? null : ms[i]); });
box.append(el('span', { className: 'tlname', textContent: T().timeline.name }), el('span', { className: 'tedge', textContent: whenLabel(ms[0]) }), range, el('span', { className: 'tedge', textContent: 'Today' }), out);
renderPast();
}
function renderPast() {
const banner = $('#past'), out = document.getElementById('timeline-label'), range = document.getElementById('timeline-range');
banner.hidden = !isPast();
$('#timeline').classList.toggle('past', isPast());
if (out) out.textContent = isPast() ? T().timeline.viewing(whenLabel(pastAt)) : T().timeline.today;
if (range && !isPast() && document.activeElement !== range) range.value = range.max;
banner.textContent = '';
if (!isPast()) return;
banner.append(el('strong', { textContent: T().timeline.viewing(whenLabel(pastAt)) }), el('span', { textContent: T().timeline.readOnly }),
btn(T().timeline.back, () => { setPast(null); const r = document.getElementById('timeline-range'); if (r) r.value = r.max; }, { className: 'btn primary', data: { key: 'past-back' } }));
}
// ---- Recorrido ("Tour"): la cámara de Universe pasa galaxia por galaxia y se detiene en cada tarea sin terminar ----
const tour = { on: false, paused: false, steps: [], i: 0, timer: null, stepMs: 4000 };
function tourSteps() {
const p = Store.current(), steps = [];
if (!p) return steps;
for (const w of Store.workflowsOf(p.id).filter((x) => !multiUni() || (x.universe || 'Main') === currentUniverse())) {
const open = Store.tasksOf(p.id).filter((t) => t.workflow === w.id && t.status !== 'done');
if (!open.length) continue;
steps.push({ kind: 'workflow', id: w.id });
for (const t of open) steps.push({ kind: 'task', id: t.id });
}
return steps;
}
function startTour() {
if (isPast()) setPast(null);
if (!LayoutHost.current() || LayoutHost.current().id !== 'universe') Actions.chooseLayout('universe');
tour.steps = tourSteps();
if (!tour.steps.length) { notify(T().tour.nothing); return; }
Object.assign(tour, { on: true, paused: false, i: 0 });
tourShow();
}
function tourShow() {
clearTimeout(tour.timer);
const st = tour.steps[tour.i];
select(st);
LayoutHost.focus(st.kind === 'task' ? { task: st.id } : { workflow: st.id });
renderTourBar();
if (!tour.paused) tour.timer = setTimeout(tourNext, st.kind === 'workflow' ? Math.round(tour.stepMs * 0.6) : tour.stepMs);
}
function tourNext() { if (!tour.on) return; tour.i++; if (tour.i >= tour.steps.length) stopTour(true); else tourShow(); }
function tourPause() { tour.paused = !tour.paused; if (tour.paused) clearTimeout(tour.timer); tourShow(); }
function stopTour(finished) {
if (!tour.on) return;
clearTimeout(tour.timer);
tour.on = false;
renderTourBar();
if (finished) notify(T().tour.finished);
}
function renderTourBar() {
const bar = $('#tourbar');
bar.hidden = !tour.on;
bar.textContent = '';
if (!tour.on) return;
const st = tour.steps[tour.i], p = Store.current();
const what = st.kind === 'task' ? (Store.task(p.id, st.id) ? st.id + ' ' + Store.task(p.id, st.id).title : st.id) : ((Store.workflow(p.id, st.id) || {}).name || st.id);
bar.append(el('strong', { textContent: 'Tour' }), el('span', { className: 'tpos', textContent: (tour.i + 1) + ' of ' + tour.steps.length + ' · ' + what }),
btn(tour.paused ? 'Resume' : 'Pause', tourPause, { className: 'abtn', data: { key: 'tour-pause' } }),
btn('Next', () => { tour.paused = false; tourNext(); }, { className: 'abtn', data: { key: 'tour-next' } }),
btn('Exit', () => stopTour(false), { className: 'abtn', data: { key: 'tour-exit' } }));
}
// Cualquier arrastre en el mapa (lo avisa el layout) o tecla fuera de los botones del recorrido lo detiene.
function interacted() { stopTour(false); stopFollow(); }
function bindTourKeys() {
addEventListener('keydown', (e) => { if (tour.on && !(e.target && e.target.closest && e.target.closest('#tourbar'))) stopTour(false); }, true);
}
// ---- Vista "List": todo el proyecto como lista simple, agrupada por workflow ----
function renderList(box) {
if (!box) return;
box.textContent = '';
const p = Store.current();
if (!p) return;
const f = activeFilter();
box.append(el('p', { className: 'nhint', textContent: T().list.hint + (multiUni() ? ' Universe: ' + currentUniverse() + '.' : '') }));
for (const w of Store.workflowsOf(p.id).filter((x) => !multiUni() || (x.universe || 'Main') === currentUniverse())) {
const c = Store.counts(p.id, w.id), color = Store.colorOf(p.id, w.id);
const head = el('div', { className: 'lhead' }, el('span', { className: 'dot' }), el('b', { textContent: w.name }), el('span', { className: 'wnum', textContent: c.done + '/' + c.total }), el('small', { textContent: w.mission || '' }));
head.style.setProperty('--c', color);
const ul = el('ul', { className: 'llist' });
for (const t of Store.tasksOf(p.id).filter((x) => x.workflow === w.id)) {
const a = Store.agentOn(p.id, t.id);
const sel = focus && focus.kind === 'task' && focus.id === t.id;
const b = btn('', () => select({ kind: 'task', id: t.id }), { className: 'lrow' + (sel ? ' sel' : '') + (f && !f.match.includes(t.id) ? ' dim' : ''), data: { key: 'lt|' + t.id },
ariaLabel: t.id + ' ' + t.title + ', ' + T().status[t.status] + (a ? ', agent ' + a.name : '') + (t.mission ? '. ' + t.mission : '') });
b.setAttribute('aria-pressed', String(!!sel));
b.append(ico(t.status, color), el('b', { className: 'lid', textContent: t.id }),
el('span', { className: 'ltext' }, el('span', { textContent: t.title }), el('small', { textContent: t.status === 'needs-you' && t.needs ? '@ ' + t.needs : t.mission || '' })),
el('span', { className: 'lstatus', textContent: T().status[t.status] + (Team.on() && t.assignee ? ' · ' + (Team.nameOf(t.assignee) || '') : '') }),
a ? el('span', { className: 'nagent' }, el('span', { className: 'aicon' }, Agents.iconSvg(a, 14)), a.name) : el('span', { className: 'ntag', textContent: '' }));
ul.append(el('li', {}, b));
}
box.append(el('section', { className: 'lgroup', ariaLabel: w.name }, head, ul));
}
}
// ---- Vista "Now": para leer de un vistazo, en todos los proyectos ----
// Waiting on you (la bandeja), In progress (con su agente) y Recently done (terminadas en los últimos 7 días, según
// la historia). Clic en una fila: va a esa tarea en Universe.
function recentlyDone(days = 7) {
const since = Date.now() - days * 864e5, seen = new Set(), out = [];
for (const e of Store.get().history.slice().reverse()) {
if (e.at < since) continue;
// Una tarea que llega nueva ya hecha en un handoff no cuenta: no se sabe cuándo se terminó.
const hits = e.kind === 'status' && e.to === 'done' ? [{ taskId: e.taskId }] : e.kind === 'import' ? (e.changes || []).filter((c) => c.to === 'done' && c.from) : [];
for (const h of hits) {
const key = e.projectId + '|' + h.taskId;
const p = Store.project(e.projectId), t = p && Store.task(p.id, h.taskId);
if (seen.has(key) || !t || p.archived || t.status !== 'done') continue;
seen.add(key);
out.push({ project: p, task: t, at: e.at, who: e.who });
}
}
return out;
}
function nowRow(p, t, extra, onclick) {
const w = Store.workflow(p.id, t.workflow);
const b = btn('', onclick || (() => goTo(p.id, { kind: 'task', id: t.id })), { className: 'nrow', data: { key: 'now|' + p.id + '|' + t.id }, ariaLabel: t.id + ' ' + t.title + ', ' + p.name + ', ' + T().status[t.status] + (extra.aria ? ', ' + extra.aria : '') + '. Go to it in Universe' });
b.append(ico(t.status, Store.colorOf(p.id, t.workflow)),
el('span', { className: 'ntext' }, el('b', { textContent: extra.lead || (t.id + ' ' + t.title) }), el('small', { textContent: (extra.lead ? t.id + ' ' + t.title + ' · ' : '') + p.name + ' · ' + (w ? w.name : t.workflow) + uniTag(w ? w.universe || 'Main' : 'Main') })),
extra.tail || null);
return b;
}
function renderNow(box) {
if (!box) return;
box.textContent = '';
const live = Store.get().projects.filter((p) => !p.archived);
const block = (title, rows, empty, key) => {
const sec = el('section', { className: 'nblock', ariaLabel: title }, el('h3', {}, el('span', { textContent: title }), el('span', { className: 'ncount', textContent: String(rows.length) })));
if (!rows.length) sec.append(el('p', { className: 'pempty', textContent: empty }));
const ul = el('ul', { className: 'nlist' });
for (const r of rows) ul.append(el('li', {}, r));
sec.append(ul);
sec.dataset.block = key;
return sec;
};
const waiting = agentWaits().map((a) => agentWaitRow(a, 'nrow agentwait')).concat(waitOn().map((r) => nowRow(r.project, r.task, { lead: r.review ? T().inbox.reviewTitle(r.task.title) + (r.task.reviewNote ? ' · ' + r.task.reviewNote : '') : r.task.needs || T().inbox.noText, aria: 'waiting ' + (r.since ? ago(r.since) : ''), tail: el('span', { className: 'ntag', textContent: r.since ? ago(r.since) : '' }) })));
const doing = [];
for (const p of live) for (const t of Store.tasksOf(p.id).filter((x) => x.status === 'doing')) {
const a = Store.agentOn(p.id, t.id);
const person = Team.on() && t.assignee ? Team.nameOf(t.assignee) : '';
const chip = a ? el('span', { className: 'nagent' }, el('span', { className: 'aicon' }, Agents.iconSvg(a, 14)), a.name + (person ? ' · ' + person : '')) : el('span', { className: 'ntag', textContent: person || T().now.noAgent });
doing.push(nowRow(p, t, { aria: a ? 'agent ' + a.name : 'no agent', tail: chip }));
}
const done = recentlyDone().map((r) => nowRow(r.project, r.task, { aria: 'done ' + ago(r.at) + ' ago', tail: el('span', { className: 'ntag', textContent: ago(r.at) + ' · ' + whoName(r.who) }) }));
box.append(el('p', { className: 'nhint', textContent: T().now.hint }),
el('div', { className: 'ngrid' },
block(T().inbox.name, waiting, T().inbox.empty, 'waiting'),
block(T().status.doing, doing, T().now.noDoing, 'doing'),
block(T().now.recent, done, T().now.noDone, 'done')));
const clashes = live.flatMap((p) => collisions(p.id).map((c) => ({ p, ...c })));
if (clashes.length) {
const rows = clashes.map((c) => { const b = btn('', () => goTo(c.p.id, { kind: 'task', id: c.tasks[0].id }), { className: 'nrow clashrow', data: { key: 'nclash|' + c.p.id + '|' + c.file } }); b.append(el('span', { className: 'ntext' }, el('b', { textContent: c.file }), el('small', { textContent: c.tasks.map((x) => x.id + ' ' + x.title + ' (' + holder(c.p, x) + ')').join(' · ') + ' · ' + c.p.name }))); return b; });
const sec = block(T().files.clashesTitle, rows, '', 'collisions');
sec.classList.add('nclashes');
box.append(sec);
}
if (Team.on()) box.append(yesterdayBlock());
}
// ---- "Yesterday" (modo Team): por persona, lo que hizo el día anterior, tal como lo cuenta la historia ----
function yesterday(now = Date.now()) {
const end = new Date(now); end.setHours(0, 0, 0, 0);
const start = new Date(end); start.setDate(start.getDate() - 1);
const by = new Map();
for (const e of Store.get().history) {
if (!e.actor || e.at < start.getTime() || e.at >= end.getTime()) continue;
if (!by.has(e.actor)) by.set(e.actor, []);
by.get(e.actor).push(e);
}
return { day: start, people: Team.members().map((m) => ({ member: m, events: (by.get(m.user_id) || []).sort((a, b) => a.at - b.at) })) };
}
function yesterdayBlock() {
const Y = T().now, MAX = 8, y = yesterday();
const sec = el('section', { className: 'nblock nyesterday', ariaLabel: Y.yesterday }, el('h3', {}, el('span', { textContent: Y.yesterday }), el('small', { className: 'nday', textContent: y.day.toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' }) })));
sec.dataset.block = 'yesterday';
const grid = el('div', { className: 'ypeople' });
for (const { member, events } of y.people) {
const box = el('div', { className: 'yperson', data: { key: 'yday|' + member.user_id } }, el('h4', {}, typeof Ships !== 'undefined' ? Ships.icon(member, 16) : el('span'), el('span', { textContent: member.name + (member.user_id === Team.myId() ? ' (you)' : '') })));
if (!events.length) box.append(el('p', { className: 'pempty', textContent: Y.yesterdayNone }));
else {
const ul = el('ul', { className: 'ylist' });
if (events.length > MAX) ul.append(el('li', { className: 'ymore', textContent: Y.yesterdayMore(events.length - MAX) }));
for (const e of events.slice(-MAX)) ul.append(el('li', {}, el('span', { className: 'htime', textContent: clock(e.at) }), el('span', { textContent: ' ' + describe(e) })));
box.append(ul);
}
grid.append(box);
}
sec.append(grid);
return sec;
}
// ---- "Since last time": al abrir, si pasó más de una hora, un resumen corto de la historia ----
const SEEN_KEY = 'tasky:lastSeen';
const readSeen = () => { try { return Number(localStorage.getItem(SEEN_KEY)) || 0; } catch { return 0; } };
const writeSeen = () => { try { localStorage.setItem(SEEN_KEY, String(Date.now())); } catch {} };
function sinceLastTime(last) {
const c = { done: 0, needs: 0, came: new Set(), left: new Set() };
for (const e of Store.get().history) {
if (e.at <= last) continue;
if (e.kind === 'status') { if (e.to === 'done') c.done++; if (e.to === 'needs-you') c.needs++; }
if (e.kind === 'import') for (const x of e.changes || []) { if (x.to === 'done' && x.from) c.done++; if (x.to === 'needs-you') c.needs++; }
if (e.kind === 'agentIn') c.came.add(e.agentName);
if (e.kind === 'agentOut') c.left.add(e.agentName);
}
return { done: c.done, needs: c.needs, came: [...c.came], left: [...c.left] };
}
function showSince(last) {
const r = sinceLastTime(last);
const S = T().since, lines = [];
if (r.done) lines.push(S.done(r.done));
if (r.needs) lines.push(S.needs(r.needs));
if (r.came.length) lines.push(S.came(r.came));
if (r.left.length) lines.push(S.left(r.left));
if (!lines.length) return null;
const ul = el('ul', { className: 'slist' });
for (const l of lines) ul.append(el('li', { textContent: l }));
const t = el('section', { className: 'toast since', role: 'status', ariaLabel: S.title },
el('div', { className: 'thead' }, el('div', {}, el('strong', { textContent: S.title }), el('small', { textContent: S.ago(ago(last)) }))), ul,
btn(S.ok, () => t.remove(), { className: 'linkish tlater', data: { key: 'since-ok' } }));
t.addEventListener('click', (e) => { if (e.target === t) t.remove(); });
$('#toasts').append(t);
setTimeout(() => t.remove(), 15000);
return t;
}
function trackSeen() {
const last = readSeen();
if (last && Date.now() - last > 3600e3) showSince(last);
writeSeen();
setInterval(() => { if (!document.hidden) writeSeen(); }, 60000);
addEventListener('pagehide', writeSeen);
document.addEventListener('visibilitychange', () => { if (document.hidden) writeSeen(); });
}
// ---- Archivos de una tarea: pedidos, de partida y entregados (con versiones) ----
const TEXT_EXT = /\.(md|markdown|txt|py|json|csv|tsv|js|mjs|cjs|ts|tsx|jsx|css|html?|xml|ya?ml|toml|ini|sql|sh|log|rst|tex|r|java|go|rb|php|c|h|cpp)$/i;
const fileSize = (n) => n < 1024 ? n + ' B' : n < 1048576 ? Math.round(n / 1024) + ' KB' : (n / 1048576).toFixed(1) + ' MB';
function filesOf(p, t) { return Store.get().files.filter((x) => x.projectId === p.id && x.taskId === t.id && !x.deleted); }
function deliveredNames(p, t) { return new Set(filesOf(p, t).filter((x) => x.kind === 'delivery').map((x) => x.name)); }
function fileRow(fl, older) {
const who = Team.on() ? (Team.nameOf(fl.by) || '') : '';
const row = el('div', { className: 'frow' + (older ? ' older' : '') },
el('span', { className: 'fname' }, el('b', { textContent: fl.name }), el('span', { className: 'fver', textContent: 'v' + fl.version })),
el('small', { className: 'fmeta', textContent: [who, fl.at ? (ago(fl.at) === 'just now' ? 'just now' : ago(fl.at) + ' ago') : '', fileSize(fl.size)].filter(Boolean).join(' · ') + (fl.note ? ' · “' + fl.note + '”' : '') }));
const acts = el('span', { className: 'facts' });
acts.append(btn('Download', () => Team.download(fl), { className: 'dbtn small', ariaLabel: 'Download ' + fl.name + ' version ' + fl.version, data: { key: 'fdl|' + fl.id } }));
if (TEXT_EXT.test(fl.name)) acts.append(btn('Preview', () => previewFile(fl), { className: 'dbtn small', ariaLabel: 'Preview ' + fl.name + ' version ' + fl.version, data: { key: 'fpv|' + fl.id } }));
if (Team.can('deleteFiles')) acts.append(btn('Delete', () => { if (confirmDelete === fl.id) { confirmDelete = null; Team.deleteFileVersion(fl); } else { confirmDelete = fl.id; renderDetail(); } }, { className: 'dbtn small danger', ariaLabel: (confirmDelete === fl.id ? 'Confirm: delete ' : 'Delete ') + fl.name + ' version ' + fl.version, textContent: confirmDelete === fl.id ? 'Sure? Delete' : 'Delete', data: { key: 'fdel|' + fl.id } }));
row.append(acts);
return row;
}
let confirmDelete = null, openVersions = new Set();
// ---- Asignar y revisar (modo Team) ----
let submitting = null, sendingBack = null;
function personRow(box, p, t) {
const who = t.assignee ? Team.nameOf(t.assignee) || 'someone' : '';
const row = el('div', { className: 'person' }, el('span', { className: 'k', textContent: 'Person' }));
if (Team.can('assignAny')) {
const sel = el('select', { className: 'fkind', ariaLabel: 'Who has ' + t.id, data: { key: 'assignee' } }, el('option', { value: '', textContent: 'Nobody' }), ...Team.members().filter((m) => m.role !== 'viewer').map((m) => el('option', { value: m.user_id, textContent: m.name + (m.user_id === Team.myId() ? ' (you)' : '') })));
sel.value = t.assignee || '';
sel.addEventListener('change', () => Actions.setAssignee(p.id, t.id, sel.value || null));
row.append(sel);
} else row.append(el('b', { className: 'pname', textContent: who || 'Nobody yet' }));
if (!t.assignee && Team.can('take') && !Team.can('assignAny')) row.append(btn("I'm on it", () => Actions.takeTask(p.id, t.id), { className: 'dbtn small go', data: { key: 'take' } }));
else if (!t.assignee && Team.can('assignAny')) row.append(btn("I'm on it", () => Actions.takeTask(p.id, t.id), { className: 'dbtn small', data: { key: 'take' } }));
if (t.assignee === Team.myId() && !Team.can('assignAny')) row.append(btn('Let it go', () => Actions.setAssignee(p.id, t.id, null), { className: 'dbtn small', data: { key: 'letgo' } }));
box.append(row);
}
function reviewBlock(box, p, t) {
if (t.backComment && t.status !== 'review' && t.status !== 'done') box.append(el('div', { className: 'reviewbox back' }, el('p', { className: 'k', textContent: T().review.sentBackTitle }), el('p', { className: 'rtext', textContent: t.backComment })));
if (submitting === t.id && t.status !== 'review') {
const note = el('textarea', { className: 'needsin', rows: 2, placeholder: T().review.notePlaceholder, ariaLabel: 'Note for the reviewer', data: { key: 'submit-note' } });
const missing = (t.deliverables || []).filter((n) => !Store.get().files.some((x) => x.projectId === p.id && x.taskId === t.id && x.kind === 'delivery' && x.name === n && !x.deleted));
const warn = missing.length ? el('p', { className: 'rwarn', role: 'alert', textContent: T().review.missing(missing) }) : null;
box.append(el('div', { className: 'reviewbox' }, el('p', { className: 'k', textContent: T().review.submitTitle }), warn, note,
el('div', { className: 'dbtns' }, btn(missing.length ? T().review.submitAnyway : T().review.send, () => { submitting = null; note.blur(); Actions.submitForReview(p.id, t.id, note.value); }, { className: 'dbtn go', data: { key: 'submit-go' } }),
btn('Cancel', () => { submitting = null; note.blur(); renderDetail(); }, { className: 'dbtn', data: { key: 'submit-cancel' } }))));
}
if (t.status !== 'review') return;
const by = Team.nameOf(t.assignee) || T().review.someone;
const block = el('div', { className: 'reviewbox' }, el('p', { className: 'k', textContent: T().review.inReview(by) }), el('p', { className: 'rtext', textContent: t.reviewNote || T().review.noNote }));
if (Team.can('approve')) {
if (sendingBack === t.id) {
const c = el('textarea', { className: 'needsin', rows: 2, placeholder: T().review.backPlaceholder, ariaLabel: 'What needs to change (required)', data: { key: 'back-comment' } });
const go = btn('Send back', () => { if (!c.value.trim()) { c.focus(); notify(T().review.needComment); return; } sendingBack = null; c.blur(); Actions.sendBack(p.id, t.id, c.value); if (typeof Team.postBack === 'function') Team.postBack(p.id, t.id, c.value); }, { className: 'dbtn danger', data: { key: 'back-go' } });
block.append(c, el('div', { className: 'dbtns' }, go, btn('Cancel', () => { sendingBack = null; c.blur(); renderDetail(); }, { className: 'dbtn', data: { key: 'back-cancel' } })));
} else block.append(el('div', { className: 'dbtns' }, btn('Approve', () => Actions.approveTask(p.id, t.id), { className: 'dbtn go', data: { key: 'approve' } }), btn('Send back', () => { sendingBack = t.id; renderDetail(); const n = document.querySelector('[data-key="back-comment"]'); if (n) n.focus(); }, { className: 'dbtn danger', data: { key: 'send-back' } })));
}
box.append(block);
}
// Crear o editar una tarea desde la app (dueño). Workflow, título, misión, entregables y persona.
function openTaskEditor(p, t, workflowId) {
const root = openPanel('task-editor', t ? 'Edit ' + t.id : 'New task');
const wf = el('select', { className: 'hsel', ariaLabel: 'Workflow', data: { key: 'te-wf' } }, ...Store.workflowsOf(p.id).map((w) => el('option', { value: w.id, textContent: w.name })), el('option', { value: '', textContent: 'New workflow…' }));
wf.value = t ? t.workflow : workflowId || (Store.workflowsOf(p.id)[0] || {}).id || '';
wf.disabled = !!t;
const wfName = el('input', { type: 'text', className: 'pn-input', placeholder: 'Name of the new workflow', ariaLabel: 'Name of the new workflow', data: { key: 'te-wfname' } });
wfName.hidden = !!wf.value;
wf.addEventListener('change', () => { wfName.hidden = !!wf.value; });
const title = el('input', { type: 'text', className: 'pn-input', value: t ? t.title : '', maxLength: 300, ariaLabel: 'Title', data: { key: 'te-title' } });
const mission = el('textarea', { className: 'pn-input te-area', rows: 3, value: t ? t.mission || '' : '', ariaLabel: 'Mission', data: { key: 'te-mission' } });
const deliv = el('input', { type: 'text', className: 'pn-input', value: t ? (t.deliverables || []).join(', ') : '', placeholder: 'report.md, extract.py', ariaLabel: 'Deliverables (comma-separated)', data: { key: 'te-deliv' } });
const planned = el('input', { type: 'text', className: 'pn-input', value: t ? (t.files || []).join(', ') : '', placeholder: 'src/extract.py, tests/test_extract.py', ariaLabel: 'Planned files (comma-separated)', data: { key: 'te-files' } });
const person = Team.on() ? el('select', { className: 'hsel', ariaLabel: 'Person', data: { key: 'te-person' } }, el('option', { value: '', textContent: 'Nobody' }), ...Team.members().filter((m) => m.role !== 'viewer').map((m) => el('option', { value: m.user_id, textContent: m.name }))) : null;
if (person) person.value = t ? t.assignee || '' : '';
const save = btn(t ? 'Save' : 'Create task', () => {
if (!title.value.trim()) { title.focus(); notify(T().review.needTitle); return; }
if (t) Actions.updateTask(p.id, t.id, { title: title.value, mission: mission.value, deliverables: deliv.value, files: planned.value, ...(person ? { assignee: person.value || null } : {}) });
else {
const id = Actions.createTask({ projectId: p.id, workflow: wf.value, workflowName: wfName.value, title: title.value, mission: mission.value, deliverables: deliv.value, files: planned.value, assignee: person ? person.value || null : null });
if (id) { closePanel(); select({ kind: 'task', id }); LayoutHost.focus({ task: id }); notify(T().review.created(id)); return; }
}
closePanel();
}, { className: 'primary', data: { key: 'te-save' } });
const field = (label, input) => el('label', { className: 'te-field' }, el('span', { textContent: label }), input);
root.append(field('Workflow', wf), wfName, field('Title', title), field('Mission', mission), field('Deliverables · uploaded to Tasky for review (comma-separated)', deliv), field('Planned files · code this task touches (comma-separated)', planned), ...(person ? [field('Person', person)] : []), el('div', { className: 'pn-actions' }, save));
if (t) {
const confirm = el('div', { className: 'pn-confirm', hidden: true }, el('span', { textContent: 'Delete ' + t.id + ' “' + t.title + '”? It goes to the Trash for ' + Actions.TRASH_DAYS + ' days. ' }),
btn('Yes, delete', () => { closePanel(); focus = null; Actions.deleteTask(p.id, t.id); notify(T().review.deleted(t.id)); }, { className: 'danger', data: { key: 'te-del-yes' } }), btn('No', () => { confirm.hidden = true; }));
root.append(el('div', { className: 'pn-actions' }, btn('Delete task', () => { confirm.hidden = false; }, { className: 'danger', data: { key: 'te-del' } })), confirm);
}
title.focus();
}
// ---- Reportes (A6): periodo y filtros; se ve aquí y se descarga como .md ----
let reportOpts = { period: 'days', days: 7, from: '', to: '', projectId: '', workflow: '', person: '', agent: '', universe: '' };
function openReport() {
const root = openPanel('report', 'Report');
root.classList.add('wide');
const R = T().report2;
const sel = (key, label, options, value) => { const s0 = el('select', { className: 'hsel', ariaLabel: label, data: { key: 'rp-' + key } }, ...options.map(([v, l]) => el('option', { value: v, textContent: l }))); s0.value = value; return s0; };
const period = sel('period', 'Period', [['today', 'Today'], ['days', 'Last 7 days'], ['range', 'Between two dates']], reportOpts.period);
const from = el('input', { type: 'date', className: 'hsel', value: reportOpts.from || Store.today(), ariaLabel: 'From', data: { key: 'rp-from' } });
const to = el('input', { type: 'date', className: 'hsel', value: reportOpts.to || Store.today(), ariaLabel: 'To', data: { key: 'rp-to' } });
const projects = Store.get().projects.filter((p) => !p.archived);
const universes = [...new Set(Store.get().workflows.map((w) => w.universe || 'Main'))].sort();
const fu = sel('universe', 'Universe', [['', 'All universes'], ...universes.map((x) => [x, x])], reportOpts.universe);
fu.hidden = universes.length < 2;
const fp = sel('project', 'Project', [['', 'All projects'], ...projects.map((p) => [p.id, p.name])], reportOpts.projectId);
const fw = el('select', { className: 'hsel', ariaLabel: 'Workflow', data: { key: 'rp-workflow' } });
const fillWf = () => { fw.textContent = ''; fw.append(el('option', { value: '', textContent: 'All workflows' }), ...(fp.value ? Store.workflowsOf(fp.value).map((w) => el('option', { value: w.id, textContent: w.name })) : [])); fw.value = fp.value ? reportOpts.workflow : ''; fw.disabled = !fp.value; };
const fpe = sel('person', 'Person', [['', 'All people'], ...(Team.on() ? Team.members().map((m) => [m.user_id, m.name]) : [])], reportOpts.person);
fpe.hidden = !Team.on();
const agentNames = [...new Set([...Store.get().agents.map((a) => a.name), ...Store.get().history.filter((e) => e.agentName).map((e) => e.agentName)])].sort();
const fa = sel('agent', 'Agent', [['', 'All agents'], ...agentNames.map((n) => [n, n])], reportOpts.agent);
const out = el('div', { className: 'report-out', data: { key: 'report-out' } });
let md = '';
const draw = () => {
reportOpts = { period: period.value, days: 7, from: from.value, to: to.value, projectId: fp.value, workflow: fw.value, person: fpe.value, agent: fa.value, universe: fu.value };
from.hidden = to.hidden = period.value !== 'range';
md = Reports.markdown(Reports.build(reportOpts));
out.textContent = '';
out.append(renderMarkdown(md));
};
for (const n of [period, from, to, fu, fw, fpe, fa]) n.addEventListener('change', draw);
fp.addEventListener('change', () => { reportOpts.workflow = ''; fillWf(); draw(); });
fillWf();
const dl = btn('Download .md', () => { const f = 'tasky-report-' + Store.today() + '.md'; download(f, md); notify('Downloaded: ' + f + '.'); }, { className: 'primary', data: { key: 'rp-download' } });
root.append(el('p', { className: 'fnote', textContent: R.hint }), el('div', { className: 'pn-actions hfilters' }, period, from, to), el('div', { className: 'pn-actions hfilters' }, fu, fp, fw, fpe, fa), el('div', { className: 'pn-actions' }, dl), out);
draw();
period.focus();
}
// Un Markdown simple (el del reporte) como HTML seguro: todo va como texto.
function renderMarkdown(text) {
const box = el('div', { className: 'md' });
let table = null;
for (const line of String(text).split('\n')) {
if (/^\|/.test(line)) {
if (/^\|[-| ]+\|$/.test(line)) continue;
const cells = line.replace(/^\||\|$/g, '').split(/(? c.trim().replace(/\\\|/g, '|'));
if (!table) { table = el('table', { className: 'md-table' }); box.append(table); table.append(el('tr', {}, ...cells.map((c) => el('th', { textContent: c })))); }
else table.append(el('tr', {}, ...cells.map((c) => el('td', { textContent: c }))));
continue;
}
table = null;
if (/^# /.test(line)) box.append(el('h2', { textContent: line.slice(2) }));
else if (/^## /.test(line)) box.append(el('h3', { textContent: line.slice(3) }));
else if (/^### /.test(line)) box.append(el('h4', { textContent: line.slice(4) }));
else if (/^- /.test(line)) box.append(el('p', { className: 'md-li', textContent: '• ' + line.slice(2) }));
else if (line.trim()) box.append(el('p', { textContent: line }));
}
return box;
}
// ---- Archivos previstos (A5): lo que la tarea va a tocar en el código, frente a lo que los agentes tocaron ----
// Distinto de los entregables (deliverables): esos se suben a Tasky para revisión.
const ACTIVE = ['doing', 'needs-you', 'blocked', 'review'];
function collisions(pid) {
const by = new Map();
for (const t of Store.tasksOf(pid).filter((x) => ACTIVE.includes(x.status))) for (const f of new Set([...(t.files || []), ...(t.touched || [])])) { if (!by.has(f)) by.set(f, []); by.get(f).push(t); }
const out = [];
for (const [file, ts] of by) if (ts.length > 1) out.push({ file, tasks: ts });
return out;
}
const holder = (p, t) => { const a = Store.agentOn(p.id, t.id); const person = Team.on() && t.assignee ? Team.nameOf(t.assignee) : ''; return [person, a ? a.name : ''].filter(Boolean).join(' · ') || T().files.nobody; };
function plannedSection(box, p, t) {
const planned = t.files || [], touched = t.touched || [];
const clash = collisions(p.id).filter((c) => c.tasks.includes(t));
if (!planned.length && !touched.length) return;
const sec = el('section', { className: 'files planned', ariaLabel: 'Planned files' });
sec.append(el('p', { className: 'k', textContent: T().files.plannedTitle }));
const group = (label, list, cls, mark) => { if (!list.length) return; sec.append(el('p', { className: 'fsub', textContent: label }), el('p', { className: 'freq' }, ...list.map((f) => el('span', { className: 'fchip ' + cls, textContent: mark + ' ' + f })))); };
group(T().files.plannedTouched, planned.filter((f) => touched.includes(f)), 'ok', '✓');
group(T().files.plannedNotYet, planned.filter((f) => !touched.includes(f)), 'pending', '○');
group(T().files.touchedUnplanned, touched.filter((f) => !planned.includes(f)), 'extra', '+');
for (const c of clash) for (const o of c.tasks.filter((x) => x !== t)) sec.append(el('p', { className: 'rwarn clash', role: 'alert', data: { key: 'clash|' + o.id + '|' + c.file } }, T().files.clash(c.file, o.id + ' ' + o.title, holder(p, o))));
box.append(sec);
}
function filesSection(box, p, t) {
const req = t.deliverables || [];
if (!Team.on()) {
if (!req.length) return;
box.append(el('p', { className: 'k', textContent: 'Files' }), el('p', { className: 'fsub', textContent: T().files.deliverablesTitle }), el('p', { className: 'freq' }, ...req.map((n) => el('span', { className: 'fchip', textContent: n }))), el('p', { className: 'fnote', textContent: T().files.personal }));
return;
}
const all = filesOf(p, t), done = deliveredNames(p, t);
const sec = el('section', { className: 'files', ariaLabel: 'Files' });
sec.append(el('p', { className: 'k', textContent: 'Files' }));
if (req.length) {
sec.append(el('p', { className: 'fsub', textContent: 'Requested · ' + T().files.deliverablesTitle }));
const chips = el('p', { className: 'freq' });
for (const n of req) {
const ok = done.has(n);
const latest = all.filter((x) => x.kind === 'delivery' && x.name === n).reduce((m, x) => Math.max(m, x.version), 0);
chips.append(el('span', { className: 'fchip ' + (ok ? 'ok' : 'pending'), textContent: (ok ? '✓ ' : '○ ') + n + (ok ? ' · v' + latest : ' · pending'), title: ok ? 'Delivered' : 'Not delivered yet' }));
}
sec.append(chips);
}
const group = (kind) => {
const byName = new Map();
for (const x of all.filter((y) => y.kind === kind).sort((a, b) => b.version - a.version)) { if (!byName.has(x.name)) byName.set(x.name, []); byName.get(x.name).push(x); }
return byName;
};
const starting = group('starting'), delivered = group('delivery');
if (starting.size || Team.can('starting')) {
sec.append(el('p', { className: 'fsub', textContent: 'Starting files' }));
for (const [, vs] of starting) sec.append(fileRow(vs[0]));
if (!starting.size) sec.append(el('p', { className: 'fnote', textContent: T().files.noStarting }));
}
sec.append(el('p', { className: 'fsub', textContent: 'Delivered' }));
if (!delivered.size) sec.append(el('p', { className: 'fnote', textContent: T().files.none }));
for (const [name, vs] of delivered) {
sec.append(fileRow(vs[0]));
if (vs.length > 1) {
const key = p.id + '|' + t.id + '|' + name, isOpen = openVersions.has(key);
sec.append(btn((isOpen ? '▾ ' : '▸ ') + (vs.length - 1) + (vs.length === 2 ? ' older version' : ' older versions'), () => { if (isOpen) openVersions.delete(key); else openVersions.add(key); renderDetail(); }, { className: 'linkish fmore', ariaExpanded: String(isOpen), data: { key: 'fold|' + key } }));
if (isOpen) for (const old of vs.slice(1)) sec.append(fileRow(old, true));
}
}
if (Team.can('upload')) {
const input = el('input', { type: 'file', multiple: true, hidden: true });
const note = el('input', { type: 'text', className: 'fnote-in', placeholder: T().files.notePlaceholder, maxLength: 500, ariaLabel: 'Note for this upload (optional)', data: { key: 'fnote' } });
const kindSel = Team.can('starting') ? el('select', { className: 'fkind', ariaLabel: 'Upload as', data: { key: 'fkind' } }, el('option', { value: 'delivery', textContent: 'as a delivery' }), el('option', { value: 'starting', textContent: 'as a starting file' })) : null;
const send = async (files) => {
const res = await Team.uploadFiles(p.id, t.id, files, { kind: kindSel ? kindSel.value : 'delivery', note: note.value });
const bad = res.filter((x) => x.error), good = res.filter((x) => !x.error);
notify([...good.map((x) => T().files.uploaded(x.name, x.version)), ...bad.map((x) => x.error)].join('\n'));
};
input.addEventListener('change', () => { const fs2 = input.files; send(Array.from(fs2)); input.value = ''; });
const zone = el('div', { className: 'fdrop', role: 'group', ariaLabel: 'Upload files' },
el('span', { textContent: T().files.drop(Team.maxMB()) }), btn('Choose files', () => input.click(), { className: 'dbtn small', data: { key: 'fpick' } }));
zone.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); zone.classList.add('over'); });
zone.addEventListener('dragleave', () => zone.classList.remove('over'));
zone.addEventListener('drop', (e) => { e.preventDefault(); e.stopPropagation(); zone.classList.remove('over'); document.body.classList.remove('dragging'); if (e.dataTransfer && e.dataTransfer.files.length) send(Array.from(e.dataTransfer.files)); });
sec.append(zone, el('div', { className: 'frow' }, note, kindSel), input);
}
box.append(sec);
}
async function previewFile(fl) {
const root = openPanel('preview', fl.name + ' · v' + fl.version);
const pre = el('pre', { className: 'pn-text', tabIndex: 0, ariaLabel: 'Content of ' + fl.name, textContent: 'Loading…' });
root.append(el('p', { className: 'fnote', textContent: T().files.previewNote }), pre, el('div', { className: 'pn-actions' }, btn('Download', () => Team.download(fl), { className: 'primary', data: { key: 'pv-dl' } })));
const text = await Team.readText(fl);
// Siempre como texto: textContent nunca interpreta HTML ni ejecuta nada.
pre.textContent = text == null ? T().files.noPreview : text;
}
// ---- Conversación: hilo de una tarea y chat del proyecto (texto simple, siempre escapado) ----
// Los enlaces http(s) se vuelven enlaces seguros (otra pestaña, sin referrer); @nombre de alguien del equipo se resalta.
function messageBody(text) {
const out = el('p', { className: 'mbody' });
const names = Team.members().map((m) => m.name).sort((a, b) => b.length - a.length);
const re = /\bhttps?:\/\/[^\s<>"']+|@[^\s@]+(?: [^\s@]+)?/g;
let last = 0, mt;
while ((mt = re.exec(text))) {
let tok = mt[0];
if (tok[0] === '@') {
const hit = names.find((n) => text.slice(mt.index + 1, mt.index + 1 + n.length).toLowerCase() === n.toLowerCase());
if (!hit) { re.lastIndex = mt.index + 1; continue; }
tok = '@' + text.slice(mt.index + 1, mt.index + 1 + hit.length);
re.lastIndex = mt.index + tok.length;
out.append(document.createTextNode(text.slice(last, mt.index)), el('b', { className: 'mention' + (hit.toLowerCase() === Team.myName().toLowerCase() ? ' me' : ''), textContent: tok }));
} else {
tok = tok.replace(/[.,;:!?)]+$/, '');
re.lastIndex = mt.index + tok.length;
const a = el('a', { href: tok, target: '_blank', rel: 'noopener noreferrer', textContent: tok });
out.append(document.createTextNode(text.slice(last, mt.index)), a);
}
last = mt.index + tok.length;
}
out.append(document.createTextNode(text.slice(last)));
return out;
}
let editingMsg = null, openThread = null;
function threadMessages(projectId, taskId) { return Store.get().messages.filter((m) => m.projectId === projectId && (m.taskId || null) === (taskId || null)).sort((a, b) => a.at - b.at); }
function openConversation(projectId, taskId) {
const p = Store.project(projectId);
if (!p) return;
const task = taskId ? Store.task(projectId, taskId) : null;
const root = openPanel(taskId ? 'thread' : 'chat', taskId ? 'Thread · ' + taskId + ' ' + (task ? task.title : '') : 'Chat · ' + p.name);
openThread = { projectId, taskId: taskId || null };
const list = el('div', { className: 'mlist', role: 'log', ariaLive: 'polite' });
const fill = () => {
list.textContent = '';
const msgs = threadMessages(projectId, taskId);
if (!msgs.length) list.append(el('p', { className: 'pempty', textContent: taskId ? T().chat.emptyThread : T().chat.emptyChat }));
for (const m of msgs) {
const mine = m.author === Team.myId();
const item = el('article', { className: 'msg' + (mine ? ' mine' : '') + (m.deleted ? ' deleted' : ''), data: { id: m.id } });
const who = Team.memberOf(m.author);
item.append(el('header', {}, typeof Ships !== 'undefined' && who ? Ships.icon(who, 16) : null, el('b', { textContent: (who ? who.name : m.authorName || 'Someone') + (mine ? ' (you)' : '') }), el('small', { textContent: ' ' + clock(m.at) + (m.editedAt && !m.deleted ? ' · edited' : '') })));
if (m.deleted) item.append(el('p', { className: 'mbody gone', textContent: T().chat.deleted }));
else if (editingMsg === m.id) {
const ta = el('textarea', { className: 'needsin', rows: 2, value: m.body, ariaLabel: 'Edit your message', data: { key: 'msg-edit-in' } });
item.append(ta, el('div', { className: 'dbtns' }, btn('Save', () => { editingMsg = null; Actions.editMessage(m.id, ta.value); fill(); }, { className: 'dbtn small go', data: { key: 'msg-save' } }), btn('Cancel', () => { editingMsg = null; fill(); }, { className: 'dbtn small' })));
} else {
item.append(messageBody(m.body));
if (mine && Team.can('comment')) item.append(el('div', { className: 'macts' }, btn('Edit', () => { editingMsg = m.id; fill(); }, { className: 'linkish', ariaLabel: 'Edit your message', data: { key: 'msg-edit|' + m.id } }), btn('Delete', () => Actions.deleteMessage(m.id), { className: 'linkish', ariaLabel: 'Delete your message', data: { key: 'msg-del|' + m.id } })));
}
list.append(item);
}
list.scrollTop = list.scrollHeight;
Team.markRead(projectId, taskId);
};
fill();
Team.setPanelRefresh(() => { if (document.querySelector('#panel.panel-thread, #panel.panel-chat') && !document.activeElement?.matches?.('[data-key="msg-edit-in"]')) fill(); });
root.append(list);
if (Team.can('comment')) {
const ta = el('textarea', { className: 'needsin mcompose', rows: 3, placeholder: T().chat.placeholder(Team.members().filter((m) => m.user_id !== Team.myId()).map((m) => m.name)), maxLength: 4000, ariaLabel: 'Write a message', data: { key: 'msg-in' } });
const send = () => { if (!ta.value.trim()) return; Actions.postMessage(projectId, taskId, ta.value); ta.value = ''; fill(); ta.focus(); };
ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); send(); } });
root.append(ta, el('div', { className: 'pn-actions' }, btn('Send', send, { className: 'primary', data: { key: 'msg-send' } }), el('small', { className: 'fnote', textContent: T().chat.hint })));
ta.focus();
}
LayoutHost.push();
}
// ---- Campana de notificaciones ----
function notifText(n) {
const t = n.taskId ? (Store.task(n.projectId, n.taskId) || { id: n.taskId, title: '' }) : null;
const task = t ? t.id + (t.title ? ' ' + t.title : '') : 'the chat';
const a = n.actorName || 'Someone';
return ({ assigned: a + ' assigned you ' + task, mention: a + ' mentioned you in ' + task + ': “' + n.body + '”', review: a + ' sent ' + task + ' for review', approved: a + ' approved ' + task,
sent_back: a + ' sent back ' + task + ': “' + n.body + '”', needs_you: task + ' needs you: ' + n.body, message: a + ' wrote in ' + task + ': “' + n.body + '”' })[n.kind] || n.body;
}
const unreadNotifs = () => Store.get().notifications.filter((n) => !n.readAt);
function renderBell() {
const box = $('#bell');
if (!box) return;
box.hidden = !Team.on() || !Team.isEntered();
box.textContent = '';
if (box.hidden) return;
const n = unreadNotifs().length;
const b = btn('', openBell, { className: 'bell' + (n ? ' has' : ''), data: { key: 'bell' }, ariaLabel: 'Notifications: ' + n + ' unread' });
b.append(svg('', 'bicon'), el('span', { textContent: 'Notifications' }), el('span', { className: 'ibox-count', textContent: String(n) }));
box.append(b);
}
function openBell() {
const root = openPanel('notifications', 'Notifications');
const fill = () => {
root.querySelectorAll('.nlist2, .pn-actions, .pempty').forEach((x) => x.remove());
const all = Store.get().notifications.slice().sort((a, b) => b.at - a.at);
root.append(el('div', { className: 'pn-actions' }, btn('Mark all as read', () => { Actions.markNotificationsRead(); fill(); }, { className: 'primary', data: { key: 'bell-all' }, disabled: !unreadNotifs().length })));
if (!all.length) { root.append(el('p', { className: 'pempty', textContent: T().chat.noNotifications })); return; }
const ul = el('ul', { className: 'pn-list nlist2' });
for (const n of all.slice(0, 200)) {
const b = btn('', () => { Actions.markNotificationsRead([n.id]); closePanel(); if (n.taskId && Store.task(n.projectId, n.taskId)) goTo(n.projectId, { kind: 'task', id: n.taskId }); else if (n.projectId && Store.project(n.projectId)) { Actions.selectProject(n.projectId); if (n.kind === 'mention' || n.kind === 'message') openConversation(n.projectId, null); } },
{ className: 'hrow' + (n.readAt ? ' read' : ' unread'), data: { key: 'notif|' + n.id } });
b.append(el('span', { className: 'htime', textContent: ago(n.at) }), el('span', { className: 'htext', textContent: notifText(n) + (n.taskId && Store.task(n.projectId, n.taskId) ? uniTag(Store.universeOf(n.projectId, Store.task(n.projectId, n.taskId).workflow)) : '') }));
ul.append(el('li', { className: 'hitem' }, b));
}
root.append(ul);
};
fill();
Team.setPanelRefresh(() => { if (document.querySelector('#panel.panel-notifications')) fill(); });
}
// ---- Modo Team: People (quién está, dónde mira, qué hizo), Follow y Activity ----
let peopleOpen = null, following = null;
const localTime = (tz) => { try { return new Intl.DateTimeFormat('en-GB', { timeZone: tz || undefined, hour: '2-digit', minute: '2-digit' }).format(new Date()); } catch { return ''; } };
const lastBy = (uid) => { const h = Store.get().history; for (let i = h.length - 1; i >= 0; i--) if (h[i].actor === uid && !h[i].agentAct) return h[i]; return null; };
function renderPeople() {
const box = $('#people');
if (!box) return;
box.hidden = !Team.on() || !Team.isEntered();
box.textContent = '';
if (box.hidden) return;
box.append(el('h2', { className: 'label', textContent: 'People' }));
const list = el('div', { className: 'plist people-list' });
const order = Team.members().slice().sort((a, b) => (Team.isOnlineUser(b.user_id) - Team.isOnlineUser(a.user_id)) || a.name.localeCompare(b.name));
for (const m of order) {
const on = Team.isOnlineUser(m.user_id), self = m.user_id === Team.myId();
const tasks = Store.get().tasks.filter((x) => x.assignee === m.user_id && x.status !== 'done');
const last = lastBy(m.user_id);
const b = btn('', () => { peopleOpen = peopleOpen === m.user_id ? null : m.user_id; renderPeople(); }, { className: 'person-row', ariaExpanded: String(peopleOpen === m.user_id), data: { key: 'person|' + m.user_id },
ariaLabel: m.name + (self ? ' (you)' : '') + ', ' + (on ? 'online' : 'offline') + (m.timezone ? ', local time ' + localTime(m.timezone) : '') + (tasks[0] ? ', on ' + tasks[0].id : '') });
b.append(Ships.icon(m, 24), el('span', { className: 'pr-main' },
el('span', { className: 'pr-name' }, el('i', { className: 'dot-online' + (on ? ' on' : ''), ariaHidden: 'true' }), m.name + (self ? ' (you)' : ''), el('small', { textContent: m.timezone ? ' · ' + localTime(m.timezone) : '' })),
el('small', { className: 'pr-meta', textContent: (tasks[0] ? 'On ' + tasks[0].id + ' ' + tasks[0].title : T().people.noTask) }),
el('small', { className: 'pr-meta', textContent: last ? T().people.last(describe(last), ago(last.at)) : T().people.nothing })));
list.append(b);
if (peopleOpen === m.user_id) list.append(el('div', { className: 'dbtns pr-acts' },
!self && on ? btn(following === m.user_id ? 'Stop following' : 'Follow', () => (following === m.user_id ? stopFollow() : startFollow(m.user_id)), { className: 'abtn', data: { key: 'follow|' + m.user_id } }) : null,
btn('Activity', () => openActivity(m.user_id), { className: 'abtn', data: { key: 'activity|' + m.user_id } })));
}
box.append(list);
}
// Follow: mi cámara sigue a su nave hasta que toque algo.
function startFollow(uid) { following = uid; lastFollow = ''; followNow(); renderFollow(); renderPeople(); }
function stopFollow() { if (!following) return; following = null; renderFollow(); renderPeople(); }
let lastFollow = '';
function followNow() {
if (!following) return;
const x = Team.people().find((p) => p.userId === following);
if (!x) { stopFollow(); return; }
const f0 = x.focus;
if (JSON.stringify(f0) === lastFollow) return; // solo cuando cambia de objetivo
lastFollow = JSON.stringify(f0);
if (!f0 || !f0.project || !Store.project(f0.project)) return;
if (!Store.current() || Store.current().id !== f0.project) Actions.selectProject(f0.project);
if (f0.kind === 'task' || f0.kind === 'workflow') { focus = { kind: f0.kind, id: f0.id }; renderDetail(); LayoutHost.push(); LayoutHost.focus(f0.kind === 'task' ? { task: f0.id } : { workflow: f0.id }); }
}
function renderFollow() {
const bar = $('#following');
if (!bar) return;
bar.hidden = !following;
bar.textContent = '';
if (!following) return;
bar.append(Ships.icon(Team.memberOf(following), 20), el('strong', { textContent: T().people.following(Team.nameOf(following)) }), el('span', { textContent: T().people.followHint }),
btn('Stop', stopFollow, { className: 'abtn', data: { key: 'follow-stop' } }));
}
function bindFollowStop() {
// Cualquier toque fuera del aviso detiene el seguimiento (el mapa avisa con tasky:interact).
addEventListener('pointerdown', (e) => { if (following && !(e.target.closest && e.target.closest('#following, #people'))) stopFollow(); }, true);
addEventListener('keydown', (e) => { if (following && !(e.target.closest && e.target.closest('#following'))) stopFollow(); }, true);
}
// Activity de una persona: lo que hizo, y lo que le aprobaron o devolvieron, por día.
function openActivity(uid) {
const name = Team.nameOf(uid) || 'Someone';
const root = openPanel('activity', name + ' · Activity');
const rows = Store.get().history.filter((e) => (e.actor === uid && !e.agentAct) || e.forUser === uid).slice().reverse();
const tag = (e) => e.kind === 'file' && e.what === 'upload' ? 'Delivered' : e.review === 'approved' && e.forUser === uid ? 'Approved' : e.review === 'sent back' && e.forUser === uid ? 'Sent back' : e.kind === 'status' && e.to === 'review' ? 'Submitted' : '';
if (!rows.length) { root.append(el('p', { className: 'pempty', textContent: T().people.noActivity(name) })); return; }
const counts = { Delivered: 0, Approved: 0, 'Sent back': 0 };
for (const e of rows) if (counts[tag(e)] != null) counts[tag(e)]++;
root.append(el('p', { className: 'act-sum', textContent: T().people.summary(counts.Delivered, counts.Approved, counts['Sent back']) }));
let day = null, ul = null;
for (const e of rows.slice(0, 300)) {
if (dayKey(e.at) !== day) { day = dayKey(e.at); root.append(el('h3', { className: 'hday', textContent: dayLabel(e.at) })); ul = el('ul', { className: 'pn-list' }); root.append(ul); }
const tg = tag(e);
const go = btn('', () => goToEntry(e), { className: 'hrow', data: { key: 'act|' + e.id } });
go.append(el('span', { className: 'htime', textContent: clock(e.at) }), el('span', { className: 'hwho ' + (tg === 'Approved' ? 'ok' : tg === 'Sent back' ? 'back' : tg ? 'you' : ''), textContent: tg || whoName(e.who, e) }), el('span', { className: 'htext', textContent: describe(e) }));
ul.append(el('li', { className: 'hitem' }, go));
}
}
// La primera vez que alguien entra: elegir su nave.
function shipFirstTime(then) {
const me = Team.me();
if (!me || me.ship_shape) { if (then) then(); return; }
const root = openPanel('ship', T().ships.chooseTitle);
root.append(el('p', { textContent: T().ships.chooseText }), Ships.picker(me, () => { closePanel(); if (then) then(); }));
}
// ---- Galaxia de bienvenida: un clic por persona, en el proyecto que se ve ----
const welcomeText = () => { const n = document.getElementById('welcome-template'); return n ? n.textContent.replace(/<\\\/script/gi, ' t.projectId === p.id && t.assignee === m.user_id && /^WEL\d*$/.test(t.workflow));
return btn(has ? 'Welcome added' : 'Add welcome galaxy', () => {
const cur = Store.current();
if (!cur) return notify(T().team.welcomeNoProject);
const id = Actions.addWelcome(cur.id, m.user_id, welcomeText());
if (id) { notify(T().team.welcomeAdded(m.name, cur.name)); closePanel(); goTo(cur.id, { kind: 'workflow', id }); }
}, { className: 'row-btn', disabled: has || !p, title: T().team.welcomeTitle, ariaLabel: 'Add the welcome galaxy for ' + m.name + (p ? ' in ' + p.name : ''), data: { key: 'welcome|' + m.user_id } });
}
// ---- Primera vez de un Member: cuatro pasos ----
function memberSteps(then) {
const me = Team.me();
if (!me || me.role !== 'member' || me.onboarded) return false;
const S = T().steps;
const root = openPanel('steps', S.title);
const ol = el('ol', { className: 'steps' });
S.list.forEach(([h, d], i) => ol.append(el('li', { data: { key: 'step|' + (i + 1) } }, el('b', { textContent: h }), el('span', { textContent: d }))));
root.append(el('p', { textContent: S.intro }), ol, el('div', { className: 'pn-actions' }, btn(S.ok, () => { Team.updateProfile({ onboarded: true }); Actions.setHelpSeen(); closePanel(); if (then) then(); }, { className: 'primary', data: { key: 'steps-ok' } })));
return true;
}
// ---- Modo Team: cuenta y equipo ----
const ROLE = { owner: 'Owner', member: 'Member', viewer: 'Viewer' };
function renderAccount() {
const box = $('#account');
if (!box) return;
box.hidden = !Team.on() || !Team.me();
box.textContent = '';
if (box.hidden) return;
const me = Team.me();
const b = btn('', openAccount, { className: 'acct', data: { key: 'account' }, ariaLabel: 'Your account: ' + me.name + ', ' + ROLE[me.role] + (Team.isOnline() ? '' : ', offline') });
b.append(typeof Ships !== 'undefined' ? Ships.icon(me, 26) : el('span'), el('span', { className: 'acct-name', textContent: me.name }), el('span', { className: 'acct-role', textContent: ROLE[me.role] }));
box.append(b);
}
function openAccount() {
const me = Team.me();
if (!me) return;
const root = openPanel('account', 'Account');
const name = el('input', { type: 'text', className: 'pn-input', value: me.name, maxLength: 60, ariaLabel: 'Your name', data: { key: 'acc-name' } });
const saveName = btn('Save name', async () => { if (name.value.trim()) { await Team.updateProfile({ name: name.value }); notify(T().team.saved); } }, { className: 'primary', data: { key: 'acc-save' } });
root.append(el('h3', { textContent: 'Name' }), name, el('div', { className: 'pn-actions' }, saveName),
el('p', {}, el('b', { textContent: 'Email: ' }), me.email), el('p', {}, el('b', { textContent: 'Role: ' }), ROLE[me.role] + ' · ' + T().team.roleText[me.role]),
el('p', {}, el('b', { textContent: 'Team: ' }), (Team.team() || {}).name || ''));
if (typeof Ships !== 'undefined') root.append(Ships.picker(me));
const cur = el('input', { type: 'password', className: 'pn-input', autocomplete: 'current-password', ariaLabel: 'Current password', placeholder: 'Current password', data: { key: 'acc-pw-old' } });
const nw = el('input', { type: 'password', className: 'pn-input', autocomplete: 'new-password', minLength: 8, ariaLabel: 'New password', placeholder: 'New password (8 or more characters)', data: { key: 'acc-pw-new' } });
const savePw = btn('Change password', async () => {
savePw.disabled = true;
const err = await Team.changePassword(cur.value, nw.value);
savePw.disabled = false;
if (err) { notify(err); return; }
cur.value = ''; nw.value = ''; notify(T().team.passwordChanged);
}, { data: { key: 'acc-pw-save' } });
root.append(el('h3', { textContent: 'Password' }), cur, nw, el('div', { className: 'pn-actions' }, savePw));
// Cuánto ven los demás de mis agentes (A2). En modo Personal no aplica: siempre se ve todo lo propio.
const lv = Team.sharing();
const share = el('div', { className: 'pn-actions', role: 'group', ariaLabel: 'What the team sees of your agents' });
for (const [k, label] of [['status', 'Status only'], ['tools', 'Status and tools'], ['files', 'Status, tools and file names']]) {
const b0 = btn(label, async () => { if (await Team.setSharing(k)) { notify(T().live.sharingSaved(label)); for (const x of share.children) x.setAttribute('aria-pressed', String(x.dataset.level === k)); } }, { data: { key: 'share|' + k, level: k } });
b0.setAttribute('aria-pressed', String(lv === k));
share.append(b0);
}
root.append(el('h3', { textContent: 'Your agents: what the team sees' }), el('p', { className: 'fnote', textContent: T().live.sharingHint }), share);
root.append(el('h3', { textContent: 'Session' }), el('div', { className: 'pn-actions' }, btn('Sign out', () => Team.signOut(), { className: 'danger', data: { key: 'acc-signout' } })));
name.focus();
}
// ---- Salida de un miembro, en un solo paso: reasignar sus tareas, retirar sus agentes, cerrar sus sesiones ----
function openOffboard(m) {
const root = openPanel('offboard', 'Remove ' + m.name);
const tasks = Store.get().tasks.filter((t) => t.assignee === m.user_id && t.status !== 'done');
const agents = Store.get().agents.filter((a) => a.owner === m.user_id);
const choices = Team.members().filter((x) => x.user_id !== m.user_id && x.role !== 'viewer');
const sels = [];
root.append(el('p', { textContent: T().team.offboardIntro(m.name) }), el('h3', { textContent: 'Their open tasks (' + tasks.length + ')' }));
if (!tasks.length) root.append(el('p', { className: 'pempty', textContent: T().team.offboardNoTasks }));
else {
const ul = el('ul', { className: 'pn-list' });
for (const t of tasks) {
const pr = Store.project(t.projectId);
const sel = el('select', { className: 'hsel', ariaLabel: 'Who takes ' + t.id, data: { key: 'off-to|' + t.projectId + '|' + t.id } }, el('option', { value: '', textContent: 'Nobody (free task)' }), ...choices.map((x) => el('option', { value: x.user_id, textContent: x.name })));
sels.push({ t, sel });
ul.append(el('li', { className: 'pn-item tmember' }, el('span', {}, el('b', { textContent: t.id + ' ' + t.title }), el('small', { textContent: ' · ' + (pr ? pr.name : '') + ' · ' + T().status[t.status] })), sel));
}
root.append(ul);
}
root.append(el('h3', { textContent: 'Their agents' }), el('p', { textContent: T().team.offboardAgents(agents.length) }), el('h3', { textContent: 'Their sessions' }), el('p', { textContent: T().team.offboardSessions }));
const go = btn('Remove ' + m.name, async () => {
go.disabled = true;
const r = await Team.offboard(m.user_id, sels.map(({ t, sel }) => ({ project: t.projectId, task: t.id, to: sel.value || null })));
go.disabled = false;
if (r) { closePanel(); notify(T().team.offboarded(m.name, r)); }
}, { className: 'danger', data: { key: 'rm-yes|' + m.user_id } });
root.append(el('div', { className: 'pn-actions' }, go, btn('Cancel', closePanel, { data: { key: 'off-cancel' } })));
go.focus();
}
function openTeam() {
if (Team.role() !== 'owner') return;
const root = openPanel('team', 'Team');
const body = el('div', { className: 'team-body' });
const fill = () => {
body.textContent = '';
const people = el('ul', { className: 'pn-list' });
for (const m of Team.members()) {
const self = m.user_id === Team.myId();
const roleSel = el('select', { className: 'hsel', ariaLabel: 'Role of ' + m.name, disabled: self, data: { key: 'role|' + m.user_id } });
for (const r of ['owner', 'member', 'viewer']) roleSel.append(el('option', { value: r, textContent: ROLE[r] }));
roleSel.value = m.role;
roleSel.addEventListener('change', async () => { if (await Team.setRole(m.user_id, roleSel.value)) { notify(T().team.roleChanged(m.name, ROLE[roleSel.value])); fill(); } });
const li = el('li', { className: 'pn-item tmember' }, typeof Ships !== 'undefined' ? Ships.icon(m, 22) : el('span'), el('span', {}, el('b', { textContent: m.name + (self ? ' (you)' : '') }), el('small', { textContent: ' ' + m.email })), roleSel);
if (!self) {
// Quitar a alguien abre un solo paso: sus tareas (para reasignarlas), sus agentes y sus sesiones.
li.append(btn('Remove', () => openOffboard(m), { className: 'row-btn', ariaLabel: 'Remove ' + m.name, data: { key: 'rm|' + m.user_id } }));
if (m.role !== 'viewer') li.append(welcomeButton(m));
}
// Un enlace de un solo uso para poner contraseña nueva (Tasky no manda correos).
li.append(btn('Password link', async () => {
const r = await Team.passwordLink(m.user_id);
if (!r) return;
const o = body.querySelector('.inv-out'); o.hidden = false; o.textContent = T().team.passwordLink(m.name, new Date(r.expires).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' })) + ' ' + r.url;
copied(await copyText(r.url), 'Password link');
}, { className: 'row-btn', title: T().team.passwordLinkTitle, ariaLabel: 'Password link for ' + m.name, data: { key: 'pw-link|' + m.user_id } }));
people.append(li);
}
const invs = Team.invitations().filter((i) => !i.accepted_at);
const pend = el('ul', { className: 'pn-list' });
for (const i of invs) {
const link = Team.inviteLink(i);
pend.append(el('li', { className: 'pn-item tinv' }, el('span', {}, el('b', { textContent: i.email }), el('small', { textContent: ' · ' + ROLE[i.role] + ' · code ' + i.code })),
btn('Copy link', async () => copied(await copyText(link), 'Invitation link'), { className: 'row-btn', data: { key: 'inv-copy|' + i.id } }),
btn('Withdraw', async () => { if (await Team.revoke(i.id)) fill(); }, { className: 'row-btn', data: { key: 'inv-del|' + i.id } })));
}
const email = el('input', { type: 'email', className: 'pn-input', placeholder: 'name@example.com', ariaLabel: 'Email to invite', data: { key: 'inv-email' } });
const roleNew = el('select', { className: 'hsel', ariaLabel: 'Role for the new person', data: { key: 'inv-role' } });
for (const r of ['member', 'viewer']) roleNew.append(el('option', { value: r, textContent: ROLE[r] }));
const out = el('p', { className: 'phrase', hidden: true });
const go = btn('Invite', async () => {
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email.value.trim())) { notify(T().team.badEmail); return; }
const inv = await Team.invite(email.value, roleNew.value);
if (inv) { email.value = ''; fill(); const o = body.querySelector('.inv-out'); o.hidden = false; o.textContent = T().team.sendLink(inv.email) + ' ' + Team.inviteLink(inv); }
}, { className: 'primary', data: { key: 'inv-go' } });
out.classList.add('inv-out');
const backup = el('input', { type: 'file', accept: '.json,application/json', hidden: true });
backup.addEventListener('change', async () => {
const file = backup.files && backup.files[0]; backup.value = '';
if (!file) return;
let data = null; try { data = JSON.parse(await file.text()); } catch {}
const r = data ? Actions.importPersonal(data) : { ok: false };
notify(r && r.ok ? T().team.imported(r.projects, r.tasks, r.events) : T().team.notBackup);
});
const u = Team.storageUsage();
const bar = el('span', { className: 'bar store-bar' + (u.near ? ' near' : '') }, el('i'));
bar.firstChild.style.width = Math.min(100, Math.round(100 * u.pct)) + '%';
body.append(el('h3', { textContent: 'Storage' }), el('p', { className: 'store-use' + (u.near ? ' near' : ''), data: { key: 'storage' }, textContent: T().team.storage(fileSize(u.used), fileSize(u.limit), Math.round(100 * u.pct)) + (u.near ? ' ' + T().team.storageNear : '') }), bar);
body.append(el('h3', { textContent: 'People' }), people,
el('h3', { textContent: 'Invite someone' }), el('p', { textContent: T().team.inviteHint }), el('div', { className: 'pn-actions' }, email, roleNew, go), out,
el('h3', { textContent: 'Pending invitations' }), invs.length ? pend : el('p', { className: 'pempty', textContent: 'None.' }),
el('h3', { textContent: 'Bring your projects from Personal mode' }), el('p', { textContent: T().team.personalHint }),
el('div', { className: 'pn-actions' }, btn('Choose a Tasky backup…', () => backup.click(), { data: { key: 'team-import' } })), backup);
};
fill();
Team.setPanelRefresh(() => { if (document.querySelector('#panel.panel-team')) fill(); });
root.append(body);
}
// ---- Universos (A7) ----
// El universo que se ve: el elegido (se recuerda por persona); si el proyecto a la vista no tiene nada allí, el primero
// que tenga. Sin universos (todo en Main) no hay selector y nada cambia.
const multiUni = () => Store.universes().length > 1;
function currentUniverse() {
const p = Store.current(), list = Store.universes();
const want = Store.get().settings.universe;
const mine = p ? [...new Set(Store.workflowsOf(p.id).map((w) => w.universe || 'Main'))] : [];
if (want && list.includes(want) && (!p || !mine.length || mine.includes(want))) return want;
return mine[0] || (list.includes(want) ? want : list[0]);
}
const uniTag = (u) => (multiUni() ? ' · ' + u : '');
function jump() {
const st = $('#stage');
if (!st) return;
st.classList.remove('warp'); void st.offsetWidth; st.classList.add('warp');
setTimeout(() => st.classList.remove('warp'), 800);
}
// Ir a un universo: si el proyecto a la vista no tiene nada allí, se abre el primero que sí.
function setUniverse(u, { quiet = false } = {}) {
const before = currentUniverse();
const p = Store.current();
if (!(p && Store.workflowsOf(p.id).some((w) => (w.universe || 'Main') === u))) {
const other = Store.get().projects.find((x) => !x.archived && Store.workflowsOf(x.id).some((w) => (w.universe || 'Main') === u));
if (other) { focus = null; Actions.selectProject(other.id); }
}
Actions.chooseUniverse(u);
if (!quiet && before !== u) jump();
}
// Cuánta gente mira cada universo y cuántos agentes trabajan en él.
function universeCounts(u) {
const people = Team.on() ? Team.people().filter((x) => x.userId !== Team.myId() && x.focus && (x.focus.universe || 'Main') === u).length : 0;
const agents = Store.visibleAgents().filter((a) => a.taskId && Store.project(a.projectId) && Store.universeOf(a.projectId, (Store.task(a.projectId, a.taskId) || {}).workflow) === u).length;
return { people, agents };
}
function renderUniverses() {
const nav = $('#universes');
if (!nav) return;
nav.hidden = !multiUni() || !Store.current();
nav.textContent = '';
if (nav.hidden) return;
const cur = currentUniverse();
nav.append(el('span', { className: 'ulabel', textContent: 'Universe' }));
for (const u of Store.universes()) {
const c = universeCounts(u);
const extra = [c.people ? c.people + (c.people === 1 ? ' person' : ' people') : '', c.agents ? c.agents + (c.agents === 1 ? ' agent' : ' agents') : ''].filter(Boolean).join(' · ');
const b = btn('', () => setUniverse(u), { className: 'ubtn2', ariaPressed: String(u === cur), ariaLabel: 'Universe ' + u + (extra ? ', ' + extra : ''), data: { key: 'uni|' + u } });
b.append(el('b', { textContent: u }));
if (extra) b.append(el('small', { textContent: extra }));
nav.append(b);
}
if (!Team.on() || Team.can('editProjects')) nav.append(btn('✎', openUniverses, { className: 'linkish urename', title: T().universes.manageTitle, ariaLabel: 'Rename universes', data: { key: 'uni-manage' } }));
}
function openUniverses() {
const root = openPanel('universes', 'Universes');
const list = el('ul', { className: 'pn-list' });
for (const u of Store.universes()) {
const n = Store.get().workflows.filter((w) => (w.universe || 'Main') === u).length;
const input = el('input', { type: 'text', className: 'pn-input', value: u, maxLength: 60, ariaLabel: 'Name of the universe ' + u, data: { key: 'uni-name|' + u } });
list.append(el('li', { className: 'pn-item tmember' }, el('span', { textContent: n + (n === 1 ? ' workflow' : ' workflows') }), input,
btn('Rename', () => { if (Actions.renameUniverse(u, input.value)) { notify(T().universes.renamed(u, input.value.trim())); closePanel(); } }, { className: 'row-btn', data: { key: 'uni-rename|' + u } })));
}
root.append(el('p', { textContent: T().universes.hint }), list);
}
// En la tarjeta de un workflow: su universo, y moverlo a otro (o a uno nuevo).
function universeRow(box, p, w) {
const can = !Team.on() || Team.can('editProjects');
if (!can && !multiUni()) return;
const row = el('div', { className: 'person' }, el('span', { className: 'k', textContent: 'Universe' }));
if (!can) { row.append(el('b', { textContent: w.universe || 'Main' })); box.append(row); return; }
const sel = el('select', { className: 'fkind', ariaLabel: 'Universe of ' + w.name, data: { key: 'wf-universe' } }, ...Store.universes().map((u) => el('option', { value: u, textContent: u })), el('option', { value: '+', textContent: 'New universe…' }));
sel.value = w.universe || 'Main';
const name = el('input', { type: 'text', className: 'fnote-in', placeholder: 'Name of the new universe', maxLength: 60, ariaLabel: 'Name of the new universe', hidden: true, data: { key: 'wf-universe-new' } });
const go = btn('Move', () => { name.blur(); const u = sel.value === '+' ? name.value : sel.value; if (u && Actions.setWorkflowUniverse(p.id, w.id, u)) { notify(T().universes.moved(w.name, u.trim())); setUniverse(u.trim().replace(/\s+/g, ' ')); } }, { className: 'dbtn small', hidden: true, data: { key: 'wf-universe-go' } });
sel.addEventListener('change', () => { name.hidden = sel.value !== '+'; go.hidden = sel.value === (w.universe || 'Main'); if (sel.value === '+') name.focus(); });
row.append(sel, name, go);
box.append(row);
}
// ---- Selección y tarjeta de detalle ----
function selectedForLayout() { return focus && (focus.kind === 'task' || focus.kind === 'workflow') ? { kind: focus.kind, id: focus.id } : null; }
function select(item) { focus = item; renderDetail(); renderWorkflows(); LayoutHost.push(); publishFocus(); }
// Modo Team: los demás ven mi nave junto a lo que miro.
function publishFocus() {
if (!Team.on() || !Team.isEntered()) return;
const p = Store.current();
Team.setFocus(p ? { project: p.id, kind: focus ? focus.kind : 'project', id: focus ? focus.id : null, universe: currentUniverse() } : null);
}
// Señalar (cursor o foco) ya no cambia la tarjeta: solo los clics dentro del mapa.
function peek() {}
function taskClicked(id) {
const p = Store.current();
if (!p || !Store.task(p.id, id)) return;
if (isPast()) { focus = { kind: 'task', id }; renderDetail(); LayoutHost.push(); notify(T().timeline.noEdit); return; }
focus = { kind: 'task', id };
Actions.advanceTask(p.id, id);
render();
}
function resolve(item) {
const p = Store.current();
if (!item) return null;
if (item.kind === 'task' && p) { const t = Store.task(p.id, item.id); return t ? { kind: 'task', p, t } : null; }
if (item.kind === 'workflow' && p) { const w = Store.workflow(p.id, item.id); return w ? { kind: 'workflow', p, w } : null; }
return null;
}
// Si el usuario escribe dentro de la tarjeta, no se redibuja debajo de sus dedos.
const typingInDetail = () => { const a = document.activeElement; return a && a.closest && a.closest('#detail') && (a.tagName === 'INPUT' || a.tagName === 'TEXTAREA'); };
function renderDetail() {
const box = $('#detail');
if (!box || typingInDetail()) return;
const key = document.activeElement && document.activeElement.closest && document.activeElement.closest('#detail') ? document.activeElement.dataset.key : null;
box.textContent = '';
const r = resolve(focus);
if (!r) detailDefault(box);
else if (r.kind === 'task') detailTask(box, r.p, r.t);
else detailWorkflow(box, r.p, r.w);
if (key) { const n = Array.from(box.querySelectorAll('[data-key]')).find((x) => x.dataset.key === key); if (n) n.focus(); }
}
function legend() {
const g = el('div', { className: 'legend' });
for (const st of Store.statusList()) g.append(el('span', {}, ico(st, '#2EC4B6'), T().status[st]));
return g;
}
function detailDefault(box) {
box.append(el('div', { className: 'dhead' }, el('span', { className: 'qmark', textContent: '?', ariaHidden: 'true' }), el('h3', { textContent: T().touchTitle })),
el('p', { className: 'k', textContent: 'How it works' }), el('p', { textContent: T().howItWorks }), legend());
}
function detailTask(box, p, t) {
if (isPast()) { const ps = statusesAt(p.id, pastAt).get(t.id); t = { ...t, status: ps || t.status }; }
const w = Store.workflow(p.id, t.workflow);
const color = Store.colorOf(p.id, t.workflow);
const big = el('span', { className: 'bigid', textContent: t.id });
big.style.setProperty('--c', color);
box.append(el('div', { className: 'dhead' }, big, el('div', {}, el('h3', { textContent: t.title }),
btn(w ? w.name : t.workflow, () => { select({ kind: 'workflow', id: t.workflow }); LayoutHost.focus({ workflow: t.workflow }); }, { className: 'wflink', ariaLabel: 'Workflow ' + (w ? w.name : t.workflow), data: { key: 'd-wf' } }))));
if (t.status === 'needs-you') {
// Lo que Felipe tiene que hacer, destacado y editable, con el botón para destrabarla.
const need = el('textarea', { className: 'needsin', rows: 2, value: t.needs || '', placeholder: T().needsPlaceholder, ariaLabel: 'What you need to do for ' + t.id, data: { key: 'd-needs' } });
need.addEventListener('input', () => Actions.setTaskNeeds(p.id, t.id, need.value));
need.addEventListener('blur', () => renderDetail());
const go = btn('', () => { focus = { kind: 'task', id: t.id }; Actions.setTaskStatus(p.id, t.id, 'doing'); }, { className: 'dbtn go', ariaLabel: 'Done, unblock ' + t.id + ': move it to In progress', data: { key: 'd-unblock' } });
go.append(ico('doing', '#FFD23F'), T().unblock);
box.append(el('div', { className: 'needsbox' }, el('p', { className: 'k', textContent: T().needsTitle + (t.needsSince ? ' · waiting ' + ago(t.needsSince) : '') }), need, go));
}
if (t.mission) box.append(el('p', { className: 'k', textContent: 'Mission' }), el('p', { textContent: t.mission }));
if (Team.on()) { personRow(box, p, t); reviewBlock(box, p, t); }
if (Team.on() ? Team.can('editTasks') : true) box.append(el('div', { className: 'dbtns' }, btn('Edit task', () => openTaskEditor(p, t), { className: 'dbtn small', ariaLabel: 'Edit ' + t.id, data: { key: 'task-edit' } })));
box.append(el('p', { className: 'k', textContent: 'Status' }));
const row = el('div', { className: 'dbtns', ariaLabel: 'Set status', role: 'group' });
for (const st of Store.statusList()) {
if (st === 'done' && Team.on() && !Team.can('approve') && t.status !== 'done') continue; // un Member no aprueba
if (st === 'review' && Team.on() && !Team.can('approve')) {
// Un Member no marca Done: su botón es "Submit for review" (con una nota).
if (t.status === 'review') { const b = btn('', null, { className: 'dbtn', disabled: true, data: { key: 'st|review' } }); b.append(ico('review', color), T().status.review); b.setAttribute('aria-pressed', 'true'); row.append(b); }
else { const b = btn('', () => { submitting = t.id; renderDetail(); }, { className: 'dbtn go', ariaLabel: 'Submit ' + t.id + ' for review', data: { key: 'submit' } }); b.append(ico('review', '#B28DFF'), T().review.submit); row.append(b); }
continue;
}
const b = btn('', () => { focus = { kind: 'task', id: t.id }; Actions.setTaskStatus(p.id, t.id, st); }, { className: 'dbtn', ariaLabel: 'Set ' + t.id + ' to ' + T().status[st], data: { key: 'st|' + st } });
b.append(ico(st, color), T().status[st]);
b.setAttribute('aria-pressed', String(t.status === st));
row.append(b);
}
box.append(row);
plannedSection(box, p, t);
filesSection(box, p, t);
if (Team.on()) {
const n = threadMessages(p.id, t.id).filter((m) => !m.deleted).length, u = Team.unreadIn(p.id, t.id);
box.append(el('div', { className: 'dbtns' }, btn('Thread' + (n ? ' (' + n + ')' : '') + (u ? ' · ' + u + ' new' : ''), () => openConversation(p.id, t.id), { className: 'dbtn' + (u ? ' go' : ''), ariaLabel: 'Thread of ' + t.id + ': ' + n + ' messages, ' + u + ' unread', data: { key: 'thread' } })));
}
if (typeof Agents !== 'undefined') Agents.assignRow(box, p, t);
if (Team.on() && !Team.can('status')) for (const n of box.querySelectorAll('.dbtns button, .needsbox button, .needsbox textarea')) n.disabled = true;
// En el pasado se mira, no se edita.
if (isPast()) {
for (const n of box.querySelectorAll('button:not(.wflink), textarea')) { n.disabled = true; }
box.append(el('p', { className: 'k', textContent: T().timeline.cardNote(whenLabel(pastAt)) }));
}
}
function detailWorkflow(box, p, w) {
const c = Store.counts(p.id, w.id);
const color = Store.colorOf(p.id, w.id);
const dot = el('span', { className: 'bigid', textContent: w.id });
dot.style.setProperty('--c', color);
box.append(el('div', { className: 'dhead' }, dot, el('h3', { textContent: w.name })));
box.append(el('p', { textContent: w.mission || T().noMission }));
universeRow(box, p, w);
const bar = el('span', { className: 'bar' }, el('i'));
bar.firstChild.style.width = (c.total ? 100 * c.done / c.total : 0) + '%';
bar.style.setProperty('--c', color);
box.append(el('p', { className: 'k', textContent: c.done + ' of ' + c.total + ' done' }), bar, el('p', { className: 'k', textContent: 'Tasks' }));
const list = el('ul', { className: 'dtasks' });
for (const t of Store.tasksOf(p.id).filter((x) => x.workflow === w.id)) {
const b = btn('', () => { select({ kind: 'task', id: t.id }); LayoutHost.focus({ task: t.id }); }, { className: 'dtask', ariaLabel: t.id + ' ' + t.title + ', ' + T().status[t.status], data: { key: 'dt|' + t.id } });
b.append(ico(t.status, color), el('b', { textContent: t.id }), el('span', { textContent: t.title }));
list.append(el('li', {}, b));
}
box.append(list);
if (Team.on() ? Team.can('editTasks') : true) box.append(el('div', { className: 'dbtns' }, btn('New task', () => openTaskEditor(p, null, w.id), { className: 'dbtn small', ariaLabel: 'New task in ' + w.name, data: { key: 'task-new' } })));
}
// Panel del proyecto: renombrar, estado, handoff, reemplazar, duplicar, archivar, borrar.
function openProject(projectId) {
const p = Store.project(projectId);
if (!p) return;
const root = openPanel('project', 'Project');
const c = Store.counts(p.id);
const name = el('input', { type: 'text', className: 'pn-input', value: p.name, ariaLabel: 'Project name', autocomplete: 'off' });
name.addEventListener('input', () => { if (name.value.trim()) Actions.renameProject(p.id, name.value); });
name.addEventListener('keydown', (e) => { if (e.key === 'Enter') name.blur(); });
name.addEventListener('blur', () => { const q = Store.project(p.id); if (q) name.value = q.name; });
const st = el('div', { className: 'pn-actions', role: 'group', ariaLabel: 'Project status' });
const stBtns = Actions.PROJECT_STATUSES.map((x) => {
const b = btn(T().projectStatus[x], () => { Actions.setProjectStatus(p.id, x); stBtns.forEach((y) => y.setAttribute('aria-pressed', String(y.dataset.status === x))); }, { ariaLabel: 'Mark project ' + T().projectStatus[x], data: { status: x } });
b.setAttribute('aria-pressed', String(p.status === x));
st.append(b);
return b;
});
const confirm = el('div', { className: 'pn-confirm', hidden: true });
const del = btn('Delete', () => { confirm.hidden = false; yes.focus(); }, { className: 'danger' });
const yes = btn('Yes, delete', () => { const n = p.name; closePanel(); Actions.deleteProject(p.id); focus = null; notify(T().trash.moved(n)); render(); }, { className: 'danger' });
const no = btn('No', () => { confirm.hidden = true; del.focus(); });
confirm.append(el('span', { textContent: 'Delete “' + p.name + '” with all its tasks? It goes to the Trash for ' + Actions.TRASH_DAYS + ' days, with its history, and you can restore it. ' }), yes, no);
root.append(el('h3', { textContent: 'Name' }), name, el('p', { textContent: c.done + ' of ' + c.total + ' done' + (p.doneWhen ? ' · Done when: ' + p.doneWhen : '') }),
el('h3', { textContent: 'Status' }), st, el('h3', { textContent: 'Actions' }),
el('div', { className: 'pn-actions' },
btn('View handoff', () => viewHandoff(p.id), { className: 'primary' }),
btn('Replace handoff', () => replaceHandoff(p.id)),
btn('Duplicate', () => { const id = Actions.duplicateProject(p.id); closePanel(); focus = null; if (id) notify('Copy created: ' + Store.project(id).name + '.'); render(); }),
btn('Archive', () => { const n = p.name; closePanel(); Actions.archiveProject(p.id, true); focus = null; notify(n + ' archived. Find it under “Archived”.'); render(); }),
del), confirm);
name.focus();
}
// ---- Columnas ----
const TOOL_ICONS = {
drop: '',
template: '',
export: '',
backup: '',
chat: '',
team: '',
history: '',
help: '',
};
const TOOL_RUN = { chat: () => { const p = Store.current(); if (p) openConversation(p.id, null); }, team: () => openTeam(), drop: () => pickFile(), template: () => openTemplate(), export: () => exportAll(), history: () => openHistory(), backup: () => openBackup(), help: () => Help.open() };
function renderTools() {
const box = $('#tool-list');
box.textContent = '';
// Modo Team: importar handoffs es del dueño; el dueño tiene además la pantalla Team.
const ids = !Team.on() ? ['drop', 'template', 'export', 'history', 'backup', 'help']
: [...(Team.can('import') ? ['drop'] : []), 'chat', 'template', 'export', 'history', ...(Team.role() === 'owner' ? ['team'] : []), 'backup', 'help'];
for (const id of ids) {
const b = el('button', { type: 'button', className: 'tool' + (id === 'drop' ? ' primary' : ''), onclick: TOOL_RUN[id], title: T().tools[id].text, data: { key: 'tool|' + id } }, svg(TOOL_ICONS[id]), T().tools[id].name);
box.append(b);
}
}
function renderProjects() {
const box = $('#project-list');
const s = Store.get();
const cur = Store.current();
box.textContent = '';
const live = s.projects.filter((p) => !p.archived);
const list = el('div', { className: 'plist' });
if (!live.length) list.append(el('p', { className: 'pempty', textContent: T().noProjects }));
for (const p of live) {
const c = Store.counts(p.id);
const pct = c.total ? Math.round(100 * c.done / c.total) : 0;
const bar = el('span', { className: 'bar' }, el('i'));
bar.firstChild.style.width = pct + '%';
const elsewhere = multiUni() && !Store.workflowsOf(p.id).some((w) => (w.universe || 'Main') === currentUniverse()) && Store.workflowsOf(p.id).length > 0;
const b = el('button', { type: 'button', className: 'pitem ' + p.status + (elsewhere ? ' elsewhere' : ''), title: elsewhere ? T().universes.elsewhere([...new Set(Store.workflowsOf(p.id).map((w) => w.universe || 'Main'))].join(', ')) : '', onclick: () => { if (Store.current() && Store.current().id === p.id) openProject(p.id); else { focus = null; Actions.selectProject(p.id); } },
ariaLabel: p.name + ', ' + pct + '% done, ' + T().projectStatus[p.status], data: { key: 'proj|' + p.id } },
el('span', { className: 'pname', textContent: p.name }), el('span', { className: 'pct', textContent: pct + '%' }), bar);
b.setAttribute('aria-current', String(!!cur && cur.id === p.id));
list.append(b);
}
box.append(list);
const archived = s.projects.length - live.length;
if (archived) box.append(btn('Archived (' + archived + ')', openArchive, { className: 'linkish', title: T().tools.archived.text, data: { key: 'archived' } }));
const trash = s.trash || [];
if (trash.length && (!Team.on() || Team.can('editProjects'))) box.append(btn('Trash (' + trash.length + ')', openTrash, { className: 'linkish', title: T().trash.title, data: { key: 'trash' } }));
}
function renderHead() {
const head = $('#head');
head.textContent = '';
const p = Store.current();
if (!p) return;
const c = Store.counts(p.id);
head.append(btn(p.name, () => { if (Team.on() && !Team.can('editProjects')) { notify(T().team.ownerOnly); return; } openProject(p.id); }, { className: 'ptitle', ariaLabel: p.name + ': project actions', title: T().projectTitle, data: { key: 'ptitle' } }),
el('span', { className: 'count', textContent: c.done + ' of ' + c.total + ' done' }),
btn('Report', () => openReport(), { className: 'chip report', title: T().report2.title, data: { key: 'report' } }));
const views = el('div', { className: 'views', role: 'group', ariaLabel: 'View' });
const cur = LayoutHost.current() ? LayoutHost.current().id : Store.get().settings.layoutId;
for (const l of LayoutHost.list()) {
const b = btn(l.name, () => Actions.chooseLayout(l.id), { title: T().views[l.id] || T().views.own, data: { key: 'view|' + l.id } });
b.setAttribute('aria-pressed', String(l.id === cur));
if (l.factory) { views.append(b); continue; }
views.append(el('span', { className: 'own' }, b, btn('✕', () => removeLayout(l.id), { className: 'del', ariaLabel: 'Delete the view ' + l.name, data: { key: 'view-del|' + l.id } })));
}
views.append(btn('+', addLayout, { ariaLabel: 'Add your own view', title: T().views.add, data: { key: 'view|+' } }));
head.append(views);
}
function renderEmpty() {
const show = !Store.current();
const box = $('#empty');
box.hidden = !show;
$('#stage').hidden = show;
if (!show || box.childElementCount) return;
const area = el('textarea', { rows: 5, placeholder: '…or paste the text of a .tasky.md here', ariaLabel: 'Handoff text to import' });
const big = el('button', { type: 'button', className: 'drop-zone', onclick: pickFile, ariaLabel: 'Drop or pick your first handoff' },
el('strong', { textContent: 'Drop your first handoff here' }), el('span', { textContent: 'a .tasky.md file, or click to pick it' }));
if (typeof Bridge !== 'undefined' && Bridge.servedByBridge()) {
const M = T().migrate;
const steps = el('ol', { className: 'msteps' });
for (const line of M.steps) steps.append(el('li', { textContent: line }));
box.append(el('section', { className: 'migrate', ariaLabel: M.title },
el('strong', { textContent: M.title }), el('p', { textContent: M.text }), steps,
btn(M.button, openBackup, { className: 'btn primary', data: { key: 'migrate-restore' } })));
}
box.append(big, el('p', { textContent: T().noHandoffYet }),
btn('Template for your agent', openTemplate, { className: 'btn' }),
el('div', { className: 'paste' }, area, btn('Import text', () => { if (area.value.trim()) { importTexts([{ name: '', text: area.value }]); area.value = ''; } }, { className: 'btn primary' })));
}
function renderWorkflows() {
const box = $('#wf-list');
box.textContent = '';
const p = Store.current();
if (!p) { box.append(el('p', { className: 'pempty', textContent: T().noWorkflows })); return; }
const uni = currentUniverse();
if (multiUni()) box.append(el('p', { className: 'wuni', textContent: uni }));
for (const w of Store.workflowsOf(p.id).filter((x) => !multiUni() || (x.universe || 'Main') === uni)) {
const c = Store.counts(p.id, w.id);
const pct = c.total ? 100 * c.done / c.total : 0;
const key = p.id + '|' + w.id;
const before = prevPct.has(key) ? prevPct.get(key) : pct;
prevPct.set(key, pct);
if (pct > before) bumpUntil.set(key, Date.now() + 900);
const color = Store.colorOf(p.id, w.id);
const bar = el('span', { className: 'bar' }, el('i'));
bar.firstChild.style.width = before + '%';
const b = el('button', { type: 'button', className: 'witem' + ((bumpUntil.get(key) || 0) > Date.now() ? ' bump' : ''), onclick: () => { select({ kind: 'workflow', id: w.id }); LayoutHost.focus({ workflow: w.id }); },
ariaLabel: w.name + ', ' + c.done + ' of ' + c.total + ' done' + (w.mission ? '. ' + w.mission : ''), data: { key: 'wf|' + w.id } },
el('span', { className: 'dot' }), el('span', { className: 'wname', textContent: w.name }), el('span', { className: 'wnum', textContent: c.done + '/' + c.total }),
el('span', { className: 'wmis', textContent: w.mission || '' }), bar);
b.style.setProperty('--c', color);
b.setAttribute('aria-current', String(!!focus && focus.kind === 'workflow' && focus.id === w.id));
box.append(b);
if (before !== pct) requestAnimationFrame(() => requestAnimationFrame(() => { bar.firstChild.style.width = pct + '%'; }));
}
}
function keep(fn) {
const key = document.activeElement && document.activeElement.dataset ? document.activeElement.dataset.key : null;
fn();
if (key && !document.activeElement.closest('#panel')) { const n = Array.from(document.querySelectorAll('[data-key]')).find((x) => x.dataset.key === key); if (n && n !== document.activeElement) n.focus(); }
}
function render() {
keep(() => {
renderTools(); renderAccount(); renderBell();
renderInbox(); renderProjects(); renderHead(); renderBar(); renderUniverses(); renderEmpty(); renderTimeline(); renderWorkflows(); renderDetail(); renderPeople(); publishFocus();
if (tour.on && (!LayoutHost.current() || LayoutHost.current().id !== 'universe')) stopTour(false);
const n = waitOn().length + agentWaits().length + (Team.on() ? unreadNotifs().length : 0); // lo que te espera y lo no leído
document.title = n ? '(' + n + ') Tasky' : 'Tasky';
$('#agents').hidden = typeof Agents === 'undefined';
if (typeof Agents !== 'undefined') Agents.render();
});
}
function bindKeys() {
addEventListener('keydown', (e) => { if (e.key === 'Escape' && panel) { e.preventDefault(); closePanel(); } });
}
function start() {
bindErrors();
Team.prepare(); // en modo Team: el Store guarda en el servidor (en Personal no hace nada)
Store.load();
renderTools();
if (!Team.on()) storageNotice();
bindDrop(); bindPaste(); bindKeys();
LayoutHost.registerNative('now', 'Now', renderNow);
LayoutHost.registerNative('list', 'List', renderList);
LayoutHost.mount($('#stage'), selectedForLayout, activeFilter, pastState, () => (multiUni() ? currentUniverse() : null));
bindSearchKeys(); bindTourKeys();
Store.subscribe((st, opts) => { if (Team.on() && !(opts && (opts.local || opts.remote))) Team.sync(); });
Store.subscribe((st, opts) => { if (Team.on() && opts && opts.remote) Team.refreshPanel(); });
Store.subscribe(render);
Store.subscribe(() => recordLive());
Actions.onLog(offerUndo);
setInterval(renderInbox, 60000); // "waiting 5 min" sigue al día
render();
if (Team.on()) {
let onlineBefore = null;
Team.onPresence = () => {
// Los agentes de otra persona se ven solo mientras ella tiene Tasky abierto: al irse, "See you later".
const now = new Set(Team.people().map((x) => x.userId));
if (onlineBefore) for (const a of Store.get().agents) if (a.owner && a.owner !== Team.myId() && !a.away && onlineBefore.has(a.owner) && !now.has(a.owner)) brief(Help.TEXT.agents.seeYouTeam(a.name, Team.nameOf(a.owner)), 'bye');
const changed = !onlineBefore || [...now].some((x) => !onlineBefore.has(x)) || [...onlineBefore].some((x) => !now.has(x));
onlineBefore = now;
if (changed) render(); else LayoutHost.push();
renderPeople(); if (following) followNow();
};
Team.onEnter = () => { shipFirstTime(() => { if (memberSteps(trackSeen)) return; if (!Store.get().settings.helpSeen) { Help.open(); Actions.setHelpSeen(); } trackSeen(); }); setTimeout(() => { trashReminder(); storageReminder(); }, 1200); };
bindFollowStop();
Team.start();
}
else { if (!Store.get().settings.helpSeen) { Help.open(); Actions.setHelpSeen(); } trackSeen(); setTimeout(trashReminder, 1200); }
if (typeof Bridge !== 'undefined') Bridge.start();
watchVersion();
}
return { start, render, notify, importTexts, importFiles, pickFile, select, peek, taskClicked, openPanel, closePanel, focusables, el, btn, svg, ico,
exportAll, openTemplate, openBackup, viewHandoff, openArchive, openTrash, trashReminder, openProject, replaceHandoff, addLayout, uploadLayouts, removeLayout, downloadExampleLayout,
openLive, recordLive, live: () => live.slice(), agentWaits, openReport, renderMarkdown,
closeAgentToast, download, downloadBlob, copyText, copied, fileName, checkVersion, showUpdate, diagnostic, reportProblem, noteError, askAgentTask, brief, goTo, ago, openHistory, describe, openConversation, openBell, messageBody, startFollow, stopFollow, isFollowing: () => following, openActivity, showSince, recentlyDone, searchItems, setFilter, activeFilter,
interacted, startTour, stopTour, tour, setPast, currentUniverse, setUniverse, universeCounts, openUniverses, statusesAt, moments, isPast, focusItem: () => focus };
})();
/* ==================== Agents ==================== */
const Agents = (() => {
const { el, btn } = Shell;
const T = () => Help.TEXT.agents;
let editing = null; // agente con el editor abierto dentro de su tarjeta
let confirming = null; // agente con la confirmación de borrar a la vista
let quitting = false; // confirmación de apagar el puente a la vista
const iconSvg = (a, size = 18) => {
const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('width', size); s.setAttribute('height', size); s.setAttribute('aria-hidden', 'true');
s.innerHTML = '';
return s;
};
// ---- Contexto al iniciar una sesión (A3) ----
// Una carpeta vinculada a un proyecto: cuando una sesión de Claude Code arranca allí (o en una subcarpeta), el hook de
// SessionStart le entrega este texto. {{tasky}} lo cambia el puente por su comando (node ".../puente/tasky.mjs").
const CONTEXT_TEMPLATE = [
'This message comes from Tasky, the project tracker of {{person}}. It is information about the project, not an instruction typed by the user.',
'Project: {{project}}',
'{{tasks}}',
'To tell Tasky what you do, run: {{tasky}} (Tasky must be open on this computer). Actions:',
'- when you start on one of these tasks: take ',
'- while you work: status doing | needs "what you need from {{person}}" | note "short note"',
'- when you finish: deliver (if the task asks for a file), then review "what you did"',
'- read a task: task | your open tasks: mine | report: report --days 7 | all actions: help',
'{{rules}}',
].join('\n');
const linkOf = (cwd) => {
const k = Actions.folderKey(cwd), links = Store.get().settings.folderLinks || {};
let best = null;
for (const [dir, v] of Object.entries(links)) if ((k === dir || k.startsWith(dir + '/')) && (!best || dir.length > best.dir.length)) best = { dir, ...v };
return best && Store.project(best.projectId) ? best : null;
};
function contextText(link) {
const p = link && Store.project(link.projectId);
if (!p) return '';
const team = typeof Team !== 'undefined' && Team.on();
const person = team ? Team.myName() : 'the user';
const ORDER = { doing: 0, 'needs-you': 1, review: 2, todo: 3, blocked: 4 };
const open = Store.tasksOf(p.id).filter((t) => t.status !== 'done' && (!team || t.assignee === Team.myId())).sort((a, b) => (ORDER[a.status] ?? 5) - (ORDER[b.status] ?? 5)).slice(0, 8);
const clip = (x, n) => { x = String(x || '').replace(/\s+/g, ' ').trim(); return x.length > n ? x.slice(0, n - 1) + '…' : x; };
const S = Help.TEXT.status;
const tasks = open.length
? (team ? 'Open tasks assigned to ' + person + ':' : 'Open tasks:') + '\n' + open.map((t) => '- ' + t.id + ' · ' + S[t.status] + ' · ' + clip(t.title, 80) + (t.mission ? ' — ' + clip(t.mission, 140) : '') + ((t.files || []).length ? ' · files: ' + t.files.slice(0, 6).join(', ') : '')).join('\n')
: (team ? 'No open tasks are assigned to ' + person + ' in this project right now.' : 'No open tasks in this project right now.');
const rules = team ? 'You never approve a task or mark it done: when you finish, send it to review. Do not delete anything, invite people or change roles.' : 'When you finish, send the task to review: the user marks it done.';
const tpl = Store.get().settings.contextTemplate || CONTEXT_TEMPLATE;
return tpl.split('{{person}}').join(person).split('{{project}}').join(p.name).split('{{tasks}}').join(tasks).split('{{rules}}').join(rules).trim().slice(0, 3000);
}
// Lo que la app le da al puente: el texto de cada carpeta vinculada con el contexto encendido.
function contexts() {
return Object.values(Store.get().settings.folderLinks || {}).filter((l) => l.context !== false && Store.project(l.projectId)).map((l) => ({ cwd: l.cwd, text: contextText(l) }));
}
// ---- La línea viva (A2): "Working · editing extract.py · 12 min", "Waiting for you · needs permission"… ----
const mins = (ms) => Math.max(1, Math.round(ms / 60000)) + ' min';
function liveLine(a) {
const L = Help.TEXT.live;
const act = a && a.activity;
const mine = Actions.ownAgent(a);
const who = mine ? L.you : (typeof Team !== 'undefined' && Team.on() ? Team.nameOf(a.owner) || L.owner : L.owner);
if (!act) return { state: a && a.status === 'working' ? 'working' : 'paused', text: a && a.status === 'working' ? L.working : L.paused };
const state = act.state || (act.waiting ? 'waiting' : act.workingSince ? 'working' : 'paused');
if (state === 'waiting' || act.waiting) return { state: 'waiting', text: L.waitingFor(who) + (act.waiting === 'permission' ? ' · ' + L.permission : act.waiting === 'answer' ? ' · ' + L.question : '') };
if (state === 'paused') return { state: 'paused', text: L.paused };
const l = act.line || {}, dur = act.workingSince ? ' · ' + mins(Date.now() - act.workingSince) : '';
const helpers = act.subagents ? ' · ' + L.helpers(act.subagents) : '';
if (l.kind === 'command') return { state, text: L.command + dur + helpers };
if (l.kind === 'read' && l.count > 1) return { state, text: L.reading(l.count) + dur + helpers };
const what = L.what(l.kind, l.file);
return { state, text: L.working + (what ? ' · ' + what : '') + dur + helpers };
}
const working = (a) => {
const p = a.taskId && Store.project(a.projectId);
const t = p && Store.task(p.id, a.taskId);
return t ? { p, t } : null;
};
// Botón de estado: Working (con ruedita), Waiting u Offline. Un clic lo cambia en los agentes manuales.
function statusButton(a) {
const label = Help.TEXT.agentStatus[a.status] || a.status;
const b = btn('', () => Actions.cycleAgentStatus(a.id), { className: 'aswitch ' + a.status, ariaLabel: a.name + ' is ' + label + (a.source === 'bridge' ? '' : '. Click to change'), data: { key: 'agent-sw|' + a.id, status: a.status } });
b.append(el('i', { className: 'aspin', ariaHidden: 'true' }), el('span', { textContent: label }));
if (a.source === 'bridge') { b.disabled = true; b.title = T().bridgeControls; }
else b.title = T().manualControls;
if (!Actions.ownAgent(a)) { b.disabled = true; b.title = T().theirs(Team.nameOf(a.owner)); }
return b;
}
// Editor dentro de la tarjeta: nombre, icono, color y borrar con confirmación.
function editor(a) {
const box = el('div', { className: 'aedit' });
const name = el('input', { type: 'text', className: 'ainput', value: a.name, ariaLabel: 'Agent name', autocomplete: 'off', data: { key: 'a-name|' + a.id } });
name.addEventListener('input', () => { if (name.value.trim()) Actions.updateAgent(a.id, { name: name.value }); });
name.addEventListener('keydown', (e) => { if (e.key === 'Enter') { editing = null; Shell.render(); } });
const icons = el('div', { className: 'apick', role: 'group', ariaLabel: 'Icon' });
for (const ic of Actions.AGENT_ICONS) {
const b = btn('', () => Actions.updateAgent(a.id, { icon: ic }), { className: 'aopt', ariaLabel: 'Icon ' + ic, data: { key: 'ai|' + a.id + '|' + ic } });
b.append(iconSvg({ icon: ic, color: a.color }));
b.setAttribute('aria-pressed', String(a.icon === ic));
icons.append(b);
}
const colors = el('div', { className: 'apick', role: 'group', ariaLabel: 'Color' });
for (const c of Actions.AGENT_COLORS) {
const b = btn('', () => Actions.updateAgent(a.id, { color: c }), { className: 'aopt swatch', ariaLabel: 'Color ' + c, data: { key: 'ac|' + a.id + '|' + c } });
b.style.setProperty('--c', c);
b.setAttribute('aria-pressed', String(a.color === c));
colors.append(b);
}
box.append(name, icons, colors);
if (confirming === a.id) box.append(deleteConfirm(a));
else box.append(el('div', { className: 'arow' },
btn('Done', () => { editing = null; Shell.render(); }, { className: 'abtn', data: { key: 'a-done|' + a.id } }),
btn('Delete agent', () => { confirming = a.id; Shell.render(); }, { className: 'abtn danger', data: { key: 'a-del|' + a.id } })));
return box;
}
function deleteConfirm(a) {
const box = el('div', { className: 'aconfirm', role: 'group', ariaLabel: 'Confirm delete' });
const done = () => { confirming = null; editing = null; };
if (a.source === 'bridge' && a.session) {
box.append(el('p', { textContent: T().deleteBridge(a.name) }),
el('div', { className: 'acol' },
btn('Delete and end its Claude Code session', async () => {
const n = a.name, session = a.session;
done(); Actions.deleteAgent(a.id);
Shell.notify(T().ending(n));
const r = await Bridge.end(session);
Shell.notify(r.ended ? (r.terminal === 'closed' ? T().endedClosed(n) : r.terminal === 'left-open' ? T().endedLeftOpen(n) : T().ended(n)) : T().endFailed(n, r.reason));
}, { className: 'abtn danger', data: { key: 'a-end|' + a.id } }),
btn('Delete only', () => { const n = a.name; done(); Actions.deleteAgent(a.id); Shell.notify(T().deletedOnly(n)); }, { className: 'abtn danger', data: { key: 'a-yes|' + a.id } }),
btn('No', () => { confirming = null; Shell.render(); }, { className: 'abtn', data: { key: 'a-no|' + a.id } })));
return box;
}
box.append(el('p', { textContent: 'Delete “' + a.name + '”?' }),
el('div', { className: 'arow' },
btn('Yes, delete', () => { const n = a.name; done(); Actions.deleteAgent(a.id); Shell.notify(n + ' was deleted.'); }, { className: 'abtn danger', data: { key: 'a-yes|' + a.id } }),
btn('No', () => { confirming = null; Shell.render(); }, { className: 'abtn', data: { key: 'a-no|' + a.id } })));
return box;
}
// Panel Agents de la columna derecha. Cada agente se edita dentro de su propia tarjeta.
function render() {
const box = document.getElementById('agents');
box.textContent = '';
const head = el('div', { className: 'ahead' }, el('h2', { className: 'label', textContent: 'Agents' }));
if (typeof Bridge !== 'undefined') {
const right = el('div', { className: 'arow' }, btn('Live', () => Shell.openLive(), { className: 'abtn small', title: Help.TEXT.live.title, ariaLabel: 'Live: what is happening now', data: { key: 'live' } }), Bridge.badge());
if (Bridge.isOn()) right.append(btn('Turn off', () => { quitting = true; Shell.render(); const n = document.querySelector('[data-key="bridge-quit-yes"]'); if (n) n.focus(); },
{ className: 'abtn small', title: T().quitTitle, ariaLabel: 'Turn off the bridge', data: { key: 'bridge-quit' } }));
head.append(right);
}
box.append(head);
if (quitting && typeof Bridge !== 'undefined' && Bridge.isOn()) {
box.append(el('div', { className: 'aconfirm', role: 'group', ariaLabel: 'Confirm turning off the bridge' },
el('p', { textContent: T().quitAsk + (Bridge.servedByBridge() ? ' ' + T().quitWindow : '') }),
el('div', { className: 'arow' },
btn('Yes, turn off', async () => { quitting = false; const ok = await Bridge.quit(); Shell.notify(ok ? T().quitDone : T().quitFailed); Shell.render(); }, { className: 'abtn danger', data: { key: 'bridge-quit-yes' } }),
btn('No', () => { quitting = false; Shell.render(); }, { className: 'abtn', data: { key: 'bridge-quit-no' } }))));
} else quitting = false;
const list = el('div', { className: 'alist' });
for (const a of Store.visibleAgents()) {
const w = working(a);
const card = el('div', { className: 'acard ' + a.status + (editing === a.id ? ' editing' : '') });
const name = btn('', () => { editing = editing === a.id ? null : a.id; confirming = null; Shell.render(); }, { className: 'aname', ariaLabel: (editing === a.id ? 'Close the editor of ' : 'Edit ') + a.name, ariaExpanded: String(editing === a.id), data: { key: 'agent|' + a.id } });
name.append(el('span', { className: 'aicon' }, iconSvg(a)), el('span', { textContent: a.name }));
const job = w
? btn('Working on: ' + w.p.name + ' · ' + w.t.id, () => jump(a), { className: 'ajob', ariaLabel: a.name + ' is working on ' + w.p.name + ', task ' + w.t.id + '. Go to the task', data: { key: 'agent-job|' + a.id } })
: el('span', { className: 'ajob idle', textContent: 'No task' });
const ll = liveLine(a);
card.append(el('div', { className: 'arow' }, name, statusButton(a)), job, el('small', { className: 'alive ' + ll.state, data: { key: 'alive|' + a.id }, textContent: ll.text }));
if (!Actions.ownAgent(a)) {
const who = Team.nameOf(a.owner) || 'Someone';
name.disabled = true; name.setAttribute('aria-label', a.name + ', agent of ' + who); name.title = T().theirs(who);
card.classList.add('theirs');
card.append(el('small', { className: 'aowner', textContent: T().ownerOf(who) }));
}
if (editing === a.id) card.append(editor(a));
list.append(card);
}
if (!Store.visibleAgents().length) list.append(el('p', { className: 'pempty', textContent: T().none }));
box.append(list, known(), folders(), btn('New agent', () => { editing = Actions.createAgent(); confirming = null; Shell.render(); const n = document.querySelector('[data-key="a-name|' + editing + '"]'); if (n) { n.focus(); n.select(); } },
{ className: 'tool', title: Help.TEXT.tools.newAgent.text, data: { key: 'agent-new' } }));
}
// "Known agents": los agentes del puente recordados que no están abiertos, plegado por defecto. Solo para
// renombrarlos u olvidarlos; no es una lista de Offline.
let knownOpen = false;
function known() {
const away = Store.get().agents.filter((a) => a.away && Actions.ownAgent(a));
const box = el('div', { className: 'known' });
if (!away.length) return box;
box.append(btn('Known agents (' + away.length + ') ' + (knownOpen ? '▾' : '▸'), () => { knownOpen = !knownOpen; Shell.render(); },
{ className: 'linkish', ariaExpanded: String(knownOpen), title: T().knownTitle, data: { key: 'known' } }));
if (!knownOpen) return box;
box.append(el('p', { className: 'knote', textContent: T().knownHint }));
for (const a of away) {
const name = el('input', { type: 'text', className: 'ainput', value: a.name, ariaLabel: 'Name of the known agent ' + a.name, autocomplete: 'off', data: { key: 'k-name|' + a.id } });
name.addEventListener('change', () => { if (name.value.trim()) Actions.updateAgent(a.id, { name: name.value }); });
box.append(el('div', { className: 'krow' }, el('span', { className: 'aicon' }, iconSvg(a)), name,
btn('Forget', () => { const n = a.name; Actions.forgetAgent(a.id); Shell.notify(T().forgot(n)); }, { className: 'abtn', ariaLabel: 'Forget ' + a.name, data: { key: 'k-forget|' + a.id } })));
}
return box;
}
// "Folders": carpetas vinculadas a un proyecto, con su interruptor de contexto y el texto exacto que recibe el agente.
let foldersOpen = false;
function folders() {
const links = Object.values(Store.get().settings.folderLinks || {});
const box = el('div', { className: 'known' });
if (!links.length) return box;
box.append(btn('Folders (' + links.length + ') ' + (foldersOpen ? '▾' : '▸'), () => { foldersOpen = !foldersOpen; Shell.render(); }, { className: 'linkish', ariaExpanded: String(foldersOpen), title: T().foldersTitle, data: { key: 'folders' } }));
if (!foldersOpen) return box;
box.append(el('p', { className: 'knote', textContent: T().foldersHint }));
for (const l of links) {
const name = String(l.cwd).split(/[\\/]/).filter(Boolean).pop() || l.cwd;
const sel = el('select', { className: 'tproj fsel', ariaLabel: 'Project of the folder ' + name, data: { key: 'fl-proj|' + Actions.folderKey(l.cwd) } }, el('option', { value: '', textContent: 'Not linked' }), ...Store.get().projects.filter((p) => !p.archived).map((p) => el('option', { value: p.id, textContent: p.name })));
sel.value = Store.project(l.projectId) ? l.projectId : '';
sel.addEventListener('change', () => Actions.linkFolder(l.cwd, sel.value || null));
const on = l.context !== false;
const sw = btn(on ? 'Briefing: on' : 'Briefing: off', () => Actions.setFolderContext(l.cwd, !on), { className: 'abtn small' + (on ? ' on' : ''), title: T().briefingTitle, ariaPressed: String(on), data: { key: 'fl-ctx|' + Actions.folderKey(l.cwd) } });
box.append(el('div', { className: 'frow2' }, el('span', { className: 'fname2', title: l.cwd, textContent: name }), sel,
el('div', { className: 'arow' }, sw, btn('Show text', () => openBriefing(l.cwd), { className: 'abtn small', data: { key: 'fl-text|' + Actions.folderKey(l.cwd) } }))));
}
return box;
}
// El texto exacto que recibe el agente (si el puente está encendido, tal como lo entrega él) y su plantilla editable.
async function openBriefing(cwd) {
const link = linkOf(cwd);
const root = Shell.openPanel('briefing', 'Session briefing');
const pre = el('pre', { className: 'pn-text', tabIndex: 0, ariaLabel: 'Text the agent receives', data: { key: 'brief-text' } });
const tpl = el('textarea', { className: 'pn-input te-area brief-tpl', rows: 9, value: Store.get().settings.contextTemplate || CONTEXT_TEMPLATE, ariaLabel: 'Briefing template', data: { key: 'brief-tpl' } });
const fill = async () => {
let text = link ? contextText(link) : '';
if (typeof Bridge !== 'undefined' && Bridge.isOn()) { const exact = await Bridge.contextFor(cwd); if (exact != null) text = exact; }
pre.textContent = text || T().briefingNone;
};
root.append(el('p', { textContent: T().briefingHint(cwd, link ? Store.project(link.projectId).name : '') }), el('h3', { textContent: 'Exactly what the agent receives' }), pre,
el('h3', { textContent: 'Template (for every linked folder)' }), el('p', { className: 'fnote', textContent: T().briefingTemplate }), tpl,
el('div', { className: 'pn-actions' },
btn('Save template', async () => { Actions.setContextTemplate(tpl.value); await Bridge.pushContext(true); fill(); Shell.notify(T().briefingSaved); }, { className: 'primary', data: { key: 'brief-save' } }),
btn('Back to the default', async () => { Actions.setContextTemplate(''); tpl.value = CONTEXT_TEMPLATE; await Bridge.pushContext(true); fill(); }, { data: { key: 'brief-reset' } })));
await fill();
}
function jump(a) {
const w = working(a);
if (!w) return;
if (!Store.current() || Store.current().id !== w.p.id) Actions.selectProject(w.p.id);
Shell.select({ kind: 'task', id: w.t.id });
LayoutHost.focus({ task: w.t.id });
}
// Fila de agentes en la tarjeta de una tarea: un clic asigna o quita.
function assignRow(box, p, t) {
box.append(el('p', { className: 'k', textContent: 'Agents' }));
const agents = Store.visibleAgents().filter(Actions.ownAgent);
if (!agents.length) { box.append(el('p', { textContent: T().noneToAssign })); return; }
const row = el('div', { className: 'dbtns', role: 'group', ariaLabel: 'Assign an agent' });
for (const a of agents) {
const on = a.projectId === p.id && a.taskId === t.id;
const b = btn('', () => Actions.assignAgent(a.id, p.id, t.id), { className: 'dbtn', ariaLabel: (on ? 'Remove ' : 'Assign ') + a.name + (on ? ' from ' : ' to ') + t.id, data: { key: 'as|' + a.id } });
b.append(iconSvg(a), a.name);
b.setAttribute('aria-pressed', String(on));
row.append(b);
}
box.append(row);
}
return { render, assignRow, jump, iconSvg, liveLine, linkOf, contextText, contexts, openBriefing, CONTEXT_TEMPLATE, edit: (id) => { editing = id; Shell.render(); } };
})();
/* ==================== Reports ==================== */
// Reportes (prompt 011, A6): solo hechos tomados de la historia, los entregables y la actividad de los agentes, para un
// periodo (hoy, últimos 7 días o entre dos fechas) y con filtros (universo, proyecto, workflow, persona, agente). Nada
// de texto inventado ni valoraciones: si un dato no existe, se omite. Se ve en pantalla y se descarga como .md; un agente
// lo pide con "node puente/tasky.mjs report". En Team se arma con lo que esa persona ve en la app.
const Reports = (() => {
const team = () => typeof Team !== 'undefined' && Team.on();
const ST = () => Help.TEXT.status;
const pad = (n) => String(n).padStart(2, '0');
const dayOf = (ms) => { const d = new Date(ms); return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); };
const fmtDay = (ms) => new Date(ms).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
const fmtWhen = (ms) => new Date(ms).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ', ' + pad(new Date(ms).getHours()) + ':' + pad(new Date(ms).getMinutes());
const universeOf = (pid, wid) => ((Store.workflow(pid, wid) || {}).universe) || 'Main';
function range({ period = 'days', days = 7, from, to } = {}) {
const now = Date.now();
if (period === 'today') { const d = new Date(now); d.setHours(0, 0, 0, 0); return { start: d.getTime(), end: now, label: 'Today, ' + fmtDay(now) }; }
if (period === 'range' && from) {
const a = new Date(from + 'T00:00:00'), b = new Date((to || from) + 'T23:59:59.999');
if (!isNaN(a) && !isNaN(b)) return { start: a.getTime(), end: Math.min(b.getTime(), now), label: fmtDay(a.getTime()) + ' – ' + fmtDay(b.getTime()) };
}
const n = Math.max(1, Math.min(366, Number(days) || 7));
return { start: now - n * 864e5, end: now, label: 'Last ' + n + (n === 1 ? ' day' : ' days') + ' (' + fmtDay(now - n * 864e5) + ' – ' + fmtDay(now) + ')' };
}
const personName = (id) => (id ? (team() ? Team.nameOf(id) || '' : '') : '');
// El último agente que trabajó en la tarea, según la historia (asignación o resumen de sesión).
function agentOf(pid, tid, until) {
const h = Store.get().history;
for (let i = h.length - 1; i >= 0; i--) { const e = h[i]; if (e.at > until || e.projectId !== pid || e.taskId !== tid) continue; if ((e.kind === 'assign' && !e.removed) || e.kind === 'agentSession') return e.agentName || ''; }
const a = Store.agentOn(pid, tid);
return a ? a.name : '';
}
function progressAt(pid, wid, ms) {
const day = dayOf(ms);
const list = Store.history(pid, wid).filter((e) => e.date <= day);
return list.length ? list[list.length - 1] : null;
}
function build(opts = {}) {
const r = range(opts);
const projects = Store.get().projects.filter((p) => !p.archived && (!opts.projectId || p.id === opts.projectId));
const wfOk = (pid, wid) => (!opts.workflow || String(wid).toLowerCase() === String(opts.workflow).toLowerCase() || (Store.workflow(pid, wid) || {}).name === opts.workflow) && (!opts.universe || universeOf(pid, wid).toLowerCase() === String(opts.universe).toLowerCase());
const personOk = (t) => !opts.person || (team() ? (t.assignee === opts.person || personName(t.assignee).toLowerCase() === String(opts.person).toLowerCase()) : true);
const agentOk = (pid, t, until) => !opts.agent || agentOf(pid, t.id, until).toLowerCase() === String(opts.agent).toLowerCase();
// Tareas terminadas en el periodo: la última vez que pasaron a Done (de la historia), si siguen Done.
const doneAt = new Map();
for (const e of Store.get().history) {
if (e.at < r.start || e.at > r.end) continue;
const hits = e.kind === 'status' && e.to === 'done' ? [{ taskId: e.taskId }] : e.kind === 'import' ? (e.changes || []).filter((c) => c.to === 'done' && c.from) : [];
for (const h of hits) doneAt.set(e.projectId + '|' + h.taskId, e);
}
const done = [], workflows = [], open = [];
for (const p of projects) {
for (const w of Store.workflowsOf(p.id).filter((x) => wfOk(p.id, x.id))) {
const tasks = Store.tasksOf(p.id).filter((t) => t.workflow === w.id);
const finished = [];
for (const t of tasks) {
const e = doneAt.get(p.id + '|' + t.id);
if (!e || t.status !== 'done' || !personOk(t) || !agentOk(p.id, t, e.at)) continue;
const files = Store.get().files.filter((x) => x.projectId === p.id && x.taskId === t.id && x.kind === 'delivery' && !x.deleted);
const finals = [...new Set(files.map((x) => x.name))].map((n) => ({ name: n, version: files.filter((x) => x.name === n).reduce((m, x) => Math.max(m, x.version), 0) }));
const sessions = Store.get().history.filter((x) => x.kind === 'agentSession' && x.projectId === p.id && x.taskId === t.id);
const touched = [...new Set([...(t.touched || []), ...sessions.flatMap((x) => x.files || [])])];
const person = team() ? personName(t.assignee) || (e.actorName && !e.agentAct ? e.actorName : '') : '';
const item = { project: p.name, projectId: p.id, workflow: w.name, universe: universeOf(p.id, w.id), id: t.id, title: t.title, mission: t.mission || '', at: e.at, person, agent: agentOf(p.id, t.id, e.at), touched, deliverables: finals, approvedBy: e.review === 'approved' ? (e.actorName || e.who || '') : '' };
finished.push(item); done.push(item);
}
const a = progressAt(p.id, w.id, r.start - 1), b = progressAt(p.id, w.id, r.end);
const touchedHere = finished.length || (a && b && (a.done !== b.done || a.total !== b.total));
if (opts.person || opts.agent ? finished.length : (touchedHere || tasks.some((t) => t.status !== 'done'))) workflows.push({ project: p.name, projectId: p.id, id: w.id, name: w.name, universe: universeOf(p.id, w.id), mission: w.mission || '', start: a ? { done: a.done, total: a.total } : null, end: b ? { done: b.done, total: b.total } : null, done: finished.slice().sort((x, y) => x.at - y.at).map((x) => x.id + ' ' + x.title) });
for (const t of tasks.filter((x) => x.status !== 'done' && personOk(x) && (!opts.agent || agentOf(p.id, x.id, r.end).toLowerCase() === String(opts.agent).toLowerCase()))) {
const ag = Store.agentOn(p.id, t.id);
const waits = t.status === 'needs-you' ? (team() ? 'the owner' : 'you') + (t.needs ? ': ' + t.needs : '')
: t.status === 'review' ? (team() ? 'the owner (review)' : 'you (review)')
: t.status === 'blocked' ? 'unblocking'
: t.status === 'doing' ? [personName(t.assignee), ag ? ag.name : ''].filter(Boolean).join(' · ')
: team() && t.assignee ? personName(t.assignee) : '';
open.push({ project: p.name, projectId: p.id, workflow: w.name, universe: universeOf(p.id, w.id), id: t.id, title: t.title, status: t.status, waits });
}
}
}
done.sort((x, y) => x.at - y.at);
const filters = [opts.universe ? 'Universe ' + opts.universe : '', opts.projectId && Store.project(opts.projectId) ? 'Project ' + Store.project(opts.projectId).name : '', opts.workflow ? 'Workflow ' + opts.workflow : '', opts.person ? 'Person ' + (personName(opts.person) || opts.person) : '', opts.agent ? 'Agent ' + opts.agent : ''].filter(Boolean);
return { label: r.label, start: r.start, end: r.end, filters, counts: { done: done.length, review: open.filter((x) => x.status === 'review').length, waiting: open.filter((x) => x.status === 'needs-you').length }, workflows, done, open, multiUniverse: new Set(Store.get().workflows.map((w) => w.universe || 'Main')).size > 1 };
}
// El reporte como Markdown (lo mismo que se ve en pantalla). Solo hechos; lo que no existe no se escribe.
function markdown(r) {
const L = [], u = (x) => (r.multiUniverse ? ' · ' + x.universe : '');
L.push('# Tasky report · ' + r.label, '');
if (r.filters.length) L.push('Filters: ' + r.filters.join(' · '), '');
L.push('Made by Tasky from the history, the deliverables and the agents\' activity. Only facts: what is not recorded is left out.', '');
L.push('## In numbers', '', '- Done in this period: ' + r.counts.done, '- In review now: ' + r.counts.review, '- Waiting on someone now: ' + r.counts.waiting, '');
if (r.workflows.length) {
L.push('## By workflow', '');
for (const w of r.workflows) {
L.push('### ' + w.project + ' · ' + w.name + u(w), '');
if (w.mission) L.push('- Mission: ' + w.mission);
if (w.start && w.end) L.push('- Progress: ' + w.start.done + '/' + w.start.total + ' at the start → ' + w.end.done + '/' + w.end.total + ' at the end');
else if (w.end) L.push('- Progress at the end: ' + w.end.done + '/' + w.end.total);
if (w.done.length) L.push('- Done in this period: ' + w.done.join(', '));
L.push('');
}
}
if (r.done.length) {
L.push('## Done tasks', '');
for (const d of r.done) {
L.push('### ' + d.id + ' ' + d.title + ' (' + d.project + ' · ' + d.workflow + u(d) + ')', '');
if (d.mission) L.push('- Mission: ' + d.mission);
const by = [d.person, d.agent ? 'agent ' + d.agent : ''].filter(Boolean).join(' · ');
if (by) L.push('- Done by: ' + by);
L.push('- When: ' + fmtWhen(d.at) + (d.approvedBy ? ' (approved by ' + d.approvedBy + ')' : ''));
if (d.touched.length) L.push('- Files touched: ' + d.touched.join(', '));
if (d.deliverables.length) L.push('- Deliverables: ' + d.deliverables.map((x) => x.name + ' v' + x.version).join(', '));
L.push('');
}
}
if (r.open.length) {
L.push('## Still open', '', '| Task | Project · workflow | Status | Waiting for |', '|---|---|---|---|');
const cell = (x) => String(x || '').replace(/\|/g, '\\|').replace(/\s+/g, ' ');
for (const o of r.open) L.push('| ' + cell(o.id + ' ' + o.title) + ' | ' + cell(o.project + ' · ' + o.workflow + u(o)) + ' | ' + ST()[o.status] + ' | ' + cell(o.waits) + ' |');
L.push('');
}
return L.join('\n');
}
return { range, build, markdown };
})();
/* ==================== AgentCmd ==================== */
// Acciones del agente hacia Tasky (prompt 011, A4). Llegan por el puente (puente/tasky.mjs → POST /action → GET /agents)
// y corren aquí, con la sesión de quien tiene Tasky abierto. Límites: solo sobre el proyecto vinculado a la carpeta del
// agente y con los permisos de su dueño; en Team un agente nunca aprueba ni marca Done (su "terminé" es enviar a
// revisión), no borra, no invita, no cambia roles. Todo queda en la historia a nombre del agente (y de su dueño).
const AgentCmd = (() => {
const team = () => typeof Team !== 'undefined' && Team.on();
const fail = (message) => ({ ok: false, message });
const done = (message, data) => ({ ok: true, message, ...(data !== undefined ? { data } : {}) });
const ST = () => Help.TEXT.status;
const clip = (x, n) => { x = String(x || '').replace(/\s+/g, ' ').trim(); return x.length > n ? x.slice(0, n - 1) + '…' : x; };
const who = (id) => (id ? (team() ? Team.nameOf(id) || 'someone' : '') : 'nobody');
const line = (t) => t.id + ' · ' + ST()[t.status] + ' · ' + clip(t.title, 80) + (t.mission ? ' — ' + clip(t.mission, 120) : '') + ((t.files || []).length ? ' · files: ' + t.files.slice(0, 6).join(', ') : '');
async function run(a) {
const agent = Store.get().agents.find((x) => x.source === 'bridge' && x.session === a.session && Actions.ownAgent(x));
if (!agent) return fail('Tasky does not know this agent yet: wait a few seconds after the session starts and try again. Nothing was changed.');
const link = Agents.linkOf(a.cwd || agent.cwd || '');
if (!link) return fail('This folder is not linked to a Tasky project, so this agent cannot change anything there. Ask the user to link it (the new agent notice, or Agents → Folders). Nothing was changed.');
const p = Store.project(link.projectId), by = agent.name, args = a.args || {};
const find = (id) => Store.tasksOf(p.id).find((x) => x.id.toLowerCase() === String(id || '').trim().toLowerCase()) || null;
const need = (id) => { const t = find(id); if (!t) throw new Error('There is no task "' + id + '" in ' + p.name + '. See your open tasks with: node tasky.mjs mine'); return t; };
const mine = (t) => !team() || t.assignee === Team.myId();
try {
switch (a.action) {
case 'mine': {
const open = Store.tasksOf(p.id).filter((t) => t.status !== 'done' && mine(t));
const free = team() ? Store.tasksOf(p.id).filter((t) => t.status === 'todo' && !t.assignee).slice(0, 5) : [];
return done('Project: ' + p.name + '\n' + (open.length ? (team() ? 'Your open tasks:' : 'Open tasks:') + '\n' + open.map((t) => '- ' + line(t)).join('\n') : (team() ? 'You have no open tasks in ' + p.name + '.' : 'No open tasks in ' + p.name + '.')) + (free.length ? '\nFree tasks you can take:\n' + free.map((t) => '- ' + line(t)).join('\n') : ''), { project: p.name, tasks: open.map((t) => ({ id: t.id, status: t.status, title: t.title })) });
}
case 'task': {
const t = need(args.id);
const files = Store.get().files.filter((x) => x.projectId === p.id && x.taskId === t.id && !x.deleted);
const latest = (kind, name) => files.filter((x) => x.kind === kind && x.name === name).reduce((m, x) => Math.max(m, x.version), 0);
const starting = [...new Set(files.filter((x) => x.kind === 'starting').map((x) => x.name))];
const ag = Store.agentOn(p.id, t.id);
return done([
t.id + ' ' + t.title + ' (' + ST()[t.status] + (team() ? ', person: ' + who(t.assignee) : '') + (ag ? ', agent: ' + ag.name : '') + ')',
'Project: ' + p.name + ' · workflow: ' + ((Store.workflow(p.id, t.workflow) || {}).name || t.workflow),
t.mission ? 'Mission: ' + t.mission : null,
(t.files || []).length ? 'Planned files (code this task touches): ' + t.files.join(', ') : null,
(t.deliverables || []).length ? 'Deliverables (upload to Tasky for review): ' + t.deliverables.map((n) => n + (latest('delivery', n) ? ' (delivered v' + latest('delivery', n) + ')' : ' (pending)')).join(', ') : null,
starting.length ? 'Starting files: ' + starting.map((n) => n + ' v' + latest('starting', n)).join(', ') : null,
t.status === 'needs-you' && t.needs ? 'Needs: ' + t.needs : null,
t.backComment ? 'Sent back: ' + t.backComment : null,
].filter(Boolean).join('\n'));
}
case 'take': {
const t = need(args.id);
if (t.status === 'done') return fail(t.id + ' is already done.');
if (team()) {
if (!Team.can('take')) return fail('Your role cannot take tasks.');
if (t.assignee && t.assignee !== Team.myId()) return fail(t.id + ' is already taken by ' + who(t.assignee) + '. Nothing was changed.');
if (!t.assignee) Actions.setAssignee(p.id, t.id, Team.myId(), { by });
}
if (!(agent.projectId === p.id && agent.taskId === t.id)) Actions.assignAgent(agent.id, p.id, t.id, { by });
Shell.closeAgentToast(agent.id);
const now = Store.task(p.id, t.id);
return done('You took ' + now.id + ' ' + now.title + '. It is ' + ST()[now.status] + '.' + (now.mission ? '\nMission: ' + now.mission : '') + ((now.files || []).length ? '\nPlanned files: ' + now.files.join(', ') : ''));
}
case 'status': {
const t = need(args.id), st = String(args.value || '').trim().toLowerCase().replace(/\s+/g, '-');
const map = { todo: 'todo', 'to-do': 'todo', doing: 'doing', 'in-progress': 'doing', blocked: 'blocked', done: 'done', review: 'review', 'needs-you': 'needs-you', needs: 'needs-you' }[st];
if (!map) return fail('Unknown status "' + args.value + '". Use todo, doing or blocked.');
if (map === 'review') return fail('To send it to review, use: node tasky.mjs review ' + t.id + ' "what you did"');
if (map === 'needs-you') return fail('To ask the person for something, use: node tasky.mjs needs ' + t.id + ' "what you need"');
if (map === 'done' && team()) return fail('In Team mode an agent never marks a task done: send it to review with node tasky.mjs review ' + t.id + ' "what you did". Nothing was changed.');
if (team() && !Team.can('status')) return fail('Your role cannot change tasks.');
if (team() && t.status === 'review') return fail(t.id + ' is in review: the owner approves it or sends it back. Nothing was changed.');
Actions.setTaskStatus(p.id, t.id, map, { by });
return done(t.id + ' is now ' + ST()[Store.task(p.id, t.id).status] + '.');
}
case 'needs': {
const t = need(args.id), reason = String(args.value || '').trim();
if (!reason) return fail('Write what you need, in quotes.');
if (team() && !Team.can('status')) return fail('Your role cannot change tasks.');
Actions.setTaskStatus(p.id, t.id, 'needs-you', { by });
Actions.setTaskNeeds(p.id, t.id, reason);
return done(t.id + ' is now Needs you: "' + clip(reason, 200) + '". The person sees it in Waiting on you.');
}
case 'note': {
const t = need(args.id), text = String(args.value || '').trim();
if (!text) return fail('Write the note in quotes.');
if (team()) {
if (!Team.can('comment')) return fail('Your role cannot write in threads.');
Actions.postMessage(p.id, t.id, '[' + by + '] ' + text);
}
Actions.agentNote(p.id, t.id, by, text);
return done('Note added to ' + t.id + (team() ? ' (in its thread).' : ' (in the history).'));
}
case 'deliver': {
const t = need(args.id);
if (!team()) return fail('Files are delivered to Tasky in Team mode. In Personal mode, leave the file in the project and mention it with node tasky.mjs note ' + t.id + ' "…".');
if (!Team.can('upload')) return fail('Your role cannot upload files.');
const f = a.file;
if (!f || !f.data) return fail('No file came with the command.');
const bin = Uint8Array.from(atob(f.data), (c) => c.charCodeAt(0));
const res = await Team.uploadFiles(p.id, t.id, [new File([bin], String(f.name || 'file').slice(0, 200))], { kind: 'delivery', note: 'Delivered by ' + by, by });
const r = res[0] || {};
if (r.error) return fail('Not uploaded: ' + r.error);
return done('Delivered ' + r.name + ' as version ' + r.version + ' of ' + t.id + '.' + ((t.deliverables || []).length && !t.deliverables.includes(r.name) ? ' (The task asks for: ' + t.deliverables.join(', ') + '.)' : ''));
}
case 'review': {
const t = need(args.id), note = String(args.value || '').trim();
if (team()) {
if (!Team.can('submit')) return fail('Your role cannot send tasks to review.');
if (t.status === 'review') return fail(t.id + ' is already in review.');
Actions.submitForReview(p.id, t.id, note, { by });
return done(t.id + ' was sent to review. The owner approves it or sends it back.');
}
Actions.setTaskStatus(p.id, t.id, 'needs-you', { by });
Actions.setTaskNeeds(p.id, t.id, 'Review: ' + (note || 'the agent finished it'));
return done(t.id + ' waits for the user to review it (Needs you): "Review: ' + clip(note || 'the agent finished it', 200) + '".');
}
case 'report': {
if (typeof Reports === 'undefined') return fail('Reports are not available in this Tasky.');
const r = Reports.build({ period: args.today ? 'today' : args.from ? 'range' : 'days', days: Number(args.days) || 7, from: args.from, to: args.to, projectId: p.id, workflow: args.workflow, person: args.person, agent: args.agent, universe: args.universe });
return done(Reports.markdown(r));
}
default: return fail('Unknown action.');
}
} catch (e) { return fail(String((e && e.message) || e)); }
}
return { run };
})();
/* ==================== Bridge ==================== */
// Puente con Claude Code: si puente/tasky-bridge.mjs responde, cada 3 s trae las sesiones y las pasa por Actions.reportAgent.
// Si no responde, la app sigue en modo manual sin mostrar errores y vuelve a probar cada 15 s.
const Bridge = (() => {
const POLL_MS = 3000, RETRY_MS = 15000;
let on = false, timer = null;
// En modo Team la app viene de un hosting (o de otro puerto local): nunca la sirve el puente.
const servedByBridge = () => !(typeof Team !== 'undefined' && Team.on()) && typeof location !== 'undefined' && /^https?:$/.test(location.protocol) && /^(127\.0\.0\.1|localhost)$/.test(location.hostname);
const port = () => Number(Store.get().settings.bridgePort || (typeof Team !== 'undefined' && Team.on() && Team.config().bridgePort)) || 4317;
const url = () => servedByBridge() ? location.origin + '/agents' : 'http://127.0.0.1:' + port() + '/agents';
// Desde una página https, Chrome pide permiso de "red local" para hablar con 127.0.0.1 y lo exime de contenido
// mixto si el pedido lo anuncia (targetAddressSpace: 'local'). Ver docs/spec/agentes.md.
const localInit = (init = {}) => (typeof location !== 'undefined' && location.protocol === 'https:' ? { ...init, targetAddressSpace: 'local' } : init);
let pollSeq = 0;
async function poll() {
clearTimeout(timer);
// Si alguien pide un sondeo mientras otro está en camino, solo vale la respuesta del último: una respuesta vieja
// que llega tarde no pisa a una nueva (y queda un solo ciclo de sondeo).
const seq = ++pollSeq;
let data = null;
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 1500);
const r = await fetch(url(), localInit({ cache: 'no-store', signal: ctrl.signal }));
clearTimeout(t);
if (r.ok) data = await r.json();
} catch {}
if (seq !== pollSeq) return;
const was = on;
const ok = !!(data && Array.isArray(data.agents));
// Un sondeo fallido justo después de estar encendido se reintenta enseguida antes de darlo por apagado.
if (!ok && was && !retrying) { retrying = true; timer = setTimeout(poll, 1500); return; }
retrying = false;
on = ok;
if (on) { if (!was) quiet = true; apply(data.agents); pushContext(); if (Array.isArray(data.actions)) for (const a of data.actions) runAction(a); }
else allGone(); // sin puente no se sabe qué sesiones siguen abiertas: los agentes del puente dejan de verse
if (was !== on) Agents.render();
timer = setTimeout(poll, on ? POLL_MS : RETRY_MS);
}
// working → Working; waiting → Waiting; ended, o ya no está en la lista → se va ("See you later").
// Una conversación nunca vista pregunta nombre y tarea; una ya nombrada que se retomó vuelve sola ("… is back").
// El primer sondeo (al abrir Tasky o al volver a encontrar el puente) aplica todo sin avisos breves.
const STATE = { working: 'working', waiting: 'waiting', idle: 'waiting' };
const ASK_DELAY_MS = () => Number(Store.get().settings.askDelayMs) || 60000;
let quiet = true, retrying = false;
const T = () => Help.TEXT.agents;
function apply(list) {
const silent = quiet;
quiet = false;
const seen = new Set();
const ignored = new Set(Store.get().settings.ignoredSessions || []);
const bySession = (id) => Store.get().agents.find((x) => x.source === 'bridge' && x.session === id && Actions.ownAgent(x));
for (const s of list) {
if (!s || typeof s.session !== 'string' || !s.session) continue;
seen.add(s.session);
if (ignored.has(s.session)) continue; // un agente borrado no reaparece aunque su sesión siga viva
if (s.state === 'ended') {
const a = bySession(s.session);
if (a && !a.away) {
if (s.activity) Actions.sessionSummary(a.id, s.activity, typeof Team !== 'undefined' && Team.on() ? Team.sharing() : 'files');
Actions.sessionGone(a.id); if (!silent) Shell.brief(T().seeYou(a.name), 'bye');
}
continue;
}
const r = Actions.sessionOpen({ session: s.session, transcript: s.transcript || null, cwd: s.cwd || null, status: STATE[s.state] || 'waiting' });
if (!r) continue;
if (s.activity) Actions.agentActivity(r.id, s.activity);
// A5: lo que el agente edita o escribe mientras trabaja en una tarea queda como "tocado" en esa tarea. En Team, solo
// si su dueño comparte nombres de archivo (el servidor lo vuelve a revisar).
const ag = Store.agent(r.id);
if (ag && ag.taskId && s.activity && Array.isArray(s.activity.touched) && s.activity.touched.length && (!(typeof Team !== 'undefined' && Team.on()) || Team.sharing() === 'files')) Actions.recordTouched(ag.projectId, ag.taskId, s.activity.touched);
if (r.created) {
// En una carpeta vinculada el agente recibe el contexto y puede declarar su tarea él solo (A4): el aviso manual
// espera un minuto y solo aparece si para entonces no declaró nada.
if (s.cwd && typeof Agents !== 'undefined' && Agents.linkOf(s.cwd)) setTimeout(() => { const a = Store.agent(r.id); if (a && !a.away && !a.taskId) Shell.askAgentTask(r.id); }, ASK_DELAY_MS());
else Shell.askAgentTask(r.id);
}
else if (r.back && !silent) Shell.brief(T().back(Store.agent(r.id).name), 'back');
}
for (const a of Store.get().agents) {
if (a.source !== 'bridge' || a.away || seen.has(a.session) || !Actions.ownAgent(a)) continue;
Actions.sessionGone(a.id);
if (!silent) Shell.brief(T().seeYou(a.name), 'bye');
}
Actions.forgetIgnored(seen);
}
function allGone() { for (const a of Store.get().agents) if (a.source === 'bridge' && !a.away && Actions.ownAgent(a)) Actions.sessionGone(a.id); }
function badge() {
const b = Shell.el('span', { className: 'bridge' + (on ? ' on' : ''), textContent: 'Bridge: ' + (on ? 'on' : 'off'), title: Help.TEXT.tools.bridge.text });
return b;
}
// Pide al puente que termine una sesión de Claude Code. Devuelve { ok, ended, terminal, reason }.
async function end(session) {
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 15000);
const r = await fetch(url().replace(/\/agents$/, '/end'), localInit({ method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Tasky': '1' }, body: JSON.stringify({ session }), signal: ctrl.signal }));
clearTimeout(t);
const data = await r.json().catch(() => ({}));
return { ok: !!data.ok, ended: !!data.ended, terminal: data.terminal || 'none', reason: data.reason || (r.ok ? '' : 'error ' + r.status) };
} catch { return { ok: false, ended: false, terminal: 'none', reason: 'the bridge is not running' }; }
}
// Apaga el puente (botón "Turn off" del panel Agents). Devuelve true si el puente lo aceptó.
async function quit() {
let ok = false;
try {
const r = await fetch(url().replace(/\/agents$/, '/quit'), localInit({ method: 'POST', headers: { 'X-Tasky': '1' } }));
ok = r.ok;
} catch {}
if (ok) { on = false; clearTimeout(timer); timer = setTimeout(poll, RETRY_MS); Agents.render(); }
return ok;
}
// Contexto al iniciar sesión (A3): la app le da al puente el texto de cada carpeta vinculada; si cambia, enseguida, y
// si no, cada 30 s (el puente lo da por vencido a los 2 minutos: si la app se cierra, deja de entregarse).
let pushedText = '', pushedAt = 0;
async function pushContext(force = false) {
if (!on) return false;
const folders = typeof Agents !== 'undefined' ? Agents.contexts() : [];
const text = JSON.stringify(folders);
if (!force && text === pushedText && Date.now() - pushedAt < 30000) return true;
try {
const r = await fetch(url().replace(/\/agents$/, '/context'), localInit({ method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Tasky': '1' }, body: JSON.stringify({ folders }) }));
if (r.ok) { pushedText = text; pushedAt = Date.now(); }
return r.ok;
} catch { return false; }
}
// El texto exacto que el puente entregaría en esa carpeta (con su comando ya puesto); null si no responde.
async function contextFor(cwd) {
try { const r = await fetch(url().replace(/\/agents$/, '/context') + '?cwd=' + encodeURIComponent(cwd), localInit({ cache: 'no-store' })); if (!r.ok) return null; const d = await r.json(); return d.text || ''; } catch { return null; }
}
// A4: una acción de un agente; la respuesta vuelve al puente, que se la da al comando que la pidió.
async function runAction(a) {
let r;
try { r = await AgentCmd.run(a); } catch (e) { r = { ok: false, message: String((e && e.message) || e) }; }
try { await fetch(url().replace(/\/agents$/, '/result'), localInit({ method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Tasky': '1' }, body: JSON.stringify({ id: a.id, ...r }) })); } catch {}
return r;
}
// Cualquier cambio (un vínculo, el interruptor, la plantilla, una tarea) llega al puente enseguida si cambia el texto.
let ctxTimer = null;
function start() { poll(); Store.subscribe(() => { clearTimeout(ctxTimer); ctxTimer = setTimeout(() => pushContext(), 300); }); }
return { start, poll, end, quit, badge, servedByBridge, pushContext, contextFor, runAction, isOn: () => on };
})();
/* ==================== Zip ==================== */
// .zip sin compresión (método "store"): el respaldo del equipo guarda sus datos y cada versión de cada archivo, y se abre
// con el Explorador de Windows. Lee lo que escribe (y cualquier zip sin compresión); revisa el CRC de cada archivo.
const Zip = (() => {
const TABLE = (() => { const t = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; } return t; })();
function crc32(u8) { let c = 0xFFFFFFFF; for (let i = 0; i < u8.length; i++) c = TABLE[(c ^ u8[i]) & 0xFF] ^ (c >>> 8); return (c ^ 0xFFFFFFFF) >>> 0; }
function make(entries) {
const parts = [], central = [];
let offset = 0;
const now = new Date();
const time = (now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1), date = ((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate();
for (const e of entries) {
const name = new TextEncoder().encode(e.name), data = e.data, crc = crc32(data);
const h = new DataView(new ArrayBuffer(30));
h.setUint32(0, 0x04034b50, true); h.setUint16(4, 20, true); h.setUint16(6, 0x0800, true); h.setUint16(10, time, true); h.setUint16(12, date, true);
h.setUint32(14, crc, true); h.setUint32(18, data.length, true); h.setUint32(22, data.length, true); h.setUint16(26, name.length, true);
parts.push(new Uint8Array(h.buffer), name, data);
const c = new DataView(new ArrayBuffer(46));
c.setUint32(0, 0x02014b50, true); c.setUint16(4, 20, true); c.setUint16(6, 20, true); c.setUint16(8, 0x0800, true); c.setUint16(12, time, true); c.setUint16(14, date, true);
c.setUint32(16, crc, true); c.setUint32(20, data.length, true); c.setUint32(24, data.length, true); c.setUint16(28, name.length, true); c.setUint32(42, offset, true);
central.push(new Uint8Array(c.buffer), name);
offset += 30 + name.length + data.length;
}
const size = central.reduce((a, x) => a + x.length, 0);
const end = new DataView(new ArrayBuffer(22));
end.setUint32(0, 0x06054b50, true); end.setUint16(8, entries.length, true); end.setUint16(10, entries.length, true); end.setUint32(12, size, true); end.setUint32(16, offset, true);
return new Blob([...parts, ...central, new Uint8Array(end.buffer)], { type: 'application/zip' });
}
function read(buffer) {
const u8 = new Uint8Array(buffer), dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength), out = new Map();
let e = u8.length - 22;
while (e >= 0 && dv.getUint32(e, true) !== 0x06054b50) e--;
if (e < 0) throw new Error('not a zip');
const count = dv.getUint16(e + 10, true);
let p = dv.getUint32(e + 16, true);
for (let i = 0; i < count; i++) {
if (dv.getUint32(p, true) !== 0x02014b50) throw new Error('damaged zip');
const method = dv.getUint16(p + 10, true), crc = dv.getUint32(p + 16, true), size = dv.getUint32(p + 20, true), nlen = dv.getUint16(p + 28, true), xlen = dv.getUint16(p + 30, true), clen = dv.getUint16(p + 32, true), off = dv.getUint32(p + 42, true);
const name = new TextDecoder().decode(u8.subarray(p + 46, p + 46 + nlen));
if (method !== 0) throw new Error('compressed zip entries are not supported');
const start = off + 30 + dv.getUint16(off + 26, true) + dv.getUint16(off + 28, true);
const data = u8.slice(start, start + size);
if (crc32(data) !== crc) throw new Error('damaged file in the zip: ' + name);
out.set(name, data);
p += 46 + nlen + xlen + clen;
}
return out;
}
return { make, read, crc32 };
})();
/* ==================== Team ==================== */
// Modo Team: los datos viven en Supabase, con cuentas y en vivo. Solo se activa en la carpeta web/ que arma
// equipo/preparar-web.mjs (meta tasky-mode = team, config.js y vendor/supabase.js). Abierto desde el disco
// (src/index.html) Tasky sigue en modo Personal y nada de esto corre.
// Es el adaptador "team" de la capa de datos: el Store y Actions funcionan igual; aquí se compara cada cambio con
// lo último que se sabe del servidor y se manda con la operación que corresponde al rol (la seguridad la pone el
// servidor: RLS y funciones de equipo/migraciones/). Los cambios de los demás llegan por tiempo real.
const Team = (() => {
const wanted = typeof document !== 'undefined' && !!document.querySelector('meta[name="tasky-mode"][content="team"]');
const cfg = (typeof window !== 'undefined' && window.TASKY_CONFIG) || {};
const on = () => wanted;
let sb = null, user = null, team = null, me = null, members = [], invitations = [], online = true, entered = false;
let dbChannel = null, shadow = null, queue = Promise.resolve(), saving = 0;
const T = () => Help.TEXT.team;
const PREFS_KEY = 'tasky:team:prefs', CACHE_KEY = 'tasky:team:cache';
const ls = () => { try { return localStorage; } catch { return null; } };
const myId = () => (user ? user.id : null);
const myName = () => (me ? me.name : 'You');
const role = () => (me ? me.role : null);
const memberOf = (id) => members.find((m) => m.user_id === id) || null;
const nameOf = (id) => { const m = memberOf(id); return m ? m.name : ''; };
// ---- Permisos: lo que la interfaz ofrece. El servidor revisa lo mismo por su cuenta. ----
const OWNER = ['import', 'editProjects', 'editTasks', 'invite', 'approve', 'deleteFiles', 'starting', 'restore', 'assignAny'];
// (take, submit, upload, comment, agents, history: Owner y Member)
const WRITER = ['status', 'take', 'submit', 'upload', 'comment', 'agents', 'history'];
function can(what) {
if (!wanted) return true;
if (!me || !online) return false;
if (OWNER.includes(what)) return me.role === 'owner';
if (WRITER.includes(what)) return me.role === 'owner' || me.role === 'member';
return true;
}
function deny(what) { Shell.notify(online ? T().denied(what) : T().offlineNoEdit); return null; }
// ---- Persistencia del Store en Team: preferencias propias y una copia para mirar sin conexión ----
const PREF_FIELDS = ['layoutId', 'helpSeen', 'projectId', 'inboxOpen', 'bridgePort', 'folderLinks', 'contextTemplate', 'universe'];
function prefs() { try { return JSON.parse(ls().getItem(PREFS_KEY) || '{}'); } catch { return {}; } }
let cacheTimer = null;
const persistence = {
load() {
const s = Store.blank(), p = prefs();
for (const k of PREF_FIELDS) if (k in p) s.settings[k] = p[k];
if (Array.isArray(p.layouts)) s.layouts = p.layouts;
return s;
},
save(s) {
const p = {}; for (const k of PREF_FIELDS) p[k] = s.settings[k];
p.layouts = s.layouts;
try { ls().setItem(PREFS_KEY, JSON.stringify(p)); } catch {}
if (!entered) return;
clearTimeout(cacheTimer);
cacheTimer = setTimeout(() => { try { ls().setItem(CACHE_KEY, JSON.stringify({ at: Date.now(), user: myId(), team, me, members, state: Store.get() })); } catch {} }, 400);
},
};
// ---- Filas del servidor ↔ objetos del Store ----
const iso = (ms) => (ms ? new Date(ms).toISOString() : null);
const ms = (t) => (t ? Date.parse(t) : 0);
const MAP = {
projects: {
key: (r) => r.id,
toRow: (p) => ({ id: p.id, name: p.name, status: p.status || 'active', done_when: p.doneWhen || '', notes: p.notes || '', color: p.color || null, archived: !!p.archived, source: p.source || null }),
fromRow: (r) => ({ id: r.id, name: r.name, status: r.status, doneWhen: r.done_when || '', notes: r.notes || '', color: r.color || Store.PALETTE[0], archived: !!r.archived, source: r.source || '', createdAt: String(r.created_at || '').slice(0, 10), updatedAt: String(r.updated_at || '').slice(0, 10) }),
rows: (s) => s.projects,
},
workflows: {
key: (r) => r.project_id + '|' + r.id,
toRow: (w, i) => ({ project_id: w.projectId, id: w.id, name: w.name, mission: w.mission || '', universe: w.universe || 'Main', position: i }),
fromRow: (r) => ({ projectId: r.project_id, id: r.id, name: r.name, mission: r.mission || '', universe: r.universe || 'Main', position: r.position }),
rows: (s) => s.workflows,
},
tasks: {
key: (r) => r.project_id + '|' + r.id,
toRow: (t, i) => ({ project_id: t.projectId, id: t.id, workflow: t.workflow, title: t.title, mission: t.mission || '', status: t.status, needs: t.needs || '', needs_since: iso(t.needsSince), deliverables: t.deliverables || [], files: t.files || [], touched: t.touched || [], assignee: t.assignee || null, review_note: t.reviewNote || '', back_comment: t.backComment || '', position: i }),
fromRow: (r) => ({ projectId: r.project_id, id: r.id, workflow: r.workflow, title: r.title, mission: r.mission || '', status: r.status, needs: r.needs || '', ...(r.needs_since ? { needsSince: ms(r.needs_since) } : {}), ...(r.status === 'review' ? { reviewSince: ms(r.updated_at) } : {}), deliverables: r.deliverables || [], files: r.files || [], touched: r.touched || [], assignee: r.assignee || null, reviewNote: r.review_note || '', backComment: r.back_comment || '', position: r.position }),
rows: (s) => s.tasks,
},
agents: {
key: (r) => r.id,
// activity (0005): lo que su dueño comparte de lo que hace el agente (a.shared, recortado otra vez por el servidor).
toRow: (a) => ({ id: a.id, owner: a.owner, name: a.name, icon: a.icon || null, color: a.color || null, status: a.status === 'working' ? 'working' : 'waiting', away: !!a.away, source: a.source || 'manual', session: a.session || null, project_id: a.projectId || null, task_id: a.taskId || null, ...(missingCols.has('agents.activity') ? {} : { activity: a.shared || {} }) }),
fromRow: (r) => ({ id: r.id, owner: r.owner, name: r.name, icon: r.icon || 'circle', color: r.color || '#5B9DFF', status: r.status, away: !!r.away, source: r.source, session: r.session || null, projectId: r.project_id || null, taskId: r.task_id || null, activity: r.activity && Object.keys(r.activity).length ? Actions.cleanActivity(r.activity) : null, shared: r.activity || {} }),
rows: (s) => s.agents.filter((a) => a.owner === myId()),
},
files: {
key: (r) => r.id,
toRow: (f) => ({ id: f.id, project_id: f.projectId, task_id: f.taskId, name: f.name, version: f.version, kind: f.kind, path: f.path, size: f.size, mime: f.mime || '', note: f.note || '', deleted: !!f.deleted }),
fromRow: (r) => ({ id: r.id, projectId: r.project_id, taskId: r.task_id, name: r.name, version: r.version, kind: r.kind, path: r.path, size: Number(r.size) || 0, mime: r.mime || '', note: r.note || '', by: r.uploaded_by, at: ms(r.uploaded_at), deleted: !!r.deleted }),
rows: (s) => s.files,
},
messages: {
key: (r) => r.id,
toRow: (m) => ({ id: m.id, project_id: m.projectId, task_id: m.taskId || null, body: m.body, deleted: !!m.deleted, edited_at: iso(m.editedAt) }),
fromRow: (r) => ({ id: r.id, projectId: r.project_id, taskId: r.task_id || null, author: r.author, authorName: r.author_name || '', body: r.body, at: ms(r.created_at), editedAt: ms(r.edited_at) || 0, deleted: !!r.deleted }),
rows: (s) => s.messages.filter((m) => m.author === myId()),
},
// Papelera (0004): solo el dueño la ve y la cambia.
trash: {
key: (r) => r.id,
toRow: (e) => ({ id: e.id, kind: e.kind, project_id: e.projectId, task_id: e.taskId || null, name: String(e.name || '').slice(0, 300), data: e.data || {} }),
fromRow: (r) => ({ id: r.id, kind: r.kind, projectId: r.project_id, taskId: r.task_id || null, name: r.name || '', data: r.data || {}, deletedAt: ms(r.deleted_at), deletedByName: r.deleted_by_name || '' }),
rows: (s) => (role() === 'owner' ? s.trash || [] : []),
},
notifications: {
key: (r) => r.id,
toRow: (n) => ({ id: n.id, read_at: iso(n.readAt) }),
fromRow: (r) => ({ id: r.id, kind: r.kind, projectId: r.project_id, taskId: r.task_id, actor: r.actor, actorName: r.actor_name || '', body: r.body || '', at: ms(r.created_at), readAt: ms(r.read_at) || 0 }),
rows: (s) => s.notifications,
},
};
// Qué tablas existen en el servidor (las de T2 y T5 llegan con sus migraciones; si faltan, se saltan).
const missing = new Set();
// Columnas nuevas que el servidor todavía no tiene (una migración sin aplicar): no se mandan.
const missingCols = new Set();
// Quién: la persona real que puso el servidor (actor_name). Solo un handoff o un agente se muestran por su nombre.
const eventToEntry = (r) => { const d = r.data || {}; const byName = d.who === 'handoff' || d.agentAct || r.kind === 'agentIn' || r.kind === 'agentOut'; return { ...d, id: r.id, at: ms(r.at), kind: r.kind, projectId: r.project_id || undefined, taskId: r.task_id || undefined, actor: r.actor, actorName: r.actor_name || '', who: byName ? d.who : (r.actor_name || d.who || '') }; };
const entryToEvent = (e) => { const { id, at, kind, projectId, taskId, actor, ...data } = e; return { id, team_id: team.id, at: iso(at), kind, project_id: projectId || null, task_id: taskId || null, data }; };
function snapshot(s) {
const out = {};
for (const [t, m] of Object.entries(MAP)) {
out[t] = new Map();
if (missing.has(t)) continue;
const counters = {};
for (const o of m.rows(s)) {
const pid = o.projectId || '';
const i = (counters[pid] = (counters[pid] ?? -1) + 1);
const row = m.toRow(o, i);
for (const k of Object.keys(row)) if (missingCols.has(t + '.' + k)) delete row[k];
out[t].set(m.key(row), JSON.stringify(row));
}
}
out.events = new Set(s.history.map((e) => e.id));
return out;
}
// ---- Enviar: compara con lo último del servidor y manda lo que cambió, según el rol ----
function diff() {
if (!shadow || !entered) return [];
const s = Store.get(), now = snapshot(s), ops = [];
const order = ['projects', 'workflows', 'tasks', 'agents', 'files', 'messages', 'notifications', 'trash'];
for (const t of order) {
if (missing.has(t)) continue;
const a = shadow[t], b = now[t];
for (const [k, v] of b) if (!a.has(k)) ops.push({ t, op: 'insert', row: JSON.parse(v) });
for (const [k, v] of b) if (a.has(k) && a.get(k) !== v) { const was = JSON.parse(a.get(k)), row = JSON.parse(v); ops.push({ t, op: 'update', row, was, changed: Object.keys(row).filter((c) => JSON.stringify(row[c]) !== JSON.stringify(was[c])) }); }
}
for (const t of order.slice().reverse()) {
if (missing.has(t)) continue;
for (const [k, v] of shadow[t]) if (!now[t].has(k)) ops.push({ t, op: 'delete', row: JSON.parse(v) });
}
for (const e of s.history) if (!shadow.events.has(e.id)) ops.push({ t: 'events', op: 'insert', row: entryToEvent(e) });
shadow = now;
return ops;
}
const pk = { projects: ['id'], workflows: ['project_id', 'id'], tasks: ['project_id', 'id'], agents: ['id'], files: ['id'], messages: ['id'], notifications: ['id'], trash: ['id'] };
function where(q, t, row) { q = q.eq('team_id', team.id); for (const c of pk[t]) q = q.eq(c, row[c]); return q; }
async function run(o) {
const isOwner = role() === 'owner';
if (o.rows) return sb.from(o.t).insert(o.t === 'events' ? o.rows : o.rows.map((r) => ({ team_id: team.id, ...r })));
// Archivos tocados (0006): siempre con su función, que suma sin repetir y revisa cuánto comparte quien los manda.
if (o.t === 'tasks' && o.op === 'update' && o.changed.includes('touched')) {
if (!missingCols.has('tasks.touched')) {
const r = await sb.rpc('add_task_touched', { p_team: team.id, p_project: o.row.project_id, p_task: o.row.id, p_files: o.row.touched || [] });
if (r.error && !/Could not find the function|does not exist/i.test(r.error.message)) return r;
}
o = { ...o, changed: o.changed.filter((c) => c !== 'touched') };
if (!o.changed.length) return {};
}
if (o.t === 'tasks' && !isOwner) {
// Un Member no edita tareas: cambia estados y se asigna con las funciones que lo revisan.
if (o.op !== 'update') return { error: { message: T().denied('editTasks') } };
const statusFields = ['status', 'needs', 'needs_since', 'review_note', 'back_comment'];
const rest = o.changed.filter((c) => !statusFields.includes(c) && c !== 'assignee');
if (rest.length) return { error: { message: T().denied('editTasks') } };
if (o.changed.some((c) => statusFields.includes(c))) {
const r = await sb.rpc('set_task_status', { p_team: team.id, p_project: o.row.project_id, p_task: o.row.id, p_status: o.row.status, p_needs: o.row.needs || null, p_review_note: o.row.review_note || null });
if (r.error) return r;
}
if (o.changed.includes('assignee')) return sb.rpc('take_task', { p_team: team.id, p_project: o.row.project_id, p_task: o.row.id, p_take: o.row.assignee === myId() });
return {};
}
const row = { team_id: team.id, ...o.row };
if (o.op === 'insert') return sb.from(o.t).insert(o.t === 'agents' ? row : row);
if (o.op === 'update') { const patch = {}; for (const c of o.changed) patch[c] = o.row[c]; if (!Object.keys(patch).length) return {}; return where(sb.from(o.t).update(patch), o.t, o.row); }
if (o.op === 'delete') return where(sb.from(o.t).delete(), o.t, o.row);
return {};
}
// Filas nuevas seguidas de la misma tabla van juntas (traer un Backup con 2000 entradas de historia = pocos pedidos).
function batch(ops) {
const out = [];
for (const o of ops) {
const last = out[out.length - 1];
const simple = o.op === 'insert' && (o.t === 'events' || role() === 'owner' || o.t !== 'tasks');
if (simple && last && last.op === 'insert' && last.t === o.t && last.rows && last.rows.length < 500) last.rows.push(o.row);
else out.push(simple ? { ...o, rows: [o.row] } : o);
}
return out;
}
function sync() {
const ops = batch(diff());
if (!ops.length) return;
saving++;
queue = queue.then(async () => {
for (const o of ops) {
let r;
try { r = await run(o); } catch (e) { r = { error: e, network: true }; }
if (r && r.error) {
const msg = String(r.error.message || r.error);
const net = r.network || /fetch|network|Failed to fetch|Load failed/i.test(msg);
// Un servidor sin una migración nueva (0005, 0006…) no tiene esa columna: se deja de mandar, sin molestar a nadie.
const col = /['"]([a-z_]+)['"] column/i.exec(msg) || /column ['"]?([a-z_]+)['"]? does not exist/i.exec(msg);
if (!net && col && /column/i.test(msg) && /does not exist|Could not find/i.test(msg)) { missingCols.add(o.t + '.' + col[1]); shadow = snapshot(Store.get()); await reload(); break; }
if (net) goOffline(); else { Shell.notify(T().notSaved(msg)); await reload(); }
break;
}
}
}).finally(() => { saving--; });
}
const pending = () => queue;
// ---- Recibir: aplicar al Store lo que cambió en el servidor, sin volver a mandarlo ----
function applyRemote(fn) {
Actions.applyRemote(fn);
shadow = snapshot(Store.get());
}
function sortByPosition(list) {
const order = new Map(Store.get().projects.map((p, i) => [p.id, i]));
return list.sort((a, b) => ((order.get(a.projectId) ?? 0) - (order.get(b.projectId) ?? 0)) || ((a.position ?? 0) - (b.position ?? 0)));
}
function onChange(p) {
const t = p.table, row = p.eventType === 'DELETE' ? p.old : p.new;
if (row && row.team_id && team && row.team_id !== team.id) return;
if (t === 'members') return onMember(p);
if (t === 'invitations') { if (p.eventType === 'DELETE') invitations = invitations.filter((i) => i.id !== p.old.id); else invitations = [...invitations.filter((i) => i.id !== p.new.id), p.new]; Shell.render(); refreshPanel(); return; }
if (t === 'events') {
if (Store.get().history.some((e) => e.id === row.id)) return;
applyRemote((s) => { s.history.push(eventToEntry(row)); s.history.sort((a, b) => a.at - b.at); if (s.history.length > 2000) s.history.splice(0, s.history.length - 2000); });
return;
}
const m = MAP[t];
if (!m) return;
const listName = t;
applyRemote((s) => {
const list = s[listName];
const same = (o) => m.key(m.toRow(o, 0)) === m.key(row);
const i = list.findIndex(same);
if (p.eventType === 'DELETE') { if (i >= 0) list.splice(i, 1); }
else {
const o = m.fromRow(row);
if (t === 'agents' && o.owner === myId() && i >= 0) return; // los agentes propios los manda esta app
if (i >= 0) list[i] = Object.assign(list[i], o); else list.push(o);
if (t === 'tasks' || t === 'workflows') sortByPosition(list);
}
if (t === 'projects' && !s.projects.some((x) => x.id === s.settings.projectId)) s.settings.projectId = (s.projects.find((x) => !x.archived) || {}).id || null;
});
if (typeof Team.onRemote === 'function') Team.onRemote(t, p);
}
function onMember(p) {
if (p.eventType === 'DELETE') {
members = members.filter((m) => m.user_id !== p.old.user_id);
if (p.old.user_id === myId()) { signOut(T().removed); return; }
} else {
members = [...members.filter((m) => m.user_id !== p.new.user_id), p.new];
if (p.new.user_id === myId()) me = p.new;
}
Shell.render(); refreshPanel();
}
// ---- Cargar todo el equipo ----
async function fetchAll() {
const q = (t, build) => build(sb.from(t).select('*').eq('team_id', team.id)).then((r) => {
if (r.error && /relation|does not exist|42P01|PGRST205|Could not find the table/.test(r.error.message + ' ' + r.error.code)) { missing.add(t); return []; }
if (r.error) throw r.error;
missing.delete(t);
return r.data;
});
const [projects, workflows, tasks, events, agents, files, messages, notifications, mem, inv, trash] = await Promise.all([
q('projects', (x) => x.order('created_at')), q('workflows', (x) => x.order('position')), q('tasks', (x) => x.order('position')),
q('events', (x) => x.order('at', { ascending: false }).limit(2000)), q('agents', (x) => x), q('files', (x) => x.order('uploaded_at')),
q('messages', (x) => x.order('created_at', { ascending: false }).limit(2000)), q('notifications', (x) => x.eq('user_id', myId()).order('created_at', { ascending: false }).limit(300)),
q('members', (x) => x.order('joined_at')), role() === 'owner' ? q('invitations', (x) => x.order('created_at')) : Promise.resolve([]),
role() === 'owner' ? q('trash', (x) => x.order('deleted_at')) : Promise.resolve([]),
]);
members = mem; me = mem.find((m) => m.user_id === myId()) || me; invitations = inv;
const cur = Store.get();
const next = { ...cur, settings: { ...cur.settings }, projects: projects.map(MAP.projects.fromRow), workflows: workflows.map(MAP.workflows.fromRow), tasks: tasks.map(MAP.tasks.fromRow),
history: events.map(eventToEntry).sort((a, b) => a.at - b.at), files: files.map(MAP.files.fromRow), messages: messages.map(MAP.messages.fromRow).sort((a, b) => a.at - b.at), notifications: notifications.map(MAP.notifications.fromRow), trash: trash.map(MAP.trash.fromRow),
// Los agentes propios del puente los vuelve a publicar esta app; los de los demás vienen del servidor.
agents: [...cur.agents.filter((a) => a.owner === myId() || (!a.owner && a.source === 'manual')).map((a) => ({ ...a, owner: myId() })), ...agents.filter((r) => r.owner !== myId()).map(MAP.agents.fromRow), ...agents.filter((r) => r.owner === myId() && !cur.agents.some((a) => a.id === r.id)).map(MAP.agents.fromRow)] };
next.tasks = next.tasks; next.workflows = next.workflows;
if (!next.projects.some((p) => p.id === next.settings.projectId)) next.settings.projectId = (next.projects.find((p) => !p.archived) || {}).id || null;
for (const p of next.projects) Store.recordProgress(next, p.id);
Actions.replaceRemote(next);
const fromServer = Store.blank();
Object.assign(fromServer, { projects: next.projects, workflows: next.workflows, tasks: next.tasks, history: next.history, files: next.files, messages: next.messages, notifications: next.notifications, trash: next.trash, agents: agents.map(MAP.agents.fromRow) });
shadow = snapshot(fromServer);
}
async function reload() { try { await fetchAll(); } catch (e) { goOffline(); } }
// ---- Tiempo real ----
function subscribe() {
if (dbChannel) sb.removeChannel(dbChannel);
const tables = ['projects', 'workflows', 'tasks', 'events', 'agents', 'members', 'files', 'messages'];
let ch = sb.channel('db:' + team.id);
for (const t of tables) {
if (missing.has(t)) continue;
ch = ch.on('postgres_changes', { event: '*', schema: 'public', table: t, filter: 'team_id=eq.' + team.id }, onChange);
}
// Los DELETE no se pueden filtrar por equipo (llegan con la clave primaria, que incluye team_id).
for (const t of ['projects', 'workflows', 'tasks', 'agents', 'members']) ch = ch.on('postgres_changes', { event: 'DELETE', schema: 'public', table: t }, (p) => { if (p.old && p.old.team_id === team.id) onChange(p); });
if (!missing.has('notifications')) ch = ch.on('postgres_changes', { event: '*', schema: 'public', table: 'notifications', filter: 'user_id=eq.' + myId() }, onChange);
if (role() === 'owner') ch = ch.on('postgres_changes', { event: '*', schema: 'public', table: 'invitations', filter: 'team_id=eq.' + team.id }, onChange)
.on('postgres_changes', { event: 'DELETE', schema: 'public', table: 'invitations' }, onChange);
if (role() === 'owner' && !missing.has('trash')) ch = ch.on('postgres_changes', { event: '*', schema: 'public', table: 'trash', filter: 'team_id=eq.' + team.id }, onChange)
.on('postgres_changes', { event: 'DELETE', schema: 'public', table: 'trash' }, (p) => { if (p.old && p.old.team_id === team.id) onChange(p); });
dbChannel = ch.subscribe((status) => {
if (status === 'SUBSCRIBED') { if (!online) goOnline(); }
else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') { if (entered && status !== 'CLOSED') goOffline(); }
});
if (typeof Team.onSubscribed === 'function') Team.onSubscribed();
}
// ---- Sin conexión: se mira lo último; no se edita ----
function goOffline() {
if (!online) return;
online = false;
Shell.render(); renderOffline();
}
async function goOnline() {
if (online) return;
try { await fetchAll(); online = true; renderOffline(); Shell.render(); persistence.save(Store.get()); } catch { /* sigue sin conexión */ }
}
function renderOffline() {
const n = document.getElementById('offline');
if (!n) return;
n.hidden = online;
n.textContent = online ? '' : T().offline;
}
// ---- Entrar ----
function prepare() {
if (!wanted) return;
Store.setPersistence(persistence);
Store.setWritable(() => !entered || (online && !!me && me.role !== 'viewer'));
Store.setAgentFilter((a) => !a.owner || a.owner === myId() || isOnlineUser(a.owner));
Store.setIdMaker((kind) => {
if (!entered) return null;
const rnd = (globalThis.crypto && crypto.randomUUID) ? crypto.randomUUID() : String(Math.random()).slice(2) + Date.now();
if (kind === 'event') return 'e_' + rnd;
if (kind === 'a_') return 'a_' + rnd.slice(0, 8);
if (kind === 'p_') return 'p_' + rnd.slice(0, 8);
return null;
});
Store.onBlocked(() => Shell.notify(!online ? T().offlineNoEdit : me && me.role === 'viewer' ? T().viewerNoEdit : T().signInFirst));
}
// Demo local (equipo/demo-local.mjs): un aviso fijo dice siempre que son datos de prueba.
function demoBanner() {
if (!cfg.demo || document.getElementById('demo-banner')) return;
const b = document.createElement('div');
b.id = 'demo-banner'; b.setAttribute('role', 'note');
b.textContent = T().demoBanner;
document.body.prepend(b);
document.body.classList.add('demo');
}
function demoAccounts() {
const { el, btn } = Shell;
const box = el('div', { className: 'demo-accounts' }, el('p', { className: 'si-hint', textContent: T().demoAccounts }));
for (const a of cfg.demoAccounts || []) box.append(btn(a.name + ' · ' + a.role + ' · ' + a.email, () => { const e = document.getElementById('si-email'), pw = document.getElementById('si-password'); if (e && pw) { e.value = a.email; pw.value = a.password; pw.focus(); } }, { className: 'linkish', data: { key: 'demo|' + a.email } }));
return box;
}
async function start() {
if (!wanted) return;
document.body.classList.add('team');
demoBanner();
// La app publicada se guarda en el navegador (sw.js) para abrirse sin conexión; los datos, aparte (copia local).
if ('serviceWorker' in navigator && /^https?:$/.test(location.protocol)) navigator.serviceWorker.register('sw.js').catch(() => {});
if (!cfg.supabaseUrl || !cfg.supabaseAnonKey || !window.supabase) { showSignIn(T().notConfigured); return; }
sb = window.supabase.createClient(cfg.supabaseUrl, cfg.supabaseAnonKey, { auth: { persistSession: true, autoRefreshToken: true, detectSessionInUrl: false } });
addEventListener('offline', () => goOffline());
// Abrir un enlace de invitación con la pestaña ya abierta solo cambia el #: la pantalla de entrada se rehace.
addEventListener('hashchange', () => { if (!entered && /(invite|reset)=/.test(location.hash)) showSignIn(); });
addEventListener('online', () => { if (dbChannel) subscribe(); });
let sess = null;
try { sess = (await sb.auth.getSession()).data.session; } catch {}
// Un enlace para poner contraseña nueva siempre abre su pantalla (aunque este navegador tenga otra sesión).
if (!sess || /reset=/.test(location.hash)) { showSignIn(); return; }
user = sess.user;
await enter();
}
async function enter(code, name) {
// Sin red, la librería reintenta varias veces: si el navegador ya sabe que no hay conexión, o el servidor no
// contesta en 8 s, se abre la copia guardada.
if (typeof navigator !== 'undefined' && navigator.onLine === false && !code) return enterOffline();
let mine;
try { mine = await Promise.race([sb.from('members').select('*').eq('user_id', myId()), new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 8000))]); }
catch { return enterOffline(); }
if (mine.error && /fetch|network/i.test(mine.error.message)) return enterOffline();
let row = mine.data && mine.data[0];
if (!row && code) {
const r = await sb.rpc('accept_invitation', { p_code: code.trim(), p_name: name || '' });
if (r.error) { showSignIn(T().badCode(r.error.message)); return; }
row = r.data;
}
if (!row) { await sb.auth.signOut(); user = null; showSignIn(T().notInvited); return; }
me = row;
const t = await sb.from('teams').select('*').eq('id', row.team_id).single();
team = t.data || { id: row.team_id, name: 'Team' };
try { await fetchAll(); } catch { return enterOffline(); }
entered = true; online = true;
if (!me.timezone) { const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; sb.rpc('update_profile', { p_team: team.id, p_timezone: tz }).then((r) => { if (r.data) me = r.data; }); }
subscribe();
joinPresence();
hideSignIn();
Shell.render(); renderOffline();
persistence.save(Store.get()); // la copia para mirar sin conexión, desde ya
if (typeof Team.onEnter === 'function') Team.onEnter();
}
// Sin conexión al abrir: lo último que se guardó, para mirar.
function enterOffline() {
let c = null; try { c = JSON.parse(ls().getItem(CACHE_KEY) || 'null'); } catch {}
if (!c || c.user !== myId()) { showSignIn(T().offlineNoCache); return; }
team = c.team; me = c.me; members = c.members || [];
Actions.replaceRemote({ ...c.state, settings: { ...c.state.settings, ...Store.get().settings } });
shadow = snapshot(Store.get());
entered = true; online = false;
hideSignIn(); Shell.render(); renderOffline();
const retry = setInterval(async () => { try { const r = await sb.from('members').select('user_id').eq('user_id', myId()); if (!r.error) { clearInterval(retry); online = false; subscribe(); await goOnline(); } } catch {} }, 5000);
}
async function signOut(msg) {
entered = false; me = null; team = null; members = []; invitations = []; shadow = null;
try { if (dbChannel) await sb.removeChannel(dbChannel); } catch {}
try { if (presenceCh) await sb.removeChannel(presenceCh); } catch {}
presenceCh = null; presence = {};
if (typeof Team.onSignOut === 'function') Team.onSignOut();
dbChannel = null;
try { await sb.auth.signOut(); } catch {}
user = null;
try { ls().removeItem(CACHE_KEY); } catch {}
const keep = Store.get().settings, blank = Store.blank();
for (const k of PREF_FIELDS) if (k !== 'projectId') blank.settings[k] = keep[k];
Actions.replaceRemote(blank); // las preferencias de esta persona quedan; los datos del equipo, no
Shell.closePanel();
showSignIn(msg);
}
// ---- Pantalla de entrada: Sign in / Create account ----
function showSignIn(message) {
const box = document.getElementById('signin');
if (!box) return;
document.getElementById('app').hidden = true;
box.hidden = false;
box.textContent = '';
const { el, btn } = Shell;
const hash = new URLSearchParams((location.hash || '').replace(/^#/, ''));
// Modos: signin, create (enlace #invite=), reset (enlace #reset= que generó el dueño: contraseña nueva, un solo uso).
let mode = hash.get('reset') ? 'reset' : hash.get('invite') ? 'create' : 'signin';
const card = el('section', { className: 'signin-card', ariaLabel: 'Sign in' });
const draw = () => {
card.textContent = '';
const msg = el('p', { className: 'signin-msg', role: 'alert', textContent: message || '' });
msg.hidden = !message;
const email = el('input', { type: 'email', id: 'si-email', autocomplete: 'email', required: true, value: hash.get('email') || '', ariaLabel: 'Email' });
const pw = el('input', { type: 'password', id: 'si-password', autocomplete: mode === 'create' ? 'new-password' : 'current-password', required: true, minLength: 8, ariaLabel: 'Password' });
const nameIn = el('input', { type: 'text', id: 'si-name', autocomplete: 'name', maxLength: 60, ariaLabel: 'Your name' });
const code = el('input', { type: 'text', id: 'si-code', autocomplete: 'off', value: hash.get('invite') || '', ariaLabel: 'Invitation code' });
const field = (label, input) => el('label', { className: 'si-field' }, el('span', { textContent: label }), input);
const pw2 = el('input', { type: 'password', id: 'si-password2', autocomplete: 'new-password', minLength: 8, ariaLabel: 'Repeat the new password' });
if (mode === 'reset') { pw.autocomplete = 'new-password'; email.readOnly = !!email.value; }
const go = btn(mode === 'create' ? 'Create account' : mode === 'reset' ? 'Save the new password' : 'Sign in', null, { className: 'btn primary', type: 'submit', data: { key: 'si-go' } });
const form = el('form', { className: 'si-form' });
form.append(...(mode === 'create' ? [field('Your name', nameIn), field('Email', email), field('Password (8 or more characters)', pw), field('Invitation code', code)]
: mode === 'reset' ? [field('Email', email), field('New password (8 or more characters)', pw), field('Repeat the new password', pw2)] : [field('Email', email), field('Password', pw)]), go);
form.addEventListener('submit', async (e) => {
e.preventDefault();
go.disabled = true; message = '';
try {
if (mode === 'reset') {
if (pw.value.length < 8) { message = T().shortPassword; return; }
if (pw.value !== pw2.value) { message = T().passwordsDiffer; return; }
const r = await sb.rpc('redeem_password_reset', { p_token: hash.get('reset') || '', p_password: pw.value });
if (r.error) { message = r.error.message; return; }
const em = (r.data && r.data.email) || email.value.trim();
history.replaceState(null, '', location.pathname + location.search);
const si = await sb.auth.signInWithPassword({ email: em, password: pw.value });
if (si.error) { mode = 'signin'; message = T().passwordSaved; return; }
user = si.data.user;
await enter();
if (entered) Shell.notify(T().passwordSaved);
} else if (mode === 'create') {
if (pw.value.length < 8) { message = T().shortPassword; return; }
// El código viaja con la cuenta: el servidor (hook) no la crea sin el código correcto para ese correo.
const r = await sb.auth.signUp({ email: email.value.trim(), password: pw.value, options: { data: { invite_code: code.value.trim() } } });
if (r.error) { message = r.error.message; return; }
if (!r.data.session) { message = T().confirmOff; return; }
user = r.data.user;
history.replaceState(null, '', location.pathname + location.search);
await enter(code.value, nameIn.value);
} else {
const r = await sb.auth.signInWithPassword({ email: email.value.trim(), password: pw.value });
if (r.error) { message = r.error.message; return; }
user = r.data.user;
await enter();
}
} catch (err) { message = String(err && err.message || err); }
finally { go.disabled = false; if (message) draw(); }
});
const toggle = btn(mode === 'create' ? 'I already have an account: Sign in' : 'New here? Create your account with your invitation', () => { mode = mode === 'create' ? 'signin' : 'create'; message = ''; draw(); }, { className: 'linkish', data: { key: 'si-toggle' } });
card.append(el('h1', { className: 'logo', textContent: 'Tasky' }), el('h2', { textContent: mode === 'create' ? 'Create your account' : mode === 'reset' ? 'Choose a new password' : 'Sign in' }),
el('p', { className: 'si-hint', textContent: mode === 'create' ? T().createHint : mode === 'reset' ? T().resetHint : T().signInHint }), msg, form, mode === 'reset' ? null : toggle);
if (cfg.demo) card.append(demoAccounts());
(mode === 'create' && !nameIn.value ? nameIn : email.value ? pw : email).focus();
};
draw();
box.append(card);
}
function hideSignIn() {
const box = document.getElementById('signin');
if (box) { box.hidden = true; box.textContent = ''; }
document.getElementById('app').hidden = false;
}
// ---- Cuenta y equipo (lo que no es el universo: va directo al servidor con sus funciones) ----
async function updateProfile(fields) {
const r = await sb.rpc('update_profile', { p_team: team.id, p_name: fields.name ?? null, p_ship_shape: fields.shipShape ?? null, p_ship_color: fields.shipColor ?? null, p_timezone: fields.timezone ?? null, p_onboarded: fields.onboarded ?? null });
if (r.error) { Shell.notify(T().notSaved(r.error.message)); return false; }
me = r.data; members = members.map((m) => (m.user_id === me.user_id ? me : m));
track();
Shell.render(); return true;
}
async function invite(email, roleName) {
const r = await sb.from('invitations').insert({ team_id: team.id, email: String(email).trim().toLowerCase(), role: roleName, invited_by: myId() }).select().single();
if (r.error) { Shell.notify(T().notSaved(/duplicate|unique/i.test(r.error.message) ? T().alreadyInvited : r.error.message)); return null; }
invitations = [...invitations.filter((i) => i.id !== r.data.id), r.data];
Actions.logTeam('invite', { email: r.data.email, role: roleName });
return r.data;
}
async function revoke(id) {
const inv = invitations.find((i) => i.id === id);
const r = await sb.from('invitations').delete().eq('id', id);
if (r.error) { Shell.notify(T().notSaved(r.error.message)); return false; }
invitations = invitations.filter((i) => i.id !== id);
if (inv) Actions.logTeam('revoke', { email: inv.email });
return true;
}
async function setRole(userId, roleName) {
const r = await sb.rpc('set_member_role', { p_team: team.id, p_user: userId, p_role: roleName });
if (r.error) { Shell.notify(T().notSaved(r.error.message)); return false; }
members = members.map((m) => (m.user_id === userId ? { ...m, role: roleName } : m));
Actions.logTeam('role', { person: nameOf(userId), to: roleName });
return true;
}
async function remove(userId) {
const n = nameOf(userId);
const r = await sb.rpc('remove_member', { p_team: team.id, p_user: userId });
if (r.error) { Shell.notify(T().notSaved(r.error.message)); return false; }
members = members.filter((m) => m.user_id !== userId);
Actions.logTeam('remove', { person: n });
await reload();
return true;
}
const appBase = () => (cfg.appUrl || (location.origin + location.pathname)).replace(/#.*$/, '');
const inviteLink = (inv) => appBase() + '#invite=' + encodeURIComponent(inv.code) + '&email=' + encodeURIComponent(inv.email);
// ---- Agentes (0005): cuánto comparte cada persona, y la actividad publicada cada tanto ----
const sharing = () => (me && ['status', 'tools', 'files'].includes(me.agent_sharing) ? me.agent_sharing : 'tools');
async function setSharing(level) {
const r = await sb.rpc('set_agent_sharing', { p_team: team.id, p_level: level });
if (r.error) { Shell.notify(T().notSaved(r.error.message)); return false; }
me = r.data; members = members.map((m) => (m.user_id === me.user_id ? me : m));
lastPush.clear(); publishActivity(true);
return true;
}
// Lo propio se publica como mucho cada 10 s por agente; enseguida si empieza o deja de esperar a su dueño.
const lastPush = new Map();
function publishActivity(force = false) {
if (!entered || !online || !me || me.role === 'viewer' || missingCols.has('agents.activity')) return;
const level = sharing(), out = [];
for (const a of Store.get().agents) {
if (a.owner !== myId() || a.away) continue;
const sh = Actions.shareActivity(a.activity, level), prev = a.shared || {};
if (JSON.stringify(sh) === JSON.stringify(prev)) continue;
const urgent = force || sh.state !== prev.state || (sh.waiting || null) !== (prev.waiting || null);
if (!urgent && Date.now() - (lastPush.get(a.id) || 0) < 10000) continue;
lastPush.set(a.id, Date.now());
out.push([a.id, sh]);
}
if (out.length) Actions.publishActivity(out);
}
if (wanted && typeof setInterval !== 'undefined') setInterval(() => publishActivity(), 2500);
// ---- Contraseñas (0004): enlace de un solo uso que genera el dueño; cada uno cambia la suya en su cuenta ----
async function passwordLink(userId) {
const r = await sb.rpc('create_password_reset', { p_team: team.id, p_user: userId, p_hours: 24 });
if (r.error) { Shell.notify(T().notSaved(r.error.message)); return null; }
const m = memberOf(userId);
return { url: appBase() + '#reset=' + encodeURIComponent(r.data.token) + (m ? '&email=' + encodeURIComponent(m.email) : ''), expires: ms(r.data.expires_at) };
}
async function changePassword(current, next) {
if (String(next || '').length < 8) return T().shortPassword;
// Primero se confirma la contraseña de ahora (así la sesión es reciente y nadie la cambia con la pestaña abierta).
const chk = await sb.auth.signInWithPassword({ email: me.email, password: String(current || '') });
if (chk.error) return T().wrongPassword;
const r = await sb.auth.updateUser({ password: String(next) });
return r.error ? r.error.message : '';
}
// ---- Salida de un miembro (0004): reasignar sus tareas, retirar sus agentes y cerrar sus sesiones, en un paso ----
async function offboard(userId, reassign) {
const n = nameOf(userId);
const r = await sb.rpc('offboard_member', { p_team: team.id, p_user: userId, p_reassign: reassign || [] });
if (r.error) { Shell.notify(T().notSaved(r.error.message)); return null; }
members = members.filter((m) => m.user_id !== userId);
Actions.logTeam('remove', { person: n, reassigned: r.data.reassigned, agents: r.data.agents });
await reload();
return r.data;
}
// ---- Papelera: borrar para siempre lo hace el servidor (y devuelve los archivos que hay que quitar de Storage) ----
async function purgeTrash(e) {
const r = await sb.rpc('purge_trash', { p_team: team.id, p_id: e.id });
if (r.error) { Shell.notify(T().notSaved(r.error.message)); return false; }
const paths = (r.data || []).filter(Boolean);
for (let i = 0; i < paths.length; i += 100) await sb.storage.from(BUCKET).remove(paths.slice(i, i + 100));
applyRemote((s) => {
s.trash = s.trash.filter((x) => x.id !== e.id);
const hit = (x) => x.projectId === e.projectId && (e.kind === 'project' || x.taskId === e.taskId);
s.files = s.files.filter((x) => !hit(x)); s.messages = s.messages.filter((x) => !hit(x));
});
Actions.logTeam('purge', { what: e.kind, name: e.name });
return true;
}
// ---- Uso de almacenamiento (dueño): lo que ocupan las versiones de archivos frente al límite configurado ----
const storageLimitMB = () => (Number(cfg.storageLimitMB) > 0 ? Number(cfg.storageLimitMB) : 1024);
function storageUsage() {
const used = Store.get().files.filter((x) => !x.deleted).reduce((a, x) => a + (Number(x.size) || 0), 0);
const limit = storageLimitMB() * 1048576;
return { used, limit, pct: limit ? used / limit : 0, near: used >= 0.8 * limit };
}
// ---- Respaldo del equipo (0004): todo en un .zip (datos + cada versión de cada archivo) y su restauración ----
async function backupTeam(progress) {
const r = await sb.rpc('export_team', { p_team: team.id });
if (r.error) { Shell.notify(T().notSaved(r.error.message)); return null; }
const data = r.data, entries = [], missingFiles = [];
let i = 0;
for (const f of data.files || []) {
i++;
if (progress) progress(i, data.files.length);
if (f.deleted) continue;
const d = await sb.storage.from(BUCKET).download(f.path);
if (d.error || !d.data) { missingFiles.push(f.id); continue; }
f.zip = 'files/' + f.id + '-' + safe(f.name);
entries.push({ name: f.zip, data: new Uint8Array(await d.data.arrayBuffer()) });
}
data.missing_files = missingFiles;
entries.unshift({ name: 'tasky-team-backup.json', data: new TextEncoder().encode(JSON.stringify(data, null, 1)) });
return { blob: Zip.make(entries), counts: { projects: (data.projects || []).length, tasks: (data.tasks || []).length, events: (data.events || []).length, messages: (data.messages || []).length, files: entries.length - 1, members: (data.members || []).length } };
}
const isEmptyTeam = () => !Store.get().projects.length && !(Store.get().trash || []).length;
async function restoreTeam(buffer, progress) {
let files, data;
try { files = Zip.read(buffer); data = JSON.parse(new TextDecoder().decode(files.get('tasky-team-backup.json'))); } catch { return { error: T().notTeamBackup }; }
if (!data || data.format !== 'tasky-team-backup') return { error: T().notTeamBackup };
if (!isEmptyTeam()) return { error: T().restoreNotEmpty };
// Cada archivo se sube con una ruta de ESTE equipo (la primera carpeta es el equipo); después van las filas.
let i = 0;
for (const f of data.files || []) {
i++;
if (progress) progress(i, data.files.length);
const path = [team.id, safe(f.project_id), safe(f.task_id), crypto.randomUUID() + '-' + safe(f.name)].join('/');
const blob = f.zip ? files.get(f.zip) : null;
if (blob) {
const up = await sb.storage.from(BUCKET).upload(path, blob, { contentType: f.mime || 'application/octet-stream', upsert: false });
if (up.error) return { error: T().notSaved(up.error.message) };
}
f.path = path; delete f.zip;
}
const r = await sb.rpc('restore_team', { p_team: team.id, p_data: data });
if (r.error) return { error: T().notSaved(r.error.message) };
await reload();
try { const m = await sb.from('members').select('*').eq('team_id', team.id); if (!m.error) members = m.data; } catch {}
Shell.render();
return { ok: true, ...r.data };
}
// ---- Presencia: quién está conectado y qué mira (canal privado "team:", nada en la base de datos) ----
let presenceCh = null, presenceGen = 0, focusNow = null, presence = {};
function joinPresence() {
if (presenceCh) { try { sb.removeChannel(presenceCh); } catch {} presenceCh = null; }
const teamId = team.id, gen = ++presenceGen;
// Canal privado: Realtime decide quién entra con el token de la sesión. Se le entrega antes de entrar, como pide
// la documentación de Realtime Authorization; si no lo tuviera aún, Realtime probaría como anon y no dejaría entrar.
Promise.resolve().then(() => sb.realtime.setAuth()).catch(() => {}).then(() => {
if (gen !== presenceGen || !team || team.id !== teamId) return;
const ch = sb.channel('team:' + teamId, { config: { private: true, presence: { key: myId() } } });
presenceCh = ch;
ch.on('presence', { event: 'sync' }, () => { if (presenceCh !== ch) return; presence = ch.presenceState(); if (typeof Team.onPresence === 'function') Team.onPresence(); })
.subscribe((st, err) => {
if (st === 'SUBSCRIBED') track();
else if (st === 'CHANNEL_ERROR' && err) console.warn('[Tasky] presence: ' + (err.message || err));
});
});
}
function track() {
if (!presenceCh || !me) return;
presenceCh.track({ name: me.name, shape: me.ship_shape || 'arrow', color: me.ship_color || '#FFD23F', tz: me.timezone || '', focus: focusNow, at: Date.now() }).catch(() => {});
}
let focusTimer = null;
// Lo que esta persona mira: { project, kind: 'task' | 'workflow' | 'project', id }. Se publica un instante después.
function setFocus(target) {
const next = target ? { project: target.project || null, kind: target.kind || 'project', id: target.id || null, universe: target.universe || 'Main' } : null;
if (JSON.stringify(next) === JSON.stringify(focusNow)) return;
focusNow = next;
clearTimeout(focusTimer);
focusTimer = setTimeout(track, 150);
}
// Personas conectadas: [{ userId, name, shape, color, tz, focus }] (sin repetir; la última conexión manda).
function people() {
const out = [];
for (const [key, metas] of Object.entries(presence || {})) {
const m = (metas || []).slice().sort((a, b) => (b.at || 0) - (a.at || 0))[0];
if (m) out.push({ userId: key, name: m.name, shape: m.shape, color: m.color, tz: m.tz, focus: m.focus || null });
}
return out;
}
const isOnlineUser = (id) => Object.prototype.hasOwnProperty.call(presence || {}, id);
// ---- Archivos (bucket privado "tasky-files"): subir, enlace temporal, leer texto, quitar ----
const BUCKET = 'tasky-files';
const maxMB = () => Number(cfg.maxFileMB) > 0 ? Number(cfg.maxFileMB) : 25;
const safe = (x) => String(x).normalize('NFKD').replace(/[^\w.-]+/g, '_').replace(/^\.+/, '').slice(0, 100) || 'file';
async function uploadFiles(projectId, taskId, list, { kind = 'delivery', note = '', by = 'you' } = {}) {
const out = [];
for (const file of Array.from(list || [])) {
const name = String(file.name || 'file').slice(0, 200);
if (file.size > maxMB() * 1024 * 1024) { out.push({ name, error: T().tooBig(name, maxMB()) }); continue; }
const prev = Store.get().files.filter((x) => x.projectId === projectId && x.taskId === taskId && x.kind === kind && x.name === name);
const version = prev.reduce((m, x) => Math.max(m, x.version), 0) + 1;
const id = crypto.randomUUID();
const path = [team.id, safe(projectId), safe(taskId), id + '-' + safe(name)].join('/');
let up;
try { up = await sb.storage.from(BUCKET).upload(path, await file.arrayBuffer(), { contentType: file.type || 'application/octet-stream', upsert: false }); }
catch (e) { up = { error: e }; }
if (up.error) { out.push({ name, error: String(up.error.message || up.error) }); continue; }
if (Actions.addFile({ id, projectId, taskId, name, version, kind, path, size: file.size, mime: file.type || '', note: String(note || '').slice(0, 500) }, { by }) == null) { out.push({ name, error: T().denied(kind === 'starting' ? 'starting' : 'upload') }); continue; }
out.push({ name, version });
}
return out;
}
async function signedUrl(fl, asDownload) {
const r = await sb.storage.from(BUCKET).createSignedUrl(fl.path, 120, asDownload ? { download: fl.name } : undefined);
return r.error ? null : r.data.signedUrl;
}
async function download(fl) {
const url = await signedUrl(fl, true);
if (!url) { Shell.notify(T().fileGone(fl.name)); return false; }
const a = document.createElement('a');
a.href = url; a.download = fl.name; a.rel = 'noopener'; a.hidden = true;
document.body.append(a); a.click(); a.remove();
Actions.logFile('download', fl);
return true;
}
async function readText(fl) {
if (fl.size > 1024 * 1024) return null;
const r = await sb.storage.from(BUCKET).download(fl.path);
return r.error ? null : r.data.text();
}
async function deleteFileVersion(fl) {
if (Actions.deleteFile(fl.id) == null) return false;
await pending();
await sb.storage.from(BUCKET).remove([fl.path]);
return true;
}
// ---- Hilos leídos: hasta cuándo leyó esta persona cada hilo (en este navegador) ----
const READS_KEY = () => 'tasky:team:reads:' + (myId() || '');
const reads = () => { try { return JSON.parse(ls().getItem(READS_KEY()) || '{}'); } catch { return {}; } };
const threadKey = (projectId, taskId) => projectId + '|' + (taskId || '#chat');
function markRead(projectId, taskId) {
const r = reads(); r[threadKey(projectId, taskId)] = Date.now();
try { ls().setItem(READS_KEY(), JSON.stringify(r)); } catch {}
}
function unreadIn(projectId, taskId) {
const since = reads()[threadKey(projectId, taskId)] || 0;
return Store.get().messages.filter((m) => m.projectId === projectId && (m.taskId || null) === (taskId || null) && !m.deleted && m.author !== myId() && m.at > since).length;
}
let panelRefresh = null;
function refreshPanel() { if (panelRefresh && document.getElementById('panel')) panelRefresh(); }
function setPanelRefresh(fn) { panelRefresh = fn; }
return {
on, can, deny, prepare, start, enter, signOut, sync, pending, reload, fetchAll, updateProfile, invite, revoke, setRole, remove, inviteLink, appBase,
passwordLink, changePassword, offboard, sharing, setSharing, publishActivity, purgeTrash, storageUsage, storageLimitMB, backupTeam, restoreTeam, isEmptyTeam,
setPanelRefresh, refreshPanel, myId, myName, role, memberOf, nameOf, markRead, unreadIn, setFocus, people, isOnlineUser, uploadFiles, signedUrl, download, readText, deleteFileVersion, maxMB,
client: () => sb, team: () => team, me: () => me, members: () => members, invitations: () => invitations, isOnline: () => online, isEntered: () => entered,
goOffline, goOnline, missing: () => missing, config: () => cfg,
};
})();
/* ==================== Ships ==================== */
// Modo Team: cada persona tiene una nave (6 formas y un color). Los mismos trazos los dibujan la app y los layouts.
const Ships = (() => {
const PATHS = {
arrow: 'M12 2l6 18-6-4-6 4z',
saucer: 'M2 13.5c0-2 4.5-3.5 10-3.5s10 1.5 10 3.5-4.5 3.5-10 3.5S2 15.5 2 13.5zM7.5 10.5a4.5 4.5 0 0 1 9 0z',
rocket: 'M12 2c3.2 2.7 4.6 6.3 4.6 10.2l2.8 3.3V20l-4.2-1.6H8.8L4.6 20v-4.5l2.8-3.3C7.4 8.3 8.8 4.7 12 2z',
delta: 'M12 3l10 17-10-4-10 4z',
orb: 'M12 6.5a5.5 5.5 0 1 0 0 11a5.5 5.5 0 1 0 0-11zM1.5 12c0-1.4 4.7-2.4 10.5-2.4s10.5 1 10.5 2.4-4.7 2.4-10.5 2.4S1.5 13.4 1.5 12z',
wing: 'M1.5 15L12 5l10.5 10-4.5 1.2L12 12l-6 4.2z',
};
const SHAPES = Object.keys(PATHS);
const COLORS = ['#FFD23F', '#FF6B6B', '#2EC4B6', '#5B9DFF', '#B28DFF', '#F47FC5', '#FF9F1C', '#7ED957'];
const path = (shape) => PATHS[shape] || PATHS.arrow;
function icon(m, size = 22) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('width', size); svg.setAttribute('height', size); svg.setAttribute('aria-hidden', 'true'); svg.setAttribute('class', 'ship');
const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
p.setAttribute('d', path(m && (m.ship_shape || m.shape))); p.setAttribute('fill', (m && (m.ship_color || m.color)) || '#FFD23F'); p.setAttribute('fill-rule', 'evenodd');
p.setAttribute('stroke', '#17142B'); p.setAttribute('stroke-width', '1');
svg.append(p);
return svg;
}
// Elegir forma y color (en la cuenta, y la primera vez que alguien entra).
function picker(m, onDone) {
const { el, btn } = Shell;
const box = el('section', { className: 'shippick', ariaLabel: 'Your ship' }, el('h3', { textContent: 'Your ship' }));
let shape = m.ship_shape || 'arrow', color = m.ship_color || COLORS[0];
const shapes = el('div', { className: 'apick', role: 'group', ariaLabel: 'Ship shape' }), colors = el('div', { className: 'apick', role: 'group', ariaLabel: 'Ship color' });
const draw = () => {
shapes.textContent = ''; colors.textContent = '';
for (const sh of SHAPES) { const b = btn('', () => { shape = sh; draw(); }, { className: 'aopt ship-opt', ariaLabel: 'Ship ' + sh, data: { key: 'ship|' + sh } }); b.append(icon({ shape: sh, color }, 26)); b.setAttribute('aria-pressed', String(shape === sh)); shapes.append(b); }
for (const c of COLORS) { const b = btn('', () => { color = c; draw(); }, { className: 'aopt swatch', ariaLabel: 'Color ' + c, data: { key: 'shipc|' + c } }); b.style.setProperty('--c', c); b.setAttribute('aria-pressed', String(color === c)); colors.append(b); }
};
draw();
box.append(shapes, colors, el('div', { className: 'pn-actions' }, btn('Save ship', async () => { if (await Team.updateProfile({ shipShape: shape, shipColor: color })) { Shell.notify(Help.TEXT.ships.saved); if (onDone) onDone(); } }, { className: 'primary', data: { key: 'ship-save' } })));
return box;
}
return { PATHS, SHAPES, COLORS, path, icon, picker };
})();
/* ==================== Help ==================== */
const Help = (() => {
// Todos los textos de explicación, en un solo objeto.
const TEXT = {
status: { todo: 'To do', doing: 'In progress', 'needs-you': 'Needs you', review: 'In review', blocked: 'Blocked', done: 'Done' },
chat: {
emptyThread: 'No messages yet. Ask, answer or leave a note for whoever works on this task.',
emptyChat: 'No messages yet. This is the chat of the whole project.',
placeholder: (names) => 'Write a message. Mention someone with @' + ([].concat(names)[0] || 'name') + '. Links open in a new tab.',
hint: 'Ctrl+Enter sends. Plain text only.',
deleted: 'This message was deleted.',
noNotifications: 'No notifications yet.',
},
ships: {
chooseTitle: 'Choose your ship',
chooseText: 'Your ship flies over the universe next to whatever you are looking at, so your team sees where you are. Pick a shape and a color; you can change them in your account.',
saved: 'Ship saved.',
},
people: {
noTask: 'No task assigned',
nothing: 'Nothing done yet',
last: (what, when) => 'Last: ' + what + ' (' + when + (when === 'just now' ? '' : ' ago') + ')',
following: (n) => 'Following ' + n,
followHint: 'Your camera follows their ship. Touch anything to stop.',
noActivity: (n) => n + ' has no activity yet.',
summary: (d, a, b) => d + (d === 1 ? ' delivery' : ' deliveries') + ' · ' + a + ' approved · ' + b + ' sent back',
},
review: {
submit: 'Submit for review',
submitTitle: 'Submit for review',
notePlaceholder: 'A note for the reviewer (optional): what you did, what to look at',
send: 'Send for review',
submitAnyway: 'Submit anyway',
missing: (list) => 'Not delivered yet: ' + [].concat(list).join(', ') + '. You can submit anyway.',
inReview: (n) => 'In review · sent by ' + n,
someone: 'someone',
noNote: 'No note.',
backPlaceholder: 'What needs to change? (required)',
needComment: 'Write what needs to change before sending it back.',
sentBackTitle: 'Sent back: what needs to change',
needTitle: 'Write a title.',
created: (id) => id + ' was created.',
deleted: (id) => id + ' was moved to the Trash. You can restore it for 30 days.',
},
needsTitle: 'What you need to do',
needsPlaceholder: 'Write what you have to do…',
unblock: 'Done, unblock',
files: {
personal: 'In Team mode, files are uploaded and downloaded here, with versions.',
deliverablesTitle: 'deliverables, uploaded to Tasky for review',
plannedTitle: 'Planned files · code this task touches',
plannedTouched: 'Planned and touched',
plannedNotYet: 'Planned, not touched yet',
touchedUnplanned: 'Touched, not planned',
clash: (f, task, who) => '⚠ ' + f + ' is also in ' + task + ' (' + who + '). Agree who changes it first.',
clashesTitle: 'Same file in two tasks',
nobody: 'nobody yet',
none: 'Nothing delivered yet.',
noStarting: 'No starting files. Add some for whoever takes this task.',
notePlaceholder: 'Note for this upload (optional)',
drop: (mb) => 'Drop files here, or choose them. Up to ' + mb + ' MB each. The same name makes a new version.',
uploaded: (n, v) => 'Uploaded ' + n + ' (v' + v + ').',
previewNote: 'Shown as plain text: nothing in the file is run or interpreted.',
noPreview: 'This file cannot be previewed (too large or unavailable). Download it to open it.',
},
team: {
tooBig: (n, mb) => n + ' is larger than ' + mb + ' MB and was not uploaded.',
fileGone: (n) => n + ' is not available anymore.',
notConfigured: 'Team mode is not configured yet: config.js needs the Supabase address and public key. See docs/DESPLIEGUE.md.',
signInHint: 'Sign in with the email and password of your Tasky account.',
createHint: 'You need an invitation from the owner: use the email they invited and the code in your invitation link.',
shortPassword: 'Use a password with 8 or more characters.',
passwordsDiffer: 'The two passwords are not the same.',
demoBanner: 'DEMO · test data on this computer only · nothing here is real or saved when the demo stops',
demoAccounts: 'Demo accounts (click one to fill it in):',
wrongPassword: 'Your current password is not right.',
passwordSaved: 'Your new password is saved. Use it next time you sign in.',
resetHint: 'The owner gave you this link. It works once and for a limited time. Choose a new password for your Tasky account.',
notTeamBackup: 'That file is not a Tasky team backup (.zip).',
restoreNotEmpty: 'A team backup can only be restored on an empty team: no projects and nothing in the Trash.',
confirmOff: 'Your account was created, but this project asks to confirm emails. Ask the owner to turn off "Confirm email" (see docs/DESPLIEGUE.md), then sign in.',
notInvited: 'This account is not part of any team. Ask the owner for an invitation.',
badCode: (m) => 'That invitation did not work: ' + m,
offline: 'You are offline. You can look at the last data Tasky had; editing is off until the connection comes back.',
offlineNoEdit: 'You are offline: Tasky shows the last data it had, but nothing can be changed until the connection comes back.',
offlineNoCache: 'Tasky cannot reach the server and has no saved copy on this computer yet. Check your connection and try again.',
viewerNoEdit: 'You are a Viewer: you can look at everything, but not change it.',
signInFirst: 'Sign in first.',
denied: (what) => ({ import: 'Only the owner can import handoffs.', editProjects: 'Only the owner can change projects.', editTasks: 'Only the owner can create, edit or delete tasks.', approve: 'Only the owner can mark a task done. Submit it for review instead.', restore: 'Only the owner can bring projects into the team.', invite: 'Only the owner can invite people.', deleteFiles: 'Only the owner can delete file versions.', starting: 'Only the owner can add starting files.', ownAgent: 'Only the person who runs an agent can change it.', agents: 'A viewer cannot add agents.' }[what] || 'Your role cannot do that.'),
notSaved: (m) => 'That change was not saved: ' + m + '. Tasky reloaded the latest data.',
removed: 'You were removed from the team.',
welcomeTitle: 'Adds the welcome galaxy (first steps for a new person) to the project you are looking at, assigned to this person.',
welcomeAdded: (n, p) => 'Welcome galaxy added for ' + n + ' in ' + p + '.',
welcomeNoProject: 'Open a project first: the welcome galaxy goes into the project you are looking at.',
ownerOnly: 'Only the owner can change the project.',
saved: 'Saved.',
roleText: { owner: 'can do everything', member: 'works on tasks and delivers files', viewer: 'can look, not change' },
roleChanged: (n, r) => n + ' is now ' + r + '.',
removeAsk: (n) => 'Remove ' + n + ' from the team? ',
offboardIntro: (n) => 'This removes ' + n + ' from the team in one step. Choose who takes each of their open tasks; the rest become free. ' + n + ' keeps their account, but sees nothing of this team any more.',
offboardNoTasks: 'No open tasks.',
offboardAgents: (k) => k ? k + (k === 1 ? ' agent leaves' : ' agents leave') + ' the team with them.' : 'No agents.',
offboardSessions: 'They are signed out everywhere and cannot come back in without a new invitation.',
offboarded: (n, r = {}) => n + ' was removed: ' + r.reassigned + ' reassigned, ' + r.unassigned + ' left free, ' + r.agents + (r.agents === 1 ? ' agent' : ' agents') + ' removed' + (r.sessions >= 0 ? ', signed out.' : '. Their open sessions end when they expire (within an hour).'),
passwordLink: (n, when) => 'Password link for ' + n + ' (works once, until ' + when + '). It was copied; send it to them:',
passwordLinkTitle: 'Make a one-time link so this person can choose a new password. Tasky sends no emails: you send the link yourself.',
passwordChanged: 'Your password was changed.',
storage: (used, limit, pct) => 'Files use ' + used + ' of ' + limit + ' (' + pct + '%).',
storageNear: 'Close to the limit: delete old versions or projects from the Trash, or raise the plan.',
backupMine: 'Download what you see of the team as a .json file (projects, tasks, history).',
backupHint: 'Everything in one .zip: projects, workflows, tasks, history, messages, members and roles, the Trash, and every version of every file. Keep it somewhere safe. Restoring needs an empty team.',
backupProgress: (i, n) => 'Downloading files: ' + i + ' of ' + n + '…',
backupDone: (f, c = {}) => 'Downloaded ' + f + ': ' + c.projects + ' projects, ' + c.tasks + ' tasks, ' + c.events + ' history entries, ' + c.messages + ' messages, ' + c.files + ' file versions, ' + c.members + ' people.',
restoreTitle: 'Only on an empty team (no projects, nothing in the Trash): for example a new Supabase project.',
restoreProgress: (i, n) => 'Uploading files: ' + i + ' of ' + n + '…',
restored: (r = {}) => 'Backup restored: ' + r.projects + ' projects, ' + r.tasks + ' tasks, ' + r.events + ' history entries, ' + r.messages + ' messages, ' + r.files + ' file versions, ' + r.members + ' people back.',
restoredInvites: 'These people need a new invitation link (send it to them):',
removed2: (n) => n + ' was removed.',
badEmail: 'Write a valid email.',
alreadyInvited: 'that email already has an invitation',
inviteHint: 'Tasky does not send emails: after inviting, copy the link and send it yourself (chat or email). Only that email can create an account with it.',
sendLink: (e) => 'Send this link to ' + e + ':',
personalHint: 'In Personal mode, open Backup → Download backup. Then choose that file here: its projects, workflows, tasks and history come into the team intact.',
imported: (p, t, e) => 'Brought ' + p + (p === 1 ? ' project' : ' projects') + ', ' + t + ' tasks and ' + e + ' history entries into the team.',
notBackup: 'That is not a Tasky backup.',
},
universes: {
hint: 'A universe groups workflows, from any project (for example Front and Back). Renaming one renames it everywhere.',
manageTitle: 'Rename universes. To move a workflow, open its card.',
renamed: (a, b) => 'The universe ' + a + ' is now ' + b + '.',
moved: (w, u) => w + ' moved to the universe ' + u + '.',
elsewhere: (list) => 'Nothing of this project is in the universe you are looking at. Its universes: ' + list + '.',
},
live: {
title: 'Live: what is happening right now with every agent, in the order it arrives.',
hint: 'What every agent is doing, in the order it happens. Each color is the ship of the person who runs it. Click a task to go there.',
empty: 'Nothing has happened yet. When an agent works, waits or pauses, it shows up here.',
noTask: 'no task',
you: 'you', owner: 'its owner',
working: 'Working', paused: 'Paused',
waitingFor: (n) => 'Waiting for ' + n,
permission: 'needs permission', question: 'has a question',
command: 'Running a command',
reading: (n) => 'Reading ' + n + ' files',
helpers: (n) => n + (n === 1 ? ' helper' : ' helpers'),
what: (k, f) => ({ edit: f ? 'editing ' + f : 'editing a file', write: f ? 'writing ' + f : 'writing a file', read: f ? 'reading ' + f : 'reading a file', search: 'searching the code', web: 'looking something up on the web', agent: 'starting a helper', plan: 'planning', mcp: 'using a connected tool', thinking: 'thinking', question: 'asking you something', other: '' })[k] || '',
needsPermission: (n) => (n || 'An agent') + ' needs your permission',
hasQuestion: (n) => (n || 'An agent') + ' has a question for you',
agentTag: 'agent',
toolCounts: (t = {}) => Object.entries(t || {}).sort((a, b) => b[1] - a[1]).map(([k, n]) => n + ' ' + ({ read: 'reads', edit: 'edits', write: 'new files', command: 'commands', search: 'searches', web: 'web lookups', agent: 'helpers', plan: 'plans', mcp: 'connected tools', question: 'questions', other: 'other' }[k] || k)).join(', '),
sharingHint: 'Others in the team see your agents while you have Tasky open. Choose how much: only whether they work, wait or pause; also which kind of tool they use; or also the file names. Prompts, answers, file contents and commands are never shared.',
sharingSaved: (l) => 'Saved: the team sees "' + l + '" of your agents.',
},
report2: {
title: 'A report of a period, made only of facts from the history, the deliverables and the agents. Download it as .md.',
hint: 'Only facts from the history, the deliverables and the agents\' activity; nothing is invented and what is not recorded is left out. In Team, it shows what you can see in the app.',
},
update: {
title: 'New version available',
text: 'Reload when you are ready. Nothing reloads by itself.',
unsent: 'You have text that is not sent or saved yet. Reload anyway?',
},
report: {
hint: 'Downloads a small file to send to whoever helps you: the version, the browser, the mode, the bridge and the last errors. It has no task, message or file content.',
done: (f) => 'Downloaded ' + f + '. Send it to whoever helps you; it has no task, message or file content.',
},
trash: {
title: 'Deleted projects and tasks. Restore them, or delete them forever after 30 days.',
hint: (d) => 'Deleted projects and tasks stay here at least ' + d + ' days, with their files, threads and history. Restore brings everything back. After ' + d + ' days you can delete them forever.',
empty: 'The Trash is empty.',
when: (when, who, left) => 'deleted ' + (when === 'just now' ? 'just now' : when + ' ago') + (who ? ' by ' + who : '') + (left > 0 ? ' · can be deleted forever in ' + left + (left === 1 ? ' day' : ' days') : ' · can be deleted forever now'),
restored: (n) => n + ' is back.',
cannot: { exists: 'Something with the same id exists now, so it cannot come back. Delete or rename that one first.', project: 'Its project is in the Trash: restore the project first.' },
purgeAsk: (n) => 'Delete “' + n + '” forever, with its files and threads? This cannot be undone. ',
purged: (n) => n + ' was deleted forever.',
purgeFailed: 'It could not be deleted forever. Try again.',
expired: (n) => n + (n === 1 ? ' item has' : ' items have') + ' been in the Trash for more than 30 days. Open Trash (under Projects) to delete them forever, or keep them.',
moved: (n) => n + ' was moved to the Trash. You can restore it for 30 days.',
},
search: {
placeholder: 'Search tasks, workflows, projects',
label: 'Search tasks, workflows and projects by id, title or mission. Shortcut: / or Ctrl+K',
none: 'Nothing matches.',
project: 'Project',
},
filters: {
label: 'Filter the map',
title: 'Click to dim everything else on the map. Click again to remove it.',
clearTitle: 'Click to remove this filter.',
doing: 'In progress', needs: 'Needs you', agents: 'With agents', notdone: 'Not done',
showing: (label, n, total) => 'Showing ' + label + ': ' + n + ' of ' + total,
clear: 'Clear',
},
tour: {
title: 'A walk through Universe: galaxy by galaxy, stopping a few seconds on every task that is not done. Any drag or key stops it.',
nothing: 'Every task is done: there is nothing to visit.',
finished: 'Tour finished.',
},
timeline: {
name: 'Timeline',
label: 'Timeline: move it to see the universe as it was at a past moment',
today: 'Today',
viewing: (when) => 'Viewing ' + when,
readOnly: 'This is the past: you can look, not edit.',
back: 'Back to today',
noEdit: 'You are viewing the past. Go back to today to change it.',
cardNote: (when) => 'As it was on ' + when + '. Go back to today to change it.',
},
list: { hint: 'The whole project as a simple list. Click a task to open its card; change its status there.' },
now: {
hint: 'Every project at a glance. Click a row to go to that task in Universe.',
recent: 'Recently done',
noAgent: 'no agent',
noDoing: 'Nothing in progress right now.',
noDone: 'Nothing finished in the last 7 days.',
yesterday: 'Yesterday',
yesterdayNone: 'Nothing recorded.',
yesterdayMore: (n) => '… and ' + n + ' earlier',
},
steps: {
title: 'Welcome to the team',
intro: 'Your work in four steps:',
list: [
['1. Take a task', 'Open a task in To do and press "I\'m on it".'],
['2. Download the files', 'In the task card, under Files, download what is there.'],
['3. Upload your version', 'Press "Upload files" in the same card. Each upload is a new version.'],
['4. Submit it for review', 'Press "Submit for review" with a short note. The owner approves it or sends it back.'],
],
ok: 'Got it',
},
since: {
title: 'Since last time',
ago: (t) => 'You were here ' + t + ' ago',
ok: 'Close',
done: (n) => n + (n === 1 ? ' task done' : ' tasks done'),
needs: (n) => n + (n === 1 ? ' new task waits for you' : ' new tasks wait for you'),
came: (names) => (names.length === 1 ? names[0] + ' arrived' : names.length + ' agents arrived: ' + names.join(', ')),
left: (names) => (names.length === 1 ? names[0] + ' left' : names.length + ' agents left: ' + names.join(', ')),
},
history: {
hint: 'Every change, newest first. You means a click; Handoff means an imported file; otherwise, the agent. Click a line to go there.',
empty: 'Nothing here yet.',
gone: 'That project no longer exists.',
archived: (n) => n + ' is archived. Bring it back from “Archived” to see it.',
},
inbox: {
name: 'Waiting on you',
title: 'Tasks in every project that wait for something from you. Click one to go to it. Click the title to fold or unfold the list.',
empty: 'Nothing waits for you right now.',
noText: 'No note yet: open the task and write what is needed',
agent: (n) => n + ' is waiting',
review: (n) => 'ready for review' + (n ? ' · ' + n : ''),
reviewTitle: (title) => 'Review: ' + title,
open: (n, wf) => 'holds ' + n + ' open in ' + wf,
age: 'oldest first',
},
projectStatus: { active: 'Active', paused: 'Paused', done: 'Done' },
agentStatus: { working: 'Working', waiting: 'Waiting' },
touchTitle: 'Touch anything inside the map',
howItWorks: 'Click a task to read its mission, change its status or give it an agent. Click a planet or the center of a flower to see its workflow and its tasks. Each finished task raises its workflow on the right. Tasks that need you wait at the top left.',
projectTitle: 'Rename, archive, delete or see the handoff of this project.',
noMission: 'This workflow has no mission yet.',
noProjects: 'No projects yet. Drop a handoff to start.',
noWorkflows: 'Workflows appear here when a project is open.',
noHandoffYet: 'No handoff yet? Give the template to your agent and it will send one back.',
migrate: {
title: 'Bring your projects from the previous version',
text: 'Tasky now opens as an app at this address. Projects saved while you opened index.html from the disk live in that other place and do not appear here by themselves.',
steps: [
'Open src/index.html with a double click, as before.',
'Click Backup → Download backup. A tasky-backup-….json file is saved.',
'Come back to this window and click Restore backup below, then pick that file.',
],
button: 'Restore backup',
},
noProjectsToExport: 'There are no projects to export. Drop a handoff first.',
exportHint: 'One .tasky.md per project, with today’s state.',
personalStorage: (used, limit) => 'Saved in this browser: ' + used + ' of about ' + limit + '.',
personalStorageNear: 'Close to what the browser allows: download a backup and delete old projects from the Trash.',
backupHint: 'All of Tasky in one file: projects, tasks, progress history, agents and uploaded views. Use it to move to another browser, or from index.html opened from the disk to the Tasky app window.',
handoffHint: 'This is the project as a .tasky.md with today’s state, exactly as Export writes it.',
archiveHint: 'Archived projects leave the list but keep all their history.',
oldLayout: 'this view was made for the old contract (version 1) and cannot show workflows. Ask for a version 2 view; the example shows how.',
stalled: (name) => 'The view “' + name + '” did not answer. Pick another: ',
agentPhrase: 'When you finish, give me the project status as a .tasky.md file with this exact structure. Do not rename headings or columns; one file per project. Group tasks into workflows, each with a one-line mission. Use the statuses todo, doing, needs-you, blocked or done. Use needs-you when a task waits for something from me, and write in the needs column, in one line, what I have to do. In the files column, list for every task the code files it will touch (paths relative to the repository, comma-separated).',
layoutPhrase: 'Make me a view for Tasky: a single .tasky-layout.html file, no network and no libraries, that follows the attached example (meta tasky-layout content 2, a title, and the messages tasky:ready, task, select, hover and height). Each workflow should look like: ',
storage: {
empty: null,
migrated: 'Your saved data was upgraded to the new format: lanes became workflows and progress is now computed from tasks.',
corrupt: 'The saved data was damaged, so Tasky opened empty. A copy was kept under "tasky:v2:damaged".',
unavailable: 'This browser does not allow saving: Tasky opened empty and changes will not last.',
save: 'Changes could not be saved in this browser.',
},
toast: {
title: 'A new agent appeared',
started: 'Started just now on this computer',
question: 'What is this agent working on?',
later: 'Decide later',
ok: 'Save name',
noFolder: 'unknown folder',
pickProject: 'Pick a project:',
otherProjects: (name) => '“' + name + '” has no open tasks. These projects do:',
noOpenAnywhere: 'No project has open tasks right now. Just give this agent a name.',
linkQuestion: 'This folder belongs to:',
linkPick: 'Choose a project…',
linked: (p) => 'This folder belongs to ' + p + '. The agent got a short Tasky briefing when it started.',
linkedNow: (p) => 'Folder linked to ' + p + '. Next time a Claude Code session starts here, the agent gets a short Tasky briefing.',
},
agents: {
none: 'No agents yet. Create one, or let the bridge find your Claude Code sessions.',
noneToAssign: 'No agents yet. Create one with “New agent” in the Agents panel.',
bridgeControls: 'The bridge sets this status from its Claude Code conversation: Working while it answers, Waiting while it is open. When the conversation closes, the agent leaves and comes back when you resume it.',
manualControls: 'Click to change: Working ↔ Waiting.',
back: (n) => n + ' is back',
seeYou: (n) => 'See you later, ' + n,
seeYouTeam: (a, n) => 'See you later, ' + a + ' (' + n + ' closed Tasky)',
ownerOf: (n) => 'Runs on ' + n + "'s computer",
theirs: (n) => n + "'s agent. Only " + n + ' can change it.',
foldersTitle: 'Folders linked to a project. When a Claude Code session starts in one of them, the agent gets a short Tasky briefing.',
foldersHint: 'When a Claude Code session starts in a linked folder (or inside it), the agent gets a short briefing: the project, your open tasks and how to tell Tasky what it does. Turn it off per folder.',
briefingTitle: 'Turn the session briefing on or off for this folder.',
briefingHint: (cwd, p) => 'Folder: ' + cwd + (p ? ' · project: ' + p : '') + '. This is the text a new Claude Code session receives when it starts here. It needs the bridge running and Tasky open; otherwise the session starts as usual, with nothing.',
briefingTemplate: 'Placeholders: {{person}}, {{project}}, {{tasks}} (your open tasks), {{tasky}} (the bridge command) and {{rules}}. Keep it short: it must fit on one screen.',
briefingNone: 'Nothing: this folder is not linked to a project, or its briefing is off.',
briefingSaved: 'Template saved. New sessions get the new text.',
knownTitle: 'Agents Tasky remembers from Claude Code conversations that are closed now. They come back by themselves when you resume the conversation.',
knownHint: 'Closed conversations Tasky remembers. Rename them, or forget them: a forgotten one is treated as new if it comes back.',
forgot: (n) => n + ' was forgotten. If that conversation comes back, Tasky will ask its name again.',
deleteBridge: (n) => 'Delete “' + n + '”? Its Claude Code session can be ended too.',
ending: (n) => n + ' was deleted. Ending its Claude Code session…',
ended: (n) => n + ' was deleted and its Claude Code session was ended.',
endedClosed: (n) => n + ' was deleted, its Claude Code session was ended and its terminal window was closed.',
endedLeftOpen: (n) => n + ' was deleted and its Claude Code session was ended. Its terminal window stays open: Tasky could not be sure it was used only for Claude Code.',
endFailed: (n, why) => n + ' was deleted, but its Claude Code session could not be ended' + (why ? ' (' + why + ')' : '') + '. It will not come back here.',
deletedOnly: (n) => n + ' was deleted. Its Claude Code session keeps running and will not come back here.',
quitTitle: 'Turn off the Tasky bridge on this computer. You will be asked first.',
quitAsk: 'Turn off the bridge? Your Claude Code sessions stop showing up here until you open Tasky again with its shortcut.',
quitWindow: 'This window keeps working until you close it.',
quitDone: 'The bridge is off. Tasky is in manual mode; open Tasky with its shortcut to turn the bridge on again.',
quitFailed: 'The bridge could not be turned off. Close its node.exe in the Task Manager instead.',
},
tools: {
bridge: { name: 'Bridge', text: 'When the Tasky bridge runs on this computer (start it with “Iniciar Tasky.bat”), each Claude Code conversation appears here by itself: Working while it answers, Waiting while it is open. When it closes the agent leaves, and it comes back with its name and task when you resume the conversation. Off means manual mode; nothing breaks.' },
newAgent: { name: 'New agent', text: 'Create an agent with a name, an icon and a color. Then assign it to a task from the task card.' },
drop: { name: 'Drop handoff', text: 'Bring in a .tasky.md from your agent. Click to pick one or more files; you can also drop them anywhere on the window or paste the text with Ctrl+V.' },
template: { name: 'Template for your agent', text: 'The blank .tasky.md your agent fills in, with an instruction ready to paste. Click to see, copy or download it.' },
export: { name: 'Export', text: 'Download your projects as .tasky.md files to give them back to your agent. With several projects you choose which.' },
backup: { name: 'Backup', text: 'Download all of Tasky in one .json file (projects, history, agents and views), or restore one.' },
help: { name: 'Help', text: 'The four steps of Tasky and everything you can touch.' },
team: { name: 'Team', text: 'Invite people, change their role or remove them, and bring your projects from Personal mode.' },
chat: { name: 'Chat', text: 'The chat of this project, live. Plain text; @name mentions someone.' },
history: { name: 'History', text: 'Every change, newest first: statuses, handoffs, names, archives, agents. Who did it, when, and Undo where it is safe.' },
archived: { name: 'Archived', text: 'Projects you archived. Click to see them and bring them back.' },
},
views: {
universe: 'Universe: one galaxy per workflow, one planet per task, in 3D. Drag to look around; click a star or a planet.',
orbit: 'Orbit: one solar system per workflow, with its tasks around a planet that shows how many are left.',
garden: 'Garden: one flower per workflow; every petal is a task.',
now: 'Now: every project at a glance. What waits for you, what is in progress and what got done this week.',
list: 'List: the whole project as a simple list, by workflow. For when you are in a hurry.',
own: 'A view you uploaded. Click to use it; the ✕ next to it deletes it.',
add: 'Upload your own .tasky-layout.html view, or drop it on the window. Start from the example.',
},
panel: {
title: 'Help',
intro: 'Tasky reads the handoffs your agents write and shows where each project stands.',
steps: [
['Give the template to your agent', 'Use “Template for your agent”: it sends back one .tasky.md per project, with workflows and tasks.'],
['Drop the handoff', 'Drag it onto the window, pick it with “Drop handoff” or paste it with Ctrl+V.'],
['Look and touch', 'Click a task to move it forward. Each workflow fills up by itself as its tasks get done.'],
['Save or send back', 'Export the updated .tasky.md for your agent, or make a backup of everything.'],
],
touchTitle: 'What you can touch',
touch: [
'A task: moves it forward (To do → In progress → Done).',
'The status buttons in the card: put a task in any status. Needs you means it waits for something from you; Blocked is a technical problem.',
'Waiting on you, at the top left: every task that needs you, in every project. Click one to go to it.',
'A workflow: shows its mission and its tasks.',
'The project name: rename, archive, delete or see its handoff.',
'Universe, Orbit and Garden: change the view at any time; your data stays the same.',
'In Universe a click selects a planet; change its status from this card.',
],
ownTitle: 'Your own view',
ok: 'Got it',
},
};
function open() {
const P = TEXT.panel;
const root = Shell.openPanel('help', P.title);
const steps = Shell.el('ol', { className: 'steps' });
P.steps.forEach(([title, body], i) => steps.append(Shell.el('li', {}, Shell.el('span', { className: 'n', textContent: i + 1, ariaHidden: 'true' }),
Shell.el('div', {}, Shell.el('strong', { textContent: title }), Shell.el('p', { textContent: body })))));
const touch = Shell.el('ul', {});
for (const line of P.touch) touch.append(Shell.el('li', { textContent: line }));
const legend = Shell.el('div', { className: 'pn-actions' });
for (const st of Store.statusList()) legend.append(Shell.el('span', { className: 'pn-item' }, Shell.ico(st, '#2EC4B6'), TEXT.status[st]));
const ok = Shell.btn(P.ok, () => Shell.closePanel(), { className: 'primary' });
root.append(Shell.el('p', { textContent: P.intro }), steps, Shell.el('h3', { textContent: P.touchTitle }), touch, legend,
Shell.el('h3', { textContent: P.ownTitle }), Shell.el('p', { className: 'phrase', textContent: TEXT.layoutPhrase + '…' }),
Shell.el('div', { className: 'pn-actions' }, Shell.btn('Download the example view', () => Shell.downloadExampleLayout()), Shell.btn('Copy the request', async () => Shell.copied(await Shell.copyText(TEXT.layoutPhrase), 'Request'))),
Shell.el('h3', { textContent: 'Something wrong?' }), Shell.el('p', { textContent: TEXT.report.hint }),
Shell.el('div', { className: 'pn-actions' }, Shell.btn('Report a problem', () => Shell.reportProblem(), { data: { key: 'report-problem' } })),
Shell.el('div', { className: 'pn-actions' }, ok));
ok.focus();
}
return { TEXT, open };
})();
if (typeof document !== 'undefined' && document.getElementById('app')) Shell.start();