|
export function renderHtml() { |
|
return `<!doctype html> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="utf-8" /> |
|
<meta name="viewport" content="width=device-width, initial-scale=1" /> |
|
<title>Work Hub</title> |
|
<style> |
|
:root { color-scheme: light dark; } |
|
* { box-sizing: border-box; } |
|
body { |
|
margin: 0; |
|
background: |
|
radial-gradient(1200px 400px at 100% -10%, color-mix(in srgb, var(--true-color-blue, #0969da) 12%, transparent), transparent), |
|
var(--background-color-default, #ffffff); |
|
color: var(--text-color-default, #1f2328); |
|
font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif); |
|
font-size: var(--text-body-medium, 14px); |
|
line-height: var(--leading-body-medium, 20px); |
|
} |
|
button, select, input { font: inherit; color: inherit; } |
|
a { color: var(--true-color-blue, #0969da); text-decoration: none; } |
|
a:hover { text-decoration: underline; } |
|
.muted { color: var(--text-color-muted, #656d76); } |
|
.tiny { font-size: 12px; color: var(--text-color-muted, #656d76); } |
|
|
|
.app { min-height: 100vh; } |
|
header.top { |
|
position: sticky; top: 0; z-index: 20; |
|
backdrop-filter: blur(10px); |
|
background: color-mix(in srgb, var(--background-color-default, #fff) 82%, transparent); |
|
border-bottom: 1px solid var(--border-color-default, #d0d7de); |
|
padding: 14px 20px 0; |
|
} |
|
.top-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap; } |
|
.brand { display: flex; align-items: center; gap: 12px; } |
|
.logo { |
|
width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center; |
|
background: linear-gradient(140deg, var(--true-color-blue, #0969da), color-mix(in srgb, var(--true-color-blue, #0969da) 40%, #8250df)); |
|
color: #fff; font-weight: 700; font-size: 18px; |
|
} |
|
h1 { margin: 0; font-size: var(--text-title-large, 24px); line-height: 1.1; } |
|
.subtitle { margin: 3px 0 0; } |
|
|
|
.controls { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; } |
|
.field { display: grid; gap: 5px; } |
|
.field > span { font-size: 10px; text-transform: uppercase; letter-spacing: .06em; font-weight: 700; color: var(--text-color-muted, #656d76); padding-left: 2px; } |
|
.control { |
|
appearance: none; -webkit-appearance: none; -moz-appearance: none; |
|
border: 1px solid var(--border-color-default, #d0d7de); border-radius: 12px; |
|
background-color: var(--background-color-default, #fff); |
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'><path fill='none' stroke='%23808895' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round' d='M4 6l4 4 4-4'/></svg>"); |
|
background-repeat: no-repeat; background-position: right 10px center; background-size: 15px; |
|
padding: 9px 32px 9px 12px; min-width: 128px; cursor: pointer; |
|
font-size: 13px; font-weight: 600; color: var(--text-color-default, #1f2328); |
|
transition: border-color .15s ease, box-shadow .15s ease, background-color .15s ease; |
|
} |
|
.control:hover { border-color: color-mix(in srgb, var(--true-color-blue, #0969da) 55%, var(--border-color-default, #d0d7de)); background-color: color-mix(in srgb, var(--true-color-blue, #0969da) 5%, var(--background-color-default, #fff)); } |
|
.control:focus-visible, .control:focus { outline: none; border-color: var(--true-color-blue, #0969da); box-shadow: 0 0 0 3px color-mix(in srgb, var(--true-color-blue, #0969da) 22%, transparent); } |
|
.control option { font-weight: 500; } |
|
/* mood/time/bandwidth/focus read as one modern segmented cluster */ |
|
.controls .field:nth-child(-n+4) .control { background-color: color-mix(in srgb, var(--text-color-muted, #656d76) 6%, var(--background-color-default, #fff)); border-radius: 999px; box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 40%, transparent); } |
|
.controls .field:nth-child(-n+4) .control:hover { background-color: color-mix(in srgb, var(--true-color-blue, #0969da) 8%, var(--background-color-default, #fff)); } |
|
.btn { |
|
border: 1px solid var(--border-color-default, #d0d7de); border-radius: 10px; |
|
background: var(--background-color-default, #fff); padding: 8px 12px; cursor: pointer; |
|
font-weight: var(--font-weight-semibold, 600); |
|
} |
|
.btn:hover { border-color: var(--true-color-blue, #0969da); } |
|
.btn.primary { background: var(--true-color-blue, #0969da); color: #fff; border-color: var(--true-color-blue, #0969da); } |
|
.btn:disabled { opacity: .55; cursor: default; } |
|
|
|
.tabs { display: flex; gap: 4px; margin-top: 14px; } |
|
.tab { |
|
border: none; background: transparent; padding: 10px 14px; cursor: pointer; |
|
border-bottom: 2px solid transparent; color: var(--text-color-muted, #656d76); font-weight: 600; |
|
display: flex; align-items: center; gap: 7px; |
|
} |
|
.tab.active { color: var(--text-color-default, #1f2328); border-bottom-color: var(--true-color-blue, #0969da); } |
|
.tab .count { |
|
font-size: 11px; background: color-mix(in srgb, var(--text-color-muted, #656d76) 18%, transparent); |
|
border-radius: 999px; padding: 1px 7px; font-weight: 700; |
|
} |
|
|
|
main { padding: 18px 20px 40px; max-width: 1180px; margin: 0 auto; } |
|
.view { display: none; } |
|
.view.active { display: block; } |
|
|
|
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(128px, 1fr)); gap: 10px; margin-bottom: 18px; } |
|
.stat { |
|
border: 1px solid var(--border-color-default, #d0d7de); border-radius: 14px; padding: 12px 14px; |
|
background: color-mix(in srgb, var(--background-color-default, #fff) 94%, var(--text-color-default, #1f2328) 6%); |
|
cursor: pointer; transition: transform .08s ease, border-color .08s ease; |
|
} |
|
.stat:hover { transform: translateY(-2px); border-color: var(--true-color-blue, #0969da); } |
|
.stat strong { display: block; font-size: 26px; line-height: 30px; } |
|
.stat span { font-size: 12px; color: var(--text-color-muted, #656d76); } |
|
.stat.alert strong { color: #cf222e; } |
|
.stat.warn strong { color: #9a6700; } |
|
|
|
.toolbar { |
|
display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 14px; |
|
} |
|
.search { |
|
flex: 1 1 240px; min-width: 200px; display: flex; align-items: center; gap: 8px; |
|
border: 1px solid var(--border-color-default, #d0d7de); border-radius: 10px; padding: 8px 10px; |
|
background: var(--background-color-default, #fff); |
|
} |
|
.search input { border: none; outline: none; background: transparent; width: 100%; } |
|
.chips { display: flex; flex-wrap: wrap; gap: 6px; } |
|
.chip { |
|
border: 1px solid var(--border-color-default, #d0d7de); border-radius: 999px; padding: 5px 11px; |
|
background: var(--background-color-default, #fff); cursor: pointer; font-size: 12px; font-weight: 600; |
|
display: inline-flex; align-items: center; gap: 6px; |
|
} |
|
.chip:hover { border-color: var(--true-color-blue, #0969da); } |
|
.chip.active { background: var(--true-color-blue, #0969da); color: #fff; border-color: var(--true-color-blue, #0969da); } |
|
.chip .dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; opacity: .6; } |
|
|
|
.list { display: grid; gap: 10px; } |
|
.card { |
|
border: 1px solid var(--border-color-default, #d0d7de); border-radius: 14px; padding: 14px; |
|
background: var(--background-color-default, #fff); |
|
} |
|
.item { border-left: 3px solid transparent; } |
|
.item.kind-pr { border-left-color: #8250df; } |
|
.item.kind-issue { border-left-color: #1a7f37; } |
|
.item.kind-release { border-left-color: #9a6700; } |
|
.item.kind-deploy { border-left-color: #bf3989; } |
|
.item.kind-session { border-left-color: #0969da; } |
|
.item-top { display: flex; justify-content: space-between; gap: 12px; align-items: flex-start; } |
|
.item-title { font-weight: 600; } |
|
.kindtag { |
|
font-size: 10px; text-transform: uppercase; letter-spacing: .05em; font-weight: 700; |
|
border-radius: 6px; padding: 2px 6px; margin-right: 8px; |
|
background: color-mix(in srgb, var(--text-color-muted, #656d76) 15%, transparent); |
|
} |
|
.badges { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; } |
|
.badge { |
|
font-size: 11px; border-radius: 999px; padding: 2px 8px; font-weight: 600; |
|
border: 1px solid var(--border-color-default, #d0d7de); |
|
} |
|
.badge.needs-review { color: #9a6700; border-color: #d4a72c; } |
|
.badge.failing-checks { color: #cf222e; border-color: #cf222e; } |
|
.badge.human { color: #8250df; border-color: #8250df; } |
|
.badge.assigned { color: #0969da; border-color: #0969da; } |
|
.badge.stale-release { color: #9a6700; } |
|
.badge.failing-deploy { color: #bf3989; border-color: #bf3989; } |
|
.badge.active-session { color: #0969da; } |
|
.badge.triage { color: #8250df; border-color: #8250df; } |
|
.badge.stale-session { color: #9a6700; border-color: #d4a72c; } |
|
.deploy-line { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; } |
|
.deploy-state { font-size: 11px; border-radius: 999px; padding: 2px 8px; font-weight: 600; border: 1px solid var(--border-color-default, #d0d7de); } |
|
.deploy-state.success { color: #1a7f37; border-color: #1a7f37; } |
|
.deploy-state.failure, .deploy-state.error { color: #cf222e; border-color: #cf222e; } |
|
.deploy-state.in_progress, .deploy-state.queued, .deploy-state.pending { color: #9a6700; border-color: #d4a72c; } |
|
.deploy-state.inactive, .deploy-state.unknown { color: var(--text-color-muted, #656d76); } |
|
.score { border-radius: 999px; padding: 2px 9px; border: 1px solid var(--border-color-default, #d0d7de); white-space: nowrap; font-size: 12px; font-weight: 600; } |
|
|
|
.badge.bkt-fresh { color: #1a7f37; border-color: #1a7f37; } |
|
.badge.bkt-aging { color: #0969da; border-color: #0969da; } |
|
.badge.bkt-stale { color: #9a6700; border-color: #d4a72c; } |
|
.badge.bkt-ancient { color: #cf222e; border-color: #cf222e; } |
|
.badge.orphan { color: #bf3989; border-color: #bf3989; } |
|
.btn.danger { color: #cf222e; border-color: color-mix(in srgb, #cf222e 45%, var(--border-color-default, #d0d7de)); } |
|
.btn.danger:hover:not(:disabled) { background: #cf222e; color: #fff; border-color: #cf222e; } |
|
.cleanup-bar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 10px 12px; margin-bottom: 12px; |
|
border: 1px solid var(--border-color-default, #d0d7de); border-radius: 12px; background: color-mix(in srgb, var(--text-color-muted, #656d76) 5%, var(--background-color-default, #fff)); } |
|
.cleanup-bar .spacer { flex: 1; } |
|
.cleanup-bar .selall { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; } |
|
.cleanup-row { display: flex; align-items: flex-start; gap: 12px; padding: 12px 14px; border: 1px solid var(--border-color-default, #d0d7de); |
|
border-radius: 12px; background: var(--background-color-default, #fff); transition: border-color .15s ease, background .15s ease; } |
|
.cleanup-row + .cleanup-row { margin-top: 8px; } |
|
.cleanup-row:hover { border-color: color-mix(in srgb, var(--true-color-blue, #0969da) 45%, var(--border-color-default, #d0d7de)); } |
|
.cleanup-row.selected { border-color: var(--true-color-blue, #0969da); background: color-mix(in srgb, var(--true-color-blue, #0969da) 6%, var(--background-color-default, #fff)); } |
|
.crow-check { padding-top: 2px; } |
|
.crow-check input, .cleanup-bar input[type=checkbox] { width: 16px; height: 16px; cursor: pointer; accent-color: var(--true-color-blue, #0969da); } |
|
.crow-main { flex: 1; min-width: 0; } |
|
.crow-title { font-weight: 600; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
|
.crow-meta { margin-top: 3px; } |
|
.crow-meta code { font-size: 11px; } |
|
.crow-side { display: flex; align-items: center; gap: 8px; flex-shrink: 0; position: relative; } |
|
.tiny-btn { font-size: 12px; padding: 4px 9px; } |
|
.cleanup-row.archived { opacity: .82; background: color-mix(in srgb, #bf3989 4%, var(--background-color-default, #fff)); } |
|
.cleanup-row.archived .crow-title { text-decoration: none; } |
|
.crow-manage { position: relative; } |
|
.crow-menu { position: absolute; right: 0; top: calc(100% + 4px); z-index: 20; display: none; min-width: 190px; |
|
background: var(--background-color-default, #fff); border: 1px solid var(--border-color-default, #d0d7de); border-radius: 10px; |
|
box-shadow: 0 8px 24px rgba(0,0,0,.18); padding: 5px; } |
|
.crow-menu.open { display: grid; gap: 2px; } |
|
.menu-item { display: block; width: 100%; text-align: left; background: none; border: none; cursor: pointer; |
|
padding: 7px 9px; border-radius: 7px; font-size: 13px; font-weight: 500; color: var(--text-color-default, #1f2328); } |
|
.menu-item:hover { background: color-mix(in srgb, var(--true-color-blue, #0969da) 10%, transparent); } |
|
.menu-item.danger { color: #cf222e; } |
|
.menu-item.danger:hover { background: color-mix(in srgb, #cf222e 12%, transparent); } |
|
.menu-note { padding: 6px 9px; color: var(--text-color-muted, #656d76); } |
|
|
|
.repo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; } |
|
.repo-card { display: grid; gap: 10px; } |
|
.repo-head { display: flex; justify-content: space-between; gap: 8px; align-items: flex-start; } |
|
.repo-title { font-weight: 700; font-size: 15px; } |
|
.pill { display: inline-flex; align-items: center; gap: 6px; border-radius: 999px; padding: 3px 9px; font-size: 12px; font-weight: 600; border: 1px solid var(--border-color-default, #d0d7de); } |
|
.pill .dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; } |
|
.tone-good { color: #1a7f37; } |
|
.tone-attention { color: #9a6700; } |
|
.tone-danger { color: #cf222e; } |
|
.tone-active { color: #0969da; } |
|
.tone-neutral, .tone-muted { color: var(--text-color-muted, #656d76); } |
|
.repo-metrics { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; } |
|
.metric { border-radius: 10px; padding: 8px; text-align: center; background: color-mix(in srgb, var(--background-color-default, #fff) 88%, var(--text-color-default, #1f2328) 12%); } |
|
.metric strong { display: block; font-size: 16px; } |
|
.metric span { font-size: 11px; color: var(--text-color-muted, #656d76); } |
|
.repo-expand { display: none; border-top: 1px dashed var(--border-color-default, #d0d7de); padding-top: 10px; margin-top: 4px; } |
|
.repo-card.open .repo-expand { display: grid; gap: 6px; } |
|
.sub-item { display: flex; justify-content: space-between; gap: 8px; font-size: 13px; padding: 4px 0; } |
|
.actionable-sub { cursor: pointer; border-radius: 8px; padding: 6px 8px; margin: 0 -8px; transition: background .1s ease; } |
|
.actionable-sub:hover { background: color-mix(in srgb, var(--true-color-blue, #0969da) 8%, transparent); } |
|
.sub-item .sub-link { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
|
.sub-right { display: inline-flex; align-items: center; gap: 8px; flex-shrink: 0; } |
|
.sub-gh { text-decoration: none; color: var(--text-color-muted, #656d76); font-weight: 700; padding: 0 2px; } |
|
.sub-gh:hover { color: var(--true-color-blue, #0969da); } |
|
.di-head { display: flex; align-items: baseline; gap: 6px; } |
|
.di-head .sub-gh { margin-left: auto; } |
|
.di-cta { color: var(--true-color-blue, #0969da); font-weight: 600; opacity: 0; transition: opacity .1s ease; } |
|
.actionable-sub:hover .di-cta { opacity: 1; } |
|
.expand-btn { background: none; border: none; cursor: pointer; color: var(--true-color-blue, #0969da); font-weight: 600; padding: 0; text-align: left; } |
|
|
|
.manage-add { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-bottom: 14px; } |
|
.manage-add input { flex: 1 1 220px; } |
|
.tracked-list { display: grid; gap: 8px; margin-bottom: 22px; } |
|
.tracked-row { display: flex; justify-content: space-between; gap: 10px; align-items: center; } |
|
.discover-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 10px; } |
|
.discover-card { display: grid; gap: 6px; } |
|
.discover-head { display: flex; justify-content: space-between; gap: 8px; align-items: center; } |
|
.onboarding { display: grid; gap: 12px; margin-bottom: 16px; border-color: color-mix(in srgb, var(--true-color-blue, #0969da) 42%, var(--border-color-default, #d0d7de)); background: color-mix(in srgb, var(--true-color-blue, #0969da) 5%, var(--background-color-default, #fff)); } |
|
.onboard-title { font-size: 18px; font-weight: 700; } |
|
.onboard-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } |
|
.pick-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 8px; } |
|
.pick-card { display: flex; align-items: flex-start; gap: 9px; padding: 10px; border: 1px solid var(--border-color-default, #d0d7de); border-radius: 10px; background: var(--background-color-default, #fff); } |
|
.pick-card input { margin-top: 3px; accent-color: var(--true-color-blue, #0969da); } |
|
.section-label { margin: 14px 0 8px; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--text-color-muted, #656d76); } |
|
|
|
.empty { border: 1px dashed var(--border-color-default, #d0d7de); border-radius: 12px; padding: 20px; text-align: center; color: var(--text-color-muted, #656d76); } |
|
.error { border-left: 3px solid #cf222e; padding: 6px 10px; border-radius: 4px; background: color-mix(in srgb, #cf222e 8%, transparent); } |
|
.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid currentColor; border-top-color: transparent; border-radius: 50%; animation: spin .7s linear infinite; } |
|
@keyframes spin { to { transform: rotate(360deg); } } |
|
.skeleton { border-radius: 12px; height: 74px; background: linear-gradient(90deg, color-mix(in srgb, var(--text-color-muted,#656d76) 8%, transparent), color-mix(in srgb, var(--text-color-muted,#656d76) 16%, transparent), color-mix(in srgb, var(--text-color-muted,#656d76) 8%, transparent)); background-size: 200% 100%; animation: shimmer 1.3s infinite; } |
|
@keyframes shimmer { to { background-position: -200% 0; } } |
|
|
|
.overlay { position: fixed; inset: 0; z-index: 50; display: none; } |
|
.overlay.open { display: block; } |
|
.overlay-bg { position: absolute; inset: 0; background: color-mix(in srgb, #000 42%, transparent); } |
|
.drawer { |
|
position: absolute; top: 0; right: 0; height: 100%; width: min(620px, 94vw); |
|
background: var(--background-color-default, #fff); border-left: 1px solid var(--border-color-default, #d0d7de); |
|
box-shadow: -12px 0 40px rgba(0,0,0,.22); overflow-y: auto; padding: 20px; |
|
transform: translateX(20px); animation: slideIn .16s ease forwards; |
|
} |
|
@keyframes slideIn { to { transform: translateX(0); } } |
|
.drawer-head { display: flex; justify-content: space-between; gap: 12px; align-items: flex-start; margin-bottom: 6px; } |
|
.drawer h2 { margin: 0; font-size: 20px; } |
|
.close-x { border: none; background: none; font-size: 22px; cursor: pointer; color: var(--text-color-muted, #656d76); line-height: 1; } |
|
.drawer section { margin-top: 18px; } |
|
.drawer h3 { margin: 0 0 8px; font-size: 13px; text-transform: uppercase; letter-spacing: .05em; color: var(--text-color-muted, #656d76); } |
|
.detail-metrics { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; } |
|
.deploy-row { display: flex; justify-content: space-between; gap: 8px; align-items: center; padding: 8px 0; border-bottom: 1px solid color-mix(in srgb, var(--border-color-default,#d0d7de) 60%, transparent); } |
|
.detail-item { display: block; padding: 8px 0; border-bottom: 1px solid color-mix(in srgb, var(--border-color-default,#d0d7de) 60%, transparent); } |
|
.repo-link { border: none; background: none; padding: 0; font: inherit; color: var(--text-color-link, #0969da); cursor: pointer; text-decoration: none; } |
|
.repo-link:hover { text-decoration: underline; } |
|
.item.actionable { cursor: pointer; transition: transform .08s ease, box-shadow .08s ease; } |
|
.item.actionable:hover { transform: translateY(-1px); box-shadow: 0 4px 14px rgba(0,0,0,.12); } |
|
.open-hint { margin-top: 6px; color: var(--text-color-link, #0969da); font-weight: 600; opacity: 0; transition: opacity .1s ease; } |
|
.item.actionable:hover .open-hint { opacity: 1; } |
|
.guidance { margin-top: 8px; display: grid; gap: 4px; padding: 8px 10px; border-radius: 10px; background: color-mix(in srgb, var(--true-color-blue, #0969da) 6%, transparent); } |
|
.guidance div { font-size: 12px; color: var(--text-color-muted, #656d76); } |
|
.guidance strong { color: var(--text-color-default, #1f2328); } |
|
.item-pane { width: min(560px, 94vw); } |
|
.pane-body { white-space: pre-wrap; word-break: break-word; font-size: 13px; line-height: 1.5; max-height: 260px; overflow-y: auto; padding: 10px 12px; border: 1px solid var(--border-color-default,#d0d7de); border-radius: 8px; background: color-mix(in srgb, var(--text-color-muted,#656d76) 5%, transparent); } |
|
.act-row { display: flex; flex-wrap: wrap; gap: 8px; } |
|
.act-btn { border: 1px solid var(--border-color-default,#d0d7de); background: var(--background-color-default,#fff); border-radius: 8px; padding: 7px 12px; font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; } |
|
.act-btn:hover { background: color-mix(in srgb, var(--text-color-muted,#656d76) 8%, transparent); } |
|
.act-btn.primary { border-color: #1a7f37; color: #1a7f37; } |
|
.act-btn.accent { border-color: #0969da; background: #0969da; color: #fff; } |
|
.act-btn.accent:hover { background: #0860c9; } |
|
.act-btn.danger { border-color: #cf222e; color: #cf222e; } |
|
.act-btn.warn { border-color: #9a6700; color: #9a6700; } |
|
.act-btn:disabled { opacity: .5; cursor: default; } |
|
.comment-box { width: 100%; box-sizing: border-box; min-height: 72px; border: 1px solid var(--border-color-default,#d0d7de); border-radius: 8px; padding: 8px 10px; font: inherit; font-size: 13px; resize: vertical; } |
|
.act-status { font-size: 12px; margin-top: 6px; min-height: 16px; } |
|
.act-status.ok { color: #1a7f37; } |
|
.act-status.err { color: #cf222e; } |
|
.comment-item { padding: 8px 0; border-bottom: 1px solid color-mix(in srgb, var(--border-color-default,#d0d7de) 60%, transparent); } |
|
.comment-item .body { white-space: pre-wrap; word-break: break-word; font-size: 13px; margin-top: 3px; } |
|
@media (max-width: 720px) { .repo-metrics, .detail-metrics { grid-template-columns: repeat(2, 1fr); } } |
|
</style> |
|
</head> |
|
<body> |
|
<div class="app"> |
|
<header class="top"> |
|
<div class="top-row"> |
|
<div class="brand"> |
|
<div class="logo">WH</div> |
|
<div> |
|
<h1>Work Hub</h1> |
|
<p class="subtitle tiny" id="subtitle">Loading your command center…</p> |
|
</div> |
|
</div> |
|
<div class="controls"> |
|
<label class="field"><span>Mood</span> |
|
<select class="control" id="mood"> |
|
<option value="focused">🎯 Focused</option> |
|
<option value="low-energy">🔋 Low energy</option> |
|
<option value="maintenance">🧹 Maintenance</option> |
|
<option value="creative">✨ Creative</option> |
|
<option value="urgent">🚨 Urgent</option> |
|
</select> |
|
</label> |
|
<label class="field"><span>Time</span> |
|
<select class="control" id="minutes"> |
|
<option value="15">15 min</option> |
|
<option value="30">30 min</option> |
|
<option value="60">1 hour</option> |
|
<option value="120">2 hours</option> |
|
</select> |
|
</label> |
|
<label class="field"><span>Bandwidth</span> |
|
<select class="control" id="busyness"> |
|
<option value="busy">Busy</option> |
|
<option value="normal">Normal</option> |
|
<option value="open">Wide open</option> |
|
</select> |
|
</label> |
|
<label class="field"><span>Focus on refresh</span> |
|
<select class="control" id="focusIntent"> |
|
<option value="balanced">⚖️ Balanced</option> |
|
<option value="prs">🔀 PRs / reviews</option> |
|
<option value="new-code">🛠 New code</option> |
|
<option value="issue-triage">🧭 Issue triage</option> |
|
<option value="maintenance">🧹 Maintenance</option> |
|
</select> |
|
</label> |
|
<button class="btn primary" id="refresh">↻ Refresh</button> |
|
</div> |
|
</div> |
|
<nav class="tabs" id="tabs"> |
|
<button class="tab active" data-tab="focus">Focus plan</button> |
|
<button class="tab" data-tab="work">Work explorer <span class="count" id="workCount">0</span></button> |
|
<button class="tab" data-tab="repos">Repositories <span class="count" id="repoCount">0</span></button> |
|
<button class="tab" data-tab="cleanup">Session cleanup <span class="count" id="cleanupCount">0</span></button> |
|
<button class="tab" data-tab="manage">Manage repos</button> |
|
</nav> |
|
</header> |
|
|
|
<main> |
|
<section class="view active" data-view="focus"> |
|
<div id="onboardingPanel"></div> |
|
<div class="stats" id="stats"></div> |
|
<div class="card" style="margin-bottom:14px"> |
|
<div class="tiny" id="planSummary">Building a plan for your available time…</div> |
|
</div> |
|
<div class="list" id="focusList"><div class="skeleton"></div><div class="skeleton"></div></div> |
|
</section> |
|
|
|
<section class="view" data-view="work"> |
|
<div class="toolbar"> |
|
<div class="search">🔎 <input id="search" type="text" placeholder="Search titles, repos, authors…" /></div> |
|
<select class="control" id="repoFilter"><option value="">All repos</option></select> |
|
<select class="control" id="sort"> |
|
<option value="priority">Sort: Priority</option> |
|
<option value="recent">Sort: Recently updated</option> |
|
<option value="repo">Sort: Repository</option> |
|
</select> |
|
</div> |
|
<div class="chips" id="chips" style="margin-bottom:14px"></div> |
|
<div class="list" id="workList"><div class="skeleton"></div><div class="skeleton"></div><div class="skeleton"></div></div> |
|
</section> |
|
|
|
<section class="view" data-view="repos"> |
|
<div class="toolbar"> |
|
<div class="search">🔎 <input id="repoSearch" type="text" placeholder="Filter repositories…" /></div> |
|
<select class="control" id="repoSort"> |
|
<option value="attention">Sort: Needs attention</option> |
|
<option value="release">Sort: Oldest release</option> |
|
<option value="work">Sort: Most open work</option> |
|
<option value="name">Sort: Name</option> |
|
</select> |
|
</div> |
|
<div class="repo-grid" id="repoGrid"><div class="skeleton"></div><div class="skeleton"></div></div> |
|
</section> |
|
|
|
<section class="view" data-view="cleanup"> |
|
<div class="stats" id="cleanupStats"></div> |
|
<div class="toolbar"> |
|
<div class="search">🔎 <input id="cleanupSearch" type="text" placeholder="Search sessions, repos, branches…" /></div> |
|
<select class="control" id="cleanupRepo"><option value="">All repos</option></select> |
|
<select class="control" id="cleanupSort"> |
|
<option value="oldest">Sort: Oldest first</option> |
|
<option value="newest">Sort: Newest first</option> |
|
<option value="repo">Sort: Repository</option> |
|
</select> |
|
</div> |
|
<div class="chips" id="cleanupChips" style="margin-bottom:12px"></div> |
|
<div class="cleanup-bar" id="cleanupBar"> |
|
<label class="tiny selall"><input type="checkbox" id="cleanupAll" /> Select all shown</label> |
|
<span class="tiny" id="cleanupSelInfo">0 selected</span> |
|
<span class="spacer"></span> |
|
<button class="btn" id="cleanupSelectStale">Select stale & older</button> |
|
<button class="btn danger" id="cleanupDelete" disabled>🗑 Clean up selected</button> |
|
</div> |
|
<div class="list" id="cleanupList"><div class="skeleton"></div><div class="skeleton"></div></div> |
|
</section> |
|
|
|
<section class="view" data-view="manage"> |
|
<div class="card" style="margin-bottom:16px"> |
|
<div class="manage-add"> |
|
<input class="control" id="manualRepo" type="text" placeholder="owner/repo (e.g. octocat/hello-world)" /> |
|
<button class="btn primary" id="addManual">+ Track repo</button> |
|
<button class="btn" id="reloadDiscover">↻ Reload my repos</button> |
|
</div> |
|
<div class="tiny">Tracked repos are stored in <code>~/.copilot/extensions/work-hub/artifacts/config.json</code>. Local checkouts under <code>~/Projects</code> are auto-detected.</div> |
|
</div> |
|
<h3 style="margin:0 0 8px">Tracked (<span id="trackedCount">0</span>)</h3> |
|
<div class="tracked-list" id="trackedList"></div> |
|
<h3 style="margin:0 0 8px">Discover your repos</h3> |
|
<div class="toolbar"><div class="search">🔎 <input id="discoverSearch" type="text" placeholder="Filter your GitHub repos…" /></div></div> |
|
<div class="discover-grid" id="discoverGrid"><div class="empty">Open this tab to load your repositories.</div></div> |
|
</section> |
|
|
|
<section style="margin-top:22px"> |
|
<details> |
|
<summary class="tiny" style="cursor:pointer">Data quality (<span id="errorCount">0</span> warnings)</summary> |
|
<div class="list" id="errorList" style="margin-top:10px"></div> |
|
</details> |
|
</section> |
|
</main> |
|
<div class="overlay" id="detailOverlay"><div class="overlay-bg" id="detailBg"></div><div class="drawer" id="detailDrawer"></div></div> |
|
<div class="overlay" id="itemOverlay"><div class="overlay-bg" id="itemBg"></div><div class="drawer item-pane" id="itemDrawer"></div></div> |
|
</div> |
|
<script>${clientScript()}</script> |
|
</body> |
|
</html>`; |
|
} |
|
|
|
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 ? '<span class="spinner"></span> 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 ? '<div class="pick-grid">' + items.map((r, i) => { |
|
const id = "onboardPick" + i; |
|
const value = encodedRepo(r); |
|
return '<label class="pick-card" for="' + id + '"><input id="' + id + '" type="checkbox" data-onboard-pick="' + esc(value) + '" ' + (state.onboarding.selected.has(value) ? "checked" : "") + ' /><span><strong>' + esc(r.slug) + '</strong>' + (r.source === "local" ? ' <span class="badge">local project</span>' : ' <span class="badge">GitHub</span>') + '<div class="tiny">' + esc(r.description || r.path || "No description") + '</div></span></label>'; |
|
}).join("") + '</div>' : '<div class="empty">Discover repositories and local projects to start picking what this hub should track.</div>'; |
|
panel.innerHTML = '<div class="card onboarding"><div><div class="onboard-title">Set up your Work Hub</div><div class="tiny">Pick GitHub repositories or local projects to track. Your choices stay local under <code>~/.copilot/extensions/work-hub/artifacts</code>.</div></div><div class="onboard-actions"><button class="btn primary" id="onboardDiscover">' + (state.discoverLoading ? "Discovering…" : "Discover repos & projects") + '</button><button class="btn" id="onboardManage">Manage manually</button><button class="btn primary" id="onboardTrack" ' + (state.onboarding.selected.size ? "" : "disabled") + '>Start tracking selected (' + state.onboarding.selected.size + ')</button></div>' + picks + '</div>'; |
|
$("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) => '<div class="stat ' + (c.cls || "") + '" data-i="' + i + '"><strong>' + esc(c.v) + '</strong><span>' + esc(c.l) + '</span></div>').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 <strong>" + esc(m.preferences.mood) + "</strong> with <strong>" + esc(m.preferences.minutes) + " min</strong> (" + esc(m.preferences.busyness) + ") and want <strong>" + esc(intent) + "</strong>, 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("") |
|
: '<div class="empty">Nothing needs your attention. Enjoy the calm.</div>'; |
|
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) => '<span class="badge ' + esc(t) + '">' + esc(t.replace(/-/g, " ")) + '</span>').join(""); |
|
const rank = opts.rank ? '<span class="score">#' + opts.rank + '</span>' : ""; |
|
const right = opts.planned ? '<span class="score">' + esc(opts.planned) + ' min</span>' : '<span class="score">' + esc(it.minutes) + ' min</span>'; |
|
const link = it.url ? '<a href="' + esc(it.url) + '" target="_blank" rel="noreferrer">' + esc(it.title) + '</a>' : 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) ? '<div class="guidance">' + (it.what ? '<div><strong>What:</strong> ' + esc(it.what) + '</div>' : "") + (it.how ? '<div><strong>How:</strong> ' + esc(it.how) + '</div>' : "") + '</div>' : ""; |
|
return '<article' + attrs + '>' + |
|
'<div class="item-top"><div class="item-title"><span class="kindtag">' + esc(it.kind) + '</span>' + link + '</div><div style="display:flex;gap:6px">' + rank + right + '</div></div>' + |
|
'<div class="tiny" style="margin-top:4px"><button class="repo-link" data-detail="' + esc(it.repo) + '">' + esc(it.repo) + '</button> · ' + esc(it.detail) + '</div>' + |
|
(badges ? '<div class="badges">' + badges + '</div>' : "") + |
|
(it.reasons && it.reasons.length ? '<div class="tiny" style="margin-top:6px">Why now: ' + esc(it.reasons.join(", ")) + (opts.rank ? '' : ' · score ' + esc(it.score)) + '</div>' : "") + |
|
guidance + |
|
(actionable ? '<div class="tiny open-hint">Click to view & act →</div>' : "") + |
|
(jumpable ? '<div class="act-row" style="margin-top:8px"><button class="act-btn accent jump-btn" data-jump="' + esc(it.sessionId) + '" data-jump-repo="' + esc(it.repo) + '" data-jump-branch="' + esc(it.branch || "") + '">↪ Jump to session</button></div>' : "") + |
|
'</article>'; |
|
} |
|
|
|
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 = '<option value="">All repos</option>' + repos.map((r) => '<option value="' + esc(r.slug) + '">' + esc(r.slug) + '</option>').join(""); |
|
$("repoFilter").value = cur; |
|
} |
|
|
|
function renderChips() { |
|
$("chips").innerHTML = CHIP_DEFS.map((c) => '<button class="chip ' + (state.filters.chips.has(c.id) ? "active" : "") + '" data-chip="' + c.id + '"><span class="dot"></span>' + esc(c.label) + '</button>').join("") + |
|
(state.filters.chips.size ? '<button class="chip" data-chip="__clear">✕ Clear</button>' : ""); |
|
$("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("") : '<div class="empty">No work matches these filters.</div>'; |
|
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 = '<option value="">All repos (' + all.length + ')</option>' + repos.map((r) => '<option value="' + esc(r) + '"' + (r === sel ? " selected" : "") + '>' + esc(r) + '</option>').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) => '<div class="stat ' + (c.cls || "") + '"><strong>' + esc(c.v) + '</strong><span>' + esc(c.l) + '</span></div>').join(""); |
|
// bucket chips |
|
const cnt = (id) => all.filter((x) => id === "orphaned" ? x.orphaned : x.bucket === id).length; |
|
$("cleanupChips").innerHTML = CLEANUP_BUCKETS.map((b) => '<button class="chip ' + (state.cleanup.buckets.has(b.id) ? "active" : "") + '" data-cbucket="' + b.id + '"><span class="dot"></span>' + esc(b.label) + ' <span class="tiny">' + cnt(b.id) + '</span></button>').join("") + |
|
(state.cleanup.buckets.size ? '<button class="chip" data-cbucket="__clear">✕ Clear</button>' : ""); |
|
$("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("") : '<div class="empty">No sessions match these filters. 🎉</div>'; |
|
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 ? '<span class="badge orphan" title="The worktree folder is gone — this session was very likely closed/archived.">📦 archived</span>' : '<span class="badge bkt-' + esc(sn.bucket) + '">' + esc(sn.bucket) + '</span>'; |
|
const where = archived ? "worktree removed" : (sn.isWorktree ? "worktree" : "main checkout"); |
|
const openBtn = archived |
|
? '<button class="act-btn tiny-btn" disabled title="No live session to open — worktree is gone.">↪ Open</button>' |
|
: '<button class="act-btn tiny-btn" data-cjump="' + esc(sn.id) + '" data-jump-repo="' + esc(sn.repository) + '" data-jump-branch="' + esc(sn.branch) + '">↪ Open</button>'; |
|
const menu = '<div class="crow-manage">' + |
|
'<button class="act-btn tiny-btn" data-cmenu="' + esc(sn.id) + '" aria-label="Manage session">⋯</button>' + |
|
'<div class="crow-menu" data-menu-for="' + esc(sn.id) + '">' + |
|
(archived ? '<div class="menu-note tiny">Already archived (worktree removed).</div>' : '<button class="menu-item" data-cjump2="' + esc(sn.id) + '" data-jump-repo="' + esc(sn.repository) + '" data-jump-branch="' + esc(sn.branch) + '">↪ Open & triage</button>') + |
|
'<button class="menu-item danger" data-cdel="' + esc(sn.id) + '">🗑 Delete session</button>' + |
|
(sn.branch ? '<button class="menu-item" data-copybranch="' + esc(sn.branch) + '">⧉ Copy branch name</button>' : '') + |
|
'</div>' + |
|
'</div>'; |
|
return '<div class="cleanup-row ' + (checked ? "selected " : "") + (archived ? "archived" : "") + '" data-id="' + esc(sn.id) + '">' + |
|
'<label class="crow-check"><input type="checkbox" data-cselect="' + esc(sn.id) + '"' + checked + ' /></label>' + |
|
'<div class="crow-main">' + |
|
'<div class="crow-title">' + esc(sn.summary) + '</div>' + |
|
'<div class="crow-meta tiny">' + esc(sn.repository) + (sn.branch ? ' · <code>' + esc(sn.branch) + '</code>' : "") + ' · ' + where + ' · last active ' + esc(sn.ageLabel) + ' ago</div>' + |
|
'</div>' + |
|
'<div class="crow-side">' + badge + openBtn + menu + '</div>' + |
|
'</div>'; |
|
} |
|
|
|
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 = '<span class="spinner"></span> 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) => '<div class="sub-item actionable-sub" data-open-repo="' + esc(r.slug) + '" data-open-type="' + esc(s.type) + '" data-open-number="' + esc(s.n) + '"><span class="sub-link">' + esc(s.t) + '</span><span class="sub-right"><span class="tiny">' + esc(s.m) + '</span><a class="sub-gh" href="' + esc(s.u) + '" target="_blank" rel="noreferrer" title="Open on GitHub">↗</a></span></div>').join("") : '<div class="tiny">No open PRs or issues.</div>'; |
|
const local = r.localGit && r.localGit.available ? ((r.localGit.branchLine || "git").replace(/^## /, "") + " · " + (r.localGit.dirtyCount || 0) + " changed") : "no local checkout"; |
|
return '<article class="card repo-card" data-repo="' + esc(r.slug) + '">' + |
|
'<div class="repo-head"><div><a class="repo-title" href="' + esc(r.url) + '" target="_blank" rel="noreferrer">' + esc(r.slug) + '</a>' + (r.isPrivate ? ' <span class="badge">private</span>' : "") + '<div class="tiny" style="margin-top:2px">' + esc(r.description || r.language || r.defaultBranch) + '</div></div>' + |
|
'<span class="pill tone-' + esc(r.status.tone) + '"><span class="dot"></span>' + esc(r.status.label) + '</span></div>' + |
|
'<div class="repo-metrics">' + metric(r.openPrCount, "PRs") + metric(r.openIssueCount, "Issues") + metric(r.needsHumanPrCount, "Review") + metric(r.releaseAgeLabel, "Release") + '</div>' + |
|
'<div class="tiny">' + esc(r.status.detail) + '</div>' + |
|
(r.latestDeploy ? '<div class="tiny deploy-line">🚀 ' + esc(r.latestDeploy.environment) + ' <span class="deploy-state ' + esc(r.latestDeploy.state) + '">' + esc(r.latestDeploy.state) + '</span> ' + esc(ageLabelClient(r.latestDeploy.ageDays)) + ' ago' + (r.deployments.length > 1 ? ' <span class="tiny">+' + (r.deployments.length - 1) + ' env</span>' : "") + '</div>' : '<div class="tiny">🚀 no deployments</div>') + |
|
'<div class="tiny">🖥 ' + esc(local) + '</div>' + |
|
'<div style="display:flex;gap:8px;margin-top:2px">' + |
|
'<button class="expand-btn" data-detail="' + esc(r.slug) + '">Details →</button>' + |
|
(sub.length ? '<button class="expand-btn">▸ ' + sub.length + ' open items</button>' : "") + |
|
'</div>' + |
|
'<div class="repo-expand">' + subHtml + '</div>' + |
|
'</article>'; |
|
} |
|
|
|
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 '<div class="metric"><strong>' + esc(v) + '</strong><span>' + esc(l) + '</span></div>'; } |
|
|
|
function renderErrors(errors) { |
|
$("errorList").innerHTML = errors.length ? errors.slice(0, 20).map((e) => '<div class="error tiny"><strong>' + esc(e.repo || e.source || "source") + '</strong>: ' + esc(e.message) + '</div>').join("") : '<div class="tiny">All data sources returned successfully.</div>'; |
|
} |
|
|
|
/* ---- 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) => '<div class="deploy-row"><div>' + (d.url ? '<a href="' + esc(d.url) + '" target="_blank" rel="noreferrer"><strong>' + esc(d.environment) + '</strong></a>' : '<strong>' + esc(d.environment) + '</strong>') + '<div class="tiny">ref ' + esc(d.ref || "—") + '</div></div><div style="text-align:right"><span class="deploy-state ' + esc(d.state) + '">' + esc(d.state) + '</span><div class="tiny">' + esc(ageLabelClient(d.ageDays)) + ' ago</div></div></div>').join("") |
|
: '<div class="tiny">No deployments found.</div>'; |
|
const prHtml = r.prs.length ? r.prs.map((p) => '<div class="detail-item actionable-sub" data-open-repo="' + esc(r.slug) + '" data-open-type="pr" data-open-number="' + p.number + '"><div class="di-head"><strong>#' + p.number + '</strong> ' + esc(p.title) + '<a class="sub-gh" href="' + esc(p.url) + '" target="_blank" rel="noreferrer" title="Open on GitHub">↗</a></div><div class="tiny">' + esc(p.author) + ' · ' + esc(p.reviewDecision) + (p.failingChecks ? ' · ⚠ checks failing' : '') + (p.isDraft ? ' · draft' : '') + ' · <span class="di-cta">View & act →</span></div></div>').join("") : '<div class="tiny">No open PRs.</div>'; |
|
const issueHtml = r.issues.length ? r.issues.map((i) => '<div class="detail-item actionable-sub" data-open-repo="' + esc(r.slug) + '" data-open-type="issue" data-open-number="' + i.number + '"><div class="di-head"><strong>#' + i.number + '</strong> ' + esc(i.title) + '<a class="sub-gh" href="' + esc(i.url) + '" target="_blank" rel="noreferrer" title="Open on GitHub">↗</a></div><div class="tiny">' + esc(i.author) + ' · ' + i.comments + ' comments' + (i.assignedToJames ? ' · assigned to you' : '') + ' · <span class="di-cta">View & act →</span></div></div>').join("") : '<div class="tiny">No open issues.</div>'; |
|
const g = r.localGit || {}; |
|
const localHtml = g.available |
|
? '<div class="tiny">Branch: <strong>' + esc((g.branchLine || "").replace(/^## /, "") || "?") + '</strong></div><div class="detail-metrics" style="margin-top:8px">' + metric(g.dirtyCount || 0, "changed") + metric(g.ahead || 0, "ahead") + metric(g.behind || 0, "behind") + metric(ageLabelClient(g.lastCommitAgeDays), "last commit") + '</div><div class="tiny" style="margin-top:6px">' + esc(g.path || "") + '</div>' |
|
: '<div class="tiny">No local checkout detected under ~/Projects.</div>'; |
|
const sessionHtml = r.activeSessions && r.activeSessions.length |
|
? r.activeSessions.map((s) => '<div class="detail-item"><strong>' + esc(s.summary) + '</strong><div class="tiny">' + esc(s.branch || "?") + ' · updated ' + esc(ageLabelClient(s.ageDays)) + ' ago</div></div>').join("") |
|
: '<div class="tiny">No recent sessions.</div>'; |
|
|
|
$("detailDrawer").innerHTML = |
|
'<div class="drawer-head"><div><h2>' + esc(r.slug) + '</h2><div class="tiny">' + esc(r.description || r.language || r.defaultBranch) + '</div></div><button class="close-x" id="detailClose">✕</button></div>' + |
|
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:6px">' + |
|
'<span class="pill tone-' + esc(r.status.tone) + '"><span class="dot"></span>' + esc(r.status.label) + '</span>' + |
|
'<a class="btn" href="' + esc(r.url) + '" target="_blank" rel="noreferrer">Open on GitHub ↗</a>' + |
|
(r.isPrivate ? '<span class="badge">private</span>' : '') + (r.language ? '<span class="badge">' + esc(r.language) + '</span>' : '') + '<span class="badge">★ ' + (r.stars || 0) + '</span>' + |
|
'</div>' + |
|
'<div class="tiny" style="margin-top:8px">' + esc(r.status.detail) + '</div>' + |
|
'<section><h3>Overview</h3><div class="detail-metrics">' + metric(r.openPrCount, "Open PRs") + metric(r.openIssueCount, "Open issues") + metric(r.needsHumanPrCount, "Need review") + metric(ageLabelClient(r.pushedAgeDays), "Last push") + '</div></section>' + |
|
'<section><h3>Deployments</h3>' + deployHtml + '</section>' + |
|
'<section><h3>Latest release</h3>' + (rel ? '<div class="detail-item">' + (rel.url ? '<a href="' + esc(rel.url) + '" target="_blank" rel="noreferrer"><strong>' + esc(rel.tagName || rel.name) + '</strong></a>' : '<strong>' + esc(rel.tagName || rel.name) + '</strong>') + '<div class="tiny">' + esc(rel.kind) + ' · ' + esc(ageLabelClient(r.releaseAgeDays)) + ' ago</div></div>' : '<div class="tiny">No release or tag found.</div>') + '</section>' + |
|
'<section><h3>Open PRs (' + r.prs.length + ')</h3>' + prHtml + '</section>' + |
|
'<section><h3>Open issues (' + r.issues.length + ')</h3>' + issueHtml + '</section>' + |
|
'<section><h3>Local checkout</h3>' + localHtml + '</section>' + |
|
'<section><h3>Recent sessions</h3>' + sessionHtml + '</section>'; |
|
$("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 = '<div class="drawer-head"><div><h2>' + esc(type.toUpperCase()) + ' #' + esc(number) + '</h2><div class="tiny">' + esc(repo) + '</div></div><button class="close-x" id="itemClose">✕</button></div><div class="skeleton" style="margin-top:16px"></div><div class="skeleton" style="margin-top:10px"></div>'; |
|
$("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 = '<div class="drawer-head"><h2>Could not load</h2><button class="close-x" id="itemClose">✕</button></div><div class="tiny err" style="margin-top:12px">' + esc(e.message) + '</div>'; |
|
$("itemClose").addEventListener("click", closeItemDetail); |
|
} |
|
} |
|
|
|
function renderItemPane(it) { |
|
const isPr = it.type === "pr"; |
|
const stateLabel = it.state + (it.isDraft ? " · draft" : ""); |
|
const meta = []; |
|
meta.push('<span class="badge">' + esc(stateLabel) + '</span>'); |
|
if (isPr) { |
|
meta.push('<span class="badge">' + esc(it.reviewDecision) + '</span>'); |
|
if (it.checksTotal) meta.push('<span class="badge ' + (it.checksFailing ? 'failing-checks' : '') + '">' + (it.checksFailing ? it.checksFailing + ' failing' : 'checks ok') + '</span>'); |
|
meta.push('<span class="badge">' + esc(it.mergeable) + '</span>'); |
|
} |
|
it.labels.forEach((l) => meta.push('<span class="badge">' + esc(l) + '</span>')); |
|
const metrics = isPr |
|
? '<div class="detail-metrics">' + metric('+' + (it.additions ?? 0), "additions") + metric('-' + (it.deletions ?? 0), "deletions") + metric(it.changedFiles ?? 0, "files") + metric(ageLabelClient(it.ageDays), "updated") + '</div>' |
|
: '<div class="detail-metrics">' + metric(it.comments.length, "comments") + metric(it.assignees.length, "assignees") + metric(ageLabelClient(it.ageDays), "updated") + '</div>'; |
|
const branchLine = isPr ? '<div class="tiny" style="margin-top:6px">' + esc(it.headRefName) + ' → ' + esc(it.baseRefName) + '</div>' : ''; |
|
const assignLine = it.assignees.length ? '<div class="tiny" style="margin-top:6px">Assignees: ' + esc(it.assignees.join(", ")) + '</div>' : ''; |
|
const bodyHtml = it.body.trim() ? '<div class="pane-body">' + esc(it.body) + '</div>' : '<div class="tiny">No description.</div>'; |
|
const commentsHtml = it.comments.length |
|
? it.comments.map((c) => '<div class="comment-item"><div class="tiny"><strong>' + esc(c.author || "unknown") + '</strong> · ' + esc(ageLabelClient(c.ageDays)) + ' ago</div><div class="body">' + esc(c.body) + '</div></div>').join("") |
|
: '<div class="tiny">No comments yet.</div>'; |
|
|
|
const actions = []; |
|
actions.push('<button class="act-btn" data-act="open-gh">Open on GitHub ↗</button>'); |
|
if (isPr) { |
|
if (it.isDraft) actions.push('<button class="act-btn" data-act="ready">Mark ready</button>'); |
|
actions.push('<button class="act-btn primary" data-act="approve">Approve</button>'); |
|
actions.push('<button class="act-btn accent" data-act="copilot-review">🤖 Request Copilot review</button>'); |
|
actions.push('<button class="act-btn" data-act="request-me">Request my review</button>'); |
|
actions.push('<button class="act-btn primary" data-act="merge">Squash & merge</button>'); |
|
} else { |
|
if (it.assignees.map((a) => a.toLowerCase()).includes((state.model.currentLogin || "").toLowerCase())) actions.push('<button class="act-btn" data-act="unassign-me">Unassign me</button>'); |
|
else actions.push('<button class="act-btn" data-act="assign-me">Assign to me</button>'); |
|
} |
|
if (it.state && it.state.toUpperCase() === "OPEN") actions.push('<button class="act-btn danger" data-act="close">Close</button>'); |
|
else actions.push('<button class="act-btn" data-act="reopen">Reopen</button>'); |
|
|
|
$("itemDrawer").innerHTML = |
|
'<div class="drawer-head"><div><h2>' + esc(it.title) + '</h2><div class="tiny">' + esc(it.repo) + ' · ' + esc(it.type.toUpperCase()) + ' #' + it.number + ' · by ' + esc(it.author || "unknown") + '</div></div><button class="close-x" id="itemClose">✕</button></div>' + |
|
'<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:8px">' + meta.join("") + '</div>' + |
|
branchLine + assignLine + |
|
'<section>' + metrics + '</section>' + |
|
'<section><h3>Description</h3>' + bodyHtml + '</section>' + |
|
'<section><h3>Work on this</h3><div class="act-row"><button class="act-btn accent" data-act="create-session" data-mode="plan">🧭 Plan mode</button><button class="act-btn" data-act="create-session" data-mode="implement">⚡ Implement now</button>' + (isPr ? "" : '<button class="act-btn" data-act="create-session" data-mode="spec">📝 Refine spec</button><button class="act-btn accent" data-act="create-session" data-mode="cloud">☁ Assign to cloud session</button>') + '</div><div class="tiny" style="margin-top:6px">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.</div></section>' + |
|
'<section><h3>Actions</h3><div class="act-row">' + actions.join("") + '</div><div class="act-status" id="actStatus"></div>' + |
|
'<textarea class="comment-box" id="commentBox" placeholder="Write a comment…" style="margin-top:10px"></textarea>' + |
|
'<div class="act-row" style="margin-top:8px"><button class="act-btn primary" data-act="comment">Comment</button></div></section>' + |
|
'<section><h3>Recent comments (' + it.comments.length + ')</h3>' + commentsHtml + '</section>'; |
|
|
|
$("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", "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 = '<div class="skeleton"></div><div class="skeleton"></div>'; |
|
try { state.discover = await api("/api/available-repos" + (force ? "?force=1" : "")); } |
|
catch (e) { $("discoverGrid").innerHTML = '<div class="empty">' + esc(e.message) + '</div>'; } |
|
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) => '<div class="card tracked-row"><div><strong>' + esc(r.slug) + '</strong>' + (r.path ? ' <span class="badge">local</span>' : "") + '<div class="tiny">' + esc(r.status ? r.status.label : "") + ' · ' + r.openPrCount + ' PRs · ' + r.openIssueCount + ' issues</div></div><button class="btn" data-remove="' + esc(r.slug) + '">Remove</button></div>').join("") || '<div class="empty">No repos tracked yet.</div>'; |
|
$("trackedList").querySelectorAll("[data-remove]").forEach((el) => el.addEventListener("click", () => removeRepo(el.dataset.remove))); |
|
|
|
if (!state.discover) { if (!state.discoverLoading) $("discoverGrid").innerHTML = '<div class="empty">Click “Reload my repos”.</div>'; 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 '<article class="card discover-card"><div class="discover-head"><strong>' + esc(r.slug) + '</strong>' + |
|
(isTracked ? '<span class="pill tone-good"><span class="dot"></span>tracked</span>' : '<button class="btn primary" data-add-repo="' + payload + '">Track</button>') + '</div>' + |
|
'<div class="tiny">' + esc(r.description || "No description") + '</div>' + |
|
'<div class="tiny">' + (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")) + '</div></article>'; |
|
}).join("") || '<div class="empty">No repositories match.</div>'; |
|
$("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); |
|
`; |
|
} |