export function renderHtml() {
return `
Work Hub
Building a plan for your available time…
Select all shown
0 selected
Select stale & older
🗑 Clean up selected
Tracked (0 )
Discover your repos
Open this tab to load your repositories.
Data quality (0 warnings)
`;
}
function clientScript() {
return `
const state = { model: null, tab: "focus", loading: false, discover: null, discoverLoading: false,
filters: { text: "", repo: "", sort: "priority", chips: new Set(), repoText: "", repoSort: "attention", discoverText: "" },
cleanup: { text: "", repo: "", sort: "oldest", buckets: new Set(), selected: new Set() },
onboarding: { selected: new Set() } };
const $ = (id) => document.getElementById(id);
const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&":"&","<":"<",">":">",'"':""","'":"'" }[c]));
const CHIP_DEFS = [
{ id: "open-pr", label: "Open PRs", kind: "pr" },
{ id: "open-issue", label: "Open issues", kind: "issue" },
{ id: "needs-review", label: "Needs review" },
{ id: "human", label: "Human authored" },
{ id: "failing-checks", label: "Failing checks" },
{ id: "assigned", label: "Assigned to me" },
{ id: "stale-release", label: "Stale release" },
{ id: "failing-deploy", label: "Failed deploy" },
{ id: "active-session", label: "Active session" },
{ id: "triage", label: "Session triage" },
];
const CHIP_KINDS = Object.fromEntries(CHIP_DEFS.filter((c) => c.kind).map((c) => [c.id, c.kind]));
async function api(path, options = {}) {
const res = await fetch(path, { ...options, headers: { "Content-Type": "application/json", ...(options.headers || {}) } });
const json = await res.json();
if (!res.ok) throw new Error(json.error || "Request failed");
return json;
}
function setLoading(v) {
state.loading = v;
$("refresh").innerHTML = v ? ' Loading' : "↻ Refresh";
$("refresh").disabled = v;
}
async function load(force = false) {
setLoading(true);
try {
state.model = force ? await api("/api/refresh", { method: "POST" }) : await api("/api/state");
render();
} catch (e) { $("subtitle").textContent = e.message; }
finally { setLoading(false); }
}
async function saveFocus() {
setLoading(true);
try {
state.model = await api("/api/focus", { method: "POST", body: JSON.stringify({ mood: $("mood").value, minutes: Number($("minutes").value), busyness: $("busyness").value, focusIntent: $("focusIntent").value }) });
render();
} catch (e) { $("subtitle").textContent = e.message; }
finally { setLoading(false); }
}
function switchTab(tab) {
state.tab = tab;
document.querySelectorAll(".tab").forEach((el) => el.classList.toggle("active", el.dataset.tab === tab));
document.querySelectorAll(".view").forEach((el) => el.classList.toggle("active", el.dataset.view === tab));
if (tab === "manage" && !state.discover && !state.discoverLoading) loadDiscover(false);
if (tab === "manage") renderManage();
}
function render() {
const m = state.model; if (!m) return;
$("subtitle").innerHTML = "Updated " + new Date(m.generatedAt).toLocaleTimeString() + (m.currentLogin ? " · @" + esc(m.currentLogin) : "") + " · " + m.summary.repoCount + " repos";
$("mood").value = m.preferences.mood;
$("minutes").value = String(m.preferences.minutes);
$("busyness").value = m.preferences.busyness;
$("focusIntent").value = m.preferences.focusIntent || "balanced";
$("workCount").textContent = m.focusItems.length;
$("repoCount").textContent = m.repos.length;
$("cleanupCount").textContent = (m.sessionInventory || []).length;
$("errorCount").textContent = m.errors.length;
renderRepoFilter(m.repos);
renderOnboarding();
renderStats(m.summary);
renderPlan(m);
renderChips();
renderWork();
renderRepos();
renderCleanup();
renderErrors(m.errors);
if (state.tab === "manage") renderManage();
}
function discoverItems() {
if (!state.discover) return [];
const repos = (state.discover.repos || []).map((r) => ({ ...r, source: "github" }));
const projects = (state.discover.projects || []).map((r) => ({ ...r, source: "local" }));
const seen = new Set();
const items = [];
for (const item of [...projects, ...repos]) {
const key = (item.slug || "").toLowerCase();
if (!key || seen.has(key)) continue;
seen.add(key);
items.push(item);
}
return items;
}
function encodedRepo(item) {
return JSON.stringify({ slug: item.slug, path: item.path || null });
}
function renderOnboarding() {
const panel = $("onboardingPanel");
const m = state.model; if (!m || m.onboarded) { panel.innerHTML = ""; return; }
const items = discoverItems().slice(0, 12);
const picks = items.length ? '' + items.map((r, i) => {
const id = "onboardPick" + i;
const value = encodedRepo(r);
return '
' + esc(r.slug) + ' ' + (r.source === "local" ? ' local project ' : ' GitHub ') + '' + esc(r.description || r.path || "No description") + '
';
}).join("") + '
' : 'Discover repositories and local projects to start picking what this hub should track.
';
panel.innerHTML = 'Set up your Work Hub
Pick GitHub repositories or local projects to track. Your choices stay local under ~/.copilot/extensions/work-hub/artifacts.
' + (state.discoverLoading ? "Discovering…" : "Discover repos & projects") + ' Manage manually Start tracking selected (' + state.onboarding.selected.size + ')
' + picks + '
';
$("onboardDiscover").addEventListener("click", () => loadDiscover(true));
$("onboardManage").addEventListener("click", () => switchTab("manage"));
$("onboardTrack").addEventListener("click", trackSelectedOnboarding);
panel.querySelectorAll("[data-onboard-pick]").forEach((el) => el.addEventListener("change", () => {
if (el.checked) state.onboarding.selected.add(el.dataset.onboardPick);
else state.onboarding.selected.delete(el.dataset.onboardPick);
renderOnboarding();
}));
}
function renderStats(s) {
const cards = [
{ v: s.repoCount, l: "repos", tab: "repos" },
{ v: s.needsHumanPrCount, l: "PRs need review", cls: s.needsHumanPrCount ? "alert" : "", chip: "needs-review" },
{ v: s.humanIssueCount, l: "human issues", cls: s.humanIssueCount ? "warn" : "", chip: "human" },
{ v: s.openPrCount, l: "open PRs", chip: "open-pr" },
{ v: s.openIssueCount, l: "open issues", chip: "open-issue" },
{ v: s.staleReleaseCount, l: "stale releases", cls: s.staleReleaseCount ? "warn" : "", chip: "stale-release" },
{ v: s.failedDeployCount, l: "failed deploys", cls: s.failedDeployCount ? "alert" : "", chip: "failing-deploy" },
{ v: s.activeSessionCount, l: "recent sessions", chip: "active-session" },
];
$("stats").innerHTML = cards.map((c, i) => '' + esc(c.v) + ' ' + esc(c.l) + '
').join("");
$("stats").querySelectorAll(".stat").forEach((el) => el.addEventListener("click", () => {
const c = cards[Number(el.dataset.i)];
if (c.tab) { switchTab(c.tab); return; }
if (c.chip) { state.filters.chips = new Set([c.chip]); renderChips(); renderWork(); switchTab("work"); }
}));
}
function renderPlan(m) {
const total = m.recommendations.reduce((a, r) => a + r.plannedMinutes, 0);
const intentLabels = { balanced: "balanced priorities", prs: "PRs and reviews", "new-code": "new code", "issue-triage": "issue triage", maintenance: "maintenance" };
const intent = intentLabels[m.preferences.focusIntent || "balanced"] || "balanced priorities";
$("planSummary").innerHTML = m.recommendations.length
? "Given you're " + esc(m.preferences.mood) + " with " + esc(m.preferences.minutes) + " min (" + esc(m.preferences.busyness) + ") and want " + esc(intent) + " , here's what to do and how (~" + total + " min):"
: "No focus items right now — you're all caught up. 🎉";
$("focusList").innerHTML = m.recommendations.length
? m.recommendations.map((it, i) => itemHtml(it, { rank: i + 1, planned: it.plannedMinutes })).join("")
: 'Nothing needs your attention. Enjoy the calm.
';
wireRepoLinks($("focusList"));
wireItemCards($("focusList"));
}
function wireRepoLinks(root) {
root.querySelectorAll(".repo-link[data-detail]").forEach((el) => el.addEventListener("click", (e) => { e.stopPropagation(); openRepoDetail(el.dataset.detail); }));
}
function itemHtml(it, opts = {}) {
const badges = (it.tags || []).map((t) => '' + esc(t.replace(/-/g, " ")) + ' ').join("");
const rank = opts.rank ? '#' + opts.rank + ' ' : "";
const right = opts.planned ? '' + esc(opts.planned) + ' min ' : '' + esc(it.minutes) + ' min ';
const link = it.url ? '' + esc(it.title) + ' ' : esc(it.title);
const actionable = (it.kind === "pr" || it.kind === "issue") && it.number;
const jumpable = it.kind === "session" && it.sessionId;
const attrs = actionable ? ' class="card item actionable kind-' + esc(it.kind) + '" data-item-repo="' + esc(it.repo) + '" data-item-type="' + esc(it.kind) + '" data-item-number="' + esc(it.number) + '"' : ' class="card item kind-' + esc(it.kind) + '"';
const guidance = opts.rank && (it.what || it.how) ? '' + (it.what ? '
What: ' + esc(it.what) + '
' : "") + (it.how ? '
How: ' + esc(it.how) + '
' : "") + '
' : "";
return '' +
'' + esc(it.kind) + ' ' + link + '
' + rank + right + '
' +
'' + esc(it.repo) + ' · ' + esc(it.detail) + '
' +
(badges ? '' + badges + '
' : "") +
(it.reasons && it.reasons.length ? 'Why now: ' + esc(it.reasons.join(", ")) + (opts.rank ? '' : ' · score ' + esc(it.score)) + '
' : "") +
guidance +
(actionable ? 'Click to view & act →
' : "") +
(jumpable ? '↪ Jump to session
' : "") +
' ';
}
function wireItemCards(root) {
root.querySelectorAll(".item.actionable").forEach((el) => el.addEventListener("click", (e) => {
if (e.target.closest("a, .repo-link")) return;
openItemDetail(el.dataset.itemRepo, el.dataset.itemType, el.dataset.itemNumber);
}));
root.querySelectorAll(".jump-btn").forEach((el) => el.addEventListener("click", (e) => { e.stopPropagation(); jumpToSession(el); }));
}
async function jumpToSession(btn) {
const original = btn.textContent;
btn.disabled = true; btn.textContent = "Jumping…";
try {
const res = await api("/api/session/jump", { method: "POST", body: JSON.stringify({ sessionId: btn.dataset.jump, repo: btn.dataset.jumpRepo, branch: btn.dataset.jumpBranch }) });
btn.textContent = "✓ " + (res.message || "Requested");
} catch (e) {
btn.textContent = "⚠ " + e.message; btn.disabled = false;
setTimeout(() => { btn.textContent = original; }, 3000);
}
}
function renderRepoFilter(repos) {
const cur = state.filters.repo;
$("repoFilter").innerHTML = 'All repos ' + repos.map((r) => '' + esc(r.slug) + ' ').join("");
$("repoFilter").value = cur;
}
function renderChips() {
$("chips").innerHTML = CHIP_DEFS.map((c) => ' ' + esc(c.label) + ' ').join("") +
(state.filters.chips.size ? '✕ Clear ' : "");
$("chips").querySelectorAll(".chip").forEach((el) => el.addEventListener("click", () => {
const id = el.dataset.chip;
if (id === "__clear") state.filters.chips.clear();
else if (state.filters.chips.has(id)) state.filters.chips.delete(id);
else state.filters.chips.add(id);
renderChips(); renderWork();
}));
}
function renderWork() {
const m = state.model; if (!m) return;
const f = state.filters;
let items = m.focusItems.slice();
if (f.repo) items = items.filter((it) => it.repo === f.repo);
if (f.text) { const q = f.text.toLowerCase(); items = items.filter((it) => (it.title + " " + it.repo + " " + it.detail).toLowerCase().includes(q)); }
if (f.chips.size) {
const kindChips = [...f.chips].filter((c) => CHIP_KINDS[c]).map((c) => CHIP_KINDS[c]);
const tagChips = [...f.chips].filter((c) => !CHIP_KINDS[c]);
if (kindChips.length) items = items.filter((it) => kindChips.includes(it.kind));
if (tagChips.length) items = items.filter((it) => tagChips.every((c) => (it.tags || []).includes(c)));
}
if (f.sort === "recent") items.sort((a, b) => (a.updatedAgeDays ?? 999) - (b.updatedAgeDays ?? 999));
else if (f.sort === "repo") items.sort((a, b) => a.repo.localeCompare(b.repo) || b.score - a.score);
else items.sort((a, b) => b.score - a.score);
$("workList").innerHTML = items.length ? items.map((it) => itemHtml(it)).join("") : 'No work matches these filters.
';
wireRepoLinks($("workList"));
wireItemCards($("workList"));
}
const CLEANUP_BUCKETS = [
{ id: "fresh", label: "Fresh (<2d)" },
{ id: "aging", label: "Aging (2–7d)" },
{ id: "stale", label: "Stale (7–30d)" },
{ id: "ancient", label: "Ancient (30d+)" },
{ id: "orphaned", label: "Archived (worktree gone)" },
];
function cleanupInventory() {
const m = state.model; if (!m) return [];
return (m.sessionInventory || []).slice();
}
function cleanupFiltered() {
const c = state.cleanup;
let items = cleanupInventory();
if (c.repo) items = items.filter((s) => s.repository === c.repo);
if (c.text) { const q = c.text.toLowerCase(); items = items.filter((s) => (s.summary + " " + s.repository + " " + s.branch + " " + s.cwd).toLowerCase().includes(q)); }
if (c.buckets.size) items = items.filter((s) => c.buckets.has(s.orphaned ? "orphaned" : s.bucket) || (c.buckets.has("orphaned") && s.orphaned));
if (c.sort === "newest") items.sort((a, b) => (a.ageDays ?? 1e9) - (b.ageDays ?? 1e9));
else if (c.sort === "repo") items.sort((a, b) => a.repository.localeCompare(b.repository) || (b.ageDays ?? 0) - (a.ageDays ?? 0));
else items.sort((a, b) => (b.ageDays ?? -1) - (a.ageDays ?? -1));
return items;
}
function renderCleanup() {
const m = state.model; if (!m) return;
const all = cleanupInventory();
// repo filter options
const repos = [...new Set(all.map((s) => s.repository))].sort();
const sel = state.cleanup.repo;
$("cleanupRepo").innerHTML = 'All repos (' + all.length + ') ' + repos.map((r) => '' + esc(r) + ' ').join("");
// stats
const s = m.summary;
const orphaned = all.filter((x) => x.orphaned).length;
const ancient = all.filter((x) => x.bucket === "ancient").length;
const cards = [
{ v: all.length, l: "total sessions" },
{ v: s.staleSessionCount ?? 0, l: "stale or older", cls: (s.staleSessionCount ? "warn" : "") },
{ v: ancient, l: "30d+ ancient", cls: ancient ? "warn" : "" },
{ v: orphaned, l: "archived (worktree gone)", cls: orphaned ? "alert" : "" },
];
$("cleanupStats").innerHTML = cards.map((c) => '' + esc(c.v) + ' ' + esc(c.l) + '
').join("");
// bucket chips
const cnt = (id) => all.filter((x) => id === "orphaned" ? x.orphaned : x.bucket === id).length;
$("cleanupChips").innerHTML = CLEANUP_BUCKETS.map((b) => ' ' + esc(b.label) + ' ' + cnt(b.id) + ' ').join("") +
(state.cleanup.buckets.size ? '✕ Clear ' : "");
$("cleanupChips").querySelectorAll(".chip").forEach((el) => el.addEventListener("click", () => {
const id = el.dataset.cbucket;
if (id === "__clear") state.cleanup.buckets.clear();
else if (state.cleanup.buckets.has(id)) state.cleanup.buckets.delete(id);
else state.cleanup.buckets.add(id);
renderCleanup();
}));
// prune stale selections
const validIds = new Set(all.map((x) => x.id));
for (const id of [...state.cleanup.selected]) if (!validIds.has(id)) state.cleanup.selected.delete(id);
// list
const items = cleanupFiltered();
$("cleanupList").innerHTML = items.length ? items.map(cleanupRowHtml).join("") : 'No sessions match these filters. 🎉
';
wireCleanupRows();
// select-all reflects shown selection
const shownIds = items.map((x) => x.id);
$("cleanupAll").checked = shownIds.length > 0 && shownIds.every((id) => state.cleanup.selected.has(id));
updateCleanupBar();
}
function cleanupRowHtml(sn) {
const checked = state.cleanup.selected.has(sn.id) ? " checked" : "";
const archived = sn.orphaned;
const badge = archived ? '📦 archived ' : '' + esc(sn.bucket) + ' ';
const where = archived ? "worktree removed" : (sn.isWorktree ? "worktree" : "main checkout");
const openBtn = archived
? '↪ Open '
: '↪ Open ';
const menu = '' +
'⋯ ' +
'' +
'
';
return '' +
'
' +
'
' +
'
' + esc(sn.summary) + '
' +
'
' + esc(sn.repository) + (sn.branch ? ' · ' + esc(sn.branch) + '' : "") + ' · ' + where + ' · last active ' + esc(sn.ageLabel) + ' ago
' +
'
' +
'
' + badge + openBtn + menu + '
' +
'
';
}
function closeCleanupMenus() {
document.querySelectorAll(".crow-menu.open").forEach((el) => el.classList.remove("open"));
}
function wireCleanupRows() {
$("cleanupList").querySelectorAll("[data-cselect]").forEach((el) => el.addEventListener("change", () => {
const id = el.dataset.cselect;
if (el.checked) state.cleanup.selected.add(id); else state.cleanup.selected.delete(id);
el.closest(".cleanup-row").classList.toggle("selected", el.checked);
updateCleanupBar();
const items = cleanupFiltered(); const shownIds = items.map((x) => x.id);
$("cleanupAll").checked = shownIds.length > 0 && shownIds.every((x) => state.cleanup.selected.has(x));
}));
const doJump = (el) => { el.dataset.jump = el.dataset.cjump || el.dataset.cjump2; jumpToSession(el); };
$("cleanupList").querySelectorAll("[data-cjump]").forEach((el) => el.addEventListener("click", () => doJump(el)));
$("cleanupList").querySelectorAll("[data-cjump2]").forEach((el) => el.addEventListener("click", () => { closeCleanupMenus(); doJump(el); }));
$("cleanupList").querySelectorAll("[data-cmenu]").forEach((el) => el.addEventListener("click", (e) => {
e.stopPropagation();
const menu = $("cleanupList").querySelector('[data-menu-for="' + CSS.escape(el.dataset.cmenu) + '"]');
const wasOpen = menu.classList.contains("open");
closeCleanupMenus();
if (!wasOpen) menu.classList.add("open");
}));
$("cleanupList").querySelectorAll("[data-cdel]").forEach((el) => el.addEventListener("click", () => { closeCleanupMenus(); deleteOneSession(el.dataset.cdel); }));
$("cleanupList").querySelectorAll("[data-copybranch]").forEach((el) => el.addEventListener("click", () => {
navigator.clipboard?.writeText(el.dataset.copybranch);
el.textContent = "✓ Copied"; setTimeout(() => { el.textContent = "⧉ Copy branch name"; closeCleanupMenus(); }, 900);
}));
}
async function requestSessionCleanup(list, btn, original) {
btn.disabled = true; btn.innerHTML = ' Requesting…';
try {
const payload = { sessions: list.map((s) => ({ id: s.id, repo: s.repository, branch: s.branch, summary: s.summary, ageLabel: s.ageLabel, archived: s.orphaned })) };
const res = await api("/api/session/cleanup", { method: "POST", body: JSON.stringify(payload) });
btn.innerHTML = "✓ " + (res.message || "Requested");
list.forEach((s) => state.cleanup.selected.delete(s.id));
setTimeout(() => { load(true); }, 500);
} catch (e) {
btn.disabled = false; btn.innerHTML = original;
$("cleanupSelInfo").textContent = e.message;
}
}
function deleteOneSession(id) {
const sn = cleanupInventory().find((s) => s.id === id);
if (!sn) return;
if (!confirm('Clean up this session?\\n\\n' + sn.repository + (sn.branch ? ' · ' + sn.branch : '') + '\\n"' + sn.summary + '"' + (sn.orphaned ? '\\n\\n(Already archived — this removes the leftover record/worktree.)' : ''))) return;
requestSessionCleanup([sn], $("cleanupDelete"), $("cleanupDelete").innerHTML);
}
function updateCleanupBar() {
const n = state.cleanup.selected.size;
$("cleanupSelInfo").textContent = n + " selected";
$("cleanupDelete").disabled = n === 0;
$("cleanupDelete").innerHTML = n ? "🗑 Clean up " + n + " selected" : "🗑 Clean up selected";
}
async function deleteSelectedSessions() {
const all = cleanupInventory();
const chosen = all.filter((s) => state.cleanup.selected.has(s.id));
if (!chosen.length) return;
requestSessionCleanup(chosen, $("cleanupDelete"), $("cleanupDelete").innerHTML);
}
function renderRepos() {
const m = state.model; if (!m) return;
const f = state.filters;
let repos = m.repos.slice();
if (f.repoText) { const q = f.repoText.toLowerCase(); repos = repos.filter((r) => (r.slug + " " + (r.description || "") + " " + (r.language || "")).toLowerCase().includes(q)); }
const toneRank = { danger: 0, attention: 1, active: 2, neutral: 3, good: 4, muted: 5 };
if (f.repoSort === "release") repos.sort((a, b) => (b.releaseAgeDays ?? 1e9) - (a.releaseAgeDays ?? 1e9));
else if (f.repoSort === "work") repos.sort((a, b) => (b.openPrCount + b.openIssueCount) - (a.openPrCount + a.openIssueCount));
else if (f.repoSort === "name") repos.sort((a, b) => a.slug.localeCompare(b.slug));
else repos.sort((a, b) => (toneRank[a.status.tone] ?? 9) - (toneRank[b.status.tone] ?? 9) || b.needsHumanPrCount - a.needsHumanPrCount);
$("repoGrid").innerHTML = repos.map(repoHtml).join("");
$("repoGrid").querySelectorAll("[data-detail]").forEach((el) => el.addEventListener("click", (e) => { e.stopPropagation(); openRepoDetail(el.dataset.detail); }));
$("repoGrid").querySelectorAll(".expand-btn:not([data-detail])").forEach((el) => el.addEventListener("click", (e) => { e.stopPropagation(); el.closest(".repo-card").classList.toggle("open"); }));
$("repoGrid").querySelectorAll(".repo-card").forEach((el) => el.addEventListener("click", (e) => { if (e.target.closest("a,button")) return; openRepoDetail(el.dataset.repo); }));
wireSubItems($("repoGrid"));
}
function wireSubItems(root) {
root.querySelectorAll(".actionable-sub").forEach((el) => el.addEventListener("click", (e) => {
if (e.target.closest("a")) return;
e.stopPropagation();
openItemDetail(el.dataset.openRepo, el.dataset.openType, el.dataset.openNumber);
}));
}
function repoHtml(r) {
const sub = [
...r.prs.map((p) => ({ t: "PR #" + p.number + " " + p.title, u: p.url, m: p.author, type: "pr", n: p.number })),
...r.issues.map((i) => ({ t: "Issue #" + i.number + " " + i.title, u: i.url, m: i.author, type: "issue", n: i.number })),
];
const subHtml = sub.length ? sub.slice(0, 12).map((s) => '' + esc(s.t) + ' ' + esc(s.m) + ' ↗ ').join("") : 'No open PRs or issues.
';
const local = r.localGit && r.localGit.available ? ((r.localGit.branchLine || "git").replace(/^## /, "") + " · " + (r.localGit.dirtyCount || 0) + " changed") : "no local checkout";
return '' +
'' + esc(r.slug) + ' ' + (r.isPrivate ? '
private ' : "") + '
' + esc(r.description || r.language || r.defaultBranch) + '
' +
'
' + esc(r.status.label) + ' ' +
'' + metric(r.openPrCount, "PRs") + metric(r.openIssueCount, "Issues") + metric(r.needsHumanPrCount, "Review") + metric(r.releaseAgeLabel, "Release") + '
' +
'' + esc(r.status.detail) + '
' +
(r.latestDeploy ? '🚀 ' + esc(r.latestDeploy.environment) + ' ' + esc(r.latestDeploy.state) + ' ' + esc(ageLabelClient(r.latestDeploy.ageDays)) + ' ago' + (r.deployments.length > 1 ? ' +' + (r.deployments.length - 1) + ' env ' : "") + '
' : '🚀 no deployments
') +
'🖥 ' + esc(local) + '
' +
'' +
'Details → ' +
(sub.length ? '▸ ' + sub.length + ' open items ' : "") +
'
' +
'' + subHtml + '
' +
' ';
}
function ageLabelClient(days) {
if (days === null || days === undefined) return "unknown";
if (days === 0) return "today";
if (days === 1) return "1 day";
if (days < 30) return days + " days";
const months = Math.floor(days / 30);
if (months < 18) return months + " mo";
return Math.floor(days / 365) + " yr";
}
function metric(v, l) { return '' + esc(v) + ' ' + esc(l) + '
'; }
function renderErrors(errors) {
$("errorList").innerHTML = errors.length ? errors.slice(0, 20).map((e) => '' + esc(e.repo || e.source || "source") + ' : ' + esc(e.message) + '
').join("") : 'All data sources returned successfully.
';
}
/* ---- repo detail drawer ---- */
function openRepoDetail(slug) {
const m = state.model; if (!m) return;
const r = m.repos.find((x) => x.slug === slug); if (!r) return;
const rel = r.latestRelease;
const deployHtml = r.deployments && r.deployments.length
? r.deployments.map((d) => '' + esc(d.state) + ' ' + esc(ageLabelClient(d.ageDays)) + ' ago
').join("")
: 'No deployments found.
';
const prHtml = r.prs.length ? r.prs.map((p) => '#' + p.number + ' ' + esc(p.title) + '
↗ ' + esc(p.author) + ' · ' + esc(p.reviewDecision) + (p.failingChecks ? ' · ⚠ checks failing' : '') + (p.isDraft ? ' · draft' : '') + ' · View & act →
').join("") : 'No open PRs.
';
const issueHtml = r.issues.length ? r.issues.map((i) => '#' + i.number + ' ' + esc(i.title) + '
↗ ' + esc(i.author) + ' · ' + i.comments + ' comments' + (i.assignedToJames ? ' · assigned to you' : '') + ' · View & act →
').join("") : 'No open issues.
';
const g = r.localGit || {};
const localHtml = g.available
? 'Branch: ' + esc((g.branchLine || "").replace(/^## /, "") || "?") + '
' + metric(g.dirtyCount || 0, "changed") + metric(g.ahead || 0, "ahead") + metric(g.behind || 0, "behind") + metric(ageLabelClient(g.lastCommitAgeDays), "last commit") + '
' + esc(g.path || "") + '
'
: 'No local checkout detected under ~/Projects.
';
const sessionHtml = r.activeSessions && r.activeSessions.length
? r.activeSessions.map((s) => '' + esc(s.summary) + ' ' + esc(s.branch || "?") + ' · updated ' + esc(ageLabelClient(s.ageDays)) + ' ago
').join("")
: 'No recent sessions.
';
$("detailDrawer").innerHTML =
'' + esc(r.slug) + ' ' + esc(r.description || r.language || r.defaultBranch) + '
✕ ' +
'' +
'
' + esc(r.status.label) + '' +
'
Open on GitHub ↗ ' +
(r.isPrivate ? '
private ' : '') + (r.language ? '
' + esc(r.language) + ' ' : '') + '
★ ' + (r.stars || 0) + ' ' +
'
' +
'' + esc(r.status.detail) + '
' +
'Overview ' + metric(r.openPrCount, "Open PRs") + metric(r.openIssueCount, "Open issues") + metric(r.needsHumanPrCount, "Need review") + metric(ageLabelClient(r.pushedAgeDays), "Last push") + '
' +
'Deployments ' + deployHtml + ' ' +
'Latest release ' + (rel ? '' : 'No release or tag found.
') + ' ' +
'Open PRs (' + r.prs.length + ') ' + prHtml + ' ' +
'Open issues (' + r.issues.length + ') ' + issueHtml + ' ' +
'Local checkout ' + localHtml + ' ' +
'Recent sessions ' + sessionHtml + ' ';
$("detailOverlay").classList.add("open");
$("detailClose").addEventListener("click", closeRepoDetail);
wireSubItems($("detailDrawer"));
}
function closeRepoDetail() { $("detailOverlay").classList.remove("open"); }
/* ---- item detail + actions ---- */
let itemCtx = null;
async function openItemDetail(repo, type, number) {
itemCtx = { repo, type, number };
$("itemDrawer").innerHTML = '' + esc(type.toUpperCase()) + ' #' + esc(number) + ' ' + esc(repo) + '
✕
';
$("itemOverlay").classList.add("open");
$("itemClose").addEventListener("click", closeItemDetail);
try {
const it = await api("/api/item?repo=" + encodeURIComponent(repo) + "&type=" + encodeURIComponent(type) + "&number=" + encodeURIComponent(number));
renderItemPane(it);
} catch (e) {
$("itemDrawer").innerHTML = '
Could not load ✕ ' + esc(e.message) + '
';
$("itemClose").addEventListener("click", closeItemDetail);
}
}
function renderItemPane(it) {
const isPr = it.type === "pr";
const stateLabel = it.state + (it.isDraft ? " · draft" : "");
const meta = [];
meta.push('' + esc(stateLabel) + ' ');
if (isPr) {
meta.push('' + esc(it.reviewDecision) + ' ');
if (it.checksTotal) meta.push('' + (it.checksFailing ? it.checksFailing + ' failing' : 'checks ok') + ' ');
meta.push('' + esc(it.mergeable) + ' ');
}
it.labels.forEach((l) => meta.push('' + esc(l) + ' '));
const metrics = isPr
? '' + metric('+' + (it.additions ?? 0), "additions") + metric('-' + (it.deletions ?? 0), "deletions") + metric(it.changedFiles ?? 0, "files") + metric(ageLabelClient(it.ageDays), "updated") + '
'
: '' + metric(it.comments.length, "comments") + metric(it.assignees.length, "assignees") + metric(ageLabelClient(it.ageDays), "updated") + '
';
const branchLine = isPr ? '' + esc(it.headRefName) + ' → ' + esc(it.baseRefName) + '
' : '';
const assignLine = it.assignees.length ? 'Assignees: ' + esc(it.assignees.join(", ")) + '
' : '';
const bodyHtml = it.body.trim() ? '' + esc(it.body) + '
' : 'No description.
';
const commentsHtml = it.comments.length
? it.comments.map((c) => '').join("")
: 'No comments yet.
';
const actions = [];
actions.push('Open on GitHub ↗ ');
if (isPr) {
if (it.isDraft) actions.push('Mark ready ');
actions.push('Approve ');
actions.push('🤖 Request Copilot review ');
actions.push('Request my review ');
actions.push('Squash & merge ');
} else {
if (it.assignees.map((a) => a.toLowerCase()).includes((state.model.currentLogin || "").toLowerCase())) actions.push('Unassign me ');
else actions.push('Assign to me ');
}
if (it.state && it.state.toUpperCase() === "OPEN") actions.push('Close ');
else actions.push('Reopen ');
$("itemDrawer").innerHTML =
'' + esc(it.title) + ' ' + esc(it.repo) + ' · ' + esc(it.type.toUpperCase()) + ' #' + it.number + ' · by ' + esc(it.author || "unknown") + '
✕ ' +
'' + meta.join("") + '
' +
branchLine + assignLine +
'' +
'Description ' + bodyHtml + ' ' +
'Work on this 🧭 Plan mode ⚡ Implement now ' + (isPr ? "" : '📝 Refine spec ☁ Assign to cloud session ') + '
Plan proposes an approach; Implement starts coding' + (isPr ? "" : "; Refine spec sharpens requirements; Cloud assigns the issue to a remote coding session") + '. New session in the ' + esc(it.repo) + ' project.
' +
'Actions ' + actions.join("") + '
' +
'' +
'Comment
' +
'Recent comments (' + it.comments.length + ') ' + commentsHtml + ' ';
$("itemClose").addEventListener("click", closeItemDetail);
$("itemDrawer").querySelectorAll("[data-act]").forEach((el) => el.addEventListener("click", () => runAct(el.dataset.act, it, el)));
}
async function runAct(act, it, btn) {
const status = $("actStatus");
if (act === "open-gh") { window.open(it.url, "_blank", "noopener,noreferrer"); return; }
if (act === "create-session") {
const mode = btn && ["implement", "spec", "cloud"].includes(btn.dataset.mode) ? btn.dataset.mode : "plan";
const label = mode === "implement" ? "build" : mode === "spec" ? "spec-refinement" : mode === "cloud" ? "cloud" : "planning";
status.className = "act-status"; status.textContent = "Requesting a " + label + " session…";
$("itemDrawer").querySelectorAll("[data-act]").forEach((b) => b.disabled = true);
try {
const res = await api("/api/item/session", { method: "POST", body: JSON.stringify({ repo: it.repo, type: it.type, number: it.number, title: it.title, mode }) });
status.className = "act-status ok"; status.textContent = res.message || "Session requested — check your chat.";
} catch (e) {
status.className = "act-status err"; status.textContent = e.message;
} finally {
$("itemDrawer").querySelectorAll("[data-act]").forEach((b) => b.disabled = false);
}
return;
}
if ((act === "merge" || act === "close") && !confirm("Are you sure you want to " + act + " " + it.type.toUpperCase() + " #" + it.number + "?")) return;
const body = act === "comment" ? ($("commentBox") ? $("commentBox").value.trim() : "") : "";
if (act === "comment" && !body) { status.className = "act-status err"; status.textContent = "Enter a comment first."; return; }
status.className = "act-status"; status.textContent = "Working…";
$("itemDrawer").querySelectorAll("[data-act]").forEach((b) => b.disabled = true);
try {
const res = await api("/api/item/action", { method: "POST", body: JSON.stringify({ repo: it.repo, type: it.type, number: it.number, action: act, body }) });
status.className = "act-status ok"; status.textContent = (res.output || (act + " succeeded.")) + " · refreshing…";
const fresh = await api("/api/item?repo=" + encodeURIComponent(it.repo) + "&type=" + encodeURIComponent(it.type) + "&number=" + encodeURIComponent(it.number));
renderItemPane(fresh);
await load(true);
const s2 = $("actStatus"); if (s2) { s2.className = "act-status ok"; s2.textContent = res.output || (act + " succeeded."); }
} catch (e) {
status.className = "act-status err"; status.textContent = e.message;
$("itemDrawer").querySelectorAll("[data-act]").forEach((b) => b.disabled = false);
}
}
function closeItemDetail() { $("itemOverlay").classList.remove("open"); itemCtx = null; }
/* ---- manage ---- */
async function loadDiscover(force) {
state.discoverLoading = true;
$("discoverGrid").innerHTML = '
';
try { state.discover = await api("/api/available-repos" + (force ? "?force=1" : "")); }
catch (e) { $("discoverGrid").innerHTML = '' + esc(e.message) + '
'; }
finally { state.discoverLoading = false; renderManage(); renderOnboarding(); }
}
function renderManage() {
const m = state.model; if (!m) return;
const tracked = m.repos;
$("trackedCount").textContent = tracked.length;
$("trackedList").innerHTML = tracked.map((r) => '' + esc(r.slug) + ' ' + (r.path ? '
local ' : "") + '
' + esc(r.status ? r.status.label : "") + ' · ' + r.openPrCount + ' PRs · ' + r.openIssueCount + ' issues
Remove ').join("") || 'No repos tracked yet.
';
$("trackedList").querySelectorAll("[data-remove]").forEach((el) => el.addEventListener("click", () => removeRepo(el.dataset.remove)));
if (!state.discover) { if (!state.discoverLoading) $("discoverGrid").innerHTML = 'Click “Reload my repos”.
'; return; }
const trackedSet = new Set(tracked.map((r) => r.slug.toLowerCase()));
const q = state.filters.discoverText.toLowerCase();
const repos = discoverItems().filter((r) => !q || (r.slug + " " + (r.description || "") + " " + (r.path || "")).toLowerCase().includes(q));
$("discoverGrid").innerHTML = repos.slice(0, 120).map((r) => {
const isTracked = trackedSet.has(r.slug.toLowerCase());
const payload = esc(encodedRepo(r));
return '' + esc(r.slug) + ' ' +
(isTracked ? ' tracked ' : 'Track ') + '
' +
'' + esc(r.description || "No description") + '
' +
'' + (r.source === "local" ? "local project · " + esc(r.path || "") : (r.isPrivate ? "private · " : "") + (r.isFork ? "fork · " : "") + "★ " + (r.stars || 0) + " · pushed " + esc(r.pushedAgeDays === null ? "?" : r.pushedAgeDays + "d ago")) + '
';
}).join("") || 'No repositories match.
';
$("discoverGrid").querySelectorAll("[data-add-repo]").forEach((el) => el.addEventListener("click", () => addRepo(JSON.parse(el.dataset.addRepo))));
}
async function trackSelectedOnboarding() {
const repos = [...state.onboarding.selected].map((value) => JSON.parse(value));
if (!repos.length) return;
try {
await api("/api/repos/set", { method: "POST", body: JSON.stringify({ repos, onboarded: true }) });
state.onboarding.selected.clear();
await load(true);
} catch (e) { alert(e.message); }
}
async function addRepo(slug) {
const repo = typeof slug === "string" ? { slug } : slug;
try { await api("/api/repos/add", { method: "POST", body: JSON.stringify({ repos: [repo] }) }); await load(true); switchTab("manage"); }
catch (e) { alert(e.message); }
}
async function removeRepo(slug) {
try { await api("/api/repos/remove", { method: "POST", body: JSON.stringify({ slug }) }); await load(true); switchTab("manage"); }
catch (e) { alert(e.message); }
}
/* ---- wiring ---- */
$("refresh").addEventListener("click", () => load(true));
["mood", "minutes", "busyness", "focusIntent"].forEach((id) => $(id).addEventListener("change", saveFocus));
document.querySelectorAll(".tab").forEach((el) => el.addEventListener("click", () => switchTab(el.dataset.tab)));
$("search").addEventListener("input", (e) => { state.filters.text = e.target.value; renderWork(); });
$("repoFilter").addEventListener("change", (e) => { state.filters.repo = e.target.value; renderWork(); });
$("sort").addEventListener("change", (e) => { state.filters.sort = e.target.value; renderWork(); });
$("repoSearch").addEventListener("input", (e) => { state.filters.repoText = e.target.value; renderRepos(); });
$("repoSort").addEventListener("change", (e) => { state.filters.repoSort = e.target.value; renderRepos(); });
$("cleanupSearch").addEventListener("input", (e) => { state.cleanup.text = e.target.value; renderCleanup(); });
$("cleanupRepo").addEventListener("change", (e) => { state.cleanup.repo = e.target.value; renderCleanup(); });
$("cleanupSort").addEventListener("change", (e) => { state.cleanup.sort = e.target.value; renderCleanup(); });
$("cleanupAll").addEventListener("change", (e) => {
const items = cleanupFiltered();
if (e.target.checked) items.forEach((s) => state.cleanup.selected.add(s.id));
else items.forEach((s) => state.cleanup.selected.delete(s.id));
renderCleanup();
});
$("cleanupSelectStale").addEventListener("click", () => {
cleanupInventory().forEach((s) => { if (s.orphaned || s.bucket === "stale" || s.bucket === "ancient") state.cleanup.selected.add(s.id); });
renderCleanup();
});
$("cleanupDelete").addEventListener("click", deleteSelectedSessions);
$("discoverSearch").addEventListener("input", (e) => { state.filters.discoverText = e.target.value; renderManage(); });
$("reloadDiscover").addEventListener("click", () => loadDiscover(true));
$("addManual").addEventListener("click", () => { const v = $("manualRepo").value.trim(); if (v) { $("manualRepo").value = ""; addRepo(v); } });
$("manualRepo").addEventListener("keydown", (e) => { if (e.key === "Enter") $("addManual").click(); });
$("detailBg").addEventListener("click", closeRepoDetail);
$("itemBg").addEventListener("click", closeItemDetail);
document.addEventListener("keydown", (e) => { if (e.key === "Escape") { closeItemDetail(); closeRepoDetail(); closeCleanupMenus(); } });
document.addEventListener("click", (e) => { if (!e.target.closest(".crow-manage")) closeCleanupMenus(); });
const events = new EventSource("/events");
events.addEventListener("state", () => load(false));
load(false);
`;
}